@elytro/cli 0.5.3 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +15 -0
  2. package/dist/index.js +470 -176
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -36,6 +36,7 @@ elytro query balance
36
36
  - **Transaction simulation** — Preview gas, paymaster sponsorship, and balance impact before sending
37
37
  - **Cross-chain support** — Manage accounts across Ethereum, Optimism, Arbitrum, Base, and testnets
38
38
  - **SecurityHook (2FA)** — Install on-chain 2FA with email OTP and daily spending limits
39
+ - **Deferred OTP** — Commands that require OTP exit immediately after sending the code; complete later with `elytro otp submit <id> <code>`
39
40
  - **Self-updating** — `elytro update` detects your package manager and upgrades in place
40
41
 
41
42
  ## Supported Chains
@@ -115,6 +116,11 @@ elytro security email bind <email>
115
116
  elytro security email change <email>
116
117
  elytro security spending-limit [amount] # View or set daily USD limit
117
118
 
119
+ # OTP (deferred verification)
120
+ elytro otp submit <id> <code> # Complete a pending OTP verification
121
+ elytro otp cancel [id] # Cancel pending OTP(s)
122
+ elytro otp list # List pending OTPs for current account
123
+
118
124
  # Updates
119
125
  elytro update # Check and upgrade to latest
120
126
  elytro update check # Check without installing
@@ -125,6 +131,15 @@ elytro config get <key>
125
131
  elytro config list
126
132
  ```
127
133
 
134
+ ## Deferred OTP Flow
135
+
136
+ When a command requires OTP verification (e.g. `security email bind`, `tx send` with 2FA), the CLI sends the OTP to your email and exits immediately instead of blocking for input. To complete the action:
137
+
138
+ 1. Check your email for the 6-digit code.
139
+ 2. Run `elytro otp submit <id> <code>`, where `<id>` is printed in the command output (e.g. `elytro otp submit abc123 654321`).
140
+
141
+ The `<id>` is returned in the JSON output as `otpPending.id` and in the stderr hint. Use `elytro otp list` to see all pending OTPs for the current account, or `elytro otp cancel [id]` to cancel.
142
+
128
143
  ## Development
129
144
 
130
145
  ```bash
package/dist/index.js CHANGED
@@ -2018,9 +2018,10 @@ var SecurityHookService = class {
2018
2018
  }
2019
2019
  /**
2020
2020
  * Verify a security OTP challenge.
2021
+ * @param authSessionIdOverride - If provided, use this session instead of getAuthSession (for deferred OTP resume)
2021
2022
  */
2022
- async verifySecurityOtp(walletAddress, chainId, challengeId, otpCode) {
2023
- const sessionId = await this.getAuthSession(walletAddress, chainId);
2023
+ async verifySecurityOtp(walletAddress, chainId, challengeId, otpCode, authSessionIdOverride) {
2024
+ const sessionId = authSessionIdOverride ?? await this.getAuthSession(walletAddress, chainId);
2024
2025
  const result = await this.gqlMutate(GQL_VERIFY_SECURITY_OTP, {
2025
2026
  input: { authSessionId: sessionId, challengeId, otpCode }
2026
2027
  });
@@ -2036,14 +2037,17 @@ var SecurityHookService = class {
2036
2037
  *
2037
2038
  * Handles auth retry automatically.
2038
2039
  */
2039
- async getHookSignature(walletAddress, chainId, entryPoint, userOp) {
2040
+ /**
2041
+ * @param authSessionIdOverride - If provided, use this session (for deferred OTP resume with session-bound challenge)
2042
+ */
2043
+ async getHookSignature(walletAddress, chainId, entryPoint, userOp, authSessionIdOverride) {
2040
2044
  const op = this.formatUserOpForGraphQL(userOp);
2041
2045
  for (let attempt = 0; attempt <= 1; attempt++) {
2042
2046
  try {
2043
- if (attempt > 0) {
2047
+ if (attempt > 0 && !authSessionIdOverride) {
2044
2048
  await this.clearAuthSession(walletAddress, chainId);
2045
2049
  }
2046
- const sessionId = await this.getAuthSession(walletAddress, chainId);
2050
+ const sessionId = authSessionIdOverride ?? await this.getAuthSession(walletAddress, chainId);
2047
2051
  const result = await this.gqlRaw(GQL_AUTHORIZE_USER_OPERATION, {
2048
2052
  input: {
2049
2053
  authSessionId: sessionId,
@@ -2556,16 +2560,10 @@ import ora2 from "ora";
2556
2560
  import { formatEther as formatEther2, padHex as padHex2 } from "viem";
2557
2561
 
2558
2562
  // src/utils/prompt.ts
2559
- import { password, confirm, select, input } from "@inquirer/prompts";
2560
- async function askConfirm(message, defaultValue = false) {
2561
- return confirm({ message, default: defaultValue });
2562
- }
2563
+ import { password, select, input } from "@inquirer/prompts";
2563
2564
  async function askSelect(message, choices) {
2564
2565
  return select({ message, choices });
2565
2566
  }
2566
- async function askInput(message, defaultValue) {
2567
- return input({ message, default: defaultValue });
2568
- }
2569
2567
 
2570
2568
  // src/commands/account.ts
2571
2569
  init_sponsor();
@@ -2979,7 +2977,92 @@ function createHookServiceForAccount(ctx, chainConfig) {
2979
2977
  // src/commands/tx.ts
2980
2978
  init_sponsor();
2981
2979
  import ora3 from "ora";
2982
- import { isAddress, isHex, formatEther as formatEther3, parseEther as parseEther2, toHex as toHex6 } from "viem";
2980
+ import { isAddress, isHex, formatEther as formatEther3, parseEther as parseEther2, toHex as toHex7 } from "viem";
2981
+
2982
+ // src/services/pendingOtp.ts
2983
+ import { randomBytes } from "crypto";
2984
+ var PENDING_OTPS_KEY = "pending-otps";
2985
+ function generateOtpId() {
2986
+ return randomBytes(4).toString("hex");
2987
+ }
2988
+ async function loadPendingOtps(store) {
2989
+ const data = await store.load(PENDING_OTPS_KEY);
2990
+ return data ?? {};
2991
+ }
2992
+ async function savePendingOtp(store, id, state) {
2993
+ const all = await loadPendingOtps(store);
2994
+ all[id] = state;
2995
+ await store.save(PENDING_OTPS_KEY, all);
2996
+ }
2997
+ async function removePendingOtp(store, id) {
2998
+ const all = await loadPendingOtps(store);
2999
+ delete all[id];
3000
+ await store.save(PENDING_OTPS_KEY, all);
3001
+ }
3002
+ async function clearPendingOtps(store, options) {
3003
+ const all = await loadPendingOtps(store);
3004
+ if (options?.id) {
3005
+ delete all[options.id];
3006
+ } else if (options?.account) {
3007
+ const accountLower = options.account.toLowerCase();
3008
+ for (const id of Object.keys(all)) {
3009
+ if (all[id].account.toLowerCase() === accountLower) {
3010
+ delete all[id];
3011
+ }
3012
+ }
3013
+ } else {
3014
+ for (const id of Object.keys(all)) {
3015
+ delete all[id];
3016
+ }
3017
+ }
3018
+ await store.save(PENDING_OTPS_KEY, all);
3019
+ }
3020
+ async function savePendingOtpAndOutput(store, state) {
3021
+ await savePendingOtp(store, state.id, state);
3022
+ const submitCommand = `elytro otp submit ${state.id} <6-digit-code>`;
3023
+ outputResult({
3024
+ status: "otp_pending",
3025
+ otpPending: {
3026
+ id: state.id,
3027
+ maskedEmail: state.maskedEmail,
3028
+ otpExpiresAt: state.otpExpiresAt,
3029
+ submitCommand
3030
+ }
3031
+ });
3032
+ console.error(
3033
+ `OTP sent to ${state.maskedEmail ?? "your email"}.${state.otpExpiresAt ? ` Expires at ${state.otpExpiresAt}.` : ""}
3034
+ To complete, run:
3035
+ ${submitCommand}`
3036
+ );
3037
+ }
3038
+ async function getPendingOtp(store, id) {
3039
+ const all = await loadPendingOtps(store);
3040
+ return all[id] ?? null;
3041
+ }
3042
+
3043
+ // src/utils/userOpSerialization.ts
3044
+ import { toHex as toHex6 } from "viem";
3045
+ function serializeUserOpForPending(op) {
3046
+ return {
3047
+ sender: op.sender,
3048
+ nonce: toHex6(op.nonce),
3049
+ factory: op.factory,
3050
+ factoryData: op.factoryData,
3051
+ callData: op.callData,
3052
+ callGasLimit: toHex6(op.callGasLimit),
3053
+ verificationGasLimit: toHex6(op.verificationGasLimit),
3054
+ preVerificationGas: toHex6(op.preVerificationGas),
3055
+ maxFeePerGas: toHex6(op.maxFeePerGas),
3056
+ maxPriorityFeePerGas: toHex6(op.maxPriorityFeePerGas),
3057
+ paymaster: op.paymaster,
3058
+ paymasterVerificationGasLimit: op.paymasterVerificationGasLimit ? toHex6(op.paymasterVerificationGasLimit) : null,
3059
+ paymasterPostOpGasLimit: op.paymasterPostOpGasLimit ? toHex6(op.paymasterPostOpGasLimit) : null,
3060
+ paymasterData: op.paymasterData,
3061
+ signature: op.signature
3062
+ };
3063
+ }
3064
+
3065
+ // src/commands/tx.ts
2983
3066
  var ERR_INVALID_PARAMS2 = -32602;
2984
3067
  var ERR_INSUFFICIENT_BALANCE = -32001;
2985
3068
  var ERR_ACCOUNT_NOT_READY2 = -32002;
@@ -3088,7 +3171,7 @@ function detectTxType(specs) {
3088
3171
  function specsToTxs(specs) {
3089
3172
  return specs.map((s) => ({
3090
3173
  to: s.to,
3091
- value: s.value ? toHex6(parseEther2(s.value)) : "0x0",
3174
+ value: s.value ? toHex7(parseEther2(s.value)) : "0x0",
3092
3175
  data: s.data ?? "0x"
3093
3176
  }));
3094
3177
  }
@@ -3186,11 +3269,6 @@ function registerTxCommand(program2, ctx) {
3186
3269
  estimatedGas: estimatedGas.toString()
3187
3270
  }
3188
3271
  }, null, 2));
3189
- const confirmed = await askConfirm("Sign and send this transaction?");
3190
- if (!confirmed) {
3191
- outputResult({ status: "cancelled" });
3192
- return;
3193
- }
3194
3272
  const spinner = ora3("Signing UserOperation...").start();
3195
3273
  let opHash;
3196
3274
  try {
@@ -3258,34 +3336,25 @@ function registerTxCommand(program2, ctx) {
3258
3336
  "OTP challenge ID was not provided by Elytro API. Please try again."
3259
3337
  );
3260
3338
  }
3261
- console.error(JSON.stringify({
3262
- challenge: errCode,
3263
- message: hookResult.error.message ?? `Verification required (${errCode}).`,
3264
- ...hookResult.error.maskedEmail ? { maskedEmail: hookResult.error.maskedEmail } : {},
3265
- ...hookResult.error.otpExpiresAt ? { otpExpiresAt: hookResult.error.otpExpiresAt } : {},
3266
- ...errCode === "SPENDING_LIMIT_EXCEEDED" && hookResult.error.projectedSpendUsdCents !== void 0 ? { projectedSpendUsd: (hookResult.error.projectedSpendUsdCents / 100).toFixed(2), dailyLimitUsd: ((hookResult.error.dailyLimitUsdCents ?? 0) / 100).toFixed(2) } : {}
3267
- }, null, 2));
3268
- const otpCode = await askInput("Enter the 6-digit OTP code:");
3269
- spinner.start("Verifying OTP...");
3270
- await hookService.verifySecurityOtp(
3271
- accountInfo.address,
3272
- accountInfo.chainId,
3273
- hookResult.error.challengeId,
3274
- otpCode.trim()
3275
- );
3276
- spinner.text = "OTP verified. Retrying authorization...";
3277
- hookResult = await hookService.getHookSignature(
3278
- accountInfo.address,
3279
- accountInfo.chainId,
3280
- ctx.sdk.entryPoint,
3281
- userOp
3282
- );
3283
- if (hookResult.error) {
3284
- throw new TxError(
3285
- ERR_SEND_FAILED2,
3286
- `Hook authorization failed after OTP: ${hookResult.error.message}`
3287
- );
3288
- }
3339
+ const challengeId = hookResult.error.challengeId;
3340
+ const authSessionId = await hookService.getAuthSession(accountInfo.address, accountInfo.chainId);
3341
+ await savePendingOtpAndOutput(ctx.store, {
3342
+ id: challengeId,
3343
+ account: accountInfo.address,
3344
+ chainId: accountInfo.chainId,
3345
+ action: "tx_send",
3346
+ challengeId,
3347
+ authSessionId,
3348
+ maskedEmail: hookResult.error.maskedEmail,
3349
+ otpExpiresAt: hookResult.error.otpExpiresAt,
3350
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3351
+ data: {
3352
+ userOp: serializeUserOpForPending(userOp),
3353
+ entryPoint: ctx.sdk.entryPoint,
3354
+ txSpec: opts?.tx
3355
+ }
3356
+ });
3357
+ return;
3289
3358
  } else {
3290
3359
  throw new TxError(
3291
3360
  ERR_SEND_FAILED2,
@@ -3513,18 +3582,18 @@ function resolveChainStrict(ctx, chainId) {
3513
3582
  function serializeUserOp(op) {
3514
3583
  return {
3515
3584
  sender: op.sender,
3516
- nonce: toHex6(op.nonce),
3585
+ nonce: toHex7(op.nonce),
3517
3586
  factory: op.factory,
3518
3587
  factoryData: op.factoryData,
3519
3588
  callData: op.callData,
3520
- callGasLimit: toHex6(op.callGasLimit),
3521
- verificationGasLimit: toHex6(op.verificationGasLimit),
3522
- preVerificationGas: toHex6(op.preVerificationGas),
3523
- maxFeePerGas: toHex6(op.maxFeePerGas),
3524
- maxPriorityFeePerGas: toHex6(op.maxPriorityFeePerGas),
3589
+ callGasLimit: toHex7(op.callGasLimit),
3590
+ verificationGasLimit: toHex7(op.verificationGasLimit),
3591
+ preVerificationGas: toHex7(op.preVerificationGas),
3592
+ maxFeePerGas: toHex7(op.maxFeePerGas),
3593
+ maxPriorityFeePerGas: toHex7(op.maxPriorityFeePerGas),
3525
3594
  paymaster: op.paymaster,
3526
- paymasterVerificationGasLimit: op.paymasterVerificationGasLimit ? toHex6(op.paymasterVerificationGasLimit) : null,
3527
- paymasterPostOpGasLimit: op.paymasterPostOpGasLimit ? toHex6(op.paymasterPostOpGasLimit) : null,
3595
+ paymasterVerificationGasLimit: op.paymasterVerificationGasLimit ? toHex7(op.paymasterVerificationGasLimit) : null,
3596
+ paymasterPostOpGasLimit: op.paymasterPostOpGasLimit ? toHex7(op.paymasterPostOpGasLimit) : null,
3528
3597
  paymasterData: op.paymasterData,
3529
3598
  signature: op.signature
3530
3599
  };
@@ -3843,7 +3912,6 @@ var ERR_ACCOUNT_NOT_READY3 = -32002;
3843
3912
  var ERR_HOOK_AUTH_FAILED = -32007;
3844
3913
  var ERR_EMAIL_NOT_BOUND = -32010;
3845
3914
  var ERR_SAFETY_DELAY = -32011;
3846
- var ERR_OTP_VERIFY_FAILED = -32012;
3847
3915
  var ERR_INTERNAL3 = -32e3;
3848
3916
  var SecurityError = class extends Error {
3849
3917
  code;
@@ -3855,7 +3923,16 @@ var SecurityError = class extends Error {
3855
3923
  this.data = data;
3856
3924
  }
3857
3925
  };
3926
+ var OtpDeferredError = class extends Error {
3927
+ constructor() {
3928
+ super("OTP deferred");
3929
+ this.name = "OtpDeferredError";
3930
+ }
3931
+ };
3858
3932
  function handleSecurityError(err) {
3933
+ if (err instanceof OtpDeferredError) {
3934
+ return;
3935
+ }
3859
3936
  if (err instanceof SecurityError) {
3860
3937
  outputError(err.code, err.message, err.data);
3861
3938
  } else {
@@ -3956,7 +4033,7 @@ async function signWithHookAndSend(ctx, chainConfig, account, hookService, userO
3956
4033
  let hookResult = await hookService.getHookSignature(account.address, account.chainId, ctx.sdk.entryPoint, userOp);
3957
4034
  if (hookResult.error) {
3958
4035
  spinner.stop();
3959
- hookResult = await handleOtpChallenge(hookService, account, ctx, userOp, hookResult);
4036
+ hookResult = await handleOtpChallenge(hookService, account, ctx, userOp, hookResult, "2fa_uninstall");
3960
4037
  }
3961
4038
  if (!spinner.isSpinning) spinner.start("Packing signature...");
3962
4039
  const hookAddress = SECURITY_HOOK_ADDRESS_MAP[account.chainId];
@@ -3977,42 +4054,48 @@ async function signWithHookAndSend(ctx, chainConfig, account, hookService, userO
3977
4054
  });
3978
4055
  }
3979
4056
  }
3980
- async function handleOtpChallenge(hookService, account, ctx, userOp, hookResult) {
4057
+ async function handleOtpChallenge(hookService, account, ctx, userOp, hookResult, action) {
3981
4058
  const err = hookResult.error;
3982
4059
  const errCode = err.code ?? "UNKNOWN";
3983
4060
  if (errCode !== "OTP_REQUIRED" && errCode !== "SPENDING_LIMIT_EXCEEDED") {
3984
4061
  throw new SecurityError(ERR_HOOK_AUTH_FAILED, `Hook authorization failed: ${err.message ?? errCode}`);
3985
4062
  }
3986
- console.error(JSON.stringify({
3987
- challenge: errCode,
3988
- message: err.message ?? `Verification required (${errCode}).`,
3989
- ...err.maskedEmail ? { maskedEmail: err.maskedEmail } : {},
3990
- ...errCode === "SPENDING_LIMIT_EXCEEDED" && err.projectedSpendUsdCents !== void 0 ? { projectedSpendUsd: (err.projectedSpendUsdCents / 100).toFixed(2), dailyLimitUsd: ((err.dailyLimitUsdCents ?? 0) / 100).toFixed(2) } : {}
3991
- }, null, 2));
3992
- const otpCode = await askInput("Enter the 6-digit OTP code:");
3993
- const verifySpinner = ora5("Verifying OTP...").start();
3994
- try {
3995
- await hookService.verifySecurityOtp(account.address, account.chainId, err.challengeId, otpCode.trim());
3996
- verifySpinner.text = "OTP verified. Retrying authorization...";
3997
- const retryResult = await hookService.getHookSignature(
3998
- account.address,
3999
- account.chainId,
4000
- ctx.sdk.entryPoint,
4001
- userOp
4002
- );
4003
- verifySpinner.stop();
4004
- if (retryResult.error) {
4005
- throw new SecurityError(ERR_HOOK_AUTH_FAILED, `Authorization failed after OTP: ${retryResult.error.message}`);
4006
- }
4007
- return retryResult;
4008
- } catch (e) {
4009
- verifySpinner.stop();
4010
- if (e instanceof SecurityError) throw e;
4011
- throw new SecurityError(
4012
- ERR_OTP_VERIFY_FAILED,
4013
- `OTP verification failed: ${sanitizeErrorMessage(e.message)}`
4014
- );
4015
- }
4063
+ let challengeId = err.challengeId;
4064
+ let maskedEmail = err.maskedEmail;
4065
+ let otpExpiresAt = err.otpExpiresAt;
4066
+ if (!challengeId) {
4067
+ try {
4068
+ const otpChallenge = await hookService.requestSecurityOtp(
4069
+ account.address,
4070
+ account.chainId,
4071
+ ctx.sdk.entryPoint,
4072
+ userOp
4073
+ );
4074
+ challengeId = otpChallenge.challengeId;
4075
+ maskedEmail ??= otpChallenge.maskedEmail;
4076
+ otpExpiresAt ??= otpChallenge.otpExpiresAt;
4077
+ } catch {
4078
+ challengeId = generateOtpId();
4079
+ }
4080
+ }
4081
+ const id = challengeId;
4082
+ const authSessionId = await hookService.getAuthSession(account.address, account.chainId);
4083
+ await savePendingOtpAndOutput(ctx.store, {
4084
+ id,
4085
+ account: account.address,
4086
+ chainId: account.chainId,
4087
+ action,
4088
+ challengeId,
4089
+ authSessionId,
4090
+ maskedEmail,
4091
+ otpExpiresAt,
4092
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
4093
+ data: {
4094
+ userOp: serializeUserOpForPending(userOp),
4095
+ entryPoint: ctx.sdk.entryPoint
4096
+ }
4097
+ });
4098
+ throw new OtpDeferredError();
4016
4099
  }
4017
4100
  function registerSecurityCommand(program2, ctx) {
4018
4101
  const security = program2.command("security").description("SecurityHook (2FA & spending limits)");
@@ -4084,13 +4167,6 @@ function registerSecurityCommand(program2, ctx) {
4084
4167
  if (![1, 2, 3].includes(capabilityFlags)) {
4085
4168
  throw new SecurityError(ERR_INTERNAL3, "Invalid capability flags. Use 1, 2, or 3.");
4086
4169
  }
4087
- const confirmed = await askConfirm(
4088
- `Install SecurityHook on ${account.alias} (${address(account.address)})? Capability: ${CAPABILITY_LABELS[capabilityFlags]}, Safety Delay: ${DEFAULT_SAFETY_DELAY}s`
4089
- );
4090
- if (!confirmed) {
4091
- outputResult({ status: "cancelled" });
4092
- return;
4093
- }
4094
4170
  const installTx = encodeInstallHook(account.address, hookAddress, DEFAULT_SAFETY_DELAY, capabilityFlags);
4095
4171
  const buildSpinner = ora5("Building UserOp...").start();
4096
4172
  try {
@@ -4149,29 +4225,18 @@ function registerSecurityCommand(program2, ctx) {
4149
4225
  throw new SecurityError(ERR_HOOK_AUTH_FAILED, sanitizeErrorMessage(err.message));
4150
4226
  }
4151
4227
  spinner.stop();
4152
- console.error(JSON.stringify({ otpSentTo: bindingResult.maskedEmail, expiresAt: bindingResult.otpExpiresAt }, null, 2));
4153
- const otpCode = await askInput("Enter the 6-digit OTP code:");
4154
- const confirmSpinner = ora5("Confirming email binding...").start();
4155
- try {
4156
- const profile = await hookService.confirmEmailBinding(
4157
- account.address,
4158
- account.chainId,
4159
- bindingResult.bindingId,
4160
- otpCode.trim()
4161
- );
4162
- confirmSpinner.stop();
4163
- outputResult({
4164
- status: "email_bound",
4165
- email: profile.maskedEmail ?? profile.email ?? emailAddr,
4166
- emailVerified: profile.emailVerified
4167
- });
4168
- } catch (err) {
4169
- confirmSpinner.stop();
4170
- throw new SecurityError(
4171
- ERR_OTP_VERIFY_FAILED,
4172
- `OTP verification failed: ${sanitizeErrorMessage(err.message)}`
4173
- );
4174
- }
4228
+ const id = bindingResult.bindingId;
4229
+ await savePendingOtpAndOutput(ctx.store, {
4230
+ id,
4231
+ account: account.address,
4232
+ chainId: account.chainId,
4233
+ action: "email_bind",
4234
+ bindingId: bindingResult.bindingId,
4235
+ maskedEmail: bindingResult.maskedEmail,
4236
+ otpExpiresAt: bindingResult.otpExpiresAt,
4237
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
4238
+ data: { email: emailAddr }
4239
+ });
4175
4240
  } catch (err) {
4176
4241
  handleSecurityError(err);
4177
4242
  }
@@ -4189,28 +4254,18 @@ function registerSecurityCommand(program2, ctx) {
4189
4254
  throw new SecurityError(ERR_HOOK_AUTH_FAILED, sanitizeErrorMessage(err.message));
4190
4255
  }
4191
4256
  spinner.stop();
4192
- console.error(JSON.stringify({ otpSentTo: bindingResult.maskedEmail }, null, 2));
4193
- const otpCode = await askInput("Enter the 6-digit OTP code:");
4194
- const confirmSpinner = ora5("Confirming email change...").start();
4195
- try {
4196
- const profile = await hookService.confirmEmailBinding(
4197
- account.address,
4198
- account.chainId,
4199
- bindingResult.bindingId,
4200
- otpCode.trim()
4201
- );
4202
- confirmSpinner.stop();
4203
- outputResult({
4204
- status: "email_changed",
4205
- email: profile.maskedEmail ?? profile.email ?? emailAddr
4206
- });
4207
- } catch (err) {
4208
- confirmSpinner.stop();
4209
- throw new SecurityError(
4210
- ERR_OTP_VERIFY_FAILED,
4211
- `OTP verification failed: ${sanitizeErrorMessage(err.message)}`
4212
- );
4213
- }
4257
+ const id = bindingResult.bindingId;
4258
+ await savePendingOtpAndOutput(ctx.store, {
4259
+ id,
4260
+ account: account.address,
4261
+ chainId: account.chainId,
4262
+ action: "email_change",
4263
+ bindingId: bindingResult.bindingId,
4264
+ maskedEmail: bindingResult.maskedEmail,
4265
+ otpExpiresAt: bindingResult.otpExpiresAt,
4266
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
4267
+ data: { email: emailAddr }
4268
+ });
4214
4269
  } catch (err) {
4215
4270
  handleSecurityError(err);
4216
4271
  }
@@ -4222,7 +4277,7 @@ function registerSecurityCommand(program2, ctx) {
4222
4277
  if (!amountStr) {
4223
4278
  await showSpendingLimit(hookService, account);
4224
4279
  } else {
4225
- await setSpendingLimit(hookService, account, amountStr);
4280
+ await setSpendingLimit(ctx.store, hookService, account, amountStr);
4226
4281
  }
4227
4282
  } catch (err) {
4228
4283
  handleSecurityError(err);
@@ -4242,11 +4297,6 @@ async function handleForceExecute(ctx, chainConfig, account, currentStatus) {
4242
4297
  `Safety delay not elapsed. Available after ${currentStatus.forceUninstall.availableAfter}.`
4243
4298
  );
4244
4299
  }
4245
- const confirmed = await askConfirm(`Execute force uninstall on ${account.alias} (${address(account.address)})? This will remove the SecurityHook.`);
4246
- if (!confirmed) {
4247
- outputResult({ status: "cancelled" });
4248
- return;
4249
- }
4250
4300
  const uninstallTx = encodeUninstallHook(account.address, currentStatus.hookAddress);
4251
4301
  const spinner = ora5("Executing force uninstall...").start();
4252
4302
  try {
@@ -4268,13 +4318,6 @@ async function handleForceStart(ctx, chainConfig, account, currentStatus, hookAd
4268
4318
  });
4269
4319
  return;
4270
4320
  }
4271
- const confirmed = await askConfirm(
4272
- `Start force-uninstall countdown on ${account.alias} (${address(account.address)})? You must wait ${DEFAULT_SAFETY_DELAY}s before executing.`
4273
- );
4274
- if (!confirmed) {
4275
- outputResult({ status: "cancelled" });
4276
- return;
4277
- }
4278
4321
  const preUninstallTx = encodeForcePreUninstall(hookAddress);
4279
4322
  const spinner = ora5("Starting force-uninstall countdown...").start();
4280
4323
  try {
@@ -4292,11 +4335,6 @@ async function handleForceStart(ctx, chainConfig, account, currentStatus, hookAd
4292
4335
  }
4293
4336
  }
4294
4337
  async function handleNormalUninstall(ctx, chainConfig, account, hookService, hookAddress) {
4295
- const confirmed = await askConfirm(`Uninstall SecurityHook from ${account.alias} (${address(account.address)})? (requires 2FA approval)`);
4296
- if (!confirmed) {
4297
- outputResult({ status: "cancelled" });
4298
- return;
4299
- }
4300
4338
  const uninstallTx = encodeUninstallHook(account.address, hookAddress);
4301
4339
  const spinner = ora5("Building UserOp...").start();
4302
4340
  try {
@@ -4330,7 +4368,7 @@ async function showSpendingLimit(hookService, account) {
4330
4368
  email: profile.maskedEmail ?? null
4331
4369
  });
4332
4370
  }
4333
- async function setSpendingLimit(hookService, account, amountStr) {
4371
+ async function setSpendingLimit(store, hookService, account, amountStr) {
4334
4372
  const amountUsd = parseFloat(amountStr);
4335
4373
  if (isNaN(amountUsd) || amountUsd < 0) {
4336
4374
  throw new SecurityError(ERR_INTERNAL3, "Invalid amount. Provide a positive number in USD.");
@@ -4349,24 +4387,240 @@ async function setSpendingLimit(hookService, account, amountStr) {
4349
4387
  throw new SecurityError(ERR_HOOK_AUTH_FAILED, sanitizeErrorMessage(msg));
4350
4388
  }
4351
4389
  spinner.stop();
4352
- console.error(JSON.stringify({ otpSentTo: otpResult.maskedEmail }, null, 2));
4353
- const otpCode = await askInput("Enter the 6-digit OTP code:");
4354
- const setSpinner = ora5("Setting daily limit...").start();
4355
- try {
4356
- await hookService.setDailyLimit(account.address, account.chainId, dailyLimitUsdCents, otpCode.trim());
4357
- setSpinner.stop();
4390
+ const id = generateOtpId();
4391
+ await savePendingOtpAndOutput(store, {
4392
+ id,
4393
+ account: account.address,
4394
+ chainId: account.chainId,
4395
+ action: "spending_limit",
4396
+ maskedEmail: otpResult.maskedEmail,
4397
+ otpExpiresAt: otpResult.otpExpiresAt,
4398
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
4399
+ data: { dailyLimitUsdCents }
4400
+ });
4401
+ }
4402
+
4403
+ // src/commands/otp.ts
4404
+ import ora6 from "ora";
4405
+ var ERR_OTP_NOT_FOUND = -32013;
4406
+ var ERR_OTP_EXPIRED = -32014;
4407
+ var ERR_ACCOUNT_NOT_READY4 = -32002;
4408
+ function isOtpExpired(otpExpiresAt) {
4409
+ if (!otpExpiresAt) return false;
4410
+ return new Date(otpExpiresAt).getTime() < Date.now();
4411
+ }
4412
+ function registerOtpCommand(program2, ctx) {
4413
+ const otp = program2.command("otp").description("Complete or manage deferred OTP verification");
4414
+ otp.command("submit").description("Complete a pending OTP verification").argument("<id>", "OTP id from the command that triggered OTP").argument("<code>", "6-digit OTP code").action(async (id, code) => {
4415
+ const pending = await getPendingOtp(ctx.store, id);
4416
+ if (!pending) {
4417
+ outputError(
4418
+ ERR_OTP_NOT_FOUND,
4419
+ "Unknown OTP id. It may have expired or been completed.",
4420
+ { id }
4421
+ );
4422
+ return;
4423
+ }
4424
+ if (isOtpExpired(pending.otpExpiresAt)) {
4425
+ await removePendingOtp(ctx.store, id);
4426
+ outputError(ERR_OTP_EXPIRED, "OTP has expired. Please re-run the original command.", {
4427
+ id,
4428
+ action: pending.action
4429
+ });
4430
+ return;
4431
+ }
4432
+ const current = ctx.account.currentAccount;
4433
+ if (!current || current.address.toLowerCase() !== pending.account.toLowerCase()) {
4434
+ outputError(ERR_ACCOUNT_NOT_READY4, `Switch to the account that initiated this OTP first: elytro account switch <alias>`, {
4435
+ pendingAccount: pending.account
4436
+ });
4437
+ return;
4438
+ }
4439
+ const accountInfo = ctx.account.resolveAccount(current.alias ?? current.address);
4440
+ if (!accountInfo) {
4441
+ outputError(ERR_ACCOUNT_NOT_READY4, "Account not found.");
4442
+ return;
4443
+ }
4444
+ const chainConfig = ctx.chain.chains.find((c) => c.id === pending.chainId);
4445
+ if (!chainConfig) {
4446
+ outputError(ERR_ACCOUNT_NOT_READY4, `Chain ${pending.chainId} not configured.`);
4447
+ return;
4448
+ }
4449
+ await ctx.sdk.initForChain(chainConfig);
4450
+ ctx.walletClient.initForChain(chainConfig);
4451
+ const hookService = new SecurityHookService({
4452
+ store: ctx.store,
4453
+ graphqlEndpoint: ctx.chain.graphqlEndpoint,
4454
+ signMessageForAuth: createSignMessageForAuth({
4455
+ signDigest: (digest) => ctx.keyring.signDigest(digest),
4456
+ packRawHash: (hash) => ctx.sdk.packRawHash(hash),
4457
+ packSignature: (rawSig, valData) => ctx.sdk.packUserOpSignature(rawSig, valData)
4458
+ }),
4459
+ readContract: async (params) => ctx.walletClient.readContract(params),
4460
+ getBlockTimestamp: async () => {
4461
+ const blockNum = await ctx.walletClient.raw.getBlockNumber();
4462
+ const block = await ctx.walletClient.raw.getBlock({ blockNumber: blockNum });
4463
+ return block.timestamp;
4464
+ }
4465
+ });
4466
+ const spinner = ora6("Completing OTP verification...").start();
4467
+ try {
4468
+ await completePendingOtp(ctx, hookService, accountInfo, pending, code.trim(), spinner);
4469
+ await removePendingOtp(ctx.store, id);
4470
+ } catch (err) {
4471
+ spinner.stop();
4472
+ outputError(-32e3, err.message);
4473
+ }
4474
+ });
4475
+ otp.command("cancel").description("Cancel pending OTP(s)").argument("[id]", "OTP id to cancel. Omit to cancel all for current account.").action(async (id) => {
4476
+ if (id) {
4477
+ const pending = await getPendingOtp(ctx.store, id);
4478
+ if (!pending) {
4479
+ outputError(ERR_OTP_NOT_FOUND, "Unknown OTP id.", { id });
4480
+ return;
4481
+ }
4482
+ await removePendingOtp(ctx.store, id);
4483
+ outputResult({ status: "cancelled", id });
4484
+ } else {
4485
+ const current = ctx.account.currentAccount;
4486
+ if (!current) {
4487
+ outputError(ERR_ACCOUNT_NOT_READY4, "No account selected.");
4488
+ return;
4489
+ }
4490
+ await clearPendingOtps(ctx.store, { account: current.address });
4491
+ outputResult({ status: "cancelled", scope: "account", account: current.address });
4492
+ }
4493
+ });
4494
+ otp.command("list").description("List pending OTPs for current account").action(async () => {
4495
+ const all = await loadPendingOtps(ctx.store);
4496
+ const current = ctx.account.currentAccount;
4497
+ const pendings = current ? Object.values(all).filter(
4498
+ (p) => p.account.toLowerCase() === current.address.toLowerCase()
4499
+ ) : Object.values(all);
4500
+ if (pendings.length === 0) {
4501
+ outputResult({ pendings: [], total: 0 });
4502
+ return;
4503
+ }
4358
4504
  outputResult({
4359
- status: "daily_limit_set",
4360
- dailyLimitUsd: amountUsd.toFixed(2)
4505
+ pendings: pendings.map((p) => ({
4506
+ id: p.id,
4507
+ action: p.action,
4508
+ maskedEmail: p.maskedEmail,
4509
+ otpExpiresAt: p.otpExpiresAt,
4510
+ submitCommand: `elytro otp submit ${p.id} <6-digit-code>`
4511
+ })),
4512
+ total: pendings.length
4361
4513
  });
4362
- } catch (err) {
4363
- setSpinner.stop();
4364
- throw new SecurityError(
4365
- ERR_OTP_VERIFY_FAILED,
4366
- `Failed to set limit: ${sanitizeErrorMessage(err.message)}`
4367
- );
4514
+ });
4515
+ }
4516
+ async function completePendingOtp(ctx, hookService, account, pending, code, spinner) {
4517
+ switch (pending.action) {
4518
+ case "email_bind":
4519
+ case "email_change": {
4520
+ spinner.stop();
4521
+ if (!pending.bindingId) throw new Error("Missing bindingId for email action.");
4522
+ const email = "email" in pending.data ? pending.data.email : "";
4523
+ const profile = await hookService.confirmEmailBinding(
4524
+ account.address,
4525
+ account.chainId,
4526
+ pending.bindingId,
4527
+ code
4528
+ );
4529
+ outputResult({
4530
+ status: pending.action === "email_bind" ? "email_bound" : "email_changed",
4531
+ email: profile.maskedEmail ?? profile.email ?? email,
4532
+ emailVerified: profile.emailVerified
4533
+ });
4534
+ break;
4535
+ }
4536
+ case "spending_limit": {
4537
+ spinner.stop();
4538
+ const dailyLimitUsdCents = pending.data.dailyLimitUsdCents;
4539
+ await hookService.setDailyLimit(
4540
+ account.address,
4541
+ account.chainId,
4542
+ dailyLimitUsdCents,
4543
+ code
4544
+ );
4545
+ outputResult({
4546
+ status: "daily_limit_set",
4547
+ dailyLimitUsd: (dailyLimitUsdCents / 100).toFixed(2)
4548
+ });
4549
+ break;
4550
+ }
4551
+ case "tx_send":
4552
+ case "2fa_uninstall": {
4553
+ if (!pending.challengeId) throw new Error("Missing challengeId for tx/2fa action.");
4554
+ spinner.text = "Verifying OTP...";
4555
+ const { userOp: userOpJson, entryPoint } = pending.data;
4556
+ const userOp = deserializeUserOp2(userOpJson);
4557
+ const authSessionId = pending.authSessionId;
4558
+ await hookService.verifySecurityOtp(
4559
+ account.address,
4560
+ account.chainId,
4561
+ pending.challengeId,
4562
+ code,
4563
+ authSessionId
4564
+ );
4565
+ spinner.text = "Retrying authorization...";
4566
+ const hookResult = await hookService.getHookSignature(
4567
+ account.address,
4568
+ account.chainId,
4569
+ entryPoint,
4570
+ userOp,
4571
+ authSessionId
4572
+ );
4573
+ if (hookResult.error) {
4574
+ throw new Error(`Authorization failed after OTP: ${hookResult.error.message}`);
4575
+ }
4576
+ const hookAddress = SECURITY_HOOK_ADDRESS_MAP[account.chainId];
4577
+ if (!hookAddress) throw new Error(`SecurityHook not deployed on chain ${account.chainId}.`);
4578
+ const { packedHash, validationData } = await ctx.sdk.getUserOpHash(userOp);
4579
+ const rawSignature = await ctx.keyring.signDigest(packedHash);
4580
+ userOp.signature = await ctx.sdk.packUserOpSignatureWithHook(
4581
+ rawSignature,
4582
+ validationData,
4583
+ hookAddress,
4584
+ hookResult.signature
4585
+ );
4586
+ spinner.text = "Sending UserOp...";
4587
+ const opHash = await ctx.sdk.sendUserOp(userOp);
4588
+ spinner.text = "Waiting for receipt...";
4589
+ const receipt = await ctx.sdk.waitForReceipt(opHash);
4590
+ if (!receipt.success) {
4591
+ throw new Error(`Transaction reverted: ${receipt.reason ?? "unknown"}`);
4592
+ }
4593
+ spinner.stop();
4594
+ outputResult({
4595
+ status: pending.action === "tx_send" ? "sent" : "uninstalled",
4596
+ userOpHash: opHash,
4597
+ transactionHash: receipt.transactionHash
4598
+ });
4599
+ break;
4600
+ }
4601
+ default:
4602
+ throw new Error(`Unknown action: ${pending.action}`);
4368
4603
  }
4369
4604
  }
4605
+ function deserializeUserOp2(raw) {
4606
+ return {
4607
+ sender: raw.sender,
4608
+ nonce: BigInt(raw.nonce ?? "0x0"),
4609
+ factory: raw.factory ?? null,
4610
+ factoryData: raw.factoryData ?? null,
4611
+ callData: raw.callData,
4612
+ callGasLimit: BigInt(raw.callGasLimit ?? "0x0"),
4613
+ verificationGasLimit: BigInt(raw.verificationGasLimit ?? "0x0"),
4614
+ preVerificationGas: BigInt(raw.preVerificationGas ?? "0x0"),
4615
+ maxFeePerGas: BigInt(raw.maxFeePerGas ?? "0x0"),
4616
+ maxPriorityFeePerGas: BigInt(raw.maxPriorityFeePerGas ?? "0x0"),
4617
+ paymaster: raw.paymaster ?? null,
4618
+ paymasterVerificationGasLimit: raw.paymasterVerificationGasLimit ? BigInt(raw.paymasterVerificationGasLimit) : null,
4619
+ paymasterPostOpGasLimit: raw.paymasterPostOpGasLimit ? BigInt(raw.paymasterPostOpGasLimit) : null,
4620
+ paymasterData: raw.paymasterData ?? null,
4621
+ signature: raw.signature ?? "0x"
4622
+ };
4623
+ }
4370
4624
 
4371
4625
  // src/commands/config.ts
4372
4626
  var KEY_MAP = {
@@ -4430,7 +4684,7 @@ import { execSync } from "child_process";
4430
4684
  import { createRequire } from "module";
4431
4685
  function resolveVersion() {
4432
4686
  if (true) {
4433
- return "0.5.3";
4687
+ return "0.6.1";
4434
4688
  }
4435
4689
  try {
4436
4690
  const require2 = createRequire(import.meta.url);
@@ -4443,7 +4697,7 @@ function resolveVersion() {
4443
4697
  var VERSION = resolveVersion();
4444
4698
 
4445
4699
  // src/commands/update.ts
4446
- import ora6 from "ora";
4700
+ import ora7 from "ora";
4447
4701
  import chalk2 from "chalk";
4448
4702
  import { realpathSync } from "fs";
4449
4703
  import { fileURLToPath } from "url";
@@ -4519,7 +4773,7 @@ function registerUpdateCommand(program2) {
4519
4773
  }
4520
4774
  });
4521
4775
  updateCmd.action(async () => {
4522
- const spinner = ora6("Checking for updates\u2026").start();
4776
+ const spinner = ora7("Checking for updates\u2026").start();
4523
4777
  try {
4524
4778
  const latest = await fetchLatestVersion();
4525
4779
  const cmp = compareSemver(VERSION, latest);
@@ -4550,6 +4804,41 @@ function registerUpdateCommand(program2) {
4550
4804
  });
4551
4805
  }
4552
4806
 
4807
+ // src/commands/prune.ts
4808
+ import { rm } from "fs/promises";
4809
+ import { join as join3 } from "path";
4810
+ import { homedir as homedir2 } from "os";
4811
+ var DATA_DIR = join3(homedir2(), ".elytro");
4812
+ async function runPrune() {
4813
+ try {
4814
+ const keyringProvider = new KeyringProvider();
4815
+ try {
4816
+ await keyringProvider.delete();
4817
+ } catch {
4818
+ }
4819
+ const fileProvider = new FileProvider(DATA_DIR);
4820
+ try {
4821
+ await fileProvider.delete();
4822
+ } catch {
4823
+ }
4824
+ try {
4825
+ await rm(DATA_DIR, { recursive: true, force: true });
4826
+ } catch (err) {
4827
+ const code = err.code;
4828
+ if (code !== "ENOENT") {
4829
+ throw err;
4830
+ }
4831
+ }
4832
+ outputResult({
4833
+ status: "pruned",
4834
+ dataDir: DATA_DIR,
4835
+ hint: "All local data cleared. Run `elytro init` to create a new wallet."
4836
+ });
4837
+ } catch (err) {
4838
+ outputError(-32e3, err.message);
4839
+ }
4840
+ }
4841
+
4553
4842
  // src/index.ts
4554
4843
  var program = new Command();
4555
4844
  program.name("elytro").description("Elytro \u2014 ERC-4337 Smart Account Wallet CLI").version(VERSION).addHelpText(
@@ -4557,6 +4846,10 @@ program.name("elytro").description("Elytro \u2014 ERC-4337 Smart Account Wallet
4557
4846
  "\nLearn how to use Elytro skills: https://github.com/Elytro-eth/skills\n"
4558
4847
  );
4559
4848
  async function main() {
4849
+ if (process.argv.includes("prune")) {
4850
+ await runPrune();
4851
+ return;
4852
+ }
4560
4853
  let ctx = null;
4561
4854
  try {
4562
4855
  ctx = await createAppContext();
@@ -4565,6 +4858,7 @@ async function main() {
4565
4858
  registerTxCommand(program, ctx);
4566
4859
  registerQueryCommand(program, ctx);
4567
4860
  registerSecurityCommand(program, ctx);
4861
+ registerOtpCommand(program, ctx);
4568
4862
  registerConfigCommand(program, ctx);
4569
4863
  registerUpdateCommand(program);
4570
4864
  await program.parseAsync(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elytro/cli",
3
- "version": "0.5.3",
3
+ "version": "0.6.1",
4
4
  "description": "Elytro ERC-4337 Smart Account Wallet CLI",
5
5
  "type": "module",
6
6
  "bin": {