From 49c3348b42a220c521031e2cd944bd112fc499c3 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Mon, 13 Oct 2025 05:21:42 +0200 Subject: [PATCH 01/18] chore: sponsored bridge module scaffold --- api/_bridges/sponsored/strategy.ts | 79 +++++++++++++++++++++ api/_bridges/sponsored/utils/eligibility.ts | 0 api/_bridges/sponsored/utils/signing.ts | 0 3 files changed, 79 insertions(+) create mode 100644 api/_bridges/sponsored/strategy.ts create mode 100644 api/_bridges/sponsored/utils/eligibility.ts create mode 100644 api/_bridges/sponsored/utils/signing.ts diff --git a/api/_bridges/sponsored/strategy.ts b/api/_bridges/sponsored/strategy.ts new file mode 100644 index 000000000..d971df9c9 --- /dev/null +++ b/api/_bridges/sponsored/strategy.ts @@ -0,0 +1,79 @@ +import { + BridgeStrategy, + GetExactInputBridgeQuoteParams, + BridgeCapabilities, + GetOutputBridgeQuoteParams, +} from "../types"; +import { CrossSwap, CrossSwapQuotes, Token } from "../../_dexes/types"; +import { AppFee } from "../../_dexes/utils"; + +const name = "sponsored-bridge"; + +const capabilities: BridgeCapabilities = { + ecosystems: ["evm", "svm"], + supports: { + A2A: false, + A2B: false, + B2A: false, + B2B: true, + B2BI: false, + crossChainMessage: false, + }, +}; + +/** + * Sponsored bridge strategy + */ +export function getSponsoredBridgeStrategy(): BridgeStrategy { + return { + name, + capabilities, + + originTxNeedsAllowance: true, + + getCrossSwapTypes: (params: { + inputToken: Token; + outputToken: Token; + isInputNative: boolean; + isOutputNative: boolean; + }) => { + throw new Error("TODO"); + }, + + getBridgeQuoteRecipient: (crossSwap: CrossSwap) => { + return crossSwap.recipient; + }, + + getBridgeQuoteMessage: (_crossSwap: CrossSwap, _appFee?: AppFee) => { + return "0x"; + }, + + getQuoteForExactInput: async ({ + inputToken, + outputToken, + exactInputAmount, + recipient, + message: _message, + }: GetExactInputBridgeQuoteParams) => { + throw new Error("TODO"); + }, + + getQuoteForOutput: async ({ + inputToken, + outputToken, + minOutputAmount, + forceExactOutput: _forceExactOutput, + recipient, + message: _message, + }: GetOutputBridgeQuoteParams) => { + throw new Error("TODO"); + }, + + buildTxForAllowanceHolder: async (params: { + quotes: CrossSwapQuotes; + integratorId?: string; + }) => { + throw new Error("TODO"); + }, + }; +} diff --git a/api/_bridges/sponsored/utils/eligibility.ts b/api/_bridges/sponsored/utils/eligibility.ts new file mode 100644 index 000000000..e69de29bb diff --git a/api/_bridges/sponsored/utils/signing.ts b/api/_bridges/sponsored/utils/signing.ts new file mode 100644 index 000000000..e69de29bb From a68036131cbbe1eaec303c25eaa9585519edb92c Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Tue, 14 Oct 2025 02:28:05 +0200 Subject: [PATCH 02/18] feat: usdc hypervem -> hypercore via cctp (#1897) --- api/_bridges/cctp/strategy.ts | 13 +++ api/_bridges/cctp/utils/constants.ts | 4 + api/_bridges/cctp/utils/hypercore.ts | 80 +++++++++++++++++++ api/_bridges/index.ts | 28 +++++-- api/_bridges/types.ts | 7 ++ api/_constants.ts | 36 ++++++--- api/swap/approval/_service.ts | 14 +++- .../api/_bridges/cctp/utils/hypercore.test.ts | 59 ++++++++++++++ 8 files changed, 222 insertions(+), 19 deletions(-) create mode 100644 api/_bridges/cctp/utils/hypercore.ts create mode 100644 test/api/_bridges/cctp/utils/hypercore.test.ts diff --git a/api/_bridges/cctp/strategy.ts b/api/_bridges/cctp/strategy.ts index e19f8e2b0..ad84e4091 100644 --- a/api/_bridges/cctp/strategy.ts +++ b/api/_bridges/cctp/strategy.ts @@ -21,6 +21,10 @@ import { getCctpDomainId, encodeDepositForBurn, } from "./utils/constants"; +import { + buildCctpTxHyperEvmToHyperCore, + isHyperEvmToHyperCoreRoute, +} from "./utils/hypercore"; const name = "cctp"; @@ -206,6 +210,15 @@ export function getCctpBridgeStrategy(): BridgeStrategy { const originChainId = crossSwap.inputToken.chainId; const destinationChainId = crossSwap.outputToken.chainId; + if ( + isHyperEvmToHyperCoreRoute({ + inputToken: crossSwap.inputToken, + outputToken: crossSwap.outputToken, + }) + ) { + return buildCctpTxHyperEvmToHyperCore(params); + } + // Get CCTP contract address for origin chain const tokenMessengerAddress = getCctpTokenMessengerAddress(originChainId); diff --git a/api/_bridges/cctp/utils/constants.ts b/api/_bridges/cctp/utils/constants.ts index fe71faa21..23082b40b 100644 --- a/api/_bridges/cctp/utils/constants.ts +++ b/api/_bridges/cctp/utils/constants.ts @@ -8,6 +8,7 @@ export const CCTP_SUPPORTED_CHAINS = [ CHAIN_IDs.MAINNET, CHAIN_IDs.ARBITRUM, CHAIN_IDs.BASE, + CHAIN_IDs.HYPERCORE, CHAIN_IDs.HYPEREVM, CHAIN_IDs.INK, CHAIN_IDs.OPTIMISM, @@ -15,6 +16,9 @@ export const CCTP_SUPPORTED_CHAINS = [ CHAIN_IDs.SOLANA, CHAIN_IDs.UNICHAIN, CHAIN_IDs.WORLD_CHAIN, + // Testnets + CHAIN_IDs.HYPEREVM_TESTNET, + CHAIN_IDs.HYPERCORE_TESTNET, ]; export const CCTP_SUPPORTED_TOKENS = [TOKEN_SYMBOLS_MAP.USDC]; diff --git a/api/_bridges/cctp/utils/hypercore.ts b/api/_bridges/cctp/utils/hypercore.ts new file mode 100644 index 000000000..898664785 --- /dev/null +++ b/api/_bridges/cctp/utils/hypercore.ts @@ -0,0 +1,80 @@ +import { BigNumber, ethers } from "ethers"; +import { CrossSwapQuotes } from "../../../_dexes/types"; +import { tagIntegratorId, tagSwapApiMarker } from "../../../_integrator-id"; +import { InvalidParamError } from "../../../_errors"; +import { CHAIN_IDs } from "../../../_constants"; +import { Token } from "../../../_dexes/types"; + +const CORE_WALLET_ADDRESSES = { + // Currently deployed only on HyperEVM Testnet + [CHAIN_IDs.HYPEREVM_TESTNET]: "0x0B80659a4076E9E93C7DbE0f10675A16a3e5C206", +}; + +// Entrypoint contract on HyperEVM for depositing USDC to Hypercore via CCTP. +const CORE_DEPOSIT_WALLET_ABI = [ + "function depositWithAuth(uint256 amount, uint256 authValidAfter, uint256 authValidBefore, bytes32 authNonce, uint8 v, bytes32 r, bytes32 s)", + "function depositFor(address recipient, uint256 amount)", + "function deposit(uint256 amount)", +]; + +export function isHyperEvmToHyperCoreRoute(params: { + inputToken: Token; + outputToken: Token; +}) { + // Mainnet or testnet route + return ( + (params.inputToken.chainId === CHAIN_IDs.HYPEREVM && + params.outputToken.chainId === CHAIN_IDs.HYPERCORE) || + (params.inputToken.chainId === CHAIN_IDs.HYPEREVM_TESTNET && + params.outputToken.chainId === CHAIN_IDs.HYPERCORE_TESTNET) + ); +} + +export function buildCctpTxHyperEvmToHyperCore(params: { + quotes: CrossSwapQuotes; + integratorId?: string; +}) { + const { bridgeQuote, crossSwap } = params.quotes; + + if ( + !isHyperEvmToHyperCoreRoute({ + inputToken: crossSwap.inputToken, + outputToken: crossSwap.outputToken, + }) + ) { + throw new InvalidParamError({ + message: "Invalid route specified for HyperEVM -> HyperCore via CCTP", + param: "inputToken, outputToken", + }); + } + + const coreWalletAddress = CORE_WALLET_ADDRESSES[crossSwap.inputToken.chainId]; + + if (!coreWalletAddress) { + throw new InvalidParamError({ + message: `CoreWallet address not found for chain ${crossSwap.inputToken.chainId}`, + param: "inputToken.chainId", + }); + } + + const iface = new ethers.utils.Interface(CORE_DEPOSIT_WALLET_ABI); + + const callData = iface.encodeFunctionData("depositFor", [ + crossSwap.recipient, + bridgeQuote.inputAmount, + ]); + + const callDataWithIntegratorId = params.integratorId + ? tagIntegratorId(params.integratorId, callData) + : callData; + const callDataWithSwapApiMarker = tagSwapApiMarker(callDataWithIntegratorId); + + return { + chainId: crossSwap.inputToken.chainId, + from: crossSwap.depositor, + to: coreWalletAddress, + data: callDataWithSwapApiMarker, + value: BigNumber.from(0), + ecosystem: "evm" as const, + }; +} diff --git a/api/_bridges/index.ts b/api/_bridges/index.ts index f4908aed5..18f5f5551 100644 --- a/api/_bridges/index.ts +++ b/api/_bridges/index.ts @@ -2,6 +2,8 @@ import { getAcrossBridgeStrategy } from "./across/strategy"; import { getHyperCoreBridgeStrategy } from "./hypercore/strategy"; import { BridgeStrategiesConfig } from "./types"; import { CHAIN_IDs } from "../_constants"; +import { getCctpBridgeStrategy } from "./cctp/strategy"; +import { Token } from "../_dexes/types"; export const bridgeStrategies: BridgeStrategiesConfig = { default: getAcrossBridgeStrategy(), @@ -13,19 +15,33 @@ export const bridgeStrategies: BridgeStrategiesConfig = { [CHAIN_IDs.HYPEREVM]: getHyperCoreBridgeStrategy(), }, }, + inputTokens: { + USDC: { + [CHAIN_IDs.HYPEREVM]: { + [CHAIN_IDs.HYPERCORE]: getCctpBridgeStrategy(), + }, + [CHAIN_IDs.HYPEREVM_TESTNET]: { + [CHAIN_IDs.HYPERCORE_TESTNET]: getCctpBridgeStrategy(), + }, + }, + }, // TODO: Add CCTP routes when ready }; // TODO: Extend the strategy selection based on more sophisticated logic when we start // implementing burn/mint bridges. export function getBridgeStrategy({ - originChainId, - destinationChainId, + inputToken, + outputToken, }: { - originChainId: number; - destinationChainId: number; + inputToken: Token; + outputToken: Token; }) { const fromToChainOverride = - bridgeStrategies.fromToChains?.[originChainId]?.[destinationChainId]; - return fromToChainOverride ?? bridgeStrategies.default; + bridgeStrategies.fromToChains?.[inputToken.chainId]?.[outputToken.chainId]; + const inputTokenOverride = + bridgeStrategies.inputTokens?.[inputToken.symbol]?.[inputToken.chainId]?.[ + outputToken.chainId + ]; + return inputTokenOverride ?? fromToChainOverride ?? bridgeStrategies.default; } diff --git a/api/_bridges/types.ts b/api/_bridges/types.ts index f941c6d47..c6f70d60a 100644 --- a/api/_bridges/types.ts +++ b/api/_bridges/types.ts @@ -10,6 +10,13 @@ export type BridgeStrategiesConfig = { [toChainId: number]: BridgeStrategy; }; }; + inputTokens?: { + [inputTokenSymbol: string]: { + [fromChainId: number]: { + [toChainId: number]: BridgeStrategy; + }; + }; + }; }; export type BridgeCapabilities = { diff --git a/api/_constants.ts b/api/_constants.ts index 2ced9d9f3..f05ed2e3d 100644 --- a/api/_constants.ts +++ b/api/_constants.ts @@ -14,18 +14,34 @@ const { RELAYER_FEE_CAPITAL_COST_ORIGIN_CHAIN_OVERRIDES, } = getEnvs(); -export const CHAIN_IDs = constants.CHAIN_IDs; -export const TOKEN_SYMBOLS_MAP = { - ...constants.TOKEN_SYMBOLS_MAP, - WHYPE: { - ...constants.TOKEN_SYMBOLS_MAP.WHYPE, - addresses: { - ...constants.TOKEN_SYMBOLS_MAP.HYPE.addresses, - [CHAIN_IDs.HYPERCORE]: "0x2222222222222222222222222222222222222222", - }, +export const CHAIN_IDs = { + ...constants.CHAIN_IDs, + HYPERCORE_TESTNET: 13372, +}; +export const TOKEN_SYMBOLS_MAP = constants.TOKEN_SYMBOLS_MAP; +TOKEN_SYMBOLS_MAP.USDC = { + ...constants.TOKEN_SYMBOLS_MAP.USDC, + addresses: { + ...constants.TOKEN_SYMBOLS_MAP.USDC.addresses, + [CHAIN_IDs.HYPERCORE]: "0x2000000000000000000000000000000000000000", + [CHAIN_IDs.HYPERCORE_TESTNET]: "0x2000000000000000000000000000000000000000", + }, +}; +TOKEN_SYMBOLS_MAP.WHYPE = { + ...constants.TOKEN_SYMBOLS_MAP.WHYPE, + addresses: { + ...constants.TOKEN_SYMBOLS_MAP.HYPE.addresses, + [CHAIN_IDs.HYPERCORE]: "0x2222222222222222222222222222222222222222", + [CHAIN_IDs.HYPERCORE_TESTNET]: "0x2222222222222222222222222222222222222222", + }, +}; +export const CHAINS = { + ...constants.PUBLIC_NETWORKS, + [CHAIN_IDs.HYPERCORE_TESTNET]: { + ...constants.PUBLIC_NETWORKS[CHAIN_IDs.HYPERCORE], + chainId: CHAIN_IDs.HYPERCORE_TESTNET, }, }; -export const CHAINS = constants.PUBLIC_NETWORKS; export const TOKEN_EQUIVALENCE_REMAPPING = constants.TOKEN_EQUIVALENCE_REMAPPING; diff --git a/api/swap/approval/_service.ts b/api/swap/approval/_service.ts index d548b16aa..35cee3453 100644 --- a/api/swap/approval/_service.ts +++ b/api/swap/approval/_service.ts @@ -27,9 +27,15 @@ import { quoteFetchStrategies } from "../_configs"; import { getBridgeStrategy } from "../../_bridges"; import { TypedVercelRequest } from "../../_types"; import { AcrossErrorCode } from "../../_errors"; +import { CHAIN_IDs } from "../../_constants"; const logger = getLogger(); +// Allows us to redirect the gas price cache chain ID to the mainnet chain ID for testnet chains +const gasPriceCacheChainIdRedirects: Record = { + [CHAIN_IDs.HYPEREVM_TESTNET]: CHAIN_IDs.HYPEREVM, +}; + export async function handleApprovalSwap( request: TypedVercelRequest, span?: Span @@ -82,8 +88,8 @@ export async function handleApprovalSwap( // TODO: Extend the strategy selection based on more sophisticated logic when we start // implementing burn/mint bridges. const bridgeStrategy = getBridgeStrategy({ - originChainId: inputToken.chainId, - destinationChainId: outputToken.chainId, + inputToken, + outputToken, }); const crossSwapQuotes = await getCrossSwapQuotes( { @@ -216,7 +222,9 @@ export async function handleApprovalSwap( ] = await Promise.all([ getOriginTxGas(), crossSwapTx.ecosystem === "evm" - ? latestGasPriceCache(originTxChainId).get() + ? latestGasPriceCache( + gasPriceCacheChainIdRedirects[originTxChainId] ?? originTxChainId + ).get() : undefined, getCachedTokenPrice({ symbol: inputToken.symbol, diff --git a/test/api/_bridges/cctp/utils/hypercore.test.ts b/test/api/_bridges/cctp/utils/hypercore.test.ts new file mode 100644 index 000000000..af5585eec --- /dev/null +++ b/test/api/_bridges/cctp/utils/hypercore.test.ts @@ -0,0 +1,59 @@ +import { isHyperEvmToHyperCoreRoute } from "../../../../../api/_bridges/cctp/utils/hypercore"; +import { CHAIN_IDs } from "../../../../../api/_constants"; +import { TOKEN_SYMBOLS_MAP } from "../../../../../api/_constants"; + +describe("bridges -> cctp -> hypercore utils", () => { + describe("#isHyperEvmToHyperCoreRoute()", () => { + test("should return true for HyperEVM -> HyperCore", () => { + const params = { + inputToken: { + ...TOKEN_SYMBOLS_MAP.USDC, + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.HYPEREVM], + chainId: CHAIN_IDs.HYPEREVM, + }, + outputToken: { + ...TOKEN_SYMBOLS_MAP.USDC, + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.HYPERCORE], + chainId: CHAIN_IDs.HYPERCORE, + }, + }; + const isRouteSupported = isHyperEvmToHyperCoreRoute(params); + expect(isRouteSupported).toEqual(true); + }); + + test("should return true for HyperEVM Testnet -> HyperCore Testnet", () => { + const params = { + inputToken: { + ...TOKEN_SYMBOLS_MAP.USDC, + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.HYPEREVM_TESTNET], + chainId: CHAIN_IDs.HYPEREVM_TESTNET, + }, + outputToken: { + ...TOKEN_SYMBOLS_MAP.USDC, + address: + TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.HYPERCORE_TESTNET], + chainId: CHAIN_IDs.HYPERCORE_TESTNET, + }, + }; + const isRouteSupported = isHyperEvmToHyperCoreRoute(params); + expect(isRouteSupported).toEqual(true); + }); + + test("should return false for HyperCore -> HyperEVM", () => { + const params = { + inputToken: { + ...TOKEN_SYMBOLS_MAP.USDC, + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.HYPERCORE], + chainId: CHAIN_IDs.HYPERCORE, + }, + outputToken: { + ...TOKEN_SYMBOLS_MAP.USDC, + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.HYPEREVM], + chainId: CHAIN_IDs.HYPEREVM, + }, + }; + const isRouteSupported = isHyperEvmToHyperCoreRoute(params); + expect(isRouteSupported).toEqual(false); + }); + }); +}); From bf4a30506045f79a5ef7c2a0bada904b5a63d8bf Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Tue, 14 Oct 2025 02:53:08 +0200 Subject: [PATCH 03/18] fix: sponsored bridge strategy --- api/_bridges/sponsored/strategy.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api/_bridges/sponsored/strategy.ts b/api/_bridges/sponsored/strategy.ts index d971df9c9..f20a7578c 100644 --- a/api/_bridges/sponsored/strategy.ts +++ b/api/_bridges/sponsored/strategy.ts @@ -31,6 +31,10 @@ export function getSponsoredBridgeStrategy(): BridgeStrategy { originTxNeedsAllowance: true, + isRouteSupported: (params: { inputToken: Token; outputToken: Token }) => { + throw new Error("TODO"); + }, + getCrossSwapTypes: (params: { inputToken: Token; outputToken: Token; From 2b3a17569e71056c363ce7bdf540907dddb430ca Mon Sep 17 00:00:00 2001 From: Melisa Guevara Date: Tue, 14 Oct 2025 10:39:32 -0300 Subject: [PATCH 04/18] chore: sync with master (#1902) --- api/_bridges/across/strategy.ts | 6 + api/_bridges/cctp/strategy.ts | 279 ++++++++++++++++++++++++--- api/_bridges/cctp/utils/constants.ts | 16 ++ api/_bridges/hypercore/strategy.ts | 5 + api/_bridges/oft/strategy.ts | 35 +++- api/_dexes/types.ts | 1 + api/swap/_utils.ts | 138 ++++++++----- docs/api-docs-openapi.yaml | 64 ++++++ package.json | 2 +- test/api/swap/_utils.test.ts | 165 +++++++++++++++- yarn.lock | 139 ++++++++++++- 11 files changed, 761 insertions(+), 89 deletions(-) diff --git a/api/_bridges/across/strategy.ts b/api/_bridges/across/strategy.ts index f28682ceb..83555dcb2 100644 --- a/api/_bridges/across/strategy.ts +++ b/api/_bridges/across/strategy.ts @@ -39,6 +39,7 @@ export function getAcrossBridgeStrategy(): BridgeStrategy { feesFromApi: Awaited>, bridgeFeesToken: Token ) => { + const zeroBN = BigNumber.from(0); return { totalRelay: { total: BigNumber.from(feesFromApi.totalRelayFee.total), @@ -60,6 +61,11 @@ export function getAcrossBridgeStrategy(): BridgeStrategy { pct: BigNumber.from(feesFromApi.lpFee.pct), token: bridgeFeesToken, }, + bridgeFee: { + pct: zeroBN, + total: zeroBN, + token: bridgeFeesToken, + }, }; }; diff --git a/api/_bridges/cctp/strategy.ts b/api/_bridges/cctp/strategy.ts index 0c9366a70..6d234fff3 100644 --- a/api/_bridges/cctp/strategy.ts +++ b/api/_bridges/cctp/strategy.ts @@ -1,4 +1,16 @@ -import { BigNumber, ethers } from "ethers"; +import { BigNumber, ethers, utils } from "ethers"; +import * as sdk from "@across-protocol/sdk"; +import { TokenMessengerMinterV2Client } from "@across-protocol/contracts"; +import { + address, + generateKeyPairSigner, + getBase64EncodedWireTransaction, + appendTransactionMessageInstruction, + createNoopSigner, + partiallySignTransaction, + compileTransaction, +} from "@solana/kit"; +import { getAddMemoInstruction } from "@solana-program/memo"; import { BridgeStrategy, @@ -11,13 +23,20 @@ import { AppFee, CROSS_SWAP_TYPE } from "../../_dexes/utils"; import { Token } from "../../_dexes/types"; import { InvalidParamError } from "../../_errors"; import { ConvertDecimals } from "../../_utils"; -import { tagIntegratorId, tagSwapApiMarker } from "../../_integrator-id"; +import { + assertValidIntegratorId, + SWAP_CALLDATA_MARKER, + tagIntegratorId, + tagSwapApiMarker, +} from "../../_integrator-id"; +import { getSVMRpc } from "../../_providers"; import { CCTP_SUPPORTED_CHAINS, CCTP_SUPPORTED_TOKENS, CCTP_FINALITY_THRESHOLDS, CCTP_FILL_TIME_ESTIMATES, getCctpTokenMessengerAddress, + getCctpMessageTransmitterAddress, getCctpDomainId, encodeDepositForBurn, } from "./utils/constants"; @@ -187,23 +206,24 @@ export function getCctpBridgeStrategy(): BridgeStrategy { integratorId?: string; }) => { const { - bridgeQuote, crossSwap, originSwapQuote, destinationSwapQuote, appFee, + bridgeQuote, } = params.quotes; // CCTP validations if (appFee?.feeAmount.gt(0)) { throw new InvalidParamError({ - message: "CCTP: App fee handling not implemented yet", + message: "App fee is not supported for CCTP bridge transfers", }); } if (originSwapQuote || destinationSwapQuote) { throw new InvalidParamError({ - message: "CCTP: Origin/destination swaps not implemented yet", + message: + "Origin/destination swaps are not supported for CCTP bridge transfers", }); } @@ -219,40 +239,39 @@ export function getCctpBridgeStrategy(): BridgeStrategy { return buildCctpTxHyperEvmToHyperCore(params); } - // Get CCTP contract address for origin chain - const tokenMessengerAddress = getCctpTokenMessengerAddress(originChainId); - // Get CCTP domain IDs const destinationDomain = getCctpDomainId(destinationChainId); + const tokenMessenger = getCctpTokenMessengerAddress(originChainId); - // Get burn token address (USDC on origin chain) - const burnTokenAddress = crossSwap.inputToken.address; - - // Encode the depositForBurn call - const callData = encodeDepositForBurn({ + // depositForBurn input parameters + const depositForBurnParams = { amount: bridgeQuote.inputAmount, destinationDomain, mintRecipient: crossSwap.recipient, - burnToken: burnTokenAddress, destinationCaller: ethers.constants.AddressZero, // Anyone can finalize the message on domain when this is set to bytes32(0) maxFee: BigNumber.from(0), // maxFee set to 0 so this will be a "standard" speed transfer minFinalityThreshold: CCTP_FINALITY_THRESHOLDS.standard, // Hardcoded minFinalityThreshold value for standard transfer - }); - - // Handle integrator ID and swap API marker tagging - const callDataWithIntegratorId = params.integratorId - ? tagIntegratorId(params.integratorId, callData) - : callData; - const callDataWithMarkers = tagSwapApiMarker(callDataWithIntegratorId); - - return { - chainId: originChainId, - from: crossSwap.depositor, - to: tokenMessengerAddress, - data: callDataWithMarkers, - value: BigNumber.from(0), // No native value for USDC burns - ecosystem: "evm" as const, }; + + if (crossSwap.isOriginSvm) { + return _buildCctpTxForAllowanceHolderSvm({ + crossSwapQuotes: params.quotes, + integratorId: params.integratorId, + originChainId, + destinationChainId, + tokenMessenger, + depositForBurnParams, + }); + } else { + return _buildCctpTxForAllowanceHolderEvm({ + crossSwapQuotes: params.quotes, + integratorId: params.integratorId, + originChainId, + destinationChainId, + tokenMessenger, + depositForBurnParams, + }); + } }, isRouteSupported, @@ -282,5 +301,207 @@ function getCctpBridgeFees(inputToken: Token) { total: zeroBN, token: inputToken, }, + bridgeFee: { + pct: zeroBN, + total: zeroBN, + token: inputToken, + }, + }; +} + +/** + * Builds CCTP deposit transaction for EVM chains + */ +async function _buildCctpTxForAllowanceHolderEvm(params: { + crossSwapQuotes: CrossSwapQuotes; + integratorId?: string; + originChainId: number; + destinationChainId: number; + tokenMessenger: string; + depositForBurnParams: { + amount: BigNumber; + destinationDomain: number; + mintRecipient: string; + destinationCaller: string; + maxFee: BigNumber; + minFinalityThreshold: number; + }; +}) { + const { + crossSwapQuotes, + integratorId, + originChainId, + tokenMessenger, + depositForBurnParams, + } = params; + const { crossSwap } = crossSwapQuotes; + const burnTokenAddress = crossSwap.inputToken.address; + + // Encode the depositForBurn call + const callData = encodeDepositForBurn({ + ...depositForBurnParams, + burnToken: burnTokenAddress, + }); + + // Handle integrator ID and swap API marker tagging + const callDataWithIntegratorId = integratorId + ? tagIntegratorId(integratorId, callData) + : callData; + const callDataWithMarkers = tagSwapApiMarker(callDataWithIntegratorId); + + return { + chainId: originChainId, + from: crossSwap.depositor, + to: tokenMessenger, + data: callDataWithMarkers, + value: BigNumber.from(0), + ecosystem: "evm" as const, + }; +} + +/** + * Builds CCTP deposit transaction for Solana + */ +async function _buildCctpTxForAllowanceHolderSvm(params: { + crossSwapQuotes: CrossSwapQuotes; + integratorId?: string; + originChainId: number; + destinationChainId: number; + tokenMessenger: string; + depositForBurnParams: { + amount: BigNumber; + destinationDomain: number; + mintRecipient: string; + destinationCaller: string; + maxFee: BigNumber; + minFinalityThreshold: number; + }; +}) { + if (params.integratorId) { + assertValidIntegratorId(params.integratorId); + } + + const { + crossSwapQuotes, + integratorId, + originChainId, + destinationChainId, + tokenMessenger, + depositForBurnParams, + } = params; + const { crossSwap } = crossSwapQuotes; + + // Get message transmitter address + const messageTransmitter = getCctpMessageTransmitterAddress(originChainId); + + // Convert addresses to Solana Kit address format for instruction parameters. + const depositor = sdk.utils.toAddressType(crossSwap.depositor, originChainId); + const mintRecipient = sdk.utils.toAddressType( + depositForBurnParams.mintRecipient, + destinationChainId + ); + const destinationCaller = sdk.utils.toAddressType( + depositForBurnParams.destinationCaller, + destinationChainId + ); + const tokenMint = sdk.utils.toAddressType( + crossSwap.inputToken.address, + originChainId + ); + + // Address class handles intermediate conversions internally (e.g., EVM address -> bytes32 -> base58). + const mintRecipientAddress = address(mintRecipient.toBase58()); + const destinationCallerAddress = address(destinationCaller.toBase58()); + const tokenMessengerAddress = address(tokenMessenger); + const messageTransmitterAddress = address(messageTransmitter); + const tokenMintAddress = address(tokenMint.toBase58()); + const depositorAddress = address(depositor.toBase58()); + + // Get depositor's USDC token account + const depositorTokenAccount = await sdk.arch.svm.getAssociatedTokenAddress( + depositor.forceSvmAddress(), + tokenMint.forceSvmAddress() + ); + + // Generate a keypair for the MessageSent event account (CCTP V2 requirement). + // Unlike EVM chains that use event logs, Solana CCTP stores events in on-chain accounts for persistence. + // This account costs ~0.0038 SOL in rent (paid by depositor), reclaimable after 5 days via reclaim_event_account. + // We partially sign the transaction with this keypair and the depositor adds their signature before submission. + // Docs: https://developers.circle.com/cctp/solana-programs#tokenmessengerminterv2 + const eventDataKeypair = await generateKeyPairSigner(); + + // Get CCTP deposit accounts + const cctpAccounts = await sdk.arch.svm.getCCTPDepositAccounts( + originChainId, + depositForBurnParams.destinationDomain, + tokenMessengerAddress, + messageTransmitterAddress + ); + + // Create signers + const depositorSigner = createNoopSigner(depositorAddress); + + // Use the TokenMessenger client to build the instruction + const depositInstruction = + await TokenMessengerMinterV2Client.getDepositForBurnInstructionAsync({ + owner: depositorSigner, + eventRentPayer: depositorSigner, + senderAuthorityPda: cctpAccounts.tokenMessengerMinterSenderAuthority, + burnTokenAccount: depositorTokenAccount, + messageTransmitter: cctpAccounts.messageTransmitter, + tokenMessenger: cctpAccounts.tokenMessenger, + remoteTokenMessenger: cctpAccounts.remoteTokenMessenger, + tokenMinter: cctpAccounts.tokenMinter, + localToken: cctpAccounts.localToken, + burnTokenMint: tokenMintAddress, + messageSentEventData: eventDataKeypair, + eventAuthority: cctpAccounts.cctpEventAuthority, + program: tokenMessengerAddress, + amount: BigInt(depositForBurnParams.amount.toString()), + destinationDomain: depositForBurnParams.destinationDomain, + mintRecipient: mintRecipientAddress, + destinationCaller: destinationCallerAddress, + maxFee: BigInt(depositForBurnParams.maxFee.toString()), + minFinalityThreshold: depositForBurnParams.minFinalityThreshold, + }); + + // Build the transaction message using SDK helper + const rpcClient = getSVMRpc(originChainId); + let tx = await sdk.arch.svm.createDefaultTransaction( + rpcClient, + depositorSigner + ); + + // Add the deposit instruction + tx = appendTransactionMessageInstruction(depositInstruction, tx); + + // Add integrator memo if provided and Swap API marker + tx = appendTransactionMessageInstruction( + getAddMemoInstruction({ + memo: integratorId + ? utils.hexConcat([integratorId, SWAP_CALLDATA_MARKER]) + : SWAP_CALLDATA_MARKER, + }), + tx + ); + + // Compile the transaction message + const compiledTx = compileTransaction(tx); + + // Partially sign the transaction with only the event data keypair + // The depositor will also sign before submitting + const partiallySignedTx = await partiallySignTransaction( + [eventDataKeypair.keyPair], + compiledTx + ); + + // Encode the partially signed transaction + const base64EncodedTx = getBase64EncodedWireTransaction(partiallySignedTx); + + return { + chainId: originChainId, + to: tokenMessengerAddress, + data: base64EncodedTx, + ecosystem: "svm" as const, }; } diff --git a/api/_bridges/cctp/utils/constants.ts b/api/_bridges/cctp/utils/constants.ts index 23082b40b..4ef33f074 100644 --- a/api/_bridges/cctp/utils/constants.ts +++ b/api/_bridges/cctp/utils/constants.ts @@ -55,6 +55,22 @@ export const getCctpTokenMessengerAddress = (chainId: number): string => { ); }; +// Source: https://developers.circle.com/cctp/evm-smart-contracts +const DEFAULT_CCTP_MESSAGE_TRANSMITTER_ADDRESS = + "0x81D40F21F12A8F0E3252Bccb954D722d4c464B64"; + +// Source: https://developers.circle.com/cctp/solana-programs +const CCTP_MESSAGE_TRANSMITTER_ADDRESS_OVERRIDES: Record = { + [CHAIN_IDs.SOLANA]: "CCTPV2Sm4AdWt5296sk4P66VBZ7bEhcARwFaaS9YPbeC", +}; + +export const getCctpMessageTransmitterAddress = (chainId: number): string => { + return ( + CCTP_MESSAGE_TRANSMITTER_ADDRESS_OVERRIDES[chainId] || + DEFAULT_CCTP_MESSAGE_TRANSMITTER_ADDRESS + ); +}; + // CCTP TokenMessenger depositForBurn ABI const CCTP_DEPOSIT_FOR_BURN_ABI = { inputs: [ diff --git a/api/_bridges/hypercore/strategy.ts b/api/_bridges/hypercore/strategy.ts index 4ae1795c4..f37a8675d 100644 --- a/api/_bridges/hypercore/strategy.ts +++ b/api/_bridges/hypercore/strategy.ts @@ -299,5 +299,10 @@ function getZeroBridgeFees(inputToken: Token) { total: zeroBN, token: inputToken, }, + bridgeFee: { + pct: zeroBN, + total: zeroBN, + token: inputToken, + }, }; } diff --git a/api/_bridges/oft/strategy.ts b/api/_bridges/oft/strategy.ts index 199edf77c..5778fd5a3 100644 --- a/api/_bridges/oft/strategy.ts +++ b/api/_bridges/oft/strategy.ts @@ -15,6 +15,7 @@ import { import { InvalidParamError } from "../../_errors"; import { ConvertDecimals, getProvider } from "../../_utils"; import { tagIntegratorId, tagSwapApiMarker } from "../../_integrator-id"; +import { getNativeTokenInfo } from "../../swap/_utils"; import { getOftMessengerForToken, createSendParamStruct, @@ -322,7 +323,7 @@ export function getOftBridgeStrategy(): BridgeStrategy { }: GetExactInputBridgeQuoteParams) => { assertSupportedRoute({ inputToken, outputToken }); - const [{ inputAmount, outputAmount }, estimatedFillTimeSec] = + const [{ inputAmount, outputAmount, nativeFee }, estimatedFillTimeSec] = await Promise.all([ getQuote({ inputToken, @@ -337,6 +338,8 @@ export function getOftBridgeStrategy(): BridgeStrategy { ), ]); + const nativeToken = getNativeTokenInfo(inputToken.chainId); + return { bridgeQuote: { inputToken, @@ -346,7 +349,11 @@ export function getOftBridgeStrategy(): BridgeStrategy { minOutputAmount: outputAmount, estimatedFillTimeSec, provider: name, - fees: getOftBridgeFees(inputToken), + fees: getOftBridgeFees({ + inputToken, + nativeFee, + nativeToken, + }), }, }; }, @@ -368,7 +375,7 @@ export function getOftBridgeStrategy(): BridgeStrategy { )(minOutputAmount); // Get quote from OFT contracts and estimated fill time in parallel - const [{ inputAmount, outputAmount }, estimatedFillTimeSec] = + const [{ inputAmount, outputAmount, nativeFee }, estimatedFillTimeSec] = await Promise.all([ getQuote({ inputToken, @@ -392,6 +399,8 @@ export function getOftBridgeStrategy(): BridgeStrategy { ); assertMinOutputAmount(outputAmount, roundedMinOutputAmount); + const nativeToken = getNativeTokenInfo(inputToken.chainId); + return { bridgeQuote: { inputToken, @@ -401,7 +410,11 @@ export function getOftBridgeStrategy(): BridgeStrategy { minOutputAmount, estimatedFillTimeSec, provider: name, - fees: getOftBridgeFees(inputToken), + fees: getOftBridgeFees({ + inputToken, + nativeFee, + nativeToken, + }), }, }; }, @@ -491,9 +504,12 @@ export function getOftBridgeStrategy(): BridgeStrategy { }; } -// TODO: Include messageFee and oftFee in the fees structure -// https://linear.app/uma/issue/ACX-4499/add-oft-fees-to-swap-api-response -function getOftBridgeFees(inputToken: Token) { +function getOftBridgeFees(params: { + inputToken: Token; + nativeFee: BigNumber; + nativeToken: Token; +}) { + const { inputToken, nativeFee, nativeToken } = params; const zeroBN = BigNumber.from(0); return { totalRelay: { @@ -516,5 +532,10 @@ function getOftBridgeFees(inputToken: Token) { total: zeroBN, token: inputToken, }, + bridgeFee: { + pct: zeroBN, + total: nativeFee, + token: nativeToken, + }, }; } diff --git a/api/_dexes/types.ts b/api/_dexes/types.ts index 174d23eda..1dd13ea67 100644 --- a/api/_dexes/types.ts +++ b/api/_dexes/types.ts @@ -131,6 +131,7 @@ export type CrossSwapQuotes = { relayerCapital: FeeComponent; relayerGas: FeeComponent; lp: FeeComponent; + bridgeFee: FeeComponent; }; } & ( | { diff --git a/api/swap/_utils.ts b/api/swap/_utils.ts index cb820a548..da4ec74a4 100644 --- a/api/swap/_utils.ts +++ b/api/swap/_utils.ts @@ -581,6 +581,45 @@ export function stringifyBigNumProps(value: T): T { ) as T; } +/** + * Helper function to format fee components with consistent structure + * @param amount - Fee amount in token units (BigNumber) + * @param amountUsd - Fee amount in USD + * @param token - Token information + * @param inputAmountUsd - Total input amount in USD (for percentage calculation). Omit to exclude pct field. + * @returns Formatted fee object with amount, amountUsd, pct (if inputAmountUsd provided), and token + */ +function formatFeeComponent(params: { + amount: BigNumber; + amountUsd: number; + token: Token; + inputAmountUsd?: number; +}) { + const DEFAULT_PRECISION = 18; + const { amount, amountUsd, token, inputAmountUsd } = params; + + const result: { + amount: BigNumber; + amountUsd: string; + pct?: BigNumber; + token: Token; + } = { + amount, + amountUsd: ethers.utils.formatEther( + ethers.utils.parseEther(amountUsd.toFixed(DEFAULT_PRECISION)) + ), + token, + }; + + if (inputAmountUsd !== undefined) { + result.pct = ethers.utils.parseEther( + (amountUsd / inputAmountUsd).toFixed(DEFAULT_PRECISION) + ); + } + + return result; +} + export async function calculateSwapFees(params: { inputAmount: BigNumber; originSwapQuote?: SwapQuote; @@ -710,6 +749,7 @@ export async function calculateSwapFees(params: { const destinationGas = bridgeFees.relayerGas; const lpFee = bridgeFees.lp; const relayerTotal = bridgeFees.totalRelay; + const bridgeFee = bridgeFees.bridgeFee; const originGasToken = getNativeTokenInfo(originChainId); const destinationGasToken = getNativeTokenInfo( @@ -717,6 +757,19 @@ export async function calculateSwapFees(params: { destinationChainId ); + // Get USD price for bridge fee token + // Skip price fetch if bridge fee is zero + const bridgeFeeTokenPriceUsd = bridgeFee.total.isZero() + ? 0 + : bridgeFee.token.chainId === originChainId && + bridgeFee.token.symbol === originGasToken.symbol + ? originNativePriceUsd + : await getCachedTokenPrice({ + symbol: bridgeFee.token.symbol, + tokenAddress: bridgeFee.token.address, + chainId: bridgeFee.token.chainId, + }); + // Calculate USD amounts const originGasUsd = parseFloat(utils.formatUnits(originGas, originGasToken.decimals)) * @@ -739,6 +792,9 @@ export async function calculateSwapFees(params: { parseFloat( utils.formatUnits(relayerTotal.total, bridgeQuote.inputToken.decimals) ) * bridgeQuoteInputTokenPriceUsd; + const bridgeFeeUsd = + parseFloat(utils.formatUnits(bridgeFee.total, bridgeFee.token.decimals)) * + bridgeFeeTokenPriceUsd; const inputAmountUsd = parseFloat(utils.formatUnits(inputAmount, inputToken.decimals)) * inputTokenPriceUsd; @@ -757,71 +813,57 @@ export async function calculateSwapFees(params: { .div(sdk.utils.fixedPointAdjustment); return { - total: { + total: formatFeeComponent({ amount: totalFeeAmount, - amountUsd: ethers.utils.formatEther( - ethers.utils.parseEther(totalFeeUsd.toFixed(18)) - ), - pct: ethers.utils.parseEther(totalFeePct.toFixed(18)), + amountUsd: totalFeeUsd, token: inputToken, - }, - originGas: { + inputAmountUsd, + }), + originGas: formatFeeComponent({ amount: originGas, - amountUsd: ethers.utils.formatEther( - ethers.utils.parseEther(originGasUsd.toFixed(18)) - ), + amountUsd: originGasUsd, token: originGasToken, - }, - destinationGas: { + }), + destinationGas: formatFeeComponent({ amount: safeUsdToTokenAmount( destinationGasUsd, destinationNativePriceUsd, destinationGasToken.decimals ), - amountUsd: ethers.utils.formatEther( - ethers.utils.parseEther(destinationGasUsd.toFixed(18)) - ), - pct: ethers.utils.parseEther( - (destinationGasUsd / inputAmountUsd).toFixed(18) - ), + amountUsd: destinationGasUsd, token: destinationGasToken, - }, - relayerCapital: { + inputAmountUsd, + }), + relayerCapital: formatFeeComponent({ amount: relayerCapital.total, - amountUsd: ethers.utils.formatEther( - ethers.utils.parseEther(relayerCapitalUsd.toFixed(18)) - ), - pct: ethers.utils.parseEther( - (relayerCapitalUsd / inputAmountUsd).toFixed(18) - ), + amountUsd: relayerCapitalUsd, token: bridgeQuote.inputToken, - }, - lpFee: { + inputAmountUsd, + }), + lpFee: formatFeeComponent({ amount: lpFee.total, - amountUsd: ethers.utils.formatEther( - ethers.utils.parseEther(lpFeeUsd.toFixed(18)) - ), - pct: ethers.utils.parseEther((lpFeeUsd / inputAmountUsd).toFixed(18)), + amountUsd: lpFeeUsd, token: bridgeQuote.inputToken, - }, - relayerTotal: { + inputAmountUsd, + }), + relayerTotal: formatFeeComponent({ amount: relayerTotal.total, - amountUsd: ethers.utils.formatEther( - ethers.utils.parseEther(relayerTotalUsd.toFixed(18)) - ), - pct: ethers.utils.parseEther( - (relayerTotalUsd / inputAmountUsd).toFixed(18) - ), + amountUsd: relayerTotalUsd, token: bridgeQuote.inputToken, - }, - app: { + inputAmountUsd, + }), + bridgeFee: formatFeeComponent({ + amount: bridgeFee.total, + amountUsd: bridgeFeeUsd, + token: bridgeFee.token, + inputAmountUsd, + }), + app: formatFeeComponent({ amount: appFeeAmount, - amountUsd: ethers.utils.formatEther( - ethers.utils.parseEther(appFeeUsd.toFixed(18)) - ), - pct: ethers.utils.parseEther((appFeeUsd / inputAmountUsd).toFixed(18)), + amountUsd: appFeeUsd, token: appFeeToken, - }, + inputAmountUsd, + }), }; } catch (error) { logger.debug({ @@ -833,7 +875,7 @@ export async function calculateSwapFees(params: { } } -function getNativeTokenInfo(chainId: number): Token { +export function getNativeTokenInfo(chainId: number): Token { const chainInfo = getChainInfo(chainId); const token = TOKEN_SYMBOLS_MAP[chainInfo.nativeToken as keyof typeof TOKEN_SYMBOLS_MAP]; diff --git a/docs/api-docs-openapi.yaml b/docs/api-docs-openapi.yaml index f0461338a..f207e28c2 100644 --- a/docs/api-docs-openapi.yaml +++ b/docs/api-docs-openapi.yaml @@ -1871,6 +1871,14 @@ paths: total: type: string example: '11' + bridgeFee: + type: object + description: Bridge-specific fee charged by the bridge provider. + properties: + pct: + type: string + total: + type: string x-gitbook-description-html:

Fee details for the bridge transaction.

x-gitbook-description-html:

Bridge step information.

destinationSwap: @@ -2205,6 +2213,30 @@ paths: type: integer example: 10 x-gitbook-description-html:

Total relayer fees.

+ bridgeFee: + type: object + description: Bridge-specific fee charged by the bridge provider. + properties: + amount: + type: string + amountUsd: + type: string + pct: + type: string + token: + type: object + properties: + decimals: + type: integer + symbol: + type: string + address: + type: string + name: + type: string + chainId: + type: integer + x-gitbook-description-html:

Bridge-specific fee charged by the bridge provider.

app: type: object description: Application fees. @@ -2788,6 +2820,14 @@ paths: total: type: string example: '11' + bridgeFee: + type: object + description: Bridge-specific fee charged by the bridge provider. + properties: + pct: + type: string + total: + type: string x-gitbook-description-html:

Fee details for the bridge transaction.

x-gitbook-description-html:

Bridge step information.

destinationSwap: @@ -3122,6 +3162,30 @@ paths: type: integer example: 10 x-gitbook-description-html:

Total relayer fees.

+ bridgeFee: + type: object + description: Bridge-specific fee charged by the bridge provider. + properties: + amount: + type: string + amountUsd: + type: string + pct: + type: string + token: + type: object + properties: + decimals: + type: integer + symbol: + type: string + address: + type: string + name: + type: string + chainId: + type: integer + x-gitbook-description-html:

Bridge-specific fee charged by the bridge provider.

app: type: object description: Application fees. diff --git a/package.json b/package.json index 670b1abde..6afae1d50 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "license": "AGPL-3.0-only", "dependencies": { "@across-protocol/constants": "^3.1.80", - "@across-protocol/contracts": "^4.1.9", + "@across-protocol/contracts": "^4.1.11", "@across-protocol/contracts-v4.1.1": "npm:@across-protocol/contracts@4.1.1", "@across-protocol/sdk": "^4.3.67", "@amplitude/analytics-browser": "^2.3.5", diff --git a/test/api/swap/_utils.test.ts b/test/api/swap/_utils.test.ts index 357f7d931..0f64bfb19 100644 --- a/test/api/swap/_utils.test.ts +++ b/test/api/swap/_utils.test.ts @@ -1,5 +1,9 @@ -import { BigNumber } from "ethers"; -import { stringifyBigNumProps } from "../../../api/swap/_utils"; +import { BigNumber, utils, constants } from "ethers"; +import { + stringifyBigNumProps, + calculateSwapFees, +} from "../../../api/swap/_utils"; +import { TOKEN_SYMBOLS_MAP, CHAIN_IDs } from "../../../api/_constants"; describe("stringifyBigNumProps", () => { describe("BigNumber detection and conversion", () => { @@ -227,3 +231,160 @@ describe("stringifyBigNumProps", () => { }); }); }); + +describe("calculateSwapFees", () => { + test("should calculate fees for bridge-only route", async () => { + // Setup test tokens using constants + const inputToken = { + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.MAINNET], + symbol: TOKEN_SYMBOLS_MAP.USDC.symbol, + decimals: TOKEN_SYMBOLS_MAP.USDC.decimals, + chainId: CHAIN_IDs.MAINNET, + }; + + const outputToken = { + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.OPTIMISM], + symbol: TOKEN_SYMBOLS_MAP.USDC.symbol, + decimals: TOKEN_SYMBOLS_MAP.USDC.decimals, + chainId: CHAIN_IDs.OPTIMISM, + }; + + const bridgeFeeToken = { + address: constants.AddressZero, + symbol: TOKEN_SYMBOLS_MAP.ETH.symbol, + decimals: TOKEN_SYMBOLS_MAP.ETH.decimals, + chainId: CHAIN_IDs.MAINNET, + }; + + // Input: 1000 USDC + const inputAmount = utils.parseUnits("1000", inputToken.decimals); + + // Bridge quote with fees + const bridgeQuote = { + inputToken, + outputToken, + inputAmount: utils.parseUnits("1000", inputToken.decimals), + outputAmount: utils.parseUnits("995", outputToken.decimals), + minOutputAmount: utils.parseUnits("995", outputToken.decimals), + estimatedFillTimeSec: 60, + provider: "across" as const, + suggestedFees: {} as any, + fees: { + relayerCapital: { + total: utils.parseUnits("1", inputToken.decimals), + pct: utils.parseEther("0.001"), + token: inputToken, + }, + relayerGas: { + total: utils.parseUnits("2", inputToken.decimals), + pct: utils.parseEther("0.002"), + token: inputToken, + }, + lp: { + total: utils.parseUnits("1.5", inputToken.decimals), + pct: utils.parseEther("0.0015"), + token: inputToken, + }, + totalRelay: { + total: utils.parseUnits("4.5", inputToken.decimals), + pct: utils.parseEther("0.0045"), + token: inputToken, + }, + bridgeFee: { + total: utils.parseUnits("0.5", bridgeFeeToken.decimals), + pct: utils.parseEther("0"), + token: bridgeFeeToken, + }, + }, + }; + + // Mock prices (1 USDC = $1, 1 ETH = $2000) + const inputTokenPriceUsd = 1; + const outputTokenPriceUsd = 1; + const originNativePriceUsd = 2000; + const destinationNativePriceUsd = 2000; + const bridgeQuoteInputTokenPriceUsd = 1; + const appFeeTokenPriceUsd = 1; + + // Origin gas: 100k gas * 50 gwei = 0.005 ETH + const originTxGas = BigNumber.from(100000); + const originTxGasPrice = utils.parseUnits("50", "gwei"); + + // Min output amount sans app fees: 995 USDC + const minOutputAmountSansAppFees = utils.parseUnits( + "995", + outputToken.decimals + ); + + // Mock logger + const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const result = await calculateSwapFees({ + inputAmount, + bridgeQuote, + originTxGas, + originTxGasPrice, + inputTokenPriceUsd, + outputTokenPriceUsd, + originNativePriceUsd, + destinationNativePriceUsd, + bridgeQuoteInputTokenPriceUsd, + appFeeTokenPriceUsd, + minOutputAmountSansAppFees, + originChainId: CHAIN_IDs.MAINNET, + destinationChainId: CHAIN_IDs.OPTIMISM, + logger, + }); + + // Verify total fee structure + expect(result.total?.amount).toBeDefined(); + expect(result.total?.amountUsd).toBe("5.0"); // 1000 - 995 = 5 USDC + expect(result.total?.pct).toBeDefined(); + expect(result.total?.token).toEqual(inputToken); + + // Verify origin gas fee + // 0.005 ETH * $2000 = $10 + expect(result.originGas?.amount).toEqual(originTxGas.mul(originTxGasPrice)); + expect(result.originGas?.amountUsd).toBe("10.0"); + expect(result.originGas?.token.symbol).toBe("ETH"); + expect(result.originGas?.pct).toBeUndefined(); // No pct for gas fees + + // Verify destination gas fee (2 USDC = $2) + expect(result.destinationGas?.amountUsd).toBe("2.0"); + expect(result.destinationGas?.token.symbol).toBe("ETH"); + + // Verify relayer capital fee (1 USDC = $1) + expect(result.relayerCapital?.amount).toEqual( + bridgeQuote.fees.relayerCapital.total + ); + expect(result.relayerCapital?.amountUsd).toBe("1.0"); + expect(result.relayerCapital?.token).toEqual(inputToken); + + // Verify LP fee (1.5 USDC = $1.5) + expect(result.lpFee?.amount).toEqual(bridgeQuote.fees.lp.total); + expect(result.lpFee?.amountUsd).toBe("1.5"); + expect(result.lpFee?.token).toEqual(inputToken); + + // Verify relayer total fee (4.5 USDC = $4.5) + expect(result.relayerTotal?.amount).toEqual( + bridgeQuote.fees.totalRelay.total + ); + expect(result.relayerTotal?.amountUsd).toBe("4.5"); + expect(result.relayerTotal?.token).toEqual(inputToken); + + // Verify bridge fee (0.5 ETH = $1000) + expect(result.bridgeFee?.amount).toEqual(bridgeQuote.fees.bridgeFee.total); + expect(result.bridgeFee?.amountUsd).toBe("1000.0"); + expect(result.bridgeFee?.token).toEqual(bridgeFeeToken); + + // Verify app fee (0 in this test) + expect(result.app?.amount).toEqual(BigNumber.from(0)); + expect(result.app?.amountUsd).toBe("0.0"); + expect(result.app?.token).toEqual(outputToken); + }); +}); diff --git a/yarn.lock b/yarn.lock index 2b99a434e..f9ea51352 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20,7 +20,7 @@ version "3.1.80" resolved "https://registry.yarnpkg.com/@across-protocol/constants/-/constants-3.1.80.tgz#a1515f9c8ca19a5a7c2e709da08c1d1f3ac01b28" integrity sha512-/MtvKygLNoxTFAIOU6FmR4TeEeueL1hyoWit9BtL0RVyXZ1h3zvNr58TYxcoXIFF8Vb+yizyACX32b+bdk7fGg== - + "@across-protocol/contracts-v4.1.1@npm:@across-protocol/contracts@4.1.1": version "4.1.1" resolved "https://registry.yarnpkg.com/@across-protocol/contracts/-/contracts-4.1.1.tgz#91c8e0fc867911a17f21b2d79d586f95417cb912" @@ -61,6 +61,39 @@ "@openzeppelin/contracts" "4.1.0" "@uma/core" "^2.18.0" +"@across-protocol/contracts@^4.1.11": + version "4.1.11" + resolved "https://registry.yarnpkg.com/@across-protocol/contracts/-/contracts-4.1.11.tgz#6194ca1d3a78edbbbcd6a047f2ade9bbc911e536" + integrity sha512-ErLVNzMgF9H7XkMRtI4fQQ4/IVaO4GAj/FShUxqSHLspcEGreeeyxRj5hfPsALOPQdGvMiT1iJmx7I0oVi1P4w== + dependencies: + "@across-protocol/constants" "^3.1.77" + "@coral-xyz/anchor" "^0.31.1" + "@defi-wonderland/smock" "^2.3.4" + "@eth-optimism/contracts" "^0.5.40" + "@ethersproject/abstract-provider" "5.7.0" + "@ethersproject/abstract-signer" "5.7.0" + "@ethersproject/bignumber" "5.7.0" + "@openzeppelin/contracts" "4.9.6" + "@openzeppelin/contracts-upgradeable" "4.9.6" + "@openzeppelin/foundry-upgrades" "^0.4.0" + "@safe-global/protocol-kit" "^6.1.1" + "@scroll-tech/contracts" "^0.1.0" + "@solana-developers/helpers" "^2.4.0" + "@solana-program/address-lookup-table" "^0.7.0" + "@solana-program/token" "^0.5.1" + "@solana/kit" "^2.1.0" + "@solana/spl-token" "^0.4.6" + "@solana/web3.js" "1.98.2" + "@types/yargs" "^17.0.33" + "@uma/common" "^2.37.3" + "@uma/contracts-node" "^0.4.17" + "@uma/core" "^2.61.0" + axios "^1.7.4" + bs58 "^6.0.0" + prettier-plugin-rust "^0.1.9" + yargs "^17.7.2" + zksync-web3 "^0.14.3" + "@across-protocol/contracts@^4.1.9": version "4.1.9" resolved "https://registry.yarnpkg.com/@across-protocol/contracts/-/contracts-4.1.9.tgz#2f8a6680e899d1b26b96dda599463ba3292977a5" @@ -5188,6 +5221,15 @@ resolved "https://registry.yarnpkg.com/@paulmillr/qr/-/qr-0.2.1.tgz#76ade7080be4ac4824f638146fd8b6db1805eeca" integrity sha512-IHnV6A+zxU7XwmKFinmYjUcwlyK9+xkG3/s9KcQhI9BjQKycrJ1JRO+FbNYPwZiPKW3je/DR0k7w8/gLa5eaxQ== +"@peculiar/asn1-schema@^2.3.13": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.5.0.tgz#4e58d7c3087c4259cebf5363e092f85b9cbf0ca1" + integrity sha512-YM/nFfskFJSlHqv59ed6dZlLZqtZQwjRVJ4bBAiWV08Oc+1rSd5lDZcBEx0lGDHfSoH3UziI2pXt2UM33KerPQ== + dependencies: + asn1js "^3.0.6" + pvtsutils "^1.3.6" + tslib "^2.8.1" + "@phenomnomnominal/tsquery@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@phenomnomnominal/tsquery/-/tsquery-3.0.0.tgz#6f2f4dbf6304ff52b12cc7a5b979f20c3794a22a" @@ -6012,6 +6054,21 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.2.tgz#40ecd1357526fe328c7af704a283ee8533ca7ad6" integrity sha512-APwKy6YUhvZaEoHyM+9xqmTpviEI+9eL7LoCH+aLcvWYHJ663qG5zx7WzWZY+a9qkg5JtzcMyJ9z0WtQBMDmgA== +"@safe-global/protocol-kit@^6.1.1": + version "6.1.1" + resolved "https://registry.yarnpkg.com/@safe-global/protocol-kit/-/protocol-kit-6.1.1.tgz#f9c262f7b9fbafa0f58e2e9185b3e4cb28d43e26" + integrity sha512-SlRosKB52h1CV2gMlKG4UOvh2j4tXuzz1GZ/yQ1HD0Zvm5azUlaytFwKzHun9xNVvfe+vvSNHUEGX2Umy+rQ9A== + dependencies: + "@safe-global/safe-deployments" "^1.37.42" + "@safe-global/safe-modules-deployments" "^2.2.14" + "@safe-global/types-kit" "^3.0.0" + abitype "^1.0.2" + semver "^7.7.2" + viem "^2.21.8" + optionalDependencies: + "@noble/curves" "^1.6.0" + "@peculiar/asn1-schema" "^2.3.13" + "@safe-global/safe-apps-provider@0.18.5", "@safe-global/safe-apps-provider@^0.18.0": version "0.18.5" resolved "https://registry.yarnpkg.com/@safe-global/safe-apps-provider/-/safe-apps-provider-0.18.5.tgz#745a932bda3739a8a298ae44ec6c465f6c4773b7" @@ -6036,11 +6093,30 @@ "@safe-global/safe-gateway-typescript-sdk" "^3.5.3" viem "^1.0.0" +"@safe-global/safe-deployments@^1.37.42": + version "1.37.45" + resolved "https://registry.yarnpkg.com/@safe-global/safe-deployments/-/safe-deployments-1.37.45.tgz#1e017a5a6a237772b93255ac8cb6ac9ad2024d23" + integrity sha512-HLH8nJSVbDlx/p3Yzhspyz9q9pITSGvw2UqlmXfAyrz6VSM8zc9xUWlBeqaUEzvmgon9YUgfstUMz2MElRUCfQ== + dependencies: + semver "^7.6.2" + "@safe-global/safe-gateway-typescript-sdk@^3.5.3": version "3.12.0" resolved "https://registry.yarnpkg.com/@safe-global/safe-gateway-typescript-sdk/-/safe-gateway-typescript-sdk-3.12.0.tgz#aa767a32f4d10f4ec9a47ad7e32d547d3b51e94c" integrity sha512-hExCo62lScVC9/ztVqYEYL2pFxcqLTvB8fj0WtdP5FWrvbtEgD0pbVolchzD5bf85pbzvEwdAxSVS7EdCZxTNw== +"@safe-global/safe-modules-deployments@^2.2.14": + version "2.2.17" + resolved "https://registry.yarnpkg.com/@safe-global/safe-modules-deployments/-/safe-modules-deployments-2.2.17.tgz#1d5c8da5037ab849be8f00a3f11e1d4199dad5a4" + integrity sha512-G5VivmG0+UlTnaJgWJvkkFSQlhMzSXT40IoOTv2A134EtHoq9cs8BsCjXUErKb96KVmDguj6ku5oJmiLp6raNQ== + +"@safe-global/types-kit@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@safe-global/types-kit/-/types-kit-3.0.0.tgz#b35826af0e417fa02a540b874c109b5ddb5ed086" + integrity sha512-AZWIlR5MguDPdGiOj7BB4JQPY2afqmWQww1mu8m8Oi16HHBW99G01kFOu4NEHBwEU1cgwWOMY19hsI5KyL4W2w== + dependencies: + abitype "^1.0.2" + "@scroll-tech/contracts@^0.1.0": version "0.1.0" resolved "https://registry.yarnpkg.com/@scroll-tech/contracts/-/contracts-0.1.0.tgz#ccea8db1b3df7d740e4b7843ac01b5bd25b4438b" @@ -11586,6 +11662,11 @@ abitype@1.1.0, abitype@^1.0.8, abitype@^1.0.9: resolved "https://registry.yarnpkg.com/abitype/-/abitype-1.1.0.tgz#510c5b3f92901877977af5e864841f443bf55406" integrity sha512-6Vh4HcRxNMLA0puzPjM5GBgT4aAcFGKZzSgAXvuZ27shJP6NEpielTuqbBmZILR5/xd0PizkBGy5hReKz9jl5A== +abitype@^1.0.2: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abitype/-/abitype-1.1.1.tgz#b50ed400f8bfca5452eb4033445c309d3e1117c8" + integrity sha512-Loe5/6tAgsBukY95eGaPSDmQHIjRZYQq8PB1MpsNccDIK8WiV+Uw6WzaIXipvaxTEL2yEB0OpEaQv3gs8pkS9Q== + abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" @@ -12067,6 +12148,15 @@ asn1@~0.2.3: dependencies: safer-buffer "~2.1.0" +asn1js@^3.0.6: + version "3.0.6" + resolved "https://registry.yarnpkg.com/asn1js/-/asn1js-3.0.6.tgz#53e002ebe00c5f7fd77c1c047c3557d7c04dce25" + integrity sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA== + dependencies: + pvtsutils "^1.3.6" + pvutils "^1.1.3" + tslib "^2.8.1" + assert-plus@1.0.0, assert-plus@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" @@ -21725,6 +21815,20 @@ ox@0.9.3: abitype "^1.0.9" eventemitter3 "5.0.1" +ox@0.9.6: + version "0.9.6" + resolved "https://registry.yarnpkg.com/ox/-/ox-0.9.6.tgz#5cf02523b6db364c10ee7f293ff1e664e0e1eab7" + integrity sha512-8SuCbHPvv2eZLYXrNmC0EC12rdzXQLdhnOMlHDW2wiCPLxBrOOJwX5L5E61by+UjTPOryqQiRSnjIKCI+GykKg== + dependencies: + "@adraffy/ens-normalize" "^1.11.0" + "@noble/ciphers" "^1.3.0" + "@noble/curves" "1.9.1" + "@noble/hashes" "^1.8.0" + "@scure/bip32" "^1.7.0" + "@scure/bip39" "^1.6.0" + abitype "^1.0.9" + eventemitter3 "5.0.1" + p-cancelable@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc" @@ -22811,6 +22915,18 @@ pushdata-bitcoin@^1.0.1: dependencies: bitcoin-ops "^1.3.0" +pvtsutils@^1.3.6: + version "1.3.6" + resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.3.6.tgz#ec46e34db7422b9e4fdc5490578c1883657d6001" + integrity sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg== + dependencies: + tslib "^2.8.1" + +pvutils@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.3.tgz#f35fc1d27e7cd3dfbd39c0826d173e806a03f5a3" + integrity sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ== + qheap@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/qheap/-/qheap-1.4.0.tgz#2d263edb4c1f7244a7a591f9b77645bcbcd9dd2e" @@ -24098,6 +24214,11 @@ semver@^7.1.2, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semve resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13" integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w== +semver@^7.6.2, semver@^7.7.2: + version "7.7.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== + semver@~5.4.1: version "5.4.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.4.1.tgz#e059c09d8571f0540823733433505d3a2f00b18e" @@ -25812,7 +25933,7 @@ tslib@1.14.1, tslib@^1, tslib@^1.13.0, tslib@^1.14.1, tslib@^1.8.1, tslib@^1.9.0 resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.4.1, tslib@^2.6.0, tslib@^2.6.2, tslib@^2.8.0: +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.4.1, tslib@^2.6.0, tslib@^2.6.2, tslib@^2.8.0, tslib@^2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -26629,6 +26750,20 @@ viem@^1.0.0: isomorphic-ws "5.0.0" ws "8.13.0" +viem@^2.21.8: + version "2.38.0" + resolved "https://registry.yarnpkg.com/viem/-/viem-2.38.0.tgz#48f9f91f6d540c83e9eb9f6d62ecb8e648517ca6" + integrity sha512-YU5TG8dgBNeYPrCMww0u9/JVeq2ZCk9fzk6QybrPkBooFysamHXL1zC3ua10aLPt9iWoA/gSVf1D9w7nc5B1aA== + dependencies: + "@noble/curves" "1.9.1" + "@noble/hashes" "1.8.0" + "@scure/bip32" "1.7.0" + "@scure/bip39" "1.6.0" + abitype "1.1.0" + isows "1.0.7" + ox "0.9.6" + ws "8.18.3" + viem@^2.30.1: version "2.37.6" resolved "https://registry.yarnpkg.com/viem/-/viem-2.37.6.tgz#3b05586555bd4b2c1b7351ed148f9fa98df72027" From b93b3bd9f3b97be719b88c897b7a6122022ca3b2 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Tue, 14 Oct 2025 15:44:23 +0200 Subject: [PATCH 05/18] feat: cctp to hypercore account creation fee (#1901) --- api/_bridges/cctp/strategy.ts | 38 ++-- api/_bridges/cctp/utils/hypercore.ts | 65 +++++++ api/_hypercore.ts | 14 +- .../api/_bridges/cctp/utils/hypercore.test.ts | 168 +++++++++++++++++- 4 files changed, 271 insertions(+), 14 deletions(-) diff --git a/api/_bridges/cctp/strategy.ts b/api/_bridges/cctp/strategy.ts index 6d234fff3..dad922dfa 100644 --- a/api/_bridges/cctp/strategy.ts +++ b/api/_bridges/cctp/strategy.ts @@ -42,7 +42,9 @@ import { } from "./utils/constants"; import { buildCctpTxHyperEvmToHyperCore, + getAmountToHyperCore, isHyperEvmToHyperCoreRoute, + isToHyperCore, } from "./utils/hypercore"; const name = "cctp"; @@ -148,15 +150,23 @@ export function getCctpBridgeStrategy(): BridgeStrategy { inputToken, outputToken, exactInputAmount, - recipient: _recipient, + recipient, message: _message, }: GetExactInputBridgeQuoteParams) => { assertSupportedRoute({ inputToken, outputToken }); - const outputAmount = ConvertDecimals( - inputToken.decimals, - outputToken.decimals - )(exactInputAmount); + const outputAmount = isToHyperCore(outputToken.chainId) + ? await getAmountToHyperCore({ + inputToken, + outputToken, + inputOrOutput: "input", + amount: exactInputAmount, + recipient, + }) + : ConvertDecimals( + inputToken.decimals, + outputToken.decimals + )(exactInputAmount); return { bridgeQuote: { @@ -177,15 +187,23 @@ export function getCctpBridgeStrategy(): BridgeStrategy { outputToken, minOutputAmount, forceExactOutput: _forceExactOutput, - recipient: _recipient, + recipient, message: _message, }: GetOutputBridgeQuoteParams) => { assertSupportedRoute({ inputToken, outputToken }); - const inputAmount = ConvertDecimals( - outputToken.decimals, - inputToken.decimals - )(minOutputAmount); + const inputAmount = isToHyperCore(outputToken.chainId) + ? await getAmountToHyperCore({ + inputToken, + outputToken, + inputOrOutput: "output", + amount: minOutputAmount, + recipient, + }) + : ConvertDecimals( + outputToken.decimals, + inputToken.decimals + )(minOutputAmount); return { bridgeQuote: { diff --git a/api/_bridges/cctp/utils/hypercore.ts b/api/_bridges/cctp/utils/hypercore.ts index 898664785..e52266104 100644 --- a/api/_bridges/cctp/utils/hypercore.ts +++ b/api/_bridges/cctp/utils/hypercore.ts @@ -4,6 +4,10 @@ import { tagIntegratorId, tagSwapApiMarker } from "../../../_integrator-id"; import { InvalidParamError } from "../../../_errors"; import { CHAIN_IDs } from "../../../_constants"; import { Token } from "../../../_dexes/types"; +import { ConvertDecimals } from "../../../_utils"; +import { accountExistsOnHyperCore } from "../../../_hypercore"; + +const HYPERCORE_ACCOUNT_CREATION_FEE_USDC = 1; const CORE_WALLET_ADDRESSES = { // Currently deployed only on HyperEVM Testnet @@ -17,6 +21,67 @@ const CORE_DEPOSIT_WALLET_ABI = [ "function deposit(uint256 amount)", ]; +export function isToHyperCore(destinationChainId: number) { + return [CHAIN_IDs.HYPERCORE, CHAIN_IDs.HYPERCORE_TESTNET].includes( + destinationChainId + ); +} + +export async function getAmountToHyperCore(params: { + inputToken: Token; + outputToken: Token; + inputOrOutput: "input" | "output"; + amount: BigNumber; + recipient?: string; +}) { + const { inputToken, outputToken, inputOrOutput, amount, recipient } = params; + + if (!recipient) { + throw new InvalidParamError({ + message: "CCTP: Recipient is not provided", + param: "recipient", + }); + } + + const recipientExists = await accountExistsOnHyperCore({ + account: recipient, + chainId: inputToken.chainId, + }); + + if (recipientExists) { + return inputOrOutput === "input" + ? ConvertDecimals(inputToken.decimals, outputToken.decimals)(amount) // return output amount + : ConvertDecimals(outputToken.decimals, inputToken.decimals)(amount); // return input amount + } + + // If recipient does not exist on HyperCore, consider 1 USDC account creation fee + const accountCreationFee = ethers.utils.parseUnits( + HYPERCORE_ACCOUNT_CREATION_FEE_USDC.toString(), + inputOrOutput === "input" ? inputToken.decimals : outputToken.decimals + ); + + // If provided amount is `inputAmount`, subtract account creation fee and return required output amount + if (inputOrOutput === "input") { + const outputAmount = amount.sub(accountCreationFee); + if (outputAmount.lte(0)) { + throw new InvalidParamError({ + message: "CCTP: Amount must exceed account creation fee", + param: "amount", + }); + } + return ConvertDecimals( + inputToken.decimals, + outputToken.decimals + )(outputAmount); + } + + // If provided amount is `outputAmount`, add account creation fee and return required input amount + return ConvertDecimals( + outputToken.decimals, + inputToken.decimals + )(amount.add(accountCreationFee)); +} + export function isHyperEvmToHyperCoreRoute(params: { inputToken: Token; outputToken: Token; diff --git a/api/_hypercore.ts b/api/_hypercore.ts index 1b6acae36..c68b40156 100644 --- a/api/_hypercore.ts +++ b/api/_hypercore.ts @@ -91,8 +91,18 @@ export async function getBalanceOnHyperCore(params: { return BigNumber.from(decodedQueryResult[0].toString()); } -export async function accountExistsOnHyperCore(params: { account: string }) { - const provider = getProvider(CHAIN_IDs.HYPEREVM); +export async function accountExistsOnHyperCore(params: { + account: string; + chainId?: number; +}) { + const chainId = params.chainId ?? CHAIN_IDs.HYPEREVM; + + if (![CHAIN_IDs.HYPEREVM, CHAIN_IDs.HYPEREVM_TESTNET].includes(chainId)) { + throw new Error("Can't check account existence on non-HyperCore chain"); + } + + const provider = getProvider(chainId); + const balanceCoreCalldata = ethers.utils.defaultAbiCoder.encode( ["address"], [params.account] diff --git a/test/api/_bridges/cctp/utils/hypercore.test.ts b/test/api/_bridges/cctp/utils/hypercore.test.ts index af5585eec..38ba3f739 100644 --- a/test/api/_bridges/cctp/utils/hypercore.test.ts +++ b/test/api/_bridges/cctp/utils/hypercore.test.ts @@ -1,8 +1,172 @@ -import { isHyperEvmToHyperCoreRoute } from "../../../../../api/_bridges/cctp/utils/hypercore"; +import { BigNumber } from "ethers"; + +import { + isHyperEvmToHyperCoreRoute, + getAmountToHyperCore, +} from "../../../../../api/_bridges/cctp/utils/hypercore"; import { CHAIN_IDs } from "../../../../../api/_constants"; import { TOKEN_SYMBOLS_MAP } from "../../../../../api/_constants"; +import * as hypercoreModule from "../../../../../api/_hypercore"; + +jest.mock("../../../../../api/_hypercore"); + +describe("bridges/cctp/utils/hypercore", () => { + const mockAccountExistsOnHyperCore = + hypercoreModule.accountExistsOnHyperCore as jest.MockedFunction< + typeof hypercoreModule.accountExistsOnHyperCore + >; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("#getAmountToHyperCore()", () => { + const inputToken = { + ...TOKEN_SYMBOLS_MAP.USDC, + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.HYPEREVM], + chainId: CHAIN_IDs.HYPEREVM, + }; + + const outputToken = { + ...TOKEN_SYMBOLS_MAP.USDC, + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.HYPERCORE], + chainId: CHAIN_IDs.HYPERCORE, + }; + + test("should throw error if recipient is not provided", async () => { + const params = { + inputToken, + outputToken, + inputOrOutput: "input" as const, + amount: BigNumber.from(1_000_000), + }; + + await expect(getAmountToHyperCore(params)).rejects.toThrow( + "CCTP: Recipient is not provided" + ); + }); + + test("should return correct output amount when recipient exists (input mode)", async () => { + mockAccountExistsOnHyperCore.mockResolvedValue(true); + + const params = { + inputToken, + outputToken, + inputOrOutput: "input" as const, + amount: BigNumber.from(1_000_000), + recipient: "0x1234567890123456789012345678901234567890", + }; + + const result = await getAmountToHyperCore(params); + expect(result).toEqual(params.amount); // Same amount, same decimals + }); + + test("should return correct input amount when recipient exists (output mode)", async () => { + mockAccountExistsOnHyperCore.mockResolvedValue(true); + + const params = { + inputToken, + outputToken, + inputOrOutput: "output" as const, + amount: BigNumber.from(2_000_000), + recipient: "0x1234567890123456789012345678901234567890", + }; + + const result = await getAmountToHyperCore(params); + expect(result).toEqual(params.amount); // Same amount, same decimals + }); + + test("should subtract account creation fee when recipient doesn't exist (input mode)", async () => { + mockAccountExistsOnHyperCore.mockResolvedValue(false); + + const params = { + inputToken, + outputToken, + inputOrOutput: "input" as const, + amount: BigNumber.from(10_000_000), + recipient: "0x1234567890123456789012345678901234567890", + }; + + const outputAmount = await getAmountToHyperCore(params); + // Should be 10 - 1 = 9 USDC (9000000) + expect(outputAmount.toNumber()).toEqual(9_000_000); + }); + + test("should add account creation fee when recipient doesn't exist (output mode)", async () => { + mockAccountExistsOnHyperCore.mockResolvedValue(false); + + const params = { + inputToken, + outputToken, + inputOrOutput: "output" as const, + amount: BigNumber.from(5_000_000), + recipient: "0x1234567890123456789012345678901234567890", + }; + + const inputAmount = await getAmountToHyperCore(params); + // Should be 5 + 1 = 6 USDC (6000000) + expect(inputAmount.toNumber()).toEqual(6_000_000); + }); + + test("should throw error if account creation fee is greater than input amount", async () => { + mockAccountExistsOnHyperCore.mockResolvedValue(false); + + const params = { + inputToken, + outputToken, + inputOrOutput: "input" as const, + amount: BigNumber.from(500_000), // 0.5 USDC (less than 1 USDC fee) + recipient: "0x1234567890123456789012345678901234567890", + }; + + await expect(getAmountToHyperCore(params)).rejects.toThrow( + "CCTP: Amount must exceed account creation fee" + ); + }); + + test("should handle different token decimals when recipient exists", async () => { + mockAccountExistsOnHyperCore.mockResolvedValue(true); + + // This case might happen when CCTP supports USDC-SPOT + const outputTokenWith8Decimals = { + ...outputToken, + decimals: 8, + }; + + const params = { + inputToken, + outputToken: outputTokenWith8Decimals, + inputOrOutput: "input" as const, + amount: BigNumber.from(1_000_000), + recipient: "0x1234567890123456789012345678901234567890", + }; + + const outputAmount = await getAmountToHyperCore(params); + expect(outputAmount.toNumber()).toEqual(100_000_000); + }); + + test("should handle different token decimals when recipient doesn't exist (input mode)", async () => { + mockAccountExistsOnHyperCore.mockResolvedValue(false); + + // This case might happen when CCTP supports USDC-SPOT + const outputTokenWith8Decimals = { + ...outputToken, + decimals: 8, + }; + + const params = { + inputToken, + outputToken: outputTokenWith8Decimals, + inputOrOutput: "input" as const, + amount: BigNumber.from(10_000_000), + recipient: "0x1234567890123456789012345678901234567890", + }; + + const outputAmount = await getAmountToHyperCore(params); + expect(outputAmount.toNumber()).toEqual(900_000_000); + }); + }); -describe("bridges -> cctp -> hypercore utils", () => { describe("#isHyperEvmToHyperCoreRoute()", () => { test("should return true for HyperEVM -> HyperCore", () => { const params = { From 8daab7579ce51e6228f090312ef2b6cb878e5374 Mon Sep 17 00:00:00 2001 From: Ashwin <213675439+ashwinrava@users.noreply.github.com> Date: Tue, 14 Oct 2025 10:27:25 -0400 Subject: [PATCH 06/18] chore: enable CCTP routing logic (#1900) Co-authored-by: Dong-Ha Kim --- api/_bridges/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/_bridges/index.ts b/api/_bridges/index.ts index 6e94b3aff..57d864259 100644 --- a/api/_bridges/index.ts +++ b/api/_bridges/index.ts @@ -34,7 +34,7 @@ export const bridgeStrategies: BridgeStrategiesConfig = { export const routableBridgeStrategies = [ getAcrossBridgeStrategy(), - // TODO: Add CCTP bridge strategy when ready + getCctpBridgeStrategy(), ]; export async function getBridgeStrategy({ From 5a218ecc3534a933c3cdd8403c432b3f1e55e4dc Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Thu, 16 Oct 2025 12:54:52 +0200 Subject: [PATCH 07/18] feat: add routingPreference query param (#1903) --- api/_bridges/index.ts | 42 ++++++++++++++++++++++-- api/_bridges/types.ts | 1 + api/swap/_utils.ts | 3 ++ api/swap/approval/_service.ts | 2 ++ scripts/tests/_swap-utils.ts | 9 ++++++ test/api/_bridges/bridges.test.ts | 54 +++++++++++++++++++++++++++++++ 6 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 test/api/_bridges/bridges.test.ts diff --git a/api/_bridges/index.ts b/api/_bridges/index.ts index 57d864259..b656e3cf0 100644 --- a/api/_bridges/index.ts +++ b/api/_bridges/index.ts @@ -46,6 +46,7 @@ export async function getBridgeStrategy({ amountType, recipient, depositor, + routingPreference = "default", }: GetBridgeStrategyParams): Promise { const inputTokenOverride = bridgeStrategies.inputTokens?.[inputToken.symbol]?.[originChainId]?.[ @@ -60,9 +61,13 @@ export async function getBridgeStrategy({ if (fromToChainOverride) { return fromToChainOverride; } - const supportedBridgeStrategies = routableBridgeStrategies.filter( - (strategy) => strategy.isRouteSupported({ inputToken, outputToken }) - ); + + const supportedBridgeStrategies = getSupportedBridgeStrategies({ + inputToken, + outputToken, + routingPreference, + }); + if (supportedBridgeStrategies.length === 1) { return supportedBridgeStrategies[0]; } @@ -83,6 +88,37 @@ export async function getBridgeStrategy({ return getAcrossBridgeStrategy(); } +export function getSupportedBridgeStrategies({ + inputToken, + outputToken, + routingPreference, +}: { + inputToken: GetBridgeStrategyParams["inputToken"]; + outputToken: GetBridgeStrategyParams["outputToken"]; + routingPreference: string; +}) { + const routingPreferenceFilter = (strategyName: string) => { + // If default routing preference, don't filter based on name + if (routingPreference === "default") { + return true; + } + + // If native routing preference, filter out 'across' bridge strategy + if (routingPreference === "native") { + return strategyName !== "across"; + } + + // Else use across bridge strategy + return strategyName === "across"; + }; + const supportedBridgeStrategies = routableBridgeStrategies.filter( + (strategy) => + strategy.isRouteSupported({ inputToken, outputToken }) && + routingPreferenceFilter(strategy.name) + ); + return supportedBridgeStrategies; +} + async function routeStrategyForCctp({ inputToken, outputToken, diff --git a/api/_bridges/types.ts b/api/_bridges/types.ts index 5a68bb1ad..f7ae6ba31 100644 --- a/api/_bridges/types.ts +++ b/api/_bridges/types.ts @@ -132,4 +132,5 @@ export type BridgeStrategyDataParams = { export type GetBridgeStrategyParams = { originChainId: number; destinationChainId: number; + routingPreference?: string; } & BridgeStrategyDataParams; diff --git a/api/swap/_utils.ts b/api/swap/_utils.ts index da4ec74a4..8269b4fa4 100644 --- a/api/swap/_utils.ts +++ b/api/swap/_utils.ts @@ -81,6 +81,7 @@ export const BaseSwapQueryParamsSchema = type({ appFeeRecipient: optional(validAddress()), strictTradeType: optional(boolStr()), skipChecks: optional(boolStr()), + routingPreference: optional(enums(["default", "across", "native"])), }); export type BaseSwapQueryParams = Infer; @@ -111,6 +112,7 @@ export async function handleBaseSwapQueryParams( appFeeRecipient, strictTradeType: _strictTradeType = "true", skipChecks: _skipChecks = "false", + routingPreference = "default", } = query; const originChainId = Number(_originChainId); @@ -251,6 +253,7 @@ export async function handleBaseSwapQueryParams( skipChecks, isDestinationSvm, isOriginSvm, + routingPreference, }; } diff --git a/api/swap/approval/_service.ts b/api/swap/approval/_service.ts index 4abd54223..892a0de36 100644 --- a/api/swap/approval/_service.ts +++ b/api/swap/approval/_service.ts @@ -72,6 +72,7 @@ export async function handleApprovalSwap( skipChecks, isDestinationSvm, isOriginSvm, + routingPreference, } = await handleBaseSwapQueryParams(request.query); const { actions } = @@ -94,6 +95,7 @@ export async function handleApprovalSwap( amountType, recipient, depositor, + routingPreference, }); const crossSwapQuotes = await getCrossSwapQuotes( diff --git a/scripts/tests/_swap-utils.ts b/scripts/tests/_swap-utils.ts index c532e17d5..2296d50e6 100644 --- a/scripts/tests/_swap-utils.ts +++ b/scripts/tests/_swap-utils.ts @@ -144,6 +144,13 @@ export const argsFromCli = yargs(hideBin(process.argv)) description: "Strict trade type.", type: "boolean", default: true, + }) + .option("routingPreference", { + alias: "rp", + description: "Routing preference.", + type: "string", + default: "default", + choices: ["default", "across", "native"], }); }) .option("host", { @@ -221,6 +228,7 @@ export async function fetchSwapQuotes() { appFeeRecipient, strictTradeType, integratorId, + routingPreference, } = argsFromCli; const params = { originChainId, @@ -246,6 +254,7 @@ export async function fetchSwapQuotes() { appFeeRecipient, strictTradeType, integratorId, + routingPreference, }; console.log("Params:", params); diff --git a/test/api/_bridges/bridges.test.ts b/test/api/_bridges/bridges.test.ts new file mode 100644 index 000000000..6a0e9fff1 --- /dev/null +++ b/test/api/_bridges/bridges.test.ts @@ -0,0 +1,54 @@ +import { CHAIN_IDs, TOKEN_SYMBOLS_MAP } from "../../../api/_constants"; +import { getSupportedBridgeStrategies } from "../../../api/_bridges/index"; + +describe("api/_bridges/index", () => { + describe("#getSupportedBridgeStrategies()", () => { + const usdcOptimism = { + ...TOKEN_SYMBOLS_MAP.USDC, + chainId: CHAIN_IDs.OPTIMISM, + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.OPTIMISM], + }; + const usdcArbitrum = { + ...TOKEN_SYMBOLS_MAP.USDC, + chainId: CHAIN_IDs.ARBITRUM, + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.ARBITRUM], + }; + + describe("basic routing preference tests", () => { + test("should return both across and cctp strategies for USDC with 'default' routing preference", () => { + const result = getSupportedBridgeStrategies({ + inputToken: usdcOptimism, + outputToken: usdcArbitrum, + routingPreference: "default", + }); + + expect(result.length).toBe(2); + expect(result.map((s) => s.name)).toEqual( + expect.arrayContaining(["across", "cctp"]) + ); + }); + + test("should return only across strategy for USDC with 'across' routing preference", () => { + const result = getSupportedBridgeStrategies({ + inputToken: usdcOptimism, + outputToken: usdcArbitrum, + routingPreference: "across", + }); + + expect(result.length).toBe(1); + expect(result[0].name).toBe("across"); + }); + + test("should return only cctp strategy for USDC with 'native' routing preference", () => { + const result = getSupportedBridgeStrategies({ + inputToken: usdcOptimism, + outputToken: usdcArbitrum, + routingPreference: "native", + }); + + expect(result.length).toBe(1); + expect(result[0].name).toBe("cctp"); + }); + }); + }); +}); From 1f4dba01f47c7e5588a4b1124400443eed8387d3 Mon Sep 17 00:00:00 2001 From: Melisa Guevara Date: Fri, 17 Oct 2025 07:04:37 -0300 Subject: [PATCH 08/18] feat: unsponsored usdc svm to hypercore via cctp (#1905) --- api/_bridges/cctp/strategy.ts | 228 +++++++++++++---- api/_bridges/cctp/utils/constants.ts | 20 ++ api/_bridges/cctp/utils/hypercore.ts | 119 ++++++++- api/_bridges/index.ts | 6 + api/swap/_utils.ts | 10 +- test/api/_bridges/cctp/strategy.test.ts | 230 ++++++++++++++++++ .../api/_bridges/cctp/utils/hypercore.test.ts | 157 ++++++++++++ 7 files changed, 715 insertions(+), 55 deletions(-) create mode 100644 test/api/_bridges/cctp/strategy.test.ts diff --git a/api/_bridges/cctp/strategy.ts b/api/_bridges/cctp/strategy.ts index dad922dfa..abffd9bf6 100644 --- a/api/_bridges/cctp/strategy.ts +++ b/api/_bridges/cctp/strategy.ts @@ -33,18 +33,22 @@ import { getSVMRpc } from "../../_providers"; import { CCTP_SUPPORTED_CHAINS, CCTP_SUPPORTED_TOKENS, - CCTP_FINALITY_THRESHOLDS, CCTP_FILL_TIME_ESTIMATES, + CCTP_FINALITY_THRESHOLDS, getCctpTokenMessengerAddress, getCctpMessageTransmitterAddress, getCctpDomainId, + getCctpForwarderAddress, encodeDepositForBurn, } from "./utils/constants"; +import { CHAIN_IDs } from "../../_constants"; import { buildCctpTxHyperEvmToHyperCore, getAmountToHyperCore, isHyperEvmToHyperCoreRoute, isToHyperCore, + encodeForwardHookData, + getCctpFees, } from "./utils/hypercore"; const name = "cctp"; @@ -115,6 +119,16 @@ export function getCctpBridgeStrategy(): BridgeStrategy { } }; + /** + * Determines the appropriate CCTP finality threshold for a route. + * When going to HyperCore we use fast finality, all others use standard. + */ + const getFinalityThreshold = (destinationChainId: number): number => { + return isToHyperCore(destinationChainId) + ? CCTP_FINALITY_THRESHOLDS.fast + : CCTP_FINALITY_THRESHOLDS.standard; + }; + return { name, capabilities, @@ -155,18 +169,42 @@ export function getCctpBridgeStrategy(): BridgeStrategy { }: GetExactInputBridgeQuoteParams) => { assertSupportedRoute({ inputToken, outputToken }); - const outputAmount = isToHyperCore(outputToken.chainId) - ? await getAmountToHyperCore({ - inputToken, - outputToken, - inputOrOutput: "input", - amount: exactInputAmount, - recipient, - }) - : ConvertDecimals( - inputToken.decimals, - outputToken.decimals - )(exactInputAmount); + let maxFee = BigNumber.from(0); + let outputAmount: BigNumber; + if (isToHyperCore(outputToken.chainId)) { + // Query CCTP fee configuration for HyperCore destinations + const minFinalityThreshold = getFinalityThreshold(outputToken.chainId); + const { transferFeeBps, forwardFee } = await getCctpFees({ + inputToken, + outputToken, + minFinalityThreshold, + }); + + // Calculate actual fee: + // transferFee = input * (bps / 10000) + // maxFee = transferFee + forwardFee + const transferFee = exactInputAmount.mul(transferFeeBps).div(10000); + maxFee = transferFee.add(forwardFee); + + // First subtract the CCTP fee from input + const remainingInputAmount = exactInputAmount.sub(maxFee); + + // Then calculate HyperCore output (accounting for account creation fee if needed) + outputAmount = await getAmountToHyperCore({ + inputToken, + outputToken, + inputOrOutput: "input", + amount: remainingInputAmount, + recipient, + }); + } else { + // Standard conversion after fees + const inputAfterFee = exactInputAmount.sub(maxFee); + outputAmount = ConvertDecimals( + inputToken.decimals, + outputToken.decimals + )(inputAfterFee); + } return { bridgeQuote: { @@ -177,7 +215,7 @@ export function getCctpBridgeStrategy(): BridgeStrategy { minOutputAmount: outputAmount, estimatedFillTimeSec: getEstimatedFillTime(inputToken.chainId), provider: name, - fees: getCctpBridgeFees(inputToken), + fees: getCctpBridgeFees(inputToken, maxFee), }, }; }, @@ -192,7 +230,12 @@ export function getCctpBridgeStrategy(): BridgeStrategy { }: GetOutputBridgeQuoteParams) => { assertSupportedRoute({ inputToken, outputToken }); - const inputAmount = isToHyperCore(outputToken.chainId) + const destinationIsHyperCore = isToHyperCore(outputToken.chainId); + + // Calculate how much needs to arrive on destination after HyperCore account creation fee (if applicable) + // For HyperCore: minOutputAmount + accountCreationFee (if needed) + // For other chains: just minOutputAmount + const amountToArriveOnDestination = destinationIsHyperCore ? await getAmountToHyperCore({ inputToken, outputToken, @@ -205,6 +248,37 @@ export function getCctpBridgeStrategy(): BridgeStrategy { inputToken.decimals )(minOutputAmount); + // Calculate how much to send from origin to cover CCTP fees + let inputAmount: BigNumber; + let maxFee = BigNumber.from(0); + + if (destinationIsHyperCore) { + const minFinalityThreshold = getFinalityThreshold(outputToken.chainId); + const { transferFeeBps, forwardFee } = await getCctpFees({ + inputToken, + outputToken, + minFinalityThreshold, + }); + + // Solve for required input based on the following equation: + // inputAmount - (inputAmount * bps / 10000) - forwardFee = amountToArriveOnDestination + // Rearranging: inputAmount * (1 - bps/10000) = amountToArriveOnDestination + forwardFee + // Therefore: inputAmount = (amountToArriveOnDestination + forwardFee) * 10000 / (10000 - bps) + // Note: 10000 converts basis points to the same scale as amounts (1 bps = 1/10000 of the total) + const bpsFactor = BigNumber.from(10000).sub(transferFeeBps); + inputAmount = amountToArriveOnDestination + .add(forwardFee) + .mul(10000) + .div(bpsFactor); + + // Calculate total CCTP fee (transfer fee + forward fee) + const transferFee = inputAmount.mul(transferFeeBps).div(10000); + maxFee = transferFee.add(forwardFee); + } else { + // Standard non-HyperCore route (no CCTP fees for now) + inputAmount = amountToArriveOnDestination; + } + return { bridgeQuote: { inputToken, @@ -214,7 +288,7 @@ export function getCctpBridgeStrategy(): BridgeStrategy { minOutputAmount, estimatedFillTimeSec: getEstimatedFillTime(inputToken.chainId), provider: name, - fees: getCctpBridgeFees(inputToken), + fees: getCctpBridgeFees(inputToken, maxFee), }, }; }, @@ -257,18 +331,29 @@ export function getCctpBridgeStrategy(): BridgeStrategy { return buildCctpTxHyperEvmToHyperCore(params); } - // Get CCTP domain IDs - const destinationDomain = getCctpDomainId(destinationChainId); + // When going to HyperCore, we need to route through HyperEVM's CCTP domain + const isDestinationHyperCore = isToHyperCore(destinationChainId); + const destinationChainIdForCctp = isDestinationHyperCore + ? CHAIN_IDs.HYPEREVM + : destinationChainId; + + // Get CCTP domain IDs and addresses + const destinationDomain = getCctpDomainId(destinationChainIdForCctp); const tokenMessenger = getCctpTokenMessengerAddress(originChainId); + // Read CCTP fees from the bridge quote (pre-calculated during quote generation) + const maxFee = bridgeQuote.fees.bridgeFee.total; + // Get the appropriate finality threshold for the destination + const minFinalityThreshold = getFinalityThreshold(destinationChainId); + // depositForBurn input parameters const depositForBurnParams = { amount: bridgeQuote.inputAmount, destinationDomain, mintRecipient: crossSwap.recipient, destinationCaller: ethers.constants.AddressZero, // Anyone can finalize the message on domain when this is set to bytes32(0) - maxFee: BigNumber.from(0), // maxFee set to 0 so this will be a "standard" speed transfer - minFinalityThreshold: CCTP_FINALITY_THRESHOLDS.standard, // Hardcoded minFinalityThreshold value for standard transfer + maxFee, + minFinalityThreshold, }; if (crossSwap.isOriginSvm) { @@ -276,7 +361,8 @@ export function getCctpBridgeStrategy(): BridgeStrategy { crossSwapQuotes: params.quotes, integratorId: params.integratorId, originChainId, - destinationChainId, + destinationChainId, // Actual destination + intermediaryChainId: destinationChainIdForCctp, // Intermediary chain for routes that use a forwarder tokenMessenger, depositForBurnParams, }); @@ -296,7 +382,10 @@ export function getCctpBridgeStrategy(): BridgeStrategy { }; } -function getCctpBridgeFees(inputToken: Token) { +function getCctpBridgeFees( + inputToken: Token, + maxFee: BigNumber = BigNumber.from(0) +) { const zeroBN = BigNumber.from(0); return { totalRelay: { @@ -321,7 +410,7 @@ function getCctpBridgeFees(inputToken: Token) { }, bridgeFee: { pct: zeroBN, - total: zeroBN, + total: maxFee, token: inputToken, }, }; @@ -385,6 +474,7 @@ async function _buildCctpTxForAllowanceHolderSvm(params: { integratorId?: string; originChainId: number; destinationChainId: number; + intermediaryChainId: number; tokenMessenger: string; depositForBurnParams: { amount: BigNumber; @@ -404,29 +494,50 @@ async function _buildCctpTxForAllowanceHolderSvm(params: { integratorId, originChainId, destinationChainId, + intermediaryChainId, tokenMessenger, depositForBurnParams, } = params; const { crossSwap } = crossSwapQuotes; + const destinationIsHyperCore = isToHyperCore(destinationChainId); + // Get message transmitter address const messageTransmitter = getCctpMessageTransmitterAddress(originChainId); // Convert addresses to Solana Kit address format for instruction parameters. const depositor = sdk.utils.toAddressType(crossSwap.depositor, originChainId); - const mintRecipient = sdk.utils.toAddressType( - depositForBurnParams.mintRecipient, - destinationChainId - ); - const destinationCaller = sdk.utils.toAddressType( - depositForBurnParams.destinationCaller, - destinationChainId - ); const tokenMint = sdk.utils.toAddressType( crossSwap.inputToken.address, originChainId ); + // Determine mint recipient and destination caller + // When going to HyperCore, route through the CCTP Forwarder contract on the intermediary chain (HyperEVM) + let mintRecipient: sdk.utils.Address; + let destinationCaller: sdk.utils.Address; + + if (destinationIsHyperCore) { + const forwarderAddress = getCctpForwarderAddress(intermediaryChainId); + mintRecipient = sdk.utils.toAddressType( + forwarderAddress, + intermediaryChainId + ); + destinationCaller = sdk.utils.toAddressType( + forwarderAddress, + intermediaryChainId + ); + } else { + mintRecipient = sdk.utils.toAddressType( + depositForBurnParams.mintRecipient, + destinationChainId + ); + destinationCaller = sdk.utils.toAddressType( + depositForBurnParams.destinationCaller, + destinationChainId + ); + } + // Address class handles intermediate conversions internally (e.g., EVM address -> bytes32 -> base58). const mintRecipientAddress = address(mintRecipient.toBase58()); const destinationCallerAddress = address(destinationCaller.toBase58()); @@ -459,29 +570,40 @@ async function _buildCctpTxForAllowanceHolderSvm(params: { // Create signers const depositorSigner = createNoopSigner(depositorAddress); + // Common parameters for both depositForBurn and depositForBurnWithHook + const depositInstructionParams = { + owner: depositorSigner, + eventRentPayer: depositorSigner, + senderAuthorityPda: cctpAccounts.tokenMessengerMinterSenderAuthority, + burnTokenAccount: depositorTokenAccount, + messageTransmitter: cctpAccounts.messageTransmitter, + tokenMessenger: cctpAccounts.tokenMessenger, + remoteTokenMessenger: cctpAccounts.remoteTokenMessenger, + tokenMinter: cctpAccounts.tokenMinter, + localToken: cctpAccounts.localToken, + burnTokenMint: tokenMintAddress, + messageSentEventData: eventDataKeypair, + eventAuthority: cctpAccounts.cctpEventAuthority, + program: tokenMessengerAddress, + amount: BigInt(depositForBurnParams.amount.toString()), + destinationDomain: depositForBurnParams.destinationDomain, + mintRecipient: mintRecipientAddress, + destinationCaller: destinationCallerAddress, + maxFee: BigInt(depositForBurnParams.maxFee.toString()), + minFinalityThreshold: depositForBurnParams.minFinalityThreshold, + }; + // Use the TokenMessenger client to build the instruction - const depositInstruction = - await TokenMessengerMinterV2Client.getDepositForBurnInstructionAsync({ - owner: depositorSigner, - eventRentPayer: depositorSigner, - senderAuthorityPda: cctpAccounts.tokenMessengerMinterSenderAuthority, - burnTokenAccount: depositorTokenAccount, - messageTransmitter: cctpAccounts.messageTransmitter, - tokenMessenger: cctpAccounts.tokenMessenger, - remoteTokenMessenger: cctpAccounts.remoteTokenMessenger, - tokenMinter: cctpAccounts.tokenMinter, - localToken: cctpAccounts.localToken, - burnTokenMint: tokenMintAddress, - messageSentEventData: eventDataKeypair, - eventAuthority: cctpAccounts.cctpEventAuthority, - program: tokenMessengerAddress, - amount: BigInt(depositForBurnParams.amount.toString()), - destinationDomain: depositForBurnParams.destinationDomain, - mintRecipient: mintRecipientAddress, - destinationCaller: destinationCallerAddress, - maxFee: BigInt(depositForBurnParams.maxFee.toString()), - minFinalityThreshold: depositForBurnParams.minFinalityThreshold, - }); + const depositInstruction = destinationIsHyperCore + ? await TokenMessengerMinterV2Client.getDepositForBurnWithHookInstructionAsync( + { + ...depositInstructionParams, + hookData: encodeForwardHookData(crossSwap.recipient), + } + ) + : await TokenMessengerMinterV2Client.getDepositForBurnInstructionAsync( + depositInstructionParams + ); // Build the transaction message using SDK helper const rpcClient = getSVMRpc(originChainId); diff --git a/api/_bridges/cctp/utils/constants.ts b/api/_bridges/cctp/utils/constants.ts index 4ef33f074..034109c7a 100644 --- a/api/_bridges/cctp/utils/constants.ts +++ b/api/_bridges/cctp/utils/constants.ts @@ -19,6 +19,7 @@ export const CCTP_SUPPORTED_CHAINS = [ // Testnets CHAIN_IDs.HYPEREVM_TESTNET, CHAIN_IDs.HYPERCORE_TESTNET, + CHAIN_IDs.SOLANA_DEVNET, ]; export const CCTP_SUPPORTED_TOKENS = [TOKEN_SYMBOLS_MAP.USDC]; @@ -46,6 +47,7 @@ const DEFAULT_CCTP_TOKEN_MESSENGER_ADDRESS = // Source: https://developers.circle.com/cctp/solana-programs const CCTP_TOKEN_MESSENGER_ADDRESS_OVERRIDES: Record = { [CHAIN_IDs.SOLANA]: "CCTPV2vPZJS2u2BBsUoscuikbYjnpFmbFsvVuJdgUMQe", + [CHAIN_IDs.SOLANA_DEVNET]: "CCTPV2vPZJS2u2BBsUoscuikbYjnpFmbFsvVuJdgUMQe", }; export const getCctpTokenMessengerAddress = (chainId: number): string => { @@ -62,6 +64,7 @@ const DEFAULT_CCTP_MESSAGE_TRANSMITTER_ADDRESS = // Source: https://developers.circle.com/cctp/solana-programs const CCTP_MESSAGE_TRANSMITTER_ADDRESS_OVERRIDES: Record = { [CHAIN_IDs.SOLANA]: "CCTPV2Sm4AdWt5296sk4P66VBZ7bEhcARwFaaS9YPbeC", + [CHAIN_IDs.SOLANA_DEVNET]: "CCTPV2Sm4AdWt5296sk4P66VBZ7bEhcARwFaaS9YPbeC", }; export const getCctpMessageTransmitterAddress = (chainId: number): string => { @@ -71,6 +74,23 @@ export const getCctpMessageTransmitterAddress = (chainId: number): string => { ); }; +// CCTP Forwarder contract addresses +// This contract receives CCTP transfers and forwards them to HyperCore +const CCTP_FORWARDER_ADDRESSES: Record = { + [CHAIN_IDs.HYPEREVM]: "0x02e39ecb8368b41bf68ff99ff351ac9864e5e2a2", + [CHAIN_IDs.HYPEREVM_TESTNET]: "0x02e39ecb8368b41bf68ff99ff351ac9864e5e2a2", +}; + +export const getCctpForwarderAddress = (chainId: number): string => { + const forwarderAddress = CCTP_FORWARDER_ADDRESSES[chainId]; + if (!forwarderAddress) { + throw new InvalidParamError({ + message: `CCTP forwarder address not found for chain ID ${chainId}`, + }); + } + return forwarderAddress; +}; + // CCTP TokenMessenger depositForBurn ABI const CCTP_DEPOSIT_FOR_BURN_ABI = { inputs: [ diff --git a/api/_bridges/cctp/utils/hypercore.ts b/api/_bridges/cctp/utils/hypercore.ts index e52266104..74593392b 100644 --- a/api/_bridges/cctp/utils/hypercore.ts +++ b/api/_bridges/cctp/utils/hypercore.ts @@ -1,3 +1,4 @@ +import axios from "axios"; import { BigNumber, ethers } from "ethers"; import { CrossSwapQuotes } from "../../../_dexes/types"; import { tagIntegratorId, tagSwapApiMarker } from "../../../_integrator-id"; @@ -6,6 +7,7 @@ import { CHAIN_IDs } from "../../../_constants"; import { Token } from "../../../_dexes/types"; import { ConvertDecimals } from "../../../_utils"; import { accountExistsOnHyperCore } from "../../../_hypercore"; +import { getCctpDomainId } from "./constants"; const HYPERCORE_ACCOUNT_CREATION_FEE_USDC = 1; @@ -27,6 +29,122 @@ export function isToHyperCore(destinationChainId: number) { ); } +/** + * Encodes forward hook data for CCTP Solana -> HyperCore routing. + * + * @param hypercoreMintRecipient address of the recipient on HyperCore + * @returns Uint8Array containing the encoded hook data + */ +export function encodeForwardHookData( + hypercoreMintRecipient: string +): Uint8Array { + const hookDataBuffer = new Uint8Array(52); + + // Base hook data is the hex representation of: + // - "cctp-forward" padded to 24 bytes + // - 4 bytes for version (0x00000000) + // - 4 bytes for data length (0x00000014 = 20 in big-endian) + const baseHookData = + "636374702d666f72776172640000000000000000000000000000000000000014"; + hookDataBuffer.set(Buffer.from(baseHookData, "hex"), 0); + + // Write the 20-byte recipient address at byte 32 + const recipientBytes = Buffer.from( + hypercoreMintRecipient.replace("0x", ""), + "hex" + ); + if (recipientBytes.length !== 20) { + throw new InvalidParamError({ + message: `Invalid HyperCore recipient address length: expected 20 bytes, got ${recipientBytes.length}`, + param: "hypercoreMintRecipient", + }); + } + hookDataBuffer.set(recipientBytes, 32); + + return hookDataBuffer; +} + +/** + * CCTP fee configuration type from Circle API + */ +type CctpFeeConfig = { + finalityThreshold: number; + minimumFee: number; // in bps + forwardFee: { + low: number; // in token units + med: number; + high: number; + }; +}; + +/** + * Queries Circle API to fetch CCTP fees for the specified finality threshold. + * + * Transfer fee: Variable fee in basis points of the transfer amount, collected at minting time. + * - 0 bps for standard transfers (finality threshold > 1000) + * - Varies by origin chain for fast transfers (finality threshold ≤ 1000) + * - See: https://developers.circle.com/cctp/technical-guide#cctp-v2-fees + * + * Forward fee: Fixed fee in token units charged when routing through CCTP forwarder (e.g., to HyperCore). + * - Applies only to forwarded transfers via depositForBurnWithHook + * - Returned in token decimals (e.g., 6 decimals for USDC) + * + * @param inputToken - Input token with chainId + * @param outputToken - Output token with chainId + * @param minFinalityThreshold - Finality threshold: 1000 for fast, 2000 for standard + * @returns transferFeeBps (basis points) and forwardFee (in token units) + */ +export async function getCctpFees(params: { + inputToken: Token; + outputToken: Token; + minFinalityThreshold: number; +}): Promise<{ + transferFeeBps: number; + forwardFee: BigNumber; +}> { + const { inputToken, outputToken, minFinalityThreshold } = params; + + // Check if destination is HyperCore (requires forward fee) + const isDestinationHyperCore = isToHyperCore(outputToken.chainId); + const useSandbox = outputToken.chainId === CHAIN_IDs.HYPERCORE_TESTNET; + + // Determine the CCTP destination domain (use HyperEVM domain for HyperCore) + const destinationChainIdForCctp = isDestinationHyperCore + ? CHAIN_IDs.HYPEREVM + : outputToken.chainId; + + // Get CCTP domain IDs + const sourceDomainId = getCctpDomainId(inputToken.chainId); + const destDomainId = getCctpDomainId(destinationChainIdForCctp); + + const endpoint = useSandbox ? "iris-api-sandbox" : "iris-api"; + const url = `https://${endpoint}.circle.com/v2/burn/USDC/fees/${sourceDomainId}/${destDomainId}`; + + const response = await axios.get(url, { + params: isDestinationHyperCore ? { forward: true } : undefined, + }); + + // Find config matching the requested finality threshold + const transferConfig = response.data.find( + (config) => config.finalityThreshold === minFinalityThreshold + ); + + if (!transferConfig) { + throw new Error( + `Fee configuration not found for finality threshold ${minFinalityThreshold} in CCTP fee response` + ); + } + + // Use medium forward fee for HyperCore destinations, 0 otherwise + // Forward fee is a fixed fee charged by CCTP when going to HyperCore + const forwardFee = isDestinationHyperCore ? transferConfig.forwardFee.med : 0; + + return { + transferFeeBps: transferConfig.minimumFee, + forwardFee: BigNumber.from(forwardFee), + }; +} + export async function getAmountToHyperCore(params: { inputToken: Token; outputToken: Token; @@ -45,7 +163,6 @@ export async function getAmountToHyperCore(params: { const recipientExists = await accountExistsOnHyperCore({ account: recipient, - chainId: inputToken.chainId, }); if (recipientExists) { diff --git a/api/_bridges/index.ts b/api/_bridges/index.ts index b656e3cf0..e3b823da1 100644 --- a/api/_bridges/index.ts +++ b/api/_bridges/index.ts @@ -28,6 +28,12 @@ export const bridgeStrategies: BridgeStrategiesConfig = { [CHAIN_IDs.HYPEREVM_TESTNET]: { [CHAIN_IDs.HYPERCORE_TESTNET]: getCctpBridgeStrategy(), }, + [CHAIN_IDs.SOLANA]: { + [CHAIN_IDs.HYPERCORE]: getCctpBridgeStrategy(), + }, + [CHAIN_IDs.SOLANA_DEVNET]: { + [CHAIN_IDs.HYPERCORE_TESTNET]: getCctpBridgeStrategy(), + }, }, }, }; diff --git a/api/swap/_utils.ts b/api/swap/_utils.ts index 8269b4fa4..545774e2f 100644 --- a/api/swap/_utils.ts +++ b/api/swap/_utils.ts @@ -55,6 +55,7 @@ import { getMultiCallHandlerAddress, } from "../_multicall-handler"; import { TOKEN_SYMBOLS_MAP } from "../_constants"; +import { isToHyperCore } from "../_bridges/cctp/utils/hypercore"; import { Logger } from "@across-protocol/sdk/dist/types/relayFeeCalculator"; const PRICE_DIFFERENCE_TOLERANCE = 0.01; @@ -163,7 +164,14 @@ export async function handleBaseSwapQueryParams( destinationChainId ); - if (!outputBridgeable) { + // HyperCore uses special system addresses (0x20...) that aren't in standard enabled routes + // Allow HyperCore as destination if output token is USDC on HyperCore + const isHyperCoreUsdcDestination = + isToHyperCore(destinationChainId) && + outputTokenAddress.toLowerCase() === + TOKEN_SYMBOLS_MAP.USDC.addresses[destinationChainId]?.toLowerCase(); + + if (!outputBridgeable && !isHyperCoreUsdcDestination) { throw new InvalidParamError({ param: "outputToken", message: diff --git a/test/api/_bridges/cctp/strategy.test.ts b/test/api/_bridges/cctp/strategy.test.ts new file mode 100644 index 000000000..c09120580 --- /dev/null +++ b/test/api/_bridges/cctp/strategy.test.ts @@ -0,0 +1,230 @@ +import { BigNumber } from "ethers"; +import axios from "axios"; + +import { getCctpBridgeStrategy } from "../../../../api/_bridges/cctp/strategy"; +import { CHAIN_IDs, TOKEN_SYMBOLS_MAP } from "../../../../api/_constants"; +import * as hypercoreModule from "../../../../api/_hypercore"; + +jest.mock("axios"); +jest.mock("../../../../api/_hypercore"); + +const mockedAxios = axios as jest.Mocked; + +describe("bridges/cctp/strategy", () => { + const mockAccountExistsOnHyperCore = + hypercoreModule.accountExistsOnHyperCore as jest.MockedFunction< + typeof hypercoreModule.accountExistsOnHyperCore + >; + + const strategy = getCctpBridgeStrategy(); + + // Shared test tokens + const inputToken = { + ...TOKEN_SYMBOLS_MAP.USDC, + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.ARBITRUM], + chainId: CHAIN_IDs.ARBITRUM, + decimals: 6, + }; + + const outputTokenHyperCore = { + ...TOKEN_SYMBOLS_MAP.USDC, + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.HYPERCORE], + chainId: CHAIN_IDs.HYPERCORE, + decimals: 6, + }; + + const outputTokenBase = { + ...TOKEN_SYMBOLS_MAP.USDC, + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.BASE], + chainId: CHAIN_IDs.BASE, + decimals: 6, + }; + + // Shared mock CCTP fee response + const mockCctpFeeResponse = [ + { + finalityThreshold: 1000, + minimumFee: 1, // 1 bps = 0.01% + forwardFee: { + low: 100000, + med: 200000, // 0.2 USDC (in 6 decimals) + high: 300000, + }, + }, + { + finalityThreshold: 2000, + minimumFee: 0, + forwardFee: { + low: 0, + med: 0, + high: 0, + }, + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + mockedAxios.get.mockResolvedValue({ + data: mockCctpFeeResponse, + }); + }); + + describe("getQuoteForExactInput()", () => { + test("should calculate correct output amount for existing HyperCore account", async () => { + mockAccountExistsOnHyperCore.mockResolvedValue(true); + + const exactInputAmount = BigNumber.from(100_000_000); // 100 USDC + const recipient = "0x1234567890123456789012345678901234567890"; + + const result = await strategy.getQuoteForExactInput({ + inputToken, + outputToken: outputTokenHyperCore, + exactInputAmount, + recipient, + }); + + // Expected calculation: + // Step 1: Calculate CCTP fees + // transferFee = 100 * 1 / 10000 = 0.01 USDC = 10,000 + // maxFee = 10,000 + 200,000 = 210,000 + // Step 2: Calculate amount after fees + // inputAfterFee = 100,000,000 - 210,000 = 99,790,000 + // Step 3: No account creation fee (account exists) + // outputAmount = 99,790,000 + + const transferFee = exactInputAmount.mul(1).div(10000); // 10,000 + const maxFee = transferFee.add(200_000); // 210,000 + const inputAfterFee = exactInputAmount.sub(maxFee); // 99,790,000 + + expect(result.bridgeQuote.inputAmount).toEqual(exactInputAmount); + expect(result.bridgeQuote.outputAmount).toEqual(inputAfterFee); + expect(result.bridgeQuote.minOutputAmount).toEqual(inputAfterFee); + expect(result.bridgeQuote.fees.bridgeFee.total).toEqual(maxFee); + }); + + test("should calculate correct output amount for new HyperCore account", async () => { + mockAccountExistsOnHyperCore.mockResolvedValue(false); + + const exactInputAmount = BigNumber.from(100_000_000); // 100 USDC + const recipient = "0x1234567890123456789012345678901234567890"; + + const result = await strategy.getQuoteForExactInput({ + inputToken, + outputToken: outputTokenHyperCore, + exactInputAmount, + recipient, + }); + + // Expected calculation: + // Step 1: Calculate CCTP fees + // transferFee = 100 * 1 / 10000 = 0.01 USDC = 10,000 + // maxFee = 10,000 + 200,000 = 210,000 + // Step 2: Calculate amount after fees + // inputAfterFee = 100,000,000 - 210,000 = 99,790,000 + // Step 3: Account creation fee (new account) + // outputAmount = 99,790,000 - 1,000,000 = 98,790,000 + + const transferFee = exactInputAmount.mul(1).div(10000); // 10,000 + const maxFee = transferFee.add(200_000); // 210,000 + const inputAfterFee = exactInputAmount.sub(maxFee); // 99,790,000 + const expectedOutput = inputAfterFee.sub(1_000_000); // 98,790,000 (minus 1 USDC account fee) + + expect(result.bridgeQuote.inputAmount).toEqual(exactInputAmount); + expect(result.bridgeQuote.outputAmount).toEqual(expectedOutput); + expect(result.bridgeQuote.minOutputAmount).toEqual(expectedOutput); + expect(result.bridgeQuote.fees.bridgeFee.total).toEqual(maxFee); + }); + + test("should calculate correct output amount for non-HyperCore route with zero fees", async () => { + const exactInputAmount = BigNumber.from(100_000_000); // 100 USDC + + const result = await strategy.getQuoteForExactInput({ + inputToken, + outputToken: outputTokenBase, + exactInputAmount, + }); + + // Expected calculation: + // Non-HyperCore routes currently have no CCTP fees + // outputAmount = inputAmount (no fees, no account creation) + + expect(result.bridgeQuote.inputAmount).toEqual(exactInputAmount); + expect(result.bridgeQuote.outputAmount).toEqual(exactInputAmount); + expect(result.bridgeQuote.minOutputAmount).toEqual(exactInputAmount); + expect(result.bridgeQuote.fees.bridgeFee.total).toEqual( + BigNumber.from(0) + ); + }); + }); + + describe("getQuoteForOutput()", () => { + test("should calculate correct input amount for existing HyperCore account", async () => { + mockAccountExistsOnHyperCore.mockResolvedValue(true); + + const minOutputAmount = BigNumber.from(100_000_000); // 100 USDC + const recipient = "0x1234567890123456789012345678901234567890"; + + const result = await strategy.getQuoteForOutput({ + inputToken, + outputToken: outputTokenHyperCore, + minOutputAmount, + recipient, + }); + + // Expected calculation: + // Step 1: amountToArriveOnDestination = 100 USDC (no account creation fee) + // Step 2: Solve algebraic formula + // inputAmount = (100 + 0.2) * 10000 / (10000 - 1) + // inputAmount = 100.2 * 10000 / 9999 + // inputAmount = 100,200,000 / 9999 ≈ 100,210,021 + + const expectedInputAmount = BigNumber.from(100_000_000) + .add(200_000) + .mul(10000) + .div(9999); + + expect(result.bridgeQuote.inputAmount).toEqual(expectedInputAmount); + expect(result.bridgeQuote.outputAmount).toEqual(minOutputAmount); + expect(result.bridgeQuote.minOutputAmount).toEqual(minOutputAmount); + + // Verify CCTP fee = inputAmount - amountToArriveOnDestination + const expectedFee = expectedInputAmount.sub(100_000_000); + expect(result.bridgeQuote.fees.bridgeFee.total).toEqual(expectedFee); + }); + + test("should calculate correct input amount for new HyperCore account", async () => { + mockAccountExistsOnHyperCore.mockResolvedValue(false); + + const minOutputAmount = BigNumber.from(100_000_000); // 100 USDC + const recipient = "0x1234567890123456789012345678901234567890"; + + const result = await strategy.getQuoteForOutput({ + inputToken, + outputToken: outputTokenHyperCore, + minOutputAmount, + recipient, + }); + + // Expected calculation: + // Step 1: amountToArriveOnDestination = 100 + 1 = 101 USDC (with account creation fee) + // Step 2: Solve algebraic formula + // inputAmount = (101 + 0.2) * 10000 / (10000 - 1) + // inputAmount = 101.2 * 10000 / 9999 + // inputAmount = 101,200,000 / 9999 ≈ 101,210,121 + + const amountToArriveOnDestination = BigNumber.from(101_000_000); // 100 + 1 USDC + const expectedInputAmount = amountToArriveOnDestination + .add(200_000) + .mul(10000) + .div(9999); + + expect(result.bridgeQuote.inputAmount).toEqual(expectedInputAmount); + expect(result.bridgeQuote.outputAmount).toEqual(minOutputAmount); + expect(result.bridgeQuote.minOutputAmount).toEqual(minOutputAmount); + + // Verify CCTP fee = inputAmount - amountToArriveOnDestination + const expectedFee = expectedInputAmount.sub(amountToArriveOnDestination); + expect(result.bridgeQuote.fees.bridgeFee.total).toEqual(expectedFee); + }); + }); +}); diff --git a/test/api/_bridges/cctp/utils/hypercore.test.ts b/test/api/_bridges/cctp/utils/hypercore.test.ts index 38ba3f739..8c21547de 100644 --- a/test/api/_bridges/cctp/utils/hypercore.test.ts +++ b/test/api/_bridges/cctp/utils/hypercore.test.ts @@ -1,14 +1,17 @@ import { BigNumber } from "ethers"; +import axios from "axios"; import { isHyperEvmToHyperCoreRoute, getAmountToHyperCore, + getCctpFees, } from "../../../../../api/_bridges/cctp/utils/hypercore"; import { CHAIN_IDs } from "../../../../../api/_constants"; import { TOKEN_SYMBOLS_MAP } from "../../../../../api/_constants"; import * as hypercoreModule from "../../../../../api/_hypercore"; jest.mock("../../../../../api/_hypercore"); +jest.mock("axios"); describe("bridges/cctp/utils/hypercore", () => { const mockAccountExistsOnHyperCore = @@ -220,4 +223,158 @@ describe("bridges/cctp/utils/hypercore", () => { expect(isRouteSupported).toEqual(false); }); }); + + describe("#getCctpFees()", () => { + const mockAxios = axios as jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Mock Circle API response with multiple finality threshold configs + const mockCctpFeeResponse = [ + { + finalityThreshold: 1000, // fast + minimumFee: 1, // 1 bps = 0.01% + forwardFee: { + low: 100000, + med: 200000, // 0.2 USDC (in 6 decimals) + high: 300000, + }, + }, + { + finalityThreshold: 2000, // standard + minimumFee: 2, // 2 bps = 0.02% + forwardFee: { + low: 150000, + med: 250000, // 0.25 USDC + high: 350000, + }, + }, + ]; + + describe("HyperCore destinations (mainnet)", () => { + const inputToken = { + ...TOKEN_SYMBOLS_MAP.USDC, + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.BASE], + chainId: CHAIN_IDs.BASE, + }; + + const outputTokenHyperCore = { + ...TOKEN_SYMBOLS_MAP.USDC, + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.HYPERCORE], + chainId: CHAIN_IDs.HYPERCORE, + }; + + test("should query with forward=true and extract forward fee from API response", async () => { + mockAxios.get.mockResolvedValue({ data: mockCctpFeeResponse }); + + const result = await getCctpFees({ + inputToken, + outputToken: outputTokenHyperCore, + minFinalityThreshold: 1000, + }); + + // Verify API call parameters + expect(mockAxios.get).toHaveBeenCalledWith( + expect.stringContaining("iris-api.circle.com/v2/burn/USDC/fees/"), + expect.objectContaining({ + params: { forward: true }, + }) + ); + + // Verify returned fee breakdown + expect(result.transferFeeBps).toEqual(1); + expect(result.forwardFee).toEqual(BigNumber.from(200000)); + }); + + test("should throw error when config for finality threshold is not found", async () => { + mockAxios.get.mockResolvedValue({ data: mockCctpFeeResponse }); + + await expect( + getCctpFees({ + inputToken, + outputToken: outputTokenHyperCore, + minFinalityThreshold: 999, // Non-existent threshold + }) + ).rejects.toThrow( + "Fee configuration not found for finality threshold 999 in CCTP fee response" + ); + }); + }); + + describe("HyperCore destinations (testnet)", () => { + const inputToken = { + ...TOKEN_SYMBOLS_MAP.USDC, + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.BASE_SEPOLIA], + chainId: CHAIN_IDs.BASE_SEPOLIA, + }; + + const outputTokenHyperCoreTestnet = { + ...TOKEN_SYMBOLS_MAP.USDC, + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.HYPERCORE_TESTNET], + chainId: CHAIN_IDs.HYPERCORE_TESTNET, + }; + + test("should use sandbox endpoint for testnet and extract forward fee", async () => { + mockAxios.get.mockResolvedValue({ data: mockCctpFeeResponse }); + + const result = await getCctpFees({ + inputToken, + outputToken: outputTokenHyperCoreTestnet, + minFinalityThreshold: 1000, + }); + + // Verify sandbox endpoint is used + expect(mockAxios.get).toHaveBeenCalledWith( + expect.stringContaining( + "iris-api-sandbox.circle.com/v2/burn/USDC/fees/" + ), + expect.objectContaining({ + params: { forward: true }, + }) + ); + + // Verify forward fee is extracted + expect(result.transferFeeBps).toEqual(1); + expect(result.forwardFee).toEqual(BigNumber.from(200000)); + }); + }); + + describe("Non-HyperCore destinations", () => { + const inputToken = { + ...TOKEN_SYMBOLS_MAP.USDC, + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.BASE], + chainId: CHAIN_IDs.BASE, + }; + + const outputTokenBase = { + ...TOKEN_SYMBOLS_MAP.USDC, + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.OPTIMISM], + chainId: CHAIN_IDs.OPTIMISM, + }; + + test("should query without forward parameter and set forwardFee to 0", async () => { + mockAxios.get.mockResolvedValue({ data: mockCctpFeeResponse }); + + const result = await getCctpFees({ + inputToken, + outputToken: outputTokenBase, + minFinalityThreshold: 2000, + }); + + // Verify API call does NOT include forward parameter + expect(mockAxios.get).toHaveBeenCalledWith( + expect.stringContaining("iris-api.circle.com/v2/burn/USDC/fees/"), + expect.objectContaining({ + params: undefined, + }) + ); + + // Verify forward fee is 0 + expect(result.transferFeeBps).toEqual(2); + expect(result.forwardFee).toEqual(BigNumber.from(0)); + }); + }); + }); }); From 7d27f142325c8f092b990cb0c6a41f69b3dfa572 Mon Sep 17 00:00:00 2001 From: Ashwin <213675439+ashwinrava@users.noreply.github.com> Date: Mon, 20 Oct 2025 11:22:34 -0400 Subject: [PATCH 09/18] feat: enable unsponsored EVM to HyperCore (#1907) --- api/_bridges/cctp/strategy.ts | 49 ++++++++-- api/_bridges/cctp/utils/constants.ts | 131 ++++++++++++++++++++++++--- api/_bridges/cctp/utils/hypercore.ts | 76 +++++++++++----- api/_bridges/index.ts | 11 ++- 4 files changed, 225 insertions(+), 42 deletions(-) diff --git a/api/_bridges/cctp/strategy.ts b/api/_bridges/cctp/strategy.ts index abffd9bf6..3fa39a16f 100644 --- a/api/_bridges/cctp/strategy.ts +++ b/api/_bridges/cctp/strategy.ts @@ -40,12 +40,14 @@ import { getCctpDomainId, getCctpForwarderAddress, encodeDepositForBurn, + encodeDepositForBurnWithHook, } from "./utils/constants"; import { CHAIN_IDs } from "../../_constants"; import { buildCctpTxHyperEvmToHyperCore, getAmountToHyperCore, isHyperEvmToHyperCoreRoute, + isEvmToHyperCoreRoute, isToHyperCore, encodeForwardHookData, getCctpFees, @@ -322,6 +324,7 @@ export function getCctpBridgeStrategy(): BridgeStrategy { const originChainId = crossSwap.inputToken.chainId; const destinationChainId = crossSwap.outputToken.chainId; + // Handle HyperEVM → HyperCore with special CoreWallet flow if ( isHyperEvmToHyperCoreRoute({ inputToken: crossSwap.inputToken, @@ -371,7 +374,8 @@ export function getCctpBridgeStrategy(): BridgeStrategy { crossSwapQuotes: params.quotes, integratorId: params.integratorId, originChainId, - destinationChainId, + destinationChainId, // Actual destination + intermediaryChainId: destinationChainIdForCctp, // Intermediary chain for routes that use a forwarder tokenMessenger, depositForBurnParams, }); @@ -424,6 +428,7 @@ async function _buildCctpTxForAllowanceHolderEvm(params: { integratorId?: string; originChainId: number; destinationChainId: number; + intermediaryChainId: number; tokenMessenger: string; depositForBurnParams: { amount: BigNumber; @@ -438,18 +443,45 @@ async function _buildCctpTxForAllowanceHolderEvm(params: { crossSwapQuotes, integratorId, originChainId, + intermediaryChainId, tokenMessenger, depositForBurnParams, } = params; const { crossSwap } = crossSwapQuotes; const burnTokenAddress = crossSwap.inputToken.address; - // Encode the depositForBurn call - const callData = encodeDepositForBurn({ - ...depositForBurnParams, - burnToken: burnTokenAddress, + // Check if this is an EVM → HyperCore route (needs depositForBurnWithHook) + const isEvmToHyperCore = isEvmToHyperCoreRoute({ + inputToken: crossSwap.inputToken, + outputToken: crossSwap.outputToken, }); + let callData: string; + + if (isEvmToHyperCore) { + // For EVM → HyperCore: use depositForBurnWithHook with CCTP Forwarder + // Use intermediaryChainId (HyperEVM) to get the forwarder address + const forwarderAddress = getCctpForwarderAddress(intermediaryChainId); + const hookData = encodeForwardHookData(crossSwap.recipient); + + callData = encodeDepositForBurnWithHook({ + amount: depositForBurnParams.amount, + destinationDomain: depositForBurnParams.destinationDomain, + mintRecipient: forwarderAddress, + burnToken: burnTokenAddress, + destinationCaller: forwarderAddress, + maxFee: depositForBurnParams.maxFee, + minFinalityThreshold: depositForBurnParams.minFinalityThreshold, + hookData, + }); + } else { + // Standard CCTP route: use depositForBurn + callData = encodeDepositForBurn({ + ...depositForBurnParams, + burnToken: burnTokenAddress, + }); + } + // Handle integrator ID and swap API marker tagging const callDataWithIntegratorId = integratorId ? tagIntegratorId(integratorId, callData) @@ -598,7 +630,12 @@ async function _buildCctpTxForAllowanceHolderSvm(params: { ? await TokenMessengerMinterV2Client.getDepositForBurnWithHookInstructionAsync( { ...depositInstructionParams, - hookData: encodeForwardHookData(crossSwap.recipient), + hookData: new Uint8Array( + Buffer.from( + encodeForwardHookData(crossSwap.recipient).slice(2), + "hex" + ) + ), } ) : await TokenMessengerMinterV2Client.getDepositForBurnInstructionAsync( diff --git a/api/_bridges/cctp/utils/constants.ts b/api/_bridges/cctp/utils/constants.ts index 034109c7a..108c685dd 100644 --- a/api/_bridges/cctp/utils/constants.ts +++ b/api/_bridges/cctp/utils/constants.ts @@ -5,6 +5,7 @@ import { InvalidParamError } from "../../../_errors"; import { toBytes32 } from "../../../_address"; export const CCTP_SUPPORTED_CHAINS = [ + // Mainnets CHAIN_IDs.MAINNET, CHAIN_IDs.ARBITRUM, CHAIN_IDs.BASE, @@ -17,9 +18,16 @@ export const CCTP_SUPPORTED_CHAINS = [ CHAIN_IDs.UNICHAIN, CHAIN_IDs.WORLD_CHAIN, // Testnets + CHAIN_IDs.SEPOLIA, + CHAIN_IDs.ARBITRUM_SEPOLIA, + CHAIN_IDs.BASE_SEPOLIA, CHAIN_IDs.HYPEREVM_TESTNET, CHAIN_IDs.HYPERCORE_TESTNET, + CHAIN_IDs.INK_SEPOLIA, + CHAIN_IDs.OPTIMISM_SEPOLIA, + CHAIN_IDs.POLYGON_AMOY, CHAIN_IDs.SOLANA_DEVNET, + CHAIN_IDs.UNICHAIN_SEPOLIA, ]; export const CCTP_SUPPORTED_TOKENS = [TOKEN_SYMBOLS_MAP.USDC]; @@ -44,23 +52,46 @@ export const CCTP_FINALITY_THRESHOLDS = { const DEFAULT_CCTP_TOKEN_MESSENGER_ADDRESS = "0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d"; +const DEFAULT_CCTP_TOKEN_MESSENGER_ADDRESS_TESTNET = + "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA"; + // Source: https://developers.circle.com/cctp/solana-programs const CCTP_TOKEN_MESSENGER_ADDRESS_OVERRIDES: Record = { [CHAIN_IDs.SOLANA]: "CCTPV2vPZJS2u2BBsUoscuikbYjnpFmbFsvVuJdgUMQe", [CHAIN_IDs.SOLANA_DEVNET]: "CCTPV2vPZJS2u2BBsUoscuikbYjnpFmbFsvVuJdgUMQe", }; +// Helper to determine if a chain is an EVM testnet +// Only includes testnet versions of CCTP-supported chains +// Excludes HyperEVM, HyperCore, Solana testnets as they have special handling +const isEvmTestnetChain = (chainId: number): boolean => { + return [ + CHAIN_IDs.SEPOLIA, + CHAIN_IDs.ARBITRUM_SEPOLIA, + CHAIN_IDs.BASE_SEPOLIA, + CHAIN_IDs.INK_SEPOLIA, + CHAIN_IDs.OPTIMISM_SEPOLIA, + CHAIN_IDs.POLYGON_AMOY, + CHAIN_IDs.UNICHAIN_SEPOLIA, + ].includes(chainId); +}; + export const getCctpTokenMessengerAddress = (chainId: number): string => { - return ( - CCTP_TOKEN_MESSENGER_ADDRESS_OVERRIDES[chainId] || - DEFAULT_CCTP_TOKEN_MESSENGER_ADDRESS - ); + if (CCTP_TOKEN_MESSENGER_ADDRESS_OVERRIDES[chainId]) { + return CCTP_TOKEN_MESSENGER_ADDRESS_OVERRIDES[chainId]; + } + return isEvmTestnetChain(chainId) + ? DEFAULT_CCTP_TOKEN_MESSENGER_ADDRESS_TESTNET + : DEFAULT_CCTP_TOKEN_MESSENGER_ADDRESS; }; // Source: https://developers.circle.com/cctp/evm-smart-contracts const DEFAULT_CCTP_MESSAGE_TRANSMITTER_ADDRESS = "0x81D40F21F12A8F0E3252Bccb954D722d4c464B64"; +const DEFAULT_CCTP_MESSAGE_TRANSMITTER_ADDRESS_TESTNET = + "0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275"; + // Source: https://developers.circle.com/cctp/solana-programs const CCTP_MESSAGE_TRANSMITTER_ADDRESS_OVERRIDES: Record = { [CHAIN_IDs.SOLANA]: "CCTPV2Sm4AdWt5296sk4P66VBZ7bEhcARwFaaS9YPbeC", @@ -68,17 +99,19 @@ const CCTP_MESSAGE_TRANSMITTER_ADDRESS_OVERRIDES: Record = { }; export const getCctpMessageTransmitterAddress = (chainId: number): string => { - return ( - CCTP_MESSAGE_TRANSMITTER_ADDRESS_OVERRIDES[chainId] || - DEFAULT_CCTP_MESSAGE_TRANSMITTER_ADDRESS - ); + if (CCTP_MESSAGE_TRANSMITTER_ADDRESS_OVERRIDES[chainId]) { + return CCTP_MESSAGE_TRANSMITTER_ADDRESS_OVERRIDES[chainId]; + } + return isEvmTestnetChain(chainId) + ? DEFAULT_CCTP_MESSAGE_TRANSMITTER_ADDRESS_TESTNET + : DEFAULT_CCTP_MESSAGE_TRANSMITTER_ADDRESS; }; // CCTP Forwarder contract addresses -// This contract receives CCTP transfers and forwards them to HyperCore +// These contracts receive CCTP transfers and forward them to HyperCore const CCTP_FORWARDER_ADDRESSES: Record = { - [CHAIN_IDs.HYPEREVM]: "0x02e39ecb8368b41bf68ff99ff351ac9864e5e2a2", - [CHAIN_IDs.HYPEREVM_TESTNET]: "0x02e39ecb8368b41bf68ff99ff351ac9864e5e2a2", + [CHAIN_IDs.HYPEREVM]: "0x02e39ECb8368b41bF68FF99ff351aC9864e5E2a2", + [CHAIN_IDs.HYPEREVM_TESTNET]: "0x02e39ECb8368b41bF68FF99ff351aC9864e5E2a2", }; export const getCctpForwarderAddress = (chainId: number): string => { @@ -158,6 +191,82 @@ export const encodeDepositForBurn = (params: { ]); }; +// CCTP TokenMessengerV2 depositForBurnWithHook ABI +const CCTP_DEPOSIT_FOR_BURN_WITH_HOOK_ABI = { + inputs: [ + { + internalType: "uint256", + name: "amount", + type: "uint256", + }, + { + internalType: "uint32", + name: "destinationDomain", + type: "uint32", + }, + { + internalType: "bytes32", + name: "mintRecipient", + type: "bytes32", + }, + { + internalType: "address", + name: "burnToken", + type: "address", + }, + { + internalType: "bytes32", + name: "destinationCaller", + type: "bytes32", + }, + { + internalType: "uint256", + name: "maxFee", + type: "uint256", + }, + { + internalType: "uint32", + name: "minFinalityThreshold", + type: "uint32", + }, + { + internalType: "bytes", + name: "hookData", + type: "bytes", + }, + ], + name: "depositForBurnWithHook", + outputs: [], + stateMutability: "nonpayable", + type: "function", +}; + +export const encodeDepositForBurnWithHook = (params: { + amount: BigNumber; + destinationDomain: number; + mintRecipient: string; + burnToken: string; + destinationCaller: string; + maxFee: BigNumber; + minFinalityThreshold: number; + hookData: string; +}): string => { + const iface = new ethers.utils.Interface([ + CCTP_DEPOSIT_FOR_BURN_WITH_HOOK_ABI, + ]); + + return iface.encodeFunctionData("depositForBurnWithHook", [ + params.amount, + params.destinationDomain, + toBytes32(params.mintRecipient), + params.burnToken, + toBytes32(params.destinationCaller), + params.maxFee, + params.minFinalityThreshold, + params.hookData, + ]); +}; + // CCTP estimated fill times in seconds // Soruce: https://developers.circle.com/cctp/required-block-confirmations export const CCTP_FILL_TIME_ESTIMATES: Record = { diff --git a/api/_bridges/cctp/utils/hypercore.ts b/api/_bridges/cctp/utils/hypercore.ts index 74593392b..b5c108a22 100644 --- a/api/_bridges/cctp/utils/hypercore.ts +++ b/api/_bridges/cctp/utils/hypercore.ts @@ -30,38 +30,43 @@ export function isToHyperCore(destinationChainId: number) { } /** - * Encodes forward hook data for CCTP Solana -> HyperCore routing. + * Encodes forward hook data for CCTP -> HyperCore routing. + * Works for both EVM and Solana ecosystems. * - * @param hypercoreMintRecipient address of the recipient on HyperCore - * @returns Uint8Array containing the encoded hook data + * Hook data structure (52 bytes): + * - Bytes 0-23: "cctp-forward" magic string padded to 24 bytes + * - Bytes 24-27: Version (0x00000000) + * - Bytes 28-31: Data length (0x00000014 = 20 bytes in big-endian) + * - Bytes 32-51: 20-byte HyperCore recipient address + * + * @param hypercoreMintRecipient address of the recipient on HyperCore (with or without 0x prefix) + * @returns Hex string (0x-prefixed) containing the 52-byte encoded hook data */ -export function encodeForwardHookData( - hypercoreMintRecipient: string -): Uint8Array { - const hookDataBuffer = new Uint8Array(52); - - // Base hook data is the hex representation of: - // - "cctp-forward" padded to 24 bytes - // - 4 bytes for version (0x00000000) - // - 4 bytes for data length (0x00000014 = 20 in big-endian) +export function encodeForwardHookData(hypercoreMintRecipient: string): string { + const hookDataBuffer = Buffer.alloc(52); + + // Base hook data: "cctp-forward" (24 bytes) + version (4 bytes) + length (4 bytes) const baseHookData = "636374702d666f72776172640000000000000000000000000000000000000014"; - hookDataBuffer.set(Buffer.from(baseHookData, "hex"), 0); + hookDataBuffer.write(baseHookData, 0, "hex"); - // Write the 20-byte recipient address at byte 32 - const recipientBytes = Buffer.from( - hypercoreMintRecipient.replace("0x", ""), - "hex" - ); - if (recipientBytes.length !== 20) { + // Extract recipient address without 0x prefix + const recipientWithoutPrefix = hypercoreMintRecipient.startsWith("0x") + ? hypercoreMintRecipient.slice(2) + : hypercoreMintRecipient; + + // Validate recipient address is exactly 20 bytes (40 hex characters) + if (recipientWithoutPrefix.length !== 40) { throw new InvalidParamError({ - message: `Invalid HyperCore recipient address length: expected 20 bytes, got ${recipientBytes.length}`, + message: `Invalid HyperCore recipient address: expected 40 hex chars, got ${recipientWithoutPrefix.length}`, param: "hypercoreMintRecipient", }); } - hookDataBuffer.set(recipientBytes, 32); - return hookDataBuffer; + // Write the 20-byte recipient address at byte offset 32 + hookDataBuffer.write(recipientWithoutPrefix, 32, "hex"); + + return "0x" + hookDataBuffer.toString("hex"); } /** @@ -260,3 +265,30 @@ export function buildCctpTxHyperEvmToHyperCore(params: { ecosystem: "evm" as const, }; } + +/** + * Check if this is an EVM (non-HyperEVM, non-Solana) → HyperCore route + * These routes use depositForBurnWithHook with CCTP Forwarder + */ +export function isEvmToHyperCoreRoute(params: { + inputToken: Token; + outputToken: Token; +}) { + // Check if destination is HyperCore (mainnet or testnet) + const isDestinationHyperCore = isToHyperCore(params.outputToken.chainId); + + // Exclude HyperEVM → HyperCore (has special CoreWallet flow) + if (isHyperEvmToHyperCoreRoute(params)) { + return false; + } + + // Check if source is EVM (not Solana or other non-EVM chains) + const isSourceEvm = ![ + CHAIN_IDs.SOLANA, + CHAIN_IDs.SOLANA_DEVNET, + CHAIN_IDs.HYPERCORE, + CHAIN_IDs.HYPERCORE_TESTNET, + ].includes(params.inputToken.chainId); + + return isDestinationHyperCore && isSourceEvm; +} diff --git a/api/_bridges/index.ts b/api/_bridges/index.ts index e3b823da1..fcd0c4c5d 100644 --- a/api/_bridges/index.ts +++ b/api/_bridges/index.ts @@ -22,12 +22,17 @@ export const bridgeStrategies: BridgeStrategiesConfig = { }, inputTokens: { USDC: { - [CHAIN_IDs.HYPEREVM]: { - [CHAIN_IDs.HYPERCORE]: getCctpBridgeStrategy(), - }, + // Testnet routes [CHAIN_IDs.HYPEREVM_TESTNET]: { [CHAIN_IDs.HYPERCORE_TESTNET]: getCctpBridgeStrategy(), }, + [CHAIN_IDs.SEPOLIA]: { + [CHAIN_IDs.HYPERCORE_TESTNET]: getCctpBridgeStrategy(), + }, + [CHAIN_IDs.ARBITRUM_SEPOLIA]: { + [CHAIN_IDs.HYPERCORE_TESTNET]: getCctpBridgeStrategy(), + }, + // SVM → HyperCore routes [CHAIN_IDs.SOLANA]: { [CHAIN_IDs.HYPERCORE]: getCctpBridgeStrategy(), }, From 293e5a6ca64efdf0ee16a3106a7e3cbea2511d98 Mon Sep 17 00:00:00 2001 From: Ashwin <213675439+ashwinrava@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:55:51 -0400 Subject: [PATCH 10/18] refactor: bridge strategy routing rules (#1912) --- api/_bridges/cctp/utils/routing.ts | 121 ++++++ api/_bridges/index.ts | 52 +-- test/api/_bridges/cctp/utils/routing.test.ts | 367 +++++++++++++++++++ 3 files changed, 489 insertions(+), 51 deletions(-) create mode 100644 api/_bridges/cctp/utils/routing.ts create mode 100644 test/api/_bridges/cctp/utils/routing.test.ts diff --git a/api/_bridges/cctp/utils/routing.ts b/api/_bridges/cctp/utils/routing.ts new file mode 100644 index 000000000..93e521a13 --- /dev/null +++ b/api/_bridges/cctp/utils/routing.ts @@ -0,0 +1,121 @@ +import { getAcrossBridgeStrategy } from "../../across/strategy"; +import { getCctpBridgeStrategy } from "../strategy"; +import { + BridgeStrategy, + BridgeStrategyData, + BridgeStrategyDataParams, +} from "../../types"; +import { getBridgeStrategyData } from "../../utils"; +import { getLogger } from "../../../_utils"; + +type RoutingRule = { + name: string; + shouldApply: (data: NonNullable) => boolean; + getStrategy: () => BridgeStrategy; + reason: string; +}; + +// Priority-ordered routing rules for CCTP +const ROUTING_RULES: RoutingRule[] = [ + { + name: "non-usdc-route", + shouldApply: (data) => !data.isUsdcToUsdc, + getStrategy: getAcrossBridgeStrategy, + reason: "Non-USDC pairs always use Across", + }, + { + name: "high-utilization", + shouldApply: (data) => data.isUtilizationHigh, + getStrategy: getCctpBridgeStrategy, + reason: "High utilization (>80%) routes to CCTP", + }, + { + name: "linea-exclusion", + shouldApply: (data) => data.isLineaSource, + getStrategy: getAcrossBridgeStrategy, + reason: "Linea source chain uses Across", + }, + { + name: "fast-cctp-small-deposit", + shouldApply: (data) => + data.isFastCctpEligible && !data.isInThreshold && !data.isLargeDeposit, + getStrategy: getCctpBridgeStrategy, + reason: + "Fast CCTP eligible chains (Polygon/BSC/Solana) use CCTP for medium deposits (>$10K, <$1M)", + }, + { + name: "fast-cctp-threshold-or-large", + shouldApply: (data) => + data.isFastCctpEligible && (data.isInThreshold || data.isLargeDeposit), + getStrategy: getAcrossBridgeStrategy, + reason: + "Fast CCTP chains use Across for very small (<$10K) or very large (>$1M) deposits", + }, + { + name: "instant-fill", + shouldApply: (data) => data.canFillInstantly, + getStrategy: getAcrossBridgeStrategy, + reason: "Instant fills always use Across for speed", + }, + { + name: "large-deposit-fallback", + shouldApply: (data) => data.isLargeDeposit, + getStrategy: getAcrossBridgeStrategy, + reason: "Large deposits (>$1M) use Across for better liquidity", + }, + { + name: "default-cctp", + shouldApply: () => true, + getStrategy: getCctpBridgeStrategy, + reason: "Default to CCTP for standard USDC routes", + }, +]; + +/** + * Determines the optimal bridge strategy (CCTP vs Across) for a given route. + * + * @param params - Bridge strategy data parameters including tokens, amounts, and addresses + * @returns The selected bridge strategy + */ +export async function routeStrategyForCctp( + params: BridgeStrategyDataParams +): Promise { + const logger = getLogger(); + const bridgeStrategyData = await getBridgeStrategyData(params); + + if (!bridgeStrategyData) { + logger.warn({ + at: "routeStrategyForCctp", + message: "Failed to fetch bridge strategy data, using default", + inputToken: params.inputToken.symbol, + outputToken: params.outputToken.symbol, + }); + return getAcrossBridgeStrategy(); + } + + const applicableRule = ROUTING_RULES.find((rule) => + rule.shouldApply(bridgeStrategyData) + ); + + if (!applicableRule) { + logger.error({ + at: "routeStrategyForCctp", + message: "No routing rule matched, using Across fallback", + bridgeStrategyData, + }); + return getAcrossBridgeStrategy(); + } + + logger.debug({ + at: "routeStrategyForCctp", + message: "Bridge routing decision", + rule: applicableRule.name, + reason: applicableRule.reason, + strategy: applicableRule.getStrategy().name, + inputToken: params.inputToken.symbol, + outputToken: params.outputToken.symbol, + bridgeStrategyData, + }); + + return applicableRule.getStrategy(); +} diff --git a/api/_bridges/index.ts b/api/_bridges/index.ts index fcd0c4c5d..5db2cc188 100644 --- a/api/_bridges/index.ts +++ b/api/_bridges/index.ts @@ -3,12 +3,11 @@ import { getHyperCoreBridgeStrategy } from "./hypercore/strategy"; import { BridgeStrategiesConfig, BridgeStrategy, - BridgeStrategyDataParams, GetBridgeStrategyParams, } from "./types"; import { CHAIN_IDs } from "../_constants"; import { getCctpBridgeStrategy } from "./cctp/strategy"; -import { getBridgeStrategyData } from "./utils"; +import { routeStrategyForCctp } from "./cctp/utils/routing"; export const bridgeStrategies: BridgeStrategiesConfig = { default: getAcrossBridgeStrategy(), @@ -129,52 +128,3 @@ export function getSupportedBridgeStrategies({ ); return supportedBridgeStrategies; } - -async function routeStrategyForCctp({ - inputToken, - outputToken, - amount, - amountType, - recipient, - depositor, -}: BridgeStrategyDataParams): Promise { - const bridgeStrategyData = await getBridgeStrategyData({ - inputToken, - outputToken, - amount, - amountType, - recipient, - depositor, - }); - if (!bridgeStrategyData) { - return bridgeStrategies.default; - } - if (!bridgeStrategyData.isUsdcToUsdc) { - return getAcrossBridgeStrategy(); - } - if (bridgeStrategyData.isUtilizationHigh) { - return getCctpBridgeStrategy(); - } - if (bridgeStrategyData.isLineaSource) { - return getAcrossBridgeStrategy(); - } - if (bridgeStrategyData.isFastCctpEligible) { - if (bridgeStrategyData.isInThreshold) { - return getAcrossBridgeStrategy(); - } - if (bridgeStrategyData.isLargeDeposit) { - return getAcrossBridgeStrategy(); - } else { - return getCctpBridgeStrategy(); - } - } - if (bridgeStrategyData.canFillInstantly) { - return getAcrossBridgeStrategy(); - } else { - if (bridgeStrategyData.isLargeDeposit) { - return getAcrossBridgeStrategy(); - } else { - return getCctpBridgeStrategy(); - } - } -} diff --git a/test/api/_bridges/cctp/utils/routing.test.ts b/test/api/_bridges/cctp/utils/routing.test.ts new file mode 100644 index 000000000..a413603f5 --- /dev/null +++ b/test/api/_bridges/cctp/utils/routing.test.ts @@ -0,0 +1,367 @@ +import { BigNumber } from "ethers"; +import { routeStrategyForCctp } from "../../../../../api/_bridges/cctp/utils/routing"; +import * as bridgeUtils from "../../../../../api/_bridges/utils"; +import { CHAIN_IDs, TOKEN_SYMBOLS_MAP } from "../../../../../api/_constants"; +import { BridgeStrategyData } from "../../../../../api/_bridges/types"; + +jest.mock("../../../../../api/_bridges/utils"); + +const mockedGetBridgeStrategyData = + bridgeUtils.getBridgeStrategyData as jest.MockedFunction< + typeof bridgeUtils.getBridgeStrategyData + >; + +describe("api/_bridges/cctp/utils/routing", () => { + const usdcOptimism = { + ...TOKEN_SYMBOLS_MAP.USDC, + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.OPTIMISM], + chainId: CHAIN_IDs.OPTIMISM, + decimals: 6, + }; + + const usdcArbitrum = { + ...TOKEN_SYMBOLS_MAP.USDC, + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.ARBITRUM], + chainId: CHAIN_IDs.ARBITRUM, + decimals: 6, + }; + + const baseParams = { + inputToken: usdcOptimism, + outputToken: usdcArbitrum, + amount: BigNumber.from("100000000"), // 100 USDC + amountType: "exactInput" as const, + depositor: "0x1234567890123456789012345678901234567890", + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("routeStrategyForCctp", () => { + describe("Rule 1: non-usdc-route", () => { + it("should return Across for non-USDC pairs", async () => { + const strategyData: BridgeStrategyData = { + isUsdcToUsdc: false, + isUtilizationHigh: false, + isLineaSource: false, + isLargeDeposit: false, + isFastCctpEligible: false, + isInThreshold: true, + canFillInstantly: true, + }; + mockedGetBridgeStrategyData.mockResolvedValue(strategyData); + + const result = await routeStrategyForCctp(baseParams); + + expect(result.name).toBe("across"); + }); + + it("should prioritize non-USDC rule over high utilization", async () => { + const strategyData: BridgeStrategyData = { + isUsdcToUsdc: false, + isUtilizationHigh: true, + isLineaSource: false, + isLargeDeposit: false, + isFastCctpEligible: false, + isInThreshold: true, + canFillInstantly: true, + }; + mockedGetBridgeStrategyData.mockResolvedValue(strategyData); + + const result = await routeStrategyForCctp(baseParams); + + expect(result.name).toBe("across"); + }); + }); + + describe("Rule 2: high-utilization", () => { + it("should return CCTP when utilization is high (>80%)", async () => { + const strategyData: BridgeStrategyData = { + isUsdcToUsdc: true, + isUtilizationHigh: true, + isLineaSource: false, + isLargeDeposit: false, + isFastCctpEligible: false, + isInThreshold: true, + canFillInstantly: true, + }; + mockedGetBridgeStrategyData.mockResolvedValue(strategyData); + + const result = await routeStrategyForCctp(baseParams); + + expect(result.name).toBe("cctp"); + }); + + it("should prioritize high utilization over Linea exclusion", async () => { + const strategyData: BridgeStrategyData = { + isUsdcToUsdc: true, + isUtilizationHigh: true, + isLineaSource: true, + isLargeDeposit: false, + isFastCctpEligible: false, + isInThreshold: true, + canFillInstantly: true, + }; + mockedGetBridgeStrategyData.mockResolvedValue(strategyData); + + const result = await routeStrategyForCctp(baseParams); + + expect(result.name).toBe("cctp"); + }); + }); + + describe("Rule 3: linea-exclusion", () => { + it("should return Across when source chain is Linea", async () => { + const strategyData: BridgeStrategyData = { + isUsdcToUsdc: true, + isUtilizationHigh: false, + isLineaSource: true, + isLargeDeposit: false, + isFastCctpEligible: false, + isInThreshold: true, + canFillInstantly: true, + }; + mockedGetBridgeStrategyData.mockResolvedValue(strategyData); + + const result = await routeStrategyForCctp(baseParams); + + expect(result.name).toBe("across"); + }); + }); + + describe("Rule 4: fast-cctp-small-deposit", () => { + it("should return CCTP for medium deposits on fast CCTP chains", async () => { + const strategyData: BridgeStrategyData = { + isUsdcToUsdc: true, + isUtilizationHigh: false, + isLineaSource: false, + isLargeDeposit: false, + isFastCctpEligible: true, + isInThreshold: false, + canFillInstantly: false, + }; + mockedGetBridgeStrategyData.mockResolvedValue(strategyData); + + const result = await routeStrategyForCctp(baseParams); + + expect(result.name).toBe("cctp"); + }); + + it("should not apply for deposits within threshold", async () => { + const strategyData: BridgeStrategyData = { + isUsdcToUsdc: true, + isUtilizationHigh: false, + isLineaSource: false, + isLargeDeposit: false, + isFastCctpEligible: true, + isInThreshold: true, + canFillInstantly: false, + }; + mockedGetBridgeStrategyData.mockResolvedValue(strategyData); + + const result = await routeStrategyForCctp(baseParams); + + expect(result.name).toBe("across"); + }); + + it("should not apply for large deposits", async () => { + const strategyData: BridgeStrategyData = { + isUsdcToUsdc: true, + isUtilizationHigh: false, + isLineaSource: false, + isLargeDeposit: true, + isFastCctpEligible: true, + isInThreshold: false, + canFillInstantly: false, + }; + mockedGetBridgeStrategyData.mockResolvedValue(strategyData); + + const result = await routeStrategyForCctp(baseParams); + + expect(result.name).toBe("across"); + }); + }); + + describe("Rule 5: fast-cctp-threshold-or-large", () => { + it("should return Across for very small deposits (<$10K) on fast CCTP chains", async () => { + const strategyData: BridgeStrategyData = { + isUsdcToUsdc: true, + isUtilizationHigh: false, + isLineaSource: false, + isLargeDeposit: false, + isFastCctpEligible: true, + isInThreshold: true, + canFillInstantly: false, + }; + mockedGetBridgeStrategyData.mockResolvedValue(strategyData); + + const result = await routeStrategyForCctp(baseParams); + + expect(result.name).toBe("across"); + }); + + it("should return Across for very large deposits (>$1M) on fast CCTP chains", async () => { + const strategyData: BridgeStrategyData = { + isUsdcToUsdc: true, + isUtilizationHigh: false, + isLineaSource: false, + isLargeDeposit: true, + isFastCctpEligible: true, + isInThreshold: false, + canFillInstantly: false, + }; + mockedGetBridgeStrategyData.mockResolvedValue(strategyData); + + const result = await routeStrategyForCctp(baseParams); + + expect(result.name).toBe("across"); + }); + }); + + describe("Rule 6: instant-fill", () => { + it("should return Across when deposit can be filled instantly", async () => { + const strategyData: BridgeStrategyData = { + isUsdcToUsdc: true, + isUtilizationHigh: false, + isLineaSource: false, + isLargeDeposit: false, + isFastCctpEligible: false, + isInThreshold: true, + canFillInstantly: true, + }; + mockedGetBridgeStrategyData.mockResolvedValue(strategyData); + + const result = await routeStrategyForCctp(baseParams); + + expect(result.name).toBe("across"); + }); + }); + + describe("Rule 7: large-deposit-fallback", () => { + it("should return Across for large deposits (>$1M)", async () => { + const strategyData: BridgeStrategyData = { + isUsdcToUsdc: true, + isUtilizationHigh: false, + isLineaSource: false, + isLargeDeposit: true, + isFastCctpEligible: false, + isInThreshold: false, + canFillInstantly: false, + }; + mockedGetBridgeStrategyData.mockResolvedValue(strategyData); + + const result = await routeStrategyForCctp(baseParams); + + expect(result.name).toBe("across"); + }); + }); + + describe("Rule 8: default-cctp", () => { + it("should return CCTP for standard USDC routes", async () => { + const strategyData: BridgeStrategyData = { + isUsdcToUsdc: true, + isUtilizationHigh: false, + isLineaSource: false, + isLargeDeposit: false, + isFastCctpEligible: false, + isInThreshold: false, + canFillInstantly: false, + }; + mockedGetBridgeStrategyData.mockResolvedValue(strategyData); + + const result = await routeStrategyForCctp(baseParams); + + expect(result.name).toBe("cctp"); + }); + }); + + describe("Edge cases and fallbacks", () => { + it("should return Across when bridge strategy data is undefined", async () => { + mockedGetBridgeStrategyData.mockResolvedValue(undefined); + + const result = await routeStrategyForCctp(baseParams); + + expect(result.name).toBe("across"); + }); + + it("should handle getBridgeStrategyData errors by returning undefined", async () => { + mockedGetBridgeStrategyData.mockResolvedValue(undefined); + + const result = await routeStrategyForCctp(baseParams); + + expect(result.name).toBe("across"); + }); + }); + + describe("Rule priority validation", () => { + it("should prioritize non-USDC over all other rules", async () => { + const strategyData: BridgeStrategyData = { + isUsdcToUsdc: false, + isUtilizationHigh: true, + isLineaSource: true, + isLargeDeposit: true, + isFastCctpEligible: true, + isInThreshold: false, + canFillInstantly: true, + }; + mockedGetBridgeStrategyData.mockResolvedValue(strategyData); + + const result = await routeStrategyForCctp(baseParams); + + expect(result.name).toBe("across"); + }); + + it("should prioritize high utilization over lower priority rules", async () => { + const strategyData: BridgeStrategyData = { + isUsdcToUsdc: true, + isUtilizationHigh: true, + isLineaSource: true, + isLargeDeposit: true, + isFastCctpEligible: true, + isInThreshold: false, + canFillInstantly: true, + }; + mockedGetBridgeStrategyData.mockResolvedValue(strategyData); + + const result = await routeStrategyForCctp(baseParams); + + expect(result.name).toBe("cctp"); + }); + + it("should prioritize Linea exclusion over fast CCTP rules", async () => { + const strategyData: BridgeStrategyData = { + isUsdcToUsdc: true, + isUtilizationHigh: false, + isLineaSource: true, + isLargeDeposit: false, + isFastCctpEligible: true, + isInThreshold: false, + canFillInstantly: false, + }; + mockedGetBridgeStrategyData.mockResolvedValue(strategyData); + + const result = await routeStrategyForCctp(baseParams); + + expect(result.name).toBe("across"); + }); + + it("should prioritize fast CCTP rules over instant fill", async () => { + const strategyData: BridgeStrategyData = { + isUsdcToUsdc: true, + isUtilizationHigh: false, + isLineaSource: false, + isLargeDeposit: false, + isFastCctpEligible: true, + isInThreshold: false, + canFillInstantly: true, + }; + mockedGetBridgeStrategyData.mockResolvedValue(strategyData); + + const result = await routeStrategyForCctp(baseParams); + + expect(result.name).toBe("cctp"); + }); + }); + }); +}); From e6e168a575550acd11881c70a01ba377f91f5024 Mon Sep 17 00:00:00 2001 From: Melisa Guevara Date: Thu, 23 Oct 2025 14:30:48 +0200 Subject: [PATCH 11/18] chore: sync sponsored flows epic branch with master (#1917) Signed-off-by: Matt Rice Co-authored-by: Nikolas Haimerl <113891786+NikolasHaimerl@users.noreply.github.com> Co-authored-by: Dong-Ha Kim Co-authored-by: Matt Rice Co-authored-by: Claude --- CODEOWNERS | 10 +- api/_bridges/cctp/strategy.ts | 25 +- api/_bridges/sponsorship/cctp.ts | 87 ++++ api/_bridges/sponsorship/index.ts | 3 + api/_bridges/sponsorship/oft.ts | 69 +++ api/_bridges/sponsorship/utils/index.ts | 1 + api/_bridges/sponsorship/utils/signature.ts | 49 +++ api/_constants.ts | 3 + api/_utils.ts | 47 ++- api/coingecko.ts | 12 +- e2e-api/swap/fetch-approval.test.ts | 75 ++++ package.json | 6 +- scripts/chain-configs/blast/index.ts | 2 + scripts/generate-routes.ts | 48 ++- src/data/chains_1.json | 14 - ...6fA914353c44b2E33eBE05f21846F1048bEda.json | 396 ------------------ test/api/_bridges/cctp/strategy.test.ts | 169 +++++++- test/api/_bridges/sponsorship/cctp.test.ts | 63 +++ test/api/_bridges/sponsorship/oft.test.ts | 62 +++ test/api/_utils.test.ts | 129 ++++++ yarn.lock | 18 +- 21 files changed, 845 insertions(+), 443 deletions(-) create mode 100644 api/_bridges/sponsorship/cctp.ts create mode 100644 api/_bridges/sponsorship/index.ts create mode 100644 api/_bridges/sponsorship/oft.ts create mode 100644 api/_bridges/sponsorship/utils/index.ts create mode 100644 api/_bridges/sponsorship/utils/signature.ts create mode 100644 test/api/_bridges/sponsorship/cctp.test.ts create mode 100644 test/api/_bridges/sponsorship/oft.test.ts diff --git a/CODEOWNERS b/CODEOWNERS index eb6d79c42..0ee2a6332 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -7,14 +7,14 @@ # These owners will be the default owners for everything in # the repo unless a later match takes precedence. -* @mrice32 @nicholaspai @dohaki @james-a-morris @gsteenkamp89 @amateima @ashwinrava @melisaguevara @NikolasHaimerl +* @mrice32 @nicholaspai @dohaki @gsteenkamp89 @amateima @ashwinrava @melisaguevara @NikolasHaimerl # Serverless api -/api/ @mrice32 @nicholaspai @dohaki @james-a-morris @pxrl @melisaguevara @amateima @ashwinrava @NikolasHaimerl +/api/ @mrice32 @nicholaspai @dohaki @pxrl @melisaguevara @amateima @ashwinrava @NikolasHaimerl # FE -/src/ @mrice32 @nicholaspai @dohaki @james-a-morris @gsteenkamp89 @amateima +/src/ @mrice32 @nicholaspai @dohaki @gsteenkamp89 @amateima # Shared -/src/data/ @mrice32 @nicholaspai @dohaki @james-a-morris @pxrl @gsteenkamp89 @melisaguevara @amateima @ashwinrava @NikolasHaimerl -/scripts/ @mrice32 @nicholaspai @dohaki @james-a-morris @pxrl @gsteenkamp89 @melisaguevara @amateima @ashwinrava @NikolasHaimerl +/src/data/ @mrice32 @nicholaspai @dohaki @pxrl @gsteenkamp89 @melisaguevara @amateima @ashwinrava @NikolasHaimerl +/scripts/ @mrice32 @nicholaspai @dohaki @pxrl @gsteenkamp89 @melisaguevara @amateima @ashwinrava @NikolasHaimerl diff --git a/api/_bridges/cctp/strategy.ts b/api/_bridges/cctp/strategy.ts index 3fa39a16f..6ba063a9a 100644 --- a/api/_bridges/cctp/strategy.ts +++ b/api/_bridges/cctp/strategy.ts @@ -423,7 +423,7 @@ function getCctpBridgeFees( /** * Builds CCTP deposit transaction for EVM chains */ -async function _buildCctpTxForAllowanceHolderEvm(params: { +export async function _buildCctpTxForAllowanceHolderEvm(params: { crossSwapQuotes: CrossSwapQuotes; integratorId?: string; originChainId: number; @@ -475,9 +475,32 @@ async function _buildCctpTxForAllowanceHolderEvm(params: { hookData, }); } else { + // For transfers going to Solana, mintRecipient must be the recipient's token account, not their wallet + let mintRecipient = depositForBurnParams.mintRecipient; + + if (crossSwap.isDestinationSvm) { + const destinationChainId = crossSwap.outputToken.chainId; + // Derive the recipient's token account address for the destination token + const recipientWallet = sdk.utils.toAddressType( + depositForBurnParams.mintRecipient, + destinationChainId + ); + const destinationTokenMint = sdk.utils.toAddressType( + crossSwap.outputToken.address, + destinationChainId + ); + const recipientTokenAccount = + await sdk.arch.svm.getAssociatedTokenAddress( + recipientWallet.forceSvmAddress(), + destinationTokenMint.forceSvmAddress() + ); + mintRecipient = recipientTokenAccount; + } + // Standard CCTP route: use depositForBurn callData = encodeDepositForBurn({ ...depositForBurnParams, + mintRecipient, burnToken: burnTokenAddress, }); } diff --git a/api/_bridges/sponsorship/cctp.ts b/api/_bridges/sponsorship/cctp.ts new file mode 100644 index 000000000..7f9d33aa8 --- /dev/null +++ b/api/_bridges/sponsorship/cctp.ts @@ -0,0 +1,87 @@ +import { BigNumberish, utils } from "ethers"; +import { signDigestWithSponsor } from "./utils"; + +/** + * Represents the parameters for a sponsored CCTP quote. + * This structure is signed by the sponsor and validated by the destination contract. + * For more details on the struct, see the original contract: + * @see https://github.com/across-protocol/contracts/blob/1d645f90062e1e7acf5db995647264ddbca07da9/contracts/libraries/SponsoredCCTPQuoteLib.sol + */ +export interface SponsoredCCTPQuote { + sourceDomain: number; + destinationDomain: number; + mintRecipient: string; + amount: BigNumberish; + burnToken: string; + destinationCaller: string; + maxFee: BigNumberish; + minFinalityThreshold: number; + nonce: string; + deadline: BigNumberish; + maxBpsToSponsor: BigNumberish; + maxUserSlippageBps: BigNumberish; + finalRecipient: string; + finalToken: string; +} + +/** + * Creates a signature for a sponsored CCTP quote. + * The signing process follows the `validateSignature` function in the `SponsoredCCTPQuoteLib` contract. + * It involves creating two separate hashes of the quote data, combining them, and then signing the final hash. + * This is done to avoid stack too deep errors in the Solidity contract. + * @param {SponsoredCCTPQuote} quote The sponsored CCTP quote to sign. + * @returns {{ signature: string; typedDataHash: string }} An object containing the signature and the typed data hash that was signed. + * @see https://github.com/across-protocol/contracts/blob/1d645f90062e1e7acf5db995647264ddbca07da9/contracts/libraries/SponsoredCCTPQuoteLib.sol + */ +export const createCctpSignature = ( + quote: SponsoredCCTPQuote +): { signature: string; typedDataHash: string } => { + // The hashing is split into two parts to match the contract's implementation, + // which does this to prevent a "stack too deep" error in Solidity. + const hash1 = utils.keccak256( + utils.defaultAbiCoder.encode( + [ + "uint32", + "uint32", + "bytes32", + "uint256", + "bytes32", + "bytes32", + "uint256", + "uint32", + ], + [ + quote.sourceDomain, + quote.destinationDomain, + quote.mintRecipient, + quote.amount, + quote.burnToken, + quote.destinationCaller, + quote.maxFee, + quote.minFinalityThreshold, + ] + ) + ); + + const hash2 = utils.keccak256( + utils.defaultAbiCoder.encode( + ["bytes32", "uint256", "uint256", "uint256", "bytes32", "bytes32"], + [ + quote.nonce, + quote.deadline, + quote.maxBpsToSponsor, + quote.maxUserSlippageBps, + quote.finalRecipient, + quote.finalToken, + ] + ) + ); + + // The two hashes are then combined and hashed again to produce the final digest to be signed. + const typedDataHash = utils.keccak256( + utils.defaultAbiCoder.encode(["bytes32", "bytes32"], [hash1, hash2]) + ); + + const signature = signDigestWithSponsor(typedDataHash); + return { signature, typedDataHash }; +}; diff --git a/api/_bridges/sponsorship/index.ts b/api/_bridges/sponsorship/index.ts new file mode 100644 index 000000000..812ff06d5 --- /dev/null +++ b/api/_bridges/sponsorship/index.ts @@ -0,0 +1,3 @@ +export * from "./cctp"; +export * from "./oft"; +export * from "./utils"; diff --git a/api/_bridges/sponsorship/oft.ts b/api/_bridges/sponsorship/oft.ts new file mode 100644 index 000000000..261d37ee7 --- /dev/null +++ b/api/_bridges/sponsorship/oft.ts @@ -0,0 +1,69 @@ +import { BigNumberish, utils } from "ethers"; +import { signMessageWithSponsor } from "./utils"; + +/** + * Represents the signed parameters of a sponsored OFT quote. + * This structure is signed by the sponsor and validated by the destination contract. + * For more details on the struct, see the original contract: + * @see https://github.com/across-protocol/contracts/blob/7b37bbee4e8c71f2d3cffb28defe1c1e26583cb0/contracts/periphery/mintburn/sponsored-oft/Structs.sol + */ +export interface SignedQuoteParams { + srcEid: number; + dstEid: number; + destinationHandler: string; + amountLD: BigNumberish; + nonce: string; + deadline: BigNumberish; + maxBpsToSponsor: BigNumberish; + finalRecipient: string; + finalToken: string; + lzReceiveGasLimit: BigNumberish; + lzComposeGasLimit: BigNumberish; +} + +/** + * Creates a signature for a sponsored OFT quote. + * The signing process follows the `validateSignature` function in the `QuoteSignLib` contract. + * It involves ABI-encoding all the parameters, hashing the result, and then signing the EIP-191 prefixed hash. + * @param quote The sponsored OFT quote parameters to sign. + * @returns A promise that resolves to an object containing the signature and the hash that was signed. + * @see https://github.com/across-protocol/contracts/blob/7b37bbee4e8c71f2d3cffb28defe1c1e26583cb0/contracts/periphery/mintburn/sponsored-oft/QuoteSignLib.sol + */ +export const createOftSignature = async ( + quote: SignedQuoteParams +): Promise<{ signature: string; hash: string }> => { + // ABI-encode all parameters and hash the result to create the digest to be signed. + const encodedData = utils.defaultAbiCoder.encode( + [ + "uint32", + "uint32", + "bytes32", + "uint256", + "bytes32", + "uint256", + "uint256", + "bytes32", + "bytes32", + "uint256", + "uint256", + ], + [ + quote.srcEid, + quote.dstEid, + quote.destinationHandler, + quote.amountLD, + quote.nonce, + quote.deadline, + quote.maxBpsToSponsor, + quote.finalRecipient, + quote.finalToken, + quote.lzReceiveGasLimit, + quote.lzComposeGasLimit, + ] + ); + + const hash = utils.keccak256(encodedData); + // The OFT contract expects an EIP-191 compliant signature, so we sign the prefixed hash of the digest. + const signature = await signMessageWithSponsor(utils.arrayify(hash)); + return { signature, hash }; +}; diff --git a/api/_bridges/sponsorship/utils/index.ts b/api/_bridges/sponsorship/utils/index.ts new file mode 100644 index 000000000..a47dc3e25 --- /dev/null +++ b/api/_bridges/sponsorship/utils/index.ts @@ -0,0 +1 @@ +export * from "./signature"; diff --git a/api/_bridges/sponsorship/utils/signature.ts b/api/_bridges/sponsorship/utils/signature.ts new file mode 100644 index 000000000..28a52015b --- /dev/null +++ b/api/_bridges/sponsorship/utils/signature.ts @@ -0,0 +1,49 @@ +import { ethers, utils } from "ethers"; +import { getEnvs } from "../../../../api/_env"; + +let sponsorshipSigner: ethers.Wallet | undefined; + +/** + * Retrieves the sponsorship signer wallet instance. + * This function caches the signer instance in memory to avoid re-creating it on every call. + * The private key for the signer is fetched from environment variables. + * @returns {ethers.Wallet} The sponsorship signer wallet instance. + * @throws {Error} If the SPONSORSHIP_SIGNER_PRIVATE_KEY environment variable is not set. + */ +export const getSponsorshipSigner = (): ethers.Wallet => { + if (sponsorshipSigner) return sponsorshipSigner; + + const { SPONSORSHIP_SIGNER_PRIVATE_KEY } = getEnvs(); + if (!SPONSORSHIP_SIGNER_PRIVATE_KEY) { + throw new Error("SPONSORSHIP_SIGNER_PRIVATE_KEY is not set"); + } + sponsorshipSigner = new ethers.Wallet(SPONSORSHIP_SIGNER_PRIVATE_KEY); + return sponsorshipSigner; +}; + +/** + * Signs a raw digest with the sponsorship signer. + * This is used for CCTP signatures where the contract expects a signature on the unprefixed hash. + * @param {string} digest The raw digest to sign. + * @returns {string} The signature string. + */ +export const signDigestWithSponsor = (digest: string): string => { + const signer = getSponsorshipSigner(); + // We use `_signingKey().signDigest` to sign the raw digest, as this is what the CCTP contract expects. + // This is necessary because `signer.signMessage` would prefix the hash. + const signature = signer._signingKey().signDigest(digest); + return utils.joinSignature(signature); +}; + +/** + * Signs a message with the sponsorship signer. + * This is used for OFT signatures where the contract expects a signature on the EIP-191 prefixed hash. + * @param {Uint8Array} message The message to sign. + * @returns {Promise} The signature string. + */ +export const signMessageWithSponsor = ( + message: Uint8Array +): Promise => { + const signer = getSponsorshipSigner(); + return signer.signMessage(message); +}; diff --git a/api/_constants.ts b/api/_constants.ts index f05ed2e3d..936343b0e 100644 --- a/api/_constants.ts +++ b/api/_constants.ts @@ -323,6 +323,7 @@ export const SUPPORTED_CG_DERIVED_CURRENCIES = new Set([ "sol", "hype", "xpl", + "pol", ]); export const CG_CONTRACTS_DEFERRED_TO_ID = new Set([ TOKEN_SYMBOLS_MAP.AZERO.addresses[CHAIN_IDs.MAINNET], @@ -384,6 +385,8 @@ export const DEFAULT_FILL_DEADLINE_BUFFER_SECONDS = 3.25 * 60 * 60; // 3.25 hour export const CUSTOM_GAS_TOKENS = { ...sdkConstants.CUSTOM_GAS_TOKENS, + [CHAIN_IDs.POLYGON]: "POL", + [CHAIN_IDs.POLYGON_AMOY]: "POL", [CHAIN_IDs.LENS]: "GHO", [CHAIN_IDs.BSC]: "BNB", [CHAIN_IDs.HYPEREVM]: "HYPE", diff --git a/api/_utils.ts b/api/_utils.ts index 0d5db3086..1f7cd6fa7 100644 --- a/api/_utils.ts +++ b/api/_utils.ts @@ -12,6 +12,7 @@ import { import { BigNumber, BigNumberish, ethers, providers, utils } from "ethers"; import { parseUnits } from "ethers/lib/utils"; import axios, { AxiosError, AxiosRequestHeaders } from "axios"; +import { http } from "viem"; import { assert, @@ -454,6 +455,16 @@ export const getTokenByAddress = ( | undefined => { try { const parsedTokenAddress = toAddressType(tokenAddress, chainId ?? 1); + // If the address is the zero address, it means the user is looking for the native token info. + if (parsedTokenAddress.isZeroAddress()) { + if (chainId) { + const nativeTokenSymbol = getChainInfo(chainId).nativeToken; + return TOKEN_SYMBOLS_MAP[ + nativeTokenSymbol as keyof typeof TOKEN_SYMBOLS_MAP + ]; + } + return undefined; + } tokenAddress = parsedTokenAddress.toNative(); const matches = @@ -470,12 +481,23 @@ export const getTokenByAddress = ( } const ambiguousTokens = ["USDC", "USDT"]; - const isAmbiguous = - matches.length > 1 && - matches.some(([symbol]) => ambiguousTokens.includes(symbol)); - if (isAmbiguous && chainId === HUB_POOL_CHAIN_ID) { - const token = matches.find(([symbol]) => - ambiguousTokens.includes(symbol) + const wrappedTokens = [ + "WETH", + "WMATIC", + "WHYPE", + "TATARA-WBTC", + "WBNB", + "WGHO", + "WGRASS", + "WSOL", + "WXPL", + ]; + + if (matches.length > 1) { + // Prefer wrapped tokens or ambiguous tokens if multiple matches + const token = matches.find( + ([symbol]) => + wrappedTokens.includes(symbol) || ambiguousTokens.includes(symbol) ); if (token) { return token[1]; @@ -2328,6 +2350,12 @@ export async function getGasPriceEstimate( ); } } + // We use viem for gas price estimation on Linea and need to pass a custom transport + // with our configured RPCs if possible. + const viemTransport = + chainId === CHAIN_IDs.LINEA && getRpcUrlsFromConfigJson(chainId).length > 0 + ? http(getRpcUrlsFromConfigJson(chainId)[0]) + : undefined; return sdk.gasPriceOracle.getGasPriceEstimate( relayerFeeCalculatorQueries.provider as Parameters< typeof sdk.gasPriceOracle.getGasPriceEstimate @@ -2337,6 +2365,7 @@ export async function getGasPriceEstimate( unsignedTx: unsignedFillTx, baseFeeMultiplier, priorityFeeMultiplier, + transport: viemTransport, } ); } @@ -2429,11 +2458,7 @@ export async function getTokenInfo({ chainId, address }: TokenOptions): Promise< } // Resolve token info statically - const token = Object.values(TOKEN_SYMBOLS_MAP).find((token) => - Boolean( - token.addresses?.[chainId]?.toLowerCase() === address.toLowerCase() - ) - ); + const token = getTokenByAddress(address, chainId); if (token) { return { diff --git a/api/coingecko.ts b/api/coingecko.ts index 6901d6706..6d402deb9 100644 --- a/api/coingecko.ts +++ b/api/coingecko.ts @@ -56,6 +56,11 @@ const CG_CUSTOM_PLATFORM_ID_MAP = { [CHAIN_IDs.HYPERCORE]: "hyperliquid", }; +// Override the base token symbol for base tokens. +const BASE_TOKEN_SYMBOL_OVERRIDES: Record = { + MATIC: "POL", +}; + const handler = async ( { query }: TypedVercelRequest, response: VercelResponse @@ -140,10 +145,11 @@ const handler = async ( let quotePrice = 1.0; let quotePrecision = 18; if (isDerivedCurrency) { + const baseTokenSymbol = + BASE_TOKEN_SYMBOL_OVERRIDES[baseCurrency.toUpperCase()] ?? + baseCurrency.toUpperCase(); const baseToken = - TOKEN_SYMBOLS_MAP[ - baseCurrency.toUpperCase() as keyof typeof TOKEN_SYMBOLS_MAP - ]; + TOKEN_SYMBOLS_MAP[baseTokenSymbol as keyof typeof TOKEN_SYMBOLS_MAP]; const { price: baseTokenPrice } = await resolvePriceBySymbol({ symbol: baseToken.symbol, baseCurrency: "usd", diff --git a/e2e-api/swap/fetch-approval.test.ts b/e2e-api/swap/fetch-approval.test.ts index 79f4cbcff..0a2184de6 100644 --- a/e2e-api/swap/fetch-approval.test.ts +++ b/e2e-api/swap/fetch-approval.test.ts @@ -2,6 +2,7 @@ import { BigNumber, ethers } from "ethers"; import { CHAIN_IDs, TOKEN_SYMBOLS_MAP } from "@across-protocol/constants"; import { e2eConfig, axiosInstance } from "../utils/config"; +import { ENABLED_ROUTES } from "../../api/_utils"; const SWAP_API_BASE_URL = e2eConfig.swapApiBaseUrl; const SWAP_API_URL = `${SWAP_API_BASE_URL}/api/swap/approval`; @@ -164,4 +165,78 @@ describe("GET /swap/approval", () => { ); }); }); + + describe("Wrapped Tokens", () => { + jest.setTimeout(30000); + const tokensToTest = [ + "WETH", + "WBNB", + "WMATIC", + "WHYPE", + "TATARA-WBTC", + "WGHO", + "WGRASS", + "WSOL", + "WXPL", + ]; + + for (const tokenSymbol of tokensToTest) { + const route = ENABLED_ROUTES.routes.find( + (r) => + r.fromTokenSymbol === tokenSymbol || r.toTokenSymbol === tokenSymbol + ); + if (route) { + test(`should return ${tokenSymbol} for ${route.fromChain} to ${route.toChain}`, async () => { + const params = { + tradeType: "exactInput", + amount: "10000000000000000", + inputToken: route.fromTokenAddress, + outputToken: route.toTokenAddress, + originChainId: route.fromChain, + destinationChainId: route.toChain, + depositor: "0xB8034521BB1a343D556e5005680B3F17FFc74BeD", + recipient: "0xB8034521BB1a343D556e5005680B3F17FFc74BeD", + }; + const response = await axiosInstance.get(SWAP_API_URL, { + params, + }); + expect(response.status).toBe(200); + expect(response.data.inputToken.symbol).toBe(route.fromTokenSymbol); + expect(response.data.outputToken.symbol).toBe(route.toTokenSymbol); + }, 10000); + } + } + }); + + describe("Ambiguous Tokens", () => { + jest.setTimeout(100000); + const tokensToTest = ["USDC", "USDT"]; + + for (const tokenSymbol of tokensToTest) { + const route = ENABLED_ROUTES.routes.find( + (r) => + r.fromTokenSymbol === tokenSymbol || r.toTokenSymbol === tokenSymbol + ); + if (route) { + test(`should return ${tokenSymbol} for ${route.fromChain} to ${route.toChain}`, async () => { + const params = { + tradeType: "exactInput", + amount: "1000000", + inputToken: route.fromTokenAddress, + outputToken: route.toTokenAddress, + originChainId: route.fromChain, + destinationChainId: route.toChain, + depositor: "0xB8034521BB1a343D556e5005680B3F17FFc74BeD", + recipient: "0xB8034521BB1a343D556e5005680B3F17FFc74BeD", + }; + const response = await axiosInstance.get(SWAP_API_URL, { + params, + }); + expect(response.status).toBe(200); + expect(response.data.inputToken.symbol).toBe(route.fromTokenSymbol); + expect(response.data.outputToken.symbol).toBe(route.toTokenSymbol); + }, 10000); + } + } + }); }); diff --git a/package.json b/package.json index 6afae1d50..2653832a7 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,10 @@ "dependencies": { "@across-protocol/constants": "^3.1.80", "@across-protocol/contracts": "^4.1.11", + "@across-protocol/constants": "^3.1.82", + "@across-protocol/contracts": "^4.1.11", "@across-protocol/contracts-v4.1.1": "npm:@across-protocol/contracts@4.1.1", - "@across-protocol/sdk": "^4.3.67", + "@across-protocol/sdk": "^4.3.75", "@amplitude/analytics-browser": "^2.3.5", "@balancer-labs/sdk": "1.1.6-beta.16", "@coral-xyz/borsh": "^0.30.1", @@ -78,7 +80,7 @@ "test:e2e:headful": "yarn pretest:e2e && playwright test", "test:e2e:headless": "yarn pretest:e2e && HEADLESS=true playwright test", "test:e2e:headless:ui": "yarn pretest:e2e && HEADLESS=true playwright test --ui", - "test:e2e-api": "jest --config jest.api.config.cjs ./e2e-api", + "test:e2e-api": "yarn remote-config && jest --config jest.api.config.cjs ./e2e-api", "eject": "react-scripts eject", "format": "prettier --check src api e2e test", "format:fix": "prettier --write src api e2e test", diff --git a/scripts/chain-configs/blast/index.ts b/scripts/chain-configs/blast/index.ts index 8e8910188..f5d4e14d9 100644 --- a/scripts/chain-configs/blast/index.ts +++ b/scripts/chain-configs/blast/index.ts @@ -19,6 +19,8 @@ export default { publicRpcUrl: "https://rpc.blast.io", blockTimeSeconds: 2, tokens: ["WETH", "ETH", "USDB", "WBTC"], + // Only allow USDB and WBTC as input tokens (from this chain) + inputTokens: ["USDB", "WBTC"], enableCCTP: false, swapTokens: [], } as ChainConfig; diff --git a/scripts/generate-routes.ts b/scripts/generate-routes.ts index 305f10783..9c521b5b7 100644 --- a/scripts/generate-routes.ts +++ b/scripts/generate-routes.ts @@ -315,7 +315,9 @@ function transformChainConfigs( } const tokens = processTokenRoutes(chainConfig, toChainConfig); - const filteredTokens = tokens.filter( + + // First, filter based on the pre-existing disabledRoutes config (chain-specific route disabling) + const tokensAfterDisabledRoutesFilter = tokens.filter( (token) => !chainConfig.disabledRoutes?.find( (disabledRoute) => @@ -330,6 +332,50 @@ function transformChainConfigs( ) ); + // Then, filter based on inputTokens/outputTokens (directional token control) + // If inputTokens is specified, only allow those tokens from this chain + // If outputTokens is specified, only allow those tokens to the destination chain + const filteredTokens = tokensAfterDisabledRoutesFilter.filter((token) => { + const inputTokenSymbol = + typeof token === "string" ? token : token.inputTokenSymbol; + const outputTokenSymbol = + typeof token === "string" ? token : token.outputTokenSymbol; + + // Check if the input token is allowed from the origin chain + if (chainConfig.inputTokens) { + const inputTokenAllowed = chainConfig.inputTokens.some( + (allowedToken) => { + const symbol = + typeof allowedToken === "string" + ? allowedToken + : allowedToken.symbol; + return symbol === inputTokenSymbol; + } + ); + if (!inputTokenAllowed) { + return false; + } + } + + // Check if the output token is allowed to the destination chain + if (toChainConfig.outputTokens) { + const outputTokenAllowed = toChainConfig.outputTokens.some( + (allowedToken) => { + const symbol = + typeof allowedToken === "string" + ? allowedToken + : allowedToken.symbol; + return symbol === outputTokenSymbol; + } + ); + if (!outputTokenAllowed) { + return false; + } + } + + return true; + }); + // Handle USDC swap tokens const usdcSwapTokens = chainConfig.enableCCTP && hasBridgedUsdcOrVariant(fromChainId) diff --git a/src/data/chains_1.json b/src/data/chains_1.json index 2a3eec976..0b7953d58 100644 --- a/src/data/chains_1.json +++ b/src/data/chains_1.json @@ -1169,20 +1169,6 @@ "spokePool": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", "spokePoolBlock": 5574280, "inputTokens": [ - { - "address": "0x4300000000000000000000000000000000000004", - "symbol": "WETH", - "name": "Wrapped Ether", - "decimals": 18, - "logoUrl": "https://raw.githubusercontent.com/across-protocol/frontend/master/src/assets/token-logos/weth.svg" - }, - { - "address": "0x4300000000000000000000000000000000000004", - "symbol": "ETH", - "name": "Ether", - "decimals": 18, - "logoUrl": "https://raw.githubusercontent.com/across-protocol/frontend/master/src/assets/token-logos/eth.svg" - }, { "address": "0x4300000000000000000000000000000000000003", "symbol": "USDB", diff --git a/src/data/routes_1_0xc186fA914353c44b2E33eBE05f21846F1048bEda.json b/src/data/routes_1_0xc186fA914353c44b2E33eBE05f21846F1048bEda.json index 510ccf302..18cf70e75 100644 --- a/src/data/routes_1_0xc186fA914353c44b2E33eBE05f21846F1048bEda.json +++ b/src/data/routes_1_0xc186fA914353c44b2E33eBE05f21846F1048bEda.json @@ -8312,28 +8312,6 @@ "l1TokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "externalProjectId": "hyperliquid" }, - { - "fromChain": 81457, - "toChain": 1, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "WETH", - "toTokenSymbol": "WETH", - "isNative": false, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, - { - "fromChain": 81457, - "toChain": 1, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "ETH", - "toTokenSymbol": "ETH", - "isNative": true, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, { "fromChain": 81457, "toChain": 1, @@ -8356,28 +8334,6 @@ "isNative": false, "l1TokenAddress": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599" }, - { - "fromChain": 81457, - "toChain": 10, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x4200000000000000000000000000000000000006", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "WETH", - "toTokenSymbol": "WETH", - "isNative": false, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, - { - "fromChain": 81457, - "toChain": 10, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x4200000000000000000000000000000000000006", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "ETH", - "toTokenSymbol": "ETH", - "isNative": true, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, { "fromChain": 81457, "toChain": 10, @@ -8400,28 +8356,6 @@ "isNative": false, "l1TokenAddress": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599" }, - { - "fromChain": 81457, - "toChain": 137, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "WETH", - "toTokenSymbol": "WETH", - "isNative": false, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, - { - "fromChain": 81457, - "toChain": 137, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "ETH", - "toTokenSymbol": "ETH", - "isNative": true, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, { "fromChain": 81457, "toChain": 137, @@ -8444,28 +8378,6 @@ "isNative": false, "l1TokenAddress": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599" }, - { - "fromChain": 81457, - "toChain": 42161, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "WETH", - "toTokenSymbol": "WETH", - "isNative": false, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, - { - "fromChain": 81457, - "toChain": 42161, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "ETH", - "toTokenSymbol": "ETH", - "isNative": true, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, { "fromChain": 81457, "toChain": 42161, @@ -8488,28 +8400,6 @@ "isNative": false, "l1TokenAddress": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599" }, - { - "fromChain": 81457, - "toChain": 324, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x5AEa5775959fBC2557Cc8789bC1bf90A239D9a91", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "WETH", - "toTokenSymbol": "WETH", - "isNative": false, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, - { - "fromChain": 81457, - "toChain": 324, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x5AEa5775959fBC2557Cc8789bC1bf90A239D9a91", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "ETH", - "toTokenSymbol": "ETH", - "isNative": true, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, { "fromChain": 81457, "toChain": 324, @@ -8532,28 +8422,6 @@ "isNative": false, "l1TokenAddress": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599" }, - { - "fromChain": 81457, - "toChain": 8453, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x4200000000000000000000000000000000000006", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "WETH", - "toTokenSymbol": "WETH", - "isNative": false, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, - { - "fromChain": 81457, - "toChain": 8453, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x4200000000000000000000000000000000000006", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "ETH", - "toTokenSymbol": "ETH", - "isNative": true, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, { "fromChain": 81457, "toChain": 8453, @@ -8565,28 +8433,6 @@ "isNative": false, "l1TokenAddress": "0x6B175474E89094C44Da98b954EedeAC495271d0F" }, - { - "fromChain": 81457, - "toChain": 59144, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0xe5D7C2a44FfDDf6b295A15c148167daaAf5Cf34f", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "WETH", - "toTokenSymbol": "WETH", - "isNative": false, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, - { - "fromChain": 81457, - "toChain": 59144, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0xe5D7C2a44FfDDf6b295A15c148167daaAf5Cf34f", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "ETH", - "toTokenSymbol": "ETH", - "isNative": true, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, { "fromChain": 81457, "toChain": 59144, @@ -8609,28 +8455,6 @@ "isNative": false, "l1TokenAddress": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599" }, - { - "fromChain": 81457, - "toChain": 34443, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x4200000000000000000000000000000000000006", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "WETH", - "toTokenSymbol": "WETH", - "isNative": false, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, - { - "fromChain": 81457, - "toChain": 34443, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x4200000000000000000000000000000000000006", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "ETH", - "toTokenSymbol": "ETH", - "isNative": true, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, { "fromChain": 81457, "toChain": 34443, @@ -8642,28 +8466,6 @@ "isNative": false, "l1TokenAddress": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599" }, - { - "fromChain": 81457, - "toChain": 1135, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x4200000000000000000000000000000000000006", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "WETH", - "toTokenSymbol": "WETH", - "isNative": false, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, - { - "fromChain": 81457, - "toChain": 1135, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x4200000000000000000000000000000000000006", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "ETH", - "toTokenSymbol": "ETH", - "isNative": true, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, { "fromChain": 81457, "toChain": 1135, @@ -8675,28 +8477,6 @@ "isNative": false, "l1TokenAddress": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599" }, - { - "fromChain": 81457, - "toChain": 534352, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x5300000000000000000000000000000000000004", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "WETH", - "toTokenSymbol": "WETH", - "isNative": false, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, - { - "fromChain": 81457, - "toChain": 534352, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x5300000000000000000000000000000000000004", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "ETH", - "toTokenSymbol": "ETH", - "isNative": true, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, { "fromChain": 81457, "toChain": 534352, @@ -8708,72 +8488,6 @@ "isNative": false, "l1TokenAddress": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599" }, - { - "fromChain": 81457, - "toChain": 690, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x4200000000000000000000000000000000000006", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "WETH", - "toTokenSymbol": "WETH", - "isNative": false, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, - { - "fromChain": 81457, - "toChain": 690, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x4200000000000000000000000000000000000006", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "ETH", - "toTokenSymbol": "ETH", - "isNative": true, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, - { - "fromChain": 81457, - "toChain": 7777777, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x4200000000000000000000000000000000000006", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "WETH", - "toTokenSymbol": "WETH", - "isNative": false, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, - { - "fromChain": 81457, - "toChain": 7777777, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x4200000000000000000000000000000000000006", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "ETH", - "toTokenSymbol": "ETH", - "isNative": true, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, - { - "fromChain": 81457, - "toChain": 480, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x4200000000000000000000000000000000000006", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "WETH", - "toTokenSymbol": "WETH", - "isNative": false, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, - { - "fromChain": 81457, - "toChain": 480, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x4200000000000000000000000000000000000006", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "ETH", - "toTokenSymbol": "ETH", - "isNative": true, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, { "fromChain": 81457, "toChain": 480, @@ -8785,116 +8499,6 @@ "isNative": false, "l1TokenAddress": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599" }, - { - "fromChain": 81457, - "toChain": 57073, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x4200000000000000000000000000000000000006", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "WETH", - "toTokenSymbol": "WETH", - "isNative": false, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, - { - "fromChain": 81457, - "toChain": 57073, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x4200000000000000000000000000000000000006", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "ETH", - "toTokenSymbol": "ETH", - "isNative": true, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, - { - "fromChain": 81457, - "toChain": 1868, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x4200000000000000000000000000000000000006", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "WETH", - "toTokenSymbol": "WETH", - "isNative": false, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, - { - "fromChain": 81457, - "toChain": 1868, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x4200000000000000000000000000000000000006", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "ETH", - "toTokenSymbol": "ETH", - "isNative": true, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, - { - "fromChain": 81457, - "toChain": 130, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x4200000000000000000000000000000000000006", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "WETH", - "toTokenSymbol": "WETH", - "isNative": false, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, - { - "fromChain": 81457, - "toChain": 130, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x4200000000000000000000000000000000000006", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "ETH", - "toTokenSymbol": "ETH", - "isNative": true, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, - { - "fromChain": 81457, - "toChain": 232, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0xE5ecd226b3032910CEaa43ba92EE8232f8237553", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "WETH", - "toTokenSymbol": "WETH", - "isNative": false, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, - { - "fromChain": 81457, - "toChain": 232, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0xE5ecd226b3032910CEaa43ba92EE8232f8237553", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "ETH", - "toTokenSymbol": "ETH", - "isNative": true, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, - { - "fromChain": 81457, - "toChain": 56, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x2170Ed0880ac9A755fd29B2688956BD959F933F8", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "WETH", - "toTokenSymbol": "WETH", - "isNative": false, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, - { - "fromChain": 81457, - "toChain": 56, - "fromTokenAddress": "0x4300000000000000000000000000000000000004", - "toTokenAddress": "0x2170Ed0880ac9A755fd29B2688956BD959F933F8", - "fromSpokeAddress": "0x2D509190Ed0172ba588407D4c2df918F955Cc6E1", - "fromTokenSymbol": "ETH", - "toTokenSymbol": "ETH", - "isNative": true, - "l1TokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - }, { "fromChain": 1135, "toChain": 1, diff --git a/test/api/_bridges/cctp/strategy.test.ts b/test/api/_bridges/cctp/strategy.test.ts index c09120580..9d8b59f5b 100644 --- a/test/api/_bridges/cctp/strategy.test.ts +++ b/test/api/_bridges/cctp/strategy.test.ts @@ -1,13 +1,50 @@ import { BigNumber } from "ethers"; import axios from "axios"; +import * as sdk from "@across-protocol/sdk"; -import { getCctpBridgeStrategy } from "../../../../api/_bridges/cctp/strategy"; +import { + getCctpBridgeStrategy, + _buildCctpTxForAllowanceHolderEvm, +} from "../../../../api/_bridges/cctp/strategy"; import { CHAIN_IDs, TOKEN_SYMBOLS_MAP } from "../../../../api/_constants"; +import { CrossSwapQuotes } from "../../../../api/_dexes/types"; import * as hypercoreModule from "../../../../api/_hypercore"; +// Mock all dependencies jest.mock("axios"); jest.mock("../../../../api/_hypercore"); +// Mock SDK - only the SVM utilities we need +jest.mock("@across-protocol/sdk", () => { + const actual = jest.requireActual("@across-protocol/sdk"); + return { + ...actual, + arch: { + ...actual.arch, + svm: { + getAssociatedTokenAddress: jest.fn(), + }, + }, + }; +}); + +// Mock only the specific functions we need to mock +jest.mock("../../../../api/_bridges/cctp/utils/constants", () => { + const actual = jest.requireActual( + "../../../../api/_bridges/cctp/utils/constants" + ); + return { + ...actual, + encodeDepositForBurn: jest.fn( + (params) => `0xencoded-mintRecipient:${params.mintRecipient}` + ), + }; +}); + +jest.mock("../../../../api/_integrator-id", () => ({ + tagSwapApiMarker: jest.fn((data) => data), +})); + const mockedAxios = axios as jest.Mocked; describe("bridges/cctp/strategy", () => { @@ -227,4 +264,134 @@ describe("bridges/cctp/strategy", () => { expect(result.bridgeQuote.fees.bridgeFee.total).toEqual(expectedFee); }); }); + + describe("buildTxForAllowanceHolder() - EVM to Solana", () => { + it("should derive recipient token account when destination is Solana", async () => { + // Set up test data + const solanaRecipient = "FmMK62wrtWVb5SVoTZftSCGw3nEDA79hDbZNTRnC1R6t"; + const solanaTokenAccount = "5fE2vJ4f41PgDWyR2HFdKcYRuckFX8PwKH2kL7jPU6TC"; + + // Mock the getAssociatedTokenAddress function to return the test token account + (sdk.arch.svm.getAssociatedTokenAddress as jest.Mock).mockResolvedValue( + solanaTokenAccount + ); + + const quotes: CrossSwapQuotes = { + crossSwap: { + amount: BigNumber.from("1000000"), + inputToken: { + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.OPTIMISM], + decimals: 6, + symbol: "USDC", + chainId: CHAIN_IDs.OPTIMISM, + }, + outputToken: { + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.SOLANA], + decimals: 6, + symbol: "USDC", + chainId: CHAIN_IDs.SOLANA, + }, + depositor: "0x9A8f92a830A5cB89a3816e3D267CB7791c16b04D", + recipient: solanaRecipient, // Solana wallet address + slippageTolerance: 0.01, + type: "exactInput", + refundOnOrigin: false, + embeddedActions: [], + strictTradeType: true, + isDestinationSvm: true, // Destination is Solana + }, + bridgeQuote: {} as any, + contracts: {} as any, + }; + + const result = await _buildCctpTxForAllowanceHolderEvm({ + crossSwapQuotes: quotes, + originChainId: CHAIN_IDs.OPTIMISM, + destinationChainId: CHAIN_IDs.SOLANA, + intermediaryChainId: CHAIN_IDs.SOLANA, + tokenMessenger: "0x1234567890123456789012345678901234567890", + depositForBurnParams: { + amount: BigNumber.from("1000000"), + destinationDomain: 5, + mintRecipient: solanaRecipient, + destinationCaller: "0x0000000000000000000000000000000000000000", + maxFee: BigNumber.from(0), + minFinalityThreshold: 12, + }, + }); + + // Verify that getAssociatedTokenAddress was called with SVM address types + const recipientSvmAddress = sdk.utils + .toAddressType(solanaRecipient, CHAIN_IDs.SOLANA) + .forceSvmAddress(); + const usdcSvmAddress = sdk.utils + .toAddressType( + TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.SOLANA], + CHAIN_IDs.SOLANA + ) + .forceSvmAddress(); + + expect(sdk.arch.svm.getAssociatedTokenAddress).toHaveBeenCalledWith( + recipientSvmAddress, + usdcSvmAddress + ); + + // Verify the encoded data contains the token account address (not wallet address) + expect(result.data).toBe(`0xencoded-mintRecipient:${solanaTokenAccount}`); + }); + + it("should use recipient wallet address when destination is EVM", async () => { + const quotes: CrossSwapQuotes = { + crossSwap: { + amount: BigNumber.from("1000000"), + inputToken: { + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.OPTIMISM], + decimals: 6, + symbol: "USDC", + chainId: CHAIN_IDs.OPTIMISM, + }, + outputToken: { + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.BASE], + decimals: 6, + symbol: "USDC", + chainId: CHAIN_IDs.BASE, + }, + depositor: "0x9A8f92a830A5cB89a3816e3D267CB7791c16b04D", + recipient: "0x9A8f92a830A5cB89a3816e3D267CB7791c16b04D", + slippageTolerance: 0.01, + type: "exactInput", + refundOnOrigin: false, + embeddedActions: [], + strictTradeType: true, + isDestinationSvm: false, // Destination is EVM + }, + bridgeQuote: {} as any, + contracts: {} as any, + }; + + const result = await _buildCctpTxForAllowanceHolderEvm({ + crossSwapQuotes: quotes, + originChainId: CHAIN_IDs.OPTIMISM, + destinationChainId: CHAIN_IDs.BASE, + intermediaryChainId: CHAIN_IDs.BASE, + tokenMessenger: "0x1234567890123456789012345678901234567890", + depositForBurnParams: { + amount: BigNumber.from("1000000"), + destinationDomain: 6, + mintRecipient: "0x9A8f92a830A5cB89a3816e3D267CB7791c16b04D", + destinationCaller: "0x0000000000000000000000000000000000000000", + maxFee: BigNumber.from(0), + minFinalityThreshold: 12, + }, + }); + + // Verify getAssociatedTokenAddress was NOT called for EVM destinations + expect(sdk.arch.svm.getAssociatedTokenAddress).not.toHaveBeenCalled(); + + // Verify the encoded data contains the wallet address unchanged + expect(result.data).toBe( + "0xencoded-mintRecipient:0x9A8f92a830A5cB89a3816e3D267CB7791c16b04D" + ); + }); + }); }); diff --git a/test/api/_bridges/sponsorship/cctp.test.ts b/test/api/_bridges/sponsorship/cctp.test.ts new file mode 100644 index 000000000..3f5340f4e --- /dev/null +++ b/test/api/_bridges/sponsorship/cctp.test.ts @@ -0,0 +1,63 @@ +import { ethers, utils } from "ethers"; +import { recoverAddress } from "viem"; + +import { getEnvs } from "../../../../api/_env"; +import { + createCctpSignature, + SponsoredCCTPQuote, +} from "../../../../api/_bridges/sponsorship"; + +// Mock the environment variables to ensure tests are deterministic. +jest.mock("../../../../api/_env", () => ({ + getEnvs: jest.fn(), +})); + +// Create a random wallet for signing. This ensures that the tests are not dependent on a hardcoded private key. +const TEST_WALLET = ethers.Wallet.createRandom(); +const TEST_PRIVATE_KEY = TEST_WALLET.privateKey; + +// Helper function to generate a random 32-byte hex string, simulating a bytes32 address. +const randomAddress = () => + utils.hexZeroPad(ethers.Wallet.createRandom().address, 32); + +describe("CCTP Signature", () => { + beforeEach(() => { + // Before each test, mock the return value of getEnvs to provide our test private key. + (getEnvs as jest.Mock).mockReturnValue({ + SPONSORSHIP_SIGNER_PRIVATE_KEY: TEST_PRIVATE_KEY, + }); + }); + + it("should create a valid signature for a CCTP quote", async () => { + // Prepare a sample CCTP quote. + const quote: SponsoredCCTPQuote = { + sourceDomain: 1, + destinationDomain: 2, + mintRecipient: randomAddress(), + amount: "1000", + burnToken: randomAddress(), + destinationCaller: randomAddress(), + maxFee: "10", + minFinalityThreshold: 12, + nonce: utils.formatBytes32String("nonce"), + deadline: Math.floor(Date.now() / 1000) + 3600, + maxBpsToSponsor: 50, + maxUserSlippageBps: 10, + finalRecipient: randomAddress(), + finalToken: randomAddress(), + }; + + // Create the signature and get the hash that was signed. + const { signature, typedDataHash } = createCctpSignature(quote); + + // Recover the address from the signature and the hash. + // This simulates the on-chain validation by checking if the signature was created by the expected signer. + const recoveredAddress = await recoverAddress({ + hash: typedDataHash as `0x${string}`, + signature: signature as `0x${string}`, + }); + + // Assert that the recovered address matches the address of our test wallet. + expect(recoveredAddress).toEqual(TEST_WALLET.address); + }); +}); diff --git a/test/api/_bridges/sponsorship/oft.test.ts b/test/api/_bridges/sponsorship/oft.test.ts new file mode 100644 index 000000000..3f703a8fe --- /dev/null +++ b/test/api/_bridges/sponsorship/oft.test.ts @@ -0,0 +1,62 @@ +import { ethers, utils } from "ethers"; +import { recoverMessageAddress } from "viem"; +import { hexToBytes } from "viem"; + +import { getEnvs } from "../../../../api/_env"; +import { + createOftSignature, + SignedQuoteParams, +} from "../../../../api/_bridges/sponsorship"; + +// Mock the environment variables to ensure tests are deterministic. +jest.mock("../../../../api/_env", () => ({ + getEnvs: jest.fn(), +})); + +// Create a random wallet for signing. This ensures that the tests are not dependent on a hardcoded private key. +const TEST_WALLET = ethers.Wallet.createRandom(); +const TEST_PRIVATE_KEY = TEST_WALLET.privateKey; + +// Helper function to generate a random 32-byte hex string, simulating a bytes32 address. +const randomAddress = () => + utils.hexZeroPad(ethers.Wallet.createRandom().address, 32); + +describe("OFT Signature", () => { + beforeEach(() => { + // Before each test, mock the return value of getEnvs to provide our test private key. + (getEnvs as jest.Mock).mockReturnValue({ + SPONSORSHIP_SIGNER_PRIVATE_KEY: TEST_PRIVATE_KEY, + }); + }); + + it("should create a valid signature for an OFT quote", async () => { + // Prepare a sample OFT quote. + const quote: SignedQuoteParams = { + srcEid: 1, + dstEid: 2, + destinationHandler: randomAddress(), + amountLD: "1000", + nonce: utils.formatBytes32String("nonce"), + deadline: Math.floor(Date.now() / 1000) + 3600, + maxBpsToSponsor: 50, + finalRecipient: randomAddress(), + finalToken: randomAddress(), + lzReceiveGasLimit: "200000", + lzComposeGasLimit: "400000", + }; + + // Create the signature and get the hash that was signed. + const { signature, hash } = await createOftSignature(quote); + + // Recover the address from the signature and the hash. + // This simulates the on-chain validation by checking if the signature was created by the expected signer. + // The OFT contract expects an EIP-191 compliant signature, so we use `recoverMessageAddress`. + const recoveredAddress = await recoverMessageAddress({ + message: { raw: hexToBytes(hash as `0x${string}`) }, + signature: signature as `0x${string}`, + }); + + // Assert that the recovered address matches the address of our test wallet. + expect(recoveredAddress).toEqual(TEST_WALLET.address); + }); +}); diff --git a/test/api/_utils.test.ts b/test/api/_utils.test.ts index 6b2b43b42..df37ef2f7 100644 --- a/test/api/_utils.test.ts +++ b/test/api/_utils.test.ts @@ -7,6 +7,8 @@ import { validEvmAddress, validSvmAddress, validAddress, + getTokenByAddress, + getChainInfo, } from "../../api/_utils"; import { is } from "superstruct"; @@ -14,6 +16,11 @@ const svmAddress = "9E8PWXZRJa7vBRvGZDmLxSJ4iAMmB4BS7FYUruHvnCPz"; const evmAddress = "0x9A8f92a830A5cB89a3816e3D267CB7791c16b04D"; const junkAddress = "0xdeadbeef"; +jest.mock("../../api/_utils", () => ({ + ...jest.requireActual("../../api/_utils"), + getChainInfo: jest.fn(), +})); + describe("_utils", () => { describe("#getRouteDetails()", () => { test("should throw if token is unknown", () => { @@ -109,6 +116,128 @@ describe("_utils", () => { }); }); + describe("#getTokenByAddress()", () => { + // Iterate over all chain IDs to test the token resolution for each chain. + for (const chainId of Object.values(CHAIN_IDs)) { + if (typeof chainId !== "number") continue; + + const weth = TOKEN_SYMBOLS_MAP.WETH.addresses[chainId]; + const eth = TOKEN_SYMBOLS_MAP.ETH.addresses[chainId]; + + // Test case where WETH and ETH have the same address. + // In this case, we want to ensure that WETH is always returned to avoid ambiguity. + if (weth && eth && weth.toLowerCase() === eth.toLowerCase()) { + test(`should return WETH for chain ${chainId} when both ETH and WETH have the same address`, () => { + const token = getTokenByAddress(weth, chainId); + expect(token?.symbol).toBe("WETH"); + }); + } else { + // Test case where WETH and ETH have different addresses. + if (weth) { + test(`should return WETH for chain ${chainId}`, () => { + const token = getTokenByAddress(weth, chainId); + expect(token?.symbol).toBe("WETH"); + }); + } + if (eth) { + test(`should return ETH for chain ${chainId}`, () => { + const token = getTokenByAddress(eth, chainId); + expect(token?.symbol).toBe("ETH"); + }); + } + } + } + + it("should return undefined for an invalid address", () => { + const token = getTokenByAddress("0xInvalidAddress"); + expect(token).toBeUndefined(); + }); + + it("should return the native token for a zero address", () => { + const zeroAddress = constants.ZERO_ADDRESS; + const chainId = CHAIN_IDs.MAINNET; + const token = getTokenByAddress(zeroAddress, chainId); + expect(token).toBeDefined(); + expect(token?.symbol).toBe("ETH"); + }); + + it("should return undefined for a zero address without a chainId", () => { + const zeroAddress = constants.ZERO_ADDRESS; + const token = getTokenByAddress(zeroAddress); + expect(token).toBeUndefined(); + }); + + it("should return undefined for a zero address on a chain with no native token in map", () => { + const zeroAddress = constants.ZERO_ADDRESS; + const chainIdWithNoNativeToken = 99999; // A chain that doesn't exist + const token = getTokenByAddress(zeroAddress, chainIdWithNoNativeToken); + expect(token).toBeUndefined(); + }); + + it("should return undefined for a zero address if the native token is not in the token map", () => { + // This test mocks the CHAINS constant to simulate a chain with a native token that does not exist in the TOKEN_SYMBOLS_MAP. + jest.mock("../../api/_constants", () => ({ + ...jest.requireActual("../../api/_constants"), + CHAINS: { 99999: { nativeToken: "DUMMY" } }, + })); + + const zeroAddress = constants.ZERO_ADDRESS; + const chainId = 99999; + + const token = getTokenByAddress(zeroAddress, chainId); + expect(token).toBeUndefined(); + }); + + it("should correctly resolve ambiguous tokens like USDC", () => { + const usdcAddresses = TOKEN_SYMBOLS_MAP.USDC.addresses; + const mainnetChainId = CHAIN_IDs.MAINNET; + const mainnetUsdcAddress = usdcAddresses[mainnetChainId]; + + const token = getTokenByAddress(mainnetUsdcAddress, mainnetChainId); + expect(token).toBeDefined(); + expect(token?.symbol).toBe("USDC"); + + const usdtAddresses = TOKEN_SYMBOLS_MAP.USDT.addresses; + const mainnetUsdtAddress = usdtAddresses[mainnetChainId]; + const tokenUsdt = getTokenByAddress(mainnetUsdtAddress, mainnetChainId); + expect(tokenUsdt).toBeDefined(); + expect(tokenUsdt?.symbol).toBe("USDT"); + }); + + it("should handle wrapped and ambiguous tokens correctly from TOKEN_SYMBOL_MAP", () => { + const ambiguousTokens = ["USDC", "USDT"]; + const wrappedTokens = [ + "WETH", + "WMATIC", + "WHYPE", + "TATARA-WBTC", + "WBNB", + "WGHO", + "WGRASS", + "WSOL", + "WXPL", + ]; + const tokensToTest = [...ambiguousTokens, ...wrappedTokens]; + + for (const symbol of tokensToTest) { + const tokenInfo = + TOKEN_SYMBOLS_MAP[symbol as keyof typeof TOKEN_SYMBOLS_MAP]; + if (tokenInfo) { + for (const chainId in tokenInfo.addresses) { + const numericChainId = Number(chainId); + const address = + tokenInfo.addresses[ + numericChainId as keyof typeof tokenInfo.addresses + ]; + const token = getTokenByAddress(address, numericChainId); + expect(token).toBeDefined(); + expect(token?.symbol).toBe(symbol); + } + } + } + }); + }); + describe("#validateChainAndTokenParams()", () => { test("throw if 'destinationChainId' is not provided", () => { expect(() => validateChainAndTokenParams({})).toThrowError( diff --git a/yarn.lock b/yarn.lock index f9ea51352..1e5c8e74d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16,10 +16,10 @@ "@uma/common" "^2.17.0" hardhat "^2.9.3" -"@across-protocol/constants@^3.1.69", "@across-protocol/constants@^3.1.77", "@across-protocol/constants@^3.1.78", "@across-protocol/constants@^3.1.80": - version "3.1.80" - resolved "https://registry.yarnpkg.com/@across-protocol/constants/-/constants-3.1.80.tgz#a1515f9c8ca19a5a7c2e709da08c1d1f3ac01b28" - integrity sha512-/MtvKygLNoxTFAIOU6FmR4TeEeueL1hyoWit9BtL0RVyXZ1h3zvNr58TYxcoXIFF8Vb+yizyACX32b+bdk7fGg== +"@across-protocol/constants@^3.1.69", "@across-protocol/constants@^3.1.77", "@across-protocol/constants@^3.1.82": + version "3.1.82" + resolved "https://registry.yarnpkg.com/@across-protocol/constants/-/constants-3.1.82.tgz#963a12d481a141048c39f54a3c1949eabb588c0a" + integrity sha512-eYz/yMjMa+MMyZ8J4g5rUtQ9ybmwNj/K5PG7RfJQZeRejg3AzxjL0o+9TNm+5FoeWUrYl+h4Ju6CO7VAr6Gr8Q== "@across-protocol/contracts-v4.1.1@npm:@across-protocol/contracts@4.1.1": version "4.1.1" @@ -126,13 +126,13 @@ yargs "^17.7.2" zksync-web3 "^0.14.3" -"@across-protocol/sdk@^4.3.67": - version "4.3.67" - resolved "https://registry.yarnpkg.com/@across-protocol/sdk/-/sdk-4.3.67.tgz#a3b39ecd3334d16ea27bb5340baf391883a46bd6" - integrity sha512-XTDbi7Qm7mbxVAhTns8fZujsEJ2gzT0HQwXbKofbsJKwGXSW9z8MqsjghjagvvB4HqanY7EHrW0cWfSvu73hjA== +"@across-protocol/sdk@^4.3.75": + version "4.3.75" + resolved "https://registry.yarnpkg.com/@across-protocol/sdk/-/sdk-4.3.75.tgz#a51f1896ec4a96038df4877ef9d52a233a8f1a7e" + integrity sha512-y+HkFgxEjq/gNcWWam0aSPaG7xfXxYmW4SBbJrxaZhT78a9gcelBJtki1lZ5jYtaZjaSbiOh3sbA9Hk8bqjSlg== dependencies: "@across-protocol/across-token" "^1.0.0" - "@across-protocol/constants" "^3.1.78" + "@across-protocol/constants" "^3.1.82" "@across-protocol/contracts" "^4.1.9" "@coral-xyz/anchor" "^0.30.1" "@eth-optimism/sdk" "^3.3.1" From be0612e76d690f9bb3fae804c05d19a0a53d4341 Mon Sep 17 00:00:00 2001 From: Ashwin <213675439+ashwinrava@users.noreply.github.com> Date: Thu, 23 Oct 2025 08:52:34 -0400 Subject: [PATCH 12/18] feat: sponsorship bridge strategy routing (#1915) --- api/_bridges/cctp/utils/routing.ts | 32 +++--- api/_bridges/index.ts | 25 ++++- api/_bridges/sponsored/utils/eligibility.ts | 39 +++++++ api/_bridges/sponsored/utils/routing.ts | 112 +++++++++++++++++++ api/_bridges/types.ts | 11 ++ test/api/_bridges/cctp/utils/routing.test.ts | 42 +++---- 6 files changed, 217 insertions(+), 44 deletions(-) create mode 100644 api/_bridges/sponsored/utils/routing.ts diff --git a/api/_bridges/cctp/utils/routing.ts b/api/_bridges/cctp/utils/routing.ts index 93e521a13..f5667c65b 100644 --- a/api/_bridges/cctp/utils/routing.ts +++ b/api/_bridges/cctp/utils/routing.ts @@ -4,19 +4,15 @@ import { BridgeStrategy, BridgeStrategyData, BridgeStrategyDataParams, + RoutingRule, } from "../../types"; import { getBridgeStrategyData } from "../../utils"; import { getLogger } from "../../../_utils"; -type RoutingRule = { - name: string; - shouldApply: (data: NonNullable) => boolean; - getStrategy: () => BridgeStrategy; - reason: string; -}; +type CctpRoutingRule = RoutingRule>; // Priority-ordered routing rules for CCTP -const ROUTING_RULES: RoutingRule[] = [ +const CCTP_ROUTING_RULES: CctpRoutingRule[] = [ { name: "non-usdc-route", shouldApply: (data) => !data.isUsdcToUsdc, @@ -75,47 +71,49 @@ const ROUTING_RULES: RoutingRule[] = [ * Determines the optimal bridge strategy (CCTP vs Across) for a given route. * * @param params - Bridge strategy data parameters including tokens, amounts, and addresses - * @returns The selected bridge strategy + * @returns The selected bridge strategy, or null to pass to next routing function */ export async function routeStrategyForCctp( params: BridgeStrategyDataParams -): Promise { +): Promise { const logger = getLogger(); const bridgeStrategyData = await getBridgeStrategyData(params); if (!bridgeStrategyData) { logger.warn({ at: "routeStrategyForCctp", - message: "Failed to fetch bridge strategy data, using default", + message: "Failed to fetch bridge strategy data, passing to next router", inputToken: params.inputToken.symbol, outputToken: params.outputToken.symbol, }); - return getAcrossBridgeStrategy(); + return null; } - const applicableRule = ROUTING_RULES.find((rule) => + const applicableRule = CCTP_ROUTING_RULES.find((rule) => rule.shouldApply(bridgeStrategyData) ); if (!applicableRule) { - logger.error({ + logger.warn({ at: "routeStrategyForCctp", - message: "No routing rule matched, using Across fallback", + message: "No routing rule matched (unexpected), passing to next router", bridgeStrategyData, }); - return getAcrossBridgeStrategy(); + return null; } + const strategy = applicableRule.getStrategy(); + logger.debug({ at: "routeStrategyForCctp", message: "Bridge routing decision", rule: applicableRule.name, reason: applicableRule.reason, - strategy: applicableRule.getStrategy().name, + strategy: strategy?.name || "null", inputToken: params.inputToken.symbol, outputToken: params.outputToken.symbol, bridgeStrategyData, }); - return applicableRule.getStrategy(); + return strategy; } diff --git a/api/_bridges/index.ts b/api/_bridges/index.ts index 5db2cc188..459f1aa13 100644 --- a/api/_bridges/index.ts +++ b/api/_bridges/index.ts @@ -4,10 +4,12 @@ import { BridgeStrategiesConfig, BridgeStrategy, GetBridgeStrategyParams, + RouteStrategyFunction, } from "./types"; import { CHAIN_IDs } from "../_constants"; import { getCctpBridgeStrategy } from "./cctp/strategy"; import { routeStrategyForCctp } from "./cctp/utils/routing"; +import { routeStrategyForSponsorship } from "./sponsored/utils/routing"; export const bridgeStrategies: BridgeStrategiesConfig = { default: getAcrossBridgeStrategy(), @@ -47,6 +49,12 @@ export const routableBridgeStrategies = [ getCctpBridgeStrategy(), ]; +// Priority-ordered routing strategies +const ROUTING_STRATEGIES: RouteStrategyFunction[] = [ + routeStrategyForSponsorship, + routeStrategyForCctp, +]; + export async function getBridgeStrategy({ originChainId, destinationChainId, @@ -81,12 +89,9 @@ export async function getBridgeStrategy({ if (supportedBridgeStrategies.length === 1) { return supportedBridgeStrategies[0]; } - if ( - supportedBridgeStrategies.some( - (strategy) => strategy.name === getCctpBridgeStrategy().name - ) - ) { - return routeStrategyForCctp({ + + for (const routeStrategy of ROUTING_STRATEGIES) { + const strategy = await routeStrategy({ inputToken, outputToken, amount, @@ -94,7 +99,15 @@ export async function getBridgeStrategy({ recipient, depositor, }); + + if ( + strategy && + supportedBridgeStrategies.some((s) => s.name === strategy.name) + ) { + return strategy; + } } + return getAcrossBridgeStrategy(); } diff --git a/api/_bridges/sponsored/utils/eligibility.ts b/api/_bridges/sponsored/utils/eligibility.ts index e69de29bb..f72c2ceb9 100644 --- a/api/_bridges/sponsored/utils/eligibility.ts +++ b/api/_bridges/sponsored/utils/eligibility.ts @@ -0,0 +1,39 @@ +import { BigNumber } from "ethers"; +import { Token } from "../../../_dexes/types"; + +export type SponsorshipEligibilityParams = { + inputToken: Token; + outputToken: Token; + amount: BigNumber; + amountType: "exactInput" | "exactOutput" | "minOutput"; + recipient?: string; + depositor: string; +}; + +export type SponsorshipEligibilityData = { + isWithinGlobalDailyLimit: boolean; + isWithinUserDailyLimit: boolean; + hasVaultBalance: boolean; + isSlippageAcceptable: boolean; + isAccountCreationValid: boolean; +}; + +/** + * Checks if a bridge transaction is eligible for sponsorship. + * + * Validates: + * - Global daily limit + * - Per-user daily limit + * - Vault balance + * - Swap slippage for destination swaps + * - Account creation status + * + * @param params - Parameters for eligibility check + * @returns Eligibility data or undefined if check fails + */ +export async function getSponsorshipEligibilityData( + params: SponsorshipEligibilityParams +): Promise { + // TODO: Implement actual checks + return undefined; +} diff --git a/api/_bridges/sponsored/utils/routing.ts b/api/_bridges/sponsored/utils/routing.ts new file mode 100644 index 000000000..bb952ac5f --- /dev/null +++ b/api/_bridges/sponsored/utils/routing.ts @@ -0,0 +1,112 @@ +import { getSponsoredBridgeStrategy } from "../strategy"; +import { + BridgeStrategy, + BridgeStrategyDataParams, + RoutingRule, +} from "../../types"; +import { + getSponsorshipEligibilityData, + SponsorshipEligibilityData, +} from "./eligibility"; +import { getLogger } from "../../../_utils"; + +type SponsorshipRoutingRule = RoutingRule< + NonNullable +>; + +// Priority-ordered routing rules for sponsorship +const SPONSORSHIP_ROUTING_RULES: SponsorshipRoutingRule[] = [ + { + name: "global-limit-exceeded", + shouldApply: (data) => !data.isWithinGlobalDailyLimit, + getStrategy: () => null as any, // Indicates ineligible + reason: "Global daily sponsorship limit exceeded", + }, + { + name: "user-limit-exceeded", + shouldApply: (data) => !data.isWithinUserDailyLimit, + getStrategy: () => null as any, // Indicates ineligible + reason: "User daily sponsorship limit exceeded", + }, + { + name: "insufficient-vault-balance", + shouldApply: (data) => !data.hasVaultBalance, + getStrategy: () => null as any, // Indicates ineligible + reason: "Insufficient vault balance for sponsorship", + }, + { + name: "slippage-too-high", + shouldApply: (data) => !data.isSlippageAcceptable, + getStrategy: () => null as any, // Indicates ineligible + reason: "Destination swap slippage exceeds acceptable bounds", + }, + { + name: "invalid-account-creation", + shouldApply: (data) => !data.isAccountCreationValid, + getStrategy: () => null as any, // Indicates ineligible + reason: "Account creation requirements not met", + }, + { + name: "eligible-for-sponsorship", + shouldApply: (data) => + data.isWithinGlobalDailyLimit && + data.isWithinUserDailyLimit && + data.hasVaultBalance && + data.isSlippageAcceptable && + data.isAccountCreationValid, + getStrategy: getSponsoredBridgeStrategy, + reason: "All sponsorship eligibility criteria met", + }, +]; + +/** + * Determines if a bridge transaction is eligible for sponsorship. + * + * @param params - Bridge strategy data parameters including tokens, amounts, and addresses + * @returns Sponsored bridge strategy if eligible, null otherwise + */ +export async function routeStrategyForSponsorship( + params: BridgeStrategyDataParams +): Promise { + const logger = getLogger(); + const eligibilityData = await getSponsorshipEligibilityData(params); + + if (!eligibilityData) { + logger.warn({ + at: "routeStrategyForSponsorship", + message: "Failed to fetch sponsorship eligibility data", + inputToken: params.inputToken.symbol, + outputToken: params.outputToken.symbol, + }); + return null; + } + + const applicableRule = SPONSORSHIP_ROUTING_RULES.find((rule) => + rule.shouldApply(eligibilityData) + ); + + if (!applicableRule) { + logger.warn({ + at: "routeStrategyForSponsorship", + message: + "No sponsorship rule matched (unexpected), passing to next router", + eligibilityData, + }); + return null; + } + + const strategy = applicableRule.getStrategy(); + + logger.debug({ + at: "routeStrategyForSponsorship", + message: "Sponsorship eligibility decision", + rule: applicableRule.name, + reason: applicableRule.reason, + strategy: strategy?.name || "null", + inputToken: params.inputToken.symbol, + outputToken: params.outputToken.symbol, + eligibilityData, + }); + + return strategy; +} diff --git a/api/_bridges/types.ts b/api/_bridges/types.ts index f7ae6ba31..eb150a658 100644 --- a/api/_bridges/types.ts +++ b/api/_bridges/types.ts @@ -134,3 +134,14 @@ export type GetBridgeStrategyParams = { destinationChainId: number; routingPreference?: string; } & BridgeStrategyDataParams; + +export type RoutingRule = { + name: string; + shouldApply: (data: TEligibilityData) => boolean; + getStrategy: () => BridgeStrategy | null; + reason: string; +}; + +export type RouteStrategyFunction = ( + params: BridgeStrategyDataParams +) => Promise; diff --git a/test/api/_bridges/cctp/utils/routing.test.ts b/test/api/_bridges/cctp/utils/routing.test.ts index a413603f5..9711cea86 100644 --- a/test/api/_bridges/cctp/utils/routing.test.ts +++ b/test/api/_bridges/cctp/utils/routing.test.ts @@ -54,7 +54,7 @@ describe("api/_bridges/cctp/utils/routing", () => { const result = await routeStrategyForCctp(baseParams); - expect(result.name).toBe("across"); + expect(result?.name).toBe("across"); }); it("should prioritize non-USDC rule over high utilization", async () => { @@ -71,7 +71,7 @@ describe("api/_bridges/cctp/utils/routing", () => { const result = await routeStrategyForCctp(baseParams); - expect(result.name).toBe("across"); + expect(result?.name).toBe("across"); }); }); @@ -90,7 +90,7 @@ describe("api/_bridges/cctp/utils/routing", () => { const result = await routeStrategyForCctp(baseParams); - expect(result.name).toBe("cctp"); + expect(result?.name).toBe("cctp"); }); it("should prioritize high utilization over Linea exclusion", async () => { @@ -107,7 +107,7 @@ describe("api/_bridges/cctp/utils/routing", () => { const result = await routeStrategyForCctp(baseParams); - expect(result.name).toBe("cctp"); + expect(result?.name).toBe("cctp"); }); }); @@ -126,7 +126,7 @@ describe("api/_bridges/cctp/utils/routing", () => { const result = await routeStrategyForCctp(baseParams); - expect(result.name).toBe("across"); + expect(result?.name).toBe("across"); }); }); @@ -145,7 +145,7 @@ describe("api/_bridges/cctp/utils/routing", () => { const result = await routeStrategyForCctp(baseParams); - expect(result.name).toBe("cctp"); + expect(result?.name).toBe("cctp"); }); it("should not apply for deposits within threshold", async () => { @@ -162,7 +162,7 @@ describe("api/_bridges/cctp/utils/routing", () => { const result = await routeStrategyForCctp(baseParams); - expect(result.name).toBe("across"); + expect(result?.name).toBe("across"); }); it("should not apply for large deposits", async () => { @@ -179,7 +179,7 @@ describe("api/_bridges/cctp/utils/routing", () => { const result = await routeStrategyForCctp(baseParams); - expect(result.name).toBe("across"); + expect(result?.name).toBe("across"); }); }); @@ -198,7 +198,7 @@ describe("api/_bridges/cctp/utils/routing", () => { const result = await routeStrategyForCctp(baseParams); - expect(result.name).toBe("across"); + expect(result?.name).toBe("across"); }); it("should return Across for very large deposits (>$1M) on fast CCTP chains", async () => { @@ -215,7 +215,7 @@ describe("api/_bridges/cctp/utils/routing", () => { const result = await routeStrategyForCctp(baseParams); - expect(result.name).toBe("across"); + expect(result?.name).toBe("across"); }); }); @@ -234,7 +234,7 @@ describe("api/_bridges/cctp/utils/routing", () => { const result = await routeStrategyForCctp(baseParams); - expect(result.name).toBe("across"); + expect(result?.name).toBe("across"); }); }); @@ -253,7 +253,7 @@ describe("api/_bridges/cctp/utils/routing", () => { const result = await routeStrategyForCctp(baseParams); - expect(result.name).toBe("across"); + expect(result?.name).toBe("across"); }); }); @@ -272,25 +272,25 @@ describe("api/_bridges/cctp/utils/routing", () => { const result = await routeStrategyForCctp(baseParams); - expect(result.name).toBe("cctp"); + expect(result?.name).toBe("cctp"); }); }); describe("Edge cases and fallbacks", () => { - it("should return Across when bridge strategy data is undefined", async () => { + it("should return null when bridge strategy data is undefined", async () => { mockedGetBridgeStrategyData.mockResolvedValue(undefined); const result = await routeStrategyForCctp(baseParams); - expect(result.name).toBe("across"); + expect(result).toBeNull(); }); - it("should handle getBridgeStrategyData errors by returning undefined", async () => { + it("should return null when getBridgeStrategyData errors", async () => { mockedGetBridgeStrategyData.mockResolvedValue(undefined); const result = await routeStrategyForCctp(baseParams); - expect(result.name).toBe("across"); + expect(result).toBeNull(); }); }); @@ -309,7 +309,7 @@ describe("api/_bridges/cctp/utils/routing", () => { const result = await routeStrategyForCctp(baseParams); - expect(result.name).toBe("across"); + expect(result?.name).toBe("across"); }); it("should prioritize high utilization over lower priority rules", async () => { @@ -326,7 +326,7 @@ describe("api/_bridges/cctp/utils/routing", () => { const result = await routeStrategyForCctp(baseParams); - expect(result.name).toBe("cctp"); + expect(result?.name).toBe("cctp"); }); it("should prioritize Linea exclusion over fast CCTP rules", async () => { @@ -343,7 +343,7 @@ describe("api/_bridges/cctp/utils/routing", () => { const result = await routeStrategyForCctp(baseParams); - expect(result.name).toBe("across"); + expect(result?.name).toBe("across"); }); it("should prioritize fast CCTP rules over instant fill", async () => { @@ -360,7 +360,7 @@ describe("api/_bridges/cctp/utils/routing", () => { const result = await routeStrategyForCctp(baseParams); - expect(result.name).toBe("cctp"); + expect(result?.name).toBe("cctp"); }); }); }); From 76918182726f2a71a42d1f7cfdd8a1fec5c6c019 Mon Sep 17 00:00:00 2001 From: Melisa Guevara Date: Thu, 23 Oct 2025 19:08:26 +0200 Subject: [PATCH 13/18] fix: duplicated packages (#1919) --- package.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/package.json b/package.json index 2653832a7..34807acc1 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,6 @@ "private": true, "license": "AGPL-3.0-only", "dependencies": { - "@across-protocol/constants": "^3.1.80", - "@across-protocol/contracts": "^4.1.11", "@across-protocol/constants": "^3.1.82", "@across-protocol/contracts": "^4.1.11", "@across-protocol/contracts-v4.1.1": "npm:@across-protocol/contracts@4.1.1", From 3a9f10c015436b339e314b05b42bc89244dfbf71 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Mon, 27 Oct 2025 04:22:03 +0100 Subject: [PATCH 14/18] feat: util for simulating market order on HL (#1904) --- api/_hypercore.ts | 255 ++++++++++++++++++++++++ test/api/_hypercore.test.ts | 375 ++++++++++++++++++++++++++++++++++++ 2 files changed, 630 insertions(+) create mode 100644 test/api/_hypercore.test.ts diff --git a/api/_hypercore.ts b/api/_hypercore.ts index c68b40156..be940913d 100644 --- a/api/_hypercore.ts +++ b/api/_hypercore.ts @@ -1,8 +1,18 @@ import { BigNumber, ethers } from "ethers"; +import axios from "axios"; import { getProvider } from "./_providers"; import { CHAIN_IDs } from "./_constants"; +const HYPERLIQUID_API_BASE_URL = "https://api.hyperliquid.xyz"; + +// Maps / to the coin identifier to be used to +// retrieve the L2 order book for a given pair via the Hyperliquid API. +// See: https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint#perpetuals-vs-spot +const L2_ORDER_BOOK_COIN_MAP: Record = { + "USDH/USDC": "@230", +}; + // Contract used to query Hypercore balances from EVM export const CORE_BALANCE_SYSTEM_PRECOMPILE = "0x0000000000000000000000000000000000000801"; @@ -117,3 +127,248 @@ export async function accountExistsOnHyperCore(params: { ); return Boolean(decodedQueryResult[0]); } + +// https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint#l2-book-snapshot +export async function getL2OrderBookForPair(params: { + tokenInSymbol: string; + tokenOutSymbol: string; +}) { + const { tokenInSymbol, tokenOutSymbol } = params; + + // Try both directions since the pair might be stored either way + let coin = + L2_ORDER_BOOK_COIN_MAP[`${tokenInSymbol}/${tokenOutSymbol}`] || + L2_ORDER_BOOK_COIN_MAP[`${tokenOutSymbol}/${tokenInSymbol}`]; + + if (!coin) { + throw new Error( + `No L2 order book coin found for pair ${tokenInSymbol}/${tokenOutSymbol}` + ); + } + + const response = await axios.post<{ + coin: string; + time: number; + levels: [ + { px: string; sz: string; n: number }[], // bids sorted by price descending + { px: string; sz: string; n: number }[], // asks sorted by price ascending + ]; + }>(`${HYPERLIQUID_API_BASE_URL}/info`, { + type: "l2Book", + coin, + }); + + if (!response.data) { + throw new Error( + `Hyperliquid API: Unexpected L2OrderBook value '${response.data}'` + ); + } + + if (response.data?.levels.length < 2) { + throw new Error("Hyperliquid API: Unexpected L2OrderBook 'levels' length"); + } + + return response.data; +} + +export type MarketOrderSimulationResult = { + averageExecutionPrice: string; // Human-readable price + inputAmount: BigNumber; + outputAmount: BigNumber; + slippagePercent: number; + bestPrice: string; // Best available price (first level) + levelsConsumed: number; + fullyFilled: boolean; +}; + +/** + * Simulates a market order by walking through the order book levels. + * Calculates execution price, slippage, and output amounts. + * + * @param tokenIn - Token being sold + * @param tokenOut - Token being bought + * @param inputAmount - Amount of input token to sell (as BigNumber) + * @returns Simulation result with execution details and slippage + * + * @example + * // Simulate selling 1000 USDC for USDH + * const result = await simulateMarketOrder({ + * tokenIn: { + * symbol: "USDC", + * decimals: 8, + * }, + * tokenOut: { + * symbol: "USDH", + * decimals: 8, + * }, + * inputAmount: ethers.utils.parseUnits("1000", 8), + * }); + */ +export async function simulateMarketOrder(params: { + tokenIn: { + symbol: string; + decimals: number; + }; + tokenOut: { + symbol: string; + decimals: number; + }; + inputAmount: BigNumber; +}): Promise { + const { tokenIn, tokenOut, inputAmount } = params; + + const orderBook = await getL2OrderBookForPair({ + tokenInSymbol: tokenIn.symbol, + tokenOutSymbol: tokenOut.symbol, + }); + + // Determine which side of the order book to use + // We need to figure out the pair direction from L2_ORDER_BOOK_COIN_MAP + const pairKey = `${tokenIn.symbol}/${tokenOut.symbol}`; + const reversePairKey = `${tokenOut.symbol}/${tokenIn.symbol}`; + + let baseCurrency = ""; + + if (L2_ORDER_BOOK_COIN_MAP[pairKey]) { + // Normal direction: tokenIn/tokenOut exists in map + baseCurrency = tokenIn.symbol; + } else if (L2_ORDER_BOOK_COIN_MAP[reversePairKey]) { + // Reverse direction: tokenOut/tokenIn exists in map + baseCurrency = tokenOut.symbol; + } else { + throw new Error( + `No L2 order book key configured for pair ${tokenIn.symbol}/${tokenOut.symbol}` + ); + } + + // Determine which side to use: + // - If buying base (quote → base): use asks + // - If selling base (base → quote): use bids + const isBuyingBase = tokenOut.symbol === baseCurrency; + const levels = isBuyingBase ? orderBook.levels[1] : orderBook.levels[0]; // asks : bids + + if (levels.length === 0) { + throw new Error( + `No liquidity available for ${tokenIn.symbol}/${tokenOut.symbol}` + ); + } + + // Get best price for slippage calculation + const bestPrice = levels[0].px; + + // Walk through order book levels + let remainingInput = inputAmount; + let totalOutput = BigNumber.from(0); + let levelsConsumed = 0; + + for (const level of levels) { + if (remainingInput.lte(0)) break; + + levelsConsumed++; + + // Prices are returned by the API in a parsed format, e.g. 0.987 USDC + const price = ethers.utils.parseUnits(level.px, tokenOut.decimals); + // Level size is returned by the API in a parsed format, e.g. 1000 USDC + const levelSize = ethers.utils.parseUnits(level.sz, tokenIn.decimals); + + if (isBuyingBase) { + // Buying base with quote + // We have quote currency (input) and want base currency (output) + // price = quote per base, so base amount = quote amount / price + + // Calculate how much base currency is available at this level + const baseAvailable = levelSize; + + // Calculate how much quote we need to buy this base + const quoteNeeded = baseAvailable + .mul(price) + .div(ethers.utils.parseUnits("1", tokenOut.decimals)); + + if (remainingInput.gte(quoteNeeded)) { + // We can consume this entire level + totalOutput = totalOutput.add(baseAvailable); + remainingInput = remainingInput.sub(quoteNeeded); + } else { + // Partial fill - only consume part of this level + const baseAmount = remainingInput + .mul(ethers.utils.parseUnits("1", tokenOut.decimals)) + .div(price); + totalOutput = totalOutput.add(baseAmount); + remainingInput = BigNumber.from(0); + } + } else { + // Selling base for quote + // We have base currency (input) and want quote currency (output) + // price = quote per base, so quote amount = base amount * price + + // Level size represents how much base can be sold at this price + const baseAvailable = levelSize; + + if (remainingInput.gte(baseAvailable)) { + // We can consume this entire level + const quoteAmount = baseAvailable + .mul(price) + .div(ethers.utils.parseUnits("1", tokenIn.decimals)); + totalOutput = totalOutput.add(quoteAmount); + remainingInput = remainingInput.sub(baseAvailable); + } else { + // Partial fill + const quoteAmount = remainingInput + .mul(price) + .div(ethers.utils.parseUnits("1", tokenIn.decimals)); + totalOutput = totalOutput.add(quoteAmount); + remainingInput = BigNumber.from(0); + } + } + } + + const fullyFilled = remainingInput.eq(0); + const filledInputAmount = inputAmount.sub(remainingInput); + + // Calculate average execution price + // Price should be in same format as order book: quote per base + let averageExecutionPrice = "0"; + if (filledInputAmount.gt(0) && totalOutput.gt(0)) { + // Calculate with proper decimal handling + const outputFormatted = parseFloat( + ethers.utils.formatUnits(totalOutput, tokenOut.decimals) + ); + const inputFormatted = parseFloat( + ethers.utils.formatUnits(filledInputAmount, tokenIn.decimals) + ); + + // When buying base (input=quote, output=base): price = input/output (quote per base) + // When selling base (input=base, output=quote): price = output/input (quote per base) + if (isBuyingBase) { + averageExecutionPrice = (inputFormatted / outputFormatted).toString(); + } else { + averageExecutionPrice = (outputFormatted / inputFormatted).toString(); + } + } + + // Calculate slippage percentage + // slippage = ((avgPrice - bestPrice) / bestPrice) * 100 + let slippagePercent = 0; + if (parseFloat(averageExecutionPrice) > 0 && parseFloat(bestPrice) > 0) { + const avgPriceNum = parseFloat(averageExecutionPrice); + const bestPriceNum = parseFloat(bestPrice); + + if (isBuyingBase) { + // When buying, higher price is worse + slippagePercent = ((avgPriceNum - bestPriceNum) / bestPriceNum) * 100; + } else { + // When selling, lower price is worse + slippagePercent = ((bestPriceNum - avgPriceNum) / bestPriceNum) * 100; + } + } + + return { + averageExecutionPrice, + inputAmount: filledInputAmount, + outputAmount: totalOutput, + slippagePercent, + bestPrice, + levelsConsumed, + fullyFilled, + }; +} diff --git a/test/api/_hypercore.test.ts b/test/api/_hypercore.test.ts new file mode 100644 index 000000000..6d1edd954 --- /dev/null +++ b/test/api/_hypercore.test.ts @@ -0,0 +1,375 @@ +import { ethers } from "ethers"; +import axios from "axios"; + +import { + getL2OrderBookForPair, + simulateMarketOrder, +} from "../../api/_hypercore"; + +jest.mock("axios"); + +const mockedAxios = axios as jest.Mocked; + +type MockOrderBookData = Awaited>; + +describe("api/_hypercore.ts", () => { + const usdc = { + symbol: "USDC", + decimals: 8, + }; + const usdh = { + symbol: "USDH", + decimals: 8, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("#simulateMarketOrder()", () => { + const mockOrderBookData: MockOrderBookData = { + coin: "@230", + time: 1760575824177, + levels: [ + [ + { + px: "0.99979", + sz: "29350.7", + n: 2, + }, + { + px: "0.99978", + sz: "101825.22", + n: 5, + }, + { + px: "0.99977", + sz: "32000.0", + n: 1, + }, + { + px: "0.99976", + sz: "32000.0", + n: 1, + }, + { + px: "0.99974", + sz: "500.0", + n: 1, + }, + ], + [ + { + px: "0.99983", + sz: "2104.57", + n: 5, + }, + { + px: "0.99987", + sz: "32000.0", + n: 1, + }, + { + px: "0.99988", + sz: "32000.0", + n: 1, + }, + { + px: "0.99989", + sz: "32000.0", + n: 1, + }, + { + px: "0.9999", + sz: "32000.0", + n: 1, + }, + { + px: "1.0", + sz: "679285.61", + n: 4, + }, + { + px: "1.0001", + sz: "365649.27", + n: 55, + }, + ], + ], + }; + + test("should simulate buying USDH with USDC (small order)", async () => { + mockedAxios.post.mockResolvedValue({ data: mockOrderBookData }); + + const result = await simulateMarketOrder({ + tokenIn: usdc, + tokenOut: usdh, + inputAmount: ethers.utils.parseUnits("1000", usdc.decimals), + }); + + // At best price of 0.99983, 1000 USDC should buy approximately 1000.17 USDH + expect(result.fullyFilled).toBe(true); + expect(result.levelsConsumed).toBe(1); + expect(result.bestPrice).toBe("0.99983"); + + // Output should be close to 1000 / 0.99983 ≈ 1000.17 + const outputAmount = parseFloat( + ethers.utils.formatUnits(result.outputAmount, usdh.decimals) + ); + expect(outputAmount).toBeGreaterThan(1000); + expect(outputAmount).toBeLessThan(1001); + + // Slippage should be minimal for small order + expect(result.slippagePercent).toBeGreaterThanOrEqual(0); + expect(result.slippagePercent).toBeLessThan(0.05); + }); + + test("should simulate buying USDH with USDC (large order consuming multiple levels)", async () => { + mockedAxios.post.mockResolvedValue({ data: mockOrderBookData }); + + const result = await simulateMarketOrder({ + tokenIn: usdc, + tokenOut: usdh, + inputAmount: ethers.utils.parseUnits("100000", usdc.decimals), + }); + + expect(result.fullyFilled).toBe(true); + expect(result.levelsConsumed).toBeGreaterThan(1); + expect(result.bestPrice).toBe("0.99983"); + + // Should have consumed multiple levels + const outputAmount = parseFloat( + ethers.utils.formatUnits(result.outputAmount, usdh.decimals) + ); + expect(outputAmount).toBeGreaterThan(99000); + expect(outputAmount).toBeLessThan(101000); + + // Slippage should be higher for larger order + expect(result.slippagePercent).toBeGreaterThan(0); + }); + + test("should simulate selling USDH for USDC", async () => { + mockedAxios.post.mockResolvedValue({ data: mockOrderBookData }); + + const result = await simulateMarketOrder({ + tokenIn: usdh, + tokenOut: usdc, + inputAmount: ethers.utils.parseUnits("1000", usdh.decimals), + }); + + expect(result.fullyFilled).toBe(true); + expect(result.levelsConsumed).toBe(1); + expect(result.bestPrice).toBe("0.99979"); + + // At best price of 0.99979, 1000 USDH should sell for approximately 999.79 USDC + const outputAmount = parseFloat( + ethers.utils.formatUnits(result.outputAmount, usdc.decimals) + ); + expect(outputAmount).toBeGreaterThan(999); + expect(outputAmount).toBeLessThan(1000); + + // Slippage should be minimal for small order + expect(result.slippagePercent).toBeGreaterThanOrEqual(0); + expect(result.slippagePercent).toBeLessThan(0.01); + }); + + test("should handle partial fills when order size exceeds available liquidity", async () => { + const limitedLiquidityOrderBook: MockOrderBookData = { + coin: "@230", + time: 1760575824177, + levels: [ + [ + { + px: "0.99979", + sz: "1000.0", + n: 1, + }, + ], + [ + { + px: "0.99983", + sz: "1000.0", + n: 1, + }, + ], + ], + }; + + mockedAxios.post.mockResolvedValue({ data: limitedLiquidityOrderBook }); + + const result = await simulateMarketOrder({ + tokenIn: usdc, + tokenOut: usdh, + inputAmount: ethers.utils.parseUnits("2000", usdc.decimals), + }); + + // Should only partially fill + expect(result.fullyFilled).toBe(false); + + // Should have consumed only the available liquidity + const inputUsed = parseFloat( + ethers.utils.formatUnits(result.inputAmount, usdc.decimals) + ); + expect(inputUsed).toBeLessThan(2000); + expect(inputUsed).toBeGreaterThan(999); + }); + + test("should calculate average execution price correctly", async () => { + mockedAxios.post.mockResolvedValue({ data: mockOrderBookData }); + + const result = await simulateMarketOrder({ + tokenIn: usdc, + tokenOut: usdh, + inputAmount: ethers.utils.parseUnits("50000", usdc.decimals), + }); + + const avgPrice = parseFloat(result.averageExecutionPrice); + const bestPrice = parseFloat(result.bestPrice); + + // Average price should be worse (higher) than best price when buying + expect(avgPrice).toBeGreaterThan(bestPrice); + + // Verify calculation: when buying base, price = inputAmount / outputAmount (quote per base) + const outputAmount = parseFloat( + ethers.utils.formatUnits(result.outputAmount, usdh.decimals) + ); + const inputAmount = parseFloat( + ethers.utils.formatUnits(result.inputAmount, usdc.decimals) + ); + const calculatedPrice = inputAmount / outputAmount; + + expect(Math.abs(calculatedPrice - avgPrice)).toBeLessThan(0.00001); + }); + + test("should throw error for unsupported token pair", async () => { + // Don't mock axios - let it fail naturally when the pair isn't found + await expect( + simulateMarketOrder({ + tokenIn: { + symbol: "BTC", + decimals: 8, + }, + tokenOut: { + symbol: "ETH", + decimals: 18, + }, + inputAmount: ethers.utils.parseUnits("1", 8), + }) + ).rejects.toThrow("No L2 order book coin found for pair BTC/ETH"); + }); + + test("should throw error when order book has no liquidity", async () => { + const emptyOrderBook: MockOrderBookData = { + coin: "@230", + time: 1760575824177, + levels: [[], []], + }; + + mockedAxios.post.mockResolvedValue({ data: emptyOrderBook }); + + await expect( + simulateMarketOrder({ + tokenIn: usdc, + tokenOut: usdh, + inputAmount: ethers.utils.parseUnits("1000", usdc.decimals), + }) + ).rejects.toThrow("No liquidity available for USDC/USDH"); + }); + + test("should calculate slippage correctly for buying (higher price = worse)", async () => { + const twoLevelOrderBook: MockOrderBookData = { + coin: "@230", + time: 1760575824177, + levels: [ + [ + { + px: "1.0", + sz: "1000.0", + n: 1, + }, + ], + [ + { + px: "1.0", + sz: "1000.0", + n: 1, + }, + { + px: "1.01", + sz: "1000.0", + n: 1, + }, + ], + ], + }; + + mockedAxios.post.mockResolvedValue({ data: twoLevelOrderBook }); + + const result = await simulateMarketOrder({ + tokenIn: usdc, + tokenOut: usdh, + inputAmount: ethers.utils.parseUnits("1500", usdc.decimals), + }); + + // Should consume both levels + expect(result.levelsConsumed).toBe(2); + + // Average price should be between 1.0 and 1.01 + const avgPrice = parseFloat(result.averageExecutionPrice); + expect(avgPrice).toBeGreaterThan(1.0); + expect(avgPrice).toBeLessThan(1.01); + + // Slippage should be positive (worse than best price) + expect(result.slippagePercent).toBeGreaterThan(0); + expect(result.slippagePercent).toBeLessThan(1); + }); + + test("should calculate slippage correctly for selling (lower price = worse)", async () => { + const twoLevelOrderBook: MockOrderBookData = { + coin: "@230", + time: 1760575824177, + levels: [ + [ + { + px: "1.0", + sz: "1000.0", + n: 1, + }, + { + px: "0.99", + sz: "1000.0", + n: 1, + }, + ], + [ + { + px: "1.01", + sz: "1000.0", + n: 1, + }, + ], + ], + }; + + mockedAxios.post.mockResolvedValue({ data: twoLevelOrderBook }); + + const result = await simulateMarketOrder({ + tokenIn: usdh, + tokenOut: usdc, + inputAmount: ethers.utils.parseUnits("1500", usdh.decimals), + }); + + // Should consume both levels + expect(result.levelsConsumed).toBe(2); + + // Average price should be between 0.99 and 1.0 + const avgPrice = parseFloat(result.averageExecutionPrice); + expect(avgPrice).toBeGreaterThan(0.99); + expect(avgPrice).toBeLessThan(1.0); + + // Slippage should be positive (worse than best price) + expect(result.slippagePercent).toBeGreaterThan(0); + expect(result.slippagePercent).toBeLessThan(1); + }); + }); +}); From fa535dca673f1ecdffe646915be3d99f39e5fc6b Mon Sep 17 00:00:00 2001 From: Nikolas Haimerl <113891786+NikolasHaimerl@users.noreply.github.com> Date: Mon, 27 Oct 2025 11:44:51 +0100 Subject: [PATCH 15/18] chore: update epic branch hypercore unsponsored (#1920) Co-authored-by: Melisa Guevara Co-authored-by: Dong-Ha Kim --- api/_bridges/oft/strategy.ts | 774 ++++++++++++++++--------- api/_bridges/oft/utils/constants.ts | 12 +- e2e-api/bridges/oft/strategy.test.ts | 277 +++++++++ scripts/tests/_swap-cases.ts | 23 + scripts/tests/_swap-utils.ts | 3 + test/api/_bridges/oft/strategy.test.ts | 273 +++++++++ 6 files changed, 1077 insertions(+), 285 deletions(-) create mode 100644 e2e-api/bridges/oft/strategy.test.ts create mode 100644 test/api/_bridges/oft/strategy.test.ts diff --git a/api/_bridges/oft/strategy.ts b/api/_bridges/oft/strategy.ts index 5778fd5a3..6d0269d66 100644 --- a/api/_bridges/oft/strategy.ts +++ b/api/_bridges/oft/strategy.ts @@ -28,10 +28,12 @@ import { OFT_MESSENGERS, OFT_SHARED_DECIMALS, V2_ENDPOINTS, + HYPEREVM_OFT_COMPOSER_ADDRESSES, } from "./utils/constants"; import * as chainConfigs from "../../../scripts/chain-configs"; +import { CHAIN_IDs } from "@across-protocol/constants"; -const name = "oft"; +const name = "oft" as const; const capabilities: BridgeCapabilities = { ecosystems: ["evm"], @@ -83,7 +85,7 @@ function roundAmountToSharedDecimals( * @param tokenSymbol token being bridged * @returns total number of required DVN signatures (requiredDVNs + optionalThreshold) */ -async function getRequiredDVNCount( +export async function getRequiredDVNCount( originChainId: number, destinationChainId: number, tokenSymbol: string @@ -132,18 +134,19 @@ async function getRequiredDVNCount( } /** - * Internal helper to get OFT quote from contracts + * Internal helper to get OFT quote from contracts. * @note This function is designed for input-based quotes (specify input amount, get output amount). * Currently works for both input-based and output-based flows because supported tokens (USDT, WBTC) have 0 OFT fees. * If we ever add tokens with non-zero OFT fees, we need to refactor this function to handle output-based quotes. * - * @param inputToken source token - * @param outputToken destination token - * @param inputAmount amount to send - * @param recipient recipient address - * @returns quote data including output amount and fees + * @param params The parameters for getting the quote. + * @param params.inputToken The input token. + * @param params.outputToken The output token. + * @param params.inputAmount The input amount. + * @param params.recipient The recipient address. + * @returns A promise that resolves with the quote data, including input amount, output amount, native fee, and OFT fee amount. */ -async function getQuote(params: { +export async function getQuote(params: { inputToken: Token; outputToken: Token; inputAmount: BigNumber; @@ -170,21 +173,28 @@ async function getQuote(params: { inputToken.symbol, inputToken.decimals ); - + const { toAddress, composeMsg, extraOptions } = + outputToken.chainId === CHAIN_IDs.HYPERCORE + ? getHyperLiquidComposerMessage(recipient, outputToken.symbol) + : { toAddress: recipient, composeMsg: "0x", extraOptions: "0x" }; // Create SendParam struct for quoting const sendParam = createSendParamStruct({ - destinationChainId: outputToken.chainId, - toAddress: recipient, + // If sending to Hypercore, the destinationChainId in the SendParam is always HyperEVM + destinationChainId: + outputToken.chainId === CHAIN_IDs.HYPERCORE + ? CHAIN_IDs.HYPEREVM + : outputToken.chainId, + toAddress, amountLD: roundedInputAmount, minAmountLD: roundedInputAmount, + composeMsg, + extraOptions, }); - // Get quote from OFT contract const [messagingFee, oftQuoteResult] = await Promise.all([ oftMessengerContract.quoteSend(sendParam, false), // false = pay in native token oftMessengerContract.quoteOFT(sendParam), ]); - const [, , oftReceipt] = oftQuoteResult; const nativeFee = BigNumber.from(messagingFee.nativeFee); @@ -209,301 +219,410 @@ async function getQuote(params: { } /** - * OFT (Omnichain Fungible Token) bridge strategy + * Builds the transaction data for an OFT bridge transfer. + * This function takes the quotes and other parameters and constructs the transaction data for sending an OFT. + * It also handles the special case of sending to Hyperliquid, where a custom message is composed. + * + * @param params The parameters for building the transaction. + * @param params.quotes The quotes for the cross-swap, including the bridge quote. + * @param params.integratorId The ID of the integrator. + * @returns A promise that resolves with the transaction data. */ -export function getOftBridgeStrategy(): BridgeStrategy { - const getEstimatedFillTime = async ( - originChainId: number, - destinationChainId: number, - tokenSymbol: string - ): Promise => { - const DEFAULT_BLOCK_TIME_SECONDS = 5; - - // Get source chain required confirmations - const originConfirmations = getOftOriginConfirmations(originChainId); - - // Get dynamic DVN count for this specific route - const requiredDVNs = await getRequiredDVNCount( - originChainId, - destinationChainId, - tokenSymbol - ); +export async function buildOftTx(params: { + quotes: CrossSwapQuotes; + integratorId?: string; +}) { + const { + bridgeQuote, + crossSwap, + originSwapQuote, + destinationSwapQuote, + appFee, + } = params.quotes; + + // OFT validations + if (appFee?.feeAmount.gt(0)) { + throw new InvalidParamError({ + message: "App fee is not supported for OFT bridge transfers", + }); + } - // Get origin and destination block times from chain configs - const originChainConfig = Object.values(chainConfigs).find( - (config) => config.chainId === originChainId - ); - const destinationChainConfig = Object.values(chainConfigs).find( - (config) => config.chainId === destinationChainId - ); - const originBlockTime = - originChainConfig?.blockTimeSeconds ?? DEFAULT_BLOCK_TIME_SECONDS; - const destinationBlockTime = - destinationChainConfig?.blockTimeSeconds ?? DEFAULT_BLOCK_TIME_SECONDS; - - // Total time ≈ (originBlockTime × originConfirmations) + (destinationBlockTime × (2 + numberOfDVNs)) - // Source: https://docs.layerzero.network/v2/faq#what-is-the-estimated-delivery-time-for-a-layerzero-message - const originTime = originBlockTime * originConfirmations; - const destinationTime = destinationBlockTime * (2 + requiredDVNs); - const totalTime = originTime + destinationTime; - - return totalTime; - }; + if (originSwapQuote || destinationSwapQuote) { + throw new InvalidParamError({ + message: + "Origin/destination swaps are not supported for OFT bridge transfers", + }); + } - const isRouteSupported = (params: { - inputToken: Token; - outputToken: Token; - }) => { - // Both tokens must be the same - if (params.inputToken.symbol !== params.outputToken.symbol) { - return false; - } + const originChainId = crossSwap.inputToken.chainId; + const destinationChainId = crossSwap.outputToken.chainId; - // Token must be supported by OFT - const oftMessengerContract = OFT_MESSENGERS[params.inputToken.symbol]; - if (!oftMessengerContract) { - return false; - } + // Get OFT contract address for origin chain + const oftMessengerAddress = getOftMessengerForToken( + crossSwap.inputToken.symbol, + originChainId + ); - // Both chains must have OFT contracts configured for the token - return Boolean( - oftMessengerContract[params.inputToken.chainId] && - oftMessengerContract[params.outputToken.chainId] - ); - }; + // Get recipient address + // If sending to Hyperliquid, compose the special message to contact the Hyperliquid Composer + // This includes setting the toAddress to the Composer contract address and creating the composeMsg and extraOptions + const { toAddress, composeMsg, extraOptions } = + destinationChainId === CHAIN_IDs.HYPERCORE + ? getHyperLiquidComposerMessage( + crossSwap.recipient!, + crossSwap.outputToken.symbol + ) + : { + toAddress: crossSwap.recipient!, + composeMsg: "0x", + extraOptions: "0x", + }; - const assertSupportedRoute = (params: { - inputToken: Token; - outputToken: Token; - }) => { - if (!isRouteSupported(params)) { - throw new InvalidParamError({ - message: `OFT: Route ${params.inputToken.symbol} -> ${params.outputToken.symbol} is not supported`, - }); - } - }; + const roundedInputAmount = roundAmountToSharedDecimals( + bridgeQuote.inputAmount, + bridgeQuote.inputToken.symbol, + bridgeQuote.inputToken.decimals + ); + + // Create SendParam struct + const sendParam = createSendParamStruct({ + destinationChainId: + destinationChainId === CHAIN_IDs.HYPERCORE + ? CHAIN_IDs.HYPEREVM + : destinationChainId, + toAddress, + amountLD: roundedInputAmount, + minAmountLD: roundedInputAmount, + composeMsg, + extraOptions, + }); + // Get messaging fee quote + const provider = getProvider(originChainId); + const oftMessengerContract = new Contract( + oftMessengerAddress, + OFT_ABI, + provider + ); + const messagingFee = await oftMessengerContract.quoteSend(sendParam, false); + + // Encode the send call + const iface = new ethers.utils.Interface(OFT_ABI); + const callData = iface.encodeFunctionData("send", [ + sendParam, + messagingFee, // MessagingFee struct + crossSwap.refundAddress ?? crossSwap.depositor, // refundAddress + ]); + + // Handle integrator ID and swap API marker tagging + const callDataWithIntegratorId = params.integratorId + ? tagIntegratorId(params.integratorId, callData) + : callData; + const callDataWithMarkers = tagSwapApiMarker(callDataWithIntegratorId); return { - name, - capabilities, + chainId: originChainId, + from: crossSwap.depositor, + to: oftMessengerAddress, + data: callDataWithMarkers, + value: BigNumber.from(messagingFee.nativeFee), // Must include native fee as value + ecosystem: "evm" as const, + }; +} - originTxNeedsAllowance: true, +/** + * Estimates the fill time for an OFT bridge transfer. + * The estimation is based on the origin and destination chain block times, the number of origin confirmations, and the number of required DVN signatures. + * The formula is: `(originBlockTime * originConfirmations) + (destinationBlockTime * (2 + numberOfDVNs))` + * See: https://docs.layerzero.network/v2/faq#what-is-the-estimated-delivery-time-for-a-layerzero-message + * + * @param originChainId The origin chain ID. + * @param destinationChainId The destination chain ID. + * @param tokenSymbol The symbol of the token being bridged. + * @returns A promise that resolves with the estimated fill time in seconds. + */ +export async function getEstimatedFillTime( + originChainId: number, + destinationChainId: number, + tokenSymbol: string +): Promise { + const DEFAULT_BLOCK_TIME_SECONDS = 5; - getCrossSwapTypes: (params: { - inputToken: Token; - outputToken: Token; - isInputNative: boolean; - isOutputNative: boolean; - }) => { - if ( - isRouteSupported({ - inputToken: params.inputToken, - outputToken: params.outputToken, - }) - ) { - return [CROSS_SWAP_TYPE.BRIDGEABLE_TO_BRIDGEABLE]; - } - return []; - }, + // Get source chain required confirmations + const originConfirmations = getOftOriginConfirmations(originChainId); - getBridgeQuoteRecipient: (crossSwap: CrossSwap) => { - return crossSwap.recipient; - }, + // Get dynamic DVN count for this specific route + const requiredDVNs = await getRequiredDVNCount( + originChainId, + destinationChainId, + tokenSymbol + ); - getBridgeQuoteMessage: (_crossSwap: CrossSwap, _appFee?: AppFee) => { - return "0x"; - }, + // Get origin and destination block times from chain configs + const originChainConfig = Object.values(chainConfigs).find( + (config) => config.chainId === originChainId + ); + const destinationChainConfig = Object.values(chainConfigs).find( + (config) => config.chainId === destinationChainId + ); + const originBlockTime = + originChainConfig?.blockTimeSeconds ?? DEFAULT_BLOCK_TIME_SECONDS; + const destinationBlockTime = + destinationChainConfig?.blockTimeSeconds ?? DEFAULT_BLOCK_TIME_SECONDS; + + // Total time ≈ (originBlockTime × originConfirmations) + (destinationBlockTime × (2 + numberOfDVNs)) + // Source: https://docs.layerzero.network/v2/faq#what-is-the-estimated-delivery-time-for-a-layerzero-message + const originTime = originBlockTime * originConfirmations; + const destinationTime = destinationBlockTime * (2 + requiredDVNs); + const totalTime = originTime + destinationTime; + + return totalTime; +} + +/** + * Checks if a route is supported for OFT bridging. + * A route is supported if the input and output tokens are the same, and the token is configured for OFT on both the origin and destination chains. + * It also checks for the special case of bridging to Hyperliquid, where the output token is on the Hypercore chain. + * + * @param params The parameters for checking the route. + * @param params.inputToken The input token. + * @param params.outputToken The output token. + * @returns True if the route is supported, false otherwise. + */ +export function isRouteSupported(params: { + inputToken: Token; + outputToken: Token; +}) { + // Both tokens must be the same + if ( + params.inputToken.symbol !== params.outputToken.symbol && + params.outputToken.symbol !== `${params.inputToken.symbol}-SPOT` + ) { + return false; + } + + // Token must be supported by OFT + const oftMessengerContract = OFT_MESSENGERS[params.inputToken.symbol]; + + if (oftMessengerContract[params.inputToken.chainId]) { + if (oftMessengerContract[params.outputToken.chainId]) { + // Both chains must have OFT contracts configured for the token + return true; + } + const oftComposerContract = + HYPEREVM_OFT_COMPOSER_ADDRESSES[params.outputToken.symbol]; + if ( + params.outputToken.chainId === CHAIN_IDs.HYPERCORE && + oftComposerContract + ) { + // The oft transfer is sending OFT directly to hyperCore via the composer contract + return true; + } + } + return false; +} + +/** + * Asserts that a route is supported for OFT bridging. + * Throws an error if the route is not supported. + * + * @param params The parameters for checking the route. + * @param params.inputToken The input token. + * @param params.outputToken The output token. + * @throws {InvalidParamError} If the route is not supported. + */ +function assertSupportedRoute(params: { + inputToken: Token; + outputToken: Token; +}) { + if (!isRouteSupported(params)) { + throw new InvalidParamError({ + message: `OFT: Route ${params.inputToken.symbol} -> ${params.outputToken.symbol} is not supported for bridging from ${params.inputToken.chainId} to ${params.outputToken.chainId}`, + }); + } +} + +/** + * Gets a quote for an OFT bridge transfer with a specified output amount. + * This function is used when the user wants to receive a specific amount of tokens on the destination chain. + * + * @param params The parameters for getting the quote. + * @param params.inputToken The input token. + * @param params.outputToken The output token. + * @param params.minOutputAmount The minimum output amount. + * @param params.recipient The recipient address. + * @returns A promise that resolves with the quote data. + */ +async function getOftQuoteForOutput(params: GetOutputBridgeQuoteParams) { + const { inputToken, outputToken, minOutputAmount } = params; + assertSupportedRoute({ inputToken, outputToken }); - getQuoteForExactInput: async ({ + // Convert minOutputAmount to input token decimals + const minOutputInInputDecimals = ConvertDecimals( + outputToken.decimals, + inputToken.decimals + )(minOutputAmount); + + // Get quote from OFT contracts and estimated fill time in parallel + const [{ inputAmount, outputAmount, nativeFee }, estimatedFillTimeSec] = + await Promise.all([ + getQuote({ + inputToken, + outputToken, + inputAmount: minOutputInInputDecimals, + recipient: params.recipient!, + }), + getEstimatedFillTime( + inputToken.chainId, + outputToken.chainId, + inputToken.symbol + ), + ]); + + // OFT precision limitations may prevent delivering the exact minimum amount + // We validate against the rounded amount (maximum possible given shared decimals) + const roundedMinOutputAmount = roundAmountToSharedDecimals( + minOutputAmount, + inputToken.symbol, + inputToken.decimals + ); + assertMinOutputAmount(outputAmount, roundedMinOutputAmount); + + const nativeToken = getNativeTokenInfo(inputToken.chainId); + + return { + bridgeQuote: { inputToken, outputToken, - exactInputAmount, - recipient, - message: _message, - }: GetExactInputBridgeQuoteParams) => { - assertSupportedRoute({ inputToken, outputToken }); - - const [{ inputAmount, outputAmount, nativeFee }, estimatedFillTimeSec] = - await Promise.all([ - getQuote({ - inputToken, - outputToken, - inputAmount: exactInputAmount, - recipient: recipient!, - }), - getEstimatedFillTime( - inputToken.chainId, - outputToken.chainId, - inputToken.symbol - ), - ]); - - const nativeToken = getNativeTokenInfo(inputToken.chainId); - - return { - bridgeQuote: { - inputToken, - outputToken, - inputAmount, - outputAmount, - minOutputAmount: outputAmount, - estimatedFillTimeSec, - provider: name, - fees: getOftBridgeFees({ - inputToken, - nativeFee, - nativeToken, - }), - }, - }; + inputAmount, + outputAmount, + minOutputAmount, + estimatedFillTimeSec, + provider: name, + fees: getOftBridgeFees({ + inputToken, + nativeFee, + nativeToken, + }), }, + }; +} - getQuoteForOutput: async ({ +/** + * Determines the cross-swap type for an OFT bridge transfer. + * For OFT, the only supported type is `BRIDGEABLE_TO_BRIDGEABLE`, which means that the tokens are the same on both the origin and destination chains. + * + * @param params The parameters for determining the cross-swap type. + * @param params.inputToken The input token. + * @param params.outputToken The output token. + * @param params.isInputNative A boolean indicating if the input token is native. + * @param params.isOutputNative A boolean indicating if the output token is native. + * @returns An array of supported cross-swap types. + */ +export function getOftCrossSwapTypes(params: { + inputToken: Token; + outputToken: Token; + isInputNative: boolean; + isOutputNative: boolean; +}) { + return isRouteSupported({ + inputToken: params.inputToken, + outputToken: params.outputToken, + }) + ? [CROSS_SWAP_TYPE.BRIDGEABLE_TO_BRIDGEABLE] + : []; +} + +/** + * Gets a quote for an OFT bridge transfer with a specified exact input amount. + * This function is used when the user wants to send a specific amount of tokens from the origin chain. + * + * @param params The parameters for getting the quote. + * @param params.inputToken The input token. + * @param params.outputToken The output token. + * @param params.exactInputAmount The exact input amount. + * @param params.recipient The recipient address. + * @param params.message An optional message to be sent with the transfer. + * @returns A promise that resolves with the quote data. + */ +export async function getOftQuoteForExactInput({ + inputToken, + outputToken, + exactInputAmount, + recipient, + message: _message, +}: GetExactInputBridgeQuoteParams) { + assertSupportedRoute({ inputToken, outputToken }); + + const [{ inputAmount, outputAmount, nativeFee }, estimatedFillTimeSec] = + await Promise.all([ + getQuote({ + inputToken, + outputToken, + inputAmount: exactInputAmount, + recipient: recipient!, + }), + getEstimatedFillTime( + inputToken.chainId, + outputToken.chainId, + inputToken.symbol + ), + ]); + + const nativeToken = getNativeTokenInfo(inputToken.chainId); + + return { + bridgeQuote: { inputToken, outputToken, - minOutputAmount, - forceExactOutput: _forceExactOutput, - recipient, - message: _message, - }: GetOutputBridgeQuoteParams) => { - assertSupportedRoute({ inputToken, outputToken }); - - // Convert minOutputAmount to input token decimals - const minOutputInInputDecimals = ConvertDecimals( - outputToken.decimals, - inputToken.decimals - )(minOutputAmount); - - // Get quote from OFT contracts and estimated fill time in parallel - const [{ inputAmount, outputAmount, nativeFee }, estimatedFillTimeSec] = - await Promise.all([ - getQuote({ - inputToken, - outputToken, - inputAmount: minOutputInInputDecimals, - recipient: recipient!, - }), - getEstimatedFillTime( - inputToken.chainId, - outputToken.chainId, - inputToken.symbol - ), - ]); - - // OFT precision limitations may prevent delivering the exact minimum amount - // We validate against the rounded amount (maximum possible given shared decimals) - const roundedMinOutputAmount = roundAmountToSharedDecimals( - minOutputAmount, - inputToken.symbol, - inputToken.decimals - ); - assertMinOutputAmount(outputAmount, roundedMinOutputAmount); - - const nativeToken = getNativeTokenInfo(inputToken.chainId); - - return { - bridgeQuote: { - inputToken, - outputToken, - inputAmount, - outputAmount, - minOutputAmount, - estimatedFillTimeSec, - provider: name, - fees: getOftBridgeFees({ - inputToken, - nativeFee, - nativeToken, - }), - }, - }; + inputAmount, + outputAmount, + minOutputAmount: outputAmount, + estimatedFillTimeSec, + provider: name, + fees: getOftBridgeFees({ + inputToken, + nativeFee, + nativeToken, + }), }, + }; +} - buildTxForAllowanceHolder: async (params: { - quotes: CrossSwapQuotes; - integratorId?: string; - }) => { - const { - bridgeQuote, - crossSwap, - originSwapQuote, - destinationSwapQuote, - appFee, - } = params.quotes; - - // OFT validations - if (appFee?.feeAmount.gt(0)) { - throw new InvalidParamError({ - message: "App fee is not supported for OFT bridge transfers", - }); - } - - if (originSwapQuote || destinationSwapQuote) { - throw new InvalidParamError({ - message: - "Origin/destination swaps are not supported for OFT bridge transfers", - }); - } - - const originChainId = crossSwap.inputToken.chainId; - const destinationChainId = crossSwap.outputToken.chainId; - - // Get OFT contract address for origin chain - const oftMessengerAddress = getOftMessengerForToken( - crossSwap.inputToken.symbol, - originChainId - ); - - // Get recipient address - const recipient = crossSwap.recipient; - - // Create SendParam struct - const sendParam = createSendParamStruct({ - destinationChainId, - toAddress: recipient, - amountLD: bridgeQuote.inputAmount, - minAmountLD: bridgeQuote.minOutputAmount, - }); - - // Get messaging fee quote - const provider = getProvider(originChainId); - const oftMessengerContract = new Contract( - oftMessengerAddress, - OFT_ABI, - provider - ); - const messagingFee = await oftMessengerContract.quoteSend( - sendParam, - false - ); - - // Encode the send call - const iface = new ethers.utils.Interface(OFT_ABI); - const callData = iface.encodeFunctionData("send", [ - sendParam, - messagingFee, // MessagingFee struct - crossSwap.refundAddress ?? crossSwap.depositor, // refundAddress - ]); - - // Handle integrator ID and swap API marker tagging - const callDataWithIntegratorId = params.integratorId - ? tagIntegratorId(params.integratorId, callData) - : callData; - const callDataWithMarkers = tagSwapApiMarker(callDataWithIntegratorId); - - return { - chainId: originChainId, - from: crossSwap.depositor, - to: oftMessengerAddress, - data: callDataWithMarkers, - value: BigNumber.from(messagingFee.nativeFee), // Must include native fee as value - ecosystem: "evm" as const, - }; +/** + * Gets the OFT bridge strategy. + * This function returns the bridge strategy object for OFT, which includes all the necessary functions for quoting, building transactions, and checking for supported routes. + * + * @returns The OFT bridge strategy. + */ +export function getOftBridgeStrategy(): BridgeStrategy { + return { + name, + capabilities, + originTxNeedsAllowance: true, + getCrossSwapTypes: getOftCrossSwapTypes, + getBridgeQuoteRecipient: (crossSwap: CrossSwap) => { + return crossSwap.recipient; }, + getBridgeQuoteMessage: (_crossSwap: CrossSwap, _appFee?: AppFee) => { + return "0x"; + }, + getQuoteForExactInput: getOftQuoteForExactInput, + getQuoteForOutput: getOftQuoteForOutput, + buildTxForAllowanceHolder: buildOftTx, isRouteSupported, }; } +/** + * Gets the OFT bridge fees. + * For OFT transfers, the fees are simple. There is only a bridge fee, which is the native fee for the messaging layer. + * + * @param params The parameters for getting the fees. + * @param params.inputToken The input token. + * @param params.nativeFee The native fee. + * @param params.nativeToken The native token. + * @returns The OFT bridge fees. + */ function getOftBridgeFees(params: { inputToken: Token; nativeFee: BigNumber; @@ -539,3 +658,92 @@ function getOftBridgeFees(params: { }, }; } + +/** + * Composes the message for a Hyperliquid transfer. + * This function creates the `composeMsg` and `extraOptions` for a Hyperliquid transfer. + * The `composeMsg` is the payload for the HyperLiquidComposer, and the `extraOptions` is a custom-packed struct required by the legacy V1-style integration that the Hyperliquid Composer uses. + * See: https://docs.layerzero.network/v2/developers/hyperliquid/hyperliquid-concepts + * + * @param recipient The recipient address on Hyperliquid. + * @param tokenSymbol The symbol of the token being transferred. + * @returns An object containing the `composeMsg`, `toAddress`, and `extraOptions`. + */ +export function getHyperLiquidComposerMessage( + recipient: string, + tokenSymbol: string +): { + composeMsg: string; + toAddress: string; + extraOptions: string; +} { + // The `composeMsg` is the payload for the HyperLiquidComposer. + // The composer contract's `decodeMessage` function expects: abi.decode(_composeMessage, (uint256, address)) + // See: https://github.com/LayerZero-Labs/devtools/blob/ed399e9e57e00848910628a2f89b958c11f63162/packages/hyperliquid-composer/contracts/HyperLiquidComposer.sol#L104 + const composeMsg = ethers.utils.defaultAbiCoder.encode( + ["uint256", "address"], + [ + 0, // `minMsgValue` is 0 as we are not sending native HYPE tokens. + recipient, // `to` is the final user's address on Hyperliquid L1. + ] + ); + if (!HYPEREVM_OFT_COMPOSER_ADDRESSES[tokenSymbol]) { + throw new InvalidParamError({ + message: `OFT: No Hyperliquid Composer contract configured for token ${tokenSymbol}`, + }); + } + // When composing a message for Hyperliquid, the recipient of the OFT `send` call + // is always the Hyperliquid Composer contract on HyperEVM. + const toAddress = HYPEREVM_OFT_COMPOSER_ADDRESSES[tokenSymbol]; + + /** + * @notice Explanation of the custom `extraOptions` payload for the Hyperliquid Composer. + * + * ## What is `extraOptions`? + * + * The `extraOptions` parameter is a concept from the LayerZero protocol. It's a `bytes` string used to pass + * special instructions and, most importantly, pre-pay for the gas required to execute a transaction on the + * destination chain. + * + * One can think of it as a "secret handshake" or a special delivery form. A standard transaction might fail, but one + * with the correct `extraOptions` is recognized and processed by the destination contract. + * + * ## Why This Specific Value: `0x00030100130300000000000000000000000000000000ea60` + * + * This value is not a standard gas payment; it's a custom-packed 26-byte struct required by the + * integration that the Hyperliquid Composer uses. On-chain analysis of successful transactions proves this is the + * only format the contract will accept. + * Examples here: https://polygonscan.com/tx/0x3b96ab9962692d173cd6851fc8308fce3ff0eb56209298a632cef9333cfe3f3f + * and here: https://arbiscan.io/tx/0x9a71d06971e7b52b7896b82e04e1129a123406e08bb016ed57c77a94cb46f979 + * + * The structure is composed of three distinct parts: + * + * 1. **Magic Prefix (`bytes6`):** `0x000301001303` + * - This acts as a unique identifier or "handshake," signaling to the LayerZero network and the Composer that + * this is a specific type of Hyperliquid-bound message. + * + * 2. **Zero Padding (`bytes16`):** `0x00000000000000000000000000000000` + * - A fixed block of 16 empty bytes required to maintain the struct's correct layout. + * + * 3. **Gas Amount (`uint32`):** `0x0000ea60` + * - This is the gas amount (in wei) being airdropped for the destination transaction on HyperEVM. + * - The hex value `ea60` is equal to the decimal value **60,000**. + * + * Any deviation from this 26-byte structure will cause the destination transaction to fail with a parsing error, + * as the Composer will not recognize the "handshake." + * + * ## Proof and Documentation + * + * - **On-Chain Proof:** The necessity of this exact format is proven by analyzing the input data of successful + * `send` transactions on block explorers that bridge to the Hyperliquid Composer. The working example we analyzed + * is the primary evidence. + * + * - **Conceptual Documentation:** While the *specific* value is custom to Hyperliquid, the *concept* of using + * `extraOptions` to provide gas comes from the LayerZero protocol's "Adapter Parameters." + * See Docs: https://docs.layerzero.network/v2/tools/sdks/options + * + */ + const extraOptions = "0x00030100130300000000000000000000000000000000ea60"; + + return { composeMsg, toAddress, extraOptions }; +} diff --git a/api/_bridges/oft/utils/constants.ts b/api/_bridges/oft/utils/constants.ts index 84fd3810d..8c17b9b5b 100644 --- a/api/_bridges/oft/utils/constants.ts +++ b/api/_bridges/oft/utils/constants.ts @@ -43,6 +43,11 @@ export const OFT_MESSENGERS: Record< // } }; +// OFT composer contract addresses per token on hyperEVM +export const HYPEREVM_OFT_COMPOSER_ADDRESSES: Record = { + "USDT-SPOT": "0x80123ab57c9bc0c452d6c18f92a653a4ee2e7585", +}; + // Shared decimals for OFT tokens across chains // These are the decimal precision values that are consistent across all chains for each token export const OFT_SHARED_DECIMALS: Record = { @@ -98,6 +103,7 @@ export const getOftMessengerForToken = ( // Get OFT endpoint ID for a chain export const getOftEndpointId = (chainId: number): number => { + chainId = chainId === CHAIN_IDs.HYPERCORE ? CHAIN_IDs.HYPEREVM : chainId; // Use HyperEVM's OFT EID for Hypercore. They share the same EID. const chainInfo = CHAINS[chainId]; if (!chainInfo || !chainInfo.oftEid) { throw new InvalidParamError({ @@ -293,6 +299,8 @@ export const createSendParamStruct = (params: { toAddress: string; amountLD: BigNumber; minAmountLD: BigNumber; + composeMsg?: string; + extraOptions?: string; }): SendParamStruct => { const dstEid = getOftEndpointId(params.destinationChainId); return { @@ -300,8 +308,8 @@ export const createSendParamStruct = (params: { to: toBytes32(params.toAddress), amountLD: params.amountLD, minAmountLD: params.minAmountLD, - extraOptions: "0x", - composeMsg: "0x", + extraOptions: params.extraOptions ?? "0x", + composeMsg: params.composeMsg ?? "0x", oftCmd: "0x", }; }; diff --git a/e2e-api/bridges/oft/strategy.test.ts b/e2e-api/bridges/oft/strategy.test.ts new file mode 100644 index 000000000..20610e1c6 --- /dev/null +++ b/e2e-api/bridges/oft/strategy.test.ts @@ -0,0 +1,277 @@ +import { + getQuote, + getEstimatedFillTime, + getOftQuoteForExactInput, + getRequiredDVNCount, + buildOftTx, +} from "../../../api/_bridges/oft/strategy"; +import { CHAIN_IDs } from "@across-protocol/constants"; +import { CrossSwapQuotes, Token } from "../../../api/_dexes/types"; +import { BigNumber } from "ethers"; +import { TOKEN_SYMBOLS_MAP } from "../../../api/_constants"; + +describe("OFT Strategy", () => { + const arbitrumUSDT: Token = { + address: TOKEN_SYMBOLS_MAP.USDT.addresses[CHAIN_IDs.ARBITRUM], + symbol: "USDT", + decimals: 6, + chainId: CHAIN_IDs.ARBITRUM, + }; + + const polygonUSDT: Token = { + address: TOKEN_SYMBOLS_MAP.USDT.addresses[CHAIN_IDs.POLYGON], + symbol: "USDT", + decimals: 6, + chainId: CHAIN_IDs.POLYGON, + }; + + const hyperCoreUSDT: Token = { + address: TOKEN_SYMBOLS_MAP["USDT-SPOT"].addresses[CHAIN_IDs.HYPERCORE], + symbol: "USDT-SPOT", + decimals: 8, + chainId: CHAIN_IDs.HYPERCORE, + }; + + describe("getRequiredDVNCount", () => { + it("should return the DVN count for a valid route", async () => { + expect( + await getRequiredDVNCount(CHAIN_IDs.ARBITRUM, CHAIN_IDs.POLYGON, "USDT") + ).toBeGreaterThan(0); + + expect( + await getRequiredDVNCount( + CHAIN_IDs.ARBITRUM, + CHAIN_IDs.HYPERCORE, + "USDT" + ) + ).toBeGreaterThan(0); + }, 30000); + }); + + describe("getQuote", () => { + it("should return a valid quote from Arbitrum to Polygon", async () => { + const result = await getQuote({ + inputToken: arbitrumUSDT, + outputToken: polygonUSDT, + inputAmount: BigNumber.from(1000), + recipient: "0x9A8f92a830A5cB89a3816e3D267CB7791c16b04D", + }); + + expect(result.inputAmount).toEqual(BigNumber.from(1000)); + expect(result.outputAmount).toBeDefined(); + expect(result.nativeFee).toBeDefined(); + expect(result.oftFeeAmount).toBeDefined(); + }, 30000); + + it("should return a valid quote from Arbitrum to Hyperlane", async () => { + const result = await getQuote({ + inputToken: arbitrumUSDT, + outputToken: hyperCoreUSDT, + inputAmount: BigNumber.from(1000), + recipient: "0x9A8f92a830A5cB89a3816e3D267CB7791c16b04D", + }); + + expect(result.inputAmount).toEqual(BigNumber.from(1000)); + expect(result.outputAmount).toBeDefined(); + expect(result.nativeFee).toBeDefined(); + expect(result.oftFeeAmount).toBeDefined(); + }, 30000); + }); + + describe("getEstimatedFillTime", () => { + it("should calculate the estimated fill time correctly", async () => { + expect( + await getEstimatedFillTime( + CHAIN_IDs.ARBITRUM, + CHAIN_IDs.POLYGON, + "USDT" + ) + ).toBeGreaterThan(0); + + expect( + await getEstimatedFillTime( + CHAIN_IDs.ARBITRUM, + CHAIN_IDs.HYPERCORE, + "USDT" + ) + ).toBeGreaterThan(0); + }); + }); + + describe("getOftQuoteForExactInput", () => { + it("should return a valid quote for an exact input amount", async () => { + let result = await getOftQuoteForExactInput({ + inputToken: arbitrumUSDT, + outputToken: polygonUSDT, + exactInputAmount: BigNumber.from(1000), + recipient: "0x9A8f92a830A5cB89a3816e3D267CB7791c16b04D", + }); + + expect(result.bridgeQuote.inputAmount).toEqual(BigNumber.from(1000)); + expect(result.bridgeQuote.outputAmount).toBeDefined(); + expect(result.bridgeQuote.minOutputAmount).toBeDefined(); + expect(result.bridgeQuote.estimatedFillTimeSec).toBeGreaterThan(0); + expect(result.bridgeQuote.provider).toBe("oft"); + + result = await getOftQuoteForExactInput({ + inputToken: arbitrumUSDT, + outputToken: hyperCoreUSDT, + exactInputAmount: BigNumber.from(1000), + recipient: "0x9A8f92a830A5cB89a3816e3D267CB7791c16b04D", + }); + + expect(result.bridgeQuote.inputAmount).toEqual(BigNumber.from(1000)); + expect(result.bridgeQuote.outputAmount).toBeDefined(); + expect(result.bridgeQuote.minOutputAmount).toBeDefined(); + expect(result.bridgeQuote.estimatedFillTimeSec).toBeGreaterThan(0); + expect(result.bridgeQuote.provider).toBe("oft"); + }, 30000); + }); + + describe("buildOftTx", () => { + it("should build a valid transaction", async () => { + /** + * NOTE: This test uses values and specific token addresses from a real, + * successful cross-chain transaction to Hyperliquid's Composer contract. + * + * REASONING: + * 1. These values were captured from an actual API quote that successfully completed + * an on-chain cross-chain transfer using the Hyperliquid Composer contract. + * 3. The specific token addresses and chain IDs represent the real Arbitrum USDT to + * HyperCore USDT-SPOT route that was validated on-chain. + * 4. Since CI tests cannot verify the actual cross-chain transfer completion, maintaining + * the exact request structure from a known-working transaction provides the highest + * confidence that buildOftTx produces valid transaction data. + * 5. The Hyperliquid Composer integration uses a very specific `extraOptions` format + * (see strategy.ts for details), and any deviation in the request structure could + * potentially result in a transaction that builds successfully but fails on-chain. + * + */ + const quotes: CrossSwapQuotes = { + crossSwap: { + amount: BigNumber.from(1000000), + inputToken: { + decimals: 6, + symbol: "USDT", + address: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + chainId: 42161, + }, + outputToken: { + decimals: 8, + symbol: "USDT-SPOT", + address: "0x200000000000000000000000000000000000010C", + chainId: 1337, + }, + depositor: "0x9A8f92a830A5cB89a3816e3D267CB7791c16b04D", + recipient: "0x9A8f92a830A5cB89a3816e3D267CB7791c16b04D", + slippageTolerance: 1, + type: "minOutput", + refundOnOrigin: true, + isInputNative: false, + isOutputNative: false, + embeddedActions: [], + strictTradeType: true, + isDestinationSvm: false, + isOriginSvm: false, + }, + bridgeQuote: { + inputToken: { + decimals: 6, + symbol: "USDT", + address: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + chainId: 42161, + }, + outputToken: { + decimals: 8, + symbol: "USDT-SPOT", + address: "0x200000000000000000000000000000000000010C", + chainId: 1337, + }, + inputAmount: BigNumber.from(10000), + outputAmount: BigNumber.from(1000000), + minOutputAmount: BigNumber.from(1000000), + estimatedFillTimeSec: 24, + provider: "oft", + fees: { + totalRelay: { + pct: BigNumber.from(0), + total: BigNumber.from(0), + token: { + decimals: 6, + symbol: "USDT", + address: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + chainId: 42161, + }, + }, + relayerCapital: { + pct: BigNumber.from(0), + total: BigNumber.from(0), + token: { + decimals: 6, + symbol: "USDT", + address: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + chainId: 42161, + }, + }, + relayerGas: { + pct: BigNumber.from(0), + total: BigNumber.from(0), + token: { + decimals: 6, + symbol: "USDT", + address: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + chainId: 42161, + }, + }, + lp: { + pct: BigNumber.from(0), + total: BigNumber.from(0), + token: { + decimals: 6, + symbol: "USDT", + address: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + chainId: 42161, + }, + }, + bridgeFee: { + pct: BigNumber.from(0), + total: BigNumber.from("0x1522fe82c8c1"), + token: { + chainId: 42161, + address: "0x0000000000000000000000000000000000000000", + decimals: 18, + symbol: "ETH", + }, + }, + }, + message: "0x", + }, + contracts: { + depositEntryPoint: { + name: "SpokePoolPeriphery", + address: "0x89415a82d909a7238d69094C3Dd1dCC1aCbDa85C", + }, + }, + appFee: { + feeAmount: BigNumber.from(0), + feeToken: { + decimals: 8, + symbol: "USDT-SPOT", + address: "0x200000000000000000000000000000000000010C", + chainId: 1337, + }, + feeActions: [], + }, + }; + + const result = await buildOftTx({ quotes }); + expect(result).toBeDefined(); + expect(result.chainId).toBe(42161); + expect(result.data).toBeDefined(); + expect(result.value).toBeDefined(); + expect(result.from).toBe("0x9A8f92a830A5cB89a3816e3D267CB7791c16b04D"); + expect(result.to).toBe("0x14E4A1B13bf7F943c8ff7C51fb60FA964A298D92"); + expect(result.ecosystem).toBe("evm"); + }, 30000); + }); +}); diff --git a/scripts/tests/_swap-cases.ts b/scripts/tests/_swap-cases.ts index b45468e3f..59d07d186 100644 --- a/scripts/tests/_swap-cases.ts +++ b/scripts/tests/_swap-cases.ts @@ -404,3 +404,26 @@ export const SOLANA_CASES = [ }, }, ]; + +export const USDT_OFT_COMPOSER_CASE = [ + // Arbitrum -> HYPERCORE USDT + { + labels: [ + "OFT", + "B2B", + "EXACT_INPUT", + "USDT - USDT-SPOT", + "ARBITRUM - HYPERCORE", + ], + params: { + amount: ethers.utils.parseUnits("1", 6).toString(), + tradeType: "minOutput", + inputToken: TOKEN_SYMBOLS_MAP.USDT.addresses[CHAIN_IDs.ARBITRUM], + originChainId: CHAIN_IDs.ARBITRUM, + outputToken: + TOKEN_SYMBOLS_MAP["USDT-SPOT"].addresses[CHAIN_IDs.HYPERCORE], + destinationChainId: CHAIN_IDs.HYPERCORE, + depositor: evmDepositor, + }, + }, +]; diff --git a/scripts/tests/_swap-utils.ts b/scripts/tests/_swap-utils.ts index 2296d50e6..af8cb880c 100644 --- a/scripts/tests/_swap-utils.ts +++ b/scripts/tests/_swap-utils.ts @@ -22,6 +22,7 @@ import { EXACT_INPUT_CASES, LENS_CASES, SOLANA_CASES, + USDT_OFT_COMPOSER_CASE, } from "./_swap-cases"; dotenv.config({ @@ -369,6 +370,8 @@ export async function signAndWaitAllowanceFlow(params: { data: params.swapResponse.swapTx.data, value: params.swapResponse.swapTx.value, gasLimit: params.swapResponse.swapTx.gas, + maxFeePerGas: params.swapResponse.swapTx.maxFeePerGas, + maxPriorityFeePerGas: params.swapResponse.swapTx.maxPriorityFeePerGas, }); console.log("Tx hash: ", tx.hash); await tx.wait(); diff --git a/test/api/_bridges/oft/strategy.test.ts b/test/api/_bridges/oft/strategy.test.ts new file mode 100644 index 000000000..9299d981a --- /dev/null +++ b/test/api/_bridges/oft/strategy.test.ts @@ -0,0 +1,273 @@ +import { + getHyperLiquidComposerMessage, + isRouteSupported, + getOftCrossSwapTypes, +} from "../../../../api/_bridges/oft/strategy"; +import { + HYPEREVM_OFT_COMPOSER_ADDRESSES, + OFT_MESSENGERS, +} from "../../../../api/_bridges/oft/utils/constants"; +import { CHAIN_IDs } from "@across-protocol/constants"; +import { Token } from "../../../../api/_dexes/types"; +import { ethers } from "ethers"; +import { CROSS_SWAP_TYPE } from "../../../../api/_dexes/utils"; +import { TOKEN_SYMBOLS_MAP } from "../../../../api/_constants"; +import { assert } from "console"; + +describe("OFT Strategy", () => { + describe("getHyperLiquidComposerMessage", () => { + const recipient = "0x0000000000000000000000000000000000000001"; + const tokenSymbol = "USDT-SPOT"; + + it("should return the correct message for HyperLiquid", () => { + const { composeMsg, toAddress, extraOptions } = + getHyperLiquidComposerMessage(recipient, tokenSymbol); + + const expectedComposeMsg = ethers.utils.defaultAbiCoder.encode( + ["uint256", "address"], + [0, recipient] + ); + + expect(composeMsg).toBe(expectedComposeMsg); + expect(toAddress).toBe(HYPEREVM_OFT_COMPOSER_ADDRESSES[tokenSymbol]); + expect(extraOptions).toBe( + "0x00030100130300000000000000000000000000000000ea60" + ); + }); + + it("should throw an error for an unsupported token", () => { + const unsupportedToken = "UNSUPPORTED"; + expect(() => + getHyperLiquidComposerMessage(recipient, unsupportedToken) + ).toThrow( + `OFT: No Hyperliquid Composer contract configured for token ${unsupportedToken}` + ); + }); + }); + + describe("isRouteSupported", () => { + it("should return true for all supported routes", () => { + for (const tokenSymbol in OFT_MESSENGERS) { + const supportedChains = Object.keys(OFT_MESSENGERS[tokenSymbol]).map( + Number + ); + // Need at least two supported chains to form a route + if (supportedChains.length < 2) continue; + + for ( + let originIndex = 0; + originIndex < supportedChains.length; + originIndex++ + ) { + for ( + let destinationIndex = originIndex + 1; + destinationIndex < supportedChains.length; + destinationIndex++ + ) { + const tokens = + TOKEN_SYMBOLS_MAP[tokenSymbol as keyof typeof TOKEN_SYMBOLS_MAP]; + assert( + tokens, + `Token ${tokenSymbol} not found in TOKEN_SYMBOLS_MAP` + ); + + const originChainId = supportedChains[originIndex]; + const destinationChainId = supportedChains[destinationIndex]; + const params = { + inputToken: { + symbol: tokenSymbol, + chainId: originChainId, + address: tokens.addresses[originChainId], + decimals: tokens.decimals, + }, + outputToken: { + symbol: tokenSymbol, + chainId: destinationChainId, + address: tokens.addresses[destinationChainId], + decimals: tokens.decimals, + }, + }; + expect(isRouteSupported(params)).toBe(true); + } + } + } + }); + + it("should return false if tokens are different", () => { + const tokenSymbols = Object.keys(OFT_MESSENGERS); + // Need at least two supported tokens to form a route + if (tokenSymbols.length < 2) return; + + const tokenA = tokenSymbols[0]; + const tokenB = tokenSymbols[1]; + const chainA = Number(Object.keys(OFT_MESSENGERS[tokenA])[0]); + const chainB = Number(Object.keys(OFT_MESSENGERS[tokenB])[0]); + + const params = { + inputToken: { + symbol: tokenA, + chainId: chainA, + address: "0x1", + decimals: 18, + }, + outputToken: { + symbol: tokenB, + chainId: chainB, + address: "0x2", + decimals: 18, + }, + }; + expect(isRouteSupported(params)).toBe(false); + }); + + it("should return false if origin chain is not supported", () => { + for (const tokenSymbol in OFT_MESSENGERS) { + const supportedChains = Object.keys(OFT_MESSENGERS[tokenSymbol]).map( + Number + ); + if (supportedChains.length === 0) continue; + + const supportedChain = supportedChains[0]; + const unsupportedChain = 99999; // A chain ID that is not in any list + + const params = { + inputToken: { + symbol: tokenSymbol, + chainId: unsupportedChain, + address: "0x1", + decimals: 18, + }, + outputToken: { + symbol: tokenSymbol, + chainId: supportedChain, + address: "0x1", + decimals: 18, + }, + }; + expect(isRouteSupported(params)).toBe(false); + } + }); + + it("should return true for supported routes to Hypercore", () => { + for (const tokenSymbol in HYPEREVM_OFT_COMPOSER_ADDRESSES) { + if (OFT_MESSENGERS[tokenSymbol]) { + const supportedChains = Object.keys(OFT_MESSENGERS[tokenSymbol]).map( + Number + ); + // Needs at least one supported chain for the token symbol to form a route to Hypercore + if (supportedChains.length === 0) continue; + + const supportedChain = supportedChains[0]; + const params = { + inputToken: { + symbol: tokenSymbol, + chainId: supportedChain, + address: "0x1", + decimals: 18, + }, + outputToken: { + symbol: tokenSymbol, + chainId: CHAIN_IDs.HYPERCORE, + address: "0x1", + decimals: 18, + }, + }; + expect(isRouteSupported(params)).toBe(true); + } + } + }); + + it("should return false for unsupported routes to Hypercore", () => { + const allOftSymbols = Object.keys(OFT_MESSENGERS); + const hypercoreSymbols = Object.keys(HYPEREVM_OFT_COMPOSER_ADDRESSES); + const unsupportedSymbol = allOftSymbols.find( + (s) => !hypercoreSymbols.includes(s) + ); + + if (unsupportedSymbol) { + const supportedChains = Object.keys( + OFT_MESSENGERS[unsupportedSymbol] + ).map(Number); + if (supportedChains.length > 0) { + const params = { + inputToken: { + symbol: unsupportedSymbol, + chainId: supportedChains[0], + address: "0x1", + decimals: 18, + }, + outputToken: { + symbol: unsupportedSymbol, + chainId: CHAIN_IDs.HYPERCORE, + address: "0x1", + decimals: 18, + }, + }; + expect(isRouteSupported(params)).toBe(false); + } + } + }); + }); + + describe("getOftCrossSwapTypes", () => { + const arbitrumUSDT: Token = { + address: TOKEN_SYMBOLS_MAP.USDT.addresses[CHAIN_IDs.ARBITRUM], + symbol: "USDT", + decimals: 6, + chainId: CHAIN_IDs.ARBITRUM, + }; + + const polygonUSDT: Token = { + address: TOKEN_SYMBOLS_MAP.USDT.addresses[CHAIN_IDs.POLYGON], + symbol: "USDT", + decimals: 6, + chainId: CHAIN_IDs.POLYGON, + }; + + const hyperCoreUSDT: Token = { + address: TOKEN_SYMBOLS_MAP["USDT-SPOT"].addresses[CHAIN_IDs.HYPERCORE], + symbol: "USDT-SPOT", + decimals: 8, + chainId: CHAIN_IDs.HYPERCORE, + }; + + const unsupportedToken: Token = { + address: "0x123", + symbol: "UNSUPPORTED", + decimals: 18, + chainId: CHAIN_IDs.ARBITRUM, + }; + it("should return BRIDGEABLE_TO_BRIDGEABLE if route is supported", () => { + const result = getOftCrossSwapTypes({ + inputToken: arbitrumUSDT, + outputToken: polygonUSDT, + isInputNative: false, + isOutputNative: false, + }); + + expect(result).toEqual([CROSS_SWAP_TYPE.BRIDGEABLE_TO_BRIDGEABLE]); + }); + + it("should return BRIDGEABLE_TO_BRIDGEABLE if route is supported", () => { + const result = getOftCrossSwapTypes({ + inputToken: polygonUSDT, + outputToken: hyperCoreUSDT, + isInputNative: false, + isOutputNative: false, + }); + + expect(result).toEqual([CROSS_SWAP_TYPE.BRIDGEABLE_TO_BRIDGEABLE]); + }); + + it("should return an empty array if route is not supported", () => { + const result = getOftCrossSwapTypes({ + inputToken: arbitrumUSDT, + outputToken: unsupportedToken, + isInputNative: false, + isOutputNative: false, + }); + + expect(result).toEqual([]); + }); + }); +}); From 38b77a864b3600453b08473f000614c573d19fb7 Mon Sep 17 00:00:00 2001 From: Melisa Guevara Date: Mon, 27 Oct 2025 17:29:18 +0100 Subject: [PATCH 16/18] chore: sync epic branch with master (#1924) From 1e4bea167a0615ced17302f8c5d40f61c5320c0e Mon Sep 17 00:00:00 2001 From: Melisa Guevara Date: Tue, 28 Oct 2025 08:55:42 +0100 Subject: [PATCH 17/18] chore: add USDT/USDC HyperCore market (#1925) --- api/_hypercore.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/api/_hypercore.ts b/api/_hypercore.ts index be940913d..0f88e9b98 100644 --- a/api/_hypercore.ts +++ b/api/_hypercore.ts @@ -11,6 +11,7 @@ const HYPERLIQUID_API_BASE_URL = "https://api.hyperliquid.xyz"; // See: https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint#perpetuals-vs-spot const L2_ORDER_BOOK_COIN_MAP: Record = { "USDH/USDC": "@230", + "USDT/USDC": "@166", }; // Contract used to query Hypercore balances from EVM From 6e8e5c4506b295230518e7a4d7d94bfcf2a45930 Mon Sep 17 00:00:00 2001 From: Melisa Guevara Date: Thu, 30 Oct 2025 11:22:55 +0100 Subject: [PATCH 18/18] feat: sponsored oft transfers (#1926) --- api/_bridges/index.ts | 2 +- api/_bridges/oft-sponsored/strategy.ts | 436 +++++++++++++++++ api/_bridges/oft-sponsored/utils/abi.ts | 79 +++ api/_bridges/oft-sponsored/utils/constants.ts | 57 +++ .../utils/eligibility.ts | 0 .../oft-sponsored/utils/quote-builder.ts | 118 +++++ .../utils/routing.ts | 4 +- .../oft.ts => oft-sponsored/utils/signing.ts} | 30 +- api/_bridges/oft/strategy.ts | 372 +------------- api/_bridges/oft/utils/shared.ts | 368 ++++++++++++++ api/_bridges/sponsored/strategy.ts | 83 ---- api/_bridges/sponsored/utils/signing.ts | 0 api/_bridges/sponsorship/cctp.ts | 2 +- api/_bridges/sponsorship/index.ts | 3 - api/_bridges/sponsorship/utils/index.ts | 1 - api/_dexes/types.ts | 2 +- ...signature.ts => _sponsorship-signature.ts} | 5 +- e2e-api/bridges/oft/strategy.test.ts | 13 +- .../_bridges/oft-sponsored/strategy.test.ts | 462 ++++++++++++++++++ test/api/_bridges/oft/strategy.test.ts | 5 +- test/api/_bridges/sponsorship/cctp.test.ts | 2 +- test/api/_bridges/sponsorship/oft.test.ts | 13 +- 22 files changed, 1578 insertions(+), 479 deletions(-) create mode 100644 api/_bridges/oft-sponsored/strategy.ts create mode 100644 api/_bridges/oft-sponsored/utils/abi.ts create mode 100644 api/_bridges/oft-sponsored/utils/constants.ts rename api/_bridges/{sponsored => oft-sponsored}/utils/eligibility.ts (100%) create mode 100644 api/_bridges/oft-sponsored/utils/quote-builder.ts rename api/_bridges/{sponsored => oft-sponsored}/utils/routing.ts (96%) rename api/_bridges/{sponsorship/oft.ts => oft-sponsored/utils/signing.ts} (70%) create mode 100644 api/_bridges/oft/utils/shared.ts delete mode 100644 api/_bridges/sponsored/strategy.ts delete mode 100644 api/_bridges/sponsored/utils/signing.ts delete mode 100644 api/_bridges/sponsorship/index.ts delete mode 100644 api/_bridges/sponsorship/utils/index.ts rename api/{_bridges/sponsorship/utils/signature.ts => _sponsorship-signature.ts} (91%) create mode 100644 test/api/_bridges/oft-sponsored/strategy.test.ts diff --git a/api/_bridges/index.ts b/api/_bridges/index.ts index 459f1aa13..1289aad3e 100644 --- a/api/_bridges/index.ts +++ b/api/_bridges/index.ts @@ -9,7 +9,7 @@ import { import { CHAIN_IDs } from "../_constants"; import { getCctpBridgeStrategy } from "./cctp/strategy"; import { routeStrategyForCctp } from "./cctp/utils/routing"; -import { routeStrategyForSponsorship } from "./sponsored/utils/routing"; +import { routeStrategyForSponsorship } from "./oft-sponsored/utils/routing"; export const bridgeStrategies: BridgeStrategiesConfig = { default: getAcrossBridgeStrategy(), diff --git a/api/_bridges/oft-sponsored/strategy.ts b/api/_bridges/oft-sponsored/strategy.ts new file mode 100644 index 000000000..51e556013 --- /dev/null +++ b/api/_bridges/oft-sponsored/strategy.ts @@ -0,0 +1,436 @@ +import { BigNumber, ethers } from "ethers"; +import { + BridgeCapabilities, + BridgeStrategy, + GetExactInputBridgeQuoteParams, + GetOutputBridgeQuoteParams, +} from "../types"; +import { OFT_MESSENGERS } from "../oft/utils/constants"; +import { + getEstimatedFillTime, + getOftBridgeFees, + getQuote, + roundAmountToSharedDecimals, +} from "../oft/utils/shared"; +import { CHAIN_IDs, TOKEN_SYMBOLS_MAP } from "../../_constants"; +import { CrossSwap, CrossSwapQuotes, Token } from "../../_dexes/types"; +import { + AppFee, + assertMinOutputAmount, + CROSS_SWAP_TYPE, + getFallbackRecipient, +} from "../../_dexes/utils"; +import { InvalidParamError } from "../../_errors"; +import { simulateMarketOrder } from "../../_hypercore"; +import { tagIntegratorId, tagSwapApiMarker } from "../../_integrator-id"; +import { ConvertDecimals, getCachedTokenInfo } from "../../_utils"; +import { getNativeTokenInfo } from "../../swap/_utils"; +import { SPONSORED_OFT_SRC_PERIPHERY_ABI } from "./utils/abi"; +import { + DST_OFT_HANDLER, + SPONSORED_OFT_DESTINATION_CHAINS, + SPONSORED_OFT_INPUT_TOKENS, + SPONSORED_OFT_OUTPUT_TOKENS, + SPONSORED_OFT_SRC_PERIPHERY, +} from "./utils/constants"; +import { buildSponsoredOFTQuote } from "./utils/quote-builder"; + +const name = "sponsored-oft" as const; + +const capabilities: BridgeCapabilities = { + ecosystems: ["evm"], + supports: { + A2A: false, + A2B: false, + B2A: true, + B2B: true, + B2BI: false, + crossChainMessage: false, + }, +}; + +/** + * Checks if a route is supported for sponsored OFT transfers. + */ +export function isRouteSupported(params: { + inputToken: Token; + outputToken: Token; +}): boolean { + const { inputToken, outputToken } = params; + + // Check if input and output tokens are supported + if ( + !SPONSORED_OFT_INPUT_TOKENS.includes(inputToken.symbol) || + !SPONSORED_OFT_OUTPUT_TOKENS.includes(outputToken.symbol) + ) { + return false; + } + + // Check if destination chain is supported + if (!SPONSORED_OFT_DESTINATION_CHAINS.includes(outputToken.chainId)) { + return false; + } + + // Check if OFT messenger exists for input chain + const oftMessengers = OFT_MESSENGERS[inputToken.symbol]; + if (!oftMessengers || !oftMessengers[inputToken.chainId]) { + return false; + } + + // Check if source periphery exists for input chain + if (!SPONSORED_OFT_SRC_PERIPHERY[inputToken.chainId]) { + return false; + } + + // Check if destination handler exists for intermediary chain + const intermediaryChain = CHAIN_IDs.HYPEREVM; + if (!DST_OFT_HANDLER[intermediaryChain]) { + return false; + } + + return true; +} + +/** + * Gets the intermediary token for sponsored OFT transfers. + * All sponsored OFT transfers go through HyperEVM USDT as an intermediary + * before reaching the final destination. + */ +async function getIntermediaryToken(): Promise { + const hyperevmUsdtAddress = + TOKEN_SYMBOLS_MAP.USDT.addresses[CHAIN_IDs.HYPEREVM]; + if (!hyperevmUsdtAddress) { + throw new InvalidParamError({ + message: "HyperEVM USDT address not found", + }); + } + + const tokenInfo = await getCachedTokenInfo({ + address: hyperevmUsdtAddress, + chainId: CHAIN_IDs.HYPEREVM, + }); + + return tokenInfo; +} + +/** + * Gets a quote for an exact input amount (user specifies input, gets output) + */ +export async function getSponsoredOftQuoteForExactInput( + params: GetExactInputBridgeQuoteParams +) { + const { inputToken, outputToken, exactInputAmount, recipient } = params; + + // Get intermediary token (HyperEVM USDT) + // All sponsored OFT transfers route through HyperEVM USDT before reaching final destination + const intermediaryToken = await getIntermediaryToken(); + + // Get OFT quote to intermediary token and estimated fill time + const [{ inputAmount, outputAmount, nativeFee }, estimatedFillTimeSec] = + await Promise.all([ + getQuote({ + inputToken, + outputToken: intermediaryToken, + inputAmount: exactInputAmount, + recipient: recipient!, + }), + getEstimatedFillTime( + inputToken.chainId, + intermediaryToken.chainId, + inputToken.symbol + ), + ]); + + const nativeToken = getNativeTokenInfo(inputToken.chainId); + + // Convert output amount from intermediary token decimals to final output token decimals + const finalOutputAmount = ConvertDecimals( + intermediaryToken.decimals, + outputToken.decimals + )(outputAmount); + + return { + bridgeQuote: { + inputToken, + outputToken, + inputAmount, + outputAmount: finalOutputAmount, + minOutputAmount: finalOutputAmount, + estimatedFillTimeSec, + provider: name, + fees: getOftBridgeFees({ + inputToken, + nativeFee, + nativeToken, + }), + }, + }; +} + +/** + * Gets a quote for a desired output amount (user specifies output, gets required input) + */ +export async function getSponsoredOftQuoteForOutput( + params: GetOutputBridgeQuoteParams +) { + const { inputToken, outputToken, minOutputAmount, recipient } = params; + + // Get intermediary token (HyperEVM USDT) + // All sponsored OFT transfers route through HyperEVM USDT before reaching final destination + const intermediaryToken = await getIntermediaryToken(); + + // Convert minOutputAmount to input token decimals + const minOutputInInputDecimals = ConvertDecimals( + outputToken.decimals, + inputToken.decimals + )(minOutputAmount); + + // Get OFT quote to intermediary token and estimated fill time + const [ + { inputAmount, outputAmount: intermediaryOutputAmount, nativeFee }, + estimatedFillTimeSec, + ] = await Promise.all([ + getQuote({ + inputToken, + outputToken: intermediaryToken, + inputAmount: minOutputInInputDecimals, + recipient: recipient!, + }), + getEstimatedFillTime( + inputToken.chainId, + intermediaryToken.chainId, + inputToken.symbol + ), + ]); + + // Convert output amount from intermediary token decimals to output token decimals + const finalOutputAmount = ConvertDecimals( + intermediaryToken.decimals, + outputToken.decimals + )(intermediaryOutputAmount); + + // OFT precision limitations may prevent delivering the exact minimum amount + // We validate against the rounded amount (maximum possible given shared decimals) + const roundedMinOutputAmount = roundAmountToSharedDecimals( + minOutputAmount, + inputToken.symbol, + inputToken.decimals + ); + assertMinOutputAmount(finalOutputAmount, roundedMinOutputAmount); + + const nativeToken = getNativeTokenInfo(inputToken.chainId); + + return { + bridgeQuote: { + inputToken, + outputToken, + inputAmount, + outputAmount: finalOutputAmount, + minOutputAmount: finalOutputAmount, + estimatedFillTimeSec, + provider: name, + fees: getOftBridgeFees({ + inputToken, + nativeFee, + nativeToken, + }), + }, + }; +} + +/** + * Calculates the maximum basis points to sponsor for a given output token + * @param outputTokenSymbol - The symbol of the output token (e.g., "USDT-SPOT", "USDC-SPOT") + * @param bridgeInputAmount - The input amount being bridged (in input token decimals) + * @param bridgeOutputAmount - The output amount from the bridge (in intermediary token decimals) + * @returns The maximum basis points to sponsor + */ +export async function calculateMaxBpsToSponsor(params: { + outputTokenSymbol: string; + bridgeInputAmount: BigNumber; + bridgeOutputAmount: BigNumber; +}): Promise { + const { outputTokenSymbol, bridgeInputAmount, bridgeOutputAmount } = params; + + if (outputTokenSymbol === "USDT-SPOT") { + // USDT -> USDT: 0 bps (no swap needed, no sponsorship needed) + return BigNumber.from(0); + } + + if (outputTokenSymbol === "USDC-SPOT") { + // USDT -> USDC: Calculate sponsorship needed to guarantee 1:1 output + + // Simulate the swap on HyperCore to get estimated output + const simulation = await simulateMarketOrder({ + tokenIn: { + symbol: "USDT", + decimals: TOKEN_SYMBOLS_MAP["USDT-SPOT"].decimals, + }, + tokenOut: { + symbol: "USDC", + decimals: TOKEN_SYMBOLS_MAP["USDT-SPOT"].decimals, // TODO: Update to use USDC-SPOT when available + }, + inputAmount: bridgeOutputAmount, + }); + + // Expected output (1:1): same amount as initial input after decimal conversion + const expectedOutput = bridgeInputAmount; + + const swapOutput = simulation.outputAmount; + const swapOutputInInputDecimals = ConvertDecimals( + TOKEN_SYMBOLS_MAP["USDT-SPOT"].decimals, + TOKEN_SYMBOLS_MAP.USDT.decimals + )(swapOutput); + + // Calculate loss if swap output is less than expected + if (swapOutputInInputDecimals.lt(expectedOutput)) { + const loss = expectedOutput.sub(swapOutputInInputDecimals); + // Loss as basis points: (loss / input) * 10000 + const lossBps = loss.mul(10000).div(bridgeInputAmount); + return BigNumber.from(Math.ceil(lossBps.toNumber())); + } + + // No loss or profit from swap, no sponsorship needed + return BigNumber.from(0); + } + + throw new InvalidParamError({ + message: `Unsupported output token: ${outputTokenSymbol}`, + }); +} + +/** + * Builds transaction for sponsored OFT flow + */ +async function buildTransaction(params: { + crossSwap: CrossSwap; + bridgeQuote: CrossSwapQuotes["bridgeQuote"]; + integratorId?: string; +}) { + const { crossSwap, bridgeQuote, integratorId } = params; + + const originChainId = crossSwap.inputToken.chainId; + + // Get source periphery contract address + const srcPeripheryAddress = SPONSORED_OFT_SRC_PERIPHERY[originChainId]; + if (!srcPeripheryAddress) { + throw new InvalidParamError({ + message: `Sponsored OFT source periphery not found for chain ${originChainId}`, + }); + } + + // Calculate maxBpsToSponsor based on output token and market simulation + const maxBpsToSponsor = await calculateMaxBpsToSponsor({ + outputTokenSymbol: crossSwap.outputToken.symbol, + bridgeInputAmount: bridgeQuote.inputAmount, + bridgeOutputAmount: bridgeQuote.outputAmount, + }); + + // Convert slippage tolerance to bps (slippageTolerance is a decimal, e.g., 0.005 = 0.5% = 50 bps) + const maxUserSlippageBps = Math.floor(crossSwap.slippageTolerance * 10000); + + // Build signed quote with signature + const { quote, signature } = await buildSponsoredOFTQuote({ + inputToken: crossSwap.inputToken, + outputToken: crossSwap.outputToken, + inputAmount: bridgeQuote.inputAmount, + recipient: crossSwap.recipient, + depositor: crossSwap.depositor, + refundRecipient: getFallbackRecipient(crossSwap, crossSwap.recipient), + maxBpsToSponsor, + maxUserSlippageBps, + }); + + // Encode the deposit call + const iface = new ethers.utils.Interface(SPONSORED_OFT_SRC_PERIPHERY_ABI); + const callData = iface.encodeFunctionData("deposit", [quote, signature]); + + // Handle integrator ID and swap API marker tagging + const callDataWithIntegratorId = integratorId + ? tagIntegratorId(integratorId, callData) + : callData; + const callDataWithMarkers = tagSwapApiMarker(callDataWithIntegratorId); + + return { + chainId: originChainId, + from: crossSwap.depositor, + to: srcPeripheryAddress, + data: callDataWithMarkers, + value: bridgeQuote.fees.bridgeFee.total, // Native fee for LayerZero + ecosystem: "evm" as const, + }; +} + +/** + * OFT sponsored bridge strategy + */ +export function getOftSponsoredBridgeStrategy(): BridgeStrategy { + return { + name, + capabilities, + + originTxNeedsAllowance: true, + + isRouteSupported: ({ inputToken, outputToken }) => { + return isRouteSupported({ inputToken, outputToken }); + }, + + getCrossSwapTypes: ({ inputToken, outputToken }) => { + // Routes supported: USDT → USDT-SPOT or USDC-SPOT + if ( + inputToken.symbol === "USDT" && + (outputToken.symbol === "USDT-SPOT" || + outputToken.symbol === "USDC-SPOT") + ) { + return [CROSS_SWAP_TYPE.BRIDGEABLE_TO_BRIDGEABLE]; + } + + return []; + }, + + getBridgeQuoteRecipient: (crossSwap: CrossSwap) => { + return crossSwap.recipient; + }, + + getBridgeQuoteMessage: (_crossSwap: CrossSwap, _appFee?: AppFee) => { + return "0x"; + }, + + getQuoteForExactInput: getSponsoredOftQuoteForExactInput, + + getQuoteForOutput: getSponsoredOftQuoteForOutput, + + buildTxForAllowanceHolder: async (params: { + quotes: CrossSwapQuotes; + integratorId?: string; + }) => { + const { + bridgeQuote, + crossSwap, + originSwapQuote, + destinationSwapQuote, + appFee, + } = params.quotes; + + // Sponsored bridge validations + if (appFee?.feeAmount.gt(0)) { + throw new InvalidParamError({ + message: "App fee is not supported for sponsored bridge transfers", + }); + } + + if (originSwapQuote || destinationSwapQuote) { + throw new InvalidParamError({ + message: + "Origin/destination swaps are not supported for sponsored bridge transfers", + }); + } + + return buildTransaction({ + crossSwap, + bridgeQuote, + integratorId: params.integratorId, + }); + }, + }; +} diff --git a/api/_bridges/oft-sponsored/utils/abi.ts b/api/_bridges/oft-sponsored/utils/abi.ts new file mode 100644 index 000000000..8278949b4 --- /dev/null +++ b/api/_bridges/oft-sponsored/utils/abi.ts @@ -0,0 +1,79 @@ +/** + * ABI for SponsoredOFTSrcPeriphery contract + * Source periphery contract that users interact with to start sponsored OFT flows + */ +export const SPONSORED_OFT_SRC_PERIPHERY_ABI = [ + { + inputs: [ + { + components: [ + { + components: [ + { internalType: "uint32", name: "srcEid", type: "uint32" }, + { internalType: "uint32", name: "dstEid", type: "uint32" }, + { + internalType: "bytes32", + name: "destinationHandler", + type: "bytes32", + }, + { internalType: "uint256", name: "amountLD", type: "uint256" }, + { internalType: "bytes32", name: "nonce", type: "bytes32" }, + { internalType: "uint256", name: "deadline", type: "uint256" }, + { + internalType: "uint256", + name: "maxBpsToSponsor", + type: "uint256", + }, + { + internalType: "bytes32", + name: "finalRecipient", + type: "bytes32", + }, + { internalType: "bytes32", name: "finalToken", type: "bytes32" }, + { + internalType: "uint256", + name: "lzReceiveGasLimit", + type: "uint256", + }, + { + internalType: "uint256", + name: "lzComposeGasLimit", + type: "uint256", + }, + { internalType: "uint8", name: "executionMode", type: "uint8" }, + { internalType: "bytes", name: "actionData", type: "bytes" }, + ], + internalType: "struct SignedQuoteParams", + name: "signedParams", + type: "tuple", + }, + { + components: [ + { + internalType: "address", + name: "refundRecipient", + type: "address", + }, + { + internalType: "uint256", + name: "maxUserSlippageBps", + type: "uint256", + }, + ], + internalType: "struct UnsignedQuoteParams", + name: "unsignedParams", + type: "tuple", + }, + ], + internalType: "struct Quote", + name: "quote", + type: "tuple", + }, + { internalType: "bytes", name: "signature", type: "bytes" }, + ], + name: "deposit", + outputs: [], + stateMutability: "payable", + type: "function", + }, +]; diff --git a/api/_bridges/oft-sponsored/utils/constants.ts b/api/_bridges/oft-sponsored/utils/constants.ts new file mode 100644 index 000000000..2c3e0ea5b --- /dev/null +++ b/api/_bridges/oft-sponsored/utils/constants.ts @@ -0,0 +1,57 @@ +import { CHAIN_IDs } from "../../../_constants"; + +/** + * Execution modes for destination handler + * Must match the ExecutionMode enum in the destination contract + */ +export enum ExecutionMode { + Default = 0, // Default HyperCore flow + ArbitraryActionsToCore = 1, // Execute arbitrary actions then transfer to HyperCore + ArbitraryActionsToEVM = 2, // Execute arbitrary actions then stay on EVM +} + +/** + * SponsoredOFTSrcPeriphery contract addresses per chain + * TODO: Update with actual deployed addresses + */ +export const SPONSORED_OFT_SRC_PERIPHERY: Record = { + [CHAIN_IDs.MAINNET]: "0x0000000000000000000000000000000000000000", // TODO + [CHAIN_IDs.ARBITRUM]: "0x1235Ac1010FeeC8ae22744f323416cBBE37feDbE", // TODO + [CHAIN_IDs.HYPEREVM]: "0x0000000000000000000000000000000000000000", // TODO + [CHAIN_IDs.POLYGON]: "0x0000000000000000000000000000000000000000", // TODO +}; + +/** + * DstOFTHandler contract addresses per chain + * TODO: Update with actual deployed addresses + */ +export const DST_OFT_HANDLER: Record = { + [CHAIN_IDs.HYPEREVM]: "0x0000000000000000000000000000000000000000", // TODO +}; + +/** + * Default gas limits for LayerZero execution + * @todo These are estimated values and should be updated based on actual gas usage of executed transactions + */ +export const DEFAULT_LZ_RECEIVE_GAS_LIMIT = 175_000; +export const DEFAULT_LZ_COMPOSE_GAS_LIMIT = 300_000; + +/** + * Default quote expiry time (15 minutes) + */ +export const DEFAULT_QUOTE_EXPIRY_SECONDS = 15 * 60; + +/** + * Supported input tokens for sponsored OFT flows + */ +export const SPONSORED_OFT_INPUT_TOKENS = ["USDT"]; + +/** + * Supported output tokens for sponsored OFT flows + */ +export const SPONSORED_OFT_OUTPUT_TOKENS = ["USDT-SPOT", "USDC-SPOT"]; + +/** + * Supported destination chains for sponsored OFT flows + */ +export const SPONSORED_OFT_DESTINATION_CHAINS = [CHAIN_IDs.HYPERCORE]; diff --git a/api/_bridges/sponsored/utils/eligibility.ts b/api/_bridges/oft-sponsored/utils/eligibility.ts similarity index 100% rename from api/_bridges/sponsored/utils/eligibility.ts rename to api/_bridges/oft-sponsored/utils/eligibility.ts diff --git a/api/_bridges/oft-sponsored/utils/quote-builder.ts b/api/_bridges/oft-sponsored/utils/quote-builder.ts new file mode 100644 index 000000000..6698f7dff --- /dev/null +++ b/api/_bridges/oft-sponsored/utils/quote-builder.ts @@ -0,0 +1,118 @@ +import { BigNumber, utils } from "ethers"; +import { Token } from "../../../_dexes/types"; +import { + SignedQuoteParams, + UnsignedQuoteParams, + SponsoredOFTQuote, + createOftSignature, +} from "./signing"; +import { toBytes32 } from "../../../_address"; +import { getOftEndpointId } from "../../oft/utils/constants"; +import { + ExecutionMode, + DEFAULT_LZ_RECEIVE_GAS_LIMIT, + DEFAULT_LZ_COMPOSE_GAS_LIMIT, + DEFAULT_QUOTE_EXPIRY_SECONDS, + DST_OFT_HANDLER, +} from "./constants"; +import { CHAIN_IDs } from "../../../_constants"; + +/** + * Generates a unique nonce for a quote + * Uses keccak256 hash of timestamp in milliseconds + depositor address + */ +function generateQuoteNonce(depositor: string): string { + const timestamp = Date.now(); + const encoded = utils.defaultAbiCoder.encode( + ["uint256", "address"], + [timestamp, depositor] + ); + return utils.keccak256(encoded); +} + +/** + * Parameters for building a sponsored OFT quote + */ +export interface BuildSponsoredOFTQuoteParams { + inputToken: Token; + outputToken: Token; + inputAmount: BigNumber; + recipient: string; + depositor: string; + refundRecipient: string; + maxBpsToSponsor: BigNumber; + maxUserSlippageBps: number; +} + +/** + * Builds a complete sponsored OFT quote with signature + * @param params Quote building parameters + * @returns Complete quote with signed and unsigned params, plus signature + */ +export async function buildSponsoredOFTQuote( + params: BuildSponsoredOFTQuoteParams +): Promise<{ quote: SponsoredOFTQuote; signature: string; hash: string }> { + const { + inputToken, + outputToken, + inputAmount, + recipient, + depositor, + refundRecipient, + maxBpsToSponsor, + maxUserSlippageBps, + } = params; + + // Generate unique nonce + const nonce = generateQuoteNonce(depositor); + + // Calculate deadline (current time + expiry) + const deadline = Math.floor(Date.now() / 1000) + DEFAULT_QUOTE_EXPIRY_SECONDS; + + // Get LayerZero endpoint IDs + // All sponsored OFT transfers route through HyperEVM as intermediary chain + const srcEid = getOftEndpointId(inputToken.chainId); + const intermediaryChainId = CHAIN_IDs.HYPEREVM; + const dstEid = getOftEndpointId(intermediaryChainId); + + // Get destination handler address for intermediary chain + const destinationHandlerAddress = DST_OFT_HANDLER[intermediaryChainId]; + if (!destinationHandlerAddress) { + throw new Error( + `Destination handler not found for intermediary chain ${intermediaryChainId}` + ); + } + + // Build signed parameters + const signedParams: SignedQuoteParams = { + srcEid, + dstEid, + destinationHandler: toBytes32(destinationHandlerAddress), + amountLD: inputAmount, + nonce, + deadline, + maxBpsToSponsor, + finalRecipient: toBytes32(recipient), + finalToken: toBytes32(outputToken.address), + lzReceiveGasLimit: DEFAULT_LZ_RECEIVE_GAS_LIMIT, + lzComposeGasLimit: DEFAULT_LZ_COMPOSE_GAS_LIMIT, + executionMode: ExecutionMode.Default, // Default HyperCore flow + actionData: "0x", // Empty for default flow + }; + + // Build unsigned parameters + const unsignedParams: UnsignedQuoteParams = { + maxUserSlippageBps, + refundRecipient, + }; + + // Create signature + const { signature, hash } = await createOftSignature(signedParams); + + const quote: SponsoredOFTQuote = { + signedParams, + unsignedParams, + }; + + return { quote, signature, hash }; +} diff --git a/api/_bridges/sponsored/utils/routing.ts b/api/_bridges/oft-sponsored/utils/routing.ts similarity index 96% rename from api/_bridges/sponsored/utils/routing.ts rename to api/_bridges/oft-sponsored/utils/routing.ts index bb952ac5f..a94a17cdf 100644 --- a/api/_bridges/sponsored/utils/routing.ts +++ b/api/_bridges/oft-sponsored/utils/routing.ts @@ -1,4 +1,4 @@ -import { getSponsoredBridgeStrategy } from "../strategy"; +import { getOftSponsoredBridgeStrategy } from "../strategy"; import { BridgeStrategy, BridgeStrategyDataParams, @@ -54,7 +54,7 @@ const SPONSORSHIP_ROUTING_RULES: SponsorshipRoutingRule[] = [ data.hasVaultBalance && data.isSlippageAcceptable && data.isAccountCreationValid, - getStrategy: getSponsoredBridgeStrategy, + getStrategy: getOftSponsoredBridgeStrategy, reason: "All sponsorship eligibility criteria met", }, ]; diff --git a/api/_bridges/sponsorship/oft.ts b/api/_bridges/oft-sponsored/utils/signing.ts similarity index 70% rename from api/_bridges/sponsorship/oft.ts rename to api/_bridges/oft-sponsored/utils/signing.ts index 261d37ee7..a0bd5c423 100644 --- a/api/_bridges/sponsorship/oft.ts +++ b/api/_bridges/oft-sponsored/utils/signing.ts @@ -1,5 +1,5 @@ import { BigNumberish, utils } from "ethers"; -import { signMessageWithSponsor } from "./utils"; +import { signDigestWithSponsor } from "../../../_sponsorship-signature"; /** * Represents the signed parameters of a sponsored OFT quote. @@ -19,6 +19,25 @@ export interface SignedQuoteParams { finalToken: string; lzReceiveGasLimit: BigNumberish; lzComposeGasLimit: BigNumberish; + executionMode: number; + actionData: string; +} + +/** + * Represents the unsigned parameters of a sponsored OFT quote. + * These parameters are not part of the signature but are still required for the deposit call. + */ +export interface UnsignedQuoteParams { + maxUserSlippageBps: BigNumberish; + refundRecipient: string; +} + +/** + * Complete quote structure matching the contract's Quote struct + */ +export interface SponsoredOFTQuote { + signedParams: SignedQuoteParams; + unsignedParams: UnsignedQuoteParams; } /** @@ -33,6 +52,7 @@ export const createOftSignature = async ( quote: SignedQuoteParams ): Promise<{ signature: string; hash: string }> => { // ABI-encode all parameters and hash the result to create the digest to be signed. + // Note: actionData is hashed before encoding to match the contract's behavior const encodedData = utils.defaultAbiCoder.encode( [ "uint32", @@ -46,6 +66,8 @@ export const createOftSignature = async ( "bytes32", "uint256", "uint256", + "uint8", + "bytes32", ], [ quote.srcEid, @@ -59,11 +81,13 @@ export const createOftSignature = async ( quote.finalToken, quote.lzReceiveGasLimit, quote.lzComposeGasLimit, + quote.executionMode, + utils.keccak256(quote.actionData), ] ); const hash = utils.keccak256(encodedData); - // The OFT contract expects an EIP-191 compliant signature, so we sign the prefixed hash of the digest. - const signature = await signMessageWithSponsor(utils.arrayify(hash)); + // The OFT contract uses ECDSA.recover(digest, signature) which expects a signature over the raw digest. + const signature = signDigestWithSponsor(hash); return { signature, hash }; }; diff --git a/api/_bridges/oft/strategy.ts b/api/_bridges/oft/strategy.ts index 6d0269d66..93217fa70 100644 --- a/api/_bridges/oft/strategy.ts +++ b/api/_bridges/oft/strategy.ts @@ -1,5 +1,6 @@ import { BigNumber, Contract, ethers } from "ethers"; +import { CHAIN_IDs } from "../../_constants"; import { BridgeStrategy, GetExactInputBridgeQuoteParams, @@ -19,19 +20,17 @@ import { getNativeTokenInfo } from "../../swap/_utils"; import { getOftMessengerForToken, createSendParamStruct, - getOftOriginConfirmations, - getOftEndpointId, - DEFAULT_OFT_REQUIRED_DVNS, - CONFIG_TYPE_ULN, - ENDPOINT_ABI, OFT_ABI, OFT_MESSENGERS, - OFT_SHARED_DECIMALS, - V2_ENDPOINTS, HYPEREVM_OFT_COMPOSER_ADDRESSES, } from "./utils/constants"; -import * as chainConfigs from "../../../scripts/chain-configs"; -import { CHAIN_IDs } from "@across-protocol/constants"; +import { + getEstimatedFillTime, + getHyperLiquidComposerMessage, + getOftBridgeFees, + getQuote, + roundAmountToSharedDecimals, +} from "./utils/shared"; const name = "oft" as const; @@ -47,177 +46,6 @@ const capabilities: BridgeCapabilities = { }, }; -/** - * Rounds the token amount down to the correct precision for OFT transfer. - * The last (tokenDecimals - sharedDecimals) digits must be zero to prevent contract-side rounding. - * Shared decimals is OFT's precision model where tokens use a common decimal precision across all chains. - * Docs: https://docs.layerzero.network/v2/concepts/technical-reference/oft-reference?utm_source=chatgpt.com#1-transferring-value-across-different-vms - * @param amount amount to round - * @param tokenSymbol symbol of the token we need to round decimals for - * @param tokenDecimals decimals of the token - * @returns The amount rounded down to the correct precision - */ -function roundAmountToSharedDecimals( - amount: BigNumber, - tokenSymbol: string, - tokenDecimals: number -): BigNumber { - const sharedDecimals = OFT_SHARED_DECIMALS[tokenSymbol]; - if (sharedDecimals === undefined) { - throw new InvalidParamError({ - message: `Shared decimals not found for token ${tokenSymbol}`, - }); - } - - const decimalDifference = tokenDecimals - sharedDecimals; - if (decimalDifference > 0) { - const divisor = BigNumber.from(10).pow(decimalDifference); - const remainder = amount.mod(divisor); - return amount.sub(remainder); - } - return amount; -} - -/** - * Fetches the number of required DVN signatures for a specific OFT route - * @param originChainId source chain ID - * @param destinationChainId destination chain ID - * @param tokenSymbol token being bridged - * @returns total number of required DVN signatures (requiredDVNs + optionalThreshold) - */ -export async function getRequiredDVNCount( - originChainId: number, - destinationChainId: number, - tokenSymbol: string -): Promise { - try { - const endpointAddress = V2_ENDPOINTS[originChainId]; - if (!endpointAddress) { - return DEFAULT_OFT_REQUIRED_DVNS; - } - - const oappAddress = getOftMessengerForToken(tokenSymbol, originChainId); - const dstEid = getOftEndpointId(destinationChainId); - const provider = getProvider(originChainId); - - const endpoint = new Contract(endpointAddress, ENDPOINT_ABI, provider); - - // Step 1: Find the send library used for this route - const libAddress = await endpoint.getSendLibrary(oappAddress, dstEid); - - // Step 2: Get DVN config for this route - const ulnConfigBytes = await endpoint.getConfig( - oappAddress, - libAddress, - dstEid, - CONFIG_TYPE_ULN - ); - - // Step 3: Decode the UlnConfig struct directly - const ulnConfigStructType = [ - "tuple(uint64 confirmations, uint8 requiredDVNCount, uint8 optionalDVNCount, uint8 optionalDVNThreshold, address[] requiredDVNs, address[] optionalDVNs)", - ]; - const decoded = ethers.utils.defaultAbiCoder.decode( - ulnConfigStructType, - ulnConfigBytes - )[0]; - - const requiredDVNs = decoded.requiredDVNs.length; - const optionalThreshold = decoded.optionalDVNThreshold; - - return requiredDVNs + optionalThreshold; - } catch (error) { - console.error("Error fetching required DVN count", error); - // Fall back to default if fetching fails - return DEFAULT_OFT_REQUIRED_DVNS; - } -} - -/** - * Internal helper to get OFT quote from contracts. - * @note This function is designed for input-based quotes (specify input amount, get output amount). - * Currently works for both input-based and output-based flows because supported tokens (USDT, WBTC) have 0 OFT fees. - * If we ever add tokens with non-zero OFT fees, we need to refactor this function to handle output-based quotes. - * - * @param params The parameters for getting the quote. - * @param params.inputToken The input token. - * @param params.outputToken The output token. - * @param params.inputAmount The input amount. - * @param params.recipient The recipient address. - * @returns A promise that resolves with the quote data, including input amount, output amount, native fee, and OFT fee amount. - */ -export async function getQuote(params: { - inputToken: Token; - outputToken: Token; - inputAmount: BigNumber; - recipient: string; -}) { - const { inputToken, outputToken, inputAmount, recipient } = params; - - // Get OFT messenger contract - const oftMessengerAddress = getOftMessengerForToken( - inputToken.symbol, - inputToken.chainId - ); - const provider = getProvider(inputToken.chainId); - const oftMessengerContract = new Contract( - oftMessengerAddress, - OFT_ABI, - provider - ); - - // Round input amount to correct precision for OFT transfer - // Required to prevent contract-side rounding - const roundedInputAmount = roundAmountToSharedDecimals( - inputAmount, - inputToken.symbol, - inputToken.decimals - ); - const { toAddress, composeMsg, extraOptions } = - outputToken.chainId === CHAIN_IDs.HYPERCORE - ? getHyperLiquidComposerMessage(recipient, outputToken.symbol) - : { toAddress: recipient, composeMsg: "0x", extraOptions: "0x" }; - // Create SendParam struct for quoting - const sendParam = createSendParamStruct({ - // If sending to Hypercore, the destinationChainId in the SendParam is always HyperEVM - destinationChainId: - outputToken.chainId === CHAIN_IDs.HYPERCORE - ? CHAIN_IDs.HYPEREVM - : outputToken.chainId, - toAddress, - amountLD: roundedInputAmount, - minAmountLD: roundedInputAmount, - composeMsg, - extraOptions, - }); - // Get quote from OFT contract - const [messagingFee, oftQuoteResult] = await Promise.all([ - oftMessengerContract.quoteSend(sendParam, false), // false = pay in native token - oftMessengerContract.quoteOFT(sendParam), - ]); - const [, , oftReceipt] = oftQuoteResult; - const nativeFee = BigNumber.from(messagingFee.nativeFee); - - // LD = Local Decimals - amounts in the input token's decimal precision - const amountReceivedLD = BigNumber.from(oftReceipt.amountReceivedLD); - // Convert amountReceivedLD to output token decimals - const outputAmount = ConvertDecimals( - inputToken.decimals, - outputToken.decimals - )(amountReceivedLD); - - // Calculate OFT fees (difference between sent and received in input token decimals) - const amountSentLD = BigNumber.from(oftReceipt.amountSentLD); - const oftFeeAmount = amountSentLD.sub(amountReceivedLD); - - return { - inputAmount: roundedInputAmount, - outputAmount, - nativeFee, - oftFeeAmount, - }; -} - /** * Builds the transaction data for an OFT bridge transfer. * This function takes the quotes and other parameters and constructs the transaction data for sending an OFT. @@ -329,55 +157,6 @@ export async function buildOftTx(params: { }; } -/** - * Estimates the fill time for an OFT bridge transfer. - * The estimation is based on the origin and destination chain block times, the number of origin confirmations, and the number of required DVN signatures. - * The formula is: `(originBlockTime * originConfirmations) + (destinationBlockTime * (2 + numberOfDVNs))` - * See: https://docs.layerzero.network/v2/faq#what-is-the-estimated-delivery-time-for-a-layerzero-message - * - * @param originChainId The origin chain ID. - * @param destinationChainId The destination chain ID. - * @param tokenSymbol The symbol of the token being bridged. - * @returns A promise that resolves with the estimated fill time in seconds. - */ -export async function getEstimatedFillTime( - originChainId: number, - destinationChainId: number, - tokenSymbol: string -): Promise { - const DEFAULT_BLOCK_TIME_SECONDS = 5; - - // Get source chain required confirmations - const originConfirmations = getOftOriginConfirmations(originChainId); - - // Get dynamic DVN count for this specific route - const requiredDVNs = await getRequiredDVNCount( - originChainId, - destinationChainId, - tokenSymbol - ); - - // Get origin and destination block times from chain configs - const originChainConfig = Object.values(chainConfigs).find( - (config) => config.chainId === originChainId - ); - const destinationChainConfig = Object.values(chainConfigs).find( - (config) => config.chainId === destinationChainId - ); - const originBlockTime = - originChainConfig?.blockTimeSeconds ?? DEFAULT_BLOCK_TIME_SECONDS; - const destinationBlockTime = - destinationChainConfig?.blockTimeSeconds ?? DEFAULT_BLOCK_TIME_SECONDS; - - // Total time ≈ (originBlockTime × originConfirmations) + (destinationBlockTime × (2 + numberOfDVNs)) - // Source: https://docs.layerzero.network/v2/faq#what-is-the-estimated-delivery-time-for-a-layerzero-message - const originTime = originBlockTime * originConfirmations; - const destinationTime = destinationBlockTime * (2 + requiredDVNs); - const totalTime = originTime + destinationTime; - - return totalTime; -} - /** * Checks if a route is supported for OFT bridging. * A route is supported if the input and output tokens are the same, and the token is configured for OFT on both the origin and destination chains. @@ -612,138 +391,3 @@ export function getOftBridgeStrategy(): BridgeStrategy { isRouteSupported, }; } - -/** - * Gets the OFT bridge fees. - * For OFT transfers, the fees are simple. There is only a bridge fee, which is the native fee for the messaging layer. - * - * @param params The parameters for getting the fees. - * @param params.inputToken The input token. - * @param params.nativeFee The native fee. - * @param params.nativeToken The native token. - * @returns The OFT bridge fees. - */ -function getOftBridgeFees(params: { - inputToken: Token; - nativeFee: BigNumber; - nativeToken: Token; -}) { - const { inputToken, nativeFee, nativeToken } = params; - const zeroBN = BigNumber.from(0); - return { - totalRelay: { - pct: zeroBN, - total: zeroBN, - token: inputToken, - }, - relayerCapital: { - pct: zeroBN, - total: zeroBN, - token: inputToken, - }, - relayerGas: { - pct: zeroBN, - total: zeroBN, - token: inputToken, - }, - lp: { - pct: zeroBN, - total: zeroBN, - token: inputToken, - }, - bridgeFee: { - pct: zeroBN, - total: nativeFee, - token: nativeToken, - }, - }; -} - -/** - * Composes the message for a Hyperliquid transfer. - * This function creates the `composeMsg` and `extraOptions` for a Hyperliquid transfer. - * The `composeMsg` is the payload for the HyperLiquidComposer, and the `extraOptions` is a custom-packed struct required by the legacy V1-style integration that the Hyperliquid Composer uses. - * See: https://docs.layerzero.network/v2/developers/hyperliquid/hyperliquid-concepts - * - * @param recipient The recipient address on Hyperliquid. - * @param tokenSymbol The symbol of the token being transferred. - * @returns An object containing the `composeMsg`, `toAddress`, and `extraOptions`. - */ -export function getHyperLiquidComposerMessage( - recipient: string, - tokenSymbol: string -): { - composeMsg: string; - toAddress: string; - extraOptions: string; -} { - // The `composeMsg` is the payload for the HyperLiquidComposer. - // The composer contract's `decodeMessage` function expects: abi.decode(_composeMessage, (uint256, address)) - // See: https://github.com/LayerZero-Labs/devtools/blob/ed399e9e57e00848910628a2f89b958c11f63162/packages/hyperliquid-composer/contracts/HyperLiquidComposer.sol#L104 - const composeMsg = ethers.utils.defaultAbiCoder.encode( - ["uint256", "address"], - [ - 0, // `minMsgValue` is 0 as we are not sending native HYPE tokens. - recipient, // `to` is the final user's address on Hyperliquid L1. - ] - ); - if (!HYPEREVM_OFT_COMPOSER_ADDRESSES[tokenSymbol]) { - throw new InvalidParamError({ - message: `OFT: No Hyperliquid Composer contract configured for token ${tokenSymbol}`, - }); - } - // When composing a message for Hyperliquid, the recipient of the OFT `send` call - // is always the Hyperliquid Composer contract on HyperEVM. - const toAddress = HYPEREVM_OFT_COMPOSER_ADDRESSES[tokenSymbol]; - - /** - * @notice Explanation of the custom `extraOptions` payload for the Hyperliquid Composer. - * - * ## What is `extraOptions`? - * - * The `extraOptions` parameter is a concept from the LayerZero protocol. It's a `bytes` string used to pass - * special instructions and, most importantly, pre-pay for the gas required to execute a transaction on the - * destination chain. - * - * One can think of it as a "secret handshake" or a special delivery form. A standard transaction might fail, but one - * with the correct `extraOptions` is recognized and processed by the destination contract. - * - * ## Why This Specific Value: `0x00030100130300000000000000000000000000000000ea60` - * - * This value is not a standard gas payment; it's a custom-packed 26-byte struct required by the - * integration that the Hyperliquid Composer uses. On-chain analysis of successful transactions proves this is the - * only format the contract will accept. - * Examples here: https://polygonscan.com/tx/0x3b96ab9962692d173cd6851fc8308fce3ff0eb56209298a632cef9333cfe3f3f - * and here: https://arbiscan.io/tx/0x9a71d06971e7b52b7896b82e04e1129a123406e08bb016ed57c77a94cb46f979 - * - * The structure is composed of three distinct parts: - * - * 1. **Magic Prefix (`bytes6`):** `0x000301001303` - * - This acts as a unique identifier or "handshake," signaling to the LayerZero network and the Composer that - * this is a specific type of Hyperliquid-bound message. - * - * 2. **Zero Padding (`bytes16`):** `0x00000000000000000000000000000000` - * - A fixed block of 16 empty bytes required to maintain the struct's correct layout. - * - * 3. **Gas Amount (`uint32`):** `0x0000ea60` - * - This is the gas amount (in wei) being airdropped for the destination transaction on HyperEVM. - * - The hex value `ea60` is equal to the decimal value **60,000**. - * - * Any deviation from this 26-byte structure will cause the destination transaction to fail with a parsing error, - * as the Composer will not recognize the "handshake." - * - * ## Proof and Documentation - * - * - **On-Chain Proof:** The necessity of this exact format is proven by analyzing the input data of successful - * `send` transactions on block explorers that bridge to the Hyperliquid Composer. The working example we analyzed - * is the primary evidence. - * - * - **Conceptual Documentation:** While the *specific* value is custom to Hyperliquid, the *concept* of using - * `extraOptions` to provide gas comes from the LayerZero protocol's "Adapter Parameters." - * See Docs: https://docs.layerzero.network/v2/tools/sdks/options - * - */ - const extraOptions = "0x00030100130300000000000000000000000000000000ea60"; - - return { composeMsg, toAddress, extraOptions }; -} diff --git a/api/_bridges/oft/utils/shared.ts b/api/_bridges/oft/utils/shared.ts new file mode 100644 index 000000000..b41764bc5 --- /dev/null +++ b/api/_bridges/oft/utils/shared.ts @@ -0,0 +1,368 @@ +import { BigNumber, Contract, ethers } from "ethers"; +import { CHAIN_IDs } from "../../../_constants"; +import { Token } from "../../../_dexes/types"; +import { InvalidParamError } from "../../../_errors"; +import { ConvertDecimals, getProvider } from "../../../_utils"; +import * as chainConfigs from "../../../../scripts/chain-configs"; +import { + getOftMessengerForToken, + createSendParamStruct, + getOftOriginConfirmations, + getOftEndpointId, + DEFAULT_OFT_REQUIRED_DVNS, + CONFIG_TYPE_ULN, + ENDPOINT_ABI, + OFT_ABI, + OFT_SHARED_DECIMALS, + V2_ENDPOINTS, + HYPEREVM_OFT_COMPOSER_ADDRESSES, +} from "./constants"; + +/** + * Get bridge fees structure for OFT-based flows + * Returns zero fees for all relay-related fees, with only the native bridge fee populated + */ +export function getOftBridgeFees(params: { + inputToken: Token; + nativeFee: BigNumber; + nativeToken: Token; +}) { + const { inputToken, nativeFee, nativeToken } = params; + const zeroBN = BigNumber.from(0); + return { + totalRelay: { + pct: zeroBN, + total: zeroBN, + token: inputToken, + }, + relayerCapital: { + pct: zeroBN, + total: zeroBN, + token: inputToken, + }, + relayerGas: { + pct: zeroBN, + total: zeroBN, + token: inputToken, + }, + lp: { + pct: zeroBN, + total: zeroBN, + token: inputToken, + }, + bridgeFee: { + pct: zeroBN, + total: nativeFee, + token: nativeToken, + }, + }; +} + +/** + * Rounds the token amount down to the correct precision for OFT transfer. + * The last (tokenDecimals - sharedDecimals) digits must be zero to prevent contract-side rounding. + * Shared decimals is OFT's precision model where tokens use a common decimal precision across all chains. + * Docs: https://docs.layerzero.network/v2/concepts/technical-reference/oft-reference?utm_source=chatgpt.com#1-transferring-value-across-different-vms + * @param amount amount to round + * @param tokenSymbol symbol of the token we need to round decimals for + * @param tokenDecimals decimals of the token + * @returns The amount rounded down to the correct precision + */ +export function roundAmountToSharedDecimals( + amount: BigNumber, + tokenSymbol: string, + tokenDecimals: number +): BigNumber { + const sharedDecimals = OFT_SHARED_DECIMALS[tokenSymbol]; + if (sharedDecimals === undefined) { + throw new InvalidParamError({ + message: `Shared decimals not found for token ${tokenSymbol}`, + }); + } + + const decimalDifference = tokenDecimals - sharedDecimals; + if (decimalDifference > 0) { + const divisor = BigNumber.from(10).pow(decimalDifference); + const remainder = amount.mod(divisor); + return amount.sub(remainder); + } + return amount; +} + +/** + * Fetches the number of required DVN signatures for a specific OFT route + * @param originChainId source chain ID + * @param destinationChainId destination chain ID + * @param tokenSymbol token being bridged + * @returns total number of required DVN signatures (requiredDVNs + optionalThreshold) + */ +export async function getRequiredDVNCount( + originChainId: number, + destinationChainId: number, + tokenSymbol: string +): Promise { + try { + const endpointAddress = V2_ENDPOINTS[originChainId]; + if (!endpointAddress) { + return DEFAULT_OFT_REQUIRED_DVNS; + } + + const oappAddress = getOftMessengerForToken(tokenSymbol, originChainId); + const dstEid = getOftEndpointId(destinationChainId); + const provider = getProvider(originChainId); + + const endpoint = new Contract(endpointAddress, ENDPOINT_ABI, provider); + + // Step 1: Find the send library used for this route + const libAddress = await endpoint.getSendLibrary(oappAddress, dstEid); + + // Step 2: Get DVN config for this route + const ulnConfigBytes = await endpoint.getConfig( + oappAddress, + libAddress, + dstEid, + CONFIG_TYPE_ULN + ); + + // Step 3: Decode the UlnConfig struct directly + const ulnConfigStructType = [ + "tuple(uint64 confirmations, uint8 requiredDVNCount, uint8 optionalDVNCount, uint8 optionalDVNThreshold, address[] requiredDVNs, address[] optionalDVNs)", + ]; + const decoded = ethers.utils.defaultAbiCoder.decode( + ulnConfigStructType, + ulnConfigBytes + )[0]; + + const requiredDVNs = decoded.requiredDVNs.length; + const optionalThreshold = decoded.optionalDVNThreshold; + + return requiredDVNs + optionalThreshold; + } catch (error) { + console.error("Error fetching required DVN count", error); + // Fall back to default if fetching fails + return DEFAULT_OFT_REQUIRED_DVNS; + } +} + +/** + * Composes the message for a Hyperliquid transfer. + * This function creates the `composeMsg` and `extraOptions` for a Hyperliquid transfer. + * The `composeMsg` is the payload for the HyperLiquidComposer, and the `extraOptions` is a custom-packed struct required by the legacy V1-style integration that the Hyperliquid Composer uses. + * See: https://docs.layerzero.network/v2/developers/hyperliquid/hyperliquid-concepts + * + * @param recipient The recipient address on Hyperliquid. + * @param tokenSymbol The symbol of the token being transferred. + * @returns An object containing the `composeMsg`, `toAddress`, and `extraOptions`. + */ +export function getHyperLiquidComposerMessage( + recipient: string, + tokenSymbol: string +): { + composeMsg: string; + toAddress: string; + extraOptions: string; +} { + // The `composeMsg` is the payload for the HyperLiquidComposer. + // The composer contract's `decodeMessage` function expects: abi.decode(_composeMessage, (uint256, address)) + // See: https://github.com/LayerZero-Labs/devtools/blob/ed399e9e57e00848910628a2f89b958c11f63162/packages/hyperliquid-composer/contracts/HyperLiquidComposer.sol#L104 + const composeMsg = ethers.utils.defaultAbiCoder.encode( + ["uint256", "address"], + [ + 0, // `minMsgValue` is 0 as we are not sending native HYPE tokens. + recipient, // `to` is the final user's address on Hyperliquid L1. + ] + ); + if (!HYPEREVM_OFT_COMPOSER_ADDRESSES[tokenSymbol]) { + throw new InvalidParamError({ + message: `OFT: No Hyperliquid Composer contract configured for token ${tokenSymbol}`, + }); + } + // When composing a message for Hyperliquid, the recipient of the OFT `send` call + // is always the Hyperliquid Composer contract on HyperEVM. + const toAddress = HYPEREVM_OFT_COMPOSER_ADDRESSES[tokenSymbol]; + + /** + * @notice Explanation of the custom `extraOptions` payload for the Hyperliquid Composer. + * + * ## What is `extraOptions`? + * + * The `extraOptions` parameter is a concept from the LayerZero protocol. It's a `bytes` string used to pass + * special instructions and, most importantly, pre-pay for the gas required to execute a transaction on the + * destination chain. + * + * One can think of it as a "secret handshake" or a special delivery form. A standard transaction might fail, but one + * with the correct `extraOptions` is recognized and processed by the destination contract. + * + * ## Why This Specific Value: `0x00030100130300000000000000000000000000000000ea60` + * + * This value is not a standard gas payment; it's a custom-packed 26-byte struct required by the + * integration that the Hyperliquid Composer uses. On-chain analysis of successful transactions proves this is the + * only format the contract will accept. + * Examples here: https://polygonscan.com/tx/0x3b96ab9962692d173cd6851fc8308fce3ff0eb56209298a632cef9333cfe3f3f + * and here: https://arbiscan.io/tx/0x9a71d06971e7b52b7896b82e04e1129a123406e08bb016ed57c77a94cb46f979 + * + * The structure is composed of three distinct parts: + * + * 1. **Magic Prefix (`bytes6`):** `0x000301001303` + * - This acts as a unique identifier or "handshake," signaling to the LayerZero network and the Composer that + * this is a specific type of Hyperliquid-bound message. + * + * 2. **Zero Padding (`bytes16`):** `0x00000000000000000000000000000000` + * - A fixed block of 16 empty bytes required to maintain the struct's correct layout. + * + * 3. **Gas Amount (`uint32`):** `0x0000ea60` + * - This is the gas amount (in wei) being airdropped for the destination transaction on HyperEVM. + * - The hex value `ea60` is equal to the decimal value **60,000**. + * + * Any deviation from this 26-byte structure will cause the destination transaction to fail with a parsing error, + * as the Composer will not recognize the "handshake." + * + * ## Proof and Documentation + * + * - **On-Chain Proof:** The necessity of this exact format is proven by analyzing the input data of successful + * `send` transactions on block explorers that bridge to the Hyperliquid Composer. The working example we analyzed + * is the primary evidence. + * + * - **Conceptual Documentation:** While the *specific* value is custom to Hyperliquid, the *concept* of using + * `extraOptions` to provide gas comes from the LayerZero protocol's "Adapter Parameters." + * See Docs: https://docs.layerzero.network/v2/tools/sdks/options + * + */ + const extraOptions = "0x00030100130300000000000000000000000000000000ea60"; + + return { composeMsg, toAddress, extraOptions }; +} + +/** + * Internal helper to get OFT quote from contracts. + * @note This function is designed for input-based quotes (specify input amount, get output amount). + * Currently works for both input-based and output-based flows because supported tokens (USDT, WBTC) have 0 OFT fees. + * If we ever add tokens with non-zero OFT fees, we need to refactor this function to handle output-based quotes. + * + * @param params The parameters for getting the quote. + * @param params.inputToken The input token. + * @param params.outputToken The output token. + * @param params.inputAmount The input amount. + * @param params.recipient The recipient address. + * @returns A promise that resolves with the quote data, including input amount, output amount, native fee, and OFT fee amount. + */ +export async function getQuote(params: { + inputToken: Token; + outputToken: Token; + inputAmount: BigNumber; + recipient: string; +}) { + const { inputToken, outputToken, inputAmount, recipient } = params; + + // Get OFT messenger contract + const oftMessengerAddress = getOftMessengerForToken( + inputToken.symbol, + inputToken.chainId + ); + const provider = getProvider(inputToken.chainId); + const oftMessengerContract = new Contract( + oftMessengerAddress, + OFT_ABI, + provider + ); + + // Round input amount to correct precision for OFT transfer + // Required to prevent contract-side rounding + const roundedInputAmount = roundAmountToSharedDecimals( + inputAmount, + inputToken.symbol, + inputToken.decimals + ); + const { toAddress, composeMsg, extraOptions } = + outputToken.chainId === CHAIN_IDs.HYPERCORE + ? getHyperLiquidComposerMessage(recipient, outputToken.symbol) + : { toAddress: recipient, composeMsg: "0x", extraOptions: "0x" }; + // Create SendParam struct for quoting + const sendParam = createSendParamStruct({ + // If sending to Hypercore, the destinationChainId in the SendParam is always HyperEVM + destinationChainId: + outputToken.chainId === CHAIN_IDs.HYPERCORE + ? CHAIN_IDs.HYPEREVM + : outputToken.chainId, + toAddress, + amountLD: roundedInputAmount, + minAmountLD: roundedInputAmount, + composeMsg, + extraOptions, + }); + // Get quote from OFT contract + const [messagingFee, oftQuoteResult] = await Promise.all([ + oftMessengerContract.quoteSend(sendParam, false), // false = pay in native token + oftMessengerContract.quoteOFT(sendParam), + ]); + const [, , oftReceipt] = oftQuoteResult; + const nativeFee = BigNumber.from(messagingFee.nativeFee); + + // LD = Local Decimals - amounts in the input token's decimal precision + const amountReceivedLD = BigNumber.from(oftReceipt.amountReceivedLD); + // Convert amountReceivedLD to output token decimals + const outputAmount = ConvertDecimals( + inputToken.decimals, + outputToken.decimals + )(amountReceivedLD); + + // Calculate OFT fees (difference between sent and received in input token decimals) + const amountSentLD = BigNumber.from(oftReceipt.amountSentLD); + const oftFeeAmount = amountSentLD.sub(amountReceivedLD); + + return { + inputAmount: roundedInputAmount, + outputAmount, + nativeFee, + oftFeeAmount, + }; +} + +/** + * Estimates the fill time for an OFT bridge transfer. + * The estimation is based on the origin and destination chain block times, the number of origin confirmations, and the number of required DVN signatures. + * The formula is: `(originBlockTime * originConfirmations) + (destinationBlockTime * (2 + numberOfDVNs))` + * See: https://docs.layerzero.network/v2/faq#what-is-the-estimated-delivery-time-for-a-layerzero-message + * + * @param originChainId The origin chain ID. + * @param destinationChainId The destination chain ID. + * @param tokenSymbol The symbol of the token being bridged. + * @returns A promise that resolves with the estimated fill time in seconds. + */ +export async function getEstimatedFillTime( + originChainId: number, + destinationChainId: number, + tokenSymbol: string +): Promise { + const DEFAULT_BLOCK_TIME_SECONDS = 5; + + // Get source chain required confirmations + const originConfirmations = getOftOriginConfirmations(originChainId); + + // Get dynamic DVN count for this specific route + const requiredDVNs = await getRequiredDVNCount( + originChainId, + destinationChainId, + tokenSymbol + ); + + // Get origin and destination block times from chain configs + const originChainConfig = Object.values(chainConfigs).find( + (config) => config.chainId === originChainId + ); + const destinationChainConfig = Object.values(chainConfigs).find( + (config) => config.chainId === destinationChainId + ); + const originBlockTime = + originChainConfig?.blockTimeSeconds ?? DEFAULT_BLOCK_TIME_SECONDS; + const destinationBlockTime = + destinationChainConfig?.blockTimeSeconds ?? DEFAULT_BLOCK_TIME_SECONDS; + + // Total time ≈ (originBlockTime × originConfirmations) + (destinationBlockTime × (2 + numberOfDVNs)) + // Source: https://docs.layerzero.network/v2/faq#what-is-the-estimated-delivery-time-for-a-layerzero-message + const originTime = originBlockTime * originConfirmations; + const destinationTime = destinationBlockTime * (2 + requiredDVNs); + const totalTime = originTime + destinationTime; + + return totalTime; +} diff --git a/api/_bridges/sponsored/strategy.ts b/api/_bridges/sponsored/strategy.ts deleted file mode 100644 index f20a7578c..000000000 --- a/api/_bridges/sponsored/strategy.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { - BridgeStrategy, - GetExactInputBridgeQuoteParams, - BridgeCapabilities, - GetOutputBridgeQuoteParams, -} from "../types"; -import { CrossSwap, CrossSwapQuotes, Token } from "../../_dexes/types"; -import { AppFee } from "../../_dexes/utils"; - -const name = "sponsored-bridge"; - -const capabilities: BridgeCapabilities = { - ecosystems: ["evm", "svm"], - supports: { - A2A: false, - A2B: false, - B2A: false, - B2B: true, - B2BI: false, - crossChainMessage: false, - }, -}; - -/** - * Sponsored bridge strategy - */ -export function getSponsoredBridgeStrategy(): BridgeStrategy { - return { - name, - capabilities, - - originTxNeedsAllowance: true, - - isRouteSupported: (params: { inputToken: Token; outputToken: Token }) => { - throw new Error("TODO"); - }, - - getCrossSwapTypes: (params: { - inputToken: Token; - outputToken: Token; - isInputNative: boolean; - isOutputNative: boolean; - }) => { - throw new Error("TODO"); - }, - - getBridgeQuoteRecipient: (crossSwap: CrossSwap) => { - return crossSwap.recipient; - }, - - getBridgeQuoteMessage: (_crossSwap: CrossSwap, _appFee?: AppFee) => { - return "0x"; - }, - - getQuoteForExactInput: async ({ - inputToken, - outputToken, - exactInputAmount, - recipient, - message: _message, - }: GetExactInputBridgeQuoteParams) => { - throw new Error("TODO"); - }, - - getQuoteForOutput: async ({ - inputToken, - outputToken, - minOutputAmount, - forceExactOutput: _forceExactOutput, - recipient, - message: _message, - }: GetOutputBridgeQuoteParams) => { - throw new Error("TODO"); - }, - - buildTxForAllowanceHolder: async (params: { - quotes: CrossSwapQuotes; - integratorId?: string; - }) => { - throw new Error("TODO"); - }, - }; -} diff --git a/api/_bridges/sponsored/utils/signing.ts b/api/_bridges/sponsored/utils/signing.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/api/_bridges/sponsorship/cctp.ts b/api/_bridges/sponsorship/cctp.ts index 7f9d33aa8..c3eb0b7e3 100644 --- a/api/_bridges/sponsorship/cctp.ts +++ b/api/_bridges/sponsorship/cctp.ts @@ -1,5 +1,5 @@ import { BigNumberish, utils } from "ethers"; -import { signDigestWithSponsor } from "./utils"; +import { signDigestWithSponsor } from "../../_sponsorship-signature"; /** * Represents the parameters for a sponsored CCTP quote. diff --git a/api/_bridges/sponsorship/index.ts b/api/_bridges/sponsorship/index.ts deleted file mode 100644 index 812ff06d5..000000000 --- a/api/_bridges/sponsorship/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./cctp"; -export * from "./oft"; -export * from "./utils"; diff --git a/api/_bridges/sponsorship/utils/index.ts b/api/_bridges/sponsorship/utils/index.ts deleted file mode 100644 index a47dc3e25..000000000 --- a/api/_bridges/sponsorship/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./signature"; diff --git a/api/_dexes/types.ts b/api/_dexes/types.ts index 1dd13ea67..c07daed5c 100644 --- a/api/_dexes/types.ts +++ b/api/_dexes/types.ts @@ -139,7 +139,7 @@ export type CrossSwapQuotes = { suggestedFees: Awaited>; } | { - provider: "hypercore" | "cctp" | "oft"; + provider: "hypercore" | "cctp" | "oft" | "sponsored-oft"; } ); destinationSwapQuote?: SwapQuote; diff --git a/api/_bridges/sponsorship/utils/signature.ts b/api/_sponsorship-signature.ts similarity index 91% rename from api/_bridges/sponsorship/utils/signature.ts rename to api/_sponsorship-signature.ts index 28a52015b..f79f33080 100644 --- a/api/_bridges/sponsorship/utils/signature.ts +++ b/api/_sponsorship-signature.ts @@ -1,5 +1,5 @@ import { ethers, utils } from "ethers"; -import { getEnvs } from "../../../../api/_env"; +import { getEnvs } from "./_env"; let sponsorshipSigner: ethers.Wallet | undefined; @@ -37,7 +37,8 @@ export const signDigestWithSponsor = (digest: string): string => { /** * Signs a message with the sponsorship signer. - * This is used for OFT signatures where the contract expects a signature on the EIP-191 prefixed hash. + * This adds the EIP-191 prefix to the message before signing. + * Use this when the contract expects `toEthSignedMessageHash().recover()`. * @param {Uint8Array} message The message to sign. * @returns {Promise} The signature string. */ diff --git a/e2e-api/bridges/oft/strategy.test.ts b/e2e-api/bridges/oft/strategy.test.ts index 20610e1c6..ec436d32a 100644 --- a/e2e-api/bridges/oft/strategy.test.ts +++ b/e2e-api/bridges/oft/strategy.test.ts @@ -1,14 +1,15 @@ +import { BigNumber } from "ethers"; import { - getQuote, - getEstimatedFillTime, getOftQuoteForExactInput, - getRequiredDVNCount, buildOftTx, } from "../../../api/_bridges/oft/strategy"; -import { CHAIN_IDs } from "@across-protocol/constants"; import { CrossSwapQuotes, Token } from "../../../api/_dexes/types"; -import { BigNumber } from "ethers"; -import { TOKEN_SYMBOLS_MAP } from "../../../api/_constants"; +import { CHAIN_IDs, TOKEN_SYMBOLS_MAP } from "../../../api/_constants"; +import { + getRequiredDVNCount, + getQuote, + getEstimatedFillTime, +} from "../../../api/_bridges/oft/utils/shared"; describe("OFT Strategy", () => { const arbitrumUSDT: Token = { diff --git a/test/api/_bridges/oft-sponsored/strategy.test.ts b/test/api/_bridges/oft-sponsored/strategy.test.ts new file mode 100644 index 000000000..62b7fc902 --- /dev/null +++ b/test/api/_bridges/oft-sponsored/strategy.test.ts @@ -0,0 +1,462 @@ +import { BigNumber } from "ethers"; +import { + calculateMaxBpsToSponsor, + getSponsoredOftQuoteForExactInput, + getSponsoredOftQuoteForOutput, + isRouteSupported, +} from "../../../../api/_bridges/oft-sponsored/strategy"; +import * as oftUtils from "../../../../api/_bridges/oft/utils/shared"; +import { CHAIN_IDs, TOKEN_SYMBOLS_MAP } from "../../../../api/_constants"; +import { Token } from "../../../../api/_dexes/types"; +import * as hypercore from "../../../../api/_hypercore"; +import * as utils from "../../../../api/_utils"; +import * as swapUtils from "../../../../api/swap/_utils"; + +describe("Sponsored OFT Strategy", () => { + // Shared test fixtures + const arbitrumUSDT: Token = { + address: TOKEN_SYMBOLS_MAP.USDT.addresses[CHAIN_IDs.ARBITRUM], + symbol: "USDT", + decimals: 6, + chainId: CHAIN_IDs.ARBITRUM, + }; + + const mainnetUSDT: Token = { + address: TOKEN_SYMBOLS_MAP.USDT.addresses[CHAIN_IDs.MAINNET], + symbol: "USDT", + decimals: 6, + chainId: CHAIN_IDs.MAINNET, + }; + + const polygonUSDT: Token = { + address: TOKEN_SYMBOLS_MAP.USDT.addresses[CHAIN_IDs.POLYGON], + symbol: "USDT", + decimals: 6, + chainId: CHAIN_IDs.POLYGON, + }; + + const hypercoreUSDT: Token = { + address: TOKEN_SYMBOLS_MAP["USDT-SPOT"].addresses[CHAIN_IDs.HYPERCORE], + symbol: "USDT-SPOT", + decimals: 8, + chainId: CHAIN_IDs.HYPERCORE, + }; + + const hyperevmUSDT: Token = { + address: TOKEN_SYMBOLS_MAP.USDT.addresses[CHAIN_IDs.HYPEREVM], + symbol: "USDT", + decimals: 6, + chainId: CHAIN_IDs.HYPEREVM, + }; + + const recipient = "0x0000000000000000000000000000000000000001"; + + describe("isRouteSupported", () => { + const arbitrumWETH: Token = { + address: TOKEN_SYMBOLS_MAP.WETH.addresses[CHAIN_IDs.ARBITRUM], + symbol: "WETH", + decimals: 18, + chainId: CHAIN_IDs.ARBITRUM, + }; + + describe("Valid routes", () => { + it("should support USDT → USDT-SPOT from Arbitrum to HyperCore", () => { + const result = isRouteSupported({ + inputToken: arbitrumUSDT, + outputToken: hypercoreUSDT, + }); + + expect(result).toBe(true); + }); + + it("should support USDT → USDT-SPOT from Mainnet to HyperCore", () => { + const result = isRouteSupported({ + inputToken: mainnetUSDT, + outputToken: hypercoreUSDT, + }); + + expect(result).toBe(true); + }); + + it("should support USDT → USDT-SPOT from Polygon to HyperCore", () => { + const result = isRouteSupported({ + inputToken: polygonUSDT, + outputToken: hypercoreUSDT, + }); + + expect(result).toBe(true); + }); + }); + + describe("Invalid input tokens", () => { + it("should reject WETH → USDT-SPOT (unsupported input token)", () => { + const result = isRouteSupported({ + inputToken: arbitrumWETH, + outputToken: hypercoreUSDT, + }); + + expect(result).toBe(false); + }); + }); + + describe("Invalid output tokens", () => { + it("should reject USDT → WETH (unsupported output token)", () => { + const hypercoreWETH: Token = { + address: "0x1234567890123456789012345678901234567890", + symbol: "WETH", + decimals: 18, + chainId: CHAIN_IDs.HYPERCORE, + }; + + const result = isRouteSupported({ + inputToken: arbitrumUSDT, + outputToken: hypercoreWETH, + }); + + expect(result).toBe(false); + }); + }); + + describe("Invalid destination chains", () => { + it("should reject USDT → USDT on Polygon (wrong destination chain)", () => { + const result = isRouteSupported({ + inputToken: arbitrumUSDT, + outputToken: polygonUSDT, + }); + + expect(result).toBe(false); + }); + + it("should reject USDT → USDT on Arbitrum (wrong destination chain)", () => { + const result = isRouteSupported({ + inputToken: mainnetUSDT, + outputToken: arbitrumUSDT, + }); + + expect(result).toBe(false); + }); + }); + + describe("Missing OFT messenger", () => { + it("should reject route from unsupported origin chain", () => { + const unsupportedChainUSDT: Token = { + address: "0x1234567890123456789012345678901234567890", + symbol: "USDT", + decimals: 6, + chainId: 99999, // Unsupported chain ID + }; + + const result = isRouteSupported({ + inputToken: unsupportedChainUSDT, + outputToken: hypercoreUSDT, + }); + + expect(result).toBe(false); + }); + }); + + describe("Missing destination handler", () => { + it("should reject route to chain without destination handler", () => { + const arbitrumUSDTOutput: Token = { + address: + TOKEN_SYMBOLS_MAP["USDT-SPOT"].addresses[CHAIN_IDs.ARBITRUM] || + "0x0", + symbol: "USDT-SPOT", + decimals: 8, + chainId: CHAIN_IDs.ARBITRUM, // Arbitrum doesn't have destination handler + }; + + const result = isRouteSupported({ + inputToken: mainnetUSDT, + outputToken: arbitrumUSDTOutput, + }); + + expect(result).toBe(false); + }); + }); + }); + + describe("calculateMaxBpsToSponsor", () => { + const bridgeInputAmount = BigNumber.from("1000000"); // 1 USDT (6 decimals) + const bridgeOutputAmount = BigNumber.from("1000000"); // 1 USDT (6 decimals on HyperEVM) + + describe("USDT-SPOT output", () => { + it("should return 0 bps for USDT-SPOT (no swap needed)", async () => { + const result = await calculateMaxBpsToSponsor({ + outputTokenSymbol: "USDT-SPOT", + bridgeInputAmount, + bridgeOutputAmount, + }); + + expect(result.toNumber()).toBe(0); + }); + }); + + describe("USDC-SPOT output", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should return 0 bps when swap has no loss", async () => { + // Mock simulateMarketOrder to return exactly 1:1 output (no loss) + jest.spyOn(hypercore, "simulateMarketOrder").mockResolvedValue({ + outputAmount: BigNumber.from("100000000"), // Exactly 1 USDC worth in 8 decimals + inputAmount: bridgeOutputAmount, + averageExecutionPrice: "1.0", + slippagePercent: 0, + bestPrice: "1.0", + levelsConsumed: 1, + fullyFilled: true, + }); + + const result = await calculateMaxBpsToSponsor({ + outputTokenSymbol: "USDC-SPOT", + bridgeInputAmount, + bridgeOutputAmount, + }); + + expect(result.toNumber()).toBe(0); + }); + + it("should return 0 bps when swap has profit", async () => { + // Mock simulateMarketOrder to return more than expected (profit scenario) + jest.spyOn(hypercore, "simulateMarketOrder").mockResolvedValue({ + outputAmount: BigNumber.from("101000000"), // 1.01 USDC (8 decimals on HyperCore) + inputAmount: bridgeOutputAmount, + averageExecutionPrice: "1.01", + slippagePercent: -1, + bestPrice: "1.01", + levelsConsumed: 1, + fullyFilled: true, + }); + + const result = await calculateMaxBpsToSponsor({ + outputTokenSymbol: "USDC-SPOT", + bridgeInputAmount, + bridgeOutputAmount, + }); + + expect(result.toNumber()).toBe(0); + }); + + it("should calculate correct bps when swap has 1% loss", async () => { + // Mock simulateMarketOrder to return 0.99 USDT (1% loss) + jest.spyOn(hypercore, "simulateMarketOrder").mockResolvedValue({ + outputAmount: BigNumber.from("99000000"), // 0.99 USDC (8 decimals on HyperCore) + inputAmount: bridgeOutputAmount, + averageExecutionPrice: "0.99", + slippagePercent: 1, + bestPrice: "0.99", + levelsConsumed: 1, + fullyFilled: true, + }); + + const result = await calculateMaxBpsToSponsor({ + outputTokenSymbol: "USDC-SPOT", + bridgeInputAmount, + bridgeOutputAmount, + }); + + // 1% loss = 100 bps + expect(result.toNumber()).toBe(100); + }); + + it("should calculate correct bps when swap has 0.5% loss", async () => { + // Mock simulateMarketOrder to return 0.995 USDT (0.5% loss) + jest.spyOn(hypercore, "simulateMarketOrder").mockResolvedValue({ + outputAmount: BigNumber.from("99500000"), // 0.995 USDC (8 decimals on HyperCore) + inputAmount: bridgeOutputAmount, + averageExecutionPrice: "0.995", + slippagePercent: 0.5, + bestPrice: "0.995", + levelsConsumed: 1, + fullyFilled: true, + }); + + const result = await calculateMaxBpsToSponsor({ + outputTokenSymbol: "USDC-SPOT", + bridgeInputAmount, + bridgeOutputAmount, + }); + + // 0.5% loss = 50 bps + expect(result.toNumber()).toBe(50); + }); + + it("should round up fractional bps", async () => { + // Mock simulateMarketOrder to return slightly less (0.01% loss) + jest.spyOn(hypercore, "simulateMarketOrder").mockResolvedValue({ + outputAmount: BigNumber.from("99990000"), // 0.9999 USDC (8 decimals on HyperCore) + inputAmount: bridgeOutputAmount, + averageExecutionPrice: "0.9999", + slippagePercent: 0.01, + bestPrice: "0.9999", + levelsConsumed: 1, + fullyFilled: true, + }); + + const result = await calculateMaxBpsToSponsor({ + outputTokenSymbol: "USDC-SPOT", + bridgeInputAmount, + bridgeOutputAmount, + }); + + // 0.01% loss = 1 bps (should round up) + expect(result.toNumber()).toBeGreaterThanOrEqual(1); + }); + }); + + describe("Unsupported output token", () => { + it("should throw error for unsupported output token", async () => { + await expect( + calculateMaxBpsToSponsor({ + outputTokenSymbol: "WETH", + bridgeInputAmount, + bridgeOutputAmount, + }) + ).rejects.toThrow("Unsupported output token: WETH"); + }); + }); + }); + + describe("getSponsoredOftQuoteForExactInput", () => { + const exactInputAmount = BigNumber.from("1000000"); // 1 USDT (6 decimals) + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock getCachedTokenInfo to return HyperEVM USDT as intermediary + jest.spyOn(utils, "getCachedTokenInfo").mockResolvedValue({ + ...hyperevmUSDT, + name: "USDT", + }); + + // Mock getNativeTokenInfo + jest.spyOn(swapUtils, "getNativeTokenInfo").mockReturnValue({ + address: "0x0000000000000000000000000000000000000000", + symbol: "ETH", + decimals: 18, + chainId: CHAIN_IDs.ARBITRUM, + }); + }); + + it("should convert output from intermediary decimals (6) to output decimals (8)", async () => { + // Mock getQuote to return 1 USDT in intermediary token decimals (6 decimals) + jest.spyOn(oftUtils, "getQuote").mockResolvedValue({ + inputAmount: exactInputAmount, + outputAmount: BigNumber.from("1000000"), // 1 USDT in 6 decimals (HyperEVM) + nativeFee: BigNumber.from("100000000000000"), // 0.0001 ETH + oftFeeAmount: BigNumber.from(0), + }); + + jest.spyOn(oftUtils, "getEstimatedFillTime").mockResolvedValue(300); + + const result = await getSponsoredOftQuoteForExactInput({ + inputToken: arbitrumUSDT, + outputToken: hypercoreUSDT, + exactInputAmount, + recipient, + }); + + // Output should be converted to 8 decimals (HyperCore USDT-SPOT) + expect(result.bridgeQuote.outputAmount.toString()).toBe("100000000"); // 1 USDT in 8 decimals + expect(result.bridgeQuote.minOutputAmount.toString()).toBe("100000000"); + expect(result.bridgeQuote.inputAmount.toString()).toBe("1000000"); + }); + + it("should maintain correct decimal precision for larger amounts", async () => { + const largeAmount = BigNumber.from("1000000000"); // 1000 USDT (6 decimals) + + jest.spyOn(oftUtils, "getQuote").mockResolvedValue({ + inputAmount: largeAmount, + outputAmount: BigNumber.from("1000000000"), // 1000 USDT in 6 decimals + nativeFee: BigNumber.from("100000000000000"), + oftFeeAmount: BigNumber.from(0), + }); + + jest.spyOn(oftUtils, "getEstimatedFillTime").mockResolvedValue(300); + + const result = await getSponsoredOftQuoteForExactInput({ + inputToken: arbitrumUSDT, + outputToken: hypercoreUSDT, + exactInputAmount: largeAmount, + recipient, + }); + + // Output should be 1000 USDT in 8 decimals + expect(result.bridgeQuote.outputAmount.toString()).toBe("100000000000"); // 1000 USDT in 8 decimals + }); + }); + + describe("getSponsoredOftQuoteForOutput", () => { + const minOutputAmount = BigNumber.from("100000000"); // 1 USDT (8 decimals) + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock getCachedTokenInfo to return HyperEVM USDT as intermediary + jest.spyOn(utils, "getCachedTokenInfo").mockResolvedValue({ + ...hyperevmUSDT, + name: "USDT", + }); + + // Mock getNativeTokenInfo + jest.spyOn(swapUtils, "getNativeTokenInfo").mockReturnValue({ + address: "0x0000000000000000000000000000000000000000", + symbol: "ETH", + decimals: 18, + chainId: CHAIN_IDs.ARBITRUM, + }); + + // Mock roundAmountToSharedDecimals + jest + .spyOn(oftUtils, "roundAmountToSharedDecimals") + .mockReturnValue(minOutputAmount); + }); + + it("should convert input from output decimals (8) to input decimals (6) and back", async () => { + // Mock getQuote to return intermediary token output (6 decimals) + jest.spyOn(oftUtils, "getQuote").mockResolvedValue({ + inputAmount: BigNumber.from("1000000"), // 1 USDT in 6 decimals + outputAmount: BigNumber.from("1000000"), // 1 USDT in 6 decimals (intermediary) + nativeFee: BigNumber.from("100000000000000"), + oftFeeAmount: BigNumber.from(0), + }); + + jest.spyOn(oftUtils, "getEstimatedFillTime").mockResolvedValue(300); + + const result = await getSponsoredOftQuoteForOutput({ + inputToken: arbitrumUSDT, + outputToken: hypercoreUSDT, + minOutputAmount, + recipient, + }); + + // Output should be converted back to 8 decimals + expect(result.bridgeQuote.outputAmount.toString()).toBe("100000000"); // 1 USDT in 8 decimals + expect(result.bridgeQuote.minOutputAmount.toString()).toBe("100000000"); + expect(result.bridgeQuote.inputAmount.toString()).toBe("1000000"); // 1 USDT in 6 decimals + }); + + it("should throw error when output is below minimum", async () => { + // Mock getQuote to return less than minimum + jest.spyOn(oftUtils, "getQuote").mockResolvedValue({ + inputAmount: BigNumber.from("1000000"), + outputAmount: BigNumber.from("900000"), // 0.9 USDT in 6 decimals (less than expected) + nativeFee: BigNumber.from("100000000000000"), + oftFeeAmount: BigNumber.from(0), + }); + + jest.spyOn(oftUtils, "getEstimatedFillTime").mockResolvedValue(300); + + await expect( + getSponsoredOftQuoteForOutput({ + inputToken: arbitrumUSDT, + outputToken: hypercoreUSDT, + minOutputAmount, + recipient, + }) + ).rejects.toThrow(); + }); + }); +}); diff --git a/test/api/_bridges/oft/strategy.test.ts b/test/api/_bridges/oft/strategy.test.ts index 9299d981a..8502c33cd 100644 --- a/test/api/_bridges/oft/strategy.test.ts +++ b/test/api/_bridges/oft/strategy.test.ts @@ -1,5 +1,4 @@ import { - getHyperLiquidComposerMessage, isRouteSupported, getOftCrossSwapTypes, } from "../../../../api/_bridges/oft/strategy"; @@ -7,11 +6,11 @@ import { HYPEREVM_OFT_COMPOSER_ADDRESSES, OFT_MESSENGERS, } from "../../../../api/_bridges/oft/utils/constants"; -import { CHAIN_IDs } from "@across-protocol/constants"; +import { getHyperLiquidComposerMessage } from "../../../../api/_bridges/oft/utils/shared"; import { Token } from "../../../../api/_dexes/types"; import { ethers } from "ethers"; import { CROSS_SWAP_TYPE } from "../../../../api/_dexes/utils"; -import { TOKEN_SYMBOLS_MAP } from "../../../../api/_constants"; +import { CHAIN_IDs, TOKEN_SYMBOLS_MAP } from "../../../../api/_constants"; import { assert } from "console"; describe("OFT Strategy", () => { diff --git a/test/api/_bridges/sponsorship/cctp.test.ts b/test/api/_bridges/sponsorship/cctp.test.ts index 3f5340f4e..488f34826 100644 --- a/test/api/_bridges/sponsorship/cctp.test.ts +++ b/test/api/_bridges/sponsorship/cctp.test.ts @@ -5,7 +5,7 @@ import { getEnvs } from "../../../../api/_env"; import { createCctpSignature, SponsoredCCTPQuote, -} from "../../../../api/_bridges/sponsorship"; +} from "../../../../api/_bridges/sponsorship/cctp"; // Mock the environment variables to ensure tests are deterministic. jest.mock("../../../../api/_env", () => ({ diff --git a/test/api/_bridges/sponsorship/oft.test.ts b/test/api/_bridges/sponsorship/oft.test.ts index 3f703a8fe..9d62fa91b 100644 --- a/test/api/_bridges/sponsorship/oft.test.ts +++ b/test/api/_bridges/sponsorship/oft.test.ts @@ -1,12 +1,10 @@ import { ethers, utils } from "ethers"; -import { recoverMessageAddress } from "viem"; -import { hexToBytes } from "viem"; import { getEnvs } from "../../../../api/_env"; import { createOftSignature, SignedQuoteParams, -} from "../../../../api/_bridges/sponsorship"; +} from "../../../../api/_bridges/oft-sponsored/utils/signing"; // Mock the environment variables to ensure tests are deterministic. jest.mock("../../../../api/_env", () => ({ @@ -43,6 +41,8 @@ describe("OFT Signature", () => { finalToken: randomAddress(), lzReceiveGasLimit: "200000", lzComposeGasLimit: "400000", + executionMode: 0, + actionData: "0x", }; // Create the signature and get the hash that was signed. @@ -50,11 +50,8 @@ describe("OFT Signature", () => { // Recover the address from the signature and the hash. // This simulates the on-chain validation by checking if the signature was created by the expected signer. - // The OFT contract expects an EIP-191 compliant signature, so we use `recoverMessageAddress`. - const recoveredAddress = await recoverMessageAddress({ - message: { raw: hexToBytes(hash as `0x${string}`) }, - signature: signature as `0x${string}`, - }); + // The OFT contract uses ECDSA.recover(digest, signature) which expects a signature over the raw digest. + const recoveredAddress = utils.recoverAddress(hash, signature); // Assert that the recovered address matches the address of our test wallet. expect(recoveredAddress).toEqual(TEST_WALLET.address);