@real-wagmi/equilibra-limit-orders
v1.1.1
Published
Maker-side client for Equilibra limit orders: the EIP-712 order model and signing, price-to-floor math replayed against the node's vectors, fillability over the offline quoting SDK, and a typed client for the limit-order node API
Readme
@real-wagmi/equilibra-limit-orders
Maker-side client for Equilibra limit orders: build and sign an EIP-712 order that keeps the funds in the wallet, check whether a pool could fill it right now, and talk to the limit-order node — minimums, login, posting, listing, cancelling. The math is replayed digit for digit against the node's own test vectors and the deployed settlement contract.
Install
pnpm add @real-wagmi/equilibra-limit-orders @real-wagmi/equilibra-sdk @real-wagmi/v2-sdk viemStep 5 below fetches a pool through the smart router's provider; that is
optional and needs pnpm add @real-wagmi/equilibra-smart-router — any
other way of obtaining an @real-wagmi/equilibra-sdk Pool works.
Usage
Robinhood chain as the example. The full path: read the node's minimum, build an order from a human price, validate it, check fillability, sign, post, follow it, cancel it.
1. Prepare the clients
Nothing the operator runs is pinned in the package: you pass the node's
endpoint and the settlement address, the way the smart router takes its
endpoint and factory.
import { LimitOrdersApiClient } from '@real-wagmi/equilibra-limit-orders/api';
import { createPublicClient, http } from 'viem';
const publicClient = createPublicClient({ transport: http('https://rpc.mainnet.chain.robinhood.com') });
const client = new LimitOrdersApiClient({
endpoint: (chainId) => `${LIMIT_ORDERS_BASE}/${chainId}`, // or one full URL for a single chain
settlement: SETTLEMENT_ADDRESS, // or a resolver by chain id
// requester?: the subset of fetch the client needs; defaults to globalThis.fetch
});
// Identity first: /health names the chain and the settlement the node
// signs for — the chainId and verifyingContract of both EIP-712 domains
// (orders and login/cancel) — so a mis-pointed endpoint stops here,
// before any wallet prompt.
const health = await client.assertIdentity(4663);
health.settlement; // the settlement, checksummed
health.minOrderAmountInFallback; // the node's minimum at 8 decimals, e.g. 1_000_000n
health.expiryMarginSecs; // the node's deadline window — 120n / 2_592_000n on Robinhood; step 4 takes it from here
health.maxDeadlineSecs;
health.executionBufferBps; // the keeper's margin above the floor — 50 on Robinhood; step 5 takes it from here2. Read the minimum order size
import { minimumOrderAmount } from '@real-wagmi/equilibra-limit-orders';
import { robinhoodTokens } from '@real-wagmi/v2-sdk';
const weth = robinhoodTokens.weth;
const anon = robinhoodTokens.anon;
// The node's answer for the input token — a hub-valued minimum or its
// decimal fallback. Fetch it right before signing: the node sends
// Cache-Control: no-store, and a hub-valued minimum can move with pool
// state.
const minimum = await client.orderMinimum(4663, weth.address);
minimum.amountIn; // raw WETH, e.g. 10_000_000_000_000_000n (0.01 WETH under the fallback)
minimum.source; // 'hub_value' | 'fallback' — can be 'fallback' for any token
// The same fallback, computed offline from /health for a token's decimals:
minimumOrderAmount(weth.decimals, health.minOrderAmountInFallback); // 10_000_000_000_000_000n3. Build the order from a human price
Amounts are CurrencyAmounts and the price is a v2-sdk Price over raw
units, so decimals are never placed by hand. The floor is the other
team's rule — exact rational, floored — and a fractional amount is
refused rather than rounded into a signed field.
import { buildLimitOrder, minAmountOutForPrice, parseHumanPrice } from '@real-wagmi/equilibra-limit-orders';
import { CurrencyAmount } from '@real-wagmi/v2-sdk';
const amountIn = CurrencyAmount.fromRawAmount(weth, 2n * 10n ** 16n); // sell 0.02 WETH
const price = parseHumanPrice(weth, anon, '7000'); // want at least 7000 ANON per WETH
const minAmountOut = minAmountOutForPrice(amountIn, price); // CurrencyAmount of 140 ANON (raw 140 × 10^18), floored
const order = buildLimitOrder({
maker: account.address,
tokenIn: weth,
tokenOut: anon,
amountIn,
minAmountOut,
deadline: BigInt(Math.floor(Date.now() / 1000) + 24 * 3600), // unix seconds, inclusive
// receiver: defaults to the zero address = "pay the maker"
// salt: defaults to generateSalt() — 96 random bits from Web Crypto
});4. Validate before the wallet prompt
validateOrder returns every violated code — the four client-only
structural codes first, then the node's seven in the node's order — so a
form can show all of them; assertValidOrder throws one
OrderValidationError whose code is the first and whose codes are
all of them. The node codes are the API's own wire strings; the deadline
window is the node's policy and comes from /health, never typed by hand.
import { OrderValidationCode, assertValidOrder, validateOrder } from '@real-wagmi/equilibra-limit-orders';
import { nodeOrderPolicy } from '@real-wagmi/equilibra-limit-orders/api';
const ctx = {
settlement: SETTLEMENT_ADDRESS,
router: ROUTER_ADDRESS, // the node refuses the router and the settlement as receiver
now: BigInt(Math.floor(Date.now() / 1000)),
policy: nodeOrderPolicy(health), // the window from the same /health that assertIdentity checked
};
validateOrder(order, ctx); // [] for a good order
validateOrder({ ...order, deadline: ctx.now + 31n * 24n * 3600n }, ctx); // [OrderValidationCode.DEADLINE_TOO_FAR]
assertValidOrder(order, ctx); // throws OrderValidationError { code: codes[0], codes } otherwise5. Check fillability against a pool
The verdict answers the keeper's own question for one direct pool: would
the full signed amountIn, swapped exact-in now, clear the signed floor?
Bring the Pool however you already do — a smart-router pool provider,
or your own reads of the pool's views assembled into an
@real-wagmi/equilibra-sdk Pool — and the platform share from the
contract.
import { Fillability, quoteOrderFillability, readSettlementPolicy } from '@real-wagmi/equilibra-limit-orders';
import { GraphqlPoolProvider } from '@real-wagmi/equilibra-smart-router';
const provider = new GraphqlPoolProvider({ endpoint: `${GRAPHQL_BASE}/4663`, factory: FACTORY_ADDRESS });
const candidates = await provider.getCandidatePools({ currencyA: weth, currencyB: anon });
const { pool } = candidates.find((c) => c.pool.token0.equals(weth) && c.pool.token1.equals(anon))!;
const policy = await readSettlementPolicy({ client: publicClient, settlement: SETTLEMENT_ADDRESS });
policy.platformShareBps; // 8000 on Robinhood — the platform keeps 80% of the surplus above the floor
const verdict = quoteOrderFillability(pool, order, { ...policy, executionBufferBps: health.executionBufferBps });
switch (verdict.status) {
case Fillability.FILLABLE:
verdict.expectedOut; // what the pool pays for the full amountIn now
verdict.expectedMakerOut; // what the maker receives after the platform's share of the surplus
verdict.clearsExecutionBuffer; // present iff a buffer was given; false = the order sits at its limit and the keeper waits
verdict.depthAtLimit; // the router's sweep toward the limit price: a LOWER bound, never the verdict
break;
case Fillability.NOT_REACHABLE: // the pool pays less than the floor for this size
verdict.expectedOut;
verdict.depthAtLimit;
break;
case Fillability.NOT_REPRESENTABLE: // the limit is outside the pool's price domain
break;
case Fillability.PAUSED:
verdict.stopped; // true = the pool's stop latch: this pause never lifts
break;
case Fillability.REFUSED: // the pool's quoter refused this size on this state
verdict.refusal;
break;
}The verdict is a direct-pool approximation of the keeper's, not a bound
on it: the keeper also prices multi-leg routes (which can fill what one
pool cannot) and applies a minimum platform fee (which can hold back what
one pool would fill). Its execution buffer IS answered, as a field:
clearsExecutionBuffer false is a FILLABLE order sitting at its limit
— the contract would pay the floor, but the keeper only submits once the
route pays the floor plus the buffer (50 bps on Robinhood, from
/health). clearsExecutionBuffer(expectedOut, minAmountOut, bps) is
exported for any other quote you hold.
Three details of the verdict follow the pools' a1b7ad3 quote path and
are worth reading before you render it:
REFUSEDis the pool's own vocabulary, not just dust. The quote view refuses where it used to answer zero, soverdict.refusalcan beAMOUNT_TOO_SMALL(a size whose output floors away),INSUFFICIENT_LIQUIDITY,LP_VALUE_DECREASED(the pool's strict LP-value guard),SOLVER_DID_NOT_CONVERGEorMATH_OUT_OF_RANGE— this size on this state, never a fault and never a permanent property of the pool. The router's own price-target search sorts them in two classes: dust (AMOUNT_TOO_SMALL) isTooSmall— the search moves on to larger inputs without recording the probe — and the other four areRejected— a search ceiling. The union is the SDK'sQuoteRefusalCode, so a new code appears here without a release of this package. A solady overflow (FullMulDivFailedand its siblings) is deliberately NOT a refusal: the chain propagates it and so doesquoteOrderFillability.NOT_REACHABLEwithexpectedOut0 now means one thing only — an unseeded pool.depthAtLimitis a lower bound.quoteSwapToPriceis the ROUTER's search since0f43f3d(the pool view was deleted), and each of its probes is a checked quote: aRejectedprobe is a search ceiling (aTooSmallone is skipped), and the input is capped at ~99% of the input-side reserve, so a sweep can stop short of the limit with no flag saying it did. ReadamountInas "the pool absorbs at least this much before its marginal price reaches the limit" — the one guarantee the chain gives — and never as "the depth up to the limit". Thecappedflag this package used to derive is gone for exactly that reason.PAUSEDcarriesstopped.paused()returns(paused, stopped)and the stop is irreversible. Both states are one verdict, because the pool's swap guard reads onlypaused, but "waiting to be unpaused" and "will never fill on this pool again" are not the same sentence to a maker.
6. Sign and post
signer is anything with signTypedData — a viem account or a wallet
client with a hoisted account, as is. An ECDSA signature is
canonicalised to 65 bytes with v ∈ {27, 28} (a smart-account signature
of another length passes through for the node to judge); the node's
returned digest must equal the
one computed locally, which proves both sides hashed the same struct
under the same domain.
import { buildOrderDomain, signOrder } from '@real-wagmi/equilibra-limit-orders';
import { isApiError } from '@real-wagmi/equilibra-limit-orders/api';
const domain = buildOrderDomain(4663, SETTLEMENT_ADDRESS);
const signed = await signOrder(account, domain, order); // { order, digest, signature }
try {
const posted = await client.postOrder(4663, signed);
posted.digest; // === signed.digest, checked by the client
posted.status; // 'open'
} catch (error) {
if (isApiError(error)) {
error.status; // e.g. 400 — also 409 duplicate_order, 503 node_at_capacity, 500 internal
error.code; // 'insufficient_balance' | 'insufficient_allowance' | 'order_too_small' | 'no_route' | …
}
throw error;
}The maker's balance and allowance to the settlement must cover the sum of ALL their active orders in this input token plus the new one; the node checks that at posting time, after the signature. Check it yourself before asking for the signature — a refused post has already cost a wallet prompt.
7. Follow the orders
Reading needs a session: a Login signature under the API's own domain,
answered with a JWT. issuedAt is your clock; the node accepts ±300 s.
import { OrderStatus } from '@real-wagmi/equilibra-limit-orders/api';
const session = await client.login(4663, account, account.address, BigInt(Math.floor(Date.now() / 1000)));
session.token; // opaque; keep it with its maker — every node sharing the signing key accepts it; a 401 later means log in again
session.expiresAt; // unix seconds
// The default listing is the maker's OPEN orders. On Robinhood the node
// caps live orders per maker at 20, so the open set fits the default
// 100-row page and this is complete; a node with a cap above 100, or
// none, needs listAllOrders for the open set too.
const open = await client.listOrders(4663, session);
open[0]?.record.order; // a LimitOrder, addresses checksummed
open[0]?.status; // OrderStatus.OPEN
// Everything else is newest first (createdAtUnix DESC, digest DESC): an
// unfiltered page is the 100 most recent orders of any status; older
// pages by offset. The node prunes terminal records 30 days after
// creation (Robinhood's default), once the deadline has passed and the
// order is not being processed — history is the retained window, not
// everything ever placed.
const recent = await client.listOrders(4663, session, {}); // the 100 most recent
const everything = await client.listAllOrders(4663, session); // walks 1000-row pages to the end, de-duplicated by digest
const filled = await client.listAllOrders(4663, session, { status: [OrderStatus.FILLED] });
const detail = await client.getOrder(4663, session, signed.digest);
detail.fills; // [{ amountIn, makerOut, platformFee, txHash, blockNumber, … }]Polling is yours: a React host uses its query library, a bot its own loop.
useQuery({
queryKey: ['limit-orders', 4663, account.address],
queryFn: () => client.listOrders(4663, session),
refetchInterval: 10_000,
});8. Cancel
There is no on-chain cancel. A cancel is a signed SoftCancel request to
the node; it is irreversible, and the nonce is per order, 1..2^63 − 1,
strictly greater than the last one the node accepted.
const cancelled = await client.cancelOrder(4663, account, signed.digest, 1n);
cancelled.status; // 'cancelled'409 order_in_flight— a fill is being submitted; the same signed request is retryable once it resolves (the nonce was not consumed).409 order_not_cancellable— filled, cancelled or expired; terminal.403 bad_nonce— choose a nonce above the last accepted one.
Layers
@real-wagmi/equilibra-limit-orders— the order model side. The settlement's EIP-712 domain andOrderstruct; the conversions (parseHumanPrice,minAmountOutForPrice,limitToSqrtTarget,minimumOrderAmount,expectedMakerOut);buildLimitOrder,validateOrder/assertValidOrder,buildOrderDomain,signOrder,orderDigest,toWireOrder/fromWireOrder;quoteOrderFillabilityover an@real-wagmi/equilibra-sdkPoolandclearsExecutionBuffer;readSettlementPolicyover a structuralReadContractClient(a viemPublicClientas is).@real-wagmi/equilibra-limit-orders/api— the node side.LimitOrdersApiClientwith{ endpoint, settlement, requester? };health/assertIdentity(+nodeOrderPolicy),orderMinimum,authPubkey,login,postOrder,listOrders,listAllOrders,getOrder,cancelOrder;ApiErrorandisApiError; theOrderStatusandAPI_ERROR_CODESwire values. The standalone functions behind the methods are exported too, for hosts that bring their own transport —fetchHealth,assertNodeIdentity,fetchOrderMinimum,fetchAuthPubkey,login,postOrder,listOrders,listAllOrders,getOrder,cancelOrder— all exceptassertNodeIdentitytake a requester and a base URL (and the API domain where a signature is made);assertNodeIdentityis pure and takes the health and the expected identity. Note that the standalonelistOrdershas no status default; the OPEN default is the client method's.- Seams are structural, never wrapper classes: a signer is anything
with
signTypedData, a requester is the subset offetchthe client calls, a read client is anything withreadContract.
Errors
- A non-2xx answer from the node throws
ApiErrorwithstatusandcodeas fields — classify withisApiError(error)and branch on the fields, never on the message.codeis one of the API's documented codes or''for a body that was not the node's (an HTML 502, an unknown code from a newer node — the raw text stays in the message). - A 2xx body off the schema throws an invariant naming the field
(
HEALTH_SETTLEMENT,ORDER_MINIMUM_HUB_TOKEN, …); so does a bad argument before any request (TOKEN_IN,DIGEST,NONCE) — a malformed digest would otherwise earn a misleading404 unknown_order, and a cancel would cost a wallet prompt. validateOrdercodes: the node'szero_amount,identical_tokens,invalid_receiver,salt_overflow,deadline_passed,deadline_too_soon,deadline_too_far, plus the client-onlyamount_range,deadline_range,salt_range,zero_address.
Notes
- Amounts are raw on-chain units (
bigint);Token,CurrencyAmount,Priceand the chain tokens come from@real-wagmi/v2-sdk. ACurrencyAmountcan carry a fraction; every function that feeds a signed field refuses one — normalise withCurrencyAmount.fromRawAmount(currency, amount.quotient)first. - No URL, address or node policy lives in the package: the endpoint, the
settlement and the router come from the consumer; the deadline window
and the keeper's execution buffer from
/health(nodeOrderPolicy(health),health.executionBufferBps); the platform share from the chain — so a redeploy or a policy change is never a package release. - The platform keeps 80 % of any surplus above the maker's floor
(
platformShareBps, read from the contract); the maker always receives at least the floor. orderDigestis pinned to the deployed settlement'shashOrder;loginDigestandsoftCancelDigestreplayvectors/login.jsonandvectors/softcancel.json, andlimitToSqrtTargetand the price rule the other three vector files, case for case — all exported by the node's own Rust reference. The vectors live in the submodule and are needed only to run this package's tests.
