multi-chain-balance-diff 0.1.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 +332 -0
- package/package.json +52 -0
- package/schema/mcbd-output.schema.json +297 -0
- package/src/adapters/baseAdapter.js +121 -0
- package/src/adapters/evmAdapter.js +126 -0
- package/src/adapters/index.js +70 -0
- package/src/adapters/solanaAdapter.js +179 -0
- package/src/config/networks.js +234 -0
- package/src/index.js +1031 -0
- package/src/services/balanceService.js +156 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,1031 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* multi-chain-balance-diff
|
|
5
|
+
*
|
|
6
|
+
* CLI tool to fetch and compare wallet balances across multiple blockchains.
|
|
7
|
+
* Supports EVM chains (Ethereum, Polygon, Base, Arbitrum, Optimism) and Solana.
|
|
8
|
+
*
|
|
9
|
+
* Useful for:
|
|
10
|
+
* - Tracking staking rewards over time
|
|
11
|
+
* - Monitoring LST/LRT positions
|
|
12
|
+
* - Helium hotspot reward tracking (HNT, MOBILE, IOT)
|
|
13
|
+
* - Multi-chain portfolio analysis
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { program } = require('commander');
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const { getNetwork, getSupportedNetworks, getNetworksByType } = require('./config/networks');
|
|
21
|
+
const { createAdapter } = require('./adapters');
|
|
22
|
+
|
|
23
|
+
// Read version from package.json (single source of truth)
|
|
24
|
+
const pkg = require('../package.json');
|
|
25
|
+
const VERSION = pkg.version;
|
|
26
|
+
const SCHEMA_VERSION = '0.1.0';
|
|
27
|
+
|
|
28
|
+
// ==========================================================================
|
|
29
|
+
// Exit Codes (CI-friendly)
|
|
30
|
+
// ==========================================================================
|
|
31
|
+
|
|
32
|
+
const EXIT_OK = 0; // No diff detected (or below threshold)
|
|
33
|
+
const EXIT_DIFF = 1; // Diff detected (above threshold)
|
|
34
|
+
const EXIT_RPC_ERROR = 2; // RPC/connection failure
|
|
35
|
+
const EXIT_SIGINT = 130; // Interrupted by Ctrl+C
|
|
36
|
+
|
|
37
|
+
// ==========================================================================
|
|
38
|
+
// CLI Configuration
|
|
39
|
+
// ==========================================================================
|
|
40
|
+
|
|
41
|
+
program
|
|
42
|
+
.name('multi-chain-balance-diff')
|
|
43
|
+
.description('Fetch wallet balances and compute diffs across EVM and Solana chains')
|
|
44
|
+
.version(VERSION)
|
|
45
|
+
.option('-a, --address <address>', 'Wallet address to check')
|
|
46
|
+
.option('-A, --addresses <addresses>', 'Multiple addresses (comma-separated or file path)')
|
|
47
|
+
.option('-n, --network <network>', 'Network to query', 'mainnet')
|
|
48
|
+
.option('-b, --blocks <number>', 'Number of blocks/slots to look back for diff', '50')
|
|
49
|
+
.option('--no-tokens', 'Skip token balance checks')
|
|
50
|
+
.option('--json', 'Output results as JSON')
|
|
51
|
+
.option('--list-networks', 'List all supported networks')
|
|
52
|
+
.option('-w, --watch', 'Watch mode: continuously monitor balance')
|
|
53
|
+
.option('-i, --interval <seconds>', 'Watch interval in seconds', '30')
|
|
54
|
+
.option('-c, --count <n>', 'Exit after N polls (watch mode only)')
|
|
55
|
+
.option('--exit-on-error', 'Exit immediately on RPC failure (watch mode)')
|
|
56
|
+
.option('--exit-on-diff', 'Exit immediately when threshold triggers (watch mode)')
|
|
57
|
+
.option('-p, --profile <name>', 'Use saved profile from config file')
|
|
58
|
+
.option('--config <path>', 'Path to config file')
|
|
59
|
+
.option('--alert-if-diff <threshold>', 'Exit 1 if diff exceeds threshold (e.g., ">0.01", ">=1", "<-0.5")')
|
|
60
|
+
.option('--alert-pct <threshold>', 'Exit 1 if diff exceeds % of balance (e.g., ">5", "<-10")')
|
|
61
|
+
.parse(process.argv);
|
|
62
|
+
|
|
63
|
+
const options = program.opts();
|
|
64
|
+
|
|
65
|
+
// ==========================================================================
|
|
66
|
+
// Config File Support
|
|
67
|
+
// ==========================================================================
|
|
68
|
+
|
|
69
|
+
const CONFIG_LOCATIONS = [
|
|
70
|
+
'.balancediffrc.json',
|
|
71
|
+
'.balancediffrc',
|
|
72
|
+
path.join(os.homedir(), '.balancediffrc.json'),
|
|
73
|
+
path.join(os.homedir(), '.config', 'balancediff', 'config.json'),
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
function loadConfig() {
|
|
77
|
+
// Use explicit config path if provided
|
|
78
|
+
if (options.config) {
|
|
79
|
+
try {
|
|
80
|
+
const content = fs.readFileSync(options.config, 'utf8');
|
|
81
|
+
return JSON.parse(content);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
console.error(`❌ Could not load config from ${options.config}: ${error.message}`);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Search default locations
|
|
89
|
+
for (const loc of CONFIG_LOCATIONS) {
|
|
90
|
+
try {
|
|
91
|
+
const content = fs.readFileSync(loc, 'utf8');
|
|
92
|
+
return JSON.parse(content);
|
|
93
|
+
} catch {
|
|
94
|
+
// Continue to next location
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function getProfileConfig(profileName) {
|
|
102
|
+
const config = loadConfig();
|
|
103
|
+
if (!config) {
|
|
104
|
+
console.error(`❌ No config file found. Create .balancediffrc.json with your profiles.`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const profile = config.profiles?.[profileName];
|
|
109
|
+
if (!profile) {
|
|
110
|
+
console.error(`❌ Profile "${profileName}" not found in config.`);
|
|
111
|
+
console.error(` Available profiles: ${Object.keys(config.profiles || {}).join(', ')}`);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return profile;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ==========================================================================
|
|
119
|
+
// Address Resolution
|
|
120
|
+
// ==========================================================================
|
|
121
|
+
|
|
122
|
+
function resolveAddresses() {
|
|
123
|
+
// If using a profile, get address from there
|
|
124
|
+
if (options.profile) {
|
|
125
|
+
const profile = getProfileConfig(options.profile);
|
|
126
|
+
// Override network from profile if not explicitly set
|
|
127
|
+
if (profile.network && options.network === 'mainnet') {
|
|
128
|
+
options.network = profile.network;
|
|
129
|
+
}
|
|
130
|
+
return Array.isArray(profile.address) ? profile.address : [profile.address];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Single address
|
|
134
|
+
if (options.address) {
|
|
135
|
+
return [options.address];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Multiple addresses
|
|
139
|
+
if (options.addresses) {
|
|
140
|
+
// Check if it's a file path
|
|
141
|
+
if (fs.existsSync(options.addresses)) {
|
|
142
|
+
const content = fs.readFileSync(options.addresses, 'utf8');
|
|
143
|
+
return content.split('\n').map(a => a.trim()).filter(a => a && !a.startsWith('#'));
|
|
144
|
+
}
|
|
145
|
+
// Comma-separated
|
|
146
|
+
return options.addresses.split(',').map(a => a.trim());
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Check for required address unless listing networks
|
|
153
|
+
const addresses = resolveAddresses();
|
|
154
|
+
if (!options.listNetworks && addresses.length === 0) {
|
|
155
|
+
console.error("\nerror: required option '-a, --address <address>' not specified\n");
|
|
156
|
+
console.error("You can also use:");
|
|
157
|
+
console.error(" -A, --addresses <addresses> Multiple addresses (comma-separated or file)");
|
|
158
|
+
console.error(" -p, --profile <name> Use saved profile from config file\n");
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ==========================================================================
|
|
163
|
+
// Display Helpers
|
|
164
|
+
// ==========================================================================
|
|
165
|
+
|
|
166
|
+
const COLORS = {
|
|
167
|
+
reset: '\x1b[0m',
|
|
168
|
+
bright: '\x1b[1m',
|
|
169
|
+
dim: '\x1b[2m',
|
|
170
|
+
green: '\x1b[32m',
|
|
171
|
+
red: '\x1b[31m',
|
|
172
|
+
yellow: '\x1b[33m',
|
|
173
|
+
cyan: '\x1b[36m',
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Disable colors for JSON output
|
|
177
|
+
const useColors = !options.json;
|
|
178
|
+
const c = (color) => useColors ? COLORS[color] : '';
|
|
179
|
+
|
|
180
|
+
function printSeparator(char = '─', length = 65) {
|
|
181
|
+
console.log(c('dim') + char.repeat(length) + c('reset'));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function printHeader(text) {
|
|
185
|
+
console.log(`${c('bright')}${text}${c('reset')}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function printKeyValue(key, value, indent = 2) {
|
|
189
|
+
const spaces = ' '.repeat(indent);
|
|
190
|
+
console.log(`${spaces}${c('cyan')}${key}:${c('reset')} ${value}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function formatDiffColored(diff, symbol, decimals) {
|
|
194
|
+
const absValue = diff < 0n ? -diff : diff;
|
|
195
|
+
const formatted = formatBigInt(absValue, decimals);
|
|
196
|
+
const isPositive = diff >= 0n;
|
|
197
|
+
const prefix = isPositive ? '+' : '-';
|
|
198
|
+
const color = isPositive ? c('green') : c('red');
|
|
199
|
+
|
|
200
|
+
return `${color}${prefix}${formatted} ${symbol}${c('reset')}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function formatBigInt(value, decimals) {
|
|
204
|
+
const divisor = BigInt(10 ** decimals);
|
|
205
|
+
const whole = value / divisor;
|
|
206
|
+
const fraction = value % divisor;
|
|
207
|
+
|
|
208
|
+
if (fraction === 0n) {
|
|
209
|
+
return whole.toString();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const fractionStr = fraction.toString().padStart(decimals, '0');
|
|
213
|
+
const trimmed = fractionStr.replace(/0+$/, '').slice(0, 6);
|
|
214
|
+
|
|
215
|
+
return `${whole}.${trimmed}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function clearLine() {
|
|
219
|
+
process.stdout.write('\x1b[2K\x1b[0G');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function formatTimestamp() {
|
|
223
|
+
return new Date().toLocaleTimeString();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ==========================================================================
|
|
227
|
+
// Threshold Parsing
|
|
228
|
+
// ==========================================================================
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Parse threshold string like ">0.01", ">=1", "<-0.5", "0.01" (defaults to >)
|
|
232
|
+
* Returns { operator, value } or null if invalid
|
|
233
|
+
*/
|
|
234
|
+
function parseThreshold(thresholdStr) {
|
|
235
|
+
if (!thresholdStr) return null;
|
|
236
|
+
|
|
237
|
+
const match = thresholdStr.match(/^(>=?|<=?)?(-?\d+\.?\d*)$/);
|
|
238
|
+
if (!match) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const operator = match[1] || '>'; // default to > if no operator
|
|
243
|
+
const value = parseFloat(match[2]);
|
|
244
|
+
|
|
245
|
+
if (isNaN(value)) return null;
|
|
246
|
+
|
|
247
|
+
return { operator, value };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Convert bigint to number for threshold comparisons.
|
|
252
|
+
* @param {bigint} raw - The raw value
|
|
253
|
+
* @param {number} decimals - Token decimals
|
|
254
|
+
* @returns {number}
|
|
255
|
+
*/
|
|
256
|
+
function bigintToNumber(raw, decimals) {
|
|
257
|
+
const divisor = BigInt(10 ** decimals);
|
|
258
|
+
const whole = Number(raw / divisor);
|
|
259
|
+
const fraction = Number(raw % divisor) / (10 ** decimals);
|
|
260
|
+
return whole + fraction;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Check if diff triggers alert based on threshold
|
|
265
|
+
* @param {bigint} diffRaw - The raw diff value
|
|
266
|
+
* @param {number} decimals - Token decimals for conversion
|
|
267
|
+
* @param {object} threshold - Parsed threshold { operator, value }
|
|
268
|
+
* @returns {boolean} - true if alert should trigger
|
|
269
|
+
*/
|
|
270
|
+
function checkThreshold(diffRaw, decimals, threshold) {
|
|
271
|
+
if (!threshold) return false;
|
|
272
|
+
|
|
273
|
+
const diffValue = bigintToNumber(diffRaw, decimals);
|
|
274
|
+
|
|
275
|
+
switch (threshold.operator) {
|
|
276
|
+
case '>': return diffValue > threshold.value;
|
|
277
|
+
case '>=': return diffValue >= threshold.value;
|
|
278
|
+
case '<': return diffValue < threshold.value;
|
|
279
|
+
case '<=': return diffValue <= threshold.value;
|
|
280
|
+
default: return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Check if diff triggers alert based on percentage threshold.
|
|
286
|
+
* @param {bigint} diffRaw - The raw diff value
|
|
287
|
+
* @param {bigint} previousRaw - The previous balance for percentage calc
|
|
288
|
+
* @param {number} decimals - Token decimals for conversion
|
|
289
|
+
* @param {object} threshold - Parsed threshold { operator, value } where value is %
|
|
290
|
+
* @returns {boolean} - true if alert should trigger
|
|
291
|
+
*/
|
|
292
|
+
function checkPercentageThreshold(diffRaw, previousRaw, decimals, threshold) {
|
|
293
|
+
if (!threshold) return false;
|
|
294
|
+
if (previousRaw === 0n) return false; // Avoid division by zero
|
|
295
|
+
|
|
296
|
+
const diffValue = bigintToNumber(diffRaw, decimals);
|
|
297
|
+
const previousValue = bigintToNumber(previousRaw, decimals);
|
|
298
|
+
|
|
299
|
+
if (previousValue === 0) return false;
|
|
300
|
+
|
|
301
|
+
const percentChange = (diffValue / previousValue) * 100;
|
|
302
|
+
|
|
303
|
+
switch (threshold.operator) {
|
|
304
|
+
case '>': return percentChange > threshold.value;
|
|
305
|
+
case '>=': return percentChange >= threshold.value;
|
|
306
|
+
case '<': return percentChange < threshold.value;
|
|
307
|
+
case '<=': return percentChange <= threshold.value;
|
|
308
|
+
default: return false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ==========================================================================
|
|
313
|
+
// Network List Command
|
|
314
|
+
// ==========================================================================
|
|
315
|
+
|
|
316
|
+
function listNetworks() {
|
|
317
|
+
if (options.json) {
|
|
318
|
+
const networks = {
|
|
319
|
+
schemaVersion: SCHEMA_VERSION,
|
|
320
|
+
evm: getNetworksByType('evm').map(key => {
|
|
321
|
+
const net = getNetwork(key);
|
|
322
|
+
return { key, name: net.name, symbol: net.nativeSymbol, chainId: net.chainId };
|
|
323
|
+
}),
|
|
324
|
+
solana: getNetworksByType('solana').map(key => {
|
|
325
|
+
const net = getNetwork(key);
|
|
326
|
+
return { key, name: net.name, symbol: net.nativeSymbol };
|
|
327
|
+
}),
|
|
328
|
+
};
|
|
329
|
+
console.log(JSON.stringify(networks, null, 2));
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
console.log('\n📡 Supported Networks:\n');
|
|
334
|
+
|
|
335
|
+
console.log(' EVM Chains:');
|
|
336
|
+
for (const key of getNetworksByType('evm')) {
|
|
337
|
+
const net = getNetwork(key);
|
|
338
|
+
console.log(` ${c('cyan')}${key.padEnd(12)}${c('reset')} ${net.name} (${net.nativeSymbol})`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
console.log('\n Solana Chains:');
|
|
342
|
+
for (const key of getNetworksByType('solana')) {
|
|
343
|
+
const net = getNetwork(key);
|
|
344
|
+
console.log(` ${c('cyan')}${key.padEnd(12)}${c('reset')} ${net.name} (${net.nativeSymbol})`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
console.log('\nUsage:');
|
|
348
|
+
console.log(' mcbd --address <ADDR> --network mainnet');
|
|
349
|
+
console.log(' mcbd --address <ADDR> --network base');
|
|
350
|
+
console.log(' mcbd --address <ADDR> --network solana');
|
|
351
|
+
console.log(' mcbd --address <ADDR> --network helium --json\n');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ==========================================================================
|
|
355
|
+
// JSON Output Builder
|
|
356
|
+
// ==========================================================================
|
|
357
|
+
|
|
358
|
+
function buildJsonOutput(networkConfig, address, balanceDiff, tokenBalances, adapter) {
|
|
359
|
+
const blockLabel = networkConfig.chainType === 'solana' ? 'slot' : 'block';
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
schemaVersion: SCHEMA_VERSION,
|
|
363
|
+
network: {
|
|
364
|
+
key: options.network,
|
|
365
|
+
name: networkConfig.name,
|
|
366
|
+
chainType: networkConfig.chainType,
|
|
367
|
+
chainId: networkConfig.chainId,
|
|
368
|
+
},
|
|
369
|
+
address,
|
|
370
|
+
explorer: adapter.getExplorerUrl(address),
|
|
371
|
+
[blockLabel]: {
|
|
372
|
+
current: balanceDiff.currentBlock,
|
|
373
|
+
previous: balanceDiff.previousBlock,
|
|
374
|
+
},
|
|
375
|
+
native: {
|
|
376
|
+
symbol: networkConfig.nativeSymbol,
|
|
377
|
+
decimals: networkConfig.nativeDecimals,
|
|
378
|
+
balance: formatBigInt(balanceDiff.current.raw, networkConfig.nativeDecimals),
|
|
379
|
+
balanceRaw: balanceDiff.current.raw.toString(),
|
|
380
|
+
diff: formatBigInt(balanceDiff.diff < 0n ? -balanceDiff.diff : balanceDiff.diff, networkConfig.nativeDecimals),
|
|
381
|
+
diffRaw: balanceDiff.diff.toString(),
|
|
382
|
+
diffSign: balanceDiff.diff >= 0n ? 'positive' : 'negative',
|
|
383
|
+
},
|
|
384
|
+
tokens: tokenBalances.map(token => ({
|
|
385
|
+
symbol: token.symbol,
|
|
386
|
+
address: token.address || token.mint,
|
|
387
|
+
decimals: token.decimals,
|
|
388
|
+
balance: token.formatted,
|
|
389
|
+
balanceRaw: token.raw.toString(),
|
|
390
|
+
})),
|
|
391
|
+
timestamp: new Date().toISOString(),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function buildMultiAddressJsonOutput(networkConfig, results) {
|
|
396
|
+
return {
|
|
397
|
+
schemaVersion: SCHEMA_VERSION,
|
|
398
|
+
network: {
|
|
399
|
+
key: options.network,
|
|
400
|
+
name: networkConfig.name,
|
|
401
|
+
chainType: networkConfig.chainType,
|
|
402
|
+
chainId: networkConfig.chainId,
|
|
403
|
+
},
|
|
404
|
+
addresses: results,
|
|
405
|
+
summary: {
|
|
406
|
+
totalAddresses: results.length,
|
|
407
|
+
successCount: results.filter(r => !r.error).length,
|
|
408
|
+
errorCount: results.filter(r => r.error).length,
|
|
409
|
+
},
|
|
410
|
+
timestamp: new Date().toISOString(),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ==========================================================================
|
|
415
|
+
// Pretty Print Output
|
|
416
|
+
// ==========================================================================
|
|
417
|
+
|
|
418
|
+
function printPrettyOutput(networkConfig, address, balanceDiff, tokenBalances, adapter, blocksBack, isMulti = false) {
|
|
419
|
+
if (!isMulti) {
|
|
420
|
+
console.log();
|
|
421
|
+
printSeparator('═');
|
|
422
|
+
}
|
|
423
|
+
printHeader(` ${isMulti ? address : networkConfig.name}`);
|
|
424
|
+
printSeparator('─');
|
|
425
|
+
|
|
426
|
+
if (!isMulti) {
|
|
427
|
+
const blockLabel = networkConfig.chainType === 'solana' ? 'Current slot' : 'Current block';
|
|
428
|
+
printKeyValue(blockLabel, balanceDiff.currentBlock.toLocaleString());
|
|
429
|
+
printKeyValue('Address', address);
|
|
430
|
+
}
|
|
431
|
+
printKeyValue('Explorer', adapter.getExplorerUrl(address));
|
|
432
|
+
|
|
433
|
+
printSeparator('─');
|
|
434
|
+
|
|
435
|
+
// Native balance
|
|
436
|
+
const nativeFormatted = `${formatBigInt(balanceDiff.current.raw, networkConfig.nativeDecimals)} ${networkConfig.nativeSymbol}`;
|
|
437
|
+
printKeyValue('Native balance', nativeFormatted);
|
|
438
|
+
|
|
439
|
+
// Diff
|
|
440
|
+
const diffColored = formatDiffColored(
|
|
441
|
+
balanceDiff.diff,
|
|
442
|
+
networkConfig.nativeSymbol,
|
|
443
|
+
networkConfig.nativeDecimals
|
|
444
|
+
);
|
|
445
|
+
const rangeLabel = networkConfig.chainType === 'solana' ? 'slots' : 'blocks';
|
|
446
|
+
console.log(` ${c('cyan')}Δ over ${blocksBack} ${rangeLabel}:${c('reset')} ${diffColored}`);
|
|
447
|
+
|
|
448
|
+
if (!isMulti) {
|
|
449
|
+
console.log(` ${c('dim')}(${balanceDiff.previousBlock.toLocaleString()} → ${balanceDiff.currentBlock.toLocaleString()})${c('reset')}`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Token balances
|
|
453
|
+
if (tokenBalances.length > 0) {
|
|
454
|
+
printSeparator('─');
|
|
455
|
+
console.log(` ${c('bright')}Tokens:${c('reset')}`);
|
|
456
|
+
|
|
457
|
+
for (const token of tokenBalances) {
|
|
458
|
+
const amount = parseFloat(token.formatted).toLocaleString(undefined, {
|
|
459
|
+
minimumFractionDigits: 2,
|
|
460
|
+
maximumFractionDigits: 6,
|
|
461
|
+
});
|
|
462
|
+
console.log(` ${c('cyan')}${token.symbol.padEnd(10)}${c('reset')} ${amount}`);
|
|
463
|
+
}
|
|
464
|
+
} else if (options.tokens && networkConfig.tokens.length > 0) {
|
|
465
|
+
printSeparator('─');
|
|
466
|
+
console.log(` ${c('dim')}Tokens: (no balances found)${c('reset')}`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (!isMulti) {
|
|
470
|
+
printSeparator('═');
|
|
471
|
+
console.log();
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function printMultiAddressSummary(networkConfig, results, blocksBack) {
|
|
476
|
+
console.log();
|
|
477
|
+
printSeparator('═');
|
|
478
|
+
printHeader(` ${networkConfig.name} — ${results.length} addresses`);
|
|
479
|
+
printSeparator('═');
|
|
480
|
+
|
|
481
|
+
let totalNative = 0n;
|
|
482
|
+
let totalDiff = 0n;
|
|
483
|
+
|
|
484
|
+
for (const result of results) {
|
|
485
|
+
if (result.error) {
|
|
486
|
+
console.log(` ${c('red')}✗${c('reset')} ${result.address}: ${result.error}`);
|
|
487
|
+
} else {
|
|
488
|
+
const shortAddr = `${result.address.slice(0, 8)}...${result.address.slice(-6)}`;
|
|
489
|
+
const balance = formatBigInt(result.balanceDiff.current.raw, networkConfig.nativeDecimals);
|
|
490
|
+
const diff = formatDiffColored(result.balanceDiff.diff, networkConfig.nativeSymbol, networkConfig.nativeDecimals);
|
|
491
|
+
|
|
492
|
+
console.log(` ${c('green')}✓${c('reset')} ${shortAddr} ${balance.padStart(12)} ${networkConfig.nativeSymbol} ${diff}`);
|
|
493
|
+
|
|
494
|
+
totalNative += result.balanceDiff.current.raw;
|
|
495
|
+
totalDiff += result.balanceDiff.diff;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
printSeparator('─');
|
|
500
|
+
const totalFormatted = formatBigInt(totalNative, networkConfig.nativeDecimals);
|
|
501
|
+
const totalDiffColored = formatDiffColored(totalDiff, networkConfig.nativeSymbol, networkConfig.nativeDecimals);
|
|
502
|
+
console.log(` ${c('bright')}Total:${c('reset')} ${totalFormatted.padStart(12)} ${networkConfig.nativeSymbol} ${totalDiffColored}`);
|
|
503
|
+
printSeparator('═');
|
|
504
|
+
console.log();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ==========================================================================
|
|
508
|
+
// Watch Mode
|
|
509
|
+
// ==========================================================================
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Build a single poll result for JSON output.
|
|
513
|
+
*/
|
|
514
|
+
function buildPollResult(networkConfig, address, balanceDiff, alertTriggered, error = null) {
|
|
515
|
+
if (error) {
|
|
516
|
+
return {
|
|
517
|
+
schemaVersion: SCHEMA_VERSION,
|
|
518
|
+
timestamp: new Date().toISOString(),
|
|
519
|
+
address,
|
|
520
|
+
error: error.message || String(error),
|
|
521
|
+
isRpcError: isRpcError(error),
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const blockLabel = networkConfig.chainType === 'solana' ? 'slot' : 'block';
|
|
526
|
+
return {
|
|
527
|
+
schemaVersion: SCHEMA_VERSION,
|
|
528
|
+
timestamp: new Date().toISOString(),
|
|
529
|
+
address,
|
|
530
|
+
[blockLabel]: balanceDiff.currentBlock,
|
|
531
|
+
balance: formatBigInt(balanceDiff.current.raw, networkConfig.nativeDecimals),
|
|
532
|
+
balanceRaw: balanceDiff.current.raw.toString(),
|
|
533
|
+
diff: formatBigInt(balanceDiff.diff < 0n ? -balanceDiff.diff : balanceDiff.diff, networkConfig.nativeDecimals),
|
|
534
|
+
diffRaw: balanceDiff.diff.toString(),
|
|
535
|
+
diffSign: balanceDiff.diff >= 0n ? 'positive' : 'negative',
|
|
536
|
+
alert: alertTriggered,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Check if an error is RPC-related.
|
|
542
|
+
*/
|
|
543
|
+
function isRpcError(error) {
|
|
544
|
+
return error.code === 'ENOTFOUND' ||
|
|
545
|
+
error.code === 'ECONNREFUSED' ||
|
|
546
|
+
error.code === 'ETIMEDOUT' ||
|
|
547
|
+
error.message?.includes('429') ||
|
|
548
|
+
error.message?.includes('rate') ||
|
|
549
|
+
error.message?.includes('timeout');
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Watch mode: poll balances periodically with CI-friendly semantics.
|
|
554
|
+
*
|
|
555
|
+
* Behavior:
|
|
556
|
+
* - Polls at --interval seconds
|
|
557
|
+
* - Exits after --count polls (if specified)
|
|
558
|
+
* - Exits immediately on threshold breach (if --exit-on-diff)
|
|
559
|
+
* - Exits immediately on RPC error (if --exit-on-error)
|
|
560
|
+
* - JSON mode outputs newline-delimited JSON (one object per poll)
|
|
561
|
+
*
|
|
562
|
+
* Exit codes:
|
|
563
|
+
* - 0: Completed without threshold breach
|
|
564
|
+
* - 1: Threshold breached
|
|
565
|
+
* - 2: RPC error (with --exit-on-error)
|
|
566
|
+
* - 130: SIGINT
|
|
567
|
+
*/
|
|
568
|
+
async function watchMode(adapter, networkConfig, address, blocksBack) {
|
|
569
|
+
const intervalMs = parseInt(options.interval, 10) * 1000;
|
|
570
|
+
const maxPolls = options.count ? parseInt(options.count, 10) : Infinity;
|
|
571
|
+
const exitOnError = options.exitOnError;
|
|
572
|
+
const exitOnDiff = options.exitOnDiff;
|
|
573
|
+
const threshold = parseThreshold(options.alertIfDiff);
|
|
574
|
+
const pctThreshold = parseThreshold(options.alertPct);
|
|
575
|
+
|
|
576
|
+
let pollCount = 0;
|
|
577
|
+
let lastBalance = null;
|
|
578
|
+
let shouldExit = false;
|
|
579
|
+
let exitCode = EXIT_OK;
|
|
580
|
+
let intervalId = null;
|
|
581
|
+
|
|
582
|
+
// JSON mode: output watch metadata at start
|
|
583
|
+
if (options.json) {
|
|
584
|
+
const meta = {
|
|
585
|
+
schemaVersion: SCHEMA_VERSION,
|
|
586
|
+
type: 'watch_start',
|
|
587
|
+
timestamp: new Date().toISOString(),
|
|
588
|
+
network: options.network,
|
|
589
|
+
address,
|
|
590
|
+
interval: parseInt(options.interval, 10),
|
|
591
|
+
count: options.count ? parseInt(options.count, 10) : null,
|
|
592
|
+
threshold: options.alertIfDiff || null,
|
|
593
|
+
thresholdPct: options.alertPct || null,
|
|
594
|
+
};
|
|
595
|
+
console.log(JSON.stringify(meta));
|
|
596
|
+
} else {
|
|
597
|
+
console.log();
|
|
598
|
+
console.log(`🔄 ${c('bright')}Watch mode${c('reset')} — monitoring ${address.slice(0, 8)}...${address.slice(-6)}`);
|
|
599
|
+
console.log(` Network: ${networkConfig.name}`);
|
|
600
|
+
console.log(` Interval: ${options.interval}s`);
|
|
601
|
+
if (maxPolls !== Infinity) {
|
|
602
|
+
console.log(` Count: ${maxPolls} polls`);
|
|
603
|
+
}
|
|
604
|
+
if (threshold) {
|
|
605
|
+
console.log(` Threshold: ${options.alertIfDiff}`);
|
|
606
|
+
}
|
|
607
|
+
if (pctThreshold) {
|
|
608
|
+
console.log(` Threshold (%): ${options.alertPct}`);
|
|
609
|
+
}
|
|
610
|
+
console.log(` Press Ctrl+C to exit`);
|
|
611
|
+
console.log();
|
|
612
|
+
printSeparator('─');
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const tick = async () => {
|
|
616
|
+
pollCount++;
|
|
617
|
+
|
|
618
|
+
try {
|
|
619
|
+
const balanceDiff = await adapter.getNativeBalanceDiff(address, blocksBack);
|
|
620
|
+
|
|
621
|
+
// Check absolute threshold
|
|
622
|
+
const absTriggered = checkThreshold(
|
|
623
|
+
balanceDiff.diff,
|
|
624
|
+
networkConfig.nativeDecimals,
|
|
625
|
+
threshold
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
// Check percentage threshold
|
|
629
|
+
const pctTriggered = checkPercentageThreshold(
|
|
630
|
+
balanceDiff.diff,
|
|
631
|
+
balanceDiff.previous.raw,
|
|
632
|
+
networkConfig.nativeDecimals,
|
|
633
|
+
pctThreshold
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
const alertTriggered = absTriggered || pctTriggered;
|
|
637
|
+
|
|
638
|
+
if (options.json) {
|
|
639
|
+
// Newline-delimited JSON for streaming
|
|
640
|
+
const result = buildPollResult(networkConfig, address, balanceDiff, alertTriggered);
|
|
641
|
+
result.poll = pollCount;
|
|
642
|
+
console.log(JSON.stringify(result));
|
|
643
|
+
} else {
|
|
644
|
+
// Pretty output
|
|
645
|
+
const currentBalance = formatBigInt(balanceDiff.current.raw, networkConfig.nativeDecimals);
|
|
646
|
+
const diff = formatDiffColored(balanceDiff.diff, networkConfig.nativeSymbol, networkConfig.nativeDecimals);
|
|
647
|
+
|
|
648
|
+
let changeIndicator = '';
|
|
649
|
+
if (lastBalance !== null && balanceDiff.current.raw !== lastBalance) {
|
|
650
|
+
const delta = balanceDiff.current.raw - lastBalance;
|
|
651
|
+
const deltaFormatted = formatBigInt(delta < 0n ? -delta : delta, networkConfig.nativeDecimals);
|
|
652
|
+
const sign = delta >= 0n ? '+' : '-';
|
|
653
|
+
const color = delta >= 0n ? c('green') : c('red');
|
|
654
|
+
changeIndicator = ` ${color}(${sign}${deltaFormatted} since last)${c('reset')}`;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const alertIndicator = alertTriggered ? ` ${c('yellow')}⚠ ALERT${c('reset')}` : '';
|
|
658
|
+
console.log(` [${formatTimestamp()}] ${currentBalance} ${networkConfig.nativeSymbol} Δ${blocksBack}: ${diff}${changeIndicator}${alertIndicator}`);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
lastBalance = balanceDiff.current.raw;
|
|
662
|
+
|
|
663
|
+
// Exit on diff if threshold triggered
|
|
664
|
+
if (alertTriggered && exitOnDiff) {
|
|
665
|
+
shouldExit = true;
|
|
666
|
+
exitCode = EXIT_DIFF;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Track if any poll triggered threshold (for final exit code)
|
|
670
|
+
if (alertTriggered) {
|
|
671
|
+
exitCode = EXIT_DIFF;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
} catch (error) {
|
|
675
|
+
if (options.json) {
|
|
676
|
+
const result = buildPollResult(networkConfig, address, null, false, error);
|
|
677
|
+
result.poll = pollCount;
|
|
678
|
+
console.log(JSON.stringify(result));
|
|
679
|
+
} else {
|
|
680
|
+
console.log(` [${formatTimestamp()}] ${c('red')}Error: ${error.message}${c('reset')}`);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (exitOnError && isRpcError(error)) {
|
|
684
|
+
shouldExit = true;
|
|
685
|
+
exitCode = EXIT_RPC_ERROR;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Check if we've reached max polls
|
|
690
|
+
if (pollCount >= maxPolls) {
|
|
691
|
+
shouldExit = true;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (shouldExit) {
|
|
695
|
+
if (intervalId) clearInterval(intervalId);
|
|
696
|
+
if (options.json) {
|
|
697
|
+
console.log(JSON.stringify({
|
|
698
|
+
schemaVersion: SCHEMA_VERSION,
|
|
699
|
+
type: 'watch_end',
|
|
700
|
+
timestamp: new Date().toISOString(),
|
|
701
|
+
polls: pollCount,
|
|
702
|
+
exitCode
|
|
703
|
+
}));
|
|
704
|
+
} else {
|
|
705
|
+
console.log();
|
|
706
|
+
console.log(`👋 Watch mode stopped. (${pollCount} polls, exit ${exitCode})`);
|
|
707
|
+
}
|
|
708
|
+
process.exit(exitCode);
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
// Initial tick
|
|
713
|
+
await tick();
|
|
714
|
+
|
|
715
|
+
// Set up interval (only if not exiting)
|
|
716
|
+
if (!shouldExit) {
|
|
717
|
+
intervalId = setInterval(tick, intervalMs);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Handle graceful shutdown
|
|
721
|
+
process.on('SIGINT', () => {
|
|
722
|
+
if (intervalId) clearInterval(intervalId);
|
|
723
|
+
if (options.json) {
|
|
724
|
+
console.log(JSON.stringify({
|
|
725
|
+
schemaVersion: SCHEMA_VERSION,
|
|
726
|
+
type: 'watch_end',
|
|
727
|
+
timestamp: new Date().toISOString(),
|
|
728
|
+
polls: pollCount,
|
|
729
|
+
exitCode: EXIT_SIGINT,
|
|
730
|
+
reason: 'SIGINT'
|
|
731
|
+
}));
|
|
732
|
+
} else {
|
|
733
|
+
console.log();
|
|
734
|
+
console.log(`\n👋 Watch mode stopped. (interrupted)`);
|
|
735
|
+
}
|
|
736
|
+
process.exit(EXIT_SIGINT);
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
// Keep process alive
|
|
740
|
+
await new Promise(() => {});
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ==========================================================================
|
|
744
|
+
// Fetch Single Address
|
|
745
|
+
// ==========================================================================
|
|
746
|
+
|
|
747
|
+
async function fetchAddressData(adapter, networkConfig, address, blocksBack, checkTokens) {
|
|
748
|
+
try {
|
|
749
|
+
// Validate address format for this chain type
|
|
750
|
+
if (!adapter.isValidAddress(address)) {
|
|
751
|
+
return { address, error: `Invalid ${networkConfig.chainType.toUpperCase()} address` };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Fetch native balance diff
|
|
755
|
+
const balanceDiff = await adapter.getNativeBalanceDiff(address, blocksBack);
|
|
756
|
+
|
|
757
|
+
// Fetch token balances
|
|
758
|
+
let tokenBalances = [];
|
|
759
|
+
if (checkTokens && networkConfig.tokens.length > 0) {
|
|
760
|
+
tokenBalances = await adapter.getTokenBalances(address, networkConfig.tokens);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return { address, balanceDiff, tokenBalances };
|
|
764
|
+
} catch (error) {
|
|
765
|
+
return { address, error: error.message };
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// ==========================================================================
|
|
770
|
+
// Main Execution
|
|
771
|
+
// ==========================================================================
|
|
772
|
+
|
|
773
|
+
async function main() {
|
|
774
|
+
// Handle --list-networks flag
|
|
775
|
+
if (options.listNetworks) {
|
|
776
|
+
listNetworks();
|
|
777
|
+
process.exit(0);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const { network, blocks, tokens: checkTokens } = options;
|
|
781
|
+
const blocksBack = parseInt(blocks, 10);
|
|
782
|
+
|
|
783
|
+
// Get network configuration
|
|
784
|
+
const networkConfig = getNetwork(network);
|
|
785
|
+
if (!networkConfig) {
|
|
786
|
+
if (options.json) {
|
|
787
|
+
console.log(JSON.stringify({ schemaVersion: SCHEMA_VERSION, error: `Unknown network: ${network}` }));
|
|
788
|
+
} else {
|
|
789
|
+
console.error(`\n❌ Unknown network: ${network}`);
|
|
790
|
+
console.error(` Run with --list-networks to see available options.\n`);
|
|
791
|
+
}
|
|
792
|
+
process.exit(1);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Validate blocks parameter
|
|
796
|
+
if (isNaN(blocksBack) || blocksBack < 1) {
|
|
797
|
+
if (options.json) {
|
|
798
|
+
console.log(JSON.stringify({ schemaVersion: SCHEMA_VERSION, error: `Invalid blocks value: ${blocks}` }));
|
|
799
|
+
} else {
|
|
800
|
+
console.error(`\n❌ Invalid blocks value: ${blocks}`);
|
|
801
|
+
console.error(' Please provide a positive integer.\n');
|
|
802
|
+
}
|
|
803
|
+
process.exit(1);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Create the appropriate adapter for this chain
|
|
807
|
+
const adapter = createAdapter(networkConfig);
|
|
808
|
+
|
|
809
|
+
// Validate all addresses before connecting (fail fast)
|
|
810
|
+
for (const addr of addresses) {
|
|
811
|
+
if (!adapter.isValidAddress(addr)) {
|
|
812
|
+
if (options.json) {
|
|
813
|
+
console.log(JSON.stringify({ schemaVersion: SCHEMA_VERSION, error: `Invalid ${networkConfig.chainType.toUpperCase()} address: ${addr}` }));
|
|
814
|
+
} else {
|
|
815
|
+
console.error(`\n❌ Invalid ${networkConfig.chainType.toUpperCase()} address: ${addr}`);
|
|
816
|
+
if (networkConfig.chainType === 'evm') {
|
|
817
|
+
console.error(' Expected format: 0x followed by 40 hex characters');
|
|
818
|
+
} else if (networkConfig.chainType === 'solana') {
|
|
819
|
+
console.error(' Expected format: Base58 encoded public key');
|
|
820
|
+
}
|
|
821
|
+
console.error('');
|
|
822
|
+
}
|
|
823
|
+
process.exit(1);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Connect
|
|
828
|
+
if (!options.json) {
|
|
829
|
+
console.log();
|
|
830
|
+
console.log(`🔗 Connecting to ${c('bright')}${networkConfig.name}${c('reset')}...`);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
try {
|
|
834
|
+
await adapter.connect();
|
|
835
|
+
} catch (error) {
|
|
836
|
+
if (options.json) {
|
|
837
|
+
console.log(JSON.stringify({ schemaVersion: SCHEMA_VERSION, error: `Connection failed: ${error.message}`, exitCode: EXIT_RPC_ERROR }));
|
|
838
|
+
} else {
|
|
839
|
+
console.error(`\n❌ Could not connect to ${networkConfig.name}`);
|
|
840
|
+
console.error(` ${error.message}\n`);
|
|
841
|
+
}
|
|
842
|
+
process.exit(EXIT_RPC_ERROR);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Watch mode (single address only)
|
|
846
|
+
if (options.watch) {
|
|
847
|
+
if (addresses.length > 1) {
|
|
848
|
+
console.error(`\n❌ Watch mode only supports a single address.\n`);
|
|
849
|
+
process.exit(1);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
await watchMode(adapter, networkConfig, addresses[0], blocksBack);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Multi-address mode
|
|
857
|
+
if (addresses.length > 1) {
|
|
858
|
+
if (!options.json) {
|
|
859
|
+
console.log(`📊 Fetching data for ${addresses.length} addresses...`);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const results = await Promise.all(
|
|
863
|
+
addresses.map(addr => fetchAddressData(adapter, networkConfig, addr, blocksBack, checkTokens))
|
|
864
|
+
);
|
|
865
|
+
|
|
866
|
+
// Check threshold for any address
|
|
867
|
+
const threshold = parseThreshold(options.alertIfDiff);
|
|
868
|
+
const pctThreshold = parseThreshold(options.alertPct);
|
|
869
|
+
let anyAlertTriggered = false;
|
|
870
|
+
|
|
871
|
+
for (const r of results) {
|
|
872
|
+
if (r.error) continue;
|
|
873
|
+
|
|
874
|
+
const absTriggered = checkThreshold(r.balanceDiff.diff, networkConfig.nativeDecimals, threshold);
|
|
875
|
+
const pctTriggered = checkPercentageThreshold(
|
|
876
|
+
r.balanceDiff.diff,
|
|
877
|
+
r.balanceDiff.previous.raw,
|
|
878
|
+
networkConfig.nativeDecimals,
|
|
879
|
+
pctThreshold
|
|
880
|
+
);
|
|
881
|
+
|
|
882
|
+
if (absTriggered || pctTriggered) {
|
|
883
|
+
anyAlertTriggered = true;
|
|
884
|
+
break;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if (options.json) {
|
|
889
|
+
const output = buildMultiAddressJsonOutput(networkConfig, results.map(r => {
|
|
890
|
+
if (r.error) {
|
|
891
|
+
return { address: r.address, error: r.error };
|
|
892
|
+
}
|
|
893
|
+
const item = buildJsonOutput(networkConfig, r.address, r.balanceDiff, r.tokenBalances, adapter);
|
|
894
|
+
|
|
895
|
+
const absTriggered = checkThreshold(r.balanceDiff.diff, networkConfig.nativeDecimals, threshold);
|
|
896
|
+
const pctTriggered = checkPercentageThreshold(
|
|
897
|
+
r.balanceDiff.diff,
|
|
898
|
+
r.balanceDiff.previous.raw,
|
|
899
|
+
networkConfig.nativeDecimals,
|
|
900
|
+
pctThreshold
|
|
901
|
+
);
|
|
902
|
+
|
|
903
|
+
if (threshold || pctThreshold) {
|
|
904
|
+
item.alert = {
|
|
905
|
+
threshold: options.alertIfDiff || null,
|
|
906
|
+
thresholdPct: options.alertPct || null,
|
|
907
|
+
triggered: absTriggered || pctTriggered,
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
return item;
|
|
911
|
+
}));
|
|
912
|
+
if (threshold || pctThreshold) {
|
|
913
|
+
output.alert = {
|
|
914
|
+
threshold: options.alertIfDiff || null,
|
|
915
|
+
thresholdPct: options.alertPct || null,
|
|
916
|
+
triggered: anyAlertTriggered
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
console.log(JSON.stringify(output, null, 2));
|
|
920
|
+
} else {
|
|
921
|
+
printMultiAddressSummary(networkConfig, results, blocksBack);
|
|
922
|
+
if (anyAlertTriggered) {
|
|
923
|
+
const which = options.alertIfDiff || (options.alertPct + '%');
|
|
924
|
+
console.log(`${c('yellow')}⚠️ Alert: threshold ${which} triggered${c('reset')}\n`);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
process.exit(anyAlertTriggered ? EXIT_DIFF : EXIT_OK);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Single address mode
|
|
932
|
+
const address = addresses[0];
|
|
933
|
+
|
|
934
|
+
if (!options.json) {
|
|
935
|
+
const shortAddr = networkConfig.chainType === 'evm'
|
|
936
|
+
? `${address.slice(0, 8)}...${address.slice(-6)}`
|
|
937
|
+
: `${address.slice(0, 6)}...${address.slice(-6)}`;
|
|
938
|
+
console.log(`📊 Fetching balance data for ${shortAddr}`);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
try {
|
|
942
|
+
// Fetch native balance diff
|
|
943
|
+
const balanceDiff = await adapter.getNativeBalanceDiff(address, blocksBack);
|
|
944
|
+
|
|
945
|
+
// Fetch token balances
|
|
946
|
+
let tokenBalances = [];
|
|
947
|
+
if (checkTokens && networkConfig.tokens.length > 0) {
|
|
948
|
+
if (!options.json) {
|
|
949
|
+
console.log(`🪙 Checking ${networkConfig.tokens.length} tokens...`);
|
|
950
|
+
}
|
|
951
|
+
tokenBalances = await adapter.getTokenBalances(address, networkConfig.tokens);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Check thresholds if specified
|
|
955
|
+
const threshold = parseThreshold(options.alertIfDiff);
|
|
956
|
+
const pctThreshold = parseThreshold(options.alertPct);
|
|
957
|
+
|
|
958
|
+
const absTriggered = checkThreshold(balanceDiff.diff, networkConfig.nativeDecimals, threshold);
|
|
959
|
+
const pctTriggered = checkPercentageThreshold(
|
|
960
|
+
balanceDiff.diff,
|
|
961
|
+
balanceDiff.previous.raw,
|
|
962
|
+
networkConfig.nativeDecimals,
|
|
963
|
+
pctThreshold
|
|
964
|
+
);
|
|
965
|
+
const alertTriggered = absTriggered || pctTriggered;
|
|
966
|
+
|
|
967
|
+
// Output results
|
|
968
|
+
if (options.json) {
|
|
969
|
+
const output = buildJsonOutput(networkConfig, address, balanceDiff, tokenBalances, adapter);
|
|
970
|
+
if (threshold || pctThreshold) {
|
|
971
|
+
output.alert = {
|
|
972
|
+
threshold: options.alertIfDiff || null,
|
|
973
|
+
thresholdPct: options.alertPct || null,
|
|
974
|
+
triggered: alertTriggered,
|
|
975
|
+
triggeredBy: absTriggered ? 'absolute' : pctTriggered ? 'percentage' : null,
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
console.log(JSON.stringify(output, null, 2));
|
|
979
|
+
} else {
|
|
980
|
+
printPrettyOutput(networkConfig, address, balanceDiff, tokenBalances, adapter, blocksBack);
|
|
981
|
+
|
|
982
|
+
if (alertTriggered) {
|
|
983
|
+
const which = absTriggered ? options.alertIfDiff : options.alertPct + '%';
|
|
984
|
+
console.log(`${c('yellow')}⚠️ Alert: threshold ${which} triggered${c('reset')}\n`);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Exit with appropriate code
|
|
989
|
+
process.exit(alertTriggered ? EXIT_DIFF : EXIT_OK);
|
|
990
|
+
|
|
991
|
+
} catch (error) {
|
|
992
|
+
// Determine if this is an RPC error
|
|
993
|
+
const isRpcError = error.code === 'ENOTFOUND' ||
|
|
994
|
+
error.code === 'ECONNREFUSED' ||
|
|
995
|
+
error.message?.includes('429') ||
|
|
996
|
+
error.message?.includes('rate') ||
|
|
997
|
+
error.message?.includes('timeout');
|
|
998
|
+
|
|
999
|
+
if (options.json) {
|
|
1000
|
+
console.log(JSON.stringify({
|
|
1001
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1002
|
+
error: error.message,
|
|
1003
|
+
code: error.code || null,
|
|
1004
|
+
exitCode: isRpcError ? EXIT_RPC_ERROR : EXIT_DIFF,
|
|
1005
|
+
}));
|
|
1006
|
+
process.exit(isRpcError ? EXIT_RPC_ERROR : EXIT_DIFF);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
console.error();
|
|
1010
|
+
|
|
1011
|
+
if (error.message?.includes('Invalid public key')) {
|
|
1012
|
+
console.error(`❌ Invalid Solana address format`);
|
|
1013
|
+
console.error(` Address: ${address}`);
|
|
1014
|
+
} else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
|
|
1015
|
+
console.error(`❌ Network error: Could not connect to ${networkConfig.name}`);
|
|
1016
|
+
console.error(` RPC: ${networkConfig.rpcUrl}`);
|
|
1017
|
+
console.error(' Please check your internet connection or try a different RPC.');
|
|
1018
|
+
} else if (error.message?.includes('429') || error.message?.includes('rate')) {
|
|
1019
|
+
console.error(`❌ Rate limited by RPC endpoint`);
|
|
1020
|
+
console.error(' Try using a private RPC URL in your .env file.');
|
|
1021
|
+
} else {
|
|
1022
|
+
console.error(`❌ Error: ${error.message}`);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
console.error();
|
|
1026
|
+
process.exit(isRpcError ? EXIT_RPC_ERROR : EXIT_DIFF);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Run
|
|
1031
|
+
main();
|