Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/features/messages/cards/WarpTransferDetailsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useMultiProvider } from '../../../store';
import { Message, WarpRouteDetails } from '../../../types';
import { tryGetBlockExplorerAddressUrl } from '../../../utils/url';
import { isCollateralRoute } from '../collateral/utils';
import { useWarpFees } from '../warpFees/useWarpFees';
import { KeyValueRow } from './KeyValueRow';
import { BlockExplorerAddressUrls } from './types';

Expand Down Expand Up @@ -50,6 +51,8 @@ export function WarpTransferDetailsCard({ message, warpRouteDetails, blur }: Pro
.catch(() => setBlockExplorerAddressUrls(undefined));
}, [getBlockExplorerLinks]);

const warpFees = useWarpFees(message, warpRouteDetails);

if (!warpRouteDetails) return null;

const { amount, transferRecipient, originToken, destinationToken } = warpRouteDetails;
Expand Down Expand Up @@ -89,6 +92,22 @@ export function WarpTransferDetailsCard({ message, warpRouteDetails, blur }: Pro
blurValue={blur}
showCopy
/>
{warpFees && (
<>
<KeyValueRow
label="Warp fee:"
labelWidth="w-28 sm:w-32"
display={`${warpFees.bridgeFee} ${warpFees.tokenSymbol}`}
blurValue={blur}
/>
<KeyValueRow
label="Total sent:"
labelWidth="w-28 sm:w-32"
display={`${warpFees.totalSent} ${warpFees.tokenSymbol}`}
blurValue={blur}
/>
</>
)}
<KeyValueRow
label="Origin token:"
labelWidth="w-28 sm:w-32"
Expand Down
116 changes: 116 additions & 0 deletions src/features/messages/warpFees/fetchWarpFees.test.ts
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();
});
});

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
});
});
162 changes: 162 additions & 0 deletions src/features/messages/warpFees/fetchWarpFees.ts
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This treats the whole receipt as one warp send.

Line 56 grabs the first SentTransferRemote, while Lines 65-70 add every matching Transfer in the receipt. If one tx emits two warp sends through the same router/token, each message can wind up showing the wrong fee or the same duplicated fee. The parser needs to correlate logs to the specific message, not just the tx-wide router/sender/token tuple.

Also applies to: 108-161

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/messages/warpFees/fetchWarpFees.ts` around lines 55 - 76, The
code currently treats the whole receipt as a single warp send by calling
parseSentTransferRemoteAmount(receipt.logs, routerAddress) and then summing all
ERC20 Transfer logs for the router/token; instead, locate the specific
SentTransferRemote log that belongs to this message (match by message fields
such as transferId/nonce/origin sender or by correlating logIndex/sequence) and
pass that identifier or log range into parsing so you only sum Transfer events
tied to that SentTransferRemote. Concretely: change
parseSentTransferRemoteAmount to accept message (or transferId/nonce) and return
the matching SentTransferRemote log (or its logIndex/identifier) rather than the
first match, and update parseTotalErc20TransferredToRouter to accept that
SentTransferRemote identifier/logIndex (or a filter function) so it only
aggregates Transfer events that are correlated to that specific
SentTransferRemote; apply the same per-message correlation where similar logic
exists in the 108-161 range.

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;
}
} catch {
// Not an ERC20 event
}
}

return found ? total : null;
}
21 changes: 21 additions & 0 deletions src/features/messages/warpFees/useWarpFees.ts
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,
});
Comment on lines +12 to +18
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

TanStack React Query v5 docs: if a query function catches an error and returns null, is that treated as a successful cached result? With staleTime: Infinity, will that null stay fresh and skip automatic refetches/retries?

💡 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 -20

Repository: hyperlane-xyz/hyperlane-explorer

Length of output: 169


🏁 Script executed:

cat -n src/features/messages/warpFees/useWarpFees.ts

Repository: hyperlane-xyz/hyperlane-explorer

Length of output: 971


🏁 Script executed:

cat -n src/features/messages/warpFees/fetchWarpFees.ts

Repository: hyperlane-xyz/hyperlane-explorer

Length of output: 6999


Layer some error retry logic to avoid caching transient RPC misses forever.

When fetchWarpFees catches an error and returns null, React Query treats that as a successful result. With staleTime: Infinity on line 17, that null sticks around for the entire session—even if the RPC was just temporarily unreachable. One brief blip becomes a permanent "no fees" state instead of retrying on the next mount or window focus.

The function logs the error (good), but swallowing it into a cached null violates the guideline: "Fail loudly with throw or console.error for unexpected issues; never add silent fallbacks." Either let unexpected errors throw so React Query retries them, or use a shorter staleTime with explicit retry logic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/messages/warpFees/useWarpFees.ts` around lines 12 - 18, The
current useQuery call in useWarpFees (queryKey ['warpFees', message.id,
warpRouteDetails?.originToken.addressOrDenom], staleTime: Infinity) caches null
when fetchWarpFees swallows errors, causing permanent “no fees”; update
fetchWarpFees to rethrow unexpected errors instead of returning null so React
Query treats failures as errors and will retry, and/or remove/shorten staleTime:
Infinity in the useQuery options and add retry/retryDelay settings so transient
RPC misses are retried on mount/focus; locate function fetchWarpFees and the
useQuery block in useWarpFees.ts and implement the rethrow or query retry
adjustments accordingly.


return data ?? null;
}
Loading