- In helpers/token-pairs.json, helpers/pools.json, and helpers/intermediate-tokens.json, the list between test and production is separated.
yarn
yarn testyarn testIn 1st terminal session
# start local node
yarn hardhat node In 2nd terminal session
# deploy base contracts
yarn hardhat run scripts/deploy-ul-base.ts --network localhost# deploy dex
yarn hardhat run scripts/deploy-dex.ts --network localhost
# input the following parameters
✔ Which dex do you want to deploy? (Ex: UniV3Dex, the contract name) …
✔ Which name do you want to represent the dex? (Ex: uniV3) … # set fees
yarn hardhat run scripts/set-fees.ts --network localhost# set pools
yarn hardhat run scripts/set-pools.ts --network localhost# set token pairs
yarn hardhat run scripts/set-paths.ts --network localhostThe UniversalLiquidatorRegistry emits no events and its paths mapping has no
enumerator, so the configured routes cannot be listed from the chain directly and
an explorer's transaction history is not a reliable substitute. helpers/registry.json
is the checked-in record of what the registry is supposed to contain; the audit
script diffs it against what is actually deployed.
yarn registry:audit # 1. is the chain what the manifest says?
yarn registry:routes # 2. find better routes -> helpers/proposals.json
# 3. review that file
yarn registry:apply # 4. dry run: see the transactions
APPLY_EXECUTE=1 yarn registry:apply # 5. send them (updates the manifest)
yarn registry:audit # 6. confirmregistry:seed is only needed when paths were changed on chain outside this
tooling — it rebuilds the manifest from what is deployed. registry:sync is the
opposite direction, for when the manifest is edited by hand and the chain has to
catch up.
Set REGISTRY_RPC_URL first. The endpoint hardhat.config.ts uses for
mainnet rate-limits hard enough that the quoting passes take tens of minutes;
every script here honours the override and the scripts say so if you forget.
REGISTRY_RPC_URL=https://base-rpc.publicnode.com yarn registry:routesNumbers move between runs because prices do. The set of proposals should be
stable; individual percentages will not be, which is why registry:apply
re-quotes before it sends anything.
# refresh the manifest from chain state
yarn registry:seed
# diff the manifest against the chain and health-check every hop
yarn registry:auditBoth accept REGISTRY_RPC_URL to override the RPC (the default Base endpoint
rate-limits aggressively). Reads are batched through Multicall3, so a full audit
is a few dozen eth_calls.
Paths are discovered by probing every ordered pair of the tokens the manifest already knows about, so a token that appears in no existing path is invisible. Extend the universe with either:
SEED_TOKENS=<file>— a JSON array of addresses to addSEED_FROM_BLOCK=<block>— scanUniversalLiquidator.Swaplogs for token pairs
New dexes are written with "kind": "unknown"; set the kind (and poolFactory /
router / vault) by hand or the audit cannot check that dex's hops.
Errors (exit code 1):
UniversalLiquidator.pathRegistryand registry owner match the manifest- every dex resolves to the manifest address, and none resolves to
address(0) - intermediate tokens match in order —
getPathreturns the first match, so order decides routing - every manifest path exists on chain with the same dex and the same token array, and no path exists on chain that the manifest does not know about
- every hop resolves to a pool that is actually deployed
- no concentrated-liquidity hop sits on a pool with zero active liquidity. Such a
pool can hold plenty of both tokens while every position is out of range, and
it reverts on any swap — which is a
doHardWorkthat reverts, not a bad price
Warnings (exit code 1 only with AUDIT_STRICT=1):
- a hop's pool holds less of a token than the
minLiquidityfloor for it - a pair has no reverse path
- a UniV3 hop uses fee 500, which is indistinguishable from never having been set
Some findings are real, will not be fixed, and should not keep a daily check
red. The registry has no removePath, so a path registered on chain can only be
repointed, never withdrawn — and if there is nothing correct to repoint it at,
the audit will report it forever.
An accepted list in the manifest declares those:
"accepted": [
{
"group": "hops",
"contains": "hop0 vAMM-VIRTUAL/cbBTC/cbBTC: resolved pool 0x14dde… has no code",
"reason": "an LP token is burned for its underlying, not swapped through a pair",
"since": "2026-09-22"
}
]A finding is excused when its group matches and its message contains the
contains string, so each entry silences one known thing rather than a class of
them — a different failure on the same path still shows up as an error. Excused
findings are still printed, under ACCEPTED, with the reason and the date, so
the decision stays visible rather than disappearing. They do not count toward
the exit code.
reason is required, because a silence with no reason rots.
An entry can also be scoped to a token rather than a message, for a token that cannot be liquidated at all:
{
"sellToken": "0x2da17faf782ae884faf7db2208bbc66b6e085c22",
"reason": "ZERO's only venue does not quote, and the paths cannot be withdrawn",
"since": "2026-09-22"
}That excuses every finding about a path selling it. Naming each path separately
would say the same thing six times over and go stale the moment a seventh is
added, and a token with no venue has nothing worth reporting about any path
that sells it. And an entry that
stops matching anything is reported as stale-accepted, so once the underlying
thing is fixed or removed, the suppression gets taken out with it.
yarn registry:sync diffs the manifest against the chain and prints the
transactions that would close the gap — addDex, changeDexAddress,
setIntermediateToken, setPath — each with its target and calldata, ordered so
that dexes are registered before any path references them.
yarn registry:sync # dry run, prints calldata
SYNC_EXECUTE=1 yarn registry:sync # send them (signer must be the registry owner)Paths that exist on chain but not in the manifest are reported and cannot be
fixed: the registry has no removePath and setPath rejects arrays shorter than
two, so such a path can only be repointed or adopted into the manifest.
Aerodrome's successor deployment ("Aero", audited Jun–Sep 2026, with an
AerodromeMigration contract moving liquidity across) changes shape in ways
that matter here:
- stable and volatile pools come from separate factories, so a pair is
configured by naming its factory rather than a
stableflag; - the
Metaroutertakes encoded commands, not a route, soAeroDexswaps against the pools directly: it moves the input into the pool and the pool pays out. The output is sized withgetAmountOutWithTotalFee, which charges the fee the caller would actually pay; - Slipstream keeps its tick-spacing-packed path, so
AeroSlipstreamDexmirrorsAeroCLV2Dexagainst the newSwapRouter; - quoting goes through one
Metaquoterthat takes pool addresses, V2 or CL alike, which the manifest records as"quoterStyle": "meta".
Two manifest dexes carry this: aero (kind aero, poolFactory volatile,
stableFactory stable) and aeroSlipstream (kind cl). Both can sit in the
manifest before deployment — the proposer finds and weighs routes onto them
from the factories and quoter alone — and registry:apply holds any proposal
onto a dex with no address back, by name, until it has one:
yarn deploy:dex # contract AeroDex, name aero; then AeroSlipstreamDex, name aeroSlipstream
# record each address under dexes in helpers/registry.json and in deployments.json
yarn registry:apply # the held-back proposals now go throughThe names must be exactly aero and aeroSlipstream: the registry id is the
keccak of the name, and the manifest already carries those ids.
Aero also issues a new AERO token (0x3f5bF3EdAc59d650D8fDf9657a71F2f904A3a4Ee),
distinct from the one the registry has routes for. Where two tokens share a
symbol, every report tags them with a short address — AERO·9401 is the old
token, AERO·3f5b the new — so a line is never ambiguous about which it means.
yarn registry:routes quotes every registered route against the alternatives and
reports the pairs where another dex or shape does better.
yarn registry:routes # $1000 test swaps
PROPOSE_USD=100 yarn registry:routesFor each pair it enumerates candidate routes — direct and via each intermediate
token, on every dex with a live pool — picks the deepest pool per hop, and quotes
them all with the same input through each dex's own quoter (QuoterV2 for
UniV3/CL, getAmountsOut for Aerodrome/Baseswap).
Test swaps are sized in dollars, because that is the size a liquidation
actually is: a route that only looks good on a huge trade is not the one being
used. There is no price feed involved — each sell token is priced by quoting a
sliver of its deepest pool into usdAnchor (USDC), where price impact is
negligible, and reading the marginal rate off that. A sell token with no route to
the anchor is reported rather than guessed at.
A route can fill the test size and still be dead at a liquidation's size. The
registered cbETH/WETH pool held 125 WETH and 2 cbETH: it paid 0.375 cbETH for
$1000 of WETH and about 2 cbETH for anything larger, however much went in. At
$1000 it looked equal to every alternative. So every registered route is quoted
a second time, at PROPOSE_CAP_FACTOR times the size (default 10), and one
whose output falls short of scaling by more than PROPOSE_CAP_LOSS (default
0.25) is treated as capped there: the alternatives are weighed at that
size instead, and the proposal says so and carries the size, so registry:apply
re-quotes it there. An alternative is only eligible if it returns at least what
the registered route does at the headline size, give or take a basis point of
rounding between pools --- a route that wins at ten times the size and loses at
the size liquidations actually are would make the common case worse to fix the
rare one --- and apply checks that again too.
WETH > cbETH +152.86% at $10000 on 4.298 WETH
the registered route caps: 0.376 cbETH for $1000, 1.488 for $10000
now WETH > cbETH [aeroCL] -> 1.488 cbETH
alt aeroSlipstream WETH>cbETH -> 3.759 cbETH
Falling short is a gate, not a verdict: a route into a thin token falls short
at ten times the size on every venue, and is only proposed if another venue
does materially better there. PROPOSE_VERBOSE=1 prints each route's share of
linear scaling, which is how to see where the gate sits for this chain.
PROPOSE_USDvalue of the test swap (default1000)PROPOSE_CAP_FACTORalso test registered routes at this multiple of the size (default10)PROPOSE_CAP_LOSSshortfall against linear scaling that marks a route capped (default0.25)PROPOSE_MIN_BPSreport threshold (default50, i.e. 0.5%)PROPOSE_LIMITonly look at the first N pathsPROPOSE_VERBOSE=1print the trade size and every quote
A registered route that does not quote at all is treated as broken rather than merely worse: there is no percentage to compare, so any alternative that does quote is proposed for it. This is what catches a route whose pool has drained or whose liquidity has moved out of range — the audit reports it, and the proposer offers the replacement.
Curve, Balancer and ERC4626 routes are not quoted, so pairs registered on those dexes are skipped rather than compared.
A pair with nothing registered has no route to compare against, so it has to be asked for. There are two ways, depending on what you know:
# "route me from A to B" — both ends named
PROPOSE_NEW_PAIRS="0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed>cbBTC" yarn registry:routes
PROPOSE_NEW_PAIRS="AERO>ezETH, cbETH -> USDC" yarn registry:routes
PROPOSE_NEW_PAIRS=wanted.txt yarn registry:routes
# "this token is new, get it somewhere useful" — one end named, the other
# being every intermediate token in turn
PROPOSE_NEW_TOKENS=0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed yarn registry:routes
PROPOSE_NEW_TOKENS=new-vaults.txt yarn registry:routesPROPOSE_NEW_PAIRS takes SELL>BUY entries (-> works too), comma, space or
newline separated, inline or in a file. Either side can be an address or a
symbol the manifest already names — a symbol that names two tokens is rejected
rather than guessed at. PROPOSE_NEW_TOKENS is the shorthand for a new reward
token: it expands to that token against every intermediate, which is all the
registry needs, since getPath reaches everything else from there.
Both are judged the same way. Candidates are enumerated and quoted exactly as
for a registered pair, so the route that wins is the one that holds up at the
test size. What differs is the accept test: with no incumbent to beat, a route
is judged on how much of the input value it keeps, and anything below
PROPOSE_MIN_RETENTION (default 0.9, i.e. 90%) is reported as too lossy
rather than proposed. An illiquid token quotes something through almost any
pool, and a route that gives up half the value is worse than having none.
A pair with no entry of its own is not necessarily unreachable. getPath falls
back to the first intermediate token that has a path on both sides and swaps
in two legs, and because each leg is itself a multi-hop path, the fallback can
cross more pools than any single entry could. So the proposer asks the registry
what it does today, and weighs a direct entry against that instead of against
nothing:
cbETH > DAI: a direct entry keeps 85.0%, the registry already gets ~100.0%
via cbETH>WETH then WETH>USDC>DAI
That one is reported rather than proposed — cbETH>WETH then WETH>USDC>DAI
crosses three pools and keeps everything, while the best single entry has to
squeeze through the thin direct DAI pool. The comparison multiplies the share
each leg keeps, so treat it as approximate.
PROPOSE_NEW_PAIRSSELL>BUYentries, or a file containing themPROPOSE_NEW_TOKENSsingle tokens, expanded against every intermediatePROPOSE_MIN_RETENTIONvalue such a route must keep (default0.9)
A pair that is already registered is compared the ordinary way instead, so
asking for one is never wasted. registry:apply sends these like any other
proposal, and adds the tokens and paths to the manifest once they land.
registry:watch runs the checks a maintainer would run and says something only
when there is something to do. It holds no key and sends no transaction.
WATCH_DRY=1 yarn registry:watch # print what it would say
WATCH_MODE=audit yarn registry:watch # just the breakage check.github/workflows/registry-watch.yml runs it on two cadences, because the two
checks age differently:
- daily, audit only. The audit finds breakage — a pool with no code, a concentrated-liquidity pool whose positions have all moved out of range, a path the chain and the manifest disagree about. That is a liquidation reverting now, so it is worth knowing the same day.
- weekly, audit and routes. The route check ranks alternatives by quote, and quotes move with liquidity. A one or two percent improvement seen on a Tuesday is often gone by Thursday, and acting on each one costs gas and an owner signature, so there is little point looking every day.
It reports three things, loudest first: audit errors, registered routes that no
longer quote (those revert on doHardWork), and routes that could be better by
at least WATCH_MIN_BPS (default 100, i.e. 1%). When all three are empty it
stays quiet, so a message in the channel always means something needs doing.
To set it up, add these repository secrets:
gh secret set REGISTRY_RPC_URL # an archive-free RPC is fine; it only reads
gh secret set WATCH_DISCORD_WEBHOOK # a channel webhook, or use telegram:
gh secret set WATCH_TELEGRAM_TOKEN
gh secret set WATCH_TELEGRAM_CHATSet whichever of the two destinations you want; both work, and it posts to either or both. With none set it prints the message instead, which is what the dry run does. Applying anything remains manual and local, with the owner key — this job is read only by design.
yarn registry:routes writes what it found to helpers/proposals.json (override
with PROPOSE_OUT). yarn registry:apply reads that file back and turns the
proposals into transactions.
yarn registry:routes # write proposals.json
yarn registry:apply # dry run, prints calldata
APPLY_ONLY="AERO>GB,AERO>SEAM" yarn registry:apply # just those two
APPLY_EXECUTE=1 yarn registry:apply # send themA proposal is more than a setPath: the target dex needs the same pair config
the quote was taken with, or the new route lands in a different pool than the one
that won. So each proposal records the pool and params per hop, and apply emits
the setFee / setTickSpacing / pairSetup calls needed to match before the
setPath that uses them.
Every proposal is re-quoted before anything is sent, because prices move
between proposing and applying; one that no longer clears the threshold is
skipped with a note. A proposal judged at a larger size, where the registered
route caps, is re-quoted at that size and at the headline size, and is skipped
if it now returns less than the registered route at the headline size.
APPLY_SKIP_RECHECK=1 disables all of that.
Two things worth knowing:
-
A dex's pair config is global —
pairFee,tickSpacingandstableare keyed only by the token pair. Each hop's pool is therefore chosen by quoting every available tier at a realistic size and keeping the best, which makes it the right pool for that hop rather than just for one route. Every other registered path crossing the hop then picks up the improvement for free, with nosetPathof its own; apply lists those so the effect is visible. The ranking is taken at one trade size, so re-run the proposer afterwards to confirm. -
On success the manifest is updated to match, since it is the record of intent. Run
yarn registry:auditafterwards to confirm chain and manifest agree. -
APPLY_ONLYcomma separated indices orSELL>BUYsymbols (default: all) -
APPLY_MIN_BPSre-check threshold (default: the file's ownminBps) -
APPLY_INread a different proposals file