Skip to content

Commit c0873f8

Browse files
tkporterclaude
andauthored
feat(cli): add warp get-fees command and lower WBTC route fees (#7915)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent b5a2c19 commit c0873f8

7 files changed

Lines changed: 308 additions & 14 deletions

File tree

.changeset/warp-get-fees.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperlane-xyz/cli": minor
3+
---
4+
5+
Added `warp get-fees` command to display fees for warp route connections with USD estimates.

rust/sealevel/environments/mainnet3/gas-oracle-configs.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,24 +82,24 @@
8282
},
8383
"eclipsemainnet": {
8484
"oracleConfig": {
85-
"tokenExchangeRate": "34506608449374557",
86-
"gasPrice": "1754",
85+
"tokenExchangeRate": "37013334906773660",
86+
"gasPrice": "1181",
8787
"tokenDecimals": 9
8888
},
8989
"overhead": 600000
9090
},
9191
"electroneum": {
9292
"oracleConfig": {
93-
"tokenExchangeRate": "155925182912438",
94-
"gasPrice": "465265020654380",
93+
"tokenExchangeRate": "140909841869247",
94+
"gasPrice": "514843622602393",
9595
"tokenDecimals": 18
9696
},
9797
"overhead": 166887
9898
},
9999
"ethereum": {
100100
"oracleConfig": {
101101
"tokenExchangeRate": "345066084493745574699",
102-
"gasPrice": "1000000000",
102+
"gasPrice": "300000000",
103103
"tokenDecimals": 18
104104
},
105105
"overhead": 166887

typescript/cli/src/commands/warp.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
type CommandModuleWithWriteContext,
2929
} from '../context/types.js';
3030
import { runWarpRouteApply, runWarpRouteDeploy } from '../deploy/warp.js';
31+
import { runWarpRouteFees } from '../fees/warp.js';
3132
import { runForkCommand } from '../fork/fork.js';
3233
import {
3334
errorRed,
@@ -78,6 +79,7 @@ export const warpCommand: CommandModule = {
7879
.command(check)
7980
.command(deploy)
8081
.command(fork)
82+
.command(getFees)
8183
.command(init)
8284
.command(read)
8385
.command(rebalancer)
@@ -258,6 +260,34 @@ export const read: CommandModuleWithContext<
258260
},
259261
};
260262

263+
const getFees: CommandModuleWithContext<
264+
SelectWarpRouteBuilder & {
265+
amount?: string;
266+
}
267+
> = {
268+
command: 'get-fees',
269+
describe: 'Show fees for each pairwise connection on a warp route',
270+
builder: {
271+
...SELECT_WARP_ROUTE_BUILDER,
272+
amount: {
273+
type: 'string',
274+
description: 'Amount for fee quotes (human-readable, e.g., "1.5")',
275+
default: '1',
276+
},
277+
},
278+
handler: async ({ context, symbol, warp, warpRouteId, amount }) => {
279+
logCommandHeader('Hyperlane Warp Route Fees');
280+
await runWarpRouteFees({
281+
context,
282+
symbol,
283+
warpCoreConfigPath: warp,
284+
warpRouteId,
285+
amount: amount!,
286+
});
287+
process.exit(0);
288+
},
289+
};
290+
261291
const send: CommandModuleWithWriteContext<
262292
MessageOptionsArgTypes &
263293
SelectWarpRouteBuilder & {

typescript/cli/src/fees/warp.ts

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
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+
}

typescript/cli/src/tests/ethereum/warp/warp-check-ica.e2e-test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,7 @@ import {
2323
} from '@hyperlane-xyz/utils';
2424

2525
import { getContext } from '../../../context/context.js';
26-
import { writeYamlOrJson } from '../../../utils/files.js';
27-
import { readYamlOrJson } from '../../../utils/files.js';
26+
import { readYamlOrJson, writeYamlOrJson } from '../../../utils/files.js';
2827
import { deployOrUseExistingCore } from '../commands/core.js';
2928
import { deployToken } from '../commands/helpers.js';
3029
import {

typescript/infra/config/environments/mainnet3/gasPrices.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@
152152
"decimals": 9
153153
},
154154
"ethereum": {
155-
"amount": "1",
155+
"amount": "0.3",
156156
"decimals": 9
157157
},
158158
"everclear": {

typescript/infra/src/config/gas-oracle.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,10 @@ export function getTypicalHandleGasAmount(
247247
return 30_000_000;
248248
}
249249

250+
if (remoteProtocolType === ProtocolType.Sealevel) {
251+
return 300_000;
252+
}
253+
250254
// A fairly arbitrary amount of gas used in a message's handle function,
251255
// generally fits most VMs.
252256
return 50_000;
@@ -288,14 +292,9 @@ function getMinUsdCost(local: ChainName, remote: ChainName): number {
288292
scroll: 1.5,
289293
taiko: 0.5,
290294
// For Solana, special min cost
291-
solanamainnet: 1.2,
295+
solanamainnet: 0.8,
292296
};
293297

294-
if (local === 'ethereum' && remote === 'solanamainnet') {
295-
minUsdCost = 0.5;
296-
remoteMinCostOverrides['solanamainnet'] = 0.9;
297-
}
298-
299298
const override = remoteMinCostOverrides[remote];
300299
if (override !== undefined) {
301300
minUsdCost = Math.max(minUsdCost, override);

0 commit comments

Comments
 (0)