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.

13 min readUpdated 26 August 2026cron, settlement, revenue, screening, market-data

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:

POST/api/admin/system/cron/triggerpermission: manage.cron
Runs one job immediately. The body carries 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 PENDING or MINED, and
  • every CONFIRMED row 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:

  1. Confirmed, not merely mined. The receipt must exist, have status 1, and be at least requiredConfirmations deep on that chain. Otherwise the group is left pending for the next tick.

  2. 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 SWEPT with creditedAmount: 0, because leaving it submitted would keep it in every future tick forever.

  3. Refuse a currency the platform does not list. If the token's symbol is not a registered currency, the group becomes UNRECOVERABLE with sweepFailureReason: CURRENCY_NOT_REGISTERED and a [CRITICAL] log — rather than minting a wallet in a currency nobody can spend.

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

  5. Credit once. One adminProfit row of type DEX_SWAP per 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 listing or its status. A token that turns BLOCKED overnight is reported, not delisted — the curation decision stays yours.
  • It is doubly gated, on dexEnabled and on dexScreeningEnabled, 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 dexPairlastPrice, 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 at 0, but you cannot reach it from the console: the settings validator floors this key at 1 and refuses 0 outright with dexQuoteRetentionDays must be at least 1, and the field itself carries min: 1. Treat the minimum as one day, not zero.
  • A quote a swap points at is kept whatever its age. dexSwap.quoteId references 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 DEX and the run returns; a successful pass logs Pruned 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 dexDirectPoolsEnabled is 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 Ns in the boot log under DEX, or for Pool 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_REGISTERED or CREDIT_FAILED — money on chain, not on your books. See Fees and revenue.
  • A dexConfirmationSweep row 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.