@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.
- package/README.md +15 -0
- package/dist/index.js +469 -143
- 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
|
-
|
|
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
|
|
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 ?
|
|
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
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
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:
|
|
3593
|
+
nonce: toHex7(op.nonce),
|
|
3517
3594
|
factory: op.factory,
|
|
3518
3595
|
factoryData: op.factoryData,
|
|
3519
3596
|
callData: op.callData,
|
|
3520
|
-
callGasLimit:
|
|
3521
|
-
verificationGasLimit:
|
|
3522
|
-
preVerificationGas:
|
|
3523
|
-
maxFeePerGas:
|
|
3524
|
-
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 ?
|
|
3527
|
-
paymasterPostOpGasLimit: op.paymasterPostOpGasLimit ?
|
|
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
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
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
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
)
|
|
4162
|
-
|
|
4163
|
-
|
|
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
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
)
|
|
4202
|
-
|
|
4203
|
-
|
|
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
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
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
|
-
|
|
4360
|
-
|
|
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
|
-
}
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
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.
|
|
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
|
|
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 =
|
|
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);
|