@pulsepairs/sdk 0.5.2 → 0.6.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.
@@ -0,0 +1,1487 @@
1
+ # `@pulsepairs/sdk` — Reference Documentation
2
+
3
+ **Version:** 0.6.0 · **License:** UNLICENSED · **Runtime:** Node ≥ 18 or a modern browser
4
+
5
+ Complete reference for the UpDown (PulsePairs) TypeScript SDK: every export, its
6
+ units, its failure modes, and the protocol it speaks.
7
+
8
+ The [`README.md`](./README.md) is the quickstart. **This** document is the
9
+ exhaustive reference — read it when you need to know exactly what a function
10
+ returns, what unit a number is in, or why a signature is being rejected.
11
+
12
+ ---
13
+
14
+ ## Table of contents
15
+
16
+ 1. [What this SDK is](#1-what-this-sdk-is)
17
+ 2. [Installation and module layout](#2-installation-and-module-layout)
18
+ 3. [Core concepts](#3-core-concepts)
19
+ - [3.1 Units — the load-bearing table](#31-units--the-load-bearing-table)
20
+ - [3.2 Market identity: composite keys](#32-market-identity-composite-keys)
21
+ - [3.3 Options, sides, order types](#33-options-sides-order-types)
22
+ - [3.4 Fees and `maxFee`](#34-fees-and-maxfee)
23
+ - [3.5 Match geometry: NORMAL vs MINT vs MERGE](#35-match-geometry-normal-vs-mint-vs-merge)
24
+ 4. [The order lifecycle](#4-the-order-lifecycle)
25
+ 5. [`UpDownHttpClient` — REST client](#5-updownhttpclient--rest-client)
26
+ 6. [`UpDownWsClient` — WebSocket client](#6-updownwsclient--websocket-client)
27
+ 7. [EIP-712 signing (`src/eip712.ts`)](#7-eip-712-signing-srceip712ts)
28
+ 8. [Trade math helpers](#8-trade-math-helpers)
29
+ 9. [PnL (`src/pnl.ts`)](#9-pnl-srcpnlts)
30
+ 10. [ERC-20 allowance (`src/approve.ts`)](#10-erc-20-allowance-srcapprovets)
31
+ 11. [On-chain reconciliation (`src/reconcile.ts`)](#11-on-chain-reconciliation-srcreconcilets)
32
+ 12. [Account Kit / smart-account signing (`src/accountKit.ts`)](#12-account-kit--smart-account-signing-srcaccountkitts)
33
+ 13. [Raw-tx tier — bring your own AA send path](#13-raw-tx-tier--bring-your-own-aa-send-path)
34
+ 14. [L2 HMAC auth (`src/auth.ts`)](#14-l2-hmac-auth-srcauthts)
35
+ 15. [Type reference](#15-type-reference)
36
+ 16. [Security checklist for a funded key](#16-security-checklist-for-a-funded-key)
37
+ 17. [Troubleshooting](#17-troubleshooting)
38
+ 18. [Testing and schema-drift guards](#18-testing-and-schema-drift-guards)
39
+ 19. [Compatibility notes](#19-compatibility-notes)
40
+
41
+ ---
42
+
43
+ ## 1. What this SDK is
44
+
45
+ UpDown is a binary prediction market: *will BTC-USD (or ETH-USD) be UP or DOWN at
46
+ the end of this 5-minute / 15-minute / 1-hour cycle?* Trading is an **off-chain
47
+ central limit order book** (the "matcher"); settlement is **on-chain** on
48
+ Arbitrum.
49
+
50
+ The SDK gives you three layers, usable independently:
51
+
52
+ | Layer | Modules | What it does |
53
+ |---|---|---|
54
+ | **Transport** | `http.ts`, `ws.ts` | Typed REST + WebSocket clients for the matcher |
55
+ | **Signing / math** | `eip712.ts`, `auth.ts`, `pnl.ts` | EIP-712 payloads, fee/stake/price math, PnL — all pure, transport-free |
56
+ | **Custody** | `accountKit.ts`, `approve.ts`, `reconcile.ts` | Smart-account (ERC-1271) signing, allowance management, on-chain audit |
57
+
58
+ The critical thing to internalise: **placing an order is not a transaction.** It
59
+ is an EIP-712 signature POSTed to the matcher over HTTP. The relayer submits the
60
+ on-chain fill. You never pay gas to place, amend or cancel an order — only for
61
+ the one-time ERC-20 `approve` (and, on the smart-account path, the one-time
62
+ account deploy).
63
+
64
+ ### The demo environment
65
+
66
+ A full stack runs 24/7 on Arbitrum One with a mock oracle and mintable test USDT:
67
+
68
+ ```
69
+ UI https://demo-pulsepairs.rainwins.com
70
+ REST https://api.demo-pulsepairs.rainwins.com
71
+ WS wss://api.demo-pulsepairs.rainwins.com/stream
72
+ Faucet POST /test/devmint (10k cap, 1 mint / address / 5 min; also seeds gas ETH)
73
+ ```
74
+
75
+ Mint to the **smart-account** address if you are on the Account Kit path, not the
76
+ owner EOA.
77
+
78
+ ---
79
+
80
+ ## 2. Installation and module layout
81
+
82
+ ```bash
83
+ npm install @pulsepairs/sdk viem
84
+ ```
85
+
86
+ `viem` is a **required** peer dependency (`^2.21.0`).
87
+
88
+ For smart-account signing, add the optional peers (the same versions rain.trade
89
+ already ships):
90
+
91
+ ```bash
92
+ npm install @account-kit/wallet-client@^4.88 @account-kit/infra@^4.88 \
93
+ @account-kit/smart-contracts@^4.88 @aa-sdk/core@^4.88
94
+ ```
95
+
96
+ These four are **lazy-imported**. A pure-EOA bot never loads them, and merely
97
+ `import`ing the SDK does not require them to be installed — they are only
98
+ resolved when you construct and connect `UpDownAccountKitSigner` or call a
99
+ raw-tx-tier builder.
100
+
101
+ ### Entry points
102
+
103
+ The package is ESM-only (`"type": "module"`), side-effect free, and ships four
104
+ subpath exports:
105
+
106
+ ```ts
107
+ import { /* everything */ } from "@pulsepairs/sdk";
108
+ import { UpDownWsClient } from "@pulsepairs/sdk/ws";
109
+ import { buildOrderTypedData } from "@pulsepairs/sdk/eip712";
110
+ import { UpDownHttpClient } from "@pulsepairs/sdk/http";
111
+ ```
112
+
113
+ The root export re-exports everything; the subpaths exist for callers who want
114
+ to pull in a single module. There is no `./pnl` or `./accountKit` subpath — use
115
+ the root export for those.
116
+
117
+ ### Source layout
118
+
119
+ ```
120
+ src/
121
+ index.ts barrel — the full public surface
122
+ types.ts wire shapes mirroring the backend's REST/WS responses
123
+ http.ts UpDownHttpClient + wsUrlFromHttpBase
124
+ ws.ts UpDownWsClient (auto-reconnect, auth handshake)
125
+ eip712.ts typed-data builders, nonce/session-id, price/stake/fee math
126
+ auth.ts L2 HMAC credentials + ClobAuth typed data (Node-only)
127
+ approve.ts ensureSettlementAllowance (EOA path)
128
+ pnl.ts BigInt-exact PnL math
129
+ reconcile.ts on-chain userShares vs matcher-reported diff
130
+ accountKit.ts Alchemy SCA signer + raw-tx tier
131
+ examples/ runnable scripts (NOT shipped in the npm tarball)
132
+ scripts/ the test suite (`npm test`)
133
+ ```
134
+
135
+ > **Note:** `files: ["dist", "README.md"]` — if you installed from npm without
136
+ > repo access, `examples/` is not on disk. The safe patterns are inlined in the
137
+ > README and in this document on purpose.
138
+
139
+ ---
140
+
141
+ ## 3. Core concepts
142
+
143
+ ### 3.1 Units — the load-bearing table
144
+
145
+ Getting a unit wrong here produces a silently-wrong number or a signature the
146
+ chain rejects. Nothing in this table is negotiable.
147
+
148
+ | Quantity | Unit | Scale | Appears as |
149
+ |---|---|---|---|
150
+ | `amount`, `shares`, `costBasis`, `markValue`, fees | **atomic USDT** | 1 USDT = `1_000_000` (6 dp) | decimal `string` in JSON, `bigint` in signed messages |
151
+ | `price`, `avgPrice`, `markBps`, `takerPrice` | **basis points** | `10000` = full face = $1.00 | `number` |
152
+ | `platformFeeBps`, `makerFeeBps`, `dmmRebateBps` | basis points | `10000` = 100% | `number` |
153
+ | `nonce`, `expiry` (in `OrderRow`) | uint256 | — | JSON **string** (precision) |
154
+ | `nonce` (in `PostOrderBody`) | uint48-ish | must stay under 2^53 | JSON `number` |
155
+ | `startTime`, `endTime`, `expiry` | unix **seconds** | — | `number` / `bigint` |
156
+ | `option` | enum | `1` = UP, `2` = DOWN | `number` |
157
+
158
+ **Shares ≡ face value.** A share's atomic count *is* the USDT it redeems for if
159
+ it wins. 5,000,000 shares of UP redeem for 5 USDT if UP wins, and 0 if it loses.
160
+ That identity is what makes `markValueAtomic(shares, 10000) === shares` correct.
161
+
162
+ **Prices are bps, not 1e18.** 55¢ is `5500`, not `0.55e18`. Partial cents are
163
+ legal on the wire — 49.5¢ is `4950`. The UI often clamps to whole cents; the
164
+ book does not.
165
+
166
+ **Money math is BigInt.** Every atomic value crossing the SDK's own math stays a
167
+ `bigint` and is returned as a decimal string. The `*Usdt` floats and `roiPct`
168
+ are derived once at the end, for display only. Never feed a display float back
169
+ into money math.
170
+
171
+ ### 3.2 Market identity: composite keys
172
+
173
+ A market is identified two different ways depending on where you are:
174
+
175
+ ```
176
+ composite key 0xBF119B…53ed-1234 ← REST paths, POST body `market`, WS channels
177
+ on-chain id 1234n ← the signed `Order.market` (uint256)
178
+ ```
179
+
180
+ `parseCompositeMarketKey` splits them:
181
+
182
+ ```ts
183
+ const parsed = parseCompositeMarketKey(market.address);
184
+ // → { settlementAddress: "0xbf119b…53ed", marketId: "1234" } | null
185
+ if (!parsed) throw new Error(`bad composite key: ${market.address}`);
186
+ ```
187
+
188
+ Note the returned `settlementAddress` is **lowercased**. Use it to look up the
189
+ EIP-712 domain (§7), and use `BigInt(parsed.marketId)` as the signed
190
+ `Order.market`. Putting the composite string into the signed message, or the
191
+ bare id into the POST body, are both rejections.
192
+
193
+ ### 3.3 Options, sides, order types
194
+
195
+ ```ts
196
+ Option.UP === 1 Option.DOWN === 2 // NOT 0/1
197
+ OrderSide.BUY === 0 OrderSide.SELL === 1
198
+ OrderType.LIMIT === 0 OrderType.MARKET === 1
199
+ OrderType.POST_ONLY === 2 OrderType.IOC === 3
200
+ ```
201
+
202
+ These numeric values match the on-chain encoding — they are what goes in the
203
+ signed message. `PostOrderBody` additionally accepts the string forms
204
+ (`"BUY"`, `"LIMIT"`, …) for `side`/`type`, but the *signature* is always over the
205
+ numbers.
206
+
207
+ `MARKET` orders carry `price: 0` (both in the signed message as `0n` and in the
208
+ POST body as `0`).
209
+
210
+ ### 3.4 Fees and `maxFee`
211
+
212
+ The backend runs a **probability-weighted** fee model: a fill at price `p` (bps)
213
+ pays
214
+
215
+ ```
216
+ effectiveBps = totalBps × 4 × p × (10000 − p) / 10000²
217
+ fee = notional × effectiveBps / 10000
218
+ ```
219
+
220
+ where `totalBps = platformFeeBps + makerFeeBps`. The weight peaks at 50¢
221
+ (`4 × 0.5 × 0.5 = 1`, so `effectiveBps = totalBps`) and falls to zero at either
222
+ extreme — cheap tails, expensive coin-flips. `feeAtomic()` implements exactly
223
+ this, and falls back to flat `totalBps` if `cfg.feeModel` is set to anything
224
+ other than `"probability-weighted"`.
225
+
226
+ **`maxFee` is mandatory on every order** (Hacken finding F-2026-17731). It sits
227
+ between `amount` and `nonce` in the signed `Order`, and the contract's
228
+ `ORDER_TYPEHASH` includes it — omit it and you produce a signature the on-chain
229
+ `SignatureChecker` rejects, so the fill reverts. Always sign the **peak** fee:
230
+
231
+ ```ts
232
+ const maxFee = (amount * BigInt(cfg.platformFeeBps + cfg.makerFeeBps)) / 10000n;
233
+ ```
234
+
235
+ Signing the peak means a legitimate probability-weighted fill can never breach
236
+ the cap (`FeeExceedsTakerCap`). The contract enforces the *actual* cumulative fee
237
+ under this cap across partial fills, so a generous cap costs you nothing.
238
+
239
+ The same value must appear in the POST body as a decimal string. If the two
240
+ disagree by one atomic unit, signature recovery fails.
241
+
242
+ ### 3.5 Match geometry: NORMAL vs MINT vs MERGE
243
+
244
+ The book supports complementary matching — "buy UP" and "buy DOWN" can cross by
245
+ **minting a set** rather than transferring existing shares. That means a `Trade`
246
+ row's two legs may be on *opposite options*, and it changes how you read prices.
247
+
248
+ | `matchType` | Legs | `price` field is… | Taker executed at |
249
+ |---|---|---|---|
250
+ | `NORMAL` | same option, buyer ↔ seller | the execution price | `price` |
251
+ | `MINT` | opposite options, both buying | the resting **maker's** price | `10000 − price` |
252
+ | `MERGE` | opposite options, both selling | the resting **maker's** price | `10000 − price` |
253
+
254
+ On a `Trade` row:
255
+
256
+ - `option` is the **taker's** option. The maker leg is the opposite option on
257
+ MINT/MERGE.
258
+ - `buyer` is the aggressor on MINT/MERGE (a buyer on MINT, a *seller* on MERGE);
259
+ `seller` is the resting maker.
260
+ - `price` is the **maker's** pegged price. Rendering a wallet's own taker fill
261
+ with this number is the single most common display bug in this system.
262
+
263
+ Always use the helper:
264
+
265
+ ```ts
266
+ import { takerPriceBps } from "@pulsepairs/sdk";
267
+
268
+ const shown = takerPriceBps(trade); // trade.takerPrice, else 10000 − price on MINT/MERGE, else price
269
+ ```
270
+
271
+ `matchType` and `takerPrice` are optional on the type so a pre-2026-07-17 backend
272
+ (which omits them) degrades instead of breaking; the helper's fallback covers
273
+ that case.
274
+
275
+ ---
276
+
277
+ ## 4. The order lifecycle
278
+
279
+ ```
280
+ 1. GET /config → chainId, addresses, fee bps, per-pair EIP-712 domains
281
+ 2. GET /markets?pair&timeframe → find an ACTIVE market, take its composite address
282
+ 3. parseCompositeMarketKey(address) → { settlementAddress, marketId }
283
+ 4. ensureSettlementAllowance(...) → ONE on-chain tx, EOA path only, idempotent
284
+ (Account Kit path: ak.onboard(...) — deploy + approve + session in one UserOp)
285
+ 5. maxFee = amount × totalBps / 10000
286
+ nonce = freshNonce()
287
+ 6. buildOrderTypedData({ cfg, settlementAddress, message })
288
+ 7. sign → EOA: account.signTypedData(td)
289
+ SCA: ak.signTypedDataBare(td) / signWithOrderSession({ ... })
290
+ 8. POST /orders { maker, market: <composite>, …, maxFee, nonce, expiry, signature }
291
+ 9. watch: WS `orders:<wallet>` (push) or GET /orders/:wallet (poll)
292
+ 10. cancel: buildCancelTypedData → sign → DELETE /orders/:id
293
+ ```
294
+
295
+ Steps 1–4 are once per session. Steps 5–8 are per order and involve **no chain
296
+ interaction at all**.
297
+
298
+ `expiry` on an order is normally the market's `endTime` — an order cannot
299
+ outlive its market. The cancel signature carries its own, much shorter expiry
300
+ (60s is typical) plus its own nonce, because a captured cancel signature would
301
+ otherwise be replayable forever.
302
+
303
+ ---
304
+
305
+ ## 5. `UpDownHttpClient` — REST client
306
+
307
+ ```ts
308
+ import { UpDownHttpClient } from "@pulsepairs/sdk";
309
+ const api = new UpDownHttpClient("https://api.demo-pulsepairs.rainwins.com");
310
+ ```
311
+
312
+ A thin, dependency-free wrapper over `fetch`. Trailing slashes on the base URL
313
+ are normalised. Query params that are `undefined` or `""` are omitted.
314
+
315
+ ### Error behaviour
316
+
317
+ Every method routes through one parser. On a non-2xx response it throws
318
+ `new Error(msg)` where `msg` is the JSON body's `error` field when present,
319
+ otherwise the raw body text, otherwise `res.statusText`. An empty 2xx body
320
+ resolves to `undefined`.
321
+
322
+ There is **no** built-in retry, timeout, backoff, or auth header injection. If
323
+ you need those, wrap the client or supply your own `fetch` at the platform level.
324
+ Network-level failures surface as whatever `fetch` throws (`TypeError` on most
325
+ runtimes) — they are not normalised.
326
+
327
+ ### Read methods
328
+
329
+ | Method | Endpoint | Returns |
330
+ |---|---|---|
331
+ | `getVersion()` | `GET /version` | `Version` — `{ commit, bootedAt, env, nodeVersion }` |
332
+ | `getHealth()` | `GET /health` | `{ status, relayer?, uptime? }` |
333
+ | `getConfig()` | `GET /config` | `ApiConfig` — **fetch this first, never hardcode addresses** |
334
+ | `getMarkets(opts?)` | `GET /markets` | `MarketListItem[]`; `opts.timeframe` ∈ `300 \| 900 \| 3600`, `opts.pair` ∈ `"BTC-USD" \| "ETH-USD"` |
335
+ | `getMarket(address)` | `GET /markets/:address` | `MarketDetail` — list item + `timeRemainingSeconds` + top-of-book |
336
+ | `getOrderbook(market)` | `GET /orderbook/:address` | `OrderBookFull` — full depth, both options, bids and asks |
337
+ | `getBalance(wallet)` | `GET /balance/:wallet` | `Balance` — `available`, `inOrders`, `cachedBalance`, `withdrawNonce` |
338
+ | `getPositions(wallet)` | `GET /positions/:wallet` | `Position[]` |
339
+ | `getPositionsWithRealized(wallet)` | `GET /positions/:wallet?includeRealized=1` | `{ positions: Position[]; realizedFromSells: string }` — a pre-0.6 backend's bare array degrades to `realizedFromSells: "0"` |
340
+ | `getTrades(wallet, opts?)` | `GET /trades/:wallet` | `Trade[]`; `opts.limit`, `opts.offset` |
341
+ | `getOrders(wallet, opts?)` | `GET /orders/:wallet` | `OrdersResponse`; `opts.status` is an array, sent as repeated `?status=` params |
342
+ | `getStats()` | `GET /stats` | `Stats` — `totalVolume`, `activeMarketsCount`, `totalTraders` |
343
+ | `getPnl(wallet, opts?)` | *(composite)* | `PortfolioPnl` — see below |
344
+
345
+ `getConfig()` is the source of truth for `chainId`, `usdtAddress`,
346
+ `relayerAddress`, fee bps, `usdtDecimals`, and the per-pair `settlementAddress` +
347
+ EIP-712 domain. Hardcoding any of these into a process that holds a funded key is
348
+ how you end up signing for the wrong chain.
349
+
350
+ Note `ApiConfig.settlementAddress` and `ApiConfig.eip712` (top-level) are
351
+ **deprecated** — they carry the *first* pair's values for one release cycle of
352
+ overlap. Use `cfg.pairs[]` with `findPairBySettlement` / `domainForSettlement`.
353
+
354
+ ### `getPnl(wallet, opts?)`
355
+
356
+ Not a backend endpoint — a client-side composition:
357
+
358
+ 1. `GET /positions/:wallet?includeRealized=1` (via `getPositionsWithRealized`) —
359
+ yields the open positions plus `realizedFromSells`, the wallet's realized P&L
360
+ from manual sells (atomic USDT string, fee-exclusive, average-cost; includes
361
+ positions sold fully to zero, which `/positions` alone omits).
362
+ 2. One `GET /markets/:address` per **distinct** market (deduped, issued in
363
+ parallel). A failed read degrades *that position* to a cost-basis mark rather
364
+ than aborting the report.
365
+ 3. Optionally `GET /trades/:wallet?limit=500` when `opts.includeFees` is set.
366
+ 4. `computePortfolioPnl(...)` over the result.
367
+
368
+ ```ts
369
+ const pnl = await api.getPnl(wallet, { includeFees: true, usdtDecimals: 6 });
370
+ console.log(pnl.unrealizedPnlUsdt, pnl.roiPct);
371
+ for (const p of pnl.positions) console.log(p.optionLabel, p.markSource, p.unrealizedPnlUsdt);
372
+ ```
373
+
374
+ An empty position list short-circuits to a zeroed report with no market reads.
375
+ `includeFees` defaults to `false` because it costs an extra request; fees are
376
+ *reported*, never netted into `unrealizedPnl` (cost basis is fee-exclusive). The
377
+ 500-trade cap on the fee query means a very high-volume wallet's `fees` line is a
378
+ partial tally.
379
+
380
+ ### Write methods
381
+
382
+ #### `postOrder(body: PostOrderBody)`
383
+
384
+ ```ts
385
+ const result = await api.postOrder({
386
+ maker: account.address,
387
+ market: live.address, // COMPOSITE key — routing only
388
+ option: Option.UP, // 1 | 2
389
+ side: OrderSide.BUY, // 0 | 1, or "BUY" | "SELL"
390
+ type: OrderType.LIMIT, // 0..3, or "LIMIT" | "MARKET" | "POST_ONLY" | "IOC"
391
+ price: 5500, // bps; 0 for MARKET
392
+ amount: amount.toString(), // atomic USDT
393
+ maxFee: maxFee.toString(), // atomic USDT — MUST equal the signed value
394
+ nonce: Number(nonce), // JSON number — must equal the signed value
395
+ expiry: live.endTime, // unix seconds
396
+ signature,
397
+ });
398
+ // → { id, status, market, option, side, type, price, amount, createdAt }
399
+ ```
400
+
401
+ The backend re-verifies the signature against the per-pair domain on receipt.
402
+ Note the asymmetry: `nonce` here is a JSON **number** (hence `freshNonce()`'s
403
+ 48-bit design, so `Number(nonce)` is lossless), while `OrderRow.nonce` coming
404
+ *back* is a **string** to preserve full uint256 precision.
405
+
406
+ #### `cancelOrder(orderId, body: CancelOrderBody)`
407
+
408
+ ```ts
409
+ const cancelNonce = freshNonce();
410
+ const cancelExpiry = BigInt(Math.floor(Date.now() / 1000) + 60);
411
+ const td = buildCancelTypedData({
412
+ cfg, settlementAddress,
413
+ message: { maker: account.address, orderId: placed.id, nonce: cancelNonce, expiry: cancelExpiry },
414
+ });
415
+ const res = await api.cancelOrder(placed.id, {
416
+ maker: account.address,
417
+ signature: await account.signTypedData(td),
418
+ nonce: cancelNonce.toString(), // strings preserve uint256 precision
419
+ expiry: cancelExpiry.toString(),
420
+ });
421
+ // → { id, status: "CANCEL_PENDING" | … }
422
+ ```
423
+
424
+ The backend caches `(maker, nonce)` for 5 minutes and rejects past-expiry
425
+ signatures. `CANCEL_PENDING` is the normal immediate response — the order leaves
426
+ the book asynchronously.
427
+
428
+ ### `wsUrlFromHttpBase(httpBase)`
429
+
430
+ ```ts
431
+ wsUrlFromHttpBase("https://api.demo-pulsepairs.rainwins.com")
432
+ // → "wss://api.demo-pulsepairs.rainwins.com/stream"
433
+ ```
434
+
435
+ Swaps `https:`→`wss:` / `http:`→`ws:`, forces the path to `/stream`, and strips
436
+ any query and hash.
437
+
438
+ ---
439
+
440
+ ## 6. `UpDownWsClient` — WebSocket client
441
+
442
+ ```ts
443
+ import { UpDownWsClient, wsUrlFromHttpBase } from "@pulsepairs/sdk";
444
+
445
+ const ws = new UpDownWsClient(wsUrlFromHttpBase(API), (msg) => {
446
+ // msg: { type: string; channel?: string; data?: unknown }
447
+ });
448
+ ```
449
+
450
+ The client uses the global `WebSocket`. That is native in browsers and in Node
451
+ ≥ 22; on Node 18–21 either run with `--experimental-websocket` or polyfill
452
+ `globalThis.WebSocket` (e.g. from `ws`) before constructing.
453
+
454
+ ### Channels
455
+
456
+ | Channel | Auth | Payload |
457
+ |---|---|---|
458
+ | `markets` | public | market list updates |
459
+ | `orderbook:<compositeKey>` | public | book deltas for one market |
460
+ | `trades:<compositeKey>` | public | fills for one market |
461
+ | `orders:<wallet>` | **signed** | your order status transitions |
462
+ | `balance:<wallet>` | **signed** | your balance changes |
463
+
464
+ Wallet and market keys in channel names are **lowercased**. On the smart-account
465
+ path the private channels are keyed by the **SCA** address (the trading
466
+ identity), not the owner EOA.
467
+
468
+ ### Public connection
469
+
470
+ ```ts
471
+ ws.connectPublic(["markets", `orderbook:${market.address.toLowerCase()}`]);
472
+ ```
473
+
474
+ Sends `{ type: "subscribe", channels }` immediately on open. No signature.
475
+
476
+ ### Authenticated connection
477
+
478
+ `orders:*` and `balance:*` are gated behind a verified EIP-712 `WsAuth`
479
+ signature. An unauthenticated client that asks for them has those subscriptions
480
+ silently dropped by the server.
481
+
482
+ ```ts
483
+ ws.connectAuthed({
484
+ channels: [`orders:${wallet.toLowerCase()}`, "markets"], // public channels may be mixed in
485
+ signAuth: async () => {
486
+ const timestamp = BigInt(Math.floor(Date.now() / 1000));
487
+ const sessionId = freshSessionId();
488
+ const td = buildWsAuthTypedData({ cfg, wallet, timestamp, sessionId });
489
+ const signature = await account.signTypedData(td);
490
+ return { wallet, timestamp, sessionId, signature };
491
+ },
492
+ onAuthError: (reason) => console.error("ws auth failed:", reason),
493
+ });
494
+ ```
495
+
496
+ On the Account Kit path, `ak.signWsAuth(cfg.chainId)` returns the whole
497
+ `WsAuthCredentials` object for you:
498
+
499
+ ```ts
500
+ ws.connectAuthed({ channels: [`orders:${sca.toLowerCase()}`], signAuth: () => ak.signWsAuth(cfg.chainId) });
501
+ ```
502
+
503
+ ### Handshake protocol
504
+
505
+ ```
506
+ client → { type: "auth", wallet, timestamp, sessionId, signature }
507
+ server → { type: "auth_ok", wallet, token, expiresAt } (token TTL 24h)
508
+ | { type: "auth_error" } (no reason — anti-probe)
509
+
510
+ client → { type: "auth", token } (replay on reconnect)
511
+ server → { type: "auth_ok", wallet, token }
512
+ ```
513
+
514
+ The server accepts a `timestamp` within **±60s** of its own clock and rejects
515
+ `sessionId` reuse inside that window — so generate a fresh one with
516
+ `freshSessionId()` for every signed handshake (token replay carries no
517
+ sessionId).
518
+
519
+ The client caches the `auth_ok` token and its `expiresAt` internally and replays
520
+ it on reconnect, so your `signAuth` callback is only re-invoked when the cache is
521
+ empty, expired, or rejected. It is therefore safe for that callback to sign
522
+ fresh every time it is called. You may also pre-seed a token by returning one on
523
+ `WsAuthCredentials.token` — the client tries replay first and falls back to a
524
+ fresh sign if the server rejects it.
525
+
526
+ **`auth_ok` and `auth_error` are consumed internally and are NOT delivered to
527
+ your `onMessage` handler.** Everything else is.
528
+
529
+ ### State machine and reconnect
530
+
531
+ ```
532
+ idle → authenticating → subscribing → live
533
+ ↘ unauthed-failure (terminal — no further reconnects)
534
+ ```
535
+
536
+ Reconnect backoff on an unexpected close is `1s, 2s, 4s, 8s, 16s, 30s`, then
537
+ capped at 30s; after 12 attempts it is a flat 30s. The counter resets on a
538
+ successful open. Sockets superseded by a newer connect are defensively torn down,
539
+ and their late `onopen`/`onmessage`/`onclose` events are ignored — a rapid
540
+ connect/reconnect churn cannot leave a zombie socket streaming.
541
+
542
+ Two cases deliberately **stop** reconnecting: an explicit `disconnect()`, and a
543
+ terminal auth failure (a freshly-signed signature rejected, or `signAuth`
544
+ throwing). The second avoids a hot loop against a wallet that keeps declining.
545
+
546
+ ### Dynamic subscriptions
547
+
548
+ ```ts
549
+ ws.subscribe([`orderbook:${key}`, `trades:${key}`]); // adds; sends immediately if OPEN
550
+ ws.unsubscribe([`trades:${key}`]); // removes; won't be replayed
551
+ ws.disconnect(); // terminal; stops reconnect
552
+ ```
553
+
554
+ Both merge into the connect-time channel set, so additions survive a reconnect
555
+ (and are replayed *after* the auth handshake on an authed client). Duplicate adds
556
+ and unknown removes are no-ops. Safe to call before the socket opens — the
557
+ channels queue and go out on connect.
558
+
559
+ Private channels still require the client to have been created via
560
+ `connectAuthed`; adding `orders:*` to a public client does not authenticate it.
561
+
562
+ ### Deprecated
563
+
564
+ `connect(payload: SubscribePayload)` routes to `connectPublic(payload.channels)`.
565
+ Its `wallet` field is **ignored** — pre-PR-19 the server auto-attached
566
+ `orders:<wallet>` to anonymous clients, and that gate is now closed. Migrate to
567
+ `connectPublic` / `connectAuthed`.
568
+
569
+ ### Field-name gotcha
570
+
571
+ The WS `order_update` payload uses **`orderType`**; the REST `OrderRow` uses
572
+ **`type`**. Same value, different key.
573
+
574
+ ---
575
+
576
+ ## 7. EIP-712 signing (`src/eip712.ts`)
577
+
578
+ ### Domain resolution
579
+
580
+ There is one settlement contract (and therefore one EIP-712 domain) **per pair**.
581
+ Today's deployment makes `cfg.pairs` a one-entry array, but code that hardcodes
582
+ `pairs[0]` breaks silently the moment a second settlement deploys. Always look up
583
+ by address:
584
+
585
+ ```ts
586
+ findPairBySettlement(cfg.pairs, settlementAddress): PairConfig | null
587
+ domainForSettlement(cfg, settlementAddress): Eip712Domain // throws if unresolvable
588
+ ```
589
+
590
+ `findPairBySettlement` is case-insensitive and returns `null` for the zero
591
+ address or an empty string. `domainForSettlement` prefers `cfg.pairs[]`, falls
592
+ back to the deprecated top-level `cfg.eip712.domain` **only** when that domain's
593
+ `verifyingContract` matches the requested settlement, and otherwise throws with
594
+ the available pairs listed — a misconfiguration produces a loud error rather
595
+ than a silently-wrong signature.
596
+
597
+ The order domain is `{ name: "UpDown Exchange", version, chainId, verifyingContract: <settlement> }`
598
+ as served by `/config`. Never hand-build it.
599
+
600
+ ### `ORDER_TYPES` / `buildOrderTypedData`
601
+
602
+ ```ts
603
+ Order {
604
+ maker address
605
+ market uint256 // BARE marketId, not the composite key
606
+ option uint256 // 1 = UP, 2 = DOWN
607
+ side uint8 // 0 = BUY, 1 = SELL
608
+ type uint8 // 0 = LIMIT, 1 = MARKET, 2 = POST_ONLY, 3 = IOC
609
+ price uint256 // bps; 0 for MARKET
610
+ amount uint256 // atomic USDT
611
+ maxFee uint256 // atomic USDT — F-2026-17731
612
+ nonce uint256
613
+ expiry uint256 // unix seconds
614
+ }
615
+ ```
616
+
617
+ Field order is part of the type hash. It must match `ORDER_TYPEHASH` in
618
+ `UpDownSettlement.sol` exactly — `maxFee` sits between `amount` and `nonce`.
619
+
620
+ ```ts
621
+ const typedData = buildOrderTypedData({
622
+ cfg,
623
+ settlementAddress: parsed.settlementAddress,
624
+ message: {
625
+ maker: account.address,
626
+ market: BigInt(parsed.marketId),
627
+ option: BigInt(Option.UP),
628
+ side: OrderSide.BUY,
629
+ type: OrderType.LIMIT,
630
+ price: 5500n,
631
+ amount,
632
+ maxFee,
633
+ nonce,
634
+ expiry: BigInt(live.endTime),
635
+ },
636
+ });
637
+ const signature = await account.signTypedData(typedData); // viem takes it verbatim
638
+ ```
639
+
640
+ The returned object is `{ domain, types, primaryType, message }` — pass it
641
+ straight to viem's `signTypedData`, or to `ak.signTypedDataBare` /
642
+ `signWithOrderSession` on the smart-account path.
643
+
644
+ ### `CANCEL_TYPES` / `buildCancelTypedData`
645
+
646
+ ```ts
647
+ Cancel { maker address, orderId string, nonce uint256, expiry uint256 }
648
+ ```
649
+
650
+ Same per-pair domain as the order. `nonce` + `expiry` were added in PR-13 so a
651
+ captured cancel signature cannot replay forever; the backend caches
652
+ `(maker, nonce)` for 5 minutes and rejects past-expiry signatures. Drift in this
653
+ schema surfaces as a **404** from the DELETE, not a 400.
654
+
655
+ ### `WS_AUTH_TYPES` / `buildWsAuthTypedData`
656
+
657
+ ```ts
658
+ WsAuth { wallet address, timestamp uint256, sessionId bytes32 }
659
+ ```
660
+
661
+ Deliberately a **different domain** from orders:
662
+ `{ name: "PulsePairs WebSocket Auth", version: "1", chainId, verifyingContract: 0x0 }`.
663
+ Domain separation means an order signature can never be replayed as a WS-auth
664
+ signature or vice versa. It does not depend on the settlement address, so
665
+ `buildWsAuthTypedData` needs only `{ chainId }` from the config.
666
+
667
+ ### `freshSessionId(): 0x${string}`
668
+
669
+ 32 CSPRNG bytes, hex-encoded, for the WS-auth handshake. Throws if
670
+ `globalThis.crypto.getRandomValues` is unavailable rather than degrading to a
671
+ weak source.
672
+
673
+ ### `freshNonce(): bigint`
674
+
675
+ CSPRNG order/cancel nonce. Two design points worth understanding:
676
+
677
+ **Why CSPRNG.** The nonce is the only thing between your order and the backend's
678
+ replay store. A *predictable* nonce lets anyone pre-burn your next one and grief
679
+ your order flow — a liveness attack (the signature itself is still unforgeable).
680
+ `Math.random()` is not a CSPRNG and `Date.now()` is public knowledge; neither is
681
+ acceptable.
682
+
683
+ **Why 48 bits, not 256.** `PostOrderBody.nonce` is a JSON `number`, so the value
684
+ must survive `Number(nonce)` losslessly. Anything above 2^53 silently rounds and
685
+ the posted nonce stops matching the signed one — signature recovery fails.
686
+
687
+ Across 2^48 values, the *cumulative* birthday-bound probability that any two of
688
+ one maker's ~750k orders collide is ≈1e-3 — about one in a thousand, not
689
+ vanishing. (The often-quoted ~1-in-10⁹ is only the *marginal* odds for a single
690
+ new draw.) Either way a collision is a rejected order, not a loss: retry with a
691
+ fresh nonce. Single-writer bots may prefer a monotonic counter — seed it from
692
+ `freshNonce()` rather than from the clock.
693
+
694
+ ---
695
+
696
+ ## 8. Trade math helpers
697
+
698
+ ```ts
699
+ centsToBps(cents: number): number // 55 → 5500; 49.5 → 4950. Throws outside (0,100)
700
+ bpsToCents(bps: number): number // 5500 → 55. Throws outside (0,10000)
701
+ parseStake(usd: string | number): bigint // "5.50" → 5_500_000n. Rejects negative/non-finite
702
+ assertStakeBounds(amountAtomic: bigint): void
703
+ feeAtomic(notionalAtomic, priceBps, cfg): bigint
704
+
705
+ MIN_STAKE_ATOMIC = 5_000_000n // $5
706
+ MAX_STAKE_ATOMIC = 500_000_000n // $500
707
+ ```
708
+
709
+ Both `centsToBps` and `bpsToCents` are exclusive at both ends — 0¢ and 100¢ are
710
+ not tradeable prices, and passing either throws.
711
+
712
+ `assertStakeBounds` is defence in depth. The frontend gates these bounds and the
713
+ backend enforces only the upper one today, so validating **before signing**
714
+ means a bad stake never becomes a signed payload at all.
715
+
716
+ `feeAtomic` mirrors the backend and contract formula (§3.4). Treats a missing
717
+ `cfg.feeModel` as probability-weighted:
718
+
719
+ ```ts
720
+ feeAtomic(5_000_000n, 5000, cfg) // peak weight at 50¢ → notional × totalBps / 10000
721
+ feeAtomic(5_000_000n, 950, cfg) // ≈0.34 × totalBps — cheap tail
722
+ ```
723
+
724
+ Use `feeAtomic` to *estimate* what a fill will cost. Use the flat peak formula
725
+ (§3.4) for the signed `maxFee` cap — they are different jobs.
726
+
727
+ ---
728
+
729
+ ## 9. PnL (`src/pnl.ts`)
730
+
731
+ The matcher exposes the ingredients for PnL but never a PnL number. This module
732
+ computes one in exactly the units the backend already uses, so the result
733
+ reconciles with the backend's own position fold instead of drifting from it.
734
+
735
+ ### Marking rules
736
+
737
+ | Situation | `markBps` | `markSource` |
738
+ |---|---|---|
739
+ | Market resolved, you hold the winner | `10000` | `"settled"` |
740
+ | Market resolved, you hold the loser | `0` | `"settled"` |
741
+ | Live, both sides quoted | `round((bestBid + bestAsk) / 2)` | `"orderbook-mid"` |
742
+ | Live, only a bid | `bestBid` | `"orderbook-bid"` |
743
+ | Live, only an ask | `bestAsk` | `"orderbook-ask"` |
744
+ | Caller supplied `markBps` | as given | `"explicit"` |
745
+ | No signal at all | the position's own `avgPrice` | `"cost"` |
746
+
747
+ **Resolution always wins** over any supplied mark: a settled market is worth
748
+ exactly full face or nothing, never a stale quote. The `"cost"` fallback is a
749
+ deliberately honest 0-PnL mark — flagged so you can tell a real
750
+ mark-to-market from "we had nothing to price this with".
751
+
752
+ `isResolvedWinner(winner)` is `winner === 1 || winner === 2`. The contract's
753
+ `resolve` rejects any other value, so there is no draw or void state.
754
+
755
+ ### Core math
756
+
757
+ ```
758
+ markValue = shares × markBps / 10000 (floor division, matching the backend)
759
+ unrealizedPnl = markValue − costBasis (signed)
760
+ roiPct = 100 × unrealizedPnl / costBasis (null when costBasis is 0)
761
+ ```
762
+
763
+ ### API
764
+
765
+ ```ts
766
+ positionPnl(position: Position, opts?: PositionPnlOptions): PositionPnl
767
+ portfolioPnl(items: PositionPnl[], opts?: { fees?, usdtDecimals? }): PortfolioPnl
768
+ computePortfolioPnl(positions, marketsByKey, opts?): PortfolioPnl
769
+ feesPaidByWallet(wallet: string, trades: Trade[]): string
770
+ markValueAtomic(sharesAtomic: bigint, markBps: number): bigint
771
+ midBps(side: OrderBookSide | null | undefined): { bps, source } | null
772
+ markForOption(orderBook, option: number): { bps, source } | null
773
+ isResolvedWinner(winner): winner is 1 | 2
774
+ atomicToUsdt(atomic: bigint | string, decimals?): number
775
+ ```
776
+
777
+ All of these are pure and transport-free — hand them data you already fetched:
778
+
779
+ ```ts
780
+ positionPnl(position, { markBps: 7000 }); // explicit mark
781
+ positionPnl(position, { winner: 1 }); // settled: full-face or zero
782
+ computePortfolioPnl(positions, marketsByKey); // roll-up; missing market → cost mark
783
+ ```
784
+
785
+ `marketsByKey` maps a position's `market` composite key to
786
+ `Pick<MarketDetail, "winner" | "orderBook">`. A missing entry degrades that
787
+ position to a cost mark rather than dropping it from the report.
788
+
789
+ `feesPaidByWallet` sums `platformFee + makerFee` across every trade the wallet
790
+ appears in on either leg. It makes no maker/taker attribution — treat it as an
791
+ **upper bound** on the wallet's fee drag.
792
+
793
+ Fees are never netted into `unrealizedPnl`, because cost basis is fee-exclusive
794
+ (matching the backend's fold). They stay a separate, explicit `fees` line.
795
+
796
+ `PortfolioPnl.realizedFromSells` (0.6.0) follows the same carried-not-netted
797
+ rule: it is the backend-computed realized P&L from manual sells (atomic USDT
798
+ string, signed, fee-exclusive), reported alongside `unrealizedPnl` rather than
799
+ folded into it. `getPnl` fills it automatically; the pure helpers accept it via
800
+ `opts.realizedFromSells` and default to `"0"`. Per-sell attribution is
801
+ average-cost (buys fold before sells), so only the combined total
802
+ (settlement outcomes + `realizedFromSells`) is order-invariant — do not present
803
+ it as a per-trade breakdown.
804
+
805
+ ### Constants
806
+
807
+ ```ts
808
+ PRICE_BPS_SCALE = 10_000n WIN_MARK_BPS = 10_000
809
+ LOSE_MARK_BPS = 0 DEFAULT_USDT_DECIMALS = 6
810
+ ```
811
+
812
+ ---
813
+
814
+ ## 10. ERC-20 allowance (`src/approve.ts`)
815
+
816
+ Settlement pulls USDT directly from the maker via `transferFrom` inside
817
+ `enterPosition`. Without an allowance the first BUY reverts with
818
+ `ERC20: insufficient allowance`.
819
+
820
+ ```ts
821
+ const result = await ensureSettlementAllowance({
822
+ publicClient, // viem PublicClient
823
+ walletClient, // viem WalletClient with an account, funded with ETH
824
+ usdt: cfg.usdtAddress,
825
+ settlement: parsed.settlementAddress,
826
+ amount: stake * 20n, // HOW MUCH to approve (default 100k USDT)
827
+ threshold: stake, // WHEN to top up (default 10k USDT)
828
+ });
829
+ // → { status: "already_ok", allowance } | { status: "approved", txHash, allowance }
830
+ ```
831
+
832
+ Idempotent: reads the current allowance and only sends a tx when it is below
833
+ `threshold`.
834
+
835
+ **`amount` and `threshold` are different questions.** `threshold` is *when* to
836
+ top up; `amount` is *how much* to approve when you do. The helper throws up-front
837
+ if `amount < threshold`, because that configuration re-approves on every single
838
+ call.
839
+
840
+ ### Why the default is bounded
841
+
842
+ `DEFAULT_APPROVAL_AMOUNT` is **100,000 USDT**, not `MAX_UINT256`. The
843
+ `settlement` address is routinely sourced from a server-supplied `/config` or
844
+ market list — an unlimited approval would grant a server-named spender unbounded
845
+ reach into a funded hot wallet. The bounded default caps that blast radius while
846
+ sitting comfortably above `DEFAULT_THRESHOLD` (10k) so a continuously-quoting bot
847
+ isn't re-approving constantly.
848
+
849
+ `MAX_UINT256` is exported for the rare caller who explicitly opts into unlimited.
850
+
851
+ ### Operational note
852
+
853
+ A bounded allowance is **consumed by fills**. A bot that quotes continuously will
854
+ run it down. This helper only tops up when called — call it inside your quoting
855
+ loop, not just at startup, or fills start reverting mid-session.
856
+
857
+ To confirm before trading:
858
+
859
+ ```ts
860
+ if (result.status === "approved") await publicClient.waitForTransactionReceipt({ hash: result.txHash });
861
+ ```
862
+
863
+ ---
864
+
865
+ ## 11. On-chain reconciliation (`src/reconcile.ts`)
866
+
867
+ `UpDownSettlement.userShares[marketId][holder][option]` is the **authoritative**
868
+ record of what a holder owns — it is the number `redeem`/`redeemFor` pays
869
+ against. The matcher's off-chain ledger is a *report* of that. A bot that trusts
870
+ only the API cannot see a ledger-integrity failure in its own book.
871
+
872
+ This module closes that gap with a read-only ABI slice (no write surface):
873
+
874
+ ```ts
875
+ const chain = await readOnChainHolderShares({ publicClient, settlement, marketId, holder });
876
+ // → { marketId, holder, up, down, resolved, winner } winner: 1 | 2 | 0
877
+
878
+ const reported = reportedFromPositions(
879
+ positions.filter((p) => p.market === compositeKey),
880
+ );
881
+ // → { up: bigint, down: bigint }
882
+
883
+ const report = await reconcileFills({ publicClient, settlement, marketId, holder, reported });
884
+ // → { marketId, holder, resolved, winner, lines, ok, hasPhantom }
885
+ ```
886
+
887
+ Each line is `{ option, optionLabel, onChain, reported, drift, status }` where
888
+ `drift = reported − onChain` and `status` is:
889
+
890
+ - `"match"` — exact agreement
891
+ - `"reported_over"` — the matcher claims **more** than the chain backs. The
892
+ dangerous direction: a phantom position you would price against but cannot
893
+ redeem.
894
+ - `"reported_under"` — the matcher claims less than the chain backs.
895
+
896
+ `ok` is true when every option matches; `hasPhantom` is true when any line is
897
+ `reported_over`.
898
+
899
+ **Before alerting on `hasPhantom`, check `resolved` and `winner`.** After a
900
+ redemption the holder has already been paid and `userShares` is zeroed, so a
901
+ winner-side `reported_over` is benign — it just means the off-chain ledger hasn't
902
+ caught up.
903
+
904
+ Run this on a timer against your own positions. It is an independent detector,
905
+ not a courtesy check.
906
+
907
+ ---
908
+
909
+ ## 12. Account Kit / smart-account signing (`src/accountKit.ts`)
910
+
911
+ ### Why this module exists
912
+
913
+ rain.trade custody is an Alchemy Account Kit smart-contract account (SCA). The
914
+ UpDown settlement verifies order signatures with OpenZeppelin
915
+ `SignatureChecker.isValidSignatureNow(maker, digest, sig)` (OZ 5.6.1), which
916
+ routes to `IERC1271(maker).isValidSignature(...)` when `maker` is a contract.
917
+
918
+ Account Kit derives the SCA address deterministically from (owner, factory), so
919
+ **the same owner EOA + the same config ⇒ the same SCA address ⇒ one wallet
920
+ across both products**. This module mirrors rain's `RainAA` *config*, not its
921
+ code — `RainAA` is a published, unpatchable package that exposes no
922
+ `signTypedData`.
923
+
924
+ ### The two rules you cannot break
925
+
926
+ Both come from the on-chain verifier, which is **not** EIP-6492-aware:
927
+
928
+ 1. **Deploy the SCA before its first fill.** A counterfactual (undeployed)
929
+ account cannot be verified via ERC-1271 — the fill reverts. `onboard()` does
930
+ the deploy; `isDeployed(publicClient)` checks it.
931
+ 2. **Bare ERC-1271, never 6492.** `signTypedData` on an undeployed account can
932
+ return a 6492-wrapped blob the settlement cannot parse. Every signature this
933
+ SDK produces passes through `stripErc6492Wrapper(...)`.
934
+
935
+ > Confirmed live on Arbitrum One (2026-07-05/06): a real SCA was deployed,
936
+ > `isValidSignature` over the full Order typed-data returned the `0x1626ba7e`
937
+ > magic on-chain, a real `enterPosition` fill settled with `maker = SCA`, and the
938
+ > negative control (undeployed SCA) reverted as expected. The MA-v2 / ERC-7739
939
+ > question is resolved — sign the **full typed data** through the account client
940
+ > (which this SDK does); handing the SCA a bare `bytes32` silently fails.
941
+
942
+ ### Constructing
943
+
944
+ ```ts
945
+ import { UpDownAccountKitSigner } from "@pulsepairs/sdk";
946
+ import { arbitrum } from "viem/chains";
947
+
948
+ const ak = new UpDownAccountKitSigner({
949
+ walletClient: window.ethereum, // owner EOA EIP-1193 provider
950
+ alchemyApiKey: ALCHEMY_KEY, // SAME key rain.trade uses
951
+ paymasterPolicyId: POLICY_ID, // SAME policy rain.trade uses (optional)
952
+ chain: arbitrum, // SAME chain (42161) — required for address parity
953
+ // gasToken: { tokenAddress }, // only for an ERC-20-type Gas Manager policy
954
+ // rpcUrl, // node RPC override
955
+ // orderSessions: true, // default; false forces owner-key signing
956
+ // orderSessionStorage, // default: localStorage, else in-memory
957
+ });
958
+ ```
959
+
960
+ Gas semantics follow the policy:
961
+
962
+ | Config | Who pays gas |
963
+ |---|---|
964
+ | Sponsorship-type `paymasterPolicyId` | the app (sponsored) |
965
+ | ERC-20-type `paymasterPolicyId` + `gasToken` | the user, in that token (must be allowlisted and held by the SCA) |
966
+ | No `paymasterPolicyId` | self-paid — the SCA must hold ETH |
967
+
968
+ The constructor validates `walletClient`, `alchemyApiKey` and `chain` eagerly and
969
+ throws on any missing.
970
+
971
+ ### Lifecycle
972
+
973
+ ```ts
974
+ const sca = await ak.connect(); // → SCA address; this is order.maker
975
+ await ak.onboard({ usdt: cfg.usdtAddress, settlement, amount }); // ONE UserOp
976
+ const sig = await ak.signTypedDataBare(typedData); // bare ERC-1271
977
+ ```
978
+
979
+ `connect()` lazily imports the peer deps, builds the owner smart-wallet client,
980
+ resolves the SCA, and caches the address under `rain:sa:<eoa>` in `localStorage`
981
+ (the same cache key rain.trade's `useRain` uses). It does **not** prompt for a
982
+ session. Order signing works immediately via the owner key.
983
+
984
+ `onboard()` batches **deploy + `approve(settlement)` + order-session install**
985
+ into a single UserOp — still exactly one owner signature. Deployment is carried
986
+ by the account init-code. If the order-session prep fails it degrades gracefully
987
+ to plain deploy+approve, and orders fall back to owner-key signing.
988
+
989
+ > ⚠️ `onboard()` and `approve()` default `amount` to `MAX_UINT256`. Unlike
990
+ > `ensureSettlementAllowance`, whose default is bounded, these are unlimited for
991
+ > backwards compatibility. Pass an explicit `amount` — `settlement` came from the
992
+ > matcher's own config.
993
+
994
+ ### Methods and properties
995
+
996
+ | Member | Description |
997
+ |---|---|
998
+ | `connect()` | Build the client, resolve + return the SCA address. Idempotent. |
999
+ | `address` | The SCA — `order.maker`, deposit address, ERC-1271 signer. Throws before `connect()`. |
1000
+ | `ownerEoa` | The owner EOA behind it. Throws before `connect()`. |
1001
+ | `ownerClient` | The raw Alchemy smart-wallet client (escape hatch). |
1002
+ | `signTypedDataBare(td)` | **The only signing path** for orders/cancels/WS-auth. Order-session-signed when active, else owner-signed with the 6492 wrapper stripped. |
1003
+ | `signWsAuth(chainId)` | Builds + signs a WS-auth handshake, returns `WsAuthCredentials` ready for `connectAuthed`. |
1004
+ | `isDeployed(publicClient)` | Bytecode check — the deploy-before-fill precondition. |
1005
+ | `onboard({ usdt, settlement, amount? })` | Deploy + approve + install session in one UserOp. Returns the tx hash. |
1006
+ | `approve({ usdt, settlement, amount? })` | Idempotent gasless approve. |
1007
+ | `withdraw({ usdt, to, amount })` | Transfer USDT out of the SCA. |
1008
+ | `sendCall({ to, data, value? })` | Arbitrary call from the SCA as a UserOp. Returns the tx hash. |
1009
+ | `grantSession()` | 24h **send** session (one popup). Safe to call when one is active. |
1010
+ | `hasActiveSession` | Whether a send session is live. |
1011
+ | `ensureOrderSession()` | Install an order session on an already-onboarded account. → `"disabled" \| "active" \| "installed"` |
1012
+ | `revokeOrderSession()` | Drop the **local** session key (no on-chain uninstall). |
1013
+ | `hasOrderSession` | Whether an unexpired order session exists for this SCA. |
1014
+ | `disconnect()` | Clear in-memory state. Persisted sessions survive. |
1015
+
1016
+ ### The two session mechanisms — do not conflate them
1017
+
1018
+ **1. Send session** (`grantSession()`, Alchemy `grantPermissions({type:'root'})`)
1019
+ authorizes UserOp **execution** only — gasless `onboard`/`approve`/`withdraw`,
1020
+ mirroring RainAA. Its signatures pack the *owner* validation entity, so it is
1021
+ **verified incapable** of ERC-1271 order signing (`isValidSignature` returns
1022
+ `0xffffffff`).
1023
+
1024
+ **2. Order session** (default **ON**) is a locally-generated key installed on the
1025
+ MA-v2 SCA as a `SingleSignerValidationModule` entity with
1026
+ `isSignatureValidation: true, isUserOpValidation: false`. It can **only** answer
1027
+ `isValidSignature` — orders, cancels, WS-auth, popup-less — and can **never**
1028
+ move funds or execute a UserOp. `onboard()` batches the install into the same
1029
+ single UserOp as deploy+approve.
1030
+
1031
+ Behaviour:
1032
+
1033
+ - `signTypedDataBare` prefers the order-session key and **falls back to the owner
1034
+ key automatically on any failure** — enabling this can never break signing.
1035
+ - Storage defaults to browser `localStorage` under `updown:oskey:<sca>`, an
1036
+ in-memory map in Node (session lives for the process), or bring your own via
1037
+ `orderSessionStorage`.
1038
+ - The installed entity id is a CSPRNG-drawn 4-byte value ≥ 2 (0 is the owner
1039
+ entity; installing an existing id reverts). Randomness prevents an observer
1040
+ from front-running the install and bricking onboarding by burning the id.
1041
+ - Opt out entirely with `orderSessions: false` — every order then prompts the
1042
+ owner wallet.
1043
+
1044
+ **Known limitations** (fine for integration/demo; review before launch):
1045
+ expiry is **client-side only** (24h, not enforced on-chain), and
1046
+ `revokeOrderSession()` does not uninstall the validation entity on-chain.
1047
+
1048
+ ### Standalone helpers
1049
+
1050
+ ```ts
1051
+ isErc6492Signature(sig: Hex): boolean
1052
+ stripErc6492Wrapper(sig: Hex): Hex // returns input unchanged if not wrapped
1053
+ bareErc1271Signer(raw: RawTypedDataSigner): RawTypedDataSigner
1054
+ ```
1055
+
1056
+ If the host app already owns an Alchemy client (e.g. rain.trade's shared
1057
+ session), skip the signer class entirely and just make its output
1058
+ UpDown-correct:
1059
+
1060
+ ```ts
1061
+ const signOrder = bareErc1271Signer((td) => rainSmartWalletClient.signTypedData(td));
1062
+ const signature = await signOrder(buildOrderTypedData({ cfg, settlementAddress, message }));
1063
+ ```
1064
+
1065
+ ---
1066
+
1067
+ ## 13. Raw-tx tier — bring your own AA send path
1068
+
1069
+ If you run your own Account-Abstraction send path (rain.trade's `sendTxs` through
1070
+ their root session), use these transport-free primitives instead of the signer
1071
+ class. They emit **byte-identical** calldata to what the class sends — the class
1072
+ delegates to the same internals.
1073
+
1074
+ ```ts
1075
+ export type RawTransaction = { to: Address; data: Hex; value?: bigint };
1076
+ ```
1077
+
1078
+ That is rain's exact `RawTransaction` shape, so these drop straight into
1079
+ `sendTxs`/`sendCalls` with no adapter.
1080
+
1081
+ ```ts
1082
+ import {
1083
+ buildApproveSettlementTx, buildInstallOrderSessionTx, signWithOrderSession,
1084
+ } from "@pulsepairs/sdk";
1085
+
1086
+ // ── Onboard once: batch both raw txs into ONE userOp through YOUR session ──
1087
+ const approveTx = buildApproveSettlementTx({ usdt: cfg.usdtAddress, settlement, amount });
1088
+ const { tx: installTx, session } = await buildInstallOrderSessionTx({ sca, chain: arbitrum });
1089
+ await sendTxs([approveTx, installTx]);
1090
+ persist(session); // { v: 1, privateKey, entityId, expirySec }
1091
+
1092
+ // ── Every order after that: no chain, no gas, no popup ──
1093
+ const typedData = buildOrderTypedData({ cfg, settlementAddress, message /* maker: sca, … */ });
1094
+ const signature = await signWithOrderSession({ session, sca, chain: arbitrum, alchemyApiKey, typedData });
1095
+ await api.postOrder({ maker: sca, /* … */ signature });
1096
+ ```
1097
+
1098
+ | Function | Sync? | Peer deps? | Notes |
1099
+ |---|---|---|---|
1100
+ | `buildApproveSettlementTx` | yes | no | Pure calldata. `amount` defaults to `MAX_UINT256` — pass a bound. |
1101
+ | `buildInstallOrderSessionTx` | async | yes | Generates the key locally; no network, gas, or popup. |
1102
+ | `signWithOrderSession` | async | yes | Pure local ECDSA over the MA-v2 `signerEntity`. No bundler. |
1103
+
1104
+ **The one mental-model flip:** placing an order is not a transaction, so there is
1105
+ no `buildPlaceOrderTx`. You sign and POST.
1106
+
1107
+ **Adoption path.** MVP = `approve` (1 raw tx) + `bareErc1271Signer` owner path
1108
+ (1 popup per order, zero new code). Production = `approve` + install (batched) +
1109
+ `signWithOrderSession` (popup-less). The on-chain acceptance of these exact bytes
1110
+ on rain's MA-v2 implementation is proven by
1111
+ `updown-contracts/test/OrderSessionForkTest.t.sol`.
1112
+
1113
+ The `sca` must be deployed and carry the session entity before a
1114
+ `signWithOrderSession` signature is used in a fill.
1115
+
1116
+ ---
1117
+
1118
+ ## 14. L2 HMAC auth (`src/auth.ts`)
1119
+
1120
+ An API-key auth layer for bots, structurally identical to Polymarket's
1121
+ `clob-client` HMAC scheme (bytes-level compatible).
1122
+
1123
+ > **Node-only.** This module imports `crypto` from Node. It is not
1124
+ > browser-safe, and **`apiSecret` must never reach client-side code.**
1125
+
1126
+ ```ts
1127
+ import { buildHmacSignature, HMAC_HEADERS, buildClobAuthTypedData } from "@pulsepairs/sdk";
1128
+
1129
+ // One-time: obtain credentials (store apiSecret + passphrase out-of-band — env, KMS).
1130
+ const { apiKey, apiSecret, passphrase } = await (await fetch("/auth/credentials", { … })).json();
1131
+
1132
+ // Every subsequent request:
1133
+ const ts = Math.floor(Date.now() / 1000); // SECONDS, not ms
1134
+ const sig = buildHmacSignature(apiSecret, ts, "DELETE", "/orders/abc");
1135
+
1136
+ await fetch("/orders/abc", {
1137
+ method: "DELETE",
1138
+ headers: {
1139
+ [HMAC_HEADERS.ADDRESS]: address,
1140
+ [HMAC_HEADERS.API_KEY]: apiKey,
1141
+ [HMAC_HEADERS.PASSPHRASE]: passphrase,
1142
+ [HMAC_HEADERS.TIMESTAMP]: String(ts),
1143
+ [HMAC_HEADERS.SIGNATURE]: sig,
1144
+ },
1145
+ });
1146
+ ```
1147
+
1148
+ ### `buildHmacSignature(secretBase64, timestamp, method, requestPath, body?)`
1149
+
1150
+ Signs `${timestamp}${method}${requestPath}${body ?? ""}` with HMAC-SHA256 over
1151
+ the base64-decoded secret, and returns **base64url** (`+`→`-`, `/`→`_`).
1152
+
1153
+ - `timestamp` — unix **seconds**.
1154
+ - `method` — uppercase.
1155
+ - `requestPath` — path **with query string**, no host (`"/orders/abc?foo=1"`).
1156
+ - `body` — the raw JSON-stringified body, byte-for-byte what you send; omit for
1157
+ GET/DELETE.
1158
+
1159
+ Any divergence in these — ms instead of seconds, a re-serialized body, a missing
1160
+ query string — produces a signature the server rejects.
1161
+
1162
+ ### `buildClobAuthTypedData(domain, address, timestamp, nonce)`
1163
+
1164
+ The EIP-712 `ClobAuth` payload a bot signs once at session open, to obtain
1165
+ credentials.
1166
+
1167
+ ```ts
1168
+ ClobAuth { address address, timestamp string, nonce uint256, message string }
1169
+ CLOB_AUTH_MESSAGE = "This message attests that I control the given wallet"
1170
+ ```
1171
+
1172
+ `domain` is `{ name, version, chainId }` — `"PulsePairsAuthDomain"` on this
1173
+ backend. Note `timestamp` is a **string** in this schema (unlike `WsAuth`, where
1174
+ it is a uint256).
1175
+
1176
+ ### Header constants
1177
+
1178
+ ```ts
1179
+ HMAC_HEADERS = {
1180
+ ADDRESS: "X-PP-ADDRESS",
1181
+ API_KEY: "X-PP-API-KEY",
1182
+ PASSPHRASE: "X-PP-PASSPHRASE",
1183
+ TIMESTAMP: "X-PP-TIMESTAMP",
1184
+ SIGNATURE: "X-PP-SIGNATURE",
1185
+ }
1186
+ ```
1187
+
1188
+ Use the constants, not literals.
1189
+
1190
+ ---
1191
+
1192
+ ## 15. Type reference
1193
+
1194
+ ### Config
1195
+
1196
+ ```ts
1197
+ type ApiConfig = {
1198
+ chainId: number;
1199
+ usdtAddress: `0x${string}`;
1200
+ relayerAddress: `0x${string}`;
1201
+ platformFeeBps: number;
1202
+ makerFeeBps: number;
1203
+ feeModel?: "probability-weighted" | string;
1204
+ peakFeeBps?: number; // platformFeeBps + makerFeeBps
1205
+ dmmRebateBps: number;
1206
+ usdtDecimals: number;
1207
+ pairs: PairConfig[]; // ← use this
1208
+ settlementAddress: `0x${string}`; // @deprecated — first pair's value
1209
+ eip712: { domain: Eip712Domain }; // @deprecated — first pair's domain
1210
+ };
1211
+
1212
+ type PairConfig = {
1213
+ pairId: "BTC-USD" | "ETH-USD";
1214
+ settlementAddress: `0x${string}`;
1215
+ autocyclerAddress: `0x${string}` | ""; // "" when read-only (no autocycler wired)
1216
+ eip712: { domain: Eip712Domain };
1217
+ };
1218
+
1219
+ type Eip712Domain = { name: string; version: string; chainId: number; verifyingContract: `0x${string}` };
1220
+ ```
1221
+
1222
+ ### Markets
1223
+
1224
+ ```ts
1225
+ type MarketListItem = {
1226
+ address: string; // COMPOSITE key
1227
+ marketId?: string;
1228
+ settlementAddress?: `0x${string}`;
1229
+ pairId: string;
1230
+ pairSymbol?: "BTC-USD" | "ETH-USD";
1231
+ chartSymbol?: "BTC" | "ETH";
1232
+ startTime: number; // unix sec
1233
+ endTime: number; // unix sec
1234
+ duration: number; // sec
1235
+ status: "ACTIVE" | "TRADING_ENDED" | "RESOLVED" | "CLAIMED" | string;
1236
+ winner: number | null; // 1 = UP, 2 = DOWN, null = unresolved
1237
+ upPrice: string;
1238
+ downPrice: string;
1239
+ strikePrice?: string;
1240
+ settlementPrice?: string;
1241
+ volume: string;
1242
+ };
1243
+
1244
+ type MarketDetail = MarketListItem & {
1245
+ timeRemainingSeconds: number;
1246
+ orderBook: { up: OrderBookSide; down: OrderBookSide }; // top of book only
1247
+ };
1248
+
1249
+ type OrderBookSide = { bestBid: { price: number; depth: string } | null;
1250
+ bestAsk: { price: number; depth: string } | null };
1251
+ type OrderBookFull = { up: { bids: OrderBookLevel[]; asks: OrderBookLevel[] };
1252
+ down: { bids: OrderBookLevel[]; asks: OrderBookLevel[] } };
1253
+ type OrderBookLevel = { price: number; depth: string; count: number };
1254
+ ```
1255
+
1256
+ `getMarket` gives top-of-book; `getOrderbook` gives full depth.
1257
+
1258
+ ### Account
1259
+
1260
+ ```ts
1261
+ type Balance = {
1262
+ wallet: string; smartAccountAddress: string;
1263
+ available: string; inOrders: string; // atomic USDT
1264
+ cachedBalance: string; balanceLastSyncedAt: string;
1265
+ withdrawNonce: number;
1266
+ };
1267
+
1268
+ type Position = {
1269
+ market: string; // composite key
1270
+ marketStatus: string;
1271
+ option: number; // 1 | 2
1272
+ optionLabel: "UP" | "DOWN";
1273
+ shares: string; // atomic — also the face value at settlement
1274
+ avgPrice: number; // bps
1275
+ costBasis: string; // atomic, FEE-EXCLUSIVE = shares × avgPrice / 10000
1276
+ };
1277
+
1278
+ type OrderRow = {
1279
+ orderId: string; maker: string; market: string;
1280
+ option: number; side: number; type: number; // numeric enums
1281
+ price: number; // bps
1282
+ amount: string; filledAmount: string; // atomic
1283
+ nonce: string; expiry: string; // uint256 as STRINGS
1284
+ signature: string;
1285
+ status: OrderStatus; reason?: string;
1286
+ createdAt: string; updatedAt?: string;
1287
+ };
1288
+
1289
+ type OrderStatus = "OPEN" | "PARTIALLY_FILLED" | "FILLED" | "CANCEL_PENDING" | "CANCELLED" | string;
1290
+ type OrdersResponse = { orders: OrderRow[]; total: number; limit: number; offset: number };
1291
+ ```
1292
+
1293
+ `OrderRow.nonce` / `expiry` are JSON strings because the backend stores them as
1294
+ `String` to preserve full uint256 precision — a `number` would silently truncate
1295
+ above 2^53. Parse with `BigInt(row.nonce)`, or pass the string straight to
1296
+ `signTypedData` (viem accepts string-form bigints).
1297
+
1298
+ ### Trades
1299
+
1300
+ See §3.5 for the `matchType` / `takerPrice` semantics — the fields most likely to
1301
+ be misread.
1302
+
1303
+ ```ts
1304
+ type Trade = {
1305
+ tradeId: string; market: string;
1306
+ option: number; // the TAKER's option
1307
+ buyOrderId: string; sellOrderId: string;
1308
+ buyer: string; // NORMAL: buyer. MINT/MERGE: the aggressor
1309
+ seller: string; // NORMAL: seller. MINT/MERGE: the resting maker
1310
+ price: number; // bps — the resting MAKER's price
1311
+ amount: string;
1312
+ platformFee: string; makerFee: string;
1313
+ settlementStatus: string; createdAt: string;
1314
+ matchType?: "NORMAL" | "MINT" | "MERGE"; // optional: pre-2026-07-17 backends omit
1315
+ takerPrice?: number; // optional: bps the taker actually paid
1316
+ };
1317
+
1318
+ takerPriceBps(trade): number // always use this to render a taker fill
1319
+ ```
1320
+
1321
+ ### Request bodies
1322
+
1323
+ ```ts
1324
+ type PostOrderBody = {
1325
+ maker: string;
1326
+ market: string; // COMPOSITE key — routing only
1327
+ option: number;
1328
+ side: number | "BUY" | "SELL";
1329
+ type: number | "LIMIT" | "MARKET" | "POST_ONLY" | "IOC";
1330
+ price?: number; // bps; 0 for MARKET
1331
+ amount: string; // atomic
1332
+ maxFee: string; // atomic — REQUIRED, must equal the signed value
1333
+ nonce: number; // JSON number (< 2^53)
1334
+ expiry: number; // unix sec
1335
+ signature: string;
1336
+ };
1337
+
1338
+ type CancelOrderBody = {
1339
+ maker: string;
1340
+ signature: string;
1341
+ nonce: string | number; // send as STRING for uint256 precision
1342
+ expiry: string | number;
1343
+ };
1344
+ ```
1345
+
1346
+ ---
1347
+
1348
+ ## 16. Security checklist for a funded key
1349
+
1350
+ This SDK is normally driven by a hot key holding real USDT. Each of these exists
1351
+ because the corresponding failure is **silent**.
1352
+
1353
+ **1. Never default your endpoints.** A bot signs orders for whatever matcher you
1354
+ point it at, then grants that matcher's *server-supplied* settlement address a
1355
+ USDT allowance. A defaulted API URL plus a defaulted mainnet RPC means a real
1356
+ funded key trading against a box you never chose.
1357
+
1358
+ ```ts
1359
+ const API = process.env.UPND_API;
1360
+ if (!API) throw new Error("Set UPND_API — refusing to default a funded key to a third-party endpoint");
1361
+ const RPC = process.env.ARBITRUM_RPC_URL;
1362
+ if (!RPC) throw new Error("Set ARBITRUM_RPC_URL");
1363
+ ```
1364
+
1365
+ **2. Assert the matcher and the RPC agree on the chain — before the approve.**
1366
+ This single check kills the whole class; a demo matcher paired with a mainnet key
1367
+ cannot survive it.
1368
+
1369
+ ```ts
1370
+ const cfg = await api.getConfig();
1371
+ const rpcChainId = await publicClient.getChainId();
1372
+ if (cfg.chainId !== rpcChainId) {
1373
+ throw new Error(`chain mismatch: matcher says ${cfg.chainId}, RPC says ${rpcChainId}`);
1374
+ }
1375
+ ```
1376
+
1377
+ **3. Bound every allowance.** Pass an explicit `amount` to
1378
+ `ensureSettlementAllowance` (EOA), `onboard`/`approve` (Account Kit), and
1379
+ `buildApproveSettlementTx` (raw-tx). The Account Kit and raw-tx paths still
1380
+ default to `MAX_UINT256` for backwards compatibility — that default is an
1381
+ unbounded claim on your balance by an address the server named. Re-run the helper
1382
+ on a timer, since fills consume the allowance.
1383
+
1384
+ **4. Use `freshNonce()`.** Never `Math.random()` or `Date.now()` — a guessable
1385
+ nonce lets anyone pre-burn it against the replay store and block your order flow.
1386
+
1387
+ **5. Never put `apiSecret` (§14) in client-side code**, and never hold an order
1388
+ session key anywhere you wouldn't hold a signing key — its blast radius is
1389
+ narrow (signature-only, cannot move funds) but it is still your trading identity.
1390
+
1391
+ **6. Validate before signing.** `assertStakeBounds` and `centsToBps` throw on
1392
+ out-of-range input, so a bad value never becomes a signed payload.
1393
+
1394
+ ---
1395
+
1396
+ ## 17. Troubleshooting
1397
+
1398
+ | Symptom | Likely cause |
1399
+ |---|---|
1400
+ | Fill reverts, on-chain signature rejected | `maxFee` missing from the signed message, or the posted `maxFee` ≠ the signed one. It sits between `amount` and `nonce` in `ORDER_TYPES`. |
1401
+ | Posted nonce doesn't match the signed nonce | A nonce ≥ 2^53 rounded through `Number()`. Use `freshNonce()`. |
1402
+ | `ERC20: insufficient allowance` mid-session | A bounded allowance ran dry. Call `ensureSettlementAllowance` inside your loop, not just at startup. |
1403
+ | Fill reverts on a smart account | The SCA is not deployed, or a 6492-wrapped signature reached the matcher. Check `isDeployed()`; all SDK signing paths strip 6492. |
1404
+ | `isValidSignature` returns `0xffffffff` | You signed with a **send** session (root permissions). Send sessions cannot do ERC-1271 — use an order session or the owner key. |
1405
+ | Order rejected, `domainForSettlement` throws | The settlement address isn't in `cfg.pairs[]`. Don't hardcode a domain; re-fetch `/config`. |
1406
+ | DELETE `/orders/:id` returns **404** | Cancel-signature schema drift — `CANCEL_TYPES` must carry `nonce` + `expiry`. |
1407
+ | No `orders:` / `balance:` events | The connection is unauthenticated. `connectPublic` silently drops private channels; use `connectAuthed`. |
1408
+ | WS auth fails immediately | Clock skew > ±60s, or a reused `sessionId`. Call `freshSessionId()` per handshake. |
1409
+ | WS stops reconnecting | Terminal auth failure (`unauthed-failure`) or an explicit `disconnect()`. Both are intentional. |
1410
+ | `WebSocket is not defined` | Node 18–21 without the global. Run Node ≥ 22, use `--experimental-websocket`, or polyfill `globalThis.WebSocket`. |
1411
+ | Displayed fill price looks inverted | A MINT/MERGE row rendered with `price` (the maker's leg). Use `takerPriceBps(trade)`. |
1412
+ | PnL shows 0 on a live position | `markSource: "cost"` — no book signal was available, so it marked at `avgPrice`. |
1413
+ | `Cannot find module '@account-kit/...'` | Optional peers not installed. Only needed for `UpDownAccountKitSigner` / raw-tx builders. |
1414
+ | `approve amount is below the re-approve threshold` | `amount < threshold` in `ensureSettlementAllowance` — that would re-approve on every call. Raise `amount` or lower `threshold`. |
1415
+ | `getRandomValues unavailable` | Node < 18 or a non-standard runtime. The SDK throws rather than degrading to a weak RNG. |
1416
+
1417
+ ---
1418
+
1419
+ ## 18. Testing and schema-drift guards
1420
+
1421
+ ```bash
1422
+ npm run build # clean + tsc → dist/
1423
+ npm test # the guard suite (also runs on prepublishOnly)
1424
+ ```
1425
+
1426
+ `npm test` runs six scripts in `scripts/`:
1427
+
1428
+ | Script | Guards |
1429
+ |---|---|
1430
+ | `eip712-golden.test.mjs` | The EIP-712 golden vector — digest + signature derived **independently** from the backend and contract definitions. This is the primary drift alarm. |
1431
+ | `rawtx-tier.test.mjs` | Raw-tx builders emit the same bytes as the signer class. |
1432
+ | `hot-key-safety.test.mjs` | The funded-key guardrails (§16). |
1433
+ | `reconcile.test.mjs` | On-chain reconciliation math. |
1434
+ | `pnl.test.mjs` | PnL math against known vectors. |
1435
+ | `ws-subscribe.test.mjs` | WS subscribe/unsubscribe/replay semantics. |
1436
+
1437
+ **If `eip712-golden.test.mjs` fails, do not "fix" the test.** It means
1438
+ `ORDER_TYPES`, `CANCEL_TYPES` or the domain has drifted from the backend or the
1439
+ contract, and every signature this SDK produces is being rejected on-chain.
1440
+ Reconcile against `UpDownSettlement.sol`'s `ORDER_TYPEHASH` first.
1441
+
1442
+ Runnable examples (repo only, not in the npm tarball):
1443
+
1444
+ ```bash
1445
+ npm run example:taker # examples/simple-taker.ts — one MARKET order, poll for fill
1446
+ npm run example:maker # examples/simple-maker.ts — LIMIT order + authed WS + cancel
1447
+ npm run example:dmm # examples/full-dmm-bot.ts — two-sided quoting loop
1448
+ # examples/account-kit-taker.ts — SCA end-to-end
1449
+ # examples/rain-taker.ts — raw-tx tier walkthrough
1450
+ ```
1451
+
1452
+ All examples require `UPND_API` and `ARBITRUM_RPC_URL` explicitly, or
1453
+ `UPND_DEMO=1` to opt into the public demo stack. There is no silent default —
1454
+ see `examples/_env.ts`.
1455
+
1456
+ ---
1457
+
1458
+ ## 19. Compatibility notes
1459
+
1460
+ **Package.** ESM-only, `sideEffects: false`, Node ≥ 18. `viem ^2.21` is a
1461
+ required peer; the four `@account-kit/*` / `@aa-sdk/core` packages are optional
1462
+ peers, lazy-imported.
1463
+
1464
+ **Versioning.** This package is deliberately **segregated** from `rain-sdk-v2` —
1465
+ bump it independently, no release coupling.
1466
+
1467
+ **Deprecated but still present:**
1468
+
1469
+ - `ApiConfig.settlementAddress` and `ApiConfig.eip712` (top-level) — first pair's
1470
+ values, kept for one release cycle. Use `cfg.pairs[]`.
1471
+ - `UpDownWsClient.connect(SubscribePayload)` — routes to `connectPublic`; the
1472
+ `wallet` field is ignored.
1473
+
1474
+ **Optional fields that mean "older backend":** `Trade.matchType` and
1475
+ `Trade.takerPrice` are absent on backends predating 2026-07-17.
1476
+ `takerPriceBps()` handles both.
1477
+
1478
+ **External references:**
1479
+
1480
+ - Wire shapes / REST / WS / rate limits — `docs/api.md` in `updown-backend`
1481
+ (branch `audit/hacken-remediation-v2`).
1482
+ - On-chain spec — `src/UpDownSettlement.sol` in `updown-contracts` (same branch):
1483
+ `ORDER_TYPEHASH`, `enterPosition`, fee-cap enforcement.
1484
+ - Raw-tx tier rationale — `UPDOWN_RAIN_RAWTX_SDK_DESIGN.md`.
1485
+ - Order-session on-chain proof — `updown-contracts/test/OrderSessionForkTest.t.sol`.
1486
+ </content>
1487
+ </invoke>