@elytro/cli 0.1.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.
Files changed (2) hide show
  1. package/dist/index.js +4095 -0
  2. package/package.json +59 -0
package/dist/index.js ADDED
@@ -0,0 +1,4095 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/utils/graphqlClient.ts
13
+ async function requestGraphQL(options) {
14
+ const { endpoint, query, variables, headers, timeoutMs = 15e3 } = options;
15
+ const controller = new AbortController();
16
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
17
+ try {
18
+ const response = await fetch(endpoint, {
19
+ method: "POST",
20
+ headers: {
21
+ "Content-Type": "application/json",
22
+ Accept: "application/json",
23
+ ...headers
24
+ },
25
+ body: JSON.stringify({ query, variables }),
26
+ signal: controller.signal
27
+ });
28
+ if (!response.ok) {
29
+ const text = await response.text().catch(() => "");
30
+ throw new GraphQLHttpError(`HTTP ${response.status}: ${text.slice(0, 200)}`, response.status);
31
+ }
32
+ const json = await response.json();
33
+ if (json.errors && json.errors.length > 0) {
34
+ const messages = json.errors.map((e) => e.message ?? "Unknown GraphQL error").join("; ");
35
+ throw new GraphQLClientError(`GraphQL errors: ${messages}`, json.errors);
36
+ }
37
+ if (!json.data) {
38
+ throw new Error("No data returned in GraphQL response");
39
+ }
40
+ return json.data;
41
+ } catch (err) {
42
+ if (err instanceof Error) {
43
+ if (err.name === "AbortError") {
44
+ throw new Error(`Request timeout after ${timeoutMs}ms`);
45
+ }
46
+ throw err;
47
+ }
48
+ throw new Error(`Network error: ${String(err)}`);
49
+ } finally {
50
+ clearTimeout(timeoutId);
51
+ }
52
+ }
53
+ var GraphQLClientError, GraphQLHttpError;
54
+ var init_graphqlClient = __esm({
55
+ "src/utils/graphqlClient.ts"() {
56
+ "use strict";
57
+ GraphQLClientError = class extends Error {
58
+ constructor(message, errors) {
59
+ super(message);
60
+ this.errors = errors;
61
+ this.name = "GraphQLClientError";
62
+ }
63
+ };
64
+ GraphQLHttpError = class extends Error {
65
+ constructor(message, status) {
66
+ super(message);
67
+ this.status = status;
68
+ this.name = "GraphQLHttpError";
69
+ }
70
+ };
71
+ }
72
+ });
73
+
74
+ // src/utils/sponsor.ts
75
+ var sponsor_exports = {};
76
+ __export(sponsor_exports, {
77
+ applySponsorToUserOp: () => applySponsorToUserOp,
78
+ registerAccount: () => registerAccount,
79
+ requestSponsorship: () => requestSponsorship
80
+ });
81
+ import { toHex as toHex4 } from "viem";
82
+ async function registerAccount(graphqlEndpoint, address2, chainId, index, initialKeys, initialGuardianHash, initialGuardianSafePeriod) {
83
+ try {
84
+ await requestGraphQL({
85
+ endpoint: graphqlEndpoint,
86
+ query: CREATE_ACCOUNT_MUTATION,
87
+ variables: {
88
+ input: {
89
+ address: address2,
90
+ chainID: toHex4(chainId),
91
+ initInfo: {
92
+ index,
93
+ initialKeys,
94
+ initialGuardianHash,
95
+ initialGuardianSafePeriod: toHex4(initialGuardianSafePeriod)
96
+ }
97
+ }
98
+ }
99
+ });
100
+ return { success: true, error: null };
101
+ } catch (err) {
102
+ return { success: false, error: err.message };
103
+ }
104
+ }
105
+ function formatHex(value) {
106
+ if (typeof value === "string" && value.startsWith("0x")) {
107
+ return value;
108
+ }
109
+ return toHex4(value);
110
+ }
111
+ function paddingBytesToEven(value) {
112
+ if (!value) return null;
113
+ const hexValue = value.startsWith("0x") ? value.slice(2) : value;
114
+ const paddedHex = hexValue.length % 2 === 1 ? "0" + hexValue : hexValue;
115
+ return "0x" + paddedHex;
116
+ }
117
+ async function requestSponsorship(graphqlEndpoint, chainId, entryPoint, userOp) {
118
+ try {
119
+ const data = await requestGraphQL({
120
+ endpoint: graphqlEndpoint,
121
+ query: SPONSOR_OP_MUTATION,
122
+ variables: {
123
+ input: {
124
+ chainID: toHex4(chainId),
125
+ entryPoint,
126
+ op: {
127
+ sender: userOp.sender,
128
+ nonce: formatHex(userOp.nonce),
129
+ factory: userOp.factory,
130
+ factoryData: userOp.factory === null ? null : paddingBytesToEven(userOp.factoryData),
131
+ callData: userOp.callData,
132
+ callGasLimit: formatHex(userOp.callGasLimit),
133
+ verificationGasLimit: formatHex(userOp.verificationGasLimit),
134
+ preVerificationGas: formatHex(userOp.preVerificationGas),
135
+ maxFeePerGas: formatHex(userOp.maxFeePerGas),
136
+ maxPriorityFeePerGas: formatHex(userOp.maxPriorityFeePerGas),
137
+ signature: SPONSOR_DUMMY_SIGNATURE
138
+ }
139
+ }
140
+ }
141
+ });
142
+ if (!data.sponsorOp) {
143
+ return { sponsor: null, error: "No sponsorOp in response data" };
144
+ }
145
+ const sponsor = data.sponsorOp;
146
+ if (!sponsor.paymaster) {
147
+ return { sponsor: null, error: "Sponsor returned empty paymaster address" };
148
+ }
149
+ return {
150
+ sponsor: {
151
+ paymaster: sponsor.paymaster,
152
+ paymasterData: sponsor.paymasterData,
153
+ callGasLimit: sponsor.callGasLimit,
154
+ verificationGasLimit: sponsor.verificationGasLimit,
155
+ preVerificationGas: sponsor.preVerificationGas,
156
+ paymasterVerificationGasLimit: sponsor.paymasterVerificationGasLimit,
157
+ paymasterPostOpGasLimit: sponsor.paymasterPostOpGasLimit
158
+ },
159
+ error: null
160
+ };
161
+ } catch (err) {
162
+ return { sponsor: null, error: err.message };
163
+ }
164
+ }
165
+ function applySponsorToUserOp(userOp, sponsor) {
166
+ userOp.paymaster = sponsor.paymaster;
167
+ userOp.paymasterData = sponsor.paymasterData;
168
+ if (sponsor.callGasLimit) {
169
+ userOp.callGasLimit = BigInt(sponsor.callGasLimit);
170
+ }
171
+ if (sponsor.verificationGasLimit) {
172
+ userOp.verificationGasLimit = BigInt(sponsor.verificationGasLimit);
173
+ }
174
+ if (sponsor.preVerificationGas) {
175
+ userOp.preVerificationGas = BigInt(sponsor.preVerificationGas);
176
+ }
177
+ if (sponsor.paymasterVerificationGasLimit) {
178
+ userOp.paymasterVerificationGasLimit = BigInt(sponsor.paymasterVerificationGasLimit);
179
+ }
180
+ if (sponsor.paymasterPostOpGasLimit) {
181
+ userOp.paymasterPostOpGasLimit = BigInt(sponsor.paymasterPostOpGasLimit);
182
+ }
183
+ }
184
+ var SPONSOR_DUMMY_SIGNATURE, CREATE_ACCOUNT_MUTATION, SPONSOR_OP_MUTATION;
185
+ var init_sponsor = __esm({
186
+ "src/utils/sponsor.ts"() {
187
+ "use strict";
188
+ init_graphqlClient();
189
+ SPONSOR_DUMMY_SIGNATURE = "0xea50a2874df3eEC9E0365425ba948989cd63FED6000000620100005f5e0fff000fffffffff0000000000000000000000000000000000000000b91467e570a6466aa9e9876cbcd013baba02900b8979d43fe208a4a4f339f5fd6007e74cd82e037b800186422fc2da167c747ef045e5d18a5f5d4300f8e1a0291c";
190
+ CREATE_ACCOUNT_MUTATION = `
191
+ mutation CreateAccount($input: CreateAccountInput!) {
192
+ createAccount(input: $input) {
193
+ address
194
+ chainID
195
+ initInfo {
196
+ index
197
+ initialKeys
198
+ initialGuardianHash
199
+ initialGuardianSafePeriod
200
+ }
201
+ }
202
+ }
203
+ `;
204
+ SPONSOR_OP_MUTATION = `
205
+ mutation SponsorOp($input: SponsorOpInput!) {
206
+ sponsorOp(input: $input) {
207
+ callGasLimit
208
+ paymaster
209
+ paymasterData
210
+ paymasterPostOpGasLimit
211
+ paymasterVerificationGasLimit
212
+ preVerificationGas
213
+ verificationGasLimit
214
+ }
215
+ }
216
+ `;
217
+ }
218
+ });
219
+
220
+ // src/index.ts
221
+ import { Command } from "commander";
222
+
223
+ // src/storage/fileStore.ts
224
+ import { readFile, writeFile, mkdir, access } from "fs/promises";
225
+ import { join, dirname } from "path";
226
+ import { homedir } from "os";
227
+ var FileStore = class {
228
+ root;
229
+ constructor(root) {
230
+ this.root = root ?? join(homedir(), ".elytro");
231
+ }
232
+ filePath(key) {
233
+ return join(this.root, `${key}.json`);
234
+ }
235
+ async load(key) {
236
+ const path = this.filePath(key);
237
+ try {
238
+ const raw = await readFile(path, "utf-8");
239
+ return JSON.parse(raw);
240
+ } catch (err) {
241
+ if (err.code === "ENOENT") {
242
+ return null;
243
+ }
244
+ throw err;
245
+ }
246
+ }
247
+ async save(key, data) {
248
+ const path = this.filePath(key);
249
+ await mkdir(dirname(path), { recursive: true });
250
+ await writeFile(path, JSON.stringify(data, null, 2), "utf-8");
251
+ }
252
+ async remove(key) {
253
+ const { unlink } = await import("fs/promises");
254
+ const path = this.filePath(key);
255
+ try {
256
+ await unlink(path);
257
+ } catch (err) {
258
+ if (err.code !== "ENOENT") {
259
+ throw err;
260
+ }
261
+ }
262
+ }
263
+ async exists(key) {
264
+ const path = this.filePath(key);
265
+ try {
266
+ await access(path);
267
+ return true;
268
+ } catch {
269
+ return false;
270
+ }
271
+ }
272
+ /** Ensure the root directory exists. Call once at startup. */
273
+ async init() {
274
+ await mkdir(this.root, { recursive: true });
275
+ }
276
+ get dataDir() {
277
+ return this.root;
278
+ }
279
+ };
280
+
281
+ // src/services/keyring.ts
282
+ import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
283
+
284
+ // src/utils/passworder.ts
285
+ import { webcrypto } from "crypto";
286
+ var ITERATIONS = 1e5;
287
+ var KEY_LENGTH = 256;
288
+ var ALGORITHM = "AES-GCM";
289
+ var subtle = webcrypto.subtle;
290
+ async function deriveKey(password2, salt) {
291
+ const encoder = new TextEncoder();
292
+ const keyMaterial = await subtle.importKey("raw", encoder.encode(password2), "PBKDF2", false, ["deriveKey"]);
293
+ return subtle.deriveKey(
294
+ {
295
+ name: "PBKDF2",
296
+ salt,
297
+ iterations: ITERATIONS,
298
+ hash: "SHA-256"
299
+ },
300
+ keyMaterial,
301
+ { name: ALGORITHM, length: KEY_LENGTH },
302
+ false,
303
+ ["encrypt", "decrypt"]
304
+ );
305
+ }
306
+ async function encrypt(password2, data) {
307
+ const salt = webcrypto.getRandomValues(new Uint8Array(16));
308
+ const iv = webcrypto.getRandomValues(new Uint8Array(12));
309
+ const key = await deriveKey(password2, salt);
310
+ const encoder = new TextEncoder();
311
+ const plaintext = encoder.encode(JSON.stringify(data));
312
+ const ciphertext = await subtle.encrypt({ name: ALGORITHM, iv }, key, plaintext);
313
+ return {
314
+ data: toHex(ciphertext),
315
+ iv: toHex(iv),
316
+ salt: toHex(salt),
317
+ version: 1
318
+ };
319
+ }
320
+ async function decrypt(password2, encrypted) {
321
+ const salt = fromHex(encrypted.salt);
322
+ const iv = fromHex(encrypted.iv);
323
+ const ciphertext = fromHex(encrypted.data);
324
+ const key = await deriveKey(password2, salt);
325
+ const plaintext = await subtle.decrypt({ name: ALGORITHM, iv }, key, ciphertext);
326
+ const decoder = new TextDecoder();
327
+ return JSON.parse(decoder.decode(plaintext));
328
+ }
329
+ async function importRawKey(rawKey) {
330
+ return subtle.importKey("raw", rawKey, { name: ALGORITHM, length: KEY_LENGTH }, false, ["encrypt", "decrypt"]);
331
+ }
332
+ async function encryptWithKey(rawKey, data) {
333
+ const iv = webcrypto.getRandomValues(new Uint8Array(12));
334
+ const key = await importRawKey(rawKey);
335
+ const encoder = new TextEncoder();
336
+ const plaintext = encoder.encode(JSON.stringify(data));
337
+ const ciphertext = await subtle.encrypt({ name: ALGORITHM, iv }, key, plaintext);
338
+ return {
339
+ data: toHex(ciphertext),
340
+ iv: toHex(iv),
341
+ salt: "",
342
+ version: 2
343
+ };
344
+ }
345
+ async function decryptWithKey(rawKey, encrypted) {
346
+ const iv = fromHex(encrypted.iv);
347
+ const ciphertext = fromHex(encrypted.data);
348
+ const key = await importRawKey(rawKey);
349
+ const plaintext = await subtle.decrypt({ name: ALGORITHM, iv }, key, ciphertext);
350
+ const decoder = new TextDecoder();
351
+ return JSON.parse(decoder.decode(plaintext));
352
+ }
353
+ function toHex(buffer) {
354
+ const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
355
+ return Buffer.from(bytes).toString("hex");
356
+ }
357
+ function fromHex(hex) {
358
+ return new Uint8Array(Buffer.from(hex, "hex"));
359
+ }
360
+
361
+ // src/services/keyring.ts
362
+ var STORAGE_KEY = "keyring";
363
+ var KeyringService = class {
364
+ store;
365
+ vault = null;
366
+ constructor(store) {
367
+ this.store = store;
368
+ }
369
+ // ─── Initialization ─────────────────────────────────────────────
370
+ /** Check if a vault (encrypted keyring) already exists on disk. */
371
+ async isInitialized() {
372
+ return this.store.exists(STORAGE_KEY);
373
+ }
374
+ /**
375
+ * Create a brand-new vault with one owner.
376
+ * Called during `elytro init`. Encrypts with device key.
377
+ */
378
+ async createNewOwner(deviceKey) {
379
+ const privateKey = generatePrivateKey();
380
+ const account = privateKeyToAccount(privateKey);
381
+ const owner = { id: account.address, key: privateKey };
382
+ const vault = {
383
+ owners: [owner],
384
+ currentOwnerId: account.address
385
+ };
386
+ const encrypted = await encryptWithKey(deviceKey, vault);
387
+ await this.store.save(STORAGE_KEY, encrypted);
388
+ this.vault = vault;
389
+ return account.address;
390
+ }
391
+ // ─── Unlock / Access ────────────────────────────────────────────
392
+ /**
393
+ * Decrypt the vault with the device key.
394
+ * Called automatically by context at CLI startup.
395
+ */
396
+ async unlock(deviceKey) {
397
+ const encrypted = await this.store.load(STORAGE_KEY);
398
+ if (!encrypted) {
399
+ throw new Error("Keyring not initialized. Run `elytro init` first.");
400
+ }
401
+ this.vault = await decryptWithKey(deviceKey, encrypted);
402
+ }
403
+ /** Lock the vault, clearing decrypted keys from memory. */
404
+ lock() {
405
+ this.vault = null;
406
+ }
407
+ get isUnlocked() {
408
+ return this.vault !== null;
409
+ }
410
+ // ─── Current owner ──────────────────────────────────────────────
411
+ get currentOwner() {
412
+ return this.vault?.currentOwnerId ?? null;
413
+ }
414
+ get owners() {
415
+ return this.vault?.owners.map((o) => o.id) ?? [];
416
+ }
417
+ // ─── Signing ────────────────────────────────────────────────────
418
+ async signMessage(message) {
419
+ const key = this.getCurrentKey();
420
+ const account = privateKeyToAccount(key);
421
+ return account.signMessage({ message: { raw: message } });
422
+ }
423
+ /**
424
+ * Raw ECDSA sign over a 32-byte digest (no EIP-191 prefix).
425
+ *
426
+ * Equivalent to extension's `ethers.SigningKey.signDigest()`.
427
+ * Used for ERC-4337 UserOperation signing where the hash is
428
+ * already computed by the SDK (userOpHash → packRawHash).
429
+ */
430
+ async signDigest(digest) {
431
+ const key = this.getCurrentKey();
432
+ const account = privateKeyToAccount(key);
433
+ return account.sign({ hash: digest });
434
+ }
435
+ /**
436
+ * Get a viem LocalAccount for the current owner.
437
+ * Useful for SDK operations that need a signer.
438
+ */
439
+ getAccount() {
440
+ const key = this.getCurrentKey();
441
+ return privateKeyToAccount(key);
442
+ }
443
+ // ─── Multi-owner management ─────────────────────────────────────
444
+ async addOwner(deviceKey) {
445
+ this.ensureUnlocked();
446
+ const privateKey = generatePrivateKey();
447
+ const account = privateKeyToAccount(privateKey);
448
+ this.vault.owners.push({ id: account.address, key: privateKey });
449
+ await this.persistVault(deviceKey);
450
+ return account.address;
451
+ }
452
+ async switchOwner(ownerId, deviceKey) {
453
+ this.ensureUnlocked();
454
+ const exists = this.vault.owners.some((o) => o.id === ownerId);
455
+ if (!exists) {
456
+ throw new Error(`Owner ${ownerId} not found in vault.`);
457
+ }
458
+ this.vault.currentOwnerId = ownerId;
459
+ await this.persistVault(deviceKey);
460
+ }
461
+ // ─── Export / Import (password-based for portability) ───────────
462
+ /**
463
+ * Export vault encrypted with a user-provided password.
464
+ * The output can be imported on another device.
465
+ */
466
+ async exportVault(password2) {
467
+ this.ensureUnlocked();
468
+ return encrypt(password2, this.vault);
469
+ }
470
+ /**
471
+ * Import vault from a password-encrypted backup.
472
+ * Decrypts with the backup password, then re-encrypts with device key.
473
+ */
474
+ async importVault(encrypted, password2, deviceKey) {
475
+ const vault = await decrypt(password2, encrypted);
476
+ this.vault = vault;
477
+ const reEncrypted = await encryptWithKey(deviceKey, vault);
478
+ await this.store.save(STORAGE_KEY, reEncrypted);
479
+ }
480
+ // ─── Rekey (device key rotation) ───────────────────────────────
481
+ async rekey(newDeviceKey) {
482
+ this.ensureUnlocked();
483
+ await this.persistVault(newDeviceKey);
484
+ }
485
+ // ─── Internal ───────────────────────────────────────────────────
486
+ getCurrentKey() {
487
+ if (!this.vault) {
488
+ throw new Error("Keyring is locked. Cannot sign.");
489
+ }
490
+ const owner = this.vault.owners.find((o) => o.id === this.vault.currentOwnerId);
491
+ if (!owner) {
492
+ throw new Error("Current owner key not found in vault.");
493
+ }
494
+ return owner.key;
495
+ }
496
+ ensureUnlocked() {
497
+ if (!this.vault) {
498
+ throw new Error("Keyring is locked. Run `elytro init` first.");
499
+ }
500
+ }
501
+ async persistVault(deviceKey) {
502
+ if (!this.vault) throw new Error("No vault to persist.");
503
+ const encrypted = await encryptWithKey(deviceKey, this.vault);
504
+ await this.store.save(STORAGE_KEY, encrypted);
505
+ }
506
+ };
507
+
508
+ // src/utils/config.ts
509
+ var PUBLIC_RPC = {
510
+ 1: "https://ethereum-rpc.publicnode.com",
511
+ 10: "https://optimism-rpc.publicnode.com",
512
+ 42161: "https://arbitrum-one-rpc.publicnode.com",
513
+ 11155111: "https://ethereum-sepolia-rpc.publicnode.com",
514
+ 11155420: "https://optimism-sepolia-rpc.publicnode.com"
515
+ };
516
+ var PUBLIC_BUNDLER = {
517
+ 1: "https://public.pimlico.io/v2/1/rpc",
518
+ 10: "https://public.pimlico.io/v2/10/rpc",
519
+ 42161: "https://public.pimlico.io/v2/42161/rpc",
520
+ 11155111: "https://public.pimlico.io/v2/11155111/rpc",
521
+ 11155420: "https://public.pimlico.io/v2/11155420/rpc"
522
+ };
523
+ function pimlicoUrl(chainSlug, key) {
524
+ return `https://api.pimlico.io/v2/${chainSlug}/rpc?apikey=${key}`;
525
+ }
526
+ function alchemyUrl(network, key) {
527
+ return `https://${network}.g.alchemy.com/v2/${key}`;
528
+ }
529
+ var ALCHEMY_NETWORK = {
530
+ 1: "eth-mainnet",
531
+ 10: "opt-mainnet",
532
+ 42161: "arb-mainnet",
533
+ 11155111: "eth-sepolia",
534
+ 11155420: "opt-sepolia"
535
+ };
536
+ var PIMLICO_SLUG = {
537
+ 1: "ethereum",
538
+ 10: "optimism",
539
+ 42161: "arbitrum",
540
+ 11155111: "sepolia",
541
+ 11155420: "optimism-sepolia"
542
+ };
543
+ function resolveEndpoint(chainId, alchemyKey) {
544
+ if (alchemyKey) {
545
+ const network = ALCHEMY_NETWORK[chainId];
546
+ if (network) return alchemyUrl(network, alchemyKey);
547
+ }
548
+ return PUBLIC_RPC[chainId] ?? PUBLIC_RPC[11155420];
549
+ }
550
+ function resolveBundler(chainId, pimlicoKey) {
551
+ if (pimlicoKey) {
552
+ const slug = PIMLICO_SLUG[chainId];
553
+ if (slug) return pimlicoUrl(slug, pimlicoKey);
554
+ }
555
+ return PUBLIC_BUNDLER[chainId] ?? PUBLIC_BUNDLER[11155420];
556
+ }
557
+ var CHAIN_META = [
558
+ { id: 1, name: "Ethereum", nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, blockExplorer: "https://etherscan.io" },
559
+ { id: 10, name: "Optimism", nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, blockExplorer: "https://optimistic.etherscan.io" },
560
+ { id: 42161, name: "Arbitrum One", nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, blockExplorer: "https://arbiscan.io" },
561
+ { id: 11155111, name: "Sepolia", nativeCurrency: { name: "Sepolia ETH", symbol: "ETH", decimals: 18 }, blockExplorer: "https://sepolia.etherscan.io" },
562
+ { id: 11155420, name: "Optimism Sepolia", nativeCurrency: { name: "Sepolia ETH", symbol: "ETH", decimals: 18 }, blockExplorer: "https://sepolia-optimism.etherscan.io" }
563
+ ];
564
+ function buildChains(alchemyKey, pimlicoKey) {
565
+ return CHAIN_META.map((meta) => ({
566
+ ...meta,
567
+ endpoint: resolveEndpoint(meta.id, alchemyKey),
568
+ bundler: resolveBundler(meta.id, pimlicoKey)
569
+ }));
570
+ }
571
+ var GRAPHQL_ENDPOINTS = {
572
+ development: "https://api-dev.soulwallet.io/elytroapi/graphql/",
573
+ production: "https://api.soulwallet.io/elytroapi/graphql/"
574
+ };
575
+ function getDefaultConfig() {
576
+ const env = process.env.ELYTRO_ENV ?? "production";
577
+ return {
578
+ currentChainId: 11155420,
579
+ // Default to OP Sepolia for safety
580
+ chains: buildChains(),
581
+ graphqlEndpoint: GRAPHQL_ENDPOINTS[env] ?? GRAPHQL_ENDPOINTS["development"]
582
+ };
583
+ }
584
+
585
+ // src/services/chain.ts
586
+ var STORAGE_KEY2 = "config";
587
+ var USER_KEYS_KEY = "user-keys";
588
+ var ChainService = class {
589
+ store;
590
+ config;
591
+ userKeys = {};
592
+ constructor(store) {
593
+ this.store = store;
594
+ this.config = getDefaultConfig();
595
+ }
596
+ /** Load persisted config and user keys, rebuild chain endpoints. */
597
+ async init() {
598
+ this.userKeys = await this.store.load(USER_KEYS_KEY) ?? {};
599
+ const saved = await this.store.load(STORAGE_KEY2);
600
+ if (saved) {
601
+ this.config = { ...getDefaultConfig(), ...saved };
602
+ }
603
+ this.config.chains = buildChains(this.userKeys.alchemyKey, this.userKeys.pimlicoKey);
604
+ }
605
+ // ─── User Keys ──────────────────────────────────────────────────
606
+ /** Get current user keys (for display — values are masked). */
607
+ getUserKeys() {
608
+ return { ...this.userKeys };
609
+ }
610
+ /** Set a user API key and rebuild chain endpoints. */
611
+ async setUserKey(key, value) {
612
+ this.userKeys[key] = value;
613
+ await this.store.save(USER_KEYS_KEY, this.userKeys);
614
+ this.config.chains = buildChains(this.userKeys.alchemyKey, this.userKeys.pimlicoKey);
615
+ }
616
+ /** Remove a user API key and fall back to env / public endpoints. */
617
+ async removeUserKey(key) {
618
+ delete this.userKeys[key];
619
+ await this.store.save(USER_KEYS_KEY, this.userKeys);
620
+ this.config.chains = buildChains(this.userKeys.alchemyKey, this.userKeys.pimlicoKey);
621
+ }
622
+ // ─── Getters ────────────────────────────────────────────────────
623
+ get currentChain() {
624
+ const chain = this.config.chains.find((c) => c.id === this.config.currentChainId);
625
+ if (!chain) {
626
+ throw new Error(`Chain ${this.config.currentChainId} not found in config.`);
627
+ }
628
+ return chain;
629
+ }
630
+ get currentChainId() {
631
+ return this.config.currentChainId;
632
+ }
633
+ get chains() {
634
+ return this.config.chains;
635
+ }
636
+ get graphqlEndpoint() {
637
+ return this.config.graphqlEndpoint;
638
+ }
639
+ get fullConfig() {
640
+ return { ...this.config };
641
+ }
642
+ // ─── Mutations ──────────────────────────────────────────────────
643
+ async switchChain(chainId) {
644
+ const chain = this.config.chains.find((c) => c.id === chainId);
645
+ if (!chain) {
646
+ throw new Error(`Chain ${chainId} is not configured.`);
647
+ }
648
+ this.config.currentChainId = chainId;
649
+ await this.persist();
650
+ return chain;
651
+ }
652
+ async addChain(chain) {
653
+ if (this.config.chains.some((c) => c.id === chain.id)) {
654
+ throw new Error(`Chain ${chain.id} already exists.`);
655
+ }
656
+ this.config.chains.push(chain);
657
+ await this.persist();
658
+ }
659
+ async removeChain(chainId) {
660
+ if (chainId === this.config.currentChainId) {
661
+ throw new Error("Cannot remove the currently selected chain.");
662
+ }
663
+ this.config.chains = this.config.chains.filter((c) => c.id !== chainId);
664
+ await this.persist();
665
+ }
666
+ // ─── Internal ───────────────────────────────────────────────────
667
+ async persist() {
668
+ await this.store.save(STORAGE_KEY2, this.config);
669
+ }
670
+ };
671
+
672
+ // src/services/sdk.ts
673
+ import { padHex, createPublicClient, http, toHex as toHex2, parseEther } from "viem";
674
+ var DEFAULT_GUARDIAN_HASH = "0x0000000000000000000000000000000000000000000000000000000000000001";
675
+ var DEFAULT_GUARDIAN_SAFE_PERIOD = 172800;
676
+ var ENTRYPOINT_CONFIGS = {
677
+ "v0.7": {
678
+ entryPoint: "0x0000000071727De22E5E9d8BAf0edAc6f37da032",
679
+ factory: "0x70B616f23bDDB18c5c412dB367568Dc360e224Bb",
680
+ fallback: "0xe4eA02c80C3CD86B2f23c8158acF2AAFcCa5A6b3",
681
+ recovery: "0x36693563E41BcBdC8d295bD3C2608eb7c32b1cCb",
682
+ validator: "0x162485941bA1FAF21013656DAB1E60e9D7226DC0",
683
+ elytroWalletLogic: "0x186b91aE45dd22dEF329BF6b4233cf910E157C84"
684
+ },
685
+ "v0.8": {
686
+ entryPoint: "0x4337084d9e255ff0702461cf8895ce9e3b5ff108",
687
+ factory: "0x82a8B1a5986f565a1546672e8939daA1b20F441E",
688
+ fallback: "0xB73Ec2FD0189202F6C22067Eeb19EAad25CAB551",
689
+ recovery: "0xAFEF5D8Fb7b4650B1724a23e40633f720813c731",
690
+ validator: "0xea50a2874df3eEC9E0365425ba948989cd63FED6",
691
+ elytroWalletLogic: "0x2CC8A41e26dAC15F1D11F333f74D0451be6caE36"
692
+ }
693
+ };
694
+ var DEFAULT_VERSION = "v0.8";
695
+ var DUMMY_SIGNATURE = "0xea50a2874df3eEC9E0365425ba948989cd63FED6000000620100005f5e0fff000fffffffff0000000000000000000000000000000000000000b91467e570a6466aa9e9876cbcd013baba02900b8979d43fe208a4a4f339f5fd6007e74cd82e037b800186422fc2da167c747ef045e5d18a5f5d4300f8e1a0291c";
696
+ var SDKService = class {
697
+ sdk = null;
698
+ bundlerInstance = null;
699
+ chainConfig = null;
700
+ contractConfig = ENTRYPOINT_CONFIGS[DEFAULT_VERSION];
701
+ async initForChain(chainConfig, entrypointVersion = DEFAULT_VERSION) {
702
+ this.chainConfig = chainConfig;
703
+ this.contractConfig = ENTRYPOINT_CONFIGS[entrypointVersion] ?? ENTRYPOINT_CONFIGS[DEFAULT_VERSION];
704
+ const { ElytroWallet, Bundler } = await import("@elytro/sdk");
705
+ this.sdk = new ElytroWallet(
706
+ chainConfig.endpoint,
707
+ chainConfig.bundler,
708
+ this.contractConfig.factory,
709
+ this.contractConfig.fallback,
710
+ this.contractConfig.recovery,
711
+ {
712
+ chainId: chainConfig.id,
713
+ entryPoint: this.contractConfig.entryPoint,
714
+ elytroWalletLogic: this.contractConfig.elytroWalletLogic
715
+ }
716
+ );
717
+ this.bundlerInstance = new Bundler(chainConfig.bundler);
718
+ }
719
+ // ─── Phase 1: Address Calculation ──────────────────────────────
720
+ /**
721
+ * Calculate the counterfactual smart account address via CREATE2.
722
+ *
723
+ * The contract doesn't exist on-chain yet, but this address is
724
+ * deterministic — guaranteed to be where it will deploy.
725
+ */
726
+ async calcWalletAddress(eoaAddress, chainId, index = 0, initialGuardianHash = DEFAULT_GUARDIAN_HASH, initialGuardianSafePeriod = DEFAULT_GUARDIAN_SAFE_PERIOD) {
727
+ const sdk = this.ensureSDK();
728
+ const paddedKey = padHex(eoaAddress, { size: 32 });
729
+ const result = await sdk.calcWalletAddress(
730
+ index,
731
+ [paddedKey],
732
+ initialGuardianHash,
733
+ initialGuardianSafePeriod,
734
+ chainId
735
+ );
736
+ if (result.isErr()) {
737
+ throw new Error(`Failed to calculate wallet address: ${result.ERR}`);
738
+ }
739
+ return result.OK;
740
+ }
741
+ // ─── Phase 2: UserOp Lifecycle ─────────────────────────────────
742
+ /**
743
+ * Create an unsigned UserOperation from transactions.
744
+ *
745
+ * Wraps the SDK's `fromTransaction()` which handles:
746
+ * - Nonce fetching from the on-chain wallet
747
+ * - callData encoding (single execute / batch executeBatch)
748
+ * - Setting factory/factoryData to null (non-deploy op)
749
+ *
750
+ * Extension reference: sdk.ts#createUserOpFromTxs (line 1115-1122).
751
+ *
752
+ * @param senderAddress Deployed smart account address
753
+ * @param txs Array of { to, value?, data? } in hex string format
754
+ */
755
+ async createSendUserOp(senderAddress, txs) {
756
+ const sdk = this.ensureSDK();
757
+ const result = await sdk.fromTransaction("0x1", "0x1", senderAddress, txs);
758
+ if (result.isErr()) {
759
+ throw new Error(`Failed to build send UserOp: ${result.ERR}`);
760
+ }
761
+ return this.normalizeUserOp(result.OK);
762
+ }
763
+ /**
764
+ * Create an unsigned deploy UserOperation.
765
+ *
766
+ * Builds a UserOp with factory + factoryData that, when sent to the
767
+ * bundler, deploys the smart wallet contract at the counterfactual address.
768
+ */
769
+ async createDeployUserOp(eoaAddress, index = 0, initialGuardianHash = DEFAULT_GUARDIAN_HASH, initialGuardianSafePeriod = DEFAULT_GUARDIAN_SAFE_PERIOD) {
770
+ const sdk = this.ensureSDK();
771
+ const paddedKey = padHex(eoaAddress, { size: 32 });
772
+ const result = await sdk.createUnsignedDeployWalletUserOp(
773
+ index,
774
+ [paddedKey],
775
+ initialGuardianHash,
776
+ void 0,
777
+ // callData
778
+ initialGuardianSafePeriod
779
+ );
780
+ if (result.isErr()) {
781
+ throw new Error(`Failed to create deploy UserOp: ${result.ERR}`);
782
+ }
783
+ return this.normalizeUserOp(result.OK);
784
+ }
785
+ /**
786
+ * Get gas price from Pimlico bundler.
787
+ *
788
+ * Uses the non-standard `pimlico_getUserOperationGasPrice` RPC method.
789
+ * Returns { maxFeePerGas, maxPriorityFeePerGas } from the "fast" tier.
790
+ */
791
+ async getFeeData(chainConfig) {
792
+ const client = createPublicClient({
793
+ transport: http(chainConfig.bundler)
794
+ });
795
+ try {
796
+ const result = await client.request({
797
+ method: "pimlico_getUserOperationGasPrice",
798
+ params: []
799
+ });
800
+ const fast = result?.fast;
801
+ if (!fast) {
802
+ throw new Error("Unexpected response from pimlico_getUserOperationGasPrice");
803
+ }
804
+ return {
805
+ maxFeePerGas: BigInt(fast.maxFeePerGas),
806
+ maxPriorityFeePerGas: BigInt(fast.maxPriorityFeePerGas)
807
+ };
808
+ } catch {
809
+ const gasPrice = await createPublicClient({
810
+ transport: http(chainConfig.endpoint)
811
+ }).getGasPrice();
812
+ return {
813
+ maxFeePerGas: gasPrice,
814
+ maxPriorityFeePerGas: gasPrice / 10n
815
+ };
816
+ }
817
+ }
818
+ /**
819
+ * Estimate gas limits for a UserOperation via the bundler.
820
+ *
821
+ * Sets a dummy signature for estimation (same as extension).
822
+ * For undeployed accounts, pass `fakeBalance: true` to inject a
823
+ * state override that gives the sender 1 ETH — prevents AA21.
824
+ *
825
+ * Extension reference: sdk.ts#estimateGas (lines 602-650).
826
+ */
827
+ async estimateUserOp(userOp, opts = {}) {
828
+ const sdk = this.ensureSDK();
829
+ const opForEstimate = { ...userOp, signature: DUMMY_SIGNATURE };
830
+ const stateOverride = opts.fakeBalance ? { [userOp.sender]: { balance: toHex2(parseEther("1")) } } : void 0;
831
+ const result = await sdk.estimateUserOperationGas(
832
+ this.contractConfig.validator,
833
+ this.toSDKUserOp(opForEstimate),
834
+ stateOverride
835
+ );
836
+ if (result.isErr()) {
837
+ const err = result.ERR;
838
+ throw new Error(
839
+ `Gas estimation failed: ${typeof err === "object" && err !== null && "message" in err ? err.message : String(err)}`
840
+ );
841
+ }
842
+ const gas = result.OK;
843
+ return {
844
+ callGasLimit: BigInt(gas.callGasLimit),
845
+ verificationGasLimit: BigInt(gas.verificationGasLimit),
846
+ preVerificationGas: BigInt(gas.preVerificationGas),
847
+ paymasterVerificationGasLimit: gas.paymasterVerificationGasLimit ? BigInt(gas.paymasterVerificationGasLimit) : null,
848
+ paymasterPostOpGasLimit: gas.paymasterPostOpGasLimit ? BigInt(gas.paymasterPostOpGasLimit) : null
849
+ };
850
+ }
851
+ /**
852
+ * Compute the ERC-4337 UserOperation hash.
853
+ *
854
+ * Two-step: userOpHash → packRawHash to get the final digest for signing.
855
+ */
856
+ async getUserOpHash(userOp) {
857
+ const sdk = this.ensureSDK();
858
+ const hashResult = await sdk.userOpHash(this.toSDKUserOp(userOp));
859
+ if (hashResult.isErr()) {
860
+ throw new Error(`Failed to compute userOpHash: ${hashResult.ERR}`);
861
+ }
862
+ const packResult = await sdk.packRawHash(hashResult.OK);
863
+ if (packResult.isErr()) {
864
+ throw new Error(`Failed to pack raw hash: ${packResult.ERR}`);
865
+ }
866
+ return {
867
+ packedHash: packResult.OK.packedHash,
868
+ validationData: packResult.OK.validationData
869
+ };
870
+ }
871
+ /**
872
+ * Pack a raw ECDSA signature into the format expected by the EntryPoint.
873
+ *
874
+ * Wraps the signature with validator address and validation data.
875
+ */
876
+ async packUserOpSignature(rawSignature, validationData) {
877
+ const sdk = this.ensureSDK();
878
+ const result = await sdk.packUserOpEOASignature(this.contractConfig.validator, rawSignature, validationData);
879
+ if (result.isErr()) {
880
+ throw new Error(`Failed to pack signature: ${result.ERR}`);
881
+ }
882
+ return result.OK;
883
+ }
884
+ /**
885
+ * Pack a raw ECDSA signature with hook input data.
886
+ *
887
+ * Used when SecurityHook is installed: the hook signature from the backend
888
+ * must be included alongside the EOA signature.
889
+ *
890
+ * Extension reference: sdk.ts#signUserOperationWithHook (line 239-318)
891
+ *
892
+ * @param rawSignature Raw ECDSA signature from device key
893
+ * @param validationData Validation data from packRawHash
894
+ * @param hookAddress SecurityHook contract address
895
+ * @param hookSignature Hook signature from authorizeUserOperation backend
896
+ */
897
+ async packUserOpSignatureWithHook(rawSignature, validationData, hookAddress, hookSignature) {
898
+ const sdk = this.ensureSDK();
899
+ const hookInputData = [
900
+ {
901
+ hookAddress,
902
+ inputData: hookSignature
903
+ }
904
+ ];
905
+ const result = await sdk.packUserOpEOASignature(
906
+ this.contractConfig.validator,
907
+ rawSignature,
908
+ validationData,
909
+ hookInputData
910
+ );
911
+ if (result.isErr()) {
912
+ throw new Error(`Failed to pack signature with hook: ${result.ERR}`);
913
+ }
914
+ return result.OK;
915
+ }
916
+ /**
917
+ * Pack a raw hash with validation time bounds.
918
+ *
919
+ * Used for EIP-1271 auth signing flow — takes an arbitrary message hash
920
+ * (not a userOp hash) and returns packedHash + validationData.
921
+ */
922
+ async packRawHash(hash) {
923
+ const sdk = this.ensureSDK();
924
+ const result = await sdk.packRawHash(hash);
925
+ if (result.isErr()) {
926
+ throw new Error(`Failed to pack raw hash: ${result.ERR}`);
927
+ }
928
+ return {
929
+ packedHash: result.OK.packedHash,
930
+ validationData: result.OK.validationData
931
+ };
932
+ }
933
+ /**
934
+ * Send a signed UserOperation to the bundler.
935
+ */
936
+ async sendUserOp(userOp) {
937
+ const sdk = this.ensureSDK();
938
+ const sendResult = await sdk.sendUserOperation(this.toSDKUserOp(userOp));
939
+ if (sendResult.isErr()) {
940
+ const err = sendResult.ERR;
941
+ throw new Error(
942
+ `Failed to send UserOp: ${typeof err === "object" && err !== null && "message" in err ? err.message : String(err)}`
943
+ );
944
+ }
945
+ const hashResult = await sdk.userOpHash(this.toSDKUserOp(userOp));
946
+ if (hashResult.isErr()) {
947
+ throw new Error(`UserOp sent but failed to compute hash for tracking: ${hashResult.ERR}`);
948
+ }
949
+ return hashResult.OK;
950
+ }
951
+ /**
952
+ * Poll the bundler for a UserOperation receipt.
953
+ *
954
+ * Exponential backoff: 2s → 1.5× → cap 15s, max 30 attempts (~90s).
955
+ */
956
+ async waitForReceipt(opHash) {
957
+ const bundler = this.ensureBundler();
958
+ let delay = 2e3;
959
+ const maxDelay = 15e3;
960
+ const maxAttempts = 30;
961
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
962
+ await sleep(delay);
963
+ const result = await bundler.eth_getUserOperationReceipt(opHash);
964
+ if (result.isErr()) {
965
+ throw new Error(`Failed to poll receipt: ${result.ERR}`);
966
+ }
967
+ const receipt = result.OK;
968
+ if (receipt) {
969
+ return {
970
+ success: receipt.success,
971
+ actualGasCost: String(receipt.actualGasCost),
972
+ actualGasUsed: String(receipt.actualGasUsed),
973
+ transactionHash: receipt.receipt?.transactionHash ?? opHash,
974
+ blockNumber: String(receipt.receipt?.blockNumber ?? "0")
975
+ };
976
+ }
977
+ delay = Math.min(Math.floor(delay * 1.5), maxDelay);
978
+ }
979
+ throw new Error(`UserOperation receipt not found after ${maxAttempts} attempts (~90s). Hash: ${opHash}`);
980
+ }
981
+ // ─── Accessors ─────────────────────────────────────────────────
982
+ get isInitialized() {
983
+ return this.sdk !== null;
984
+ }
985
+ get contracts() {
986
+ return this.contractConfig;
987
+ }
988
+ get entryPoint() {
989
+ return this.contractConfig.entryPoint;
990
+ }
991
+ get validatorAddress() {
992
+ return this.contractConfig.validator;
993
+ }
994
+ /** Default init params used for wallet creation — needed for backend registration. */
995
+ get initDefaults() {
996
+ return {
997
+ guardianHash: DEFAULT_GUARDIAN_HASH,
998
+ guardianSafePeriod: DEFAULT_GUARDIAN_SAFE_PERIOD
999
+ };
1000
+ }
1001
+ /** Expose the raw SDK instance for advanced operations. */
1002
+ get raw() {
1003
+ return this.ensureSDK();
1004
+ }
1005
+ // ─── Internal ──────────────────────────────────────────────────
1006
+ ensureSDK() {
1007
+ if (!this.sdk) {
1008
+ throw new Error("SDK not initialized. Call initForChain() first.");
1009
+ }
1010
+ return this.sdk;
1011
+ }
1012
+ ensureBundler() {
1013
+ if (!this.bundlerInstance) {
1014
+ throw new Error("Bundler not initialized. Call initForChain() first.");
1015
+ }
1016
+ return this.bundlerInstance;
1017
+ }
1018
+ /**
1019
+ * Normalize SDK UserOp (which uses string/BigNumberish) to our typed format.
1020
+ */
1021
+ normalizeUserOp(sdkOp) {
1022
+ return {
1023
+ sender: sdkOp.sender,
1024
+ nonce: BigInt(sdkOp.nonce),
1025
+ factory: sdkOp.factory ?? null,
1026
+ factoryData: sdkOp.factoryData ?? null,
1027
+ callData: sdkOp.callData ?? "0x",
1028
+ callGasLimit: BigInt(sdkOp.callGasLimit || 0),
1029
+ verificationGasLimit: BigInt(sdkOp.verificationGasLimit || 0),
1030
+ preVerificationGas: BigInt(sdkOp.preVerificationGas || 0),
1031
+ maxFeePerGas: BigInt(sdkOp.maxFeePerGas || 0),
1032
+ maxPriorityFeePerGas: BigInt(sdkOp.maxPriorityFeePerGas || 0),
1033
+ paymaster: sdkOp.paymaster ?? null,
1034
+ paymasterVerificationGasLimit: sdkOp.paymasterVerificationGasLimit ? BigInt(sdkOp.paymasterVerificationGasLimit) : null,
1035
+ paymasterPostOpGasLimit: sdkOp.paymasterPostOpGasLimit ? BigInt(sdkOp.paymasterPostOpGasLimit) : null,
1036
+ paymasterData: sdkOp.paymasterData ?? null,
1037
+ signature: sdkOp.signature ?? "0x"
1038
+ };
1039
+ }
1040
+ /**
1041
+ * Convert our typed UserOp back to the SDK's format (string-based BigNumberish).
1042
+ */
1043
+ toSDKUserOp(op) {
1044
+ return {
1045
+ sender: op.sender,
1046
+ nonce: toHex2(op.nonce),
1047
+ factory: op.factory,
1048
+ factoryData: op.factoryData,
1049
+ callData: op.callData,
1050
+ callGasLimit: toHex2(op.callGasLimit),
1051
+ verificationGasLimit: toHex2(op.verificationGasLimit),
1052
+ preVerificationGas: toHex2(op.preVerificationGas),
1053
+ maxFeePerGas: toHex2(op.maxFeePerGas),
1054
+ maxPriorityFeePerGas: toHex2(op.maxPriorityFeePerGas),
1055
+ paymaster: op.paymaster,
1056
+ paymasterVerificationGasLimit: op.paymasterVerificationGasLimit ? toHex2(op.paymasterVerificationGasLimit) : null,
1057
+ paymasterPostOpGasLimit: op.paymasterPostOpGasLimit ? toHex2(op.paymasterPostOpGasLimit) : null,
1058
+ paymasterData: op.paymasterData,
1059
+ signature: op.signature
1060
+ };
1061
+ }
1062
+ };
1063
+ function sleep(ms) {
1064
+ return new Promise((resolve) => setTimeout(resolve, ms));
1065
+ }
1066
+
1067
+ // src/services/walletClient.ts
1068
+ import { createPublicClient as createPublicClient2, http as http2, formatEther } from "viem";
1069
+ var WalletClientService = class {
1070
+ client = null;
1071
+ chainConfig = null;
1072
+ initForChain(chainConfig) {
1073
+ const viemChain = {
1074
+ id: chainConfig.id,
1075
+ name: chainConfig.name,
1076
+ nativeCurrency: chainConfig.nativeCurrency,
1077
+ rpcUrls: {
1078
+ default: { http: [chainConfig.endpoint] }
1079
+ },
1080
+ blockExplorers: chainConfig.blockExplorer ? {
1081
+ default: {
1082
+ name: chainConfig.name,
1083
+ url: chainConfig.blockExplorer
1084
+ }
1085
+ } : void 0
1086
+ };
1087
+ this.client = createPublicClient2({
1088
+ chain: viemChain,
1089
+ transport: http2(chainConfig.endpoint)
1090
+ });
1091
+ this.chainConfig = chainConfig;
1092
+ }
1093
+ ensureClient() {
1094
+ if (!this.client) {
1095
+ throw new Error("WalletClient not initialized. Call initForChain().");
1096
+ }
1097
+ return this.client;
1098
+ }
1099
+ // ─── Queries ────────────────────────────────────────────────────
1100
+ async getBalance(address2) {
1101
+ const client = this.ensureClient();
1102
+ const wei = await client.getBalance({ address: address2 });
1103
+ return { wei, ether: formatEther(wei) };
1104
+ }
1105
+ async getCode(address2) {
1106
+ const client = this.ensureClient();
1107
+ return client.getCode({ address: address2 });
1108
+ }
1109
+ async isContractDeployed(address2) {
1110
+ const code = await this.getCode(address2);
1111
+ return !!code && code !== "0x";
1112
+ }
1113
+ async getBlockNumber() {
1114
+ const client = this.ensureClient();
1115
+ return client.getBlockNumber();
1116
+ }
1117
+ async readContract(params) {
1118
+ const client = this.ensureClient();
1119
+ return client.readContract(params);
1120
+ }
1121
+ /**
1122
+ * Fetch gas price from the network.
1123
+ */
1124
+ async getGasPrice() {
1125
+ const client = this.ensureClient();
1126
+ return client.getGasPrice();
1127
+ }
1128
+ /**
1129
+ * Fetch a transaction receipt by hash.
1130
+ */
1131
+ async getTransactionReceipt(hash) {
1132
+ const client = this.ensureClient();
1133
+ try {
1134
+ const receipt = await client.getTransactionReceipt({ hash });
1135
+ return {
1136
+ status: receipt.status,
1137
+ blockNumber: receipt.blockNumber,
1138
+ gasUsed: receipt.gasUsed,
1139
+ from: receipt.from,
1140
+ to: receipt.to,
1141
+ transactionHash: receipt.transactionHash
1142
+ };
1143
+ } catch {
1144
+ return null;
1145
+ }
1146
+ }
1147
+ /**
1148
+ * Get all ERC-20 token balances for an address via Alchemy's custom RPC method.
1149
+ * Returns only tokens with non-zero balance.
1150
+ *
1151
+ * Requires an Alchemy RPC endpoint.
1152
+ */
1153
+ async getTokenBalances(address2) {
1154
+ const client = this.ensureClient();
1155
+ const result = await client.request({
1156
+ method: "alchemy_getTokenBalances",
1157
+ params: [address2, "erc20"]
1158
+ });
1159
+ if (!result?.tokenBalances) return [];
1160
+ return result.tokenBalances.filter((t) => !t.error && t.tokenBalance && t.tokenBalance !== "0x" && t.tokenBalance !== "0x0").map((t) => ({
1161
+ tokenAddress: t.contractAddress,
1162
+ balance: BigInt(t.tokenBalance)
1163
+ })).filter((t) => t.balance > 0n);
1164
+ }
1165
+ /** Current chain config (after initForChain). */
1166
+ get currentChainConfig() {
1167
+ return this.chainConfig;
1168
+ }
1169
+ /** Expose the raw viem client for advanced use. */
1170
+ get raw() {
1171
+ return this.ensureClient();
1172
+ }
1173
+ };
1174
+
1175
+ // src/utils/alias.ts
1176
+ var ADJECTIVES = [
1177
+ "bold",
1178
+ "calm",
1179
+ "cool",
1180
+ "dark",
1181
+ "deep",
1182
+ "fair",
1183
+ "fast",
1184
+ "firm",
1185
+ "free",
1186
+ "gold",
1187
+ "keen",
1188
+ "kind",
1189
+ "late",
1190
+ "lean",
1191
+ "loud",
1192
+ "mild",
1193
+ "neat",
1194
+ "pale",
1195
+ "pure",
1196
+ "rare",
1197
+ "rich",
1198
+ "safe",
1199
+ "slim",
1200
+ "soft",
1201
+ "sure",
1202
+ "tall",
1203
+ "tiny",
1204
+ "true",
1205
+ "vast",
1206
+ "warm",
1207
+ "wild",
1208
+ "wise",
1209
+ "aged",
1210
+ "arid",
1211
+ "avid",
1212
+ "blue",
1213
+ "busy",
1214
+ "cozy",
1215
+ "deft",
1216
+ "dire",
1217
+ "dull",
1218
+ "edgy",
1219
+ "epic",
1220
+ "even",
1221
+ "fond",
1222
+ "glad",
1223
+ "grim",
1224
+ "hazy",
1225
+ "icy",
1226
+ "idle"
1227
+ ];
1228
+ var NOUNS = [
1229
+ "arch",
1230
+ "bass",
1231
+ "bear",
1232
+ "bell",
1233
+ "bird",
1234
+ "bolt",
1235
+ "cape",
1236
+ "cave",
1237
+ "claw",
1238
+ "clay",
1239
+ "coal",
1240
+ "colt",
1241
+ "core",
1242
+ "dawn",
1243
+ "deer",
1244
+ "dew",
1245
+ "dove",
1246
+ "dune",
1247
+ "dust",
1248
+ "edge",
1249
+ "elm",
1250
+ "fawn",
1251
+ "fern",
1252
+ "fire",
1253
+ "flax",
1254
+ "fog",
1255
+ "fork",
1256
+ "fox",
1257
+ "frost",
1258
+ "gate",
1259
+ "glen",
1260
+ "glow",
1261
+ "grove",
1262
+ "hare",
1263
+ "hawk",
1264
+ "haze",
1265
+ "hill",
1266
+ "jade",
1267
+ "lake",
1268
+ "lark",
1269
+ "leaf",
1270
+ "lion",
1271
+ "lynx",
1272
+ "mist",
1273
+ "moon",
1274
+ "moss",
1275
+ "oak",
1276
+ "owl",
1277
+ "palm",
1278
+ "peak",
1279
+ "pine",
1280
+ "pond",
1281
+ "rain",
1282
+ "reef",
1283
+ "ridge",
1284
+ "ring",
1285
+ "root",
1286
+ "rose",
1287
+ "sage",
1288
+ "sand",
1289
+ "seal",
1290
+ "snow",
1291
+ "star",
1292
+ "stem",
1293
+ "storm",
1294
+ "swan",
1295
+ "thorn",
1296
+ "tide",
1297
+ "vale",
1298
+ "vine",
1299
+ "wave",
1300
+ "wren",
1301
+ "wolf",
1302
+ "yarn",
1303
+ "zinc"
1304
+ ];
1305
+ function pick(list) {
1306
+ return list[Math.floor(Math.random() * list.length)];
1307
+ }
1308
+ function generateAlias() {
1309
+ return `${pick(ADJECTIVES)}-${pick(NOUNS)}`;
1310
+ }
1311
+
1312
+ // src/services/account.ts
1313
+ var STORAGE_KEY3 = "accounts";
1314
+ var AccountService = class {
1315
+ store;
1316
+ keyring;
1317
+ sdk;
1318
+ chain;
1319
+ walletClient;
1320
+ state = { accounts: [], currentAddress: null };
1321
+ constructor(deps) {
1322
+ this.store = deps.store;
1323
+ this.keyring = deps.keyring;
1324
+ this.sdk = deps.sdk;
1325
+ this.chain = deps.chain;
1326
+ this.walletClient = deps.walletClient;
1327
+ }
1328
+ /** Load persisted accounts from disk. */
1329
+ async init() {
1330
+ const saved = await this.store.load(STORAGE_KEY3);
1331
+ if (saved) {
1332
+ this.state = saved;
1333
+ }
1334
+ }
1335
+ // ─── Create ─────────────────────────────────────────────────────
1336
+ /**
1337
+ * Create a new smart account on the specified chain.
1338
+ *
1339
+ * Multiple accounts per chain are allowed — each gets a unique
1340
+ * CREATE2 index, producing a different contract address.
1341
+ *
1342
+ * @param chainId - Required. The target chain.
1343
+ * @param alias - Optional. Human-readable name. Auto-generated if omitted.
1344
+ */
1345
+ async createAccount(chainId, alias) {
1346
+ const owner = this.keyring.currentOwner;
1347
+ if (!owner) {
1348
+ throw new Error("Keyring is locked. Unlock first.");
1349
+ }
1350
+ const finalAlias = alias ?? this.uniqueAlias();
1351
+ if (this.state.accounts.some((a) => a.alias === finalAlias)) {
1352
+ throw new Error(`Alias "${finalAlias}" is already taken.`);
1353
+ }
1354
+ const index = this.nextIndex(owner, chainId);
1355
+ const address2 = await this.sdk.calcWalletAddress(owner, chainId, index);
1356
+ const account = {
1357
+ address: address2,
1358
+ chainId,
1359
+ alias: finalAlias,
1360
+ owner,
1361
+ index,
1362
+ isDeployed: false,
1363
+ isRecoveryEnabled: false
1364
+ };
1365
+ this.state.accounts.push(account);
1366
+ this.state.currentAddress = address2;
1367
+ await this.persist();
1368
+ return account;
1369
+ }
1370
+ // ─── Query ──────────────────────────────────────────────────────
1371
+ get currentAccount() {
1372
+ if (!this.state.currentAddress) return null;
1373
+ return this.state.accounts.find((a) => a.address === this.state.currentAddress) ?? null;
1374
+ }
1375
+ get allAccounts() {
1376
+ return [...this.state.accounts];
1377
+ }
1378
+ getAccountsByChain(chainId) {
1379
+ return this.state.accounts.filter((a) => a.chainId === chainId);
1380
+ }
1381
+ /**
1382
+ * Resolve an account by alias or address (case-insensitive).
1383
+ * This is the primary lookup method for commands.
1384
+ */
1385
+ resolveAccount(aliasOrAddress) {
1386
+ const needle = aliasOrAddress.toLowerCase();
1387
+ return this.state.accounts.find((a) => a.alias.toLowerCase() === needle || a.address.toLowerCase() === needle) ?? null;
1388
+ }
1389
+ // ─── Switch ─────────────────────────────────────────────────────
1390
+ async switchAccount(aliasOrAddress) {
1391
+ const account = this.resolveAccount(aliasOrAddress);
1392
+ if (!account) {
1393
+ throw new Error(`Account "${aliasOrAddress}" not found.`);
1394
+ }
1395
+ this.state.currentAddress = account.address;
1396
+ await this.persist();
1397
+ return account;
1398
+ }
1399
+ // ─── Activation ───────────────────────────────────────────────────
1400
+ /**
1401
+ * Mark an account as deployed on-chain.
1402
+ * Called after successful UserOp receipt confirms deployment.
1403
+ */
1404
+ async markDeployed(address2, chainId) {
1405
+ const account = this.state.accounts.find(
1406
+ (a) => a.address.toLowerCase() === address2.toLowerCase() && a.chainId === chainId
1407
+ );
1408
+ if (!account) {
1409
+ throw new Error(`Account ${address2} on chain ${chainId} not found.`);
1410
+ }
1411
+ account.isDeployed = true;
1412
+ await this.persist();
1413
+ }
1414
+ // ─── On-chain info ──────────────────────────────────────────────
1415
+ async getAccountDetail(aliasOrAddress) {
1416
+ const account = this.resolveAccount(aliasOrAddress);
1417
+ if (!account) {
1418
+ throw new Error(`Account "${aliasOrAddress}" not found.`);
1419
+ }
1420
+ const [isDeployed, { ether: balance }] = await Promise.all([
1421
+ this.walletClient.isContractDeployed(account.address),
1422
+ this.walletClient.getBalance(account.address)
1423
+ ]);
1424
+ if (isDeployed && !account.isDeployed) {
1425
+ account.isDeployed = true;
1426
+ await this.persist();
1427
+ }
1428
+ return {
1429
+ ...account,
1430
+ isDeployed,
1431
+ balance
1432
+ };
1433
+ }
1434
+ // ─── Import / Export ────────────────────────────────────────────
1435
+ async importAccounts(accounts) {
1436
+ let imported = 0;
1437
+ for (const account of accounts) {
1438
+ const exists = this.state.accounts.some(
1439
+ (a) => a.address.toLowerCase() === account.address.toLowerCase() && a.chainId === account.chainId
1440
+ );
1441
+ if (!exists) {
1442
+ this.state.accounts.push(account);
1443
+ imported++;
1444
+ }
1445
+ }
1446
+ if (imported > 0) {
1447
+ await this.persist();
1448
+ }
1449
+ return imported;
1450
+ }
1451
+ // ─── Internal ───────────────────────────────────────────────────
1452
+ /**
1453
+ * Get the next available CREATE2 index for a given owner+chain.
1454
+ * Simply counts how many accounts this owner already has on this chain.
1455
+ */
1456
+ nextIndex(owner, chainId) {
1457
+ return this.state.accounts.filter((a) => a.owner.toLowerCase() === owner.toLowerCase() && a.chainId === chainId).length;
1458
+ }
1459
+ /** Generate an alias that doesn't collide with existing ones. */
1460
+ uniqueAlias() {
1461
+ const existing = new Set(this.state.accounts.map((a) => a.alias));
1462
+ for (let i = 0; i < 100; i++) {
1463
+ const candidate = generateAlias();
1464
+ if (!existing.has(candidate)) return candidate;
1465
+ }
1466
+ return `account-${this.state.accounts.length + 1}`;
1467
+ }
1468
+ async persist() {
1469
+ await this.store.save(STORAGE_KEY3, this.state);
1470
+ }
1471
+ };
1472
+
1473
+ // src/services/securityHook.ts
1474
+ import {
1475
+ toHex as toHex3,
1476
+ toBytes,
1477
+ parseAbi,
1478
+ keccak256,
1479
+ hashMessage,
1480
+ encodeAbiParameters,
1481
+ encodePacked,
1482
+ parseAbiParameters
1483
+ } from "viem";
1484
+
1485
+ // src/constants/securityHook.ts
1486
+ var SECURITY_HOOK_ADDRESS_MAP = {
1487
+ 1: "0xd4e23c76e56532c0620f0b80e62918cc7ca9d442",
1488
+ // Ethereum Mainnet
1489
+ 10: "0xd4e23c76e56532c0620f0b80e62918cc7ca9d442",
1490
+ // Optimism
1491
+ 42161: "0xd4e23c76e56532c0620f0b80e62918cc7ca9d442",
1492
+ // Arbitrum One
1493
+ 11155111: "0xd4e23c76e56532c0620f0b80e62918cc7ca9d442",
1494
+ // Sepolia
1495
+ 11155420: "0xd4e23c76e56532c0620f0b80e62918cc7ca9d442"
1496
+ // Optimism Sepolia
1497
+ };
1498
+ var CAPABILITY_FLAGS = {
1499
+ SIGNATURE_ONLY: 1,
1500
+ USER_OP_ONLY: 2,
1501
+ BOTH: 3
1502
+ };
1503
+ var CAPABILITY_LABELS = {
1504
+ [CAPABILITY_FLAGS.SIGNATURE_ONLY]: "SIGNATURE_ONLY",
1505
+ [CAPABILITY_FLAGS.USER_OP_ONLY]: "USER_OP_ONLY",
1506
+ [CAPABILITY_FLAGS.BOTH]: "BOTH"
1507
+ };
1508
+ var DEFAULT_CAPABILITY = CAPABILITY_FLAGS.USER_OP_ONLY;
1509
+ var DEFAULT_SAFETY_DELAY = 2;
1510
+
1511
+ // src/services/securityHook.ts
1512
+ init_graphqlClient();
1513
+ var ELYTRO_MSG_TYPE_HASH = keccak256(toBytes("ElytroMessage(bytes32 message)"));
1514
+ var DOMAIN_SEPARATOR_TYPE_HASH = "0x47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a79469218";
1515
+ function getEncoded1271MessageHash(message) {
1516
+ return keccak256(encodeAbiParameters(parseAbiParameters(["bytes32", "bytes32"]), [ELYTRO_MSG_TYPE_HASH, message]));
1517
+ }
1518
+ function getDomainSeparator(chainIdHex, walletAddress) {
1519
+ return keccak256(
1520
+ encodeAbiParameters(parseAbiParameters(["bytes32", "uint256", "address"]), [
1521
+ DOMAIN_SEPARATOR_TYPE_HASH,
1522
+ BigInt(chainIdHex),
1523
+ walletAddress
1524
+ ])
1525
+ );
1526
+ }
1527
+ function getEncodedSHA(domainSeparator, encode1271MessageHash) {
1528
+ return keccak256(
1529
+ encodePacked(["bytes1", "bytes1", "bytes32", "bytes32"], ["0x19", "0x01", domainSeparator, encode1271MessageHash])
1530
+ );
1531
+ }
1532
+ var GQL_REQUEST_WALLET_AUTH_CHALLENGE = `
1533
+ mutation RequestWalletAuthChallenge($input: RequestWalletAuthChallengeInput!) {
1534
+ requestWalletAuthChallenge(input: $input) {
1535
+ challengeId
1536
+ message
1537
+ expiresAt
1538
+ }
1539
+ }
1540
+ `;
1541
+ var GQL_CONFIRM_WALLET_AUTH_CHALLENGE = `
1542
+ mutation ConfirmWalletAuthChallenge($input: ConfirmWalletAuthChallengeInput!) {
1543
+ confirmWalletAuthChallenge(input: $input) {
1544
+ sessionId
1545
+ expiresAt
1546
+ }
1547
+ }
1548
+ `;
1549
+ var GQL_WALLET_SECURITY_PROFILE = `
1550
+ query WalletSecurityProfile($input: WalletSecurityProfileInput!) {
1551
+ walletSecurityProfile(input: $input) {
1552
+ email
1553
+ emailVerified
1554
+ maskedEmail
1555
+ dailyLimitUsdCents
1556
+ createdAt
1557
+ updatedAt
1558
+ }
1559
+ }
1560
+ `;
1561
+ var GQL_REQUEST_WALLET_EMAIL_BINDING = `
1562
+ mutation RequestWalletEmailBinding($input: RequestWalletEmailBindingInput!) {
1563
+ requestWalletEmailBinding(input: $input) {
1564
+ bindingId
1565
+ maskedEmail
1566
+ otpExpiresAt
1567
+ resendAvailableAt
1568
+ }
1569
+ }
1570
+ `;
1571
+ var GQL_CONFIRM_WALLET_EMAIL_BINDING = `
1572
+ mutation ConfirmWalletEmailBinding($input: ConfirmWalletEmailBindingInput!) {
1573
+ confirmWalletEmailBinding(input: $input) {
1574
+ email
1575
+ emailVerified
1576
+ maskedEmail
1577
+ dailyLimitUsdCents
1578
+ updatedAt
1579
+ }
1580
+ }
1581
+ `;
1582
+ var GQL_CHANGE_WALLET_EMAIL = `
1583
+ mutation RequestChangeWalletEmail($input: ChangeWalletEmailInput!) {
1584
+ requestChangeWalletEmail(input: $input) {
1585
+ bindingId
1586
+ maskedEmail
1587
+ otpExpiresAt
1588
+ resendAvailableAt
1589
+ }
1590
+ }
1591
+ `;
1592
+ var GQL_SET_WALLET_DAILY_LIMIT = `
1593
+ mutation SetWalletDailyLimit($input: SetWalletDailyLimitInput!) {
1594
+ setWalletDailyLimit(input: $input) {
1595
+ dailyLimitUsdCents
1596
+ updatedAt
1597
+ }
1598
+ }
1599
+ `;
1600
+ var GQL_REQUEST_DAILY_LIMIT_OTP = `
1601
+ mutation RequestChangeWalletDailyLimit($input: RequestWalletDailyLimitInput!) {
1602
+ requestChangeWalletDailyLimit(input: $input) {
1603
+ maskedEmail
1604
+ otpExpiresAt
1605
+ resendAvailableAt
1606
+ }
1607
+ }
1608
+ `;
1609
+ var GQL_AUTHORIZE_USER_OPERATION = `
1610
+ mutation AuthorizeUserOperation($input: AuthorizeUserOperationInput!) {
1611
+ authorizeUserOperation(input: $input) {
1612
+ decision
1613
+ signature
1614
+ spendDeltaUsdCents
1615
+ totalSpendUsdCents
1616
+ refreshedAt
1617
+ }
1618
+ }
1619
+ `;
1620
+ var GQL_REQUEST_SECURITY_OTP = `
1621
+ mutation RequestSecurityOtp($input: RequestSecurityOtpInput!) {
1622
+ requestSecurityOtp(input: $input) {
1623
+ challengeId
1624
+ maskedEmail
1625
+ otpExpiresAt
1626
+ }
1627
+ }
1628
+ `;
1629
+ var GQL_VERIFY_SECURITY_OTP = `
1630
+ mutation VerifySecurityOtp($input: VerifySecurityOtpInput!) {
1631
+ verifySecurityOtp(input: $input) {
1632
+ challengeId
1633
+ status
1634
+ verifiedAt
1635
+ }
1636
+ }
1637
+ `;
1638
+ var ABI_LIST_HOOK = parseAbi([
1639
+ "function listHook() view returns (address[] preIsValidSignatureHooks, address[] preUserOpValidationHooks)"
1640
+ ]);
1641
+ var ABI_SECURITY_HOOK_USER_DATA = parseAbi([
1642
+ "function userData(address) view returns (bool initialized, uint32 safetyDelay, uint64 forceUninstallAfter)"
1643
+ ]);
1644
+ function createSignMessageForAuth(deps) {
1645
+ return async (message, walletAddress, chainId) => {
1646
+ const hashedMessage = hashMessage({ raw: toBytes(message) });
1647
+ const encoded1271Hash = getEncoded1271MessageHash(hashedMessage);
1648
+ const chainIdHex = `0x${chainId.toString(16)}`;
1649
+ const domainSeparator = getDomainSeparator(chainIdHex, walletAddress.toLowerCase());
1650
+ const messageHash = getEncodedSHA(domainSeparator, encoded1271Hash);
1651
+ const { packedHash, validationData } = await deps.packRawHash(messageHash);
1652
+ const rawSignature = await deps.signDigest(packedHash);
1653
+ return deps.packSignature(rawSignature, validationData);
1654
+ };
1655
+ }
1656
+ var SecurityHookService = class {
1657
+ store;
1658
+ graphqlEndpoint;
1659
+ signMessageForAuth;
1660
+ readContract;
1661
+ getBlockTimestamp;
1662
+ /**
1663
+ * In-memory cache of the last authenticated session ID.
1664
+ * Prevents re-authentication between getHookSignature → verifySecurityOtp
1665
+ * calls, which would create a new session that doesn't own the OTP challenge.
1666
+ */
1667
+ _cachedSessionId = null;
1668
+ constructor(deps) {
1669
+ this.store = deps.store;
1670
+ this.graphqlEndpoint = deps.graphqlEndpoint;
1671
+ this.signMessageForAuth = deps.signMessageForAuth;
1672
+ this.readContract = deps.readContract;
1673
+ this.getBlockTimestamp = deps.getBlockTimestamp;
1674
+ }
1675
+ // ─── Auth Session ─────────────────────────────────────────────
1676
+ sessionKey(walletAddress, chainId) {
1677
+ return `authSession_${walletAddress.toLowerCase()}_${chainId}`;
1678
+ }
1679
+ async loadAuthSession(walletAddress, chainId) {
1680
+ const key = this.sessionKey(walletAddress, chainId);
1681
+ const session = await this.store.load(key);
1682
+ if (!session) return null;
1683
+ if (Date.now() > session.expiresAt) {
1684
+ await this.store.remove(key);
1685
+ return null;
1686
+ }
1687
+ return session.authSessionId;
1688
+ }
1689
+ async storeAuthSession(walletAddress, chainId, sessionId, expiresAt) {
1690
+ const key = this.sessionKey(walletAddress, chainId);
1691
+ await this.store.save(key, {
1692
+ authSessionId: sessionId,
1693
+ expiresAt: new Date(expiresAt).getTime()
1694
+ });
1695
+ }
1696
+ async clearAuthSession(walletAddress, chainId) {
1697
+ this._cachedSessionId = null;
1698
+ const key = this.sessionKey(walletAddress, chainId);
1699
+ await this.store.remove(key);
1700
+ }
1701
+ /**
1702
+ * Authenticate wallet via challenge-response.
1703
+ *
1704
+ * Flow: requestWalletAuthChallenge → sign challenge message → confirmWalletAuthChallenge → sessionId.
1705
+ */
1706
+ async authenticate(walletAddress, chainId) {
1707
+ const challengeResult = await this.gqlMutate(GQL_REQUEST_WALLET_AUTH_CHALLENGE, {
1708
+ input: {
1709
+ chainID: `0x${chainId.toString(16)}`,
1710
+ address: walletAddress.toLowerCase()
1711
+ }
1712
+ });
1713
+ const challenge = challengeResult.requestWalletAuthChallenge;
1714
+ const signature = await this.signMessageForAuth(toHex3(challenge.message), walletAddress, chainId);
1715
+ const confirmResult = await this.gqlMutate(GQL_CONFIRM_WALLET_AUTH_CHALLENGE, {
1716
+ input: {
1717
+ chainID: `0x${chainId.toString(16)}`,
1718
+ address: walletAddress.toLowerCase(),
1719
+ challengeId: challenge.challengeId,
1720
+ signature
1721
+ }
1722
+ });
1723
+ const { sessionId, expiresAt } = confirmResult.confirmWalletAuthChallenge;
1724
+ await this.storeAuthSession(walletAddress, chainId, sessionId, expiresAt);
1725
+ return sessionId;
1726
+ }
1727
+ /**
1728
+ * Get or create an auth session, with one retry on auth errors.
1729
+ * Caches the session ID in-memory so subsequent calls within the same
1730
+ * service instance (e.g. verifySecurityOtp after getHookSignature)
1731
+ * always use the same session.
1732
+ */
1733
+ async getAuthSession(walletAddress, chainId) {
1734
+ if (this._cachedSessionId) return this._cachedSessionId;
1735
+ let sessionId = await this.loadAuthSession(walletAddress, chainId);
1736
+ if (sessionId) {
1737
+ this._cachedSessionId = sessionId;
1738
+ return sessionId;
1739
+ }
1740
+ try {
1741
+ sessionId = await this.authenticate(walletAddress, chainId);
1742
+ this._cachedSessionId = sessionId;
1743
+ return sessionId;
1744
+ } catch (err) {
1745
+ if (this.isAuthError(err)) {
1746
+ await this.clearAuthSession(walletAddress, chainId);
1747
+ sessionId = await this.authenticate(walletAddress, chainId);
1748
+ this._cachedSessionId = sessionId;
1749
+ return sessionId;
1750
+ }
1751
+ throw err;
1752
+ }
1753
+ }
1754
+ isAuthError(error2) {
1755
+ if (!error2 || typeof error2 !== "object") return false;
1756
+ const msg = String(error2.message ?? "").toLowerCase();
1757
+ return msg.includes("forbidden") || msg.includes("unauthorized") || msg.includes("session") || msg.includes("expired") || msg.includes("failed to authenticate");
1758
+ }
1759
+ // ─── On-chain Hook Status ────────────────────────────────────
1760
+ /**
1761
+ * Query on-chain hook status: which hooks are installed + force-uninstall state.
1762
+ */
1763
+ async getHookStatus(walletAddress, chainId) {
1764
+ const hookAddress = SECURITY_HOOK_ADDRESS_MAP[chainId];
1765
+ if (!hookAddress) {
1766
+ return {
1767
+ installed: false,
1768
+ hookAddress: "0x0000000000000000000000000000000000000000",
1769
+ capabilities: { preUserOpValidation: false, preIsValidSignature: false },
1770
+ forceUninstall: { initiated: false, canExecute: false, availableAfter: null }
1771
+ };
1772
+ }
1773
+ try {
1774
+ const hooks = await this.readContract({
1775
+ address: walletAddress,
1776
+ abi: ABI_LIST_HOOK,
1777
+ functionName: "listHook"
1778
+ });
1779
+ const [preIsValidSignatureHooks, preUserOpValidationHooks] = Array.isArray(hooks) ? hooks : [[], []];
1780
+ const hookLower = hookAddress.toLowerCase();
1781
+ const hasPreIsValidSignature = preIsValidSignatureHooks.some((h) => h.toLowerCase() === hookLower);
1782
+ const hasPreUserOpValidation = preUserOpValidationHooks.some((h) => h.toLowerCase() === hookLower);
1783
+ const installed = hasPreIsValidSignature || hasPreUserOpValidation;
1784
+ let forceUninstall = { initiated: false, canExecute: false, availableAfter: null };
1785
+ if (installed) {
1786
+ try {
1787
+ const userData = await this.readContract({
1788
+ address: hookAddress,
1789
+ abi: ABI_SECURITY_HOOK_USER_DATA,
1790
+ functionName: "userData",
1791
+ args: [walletAddress]
1792
+ });
1793
+ const [_isInstalled, _safetyDelay, forceUninstallAfterRaw] = userData;
1794
+ const forceUninstallAfter = Number(forceUninstallAfterRaw);
1795
+ if (forceUninstallAfter > 0) {
1796
+ const currentTimestamp = Number(await this.getBlockTimestamp());
1797
+ forceUninstall = {
1798
+ initiated: true,
1799
+ canExecute: currentTimestamp >= forceUninstallAfter,
1800
+ availableAfter: new Date(forceUninstallAfter * 1e3).toISOString()
1801
+ };
1802
+ }
1803
+ } catch {
1804
+ }
1805
+ }
1806
+ return {
1807
+ installed,
1808
+ hookAddress,
1809
+ capabilities: {
1810
+ preUserOpValidation: hasPreUserOpValidation,
1811
+ preIsValidSignature: hasPreIsValidSignature
1812
+ },
1813
+ forceUninstall
1814
+ };
1815
+ } catch {
1816
+ return {
1817
+ installed: false,
1818
+ hookAddress,
1819
+ capabilities: { preUserOpValidation: false, preIsValidSignature: false },
1820
+ forceUninstall: { initiated: false, canExecute: false, availableAfter: null }
1821
+ };
1822
+ }
1823
+ }
1824
+ // ─── Security Profile ────────────────────────────────────────
1825
+ /**
1826
+ * Load security profile from backend (email, dailyLimit, etc.).
1827
+ */
1828
+ async loadSecurityProfile(walletAddress, chainId) {
1829
+ try {
1830
+ const sessionId = await this.getAuthSession(walletAddress, chainId);
1831
+ const result = await this.gqlQuery(GQL_WALLET_SECURITY_PROFILE, {
1832
+ input: { authSessionId: sessionId }
1833
+ });
1834
+ return result.walletSecurityProfile;
1835
+ } catch (err) {
1836
+ const msg = String(err.message ?? "");
1837
+ if (msg.includes("NOT_FOUND") || msg.includes("not found")) {
1838
+ return null;
1839
+ }
1840
+ throw err;
1841
+ }
1842
+ }
1843
+ // ─── Email Binding ───────────────────────────────────────────
1844
+ /**
1845
+ * Request email binding — sends OTP to the provided email.
1846
+ */
1847
+ async requestEmailBinding(walletAddress, chainId, email) {
1848
+ const sessionId = await this.getAuthSession(walletAddress, chainId);
1849
+ const result = await this.gqlMutate(GQL_REQUEST_WALLET_EMAIL_BINDING, {
1850
+ input: { authSessionId: sessionId, email, locale: "en-US" }
1851
+ });
1852
+ return result.requestWalletEmailBinding;
1853
+ }
1854
+ /**
1855
+ * Confirm email binding with OTP code.
1856
+ */
1857
+ async confirmEmailBinding(walletAddress, chainId, bindingId, otpCode) {
1858
+ const sessionId = await this.getAuthSession(walletAddress, chainId);
1859
+ const result = await this.gqlMutate(GQL_CONFIRM_WALLET_EMAIL_BINDING, {
1860
+ input: { authSessionId: sessionId, bindingId, otpCode }
1861
+ });
1862
+ return result.confirmWalletEmailBinding;
1863
+ }
1864
+ /**
1865
+ * Request email change — sends OTP to the new email.
1866
+ */
1867
+ async changeWalletEmail(walletAddress, chainId, email) {
1868
+ const sessionId = await this.getAuthSession(walletAddress, chainId);
1869
+ const result = await this.gqlMutate(GQL_CHANGE_WALLET_EMAIL, {
1870
+ input: { authSessionId: sessionId, email, locale: "en-US" }
1871
+ });
1872
+ return result.requestChangeWalletEmail;
1873
+ }
1874
+ // ─── Daily Spending Limit ────────────────────────────────────
1875
+ /**
1876
+ * Request OTP for changing daily limit.
1877
+ */
1878
+ async requestDailyLimitOtp(walletAddress, chainId, dailyLimitUsdCents) {
1879
+ const sessionId = await this.getAuthSession(walletAddress, chainId);
1880
+ const result = await this.gqlMutate(GQL_REQUEST_DAILY_LIMIT_OTP, {
1881
+ input: { authSessionId: sessionId, dailyLimitUsdCents }
1882
+ });
1883
+ return result.requestChangeWalletDailyLimit;
1884
+ }
1885
+ /**
1886
+ * Set daily spending limit (with optional OTP code for confirmation).
1887
+ */
1888
+ async setDailyLimit(walletAddress, chainId, dailyLimitUsdCents, otpCode) {
1889
+ const sessionId = await this.getAuthSession(walletAddress, chainId);
1890
+ await this.gqlMutate(GQL_SET_WALLET_DAILY_LIMIT, {
1891
+ input: { authSessionId: sessionId, dailyLimitUsdCents, ...otpCode && { otpCode } }
1892
+ });
1893
+ }
1894
+ // ─── OTP ─────────────────────────────────────────────────────
1895
+ /**
1896
+ * Request a security OTP for a user operation (proactive escalation).
1897
+ */
1898
+ async requestSecurityOtp(walletAddress, chainId, entryPoint, userOp) {
1899
+ const sessionId = await this.getAuthSession(walletAddress, chainId);
1900
+ const op = this.formatUserOpForGraphQL(userOp);
1901
+ const result = await this.gqlMutate(GQL_REQUEST_SECURITY_OTP, {
1902
+ input: {
1903
+ authSessionId: sessionId,
1904
+ chainID: toHex3(chainId),
1905
+ entryPoint: entryPoint.toLowerCase(),
1906
+ op
1907
+ }
1908
+ });
1909
+ return result.requestSecurityOtp;
1910
+ }
1911
+ /**
1912
+ * Verify a security OTP challenge.
1913
+ */
1914
+ async verifySecurityOtp(walletAddress, chainId, challengeId, otpCode) {
1915
+ const sessionId = await this.getAuthSession(walletAddress, chainId);
1916
+ const result = await this.gqlMutate(GQL_VERIFY_SECURITY_OTP, {
1917
+ input: { authSessionId: sessionId, challengeId, otpCode }
1918
+ });
1919
+ return result.verifySecurityOtp;
1920
+ }
1921
+ // ─── UserOp Authorization ────────────────────────────────────
1922
+ /**
1923
+ * Get hook signature for a user operation.
1924
+ *
1925
+ * Returns either:
1926
+ * - { signature } on success
1927
+ * - { error } when OTP/spending-limit challenge is required
1928
+ *
1929
+ * Handles auth retry automatically.
1930
+ */
1931
+ async getHookSignature(walletAddress, chainId, entryPoint, userOp) {
1932
+ const op = this.formatUserOpForGraphQL(userOp);
1933
+ for (let attempt = 0; attempt <= 1; attempt++) {
1934
+ try {
1935
+ if (attempt > 0) {
1936
+ await this.clearAuthSession(walletAddress, chainId);
1937
+ }
1938
+ const sessionId = await this.getAuthSession(walletAddress, chainId);
1939
+ const result = await this.gqlRaw(GQL_AUTHORIZE_USER_OPERATION, {
1940
+ input: {
1941
+ authSessionId: sessionId,
1942
+ chainID: toHex3(chainId),
1943
+ entryPoint: entryPoint.toLowerCase(),
1944
+ op
1945
+ }
1946
+ });
1947
+ if (result.data?.authorizeUserOperation?.signature) {
1948
+ return { signature: result.data.authorizeUserOperation.signature };
1949
+ }
1950
+ if (result.errors && result.errors.length > 0) {
1951
+ const ext = result.errors[0].extensions;
1952
+ if (ext) {
1953
+ return {
1954
+ error: {
1955
+ code: ext.code,
1956
+ challengeId: ext.challengeId,
1957
+ currentSpendUsdCents: ext.currentSpendUsdCents,
1958
+ dailyLimitUsdCents: ext.dailyLimitUsdCents,
1959
+ maskedEmail: ext.maskedEmail,
1960
+ otpExpiresAt: ext.otpExpiresAt,
1961
+ projectedSpendUsdCents: ext.projectedSpendUsdCents,
1962
+ message: result.errors[0].message
1963
+ }
1964
+ };
1965
+ }
1966
+ }
1967
+ throw new Error("Unknown authorization error");
1968
+ } catch (err) {
1969
+ if (this.isAuthError(err) && attempt === 0) continue;
1970
+ if (err instanceof GraphQLClientError && err.errors?.length) {
1971
+ const ext = err.errors[0].extensions;
1972
+ if (ext?.code) {
1973
+ return {
1974
+ error: {
1975
+ code: ext.code,
1976
+ challengeId: ext.challengeId,
1977
+ currentSpendUsdCents: ext.currentSpendUsdCents,
1978
+ dailyLimitUsdCents: ext.dailyLimitUsdCents,
1979
+ maskedEmail: ext.maskedEmail,
1980
+ otpExpiresAt: ext.otpExpiresAt,
1981
+ projectedSpendUsdCents: ext.projectedSpendUsdCents,
1982
+ message: err.errors[0].message
1983
+ }
1984
+ };
1985
+ }
1986
+ }
1987
+ throw err;
1988
+ }
1989
+ }
1990
+ throw new Error("Hook signature authorization failed after retry");
1991
+ }
1992
+ // ─── Helpers ─────────────────────────────────────────────────
1993
+ /**
1994
+ * Format UserOp fields for GraphQL input (all values as hex strings).
1995
+ */
1996
+ formatUserOpForGraphQL(userOp) {
1997
+ return {
1998
+ sender: userOp.sender.toLowerCase(),
1999
+ nonce: this.formatHex(userOp.nonce),
2000
+ factory: userOp.factory?.toLowerCase() || null,
2001
+ factoryData: userOp.factoryData || "0x",
2002
+ callData: userOp.callData,
2003
+ callGasLimit: this.formatHex(userOp.callGasLimit),
2004
+ verificationGasLimit: this.formatHex(userOp.verificationGasLimit),
2005
+ preVerificationGas: this.formatHex(userOp.preVerificationGas),
2006
+ maxPriorityFeePerGas: this.formatHex(userOp.maxPriorityFeePerGas),
2007
+ maxFeePerGas: this.formatHex(userOp.maxFeePerGas),
2008
+ paymaster: userOp.paymaster?.toLowerCase() || null,
2009
+ paymasterVerificationGasLimit: userOp.paymasterVerificationGasLimit ? this.formatHex(userOp.paymasterVerificationGasLimit) : "0x0",
2010
+ paymasterPostOpGasLimit: userOp.paymasterPostOpGasLimit ? this.formatHex(userOp.paymasterPostOpGasLimit) : "0x0",
2011
+ paymasterData: userOp.paymasterData || "0x",
2012
+ signature: userOp.signature
2013
+ };
2014
+ }
2015
+ /**
2016
+ * Convert a value to hex string. If already a hex string, return as-is.
2017
+ * Mirrors extension's formatHex utility.
2018
+ */
2019
+ formatHex(value) {
2020
+ if (typeof value === "string" && value.startsWith("0x")) {
2021
+ return value;
2022
+ }
2023
+ return toHex3(value);
2024
+ }
2025
+ // ─── GraphQL Wrappers ───────────────────────────────────────
2026
+ async gqlMutate(query, variables) {
2027
+ return requestGraphQL({
2028
+ endpoint: this.graphqlEndpoint,
2029
+ query,
2030
+ variables
2031
+ });
2032
+ }
2033
+ async gqlQuery(query, variables) {
2034
+ return requestGraphQL({
2035
+ endpoint: this.graphqlEndpoint,
2036
+ query,
2037
+ variables
2038
+ });
2039
+ }
2040
+ /**
2041
+ * Raw GraphQL request that returns the full response (data + errors)
2042
+ * instead of throwing on errors. Needed for authorizeUserOperation
2043
+ * which uses GraphQL errors with extensions for challenge responses.
2044
+ */
2045
+ async gqlRaw(query, variables) {
2046
+ const { endpoint } = { endpoint: this.graphqlEndpoint };
2047
+ const controller = new AbortController();
2048
+ const timeoutId = setTimeout(() => controller.abort(), 15e3);
2049
+ try {
2050
+ const response = await fetch(endpoint, {
2051
+ method: "POST",
2052
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
2053
+ body: JSON.stringify({ query, variables }),
2054
+ signal: controller.signal
2055
+ });
2056
+ if (!response.ok) {
2057
+ const text = await response.text().catch(() => "");
2058
+ throw new Error(`HTTP ${response.status}: ${text.slice(0, 200)}`);
2059
+ }
2060
+ return await response.json();
2061
+ } finally {
2062
+ clearTimeout(timeoutId);
2063
+ }
2064
+ }
2065
+ };
2066
+
2067
+ // src/utils/deviceKey.ts
2068
+ import { webcrypto as webcrypto2 } from "crypto";
2069
+ import { readFile as readFile2, writeFile as writeFile2, stat, chmod } from "fs/promises";
2070
+ import { join as join2 } from "path";
2071
+ var DEVICE_KEY_FILE = ".device-key";
2072
+ var KEY_LENGTH2 = 32;
2073
+ var REQUIRED_MODE = 384;
2074
+ function generateDeviceKey() {
2075
+ return webcrypto2.getRandomValues(new Uint8Array(KEY_LENGTH2));
2076
+ }
2077
+ async function saveDeviceKey(dataDir, key) {
2078
+ validateDeviceKey(key);
2079
+ const path = keyPath(dataDir);
2080
+ await writeFile2(path, key, { mode: REQUIRED_MODE });
2081
+ await chmod(path, REQUIRED_MODE);
2082
+ }
2083
+ async function loadDeviceKey(dataDir) {
2084
+ const path = keyPath(dataDir);
2085
+ try {
2086
+ const st = await stat(path);
2087
+ const mode = st.mode & 511;
2088
+ if (mode !== REQUIRED_MODE) {
2089
+ throw new Error(
2090
+ `Device key has insecure permissions (${modeStr(mode)}). Expected 600. Fix with: chmod 600 ${path}`
2091
+ );
2092
+ }
2093
+ const buf = await readFile2(path);
2094
+ const key = new Uint8Array(buf);
2095
+ validateDeviceKey(key);
2096
+ return key;
2097
+ } catch (err) {
2098
+ if (err.code === "ENOENT") {
2099
+ return null;
2100
+ }
2101
+ throw err;
2102
+ }
2103
+ }
2104
+ function validateDeviceKey(key) {
2105
+ if (key.length !== KEY_LENGTH2) {
2106
+ throw new Error(`Invalid device key: expected ${KEY_LENGTH2} bytes, got ${key.length}.`);
2107
+ }
2108
+ }
2109
+ function keyPath(dataDir) {
2110
+ return join2(dataDir, DEVICE_KEY_FILE);
2111
+ }
2112
+ function modeStr(mode) {
2113
+ return "0o" + mode.toString(8).padStart(3, "0");
2114
+ }
2115
+
2116
+ // src/context.ts
2117
+ async function createAppContext() {
2118
+ const store = new FileStore();
2119
+ await store.init();
2120
+ const keyring = new KeyringService(store);
2121
+ const chain = new ChainService(store);
2122
+ const sdk = new SDKService();
2123
+ const walletClient = new WalletClientService();
2124
+ await chain.init();
2125
+ const defaultChain = chain.currentChain;
2126
+ walletClient.initForChain(defaultChain);
2127
+ await sdk.initForChain(defaultChain);
2128
+ const deviceKey = await loadDeviceKey(store.dataDir);
2129
+ if (deviceKey && await keyring.isInitialized()) {
2130
+ await keyring.unlock(deviceKey);
2131
+ }
2132
+ const account = new AccountService({
2133
+ store,
2134
+ keyring,
2135
+ sdk,
2136
+ chain,
2137
+ walletClient
2138
+ });
2139
+ await account.init();
2140
+ const currentAccount = account.currentAccount;
2141
+ if (currentAccount) {
2142
+ const acctInfo = account.resolveAccount(currentAccount.alias ?? currentAccount.address);
2143
+ if (acctInfo) {
2144
+ const acctChain = chain.chains.find((c) => c.id === acctInfo.chainId);
2145
+ if (acctChain && acctChain.id !== defaultChain.id) {
2146
+ walletClient.initForChain(acctChain);
2147
+ await sdk.initForChain(acctChain);
2148
+ }
2149
+ }
2150
+ }
2151
+ return { store, keyring, chain, sdk, walletClient, account, deviceKey };
2152
+ }
2153
+
2154
+ // src/commands/init.ts
2155
+ import ora from "ora";
2156
+
2157
+ // src/utils/display.ts
2158
+ import chalk from "chalk";
2159
+ function heading(text) {
2160
+ console.log(chalk.bold.cyan(`
2161
+ ${text}
2162
+ `));
2163
+ }
2164
+ function info(label, value) {
2165
+ console.log(` ${chalk.gray(label + ":")} ${value}`);
2166
+ }
2167
+ function success(text) {
2168
+ console.log(chalk.green(`\u2714 ${text}`));
2169
+ }
2170
+ function warn(text) {
2171
+ console.log(chalk.yellow(`\u26A0 ${text}`));
2172
+ }
2173
+ function error(text) {
2174
+ console.error(chalk.red(`\u2716 ${text}`));
2175
+ }
2176
+ function txError(payload) {
2177
+ const output = {
2178
+ success: false,
2179
+ error: {
2180
+ code: payload.code,
2181
+ message: payload.message,
2182
+ ...payload.data && Object.keys(payload.data).length > 0 ? { data: payload.data } : {}
2183
+ }
2184
+ };
2185
+ console.error(chalk.red(JSON.stringify(output, null, 2)));
2186
+ }
2187
+ function table(rows, columns) {
2188
+ const header = columns.map((c) => c.label.padEnd(c.width ?? 20)).join(" ");
2189
+ console.log(chalk.bold(header));
2190
+ console.log(chalk.gray("\u2500".repeat(header.length)));
2191
+ for (const row of rows) {
2192
+ const line = columns.map((c) => (row[c.key] ?? "").padEnd(c.width ?? 20)).join(" ");
2193
+ console.log(line);
2194
+ }
2195
+ }
2196
+ function address(addr) {
2197
+ if (addr.length <= 14) return addr;
2198
+ return `${addr.slice(0, 8)}...${addr.slice(-6)}`;
2199
+ }
2200
+ function maskApiKeys(url) {
2201
+ let masked = url;
2202
+ masked = masked.replace(/(\/v\d+\/)[^/?#]+(\?|#|$)/gi, "$1***$2");
2203
+ masked = masked.replace(/([?&](?:apikey|api_key|key|token|secret))=[^&#]+/gi, "$1=***");
2204
+ return masked;
2205
+ }
2206
+ function sanitizeErrorMessage(message) {
2207
+ return message.replace(/https?:\/\/[^\s"']+/gi, (match) => maskApiKeys(match));
2208
+ }
2209
+
2210
+ // src/commands/init.ts
2211
+ function registerInitCommand(program2, ctx) {
2212
+ program2.command("init").description("Initialize a new Elytro wallet").action(async () => {
2213
+ if (await ctx.keyring.isInitialized()) {
2214
+ warn("Wallet already initialized.");
2215
+ info("Data", ctx.store.dataDir);
2216
+ info("Hint", "Use `elytro account create` to create a smart account.");
2217
+ return;
2218
+ }
2219
+ heading("Initialize Elytro Wallet");
2220
+ const spinner = ora("Setting up wallet...").start();
2221
+ try {
2222
+ const deviceKey = generateDeviceKey();
2223
+ await saveDeviceKey(ctx.store.dataDir, deviceKey);
2224
+ await ctx.keyring.createNewOwner(deviceKey);
2225
+ ctx.deviceKey = deviceKey;
2226
+ spinner.succeed("Wallet initialized.");
2227
+ console.log("");
2228
+ info("Data", ctx.store.dataDir);
2229
+ console.log("");
2230
+ success("Run `elytro account create --chain <chainId>` to create your first smart account.");
2231
+ } catch (err) {
2232
+ spinner.fail("Failed to initialize wallet.");
2233
+ error(sanitizeErrorMessage(err.message));
2234
+ process.exitCode = 1;
2235
+ }
2236
+ });
2237
+ }
2238
+
2239
+ // src/commands/account.ts
2240
+ import ora2 from "ora";
2241
+ import { formatEther as formatEther2, padHex as padHex2 } from "viem";
2242
+
2243
+ // src/utils/prompt.ts
2244
+ import { password, confirm, select, input } from "@inquirer/prompts";
2245
+ async function askConfirm(message, defaultValue = false) {
2246
+ return confirm({ message, default: defaultValue });
2247
+ }
2248
+ async function askSelect(message, choices) {
2249
+ return select({ message, choices });
2250
+ }
2251
+ async function askInput(message, defaultValue) {
2252
+ return input({ message, default: defaultValue });
2253
+ }
2254
+
2255
+ // src/commands/account.ts
2256
+ init_sponsor();
2257
+ function registerAccountCommand(program2, ctx) {
2258
+ const account = program2.command("account").description("Manage smart accounts");
2259
+ account.command("create").description("Create a new smart account").requiredOption("-c, --chain <chainId>", "Target chain ID").option("-a, --alias <alias>", "Human-readable alias (default: random)").action(async (opts) => {
2260
+ if (!ctx.deviceKey) {
2261
+ error("Wallet not initialized. Run `elytro init` first.");
2262
+ process.exitCode = 1;
2263
+ return;
2264
+ }
2265
+ const chainId = Number(opts.chain);
2266
+ if (Number.isNaN(chainId)) {
2267
+ error("Invalid chain ID.");
2268
+ process.exitCode = 1;
2269
+ return;
2270
+ }
2271
+ const spinner = ora2("Creating smart account...").start();
2272
+ try {
2273
+ const chainConfig = ctx.chain.chains.find((c) => c.id === chainId);
2274
+ const chainName = chainConfig?.name ?? String(chainId);
2275
+ if (chainConfig) {
2276
+ await ctx.sdk.initForChain(chainConfig);
2277
+ ctx.walletClient.initForChain(chainConfig);
2278
+ }
2279
+ const accountInfo = await ctx.account.createAccount(chainId, opts.alias);
2280
+ spinner.text = "Registering with backend...";
2281
+ const { guardianHash, guardianSafePeriod } = ctx.sdk.initDefaults;
2282
+ const paddedKey = padHex2(accountInfo.owner, { size: 32 });
2283
+ const { error: regError } = await registerAccount(
2284
+ ctx.chain.graphqlEndpoint,
2285
+ accountInfo.address,
2286
+ chainId,
2287
+ accountInfo.index,
2288
+ [paddedKey],
2289
+ guardianHash,
2290
+ guardianSafePeriod
2291
+ );
2292
+ spinner.succeed(`Account "${accountInfo.alias}" created.`);
2293
+ console.log("");
2294
+ info("Alias", accountInfo.alias);
2295
+ info("Address", accountInfo.address);
2296
+ info("Chain", `${chainName} (${chainId})`);
2297
+ info("Status", "Not deployed (run `elytro account activate` to deploy)");
2298
+ if (regError) {
2299
+ warn(`Backend registration failed: ${regError}`);
2300
+ warn("Sponsorship may not work. You can still activate with ETH.");
2301
+ }
2302
+ } catch (err) {
2303
+ spinner.fail("Failed to create account.");
2304
+ error(sanitizeErrorMessage(err.message));
2305
+ process.exitCode = 1;
2306
+ }
2307
+ });
2308
+ account.command("activate").description("Deploy the smart contract on-chain").argument("[account]", "Alias or address (default: current)").option("--no-sponsor", "Skip sponsorship check (user pays gas)").action(async (target, opts) => {
2309
+ if (!ctx.deviceKey) {
2310
+ error("Wallet not initialized. Run `elytro init` first.");
2311
+ process.exitCode = 1;
2312
+ return;
2313
+ }
2314
+ const identifier = target ?? ctx.account.currentAccount?.alias ?? ctx.account.currentAccount?.address;
2315
+ if (!identifier) {
2316
+ warn("No account selected. Specify an alias/address or create an account first.");
2317
+ return;
2318
+ }
2319
+ const accountInfo = ctx.account.resolveAccount(identifier);
2320
+ if (!accountInfo) {
2321
+ error(`Account "${identifier}" not found.`);
2322
+ process.exitCode = 1;
2323
+ return;
2324
+ }
2325
+ if (accountInfo.isDeployed) {
2326
+ warn(`Account "${accountInfo.alias}" is already deployed.`);
2327
+ return;
2328
+ }
2329
+ const chainConfig = ctx.chain.chains.find((c) => c.id === accountInfo.chainId);
2330
+ const chainName = chainConfig?.name ?? String(accountInfo.chainId);
2331
+ if (!chainConfig) {
2332
+ error(`Chain ${accountInfo.chainId} not configured.`);
2333
+ process.exitCode = 1;
2334
+ return;
2335
+ }
2336
+ await ctx.sdk.initForChain(chainConfig);
2337
+ ctx.walletClient.initForChain(chainConfig);
2338
+ const spinner = ora2(`Activating "${accountInfo.alias}" on ${chainName}...`).start();
2339
+ try {
2340
+ spinner.text = "Building deployment UserOp...";
2341
+ const userOp = await ctx.sdk.createDeployUserOp(accountInfo.owner, accountInfo.index);
2342
+ spinner.text = "Fetching gas prices...";
2343
+ const feeData = await ctx.sdk.getFeeData(chainConfig);
2344
+ userOp.maxFeePerGas = feeData.maxFeePerGas;
2345
+ userOp.maxPriorityFeePerGas = feeData.maxPriorityFeePerGas;
2346
+ spinner.text = "Estimating gas...";
2347
+ const gasEstimate = await ctx.sdk.estimateUserOp(userOp, { fakeBalance: true });
2348
+ userOp.callGasLimit = gasEstimate.callGasLimit;
2349
+ userOp.verificationGasLimit = gasEstimate.verificationGasLimit;
2350
+ userOp.preVerificationGas = gasEstimate.preVerificationGas;
2351
+ let sponsored = false;
2352
+ if (opts?.sponsor !== false) {
2353
+ spinner.text = "Checking sponsorship...";
2354
+ const { sponsor: sponsorResult, error: sponsorError } = await requestSponsorship(
2355
+ ctx.chain.graphqlEndpoint,
2356
+ accountInfo.chainId,
2357
+ ctx.sdk.entryPoint,
2358
+ userOp
2359
+ );
2360
+ if (sponsorResult) {
2361
+ applySponsorToUserOp(userOp, sponsorResult);
2362
+ sponsored = true;
2363
+ } else {
2364
+ spinner.text = "Sponsorship unavailable, checking balance...";
2365
+ const { ether: balance } = await ctx.walletClient.getBalance(accountInfo.address);
2366
+ if (parseFloat(balance) === 0) {
2367
+ spinner.fail("Activation failed.");
2368
+ error(`Sponsorship failed: ${sponsorError ?? "unknown reason"}`);
2369
+ error(
2370
+ `Account has no ETH to pay gas. Fund ${accountInfo.address} on ${chainName}, or fix sponsorship.`
2371
+ );
2372
+ process.exitCode = 1;
2373
+ return;
2374
+ }
2375
+ spinner.text = "Proceeding without sponsor (user pays gas)...";
2376
+ }
2377
+ }
2378
+ spinner.text = "Signing UserOperation...";
2379
+ const { packedHash, validationData } = await ctx.sdk.getUserOpHash(userOp);
2380
+ const rawSignature = await ctx.keyring.signDigest(packedHash);
2381
+ userOp.signature = await ctx.sdk.packUserOpSignature(rawSignature, validationData);
2382
+ spinner.text = "Sending to bundler...";
2383
+ const opHash = await ctx.sdk.sendUserOp(userOp);
2384
+ spinner.text = "Waiting for on-chain confirmation...";
2385
+ const receipt = await ctx.sdk.waitForReceipt(opHash);
2386
+ await ctx.account.markDeployed(accountInfo.address, accountInfo.chainId);
2387
+ if (receipt.success) {
2388
+ spinner.succeed(`Account "${accountInfo.alias}" activated!`);
2389
+ } else {
2390
+ spinner.warn(`UserOp included but execution reverted.`);
2391
+ }
2392
+ console.log("");
2393
+ info("Account", accountInfo.alias);
2394
+ info("Address", accountInfo.address);
2395
+ info("Chain", `${chainName} (${accountInfo.chainId})`);
2396
+ info("Tx Hash", receipt.transactionHash);
2397
+ info("Gas Cost", `${formatEther2(BigInt(receipt.actualGasCost))} ETH`);
2398
+ info("Sponsored", sponsored ? "Yes (gasless)" : "No (user paid)");
2399
+ if (chainConfig.blockExplorer) {
2400
+ info("Explorer", `${chainConfig.blockExplorer}/tx/${receipt.transactionHash}`);
2401
+ }
2402
+ } catch (err) {
2403
+ spinner.fail("Activation failed.");
2404
+ error(sanitizeErrorMessage(err.message));
2405
+ process.exitCode = 1;
2406
+ }
2407
+ });
2408
+ account.command("list").description("List all accounts (or query one by alias/address)").argument("[account]", "Filter by alias or address").option("-c, --chain <chainId>", "Filter by chain ID").action(async (target, opts) => {
2409
+ let accounts = opts?.chain ? ctx.account.getAccountsByChain(Number(opts.chain)) : ctx.account.allAccounts;
2410
+ if (target) {
2411
+ const matched = ctx.account.resolveAccount(target);
2412
+ if (!matched) {
2413
+ error(`Account "${target}" not found.`);
2414
+ process.exitCode = 1;
2415
+ return;
2416
+ }
2417
+ accounts = [matched];
2418
+ }
2419
+ if (accounts.length === 0) {
2420
+ warn("No accounts found. Run `elytro account create --chain <chainId>` first.");
2421
+ return;
2422
+ }
2423
+ const current = ctx.account.currentAccount;
2424
+ heading("Accounts");
2425
+ table(
2426
+ accounts.map((a) => {
2427
+ const chainConfig = ctx.chain.chains.find((c) => c.id === a.chainId);
2428
+ return {
2429
+ active: a.address === current?.address ? "\u2192" : " ",
2430
+ alias: a.alias,
2431
+ address: a.address,
2432
+ chain: chainConfig?.name ?? String(a.chainId),
2433
+ deployed: a.isDeployed ? "Yes" : "No",
2434
+ recovery: a.isRecoveryEnabled ? "Yes" : "No"
2435
+ };
2436
+ }),
2437
+ [
2438
+ { key: "active", label: "", width: 3 },
2439
+ { key: "alias", label: "Alias", width: 16 },
2440
+ { key: "address", label: "Address", width: 44 },
2441
+ { key: "chain", label: "Chain", width: 18 },
2442
+ { key: "deployed", label: "Deployed", width: 10 },
2443
+ { key: "recovery", label: "Recovery", width: 10 }
2444
+ ]
2445
+ );
2446
+ });
2447
+ account.command("info").description("Show details for an account").argument("[account]", "Alias or address (default: current)").action(async (target) => {
2448
+ const identifier = target ?? ctx.account.currentAccount?.alias ?? ctx.account.currentAccount?.address;
2449
+ if (!identifier) {
2450
+ warn("No account selected. Run `elytro account create --chain <chainId>` first.");
2451
+ return;
2452
+ }
2453
+ const spinner = ora2("Fetching on-chain data...").start();
2454
+ try {
2455
+ const accountInfo = ctx.account.resolveAccount(identifier);
2456
+ if (!accountInfo) {
2457
+ spinner.fail("Account not found.");
2458
+ error(`Account "${identifier}" not found.`);
2459
+ process.exitCode = 1;
2460
+ return;
2461
+ }
2462
+ const chainConfig = ctx.chain.chains.find((c) => c.id === accountInfo.chainId);
2463
+ if (chainConfig) {
2464
+ ctx.walletClient.initForChain(chainConfig);
2465
+ }
2466
+ const detail = await ctx.account.getAccountDetail(identifier);
2467
+ spinner.stop();
2468
+ heading("Account Details");
2469
+ info("Alias", detail.alias);
2470
+ info("Address", detail.address);
2471
+ info("Chain", chainConfig?.name ?? String(detail.chainId));
2472
+ info("Deployed", detail.isDeployed ? "Yes" : "No");
2473
+ info("Balance", `${detail.balance} ${chainConfig?.nativeCurrency.symbol ?? "ETH"}`);
2474
+ info("Recovery", detail.isRecoveryEnabled ? "Enabled" : "Not set");
2475
+ if (chainConfig?.blockExplorer) {
2476
+ info("Explorer", `${chainConfig.blockExplorer}/address/${detail.address}`);
2477
+ }
2478
+ } catch (err) {
2479
+ spinner.fail("Failed to fetch account info.");
2480
+ error(sanitizeErrorMessage(err.message));
2481
+ process.exitCode = 1;
2482
+ }
2483
+ });
2484
+ account.command("switch").description("Switch the active account").argument("[account]", "Alias or address").action(async (target) => {
2485
+ const accounts = ctx.account.allAccounts;
2486
+ if (accounts.length === 0) {
2487
+ warn("No accounts found.");
2488
+ return;
2489
+ }
2490
+ let identifier = target;
2491
+ if (!identifier) {
2492
+ const chainConfig = (chainId) => ctx.chain.chains.find((c) => c.id === chainId);
2493
+ identifier = await askSelect(
2494
+ "Select an account",
2495
+ accounts.map((a) => ({
2496
+ name: `${a.alias} ${address(a.address)} ${chainConfig(a.chainId)?.name ?? a.chainId}`,
2497
+ value: a.alias
2498
+ }))
2499
+ );
2500
+ }
2501
+ try {
2502
+ const switched = await ctx.account.switchAccount(identifier);
2503
+ const newChain = ctx.chain.chains.find((c) => c.id === switched.chainId);
2504
+ if (newChain) {
2505
+ ctx.walletClient.initForChain(newChain);
2506
+ await ctx.sdk.initForChain(newChain);
2507
+ }
2508
+ success(`Switched to "${switched.alias}"`);
2509
+ const spinner = ora2("Fetching on-chain data...").start();
2510
+ try {
2511
+ const detail = await ctx.account.getAccountDetail(switched.alias);
2512
+ spinner.stop();
2513
+ console.log("");
2514
+ info("Address", detail.address);
2515
+ info("Chain", newChain?.name ?? String(detail.chainId));
2516
+ info("Deployed", detail.isDeployed ? "Yes" : "No");
2517
+ info("Balance", `${detail.balance} ${newChain?.nativeCurrency.symbol ?? "ETH"}`);
2518
+ if (newChain?.blockExplorer) {
2519
+ info("Explorer", `${newChain.blockExplorer}/address/${detail.address}`);
2520
+ }
2521
+ } catch {
2522
+ spinner.stop();
2523
+ warn("Could not fetch on-chain data. Run `elytro account info` to retry.");
2524
+ }
2525
+ } catch (err) {
2526
+ error(sanitizeErrorMessage(err.message));
2527
+ process.exitCode = 1;
2528
+ }
2529
+ });
2530
+ }
2531
+
2532
+ // src/commands/tx.ts
2533
+ init_sponsor();
2534
+ import ora3 from "ora";
2535
+ import { isAddress, isHex, formatEther as formatEther3, parseEther as parseEther2, toHex as toHex5 } from "viem";
2536
+ var ERR_INVALID_PARAMS = -32602;
2537
+ var ERR_INSUFFICIENT_BALANCE = -32001;
2538
+ var ERR_ACCOUNT_NOT_READY = -32002;
2539
+ var ERR_SPONSOR_FAILED = -32003;
2540
+ var ERR_BUILD_FAILED = -32004;
2541
+ var ERR_SEND_FAILED = -32005;
2542
+ var ERR_EXECUTION_REVERTED = -32006;
2543
+ var ERR_INTERNAL = -32e3;
2544
+ var TxError = class extends Error {
2545
+ code;
2546
+ data;
2547
+ constructor(code, message, data) {
2548
+ super(message);
2549
+ this.name = "TxError";
2550
+ this.code = code;
2551
+ this.data = data;
2552
+ }
2553
+ };
2554
+ function handleTxError(err) {
2555
+ if (err instanceof TxError) {
2556
+ txError({ code: err.code, message: sanitizeErrorMessage(err.message), data: err.data });
2557
+ } else {
2558
+ txError({
2559
+ code: ERR_INTERNAL,
2560
+ message: sanitizeErrorMessage(err.message ?? String(err))
2561
+ });
2562
+ }
2563
+ process.exitCode = 1;
2564
+ }
2565
+ function parseTxSpec(spec, index) {
2566
+ const prefix = `--tx #${index + 1}`;
2567
+ const fields = {};
2568
+ for (const part of spec.split(",")) {
2569
+ const colonIdx = part.indexOf(":");
2570
+ if (colonIdx === -1) {
2571
+ throw new TxError(ERR_INVALID_PARAMS, `${prefix}: invalid segment "${part}". Expected key:value format.`, {
2572
+ spec,
2573
+ index
2574
+ });
2575
+ }
2576
+ const key = part.slice(0, colonIdx).trim().toLowerCase();
2577
+ const val = part.slice(colonIdx + 1).trim();
2578
+ if (!key || !val) {
2579
+ throw new TxError(ERR_INVALID_PARAMS, `${prefix}: empty key or value in "${part}".`, { spec, index });
2580
+ }
2581
+ if (fields[key]) {
2582
+ throw new TxError(ERR_INVALID_PARAMS, `${prefix}: duplicate key "${key}".`, { spec, index, key });
2583
+ }
2584
+ fields[key] = val;
2585
+ }
2586
+ const knownKeys = /* @__PURE__ */ new Set(["to", "value", "data"]);
2587
+ for (const key of Object.keys(fields)) {
2588
+ if (!knownKeys.has(key)) {
2589
+ throw new TxError(ERR_INVALID_PARAMS, `${prefix}: unknown key "${key}". Allowed: to, value, data.`, {
2590
+ spec,
2591
+ index,
2592
+ key
2593
+ });
2594
+ }
2595
+ }
2596
+ if (!fields.to) {
2597
+ throw new TxError(ERR_INVALID_PARAMS, `${prefix}: "to" is required.`, { spec, index });
2598
+ }
2599
+ if (!isAddress(fields.to)) {
2600
+ throw new TxError(ERR_INVALID_PARAMS, `${prefix}: invalid address "${fields.to}".`, { spec, index, to: fields.to });
2601
+ }
2602
+ if (!fields.value && !fields.data) {
2603
+ throw new TxError(ERR_INVALID_PARAMS, `${prefix}: at least one of "value" or "data" is required.`, { spec, index });
2604
+ }
2605
+ if (fields.value) {
2606
+ try {
2607
+ const wei = parseEther2(fields.value);
2608
+ if (wei < 0n) throw new Error("negative");
2609
+ } catch {
2610
+ throw new TxError(
2611
+ ERR_INVALID_PARAMS,
2612
+ `${prefix}: invalid ETH amount "${fields.value}". Use human-readable format (e.g. "0.1").`,
2613
+ { spec, index, value: fields.value }
2614
+ );
2615
+ }
2616
+ }
2617
+ if (fields.data) {
2618
+ if (!isHex(fields.data)) {
2619
+ throw new TxError(ERR_INVALID_PARAMS, `${prefix}: invalid hex in "data". Must start with 0x.`, {
2620
+ spec,
2621
+ index,
2622
+ data: fields.data
2623
+ });
2624
+ }
2625
+ if (fields.data.length > 2 && fields.data.length % 2 !== 0) {
2626
+ throw new TxError(ERR_INVALID_PARAMS, `${prefix}: "data" hex must have even length (complete bytes).`, {
2627
+ spec,
2628
+ index,
2629
+ data: fields.data
2630
+ });
2631
+ }
2632
+ }
2633
+ return {
2634
+ to: fields.to,
2635
+ value: fields.value,
2636
+ data: fields.data
2637
+ };
2638
+ }
2639
+ function detectTxType(specs) {
2640
+ if (specs.length > 1) return "batch";
2641
+ const tx = specs[0];
2642
+ if (tx.data && tx.data !== "0x") return "contract-call";
2643
+ return "eth-transfer";
2644
+ }
2645
+ function specsToTxs(specs) {
2646
+ return specs.map((s) => ({
2647
+ to: s.to,
2648
+ value: s.value ? toHex5(parseEther2(s.value)) : "0x0",
2649
+ data: s.data ?? "0x"
2650
+ }));
2651
+ }
2652
+ function totalEthValue(specs) {
2653
+ let sum = 0n;
2654
+ for (const s of specs) {
2655
+ if (s.value) sum += parseEther2(s.value);
2656
+ }
2657
+ return sum;
2658
+ }
2659
+ function txTypeLabel(txType) {
2660
+ switch (txType) {
2661
+ case "eth-transfer":
2662
+ return "ETH Transfer";
2663
+ case "contract-call":
2664
+ return "Contract Call";
2665
+ case "batch":
2666
+ return "Batch Transaction";
2667
+ }
2668
+ }
2669
+ function truncateHex(hex, maxLen = 42) {
2670
+ if (hex.length <= maxLen) return hex;
2671
+ return `${hex.slice(0, 20)}...${hex.slice(-8)} (${(hex.length - 2) / 2} bytes)`;
2672
+ }
2673
+ function displayTxSpec(spec, index) {
2674
+ const parts = [`#${index + 1}`];
2675
+ parts.push(`\u2192 ${spec.to}`);
2676
+ if (spec.value) parts.push(`${spec.value} ETH`);
2677
+ if (spec.data && spec.data !== "0x") {
2678
+ const selector = spec.data.length >= 10 ? spec.data.slice(0, 10) : spec.data;
2679
+ parts.push(`call ${selector}`);
2680
+ }
2681
+ info("Tx", parts.join(" "));
2682
+ }
2683
+ function registerTxCommand(program2, ctx) {
2684
+ const tx = program2.command("tx").description("Build, simulate, and send transactions");
2685
+ tx.command("build").description("Build an unsigned UserOp from transaction parameters").argument("[account]", "Source account alias or address (default: current)").option("--tx <spec...>", 'Transaction spec: "to:0xAddr,value:0.1,data:0x..." (repeatable, ordered)').option("--no-sponsor", "Skip sponsorship check").action(async (target, opts) => {
2686
+ try {
2687
+ const specs = parseAllTxSpecs(opts?.tx);
2688
+ const { userOp, accountInfo, chainConfig, sponsored, txType } = await buildUserOp(
2689
+ ctx,
2690
+ target,
2691
+ specs,
2692
+ opts?.sponsor
2693
+ );
2694
+ heading("UserOperation (unsigned)");
2695
+ console.log(JSON.stringify(serializeUserOp(userOp), null, 2));
2696
+ console.log("");
2697
+ info("Account", accountInfo.alias);
2698
+ info("Chain", `${chainConfig.name} (${chainConfig.id})`);
2699
+ info("Type", txTypeLabel(txType));
2700
+ if (txType === "batch") info("Tx Count", specs.length.toString());
2701
+ info("Sponsored", sponsored ? "Yes" : "No");
2702
+ } catch (err) {
2703
+ handleTxError(err);
2704
+ }
2705
+ });
2706
+ tx.command("send").description("Send a transaction on-chain").argument("[account]", "Source account alias or address (default: current)").option("--tx <spec...>", 'Transaction spec: "to:0xAddr,value:0.1,data:0x..." (repeatable, ordered)').option("--no-sponsor", "Skip sponsorship check").option("--no-hook", "Skip SecurityHook signing (bypass 2FA)").option("--userop <json>", "Send a pre-built UserOp JSON (skips build step)").action(async (target, opts) => {
2707
+ if (!ctx.deviceKey) {
2708
+ handleTxError(new TxError(ERR_ACCOUNT_NOT_READY, "Wallet not initialized. Run `elytro init` first."));
2709
+ return;
2710
+ }
2711
+ try {
2712
+ let userOp;
2713
+ let accountInfo;
2714
+ let chainConfig;
2715
+ let sponsored;
2716
+ let txType = "contract-call";
2717
+ let specs = [];
2718
+ if (opts?.userop) {
2719
+ userOp = deserializeUserOp(opts.userop);
2720
+ sponsored = !!userOp.paymaster;
2721
+ const identifier = target ?? ctx.account.currentAccount?.alias ?? ctx.account.currentAccount?.address;
2722
+ if (!identifier) {
2723
+ throw new TxError(ERR_ACCOUNT_NOT_READY, "No account selected.", {
2724
+ hint: "Specify an alias/address or create an account first."
2725
+ });
2726
+ }
2727
+ accountInfo = resolveAccountStrict(ctx, identifier);
2728
+ chainConfig = resolveChainStrict(ctx, accountInfo.chainId);
2729
+ await ctx.sdk.initForChain(chainConfig);
2730
+ ctx.walletClient.initForChain(chainConfig);
2731
+ } else {
2732
+ specs = parseAllTxSpecs(opts?.tx);
2733
+ const result = await buildUserOp(ctx, target, specs, opts?.sponsor);
2734
+ userOp = result.userOp;
2735
+ accountInfo = result.accountInfo;
2736
+ chainConfig = result.chainConfig;
2737
+ sponsored = result.sponsored;
2738
+ txType = result.txType;
2739
+ }
2740
+ console.log("");
2741
+ heading("Transaction Summary");
2742
+ info("Type", txTypeLabel(txType));
2743
+ info("From", `${accountInfo.alias} (${accountInfo.address})`);
2744
+ if (txType === "batch") {
2745
+ info("Tx Count", specs.length.toString());
2746
+ for (let i = 0; i < specs.length; i++) {
2747
+ displayTxSpec(specs[i], i);
2748
+ }
2749
+ } else if (txType === "contract-call") {
2750
+ const s = specs[0];
2751
+ info("To", s.to);
2752
+ info("Calldata", truncateHex(s.data ?? "0x"));
2753
+ if (s.data && s.data.length >= 10) {
2754
+ info("Selector", s.data.slice(0, 10));
2755
+ }
2756
+ if (s.value && s.value !== "0") {
2757
+ info("Value", `${s.value} ETH (payable)`);
2758
+ }
2759
+ } else {
2760
+ const s = specs[0];
2761
+ info("To", s.to);
2762
+ info("Value", `${s.value ?? "0"} ETH`);
2763
+ }
2764
+ info("Sponsored", sponsored ? "Yes (gasless)" : "No (user pays gas)");
2765
+ const estimatedGas = userOp.callGasLimit + userOp.verificationGasLimit + userOp.preVerificationGas;
2766
+ info("Est. Gas", estimatedGas.toString());
2767
+ console.log("");
2768
+ const confirmed = await askConfirm("Sign and send this transaction?");
2769
+ if (!confirmed) {
2770
+ warn("Transaction cancelled.");
2771
+ return;
2772
+ }
2773
+ const spinner = ora3("Signing UserOperation...").start();
2774
+ let opHash;
2775
+ try {
2776
+ const { packedHash, validationData } = await ctx.sdk.getUserOpHash(userOp);
2777
+ const rawSignature = await ctx.keyring.signDigest(packedHash);
2778
+ const useHook = opts?.hook !== false;
2779
+ let hookSigned = false;
2780
+ if (useHook) {
2781
+ const hookAddress = SECURITY_HOOK_ADDRESS_MAP[accountInfo.chainId];
2782
+ if (hookAddress) {
2783
+ const hookService = new SecurityHookService({
2784
+ store: ctx.store,
2785
+ graphqlEndpoint: ctx.chain.graphqlEndpoint,
2786
+ signMessageForAuth: createSignMessageForAuth({
2787
+ signDigest: (digest) => ctx.keyring.signDigest(digest),
2788
+ packRawHash: (hash) => ctx.sdk.packRawHash(hash),
2789
+ packSignature: (rawSig, valData) => ctx.sdk.packUserOpSignature(rawSig, valData)
2790
+ }),
2791
+ readContract: async (params) => ctx.walletClient.readContract(params),
2792
+ getBlockTimestamp: async () => {
2793
+ const blockNum = await ctx.walletClient.raw.getBlockNumber();
2794
+ const block = await ctx.walletClient.raw.getBlock({ blockNumber: blockNum });
2795
+ return block.timestamp;
2796
+ }
2797
+ });
2798
+ spinner.text = "Checking SecurityHook status...";
2799
+ const hookStatus = await hookService.getHookStatus(accountInfo.address, accountInfo.chainId);
2800
+ if (hookStatus.installed && hookStatus.capabilities.preUserOpValidation) {
2801
+ userOp.signature = await ctx.sdk.packUserOpSignature(rawSignature, validationData);
2802
+ spinner.text = "Requesting hook authorization...";
2803
+ let hookResult = await hookService.getHookSignature(
2804
+ accountInfo.address,
2805
+ accountInfo.chainId,
2806
+ ctx.sdk.entryPoint,
2807
+ userOp
2808
+ );
2809
+ if (hookResult.error) {
2810
+ spinner.stop();
2811
+ const errCode = hookResult.error.code;
2812
+ if (errCode === "OTP_REQUIRED" || errCode === "SPENDING_LIMIT_EXCEEDED") {
2813
+ warn(hookResult.error.message ?? `Verification required (${errCode}).`);
2814
+ if (hookResult.error.maskedEmail) {
2815
+ info("OTP sent to", hookResult.error.maskedEmail);
2816
+ }
2817
+ if (errCode === "SPENDING_LIMIT_EXCEEDED" && hookResult.error.projectedSpendUsdCents !== void 0) {
2818
+ info("Projected spend", `$${(hookResult.error.projectedSpendUsdCents / 100).toFixed(2)}`);
2819
+ info("Daily limit", `$${((hookResult.error.dailyLimitUsdCents ?? 0) / 100).toFixed(2)}`);
2820
+ }
2821
+ const otpCode = await askInput("Enter the 6-digit OTP code:");
2822
+ spinner.start("Verifying OTP...");
2823
+ await hookService.verifySecurityOtp(
2824
+ accountInfo.address,
2825
+ accountInfo.chainId,
2826
+ hookResult.error.challengeId,
2827
+ otpCode.trim()
2828
+ );
2829
+ spinner.text = "OTP verified. Retrying authorization...";
2830
+ hookResult = await hookService.getHookSignature(
2831
+ accountInfo.address,
2832
+ accountInfo.chainId,
2833
+ ctx.sdk.entryPoint,
2834
+ userOp
2835
+ );
2836
+ if (hookResult.error) {
2837
+ throw new TxError(
2838
+ ERR_SEND_FAILED,
2839
+ `Hook authorization failed after OTP: ${hookResult.error.message}`
2840
+ );
2841
+ }
2842
+ } else {
2843
+ throw new TxError(
2844
+ ERR_SEND_FAILED,
2845
+ `Hook authorization failed: ${hookResult.error.message ?? errCode}`
2846
+ );
2847
+ }
2848
+ }
2849
+ userOp.signature = await ctx.sdk.packUserOpSignatureWithHook(
2850
+ rawSignature,
2851
+ validationData,
2852
+ hookAddress,
2853
+ hookResult.signature
2854
+ );
2855
+ hookSigned = true;
2856
+ }
2857
+ }
2858
+ }
2859
+ if (!hookSigned) {
2860
+ userOp.signature = await ctx.sdk.packUserOpSignature(rawSignature, validationData);
2861
+ }
2862
+ spinner.text = "Sending to bundler...";
2863
+ opHash = await ctx.sdk.sendUserOp(userOp);
2864
+ } catch (err) {
2865
+ spinner.fail("Send failed.");
2866
+ throw new TxError(ERR_SEND_FAILED, err.message, {
2867
+ sender: accountInfo.address,
2868
+ chain: chainConfig.name
2869
+ });
2870
+ }
2871
+ spinner.text = "Waiting for on-chain confirmation...";
2872
+ const receipt = await ctx.sdk.waitForReceipt(opHash);
2873
+ if (receipt.success) {
2874
+ spinner.succeed("Transaction confirmed!");
2875
+ } else {
2876
+ spinner.warn("Execution reverted.");
2877
+ txError({
2878
+ code: ERR_EXECUTION_REVERTED,
2879
+ message: "UserOp included but execution reverted on-chain.",
2880
+ data: {
2881
+ txHash: receipt.transactionHash,
2882
+ block: receipt.blockNumber,
2883
+ gasCost: `${formatEther3(BigInt(receipt.actualGasCost))} ETH`,
2884
+ sender: accountInfo.address
2885
+ }
2886
+ });
2887
+ }
2888
+ console.log("");
2889
+ info("Account", accountInfo.alias);
2890
+ info("Tx Hash", receipt.transactionHash);
2891
+ info("Block", receipt.blockNumber);
2892
+ info("Gas Cost", `${formatEther3(BigInt(receipt.actualGasCost))} ETH`);
2893
+ info("Sponsored", sponsored ? "Yes (gasless)" : "No (user paid)");
2894
+ if (chainConfig.blockExplorer) {
2895
+ info("Explorer", `${chainConfig.blockExplorer}/tx/${receipt.transactionHash}`);
2896
+ }
2897
+ if (!receipt.success) {
2898
+ process.exitCode = 1;
2899
+ }
2900
+ } catch (err) {
2901
+ handleTxError(err);
2902
+ }
2903
+ });
2904
+ tx.command("simulate").description("Preview a transaction (gas estimate, sponsor check)").argument("[account]", "Source account alias or address (default: current)").option("--tx <spec...>", 'Transaction spec: "to:0xAddr,value:0.1,data:0x..." (repeatable, ordered)').option("--no-sponsor", "Skip sponsorship check").action(async (target, opts) => {
2905
+ if (!ctx.deviceKey) {
2906
+ handleTxError(new TxError(ERR_ACCOUNT_NOT_READY, "Wallet not initialized. Run `elytro init` first."));
2907
+ return;
2908
+ }
2909
+ try {
2910
+ const specs = parseAllTxSpecs(opts?.tx);
2911
+ const { userOp, accountInfo, chainConfig, sponsored, txType } = await buildUserOp(
2912
+ ctx,
2913
+ target,
2914
+ specs,
2915
+ opts?.sponsor
2916
+ );
2917
+ const { wei: ethBalance, ether: ethFormatted } = await ctx.walletClient.getBalance(accountInfo.address);
2918
+ const nativeCurrency = chainConfig.nativeCurrency.symbol;
2919
+ console.log("");
2920
+ heading("Transaction Simulation");
2921
+ info("Type", txTypeLabel(txType));
2922
+ info("From", `${accountInfo.alias} (${accountInfo.address})`);
2923
+ info("Chain", `${chainConfig.name} (${chainConfig.id})`);
2924
+ if (txType === "batch") {
2925
+ console.log("");
2926
+ info("Tx Count", specs.length.toString());
2927
+ for (let i = 0; i < specs.length; i++) {
2928
+ displayTxSpec(specs[i], i);
2929
+ }
2930
+ const total = totalEthValue(specs);
2931
+ if (total > 0n) {
2932
+ info("Total ETH", formatEther3(total));
2933
+ if (ethBalance < total) {
2934
+ warn(`Insufficient balance: need ${formatEther3(total)}, have ${ethFormatted} ${nativeCurrency}`);
2935
+ }
2936
+ }
2937
+ } else if (txType === "contract-call") {
2938
+ const s = specs[0];
2939
+ console.log("");
2940
+ info("To", s.to);
2941
+ info("Calldata", truncateHex(s.data ?? "0x"));
2942
+ info("Calldata Size", `${Math.max(0, ((s.data?.length ?? 2) - 2) / 2)} bytes`);
2943
+ if (s.data && s.data.length >= 10) {
2944
+ info("Selector", s.data.slice(0, 10));
2945
+ }
2946
+ if (s.value && s.value !== "0") {
2947
+ info("Value", `${s.value} ${nativeCurrency} (payable)`);
2948
+ const sendValue = parseEther2(s.value);
2949
+ if (ethBalance < sendValue) {
2950
+ warn(`Insufficient balance for value: need ${s.value}, have ${ethFormatted} ${nativeCurrency}`);
2951
+ }
2952
+ }
2953
+ const isContract = await ctx.walletClient.isContractDeployed(s.to);
2954
+ info("Target", isContract ? "Contract" : "EOA (warning: calling non-contract)");
2955
+ if (!isContract) {
2956
+ warn("Target address has no deployed code. The call may be a no-op or revert.");
2957
+ }
2958
+ } else {
2959
+ const s = specs[0];
2960
+ console.log("");
2961
+ info("To", s.to);
2962
+ info("Value", `${s.value ?? "0"} ${nativeCurrency}`);
2963
+ if (s.value) {
2964
+ const sendValue = parseEther2(s.value);
2965
+ if (ethBalance < sendValue) {
2966
+ warn(`Insufficient balance: need ${s.value}, have ${ethFormatted} ${nativeCurrency}`);
2967
+ }
2968
+ }
2969
+ }
2970
+ console.log("");
2971
+ info("callGasLimit", userOp.callGasLimit.toString());
2972
+ info("verificationGasLimit", userOp.verificationGasLimit.toString());
2973
+ info("preVerificationGas", userOp.preVerificationGas.toString());
2974
+ info("maxFeePerGas", `${userOp.maxFeePerGas.toString()} wei`);
2975
+ info("maxPriorityFeePerGas", `${userOp.maxPriorityFeePerGas.toString()} wei`);
2976
+ const totalGas = userOp.callGasLimit + userOp.verificationGasLimit + userOp.preVerificationGas;
2977
+ const maxCostWei = totalGas * userOp.maxFeePerGas;
2978
+ info("Max Gas Cost", `${formatEther3(maxCostWei)} ${nativeCurrency}`);
2979
+ console.log("");
2980
+ info("Sponsored", sponsored ? "Yes (gasless)" : "No (user pays gas)");
2981
+ if (sponsored && userOp.paymaster) {
2982
+ info("Paymaster", userOp.paymaster);
2983
+ }
2984
+ info(`${nativeCurrency} Balance`, `${ethFormatted} ${nativeCurrency}`);
2985
+ if (!sponsored && ethBalance < maxCostWei) {
2986
+ warn(
2987
+ `Insufficient ${nativeCurrency} for gas: need ~${formatEther3(maxCostWei)}, have ${ethFormatted}`
2988
+ );
2989
+ }
2990
+ } catch (err) {
2991
+ handleTxError(err);
2992
+ }
2993
+ });
2994
+ }
2995
+ function parseAllTxSpecs(rawSpecs) {
2996
+ if (!rawSpecs || rawSpecs.length === 0) {
2997
+ throw new TxError(ERR_INVALID_PARAMS, 'At least one --tx is required. Format: --tx "to:0xAddr,value:0.1"');
2998
+ }
2999
+ return rawSpecs.map((spec, i) => parseTxSpec(spec, i));
3000
+ }
3001
+ async function buildUserOp(ctx, target, specs, sponsor) {
3002
+ const identifier = target ?? ctx.account.currentAccount?.alias ?? ctx.account.currentAccount?.address;
3003
+ if (!identifier) {
3004
+ throw new TxError(ERR_ACCOUNT_NOT_READY, "No account selected.", {
3005
+ hint: "Specify an alias/address or create an account first."
3006
+ });
3007
+ }
3008
+ const accountInfo = resolveAccountStrict(ctx, identifier);
3009
+ const chainConfig = resolveChainStrict(ctx, accountInfo.chainId);
3010
+ if (!accountInfo.isDeployed) {
3011
+ throw new TxError(ERR_ACCOUNT_NOT_READY, `Account "${accountInfo.alias}" is not deployed.`, {
3012
+ account: accountInfo.alias,
3013
+ address: accountInfo.address,
3014
+ hint: "Run `elytro account activate` first."
3015
+ });
3016
+ }
3017
+ await ctx.sdk.initForChain(chainConfig);
3018
+ ctx.walletClient.initForChain(chainConfig);
3019
+ const ethValueTotal = totalEthValue(specs);
3020
+ if (ethValueTotal > 0n) {
3021
+ const { wei: ethBalance } = await ctx.walletClient.getBalance(accountInfo.address);
3022
+ if (ethBalance < ethValueTotal) {
3023
+ const have = formatEther3(ethBalance);
3024
+ const need = formatEther3(ethValueTotal);
3025
+ throw new TxError(ERR_INSUFFICIENT_BALANCE, "Insufficient ETH balance for transfer value.", {
3026
+ need: `${need} ETH`,
3027
+ have: `${have} ETH`,
3028
+ account: accountInfo.address,
3029
+ chain: chainConfig.name
3030
+ });
3031
+ }
3032
+ }
3033
+ const txType = detectTxType(specs);
3034
+ const txs = specsToTxs(specs);
3035
+ const spinner = ora3("Building UserOp...").start();
3036
+ let userOp;
3037
+ try {
3038
+ userOp = await ctx.sdk.createSendUserOp(accountInfo.address, txs);
3039
+ } catch (err) {
3040
+ spinner.fail("Build failed.");
3041
+ throw new TxError(ERR_BUILD_FAILED, `Failed to build UserOp: ${err.message}`, {
3042
+ account: accountInfo.address,
3043
+ chain: chainConfig.name
3044
+ });
3045
+ }
3046
+ spinner.text = "Fetching gas prices...";
3047
+ const feeData = await ctx.sdk.getFeeData(chainConfig);
3048
+ userOp.maxFeePerGas = feeData.maxFeePerGas;
3049
+ userOp.maxPriorityFeePerGas = feeData.maxPriorityFeePerGas;
3050
+ spinner.text = "Estimating gas...";
3051
+ try {
3052
+ const gasEstimate = await ctx.sdk.estimateUserOp(userOp, { fakeBalance: true });
3053
+ userOp.callGasLimit = gasEstimate.callGasLimit;
3054
+ userOp.verificationGasLimit = gasEstimate.verificationGasLimit;
3055
+ userOp.preVerificationGas = gasEstimate.preVerificationGas;
3056
+ } catch (err) {
3057
+ spinner.fail("Gas estimation failed.");
3058
+ throw new TxError(ERR_BUILD_FAILED, `Gas estimation failed: ${err.message}`, {
3059
+ account: accountInfo.address,
3060
+ chain: chainConfig.name
3061
+ });
3062
+ }
3063
+ let sponsored = false;
3064
+ if (sponsor !== false) {
3065
+ spinner.text = "Checking sponsorship...";
3066
+ const { sponsor: sponsorResult, error: sponsorError } = await requestSponsorship(
3067
+ ctx.chain.graphqlEndpoint,
3068
+ accountInfo.chainId,
3069
+ ctx.sdk.entryPoint,
3070
+ userOp
3071
+ );
3072
+ if (sponsorResult) {
3073
+ applySponsorToUserOp(userOp, sponsorResult);
3074
+ sponsored = true;
3075
+ } else {
3076
+ spinner.text = "Sponsorship unavailable, checking balance...";
3077
+ const { wei: balance } = await ctx.walletClient.getBalance(accountInfo.address);
3078
+ if (balance === 0n) {
3079
+ spinner.fail("Build failed.");
3080
+ throw new TxError(ERR_SPONSOR_FAILED, "Sponsorship failed and account has no ETH to pay gas.", {
3081
+ reason: sponsorError ?? "unknown",
3082
+ account: accountInfo.address,
3083
+ chain: chainConfig.name,
3084
+ hint: `Fund ${accountInfo.address} on ${chainConfig.name}.`
3085
+ });
3086
+ }
3087
+ }
3088
+ }
3089
+ spinner.succeed("UserOp built.");
3090
+ return { userOp, accountInfo, chainConfig, sponsored, txType };
3091
+ }
3092
+ function resolveAccountStrict(ctx, identifier) {
3093
+ const account = ctx.account.resolveAccount(identifier);
3094
+ if (!account) {
3095
+ throw new TxError(ERR_ACCOUNT_NOT_READY, `Account "${identifier}" not found.`, { identifier });
3096
+ }
3097
+ return account;
3098
+ }
3099
+ function resolveChainStrict(ctx, chainId) {
3100
+ const chain = ctx.chain.chains.find((c) => c.id === chainId);
3101
+ if (!chain) {
3102
+ throw new TxError(ERR_ACCOUNT_NOT_READY, `Chain ${chainId} not configured.`, { chainId });
3103
+ }
3104
+ return chain;
3105
+ }
3106
+ function serializeUserOp(op) {
3107
+ return {
3108
+ sender: op.sender,
3109
+ nonce: toHex5(op.nonce),
3110
+ factory: op.factory,
3111
+ factoryData: op.factoryData,
3112
+ callData: op.callData,
3113
+ callGasLimit: toHex5(op.callGasLimit),
3114
+ verificationGasLimit: toHex5(op.verificationGasLimit),
3115
+ preVerificationGas: toHex5(op.preVerificationGas),
3116
+ maxFeePerGas: toHex5(op.maxFeePerGas),
3117
+ maxPriorityFeePerGas: toHex5(op.maxPriorityFeePerGas),
3118
+ paymaster: op.paymaster,
3119
+ paymasterVerificationGasLimit: op.paymasterVerificationGasLimit ? toHex5(op.paymasterVerificationGasLimit) : null,
3120
+ paymasterPostOpGasLimit: op.paymasterPostOpGasLimit ? toHex5(op.paymasterPostOpGasLimit) : null,
3121
+ paymasterData: op.paymasterData,
3122
+ signature: op.signature
3123
+ };
3124
+ }
3125
+ function deserializeUserOp(json) {
3126
+ let raw;
3127
+ try {
3128
+ raw = JSON.parse(json);
3129
+ } catch {
3130
+ throw new TxError(ERR_INVALID_PARAMS, "Invalid UserOp JSON. Pass a JSON-encoded UserOp object.", { json });
3131
+ }
3132
+ if (!raw.sender || !raw.callData) {
3133
+ throw new TxError(ERR_INVALID_PARAMS, "Invalid UserOp: missing required fields (sender, callData).");
3134
+ }
3135
+ return {
3136
+ sender: raw.sender,
3137
+ nonce: BigInt(raw.nonce ?? "0x0"),
3138
+ factory: raw.factory ?? null,
3139
+ factoryData: raw.factoryData ?? null,
3140
+ callData: raw.callData,
3141
+ callGasLimit: BigInt(raw.callGasLimit ?? "0x0"),
3142
+ verificationGasLimit: BigInt(raw.verificationGasLimit ?? "0x0"),
3143
+ preVerificationGas: BigInt(raw.preVerificationGas ?? "0x0"),
3144
+ maxFeePerGas: BigInt(raw.maxFeePerGas ?? "0x0"),
3145
+ maxPriorityFeePerGas: BigInt(raw.maxPriorityFeePerGas ?? "0x0"),
3146
+ paymaster: raw.paymaster ?? null,
3147
+ paymasterVerificationGasLimit: raw.paymasterVerificationGasLimit ? BigInt(raw.paymasterVerificationGasLimit) : null,
3148
+ paymasterPostOpGasLimit: raw.paymasterPostOpGasLimit ? BigInt(raw.paymasterPostOpGasLimit) : null,
3149
+ paymasterData: raw.paymasterData ?? null,
3150
+ signature: raw.signature ?? "0x"
3151
+ };
3152
+ }
3153
+
3154
+ // src/commands/query.ts
3155
+ import ora4 from "ora";
3156
+ import { isAddress as isAddress2, formatEther as formatEther4, formatUnits } from "viem";
3157
+
3158
+ // src/utils/erc20.ts
3159
+ import { encodeFunctionData, parseUnits } from "viem";
3160
+ var ERC20_ABI = [
3161
+ {
3162
+ name: "transfer",
3163
+ type: "function",
3164
+ stateMutability: "nonpayable",
3165
+ inputs: [
3166
+ { name: "to", type: "address" },
3167
+ { name: "amount", type: "uint256" }
3168
+ ],
3169
+ outputs: [{ name: "", type: "bool" }]
3170
+ },
3171
+ {
3172
+ name: "balanceOf",
3173
+ type: "function",
3174
+ stateMutability: "view",
3175
+ inputs: [{ name: "account", type: "address" }],
3176
+ outputs: [{ name: "", type: "uint256" }]
3177
+ },
3178
+ {
3179
+ name: "decimals",
3180
+ type: "function",
3181
+ stateMutability: "view",
3182
+ inputs: [],
3183
+ outputs: [{ name: "", type: "uint8" }]
3184
+ },
3185
+ {
3186
+ name: "symbol",
3187
+ type: "function",
3188
+ stateMutability: "view",
3189
+ inputs: [],
3190
+ outputs: [{ name: "", type: "string" }]
3191
+ }
3192
+ ];
3193
+ async function getTokenInfo(walletClient, tokenAddress) {
3194
+ const [symbol, decimals] = await Promise.all([
3195
+ walletClient.readContract({
3196
+ address: tokenAddress,
3197
+ abi: ERC20_ABI,
3198
+ functionName: "symbol"
3199
+ }),
3200
+ walletClient.readContract({
3201
+ address: tokenAddress,
3202
+ abi: ERC20_ABI,
3203
+ functionName: "decimals"
3204
+ })
3205
+ ]);
3206
+ return { symbol, decimals };
3207
+ }
3208
+ async function getTokenBalance(walletClient, tokenAddress, account) {
3209
+ return walletClient.readContract({
3210
+ address: tokenAddress,
3211
+ abi: ERC20_ABI,
3212
+ functionName: "balanceOf",
3213
+ args: [account]
3214
+ });
3215
+ }
3216
+
3217
+ // src/commands/query.ts
3218
+ function registerQueryCommand(program2, ctx) {
3219
+ const query = program2.command("query").description("Query on-chain data");
3220
+ query.command("balance").description("Query ETH or ERC-20 balance").argument("[account]", "Account alias or address (default: current)").option("--token <address>", "ERC-20 token contract address").action(async (target, opts) => {
3221
+ try {
3222
+ const { accountInfo, chainConfig } = resolveAccountAndChain(ctx, target);
3223
+ ctx.walletClient.initForChain(chainConfig);
3224
+ const spinner = ora4("Querying balance...").start();
3225
+ if (opts?.token) {
3226
+ if (!isAddress2(opts.token)) {
3227
+ spinner.fail("Invalid token address.");
3228
+ outputError(-32602, "Invalid token address.", { token: opts.token });
3229
+ return;
3230
+ }
3231
+ const [tokenInfo, tokenBal] = await Promise.all([
3232
+ getTokenInfo(ctx.walletClient, opts.token),
3233
+ getTokenBalance(ctx.walletClient, opts.token, accountInfo.address)
3234
+ ]);
3235
+ spinner.stop();
3236
+ outputSuccess({
3237
+ account: accountInfo.alias,
3238
+ address: accountInfo.address,
3239
+ chain: chainConfig.name,
3240
+ token: opts.token,
3241
+ symbol: tokenInfo.symbol,
3242
+ decimals: tokenInfo.decimals,
3243
+ balance: formatUnits(tokenBal, tokenInfo.decimals)
3244
+ });
3245
+ } else {
3246
+ const { ether } = await ctx.walletClient.getBalance(accountInfo.address);
3247
+ spinner.stop();
3248
+ outputSuccess({
3249
+ account: accountInfo.alias,
3250
+ address: accountInfo.address,
3251
+ chain: chainConfig.name,
3252
+ symbol: chainConfig.nativeCurrency.symbol,
3253
+ balance: ether
3254
+ });
3255
+ }
3256
+ } catch (err) {
3257
+ outputError(-32e3, sanitizeErrorMessage(err.message));
3258
+ }
3259
+ });
3260
+ query.command("tokens").description("List all ERC-20 token holdings").argument("[account]", "Account alias or address (default: current)").action(async (target) => {
3261
+ try {
3262
+ const { accountInfo, chainConfig } = resolveAccountAndChain(ctx, target);
3263
+ ctx.walletClient.initForChain(chainConfig);
3264
+ const spinner = ora4("Fetching token balances...").start();
3265
+ const rawBalances = await ctx.walletClient.getTokenBalances(accountInfo.address);
3266
+ if (rawBalances.length === 0) {
3267
+ spinner.stop();
3268
+ heading(`Token Holdings (${accountInfo.alias})`);
3269
+ info("Result", "No ERC-20 tokens found.");
3270
+ return;
3271
+ }
3272
+ spinner.text = `Fetching metadata for ${rawBalances.length} tokens...`;
3273
+ const tokens = await Promise.all(
3274
+ rawBalances.map(async ({ tokenAddress, balance }) => {
3275
+ try {
3276
+ const info2 = await getTokenInfo(ctx.walletClient, tokenAddress);
3277
+ return {
3278
+ address: tokenAddress,
3279
+ symbol: info2.symbol,
3280
+ decimals: info2.decimals,
3281
+ balance: formatUnits(balance, info2.decimals),
3282
+ rawBalance: balance
3283
+ };
3284
+ } catch {
3285
+ return {
3286
+ address: tokenAddress,
3287
+ symbol: "???",
3288
+ decimals: 18,
3289
+ balance: formatUnits(balance, 18),
3290
+ rawBalance: balance
3291
+ };
3292
+ }
3293
+ })
3294
+ );
3295
+ spinner.stop();
3296
+ heading(`Token Holdings (${accountInfo.alias})`);
3297
+ info("Account", accountInfo.address);
3298
+ info("Chain", `${chainConfig.name} (${chainConfig.id})`);
3299
+ info("Tokens", tokens.length.toString());
3300
+ console.log("");
3301
+ table(
3302
+ tokens.map((t) => ({
3303
+ address: t.address,
3304
+ symbol: t.symbol,
3305
+ decimals: String(t.decimals),
3306
+ balance: t.balance
3307
+ })),
3308
+ [
3309
+ { key: "address", label: "Token Address", width: 44 },
3310
+ { key: "symbol", label: "Symbol", width: 10 },
3311
+ { key: "decimals", label: "Decimals", width: 10 },
3312
+ { key: "balance", label: "Balance", width: 24 }
3313
+ ]
3314
+ );
3315
+ } catch (err) {
3316
+ outputError(-32e3, sanitizeErrorMessage(err.message));
3317
+ }
3318
+ });
3319
+ query.command("tx").description("Query transaction status by hash").argument("<hash>", "Transaction hash (0x...)").action(async (hash) => {
3320
+ try {
3321
+ if (!hash || !isHex66(hash)) {
3322
+ outputError(-32602, "Invalid transaction hash. Must be a 66-character hex string (0x + 64 hex chars).", {
3323
+ hash
3324
+ });
3325
+ return;
3326
+ }
3327
+ const chainConfig = resolveCurrentChain(ctx);
3328
+ ctx.walletClient.initForChain(chainConfig);
3329
+ const spinner = ora4("Querying transaction...").start();
3330
+ const receipt = await ctx.walletClient.getTransactionReceipt(hash);
3331
+ if (!receipt) {
3332
+ spinner.stop();
3333
+ outputError(-32001, "Transaction not found. It may be pending or on a different chain.", {
3334
+ hash,
3335
+ chain: chainConfig.name
3336
+ });
3337
+ return;
3338
+ }
3339
+ spinner.stop();
3340
+ outputSuccess({
3341
+ hash: receipt.transactionHash,
3342
+ status: receipt.status,
3343
+ block: receipt.blockNumber.toString(),
3344
+ from: receipt.from,
3345
+ to: receipt.to,
3346
+ gasUsed: receipt.gasUsed.toString(),
3347
+ chain: chainConfig.name
3348
+ });
3349
+ } catch (err) {
3350
+ outputError(-32e3, sanitizeErrorMessage(err.message));
3351
+ }
3352
+ });
3353
+ query.command("chain").description("Show current chain information").action(async () => {
3354
+ try {
3355
+ const chainConfig = resolveCurrentChain(ctx);
3356
+ ctx.walletClient.initForChain(chainConfig);
3357
+ const spinner = ora4("Fetching chain data...").start();
3358
+ const [blockNumber, gasPrice] = await Promise.all([
3359
+ ctx.walletClient.getBlockNumber(),
3360
+ ctx.walletClient.getGasPrice()
3361
+ ]);
3362
+ spinner.stop();
3363
+ outputSuccess({
3364
+ chainId: chainConfig.id,
3365
+ name: chainConfig.name,
3366
+ nativeCurrency: chainConfig.nativeCurrency.symbol,
3367
+ rpcEndpoint: maskApiKeys(chainConfig.endpoint),
3368
+ bundler: maskApiKeys(chainConfig.bundler),
3369
+ blockExplorer: chainConfig.blockExplorer ?? null,
3370
+ blockNumber: blockNumber.toString(),
3371
+ gasPrice: `${gasPrice.toString()} wei (${formatEther4(gasPrice * 21000n)} ETH per basic tx)`
3372
+ });
3373
+ } catch (err) {
3374
+ outputError(-32e3, sanitizeErrorMessage(err.message));
3375
+ }
3376
+ });
3377
+ query.command("address").description("Inspect any on-chain address").argument("<address>", "Address to inspect (0x...)").action(async (addr) => {
3378
+ try {
3379
+ if (!isAddress2(addr)) {
3380
+ outputError(-32602, "Invalid address.", { address: addr });
3381
+ return;
3382
+ }
3383
+ const chainConfig = resolveCurrentChain(ctx);
3384
+ ctx.walletClient.initForChain(chainConfig);
3385
+ const spinner = ora4("Querying address...").start();
3386
+ const [{ ether: balance }, code] = await Promise.all([
3387
+ ctx.walletClient.getBalance(addr),
3388
+ ctx.walletClient.getCode(addr)
3389
+ ]);
3390
+ const isContract = !!code && code !== "0x";
3391
+ const codeSize = isContract ? (code.length - 2) / 2 : 0;
3392
+ spinner.stop();
3393
+ outputSuccess({
3394
+ address: addr,
3395
+ chain: chainConfig.name,
3396
+ type: isContract ? "contract" : "EOA",
3397
+ balance: `${balance} ${chainConfig.nativeCurrency.symbol}`,
3398
+ ...isContract ? { codeSize: `${codeSize} bytes` } : {}
3399
+ });
3400
+ } catch (err) {
3401
+ outputError(-32e3, sanitizeErrorMessage(err.message));
3402
+ }
3403
+ });
3404
+ }
3405
+ function resolveAccountAndChain(ctx, target) {
3406
+ const identifier = target ?? ctx.account.currentAccount?.alias ?? ctx.account.currentAccount?.address;
3407
+ if (!identifier) {
3408
+ throw new Error("No account selected. Specify an alias/address or create an account first.");
3409
+ }
3410
+ const accountInfo = ctx.account.resolveAccount(identifier);
3411
+ if (!accountInfo) {
3412
+ throw new Error(`Account "${identifier}" not found.`);
3413
+ }
3414
+ const chainConfig = ctx.chain.chains.find((c) => c.id === accountInfo.chainId);
3415
+ if (!chainConfig) {
3416
+ throw new Error(`Chain ${accountInfo.chainId} not configured.`);
3417
+ }
3418
+ return { accountInfo, chainConfig };
3419
+ }
3420
+ function resolveCurrentChain(ctx) {
3421
+ const currentAccount = ctx.account.currentAccount;
3422
+ if (currentAccount) {
3423
+ const accountInfo = ctx.account.resolveAccount(currentAccount.alias ?? currentAccount.address);
3424
+ if (accountInfo) {
3425
+ const chain = ctx.chain.chains.find((c) => c.id === accountInfo.chainId);
3426
+ if (chain) return chain;
3427
+ }
3428
+ }
3429
+ return ctx.chain.currentChain;
3430
+ }
3431
+ function isHex66(s) {
3432
+ return /^0x[0-9a-fA-F]{64}$/.test(s);
3433
+ }
3434
+ function outputSuccess(result) {
3435
+ console.log(JSON.stringify({ success: true, result }, null, 2));
3436
+ }
3437
+ function outputError(code, message, data) {
3438
+ txError({ code, message, data });
3439
+ process.exitCode = 1;
3440
+ }
3441
+
3442
+ // src/commands/security.ts
3443
+ import ora5 from "ora";
3444
+
3445
+ // src/utils/contracts/securityHook.ts
3446
+ import { encodeFunctionData as encodeFunctionData2, parseAbi as parseAbi2, pad, toHex as toHex6 } from "viem";
3447
+ function encodeInstallHook(walletAddress, hookAddress, safetyDelay = DEFAULT_SAFETY_DELAY, capabilityFlags = DEFAULT_CAPABILITY) {
3448
+ const safetyDelayHex = pad(toHex6(safetyDelay), { size: 4 }).slice(2);
3449
+ const hookAndData = hookAddress + safetyDelayHex;
3450
+ const callData = encodeFunctionData2({
3451
+ abi: parseAbi2(["function installHook(bytes calldata hookAndData, uint8 capabilityFlags)"]),
3452
+ functionName: "installHook",
3453
+ args: [hookAndData, capabilityFlags]
3454
+ });
3455
+ return { to: walletAddress, value: "0", data: callData };
3456
+ }
3457
+ function encodeUninstallHook(walletAddress, hookAddress) {
3458
+ const callData = encodeFunctionData2({
3459
+ abi: parseAbi2(["function uninstallHook(address)"]),
3460
+ functionName: "uninstallHook",
3461
+ args: [hookAddress]
3462
+ });
3463
+ return { to: walletAddress, value: "0", data: callData };
3464
+ }
3465
+ function encodeForcePreUninstall(hookAddress) {
3466
+ const callData = encodeFunctionData2({
3467
+ abi: parseAbi2(["function forcePreUninstall()"]),
3468
+ functionName: "forcePreUninstall",
3469
+ args: []
3470
+ });
3471
+ return { to: hookAddress, value: "0", data: callData };
3472
+ }
3473
+
3474
+ // src/commands/security.ts
3475
+ var ERR_ACCOUNT_NOT_READY2 = -32002;
3476
+ var ERR_HOOK_AUTH_FAILED = -32007;
3477
+ var ERR_EMAIL_NOT_BOUND = -32010;
3478
+ var ERR_SAFETY_DELAY = -32011;
3479
+ var ERR_OTP_VERIFY_FAILED = -32012;
3480
+ var ERR_INTERNAL2 = -32e3;
3481
+ var SecurityError = class extends Error {
3482
+ code;
3483
+ data;
3484
+ constructor(code, message, data) {
3485
+ super(message);
3486
+ this.name = "SecurityError";
3487
+ this.code = code;
3488
+ this.data = data;
3489
+ }
3490
+ };
3491
+ function handleSecurityError(err) {
3492
+ if (err instanceof SecurityError) {
3493
+ txError({ code: err.code, message: sanitizeErrorMessage(err.message), data: err.data });
3494
+ } else {
3495
+ txError({
3496
+ code: ERR_INTERNAL2,
3497
+ message: sanitizeErrorMessage(err.message ?? String(err))
3498
+ });
3499
+ }
3500
+ process.exitCode = 1;
3501
+ }
3502
+ function initSecurityContext(ctx) {
3503
+ if (!ctx.deviceKey) {
3504
+ throw new SecurityError(ERR_ACCOUNT_NOT_READY2, "Wallet not initialized. Run `elytro init` first.");
3505
+ }
3506
+ const current = ctx.account.currentAccount;
3507
+ if (!current) {
3508
+ throw new SecurityError(ERR_ACCOUNT_NOT_READY2, "No account selected. Run `elytro account create` first.");
3509
+ }
3510
+ const account = ctx.account.resolveAccount(current.alias ?? current.address);
3511
+ if (!account) {
3512
+ throw new SecurityError(ERR_ACCOUNT_NOT_READY2, "Account not found.");
3513
+ }
3514
+ if (!account.isDeployed) {
3515
+ throw new SecurityError(ERR_ACCOUNT_NOT_READY2, "Account not deployed. Run `elytro account activate` first.");
3516
+ }
3517
+ const chainConfig = ctx.chain.chains.find((c) => c.id === account.chainId);
3518
+ if (!chainConfig) {
3519
+ throw new SecurityError(ERR_ACCOUNT_NOT_READY2, `No chain config for chainId ${account.chainId}.`);
3520
+ }
3521
+ ctx.walletClient.initForChain(chainConfig);
3522
+ const hookService = new SecurityHookService({
3523
+ store: ctx.store,
3524
+ graphqlEndpoint: ctx.chain.graphqlEndpoint,
3525
+ signMessageForAuth: createSignMessageForAuth({
3526
+ signDigest: (digest) => ctx.keyring.signDigest(digest),
3527
+ packRawHash: (hash) => ctx.sdk.packRawHash(hash),
3528
+ packSignature: (rawSig, valData) => ctx.sdk.packUserOpSignature(rawSig, valData)
3529
+ }),
3530
+ readContract: async (params) => {
3531
+ return ctx.walletClient.readContract(params);
3532
+ },
3533
+ getBlockTimestamp: async () => {
3534
+ const blockNumber = await ctx.walletClient.raw.getBlockNumber();
3535
+ const block = await ctx.walletClient.raw.getBlock({ blockNumber });
3536
+ return block.timestamp;
3537
+ }
3538
+ });
3539
+ return { account, chainConfig, hookService };
3540
+ }
3541
+ async function buildUserOp2(ctx, chainConfig, account, txs, spinner) {
3542
+ const userOp = await ctx.sdk.createSendUserOp(
3543
+ account.address,
3544
+ txs.map((tx) => ({ to: tx.to, value: tx.value, data: tx.data }))
3545
+ );
3546
+ const feeData = await ctx.sdk.getFeeData(chainConfig);
3547
+ userOp.maxFeePerGas = feeData.maxFeePerGas;
3548
+ userOp.maxPriorityFeePerGas = feeData.maxPriorityFeePerGas;
3549
+ spinner.text = "Estimating gas...";
3550
+ const gasEstimate = await ctx.sdk.estimateUserOp(userOp, { fakeBalance: true });
3551
+ userOp.callGasLimit = gasEstimate.callGasLimit;
3552
+ userOp.verificationGasLimit = gasEstimate.verificationGasLimit;
3553
+ userOp.preVerificationGas = gasEstimate.preVerificationGas;
3554
+ spinner.text = "Checking sponsorship...";
3555
+ try {
3556
+ const { requestSponsorship: requestSponsorship2, applySponsorToUserOp: applySponsorToUserOp2 } = await Promise.resolve().then(() => (init_sponsor(), sponsor_exports));
3557
+ const { sponsor: sponsorResult } = await requestSponsorship2(
3558
+ ctx.chain.graphqlEndpoint,
3559
+ account.chainId,
3560
+ ctx.sdk.entryPoint,
3561
+ userOp
3562
+ );
3563
+ if (sponsorResult) applySponsorToUserOp2(userOp, sponsorResult);
3564
+ } catch {
3565
+ }
3566
+ return userOp;
3567
+ }
3568
+ async function signAndSend(ctx, chainConfig, userOp, spinner) {
3569
+ spinner.text = "Signing...";
3570
+ const { packedHash, validationData } = await ctx.sdk.getUserOpHash(userOp);
3571
+ const rawSignature = await ctx.keyring.signDigest(packedHash);
3572
+ userOp.signature = await ctx.sdk.packUserOpSignature(rawSignature, validationData);
3573
+ spinner.text = "Sending UserOp...";
3574
+ const opHash = await ctx.sdk.sendUserOp(userOp);
3575
+ spinner.text = "Waiting for receipt...";
3576
+ const receipt = await ctx.sdk.waitForReceipt(opHash);
3577
+ spinner.stop();
3578
+ if (receipt.success) {
3579
+ success("Transaction confirmed!");
3580
+ info("Tx Hash", receipt.transactionHash);
3581
+ if (chainConfig.blockExplorer) {
3582
+ info("Explorer", `${chainConfig.blockExplorer}/tx/${receipt.transactionHash}`);
3583
+ }
3584
+ } else {
3585
+ throw new SecurityError(ERR_INTERNAL2, "Transaction reverted on-chain.", {
3586
+ txHash: receipt.transactionHash
3587
+ });
3588
+ }
3589
+ }
3590
+ async function signWithHookAndSend(ctx, chainConfig, account, hookService, userOp, spinner) {
3591
+ spinner.text = "Signing...";
3592
+ const { packedHash, validationData } = await ctx.sdk.getUserOpHash(userOp);
3593
+ const rawSignature = await ctx.keyring.signDigest(packedHash);
3594
+ userOp.signature = await ctx.sdk.packUserOpSignature(rawSignature, validationData);
3595
+ spinner.text = "Requesting hook authorization...";
3596
+ let hookResult = await hookService.getHookSignature(account.address, account.chainId, ctx.sdk.entryPoint, userOp);
3597
+ if (hookResult.error) {
3598
+ spinner.stop();
3599
+ hookResult = await handleOtpChallenge(hookService, account, ctx, userOp, hookResult);
3600
+ }
3601
+ if (!spinner.isSpinning) spinner.start("Packing signature...");
3602
+ const hookAddress = SECURITY_HOOK_ADDRESS_MAP[account.chainId];
3603
+ userOp.signature = await ctx.sdk.packUserOpSignatureWithHook(
3604
+ rawSignature,
3605
+ validationData,
3606
+ hookAddress,
3607
+ hookResult.signature
3608
+ );
3609
+ spinner.text = "Sending UserOp...";
3610
+ const opHash = await ctx.sdk.sendUserOp(userOp);
3611
+ spinner.text = "Waiting for receipt...";
3612
+ const receipt = await ctx.sdk.waitForReceipt(opHash);
3613
+ spinner.stop();
3614
+ if (receipt.success) {
3615
+ success("Transaction confirmed!");
3616
+ info("Tx Hash", receipt.transactionHash);
3617
+ if (chainConfig.blockExplorer) {
3618
+ info("Explorer", `${chainConfig.blockExplorer}/tx/${receipt.transactionHash}`);
3619
+ }
3620
+ } else {
3621
+ throw new SecurityError(ERR_INTERNAL2, "Transaction reverted on-chain.", {
3622
+ txHash: receipt.transactionHash
3623
+ });
3624
+ }
3625
+ }
3626
+ async function handleOtpChallenge(hookService, account, ctx, userOp, hookResult) {
3627
+ const err = hookResult.error;
3628
+ const errCode = err.code ?? "UNKNOWN";
3629
+ if (errCode !== "OTP_REQUIRED" && errCode !== "SPENDING_LIMIT_EXCEEDED") {
3630
+ throw new SecurityError(ERR_HOOK_AUTH_FAILED, `Hook authorization failed: ${err.message ?? errCode}`);
3631
+ }
3632
+ warn(err.message ?? `Verification required (${errCode}).`);
3633
+ if (err.maskedEmail) {
3634
+ info("OTP sent to", err.maskedEmail);
3635
+ }
3636
+ if (errCode === "SPENDING_LIMIT_EXCEEDED" && err.projectedSpendUsdCents !== void 0) {
3637
+ info("Projected spend", `$${(err.projectedSpendUsdCents / 100).toFixed(2)}`);
3638
+ info("Daily limit", `$${((err.dailyLimitUsdCents ?? 0) / 100).toFixed(2)}`);
3639
+ }
3640
+ const otpCode = await askInput("Enter the 6-digit OTP code:");
3641
+ const verifySpinner = ora5("Verifying OTP...").start();
3642
+ try {
3643
+ await hookService.verifySecurityOtp(account.address, account.chainId, err.challengeId, otpCode.trim());
3644
+ verifySpinner.text = "OTP verified. Retrying authorization...";
3645
+ const retryResult = await hookService.getHookSignature(
3646
+ account.address,
3647
+ account.chainId,
3648
+ ctx.sdk.entryPoint,
3649
+ userOp
3650
+ );
3651
+ verifySpinner.stop();
3652
+ if (retryResult.error) {
3653
+ throw new SecurityError(ERR_HOOK_AUTH_FAILED, `Authorization failed after OTP: ${retryResult.error.message}`);
3654
+ }
3655
+ return retryResult;
3656
+ } catch (e) {
3657
+ verifySpinner.stop();
3658
+ if (e instanceof SecurityError) throw e;
3659
+ throw new SecurityError(
3660
+ ERR_OTP_VERIFY_FAILED,
3661
+ `OTP verification failed: ${sanitizeErrorMessage(e.message)}`
3662
+ );
3663
+ }
3664
+ }
3665
+ function registerSecurityCommand(program2, ctx) {
3666
+ const security = program2.command("security").description("SecurityHook (2FA & spending limits)");
3667
+ security.command("status").description("Show SecurityHook status and security profile").action(async () => {
3668
+ try {
3669
+ const { account, chainConfig, hookService } = initSecurityContext(ctx);
3670
+ await ctx.sdk.initForChain(chainConfig);
3671
+ const spinner = ora5("Querying hook status...").start();
3672
+ const hookStatus = await hookService.getHookStatus(account.address, account.chainId);
3673
+ let profile = null;
3674
+ try {
3675
+ profile = await hookService.loadSecurityProfile(account.address, account.chainId);
3676
+ } catch {
3677
+ }
3678
+ spinner.stop();
3679
+ heading("Security Status");
3680
+ info("Account", `${account.alias} (${address(account.address)})`);
3681
+ info("Chain", `${chainConfig.name} (${account.chainId})`);
3682
+ info("Hook Installed", hookStatus.installed ? "Yes" : "No");
3683
+ if (hookStatus.installed) {
3684
+ info("Hook Address", address(hookStatus.hookAddress));
3685
+ info(
3686
+ "Capabilities",
3687
+ [
3688
+ hookStatus.capabilities.preUserOpValidation && "UserOp",
3689
+ hookStatus.capabilities.preIsValidSignature && "Signature"
3690
+ ].filter(Boolean).join(" + ") || "None"
3691
+ );
3692
+ if (hookStatus.forceUninstall.initiated) {
3693
+ info(
3694
+ "Force Uninstall",
3695
+ hookStatus.forceUninstall.canExecute ? "Ready to execute" : `Pending until ${hookStatus.forceUninstall.availableAfter}`
3696
+ );
3697
+ }
3698
+ }
3699
+ if (profile) {
3700
+ console.log("");
3701
+ info("Email", profile.maskedEmail ?? profile.email ?? "Not bound");
3702
+ info("Email Verified", profile.emailVerified ? "Yes" : "No");
3703
+ if (profile.dailyLimitUsdCents !== void 0) {
3704
+ info("Daily Limit", `$${(profile.dailyLimitUsdCents / 100).toFixed(2)}`);
3705
+ }
3706
+ } else if (hookStatus.installed) {
3707
+ console.log("");
3708
+ warn("Security profile not loaded (not yet authenticated or email not bound).");
3709
+ }
3710
+ } catch (err) {
3711
+ handleSecurityError(err);
3712
+ }
3713
+ });
3714
+ const twofa = security.command("2fa").description("Install/uninstall SecurityHook (2FA)");
3715
+ twofa.command("install").description("Install SecurityHook on current account").option(
3716
+ "--capability <flags>",
3717
+ "Capability flags: 1=SIGNATURE_ONLY, 2=USER_OP_ONLY, 3=BOTH",
3718
+ String(DEFAULT_CAPABILITY)
3719
+ ).action(async (opts) => {
3720
+ try {
3721
+ const { account, chainConfig, hookService } = initSecurityContext(ctx);
3722
+ await ctx.sdk.initForChain(chainConfig);
3723
+ const spinner = ora5("Checking hook status...").start();
3724
+ const currentStatus = await hookService.getHookStatus(account.address, account.chainId);
3725
+ spinner.stop();
3726
+ if (currentStatus.installed) {
3727
+ warn("SecurityHook is already installed on this account.");
3728
+ return;
3729
+ }
3730
+ const hookAddress = SECURITY_HOOK_ADDRESS_MAP[account.chainId];
3731
+ if (!hookAddress) {
3732
+ throw new SecurityError(ERR_INTERNAL2, `SecurityHook not deployed on chain ${account.chainId}.`);
3733
+ }
3734
+ const capabilityFlags = Number(opts.capability);
3735
+ if (![1, 2, 3].includes(capabilityFlags)) {
3736
+ throw new SecurityError(ERR_INTERNAL2, "Invalid capability flags. Use 1, 2, or 3.");
3737
+ }
3738
+ heading("Install SecurityHook");
3739
+ info("Account", `${account.alias} (${address(account.address)})`);
3740
+ info("Hook Address", address(hookAddress));
3741
+ info("Capability", CAPABILITY_LABELS[capabilityFlags]);
3742
+ info("Safety Delay", `${DEFAULT_SAFETY_DELAY}s`);
3743
+ const confirmed = await askConfirm("Proceed with hook installation?");
3744
+ if (!confirmed) {
3745
+ warn("Cancelled.");
3746
+ return;
3747
+ }
3748
+ const installTx = encodeInstallHook(account.address, hookAddress, DEFAULT_SAFETY_DELAY, capabilityFlags);
3749
+ const buildSpinner = ora5("Building UserOp...").start();
3750
+ try {
3751
+ const userOp = await buildUserOp2(ctx, chainConfig, account, [installTx], buildSpinner);
3752
+ await signAndSend(ctx, chainConfig, userOp, buildSpinner);
3753
+ success("SecurityHook installed successfully!");
3754
+ } catch (innerErr) {
3755
+ buildSpinner.stop();
3756
+ throw innerErr;
3757
+ }
3758
+ } catch (err) {
3759
+ handleSecurityError(err);
3760
+ }
3761
+ });
3762
+ twofa.command("uninstall").description("Uninstall SecurityHook from current account").option("--force", "Start force-uninstall countdown (bypass hook signature)").option("--execute", "Execute force-uninstall after safety delay has elapsed").action(async (opts) => {
3763
+ try {
3764
+ const { account, chainConfig, hookService } = initSecurityContext(ctx);
3765
+ await ctx.sdk.initForChain(chainConfig);
3766
+ const spinner = ora5("Checking hook status...").start();
3767
+ const currentStatus = await hookService.getHookStatus(account.address, account.chainId);
3768
+ spinner.stop();
3769
+ if (!currentStatus.installed) {
3770
+ warn("SecurityHook is not installed on this account.");
3771
+ return;
3772
+ }
3773
+ const hookAddress = currentStatus.hookAddress;
3774
+ if (opts.force && opts.execute) {
3775
+ await handleForceExecute(ctx, chainConfig, account, currentStatus);
3776
+ } else if (opts.force) {
3777
+ await handleForceStart(ctx, chainConfig, account, currentStatus, hookAddress);
3778
+ } else {
3779
+ await handleNormalUninstall(ctx, chainConfig, account, hookService, hookAddress);
3780
+ }
3781
+ } catch (err) {
3782
+ handleSecurityError(err);
3783
+ }
3784
+ });
3785
+ const email = security.command("email").description("Manage security email for OTP");
3786
+ email.command("bind").description("Bind an email address for OTP delivery").argument("<email>", "Email address to bind").action(async (emailAddr) => {
3787
+ try {
3788
+ const { account, chainConfig, hookService } = initSecurityContext(ctx);
3789
+ await ctx.sdk.initForChain(chainConfig);
3790
+ const spinner = ora5("Requesting email binding...").start();
3791
+ let bindingResult;
3792
+ try {
3793
+ bindingResult = await hookService.requestEmailBinding(account.address, account.chainId, emailAddr);
3794
+ } catch (err) {
3795
+ spinner.stop();
3796
+ throw new SecurityError(ERR_HOOK_AUTH_FAILED, sanitizeErrorMessage(err.message));
3797
+ }
3798
+ spinner.stop();
3799
+ info("OTP sent to", bindingResult.maskedEmail);
3800
+ info("Expires at", bindingResult.otpExpiresAt);
3801
+ const otpCode = await askInput("Enter the 6-digit OTP code:");
3802
+ const confirmSpinner = ora5("Confirming email binding...").start();
3803
+ try {
3804
+ const profile = await hookService.confirmEmailBinding(
3805
+ account.address,
3806
+ account.chainId,
3807
+ bindingResult.bindingId,
3808
+ otpCode.trim()
3809
+ );
3810
+ confirmSpinner.stop();
3811
+ success("Email bound successfully!");
3812
+ info("Email", profile.maskedEmail ?? profile.email ?? emailAddr);
3813
+ info("Verified", profile.emailVerified ? "Yes" : "No");
3814
+ } catch (err) {
3815
+ confirmSpinner.stop();
3816
+ throw new SecurityError(
3817
+ ERR_OTP_VERIFY_FAILED,
3818
+ `OTP verification failed: ${sanitizeErrorMessage(err.message)}`
3819
+ );
3820
+ }
3821
+ } catch (err) {
3822
+ handleSecurityError(err);
3823
+ }
3824
+ });
3825
+ email.command("change").description("Change bound email address").argument("<email>", "New email address").action(async (emailAddr) => {
3826
+ try {
3827
+ const { account, chainConfig, hookService } = initSecurityContext(ctx);
3828
+ await ctx.sdk.initForChain(chainConfig);
3829
+ const spinner = ora5("Requesting email change...").start();
3830
+ let bindingResult;
3831
+ try {
3832
+ bindingResult = await hookService.changeWalletEmail(account.address, account.chainId, emailAddr);
3833
+ } catch (err) {
3834
+ spinner.stop();
3835
+ throw new SecurityError(ERR_HOOK_AUTH_FAILED, sanitizeErrorMessage(err.message));
3836
+ }
3837
+ spinner.stop();
3838
+ info("OTP sent to", bindingResult.maskedEmail);
3839
+ const otpCode = await askInput("Enter the 6-digit OTP code:");
3840
+ const confirmSpinner = ora5("Confirming email change...").start();
3841
+ try {
3842
+ const profile = await hookService.confirmEmailBinding(
3843
+ account.address,
3844
+ account.chainId,
3845
+ bindingResult.bindingId,
3846
+ otpCode.trim()
3847
+ );
3848
+ confirmSpinner.stop();
3849
+ success("Email changed successfully!");
3850
+ info("Email", profile.maskedEmail ?? profile.email ?? emailAddr);
3851
+ } catch (err) {
3852
+ confirmSpinner.stop();
3853
+ throw new SecurityError(
3854
+ ERR_OTP_VERIFY_FAILED,
3855
+ `OTP verification failed: ${sanitizeErrorMessage(err.message)}`
3856
+ );
3857
+ }
3858
+ } catch (err) {
3859
+ handleSecurityError(err);
3860
+ }
3861
+ });
3862
+ security.command("spending-limit").description("View or set daily spending limit (USD)").argument("[amount]", 'Daily limit in USD (e.g. "100" for $100). Omit to view current limit.').action(async (amountStr) => {
3863
+ try {
3864
+ const { account, chainConfig, hookService } = initSecurityContext(ctx);
3865
+ await ctx.sdk.initForChain(chainConfig);
3866
+ if (!amountStr) {
3867
+ await showSpendingLimit(hookService, account);
3868
+ } else {
3869
+ await setSpendingLimit(hookService, account, amountStr);
3870
+ }
3871
+ } catch (err) {
3872
+ handleSecurityError(err);
3873
+ }
3874
+ });
3875
+ }
3876
+ async function handleForceExecute(ctx, chainConfig, account, currentStatus) {
3877
+ if (!currentStatus.forceUninstall.initiated) {
3878
+ throw new SecurityError(
3879
+ ERR_SAFETY_DELAY,
3880
+ "Force-uninstall not initiated. Run `security 2fa uninstall --force` first."
3881
+ );
3882
+ }
3883
+ if (!currentStatus.forceUninstall.canExecute) {
3884
+ throw new SecurityError(
3885
+ ERR_SAFETY_DELAY,
3886
+ `Safety delay not elapsed. Available after ${currentStatus.forceUninstall.availableAfter}.`
3887
+ );
3888
+ }
3889
+ heading("Execute Force Uninstall");
3890
+ info("Account", `${account.alias} (${address(account.address)})`);
3891
+ const confirmed = await askConfirm("Execute force uninstall? This will remove the SecurityHook.");
3892
+ if (!confirmed) {
3893
+ warn("Cancelled.");
3894
+ return;
3895
+ }
3896
+ const uninstallTx = encodeUninstallHook(account.address, currentStatus.hookAddress);
3897
+ const spinner = ora5("Executing force uninstall...").start();
3898
+ try {
3899
+ const userOp = await buildUserOp2(ctx, chainConfig, account, [uninstallTx], spinner);
3900
+ await signAndSend(ctx, chainConfig, userOp, spinner);
3901
+ } catch (err) {
3902
+ spinner.stop();
3903
+ throw err;
3904
+ }
3905
+ }
3906
+ async function handleForceStart(ctx, chainConfig, account, currentStatus, hookAddress) {
3907
+ if (currentStatus.forceUninstall.initiated) {
3908
+ if (currentStatus.forceUninstall.canExecute) {
3909
+ info("Force Uninstall", "Ready to execute. Run `security 2fa uninstall --force --execute`.");
3910
+ } else {
3911
+ info(
3912
+ "Force Uninstall",
3913
+ `Already initiated. Available after ${currentStatus.forceUninstall.availableAfter}.`
3914
+ );
3915
+ }
3916
+ return;
3917
+ }
3918
+ heading("Start Force Uninstall");
3919
+ info("Account", `${account.alias} (${address(account.address)})`);
3920
+ warn(`After starting, you must wait the safety delay (${DEFAULT_SAFETY_DELAY}s) before executing.`);
3921
+ const confirmed = await askConfirm("Start force-uninstall countdown?");
3922
+ if (!confirmed) {
3923
+ warn("Cancelled.");
3924
+ return;
3925
+ }
3926
+ const preUninstallTx = encodeForcePreUninstall(hookAddress);
3927
+ const spinner = ora5("Starting force-uninstall countdown...").start();
3928
+ try {
3929
+ const userOp = await buildUserOp2(ctx, chainConfig, account, [preUninstallTx], spinner);
3930
+ await signAndSend(ctx, chainConfig, userOp, spinner);
3931
+ } catch (err) {
3932
+ spinner.stop();
3933
+ throw err;
3934
+ }
3935
+ }
3936
+ async function handleNormalUninstall(ctx, chainConfig, account, hookService, hookAddress) {
3937
+ heading("Uninstall SecurityHook");
3938
+ info("Account", `${account.alias} (${address(account.address)})`);
3939
+ const confirmed = await askConfirm("Proceed with hook uninstall? (requires 2FA approval)");
3940
+ if (!confirmed) {
3941
+ warn("Cancelled.");
3942
+ return;
3943
+ }
3944
+ const uninstallTx = encodeUninstallHook(account.address, hookAddress);
3945
+ const spinner = ora5("Building UserOp...").start();
3946
+ try {
3947
+ const userOp = await buildUserOp2(ctx, chainConfig, account, [uninstallTx], spinner);
3948
+ await signWithHookAndSend(ctx, chainConfig, account, hookService, userOp, spinner);
3949
+ } catch (err) {
3950
+ spinner.stop();
3951
+ throw err;
3952
+ }
3953
+ }
3954
+ async function showSpendingLimit(hookService, account) {
3955
+ const spinner = ora5("Loading security profile...").start();
3956
+ let profile;
3957
+ try {
3958
+ profile = await hookService.loadSecurityProfile(account.address, account.chainId);
3959
+ } catch (err) {
3960
+ spinner.stop();
3961
+ throw err;
3962
+ }
3963
+ spinner.stop();
3964
+ if (!profile) {
3965
+ warn("No security profile found. Bind an email first: `elytro security email bind <email>`.");
3966
+ return;
3967
+ }
3968
+ heading("Spending Limit");
3969
+ info(
3970
+ "Daily Limit",
3971
+ profile.dailyLimitUsdCents !== void 0 ? `$${(profile.dailyLimitUsdCents / 100).toFixed(2)}` : "Not set"
3972
+ );
3973
+ info("Email", profile.maskedEmail ?? "Not bound");
3974
+ }
3975
+ async function setSpendingLimit(hookService, account, amountStr) {
3976
+ const amountUsd = parseFloat(amountStr);
3977
+ if (isNaN(amountUsd) || amountUsd < 0) {
3978
+ throw new SecurityError(ERR_INTERNAL2, "Invalid amount. Provide a positive number in USD.");
3979
+ }
3980
+ const dailyLimitUsdCents = Math.round(amountUsd * 100);
3981
+ heading("Set Daily Spending Limit");
3982
+ info("New Limit", `$${amountUsd.toFixed(2)}`);
3983
+ const spinner = ora5("Requesting OTP for limit change...").start();
3984
+ let otpResult;
3985
+ try {
3986
+ otpResult = await hookService.requestDailyLimitOtp(account.address, account.chainId, dailyLimitUsdCents);
3987
+ } catch (err) {
3988
+ spinner.stop();
3989
+ const msg = err.message ?? "";
3990
+ if (msg.includes("EMAIL") || msg.includes("email") || msg.includes("NOT_FOUND")) {
3991
+ throw new SecurityError(ERR_EMAIL_NOT_BOUND, "Email not bound. Run `elytro security email bind <email>` first.");
3992
+ }
3993
+ throw new SecurityError(ERR_HOOK_AUTH_FAILED, sanitizeErrorMessage(msg));
3994
+ }
3995
+ spinner.stop();
3996
+ info("OTP sent to", otpResult.maskedEmail);
3997
+ const otpCode = await askInput("Enter the 6-digit OTP code:");
3998
+ const setSpinner = ora5("Setting daily limit...").start();
3999
+ try {
4000
+ await hookService.setDailyLimit(account.address, account.chainId, dailyLimitUsdCents, otpCode.trim());
4001
+ setSpinner.stop();
4002
+ success(`Daily limit set to $${amountUsd.toFixed(2)}.`);
4003
+ } catch (err) {
4004
+ setSpinner.stop();
4005
+ throw new SecurityError(
4006
+ ERR_OTP_VERIFY_FAILED,
4007
+ `Failed to set limit: ${sanitizeErrorMessage(err.message)}`
4008
+ );
4009
+ }
4010
+ }
4011
+
4012
+ // src/commands/config.ts
4013
+ var KEY_MAP = {
4014
+ "alchemy-key": "alchemyKey",
4015
+ "pimlico-key": "pimlicoKey"
4016
+ };
4017
+ var VALID_KEYS = Object.keys(KEY_MAP);
4018
+ function maskKey(value) {
4019
+ if (value.length <= 6) return "***";
4020
+ return value.slice(0, 4) + "***" + value.slice(-4);
4021
+ }
4022
+ function registerConfigCommand(program2, ctx) {
4023
+ const configCmd = program2.command("config").description("Manage CLI configuration (API keys, RPC endpoints)");
4024
+ configCmd.command("show").description("Show current endpoint configuration").action(() => {
4025
+ heading("Configuration");
4026
+ const keys = ctx.chain.getUserKeys();
4027
+ const hasAlchemy = !!keys.alchemyKey;
4028
+ const hasPimlico = !!keys.pimlicoKey;
4029
+ info("RPC provider", hasAlchemy ? "Alchemy (user-configured)" : "Public (publicnode.com)");
4030
+ info("Bundler provider", hasPimlico ? "Pimlico (user-configured)" : "Public (pimlico.io/public)");
4031
+ if (keys.alchemyKey) {
4032
+ info("Alchemy key", maskKey(keys.alchemyKey));
4033
+ }
4034
+ if (keys.pimlicoKey) {
4035
+ info("Pimlico key", maskKey(keys.pimlicoKey));
4036
+ }
4037
+ console.log("");
4038
+ const chain = ctx.chain.currentChain;
4039
+ info("Current chain", `${chain.name} (${chain.id})`);
4040
+ info("RPC endpoint", maskApiKeys(chain.endpoint));
4041
+ info("Bundler", maskApiKeys(chain.bundler));
4042
+ if (!hasAlchemy || !hasPimlico) {
4043
+ console.log("");
4044
+ warn("Public endpoints have rate limits. Set your own keys for production use:");
4045
+ if (!hasAlchemy) console.log(" elytro config set alchemy-key <YOUR_KEY>");
4046
+ if (!hasPimlico) console.log(" elytro config set pimlico-key <YOUR_KEY>");
4047
+ }
4048
+ });
4049
+ configCmd.command("set <key> <value>").description(`Set an API key (${VALID_KEYS.join(" | ")})`).action(async (key, value) => {
4050
+ const mapped = KEY_MAP[key];
4051
+ if (!mapped) {
4052
+ error(`Unknown key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
4053
+ process.exitCode = 1;
4054
+ return;
4055
+ }
4056
+ await ctx.chain.setUserKey(mapped, value);
4057
+ success(`${key} saved. Endpoints updated.`);
4058
+ const chain = ctx.chain.currentChain;
4059
+ info("RPC endpoint", maskApiKeys(chain.endpoint));
4060
+ info("Bundler", maskApiKeys(chain.bundler));
4061
+ });
4062
+ configCmd.command("remove <key>").description(`Remove an API key and revert to public endpoint (${VALID_KEYS.join(" | ")})`).action(async (key) => {
4063
+ const mapped = KEY_MAP[key];
4064
+ if (!mapped) {
4065
+ error(`Unknown key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
4066
+ process.exitCode = 1;
4067
+ return;
4068
+ }
4069
+ await ctx.chain.removeUserKey(mapped);
4070
+ success(`${key} removed. Reverted to public endpoint.`);
4071
+ });
4072
+ }
4073
+
4074
+ // src/index.ts
4075
+ var program = new Command();
4076
+ program.name("elytro").description("Elytro \u2014 ERC-4337 Smart Account Wallet CLI").version("0.0.1");
4077
+ async function main() {
4078
+ let ctx = null;
4079
+ try {
4080
+ ctx = await createAppContext();
4081
+ registerInitCommand(program, ctx);
4082
+ registerAccountCommand(program, ctx);
4083
+ registerTxCommand(program, ctx);
4084
+ registerQueryCommand(program, ctx);
4085
+ registerSecurityCommand(program, ctx);
4086
+ registerConfigCommand(program, ctx);
4087
+ await program.parseAsync(process.argv);
4088
+ } catch (err) {
4089
+ error(sanitizeErrorMessage(err.message));
4090
+ process.exitCode = 1;
4091
+ } finally {
4092
+ ctx?.keyring.lock();
4093
+ }
4094
+ }
4095
+ main();