@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.
Files changed (3) hide show
  1. package/README.md +83 -37
  2. package/dist/index.js +273 -81
  3. 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 Agents managing smart accounts across multiple chains.
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
- bun dev init
19
+ elytro init
10
20
 
11
- # Create a smart account on Sepolia
12
- bun dev account create --chain 11155420 --email user@example.com --daily-limit 100
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
- bun dev tx send --tx "to:0xRecipient,value:0.1"
25
+ elytro tx send --tx "to:0xRecipient,value:0.1"
16
26
 
17
27
  # Check balance
18
- bun dev query balance
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 Keychain; non-macOS: injected via `ELYTRO_VAULT_SECRET`
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 Sepolia, OP Sepolia, Arbitrum, and custom networks
28
- - **Security intents** — Declare email/spending limits at account creation; deployed atomically on activation
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** | @elytro/sdk wrapper (UserOp building) |
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 macOS Keychain or injected at runtime
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 | Required |
54
- | --------------------- | ----------------------------- | -------------- |
55
- | `ELYTRO_VAULT_SECRET` | Base64 vault key (non-macOS) | Yes, non-macOS |
56
- | `ELYTRO_ALCHEMY_KEY` | Alchemy RPC endpoint | For queries |
57
- | `ELYTRO_PIMLICO_KEY` | Bundler + paymaster | For tx send |
58
- | `ELYTRO_ENV` | `development` or `production` | Optional |
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: `bun dev config set alchemy-key <key>`
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
- bun dev account create --chain 11155420 [--alias name] [--email addr] [--daily-limit amount]
67
- bun dev account list [alias|address]
68
- bun dev account info [alias|address]
69
- bun dev account switch [alias|address]
70
- bun dev account activate [alias|address] # Deploy to chain
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
- bun dev tx send --tx "to:0xAddr,value:0.1" [--tx ...]
74
- bun dev tx build --tx "to:0xAddr,data:0xab..."
75
- bun dev tx simulate --tx "to:0xAddr,value:0.1"
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
- bun dev query balance [account] [--token erc20Addr]
79
- bun dev query tokens [account]
80
- bun dev query tx <hash>
81
- bun dev query chain
82
- bun dev query address <address>
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 path = this.filePath(key);
236
+ const path2 = this.filePath(key);
237
237
  try {
238
- const raw = await readFile(path, "utf-8");
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 path = this.filePath(key);
249
- await mkdir(dirname(path), { recursive: true });
250
- await writeFile(path, JSON.stringify(data, null, 2), "utf-8");
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 path = this.filePath(key);
253
+ const { unlink: unlink2 } = await import("fs/promises");
254
+ const path2 = this.filePath(key);
255
255
  try {
256
- await unlink(path);
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 path = this.filePath(key);
264
+ const path2 = this.filePath(key);
265
265
  try {
266
- await access(path);
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: ext.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: ext.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/keychainProvider.ts
2155
- import { execFile } from "child_process";
2156
- import { promisify } from "util";
2157
- var execFileAsync = promisify(execFile);
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
- await execFileAsync("security", ["help"], { timeout: 5e3 });
2193
+ const entry = new Entry(this.service, this.account);
2194
+ entry.getSecret();
2166
2195
  return true;
2167
- } catch {
2168
- return process.platform === "darwin";
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 execFileAsync("security", [
2176
- "add-generic-password",
2177
- "-U",
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
- throw new Error(`Failed to store vault key in Keychain: ${err.message}`);
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 { stdout } = await execFileAsync("security", [
2192
- "find-generic-password",
2193
- "-s",
2194
- this.service,
2195
- "-a",
2196
- this.account,
2197
- "-w"
2198
- ]);
2199
- const trimmed = stdout.trim();
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(`Keychain vault key has invalid length: expected 32 bytes, got ${key.length}.`);
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("could not be found") || msg.includes("SecKeychainSearchCopyNext")) {
2209
- return null;
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 execFileAsync("security", ["delete-generic-password", "-s", this.service, "-a", this.account]);
2327
+ await fs.unlink(this.keyPath);
2217
2328
  } catch {
2218
2329
  }
2219
2330
  }
2220
2331
  };
2221
- function validateKeyLength(key) {
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. Use a persistent provider (macOS Keychain) or store the secret manually."
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}. The value must be a base64-encoded 256-bit key.`
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 keychainProvider = new KeychainProvider();
2377
+ const keyringProvider = new KeyringProvider();
2378
+ const fileProvider = new FileProvider();
2259
2379
  const envProvider = new EnvVarProvider();
2260
- const initProvider = await keychainProvider.available() ? keychainProvider : null;
2261
- let loadProvider = null;
2262
- if (await keychainProvider.available()) {
2263
- loadProvider = keychainProvider;
2264
- } else if (await envProvider.available()) {
2265
- loadProvider = envProvider;
2266
- }
2267
- return { initProvider, loadProvider };
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" + (process.platform === "darwin" ? "Keychain access failed. Check macOS Keychain permissions." : "Set the ELYTRO_VAULT_SECRET environment variable.")
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
- ` + (process.platform === "darwin" ? "The Keychain item may have been deleted. Re-run `elytro init` to create a new wallet,\nor import a backup with `elytro import`." : "Set ELYTRO_VAULT_SECRET to the base64-encoded vault key.")
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 ? { hint: "Save ELYTRO_VAULT_SECRET \u2014 it will NOT be shown again." } : {},
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 chainConfig = ctx.chain.chains.find((c) => c.id === chainId);
2491
- const chainName = chainConfig?.name ?? String(chainId);
2492
- if (chainConfig) {
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.1";
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.1",
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",