@elytro/cli 0.5.3 → 0.6.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 +15 -0
  2. package/dist/index.js +469 -143
  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,
@@ -2563,9 +2567,6 @@ async function askConfirm(message, defaultValue = false) {
2563
2567
  async function askSelect(message, choices) {
2564
2568
  return select({ message, choices });
2565
2569
  }
2566
- async function askInput(message, defaultValue) {
2567
- return input({ message, default: defaultValue });
2568
- }
2569
2570
 
2570
2571
  // src/commands/account.ts
2571
2572
  init_sponsor();
@@ -2979,7 +2980,92 @@ function createHookServiceForAccount(ctx, chainConfig) {
2979
2980
  // src/commands/tx.ts
2980
2981
  init_sponsor();
2981
2982
  import ora3 from "ora";
2982
- import { isAddress, isHex, formatEther as formatEther3, parseEther as parseEther2, toHex as toHex6 } from "viem";
2983
+ import { isAddress, isHex, formatEther as formatEther3, parseEther as parseEther2, toHex as toHex7 } from "viem";
2984
+
2985
+ // src/services/pendingOtp.ts
2986
+ import { randomBytes } from "crypto";
2987
+ var PENDING_OTPS_KEY = "pending-otps";
2988
+ function generateOtpId() {
2989
+ return randomBytes(4).toString("hex");
2990
+ }
2991
+ async function loadPendingOtps(store) {
2992
+ const data = await store.load(PENDING_OTPS_KEY);
2993
+ return data ?? {};
2994
+ }
2995
+ async function savePendingOtp(store, id, state) {
2996
+ const all = await loadPendingOtps(store);
2997
+ all[id] = state;
2998
+ await store.save(PENDING_OTPS_KEY, all);
2999
+ }
3000
+ async function removePendingOtp(store, id) {
3001
+ const all = await loadPendingOtps(store);
3002
+ delete all[id];
3003
+ await store.save(PENDING_OTPS_KEY, all);
3004
+ }
3005
+ async function clearPendingOtps(store, options) {
3006
+ const all = await loadPendingOtps(store);
3007
+ if (options?.id) {
3008
+ delete all[options.id];
3009
+ } else if (options?.account) {
3010
+ const accountLower = options.account.toLowerCase();
3011
+ for (const id of Object.keys(all)) {
3012
+ if (all[id].account.toLowerCase() === accountLower) {
3013
+ delete all[id];
3014
+ }
3015
+ }
3016
+ } else {
3017
+ for (const id of Object.keys(all)) {
3018
+ delete all[id];
3019
+ }
3020
+ }
3021
+ await store.save(PENDING_OTPS_KEY, all);
3022
+ }
3023
+ async function savePendingOtpAndOutput(store, state) {
3024
+ await savePendingOtp(store, state.id, state);
3025
+ const submitCommand = `elytro otp submit ${state.id} <6-digit-code>`;
3026
+ outputResult({
3027
+ status: "otp_pending",
3028
+ otpPending: {
3029
+ id: state.id,
3030
+ maskedEmail: state.maskedEmail,
3031
+ otpExpiresAt: state.otpExpiresAt,
3032
+ submitCommand
3033
+ }
3034
+ });
3035
+ console.error(
3036
+ `OTP sent to ${state.maskedEmail ?? "your email"}.${state.otpExpiresAt ? ` Expires at ${state.otpExpiresAt}.` : ""}
3037
+ To complete, run:
3038
+ ${submitCommand}`
3039
+ );
3040
+ }
3041
+ async function getPendingOtp(store, id) {
3042
+ const all = await loadPendingOtps(store);
3043
+ return all[id] ?? null;
3044
+ }
3045
+
3046
+ // src/utils/userOpSerialization.ts
3047
+ import { toHex as toHex6 } from "viem";
3048
+ function serializeUserOpForPending(op) {
3049
+ return {
3050
+ sender: op.sender,
3051
+ nonce: toHex6(op.nonce),
3052
+ factory: op.factory,
3053
+ factoryData: op.factoryData,
3054
+ callData: op.callData,
3055
+ callGasLimit: toHex6(op.callGasLimit),
3056
+ verificationGasLimit: toHex6(op.verificationGasLimit),
3057
+ preVerificationGas: toHex6(op.preVerificationGas),
3058
+ maxFeePerGas: toHex6(op.maxFeePerGas),
3059
+ maxPriorityFeePerGas: toHex6(op.maxPriorityFeePerGas),
3060
+ paymaster: op.paymaster,
3061
+ paymasterVerificationGasLimit: op.paymasterVerificationGasLimit ? toHex6(op.paymasterVerificationGasLimit) : null,
3062
+ paymasterPostOpGasLimit: op.paymasterPostOpGasLimit ? toHex6(op.paymasterPostOpGasLimit) : null,
3063
+ paymasterData: op.paymasterData,
3064
+ signature: op.signature
3065
+ };
3066
+ }
3067
+
3068
+ // src/commands/tx.ts
2983
3069
  var ERR_INVALID_PARAMS2 = -32602;
2984
3070
  var ERR_INSUFFICIENT_BALANCE = -32001;
2985
3071
  var ERR_ACCOUNT_NOT_READY2 = -32002;
@@ -3088,7 +3174,7 @@ function detectTxType(specs) {
3088
3174
  function specsToTxs(specs) {
3089
3175
  return specs.map((s) => ({
3090
3176
  to: s.to,
3091
- value: s.value ? toHex6(parseEther2(s.value)) : "0x0",
3177
+ value: s.value ? toHex7(parseEther2(s.value)) : "0x0",
3092
3178
  data: s.data ?? "0x"
3093
3179
  }));
3094
3180
  }
@@ -3258,34 +3344,25 @@ function registerTxCommand(program2, ctx) {
3258
3344
  "OTP challenge ID was not provided by Elytro API. Please try again."
3259
3345
  );
3260
3346
  }
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
- }
3347
+ const challengeId = hookResult.error.challengeId;
3348
+ const authSessionId = await hookService.getAuthSession(accountInfo.address, accountInfo.chainId);
3349
+ await savePendingOtpAndOutput(ctx.store, {
3350
+ id: challengeId,
3351
+ account: accountInfo.address,
3352
+ chainId: accountInfo.chainId,
3353
+ action: "tx_send",
3354
+ challengeId,
3355
+ authSessionId,
3356
+ maskedEmail: hookResult.error.maskedEmail,
3357
+ otpExpiresAt: hookResult.error.otpExpiresAt,
3358
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3359
+ data: {
3360
+ userOp: serializeUserOpForPending(userOp),
3361
+ entryPoint: ctx.sdk.entryPoint,
3362
+ txSpec: opts?.tx
3363
+ }
3364
+ });
3365
+ return;
3289
3366
  } else {
3290
3367
  throw new TxError(
3291
3368
  ERR_SEND_FAILED2,
@@ -3513,18 +3590,18 @@ function resolveChainStrict(ctx, chainId) {
3513
3590
  function serializeUserOp(op) {
3514
3591
  return {
3515
3592
  sender: op.sender,
3516
- nonce: toHex6(op.nonce),
3593
+ nonce: toHex7(op.nonce),
3517
3594
  factory: op.factory,
3518
3595
  factoryData: op.factoryData,
3519
3596
  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),
3597
+ callGasLimit: toHex7(op.callGasLimit),
3598
+ verificationGasLimit: toHex7(op.verificationGasLimit),
3599
+ preVerificationGas: toHex7(op.preVerificationGas),
3600
+ maxFeePerGas: toHex7(op.maxFeePerGas),
3601
+ maxPriorityFeePerGas: toHex7(op.maxPriorityFeePerGas),
3525
3602
  paymaster: op.paymaster,
3526
- paymasterVerificationGasLimit: op.paymasterVerificationGasLimit ? toHex6(op.paymasterVerificationGasLimit) : null,
3527
- paymasterPostOpGasLimit: op.paymasterPostOpGasLimit ? toHex6(op.paymasterPostOpGasLimit) : null,
3603
+ paymasterVerificationGasLimit: op.paymasterVerificationGasLimit ? toHex7(op.paymasterVerificationGasLimit) : null,
3604
+ paymasterPostOpGasLimit: op.paymasterPostOpGasLimit ? toHex7(op.paymasterPostOpGasLimit) : null,
3528
3605
  paymasterData: op.paymasterData,
3529
3606
  signature: op.signature
3530
3607
  };
@@ -3843,7 +3920,6 @@ var ERR_ACCOUNT_NOT_READY3 = -32002;
3843
3920
  var ERR_HOOK_AUTH_FAILED = -32007;
3844
3921
  var ERR_EMAIL_NOT_BOUND = -32010;
3845
3922
  var ERR_SAFETY_DELAY = -32011;
3846
- var ERR_OTP_VERIFY_FAILED = -32012;
3847
3923
  var ERR_INTERNAL3 = -32e3;
3848
3924
  var SecurityError = class extends Error {
3849
3925
  code;
@@ -3855,7 +3931,16 @@ var SecurityError = class extends Error {
3855
3931
  this.data = data;
3856
3932
  }
3857
3933
  };
3934
+ var OtpDeferredError = class extends Error {
3935
+ constructor() {
3936
+ super("OTP deferred");
3937
+ this.name = "OtpDeferredError";
3938
+ }
3939
+ };
3858
3940
  function handleSecurityError(err) {
3941
+ if (err instanceof OtpDeferredError) {
3942
+ return;
3943
+ }
3859
3944
  if (err instanceof SecurityError) {
3860
3945
  outputError(err.code, err.message, err.data);
3861
3946
  } else {
@@ -3956,7 +4041,7 @@ async function signWithHookAndSend(ctx, chainConfig, account, hookService, userO
3956
4041
  let hookResult = await hookService.getHookSignature(account.address, account.chainId, ctx.sdk.entryPoint, userOp);
3957
4042
  if (hookResult.error) {
3958
4043
  spinner.stop();
3959
- hookResult = await handleOtpChallenge(hookService, account, ctx, userOp, hookResult);
4044
+ hookResult = await handleOtpChallenge(hookService, account, ctx, userOp, hookResult, "2fa_uninstall");
3960
4045
  }
3961
4046
  if (!spinner.isSpinning) spinner.start("Packing signature...");
3962
4047
  const hookAddress = SECURITY_HOOK_ADDRESS_MAP[account.chainId];
@@ -3977,42 +4062,48 @@ async function signWithHookAndSend(ctx, chainConfig, account, hookService, userO
3977
4062
  });
3978
4063
  }
3979
4064
  }
3980
- async function handleOtpChallenge(hookService, account, ctx, userOp, hookResult) {
4065
+ async function handleOtpChallenge(hookService, account, ctx, userOp, hookResult, action) {
3981
4066
  const err = hookResult.error;
3982
4067
  const errCode = err.code ?? "UNKNOWN";
3983
4068
  if (errCode !== "OTP_REQUIRED" && errCode !== "SPENDING_LIMIT_EXCEEDED") {
3984
4069
  throw new SecurityError(ERR_HOOK_AUTH_FAILED, `Hook authorization failed: ${err.message ?? errCode}`);
3985
4070
  }
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
- }
4071
+ let challengeId = err.challengeId;
4072
+ let maskedEmail = err.maskedEmail;
4073
+ let otpExpiresAt = err.otpExpiresAt;
4074
+ if (!challengeId) {
4075
+ try {
4076
+ const otpChallenge = await hookService.requestSecurityOtp(
4077
+ account.address,
4078
+ account.chainId,
4079
+ ctx.sdk.entryPoint,
4080
+ userOp
4081
+ );
4082
+ challengeId = otpChallenge.challengeId;
4083
+ maskedEmail ??= otpChallenge.maskedEmail;
4084
+ otpExpiresAt ??= otpChallenge.otpExpiresAt;
4085
+ } catch {
4086
+ challengeId = generateOtpId();
4087
+ }
4088
+ }
4089
+ const id = challengeId;
4090
+ const authSessionId = await hookService.getAuthSession(account.address, account.chainId);
4091
+ await savePendingOtpAndOutput(ctx.store, {
4092
+ id,
4093
+ account: account.address,
4094
+ chainId: account.chainId,
4095
+ action,
4096
+ challengeId,
4097
+ authSessionId,
4098
+ maskedEmail,
4099
+ otpExpiresAt,
4100
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
4101
+ data: {
4102
+ userOp: serializeUserOpForPending(userOp),
4103
+ entryPoint: ctx.sdk.entryPoint
4104
+ }
4105
+ });
4106
+ throw new OtpDeferredError();
4016
4107
  }
4017
4108
  function registerSecurityCommand(program2, ctx) {
4018
4109
  const security = program2.command("security").description("SecurityHook (2FA & spending limits)");
@@ -4149,29 +4240,18 @@ function registerSecurityCommand(program2, ctx) {
4149
4240
  throw new SecurityError(ERR_HOOK_AUTH_FAILED, sanitizeErrorMessage(err.message));
4150
4241
  }
4151
4242
  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
- }
4243
+ const id = bindingResult.bindingId;
4244
+ await savePendingOtpAndOutput(ctx.store, {
4245
+ id,
4246
+ account: account.address,
4247
+ chainId: account.chainId,
4248
+ action: "email_bind",
4249
+ bindingId: bindingResult.bindingId,
4250
+ maskedEmail: bindingResult.maskedEmail,
4251
+ otpExpiresAt: bindingResult.otpExpiresAt,
4252
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
4253
+ data: { email: emailAddr }
4254
+ });
4175
4255
  } catch (err) {
4176
4256
  handleSecurityError(err);
4177
4257
  }
@@ -4189,28 +4269,18 @@ function registerSecurityCommand(program2, ctx) {
4189
4269
  throw new SecurityError(ERR_HOOK_AUTH_FAILED, sanitizeErrorMessage(err.message));
4190
4270
  }
4191
4271
  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
- }
4272
+ const id = bindingResult.bindingId;
4273
+ await savePendingOtpAndOutput(ctx.store, {
4274
+ id,
4275
+ account: account.address,
4276
+ chainId: account.chainId,
4277
+ action: "email_change",
4278
+ bindingId: bindingResult.bindingId,
4279
+ maskedEmail: bindingResult.maskedEmail,
4280
+ otpExpiresAt: bindingResult.otpExpiresAt,
4281
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
4282
+ data: { email: emailAddr }
4283
+ });
4214
4284
  } catch (err) {
4215
4285
  handleSecurityError(err);
4216
4286
  }
@@ -4222,7 +4292,7 @@ function registerSecurityCommand(program2, ctx) {
4222
4292
  if (!amountStr) {
4223
4293
  await showSpendingLimit(hookService, account);
4224
4294
  } else {
4225
- await setSpendingLimit(hookService, account, amountStr);
4295
+ await setSpendingLimit(ctx.store, hookService, account, amountStr);
4226
4296
  }
4227
4297
  } catch (err) {
4228
4298
  handleSecurityError(err);
@@ -4330,7 +4400,7 @@ async function showSpendingLimit(hookService, account) {
4330
4400
  email: profile.maskedEmail ?? null
4331
4401
  });
4332
4402
  }
4333
- async function setSpendingLimit(hookService, account, amountStr) {
4403
+ async function setSpendingLimit(store, hookService, account, amountStr) {
4334
4404
  const amountUsd = parseFloat(amountStr);
4335
4405
  if (isNaN(amountUsd) || amountUsd < 0) {
4336
4406
  throw new SecurityError(ERR_INTERNAL3, "Invalid amount. Provide a positive number in USD.");
@@ -4349,24 +4419,240 @@ async function setSpendingLimit(hookService, account, amountStr) {
4349
4419
  throw new SecurityError(ERR_HOOK_AUTH_FAILED, sanitizeErrorMessage(msg));
4350
4420
  }
4351
4421
  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();
4422
+ const id = generateOtpId();
4423
+ await savePendingOtpAndOutput(store, {
4424
+ id,
4425
+ account: account.address,
4426
+ chainId: account.chainId,
4427
+ action: "spending_limit",
4428
+ maskedEmail: otpResult.maskedEmail,
4429
+ otpExpiresAt: otpResult.otpExpiresAt,
4430
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
4431
+ data: { dailyLimitUsdCents }
4432
+ });
4433
+ }
4434
+
4435
+ // src/commands/otp.ts
4436
+ import ora6 from "ora";
4437
+ var ERR_OTP_NOT_FOUND = -32013;
4438
+ var ERR_OTP_EXPIRED = -32014;
4439
+ var ERR_ACCOUNT_NOT_READY4 = -32002;
4440
+ function isOtpExpired(otpExpiresAt) {
4441
+ if (!otpExpiresAt) return false;
4442
+ return new Date(otpExpiresAt).getTime() < Date.now();
4443
+ }
4444
+ function registerOtpCommand(program2, ctx) {
4445
+ const otp = program2.command("otp").description("Complete or manage deferred OTP verification");
4446
+ 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) => {
4447
+ const pending = await getPendingOtp(ctx.store, id);
4448
+ if (!pending) {
4449
+ outputError(
4450
+ ERR_OTP_NOT_FOUND,
4451
+ "Unknown OTP id. It may have expired or been completed.",
4452
+ { id }
4453
+ );
4454
+ return;
4455
+ }
4456
+ if (isOtpExpired(pending.otpExpiresAt)) {
4457
+ await removePendingOtp(ctx.store, id);
4458
+ outputError(ERR_OTP_EXPIRED, "OTP has expired. Please re-run the original command.", {
4459
+ id,
4460
+ action: pending.action
4461
+ });
4462
+ return;
4463
+ }
4464
+ const current = ctx.account.currentAccount;
4465
+ if (!current || current.address.toLowerCase() !== pending.account.toLowerCase()) {
4466
+ outputError(ERR_ACCOUNT_NOT_READY4, `Switch to the account that initiated this OTP first: elytro account switch <alias>`, {
4467
+ pendingAccount: pending.account
4468
+ });
4469
+ return;
4470
+ }
4471
+ const accountInfo = ctx.account.resolveAccount(current.alias ?? current.address);
4472
+ if (!accountInfo) {
4473
+ outputError(ERR_ACCOUNT_NOT_READY4, "Account not found.");
4474
+ return;
4475
+ }
4476
+ const chainConfig = ctx.chain.chains.find((c) => c.id === pending.chainId);
4477
+ if (!chainConfig) {
4478
+ outputError(ERR_ACCOUNT_NOT_READY4, `Chain ${pending.chainId} not configured.`);
4479
+ return;
4480
+ }
4481
+ await ctx.sdk.initForChain(chainConfig);
4482
+ ctx.walletClient.initForChain(chainConfig);
4483
+ const hookService = new SecurityHookService({
4484
+ store: ctx.store,
4485
+ graphqlEndpoint: ctx.chain.graphqlEndpoint,
4486
+ signMessageForAuth: createSignMessageForAuth({
4487
+ signDigest: (digest) => ctx.keyring.signDigest(digest),
4488
+ packRawHash: (hash) => ctx.sdk.packRawHash(hash),
4489
+ packSignature: (rawSig, valData) => ctx.sdk.packUserOpSignature(rawSig, valData)
4490
+ }),
4491
+ readContract: async (params) => ctx.walletClient.readContract(params),
4492
+ getBlockTimestamp: async () => {
4493
+ const blockNum = await ctx.walletClient.raw.getBlockNumber();
4494
+ const block = await ctx.walletClient.raw.getBlock({ blockNumber: blockNum });
4495
+ return block.timestamp;
4496
+ }
4497
+ });
4498
+ const spinner = ora6("Completing OTP verification...").start();
4499
+ try {
4500
+ await completePendingOtp(ctx, hookService, accountInfo, pending, code.trim(), spinner);
4501
+ await removePendingOtp(ctx.store, id);
4502
+ } catch (err) {
4503
+ spinner.stop();
4504
+ outputError(-32e3, err.message);
4505
+ }
4506
+ });
4507
+ otp.command("cancel").description("Cancel pending OTP(s)").argument("[id]", "OTP id to cancel. Omit to cancel all for current account.").action(async (id) => {
4508
+ if (id) {
4509
+ const pending = await getPendingOtp(ctx.store, id);
4510
+ if (!pending) {
4511
+ outputError(ERR_OTP_NOT_FOUND, "Unknown OTP id.", { id });
4512
+ return;
4513
+ }
4514
+ await removePendingOtp(ctx.store, id);
4515
+ outputResult({ status: "cancelled", id });
4516
+ } else {
4517
+ const current = ctx.account.currentAccount;
4518
+ if (!current) {
4519
+ outputError(ERR_ACCOUNT_NOT_READY4, "No account selected.");
4520
+ return;
4521
+ }
4522
+ await clearPendingOtps(ctx.store, { account: current.address });
4523
+ outputResult({ status: "cancelled", scope: "account", account: current.address });
4524
+ }
4525
+ });
4526
+ otp.command("list").description("List pending OTPs for current account").action(async () => {
4527
+ const all = await loadPendingOtps(ctx.store);
4528
+ const current = ctx.account.currentAccount;
4529
+ const pendings = current ? Object.values(all).filter(
4530
+ (p) => p.account.toLowerCase() === current.address.toLowerCase()
4531
+ ) : Object.values(all);
4532
+ if (pendings.length === 0) {
4533
+ outputResult({ pendings: [], total: 0 });
4534
+ return;
4535
+ }
4358
4536
  outputResult({
4359
- status: "daily_limit_set",
4360
- dailyLimitUsd: amountUsd.toFixed(2)
4537
+ pendings: pendings.map((p) => ({
4538
+ id: p.id,
4539
+ action: p.action,
4540
+ maskedEmail: p.maskedEmail,
4541
+ otpExpiresAt: p.otpExpiresAt,
4542
+ submitCommand: `elytro otp submit ${p.id} <6-digit-code>`
4543
+ })),
4544
+ total: pendings.length
4361
4545
  });
4362
- } catch (err) {
4363
- setSpinner.stop();
4364
- throw new SecurityError(
4365
- ERR_OTP_VERIFY_FAILED,
4366
- `Failed to set limit: ${sanitizeErrorMessage(err.message)}`
4367
- );
4546
+ });
4547
+ }
4548
+ async function completePendingOtp(ctx, hookService, account, pending, code, spinner) {
4549
+ switch (pending.action) {
4550
+ case "email_bind":
4551
+ case "email_change": {
4552
+ spinner.stop();
4553
+ if (!pending.bindingId) throw new Error("Missing bindingId for email action.");
4554
+ const email = "email" in pending.data ? pending.data.email : "";
4555
+ const profile = await hookService.confirmEmailBinding(
4556
+ account.address,
4557
+ account.chainId,
4558
+ pending.bindingId,
4559
+ code
4560
+ );
4561
+ outputResult({
4562
+ status: pending.action === "email_bind" ? "email_bound" : "email_changed",
4563
+ email: profile.maskedEmail ?? profile.email ?? email,
4564
+ emailVerified: profile.emailVerified
4565
+ });
4566
+ break;
4567
+ }
4568
+ case "spending_limit": {
4569
+ spinner.stop();
4570
+ const dailyLimitUsdCents = pending.data.dailyLimitUsdCents;
4571
+ await hookService.setDailyLimit(
4572
+ account.address,
4573
+ account.chainId,
4574
+ dailyLimitUsdCents,
4575
+ code
4576
+ );
4577
+ outputResult({
4578
+ status: "daily_limit_set",
4579
+ dailyLimitUsd: (dailyLimitUsdCents / 100).toFixed(2)
4580
+ });
4581
+ break;
4582
+ }
4583
+ case "tx_send":
4584
+ case "2fa_uninstall": {
4585
+ if (!pending.challengeId) throw new Error("Missing challengeId for tx/2fa action.");
4586
+ spinner.text = "Verifying OTP...";
4587
+ const { userOp: userOpJson, entryPoint } = pending.data;
4588
+ const userOp = deserializeUserOp2(userOpJson);
4589
+ const authSessionId = pending.authSessionId;
4590
+ await hookService.verifySecurityOtp(
4591
+ account.address,
4592
+ account.chainId,
4593
+ pending.challengeId,
4594
+ code,
4595
+ authSessionId
4596
+ );
4597
+ spinner.text = "Retrying authorization...";
4598
+ const hookResult = await hookService.getHookSignature(
4599
+ account.address,
4600
+ account.chainId,
4601
+ entryPoint,
4602
+ userOp,
4603
+ authSessionId
4604
+ );
4605
+ if (hookResult.error) {
4606
+ throw new Error(`Authorization failed after OTP: ${hookResult.error.message}`);
4607
+ }
4608
+ const hookAddress = SECURITY_HOOK_ADDRESS_MAP[account.chainId];
4609
+ if (!hookAddress) throw new Error(`SecurityHook not deployed on chain ${account.chainId}.`);
4610
+ const { packedHash, validationData } = await ctx.sdk.getUserOpHash(userOp);
4611
+ const rawSignature = await ctx.keyring.signDigest(packedHash);
4612
+ userOp.signature = await ctx.sdk.packUserOpSignatureWithHook(
4613
+ rawSignature,
4614
+ validationData,
4615
+ hookAddress,
4616
+ hookResult.signature
4617
+ );
4618
+ spinner.text = "Sending UserOp...";
4619
+ const opHash = await ctx.sdk.sendUserOp(userOp);
4620
+ spinner.text = "Waiting for receipt...";
4621
+ const receipt = await ctx.sdk.waitForReceipt(opHash);
4622
+ if (!receipt.success) {
4623
+ throw new Error(`Transaction reverted: ${receipt.reason ?? "unknown"}`);
4624
+ }
4625
+ spinner.stop();
4626
+ outputResult({
4627
+ status: pending.action === "tx_send" ? "sent" : "uninstalled",
4628
+ userOpHash: opHash,
4629
+ transactionHash: receipt.transactionHash
4630
+ });
4631
+ break;
4632
+ }
4633
+ default:
4634
+ throw new Error(`Unknown action: ${pending.action}`);
4368
4635
  }
4369
4636
  }
4637
+ function deserializeUserOp2(raw) {
4638
+ return {
4639
+ sender: raw.sender,
4640
+ nonce: BigInt(raw.nonce ?? "0x0"),
4641
+ factory: raw.factory ?? null,
4642
+ factoryData: raw.factoryData ?? null,
4643
+ callData: raw.callData,
4644
+ callGasLimit: BigInt(raw.callGasLimit ?? "0x0"),
4645
+ verificationGasLimit: BigInt(raw.verificationGasLimit ?? "0x0"),
4646
+ preVerificationGas: BigInt(raw.preVerificationGas ?? "0x0"),
4647
+ maxFeePerGas: BigInt(raw.maxFeePerGas ?? "0x0"),
4648
+ maxPriorityFeePerGas: BigInt(raw.maxPriorityFeePerGas ?? "0x0"),
4649
+ paymaster: raw.paymaster ?? null,
4650
+ paymasterVerificationGasLimit: raw.paymasterVerificationGasLimit ? BigInt(raw.paymasterVerificationGasLimit) : null,
4651
+ paymasterPostOpGasLimit: raw.paymasterPostOpGasLimit ? BigInt(raw.paymasterPostOpGasLimit) : null,
4652
+ paymasterData: raw.paymasterData ?? null,
4653
+ signature: raw.signature ?? "0x"
4654
+ };
4655
+ }
4370
4656
 
4371
4657
  // src/commands/config.ts
4372
4658
  var KEY_MAP = {
@@ -4430,7 +4716,7 @@ import { execSync } from "child_process";
4430
4716
  import { createRequire } from "module";
4431
4717
  function resolveVersion() {
4432
4718
  if (true) {
4433
- return "0.5.3";
4719
+ return "0.6.0";
4434
4720
  }
4435
4721
  try {
4436
4722
  const require2 = createRequire(import.meta.url);
@@ -4443,7 +4729,7 @@ function resolveVersion() {
4443
4729
  var VERSION = resolveVersion();
4444
4730
 
4445
4731
  // src/commands/update.ts
4446
- import ora6 from "ora";
4732
+ import ora7 from "ora";
4447
4733
  import chalk2 from "chalk";
4448
4734
  import { realpathSync } from "fs";
4449
4735
  import { fileURLToPath } from "url";
@@ -4519,7 +4805,7 @@ function registerUpdateCommand(program2) {
4519
4805
  }
4520
4806
  });
4521
4807
  updateCmd.action(async () => {
4522
- const spinner = ora6("Checking for updates\u2026").start();
4808
+ const spinner = ora7("Checking for updates\u2026").start();
4523
4809
  try {
4524
4810
  const latest = await fetchLatestVersion();
4525
4811
  const cmp = compareSemver(VERSION, latest);
@@ -4550,6 +4836,41 @@ function registerUpdateCommand(program2) {
4550
4836
  });
4551
4837
  }
4552
4838
 
4839
+ // src/commands/prune.ts
4840
+ import { rm } from "fs/promises";
4841
+ import { join as join3 } from "path";
4842
+ import { homedir as homedir2 } from "os";
4843
+ var DATA_DIR = join3(homedir2(), ".elytro");
4844
+ async function runPrune() {
4845
+ try {
4846
+ const keyringProvider = new KeyringProvider();
4847
+ try {
4848
+ await keyringProvider.delete();
4849
+ } catch {
4850
+ }
4851
+ const fileProvider = new FileProvider(DATA_DIR);
4852
+ try {
4853
+ await fileProvider.delete();
4854
+ } catch {
4855
+ }
4856
+ try {
4857
+ await rm(DATA_DIR, { recursive: true, force: true });
4858
+ } catch (err) {
4859
+ const code = err.code;
4860
+ if (code !== "ENOENT") {
4861
+ throw err;
4862
+ }
4863
+ }
4864
+ outputResult({
4865
+ status: "pruned",
4866
+ dataDir: DATA_DIR,
4867
+ hint: "All local data cleared. Run `elytro init` to create a new wallet."
4868
+ });
4869
+ } catch (err) {
4870
+ outputError(-32e3, err.message);
4871
+ }
4872
+ }
4873
+
4553
4874
  // src/index.ts
4554
4875
  var program = new Command();
4555
4876
  program.name("elytro").description("Elytro \u2014 ERC-4337 Smart Account Wallet CLI").version(VERSION).addHelpText(
@@ -4557,6 +4878,10 @@ program.name("elytro").description("Elytro \u2014 ERC-4337 Smart Account Wallet
4557
4878
  "\nLearn how to use Elytro skills: https://github.com/Elytro-eth/skills\n"
4558
4879
  );
4559
4880
  async function main() {
4881
+ if (process.argv.includes("prune")) {
4882
+ await runPrune();
4883
+ return;
4884
+ }
4560
4885
  let ctx = null;
4561
4886
  try {
4562
4887
  ctx = await createAppContext();
@@ -4565,6 +4890,7 @@ async function main() {
4565
4890
  registerTxCommand(program, ctx);
4566
4891
  registerQueryCommand(program, ctx);
4567
4892
  registerSecurityCommand(program, ctx);
4893
+ registerOtpCommand(program, ctx);
4568
4894
  registerConfigCommand(program, ctx);
4569
4895
  registerUpdateCommand(program);
4570
4896
  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.0",
4
4
  "description": "Elytro ERC-4337 Smart Account Wallet CLI",
5
5
  "type": "module",
6
6
  "bin": {