ethnotary 1.0.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 +514 -0
- package/cli/commands/account/index.js +855 -0
- package/cli/commands/config/index.js +369 -0
- package/cli/commands/contact/index.js +139 -0
- package/cli/commands/contract/index.js +197 -0
- package/cli/commands/data/index.js +536 -0
- package/cli/commands/tx/index.js +841 -0
- package/cli/commands/wallet/index.js +181 -0
- package/cli/index.js +624 -0
- package/cli/utils/auth.js +146 -0
- package/cli/utils/constants.js +68 -0
- package/cli/utils/contacts.js +131 -0
- package/cli/utils/contracts.js +269 -0
- package/cli/utils/crosschain.js +278 -0
- package/cli/utils/networks.js +335 -0
- package/cli/utils/notifications.js +135 -0
- package/cli/utils/output.js +123 -0
- package/cli/utils/pin.js +89 -0
- package/data/balance.js +680 -0
- package/data/events.js +334 -0
- package/data/pending.js +261 -0
- package/data/scanWorker.js +169 -0
- package/data/token_cache.json +54 -0
- package/data/token_database.json +92 -0
- package/data/tokens.js +380 -0
- package/package.json +57 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
const { ethers } = require('ethers');
|
|
2
|
+
const { getRpcUrl, validateNetwork } = require('./networks');
|
|
3
|
+
const { getContractNetworks, createDecoupledContract } = require('./contracts');
|
|
4
|
+
|
|
5
|
+
const MULTISIG_ABI = [
|
|
6
|
+
"function getOwners() view returns (address[])",
|
|
7
|
+
"function required() view returns (uint)",
|
|
8
|
+
"function isOwner(address) view returns (bool)",
|
|
9
|
+
"function pinHash() view returns (bytes32)",
|
|
10
|
+
"function pinNonce() view returns (uint256)",
|
|
11
|
+
"function addOwner(address accountOwner, uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC) public",
|
|
12
|
+
"function removeOwner(address accountOwner, uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC) public",
|
|
13
|
+
"function replaceOwner(address accountOwner, address newOwner, uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC) public",
|
|
14
|
+
"function changeRequirement(uint _required, uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC) public"
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Fetch account state from all configured networks
|
|
19
|
+
*/
|
|
20
|
+
async function fetchAccountStateAcrossNetworks(aliasOrAddress, address) {
|
|
21
|
+
const networks = getContractNetworks(aliasOrAddress);
|
|
22
|
+
const states = [];
|
|
23
|
+
|
|
24
|
+
for (const networkKey of networks) {
|
|
25
|
+
try {
|
|
26
|
+
const rpc = getRpcUrl(networkKey);
|
|
27
|
+
if (!rpc) continue;
|
|
28
|
+
|
|
29
|
+
const provider = new ethers.JsonRpcProvider(rpc);
|
|
30
|
+
const multisig = new ethers.Contract(address, MULTISIG_ABI, provider);
|
|
31
|
+
|
|
32
|
+
const [owners, required] = await Promise.all([
|
|
33
|
+
multisig.getOwners(),
|
|
34
|
+
multisig.required()
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
states.push({
|
|
38
|
+
network: networkKey,
|
|
39
|
+
owners: owners.map(o => o.toLowerCase()),
|
|
40
|
+
required: Number(required),
|
|
41
|
+
success: true
|
|
42
|
+
});
|
|
43
|
+
} catch (err) {
|
|
44
|
+
states.push({
|
|
45
|
+
network: networkKey,
|
|
46
|
+
error: err.message,
|
|
47
|
+
success: false
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return states;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check if account is in sync across all networks
|
|
57
|
+
* Returns { inSync: boolean, canonical: { owners, required }, discrepancies: [] }
|
|
58
|
+
*/
|
|
59
|
+
function analyzeAccountSync(states) {
|
|
60
|
+
const successfulStates = states.filter(s => s.success);
|
|
61
|
+
|
|
62
|
+
if (successfulStates.length === 0) {
|
|
63
|
+
return { inSync: false, error: 'No networks responded', discrepancies: [] };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (successfulStates.length === 1) {
|
|
67
|
+
return {
|
|
68
|
+
inSync: true,
|
|
69
|
+
canonical: {
|
|
70
|
+
owners: successfulStates[0].owners,
|
|
71
|
+
required: successfulStates[0].required
|
|
72
|
+
},
|
|
73
|
+
discrepancies: []
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Find canonical state (most common configuration)
|
|
78
|
+
const configCounts = {};
|
|
79
|
+
for (const state of successfulStates) {
|
|
80
|
+
const key = JSON.stringify({ owners: state.owners.sort(), required: state.required });
|
|
81
|
+
configCounts[key] = (configCounts[key] || 0) + 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Get the most common configuration
|
|
85
|
+
const sortedConfigs = Object.entries(configCounts).sort((a, b) => b[1] - a[1]);
|
|
86
|
+
const canonicalConfig = JSON.parse(sortedConfigs[0][0]);
|
|
87
|
+
|
|
88
|
+
// Find discrepancies
|
|
89
|
+
const discrepancies = [];
|
|
90
|
+
for (const state of successfulStates) {
|
|
91
|
+
const stateKey = JSON.stringify({ owners: state.owners.sort(), required: state.required });
|
|
92
|
+
const canonicalKey = JSON.stringify({ owners: canonicalConfig.owners.sort(), required: canonicalConfig.required });
|
|
93
|
+
|
|
94
|
+
if (stateKey !== canonicalKey) {
|
|
95
|
+
const missingOwners = canonicalConfig.owners.filter(o => !state.owners.includes(o));
|
|
96
|
+
const extraOwners = state.owners.filter(o => !canonicalConfig.owners.includes(o));
|
|
97
|
+
|
|
98
|
+
discrepancies.push({
|
|
99
|
+
network: state.network,
|
|
100
|
+
currentOwners: state.owners,
|
|
101
|
+
currentRequired: state.required,
|
|
102
|
+
missingOwners,
|
|
103
|
+
extraOwners,
|
|
104
|
+
requirementMismatch: state.required !== canonicalConfig.required
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
inSync: discrepancies.length === 0,
|
|
111
|
+
canonical: canonicalConfig,
|
|
112
|
+
discrepancies,
|
|
113
|
+
networkCount: successfulStates.length
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Handle decoupling when an account operation fails on some networks
|
|
119
|
+
*/
|
|
120
|
+
function handleDecoupling(alias, address, failedNetworks, out) {
|
|
121
|
+
const chalk = require('chalk');
|
|
122
|
+
|
|
123
|
+
if (failedNetworks.length === 0) return;
|
|
124
|
+
|
|
125
|
+
out.warn(`\n⚠️ Account is now out of sync on ${failedNetworks.length} network(s):`);
|
|
126
|
+
|
|
127
|
+
for (const { network, error } of failedNetworks) {
|
|
128
|
+
out.warn(` - ${network}: ${error}`);
|
|
129
|
+
|
|
130
|
+
// Create decoupled contract alias
|
|
131
|
+
const decoupled = createDecoupledContract(alias, address, network);
|
|
132
|
+
out.info(` Created decoupled alias: ${decoupled.alias}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
out.info('\nTo re-sync accounts, run:');
|
|
136
|
+
out.info(chalk.white(` ethnotary account sync --address ${alias}`));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Pre-flight check for account management operations across all networks
|
|
141
|
+
* Estimates gas and checks wallet balance on each network
|
|
142
|
+
* Returns { canProceed, networks: [{ network, gasEstimate, balance, sufficient, error }] }
|
|
143
|
+
*/
|
|
144
|
+
async function preflightAccountOperation(aliasOrAddress, address, walletAddress, operation, operationArgs) {
|
|
145
|
+
const networks = getContractNetworks(aliasOrAddress);
|
|
146
|
+
const results = [];
|
|
147
|
+
let allSufficient = true;
|
|
148
|
+
let hasErrors = false;
|
|
149
|
+
|
|
150
|
+
for (const networkKey of networks) {
|
|
151
|
+
try {
|
|
152
|
+
const rpc = getRpcUrl(networkKey);
|
|
153
|
+
if (!rpc) {
|
|
154
|
+
results.push({
|
|
155
|
+
network: networkKey,
|
|
156
|
+
error: 'No RPC configured',
|
|
157
|
+
sufficient: false
|
|
158
|
+
});
|
|
159
|
+
hasErrors = true;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const provider = new ethers.JsonRpcProvider(rpc);
|
|
164
|
+
const multisig = new ethers.Contract(address, MULTISIG_ABI, provider);
|
|
165
|
+
|
|
166
|
+
// Get wallet balance
|
|
167
|
+
const balance = await provider.getBalance(walletAddress);
|
|
168
|
+
|
|
169
|
+
// Get current gas price
|
|
170
|
+
const feeData = await provider.getFeeData();
|
|
171
|
+
const gasPrice = feeData.gasPrice || feeData.maxFeePerGas || ethers.parseUnits('20', 'gwei');
|
|
172
|
+
|
|
173
|
+
// Use fixed gas estimates for account operations
|
|
174
|
+
// (Can't estimate with mock proof - zkSNARK verifier rejects invalid proofs)
|
|
175
|
+
// These are conservative estimates based on typical gas usage
|
|
176
|
+
let gasEstimate;
|
|
177
|
+
if (operation === 'addOwner' || operation === 'removeOwner') {
|
|
178
|
+
gasEstimate = 350000n; // ~300k typical + buffer
|
|
179
|
+
} else if (operation === 'replaceOwner') {
|
|
180
|
+
gasEstimate = 400000n; // slightly higher for replace
|
|
181
|
+
} else if (operation === 'changeRequirement') {
|
|
182
|
+
gasEstimate = 100000n; // simpler operation
|
|
183
|
+
} else {
|
|
184
|
+
gasEstimate = 350000n; // default
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Verify contract exists on this network
|
|
188
|
+
try {
|
|
189
|
+
const code = await provider.getCode(address);
|
|
190
|
+
if (code === '0x') {
|
|
191
|
+
results.push({
|
|
192
|
+
network: networkKey,
|
|
193
|
+
error: 'Contract not deployed on this network',
|
|
194
|
+
balance: ethers.formatEther(balance),
|
|
195
|
+
sufficient: false
|
|
196
|
+
});
|
|
197
|
+
hasErrors = true;
|
|
198
|
+
allSufficient = false;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
} catch (codeErr) {
|
|
202
|
+
// Continue with balance check even if code check fails
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Add 20% buffer to gas estimate
|
|
206
|
+
const gasWithBuffer = (gasEstimate * 120n) / 100n;
|
|
207
|
+
const estimatedCost = gasWithBuffer * gasPrice;
|
|
208
|
+
const sufficient = balance >= estimatedCost;
|
|
209
|
+
|
|
210
|
+
if (!sufficient) {
|
|
211
|
+
allSufficient = false;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
results.push({
|
|
215
|
+
network: networkKey,
|
|
216
|
+
gasEstimate: Number(gasWithBuffer),
|
|
217
|
+
gasPrice: ethers.formatUnits(gasPrice, 'gwei') + ' gwei',
|
|
218
|
+
estimatedCost: ethers.formatEther(estimatedCost) + ' ETH',
|
|
219
|
+
balance: ethers.formatEther(balance) + ' ETH',
|
|
220
|
+
sufficient,
|
|
221
|
+
error: null
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
} catch (err) {
|
|
225
|
+
results.push({
|
|
226
|
+
network: networkKey,
|
|
227
|
+
error: err.message,
|
|
228
|
+
sufficient: false
|
|
229
|
+
});
|
|
230
|
+
hasErrors = true;
|
|
231
|
+
allSufficient = false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
canProceed: allSufficient && !hasErrors,
|
|
237
|
+
allSufficient,
|
|
238
|
+
hasErrors,
|
|
239
|
+
networks: results
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Display preflight results to user
|
|
245
|
+
*/
|
|
246
|
+
function displayPreflightResults(preflight, out) {
|
|
247
|
+
const chalk = require('chalk');
|
|
248
|
+
|
|
249
|
+
if (!out.json) {
|
|
250
|
+
console.log(chalk.cyan('\n📋 Pre-flight Check Results:\n'));
|
|
251
|
+
|
|
252
|
+
for (const result of preflight.networks) {
|
|
253
|
+
if (result.error) {
|
|
254
|
+
console.log(chalk.red(` ✗ ${result.network}: ${result.error}`));
|
|
255
|
+
} else if (!result.sufficient) {
|
|
256
|
+
console.log(chalk.yellow(` ⚠ ${result.network}: Insufficient balance`));
|
|
257
|
+
console.log(chalk.gray(` Balance: ${result.balance}`));
|
|
258
|
+
console.log(chalk.gray(` Required: ~${result.estimatedCost}`));
|
|
259
|
+
} else {
|
|
260
|
+
console.log(chalk.green(` ✓ ${result.network}: Ready`));
|
|
261
|
+
console.log(chalk.gray(` Balance: ${result.balance}`));
|
|
262
|
+
console.log(chalk.gray(` Est. cost: ~${result.estimatedCost}`));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
console.log('');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return preflight;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
module.exports = {
|
|
272
|
+
MULTISIG_ABI,
|
|
273
|
+
fetchAccountStateAcrossNetworks,
|
|
274
|
+
analyzeAccountSync,
|
|
275
|
+
handleDecoupling,
|
|
276
|
+
preflightAccountOperation,
|
|
277
|
+
displayPreflightResults
|
|
278
|
+
};
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
const ETHNOTARY_DIR = path.join(os.homedir(), '.ethnotary');
|
|
6
|
+
const CONFIG_PATH = path.join(ETHNOTARY_DIR, 'config.json');
|
|
7
|
+
|
|
8
|
+
// Comprehensive network definitions
|
|
9
|
+
const DEFAULT_NETWORKS = {
|
|
10
|
+
// Mainnets
|
|
11
|
+
ethereum: { name: 'Ethereum Mainnet', chainId: 1, testnet: false },
|
|
12
|
+
optimism: { name: 'Optimism', chainId: 10, testnet: false },
|
|
13
|
+
base: { name: 'Base', chainId: 8453, testnet: false },
|
|
14
|
+
arbitrum: { name: 'Arbitrum One', chainId: 42161, testnet: false },
|
|
15
|
+
'arbitrum-nova': { name: 'Arbitrum Nova', chainId: 42170, testnet: false },
|
|
16
|
+
'zksync-era': { name: 'zkSync Era', chainId: 324, testnet: false },
|
|
17
|
+
scroll: { name: 'Scroll', chainId: 534352, testnet: false },
|
|
18
|
+
'polygon-zkevm': { name: 'Polygon zkEVM', chainId: 1101, testnet: false },
|
|
19
|
+
linea: { name: 'Linea', chainId: 59144, testnet: false },
|
|
20
|
+
polygon: { name: 'Polygon PoS', chainId: 137, testnet: false },
|
|
21
|
+
gnosis: { name: 'Gnosis Chain', chainId: 100, testnet: false },
|
|
22
|
+
avalanche: { name: 'Avalanche C-Chain', chainId: 43114, testnet: false },
|
|
23
|
+
celo: { name: 'Celo', chainId: 42220, testnet: false },
|
|
24
|
+
soneium: { name: 'Soneium', chainId: 1868, testnet: false },
|
|
25
|
+
|
|
26
|
+
// Testnets
|
|
27
|
+
sepolia: { name: 'Sepolia', chainId: 11155111, testnet: true },
|
|
28
|
+
'base-sepolia': { name: 'Base Sepolia', chainId: 84532, testnet: true },
|
|
29
|
+
'arbitrum-sepolia': { name: 'Arbitrum Sepolia', chainId: 421614, testnet: true },
|
|
30
|
+
'polygon-mumbai': { name: 'Polygon Mumbai', chainId: 80001, testnet: true },
|
|
31
|
+
'polygon-amoy': { name: 'Polygon Amoy', chainId: 80002, testnet: true },
|
|
32
|
+
'avalanche-fuji': { name: 'Avalanche Fuji', chainId: 43113, testnet: true }
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Build chain ID to network key mapping
|
|
36
|
+
const CHAIN_ID_TO_NETWORK = {};
|
|
37
|
+
for (const [key, config] of Object.entries(DEFAULT_NETWORKS)) {
|
|
38
|
+
CHAIN_ID_TO_NETWORK[config.chainId] = key;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// RPC provider suggestions
|
|
42
|
+
const RPC_PROVIDERS = [
|
|
43
|
+
{ name: 'Infura', url: 'https://infura.io' },
|
|
44
|
+
{ name: 'Alchemy', url: 'https://alchemy.com' },
|
|
45
|
+
{ name: 'QuickNode', url: 'https://quicknode.com' },
|
|
46
|
+
{ name: 'PublicNode', url: 'https://publicnode.com' }
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
// Load config from ~/.ethnotary/config.json
|
|
50
|
+
function loadConfig() {
|
|
51
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
52
|
+
return { networks: {}, rpc: {} };
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
56
|
+
if (!cfg.networks) cfg.networks = {};
|
|
57
|
+
if (!cfg.rpc) cfg.rpc = {};
|
|
58
|
+
return cfg;
|
|
59
|
+
} catch {
|
|
60
|
+
return { networks: {}, rpc: {} };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Save config
|
|
65
|
+
function saveConfig(config) {
|
|
66
|
+
if (!fs.existsSync(ETHNOTARY_DIR)) {
|
|
67
|
+
fs.mkdirSync(ETHNOTARY_DIR, { recursive: true });
|
|
68
|
+
}
|
|
69
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Get RPC URL for a network (config file > env var)
|
|
73
|
+
function getRpcUrl(networkName) {
|
|
74
|
+
const cfg = loadConfig();
|
|
75
|
+
|
|
76
|
+
// Priority 1: Config file
|
|
77
|
+
if (cfg.rpc[networkName]) {
|
|
78
|
+
return cfg.rpc[networkName];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Priority 2: Environment variable (legacy support)
|
|
82
|
+
const envVar = `${networkName.toUpperCase().replace(/-/g, '_')}_RPC_URL`;
|
|
83
|
+
if (process.env[envVar]) {
|
|
84
|
+
return process.env[envVar];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const DEFAULT_NETWORK = 'sepolia';
|
|
91
|
+
|
|
92
|
+
// Resolve network from name or chain ID
|
|
93
|
+
function resolveNetwork(networkOrChainId) {
|
|
94
|
+
const cfg = loadConfig();
|
|
95
|
+
|
|
96
|
+
// Check if it's a chain ID (number)
|
|
97
|
+
const chainId = parseInt(networkOrChainId);
|
|
98
|
+
if (!isNaN(chainId)) {
|
|
99
|
+
// Look up by chain ID
|
|
100
|
+
const networkKey = CHAIN_ID_TO_NETWORK[chainId];
|
|
101
|
+
if (networkKey) {
|
|
102
|
+
return { key: networkKey, config: DEFAULT_NETWORKS[networkKey] };
|
|
103
|
+
}
|
|
104
|
+
// Check custom networks
|
|
105
|
+
for (const [key, config] of Object.entries(cfg.networks || {})) {
|
|
106
|
+
if (config.chainId === chainId) {
|
|
107
|
+
return { key, config };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// It's a network name - normalize it
|
|
114
|
+
const normalized = networkOrChainId.toLowerCase().trim();
|
|
115
|
+
|
|
116
|
+
// Check default networks
|
|
117
|
+
if (DEFAULT_NETWORKS[normalized]) {
|
|
118
|
+
return { key: normalized, config: DEFAULT_NETWORKS[normalized] };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Check custom networks
|
|
122
|
+
if (cfg.networks && cfg.networks[normalized]) {
|
|
123
|
+
return { key: normalized, config: cfg.networks[normalized] };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Validate network and return helpful error if not found
|
|
130
|
+
function validateNetwork(networkOrChainId) {
|
|
131
|
+
const resolved = resolveNetwork(networkOrChainId);
|
|
132
|
+
|
|
133
|
+
if (!resolved) {
|
|
134
|
+
const chalk = require('chalk');
|
|
135
|
+
const allNetworks = { ...DEFAULT_NETWORKS, ...loadConfig().networks };
|
|
136
|
+
const networkList = Object.entries(allNetworks)
|
|
137
|
+
.map(([key, cfg]) => ` ${key} (${cfg.chainId})`)
|
|
138
|
+
.join('\n');
|
|
139
|
+
|
|
140
|
+
console.log(chalk.red(`\n✗ Unknown network: "${networkOrChainId}"`));
|
|
141
|
+
console.log(chalk.yellow('\nPlease double-check the network name or chain ID.'));
|
|
142
|
+
console.log(chalk.gray('\nSupported networks:'));
|
|
143
|
+
console.log(chalk.gray(networkList));
|
|
144
|
+
console.log(chalk.cyan('\nTo add a custom network:'));
|
|
145
|
+
console.log(chalk.white(` ethnotary config network <name> --chain-id <id> --rpc <url>`));
|
|
146
|
+
console.log(chalk.cyan('\nTo request support for a new network:'));
|
|
147
|
+
console.log(chalk.white(' https://github.com/ethnotary/cli/issues/new'));
|
|
148
|
+
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return resolved;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Ensure RPC is configured for a network, prompt if missing
|
|
156
|
+
async function ensureRpcConfigured(networkKey, networkConfig, options = {}) {
|
|
157
|
+
const rpc = getRpcUrl(networkKey);
|
|
158
|
+
|
|
159
|
+
if (rpc) {
|
|
160
|
+
return rpc;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// In JSON mode, fail instead of prompting
|
|
164
|
+
if (options.json) {
|
|
165
|
+
const chalk = require('chalk');
|
|
166
|
+
console.log(chalk.red(`\n✗ No RPC URL configured for ${networkConfig.name}`));
|
|
167
|
+
console.log(chalk.cyan('\nTo configure an RPC URL, run:'));
|
|
168
|
+
console.log(chalk.white(` ethnotary config rpc ${networkKey} --url <your-rpc-url>`));
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Interactive prompt
|
|
173
|
+
return await promptForRpc(networkKey, networkConfig);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Prompt for RPC URL if not configured
|
|
177
|
+
async function promptForRpc(networkName, networkConfig) {
|
|
178
|
+
const inquirer = require('inquirer');
|
|
179
|
+
const chalk = require('chalk');
|
|
180
|
+
|
|
181
|
+
console.log(chalk.yellow(`\n⚠️ No RPC URL configured for ${networkConfig.name}.`));
|
|
182
|
+
console.log(chalk.cyan('\nTo configure an RPC URL, run:'));
|
|
183
|
+
console.log(chalk.white(` ethnotary config rpc ${networkName} --url <your-rpc-url>`));
|
|
184
|
+
console.log(chalk.gray('\nOr run interactively:'));
|
|
185
|
+
console.log(chalk.white(` ethnotary config rpc ${networkName}`));
|
|
186
|
+
console.log(chalk.gray('\nWhere to get RPC URLs:'));
|
|
187
|
+
for (const provider of RPC_PROVIDERS) {
|
|
188
|
+
console.log(chalk.gray(` • ${provider.name}: ${provider.url}`));
|
|
189
|
+
}
|
|
190
|
+
console.log('');
|
|
191
|
+
|
|
192
|
+
const { rpcUrl } = await inquirer.prompt([{
|
|
193
|
+
type: 'input',
|
|
194
|
+
name: 'rpcUrl',
|
|
195
|
+
message: `Enter RPC URL for ${networkConfig.name}:`,
|
|
196
|
+
validate: (input) => {
|
|
197
|
+
if (!input.startsWith('http://') && !input.startsWith('https://')) {
|
|
198
|
+
return 'RPC URL must start with http:// or https://';
|
|
199
|
+
}
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
}]);
|
|
203
|
+
|
|
204
|
+
// Save to config file
|
|
205
|
+
const cfg = loadConfig();
|
|
206
|
+
cfg.rpc[networkName] = rpcUrl;
|
|
207
|
+
saveConfig(cfg);
|
|
208
|
+
console.log(chalk.green(`✓ RPC URL saved to ~/.ethnotary/config.json\n`));
|
|
209
|
+
|
|
210
|
+
return rpcUrl;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function getNetwork(networkName) {
|
|
214
|
+
const cfg = loadConfig();
|
|
215
|
+
|
|
216
|
+
// Get network config (custom > default)
|
|
217
|
+
const networkConfig = cfg.networks[networkName] || DEFAULT_NETWORKS[networkName];
|
|
218
|
+
if (!networkConfig) {
|
|
219
|
+
const available = [...Object.keys(DEFAULT_NETWORKS), ...Object.keys(cfg.networks)];
|
|
220
|
+
throw new Error(`Unknown network: ${networkName}. Available: ${[...new Set(available)].join(', ')}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let rpc = getRpcUrl(networkName);
|
|
224
|
+
|
|
225
|
+
// If no RPC configured, prompt for one
|
|
226
|
+
if (!rpc) {
|
|
227
|
+
rpc = await promptForRpc(networkName, networkConfig);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
name: networkConfig.name,
|
|
232
|
+
rpc,
|
|
233
|
+
chainId: networkConfig.chainId
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function listNetworks() {
|
|
238
|
+
const cfg = loadConfig();
|
|
239
|
+
const allNetworks = { ...DEFAULT_NETWORKS, ...cfg.networks };
|
|
240
|
+
|
|
241
|
+
return Object.entries(allNetworks).map(([key, value]) => ({
|
|
242
|
+
key,
|
|
243
|
+
name: value.name,
|
|
244
|
+
chainId: value.chainId,
|
|
245
|
+
configured: !!getRpcUrl(key)
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Parse multiple networks from comma-separated string (names or chain IDs)
|
|
250
|
+
function parseNetworks(networkString) {
|
|
251
|
+
if (!networkString) return [];
|
|
252
|
+
|
|
253
|
+
const parts = networkString.split(',').map(s => s.trim()).filter(Boolean);
|
|
254
|
+
const results = [];
|
|
255
|
+
const errors = [];
|
|
256
|
+
|
|
257
|
+
for (const part of parts) {
|
|
258
|
+
const resolved = resolveNetwork(part);
|
|
259
|
+
if (resolved) {
|
|
260
|
+
results.push(resolved);
|
|
261
|
+
} else {
|
|
262
|
+
errors.push(part);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return { networks: results, errors };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Validate multiple networks and show helpful errors
|
|
270
|
+
function validateNetworks(networkString) {
|
|
271
|
+
const { networks, errors } = parseNetworks(networkString);
|
|
272
|
+
|
|
273
|
+
if (errors.length > 0) {
|
|
274
|
+
const chalk = require('chalk');
|
|
275
|
+
const allNetworks = { ...DEFAULT_NETWORKS, ...loadConfig().networks };
|
|
276
|
+
const networkList = Object.entries(allNetworks)
|
|
277
|
+
.map(([key, cfg]) => ` ${key} (${cfg.chainId})`)
|
|
278
|
+
.join('\n');
|
|
279
|
+
|
|
280
|
+
console.log(chalk.red(`\n✗ Unknown network(s): ${errors.join(', ')}`));
|
|
281
|
+
console.log(chalk.yellow('\nPlease double-check the network name(s) or chain ID(s).'));
|
|
282
|
+
console.log(chalk.gray('\nSupported networks:'));
|
|
283
|
+
console.log(chalk.gray(networkList));
|
|
284
|
+
console.log(chalk.cyan('\nTo add a custom network:'));
|
|
285
|
+
console.log(chalk.white(` ethnotary config network <name> --chain-id <id> --rpc <url>`));
|
|
286
|
+
console.log(chalk.cyan('\nTo request support for a new network:'));
|
|
287
|
+
console.log(chalk.white(' https://github.com/ethnotary/cli/issues/new'));
|
|
288
|
+
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return networks;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Ensure RPC is configured for multiple networks, prompt for missing ones
|
|
296
|
+
async function ensureRpcsConfigured(networks, options = {}) {
|
|
297
|
+
const chalk = require('chalk');
|
|
298
|
+
const results = [];
|
|
299
|
+
|
|
300
|
+
for (const { key, config } of networks) {
|
|
301
|
+
const rpc = getRpcUrl(key);
|
|
302
|
+
|
|
303
|
+
if (rpc) {
|
|
304
|
+
results.push({ key, config, rpc });
|
|
305
|
+
} else {
|
|
306
|
+
if (options.json) {
|
|
307
|
+
console.log(chalk.red(`\n✗ No RPC URL configured for ${config.name}`));
|
|
308
|
+
console.log(chalk.cyan('\nTo configure an RPC URL, run:'));
|
|
309
|
+
console.log(chalk.white(` ethnotary config rpc ${key} --url <your-rpc-url>`));
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Interactive prompt
|
|
314
|
+
const promptedRpc = await promptForRpc(key, config);
|
|
315
|
+
results.push({ key, config, rpc: promptedRpc });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return results;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
module.exports = {
|
|
323
|
+
DEFAULT_NETWORKS,
|
|
324
|
+
DEFAULT_NETWORK,
|
|
325
|
+
CHAIN_ID_TO_NETWORK,
|
|
326
|
+
getNetwork,
|
|
327
|
+
listNetworks,
|
|
328
|
+
getRpcUrl,
|
|
329
|
+
resolveNetwork,
|
|
330
|
+
validateNetwork,
|
|
331
|
+
ensureRpcConfigured,
|
|
332
|
+
parseNetworks,
|
|
333
|
+
validateNetworks,
|
|
334
|
+
ensureRpcsConfigured
|
|
335
|
+
};
|