@elytro/cli 0.1.0 → 0.4.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 (3) hide show
  1. package/README.md +89 -0
  2. package/dist/index.js +790 -641
  3. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -363,6 +363,8 @@ var STORAGE_KEY = "keyring";
363
363
  var KeyringService = class {
364
364
  store;
365
365
  vault = null;
366
+ /** Vault key kept in memory for re-encryption operations (addOwner, switchOwner). */
367
+ vaultKey = null;
366
368
  constructor(store) {
367
369
  this.store = store;
368
370
  }
@@ -373,9 +375,9 @@ var KeyringService = class {
373
375
  }
374
376
  /**
375
377
  * Create a brand-new vault with one owner.
376
- * Called during `elytro init`. Encrypts with device key.
378
+ * Called during `elytro init`. Encrypts with vault key.
377
379
  */
378
- async createNewOwner(deviceKey) {
380
+ async createNewOwner(vaultKey) {
379
381
  const privateKey = generatePrivateKey();
380
382
  const account = privateKeyToAccount(privateKey);
381
383
  const owner = { id: account.address, key: privateKey };
@@ -383,26 +385,32 @@ var KeyringService = class {
383
385
  owners: [owner],
384
386
  currentOwnerId: account.address
385
387
  };
386
- const encrypted = await encryptWithKey(deviceKey, vault);
388
+ const encrypted = await encryptWithKey(vaultKey, vault);
387
389
  await this.store.save(STORAGE_KEY, encrypted);
388
390
  this.vault = vault;
391
+ this.vaultKey = new Uint8Array(vaultKey);
389
392
  return account.address;
390
393
  }
391
394
  // ─── Unlock / Access ────────────────────────────────────────────
392
395
  /**
393
- * Decrypt the vault with the device key.
394
- * Called automatically by context at CLI startup.
396
+ * Decrypt the vault with the vault key.
397
+ * Called automatically by context at CLI startup via SecretProvider.
395
398
  */
396
- async unlock(deviceKey) {
399
+ async unlock(vaultKey) {
397
400
  const encrypted = await this.store.load(STORAGE_KEY);
398
401
  if (!encrypted) {
399
402
  throw new Error("Keyring not initialized. Run `elytro init` first.");
400
403
  }
401
- this.vault = await decryptWithKey(deviceKey, encrypted);
404
+ this.vault = await decryptWithKey(vaultKey, encrypted);
405
+ this.vaultKey = new Uint8Array(vaultKey);
402
406
  }
403
- /** Lock the vault, clearing decrypted keys from memory. */
407
+ /** Lock the vault, clearing decrypted keys and vault key from memory. */
404
408
  lock() {
405
409
  this.vault = null;
410
+ if (this.vaultKey) {
411
+ this.vaultKey.fill(0);
412
+ this.vaultKey = null;
413
+ }
406
414
  }
407
415
  get isUnlocked() {
408
416
  return this.vault !== null;
@@ -441,22 +449,22 @@ var KeyringService = class {
441
449
  return privateKeyToAccount(key);
442
450
  }
443
451
  // ─── Multi-owner management ─────────────────────────────────────
444
- async addOwner(deviceKey) {
452
+ async addOwner() {
445
453
  this.ensureUnlocked();
446
454
  const privateKey = generatePrivateKey();
447
455
  const account = privateKeyToAccount(privateKey);
448
456
  this.vault.owners.push({ id: account.address, key: privateKey });
449
- await this.persistVault(deviceKey);
457
+ await this.persistVault();
450
458
  return account.address;
451
459
  }
452
- async switchOwner(ownerId, deviceKey) {
460
+ async switchOwner(ownerId) {
453
461
  this.ensureUnlocked();
454
462
  const exists = this.vault.owners.some((o) => o.id === ownerId);
455
463
  if (!exists) {
456
464
  throw new Error(`Owner ${ownerId} not found in vault.`);
457
465
  }
458
466
  this.vault.currentOwnerId = ownerId;
459
- await this.persistVault(deviceKey);
467
+ await this.persistVault();
460
468
  }
461
469
  // ─── Export / Import (password-based for portability) ───────────
462
470
  /**
@@ -469,18 +477,20 @@ var KeyringService = class {
469
477
  }
470
478
  /**
471
479
  * Import vault from a password-encrypted backup.
472
- * Decrypts with the backup password, then re-encrypts with device key.
480
+ * Decrypts with the backup password, then re-encrypts with vault key.
473
481
  */
474
- async importVault(encrypted, password2, deviceKey) {
482
+ async importVault(encrypted, password2, vaultKey) {
475
483
  const vault = await decrypt(password2, encrypted);
476
484
  this.vault = vault;
477
- const reEncrypted = await encryptWithKey(deviceKey, vault);
485
+ this.vaultKey = new Uint8Array(vaultKey);
486
+ const reEncrypted = await encryptWithKey(vaultKey, vault);
478
487
  await this.store.save(STORAGE_KEY, reEncrypted);
479
488
  }
480
- // ─── Rekey (device key rotation) ───────────────────────────────
481
- async rekey(newDeviceKey) {
489
+ // ─── Rekey (vault key rotation) ───────────────────────────────
490
+ async rekey(newVaultKey) {
482
491
  this.ensureUnlocked();
483
- await this.persistVault(newDeviceKey);
492
+ this.vaultKey = new Uint8Array(newVaultKey);
493
+ await this.persistVault();
484
494
  }
485
495
  // ─── Internal ───────────────────────────────────────────────────
486
496
  getCurrentKey() {
@@ -498,9 +508,10 @@ var KeyringService = class {
498
508
  throw new Error("Keyring is locked. Run `elytro init` first.");
499
509
  }
500
510
  }
501
- async persistVault(deviceKey) {
511
+ async persistVault() {
502
512
  if (!this.vault) throw new Error("No vault to persist.");
503
- const encrypted = await encryptWithKey(deviceKey, this.vault);
513
+ if (!this.vaultKey) throw new Error("No vault key available for re-encryption.");
514
+ const encrypted = await encryptWithKey(this.vaultKey, this.vault);
504
515
  await this.store.save(STORAGE_KEY, encrypted);
505
516
  }
506
517
  };
@@ -555,11 +566,36 @@ function resolveBundler(chainId, pimlicoKey) {
555
566
  return PUBLIC_BUNDLER[chainId] ?? PUBLIC_BUNDLER[11155420];
556
567
  }
557
568
  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" }
569
+ {
570
+ id: 1,
571
+ name: "Ethereum",
572
+ nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
573
+ blockExplorer: "https://etherscan.io"
574
+ },
575
+ {
576
+ id: 10,
577
+ name: "Optimism",
578
+ nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
579
+ blockExplorer: "https://optimistic.etherscan.io"
580
+ },
581
+ {
582
+ id: 42161,
583
+ name: "Arbitrum One",
584
+ nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
585
+ blockExplorer: "https://arbiscan.io"
586
+ },
587
+ {
588
+ id: 11155111,
589
+ name: "Sepolia",
590
+ nativeCurrency: { name: "Sepolia ETH", symbol: "ETH", decimals: 18 },
591
+ blockExplorer: "https://sepolia.etherscan.io"
592
+ },
593
+ {
594
+ id: 11155420,
595
+ name: "Optimism Sepolia",
596
+ nativeCurrency: { name: "Sepolia ETH", symbol: "ETH", decimals: 18 },
597
+ blockExplorer: "https://sepolia-optimism.etherscan.io"
598
+ }
563
599
  ];
564
600
  function buildChains(alchemyKey, pimlicoKey) {
565
601
  return CHAIN_META.map((meta) => ({
@@ -1339,10 +1375,11 @@ var AccountService = class {
1339
1375
  * Multiple accounts per chain are allowed — each gets a unique
1340
1376
  * CREATE2 index, producing a different contract address.
1341
1377
  *
1342
- * @param chainId - Required. The target chain.
1343
- * @param alias - Optional. Human-readable name. Auto-generated if omitted.
1378
+ * @param chainId - Required. The target chain.
1379
+ * @param alias - Optional. Human-readable name. Auto-generated if omitted.
1380
+ * @param securityIntent - Optional. Security intent (email, dailyLimit) to execute during activate.
1344
1381
  */
1345
- async createAccount(chainId, alias) {
1382
+ async createAccount(chainId, alias, securityIntent) {
1346
1383
  const owner = this.keyring.currentOwner;
1347
1384
  if (!owner) {
1348
1385
  throw new Error("Keyring is locked. Unlock first.");
@@ -1360,7 +1397,8 @@ var AccountService = class {
1360
1397
  owner,
1361
1398
  index,
1362
1399
  isDeployed: false,
1363
- isRecoveryEnabled: false
1400
+ isRecoveryEnabled: false,
1401
+ ...securityIntent && { securityIntent }
1364
1402
  };
1365
1403
  this.state.accounts.push(account);
1366
1404
  this.state.currentAddress = address2;
@@ -1431,6 +1469,45 @@ var AccountService = class {
1431
1469
  balance
1432
1470
  };
1433
1471
  }
1472
+ // ─── Security Intent / Status ─────────────────────────────
1473
+ /**
1474
+ * Patch the temporary security intent (e.g. store emailBindingId).
1475
+ * Only valid before activate — intent is deleted after activate.
1476
+ */
1477
+ async updateSecurityIntent(address2, chainId, patch) {
1478
+ const account = this.findAccount(address2, chainId);
1479
+ account.securityIntent = { ...account.securityIntent, ...patch };
1480
+ await this.persist();
1481
+ }
1482
+ /**
1483
+ * Called after activate succeeds with hook install:
1484
+ * 1. Write persistent SecurityStatus
1485
+ * 2. Delete temporary SecurityIntent (consumed)
1486
+ */
1487
+ async finalizeSecurityIntent(address2, chainId) {
1488
+ const account = this.findAccount(address2, chainId);
1489
+ account.securityStatus = { hookInstalled: true };
1490
+ delete account.securityIntent;
1491
+ await this.persist();
1492
+ }
1493
+ /**
1494
+ * Delete security intent without writing status.
1495
+ * Used if activate succeeds but hook batching failed (deploy-only).
1496
+ */
1497
+ async clearSecurityIntent(address2, chainId) {
1498
+ const account = this.findAccount(address2, chainId);
1499
+ delete account.securityIntent;
1500
+ await this.persist();
1501
+ }
1502
+ findAccount(address2, chainId) {
1503
+ const account = this.state.accounts.find(
1504
+ (a) => a.address.toLowerCase() === address2.toLowerCase() && a.chainId === chainId
1505
+ );
1506
+ if (!account) {
1507
+ throw new Error(`Account ${address2} on chain ${chainId} not found.`);
1508
+ }
1509
+ return account;
1510
+ }
1434
1511
  // ─── Import / Export ────────────────────────────────────────────
1435
1512
  async importAccounts(accounts) {
1436
1513
  let imported = 0;
@@ -1751,9 +1828,9 @@ var SecurityHookService = class {
1751
1828
  throw err;
1752
1829
  }
1753
1830
  }
1754
- isAuthError(error2) {
1755
- if (!error2 || typeof error2 !== "object") return false;
1756
- const msg = String(error2.message ?? "").toLowerCase();
1831
+ isAuthError(error) {
1832
+ if (!error || typeof error !== "object") return false;
1833
+ const msg = String(error.message ?? "").toLowerCase();
1757
1834
  return msg.includes("forbidden") || msg.includes("unauthorized") || msg.includes("session") || msg.includes("expired") || msg.includes("failed to authenticate");
1758
1835
  }
1759
1836
  // ─── On-chain Hook Status ────────────────────────────────────
@@ -2064,53 +2141,120 @@ var SecurityHookService = class {
2064
2141
  }
2065
2142
  };
2066
2143
 
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
- );
2144
+ // src/providers/keychainProvider.ts
2145
+ import { execFile } from "child_process";
2146
+ import { promisify } from "util";
2147
+ var execFileAsync = promisify(execFile);
2148
+ var KeychainProvider = class {
2149
+ name = "macos-keychain";
2150
+ service = "elytro-wallet";
2151
+ account = "vault-key";
2152
+ async available() {
2153
+ if (process.platform !== "darwin") return false;
2154
+ try {
2155
+ await execFileAsync("security", ["help"], { timeout: 5e3 });
2156
+ return true;
2157
+ } catch {
2158
+ return process.platform === "darwin";
2092
2159
  }
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;
2160
+ }
2161
+ async store(secret) {
2162
+ validateKeyLength(secret);
2163
+ const b64 = Buffer.from(secret).toString("base64");
2164
+ try {
2165
+ await execFileAsync("security", [
2166
+ "add-generic-password",
2167
+ "-U",
2168
+ "-s",
2169
+ this.service,
2170
+ "-a",
2171
+ this.account,
2172
+ "-w",
2173
+ b64
2174
+ ]);
2175
+ } catch (err) {
2176
+ throw new Error(`Failed to store vault key in Keychain: ${err.message}`);
2100
2177
  }
2101
- throw err;
2102
2178
  }
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}.`);
2179
+ async load() {
2180
+ try {
2181
+ const { stdout } = await execFileAsync("security", [
2182
+ "find-generic-password",
2183
+ "-s",
2184
+ this.service,
2185
+ "-a",
2186
+ this.account,
2187
+ "-w"
2188
+ ]);
2189
+ const trimmed = stdout.trim();
2190
+ if (!trimmed) return null;
2191
+ const key = Buffer.from(trimmed, "base64");
2192
+ if (key.length !== 32) {
2193
+ throw new Error(`Keychain vault key has invalid length: expected 32 bytes, got ${key.length}.`);
2194
+ }
2195
+ return new Uint8Array(key);
2196
+ } catch (err) {
2197
+ const msg = err.message || "";
2198
+ if (msg.includes("could not be found") || msg.includes("SecKeychainSearchCopyNext")) {
2199
+ return null;
2200
+ }
2201
+ throw new Error(`Failed to load vault key from Keychain: ${msg}`);
2202
+ }
2203
+ }
2204
+ async delete() {
2205
+ try {
2206
+ await execFileAsync("security", ["delete-generic-password", "-s", this.service, "-a", this.account]);
2207
+ } catch {
2208
+ }
2209
+ }
2210
+ };
2211
+ function validateKeyLength(key) {
2212
+ if (key.length !== 32) {
2213
+ throw new Error(`Invalid vault key: expected 32 bytes, got ${key.length}.`);
2107
2214
  }
2108
2215
  }
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");
2216
+
2217
+ // src/providers/envVarProvider.ts
2218
+ var ENV_KEY = "ELYTRO_VAULT_SECRET";
2219
+ var EnvVarProvider = class {
2220
+ name = "env-var";
2221
+ async available() {
2222
+ return !!process.env[ENV_KEY];
2223
+ }
2224
+ async store(_secret) {
2225
+ throw new Error(
2226
+ "EnvVarProvider is read-only. Cannot store vault key in an environment variable. Use a persistent provider (macOS Keychain) or store the secret manually."
2227
+ );
2228
+ }
2229
+ async load() {
2230
+ const raw = process.env[ENV_KEY];
2231
+ if (!raw) return null;
2232
+ delete process.env[ENV_KEY];
2233
+ const key = Buffer.from(raw, "base64");
2234
+ if (key.length !== 32) {
2235
+ throw new Error(
2236
+ `${ENV_KEY} has invalid length: expected 32 bytes (base64), got ${key.length}. The value must be a base64-encoded 256-bit key.`
2237
+ );
2238
+ }
2239
+ return new Uint8Array(key);
2240
+ }
2241
+ async delete() {
2242
+ delete process.env[ENV_KEY];
2243
+ }
2244
+ };
2245
+
2246
+ // src/providers/resolveProvider.ts
2247
+ async function resolveProvider() {
2248
+ const keychainProvider = new KeychainProvider();
2249
+ const envProvider = new EnvVarProvider();
2250
+ const initProvider = await keychainProvider.available() ? keychainProvider : null;
2251
+ let loadProvider = null;
2252
+ if (await keychainProvider.available()) {
2253
+ loadProvider = keychainProvider;
2254
+ } else if (await envProvider.available()) {
2255
+ loadProvider = envProvider;
2256
+ }
2257
+ return { initProvider, loadProvider };
2114
2258
  }
2115
2259
 
2116
2260
  // src/context.ts
@@ -2125,9 +2269,31 @@ async function createAppContext() {
2125
2269
  const defaultChain = chain.currentChain;
2126
2270
  walletClient.initForChain(defaultChain);
2127
2271
  await sdk.initForChain(defaultChain);
2128
- const deviceKey = await loadDeviceKey(store.dataDir);
2129
- if (deviceKey && await keyring.isInitialized()) {
2130
- await keyring.unlock(deviceKey);
2272
+ const { loadProvider } = await resolveProvider();
2273
+ const isInitialized = await keyring.isInitialized();
2274
+ if (isInitialized) {
2275
+ if (!loadProvider) {
2276
+ throw new Error(
2277
+ "Wallet is initialized but no secret provider is available.\n" + (process.platform === "darwin" ? "Keychain access failed. Check macOS Keychain permissions." : "Set the ELYTRO_VAULT_SECRET environment variable.")
2278
+ );
2279
+ }
2280
+ const vaultKey = await loadProvider.load();
2281
+ if (!vaultKey) {
2282
+ throw new Error(
2283
+ `Wallet is initialized but vault key not found in ${loadProvider.name}.
2284
+ ` + (process.platform === "darwin" ? "The Keychain item may have been deleted. Re-run `elytro init` to create a new wallet,\nor import a backup with `elytro import`." : "Set ELYTRO_VAULT_SECRET to the base64-encoded vault key.")
2285
+ );
2286
+ }
2287
+ try {
2288
+ await keyring.unlock(vaultKey);
2289
+ } catch (err) {
2290
+ vaultKey.fill(0);
2291
+ throw new Error(
2292
+ `Wallet unlock failed: ${err.message}
2293
+ The vault key may not match the encrypted keyring. Re-run \`elytro init\` or import a backup.`
2294
+ );
2295
+ }
2296
+ vaultKey.fill(0);
2131
2297
  }
2132
2298
  const account = new AccountService({
2133
2299
  store,
@@ -2148,31 +2314,15 @@ async function createAppContext() {
2148
2314
  }
2149
2315
  }
2150
2316
  }
2151
- return { store, keyring, chain, sdk, walletClient, account, deviceKey };
2317
+ return { store, keyring, chain, sdk, walletClient, account, secretProvider: loadProvider };
2152
2318
  }
2153
2319
 
2154
2320
  // src/commands/init.ts
2321
+ import { webcrypto as webcrypto2 } from "crypto";
2155
2322
  import ora from "ora";
2156
2323
 
2157
2324
  // src/utils/display.ts
2158
2325
  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
2326
  function txError(payload) {
2177
2327
  const output = {
2178
2328
  success: false,
@@ -2184,15 +2334,6 @@ function txError(payload) {
2184
2334
  };
2185
2335
  console.error(chalk.red(JSON.stringify(output, null, 2)));
2186
2336
  }
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
2337
  function address(addr) {
2197
2338
  if (addr.length <= 14) return addr;
2198
2339
  return `${addr.slice(0, 8)}...${addr.slice(-6)}`;
@@ -2206,32 +2347,51 @@ function maskApiKeys(url) {
2206
2347
  function sanitizeErrorMessage(message) {
2207
2348
  return message.replace(/https?:\/\/[^\s"']+/gi, (match) => maskApiKeys(match));
2208
2349
  }
2350
+ function outputResult(result) {
2351
+ console.log(JSON.stringify({ success: true, result }, null, 2));
2352
+ }
2353
+ function outputError(code, message, data) {
2354
+ txError({ code, message: sanitizeErrorMessage(message), data });
2355
+ process.exitCode = 1;
2356
+ }
2209
2357
 
2210
2358
  // src/commands/init.ts
2211
2359
  function registerInitCommand(program2, ctx) {
2212
2360
  program2.command("init").description("Initialize a new Elytro wallet").action(async () => {
2213
2361
  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.");
2362
+ outputResult({
2363
+ status: "already_initialized",
2364
+ dataDir: ctx.store.dataDir,
2365
+ hint: "Use `elytro account create` to create a smart account."
2366
+ });
2217
2367
  return;
2218
2368
  }
2219
- heading("Initialize Elytro Wallet");
2220
2369
  const spinner = ora("Setting up wallet...").start();
2221
2370
  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.");
2371
+ const vaultKey = webcrypto2.getRandomValues(new Uint8Array(32));
2372
+ const { initProvider } = await resolveProvider();
2373
+ let providerName = null;
2374
+ let vaultSecretB64 = null;
2375
+ if (initProvider) {
2376
+ await initProvider.store(vaultKey);
2377
+ providerName = initProvider.name;
2378
+ } else {
2379
+ vaultSecretB64 = Buffer.from(vaultKey).toString("base64");
2380
+ }
2381
+ await ctx.keyring.createNewOwner(vaultKey);
2382
+ vaultKey.fill(0);
2383
+ spinner.stop();
2384
+ outputResult({
2385
+ status: "initialized",
2386
+ dataDir: ctx.store.dataDir,
2387
+ secretProvider: providerName,
2388
+ ...vaultSecretB64 ? { vaultSecret: vaultSecretB64 } : {},
2389
+ ...vaultSecretB64 ? { hint: "Save ELYTRO_VAULT_SECRET \u2014 it will NOT be shown again." } : {},
2390
+ nextStep: "Run `elytro account create --chain <chainId>` to create your first smart account."
2391
+ });
2231
2392
  } catch (err) {
2232
2393
  spinner.fail("Failed to initialize wallet.");
2233
- error(sanitizeErrorMessage(err.message));
2234
- process.exitCode = 1;
2394
+ outputError(-32e3, sanitizeErrorMessage(err.message));
2235
2395
  }
2236
2396
  });
2237
2397
  }
@@ -2254,20 +2414,67 @@ async function askInput(message, defaultValue) {
2254
2414
 
2255
2415
  // src/commands/account.ts
2256
2416
  init_sponsor();
2417
+
2418
+ // src/utils/contracts/securityHook.ts
2419
+ import { encodeFunctionData, parseAbi as parseAbi2, pad, toHex as toHex5 } from "viem";
2420
+ function encodeInstallHook(walletAddress, hookAddress, safetyDelay = DEFAULT_SAFETY_DELAY, capabilityFlags = DEFAULT_CAPABILITY) {
2421
+ const safetyDelayHex = pad(toHex5(safetyDelay), { size: 4 }).slice(2);
2422
+ const hookAndData = hookAddress + safetyDelayHex;
2423
+ const callData = encodeFunctionData({
2424
+ abi: parseAbi2(["function installHook(bytes calldata hookAndData, uint8 capabilityFlags)"]),
2425
+ functionName: "installHook",
2426
+ args: [hookAndData, capabilityFlags]
2427
+ });
2428
+ return { to: walletAddress, value: "0", data: callData };
2429
+ }
2430
+ function encodeUninstallHook(walletAddress, hookAddress) {
2431
+ const callData = encodeFunctionData({
2432
+ abi: parseAbi2(["function uninstallHook(address)"]),
2433
+ functionName: "uninstallHook",
2434
+ args: [hookAddress]
2435
+ });
2436
+ return { to: walletAddress, value: "0", data: callData };
2437
+ }
2438
+ function encodeForcePreUninstall(hookAddress) {
2439
+ const callData = encodeFunctionData({
2440
+ abi: parseAbi2(["function forcePreUninstall()"]),
2441
+ functionName: "forcePreUninstall",
2442
+ args: []
2443
+ });
2444
+ return { to: hookAddress, value: "0", data: callData };
2445
+ }
2446
+
2447
+ // src/commands/account.ts
2448
+ var ERR_INVALID_PARAMS = -32602;
2449
+ var ERR_ACCOUNT_NOT_READY = -32002;
2450
+ var ERR_SPONSOR_FAILED = -32003;
2451
+ var ERR_SEND_FAILED = -32005;
2452
+ var ERR_REVERTED = -32006;
2453
+ var ERR_INTERNAL = -32e3;
2257
2454
  function registerAccountCommand(program2, ctx) {
2258
2455
  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;
2456
+ 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)").option("-e, --email <email>", "2FA email for SecurityHook OTP (strongly recommended)").option("-l, --daily-limit <usd>", 'Daily spending limit in USD (e.g. "100")').action(async (opts) => {
2457
+ if (!ctx.keyring.isUnlocked) {
2458
+ outputError(ERR_ACCOUNT_NOT_READY, "Wallet not initialized. Run `elytro init` first.");
2263
2459
  return;
2264
2460
  }
2265
2461
  const chainId = Number(opts.chain);
2266
2462
  if (Number.isNaN(chainId)) {
2267
- error("Invalid chain ID.");
2268
- process.exitCode = 1;
2463
+ outputError(ERR_INVALID_PARAMS, "Invalid chain ID.", { chain: opts.chain });
2269
2464
  return;
2270
2465
  }
2466
+ let dailyLimitUsd;
2467
+ if (opts.dailyLimit !== void 0) {
2468
+ dailyLimitUsd = parseFloat(opts.dailyLimit);
2469
+ if (isNaN(dailyLimitUsd) || dailyLimitUsd < 0) {
2470
+ outputError(ERR_INVALID_PARAMS, 'Invalid --daily-limit. Provide a positive number in USD (e.g. "100").');
2471
+ return;
2472
+ }
2473
+ }
2474
+ const securityIntent = opts.email || dailyLimitUsd !== void 0 ? {
2475
+ ...opts.email && { email: opts.email },
2476
+ ...dailyLimitUsd !== void 0 && { dailyLimitUsd }
2477
+ } : void 0;
2271
2478
  const spinner = ora2("Creating smart account...").start();
2272
2479
  try {
2273
2480
  const chainConfig = ctx.chain.chains.find((c) => c.id === chainId);
@@ -2276,7 +2483,7 @@ function registerAccountCommand(program2, ctx) {
2276
2483
  await ctx.sdk.initForChain(chainConfig);
2277
2484
  ctx.walletClient.initForChain(chainConfig);
2278
2485
  }
2279
- const accountInfo = await ctx.account.createAccount(chainId, opts.alias);
2486
+ const accountInfo = await ctx.account.createAccount(chainId, opts.alias, securityIntent);
2280
2487
  spinner.text = "Registering with backend...";
2281
2488
  const { guardianHash, guardianSafePeriod } = ctx.sdk.initDefaults;
2282
2489
  const paddedKey = padHex2(accountInfo.owner, { size: 32 });
@@ -2289,56 +2496,107 @@ function registerAccountCommand(program2, ctx) {
2289
2496
  guardianHash,
2290
2497
  guardianSafePeriod
2291
2498
  );
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.");
2499
+ let emailBindingStarted = false;
2500
+ if (opts.email && chainConfig) {
2501
+ spinner.text = "Initiating email binding...";
2502
+ try {
2503
+ const hookService = createHookServiceForAccount(ctx, chainConfig);
2504
+ const bindingResult = await hookService.requestEmailBinding(
2505
+ accountInfo.address,
2506
+ chainId,
2507
+ opts.email
2508
+ );
2509
+ await ctx.account.updateSecurityIntent(accountInfo.address, chainId, {
2510
+ emailBindingId: bindingResult.bindingId
2511
+ });
2512
+ emailBindingStarted = true;
2513
+ } catch {
2514
+ }
2301
2515
  }
2516
+ spinner.stop();
2517
+ outputResult({
2518
+ alias: accountInfo.alias,
2519
+ address: accountInfo.address,
2520
+ chain: chainName,
2521
+ chainId,
2522
+ deployed: false,
2523
+ ...securityIntent ? {
2524
+ security: {
2525
+ ...opts.email ? { email: opts.email, emailBindingStarted } : {},
2526
+ ...dailyLimitUsd !== void 0 ? { dailyLimitUsd } : {},
2527
+ hookPending: true
2528
+ }
2529
+ } : { security: null },
2530
+ ...regError ? { warning: `Backend registration failed: ${regError}` } : {}
2531
+ });
2302
2532
  } catch (err) {
2303
- spinner.fail("Failed to create account.");
2304
- error(sanitizeErrorMessage(err.message));
2305
- process.exitCode = 1;
2533
+ spinner.stop();
2534
+ outputError(ERR_INTERNAL, err.message);
2306
2535
  }
2307
2536
  });
2308
2537
  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;
2538
+ if (!ctx.keyring.isUnlocked) {
2539
+ outputError(ERR_ACCOUNT_NOT_READY, "Wallet not initialized. Run `elytro init` first.");
2312
2540
  return;
2313
2541
  }
2314
2542
  const identifier = target ?? ctx.account.currentAccount?.alias ?? ctx.account.currentAccount?.address;
2315
2543
  if (!identifier) {
2316
- warn("No account selected. Specify an alias/address or create an account first.");
2544
+ outputError(ERR_ACCOUNT_NOT_READY, "No account selected. Specify an alias/address or create an account first.");
2317
2545
  return;
2318
2546
  }
2319
2547
  const accountInfo = ctx.account.resolveAccount(identifier);
2320
2548
  if (!accountInfo) {
2321
- error(`Account "${identifier}" not found.`);
2322
- process.exitCode = 1;
2549
+ outputError(ERR_ACCOUNT_NOT_READY, `Account "${identifier}" not found.`);
2323
2550
  return;
2324
2551
  }
2325
2552
  if (accountInfo.isDeployed) {
2326
- warn(`Account "${accountInfo.alias}" is already deployed.`);
2553
+ outputResult({
2554
+ alias: accountInfo.alias,
2555
+ address: accountInfo.address,
2556
+ status: "already_deployed"
2557
+ });
2327
2558
  return;
2328
2559
  }
2329
2560
  const chainConfig = ctx.chain.chains.find((c) => c.id === accountInfo.chainId);
2330
2561
  const chainName = chainConfig?.name ?? String(accountInfo.chainId);
2331
2562
  if (!chainConfig) {
2332
- error(`Chain ${accountInfo.chainId} not configured.`);
2333
- process.exitCode = 1;
2563
+ outputError(ERR_ACCOUNT_NOT_READY, `Chain ${accountInfo.chainId} not configured.`);
2334
2564
  return;
2335
2565
  }
2336
2566
  await ctx.sdk.initForChain(chainConfig);
2337
2567
  ctx.walletClient.initForChain(chainConfig);
2338
2568
  const spinner = ora2(`Activating "${accountInfo.alias}" on ${chainName}...`).start();
2569
+ const intent = accountInfo.securityIntent;
2570
+ const hookAddress = SECURITY_HOOK_ADDRESS_MAP[accountInfo.chainId];
2571
+ const shouldInstallHook = !!(intent && hookAddress && (intent.email || intent.dailyLimitUsd !== void 0));
2339
2572
  try {
2340
2573
  spinner.text = "Building deployment UserOp...";
2574
+ let deployCallData;
2575
+ let hookBatched = false;
2576
+ if (shouldInstallHook) {
2577
+ const installTx = encodeInstallHook(
2578
+ accountInfo.address,
2579
+ hookAddress,
2580
+ DEFAULT_SAFETY_DELAY,
2581
+ DEFAULT_CAPABILITY
2582
+ );
2583
+ deployCallData = installTx.data;
2584
+ }
2341
2585
  const userOp = await ctx.sdk.createDeployUserOp(accountInfo.owner, accountInfo.index);
2586
+ if (shouldInstallHook && deployCallData) {
2587
+ try {
2588
+ const hookInstallOp = await ctx.sdk.createSendUserOp(accountInfo.address, [
2589
+ {
2590
+ to: accountInfo.address,
2591
+ value: "0",
2592
+ data: deployCallData
2593
+ }
2594
+ ]);
2595
+ userOp.callData = hookInstallOp.callData;
2596
+ hookBatched = true;
2597
+ } catch {
2598
+ }
2599
+ }
2342
2600
  spinner.text = "Fetching gas prices...";
2343
2601
  const feeData = await ctx.sdk.getFeeData(chainConfig);
2344
2602
  userOp.maxFeePerGas = feeData.maxFeePerGas;
@@ -2364,15 +2622,14 @@ function registerAccountCommand(program2, ctx) {
2364
2622
  spinner.text = "Sponsorship unavailable, checking balance...";
2365
2623
  const { ether: balance } = await ctx.walletClient.getBalance(accountInfo.address);
2366
2624
  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;
2625
+ spinner.stop();
2626
+ outputError(ERR_SPONSOR_FAILED, `Sponsorship failed: ${sponsorError ?? "unknown"}. Account has no ETH to pay gas.`, {
2627
+ account: accountInfo.alias,
2628
+ address: accountInfo.address,
2629
+ chain: chainName
2630
+ });
2373
2631
  return;
2374
2632
  }
2375
- spinner.text = "Proceeding without sponsor (user pays gas)...";
2376
2633
  }
2377
2634
  }
2378
2635
  spinner.text = "Signing UserOperation...";
@@ -2384,25 +2641,37 @@ function registerAccountCommand(program2, ctx) {
2384
2641
  spinner.text = "Waiting for on-chain confirmation...";
2385
2642
  const receipt = await ctx.sdk.waitForReceipt(opHash);
2386
2643
  await ctx.account.markDeployed(accountInfo.address, accountInfo.chainId);
2644
+ const hookInstalled = shouldInstallHook && hookBatched && receipt.success;
2645
+ if (hookInstalled) {
2646
+ await ctx.account.finalizeSecurityIntent(accountInfo.address, accountInfo.chainId);
2647
+ } else if (intent) {
2648
+ await ctx.account.clearSecurityIntent(accountInfo.address, accountInfo.chainId);
2649
+ }
2650
+ spinner.stop();
2387
2651
  if (receipt.success) {
2388
- spinner.succeed(`Account "${accountInfo.alias}" activated!`);
2652
+ outputResult({
2653
+ alias: accountInfo.alias,
2654
+ address: accountInfo.address,
2655
+ chain: chainName,
2656
+ chainId: accountInfo.chainId,
2657
+ transactionHash: receipt.transactionHash,
2658
+ gasCost: `${formatEther2(BigInt(receipt.actualGasCost))} ETH`,
2659
+ sponsored,
2660
+ hookInstalled,
2661
+ ...hookInstalled && intent?.email ? { emailPending: intent.email } : {},
2662
+ ...hookInstalled && intent?.dailyLimitUsd !== void 0 ? { dailyLimitPending: intent.dailyLimitUsd } : {},
2663
+ ...chainConfig.blockExplorer ? { explorer: `${chainConfig.blockExplorer}/tx/${receipt.transactionHash}` } : {}
2664
+ });
2389
2665
  } 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}`);
2666
+ outputError(ERR_REVERTED, "UserOp included but execution reverted.", {
2667
+ alias: accountInfo.alias,
2668
+ transactionHash: receipt.transactionHash,
2669
+ block: receipt.blockNumber
2670
+ });
2401
2671
  }
2402
2672
  } catch (err) {
2403
- spinner.fail("Activation failed.");
2404
- error(sanitizeErrorMessage(err.message));
2405
- process.exitCode = 1;
2673
+ spinner.stop();
2674
+ outputError(ERR_SEND_FAILED, err.message);
2406
2675
  }
2407
2676
  });
2408
2677
  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) => {
@@ -2410,53 +2679,40 @@ function registerAccountCommand(program2, ctx) {
2410
2679
  if (target) {
2411
2680
  const matched = ctx.account.resolveAccount(target);
2412
2681
  if (!matched) {
2413
- error(`Account "${target}" not found.`);
2414
- process.exitCode = 1;
2682
+ outputError(ERR_ACCOUNT_NOT_READY, `Account "${target}" not found.`);
2415
2683
  return;
2416
2684
  }
2417
2685
  accounts = [matched];
2418
2686
  }
2419
- if (accounts.length === 0) {
2420
- warn("No accounts found. Run `elytro account create --chain <chainId>` first.");
2421
- return;
2422
- }
2423
2687
  const current = ctx.account.currentAccount;
2424
- heading("Accounts");
2425
- table(
2426
- accounts.map((a) => {
2688
+ outputResult({
2689
+ accounts: accounts.map((a) => {
2427
2690
  const chainConfig = ctx.chain.chains.find((c) => c.id === a.chainId);
2428
2691
  return {
2429
- active: a.address === current?.address ? "\u2192" : " ",
2692
+ active: a.address === current?.address,
2430
2693
  alias: a.alias,
2431
2694
  address: a.address,
2432
2695
  chain: chainConfig?.name ?? String(a.chainId),
2433
- deployed: a.isDeployed ? "Yes" : "No",
2434
- recovery: a.isRecoveryEnabled ? "Yes" : "No"
2696
+ chainId: a.chainId,
2697
+ deployed: a.isDeployed,
2698
+ recovery: a.isRecoveryEnabled
2435
2699
  };
2436
2700
  }),
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
- );
2701
+ total: accounts.length
2702
+ });
2446
2703
  });
2447
2704
  account.command("info").description("Show details for an account").argument("[account]", "Alias or address (default: current)").action(async (target) => {
2448
2705
  const identifier = target ?? ctx.account.currentAccount?.alias ?? ctx.account.currentAccount?.address;
2449
2706
  if (!identifier) {
2450
- warn("No account selected. Run `elytro account create --chain <chainId>` first.");
2707
+ outputError(ERR_ACCOUNT_NOT_READY, "No account selected. Run `elytro account create` first.");
2451
2708
  return;
2452
2709
  }
2453
2710
  const spinner = ora2("Fetching on-chain data...").start();
2454
2711
  try {
2455
2712
  const accountInfo = ctx.account.resolveAccount(identifier);
2456
2713
  if (!accountInfo) {
2457
- spinner.fail("Account not found.");
2458
- error(`Account "${identifier}" not found.`);
2459
- process.exitCode = 1;
2714
+ spinner.stop();
2715
+ outputError(ERR_ACCOUNT_NOT_READY, `Account "${identifier}" not found.`);
2460
2716
  return;
2461
2717
  }
2462
2718
  const chainConfig = ctx.chain.chains.find((c) => c.id === accountInfo.chainId);
@@ -2465,26 +2721,27 @@ function registerAccountCommand(program2, ctx) {
2465
2721
  }
2466
2722
  const detail = await ctx.account.getAccountDetail(identifier);
2467
2723
  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
- }
2724
+ outputResult({
2725
+ alias: detail.alias,
2726
+ address: detail.address,
2727
+ chain: chainConfig?.name ?? String(detail.chainId),
2728
+ chainId: detail.chainId,
2729
+ deployed: detail.isDeployed,
2730
+ balance: `${detail.balance} ${chainConfig?.nativeCurrency.symbol ?? "ETH"}`,
2731
+ recovery: detail.isRecoveryEnabled,
2732
+ ...detail.securityStatus ? { securityStatus: detail.securityStatus } : {},
2733
+ ...detail.securityIntent ? { securityIntent: detail.securityIntent } : {},
2734
+ ...chainConfig?.blockExplorer ? { explorer: `${chainConfig.blockExplorer}/address/${detail.address}` } : {}
2735
+ });
2478
2736
  } catch (err) {
2479
- spinner.fail("Failed to fetch account info.");
2480
- error(sanitizeErrorMessage(err.message));
2481
- process.exitCode = 1;
2737
+ spinner.stop();
2738
+ outputError(ERR_INTERNAL, err.message);
2482
2739
  }
2483
2740
  });
2484
2741
  account.command("switch").description("Switch the active account").argument("[account]", "Alias or address").action(async (target) => {
2485
2742
  const accounts = ctx.account.allAccounts;
2486
2743
  if (accounts.length === 0) {
2487
- warn("No accounts found.");
2744
+ outputError(ERR_ACCOUNT_NOT_READY, "No accounts found. Run `elytro account create` first.");
2488
2745
  return;
2489
2746
  }
2490
2747
  let identifier = target;
@@ -2505,26 +2762,42 @@ function registerAccountCommand(program2, ctx) {
2505
2762
  ctx.walletClient.initForChain(newChain);
2506
2763
  await ctx.sdk.initForChain(newChain);
2507
2764
  }
2508
- success(`Switched to "${switched.alias}"`);
2509
- const spinner = ora2("Fetching on-chain data...").start();
2765
+ let balance = null;
2510
2766
  try {
2511
2767
  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
- }
2768
+ balance = `${detail.balance} ${newChain?.nativeCurrency.symbol ?? "ETH"}`;
2521
2769
  } catch {
2522
- spinner.stop();
2523
- warn("Could not fetch on-chain data. Run `elytro account info` to retry.");
2524
2770
  }
2771
+ outputResult({
2772
+ alias: switched.alias,
2773
+ address: switched.address,
2774
+ chain: newChain?.name ?? String(switched.chainId),
2775
+ chainId: switched.chainId,
2776
+ deployed: switched.isDeployed,
2777
+ ...balance ? { balance } : {},
2778
+ ...newChain?.blockExplorer ? { explorer: `${newChain.blockExplorer}/address/${switched.address}` } : {}
2779
+ });
2525
2780
  } catch (err) {
2526
- error(sanitizeErrorMessage(err.message));
2527
- process.exitCode = 1;
2781
+ outputError(ERR_INTERNAL, err.message);
2782
+ }
2783
+ });
2784
+ }
2785
+ function createHookServiceForAccount(ctx, chainConfig) {
2786
+ return new SecurityHookService({
2787
+ store: ctx.store,
2788
+ graphqlEndpoint: ctx.chain.graphqlEndpoint,
2789
+ signMessageForAuth: createSignMessageForAuth({
2790
+ signDigest: (digest) => ctx.keyring.signDigest(digest),
2791
+ packRawHash: (hash) => ctx.sdk.packRawHash(hash),
2792
+ packSignature: (rawSig, valData) => ctx.sdk.packUserOpSignature(rawSig, valData)
2793
+ }),
2794
+ readContract: async (params) => {
2795
+ return ctx.walletClient.readContract(params);
2796
+ },
2797
+ getBlockTimestamp: async () => {
2798
+ const blockNumber = await ctx.walletClient.raw.getBlockNumber();
2799
+ const block = await ctx.walletClient.raw.getBlock({ blockNumber });
2800
+ return block.timestamp;
2528
2801
  }
2529
2802
  });
2530
2803
  }
@@ -2532,15 +2805,15 @@ function registerAccountCommand(program2, ctx) {
2532
2805
  // src/commands/tx.ts
2533
2806
  init_sponsor();
2534
2807
  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;
2808
+ import { isAddress, isHex, formatEther as formatEther3, parseEther as parseEther2, toHex as toHex6 } from "viem";
2809
+ var ERR_INVALID_PARAMS2 = -32602;
2537
2810
  var ERR_INSUFFICIENT_BALANCE = -32001;
2538
- var ERR_ACCOUNT_NOT_READY = -32002;
2539
- var ERR_SPONSOR_FAILED = -32003;
2811
+ var ERR_ACCOUNT_NOT_READY2 = -32002;
2812
+ var ERR_SPONSOR_FAILED2 = -32003;
2540
2813
  var ERR_BUILD_FAILED = -32004;
2541
- var ERR_SEND_FAILED = -32005;
2814
+ var ERR_SEND_FAILED2 = -32005;
2542
2815
  var ERR_EXECUTION_REVERTED = -32006;
2543
- var ERR_INTERNAL = -32e3;
2816
+ var ERR_INTERNAL2 = -32e3;
2544
2817
  var TxError = class extends Error {
2545
2818
  code;
2546
2819
  data;
@@ -2553,14 +2826,10 @@ var TxError = class extends Error {
2553
2826
  };
2554
2827
  function handleTxError(err) {
2555
2828
  if (err instanceof TxError) {
2556
- txError({ code: err.code, message: sanitizeErrorMessage(err.message), data: err.data });
2829
+ outputError(err.code, err.message, err.data);
2557
2830
  } else {
2558
- txError({
2559
- code: ERR_INTERNAL,
2560
- message: sanitizeErrorMessage(err.message ?? String(err))
2561
- });
2831
+ outputError(ERR_INTERNAL2, err.message ?? String(err));
2562
2832
  }
2563
- process.exitCode = 1;
2564
2833
  }
2565
2834
  function parseTxSpec(spec, index) {
2566
2835
  const prefix = `--tx #${index + 1}`;
@@ -2568,7 +2837,7 @@ function parseTxSpec(spec, index) {
2568
2837
  for (const part of spec.split(",")) {
2569
2838
  const colonIdx = part.indexOf(":");
2570
2839
  if (colonIdx === -1) {
2571
- throw new TxError(ERR_INVALID_PARAMS, `${prefix}: invalid segment "${part}". Expected key:value format.`, {
2840
+ throw new TxError(ERR_INVALID_PARAMS2, `${prefix}: invalid segment "${part}". Expected key:value format.`, {
2572
2841
  spec,
2573
2842
  index
2574
2843
  });
@@ -2576,17 +2845,17 @@ function parseTxSpec(spec, index) {
2576
2845
  const key = part.slice(0, colonIdx).trim().toLowerCase();
2577
2846
  const val = part.slice(colonIdx + 1).trim();
2578
2847
  if (!key || !val) {
2579
- throw new TxError(ERR_INVALID_PARAMS, `${prefix}: empty key or value in "${part}".`, { spec, index });
2848
+ throw new TxError(ERR_INVALID_PARAMS2, `${prefix}: empty key or value in "${part}".`, { spec, index });
2580
2849
  }
2581
2850
  if (fields[key]) {
2582
- throw new TxError(ERR_INVALID_PARAMS, `${prefix}: duplicate key "${key}".`, { spec, index, key });
2851
+ throw new TxError(ERR_INVALID_PARAMS2, `${prefix}: duplicate key "${key}".`, { spec, index, key });
2583
2852
  }
2584
2853
  fields[key] = val;
2585
2854
  }
2586
2855
  const knownKeys = /* @__PURE__ */ new Set(["to", "value", "data"]);
2587
2856
  for (const key of Object.keys(fields)) {
2588
2857
  if (!knownKeys.has(key)) {
2589
- throw new TxError(ERR_INVALID_PARAMS, `${prefix}: unknown key "${key}". Allowed: to, value, data.`, {
2858
+ throw new TxError(ERR_INVALID_PARAMS2, `${prefix}: unknown key "${key}". Allowed: to, value, data.`, {
2590
2859
  spec,
2591
2860
  index,
2592
2861
  key
@@ -2594,13 +2863,13 @@ function parseTxSpec(spec, index) {
2594
2863
  }
2595
2864
  }
2596
2865
  if (!fields.to) {
2597
- throw new TxError(ERR_INVALID_PARAMS, `${prefix}: "to" is required.`, { spec, index });
2866
+ throw new TxError(ERR_INVALID_PARAMS2, `${prefix}: "to" is required.`, { spec, index });
2598
2867
  }
2599
2868
  if (!isAddress(fields.to)) {
2600
- throw new TxError(ERR_INVALID_PARAMS, `${prefix}: invalid address "${fields.to}".`, { spec, index, to: fields.to });
2869
+ throw new TxError(ERR_INVALID_PARAMS2, `${prefix}: invalid address "${fields.to}".`, { spec, index, to: fields.to });
2601
2870
  }
2602
2871
  if (!fields.value && !fields.data) {
2603
- throw new TxError(ERR_INVALID_PARAMS, `${prefix}: at least one of "value" or "data" is required.`, { spec, index });
2872
+ throw new TxError(ERR_INVALID_PARAMS2, `${prefix}: at least one of "value" or "data" is required.`, { spec, index });
2604
2873
  }
2605
2874
  if (fields.value) {
2606
2875
  try {
@@ -2608,7 +2877,7 @@ function parseTxSpec(spec, index) {
2608
2877
  if (wei < 0n) throw new Error("negative");
2609
2878
  } catch {
2610
2879
  throw new TxError(
2611
- ERR_INVALID_PARAMS,
2880
+ ERR_INVALID_PARAMS2,
2612
2881
  `${prefix}: invalid ETH amount "${fields.value}". Use human-readable format (e.g. "0.1").`,
2613
2882
  { spec, index, value: fields.value }
2614
2883
  );
@@ -2616,14 +2885,14 @@ function parseTxSpec(spec, index) {
2616
2885
  }
2617
2886
  if (fields.data) {
2618
2887
  if (!isHex(fields.data)) {
2619
- throw new TxError(ERR_INVALID_PARAMS, `${prefix}: invalid hex in "data". Must start with 0x.`, {
2888
+ throw new TxError(ERR_INVALID_PARAMS2, `${prefix}: invalid hex in "data". Must start with 0x.`, {
2620
2889
  spec,
2621
2890
  index,
2622
2891
  data: fields.data
2623
2892
  });
2624
2893
  }
2625
2894
  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).`, {
2895
+ throw new TxError(ERR_INVALID_PARAMS2, `${prefix}: "data" hex must have even length (complete bytes).`, {
2627
2896
  spec,
2628
2897
  index,
2629
2898
  data: fields.data
@@ -2645,7 +2914,7 @@ function detectTxType(specs) {
2645
2914
  function specsToTxs(specs) {
2646
2915
  return specs.map((s) => ({
2647
2916
  to: s.to,
2648
- value: s.value ? toHex5(parseEther2(s.value)) : "0x0",
2917
+ value: s.value ? toHex6(parseEther2(s.value)) : "0x0",
2649
2918
  data: s.data ?? "0x"
2650
2919
  }));
2651
2920
  }
@@ -2666,19 +2935,12 @@ function txTypeLabel(txType) {
2666
2935
  return "Batch Transaction";
2667
2936
  }
2668
2937
  }
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(" "));
2938
+ function specToJson(spec) {
2939
+ return {
2940
+ to: spec.to,
2941
+ ...spec.value ? { value: spec.value } : {},
2942
+ ...spec.data && spec.data !== "0x" ? { data: spec.data, selector: spec.data.length >= 10 ? spec.data.slice(0, 10) : spec.data } : {}
2943
+ };
2682
2944
  }
2683
2945
  function registerTxCommand(program2, ctx) {
2684
2946
  const tx = program2.command("tx").description("Build, simulate, and send transactions");
@@ -2691,21 +2953,23 @@ function registerTxCommand(program2, ctx) {
2691
2953
  specs,
2692
2954
  opts?.sponsor
2693
2955
  );
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");
2956
+ outputResult({
2957
+ userOp: serializeUserOp(userOp),
2958
+ account: accountInfo.alias,
2959
+ address: accountInfo.address,
2960
+ chain: chainConfig.name,
2961
+ chainId: chainConfig.id,
2962
+ txType: txTypeLabel(txType),
2963
+ ...txType === "batch" ? { txCount: specs.length } : {},
2964
+ sponsored
2965
+ });
2702
2966
  } catch (err) {
2703
2967
  handleTxError(err);
2704
2968
  }
2705
2969
  });
2706
2970
  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."));
2971
+ if (!ctx.keyring.isUnlocked) {
2972
+ handleTxError(new TxError(ERR_ACCOUNT_NOT_READY2, "Wallet not initialized. Run `elytro init` first."));
2709
2973
  return;
2710
2974
  }
2711
2975
  try {
@@ -2720,7 +2984,7 @@ function registerTxCommand(program2, ctx) {
2720
2984
  sponsored = !!userOp.paymaster;
2721
2985
  const identifier = target ?? ctx.account.currentAccount?.alias ?? ctx.account.currentAccount?.address;
2722
2986
  if (!identifier) {
2723
- throw new TxError(ERR_ACCOUNT_NOT_READY, "No account selected.", {
2987
+ throw new TxError(ERR_ACCOUNT_NOT_READY2, "No account selected.", {
2724
2988
  hint: "Specify an alias/address or create an account first."
2725
2989
  });
2726
2990
  }
@@ -2737,37 +3001,20 @@ function registerTxCommand(program2, ctx) {
2737
3001
  sponsored = result.sponsored;
2738
3002
  txType = result.txType;
2739
3003
  }
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
3004
  const estimatedGas = userOp.callGasLimit + userOp.verificationGasLimit + userOp.preVerificationGas;
2766
- info("Est. Gas", estimatedGas.toString());
2767
- console.log("");
3005
+ console.error(JSON.stringify({
3006
+ summary: {
3007
+ txType: txTypeLabel(txType),
3008
+ from: accountInfo.alias,
3009
+ address: accountInfo.address,
3010
+ transactions: specs.map((s, i) => specToJson(s)),
3011
+ sponsored,
3012
+ estimatedGas: estimatedGas.toString()
3013
+ }
3014
+ }, null, 2));
2768
3015
  const confirmed = await askConfirm("Sign and send this transaction?");
2769
3016
  if (!confirmed) {
2770
- warn("Transaction cancelled.");
3017
+ outputResult({ status: "cancelled" });
2771
3018
  return;
2772
3019
  }
2773
3020
  const spinner = ora3("Signing UserOperation...").start();
@@ -2810,14 +3057,12 @@ function registerTxCommand(program2, ctx) {
2810
3057
  spinner.stop();
2811
3058
  const errCode = hookResult.error.code;
2812
3059
  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
- }
3060
+ console.error(JSON.stringify({
3061
+ challenge: errCode,
3062
+ message: hookResult.error.message ?? `Verification required (${errCode}).`,
3063
+ ...hookResult.error.maskedEmail ? { maskedEmail: hookResult.error.maskedEmail } : {},
3064
+ ...errCode === "SPENDING_LIMIT_EXCEEDED" && hookResult.error.projectedSpendUsdCents !== void 0 ? { projectedSpendUsd: (hookResult.error.projectedSpendUsdCents / 100).toFixed(2), dailyLimitUsd: ((hookResult.error.dailyLimitUsdCents ?? 0) / 100).toFixed(2) } : {}
3065
+ }, null, 2));
2821
3066
  const otpCode = await askInput("Enter the 6-digit OTP code:");
2822
3067
  spinner.start("Verifying OTP...");
2823
3068
  await hookService.verifySecurityOtp(
@@ -2835,13 +3080,13 @@ function registerTxCommand(program2, ctx) {
2835
3080
  );
2836
3081
  if (hookResult.error) {
2837
3082
  throw new TxError(
2838
- ERR_SEND_FAILED,
3083
+ ERR_SEND_FAILED2,
2839
3084
  `Hook authorization failed after OTP: ${hookResult.error.message}`
2840
3085
  );
2841
3086
  }
2842
3087
  } else {
2843
3088
  throw new TxError(
2844
- ERR_SEND_FAILED,
3089
+ ERR_SEND_FAILED2,
2845
3090
  `Hook authorization failed: ${hookResult.error.message ?? errCode}`
2846
3091
  );
2847
3092
  }
@@ -2862,48 +3107,41 @@ function registerTxCommand(program2, ctx) {
2862
3107
  spinner.text = "Sending to bundler...";
2863
3108
  opHash = await ctx.sdk.sendUserOp(userOp);
2864
3109
  } catch (err) {
2865
- spinner.fail("Send failed.");
2866
- throw new TxError(ERR_SEND_FAILED, err.message, {
3110
+ spinner.stop();
3111
+ throw new TxError(ERR_SEND_FAILED2, err.message, {
2867
3112
  sender: accountInfo.address,
2868
3113
  chain: chainConfig.name
2869
3114
  });
2870
3115
  }
2871
3116
  spinner.text = "Waiting for on-chain confirmation...";
2872
3117
  const receipt = await ctx.sdk.waitForReceipt(opHash);
3118
+ spinner.stop();
2873
3119
  if (receipt.success) {
2874
- spinner.succeed("Transaction confirmed!");
3120
+ outputResult({
3121
+ status: "confirmed",
3122
+ account: accountInfo.alias,
3123
+ address: accountInfo.address,
3124
+ transactionHash: receipt.transactionHash,
3125
+ block: receipt.blockNumber,
3126
+ gasCost: `${formatEther3(BigInt(receipt.actualGasCost))} ETH`,
3127
+ sponsored,
3128
+ ...chainConfig.blockExplorer ? { explorer: `${chainConfig.blockExplorer}/tx/${receipt.transactionHash}` } : {}
3129
+ });
2875
3130
  } 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
- }
3131
+ outputError(ERR_EXECUTION_REVERTED, "UserOp included but execution reverted on-chain.", {
3132
+ transactionHash: receipt.transactionHash,
3133
+ block: receipt.blockNumber,
3134
+ gasCost: `${formatEther3(BigInt(receipt.actualGasCost))} ETH`,
3135
+ sender: accountInfo.address
2886
3136
  });
2887
3137
  }
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
3138
  } catch (err) {
2901
3139
  handleTxError(err);
2902
3140
  }
2903
3141
  });
2904
3142
  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."));
3143
+ if (!ctx.keyring.isUnlocked) {
3144
+ handleTxError(new TxError(ERR_ACCOUNT_NOT_READY2, "Wallet not initialized. Run `elytro init` first."));
2907
3145
  return;
2908
3146
  }
2909
3147
  try {
@@ -2916,77 +3154,44 @@ function registerTxCommand(program2, ctx) {
2916
3154
  );
2917
3155
  const { wei: ethBalance, ether: ethFormatted } = await ctx.walletClient.getBalance(accountInfo.address);
2918
3156
  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
3157
  const totalGas = userOp.callGasLimit + userOp.verificationGasLimit + userOp.preVerificationGas;
2977
3158
  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);
3159
+ const ethValueSum = totalEthValue(specs);
3160
+ const warnings = [];
3161
+ if (ethValueSum > 0n && ethBalance < ethValueSum) {
3162
+ warnings.push(`Insufficient balance for value: need ${formatEther3(ethValueSum)}, have ${ethFormatted} ${nativeCurrency}`);
2983
3163
  }
2984
- info(`${nativeCurrency} Balance`, `${ethFormatted} ${nativeCurrency}`);
2985
3164
  if (!sponsored && ethBalance < maxCostWei) {
2986
- warn(
2987
- `Insufficient ${nativeCurrency} for gas: need ~${formatEther3(maxCostWei)}, have ${ethFormatted}`
2988
- );
3165
+ warnings.push(`Insufficient ${nativeCurrency} for gas: need ~${formatEther3(maxCostWei)}, have ${ethFormatted}`);
3166
+ }
3167
+ let targetIsContract;
3168
+ if (txType === "contract-call") {
3169
+ targetIsContract = await ctx.walletClient.isContractDeployed(specs[0].to);
3170
+ if (!targetIsContract) {
3171
+ warnings.push("Target address has no deployed code. The call may be a no-op or revert.");
3172
+ }
2989
3173
  }
3174
+ outputResult({
3175
+ txType: txTypeLabel(txType),
3176
+ account: accountInfo.alias,
3177
+ address: accountInfo.address,
3178
+ chain: chainConfig.name,
3179
+ chainId: chainConfig.id,
3180
+ transactions: specs.map((s) => specToJson(s)),
3181
+ ...txType === "contract-call" && targetIsContract !== void 0 ? { targetIsContract } : {},
3182
+ gas: {
3183
+ callGasLimit: userOp.callGasLimit.toString(),
3184
+ verificationGasLimit: userOp.verificationGasLimit.toString(),
3185
+ preVerificationGas: userOp.preVerificationGas.toString(),
3186
+ maxFeePerGas: userOp.maxFeePerGas.toString(),
3187
+ maxPriorityFeePerGas: userOp.maxPriorityFeePerGas.toString(),
3188
+ maxCost: `${formatEther3(maxCostWei)} ${nativeCurrency}`
3189
+ },
3190
+ sponsored,
3191
+ ...sponsored && userOp.paymaster ? { paymaster: userOp.paymaster } : {},
3192
+ balance: `${ethFormatted} ${nativeCurrency}`,
3193
+ ...warnings.length > 0 ? { warnings } : {}
3194
+ });
2990
3195
  } catch (err) {
2991
3196
  handleTxError(err);
2992
3197
  }
@@ -2994,21 +3199,21 @@ function registerTxCommand(program2, ctx) {
2994
3199
  }
2995
3200
  function parseAllTxSpecs(rawSpecs) {
2996
3201
  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"');
3202
+ throw new TxError(ERR_INVALID_PARAMS2, 'At least one --tx is required. Format: --tx "to:0xAddr,value:0.1"');
2998
3203
  }
2999
3204
  return rawSpecs.map((spec, i) => parseTxSpec(spec, i));
3000
3205
  }
3001
3206
  async function buildUserOp(ctx, target, specs, sponsor) {
3002
3207
  const identifier = target ?? ctx.account.currentAccount?.alias ?? ctx.account.currentAccount?.address;
3003
3208
  if (!identifier) {
3004
- throw new TxError(ERR_ACCOUNT_NOT_READY, "No account selected.", {
3209
+ throw new TxError(ERR_ACCOUNT_NOT_READY2, "No account selected.", {
3005
3210
  hint: "Specify an alias/address or create an account first."
3006
3211
  });
3007
3212
  }
3008
3213
  const accountInfo = resolveAccountStrict(ctx, identifier);
3009
3214
  const chainConfig = resolveChainStrict(ctx, accountInfo.chainId);
3010
3215
  if (!accountInfo.isDeployed) {
3011
- throw new TxError(ERR_ACCOUNT_NOT_READY, `Account "${accountInfo.alias}" is not deployed.`, {
3216
+ throw new TxError(ERR_ACCOUNT_NOT_READY2, `Account "${accountInfo.alias}" is not deployed.`, {
3012
3217
  account: accountInfo.alias,
3013
3218
  address: accountInfo.address,
3014
3219
  hint: "Run `elytro account activate` first."
@@ -3037,7 +3242,7 @@ async function buildUserOp(ctx, target, specs, sponsor) {
3037
3242
  try {
3038
3243
  userOp = await ctx.sdk.createSendUserOp(accountInfo.address, txs);
3039
3244
  } catch (err) {
3040
- spinner.fail("Build failed.");
3245
+ spinner.stop();
3041
3246
  throw new TxError(ERR_BUILD_FAILED, `Failed to build UserOp: ${err.message}`, {
3042
3247
  account: accountInfo.address,
3043
3248
  chain: chainConfig.name
@@ -3054,7 +3259,7 @@ async function buildUserOp(ctx, target, specs, sponsor) {
3054
3259
  userOp.verificationGasLimit = gasEstimate.verificationGasLimit;
3055
3260
  userOp.preVerificationGas = gasEstimate.preVerificationGas;
3056
3261
  } catch (err) {
3057
- spinner.fail("Gas estimation failed.");
3262
+ spinner.stop();
3058
3263
  throw new TxError(ERR_BUILD_FAILED, `Gas estimation failed: ${err.message}`, {
3059
3264
  account: accountInfo.address,
3060
3265
  chain: chainConfig.name
@@ -3076,8 +3281,8 @@ async function buildUserOp(ctx, target, specs, sponsor) {
3076
3281
  spinner.text = "Sponsorship unavailable, checking balance...";
3077
3282
  const { wei: balance } = await ctx.walletClient.getBalance(accountInfo.address);
3078
3283
  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.", {
3284
+ spinner.stop();
3285
+ throw new TxError(ERR_SPONSOR_FAILED2, "Sponsorship failed and account has no ETH to pay gas.", {
3081
3286
  reason: sponsorError ?? "unknown",
3082
3287
  account: accountInfo.address,
3083
3288
  chain: chainConfig.name,
@@ -3086,38 +3291,38 @@ async function buildUserOp(ctx, target, specs, sponsor) {
3086
3291
  }
3087
3292
  }
3088
3293
  }
3089
- spinner.succeed("UserOp built.");
3294
+ spinner.stop();
3090
3295
  return { userOp, accountInfo, chainConfig, sponsored, txType };
3091
3296
  }
3092
3297
  function resolveAccountStrict(ctx, identifier) {
3093
3298
  const account = ctx.account.resolveAccount(identifier);
3094
3299
  if (!account) {
3095
- throw new TxError(ERR_ACCOUNT_NOT_READY, `Account "${identifier}" not found.`, { identifier });
3300
+ throw new TxError(ERR_ACCOUNT_NOT_READY2, `Account "${identifier}" not found.`, { identifier });
3096
3301
  }
3097
3302
  return account;
3098
3303
  }
3099
3304
  function resolveChainStrict(ctx, chainId) {
3100
3305
  const chain = ctx.chain.chains.find((c) => c.id === chainId);
3101
3306
  if (!chain) {
3102
- throw new TxError(ERR_ACCOUNT_NOT_READY, `Chain ${chainId} not configured.`, { chainId });
3307
+ throw new TxError(ERR_ACCOUNT_NOT_READY2, `Chain ${chainId} not configured.`, { chainId });
3103
3308
  }
3104
3309
  return chain;
3105
3310
  }
3106
3311
  function serializeUserOp(op) {
3107
3312
  return {
3108
3313
  sender: op.sender,
3109
- nonce: toHex5(op.nonce),
3314
+ nonce: toHex6(op.nonce),
3110
3315
  factory: op.factory,
3111
3316
  factoryData: op.factoryData,
3112
3317
  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),
3318
+ callGasLimit: toHex6(op.callGasLimit),
3319
+ verificationGasLimit: toHex6(op.verificationGasLimit),
3320
+ preVerificationGas: toHex6(op.preVerificationGas),
3321
+ maxFeePerGas: toHex6(op.maxFeePerGas),
3322
+ maxPriorityFeePerGas: toHex6(op.maxPriorityFeePerGas),
3118
3323
  paymaster: op.paymaster,
3119
- paymasterVerificationGasLimit: op.paymasterVerificationGasLimit ? toHex5(op.paymasterVerificationGasLimit) : null,
3120
- paymasterPostOpGasLimit: op.paymasterPostOpGasLimit ? toHex5(op.paymasterPostOpGasLimit) : null,
3324
+ paymasterVerificationGasLimit: op.paymasterVerificationGasLimit ? toHex6(op.paymasterVerificationGasLimit) : null,
3325
+ paymasterPostOpGasLimit: op.paymasterPostOpGasLimit ? toHex6(op.paymasterPostOpGasLimit) : null,
3121
3326
  paymasterData: op.paymasterData,
3122
3327
  signature: op.signature
3123
3328
  };
@@ -3127,10 +3332,10 @@ function deserializeUserOp(json) {
3127
3332
  try {
3128
3333
  raw = JSON.parse(json);
3129
3334
  } catch {
3130
- throw new TxError(ERR_INVALID_PARAMS, "Invalid UserOp JSON. Pass a JSON-encoded UserOp object.", { json });
3335
+ throw new TxError(ERR_INVALID_PARAMS2, "Invalid UserOp JSON. Pass a JSON-encoded UserOp object.", { json });
3131
3336
  }
3132
3337
  if (!raw.sender || !raw.callData) {
3133
- throw new TxError(ERR_INVALID_PARAMS, "Invalid UserOp: missing required fields (sender, callData).");
3338
+ throw new TxError(ERR_INVALID_PARAMS2, "Invalid UserOp: missing required fields (sender, callData).");
3134
3339
  }
3135
3340
  return {
3136
3341
  sender: raw.sender,
@@ -3156,7 +3361,7 @@ import ora4 from "ora";
3156
3361
  import { isAddress as isAddress2, formatEther as formatEther4, formatUnits } from "viem";
3157
3362
 
3158
3363
  // src/utils/erc20.ts
3159
- import { encodeFunctionData, parseUnits } from "viem";
3364
+ import { encodeFunctionData as encodeFunctionData2, parseUnits } from "viem";
3160
3365
  var ERC20_ABI = [
3161
3366
  {
3162
3367
  name: "transfer",
@@ -3233,7 +3438,7 @@ function registerQueryCommand(program2, ctx) {
3233
3438
  getTokenBalance(ctx.walletClient, opts.token, accountInfo.address)
3234
3439
  ]);
3235
3440
  spinner.stop();
3236
- outputSuccess({
3441
+ outputResult({
3237
3442
  account: accountInfo.alias,
3238
3443
  address: accountInfo.address,
3239
3444
  chain: chainConfig.name,
@@ -3245,7 +3450,7 @@ function registerQueryCommand(program2, ctx) {
3245
3450
  } else {
3246
3451
  const { ether } = await ctx.walletClient.getBalance(accountInfo.address);
3247
3452
  spinner.stop();
3248
- outputSuccess({
3453
+ outputResult({
3249
3454
  account: accountInfo.alias,
3250
3455
  address: accountInfo.address,
3251
3456
  chain: chainConfig.name,
@@ -3265,20 +3470,25 @@ function registerQueryCommand(program2, ctx) {
3265
3470
  const rawBalances = await ctx.walletClient.getTokenBalances(accountInfo.address);
3266
3471
  if (rawBalances.length === 0) {
3267
3472
  spinner.stop();
3268
- heading(`Token Holdings (${accountInfo.alias})`);
3269
- info("Result", "No ERC-20 tokens found.");
3473
+ outputResult({
3474
+ account: accountInfo.alias,
3475
+ address: accountInfo.address,
3476
+ chain: chainConfig.name,
3477
+ tokens: [],
3478
+ total: 0
3479
+ });
3270
3480
  return;
3271
3481
  }
3272
3482
  spinner.text = `Fetching metadata for ${rawBalances.length} tokens...`;
3273
3483
  const tokens = await Promise.all(
3274
3484
  rawBalances.map(async ({ tokenAddress, balance }) => {
3275
3485
  try {
3276
- const info2 = await getTokenInfo(ctx.walletClient, tokenAddress);
3486
+ const info = await getTokenInfo(ctx.walletClient, tokenAddress);
3277
3487
  return {
3278
3488
  address: tokenAddress,
3279
- symbol: info2.symbol,
3280
- decimals: info2.decimals,
3281
- balance: formatUnits(balance, info2.decimals),
3489
+ symbol: info.symbol,
3490
+ decimals: info.decimals,
3491
+ balance: formatUnits(balance, info.decimals),
3282
3492
  rawBalance: balance
3283
3493
  };
3284
3494
  } catch {
@@ -3293,25 +3503,18 @@ function registerQueryCommand(program2, ctx) {
3293
3503
  })
3294
3504
  );
3295
3505
  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) => ({
3506
+ outputResult({
3507
+ account: accountInfo.alias,
3508
+ address: accountInfo.address,
3509
+ chain: chainConfig.name,
3510
+ tokens: tokens.map((t) => ({
3303
3511
  address: t.address,
3304
3512
  symbol: t.symbol,
3305
- decimals: String(t.decimals),
3513
+ decimals: t.decimals,
3306
3514
  balance: t.balance
3307
3515
  })),
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
- );
3516
+ total: tokens.length
3517
+ });
3315
3518
  } catch (err) {
3316
3519
  outputError(-32e3, sanitizeErrorMessage(err.message));
3317
3520
  }
@@ -3337,7 +3540,7 @@ function registerQueryCommand(program2, ctx) {
3337
3540
  return;
3338
3541
  }
3339
3542
  spinner.stop();
3340
- outputSuccess({
3543
+ outputResult({
3341
3544
  hash: receipt.transactionHash,
3342
3545
  status: receipt.status,
3343
3546
  block: receipt.blockNumber.toString(),
@@ -3360,7 +3563,7 @@ function registerQueryCommand(program2, ctx) {
3360
3563
  ctx.walletClient.getGasPrice()
3361
3564
  ]);
3362
3565
  spinner.stop();
3363
- outputSuccess({
3566
+ outputResult({
3364
3567
  chainId: chainConfig.id,
3365
3568
  name: chainConfig.name,
3366
3569
  nativeCurrency: chainConfig.nativeCurrency.symbol,
@@ -3390,7 +3593,7 @@ function registerQueryCommand(program2, ctx) {
3390
3593
  const isContract = !!code && code !== "0x";
3391
3594
  const codeSize = isContract ? (code.length - 2) / 2 : 0;
3392
3595
  spinner.stop();
3393
- outputSuccess({
3596
+ outputResult({
3394
3597
  address: addr,
3395
3598
  chain: chainConfig.name,
3396
3599
  type: isContract ? "contract" : "EOA",
@@ -3431,53 +3634,15 @@ function resolveCurrentChain(ctx) {
3431
3634
  function isHex66(s) {
3432
3635
  return /^0x[0-9a-fA-F]{64}$/.test(s);
3433
3636
  }
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
3637
 
3442
3638
  // src/commands/security.ts
3443
3639
  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;
3640
+ var ERR_ACCOUNT_NOT_READY3 = -32002;
3476
3641
  var ERR_HOOK_AUTH_FAILED = -32007;
3477
3642
  var ERR_EMAIL_NOT_BOUND = -32010;
3478
3643
  var ERR_SAFETY_DELAY = -32011;
3479
3644
  var ERR_OTP_VERIFY_FAILED = -32012;
3480
- var ERR_INTERNAL2 = -32e3;
3645
+ var ERR_INTERNAL3 = -32e3;
3481
3646
  var SecurityError = class extends Error {
3482
3647
  code;
3483
3648
  data;
@@ -3490,33 +3655,32 @@ var SecurityError = class extends Error {
3490
3655
  };
3491
3656
  function handleSecurityError(err) {
3492
3657
  if (err instanceof SecurityError) {
3493
- txError({ code: err.code, message: sanitizeErrorMessage(err.message), data: err.data });
3658
+ outputError(err.code, err.message, err.data);
3494
3659
  } else {
3495
- txError({
3496
- code: ERR_INTERNAL2,
3497
- message: sanitizeErrorMessage(err.message ?? String(err))
3498
- });
3660
+ outputError(ERR_INTERNAL3, err.message ?? String(err));
3499
3661
  }
3500
- process.exitCode = 1;
3501
3662
  }
3502
3663
  function initSecurityContext(ctx) {
3503
- if (!ctx.deviceKey) {
3504
- throw new SecurityError(ERR_ACCOUNT_NOT_READY2, "Wallet not initialized. Run `elytro init` first.");
3664
+ if (!ctx.keyring.isUnlocked) {
3665
+ throw new SecurityError(
3666
+ ERR_ACCOUNT_NOT_READY3,
3667
+ "Keyring is locked. Run `elytro init` to initialize, or check your secret provider (Keychain / ELYTRO_VAULT_SECRET)."
3668
+ );
3505
3669
  }
3506
3670
  const current = ctx.account.currentAccount;
3507
3671
  if (!current) {
3508
- throw new SecurityError(ERR_ACCOUNT_NOT_READY2, "No account selected. Run `elytro account create` first.");
3672
+ throw new SecurityError(ERR_ACCOUNT_NOT_READY3, "No account selected. Run `elytro account create` first.");
3509
3673
  }
3510
3674
  const account = ctx.account.resolveAccount(current.alias ?? current.address);
3511
3675
  if (!account) {
3512
- throw new SecurityError(ERR_ACCOUNT_NOT_READY2, "Account not found.");
3676
+ throw new SecurityError(ERR_ACCOUNT_NOT_READY3, "Account not found.");
3513
3677
  }
3514
3678
  if (!account.isDeployed) {
3515
- throw new SecurityError(ERR_ACCOUNT_NOT_READY2, "Account not deployed. Run `elytro account activate` first.");
3679
+ throw new SecurityError(ERR_ACCOUNT_NOT_READY3, "Account not deployed. Run `elytro account activate` first.");
3516
3680
  }
3517
3681
  const chainConfig = ctx.chain.chains.find((c) => c.id === account.chainId);
3518
3682
  if (!chainConfig) {
3519
- throw new SecurityError(ERR_ACCOUNT_NOT_READY2, `No chain config for chainId ${account.chainId}.`);
3683
+ throw new SecurityError(ERR_ACCOUNT_NOT_READY3, `No chain config for chainId ${account.chainId}.`);
3520
3684
  }
3521
3685
  ctx.walletClient.initForChain(chainConfig);
3522
3686
  const hookService = new SecurityHookService({
@@ -3575,14 +3739,8 @@ async function signAndSend(ctx, chainConfig, userOp, spinner) {
3575
3739
  spinner.text = "Waiting for receipt...";
3576
3740
  const receipt = await ctx.sdk.waitForReceipt(opHash);
3577
3741
  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.", {
3742
+ if (!receipt.success) {
3743
+ throw new SecurityError(ERR_INTERNAL3, "Transaction reverted on-chain.", {
3586
3744
  txHash: receipt.transactionHash
3587
3745
  });
3588
3746
  }
@@ -3611,14 +3769,8 @@ async function signWithHookAndSend(ctx, chainConfig, account, hookService, userO
3611
3769
  spinner.text = "Waiting for receipt...";
3612
3770
  const receipt = await ctx.sdk.waitForReceipt(opHash);
3613
3771
  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.", {
3772
+ if (!receipt.success) {
3773
+ throw new SecurityError(ERR_INTERNAL3, "Transaction reverted on-chain.", {
3622
3774
  txHash: receipt.transactionHash
3623
3775
  });
3624
3776
  }
@@ -3629,14 +3781,12 @@ async function handleOtpChallenge(hookService, account, ctx, userOp, hookResult)
3629
3781
  if (errCode !== "OTP_REQUIRED" && errCode !== "SPENDING_LIMIT_EXCEEDED") {
3630
3782
  throw new SecurityError(ERR_HOOK_AUTH_FAILED, `Hook authorization failed: ${err.message ?? errCode}`);
3631
3783
  }
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
- }
3784
+ console.error(JSON.stringify({
3785
+ challenge: errCode,
3786
+ message: err.message ?? `Verification required (${errCode}).`,
3787
+ ...err.maskedEmail ? { maskedEmail: err.maskedEmail } : {},
3788
+ ...errCode === "SPENDING_LIMIT_EXCEEDED" && err.projectedSpendUsdCents !== void 0 ? { projectedSpendUsd: (err.projectedSpendUsdCents / 100).toFixed(2), dailyLimitUsd: ((err.dailyLimitUsdCents ?? 0) / 100).toFixed(2) } : {}
3789
+ }, null, 2));
3640
3790
  const otpCode = await askInput("Enter the 6-digit OTP code:");
3641
3791
  const verifySpinner = ora5("Verifying OTP...").start();
3642
3792
  try {
@@ -3676,37 +3826,34 @@ function registerSecurityCommand(program2, ctx) {
3676
3826
  } catch {
3677
3827
  }
3678
3828
  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
- }
3829
+ outputResult({
3830
+ account: account.alias,
3831
+ address: account.address,
3832
+ chain: chainConfig.name,
3833
+ chainId: account.chainId,
3834
+ hookInstalled: hookStatus.installed,
3835
+ ...hookStatus.installed ? {
3836
+ hookAddress: hookStatus.hookAddress,
3837
+ capabilities: {
3838
+ preUserOpValidation: hookStatus.capabilities.preUserOpValidation,
3839
+ preIsValidSignature: hookStatus.capabilities.preIsValidSignature
3840
+ },
3841
+ ...hookStatus.forceUninstall.initiated ? {
3842
+ forceUninstall: {
3843
+ initiated: true,
3844
+ canExecute: hookStatus.forceUninstall.canExecute,
3845
+ availableAfter: hookStatus.forceUninstall.availableAfter
3846
+ }
3847
+ } : {}
3848
+ } : {},
3849
+ ...profile ? {
3850
+ profile: {
3851
+ email: profile.maskedEmail ?? profile.email ?? null,
3852
+ emailVerified: profile.emailVerified,
3853
+ ...profile.dailyLimitUsdCents !== void 0 ? { dailyLimitUsd: (profile.dailyLimitUsdCents / 100).toFixed(2) } : {}
3854
+ }
3855
+ } : {}
3856
+ });
3710
3857
  } catch (err) {
3711
3858
  handleSecurityError(err);
3712
3859
  }
@@ -3724,25 +3871,22 @@ function registerSecurityCommand(program2, ctx) {
3724
3871
  const currentStatus = await hookService.getHookStatus(account.address, account.chainId);
3725
3872
  spinner.stop();
3726
3873
  if (currentStatus.installed) {
3727
- warn("SecurityHook is already installed on this account.");
3874
+ outputResult({ status: "already_installed", account: account.alias, address: account.address });
3728
3875
  return;
3729
3876
  }
3730
3877
  const hookAddress = SECURITY_HOOK_ADDRESS_MAP[account.chainId];
3731
3878
  if (!hookAddress) {
3732
- throw new SecurityError(ERR_INTERNAL2, `SecurityHook not deployed on chain ${account.chainId}.`);
3879
+ throw new SecurityError(ERR_INTERNAL3, `SecurityHook not deployed on chain ${account.chainId}.`);
3733
3880
  }
3734
3881
  const capabilityFlags = Number(opts.capability);
3735
3882
  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?");
3883
+ throw new SecurityError(ERR_INTERNAL3, "Invalid capability flags. Use 1, 2, or 3.");
3884
+ }
3885
+ const confirmed = await askConfirm(
3886
+ `Install SecurityHook on ${account.alias} (${address(account.address)})? Capability: ${CAPABILITY_LABELS[capabilityFlags]}, Safety Delay: ${DEFAULT_SAFETY_DELAY}s`
3887
+ );
3744
3888
  if (!confirmed) {
3745
- warn("Cancelled.");
3889
+ outputResult({ status: "cancelled" });
3746
3890
  return;
3747
3891
  }
3748
3892
  const installTx = encodeInstallHook(account.address, hookAddress, DEFAULT_SAFETY_DELAY, capabilityFlags);
@@ -3750,7 +3894,14 @@ function registerSecurityCommand(program2, ctx) {
3750
3894
  try {
3751
3895
  const userOp = await buildUserOp2(ctx, chainConfig, account, [installTx], buildSpinner);
3752
3896
  await signAndSend(ctx, chainConfig, userOp, buildSpinner);
3753
- success("SecurityHook installed successfully!");
3897
+ outputResult({
3898
+ status: "installed",
3899
+ account: account.alias,
3900
+ address: account.address,
3901
+ hookAddress,
3902
+ capability: CAPABILITY_LABELS[capabilityFlags],
3903
+ safetyDelay: DEFAULT_SAFETY_DELAY
3904
+ });
3754
3905
  } catch (innerErr) {
3755
3906
  buildSpinner.stop();
3756
3907
  throw innerErr;
@@ -3767,7 +3918,7 @@ function registerSecurityCommand(program2, ctx) {
3767
3918
  const currentStatus = await hookService.getHookStatus(account.address, account.chainId);
3768
3919
  spinner.stop();
3769
3920
  if (!currentStatus.installed) {
3770
- warn("SecurityHook is not installed on this account.");
3921
+ outputResult({ status: "not_installed", account: account.alias, address: account.address });
3771
3922
  return;
3772
3923
  }
3773
3924
  const hookAddress = currentStatus.hookAddress;
@@ -3796,8 +3947,7 @@ function registerSecurityCommand(program2, ctx) {
3796
3947
  throw new SecurityError(ERR_HOOK_AUTH_FAILED, sanitizeErrorMessage(err.message));
3797
3948
  }
3798
3949
  spinner.stop();
3799
- info("OTP sent to", bindingResult.maskedEmail);
3800
- info("Expires at", bindingResult.otpExpiresAt);
3950
+ console.error(JSON.stringify({ otpSentTo: bindingResult.maskedEmail, expiresAt: bindingResult.otpExpiresAt }, null, 2));
3801
3951
  const otpCode = await askInput("Enter the 6-digit OTP code:");
3802
3952
  const confirmSpinner = ora5("Confirming email binding...").start();
3803
3953
  try {
@@ -3808,9 +3958,11 @@ function registerSecurityCommand(program2, ctx) {
3808
3958
  otpCode.trim()
3809
3959
  );
3810
3960
  confirmSpinner.stop();
3811
- success("Email bound successfully!");
3812
- info("Email", profile.maskedEmail ?? profile.email ?? emailAddr);
3813
- info("Verified", profile.emailVerified ? "Yes" : "No");
3961
+ outputResult({
3962
+ status: "email_bound",
3963
+ email: profile.maskedEmail ?? profile.email ?? emailAddr,
3964
+ emailVerified: profile.emailVerified
3965
+ });
3814
3966
  } catch (err) {
3815
3967
  confirmSpinner.stop();
3816
3968
  throw new SecurityError(
@@ -3835,7 +3987,7 @@ function registerSecurityCommand(program2, ctx) {
3835
3987
  throw new SecurityError(ERR_HOOK_AUTH_FAILED, sanitizeErrorMessage(err.message));
3836
3988
  }
3837
3989
  spinner.stop();
3838
- info("OTP sent to", bindingResult.maskedEmail);
3990
+ console.error(JSON.stringify({ otpSentTo: bindingResult.maskedEmail }, null, 2));
3839
3991
  const otpCode = await askInput("Enter the 6-digit OTP code:");
3840
3992
  const confirmSpinner = ora5("Confirming email change...").start();
3841
3993
  try {
@@ -3846,8 +3998,10 @@ function registerSecurityCommand(program2, ctx) {
3846
3998
  otpCode.trim()
3847
3999
  );
3848
4000
  confirmSpinner.stop();
3849
- success("Email changed successfully!");
3850
- info("Email", profile.maskedEmail ?? profile.email ?? emailAddr);
4001
+ outputResult({
4002
+ status: "email_changed",
4003
+ email: profile.maskedEmail ?? profile.email ?? emailAddr
4004
+ });
3851
4005
  } catch (err) {
3852
4006
  confirmSpinner.stop();
3853
4007
  throw new SecurityError(
@@ -3886,11 +4040,9 @@ async function handleForceExecute(ctx, chainConfig, account, currentStatus) {
3886
4040
  `Safety delay not elapsed. Available after ${currentStatus.forceUninstall.availableAfter}.`
3887
4041
  );
3888
4042
  }
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.");
4043
+ const confirmed = await askConfirm(`Execute force uninstall on ${account.alias} (${address(account.address)})? This will remove the SecurityHook.`);
3892
4044
  if (!confirmed) {
3893
- warn("Cancelled.");
4045
+ outputResult({ status: "cancelled" });
3894
4046
  return;
3895
4047
  }
3896
4048
  const uninstallTx = encodeUninstallHook(account.address, currentStatus.hookAddress);
@@ -3898,6 +4050,7 @@ async function handleForceExecute(ctx, chainConfig, account, currentStatus) {
3898
4050
  try {
3899
4051
  const userOp = await buildUserOp2(ctx, chainConfig, account, [uninstallTx], spinner);
3900
4052
  await signAndSend(ctx, chainConfig, userOp, spinner);
4053
+ outputResult({ status: "force_uninstalled", account: account.alias, address: account.address });
3901
4054
  } catch (err) {
3902
4055
  spinner.stop();
3903
4056
  throw err;
@@ -3905,22 +4058,19 @@ async function handleForceExecute(ctx, chainConfig, account, currentStatus) {
3905
4058
  }
3906
4059
  async function handleForceStart(ctx, chainConfig, account, currentStatus, hookAddress) {
3907
4060
  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
- }
4061
+ outputResult({
4062
+ status: "already_initiated",
4063
+ canExecute: currentStatus.forceUninstall.canExecute,
4064
+ availableAfter: currentStatus.forceUninstall.availableAfter,
4065
+ hint: currentStatus.forceUninstall.canExecute ? "Run `security 2fa uninstall --force --execute`." : `Wait until ${currentStatus.forceUninstall.availableAfter}.`
4066
+ });
3916
4067
  return;
3917
4068
  }
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?");
4069
+ const confirmed = await askConfirm(
4070
+ `Start force-uninstall countdown on ${account.alias} (${address(account.address)})? You must wait ${DEFAULT_SAFETY_DELAY}s before executing.`
4071
+ );
3922
4072
  if (!confirmed) {
3923
- warn("Cancelled.");
4073
+ outputResult({ status: "cancelled" });
3924
4074
  return;
3925
4075
  }
3926
4076
  const preUninstallTx = encodeForcePreUninstall(hookAddress);
@@ -3928,17 +4078,21 @@ async function handleForceStart(ctx, chainConfig, account, currentStatus, hookAd
3928
4078
  try {
3929
4079
  const userOp = await buildUserOp2(ctx, chainConfig, account, [preUninstallTx], spinner);
3930
4080
  await signAndSend(ctx, chainConfig, userOp, spinner);
4081
+ outputResult({
4082
+ status: "force_uninstall_started",
4083
+ account: account.alias,
4084
+ address: account.address,
4085
+ safetyDelay: DEFAULT_SAFETY_DELAY
4086
+ });
3931
4087
  } catch (err) {
3932
4088
  spinner.stop();
3933
4089
  throw err;
3934
4090
  }
3935
4091
  }
3936
4092
  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)");
4093
+ const confirmed = await askConfirm(`Uninstall SecurityHook from ${account.alias} (${address(account.address)})? (requires 2FA approval)`);
3940
4094
  if (!confirmed) {
3941
- warn("Cancelled.");
4095
+ outputResult({ status: "cancelled" });
3942
4096
  return;
3943
4097
  }
3944
4098
  const uninstallTx = encodeUninstallHook(account.address, hookAddress);
@@ -3946,6 +4100,7 @@ async function handleNormalUninstall(ctx, chainConfig, account, hookService, hoo
3946
4100
  try {
3947
4101
  const userOp = await buildUserOp2(ctx, chainConfig, account, [uninstallTx], spinner);
3948
4102
  await signWithHookAndSend(ctx, chainConfig, account, hookService, userOp, spinner);
4103
+ outputResult({ status: "uninstalled", account: account.alias, address: account.address });
3949
4104
  } catch (err) {
3950
4105
  spinner.stop();
3951
4106
  throw err;
@@ -3962,24 +4117,23 @@ async function showSpendingLimit(hookService, account) {
3962
4117
  }
3963
4118
  spinner.stop();
3964
4119
  if (!profile) {
3965
- warn("No security profile found. Bind an email first: `elytro security email bind <email>`.");
4120
+ outputResult({
4121
+ status: "no_profile",
4122
+ hint: "Bind an email first: `elytro security email bind <email>`."
4123
+ });
3966
4124
  return;
3967
4125
  }
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");
4126
+ outputResult({
4127
+ dailyLimitUsd: profile.dailyLimitUsdCents !== void 0 ? (profile.dailyLimitUsdCents / 100).toFixed(2) : null,
4128
+ email: profile.maskedEmail ?? null
4129
+ });
3974
4130
  }
3975
4131
  async function setSpendingLimit(hookService, account, amountStr) {
3976
4132
  const amountUsd = parseFloat(amountStr);
3977
4133
  if (isNaN(amountUsd) || amountUsd < 0) {
3978
- throw new SecurityError(ERR_INTERNAL2, "Invalid amount. Provide a positive number in USD.");
4134
+ throw new SecurityError(ERR_INTERNAL3, "Invalid amount. Provide a positive number in USD.");
3979
4135
  }
3980
4136
  const dailyLimitUsdCents = Math.round(amountUsd * 100);
3981
- heading("Set Daily Spending Limit");
3982
- info("New Limit", `$${amountUsd.toFixed(2)}`);
3983
4137
  const spinner = ora5("Requesting OTP for limit change...").start();
3984
4138
  let otpResult;
3985
4139
  try {
@@ -3993,13 +4147,16 @@ async function setSpendingLimit(hookService, account, amountStr) {
3993
4147
  throw new SecurityError(ERR_HOOK_AUTH_FAILED, sanitizeErrorMessage(msg));
3994
4148
  }
3995
4149
  spinner.stop();
3996
- info("OTP sent to", otpResult.maskedEmail);
4150
+ console.error(JSON.stringify({ otpSentTo: otpResult.maskedEmail }, null, 2));
3997
4151
  const otpCode = await askInput("Enter the 6-digit OTP code:");
3998
4152
  const setSpinner = ora5("Setting daily limit...").start();
3999
4153
  try {
4000
4154
  await hookService.setDailyLimit(account.address, account.chainId, dailyLimitUsdCents, otpCode.trim());
4001
4155
  setSpinner.stop();
4002
- success(`Daily limit set to $${amountUsd.toFixed(2)}.`);
4156
+ outputResult({
4157
+ status: "daily_limit_set",
4158
+ dailyLimitUsd: amountUsd.toFixed(2)
4159
+ });
4003
4160
  } catch (err) {
4004
4161
  setSpinner.stop();
4005
4162
  throw new SecurityError(
@@ -4022,58 +4179,51 @@ function maskKey(value) {
4022
4179
  function registerConfigCommand(program2, ctx) {
4023
4180
  const configCmd = program2.command("config").description("Manage CLI configuration (API keys, RPC endpoints)");
4024
4181
  configCmd.command("show").description("Show current endpoint configuration").action(() => {
4025
- heading("Configuration");
4026
4182
  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
4183
  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
- }
4184
+ outputResult({
4185
+ rpcProvider: keys.alchemyKey ? "Alchemy (user-configured)" : "Public (publicnode.com)",
4186
+ bundlerProvider: keys.pimlicoKey ? "Pimlico (user-configured)" : "Public (pimlico.io/public)",
4187
+ ...keys.alchemyKey ? { alchemyKey: maskKey(keys.alchemyKey) } : {},
4188
+ ...keys.pimlicoKey ? { pimlicoKey: maskKey(keys.pimlicoKey) } : {},
4189
+ currentChain: chain.name,
4190
+ chainId: chain.id,
4191
+ rpcEndpoint: maskApiKeys(chain.endpoint),
4192
+ bundler: maskApiKeys(chain.bundler)
4193
+ });
4048
4194
  });
4049
4195
  configCmd.command("set <key> <value>").description(`Set an API key (${VALID_KEYS.join(" | ")})`).action(async (key, value) => {
4050
4196
  const mapped = KEY_MAP[key];
4051
4197
  if (!mapped) {
4052
- error(`Unknown key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
4053
- process.exitCode = 1;
4198
+ outputError(-32602, `Unknown key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
4054
4199
  return;
4055
4200
  }
4056
4201
  await ctx.chain.setUserKey(mapped, value);
4057
- success(`${key} saved. Endpoints updated.`);
4058
4202
  const chain = ctx.chain.currentChain;
4059
- info("RPC endpoint", maskApiKeys(chain.endpoint));
4060
- info("Bundler", maskApiKeys(chain.bundler));
4203
+ outputResult({
4204
+ key,
4205
+ status: "saved",
4206
+ rpcEndpoint: maskApiKeys(chain.endpoint),
4207
+ bundler: maskApiKeys(chain.bundler)
4208
+ });
4061
4209
  });
4062
4210
  configCmd.command("remove <key>").description(`Remove an API key and revert to public endpoint (${VALID_KEYS.join(" | ")})`).action(async (key) => {
4063
4211
  const mapped = KEY_MAP[key];
4064
4212
  if (!mapped) {
4065
- error(`Unknown key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
4066
- process.exitCode = 1;
4213
+ outputError(-32602, `Unknown key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
4067
4214
  return;
4068
4215
  }
4069
4216
  await ctx.chain.removeUserKey(mapped);
4070
- success(`${key} removed. Reverted to public endpoint.`);
4217
+ outputResult({
4218
+ key,
4219
+ status: "removed"
4220
+ });
4071
4221
  });
4072
4222
  }
4073
4223
 
4074
4224
  // src/index.ts
4075
4225
  var program = new Command();
4076
- program.name("elytro").description("Elytro \u2014 ERC-4337 Smart Account Wallet CLI").version("0.0.1");
4226
+ program.name("elytro").description("Elytro \u2014 ERC-4337 Smart Account Wallet CLI").version("0.0.1").addHelpText("after", "\nLearn how to use Elytro skills: https://github.com/Elytro-eth/skills\n");
4077
4227
  async function main() {
4078
4228
  let ctx = null;
4079
4229
  try {
@@ -4086,8 +4236,7 @@ async function main() {
4086
4236
  registerConfigCommand(program, ctx);
4087
4237
  await program.parseAsync(process.argv);
4088
4238
  } catch (err) {
4089
- error(sanitizeErrorMessage(err.message));
4090
- process.exitCode = 1;
4239
+ outputError(-32e3, sanitizeErrorMessage(err.message));
4091
4240
  } finally {
4092
4241
  ctx?.keyring.lock();
4093
4242
  }