UNPKG

@hyperlane-xyz/sdk

Version:

The official SDK for the Hyperlane Network

1,012 lines 59.6 kB
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