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:
parent
094688d1d1
commit
b45682d578
@ -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;
|
||||
|
||||
@ -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 ------------------------------ */
|
||||
|
||||
724
src/corebook/CoreBookClient.ts
Normal file
724
src/corebook/CoreBookClient.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user