From 16fbe2932fb65975ccd52a24190e75be2d34d6f3 Mon Sep 17 00:00:00 2001 From: Liviu Date: Thu, 11 Jun 2026 10:44:18 +0300 Subject: [PATCH] feat(corebook): CoreBookClient + AereCoreBookClient selector fixes + AERE_COREBOOK markets gate - CoreBookClient: full read/write surface for AereCoreBookV0 (placeGTC/IOC/ withSlippage/Protected, cancel, claim base+quote, flushFees; bestBid/bestAsk/ marketInfo/depth/openOrdersOf via Lens), price math mirrors (_computeQuote, _lockBuyFunds incl. 256-wei buffer), orderId derivation (pre-incremented nonce keccak), event decode for OrderPlaced/Filled/OrderCancelled/Claimed. Verified 63/63 against ethers v6 ground truth. - AereCoreBookClient (pre-existing): fixed 11 wrong read selectors (bestBid, bestAsk, pendingSinkFee, TAKER_FEE_BPS, PRICE_TICK, LOT_SIZE, decimals factors, PRICE_SCALE, cancel-delay constants) - every read would have thrown before. - addresses.ts: AERE_COREBOOK = { markets: {} } - founder gate, no market listed until explicit approval. Co-Authored-By: Claude Fable 5 --- src/addresses.ts | 13 + src/corebook/AereCoreBookClient.ts | 33 +- src/corebook/CoreBookClient.ts | 724 +++++++++++++++++++++++++++++ src/corebook/index.ts | 5 + src/index.ts | 2 +- 5 files changed, 760 insertions(+), 17 deletions(-) create mode 100644 src/corebook/CoreBookClient.ts diff --git a/src/addresses.ts b/src/addresses.ts index 315738f..13b311f 100644 --- a/src/addresses.ts +++ b/src/addresses.ts @@ -189,3 +189,16 @@ export type AereContractName = keyof Omit< typeof AERE_MAINNET, 'chainId' | 'chainIdHex' | 'rpc' | 'ws' | 'explorer' | 'indexer' >; + +// ── AereCoreBookV0 — native CLOB (per-market deploys) ───────────────────── +// One AereCoreBookV0 contract per (base, quote) market. NOTHING is deployed +// or listed yet: market listing is founder-gated, and the trade UI must fall +// back to demo mode while this map is empty. Keys are human market symbols +// ("WAERE/USDC.e"), values are the per-market AereCoreBookV0 addresses. +export const AERE_COREBOOK = { + markets: { + // no markets listed — founder gate + } as Record, +} as const; + +export type CoreBookMarketSymbol = keyof typeof AERE_COREBOOK.markets; diff --git a/src/corebook/AereCoreBookClient.ts b/src/corebook/AereCoreBookClient.ts index 3cd2678..a1cb73b 100644 --- a/src/corebook/AereCoreBookClient.ts +++ b/src/corebook/AereCoreBookClient.ts @@ -41,22 +41,23 @@ const SEL = { flushFees: '0x313d8cb9', // flushFees() sweepDust: '0xa53df2e2', // sweepDust() claim: '0x2d81a78e', // claim(bool) - // reads - bestBid: '0xeae2ea7e', // bestBid() - bestAsk: '0x86b07c1d', // bestAsk() - pendingSinkFee: '0x9c2f4d12', // pendingSinkFee() - totalClaimableQuote: '0x17554eb7', - totalClaimableBase: '0x200d51bc', - claimableQuote: '0x2d9cf134', - claimableBase: '0x22046da1', - TAKER_FEE_BPS: '0x35b25c5d', - PRICE_TICK: '0x6e716bdb', - LOT_SIZE: '0x49bf3edb', - QUOTE_DECIMALS_FACTOR: '0x8b4ad97e', - BASE_DECIMALS_FACTOR: '0xa2d5d92b', - PRICE_SCALE: '0xb6d8b8e9', - MAX_CANCEL_DELAY_BLOCKS: '0x77ec5e2a', - DEFAULT_CANCEL_DELAY_BLOCKS: '0x29c8bc8e', + // 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 ------------------------------ */ diff --git a/src/corebook/CoreBookClient.ts b/src/corebook/CoreBookClient.ts new file mode 100644 index 0000000..94db1e7 --- /dev/null +++ b/src/corebook/CoreBookClient.ts @@ -0,0 +1,724 @@ +/** + * CoreBookClient — full typed read/write client + off-chain lens for + * AereCoreBookV0 (native CLOB, one contract per market). + * + * Contract: aerenew/contracts/contracts/corebook/AereCoreBookV0.sol + * (R6-hardened, 289/289 tests passing — source of truth for all math here). + * + * Pattern: dependency-free EIP-1193 wrapper with precomputed 4-byte + * selectors. Read methods just `eth_call`; write methods build calldata + * + `eth_sendTransaction`. Caller handles signer + gas estimation. + * + * Market conventions (mirrors the contract exactly): + * - Side: 0 = BUY (quote → base), 1 = SELL (base → quote). + * - TimeInForce: 0 = GTC, 1 = IOC. + * - price is uint128, quote-per-base scaled by PRICE_SCALE = 1e18. + * Verified against `_computeQuote`: + * quote = price * qty * QUOTE_DECIMALS_FACTOR / (1e18 * BASE_DECIMALS_FACTOR) + * For human price P (quote units per base unit) the decimals factors + * cancel, so priceWei = P * 1e18 regardless of token decimals. + * - quantity is uint128 in base smallest units, multiple of LOT_SIZE. + * - BUY locks quote principal + max taker fee + 256-wei buffer + * (`_lockBuyFunds`: MAX_OPEN_ORDERS_PER_MAKER covers worst-case per-fill + * fee-ceiling rounding). SELL locks base. ERC-20 approve required first. + * - `place()` REFUSES crossing orders (CrossingRequiresSlippage) and applies + * a default 2-block cancel delay. Crossing flow must go through + * `iocFill` / `placeWithSlippage(Protected)`. + * - orderId = uint128(keccak256(abi.encode(maker, makerNonce))) — the lens + * derives historical ids client-side (see `deriveOrderId`). + */ +import type { RpcProvider } from '../sink/AereSinkClient.js'; +import type { Side, TimeInForce } from './AereCoreBookClient.js'; + +export interface CoreBookClientConfig { + address: `0x${string}`; + provider: RpcProvider; +} + +/* ------------------------------- constants ------------------------------- */ + +/** Contract PRICE_SCALE — price uint128 is quote-per-base scaled by 1e18. */ +export const COREBOOK_PRICE_SCALE = 10n ** 18n; +/** `bestAsk()` sentinel when the ask side is empty (type(uint128).max). */ +export const COREBOOK_ASK_EMPTY = (1n << 128n) - 1n; +/** `bestBid()` sentinel when the bid side is empty. */ +export const COREBOOK_BID_EMPTY = 0n; +/** `_lockBuyFunds` flat buffer = MAX_OPEN_ORDERS_PER_MAKER (256 wei of quote). */ +export const COREBOOK_BUY_LOCK_BUFFER = 256n; +/** Contract caps for cancel-delay (blocks). */ +export const COREBOOK_DEFAULT_CANCEL_DELAY_BLOCKS = 2; +export const COREBOOK_MAX_CANCEL_DELAY_BLOCKS = 1200; + +const UINT128_MAX = (1n << 128n) - 1n; +const UINT256_MAX = (1n << 256n) - 1n; + +/* ------------------------------- selectors ------------------------------- */ + +const SEL = { + // entrypoints + place: '0x8121866a', // place(uint8,uint128,uint128,uint8) + placeProtected: '0x8af26684', // placeProtected(uint8,uint128,uint128,uint8,uint16) + iocFill: '0x5bbb981a', // iocFill(uint8,uint128,uint128) + placeWithSlippage: '0xf4526bbc', // placeWithSlippage(uint8,uint128,uint128,uint8,uint128,uint256,uint128) + placeWithSlippageProtected: '0xc0efac90', // placeWithSlippageProtected(uint8,uint128,uint128,uint8,uint128,uint256,uint16,uint128) + cancel: '0x81649d06', // cancel(uint128) + claim: '0x2d81a78e', // claim(bool) + flushFees: '0x313d8cb9', // flushFees() + sweepDust: '0xa53df2e2', // sweepDust() + + // book state + bestBid: '0x20c1614b', // bestBid() + bestAsk: '0xcffb7d15', // bestAsk() + pendingSinkFee: '0x20a0e28c', // pendingSinkFee() + getOrder: '0x117d4128', // getOrder(uint128) + bidHeadAtPrice: '0xf056e2d7', // bidHeadAtPrice(uint128) + askHeadAtPrice: '0xec29c07b', // askHeadAtPrice(uint128) + nextPopulatedAsk: '0xcce549e0', // nextPopulatedAsk(uint128) + nextPopulatedBid: '0xe3340be7', // nextPopulatedBid(uint128) + claimableQuote: '0x2d9cf134', // claimableQuote(address) + claimableBase: '0x22046da1', // claimableBase(address) + makerNonce: '0xcd370bb2', // makerNonce(address) + openOrderCount: '0x639babfb', // openOrderCount(address) + + // immutables + BASE: '0xec342ad0', // BASE() + QUOTE: '0x9c579839', // QUOTE() + SINK: '0xae9fc205', // SINK() + PRICE_TICK: '0xaffabe45', // PRICE_TICK() + LOT_SIZE: '0x7fee67a2', // LOT_SIZE() + TAKER_FEE_BPS: '0x2057bfa0', // TAKER_FEE_BPS() + QUOTE_DECIMALS_FACTOR: '0x3da2ad02', // QUOTE_DECIMALS_FACTOR() + BASE_DECIMALS_FACTOR: '0x43878833', // BASE_DECIMALS_FACTOR() + + // ERC-20 (approve flow) + allowance: '0xdd62ed3e', // allowance(address,address) + approve: '0x095ea7b3', // approve(address,uint256) + balanceOf: '0x70a08231', // balanceOf(address) +} as const; + +/* ----------------------------- event topic0 ------------------------------ */ +// keccak256 of the exact event signature strings from AereCoreBookV0.sol +// (enums are uint8 in the ABI signature). + +/** OrderPlaced(uint128,address,uint8,uint128,uint128) */ +export const TOPIC_ORDER_PLACED = + '0x2229a00bfbf976283c0743a5a848fbf6e1a6843ac1eb8a9744a624a68b57632c'; +/** OrderCancelled(uint128,address,uint128) */ +export const TOPIC_ORDER_CANCELLED = + '0x5264d0657a58acd302a6fb1f840ac461d0347aae6be5019868b66ee71b1b8cab'; +/** Filled(uint128,uint128,address,address,uint8,uint128,uint128,uint256) */ +export const TOPIC_FILLED = + '0x39440616edbc0f927b9a918e409c20c622cd6e4570e90f80b679f6181e4f70f7'; +/** Claimed(address,address,uint256) */ +export const TOPIC_CLAIMED = + '0xf7a40077ff7a04c7e61f6f26fb13774259ddf1b6bce9ecf26a8276cdd3992683'; + +/* ----------------------------- encode helpers ---------------------------- */ + +function pad32(hex: string): string { + const h = hex.toLowerCase().replace(/^0x/, ''); + return h.padStart(64, '0'); +} +function encUint(n: bigint | number): string { + const v = BigInt(n); + if (v < 0n || v > UINT256_MAX) throw new Error(`CoreBookClient: uint out of range: ${v}`); + return pad32(v.toString(16)); +} +function encAddr(addr: string): string { return pad32(addr.replace(/^0x/, '')); } + +function word(data: string, i: number): bigint { + const h = data.replace(/^0x/, ''); + const w = h.slice(i * 64, i * 64 + 64); + if (w.length !== 64) throw new Error('CoreBookClient: short returndata'); + return BigInt('0x' + w); +} +function addrWord(data: string, i: number): string { + const h = data.replace(/^0x/, ''); + return '0x' + h.slice(i * 64 + 24, i * 64 + 64); +} +function topicAddr(topic: string): string { return '0x' + topic.slice(26).toLowerCase(); } + +async function ethCall(p: RpcProvider, to: string, data: string): Promise { + const r = await p.request({ method: 'eth_call', params: [{ to, data }, 'latest'] }); + if (typeof r !== 'string') throw new Error('CoreBookClient: bad eth_call result'); + return r; +} +async function ethSend(p: RpcProvider, from: string, to: string, data: string): Promise { + return await p.request({ method: 'eth_sendTransaction', params: [{ from, to, data, value: '0x0' }] }) as string; +} + +/* --------------------- keccak-256 (vanilla, no deps) --------------------- */ +// Needed ONLY for client-side orderId derivation: +// orderId = uint128(keccak256(abi.encode(maker, nonce))) +// (AereCoreBookV0._nextOrderId — nonce is PRE-incremented, so the first +// order of a maker uses nonce 1). Verified against the canonical Keccak-256 +// test vector keccak256("") = 0xc5d2...a456 and ethers.keccak256. + +const KECCAK_RC: bigint[] = [ + 0x0000000000000001n, 0x0000000000008082n, 0x800000000000808an, 0x8000000080008000n, + 0x000000000000808bn, 0x0000000080000001n, 0x8000000080008081n, 0x8000000000008009n, + 0x000000000000008an, 0x0000000000000088n, 0x0000000080008009n, 0x000000008000000an, + 0x000000008000808bn, 0x800000000000008bn, 0x8000000000008089n, 0x8000000000008003n, + 0x8000000000008002n, 0x8000000000000080n, 0x000000000000800an, 0x800000008000000an, + 0x8000000080008081n, 0x8000000000008080n, 0x0000000080000001n, 0x8000000080008008n, +]; +// rotation offsets r[x][y], flattened as [x + 5y] +const KECCAK_ROT: number[] = [ + 0, 1, 62, 28, 27, 36, 44, 6, 55, 20, 3, 10, 43, 25, 39, 41, 45, 15, 21, 8, 18, 2, 61, 56, 14, +]; +const M64 = (1n << 64n) - 1n; + +function rotl64(v: bigint, n: number): bigint { + if (n === 0) return v; + return ((v << BigInt(n)) | (v >> BigInt(64 - n))) & M64; +} + +function keccakF1600(s: bigint[]): void { + for (let r = 0; r < 24; r++) { + // theta + const c: bigint[] = new Array(5); + for (let x = 0; x < 5; x++) c[x] = s[x] ^ s[x + 5] ^ s[x + 10] ^ s[x + 15] ^ s[x + 20]; + for (let x = 0; x < 5; x++) { + const d = c[(x + 4) % 5] ^ rotl64(c[(x + 1) % 5], 1); + for (let y = 0; y < 25; y += 5) s[x + y] ^= d; + } + // rho + pi + const b: bigint[] = new Array(25); + for (let x = 0; x < 5; x++) { + for (let y = 0; y < 5; y++) { + b[y + 5 * ((2 * x + 3 * y) % 5)] = rotl64(s[x + 5 * y], KECCAK_ROT[x + 5 * y]); + } + } + // chi + for (let x = 0; x < 5; x++) { + for (let y = 0; y < 25; y += 5) { + s[x + y] = b[x + y] ^ ((~b[((x + 1) % 5) + y] & M64) & b[((x + 2) % 5) + y]); + } + } + // iota + s[0] ^= KECCAK_RC[r]; + } +} + +function keccak256Bytes(input: Uint8Array): Uint8Array { + const rate = 136; // 1088-bit rate for keccak-256 + const s: bigint[] = new Array(25).fill(0n); + const padLen = rate - (input.length % rate); + const padded = new Uint8Array(input.length + padLen); + padded.set(input); + padded[input.length] = 0x01; // keccak (NOT sha3 0x06) domain padding + padded[padded.length - 1] |= 0x80; + for (let off = 0; off < padded.length; off += rate) { + for (let i = 0; i < rate / 8; i++) { + let lane = 0n; + for (let b2 = 7; b2 >= 0; b2--) lane = (lane << 8n) | BigInt(padded[off + i * 8 + b2]); + s[i] ^= lane; + } + keccakF1600(s); + } + const out = new Uint8Array(32); + for (let i = 0; i < 4; i++) { + let lane = s[i]; + for (let b2 = 0; b2 < 8; b2++) { out[i * 8 + b2] = Number(lane & 0xffn); lane >>= 8n; } + } + return out; +} + +function hexToBytes(hexNo0x: string): Uint8Array { + const out = new Uint8Array(hexNo0x.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(hexNo0x.slice(i * 2, i * 2 + 2), 16); + return out; +} + +/** + * Derive a CoreBook orderId exactly like `_nextOrderId`: + * orderId = uint128(keccak256(abi.encode(maker, nonce))) + * The contract pre-increments, so a maker's first order uses nonce = 1 and + * the latest used nonce equals `makerNonce(maker)`. + */ +export function deriveOrderId(maker: string, nonce: bigint): bigint { + const hash = keccak256Bytes(hexToBytes(encAddr(maker) + encUint(nonce))); + let v = 0n; + for (const b of hash.subarray(16)) v = (v << 8n) | BigInt(b); // low 128 bits + return v; +} + +/* --------------------------- price / qty helpers -------------------------- */ + +/** + * Convert a human decimal price string ("63000", "0.0125") into the uint128 + * price the book expects: quote-per-base scaled by PRICE_SCALE = 1e18. + * + * Verified against `_computeQuote`: + * quote = price * qty * 10^quoteDecimals / (1e18 * 10^baseDecimals) + * For qty = 10^baseDecimals (one whole base unit) this yields exactly + * P * 10^quoteDecimals — i.e. the decimals factors CANCEL and + * priceWei = P * 1e18 independent of token decimals. The decimals params + * are kept in the signature for call-site self-documentation + validation. + */ +export function humanPriceToWei(price: string, quoteDecimals: number, baseDecimals: number): bigint { + if (!Number.isInteger(quoteDecimals) || !Number.isInteger(baseDecimals) + || quoteDecimals < 0 || baseDecimals < 0 || quoteDecimals > 77 || baseDecimals > 77) { + throw new Error('CoreBookClient: bad token decimals'); + } + const m = /^(\d+)(?:\.(\d+))?$/.exec(price.trim()); + if (!m) throw new Error(`CoreBookClient: bad price string: ${price}`); + const frac = (m[2] ?? ''); + if (frac.length > 18) throw new Error('CoreBookClient: price has more than 18 decimal places'); + return BigInt(m[1]) * COREBOOK_PRICE_SCALE + BigInt(frac.padEnd(18, '0') || '0'); +} + +/** Inverse of humanPriceToWei — render a 1e18-scaled price as a decimal string. */ +export function weiPriceToHuman(priceWei: bigint): string { + const whole = priceWei / COREBOOK_PRICE_SCALE; + const frac = (priceWei % COREBOOK_PRICE_SCALE).toString().padStart(18, '0').replace(/0+$/, ''); + return frac.length > 0 ? `${whole}.${frac}` : whole.toString(); +} + +/** Snap a price to a multiple of PRICE_TICK (default rounds down). */ +export function snapToTick(price: bigint, priceTick: bigint, mode: 'down' | 'up' | 'nearest' = 'down'): bigint { + if (priceTick <= 0n) throw new Error('CoreBookClient: priceTick must be > 0'); + const rem = price % priceTick; + if (rem === 0n) return price; + if (mode === 'up') return price + (priceTick - rem); + if (mode === 'nearest') return rem * 2n >= priceTick ? price + (priceTick - rem) : price - rem; + return price - rem; +} + +/** Snap a base quantity to a multiple of LOT_SIZE (default rounds down). */ +export function snapToLot(quantity: bigint, lotSize: bigint, mode: 'down' | 'up' | 'nearest' = 'down'): bigint { + return snapToTick(quantity, lotSize, mode); +} + +/** + * Exact mirror of `_computeQuote` (floor division): + * quote = price * qty * quoteFactor / (PRICE_SCALE * baseFactor) + * Factors are 10**decimals (read them from `marketInfo()`). + */ +export function computeQuote(priceWei: bigint, quantity: bigint, quoteFactor: bigint, baseFactor: bigint): bigint { + return (priceWei * quantity * quoteFactor) / (COREBOOK_PRICE_SCALE * baseFactor); +} + +/** + * Exact mirror of `_lockBuyFunds` — the QUOTE amount a BUY order must have + * approved + available: + * principal = _computeQuote(price, qty) (must be > 0) + * feeReserve = ceil(principal * takerFeeBps / 1e4) ((p*bps + 9999) / 10000) + * locked = principal + feeReserve + 256 + * The flat 256-wei buffer is MAX_OPEN_ORDERS_PER_MAKER — covers worst-case + * per-fill fee-ceiling rounding across up to 256 maker fills (AUDIT FIX R6 + * CRITICAL #2). Unspent lock is refunded in the same transaction. + */ +export function computeMaxBuyLock( + priceWei: bigint, quantity: bigint, takerFeeBps: number | bigint, + quoteFactor: bigint, baseFactor: bigint, +): bigint { + const principal = computeQuote(priceWei, quantity, quoteFactor, baseFactor); + if (principal === 0n) throw new Error('CoreBookClient: BUY lock principal is zero (qty*price below min quote unit)'); + const feeReserve = (principal * BigInt(takerFeeBps) + 9999n) / 10_000n; + return principal + feeReserve + COREBOOK_BUY_LOCK_BUFFER; +} + +/* --------------------------------- types --------------------------------- */ + +export interface CoreBookMarketInfo { + base: string; + quote: string; + sink: string; + priceTick: bigint; + lotSize: bigint; + takerFeeBps: number; + quoteDecimalsFactor: bigint; + baseDecimalsFactor: bigint; +} + +/** Decoded `getOrder` view (field order matches the Order struct). */ +export interface CoreBookOrder { + maker: string; + price: bigint; + quantity: bigint; + placedAt: bigint; + placedAtBlock: bigint; + side: Side; + exists: boolean; + cancelDelayBlocks: number; + nextAtPrice: bigint; + prevAtPrice: bigint; +} + +export interface CoreBookOpenOrder extends CoreBookOrder { + orderId: bigint; +} + +export interface CoreBookDepthLevel { + price: bigint; + quantity: bigint; // sum of resting base quantity at this level + orderCount: number; + truncated: boolean; // true if maxOrdersPerLevel cap stopped the walk early +} + +export interface CoreBookDepth { + bids: CoreBookDepthLevel[]; // best (highest) bid first + asks: CoreBookDepthLevel[]; // best (lowest) ask first +} + +/** Minimal eth_getTransactionReceipt log shape. */ +export interface CoreBookLog { + address: string; + topics: string[]; + data: string; + transactionHash?: string; + logIndex?: string | number; +} + +export interface CoreBookEventBase { + address: string; + transactionHash?: string; + logIndex?: number; +} +export interface CoreBookOrderPlacedEvent extends CoreBookEventBase { + type: 'OrderPlaced'; + orderId: bigint; maker: string; side: Side; price: bigint; quantity: bigint; +} +export interface CoreBookOrderCancelledEvent extends CoreBookEventBase { + type: 'OrderCancelled'; + orderId: bigint; maker: string; unfilled: bigint; +} +export interface CoreBookFilledEvent extends CoreBookEventBase { + type: 'Filled'; + takerOrderId: bigint; makerOrderId: bigint; taker: string; maker: string; + takerSide: Side; price: bigint; quantity: bigint; takerFee: bigint; +} +export interface CoreBookClaimedEvent extends CoreBookEventBase { + type: 'Claimed'; + who: string; token: string; amount: bigint; +} +export type CoreBookEvent = + | CoreBookOrderPlacedEvent + | CoreBookOrderCancelledEvent + | CoreBookFilledEvent + | CoreBookClaimedEvent; + +/* ----------------------------- event decoding ---------------------------- */ + +function logMeta(log: CoreBookLog): CoreBookEventBase { + const meta: CoreBookEventBase = { address: log.address.toLowerCase() }; + if (log.transactionHash !== undefined) meta.transactionHash = log.transactionHash; + if (log.logIndex !== undefined) meta.logIndex = Number(BigInt(log.logIndex)); + return meta; +} + +/** + * Decode AereCoreBookV0 logs from a transaction receipt (manual topic match — + * unknown topics are skipped). Pass `book` to filter to one market address. + */ +export function decodeCoreBookLogs(logs: CoreBookLog[], book?: string): CoreBookEvent[] { + const out: CoreBookEvent[] = []; + const filter = book?.toLowerCase(); + for (const log of logs) { + if (filter && log.address.toLowerCase() !== filter) continue; + const t0 = log.topics[0]?.toLowerCase(); + if (t0 === TOPIC_ORDER_PLACED) { + // OrderPlaced(uint128 indexed orderId, address indexed maker, uint8 side, uint128 price, uint128 quantity) + out.push({ + ...logMeta(log), type: 'OrderPlaced', + orderId: BigInt(log.topics[1]), + maker: topicAddr(log.topics[2]), + side: Number(word(log.data, 0)) as Side, + price: word(log.data, 1), + quantity: word(log.data, 2), + }); + } else if (t0 === TOPIC_ORDER_CANCELLED) { + // OrderCancelled(uint128 indexed orderId, address indexed maker, uint128 unfilled) + out.push({ + ...logMeta(log), type: 'OrderCancelled', + orderId: BigInt(log.topics[1]), + maker: topicAddr(log.topics[2]), + unfilled: word(log.data, 0), + }); + } else if (t0 === TOPIC_FILLED) { + // Filled(uint128 indexed takerOrderId, uint128 indexed makerOrderId, address indexed taker, + // address maker, uint8 takerSide, uint128 price, uint128 quantity, uint256 takerFee) + out.push({ + ...logMeta(log), type: 'Filled', + takerOrderId: BigInt(log.topics[1]), + makerOrderId: BigInt(log.topics[2]), + taker: topicAddr(log.topics[3]), + maker: addrWord(log.data, 0), + takerSide: Number(word(log.data, 1)) as Side, + price: word(log.data, 2), + quantity: word(log.data, 3), + takerFee: word(log.data, 4), + }); + } else if (t0 === TOPIC_CLAIMED) { + // Claimed(address indexed who, address indexed token, uint256 amount) + out.push({ + ...logMeta(log), type: 'Claimed', + who: topicAddr(log.topics[1]), + token: topicAddr(log.topics[2]), + amount: word(log.data, 0), + }); + } + } + return out; +} + +/* --------------------------------- client -------------------------------- */ + +export class CoreBookClient { + readonly address: `0x${string}`; + readonly provider: RpcProvider; + + constructor(cfg: CoreBookClientConfig) { + this.address = cfg.address; + this.provider = cfg.provider; + } + + /* ---- market info (immutables) ----------------------------------------- */ + + async marketInfo(): Promise { + const [base, quote, sink, tick, lot, fee, qf, bf] = await Promise.all([ + ethCall(this.provider, this.address, SEL.BASE), + ethCall(this.provider, this.address, SEL.QUOTE), + ethCall(this.provider, this.address, SEL.SINK), + ethCall(this.provider, this.address, SEL.PRICE_TICK), + ethCall(this.provider, this.address, SEL.LOT_SIZE), + ethCall(this.provider, this.address, SEL.TAKER_FEE_BPS), + ethCall(this.provider, this.address, SEL.QUOTE_DECIMALS_FACTOR), + ethCall(this.provider, this.address, SEL.BASE_DECIMALS_FACTOR), + ]); + return { + base: addrWord(base, 0), quote: addrWord(quote, 0), sink: addrWord(sink, 0), + priceTick: word(tick, 0), lotSize: word(lot, 0), + takerFeeBps: Number(word(fee, 0)), + quoteDecimalsFactor: word(qf, 0), baseDecimalsFactor: word(bf, 0), + }; + } + + /* ---- book state -------------------------------------------------------- */ + + /** Highest resting BUY price (0 = bid side empty). */ + async bestBid(): Promise { return word(await ethCall(this.provider, this.address, SEL.bestBid), 0); } + /** Lowest resting SELL price (COREBOOK_ASK_EMPTY sentinel = ask side empty). */ + async bestAsk(): Promise { return word(await ethCall(this.provider, this.address, SEL.bestAsk), 0); } + async pendingSinkFee(): Promise { return word(await ethCall(this.provider, this.address, SEL.pendingSinkFee), 0); } + async makerNonce(maker: string): Promise { + return word(await ethCall(this.provider, this.address, SEL.makerNonce + encAddr(maker)), 0); + } + async openOrderCount(maker: string): Promise { + return word(await ethCall(this.provider, this.address, SEL.openOrderCount + encAddr(maker)), 0); + } + + /** Failed-transfer credits pulled via claimBase/claimQuote. */ + async claimableOf(user: string): Promise<{ base: bigint; quote: bigint }> { + const [b, q] = await Promise.all([ + ethCall(this.provider, this.address, SEL.claimableBase + encAddr(user)), + ethCall(this.provider, this.address, SEL.claimableQuote + encAddr(user)), + ]); + return { base: word(b, 0), quote: word(q, 0) }; + } + + async getOrder(orderId: bigint): Promise { + const d = await ethCall(this.provider, this.address, SEL.getOrder + encUint(orderId)); + // Order struct = 10 static fields → 10 inline words. + return { + maker: addrWord(d, 0), + price: word(d, 1), + quantity: word(d, 2), + placedAt: word(d, 3), + placedAtBlock: word(d, 4), + side: Number(word(d, 5)) as Side, + exists: word(d, 6) === 1n, + cancelDelayBlocks: Number(word(d, 7)), + nextAtPrice: word(d, 8), + prevAtPrice: word(d, 9), + }; + } + + /* ---- lens: depth + open orders (pure view composition) ----------------- */ + + /** + * Aggregate the top `levels` price levels per side by walking the + * populated-level linked lists + per-level FIFO order lists. View-only; + * cost is one eth_call per pointer hop + per order, so keep `levels` + * UI-sized (e.g. 10-25). + */ + async depth(levels: number, opts: { maxOrdersPerLevel?: number } = {}): Promise { + const cap = opts.maxOrdersPerLevel ?? 256; + const [bb, ba] = await Promise.all([this.bestBid(), this.bestAsk()]); + const asks: CoreBookDepthLevel[] = []; + const bids: CoreBookDepthLevel[] = []; + + let ap = ba; + while (ap !== COREBOOK_ASK_EMPTY && ap !== 0n && asks.length < levels) { + asks.push(await this._levelDepth(false, ap, cap)); + ap = await this._readPriceMap(SEL.nextPopulatedAsk, ap); // 0 = end of list + } + let bp = bb; + while (bp !== COREBOOK_BID_EMPTY && bids.length < levels) { + bids.push(await this._levelDepth(true, bp, cap)); + bp = await this._readPriceMap(SEL.nextPopulatedBid, bp); // 0 = end of list + } + return { bids, asks }; + } + + /** + * Enumerate a maker's resting orders WITHOUT an enumeration view on the + * contract: derive orderIds from makerNonce (latest first, see + * `deriveOrderId`) and probe `getOrder` until `openOrderCount` orders are + * found or `maxScan` nonces have been checked (high-churn makers may need + * a larger budget). + */ + async openOrdersOf(maker: string, opts: { maxScan?: number } = {}): Promise { + const [nonce, open] = await Promise.all([this.makerNonce(maker), this.openOrderCount(maker)]); + if (open === 0n || nonce === 0n) return []; + const maxScan = BigInt(opts.maxScan ?? 4096); + const makerLc = maker.toLowerCase(); + const found: CoreBookOpenOrder[] = []; + const BATCH = 16; + let scanned = 0n; + let n = nonce; + while (n >= 1n && scanned < maxScan && BigInt(found.length) < open) { + const batch: bigint[] = []; + while (batch.length < BATCH && n >= 1n && scanned < maxScan) { batch.push(n); n -= 1n; scanned += 1n; } + const orders = await Promise.all(batch.map(async (nn) => { + const orderId = deriveOrderId(maker, nn); + return { orderId, order: await this.getOrder(orderId) }; + })); + for (const { orderId, order } of orders) { + if (order.exists && order.maker.toLowerCase() === makerLc) { + found.push({ orderId, ...order }); + if (BigInt(found.length) >= open) break; + } + } + } + return found; + } + + /* ---- writes (caller signs via EIP-1193 from) ---------------------------- */ + + /** + * GTC limit order via `place` (default 2-block cancel delay) or + * `placeProtected` when `cancelDelayBlocks` is given. REVERTS with + * CrossingRequiresSlippage if the order would cross — route crossing + * orders through placeIOC / placeWithSlippage instead. + */ + async placeGTC(from: string, side: Side, price: bigint, quantity: bigint, cancelDelayBlocks?: number): Promise { + if (cancelDelayBlocks === undefined) { + return ethSend(this.provider, from, this.address, + SEL.place + encUint(side) + encUint(price) + encUint(quantity) + encUint(0 /* GTC */)); + } + if (cancelDelayBlocks < 0 || cancelDelayBlocks > COREBOOK_MAX_CANCEL_DELAY_BLOCKS) { + throw new Error('CoreBookClient: cancelDelayBlocks out of range (0-1200)'); + } + return ethSend(this.provider, from, this.address, + SEL.placeProtected + encUint(side) + encUint(price) + encUint(quantity) + + encUint(0 /* GTC */) + encUint(cancelDelayBlocks)); + } + + /** + * IOC sweep via `iocFill` — fills whatever crosses up to `worstPrice`, + * discards the rest. Average price is only capped at worstPrice; for a + * tighter band use placeWithSlippage with explicit maxAvgPriceWei. + */ + async placeIOC(from: string, side: Side, worstPrice: bigint, quantity: bigint): Promise { + return ethSend(this.provider, from, this.address, + SEL.iocFill + encUint(side) + encUint(worstPrice) + encUint(quantity)); + } + + /** + * Recommended entrypoint for crossing orders. Pass maxQuoteSpent = + * COREBOOK_UINT256_MAX-style no-cap via (1n<<256n)-1n if unused; set + * maxAvgPriceWei = 0n to disable the average-price band. + */ + async placeWithSlippage( + from: string, side: Side, price: bigint, quantity: bigint, tif: TimeInForce, + minBaseFilled: bigint, maxQuoteSpent: bigint, maxAvgPriceWei: bigint, + ): Promise { + return ethSend(this.provider, from, this.address, + SEL.placeWithSlippage + encUint(side) + encUint(price) + encUint(quantity) + encUint(tif) + + encUint(minBaseFilled) + encUint(maxQuoteSpent) + encUint(maxAvgPriceWei)); + } + + /** Full protection: slippage bounds + per-order cancel delay. */ + async placeWithSlippageProtected( + from: string, side: Side, price: bigint, quantity: bigint, tif: TimeInForce, + minBaseFilled: bigint, maxQuoteSpent: bigint, cancelDelayBlocks: number, maxAvgPriceWei: bigint, + ): Promise { + if (cancelDelayBlocks < 0 || cancelDelayBlocks > COREBOOK_MAX_CANCEL_DELAY_BLOCKS) { + throw new Error('CoreBookClient: cancelDelayBlocks out of range (0-1200)'); + } + return ethSend(this.provider, from, this.address, + SEL.placeWithSlippageProtected + encUint(side) + encUint(price) + encUint(quantity) + encUint(tif) + + encUint(minBaseFilled) + encUint(maxQuoteSpent) + encUint(cancelDelayBlocks) + encUint(maxAvgPriceWei)); + } + + async cancel(from: string, orderId: bigint): Promise { + return ethSend(this.provider, from, this.address, SEL.cancel + encUint(orderId)); + } + + /** Pull failed-transfer BASE credits (claim(true)). */ + async claimBase(from: string): Promise { + return ethSend(this.provider, from, this.address, SEL.claim + encUint(1)); + } + /** Pull failed-transfer QUOTE credits (claim(false)). */ + async claimQuote(from: string): Promise { + return ethSend(this.provider, from, this.address, SEL.claim + encUint(0)); + } + + /** Permissionless — forwards pendingSinkFee to AereSink. */ + async flushFees(from: string): Promise { + return ethSend(this.provider, from, this.address, SEL.flushFees); + } + + /* ---- ERC-20 approve flow ------------------------------------------------ */ + + /** Current allowance granted by `owner` to THIS book for `token`. */ + async allowanceForBook(token: string, owner: string): Promise { + const d = await ethCall(this.provider, token, SEL.allowance + encAddr(owner) + encAddr(this.address)); + return word(d, 0); + } + + /** ERC-20 balance helper (base/quote funding checks before placing). */ + async balanceOf(token: string, owner: string): Promise { + return word(await ethCall(this.provider, token, SEL.balanceOf + encAddr(owner)), 0); + } + + /** approve(book, amount) on `token` from `from`. */ + async approveForBook(token: string, from: string, amount: bigint): Promise { + return ethSend(this.provider, from, token, SEL.approve + encAddr(this.address) + encUint(amount)); + } + + /** + * Checks allowance and approves only when insufficient. Returns the + * approve tx hash, or null when the existing allowance already covers + * `amount`. For BUY orders pass `computeMaxBuyLock(...)` as the amount; + * for SELL orders pass the base quantity. + */ + async ensureApprovalForBook(token: string, from: string, amount: bigint): Promise { + const current = await this.allowanceForBook(token, from); + if (current >= amount) return null; + return this.approveForBook(token, from, amount); + } + + /* ---- internals ---------------------------------------------------------- */ + + private async _readPriceMap(selector: string, price: bigint): Promise { + return word(await ethCall(this.provider, this.address, selector + encUint(price)), 0); + } + + private async _levelDepth(isBid: boolean, price: bigint, cap: number): Promise { + const headSel = isBid ? SEL.bidHeadAtPrice : SEL.askHeadAtPrice; + let id = await this._readPriceMap(headSel, price); + let quantity = 0n; + let orderCount = 0; + while (id !== 0n && orderCount < cap) { + const o = await this.getOrder(id); + if (!o.exists) break; // transient inconsistency guard + quantity += o.quantity; + orderCount += 1; + id = o.nextAtPrice; + } + return { price, quantity, orderCount, truncated: id !== 0n && orderCount >= cap }; + } +} diff --git a/src/corebook/index.ts b/src/corebook/index.ts index b3d50dc..16dad51 100644 --- a/src/corebook/index.ts +++ b/src/corebook/index.ts @@ -1 +1,6 @@ +// AereCoreBookV0 — native CLOB, one contract per (base, quote) market. +// CoreBookClient is the full read/write client + off-chain lens (depth, +// open-order enumeration, event decoding, price/qty/lock math helpers). +// AereCoreBookClient is the lighter calldata-builder variant. +export * from './CoreBookClient.js'; export * from './AereCoreBookClient.js'; diff --git a/src/index.ts b/src/index.ts index f517762..ff1d26f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export { AereClient, type AereClientOptions } from './client.js'; -export { AERE_MAINNET, type AereContractName } from './addresses.js'; +export { AERE_MAINNET, AERE_COREBOOK, type AereContractName, type CoreBookMarketSymbol } from './addresses.js'; export { ERC20_ABI, WAERE_ABI, STAKING_V2_ABI, STAKING_V1_ABI, LENDING_ABI, STABLE_ABI, IDENTITY_ABI, BRIDGE_ABI, FAUCET_ABI, SWAP_FACTORY_ABI, SWAP_PAIR_ABI, LIGHTNING_CHANNELS_ABI,