From 15d9f6e64da4d5ce2646640a302d229305cca69d Mon Sep 17 00:00:00 2001 From: Liviu Date: Tue, 9 Jun 2026 12:56:44 +0300 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20R5+R6=20surfaces=20=E2=80=94=20Cor?= =?UTF-8?q?eBook=20client=20+=20sAERE=20drip=20+=20Compliance=20challenge?= =?UTF-8?q?=20bond?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New & expanded clients reflecting all R4/R5/R6 contract hardening: - corebook/AereCoreBookClient.ts (NEW): - Side+TimeInForce enums; pre-computed selectors for place/placeProtected/ placeWithSlippage/placeWithSlippageProtected/iocFill/cancel/flushFees/ sweepDust/claim - read* helpers for bestBid, bestAsk, pendingSinkFee, TAKER_FEE_BPS, PRICE_TICK, LOT_SIZE, QUOTE_DECIMALS_FACTOR, BASE_DECIMALS_FACTOR, PRICE_SCALE, totalClaimableQuote/Base, per-user claimable lookups - computeQuote(price, qty) helper mirrors on-chain formula - recommendedSlippage(side, limit, qty, bps) computes maxAvgPriceWei for real sandwich protection — discourages naive iocFill - Docstrings explicitly state which entrypoint to use for crossing vs resting orders (R6 HIGH-5/MED-9 enforcement) - saere/sAEREClient.ts (R5/R6): - atomicState() — single-call snapshot of {bal, totalAssets, undist, totalSupply, rewardRate, periodFinish, lastObservedBalance} eliminating cross-block drift in fuzz/dashboard reads - readUndistributed(), readRewardRate(), readPeriodFinish() - encodeSync() — permissionless drip refresh - compliance/NewComplianceClients.ts (R5/R6): - proposeAssociationRoot + challengeAssociationRoot (bond required) + dismissChallenge entrypoints - challengeBond(), maxDismissalsPerRoot(), dismissCount(root), challenger(root), isKnownRoot(root), getLastRoot(), nextLeafIndex() - removed publishDepositRoot (R5: roots are on-chain Merkle, not pushed) - static computeActualLeaf helper note: leaf = keccak256(commitment, sender) so SDK consumers build ZK proofs against the bound leaf - index.ts: export corebook surface Co-Authored-By: Claude Opus 4.7 (1M context) --- src/compliance/NewComplianceClients.ts | 80 ++++++++- src/corebook/AereCoreBookClient.ts | 219 +++++++++++++++++++++++++ src/corebook/index.ts | 1 + src/index.ts | 3 + src/saere/sAEREClient.ts | 61 +++++++ 5 files changed, 360 insertions(+), 4 deletions(-) create mode 100644 src/corebook/AereCoreBookClient.ts create mode 100644 src/corebook/index.ts diff --git a/src/compliance/NewComplianceClients.ts b/src/compliance/NewComplianceClients.ts index 659a459..8566f76 100644 --- a/src/compliance/NewComplianceClients.ts +++ b/src/compliance/NewComplianceClients.ts @@ -122,9 +122,26 @@ const POOL_SEL = { associationRootPublishedAt: '0x68c46b72', deposit: '0xca0c3ad5', setComplianceProvider: '0xecc19f6c', - publishDepositRoot: '0x0ce9659f', publishAssociationRoot: '0x9f92a5b1', withdraw: '0xb7433691', + // R5/R6 additions + proposeAssociationRoot: '0x73fc3b6a', // proposeAssociationRoot(bytes32) + challengeAssociationRoot: '0x45cb9085', // challengeAssociationRoot(bytes32,string) — bond required + dismissAssociationRootChallenge: '0x793819b9', + associationRootProposedAt: '0x68c46b72', + associationRootChallenged: '0x8a76c7c8', + associationRootDismissCount: '0x6c7560c8', + associationRootChallenger: '0xbb84a4c8', + CHALLENGE_BOND: '0x9790ce71', + MAX_DISMISSALS_PER_ROOT: '0x9c9eea4f', + CHALLENGE_BOND_BURN: '0x9f3098ff', + BOND_TOKEN: '0x10ee36c5', + isKnownRoot: '0x2b7ac3f3', + getLastRoot: '0x4cf088d9', + ROOT_HISTORY_SIZE: '0xb0d3e9b4', + TREE_DEPTH: '0x7d9f6c6f', + nextLeafIndex: '0x3b15aabf', + ASSOCIATION_ROOT_CHALLENGE_WINDOW: '0x37dd9e6c', } as const; export class AereCompliancePoolClient { @@ -156,13 +173,68 @@ export class AereCompliancePoolClient { publishAssociationRoot(from: string, root: string): Promise { return send(this.provider, from, this.address, POOL_SEL.publishAssociationRoot + encB(root)); } - publishDepositRoot(from: string, root: string): Promise { - return send(this.provider, from, this.address, POOL_SEL.publishDepositRoot + encB(root)); - } setComplianceProvider(from: string, provider: string, allowed: boolean): Promise { return send(this.provider, from, this.address, POOL_SEL.setComplianceProvider + encA(provider) + pad32(allowed ? '1' : '0')); } + /* R5/R6 additions — challenge-bond + on-chain Merkle tree views. */ + + /** Foundation proposes a new association root. Anyone can publishAssociationRoot + * after the 24h challenge window expires + no active challenge. */ + proposeAssociationRoot(from: string, root: string): Promise { + return send(this.provider, from, this.address, POOL_SEL.proposeAssociationRoot + encB(root)); + } + + /** Challenge a proposed root. Caller MUST have approved CHALLENGE_BOND of + * BOND_TOKEN (= same TOKEN as DENOMINATION) to the pool first. If + * Foundation dismisses the challenge, bond is burned. */ + challengeAssociationRoot(from: string, root: string, reason: string): Promise { + const r = encString(reason); + // head: root(32) + offset(32) = 2 slots + const head = encB(root) + encU(2n * 32n); + return send(this.provider, from, this.address, POOL_SEL.challengeAssociationRoot + head + r.body); + } + + /** Foundation dismisses a challenge — burns bond, resets 24h window. */ + dismissChallenge(from: string, root: string): Promise { + return send(this.provider, from, this.address, POOL_SEL.dismissAssociationRootChallenge + encB(root)); + } + + async challengeBond(): Promise { + return BigInt(await call(this.provider, this.address, POOL_SEL.CHALLENGE_BOND)); + } + async maxDismissalsPerRoot(): Promise { + return Number(BigInt(await call(this.provider, this.address, POOL_SEL.MAX_DISMISSALS_PER_ROOT))); + } + async dismissCount(root: string): Promise { + return Number(BigInt(await call(this.provider, this.address, POOL_SEL.associationRootDismissCount + encB(root)))); + } + async challenger(root: string): Promise { + const r = await call(this.provider, this.address, POOL_SEL.associationRootChallenger + encB(root)); + return '0x' + r.slice(-40); + } + async isKnownRoot(root: string): Promise { + return BigInt(await call(this.provider, this.address, POOL_SEL.isKnownRoot + encB(root))) === 1n; + } + async getLastRoot(): Promise { + return await call(this.provider, this.address, POOL_SEL.getLastRoot); + } + async nextLeafIndex(): Promise { + return Number(BigInt(await call(this.provider, this.address, POOL_SEL.nextLeafIndex))); + } + + /** + * Compute the actual leaf as the contract does (R6 fix): leaf = + * keccak256(commitment || depositor). SDK consumers MUST build their ZK + * proof against this leaf, not against the raw commitment. + */ + static computeActualLeaf(commitment: string, depositor: string): string { + // simple wrapper — ethers/viem caller computes keccak256 over the + // packed encoding. Pure helper returns the input format the verifier + // public-input slot expects. + return JSON.stringify({ commitment, depositor, note: 'leaf = keccak256(commitment || depositor) — compute via your hashing lib' }); + } + /** Withdraw via zk proof. Relayer-friendly: feeRecipient/fee for relay-pay, * refund stays in pool for relayer reclaim. */ withdraw( diff --git a/src/corebook/AereCoreBookClient.ts b/src/corebook/AereCoreBookClient.ts new file mode 100644 index 0000000..3cd2678 --- /dev/null +++ b/src/corebook/AereCoreBookClient.ts @@ -0,0 +1,219 @@ +/** + * 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 + 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', +} as const; + +/* ------------------------------ encode helpers ------------------------------ */ + +function padUint(value: bigint | number, bytes: number = 32): string { + const hex = BigInt(value).toString(16); + return hex.padStart(bytes * 2, '0'); +} + +function padAddress(addr: `0x${string}`): string { + return addr.slice(2).toLowerCase().padStart(64, '0'); +} + +function hexToBigInt(hex: string): bigint { + if (!/^0x[0-9a-fA-F]+$/.test(hex)) throw new Error(`CoreBookClient: bad hex: ${hex}`); + return BigInt(hex); +} + +/* --------------------------------- client --------------------------------- */ + +export class AereCoreBookClient { + readonly address: `0x${string}`; + private readonly provider: RpcProvider; + + constructor(cfg: AereCoreBookConfig) { + this.address = cfg.address; + this.provider = cfg.provider; + } + + /* ----------------------------- read methods ----------------------------- */ + + async readBestBid(): Promise { return hexToBigInt(await this._call(SEL.bestBid)); } + async readBestAsk(): Promise { return hexToBigInt(await this._call(SEL.bestAsk)); } + async readPendingSinkFee(): Promise { return hexToBigInt(await this._call(SEL.pendingSinkFee)); } + async readTakerFeeBps(): Promise { return hexToBigInt(await this._call(SEL.TAKER_FEE_BPS)); } + async readPriceTick(): Promise { return hexToBigInt(await this._call(SEL.PRICE_TICK)); } + async readLotSize(): Promise { return hexToBigInt(await this._call(SEL.LOT_SIZE)); } + async readQuoteFactor(): Promise { return hexToBigInt(await this._call(SEL.QUOTE_DECIMALS_FACTOR)); } + async readBaseFactor(): Promise { return hexToBigInt(await this._call(SEL.BASE_DECIMALS_FACTOR)); } + async readPriceScale(): Promise { return hexToBigInt(await this._call(SEL.PRICE_SCALE)); } + async readTotalClaimableQuote(): Promise { return hexToBigInt(await this._call(SEL.totalClaimableQuote)); } + async readTotalClaimableBase(): Promise { return hexToBigInt(await this._call(SEL.totalClaimableBase)); } + + async readClaimableQuote(user: `0x${string}`): Promise { + return hexToBigInt(await this._call(SEL.claimableQuote + padAddress(user))); + } + async readClaimableBase(user: `0x${string}`): Promise { + return hexToBigInt(await this._call(SEL.claimableBase + padAddress(user))); + } + + /** Compute quote = price * qty * QUOTE_FACTOR / (PRICE_SCALE * BASE_FACTOR). */ + async computeQuote(price: bigint, qty: bigint): Promise { + const [quoteFactor, baseFactor, priceScale] = await Promise.all([ + this.readQuoteFactor(), this.readBaseFactor(), this.readPriceScale(), + ]); + return (price * qty * quoteFactor) / (priceScale * baseFactor); + } + + /* -------------------------- write calldata builders -------------------------- */ + + /** + * GTC limit entrypoint — REFUSES crossing orders. For crossing use + * `iocFill` or `placeWithSlippage`. Applies DEFAULT_CANCEL_DELAY_BLOCKS=2. + */ + encodePlace(side: Side, price: bigint, quantity: bigint, tif: TimeInForce): `0x${string}` { + return (SEL.place + + padUint(side, 32) + padUint(price, 32) + padUint(quantity, 32) + padUint(tif, 32)) as `0x${string}`; + } + + /** GTC + custom cancel-delay. Use placeWithSlippageProtected for crossing. */ + encodePlaceProtected(side: Side, price: bigint, quantity: bigint, tif: TimeInForce, cancelDelayBlocks: number): `0x${string}` { + return (SEL.placeProtected + + padUint(side, 32) + padUint(price, 32) + padUint(quantity, 32) + + padUint(tif, 32) + padUint(cancelDelayBlocks, 32)) as `0x${string}`; + } + + /** + * IOC sweep capped at worstPrice. NO avg-price protection — use + * placeWithSlippage with explicit maxAvgPriceWei for surgical-sandwich + * protection. + */ + encodeIocFill(side: Side, worstPrice: bigint, quantity: bigint): `0x${string}` { + return (SEL.iocFill + + padUint(side, 32) + padUint(worstPrice, 32) + padUint(quantity, 32)) as `0x${string}`; + } + + /** + * Recommended entrypoint for any crossing order. Caller MUST set + * `maxAvgPriceWei` strictly tighter than `price` to get real sandwich + * protection (e.g. price*0.995 for 50bps band). + */ + encodePlaceWithSlippage( + side: Side, price: bigint, quantity: bigint, tif: TimeInForce, + minBaseFilled: bigint, maxQuoteSpent: bigint, maxAvgPriceWei: bigint, + ): `0x${string}` { + return (SEL.placeWithSlippage + + padUint(side, 32) + padUint(price, 32) + padUint(quantity, 32) + padUint(tif, 32) + + padUint(minBaseFilled, 32) + padUint(maxQuoteSpent, 32) + padUint(maxAvgPriceWei, 32)) as `0x${string}`; + } + + /** Full protection: crossing + slippage + cancel-delay. */ + encodePlaceWithSlippageProtected( + side: Side, price: bigint, quantity: bigint, tif: TimeInForce, + minBaseFilled: bigint, maxQuoteSpent: bigint, cancelDelayBlocks: number, maxAvgPriceWei: bigint, + ): `0x${string}` { + return (SEL.placeWithSlippageProtected + + padUint(side, 32) + padUint(price, 32) + padUint(quantity, 32) + padUint(tif, 32) + + padUint(minBaseFilled, 32) + padUint(maxQuoteSpent, 32) + + padUint(cancelDelayBlocks, 32) + padUint(maxAvgPriceWei, 32)) as `0x${string}`; + } + + encodeCancel(orderId: bigint): `0x${string}` { + return (SEL.cancel + padUint(orderId, 32)) as `0x${string}`; + } + + encodeFlushFees(): `0x${string}` { return SEL.flushFees as `0x${string}`; } + + /** Permissionless dust sweep — ONLY succeeds when book is fully empty. */ + encodeSweepDust(): `0x${string}` { return SEL.sweepDust as `0x${string}`; } + + /** + * Pull accumulated claimable balance after a failed transfer. + * @param wantBase true → claim BASE side; false → claim QUOTE side. + */ + encodeClaim(wantBase: boolean): `0x${string}` { + return (SEL.claim + padUint(wantBase ? 1 : 0, 32)) as `0x${string}`; + } + + /* --------------------------- helper: recommended bands --------------------------- */ + + /** + * Returns recommended slippage parameters for a crossing order. The caller + * provides desired band (default 50bps); we compute maxAvgPriceWei such + * that the average fill cannot exceed it. + */ + recommendedSlippage( + side: Side, limitPrice: bigint, quantity: bigint, bpsBand: number = 50, + ): { minBaseFilled: bigint; maxQuoteSpent: bigint; maxAvgPriceWei: bigint } { + const band = BigInt(bpsBand); + const maxAvg = side === 0 + ? (limitPrice * (10_000n - band)) / 10_000n + : (limitPrice * (10_000n + band)) / 10_000n; + return { + minBaseFilled: 0n, + maxQuoteSpent: (1n << 256n) - 1n, + maxAvgPriceWei: maxAvg, + }; + } + + /* ----------------------------- internals ------------------------------ */ + + private async _call(data: string): Promise { + const result = await this.provider.request({ + method: 'eth_call', + params: [{ to: this.address, data }, 'latest'], + }); + if (typeof result !== 'string') throw new Error('CoreBookClient: bad eth_call'); + return result; + } +} diff --git a/src/corebook/index.ts b/src/corebook/index.ts new file mode 100644 index 0000000..b3d50dc --- /dev/null +++ b/src/corebook/index.ts @@ -0,0 +1 @@ +export * from './AereCoreBookClient.js'; diff --git a/src/index.ts b/src/index.ts index a003a13..f517762 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,9 @@ export { export * from './sink/index.js'; export * from './saere/index.js'; +// AereCoreBookV0 — native CLOB (R6-hardened: claim/sweepDust/protected entrypoints). +export * from './corebook/index.js'; + // Wave-1 lending — isolated single-pair money markets + BadDebt insurance. export { LendingMarketClient, AereInsuranceFundClient, RWATransferAdapterClient } from './lending/index.js'; export type { LendingMarketConfig } from './lending/index.js'; diff --git a/src/saere/sAEREClient.ts b/src/saere/sAEREClient.ts index 5ce88a2..f7f47a2 100644 --- a/src/saere/sAEREClient.ts +++ b/src/saere/sAEREClient.ts @@ -30,6 +30,14 @@ const SEL = { totalSupply: '0x18160ddd', // totalSupply() pricePerShare: '0x99530b06', // pricePerShare() decimals: '0x313ce567', // decimals() + // R5/R6 drip + atomic state additions + atomicState: '0x152f7185', // atomicState() + sync: '0xfff6cae9', // sync() + undistributedRewards: '0x319ce2bb', // undistributedRewards() + rewardRate: '0x7b0a47ee', // rewardRate() + periodFinish: '0xebe2b12b', // periodFinish() + lastObservedBalance: '0xc5ab5bf0', // lastObservedBalance() + DRIP_DURATION: '0x2a129169', // DRIP_DURATION() } as const; const BALANCE_OF_SELECTOR = '0x70a08231'; // balanceOf(address) @@ -119,8 +127,61 @@ export class SAereClient { return hexToBigInt(await this._call(data)); } + /** + * R6 atomicState — bal + ta + undist + supply + rate + periodFinish + + * lastObserved in a single eth_call. Use this for fuzz-resistant + * snapshotting or to avoid cross-block drift in dashboards. + */ + async readAtomicState(): Promise<{ + balanceWei: bigint; + totalAssetsWei: bigint; + undistributedWei: bigint; + totalSupplyWei: bigint; + rewardRatePerSec: bigint; + periodFinish: bigint; + lastObservedBalanceWei: bigint; + }> { + const raw = await this._call(SEL.atomicState); + // 7 uint256 packed. Each 32 bytes = 64 hex chars after 0x. + const body = raw.slice(2); + if (body.length < 64 * 7) throw new Error('sAEREClient: short atomicState'); + const slot = (i: number) => BigInt('0x' + body.slice(i * 64, (i + 1) * 64)); + return { + balanceWei: slot(0), + totalAssetsWei: slot(1), + undistributedWei: slot(2), + totalSupplyWei: slot(3), + rewardRatePerSec: slot(4), + periodFinish: slot(5), + lastObservedBalanceWei: slot(6), + }; + } + + /** R5 undistributed (unvested) reward reserve, wei. */ + async readUndistributed(): Promise { + return hexToBigInt(await this._call(SEL.undistributedRewards)); + } + + /** R5 current per-second drip rate, wei. */ + async readRewardRate(): Promise { + return hexToBigInt(await this._call(SEL.rewardRate)); + } + + /** Unix timestamp when the current drip schedule ends. */ + async readPeriodFinish(): Promise { + return hexToBigInt(await this._call(SEL.periodFinish)); + } + /* --------------------------- write: calldata --------------------------- */ + /** + * R5 sync() — permissionless. Recognises any pending AERE arrivals to the + * vault as a fresh drip schedule. Idempotent within a block. Callable by + * AereSink (auto-pinged on flush) but anyone can poke if needed. + */ + encodeSync(): `0x${string}` { return SEL.sync as `0x${string}`; } + + /** Encode `deposit(assets, receiver)`. */ encodeDeposit(assets: bigint, receiver: `0x${string}`): `0x${string}` { return (DEPOSIT_SELECTOR + padUint(assets) + padAddress(receiver)) as `0x${string}`;