-
Notifications
You must be signed in to change notification settings - Fork 95
feat: parse and display warp route fees #290
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| import { BigNumber, utils } from 'ethers'; | ||
|
|
||
| import { | ||
| normalizeDecimals, | ||
| parseSentTransferRemoteAmount, | ||
| parseTotalErc20TransferredToRouter, | ||
| } from './fetchWarpFees'; | ||
|
|
||
| const erc20Iface = new utils.Interface([ | ||
| 'event Transfer(address indexed from, address indexed to, uint256 value)', | ||
| ]); | ||
| const routerIface = new utils.Interface([ | ||
| 'event SentTransferRemote(uint32 indexed destination, bytes32 indexed recipient, uint256 amountOrId)', | ||
| ]); | ||
|
|
||
| function makeTransferLog(from: string, to: string, value: BigNumber, address: string) { | ||
| const log = erc20Iface.encodeEventLog(erc20Iface.getEvent('Transfer'), [from, to, value]); | ||
| return { ...log, address }; | ||
| } | ||
|
|
||
| function makeSentTransferRemoteLog( | ||
| destination: number, | ||
| recipient: string, | ||
| amount: BigNumber, | ||
| address: string, | ||
| ) { | ||
| const log = routerIface.encodeEventLog(routerIface.getEvent('SentTransferRemote'), [ | ||
| destination, | ||
| recipient, | ||
| amount, | ||
| ]); | ||
| return { ...log, address }; | ||
| } | ||
|
|
||
| const ROUTER = utils.getAddress('0x1234567890123456789012345678901234567890'); | ||
| const SENDER = utils.getAddress('0xabcdef0123456789abcdef0123456789abcdef01'); | ||
| const TOKEN = utils.getAddress('0x000000000000000000000000000000000000dEaD'); | ||
| const RECIPIENT = '0x' + '00'.repeat(31) + '01'; | ||
|
|
||
| describe('parseSentTransferRemoteAmount', () => { | ||
| it('parses SentTransferRemote amount from router logs', () => { | ||
| const amount = BigNumber.from('995000'); | ||
| const log = makeSentTransferRemoteLog(137, RECIPIENT, amount, ROUTER); | ||
| const result = parseSentTransferRemoteAmount([log], ROUTER); | ||
| expect(result?.eq(amount)).toBe(true); | ||
| }); | ||
|
|
||
| it('returns null when no SentTransferRemote event exists', () => { | ||
| const log = makeTransferLog(SENDER, ROUTER, BigNumber.from('1000000'), ROUTER); | ||
| const result = parseSentTransferRemoteAmount([log], ROUTER); | ||
| expect(result).toBeNull(); | ||
| }); | ||
|
|
||
| it('ignores events from other addresses', () => { | ||
| const other = utils.getAddress('0x0000000000000000000000000000000000000001'); | ||
| const log = makeSentTransferRemoteLog(137, RECIPIENT, BigNumber.from('100'), other); | ||
| const result = parseSentTransferRemoteAmount([log], ROUTER); | ||
| expect(result).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('parseTotalErc20TransferredToRouter', () => { | ||
| it('sums ERC20 transfers from sender to router', () => { | ||
| const logs = [ | ||
| makeTransferLog(SENDER, ROUTER, BigNumber.from('1000000'), TOKEN), | ||
| makeTransferLog(SENDER, ROUTER, BigNumber.from('50000'), TOKEN), | ||
| ]; | ||
| const result = parseTotalErc20TransferredToRouter(logs, ROUTER, SENDER, TOKEN); | ||
| expect(result?.eq(BigNumber.from('1050000'))).toBe(true); | ||
| }); | ||
|
|
||
| it('ignores transfers from other senders', () => { | ||
| const other = utils.getAddress('0x0000000000000000000000000000000000000002'); | ||
| const logs = [makeTransferLog(other, ROUTER, BigNumber.from('1000000'), TOKEN)]; | ||
| const result = parseTotalErc20TransferredToRouter(logs, ROUTER, SENDER, TOKEN); | ||
| expect(result).toBeNull(); | ||
| }); | ||
|
|
||
| it('ignores transfers to other addresses', () => { | ||
| const other = utils.getAddress('0x0000000000000000000000000000000000000003'); | ||
| const logs = [makeTransferLog(SENDER, other, BigNumber.from('1000000'), other)]; | ||
| const result = parseTotalErc20TransferredToRouter(logs, ROUTER, SENDER, TOKEN); | ||
| expect(result).toBeNull(); | ||
| }); | ||
|
|
||
| it('ignores transfers from a different token contract', () => { | ||
| const otherToken = utils.getAddress('0x0000000000000000000000000000000000000099'); | ||
| const logs = [makeTransferLog(SENDER, ROUTER, BigNumber.from('1000000'), otherToken)]; | ||
| const result = parseTotalErc20TransferredToRouter(logs, ROUTER, SENDER, TOKEN); | ||
| expect(result).toBeNull(); | ||
| }); | ||
|
|
||
| it('returns null when no matching transfers found', () => { | ||
| const result = parseTotalErc20TransferredToRouter([], ROUTER, SENDER, TOKEN); | ||
| expect(result).toBeNull(); | ||
| }); | ||
| }); | ||
paulbalaji marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| describe('normalizeDecimals', () => { | ||
| it('returns value unchanged when decimals are equal', () => { | ||
| const value = BigNumber.from('1000000'); | ||
| expect(normalizeDecimals(value, 6, 6).eq(value)).toBe(true); | ||
| }); | ||
|
|
||
| it('scales down when fromDecimals > toDecimals', () => { | ||
| const value = BigNumber.from('1000000000000000000'); // 1e18 | ||
| const result = normalizeDecimals(value, 18, 6); | ||
| expect(result.eq(BigNumber.from('1000000'))).toBe(true); // 1e6 | ||
| }); | ||
|
|
||
| it('scales up when fromDecimals < toDecimals', () => { | ||
| const value = BigNumber.from('1000000'); // 1e6 | ||
| const result = normalizeDecimals(value, 6, 18); | ||
| expect(result.eq(BigNumber.from('1000000000000000000'))).toBe(true); // 1e18 | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| // eslint-disable-next-line camelcase | ||
| import { IERC20__factory, TokenRouter__factory } from '@hyperlane-xyz/core'; | ||
| import { MultiProtocolProvider } from '@hyperlane-xyz/sdk'; | ||
| import { fromWei } from '@hyperlane-xyz/utils'; | ||
| import { BigNumber } from 'ethers'; | ||
| import { Message, WarpRouteDetails } from '../../../types'; | ||
| import { formatAmountCompact } from '../../../utils/amount'; | ||
| import { logger } from '../../../utils/logger'; | ||
|
|
||
| export interface WarpFeeBreakdown { | ||
| bridgeFee: string; | ||
| tokenSymbol: string; | ||
| totalSent: string; | ||
| } | ||
|
|
||
| // Hoist interface objects to module scope to avoid re-creation per call | ||
| // eslint-disable-next-line camelcase | ||
| const routerIface = TokenRouter__factory.createInterface(); | ||
| // eslint-disable-next-line camelcase | ||
| const erc20Iface = IERC20__factory.createInterface(); | ||
|
|
||
| /** | ||
| * Parse warp route fees from the origin transaction receipt. | ||
| * | ||
| * Bridge fee = total ERC20 transferred to router - SentTransferRemote amount. | ||
| * Both values are in the origin token's native decimals: | ||
| * - ERC20 Transfer events use native decimals by definition | ||
| * - SentTransferRemote emits wireDecimals, but for same-decimal routes they match. | ||
| * We normalize the sent amount back to native decimals before subtracting. | ||
| * | ||
| * Only works for EVM ERC20 token routes (not native token routes). | ||
| * Does not work for smart contract wallet / multisig senders where tx.from != ERC20 sender. | ||
| */ | ||
| export async function fetchWarpFees( | ||
| message: Message, | ||
| warpRouteDetails: WarpRouteDetails, | ||
| multiProvider: MultiProtocolProvider, | ||
| ): Promise<WarpFeeBreakdown | null> { | ||
| try { | ||
| const chainMetadata = multiProvider.tryGetChainMetadata(message.originDomainId); | ||
| if (!chainMetadata || chainMetadata.protocol !== 'ethereum') return null; | ||
|
|
||
| const { decimals, symbol } = warpRouteDetails.originToken; | ||
| if (decimals === undefined) { | ||
| logger.warn('Token missing decimals, skipping fee parsing'); | ||
| return null; | ||
| } | ||
|
|
||
| const provider = multiProvider.getEthersV5Provider(message.originDomainId); | ||
| const receipt = await provider.getTransactionReceipt(message.origin.hash); | ||
| if (!receipt) return null; | ||
|
|
||
| const routerAddress = warpRouteDetails.originToken.addressOrDenom; | ||
|
|
||
| // SentTransferRemote amount is in wireDecimals (max decimals across route) | ||
| const sentAmountWire = parseSentTransferRemoteAmount(receipt.logs, routerAddress); | ||
| if (!sentAmountWire) return null; | ||
|
|
||
| // For collateral routes the ERC20 token differs from the router; | ||
| // for synthetic routes the router IS the ERC20 token. | ||
| const tokenAddress = (warpRouteDetails.originToken as Record<string, unknown>) | ||
| .collateralAddressOrDenom as string | undefined; | ||
|
|
||
| // ERC20 Transfer amounts are in native token decimals | ||
| const totalTransferred = parseTotalErc20TransferredToRouter( | ||
| receipt.logs, | ||
| routerAddress, | ||
| message.origin.from, | ||
| tokenAddress || routerAddress, | ||
| ); | ||
| if (!totalTransferred || totalTransferred.isZero()) return null; | ||
|
|
||
| // Normalize sentAmount from wireDecimals to native decimals | ||
| const wireDecimals = warpRouteDetails.originToken.wireDecimals ?? decimals; | ||
| const sentAmount = normalizeDecimals(sentAmountWire, wireDecimals, decimals); | ||
|
|
||
|
Comment on lines
+55
to
+76
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This treats the whole receipt as one warp send. Line 56 grabs the first Also applies to: 108-161 🤖 Prompt for AI Agents |
||
| const feeRaw = totalTransferred.sub(sentAmount); | ||
| if (feeRaw.isNegative()) { | ||
| logger.warn('Negative warp fee detected, likely a parsing issue'); | ||
| return null; | ||
| } | ||
| if (feeRaw.isZero()) return null; | ||
|
|
||
| return { | ||
| bridgeFee: formatAmountCompact(fromWei(feeRaw.toString(), decimals)), | ||
| tokenSymbol: symbol || 'tokens', | ||
| totalSent: formatAmountCompact(fromWei(totalTransferred.toString(), decimals)), | ||
| }; | ||
| } catch (err) { | ||
| logger.error('Error fetching warp fees:', err); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /** Normalize a BigNumber from one decimal basis to another */ | ||
| export function normalizeDecimals( | ||
| value: BigNumber, | ||
| fromDecimals: number, | ||
| toDecimals: number, | ||
| ): BigNumber { | ||
| if (fromDecimals === toDecimals) return value; | ||
| if (fromDecimals > toDecimals) { | ||
| return value.div(BigNumber.from(10).pow(fromDecimals - toDecimals)); | ||
| } | ||
| return value.mul(BigNumber.from(10).pow(toDecimals - fromDecimals)); | ||
| } | ||
|
|
||
| export function parseSentTransferRemoteAmount( | ||
| logs: Array<{ address: string; topics: string[]; data: string }>, | ||
| routerAddress: string, | ||
| ): BigNumber | null { | ||
| const lowerRouter = routerAddress.toLowerCase(); | ||
|
|
||
| for (const log of logs) { | ||
| if (log.address.toLowerCase() !== lowerRouter) continue; | ||
| try { | ||
| const parsed = routerIface.parseLog(log); | ||
| if (parsed.name === 'SentTransferRemote') { | ||
| return BigNumber.from(parsed.args.amountOrId); | ||
| } | ||
| } catch { | ||
| // Not a TokenRouter event | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| export function parseTotalErc20TransferredToRouter( | ||
| logs: Array<{ address: string; topics: string[]; data: string }>, | ||
| routerAddress: string, | ||
| senderAddress: string, | ||
| tokenAddress: string, | ||
| ): BigNumber | null { | ||
| const lowerRouter = routerAddress.toLowerCase(); | ||
| const lowerSender = senderAddress.toLowerCase(); | ||
| const lowerToken = tokenAddress.toLowerCase(); | ||
|
|
||
| let total = BigNumber.from(0); | ||
| let found = false; | ||
|
|
||
| for (const log of logs) { | ||
| // Only consider Transfer events from the expected token contract | ||
| if (log.address.toLowerCase() !== lowerToken) continue; | ||
| try { | ||
| const parsed = erc20Iface.parseLog(log); | ||
| if (parsed.name !== 'Transfer') continue; | ||
|
|
||
| const from = (parsed.args.from as string).toLowerCase(); | ||
| const to = (parsed.args.to as string).toLowerCase(); | ||
|
|
||
| // Sum all ERC20 transfers from the sender to the router | ||
| if (from === lowerSender && to === lowerRouter) { | ||
| total = total.add(parsed.args.value); | ||
| found = true; | ||
| } | ||
paulbalaji marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } catch { | ||
| // Not an ERC20 event | ||
| } | ||
| } | ||
paulbalaji marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| return found ? total : null; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { useQuery } from '@tanstack/react-query'; | ||
| import { useMultiProvider } from '../../../store'; | ||
| import { Message, WarpRouteDetails } from '../../../types'; | ||
| import { fetchWarpFees, WarpFeeBreakdown } from './fetchWarpFees'; | ||
|
|
||
| export function useWarpFees( | ||
| message: Message, | ||
| warpRouteDetails: WarpRouteDetails | undefined, | ||
| ): WarpFeeBreakdown | null { | ||
| const multiProvider = useMultiProvider(); | ||
|
|
||
| const { data } = useQuery({ | ||
| // eslint-disable-next-line @tanstack/query/exhaustive-deps | ||
| queryKey: ['warpFees', message.id, warpRouteDetails?.originToken.addressOrDenom], | ||
| queryFn: () => fetchWarpFees(message, warpRouteDetails!, multiProvider), | ||
| enabled: !!warpRouteDetails, | ||
| staleTime: Infinity, | ||
| }); | ||
paulbalaji marked this conversation as resolved.
Show resolved
Hide resolved
paulbalaji marked this conversation as resolved.
Show resolved
Hide resolved
Comment on lines
+12
to
+18
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Yes, if a query function catches an error and returns null (or any value), it is treated as a successful result by TanStack React Query v5. The library determines success solely by whether the queryFn promise resolves without throwing/rejecting. A resolved promise (with data=null) sets the query status to "success", caches the null value, and with staleTime: Infinity, that null will remain fresh. This prevents automatic refetches (like on mount, window focus, etc.) and skips retries (since no error occurred). To treat it as an error, the queryFn must throw. Null is a valid data value (unlike undefined, which is disallowed since v4). The cached null acts as any successful data for staleness checks. Citations:
🏁 Script executed: find src -name "fetchWarpFees.ts" -o -name "useWarpFees.ts" | head -20Repository: hyperlane-xyz/hyperlane-explorer Length of output: 169 🏁 Script executed: cat -n src/features/messages/warpFees/useWarpFees.tsRepository: hyperlane-xyz/hyperlane-explorer Length of output: 971 🏁 Script executed: cat -n src/features/messages/warpFees/fetchWarpFees.tsRepository: hyperlane-xyz/hyperlane-explorer Length of output: 6999 Layer some error retry logic to avoid caching transient RPC misses forever. When The function logs the error (good), but swallowing it into a cached 🤖 Prompt for AI Agents |
||
|
|
||
| return data ?? null; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.