@hyperlane-xyz/sdk
Version:
The official SDK for the Hyperlane Network
1,012 lines • 59.6 kB
JavaScript
import { ProtocolType, addressToBytes32, assert, convertDecimalsToIntegerString, convertToProtocolAddress, isEVMLike, isValidAddress, isZeroishAddress, rootLogger, } from '@hyperlane-xyz/utils';
import { Keypair } from '@solana/web3.js';
import { ProviderType } from '../providers/ProviderType.js';
import { estimateTransactionFeeEthersV5ForGasUnits, } from '../providers/transactionFeeEstimators.js';
import { Token } from '../token/Token.js';
import { TokenAmount } from '../token/TokenAmount.js';
import { parseTokenConnectionId } from '../token/TokenConnection.js';
import { tokenIdentifiersEqual } from '../token/TokenMetadata.js';
import { ERC4626_COLLATERAL_STANDARDS, LOCKBOX_STANDARDS, MINT_LIMITED_STANDARDS, TOKEN_COLLATERALIZED_STANDARDS, TOKEN_STANDARD_TO_PROVIDER_TYPE, TokenStandard, } from '../token/TokenStandard.js';
import { EVM_TRANSFER_REMOTE_GAS_ESTIMATE, } from '../token/adapters/EvmTokenAdapter.js';
import { isHypCrossCollateralAdapter, isPredicateCapableAdapter, } from '../token/adapters/ITokenAdapter.js';
import { buildExecuteCalldata, buildQuoteCalldata, } from '../quoted-calls/builder.js';
import { decodeQuoteExecuteResult, extractQuoteTotals, } from '../quoted-calls/codec.js';
import { TokenPullMode } from '../quoted-calls/types.js';
import { messageAmountFromLocal } from '../utils/decimals.js';
import { WarpCoreConfigSchema, WarpTxCategory, } from './types.js';
export class WarpCore {
multiProvider;
tokens;
localFeeConstants;
interchainFeeConstants;
routeBlacklist;
logger;
constructor(multiProvider, tokens, options) {
this.multiProvider = multiProvider;
this.tokens = tokens;
this.localFeeConstants = options?.localFeeConstants || [];
this.interchainFeeConstants = options?.interchainFeeConstants || [];
this.routeBlacklist = options?.routeBlacklist || [];
this.logger =
options?.logger ||
rootLogger.child({
module: 'WarpCore',
});
}
/**
* Takes the serialized representation of a warp config and returns a WarpCore instance
* @param multiProvider the MultiProviderAdapter containing chain metadata
* @param config the config object of type WarpCoreConfig
*/
static FromConfig(multiProvider, config) {
const parsedConfig = WarpCoreConfigSchema.parse(config);
const tokens = parsedConfig.tokens.map((token) => new Token({
...token,
addressOrDenom: token.addressOrDenom || '',
connections: undefined,
}));
parsedConfig.tokens.forEach((config, i) => {
for (const connection of config.connections || []) {
const token1 = tokens[i];
assert(token1, `Token config missing at index ${i}`);
const { chainName, addressOrDenom } = parseTokenConnectionId(connection.token);
// If token1 has a warpRouteId, token2 must share it — disambiguates
// tokens at the same address (e.g. M0 Portal: mUSD, wM, USDSC)
const token2 = tokens.find((token) => token.chainName === chainName &&
tokenIdentifiersEqual(token.addressOrDenom, addressOrDenom) &&
(!token1.warpRouteId || token.warpRouteId === token1.warpRouteId));
assert(token2, `Connected token not found: ${chainName} ${addressOrDenom}`);
token1.addConnection({
...connection,
token: token2,
});
}
});
return new WarpCore(multiProvider, tokens, parsedConfig.options);
}
/**
* Queries the token router for an interchain gas quote (i.e. IGP fee).
* and for token fee quote if it exists.
* Sender is only required for Sealevel origins.
*/
async getInterchainTransferFee({ originTokenAmount, destination, sender, recipient, destinationToken, }) {
this.logger.debug(`Fetching interchain transfer quote to ${destination}`);
const { amount, token: originToken } = originTokenAmount;
const originName = originToken.chainName;
const destinationName = this.multiProvider.getChainName(destination);
let gasAmount;
let gasAddressOrDenom;
let feeAmount;
let feeTokenAddress;
// Check constant quotes first
const defaultQuote = this.interchainFeeConstants.find((q) => q.origin === originName && q.destination === destinationName);
if (defaultQuote) {
gasAmount = BigInt(defaultQuote.amount.toString());
gasAddressOrDenom = defaultQuote.addressOrDenom;
}
else {
// Otherwise, compute IGP quote via the adapter
let quote;
const destinationDomainId = this.multiProvider.getDomainId(destination);
if (this.isCrossCollateralTransfer(originToken, destinationToken)) {
const resolvedDestinationToken = this.resolveDestinationToken({
originToken,
destination,
destinationToken,
});
assert(resolvedDestinationToken.addressOrDenom, 'Destination token missing addressOrDenom');
const crossCollateralAdapter = originToken.getHypAdapter(this.multiProvider, destinationName);
assert(isHypCrossCollateralAdapter(crossCollateralAdapter), 'Adapter does not implement IHypCrossCollateralAdapter');
quote = await crossCollateralAdapter.quoteTransferRemoteToGas({
destination: destinationDomainId,
recipient,
amount,
targetRouter: resolvedDestinationToken.addressOrDenom,
sender,
});
}
else {
const hypAdapter = originToken.getHypAdapter(this.multiProvider, destinationName);
quote = await hypAdapter.quoteTransferRemoteGas({
destination: destinationDomainId,
sender,
customHook: originToken.igpTokenAddressOrDenom,
recipient,
amount,
});
}
gasAmount = BigInt(quote.igpQuote.amount);
gasAddressOrDenom = quote.igpQuote.addressOrDenom;
feeAmount = quote.tokenFeeQuote?.amount;
feeTokenAddress = quote.tokenFeeQuote?.addressOrDenom;
}
let igpToken;
if (!gasAddressOrDenom || isZeroishAddress(gasAddressOrDenom)) {
// An empty/undefined addressOrDenom indicates the native token
igpToken = Token.FromChainMetadataNativeToken(this.multiProvider.getChainMetadata(originName));
}
else {
const searchResult = this.findToken(originName, gasAddressOrDenom);
assert(searchResult, `Fee token ${gasAddressOrDenom} is unknown`);
igpToken = searchResult;
}
let feeTokenAmount;
if (feeAmount) {
// empty address or zero address is native route
if (!feeTokenAddress || isZeroishAddress(feeTokenAddress)) {
const nativeToken = Token.FromChainMetadataNativeToken(this.multiProvider.getChainMetadata(originName));
feeTokenAmount = new TokenAmount(feeAmount, nativeToken);
}
else {
// for non-native routes, fees will be in the current route token
feeTokenAmount = new TokenAmount(feeAmount, originToken);
}
}
this.logger.debug(`Quoted interchain transfer fee: ${gasAmount} ${igpToken.symbol}`);
return {
igpQuote: new TokenAmount(gasAmount, igpToken),
tokenFeeQuote: feeTokenAmount,
};
}
/**
* Simulates a transfer to estimate 'local' gas fees on the origin chain
*/
async getLocalTransferFee({ originToken, destination, sender, senderPubKey, interchainFee, tokenFeeQuote, attestation, amount, destinationToken, quotedCalls, }) {
this.logger.debug(`Estimating local transfer gas to ${destination}`);
const originMetadata = this.multiProvider.getChainMetadata(originToken.chainName);
const destinationMetadata = this.multiProvider.getChainMetadata(destination);
// Check constant quotes first
const defaultQuote = this.localFeeConstants.find((q) => q.origin === originMetadata.name &&
q.destination === destinationMetadata.name);
if (defaultQuote) {
return { gasUnits: 0, gasPrice: 0, fee: Number(defaultQuote.amount) };
}
// Form transactions to estimate local gas with
const recipient = convertToProtocolAddress(sender, destinationMetadata.protocol, destinationMetadata.bech32Prefix);
// Use a small but viable amount for gas estimation when none is provided.
// Must survive on-chain decimal truncation (e.g. 18→6 decimals) to avoid
// reverts like "HypNativeMinter: destination amount < 1". Compute minimum
// as 10^(originDecimals - destDecimals) so destination gets exactly 1 unit.
const estimationAmount = amount ??
(() => {
const destToken = originToken.getConnectionForChain(destinationMetadata.name)?.token;
const decimalDiff = destToken
? Math.max(0, originToken.decimals - destToken.decimals)
: 0;
return BigInt(10) ** BigInt(decimalDiff);
})();
const txs = await this.getTransferRemoteTxs({
originTokenAmount: originToken.amount(estimationAmount),
destination,
sender,
recipient,
interchainFee,
tokenFeeQuote,
attestation,
destinationToken,
quotedCalls,
});
// Starknet does not support gas estimation without starknet account
if (originToken.protocol === ProtocolType.Starknet) {
return { gasUnits: 0n, gasPrice: 0n, fee: 0n };
}
// Typically the transfers require a single transaction
if (txs.length === 1) {
try {
return await this.multiProvider.estimateTransactionFee({
chainNameOrId: originMetadata.name,
transaction: txs[0],
sender,
senderPubKey,
});
}
catch (error) {
this.logger.error(`Failed to estimate local gas fee for ${originToken.symbol} transfer`, error);
throw new Error('Gas estimation failed, balance may be insufficient', {
cause: error,
});
}
}
// On ethereum, sometimes 2 txs are required (one approve, one transferRemote)
else if (txs.length >= 2 && isEVMLike(originToken.protocol)) {
const provider = this.multiProvider.getEthersV5Provider(originMetadata.name);
// We use a hard-coded const as an estimate for the transferRemote because we
// cannot reliably simulate the tx when an approval tx is required first
return estimateTransactionFeeEthersV5ForGasUnits({
provider,
gasUnits: EVM_TRANSFER_REMOTE_GAS_ESTIMATE,
});
}
else {
throw new Error('Cannot estimate local gas for multiple transactions');
}
}
/**
* Similar to getLocalTransferFee in that it estimates local gas fees
* but it also resolves the native token and returns a TokenAmount
* @todo: rename to getLocalTransferFee for consistency (requires breaking change)
*/
async getLocalTransferFeeAmount({ originToken, destination, sender, senderPubKey, interchainFee, tokenFeeQuote, attestation, amount, destinationToken, quotedCalls, }) {
const originMetadata = this.multiProvider.getChainMetadata(originToken.chainName);
// If there's no native token, we can't represent local gas
if (!originMetadata.nativeToken)
throw new Error(`No native token found for ${originMetadata.name}`);
this.logger.debug(`Using native token ${originMetadata.nativeToken.symbol} for local gas fee`);
const localFee = await this.getLocalTransferFee({
originToken,
destination,
sender,
senderPubKey,
interchainFee,
tokenFeeQuote,
attestation,
amount,
destinationToken,
quotedCalls,
});
// Get the local gas token. This assumes the chain's native token will pay for local gas
// This will need to be smarter if more complex scenarios on Cosmos are supported
const localGasToken = Token.FromChainMetadataNativeToken(originMetadata);
return localGasToken.amount(localFee.fee);
}
/**
* Gets a list of populated transactions required to transfer a token to a remote chain
* Typically just 1 transaction but sometimes more, like when an approval is required first
*/
async getTransferRemoteTxs({ originTokenAmount, destination, sender, recipient, interchainFee, tokenFeeQuote, attestation, destinationToken, quotedCalls, }) {
// QuotedCalls and attestation are mutually exclusive: the QuotedCalls.execute() path
// calls transferRemote, not transferRemoteWithAttestation. Composing them would require
// new contract support; for now, surface a clear error rather than silently dropping
// the attestation.
assert(!(quotedCalls && attestation), 'quotedCalls and attestation cannot be used together. The QuotedCalls path does not support attestation-gated transfers.');
// QuotedCalls atomic path — single execute() tx with quotes + token pull + transfer + sweep
if (quotedCalls) {
return this.getQuotedCallsTransferTxs({
originTokenAmount,
destination,
sender,
recipient,
quotedCalls,
destinationToken,
feeQuotes: quotedCalls.feeQuotes,
});
}
// Check if this is a CrossCollateralRouter transfer
if (destinationToken &&
this.isCrossCollateralTransfer(originTokenAmount.token, destinationToken)) {
return this.getCrossCollateralTransferTxs({
originTokenAmount,
destination,
sender,
recipient,
destinationToken,
attestation,
interchainFee,
tokenFeeQuote,
});
}
// Standard warp route transfer
const transactions = [];
const { token, amount } = originTokenAmount;
const destinationName = this.multiProvider.getChainName(destination);
const destinationDomainId = this.multiProvider.getDomainId(destination);
const providerType = TOKEN_STANDARD_TO_PROVIDER_TYPE[token.standard];
const hypAdapter = token.getHypAdapter(this.multiProvider, destinationName);
if (!interchainFee) {
// Only re-fetch when the IGP quote is missing. tokenFeeQuote may legitimately
// be undefined for routes that have no token fee — treating its absence as a
// reason to re-fetch would overwrite a valid pinned interchainFee.
const transferFee = await this.getInterchainTransferFee({
originTokenAmount,
destination,
sender,
recipient,
destinationToken,
});
interchainFee = transferFee.igpQuote;
tokenFeeQuote = transferFee.tokenFeeQuote;
}
const interchainGas = {
igpQuote: {
amount: interchainFee.amount,
addressOrDenom: interchainFee.token.addressOrDenom,
},
tokenFeeQuote: tokenFeeQuote && {
amount: tokenFeeQuote.amount,
addressOrDenom: tokenFeeQuote.token.addressOrDenom,
},
};
const [isApproveRequired, isRevokeApprovalRequired] = await Promise.all([
this.isApproveRequired({
originTokenAmount,
owner: sender,
}),
hypAdapter.isRevokeApprovalRequired(sender, originTokenAmount.token.addressOrDenom),
]);
const preTransferRemoteTxs = [];
// if the approval is required and the current allowance is not 0 we reset
// the allowance before setting the right approval as some tokens don't allow
// to override an already existing allowance. USDT is one of these tokens
// see: https://etherscan.io/token/0xdac17f958d2ee523a2206206994597c13d831ec7#code#L205
if (isApproveRequired && isRevokeApprovalRequired) {
preTransferRemoteTxs.push([0, WarpTxCategory.Revoke]);
}
if (isApproveRequired) {
// feeQuote is required to be approved for routes that have fees set
const feeQuote = tokenFeeQuote?.amount ?? 0n;
const amountToApprove = amount + feeQuote;
preTransferRemoteTxs.push([
amountToApprove.toString(),
WarpTxCategory.Approval,
]);
}
for (const [approveAmount, txCategory] of preTransferRemoteTxs) {
this.logger.info(`${txCategory} required for transfer of ${token.symbol}`);
const approveTxReq = await hypAdapter.populateApproveTx({
weiAmountOrId: approveAmount,
recipient: token.addressOrDenom,
interchainGas,
});
this.logger.debug(`${txCategory} tx for ${token.symbol} populated`);
const approveTx = {
category: txCategory,
type: providerType,
transaction: approveTxReq,
};
transactions.push(approveTx);
}
// if the interchain fee is of protocol starknet we also have
// to approve the transfer of this fee token
if (interchainFee.token.protocol === ProtocolType.Starknet) {
const interchainFeeAdapter = interchainFee.token.getAdapter(this.multiProvider);
const isRequired = await interchainFeeAdapter.isApproveRequired(sender, token.addressOrDenom, interchainFee.amount);
this.logger.debug(`Approval is${isRequired ? '' : ' not'} required for interchain fee of ${interchainFee.token.symbol}`);
if (isRequired) {
const txCategory = WarpTxCategory.Approval;
this.logger.info(`${txCategory} required for transfer of ${interchainFee.token.symbol}`);
const approveTxReq = await interchainFeeAdapter.populateApproveTx({
weiAmountOrId: interchainFee.amount,
recipient: token.addressOrDenom,
});
this.logger.debug(`${txCategory} tx for ${interchainFee.token.symbol} populated`);
const approveTx = {
category: txCategory,
type: providerType,
transaction: approveTxReq,
};
transactions.push(approveTx);
}
}
const extraSignerKeypairs = providerType === ProviderType.SolanaWeb3
? [Keypair.generate()]
: undefined;
if (attestation) {
assert(isPredicateCapableAdapter(hypAdapter), 'Attestation provided but adapter does not support predicate transfers');
}
const transferTxReq = await hypAdapter.populateTransferRemoteTx({
weiAmountOrId: amount.toString(),
destination: destinationDomainId,
fromAccountOwner: sender,
recipient,
interchainGas,
customHook: token.igpTokenAddressOrDenom,
attestation,
extraSigners: extraSignerKeypairs,
});
this.logger.debug(`Remote transfer tx for ${token.symbol} populated`);
const transferTx = {
category: WarpTxCategory.Transfer,
type: providerType,
transaction: transferTxReq,
...(extraSignerKeypairs && { extraSigners: extraSignerKeypairs }),
};
transactions.push(transferTx);
return transactions;
}
/**
* Check if this is a CrossCollateralRouter transfer.
* Returns true if both tokens are CrossCollateralRouter tokens.
*/
isCrossCollateralTransfer(originToken, destinationToken) {
if (!destinationToken)
return false;
return (originToken.isCrossCollateralToken() &&
destinationToken.isCrossCollateralToken());
}
/**
* Executes a CrossCollateralRouter transfer between different collateral routers.
* Uses transferRemoteTo for both same-chain and cross-chain transfers.
* Same-chain: calls handle() directly on target router (atomic, no relay needed).
*/
async getCrossCollateralTransferTxs({ originTokenAmount, destination, sender, recipient, destinationToken, attestation, interchainFee, tokenFeeQuote, }) {
const transactions = [];
const { token: originToken, amount } = originTokenAmount;
const destinationName = this.multiProvider.getChainName(destination);
const resolvedDestinationToken = this.resolveDestinationToken({
originToken,
destination,
destinationToken,
});
assert(originToken.collateralAddressOrDenom, 'Origin token missing collateralAddressOrDenom');
assert(resolvedDestinationToken.addressOrDenom, 'Destination token missing addressOrDenom');
const providerType = TOKEN_STANDARD_TO_PROVIDER_TYPE[originToken.standard];
const adapter = originToken.getHypAdapter(this.multiProvider, destinationName);
assert(isHypCrossCollateralAdapter(adapter), 'Adapter does not implement IHypCrossCollateralAdapter');
// Use pre-computed fees when provided (e.g. when attestation is present and the
// msg_value was signed over the original quote). Re-quoting could produce a
// different value and cause the attestation check to fail on-chain.
// Mirror the standard path (line ~517): only re-quote when interchainFee is absent.
// tokenFeeQuote may legitimately be undefined (no token fee on this route) and that
// is not a reason to re-quote — doing so would overwrite a valid pinned interchainFee.
let transferQuote;
if (interchainFee) {
transferQuote = {
igpQuote: {
amount: interchainFee.amount,
addressOrDenom: interchainFee.token.addressOrDenom,
},
...(tokenFeeQuote && {
tokenFeeQuote: {
amount: tokenFeeQuote.amount,
addressOrDenom: tokenFeeQuote.token.addressOrDenom,
},
}),
};
}
else {
transferQuote = await adapter.quoteTransferRemoteToGas({
destination: this.multiProvider.getDomainId(destination),
recipient,
amount,
targetRouter: resolvedDestinationToken.addressOrDenom,
sender,
});
}
const igpDenom = transferQuote.igpQuote.addressOrDenom;
assert(!igpDenom || isZeroishAddress(igpDenom) || igpDenom === 'lamport', `CrossCollateralRouter transferRemoteTo requires native IGP fee; got ${igpDenom}`);
const tokenFeeAmount = transferQuote.tokenFeeQuote?.amount ?? 0n;
const totalDebit = amount + tokenFeeAmount;
const [isApproveRequired, isRevokeApprovalRequired] = await Promise.all([
adapter.isApproveRequired(sender, originToken.addressOrDenom, totalDebit),
adapter.isRevokeApprovalRequired(sender, originToken.addressOrDenom),
]);
if (isApproveRequired && isRevokeApprovalRequired) {
const revokeTxReq = await adapter.populateApproveTx({
weiAmountOrId: 0,
recipient: originToken.addressOrDenom,
});
transactions.push({
category: WarpTxCategory.Revoke,
type: providerType,
transaction: revokeTxReq,
});
}
if (isApproveRequired) {
const approveTxReq = await adapter.populateApproveTx({
weiAmountOrId: totalDebit,
recipient: originToken.addressOrDenom,
});
transactions.push({
category: WarpTxCategory.Approval,
type: providerType,
transaction: approveTxReq,
});
}
// transferRemoteTo works for both same-chain and cross-chain.
// Same-chain: calls handle() directly on target router (atomic, no relay needed).
const destinationDomainId = this.multiProvider.getDomainId(destination);
const originDomainId = this.multiProvider.getDomainId(originToken.chainName);
const isLocalTransfer = destinationDomainId === originDomainId;
const extraSignerKeypairs = providerType === ProviderType.SolanaWeb3 && !isLocalTransfer
? [Keypair.generate()]
: undefined;
const txReq = await adapter.populateTransferRemoteToTx({
destination: destinationDomainId,
recipient,
amount,
targetRouter: resolvedDestinationToken.addressOrDenom,
interchainGas: transferQuote,
fromAccountOwner: sender,
extraSigners: extraSignerKeypairs,
attestation,
});
transactions.push({
category: WarpTxCategory.Transfer,
type: providerType,
transaction: txReq,
...(extraSignerKeypairs && { extraSigners: extraSignerKeypairs }),
});
return transactions;
}
/**
* Resolve common params for QuotedCalls operations.
*/
resolveQuotedCallsParams({ originTokenAmount, destination, recipient, quotedCalls, destinationToken, }) {
const { token, amount } = originTokenAmount;
assert(isEVMLike(token.protocol), 'QuotedCalls is only supported on EVM origins');
assert(!token.isNft(), 'QuotedCalls does not support NFT routes');
const destinationDomainId = this.multiProvider.getDomainId(destination);
// For collateral routes, the ERC20 token is collateralAddressOrDenom.
// For synthetic/native routes, use addressOrDenom (the router itself).
// Only treat as native (zeroAddress) if collateral is explicitly address(0).
const collateral = token.collateralAddressOrDenom;
const tokenAddress = (collateral && !isZeroishAddress(collateral)
? collateral
: token.isNative() || token.isHypNative()
? '0x0000000000000000000000000000000000000000'
: token.addressOrDenom);
let targetRouter;
if (destinationToken &&
this.isCrossCollateralTransfer(token, destinationToken)) {
const resolved = this.resolveDestinationToken({
originToken: token,
destination,
destinationToken,
});
assert(resolved.addressOrDenom, 'Destination token missing addressOrDenom for cross-collateral');
targetRouter = addressToBytes32(resolved.addressOrDenom);
}
return {
quotedCallsAddress: quotedCalls.address,
warpRoute: token.addressOrDenom,
destination: destinationDomainId,
recipient: addressToBytes32(recipient),
amount,
token: tokenAddress,
quotes: quotedCalls.quotes,
clientSalt: quotedCalls.clientSalt,
targetRouter,
};
}
/**
* Quote fees for a QuotedCalls transfer via quoteExecute eth_call.
* Returns structured fee data (like getInterchainTransferFee) plus
* the raw Quote[][] needed to build the execute tx.
*/
async getQuotedTransferFee({ originTokenAmount, destination, sender, recipient, quotedCalls, destinationToken, }) {
const { token: originToken } = originTokenAmount;
const originName = originToken.chainName;
const transferParams = this.resolveQuotedCallsParams({
originTokenAmount,
destination,
recipient,
quotedCalls,
destinationToken,
});
const quoteTx = buildQuoteCalldata(transferParams);
const provider = this.multiProvider.getEthersV5Provider(originName);
const quoteResult = await provider.call({
to: quoteTx.to,
data: quoteTx.data,
from: sender,
});
const feeQuotes = decodeQuoteExecuteResult(quoteResult);
const { nativeValue, tokenTotals } = extractQuoteTotals(feeQuotes);
// Build structured fee amounts matching getInterchainTransferFee return shape.
// For native routes, quoteTransferRemote includes the transfer amount in
// the native quotes, so we subtract it to get the fee-only portion.
const isNativeRoute = isZeroishAddress(transferParams.token);
const nativeToken = Token.FromChainMetadataNativeToken(this.multiProvider.getChainMetadata(originName));
const igpFeeOnly = isNativeRoute
? nativeValue - originTokenAmount.amount
: nativeValue;
const igpQuote = new TokenAmount(igpFeeOnly, nativeToken);
// Token fees = total ERC20 quoted minus the transfer amount
// sumQuotesByToken normalizes keys to lowercase
const tokenKey = transferParams.token.toLowerCase();
assert(tokenTotals.size <= 1, `Unexpected multi-token fee quotes: ${[...tokenTotals.keys()].join(', ')}`);
let tokenFeeQuote;
const totalTokenQuoted = tokenTotals.get(tokenKey);
if (totalTokenQuoted != null) {
const feeOnly = totalTokenQuoted - originTokenAmount.amount;
assert(feeOnly >= 0n, `Token fee quote underflow: quoted ${totalTokenQuoted} < amount ${originTokenAmount.amount}`);
if (feeOnly > 0n) {
tokenFeeQuote = new TokenAmount(feeOnly, originToken);
}
}
return { igpQuote, tokenFeeQuote, feeQuotes };
}
/**
* Build transactions for a QuotedCalls atomic transfer.
* Returns [approval (if needed), execute] transactions.
*
* @param feeQuotes Raw Quote[][] from getQuotedTransferFee.
* If not provided, calls quoteExecute internally.
*/
async getQuotedCallsTransferTxs({ originTokenAmount, destination, sender, recipient, quotedCalls, destinationToken, feeQuotes, }) {
const { token } = originTokenAmount;
const transactions = [];
const providerType = TOKEN_STANDARD_TO_PROVIDER_TYPE[token.standard];
const transferParams = this.resolveQuotedCallsParams({
originTokenAmount,
destination,
recipient,
quotedCalls,
destinationToken,
});
// Get fee quotes if not provided
if (!feeQuotes) {
const fees = await this.getQuotedTransferFee({
originTokenAmount,
destination,
sender,
recipient,
quotedCalls,
destinationToken,
});
feeQuotes = fees.feeQuotes;
}
const { tokenTotals } = extractQuoteTotals(feeQuotes);
const totalTokenNeeded = tokenTotals.get(transferParams.token.toLowerCase()) ??
0n;
// Check approval for QuotedCalls (TransferFrom mode).
// The spender is quotedCalls.address (not the token itself), so
// EvmHypSyntheticAdapter correctly falls through to the ERC20 allowance
// check rather than returning false.
if (quotedCalls.tokenPullMode === TokenPullMode.TransferFrom &&
totalTokenNeeded > 0n) {
const adapter = token.getAdapter(this.multiProvider);
const [isApproveRequired, isRevokeApprovalRequired] = await Promise.all([
adapter.isApproveRequired(sender, quotedCalls.address, totalTokenNeeded),
adapter.isRevokeApprovalRequired(sender, quotedCalls.address),
]);
// USDT-like tokens require revoking to 0 before re-approving
if (isApproveRequired && isRevokeApprovalRequired) {
const revokeTxReq = await adapter.populateApproveTx({
weiAmountOrId: 0,
recipient: quotedCalls.address,
});
transactions.push({
category: WarpTxCategory.Revoke,
type: providerType,
transaction: revokeTxReq,
}); // CAST: providerType is determined at runtime from token.standard
}
if (isApproveRequired) {
const approveTxReq = await adapter.populateApproveTx({
weiAmountOrId: totalTokenNeeded,
recipient: quotedCalls.address,
});
transactions.push({
category: WarpTxCategory.Approval,
type: providerType,
transaction: approveTxReq,
}); // CAST: providerType is determined at runtime from token.standard
}
}
// Build execute tx with exact fee amounts
const executeTx = buildExecuteCalldata({
...transferParams,
feeQuotes,
tokenPullMode: quotedCalls.tokenPullMode,
permit2Data: quotedCalls.permit2Data,
});
transactions.push({
category: WarpTxCategory.Transfer,
type: providerType,
transaction: {
to: executeTx.to,
data: executeTx.data,
value: executeTx.value.toString(),
},
}); // CAST: providerType is determined at runtime from token.standard
return transactions;
}
/**
* Fetch local and interchain fee estimates for a remote transfer
*/
async estimateTransferRemoteFees({ originTokenAmount, destination, recipient, sender, senderPubKey, attestation, destinationToken, }) {
this.logger.debug('Fetching remote transfer fee estimates');
const { token: originToken } = originTokenAmount;
// Handle CrossCollateralRouter fee estimation
if (this.isCrossCollateralTransfer(originToken, destinationToken)) {
return this.estimateCrossCollateralFees({
originTokenAmount,
destination,
destinationToken,
recipient,
sender,
senderPubKey,
attestation,
});
}
// First get interchain gas quote (aka IGP quote)
// Start with this because it's used in the local fee estimation
const { igpQuote, tokenFeeQuote } = await this.getInterchainTransferFee({
originTokenAmount,
destination,
sender,
recipient,
});
// Next, get the local gas quote
const localQuote = await this.getLocalTransferFeeAmount({
originToken: originTokenAmount.token,
destination,
sender,
senderPubKey,
interchainFee: igpQuote,
tokenFeeQuote,
attestation,
// Only pass amount for predicate flows — it feeds the attested msg_value into
// eth_estimateGas. For non-predicate flows, let getLocalTransferFee use its
// minimal-amount fallback so simulation doesn't fail on large balances or
// placeholder senders (e.g. 0x...dead) that have no ETH.
amount: attestation ? originTokenAmount.amount : undefined,
});
return {
interchainQuote: igpQuote,
localQuote,
tokenFeeQuote,
};
}
/**
* Estimate fees for a CrossCollateralRouter transfer.
*/
async estimateCrossCollateralFees({ originTokenAmount, destination, destinationToken, recipient, sender, senderPubKey, attestation, }) {
const { token: originToken } = originTokenAmount;
const resolvedDestinationToken = this.resolveDestinationToken({
originToken,
destination,
destinationToken,
});
const { igpQuote: interchainQuote, tokenFeeQuote } = await this.getInterchainTransferFee({
originTokenAmount,
destination,
sender,
recipient,
destinationToken: resolvedDestinationToken,
});
const localQuote = await this.getLocalTransferFeeAmount({
originToken,
destination,
sender,
senderPubKey,
interchainFee: interchainQuote,
tokenFeeQuote,
attestation,
amount: attestation ? originTokenAmount.amount : undefined,
destinationToken: resolvedDestinationToken,
});
return {
interchainQuote,
localQuote,
tokenFeeQuote,
};
}
/**
* Computes the max transferrable amount of the from the given
* token balance, accounting for local and interchain gas fees
*/
async getMaxTransferAmount({ balance, destination, recipient, sender, senderPubKey, feeEstimate, destinationToken, }) {
const originToken = balance.token;
if (!feeEstimate) {
// Get IGP and token fee quotes using the full balance so amount-dependent fees
// (e.g. percentage-based token fees) are correctly computed and subtracted.
const { igpQuote: interchainQuote, tokenFeeQuote } = await this.getInterchainTransferFee({
originTokenAmount: balance,
destination,
recipient,
sender,
destinationToken,
});
// Estimate local gas with no amount so getLocalTransferFee uses its minimal-amount
// fallback — avoids eth_estimateGas failures on native token routes where simulating
// the full balance leaves nothing to cover gas.
const localQuote = await this.getLocalTransferFeeAmount({
originToken,
destination,
sender,
senderPubKey,
interchainFee: interchainQuote,
tokenFeeQuote,
destinationToken,
});
feeEstimate = { interchainQuote, localQuote, tokenFeeQuote };
}
const { localQuote, interchainQuote, tokenFeeQuote } = feeEstimate;
let maxAmount = balance;
if (originToken.isFungibleWith(localQuote.token)) {
maxAmount = maxAmount.minus(localQuote.amount);
}
if (originToken.isFungibleWith(interchainQuote.token)) {
maxAmount = maxAmount.minus(interchainQuote.amount);
}
if (originToken.isFungibleWith(tokenFeeQuote?.token)) {
const { tokenFeeQuote: newFeeQuote } = await this.getInterchainTransferFee({
originTokenAmount: maxAmount,
destination,
recipient,
sender,
destinationToken,
});
// Because tokenFeeQuote is calculated based on the amount, we need to recalculate
// the tokenFeeQuote after subtracting the localQuote and IGP to get max transfer amount
// to be as close as possible
maxAmount = maxAmount.minus(newFeeQuote?.amount || 0n);
}
if (maxAmount.amount > 0)
return maxAmount;
else
return originToken.amount(0);
}
async getTokenCollateral(token) {
if (LOCKBOX_STANDARDS.includes(token.standard) ||
ERC4626_COLLATERAL_STANDARDS.includes(token.standard)) {
const adapter = token.getHypAdapter(this.multiProvider);
const tokenCollateral = await adapter.getBridgedSupply();
assert(tokenCollateral !== undefined, `getBridgedSupply returned undefined for ${token.symbol} on ${token.chainName}`);
return tokenCollateral;
}
else {
const adapter = token.getAdapter(this.multiProvider);
const tokenCollateral = await adapter.getBalance(token.addressOrDenom);
return tokenCollateral;
}
}
/**
* Checks if destination chain's collateral is sufficient to cover the transfer
*/
async isDestinationCollateralSufficient({ originTokenAmount, destination, destinationToken, }) {
const { token: originToken, amount } = originTokenAmount;
this.logger.debug(`Checking collateral for ${originToken.symbol} to ${destination}`);
const resolvedDestinationToken = this.resolveDestinationToken({
originToken,
destination,
destinationToken,
});
if (!TOKEN_COLLATERALIZED_STANDARDS.includes(resolvedDestinationToken.standard)) {
this.logger.debug(`${resolvedDestinationToken.symbol} is not collateralized, skipping`);
return true;
}
const destinationBalance = await this.getTokenCollateral(resolvedDestinationToken);
// Legacy fallback: when both scales are undefined but decimals differ,
// we can't use message-space comparison because messageAmountFromLocal
// with identity scale would compare raw local units across different
// decimal spaces. Fall back to decimal conversion instead.
// Well-configured routes should have scale set — verifyScale() catches
// this at deploy time. Misconfigured routes that reach the message-space
// path below may produce incorrect results.
if (originToken.decimals !== resolvedDestinationToken.decimals &&
originToken.scale === undefined &&
resolvedDestinationToken.scale === undefined) {
const destinationBalanceInOriginDecimals = BigInt(convertDecimalsToIntegerString(resolvedDestinationToken.decimals, originToken.decimals, destinationBalance.toString()));
const isSufficient = destinationBalanceInOriginDecimals >= amount;
this.logger.debug(`${originTokenAmount.token.symbol} to ${destination} has ${isSufficient ? 'sufficient' : 'INSUFFICIENT'} collateral`);
return isSufficient;
}
const requiredMessageAmount = messageAmountFromLocal(amount, originToken.scale);
const availableMessageAmount = messageAmountFromLocal(destinationBalance, resolvedDestinationToken.scale);
const isSufficient = availableMessageAmount >= requiredMessageAmount;
this.logger.debug(`${originTokenAmount.token.symbol} to ${destination} has ${isSufficient ? 'sufficient' : 'INSUFFICIENT'} collateral`);
return isSufficient;
}
/**
* Checks if a token transfer requires an approval tx first
*/
async isApproveRequired({ originTokenAmount, owner, }) {
const { token, amount } = originTokenAmount;
const adapter = token.getAdapter(this.multiProvider);
const isRequired = await adapter.isApproveRequired(owner, token.addressOrDenom, amount);
this.logger.debug(`Approval is${isRequired ? '' : ' not'} required for transfer of ${token.symbol}`);
return isRequired;
}
/**
* Ensure the remote token transfer would be valid for the given chains, amount, sender, and recipient
*/
async validateTransfer({ originTokenAmount, destination, recipient, sender, senderPubKey, attestation, destinationToken, }) {
const chainError = this.validateChains(originTokenAmount.token.chainName, destination);
if (chainError)
return chainError;
const recipientError = this.validateRecipient(recipient, destination);
if (recipientError)
return recipientError;
const resolvedDestinationToken = (() => {
try {
return this.resolveDestinationToken({
originToken: originTokenAmount.token,
destination,
destinationToken,
});
}
catch (error) {
const message = error instanceof Error ? error.message : 'Invalid destination token';
return { error: message };
}
})();
if ('error' in resolvedDestinationToken) {
return { destinationToken: resolvedDestinationToken.error };
}
const amountError = await this.validateAmount(originTokenAmount, destination, recipient, resolvedDestinationToken);
if (amountError)
return amountError;
const destinationRateLimitError = await this.validateDestinationRateLimit(originTokenAmount, destination, resolvedDestinationToken);
if (destinationRateLimitError)
return destinationRateLimitError;
const destinationCollateralError = await this.validateDestinationCollateral(originTokenAmount, destination, resolvedDestinationToken);
if (destinationCollateralError)
return destinationCollateralError;
const originCollateralError = await this.validateOriginCollateral(originTokenAmount);
if (originCollateralError)
return originCollateralError;
const balancesError = await this.validateTokenBalances(originTokenAmount, destination, sender, recipient, senderPubKey, attestation, resolvedDestinationToken);
if (balancesError)
return balancesError;
return null;
}
/**
* Ensure the origin and destination chains are valid and known by this WarpCore
*/
validateChains(origin, destination) {
if (!origin)
return { origin: 'Origin chain required' };
if (!destination)
return { destination: 'Destination chain required' };
const originMetadata = this.multiProvider.tryGetChainMetadata(origin);
const destinationMetadata = this.multiProvider.tryGetChainMetadata(destination);
if (!originMetadata)
return { origin: 'Origin chain metadata missing' };
if (!destinationMetadata)
return { destination: 'Destination chain metadata missing' };
if (this.routeBlacklist.some((bl) => bl.origin === originMetadata.name &&
bl.destination === destinationMetadata.name)) {
return { destination: 'Route is not currently allowed' };
}
return null;
}
/**
* Ensure recipient address is valid for the destination chain
*/
validateRecipient(recipient, destination) {
const destinationMetadata = this.multiProvider.getChainMetadata(destination);
const { protocol, bech32Prefix } = destinationMetadata;
// Ensure recip address is valid for the destination chain's protocol
if (!isValidAddress(recipient, protocol) || isZeroishAddress(recipient))
return { recipient: 'Invalid recipient' };
// Also ensure the address denom is correct if the dest protocol is Cosmos
if (protocol === ProtocolType.Cosmos ||
protocol === ProtocolType.CosmosNative) {
if (!bech32Prefix) {
this.logger.error(`No bech32 prefix found for chain ${destination}`);
return { destination: 'Invalid chain data' };
}
else if (!recipient.startsWith(bech32Prefix)) {
this.logger.error(`Recipient prefix should be ${bech32Prefix}`);
return { recipient: 'Invalid recipient prefix' };
}
}
return null;
}
/**
* Ensure token amount is valid
*/
async validateAmount(originTokenAmount, destination, recipient, destinationToken) {
if (!originTokenAmount.amount || originTokenAmount.amount < 0n) {
const isNft = originTokenAmount.token.isNft();
return { amount: isNft ? 'Invalid Token Id' : 'Invalid amount' };
}
// Check the transfer amount is sufficient on the destination side
const originToken = originTokenAmount.token;
const resolvedDestinationToken = this.resolveDestinationToken({
originToken,
destination,
destinationToken,
});
const destinationAdapter = resolvedDestinationToken.getAdapter(this.multiProvider);
// Get the min required destination amount
const minDestinationTransferAmount = await destinationAdapter.getMinimumTransferAmount(recipient);
// Convert the minDestinationTransferAmount to an origin amount
const minOriginTransferAmount = originToken.amount(convertDecimalsToIntegerString(resolvedDestinationToken.decimals, originToken.decimals, minDestinationTransferAmount.toString()));
if (minOriginTransferAmount.amount > originTokenAmount.amount) {
return {
amount: `Minimum transfer amount is ${minOriginTransferAmount.getDecimalFormattedAmount()} ${originToken.symbol}`,
};
}
return null;
}
/**
* Ensure the sender has sufficient balances for transfer and interchain gas
*/
async validateTokenBalances(originTokenAmount, destination, sender, recipient, senderPubKey, attestation, destinationToken) {
const { token: originToken, amount } = originTokenAmount;
const { amount: senderBalance } = await originToken.getBalance(this.multiProvider, sender);
const sender