The five Swap cron jobs
dexConfirmationSweep, dexFeeSweepSettle, dexTokenRescreen, runDexMarketRefresh and dexQuotePrune — what each one carries, where it runs in a split deployment, and what stops if it does not.
Five scheduled jobs carry the whole of settlement, revenue crediting, token safety, market freshness and quote housekeeping. None of them announces itself when it is working, and only one of them fails in a way a user would report.
Find them at Admin → System → Cron (/admin/system/cron, permission
access.cron), under the category dex. Each row shows the job's title, its
period, its last run and its last error.
dexConfirmationSweep is the only thing that reads a receipt and advances a
swap. Without it a broadcast transaction confirms on the block explorer, the
user's funds move, and the platform's row stays PENDING forever — no fee
accrual is written, so revenue is not merely delayed, it is never recorded.
Check the cron console before you check anything else when swaps look stuck.
The five
Job (name) |
Console title | Period | What stops without it |
|---|---|---|---|
dexConfirmationSweep |
DEX Confirmation Sweep | 30s | Every swap stays PENDING. No fee accrues. No reorg is ever caught |
dexFeeSweepSettle |
DEX Fee Sweep Settlement | 5m | A sweep you recorded never becomes an adminProfit row. The money is on chain and off your books |
dexTokenRescreen |
DEX Token Re-screening | 24h | Screening verdicts freeze at whatever the last run found. A token that turns malicious keeps its old verdict |
runDexMarketRefresh |
Refresh DEX Market Data | 5m | dexPair price and liquidity columns go stale. Nothing else — it is read-only |
dexQuotePrune |
DEX Quote Pruning | 1h | dex_quote grows without bound. It takes a row on every quote, not every swap, so it is the fastest-growing table in the addon |
The first four return immediately when dexEnabled is off, so a "last run"
timestamp on a switched-off install is not evidence that anything happened.
dexQuotePrune is the exception: it reads only dexQuoteRetentionDays and has
no dexEnabled guard, so it keeps trimming the quote table on an install where
the DEX is switched off — which is what you want, since the rows are still there.
A job can be run by hand from the cron screen, or directly:
cronName — the job name in the table above.Under CRON_MODE=off the scheduler lives in a separate process, and this
process's job map is permanently empty — so the single-flight guard would find
nothing and the handler would run on the web loop beside the cron process's own
tick, against the same rows. The trigger route refuses with a 503, and the
refusal is terminal by design: the cron process registers no application routes
at all, so there is no admin endpoint there to POST to instead.
Check this first: the jobs are registered under the dex extension key
The cron registry keys addon jobs by extension name, and pushes a group only
when that extension is installed and enabled. All five sit in the dex
group, so the toggle that decides whether they exist is the DEX extension on
Admin → System → Extensions.
The practical consequence, on any install:
- The rows are on the cron console → they are registered and running on their period. Nothing to do.
- The rows are absent → they were never registered, and none of them is running. Enabling the DEX extension registers them without a restart — the scheduler re-evaluates extension gating every 60 seconds.
Earlier builds declared the four older jobs inside the forex_trading
group, so an operator who disabled Forex Trading stopped the confirmation sweep
and the fee settlement with it, and an operator who owned DEX alone never
started them. Both were silent. If you are working from an older runbook,
forex_trading is no longer the key to check here.
There is one important asymmetry, and it is about deployment shape rather than gating. The confirmation poller — the same sweep, armed as a 30-second timer instead of a cron job — arms on the web process of a cron split. So:
| Deployment | What drives the confirmation sweep |
|---|---|
Single process (CRON_MODE unset or inline) |
The cron job — and only the cron job |
Cron split, web half (CRON_MODE=off) |
The armed timer, under the dex-confirmations lease |
Cron split, cron half (CRON_MODE=only) |
Nothing — the job declines here on purpose |
Which means the standard production topology (PM2's backend at
CRON_MODE=off plus a cron app at CRON_MODE=only) keeps confirmations
running regardless. A single-process install does not.
dexConfirmationSweep — every 30 seconds
The only job that touches a user's transaction.
Each run selects up to dexSweepBatch swap rows (default 200), oldest
unchecked first, and reads their receipts. Two kinds of row are selected:
- everything in
PENDINGorMINED, and - every
CONFIRMEDrow whose reorg re-check has not yet been done, once it is at least a minute old.
It advances the state machine through the seven statuses — PENDING, MINED,
CONFIRMED, REVERTED, DROPPED, REPLACED, REORGED — and dexDropAfterMs
(default 30 minutes) is how long a transaction may go unseen in the mempool
before it is called DROPPED. All seven states and the eight machine reasons
behind them are in Swap history.
The reorg re-check runs exactly once per row, inside a window of
requiredConfirmations × 2 blocks, and reorgCheckedAt is what records that it
happened. That is why a chain's confirmation depth is not only a delay: it also
sizes the window in which a settled swap can still be taken back.
Every write is compare-and-set — WHERE id = ? AND status = <the status we read>
— so a second sweeper that somehow exists writes nothing rather than appending a
duplicate history entry. Correctness does not rest on the topology being right;
only the RPC bill does.
Why it declines on a dedicated cron process
On CRON_MODE=only this job returns { skipped: "web process holds the lease" }
and does nothing. That is not a failure and it is not a misconfiguration: the
web half of a cron split has already armed the same sweep under the
dex-confirmations engine lease, and running both would poll the same rows
twice against the same metered RPC endpoints.
The guard and the arming rule are deliberate mirror images, so exactly one process polls the chain whichever way the deployment is split. If you see that "skipped" result on the cron console of a split deployment, the sweep is running — on the other process.
It is one of three routes allowed to broadcast from cron
/api/dex/swap is on the websocket relay's route allow-list, so the status
frames this sweep publishes reach a user watching a swap modal even when the
sweep ran in a process holding no sockets. That entry exists for exactly this
job.
dexFeeSweepSettle — every 5 minutes
This is what turns a sweep you made into revenue on your books. The platform
signs nothing here. POST /api/admin/dex/revenue/sweep records a transfer you
made from your own fee-recipient address and marks the covered accruals
SWEEP_SUBMITTED; this job observes that transfer and credits.
It is a cron rather than part of the HTTP handler because confirmations take minutes — an admin request that waited for them would time out, and retrying it would be indistinguishable from a second sweep.
Each run reads up to 1,000 SWEEP_SUBMITTED accruals and groups them by
(chain, token, sweep transaction) — the granularity of the on-chain
movement, and therefore the granularity of the credit. Per group, in order:
-
Confirmed, not merely mined. The receipt must exist, have status 1, and be at least
requiredConfirmationsdeep on that chain. Otherwise the group is left pending for the next tick. -
Net out reversals. Negative accrual rows — the fee of a swap that was later reorged — are summed in. A fee that was never in the transfer must not be credited from it. A group that nets to zero or below is closed as
SWEPTwithcreditedAmount: 0, because leaving it submitted would keep it in every future tick forever. -
Refuse a currency the platform does not list. If the token's symbol is not a registered currency, the group becomes
UNRECOVERABLEwithsweepFailureReason: CURRENCY_NOT_REGISTEREDand a[CRITICAL]log — rather than minting a wallet in a currency nobody can spend. -
Apply the value floor.
dexSweepMinValueUsd(default $50) holds a group back until it is worth crediting. An unpriced row counts as above the floor, never below: withholding a fee because we could not price it would strand it indefinitely. -
Credit once. One
adminProfitrow of typeDEX_SWAPper real transfer, through an idempotency key derived from the on-chain hash — so a retry after a partial failure credits once.
The credit call returns null rather than throwing when it fails. A null must not
mark the group swept — that is the difference between "we credited it" and
"we stopped trying". Attempts are counted; the group stays SWEEP_SUBMITTED;
at five attempts it becomes UNRECOVERABLE with
sweepFailureReason: CREDIT_FAILED and a [CRITICAL] log naming the hash, the
chain and the amount.
An UNRECOVERABLE group means the money is at your recipient address on chain
and is not booked as revenue. It needs a person, not another tick.
A group whose total rounds to zero in the platform's currency precision is also
closed as UNRECOVERABLE, with sweepFailureReason: ROUNDS_TO_ZERO.
dexTokenRescreen — nightly
Re-runs the safety screen over the token catalogue. It does not re-screen everything: a large catalogue against a rate-limited vendor either gets throttled into failure or bills you for a full sweep every night.
Instead it rotates. dexScreeningSliceSize rows per run (default 50),
ordered oldest-checked first — MySQL sorts NULLs first on ascending order, so
never-screened outranks stale — so a catalogue of n tokens is fully covered
within ceil(n / slice) runs. At the default that is a hundred nights for a
five-thousand-token catalogue; raise the slice if that is too slow for your
listing policy.
The cursor is what stops a dead token starving the queue. Ordering alone is not
enough: a contract the provider can never answer for keeps its null
riskCheckedAt and would sit at the front of the ordering forever. The keyset
cursor advances past every row it visited, including the ones that failed,
so they are not retried until the next full cycle.
Two more properties worth knowing:
- It records verdicts only. It never changes a token's
listingor itsstatus. A token that turnsBLOCKEDovernight is reported, not delisted — the curation decision stays yours. - It is doubly gated, on
dexEnabledand ondexScreeningEnabled, and it resolves the screening provider once before reading any rows. A misspelt provider name is where the[CRITICAL]log for silent no-op screening lives.
The cursor lives in process memory, deliberately. Losing it on restart costs exactly one thing: the next pass starts from the oldest rows again, which is where a fresh pass should start.
runDexMarketRefresh — every 5 minutes
The only one that touches nothing important. It refreshes the
denormalised statistics columns on dexPair — lastPrice, change24h,
volume24hUsd and liquidityUsd — from the configured market-data provider, so
the market rail can sort and render without an indexer call per row.
It is read-only: it never broadcasts, never signs and never touches a wallet. A provider outage degrades the pair list's freshness and nothing else.
Rows are grouped per chain and each chain's chunks (25 pairs at a time) are attempted independently, so a provider with no data for Optimism costs Optimism's rows and nothing else. When a chunk fails, the run moves to the next chain rather than the next chunk — whatever refused one chunk will refuse the rest of that chain's, and each attempt is a metered call.
The websocket relay carries a frame from a cron process only for routes on its
allow-list, and /api/dex/market is not one of them — the list is
/api/admin/system/cron, /api/ecosystem/deposit and /api/dex/swap. A frame
published from a CRON_MODE=only process on any other route is dropped with
no error and no log.
So this sweep is the durable path and the websocket route's own per-symbol poller is the live one. Neither is a fallback for the other. In a single-process dev install the relay is bypassed entirely, which is why a broadcast added here would appear to work on a developer's machine and emit nothing in production.
The setting exists, defaults to 60,000 ms, and is described as the market
refresh cadence. The cron registration hard-codes five minutes, and nothing
in the codebase reads dexMarketRefreshMs to schedule anything.
Changing it has no effect. If the pair statistics are staler than you want, that is the number to ignore and the cron period to raise a request against.
dexQuotePrune — every hour
Housekeeping, and the only job here that exists to protect the disk rather than
the money. dex_quote takes a row on every quote request, not every swap,
and the terminal re-quotes on a timer while a user is simply looking at the
screen — so it outgrows the swap table by a wide margin.
- The cut is
dexQuoteRetentionDays, default 90. The job does have a "keep everything" branch at0, but you cannot reach it from the console: the settings validator floors this key at 1 and refuses 0 outright withdexQuoteRetentionDays must be at least 1, and the field itself carriesmin: 1. Treat the minimum as one day, not zero. - A quote a swap points at is kept whatever its age.
dexSwap.quoteIdreferences it and the trade history joins through it, so pruning one would blank a row for a swap that settled correctly. - Work is bounded — 1,000 rows a slice, at most 50 slices a run. A large backlog drains over several runs rather than holding row locks on the table the quote path is writing to.
- It never takes the worker down. A failure is logged under
DEXand the run returns; a successful pass logsPruned N quote row(s) older than D day(s).
Hourly rather than nightly on purpose: at this growth rate a daily job would let a day of quote rows accumulate before the first slice ran.
The pool indexer is not a cron job
The pool indexer — the service that turns a direct pool's own Swap logs into
1m candles for the chart cache — is not on the cron console and cannot be
triggered from it. It runs as a timer on the main thread of whichever process
wins the dex-pool-index engine lease, at max(30s, dexPoolRefreshMs)
(default 5 minutes).
It is not a cron job for the same reason the market refresh cannot broadcast: a
frame from a cron-only process on /api/dex/market is dropped silently. And it
is not a websocket module because every route module is loaded once per worker
thread, which would give one poller per thread hammering a shared RPC endpoint.
So: the main thread, behind a lease, invisible to the cron screen.
Three things follow for you:
- It only arms when
dexDirectPoolsEnabledis on. The switch is checked before the lease, so a disabled install does not hold one. - Its absence from the cron console is normal. Look for
Pool indexer armed at Nsin the boot log underDEX, or forPool indexer not armed: another process holds the dex-pool-index lease, which means it is running elsewhere. - A failure to start is non-fatal. Charts on direct pools degrade to whatever is already cached, which is a visible absence rather than a broken platform.
When one is failing
Each job's last error is on its row on the cron screen, and all five write to
the console log under DEX. None of them throws in a way that takes the others
down: one dead chain, one unlistable token or one bad pool is logged and
skipped, and the run continues.
The specific ones worth an alert:
[CRITICAL] ... CURRENCY_NOT_REGISTEREDorCREDIT_FAILED— money on chain, not on your books. See Fees and revenue.- A
dexConfirmationSweeprow whose last run is older than a couple of minutes on a single-process install — the scheduler is not running. - A market refresh that reports failures for one chain only — that chain's market-data provider coverage, not the cron.