@real-wagmi/equilibra-smart-router
v1.4.1
Published
Trade routing for the EquilibraSwap cubic-invariant AMM: candidate discovery, route search and split optimization over the offline quoting SDK
Readme
@real-wagmi/equilibra-smart-router
Trade routing for the EquilibraSwap cubic-invariant AMM: candidate pool
discovery (indexer GraphQL with on-chain fallback), route enumeration,
split optimization with a gas model, and router calldata — all quoting
offline through @real-wagmi/equilibra-sdk's mirror of
the protocol's quote kernel (protocol build a1b7ad3; the pinned quotes in
this package's specs are dumped from an in-process deployment of it). No RPC
in the hot path.
Install
pnpm add @real-wagmi/equilibra-smart-router @real-wagmi/equilibra-sdk @real-wagmi/v2-sdk viemQuick start
import { ChainId, CurrencyAmount, Percent, TradeType, nativeOnChain, robinhoodTokens } from '@real-wagmi/v2-sdk';
import {
GraphqlPoolProvider,
OnChainPoolProvider,
OnChainStateRefresher,
createGasModel,
getBestTrade,
swapCallParameters,
withPoolProviderFallback,
} from '@real-wagmi/equilibra-smart-router';
import { createPublicClient, hexToBigInt, http } from 'viem';
const client = createPublicClient({ chain, transport: http(RPC_URL) });
// Candidate sources: the indexer API is primary (pools arrive with TVL and
// full quoting state in ONE query), the chain is the fallback.
const poolProvider = withPoolProviderFallback([
new GraphqlPoolProvider({
endpoint: (chainId) => `https://api.example.com/graphql/${chainId}`,
factory: FACTORY_ADDRESS, // the factory the trade will EXECUTE against
}),
new OnChainPoolProvider({ client, factory: FACTORY_ADDRESS }),
]);
// Gas pricing (optional): measured dev-chain constants + native prices
// from the indexer (`tokens { address derivedNativeWadRaw }`).
const gasModel = createGasModel({
gasPriceWei: await client.getGasPrice(),
nativePriceWadByToken, // Record<lowercase address, bigint>
});
// Head-state refresh (recommended): the indexer may lag the chain by a
// few blocks — the refresher re-snapshots the SELECTED pools in one
// multicall so quotes run on exactly the state execution will see.
const stateRefresher = new OnChainStateRefresher({ client });
const eth = nativeOnChain(ChainId.ROBINHOOD);
const trade = await getBestTrade(
CurrencyAmount.fromRawAmount(eth, 10n ** 18n), // exact side
robinhoodTokens.anon, // the other side
TradeType.EXACT_INPUT,
{ poolProvider, gasModel, stateRefresher },
);
if (!trade) throw new Error('no route');
trade.outputAmount.toSignificant(6); // best obtainable ANON
trade.routes.map((route) => `${route.percent}% via ${route.path.map((token) => token.symbol).join(' -> ')}`);
// Split layer versions. The default is v2 — the percent grid plus a
// water-filling refinement of every finalist, so leg allocations are
// exact integer wei rather than grid multiples. v1 — the plain 5% grid —
// stays callable for measurement and as the fallback story. v2 never
// returns a worse gas-adjusted quote than v1 for the same inputs (the
// pour is seeded with the grid winner); the 2026-08-24 stand A/B
// (git history, 68f6f3e) measured exactly that at scale: zero losses
// in 99,867 comparisons, +4.7 bps mean, at ~3x v1's search time
// (12.4 ms vs 4.3 ms).
// `trade` above IS a v2 trade — no option needed. The explicit form of
// both, on the same inputs:
const v2 = await getBestTrade(
CurrencyAmount.fromRawAmount(eth, 10n ** 18n),
robinhoodTokens.anon,
TradeType.EXACT_INPUT,
{ poolProvider, gasModel, stateRefresher, splitVersion: 'v2' }, // same as omitting it
);
const v1 = await getBestTrade(
CurrencyAmount.fromRawAmount(eth, 10n ** 18n),
robinhoodTokens.anon,
TradeType.EXACT_INPUT,
{ poolProvider, gasModel, stateRefresher, splitVersion: 'v1' }, // the plain grid
);
// In a v2 trade `route.percent` is a DISPLAY label (nearest-integer
// apportionment, sums to 100, can be 0 for a sub-half-percent sliver);
// `route.amount`/`route.quote` carry the exact allocations. Consumers
// that settle or account read the amounts, never the percents.
// Execute through the router.
const { calldata, value } = swapCallParameters(trade, {
slippageTolerance: new Percent(50n, 10_000n), // 0.5%
recipient: account,
deadline: BigInt(Math.floor(Date.now() / 1000) + 1200),
});
await walletClient.sendTransaction({ to: ROUTER_ADDRESS, data: calldata, value: hexToBigInt(value) });How a trade is found
- Candidates —
PoolProvider.getCandidatePoolsreturns every pool between{input, output} ∪ bases(default bases: the chain's[WETH9, ...STABLE_COINS, ...EXTRA_BASES]— the extras are a routing policy registry in this package, e.g. ANON on Robinhood). The GraphQL provider gets them in one indexer query, factory-scoped, with TVL; the on-chain provider discovers viafactory.getPoolsByPair+ one batched state multicall (TVL unknown → ranking degrades to discovery order). Either way a pool that is PAUSED OR PERMANENTLY STOPPED (paused()returns both latches; the stop is irreversible and implies the pause), has no anchor yet (priceScale0, pre-first-liquidity) or has emptied a reserve side is not a candidate. - Selection —
selectPoolsByTvltrims to the pools worth routing over (per-base buckets around each endpoint, direct pools, native bridges, global top-TVL, second hops — the v3 heuristic, withtopNraised from 2 to 4 on our own routing data; see the constant's note). With astateRefresherconfigured, the SELECTED candidates are then re-snapshotted at the chain head in one multicall (OnChainStateRefresher) — ranking may run on indexer-lagged TVL, but quotes run on exactly the state execution will see. With the GraphQL provider primary, the refresher is also the only fresh source of the pause/stop latches (the on-chain provider reads them at discovery, but it is the fallback):state.pausedis as fresh as the indexer's fold, and the indexer has not yet re-pinned thePauseStateChangedtopica1b7ad3changed — until it does, a pool paused or stopped after the update can still arrive as a live candidate. Configure astateRefresherif you settle. - Routes —
computeAllRoutesDFS up tomaxHops(default 3), no pool reused within a route. - Grid + quotes — the amount is sliced at every multiple of
distributionPercent(default 5%) as integer raw amounts;OfflineQuoteProvidersimulates every (route, slice) through the SDK kernel and folds the gas model intoquoteAdjustedForGas. A slice the pool REFUSES is dropped and the search goes on; the refusal set is exactly the SDK'sQuoteRefusalCode— paused, zero or dust amount, insufficient liquidity, insufficient output, and the three classesa1b7ad3added: numeric domain (MathOutOfRange), solver non-convergence (SolverDidNotConverge) and LP-depth decrease (LpValueDecreased), each a refusal of THIS amount on THIS state, which is how the on-chain router's own price-target probe classifies them. Anything else — a solady arithmetic fault, a kernel invariant — is a bug and PROPAGATES out ofgetBestTrade, exactly as the chain propagates it. - Split search —
getBestRouteCombinationsruns the BFS over percent buckets: disjoint routes only (a pool may serve ONE leg of a split), up tomaxSplits(default 4), ranked by summed gas-adjusted quotes, exact ties preferring fewer pools. - Exact re-simulation — the top-K finalists (default 3) are re-run
at integer allocations summing EXACTLY to the request (grid floor-dust
folds into the largest leg); the winner is judged on the re-simulated
numbers. The result's amounts are precisely what execution settles
against the snapshot — for EXACT_INPUT, to the raw unit. For
EXACT_OUTPUT the guarantee is the protocol's own inversion bound:
replaying the quoted input exact-in delivers at least
out - 1raw units PER LEG (a1b7ad3solves exact-out forout + max(1, out/99999999)and takes no input surcharge), so a k-way split carries up to k units of slack. Slippage minimums are what protect the settlement; see Calldata. - The pour (v2, the default) — each exactly-re-simulated finalist's
allocation is refined by water-filling (
utils/water-fill-allocation): integer wei move between legs in a halving-Δ schedule — each Δ is retried until no move of that size improves, and the whole descending schedule (floored at one wei) repeats after any sweep that accepted a move. A move is accepted only when the SUMMED objective strictly improves — more output for exact-in, less input for exact-out. That objective test is also why the pour survivesa1b7ad3's weaker quote response: at the newly admissible λ = 1e12 the checked quote is not monotone in the input and a refusal can be a HOLE (refused at X, quotable at X ± δ), which the pour treats as a veto on that move and nothing more. Never-worse-than-seed and termination do not rest on curvature; optimality is a MEASUREMENT, and it is measured against brute force at the envelope corners (seeutils/water-fill-allocation.test.ts). The pour is seeded with the finalist's own allocation, so it can never do worse than the grid; legs poured empty are dropped and the survivors requoted. Route DISCOVERY is untouched — the pour polishes allocations within a route set, it cannot invent routes, which is why the 5% grid stays the search's resolution (coarser grids were measured and REJECTED: they miss route sets the pour cannot recover).splitVersion: 'v1'skips this step; any other value than'v1'/'v2'/omitted throws.
Calldata
swapCallParameters(trade, { slippageTolerance, recipient, deadline })
returns { calldata, value } for the router:
- ≤ 2 splits — the SDK
SwapRouterencoding: per-route minimums. - > 2 splits — aggregated slippage: every leg swaps with minimum 0 to
router custody, and a single closing
sweepToken/unwrapWETH9pays the recipient enforcing the bound on the TOTAL (per-leg minimums would revert the batch on harmless per-leg jitter). Native input attaches the summed per-leg maximums and closes withrefundETHwhen it can underspend.
Send value verbatim — it is the exact native input the batch can
consume, not a floor. The router's refundETH() is permissionless, so an
over-attached surplus is claimable by the next account that calls it.
Price impact breakdown
getTradeBreakdown(trade) decomposes the quote's deviation from spot,
Uniswap-style: feePct (the route's resolved DYNAMIC fee — paid at any
size) apart from impactPct (what the trade's size did to the price
along the curve, from re-quoting each leg's gross input through zero-fee
clones of the same pool snapshots). totalPct = feePct + impactPct
exactly; when the fee-free counterfactual cannot quote, only totalPct
is returned (feePct/impactPct null). Show the two apart — the
fee-inclusive number makes every quote look 1.4–2.8%/hop worse than the
size actually moved the market.
Configuration
All getBestTrade parameters are optional except poolProvider:
| Option | Default | Meaning |
| --- | --- | --- |
| splitVersion | 'v2' | 'v2' = grid + water-filling pour; 'v1' = plain grid; anything else throws |
| quoteProvider | offline over gasModel | quoting seam (an on-chain quoter can slot in) |
| stateRefresher | none | head-state re-snapshot of selected candidates |
| gasModel | zero-cost | see createGasModel |
| maxHops | 3 | route length cap |
| maxSplits | 4 | disjoint routes per trade |
| distributionPercent | 5 | split grid step |
| topK | 3 | finalists re-simulated exactly |
| bases | [WETH9, ...STABLE_COINS, ...EXTRA_BASES] | intermediate tokens |
| selector | v3 defaults, topN 4 | TVL bucket caps |
createGasModel({ gasPriceWei, nativePriceWadByToken }): units are an
affine curve, 60k base + 150k per hop, fit on 324 executed a1b7ad3
receipts — 270 MULTI-HOP router swaps over two four-token chains of three
pools per config (1, 2 and 3 hops; exactInput, exactOutput,
exactInputSingle; ERC20, WETH and native input — the sampling grid per
entrypoint is stated in constants/gas.ts) plus the 54 single-hop swaps of
the nine parity scenarios. The affine SHAPE is measured, not assumed: the
marginal between the per-hop maxima is 130.9..131.4k with auto-repeg off
and 146.0..146.4k with an auto-repeg on every hop (over every individual
exactInput series 128.1..131.4k and 143.3..146.4k; the exactOutput series
sit lower, 122.3k and 140.0..140.2k), and within every series the 1→2 and
2→3 marginals agree to within 400 gas. The slope is the repegging marginal rounded up; the base absorbs
the one-time native wrap (+9,615) and the storage-skewed 1-hop corpus point,
so the envelope covers every receipt (1 hop 210,000 ≥ 204,084; 2 hops
360,000 ≥ 342,155; 3 hops 510,000 ≥ 488,307). See constants/gas.ts for the
per-config table and the blind spots — skewed reserves measured at one hop
only, the native side measured as a direct payable exactInput rather than
through multicall + refundETH/unwrapWETH9, one curve pair, and hardhat
execution gas with no L1 data cost. Cost converts native → quote currency
through the supplied prices (the indexer's derivedNativeWadRaw verbatim;
numeric overrides welcome).
No gas price → zero cost, and the split search tie-breaks toward fewer hops.
These constants RANK routes; they are not execution limits.
