Skip to content

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Harvest Universal Liquidator Arbitrum

Structure

  • In helpers/token-pairs.json, helpers/pools.json, and helpers/intermediate-tokens.json, the list between test and production is separated.

Get Started

yarn
yarn test

Test locally

yarn  test

Deploy locally

In 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 localhost

Registry maintenance

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

Workflow

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

registry: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:routes

Numbers 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:audit

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

Seeding

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 add
  • SEED_FROM_BLOCK=<block> — scan UniversalLiquidator.Swap logs 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.

What the audit checks

Errors (exit code 1):

  • UniversalLiquidator.pathRegistry and registry owner match the manifest
  • every dex resolves to the manifest address, and none resolves to address(0)
  • intermediate tokens match in order — getPath returns 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 doHardWork that 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 minLiquidity floor for it
  • a pair has no reverse path
  • a UniV3 hop uses fee 500, which is indistinguishable from never having been set

Findings you have decided to live with

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.

Converging the chain to the manifest

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.

The Aero deployment

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 stable flag;
  • the Metarouter takes encoded commands, not a route, so AeroDex swaps against the pools directly: it moves the input into the pool and the pool pays out. The output is sized with getAmountOutWithTotalFee, which charges the fee the caller would actually pay;
  • Slipstream keeps its tick-spacing-packed path, so AeroSlipstreamDex mirrors AeroCLV2Dex against the new SwapRouter;
  • quoting goes through one Metaquoter that 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 through

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

Proposing better routes

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:routes

For 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_USD value of the test swap (default 1000)
  • PROPOSE_CAP_FACTOR also test registered routes at this multiple of the size (default 10)
  • PROPOSE_CAP_LOSS shortfall against linear scaling that marks a route capped (default 0.25)
  • PROPOSE_MIN_BPS report threshold (default 50, i.e. 0.5%)
  • PROPOSE_LIMIT only look at the first N paths
  • PROPOSE_VERBOSE=1 print 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.

Routes the registry does not have yet

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:routes

PROPOSE_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_PAIRS SELL>BUY entries, or a file containing them
  • PROPOSE_NEW_TOKENS single tokens, expanded against every intermediate
  • PROPOSE_MIN_RETENTION value such a route must keep (default 0.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.

Watching it on a schedule

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_CHAT

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

Applying proposals

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 them

A 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, tickSpacing and stable are 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 no setPath of 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:audit afterwards to confirm chain and manifest agree.

  • APPLY_ONLY comma separated indices or SELL>BUY symbols (default: all)

  • APPLY_MIN_BPS re-check threshold (default: the file's own minBps)

  • APPLY_IN read a different proposals file

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages