@elytro/cli 0.5.1 → 0.5.3
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 +83 -37
- package/dist/index.js +273 -81
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,89 +1,135 @@
|
|
|
1
1
|
# Elytro CLI
|
|
2
2
|
|
|
3
|
-
A command-line interface for ERC-4337 smart account wallets. Built for power users and AI
|
|
3
|
+
A command-line interface for ERC-4337 smart account wallets. Built for power users and AI agents managing smart accounts across multiple chains.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @elytro/cli
|
|
9
|
+
# or
|
|
10
|
+
bun add -g @elytro/cli
|
|
11
|
+
# or
|
|
12
|
+
pnpm add -g @elytro/cli
|
|
13
|
+
```
|
|
4
14
|
|
|
5
15
|
## Quick Start
|
|
6
16
|
|
|
7
17
|
```bash
|
|
8
18
|
# Initialize wallet (creates vault + EOA)
|
|
9
|
-
|
|
19
|
+
elytro init
|
|
10
20
|
|
|
11
|
-
# Create a smart account on Sepolia
|
|
12
|
-
|
|
21
|
+
# Create a smart account on OP Sepolia
|
|
22
|
+
elytro account create --chain 11155420 --email user@example.com --daily-limit 100
|
|
13
23
|
|
|
14
24
|
# Send a transaction
|
|
15
|
-
|
|
25
|
+
elytro tx send --tx "to:0xRecipient,value:0.1"
|
|
16
26
|
|
|
17
27
|
# Check balance
|
|
18
|
-
|
|
28
|
+
elytro query balance
|
|
19
29
|
```
|
|
20
30
|
|
|
21
31
|
## Key Features
|
|
22
32
|
|
|
23
33
|
- **Multi-account management** — Create multiple smart accounts per chain with user-friendly aliases
|
|
24
|
-
- **Zero-interaction security** — macOS: vault key stored in
|
|
34
|
+
- **Zero-interaction security** — macOS/Linux/Windows: vault key stored in system keychain via `@napi-rs/keyring`; fallback: inject via `ELYTRO_VAULT_SECRET`
|
|
25
35
|
- **Flexible transaction building** — Single transfers, batch operations, contract calls via unified `--tx` syntax
|
|
26
36
|
- **Transaction simulation** — Preview gas, paymaster sponsorship, and balance impact before sending
|
|
27
|
-
- **Cross-chain support** — Manage accounts across
|
|
28
|
-
- **
|
|
37
|
+
- **Cross-chain support** — Manage accounts across Ethereum, Optimism, Arbitrum, Base, and testnets
|
|
38
|
+
- **SecurityHook (2FA)** — Install on-chain 2FA with email OTP and daily spending limits
|
|
39
|
+
- **Self-updating** — `elytro update` detects your package manager and upgrades in place
|
|
40
|
+
|
|
41
|
+
## Supported Chains
|
|
42
|
+
|
|
43
|
+
| Chain | Chain ID |
|
|
44
|
+
| ---------------- | --------- |
|
|
45
|
+
| Ethereum | 1 |
|
|
46
|
+
| Optimism | 10 |
|
|
47
|
+
| Arbitrum One | 42161 |
|
|
48
|
+
| Base | 8453 |
|
|
49
|
+
| Sepolia | 11155111 |
|
|
50
|
+
| Optimism Sepolia | 11155420 |
|
|
51
|
+
|
|
52
|
+
Public RPC and bundler endpoints are used by default. Provide your own Alchemy/Pimlico keys for higher rate limits.
|
|
29
53
|
|
|
30
54
|
## Architecture
|
|
31
55
|
|
|
32
56
|
| Component | Purpose |
|
|
33
57
|
| ------------------ | ------------------------------------------------ |
|
|
34
|
-
| **SecretProvider** | Vault key management (Keychain/env var)
|
|
58
|
+
| **SecretProvider** | Vault key management (Keychain / env var) |
|
|
35
59
|
| **KeyringService** | EOA encryption + decryption (AES-GCM) |
|
|
36
60
|
| **AccountService** | Smart account lifecycle (CREATE2, multi-account) |
|
|
37
|
-
| **SdkService** |
|
|
61
|
+
| **SdkService** | `@elytro/sdk` wrapper (UserOp building) |
|
|
38
62
|
| **FileStore** | Persistent state (`~/.elytro/`) |
|
|
39
63
|
|
|
40
|
-
See [docs/architecture.md](docs/architecture.md) for detailed data flow.
|
|
41
|
-
|
|
42
64
|
## Security Model
|
|
43
65
|
|
|
44
|
-
- **No plaintext keys on disk** — vault key stored in
|
|
66
|
+
- **No plaintext keys on disk** — vault key stored in system keychain or injected at runtime
|
|
45
67
|
- **AES-GCM encryption** — all private keys encrypted with vault key before storage
|
|
46
68
|
- **Consume-once env var** — `ELYTRO_VAULT_SECRET` deleted from process after load
|
|
47
69
|
- **Memory cleanup** — all key buffers zeroed after use
|
|
48
70
|
|
|
49
|
-
See [docs/security.md](docs/security.md) for threat model.
|
|
50
|
-
|
|
51
71
|
## Configuration
|
|
52
72
|
|
|
53
|
-
| Variable | Purpose
|
|
54
|
-
| --------------------- |
|
|
55
|
-
| `ELYTRO_VAULT_SECRET` | Base64 vault key (non-
|
|
56
|
-
| `ELYTRO_ALCHEMY_KEY` | Alchemy RPC endpoint
|
|
57
|
-
| `ELYTRO_PIMLICO_KEY` | Bundler + paymaster
|
|
58
|
-
| `ELYTRO_ENV` | `development` or `production`
|
|
73
|
+
| Variable | Purpose | Required |
|
|
74
|
+
| --------------------- | ----------------------------------------- | -------------------- |
|
|
75
|
+
| `ELYTRO_VAULT_SECRET` | Base64 vault key (non-keychain platforms) | Yes, if no keychain |
|
|
76
|
+
| `ELYTRO_ALCHEMY_KEY` | Alchemy RPC endpoint | Optional (rate limit)|
|
|
77
|
+
| `ELYTRO_PIMLICO_KEY` | Bundler + paymaster | Optional (rate limit)|
|
|
78
|
+
| `ELYTRO_ENV` | `development` or `production` | Optional |
|
|
59
79
|
|
|
60
|
-
Persist API keys:
|
|
80
|
+
Persist API keys:
|
|
81
|
+
```bash
|
|
82
|
+
elytro config set alchemy-key <key>
|
|
83
|
+
elytro config set pimlico-key <key>
|
|
84
|
+
```
|
|
61
85
|
|
|
62
86
|
## Commands
|
|
63
87
|
|
|
64
88
|
```bash
|
|
65
89
|
# Account Management
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
90
|
+
elytro account create --chain <chainId> [--alias name] [--email addr] [--daily-limit amount]
|
|
91
|
+
elytro account list [alias|address]
|
|
92
|
+
elytro account info [alias|address]
|
|
93
|
+
elytro account switch [alias|address]
|
|
94
|
+
elytro account activate [alias|address] # Deploy to chain
|
|
71
95
|
|
|
72
96
|
# Transactions
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
97
|
+
elytro tx send --tx "to:0xAddr,value:0.1" [--tx ...]
|
|
98
|
+
elytro tx build --tx "to:0xAddr,data:0xab..."
|
|
99
|
+
elytro tx simulate --tx "to:0xAddr,value:0.1"
|
|
76
100
|
|
|
77
101
|
# Queries
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
102
|
+
elytro query balance [account] [--token erc20Addr]
|
|
103
|
+
elytro query tokens [account]
|
|
104
|
+
elytro query tx <hash>
|
|
105
|
+
elytro query chain
|
|
106
|
+
elytro query address <address>
|
|
107
|
+
|
|
108
|
+
# Security (2FA + spending limits)
|
|
109
|
+
elytro security status
|
|
110
|
+
elytro security 2fa install [--capability 1|2|3]
|
|
111
|
+
elytro security 2fa uninstall
|
|
112
|
+
elytro security 2fa uninstall --force # Start safety-delay countdown
|
|
113
|
+
elytro security 2fa uninstall --force --execute # Execute after delay
|
|
114
|
+
elytro security email bind <email>
|
|
115
|
+
elytro security email change <email>
|
|
116
|
+
elytro security spending-limit [amount] # View or set daily USD limit
|
|
117
|
+
|
|
118
|
+
# Updates
|
|
119
|
+
elytro update # Check and upgrade to latest
|
|
120
|
+
elytro update check # Check without installing
|
|
121
|
+
|
|
122
|
+
# Config
|
|
123
|
+
elytro config set <key> <value>
|
|
124
|
+
elytro config get <key>
|
|
125
|
+
elytro config list
|
|
83
126
|
```
|
|
84
127
|
|
|
85
128
|
## Development
|
|
86
129
|
|
|
87
130
|
```bash
|
|
88
|
-
|
|
131
|
+
bun install
|
|
132
|
+
bun dev <command> # Run from source
|
|
133
|
+
bun run build # Build to dist/
|
|
134
|
+
bun run test # Smoke tests
|
|
89
135
|
```
|
package/dist/index.js
CHANGED
|
@@ -233,9 +233,9 @@ var FileStore = class {
|
|
|
233
233
|
return join(this.root, `${key}.json`);
|
|
234
234
|
}
|
|
235
235
|
async load(key) {
|
|
236
|
-
const
|
|
236
|
+
const path2 = this.filePath(key);
|
|
237
237
|
try {
|
|
238
|
-
const raw = await readFile(
|
|
238
|
+
const raw = await readFile(path2, "utf-8");
|
|
239
239
|
return JSON.parse(raw);
|
|
240
240
|
} catch (err) {
|
|
241
241
|
if (err.code === "ENOENT") {
|
|
@@ -245,15 +245,15 @@ var FileStore = class {
|
|
|
245
245
|
}
|
|
246
246
|
}
|
|
247
247
|
async save(key, data) {
|
|
248
|
-
const
|
|
249
|
-
await mkdir(dirname(
|
|
250
|
-
await writeFile(
|
|
248
|
+
const path2 = this.filePath(key);
|
|
249
|
+
await mkdir(dirname(path2), { recursive: true });
|
|
250
|
+
await writeFile(path2, JSON.stringify(data, null, 2), "utf-8");
|
|
251
251
|
}
|
|
252
252
|
async remove(key) {
|
|
253
|
-
const { unlink } = await import("fs/promises");
|
|
254
|
-
const
|
|
253
|
+
const { unlink: unlink2 } = await import("fs/promises");
|
|
254
|
+
const path2 = this.filePath(key);
|
|
255
255
|
try {
|
|
256
|
-
await
|
|
256
|
+
await unlink2(path2);
|
|
257
257
|
} catch (err) {
|
|
258
258
|
if (err.code !== "ENOENT") {
|
|
259
259
|
throw err;
|
|
@@ -261,9 +261,9 @@ var FileStore = class {
|
|
|
261
261
|
}
|
|
262
262
|
}
|
|
263
263
|
async exists(key) {
|
|
264
|
-
const
|
|
264
|
+
const path2 = this.filePath(key);
|
|
265
265
|
try {
|
|
266
|
-
await access(
|
|
266
|
+
await access(path2);
|
|
267
267
|
return true;
|
|
268
268
|
} catch {
|
|
269
269
|
return false;
|
|
@@ -1444,6 +1444,27 @@ var AccountService = class {
|
|
|
1444
1444
|
await this.persist();
|
|
1445
1445
|
return account;
|
|
1446
1446
|
}
|
|
1447
|
+
// ─── Rename ───────────────────────────────────────────────────────
|
|
1448
|
+
/**
|
|
1449
|
+
* Rename an account's alias.
|
|
1450
|
+
* @param aliasOrAddress - Current alias or address to identify the account.
|
|
1451
|
+
* @param newAlias - The new alias. Must be unique.
|
|
1452
|
+
*/
|
|
1453
|
+
async renameAccount(aliasOrAddress, newAlias) {
|
|
1454
|
+
const account = this.resolveAccount(aliasOrAddress);
|
|
1455
|
+
if (!account) {
|
|
1456
|
+
throw new Error(`Account "${aliasOrAddress}" not found.`);
|
|
1457
|
+
}
|
|
1458
|
+
const conflict = this.state.accounts.find(
|
|
1459
|
+
(a) => a.alias.toLowerCase() === newAlias.toLowerCase() && a.address !== account.address
|
|
1460
|
+
);
|
|
1461
|
+
if (conflict) {
|
|
1462
|
+
throw new Error(`Alias "${newAlias}" is already taken by ${conflict.address}.`);
|
|
1463
|
+
}
|
|
1464
|
+
account.alias = newAlias;
|
|
1465
|
+
await this.persist();
|
|
1466
|
+
return account;
|
|
1467
|
+
}
|
|
1447
1468
|
// ─── Activation ───────────────────────────────────────────────────
|
|
1448
1469
|
/**
|
|
1449
1470
|
* Mark an account as deployed on-chain.
|
|
@@ -2037,15 +2058,17 @@ var SecurityHookService = class {
|
|
|
2037
2058
|
if (result.errors && result.errors.length > 0) {
|
|
2038
2059
|
const ext = result.errors[0].extensions;
|
|
2039
2060
|
if (ext) {
|
|
2061
|
+
const challengeDetails = typeof ext.challenge === "object" && ext.challenge !== null ? ext.challenge : void 0;
|
|
2062
|
+
const getChallengeValue = (key) => ext[key] ?? (challengeDetails ? challengeDetails[key] : void 0);
|
|
2040
2063
|
return {
|
|
2041
2064
|
error: {
|
|
2042
2065
|
code: ext.code,
|
|
2043
|
-
challengeId:
|
|
2044
|
-
currentSpendUsdCents: ext.currentSpendUsdCents,
|
|
2045
|
-
dailyLimitUsdCents: ext.dailyLimitUsdCents,
|
|
2046
|
-
maskedEmail: ext.maskedEmail,
|
|
2047
|
-
otpExpiresAt: ext.otpExpiresAt,
|
|
2048
|
-
projectedSpendUsdCents: ext.projectedSpendUsdCents,
|
|
2066
|
+
challengeId: getChallengeValue("challengeId"),
|
|
2067
|
+
currentSpendUsdCents: ext.currentSpendUsdCents ?? getChallengeValue("currentSpendUsdCents"),
|
|
2068
|
+
dailyLimitUsdCents: ext.dailyLimitUsdCents ?? getChallengeValue("dailyLimitUsdCents"),
|
|
2069
|
+
maskedEmail: ext.maskedEmail ?? getChallengeValue("maskedEmail"),
|
|
2070
|
+
otpExpiresAt: ext.otpExpiresAt ?? getChallengeValue("otpExpiresAt"),
|
|
2071
|
+
projectedSpendUsdCents: ext.projectedSpendUsdCents ?? getChallengeValue("projectedSpendUsdCents"),
|
|
2049
2072
|
message: result.errors[0].message
|
|
2050
2073
|
}
|
|
2051
2074
|
};
|
|
@@ -2057,15 +2080,17 @@ var SecurityHookService = class {
|
|
|
2057
2080
|
if (err instanceof GraphQLClientError && err.errors?.length) {
|
|
2058
2081
|
const ext = err.errors[0].extensions;
|
|
2059
2082
|
if (ext?.code) {
|
|
2083
|
+
const challengeDetails = typeof ext.challenge === "object" && ext.challenge !== null ? ext.challenge : void 0;
|
|
2084
|
+
const getChallengeValue = (key) => ext[key] ?? (challengeDetails ? challengeDetails[key] : void 0);
|
|
2060
2085
|
return {
|
|
2061
2086
|
error: {
|
|
2062
2087
|
code: ext.code,
|
|
2063
|
-
challengeId:
|
|
2064
|
-
currentSpendUsdCents: ext.currentSpendUsdCents,
|
|
2065
|
-
dailyLimitUsdCents: ext.dailyLimitUsdCents,
|
|
2066
|
-
maskedEmail: ext.maskedEmail,
|
|
2067
|
-
otpExpiresAt: ext.otpExpiresAt,
|
|
2068
|
-
projectedSpendUsdCents: ext.projectedSpendUsdCents,
|
|
2088
|
+
challengeId: getChallengeValue("challengeId"),
|
|
2089
|
+
currentSpendUsdCents: ext.currentSpendUsdCents ?? getChallengeValue("currentSpendUsdCents"),
|
|
2090
|
+
dailyLimitUsdCents: ext.dailyLimitUsdCents ?? getChallengeValue("dailyLimitUsdCents"),
|
|
2091
|
+
maskedEmail: ext.maskedEmail ?? getChallengeValue("maskedEmail"),
|
|
2092
|
+
otpExpiresAt: ext.otpExpiresAt ?? getChallengeValue("otpExpiresAt"),
|
|
2093
|
+
projectedSpendUsdCents: ext.projectedSpendUsdCents ?? getChallengeValue("projectedSpendUsdCents"),
|
|
2069
2094
|
message: err.errors[0].message
|
|
2070
2095
|
}
|
|
2071
2096
|
};
|
|
@@ -2151,120 +2176,227 @@ var SecurityHookService = class {
|
|
|
2151
2176
|
}
|
|
2152
2177
|
};
|
|
2153
2178
|
|
|
2154
|
-
// src/providers/
|
|
2155
|
-
import {
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
var KeychainProvider = class {
|
|
2159
|
-
name = "macos-keychain";
|
|
2179
|
+
// src/providers/keyringProvider.ts
|
|
2180
|
+
import { Entry } from "@napi-rs/keyring";
|
|
2181
|
+
var KeyringProvider = class {
|
|
2182
|
+
name;
|
|
2160
2183
|
service = "elytro-wallet";
|
|
2161
2184
|
account = "vault-key";
|
|
2185
|
+
constructor() {
|
|
2186
|
+
const platform = process.platform;
|
|
2187
|
+
if (platform === "darwin") this.name = "macos-keychain";
|
|
2188
|
+
else if (platform === "win32") this.name = "windows-credential-manager";
|
|
2189
|
+
else this.name = "linux-secret-service";
|
|
2190
|
+
}
|
|
2162
2191
|
async available() {
|
|
2163
|
-
if (process.platform !== "darwin") return false;
|
|
2164
2192
|
try {
|
|
2165
|
-
|
|
2193
|
+
const entry = new Entry(this.service, this.account);
|
|
2194
|
+
entry.getSecret();
|
|
2166
2195
|
return true;
|
|
2167
|
-
} catch {
|
|
2168
|
-
|
|
2196
|
+
} catch (err) {
|
|
2197
|
+
const msg = err.message || "";
|
|
2198
|
+
if (isNotFoundError(msg)) return true;
|
|
2199
|
+
return false;
|
|
2169
2200
|
}
|
|
2170
2201
|
}
|
|
2171
2202
|
async store(secret) {
|
|
2172
2203
|
validateKeyLength(secret);
|
|
2204
|
+
try {
|
|
2205
|
+
const entry = new Entry(this.service, this.account);
|
|
2206
|
+
entry.setSecret(Buffer.from(secret));
|
|
2207
|
+
} catch (err) {
|
|
2208
|
+
throw new Error(`Failed to store vault key in OS credential store: ${err.message}`);
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
async load() {
|
|
2212
|
+
try {
|
|
2213
|
+
const entry = new Entry(this.service, this.account);
|
|
2214
|
+
const raw = entry.getSecret();
|
|
2215
|
+
if (!raw || raw.length === 0) return null;
|
|
2216
|
+
const key = new Uint8Array(raw);
|
|
2217
|
+
if (key.length !== 32) {
|
|
2218
|
+
throw new Error(
|
|
2219
|
+
`OS credential store returned vault key with invalid length: expected 32 bytes, got ${key.length}.`
|
|
2220
|
+
);
|
|
2221
|
+
}
|
|
2222
|
+
return key;
|
|
2223
|
+
} catch (err) {
|
|
2224
|
+
const msg = err.message || "";
|
|
2225
|
+
if (isNotFoundError(msg)) return null;
|
|
2226
|
+
throw new Error(`Failed to load vault key from OS credential store: ${msg}`);
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
async delete() {
|
|
2230
|
+
try {
|
|
2231
|
+
const entry = new Entry(this.service, this.account);
|
|
2232
|
+
entry.deleteCredential();
|
|
2233
|
+
} catch {
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
};
|
|
2237
|
+
function isNotFoundError(msg) {
|
|
2238
|
+
const lower = msg.toLowerCase();
|
|
2239
|
+
return lower.includes("not found") || lower.includes("no matching") || lower.includes("no such") || lower.includes("itemnotfound") || lower.includes("element not found") || // Windows
|
|
2240
|
+
lower.includes("no result") || lower.includes("no password");
|
|
2241
|
+
}
|
|
2242
|
+
function validateKeyLength(key) {
|
|
2243
|
+
if (key.length !== 32) {
|
|
2244
|
+
throw new Error(`Invalid vault key: expected 32 bytes, got ${key.length}.`);
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
// src/providers/fileProvider.ts
|
|
2249
|
+
import * as fs from "fs/promises";
|
|
2250
|
+
import * as path from "path";
|
|
2251
|
+
import { constants } from "fs";
|
|
2252
|
+
var FileProvider = class {
|
|
2253
|
+
name = "file-protected";
|
|
2254
|
+
keyPath;
|
|
2255
|
+
/**
|
|
2256
|
+
* @param dataDir — the ~/.elytro/ directory path (from FileStore.dataDir).
|
|
2257
|
+
* Defaults to ~/.elytro if not provided.
|
|
2258
|
+
*/
|
|
2259
|
+
constructor(dataDir) {
|
|
2260
|
+
const base = dataDir ?? path.join(process.env.HOME || "~", ".elytro");
|
|
2261
|
+
this.keyPath = path.join(base, ".vault-key");
|
|
2262
|
+
}
|
|
2263
|
+
/**
|
|
2264
|
+
* FileProvider is always technically available on any OS (filesystem always exists).
|
|
2265
|
+
* But it should only be used on Linux when the KeyringProvider is not available.
|
|
2266
|
+
* The resolution logic in resolveProvider handles this gating — FileProvider
|
|
2267
|
+
* itself does not restrict by platform.
|
|
2268
|
+
*/
|
|
2269
|
+
async available() {
|
|
2270
|
+
try {
|
|
2271
|
+
await fs.access(this.keyPath, constants.R_OK);
|
|
2272
|
+
return true;
|
|
2273
|
+
} catch {
|
|
2274
|
+
try {
|
|
2275
|
+
await fs.access(path.dirname(this.keyPath), constants.W_OK);
|
|
2276
|
+
return true;
|
|
2277
|
+
} catch {
|
|
2278
|
+
return false;
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
async store(secret) {
|
|
2283
|
+
validateKeyLength2(secret);
|
|
2173
2284
|
const b64 = Buffer.from(secret).toString("base64");
|
|
2285
|
+
const tmpPath = this.keyPath + ".tmp";
|
|
2174
2286
|
try {
|
|
2175
|
-
await
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
"-s",
|
|
2179
|
-
this.service,
|
|
2180
|
-
"-a",
|
|
2181
|
-
this.account,
|
|
2182
|
-
"-w",
|
|
2183
|
-
b64
|
|
2184
|
-
]);
|
|
2287
|
+
await fs.writeFile(tmpPath, b64, { encoding: "utf-8", mode: 384 });
|
|
2288
|
+
await fs.rename(tmpPath, this.keyPath);
|
|
2289
|
+
await fs.chmod(this.keyPath, 384);
|
|
2185
2290
|
} catch (err) {
|
|
2186
|
-
|
|
2291
|
+
try {
|
|
2292
|
+
await fs.unlink(tmpPath);
|
|
2293
|
+
} catch {
|
|
2294
|
+
}
|
|
2295
|
+
throw new Error(`Failed to store vault key to file: ${err.message}`);
|
|
2187
2296
|
}
|
|
2188
2297
|
}
|
|
2189
2298
|
async load() {
|
|
2190
2299
|
try {
|
|
2191
|
-
const
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2300
|
+
const stat2 = await fs.stat(this.keyPath);
|
|
2301
|
+
const mode = stat2.mode & 511;
|
|
2302
|
+
if (mode !== 384) {
|
|
2303
|
+
throw new Error(
|
|
2304
|
+
`Vault key file has insecure permissions: ${modeToOctal(mode)} (expected 0600).
|
|
2305
|
+
Fix with: chmod 600 ${this.keyPath}
|
|
2306
|
+
Refusing to load until permissions are corrected.`
|
|
2307
|
+
);
|
|
2308
|
+
}
|
|
2309
|
+
const raw = await fs.readFile(this.keyPath, "utf-8");
|
|
2310
|
+
const trimmed = raw.trim();
|
|
2200
2311
|
if (!trimmed) return null;
|
|
2201
2312
|
const key = Buffer.from(trimmed, "base64");
|
|
2202
2313
|
if (key.length !== 32) {
|
|
2203
|
-
throw new Error(
|
|
2314
|
+
throw new Error(
|
|
2315
|
+
`Vault key file has invalid content: expected 32 bytes (base64), got ${key.length}.`
|
|
2316
|
+
);
|
|
2204
2317
|
}
|
|
2205
2318
|
return new Uint8Array(key);
|
|
2206
2319
|
} catch (err) {
|
|
2207
2320
|
const msg = err.message || "";
|
|
2208
|
-
if (msg.includes("
|
|
2209
|
-
|
|
2210
|
-
}
|
|
2211
|
-
throw new Error(`Failed to load vault key from Keychain: ${msg}`);
|
|
2321
|
+
if (msg.includes("ENOENT")) return null;
|
|
2322
|
+
throw err;
|
|
2212
2323
|
}
|
|
2213
2324
|
}
|
|
2214
2325
|
async delete() {
|
|
2215
2326
|
try {
|
|
2216
|
-
await
|
|
2327
|
+
await fs.unlink(this.keyPath);
|
|
2217
2328
|
} catch {
|
|
2218
2329
|
}
|
|
2219
2330
|
}
|
|
2220
2331
|
};
|
|
2221
|
-
function
|
|
2332
|
+
function validateKeyLength2(key) {
|
|
2222
2333
|
if (key.length !== 32) {
|
|
2223
2334
|
throw new Error(`Invalid vault key: expected 32 bytes, got ${key.length}.`);
|
|
2224
2335
|
}
|
|
2225
2336
|
}
|
|
2337
|
+
function modeToOctal(mode) {
|
|
2338
|
+
return "0" + mode.toString(8);
|
|
2339
|
+
}
|
|
2226
2340
|
|
|
2227
2341
|
// src/providers/envVarProvider.ts
|
|
2228
2342
|
var ENV_KEY = "ELYTRO_VAULT_SECRET";
|
|
2343
|
+
var ENV_ALLOW = "ELYTRO_ALLOW_ENV";
|
|
2229
2344
|
var EnvVarProvider = class {
|
|
2230
2345
|
name = "env-var";
|
|
2231
2346
|
async available() {
|
|
2232
|
-
return !!process.env[ENV_KEY];
|
|
2347
|
+
return !!process.env[ENV_KEY] && process.env[ENV_ALLOW] === "1";
|
|
2233
2348
|
}
|
|
2234
2349
|
async store(_secret) {
|
|
2235
2350
|
throw new Error(
|
|
2236
|
-
"EnvVarProvider is read-only. Cannot store vault key in an environment variable
|
|
2351
|
+
"EnvVarProvider is read-only. Cannot store vault key in an environment variable.\nUse a persistent provider (OS keychain or file-protected) or store the secret manually."
|
|
2237
2352
|
);
|
|
2238
2353
|
}
|
|
2239
2354
|
async load() {
|
|
2355
|
+
if (process.env[ENV_ALLOW] !== "1") return null;
|
|
2240
2356
|
const raw = process.env[ENV_KEY];
|
|
2241
2357
|
if (!raw) return null;
|
|
2242
2358
|
delete process.env[ENV_KEY];
|
|
2359
|
+
delete process.env[ENV_ALLOW];
|
|
2243
2360
|
const key = Buffer.from(raw, "base64");
|
|
2244
2361
|
if (key.length !== 32) {
|
|
2245
2362
|
throw new Error(
|
|
2246
|
-
`${ENV_KEY} has invalid length: expected 32 bytes (base64), got ${key.length}.
|
|
2363
|
+
`${ENV_KEY} has invalid length: expected 32 bytes (base64), got ${key.length}.
|
|
2364
|
+
The value must be a base64-encoded 256-bit key.`
|
|
2247
2365
|
);
|
|
2248
2366
|
}
|
|
2249
2367
|
return new Uint8Array(key);
|
|
2250
2368
|
}
|
|
2251
2369
|
async delete() {
|
|
2252
2370
|
delete process.env[ENV_KEY];
|
|
2371
|
+
delete process.env[ENV_ALLOW];
|
|
2253
2372
|
}
|
|
2254
2373
|
};
|
|
2255
2374
|
|
|
2256
2375
|
// src/providers/resolveProvider.ts
|
|
2257
2376
|
async function resolveProvider() {
|
|
2258
|
-
const
|
|
2377
|
+
const keyringProvider = new KeyringProvider();
|
|
2378
|
+
const fileProvider = new FileProvider();
|
|
2259
2379
|
const envProvider = new EnvVarProvider();
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2380
|
+
if (await keyringProvider.available()) {
|
|
2381
|
+
return {
|
|
2382
|
+
initProvider: keyringProvider,
|
|
2383
|
+
loadProvider: keyringProvider
|
|
2384
|
+
};
|
|
2385
|
+
}
|
|
2386
|
+
if (process.platform === "linux" && await fileProvider.available()) {
|
|
2387
|
+
return {
|
|
2388
|
+
initProvider: fileProvider,
|
|
2389
|
+
loadProvider: fileProvider
|
|
2390
|
+
};
|
|
2391
|
+
}
|
|
2392
|
+
if (await envProvider.available()) {
|
|
2393
|
+
return {
|
|
2394
|
+
initProvider: null,
|
|
2395
|
+
// Cannot store via env var
|
|
2396
|
+
loadProvider: envProvider
|
|
2397
|
+
};
|
|
2398
|
+
}
|
|
2399
|
+
return { initProvider: null, loadProvider: null };
|
|
2268
2400
|
}
|
|
2269
2401
|
|
|
2270
2402
|
// src/context.ts
|
|
@@ -2284,14 +2416,15 @@ async function createAppContext() {
|
|
|
2284
2416
|
if (isInitialized) {
|
|
2285
2417
|
if (!loadProvider) {
|
|
2286
2418
|
throw new Error(
|
|
2287
|
-
"Wallet is initialized but no secret provider is available.\n" + (
|
|
2419
|
+
"Wallet is initialized but no secret provider is available.\n" + noProviderHint()
|
|
2288
2420
|
);
|
|
2289
2421
|
}
|
|
2290
2422
|
const vaultKey = await loadProvider.load();
|
|
2291
2423
|
if (!vaultKey) {
|
|
2292
2424
|
throw new Error(
|
|
2293
2425
|
`Wallet is initialized but vault key not found in ${loadProvider.name}.
|
|
2294
|
-
|
|
2426
|
+
The credential may have been deleted. Re-run \`elytro init\` to create a new wallet,
|
|
2427
|
+
or import a backup with \`elytro import\`.`
|
|
2295
2428
|
);
|
|
2296
2429
|
}
|
|
2297
2430
|
try {
|
|
@@ -2326,6 +2459,16 @@ The vault key may not match the encrypted keyring. Re-run \`elytro init\` or imp
|
|
|
2326
2459
|
}
|
|
2327
2460
|
return { store, keyring, chain, sdk, walletClient, account, secretProvider: loadProvider };
|
|
2328
2461
|
}
|
|
2462
|
+
function noProviderHint() {
|
|
2463
|
+
switch (process.platform) {
|
|
2464
|
+
case "darwin":
|
|
2465
|
+
return "macOS Keychain access failed. Check Keychain permissions or security settings.";
|
|
2466
|
+
case "win32":
|
|
2467
|
+
return "Windows Credential Manager access failed. Run as the same user who initialized the wallet.";
|
|
2468
|
+
default:
|
|
2469
|
+
return "No secret provider available. Options:\n 1. Install and start a Secret Service provider (GNOME Keyring or KWallet)\n 2. The vault key file (~/.elytro/.vault-key) may have been deleted\n 3. For CI: set ELYTRO_VAULT_SECRET and ELYTRO_ALLOW_ENV=1";
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2329
2472
|
|
|
2330
2473
|
// src/commands/init.ts
|
|
2331
2474
|
import { webcrypto as webcrypto2 } from "crypto";
|
|
@@ -2396,7 +2539,9 @@ function registerInitCommand(program2, ctx) {
|
|
|
2396
2539
|
dataDir: ctx.store.dataDir,
|
|
2397
2540
|
secretProvider: providerName,
|
|
2398
2541
|
...vaultSecretB64 ? { vaultSecret: vaultSecretB64 } : {},
|
|
2399
|
-
...vaultSecretB64 ? {
|
|
2542
|
+
...vaultSecretB64 ? {
|
|
2543
|
+
hint: "No persistent secret provider available. Save this vault key securely \u2014 it will NOT be shown again.\nFor CI: set ELYTRO_VAULT_SECRET=<key> and ELYTRO_ALLOW_ENV=1."
|
|
2544
|
+
} : {},
|
|
2400
2545
|
nextStep: "Run `elytro account create --chain <chainId>` to create your first smart account."
|
|
2401
2546
|
});
|
|
2402
2547
|
} catch (err) {
|
|
@@ -2473,6 +2618,14 @@ function registerAccountCommand(program2, ctx) {
|
|
|
2473
2618
|
outputError(ERR_INVALID_PARAMS, "Invalid chain ID.", { chain: opts.chain });
|
|
2474
2619
|
return;
|
|
2475
2620
|
}
|
|
2621
|
+
const chainConfig = ctx.chain.chains.find((c) => c.id === chainId);
|
|
2622
|
+
if (!chainConfig) {
|
|
2623
|
+
const supported = ctx.chain.chains.map((c) => `${c.id} (${c.name})`);
|
|
2624
|
+
outputError(ERR_INVALID_PARAMS, `Chain ${chainId} is not supported.`, {
|
|
2625
|
+
supportedChains: supported
|
|
2626
|
+
});
|
|
2627
|
+
return;
|
|
2628
|
+
}
|
|
2476
2629
|
let dailyLimitUsd;
|
|
2477
2630
|
if (opts.dailyLimit !== void 0) {
|
|
2478
2631
|
dailyLimitUsd = parseFloat(opts.dailyLimit);
|
|
@@ -2487,12 +2640,9 @@ function registerAccountCommand(program2, ctx) {
|
|
|
2487
2640
|
} : void 0;
|
|
2488
2641
|
const spinner = ora2("Creating smart account...").start();
|
|
2489
2642
|
try {
|
|
2490
|
-
const
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
await ctx.sdk.initForChain(chainConfig);
|
|
2494
|
-
ctx.walletClient.initForChain(chainConfig);
|
|
2495
|
-
}
|
|
2643
|
+
const chainName = chainConfig.name;
|
|
2644
|
+
await ctx.sdk.initForChain(chainConfig);
|
|
2645
|
+
ctx.walletClient.initForChain(chainConfig);
|
|
2496
2646
|
const accountInfo = await ctx.account.createAccount(chainId, opts.alias, securityIntent);
|
|
2497
2647
|
spinner.text = "Registering with backend...";
|
|
2498
2648
|
const { guardianHash, guardianSafePeriod } = ctx.sdk.initDefaults;
|
|
@@ -2748,6 +2898,20 @@ function registerAccountCommand(program2, ctx) {
|
|
|
2748
2898
|
outputError(ERR_INTERNAL, err.message);
|
|
2749
2899
|
}
|
|
2750
2900
|
});
|
|
2901
|
+
account.command("rename").description("Rename an account alias").argument("<account>", "Current alias or address").argument("<newAlias>", "New alias").action(async (target, newAlias) => {
|
|
2902
|
+
try {
|
|
2903
|
+
const renamed = await ctx.account.renameAccount(target, newAlias);
|
|
2904
|
+
const chainConfig = ctx.chain.chains.find((c) => c.id === renamed.chainId);
|
|
2905
|
+
outputResult({
|
|
2906
|
+
alias: renamed.alias,
|
|
2907
|
+
address: renamed.address,
|
|
2908
|
+
chain: chainConfig?.name ?? String(renamed.chainId),
|
|
2909
|
+
chainId: renamed.chainId
|
|
2910
|
+
});
|
|
2911
|
+
} catch (err) {
|
|
2912
|
+
outputError(ERR_INTERNAL, err.message);
|
|
2913
|
+
}
|
|
2914
|
+
});
|
|
2751
2915
|
account.command("switch").description("Switch the active account").argument("[account]", "Alias or address").action(async (target) => {
|
|
2752
2916
|
const accounts = ctx.account.allAccounts;
|
|
2753
2917
|
if (accounts.length === 0) {
|
|
@@ -3067,10 +3231,38 @@ function registerTxCommand(program2, ctx) {
|
|
|
3067
3231
|
spinner.stop();
|
|
3068
3232
|
const errCode = hookResult.error.code;
|
|
3069
3233
|
if (errCode === "OTP_REQUIRED" || errCode === "SPENDING_LIMIT_EXCEEDED") {
|
|
3234
|
+
if (!hookResult.error.challengeId) {
|
|
3235
|
+
const otpRequestSpinner = ora3("Requesting OTP challenge...").start();
|
|
3236
|
+
try {
|
|
3237
|
+
const otpChallenge = await hookService.requestSecurityOtp(
|
|
3238
|
+
accountInfo.address,
|
|
3239
|
+
accountInfo.chainId,
|
|
3240
|
+
ctx.sdk.entryPoint,
|
|
3241
|
+
userOp
|
|
3242
|
+
);
|
|
3243
|
+
hookResult.error.challengeId = otpChallenge.challengeId;
|
|
3244
|
+
hookResult.error.maskedEmail ??= otpChallenge.maskedEmail;
|
|
3245
|
+
hookResult.error.otpExpiresAt ??= otpChallenge.otpExpiresAt;
|
|
3246
|
+
otpRequestSpinner.stop();
|
|
3247
|
+
} catch (otpErr) {
|
|
3248
|
+
otpRequestSpinner.fail("Failed to request OTP challenge.");
|
|
3249
|
+
throw new TxError(
|
|
3250
|
+
ERR_SEND_FAILED2,
|
|
3251
|
+
`Unable to request OTP challenge: ${otpErr.message}`
|
|
3252
|
+
);
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
if (!hookResult.error.challengeId) {
|
|
3256
|
+
throw new TxError(
|
|
3257
|
+
ERR_SEND_FAILED2,
|
|
3258
|
+
"OTP challenge ID was not provided by Elytro API. Please try again."
|
|
3259
|
+
);
|
|
3260
|
+
}
|
|
3070
3261
|
console.error(JSON.stringify({
|
|
3071
3262
|
challenge: errCode,
|
|
3072
3263
|
message: hookResult.error.message ?? `Verification required (${errCode}).`,
|
|
3073
3264
|
...hookResult.error.maskedEmail ? { maskedEmail: hookResult.error.maskedEmail } : {},
|
|
3265
|
+
...hookResult.error.otpExpiresAt ? { otpExpiresAt: hookResult.error.otpExpiresAt } : {},
|
|
3074
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) } : {}
|
|
3075
3267
|
}, null, 2));
|
|
3076
3268
|
const otpCode = await askInput("Enter the 6-digit OTP code:");
|
|
@@ -4238,7 +4430,7 @@ import { execSync } from "child_process";
|
|
|
4238
4430
|
import { createRequire } from "module";
|
|
4239
4431
|
function resolveVersion() {
|
|
4240
4432
|
if (true) {
|
|
4241
|
-
return "0.5.
|
|
4433
|
+
return "0.5.3";
|
|
4242
4434
|
}
|
|
4243
4435
|
try {
|
|
4244
4436
|
const require2 = createRequire(import.meta.url);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elytro/cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"description": "Elytro ERC-4337 Smart Account Wallet CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"@elytro/abi": "latest",
|
|
42
42
|
"@elytro/sdk": "latest",
|
|
43
43
|
"@inquirer/prompts": "^7.0.0",
|
|
44
|
+
"@napi-rs/keyring": "^1.2.0",
|
|
44
45
|
"chalk": "^5.3.0",
|
|
45
46
|
"commander": "^13.0.0",
|
|
46
47
|
"graphql": "^16.9.0",
|