The quote log
The /admin/dex/quote compliance record column by column — the outcome and status vocabularies, the compliance snapshot, the calldata binding, wallet verification, dedup and retention.
Admin → Swap → Activity → Quotes, at /admin/dex/quote. Reaching the screen
needs access.dex.quote; the list route behind it (GET /api/admin/dex/quote)
checks view.dex.quote.
Why this table exists at all
The platform never holds the funds. Everything after
POST /api/dex/quote is signed by the user and broadcast to a public RPC —
uninspectable, unstoppable, irreversible. So the quote row is the only
server-side evidence of what was offered, to whom, under which jurisdiction and
under which token policy.
That makes it the compliance artefact, and it is treated as one:
- Create, edit and delete are all off. The row's whole value is that it says what you said at the time.
- The model is
paranoid: false— no soft delete. A soft-deleted compliance log is a bug waiting to be argued about in a dispute. - Rows are newest first, joined to the user's name and email, so a ticket can be answered without leaving the page.
Swap history is the record of trades that happened. This is the record of the ones that did not.
The columns
- Did we produce a price at all
- What became of the price we produced
- Which provider won the route
- The tolerance actually used, after the clamp
Visible by default:
| Column | Field | What it tells you |
|---|---|---|
| User | user.firstName / user.email |
Who asked. Searchable. |
| Result | outcome |
Did we produce a price at all. Filterable. |
| Status | status |
What became of the price we produced. Filterable. |
| Chain ID | chainId |
Numeric id from the chain registry. Filterable. |
| Routed through | aggregator |
The dexProvider.name that won. A direct-pool route writes direct. |
| Value (USD) | sellUsd |
USD snapshot at quote time. Filterable — sort on this for the large asks. |
| Slippage | slippageBps |
The value actually used, after the clamp. |
| Price impact | priceImpactBps |
As reported by the venue. Null on a vendor that does not report it. |
| Served at | createdAt |
When the quote was produced. |
Open the row for the rest:
| Column | Field | What it tells you |
|---|---|---|
| Taker verified | takerVerified |
Whether the address was a verified wallet link at quote time. |
| Taker | takerAddress |
The address that would sign. Searchable and filterable. |
| Router | router |
The contract to the user is asked to sign against. |
| Binding key | calldataHash |
The hash the execute path verifies against. Searchable. |
| Fee (bps) | feeBps |
What was stamped on this quote. Your current setting may have moved since. |
| Expires | expiresAt |
createdAt + dexQuoteTtlSeconds. |
calldata itself is excluded by the route, and it is the only field that is.
It is LONGTEXT holding a multi-kilobyte payload per quote; a page of fifty
would be megabytes on the wire to render a column nobody can read. The exclusion
is written as a denylist rather than an allowlist so a new column an audit needs
appears here by default.
The row carries more than the console draws — venueKind, venueName, poolId,
allowanceTarget, feeRecipient, feeSide, estimatedFeeAmountRaw,
estimatedGas, latencyMs, vm, quoteKey, ipAddress, userAgent,
complianceSnapshot and routeSummary all come back on the API response.
outcome and status are two different questions
They are next to each other and they are easy to conflate.
outcome — did we produce a price at all. The column permits exactly five
values:
| Value | Means |
|---|---|
OK |
A route was found and a price was served. |
NO_ROUTE |
No venue could price this pair at this size. |
PROVIDER_ERROR |
A vendor answered with a failure. |
RATE_LIMITED |
A vendor refused on quota. |
TIMEOUT |
A vendor did not answer inside dexQuoteTimeoutMs. |
status — what became of the price we produced. The column permits OPEN,
USED, EXPIRED and CANCELLED.
Persisting the failures is the design intent, and it is the reason the table
exists rather than a swap-only log: "I could never get a price" is answerable
from outcome and from nothing else.
Verify this on your own install before you build a report or a support workflow on the Result filter. In the code as shipped:
POST /api/dex/quotewritesoutcome: "OPEN"alongsidestatus: "OPEN".POST /api/dex/swapwritesoutcome: "USED"alongsidestatus: "USED"when a quote is spent.- No code path writes
NO_ROUTE,PROVIDER_ERROR,RATE_LIMITEDorTIMEOUT, and neitherOPENnorUSEDis one of the five values the column's own validator lists. - Nothing writes
EXPIREDorCANCELLEDeither — a quote that lapses is simply past itsexpiresAt.
So a refused quote reaches the user as a 4xx or 502 response and a DEX log
line, not as a row you can filter for. Until that write path is corrected, treat
"which users could not get a price" as a log question rather than a console one,
and use expiresAt against createdAt rather than status to reason about
lapsed quotes.
The refusal vocabulary that does exist today lives in the venue layer and
reaches the user as an HTTP status: NO_ROUTE, INSUFFICIENT_LIQUIDITY,
POOL_TOO_SHALLOW, POOL_NOT_FOUND, DIRECT_POOL_REFUSED, UNSUPPORTED_CHAIN,
UNSUPPORTED_TOKEN, BAD_REQUEST, AUTH, RATE_LIMIT, NETWORK, TIMEOUT,
QUOTER_FAILED and UNKNOWN.
Exactly five of them are treated as the caller's own input and answer 400 —
NO_ROUTE, UNSUPPORTED_TOKEN, POOL_NOT_FOUND, POOL_TOO_SHALLOW and
DIRECT_POOL_REFUSED. Everything else answers 502, and two of those are
worth knowing because they read the other way round:
INSUFFICIENT_LIQUIDITYis a depth refusal and still answers 502.QUOTER_FAILEDis deliberately excluded and stays 502 — an RPC that will not simulate is our infrastructure, and a 400 would tell the user to fix something they cannot.
complianceSnapshot — the policy as it was, not as it is
A TEXT column holding JSON, written once at quote time and never updated. It is
a snapshot, never a join, and that is the whole point: settings, the geo list
and the token allowlist will all have moved by the time anyone reads the row, so
resolving them at read time would answer a different question from the one that
was asked.
What is recorded:
| Key | Records |
|---|---|
kycRequired |
Whether dexKycRequired was on. |
walletLinkRequired / takerVerified |
Whether wallet ownership was demanded, and whether it was proved. |
sellTokenRisk / buyTokenRisk |
Each leg's level and flags as the screener had them then. |
allowlistMode |
The dexAllowlistMode in force. |
notionalCheck / notionalUsd |
Either checked with the figure, or price-unavailable — an unpriceable token allows and says so. |
slippageRequested / slippageClamped / slippageVenueClamped |
What the user asked for, whether the operator band moved it, and whether the direct-pool ceiling moved it further. |
selectionBasis / considered |
How the winner was scored, and every candidate that answered with its buyAmount and net. |
venueKind / venueName / poolAddress / poolFeeTier |
Which venue filled it. |
venueRace / venueReason |
Both net figures, the margin, the required margin, and the machine-readable reason the winner won. |
poolState |
On a direct route, the pool's reserves at quote time — the entire justification for the price offered, and different one block later. |
ipAddress / userAgent / at |
The request's own provenance. |
Why it matters in a dispute. A customer arguing that they were shown a price
they should not have been, or that a token was flagged when they traded it, is
arguing about a state of the world that no longer exists. The snapshot is the
only thing that can answer them, and the geo half of it derives from the
ipAddress on the same row.
The customer-facing serialiser strips complianceSnapshot, calldata,
quoteKey, ipAddress and userAgent from any quote joined onto a swap, and
drops routeSummary.raw — which can carry upstream URLs and vendor identifiers.
This admin route applies no such strip, so the console is the one place the whole
record is visible.
routeSummary
Declared as the normalised hop list, with the untouched vendor payload
preserved under .raw, stored as JSON in a TEXT column with a
parse-on-read getter.
In this build no write path populates it — the route narrative that is
persisted lives in complianceSnapshot under considered, venueRace,
venueReason and selectionBasis. Read those when you need "why that venue",
and expect routeSummary to be null on rows from this version.
calldataHash and router — the binding that proves what was signed
These two answer different halves of one question.
calldataHash is the binding key: a hash of the exact payload served.
keccak256 over EVM calldata bytes, sha256 over the stored string on every
other VM, always rendered as 0x plus 64 hex so the column has one shape
everywhere. (chainId, calldataHash) is a unique index — two rows sharing a
hash on one chain would make "which quote does this transaction belong to"
ambiguous, and the loser would attach its compliance snapshot to the wrong trade.
When the user records a transaction, the execute path looks the quote up by that
hash. A quote with a null calldataHash was never executable — it was served
while dexQuoteOnly was on — so a transaction claiming to be it is refused
rather than compared against nothing.
router is the contract the user's wallet was asked to sign against, and
allowanceTarget is the spender of the approval, which is often not the same
address. Both arrive in the aggregator's own HTTP response and go straight to the
wallet, so they are checked before the row is written:
A quote that fails the allowlist is refused with 502 and never persisted — persisting is what turns calldata into an artefact the record route will later accept. What you see instead is a DEX log line:
Quote refused: ROUTER_NOT_ALLOWLISTED { chainId, aggregator, address }The reason is either ROUTER_NOT_ALLOWLISTED or the spender equivalent. The
check is also told which class of venue it expected: an aggregator quote
whose to was tampered to a bare AMM router — which is on the list, because
direct pools put it there — would otherwise sail through, and the user would sign
a single-pool swap with none of the aggregator's route splitting or minimum-out
protection.
If quoting suddenly stops on one vendor because it rotated a router, leaving the
switch off is still the wrong answer. The extension point is
dexChain.metadata.extraRouters[] — entries of {router, kind, allowanceTargets, aggregator, label}, merged in behind the static list, kind
required, a static entry always winning a collision.
There is no Extra routers field on Chains and no route that sets one. The
Chains edit form carries six fields — status, required confirmations, fee
recipient, the zero-fee acknowledgement and the two RPC URLs — and
PUT /api/admin/dex/chain/{id} declares and applies only those same six. Adding
a rotated address today means writing the chain row's metadata column directly,
which is not audited; the allowlist is rebuilt from the row on every check,
so at least there is no cache to clear afterwards. See Chains.
takerVerified and takerAddress
takerAddress is the address that would sign. takerVerified records whether
that address was a proved wallet link for that user at the moment of the
quote — a providerUser row with provider WALLET, which is what Sign-In With
Ethereum writes.
With the setting on, an unverified taker is refused with 403 and no row is
written. With it off, the quote is served and takerVerified records false —
which is the state you want on the row, because it is the honest answer.
The value is stored rather than joined, on purpose: a user who verifies their
wallet next week must not retroactively make last week's quote look verified.
A missing or unreadable link table resolves to false rather than to a bound
quote.
Dedup: why the log is not one row per keystroke
The terminal debounces its amount field at 350 ms. Without dedup, one user settling on an amount writes dozens of compliance rows for a single intent.
A request is reduced to a quoteKey — a sha256 over the chain, both token
addresses, the sell amount, the taker, the clamped slippage, the fee bps, the fee
recipient and any pinned aggregator. If the same user has an OPEN row with that
key, created inside dexQuoteDedupMs and not yet past its expiresAt, that
row is returned verbatim and nothing new is written.
A deduplicated response is the same quote, and every field that decides what the
user signs says so — including allowanceTarget, which is read off the row
rather than defaulted. It is flagged deduplicated: true in the response.
dexQuoteTtlSeconds is the separate question of how long the served price stays
valid. expiresAt is createdAt plus that value, and the terminal refetches on
expiry.
Two operator consequences:
- A user complaining about "dozens of quotes" is describing the window working. One row per intent inside three seconds is the design.
- Raising
dexQuoteDedupMsabovedexQuoteTtlSecondsdoes nothing useful — the freshness check also refuses an expired row, so the shorter of the two wins.
Retention
Set on Admin → Swap → Settings → Quotes, minimum 1 day. Quotes are pruned on their own schedule and separately from swaps — a swap row is an on-chain fact and is never pruned, while a quote is a decision record with a finite useful life.
The cron registry ships four DEX jobs — runDexMarketRefresh,
dexConfirmationSweep, dexFeeSweepSettle and dexTokenRescreen — and none of
them reads dexQuoteRetentionDays or deletes from dex_quote. The setting is
stored and validated; nothing acts on it yet.
Plan for the table to grow without bound on a busy install, and treat the retention figure as a policy statement rather than an enforced one until you have confirmed a prune job exists on your version. If your own retention policy matters legally, that is the gap to close first.
Reading a ticket from this screen
-
Find the user — search their email in the User column.
-
Read
outcomeandstatustogether. A row exists at all means a price was produced; see the warning above for what the two columns can and cannot tell you in this build. -
Open the row.
takerVerifiedanswers "was this their wallet",slippageBpsanswers "what tolerance was actually used", andpriceImpactBpsanswers "was this a thin market". -
Compare the swap's transaction against
calldataHash. That is the proof the transaction they signed is the one you served — it is what the record route already checked, and it is the answer to a "you sent me somewhere else" claim. -
Read
complianceSnapshotfor anything about policy: which risk level each token carried, whether KYC was required, what the venue race decided, and — on a direct route — the pool's reserves at the moment of the quote. -
If there is no row at all, the request never reached persistence. It was refused at one of the gates before the venue call — geo, KYC, chain, token allowlist, notional ceiling or wallet link — or by the router allowlist afterwards. Those are log lines, and Troubleshooting walks them in order.