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.
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Base Chain Adapter Interface
3
+ *
4
+ * Defines the contract that all chain adapters must implement.
5
+ * This enables the balance service to work with any blockchain
6
+ * without knowing the underlying implementation details.
7
+ *
8
+ * Supported chain types:
9
+ * - EVM (Ethereum, Polygon, Arbitrum, etc.) via ethers.js
10
+ * - Solana (including Helium tokens) via @solana/web3.js
11
+ *
12
+ * Future: Cosmos, Bitcoin, etc.
13
+ */
14
+
15
+ class BaseAdapter {
16
+ constructor(networkConfig) {
17
+ if (new.target === BaseAdapter) {
18
+ throw new Error('BaseAdapter is abstract and cannot be instantiated directly');
19
+ }
20
+ this.networkConfig = networkConfig;
21
+ this.connection = null;
22
+ }
23
+
24
+ /**
25
+ * Get the chain type identifier.
26
+ * @returns {string} Chain type ('evm', 'solana', etc.)
27
+ */
28
+ getChainType() {
29
+ throw new Error('getChainType() must be implemented');
30
+ }
31
+
32
+ /**
33
+ * Connect to the network RPC.
34
+ * @returns {Promise<void>}
35
+ */
36
+ async connect() {
37
+ throw new Error('connect() must be implemented');
38
+ }
39
+
40
+ /**
41
+ * Get current block/slot number.
42
+ * @returns {Promise<number>}
43
+ */
44
+ async getCurrentBlock() {
45
+ throw new Error('getCurrentBlock() must be implemented');
46
+ }
47
+
48
+ /**
49
+ * Get native balance for an address.
50
+ * @param {string} address - Wallet address
51
+ * @param {number|string} blockTag - Block number or 'latest'
52
+ * @returns {Promise<{raw: bigint, formatted: string, decimals: number}>}
53
+ */
54
+ async getNativeBalance(address, blockTag = 'latest') {
55
+ throw new Error('getNativeBalance() must be implemented');
56
+ }
57
+
58
+ /**
59
+ * Get native balance diff over N blocks/slots.
60
+ * @param {string} address - Wallet address
61
+ * @param {number} blocksBack - Number of blocks to look back
62
+ * @returns {Promise<{current: object, previous: object, diff: bigint, currentBlock: number, previousBlock: number}>}
63
+ */
64
+ async getNativeBalanceDiff(address, blocksBack) {
65
+ const currentBlock = await this.getCurrentBlock();
66
+ const previousBlock = Math.max(0, currentBlock - blocksBack);
67
+
68
+ const [current, previous] = await Promise.all([
69
+ this.getNativeBalance(address, currentBlock),
70
+ this.getNativeBalance(address, previousBlock),
71
+ ]);
72
+
73
+ return {
74
+ current,
75
+ previous,
76
+ diff: current.raw - previous.raw,
77
+ currentBlock,
78
+ previousBlock,
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Get token balances for an address.
84
+ * @param {string} address - Wallet address
85
+ * @param {object[]} tokens - Token configurations
86
+ * @returns {Promise<object[]>} Array of token balances
87
+ */
88
+ async getTokenBalances(address, tokens) {
89
+ throw new Error('getTokenBalances() must be implemented');
90
+ }
91
+
92
+ /**
93
+ * Validate an address format.
94
+ * @param {string} address - Address to validate
95
+ * @returns {boolean} True if valid
96
+ */
97
+ isValidAddress(address) {
98
+ throw new Error('isValidAddress() must be implemented');
99
+ }
100
+
101
+ /**
102
+ * Format a raw balance to human-readable string.
103
+ * @param {bigint} raw - Raw balance
104
+ * @param {number} decimals - Token decimals
105
+ * @returns {string} Formatted balance
106
+ */
107
+ formatBalance(raw, decimals) {
108
+ throw new Error('formatBalance() must be implemented');
109
+ }
110
+
111
+ /**
112
+ * Get explorer URL for an address.
113
+ * @param {string} address - Wallet address
114
+ * @returns {string} Block explorer URL
115
+ */
116
+ getExplorerUrl(address) {
117
+ return `${this.networkConfig.blockExplorer}/address/${address}`;
118
+ }
119
+ }
120
+
121
+ module.exports = BaseAdapter;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * EVM Chain Adapter
3
+ *
4
+ * Handles all EVM-compatible chains (Ethereum, Polygon, Arbitrum, etc.)
5
+ * using ethers.js. The EVM standard ensures consistent behavior across
6
+ * all compatible networks.
7
+ */
8
+
9
+ const { ethers } = require('ethers');
10
+ const BaseAdapter = require('./baseAdapter');
11
+
12
+ // Minimal ERC-20 ABI for balance queries
13
+ const ERC20_ABI = [
14
+ 'function balanceOf(address owner) view returns (uint256)',
15
+ 'function decimals() view returns (uint8)',
16
+ 'function symbol() view returns (string)',
17
+ ];
18
+
19
+ class EVMAdapter extends BaseAdapter {
20
+ constructor(networkConfig) {
21
+ super(networkConfig);
22
+ this.provider = null;
23
+ }
24
+
25
+ getChainType() {
26
+ return 'evm';
27
+ }
28
+
29
+ async connect() {
30
+ this.provider = new ethers.JsonRpcProvider(this.networkConfig.rpcUrl);
31
+ // Verify connection by fetching network
32
+ await this.provider.getNetwork();
33
+ this.connection = this.provider;
34
+ }
35
+
36
+ async getCurrentBlock() {
37
+ return this.provider.getBlockNumber();
38
+ }
39
+
40
+ async getNativeBalance(address, blockTag = 'latest') {
41
+ const raw = await this.provider.getBalance(address, blockTag);
42
+ return {
43
+ raw,
44
+ formatted: this.formatBalance(raw, 18),
45
+ decimals: 18,
46
+ };
47
+ }
48
+
49
+ async getTokenBalances(address, tokens) {
50
+ const results = await Promise.all(
51
+ tokens.map(token => this._getTokenBalance(address, token))
52
+ );
53
+
54
+ // Filter out failed fetches and zero balances
55
+ return results.filter(result =>
56
+ result !== null && result.raw > 0n
57
+ );
58
+ }
59
+
60
+ async _getTokenBalance(address, tokenConfig) {
61
+ try {
62
+ const contract = new ethers.Contract(
63
+ tokenConfig.address,
64
+ ERC20_ABI,
65
+ this.provider
66
+ );
67
+
68
+ const raw = await contract.balanceOf(address);
69
+
70
+ return {
71
+ symbol: tokenConfig.symbol,
72
+ address: tokenConfig.address,
73
+ raw,
74
+ formatted: this.formatBalance(raw, tokenConfig.decimals),
75
+ decimals: tokenConfig.decimals,
76
+ };
77
+ } catch (error) {
78
+ // Token contract might not exist or be inaccessible
79
+ return null;
80
+ }
81
+ }
82
+
83
+ isValidAddress(address) {
84
+ try {
85
+ ethers.getAddress(address);
86
+ return true;
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ formatBalance(raw, decimals = 18) {
93
+ const formatted = ethers.formatUnits(raw, decimals);
94
+ // Trim trailing zeros but keep reasonable precision
95
+ return parseFloat(formatted).toString();
96
+ }
97
+
98
+ /**
99
+ * Format balance with symbol for display.
100
+ * @param {bigint} raw - Raw balance in wei
101
+ * @param {string} symbol - Currency symbol
102
+ * @param {number} decimals - Decimals
103
+ * @returns {string} e.g., "1.234 ETH"
104
+ */
105
+ formatBalanceWithSymbol(raw, symbol, decimals = 18) {
106
+ const value = parseFloat(ethers.formatUnits(raw, decimals));
107
+ const display = value.toFixed(6).replace(/\.?0+$/, '');
108
+ return `${display} ${symbol}`;
109
+ }
110
+
111
+ /**
112
+ * Format balance diff with +/- prefix.
113
+ * @param {bigint} diff - Diff in wei
114
+ * @param {string} symbol - Currency symbol
115
+ * @param {number} decimals - Decimals
116
+ * @returns {string} e.g., "+0.01 ETH"
117
+ */
118
+ formatDiff(diff, symbol, decimals = 18) {
119
+ const value = parseFloat(ethers.formatUnits(diff, decimals));
120
+ const prefix = value >= 0 ? '+' : '';
121
+ const display = value.toFixed(6).replace(/\.?0+$/, '');
122
+ return `${prefix}${display} ${symbol}`;
123
+ }
124
+ }
125
+
126
+ module.exports = EVMAdapter;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Chain Adapter Factory
3
+ *
4
+ * Returns the appropriate adapter based on network configuration.
5
+ * This is the main entry point for multi-chain support.
6
+ *
7
+ * Usage:
8
+ * const adapter = createAdapter(networkConfig);
9
+ * await adapter.connect();
10
+ * const balance = await adapter.getNativeBalance(address);
11
+ */
12
+
13
+ const EVMAdapter = require('./evmAdapter');
14
+ const SolanaAdapter = require('./solanaAdapter');
15
+
16
+ /**
17
+ * Chain type to adapter class mapping.
18
+ */
19
+ const ADAPTER_MAP = {
20
+ evm: EVMAdapter,
21
+ solana: SolanaAdapter,
22
+ };
23
+
24
+ /**
25
+ * Create an adapter for the given network configuration.
26
+ *
27
+ * @param {object} networkConfig - Network configuration from networks.js
28
+ * @returns {BaseAdapter} Chain-specific adapter instance
29
+ * @throws {Error} If chain type is not supported
30
+ */
31
+ function createAdapter(networkConfig) {
32
+ // Determine chain type (default to 'evm' for backwards compatibility)
33
+ const chainType = networkConfig.chainType || 'evm';
34
+
35
+ const AdapterClass = ADAPTER_MAP[chainType];
36
+
37
+ if (!AdapterClass) {
38
+ throw new Error(
39
+ `Unsupported chain type: ${chainType}. ` +
40
+ `Supported types: ${Object.keys(ADAPTER_MAP).join(', ')}`
41
+ );
42
+ }
43
+
44
+ return new AdapterClass(networkConfig);
45
+ }
46
+
47
+ /**
48
+ * Get list of supported chain types.
49
+ * @returns {string[]}
50
+ */
51
+ function getSupportedChainTypes() {
52
+ return Object.keys(ADAPTER_MAP);
53
+ }
54
+
55
+ /**
56
+ * Check if a chain type is supported.
57
+ * @param {string} chainType
58
+ * @returns {boolean}
59
+ */
60
+ function isChainTypeSupported(chainType) {
61
+ return chainType in ADAPTER_MAP;
62
+ }
63
+
64
+ module.exports = {
65
+ createAdapter,
66
+ getSupportedChainTypes,
67
+ isChainTypeSupported,
68
+ EVMAdapter,
69
+ SolanaAdapter,
70
+ };
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Solana Chain Adapter
3
+ *
4
+ * Handles Solana blockchain and SPL tokens, including:
5
+ * - Native SOL balance
6
+ * - SPL token balances (HNT, MOBILE, IOT for Helium ecosystem)
7
+ *
8
+ * Uses @solana/web3.js for RPC communication.
9
+ */
10
+
11
+ const {
12
+ Connection,
13
+ PublicKey,
14
+ LAMPORTS_PER_SOL,
15
+ } = require('@solana/web3.js');
16
+ const BaseAdapter = require('./baseAdapter');
17
+
18
+ // SPL Token Program ID
19
+ const TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
20
+
21
+ class SolanaAdapter extends BaseAdapter {
22
+ constructor(networkConfig) {
23
+ super(networkConfig);
24
+ this.conn = null;
25
+ }
26
+
27
+ getChainType() {
28
+ return 'solana';
29
+ }
30
+
31
+ async connect() {
32
+ this.conn = new Connection(
33
+ this.networkConfig.rpcUrl,
34
+ { commitment: 'confirmed' }
35
+ );
36
+ // Verify connection
37
+ await this.conn.getSlot();
38
+ this.connection = this.conn;
39
+ }
40
+
41
+ async getCurrentBlock() {
42
+ // Solana uses "slots" instead of blocks
43
+ return this.conn.getSlot();
44
+ }
45
+
46
+ async getNativeBalance(address, slotTag = 'latest') {
47
+ const pubkey = new PublicKey(address);
48
+
49
+ let raw;
50
+ if (slotTag === 'latest') {
51
+ raw = await this.conn.getBalance(pubkey);
52
+ } else {
53
+ // Historical balance query with specific slot
54
+ // Note: Many RPC nodes don't support historical queries
55
+ // We use getBalanceAndContext for better reliability
56
+ try {
57
+ const result = await this.conn.getBalance(pubkey, {
58
+ minContextSlot: slotTag,
59
+ });
60
+ raw = result;
61
+ } catch (error) {
62
+ // Fallback to current if historical not supported
63
+ raw = await this.conn.getBalance(pubkey);
64
+ }
65
+ }
66
+
67
+ return {
68
+ raw: BigInt(raw),
69
+ formatted: this.formatBalance(BigInt(raw), 9), // SOL has 9 decimals
70
+ decimals: 9,
71
+ };
72
+ }
73
+
74
+ async getTokenBalances(address, tokens) {
75
+ const pubkey = new PublicKey(address);
76
+
77
+ try {
78
+ // Fetch all token accounts for this wallet
79
+ const tokenAccounts = await this.conn.getParsedTokenAccountsByOwner(
80
+ pubkey,
81
+ { programId: TOKEN_PROGRAM_ID }
82
+ );
83
+
84
+ // Build lookup map from mint address to our token config
85
+ const tokenLookup = new Map();
86
+ for (const token of tokens) {
87
+ // Solana tokens use 'mint' instead of 'address'
88
+ const mintAddress = token.mint || token.address;
89
+ if (mintAddress) {
90
+ tokenLookup.set(mintAddress, token);
91
+ }
92
+ }
93
+
94
+ // Process token accounts
95
+ const results = [];
96
+ for (const { account } of tokenAccounts.value) {
97
+ const parsed = account.data.parsed?.info;
98
+ if (!parsed) continue;
99
+
100
+ const mint = parsed.mint;
101
+ const tokenConfig = tokenLookup.get(mint);
102
+
103
+ if (tokenConfig) {
104
+ const amount = parsed.tokenAmount;
105
+ const raw = BigInt(amount.amount);
106
+
107
+ if (raw > 0n) {
108
+ results.push({
109
+ symbol: tokenConfig.symbol,
110
+ mint: mint,
111
+ raw,
112
+ formatted: amount.uiAmountString || this.formatBalance(raw, amount.decimals),
113
+ decimals: amount.decimals,
114
+ });
115
+ }
116
+ }
117
+ }
118
+
119
+ return results;
120
+ } catch (error) {
121
+ console.warn(`Warning: Could not fetch SPL tokens: ${error.message}`);
122
+ return [];
123
+ }
124
+ }
125
+
126
+ isValidAddress(address) {
127
+ try {
128
+ new PublicKey(address);
129
+ return true;
130
+ } catch {
131
+ return false;
132
+ }
133
+ }
134
+
135
+ formatBalance(raw, decimals = 9) {
136
+ const divisor = BigInt(10 ** decimals);
137
+ const whole = raw / divisor;
138
+ const fraction = raw % divisor;
139
+
140
+ if (fraction === 0n) {
141
+ return whole.toString();
142
+ }
143
+
144
+ // Format fraction with proper padding
145
+ const fractionStr = fraction.toString().padStart(decimals, '0');
146
+ // Trim trailing zeros
147
+ const trimmed = fractionStr.replace(/0+$/, '');
148
+
149
+ return `${whole}.${trimmed}`;
150
+ }
151
+
152
+ /**
153
+ * Format balance with symbol for display.
154
+ */
155
+ formatBalanceWithSymbol(raw, symbol, decimals = 9) {
156
+ return `${this.formatBalance(raw, decimals)} ${symbol}`;
157
+ }
158
+
159
+ /**
160
+ * Format balance diff with +/- prefix.
161
+ */
162
+ formatDiff(diff, symbol, decimals = 9) {
163
+ const formatted = this.formatBalance(diff < 0n ? -diff : diff, decimals);
164
+ const prefix = diff >= 0n ? '+' : '-';
165
+ return `${prefix}${formatted} ${symbol}`;
166
+ }
167
+
168
+ /**
169
+ * Get Solana-specific explorer URL.
170
+ */
171
+ getExplorerUrl(address) {
172
+ // Determine if mainnet or devnet based on RPC
173
+ const isDevnet = this.networkConfig.rpcUrl.includes('devnet');
174
+ const cluster = isDevnet ? '?cluster=devnet' : '';
175
+ return `https://explorer.solana.com/address/${address}${cluster}`;
176
+ }
177
+ }
178
+
179
+ module.exports = SolanaAdapter;