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 <noreply@anthropic.com>
This commit is contained in:
Liviu 2026-06-11 10:44:18 +03:00
parent 094688d1d1
commit b45682d578
5 changed files with 760 additions and 17 deletions

View File

@ -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<string, `0x${string}`>,
} as const;
export type CoreBookMarketSymbol = keyof typeof AERE_COREBOOK.markets;

View File

@ -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 ------------------------------ */

View File

@ -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<string> {
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<string> {
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<CoreBookMarketInfo> {
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<bigint> { 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<bigint> { return word(await ethCall(this.provider, this.address, SEL.bestAsk), 0); }
async pendingSinkFee(): Promise<bigint> { return word(await ethCall(this.provider, this.address, SEL.pendingSinkFee), 0); }
async makerNonce(maker: string): Promise<bigint> {
return word(await ethCall(this.provider, this.address, SEL.makerNonce + encAddr(maker)), 0);
}
async openOrderCount(maker: string): Promise<bigint> {
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<CoreBookOrder> {
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<CoreBookDepth> {
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<CoreBookOpenOrder[]> {
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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
return ethSend(this.provider, from, this.address, SEL.cancel + encUint(orderId));
}
/** Pull failed-transfer BASE credits (claim(true)). */
async claimBase(from: string): Promise<string> {
return ethSend(this.provider, from, this.address, SEL.claim + encUint(1));
}
/** Pull failed-transfer QUOTE credits (claim(false)). */
async claimQuote(from: string): Promise<string> {
return ethSend(this.provider, from, this.address, SEL.claim + encUint(0));
}
/** Permissionless — forwards pendingSinkFee to AereSink. */
async flushFees(from: string): Promise<string> {
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<bigint> {
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<bigint> {
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<string> {
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<string | null> {
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<bigint> {
return word(await ethCall(this.provider, this.address, selector + encUint(price)), 0);
}
private async _levelDepth(isBid: boolean, price: bigint, cap: number): Promise<CoreBookDepthLevel> {
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 };
}
}

View File

@ -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';

View File

@ -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,