sdk-js/src/corebook/AereCoreBookClient.ts
Liviu b45682d578 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>
2026-06-11 10:44:18 +03:00

221 lines
10 KiB
TypeScript

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