Swap history and the execution state machine

The /admin/dex/swap ledger column by column, all seven execution states and eight status reasons, the drop rule, the reorg window and its ledger reversal, and the manual re-poll.

9 min readUpdated 6 August 2026admin, swaps, status, reorg, support

Admin → Swap → Activity → Swaps, at /admin/dex/swap. This is the durable record of every transaction a user broadcast through this addon, and it is the screen that answers every support ticket that starts "my swap…".

It is a ledger, not a control surface. Create, edit and delete are all off, and the table has no soft delete — a swap is an on-chain fact, so a row that could be edited would be an invitation to make your record disagree with the chain. The only write path into these rows is the confirmation poller reading receipts.

Reaching the screen needs access.dex.swap; the list route behind it (GET /api/admin/dex/swap) checks view.dex.swap. Rows are newest first and carry the user's name and email, so a ticket can be answered without leaving the page.

The columns

  1. Searchable — answer a ticket from this column
  2. One of seven states; the tooltip is the status reason
  3. SWAP, APPROVAL, WRAP or UNWRAP — one table, four kinds
  4. Rendered verbatim, never case-folded

The first block is visible by default; the rest open with the row.

Column Field What it tells you
User user.firstName / user.email Who signed it. Searchable.
Status status One of the seven states below. Filterable. The chip's tooltip is the statusReason.
Type kind SWAP, APPROVAL, WRAP or UNWRAP — one table, one poller, four kinds.
Chain ID chainId Numeric id from the chain registry. Filterable.
Transaction txHash Rendered verbatim, never case-folded — a Solana signature is base58 and a TON message hash is base64, so a fold would produce an id the explorer cannot find.
Sold sellAmountDisplay Display only. Lossy — the authoritative figure is the raw base-unit amount.
Bought buyAmountDisplay Display only, same caveat.
Value (USD) sellUsd USD snapshot taken at confirmation. Filterable — this is the column to sort on when you need the large trades.
Routed via aggregator The dexProvider.name copied off the quote, so the row survives a provider being switched off or removed. A direct route writes direct.
Broadcast createdAt When the row was recorded, not when it mined.

Expanded-only:

Column Field What it tells you
Venue venueKind AGGREGATOR or DIRECT_POOL. Every new consumer reads this rather than aggregator.
Sender fromAddress The sender as read from the chain, never as claimed by the browser. This is what ties an on-chain fact to a user account.
Confirmations confirmations Counted the ethers/viem way: the including block counts as one.
Block blockNumber Null while pending.
Network Fee gasCostUsd gasUsed × effectiveGasPrice, priced. Null on chains that do not charge that way — see below.
Fee (bps) feeBps Copied from the quote. Your current setting may have moved since.
Reason statusReason The machine slug for the current state. The eight values are enumerated below.

Only EVM charges gas × price. Solana charges a flat fee per signature plus a priority bid, TRON charges energy that a frozen-TRX stake can cover entirely, and TON charges total fees in nanotons. On those chains the poller records the total and leaves the per-unit price null, precisely so nothing multiplies two figures into a fee that was never charged.

The row also carries statusHistory — an append-only JSON array of {status, at, reason, blockNumber} capped at the last 24 entries — and statusChangedAt, replacedByTxHash, confirmedAt and reorgCheckedAt. There is no column for the history in this build; Reason shows only where the row is now.

The seven states

Status Meaning Terminal
PENDING Broadcast, not yet in a block. Nothing is wrong. No
MINED In a block, not yet past the chain's requiredConfirmations. No
CONFIRMED Past the confirmation depth. Final for the user. Yes (but see the reorg window)
REVERTED Included and failed. The user paid gas and got nothing. Yes
DROPPED Left the mempool without confirming; the nonce went elsewhere, or it was never seen. Yes
REPLACED The user's own wallet superseded it — a speed-up or a cancel. Yes
REORGED It was confirmed, and the chain then changed its mind. Yes

The four terminal failures need four different answers, and telling them apart at a glance is the whole reason they are not one red "failed" chip:

  • REVERTED cost the user money. REPLACED did not.
  • DROPPED is usually the user's own doing — they hit speed-up or cancel in their wallet. REORGED never is.
  • Only REORGED has a revenue consequence for you.

CONFIRMED is terminal for the user and not quite terminal for the poller: it is re-read exactly once, inside the reorg window, and then never again.

The eight status reasons

statusReason is a short machine slug, translated for display and never a raw vendor string. A ticket that says "dropped" is unanswerable; one that says nonce-consumed answers itself.

Reason Written with What it means to a support answer
mined MINED The receipt exists and succeeded, but the row has not reached the chain's confirmation depth yet. Tell the customer to wait.
confirmed CONFIRMED Receipt succeeded and the depth is met. Done.
reverted REVERTED The receipt came back with status 0. The trade did not happen; the gas did. On TON this also covers a non-zero compute or action exit code, which is the same thing.
nonce-consumed DROPPED We saw this transaction once, it is gone, and the sender's nonce has moved past it. Something else took that slot: a speed-up, a cancel, or a wallet-side replacement the browser never reported. Their funds are fine; the other transaction is the real one. This is also the reason a Solana row gets when its blockhash expires — expiry is mapped onto the nonce pair, so it lands here rather than on the age rule, and it is a hard fact rather than an inference.
not-seen DROPPED We have never seen this transaction on chain, and it is older than the drop window. Usually a broadcast that never propagated.
reorged REORGED The confirming block is no longer canonical, or the same height now carries a different block.
reorg-clear CONFIRMED The one-shot reorg re-check ran and found nothing wrong. The status did not move — this reason is the receipt for a check having been done.
reorg-recovered Declared but never written by any code path in this build. A reorged transaction that gets re-included arrives as a new row with its own hash, so no row ever climbs back. If you see it, it came from somewhere else.

How a row moves

The confirmation poller runs every 30 seconds, arbitrated so exactly one process in your deployment sweeps: it is gated by an engine lease, by isMainThread, and by whether cron has been split out. On top of that every write is compare-and-set on the status that was read, so a second sweeper that somehow exists writes nothing rather than double-posting a history entry.

One pass examines up to dexSweepBatch rows (default 200), oldest-checked first, and per row it makes at most three RPC calls:

  1. getTransactionReceipt. A receipt is the strongest evidence there is, so it is asked for first and alone. Every "it is gone" rule below runs only when there is no receipt at all.
  2. getTransaction, only when there was no receipt — "is it still in a mempool?"
  3. getTransactionCount(from, "latest"), only when it is not in a mempool either. That is the second half of the drop rule.

A chain whose endpoint is failing costs its own rows and nothing else: failures are counted per row into checkAttempts and back off exponentially from 30 seconds to a 15-minute cap, and a successful read clears the counter.

The drop rule needs two facts, not one

This is the rule most likely to be misread as a bug, so it is worth stating exactly. A pending row with no receipt becomes DROPPED in only two cases:

  • nonce-consumed — the row has a recorded nonce (meaning we saw the transaction at least once), the transaction is no longer visible, and the sender's current nonce is greater than the row's.
  • not-seen — the row has no recorded nonce, so there is nothing to reason about, and it is older than the drop window.

Anything else stays PENDING on purpose. A transaction we saw once, that is now absent, whose nonce is still free, can still be mined — telling that customer their swap is gone while it sits in another node's mempool is the failure this rule exists to avoid. Nonce alone is not enough either: the same nonce could have been consumed by this very transaction, in a block the node you are asking has not caught up to.

dexDropAfterMstype: numberdefault: 1800000
How long a submitted transaction is polled for before it is treated as dropped from the mempool.

It lives on Admin → Swap → Settings → Execution → Poller, labelled Drop After. Thirty minutes by default, adjustable between 1 minute and 24 hours. It is deliberately long, because it is the only evidence available for a transaction that was never seen.

On the non-EVM chains there is no account nonce to work with:

  • Solana maps blockhash expiry onto the nonce pair — past lastValidBlockHeight the transaction can never be included, which is a terminal fact rather than a guess. An expired Solana row is therefore dropped with nonce-consumed on the first sweep that finds no receipt, rather than waiting out dexDropAfterMs.
  • TRON and TON have no nonce at all (a reference block plus an expiry, and an external message expiry, respectively), so the rule degenerates to the time limit and dexDropAfterMs is what resolves them.

REPLACED can only ever come from the browser

There is no RPC call that answers "which transaction consumed nonce N for sender S". Answering that server-side means scanning blocks. viem's waitForTransactionReceipt({ onReplaced }) in the user's own browser is the only honest source, so the client reports the replacement when it records the new transaction.

Client-reported is not client-trusted. POST /api/dex/swap verifies the claim against the chain by (from, nonce) — a nonce is consumed exactly once, so a transaction sharing the sender and the nonce genuinely is the one that displaced the old row. A mismatch is refused with "that transaction does not replace the one you named".

Two consequences an operator will meet:

  • If the user closed the tab, you get DROPPED / nonce-consumed instead. That is not a defect and not a lost trade — it is the poller reaching the correct terminal state without the browser's help, and the replacement transaction is on chain either way.
  • A wallet "cancel" marks the old row REPLACED and writes no new swap row. A cancel broadcasts a zero-value self-transfer at the same nonce; it is not a trade, so putting a row in the customer's history for it would be a lie. The old row still becomes REPLACED, because (from, nonce) proved it.

Replacement is refused outright on Solana, TRON and TON — it is a nonce mechanic and those chains have no equivalent.

The reorg window, and the money

Every CONFIRMED row is re-verified exactly once, requiredConfirmations × 2 blocks past the block it confirmed in. Whatever that check finds, it stamps reorgCheckedAt, and that stamp is what stops the poller re-reading every confirmed swap this install has ever written.

The comparison is against the block hash recorded at confirmation, not the height. A reorg that replaces block N with a different block N is the ordinary case, so height alone would miss it. A row with no stored hash — an older row, or TRON, whose API does not return one — is never called reorged on missing evidence.

When a row does go REORGED, the status flip and the ledger correction happen inside one database transaction:

  • The swap moves to REORGED / reorged.
  • A negative reversal row is written into the fee accrual ledger: verification: REVERSAL, the amount negated, reversalOfId pointing at the original, priced at the original rate rather than today's. One reversal per accrual is enforced by a unique index, not by convention.

That negative row is what keeps un-done revenue out of your profit report. The fee settlement run sums a group's raw amounts including reversals; if the total comes out at zero or below, nothing is credited and the group is closed.

If the reversal lands on an accrual whose sweepStatus is already SWEPT, the money has been credited as platform profit for a swap that did not happen. The ledger is corrected, but your books and the chain now genuinely disagree, and nothing automatic can fix it — the credit already exists as a transaction.

The backend logs it as [CRITICAL] Swap <id> reorged AFTER its fee was swept … Reconcile by hand. Search your DEX logs for reorged AFTER before closing a month. Raising Confirmations on the chain in Admin → Swap → Chains is what makes this rarer; see Fees and revenue for the sweep itself.

The manual re-poll

POST/api/dex/swap/{id}/refresh
Re-checks one swap against the chain immediately instead of waiting for the 30-second sweep.

This is the customer's own "check now" button, and it is scoped to them: it 401s without a session and 403s on a swap belonging to another account. There is no admin equivalent in this build — an operator who needs a row moved on waits for the sweep, which will reach it.

Three behaviours worth knowing when a customer says the button "does nothing":

  • A terminal row is never re-polled. CONFIRMED, REVERTED, DROPPED and REPLACED come back unchanged with refreshed: false, reason: "terminal" — spending an RPC call to learn what the row already says would be waste.
  • There is a 10-second per-row cooldown. Inside it the response is refreshed: false, reason: "cooldown" with retryAfterSeconds, deliberately as a 200 rather than a 429: nothing failed, and a non-2xx would render in the UI as a failed refresh.
  • It bypasses the poller's backoff. That backoff protects the batch from a dead endpoint; refusing a person who pressed a button because an unrelated chain has been failing would be a button that does nothing.

It takes the same VM dispatch and the same decision function as the batch sweep, so the two can never disagree about what a receipt means.

Two sources, and neither is a fallback

The customer's activity panel in the trading terminal and this ledger are different surfaces over the same rows, and each has to stand alone:

  • The live one is a websocket stream on /api/dex/swap, per user, opening with a snapshot of their most recent 25 swaps and then pushing frames as the sweep moves rows. Without the snapshot a user who opens the panel would see nothing until something changed, which for a confirmed swap is never.
  • The durable one is this console and the REST history endpoint, straight off the database, across every user, filterable and paginated.

Do not diagnose from one when the question is about the other. If a customer's panel is stale, the frame was lost and their history is still right; if this ledger is stale, the poller is not running — check whether another process holds the dex-confirmations engine lease. See Requirements for what else to check when execution has stopped moving.