@pulsepairs/sdk 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -65,11 +65,14 @@ import { createPublicClient, createWalletClient, http } from "viem";
65
65
  import { privateKeyToAccount } from "viem/accounts";
66
66
  import { arbitrum } from "viem/chains";
67
67
  import {
68
- UpDownHttpClient, buildOrderTypedData, ensureSettlementAllowance,
68
+ UpDownHttpClient, buildOrderTypedData, ensureSettlementAllowance, freshNonce,
69
69
  parseCompositeMarketKey, parseStake, OrderType, OrderSide, Option,
70
70
  } from "@pulsepairs/sdk";
71
71
 
72
- const api = new UpDownHttpClient("https://api.demo-pulsepairs.rainwins.com");
72
+ // Point the bot at a matcher YOU chose. Don't hardcode ours into a process
73
+ // that holds a funded key — and assert the chain matches your RPC's chain
74
+ // before you approve anything (see "Handling a funded key" below).
75
+ const api = new UpDownHttpClient(process.env.UPND_API!);
73
76
  const cfg = await api.getConfig(); // never hardcode addresses
74
77
  const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
75
78
  const [live] = (await api.getMarkets({ pair: "BTC-USD", timeframe: 300 }))
@@ -77,8 +80,18 @@ const [live] = (await api.getMarkets({ pair: "BTC-USD", timeframe: 300 }))
77
80
  const { settlementAddress, marketId } = parseCompositeMarketKey(live.address)!;
78
81
 
79
82
  const amount = parseStake("5");
83
+
84
+ // BOUNDED approve. `settlementAddress` is server-supplied (it came from the
85
+ // matcher's market list), so cap its reach at what you actually intend to
86
+ // spend. `amount` = how much to approve; `threshold` = when to top back up.
87
+ // Omitting `amount` still approves MAX_UINT256 — that default is legacy.
88
+ await ensureSettlementAllowance({
89
+ publicClient, walletClient, usdt: cfg.usdtAddress, settlement: settlementAddress,
90
+ amount: amount * 20n, threshold: amount,
91
+ });
92
+
80
93
  const maxFee = (amount * BigInt(cfg.platformFeeBps + cfg.makerFeeBps)) / 10000n; // peak fee
81
- const nonce = BigInt(Math.floor(Math.random() * 1e12));
94
+ const nonce = freshNonce(); // CSPRNG; never Math.random/Date.now
82
95
  const typedData = buildOrderTypedData({
83
96
  cfg, settlementAddress,
84
97
  message: { maker: account.address, market: BigInt(marketId), option: BigInt(Option.UP),
@@ -91,8 +104,54 @@ await api.postOrder({ maker: account.address, market: live.address, option: Opti
91
104
  maxFee: maxFee.toString(), nonce: Number(nonce), expiry: live.endTime, signature });
92
105
  ```
93
106
 
94
- Full runnable scripts: `examples/simple-taker.ts` (MARKET), `examples/simple-maker.ts`
95
- (LIMIT + authed WS), `examples/full-dmm-bot.ts` (two-sided quoting).
107
+ Full runnable scripts live in the **repo** under `examples/` — `simple-taker.ts`
108
+ (MARKET), `simple-maker.ts` (LIMIT + authed WS), `full-dmm-bot.ts` (two-sided
109
+ quoting). They are **not in the npm tarball** (`files: ["dist","README.md"]`), so
110
+ if you installed from npm without repo access, this README is your reference —
111
+ the safe patterns are inlined here on purpose.
112
+
113
+ ---
114
+
115
+ ## Handling a funded key
116
+
117
+ This SDK is normally driven by a hot key that holds real USDT. Three rules,
118
+ each of which exists because the failure is silent:
119
+
120
+ **1. Never default your endpoints.** A bot signs orders for whatever matcher you
121
+ point it at, and then grants that matcher's *server-supplied* settlement address
122
+ a USDT allowance. A defaulted `UPND_API` plus a defaulted mainnet RPC means a
123
+ real funded key trading against a box you never chose. Require both explicitly:
124
+
125
+ ```ts
126
+ const API = process.env.UPND_API;
127
+ if (!API) throw new Error("Set UPND_API — refusing to default a funded key to a third-party endpoint");
128
+ const RPC = process.env.ARBITRUM_RPC_URL;
129
+ if (!RPC) throw new Error("Set ARBITRUM_RPC_URL");
130
+ ```
131
+
132
+ **2. Assert the matcher and the RPC agree on the chain**, before the approve.
133
+ This one check catches the whole class — a testnet/demo matcher paired with a
134
+ mainnet key can't survive it:
135
+
136
+ ```ts
137
+ const cfg = await api.getConfig();
138
+ const rpcChainId = await publicClient.getChainId();
139
+ if (cfg.chainId !== rpcChainId) {
140
+ throw new Error(`chain mismatch: matcher says ${cfg.chainId}, RPC says ${rpcChainId}`);
141
+ }
142
+ ```
143
+
144
+ **3. Bound the allowance.** Pass `amount` to `ensureSettlementAllowance` (EOA) /
145
+ `onboard`/`approve` (Account Kit) / `buildApproveSettlementTx` (raw-tx tier).
146
+ All four default to `MAX_UINT256` for backwards compatibility; that default is
147
+ an unbounded claim on your balance by an address the server named. A bounded
148
+ allowance is consumed by fills, so re-run the helper on a timer — if it runs dry
149
+ mid-session your fills revert with `insufficient allowance`.
150
+
151
+ Use `freshNonce()` for order/cancel nonces. It draws from `crypto.getRandomValues`
152
+ and stays under 2^53 so `Number(nonce)` on the wire matches the value you signed.
153
+ `Math.random()`/`Date.now()` nonces are guessable, which lets anyone pre-burn your
154
+ next nonce against the replay store and block your order flow.
96
155
 
97
156
  ---
98
157
 
@@ -138,6 +197,45 @@ const signOrder = bareErc1271Signer((td) => rainSmartWalletClient.signTypedData(
138
197
  const signature = await signOrder(buildOrderTypedData({ cfg, settlementAddress, message }));
139
198
  ```
140
199
 
200
+ ### Low-level "raw tx" tier (v0.3.0) — send with your own AA session
201
+
202
+ If you run your own Account-Abstraction send path (like rain.trade's `sendTxs`
203
+ through their root session), use the **transport-free primitives** instead of the
204
+ signer class. They return rain's exact `RawTransaction` shape (`{ to, data, value? }`)
205
+ for the on-chain setup, and sign orders **locally** with the popup-less
206
+ order-session key — the part your send-only session structurally cannot do
207
+ (a root session packs the *owner* entity → `isValidSignature` returns `0xffffffff`).
208
+
209
+ The one mental-model flip: **placing an order is not a transaction.** It is an
210
+ off-chain EIP-712 signature POSTed to the matcher; the relayer transacts the fill.
211
+ So there is no `buildPlaceOrderTx` — you `signWithOrderSession(...)` and `postOrder`.
212
+
213
+ ```ts
214
+ import {
215
+ buildApproveSettlementTx, buildInstallOrderSessionTx, signWithOrderSession,
216
+ buildOrderTypedData,
217
+ } from "@pulsepairs/sdk";
218
+ import { arbitrum } from "viem/chains";
219
+
220
+ // Onboard once — batch both raw txs into ONE userOp through YOUR session:
221
+ const approveTx = buildApproveSettlementTx({ usdt: cfg.usdtAddress, settlement });
222
+ const { tx: installTx, session } = await buildInstallOrderSessionTx({ sca, chain: arbitrum });
223
+ await sendTxs([approveTx, installTx]); // your batched, gasless send
224
+ persist(session); // { v, privateKey, entityId, expirySec }
225
+
226
+ // Every order after that — no chain, no gas, no popup:
227
+ const typedData = buildOrderTypedData({ cfg, settlementAddress, message /* maker: sca, maxFee, … */ });
228
+ const signature = await signWithOrderSession({ session, sca, chain: arbitrum, alchemyApiKey, typedData });
229
+ await api.postOrder({ maker: sca, /* … */, signature });
230
+ ```
231
+
232
+ Two tiers, adopt in order: **MVP** = `approve` (1 raw tx) + `bareErc1271Signer`
233
+ owner-path (1 popup/order, zero new code) → **Production** = `approve` + install
234
+ (batched) + `signWithOrderSession` (popup-less). Full walkthrough in
235
+ [`examples/rain-taker.ts`](examples/rain-taker.ts); rationale in
236
+ `UPDOWN_RAIN_RAWTX_SDK_DESIGN.md`. The on-chain acceptance of these bytes on
237
+ rain's exact MA-v2 impl is proven by `updown-contracts/test/OrderSessionForkTest.t.sol`.
238
+
141
239
  ---
142
240
 
143
241
  ## Account Kit: the two rules you cannot break
@@ -222,8 +320,8 @@ this cap across partial fills.
222
320
  Clients UpDownHttpClient, UpDownWsClient, wsUrlFromHttpBase
223
321
  EIP-712 ORDER_TYPES, CANCEL_TYPES, WS_AUTH_TYPES,
224
322
  buildOrderTypedData, buildCancelTypedData, buildWsAuthTypedData,
225
- freshSessionId, domainForSettlement, findPairBySettlement,
226
- parseCompositeMarketKey
323
+ freshSessionId, freshNonce, domainForSettlement,
324
+ findPairBySettlement, parseCompositeMarketKey
227
325
  Trade-math centsToBps, bpsToCents, parseStake, assertStakeBounds, feeAtomic,
228
326
  MIN_STAKE_ATOMIC, MAX_STAKE_ATOMIC
229
327
  Approve (EOA) ensureSettlementAllowance, MAX_UINT256
@@ -231,6 +329,8 @@ Account Kit UpDownAccountKitSigner (connect, onboard, approve, withdraw,
231
329
  signTypedDataBare, signWsAuth, isDeployed, grantSession,
232
330
  ensureOrderSession, revokeOrderSession, hasOrderSession),
233
331
  bareErc1271Signer, stripErc6492Wrapper, isErc6492Signature
332
+ Raw-tx tier buildApproveSettlementTx, buildInstallOrderSessionTx,
333
+ (v0.3.0) signWithOrderSession, RawTransaction, OrderSessionRecord
234
334
  L2 HMAC auth CLOB_AUTH_TYPES, buildClobAuthTypedData, buildHmacSignature, HMAC_HEADERS
235
335
  Enums/types OrderType, OrderSide, Option, ApiConfig, PairConfig, PostOrderBody, …
236
336
  ```
@@ -64,6 +64,17 @@ export declare function isErc6492Signature(signature: Hex): boolean;
64
64
  */
65
65
  export declare function stripErc6492Wrapper(signature: Hex): Hex;
66
66
  export type RawTypedDataSigner = (typedData: TypedDataDefinition) => Promise<Hex>;
67
+ /**
68
+ * A pending on-chain call, shaped EXACTLY like rain.trade's `RawTransaction`
69
+ * (`rain-sdk/tx/*` → `{ to, data, value? }`) so the low-level builders below
70
+ * drop straight into their `sendTxs`/`sendCalls` without any adapter. `value`
71
+ * is optional and defaults to 0 for the UpDown setup calls (approve / install).
72
+ */
73
+ export type RawTransaction = {
74
+ to: Address;
75
+ data: Hex;
76
+ value?: bigint;
77
+ };
67
78
  /**
68
79
  * Wrap ANY raw typed-data signer so its output is a BARE ERC-1271 signature
69
80
  * the UpDown settlement can verify. Use this when the host app already owns an
@@ -126,6 +137,17 @@ export type GrantSessionResult = {
126
137
  context: Hex;
127
138
  expirySec: number;
128
139
  };
140
+ export type OrderSessionRecord = {
141
+ v: 1;
142
+ /** Session private key — browser/host custody, same trust class as RainAA's
143
+ * IndexedDB session keys but with a strictly narrower blast radius (1271
144
+ * signing only, no UserOp execution). */
145
+ privateKey: Hex;
146
+ /** MA-v2 validation entity id the key is installed under on the SCA. */
147
+ entityId: number;
148
+ /** Client-side expiry (unix sec). NOT enforced on-chain. */
149
+ expirySec: number;
150
+ };
129
151
  /**
130
152
  * Owner-key ERC-1271 order signer + gasless custody sends on an Alchemy SCA.
131
153
  *
@@ -202,7 +224,9 @@ export declare class UpDownAccountKitSigner {
202
224
  /**
203
225
  * Build the `installValidation` self-call that registers a fresh session
204
226
  * key on the SCA as a signature-validation-ONLY entity (validated flow:
205
- * updown-frontend/scripts/poc-session-key-fe-flow.mjs).
227
+ * updown-frontend/scripts/poc-session-key-fe-flow.mjs). Delegates to the
228
+ * shared, transport-free `buildOrderSessionInstall(...)` so the class and the
229
+ * standalone `buildInstallOrderSessionTx(...)` emit byte-identical calldata.
206
230
  */
207
231
  private buildOrderSessionInstallCall;
208
232
  /** The @account-kit/infra chain (Alchemy RPC config baked in) for MA-v2 clients. */
@@ -229,15 +253,22 @@ export declare class UpDownAccountKitSigner {
229
253
  * popup-less. Order-session prep failures degrade to plain deploy+approve
230
254
  * (owner-key signing per order). Uses the owner client so it works before
231
255
  * any session exists; deployment is carried by the account init-code.
256
+ *
257
+ * `amount` bounds the allowance (default unlimited) — see
258
+ * `ensureSettlementAllowance`'s notes; the same argument applies here,
259
+ * since `settlement` comes from the matcher's own config.
232
260
  */
233
261
  onboard(args: {
234
262
  usdt: Address;
235
263
  settlement: Address;
264
+ amount?: bigint;
236
265
  }): Promise<Hex>;
237
- /** Idempotent USDT approve to the settlement (gasless UserOp). */
266
+ /** Idempotent USDT approve to the settlement (gasless UserOp). `amount`
267
+ * bounds the allowance; defaults to unlimited (MAX_UINT256). */
238
268
  approve(args: {
239
269
  usdt: Address;
240
270
  settlement: Address;
271
+ amount?: bigint;
241
272
  }): Promise<Hex>;
242
273
  /** Transfer USDT out of the SCA to `to` (gasless UserOp). */
243
274
  withdraw(args: {
@@ -263,3 +294,46 @@ export declare class UpDownAccountKitSigner {
263
294
  /** Clear in-memory state (persisted sessions, if any, are left intact). */
264
295
  disconnect(): void;
265
296
  }
297
+ /**
298
+ * Raw `approve(settlement, amount)` on the USDT contract as a `RawTransaction`.
299
+ * Defaults to an unlimited (MAX_UINT256) allowance — the amount `enterPosition`
300
+ * needs — matching what `UpDownAccountKitSigner.onboard/approve` send. Push it
301
+ * straight into rain's `sendTxs` (batched with `buildInstallOrderSessionTx`).
302
+ * Pure calldata: synchronous, no peer deps, no network.
303
+ */
304
+ export declare function buildApproveSettlementTx(args: {
305
+ usdt: Address;
306
+ settlement: Address;
307
+ amount?: bigint;
308
+ }): RawTransaction;
309
+ /**
310
+ * Build the one-time popup-less-order-session install as a `RawTransaction`
311
+ * (the `installValidation` self-call) plus the `OrderSessionRecord` the host
312
+ * must persist and later feed to `signWithOrderSession`. Send `tx` through the
313
+ * host's own AA session — batched with `buildApproveSettlementTx` it costs one
314
+ * userOp. Requires the `@account-kit/*` peer deps (lazy-imported). The `sca`
315
+ * MUST be the account the session installs on (order.maker); `chain` is the
316
+ * viem chain the SCA lives on (Arbitrum One / Sepolia).
317
+ */
318
+ export declare function buildInstallOrderSessionTx(args: {
319
+ sca: Address;
320
+ chain: Chain;
321
+ }): Promise<{
322
+ tx: RawTransaction;
323
+ session: OrderSessionRecord;
324
+ }>;
325
+ /**
326
+ * Sign EIP-712 typed data (an Order / Cancel / WS-auth) LOCALLY with a
327
+ * persisted order-session key, yielding the BARE ERC-1271 signature the UpDown
328
+ * matcher accepts. Pure local ECDSA over a MA-v2 `signerEntity` — no bundler,
329
+ * gas, popup, or network. The `sca` must be deployed and carry the session
330
+ * entity (via `buildInstallOrderSessionTx`'s tx) before the signature is used
331
+ * in a fill. This is the piece rain's send-only session structurally cannot do.
332
+ */
333
+ export declare function signWithOrderSession(args: {
334
+ session: OrderSessionRecord;
335
+ sca: Address;
336
+ chain: Chain;
337
+ alchemyApiKey: string;
338
+ typedData: TypedDataDefinition;
339
+ }): Promise<Hex>;
@@ -356,24 +356,12 @@ export class UpDownAccountKitSigner {
356
356
  }
357
357
  this._orderSession = rec;
358
358
  if (!this._orderSessionClient) {
359
- const [aaCore, scMod, infraMod] = await Promise.all([
360
- // @ts-ignore peer dep
361
- import("@aa-sdk/core"),
362
- // @ts-ignore peer dep
363
- import("@account-kit/smart-contracts"),
364
- // @ts-ignore peer dep
365
- import("@account-kit/infra"),
366
- ]);
367
- const { LocalAccountSigner } = aaCore;
368
- const { createModularAccountV2Client } = scMod;
369
- const { alchemy } = infraMod;
370
- this._orderSessionClient = await createModularAccountV2Client({
371
- mode: "default",
372
- chain: await this.infraChain(),
373
- transport: alchemy({ apiKey: this.config.alchemyApiKey }),
374
- signer: LocalAccountSigner.privateKeyToAccountSigner(rec.privateKey),
375
- accountAddress: this._address,
376
- signerEntity: { entityId: rec.entityId, isGlobalValidation: false },
359
+ this._orderSessionClient = await buildOrderSessionClient({
360
+ sca: this._address,
361
+ chainId: this.config.chain.id,
362
+ alchemyApiKey: this.config.alchemyApiKey,
363
+ privateKey: rec.privateKey,
364
+ entityId: rec.entityId,
377
365
  });
378
366
  }
379
367
  return this._orderSessionClient.account;
@@ -400,51 +388,17 @@ export class UpDownAccountKitSigner {
400
388
  /**
401
389
  * Build the `installValidation` self-call that registers a fresh session
402
390
  * key on the SCA as a signature-validation-ONLY entity (validated flow:
403
- * updown-frontend/scripts/poc-session-key-fe-flow.mjs).
391
+ * updown-frontend/scripts/poc-session-key-fe-flow.mjs). Delegates to the
392
+ * shared, transport-free `buildOrderSessionInstall(...)` so the class and the
393
+ * standalone `buildInstallOrderSessionTx(...)` emit byte-identical calldata.
404
394
  */
405
395
  async buildOrderSessionInstallCall() {
406
- const [viemAccounts, expMod, viemMod] = await Promise.all([
407
- // @ts-ignore viem subpath
408
- import("viem/accounts"),
409
- // @ts-ignore peer dep
410
- import("@account-kit/smart-contracts/experimental"),
411
- // @ts-ignore viem
412
- import("viem"),
413
- ]);
414
- const { generatePrivateKey, privateKeyToAccount } = viemAccounts;
415
- const { getDefaultSingleSignerValidationModuleAddress, SingleSignerValidationModule, serializeValidationConfig, semiModularAccountBytecodeAbi, } = expMod;
416
- const privateKey = generatePrivateKey();
417
- const sessionAddress = privateKeyToAccount(privateKey).address;
418
- // Random 4-byte entity id (≥2): 0 is the owner entity, and installing an
419
- // id that already exists on the account reverts — random keeps collisions
420
- // with prior sessions (lost storage, other hosts) vanishingly unlikely.
421
- const entityId = 2 + Math.floor(Math.random() * 0x7ffffff0);
422
- const data = viemMod.encodeFunctionData({
423
- abi: semiModularAccountBytecodeAbi,
424
- functionName: "installValidation",
425
- args: [
426
- serializeValidationConfig({
427
- moduleAddress: getDefaultSingleSignerValidationModuleAddress(await this.infraChain()),
428
- entityId,
429
- isGlobal: false,
430
- isSignatureValidation: true, // can answer ERC-1271…
431
- isUserOpValidation: false, // …but can never execute a UserOp
432
- }),
433
- [],
434
- SingleSignerValidationModule.encodeOnInstallData({ entityId, signer: sessionAddress }),
435
- [],
436
- ],
437
- });
438
- return {
439
- call: { to: this.address, data },
440
- record: { v: 1, privateKey, entityId, expirySec: Math.floor(Date.now() / 1000) + ORDER_SESSION_TTL_SEC },
441
- };
396
+ const { tx, session } = await buildOrderSessionInstall(this.address, await this.infraChain());
397
+ return { call: { to: tx.to, data: tx.data }, record: session };
442
398
  }
443
399
  /** The @account-kit/infra chain (Alchemy RPC config baked in) for MA-v2 clients. */
444
- async infraChain() {
445
- // @ts-ignore peer dep
446
- const infra = (await import("@account-kit/infra"));
447
- return this.config.chain.id === 421614 ? infra.arbitrumSepolia : infra.arbitrum;
400
+ infraChain() {
401
+ return infraChainFor(this.config.chain.id);
448
402
  }
449
403
  /**
450
404
  * Build + owner-sign a WS-auth handshake and package it as `WsAuthCredentials`
@@ -529,10 +483,14 @@ export class UpDownAccountKitSigner {
529
483
  * popup-less. Order-session prep failures degrade to plain deploy+approve
530
484
  * (owner-key signing per order). Uses the owner client so it works before
531
485
  * any session exists; deployment is carried by the account init-code.
486
+ *
487
+ * `amount` bounds the allowance (default unlimited) — see
488
+ * `ensureSettlementAllowance`'s notes; the same argument applies here,
489
+ * since `settlement` comes from the matcher's own config.
532
490
  */
533
491
  async onboard(args) {
534
492
  const calls = [
535
- { to: args.usdt, data: encodeApprove(args.settlement) },
493
+ { to: args.usdt, data: encodeApprove(args.settlement, args.amount ?? MAX_UINT256) },
536
494
  ];
537
495
  let record = null;
538
496
  if (this.orderSessionsEnabled && !(await this.orderSessionAccount().catch(() => null))) {
@@ -550,9 +508,13 @@ export class UpDownAccountKitSigner {
550
508
  this.persistOrderSession(record);
551
509
  return txHash;
552
510
  }
553
- /** Idempotent USDT approve to the settlement (gasless UserOp). */
511
+ /** Idempotent USDT approve to the settlement (gasless UserOp). `amount`
512
+ * bounds the allowance; defaults to unlimited (MAX_UINT256). */
554
513
  async approve(args) {
555
- return this.sendCall({ to: args.usdt, data: encodeApprove(args.settlement) });
514
+ return this.sendCall({
515
+ to: args.usdt,
516
+ data: encodeApprove(args.settlement, args.amount ?? MAX_UINT256),
517
+ });
556
518
  }
557
519
  /** Transfer USDT out of the SCA to `to` (gasless UserOp). */
558
520
  async withdraw(args) {
@@ -621,11 +583,157 @@ export class UpDownAccountKitSigner {
621
583
  this._mods = null;
622
584
  }
623
585
  }
586
+ /* ═══════════════════════════════════════════════════════════════════════
587
+ * Low-level ("raw tx") tier — transport-free primitives (design doc §4)
588
+ *
589
+ * These surface the exact calldata + local-signing logic the
590
+ * `UpDownAccountKitSigner` class uses privately, so a host that owns its own
591
+ * Account-Abstraction send path (rain.trade's shared session) can:
592
+ * 1. get UpDown's setup calls as `RawTransaction`s and push them through
593
+ * ITS batched userOp (`sendTxs`), and
594
+ * 2. sign UpDown orders LOCALLY with the popup-less order-session key.
595
+ * The class delegates to the same `buildOrderSessionInstall` /
596
+ * `buildOrderSessionClient` helpers, guaranteeing identical bytes.
597
+ * ═══════════════════════════════════════════════════════════════════════ */
598
+ /** The @account-kit/infra chain (Alchemy RPC baked in) for MA-v2 clients. */
599
+ async function infraChainFor(chainId) {
600
+ // @ts-ignore peer dep
601
+ const infra = (await import("@account-kit/infra"));
602
+ return chainId === 421614 ? infra.arbitrumSepolia : infra.arbitrum;
603
+ }
604
+ /**
605
+ * Build the `installValidation` self-call that registers a fresh order-session
606
+ * key on `sca` as a signature-validation-ONLY MA-v2 entity, plus the session
607
+ * material to persist. Transport-free: pure local key-gen + calldata encoding —
608
+ * no bundler, gas, popup, or network. `moduleChain` only selects the
609
+ * `SingleSignerValidationModule` address (a constant across chains today), so
610
+ * either an `@account-kit/infra` chain or a viem `Chain` works.
611
+ */
612
+ async function buildOrderSessionInstall(sca, moduleChain) {
613
+ const [viemAccounts, expMod, viemMod] = await Promise.all([
614
+ // @ts-ignore viem subpath
615
+ import("viem/accounts"),
616
+ // @ts-ignore peer dep
617
+ import("@account-kit/smart-contracts/experimental"),
618
+ // @ts-ignore viem
619
+ import("viem"),
620
+ ]);
621
+ const { generatePrivateKey, privateKeyToAccount } = viemAccounts;
622
+ const { getDefaultSingleSignerValidationModuleAddress, SingleSignerValidationModule, serializeValidationConfig, semiModularAccountBytecodeAbi, } = expMod;
623
+ const privateKey = generatePrivateKey();
624
+ const sessionAddress = privateKeyToAccount(privateKey).address;
625
+ // Random 4-byte entity id (≥2): 0 is the owner entity, and installing an
626
+ // id that already exists on the account reverts — random keeps collisions
627
+ // with prior sessions (lost storage, other hosts) vanishingly unlikely.
628
+ // CSPRNG-drawn: a predictable id lets an observer front-run the install
629
+ // and brick onboarding by burning the id we're about to claim (the revert
630
+ // is the whole failure mode this randomness exists to avoid).
631
+ const entityId = 2 + randomUint31();
632
+ const data = viemMod.encodeFunctionData({
633
+ abi: semiModularAccountBytecodeAbi,
634
+ functionName: "installValidation",
635
+ args: [
636
+ serializeValidationConfig({
637
+ moduleAddress: getDefaultSingleSignerValidationModuleAddress(moduleChain),
638
+ entityId,
639
+ isGlobal: false,
640
+ isSignatureValidation: true, // can answer ERC-1271…
641
+ isUserOpValidation: false, // …but can never execute a UserOp
642
+ }),
643
+ [],
644
+ SingleSignerValidationModule.encodeOnInstallData({ entityId, signer: sessionAddress }),
645
+ [],
646
+ ],
647
+ });
648
+ return {
649
+ tx: { to: sca, data },
650
+ session: { v: 1, privateKey, entityId, expirySec: Math.floor(Date.now() / 1000) + ORDER_SESSION_TTL_SEC },
651
+ };
652
+ }
653
+ /**
654
+ * Build a MA-v2 smart-wallet client bound to an order-session key's validation
655
+ * entity. Used only for LOCAL ERC-1271 signing (`account.signTypedData`) — the
656
+ * entity cannot execute UserOps — so no bundler/gas/network is exercised by the
657
+ * signing path. Shared by the class and the standalone `signWithOrderSession`.
658
+ */
659
+ async function buildOrderSessionClient(args) {
660
+ const [aaCore, scMod, infraMod] = await Promise.all([
661
+ // @ts-ignore peer dep
662
+ import("@aa-sdk/core"),
663
+ // @ts-ignore peer dep
664
+ import("@account-kit/smart-contracts"),
665
+ // @ts-ignore peer dep
666
+ import("@account-kit/infra"),
667
+ ]);
668
+ const { LocalAccountSigner } = aaCore;
669
+ const { createModularAccountV2Client } = scMod;
670
+ const { alchemy } = infraMod;
671
+ return createModularAccountV2Client({
672
+ mode: "default",
673
+ chain: await infraChainFor(args.chainId),
674
+ transport: alchemy({ apiKey: args.alchemyApiKey }),
675
+ signer: LocalAccountSigner.privateKeyToAccountSigner(args.privateKey),
676
+ accountAddress: args.sca,
677
+ signerEntity: { entityId: args.entityId, isGlobalValidation: false },
678
+ });
679
+ }
680
+ /**
681
+ * Raw `approve(settlement, amount)` on the USDT contract as a `RawTransaction`.
682
+ * Defaults to an unlimited (MAX_UINT256) allowance — the amount `enterPosition`
683
+ * needs — matching what `UpDownAccountKitSigner.onboard/approve` send. Push it
684
+ * straight into rain's `sendTxs` (batched with `buildInstallOrderSessionTx`).
685
+ * Pure calldata: synchronous, no peer deps, no network.
686
+ */
687
+ export function buildApproveSettlementTx(args) {
688
+ return { to: args.usdt, data: encodeApprove(args.settlement, args.amount ?? MAX_UINT256) };
689
+ }
690
+ /**
691
+ * Build the one-time popup-less-order-session install as a `RawTransaction`
692
+ * (the `installValidation` self-call) plus the `OrderSessionRecord` the host
693
+ * must persist and later feed to `signWithOrderSession`. Send `tx` through the
694
+ * host's own AA session — batched with `buildApproveSettlementTx` it costs one
695
+ * userOp. Requires the `@account-kit/*` peer deps (lazy-imported). The `sca`
696
+ * MUST be the account the session installs on (order.maker); `chain` is the
697
+ * viem chain the SCA lives on (Arbitrum One / Sepolia).
698
+ */
699
+ export function buildInstallOrderSessionTx(args) {
700
+ return buildOrderSessionInstall(args.sca, args.chain);
701
+ }
702
+ /**
703
+ * Sign EIP-712 typed data (an Order / Cancel / WS-auth) LOCALLY with a
704
+ * persisted order-session key, yielding the BARE ERC-1271 signature the UpDown
705
+ * matcher accepts. Pure local ECDSA over a MA-v2 `signerEntity` — no bundler,
706
+ * gas, popup, or network. The `sca` must be deployed and carry the session
707
+ * entity (via `buildInstallOrderSessionTx`'s tx) before the signature is used
708
+ * in a fill. This is the piece rain's send-only session structurally cannot do.
709
+ */
710
+ export async function signWithOrderSession(args) {
711
+ const client = await buildOrderSessionClient({
712
+ sca: args.sca,
713
+ chainId: args.chain.id,
714
+ alchemyApiKey: args.alchemyApiKey,
715
+ privateKey: args.session.privateKey,
716
+ entityId: args.session.entityId,
717
+ });
718
+ return (await client.account.signTypedData(args.typedData));
719
+ }
624
720
  /* ───────────────────────── calldata encoders ───────────────────────── */
625
721
  const MAX_UINT256 = (1n << 256n) - 1n;
626
- function encodeApprove(spender) {
722
+ /** Uniform CSPRNG draw in [0, 0x7ffffff0). Mirrors `freshSessionId`'s
723
+ * contract: throws rather than silently degrading to a weak source. */
724
+ function randomUint31() {
725
+ const bytes = new Uint8Array(4);
726
+ const g = globalThis;
727
+ if (!g.crypto || typeof g.crypto.getRandomValues !== "function") {
728
+ throw new Error("globalThis.crypto.getRandomValues unavailable; use Node 18+ or a modern browser");
729
+ }
730
+ g.crypto.getRandomValues(bytes);
731
+ const raw = ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0;
732
+ return raw % 0x7ffffff0;
733
+ }
734
+ function encodeApprove(spender, amount = MAX_UINT256) {
627
735
  // approve(address,uint256) selector 0x095ea7b3
628
- return ("0x095ea7b3" + pad(spender) + pad(MAX_UINT256));
736
+ return ("0x095ea7b3" + pad(spender) + pad(amount));
629
737
  }
630
738
  function encodeTransfer(to, amount) {
631
739
  // transfer(address,uint256) selector 0xa9059cbb
package/dist/approve.d.ts CHANGED
@@ -1,16 +1,32 @@
1
1
  /**
2
- * One-time USDT.approve(settlement, MaxUint256) helper.
2
+ * USDT.approve(settlement, …) helper.
3
3
  *
4
4
  * Path-1 settlement pulls USDT directly from the maker via `transferFrom`
5
5
  * inside `enterPosition`. Without this approval the first BUY reverts
6
6
  * with `ERC20: insufficient allowance`. Idempotent: reads current
7
7
  * allowance and only submits a tx when below threshold.
8
8
  *
9
+ * `amount` defaults to a BOUNDED amount (`DEFAULT_APPROVAL_AMOUNT`, 100k USDT),
10
+ * not MAX_UINT256 (S2): `settlement` is routinely sourced from a server-supplied
11
+ * `/config` or market list, so an unlimited approval would grant a server-named
12
+ * spender unbounded reach into a funded hot wallet. A hot bot key SHOULD still
13
+ * pass its own inventory-sized bound; `MAX_UINT256` is exported for the rare
14
+ * caller that explicitly opts into unlimited.
15
+ *
9
16
  * `viem` is a peer dependency — pass in the public + wallet clients you
10
17
  * already have so this helper doesn't bake in a transport choice.
11
18
  */
12
19
  import type { Address, PublicClient, WalletClient } from "viem";
13
20
  export declare const MAX_UINT256: bigint;
21
+ /**
22
+ * S2: bounded default approval (100k USDT, atomic). Caps the blast radius of a
23
+ * settlement-contract bug (or a hostile server-supplied spender) to one
24
+ * inventory bound instead of the wallet's entire USDT balance, while sitting
25
+ * comfortably above `DEFAULT_THRESHOLD` so a continuously-quoting bot isn't
26
+ * re-approving on every call. Bots with larger inventory should pass their own
27
+ * `amount`. This is 6-decimal USDT, so 100k * 1e6.
28
+ */
29
+ export declare const DEFAULT_APPROVAL_AMOUNT: bigint;
14
30
  export type EnsureAllowanceResult = {
15
31
  status: "already_ok";
16
32
  allowance: bigint;
@@ -30,12 +46,27 @@ export type EnsureAllowanceResult = {
30
46
  * `cfg.pairs[0].settlementAddress` for today's
31
47
  * single-settlement deploy).
32
48
  * @param args.threshold — Re-approve when current allowance is below this
33
- * atomic-USDT amount. Default 10,000 USDT.
49
+ * atomic-USDT amount. Default 10,000 USDT. This is
50
+ * WHEN to top up, not HOW MUCH — see `amount`.
51
+ * @param args.amount — How much to approve when a top-up is sent. Defaults
52
+ * to `DEFAULT_APPROVAL_AMOUNT` (100k USDT, BOUNDED — S2),
53
+ * not `MAX_UINT256`. `settlement` is routinely sourced
54
+ * from the matcher's own `/config` or market list, so an
55
+ * unlimited approval would grant a server-supplied spender
56
+ * unbounded reach into a funded hot wallet — size this to
57
+ * the inventory the bot actually needs between top-ups.
58
+ * It must exceed `threshold`, or every call re-approves.
59
+ * Pass `MAX_UINT256` to explicitly opt into unlimited.
34
60
  *
35
61
  * Returns `already_ok` if nothing needed to be done, `approved` with the
36
- * tx hash if a new approval was sent. Caller can `await
37
- * publicClient.waitForTransactionReceipt({ hash })` if it wants to confirm
38
- * before placing orders.
62
+ * tx hash and the amount approved if a new approval was sent. Caller can
63
+ * `await publicClient.waitForTransactionReceipt({ hash })` if it wants to
64
+ * confirm before placing orders.
65
+ *
66
+ * Operational note for bounded approvals: the allowance is consumed by
67
+ * fills, so a bot that quotes continuously WILL run it down. This helper
68
+ * only tops up when called — call it on your quoting loop, not just at
69
+ * startup, or fills start reverting with `insufficient allowance` mid-session.
39
70
  */
40
71
  export declare function ensureSettlementAllowance(args: {
41
72
  publicClient: PublicClient;
@@ -43,4 +74,5 @@ export declare function ensureSettlementAllowance(args: {
43
74
  usdt: Address;
44
75
  settlement: Address;
45
76
  threshold?: bigint;
77
+ amount?: bigint;
46
78
  }): Promise<EnsureAllowanceResult>;
package/dist/approve.js CHANGED
@@ -21,10 +21,17 @@ const ERC20_ABI = [
21
21
  },
22
22
  ];
23
23
  export const MAX_UINT256 = (1n << 256n) - 1n;
24
- /** Default refresh threshold — re-approve if allowance falls below 10k USDT.
25
- * MaxUint256 effectively never decreases with USDT, but if a partial
26
- * approval was set in some flow this guards against drift. */
24
+ /** Default refresh threshold — re-approve if allowance falls below 10k USDT. */
27
25
  const DEFAULT_THRESHOLD = 10000n * 1000000n;
26
+ /**
27
+ * S2: bounded default approval (100k USDT, atomic). Caps the blast radius of a
28
+ * settlement-contract bug (or a hostile server-supplied spender) to one
29
+ * inventory bound instead of the wallet's entire USDT balance, while sitting
30
+ * comfortably above `DEFAULT_THRESHOLD` so a continuously-quoting bot isn't
31
+ * re-approving on every call. Bots with larger inventory should pass their own
32
+ * `amount`. This is 6-decimal USDT, so 100k * 1e6.
33
+ */
34
+ export const DEFAULT_APPROVAL_AMOUNT = 100000n * 1000000n;
28
35
  /**
29
36
  * Ensure the maker has enough USDT allowance on the settlement contract.
30
37
  *
@@ -36,12 +43,27 @@ const DEFAULT_THRESHOLD = 10000n * 1000000n;
36
43
  * `cfg.pairs[0].settlementAddress` for today's
37
44
  * single-settlement deploy).
38
45
  * @param args.threshold — Re-approve when current allowance is below this
39
- * atomic-USDT amount. Default 10,000 USDT.
46
+ * atomic-USDT amount. Default 10,000 USDT. This is
47
+ * WHEN to top up, not HOW MUCH — see `amount`.
48
+ * @param args.amount — How much to approve when a top-up is sent. Defaults
49
+ * to `DEFAULT_APPROVAL_AMOUNT` (100k USDT, BOUNDED — S2),
50
+ * not `MAX_UINT256`. `settlement` is routinely sourced
51
+ * from the matcher's own `/config` or market list, so an
52
+ * unlimited approval would grant a server-supplied spender
53
+ * unbounded reach into a funded hot wallet — size this to
54
+ * the inventory the bot actually needs between top-ups.
55
+ * It must exceed `threshold`, or every call re-approves.
56
+ * Pass `MAX_UINT256` to explicitly opt into unlimited.
40
57
  *
41
58
  * Returns `already_ok` if nothing needed to be done, `approved` with the
42
- * tx hash if a new approval was sent. Caller can `await
43
- * publicClient.waitForTransactionReceipt({ hash })` if it wants to confirm
44
- * before placing orders.
59
+ * tx hash and the amount approved if a new approval was sent. Caller can
60
+ * `await publicClient.waitForTransactionReceipt({ hash })` if it wants to
61
+ * confirm before placing orders.
62
+ *
63
+ * Operational note for bounded approvals: the allowance is consumed by
64
+ * fills, so a bot that quotes continuously WILL run it down. This helper
65
+ * only tops up when called — call it on your quoting loop, not just at
66
+ * startup, or fills start reverting with `insufficient allowance` mid-session.
45
67
  */
46
68
  export async function ensureSettlementAllowance(args) {
47
69
  const account = args.walletClient.account;
@@ -49,6 +71,11 @@ export async function ensureSettlementAllowance(args) {
49
71
  throw new Error("walletClient has no account");
50
72
  const owner = account.address;
51
73
  const threshold = args.threshold ?? DEFAULT_THRESHOLD;
74
+ const amount = args.amount ?? DEFAULT_APPROVAL_AMOUNT;
75
+ if (amount < threshold) {
76
+ throw new Error(`approve amount (${amount}) is below the re-approve threshold (${threshold}) — ` +
77
+ "every call would send a redundant approve tx; raise amount or lower threshold");
78
+ }
52
79
  const current = (await args.publicClient.readContract({
53
80
  address: args.usdt,
54
81
  abi: ERC20_ABI,
@@ -63,7 +90,7 @@ export async function ensureSettlementAllowance(args) {
63
90
  address: args.usdt,
64
91
  abi: ERC20_ABI,
65
92
  functionName: "approve",
66
- args: [args.settlement, MAX_UINT256],
93
+ args: [args.settlement, amount],
67
94
  });
68
- return { status: "approved", txHash, allowance: MAX_UINT256 };
95
+ return { status: "approved", txHash, allowance: amount };
69
96
  }
package/dist/eip712.d.ts CHANGED
@@ -60,6 +60,29 @@ export declare function buildWsAuthTypedData(args: {
60
60
  * no sessionId).
61
61
  */
62
62
  export declare function freshSessionId(): `0x${string}`;
63
+ /**
64
+ * Generate a fresh order/cancel `nonce` from a CSPRNG.
65
+ *
66
+ * The nonce is the only thing standing between an order and the backend's
67
+ * replay store: a PREDICTABLE nonce lets anyone pre-burn a maker's next
68
+ * nonce and grief their order flow (a liveness attack — the signature
69
+ * itself still can't be forged). `Math.random()` is not a CSPRNG and
70
+ * `Date.now()` is public knowledge; neither is acceptable here.
71
+ *
72
+ * Deliberately 48 bits, NOT the full uint256: `PostOrderBody.nonce` is a
73
+ * JSON `number`, so the value must survive `Number(nonce)` losslessly —
74
+ * anything above 2^53 would silently round and the posted nonce would stop
75
+ * matching the signed one, failing signature recovery. Across 2^48 values the
76
+ * CUMULATIVE (birthday-bound) chance that ANY two of one maker's ~750k orders
77
+ * collide is ~1e-3 (~k²/2·2^48) — about one in a thousand, not vanishing; the
78
+ * ~1-in-10^9 figure is only the MARGINAL odds that a single new draw hits an
79
+ * existing nonce at that point. Either way a collision is a rejected order
80
+ * (retry with a fresh nonce), not a loss.
81
+ *
82
+ * Single-writer bots may prefer a monotonic counter (no birthday bound at
83
+ * all) — seed it from this rather than from the clock.
84
+ */
85
+ export declare function freshNonce(): bigint;
63
86
  /**
64
87
  * EIP-712 type schemas. Mirrors the backend's `EIP712_ORDER_TYPES` /
65
88
  * `EIP712_CANCEL_TYPES` exactly — if these drift, signatures stop being
package/dist/eip712.js CHANGED
@@ -66,6 +66,40 @@ export function freshSessionId() {
66
66
  }
67
67
  return hex;
68
68
  }
69
+ /**
70
+ * Generate a fresh order/cancel `nonce` from a CSPRNG.
71
+ *
72
+ * The nonce is the only thing standing between an order and the backend's
73
+ * replay store: a PREDICTABLE nonce lets anyone pre-burn a maker's next
74
+ * nonce and grief their order flow (a liveness attack — the signature
75
+ * itself still can't be forged). `Math.random()` is not a CSPRNG and
76
+ * `Date.now()` is public knowledge; neither is acceptable here.
77
+ *
78
+ * Deliberately 48 bits, NOT the full uint256: `PostOrderBody.nonce` is a
79
+ * JSON `number`, so the value must survive `Number(nonce)` losslessly —
80
+ * anything above 2^53 would silently round and the posted nonce would stop
81
+ * matching the signed one, failing signature recovery. Across 2^48 values the
82
+ * CUMULATIVE (birthday-bound) chance that ANY two of one maker's ~750k orders
83
+ * collide is ~1e-3 (~k²/2·2^48) — about one in a thousand, not vanishing; the
84
+ * ~1-in-10^9 figure is only the MARGINAL odds that a single new draw hits an
85
+ * existing nonce at that point. Either way a collision is a rejected order
86
+ * (retry with a fresh nonce), not a loss.
87
+ *
88
+ * Single-writer bots may prefer a monotonic counter (no birthday bound at
89
+ * all) — seed it from this rather than from the clock.
90
+ */
91
+ export function freshNonce() {
92
+ const bytes = new Uint8Array(6);
93
+ const g = globalThis;
94
+ if (!g.crypto || typeof g.crypto.getRandomValues !== "function") {
95
+ throw new Error("globalThis.crypto.getRandomValues unavailable; use Node 18+ or a modern browser");
96
+ }
97
+ g.crypto.getRandomValues(bytes);
98
+ let n = 0n;
99
+ for (let i = 0; i < bytes.length; i++)
100
+ n = (n << 8n) | BigInt(bytes[i]);
101
+ return n;
102
+ }
69
103
  /**
70
104
  * EIP-712 type schemas. Mirrors the backend's `EIP712_ORDER_TYPES` /
71
105
  * `EIP712_CANCEL_TYPES` exactly — if these drift, signatures stop being
package/dist/index.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  export { UpDownHttpClient, wsUrlFromHttpBase } from "./http.js";
2
2
  export { UpDownWsClient, type UpDownWsMessage, type SubscribePayload, type WsAuthCredentials, type ConnectAuthedOptions, } from "./ws.js";
3
- export { ORDER_TYPES, CANCEL_TYPES, WS_AUTH_TYPES, buildOrderTypedData, buildCancelTypedData, buildWsAuthTypedData, freshSessionId, domainForSettlement, findPairBySettlement, parseCompositeMarketKey, centsToBps, bpsToCents, parseStake, assertStakeBounds, feeAtomic, MIN_STAKE_ATOMIC, MAX_STAKE_ATOMIC, type OrderSignMessage, type CancelSignMessage, type WsAuthMessage, type ParsedComposite, } from "./eip712.js";
3
+ export { ORDER_TYPES, CANCEL_TYPES, WS_AUTH_TYPES, buildOrderTypedData, buildCancelTypedData, buildWsAuthTypedData, freshSessionId, freshNonce, domainForSettlement, findPairBySettlement, parseCompositeMarketKey, centsToBps, bpsToCents, parseStake, assertStakeBounds, feeAtomic, MIN_STAKE_ATOMIC, MAX_STAKE_ATOMIC, type OrderSignMessage, type CancelSignMessage, type WsAuthMessage, type ParsedComposite, } from "./eip712.js";
4
4
  export { CLOB_AUTH_TYPES, CLOB_AUTH_MESSAGE, buildClobAuthTypedData, buildHmacSignature, HMAC_HEADERS, type ClobAuthDomain, type ClobAuthMessage, } from "./auth.js";
5
- export { ensureSettlementAllowance, MAX_UINT256, type EnsureAllowanceResult, } from "./approve.js";
5
+ export { ensureSettlementAllowance, MAX_UINT256, DEFAULT_APPROVAL_AMOUNT, type EnsureAllowanceResult, } from "./approve.js";
6
+ export { readOnChainHolderShares, reconcileFills, reportedFromPositions, type OnChainHolderShares, type ShareReconciliation, type ReconcileStatus, type FillReconciliationReport, } from "./reconcile.js";
6
7
  export { UpDownAccountKitSigner, bareErc1271Signer, stripErc6492Wrapper, isErc6492Signature, type UpDownAccountKitConfig, type Eip1193Provider, type GrantSessionResult, type RawTypedDataSigner, } from "./accountKit.js";
8
+ export { buildApproveSettlementTx, buildInstallOrderSessionTx, signWithOrderSession, type RawTransaction, type OrderSessionRecord, } from "./accountKit.js";
7
9
  export { OrderType, OrderSide, Option, type ApiConfig, type Balance, type CancelOrderBody, type Eip712Domain, type MarketDetail, type MarketListItem, type OptionValue, type OrderBookFull, type OrderBookLevel, type OrderBookSide, type OrderRow, type OrderSideKey, type OrderSideValue, type OrderStatus, type OrderTypeKey, type OrderTypeValue, type OrdersResponse, type PairConfig, type PairSymbol, type PostOrderBody, type Position, type Stats, type Trade, type Version, } from "./types.js";
package/dist/index.js CHANGED
@@ -2,14 +2,23 @@
2
2
  export { UpDownHttpClient, wsUrlFromHttpBase } from "./http.js";
3
3
  export { UpDownWsClient, } from "./ws.js";
4
4
  // EIP-712 helpers
5
- export { ORDER_TYPES, CANCEL_TYPES, WS_AUTH_TYPES, buildOrderTypedData, buildCancelTypedData, buildWsAuthTypedData, freshSessionId, domainForSettlement, findPairBySettlement, parseCompositeMarketKey, centsToBps, bpsToCents, parseStake, assertStakeBounds, feeAtomic, MIN_STAKE_ATOMIC, MAX_STAKE_ATOMIC, } from "./eip712.js";
5
+ export { ORDER_TYPES, CANCEL_TYPES, WS_AUTH_TYPES, buildOrderTypedData, buildCancelTypedData, buildWsAuthTypedData, freshSessionId, freshNonce, domainForSettlement, findPairBySettlement, parseCompositeMarketKey, centsToBps, bpsToCents, parseStake, assertStakeBounds, feeAtomic, MIN_STAKE_ATOMIC, MAX_STAKE_ATOMIC, } from "./eip712.js";
6
6
  // Phase 3 / Gate 1 — L2 HMAC auth helpers
7
7
  export { CLOB_AUTH_TYPES, CLOB_AUTH_MESSAGE, buildClobAuthTypedData, buildHmacSignature, HMAC_HEADERS, } from "./auth.js";
8
8
  // Approve helper
9
- export { ensureSettlementAllowance, MAX_UINT256, } from "./approve.js";
9
+ export { ensureSettlementAllowance, MAX_UINT256, DEFAULT_APPROVAL_AMOUNT, } from "./approve.js";
10
+ // S5 — on-chain fill reconciliation (independent detector for the B1/B6
11
+ // off-chain ledger bugs: diffs authoritative on-chain `userShares` against
12
+ // what the matcher reported).
13
+ export { readOnChainHolderShares, reconcileFills, reportedFromPositions, } from "./reconcile.js";
10
14
  // Account Kit (Alchemy SCA) — owner-key ERC-1271 order signing + gasless custody.
11
15
  // Peer deps (@account-kit/*, @aa-sdk/core) are lazy-imported; importing this
12
16
  // module does NOT require them unless you construct/connect the signer.
13
17
  export { UpDownAccountKitSigner, bareErc1271Signer, stripErc6492Wrapper, isErc6492Signature, } from "./accountKit.js";
18
+ // Low-level "raw tx" tier (design doc §4) — transport-free primitives for hosts
19
+ // that own their own Account-Abstraction send path (e.g. rain.trade's shared
20
+ // session): on-chain setup builders → rain's exact `RawTransaction` shape, plus
21
+ // a local order-session signer for the off-chain sigs their session can't make.
22
+ export { buildApproveSettlementTx, buildInstallOrderSessionTx, signWithOrderSession, } from "./accountKit.js";
14
23
  // Types
15
24
  export { OrderType, OrderSide, Option, } from "./types.js";
@@ -0,0 +1,103 @@
1
+ /**
2
+ * S5 — on-chain fill reconciliation.
3
+ *
4
+ * The matcher reports an MM's positions off-chain (`GET /positions/:wallet`,
5
+ * `GET /markets/:address/holders`). On-chain, `UpDownSettlement.userShares`
6
+ * (a public mapping) is the AUTHORITATIVE record of what each holder actually
7
+ * owns — it is the number `redeemFor`/`redeem` pays against. A bot that trusts
8
+ * only the matcher cannot see the off-chain ledger-integrity failures in the
9
+ * pre-prod review (B1 collateral double-commit, B6 phantom positions): both
10
+ * manifest as the matcher REPORTING shares (or a fill) that on-chain state does
11
+ * not back. This helper closes that gap — it reads `userShares` directly and
12
+ * diffs it against what the matcher reported, so an MM has an independent
13
+ * detector rather than taking the API's word for its own book.
14
+ *
15
+ * `viem` is a peer dependency — pass the `PublicClient` you already have.
16
+ */
17
+ import type { Address, PublicClient } from "viem";
18
+ import { type OptionValue } from "./types.js";
19
+ /** Authoritative on-chain shares for one holder in one market. */
20
+ export interface OnChainHolderShares {
21
+ marketId: bigint;
22
+ holder: Address;
23
+ /** `userShares[marketId][holder][UP]` (atomic). */
24
+ up: bigint;
25
+ /** `userShares[marketId][holder][DOWN]` (atomic). */
26
+ down: bigint;
27
+ resolved: boolean;
28
+ /** Winning option once resolved: `Option.UP`/`Option.DOWN`, or 0 if unresolved. */
29
+ winner: OptionValue | 0;
30
+ }
31
+ /**
32
+ * Read a holder's authoritative on-chain shares (both options) and the market's
33
+ * resolution state in one batch of `readContract` calls.
34
+ */
35
+ export declare function readOnChainHolderShares(args: {
36
+ publicClient: PublicClient;
37
+ settlement: Address;
38
+ marketId: bigint;
39
+ holder: Address;
40
+ }): Promise<OnChainHolderShares>;
41
+ export type ReconcileStatus = "match" | "reported_over" | "reported_under";
42
+ /** Per-option reconciliation of matcher-reported vs on-chain shares. */
43
+ export interface ShareReconciliation {
44
+ option: OptionValue;
45
+ optionLabel: "UP" | "DOWN";
46
+ /** Authoritative on-chain `userShares`. */
47
+ onChain: bigint;
48
+ /** What the matcher reported off-chain. */
49
+ reported: bigint;
50
+ /** `reported - onChain`. Positive = matcher over-reports (phantom risk). */
51
+ drift: bigint;
52
+ status: ReconcileStatus;
53
+ }
54
+ export interface FillReconciliationReport {
55
+ marketId: bigint;
56
+ holder: Address;
57
+ resolved: boolean;
58
+ winner: OptionValue | 0;
59
+ lines: ShareReconciliation[];
60
+ /** True when every option reconciles exactly. */
61
+ ok: boolean;
62
+ /**
63
+ * True when the matcher reports MORE shares than the chain backs on some
64
+ * option (`reported_over`) — the dangerous direction: a phantom position the
65
+ * MM would price against but cannot redeem. Post-redemption a winner-side
66
+ * `reported_over` is benign (the holder was already paid and `userShares`
67
+ * zeroed); check `resolved`/`winner` before alerting.
68
+ */
69
+ hasPhantom: boolean;
70
+ }
71
+ /**
72
+ * Reconcile matcher-reported net shares against on-chain `userShares` for one
73
+ * holder in one market. `reported` is the net shares the matcher attributes to
74
+ * the holder per option (atomic units), e.g. folded from `GET /positions/:wallet`
75
+ * — see {@link reportedFromPositions}.
76
+ *
77
+ * Returns a per-option diff plus `ok` / `hasPhantom` flags an MM can gate on.
78
+ * A healthy market reconciles exactly; a divergence is the on-chain signature of
79
+ * the B1/B6 ledger bugs (or of a redemption the off-chain ledger hasn't caught
80
+ * up to yet — hence the `resolved`/`winner` context).
81
+ */
82
+ export declare function reconcileFills(args: {
83
+ publicClient: PublicClient;
84
+ settlement: Address;
85
+ marketId: bigint;
86
+ holder: Address;
87
+ reported: {
88
+ up: bigint;
89
+ down: bigint;
90
+ };
91
+ }): Promise<FillReconciliationReport>;
92
+ /**
93
+ * Fold a matcher `Position[]` list (from `GET /positions/:wallet`, already
94
+ * filtered to one market) into the `{ up, down }` atomic-share shape
95
+ * {@link reconcileFills} expects. Unknown option labels are ignored.
96
+ */
97
+ export declare function reportedFromPositions(positions: ReadonlyArray<{
98
+ optionLabel: "UP" | "DOWN";
99
+ shares: string;
100
+ }>): {
101
+ up: bigint;
102
+ down: bigint;
103
+ };
@@ -0,0 +1,129 @@
1
+ import { Option } from "./types.js";
2
+ /** Minimal read-only slice of the settlement ABI — no write surface. */
3
+ const SETTLEMENT_RECON_ABI = [
4
+ {
5
+ type: "function",
6
+ name: "userShares",
7
+ stateMutability: "view",
8
+ inputs: [
9
+ { name: "marketId", type: "uint256" },
10
+ { name: "holder", type: "address" },
11
+ { name: "option", type: "uint8" },
12
+ ],
13
+ outputs: [{ name: "", type: "uint256" }],
14
+ },
15
+ {
16
+ type: "function",
17
+ name: "getMarket",
18
+ stateMutability: "view",
19
+ inputs: [{ name: "marketId", type: "uint256" }],
20
+ outputs: [
21
+ {
22
+ name: "",
23
+ type: "tuple",
24
+ components: [
25
+ { name: "pairId", type: "bytes32" },
26
+ { name: "cashUpFlow", type: "uint128" },
27
+ { name: "cashDownFlow", type: "uint128" },
28
+ { name: "startTime", type: "uint64" },
29
+ { name: "endTime", type: "uint64" },
30
+ { name: "duration", type: "uint32" },
31
+ { name: "winner", type: "uint8" },
32
+ { name: "resolved", type: "bool" },
33
+ { name: "settled", type: "bool" },
34
+ { name: "strikePrice", type: "int128" },
35
+ { name: "settlementPrice", type: "int128" },
36
+ ],
37
+ },
38
+ ],
39
+ },
40
+ ];
41
+ /**
42
+ * Read a holder's authoritative on-chain shares (both options) and the market's
43
+ * resolution state in one batch of `readContract` calls.
44
+ */
45
+ export async function readOnChainHolderShares(args) {
46
+ const { publicClient, settlement, marketId, holder } = args;
47
+ const read = (option) => publicClient.readContract({
48
+ address: settlement,
49
+ abi: SETTLEMENT_RECON_ABI,
50
+ functionName: "userShares",
51
+ args: [marketId, holder, option],
52
+ });
53
+ const [up, down, market] = await Promise.all([
54
+ read(Option.UP),
55
+ read(Option.DOWN),
56
+ publicClient.readContract({
57
+ address: settlement,
58
+ abi: SETTLEMENT_RECON_ABI,
59
+ functionName: "getMarket",
60
+ args: [marketId],
61
+ }),
62
+ ]);
63
+ const winnerNum = Number(market.winner);
64
+ const winner = winnerNum === Option.UP ? Option.UP : winnerNum === Option.DOWN ? Option.DOWN : 0;
65
+ return { marketId, holder, up, down, resolved: market.resolved, winner };
66
+ }
67
+ function classify(onChain, reported) {
68
+ if (reported === onChain)
69
+ return "match";
70
+ return reported > onChain ? "reported_over" : "reported_under";
71
+ }
72
+ /**
73
+ * Reconcile matcher-reported net shares against on-chain `userShares` for one
74
+ * holder in one market. `reported` is the net shares the matcher attributes to
75
+ * the holder per option (atomic units), e.g. folded from `GET /positions/:wallet`
76
+ * — see {@link reportedFromPositions}.
77
+ *
78
+ * Returns a per-option diff plus `ok` / `hasPhantom` flags an MM can gate on.
79
+ * A healthy market reconciles exactly; a divergence is the on-chain signature of
80
+ * the B1/B6 ledger bugs (or of a redemption the off-chain ledger hasn't caught
81
+ * up to yet — hence the `resolved`/`winner` context).
82
+ */
83
+ export async function reconcileFills(args) {
84
+ const chain = await readOnChainHolderShares(args);
85
+ const lines = [
86
+ {
87
+ option: Option.UP,
88
+ optionLabel: "UP",
89
+ onChain: chain.up,
90
+ reported: args.reported.up,
91
+ drift: args.reported.up - chain.up,
92
+ status: classify(chain.up, args.reported.up),
93
+ },
94
+ {
95
+ option: Option.DOWN,
96
+ optionLabel: "DOWN",
97
+ onChain: chain.down,
98
+ reported: args.reported.down,
99
+ drift: args.reported.down - chain.down,
100
+ status: classify(chain.down, args.reported.down),
101
+ },
102
+ ];
103
+ return {
104
+ marketId: args.marketId,
105
+ holder: args.holder,
106
+ resolved: chain.resolved,
107
+ winner: chain.winner,
108
+ lines,
109
+ ok: lines.every((l) => l.status === "match"),
110
+ hasPhantom: lines.some((l) => l.status === "reported_over"),
111
+ };
112
+ }
113
+ /**
114
+ * Fold a matcher `Position[]` list (from `GET /positions/:wallet`, already
115
+ * filtered to one market) into the `{ up, down }` atomic-share shape
116
+ * {@link reconcileFills} expects. Unknown option labels are ignored.
117
+ */
118
+ export function reportedFromPositions(positions) {
119
+ let up = 0n;
120
+ let down = 0n;
121
+ for (const p of positions) {
122
+ const s = BigInt(p.shares);
123
+ if (p.optionLabel === "UP")
124
+ up += s;
125
+ else if (p.optionLabel === "DOWN")
126
+ down += s;
127
+ }
128
+ return { up, down };
129
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pulsepairs/sdk",
3
- "version": "0.2.0",
4
- "description": "Standalone SDK for the UpDown (PulsePairs) up/down prediction markets — matcher REST/WS client, EIP-712 order signing, trade-math, and Alchemy Account Kit (smart-account) order signing for rain.trade integration.",
3
+ "version": "0.4.0",
4
+ "description": "Standalone SDK for the UpDown (PulsePairs) up/down prediction markets \u2014 matcher REST/WS client, EIP-712 order signing, trade-math, and Alchemy Account Kit (smart-account) order signing for rain.trade integration.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -21,7 +21,7 @@
21
21
  "scripts": {
22
22
  "clean": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\"",
23
23
  "build": "npm run clean && tsc",
24
- "test": "node scripts/eip712-golden.test.mjs",
24
+ "test": "node scripts/eip712-golden.test.mjs && node scripts/rawtx-tier.test.mjs && node scripts/hot-key-safety.test.mjs && node scripts/reconcile.test.mjs",
25
25
  "prepublishOnly": "npm run build && npm test",
26
26
  "example:taker": "npx tsx examples/simple-taker.ts",
27
27
  "example:maker": "npx tsx examples/simple-maker.ts",