@pulsepairs/sdk 0.2.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/dist/ws.js ADDED
@@ -0,0 +1,224 @@
1
+ /**
2
+ * WebSocket client with auto-reconnect + PR-19 auth handshake.
3
+ *
4
+ * Two paths:
5
+ *
6
+ * - `connectPublic(channels)` — public channels only (`markets`,
7
+ * `orderbook:*`, `trades:*`). Sends `{type:'subscribe', channels}`
8
+ * immediately on open, no auth.
9
+ *
10
+ * - `connectAuthed({ signAuth, channels })` — sends
11
+ * `{type:'auth', ...}` first, awaits `auth_ok`, then sends
12
+ * `{type:'subscribe', channels}`. Required for `orders:<wallet>` +
13
+ * `balance:<wallet>` (PR-19 gates these behind a verified WsAuth
14
+ * signature). On reconnect within the 24h token TTL, replays the
15
+ * cached token via `{type:'auth', token}` — only re-prompts the
16
+ * `signAuth` callback if the server rejects the cached token.
17
+ *
18
+ * Server message envelope (mirrors `src/ws/WebSocketServer.ts:235-293`):
19
+ *
20
+ * client → server { type: 'auth', wallet, timestamp, sessionId, signature }
21
+ * server → client { type: 'auth_ok', wallet, token, expiresAt }
22
+ * or { type: 'auth_error' } (no reason echoed — anti-probe)
23
+ *
24
+ * client → server { type: 'auth', token } (token replay)
25
+ * server → client { type: 'auth_ok', wallet, token } (token re-validated)
26
+ *
27
+ * State machine:
28
+ * idle → authenticating → subscribing → live
29
+ * → unauthed-failure (signAuth rejected)
30
+ *
31
+ * The legacy `connect(SubscribePayload)` API is retained as a deprecated
32
+ * shim that routes to `connectPublic` so existing public-only callers
33
+ * keep working without code changes. The `wallet` field on the legacy
34
+ * payload is now silently ignored (it was never authenticated and
35
+ * post-PR-19 the server drops private subs from anonymous clients).
36
+ */
37
+ export class UpDownWsClient {
38
+ url;
39
+ onMessage;
40
+ ws = null;
41
+ reconnect = 0;
42
+ timer = null;
43
+ closed = false;
44
+ mode = null;
45
+ state = "idle";
46
+ /** Cached token + expiry from the last successful auth_ok. Replayed on
47
+ * reconnect; cleared if the server returns auth_error against it. */
48
+ cachedToken = null;
49
+ cachedTokenExpiresAt = 0;
50
+ constructor(url, onMessage) {
51
+ this.url = url;
52
+ this.onMessage = onMessage;
53
+ }
54
+ /**
55
+ * Subscribe to public channels only (`markets`, `orderbook:*`,
56
+ * `trades:*`). No signature required.
57
+ */
58
+ connectPublic(channels) {
59
+ this.closed = false;
60
+ this.mode = { kind: "public", channels: channels.slice() };
61
+ this.openSocket();
62
+ }
63
+ /**
64
+ * Authenticate via the PR-19 EIP-712 handshake, then subscribe.
65
+ * Required for `orders:*` and `balance:*` channels.
66
+ */
67
+ connectAuthed(opts) {
68
+ this.closed = false;
69
+ this.mode = {
70
+ kind: "authed",
71
+ opts: { ...opts, channels: opts.channels.slice() },
72
+ };
73
+ this.openSocket();
74
+ }
75
+ /**
76
+ * @deprecated Use `connectPublic(channels)` or `connectAuthed({ ... })`.
77
+ * Pre-PR-19 the WS server accepted `{type:'subscribe', wallet}` and
78
+ * auto-attached `orders:<wallet>` to the anonymous client; that path
79
+ * is now closed. The `wallet` field on the payload is ignored — if
80
+ * the channel list contains private channels they will be silently
81
+ * dropped by the server because the connection is unauthenticated.
82
+ */
83
+ connect(subscribe) {
84
+ this.connectPublic(subscribe.channels);
85
+ }
86
+ disconnect() {
87
+ this.closed = true;
88
+ this.state = "idle";
89
+ if (this.timer) {
90
+ clearTimeout(this.timer);
91
+ this.timer = null;
92
+ }
93
+ this.ws?.close();
94
+ this.ws = null;
95
+ }
96
+ openSocket() {
97
+ if (this.closed)
98
+ return;
99
+ const ws = new WebSocket(this.url);
100
+ this.ws = ws;
101
+ this.state = "idle";
102
+ ws.onopen = () => {
103
+ this.reconnect = 0;
104
+ void this.startHandshake();
105
+ };
106
+ ws.onmessage = (ev) => {
107
+ let parsed;
108
+ try {
109
+ parsed = JSON.parse(String(ev.data));
110
+ }
111
+ catch {
112
+ return;
113
+ }
114
+ void this.handleIncoming(parsed);
115
+ };
116
+ ws.onclose = () => {
117
+ if (this.closed)
118
+ return;
119
+ if (this.state === "unauthed-failure") {
120
+ // signAuth was rejected — don't reconnect into a hot loop.
121
+ return;
122
+ }
123
+ const attempt = this.reconnect++;
124
+ const delay = attempt > 12 ? 30_000 : Math.min(30_000, 1000 * 2 ** Math.min(attempt, 5));
125
+ this.timer = setTimeout(() => this.openSocket(), delay);
126
+ };
127
+ ws.onerror = () => ws.close();
128
+ }
129
+ async startHandshake() {
130
+ if (!this.mode || !this.ws)
131
+ return;
132
+ if (this.mode.kind === "public") {
133
+ this.state = "subscribing";
134
+ this.send({ type: "subscribe", channels: this.mode.channels });
135
+ // Public path: the `subscribed` reply marks us live, but for
136
+ // simplicity we mark live optimistically — onMessage delivery
137
+ // doesn't gate on it.
138
+ this.state = "live";
139
+ return;
140
+ }
141
+ // Authed path. Try token replay first if we have a non-expired cache.
142
+ this.state = "authenticating";
143
+ if (this.cachedToken && this.cachedTokenExpiresAt > Date.now()) {
144
+ this.send({ type: "auth", token: this.cachedToken });
145
+ return;
146
+ }
147
+ await this.runSignAndSendAuth();
148
+ }
149
+ async runSignAndSendAuth() {
150
+ if (!this.mode || this.mode.kind !== "authed")
151
+ return;
152
+ let creds;
153
+ try {
154
+ creds = await this.mode.opts.signAuth();
155
+ }
156
+ catch (err) {
157
+ this.state = "unauthed-failure";
158
+ this.mode.opts.onAuthError?.(err instanceof Error ? err.message : "signAuth threw");
159
+ this.ws?.close();
160
+ return;
161
+ }
162
+ if (creds.token) {
163
+ // Caller pre-cached a token — try replay first.
164
+ this.cachedToken = creds.token;
165
+ // Caller didn't supply expiry, so trust it for this attempt; if
166
+ // the server rejects we'll re-sign on auth_error.
167
+ this.cachedTokenExpiresAt = Math.max(this.cachedTokenExpiresAt, Date.now() + 60_000);
168
+ this.send({ type: "auth", token: creds.token });
169
+ return;
170
+ }
171
+ this.send({
172
+ type: "auth",
173
+ wallet: creds.wallet,
174
+ timestamp: creds.timestamp.toString(),
175
+ sessionId: creds.sessionId,
176
+ signature: creds.signature,
177
+ });
178
+ }
179
+ async handleIncoming(msg) {
180
+ if (msg.type === "auth_ok") {
181
+ const m = msg;
182
+ if (typeof m.token === "string") {
183
+ this.cachedToken = m.token;
184
+ // Server-supplied expiresAt is the source of truth (24h TTL).
185
+ // Fall back to TTL-from-now if the server omits it (token
186
+ // replay path doesn't echo expiresAt).
187
+ this.cachedTokenExpiresAt =
188
+ typeof m.expiresAt === "number"
189
+ ? m.expiresAt
190
+ : Math.max(this.cachedTokenExpiresAt, Date.now() + 23 * 60 * 60 * 1000);
191
+ }
192
+ if (this.mode?.kind === "authed") {
193
+ this.state = "subscribing";
194
+ this.send({ type: "subscribe", channels: this.mode.opts.channels });
195
+ this.state = "live";
196
+ }
197
+ return;
198
+ }
199
+ if (msg.type === "auth_error") {
200
+ // Cached token is bad. Drop it and try a fresh sign — but only
201
+ // ONCE per connect cycle to avoid a sign loop if the user's
202
+ // wallet keeps rejecting.
203
+ if (this.cachedToken) {
204
+ this.cachedToken = null;
205
+ this.cachedTokenExpiresAt = 0;
206
+ await this.runSignAndSendAuth();
207
+ return;
208
+ }
209
+ // Fresh signature was rejected — terminal failure.
210
+ this.state = "unauthed-failure";
211
+ if (this.mode?.kind === "authed") {
212
+ this.mode.opts.onAuthError?.("auth_error");
213
+ }
214
+ this.ws?.close();
215
+ return;
216
+ }
217
+ this.onMessage(msg);
218
+ }
219
+ send(payload) {
220
+ if (this.ws && this.ws.readyState === 1 /* OPEN */) {
221
+ this.ws.send(JSON.stringify(payload));
222
+ }
223
+ }
224
+ }
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@pulsepairs/sdk",
3
+ "version": "0.2.0",
4
+ "description": "Standalone SDK for the UpDown (PulsePairs) up/down prediction markets — matcher REST/WS client, EIP-712 order signing, trade-math, and Alchemy Account Kit (smart-account) order signing for rain.trade integration.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "module": "dist/index.js",
9
+ "types": "dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md"
19
+ ],
20
+ "sideEffects": false,
21
+ "scripts": {
22
+ "clean": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\"",
23
+ "build": "npm run clean && tsc",
24
+ "test": "node scripts/eip712-golden.test.mjs",
25
+ "prepublishOnly": "npm run build && npm test",
26
+ "example:taker": "npx tsx examples/simple-taker.ts",
27
+ "example:maker": "npx tsx examples/simple-maker.ts",
28
+ "example:dmm": "npx tsx examples/full-dmm-bot.ts"
29
+ },
30
+ "peerDependencies": {
31
+ "@aa-sdk/core": "^4.88.0",
32
+ "@account-kit/infra": "^4.88.0",
33
+ "@account-kit/smart-contracts": "^4.88.0",
34
+ "@account-kit/wallet-client": "^4.88.0",
35
+ "viem": "^2.21.0"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "@account-kit/smart-contracts": {
39
+ "optional": true
40
+ },
41
+ "@account-kit/wallet-client": {
42
+ "optional": true
43
+ },
44
+ "@account-kit/infra": {
45
+ "optional": true
46
+ },
47
+ "@aa-sdk/core": {
48
+ "optional": true
49
+ }
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "^22.10.0",
53
+ "typescript": "^5.9.3",
54
+ "viem": "^2.47.0"
55
+ },
56
+ "publishConfig": {
57
+ "access": "public"
58
+ },
59
+ "engines": {
60
+ "node": ">=18"
61
+ }
62
+ }