@swivy/web 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,1331 @@
|
|
|
1
|
+
import { browserSupportsWebAuthn, browserSupportsWebAuthnAutofill, platformAuthenticatorIsAvailable, startAuthentication, startRegistration, } from "@simplewebauthn/browser";
|
|
2
|
+
import { generateExportRecipientKeyPair, openSealedExport } from "@swivy/core";
|
|
3
|
+
import { createOfflineTransactionSigningEnvelope, createOfflineTransactionSigningResponseEnvelope, decodeOfflineSigningEnvelope, encodeOfflineSigningEnvelope, verifyOfflineSigningEnvelopeSignature, } from "@swivy/core";
|
|
4
|
+
import { clearDeviceShare, createBrowserSplitWalletMaterial, decodeRecoveryPackage, exportPrivateKeyFromShares, loadDeviceShare, parseServerShareRecord, signMessageLocallyWithShares, signTransactionLocallyWithShares, signTypedDataLocallyWithShares, storeDeviceShare, } from "./local-wallet.js";
|
|
5
|
+
export class SwivyWebAuthnError extends Error {
|
|
6
|
+
name = "SwivyWebAuthnError";
|
|
7
|
+
code;
|
|
8
|
+
operation;
|
|
9
|
+
retryable;
|
|
10
|
+
recoveryHint;
|
|
11
|
+
browserErrorName;
|
|
12
|
+
rawMessage;
|
|
13
|
+
cause;
|
|
14
|
+
constructor(params) {
|
|
15
|
+
super(params.userMessage);
|
|
16
|
+
this.code = params.code;
|
|
17
|
+
this.operation = params.operation;
|
|
18
|
+
this.retryable = params.retryable;
|
|
19
|
+
this.recoveryHint = params.recoveryHint;
|
|
20
|
+
this.browserErrorName = params.browserErrorName ?? null;
|
|
21
|
+
this.rawMessage = params.rawMessage ?? null;
|
|
22
|
+
this.cause = params.cause;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function isSwivyWebAuthnError(error) {
|
|
26
|
+
return error instanceof SwivyWebAuthnError;
|
|
27
|
+
}
|
|
28
|
+
function parseJsonResponseBody(response, text) {
|
|
29
|
+
if (!text) {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(text);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
37
|
+
if (response.ok) {
|
|
38
|
+
throw new Error(contentType ? `invalid_json_response:${contentType}` : "invalid_json_response");
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`http_${response.status}_non_json_response`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function resolveResponseErrorMessage(response, payload) {
|
|
44
|
+
if (typeof payload?.error === "string" && payload.error) {
|
|
45
|
+
return payload.error;
|
|
46
|
+
}
|
|
47
|
+
if (typeof payload?.error?.message === "string" && payload.error.message) {
|
|
48
|
+
return payload.error.message;
|
|
49
|
+
}
|
|
50
|
+
if (typeof payload?.message === "string" && payload.message) {
|
|
51
|
+
return payload.message;
|
|
52
|
+
}
|
|
53
|
+
return response.statusText || `http_${response.status}`;
|
|
54
|
+
}
|
|
55
|
+
function containsPattern(value, patterns) {
|
|
56
|
+
return patterns.some((pattern) => value.includes(pattern));
|
|
57
|
+
}
|
|
58
|
+
function normalizeWebAuthnOperationLabel(operation) {
|
|
59
|
+
switch (operation) {
|
|
60
|
+
case "passkey_registration":
|
|
61
|
+
return "passkey registration";
|
|
62
|
+
case "intent_approval":
|
|
63
|
+
return "passkey approval";
|
|
64
|
+
case "wallet_reconstruction":
|
|
65
|
+
return "wallet reconstruction approval";
|
|
66
|
+
case "account_export":
|
|
67
|
+
return "wallet export approval";
|
|
68
|
+
case "session_delegation":
|
|
69
|
+
return "delegated session approval";
|
|
70
|
+
case "recovery":
|
|
71
|
+
return "account recovery approval";
|
|
72
|
+
default:
|
|
73
|
+
return "passkey request";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function normalizeWebAuthnErrorCode(name, message) {
|
|
77
|
+
if (name === "securityerror"
|
|
78
|
+
|| containsPattern(message, [
|
|
79
|
+
"rp id",
|
|
80
|
+
"rpid",
|
|
81
|
+
"origin",
|
|
82
|
+
"same origin",
|
|
83
|
+
"registrable domain suffix",
|
|
84
|
+
])) {
|
|
85
|
+
return "webauthn_wrong_origin";
|
|
86
|
+
}
|
|
87
|
+
if (name === "invalidstateerror"
|
|
88
|
+
|| containsPattern(message, [
|
|
89
|
+
"already registered",
|
|
90
|
+
"already exists",
|
|
91
|
+
"credential already",
|
|
92
|
+
])) {
|
|
93
|
+
return "webauthn_credential_already_registered";
|
|
94
|
+
}
|
|
95
|
+
if (containsPattern(message, [
|
|
96
|
+
"challenge_expired",
|
|
97
|
+
"challenge expired",
|
|
98
|
+
"stale challenge",
|
|
99
|
+
"challenge already used",
|
|
100
|
+
"challenge consumed",
|
|
101
|
+
"challenge is no longer valid",
|
|
102
|
+
"expired challenge",
|
|
103
|
+
])) {
|
|
104
|
+
return "webauthn_stale_challenge";
|
|
105
|
+
}
|
|
106
|
+
if (containsPattern(message, [
|
|
107
|
+
"no credential",
|
|
108
|
+
"credential not found",
|
|
109
|
+
"credential is not recognized",
|
|
110
|
+
"credential not recognized",
|
|
111
|
+
"unknown credential",
|
|
112
|
+
"no passkeys available",
|
|
113
|
+
"no passkey available",
|
|
114
|
+
"no matching credential",
|
|
115
|
+
])) {
|
|
116
|
+
return "webauthn_missing_credential";
|
|
117
|
+
}
|
|
118
|
+
if (name === "aborterror"
|
|
119
|
+
|| containsPattern(message, [
|
|
120
|
+
"timed out or was not allowed",
|
|
121
|
+
"operation either timed out or was not allowed",
|
|
122
|
+
"cancelled",
|
|
123
|
+
"canceled",
|
|
124
|
+
"aborted",
|
|
125
|
+
"dismissed",
|
|
126
|
+
"timed out",
|
|
127
|
+
])) {
|
|
128
|
+
return "webauthn_canceled";
|
|
129
|
+
}
|
|
130
|
+
if (name === "notallowederror") {
|
|
131
|
+
return "webauthn_not_allowed";
|
|
132
|
+
}
|
|
133
|
+
return "webauthn_failed";
|
|
134
|
+
}
|
|
135
|
+
function buildWebAuthnErrorMetadata(code, operation) {
|
|
136
|
+
const operationLabel = normalizeWebAuthnOperationLabel(operation);
|
|
137
|
+
switch (code) {
|
|
138
|
+
case "webauthn_not_supported":
|
|
139
|
+
return {
|
|
140
|
+
userMessage: "Passkeys are not supported in this browser or device.",
|
|
141
|
+
retryable: false,
|
|
142
|
+
recoveryHint: "Retry from a browser and device that support WebAuthn passkeys.",
|
|
143
|
+
};
|
|
144
|
+
case "webauthn_wrong_origin":
|
|
145
|
+
return {
|
|
146
|
+
userMessage: `This ${operationLabel} was started from the wrong browser origin.`,
|
|
147
|
+
retryable: false,
|
|
148
|
+
recoveryHint: "Open the expected auth domain and verify the configured WebAuthn RP ID and allowed origins.",
|
|
149
|
+
};
|
|
150
|
+
case "webauthn_stale_challenge":
|
|
151
|
+
return {
|
|
152
|
+
userMessage: `This ${operationLabel} has expired or was already used.`,
|
|
153
|
+
retryable: true,
|
|
154
|
+
recoveryHint: "Start a fresh passkey challenge and try again.",
|
|
155
|
+
};
|
|
156
|
+
case "webauthn_missing_credential":
|
|
157
|
+
return {
|
|
158
|
+
userMessage: `No matching passkey was available for this ${operationLabel}.`,
|
|
159
|
+
retryable: true,
|
|
160
|
+
recoveryHint: "Use the device that enrolled the passkey, or enroll a new passkey from an active session.",
|
|
161
|
+
};
|
|
162
|
+
case "webauthn_canceled":
|
|
163
|
+
return {
|
|
164
|
+
userMessage: `The ${operationLabel} was canceled or timed out before completion.`,
|
|
165
|
+
retryable: true,
|
|
166
|
+
recoveryHint: "Retry the passkey prompt and complete it on the device authenticator.",
|
|
167
|
+
};
|
|
168
|
+
case "webauthn_credential_already_registered":
|
|
169
|
+
return {
|
|
170
|
+
userMessage: "This passkey is already registered for the current account.",
|
|
171
|
+
retryable: false,
|
|
172
|
+
recoveryHint: "Reuse the active passkey instead of enrolling it again.",
|
|
173
|
+
};
|
|
174
|
+
case "webauthn_not_allowed":
|
|
175
|
+
return {
|
|
176
|
+
userMessage: `The browser or authenticator did not allow this ${operationLabel}.`,
|
|
177
|
+
retryable: true,
|
|
178
|
+
recoveryHint: "Retry and make sure the browser can access the expected passkey authenticator.",
|
|
179
|
+
};
|
|
180
|
+
case "webauthn_failed":
|
|
181
|
+
default:
|
|
182
|
+
return {
|
|
183
|
+
userMessage: `The ${operationLabel} failed.`,
|
|
184
|
+
retryable: true,
|
|
185
|
+
recoveryHint: "Retry once. If it fails again, inspect the browser and server logs for the passkey flow.",
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function toSwivyWebAuthnError(error, operation) {
|
|
190
|
+
if (isSwivyWebAuthnError(error)) {
|
|
191
|
+
return error;
|
|
192
|
+
}
|
|
193
|
+
const browserErrorName = typeof error === "object" && error && "name" in error && typeof error.name === "string"
|
|
194
|
+
? error.name
|
|
195
|
+
: null;
|
|
196
|
+
const rawMessage = typeof error === "object" && error && "message" in error && typeof error.message === "string"
|
|
197
|
+
? error.message
|
|
198
|
+
: error instanceof Error
|
|
199
|
+
? error.message
|
|
200
|
+
: typeof error === "string"
|
|
201
|
+
? error
|
|
202
|
+
: null;
|
|
203
|
+
const code = normalizeWebAuthnErrorCode(browserErrorName?.toLowerCase() ?? "", rawMessage?.toLowerCase() ?? "");
|
|
204
|
+
const metadata = buildWebAuthnErrorMetadata(code, operation);
|
|
205
|
+
return new SwivyWebAuthnError({
|
|
206
|
+
code,
|
|
207
|
+
operation,
|
|
208
|
+
browserErrorName,
|
|
209
|
+
rawMessage,
|
|
210
|
+
cause: error,
|
|
211
|
+
...metadata,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
export class SwivyBrowserClient {
|
|
215
|
+
config;
|
|
216
|
+
sessionToken;
|
|
217
|
+
sessionTokenListeners = new Set();
|
|
218
|
+
sessionPersistence;
|
|
219
|
+
embeddedWalletStorage;
|
|
220
|
+
embeddedWalletStorageKeyPrefix;
|
|
221
|
+
constructor(config) {
|
|
222
|
+
this.config = config;
|
|
223
|
+
this.sessionPersistence = this.resolveSessionPersistence();
|
|
224
|
+
this.embeddedWalletStorage = this.resolveEmbeddedWalletStorage();
|
|
225
|
+
this.embeddedWalletStorageKeyPrefix =
|
|
226
|
+
config.embeddedWalletStorageKeyPrefix ?? "swivy:embedded-wallet-share";
|
|
227
|
+
this.sessionToken = config.sessionToken ?? this.sessionPersistence?.load();
|
|
228
|
+
}
|
|
229
|
+
getSessionToken() {
|
|
230
|
+
return this.sessionToken;
|
|
231
|
+
}
|
|
232
|
+
setSessionToken(token) {
|
|
233
|
+
this.sessionToken = token;
|
|
234
|
+
if (token) {
|
|
235
|
+
this.sessionPersistence?.save(token);
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
this.sessionPersistence?.clear();
|
|
239
|
+
}
|
|
240
|
+
for (const listener of this.sessionTokenListeners) {
|
|
241
|
+
listener(token);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
clearSessionToken() {
|
|
245
|
+
this.setSessionToken(undefined);
|
|
246
|
+
}
|
|
247
|
+
onSessionTokenChange(listener) {
|
|
248
|
+
this.sessionTokenListeners.add(listener);
|
|
249
|
+
return () => {
|
|
250
|
+
this.sessionTokenListeners.delete(listener);
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
async requestEmailCode(email, externalUserId) {
|
|
254
|
+
return this.request("/v1/runtime/auth/email/request", {
|
|
255
|
+
method: "POST",
|
|
256
|
+
body: { email, externalUserId },
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
async verifyEmailCode(email, code, externalUserId) {
|
|
260
|
+
const payload = await this.request("/v1/runtime/auth/email/verify", {
|
|
261
|
+
method: "POST",
|
|
262
|
+
body: { email, code, externalUserId },
|
|
263
|
+
});
|
|
264
|
+
this.setSessionToken(payload.sessionToken);
|
|
265
|
+
return payload;
|
|
266
|
+
}
|
|
267
|
+
async verifyEmailCodeAndBootstrapWallets(email, code, externalUserId) {
|
|
268
|
+
const payload = (await this.verifyEmailCode(email, code, externalUserId));
|
|
269
|
+
return this.bootstrapEmbeddedWallets(payload);
|
|
270
|
+
}
|
|
271
|
+
/** Create a new account with a passkey only (Privy signupWithPasskey). */
|
|
272
|
+
async signupWithPasskey(options = {}) {
|
|
273
|
+
this.assertWebAuthnSupport("passkey_registration");
|
|
274
|
+
const started = await this.request("/v1/runtime/auth/passkeys/signup/start", {
|
|
275
|
+
method: "POST",
|
|
276
|
+
body: { displayName: options.displayName },
|
|
277
|
+
});
|
|
278
|
+
const registrationResponse = await startRegistration({ optionsJSON: started.options });
|
|
279
|
+
const payload = await this.request("/v1/runtime/auth/passkeys/signup/finish", {
|
|
280
|
+
method: "POST",
|
|
281
|
+
body: { challengeId: started.challengeId, registrationResponse },
|
|
282
|
+
});
|
|
283
|
+
this.setSessionToken(payload.sessionToken);
|
|
284
|
+
return payload;
|
|
285
|
+
}
|
|
286
|
+
/** Log in with any passkey registered for this app (Privy loginWithPasskey). */
|
|
287
|
+
async loginWithPasskey(options = {}) {
|
|
288
|
+
this.assertWebAuthnSupport("login");
|
|
289
|
+
const started = await this.request("/v1/runtime/auth/passkeys/login/start", { method: "POST", body: {} });
|
|
290
|
+
const authenticationResponse = await startAuthentication({
|
|
291
|
+
optionsJSON: started.options,
|
|
292
|
+
useBrowserAutofill: options.useBrowserAutofill,
|
|
293
|
+
});
|
|
294
|
+
const payload = await this.request("/v1/runtime/auth/passkeys/login/verify", {
|
|
295
|
+
method: "POST",
|
|
296
|
+
body: { challengeId: started.challengeId, authenticationResponse },
|
|
297
|
+
});
|
|
298
|
+
this.setSessionToken(payload.sessionToken);
|
|
299
|
+
return payload;
|
|
300
|
+
}
|
|
301
|
+
/** Send a login code by SMS (Privy loginWithSMS, step 1). Phone in E.164, e.g. +14155550123. */
|
|
302
|
+
async requestSmsCode(phoneNumber) {
|
|
303
|
+
return this.request("/v1/runtime/auth/sms/request", { method: "POST", body: { phoneNumber } });
|
|
304
|
+
}
|
|
305
|
+
async verifySmsCode(challengeId, code) {
|
|
306
|
+
const payload = await this.request("/v1/runtime/auth/sms/verify", {
|
|
307
|
+
method: "POST",
|
|
308
|
+
body: { challengeId, code },
|
|
309
|
+
});
|
|
310
|
+
this.setSessionToken(payload.sessionToken);
|
|
311
|
+
return payload;
|
|
312
|
+
}
|
|
313
|
+
async exchangeOidcToken(token) {
|
|
314
|
+
const payload = await this.request("/v1/runtime/auth/oidc/exchange", {
|
|
315
|
+
method: "POST",
|
|
316
|
+
body: { token },
|
|
317
|
+
});
|
|
318
|
+
this.setSessionToken(payload.sessionToken);
|
|
319
|
+
return payload;
|
|
320
|
+
}
|
|
321
|
+
async exchangeOidcTokenAndBootstrapWallets(token) {
|
|
322
|
+
const payload = (await this.exchangeOidcToken(token));
|
|
323
|
+
return this.bootstrapEmbeddedWallets(payload);
|
|
324
|
+
}
|
|
325
|
+
async requestWalletLogin(options) {
|
|
326
|
+
return this.request("/v1/runtime/auth/wallet/request", {
|
|
327
|
+
method: "POST",
|
|
328
|
+
body: options,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
async verifyWalletLogin(options) {
|
|
332
|
+
const payload = await this.request("/v1/runtime/auth/wallet/verify", {
|
|
333
|
+
method: "POST",
|
|
334
|
+
body: options,
|
|
335
|
+
});
|
|
336
|
+
this.setSessionToken(payload.sessionToken);
|
|
337
|
+
return payload;
|
|
338
|
+
}
|
|
339
|
+
async verifyWalletLoginAndBootstrapWallets(options) {
|
|
340
|
+
const payload = (await this.verifyWalletLogin(options));
|
|
341
|
+
return this.bootstrapEmbeddedWallets(payload);
|
|
342
|
+
}
|
|
343
|
+
async authenticate(options) {
|
|
344
|
+
const session = options.method === "oidc"
|
|
345
|
+
? await this.exchangeOidcTokenAndBootstrapWallets(options.token)
|
|
346
|
+
: options.method === "wallet"
|
|
347
|
+
? await this.verifyWalletLoginAndBootstrapWallets({
|
|
348
|
+
chain: options.chain,
|
|
349
|
+
address: options.address,
|
|
350
|
+
challengeId: options.challengeId,
|
|
351
|
+
signature: options.signature,
|
|
352
|
+
})
|
|
353
|
+
: await this.verifyEmailCodeAndBootstrapWallets(options.email, options.code, options.externalUserId);
|
|
354
|
+
return this.attachWalletTools(session);
|
|
355
|
+
}
|
|
356
|
+
async hydrateRuntimeSession() {
|
|
357
|
+
const session = await this.bootstrapEmbeddedWallets();
|
|
358
|
+
return this.attachWalletTools(session);
|
|
359
|
+
}
|
|
360
|
+
async startTotp(label = "Authenticator app") {
|
|
361
|
+
return this.request("/v1/runtime/mfa/totp/start", {
|
|
362
|
+
method: "POST",
|
|
363
|
+
body: { label },
|
|
364
|
+
runtime: true,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
async verifyTotp(authMethodId, code) {
|
|
368
|
+
return this.request("/v1/runtime/mfa/totp/verify", {
|
|
369
|
+
method: "POST",
|
|
370
|
+
body: { authMethodId, code },
|
|
371
|
+
runtime: true,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
async startSmsEnrollment(phoneNumber, label = "SMS") {
|
|
375
|
+
return this.request("/v1/runtime/mfa/sms/start", {
|
|
376
|
+
method: "POST",
|
|
377
|
+
body: { phoneNumber, label },
|
|
378
|
+
runtime: true,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
async verifySmsEnrollment(authMethodId, challengeId, code) {
|
|
382
|
+
return this.request("/v1/runtime/mfa/sms/verify", {
|
|
383
|
+
method: "POST",
|
|
384
|
+
body: { authMethodId, challengeId, code },
|
|
385
|
+
runtime: true,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
async getMe() {
|
|
389
|
+
return this.request("/v1/runtime/me", { method: "GET", runtime: true });
|
|
390
|
+
}
|
|
391
|
+
async createManagedAccount(chain, options) {
|
|
392
|
+
const normalizedOptions = typeof options === "string" ? { label: options } : options;
|
|
393
|
+
if (normalizedOptions?.custodyMode === "split_custody" && !normalizedOptions.splitCustody) {
|
|
394
|
+
return this.createBrowserSplitWallet(chain, { label: normalizedOptions.label });
|
|
395
|
+
}
|
|
396
|
+
return this.request("/v1/runtime/accounts/managed", {
|
|
397
|
+
method: "POST",
|
|
398
|
+
body: { chain, ...normalizedOptions },
|
|
399
|
+
runtime: true,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
async listAccounts() {
|
|
403
|
+
return this.request("/v1/runtime/accounts", { method: "GET", runtime: true });
|
|
404
|
+
}
|
|
405
|
+
async listWalletTools() {
|
|
406
|
+
return this.request("/v1/runtime/tools", { method: "GET", runtime: true });
|
|
407
|
+
}
|
|
408
|
+
async createLifiQuote(body) {
|
|
409
|
+
return this.request("/v1/runtime/tools/lifi/quote", {
|
|
410
|
+
method: "POST",
|
|
411
|
+
body,
|
|
412
|
+
runtime: true,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
async createJupiterQuote(body) {
|
|
416
|
+
return this.request("/v1/runtime/tools/jupiter/quote", {
|
|
417
|
+
method: "POST",
|
|
418
|
+
body,
|
|
419
|
+
runtime: true,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
async createSwapQuote(body) {
|
|
423
|
+
return this.request("/v1/runtime/swaps/quote", {
|
|
424
|
+
method: "POST",
|
|
425
|
+
body,
|
|
426
|
+
runtime: true,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
async listEmbeddedWallets() {
|
|
430
|
+
const payload = await this.listAccounts();
|
|
431
|
+
return payload.managedAccounts;
|
|
432
|
+
}
|
|
433
|
+
async getManagedAccount(chain, options) {
|
|
434
|
+
const payload = await this.listAccounts();
|
|
435
|
+
return (payload.managedAccounts.find((account) => account.chain === chain &&
|
|
436
|
+
(!options?.purpose || ((account.purpose ?? "agent") === options.purpose))) ?? null);
|
|
437
|
+
}
|
|
438
|
+
async getEmbeddedWallet(chain) {
|
|
439
|
+
return this.getManagedAccount(chain);
|
|
440
|
+
}
|
|
441
|
+
async ensureManagedAccount(chain, options) {
|
|
442
|
+
const normalizedOptions = typeof options === "string" ? { label: options } : options;
|
|
443
|
+
const purpose = normalizedOptions?.purpose ??
|
|
444
|
+
(normalizedOptions?.custodyMode === "split_custody" ? "personal" : "agent");
|
|
445
|
+
const existing = await this.getManagedAccount(chain, {
|
|
446
|
+
purpose,
|
|
447
|
+
});
|
|
448
|
+
if (existing) {
|
|
449
|
+
return {
|
|
450
|
+
account: existing,
|
|
451
|
+
existing: true,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
if (normalizedOptions?.custodyMode === "split_custody" && !normalizedOptions.splitCustody) {
|
|
455
|
+
return this.ensureBrowserSplitWallet(chain, { label: normalizedOptions.label });
|
|
456
|
+
}
|
|
457
|
+
const created = await this.createManagedAccount(chain, normalizedOptions);
|
|
458
|
+
return {
|
|
459
|
+
account: created.account,
|
|
460
|
+
existing: Boolean(created.existing),
|
|
461
|
+
attestation: created.attestation,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
async ensureEmbeddedWallet(chain, options) {
|
|
465
|
+
return this.ensureManagedAccount(chain, options);
|
|
466
|
+
}
|
|
467
|
+
async bootstrapEmbeddedWallets(payload) {
|
|
468
|
+
const sessionPayload = payload ?? (await this.getMe());
|
|
469
|
+
const missingChains = this.getMissingSplitCustodyChains(sessionPayload);
|
|
470
|
+
if (missingChains.length === 0) {
|
|
471
|
+
return sessionPayload;
|
|
472
|
+
}
|
|
473
|
+
this.requireEmbeddedWalletStorage();
|
|
474
|
+
for (const chain of missingChains) {
|
|
475
|
+
await this.createBrowserSplitWallet(chain, {
|
|
476
|
+
label: chain === "evm" ? "EVM Wallet" : "Solana Wallet",
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
const accounts = await this.listAccounts();
|
|
480
|
+
return {
|
|
481
|
+
...sessionPayload,
|
|
482
|
+
managedAccounts: accounts.managedAccounts,
|
|
483
|
+
linkedWallets: accounts.linkedWallets,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
async createChallenge(authMethodId, purpose) {
|
|
487
|
+
return this.request("/v1/runtime/mfa/challenges", {
|
|
488
|
+
method: "POST",
|
|
489
|
+
body: { authMethodId, purpose },
|
|
490
|
+
runtime: true,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
async verifyChallenge(challengeId, payload) {
|
|
494
|
+
return this.request("/v1/runtime/mfa/challenges/verify", {
|
|
495
|
+
method: "POST",
|
|
496
|
+
body: { challengeId, ...payload },
|
|
497
|
+
runtime: true,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
async isWebAuthnSupported() {
|
|
501
|
+
return browserSupportsWebAuthn();
|
|
502
|
+
}
|
|
503
|
+
async isPlatformAuthenticatorAvailable() {
|
|
504
|
+
return platformAuthenticatorIsAvailable();
|
|
505
|
+
}
|
|
506
|
+
async isWebAuthnAutofillSupported() {
|
|
507
|
+
return browserSupportsWebAuthnAutofill();
|
|
508
|
+
}
|
|
509
|
+
async startPasskeyRegistration(label = "Passkey") {
|
|
510
|
+
return this.request("/v1/runtime/mfa/passkeys/register/start", {
|
|
511
|
+
method: "POST",
|
|
512
|
+
body: { label },
|
|
513
|
+
runtime: true,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
async finishPasskeyRegistration(authMethodId, challengeId, registrationResponse) {
|
|
517
|
+
return this.request("/v1/runtime/mfa/passkeys/register/finish", {
|
|
518
|
+
method: "POST",
|
|
519
|
+
body: { authMethodId, challengeId, registrationResponse },
|
|
520
|
+
runtime: true,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
async enrollPasskey(label = "Passkey", options) {
|
|
524
|
+
this.assertWebAuthnSupport("passkey_registration");
|
|
525
|
+
try {
|
|
526
|
+
const started = await this.startPasskeyRegistration(label);
|
|
527
|
+
const registrationResponse = await startRegistration({
|
|
528
|
+
optionsJSON: started.options,
|
|
529
|
+
useAutoRegister: options?.useAutoRegister,
|
|
530
|
+
});
|
|
531
|
+
const completed = await this.finishPasskeyRegistration(started.authMethod.id, started.challengeId, registrationResponse);
|
|
532
|
+
return {
|
|
533
|
+
...completed,
|
|
534
|
+
started,
|
|
535
|
+
registrationResponse,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
catch (error) {
|
|
539
|
+
throw toSwivyWebAuthnError(error, "passkey_registration");
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
async ensurePasskey(label = "Passkey", options) {
|
|
543
|
+
const me = (await this.getMe());
|
|
544
|
+
const existing = (me.authMethods ?? []).find((method) => method.type === "passkey" && method.active);
|
|
545
|
+
if (existing) {
|
|
546
|
+
return {
|
|
547
|
+
authMethod: existing,
|
|
548
|
+
existing: true,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
const enrollment = await this.enrollPasskey(label, options);
|
|
552
|
+
const authMethod = (enrollment.authMethod
|
|
553
|
+
?? enrollment.started.authMethod);
|
|
554
|
+
return {
|
|
555
|
+
authMethod,
|
|
556
|
+
existing: false,
|
|
557
|
+
enrollment,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
async completePasskeyChallenge(challengeId, optionsJSON, operation, options) {
|
|
561
|
+
this.assertWebAuthnSupport(operation);
|
|
562
|
+
try {
|
|
563
|
+
const authenticationResponse = await startAuthentication({
|
|
564
|
+
optionsJSON,
|
|
565
|
+
useBrowserAutofill: options?.useBrowserAutofill,
|
|
566
|
+
verifyBrowserAutofillInput: options?.verifyBrowserAutofillInput,
|
|
567
|
+
});
|
|
568
|
+
const verification = await this.verifyChallenge(challengeId, {
|
|
569
|
+
authenticationResponse,
|
|
570
|
+
});
|
|
571
|
+
return {
|
|
572
|
+
verification,
|
|
573
|
+
authenticationResponse,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
catch (error) {
|
|
577
|
+
throw toSwivyWebAuthnError(error, operation);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
async approveWithPasskey(authMethodId, purpose, options) {
|
|
581
|
+
try {
|
|
582
|
+
const challenge = (await this.createChallenge(authMethodId, purpose));
|
|
583
|
+
if (!challenge.options) {
|
|
584
|
+
throw new Error("passkey challenge options missing");
|
|
585
|
+
}
|
|
586
|
+
const completed = await this.completePasskeyChallenge(challenge.challengeId, challenge.options, purpose, options);
|
|
587
|
+
return {
|
|
588
|
+
challenge,
|
|
589
|
+
...completed,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
catch (error) {
|
|
593
|
+
throw toSwivyWebAuthnError(error, purpose);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
async approveWalletReconstructionWithPasskey(authMethodId, options) {
|
|
597
|
+
return this.approveWithPasskey(authMethodId, "wallet_reconstruction", options);
|
|
598
|
+
}
|
|
599
|
+
async prepareLocalWalletWithPasskey(accountId, options = {}) {
|
|
600
|
+
const account = await this.requireManagedAccountById(accountId);
|
|
601
|
+
if (account.custodyMode !== "split_custody") {
|
|
602
|
+
throw new Error("local_wallet_requires_split_custody");
|
|
603
|
+
}
|
|
604
|
+
const passkey = options.authMethodId
|
|
605
|
+
? {
|
|
606
|
+
authMethod: {
|
|
607
|
+
id: options.authMethodId,
|
|
608
|
+
type: "passkey",
|
|
609
|
+
label: "Provided passkey",
|
|
610
|
+
active: true,
|
|
611
|
+
},
|
|
612
|
+
existing: true,
|
|
613
|
+
}
|
|
614
|
+
: await this.ensurePasskey(options.passkeyLabel ?? "Primary passkey", {
|
|
615
|
+
useAutoRegister: options.useAutoRegister,
|
|
616
|
+
});
|
|
617
|
+
const approval = await this.approveWalletReconstructionWithPasskey(passkey.authMethod.id, options);
|
|
618
|
+
return {
|
|
619
|
+
account,
|
|
620
|
+
passkey,
|
|
621
|
+
approval,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
async createSigningIntent(body) {
|
|
625
|
+
return this.request("/v1/runtime/intents/signing", {
|
|
626
|
+
method: "POST",
|
|
627
|
+
body,
|
|
628
|
+
runtime: true,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
async createTransactionIntent(body) {
|
|
632
|
+
return this.request("/v1/runtime/intents/transactions", {
|
|
633
|
+
method: "POST",
|
|
634
|
+
body,
|
|
635
|
+
runtime: true,
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
async getIntent(intentId) {
|
|
639
|
+
return this.request(`/v1/runtime/intents/${intentId}`, {
|
|
640
|
+
method: "GET",
|
|
641
|
+
runtime: true,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
async waitForIntent(intentId, options = {}) {
|
|
645
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
646
|
+
const pollIntervalMs = options.pollIntervalMs ?? 1_000;
|
|
647
|
+
const startedAt = Date.now();
|
|
648
|
+
for (;;) {
|
|
649
|
+
if (options.signal?.aborted) {
|
|
650
|
+
throw new Error("intent_wait_aborted");
|
|
651
|
+
}
|
|
652
|
+
const payload = (await this.getIntent(intentId));
|
|
653
|
+
const intent = payload.intent;
|
|
654
|
+
if (intent.status === "completed" || intent.status === "failed") {
|
|
655
|
+
return intent;
|
|
656
|
+
}
|
|
657
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
658
|
+
throw new Error("intent_wait_timeout");
|
|
659
|
+
}
|
|
660
|
+
await this.delay(pollIntervalMs, options.signal);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
async createSigningIntentAndWait(body, options) {
|
|
664
|
+
const payload = (await this.createSigningIntent(body));
|
|
665
|
+
return this.waitForIntent(payload.intent.id, options);
|
|
666
|
+
}
|
|
667
|
+
async signMessageAndWait(body, options) {
|
|
668
|
+
return this.createSigningIntentAndWait(body, options);
|
|
669
|
+
}
|
|
670
|
+
async signTypedDataAndWait(body, options) {
|
|
671
|
+
return this.createSigningIntentAndWait({
|
|
672
|
+
accountId: body.accountId,
|
|
673
|
+
chain: "evm",
|
|
674
|
+
challengeId: body.challengeId,
|
|
675
|
+
payload: {
|
|
676
|
+
typedData: body.typedData,
|
|
677
|
+
},
|
|
678
|
+
}, options);
|
|
679
|
+
}
|
|
680
|
+
async signTransactionAndWait(body, options) {
|
|
681
|
+
return this.createSigningIntentAndWait(body, options);
|
|
682
|
+
}
|
|
683
|
+
async createTransactionIntentAndWait(body, options) {
|
|
684
|
+
const payload = (await this.createTransactionIntent(body));
|
|
685
|
+
return this.waitForIntent(payload.intent.id, options);
|
|
686
|
+
}
|
|
687
|
+
async executeTransactionAndWait(body, options) {
|
|
688
|
+
return this.createTransactionIntentAndWait(body, options);
|
|
689
|
+
}
|
|
690
|
+
async exportManagedAccount(accountId, challengeId) {
|
|
691
|
+
// The key comes back HPKE-sealed to a one-time key made here, so it is never in
|
|
692
|
+
// clear on the wire or in the API; it is opened locally.
|
|
693
|
+
const recipient = generateExportRecipientKeyPair();
|
|
694
|
+
const payload = await this.request(`/v1/runtime/accounts/${accountId}/export`, {
|
|
695
|
+
method: "POST",
|
|
696
|
+
body: { challengeId, recipientPublicKey: recipient.publicKeyBase64 },
|
|
697
|
+
runtime: true,
|
|
698
|
+
});
|
|
699
|
+
if (!payload.encryption) {
|
|
700
|
+
// An older API ignored recipientPublicKey and sent the key in clear: refuse it.
|
|
701
|
+
throw new Error("sealed_export_not_supported_by_server");
|
|
702
|
+
}
|
|
703
|
+
const { encryption, ...rest } = payload;
|
|
704
|
+
// Pin what the server says it bound the ciphertext to.
|
|
705
|
+
if (encryption.info !== "swivy-export-v1" || !String(encryption.aad).startsWith(`swivy-export:${accountId}:`)) {
|
|
706
|
+
throw new Error("sealed_export_binding_mismatch");
|
|
707
|
+
}
|
|
708
|
+
return { ...rest, privateKey: openSealedExport(encryption, recipient.privateKey) };
|
|
709
|
+
}
|
|
710
|
+
async rawSign(accountId, challengeId, payload) {
|
|
711
|
+
return this.request(`/v1/runtime/accounts/${accountId}/raw-sign`, {
|
|
712
|
+
method: "POST",
|
|
713
|
+
body: {
|
|
714
|
+
challengeId,
|
|
715
|
+
payload,
|
|
716
|
+
},
|
|
717
|
+
runtime: true,
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
async getAccountCustody(accountId) {
|
|
721
|
+
return this.request(`/v1/runtime/accounts/${accountId}/custody`, {
|
|
722
|
+
method: "GET",
|
|
723
|
+
runtime: true,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
async enableSplitCustody(accountId, splitCustody) {
|
|
727
|
+
return this.request(`/v1/runtime/accounts/${accountId}/custody/split`, {
|
|
728
|
+
method: "POST",
|
|
729
|
+
body: splitCustody,
|
|
730
|
+
runtime: true,
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
async createSessionGrant(body) {
|
|
734
|
+
return this.request("/v1/runtime/session-grants", {
|
|
735
|
+
method: "POST",
|
|
736
|
+
body,
|
|
737
|
+
runtime: true,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
async listSessionGrants() {
|
|
741
|
+
return this.request("/v1/runtime/session-grants", {
|
|
742
|
+
method: "GET",
|
|
743
|
+
runtime: true,
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
async revokeSessionGrant(grantId) {
|
|
747
|
+
return this.request(`/v1/runtime/session-grants/${grantId}/revoke`, {
|
|
748
|
+
method: "POST",
|
|
749
|
+
runtime: true,
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
async createBrowserSplitWallet(chain, options) {
|
|
753
|
+
this.requireEmbeddedWalletStorage();
|
|
754
|
+
const material = await createBrowserSplitWalletMaterial(chain, {
|
|
755
|
+
label: options?.label,
|
|
756
|
+
});
|
|
757
|
+
const payload = await this.request("/v1/runtime/accounts/managed", {
|
|
758
|
+
method: "POST",
|
|
759
|
+
body: {
|
|
760
|
+
chain,
|
|
761
|
+
purpose: "personal",
|
|
762
|
+
label: options?.label,
|
|
763
|
+
custodyMode: "split_custody",
|
|
764
|
+
browserProvisioning: material.provisioning,
|
|
765
|
+
},
|
|
766
|
+
runtime: true,
|
|
767
|
+
});
|
|
768
|
+
storeDeviceShare(this.embeddedWalletStorage, this.embeddedWalletStorageKeyPrefix, payload.account.id, material.deviceShare);
|
|
769
|
+
return {
|
|
770
|
+
account: payload.account,
|
|
771
|
+
attestation: payload.attestation,
|
|
772
|
+
existing: Boolean(payload.existing),
|
|
773
|
+
recoveryPackage: material.recoveryPackage,
|
|
774
|
+
deviceShare: {
|
|
775
|
+
...material.deviceShare,
|
|
776
|
+
accountId: payload.account.id,
|
|
777
|
+
},
|
|
778
|
+
recoveryShare: material.recoveryShare,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
async ensureBrowserSplitWallet(chain, options) {
|
|
782
|
+
const existing = await this.getManagedAccount(chain, { purpose: "personal" });
|
|
783
|
+
if (existing) {
|
|
784
|
+
const deviceShare = this.getLocalEmbeddedWalletShare(existing.id);
|
|
785
|
+
if (!deviceShare) {
|
|
786
|
+
throw new Error("local_embedded_wallet_share_missing");
|
|
787
|
+
}
|
|
788
|
+
return {
|
|
789
|
+
account: existing,
|
|
790
|
+
existing: true,
|
|
791
|
+
deviceShare,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
return this.createBrowserSplitWallet(chain, options);
|
|
795
|
+
}
|
|
796
|
+
async getServerShare(accountId, challengeId) {
|
|
797
|
+
return this.request(`/v1/runtime/accounts/${accountId}/custody/server-share`, {
|
|
798
|
+
method: "POST",
|
|
799
|
+
body: { challengeId },
|
|
800
|
+
runtime: true,
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
hasLocalEmbeddedWallet(accountId) {
|
|
804
|
+
return Boolean(this.getLocalEmbeddedWalletShare(accountId));
|
|
805
|
+
}
|
|
806
|
+
clearLocalEmbeddedWallet(accountId) {
|
|
807
|
+
if (!this.embeddedWalletStorage) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
clearDeviceShare(this.embeddedWalletStorage, this.embeddedWalletStorageKeyPrefix, accountId);
|
|
811
|
+
}
|
|
812
|
+
getLocalEmbeddedWalletShare(accountId) {
|
|
813
|
+
if (!this.embeddedWalletStorage) {
|
|
814
|
+
return null;
|
|
815
|
+
}
|
|
816
|
+
return loadDeviceShare(this.embeddedWalletStorage, this.embeddedWalletStorageKeyPrefix, accountId);
|
|
817
|
+
}
|
|
818
|
+
async exportEmbeddedWalletLocally(accountId, options = {}) {
|
|
819
|
+
const account = await this.requireManagedAccountById(accountId);
|
|
820
|
+
const { deviceShare, serverShare } = await this.loadReconstructionShares(accountId, options);
|
|
821
|
+
const exported = exportPrivateKeyFromShares(account.chain, deviceShare, serverShare);
|
|
822
|
+
return {
|
|
823
|
+
accountId,
|
|
824
|
+
chain: account.chain,
|
|
825
|
+
privateKey: exported.privateKey,
|
|
826
|
+
publicKey: exported.publicKey,
|
|
827
|
+
address: exported.address,
|
|
828
|
+
local: true,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
async exportEmbeddedWalletLocallyWithPasskey(accountId, authMethodId, options = {}) {
|
|
832
|
+
const prepared = await this.prepareLocalWalletWithPasskey(accountId, {
|
|
833
|
+
...options,
|
|
834
|
+
authMethodId,
|
|
835
|
+
});
|
|
836
|
+
const result = await this.exportEmbeddedWalletLocally(accountId, {
|
|
837
|
+
challengeId: prepared.approval.challenge.challengeId,
|
|
838
|
+
recoveryPackage: options.recoveryPackage,
|
|
839
|
+
});
|
|
840
|
+
return {
|
|
841
|
+
approval: prepared.approval,
|
|
842
|
+
result,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
async signMessageLocally(body, options = {}) {
|
|
846
|
+
const { deviceShare, serverShare } = await this.loadReconstructionShares(body.accountId, options);
|
|
847
|
+
const result = await signMessageLocallyWithShares({
|
|
848
|
+
chain: body.chain,
|
|
849
|
+
first: deviceShare,
|
|
850
|
+
second: serverShare,
|
|
851
|
+
...body.payload,
|
|
852
|
+
});
|
|
853
|
+
const signedResult = {
|
|
854
|
+
accountId: body.accountId,
|
|
855
|
+
chain: body.chain,
|
|
856
|
+
signature: result.signature,
|
|
857
|
+
signerAddress: result.address,
|
|
858
|
+
signerPublicKey: result.publicKey,
|
|
859
|
+
};
|
|
860
|
+
await this.reportLocalSigningActivity({
|
|
861
|
+
accountId: body.accountId,
|
|
862
|
+
chain: body.chain,
|
|
863
|
+
signingKind: "message",
|
|
864
|
+
challengeId: options.challengeId,
|
|
865
|
+
signerAddress: signedResult.signerAddress,
|
|
866
|
+
signerPublicKey: signedResult.signerPublicKey,
|
|
867
|
+
});
|
|
868
|
+
return signedResult;
|
|
869
|
+
}
|
|
870
|
+
async signMessageLocallyWithPasskey(body, authMethodId, options = {}) {
|
|
871
|
+
const prepared = await this.prepareLocalWalletWithPasskey(body.accountId, {
|
|
872
|
+
...options,
|
|
873
|
+
authMethodId,
|
|
874
|
+
});
|
|
875
|
+
const result = await this.signMessageLocally(body, {
|
|
876
|
+
challengeId: prepared.approval.challenge.challengeId,
|
|
877
|
+
recoveryPackage: options.recoveryPackage,
|
|
878
|
+
});
|
|
879
|
+
return {
|
|
880
|
+
approval: prepared.approval,
|
|
881
|
+
result,
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
async signTypedDataLocally(body, options = {}) {
|
|
885
|
+
const { deviceShare, serverShare } = await this.loadReconstructionShares(body.accountId, options);
|
|
886
|
+
const result = await signTypedDataLocallyWithShares({
|
|
887
|
+
first: deviceShare,
|
|
888
|
+
second: serverShare,
|
|
889
|
+
typedData: body.typedData,
|
|
890
|
+
});
|
|
891
|
+
const signedResult = {
|
|
892
|
+
accountId: body.accountId,
|
|
893
|
+
chain: "evm",
|
|
894
|
+
signature: result.signature,
|
|
895
|
+
signerAddress: result.address,
|
|
896
|
+
signerPublicKey: result.publicKey,
|
|
897
|
+
};
|
|
898
|
+
await this.reportLocalSigningActivity({
|
|
899
|
+
accountId: body.accountId,
|
|
900
|
+
chain: "evm",
|
|
901
|
+
signingKind: "typed_data",
|
|
902
|
+
challengeId: options.challengeId,
|
|
903
|
+
signerAddress: signedResult.signerAddress,
|
|
904
|
+
signerPublicKey: signedResult.signerPublicKey,
|
|
905
|
+
});
|
|
906
|
+
return signedResult;
|
|
907
|
+
}
|
|
908
|
+
async signTypedDataLocallyWithPasskey(body, authMethodId, options = {}) {
|
|
909
|
+
const prepared = await this.prepareLocalWalletWithPasskey(body.accountId, {
|
|
910
|
+
...options,
|
|
911
|
+
authMethodId,
|
|
912
|
+
});
|
|
913
|
+
const result = await this.signTypedDataLocally(body, {
|
|
914
|
+
challengeId: prepared.approval.challenge.challengeId,
|
|
915
|
+
recoveryPackage: options.recoveryPackage,
|
|
916
|
+
});
|
|
917
|
+
return {
|
|
918
|
+
approval: prepared.approval,
|
|
919
|
+
result,
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
async signTransactionLocally(body, options = {}) {
|
|
923
|
+
const { deviceShare, serverShare } = await this.loadReconstructionShares(body.accountId, options);
|
|
924
|
+
const result = await signTransactionLocallyWithShares({
|
|
925
|
+
chain: body.chain,
|
|
926
|
+
first: deviceShare,
|
|
927
|
+
second: serverShare,
|
|
928
|
+
transaction: body.payload.transaction,
|
|
929
|
+
serializedTransactionBase64: body.payload.serializedTransactionBase64,
|
|
930
|
+
});
|
|
931
|
+
const signedResult = {
|
|
932
|
+
accountId: body.accountId,
|
|
933
|
+
chain: body.chain,
|
|
934
|
+
signedPayload: result.signedPayload,
|
|
935
|
+
signature: result.signature,
|
|
936
|
+
signerAddress: result.address,
|
|
937
|
+
signerPublicKey: result.publicKey,
|
|
938
|
+
};
|
|
939
|
+
await this.reportLocalSigningActivity({
|
|
940
|
+
accountId: body.accountId,
|
|
941
|
+
chain: body.chain,
|
|
942
|
+
signingKind: "transaction",
|
|
943
|
+
challengeId: options.challengeId,
|
|
944
|
+
signerAddress: signedResult.signerAddress,
|
|
945
|
+
signerPublicKey: signedResult.signerPublicKey,
|
|
946
|
+
signedPayloadPresent: Boolean(signedResult.signedPayload),
|
|
947
|
+
});
|
|
948
|
+
return signedResult;
|
|
949
|
+
}
|
|
950
|
+
async signTransactionLocallyWithPasskey(body, authMethodId, options = {}) {
|
|
951
|
+
const prepared = await this.prepareLocalWalletWithPasskey(body.accountId, {
|
|
952
|
+
...options,
|
|
953
|
+
authMethodId,
|
|
954
|
+
});
|
|
955
|
+
const result = await this.signTransactionLocally(body, {
|
|
956
|
+
challengeId: prepared.approval.challenge.challengeId,
|
|
957
|
+
recoveryPackage: options.recoveryPackage,
|
|
958
|
+
});
|
|
959
|
+
return {
|
|
960
|
+
approval: prepared.approval,
|
|
961
|
+
result,
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
async createOfflineTransactionSigningEnvelope(body) {
|
|
965
|
+
const account = await this.requireManagedAccountById(body.accountId);
|
|
966
|
+
if (account.custodyMode !== "split_custody") {
|
|
967
|
+
throw new Error("offline_signing_requires_split_custody");
|
|
968
|
+
}
|
|
969
|
+
if (account.chain !== body.chain) {
|
|
970
|
+
throw new Error("offline_signing_chain_mismatch");
|
|
971
|
+
}
|
|
972
|
+
const session = (await this.getMe());
|
|
973
|
+
const offlineSigningPolicy = session.environmentPolicy?.offlineSigningPolicy;
|
|
974
|
+
if (!offlineSigningPolicy?.enabled) {
|
|
975
|
+
throw new Error("offline_signing_disabled");
|
|
976
|
+
}
|
|
977
|
+
if (offlineSigningPolicy.bluetoothMeshEnabled === false) {
|
|
978
|
+
throw new Error("offline_signing_bluetooth_mesh_disabled");
|
|
979
|
+
}
|
|
980
|
+
if (offlineSigningPolicy.maxEnvelopeTtlSeconds
|
|
981
|
+
&& body.expiresAt
|
|
982
|
+
&& Date.parse(String(body.expiresAt)) - Date.now() > offlineSigningPolicy.maxEnvelopeTtlSeconds * 1000) {
|
|
983
|
+
throw new Error("offline_signing_ttl_exceeds_policy");
|
|
984
|
+
}
|
|
985
|
+
const envelope = await createOfflineTransactionSigningEnvelope({
|
|
986
|
+
accountId: body.accountId,
|
|
987
|
+
chain: body.chain,
|
|
988
|
+
expiresAt: body.expiresAt,
|
|
989
|
+
payload: body.payload,
|
|
990
|
+
requester: body.requester ?? {
|
|
991
|
+
appId: this.config.appId ?? this.config.environmentId,
|
|
992
|
+
},
|
|
993
|
+
policySnapshot: {
|
|
994
|
+
enabled: offlineSigningPolicy.enabled === true,
|
|
995
|
+
bluetoothMeshEnabled: offlineSigningPolicy.bluetoothMeshEnabled === true,
|
|
996
|
+
requireSplitCustody: offlineSigningPolicy.requireSplitCustody !== false,
|
|
997
|
+
requirePasskeyApproval: offlineSigningPolicy.requirePasskeyApproval !== false,
|
|
998
|
+
allowRecoveryPackageSigning: offlineSigningPolicy.allowRecoveryPackageSigning === true,
|
|
999
|
+
maxEnvelopeTtlSeconds: offlineSigningPolicy.maxEnvelopeTtlSeconds ?? null,
|
|
1000
|
+
},
|
|
1001
|
+
pairingKeyId: this.config.offlineSigningPairingKeyId,
|
|
1002
|
+
pairingSecret: this.config.offlineSigningPairingSecret,
|
|
1003
|
+
metadata: body.metadata,
|
|
1004
|
+
});
|
|
1005
|
+
await this.reportOfflineSigningActivity({
|
|
1006
|
+
eventType: "offline_signing.request.created",
|
|
1007
|
+
accountId: body.accountId,
|
|
1008
|
+
envelopeId: envelope.id,
|
|
1009
|
+
chain: body.chain,
|
|
1010
|
+
network: body.payload.network ?? null,
|
|
1011
|
+
transport: envelope.transport,
|
|
1012
|
+
signed: Boolean(envelope.requestSignature),
|
|
1013
|
+
policySnapshotHash: envelope.policySnapshot?.hash ?? null,
|
|
1014
|
+
});
|
|
1015
|
+
return envelope;
|
|
1016
|
+
}
|
|
1017
|
+
async signOfflineTransactionSigningEnvelope(envelope, options = {}) {
|
|
1018
|
+
const requestEnvelope = typeof envelope === "string"
|
|
1019
|
+
? decodeOfflineSigningEnvelope(envelope)
|
|
1020
|
+
: envelope;
|
|
1021
|
+
if (requestEnvelope.type !== "swivy.offline_transaction_signing_request") {
|
|
1022
|
+
throw new Error("offline_signing_request_required");
|
|
1023
|
+
}
|
|
1024
|
+
if (this.hasSeenOfflineSigningRequest(requestEnvelope.id)) {
|
|
1025
|
+
throw new Error("offline_signing_replay_detected");
|
|
1026
|
+
}
|
|
1027
|
+
const pairingSecret = options.pairingSecret ?? this.config.offlineSigningPairingSecret;
|
|
1028
|
+
if (options.requireSignature !== false) {
|
|
1029
|
+
if (!pairingSecret) {
|
|
1030
|
+
throw new Error("offline_signing_pairing_secret_required");
|
|
1031
|
+
}
|
|
1032
|
+
const verified = await verifyOfflineSigningEnvelopeSignature(requestEnvelope, pairingSecret);
|
|
1033
|
+
if (!verified) {
|
|
1034
|
+
throw new Error("offline_signing_request_signature_invalid");
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
if (requestEnvelope.expiresAt && Date.parse(requestEnvelope.expiresAt) <= Date.now()) {
|
|
1038
|
+
throw new Error("offline_signing_request_expired");
|
|
1039
|
+
}
|
|
1040
|
+
if (requestEnvelope.policySnapshot?.requirePasskeyApproval && !options.localApproval) {
|
|
1041
|
+
throw new Error("offline_signing_local_passkey_approval_required");
|
|
1042
|
+
}
|
|
1043
|
+
if (requestEnvelope.policySnapshot?.allowRecoveryPackageSigning === false
|
|
1044
|
+
&& options.recoveryPackage) {
|
|
1045
|
+
throw new Error("offline_signing_recovery_package_not_allowed");
|
|
1046
|
+
}
|
|
1047
|
+
const { deviceShare, serverShare } = await this.loadReconstructionShares(requestEnvelope.accountId, options);
|
|
1048
|
+
const result = await signTransactionLocallyWithShares({
|
|
1049
|
+
chain: requestEnvelope.chain,
|
|
1050
|
+
first: deviceShare,
|
|
1051
|
+
second: serverShare,
|
|
1052
|
+
transaction: requestEnvelope.payload.transaction,
|
|
1053
|
+
serializedTransactionBase64: requestEnvelope.payload.serializedTransactionBase64,
|
|
1054
|
+
});
|
|
1055
|
+
this.markOfflineSigningRequestSeen(requestEnvelope.id);
|
|
1056
|
+
await this.reportOfflineSigningActivity({
|
|
1057
|
+
eventType: "offline_signing.request.signed",
|
|
1058
|
+
accountId: requestEnvelope.accountId,
|
|
1059
|
+
envelopeId: requestEnvelope.id,
|
|
1060
|
+
chain: requestEnvelope.chain,
|
|
1061
|
+
network: requestEnvelope.payload.network ?? null,
|
|
1062
|
+
transport: requestEnvelope.transport,
|
|
1063
|
+
signerAddress: result.address,
|
|
1064
|
+
signerPublicKey: result.publicKey,
|
|
1065
|
+
signedPayloadPresent: true,
|
|
1066
|
+
policySnapshotHash: requestEnvelope.policySnapshot?.hash ?? null,
|
|
1067
|
+
localApprovalType: options.localApproval?.type ?? null,
|
|
1068
|
+
metadata: options.metadata ?? {},
|
|
1069
|
+
});
|
|
1070
|
+
return createOfflineTransactionSigningResponseEnvelope({
|
|
1071
|
+
request: requestEnvelope,
|
|
1072
|
+
signedPayload: result.signedPayload,
|
|
1073
|
+
signature: result.signature,
|
|
1074
|
+
signerAddress: result.address,
|
|
1075
|
+
signerPublicKey: result.publicKey,
|
|
1076
|
+
metadata: options.metadata,
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
encodeOfflineSigningEnvelope(envelope) {
|
|
1080
|
+
return encodeOfflineSigningEnvelope(envelope);
|
|
1081
|
+
}
|
|
1082
|
+
decodeOfflineSigningEnvelope(envelope) {
|
|
1083
|
+
return decodeOfflineSigningEnvelope(envelope);
|
|
1084
|
+
}
|
|
1085
|
+
async importOfflineTransactionSigningResponse(envelope, options = {}) {
|
|
1086
|
+
const responseEnvelope = typeof envelope === "string"
|
|
1087
|
+
? decodeOfflineSigningEnvelope(envelope)
|
|
1088
|
+
: envelope;
|
|
1089
|
+
if (responseEnvelope.type !== "swivy.offline_transaction_signing_response") {
|
|
1090
|
+
throw new Error("offline_signing_response_required");
|
|
1091
|
+
}
|
|
1092
|
+
return this.request("/v1/runtime/offline-signing/responses/import", {
|
|
1093
|
+
method: "POST",
|
|
1094
|
+
runtime: true,
|
|
1095
|
+
body: {
|
|
1096
|
+
requestId: responseEnvelope.requestId,
|
|
1097
|
+
accountId: responseEnvelope.accountId,
|
|
1098
|
+
chain: responseEnvelope.chain,
|
|
1099
|
+
network: options.network ?? null,
|
|
1100
|
+
signedPayload: responseEnvelope.signedPayload,
|
|
1101
|
+
signature: responseEnvelope.signature,
|
|
1102
|
+
signerAddress: responseEnvelope.signerAddress,
|
|
1103
|
+
signerPublicKey: responseEnvelope.signerPublicKey,
|
|
1104
|
+
policySnapshotHash: options.policySnapshotHash ?? null,
|
|
1105
|
+
broadcast: options.broadcast === true,
|
|
1106
|
+
metadata: {
|
|
1107
|
+
...(responseEnvelope.metadata ?? {}),
|
|
1108
|
+
...(options.metadata ?? {}),
|
|
1109
|
+
},
|
|
1110
|
+
},
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
async request(path, options) {
|
|
1114
|
+
const headers = {
|
|
1115
|
+
"x-swivy-app-id": this.config.appId ?? this.config.environmentId ?? "",
|
|
1116
|
+
"x-swivy-publishable-key": this.config.publishableKey ?? this.config.apiKey ?? "",
|
|
1117
|
+
};
|
|
1118
|
+
if (!headers["x-swivy-app-id"] || !headers["x-swivy-publishable-key"]) {
|
|
1119
|
+
throw new Error("Swivy appId and publishableKey are required");
|
|
1120
|
+
}
|
|
1121
|
+
if (options.body !== undefined) {
|
|
1122
|
+
headers["content-type"] = "application/json";
|
|
1123
|
+
}
|
|
1124
|
+
if (options.runtime) {
|
|
1125
|
+
if (!this.sessionToken) {
|
|
1126
|
+
throw new Error("session token is required");
|
|
1127
|
+
}
|
|
1128
|
+
headers.authorization = `Bearer ${this.sessionToken}`;
|
|
1129
|
+
}
|
|
1130
|
+
const response = await fetch(`${this.config.baseUrl}${path}`, {
|
|
1131
|
+
method: options.method,
|
|
1132
|
+
headers,
|
|
1133
|
+
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
|
1134
|
+
});
|
|
1135
|
+
const text = await response.text();
|
|
1136
|
+
const payload = parseJsonResponseBody(response, text);
|
|
1137
|
+
if (!response.ok) {
|
|
1138
|
+
throw new Error(resolveResponseErrorMessage(response, payload));
|
|
1139
|
+
}
|
|
1140
|
+
return payload;
|
|
1141
|
+
}
|
|
1142
|
+
assertWebAuthnSupport(operation) {
|
|
1143
|
+
if (!browserSupportsWebAuthn()) {
|
|
1144
|
+
throw new SwivyWebAuthnError({
|
|
1145
|
+
code: "webauthn_not_supported",
|
|
1146
|
+
operation,
|
|
1147
|
+
cause: new Error("webauthn_not_supported"),
|
|
1148
|
+
browserErrorName: "UnsupportedError",
|
|
1149
|
+
rawMessage: "webauthn_not_supported",
|
|
1150
|
+
...buildWebAuthnErrorMetadata("webauthn_not_supported", operation),
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
resolveSessionPersistence() {
|
|
1155
|
+
const configured = this.config.sessionPersistence ?? "memory";
|
|
1156
|
+
if (configured === "memory") {
|
|
1157
|
+
return undefined;
|
|
1158
|
+
}
|
|
1159
|
+
if (typeof configured === "object") {
|
|
1160
|
+
return configured;
|
|
1161
|
+
}
|
|
1162
|
+
const storage = configured === "localStorage" ? globalThis.localStorage : globalThis.sessionStorage;
|
|
1163
|
+
if (!storage) {
|
|
1164
|
+
return undefined;
|
|
1165
|
+
}
|
|
1166
|
+
const key = this.config.sessionStorageKey ?? "swivy:session-token";
|
|
1167
|
+
return {
|
|
1168
|
+
load: () => storage.getItem(key) ?? undefined,
|
|
1169
|
+
save: (token) => {
|
|
1170
|
+
storage.setItem(key, token);
|
|
1171
|
+
},
|
|
1172
|
+
clear: () => {
|
|
1173
|
+
storage.removeItem(key);
|
|
1174
|
+
},
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
resolveEmbeddedWalletStorage() {
|
|
1178
|
+
if (this.config.embeddedWalletStorage) {
|
|
1179
|
+
return this.config.embeddedWalletStorage;
|
|
1180
|
+
}
|
|
1181
|
+
if (typeof globalThis.localStorage === "undefined") {
|
|
1182
|
+
return undefined;
|
|
1183
|
+
}
|
|
1184
|
+
return globalThis.localStorage;
|
|
1185
|
+
}
|
|
1186
|
+
requireEmbeddedWalletStorage() {
|
|
1187
|
+
if (!this.embeddedWalletStorage) {
|
|
1188
|
+
throw new Error("embedded_wallet_storage_unavailable");
|
|
1189
|
+
}
|
|
1190
|
+
return this.embeddedWalletStorage;
|
|
1191
|
+
}
|
|
1192
|
+
getMissingSplitCustodyChains(payload) {
|
|
1193
|
+
const policy = payload.environmentPolicy;
|
|
1194
|
+
const walletPolicy = policy?.walletPolicy;
|
|
1195
|
+
if (!walletPolicy?.allowManagedAccounts
|
|
1196
|
+
|| !walletPolicy.allowSplitCustody
|
|
1197
|
+
|| !walletPolicy.autoCreateManagedAccounts
|
|
1198
|
+
|| walletPolicy.defaultCustodyMode !== "split_custody") {
|
|
1199
|
+
return [];
|
|
1200
|
+
}
|
|
1201
|
+
const allowedChains = new Set((policy?.allowedChains ?? []));
|
|
1202
|
+
const managedChains = (walletPolicy.managedAccountChains ?? []).filter((chain) => allowedChains.size === 0 || allowedChains.has(chain));
|
|
1203
|
+
const existingChains = new Set((payload.managedAccounts ?? []).map((account) => account.chain));
|
|
1204
|
+
return managedChains.filter((chain) => !existingChains.has(chain));
|
|
1205
|
+
}
|
|
1206
|
+
async attachWalletTools(payload) {
|
|
1207
|
+
const walletTools = await this.listWalletTools();
|
|
1208
|
+
return {
|
|
1209
|
+
...payload,
|
|
1210
|
+
walletTools: walletTools.tools ?? [],
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
async requireManagedAccountById(accountId) {
|
|
1214
|
+
const payload = await this.listAccounts();
|
|
1215
|
+
const account = payload.managedAccounts.find((candidate) => candidate.id === accountId);
|
|
1216
|
+
if (!account) {
|
|
1217
|
+
throw new Error("managed_account_not_found");
|
|
1218
|
+
}
|
|
1219
|
+
return account;
|
|
1220
|
+
}
|
|
1221
|
+
async loadReconstructionShares(accountId, options) {
|
|
1222
|
+
const storage = this.requireEmbeddedWalletStorage();
|
|
1223
|
+
const storedDeviceShare = loadDeviceShare(storage, this.embeddedWalletStorageKeyPrefix, accountId);
|
|
1224
|
+
const recoveryShare = options.recoveryPackage
|
|
1225
|
+
? decodeRecoveryPackage(options.recoveryPackage)
|
|
1226
|
+
: null;
|
|
1227
|
+
if (storedDeviceShare && recoveryShare) {
|
|
1228
|
+
return {
|
|
1229
|
+
deviceShare: storedDeviceShare,
|
|
1230
|
+
serverShare: recoveryShare,
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
const deviceShare = storedDeviceShare ?? recoveryShare;
|
|
1234
|
+
if (!deviceShare) {
|
|
1235
|
+
throw new Error("local_embedded_wallet_share_missing");
|
|
1236
|
+
}
|
|
1237
|
+
if (!options.challengeId) {
|
|
1238
|
+
throw new Error("wallet_reconstruction_challenge_required");
|
|
1239
|
+
}
|
|
1240
|
+
const payload = await this.getServerShare(accountId, options.challengeId);
|
|
1241
|
+
const serverShare = await parseServerShareRecord(payload.serverShare.encryptedPayload, deviceShare);
|
|
1242
|
+
return {
|
|
1243
|
+
deviceShare,
|
|
1244
|
+
serverShare,
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
async reportLocalSigningActivity(params) {
|
|
1248
|
+
try {
|
|
1249
|
+
await this.request(`/v1/runtime/accounts/${params.accountId}/local-signing-events`, {
|
|
1250
|
+
method: "POST",
|
|
1251
|
+
runtime: true,
|
|
1252
|
+
body: {
|
|
1253
|
+
chain: params.chain,
|
|
1254
|
+
signingKind: params.signingKind,
|
|
1255
|
+
challengeId: params.challengeId,
|
|
1256
|
+
signerAddress: params.signerAddress,
|
|
1257
|
+
signerPublicKey: params.signerPublicKey,
|
|
1258
|
+
signedPayloadPresent: params.signedPayloadPresent,
|
|
1259
|
+
},
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
catch {
|
|
1263
|
+
// Local signing should not fail just because best-effort audit reporting did.
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
offlineSigningReplayStorageKey() {
|
|
1267
|
+
return `${this.embeddedWalletStorageKeyPrefix}:offline-signing-seen`;
|
|
1268
|
+
}
|
|
1269
|
+
hasSeenOfflineSigningRequest(requestId) {
|
|
1270
|
+
const storage = this.resolveEmbeddedWalletStorage();
|
|
1271
|
+
if (!storage) {
|
|
1272
|
+
return false;
|
|
1273
|
+
}
|
|
1274
|
+
try {
|
|
1275
|
+
const raw = storage.getItem(this.offlineSigningReplayStorageKey());
|
|
1276
|
+
const seen = raw ? JSON.parse(raw) : {};
|
|
1277
|
+
return Boolean(seen && typeof seen === "object" && seen[requestId]);
|
|
1278
|
+
}
|
|
1279
|
+
catch {
|
|
1280
|
+
return false;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
markOfflineSigningRequestSeen(requestId) {
|
|
1284
|
+
const storage = this.resolveEmbeddedWalletStorage();
|
|
1285
|
+
if (!storage) {
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
try {
|
|
1289
|
+
const key = this.offlineSigningReplayStorageKey();
|
|
1290
|
+
const raw = storage.getItem(key);
|
|
1291
|
+
const seen = raw ? JSON.parse(raw) : {};
|
|
1292
|
+
const entries = Object.entries({
|
|
1293
|
+
...(seen && typeof seen === "object" ? seen : {}),
|
|
1294
|
+
[requestId]: new Date().toISOString(),
|
|
1295
|
+
}).slice(-100);
|
|
1296
|
+
storage.setItem(key, JSON.stringify(Object.fromEntries(entries)));
|
|
1297
|
+
}
|
|
1298
|
+
catch {
|
|
1299
|
+
// Replay cache failures should not expose key material or break local signing.
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
async reportOfflineSigningActivity(params) {
|
|
1303
|
+
try {
|
|
1304
|
+
await this.request("/v1/runtime/offline-signing/events", {
|
|
1305
|
+
method: "POST",
|
|
1306
|
+
runtime: true,
|
|
1307
|
+
body: params,
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
catch {
|
|
1311
|
+
// Offline signing must not fail just because best-effort audit reporting did.
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
async delay(ms, signal) {
|
|
1315
|
+
await new Promise((resolve, reject) => {
|
|
1316
|
+
const timer = setTimeout(() => {
|
|
1317
|
+
cleanup();
|
|
1318
|
+
resolve();
|
|
1319
|
+
}, ms);
|
|
1320
|
+
const onAbort = () => {
|
|
1321
|
+
cleanup();
|
|
1322
|
+
reject(new Error("intent_wait_aborted"));
|
|
1323
|
+
};
|
|
1324
|
+
const cleanup = () => {
|
|
1325
|
+
clearTimeout(timer);
|
|
1326
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1327
|
+
};
|
|
1328
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
}
|