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
|
@@ -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;
|