/** * AereCoreBookClient — typed helpers for AereCoreBookV0 native CLOB. * * Contract: aerenew/contracts/contracts/corebook/AereCoreBookV0.sol * * Post-R6 hardening notes: * - `place()` is the GTC limit entrypoint; refuses CROSSING orders (use * iocFill / placeWithSlippage for those). * - `iocFill(side, worstPrice, qty)` — naive IOC sweep capped at worstPrice. * - `placeWithSlippage(side, price, qty, tif, minBaseFilled, maxQuoteSpent, * maxAvgPriceWei)` — REAL slippage protection. SDKs MUST prefer this. * - `placeProtected(...)` adds a per-order cancelDelayBlocks so mempool * races cannot front-run a maker's cancel. * - BUY taker pays `principal + maxFee + 256-wei buffer`; maker receives * FULL `quoteAmount` (no fee skim). * - On transfer-revert (USDC blacklist, ERC777 hook) the recipient gets a * claimable credit. Pull via `claim(wantBase)`. * - When the book is fully empty, `sweepDust()` forwards leftover QUOTE * (excluding pendingSinkFee + totalClaimableQuote) to AereSink. */ import type { RpcProvider } from '../sink/AereSinkClient.js'; export interface AereCoreBookConfig { address: `0x${string}`; provider: RpcProvider; } export type Side = 0 | 1; // BUY=0, SELL=1 export type TimeInForce = 0 | 1; // GTC=0, IOC=1 /* --------------------------------- selectors --------------------------------- */ const SEL = { // entrypoints place: '0x8121866a', // place(uint8,uint128,uint128,uint8) placeProtected: '0x8af26684', // placeProtected(uint8,uint128,uint128,uint8,uint16) placeWithSlippage: '0xf4526bbc', // placeWithSlippage(uint8,uint128,uint128,uint8,uint128,uint256,uint128) placeWithSlippageProtected: '0xc0efac90', // placeWithSlippageProtected(uint8,uint128,uint128,uint8,uint128,uint256,uint16,uint128) iocFill: '0x5bbb981a', // iocFill(uint8,uint128,uint128) cancel: '0x81649d06', // cancel(uint128) flushFees: '0x313d8cb9', // flushFees() sweepDust: '0xa53df2e2', // sweepDust() claim: '0x2d81a78e', // claim(bool) // reads (selectors recomputed 2026-06-11 against AereCoreBookV0.sol — the // previous values for bestBid/bestAsk/pendingSinkFee + immutables were wrong) bestBid: '0x20c1614b', // bestBid() bestAsk: '0xcffb7d15', // bestAsk() pendingSinkFee: '0x20a0e28c', // pendingSinkFee() totalClaimableQuote: '0x17554eb7', // totalClaimableQuote() totalClaimableBase: '0x200d51bc', // totalClaimableBase() claimableQuote: '0x2d9cf134', // claimableQuote(address) claimableBase: '0x22046da1', // claimableBase(address) TAKER_FEE_BPS: '0x2057bfa0', // TAKER_FEE_BPS() PRICE_TICK: '0xaffabe45', // PRICE_TICK() LOT_SIZE: '0x7fee67a2', // LOT_SIZE() QUOTE_DECIMALS_FACTOR: '0x3da2ad02', // QUOTE_DECIMALS_FACTOR() BASE_DECIMALS_FACTOR: '0x43878833', // BASE_DECIMALS_FACTOR() PRICE_SCALE: '0xc33f59d3', // PRICE_SCALE() MAX_CANCEL_DELAY_BLOCKS: '0xef8045a2', // MAX_CANCEL_DELAY_BLOCKS() DEFAULT_CANCEL_DELAY_BLOCKS: '0x3d81cedd', // DEFAULT_CANCEL_DELAY_BLOCKS() } as const; /* ------------------------------ encode helpers ------------------------------ */ function padUint(value: bigint | number, bytes: number = 32): string { const hex = BigInt(value).toString(16); return hex.padStart(bytes * 2, '0'); } function padAddress(addr: `0x${string}`): string { return addr.slice(2).toLowerCase().padStart(64, '0'); } function hexToBigInt(hex: string): bigint { if (!/^0x[0-9a-fA-F]+$/.test(hex)) throw new Error(`CoreBookClient: bad hex: ${hex}`); return BigInt(hex); } /* --------------------------------- client --------------------------------- */ export class AereCoreBookClient { readonly address: `0x${string}`; private readonly provider: RpcProvider; constructor(cfg: AereCoreBookConfig) { this.address = cfg.address; this.provider = cfg.provider; } /* ----------------------------- read methods ----------------------------- */ async readBestBid(): Promise { return hexToBigInt(await this._call(SEL.bestBid)); } async readBestAsk(): Promise { return hexToBigInt(await this._call(SEL.bestAsk)); } async readPendingSinkFee(): Promise { return hexToBigInt(await this._call(SEL.pendingSinkFee)); } async readTakerFeeBps(): Promise { return hexToBigInt(await this._call(SEL.TAKER_FEE_BPS)); } async readPriceTick(): Promise { return hexToBigInt(await this._call(SEL.PRICE_TICK)); } async readLotSize(): Promise { return hexToBigInt(await this._call(SEL.LOT_SIZE)); } async readQuoteFactor(): Promise { return hexToBigInt(await this._call(SEL.QUOTE_DECIMALS_FACTOR)); } async readBaseFactor(): Promise { return hexToBigInt(await this._call(SEL.BASE_DECIMALS_FACTOR)); } async readPriceScale(): Promise { return hexToBigInt(await this._call(SEL.PRICE_SCALE)); } async readTotalClaimableQuote(): Promise { return hexToBigInt(await this._call(SEL.totalClaimableQuote)); } async readTotalClaimableBase(): Promise { return hexToBigInt(await this._call(SEL.totalClaimableBase)); } async readClaimableQuote(user: `0x${string}`): Promise { return hexToBigInt(await this._call(SEL.claimableQuote + padAddress(user))); } async readClaimableBase(user: `0x${string}`): Promise { return hexToBigInt(await this._call(SEL.claimableBase + padAddress(user))); } /** Compute quote = price * qty * QUOTE_FACTOR / (PRICE_SCALE * BASE_FACTOR). */ async computeQuote(price: bigint, qty: bigint): Promise { const [quoteFactor, baseFactor, priceScale] = await Promise.all([ this.readQuoteFactor(), this.readBaseFactor(), this.readPriceScale(), ]); return (price * qty * quoteFactor) / (priceScale * baseFactor); } /* -------------------------- write calldata builders -------------------------- */ /** * GTC limit entrypoint — REFUSES crossing orders. For crossing use * `iocFill` or `placeWithSlippage`. Applies DEFAULT_CANCEL_DELAY_BLOCKS=2. */ encodePlace(side: Side, price: bigint, quantity: bigint, tif: TimeInForce): `0x${string}` { return (SEL.place + padUint(side, 32) + padUint(price, 32) + padUint(quantity, 32) + padUint(tif, 32)) as `0x${string}`; } /** GTC + custom cancel-delay. Use placeWithSlippageProtected for crossing. */ encodePlaceProtected(side: Side, price: bigint, quantity: bigint, tif: TimeInForce, cancelDelayBlocks: number): `0x${string}` { return (SEL.placeProtected + padUint(side, 32) + padUint(price, 32) + padUint(quantity, 32) + padUint(tif, 32) + padUint(cancelDelayBlocks, 32)) as `0x${string}`; } /** * IOC sweep capped at worstPrice. NO avg-price protection — use * placeWithSlippage with explicit maxAvgPriceWei for surgical-sandwich * protection. */ encodeIocFill(side: Side, worstPrice: bigint, quantity: bigint): `0x${string}` { return (SEL.iocFill + padUint(side, 32) + padUint(worstPrice, 32) + padUint(quantity, 32)) as `0x${string}`; } /** * Recommended entrypoint for any crossing order. Caller MUST set * `maxAvgPriceWei` strictly tighter than `price` to get real sandwich * protection (e.g. price*0.995 for 50bps band). */ encodePlaceWithSlippage( side: Side, price: bigint, quantity: bigint, tif: TimeInForce, minBaseFilled: bigint, maxQuoteSpent: bigint, maxAvgPriceWei: bigint, ): `0x${string}` { return (SEL.placeWithSlippage + padUint(side, 32) + padUint(price, 32) + padUint(quantity, 32) + padUint(tif, 32) + padUint(minBaseFilled, 32) + padUint(maxQuoteSpent, 32) + padUint(maxAvgPriceWei, 32)) as `0x${string}`; } /** Full protection: crossing + slippage + cancel-delay. */ encodePlaceWithSlippageProtected( side: Side, price: bigint, quantity: bigint, tif: TimeInForce, minBaseFilled: bigint, maxQuoteSpent: bigint, cancelDelayBlocks: number, maxAvgPriceWei: bigint, ): `0x${string}` { return (SEL.placeWithSlippageProtected + padUint(side, 32) + padUint(price, 32) + padUint(quantity, 32) + padUint(tif, 32) + padUint(minBaseFilled, 32) + padUint(maxQuoteSpent, 32) + padUint(cancelDelayBlocks, 32) + padUint(maxAvgPriceWei, 32)) as `0x${string}`; } encodeCancel(orderId: bigint): `0x${string}` { return (SEL.cancel + padUint(orderId, 32)) as `0x${string}`; } encodeFlushFees(): `0x${string}` { return SEL.flushFees as `0x${string}`; } /** Permissionless dust sweep — ONLY succeeds when book is fully empty. */ encodeSweepDust(): `0x${string}` { return SEL.sweepDust as `0x${string}`; } /** * Pull accumulated claimable balance after a failed transfer. * @param wantBase true → claim BASE side; false → claim QUOTE side. */ encodeClaim(wantBase: boolean): `0x${string}` { return (SEL.claim + padUint(wantBase ? 1 : 0, 32)) as `0x${string}`; } /* --------------------------- helper: recommended bands --------------------------- */ /** * Returns recommended slippage parameters for a crossing order. The caller * provides desired band (default 50bps); we compute maxAvgPriceWei such * that the average fill cannot exceed it. */ recommendedSlippage( side: Side, limitPrice: bigint, quantity: bigint, bpsBand: number = 50, ): { minBaseFilled: bigint; maxQuoteSpent: bigint; maxAvgPriceWei: bigint } { const band = BigInt(bpsBand); const maxAvg = side === 0 ? (limitPrice * (10_000n - band)) / 10_000n : (limitPrice * (10_000n + band)) / 10_000n; return { minBaseFilled: 0n, maxQuoteSpent: (1n << 256n) - 1n, maxAvgPriceWei: maxAvg, }; } /* ----------------------------- internals ------------------------------ */ private async _call(data: string): Promise { const result = await this.provider.request({ method: 'eth_call', params: [{ to: this.address, data }, 'latest'], }); if (typeof result !== 'string') throw new Error('CoreBookClient: bad eth_call'); return result; } }