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.
@@ -0,0 +1,536 @@
1
+ const { Command } = require('commander');
2
+ const { ethers } = require('ethers');
3
+ const { createOutput } = require('../../utils/output');
4
+ const { getNetwork, getRpcUrl, validateNetwork } = require('../../utils/networks');
5
+ const { resolveAddress, getContract, getContractNetworks } = require('../../utils/contracts');
6
+
7
+ const data = new Command('data')
8
+ .description('Data query commands');
9
+
10
+ /**
11
+ * Get networks to query for a contract
12
+ * If --network is specified, use only that network
13
+ * Otherwise, use all networks the contract is deployed on
14
+ */
15
+ async function getQueryNetworks(aliasOrAddress, specifiedNetwork, out) {
16
+ const contractNetworks = getContractNetworks(aliasOrAddress);
17
+
18
+ if (specifiedNetwork) {
19
+ // Filter to specified network only
20
+ const resolved = validateNetwork(specifiedNetwork);
21
+ if (!resolved) {
22
+ return null;
23
+ }
24
+ if (contractNetworks.length > 0 && !contractNetworks.includes(resolved.key)) {
25
+ out.warn(`Contract is not configured for network: ${specifiedNetwork}`);
26
+ out.info(`Configured networks: ${contractNetworks.join(', ')}`);
27
+ }
28
+ return [resolved.key];
29
+ }
30
+
31
+ // Use all contract networks, or fall back to default
32
+ if (contractNetworks.length > 0) {
33
+ return contractNetworks;
34
+ }
35
+
36
+ // No networks configured - use default
37
+ return ['sepolia'];
38
+ }
39
+
40
+ // data balance - Get portfolio balance across all contract networks
41
+ data
42
+ .command('balance')
43
+ .description('Get account balance across all networks the contract is deployed on')
44
+ .option('--address <address>', 'MultiSig address or alias')
45
+ .option('--network <network>', 'Filter to specific network')
46
+ .action(async (options, command) => {
47
+ const globalOpts = command.parent.parent.opts();
48
+ const out = createOutput(globalOpts);
49
+
50
+ try {
51
+ const address = resolveAddress(options.address);
52
+ const networks = await getQueryNetworks(options.address, options.network || globalOpts.network, out);
53
+
54
+ if (!networks) {
55
+ return;
56
+ }
57
+
58
+ out.startSpinner(`Fetching balance across ${networks.length} network(s)...`);
59
+
60
+ const balances = [];
61
+ let totalEth = 0n;
62
+
63
+ for (const networkKey of networks) {
64
+ try {
65
+ const rpc = getRpcUrl(networkKey);
66
+ if (!rpc) {
67
+ out.warn(`No RPC configured for ${networkKey}, skipping...`);
68
+ continue;
69
+ }
70
+
71
+ const provider = new ethers.JsonRpcProvider(rpc);
72
+ const balance = await provider.getBalance(address);
73
+
74
+ balances.push({
75
+ network: networkKey,
76
+ balance: ethers.formatEther(balance) + ' ETH',
77
+ balanceWei: balance.toString()
78
+ });
79
+
80
+ totalEth += balance;
81
+ } catch (err) {
82
+ out.warn(`Failed to fetch balance on ${networkKey}: ${err.message}`);
83
+ }
84
+ }
85
+
86
+ out.succeedSpinner(`Balance retrieved from ${balances.length} network(s)`);
87
+
88
+ out.print({
89
+ address,
90
+ networks: networks,
91
+ totalBalance: ethers.formatEther(totalEth) + ' ETH',
92
+ balances
93
+ });
94
+
95
+ } catch (error) {
96
+ out.error(error.message);
97
+ }
98
+ });
99
+
100
+ // MultiSig event signatures - complete list
101
+ const MULTISIG_EVENTS_ABI = [
102
+ "event Confirmation(address indexed sender, uint indexed transactionId)",
103
+ "event Revocation(address indexed sender, uint indexed transactionId)",
104
+ "event Submission(uint indexed transactionId, address dest, uint256 value, bytes func)",
105
+ "event ExecutionFailure(uint indexed transactionId)",
106
+ "event Deposit(address sender, uint value)",
107
+ "event OwnerAddition(address indexed owner)",
108
+ "event OwnerRemoval(address indexed owner)",
109
+ "event OwnerReplace(address indexed oldOwner, address indexed newOwner)",
110
+ "event RequirementChange(uint required)",
111
+ "event Delete(uint indexed transactionId, address indexed sender)",
112
+ "event NftReceived(address operator, address from, uint256 tokenId, bytes data)",
113
+ "event Swap(uint indexed transactionId, address indexed swapModule, address indexed executor, uint256 ethValue)",
114
+ "event TokenTransfer(uint indexed transactionId, address indexed assetContract, address indexed to, uint256 amountOrTokenId, address executor, bool isNFT)",
115
+ "event NativeTransfer(uint indexed transactionId, address indexed to, uint256 amount, address executor)",
116
+ "event ContractInteraction(uint indexed transactionId, address indexed target, address indexed executor, uint256 value, bytes data)",
117
+ "event CashOut(uint256 indexed approvalTxId, uint256 indexed transferTxId, address indexed depositAddress, address tokenAddress, uint256 amount, address executor, bool isNative)"
118
+ ];
119
+
120
+ // data events - Get transaction events across all contract networks
121
+ data
122
+ .command('events')
123
+ .description('Get recent transaction events across all networks')
124
+ .option('--address <address>', 'MultiSig address or alias')
125
+ .option('--network <network>', 'Filter to specific network')
126
+ .option('--limit <number>', 'Number of events to fetch per network', parseInt, 10)
127
+ .option('--from-block <block>', 'Start from specific block number', parseInt)
128
+ .action(async (options, command) => {
129
+ const globalOpts = command.parent.parent.opts();
130
+ const out = createOutput(globalOpts);
131
+
132
+ try {
133
+ const address = resolveAddress(options.address);
134
+ const networks = await getQueryNetworks(options.address, options.network || globalOpts.network, out);
135
+
136
+ if (!networks) {
137
+ return;
138
+ }
139
+
140
+ out.startSpinner(`Fetching events across ${networks.length} network(s)...`);
141
+
142
+ const allEvents = [];
143
+ let totalLogs = 0;
144
+
145
+ for (const networkKey of networks) {
146
+ try {
147
+ const rpc = getRpcUrl(networkKey);
148
+ if (!rpc) {
149
+ out.warn(`No RPC configured for ${networkKey}, skipping...`);
150
+ continue;
151
+ }
152
+
153
+ const provider = new ethers.JsonRpcProvider(rpc);
154
+ const multisig = new ethers.Contract(address, MULTISIG_EVENTS_ABI, provider);
155
+
156
+ const fromBlock = options.fromBlock || 0;
157
+ const filter = {
158
+ address: address,
159
+ fromBlock,
160
+ toBlock: 'latest'
161
+ };
162
+
163
+ const logs = await provider.getLogs(filter);
164
+ totalLogs += logs.length;
165
+
166
+ // Take the latest N events per network
167
+ const recentLogs = logs.slice(-options.limit).reverse();
168
+
169
+ for (const log of recentLogs) {
170
+ try {
171
+ const parsed = multisig.interface.parseLog(log);
172
+ if (parsed) {
173
+ const block = await provider.getBlock(log.blockNumber);
174
+
175
+ const args = {};
176
+ const fragment = parsed.fragment;
177
+ for (let i = 0; i < fragment.inputs.length; i++) {
178
+ const input = fragment.inputs[i];
179
+ let value = parsed.args[i];
180
+ if (typeof value === 'bigint') {
181
+ value = value.toString();
182
+ }
183
+ args[input.name] = value;
184
+ }
185
+
186
+ allEvents.push({
187
+ network: networkKey,
188
+ event: parsed.name,
189
+ args,
190
+ blockNumber: log.blockNumber,
191
+ transactionHash: log.transactionHash,
192
+ timestamp: new Date(block.timestamp * 1000).toISOString()
193
+ });
194
+ }
195
+ } catch {}
196
+ }
197
+ } catch (err) {
198
+ out.warn(`Failed to fetch events on ${networkKey}: ${err.message}`);
199
+ }
200
+ }
201
+
202
+ // Sort all events by timestamp (most recent first)
203
+ allEvents.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
204
+
205
+ out.succeedSpinner(`Found ${allEvents.length} events (${totalLogs} total across ${networks.length} networks)`);
206
+
207
+ out.print({
208
+ address,
209
+ networks,
210
+ totalEvents: totalLogs,
211
+ showing: allEvents.length,
212
+ events: allEvents
213
+ });
214
+
215
+ } catch (error) {
216
+ out.error(error.message);
217
+ }
218
+ });
219
+
220
+ // Block explorer API V2 endpoint and chain IDs
221
+ const ETHERSCAN_V2_API = 'https://api.etherscan.io/v2/api';
222
+ const CHAIN_IDS = {
223
+ sepolia: 11155111,
224
+ ethereum: 1,
225
+ 'base-sepolia': 84532,
226
+ base: 8453,
227
+ 'arbitrum-sepolia': 421614,
228
+ arbitrum: 42161
229
+ };
230
+
231
+ const fs = require('fs');
232
+ const path = require('path');
233
+ const os = require('os');
234
+ const ETHNOTARY_DIR = path.join(os.homedir(), '.ethnotary');
235
+ const CONFIG_PATH = path.join(ETHNOTARY_DIR, 'config.json');
236
+
237
+ function loadTokenConfig() {
238
+ if (!fs.existsSync(CONFIG_PATH)) return {};
239
+ try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); } catch { return {}; }
240
+ }
241
+
242
+ function saveTokenConfig(config) {
243
+ if (!fs.existsSync(ETHNOTARY_DIR)) fs.mkdirSync(ETHNOTARY_DIR, { recursive: true });
244
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
245
+ }
246
+
247
+ function getEtherscanApiKey() {
248
+ if (process.env.ETHERSCAN_API_KEY) return process.env.ETHERSCAN_API_KEY;
249
+ const config = loadTokenConfig();
250
+ return config.etherscanApiKey || null;
251
+ }
252
+
253
+ async function ensureEtherscanApiKey() {
254
+ let apiKey = getEtherscanApiKey();
255
+ if (apiKey) return apiKey;
256
+
257
+ const inquirer = require('inquirer');
258
+ const chalk = require('chalk');
259
+
260
+ console.log(chalk.yellow('\n⚠️ No Etherscan API key configured.'));
261
+ console.log(chalk.cyan('\nToken discovery requires an Etherscan API key (free).'));
262
+ console.log(chalk.gray('\nHow to get one:'));
263
+ console.log(chalk.gray(' 1. Go to https://etherscan.io/register'));
264
+ console.log(chalk.gray(' 2. Create a free account'));
265
+ console.log(chalk.gray(' 3. Go to https://etherscan.io/myapikey'));
266
+ console.log(chalk.gray(' 4. Create a new API key'));
267
+ console.log('');
268
+
269
+ const { apiKeyInput } = await inquirer.prompt([{
270
+ type: 'input',
271
+ name: 'apiKeyInput',
272
+ message: 'Enter your Etherscan API key:',
273
+ validate: (input) => input && input.length >= 10 ? true : 'Please enter a valid API key'
274
+ }]);
275
+
276
+ const config = loadTokenConfig();
277
+ config.etherscanApiKey = apiKeyInput;
278
+ saveTokenConfig(config);
279
+ console.log(chalk.green('✓ API key saved to ~/.ethnotary/config.json\n'));
280
+
281
+ return apiKeyInput;
282
+ }
283
+
284
+ const https = require('https');
285
+
286
+ function httpsGet(url) {
287
+ return new Promise((resolve, reject) => {
288
+ https.get(url, (res) => {
289
+ let data = '';
290
+ res.on('data', chunk => data += chunk);
291
+ res.on('end', () => {
292
+ try { resolve(JSON.parse(data)); }
293
+ catch { resolve(null); }
294
+ });
295
+ }).on('error', reject);
296
+ });
297
+ }
298
+
299
+ async function fetchNFTTransfers(address, networkKey, apiKey) {
300
+ const chainId = CHAIN_IDS[networkKey];
301
+ if (!chainId) return [];
302
+
303
+ const url = `${ETHERSCAN_V2_API}?chainid=${chainId}&module=account&action=tokennfttx&address=${address}&page=1&offset=100&sort=desc&apikey=${apiKey}`;
304
+ const data = await httpsGet(url);
305
+
306
+ if (!data || data.status !== '1' || !data.result) return [];
307
+
308
+ // Group by contract and track token IDs currently held
309
+ const nftMap = new Map();
310
+ for (const tx of data.result) {
311
+ const key = tx.contractAddress.toLowerCase();
312
+ if (!nftMap.has(key)) {
313
+ nftMap.set(key, {
314
+ contract: tx.contractAddress,
315
+ symbol: tx.tokenSymbol || 'NFT',
316
+ name: tx.tokenName || 'Unknown NFT',
317
+ tokenIds: new Set()
318
+ });
319
+ }
320
+ // Received
321
+ if (tx.to.toLowerCase() === address.toLowerCase()) {
322
+ nftMap.get(key).tokenIds.add(tx.tokenID);
323
+ }
324
+ // Sent out
325
+ if (tx.from.toLowerCase() === address.toLowerCase()) {
326
+ nftMap.get(key).tokenIds.delete(tx.tokenID);
327
+ }
328
+ }
329
+
330
+ return Array.from(nftMap.values())
331
+ .filter(nft => nft.tokenIds.size > 0)
332
+ .map(nft => ({
333
+ ...nft,
334
+ tokenIds: Array.from(nft.tokenIds),
335
+ count: nft.tokenIds.size
336
+ }));
337
+ }
338
+
339
+ async function fetchERC20Transfers(address, networkKey, apiKey) {
340
+ const chainId = CHAIN_IDS[networkKey];
341
+ if (!chainId) return [];
342
+
343
+ const url = `${ETHERSCAN_V2_API}?chainid=${chainId}&module=account&action=tokentx&address=${address}&page=1&offset=100&sort=desc&apikey=${apiKey}`;
344
+ const data = await httpsGet(url);
345
+
346
+ if (!data || data.status !== '1' || !data.result) return [];
347
+
348
+ const tokenMap = new Map();
349
+ for (const tx of data.result) {
350
+ if (!tokenMap.has(tx.contractAddress.toLowerCase())) {
351
+ tokenMap.set(tx.contractAddress.toLowerCase(), {
352
+ contract: tx.contractAddress,
353
+ symbol: tx.tokenSymbol || 'UNKNOWN',
354
+ name: tx.tokenName || 'Unknown Token',
355
+ decimals: parseInt(tx.tokenDecimal) || 18
356
+ });
357
+ }
358
+ }
359
+
360
+ return Array.from(tokenMap.values());
361
+ }
362
+
363
+ const ERC20_ABI = ["function balanceOf(address) view returns (uint256)"];
364
+
365
+ async function getERC20Balance(address, tokenContract, decimals, provider) {
366
+ try {
367
+ const token = new ethers.Contract(tokenContract, ERC20_ABI, provider);
368
+ const balance = await token.balanceOf(address);
369
+ return Number(balance) / Math.pow(10, decimals);
370
+ } catch { return 0; }
371
+ }
372
+
373
+ // data tokens - Get token holdings
374
+ data
375
+ .command('tokens')
376
+ .description('Get ERC20 and NFT holdings across all networks')
377
+ .option('--address <address>', 'MultiSig address or alias')
378
+ .option('--network <network>', 'Filter to specific network')
379
+ .option('--type <type>', 'Filter by token type: erc20, nft, or all', 'all')
380
+ .action(async (options, command) => {
381
+ const globalOpts = command.parent.parent.opts();
382
+ const out = createOutput(globalOpts);
383
+
384
+ try {
385
+ const address = resolveAddress(options.address);
386
+ const networks = await getQueryNetworks(options.address, options.network || globalOpts.network, out);
387
+
388
+ if (!networks) return;
389
+
390
+ const apiKey = await ensureEtherscanApiKey();
391
+ if (!apiKey) return;
392
+
393
+ const showERC20 = options.type === 'all' || options.type === 'erc20';
394
+ const showNFT = options.type === 'all' || options.type === 'nft';
395
+
396
+ out.startSpinner(`Discovering tokens across ${networks.length} network(s)...`);
397
+
398
+ const allERC20 = [];
399
+ const allNFTs = [];
400
+
401
+ for (const networkKey of networks) {
402
+ try {
403
+ const rpc = getRpcUrl(networkKey);
404
+ if (!rpc) continue;
405
+
406
+ const provider = new ethers.JsonRpcProvider(rpc);
407
+
408
+ if (showNFT) {
409
+ out.updateSpinner(`Fetching NFTs on ${networkKey}...`);
410
+ const nfts = await fetchNFTTransfers(address, networkKey, apiKey);
411
+ for (const nft of nfts) {
412
+ allNFTs.push({ network: networkKey, ...nft });
413
+ }
414
+ }
415
+
416
+ if (showERC20) {
417
+ out.updateSpinner(`Fetching ERC20 tokens on ${networkKey}...`);
418
+ const tokens = await fetchERC20Transfers(address, networkKey, apiKey);
419
+ for (const token of tokens) {
420
+ const balance = await getERC20Balance(address, token.contract, token.decimals, provider);
421
+ if (balance > 0) {
422
+ allERC20.push({ network: networkKey, ...token, balance });
423
+ }
424
+ }
425
+ }
426
+ } catch (err) {
427
+ // Continue to next network
428
+ }
429
+ }
430
+
431
+ out.succeedSpinner(`Found ${allERC20.length} ERC20 tokens and ${allNFTs.length} NFT collections`);
432
+
433
+ out.print({
434
+ address,
435
+ networks,
436
+ erc20Tokens: allERC20,
437
+ nfts: allNFTs,
438
+ summary: {
439
+ erc20Count: allERC20.length,
440
+ nftCollections: allNFTs.length,
441
+ totalNFTs: allNFTs.reduce((sum, n) => sum + n.count, 0)
442
+ }
443
+ });
444
+
445
+ } catch (error) {
446
+ out.error(error.message);
447
+ }
448
+ });
449
+
450
+ // data pending - Get pending transactions across all contract networks
451
+ const MULTISIG_ABI = [
452
+ "function transactions(uint) view returns (address dest, uint value, bytes func, bool executed, uint id)",
453
+ "function getConfirmationCount(uint transactionId) view returns (uint count)",
454
+ "function getConfirmations(uint transactionId) view returns (address[])",
455
+ "function required() view returns (uint)",
456
+ "function transactionCount() view returns (uint)"
457
+ ];
458
+
459
+ data
460
+ .command('pending')
461
+ .description('Get pending transactions across all networks')
462
+ .option('--address <address>', 'MultiSig address or alias')
463
+ .option('--network <network>', 'Filter to specific network')
464
+ .action(async (options, command) => {
465
+ const globalOpts = command.parent.parent.opts();
466
+ const out = createOutput(globalOpts);
467
+
468
+ try {
469
+ const address = resolveAddress(options.address);
470
+ const networks = await getQueryNetworks(options.address, options.network || globalOpts.network, out);
471
+
472
+ if (!networks) {
473
+ return;
474
+ }
475
+
476
+ out.startSpinner(`Fetching pending transactions across ${networks.length} network(s)...`);
477
+
478
+ const allPending = [];
479
+ let totalRequired = 0;
480
+
481
+ for (const networkKey of networks) {
482
+ try {
483
+ const rpc = getRpcUrl(networkKey);
484
+ if (!rpc) {
485
+ out.warn(`No RPC configured for ${networkKey}, skipping...`);
486
+ continue;
487
+ }
488
+
489
+ const provider = new ethers.JsonRpcProvider(rpc);
490
+ const multisig = new ethers.Contract(address, MULTISIG_ABI, provider);
491
+
492
+ const [txCount, required] = await Promise.all([
493
+ multisig.transactionCount(),
494
+ multisig.required()
495
+ ]);
496
+
497
+ totalRequired = Number(required);
498
+
499
+ for (let i = 0; i < Number(txCount); i++) {
500
+ const txData = await multisig.transactions(i);
501
+ if (!txData.executed) {
502
+ const confirmCount = await multisig.getConfirmationCount(i);
503
+ const confirmers = await multisig.getConfirmations(i);
504
+ allPending.push({
505
+ network: networkKey,
506
+ id: i,
507
+ destination: txData.dest,
508
+ value: ethers.formatEther(txData.value) + ' ETH',
509
+ data: txData.func,
510
+ confirmations: `${confirmCount}/${required}`,
511
+ confirmedBy: confirmers,
512
+ canExecute: Number(confirmCount) >= Number(required)
513
+ });
514
+ }
515
+ }
516
+ } catch (err) {
517
+ out.warn(`Failed to fetch pending on ${networkKey}: ${err.message}`);
518
+ }
519
+ }
520
+
521
+ out.succeedSpinner(`Found ${allPending.length} pending transactions across ${networks.length} network(s)`);
522
+
523
+ out.print({
524
+ multisig: address,
525
+ networks,
526
+ required: totalRequired,
527
+ pendingCount: allPending.length,
528
+ transactions: allPending
529
+ });
530
+
531
+ } catch (error) {
532
+ out.error(error.message);
533
+ }
534
+ });
535
+
536
+ module.exports = data;