Trade execution¶
Status: Decided 2026-09-26. Reconciled 2026-09-27 with backend-platform.md, which wins where the two conflict. Built in Rollout step 4 (flows 9 to 13, with crash-point tests). The old backend has part of it; see Gap between this and the code.
Decision¶
A trade moves through three stages, each owned by a different module, joined by an event:
- Governance (
governancemodule). A member proposes a trade. Voters in the cabal vote. The tally marks the proposalpassed,failed, orexpired. - Trade engine (
tradingmodule). A passed proposal emits aproposal.passedevent. The trade engine consumes it, runs pre-trade checks (treasury has the funds, price is inside slippage tolerance, asset is tradable), and if every check passes, hands the trade to the swap layer. - Swap layer (
tradingmodule). The swap layer inserts a row in trading'sswapstable, gets a signed transaction from Jupiter, calls Jupiter/execute, and drives the row to exactly one terminal state through a single guarded update. Every path that can finish a swap (the/executeresponse, a Pending re-poll, a crash sweeper, an optional Helius webhook) goes through that same update, so a swap has one current state and a full history no matter which path finished it.
Modules never call each other's packages (dependency rules). Stages hand off through NATS JetStream. Each state change appends an events row inside the same uow.Do transaction (Unit of Work), and the relay publishes it. Side effects of a finished trade (the ledger, notifications, feed items, rankings, closing the proposal) are consumers of trade.confirmed / trade.failed. The swap path does not know which consumers exist.
Why¶
- Voting is the product; execution is plumbing. Splitting them keeps governance rules (voter sets, majority vs unanimous, expiry) free of Solana and Jupiter concerns. It also lets the trade engine serve agent intents through the same checks (flow 17).
- Money code must be idempotent. Jupiter
/executeblocks and normally returns a terminal result. But the process can die mid-call, the network can drop the response, and the same proposal can be delivered twice. A guarded update (WHERE status = 'submitted') plus a uniquetx_signaturemakes double-finishing impossible, regardless of which path wins. - Pre-trade checks belong at execution time, not proposal time. The pot value, treasury USDC, and price all move between proposal and pass. Checks at proposal time are a UX courtesy. Checks at execution time are the guarantee.
- Events over direct calls. A notification for a trade that later rolled back is wrong. A trade that confirms but never notifies because the push service was down is also wrong. Writing the event in the same transaction and relaying it gives at-least-once delivery with no lost or phantom events.
How it works¶
Stage 1: propose and vote¶
Full design in proposals.md. Relevant to execution:
- The
proposal.passedpayload carries everything the engine needs, so the engine never reads governance tables:proposal_id,cabal_id,kind(buyorsell),symbol,usdc_micros(money.Micros, buy) ortoken_amount(money.BaseUnits, sell),quote_out_amount,proposer_id. Amounts are integers (Money and types). - Proposal-time checks are advisory: a buy is refused up front if Jupiter cannot route it or if it exceeds the whole pot. The market module answers both through its query port (flow 9). The engine repeats them with fresher data.
- The tally runs in one
uow.Doafter every vote and on expiry. When status flips topassed, that same transaction appendsproposal.passed. The tally does not call the engine.
Stage 2: trade engine¶
A durable JetStream pull consumer in the trading module, filtered on proposal.passed and agent.intent_created, run through bus.Dispatch. One proposal or intent maps to at most one live execution attempt. The handler calls msg.InProgress() while a swap is in flight so the ack deadline does not expire during a 2-minute /execute. Its idempotency comes from its own claim, not from the single event_deliveries transaction; see event-bus.md.
Claim. In trading's own tables only. If event_deliveries already holds this event for trading, or a swaps row for the proposal exists in created, submitted, or confirmed, ack and stop (idempotent redelivery). A failed row does not block a retry. The partial unique index on swaps is what makes the claim safe under concurrent deliveries.
Pre-trade checks, in order. Each refusal is an errs code of kind Blocked:
| Check | Buy | Sell | Code |
|---|---|---|---|
| Asset tradable | Mint resolves in the market catalog; mint not paused (pre-IPO tokens can pause) | Same | AssetUntradable |
| Treasury funds | Treasury on-chain USDC ≥ usdc_micros plus fee headroom |
Treasury holds ≥ token_amount of the mint |
InsufficientFunds |
| Price within tolerance | Fresh quote; output amount ≥ quote_out_amount × (1 − slippage bps) |
Same, on USDC out | SlippageExceeded |
| Route exists | Jupiter returns a route for the exact-in amount | Same | Unroutable |
| Cabal not paused | Cabal not banned (cabal query port), and no active pause in funding's pause record, whether for an unresolved external deposit or for ops (funding query port) | Same | CabalPaused |
| Agent budget (intents only) | Intent fits the agent's remaining budget, re-read through the agents module's query port | Same | AgentBudgetExceeded |
Slippage tolerance is a cabal rule with a platform default (proposal: 100 bps default, cabal can tighten, platform caps at 300 bps). The engine reads it through the cabal module's query port. Treasury USDC is read from chain, not from the ledger, matching the platform-balance rule elsewhere. Every amount and bps value is an integer.
The pause is owned by funding and read at check time, so there is no window between detection and pause and no pause state in trading (deposits-withdrawals.md). Buy fee headroom is computed from the mint's transfer-fee config, not a fixed buffer or a guessed percentage. The relayer pays SOL fees, so headroom only covers token transfer fees. Pre-IPO transfer-fee handling is deferred (decided 2026-09-27). SubmitAgentIntent checks the agent budget at submit (flow 17); the engine checks it again here, because checks at execution time are the guarantee.
A refused check appends trade.blocked with the code and the numbers it compared, records the delivery, acks, and stops. Governance consumes trade.blocked, sets the proposal to execution_blocked and emits proposal.execution_blocked. The engine logs the decision once, with have and need (Logs as evidence). It never retries a refusal, InsufficientFunds and SlippageExceeded included, even when a fund-to-cabal sweep is in flight. Any voter can re-submit it as a new proposal.
An upstream failure while running the checks is not a refusal. Jupiter, Privy or RPC down returns an Unavailable code (JupiterUnavailable and the like), which is retryable, so bus.Dispatch naks with backoff and the checks run again on redelivery (Surfacing). After MaxDeliver the message goes to DEADLETTER.
Hand-off. If all checks pass, the engine calls the swap layer with the proposal id (or intent id) as the idempotency key.
Stage 3: swap layer¶
created ──► submitted ──► confirmed
│ │
│ └───────► failed
└────────────────────► failed (sweeper: never_submitted)
The status is a Go type with a transitions table and a pure Next(from, event). Adapters apply it as a guarded update, and exhaustive fails a missed case (State machine).
- Insert
created. Newswapsrow:proposal_idorintent_id,cabal_id,action, mints,amount,status = created. Quote Jupiter, sign with the treasury's Privy wallet (app-owned,PRIVY_AUTHORIZATION_*), and then store Jupiter'srequestIdand the signed transaction bytes and flip tosubmittedin one guarded write that commits before any send. The signed tx is persisted before the network call, so a crash can never lose track of a transaction that may have landed, and acreatedrow was never signed or sent. - Call
/execute. Jupiter lands the transaction and polls the chain itself. The response is normally terminal: Success→ guarded update toconfirmed, settx_signature,confirmed_at, fill amounts, cost basis.Failed→ guarded update tofailedwith Jupiter's error code.Pending→ re-send the same/executewith the storedrequestIdevery 2 s. Safe for up to 2 minutes; Jupiter dedupes onrequestId, so this never double-executes.- One
uow.Doper terminal transition. It does, atomically: UPDATE … SET status = $new, … WHERE id = $id AND status = 'submitted'. If rows affected = 0, another path already finished it: roll back and return.- Append
trade.confirmedortrade.failed. The payload carries the fill amounts as a snapshot for consumers. - Nothing else. Consumers do the rest (flow 11):
treasurywrites thecabal_txnsheader and ledger entries in the same transaction as its own event,governancesets the proposal toexecutedand emitsproposal.executed, andranking,feedandnotifyreact. - Sweeper. A poller in the worker (every 30 s, under the poller advisory lock from Deploy rule 1). It moves
createdrows older than 2 minutes tofailedwithnever_submitted, with no on-chain lookup. That is safe because the signed bytes and thesubmittedstatus commit together before any send, so acreatedrow was never sent. Failing it frees the claim for a retry. It also selects rows insubmittedolder than 2 minutes. It callsgetSignatureStatuseson the signature derived from the stored bytes and resolves through the same guarded update. Finalized →confirmed. Not found after blockhash expiry →failedwithblockhash_expired. Still processing → leave for the next tick. This is the crash-recovery path and the only path that can finish a row the original process abandoned. There is no proposal poller behind it: aproposal.passedthat no handler finished lands inDEADLETTER, andmonacoctl deadletter retryis the one recovery path (flow 27). - Optional Helius webhook. An address webhook on treasury wallets posts confirmed transactions. The handler looks up the row by
tx_signatureand runs the same guarded update. It sometimes wins the race with/execute, which is fine: the loser sees zero rows affected and stops. Its purpose is faster UI, not correctness. Not required for launch.
Invariants
tx_signatureis unique.execute_request_idis unique.- At most one
swapsrow per proposal (or intent) in a non-failedstate: a partial unique index on the source idWHERE status <> 'failed'. - Status only moves forward. No path writes
submittedback tocreated, orconfirmedto anything. Only the sweeper movescreatedtofailed. - The
eventstable is append-only and is the audit log. The swap status is a projection of the latest event. eventsrows are written only inside the same transaction as the state change they announce. Nothing publishes to NATS except the relay.
Crash points. Flows 11 and 12 list these in flows.tsv, each with a crash-point test that restarts and asserts convergence:
| Crash point | What survives | What converges it |
|---|---|---|
after-insert |
created row, nothing signed or sent |
Sweeper: older than 2 minutes → failed with never_submitted; retry allowed |
after-sign |
submitted row with signed bytes, never sent |
Sweeper: not found after blockhash expiry → failed |
after-execute |
submitted row; the swap may have landed |
Sweeper: finalized → confirmed |
before-commit |
Terminal transition rolled back; row still submitted |
Sweeper, same as above |
after-publish |
Event published, published_at not set |
Relay republishes; JetStream drops it on Nats-Msg-Id, and event_deliveries drops any second delivery |
Delivery¶
Relay, streams (EVENTS, DEADLETTER), consumer settings, retries and dedupe on event_deliveries are in event-bus.md and, where they differ, NATS hosting and budget. Trade execution is the first domain on the bus.
Retry and manual paths¶
RetryTrade(flow 12) re-runs the engine for the proposal: re-check, then a new swap row. Allowed only when the latestswapsrow isfailed. It takes anIdempotency-Keylike every mutating call (Thin client). The trade parameters come from the failedswapsrow, not from governance tables. The route isPOST /v1/swaps/{id}/retry(flow 12 in the flows table).- An ops command to force-resolve a stuck
submittedrow by signature, going through the same guarded update. It runs frommonacoctlor the admin module (flow 26). Useful when Jupiter and RPC disagree.
Alternatives considered¶
| Alternative | Why not |
|---|---|
| Execute inline in the vote handler when the tally passes | Ties a user's HTTP request to a 2-minute swap. Crash mid-request loses the trade. |
| A proposal poller as the trigger or as a safety net (the old backend's 15 s poller) | A scan, not an event: 15 s latency on every trade, and no recorded reason for why execution did not happen. As a safety net it would be a second recovery path next to DEADLETTER and monacoctl deadletter retry. Dropped (default 2026-09-27). |
| Direct calls for notifications and feed from the swap code | Loses events when a downstream service is down, or fires them for trades that then roll back. Also crosses module walls, which depguard forbids. |
| Trading writes the ledger in the swap transaction | cabal_txns belongs to the treasury module (repository layout). Trading owns swaps; treasury writes cabal_txns as a consumer of trade.confirmed. |
| Pause state inside trading, fed by an event | Leaves a window between detection and the consumer running, and treasury would need its own copy. Funding owns the pause and both read it at check time. |
| Slippage measured against the quote at pass time | Fresher, but not what voters saw. The reference is quote_out_amount, the quote at proposal creation, shown in the proposal UI. |
Status polling on chain instead of Jupiter /execute |
Jupiter already does this and handles priority fees and rebroadcast. We use the chain read only as the crash fallback. |
| Pre-trade checks at proposal time only | Pot and price move during voting. Checking again at execute is the only guarantee. |
| Auto-retry blocked proposals (e.g. slippage) | Rejected for now. A cabal voted on a specific price expectation; silently retrying later trades on a price they did not approve. Voters re-propose instead. Upstream outages are retried by the bus, because they are Unavailable, not Blocked. |
| Custom on-chain program for governance and execution | No custody or trust benefit at our scale. Privy plus Postgres already gives one signer and one ledger. Large cost, more surface, slower iteration. |
Gap between this and the code¶
The rewrite replaces the old backend rather than refactoring it (backend-platform.md). This list records what the old backend does today (checked 2026-09-27 in apps/backend), so behavior is known during cutover:
- Propose, vote, tally,
passedstatus:internal/app/governance.go. - Proposal execute poller every 15 s;
ExecuteOnPassServiceruns buy or sell viaSwapService:internal/app/start_buy.go,internal/worker/proposal_execute_poller.go. SwapServicequotes, signs, submits, and awaits fill through theswapprovider.Providerinterface (Jupiter or Flash):internal/app/swap.go,internal/swapprovider/provider.go.transactionsstatuses:pending,confirmed,failed. Idempotent ontx_signatureandexecute_request_id.- Proposal-time checks (route exists, amount ≤ pot).
POST /v1/transactions/{id}/retry.
What the old backend lacks and the rewrite builds in Rollout step 4: created and submitted statuses with signed bytes and requestId stored before the network call; the guarded update as the only terminal-transition primitive; the trade engine with the check table and trade.blocked; the swaps table; the sweeper; slippage tolerance as a cabal rule. The Helius webhook comes later. Events, relay and bus.Dispatch land first, in step 2.
Open questions¶
None.
Deferred. Pre-IPO transfer-fee handling (decided 2026-09-27).
Log¶
- 2026-09-29:
RetryTraderoute isPOST /v1/swaps/{id}/retry(default; see #535). - 2026-09-27: Decided 2026-09-27: pre-IPO transfer-fee handling is deferred. The headroom rule stays as written, because it reads any mint's transfer-fee config. No checks remain.
- 2026-09-27: Default 2026-09-27 (reversible): A17 restated. The sweeper fails
createdrows older than 2 minutes withnever_submitted, with no on-chain lookup. It is safe because the signed bytes,requestIdand thesubmittedstatus commit in one guarded write before any send, so acreatedrow was never signed or sent.submittedrows keep thegetSignatureStatusespath. A chain-check variant was drafted and withdrawn the same day. - 2026-09-27: Applied the 2026-09-27 decisions. Default 2026-09-27 (reversible): trading owns a
swapstable and treasury writescabal_txnsas a consumer oftrade.confirmed, in the same transaction as its own event; the pause is owned byfundingand read through its query port at check time, ops pauses included; governance emitsproposal.executedandproposal.execution_blocked; the sweeper failscreatedrows older than 2 minutes (newafter-insertcrash point); the 15 s safety-net poller is dropped in favor ofDEADLETTERplusmonacoctl deadletter retry; the engine re-checks agent budget;InsufficientFundsnever auto-retries; slippage is measured against the proposal-time quote; fee headroom comes from the mint's transfer-fee config. Open: verify pre-IPO mints expose that config. - 2026-09-27: Reconciled with backend-platform.md. Stages named by module (
governance,trading); the claim reads only trading's tables and the engine reads trade parameters from the event payload. Pre-trade failure codes becomeerrscodes of kindBlocked(group_frozenrenamedCabalPaused); upstream failures are retryableUnavailablecodes the bus naks.treasurywrites the ledger as a consumer oftrade.confirmed, not in the swap transaction. Agent intents use the same engine (flow 17). Crash-point table for flows 11 and 12,uow.Do, state-machine pattern, sweeper under the poller lock,RetryTradecommand,cabal_idnaming, Rollout step 4. New open questions: stuckcreatedrows, pause lag, safety-net poller, agent budget re-check, swap table name. - 2026-09-26: Outbox worker replaced by the NATS event bus (event-bus.md). Swap transitions write only an
eventsrow; side effects are consumers. - 2026-09-26: Pre-trade check also blocks cabals paused for an external deposit (deposits-withdrawals.md).
- 2026-09-26: Stage 1 detail moved to proposals.md.
- 2026-09-26: Initial decision recorded from design discussion. Swap-layer idempotency design (guarded update, requestId re-poll, sweeper, optional Helius) carried over from prior chat; governance → engine → swap staging and pre-trade check table added.