@waaskey/sdk 0.0.1 → 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/index.cjs CHANGED
@@ -1,104 +1,2670 @@
1
1
  'use strict';
2
2
 
3
- // src/types.ts
3
+ var ed25519_js = require('@noble/curves/ed25519.js');
4
+
5
+ // src/analytics/analytics.ts
6
+ var Analytics = class {
7
+ constructor(sink) {
8
+ this.sink = sink;
9
+ }
10
+ sink;
11
+ track(type, props = {}) {
12
+ if (!this.sink) return;
13
+ try {
14
+ this.sink.track({ type, timestamp: (/* @__PURE__ */ new Date()).toISOString(), ...props });
15
+ } catch {
16
+ }
17
+ }
18
+ };
19
+
20
+ // src/analytics/http-sink.ts
21
+ var HttpAnalyticsSink = class {
22
+ constructor(http) {
23
+ this.http = http;
24
+ }
25
+ http;
26
+ track(event) {
27
+ void this.http.request("POST", "/v1/analytics/events", event).catch(() => void 0);
28
+ }
29
+ };
30
+
31
+ // src/errors.ts
4
32
  var WaaskeyError = class extends Error {
5
- constructor(message, status, code) {
6
- super(message);
7
- this.status = status;
8
- this.code = code;
33
+ code;
34
+ status;
35
+ details;
36
+ constructor(message, code, options = {}) {
37
+ super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
9
38
  this.name = "WaaskeyError";
39
+ this.code = code;
40
+ this.status = options.status;
41
+ this.details = options.details;
10
42
  }
11
- status;
12
- code;
13
43
  };
44
+ var STATUS_CODES = {
45
+ 400: "validation",
46
+ 401: "unauthorized",
47
+ 403: "forbidden",
48
+ 404: "not_found",
49
+ 409: "conflict",
50
+ 429: "rate_limited"
51
+ };
52
+ function errorFromResponse(status, body) {
53
+ const code = STATUS_CODES[status] ?? (status >= 500 ? "server_error" : "unknown");
54
+ return new WaaskeyError(messageFromBody(body, status), code, { status, details: body });
55
+ }
56
+ function messageFromBody(body, status) {
57
+ if (body && typeof body === "object") {
58
+ const { message, error } = body;
59
+ if (Array.isArray(message)) return message.filter((m) => typeof m === "string").join("; ");
60
+ if (typeof message === "string" && message) return message;
61
+ if (typeof error === "string" && error) return error;
62
+ }
63
+ return `Request failed with status ${status}`;
64
+ }
65
+
66
+ // src/auth.ts
67
+ var Auth = class {
68
+ constructor(http) {
69
+ this.http = http;
70
+ }
71
+ http;
72
+ current;
73
+ /** Email one-time-code login. */
74
+ email = {
75
+ /** Send a login code to `email`. */
76
+ start: (email, signal) => this.http.request("POST", "/v1/embedded/auth/email/start", { email }, signal),
77
+ /** Verify the code; on success the end-user is provisioned and the session is established. */
78
+ verify: (email, code, signal) => this.establish("/v1/embedded/auth/email/verify", { email, code }, signal)
79
+ };
80
+ /** Phone one-time-code (SMS) login. `phone` is E.164, e.g. `+14155550123`. */
81
+ phone = {
82
+ /** Send a login code to `phone`. */
83
+ start: (phone, signal) => this.http.request("POST", "/v1/embedded/auth/phone/start", { phone }, signal),
84
+ /** Verify the code; on success the end-user is provisioned and the session is established. */
85
+ verify: (phone, code, signal) => this.establish("/v1/embedded/auth/phone/verify", { phone, code }, signal)
86
+ };
87
+ /** Social / federated-provider login. */
88
+ social = {
89
+ /**
90
+ * Exchange a Google ID token (obtained on the client via Google Identity Services) for a
91
+ * session. Requires the tenant's Google client id to be configured server-side.
92
+ */
93
+ google: (idToken, signal) => this.establish("/v1/embedded/auth/social/google", { idToken }, signal),
94
+ /**
95
+ * Exchange a Firebase ID token (obtained on the client via the Firebase Auth SDK) for a session
96
+ * — the Firebase analogue of {@link social.google}. Requires the tenant to have opted into
97
+ * embedded Firebase sign-in (`firebaseAuthEnabled`); when it hasn't, the API replies 412 and the
98
+ * SDK surfaces a typed `firebase_not_enabled` {@link WaaskeyError}.
99
+ */
100
+ firebase: (idToken, signal) => this.establish("/v1/embedded/auth/firebase", { idToken }, signal).catch((error) => {
101
+ if (error instanceof WaaskeyError && error.status === 412) {
102
+ throw new WaaskeyError("Firebase sign-in is not enabled for this tenant.", "firebase_not_enabled", { status: 412, cause: error, details: error.details });
103
+ }
104
+ throw error;
105
+ })
106
+ };
107
+ /**
108
+ * Passkey (WebAuthn) login. `register` adds a passkey to the **currently logged-in** end-user;
109
+ * `login` is usernameless (a passkey assertion resolves the user and establishes a session).
110
+ *
111
+ * The browser ceremony uses `@simplewebauthn/browser` (an optional peer dependency, loaded on
112
+ * demand). Pass a `ceremony` to override it — e.g. on React Native with a native authenticator.
113
+ */
114
+ passkey = {
115
+ /** Add a passkey to the logged-in end-user. Requires an active session. */
116
+ register: async (ceremony) => {
117
+ const token = this.requireToken();
118
+ const options = await this.http.request("POST", "/v1/embedded/auth/passkey/register/options", void 0, void 0, token);
119
+ const response = await (ceremony ?? await defaultCeremony()).create(options);
120
+ await this.http.request("POST", "/v1/embedded/auth/passkey/register/verify", { response }, void 0, token);
121
+ },
122
+ /** Usernameless passkey login — establishes the session on success. */
123
+ login: async (ceremony) => {
124
+ const { challengeId, options } = await this.http.request("POST", "/v1/embedded/auth/passkey/login/options", {});
125
+ const response = await (ceremony ?? await defaultCeremony()).get(options);
126
+ return this.establish("/v1/embedded/auth/passkey/login/verify", { challengeId, response });
127
+ }
128
+ };
129
+ /** The active session, or `undefined` when not logged in. */
130
+ get session() {
131
+ return this.current;
132
+ }
133
+ /** The end-user session token, or `undefined` when not logged in. */
134
+ get token() {
135
+ return this.current?.token;
136
+ }
137
+ /** Whether an end-user is currently logged in. */
138
+ get isAuthenticated() {
139
+ return this.current !== void 0;
140
+ }
141
+ /** The logged-in end-user (re-fetched from the session token). Rejects if not logged in. */
142
+ async me(signal) {
143
+ return this.http.request("GET", "/v1/embedded/auth/me", void 0, signal, this.requireToken());
144
+ }
145
+ /** Restore a session from a previously stored token (e.g. across reloads). */
146
+ restore(session) {
147
+ this.current = session;
148
+ }
149
+ /** Clear the session (sign out). */
150
+ logout() {
151
+ this.current = void 0;
152
+ }
153
+ /** POST a verify request, store the resulting session, and return it. */
154
+ async establish(path, body, signal) {
155
+ const session = await this.http.request("POST", path, body, signal);
156
+ this.current = session;
157
+ return session;
158
+ }
159
+ requireToken() {
160
+ if (!this.current) throw new WaaskeyError("Not authenticated \u2014 verify a login code first.", "unauthenticated");
161
+ return this.current.token;
162
+ }
163
+ };
164
+ async function defaultCeremony() {
165
+ let mod;
166
+ try {
167
+ mod = await import('@simplewebauthn/browser');
168
+ } catch {
169
+ throw new WaaskeyError('Passkeys need "@simplewebauthn/browser" installed, or pass a custom ceremony.', "unsupported");
170
+ }
171
+ return {
172
+ create: (options) => mod.startRegistration({ optionsJSON: options }),
173
+ get: (options) => mod.startAuthentication({ optionsJSON: options })
174
+ };
175
+ }
176
+
177
+ // src/balances/evm-provider.ts
178
+ var SELECTOR_BALANCE_OF = "0x70a08231";
179
+ var SELECTOR_DECIMALS = "0x313ce567";
180
+ var EvmRpcProvider = class {
181
+ constructor(rpcUrl, fetchImpl) {
182
+ this.rpcUrl = rpcUrl;
183
+ const resolved = fetchImpl ?? globalThis.fetch;
184
+ if (!resolved) throw new WaaskeyError("No fetch implementation for the EVM provider.", "provider_error");
185
+ this.fetchImpl = resolved.bind(globalThis);
186
+ }
187
+ rpcUrl;
188
+ fetchImpl;
189
+ async getNativeBalance(address) {
190
+ return this.toBigInt(await this.call("eth_getBalance", [address, "latest"]));
191
+ }
192
+ async getTokenBalance(token, owner) {
193
+ const data = SELECTOR_BALANCE_OF + padAddress(owner);
194
+ return this.toBigInt(await this.call("eth_call", [{ to: token, data }, "latest"]));
195
+ }
196
+ async getTokenDecimals(token) {
197
+ const result = await this.call("eth_call", [{ to: token, data: SELECTOR_DECIMALS }, "latest"]);
198
+ return result === "0x" || result === void 0 ? 18 : Number(this.toBigInt(result));
199
+ }
200
+ async call(method, params) {
201
+ let res;
202
+ try {
203
+ res = await this.fetchImpl(this.rpcUrl, {
204
+ method: "POST",
205
+ headers: { "content-type": "application/json" },
206
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params })
207
+ });
208
+ } catch (cause) {
209
+ throw new WaaskeyError(`Chain RPC request failed (${method}).`, "provider_error", { cause });
210
+ }
211
+ if (!res.ok) throw new WaaskeyError(`Chain RPC returned ${res.status} (${method}).`, "provider_error", { status: res.status });
212
+ const json = await res.json().catch(() => ({}));
213
+ if (json.error) throw new WaaskeyError(`Chain RPC error (${method}): ${json.error.message ?? "unknown"}.`, "provider_error", { details: json.error });
214
+ return json.result;
215
+ }
216
+ toBigInt(hex) {
217
+ return hex && hex !== "0x" ? BigInt(hex) : 0n;
218
+ }
219
+ };
220
+ function padAddress(address) {
221
+ return address.replace(/^0x/i, "").toLowerCase().padStart(64, "0");
222
+ }
223
+
224
+ // src/balances/format.ts
225
+ function formatUnits(raw, decimals) {
226
+ if (decimals === 0) return raw.toString();
227
+ const negative = raw < 0n;
228
+ const value = negative ? -raw : raw;
229
+ const base = 10n ** BigInt(decimals);
230
+ const whole = value / base;
231
+ const fraction = (value % base).toString().padStart(decimals, "0").replace(/0+$/, "");
232
+ const text = fraction ? `${whole}.${fraction}` : `${whole}`;
233
+ return negative ? `-${text}` : text;
234
+ }
235
+
236
+ // src/balances/balances.ts
237
+ var NATIVE_ASSETS = {
238
+ ethereum: { symbol: "ETH", decimals: 18 },
239
+ polygon: { symbol: "POL", decimals: 18 },
240
+ arbitrum: { symbol: "ETH", decimals: 18 },
241
+ base: { symbol: "ETH", decimals: 18 },
242
+ optimism: { symbol: "ETH", decimals: 18 },
243
+ bitcoin: { symbol: "BTC", decimals: 8 },
244
+ solana: { symbol: "SOL", decimals: 9 }
245
+ };
246
+ var DEFAULT_EVM_RPC = {
247
+ ethereum: "https://eth.llamarpc.com",
248
+ polygon: "https://polygon-rpc.com",
249
+ arbitrum: "https://arb1.arbitrum.io/rpc",
250
+ base: "https://mainnet.base.org",
251
+ optimism: "https://mainnet.optimism.io"
252
+ };
253
+ var isEvm = (chain) => chain in DEFAULT_EVM_RPC;
254
+ var Balances = class {
255
+ constructor(config = {}, fetchImpl) {
256
+ this.config = config;
257
+ this.fetchImpl = fetchImpl;
258
+ }
259
+ config;
260
+ fetchImpl;
261
+ providers = /* @__PURE__ */ new Map();
262
+ /** Native (gas-token) balance of an address on a chain. */
263
+ async getBalance(chain, address) {
264
+ const { symbol, decimals } = NATIVE_ASSETS[chain];
265
+ const raw = await this.provider(chain).getNativeBalance(address);
266
+ return { raw, decimals, symbol, formatted: formatUnits(raw, decimals) };
267
+ }
268
+ /** Token balance of `owner` for `token` on a chain (ERC-20 on EVM). */
269
+ async getTokenBalance(chain, token, owner, options = {}) {
270
+ const provider = this.provider(chain);
271
+ const raw = await provider.getTokenBalance(token, owner);
272
+ const decimals = options.decimals ?? (provider.getTokenDecimals ? await provider.getTokenDecimals(token) : 18);
273
+ return { raw, decimals, symbol: options.symbol, formatted: formatUnits(raw, decimals) };
274
+ }
275
+ /** Resolve (and cache) the provider for a chain: custom → configured RPC → default EVM RPC. */
276
+ provider(chain) {
277
+ const cached = this.providers.get(chain);
278
+ if (cached) return cached;
279
+ const cfg = this.config[chain];
280
+ let provider = cfg?.provider;
281
+ if (!provider) {
282
+ const rpcUrl = cfg?.rpcUrl ?? DEFAULT_EVM_RPC[chain];
283
+ if (rpcUrl && isEvm(chain)) provider = new EvmRpcProvider(rpcUrl, this.fetchImpl);
284
+ }
285
+ if (!provider) {
286
+ throw new WaaskeyError(`No chain provider configured for "${chain}". Pass chains: { ${chain}: { rpcUrl } } or a custom provider.`, "unsupported_chain");
287
+ }
288
+ this.providers.set(chain, provider);
289
+ return provider;
290
+ }
291
+ };
292
+
293
+ // src/broadcast.ts
294
+ async function broadcast(signedTx, options) {
295
+ if (!signedTx) throw new WaaskeyError("`signedTx` is required to broadcast.", "validation");
296
+ if (!options?.rpcUrl) throw new WaaskeyError("`rpcUrl` is required \u2014 broadcast from your own node/provider.", "validation");
297
+ const fetchImpl = (options.fetch ?? globalThis.fetch)?.bind(globalThis);
298
+ if (!fetchImpl) throw new WaaskeyError("No fetch implementation for broadcast \u2014 pass `fetch`.", "provider_error");
299
+ const rawTx = signedTx.startsWith("0x") ? signedTx : `0x${signedTx}`;
300
+ let res;
301
+ try {
302
+ res = await fetchImpl(options.rpcUrl, {
303
+ method: "POST",
304
+ headers: { "content-type": "application/json" },
305
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_sendRawTransaction", params: [rawTx] }),
306
+ signal: options.signal
307
+ });
308
+ } catch (cause) {
309
+ if (options.signal?.aborted) throw new WaaskeyError("Broadcast aborted.", "aborted", { cause });
310
+ throw new WaaskeyError("Broadcast request failed.", "provider_error", { cause });
311
+ }
312
+ if (!res.ok) throw new WaaskeyError(`Broadcast RPC returned ${res.status}.`, "provider_error", { status: res.status });
313
+ const json = await res.json().catch(() => ({}));
314
+ if (json.error) throw new WaaskeyError(`Broadcast rejected by the node: ${json.error.message ?? "unknown"}.`, "provider_error", { details: json.error });
315
+ if (!json.result) throw new WaaskeyError("Broadcast returned no transaction hash.", "provider_error");
316
+ return { txHash: json.result };
317
+ }
14
318
 
15
319
  // src/http.ts
16
320
  var HttpClient = class {
17
321
  apiKey;
18
322
  baseUrl;
19
323
  fetchImpl;
324
+ memberAccessToken;
20
325
  constructor(apiKey, baseUrl, fetchImpl) {
21
326
  this.apiKey = apiKey;
22
- this.baseUrl = baseUrl.replace(/\/$/, "");
327
+ this.baseUrl = assertSecureBaseUrl(baseUrl).replace(/\/$/, "");
23
328
  const resolved = fetchImpl ?? globalThis.fetch;
24
329
  if (!resolved) {
25
330
  throw new Error("No fetch implementation available \u2014 pass `fetch` in WaaskeyOptions.");
26
331
  }
27
332
  this.fetchImpl = resolved.bind(globalThis);
28
333
  }
29
- async request(method, path, body) {
30
- const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
31
- method,
32
- headers: {
33
- authorization: `Bearer ${this.apiKey}`,
34
- "content-type": "application/json"
35
- },
36
- body: body === void 0 ? void 0 : JSON.stringify(body)
37
- });
334
+ /**
335
+ * Wire a provider of the held org-member access token (from {@link Members}). When it returns a
336
+ * token, {@link requestAsMember} authenticates member calls with `Authorization: Bearer <token>`
337
+ * rather than the ambient dashboard cookie. Injected by the client (not imported) so `Members` and
338
+ * `HttpClient` don't form an import cycle.
339
+ */
340
+ useMemberAccessToken(provider) {
341
+ this.memberAccessToken = provider;
342
+ }
343
+ async request(method, path, body, signal, bearer) {
344
+ return this.send(method, path, { authorization: `Bearer ${bearer ?? this.apiKey}`, "content-type": "application/json" }, body, signal);
345
+ }
346
+ /**
347
+ * `@Public` request: NO `Authorization` header and NO cookie. Used by the org-member Firebase
348
+ * login exchange (`POST /v1/auth/firebase`), which authenticates from the Firebase ID token in the
349
+ * body alone and returns the member bearer tokens in its response.
350
+ */
351
+ async requestPublic(method, path, body, signal) {
352
+ return this.send(method, path, { "content-type": "application/json" }, body, signal);
353
+ }
354
+ /**
355
+ * Member-session request (#349). Authenticates as the org member in one of two ways, transparent
356
+ * to the caller ({@link Wallets.joinCeremony} / {@link Wallets.joinSignCeremony}):
357
+ *
358
+ * - **Held bearer session** (a {@link memberAccessToken} provider returns a token — e.g. after
359
+ * {@link Members.loginWithFirebase}): sends `Authorization: Bearer <accessToken>`. The backend's
360
+ * `ApiKeyAuthGuard` recognises a non-API-key bearer and falls through to the member-session
361
+ * resolver, so a headless (non-browser) consumer authenticates without a cookie.
362
+ * - **No held session** (the browser dashboard path): sends NO `Authorization` header and
363
+ * `credentials: 'include'`, relying on the caller's own same-site httpOnly member cookie. The
364
+ * guard only falls back to the cookie resolver when the header is absent entirely, so it is
365
+ * omitted here.
366
+ *
367
+ * Never used by the tenant-apiKey ({@link request}) or embedded-end-user paths.
368
+ */
369
+ async requestAsMember(method, path, body, signal) {
370
+ const token = this.memberAccessToken?.();
371
+ if (token) {
372
+ return this.send(method, path, { authorization: `Bearer ${token}`, "content-type": "application/json" }, body, signal);
373
+ }
374
+ return this.send(method, path, { "content-type": "application/json" }, body, signal, "include");
375
+ }
376
+ async send(method, path, headers, body, signal, credentials) {
377
+ let res;
378
+ try {
379
+ res = await this.fetchImpl(`${this.baseUrl}${path}`, {
380
+ method,
381
+ headers,
382
+ body: body === void 0 ? void 0 : JSON.stringify(body),
383
+ signal,
384
+ ...credentials === void 0 ? {} : { credentials }
385
+ });
386
+ } catch (cause) {
387
+ if (signal?.aborted) throw new WaaskeyError("Request aborted.", "aborted", { cause });
388
+ throw new WaaskeyError("Network request failed.", "network", { cause });
389
+ }
38
390
  if (!res.ok) {
39
391
  const detail = await res.json().catch(() => ({}));
40
- const message = typeof detail.message === "string" ? detail.message : res.statusText;
41
- const code = typeof detail.code === "string" ? detail.code : void 0;
42
- throw new WaaskeyError(message, res.status, code);
392
+ throw errorFromResponse(res.status, detail);
393
+ }
394
+ const text = await res.text();
395
+ return text ? JSON.parse(text) : void 0;
396
+ }
397
+ };
398
+ function assertSecureBaseUrl(baseUrl) {
399
+ let url;
400
+ try {
401
+ url = new URL(baseUrl);
402
+ } catch (cause) {
403
+ throw new WaaskeyError(`Invalid API baseUrl "${baseUrl}" \u2014 expected an absolute URL.`, "validation", { cause });
404
+ }
405
+ const isLoopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
406
+ if (url.protocol === "https:") return baseUrl;
407
+ if (url.protocol === "http:" && isLoopback) return baseUrl;
408
+ throw new WaaskeyError(`Insecure API baseUrl "${baseUrl}" \u2014 the API key would be sent in cleartext. Use https:// (http:// is allowed only for localhost).`, "validation");
409
+ }
410
+
411
+ // src/members.ts
412
+ var Members = class {
413
+ constructor(http) {
414
+ this.http = http;
415
+ }
416
+ http;
417
+ held;
418
+ /**
419
+ * Exchange a Firebase ID token for an org-member session and hold it.
420
+ *
421
+ * The token must belong to an ALREADY-INVITED member — Firebase never self-provisions a member, so
422
+ * an unknown email is rejected (`no_member`). A member with 2FA enrolled must present a Firebase
423
+ * token that itself passed a second factor, or the login is refused (`mfa_required`).
424
+ *
425
+ * @throws {WaaskeyError} `no_member` — no org member exists for the token's Firebase identity (401).
426
+ * @throws {WaaskeyError} `mfa_required` — the member needs a second factor the token didn't carry (401).
427
+ */
428
+ async loginWithFirebase(idToken, signal) {
429
+ const body = { idToken };
430
+ let session;
431
+ try {
432
+ session = await this.http.requestPublic("POST", "/v1/auth/firebase", body, signal);
433
+ } catch (error) {
434
+ throw mapFirebaseMemberError(error);
435
+ }
436
+ this.held = session;
437
+ return session;
438
+ }
439
+ /** The current member, or `undefined` when not logged in. */
440
+ get member() {
441
+ return this.held?.member;
442
+ }
443
+ /** The held member session (access + refresh tokens + member), or `undefined` when not logged in. */
444
+ get session() {
445
+ return this.held;
446
+ }
447
+ /** The held member access token — the value the client wires into {@link HttpClient.requestAsMember}. */
448
+ get accessToken() {
449
+ return this.held?.accessToken;
450
+ }
451
+ /** Whether an org member is currently logged in over a held bearer session. */
452
+ get isAuthenticated() {
453
+ return this.held !== void 0;
454
+ }
455
+ /** Restore a previously stored member session (e.g. across process restarts). */
456
+ restore(session) {
457
+ this.held = session;
458
+ }
459
+ /** Clear the held member session (sign out); member calls fall back to the cookie path afterwards. */
460
+ memberSignOut() {
461
+ this.held = void 0;
462
+ }
463
+ };
464
+ function mapFirebaseMemberError(error) {
465
+ if (error instanceof WaaskeyError && error.status === 401) {
466
+ const message = error.message.toLowerCase();
467
+ if (message.includes("multi-factor") || message.includes("second factor")) {
468
+ return new WaaskeyError(error.message, "mfa_required", { status: 401, cause: error, details: error.details });
469
+ }
470
+ if (message.includes("no member account")) {
471
+ return new WaaskeyError(error.message, "no_member", { status: 401, cause: error, details: error.details });
43
472
  }
44
- return await res.json();
473
+ }
474
+ return error;
475
+ }
476
+
477
+ // src/onramp.ts
478
+ var Onramp = class {
479
+ constructor(http) {
480
+ this.http = http;
481
+ }
482
+ http;
483
+ /** Get a provider widget URL to buy `cryptoCurrency` on `chainId` for `walletAddress`. */
484
+ widgetUrl(params, signal) {
485
+ return this.http.request("POST", "/onramp/widget-url", params, signal);
45
486
  }
46
487
  };
47
488
 
489
+ // src/storage/crypto.ts
490
+ var PBKDF2_ITERATIONS = 21e4;
491
+ var PBKDF2_HASH = "SHA-256";
492
+ var SALT_BYTES = 16;
493
+ var IV_BYTES = 12;
494
+ var AES_KEY_BITS = 256;
495
+ function subtle() {
496
+ const c = globalThis.crypto;
497
+ if (!c?.subtle) {
498
+ throw new Error("WebCrypto (crypto.subtle) is unavailable \u2014 a secure context (https/localhost) or Node \u2265 22 is required for share storage.");
499
+ }
500
+ return c.subtle;
501
+ }
502
+ function randomBytes(length) {
503
+ const bytes = new Uint8Array(length);
504
+ globalThis.crypto.getRandomValues(bytes);
505
+ return bytes;
506
+ }
507
+ async function sha256Hex(input) {
508
+ const digest = await subtle().digest("SHA-256", new TextEncoder().encode(input));
509
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
510
+ }
511
+ function freshSalt() {
512
+ return randomBytes(SALT_BYTES);
513
+ }
514
+ async function deriveKey(secret, salt) {
515
+ const s = subtle();
516
+ const baseKey = await s.importKey("raw", new TextEncoder().encode(secret), "PBKDF2", false, ["deriveKey"]);
517
+ return s.deriveKey({ name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: PBKDF2_HASH }, baseKey, { name: "AES-GCM", length: AES_KEY_BITS }, false, [
518
+ "encrypt",
519
+ "decrypt"
520
+ ]);
521
+ }
522
+ async function seal(key, plaintext) {
523
+ const iv = randomBytes(IV_BYTES);
524
+ const data = new TextEncoder().encode(plaintext);
525
+ const ct = await subtle().encrypt({ name: "AES-GCM", iv }, key, data);
526
+ return `${bytesToBase64(iv)}.${bytesToBase64(new Uint8Array(ct))}`;
527
+ }
528
+ async function open(key, record) {
529
+ const dot = record.indexOf(".");
530
+ if (dot <= 0) throw new Error("Malformed encrypted share record.");
531
+ const iv = base64ToBytes(record.slice(0, dot));
532
+ const ct = base64ToBytes(record.slice(dot + 1));
533
+ try {
534
+ const plain = await subtle().decrypt({ name: "AES-GCM", iv }, key, ct);
535
+ return new TextDecoder().decode(plain);
536
+ } catch {
537
+ throw new Error("Failed to decrypt share \u2014 wrong secret or corrupted data.");
538
+ }
539
+ }
540
+ async function sealWithPassword(password, plaintext) {
541
+ const salt = freshSalt();
542
+ const key = await deriveKey(password, salt);
543
+ return `${bytesToBase64(salt)}.${await seal(key, plaintext)}`;
544
+ }
545
+ async function openWithPassword(password, record) {
546
+ const dot = record.indexOf(".");
547
+ if (dot <= 0) throw new Error("Malformed password-sealed record.");
548
+ const salt = base64ToBytes(record.slice(0, dot));
549
+ const key = await deriveKey(password, salt);
550
+ return open(key, record.slice(dot + 1));
551
+ }
552
+ function bytesToBase64(bytes) {
553
+ let binary = "";
554
+ for (const byte of bytes) binary += String.fromCharCode(byte);
555
+ return btoa(binary);
556
+ }
557
+ function base64ToBytes(base64) {
558
+ const binary = atob(base64);
559
+ const bytes = new Uint8Array(binary.length);
560
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
561
+ return bytes;
562
+ }
563
+
564
+ // src/recovery.ts
565
+ var MIN_FACTORS = 3;
566
+ var Recovery = class {
567
+ constructor(http, deps = {}) {
568
+ this.http = http;
569
+ this.deps = deps;
570
+ }
571
+ http;
572
+ deps;
573
+ /**
574
+ * Back up a wallet's device share: encrypt it with the recovery code and enrol the
575
+ * factors. Returns the (possibly generated) recovery code — show it to the user
576
+ * once; it is the only key to the backup and is never recoverable from the server.
577
+ */
578
+ async register(walletId, params, options = {}) {
579
+ return registerRecoveryShare(this.http, walletId, params, options.signal);
580
+ }
581
+ /** The registered recovery factors for a wallet (no secrets). */
582
+ getInfo(walletId, options = {}) {
583
+ return this.http.request("GET", `/v1/wallets/${walletId}/recovery/info`, void 0, options.signal);
584
+ }
585
+ /** Start a recovery session — returns the challengeId + the factors the user must verify. */
586
+ challenge(walletId, options = {}) {
587
+ return this.http.request("POST", `/v1/wallets/${walletId}/recovery/challenge`, void 0, options.signal);
588
+ }
589
+ /**
590
+ * Device-loss recovery: verify factors, have the server rotate the key shares,
591
+ * then decrypt the backup with the recovery code and restore it to the share store
592
+ * (when one is configured). Returns when the share is restored.
593
+ */
594
+ async recover(walletId, params, options = {}) {
595
+ const res = await this.http.request(
596
+ "POST",
597
+ `/v1/wallets/${walletId}/recovery/recover`,
598
+ { challengeId: params.challengeId, verifications: await hashRecoveryFactors(params.verifications) },
599
+ options.signal
600
+ );
601
+ const share = await this.decrypt(res.ciphertext, params.recoveryCode);
602
+ await this.deps.shareStore?.put(walletId, share);
603
+ this.deps.analytics?.track("wallet.recovered", { walletId });
604
+ return { share, refreshedAt: res.refreshedAt };
605
+ }
606
+ /**
607
+ * Verify factors and decrypt the backed-up share **without** rotating keys — for a
608
+ * read-only restore. Use {@link recover} for true device-loss (which re-keys).
609
+ *
610
+ * NOTE (issue #41, LOW): because this path does not rotate the (possibly lost) device
611
+ * share, the server `/verify` endpoint must enforce that **all ≥3 factors** were
612
+ * satisfied before releasing the ciphertext; prefer {@link recover} (always-rotate) for
613
+ * device-loss so a leaked backup can't be replayed against a still-valid old share.
614
+ */
615
+ async retrieveShare(walletId, params, options = {}) {
616
+ const ciphertext = await fetchRecoveryCiphertext(this.http, walletId, params, options.signal);
617
+ return this.decrypt(ciphertext, params.recoveryCode);
618
+ }
619
+ async decrypt(ciphertext, recoveryCode) {
620
+ try {
621
+ return await openWithPassword(recoveryCode, ciphertext);
622
+ } catch (cause) {
623
+ throw new WaaskeyError("Could not decrypt the recovery share \u2014 wrong recovery code?", "recovery_failed", { cause });
624
+ }
625
+ }
626
+ };
627
+ async function buildRecoveryRegistration(params) {
628
+ const recoveryCode = params.recoveryCode ?? generateRecoveryCode();
629
+ const ciphertext = await sealWithPassword(recoveryCode, params.share);
630
+ const factors = [
631
+ { type: "recovery_code", credentialHash: await sha256Hex(recoveryCode) },
632
+ { type: "totp", credential: params.totpSecret },
633
+ { type: "email_otp", credential: params.email },
634
+ ...params.extraFactors ?? []
635
+ ];
636
+ if (factors.length < MIN_FACTORS) {
637
+ throw new WaaskeyError(`Recovery requires at least ${MIN_FACTORS} factors.`, "validation");
638
+ }
639
+ return { recoveryCode, payload: { ciphertext, factors } };
640
+ }
641
+ function postRecoveryRegistration(http, walletId, payload, signal) {
642
+ return http.request("POST", `/v1/wallets/${walletId}/recovery/register`, payload, signal);
643
+ }
644
+ async function fetchRecoveryCiphertext(http, walletId, params, signal) {
645
+ const res = await http.request(
646
+ "POST",
647
+ `/v1/wallets/${walletId}/recovery/verify`,
648
+ { challengeId: params.challengeId, verifications: await hashRecoveryFactors(params.verifications) },
649
+ signal
650
+ );
651
+ return res.ciphertext;
652
+ }
653
+ async function registerRecoveryShare(http, walletId, params, signal) {
654
+ const { recoveryCode, payload } = await buildRecoveryRegistration(params);
655
+ const share = await postRecoveryRegistration(http, walletId, payload, signal);
656
+ return { recoveryCode, share };
657
+ }
658
+ async function hashRecoveryFactors(verifications) {
659
+ return Promise.all(verifications.map(async (v) => v.type === "recovery_code" && v.token !== void 0 ? { type: v.type, credentialHash: await sha256Hex(v.token) } : v));
660
+ }
661
+ function generateRecoveryCode() {
662
+ const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
663
+ const bytes = randomBytes(20);
664
+ let out = "";
665
+ for (let i = 0; i < bytes.length; i++) {
666
+ out += alphabet[bytes[i] % alphabet.length];
667
+ if ((i + 1) % 4 === 0 && i + 1 < bytes.length) out += "-";
668
+ }
669
+ return out;
670
+ }
671
+
672
+ // src/chains.ts
673
+ var CHAIN_CURVES = {
674
+ ethereum: "secp256k1",
675
+ polygon: "secp256k1",
676
+ arbitrum: "secp256k1",
677
+ base: "secp256k1",
678
+ optimism: "secp256k1",
679
+ bitcoin: "secp256k1",
680
+ // Solana signs with threshold EdDSA (ed25519 / FROST, #110): the device co-signs 2-party with the
681
+ // server `server` party over the relay (keygenEddsa / signEddsa), distinct from the cggmp24 path.
682
+ solana: "ed25519"
683
+ };
684
+ function curveForChain(chain) {
685
+ const curve = CHAIN_CURVES[chain];
686
+ if (!curve) {
687
+ throw new WaaskeyError(`Chain "${chain}" is not supported yet.`, "unsupported_chain");
688
+ }
689
+ return curve;
690
+ }
691
+ function toMpcCurve(curve) {
692
+ if (curve === "ed25519") {
693
+ throw new WaaskeyError("ed25519 (FROST) wallets do not use the cggmp24 curve-based MPC path.", "unsupported");
694
+ }
695
+ return curve;
696
+ }
697
+
698
+ // src/internal.ts
699
+ function throwIfAborted(signal) {
700
+ if (signal?.aborted) throw new WaaskeyError("Operation aborted.", "aborted");
701
+ }
702
+ function delay(ms, signal) {
703
+ return new Promise((resolve, reject) => {
704
+ if (signal?.aborted) {
705
+ reject(new WaaskeyError("Operation aborted.", "aborted"));
706
+ return;
707
+ }
708
+ const handle = {};
709
+ const onAbort = () => {
710
+ if (handle.timer) clearTimeout(handle.timer);
711
+ reject(new WaaskeyError("Operation aborted.", "aborted"));
712
+ };
713
+ handle.timer = setTimeout(() => {
714
+ signal?.removeEventListener("abort", onAbort);
715
+ resolve();
716
+ }, ms);
717
+ signal?.addEventListener("abort", onAbort, { once: true });
718
+ });
719
+ }
720
+
721
+ // src/reshare.ts
722
+ var Reshare = class {
723
+ constructor(http, deps = {}) {
724
+ this.http = http;
725
+ this.deps = deps;
726
+ }
727
+ http;
728
+ deps;
729
+ /**
730
+ * Complete this device's share for a device-retaining reshare and make the wallet signable on the
731
+ * device under the new epoch.
732
+ *
733
+ * 1. Fetch the wallet to learn its (unchanged) public key — the invariant the completion is checked
734
+ * against, taken from the server's truth rather than the caller.
735
+ * 2. Assemble the NEW-epoch bare core locally from {@link ReshareCompletionParams.material} (no relay).
736
+ * 3. Run the aux-completion ceremony over the relay ({@link ReshareCompletionParams.ceremony}),
737
+ * alongside the platform server + recovery parties, to obtain the COMPLETE signable share.
738
+ * 4. Verify the completed share's public key equals the wallet's (fail closed on any mismatch).
739
+ * 5. Seal + persist the completed share under `(walletId, keyEpoch)`, keeping the old-epoch share.
740
+ *
741
+ * Requires `mpc` (built with the reshare capability) + `shareStore`. Idempotent/recoverable: safe to
742
+ * retry, since nothing is overwritten until the new share is assembled, verified, and stored.
743
+ */
744
+ async complete(walletId, params, options = {}) {
745
+ const { mpc, shareStore } = this.deps;
746
+ if (!mpc || !shareStore) {
747
+ throw new WaaskeyError("Completing a reshare requires a device MPC core and a share store \u2014 pass `mpc` and `shareStore` to `new Waaskey(...)`.", "device_core_required");
748
+ }
749
+ if (!mpc.runReshareAssemble || !mpc.runCompleteReshare) {
750
+ throw new WaaskeyError("The configured MPC core does not support reshare \u2014 use a client-wasm built with the `reshare` feature.", "unsupported");
751
+ }
752
+ const { material, ceremony, keyEpoch } = params;
753
+ const { signal } = options;
754
+ throwIfAborted(signal);
755
+ const wallet = await this.http.request("GET", `/v1/wallets/${walletId}`, void 0, signal);
756
+ if (!wallet.publicKey) {
757
+ throw new WaaskeyError("The wallet has no public key yet \u2014 it must be an ACTIVE reshared wallet to complete on device.", "reshare_failed", { details: { walletId } });
758
+ }
759
+ throwIfAborted(signal);
760
+ let completed;
761
+ try {
762
+ const assembled = await mpc.runReshareAssemble({
763
+ curve: toMpcCurve(material.curve),
764
+ newPosition: material.newPosition,
765
+ newPreimages: material.newPreimages,
766
+ newThreshold: material.newThreshold,
767
+ wallet: material.wallet,
768
+ commitments: material.commitments,
769
+ subShares: material.subShares
770
+ });
771
+ assertPublicKey(assembled.sharedPublicKey, wallet.publicKey, walletId);
772
+ throwIfAborted(signal);
773
+ completed = await mpc.runCompleteReshare({
774
+ curve: toMpcCurve(material.curve),
775
+ relayUrl: ceremony.relayUrl,
776
+ sessionId: ceremony.sessionId,
777
+ role: ceremony.role,
778
+ peerRole: ceremony.peerRole,
779
+ partyIndex: ceremony.partyIndex,
780
+ peerPartyIndex: ceremony.peerPartyIndex,
781
+ parties: ceremony.parties,
782
+ relayToken: ceremony.relayToken,
783
+ core: assembled.core,
784
+ pregeneratedPrimes: params.pregeneratedPrimes
785
+ });
786
+ } catch (cause) {
787
+ if (cause instanceof WaaskeyError) throw cause;
788
+ throw new WaaskeyError("The device reshare-completion ceremony failed.", "reshare_failed", { cause });
789
+ }
790
+ assertPublicKey(completed.sharedPublicKey, wallet.publicKey, walletId);
791
+ await shareStore.put(epochShareKey(walletId, keyEpoch), serializeCompletedShare(completed));
792
+ this.deps.analytics?.track("wallet.reshared", { walletId, curve: material.curve });
793
+ return { walletId, keyEpoch, sharedPublicKey: completed.sharedPublicKey };
794
+ }
795
+ };
796
+ function epochShareKey(walletId, keyEpoch) {
797
+ return keyEpoch <= 1 ? walletId : `${walletId}@epoch-${keyEpoch}`;
798
+ }
799
+ function assertPublicKey(actual, expected, walletId) {
800
+ if (actual !== expected) {
801
+ throw new WaaskeyError("The reshared share reproduced a DIFFERENT public key than the wallet \u2014 aborting (device share not stored).", "reshare_pubkey_mismatch", {
802
+ details: { walletId, expected, actual }
803
+ });
804
+ }
805
+ }
806
+ function serializeCompletedShare(completed) {
807
+ return JSON.stringify({ keyShare: completed.keyShare, sharedPublicKey: completed.sharedPublicKey });
808
+ }
809
+
810
+ // src/custody.ts
811
+ var CUSTODY_KINDS = /* @__PURE__ */ new Set(["user_device", "user_backup", "platform_signer", "platform_recovery", "external_party"]);
812
+ var CUSTODY_TYPES = /* @__PURE__ */ new Set(["embedded", "shared", "self_custody"]);
813
+ function isNonCustodial(wallet) {
814
+ return wallet.platformShareCount < wallet.threshold;
815
+ }
816
+ function validateCustodyPolicy(params) {
817
+ const { threshold, parties, custodyKinds, custodyType } = params;
818
+ if (threshold === void 0 && parties === void 0 && custodyKinds === void 0 && custodyType === void 0) return;
819
+ if (parties !== void 0) {
820
+ if (!Array.isArray(parties) || parties.length < 2) {
821
+ throw new WaaskeyError("`parties` must list at least 2 party roles.", "validation");
822
+ }
823
+ if (parties.some((role) => typeof role !== "string" || role.trim() === "")) {
824
+ throw new WaaskeyError("`parties` roles must be non-empty strings.", "validation");
825
+ }
826
+ if (new Set(parties).size !== parties.length) {
827
+ throw new WaaskeyError("`parties` roles must be distinct.", "validation");
828
+ }
829
+ }
830
+ if (threshold !== void 0) {
831
+ if (!Number.isInteger(threshold) || threshold < 2) {
832
+ throw new WaaskeyError("`threshold` must be an integer of at least 2.", "validation");
833
+ }
834
+ if (parties !== void 0 && threshold > parties.length) {
835
+ throw new WaaskeyError("`threshold` cannot exceed the number of parties.", "validation");
836
+ }
837
+ }
838
+ if (custodyKinds !== void 0) {
839
+ if (parties === void 0) {
840
+ throw new WaaskeyError("`custodyKinds` requires `parties` \u2014 they are parallel arrays.", "validation");
841
+ }
842
+ if (custodyKinds.length !== parties.length) {
843
+ throw new WaaskeyError("`custodyKinds` must have exactly one entry per party (same length as `parties`).", "validation");
844
+ }
845
+ const unknown = custodyKinds.find((kind) => !CUSTODY_KINDS.has(kind));
846
+ if (unknown !== void 0) {
847
+ throw new WaaskeyError(`\`custodyKinds\` contains an unknown custody kind: ${String(unknown)}.`, "validation");
848
+ }
849
+ }
850
+ if (custodyType !== void 0 && !CUSTODY_TYPES.has(custodyType)) {
851
+ throw new WaaskeyError(`\`custodyType\` is not a valid custody type: ${String(custodyType)}.`, "validation");
852
+ }
853
+ }
854
+
855
+ // src/eddsa-share.ts
856
+ function serializeEddsaShare(keygen) {
857
+ return JSON.stringify({ keyPackage: keygen.keyPackage, publicKeyPackage: keygen.publicKeyPackage });
858
+ }
859
+ function deserializeEddsaShare(blob) {
860
+ let parsed;
861
+ try {
862
+ parsed = JSON.parse(blob);
863
+ } catch (cause) {
864
+ throw new WaaskeyError("Stored ed25519 device share is corrupt \u2014 it is not valid JSON.", "share_not_found", { cause });
865
+ }
866
+ if (parsed.keyPackage === void 0 || parsed.publicKeyPackage === void 0) {
867
+ throw new WaaskeyError("Stored ed25519 device share is malformed \u2014 it is missing its FROST key/public-key package.", "share_not_found", {
868
+ details: { keys: Object.keys(parsed) }
869
+ });
870
+ }
871
+ return { keyPackage: parsed.keyPackage, publicKeyPackage: parsed.publicKeyPackage };
872
+ }
873
+ var DEVICE_ENC_STORE_KEY = "__waaskey_device_enc_v1__";
874
+ async function getOrCreateDeviceEncKeypair(shareStore) {
875
+ const existing = await shareStore.get(DEVICE_ENC_STORE_KEY);
876
+ if (existing !== null) return parseDeviceEncKeypair(existing);
877
+ const keypair = generateDeviceEncKeypair();
878
+ await shareStore.put(DEVICE_ENC_STORE_KEY, JSON.stringify(keypair));
879
+ return keypair;
880
+ }
881
+ function generateDeviceEncKeypair() {
882
+ const secret = ed25519_js.x25519.utils.randomSecretKey();
883
+ const publicKey = ed25519_js.x25519.getPublicKey(secret);
884
+ if (isAllZero(publicKey)) {
885
+ throw new WaaskeyError("Generated an all-zero X25519 encryption public key \u2014 refusing to seal keygen packages to it.", "keygen_failed");
886
+ }
887
+ return { secretHex: bytesToHex(secret), publicHex: bytesToHex(publicKey) };
888
+ }
889
+ function parseDeviceEncKeypair(blob) {
890
+ let parsed;
891
+ try {
892
+ parsed = JSON.parse(blob);
893
+ } catch (cause) {
894
+ throw new WaaskeyError("Stored device encryption keypair is corrupt \u2014 it is not valid JSON.", "keygen_failed", { cause });
895
+ }
896
+ if (!isHex32(parsed.secretHex) || !isHex32(parsed.publicHex)) {
897
+ throw new WaaskeyError("Stored device encryption keypair is malformed \u2014 expected 32-byte (64 hex) secret + public keys.", "keygen_failed", {
898
+ details: { keys: Object.keys(parsed) }
899
+ });
900
+ }
901
+ return { secretHex: parsed.secretHex, publicHex: parsed.publicHex };
902
+ }
903
+ function isHex32(v) {
904
+ return typeof v === "string" && /^[0-9a-fA-F]{64}$/.test(v);
905
+ }
906
+ function isAllZero(bytes) {
907
+ return bytes.every((byte) => byte === 0);
908
+ }
909
+ function bytesToHex(bytes) {
910
+ let hex = "";
911
+ for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
912
+ return hex;
913
+ }
914
+
915
+ // src/passkey/assertion.ts
916
+ function isPasskeyAssertionSupported() {
917
+ return typeof globalThis.navigator !== "undefined" && typeof globalThis.navigator.credentials !== "undefined";
918
+ }
919
+ async function defaultAssertionCeremony() {
920
+ let mod;
921
+ try {
922
+ mod = await import('@simplewebauthn/browser');
923
+ } catch {
924
+ throw new WaaskeyError('Passkey step-up requires "@simplewebauthn/browser" installed, or pass a custom ceremony.', "unsupported");
925
+ }
926
+ return {
927
+ get: (options) => mod.startAuthentication({ optionsJSON: options })
928
+ };
929
+ }
930
+ async function getSigningAssertion(challenge, options = {}) {
931
+ if (options.ceremony === void 0 && !isPasskeyAssertionSupported()) {
932
+ throw new WaaskeyError("Passkey step-up is not available in this runtime (no WebAuthn API).", "unsupported");
933
+ }
934
+ const ceremony = options.ceremony ?? await defaultAssertionCeremony();
935
+ const rpId = options.rpId ?? (typeof location === "undefined" ? void 0 : location.hostname);
936
+ const requestOptions = {
937
+ challenge,
938
+ userVerification: "required",
939
+ ...rpId ? { rpId } : {},
940
+ ...options.credentialId ? { allowCredentials: [{ id: options.credentialId, type: "public-key" }] } : {}
941
+ };
942
+ try {
943
+ return await ceremony.get(requestOptions);
944
+ } catch (cause) {
945
+ if (cause instanceof WaaskeyError) throw cause;
946
+ const msg = cause instanceof Error ? cause.message : String(cause);
947
+ if (/cancel|abort|not allowed|user gesture/i.test(msg)) {
948
+ throw new WaaskeyError("Passkey authentication was cancelled by the user.", "aborted", { cause });
949
+ }
950
+ throw new WaaskeyError("Passkey step-up assertion failed.", "unsupported", { cause });
951
+ }
952
+ }
953
+
48
954
  // src/wallet.ts
49
955
  var Wallet = class {
50
- constructor(http, data) {
956
+ constructor(http, data, analytics, device = {}) {
51
957
  this.http = http;
958
+ this.analytics = analytics;
959
+ this.device = device;
52
960
  this.id = data.id;
53
- this.address = data.address;
54
- this.chain = data.chain;
961
+ this.data = data;
55
962
  }
56
963
  http;
964
+ analytics;
965
+ device;
966
+ /** Waaskey wallet id, e.g. `wlt_…`. */
57
967
  id;
58
- address;
59
- chain;
60
- /** Sign an arbitrary message with this wallet's key (2-of-3 threshold MPC). */
61
- async signMessage(message) {
62
- const { signature } = await this.http.request("POST", `/v1/wallets/${this.id}/sign-message`, { message });
63
- return signature;
968
+ /** The full wallet record as returned by the API. */
969
+ data;
970
+ /** On-chain address (set once keygen completes). */
971
+ get address() {
972
+ return this.data.address;
973
+ }
974
+ /** Lifecycle state (`pending` until keygen completes, then `active`). */
975
+ get status() {
976
+ return this.data.status;
977
+ }
978
+ /** Signing curve of the wallet. */
979
+ get curve() {
980
+ return this.data.curve;
981
+ }
982
+ /** Signing threshold `t` of the wallet's `t`-of-`n` MPC key. */
983
+ get threshold() {
984
+ return this.data.threshold;
985
+ }
986
+ /** Ordered party roles of the wallet's keygen topology, length `n`. */
987
+ get parties() {
988
+ return this.data.parties;
989
+ }
990
+ /** Per-party custody kind, parallel to {@link parties}. */
991
+ get custodyKinds() {
992
+ return this.data.custodyKinds;
993
+ }
994
+ /** How many of the wallet's shares the platform itself holds. */
995
+ get platformShareCount() {
996
+ return this.data.platformShareCount;
997
+ }
998
+ /**
999
+ * The wallet's custody attestation (`embedded` / `shared` / `self_custody`) — display
1000
+ * it to prove the custody guarantee to the end-user (see {@link isNonCustodial}).
1001
+ */
1002
+ get custodyType() {
1003
+ return this.data.custodyType;
1004
+ }
1005
+ /**
1006
+ * Whether this wallet is **non-custodial** — the platform's shares alone do not meet
1007
+ * the threshold (`platformShareCount < threshold`, i.e. custody type `shared` or
1008
+ * `self_custody`), so WaaS cannot sign without the user/external party.
1009
+ */
1010
+ get isNonCustodial() {
1011
+ return isNonCustodial(this.data);
1012
+ }
1013
+ /**
1014
+ * Sign a 32-byte message digest with this wallet's key (2-of-3 threshold MPC).
1015
+ *
1016
+ * `digest` is a 32-byte hash as hex (the `0x` prefix is optional) — e.g. the
1017
+ * keccak-256 of an EVM transaction. Hashing a higher-level message/transaction
1018
+ * into a digest is the caller's (or a chain helper's) responsibility.
1019
+ *
1020
+ * **Passkey step-up (Pattern B / issue #21, #39):** pass `{ requirePasskey: true }` to run a
1021
+ * WebAuthn assertion before signing. The SDK fetches a **server-issued one-time challenge**,
1022
+ * prompts the user's authenticator over it, and attaches `passkeyAssertion` + its
1023
+ * `passkeyChallengeId` to the POST body; the backend verifies both the MPC signature and the
1024
+ * assertion, then burns the challenge (so it cannot be replayed). Alternatively supply a
1025
+ * pre-built assertion via `options.passkeyAssertion` (with its `options.passkeyChallengeId`).
1026
+ */
1027
+ async sign(digest, options = {}) {
1028
+ const message = normalizeDigest(digest);
1029
+ const body = { message };
1030
+ await this.attachStepUp(body, "sign", options);
1031
+ const res = await this.http.request("POST", `/v1/wallets/${this.id}/sign`, body, options.signal);
1032
+ this.analytics?.track("wallet.signed", { walletId: this.id, curve: this.data.curve });
1033
+ return res.signature;
1034
+ }
1035
+ /**
1036
+ * Send a transaction from this wallet. The platform builds the chain-specific transaction
1037
+ * and co-signs it with the 2-of-3 MPC quorum, returning the **signed raw transaction**.
1038
+ * `value` is in the chain's base unit (wei) as a numeric string.
1039
+ *
1040
+ * **WaaS is sign-only — it never broadcasts.** The result's {@link SendResult.signedTx} is
1041
+ * the signed raw tx the *client* submits to its own node/provider (see
1042
+ * {@link Waaskey.broadcast} or your own submitter); {@link SendResult.txHash} is a
1043
+ * deterministic offline id for reference, not proof of broadcast.
1044
+ *
1045
+ * **Passkey step-up (Pattern B / issue #21, #39):** pass `{ requirePasskey: true }` to run a
1046
+ * WebAuthn assertion before sending, over a server-issued one-time challenge (verified + burned
1047
+ * server-side). Alternatively supply a pre-built assertion via `options.passkeyAssertion`.
1048
+ */
1049
+ async send(params, options = {}) {
1050
+ if (this.data.curve === "ed25519") {
1051
+ return this.sendEd25519(params, options);
1052
+ }
1053
+ const body = { ...params };
1054
+ await this.attachStepUp(body, "send", options);
1055
+ const res = await this.http.request("POST", `/v1/wallets/${this.id}/send`, body, options.signal);
1056
+ this.analytics?.track("wallet.sent", { walletId: this.id, chain: params.chainId });
1057
+ return res;
1058
+ }
1059
+ /**
1060
+ * Device-co-signed send for an ed25519 (FROST) wallet (#110) — the browser holds the device FROST
1061
+ * share and co-signs 2-party with the backend `server` party over the relay:
1062
+ *
1063
+ * 1. **START** (`POST …/send-session`): the backend builds the unsigned tx (chain adapter), starts
1064
+ * its server FROST party on the relay in the background, and returns the raw `message` bytes to
1065
+ * sign + the relay coordination ({@link EddsaSendSession}).
1066
+ * 2. **CO-SIGN**: the device runs `signEddsa` over the relay with its stored `{keyPackage,
1067
+ * publicKeyPackage}` share; the two parties aggregate the RFC 8032 signature (returned locally).
1068
+ * 3. **ASSEMBLE** (`POST …/send-session/:txId/assemble`): the backend embeds the aggregated
1069
+ * signature into the wire tx (chain adapter) and returns the signed raw tx + offline `txHash`.
1070
+ *
1071
+ * WaaS stays sign-only: the returned {@link SendResult.signedTx} is what the client broadcasts.
1072
+ */
1073
+ async sendEd25519(params, options) {
1074
+ const { mpc, shareStore } = this.device;
1075
+ if (!mpc || !shareStore) {
1076
+ throw new WaaskeyError(
1077
+ "Signing an ed25519 transaction requires a device MPC core and a share store \u2014 pass `mpc` and `shareStore` to `new Waaskey(...)`.",
1078
+ "device_core_required"
1079
+ );
1080
+ }
1081
+ if (!mpc.runEddsaSign) {
1082
+ throw new WaaskeyError(
1083
+ "The configured MPC core does not support ed25519 (FROST) signing \u2014 use a client-wasm build with the ed25519 (keygenEddsa/signEddsa) exports.",
1084
+ "unsupported"
1085
+ );
1086
+ }
1087
+ const { signal } = options;
1088
+ throwIfAborted(signal);
1089
+ const blob = await shareStore.get(this.id);
1090
+ if (!blob) {
1091
+ throw new WaaskeyError(`No stored device share for wallet "${this.id}" \u2014 this device never completed the ed25519 keygen.`, "share_not_found", {
1092
+ details: { walletId: this.id }
1093
+ });
1094
+ }
1095
+ const { keyPackage, publicKeyPackage } = deserializeEddsaShare(blob);
1096
+ const startBody = { ...params };
1097
+ await this.attachStepUp(startBody, "send", options);
1098
+ throwIfAborted(signal);
1099
+ const session = await this.http.request("POST", `/v1/wallets/${this.id}/send-session`, startBody, signal);
1100
+ throwIfAborted(signal);
1101
+ let signature;
1102
+ try {
1103
+ ({ signature } = await mpc.runEddsaSign({
1104
+ relayUrl: session.relayUrl,
1105
+ sessionId: session.sessionId,
1106
+ roles: session.roles,
1107
+ partyIndex: session.signerPosition,
1108
+ keyPackage,
1109
+ publicKeyPackage,
1110
+ participants: session.participants,
1111
+ message: session.message,
1112
+ relayToken: session.relayToken
1113
+ }));
1114
+ } catch (cause) {
1115
+ if (cause instanceof WaaskeyError) throw cause;
1116
+ throw new WaaskeyError("The device ed25519 sign ceremony failed.", "sign_failed", { cause });
1117
+ }
1118
+ throwIfAborted(signal);
1119
+ const assemble = { signature };
1120
+ const res = await this.http.request("POST", `/v1/wallets/${this.id}/send-session/${session.txId}/assemble`, assemble, signal);
1121
+ this.analytics?.track("wallet.sent", { walletId: this.id, chain: params.chainId });
1122
+ return res;
1123
+ }
1124
+ /**
1125
+ * Attach a passkey step-up assertion to `body` when the caller requests one (issue #39).
1126
+ *
1127
+ * The challenge is a **server-issued one-time nonce**, never derived from the request
1128
+ * payload: the SDK fetches it from the step-up challenge endpoint, runs the WebAuthn
1129
+ * assertion over it, and echoes its `challengeId` so the server can verify and burn it —
1130
+ * making a captured assertion non-replayable. A pre-built `passkeyAssertion` (with its
1131
+ * `passkeyChallengeId`) is attached as-is and takes precedence over `requirePasskey`.
1132
+ */
1133
+ attachStepUp(body, operation, options) {
1134
+ return attachPasskeyStepUp(this.http, this.id, body, operation, options);
1135
+ }
1136
+ /**
1137
+ * List this wallet's **signing activity**, newest first (paginated) — the unified audit
1138
+ * trail of what this key signed (raw signs and send/sweep signed txs, #289). A send/sweep
1139
+ * record carries the `signedTx` the client broadcasts.
1140
+ */
1141
+ async signatures(query = {}, options = {}) {
1142
+ const qs = new URLSearchParams();
1143
+ if (query.page !== void 0) qs.set("page", String(query.page));
1144
+ if (query.limit !== void 0) qs.set("limit", String(query.limit));
1145
+ const suffix = qs.toString() ? `?${qs}` : "";
1146
+ return this.http.request("GET", `/v1/wallets/${this.id}/signatures${suffix}`, void 0, options.signal);
64
1147
  }
65
1148
  };
1149
+ async function attachPasskeyStepUp(http, walletId, body, operation, options) {
1150
+ if (options.passkeyAssertion !== void 0) {
1151
+ body["passkeyAssertion"] = options.passkeyAssertion;
1152
+ if (options.passkeyChallengeId !== void 0) body["passkeyChallengeId"] = options.passkeyChallengeId;
1153
+ return;
1154
+ }
1155
+ if (!options.requirePasskey) return;
1156
+ const { challengeId, challenge } = await http.request("POST", `/v1/wallets/${walletId}/stepup/challenge`, { operation }, options.signal);
1157
+ body["passkeyAssertion"] = await getSigningAssertion(challenge, { credentialId: options.passkeyCredentialId });
1158
+ body["passkeyChallengeId"] = challengeId;
1159
+ }
1160
+ function normalizeDigest(digest) {
1161
+ const hex = digest.startsWith("0x") || digest.startsWith("0X") ? digest.slice(2) : digest;
1162
+ if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
1163
+ throw new WaaskeyError("`digest` must be a 32-byte hex string (64 hex chars, optional 0x prefix).", "validation");
1164
+ }
1165
+ return hex;
1166
+ }
66
1167
 
67
1168
  // src/wallets.ts
1169
+ var BACKUP_REGISTER_ATTEMPTS = 3;
1170
+ var BACKUP_REGISTER_BACKOFF_MS = 200;
1171
+ var USER_BACKUP_ROLE = "user_backup";
1172
+ var DEFAULT_ACTIVATION_TIMEOUT_MS = 6e4;
1173
+ var DEFAULT_POLL_INTERVAL_MS = 1e3;
68
1174
  var Wallets = class {
69
- constructor(http) {
1175
+ constructor(http, deps = {}) {
70
1176
  this.http = http;
1177
+ this.deps = deps;
71
1178
  }
72
1179
  http;
73
- /** Create a new MPC wallet on the given chain. */
74
- async create(params) {
75
- const data = await this.http.request("POST", "/v1/wallets", params);
76
- return new Wallet(this.http, data);
1180
+ deps;
1181
+ /**
1182
+ * Create a new MPC wallet on the given chain. The keygen is a `t`-of-`n` ceremony:
1183
+ *
1184
+ * 1. ask the API to start a wallet + the server party (returns the ceremony params),
1185
+ * 2. run the **device** half of keygen locally (in WASM) against the relay,
1186
+ * 3. seal + persist the device share (it never leaves the device),
1187
+ * 4. wait until the wallet is ACTIVE (server party finished) and return it.
1188
+ *
1189
+ * By default (no custody policy) this is the embedded **2-of-3** topology. Supply an
1190
+ * explicit `{ threshold, parties, custodyKinds }` and/or a requested `custodyType` to
1191
+ * change the topology / custody posture — the SDK validates it client-side (see
1192
+ * {@link validateCustodyPolicy}) and the backend enforces the attested invariant. The
1193
+ * device runs whatever `t`-of-`n` the returned ceremony describes (its `parties` /
1194
+ * `threshold`), so the ceremony is not pinned to 2-of-3.
1195
+ *
1196
+ * **Non-custodial `[device, server, user_backup]` (#351/#78).** When the returned ceremony carries a
1197
+ * `user_backup` party ({@link WalletCeremony.additionalParties}), the platform signer drives ONLY the
1198
+ * `server` share, so this ONE device runs BOTH client parties in the SAME keygen ceremony — the
1199
+ * `device` party AND the client-held `user_backup` party — joining the same relay session with each
1200
+ * party's own relay token. It then persists the `device` share locally (as always) and, because there
1201
+ * is no platform recovery share, seals the `user_backup` share with the caller's recovery code and
1202
+ * registers the ciphertext server-side (reusing the recovery mechanism), so device-loss can never lock
1203
+ * funds. This path therefore REQUIRES `options.backup`. The sealed backup is persisted to a local
1204
+ * pending slot BEFORE the network call, so if registration fails the (non-re-derivable) share is not
1205
+ * lost — `create` throws `backup_failed` and {@link retryBackup} re-registers it (no re-keygen).
1206
+ *
1207
+ * Requires `mpc` + `shareStore` on the client. The device share is the user's half
1208
+ * of the key; without storing it the wallet would be unrecoverable.
1209
+ */
1210
+ async create(params, options = {}) {
1211
+ const { mpc, shareStore, primePool } = this.deps;
1212
+ if (!mpc || !shareStore) {
1213
+ throw new WaaskeyError("Creating a wallet requires a device MPC core and a share store \u2014 pass `mpc` and `shareStore` to `new Waaskey(...)`.", "device_core_required");
1214
+ }
1215
+ validateCustodyPolicy(params);
1216
+ const curve = curveForChain(params.chain);
1217
+ const { signal } = options;
1218
+ throwIfAborted(signal);
1219
+ const body = { label: params.label ?? params.chain, curve };
1220
+ if (params.threshold !== void 0) body["threshold"] = params.threshold;
1221
+ if (params.parties !== void 0) body["parties"] = params.parties;
1222
+ if (params.custodyKinds !== void 0) body["custodyKinds"] = params.custodyKinds;
1223
+ if (params.custodyType !== void 0) body["custodyType"] = params.custodyType;
1224
+ let deviceEncKeypair;
1225
+ if (curve === "ed25519") {
1226
+ deviceEncKeypair = await getOrCreateDeviceEncKeypair(shareStore);
1227
+ body["deviceEncPubkey"] = deviceEncKeypair.publicHex;
1228
+ throwIfAborted(signal);
1229
+ }
1230
+ const created = await this.http.request("POST", "/v1/wallets", body, signal);
1231
+ const { ceremony } = created;
1232
+ if (!ceremony) {
1233
+ throw new WaaskeyError("The API did not return a keygen ceremony to join.", "keygen_failed", { details: created });
1234
+ }
1235
+ throwIfAborted(signal);
1236
+ if (curve === "ed25519") {
1237
+ await this.runEddsaKeygen(mpc, shareStore, created.id, ceremony, deviceEncKeypair);
1238
+ } else {
1239
+ await this.runSecpKeygen(mpc, shareStore, primePool, created.id, ceremony, curve, options.backup, signal);
1240
+ }
1241
+ this.deps.analytics?.track("wallet.created", { walletId: created.id, chain: params.chain, curve });
1242
+ if (options.waitForActive === false) {
1243
+ return new Wallet(this.http, created, this.deps.analytics, this.walletDeviceDeps());
1244
+ }
1245
+ const active = await this.waitUntilActive(created.id, options);
1246
+ return new Wallet(this.http, active, this.deps.analytics, this.walletDeviceDeps());
1247
+ }
1248
+ /**
1249
+ * Create a **member-bound** wallet (#342-#349): an N+1 threshold topology where `N` specific ORG
1250
+ * MEMBERSHIPS (`shareholderMembershipIds`) each hold one share and the platform holds exactly
1251
+ * one (a derived `shared` custody topology — never `embedded`). Unlike {@link create} (the
1252
+ * embedded device+server(+recovery) flow, which runs the device's keygen inline and waits for
1253
+ * ACTIVE), this wallet is provisioned `pending_keygen` with **no ceremony to join here** — it
1254
+ * has no `mpc`/`shareStore` dependency and no custody-policy validation (that machinery is for
1255
+ * the raw `parties`/`custodyKinds` embedded topology, mutually exclusive with
1256
+ * `shareholderMembershipIds` server-side). Each invited member later runs {@link joinCeremony}
1257
+ * from their OWN device/session; the wallet only activates once every member has joined.
1258
+ */
1259
+ async createWallet(params, options = {}) {
1260
+ if (!Array.isArray(params.shareholderMembershipIds) || params.shareholderMembershipIds.length === 0) {
1261
+ throw new WaaskeyError("`shareholderMembershipIds` must list at least 1 member.", "validation");
1262
+ }
1263
+ if (new Set(params.shareholderMembershipIds).size !== params.shareholderMembershipIds.length) {
1264
+ throw new WaaskeyError("`shareholderMembershipIds` must be distinct.", "validation");
1265
+ }
1266
+ if (params.threshold !== void 0) {
1267
+ const n = params.shareholderMembershipIds.length + 1;
1268
+ if (!Number.isInteger(params.threshold) || params.threshold < 2 || params.threshold > n) {
1269
+ throw new WaaskeyError(`\`threshold\` must be an integer in [2, ${n}] (the ${n - 1} members + 1 platform party).`, "validation");
1270
+ }
1271
+ }
1272
+ const { signal } = options;
1273
+ throwIfAborted(signal);
1274
+ const body = { label: params.label, shareholderMembershipIds: params.shareholderMembershipIds };
1275
+ if (params.curve !== void 0) body["curve"] = params.curve;
1276
+ if (params.threshold !== void 0) body["threshold"] = params.threshold;
1277
+ return this.http.request("POST", "/v1/wallets", body, signal);
1278
+ }
1279
+ /**
1280
+ * Warm the prime pool for a chain's curve OFF the hot path — call during onboarding/idle (ideally
1281
+ * from a Web Worker) so the next {@link create} on that chain doesn't pay the safe-prime cost.
1282
+ * No-op when no prime pool is configured.
1283
+ */
1284
+ async prewarm(chain) {
1285
+ const curve = curveForChain(chain);
1286
+ if (curve === "ed25519") return;
1287
+ await this.deps.primePool?.ensure(curve);
1288
+ }
1289
+ /**
1290
+ * List the tenant's wallets, newest first (paginated). Returns plain {@link WalletData}
1291
+ * rows — pass an `id` to {@link get} to obtain a signing-capable {@link Wallet}.
1292
+ */
1293
+ async list(query = {}, options = {}) {
1294
+ const qs = new URLSearchParams();
1295
+ if (query.page !== void 0) qs.set("page", String(query.page));
1296
+ if (query.limit !== void 0) qs.set("limit", String(query.limit));
1297
+ const suffix = qs.toString() ? `?${qs}` : "";
1298
+ return this.http.request("GET", `/v1/wallets${suffix}`, void 0, options.signal);
77
1299
  }
78
1300
  /** Load an existing wallet by id. */
79
- async get(id) {
80
- const data = await this.http.request("GET", `/v1/wallets/${id}`);
81
- return new Wallet(this.http, data);
1301
+ async get(id, options = {}) {
1302
+ const data = await this.http.request("GET", `/v1/wallets/${id}`, void 0, options.signal);
1303
+ return new Wallet(this.http, data, this.deps.analytics, this.walletDeviceDeps());
1304
+ }
1305
+ /** The device core + share store a {@link Wallet} needs to co-sign an ed25519 (FROST) tx locally. */
1306
+ walletDeviceDeps() {
1307
+ return { mpc: this.deps.mpc, shareStore: this.deps.shareStore };
1308
+ }
1309
+ /**
1310
+ * Join a **member-bound** wallet's multi-device keygen ceremony (#344, #349) as an invited org
1311
+ * member — call this from the member's OWN device/session (never a tenant API key: every HTTP
1312
+ * call here is member-session-authenticated, see {@link HttpClient.requestAsMember}). Unlike
1313
+ * {@link create}, every member (N of them) + the platform join the SAME relay session, so:
1314
+ *
1315
+ * 1. fetch this member's own party ({@link MemberCeremony}) plus the wallet's other
1316
+ * share-holders (to derive the full n-party relay roster the ceremony needs),
1317
+ * 2. ack readiness (`POST .../ceremony/join`) — the backend starts its platform party's own
1318
+ * relay connection only once EVERY member has acked, so this must happen before the ceremony
1319
+ * can complete,
1320
+ * 3. run this device's half of the n-party keygen over the relay (blocks until every party,
1321
+ * including the platform, is present and the ceremony completes),
1322
+ * 4. seal + persist the device's ONE share, keyed by `(walletId, membershipId)` — never by
1323
+ * `walletId` alone, since several members' shares for the SAME wallet may live in one
1324
+ * `shareStore` (e.g. a shared device, or a test harness).
1325
+ *
1326
+ * Safe to retry: a failed ceremony reopens the roster server-side, and re-acking/re-running is
1327
+ * idempotent from the caller's perspective.
1328
+ */
1329
+ async joinCeremony(walletId, options = {}) {
1330
+ const { mpc, shareStore } = this.deps;
1331
+ if (!mpc || !shareStore) {
1332
+ throw new WaaskeyError("Joining a keygen ceremony requires a device MPC core and a share store \u2014 pass `mpc` and `shareStore` to `new Waaskey(...)`.", "device_core_required");
1333
+ }
1334
+ if (!mpc.runMemberKeygen) {
1335
+ throw new WaaskeyError("The configured MPC core does not support n-party (member-bound) keygen \u2014 use a client-wasm build with member-ceremony support.", "unsupported");
1336
+ }
1337
+ const { signal } = options;
1338
+ throwIfAborted(signal);
1339
+ const [ceremony, shareholders] = await Promise.all([
1340
+ this.http.requestAsMember("GET", `/v1/wallets/${walletId}/ceremony/mine`, void 0, signal),
1341
+ this.http.requestAsMember("GET", `/v1/wallets/${walletId}/shareholders`, void 0, signal)
1342
+ ]);
1343
+ const roles = buildMemberRoster(shareholders, ceremony.parties);
1344
+ throwIfAborted(signal);
1345
+ const joined = await this.http.requestAsMember("POST", `/v1/wallets/${walletId}/ceremony/join`, void 0, signal);
1346
+ throwIfAborted(signal);
1347
+ let keygen;
1348
+ try {
1349
+ keygen = await mpc.runMemberKeygen({
1350
+ curve: toMpcCurve(ceremony.curve),
1351
+ relayUrl: ceremony.relayUrl,
1352
+ sessionId: ceremony.sessionId,
1353
+ roles,
1354
+ partyIndex: ceremony.partyIndex,
1355
+ threshold: ceremony.threshold,
1356
+ relayToken: ceremony.relayToken
1357
+ });
1358
+ } catch (cause) {
1359
+ if (cause instanceof WaaskeyError) throw cause;
1360
+ throw new WaaskeyError("The member device keygen ceremony failed.", "keygen_failed", { cause });
1361
+ }
1362
+ await shareStore.put(memberShareKey(walletId, membershipIdFromRole(ceremony.role)), serializeShare(keygen));
1363
+ return joined;
1364
+ }
1365
+ /**
1366
+ * Join a **member-bound** wallet's multi-device SIGN ceremony (#347, #349) as an invited org
1367
+ * member — the signing analogue of {@link joinCeremony}, called from the member's OWN
1368
+ * device/session. A sign is a `t`-of-`n` SELECTION (only `t` of the N+1 parties actually sign),
1369
+ * so the flow is:
1370
+ *
1371
+ * 1. cast this member's APPROVE vote (`POST .../sign-requests/:reqId/approve`) — a no-op if
1372
+ * already cast; once `t-1` members have approved, the backend fixes the signing quorum,
1373
+ * 2. poll this member's own sign-ceremony party (`GET .../sign-requests/:reqId/ceremony/mine`)
1374
+ * until the quorum is fixed AND this member was selected into it ({@link MemberSignCeremony.ready}),
1375
+ * 3. load this member's OWN stored share (keyed by `(walletId, membershipId)`, from
1376
+ * {@link joinCeremony}) and run this device's half of the fixed-quorum sign ceremony —
1377
+ * the interactive protocol's public output IS the completed signature.
1378
+ *
1379
+ * A member who is not selected into the fixed quorum, or whose approval never gets it there,
1380
+ * simply times out (`sign_ceremony_timeout`) rather than running anything.
1381
+ */
1382
+ async joinSignCeremony(walletId, reqId, options = {}) {
1383
+ const { mpc, shareStore } = this.deps;
1384
+ if (!mpc || !shareStore) {
1385
+ throw new WaaskeyError("Joining a sign ceremony requires a device MPC core and a share store \u2014 pass `mpc` and `shareStore` to `new Waaskey(...)`.", "device_core_required");
1386
+ }
1387
+ if (!mpc.runMemberSign) {
1388
+ throw new WaaskeyError("The configured MPC core does not support n-party (member-bound) signing \u2014 use a client-wasm build with member-ceremony support.", "unsupported");
1389
+ }
1390
+ const { signal } = options;
1391
+ throwIfAborted(signal);
1392
+ await this.http.requestAsMember("POST", `/v1/wallets/${walletId}/sign-requests/${reqId}/approve`, void 0, signal);
1393
+ const ceremony = await this.waitUntilReady(walletId, reqId, options);
1394
+ const membershipId = membershipIdFromRole(ceremony.role);
1395
+ const blob = await shareStore.get(memberShareKey(walletId, membershipId));
1396
+ if (!blob) {
1397
+ throw new WaaskeyError(`No stored device share for wallet "${walletId}" / member "${membershipId}" \u2014 this device never completed joinCeremony.`, "share_not_found", {
1398
+ details: { walletId, membershipId }
1399
+ });
1400
+ }
1401
+ const { keyShare } = deserializeShare(blob);
1402
+ throwIfAborted(signal);
1403
+ try {
1404
+ return await mpc.runMemberSign({
1405
+ curve: toMpcCurve(ceremony.curve),
1406
+ relayUrl: ceremony.relayUrl,
1407
+ sessionId: ceremony.sessionId,
1408
+ roles: ceremony.quorumRoles,
1409
+ share: keyShare,
1410
+ participants: ceremony.participants,
1411
+ signerPosition: ceremony.signerPosition,
1412
+ digest: ceremony.digest,
1413
+ relayToken: ceremony.relayToken
1414
+ });
1415
+ } catch (cause) {
1416
+ if (cause instanceof WaaskeyError) throw cause;
1417
+ throw new WaaskeyError("The member device sign ceremony failed.", "sign_failed", { cause });
1418
+ }
1419
+ }
1420
+ /**
1421
+ * Approve a pending async signing request (#229) — either completing a single
1422
+ * device-approval request outright, or casting one APPROVE vote in an M-of-N approver
1423
+ * quorum (#309) configured on the wallet (via the tenant dashboard's `PUT .../quorum`). A
1424
+ * non-quorum request starts the MPC ceremony immediately; a quorum request only starts
1425
+ * it once enough approvers vote, and until then the response reports how many
1426
+ * approvals are still needed via {@link SigningRequestResponse.approvalsRemaining}.
1427
+ *
1428
+ * Takes `walletId` + `reqId` directly (rather than a {@link Wallet} instance) since an
1429
+ * approver typically learns of a pending request out-of-band — e.g. a
1430
+ * `sign_request.created` webhook or push notification — without first loading the wallet.
1431
+ */
1432
+ async approveSignRequest(walletId, reqId, options = {}) {
1433
+ return this.http.request("POST", `/v1/wallets/${walletId}/sign-requests/${reqId}/approve`, void 0, options.signal);
1434
+ }
1435
+ /**
1436
+ * Decline a pending async signing request (#229) — either rejecting a single
1437
+ * device-approval request outright, or casting a REJECT vote in an M-of-N approver
1438
+ * quorum (#309), which declines the request as soon as one approver rejects it.
1439
+ */
1440
+ async declineSignRequest(walletId, reqId, options = {}) {
1441
+ return this.http.request("POST", `/v1/wallets/${walletId}/sign-requests/${reqId}/decline`, void 0, options.signal);
1442
+ }
1443
+ /**
1444
+ * List a wallet's async signing requests, newest first (paginated) — optionally filtered to
1445
+ * a single lifecycle {@link SignRequestsQuery.status} (#332). The API paginates the
1446
+ * *filtered* set, so `total`/`page` always describe what was actually matched.
1447
+ */
1448
+ async listSignRequests(walletId, query = {}, options = {}) {
1449
+ const qs = new URLSearchParams();
1450
+ if (query.status !== void 0) qs.set("status", query.status);
1451
+ if (query.page !== void 0) qs.set("page", String(query.page));
1452
+ if (query.limit !== void 0) qs.set("limit", String(query.limit));
1453
+ const suffix = qs.toString() ? `?${qs}` : "";
1454
+ return this.http.request("GET", `/v1/wallets/${walletId}/sign-requests${suffix}`, void 0, options.signal);
1455
+ }
1456
+ /**
1457
+ * List a wallet's **pending approver queue** — the ergonomic entry point over
1458
+ * {@link listSignRequests} pinned to `status: 'pending_approval'` (#309, #329): the
1459
+ * requests currently awaiting an approver's {@link approveSignRequest}/
1460
+ * {@link declineSignRequest} vote, either a single device-approval request or one still
1461
+ * short of its M-of-N quorum (see {@link SigningRequestResponse.approvalsRemaining}).
1462
+ */
1463
+ async listPendingApprovals(walletId, query = {}, options = {}) {
1464
+ return this.listSignRequests(walletId, { ...query, status: "pending_approval" }, options);
1465
+ }
1466
+ /**
1467
+ * Run the cggmp24 (secp256k1) device keygen and persist the resulting share(s) (#351/#78). Two shapes,
1468
+ * chosen by whether the ceremony carries client-held extra parties ({@link WalletCeremony.additionalParties}):
1469
+ *
1470
+ * - **Single client party** (today's path — `[device, server]` or custodial `[device, server, recovery]`,
1471
+ * where the platform drives every non-device party): run just the `device` party and seal its share
1472
+ * under the wallet id, exactly as before.
1473
+ * - **Non-custodial `[device, server, user_backup]`** (a `user_backup` extra party): run BOTH the
1474
+ * `device` and `user_backup` parties CONCURRENTLY in the SAME relay session (each with its own
1475
+ * role-scoped relay token + its OWN Paillier primes), then persist the `device` share locally and
1476
+ * seal + register the `user_backup` share as the non-custodial backup (requires `backup`).
1477
+ *
1478
+ * Any additional party the client cannot drive (a non-`user_backup` role) is refused up front — leaving
1479
+ * it unjoined would hang the whole ceremony, so failing loud beats silently deadlocking.
1480
+ */
1481
+ async runSecpKeygen(mpc, shareStore, primePool, walletId, ceremony, curve, backup, signal) {
1482
+ const extras = ceremony.additionalParties ?? [];
1483
+ const unsupported = extras.filter((party) => party.role !== USER_BACKUP_ROLE);
1484
+ if (unsupported.length > 0) {
1485
+ throw new WaaskeyError(
1486
+ `The keygen ceremony carries an additional client party this SDK cannot drive (${unsupported.map((party) => party.role).join(", ")}) \u2014 refusing to keygen, since leaving a party unjoined would hang the ceremony.`,
1487
+ "unsupported",
1488
+ { details: { roles: unsupported.map((party) => party.role) } }
1489
+ );
1490
+ }
1491
+ const userBackupParty = extras.find((party) => party.role === USER_BACKUP_ROLE);
1492
+ if (!userBackupParty) {
1493
+ const pregeneratedPrimes = primePool ? await primePool.take(curve) : void 0;
1494
+ throwIfAborted(signal);
1495
+ const [keygen] = await this.runKeygenParties(mpc, [{ ...ceremony, curve, pregeneratedPrimes }]);
1496
+ await shareStore.put(walletId, serializeShare(keygen));
1497
+ return;
1498
+ }
1499
+ if (!backup) {
1500
+ throw new WaaskeyError(
1501
+ "This wallet is a non-custodial [device, server, user_backup] topology whose user_backup share must be backed up at keygen \u2014 pass `options.backup` (recoveryCode + totpSecret + email) to `wallets.create(...)`, otherwise the wallet would be unrecoverable on device loss.",
1502
+ "validation"
1503
+ );
1504
+ }
1505
+ const [devicePrimes, backupPrimes] = primePool ? await Promise.all([primePool.take(curve), primePool.take(curve)]) : [void 0, void 0];
1506
+ throwIfAborted(signal);
1507
+ const [deviceKeygen, backupKeygen] = await this.runKeygenParties(mpc, [
1508
+ { ...ceremony, curve, pregeneratedPrimes: devicePrimes },
1509
+ { ...userBackupParty, curve, pregeneratedPrimes: backupPrimes }
1510
+ ]);
1511
+ await shareStore.put(walletId, serializeShare(deviceKeygen));
1512
+ throwIfAborted(signal);
1513
+ const { payload } = await buildRecoveryRegistration({ share: serializeShare(backupKeygen), ...backup });
1514
+ await shareStore.put(userBackupPendingKey(walletId), JSON.stringify(payload));
1515
+ await this.registerBackup(walletId, payload, signal);
1516
+ await shareStore.remove(userBackupPendingKey(walletId));
1517
+ }
1518
+ /**
1519
+ * Register a sealed user_backup {@link RecoveryRegisterPayload} server-side, with a small bounded retry
1520
+ * on TRANSIENT failures (network / 5xx / rate-limit) — a flaky moment must not cost the backup. A
1521
+ * non-transient failure (4xx) fails fast. On abort, the abort propagates unchanged. If it ultimately
1522
+ * cannot register, throws a distinct, actionable {@link WaaskeyError} `backup_failed` (NOT `keygen_failed`)
1523
+ * — the caller's cue that the wallet was created but its backup is only local, and pointing at
1524
+ * {@link retryBackup}. The pending slot is intentionally NOT touched here, so a failure leaves the
1525
+ * sealed share intact for the retry.
1526
+ */
1527
+ async registerBackup(walletId, payload, signal) {
1528
+ let lastCause;
1529
+ for (let attempt = 1; attempt <= BACKUP_REGISTER_ATTEMPTS; attempt++) {
1530
+ throwIfAborted(signal);
1531
+ try {
1532
+ return await postRecoveryRegistration(this.http, walletId, payload, signal);
1533
+ } catch (cause) {
1534
+ if (cause instanceof WaaskeyError && cause.code === "aborted") throw cause;
1535
+ lastCause = cause;
1536
+ const transient = cause instanceof WaaskeyError && (cause.code === "network" || cause.code === "server_error" || cause.code === "rate_limited");
1537
+ if (!transient || attempt === BACKUP_REGISTER_ATTEMPTS) break;
1538
+ await delay(BACKUP_REGISTER_BACKOFF_MS * attempt, signal);
1539
+ }
1540
+ }
1541
+ throw new WaaskeyError(
1542
+ `The wallet "${walletId}" was created, but registering its user_backup recovery backup failed. The sealed backup is retained locally \u2014 retry with \`wallets.retryBackup("${walletId}")\`.`,
1543
+ "backup_failed",
1544
+ { cause: lastCause, details: { walletId } }
1545
+ );
1546
+ }
1547
+ /**
1548
+ * Re-register a non-custodial wallet's user_backup backup that a previous {@link create} sealed locally
1549
+ * but could not register (a `backup_failed` create) (#351/#78). Reads the LOCAL pending-backup slot's
1550
+ * sealed ciphertext and re-POSTs it — **no re-keygen** (the share is not re-derivable) — with the same
1551
+ * bounded transient retry, then clears the slot on success. Idempotent-ish: a no-op `share_not_found`
1552
+ * when nothing is pending (already registered, or never created here). Requires a share store.
1553
+ */
1554
+ async retryBackup(walletId, options = {}) {
1555
+ const { shareStore } = this.deps;
1556
+ if (!shareStore) {
1557
+ throw new WaaskeyError("Retrying a wallet backup requires a share store \u2014 pass `shareStore` to `new Waaskey(...)`.", "device_core_required");
1558
+ }
1559
+ const { signal } = options;
1560
+ throwIfAborted(signal);
1561
+ const stored = await shareStore.get(userBackupPendingKey(walletId));
1562
+ if (!stored) {
1563
+ throw new WaaskeyError(
1564
+ `No pending user_backup backup for wallet "${walletId}" \u2014 nothing to retry (it was already registered, or this device never created it).`,
1565
+ "share_not_found",
1566
+ { details: { walletId } }
1567
+ );
1568
+ }
1569
+ const payload = parseBackupPayload(stored, walletId);
1570
+ const share = await this.registerBackup(walletId, payload, signal);
1571
+ await shareStore.remove(userBackupPendingKey(walletId));
1572
+ return share;
1573
+ }
1574
+ /**
1575
+ * Device-loss RECOVERY CO-SIGN for a non-custodial `[device, server, user_backup]` secp256k1 wallet
1576
+ * (#351/#78). When the device is lost, the user restores their client-held `user_backup` share from the
1577
+ * sealed server-side backup and co-signs `digest` with the platform's `server` party — the 2-party
1578
+ * `{server, user_backup}` quorum. This is NOT the platform-only custodial `{server, recovery}` recoverSign
1579
+ * (`recovery.recoverSign` / `POST …/recovery/recover-sign`): here the platform CANNOT sign alone; the
1580
+ * user's restored share is the co-signing factor.
1581
+ *
1582
+ * 1. **RESTORE** — retrieve the sealed `user_backup` ciphertext (the multi-factor recovery gate,
1583
+ * {@link RecoverParams}, releases it) and open it with the recovery code CLIENT-SIDE (Contract A: the
1584
+ * raw code never leaves the device), yielding the cggmp24 `user_backup` KeyShare. No registered backup
1585
+ * fails `share_not_found`; a wrong recovery code fails `invalid_recovery_code` — either BEFORE any
1586
+ * ceremony starts, so a bad restore never signs (and never hangs).
1587
+ * 2. **START** — `POST /v1/wallets/:id/recover-sign-session` with the digest (+ passkey step-up when the
1588
+ * wallet requires it, threaded exactly like a normal sign via `options`) → the `user_backup` party's
1589
+ * ceremony descriptor: its role, the `{server, user_backup}` keygen `participants`, this party's
1590
+ * `signerPosition`, and a `user_backup`-role relay token.
1591
+ * 3. **CO-SIGN** — run the `user_backup` MPC party ({@link MpcCore.runSign}) over the relay with the
1592
+ * restored share + that descriptor; the platform `server` party co-signs server-side. Returns the
1593
+ * resulting signature.
1594
+ *
1595
+ * The ONLY differences from a normal `{device, server}` device sign are the SHARE (the restored
1596
+ * `user_backup`, not the local device share) and the DESCRIPTOR (from the recover-sign session, not the
1597
+ * normal sign session) — the same {@link MpcCore.runSign} relay machinery drives both.
1598
+ *
1599
+ * Requires `mpc` on the client (the co-signing core). No share store is needed: the restored share is
1600
+ * held only in memory for the ceremony and never persisted (the device is lost/new). secp256k1 only —
1601
+ * re-provisioning a fresh device share (reshare back to a full 2-of-3) is a separate follow-up.
1602
+ */
1603
+ async recoverSign(walletId, params, options = {}) {
1604
+ const { mpc } = this.deps;
1605
+ if (!mpc) {
1606
+ throw new WaaskeyError("Recovery co-signing requires a device MPC core \u2014 pass `mpc` to `new Waaskey(...)`.", "device_core_required");
1607
+ }
1608
+ const { signal } = options;
1609
+ throwIfAborted(signal);
1610
+ const digest = normalizeDigest(params.digest);
1611
+ const ciphertext = await this.fetchUserBackupCiphertext(walletId, params, signal);
1612
+ const share = await restoreUserBackupShare(ciphertext, params.recoveryCode);
1613
+ throwIfAborted(signal);
1614
+ const body = { digest };
1615
+ if (params.chainId !== void 0) body["chainId"] = params.chainId;
1616
+ await attachPasskeyStepUp(this.http, walletId, body, "sign", options);
1617
+ throwIfAborted(signal);
1618
+ const session = await this.http.request("POST", `/v1/wallets/${walletId}/recover-sign-session`, body, signal);
1619
+ throwIfAborted(signal);
1620
+ let signature;
1621
+ try {
1622
+ ({ signature } = await mpc.runSign(toUserBackupSignParams(session, share, digest)));
1623
+ } catch (cause) {
1624
+ if (cause instanceof WaaskeyError) throw cause;
1625
+ throw new WaaskeyError("The user_backup device recover-sign ceremony failed.", "sign_failed", { cause });
1626
+ }
1627
+ this.deps.analytics?.track("wallet.recovered", { walletId, curve: session.curve });
1628
+ return signature;
1629
+ }
1630
+ /**
1631
+ * Retrieve the sealed `user_backup` ciphertext via the recovery gate ({@link fetchRecoveryCiphertext}),
1632
+ * mapping a "no recovery share registered" (404) to the actionable `share_not_found` — this wallet has no
1633
+ * client-held `user_backup` backup to co-sign with (its device-loss recovery is the custodial path instead).
1634
+ */
1635
+ async fetchUserBackupCiphertext(walletId, params, signal) {
1636
+ try {
1637
+ return await fetchRecoveryCiphertext(this.http, walletId, params, signal);
1638
+ } catch (cause) {
1639
+ if (cause instanceof WaaskeyError && cause.code === "not_found") {
1640
+ throw new WaaskeyError(
1641
+ `No registered user_backup recovery backup for wallet "${walletId}" \u2014 device-loss recover-sign needs a non-custodial [device, server, user_backup] wallet whose backup was registered at create.`,
1642
+ "share_not_found",
1643
+ { cause, details: { walletId } }
1644
+ );
1645
+ }
1646
+ throw cause;
1647
+ }
1648
+ }
1649
+ /**
1650
+ * Run one or more device keygen parties (each `mpc.runKeygen`), concurrently, mapping any failure to a
1651
+ * single `keygen_failed`. Used for both the single `device` party and the two-party non-custodial
1652
+ * `[device, user_backup]` ceremony — one place owns the error contract so both paths stay identical.
1653
+ */
1654
+ async runKeygenParties(mpc, params) {
1655
+ try {
1656
+ return await Promise.all(params.map((party) => mpc.runKeygen(party)));
1657
+ } catch (cause) {
1658
+ if (cause instanceof WaaskeyError) throw cause;
1659
+ throw new WaaskeyError("The device keygen ceremony failed.", "keygen_failed", { cause });
1660
+ }
1661
+ }
1662
+ /**
1663
+ * Run the device half of an ed25519 (FROST) keygen (#110) and seal the resulting `{keyPackage,
1664
+ * publicKeyPackage}` share — the EdDSA counterpart of the cggmp24 `runKeygen` branch in {@link create}.
1665
+ * The device co-generates the group key with the backend `server` party over the relay; the FROST DKG
1666
+ * is the 2-party {device, server} quorum the ceremony names (M6 scope), roster-addressed like the
1667
+ * member ceremony rather than the cggmp24 single-peer shape.
1668
+ */
1669
+ async runEddsaKeygen(mpc, shareStore, walletId, ceremony, encKeypair) {
1670
+ if (!mpc.runEddsaKeygen) {
1671
+ throw new WaaskeyError(
1672
+ "The configured MPC core does not support ed25519 (FROST) keygen \u2014 use a client-wasm build with the ed25519 (keygenEddsa/signEddsa) exports.",
1673
+ "unsupported"
1674
+ );
1675
+ }
1676
+ const roles = buildEddsaKeygenRoster(ceremony);
1677
+ const encPubkeys = readEddsaEncRoster(ceremony, encKeypair);
1678
+ let keygen;
1679
+ try {
1680
+ keygen = await mpc.runEddsaKeygen({
1681
+ relayUrl: ceremony.relayUrl,
1682
+ sessionId: ceremony.sessionId,
1683
+ roles,
1684
+ partyIndex: ceremony.partyIndex,
1685
+ threshold: ceremony.threshold,
1686
+ relayToken: ceremony.relayToken,
1687
+ encPubkeys,
1688
+ encSecret: encKeypair.secretHex
1689
+ });
1690
+ } catch (cause) {
1691
+ if (cause instanceof WaaskeyError) throw cause;
1692
+ throw new WaaskeyError("The device ed25519 keygen ceremony failed.", "keygen_failed", { cause });
1693
+ }
1694
+ await shareStore.put(walletId, serializeEddsaShare(keygen));
1695
+ }
1696
+ /** Poll the wallet until keygen completes (ACTIVE), or throw on failure/timeout. */
1697
+ async waitUntilActive(id, options) {
1698
+ const timeoutMs = options.activationTimeoutMs ?? DEFAULT_ACTIVATION_TIMEOUT_MS;
1699
+ const intervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
1700
+ const { signal } = options;
1701
+ const deadline = Date.now() + timeoutMs;
1702
+ for (; ; ) {
1703
+ throwIfAborted(signal);
1704
+ const wallet = await this.http.request("GET", `/v1/wallets/${id}`, void 0, signal);
1705
+ if (wallet.status === "active") return wallet;
1706
+ if (wallet.status === "failed") {
1707
+ throw new WaaskeyError("Wallet keygen failed.", "keygen_failed", { details: wallet });
1708
+ }
1709
+ if (Date.now() >= deadline) {
1710
+ throw new WaaskeyError("Wallet did not activate before the timeout.", "wallet_activation_timeout", { details: wallet });
1711
+ }
1712
+ await delay(intervalMs, signal);
1713
+ }
1714
+ }
1715
+ /** Poll the member's own sign ceremony until the t-of-n quorum is fixed AND this member is selected, or throw on timeout. */
1716
+ async waitUntilReady(walletId, reqId, options) {
1717
+ const timeoutMs = options.readyTimeoutMs ?? DEFAULT_ACTIVATION_TIMEOUT_MS;
1718
+ const intervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
1719
+ const { signal } = options;
1720
+ const deadline = Date.now() + timeoutMs;
1721
+ for (; ; ) {
1722
+ throwIfAborted(signal);
1723
+ const ceremony = await this.http.requestAsMember("GET", `/v1/wallets/${walletId}/sign-requests/${reqId}/ceremony/mine`, void 0, signal);
1724
+ if (isReadyMemberSignCeremony(ceremony)) return ceremony;
1725
+ if (Date.now() >= deadline) {
1726
+ throw new WaaskeyError("The signing quorum did not fix (or did not select this member) before the timeout.", "sign_ceremony_timeout", { details: { walletId, reqId } });
1727
+ }
1728
+ await delay(intervalMs, signal);
1729
+ }
82
1730
  }
83
1731
  };
1732
+ function serializeShare(keygen) {
1733
+ return JSON.stringify({ keyShare: keygen.keyShare, auxInfo: keygen.auxInfo, sharedPublicKey: keygen.sharedPublicKey });
1734
+ }
1735
+ function deserializeShare(blob) {
1736
+ let parsed;
1737
+ try {
1738
+ parsed = JSON.parse(blob);
1739
+ } catch (cause) {
1740
+ throw new WaaskeyError("Stored device share is corrupt \u2014 it is not valid JSON.", "share_not_found", { cause });
1741
+ }
1742
+ if (typeof parsed.keyShare !== "string") {
1743
+ throw new WaaskeyError("Stored device share is malformed \u2014 it is missing its KeyShare.", "share_not_found", { details: { keys: Object.keys(parsed) } });
1744
+ }
1745
+ return { keyShare: parsed.keyShare, sharedPublicKey: typeof parsed.sharedPublicKey === "string" ? parsed.sharedPublicKey : void 0 };
1746
+ }
1747
+ async function restoreUserBackupShare(ciphertext, recoveryCode) {
1748
+ let blob;
1749
+ try {
1750
+ blob = await openWithPassword(recoveryCode, ciphertext);
1751
+ } catch (cause) {
1752
+ throw new WaaskeyError("Could not open the user_backup backup \u2014 wrong recovery code?", "invalid_recovery_code", { cause });
1753
+ }
1754
+ return deserializeShare(blob).keyShare;
1755
+ }
1756
+ function toUserBackupSignParams(session, share, digest) {
1757
+ if (session.participants.length !== 2 || session.signerPosition < 0 || session.signerPosition > 1) {
1758
+ throw new WaaskeyError(
1759
+ `Unexpected recover-sign descriptor: the {server, user_backup} quorum must be exactly 2 parties with signerPosition in {0, 1} (got ${session.participants.length} participants, signerPosition ${session.signerPosition}).`,
1760
+ "sign_failed",
1761
+ { details: { participants: session.participants, signerPosition: session.signerPosition } }
1762
+ );
1763
+ }
1764
+ return {
1765
+ curve: toMpcCurve(session.curve),
1766
+ relayUrl: session.relayUrl,
1767
+ sessionId: session.sessionId,
1768
+ role: session.role,
1769
+ peerRole: session.peerRole,
1770
+ partyIndex: session.signerPosition,
1771
+ peerPartyIndex: 1 - session.signerPosition,
1772
+ relayToken: session.relayToken,
1773
+ share,
1774
+ participants: session.participants,
1775
+ signerPosition: session.signerPosition,
1776
+ digest
1777
+ };
1778
+ }
1779
+ function memberShareKey(walletId, membershipId) {
1780
+ return `${walletId}@member-${membershipId}`;
1781
+ }
1782
+ function userBackupPendingKey(walletId) {
1783
+ return `${walletId}:user_backup_pending`;
1784
+ }
1785
+ function parseBackupPayload(stored, walletId) {
1786
+ let parsed;
1787
+ try {
1788
+ parsed = JSON.parse(stored);
1789
+ } catch (cause) {
1790
+ throw new WaaskeyError(`The pending user_backup backup for wallet "${walletId}" is corrupt \u2014 it is not valid JSON.`, "backup_failed", { cause, details: { walletId } });
1791
+ }
1792
+ if (typeof parsed.ciphertext !== "string" || !Array.isArray(parsed.factors)) {
1793
+ throw new WaaskeyError(`The pending user_backup backup for wallet "${walletId}" is malformed.`, "backup_failed", { details: { walletId, keys: Object.keys(parsed) } });
1794
+ }
1795
+ return { ciphertext: parsed.ciphertext, factors: parsed.factors };
1796
+ }
1797
+ function membershipIdFromRole(role) {
1798
+ const prefix = "member:";
1799
+ if (!role.startsWith(prefix)) {
1800
+ throw new WaaskeyError(`Unexpected relay role "${role}" \u2014 expected a member-bound "member:<membershipId>" role.`, "validation");
1801
+ }
1802
+ return role.slice(prefix.length);
1803
+ }
1804
+ function buildMemberRoster(shareholders, parties) {
1805
+ const roles = new Array(parties).fill(void 0);
1806
+ for (const shareholder of shareholders) {
1807
+ if (shareholder.partyIndex < 0 || shareholder.partyIndex >= parties) {
1808
+ throw new WaaskeyError(`Share-holder partyIndex ${shareholder.partyIndex} is out of range for a ${parties}-party ceremony.`, "validation");
1809
+ }
1810
+ roles[shareholder.partyIndex] = shareholder.role;
1811
+ }
1812
+ const gaps = roles.reduce((acc, role, index) => role === void 0 ? [...acc, index] : acc, []);
1813
+ if (gaps.length !== 1) {
1814
+ throw new WaaskeyError("Could not derive the platform party slot from the wallet share-holders.", "validation", { details: { parties, shareholders } });
1815
+ }
1816
+ roles[gaps[0]] = "platform";
1817
+ return roles;
1818
+ }
1819
+ function buildEddsaKeygenRoster(ceremony) {
1820
+ const slots = Math.max(ceremony.partyIndex, ceremony.peerPartyIndex) + 1;
1821
+ const roles = new Array(slots).fill(void 0);
1822
+ roles[ceremony.partyIndex] = ceremony.role;
1823
+ roles[ceremony.peerPartyIndex] = ceremony.peerRole;
1824
+ if (roles.some((role) => role === void 0)) {
1825
+ throw new WaaskeyError("The ed25519 keygen ceremony did not fully describe its party roster (only the 2-party device+server topology is supported).", "keygen_failed", {
1826
+ details: { ceremony }
1827
+ });
1828
+ }
1829
+ return roles;
1830
+ }
1831
+ function readEddsaEncRoster(ceremony, encKeypair) {
1832
+ const roster = ceremony.encPubkeys;
1833
+ if (!Array.isArray(roster) || roster.length === 0) {
1834
+ throw new WaaskeyError(
1835
+ "The ed25519 keygen ceremony did not return the FROST DKG round-2 encryption roster (encPubkeys) \u2014 the backend must advertise it (#114) before the device can seal its DKG packages.",
1836
+ "keygen_failed",
1837
+ { details: { ceremony } }
1838
+ );
1839
+ }
1840
+ const own = roster[ceremony.partyIndex];
1841
+ if (typeof own !== "string" || own.toLowerCase() !== encKeypair.publicHex.toLowerCase()) {
1842
+ throw new WaaskeyError(
1843
+ "The ed25519 keygen ceremony's encPubkeys roster does not carry this device's own encryption public key at its party index \u2014 refusing to keygen against a mismatched roster (it would mis-seal the DKG packages).",
1844
+ "keygen_failed",
1845
+ { details: { partyIndex: ceremony.partyIndex } }
1846
+ );
1847
+ }
1848
+ return roster;
1849
+ }
1850
+ function isReadyMemberSignCeremony(ceremony) {
1851
+ return ceremony.ready && ceremony.quorumRoles !== void 0 && ceremony.signerPosition !== void 0 && ceremony.participants !== void 0 && ceremony.digest !== void 0;
1852
+ }
84
1853
 
85
1854
  // src/client.ts
86
1855
  var DEFAULT_BASE_URL = "https://api.waaskey.com";
87
1856
  var Waaskey = class {
88
1857
  /** The `wallets` resource. */
89
1858
  wallets;
1859
+ /** The `recovery` resource — multi-factor, client-encrypted wallet recovery. */
1860
+ recovery;
1861
+ /** The `reshare` resource — device-side completion of a device-retaining reshare (#318). */
1862
+ reshare;
1863
+ /** The `balances` resource — client-side balance reads from a chain provider (no backend). */
1864
+ balances;
1865
+ /** The `auth` resource — embedded end-user login (email-OTP, …) → non-custodial wallet. */
1866
+ auth;
1867
+ /** The `members` resource — org-member (dashboard "plane B") bearer login for headless consumers. */
1868
+ members;
1869
+ /** The `onramp` resource — fund the wallet with fiat via a provider on-ramp. */
1870
+ onramp;
1871
+ /** Default fetch used by the optional {@link broadcast} helper (from `WaaskeyOptions.fetch`). */
1872
+ defaultFetch;
90
1873
  constructor(options) {
91
1874
  if (!options?.apiKey) {
92
1875
  throw new Error("Waaskey: `apiKey` is required.");
93
1876
  }
94
1877
  const http = new HttpClient(options.apiKey, options.baseUrl ?? DEFAULT_BASE_URL, options.fetch);
95
- this.wallets = new Wallets(http);
1878
+ const analytics = new Analytics(resolveSink(options.analytics, http));
1879
+ this.auth = new Auth(http);
1880
+ this.members = new Members(http);
1881
+ http.useMemberAccessToken(() => this.members.accessToken);
1882
+ this.wallets = new Wallets(http, { mpc: options.mpc, shareStore: options.shareStore, primePool: options.primePool, analytics });
1883
+ this.recovery = new Recovery(http, { shareStore: options.shareStore, analytics });
1884
+ this.reshare = new Reshare(http, { mpc: options.mpc, shareStore: options.shareStore, analytics });
1885
+ this.balances = new Balances(options.chains, options.fetch);
1886
+ this.onramp = new Onramp(http);
1887
+ this.defaultFetch = options.fetch;
1888
+ }
1889
+ /**
1890
+ * **Optional** client-side broadcast of a signed raw tx from {@link Wallet.send}.
1891
+ *
1892
+ * WaaS is a **signing service** — `wallet.send(...)` returns `signedTx` and WaaS never
1893
+ * submits it. This is a thin, best-effort convenience to broadcast from **your own**
1894
+ * node/provider (one JSON-RPC call, no status polling / no retries — tracking is your
1895
+ * concern). Most integrators broadcast with their own infra.
1896
+ *
1897
+ * @example
1898
+ * ```ts
1899
+ * const { signedTx } = await wallet.send({ chainId: 'evm:1', to, value });
1900
+ * const { txHash } = await waaskey.broadcast(signedTx, { rpcUrl: 'https://your-rpc' });
1901
+ * ```
1902
+ */
1903
+ broadcast(signedTx, options) {
1904
+ return broadcast(signedTx, { fetch: this.defaultFetch, ...options });
1905
+ }
1906
+ };
1907
+ function resolveSink(analytics, http) {
1908
+ if (analytics === false) return void 0;
1909
+ return analytics ?? new HttpAnalyticsSink(http);
1910
+ }
1911
+
1912
+ // src/mpc/wasm-core.ts
1913
+ var WasmMpcCore = class {
1914
+ constructor(load) {
1915
+ this.load = load;
1916
+ }
1917
+ load;
1918
+ modulePromise;
1919
+ init() {
1920
+ return this.modulePromise ??= this.load();
1921
+ }
1922
+ async runKeygen(params) {
1923
+ const wasm = await this.init();
1924
+ const raw = await wasm.keygen(
1925
+ this.encode(params, { parties: params.parties, threshold: params.threshold, pregenerated_primes_json: params.pregeneratedPrimes })
1926
+ );
1927
+ if (!raw || typeof raw.keyshare_json !== "string") {
1928
+ throw new Error("client-wasm returned an unexpected keygen result");
1929
+ }
1930
+ return {
1931
+ keyShare: raw.keyshare_json,
1932
+ auxInfo: raw.aux_info_json,
1933
+ sharedPublicKey: extractSharedPublicKey(raw.keyshare_json)
1934
+ };
1935
+ }
1936
+ async runSign(params) {
1937
+ const wasm = await this.init();
1938
+ const raw = await wasm.sign(
1939
+ this.encode(params, {
1940
+ // The wasm deserializes `share` with `serde_json::from_value::<KeyShare>`, so it must ride
1941
+ // the wire as the KeyShare OBJECT — not a JSON string, and not the storage blob.
1942
+ share: toShareObject(params.share),
1943
+ participants: params.participants,
1944
+ signer_position: params.signerPosition,
1945
+ digest: params.digest
1946
+ })
1947
+ );
1948
+ if (!raw || typeof raw.signature_json !== "string") {
1949
+ throw new Error("client-wasm returned an unexpected sign result");
1950
+ }
1951
+ return { signature: raw.signature_json };
1952
+ }
1953
+ async pregeneratePrimes(curve) {
1954
+ const wasm = await this.init();
1955
+ if (!wasm.pregeneratePrimes) {
1956
+ throw new Error("client-wasm does not expose pregeneratePrimes");
1957
+ }
1958
+ const raw = await wasm.pregeneratePrimes(JSON.stringify({ curve }));
1959
+ if (!raw || typeof raw.primes_json !== "string") {
1960
+ throw new Error("client-wasm returned an unexpected pregeneratePrimes result");
1961
+ }
1962
+ return raw.primes_json;
1963
+ }
1964
+ async runReshareAssemble(params) {
1965
+ const wasm = await this.init();
1966
+ if (!wasm.reshareAssemble) {
1967
+ throw new Error(reshareFeatureHint("reshareAssemble"));
1968
+ }
1969
+ const raw = await wasm.reshareAssemble(
1970
+ JSON.stringify({
1971
+ curve: params.curve,
1972
+ new_position: params.newPosition,
1973
+ new_preimages: params.newPreimages,
1974
+ new_threshold: params.newThreshold,
1975
+ wallet: params.wallet,
1976
+ commitments: params.commitments,
1977
+ sub_shares: params.subShares
1978
+ })
1979
+ );
1980
+ if (!raw || typeof raw.core_json !== "string") {
1981
+ throw new Error("client-wasm returned an unexpected reshareAssemble result");
1982
+ }
1983
+ return { core: raw.core_json, sharedPublicKey: decodePublicKey(raw.shared_public_key_json) };
1984
+ }
1985
+ async runCompleteReshare(params) {
1986
+ const wasm = await this.init();
1987
+ if (!wasm.completeReshare) {
1988
+ throw new Error(reshareFeatureHint("completeReshare"));
1989
+ }
1990
+ const raw = await wasm.completeReshare(
1991
+ this.encode(params, {
1992
+ // The wasm expects the bare core as an inline JSON object (serde_json::Value), not a string.
1993
+ core: JSON.parse(params.core),
1994
+ parties: params.parties,
1995
+ // The device's OWN primes for the aux ceremony — never server-provided. Absent ⇒ generated inline.
1996
+ ...params.pregeneratedPrimes === void 0 ? {} : { pregenerated_primes_json: params.pregeneratedPrimes }
1997
+ })
1998
+ );
1999
+ if (!raw || typeof raw.keyshare_json !== "string") {
2000
+ throw new Error("client-wasm returned an unexpected completeReshare result");
2001
+ }
2002
+ return { keyShare: raw.keyshare_json, sharedPublicKey: extractSharedPublicKey(raw.keyshare_json) };
2003
+ }
2004
+ async runMemberKeygen(params) {
2005
+ const wasm = await this.init();
2006
+ if (!wasm.keygenMember) {
2007
+ throw new Error(memberCeremonyFeatureHint("keygenMember"));
2008
+ }
2009
+ const raw = await wasm.keygenMember(
2010
+ this.encodeMember(params, {
2011
+ roles: params.roles,
2012
+ party_index: params.partyIndex,
2013
+ threshold: params.threshold,
2014
+ ...params.pregeneratedPrimes === void 0 ? {} : { pregenerated_primes_json: params.pregeneratedPrimes }
2015
+ })
2016
+ );
2017
+ if (!raw || typeof raw.keyshare_json !== "string") {
2018
+ throw new Error("client-wasm returned an unexpected keygenMember result");
2019
+ }
2020
+ return {
2021
+ keyShare: raw.keyshare_json,
2022
+ auxInfo: raw.aux_info_json,
2023
+ sharedPublicKey: extractSharedPublicKey(raw.keyshare_json)
2024
+ };
2025
+ }
2026
+ async runMemberSign(params) {
2027
+ const wasm = await this.init();
2028
+ if (!wasm.signMember) {
2029
+ throw new Error(memberCeremonyFeatureHint("signMember"));
2030
+ }
2031
+ const raw = await wasm.signMember(
2032
+ this.encodeMember(params, {
2033
+ roles: params.roles,
2034
+ // Mirrors runSign: the wasm deserializes `share` with `serde_json::from_value::<KeyShare>`,
2035
+ // so the KeyShare rides the wire as an OBJECT, not a JSON string (and not the storage blob).
2036
+ share: toShareObject(params.share),
2037
+ participants: params.participants,
2038
+ // This device's protocol index into `roles` (the value the SDK calls `signerPosition`).
2039
+ // The wire key is `party_index` — the same field keygen sends — NOT `signer_position`.
2040
+ party_index: params.signerPosition,
2041
+ digest: params.digest
2042
+ })
2043
+ );
2044
+ if (!raw || typeof raw.signature_json !== "string") {
2045
+ throw new Error("client-wasm returned an unexpected signMember result");
2046
+ }
2047
+ return { signature: raw.signature_json };
2048
+ }
2049
+ async runEddsaKeygen(params) {
2050
+ const wasm = await this.init();
2051
+ if (!wasm.keygenEddsa) {
2052
+ throw new Error(eddsaFeatureHint("keygenEddsa"));
2053
+ }
2054
+ const raw = await wasm.keygenEddsa(
2055
+ this.encodeEddsa(params, {
2056
+ threshold: params.threshold,
2057
+ // The FROST DKG round-2 encryption roster (#114): every party's X25519 enc PUBLIC key in `roles`
2058
+ // order (`enc_pubkeys[i]` ↔ FROST id i+1) + THIS device's own X25519 enc SECRET. `enc_secret` is
2059
+ // secret and MUST NOT be logged; the wasm seals/opens the round-2 packages with these.
2060
+ enc_pubkeys: params.encPubkeys,
2061
+ enc_secret: params.encSecret
2062
+ })
2063
+ );
2064
+ if (!raw || raw.key_package === void 0 || raw.public_key_package === void 0) {
2065
+ throw new Error("client-wasm returned an unexpected keygenEddsa result");
2066
+ }
2067
+ return { keyPackage: raw.key_package, publicKeyPackage: raw.public_key_package };
2068
+ }
2069
+ async runEddsaSign(params) {
2070
+ const wasm = await this.init();
2071
+ if (!wasm.signEddsa) {
2072
+ throw new Error(eddsaFeatureHint("signEddsa"));
2073
+ }
2074
+ const raw = await wasm.signEddsa(
2075
+ this.encodeEddsa(params, {
2076
+ // The wasm re-serializes these with `serde_json::to_vec` + `import_*_package`, so each MUST
2077
+ // ride the wire as the FROST JSON OBJECT — not a JSON string (and not a storage wrapper).
2078
+ key_package: toFrostPackage(params.keyPackage, "key_package"),
2079
+ public_key_package: toFrostPackage(params.publicKeyPackage, "public_key_package"),
2080
+ participants: params.participants,
2081
+ message: params.message
2082
+ })
2083
+ );
2084
+ if (!raw || typeof raw.signature !== "string") {
2085
+ throw new Error("client-wasm returned an unexpected signEddsa result");
2086
+ }
2087
+ return { signature: raw.signature };
2088
+ }
2089
+ /** Build the snake_case params JSON the wasm exports expect from the common routing + extras. */
2090
+ encode(p, extra) {
2091
+ assertAllowedRelayUrl(p.relayUrl);
2092
+ return JSON.stringify({
2093
+ curve: p.curve,
2094
+ relay_url: p.relayUrl,
2095
+ session_id: p.sessionId,
2096
+ role: p.role ?? "device",
2097
+ peer_role: p.peerRole ?? "server",
2098
+ party_index: p.partyIndex,
2099
+ peer_party_index: p.peerPartyIndex,
2100
+ // Only present the relay token when set, so an auth-disabled ceremony's wire payload
2101
+ // is byte-identical to before (the client-wasm treats absent as an unauthenticated join).
2102
+ ...p.relayToken === void 0 ? {} : { relay_token: p.relayToken },
2103
+ ...extra
2104
+ });
2105
+ }
2106
+ /**
2107
+ * Build the snake_case params JSON for an n-party (member-bound, #349) member ceremony — the
2108
+ * roster-based routing shape (`roles` + `party_index`), not the 2-party `role`/`peer_role` shape
2109
+ * {@link encode} builds. The `roles` roster and `party_index` are passed through in `extra` by
2110
+ * the caller and land on the wire under those exact keys — what `keygenMember` / `signMember`
2111
+ * deserialize.
2112
+ */
2113
+ encodeMember(p, extra) {
2114
+ assertAllowedRelayUrl(p.relayUrl);
2115
+ return JSON.stringify({
2116
+ curve: p.curve,
2117
+ relay_url: p.relayUrl,
2118
+ session_id: p.sessionId,
2119
+ ...p.relayToken === void 0 ? {} : { relay_token: p.relayToken },
2120
+ ...extra
2121
+ });
2122
+ }
2123
+ /**
2124
+ * Build the snake_case params JSON for an ed25519 (FROST, #110) ceremony — the roster-based routing
2125
+ * shape (`roles` + `party_index`) the `keygenEddsa` / `signEddsa` exports deserialize. Unlike
2126
+ * {@link encodeMember} there is NO `curve` (ed25519-only) and no Paillier primes; the caller passes
2127
+ * the ceremony-specific extras (keygen: `threshold`; sign: the FROST packages + participants + message).
2128
+ */
2129
+ encodeEddsa(p, extra) {
2130
+ assertAllowedRelayUrl(p.relayUrl);
2131
+ return JSON.stringify({
2132
+ relay_url: p.relayUrl,
2133
+ session_id: p.sessionId,
2134
+ roles: p.roles,
2135
+ party_index: p.partyIndex,
2136
+ ...p.relayToken === void 0 ? {} : { relay_token: p.relayToken },
2137
+ ...extra
2138
+ });
2139
+ }
2140
+ };
2141
+ function reshareFeatureHint(fn) {
2142
+ return `client-wasm does not expose ${fn} \u2014 rebuild @waaskey/client-wasm with the \`reshare\` feature enabled (wasm-pack build \u2026 -- --features reshare).`;
2143
+ }
2144
+ function memberCeremonyFeatureHint(fn) {
2145
+ return `client-wasm does not expose ${fn} \u2014 rebuild @waaskey/client-wasm with n-party (member-ceremony) support (the keygenMember/signMember exports).`;
2146
+ }
2147
+ function eddsaFeatureHint(fn) {
2148
+ return `client-wasm does not expose ${fn} \u2014 rebuild @waaskey/client-wasm with the ed25519 (FROST) exports (keygenEddsa/signEddsa).`;
2149
+ }
2150
+ function assertAllowedRelayUrl(relayUrl) {
2151
+ let url;
2152
+ try {
2153
+ url = new URL(relayUrl);
2154
+ } catch (cause) {
2155
+ throw new Error(`Invalid relay URL "${relayUrl}".`, { cause });
2156
+ }
2157
+ if (url.protocol === "ws:" && isLoopbackHost(url.hostname)) return;
2158
+ if (url.protocol !== "wss:") {
2159
+ throw new Error(`Refusing server-supplied relay URL "${relayUrl}" \u2014 only wss:// (or ws:// on localhost) is allowed for the MPC transport.`);
2160
+ }
2161
+ if (isInternalHost(url.hostname)) {
2162
+ throw new Error(`Refusing server-supplied relay URL "${relayUrl}" \u2014 the relay host is internal/private; only a public wss:// endpoint is allowed.`);
2163
+ }
2164
+ }
2165
+ function isLoopbackHost(hostname) {
2166
+ const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
2167
+ return host === "localhost" || host === "::1" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
2168
+ }
2169
+ function isInternalHost(hostname) {
2170
+ const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
2171
+ if (isLoopbackHost(hostname)) return true;
2172
+ if (host.endsWith(".internal") || host.endsWith(".local")) return true;
2173
+ const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
2174
+ if (v4) {
2175
+ const [a, b] = [Number(v4[1]), Number(v4[2])];
2176
+ if (a === 10) return true;
2177
+ if (a === 172 && b >= 16 && b <= 31) return true;
2178
+ if (a === 192 && b === 168) return true;
2179
+ if (a === 169 && b === 254) return true;
2180
+ return false;
2181
+ }
2182
+ if (host.includes(":")) {
2183
+ if (/^f[cd][0-9a-f]{0,2}:/.test(host)) return true;
2184
+ if (/^fe[89ab][0-9a-f]:/.test(host)) return true;
2185
+ }
2186
+ return false;
2187
+ }
2188
+ function toShareObject(share) {
2189
+ const value = typeof share === "string" ? JSON.parse(share) : share;
2190
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
2191
+ throw new Error("KeyShare must be a JSON object (or its JSON string), not a scalar or array.");
2192
+ }
2193
+ return value;
2194
+ }
2195
+ function toFrostPackage(pkg, which) {
2196
+ const value = typeof pkg === "string" ? JSON.parse(pkg) : pkg;
2197
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
2198
+ throw new Error(`FROST ${which} must be a JSON object (or its JSON string), not a scalar or array.`);
2199
+ }
2200
+ return value;
2201
+ }
2202
+ function extractSharedPublicKey(keyShareJson) {
2203
+ const parsed = JSON.parse(keyShareJson);
2204
+ const pk = parsed.core?.shared_public_key;
2205
+ if (typeof pk !== "string") {
2206
+ throw new Error("KeyShare is missing core.shared_public_key");
2207
+ }
2208
+ return pk;
2209
+ }
2210
+ function decodePublicKey(sharedPublicKeyJson) {
2211
+ const pk = JSON.parse(sharedPublicKeyJson);
2212
+ if (typeof pk !== "string") {
2213
+ throw new Error("reshare assemble result is missing a shared_public_key");
2214
+ }
2215
+ return pk;
2216
+ }
2217
+
2218
+ // src/mpc/load-wasm.ts
2219
+ var CLIENT_WASM_PACKAGE = "@waaskey/client-wasm";
2220
+ var CLIENT_WASM_VERSION = "0.2.1";
2221
+ async function verifyWasmIntegrity(bytes, expectedSha384) {
2222
+ if (!expectedSha384 || !expectedSha384.startsWith("sha384-")) {
2223
+ throw new Error("Waaskey: an expected SHA-384 integrity hash (sha384-<base64>) is required to load the wasm MPC core.");
2224
+ }
2225
+ const digest = await crypto.subtle.digest("SHA-384", bytes);
2226
+ const actual = `sha384-${toBase64(new Uint8Array(digest))}`;
2227
+ if (!timingSafeEqual(actual, expectedSha384)) {
2228
+ throw new Error(`Waaskey: client-wasm integrity check FAILED \u2014 refusing to instantiate the MPC core (expected ${expectedSha384}, got ${actual}).`);
2229
+ }
2230
+ }
2231
+ function createVerifiedClientWasmLoader(options) {
2232
+ return async () => {
2233
+ const fetchImpl = options.fetch ?? globalThis.fetch;
2234
+ if (!fetchImpl) {
2235
+ throw new Error("Waaskey: no fetch implementation available to load the wasm MPC core \u2014 pass `fetch` in the loader options.");
2236
+ }
2237
+ let bytes;
2238
+ try {
2239
+ const res = await fetchImpl(options.wasmUrl instanceof URL ? options.wasmUrl.href : options.wasmUrl);
2240
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
2241
+ bytes = await res.arrayBuffer();
2242
+ } catch (cause) {
2243
+ throw new Error(`Waaskey: failed to fetch the client-wasm binary from ${String(options.wasmUrl)}.`, { cause });
2244
+ }
2245
+ await verifyWasmIntegrity(bytes, options.expectedSha384);
2246
+ const compiled = await WebAssembly.compile(bytes);
2247
+ let mod;
2248
+ try {
2249
+ mod = await import(CLIENT_WASM_PACKAGE);
2250
+ } catch (cause) {
2251
+ throw new Error("Waaskey: creating a wallet needs the wasm engine \u2014 install '@waaskey/client-wasm' alongside '@waaskey/sdk'.", { cause });
2252
+ }
2253
+ const init = mod["default"] ?? mod["init"];
2254
+ const initSync = mod["initSync"];
2255
+ if (typeof init === "function") {
2256
+ await init(compiled);
2257
+ } else if (typeof initSync === "function") {
2258
+ initSync(compiled);
2259
+ }
2260
+ return mod;
2261
+ };
2262
+ }
2263
+ var loadClientWasm = async () => {
2264
+ try {
2265
+ const mod = await import(CLIENT_WASM_PACKAGE);
2266
+ return mod;
2267
+ } catch (cause) {
2268
+ throw new Error("Waaskey: creating a wallet needs the wasm engine \u2014 install '@waaskey/client-wasm' alongside '@waaskey/sdk'.", { cause });
2269
+ }
2270
+ };
2271
+ function toBase64(bytes) {
2272
+ let binary = "";
2273
+ for (const byte of bytes) binary += String.fromCharCode(byte);
2274
+ return btoa(binary);
2275
+ }
2276
+ function timingSafeEqual(a, b) {
2277
+ if (a.length !== b.length) return false;
2278
+ let diff = 0;
2279
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
2280
+ return diff === 0;
2281
+ }
2282
+
2283
+ // src/mpc/prime-pool.ts
2284
+ var MemoryPrimeStore = class {
2285
+ pools = /* @__PURE__ */ new Map();
2286
+ async take(curve) {
2287
+ return this.pools.get(curve)?.shift();
2288
+ }
2289
+ async add(curve, primes) {
2290
+ const pool = this.pools.get(curve) ?? [];
2291
+ pool.push(primes);
2292
+ this.pools.set(curve, pool);
2293
+ }
2294
+ async size(curve) {
2295
+ return this.pools.get(curve)?.length ?? 0;
2296
+ }
2297
+ };
2298
+ var PrimePool = class {
2299
+ constructor(core, options = {}) {
2300
+ this.core = core;
2301
+ this.store = options.store ?? new MemoryPrimeStore();
2302
+ this.targetSize = Math.max(1, options.targetSize ?? 2);
2303
+ }
2304
+ core;
2305
+ store;
2306
+ targetSize;
2307
+ /** Per-curve in-flight refill, so concurrent calls don't over-generate. */
2308
+ refilling = /* @__PURE__ */ new Map();
2309
+ /**
2310
+ * Top up the pool to the target size — call this OFF the hot path (onboarding/idle, ideally a
2311
+ * Web Worker). Deduped per curve, so calling it repeatedly is safe and cheap.
2312
+ */
2313
+ ensure(curve) {
2314
+ const inflight = this.refilling.get(curve);
2315
+ if (inflight) return inflight;
2316
+ const task = this.refill(curve).finally(() => this.refilling.delete(curve));
2317
+ this.refilling.set(curve, task);
2318
+ return task;
2319
+ }
2320
+ async refill(curve) {
2321
+ while (await this.store.size(curve) < this.targetSize) {
2322
+ await this.store.add(curve, await this.core.pregeneratePrimes(curve));
2323
+ }
2324
+ }
2325
+ /**
2326
+ * Claim a prime for a keygen. Returns a cached one instantly when the pool is warm; otherwise
2327
+ * generates one inline (the slow fallback) so keygen never fails on an empty pool. Either way it
2328
+ * kicks off a background refill so the next wallet is instant.
2329
+ */
2330
+ async take(curve) {
2331
+ const cached = await this.store.take(curve);
2332
+ const primes = cached ?? await this.core.pregeneratePrimes(curve);
2333
+ void this.ensure(curve).catch(() => void 0);
2334
+ return primes;
2335
+ }
2336
+ };
2337
+
2338
+ // src/storage/indexeddb-store.ts
2339
+ var IndexedDbKeyValueStore = class {
2340
+ constructor(dbName = "waaskey", storeName = "shares") {
2341
+ this.dbName = dbName;
2342
+ this.storeName = storeName;
2343
+ }
2344
+ dbName;
2345
+ storeName;
2346
+ dbPromise;
2347
+ db() {
2348
+ return this.dbPromise ??= new Promise((resolve, reject) => {
2349
+ const idb = globalThis.indexedDB;
2350
+ if (!idb) {
2351
+ reject(new Error("IndexedDB is unavailable \u2014 pass a custom KeyValueStore (e.g. MemoryKeyValueStore) in this runtime."));
2352
+ return;
2353
+ }
2354
+ const request = idb.open(this.dbName, 1);
2355
+ request.onupgradeneeded = () => {
2356
+ if (!request.result.objectStoreNames.contains(this.storeName)) {
2357
+ request.result.createObjectStore(this.storeName);
2358
+ }
2359
+ };
2360
+ request.onsuccess = () => resolve(request.result);
2361
+ request.onerror = () => reject(request.error ?? new Error("Failed to open IndexedDB."));
2362
+ });
2363
+ }
2364
+ async run(mode, op) {
2365
+ const db = await this.db();
2366
+ return new Promise((resolve, reject) => {
2367
+ const tx = db.transaction(this.storeName, mode);
2368
+ const request = op(tx.objectStore(this.storeName));
2369
+ request.onsuccess = () => resolve(request.result);
2370
+ request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed."));
2371
+ });
2372
+ }
2373
+ async get(key) {
2374
+ const value = await this.run("readonly", (store) => store.get(key));
2375
+ return typeof value === "string" ? value : null;
2376
+ }
2377
+ async set(key, value) {
2378
+ await this.run("readwrite", (store) => store.put(value, key));
2379
+ }
2380
+ async delete(key) {
2381
+ await this.run("readwrite", (store) => store.delete(key));
2382
+ }
2383
+ async keys() {
2384
+ const keys = await this.run("readonly", (store) => store.getAllKeys());
2385
+ return keys.filter((key) => typeof key === "string");
2386
+ }
2387
+ async clear() {
2388
+ await this.run("readwrite", (store) => store.clear());
2389
+ }
2390
+ };
2391
+
2392
+ // src/storage/encrypted-share-store.ts
2393
+ var SHARE_PREFIX = "share:";
2394
+ var SALT_KEY = "meta:salt";
2395
+ var MIN_SECRET_LENGTH = 16;
2396
+ var bytesToBase642 = (bytes) => {
2397
+ let binary = "";
2398
+ for (const byte of bytes) binary += String.fromCharCode(byte);
2399
+ return btoa(binary);
2400
+ };
2401
+ var base64ToBytes2 = (base64) => {
2402
+ const binary = atob(base64);
2403
+ const bytes = new Uint8Array(binary.length);
2404
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
2405
+ return bytes;
2406
+ };
2407
+ var EncryptedShareStore = class _EncryptedShareStore {
2408
+ constructor(kv, secret, options = {}) {
2409
+ this.kv = kv;
2410
+ this.secret = secret;
2411
+ if (!secret) throw new Error("EncryptedShareStore: a non-empty `secret` is required.");
2412
+ if (!options.allowWeakSecret && secret.length < MIN_SECRET_LENGTH) {
2413
+ throw new Error(
2414
+ `EncryptedShareStore: the sealing secret is too weak (< ${MIN_SECRET_LENGTH} chars) and is brute-forceable offline. Use the 32-byte passkey-PRF secret (PasskeyPrfSecretProvider), or pass { allowWeakSecret: true } only when entropy is guaranteed elsewhere.`
2415
+ );
2416
+ }
2417
+ }
2418
+ kv;
2419
+ secret;
2420
+ keyPromise;
2421
+ /** Default web store: AES-GCM sealing over IndexedDB. */
2422
+ static browser(secret, options) {
2423
+ return new _EncryptedShareStore(new IndexedDbKeyValueStore(options?.dbName, options?.storeName), secret, { allowWeakSecret: options?.allowWeakSecret });
2424
+ }
2425
+ async put(walletId, share) {
2426
+ const key = await this.key();
2427
+ await this.kv.set(SHARE_PREFIX + walletId, await seal(key, share));
2428
+ }
2429
+ async get(walletId) {
2430
+ const record = await this.kv.get(SHARE_PREFIX + walletId);
2431
+ if (record === null) return null;
2432
+ return open(await this.key(), record);
2433
+ }
2434
+ async has(walletId) {
2435
+ return await this.kv.get(SHARE_PREFIX + walletId) !== null;
2436
+ }
2437
+ async remove(walletId) {
2438
+ await this.kv.delete(SHARE_PREFIX + walletId);
2439
+ }
2440
+ async clear() {
2441
+ this.keyPromise = void 0;
2442
+ await this.kv.clear();
2443
+ }
2444
+ /** Lazily load (or create) the persisted salt and derive the AES key once. */
2445
+ key() {
2446
+ return this.keyPromise ??= this.loadKey();
2447
+ }
2448
+ async loadKey() {
2449
+ const stored = await this.kv.get(SALT_KEY);
2450
+ let salt;
2451
+ if (stored) {
2452
+ salt = base64ToBytes2(stored);
2453
+ } else {
2454
+ salt = freshSalt();
2455
+ await this.kv.set(SALT_KEY, bytesToBase642(salt));
2456
+ }
2457
+ return deriveKey(this.secret, salt);
2458
+ }
2459
+ };
2460
+
2461
+ // src/storage/memory-store.ts
2462
+ var MemoryKeyValueStore = class {
2463
+ map = /* @__PURE__ */ new Map();
2464
+ async get(key) {
2465
+ return this.map.get(key) ?? null;
2466
+ }
2467
+ async set(key, value) {
2468
+ this.map.set(key, value);
2469
+ }
2470
+ async delete(key) {
2471
+ this.map.delete(key);
2472
+ }
2473
+ async keys() {
2474
+ return [...this.map.keys()];
2475
+ }
2476
+ async clear() {
2477
+ this.map.clear();
2478
+ }
2479
+ };
2480
+
2481
+ // src/passkey/prf.ts
2482
+ var LEGACY_PRF_SALT_STRING = "waaskey-share-seal-v1";
2483
+ function legacyPrfSalt() {
2484
+ return new TextEncoder().encode(LEGACY_PRF_SALT_STRING).buffer;
2485
+ }
2486
+ function randomPrfSalt() {
2487
+ return crypto.getRandomValues(new Uint8Array(32)).buffer;
2488
+ }
2489
+ function saltToBase64(salt) {
2490
+ let binary = "";
2491
+ for (const byte of new Uint8Array(salt)) binary += String.fromCharCode(byte);
2492
+ return btoa(binary);
2493
+ }
2494
+ function saltFromBase64(base64) {
2495
+ const binary = atob(base64);
2496
+ const bytes = new Uint8Array(binary.length);
2497
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
2498
+ return bytes.buffer;
2499
+ }
2500
+ function isPasskeySupported() {
2501
+ return typeof globalThis.navigator !== "undefined" && typeof globalThis.navigator.credentials !== "undefined";
2502
+ }
2503
+ async function isPrfSupported() {
2504
+ if (!isPasskeySupported()) return false;
2505
+ try {
2506
+ const cred = globalThis.PublicKeyCredential;
2507
+ if (typeof cred?.isConditionalMediationAvailable === "function") {
2508
+ return cred.isConditionalMediationAvailable();
2509
+ }
2510
+ } catch {
2511
+ }
2512
+ return isPasskeySupported();
2513
+ }
2514
+ async function loadSimpleWebAuthn() {
2515
+ try {
2516
+ return await import('@simplewebauthn/browser');
2517
+ } catch {
2518
+ throw new WaaskeyError('Passkey PRF requires "@simplewebauthn/browser" installed, or pass a custom ceremony.', "unsupported");
2519
+ }
2520
+ }
2521
+ async function defaultPrfCeremony() {
2522
+ const mod = await loadSimpleWebAuthn();
2523
+ return {
2524
+ async register(options) {
2525
+ const response = await mod.startRegistration({ optionsJSON: options });
2526
+ const outputs = response.clientExtensionResults;
2527
+ const prfResult = outputs.prf?.results?.first ?? null;
2528
+ return { credentialId: response.id, prfResult };
2529
+ },
2530
+ async authenticate(_credentialId, options) {
2531
+ const response = await mod.startAuthentication({ optionsJSON: options });
2532
+ const outputs = response.clientExtensionResults;
2533
+ const prfResult = outputs.prf?.results?.first ?? null;
2534
+ return { prfResult };
2535
+ }
2536
+ };
2537
+ }
2538
+ var PasskeyPrfSecretProvider = class {
2539
+ /**
2540
+ * Register a new passkey that supports PRF and derive the initial secret from it.
2541
+ *
2542
+ * @returns `{ credentialId, secret }` — persist `credentialId`; use `secret`
2543
+ * to construct `EncryptedShareStore.browser(secret)` for this session only.
2544
+ *
2545
+ * @throws `WaaskeyError('unsupported')` when PRF isn't available in this runtime.
2546
+ * @throws `WaaskeyError('aborted')` when the user cancels the authenticator dialog.
2547
+ */
2548
+ async enroll(opts = {}) {
2549
+ const ceremony = opts.ceremony ?? await defaultPrfCeremony();
2550
+ const salt = randomPrfSalt();
2551
+ const rpId = opts.rpId ?? (typeof location === "undefined" ? void 0 : location.hostname);
2552
+ const creationOptions = {
2553
+ rp: { name: opts.rpName ?? "Waaskey", ...rpId ? { id: rpId } : {} },
2554
+ user: {
2555
+ id: btoa(String.fromCharCode(...new Uint8Array(crypto.getRandomValues(new Uint8Array(16))))),
2556
+ name: opts.userName ?? "user",
2557
+ displayName: opts.userName ?? "User"
2558
+ },
2559
+ challenge: btoa(String.fromCharCode(...new Uint8Array(crypto.getRandomValues(new Uint8Array(32))))),
2560
+ pubKeyCredParams: [
2561
+ { type: "public-key", alg: -7 },
2562
+ // ES256 (secp256r1)
2563
+ { type: "public-key", alg: -257 }
2564
+ // RS256
2565
+ ],
2566
+ authenticatorSelection: { userVerification: "required", residentKey: "required" },
2567
+ extensions: { prf: { eval: { first: salt } } }
2568
+ };
2569
+ let credentialId;
2570
+ let prfResult;
2571
+ try {
2572
+ ({ credentialId, prfResult } = await ceremony.register(creationOptions));
2573
+ } catch (cause) {
2574
+ if (cause instanceof WaaskeyError) throw cause;
2575
+ const msg = cause instanceof Error ? cause.message : String(cause);
2576
+ if (/cancel|abort|not allowed|user gesture/i.test(msg)) {
2577
+ throw new WaaskeyError("Passkey registration was cancelled by the user.", "aborted", { cause });
2578
+ }
2579
+ throw new WaaskeyError("Passkey registration failed.", "unsupported", { cause });
2580
+ }
2581
+ if (prfResult === null) {
2582
+ throw new WaaskeyError("This authenticator does not support the PRF extension. Use a password or device-secret share instead.", "unsupported");
2583
+ }
2584
+ return { credentialId, salt: saltToBase64(salt), secret: prfOutputToSecret(prfResult) };
2585
+ }
2586
+ /**
2587
+ * Authenticate with an existing passkey and re-derive the same stable secret.
2588
+ *
2589
+ * @param credentialId — the id returned by `enroll()`.
2590
+ * @param opts.salt — the per-user salt `enroll()` returned (issue #41). Pass it to
2591
+ * re-derive the same secret; omit only for legacy credentials enrolled before
2592
+ * per-user salts (falls back to the constant salt).
2593
+ * @returns `{ credentialId, salt, secret }` — reconstruct `EncryptedShareStore.browser(secret)`.
2594
+ *
2595
+ * @throws `WaaskeyError('unsupported')` when PRF isn't available.
2596
+ * @throws `WaaskeyError('aborted')` when the user cancels.
2597
+ */
2598
+ async unlock(credentialId, opts = {}) {
2599
+ const ceremony = opts.ceremony ?? await defaultPrfCeremony();
2600
+ const salt = opts.salt ? saltFromBase64(opts.salt) : legacyPrfSalt();
2601
+ const rpId = opts.rpId ?? (typeof location === "undefined" ? void 0 : location.hostname);
2602
+ const requestOptions = {
2603
+ challenge: btoa(String.fromCharCode(...new Uint8Array(crypto.getRandomValues(new Uint8Array(32))))),
2604
+ allowCredentials: [{ id: credentialId, type: "public-key" }],
2605
+ userVerification: "required",
2606
+ ...rpId ? { rpId } : {},
2607
+ extensions: { prf: { eval: { first: salt } } }
2608
+ };
2609
+ let prfResult;
2610
+ try {
2611
+ ({ prfResult } = await ceremony.authenticate(credentialId, requestOptions));
2612
+ } catch (cause) {
2613
+ if (cause instanceof WaaskeyError) throw cause;
2614
+ const msg = cause instanceof Error ? cause.message : String(cause);
2615
+ if (/cancel|abort|not allowed|user gesture/i.test(msg)) {
2616
+ throw new WaaskeyError("Passkey authentication was cancelled by the user.", "aborted", { cause });
2617
+ }
2618
+ throw new WaaskeyError("Passkey authentication failed.", "unsupported", { cause });
2619
+ }
2620
+ if (prfResult === null) {
2621
+ throw new WaaskeyError("Passkey PRF output was not returned \u2014 authenticator may not support PRF or the credential was not created with PRF enabled.", "unsupported");
2622
+ }
2623
+ return { credentialId, salt: saltToBase64(salt), secret: prfOutputToSecret(prfResult) };
96
2624
  }
97
2625
  };
2626
+ function prfOutputToSecret(prfResult) {
2627
+ const bytes = new Uint8Array(prfResult);
2628
+ let binary = "";
2629
+ for (const byte of bytes) binary += String.fromCharCode(byte);
2630
+ return btoa(binary);
2631
+ }
98
2632
 
2633
+ exports.Analytics = Analytics;
2634
+ exports.Auth = Auth;
2635
+ exports.Balances = Balances;
2636
+ exports.CLIENT_WASM_VERSION = CLIENT_WASM_VERSION;
2637
+ exports.EncryptedShareStore = EncryptedShareStore;
2638
+ exports.EvmRpcProvider = EvmRpcProvider;
2639
+ exports.HttpAnalyticsSink = HttpAnalyticsSink;
2640
+ exports.IndexedDbKeyValueStore = IndexedDbKeyValueStore;
2641
+ exports.Members = Members;
2642
+ exports.MemoryKeyValueStore = MemoryKeyValueStore;
2643
+ exports.MemoryPrimeStore = MemoryPrimeStore;
2644
+ exports.Onramp = Onramp;
2645
+ exports.PasskeyPrfSecretProvider = PasskeyPrfSecretProvider;
2646
+ exports.PrimePool = PrimePool;
2647
+ exports.Recovery = Recovery;
2648
+ exports.Reshare = Reshare;
99
2649
  exports.Waaskey = Waaskey;
100
2650
  exports.WaaskeyError = WaaskeyError;
101
2651
  exports.Wallet = Wallet;
102
2652
  exports.Wallets = Wallets;
2653
+ exports.WasmMpcCore = WasmMpcCore;
2654
+ exports.broadcast = broadcast;
2655
+ exports.createVerifiedClientWasmLoader = createVerifiedClientWasmLoader;
2656
+ exports.epochShareKey = epochShareKey;
2657
+ exports.formatUnits = formatUnits;
2658
+ exports.generateRecoveryCode = generateRecoveryCode;
2659
+ exports.getSigningAssertion = getSigningAssertion;
2660
+ exports.isNonCustodial = isNonCustodial;
2661
+ exports.isPasskeyAssertionSupported = isPasskeyAssertionSupported;
2662
+ exports.isPasskeySupported = isPasskeySupported;
2663
+ exports.isPrfSupported = isPrfSupported;
2664
+ exports.loadClientWasm = loadClientWasm;
2665
+ exports.memberShareKey = memberShareKey;
2666
+ exports.userBackupPendingKey = userBackupPendingKey;
2667
+ exports.validateCustodyPolicy = validateCustodyPolicy;
2668
+ exports.verifyWasmIntegrity = verifyWasmIntegrity;
103
2669
  //# sourceMappingURL=index.cjs.map
104
2670
  //# sourceMappingURL=index.cjs.map