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.

11 min readUpdated 6 August 2026admin, quotes, compliance, evidence, disputes

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

  1. Did we produce a price at all
  2. What became of the price we produced
  3. Which provider won the route
  4. 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.

outcomedid 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.

statuswhat 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/quote writes outcome: "OPEN" alongside status: "OPEN".
  • POST /api/dex/swap writes outcome: "USED" alongside status: "USED" when a quote is spent.
  • No code path writes NO_ROUTE, PROVIDER_ERROR, RATE_LIMITED or TIMEOUT, and neither OPEN nor USED is one of the five values the column's own validator lists.
  • Nothing writes EXPIRED or CANCELLED either — a quote that lapses is simply past its expiresAt.

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 400NO_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_LIQUIDITY is a depth refusal and still answers 502.
  • QUOTER_FAILED is 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:

dexEnforceRouterAllowlisttype: booleandefault: true
Refuse a quote whose router or approval spender is not on the allowlist.

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.

dexRequireWalletLinktype: booleandefault: true
Require a signed message proving wallet ownership before building a swap.

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

dexQuoteDedupMstype: numberdefault: 3000
Window in which an identical quote request is served from the last answer.
dexQuoteTtlSecondstype: numberdefault: 60
How long a quote stays valid before the terminal refetches.

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 dexQuoteDedupMs above dexQuoteTtlSeconds does nothing useful — the freshness check also refuses an expired row, so the shorter of the two wins.

Retention

dexQuoteRetentionDaystype: numberdefault: 90
How long quote records are kept before pruning.

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

  1. Find the user — search their email in the User column.

  2. Read outcome and status together. 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.

  3. Open the row. takerVerified answers "was this their wallet", slippageBps answers "what tolerance was actually used", and priceImpactBps answers "was this a thin market".

  4. 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.

  5. Read complianceSnapshot for 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.

  6. 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.