Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9aaa758
feat: initial fee draft
Xaroz Aug 15, 2025
b594054
chore: show fees formatted
Xaroz Aug 19, 2025
54930b9
chore: test native route
Xaroz Aug 28, 2025
ddb064d
feat: initial fee section and getTotalFee function and tests
Xaroz Sep 3, 2025
d98e519
feat: fees modal and total fees logic
Xaroz Sep 3, 2025
e0197ee
chore: minor improvements to the sections
Xaroz Sep 3, 2025
e084d88
chore: add loading skeleton
Xaroz Sep 8, 2025
7df4f74
chore: get lowest fee route
Xaroz Sep 10, 2025
13ed533
feat: get lowest fee route
Xaroz Sep 11, 2025
82000c6
fix: tokens sort fee comparison
Xaroz Sep 12, 2025
eb3d6aa
chore: some clariying comments
Xaroz Sep 12, 2025
c022a2a
chore: get lowest fee token for max transfer
Xaroz Sep 17, 2025
55890f1
feat: maxTransferAmount with lowest fee mcwr
Xaroz Sep 18, 2025
f4b4356
chore: omit sender in params
Xaroz Sep 19, 2025
4daa2b0
Merge pull request #754 from hyperlane-xyz/xaroz/mcwr-max-transfer
Xaroz Sep 19, 2025
13ada8d
chore: multi-collateral routes
Xaroz Oct 16, 2025
1b4fcdf
Merge branch 'feat/routes-fee' of github.com:hyperlane-xyz/hyperlane-…
Xaroz Oct 22, 2025
912febb
chore: new version routes
Xaroz Nov 3, 2025
7bd5e80
Merge remote-tracking branch 'origin' into feat/routes-fee
Xaroz Nov 3, 2025
62da86d
chore: look for routes with enough collateral
Xaroz Nov 3, 2025
efd59fb
chore: clean up
Xaroz Nov 3, 2025
f7cfb22
chore: remove console log
Xaroz Nov 3, 2025
905e234
chore: re-add packages updates
Xaroz Nov 3, 2025
46c81d0
chore: upgrade to proper version
Xaroz Nov 3, 2025
7a5db07
fix: spacing and styling for continue button
Xaroz Nov 4, 2025
92b3d15
chore: upgrade packages
Xaroz Nov 5, 2025
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
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@
"@emotion/styled": "^11.13.0",
"@headlessui/react": "^2.2.0",
"@hyperlane-xyz/registry": "23.4.0",
"@hyperlane-xyz/sdk": "19.5.0",
"@hyperlane-xyz/utils": "19.5.0",
"@hyperlane-xyz/widgets": "19.5.0",
"@hyperlane-xyz/sdk": "19.7.0",
"@hyperlane-xyz/utils": "19.7.0",
"@hyperlane-xyz/widgets": "19.7.0",
"@interchain-ui/react": "^1.23.28",
"@metamask/post-message-stream": "6.1.2",
"@metamask/providers": "10.2.1",
Expand Down
2 changes: 1 addition & 1 deletion src/features/transfer/FeeSectionButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function FeeSectionButton({

return (
<>
<div className="mt-2 h-2">
<div className="mb-2 mt-2 h-2">
{isLoading ? (
<Skeleton className="h-4 w-72" />
) : fees ? (
Expand Down
14 changes: 14 additions & 0 deletions src/features/transfer/TransferFeeModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ export function TransferFeeModal({
)}
</div>
)}
{fees?.tokenFeeQuote && fees.tokenFeeQuote.amount > 0n && (
<div className="flex gap-4">
<span className="flex min-w-[7.5rem] items-center gap-1">
Token Fee <Tooltip content="Variable fee based on amount" id="token-fee-tooltip" />
</span>
{isLoading ? (
<Skeleton className="h-4 w-52" />
) : (
<span>{`${fees.tokenFeeQuote.getDecimalFormattedAmount().toFixed(8) || '0'} ${
fees.tokenFeeQuote.token.symbol || ''
}`}</span>
)}
</div>
)}
<span className="mt-2">
Read more about{' '}
<Link
Expand Down
41 changes: 28 additions & 13 deletions src/features/transfer/TransferTokenForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ import { useTokenPrice } from '../tokens/useTokenPrice';
import { WalletConnectionWarning } from '../wallet/WalletConnectionWarning';
import { FeeSectionButton } from './FeeSectionButton';
import { RecipientConfirmationModal } from './RecipientConfirmationModal';
import { getInterchainQuote, getTotalFee, getTransferToken } from './fees';
import { getInterchainQuote, getLowestFeeTransferToken, getTotalFee } from './fees';
import { useFetchMaxAmount } from './maxAmount';
import { TransferFormValues } from './types';
import { useRecipientBalanceWatcher } from './useBalanceWatcher';
Expand Down Expand Up @@ -515,7 +515,7 @@ function ButtonSection({
disabled={!addressConfirmed}
chainName={values.origin}
text={isValidating ? 'Validating...' : 'Continue'}
classes={`${isReview ? 'mt-4' : 'mt-2'} px-3 py-1.5`}
classes={`${isReview ? 'mt-4' : 'mt-0'} px-3 py-1.5`}
/>
</>
);
Expand Down Expand Up @@ -696,6 +696,7 @@ function ReviewDetails({
return (
<>
{!isReview && <FeeSectionButton visible={!isReview} fees={fees} isLoading={isLoading} />}

<div
className={`${
isReview ? 'max-h-screen duration-1000 ease-in' : 'max-h-0 duration-500'
Expand Down Expand Up @@ -743,19 +744,27 @@ function ReviewDetails({
{fees?.localQuote && fees.localQuote.amount > 0n && (
<p className="flex">
<span className="min-w-[7.5rem]">Local Gas (est.)</span>
<span>{`${fees.localQuote.getDecimalFormattedAmount().toFixed(4) || '0'} ${
<span>{`${fees.localQuote.getDecimalFormattedAmount().toFixed(8) || '0'} ${
fees.localQuote.token.symbol || ''
}`}</span>
</p>
)}
{fees?.interchainQuote && fees.interchainQuote.amount > 0n && (
<p className="flex">
<span className="min-w-[7.5rem]">Interchain Gas</span>
<span>{`${fees.interchainQuote.getDecimalFormattedAmount().toFixed(4) || '0'} ${
<span>{`${fees.interchainQuote.getDecimalFormattedAmount().toFixed(8) || '0'} ${
fees.interchainQuote.token.symbol || ''
}`}</span>
</p>
)}
{fees?.tokenFeeQuote && fees.tokenFeeQuote.amount > 0n && (
<p className="flex">
<span className="min-w-[7.5rem]">Token Fee</span>
<span>{`${fees.tokenFeeQuote.getDecimalFormattedAmount().toFixed(8) || '0'} ${
fees.tokenFeeQuote.token.symbol || ''
}`}</span>
</p>
)}
</div>
</div>
</>
Expand Down Expand Up @@ -845,8 +854,20 @@ async function validateForm(
return [{ recipient: 'Warp Route address is not valid as recipient' }, null];
}

const transferToken = await getTransferToken(warpCore, token, destinationToken);
const amountWei = toWei(amount, transferToken.decimals);
const { address: sender, publicKey: senderPubKey } = getAccountAddressAndPubKey(
warpCore.multiProvider,
origin,
accounts,
);
const amountWei = toWei(amount, token.decimals);
const transferToken = await getLowestFeeTransferToken(
warpCore,
token,
destinationToken,
amountWei,
recipient,
sender,
);
const multiCollateralLimit = isMultiCollateralLimitExceeded(token, destination, amountWei);

if (multiCollateralLimit) {
Expand All @@ -858,17 +879,11 @@ async function validateForm(
];
}

const { address, publicKey: senderPubKey } = getAccountAddressAndPubKey(
warpCore.multiProvider,
origin,
accounts,
);

const result = await warpCore.validateTransfer({
originTokenAmount: transferToken.amount(amountWei),
destination,
recipient,
sender: address || '',
sender: sender || '',
senderPubKey: await senderPubKey,
});

Expand Down
71 changes: 57 additions & 14 deletions src/features/transfer/fees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { chainsRentEstimate } from '../../consts/chains';
import { logger } from '../../utils/logger';
import { getTokensWithSameCollateralAddresses, isValidMultiCollateralToken } from '../tokens/utils';

// get the total amount combined of all the fees
export function getTotalFee({
interchainQuote,
localQuote,
Expand Down Expand Up @@ -55,11 +56,14 @@ export function getInterchainQuote(

// Checks if a token is a multi-collateral token and if so
// look for other tokens that are the same and returns
// the one with the highest collateral in the destination
export async function getTransferToken(
// the one with the lowest fee
export async function getLowestFeeTransferToken(
warpCore: WarpCore,
originToken: Token,
destinationToken: IToken,
amountWei: string,
recipient: string,
sender: string | undefined,
) {
if (!isValidMultiCollateralToken(originToken, destinationToken)) return originToken;

Expand All @@ -73,37 +77,76 @@ export async function getTransferToken(
if (tokensWithSameCollateralAddresses.length <= 1) return originToken;

logger.debug(
'Multiple multi-collateral tokens found for same collateral address, retrieving balances...',
'Multiple multi-collateral tokens found for same collateral address, retrieving routes with collateral balance...',
);
const tokenBalances: Array<{ token: Token; balance: bigint }> = [];

// fetch each destination token balance
const balanceResults = await Promise.allSettled(
tokensWithSameCollateralAddresses.map(async ({ originToken, destinationToken }) => {
try {
const balance = await warpCore.getTokenCollateral(destinationToken);
return { token: originToken, balance };
return { originToken, destinationToken, balance };
} catch {
return null;
}
}),
);

const amountWeiBigInt = BigInt(amountWei);
const tokenBalances: Array<{ originToken: Token; destinationToken: Token; balance: bigint }> = [];
// filter tokens that have lower collateral in destination than the amount
for (const result of balanceResults) {
if (result.status === 'fulfilled' && result.value) {
if (
result.status === 'fulfilled' &&
result.value?.balance &&
result.value.balance >= amountWeiBigInt
) {
tokenBalances.push(result.value);
}
}

if (!tokenBalances.length) return originToken;

// sort by balance to return the highest one
tokenBalances.sort((a, b) => {
if (a.balance > b.balance) return -1;
else if (a.balance < b.balance) return 1;
else return 0;
logger.debug('Retrieving fees for multi-collateral routes...');
// fetch each route fees
const feeResults = await Promise.allSettled(
tokenBalances.map(async ({ originToken, destinationToken }) => {
try {
const originTokenAmount = new TokenAmount(amountWei, originToken);
const fees = await warpCore.getInterchainTransferFee({
originTokenAmount,
destination: destinationToken.chainName,
recipient,
sender,
});
return { token: originToken, fees };
} catch {
return null;
}
}),
);

const tokenFees: Array<{ token: Token; tokenFee?: TokenAmount }> = [];
for (const result of feeResults) {
if (result.status === 'fulfilled' && result.value) {
tokenFees.push({ token: result.value.token, tokenFee: result.value.fees.tokenFeeQuote });
}
}
if (!tokenFees.length) return originToken;

// sort by token fees, no fees routes take precedence, then lowest fee to highest
tokenFees.sort((a, b) => {
const aFee = a.tokenFee?.amount;
const bFee = b.tokenFee?.amount;

if (aFee === undefined && bFee !== undefined) return -1;
if (aFee !== undefined && bFee === undefined) return 1;
if (aFee === undefined && bFee === undefined) return 0;

if (aFee! < bFee!) return -1;
if (aFee! > bFee!) return 1;
return 0;
});

logger.debug('Found route with higher collateral in destination, switching route...');
return tokenBalances[0].token;
logger.debug('Found route with lower fee, switching route...');
return tokenFees[0].token;
}
20 changes: 18 additions & 2 deletions src/features/transfer/maxAmount.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MultiProtocolProvider, TokenAmount, WarpCore } from '@hyperlane-xyz/sdk';
import { MultiProtocolProvider, Token, TokenAmount, WarpCore } from '@hyperlane-xyz/sdk';
import { ProtocolType } from '@hyperlane-xyz/utils';
import { AccountInfo, getAccountAddressAndPubKey } from '@hyperlane-xyz/widgets';
import { useMutation } from '@tanstack/react-query';
Expand All @@ -7,6 +7,7 @@ import { logger } from '../../utils/logger';
import { useMultiProvider } from '../chains/hooks';
import { isMultiCollateralLimitExceeded } from '../limits/utils';
import { useWarpCore } from '../tokens/hooks';
import { getLowestFeeTransferToken } from './fees';

interface FetchMaxParams {
accounts: Record<ProtocolType, AccountInfo>;
Expand Down Expand Up @@ -34,11 +35,26 @@ async function fetchMaxAmount(
try {
const { address, publicKey } = getAccountAddressAndPubKey(multiProvider, origin, accounts);
if (!address) return balance;
const originToken = new Token(balance.token);
const destinationToken = originToken.getConnectionForChain(destination)?.token;
if (!destinationToken) return undefined;

const transferToken = await getLowestFeeTransferToken(
warpCore,
originToken,
destinationToken,
balance.amount.toString(),
address,
address,
);
const tokenAmount = new TokenAmount(balance.amount, transferToken);
const maxAmount = await warpCore.getMaxTransferAmount({
balance,
balance: tokenAmount,
destination,
sender: address,
senderPubKey: await publicKey,
// defaulting to address here for recipient
recipient: address,
});

const multiCollateralLimit = isMultiCollateralLimitExceeded(
Expand Down
Loading