|
| 1 | +import { |
| 2 | + type ChainMap, |
| 3 | + CoinGeckoTokenPriceGetter, |
| 4 | + WarpCore, |
| 5 | + type WarpCoreConfig, |
| 6 | +} from '@hyperlane-xyz/sdk'; |
| 7 | +import { type Address, ProtocolType, toWei } from '@hyperlane-xyz/utils'; |
| 8 | + |
| 9 | +import { type CommandContext } from '../context/types.js'; |
| 10 | +import { logBlue, logGreen, logTable, warnYellow } from '../logger.js'; |
| 11 | +import { ENV } from '../utils/env.js'; |
| 12 | +import { getWarpCoreConfigOrExit } from '../utils/warp.js'; |
| 13 | + |
| 14 | +interface FeeRow { |
| 15 | + Origin: string; |
| 16 | + Destination: string; |
| 17 | + 'Fee Amount': string; |
| 18 | + 'Fee Token': string; |
| 19 | + 'USD Cost': string; |
| 20 | +} |
| 21 | + |
| 22 | +// Placeholder addresses for fee quotes |
| 23 | +// Note: Sealevel fee quotes require a funded sender for transaction simulation, |
| 24 | +// so we use the Hyperlane relayer account as a known funded address |
| 25 | +// Note: EVM can't use zero address as it fails "address bytes must not be empty" validation |
| 26 | +const PLACEHOLDER_ADDRESSES: Record<ProtocolType, string> = { |
| 27 | + [ProtocolType.Ethereum]: '0x0000000000000000000000000000000000000001', |
| 28 | + [ProtocolType.Sealevel]: 'G5FM3UKwcBJ47PwLWLLY1RQpqNtTMgnqnd6nZGcJqaBp', // Hyperlane relayer (funded) |
| 29 | + [ProtocolType.Cosmos]: 'cosmos1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqnrql8a', |
| 30 | + [ProtocolType.CosmosNative]: 'cosmos1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqnrql8a', |
| 31 | + [ProtocolType.Starknet]: '0x0', |
| 32 | + [ProtocolType.Aleo]: |
| 33 | + 'aleo1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3ljyzc', |
| 34 | + [ProtocolType.Radix]: |
| 35 | + 'resource_rdx1tknxxxxxxxxxradxrdxxxxxxxxx009923554798xxxxxxxxxradxrd', |
| 36 | +}; |
| 37 | + |
| 38 | +export async function runWarpRouteFees({ |
| 39 | + context, |
| 40 | + symbol, |
| 41 | + warpCoreConfigPath, |
| 42 | + warpRouteId, |
| 43 | + amount, |
| 44 | +}: { |
| 45 | + context: CommandContext; |
| 46 | + symbol?: string; |
| 47 | + warpCoreConfigPath?: string; |
| 48 | + warpRouteId?: string; |
| 49 | + amount: string; |
| 50 | +}): Promise<void> { |
| 51 | + // Load warp core config |
| 52 | + const warpCoreConfig: WarpCoreConfig = await getWarpCoreConfigOrExit({ |
| 53 | + context, |
| 54 | + symbol, |
| 55 | + warp: warpCoreConfigPath, |
| 56 | + warpRouteId, |
| 57 | + }); |
| 58 | + |
| 59 | + // Get registry addresses and extend multiProtocolProvider with mailbox metadata |
| 60 | + // This is needed for Sealevel chains which require mailbox address for fee quotes |
| 61 | + const registryAddresses = await context.registry.getAddresses(); |
| 62 | + const mailboxMetadata: ChainMap<{ mailbox?: Address }> = {}; |
| 63 | + for (const token of warpCoreConfig.tokens) { |
| 64 | + const chainAddresses = registryAddresses[token.chainName]; |
| 65 | + if (chainAddresses?.mailbox) { |
| 66 | + mailboxMetadata[token.chainName] = { mailbox: chainAddresses.mailbox }; |
| 67 | + } |
| 68 | + } |
| 69 | + const multiProviderWithMailbox = |
| 70 | + context.multiProtocolProvider.extendChainMetadata(mailboxMetadata); |
| 71 | + |
| 72 | + // Create WarpCore |
| 73 | + const warpCore = WarpCore.FromConfig( |
| 74 | + multiProviderWithMailbox, |
| 75 | + warpCoreConfig, |
| 76 | + ); |
| 77 | + |
| 78 | + // Create price getter (optional) |
| 79 | + let priceGetter: CoinGeckoTokenPriceGetter | undefined; |
| 80 | + const coingeckoApiKey = ENV.COINGECKO_API_KEY; |
| 81 | + try { |
| 82 | + priceGetter = new CoinGeckoTokenPriceGetter({ |
| 83 | + chainMetadata: context.chainMetadata, |
| 84 | + apiKey: coingeckoApiKey, |
| 85 | + sleepMsBetweenRequests: 500, |
| 86 | + }); |
| 87 | + } catch (_e) { |
| 88 | + warnYellow( |
| 89 | + 'Could not initialize CoinGecko price getter, USD prices will not be shown', |
| 90 | + ); |
| 91 | + } |
| 92 | + |
| 93 | + // Find coinGeckoId for the warp route token (usually on collateral/native types). |
| 94 | + // Assumes all tokens in the warp route are equal in value (standard warp route assumption) |
| 95 | + // and that token fees are charged in the bridged token (WarpCore assumption). |
| 96 | + const warpTokenCoinGeckoId = warpCoreConfig.tokens.find( |
| 97 | + (t) => t.coinGeckoId, |
| 98 | + )?.coinGeckoId; |
| 99 | + |
| 100 | + // Collect fee data for all routes |
| 101 | + const feeRows: FeeRow[] = []; |
| 102 | + // Track total USD costs for matrix display: origin -> destination -> cost |
| 103 | + const totalUsdMatrix: Record<string, Record<string, string>> = {}; |
| 104 | + |
| 105 | + for (const token of warpCore.tokens) { |
| 106 | + const connections = token.getConnections(); |
| 107 | + |
| 108 | + for (const connection of connections) { |
| 109 | + const destChain = connection.token.chainName; |
| 110 | + let igpUsdCost: number | null = null; |
| 111 | + let tokenFeeUsdCost: number | null = null; |
| 112 | + |
| 113 | + try { |
| 114 | + // Convert human-readable amount to smallest unit |
| 115 | + const amountWei = toWei(amount, token.decimals); |
| 116 | + const originTokenAmount = token.amount(amountWei); |
| 117 | + |
| 118 | + // Use protocol-appropriate placeholder addresses |
| 119 | + const senderAddress = PLACEHOLDER_ADDRESSES[token.protocol]; |
| 120 | + const recipientAddress = |
| 121 | + PLACEHOLDER_ADDRESSES[connection.token.protocol]; |
| 122 | + |
| 123 | + const { igpQuote, tokenFeeQuote } = |
| 124 | + await warpCore.getInterchainTransferFee({ |
| 125 | + originTokenAmount, |
| 126 | + destination: destChain, |
| 127 | + sender: senderAddress, |
| 128 | + recipient: recipientAddress, |
| 129 | + }); |
| 130 | + |
| 131 | + const igpFormatted = igpQuote.getDecimalFormattedAmount(); |
| 132 | + let igpUsdStr = 'N/A'; |
| 133 | + |
| 134 | + if (priceGetter) { |
| 135 | + try { |
| 136 | + const price = await priceGetter.getTokenPrice( |
| 137 | + igpQuote.token.chainName, |
| 138 | + ); |
| 139 | + igpUsdCost = igpFormatted * price; |
| 140 | + igpUsdStr = `~$${igpUsdCost.toFixed(2)}`; |
| 141 | + } catch (e) { |
| 142 | + warnYellow( |
| 143 | + `Could not fetch USD price for ${igpQuote.token.chainName}: ${e}`, |
| 144 | + ); |
| 145 | + } |
| 146 | + } |
| 147 | + |
| 148 | + // Calculate total USD (start with IGP) |
| 149 | + let totalUsd = igpUsdCost; |
| 150 | + |
| 151 | + // Add IGP row |
| 152 | + feeRows.push({ |
| 153 | + Origin: token.chainName, |
| 154 | + Destination: destChain, |
| 155 | + 'Fee Amount': igpFormatted.toFixed(8), |
| 156 | + 'Fee Token': igpQuote.token.symbol, |
| 157 | + 'USD Cost': igpUsdStr, |
| 158 | + }); |
| 159 | + |
| 160 | + // Handle token fee if present |
| 161 | + if (tokenFeeQuote) { |
| 162 | + const tokenFeeFormatted = tokenFeeQuote.getDecimalFormattedAmount(); |
| 163 | + let tokenFeeUsdStr = 'N/A'; |
| 164 | + |
| 165 | + // Use warp token's coinGeckoId for token fee pricing (not chain's native token) |
| 166 | + if (priceGetter && warpTokenCoinGeckoId) { |
| 167 | + try { |
| 168 | + const prices = await priceGetter.getTokenPriceByIds([ |
| 169 | + warpTokenCoinGeckoId, |
| 170 | + ]); |
| 171 | + if (prices && prices[0]) { |
| 172 | + tokenFeeUsdCost = tokenFeeFormatted * prices[0]; |
| 173 | + tokenFeeUsdStr = `~$${tokenFeeUsdCost.toFixed(2)}`; |
| 174 | + |
| 175 | + // Add to total |
| 176 | + if (totalUsd !== null) { |
| 177 | + totalUsd += tokenFeeUsdCost; |
| 178 | + } else { |
| 179 | + totalUsd = tokenFeeUsdCost; |
| 180 | + } |
| 181 | + } |
| 182 | + } catch (e) { |
| 183 | + warnYellow( |
| 184 | + `Could not fetch USD price for ${tokenFeeQuote.token.symbol}: ${e}`, |
| 185 | + ); |
| 186 | + } |
| 187 | + } |
| 188 | + |
| 189 | + // Add token fee row |
| 190 | + feeRows.push({ |
| 191 | + Origin: token.chainName, |
| 192 | + Destination: `${destChain} (token)`, |
| 193 | + 'Fee Amount': tokenFeeFormatted.toFixed(8), |
| 194 | + 'Fee Token': tokenFeeQuote.token.symbol, |
| 195 | + 'USD Cost': tokenFeeUsdStr, |
| 196 | + }); |
| 197 | + } |
| 198 | + |
| 199 | + // Store total USD for matrix |
| 200 | + if (!totalUsdMatrix[token.chainName]) { |
| 201 | + totalUsdMatrix[token.chainName] = {}; |
| 202 | + } |
| 203 | + totalUsdMatrix[token.chainName][destChain] = |
| 204 | + totalUsd !== null ? `$${totalUsd.toFixed(2)}` : 'N/A'; |
| 205 | + } catch (e) { |
| 206 | + warnYellow( |
| 207 | + `Could not fetch fee for ${token.chainName} -> ${destChain}: ${e}`, |
| 208 | + ); |
| 209 | + feeRows.push({ |
| 210 | + Origin: token.chainName, |
| 211 | + Destination: destChain, |
| 212 | + 'Fee Amount': 'Error', |
| 213 | + 'Fee Token': '-', |
| 214 | + 'USD Cost': 'N/A', |
| 215 | + }); |
| 216 | + |
| 217 | + // Store error in matrix |
| 218 | + if (!totalUsdMatrix[token.chainName]) { |
| 219 | + totalUsdMatrix[token.chainName] = {}; |
| 220 | + } |
| 221 | + totalUsdMatrix[token.chainName][destChain] = 'Error'; |
| 222 | + } |
| 223 | + } |
| 224 | + } |
| 225 | + |
| 226 | + // Display results |
| 227 | + if (feeRows.length === 0) { |
| 228 | + logBlue('No routes found in warp config'); |
| 229 | + return; |
| 230 | + } |
| 231 | + |
| 232 | + // Display detailed fee breakdown |
| 233 | + logBlue('\nFee Breakdown:'); |
| 234 | + logTable(feeRows); |
| 235 | + |
| 236 | + // Build and display N x N matrix of total USD costs |
| 237 | + const chains = [...new Set(warpCore.tokens.map((t) => t.chainName))].sort(); |
| 238 | + if (priceGetter && chains.length > 1) { |
| 239 | + logBlue('\nTotal USD Cost Matrix (From → To):'); |
| 240 | + |
| 241 | + // Build matrix rows with chain name as first column |
| 242 | + const matrixRows: Record<string, string>[] = chains.map((fromChain) => { |
| 243 | + const row: Record<string, string> = { From: fromChain }; |
| 244 | + for (const toChain of chains) { |
| 245 | + if (fromChain === toChain) { |
| 246 | + row[toChain] = '-'; |
| 247 | + } else { |
| 248 | + row[toChain] = totalUsdMatrix[fromChain]?.[toChain] ?? 'N/A'; |
| 249 | + } |
| 250 | + } |
| 251 | + return row; |
| 252 | + }); |
| 253 | + |
| 254 | + logTable(matrixRows); |
| 255 | + } |
| 256 | + |
| 257 | + logGreen(`\nFees quoted for ${amount} token transfer.`); |
| 258 | + if (priceGetter) { |
| 259 | + logBlue('USD prices from CoinGecko (approximate).'); |
| 260 | + } |
| 261 | +} |
0 commit comments