@pulsepairs/sdk 0.5.1 → 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.
- package/DOCUMENTATION.md +1487 -0
- package/README.md +4 -0
- package/dist/http.d.ts +12 -0
- package/dist/http.js +24 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/pnl.d.ts +18 -1
- package/dist/pnl.js +5 -1
- package/dist/types.d.ts +22 -0
- package/dist/types.js +11 -0
- package/package.json +3 -2
package/DOCUMENTATION.md
ADDED
|
@@ -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>
|