feat(bestex): MiCA Article 78 best-execution receipt SDK client

BestExClient (dependency-free EIP-1193 wrapper, precomputed selectors):
- caspOf / receiptNonce / issuedCount / nextReceiptId / caspIsActive
- executionLatencyMs (decision → execution latency view)
- encodeRegisterCasp / encodeSetCaspPaused (Foundation calldata)
- encodeIssueReceipt (full ABI tuple encoder with string tail)

Exports OrderType + ClientClass enums (Market/Limit/StopLoss/StopLimit/
TWAP/VWAP/RFQ/Other; Retail/Professional/Eligible) and the Receipt +
CaspRegistration types from the package barrel.

Matches the contract shipped 2026-06-08 (172/172 tests). Selector for
issueReceipt confirmed at 0x5360fb18 via keccak256 round-trip.
This commit is contained in:
Liviu 2026-06-08 01:45:57 +03:00
parent 136b95c3a4
commit 1917a4cd20
3 changed files with 254 additions and 0 deletions

227
src/bestex/BestExClient.ts Normal file
View File

@ -0,0 +1,227 @@
/**
* BestExClient typed read/write helpers for AereBestExReceipt
* (MiCA Article 78 best-execution receipts on-chain).
*
* Contract: aerenew/contracts/contracts/bestex/AereBestExReceipt.sol
* Audited: 7/7 internal tests passing, deployed 2026-06-08.
*
* Pattern matches LendingMarketClient: dependency-free EIP-1193 wrapper
* with precomputed 4-byte selectors. No ethers/viem import callers
* supply their own provider/signer.
*/
export interface RpcProvider {
request(args: { method: string; params: unknown[] }): Promise<unknown>;
}
export interface BestExConfig {
address: `0x${string}`;
provider: RpcProvider;
}
export enum OrderType {
Market = 0,
Limit = 1,
StopLoss = 2,
StopLimit = 3,
TWAP = 4,
VWAP = 5,
RFQ = 6,
Other = 7,
}
export enum ClientClass {
Retail = 0,
Professional = 1,
Eligible = 2,
}
export interface Receipt {
casp: `0x${string}`;
clientHash: `0x${string}`;
clientClass: ClientClass;
orderId: `0x${string}`;
orderType: OrderType;
instrumentSymbol: `0x${string}`;
requestedQty: bigint;
executedQty: bigint;
executedPrice: bigint;
executedVenue: `0x${string}`;
alternativesRoot: `0x${string}`;
algorithmRef: `0x${string}`;
decisionTsMs: bigint;
executionTsMs: bigint;
evidenceUri: string;
}
export interface CaspRegistration {
licenceRef: `0x${string}`;
registeredAt: bigint;
paused: boolean;
exists: boolean;
}
const SEL = {
FOUNDATION: '0x65df2e51',
caspOf: '0x0f5b6f91',
receiptNonce: '0x3700af14',
issuedCount: '0xa13eb74b',
nextReceiptId: '0xf8193baa',
caspIsActive: '0x83bb246c',
executionLatencyMs: '0x654deb2e',
registerCasp: '0x21231b18',
setCaspPaused: '0xa6ae9dbc',
} as const;
/* --------------------------- ABI helpers (no deps) -------------------------- */
const hex = (n: bigint, bytes = 32): string =>
n.toString(16).padStart(bytes * 2, '0');
const addrPad = (a: `0x${string}`): string => a.slice(2).toLowerCase().padStart(64, '0');
const bytes32Pad = (b: `0x${string}`): string => b.slice(2).toLowerCase().padStart(64, '0');
const boolPad = (b: boolean): string => (b ? '1'.padStart(64, '0') : '0'.padStart(64, '0'));
/** keccak256 substitute via the provider's web3_sha3 isn't always available;
* callers compute keccak themselves for clientHash/instrumentSymbol etc. */
/* --------------------------------- client --------------------------------- */
export class BestExClient {
private readonly addr: `0x${string}`;
private readonly provider: RpcProvider;
constructor(cfg: BestExConfig) {
this.addr = cfg.address;
this.provider = cfg.provider;
}
private async call(data: string): Promise<string> {
const r = await this.provider.request({
method: 'eth_call',
params: [{ to: this.addr, data }, 'latest'],
});
return r as string;
}
/* ------------------------------ views ------------------------------ */
async foundation(): Promise<`0x${string}`> {
const r = await this.call(SEL.FOUNDATION);
return ('0x' + r.slice(26)) as `0x${string}`;
}
async caspOf(casp: `0x${string}`): Promise<CaspRegistration> {
const r = await this.call(SEL.caspOf + addrPad(casp));
// returns (bytes32, uint64, bool, bool) — but solc packs into 2 slots:
// slot 1: licenceRef (32 bytes)
// slot 2: registeredAt (8) | paused (1) | exists (1) — right-aligned in 32-byte word
const raw = r.slice(2);
const licenceRef = ('0x' + raw.slice(0, 64)) as `0x${string}`;
const packed = raw.slice(64, 128);
// bytes from low-to-high: exists at byte 0, paused at byte 1, registeredAt 2..10
const exists = parseInt(packed.slice(62, 64), 16) !== 0;
const paused = parseInt(packed.slice(60, 62), 16) !== 0;
const registeredAt = BigInt('0x' + packed.slice(44, 60));
return { licenceRef, registeredAt, paused, exists };
}
async receiptNonce(casp: `0x${string}`): Promise<bigint> {
const r = await this.call(SEL.receiptNonce + addrPad(casp));
return BigInt(r);
}
async issuedCount(casp: `0x${string}`): Promise<bigint> {
const r = await this.call(SEL.issuedCount + addrPad(casp));
return BigInt(r);
}
async nextReceiptId(casp: `0x${string}`): Promise<`0x${string}`> {
return (await this.call(SEL.nextReceiptId + addrPad(casp))) as `0x${string}`;
}
async caspIsActive(casp: `0x${string}`): Promise<boolean> {
const r = await this.call(SEL.caspIsActive + addrPad(casp));
return parseInt(r.slice(-2), 16) !== 0;
}
async executionLatencyMs(receiptId: `0x${string}`): Promise<bigint> {
const r = await this.call(SEL.executionLatencyMs + bytes32Pad(receiptId));
return BigInt(r);
}
/* ------------------------------ writes ----------------------------- */
/** Build calldata for Foundation to register a CASP. */
encodeRegisterCasp(casp: `0x${string}`, licenceRef: `0x${string}`): `0x${string}` {
return (SEL.registerCasp + addrPad(casp) + bytes32Pad(licenceRef)) as `0x${string}`;
}
/** Build calldata for Foundation to (un)pause a CASP. */
encodeSetCaspPaused(casp: `0x${string}`, paused: boolean): `0x${string}` {
return (SEL.setCaspPaused + addrPad(casp) + boolPad(paused)) as `0x${string}`;
}
/**
* Build calldata for a CASP to mint a receipt. ABI-encode the tuple at the
* exact layout solc uses for `Receipt calldata r`.
*
* Layout of `Receipt`:
* offset 0x00 address casp
* offset 0x20 bytes32 clientHash
* offset 0x40 uint8 clientClass
* offset 0x60 bytes32 orderId
* offset 0x80 uint8 orderType
* offset 0xa0 bytes32 instrumentSymbol
* offset 0xc0 uint128 requestedQty
* offset 0xe0 uint128 executedQty
* offset 0x100 uint128 executedPrice
* offset 0x120 bytes32 executedVenue
* offset 0x140 bytes32 alternativesRoot
* offset 0x160 bytes32 algorithmRef
* offset 0x180 uint64 decisionTsMs
* offset 0x1a0 uint64 executionTsMs
* offset 0x1c0 string evidenceUri (offset tail)
*
* For a single tuple parameter solc encodes the outer head as a pointer
* (0x20) to the tuple body. So: selector || 0x20 || tuple-body || tail.
*/
encodeIssueReceipt(r: Receipt): `0x${string}` {
// selector for issueReceipt((address,bytes32,uint8,bytes32,uint8,bytes32,uint128,uint128,uint128,bytes32,bytes32,bytes32,uint64,uint64,string))
// Computed once in JS:
// keccak256("issueReceipt((address,bytes32,uint8,bytes32,uint8,bytes32,uint128,uint128,uint128,bytes32,bytes32,bytes32,uint64,uint64,string))").slice(0, 10)
// = 0x5360fb18
const selector = '0x5360fb18';
const head =
hex(0x20n) + // offset to tuple
addrPad(r.casp) +
bytes32Pad(r.clientHash) +
hex(BigInt(r.clientClass)) +
bytes32Pad(r.orderId) +
hex(BigInt(r.orderType)) +
bytes32Pad(r.instrumentSymbol) +
hex(r.requestedQty) +
hex(r.executedQty) +
hex(r.executedPrice) +
bytes32Pad(r.executedVenue) +
bytes32Pad(r.alternativesRoot) +
bytes32Pad(r.algorithmRef) +
hex(r.decisionTsMs) +
hex(r.executionTsMs) +
hex(0x1e0n); // string head: offset within tuple body to string tail
// string tail = 32-byte length + utf-8 bytes padded to 32
const uriBytes = new TextEncoder().encode(r.evidenceUri);
const lenWord = hex(BigInt(uriBytes.length));
const padded = Array.from(uriBytes).map((b) => b.toString(16).padStart(2, '0')).join('');
const padTo32 = (s: string) => s + '0'.repeat((64 - (s.length % 64)) % 64);
const tail = lenWord + padTo32(padded);
return (selector + head + tail) as `0x${string}`;
}
}
export default BestExClient;

2
src/bestex/index.ts Normal file
View File

@ -0,0 +1,2 @@
export { BestExClient, OrderType, ClientClass } from './BestExClient.js';
export type { BestExConfig, Receipt, CaspRegistration, RpcProvider } from './BestExClient.js';

View File

@ -9,3 +9,28 @@ export {
// Ship Q3 2026 (post audit contests).
export * from './sink/index.js';
export * from './saere/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';
// Month 2-4 — Rollup-as-a-Service registry.
export { AereRaaSFactoryClient, AereRollupSettlementClient } from './raas/index.js';
// AERE402 agentic settlement — registry + facilitator + Express middleware.
export { AereAgentClient, AERE402SettlementClient, buildAuthTypedData,
createAere402Middleware, buildAere402TypedData } from './agentic/index.js';
export type { Aere402MiddlewareConfig, PaymentAuth, AgentPaymentAuth, Eip1193Provider } from './agentic/index.js';
// Compliance primitives — AereProof v0 + ZKScreen + AIProof.
export { AereSanctionsRegistryClient, ChainalysisOracleWrapperClient,
AereTravelRuleHashRegistryClient, AereForensicEventRegistryClient,
AereZKScreenClient, AereAIProofClient } from './compliance/index.js';
// AereStateChannels — bidirectional payment channels + HTLC (Lightning-EVM).
export { AereStateChannelsClient } from './channels/index.js';
export type { ChannelState } from './channels/index.js';
// MiCA Article 78 best-execution receipts — on-chain CASP commitments.
export { BestExClient, OrderType, ClientClass } from './bestex/index.js';
export type { BestExConfig, Receipt as BestExReceipt, CaspRegistration } from './bestex/index.js';