@pulsepairs/sdk 0.3.0 → 0.5.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
 
@@ -255,16 +314,60 @@ this cap across partial fills.
255
314
 
256
315
  ---
257
316
 
317
+ ## PnL — positions marked to market (v0.5.0)
318
+
319
+ The matcher hands you the *ingredients* for PnL (`shares` / `avgPrice` /
320
+ `costBasis` on positions, `winner` + order book on markets) but never a PnL
321
+ number. `getPnl` assembles one, marking each position:
322
+
323
+ - **resolved market** → won shares at full face (10000 bps), lost shares at 0;
324
+ - **live market** → the order-book **mid** for that option;
325
+ - **no book signal** → the position's own `avgPrice` (an honest 0-PnL mark,
326
+ flagged `markSource: "cost"` so you can tell it apart).
327
+
328
+ ```ts
329
+ const client = new UpDownHttpClient("https://api.demo-pulsepairs.rainwins.com");
330
+
331
+ const pnl = await client.getPnl(wallet, { includeFees: true });
332
+ console.log(pnl.unrealizedPnlUsdt, pnl.roiPct); // e.g. -3, -30
333
+ for (const p of pnl.positions) {
334
+ console.log(p.optionLabel, p.markSource, p.unrealizedPnlUsdt);
335
+ }
336
+ ```
337
+
338
+ All money math is BigInt-exact on **atomic** units and returned as decimal
339
+ strings (`costBasis`, `markValue`, `unrealizedPnl` — signed); the `*Usdt` /
340
+ `roiPct` floats are display-only. Cost basis is **fee-exclusive** (matching the
341
+ backend's fold), so fees are a separate `fees` line, reported not netted.
342
+
343
+ Already holding the data? The pure helpers are transport-free and unit-tested:
344
+
345
+ ```ts
346
+ import { positionPnl, computePortfolioPnl, midBps, feesPaidByWallet } from "@pulsepairs/sdk";
347
+
348
+ positionPnl(position, { markBps: 7000 }); // one position, explicit mark
349
+ positionPnl(position, { winner: 1 }); // settled: full-face / zero
350
+ computePortfolioPnl(positions, marketsByKey); // roll-up from market details
351
+ ```
352
+
353
+ Units: `shares` atomic (= USDT face at settlement), price/`avgPrice`/`markBps`
354
+ in **bps** (10000 = full face), fees atomic. `Option` is `1 = UP`, `2 = DOWN`.
355
+
356
+ ---
357
+
258
358
  ## API surface
259
359
 
260
360
  ```
261
361
  Clients UpDownHttpClient, UpDownWsClient, wsUrlFromHttpBase
262
362
  EIP-712 ORDER_TYPES, CANCEL_TYPES, WS_AUTH_TYPES,
263
363
  buildOrderTypedData, buildCancelTypedData, buildWsAuthTypedData,
264
- freshSessionId, domainForSettlement, findPairBySettlement,
265
- parseCompositeMarketKey
364
+ freshSessionId, freshNonce, domainForSettlement,
365
+ findPairBySettlement, parseCompositeMarketKey
266
366
  Trade-math centsToBps, bpsToCents, parseStake, assertStakeBounds, feeAtomic,
267
367
  MIN_STAKE_ATOMIC, MAX_STAKE_ATOMIC
368
+ PnL (v0.5.0) UpDownHttpClient.getPnl, positionPnl, portfolioPnl,
369
+ computePortfolioPnl, markValueAtomic, midBps, markForOption,
370
+ feesPaidByWallet, atomicToUsdt, isResolvedWinner
268
371
  Approve (EOA) ensureSettlementAllowance, MAX_UINT256
269
372
  Account Kit UpDownAccountKitSigner (connect, onboard, approve, withdraw,
270
373
  signTypedDataBare, signWsAuth, isDeployed, grantSession,
@@ -253,15 +253,22 @@ export declare class UpDownAccountKitSigner {
253
253
  * popup-less. Order-session prep failures degrade to plain deploy+approve
254
254
  * (owner-key signing per order). Uses the owner client so it works before
255
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.
256
260
  */
257
261
  onboard(args: {
258
262
  usdt: Address;
259
263
  settlement: Address;
264
+ amount?: bigint;
260
265
  }): Promise<Hex>;
261
- /** 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). */
262
268
  approve(args: {
263
269
  usdt: Address;
264
270
  settlement: Address;
271
+ amount?: bigint;
265
272
  }): Promise<Hex>;
266
273
  /** Transfer USDT out of the SCA to `to` (gasless UserOp). */
267
274
  withdraw(args: {
@@ -483,10 +483,14 @@ export class UpDownAccountKitSigner {
483
483
  * popup-less. Order-session prep failures degrade to plain deploy+approve
484
484
  * (owner-key signing per order). Uses the owner client so it works before
485
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.
486
490
  */
487
491
  async onboard(args) {
488
492
  const calls = [
489
- { to: args.usdt, data: encodeApprove(args.settlement) },
493
+ { to: args.usdt, data: encodeApprove(args.settlement, args.amount ?? MAX_UINT256) },
490
494
  ];
491
495
  let record = null;
492
496
  if (this.orderSessionsEnabled && !(await this.orderSessionAccount().catch(() => null))) {
@@ -504,9 +508,13 @@ export class UpDownAccountKitSigner {
504
508
  this.persistOrderSession(record);
505
509
  return txHash;
506
510
  }
507
- /** 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). */
508
513
  async approve(args) {
509
- 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
+ });
510
518
  }
511
519
  /** Transfer USDT out of the SCA to `to` (gasless UserOp). */
512
520
  async withdraw(args) {
@@ -617,7 +625,10 @@ async function buildOrderSessionInstall(sca, moduleChain) {
617
625
  // Random 4-byte entity id (≥2): 0 is the owner entity, and installing an
618
626
  // id that already exists on the account reverts — random keeps collisions
619
627
  // with prior sessions (lost storage, other hosts) vanishingly unlikely.
620
- const entityId = 2 + Math.floor(Math.random() * 0x7ffffff0);
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();
621
632
  const data = viemMod.encodeFunctionData({
622
633
  abi: semiModularAccountBytecodeAbi,
623
634
  functionName: "installValidation",
@@ -708,6 +719,18 @@ export async function signWithOrderSession(args) {
708
719
  }
709
720
  /* ───────────────────────── calldata encoders ───────────────────────── */
710
721
  const MAX_UINT256 = (1n << 256n) - 1n;
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
+ }
711
734
  function encodeApprove(spender, amount = MAX_UINT256) {
712
735
  // approve(address,uint256) selector 0x095ea7b3
713
736
  return ("0x095ea7b3" + pad(spender) + pad(amount));
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/http.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ApiConfig, Balance, CancelOrderBody, MarketDetail, MarketListItem, OrderBookFull, OrdersResponse, PairSymbol, PostOrderBody, Position, Stats, Trade, Version } from "./types.js";
2
+ import { type PortfolioPnl } from "./pnl.js";
2
3
  export declare class UpDownHttpClient {
3
4
  private readonly baseUrl;
4
5
  constructor(baseUrl: string);
@@ -21,6 +22,24 @@ export declare class UpDownHttpClient {
21
22
  limit?: number;
22
23
  offset?: number;
23
24
  }): Promise<Trade[]>;
25
+ /**
26
+ * Portfolio PnL for a wallet: fetches open positions, marks each to its
27
+ * market (settled winner → full-face/zero, else order-book mid, else cost),
28
+ * and rolls the results up. One `/markets/:address` read per distinct market;
29
+ * a failed read degrades that position to a cost-basis mark rather than
30
+ * aborting the whole report.
31
+ *
32
+ * See `pnl.ts` for the unit contract and the standalone (transport-free)
33
+ * helpers if you already hold the positions/markets.
34
+ *
35
+ * @param opts.includeFees fetch `/trades/:wallet` and report total fees paid
36
+ * (default false — it costs an extra request; fees are reported, not netted).
37
+ * @param opts.usdtDecimals display-float decimals (default 6).
38
+ */
39
+ getPnl(wallet: string, opts?: {
40
+ includeFees?: boolean;
41
+ usdtDecimals?: number;
42
+ }): Promise<PortfolioPnl>;
24
43
  getOrders(wallet: string, opts?: {
25
44
  status?: string[];
26
45
  limit?: number;
package/dist/http.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { computePortfolioPnl, feesPaidByWallet } from "./pnl.js";
1
2
  function buildUrl(base, path, query) {
2
3
  const b = base.replace(/\/$/, "");
3
4
  const p = path.startsWith("/") ? path : `/${path}`;
@@ -75,6 +76,43 @@ export class UpDownHttpClient {
75
76
  }));
76
77
  return parseJson(res);
77
78
  }
79
+ /**
80
+ * Portfolio PnL for a wallet: fetches open positions, marks each to its
81
+ * market (settled winner → full-face/zero, else order-book mid, else cost),
82
+ * and rolls the results up. One `/markets/:address` read per distinct market;
83
+ * a failed read degrades that position to a cost-basis mark rather than
84
+ * aborting the whole report.
85
+ *
86
+ * See `pnl.ts` for the unit contract and the standalone (transport-free)
87
+ * helpers if you already hold the positions/markets.
88
+ *
89
+ * @param opts.includeFees fetch `/trades/:wallet` and report total fees paid
90
+ * (default false — it costs an extra request; fees are reported, not netted).
91
+ * @param opts.usdtDecimals display-float decimals (default 6).
92
+ */
93
+ async getPnl(wallet, opts) {
94
+ const positions = await this.getPositions(wallet);
95
+ if (positions.length === 0) {
96
+ return computePortfolioPnl([], new Map(), { usdtDecimals: opts?.usdtDecimals });
97
+ }
98
+ const marketKeys = Array.from(new Set(positions.map((p) => p.market)));
99
+ const details = await Promise.all(marketKeys.map((key) => this.getMarket(key).catch(() => null)));
100
+ const marketsByKey = new Map();
101
+ marketKeys.forEach((key, i) => {
102
+ const md = details[i];
103
+ if (md)
104
+ marketsByKey.set(key, { winner: md.winner, orderBook: md.orderBook });
105
+ });
106
+ let fees;
107
+ if (opts?.includeFees) {
108
+ const trades = await this.getTrades(wallet, { limit: 500 });
109
+ fees = feesPaidByWallet(wallet, trades);
110
+ }
111
+ return computePortfolioPnl(positions, marketsByKey, {
112
+ fees,
113
+ usdtDecimals: opts?.usdtDecimals,
114
+ });
115
+ }
78
116
  async getOrders(wallet, opts) {
79
117
  const params = new URLSearchParams();
80
118
  if (opts?.limit != null)
package/dist/index.d.ts CHANGED
@@ -1,8 +1,10 @@
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";
7
8
  export { buildApproveSettlementTx, buildInstallOrderSessionTx, signWithOrderSession, type RawTransaction, type OrderSessionRecord, } from "./accountKit.js";
9
+ export { PRICE_BPS_SCALE, WIN_MARK_BPS, LOSE_MARK_BPS, DEFAULT_USDT_DECIMALS, atomicToUsdt, markValueAtomic, midBps, markForOption, isResolvedWinner, positionPnl, feesPaidByWallet, portfolioPnl, computePortfolioPnl, type MarkSource, type PositionPnl, type PortfolioPnl, type PositionPnlOptions, } from "./pnl.js";
8
10
  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,11 +2,15 @@
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.
@@ -16,5 +20,9 @@ export { UpDownAccountKitSigner, bareErc1271Signer, stripErc6492Wrapper, isErc64
16
20
  // session): on-chain setup builders → rain's exact `RawTransaction` shape, plus
17
21
  // a local order-session signer for the off-chain sigs their session can't make.
18
22
  export { buildApproveSettlementTx, buildInstallOrderSessionTx, signWithOrderSession, } from "./accountKit.js";
23
+ // PnL — profit-and-loss math over positions/markets/trades. Pure, BigInt-exact,
24
+ // unit-faithful to the backend's own position fold (see pnl.ts for the unit
25
+ // contract). `UpDownHttpClient.getPnl` is the batteries-included wrapper.
26
+ export { PRICE_BPS_SCALE, WIN_MARK_BPS, LOSE_MARK_BPS, DEFAULT_USDT_DECIMALS, atomicToUsdt, markValueAtomic, midBps, markForOption, isResolvedWinner, positionPnl, feesPaidByWallet, portfolioPnl, computePortfolioPnl, } from "./pnl.js";
19
27
  // Types
20
28
  export { OrderType, OrderSide, Option, } from "./types.js";
package/dist/pnl.d.ts ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * PnL — profit-and-loss math for a wallet's UpDown positions.
3
+ *
4
+ * The matcher exposes the *ingredients* for PnL (`GET /positions/:wallet` gives
5
+ * `shares` / `avgPrice` / `costBasis`; `GET /markets/:address` gives the live
6
+ * order book and the resolved `winner`) but never a PnL number itself. This
7
+ * module layers that number on top, in exactly the units the rest of the SDK
8
+ * and the backend already use — so a PnL here reconciles with the backend's own
9
+ * position accounting rather than drifting from it.
10
+ *
11
+ * ── Units (do not guess these; they are load-bearing) ───────────────────────
12
+ * • `shares` — atomic, same denomination as `Trade.amount`. Shares ≡ the
13
+ * USDT face value paid at settlement: a WINNING share redeems
14
+ * 1:1 for its face (`shares` atomic USDT), a losing share for 0.
15
+ * (`UpDownSettlement._redeemToAllowZero` pays `userShares`.)
16
+ * • `avgPrice` — basis points, 0..10000. 10000 bps = full face (1.0). This is
17
+ * what the backend's `avgPriceBps` returns.
18
+ * • `costBasis`— atomic USDT = `shares * avgPrice / 10000`, FEE-EXCLUSIVE
19
+ * (the backend's fold ignores fees; so do we, so the two agree).
20
+ * • order-book `price` — basis points, same scale as `avgPrice`.
21
+ * • fees (`platformFee`/`makerFee`) — atomic USDT.
22
+ *
23
+ * ── What we compute ─────────────────────────────────────────────────────────
24
+ * markValue = shares * markBps / 10000 (atomic USDT)
25
+ * unrealizedPnl = markValue - costBasis (atomic USDT, signed)
26
+ * where `markBps` is:
27
+ * • 10000 or 0 for a RESOLVED market (won → full face, lost → nothing).
28
+ * This is the *realized-at-settlement* value; `resolved: true` flags it.
29
+ * • the order-book mid for a live market (mark-to-market).
30
+ * • the position's own `avgPrice` as a last resort when there is no book
31
+ * signal — a deliberately honest 0-PnL mark, flagged `markSource: "cost"`.
32
+ *
33
+ * All money math is BigInt-exact on atomic units; the `*Usdt` / `roiPct` floats
34
+ * are derived once at the end for display only.
35
+ */
36
+ import type { MarketDetail, OrderBookSide, Position, Trade } from "./types.js";
37
+ /** Basis-points denominator shared by every price in the system. */
38
+ export declare const PRICE_BPS_SCALE = 10000n;
39
+ /** A winning share is worth its full face (10000 bps). */
40
+ export declare const WIN_MARK_BPS = 10000;
41
+ /** A losing share is worth nothing. */
42
+ export declare const LOSE_MARK_BPS = 0;
43
+ /** USDT atomic decimals on the demo/mainnet mock (1 USDT = 1e6). */
44
+ export declare const DEFAULT_USDT_DECIMALS = 6;
45
+ /** Where a position's mark price came from — surfaced so callers can tell a
46
+ * real mark-to-market from a cost-basis fallback. */
47
+ export type MarkSource = "settled" | "orderbook-mid" | "orderbook-bid" | "orderbook-ask" | "explicit" | "cost";
48
+ /** PnL for a single open position, all atomic strings BigInt-exact. */
49
+ export type PositionPnl = {
50
+ market: string;
51
+ option: number;
52
+ optionLabel: "UP" | "DOWN";
53
+ /** Atomic shares (echoed from the position). */
54
+ shares: string;
55
+ /** Atomic USDT cost, fee-exclusive (echoed from the position). */
56
+ costBasis: string;
57
+ /** Price used to value the shares, basis points. */
58
+ markBps: number;
59
+ markSource: MarkSource;
60
+ /** Atomic USDT the shares are worth at `markBps` (= shares * markBps / 10000). */
61
+ markValue: string;
62
+ /** Atomic USDT, signed: `markValue - costBasis`. Negative = loss. */
63
+ unrealizedPnl: string;
64
+ /** `unrealizedPnl` as a human USDT float (display only). */
65
+ unrealizedPnlUsdt: number;
66
+ /** `100 * unrealizedPnl / costBasis`, or null when costBasis is 0. */
67
+ roiPct: number | null;
68
+ /** True when `markBps` is a settled winner/loser value, not a live mark. */
69
+ resolved: boolean;
70
+ };
71
+ /** Portfolio roll-up across positions. */
72
+ export type PortfolioPnl = {
73
+ positions: PositionPnl[];
74
+ /** Atomic USDT sums across all positions. */
75
+ costBasis: string;
76
+ markValue: string;
77
+ unrealizedPnl: string;
78
+ /** Display floats. */
79
+ unrealizedPnlUsdt: number;
80
+ roiPct: number | null;
81
+ /** Atomic USDT of fees paid on the wallet's trades, when trades were supplied
82
+ * (see `feesPaidByWallet`); "0" otherwise. Not netted into `unrealizedPnl`
83
+ * — cost basis is fee-exclusive, so fees stay a separate, explicit line. */
84
+ fees: string;
85
+ };
86
+ /** Options controlling how one position is valued. */
87
+ export type PositionPnlOptions = {
88
+ /** Explicit mark, basis points. Ignored when `winner` marks the position as
89
+ * resolved. When omitted and not resolved, we fall back to `avgPrice`. */
90
+ markBps?: number;
91
+ /** Label for an explicit `markBps` (defaults to "explicit"). */
92
+ markSource?: MarkSource;
93
+ /** Resolved winner: 1 = UP, 2 = DOWN, 0/null = not resolved. When set to a
94
+ * real winner, the position is marked at full face (won) or 0 (lost). */
95
+ winner?: number | null;
96
+ /** USDT atomic decimals for the display float (default 6). */
97
+ usdtDecimals?: number;
98
+ };
99
+ /** Atomic → human float for display. Precision is ample for real stakes; never
100
+ * feed the result back into money math (use the atomic strings for that). */
101
+ export declare function atomicToUsdt(atomic: bigint | string, decimals?: number): number;
102
+ /** Value of `sharesAtomic` shares marked at `markBps`, atomic USDT. Uses the
103
+ * same floor-division as the backend's cost fold so the two never disagree by
104
+ * a rounding unit. */
105
+ export declare function markValueAtomic(sharesAtomic: bigint, markBps: number): bigint;
106
+ /**
107
+ * Mid price (basis points) for one side of a book, from its best bid/ask.
108
+ * Prefers the two-sided mid; degrades to the single resting quote; returns null
109
+ * for an empty book so the caller can choose a fallback rather than invent one.
110
+ */
111
+ export declare function midBps(side: OrderBookSide | undefined | null): {
112
+ bps: number;
113
+ source: MarkSource;
114
+ } | null;
115
+ /** The book side (`up`/`down`) an option trades on. UP = 1, DOWN = 2. */
116
+ export declare function markForOption(orderBook: MarketDetail["orderBook"] | undefined, option: number): {
117
+ bps: number;
118
+ source: MarkSource;
119
+ } | null;
120
+ /** True once a market carries a definitive winner (1 = UP, 2 = DOWN). The
121
+ * contract's `resolve` rejects any other value, so there is no draw/void. */
122
+ export declare function isResolvedWinner(winner: number | null | undefined): winner is 1 | 2;
123
+ /**
124
+ * PnL for a single position. Resolution wins over any supplied `markBps`: a
125
+ * settled market is worth exactly full-face or nothing, never a stale quote.
126
+ */
127
+ export declare function positionPnl(position: Position, opts?: PositionPnlOptions): PositionPnl;
128
+ /** Sum of `platformFee + makerFee` (atomic USDT) across every trade the wallet
129
+ * took part in. A gross tally — it does not attempt maker/taker attribution,
130
+ * so treat it as an upper bound on the wallet's own fee drag. */
131
+ export declare function feesPaidByWallet(wallet: string, trades: Trade[]): string;
132
+ /** Roll individual position PnLs up into a portfolio total. Pass `fees` (from
133
+ * `feesPaidByWallet`) to carry a fee line through; it is reported, not netted. */
134
+ export declare function portfolioPnl(items: PositionPnl[], opts?: {
135
+ fees?: string;
136
+ usdtDecimals?: number;
137
+ }): PortfolioPnl;
138
+ /**
139
+ * Given positions and the market details they live in, compute a full
140
+ * portfolio PnL. Transport-free: hand it data you already fetched. The HTTP
141
+ * client's `getPnl` is a thin wrapper that fetches the markets for you.
142
+ *
143
+ * `marketsByKey` maps a position's `market` key to its `MarketDetail`. A
144
+ * missing entry (or an empty book) degrades to a cost-basis mark for that
145
+ * position rather than dropping it.
146
+ */
147
+ export declare function computePortfolioPnl(positions: Position[], marketsByKey: Map<string, Pick<MarketDetail, "winner" | "orderBook">>, opts?: {
148
+ fees?: string;
149
+ usdtDecimals?: number;
150
+ }): PortfolioPnl;
package/dist/pnl.js ADDED
@@ -0,0 +1,157 @@
1
+ /** Basis-points denominator shared by every price in the system. */
2
+ export const PRICE_BPS_SCALE = 10000n;
3
+ /** A winning share is worth its full face (10000 bps). */
4
+ export const WIN_MARK_BPS = 10_000;
5
+ /** A losing share is worth nothing. */
6
+ export const LOSE_MARK_BPS = 0;
7
+ /** USDT atomic decimals on the demo/mainnet mock (1 USDT = 1e6). */
8
+ export const DEFAULT_USDT_DECIMALS = 6;
9
+ /** BigInt-exact `numer/denom` as a percentage float with 4-decimal resolution;
10
+ * null on a zero denominator. Sign-correct for negative numerators. */
11
+ function pctOf(numer, denom) {
12
+ if (denom === 0n)
13
+ return null;
14
+ // scaled = ratio * 1e6, kept in BigInt so huge atomic values stay exact;
15
+ // /1e4 turns the ratio into a percentage carrying 4 decimal places.
16
+ const scaled = (numer * 1000000n) / denom;
17
+ return Number(scaled) / 10_000;
18
+ }
19
+ /** Atomic → human float for display. Precision is ample for real stakes; never
20
+ * feed the result back into money math (use the atomic strings for that). */
21
+ export function atomicToUsdt(atomic, decimals = DEFAULT_USDT_DECIMALS) {
22
+ const a = typeof atomic === "bigint" ? atomic : BigInt(atomic);
23
+ return Number(a) / 10 ** decimals;
24
+ }
25
+ /** Value of `sharesAtomic` shares marked at `markBps`, atomic USDT. Uses the
26
+ * same floor-division as the backend's cost fold so the two never disagree by
27
+ * a rounding unit. */
28
+ export function markValueAtomic(sharesAtomic, markBps) {
29
+ return (sharesAtomic * BigInt(Math.trunc(markBps))) / PRICE_BPS_SCALE;
30
+ }
31
+ /**
32
+ * Mid price (basis points) for one side of a book, from its best bid/ask.
33
+ * Prefers the two-sided mid; degrades to the single resting quote; returns null
34
+ * for an empty book so the caller can choose a fallback rather than invent one.
35
+ */
36
+ export function midBps(side) {
37
+ const bid = side?.bestBid?.price;
38
+ const ask = side?.bestAsk?.price;
39
+ const hasBid = typeof bid === "number" && Number.isFinite(bid);
40
+ const hasAsk = typeof ask === "number" && Number.isFinite(ask);
41
+ if (hasBid && hasAsk)
42
+ return { bps: Math.round((bid + ask) / 2), source: "orderbook-mid" };
43
+ if (hasBid)
44
+ return { bps: bid, source: "orderbook-bid" };
45
+ if (hasAsk)
46
+ return { bps: ask, source: "orderbook-ask" };
47
+ return null;
48
+ }
49
+ /** The book side (`up`/`down`) an option trades on. UP = 1, DOWN = 2. */
50
+ export function markForOption(orderBook, option) {
51
+ if (!orderBook)
52
+ return null;
53
+ return midBps(option === 1 ? orderBook.up : orderBook.down);
54
+ }
55
+ /** True once a market carries a definitive winner (1 = UP, 2 = DOWN). The
56
+ * contract's `resolve` rejects any other value, so there is no draw/void. */
57
+ export function isResolvedWinner(winner) {
58
+ return winner === 1 || winner === 2;
59
+ }
60
+ /**
61
+ * PnL for a single position. Resolution wins over any supplied `markBps`: a
62
+ * settled market is worth exactly full-face or nothing, never a stale quote.
63
+ */
64
+ export function positionPnl(position, opts = {}) {
65
+ const decimals = opts.usdtDecimals ?? DEFAULT_USDT_DECIMALS;
66
+ const shares = BigInt(position.shares);
67
+ const costBasis = BigInt(position.costBasis);
68
+ let markBps;
69
+ let markSource;
70
+ let resolved = false;
71
+ if (isResolvedWinner(opts.winner)) {
72
+ resolved = true;
73
+ markSource = "settled";
74
+ markBps = position.option === opts.winner ? WIN_MARK_BPS : LOSE_MARK_BPS;
75
+ }
76
+ else if (typeof opts.markBps === "number" && Number.isFinite(opts.markBps)) {
77
+ markBps = opts.markBps;
78
+ markSource = opts.markSource ?? "explicit";
79
+ }
80
+ else {
81
+ // No signal: mark at cost so unrealized PnL is 0 rather than a fiction.
82
+ markBps = position.avgPrice;
83
+ markSource = "cost";
84
+ }
85
+ const markValue = markValueAtomic(shares, markBps);
86
+ const unrealizedPnl = markValue - costBasis;
87
+ return {
88
+ market: position.market,
89
+ option: position.option,
90
+ optionLabel: position.optionLabel,
91
+ shares: shares.toString(),
92
+ costBasis: costBasis.toString(),
93
+ markBps,
94
+ markSource,
95
+ markValue: markValue.toString(),
96
+ unrealizedPnl: unrealizedPnl.toString(),
97
+ unrealizedPnlUsdt: atomicToUsdt(unrealizedPnl, decimals),
98
+ roiPct: pctOf(unrealizedPnl, costBasis),
99
+ resolved,
100
+ };
101
+ }
102
+ /** Sum of `platformFee + makerFee` (atomic USDT) across every trade the wallet
103
+ * took part in. A gross tally — it does not attempt maker/taker attribution,
104
+ * so treat it as an upper bound on the wallet's own fee drag. */
105
+ export function feesPaidByWallet(wallet, trades) {
106
+ const w = wallet.toLowerCase();
107
+ let total = 0n;
108
+ for (const t of trades) {
109
+ if (t.buyer?.toLowerCase() !== w && t.seller?.toLowerCase() !== w)
110
+ continue;
111
+ total += BigInt(t.platformFee || "0") + BigInt(t.makerFee || "0");
112
+ }
113
+ return total.toString();
114
+ }
115
+ /** Roll individual position PnLs up into a portfolio total. Pass `fees` (from
116
+ * `feesPaidByWallet`) to carry a fee line through; it is reported, not netted. */
117
+ export function portfolioPnl(items, opts = {}) {
118
+ const decimals = opts.usdtDecimals ?? DEFAULT_USDT_DECIMALS;
119
+ let costBasis = 0n;
120
+ let markValue = 0n;
121
+ for (const it of items) {
122
+ costBasis += BigInt(it.costBasis);
123
+ markValue += BigInt(it.markValue);
124
+ }
125
+ const unrealizedPnl = markValue - costBasis;
126
+ return {
127
+ positions: items,
128
+ costBasis: costBasis.toString(),
129
+ markValue: markValue.toString(),
130
+ unrealizedPnl: unrealizedPnl.toString(),
131
+ unrealizedPnlUsdt: atomicToUsdt(unrealizedPnl, decimals),
132
+ roiPct: pctOf(unrealizedPnl, costBasis),
133
+ fees: opts.fees ?? "0",
134
+ };
135
+ }
136
+ /**
137
+ * Given positions and the market details they live in, compute a full
138
+ * portfolio PnL. Transport-free: hand it data you already fetched. The HTTP
139
+ * client's `getPnl` is a thin wrapper that fetches the markets for you.
140
+ *
141
+ * `marketsByKey` maps a position's `market` key to its `MarketDetail`. A
142
+ * missing entry (or an empty book) degrades to a cost-basis mark for that
143
+ * position rather than dropping it.
144
+ */
145
+ export function computePortfolioPnl(positions, marketsByKey, opts = {}) {
146
+ const items = positions.map((pos) => {
147
+ const md = marketsByKey.get(pos.market);
148
+ const mark = md ? markForOption(md.orderBook, pos.option) : null;
149
+ return positionPnl(pos, {
150
+ winner: md?.winner ?? null,
151
+ markBps: mark?.bps,
152
+ markSource: mark?.source,
153
+ usdtDecimals: opts.usdtDecimals,
154
+ });
155
+ });
156
+ return portfolioPnl(items, opts);
157
+ }
@@ -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/dist/ws.d.ts CHANGED
@@ -110,6 +110,29 @@ export declare class UpDownWsClient {
110
110
  */
111
111
  connect(subscribe: SubscribePayload): void;
112
112
  disconnect(): void;
113
+ /**
114
+ * Add channels to the live subscription set on an already-connected
115
+ * client, without tearing down the socket. Sends `{type:'subscribe',
116
+ * channels}` immediately if the socket is OPEN; the channels are also
117
+ * merged into the connect-time set so they are replayed automatically
118
+ * after any reconnect (or after the auth handshake, on an authed
119
+ * client). Safe to call before the socket opens.
120
+ *
121
+ * Public channels (`markets`, `orderbook:*`, `trades:*`) work on any
122
+ * client. Private channels (`orders:*`, `balance:*`) still require the
123
+ * client to have been created via `connectAuthed` — the server drops
124
+ * private subs from an unauthenticated connection.
125
+ */
126
+ subscribe(channels: string[]): void;
127
+ /**
128
+ * Remove channels from the live subscription set. Sends
129
+ * `{type:'unsubscribe', channels}` if OPEN and drops them from the
130
+ * replay set so a later reconnect does not re-subscribe them. Channels
131
+ * not currently subscribed are ignored.
132
+ */
133
+ unsubscribe(channels: string[]): void;
134
+ /** The mutable connect-time channel array for the active mode. */
135
+ private modeChannels;
113
136
  private openSocket;
114
137
  private startHandshake;
115
138
  private runSignAndSendAuth;
package/dist/ws.js CHANGED
@@ -93,6 +93,64 @@ export class UpDownWsClient {
93
93
  this.ws?.close();
94
94
  this.ws = null;
95
95
  }
96
+ /**
97
+ * Add channels to the live subscription set on an already-connected
98
+ * client, without tearing down the socket. Sends `{type:'subscribe',
99
+ * channels}` immediately if the socket is OPEN; the channels are also
100
+ * merged into the connect-time set so they are replayed automatically
101
+ * after any reconnect (or after the auth handshake, on an authed
102
+ * client). Safe to call before the socket opens.
103
+ *
104
+ * Public channels (`markets`, `orderbook:*`, `trades:*`) work on any
105
+ * client. Private channels (`orders:*`, `balance:*`) still require the
106
+ * client to have been created via `connectAuthed` — the server drops
107
+ * private subs from an unauthenticated connection.
108
+ */
109
+ subscribe(channels) {
110
+ if (!this.mode || channels.length === 0)
111
+ return;
112
+ const set = this.modeChannels();
113
+ const added = [];
114
+ for (const ch of channels) {
115
+ if (!set.includes(ch)) {
116
+ set.push(ch);
117
+ added.push(ch);
118
+ }
119
+ }
120
+ if (added.length === 0)
121
+ return;
122
+ this.send({ type: "subscribe", channels: added });
123
+ }
124
+ /**
125
+ * Remove channels from the live subscription set. Sends
126
+ * `{type:'unsubscribe', channels}` if OPEN and drops them from the
127
+ * replay set so a later reconnect does not re-subscribe them. Channels
128
+ * not currently subscribed are ignored.
129
+ */
130
+ unsubscribe(channels) {
131
+ if (!this.mode || channels.length === 0)
132
+ return;
133
+ const set = this.modeChannels();
134
+ const removed = [];
135
+ for (const ch of channels) {
136
+ const i = set.indexOf(ch);
137
+ if (i !== -1) {
138
+ set.splice(i, 1);
139
+ removed.push(ch);
140
+ }
141
+ }
142
+ if (removed.length === 0)
143
+ return;
144
+ this.send({ type: "unsubscribe", channels: removed });
145
+ }
146
+ /** The mutable connect-time channel array for the active mode. */
147
+ modeChannels() {
148
+ if (!this.mode)
149
+ return [];
150
+ return this.mode.kind === "public"
151
+ ? this.mode.channels
152
+ : this.mode.opts.channels;
153
+ }
96
154
  openSocket() {
97
155
  if (this.closed)
98
156
  return;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pulsepairs/sdk",
3
- "version": "0.3.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.5.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",
@@ -11,6 +11,18 @@
11
11
  ".": {
12
12
  "types": "./dist/index.d.ts",
13
13
  "import": "./dist/index.js"
14
+ },
15
+ "./ws": {
16
+ "types": "./dist/ws.d.ts",
17
+ "import": "./dist/ws.js"
18
+ },
19
+ "./eip712": {
20
+ "types": "./dist/eip712.d.ts",
21
+ "import": "./dist/eip712.js"
22
+ },
23
+ "./http": {
24
+ "types": "./dist/http.d.ts",
25
+ "import": "./dist/http.js"
14
26
  }
15
27
  },
16
28
  "files": [
@@ -21,7 +33,7 @@
21
33
  "scripts": {
22
34
  "clean": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\"",
23
35
  "build": "npm run clean && tsc",
24
- "test": "node scripts/eip712-golden.test.mjs && node scripts/rawtx-tier.test.mjs",
36
+ "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 && node scripts/pnl.test.mjs && node scripts/ws-subscribe.test.mjs",
25
37
  "prepublishOnly": "npm run build && npm test",
26
38
  "example:taker": "npx tsx examples/simple-taker.ts",
27
39
  "example:maker": "npx tsx examples/simple-maker.ts",