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.
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
- Searchable — answer a ticket from this column
- One of seven states; the tooltip is the status reason
- SWAP, APPROVAL, WRAP or UNWRAP — one table, four kinds
- 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:
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.getTransaction, only when there was no receipt — "is it still in a mempool?"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.
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
lastValidBlockHeightthe transaction can never be included, which is a terminal fact rather than a guess. An expired Solana row is therefore dropped withnonce-consumedon the first sweep that finds no receipt, rather than waiting outdexDropAfterMs. - 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
dexDropAfterMsis 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-consumedinstead. 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,reversalOfIdpointing 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
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"withretryAfterSeconds, 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.