@elytro/cli 0.1.0 → 0.4.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 +89 -0
- package/dist/index.js +790 -641
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -363,6 +363,8 @@ var STORAGE_KEY = "keyring";
|
|
|
363
363
|
var KeyringService = class {
|
|
364
364
|
store;
|
|
365
365
|
vault = null;
|
|
366
|
+
/** Vault key kept in memory for re-encryption operations (addOwner, switchOwner). */
|
|
367
|
+
vaultKey = null;
|
|
366
368
|
constructor(store) {
|
|
367
369
|
this.store = store;
|
|
368
370
|
}
|
|
@@ -373,9 +375,9 @@ var KeyringService = class {
|
|
|
373
375
|
}
|
|
374
376
|
/**
|
|
375
377
|
* Create a brand-new vault with one owner.
|
|
376
|
-
* Called during `elytro init`. Encrypts with
|
|
378
|
+
* Called during `elytro init`. Encrypts with vault key.
|
|
377
379
|
*/
|
|
378
|
-
async createNewOwner(
|
|
380
|
+
async createNewOwner(vaultKey) {
|
|
379
381
|
const privateKey = generatePrivateKey();
|
|
380
382
|
const account = privateKeyToAccount(privateKey);
|
|
381
383
|
const owner = { id: account.address, key: privateKey };
|
|
@@ -383,26 +385,32 @@ var KeyringService = class {
|
|
|
383
385
|
owners: [owner],
|
|
384
386
|
currentOwnerId: account.address
|
|
385
387
|
};
|
|
386
|
-
const encrypted = await encryptWithKey(
|
|
388
|
+
const encrypted = await encryptWithKey(vaultKey, vault);
|
|
387
389
|
await this.store.save(STORAGE_KEY, encrypted);
|
|
388
390
|
this.vault = vault;
|
|
391
|
+
this.vaultKey = new Uint8Array(vaultKey);
|
|
389
392
|
return account.address;
|
|
390
393
|
}
|
|
391
394
|
// ─── Unlock / Access ────────────────────────────────────────────
|
|
392
395
|
/**
|
|
393
|
-
* Decrypt the vault with the
|
|
394
|
-
* Called automatically by context at CLI startup.
|
|
396
|
+
* Decrypt the vault with the vault key.
|
|
397
|
+
* Called automatically by context at CLI startup via SecretProvider.
|
|
395
398
|
*/
|
|
396
|
-
async unlock(
|
|
399
|
+
async unlock(vaultKey) {
|
|
397
400
|
const encrypted = await this.store.load(STORAGE_KEY);
|
|
398
401
|
if (!encrypted) {
|
|
399
402
|
throw new Error("Keyring not initialized. Run `elytro init` first.");
|
|
400
403
|
}
|
|
401
|
-
this.vault = await decryptWithKey(
|
|
404
|
+
this.vault = await decryptWithKey(vaultKey, encrypted);
|
|
405
|
+
this.vaultKey = new Uint8Array(vaultKey);
|
|
402
406
|
}
|
|
403
|
-
/** Lock the vault, clearing decrypted keys from memory. */
|
|
407
|
+
/** Lock the vault, clearing decrypted keys and vault key from memory. */
|
|
404
408
|
lock() {
|
|
405
409
|
this.vault = null;
|
|
410
|
+
if (this.vaultKey) {
|
|
411
|
+
this.vaultKey.fill(0);
|
|
412
|
+
this.vaultKey = null;
|
|
413
|
+
}
|
|
406
414
|
}
|
|
407
415
|
get isUnlocked() {
|
|
408
416
|
return this.vault !== null;
|
|
@@ -441,22 +449,22 @@ var KeyringService = class {
|
|
|
441
449
|
return privateKeyToAccount(key);
|
|
442
450
|
}
|
|
443
451
|
// ─── Multi-owner management ─────────────────────────────────────
|
|
444
|
-
async addOwner(
|
|
452
|
+
async addOwner() {
|
|
445
453
|
this.ensureUnlocked();
|
|
446
454
|
const privateKey = generatePrivateKey();
|
|
447
455
|
const account = privateKeyToAccount(privateKey);
|
|
448
456
|
this.vault.owners.push({ id: account.address, key: privateKey });
|
|
449
|
-
await this.persistVault(
|
|
457
|
+
await this.persistVault();
|
|
450
458
|
return account.address;
|
|
451
459
|
}
|
|
452
|
-
async switchOwner(ownerId
|
|
460
|
+
async switchOwner(ownerId) {
|
|
453
461
|
this.ensureUnlocked();
|
|
454
462
|
const exists = this.vault.owners.some((o) => o.id === ownerId);
|
|
455
463
|
if (!exists) {
|
|
456
464
|
throw new Error(`Owner ${ownerId} not found in vault.`);
|
|
457
465
|
}
|
|
458
466
|
this.vault.currentOwnerId = ownerId;
|
|
459
|
-
await this.persistVault(
|
|
467
|
+
await this.persistVault();
|
|
460
468
|
}
|
|
461
469
|
// ─── Export / Import (password-based for portability) ───────────
|
|
462
470
|
/**
|
|
@@ -469,18 +477,20 @@ var KeyringService = class {
|
|
|
469
477
|
}
|
|
470
478
|
/**
|
|
471
479
|
* Import vault from a password-encrypted backup.
|
|
472
|
-
* Decrypts with the backup password, then re-encrypts with
|
|
480
|
+
* Decrypts with the backup password, then re-encrypts with vault key.
|
|
473
481
|
*/
|
|
474
|
-
async importVault(encrypted, password2,
|
|
482
|
+
async importVault(encrypted, password2, vaultKey) {
|
|
475
483
|
const vault = await decrypt(password2, encrypted);
|
|
476
484
|
this.vault = vault;
|
|
477
|
-
|
|
485
|
+
this.vaultKey = new Uint8Array(vaultKey);
|
|
486
|
+
const reEncrypted = await encryptWithKey(vaultKey, vault);
|
|
478
487
|
await this.store.save(STORAGE_KEY, reEncrypted);
|
|
479
488
|
}
|
|
480
|
-
// ─── Rekey (
|
|
481
|
-
async rekey(
|
|
489
|
+
// ─── Rekey (vault key rotation) ───────────────────────────────
|
|
490
|
+
async rekey(newVaultKey) {
|
|
482
491
|
this.ensureUnlocked();
|
|
483
|
-
|
|
492
|
+
this.vaultKey = new Uint8Array(newVaultKey);
|
|
493
|
+
await this.persistVault();
|
|
484
494
|
}
|
|
485
495
|
// ─── Internal ───────────────────────────────────────────────────
|
|
486
496
|
getCurrentKey() {
|
|
@@ -498,9 +508,10 @@ var KeyringService = class {
|
|
|
498
508
|
throw new Error("Keyring is locked. Run `elytro init` first.");
|
|
499
509
|
}
|
|
500
510
|
}
|
|
501
|
-
async persistVault(
|
|
511
|
+
async persistVault() {
|
|
502
512
|
if (!this.vault) throw new Error("No vault to persist.");
|
|
503
|
-
|
|
513
|
+
if (!this.vaultKey) throw new Error("No vault key available for re-encryption.");
|
|
514
|
+
const encrypted = await encryptWithKey(this.vaultKey, this.vault);
|
|
504
515
|
await this.store.save(STORAGE_KEY, encrypted);
|
|
505
516
|
}
|
|
506
517
|
};
|
|
@@ -555,11 +566,36 @@ function resolveBundler(chainId, pimlicoKey) {
|
|
|
555
566
|
return PUBLIC_BUNDLER[chainId] ?? PUBLIC_BUNDLER[11155420];
|
|
556
567
|
}
|
|
557
568
|
var CHAIN_META = [
|
|
558
|
-
{
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
569
|
+
{
|
|
570
|
+
id: 1,
|
|
571
|
+
name: "Ethereum",
|
|
572
|
+
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
|
573
|
+
blockExplorer: "https://etherscan.io"
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
id: 10,
|
|
577
|
+
name: "Optimism",
|
|
578
|
+
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
|
579
|
+
blockExplorer: "https://optimistic.etherscan.io"
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
id: 42161,
|
|
583
|
+
name: "Arbitrum One",
|
|
584
|
+
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
|
585
|
+
blockExplorer: "https://arbiscan.io"
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
id: 11155111,
|
|
589
|
+
name: "Sepolia",
|
|
590
|
+
nativeCurrency: { name: "Sepolia ETH", symbol: "ETH", decimals: 18 },
|
|
591
|
+
blockExplorer: "https://sepolia.etherscan.io"
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
id: 11155420,
|
|
595
|
+
name: "Optimism Sepolia",
|
|
596
|
+
nativeCurrency: { name: "Sepolia ETH", symbol: "ETH", decimals: 18 },
|
|
597
|
+
blockExplorer: "https://sepolia-optimism.etherscan.io"
|
|
598
|
+
}
|
|
563
599
|
];
|
|
564
600
|
function buildChains(alchemyKey, pimlicoKey) {
|
|
565
601
|
return CHAIN_META.map((meta) => ({
|
|
@@ -1339,10 +1375,11 @@ var AccountService = class {
|
|
|
1339
1375
|
* Multiple accounts per chain are allowed — each gets a unique
|
|
1340
1376
|
* CREATE2 index, producing a different contract address.
|
|
1341
1377
|
*
|
|
1342
|
-
* @param chainId
|
|
1343
|
-
* @param alias
|
|
1378
|
+
* @param chainId - Required. The target chain.
|
|
1379
|
+
* @param alias - Optional. Human-readable name. Auto-generated if omitted.
|
|
1380
|
+
* @param securityIntent - Optional. Security intent (email, dailyLimit) to execute during activate.
|
|
1344
1381
|
*/
|
|
1345
|
-
async createAccount(chainId, alias) {
|
|
1382
|
+
async createAccount(chainId, alias, securityIntent) {
|
|
1346
1383
|
const owner = this.keyring.currentOwner;
|
|
1347
1384
|
if (!owner) {
|
|
1348
1385
|
throw new Error("Keyring is locked. Unlock first.");
|
|
@@ -1360,7 +1397,8 @@ var AccountService = class {
|
|
|
1360
1397
|
owner,
|
|
1361
1398
|
index,
|
|
1362
1399
|
isDeployed: false,
|
|
1363
|
-
isRecoveryEnabled: false
|
|
1400
|
+
isRecoveryEnabled: false,
|
|
1401
|
+
...securityIntent && { securityIntent }
|
|
1364
1402
|
};
|
|
1365
1403
|
this.state.accounts.push(account);
|
|
1366
1404
|
this.state.currentAddress = address2;
|
|
@@ -1431,6 +1469,45 @@ var AccountService = class {
|
|
|
1431
1469
|
balance
|
|
1432
1470
|
};
|
|
1433
1471
|
}
|
|
1472
|
+
// ─── Security Intent / Status ─────────────────────────────
|
|
1473
|
+
/**
|
|
1474
|
+
* Patch the temporary security intent (e.g. store emailBindingId).
|
|
1475
|
+
* Only valid before activate — intent is deleted after activate.
|
|
1476
|
+
*/
|
|
1477
|
+
async updateSecurityIntent(address2, chainId, patch) {
|
|
1478
|
+
const account = this.findAccount(address2, chainId);
|
|
1479
|
+
account.securityIntent = { ...account.securityIntent, ...patch };
|
|
1480
|
+
await this.persist();
|
|
1481
|
+
}
|
|
1482
|
+
/**
|
|
1483
|
+
* Called after activate succeeds with hook install:
|
|
1484
|
+
* 1. Write persistent SecurityStatus
|
|
1485
|
+
* 2. Delete temporary SecurityIntent (consumed)
|
|
1486
|
+
*/
|
|
1487
|
+
async finalizeSecurityIntent(address2, chainId) {
|
|
1488
|
+
const account = this.findAccount(address2, chainId);
|
|
1489
|
+
account.securityStatus = { hookInstalled: true };
|
|
1490
|
+
delete account.securityIntent;
|
|
1491
|
+
await this.persist();
|
|
1492
|
+
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Delete security intent without writing status.
|
|
1495
|
+
* Used if activate succeeds but hook batching failed (deploy-only).
|
|
1496
|
+
*/
|
|
1497
|
+
async clearSecurityIntent(address2, chainId) {
|
|
1498
|
+
const account = this.findAccount(address2, chainId);
|
|
1499
|
+
delete account.securityIntent;
|
|
1500
|
+
await this.persist();
|
|
1501
|
+
}
|
|
1502
|
+
findAccount(address2, chainId) {
|
|
1503
|
+
const account = this.state.accounts.find(
|
|
1504
|
+
(a) => a.address.toLowerCase() === address2.toLowerCase() && a.chainId === chainId
|
|
1505
|
+
);
|
|
1506
|
+
if (!account) {
|
|
1507
|
+
throw new Error(`Account ${address2} on chain ${chainId} not found.`);
|
|
1508
|
+
}
|
|
1509
|
+
return account;
|
|
1510
|
+
}
|
|
1434
1511
|
// ─── Import / Export ────────────────────────────────────────────
|
|
1435
1512
|
async importAccounts(accounts) {
|
|
1436
1513
|
let imported = 0;
|
|
@@ -1751,9 +1828,9 @@ var SecurityHookService = class {
|
|
|
1751
1828
|
throw err;
|
|
1752
1829
|
}
|
|
1753
1830
|
}
|
|
1754
|
-
isAuthError(
|
|
1755
|
-
if (!
|
|
1756
|
-
const msg = String(
|
|
1831
|
+
isAuthError(error) {
|
|
1832
|
+
if (!error || typeof error !== "object") return false;
|
|
1833
|
+
const msg = String(error.message ?? "").toLowerCase();
|
|
1757
1834
|
return msg.includes("forbidden") || msg.includes("unauthorized") || msg.includes("session") || msg.includes("expired") || msg.includes("failed to authenticate");
|
|
1758
1835
|
}
|
|
1759
1836
|
// ─── On-chain Hook Status ────────────────────────────────────
|
|
@@ -2064,53 +2141,120 @@ var SecurityHookService = class {
|
|
|
2064
2141
|
}
|
|
2065
2142
|
};
|
|
2066
2143
|
|
|
2067
|
-
// src/
|
|
2068
|
-
import {
|
|
2069
|
-
import {
|
|
2070
|
-
|
|
2071
|
-
var
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
}
|
|
2083
|
-
async function loadDeviceKey(dataDir) {
|
|
2084
|
-
const path = keyPath(dataDir);
|
|
2085
|
-
try {
|
|
2086
|
-
const st = await stat(path);
|
|
2087
|
-
const mode = st.mode & 511;
|
|
2088
|
-
if (mode !== REQUIRED_MODE) {
|
|
2089
|
-
throw new Error(
|
|
2090
|
-
`Device key has insecure permissions (${modeStr(mode)}). Expected 600. Fix with: chmod 600 ${path}`
|
|
2091
|
-
);
|
|
2144
|
+
// src/providers/keychainProvider.ts
|
|
2145
|
+
import { execFile } from "child_process";
|
|
2146
|
+
import { promisify } from "util";
|
|
2147
|
+
var execFileAsync = promisify(execFile);
|
|
2148
|
+
var KeychainProvider = class {
|
|
2149
|
+
name = "macos-keychain";
|
|
2150
|
+
service = "elytro-wallet";
|
|
2151
|
+
account = "vault-key";
|
|
2152
|
+
async available() {
|
|
2153
|
+
if (process.platform !== "darwin") return false;
|
|
2154
|
+
try {
|
|
2155
|
+
await execFileAsync("security", ["help"], { timeout: 5e3 });
|
|
2156
|
+
return true;
|
|
2157
|
+
} catch {
|
|
2158
|
+
return process.platform === "darwin";
|
|
2092
2159
|
}
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2160
|
+
}
|
|
2161
|
+
async store(secret) {
|
|
2162
|
+
validateKeyLength(secret);
|
|
2163
|
+
const b64 = Buffer.from(secret).toString("base64");
|
|
2164
|
+
try {
|
|
2165
|
+
await execFileAsync("security", [
|
|
2166
|
+
"add-generic-password",
|
|
2167
|
+
"-U",
|
|
2168
|
+
"-s",
|
|
2169
|
+
this.service,
|
|
2170
|
+
"-a",
|
|
2171
|
+
this.account,
|
|
2172
|
+
"-w",
|
|
2173
|
+
b64
|
|
2174
|
+
]);
|
|
2175
|
+
} catch (err) {
|
|
2176
|
+
throw new Error(`Failed to store vault key in Keychain: ${err.message}`);
|
|
2100
2177
|
}
|
|
2101
|
-
throw err;
|
|
2102
2178
|
}
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2179
|
+
async load() {
|
|
2180
|
+
try {
|
|
2181
|
+
const { stdout } = await execFileAsync("security", [
|
|
2182
|
+
"find-generic-password",
|
|
2183
|
+
"-s",
|
|
2184
|
+
this.service,
|
|
2185
|
+
"-a",
|
|
2186
|
+
this.account,
|
|
2187
|
+
"-w"
|
|
2188
|
+
]);
|
|
2189
|
+
const trimmed = stdout.trim();
|
|
2190
|
+
if (!trimmed) return null;
|
|
2191
|
+
const key = Buffer.from(trimmed, "base64");
|
|
2192
|
+
if (key.length !== 32) {
|
|
2193
|
+
throw new Error(`Keychain vault key has invalid length: expected 32 bytes, got ${key.length}.`);
|
|
2194
|
+
}
|
|
2195
|
+
return new Uint8Array(key);
|
|
2196
|
+
} catch (err) {
|
|
2197
|
+
const msg = err.message || "";
|
|
2198
|
+
if (msg.includes("could not be found") || msg.includes("SecKeychainSearchCopyNext")) {
|
|
2199
|
+
return null;
|
|
2200
|
+
}
|
|
2201
|
+
throw new Error(`Failed to load vault key from Keychain: ${msg}`);
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
async delete() {
|
|
2205
|
+
try {
|
|
2206
|
+
await execFileAsync("security", ["delete-generic-password", "-s", this.service, "-a", this.account]);
|
|
2207
|
+
} catch {
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
};
|
|
2211
|
+
function validateKeyLength(key) {
|
|
2212
|
+
if (key.length !== 32) {
|
|
2213
|
+
throw new Error(`Invalid vault key: expected 32 bytes, got ${key.length}.`);
|
|
2107
2214
|
}
|
|
2108
2215
|
}
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2216
|
+
|
|
2217
|
+
// src/providers/envVarProvider.ts
|
|
2218
|
+
var ENV_KEY = "ELYTRO_VAULT_SECRET";
|
|
2219
|
+
var EnvVarProvider = class {
|
|
2220
|
+
name = "env-var";
|
|
2221
|
+
async available() {
|
|
2222
|
+
return !!process.env[ENV_KEY];
|
|
2223
|
+
}
|
|
2224
|
+
async store(_secret) {
|
|
2225
|
+
throw new Error(
|
|
2226
|
+
"EnvVarProvider is read-only. Cannot store vault key in an environment variable. Use a persistent provider (macOS Keychain) or store the secret manually."
|
|
2227
|
+
);
|
|
2228
|
+
}
|
|
2229
|
+
async load() {
|
|
2230
|
+
const raw = process.env[ENV_KEY];
|
|
2231
|
+
if (!raw) return null;
|
|
2232
|
+
delete process.env[ENV_KEY];
|
|
2233
|
+
const key = Buffer.from(raw, "base64");
|
|
2234
|
+
if (key.length !== 32) {
|
|
2235
|
+
throw new Error(
|
|
2236
|
+
`${ENV_KEY} has invalid length: expected 32 bytes (base64), got ${key.length}. The value must be a base64-encoded 256-bit key.`
|
|
2237
|
+
);
|
|
2238
|
+
}
|
|
2239
|
+
return new Uint8Array(key);
|
|
2240
|
+
}
|
|
2241
|
+
async delete() {
|
|
2242
|
+
delete process.env[ENV_KEY];
|
|
2243
|
+
}
|
|
2244
|
+
};
|
|
2245
|
+
|
|
2246
|
+
// src/providers/resolveProvider.ts
|
|
2247
|
+
async function resolveProvider() {
|
|
2248
|
+
const keychainProvider = new KeychainProvider();
|
|
2249
|
+
const envProvider = new EnvVarProvider();
|
|
2250
|
+
const initProvider = await keychainProvider.available() ? keychainProvider : null;
|
|
2251
|
+
let loadProvider = null;
|
|
2252
|
+
if (await keychainProvider.available()) {
|
|
2253
|
+
loadProvider = keychainProvider;
|
|
2254
|
+
} else if (await envProvider.available()) {
|
|
2255
|
+
loadProvider = envProvider;
|
|
2256
|
+
}
|
|
2257
|
+
return { initProvider, loadProvider };
|
|
2114
2258
|
}
|
|
2115
2259
|
|
|
2116
2260
|
// src/context.ts
|
|
@@ -2125,9 +2269,31 @@ async function createAppContext() {
|
|
|
2125
2269
|
const defaultChain = chain.currentChain;
|
|
2126
2270
|
walletClient.initForChain(defaultChain);
|
|
2127
2271
|
await sdk.initForChain(defaultChain);
|
|
2128
|
-
const
|
|
2129
|
-
|
|
2130
|
-
|
|
2272
|
+
const { loadProvider } = await resolveProvider();
|
|
2273
|
+
const isInitialized = await keyring.isInitialized();
|
|
2274
|
+
if (isInitialized) {
|
|
2275
|
+
if (!loadProvider) {
|
|
2276
|
+
throw new Error(
|
|
2277
|
+
"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.")
|
|
2278
|
+
);
|
|
2279
|
+
}
|
|
2280
|
+
const vaultKey = await loadProvider.load();
|
|
2281
|
+
if (!vaultKey) {
|
|
2282
|
+
throw new Error(
|
|
2283
|
+
`Wallet is initialized but vault key not found in ${loadProvider.name}.
|
|
2284
|
+
` + (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.")
|
|
2285
|
+
);
|
|
2286
|
+
}
|
|
2287
|
+
try {
|
|
2288
|
+
await keyring.unlock(vaultKey);
|
|
2289
|
+
} catch (err) {
|
|
2290
|
+
vaultKey.fill(0);
|
|
2291
|
+
throw new Error(
|
|
2292
|
+
`Wallet unlock failed: ${err.message}
|
|
2293
|
+
The vault key may not match the encrypted keyring. Re-run \`elytro init\` or import a backup.`
|
|
2294
|
+
);
|
|
2295
|
+
}
|
|
2296
|
+
vaultKey.fill(0);
|
|
2131
2297
|
}
|
|
2132
2298
|
const account = new AccountService({
|
|
2133
2299
|
store,
|
|
@@ -2148,31 +2314,15 @@ async function createAppContext() {
|
|
|
2148
2314
|
}
|
|
2149
2315
|
}
|
|
2150
2316
|
}
|
|
2151
|
-
return { store, keyring, chain, sdk, walletClient, account,
|
|
2317
|
+
return { store, keyring, chain, sdk, walletClient, account, secretProvider: loadProvider };
|
|
2152
2318
|
}
|
|
2153
2319
|
|
|
2154
2320
|
// src/commands/init.ts
|
|
2321
|
+
import { webcrypto as webcrypto2 } from "crypto";
|
|
2155
2322
|
import ora from "ora";
|
|
2156
2323
|
|
|
2157
2324
|
// src/utils/display.ts
|
|
2158
2325
|
import chalk from "chalk";
|
|
2159
|
-
function heading(text) {
|
|
2160
|
-
console.log(chalk.bold.cyan(`
|
|
2161
|
-
${text}
|
|
2162
|
-
`));
|
|
2163
|
-
}
|
|
2164
|
-
function info(label, value) {
|
|
2165
|
-
console.log(` ${chalk.gray(label + ":")} ${value}`);
|
|
2166
|
-
}
|
|
2167
|
-
function success(text) {
|
|
2168
|
-
console.log(chalk.green(`\u2714 ${text}`));
|
|
2169
|
-
}
|
|
2170
|
-
function warn(text) {
|
|
2171
|
-
console.log(chalk.yellow(`\u26A0 ${text}`));
|
|
2172
|
-
}
|
|
2173
|
-
function error(text) {
|
|
2174
|
-
console.error(chalk.red(`\u2716 ${text}`));
|
|
2175
|
-
}
|
|
2176
2326
|
function txError(payload) {
|
|
2177
2327
|
const output = {
|
|
2178
2328
|
success: false,
|
|
@@ -2184,15 +2334,6 @@ function txError(payload) {
|
|
|
2184
2334
|
};
|
|
2185
2335
|
console.error(chalk.red(JSON.stringify(output, null, 2)));
|
|
2186
2336
|
}
|
|
2187
|
-
function table(rows, columns) {
|
|
2188
|
-
const header = columns.map((c) => c.label.padEnd(c.width ?? 20)).join(" ");
|
|
2189
|
-
console.log(chalk.bold(header));
|
|
2190
|
-
console.log(chalk.gray("\u2500".repeat(header.length)));
|
|
2191
|
-
for (const row of rows) {
|
|
2192
|
-
const line = columns.map((c) => (row[c.key] ?? "").padEnd(c.width ?? 20)).join(" ");
|
|
2193
|
-
console.log(line);
|
|
2194
|
-
}
|
|
2195
|
-
}
|
|
2196
2337
|
function address(addr) {
|
|
2197
2338
|
if (addr.length <= 14) return addr;
|
|
2198
2339
|
return `${addr.slice(0, 8)}...${addr.slice(-6)}`;
|
|
@@ -2206,32 +2347,51 @@ function maskApiKeys(url) {
|
|
|
2206
2347
|
function sanitizeErrorMessage(message) {
|
|
2207
2348
|
return message.replace(/https?:\/\/[^\s"']+/gi, (match) => maskApiKeys(match));
|
|
2208
2349
|
}
|
|
2350
|
+
function outputResult(result) {
|
|
2351
|
+
console.log(JSON.stringify({ success: true, result }, null, 2));
|
|
2352
|
+
}
|
|
2353
|
+
function outputError(code, message, data) {
|
|
2354
|
+
txError({ code, message: sanitizeErrorMessage(message), data });
|
|
2355
|
+
process.exitCode = 1;
|
|
2356
|
+
}
|
|
2209
2357
|
|
|
2210
2358
|
// src/commands/init.ts
|
|
2211
2359
|
function registerInitCommand(program2, ctx) {
|
|
2212
2360
|
program2.command("init").description("Initialize a new Elytro wallet").action(async () => {
|
|
2213
2361
|
if (await ctx.keyring.isInitialized()) {
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2362
|
+
outputResult({
|
|
2363
|
+
status: "already_initialized",
|
|
2364
|
+
dataDir: ctx.store.dataDir,
|
|
2365
|
+
hint: "Use `elytro account create` to create a smart account."
|
|
2366
|
+
});
|
|
2217
2367
|
return;
|
|
2218
2368
|
}
|
|
2219
|
-
heading("Initialize Elytro Wallet");
|
|
2220
2369
|
const spinner = ora("Setting up wallet...").start();
|
|
2221
2370
|
try {
|
|
2222
|
-
const
|
|
2223
|
-
await
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2371
|
+
const vaultKey = webcrypto2.getRandomValues(new Uint8Array(32));
|
|
2372
|
+
const { initProvider } = await resolveProvider();
|
|
2373
|
+
let providerName = null;
|
|
2374
|
+
let vaultSecretB64 = null;
|
|
2375
|
+
if (initProvider) {
|
|
2376
|
+
await initProvider.store(vaultKey);
|
|
2377
|
+
providerName = initProvider.name;
|
|
2378
|
+
} else {
|
|
2379
|
+
vaultSecretB64 = Buffer.from(vaultKey).toString("base64");
|
|
2380
|
+
}
|
|
2381
|
+
await ctx.keyring.createNewOwner(vaultKey);
|
|
2382
|
+
vaultKey.fill(0);
|
|
2383
|
+
spinner.stop();
|
|
2384
|
+
outputResult({
|
|
2385
|
+
status: "initialized",
|
|
2386
|
+
dataDir: ctx.store.dataDir,
|
|
2387
|
+
secretProvider: providerName,
|
|
2388
|
+
...vaultSecretB64 ? { vaultSecret: vaultSecretB64 } : {},
|
|
2389
|
+
...vaultSecretB64 ? { hint: "Save ELYTRO_VAULT_SECRET \u2014 it will NOT be shown again." } : {},
|
|
2390
|
+
nextStep: "Run `elytro account create --chain <chainId>` to create your first smart account."
|
|
2391
|
+
});
|
|
2231
2392
|
} catch (err) {
|
|
2232
2393
|
spinner.fail("Failed to initialize wallet.");
|
|
2233
|
-
|
|
2234
|
-
process.exitCode = 1;
|
|
2394
|
+
outputError(-32e3, sanitizeErrorMessage(err.message));
|
|
2235
2395
|
}
|
|
2236
2396
|
});
|
|
2237
2397
|
}
|
|
@@ -2254,20 +2414,67 @@ async function askInput(message, defaultValue) {
|
|
|
2254
2414
|
|
|
2255
2415
|
// src/commands/account.ts
|
|
2256
2416
|
init_sponsor();
|
|
2417
|
+
|
|
2418
|
+
// src/utils/contracts/securityHook.ts
|
|
2419
|
+
import { encodeFunctionData, parseAbi as parseAbi2, pad, toHex as toHex5 } from "viem";
|
|
2420
|
+
function encodeInstallHook(walletAddress, hookAddress, safetyDelay = DEFAULT_SAFETY_DELAY, capabilityFlags = DEFAULT_CAPABILITY) {
|
|
2421
|
+
const safetyDelayHex = pad(toHex5(safetyDelay), { size: 4 }).slice(2);
|
|
2422
|
+
const hookAndData = hookAddress + safetyDelayHex;
|
|
2423
|
+
const callData = encodeFunctionData({
|
|
2424
|
+
abi: parseAbi2(["function installHook(bytes calldata hookAndData, uint8 capabilityFlags)"]),
|
|
2425
|
+
functionName: "installHook",
|
|
2426
|
+
args: [hookAndData, capabilityFlags]
|
|
2427
|
+
});
|
|
2428
|
+
return { to: walletAddress, value: "0", data: callData };
|
|
2429
|
+
}
|
|
2430
|
+
function encodeUninstallHook(walletAddress, hookAddress) {
|
|
2431
|
+
const callData = encodeFunctionData({
|
|
2432
|
+
abi: parseAbi2(["function uninstallHook(address)"]),
|
|
2433
|
+
functionName: "uninstallHook",
|
|
2434
|
+
args: [hookAddress]
|
|
2435
|
+
});
|
|
2436
|
+
return { to: walletAddress, value: "0", data: callData };
|
|
2437
|
+
}
|
|
2438
|
+
function encodeForcePreUninstall(hookAddress) {
|
|
2439
|
+
const callData = encodeFunctionData({
|
|
2440
|
+
abi: parseAbi2(["function forcePreUninstall()"]),
|
|
2441
|
+
functionName: "forcePreUninstall",
|
|
2442
|
+
args: []
|
|
2443
|
+
});
|
|
2444
|
+
return { to: hookAddress, value: "0", data: callData };
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
// src/commands/account.ts
|
|
2448
|
+
var ERR_INVALID_PARAMS = -32602;
|
|
2449
|
+
var ERR_ACCOUNT_NOT_READY = -32002;
|
|
2450
|
+
var ERR_SPONSOR_FAILED = -32003;
|
|
2451
|
+
var ERR_SEND_FAILED = -32005;
|
|
2452
|
+
var ERR_REVERTED = -32006;
|
|
2453
|
+
var ERR_INTERNAL = -32e3;
|
|
2257
2454
|
function registerAccountCommand(program2, ctx) {
|
|
2258
2455
|
const account = program2.command("account").description("Manage smart accounts");
|
|
2259
|
-
account.command("create").description("Create a new smart account").requiredOption("-c, --chain <chainId>", "Target chain ID").option("-a, --alias <alias>", "Human-readable alias (default: random)").action(async (opts) => {
|
|
2260
|
-
if (!ctx.
|
|
2261
|
-
|
|
2262
|
-
process.exitCode = 1;
|
|
2456
|
+
account.command("create").description("Create a new smart account").requiredOption("-c, --chain <chainId>", "Target chain ID").option("-a, --alias <alias>", "Human-readable alias (default: random)").option("-e, --email <email>", "2FA email for SecurityHook OTP (strongly recommended)").option("-l, --daily-limit <usd>", 'Daily spending limit in USD (e.g. "100")').action(async (opts) => {
|
|
2457
|
+
if (!ctx.keyring.isUnlocked) {
|
|
2458
|
+
outputError(ERR_ACCOUNT_NOT_READY, "Wallet not initialized. Run `elytro init` first.");
|
|
2263
2459
|
return;
|
|
2264
2460
|
}
|
|
2265
2461
|
const chainId = Number(opts.chain);
|
|
2266
2462
|
if (Number.isNaN(chainId)) {
|
|
2267
|
-
|
|
2268
|
-
process.exitCode = 1;
|
|
2463
|
+
outputError(ERR_INVALID_PARAMS, "Invalid chain ID.", { chain: opts.chain });
|
|
2269
2464
|
return;
|
|
2270
2465
|
}
|
|
2466
|
+
let dailyLimitUsd;
|
|
2467
|
+
if (opts.dailyLimit !== void 0) {
|
|
2468
|
+
dailyLimitUsd = parseFloat(opts.dailyLimit);
|
|
2469
|
+
if (isNaN(dailyLimitUsd) || dailyLimitUsd < 0) {
|
|
2470
|
+
outputError(ERR_INVALID_PARAMS, 'Invalid --daily-limit. Provide a positive number in USD (e.g. "100").');
|
|
2471
|
+
return;
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
const securityIntent = opts.email || dailyLimitUsd !== void 0 ? {
|
|
2475
|
+
...opts.email && { email: opts.email },
|
|
2476
|
+
...dailyLimitUsd !== void 0 && { dailyLimitUsd }
|
|
2477
|
+
} : void 0;
|
|
2271
2478
|
const spinner = ora2("Creating smart account...").start();
|
|
2272
2479
|
try {
|
|
2273
2480
|
const chainConfig = ctx.chain.chains.find((c) => c.id === chainId);
|
|
@@ -2276,7 +2483,7 @@ function registerAccountCommand(program2, ctx) {
|
|
|
2276
2483
|
await ctx.sdk.initForChain(chainConfig);
|
|
2277
2484
|
ctx.walletClient.initForChain(chainConfig);
|
|
2278
2485
|
}
|
|
2279
|
-
const accountInfo = await ctx.account.createAccount(chainId, opts.alias);
|
|
2486
|
+
const accountInfo = await ctx.account.createAccount(chainId, opts.alias, securityIntent);
|
|
2280
2487
|
spinner.text = "Registering with backend...";
|
|
2281
2488
|
const { guardianHash, guardianSafePeriod } = ctx.sdk.initDefaults;
|
|
2282
2489
|
const paddedKey = padHex2(accountInfo.owner, { size: 32 });
|
|
@@ -2289,56 +2496,107 @@ function registerAccountCommand(program2, ctx) {
|
|
|
2289
2496
|
guardianHash,
|
|
2290
2497
|
guardianSafePeriod
|
|
2291
2498
|
);
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2499
|
+
let emailBindingStarted = false;
|
|
2500
|
+
if (opts.email && chainConfig) {
|
|
2501
|
+
spinner.text = "Initiating email binding...";
|
|
2502
|
+
try {
|
|
2503
|
+
const hookService = createHookServiceForAccount(ctx, chainConfig);
|
|
2504
|
+
const bindingResult = await hookService.requestEmailBinding(
|
|
2505
|
+
accountInfo.address,
|
|
2506
|
+
chainId,
|
|
2507
|
+
opts.email
|
|
2508
|
+
);
|
|
2509
|
+
await ctx.account.updateSecurityIntent(accountInfo.address, chainId, {
|
|
2510
|
+
emailBindingId: bindingResult.bindingId
|
|
2511
|
+
});
|
|
2512
|
+
emailBindingStarted = true;
|
|
2513
|
+
} catch {
|
|
2514
|
+
}
|
|
2301
2515
|
}
|
|
2516
|
+
spinner.stop();
|
|
2517
|
+
outputResult({
|
|
2518
|
+
alias: accountInfo.alias,
|
|
2519
|
+
address: accountInfo.address,
|
|
2520
|
+
chain: chainName,
|
|
2521
|
+
chainId,
|
|
2522
|
+
deployed: false,
|
|
2523
|
+
...securityIntent ? {
|
|
2524
|
+
security: {
|
|
2525
|
+
...opts.email ? { email: opts.email, emailBindingStarted } : {},
|
|
2526
|
+
...dailyLimitUsd !== void 0 ? { dailyLimitUsd } : {},
|
|
2527
|
+
hookPending: true
|
|
2528
|
+
}
|
|
2529
|
+
} : { security: null },
|
|
2530
|
+
...regError ? { warning: `Backend registration failed: ${regError}` } : {}
|
|
2531
|
+
});
|
|
2302
2532
|
} catch (err) {
|
|
2303
|
-
spinner.
|
|
2304
|
-
|
|
2305
|
-
process.exitCode = 1;
|
|
2533
|
+
spinner.stop();
|
|
2534
|
+
outputError(ERR_INTERNAL, err.message);
|
|
2306
2535
|
}
|
|
2307
2536
|
});
|
|
2308
2537
|
account.command("activate").description("Deploy the smart contract on-chain").argument("[account]", "Alias or address (default: current)").option("--no-sponsor", "Skip sponsorship check (user pays gas)").action(async (target, opts) => {
|
|
2309
|
-
if (!ctx.
|
|
2310
|
-
|
|
2311
|
-
process.exitCode = 1;
|
|
2538
|
+
if (!ctx.keyring.isUnlocked) {
|
|
2539
|
+
outputError(ERR_ACCOUNT_NOT_READY, "Wallet not initialized. Run `elytro init` first.");
|
|
2312
2540
|
return;
|
|
2313
2541
|
}
|
|
2314
2542
|
const identifier = target ?? ctx.account.currentAccount?.alias ?? ctx.account.currentAccount?.address;
|
|
2315
2543
|
if (!identifier) {
|
|
2316
|
-
|
|
2544
|
+
outputError(ERR_ACCOUNT_NOT_READY, "No account selected. Specify an alias/address or create an account first.");
|
|
2317
2545
|
return;
|
|
2318
2546
|
}
|
|
2319
2547
|
const accountInfo = ctx.account.resolveAccount(identifier);
|
|
2320
2548
|
if (!accountInfo) {
|
|
2321
|
-
|
|
2322
|
-
process.exitCode = 1;
|
|
2549
|
+
outputError(ERR_ACCOUNT_NOT_READY, `Account "${identifier}" not found.`);
|
|
2323
2550
|
return;
|
|
2324
2551
|
}
|
|
2325
2552
|
if (accountInfo.isDeployed) {
|
|
2326
|
-
|
|
2553
|
+
outputResult({
|
|
2554
|
+
alias: accountInfo.alias,
|
|
2555
|
+
address: accountInfo.address,
|
|
2556
|
+
status: "already_deployed"
|
|
2557
|
+
});
|
|
2327
2558
|
return;
|
|
2328
2559
|
}
|
|
2329
2560
|
const chainConfig = ctx.chain.chains.find((c) => c.id === accountInfo.chainId);
|
|
2330
2561
|
const chainName = chainConfig?.name ?? String(accountInfo.chainId);
|
|
2331
2562
|
if (!chainConfig) {
|
|
2332
|
-
|
|
2333
|
-
process.exitCode = 1;
|
|
2563
|
+
outputError(ERR_ACCOUNT_NOT_READY, `Chain ${accountInfo.chainId} not configured.`);
|
|
2334
2564
|
return;
|
|
2335
2565
|
}
|
|
2336
2566
|
await ctx.sdk.initForChain(chainConfig);
|
|
2337
2567
|
ctx.walletClient.initForChain(chainConfig);
|
|
2338
2568
|
const spinner = ora2(`Activating "${accountInfo.alias}" on ${chainName}...`).start();
|
|
2569
|
+
const intent = accountInfo.securityIntent;
|
|
2570
|
+
const hookAddress = SECURITY_HOOK_ADDRESS_MAP[accountInfo.chainId];
|
|
2571
|
+
const shouldInstallHook = !!(intent && hookAddress && (intent.email || intent.dailyLimitUsd !== void 0));
|
|
2339
2572
|
try {
|
|
2340
2573
|
spinner.text = "Building deployment UserOp...";
|
|
2574
|
+
let deployCallData;
|
|
2575
|
+
let hookBatched = false;
|
|
2576
|
+
if (shouldInstallHook) {
|
|
2577
|
+
const installTx = encodeInstallHook(
|
|
2578
|
+
accountInfo.address,
|
|
2579
|
+
hookAddress,
|
|
2580
|
+
DEFAULT_SAFETY_DELAY,
|
|
2581
|
+
DEFAULT_CAPABILITY
|
|
2582
|
+
);
|
|
2583
|
+
deployCallData = installTx.data;
|
|
2584
|
+
}
|
|
2341
2585
|
const userOp = await ctx.sdk.createDeployUserOp(accountInfo.owner, accountInfo.index);
|
|
2586
|
+
if (shouldInstallHook && deployCallData) {
|
|
2587
|
+
try {
|
|
2588
|
+
const hookInstallOp = await ctx.sdk.createSendUserOp(accountInfo.address, [
|
|
2589
|
+
{
|
|
2590
|
+
to: accountInfo.address,
|
|
2591
|
+
value: "0",
|
|
2592
|
+
data: deployCallData
|
|
2593
|
+
}
|
|
2594
|
+
]);
|
|
2595
|
+
userOp.callData = hookInstallOp.callData;
|
|
2596
|
+
hookBatched = true;
|
|
2597
|
+
} catch {
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2342
2600
|
spinner.text = "Fetching gas prices...";
|
|
2343
2601
|
const feeData = await ctx.sdk.getFeeData(chainConfig);
|
|
2344
2602
|
userOp.maxFeePerGas = feeData.maxFeePerGas;
|
|
@@ -2364,15 +2622,14 @@ function registerAccountCommand(program2, ctx) {
|
|
|
2364
2622
|
spinner.text = "Sponsorship unavailable, checking balance...";
|
|
2365
2623
|
const { ether: balance } = await ctx.walletClient.getBalance(accountInfo.address);
|
|
2366
2624
|
if (parseFloat(balance) === 0) {
|
|
2367
|
-
spinner.
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2625
|
+
spinner.stop();
|
|
2626
|
+
outputError(ERR_SPONSOR_FAILED, `Sponsorship failed: ${sponsorError ?? "unknown"}. Account has no ETH to pay gas.`, {
|
|
2627
|
+
account: accountInfo.alias,
|
|
2628
|
+
address: accountInfo.address,
|
|
2629
|
+
chain: chainName
|
|
2630
|
+
});
|
|
2373
2631
|
return;
|
|
2374
2632
|
}
|
|
2375
|
-
spinner.text = "Proceeding without sponsor (user pays gas)...";
|
|
2376
2633
|
}
|
|
2377
2634
|
}
|
|
2378
2635
|
spinner.text = "Signing UserOperation...";
|
|
@@ -2384,25 +2641,37 @@ function registerAccountCommand(program2, ctx) {
|
|
|
2384
2641
|
spinner.text = "Waiting for on-chain confirmation...";
|
|
2385
2642
|
const receipt = await ctx.sdk.waitForReceipt(opHash);
|
|
2386
2643
|
await ctx.account.markDeployed(accountInfo.address, accountInfo.chainId);
|
|
2644
|
+
const hookInstalled = shouldInstallHook && hookBatched && receipt.success;
|
|
2645
|
+
if (hookInstalled) {
|
|
2646
|
+
await ctx.account.finalizeSecurityIntent(accountInfo.address, accountInfo.chainId);
|
|
2647
|
+
} else if (intent) {
|
|
2648
|
+
await ctx.account.clearSecurityIntent(accountInfo.address, accountInfo.chainId);
|
|
2649
|
+
}
|
|
2650
|
+
spinner.stop();
|
|
2387
2651
|
if (receipt.success) {
|
|
2388
|
-
|
|
2652
|
+
outputResult({
|
|
2653
|
+
alias: accountInfo.alias,
|
|
2654
|
+
address: accountInfo.address,
|
|
2655
|
+
chain: chainName,
|
|
2656
|
+
chainId: accountInfo.chainId,
|
|
2657
|
+
transactionHash: receipt.transactionHash,
|
|
2658
|
+
gasCost: `${formatEther2(BigInt(receipt.actualGasCost))} ETH`,
|
|
2659
|
+
sponsored,
|
|
2660
|
+
hookInstalled,
|
|
2661
|
+
...hookInstalled && intent?.email ? { emailPending: intent.email } : {},
|
|
2662
|
+
...hookInstalled && intent?.dailyLimitUsd !== void 0 ? { dailyLimitPending: intent.dailyLimitUsd } : {},
|
|
2663
|
+
...chainConfig.blockExplorer ? { explorer: `${chainConfig.blockExplorer}/tx/${receipt.transactionHash}` } : {}
|
|
2664
|
+
});
|
|
2389
2665
|
} else {
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
info("Chain", `${chainName} (${accountInfo.chainId})`);
|
|
2396
|
-
info("Tx Hash", receipt.transactionHash);
|
|
2397
|
-
info("Gas Cost", `${formatEther2(BigInt(receipt.actualGasCost))} ETH`);
|
|
2398
|
-
info("Sponsored", sponsored ? "Yes (gasless)" : "No (user paid)");
|
|
2399
|
-
if (chainConfig.blockExplorer) {
|
|
2400
|
-
info("Explorer", `${chainConfig.blockExplorer}/tx/${receipt.transactionHash}`);
|
|
2666
|
+
outputError(ERR_REVERTED, "UserOp included but execution reverted.", {
|
|
2667
|
+
alias: accountInfo.alias,
|
|
2668
|
+
transactionHash: receipt.transactionHash,
|
|
2669
|
+
block: receipt.blockNumber
|
|
2670
|
+
});
|
|
2401
2671
|
}
|
|
2402
2672
|
} catch (err) {
|
|
2403
|
-
spinner.
|
|
2404
|
-
|
|
2405
|
-
process.exitCode = 1;
|
|
2673
|
+
spinner.stop();
|
|
2674
|
+
outputError(ERR_SEND_FAILED, err.message);
|
|
2406
2675
|
}
|
|
2407
2676
|
});
|
|
2408
2677
|
account.command("list").description("List all accounts (or query one by alias/address)").argument("[account]", "Filter by alias or address").option("-c, --chain <chainId>", "Filter by chain ID").action(async (target, opts) => {
|
|
@@ -2410,53 +2679,40 @@ function registerAccountCommand(program2, ctx) {
|
|
|
2410
2679
|
if (target) {
|
|
2411
2680
|
const matched = ctx.account.resolveAccount(target);
|
|
2412
2681
|
if (!matched) {
|
|
2413
|
-
|
|
2414
|
-
process.exitCode = 1;
|
|
2682
|
+
outputError(ERR_ACCOUNT_NOT_READY, `Account "${target}" not found.`);
|
|
2415
2683
|
return;
|
|
2416
2684
|
}
|
|
2417
2685
|
accounts = [matched];
|
|
2418
2686
|
}
|
|
2419
|
-
if (accounts.length === 0) {
|
|
2420
|
-
warn("No accounts found. Run `elytro account create --chain <chainId>` first.");
|
|
2421
|
-
return;
|
|
2422
|
-
}
|
|
2423
2687
|
const current = ctx.account.currentAccount;
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
accounts.map((a) => {
|
|
2688
|
+
outputResult({
|
|
2689
|
+
accounts: accounts.map((a) => {
|
|
2427
2690
|
const chainConfig = ctx.chain.chains.find((c) => c.id === a.chainId);
|
|
2428
2691
|
return {
|
|
2429
|
-
active: a.address === current?.address
|
|
2692
|
+
active: a.address === current?.address,
|
|
2430
2693
|
alias: a.alias,
|
|
2431
2694
|
address: a.address,
|
|
2432
2695
|
chain: chainConfig?.name ?? String(a.chainId),
|
|
2433
|
-
|
|
2434
|
-
|
|
2696
|
+
chainId: a.chainId,
|
|
2697
|
+
deployed: a.isDeployed,
|
|
2698
|
+
recovery: a.isRecoveryEnabled
|
|
2435
2699
|
};
|
|
2436
2700
|
}),
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
{ key: "alias", label: "Alias", width: 16 },
|
|
2440
|
-
{ key: "address", label: "Address", width: 44 },
|
|
2441
|
-
{ key: "chain", label: "Chain", width: 18 },
|
|
2442
|
-
{ key: "deployed", label: "Deployed", width: 10 },
|
|
2443
|
-
{ key: "recovery", label: "Recovery", width: 10 }
|
|
2444
|
-
]
|
|
2445
|
-
);
|
|
2701
|
+
total: accounts.length
|
|
2702
|
+
});
|
|
2446
2703
|
});
|
|
2447
2704
|
account.command("info").description("Show details for an account").argument("[account]", "Alias or address (default: current)").action(async (target) => {
|
|
2448
2705
|
const identifier = target ?? ctx.account.currentAccount?.alias ?? ctx.account.currentAccount?.address;
|
|
2449
2706
|
if (!identifier) {
|
|
2450
|
-
|
|
2707
|
+
outputError(ERR_ACCOUNT_NOT_READY, "No account selected. Run `elytro account create` first.");
|
|
2451
2708
|
return;
|
|
2452
2709
|
}
|
|
2453
2710
|
const spinner = ora2("Fetching on-chain data...").start();
|
|
2454
2711
|
try {
|
|
2455
2712
|
const accountInfo = ctx.account.resolveAccount(identifier);
|
|
2456
2713
|
if (!accountInfo) {
|
|
2457
|
-
spinner.
|
|
2458
|
-
|
|
2459
|
-
process.exitCode = 1;
|
|
2714
|
+
spinner.stop();
|
|
2715
|
+
outputError(ERR_ACCOUNT_NOT_READY, `Account "${identifier}" not found.`);
|
|
2460
2716
|
return;
|
|
2461
2717
|
}
|
|
2462
2718
|
const chainConfig = ctx.chain.chains.find((c) => c.id === accountInfo.chainId);
|
|
@@ -2465,26 +2721,27 @@ function registerAccountCommand(program2, ctx) {
|
|
|
2465
2721
|
}
|
|
2466
2722
|
const detail = await ctx.account.getAccountDetail(identifier);
|
|
2467
2723
|
spinner.stop();
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2724
|
+
outputResult({
|
|
2725
|
+
alias: detail.alias,
|
|
2726
|
+
address: detail.address,
|
|
2727
|
+
chain: chainConfig?.name ?? String(detail.chainId),
|
|
2728
|
+
chainId: detail.chainId,
|
|
2729
|
+
deployed: detail.isDeployed,
|
|
2730
|
+
balance: `${detail.balance} ${chainConfig?.nativeCurrency.symbol ?? "ETH"}`,
|
|
2731
|
+
recovery: detail.isRecoveryEnabled,
|
|
2732
|
+
...detail.securityStatus ? { securityStatus: detail.securityStatus } : {},
|
|
2733
|
+
...detail.securityIntent ? { securityIntent: detail.securityIntent } : {},
|
|
2734
|
+
...chainConfig?.blockExplorer ? { explorer: `${chainConfig.blockExplorer}/address/${detail.address}` } : {}
|
|
2735
|
+
});
|
|
2478
2736
|
} catch (err) {
|
|
2479
|
-
spinner.
|
|
2480
|
-
|
|
2481
|
-
process.exitCode = 1;
|
|
2737
|
+
spinner.stop();
|
|
2738
|
+
outputError(ERR_INTERNAL, err.message);
|
|
2482
2739
|
}
|
|
2483
2740
|
});
|
|
2484
2741
|
account.command("switch").description("Switch the active account").argument("[account]", "Alias or address").action(async (target) => {
|
|
2485
2742
|
const accounts = ctx.account.allAccounts;
|
|
2486
2743
|
if (accounts.length === 0) {
|
|
2487
|
-
|
|
2744
|
+
outputError(ERR_ACCOUNT_NOT_READY, "No accounts found. Run `elytro account create` first.");
|
|
2488
2745
|
return;
|
|
2489
2746
|
}
|
|
2490
2747
|
let identifier = target;
|
|
@@ -2505,26 +2762,42 @@ function registerAccountCommand(program2, ctx) {
|
|
|
2505
2762
|
ctx.walletClient.initForChain(newChain);
|
|
2506
2763
|
await ctx.sdk.initForChain(newChain);
|
|
2507
2764
|
}
|
|
2508
|
-
|
|
2509
|
-
const spinner = ora2("Fetching on-chain data...").start();
|
|
2765
|
+
let balance = null;
|
|
2510
2766
|
try {
|
|
2511
2767
|
const detail = await ctx.account.getAccountDetail(switched.alias);
|
|
2512
|
-
|
|
2513
|
-
console.log("");
|
|
2514
|
-
info("Address", detail.address);
|
|
2515
|
-
info("Chain", newChain?.name ?? String(detail.chainId));
|
|
2516
|
-
info("Deployed", detail.isDeployed ? "Yes" : "No");
|
|
2517
|
-
info("Balance", `${detail.balance} ${newChain?.nativeCurrency.symbol ?? "ETH"}`);
|
|
2518
|
-
if (newChain?.blockExplorer) {
|
|
2519
|
-
info("Explorer", `${newChain.blockExplorer}/address/${detail.address}`);
|
|
2520
|
-
}
|
|
2768
|
+
balance = `${detail.balance} ${newChain?.nativeCurrency.symbol ?? "ETH"}`;
|
|
2521
2769
|
} catch {
|
|
2522
|
-
spinner.stop();
|
|
2523
|
-
warn("Could not fetch on-chain data. Run `elytro account info` to retry.");
|
|
2524
2770
|
}
|
|
2771
|
+
outputResult({
|
|
2772
|
+
alias: switched.alias,
|
|
2773
|
+
address: switched.address,
|
|
2774
|
+
chain: newChain?.name ?? String(switched.chainId),
|
|
2775
|
+
chainId: switched.chainId,
|
|
2776
|
+
deployed: switched.isDeployed,
|
|
2777
|
+
...balance ? { balance } : {},
|
|
2778
|
+
...newChain?.blockExplorer ? { explorer: `${newChain.blockExplorer}/address/${switched.address}` } : {}
|
|
2779
|
+
});
|
|
2525
2780
|
} catch (err) {
|
|
2526
|
-
|
|
2527
|
-
|
|
2781
|
+
outputError(ERR_INTERNAL, err.message);
|
|
2782
|
+
}
|
|
2783
|
+
});
|
|
2784
|
+
}
|
|
2785
|
+
function createHookServiceForAccount(ctx, chainConfig) {
|
|
2786
|
+
return new SecurityHookService({
|
|
2787
|
+
store: ctx.store,
|
|
2788
|
+
graphqlEndpoint: ctx.chain.graphqlEndpoint,
|
|
2789
|
+
signMessageForAuth: createSignMessageForAuth({
|
|
2790
|
+
signDigest: (digest) => ctx.keyring.signDigest(digest),
|
|
2791
|
+
packRawHash: (hash) => ctx.sdk.packRawHash(hash),
|
|
2792
|
+
packSignature: (rawSig, valData) => ctx.sdk.packUserOpSignature(rawSig, valData)
|
|
2793
|
+
}),
|
|
2794
|
+
readContract: async (params) => {
|
|
2795
|
+
return ctx.walletClient.readContract(params);
|
|
2796
|
+
},
|
|
2797
|
+
getBlockTimestamp: async () => {
|
|
2798
|
+
const blockNumber = await ctx.walletClient.raw.getBlockNumber();
|
|
2799
|
+
const block = await ctx.walletClient.raw.getBlock({ blockNumber });
|
|
2800
|
+
return block.timestamp;
|
|
2528
2801
|
}
|
|
2529
2802
|
});
|
|
2530
2803
|
}
|
|
@@ -2532,15 +2805,15 @@ function registerAccountCommand(program2, ctx) {
|
|
|
2532
2805
|
// src/commands/tx.ts
|
|
2533
2806
|
init_sponsor();
|
|
2534
2807
|
import ora3 from "ora";
|
|
2535
|
-
import { isAddress, isHex, formatEther as formatEther3, parseEther as parseEther2, toHex as
|
|
2536
|
-
var
|
|
2808
|
+
import { isAddress, isHex, formatEther as formatEther3, parseEther as parseEther2, toHex as toHex6 } from "viem";
|
|
2809
|
+
var ERR_INVALID_PARAMS2 = -32602;
|
|
2537
2810
|
var ERR_INSUFFICIENT_BALANCE = -32001;
|
|
2538
|
-
var
|
|
2539
|
-
var
|
|
2811
|
+
var ERR_ACCOUNT_NOT_READY2 = -32002;
|
|
2812
|
+
var ERR_SPONSOR_FAILED2 = -32003;
|
|
2540
2813
|
var ERR_BUILD_FAILED = -32004;
|
|
2541
|
-
var
|
|
2814
|
+
var ERR_SEND_FAILED2 = -32005;
|
|
2542
2815
|
var ERR_EXECUTION_REVERTED = -32006;
|
|
2543
|
-
var
|
|
2816
|
+
var ERR_INTERNAL2 = -32e3;
|
|
2544
2817
|
var TxError = class extends Error {
|
|
2545
2818
|
code;
|
|
2546
2819
|
data;
|
|
@@ -2553,14 +2826,10 @@ var TxError = class extends Error {
|
|
|
2553
2826
|
};
|
|
2554
2827
|
function handleTxError(err) {
|
|
2555
2828
|
if (err instanceof TxError) {
|
|
2556
|
-
|
|
2829
|
+
outputError(err.code, err.message, err.data);
|
|
2557
2830
|
} else {
|
|
2558
|
-
|
|
2559
|
-
code: ERR_INTERNAL,
|
|
2560
|
-
message: sanitizeErrorMessage(err.message ?? String(err))
|
|
2561
|
-
});
|
|
2831
|
+
outputError(ERR_INTERNAL2, err.message ?? String(err));
|
|
2562
2832
|
}
|
|
2563
|
-
process.exitCode = 1;
|
|
2564
2833
|
}
|
|
2565
2834
|
function parseTxSpec(spec, index) {
|
|
2566
2835
|
const prefix = `--tx #${index + 1}`;
|
|
@@ -2568,7 +2837,7 @@ function parseTxSpec(spec, index) {
|
|
|
2568
2837
|
for (const part of spec.split(",")) {
|
|
2569
2838
|
const colonIdx = part.indexOf(":");
|
|
2570
2839
|
if (colonIdx === -1) {
|
|
2571
|
-
throw new TxError(
|
|
2840
|
+
throw new TxError(ERR_INVALID_PARAMS2, `${prefix}: invalid segment "${part}". Expected key:value format.`, {
|
|
2572
2841
|
spec,
|
|
2573
2842
|
index
|
|
2574
2843
|
});
|
|
@@ -2576,17 +2845,17 @@ function parseTxSpec(spec, index) {
|
|
|
2576
2845
|
const key = part.slice(0, colonIdx).trim().toLowerCase();
|
|
2577
2846
|
const val = part.slice(colonIdx + 1).trim();
|
|
2578
2847
|
if (!key || !val) {
|
|
2579
|
-
throw new TxError(
|
|
2848
|
+
throw new TxError(ERR_INVALID_PARAMS2, `${prefix}: empty key or value in "${part}".`, { spec, index });
|
|
2580
2849
|
}
|
|
2581
2850
|
if (fields[key]) {
|
|
2582
|
-
throw new TxError(
|
|
2851
|
+
throw new TxError(ERR_INVALID_PARAMS2, `${prefix}: duplicate key "${key}".`, { spec, index, key });
|
|
2583
2852
|
}
|
|
2584
2853
|
fields[key] = val;
|
|
2585
2854
|
}
|
|
2586
2855
|
const knownKeys = /* @__PURE__ */ new Set(["to", "value", "data"]);
|
|
2587
2856
|
for (const key of Object.keys(fields)) {
|
|
2588
2857
|
if (!knownKeys.has(key)) {
|
|
2589
|
-
throw new TxError(
|
|
2858
|
+
throw new TxError(ERR_INVALID_PARAMS2, `${prefix}: unknown key "${key}". Allowed: to, value, data.`, {
|
|
2590
2859
|
spec,
|
|
2591
2860
|
index,
|
|
2592
2861
|
key
|
|
@@ -2594,13 +2863,13 @@ function parseTxSpec(spec, index) {
|
|
|
2594
2863
|
}
|
|
2595
2864
|
}
|
|
2596
2865
|
if (!fields.to) {
|
|
2597
|
-
throw new TxError(
|
|
2866
|
+
throw new TxError(ERR_INVALID_PARAMS2, `${prefix}: "to" is required.`, { spec, index });
|
|
2598
2867
|
}
|
|
2599
2868
|
if (!isAddress(fields.to)) {
|
|
2600
|
-
throw new TxError(
|
|
2869
|
+
throw new TxError(ERR_INVALID_PARAMS2, `${prefix}: invalid address "${fields.to}".`, { spec, index, to: fields.to });
|
|
2601
2870
|
}
|
|
2602
2871
|
if (!fields.value && !fields.data) {
|
|
2603
|
-
throw new TxError(
|
|
2872
|
+
throw new TxError(ERR_INVALID_PARAMS2, `${prefix}: at least one of "value" or "data" is required.`, { spec, index });
|
|
2604
2873
|
}
|
|
2605
2874
|
if (fields.value) {
|
|
2606
2875
|
try {
|
|
@@ -2608,7 +2877,7 @@ function parseTxSpec(spec, index) {
|
|
|
2608
2877
|
if (wei < 0n) throw new Error("negative");
|
|
2609
2878
|
} catch {
|
|
2610
2879
|
throw new TxError(
|
|
2611
|
-
|
|
2880
|
+
ERR_INVALID_PARAMS2,
|
|
2612
2881
|
`${prefix}: invalid ETH amount "${fields.value}". Use human-readable format (e.g. "0.1").`,
|
|
2613
2882
|
{ spec, index, value: fields.value }
|
|
2614
2883
|
);
|
|
@@ -2616,14 +2885,14 @@ function parseTxSpec(spec, index) {
|
|
|
2616
2885
|
}
|
|
2617
2886
|
if (fields.data) {
|
|
2618
2887
|
if (!isHex(fields.data)) {
|
|
2619
|
-
throw new TxError(
|
|
2888
|
+
throw new TxError(ERR_INVALID_PARAMS2, `${prefix}: invalid hex in "data". Must start with 0x.`, {
|
|
2620
2889
|
spec,
|
|
2621
2890
|
index,
|
|
2622
2891
|
data: fields.data
|
|
2623
2892
|
});
|
|
2624
2893
|
}
|
|
2625
2894
|
if (fields.data.length > 2 && fields.data.length % 2 !== 0) {
|
|
2626
|
-
throw new TxError(
|
|
2895
|
+
throw new TxError(ERR_INVALID_PARAMS2, `${prefix}: "data" hex must have even length (complete bytes).`, {
|
|
2627
2896
|
spec,
|
|
2628
2897
|
index,
|
|
2629
2898
|
data: fields.data
|
|
@@ -2645,7 +2914,7 @@ function detectTxType(specs) {
|
|
|
2645
2914
|
function specsToTxs(specs) {
|
|
2646
2915
|
return specs.map((s) => ({
|
|
2647
2916
|
to: s.to,
|
|
2648
|
-
value: s.value ?
|
|
2917
|
+
value: s.value ? toHex6(parseEther2(s.value)) : "0x0",
|
|
2649
2918
|
data: s.data ?? "0x"
|
|
2650
2919
|
}));
|
|
2651
2920
|
}
|
|
@@ -2666,19 +2935,12 @@ function txTypeLabel(txType) {
|
|
|
2666
2935
|
return "Batch Transaction";
|
|
2667
2936
|
}
|
|
2668
2937
|
}
|
|
2669
|
-
function
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
}
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
parts.push(`\u2192 ${spec.to}`);
|
|
2676
|
-
if (spec.value) parts.push(`${spec.value} ETH`);
|
|
2677
|
-
if (spec.data && spec.data !== "0x") {
|
|
2678
|
-
const selector = spec.data.length >= 10 ? spec.data.slice(0, 10) : spec.data;
|
|
2679
|
-
parts.push(`call ${selector}`);
|
|
2680
|
-
}
|
|
2681
|
-
info("Tx", parts.join(" "));
|
|
2938
|
+
function specToJson(spec) {
|
|
2939
|
+
return {
|
|
2940
|
+
to: spec.to,
|
|
2941
|
+
...spec.value ? { value: spec.value } : {},
|
|
2942
|
+
...spec.data && spec.data !== "0x" ? { data: spec.data, selector: spec.data.length >= 10 ? spec.data.slice(0, 10) : spec.data } : {}
|
|
2943
|
+
};
|
|
2682
2944
|
}
|
|
2683
2945
|
function registerTxCommand(program2, ctx) {
|
|
2684
2946
|
const tx = program2.command("tx").description("Build, simulate, and send transactions");
|
|
@@ -2691,21 +2953,23 @@ function registerTxCommand(program2, ctx) {
|
|
|
2691
2953
|
specs,
|
|
2692
2954
|
opts?.sponsor
|
|
2693
2955
|
);
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2956
|
+
outputResult({
|
|
2957
|
+
userOp: serializeUserOp(userOp),
|
|
2958
|
+
account: accountInfo.alias,
|
|
2959
|
+
address: accountInfo.address,
|
|
2960
|
+
chain: chainConfig.name,
|
|
2961
|
+
chainId: chainConfig.id,
|
|
2962
|
+
txType: txTypeLabel(txType),
|
|
2963
|
+
...txType === "batch" ? { txCount: specs.length } : {},
|
|
2964
|
+
sponsored
|
|
2965
|
+
});
|
|
2702
2966
|
} catch (err) {
|
|
2703
2967
|
handleTxError(err);
|
|
2704
2968
|
}
|
|
2705
2969
|
});
|
|
2706
2970
|
tx.command("send").description("Send a transaction on-chain").argument("[account]", "Source account alias or address (default: current)").option("--tx <spec...>", 'Transaction spec: "to:0xAddr,value:0.1,data:0x..." (repeatable, ordered)').option("--no-sponsor", "Skip sponsorship check").option("--no-hook", "Skip SecurityHook signing (bypass 2FA)").option("--userop <json>", "Send a pre-built UserOp JSON (skips build step)").action(async (target, opts) => {
|
|
2707
|
-
if (!ctx.
|
|
2708
|
-
handleTxError(new TxError(
|
|
2971
|
+
if (!ctx.keyring.isUnlocked) {
|
|
2972
|
+
handleTxError(new TxError(ERR_ACCOUNT_NOT_READY2, "Wallet not initialized. Run `elytro init` first."));
|
|
2709
2973
|
return;
|
|
2710
2974
|
}
|
|
2711
2975
|
try {
|
|
@@ -2720,7 +2984,7 @@ function registerTxCommand(program2, ctx) {
|
|
|
2720
2984
|
sponsored = !!userOp.paymaster;
|
|
2721
2985
|
const identifier = target ?? ctx.account.currentAccount?.alias ?? ctx.account.currentAccount?.address;
|
|
2722
2986
|
if (!identifier) {
|
|
2723
|
-
throw new TxError(
|
|
2987
|
+
throw new TxError(ERR_ACCOUNT_NOT_READY2, "No account selected.", {
|
|
2724
2988
|
hint: "Specify an alias/address or create an account first."
|
|
2725
2989
|
});
|
|
2726
2990
|
}
|
|
@@ -2737,37 +3001,20 @@ function registerTxCommand(program2, ctx) {
|
|
|
2737
3001
|
sponsored = result.sponsored;
|
|
2738
3002
|
txType = result.txType;
|
|
2739
3003
|
}
|
|
2740
|
-
console.log("");
|
|
2741
|
-
heading("Transaction Summary");
|
|
2742
|
-
info("Type", txTypeLabel(txType));
|
|
2743
|
-
info("From", `${accountInfo.alias} (${accountInfo.address})`);
|
|
2744
|
-
if (txType === "batch") {
|
|
2745
|
-
info("Tx Count", specs.length.toString());
|
|
2746
|
-
for (let i = 0; i < specs.length; i++) {
|
|
2747
|
-
displayTxSpec(specs[i], i);
|
|
2748
|
-
}
|
|
2749
|
-
} else if (txType === "contract-call") {
|
|
2750
|
-
const s = specs[0];
|
|
2751
|
-
info("To", s.to);
|
|
2752
|
-
info("Calldata", truncateHex(s.data ?? "0x"));
|
|
2753
|
-
if (s.data && s.data.length >= 10) {
|
|
2754
|
-
info("Selector", s.data.slice(0, 10));
|
|
2755
|
-
}
|
|
2756
|
-
if (s.value && s.value !== "0") {
|
|
2757
|
-
info("Value", `${s.value} ETH (payable)`);
|
|
2758
|
-
}
|
|
2759
|
-
} else {
|
|
2760
|
-
const s = specs[0];
|
|
2761
|
-
info("To", s.to);
|
|
2762
|
-
info("Value", `${s.value ?? "0"} ETH`);
|
|
2763
|
-
}
|
|
2764
|
-
info("Sponsored", sponsored ? "Yes (gasless)" : "No (user pays gas)");
|
|
2765
3004
|
const estimatedGas = userOp.callGasLimit + userOp.verificationGasLimit + userOp.preVerificationGas;
|
|
2766
|
-
|
|
2767
|
-
|
|
3005
|
+
console.error(JSON.stringify({
|
|
3006
|
+
summary: {
|
|
3007
|
+
txType: txTypeLabel(txType),
|
|
3008
|
+
from: accountInfo.alias,
|
|
3009
|
+
address: accountInfo.address,
|
|
3010
|
+
transactions: specs.map((s, i) => specToJson(s)),
|
|
3011
|
+
sponsored,
|
|
3012
|
+
estimatedGas: estimatedGas.toString()
|
|
3013
|
+
}
|
|
3014
|
+
}, null, 2));
|
|
2768
3015
|
const confirmed = await askConfirm("Sign and send this transaction?");
|
|
2769
3016
|
if (!confirmed) {
|
|
2770
|
-
|
|
3017
|
+
outputResult({ status: "cancelled" });
|
|
2771
3018
|
return;
|
|
2772
3019
|
}
|
|
2773
3020
|
const spinner = ora3("Signing UserOperation...").start();
|
|
@@ -2810,14 +3057,12 @@ function registerTxCommand(program2, ctx) {
|
|
|
2810
3057
|
spinner.stop();
|
|
2811
3058
|
const errCode = hookResult.error.code;
|
|
2812
3059
|
if (errCode === "OTP_REQUIRED" || errCode === "SPENDING_LIMIT_EXCEEDED") {
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
info("Daily limit", `$${((hookResult.error.dailyLimitUsdCents ?? 0) / 100).toFixed(2)}`);
|
|
2820
|
-
}
|
|
3060
|
+
console.error(JSON.stringify({
|
|
3061
|
+
challenge: errCode,
|
|
3062
|
+
message: hookResult.error.message ?? `Verification required (${errCode}).`,
|
|
3063
|
+
...hookResult.error.maskedEmail ? { maskedEmail: hookResult.error.maskedEmail } : {},
|
|
3064
|
+
...errCode === "SPENDING_LIMIT_EXCEEDED" && hookResult.error.projectedSpendUsdCents !== void 0 ? { projectedSpendUsd: (hookResult.error.projectedSpendUsdCents / 100).toFixed(2), dailyLimitUsd: ((hookResult.error.dailyLimitUsdCents ?? 0) / 100).toFixed(2) } : {}
|
|
3065
|
+
}, null, 2));
|
|
2821
3066
|
const otpCode = await askInput("Enter the 6-digit OTP code:");
|
|
2822
3067
|
spinner.start("Verifying OTP...");
|
|
2823
3068
|
await hookService.verifySecurityOtp(
|
|
@@ -2835,13 +3080,13 @@ function registerTxCommand(program2, ctx) {
|
|
|
2835
3080
|
);
|
|
2836
3081
|
if (hookResult.error) {
|
|
2837
3082
|
throw new TxError(
|
|
2838
|
-
|
|
3083
|
+
ERR_SEND_FAILED2,
|
|
2839
3084
|
`Hook authorization failed after OTP: ${hookResult.error.message}`
|
|
2840
3085
|
);
|
|
2841
3086
|
}
|
|
2842
3087
|
} else {
|
|
2843
3088
|
throw new TxError(
|
|
2844
|
-
|
|
3089
|
+
ERR_SEND_FAILED2,
|
|
2845
3090
|
`Hook authorization failed: ${hookResult.error.message ?? errCode}`
|
|
2846
3091
|
);
|
|
2847
3092
|
}
|
|
@@ -2862,48 +3107,41 @@ function registerTxCommand(program2, ctx) {
|
|
|
2862
3107
|
spinner.text = "Sending to bundler...";
|
|
2863
3108
|
opHash = await ctx.sdk.sendUserOp(userOp);
|
|
2864
3109
|
} catch (err) {
|
|
2865
|
-
spinner.
|
|
2866
|
-
throw new TxError(
|
|
3110
|
+
spinner.stop();
|
|
3111
|
+
throw new TxError(ERR_SEND_FAILED2, err.message, {
|
|
2867
3112
|
sender: accountInfo.address,
|
|
2868
3113
|
chain: chainConfig.name
|
|
2869
3114
|
});
|
|
2870
3115
|
}
|
|
2871
3116
|
spinner.text = "Waiting for on-chain confirmation...";
|
|
2872
3117
|
const receipt = await ctx.sdk.waitForReceipt(opHash);
|
|
3118
|
+
spinner.stop();
|
|
2873
3119
|
if (receipt.success) {
|
|
2874
|
-
|
|
3120
|
+
outputResult({
|
|
3121
|
+
status: "confirmed",
|
|
3122
|
+
account: accountInfo.alias,
|
|
3123
|
+
address: accountInfo.address,
|
|
3124
|
+
transactionHash: receipt.transactionHash,
|
|
3125
|
+
block: receipt.blockNumber,
|
|
3126
|
+
gasCost: `${formatEther3(BigInt(receipt.actualGasCost))} ETH`,
|
|
3127
|
+
sponsored,
|
|
3128
|
+
...chainConfig.blockExplorer ? { explorer: `${chainConfig.blockExplorer}/tx/${receipt.transactionHash}` } : {}
|
|
3129
|
+
});
|
|
2875
3130
|
} else {
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
txHash: receipt.transactionHash,
|
|
2882
|
-
block: receipt.blockNumber,
|
|
2883
|
-
gasCost: `${formatEther3(BigInt(receipt.actualGasCost))} ETH`,
|
|
2884
|
-
sender: accountInfo.address
|
|
2885
|
-
}
|
|
3131
|
+
outputError(ERR_EXECUTION_REVERTED, "UserOp included but execution reverted on-chain.", {
|
|
3132
|
+
transactionHash: receipt.transactionHash,
|
|
3133
|
+
block: receipt.blockNumber,
|
|
3134
|
+
gasCost: `${formatEther3(BigInt(receipt.actualGasCost))} ETH`,
|
|
3135
|
+
sender: accountInfo.address
|
|
2886
3136
|
});
|
|
2887
3137
|
}
|
|
2888
|
-
console.log("");
|
|
2889
|
-
info("Account", accountInfo.alias);
|
|
2890
|
-
info("Tx Hash", receipt.transactionHash);
|
|
2891
|
-
info("Block", receipt.blockNumber);
|
|
2892
|
-
info("Gas Cost", `${formatEther3(BigInt(receipt.actualGasCost))} ETH`);
|
|
2893
|
-
info("Sponsored", sponsored ? "Yes (gasless)" : "No (user paid)");
|
|
2894
|
-
if (chainConfig.blockExplorer) {
|
|
2895
|
-
info("Explorer", `${chainConfig.blockExplorer}/tx/${receipt.transactionHash}`);
|
|
2896
|
-
}
|
|
2897
|
-
if (!receipt.success) {
|
|
2898
|
-
process.exitCode = 1;
|
|
2899
|
-
}
|
|
2900
3138
|
} catch (err) {
|
|
2901
3139
|
handleTxError(err);
|
|
2902
3140
|
}
|
|
2903
3141
|
});
|
|
2904
3142
|
tx.command("simulate").description("Preview a transaction (gas estimate, sponsor check)").argument("[account]", "Source account alias or address (default: current)").option("--tx <spec...>", 'Transaction spec: "to:0xAddr,value:0.1,data:0x..." (repeatable, ordered)').option("--no-sponsor", "Skip sponsorship check").action(async (target, opts) => {
|
|
2905
|
-
if (!ctx.
|
|
2906
|
-
handleTxError(new TxError(
|
|
3143
|
+
if (!ctx.keyring.isUnlocked) {
|
|
3144
|
+
handleTxError(new TxError(ERR_ACCOUNT_NOT_READY2, "Wallet not initialized. Run `elytro init` first."));
|
|
2907
3145
|
return;
|
|
2908
3146
|
}
|
|
2909
3147
|
try {
|
|
@@ -2916,77 +3154,44 @@ function registerTxCommand(program2, ctx) {
|
|
|
2916
3154
|
);
|
|
2917
3155
|
const { wei: ethBalance, ether: ethFormatted } = await ctx.walletClient.getBalance(accountInfo.address);
|
|
2918
3156
|
const nativeCurrency = chainConfig.nativeCurrency.symbol;
|
|
2919
|
-
console.log("");
|
|
2920
|
-
heading("Transaction Simulation");
|
|
2921
|
-
info("Type", txTypeLabel(txType));
|
|
2922
|
-
info("From", `${accountInfo.alias} (${accountInfo.address})`);
|
|
2923
|
-
info("Chain", `${chainConfig.name} (${chainConfig.id})`);
|
|
2924
|
-
if (txType === "batch") {
|
|
2925
|
-
console.log("");
|
|
2926
|
-
info("Tx Count", specs.length.toString());
|
|
2927
|
-
for (let i = 0; i < specs.length; i++) {
|
|
2928
|
-
displayTxSpec(specs[i], i);
|
|
2929
|
-
}
|
|
2930
|
-
const total = totalEthValue(specs);
|
|
2931
|
-
if (total > 0n) {
|
|
2932
|
-
info("Total ETH", formatEther3(total));
|
|
2933
|
-
if (ethBalance < total) {
|
|
2934
|
-
warn(`Insufficient balance: need ${formatEther3(total)}, have ${ethFormatted} ${nativeCurrency}`);
|
|
2935
|
-
}
|
|
2936
|
-
}
|
|
2937
|
-
} else if (txType === "contract-call") {
|
|
2938
|
-
const s = specs[0];
|
|
2939
|
-
console.log("");
|
|
2940
|
-
info("To", s.to);
|
|
2941
|
-
info("Calldata", truncateHex(s.data ?? "0x"));
|
|
2942
|
-
info("Calldata Size", `${Math.max(0, ((s.data?.length ?? 2) - 2) / 2)} bytes`);
|
|
2943
|
-
if (s.data && s.data.length >= 10) {
|
|
2944
|
-
info("Selector", s.data.slice(0, 10));
|
|
2945
|
-
}
|
|
2946
|
-
if (s.value && s.value !== "0") {
|
|
2947
|
-
info("Value", `${s.value} ${nativeCurrency} (payable)`);
|
|
2948
|
-
const sendValue = parseEther2(s.value);
|
|
2949
|
-
if (ethBalance < sendValue) {
|
|
2950
|
-
warn(`Insufficient balance for value: need ${s.value}, have ${ethFormatted} ${nativeCurrency}`);
|
|
2951
|
-
}
|
|
2952
|
-
}
|
|
2953
|
-
const isContract = await ctx.walletClient.isContractDeployed(s.to);
|
|
2954
|
-
info("Target", isContract ? "Contract" : "EOA (warning: calling non-contract)");
|
|
2955
|
-
if (!isContract) {
|
|
2956
|
-
warn("Target address has no deployed code. The call may be a no-op or revert.");
|
|
2957
|
-
}
|
|
2958
|
-
} else {
|
|
2959
|
-
const s = specs[0];
|
|
2960
|
-
console.log("");
|
|
2961
|
-
info("To", s.to);
|
|
2962
|
-
info("Value", `${s.value ?? "0"} ${nativeCurrency}`);
|
|
2963
|
-
if (s.value) {
|
|
2964
|
-
const sendValue = parseEther2(s.value);
|
|
2965
|
-
if (ethBalance < sendValue) {
|
|
2966
|
-
warn(`Insufficient balance: need ${s.value}, have ${ethFormatted} ${nativeCurrency}`);
|
|
2967
|
-
}
|
|
2968
|
-
}
|
|
2969
|
-
}
|
|
2970
|
-
console.log("");
|
|
2971
|
-
info("callGasLimit", userOp.callGasLimit.toString());
|
|
2972
|
-
info("verificationGasLimit", userOp.verificationGasLimit.toString());
|
|
2973
|
-
info("preVerificationGas", userOp.preVerificationGas.toString());
|
|
2974
|
-
info("maxFeePerGas", `${userOp.maxFeePerGas.toString()} wei`);
|
|
2975
|
-
info("maxPriorityFeePerGas", `${userOp.maxPriorityFeePerGas.toString()} wei`);
|
|
2976
3157
|
const totalGas = userOp.callGasLimit + userOp.verificationGasLimit + userOp.preVerificationGas;
|
|
2977
3158
|
const maxCostWei = totalGas * userOp.maxFeePerGas;
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
info("Paymaster", userOp.paymaster);
|
|
3159
|
+
const ethValueSum = totalEthValue(specs);
|
|
3160
|
+
const warnings = [];
|
|
3161
|
+
if (ethValueSum > 0n && ethBalance < ethValueSum) {
|
|
3162
|
+
warnings.push(`Insufficient balance for value: need ${formatEther3(ethValueSum)}, have ${ethFormatted} ${nativeCurrency}`);
|
|
2983
3163
|
}
|
|
2984
|
-
info(`${nativeCurrency} Balance`, `${ethFormatted} ${nativeCurrency}`);
|
|
2985
3164
|
if (!sponsored && ethBalance < maxCostWei) {
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
3165
|
+
warnings.push(`Insufficient ${nativeCurrency} for gas: need ~${formatEther3(maxCostWei)}, have ${ethFormatted}`);
|
|
3166
|
+
}
|
|
3167
|
+
let targetIsContract;
|
|
3168
|
+
if (txType === "contract-call") {
|
|
3169
|
+
targetIsContract = await ctx.walletClient.isContractDeployed(specs[0].to);
|
|
3170
|
+
if (!targetIsContract) {
|
|
3171
|
+
warnings.push("Target address has no deployed code. The call may be a no-op or revert.");
|
|
3172
|
+
}
|
|
2989
3173
|
}
|
|
3174
|
+
outputResult({
|
|
3175
|
+
txType: txTypeLabel(txType),
|
|
3176
|
+
account: accountInfo.alias,
|
|
3177
|
+
address: accountInfo.address,
|
|
3178
|
+
chain: chainConfig.name,
|
|
3179
|
+
chainId: chainConfig.id,
|
|
3180
|
+
transactions: specs.map((s) => specToJson(s)),
|
|
3181
|
+
...txType === "contract-call" && targetIsContract !== void 0 ? { targetIsContract } : {},
|
|
3182
|
+
gas: {
|
|
3183
|
+
callGasLimit: userOp.callGasLimit.toString(),
|
|
3184
|
+
verificationGasLimit: userOp.verificationGasLimit.toString(),
|
|
3185
|
+
preVerificationGas: userOp.preVerificationGas.toString(),
|
|
3186
|
+
maxFeePerGas: userOp.maxFeePerGas.toString(),
|
|
3187
|
+
maxPriorityFeePerGas: userOp.maxPriorityFeePerGas.toString(),
|
|
3188
|
+
maxCost: `${formatEther3(maxCostWei)} ${nativeCurrency}`
|
|
3189
|
+
},
|
|
3190
|
+
sponsored,
|
|
3191
|
+
...sponsored && userOp.paymaster ? { paymaster: userOp.paymaster } : {},
|
|
3192
|
+
balance: `${ethFormatted} ${nativeCurrency}`,
|
|
3193
|
+
...warnings.length > 0 ? { warnings } : {}
|
|
3194
|
+
});
|
|
2990
3195
|
} catch (err) {
|
|
2991
3196
|
handleTxError(err);
|
|
2992
3197
|
}
|
|
@@ -2994,21 +3199,21 @@ function registerTxCommand(program2, ctx) {
|
|
|
2994
3199
|
}
|
|
2995
3200
|
function parseAllTxSpecs(rawSpecs) {
|
|
2996
3201
|
if (!rawSpecs || rawSpecs.length === 0) {
|
|
2997
|
-
throw new TxError(
|
|
3202
|
+
throw new TxError(ERR_INVALID_PARAMS2, 'At least one --tx is required. Format: --tx "to:0xAddr,value:0.1"');
|
|
2998
3203
|
}
|
|
2999
3204
|
return rawSpecs.map((spec, i) => parseTxSpec(spec, i));
|
|
3000
3205
|
}
|
|
3001
3206
|
async function buildUserOp(ctx, target, specs, sponsor) {
|
|
3002
3207
|
const identifier = target ?? ctx.account.currentAccount?.alias ?? ctx.account.currentAccount?.address;
|
|
3003
3208
|
if (!identifier) {
|
|
3004
|
-
throw new TxError(
|
|
3209
|
+
throw new TxError(ERR_ACCOUNT_NOT_READY2, "No account selected.", {
|
|
3005
3210
|
hint: "Specify an alias/address or create an account first."
|
|
3006
3211
|
});
|
|
3007
3212
|
}
|
|
3008
3213
|
const accountInfo = resolveAccountStrict(ctx, identifier);
|
|
3009
3214
|
const chainConfig = resolveChainStrict(ctx, accountInfo.chainId);
|
|
3010
3215
|
if (!accountInfo.isDeployed) {
|
|
3011
|
-
throw new TxError(
|
|
3216
|
+
throw new TxError(ERR_ACCOUNT_NOT_READY2, `Account "${accountInfo.alias}" is not deployed.`, {
|
|
3012
3217
|
account: accountInfo.alias,
|
|
3013
3218
|
address: accountInfo.address,
|
|
3014
3219
|
hint: "Run `elytro account activate` first."
|
|
@@ -3037,7 +3242,7 @@ async function buildUserOp(ctx, target, specs, sponsor) {
|
|
|
3037
3242
|
try {
|
|
3038
3243
|
userOp = await ctx.sdk.createSendUserOp(accountInfo.address, txs);
|
|
3039
3244
|
} catch (err) {
|
|
3040
|
-
spinner.
|
|
3245
|
+
spinner.stop();
|
|
3041
3246
|
throw new TxError(ERR_BUILD_FAILED, `Failed to build UserOp: ${err.message}`, {
|
|
3042
3247
|
account: accountInfo.address,
|
|
3043
3248
|
chain: chainConfig.name
|
|
@@ -3054,7 +3259,7 @@ async function buildUserOp(ctx, target, specs, sponsor) {
|
|
|
3054
3259
|
userOp.verificationGasLimit = gasEstimate.verificationGasLimit;
|
|
3055
3260
|
userOp.preVerificationGas = gasEstimate.preVerificationGas;
|
|
3056
3261
|
} catch (err) {
|
|
3057
|
-
spinner.
|
|
3262
|
+
spinner.stop();
|
|
3058
3263
|
throw new TxError(ERR_BUILD_FAILED, `Gas estimation failed: ${err.message}`, {
|
|
3059
3264
|
account: accountInfo.address,
|
|
3060
3265
|
chain: chainConfig.name
|
|
@@ -3076,8 +3281,8 @@ async function buildUserOp(ctx, target, specs, sponsor) {
|
|
|
3076
3281
|
spinner.text = "Sponsorship unavailable, checking balance...";
|
|
3077
3282
|
const { wei: balance } = await ctx.walletClient.getBalance(accountInfo.address);
|
|
3078
3283
|
if (balance === 0n) {
|
|
3079
|
-
spinner.
|
|
3080
|
-
throw new TxError(
|
|
3284
|
+
spinner.stop();
|
|
3285
|
+
throw new TxError(ERR_SPONSOR_FAILED2, "Sponsorship failed and account has no ETH to pay gas.", {
|
|
3081
3286
|
reason: sponsorError ?? "unknown",
|
|
3082
3287
|
account: accountInfo.address,
|
|
3083
3288
|
chain: chainConfig.name,
|
|
@@ -3086,38 +3291,38 @@ async function buildUserOp(ctx, target, specs, sponsor) {
|
|
|
3086
3291
|
}
|
|
3087
3292
|
}
|
|
3088
3293
|
}
|
|
3089
|
-
spinner.
|
|
3294
|
+
spinner.stop();
|
|
3090
3295
|
return { userOp, accountInfo, chainConfig, sponsored, txType };
|
|
3091
3296
|
}
|
|
3092
3297
|
function resolveAccountStrict(ctx, identifier) {
|
|
3093
3298
|
const account = ctx.account.resolveAccount(identifier);
|
|
3094
3299
|
if (!account) {
|
|
3095
|
-
throw new TxError(
|
|
3300
|
+
throw new TxError(ERR_ACCOUNT_NOT_READY2, `Account "${identifier}" not found.`, { identifier });
|
|
3096
3301
|
}
|
|
3097
3302
|
return account;
|
|
3098
3303
|
}
|
|
3099
3304
|
function resolveChainStrict(ctx, chainId) {
|
|
3100
3305
|
const chain = ctx.chain.chains.find((c) => c.id === chainId);
|
|
3101
3306
|
if (!chain) {
|
|
3102
|
-
throw new TxError(
|
|
3307
|
+
throw new TxError(ERR_ACCOUNT_NOT_READY2, `Chain ${chainId} not configured.`, { chainId });
|
|
3103
3308
|
}
|
|
3104
3309
|
return chain;
|
|
3105
3310
|
}
|
|
3106
3311
|
function serializeUserOp(op) {
|
|
3107
3312
|
return {
|
|
3108
3313
|
sender: op.sender,
|
|
3109
|
-
nonce:
|
|
3314
|
+
nonce: toHex6(op.nonce),
|
|
3110
3315
|
factory: op.factory,
|
|
3111
3316
|
factoryData: op.factoryData,
|
|
3112
3317
|
callData: op.callData,
|
|
3113
|
-
callGasLimit:
|
|
3114
|
-
verificationGasLimit:
|
|
3115
|
-
preVerificationGas:
|
|
3116
|
-
maxFeePerGas:
|
|
3117
|
-
maxPriorityFeePerGas:
|
|
3318
|
+
callGasLimit: toHex6(op.callGasLimit),
|
|
3319
|
+
verificationGasLimit: toHex6(op.verificationGasLimit),
|
|
3320
|
+
preVerificationGas: toHex6(op.preVerificationGas),
|
|
3321
|
+
maxFeePerGas: toHex6(op.maxFeePerGas),
|
|
3322
|
+
maxPriorityFeePerGas: toHex6(op.maxPriorityFeePerGas),
|
|
3118
3323
|
paymaster: op.paymaster,
|
|
3119
|
-
paymasterVerificationGasLimit: op.paymasterVerificationGasLimit ?
|
|
3120
|
-
paymasterPostOpGasLimit: op.paymasterPostOpGasLimit ?
|
|
3324
|
+
paymasterVerificationGasLimit: op.paymasterVerificationGasLimit ? toHex6(op.paymasterVerificationGasLimit) : null,
|
|
3325
|
+
paymasterPostOpGasLimit: op.paymasterPostOpGasLimit ? toHex6(op.paymasterPostOpGasLimit) : null,
|
|
3121
3326
|
paymasterData: op.paymasterData,
|
|
3122
3327
|
signature: op.signature
|
|
3123
3328
|
};
|
|
@@ -3127,10 +3332,10 @@ function deserializeUserOp(json) {
|
|
|
3127
3332
|
try {
|
|
3128
3333
|
raw = JSON.parse(json);
|
|
3129
3334
|
} catch {
|
|
3130
|
-
throw new TxError(
|
|
3335
|
+
throw new TxError(ERR_INVALID_PARAMS2, "Invalid UserOp JSON. Pass a JSON-encoded UserOp object.", { json });
|
|
3131
3336
|
}
|
|
3132
3337
|
if (!raw.sender || !raw.callData) {
|
|
3133
|
-
throw new TxError(
|
|
3338
|
+
throw new TxError(ERR_INVALID_PARAMS2, "Invalid UserOp: missing required fields (sender, callData).");
|
|
3134
3339
|
}
|
|
3135
3340
|
return {
|
|
3136
3341
|
sender: raw.sender,
|
|
@@ -3156,7 +3361,7 @@ import ora4 from "ora";
|
|
|
3156
3361
|
import { isAddress as isAddress2, formatEther as formatEther4, formatUnits } from "viem";
|
|
3157
3362
|
|
|
3158
3363
|
// src/utils/erc20.ts
|
|
3159
|
-
import { encodeFunctionData, parseUnits } from "viem";
|
|
3364
|
+
import { encodeFunctionData as encodeFunctionData2, parseUnits } from "viem";
|
|
3160
3365
|
var ERC20_ABI = [
|
|
3161
3366
|
{
|
|
3162
3367
|
name: "transfer",
|
|
@@ -3233,7 +3438,7 @@ function registerQueryCommand(program2, ctx) {
|
|
|
3233
3438
|
getTokenBalance(ctx.walletClient, opts.token, accountInfo.address)
|
|
3234
3439
|
]);
|
|
3235
3440
|
spinner.stop();
|
|
3236
|
-
|
|
3441
|
+
outputResult({
|
|
3237
3442
|
account: accountInfo.alias,
|
|
3238
3443
|
address: accountInfo.address,
|
|
3239
3444
|
chain: chainConfig.name,
|
|
@@ -3245,7 +3450,7 @@ function registerQueryCommand(program2, ctx) {
|
|
|
3245
3450
|
} else {
|
|
3246
3451
|
const { ether } = await ctx.walletClient.getBalance(accountInfo.address);
|
|
3247
3452
|
spinner.stop();
|
|
3248
|
-
|
|
3453
|
+
outputResult({
|
|
3249
3454
|
account: accountInfo.alias,
|
|
3250
3455
|
address: accountInfo.address,
|
|
3251
3456
|
chain: chainConfig.name,
|
|
@@ -3265,20 +3470,25 @@ function registerQueryCommand(program2, ctx) {
|
|
|
3265
3470
|
const rawBalances = await ctx.walletClient.getTokenBalances(accountInfo.address);
|
|
3266
3471
|
if (rawBalances.length === 0) {
|
|
3267
3472
|
spinner.stop();
|
|
3268
|
-
|
|
3269
|
-
|
|
3473
|
+
outputResult({
|
|
3474
|
+
account: accountInfo.alias,
|
|
3475
|
+
address: accountInfo.address,
|
|
3476
|
+
chain: chainConfig.name,
|
|
3477
|
+
tokens: [],
|
|
3478
|
+
total: 0
|
|
3479
|
+
});
|
|
3270
3480
|
return;
|
|
3271
3481
|
}
|
|
3272
3482
|
spinner.text = `Fetching metadata for ${rawBalances.length} tokens...`;
|
|
3273
3483
|
const tokens = await Promise.all(
|
|
3274
3484
|
rawBalances.map(async ({ tokenAddress, balance }) => {
|
|
3275
3485
|
try {
|
|
3276
|
-
const
|
|
3486
|
+
const info = await getTokenInfo(ctx.walletClient, tokenAddress);
|
|
3277
3487
|
return {
|
|
3278
3488
|
address: tokenAddress,
|
|
3279
|
-
symbol:
|
|
3280
|
-
decimals:
|
|
3281
|
-
balance: formatUnits(balance,
|
|
3489
|
+
symbol: info.symbol,
|
|
3490
|
+
decimals: info.decimals,
|
|
3491
|
+
balance: formatUnits(balance, info.decimals),
|
|
3282
3492
|
rawBalance: balance
|
|
3283
3493
|
};
|
|
3284
3494
|
} catch {
|
|
@@ -3293,25 +3503,18 @@ function registerQueryCommand(program2, ctx) {
|
|
|
3293
3503
|
})
|
|
3294
3504
|
);
|
|
3295
3505
|
spinner.stop();
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
table(
|
|
3302
|
-
tokens.map((t) => ({
|
|
3506
|
+
outputResult({
|
|
3507
|
+
account: accountInfo.alias,
|
|
3508
|
+
address: accountInfo.address,
|
|
3509
|
+
chain: chainConfig.name,
|
|
3510
|
+
tokens: tokens.map((t) => ({
|
|
3303
3511
|
address: t.address,
|
|
3304
3512
|
symbol: t.symbol,
|
|
3305
|
-
decimals:
|
|
3513
|
+
decimals: t.decimals,
|
|
3306
3514
|
balance: t.balance
|
|
3307
3515
|
})),
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
{ key: "symbol", label: "Symbol", width: 10 },
|
|
3311
|
-
{ key: "decimals", label: "Decimals", width: 10 },
|
|
3312
|
-
{ key: "balance", label: "Balance", width: 24 }
|
|
3313
|
-
]
|
|
3314
|
-
);
|
|
3516
|
+
total: tokens.length
|
|
3517
|
+
});
|
|
3315
3518
|
} catch (err) {
|
|
3316
3519
|
outputError(-32e3, sanitizeErrorMessage(err.message));
|
|
3317
3520
|
}
|
|
@@ -3337,7 +3540,7 @@ function registerQueryCommand(program2, ctx) {
|
|
|
3337
3540
|
return;
|
|
3338
3541
|
}
|
|
3339
3542
|
spinner.stop();
|
|
3340
|
-
|
|
3543
|
+
outputResult({
|
|
3341
3544
|
hash: receipt.transactionHash,
|
|
3342
3545
|
status: receipt.status,
|
|
3343
3546
|
block: receipt.blockNumber.toString(),
|
|
@@ -3360,7 +3563,7 @@ function registerQueryCommand(program2, ctx) {
|
|
|
3360
3563
|
ctx.walletClient.getGasPrice()
|
|
3361
3564
|
]);
|
|
3362
3565
|
spinner.stop();
|
|
3363
|
-
|
|
3566
|
+
outputResult({
|
|
3364
3567
|
chainId: chainConfig.id,
|
|
3365
3568
|
name: chainConfig.name,
|
|
3366
3569
|
nativeCurrency: chainConfig.nativeCurrency.symbol,
|
|
@@ -3390,7 +3593,7 @@ function registerQueryCommand(program2, ctx) {
|
|
|
3390
3593
|
const isContract = !!code && code !== "0x";
|
|
3391
3594
|
const codeSize = isContract ? (code.length - 2) / 2 : 0;
|
|
3392
3595
|
spinner.stop();
|
|
3393
|
-
|
|
3596
|
+
outputResult({
|
|
3394
3597
|
address: addr,
|
|
3395
3598
|
chain: chainConfig.name,
|
|
3396
3599
|
type: isContract ? "contract" : "EOA",
|
|
@@ -3431,53 +3634,15 @@ function resolveCurrentChain(ctx) {
|
|
|
3431
3634
|
function isHex66(s) {
|
|
3432
3635
|
return /^0x[0-9a-fA-F]{64}$/.test(s);
|
|
3433
3636
|
}
|
|
3434
|
-
function outputSuccess(result) {
|
|
3435
|
-
console.log(JSON.stringify({ success: true, result }, null, 2));
|
|
3436
|
-
}
|
|
3437
|
-
function outputError(code, message, data) {
|
|
3438
|
-
txError({ code, message, data });
|
|
3439
|
-
process.exitCode = 1;
|
|
3440
|
-
}
|
|
3441
3637
|
|
|
3442
3638
|
// src/commands/security.ts
|
|
3443
3639
|
import ora5 from "ora";
|
|
3444
|
-
|
|
3445
|
-
// src/utils/contracts/securityHook.ts
|
|
3446
|
-
import { encodeFunctionData as encodeFunctionData2, parseAbi as parseAbi2, pad, toHex as toHex6 } from "viem";
|
|
3447
|
-
function encodeInstallHook(walletAddress, hookAddress, safetyDelay = DEFAULT_SAFETY_DELAY, capabilityFlags = DEFAULT_CAPABILITY) {
|
|
3448
|
-
const safetyDelayHex = pad(toHex6(safetyDelay), { size: 4 }).slice(2);
|
|
3449
|
-
const hookAndData = hookAddress + safetyDelayHex;
|
|
3450
|
-
const callData = encodeFunctionData2({
|
|
3451
|
-
abi: parseAbi2(["function installHook(bytes calldata hookAndData, uint8 capabilityFlags)"]),
|
|
3452
|
-
functionName: "installHook",
|
|
3453
|
-
args: [hookAndData, capabilityFlags]
|
|
3454
|
-
});
|
|
3455
|
-
return { to: walletAddress, value: "0", data: callData };
|
|
3456
|
-
}
|
|
3457
|
-
function encodeUninstallHook(walletAddress, hookAddress) {
|
|
3458
|
-
const callData = encodeFunctionData2({
|
|
3459
|
-
abi: parseAbi2(["function uninstallHook(address)"]),
|
|
3460
|
-
functionName: "uninstallHook",
|
|
3461
|
-
args: [hookAddress]
|
|
3462
|
-
});
|
|
3463
|
-
return { to: walletAddress, value: "0", data: callData };
|
|
3464
|
-
}
|
|
3465
|
-
function encodeForcePreUninstall(hookAddress) {
|
|
3466
|
-
const callData = encodeFunctionData2({
|
|
3467
|
-
abi: parseAbi2(["function forcePreUninstall()"]),
|
|
3468
|
-
functionName: "forcePreUninstall",
|
|
3469
|
-
args: []
|
|
3470
|
-
});
|
|
3471
|
-
return { to: hookAddress, value: "0", data: callData };
|
|
3472
|
-
}
|
|
3473
|
-
|
|
3474
|
-
// src/commands/security.ts
|
|
3475
|
-
var ERR_ACCOUNT_NOT_READY2 = -32002;
|
|
3640
|
+
var ERR_ACCOUNT_NOT_READY3 = -32002;
|
|
3476
3641
|
var ERR_HOOK_AUTH_FAILED = -32007;
|
|
3477
3642
|
var ERR_EMAIL_NOT_BOUND = -32010;
|
|
3478
3643
|
var ERR_SAFETY_DELAY = -32011;
|
|
3479
3644
|
var ERR_OTP_VERIFY_FAILED = -32012;
|
|
3480
|
-
var
|
|
3645
|
+
var ERR_INTERNAL3 = -32e3;
|
|
3481
3646
|
var SecurityError = class extends Error {
|
|
3482
3647
|
code;
|
|
3483
3648
|
data;
|
|
@@ -3490,33 +3655,32 @@ var SecurityError = class extends Error {
|
|
|
3490
3655
|
};
|
|
3491
3656
|
function handleSecurityError(err) {
|
|
3492
3657
|
if (err instanceof SecurityError) {
|
|
3493
|
-
|
|
3658
|
+
outputError(err.code, err.message, err.data);
|
|
3494
3659
|
} else {
|
|
3495
|
-
|
|
3496
|
-
code: ERR_INTERNAL2,
|
|
3497
|
-
message: sanitizeErrorMessage(err.message ?? String(err))
|
|
3498
|
-
});
|
|
3660
|
+
outputError(ERR_INTERNAL3, err.message ?? String(err));
|
|
3499
3661
|
}
|
|
3500
|
-
process.exitCode = 1;
|
|
3501
3662
|
}
|
|
3502
3663
|
function initSecurityContext(ctx) {
|
|
3503
|
-
if (!ctx.
|
|
3504
|
-
throw new SecurityError(
|
|
3664
|
+
if (!ctx.keyring.isUnlocked) {
|
|
3665
|
+
throw new SecurityError(
|
|
3666
|
+
ERR_ACCOUNT_NOT_READY3,
|
|
3667
|
+
"Keyring is locked. Run `elytro init` to initialize, or check your secret provider (Keychain / ELYTRO_VAULT_SECRET)."
|
|
3668
|
+
);
|
|
3505
3669
|
}
|
|
3506
3670
|
const current = ctx.account.currentAccount;
|
|
3507
3671
|
if (!current) {
|
|
3508
|
-
throw new SecurityError(
|
|
3672
|
+
throw new SecurityError(ERR_ACCOUNT_NOT_READY3, "No account selected. Run `elytro account create` first.");
|
|
3509
3673
|
}
|
|
3510
3674
|
const account = ctx.account.resolveAccount(current.alias ?? current.address);
|
|
3511
3675
|
if (!account) {
|
|
3512
|
-
throw new SecurityError(
|
|
3676
|
+
throw new SecurityError(ERR_ACCOUNT_NOT_READY3, "Account not found.");
|
|
3513
3677
|
}
|
|
3514
3678
|
if (!account.isDeployed) {
|
|
3515
|
-
throw new SecurityError(
|
|
3679
|
+
throw new SecurityError(ERR_ACCOUNT_NOT_READY3, "Account not deployed. Run `elytro account activate` first.");
|
|
3516
3680
|
}
|
|
3517
3681
|
const chainConfig = ctx.chain.chains.find((c) => c.id === account.chainId);
|
|
3518
3682
|
if (!chainConfig) {
|
|
3519
|
-
throw new SecurityError(
|
|
3683
|
+
throw new SecurityError(ERR_ACCOUNT_NOT_READY3, `No chain config for chainId ${account.chainId}.`);
|
|
3520
3684
|
}
|
|
3521
3685
|
ctx.walletClient.initForChain(chainConfig);
|
|
3522
3686
|
const hookService = new SecurityHookService({
|
|
@@ -3575,14 +3739,8 @@ async function signAndSend(ctx, chainConfig, userOp, spinner) {
|
|
|
3575
3739
|
spinner.text = "Waiting for receipt...";
|
|
3576
3740
|
const receipt = await ctx.sdk.waitForReceipt(opHash);
|
|
3577
3741
|
spinner.stop();
|
|
3578
|
-
if (receipt.success) {
|
|
3579
|
-
|
|
3580
|
-
info("Tx Hash", receipt.transactionHash);
|
|
3581
|
-
if (chainConfig.blockExplorer) {
|
|
3582
|
-
info("Explorer", `${chainConfig.blockExplorer}/tx/${receipt.transactionHash}`);
|
|
3583
|
-
}
|
|
3584
|
-
} else {
|
|
3585
|
-
throw new SecurityError(ERR_INTERNAL2, "Transaction reverted on-chain.", {
|
|
3742
|
+
if (!receipt.success) {
|
|
3743
|
+
throw new SecurityError(ERR_INTERNAL3, "Transaction reverted on-chain.", {
|
|
3586
3744
|
txHash: receipt.transactionHash
|
|
3587
3745
|
});
|
|
3588
3746
|
}
|
|
@@ -3611,14 +3769,8 @@ async function signWithHookAndSend(ctx, chainConfig, account, hookService, userO
|
|
|
3611
3769
|
spinner.text = "Waiting for receipt...";
|
|
3612
3770
|
const receipt = await ctx.sdk.waitForReceipt(opHash);
|
|
3613
3771
|
spinner.stop();
|
|
3614
|
-
if (receipt.success) {
|
|
3615
|
-
|
|
3616
|
-
info("Tx Hash", receipt.transactionHash);
|
|
3617
|
-
if (chainConfig.blockExplorer) {
|
|
3618
|
-
info("Explorer", `${chainConfig.blockExplorer}/tx/${receipt.transactionHash}`);
|
|
3619
|
-
}
|
|
3620
|
-
} else {
|
|
3621
|
-
throw new SecurityError(ERR_INTERNAL2, "Transaction reverted on-chain.", {
|
|
3772
|
+
if (!receipt.success) {
|
|
3773
|
+
throw new SecurityError(ERR_INTERNAL3, "Transaction reverted on-chain.", {
|
|
3622
3774
|
txHash: receipt.transactionHash
|
|
3623
3775
|
});
|
|
3624
3776
|
}
|
|
@@ -3629,14 +3781,12 @@ async function handleOtpChallenge(hookService, account, ctx, userOp, hookResult)
|
|
|
3629
3781
|
if (errCode !== "OTP_REQUIRED" && errCode !== "SPENDING_LIMIT_EXCEEDED") {
|
|
3630
3782
|
throw new SecurityError(ERR_HOOK_AUTH_FAILED, `Hook authorization failed: ${err.message ?? errCode}`);
|
|
3631
3783
|
}
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
info("Daily limit", `$${((err.dailyLimitUsdCents ?? 0) / 100).toFixed(2)}`);
|
|
3639
|
-
}
|
|
3784
|
+
console.error(JSON.stringify({
|
|
3785
|
+
challenge: errCode,
|
|
3786
|
+
message: err.message ?? `Verification required (${errCode}).`,
|
|
3787
|
+
...err.maskedEmail ? { maskedEmail: err.maskedEmail } : {},
|
|
3788
|
+
...errCode === "SPENDING_LIMIT_EXCEEDED" && err.projectedSpendUsdCents !== void 0 ? { projectedSpendUsd: (err.projectedSpendUsdCents / 100).toFixed(2), dailyLimitUsd: ((err.dailyLimitUsdCents ?? 0) / 100).toFixed(2) } : {}
|
|
3789
|
+
}, null, 2));
|
|
3640
3790
|
const otpCode = await askInput("Enter the 6-digit OTP code:");
|
|
3641
3791
|
const verifySpinner = ora5("Verifying OTP...").start();
|
|
3642
3792
|
try {
|
|
@@ -3676,37 +3826,34 @@ function registerSecurityCommand(program2, ctx) {
|
|
|
3676
3826
|
} catch {
|
|
3677
3827
|
}
|
|
3678
3828
|
spinner.stop();
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
hookStatus.capabilities.preUserOpValidation
|
|
3689
|
-
hookStatus.capabilities.preIsValidSignature
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
}
|
|
3706
|
-
}
|
|
3707
|
-
console.log("");
|
|
3708
|
-
warn("Security profile not loaded (not yet authenticated or email not bound).");
|
|
3709
|
-
}
|
|
3829
|
+
outputResult({
|
|
3830
|
+
account: account.alias,
|
|
3831
|
+
address: account.address,
|
|
3832
|
+
chain: chainConfig.name,
|
|
3833
|
+
chainId: account.chainId,
|
|
3834
|
+
hookInstalled: hookStatus.installed,
|
|
3835
|
+
...hookStatus.installed ? {
|
|
3836
|
+
hookAddress: hookStatus.hookAddress,
|
|
3837
|
+
capabilities: {
|
|
3838
|
+
preUserOpValidation: hookStatus.capabilities.preUserOpValidation,
|
|
3839
|
+
preIsValidSignature: hookStatus.capabilities.preIsValidSignature
|
|
3840
|
+
},
|
|
3841
|
+
...hookStatus.forceUninstall.initiated ? {
|
|
3842
|
+
forceUninstall: {
|
|
3843
|
+
initiated: true,
|
|
3844
|
+
canExecute: hookStatus.forceUninstall.canExecute,
|
|
3845
|
+
availableAfter: hookStatus.forceUninstall.availableAfter
|
|
3846
|
+
}
|
|
3847
|
+
} : {}
|
|
3848
|
+
} : {},
|
|
3849
|
+
...profile ? {
|
|
3850
|
+
profile: {
|
|
3851
|
+
email: profile.maskedEmail ?? profile.email ?? null,
|
|
3852
|
+
emailVerified: profile.emailVerified,
|
|
3853
|
+
...profile.dailyLimitUsdCents !== void 0 ? { dailyLimitUsd: (profile.dailyLimitUsdCents / 100).toFixed(2) } : {}
|
|
3854
|
+
}
|
|
3855
|
+
} : {}
|
|
3856
|
+
});
|
|
3710
3857
|
} catch (err) {
|
|
3711
3858
|
handleSecurityError(err);
|
|
3712
3859
|
}
|
|
@@ -3724,25 +3871,22 @@ function registerSecurityCommand(program2, ctx) {
|
|
|
3724
3871
|
const currentStatus = await hookService.getHookStatus(account.address, account.chainId);
|
|
3725
3872
|
spinner.stop();
|
|
3726
3873
|
if (currentStatus.installed) {
|
|
3727
|
-
|
|
3874
|
+
outputResult({ status: "already_installed", account: account.alias, address: account.address });
|
|
3728
3875
|
return;
|
|
3729
3876
|
}
|
|
3730
3877
|
const hookAddress = SECURITY_HOOK_ADDRESS_MAP[account.chainId];
|
|
3731
3878
|
if (!hookAddress) {
|
|
3732
|
-
throw new SecurityError(
|
|
3879
|
+
throw new SecurityError(ERR_INTERNAL3, `SecurityHook not deployed on chain ${account.chainId}.`);
|
|
3733
3880
|
}
|
|
3734
3881
|
const capabilityFlags = Number(opts.capability);
|
|
3735
3882
|
if (![1, 2, 3].includes(capabilityFlags)) {
|
|
3736
|
-
throw new SecurityError(
|
|
3737
|
-
}
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
info("Capability", CAPABILITY_LABELS[capabilityFlags]);
|
|
3742
|
-
info("Safety Delay", `${DEFAULT_SAFETY_DELAY}s`);
|
|
3743
|
-
const confirmed = await askConfirm("Proceed with hook installation?");
|
|
3883
|
+
throw new SecurityError(ERR_INTERNAL3, "Invalid capability flags. Use 1, 2, or 3.");
|
|
3884
|
+
}
|
|
3885
|
+
const confirmed = await askConfirm(
|
|
3886
|
+
`Install SecurityHook on ${account.alias} (${address(account.address)})? Capability: ${CAPABILITY_LABELS[capabilityFlags]}, Safety Delay: ${DEFAULT_SAFETY_DELAY}s`
|
|
3887
|
+
);
|
|
3744
3888
|
if (!confirmed) {
|
|
3745
|
-
|
|
3889
|
+
outputResult({ status: "cancelled" });
|
|
3746
3890
|
return;
|
|
3747
3891
|
}
|
|
3748
3892
|
const installTx = encodeInstallHook(account.address, hookAddress, DEFAULT_SAFETY_DELAY, capabilityFlags);
|
|
@@ -3750,7 +3894,14 @@ function registerSecurityCommand(program2, ctx) {
|
|
|
3750
3894
|
try {
|
|
3751
3895
|
const userOp = await buildUserOp2(ctx, chainConfig, account, [installTx], buildSpinner);
|
|
3752
3896
|
await signAndSend(ctx, chainConfig, userOp, buildSpinner);
|
|
3753
|
-
|
|
3897
|
+
outputResult({
|
|
3898
|
+
status: "installed",
|
|
3899
|
+
account: account.alias,
|
|
3900
|
+
address: account.address,
|
|
3901
|
+
hookAddress,
|
|
3902
|
+
capability: CAPABILITY_LABELS[capabilityFlags],
|
|
3903
|
+
safetyDelay: DEFAULT_SAFETY_DELAY
|
|
3904
|
+
});
|
|
3754
3905
|
} catch (innerErr) {
|
|
3755
3906
|
buildSpinner.stop();
|
|
3756
3907
|
throw innerErr;
|
|
@@ -3767,7 +3918,7 @@ function registerSecurityCommand(program2, ctx) {
|
|
|
3767
3918
|
const currentStatus = await hookService.getHookStatus(account.address, account.chainId);
|
|
3768
3919
|
spinner.stop();
|
|
3769
3920
|
if (!currentStatus.installed) {
|
|
3770
|
-
|
|
3921
|
+
outputResult({ status: "not_installed", account: account.alias, address: account.address });
|
|
3771
3922
|
return;
|
|
3772
3923
|
}
|
|
3773
3924
|
const hookAddress = currentStatus.hookAddress;
|
|
@@ -3796,8 +3947,7 @@ function registerSecurityCommand(program2, ctx) {
|
|
|
3796
3947
|
throw new SecurityError(ERR_HOOK_AUTH_FAILED, sanitizeErrorMessage(err.message));
|
|
3797
3948
|
}
|
|
3798
3949
|
spinner.stop();
|
|
3799
|
-
|
|
3800
|
-
info("Expires at", bindingResult.otpExpiresAt);
|
|
3950
|
+
console.error(JSON.stringify({ otpSentTo: bindingResult.maskedEmail, expiresAt: bindingResult.otpExpiresAt }, null, 2));
|
|
3801
3951
|
const otpCode = await askInput("Enter the 6-digit OTP code:");
|
|
3802
3952
|
const confirmSpinner = ora5("Confirming email binding...").start();
|
|
3803
3953
|
try {
|
|
@@ -3808,9 +3958,11 @@ function registerSecurityCommand(program2, ctx) {
|
|
|
3808
3958
|
otpCode.trim()
|
|
3809
3959
|
);
|
|
3810
3960
|
confirmSpinner.stop();
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3961
|
+
outputResult({
|
|
3962
|
+
status: "email_bound",
|
|
3963
|
+
email: profile.maskedEmail ?? profile.email ?? emailAddr,
|
|
3964
|
+
emailVerified: profile.emailVerified
|
|
3965
|
+
});
|
|
3814
3966
|
} catch (err) {
|
|
3815
3967
|
confirmSpinner.stop();
|
|
3816
3968
|
throw new SecurityError(
|
|
@@ -3835,7 +3987,7 @@ function registerSecurityCommand(program2, ctx) {
|
|
|
3835
3987
|
throw new SecurityError(ERR_HOOK_AUTH_FAILED, sanitizeErrorMessage(err.message));
|
|
3836
3988
|
}
|
|
3837
3989
|
spinner.stop();
|
|
3838
|
-
|
|
3990
|
+
console.error(JSON.stringify({ otpSentTo: bindingResult.maskedEmail }, null, 2));
|
|
3839
3991
|
const otpCode = await askInput("Enter the 6-digit OTP code:");
|
|
3840
3992
|
const confirmSpinner = ora5("Confirming email change...").start();
|
|
3841
3993
|
try {
|
|
@@ -3846,8 +3998,10 @@ function registerSecurityCommand(program2, ctx) {
|
|
|
3846
3998
|
otpCode.trim()
|
|
3847
3999
|
);
|
|
3848
4000
|
confirmSpinner.stop();
|
|
3849
|
-
|
|
3850
|
-
|
|
4001
|
+
outputResult({
|
|
4002
|
+
status: "email_changed",
|
|
4003
|
+
email: profile.maskedEmail ?? profile.email ?? emailAddr
|
|
4004
|
+
});
|
|
3851
4005
|
} catch (err) {
|
|
3852
4006
|
confirmSpinner.stop();
|
|
3853
4007
|
throw new SecurityError(
|
|
@@ -3886,11 +4040,9 @@ async function handleForceExecute(ctx, chainConfig, account, currentStatus) {
|
|
|
3886
4040
|
`Safety delay not elapsed. Available after ${currentStatus.forceUninstall.availableAfter}.`
|
|
3887
4041
|
);
|
|
3888
4042
|
}
|
|
3889
|
-
|
|
3890
|
-
info("Account", `${account.alias} (${address(account.address)})`);
|
|
3891
|
-
const confirmed = await askConfirm("Execute force uninstall? This will remove the SecurityHook.");
|
|
4043
|
+
const confirmed = await askConfirm(`Execute force uninstall on ${account.alias} (${address(account.address)})? This will remove the SecurityHook.`);
|
|
3892
4044
|
if (!confirmed) {
|
|
3893
|
-
|
|
4045
|
+
outputResult({ status: "cancelled" });
|
|
3894
4046
|
return;
|
|
3895
4047
|
}
|
|
3896
4048
|
const uninstallTx = encodeUninstallHook(account.address, currentStatus.hookAddress);
|
|
@@ -3898,6 +4050,7 @@ async function handleForceExecute(ctx, chainConfig, account, currentStatus) {
|
|
|
3898
4050
|
try {
|
|
3899
4051
|
const userOp = await buildUserOp2(ctx, chainConfig, account, [uninstallTx], spinner);
|
|
3900
4052
|
await signAndSend(ctx, chainConfig, userOp, spinner);
|
|
4053
|
+
outputResult({ status: "force_uninstalled", account: account.alias, address: account.address });
|
|
3901
4054
|
} catch (err) {
|
|
3902
4055
|
spinner.stop();
|
|
3903
4056
|
throw err;
|
|
@@ -3905,22 +4058,19 @@ async function handleForceExecute(ctx, chainConfig, account, currentStatus) {
|
|
|
3905
4058
|
}
|
|
3906
4059
|
async function handleForceStart(ctx, chainConfig, account, currentStatus, hookAddress) {
|
|
3907
4060
|
if (currentStatus.forceUninstall.initiated) {
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
);
|
|
3915
|
-
}
|
|
4061
|
+
outputResult({
|
|
4062
|
+
status: "already_initiated",
|
|
4063
|
+
canExecute: currentStatus.forceUninstall.canExecute,
|
|
4064
|
+
availableAfter: currentStatus.forceUninstall.availableAfter,
|
|
4065
|
+
hint: currentStatus.forceUninstall.canExecute ? "Run `security 2fa uninstall --force --execute`." : `Wait until ${currentStatus.forceUninstall.availableAfter}.`
|
|
4066
|
+
});
|
|
3916
4067
|
return;
|
|
3917
4068
|
}
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
const confirmed = await askConfirm("Start force-uninstall countdown?");
|
|
4069
|
+
const confirmed = await askConfirm(
|
|
4070
|
+
`Start force-uninstall countdown on ${account.alias} (${address(account.address)})? You must wait ${DEFAULT_SAFETY_DELAY}s before executing.`
|
|
4071
|
+
);
|
|
3922
4072
|
if (!confirmed) {
|
|
3923
|
-
|
|
4073
|
+
outputResult({ status: "cancelled" });
|
|
3924
4074
|
return;
|
|
3925
4075
|
}
|
|
3926
4076
|
const preUninstallTx = encodeForcePreUninstall(hookAddress);
|
|
@@ -3928,17 +4078,21 @@ async function handleForceStart(ctx, chainConfig, account, currentStatus, hookAd
|
|
|
3928
4078
|
try {
|
|
3929
4079
|
const userOp = await buildUserOp2(ctx, chainConfig, account, [preUninstallTx], spinner);
|
|
3930
4080
|
await signAndSend(ctx, chainConfig, userOp, spinner);
|
|
4081
|
+
outputResult({
|
|
4082
|
+
status: "force_uninstall_started",
|
|
4083
|
+
account: account.alias,
|
|
4084
|
+
address: account.address,
|
|
4085
|
+
safetyDelay: DEFAULT_SAFETY_DELAY
|
|
4086
|
+
});
|
|
3931
4087
|
} catch (err) {
|
|
3932
4088
|
spinner.stop();
|
|
3933
4089
|
throw err;
|
|
3934
4090
|
}
|
|
3935
4091
|
}
|
|
3936
4092
|
async function handleNormalUninstall(ctx, chainConfig, account, hookService, hookAddress) {
|
|
3937
|
-
|
|
3938
|
-
info("Account", `${account.alias} (${address(account.address)})`);
|
|
3939
|
-
const confirmed = await askConfirm("Proceed with hook uninstall? (requires 2FA approval)");
|
|
4093
|
+
const confirmed = await askConfirm(`Uninstall SecurityHook from ${account.alias} (${address(account.address)})? (requires 2FA approval)`);
|
|
3940
4094
|
if (!confirmed) {
|
|
3941
|
-
|
|
4095
|
+
outputResult({ status: "cancelled" });
|
|
3942
4096
|
return;
|
|
3943
4097
|
}
|
|
3944
4098
|
const uninstallTx = encodeUninstallHook(account.address, hookAddress);
|
|
@@ -3946,6 +4100,7 @@ async function handleNormalUninstall(ctx, chainConfig, account, hookService, hoo
|
|
|
3946
4100
|
try {
|
|
3947
4101
|
const userOp = await buildUserOp2(ctx, chainConfig, account, [uninstallTx], spinner);
|
|
3948
4102
|
await signWithHookAndSend(ctx, chainConfig, account, hookService, userOp, spinner);
|
|
4103
|
+
outputResult({ status: "uninstalled", account: account.alias, address: account.address });
|
|
3949
4104
|
} catch (err) {
|
|
3950
4105
|
spinner.stop();
|
|
3951
4106
|
throw err;
|
|
@@ -3962,24 +4117,23 @@ async function showSpendingLimit(hookService, account) {
|
|
|
3962
4117
|
}
|
|
3963
4118
|
spinner.stop();
|
|
3964
4119
|
if (!profile) {
|
|
3965
|
-
|
|
4120
|
+
outputResult({
|
|
4121
|
+
status: "no_profile",
|
|
4122
|
+
hint: "Bind an email first: `elytro security email bind <email>`."
|
|
4123
|
+
});
|
|
3966
4124
|
return;
|
|
3967
4125
|
}
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
);
|
|
3973
|
-
info("Email", profile.maskedEmail ?? "Not bound");
|
|
4126
|
+
outputResult({
|
|
4127
|
+
dailyLimitUsd: profile.dailyLimitUsdCents !== void 0 ? (profile.dailyLimitUsdCents / 100).toFixed(2) : null,
|
|
4128
|
+
email: profile.maskedEmail ?? null
|
|
4129
|
+
});
|
|
3974
4130
|
}
|
|
3975
4131
|
async function setSpendingLimit(hookService, account, amountStr) {
|
|
3976
4132
|
const amountUsd = parseFloat(amountStr);
|
|
3977
4133
|
if (isNaN(amountUsd) || amountUsd < 0) {
|
|
3978
|
-
throw new SecurityError(
|
|
4134
|
+
throw new SecurityError(ERR_INTERNAL3, "Invalid amount. Provide a positive number in USD.");
|
|
3979
4135
|
}
|
|
3980
4136
|
const dailyLimitUsdCents = Math.round(amountUsd * 100);
|
|
3981
|
-
heading("Set Daily Spending Limit");
|
|
3982
|
-
info("New Limit", `$${amountUsd.toFixed(2)}`);
|
|
3983
4137
|
const spinner = ora5("Requesting OTP for limit change...").start();
|
|
3984
4138
|
let otpResult;
|
|
3985
4139
|
try {
|
|
@@ -3993,13 +4147,16 @@ async function setSpendingLimit(hookService, account, amountStr) {
|
|
|
3993
4147
|
throw new SecurityError(ERR_HOOK_AUTH_FAILED, sanitizeErrorMessage(msg));
|
|
3994
4148
|
}
|
|
3995
4149
|
spinner.stop();
|
|
3996
|
-
|
|
4150
|
+
console.error(JSON.stringify({ otpSentTo: otpResult.maskedEmail }, null, 2));
|
|
3997
4151
|
const otpCode = await askInput("Enter the 6-digit OTP code:");
|
|
3998
4152
|
const setSpinner = ora5("Setting daily limit...").start();
|
|
3999
4153
|
try {
|
|
4000
4154
|
await hookService.setDailyLimit(account.address, account.chainId, dailyLimitUsdCents, otpCode.trim());
|
|
4001
4155
|
setSpinner.stop();
|
|
4002
|
-
|
|
4156
|
+
outputResult({
|
|
4157
|
+
status: "daily_limit_set",
|
|
4158
|
+
dailyLimitUsd: amountUsd.toFixed(2)
|
|
4159
|
+
});
|
|
4003
4160
|
} catch (err) {
|
|
4004
4161
|
setSpinner.stop();
|
|
4005
4162
|
throw new SecurityError(
|
|
@@ -4022,58 +4179,51 @@ function maskKey(value) {
|
|
|
4022
4179
|
function registerConfigCommand(program2, ctx) {
|
|
4023
4180
|
const configCmd = program2.command("config").description("Manage CLI configuration (API keys, RPC endpoints)");
|
|
4024
4181
|
configCmd.command("show").description("Show current endpoint configuration").action(() => {
|
|
4025
|
-
heading("Configuration");
|
|
4026
4182
|
const keys = ctx.chain.getUserKeys();
|
|
4027
|
-
const hasAlchemy = !!keys.alchemyKey;
|
|
4028
|
-
const hasPimlico = !!keys.pimlicoKey;
|
|
4029
|
-
info("RPC provider", hasAlchemy ? "Alchemy (user-configured)" : "Public (publicnode.com)");
|
|
4030
|
-
info("Bundler provider", hasPimlico ? "Pimlico (user-configured)" : "Public (pimlico.io/public)");
|
|
4031
|
-
if (keys.alchemyKey) {
|
|
4032
|
-
info("Alchemy key", maskKey(keys.alchemyKey));
|
|
4033
|
-
}
|
|
4034
|
-
if (keys.pimlicoKey) {
|
|
4035
|
-
info("Pimlico key", maskKey(keys.pimlicoKey));
|
|
4036
|
-
}
|
|
4037
|
-
console.log("");
|
|
4038
4183
|
const chain = ctx.chain.currentChain;
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4184
|
+
outputResult({
|
|
4185
|
+
rpcProvider: keys.alchemyKey ? "Alchemy (user-configured)" : "Public (publicnode.com)",
|
|
4186
|
+
bundlerProvider: keys.pimlicoKey ? "Pimlico (user-configured)" : "Public (pimlico.io/public)",
|
|
4187
|
+
...keys.alchemyKey ? { alchemyKey: maskKey(keys.alchemyKey) } : {},
|
|
4188
|
+
...keys.pimlicoKey ? { pimlicoKey: maskKey(keys.pimlicoKey) } : {},
|
|
4189
|
+
currentChain: chain.name,
|
|
4190
|
+
chainId: chain.id,
|
|
4191
|
+
rpcEndpoint: maskApiKeys(chain.endpoint),
|
|
4192
|
+
bundler: maskApiKeys(chain.bundler)
|
|
4193
|
+
});
|
|
4048
4194
|
});
|
|
4049
4195
|
configCmd.command("set <key> <value>").description(`Set an API key (${VALID_KEYS.join(" | ")})`).action(async (key, value) => {
|
|
4050
4196
|
const mapped = KEY_MAP[key];
|
|
4051
4197
|
if (!mapped) {
|
|
4052
|
-
|
|
4053
|
-
process.exitCode = 1;
|
|
4198
|
+
outputError(-32602, `Unknown key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
|
|
4054
4199
|
return;
|
|
4055
4200
|
}
|
|
4056
4201
|
await ctx.chain.setUserKey(mapped, value);
|
|
4057
|
-
success(`${key} saved. Endpoints updated.`);
|
|
4058
4202
|
const chain = ctx.chain.currentChain;
|
|
4059
|
-
|
|
4060
|
-
|
|
4203
|
+
outputResult({
|
|
4204
|
+
key,
|
|
4205
|
+
status: "saved",
|
|
4206
|
+
rpcEndpoint: maskApiKeys(chain.endpoint),
|
|
4207
|
+
bundler: maskApiKeys(chain.bundler)
|
|
4208
|
+
});
|
|
4061
4209
|
});
|
|
4062
4210
|
configCmd.command("remove <key>").description(`Remove an API key and revert to public endpoint (${VALID_KEYS.join(" | ")})`).action(async (key) => {
|
|
4063
4211
|
const mapped = KEY_MAP[key];
|
|
4064
4212
|
if (!mapped) {
|
|
4065
|
-
|
|
4066
|
-
process.exitCode = 1;
|
|
4213
|
+
outputError(-32602, `Unknown key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
|
|
4067
4214
|
return;
|
|
4068
4215
|
}
|
|
4069
4216
|
await ctx.chain.removeUserKey(mapped);
|
|
4070
|
-
|
|
4217
|
+
outputResult({
|
|
4218
|
+
key,
|
|
4219
|
+
status: "removed"
|
|
4220
|
+
});
|
|
4071
4221
|
});
|
|
4072
4222
|
}
|
|
4073
4223
|
|
|
4074
4224
|
// src/index.ts
|
|
4075
4225
|
var program = new Command();
|
|
4076
|
-
program.name("elytro").description("Elytro \u2014 ERC-4337 Smart Account Wallet CLI").version("0.0.1");
|
|
4226
|
+
program.name("elytro").description("Elytro \u2014 ERC-4337 Smart Account Wallet CLI").version("0.0.1").addHelpText("after", "\nLearn how to use Elytro skills: https://github.com/Elytro-eth/skills\n");
|
|
4077
4227
|
async function main() {
|
|
4078
4228
|
let ctx = null;
|
|
4079
4229
|
try {
|
|
@@ -4086,8 +4236,7 @@ async function main() {
|
|
|
4086
4236
|
registerConfigCommand(program, ctx);
|
|
4087
4237
|
await program.parseAsync(process.argv);
|
|
4088
4238
|
} catch (err) {
|
|
4089
|
-
|
|
4090
|
-
process.exitCode = 1;
|
|
4239
|
+
outputError(-32e3, sanitizeErrorMessage(err.message));
|
|
4091
4240
|
} finally {
|
|
4092
4241
|
ctx?.keyring.lock();
|
|
4093
4242
|
}
|