@gasfree-kit/evm-4337 0.1.0 → 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.mjs CHANGED
@@ -4,8 +4,16 @@ var DEFAULT_ENTRY_POINT = "0x0000000071727De22E5E9d8BAf0edAc6f37da032";
4
4
  var DEFAULT_SAFE_MODULES_VERSION = "0.3.0";
5
5
  var DEFAULT_PAYMASTER_ADDRESS = "0x8b1f6cb5d062aa2ce8d581942bbb960420d875ba";
6
6
  var DEFAULT_TRANSFER_MAX_FEE = 1e15;
7
+ function getNormalizedSponsorshipPolicyId(config) {
8
+ const sponsorshipPolicyId = config.sponsorshipPolicyId?.trim() ?? "";
9
+ if (config.isSponsored && !sponsorshipPolicyId) {
10
+ throw new Error("sponsorshipPolicyId is required when isSponsored is true");
11
+ }
12
+ return sponsorshipPolicyId;
13
+ }
7
14
  function getErc4337ConfigForChain(config) {
8
15
  const chainConfig = EVM_CHAINS[config.chain];
16
+ const sponsorshipPolicyId = getNormalizedSponsorshipPolicyId(config);
9
17
  return {
10
18
  chainId: chainConfig.chainId,
11
19
  provider: config.rpcUrl,
@@ -16,7 +24,7 @@ function getErc4337ConfigForChain(config) {
16
24
  safeModulesVersion: config.safeModulesVersion ?? DEFAULT_SAFE_MODULES_VERSION,
17
25
  transferMaxFee: DEFAULT_TRANSFER_MAX_FEE,
18
26
  isSponsored: config.isSponsored,
19
- sponsorshipPolicyId: config.sponsorshipPolicyId ?? "",
27
+ sponsorshipPolicyId,
20
28
  paymasterToken: {
21
29
  address: config.paymasterTokenAddress ?? chainConfig.usdtAddress
22
30
  }
@@ -28,7 +36,8 @@ var GAS_FEE_FALLBACKS = {
28
36
  polygon: 100000n,
29
37
  celo: 50000n,
30
38
  optimism: 100000n,
31
- base: 150000n
39
+ base: 150000n,
40
+ plasma: 50000n
32
41
  };
33
42
  var GAS_FEE_ESTIMATES = {
34
43
  ethereum: "1.40",
@@ -36,11 +45,639 @@ var GAS_FEE_ESTIMATES = {
36
45
  polygon: "0.10",
37
46
  celo: "0.05",
38
47
  optimism: "0.10",
39
- base: "0.15"
48
+ base: "0.15",
49
+ plasma: "0.05"
50
+ };
51
+
52
+ // src/passkey/linkPasskey.ts
53
+ import { keccak_256 } from "@noble/hashes/sha3.js";
54
+ import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
55
+ import { TransactionFailedError as TransactionFailedError2 } from "@gasfree-kit/core";
56
+
57
+ // src/userOpUtils.ts
58
+ import { Bundler, SendUseroperationResponse } from "abstractionkit";
59
+ import { InsufficientBalanceError, TransactionFailedError } from "@gasfree-kit/core";
60
+ var USER_OP_TIMEOUT_MS = 12e4;
61
+ async function waitForUserOpConfirmation(userOpHash, bundlerUrl, entryPointAddress) {
62
+ const bundler = new Bundler(bundlerUrl);
63
+ const response = new SendUseroperationResponse(userOpHash, bundler, entryPointAddress);
64
+ const timeoutPromise = new Promise((_, reject) => {
65
+ setTimeout(
66
+ () => reject(
67
+ new TransactionFailedError(
68
+ "evm",
69
+ `UserOperation confirmation timed out after ${USER_OP_TIMEOUT_MS / 1e3}s. Hash: ${userOpHash}`
70
+ )
71
+ ),
72
+ USER_OP_TIMEOUT_MS
73
+ );
74
+ });
75
+ const receipt = await Promise.race([response.included(), timeoutPromise]);
76
+ if (!receipt.success) {
77
+ throw new TransactionFailedError(
78
+ "evm",
79
+ `UserOperation reverted. Tx: ${receipt.receipt.transactionHash}`
80
+ );
81
+ }
82
+ return receipt.receipt.transactionHash;
83
+ }
84
+ function handleTransferError(error, chain) {
85
+ if (!(error instanceof Error)) {
86
+ return new TransactionFailedError(chain, "Unknown error");
87
+ }
88
+ const msg = error.message;
89
+ if (msg.includes("callData reverts")) {
90
+ return new TransactionFailedError(
91
+ chain,
92
+ "Transaction reverted. Check your balance and try again."
93
+ );
94
+ }
95
+ if (msg.includes("not enough funds")) {
96
+ return new InsufficientBalanceError(chain, "paymaster token");
97
+ }
98
+ if (msg.includes("token balance lower than the required") || msg.includes("token allowance lower than the required")) {
99
+ return new InsufficientBalanceError(chain, "USDT (insufficient for transfer + gas)");
100
+ }
101
+ if (msg.includes("Insufficient") || error instanceof InsufficientBalanceError) {
102
+ return error;
103
+ }
104
+ if (msg.includes("nonce too low")) {
105
+ return new TransactionFailedError(chain, "Nonce conflict. Please try again.");
106
+ }
107
+ if (msg.includes("insufficient funds") || msg.includes("transfer amount exceeds balance")) {
108
+ return new InsufficientBalanceError(chain, "USDT");
109
+ }
110
+ if (msg.includes("transaction underpriced") || msg.includes("max fee per gas less than block base fee")) {
111
+ return new TransactionFailedError(chain, "Gas price too low. Please try again.");
112
+ }
113
+ if (msg.includes("out of gas") || msg.includes("intrinsic gas too low")) {
114
+ return new TransactionFailedError(chain, "Insufficient gas limit.");
115
+ }
116
+ if (msg.includes("execution reverted")) {
117
+ return new TransactionFailedError(chain, "Transaction reverted on-chain.");
118
+ }
119
+ if (msg.includes("504") || msg.includes("Gateway Time-out")) {
120
+ return new TransactionFailedError(chain, "Network timeout. Please try again.");
121
+ }
122
+ return new TransactionFailedError(chain, msg);
123
+ }
124
+
125
+ // src/passkey/credentialStore.ts
126
+ var STORAGE_KEY = "@gasfree-kit/passkey-credentials";
127
+ function serialize(cred) {
128
+ return {
129
+ id: cred.id,
130
+ publicKey: {
131
+ x: cred.publicKey.x.toString(),
132
+ y: cred.publicKey.y.toString()
133
+ },
134
+ signerAddress: cred.signerAddress,
135
+ safeAddress: cred.safeAddress
136
+ };
137
+ }
138
+ function deserialize(raw) {
139
+ if (!raw || typeof raw !== "object") {
140
+ throw new Error("Invalid stored credential: not an object");
141
+ }
142
+ const obj = raw;
143
+ if (typeof obj.id !== "string" || !obj.id) {
144
+ throw new Error("Invalid stored credential: missing or invalid id");
145
+ }
146
+ if (typeof obj.signerAddress !== "string" || !obj.signerAddress) {
147
+ throw new Error("Invalid stored credential: missing or invalid signerAddress");
148
+ }
149
+ if (typeof obj.safeAddress !== "string" || !obj.safeAddress) {
150
+ throw new Error("Invalid stored credential: missing or invalid safeAddress");
151
+ }
152
+ const pk = obj.publicKey;
153
+ if (!pk || typeof pk !== "object" || typeof pk.x !== "string" || typeof pk.y !== "string") {
154
+ throw new Error("Invalid stored credential: missing or invalid publicKey");
155
+ }
156
+ let x;
157
+ let y;
158
+ try {
159
+ x = BigInt(pk.x);
160
+ y = BigInt(pk.y);
161
+ } catch {
162
+ throw new Error("Invalid stored credential: publicKey coordinates are not valid integers");
163
+ }
164
+ return {
165
+ id: obj.id,
166
+ publicKey: { x, y },
167
+ signerAddress: obj.signerAddress,
168
+ safeAddress: obj.safeAddress
169
+ };
170
+ }
171
+ var BrowserCredentialStore = class {
172
+ readAll() {
173
+ try {
174
+ const data = localStorage.getItem(STORAGE_KEY);
175
+ return data ? JSON.parse(data) : [];
176
+ } catch {
177
+ return [];
178
+ }
179
+ }
180
+ writeAll(credentials) {
181
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(credentials));
182
+ }
183
+ async save(credential) {
184
+ const all = this.readAll();
185
+ const idx = all.findIndex(
186
+ (c) => c.safeAddress.toLowerCase() === credential.safeAddress.toLowerCase()
187
+ );
188
+ const serialized = serialize(credential);
189
+ if (idx >= 0) {
190
+ all[idx] = serialized;
191
+ } else {
192
+ all.push(serialized);
193
+ }
194
+ this.writeAll(all);
195
+ }
196
+ async load(safeAddress) {
197
+ const all = this.readAll();
198
+ const found = all.find((c) => c.safeAddress.toLowerCase() === safeAddress.toLowerCase());
199
+ return found ? deserialize(found) : null;
200
+ }
201
+ async remove(safeAddress) {
202
+ const all = this.readAll();
203
+ const filtered = all.filter((c) => c.safeAddress.toLowerCase() !== safeAddress.toLowerCase());
204
+ this.writeAll(filtered);
205
+ }
206
+ async listAll() {
207
+ return this.readAll().map(deserialize);
208
+ }
40
209
  };
210
+ var NodeCredentialStore = class {
211
+ filePath = null;
212
+ async getFilePath() {
213
+ if (this.filePath) return this.filePath;
214
+ const os = await import("os");
215
+ const path = await import("path");
216
+ this.filePath = path.join(os.homedir(), ".gasfree-kit", "passkey-credentials.json");
217
+ return this.filePath;
218
+ }
219
+ async ensureDir(filePath) {
220
+ const path = await import("path");
221
+ const fs = await import("fs/promises");
222
+ await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 448 });
223
+ }
224
+ async readAll() {
225
+ try {
226
+ const fs = await import("fs/promises");
227
+ const filePath = await this.getFilePath();
228
+ const data = await fs.readFile(filePath, "utf-8");
229
+ const parsed = JSON.parse(data);
230
+ if (!Array.isArray(parsed)) return [];
231
+ return parsed;
232
+ } catch {
233
+ return [];
234
+ }
235
+ }
236
+ async writeAll(credentials) {
237
+ const fs = await import("fs/promises");
238
+ const filePath = await this.getFilePath();
239
+ await this.ensureDir(filePath);
240
+ const tmpPath = filePath + ".tmp." + Date.now();
241
+ try {
242
+ await fs.writeFile(tmpPath, JSON.stringify(credentials, null, 2), {
243
+ encoding: "utf-8",
244
+ mode: 384
245
+ });
246
+ await fs.rename(tmpPath, filePath);
247
+ await fs.chmod(filePath, 384);
248
+ } catch (err) {
249
+ try {
250
+ await fs.unlink(tmpPath);
251
+ } catch {
252
+ }
253
+ throw err;
254
+ }
255
+ }
256
+ async save(credential) {
257
+ const all = await this.readAll();
258
+ const idx = all.findIndex(
259
+ (c) => c.safeAddress.toLowerCase() === credential.safeAddress.toLowerCase()
260
+ );
261
+ const serialized = serialize(credential);
262
+ if (idx >= 0) {
263
+ all[idx] = serialized;
264
+ } else {
265
+ all.push(serialized);
266
+ }
267
+ await this.writeAll(all);
268
+ }
269
+ async load(safeAddress) {
270
+ const all = await this.readAll();
271
+ const found = all.find((c) => c.safeAddress.toLowerCase() === safeAddress.toLowerCase());
272
+ return found ? deserialize(found) : null;
273
+ }
274
+ async remove(safeAddress) {
275
+ const all = await this.readAll();
276
+ const filtered = all.filter((c) => c.safeAddress.toLowerCase() !== safeAddress.toLowerCase());
277
+ await this.writeAll(filtered);
278
+ }
279
+ async listAll() {
280
+ return (await this.readAll()).map(deserialize);
281
+ }
282
+ };
283
+ function createCredentialStore() {
284
+ const isBrowser = typeof window !== "undefined" && typeof localStorage !== "undefined" && typeof navigator !== "undefined" && typeof navigator.credentials !== "undefined";
285
+ if (isBrowser) {
286
+ return new BrowserCredentialStore();
287
+ }
288
+ return new NodeCredentialStore();
289
+ }
290
+
291
+ // src/passkey/rpcUtils.ts
292
+ function sanitizeRpcUrl(rpcUrl) {
293
+ try {
294
+ const url = new URL(rpcUrl);
295
+ return url.origin;
296
+ } catch {
297
+ return "(invalid URL)";
298
+ }
299
+ }
300
+ async function jsonRpcCall(rpcUrl, request) {
301
+ if (!rpcUrl) {
302
+ throw new Error("RPC URL is not configured");
303
+ }
304
+ const safeUrl = sanitizeRpcUrl(rpcUrl);
305
+ let response;
306
+ try {
307
+ response = await fetch(rpcUrl, {
308
+ method: "POST",
309
+ headers: { "Content-Type": "application/json" },
310
+ body: JSON.stringify({
311
+ jsonrpc: "2.0",
312
+ id: 1,
313
+ method: request.method,
314
+ params: request.params
315
+ })
316
+ });
317
+ } catch (err) {
318
+ const msg = err instanceof Error ? err.message : String(err);
319
+ throw new Error(`RPC network error (${safeUrl}): ${msg}`);
320
+ }
321
+ if (!response.ok) {
322
+ throw new Error(`RPC HTTP ${response.status}: ${response.statusText} (${safeUrl})`);
323
+ }
324
+ let json;
325
+ try {
326
+ json = await response.json();
327
+ } catch {
328
+ throw new Error(`RPC returned non-JSON response (${safeUrl})`);
329
+ }
330
+ if (json.error) {
331
+ const code = json.error.code ? ` (${json.error.code})` : "";
332
+ throw new Error(`RPC error${code}: ${json.error.message ?? "unknown error"}`);
333
+ }
334
+ if (json.result === void 0 || json.result === null) {
335
+ throw new Error(`RPC returned null result for ${request.method}`);
336
+ }
337
+ return json.result;
338
+ }
339
+ async function isContractDeployed(rpcUrl, address) {
340
+ const code = await jsonRpcCall(rpcUrl, {
341
+ method: "eth_getCode",
342
+ params: [address, "latest"]
343
+ });
344
+ return code !== "0x" && code !== "0x0";
345
+ }
346
+ async function getSafeOwners(rpcUrl, safeAddress) {
347
+ const GET_OWNERS_SELECTOR = "0xa0e67e2b";
348
+ const result = await jsonRpcCall(rpcUrl, {
349
+ method: "eth_call",
350
+ params: [{ to: safeAddress, data: GET_OWNERS_SELECTOR }, "latest"]
351
+ });
352
+ const hex = result.startsWith("0x") ? result.slice(2) : result;
353
+ if (hex.length < 128) {
354
+ throw new Error(`Invalid getOwners response from ${safeAddress}`);
355
+ }
356
+ const arrayLength = parseInt(hex.slice(64, 128), 16);
357
+ const owners = [];
358
+ for (let i = 0; i < arrayLength; i++) {
359
+ const start = 128 + i * 64;
360
+ const ownerHex = hex.slice(start + 24, start + 64);
361
+ owners.push("0x" + ownerHex);
362
+ }
363
+ return owners;
364
+ }
365
+ async function isSafeOwner(rpcUrl, safeAddress, ownerAddress) {
366
+ const owners = await getSafeOwners(rpcUrl, safeAddress);
367
+ return owners.some((o) => o.toLowerCase() === ownerAddress.toLowerCase());
368
+ }
369
+
370
+ // src/passkey/types.ts
371
+ var SAFE_WEBAUTHN_SINGLETON = "0x270d7e4a57e6322f336261f3eae2bade72e68d72";
372
+ var SAFE_WEBAUTHN_SIGNER_PROXY_CREATION_CODE = "61010060405234801561001157600080fd5b506040516101ee3803806101ee83398101604081905261003091610058565b6001600160a01b0390931660805260a09190915260c0526001600160b01b031660e0526100bc565b6000806000806080858703121561006e57600080fd5b84516001600160a01b038116811461008557600080fd5b60208601516040870151606088015192965090945092506001600160b01b03811681146100b157600080fd5b939692955090935050565b60805160a05160c05160e05160ff6100ef60003960006008015260006031015260006059015260006080015260ff6000f3fe608060408190527f00000000000000000000000000000000000000000000000000000000000000003660b681018290527f000000000000000000000000000000000000000000000000000000000000000060a082018190527f00000000000000000000000000000000000000000000000000000000000000008285018190527f00000000000000000000000000000000000000000000000000000000000000009490939192600082376000806056360183885af490503d6000803e8060c3573d6000fd5b503d6000f3fea2646970667358221220ddd9bb059ba7a6497d560ca97aadf4dbf0476f578378554a50d41c6bb654beae64736f6c63430008180033";
373
+ var SAFE_WEBAUTHN_SIGNER_FACTORY = {
374
+ ethereum: "0xF7488fFbe67327ac9f37D5F722d83Fc900852Fbf",
375
+ base: "0xF7488fFbe67327ac9f37D5F722d83Fc900852Fbf",
376
+ arbitrum: "0xF7488fFbe67327ac9f37D5F722d83Fc900852Fbf",
377
+ optimism: "0xF7488fFbe67327ac9f37D5F722d83Fc900852Fbf",
378
+ polygon: "0xF7488fFbe67327ac9f37D5F722d83Fc900852Fbf",
379
+ celo: "0xF7488fFbe67327ac9f37D5F722d83Fc900852Fbf"
380
+ };
381
+ var FCL_P256_VERIFIER = {
382
+ ethereum: "0xc2b78104907F722DABAc4C69f826a522B2754De4",
383
+ base: "0xc2b78104907F722DABAc4C69f826a522B2754De4",
384
+ arbitrum: "0xc2b78104907F722DABAc4C69f826a522B2754De4",
385
+ optimism: "0xc2b78104907F722DABAc4C69f826a522B2754De4",
386
+ polygon: "0xc2b78104907F722DABAc4C69f826a522B2754De4",
387
+ celo: "0xc2b78104907F722DABAc4C69f826a522B2754De4"
388
+ };
389
+
390
+ // src/passkey/linkPasskey.ts
391
+ var P256_FIELD_PRIME = BigInt(
392
+ "0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff"
393
+ );
394
+ function validateAndPadAddress(addr) {
395
+ const hex = addr.startsWith("0x") ? addr.slice(2) : addr;
396
+ if (!/^[0-9a-fA-F]{40}$/.test(hex)) {
397
+ throw new Error(`Invalid EVM address for ABI encoding: ${addr}`);
398
+ }
399
+ return hex.toLowerCase().padStart(64, "0");
400
+ }
401
+ function validateUint256(value, name) {
402
+ if (value < 0n) {
403
+ throw new Error(`Invalid ${name}: ${value}. Must be non-negative.`);
404
+ }
405
+ const hex = value.toString(16);
406
+ if (hex.length > 64) {
407
+ throw new Error(`Invalid ${name}: exceeds uint256 max.`);
408
+ }
409
+ return hex.padStart(64, "0");
410
+ }
411
+ function encodeAddOwnerWithThreshold(owner, threshold) {
412
+ const selector = "0x0d582f13";
413
+ return `${selector}${validateAndPadAddress(owner)}${validateUint256(threshold, "threshold")}`;
414
+ }
415
+ function encodeRemoveOwner(prevOwner, owner, threshold) {
416
+ const selector = "0xf8dc5dd9";
417
+ return `${selector}${validateAndPadAddress(prevOwner)}${validateAndPadAddress(owner)}${validateUint256(threshold, "threshold")}`;
418
+ }
419
+ function encodeCreateSigner(x, y, verifier) {
420
+ const selector = "0xa8a65a78";
421
+ return `${selector}${validateUint256(x, "x")}${validateUint256(y, "y")}${validateAndPadAddress(verifier)}`;
422
+ }
423
+ function extractPublicKeyFromAttestation(credential) {
424
+ const response = credential.response;
425
+ const publicKey = response.getPublicKey();
426
+ if (!publicKey) {
427
+ throw new Error("Failed to extract public key from WebAuthn attestation");
428
+ }
429
+ const keyBytes = new Uint8Array(publicKey);
430
+ let offset = -1;
431
+ for (let i = 0; i <= keyBytes.length - 65; i++) {
432
+ if (keyBytes[i] === 4) {
433
+ offset = i + 1;
434
+ break;
435
+ }
436
+ }
437
+ if (offset === -1 || offset + 64 > keyBytes.length) {
438
+ throw new Error("Could not find P-256 public key in attestation data");
439
+ }
440
+ const xBytes = keyBytes.slice(offset, offset + 32);
441
+ const yBytes = keyBytes.slice(offset + 32, offset + 64);
442
+ const x = BigInt(
443
+ "0x" + Array.from(xBytes).map((b) => b.toString(16).padStart(2, "0")).join("")
444
+ );
445
+ const y = BigInt(
446
+ "0x" + Array.from(yBytes).map((b) => b.toString(16).padStart(2, "0")).join("")
447
+ );
448
+ if (x === 0n && y === 0n) {
449
+ throw new Error("Public key is point at infinity (invalid)");
450
+ }
451
+ if (x >= P256_FIELD_PRIME || y >= P256_FIELD_PRIME) {
452
+ throw new Error("Public key coordinates exceed P-256 field prime");
453
+ }
454
+ return { x, y };
455
+ }
456
+ function arrayBufferToBase64Url(buffer) {
457
+ const bytes = new Uint8Array(buffer);
458
+ let binary = "";
459
+ for (const byte of bytes) {
460
+ binary += String.fromCharCode(byte);
461
+ }
462
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
463
+ }
464
+ function deriveSignerAddress(factoryAddress, x, y, verifierAddress) {
465
+ const args = BigInt(SAFE_WEBAUTHN_SINGLETON).toString(16).padStart(64, "0") + x.toString(16).padStart(64, "0") + y.toString(16).padStart(64, "0") + BigInt(verifierAddress).toString(16).padStart(64, "0");
466
+ const initCode = SAFE_WEBAUTHN_SIGNER_PROXY_CREATION_CODE + args;
467
+ const codeHash = bytesToHex(keccak_256(hexToBytes(initCode)));
468
+ const salt = "0".repeat(64);
469
+ const create2Input = "ff" + factoryAddress.slice(2).toLowerCase() + salt + codeHash;
470
+ const create2Hash = bytesToHex(keccak_256(hexToBytes(create2Input)));
471
+ return "0x" + create2Hash.slice(24);
472
+ }
473
+ async function linkPasskeyToSafe(seedPhrase, config, passkeyOptions = {}, accountIndex = 0) {
474
+ const networkConfig = getErc4337ConfigForChain(config);
475
+ const {
476
+ wallet,
477
+ account,
478
+ address: safeAddress
479
+ } = await setupErc4337Wallet(seedPhrase, config, accountIndex);
480
+ try {
481
+ const deployed = await isContractDeployed(networkConfig.provider, safeAddress);
482
+ if (!deployed) {
483
+ throw new TransactionFailedError2(
484
+ config.chain,
485
+ `Safe account at ${safeAddress} is not yet deployed on-chain. Send a transaction first to deploy it.`
486
+ );
487
+ }
488
+ const {
489
+ rpId = typeof window !== "undefined" ? window.location.hostname : "localhost",
490
+ rpName = "Gasfree Kit",
491
+ userName = "Wallet User",
492
+ storage = "not_persist"
493
+ } = passkeyOptions;
494
+ const signerFactoryAddress = SAFE_WEBAUTHN_SIGNER_FACTORY[config.chain];
495
+ const p256VerifierAddress = FCL_P256_VERIFIER[config.chain];
496
+ if (!signerFactoryAddress || !p256VerifierAddress) {
497
+ throw new TransactionFailedError2(
498
+ config.chain,
499
+ `Passkey contracts not available on ${config.chain}`
500
+ );
501
+ }
502
+ const credentialPromise = navigator.credentials.create({
503
+ publicKey: {
504
+ rp: { id: rpId, name: rpName },
505
+ user: {
506
+ // Use an opaque random handle so the WebAuthn user record does not expose the Safe address.
507
+ id: crypto.getRandomValues(new Uint8Array(32)).buffer,
508
+ name: userName,
509
+ displayName: userName
510
+ },
511
+ challenge: Uint8Array.from(crypto.getRandomValues(new Uint8Array(32))).buffer,
512
+ pubKeyCredParams: [{ alg: -7, type: "public-key" }],
513
+ // ES256 (P-256)
514
+ authenticatorSelection: {
515
+ authenticatorAttachment: "platform",
516
+ residentKey: "required",
517
+ userVerification: "required"
518
+ },
519
+ timeout: 6e4
520
+ }
521
+ });
522
+ const timeoutPromise = new Promise((_, reject) => {
523
+ setTimeout(
524
+ () => reject(new TransactionFailedError2(config.chain, "Passkey registration timed out")),
525
+ 65e3
526
+ );
527
+ });
528
+ const credential = await Promise.race([
529
+ credentialPromise,
530
+ timeoutPromise
531
+ ]);
532
+ if (!credential) {
533
+ throw new TransactionFailedError2(config.chain, "Passkey registration was cancelled");
534
+ }
535
+ const publicKey = extractPublicKeyFromAttestation(credential);
536
+ const credentialId = arrayBufferToBase64Url(credential.rawId);
537
+ const signerAddress = deriveSignerAddress(
538
+ signerFactoryAddress,
539
+ publicKey.x,
540
+ publicKey.y,
541
+ p256VerifierAddress
542
+ );
543
+ const alreadyOwner = await isSafeOwner(networkConfig.provider, safeAddress, signerAddress);
544
+ if (alreadyOwner) {
545
+ throw new TransactionFailedError2(
546
+ config.chain,
547
+ `Passkey signer ${signerAddress} is already a Safe owner. Cannot link the same passkey twice.`
548
+ );
549
+ }
550
+ const createSignerCalldata = encodeCreateSigner(publicKey.x, publicKey.y, p256VerifierAddress);
551
+ const deployResult = await account.sendTransaction([
552
+ {
553
+ to: signerFactoryAddress,
554
+ value: 0,
555
+ data: createSignerCalldata
556
+ }
557
+ ]);
558
+ await waitForUserOpConfirmation(
559
+ deployResult.hash,
560
+ networkConfig.bundlerUrl,
561
+ networkConfig.entryPointAddress
562
+ );
563
+ if (!await isContractDeployed(networkConfig.provider, signerAddress)) {
564
+ throw new TransactionFailedError2(
565
+ config.chain,
566
+ `Passkey signer deployment could not be verified at ${signerAddress}`
567
+ );
568
+ }
569
+ const addOwnerCalldata = encodeAddOwnerWithThreshold(signerAddress, 1n);
570
+ const addOwnerResult = await account.sendTransaction([
571
+ {
572
+ to: safeAddress,
573
+ value: 0,
574
+ data: addOwnerCalldata
575
+ }
576
+ ]);
577
+ const transactionHash = await waitForUserOpConfirmation(
578
+ addOwnerResult.hash,
579
+ networkConfig.bundlerUrl,
580
+ networkConfig.entryPointAddress
581
+ );
582
+ const passkeyCredential = {
583
+ id: credentialId,
584
+ publicKey,
585
+ signerAddress,
586
+ safeAddress
587
+ };
588
+ let persisted = false;
589
+ if (storage === "persist") {
590
+ const store = createCredentialStore();
591
+ await store.save(passkeyCredential);
592
+ persisted = true;
593
+ }
594
+ return {
595
+ credential: passkeyCredential,
596
+ safeAddress,
597
+ transactionHash,
598
+ persisted
599
+ };
600
+ } finally {
601
+ account.dispose();
602
+ wallet.dispose();
603
+ }
604
+ }
605
+ async function isPasskeyLinked(seedPhrase, config, accountIndex = 0) {
606
+ const store = createCredentialStore();
607
+ const {
608
+ wallet,
609
+ account,
610
+ address: safeAddress
611
+ } = await setupErc4337Wallet(seedPhrase, config, accountIndex);
612
+ try {
613
+ const stored = await store.load(safeAddress);
614
+ if (stored) {
615
+ const networkConfig = getErc4337ConfigForChain(config);
616
+ const onChainOwner = await isSafeOwner(
617
+ networkConfig.provider,
618
+ safeAddress,
619
+ stored.signerAddress
620
+ );
621
+ if (onChainOwner) {
622
+ return { linked: true, signerAddress: stored.signerAddress };
623
+ }
624
+ await store.remove(safeAddress);
625
+ }
626
+ return { linked: false };
627
+ } finally {
628
+ account.dispose();
629
+ wallet.dispose();
630
+ }
631
+ }
632
+ async function unlinkPasskeyFromSafe(seedPhrase, config, credential, accountIndex = 0) {
633
+ const networkConfig = getErc4337ConfigForChain(config);
634
+ const {
635
+ wallet,
636
+ account,
637
+ address: safeAddress
638
+ } = await setupErc4337Wallet(seedPhrase, config, accountIndex);
639
+ try {
640
+ if (credential.safeAddress.toLowerCase() !== safeAddress.toLowerCase()) {
641
+ throw new TransactionFailedError2(
642
+ config.chain,
643
+ `Credential belongs to Safe ${credential.safeAddress}, not ${safeAddress}`
644
+ );
645
+ }
646
+ const owners = await getSafeOwners(networkConfig.provider, safeAddress);
647
+ const signerLower = credential.signerAddress.toLowerCase();
648
+ const ownerIndex = owners.findIndex((o) => o.toLowerCase() === signerLower);
649
+ if (ownerIndex === -1) {
650
+ throw new TransactionFailedError2(
651
+ config.chain,
652
+ `Passkey signer ${credential.signerAddress} is not a Safe owner`
653
+ );
654
+ }
655
+ const SENTINEL = "0x0000000000000000000000000000000000000001";
656
+ const prevOwner = ownerIndex === 0 ? SENTINEL : owners[ownerIndex - 1];
657
+ const removeOwnerCalldata = encodeRemoveOwner(prevOwner, credential.signerAddress, 1n);
658
+ const result = await account.sendTransaction([
659
+ {
660
+ to: safeAddress,
661
+ value: 0,
662
+ data: removeOwnerCalldata
663
+ }
664
+ ]);
665
+ const transactionHash = await waitForUserOpConfirmation(
666
+ result.hash,
667
+ networkConfig.bundlerUrl,
668
+ networkConfig.entryPointAddress
669
+ );
670
+ const store = createCredentialStore();
671
+ await store.remove(safeAddress);
672
+ return { transactionHash };
673
+ } finally {
674
+ account.dispose();
675
+ wallet.dispose();
676
+ }
677
+ }
41
678
 
42
679
  // src/evmERC4337.ts
43
- async function setupErc4337Wallet(seedPhrase, config, accountIndex = 0) {
680
+ async function setupErc4337Wallet(seedPhrase, config, accountIndex = 0, passkeyOptions) {
44
681
  const networkConfig = getErc4337ConfigForChain(config);
45
682
  const WalletManagerEvmErc4337Module = await import("@tetherto/wdk-wallet-evm-erc-4337");
46
683
  const WalletManagerEvmErc4337 = WalletManagerEvmErc4337Module.default;
@@ -69,14 +706,17 @@ async function setupErc4337Wallet(seedPhrase, config, accountIndex = 0) {
69
706
  );
70
707
  const account = await wallet.getAccount(accountIndex);
71
708
  const address = await account.getAddress();
72
- return { wallet, account, address };
709
+ let passkey;
710
+ if (passkeyOptions) {
711
+ passkey = await linkPasskeyToSafe(seedPhrase, config, passkeyOptions, accountIndex);
712
+ }
713
+ return { wallet, account, address, passkey };
73
714
  }
74
715
 
75
716
  // src/evmERC4337TetherTransfer.ts
76
- import { Bundler, SendUseroperationResponse } from "abstractionkit";
77
717
  import {
78
- InsufficientBalanceError,
79
- TransactionFailedError,
718
+ InsufficientBalanceError as InsufficientBalanceError2,
719
+ TransactionFailedError as TransactionFailedError3,
80
720
  validateEvmAddress
81
721
  } from "@gasfree-kit/core";
82
722
 
@@ -128,36 +768,7 @@ function feeToUsdt(feeInWei, nativeTokenPriceUsdt) {
128
768
  }
129
769
 
130
770
  // src/evmERC4337TetherTransfer.ts
131
- var TetherEVMERC4337Transfer = class {
132
- /** Default timeout for UserOp confirmation: 120 seconds. */
133
- static USER_OP_TIMEOUT_MS = 12e4;
134
- /**
135
- * Wait for a UserOperation to be included on-chain via the Candide bundler.
136
- * Times out after USER_OP_TIMEOUT_MS to prevent indefinite hangs.
137
- */
138
- static async waitForUserOpConfirmation(userOpHash, bundlerUrl, entryPointAddress) {
139
- const bundler = new Bundler(bundlerUrl);
140
- const response = new SendUseroperationResponse(userOpHash, bundler, entryPointAddress);
141
- const timeoutPromise = new Promise((_, reject) => {
142
- setTimeout(
143
- () => reject(
144
- new TransactionFailedError(
145
- "evm",
146
- `UserOperation confirmation timed out after ${this.USER_OP_TIMEOUT_MS / 1e3}s. Hash: ${userOpHash}`
147
- )
148
- ),
149
- this.USER_OP_TIMEOUT_MS
150
- );
151
- });
152
- const receipt = await Promise.race([response.included(), timeoutPromise]);
153
- if (!receipt.success) {
154
- throw new TransactionFailedError(
155
- "evm",
156
- `UserOperation reverted. Tx: ${receipt.receipt.transactionHash}`
157
- );
158
- }
159
- return receipt.receipt.transactionHash;
160
- }
771
+ var EvmTransfer = class {
161
772
  /**
162
773
  * Get transaction fee estimate for a USDT transfer.
163
774
  *
@@ -250,7 +861,7 @@ var TetherEVMERC4337Transfer = class {
250
861
  } = await setupErc4337Wallet(seedPhrase, config, accountIndex);
251
862
  try {
252
863
  if (recipientAddress.toLowerCase() === senderAddress.toLowerCase()) {
253
- throw new TransactionFailedError(config.chain, "Cannot transfer to your own address");
864
+ throw new TransactionFailedError3(config.chain, "Cannot transfer to your own address");
254
865
  }
255
866
  const amountInBaseUnits = toUsdtBaseUnitsEvm(amount);
256
867
  const balance = await account.getTokenBalance(tokenAddress);
@@ -269,20 +880,20 @@ var TetherEVMERC4337Transfer = class {
269
880
  }
270
881
  const totalRequired = amountInBaseUnits + gasFeeBaseUnits;
271
882
  if (BigInt(balance) < totalRequired) {
272
- throw new InsufficientBalanceError(
883
+ throw new InsufficientBalanceError2(
273
884
  config.chain,
274
885
  `USDT (need ${formatTokenBalance(totalRequired, 6)} but have ${formatTokenBalance(balance, 6)} \u2014 includes ${formatTokenBalance(gasFeeBaseUnits, 6)} gas fee)`
275
886
  );
276
887
  }
277
888
  } else if (BigInt(balance) < amountInBaseUnits) {
278
- throw new InsufficientBalanceError(config.chain, "USDT");
889
+ throw new InsufficientBalanceError2(config.chain, "USDT");
279
890
  }
280
891
  const transferResult = await account.transfer({
281
892
  token: tokenAddress,
282
893
  recipient: recipientAddress,
283
894
  amount: amountInBaseUnits
284
895
  });
285
- const transactionHash = await this.waitForUserOpConfirmation(
896
+ const transactionHash = await waitForUserOpConfirmation(
286
897
  transferResult.hash,
287
898
  networkConfig.bundlerUrl,
288
899
  networkConfig.entryPointAddress
@@ -294,7 +905,7 @@ var TetherEVMERC4337Transfer = class {
294
905
  amount
295
906
  };
296
907
  } catch (error) {
297
- throw this.handleTransferError(error, config.chain);
908
+ throw handleTransferError(error, config.chain);
298
909
  } finally {
299
910
  account.dispose();
300
911
  wallet.dispose();
@@ -317,7 +928,7 @@ var TetherEVMERC4337Transfer = class {
317
928
  try {
318
929
  for (const r of recipients) {
319
930
  if (r.address.toLowerCase() === senderAddress.toLowerCase()) {
320
- throw new TransactionFailedError(config.chain, "Cannot transfer to your own address");
931
+ throw new TransactionFailedError3(config.chain, "Cannot transfer to your own address");
321
932
  }
322
933
  }
323
934
  const totalAmount = recipients.reduce((sum, r) => sum + toUsdtBaseUnitsEvm(r.amount), 0n);
@@ -326,13 +937,13 @@ var TetherEVMERC4337Transfer = class {
326
937
  const gasFeeReserve = GAS_FEE_FALLBACKS[config.chain] ?? 150000n;
327
938
  const totalRequired = totalAmount + gasFeeReserve;
328
939
  if (BigInt(balance) < totalRequired) {
329
- throw new InsufficientBalanceError(
940
+ throw new InsufficientBalanceError2(
330
941
  config.chain,
331
942
  `USDT (need ${formatTokenBalance(totalRequired, 6)} but have ${formatTokenBalance(balance, 6)} \u2014 includes ${formatTokenBalance(gasFeeReserve, 6)} gas fee)`
332
943
  );
333
944
  }
334
945
  } else if (BigInt(balance) < totalAmount) {
335
- throw new InsufficientBalanceError(config.chain, "USDT");
946
+ throw new InsufficientBalanceError2(config.chain, "USDT");
336
947
  }
337
948
  const batchTransactions = recipients.map(({ address, amount }) => {
338
949
  const amountBase = toUsdtBaseUnitsEvm(amount);
@@ -343,7 +954,7 @@ var TetherEVMERC4337Transfer = class {
343
954
  };
344
955
  });
345
956
  const transferResult = await account.sendTransaction(batchTransactions);
346
- const transactionHash = await this.waitForUserOpConfirmation(
957
+ const transactionHash = await waitForUserOpConfirmation(
347
958
  transferResult.hash,
348
959
  networkConfig.bundlerUrl,
349
960
  networkConfig.entryPointAddress
@@ -355,64 +966,171 @@ var TetherEVMERC4337Transfer = class {
355
966
  recipients
356
967
  };
357
968
  } catch (error) {
358
- throw this.handleTransferError(error, config.chain);
969
+ throw handleTransferError(error, config.chain);
359
970
  } finally {
360
971
  account.dispose();
361
972
  wallet.dispose();
362
973
  }
363
974
  }
975
+ };
976
+
977
+ // src/passkey/passkeyTransfer.ts
978
+ import { TransactionFailedError as TransactionFailedError4, validateEvmAddress as validateEvmAddress2 } from "@gasfree-kit/core";
979
+ var ERC20_BALANCE_OF_SELECTOR = "0x70a08231";
980
+ async function loadSafe4337Pack() {
981
+ try {
982
+ const module = await import("@safe-global/relay-kit");
983
+ return module.Safe4337Pack;
984
+ } catch {
985
+ throw new Error(
986
+ "Passkey transfers require @safe-global/relay-kit. Install it: npm install @safe-global/relay-kit"
987
+ );
988
+ }
989
+ }
990
+ async function createPasskeyPack(credential, config) {
991
+ const Safe4337Pack = await loadSafe4337Pack();
992
+ const networkConfig = getErc4337ConfigForChain(config);
993
+ const passkeySigner = {
994
+ rawId: credential.id,
995
+ coordinates: {
996
+ x: credential.publicKey.x,
997
+ y: credential.publicKey.y
998
+ }
999
+ };
1000
+ const paymasterOptions = networkConfig.isSponsored ? {
1001
+ isSponsored: true,
1002
+ paymasterUrl: networkConfig.paymasterUrl,
1003
+ ...networkConfig.sponsorshipPolicyId ? { sponsorshipPolicyId: networkConfig.sponsorshipPolicyId } : {}
1004
+ } : {
1005
+ paymasterAddress: networkConfig.paymasterAddress,
1006
+ paymasterUrl: networkConfig.paymasterUrl
1007
+ };
1008
+ const pack = await Safe4337Pack.init({
1009
+ provider: networkConfig.provider,
1010
+ signer: passkeySigner,
1011
+ bundlerUrl: networkConfig.bundlerUrl,
1012
+ safeAddress: credential.safeAddress,
1013
+ paymasterOptions
1014
+ });
1015
+ return { pack, networkConfig };
1016
+ }
1017
+ var PasskeyTransfer = class {
364
1018
  /**
365
- * Map raw errors into user-friendly messages.
1019
+ * Send a single token transfer signed by passkey.
1020
+ * Triggers biometric authentication via WebAuthn.
366
1021
  */
367
- static handleTransferError(error, chain) {
368
- if (!(error instanceof Error)) {
369
- return new TransactionFailedError(chain, "Unknown error");
370
- }
371
- const msg = error.message;
372
- if (msg.includes("callData reverts")) {
373
- return new TransactionFailedError(
374
- chain,
375
- "Transaction reverted. Check your balance and try again."
376
- );
1022
+ static async sendToken(credential, config, tokenAddress, amount, recipientAddress) {
1023
+ validateEvmAddress2(tokenAddress, config.chain, "token address");
1024
+ validateEvmAddress2(recipientAddress, config.chain, "recipient address");
1025
+ if (recipientAddress.toLowerCase() === credential.safeAddress.toLowerCase()) {
1026
+ throw new TransactionFailedError4(config.chain, "Cannot transfer to your own address");
377
1027
  }
378
- if (msg.includes("not enough funds")) {
379
- return new InsufficientBalanceError(chain, "paymaster token");
380
- }
381
- if (msg.includes("token balance lower than the required") || msg.includes("token allowance lower than the required")) {
382
- return new InsufficientBalanceError(chain, "USDT (insufficient for transfer + gas)");
383
- }
384
- if (msg.includes("Insufficient") || error instanceof InsufficientBalanceError) {
385
- return error;
386
- }
387
- if (msg.includes("nonce too low")) {
388
- return new TransactionFailedError(chain, "Nonce conflict. Please try again.");
389
- }
390
- if (msg.includes("insufficient funds") || msg.includes("transfer amount exceeds balance")) {
391
- return new InsufficientBalanceError(chain, "USDT");
392
- }
393
- if (msg.includes("transaction underpriced") || msg.includes("max fee per gas less than block base fee")) {
394
- return new TransactionFailedError(chain, "Gas price too low. Please try again.");
395
- }
396
- if (msg.includes("out of gas") || msg.includes("intrinsic gas too low")) {
397
- return new TransactionFailedError(chain, "Insufficient gas limit.");
1028
+ try {
1029
+ const { pack } = await createPasskeyPack(credential, config);
1030
+ const amountInBaseUnits = toUsdtBaseUnitsEvm(amount);
1031
+ const transferCalldata = encodeErc20Transfer(recipientAddress, amountInBaseUnits);
1032
+ const safeOperation = await pack.createTransaction({
1033
+ transactions: [
1034
+ {
1035
+ to: tokenAddress,
1036
+ data: transferCalldata,
1037
+ value: "0"
1038
+ }
1039
+ ]
1040
+ });
1041
+ const signedOperation = await pack.signSafeOperation(safeOperation);
1042
+ const userOpHash = await pack.executeTransaction({
1043
+ executable: signedOperation
1044
+ });
1045
+ const receipt = await pack.getUserOperationReceipt(userOpHash);
1046
+ return {
1047
+ success: true,
1048
+ transactionHash: receipt.receipt.transactionHash,
1049
+ chain: config.chain,
1050
+ amount
1051
+ };
1052
+ } catch (error) {
1053
+ throw handleTransferError(error, config.chain);
398
1054
  }
399
- if (msg.includes("execution reverted")) {
400
- return new TransactionFailedError(chain, "Transaction reverted on-chain.");
1055
+ }
1056
+ /**
1057
+ * Send a batch token transfer to multiple recipients, signed by passkey.
1058
+ */
1059
+ static async sendBatchToken(credential, config, tokenAddress, recipients) {
1060
+ for (const r of recipients) {
1061
+ validateEvmAddress2(r.address, config.chain, "recipient address");
1062
+ if (r.address.toLowerCase() === credential.safeAddress.toLowerCase()) {
1063
+ throw new TransactionFailedError4(config.chain, "Cannot transfer to your own address");
1064
+ }
401
1065
  }
402
- if (msg.includes("504") || msg.includes("Gateway Time-out")) {
403
- return new TransactionFailedError(chain, "Network timeout. Please try again.");
1066
+ try {
1067
+ const { pack } = await createPasskeyPack(credential, config);
1068
+ const transactions = recipients.map(({ address, amount }) => {
1069
+ const amountBase = toUsdtBaseUnitsEvm(amount);
1070
+ return {
1071
+ to: tokenAddress,
1072
+ data: encodeErc20Transfer(address, amountBase),
1073
+ value: "0"
1074
+ };
1075
+ });
1076
+ const safeOperation = await pack.createTransaction({ transactions });
1077
+ const signedOperation = await pack.signSafeOperation(safeOperation);
1078
+ const userOpHash = await pack.executeTransaction({ executable: signedOperation });
1079
+ const receipt = await pack.getUserOperationReceipt(userOpHash);
1080
+ return {
1081
+ success: true,
1082
+ transactionHash: receipt.receipt.transactionHash,
1083
+ chain: config.chain,
1084
+ recipients
1085
+ };
1086
+ } catch (error) {
1087
+ throw handleTransferError(error, config.chain);
404
1088
  }
405
- return new TransactionFailedError(chain, msg);
1089
+ }
1090
+ /**
1091
+ * Check token balance for a passkey-linked Safe account.
1092
+ * Does NOT require biometric — balance is publicly readable via RPC.
1093
+ */
1094
+ static async checkTokenBalance(credential, config, tokenAddress) {
1095
+ validateEvmAddress2(tokenAddress, config.chain, "token address");
1096
+ validateEvmAddress2(credential.safeAddress, config.chain, "safe address");
1097
+ const networkConfig = getErc4337ConfigForChain(config);
1098
+ const addr = (credential.safeAddress.startsWith("0x") ? credential.safeAddress.slice(2) : credential.safeAddress).toLowerCase().padStart(64, "0");
1099
+ const balanceCalldata = `${ERC20_BALANCE_OF_SELECTOR}${addr}`;
1100
+ const result = await jsonRpcCall(networkConfig.provider, {
1101
+ method: "eth_call",
1102
+ params: [{ to: tokenAddress, data: balanceCalldata }, "latest"]
1103
+ });
1104
+ const balance = BigInt(result);
1105
+ const usdBalance = formatTokenBalance(balance, 6);
1106
+ return {
1107
+ message: `${config.chain.toUpperCase()} balances retrieved successfully`,
1108
+ success: true,
1109
+ data: {
1110
+ tokenBalance: balance.toString(),
1111
+ decimals: 6,
1112
+ usdBalance: String(usdBalance)
1113
+ }
1114
+ };
406
1115
  }
407
1116
  };
408
1117
  export {
1118
+ EvmTransfer,
1119
+ FCL_P256_VERIFIER,
409
1120
  GAS_FEE_ESTIMATES,
410
1121
  GAS_FEE_FALLBACKS,
411
- TetherEVMERC4337Transfer,
1122
+ PasskeyTransfer,
1123
+ SAFE_WEBAUTHN_SIGNER_FACTORY,
1124
+ createCredentialStore,
412
1125
  encodeErc20Transfer,
413
1126
  feeToUsdt,
414
1127
  formatTokenBalance,
415
1128
  getErc4337ConfigForChain,
1129
+ handleTransferError,
1130
+ isPasskeyLinked,
1131
+ linkPasskeyToSafe,
416
1132
  setupErc4337Wallet,
417
- toUsdtBaseUnitsEvm
1133
+ toUsdtBaseUnitsEvm,
1134
+ unlinkPasskeyFromSafe,
1135
+ waitForUserOpConfirmation
418
1136
  };