From 3e42e6f71b33189a11370be3aca48ef055a9aa2e Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Fri, 31 Oct 2025 14:26:40 +0100 Subject: [PATCH 1/8] feat: sponsored cctp strategy evm --- api/_bridges/cctp-sponsored/strategy.ts | 322 ++++++++ api/_bridges/cctp-sponsored/utils/abi.ts | 50 ++ .../cctp-sponsored/utils/constants.ts | 28 + .../cctp-sponsored/utils/quote-builder.ts | 85 ++ api/_bridges/cctp-sponsored/utils/signing.ts | 100 +++ api/_bridges/cctp/strategy.ts | 21 +- api/_bridges/cctp/utils/constants.ts | 41 +- api/_bridges/cctp/utils/fill-times.ts | 10 + api/_bridges/hypercore/strategy.ts | 32 +- api/_bridges/index.ts | 6 +- api/_bridges/oft-sponsored/strategy.ts | 6 +- api/_bridges/oft-sponsored/utils/constants.ts | 15 - .../oft-sponsored/utils/eligibility.ts | 39 - .../oft-sponsored/utils/quote-builder.ts | 47 +- api/_bridges/oft-sponsored/utils/signing.ts | 4 +- api/_bridges/types.ts | 2 +- api/_bridges/utils.ts | 32 + api/_constants.ts | 41 +- api/_dexes/types.ts | 7 +- api/_hypercore.ts | 54 +- .../routing.ts => _sponsorship-routing.ts} | 21 +- api/_sponsorship-utils.ts | 82 ++ api/_utils.ts | 4 +- .../_bridges/cctp-sponsored/strategy.test.ts | 751 ++++++++++++++++++ 24 files changed, 1627 insertions(+), 173 deletions(-) create mode 100644 api/_bridges/cctp-sponsored/strategy.ts create mode 100644 api/_bridges/cctp-sponsored/utils/abi.ts create mode 100644 api/_bridges/cctp-sponsored/utils/constants.ts create mode 100644 api/_bridges/cctp-sponsored/utils/quote-builder.ts create mode 100644 api/_bridges/cctp-sponsored/utils/signing.ts create mode 100644 api/_bridges/cctp/utils/fill-times.ts delete mode 100644 api/_bridges/oft-sponsored/utils/eligibility.ts rename api/{_bridges/oft-sponsored/utils/routing.ts => _sponsorship-routing.ts} (83%) create mode 100644 api/_sponsorship-utils.ts create mode 100644 test/api/_bridges/cctp-sponsored/strategy.test.ts diff --git a/api/_bridges/cctp-sponsored/strategy.ts b/api/_bridges/cctp-sponsored/strategy.ts new file mode 100644 index 000000000..b113b848c --- /dev/null +++ b/api/_bridges/cctp-sponsored/strategy.ts @@ -0,0 +1,322 @@ +import { BigNumber, ethers, utils } from "ethers"; + +import { + BridgeStrategy, + GetExactInputBridgeQuoteParams, + BridgeCapabilities, + GetOutputBridgeQuoteParams, +} from "../types"; +import { CrossSwap, CrossSwapQuotes, Token } from "../../_dexes/types"; +import { AMOUNT_TYPE, AppFee, CROSS_SWAP_TYPE } from "../../_dexes/utils"; +import { CCTP_FINALITY_THRESHOLDS } from "../cctp/utils/constants"; +import { InvalidParamError } from "../../_errors"; +import { ConvertDecimals } from "../../_utils"; +import { getFallbackRecipient } from "../../_dexes/utils"; +import { getEstimatedFillTime } from "../cctp/utils/fill-times"; +import { getZeroBridgeFees } from "../utils"; +import { getCctpFees } from "../cctp/utils/hypercore"; +import { buildSponsoredCCTPQuote } from "./utils/quote-builder"; +import { + SPONSORED_CCTP_DESTINATION_CHAINS, + SPONSORED_CCTP_INPUT_TOKENS, + SPONSORED_CCTP_ORIGIN_CHAINS, + SPONSORED_CCTP_OUTPUT_TOKENS, + SPONSORED_CCTP_SRC_PERIPHERY_ADDRESSES, +} from "./utils/constants"; +import { simulateMarketOrder } from "../../_hypercore"; +import { SPONSORED_CCTP_SRC_PERIPHERY_ABI } from "./utils/abi"; +import { tagIntegratorId, tagSwapApiMarker } from "../../_integrator-id"; + +const name = "sponsored-cctp" as const; + +const capabilities: BridgeCapabilities = { + ecosystems: ["evm", "svm"], + supports: { + A2A: false, + A2B: false, + B2A: false, + B2B: true, + B2BI: false, + crossChainMessage: false, + }, +}; + +// TODO: Should this always be fast? +const cctpMode = "fast" as const; + +/** + * Sponsored CCTP bridge strategy + */ +export function getSponsoredCctpBridgeStrategy(): BridgeStrategy { + return { + name, + capabilities, + originTxNeedsAllowance: true, + isRouteSupported, + + getCrossSwapTypes: ({ inputToken, outputToken }) => { + if (isRouteSupported({ inputToken, outputToken })) { + return [CROSS_SWAP_TYPE.BRIDGEABLE_TO_BRIDGEABLE]; + } + return []; + }, + + getBridgeQuoteRecipient: (crossSwap: CrossSwap) => { + return crossSwap.recipient; + }, + getBridgeQuoteMessage: (_crossSwap: CrossSwap, _appFee?: AppFee) => { + return "0x"; + }, + getQuoteForExactInput, + getQuoteForOutput, + // TODO: ADD Solana support + buildTxForAllowanceHolder: buildEvmTxForAllowanceHolder, + }; +} + +export function isRouteSupported(params: { + inputToken: Token; + outputToken: Token; +}) { + return ( + SPONSORED_CCTP_ORIGIN_CHAINS.includes(params.inputToken.chainId) && + SPONSORED_CCTP_DESTINATION_CHAINS.includes(params.outputToken.chainId) && + SPONSORED_CCTP_INPUT_TOKENS.some( + (tokenSymbol) => + tokenSymbol.toLowerCase() === params.inputToken.symbol.toLowerCase() + ) && + SPONSORED_CCTP_OUTPUT_TOKENS.some( + (tokenSymbol) => + tokenSymbol.toLowerCase() === params.outputToken.symbol.toLowerCase() + ) + ); +} + +export async function getQuoteForExactInput({ + inputToken, + outputToken, + exactInputAmount, +}: GetExactInputBridgeQuoteParams) { + assertSupportedRoute({ inputToken, outputToken }); + + // We guarantee input amount == output amount for sponsored flows + const outputAmount = ConvertDecimals( + inputToken.decimals, + outputToken.decimals + )(exactInputAmount); + + return { + bridgeQuote: { + inputToken, + outputToken, + inputAmount: exactInputAmount, + outputAmount, + minOutputAmount: outputAmount, + estimatedFillTimeSec: getEstimatedFillTime(inputToken.chainId, cctpMode), + provider: name, + fees: getZeroBridgeFees(inputToken), + }, + }; +} + +export async function getQuoteForOutput({ + inputToken, + outputToken, + minOutputAmount, +}: GetOutputBridgeQuoteParams) { + assertSupportedRoute({ inputToken, outputToken }); + + // We guarantee input amount == output amount for sponsored flows + const inputAmount = ConvertDecimals( + outputToken.decimals, + inputToken.decimals + )(minOutputAmount); + + return { + bridgeQuote: { + inputToken, + outputToken, + inputAmount, + outputAmount: minOutputAmount, + minOutputAmount, + estimatedFillTimeSec: getEstimatedFillTime(inputToken.chainId, cctpMode), + provider: name, + fees: getZeroBridgeFees(inputToken), + }, + }; +} + +export async function buildEvmTxForAllowanceHolder(params: { + quotes: CrossSwapQuotes; + integratorId?: string; +}) { + const { + bridgeQuote, + crossSwap, + originSwapQuote, + destinationSwapQuote, + appFee, + } = params.quotes; + + assertSupportedRoute({ + inputToken: crossSwap.inputToken, + outputToken: crossSwap.outputToken, + }); + + const originChainId = crossSwap.inputToken.chainId; + const sponsoredCctpSrcPeripheryAddress = + SPONSORED_CCTP_SRC_PERIPHERY_ADDRESSES[originChainId]; + + if (!sponsoredCctpSrcPeripheryAddress) { + throw new InvalidParamError({ + message: `Sponsored CCTP: 'SponsoredCCTPSrcPeriphery' address not found for chain ${originChainId}`, + }); + } + + if (appFee?.feeAmount.gt(0)) { + throw new InvalidParamError({ + message: `Sponsored CCTP: App fee is not supported`, + }); + } + + if (originSwapQuote || destinationSwapQuote) { + throw new InvalidParamError({ + message: `Sponsored CCTP: Origin/destination swaps are not supported`, + }); + } + + const minFinalityThreshold = CCTP_FINALITY_THRESHOLDS[cctpMode]; + + // Calculate `maxFee` as required by `depositForBurnWithHook` + const { transferFeeBps, forwardFee } = await getCctpFees({ + inputToken: crossSwap.inputToken, + outputToken: crossSwap.outputToken, + minFinalityThreshold, + }); + const transferFee = bridgeQuote.inputAmount.mul(transferFeeBps).div(10_000); + const maxFee = transferFee.add(forwardFee); + + // Calculate `maxBpsToSponsor` based on `maxFee` and est. swap slippage + const maxBpsToSponsor = await calculateMaxBpsToSponsor({ + inputToken: crossSwap.inputToken, + outputToken: crossSwap.outputToken, + maxFee, + inputAmount: + crossSwap.type === AMOUNT_TYPE.EXACT_INPUT + ? bridgeQuote.inputAmount + : bridgeQuote.inputAmount.add(maxFee), + }); + const maxBpsToSponsorBn = BigNumber.from(Math.ceil(maxBpsToSponsor)); + + // Convert slippage tolerance (expressed as 0 < slippage < 100, e.g. 1 = 1%) set by user to bps + const maxUserSlippageBps = Math.floor(crossSwap.slippageTolerance * 100); + + const { quote, signature } = buildSponsoredCCTPQuote({ + inputToken: crossSwap.inputToken, + outputToken: crossSwap.outputToken, + inputAmount: bridgeQuote.inputAmount, + recipient: crossSwap.recipient, + depositor: crossSwap.depositor, + refundRecipient: getFallbackRecipient(crossSwap, crossSwap.recipient), + maxBpsToSponsor: maxBpsToSponsorBn, + maxUserSlippageBps, + maxFee, + }); + + const iface = new ethers.utils.Interface(SPONSORED_CCTP_SRC_PERIPHERY_ABI); + const callData = iface.encodeFunctionData("depositForBurn", [ + quote, + signature, + ]); + + const callDataWithIntegratorId = params.integratorId + ? tagIntegratorId(params.integratorId, callData) + : callData; + const callDataWithMarkers = tagSwapApiMarker(callDataWithIntegratorId); + + return { + chainId: originChainId, + from: crossSwap.depositor, + to: sponsoredCctpSrcPeripheryAddress, + data: callDataWithMarkers, + value: BigNumber.from(0), + ecosystem: "evm" as const, + }; +} + +export async function calculateMaxBpsToSponsor(params: { + inputToken: Token; + outputToken: Token; + maxFee: BigNumber; + inputAmount: BigNumber; +}) { + const { inputToken, outputToken, maxFee, inputAmount } = params; + + assertSupportedRoute({ inputToken, outputToken }); + + const maxFeeBps = maxFee + .mul(10_000) + .mul(utils.parseEther("1")) + .div(inputAmount); + + let maxBpsToSponsor = maxFeeBps; + + // Simple transfer flow: no swap needed, therefore `maxBpsToSponsor` is `maxFee` in bps + if (outputToken.symbol === "USDC-SPOT") { + maxBpsToSponsor = maxFeeBps; + } + // Swap flow: `maxBpsToSponsor` is `maxFee` + est. swap slippage if slippage is positive + // or only `maxFee` if slippage is negative. + else { + const bridgeOutputAmountInputTokenDecimals = params.inputAmount.sub( + params.maxFee + ); + const bridgeOutputAmountOutputTokenDecimals = ConvertDecimals( + params.inputToken.decimals, + params.outputToken.decimals + )(bridgeOutputAmountInputTokenDecimals); + + // Retrieve est. swap slippage by simulating a market order for token pair + const simResult = await simulateMarketOrder({ + chainId: outputToken.chainId, + tokenIn: { + symbol: "USDC", + decimals: 8, + }, + tokenOut: { + symbol: outputToken.symbol, + decimals: outputToken.decimals, + }, + inputAmount: bridgeOutputAmountOutputTokenDecimals, + }); + const slippageBps = BigNumber.from( + Math.ceil(simResult.slippagePercent * 100) + ).mul(utils.parseEther("1")); + + // Positive slippage indicates loss, so we add it to `maxFeeBps` + if (simResult.slippagePercent > 0) { + maxBpsToSponsor = maxFeeBps.add(slippageBps); + } + // Negative slippage indicates profit, so we return `maxFeeBps` + else { + maxBpsToSponsor = maxFeeBps; + } + } + + return parseFloat(utils.formatEther(maxBpsToSponsor)); +} + +function assertSupportedRoute(params: { + inputToken: Token; + outputToken: Token; +}) { + if (!isRouteSupported(params)) { + throw new InvalidParamError({ + message: `Sponsored CCTP: Route ${ + params.inputToken.symbol + } (${params.inputToken.chainId}) -> ${ + params.outputToken.symbol + } (${params.outputToken.chainId}) is not supported`, + }); + } +} diff --git a/api/_bridges/cctp-sponsored/utils/abi.ts b/api/_bridges/cctp-sponsored/utils/abi.ts new file mode 100644 index 000000000..d9a77d0e4 --- /dev/null +++ b/api/_bridges/cctp-sponsored/utils/abi.ts @@ -0,0 +1,50 @@ +/** + * ABI for SponsoredCCTPSrcPeriphery contract + * Source periphery contract that users interact with to start sponsored CCTP flows + */ +export const SPONSORED_CCTP_SRC_PERIPHERY_ABI = [ + { + inputs: [ + { + components: [ + { internalType: "uint32", name: "sourceDomain", type: "uint32" }, + { internalType: "uint32", name: "destinationDomain", type: "uint32" }, + { internalType: "bytes32", name: "mintRecipient", type: "bytes32" }, + { internalType: "uint256", name: "amount", type: "uint256" }, + { internalType: "bytes32", name: "burnToken", type: "bytes32" }, + { + internalType: "bytes32", + name: "destinationCaller", + type: "bytes32", + }, + { internalType: "uint256", name: "maxFee", type: "uint256" }, + { + internalType: "uint32", + name: "minFinalityThreshold", + type: "uint32", + }, + { internalType: "bytes32", name: "nonce", type: "bytes32" }, + { internalType: "uint256", name: "deadline", type: "uint256" }, + { internalType: "uint256", name: "maxBpsToSponsor", type: "uint256" }, + { + internalType: "uint256", + name: "maxUserSlippageBps", + type: "uint256", + }, + { internalType: "bytes32", name: "finalRecipient", type: "bytes32" }, + { internalType: "bytes32", name: "finalToken", type: "bytes32" }, + { internalType: "uint8", name: "executionMode", type: "uint8" }, + { internalType: "bytes", name: "actionData", type: "bytes" }, + ], + internalType: "struct SponsoredCCTPInterface.SponsoredCCTPQuote", + name: "quote", + type: "tuple", + }, + { internalType: "bytes", name: "signature", type: "bytes" }, + ], + name: "depositForBurn", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, +]; diff --git a/api/_bridges/cctp-sponsored/utils/constants.ts b/api/_bridges/cctp-sponsored/utils/constants.ts new file mode 100644 index 000000000..584a5317a --- /dev/null +++ b/api/_bridges/cctp-sponsored/utils/constants.ts @@ -0,0 +1,28 @@ +import { CHAIN_IDs } from "../../../_constants"; +import { CCTP_SUPPORTED_CHAINS } from "../../cctp/utils/constants"; + +// TODO: Use actual addresses for zero addresses +export const SPONSORED_CCTP_SRC_PERIPHERY_ADDRESSES = { + [CHAIN_IDs.ARBITRUM]: "0x0000000000000000000000000000000000000000", + [CHAIN_IDs.ARBITRUM_SEPOLIA]: "0x79176E2E91c77b57AC11c6fe2d2Ab2203D87AF85", +}; + +// TODO: Use actual addresses for zero addresses +export const SPONSORED_CCTP_DST_PERIPHERY_ADDRESSES = { + [CHAIN_IDs.HYPEREVM]: "0x0000000000000000000000000000000000000000", + [CHAIN_IDs.HYPEREVM_TESTNET]: "0x0000000000000000000000000000000000000000", +}; + +export const SPONSORED_CCTP_ORIGIN_CHAINS = CCTP_SUPPORTED_CHAINS.filter( + (chainId) => + ![CHAIN_IDs.HYPERCORE, CHAIN_IDs.HYPERCORE_TESTNET].includes(chainId) +); + +export const SPONSORED_CCTP_INPUT_TOKENS = ["USDC"]; + +export const SPONSORED_CCTP_OUTPUT_TOKENS = ["USDC-SPOT", "USDH-SPOT"]; + +export const SPONSORED_CCTP_DESTINATION_CHAINS = [ + CHAIN_IDs.HYPERCORE, + CHAIN_IDs.HYPERCORE_TESTNET, +]; diff --git a/api/_bridges/cctp-sponsored/utils/quote-builder.ts b/api/_bridges/cctp-sponsored/utils/quote-builder.ts new file mode 100644 index 000000000..6ecaccead --- /dev/null +++ b/api/_bridges/cctp-sponsored/utils/quote-builder.ts @@ -0,0 +1,85 @@ +import { BigNumber } from "ethers"; +import { createCctpSignature, SponsoredCCTPQuote } from "./signing"; +import { toBytes32 } from "../../../_address"; +import { CHAIN_IDs } from "../../../_constants"; +import { + generateQuoteNonce, + ExecutionMode, + BuildSponsoredQuoteParams, + DEFAULT_QUOTE_EXPIRY_SECONDS, +} from "../../../_sponsorship-utils"; +import { + CCTP_FINALITY_THRESHOLDS, + getCctpDomainId, +} from "../../cctp/utils/constants"; +import { isToHyperCore } from "../../cctp/utils/hypercore"; +import { SPONSORED_CCTP_DST_PERIPHERY_ADDRESSES } from "./constants"; + +/** + * Builds a complete sponsored CCTP quote with signature + * @param params Quote building parameters + * @returns Complete quote with signed and unsigned params, plus signature + */ +export function buildSponsoredCCTPQuote( + params: BuildSponsoredQuoteParams & { maxFee: BigNumber } +): { quote: SponsoredCCTPQuote; signature: string; hash: string } { + const { + inputToken, + outputToken, + inputAmount, + recipient, + depositor, + maxBpsToSponsor, + maxUserSlippageBps, + maxFee, + } = params; + + const isDestinationHyperCore = isToHyperCore(outputToken.chainId); + + if (!isDestinationHyperCore) { + throw new Error( + "Can't build sponsored CCTP quote for non-HyperCore destination" + ); + } + + const nonce = generateQuoteNonce(depositor); + + const deadline = Math.floor(Date.now() / 1000) + DEFAULT_QUOTE_EXPIRY_SECONDS; + + const intermediaryChainId = + outputToken.chainId === CHAIN_IDs.HYPERCORE + ? CHAIN_IDs.HYPEREVM + : CHAIN_IDs.HYPEREVM_TESTNET; + + const sponsoredCCTPDstPeripheryAddress = + SPONSORED_CCTP_DST_PERIPHERY_ADDRESSES[intermediaryChainId]; + if (!sponsoredCCTPDstPeripheryAddress) { + throw new Error( + `'SponsoredCCTPDstPeriphery' not found for intermediary chain ${intermediaryChainId}` + ); + } + + const sponsoredCCTPQuote: SponsoredCCTPQuote = { + sourceDomain: getCctpDomainId(inputToken.chainId), + destinationDomain: getCctpDomainId(intermediaryChainId), + destinationCaller: toBytes32(sponsoredCCTPDstPeripheryAddress), + mintRecipient: toBytes32(recipient), + amount: inputAmount, + burnToken: toBytes32(inputToken.address), + maxFee, + // TODO: Should this always be fast? + minFinalityThreshold: CCTP_FINALITY_THRESHOLDS.fast, + nonce, + deadline, + maxBpsToSponsor, + maxUserSlippageBps, + finalRecipient: toBytes32(recipient), + finalToken: toBytes32(outputToken.address), + executionMode: ExecutionMode.Default, // Default HyperCore flow + actionData: "0x", // Empty for default flow + }; + + const { signature, typedDataHash } = createCctpSignature(sponsoredCCTPQuote); + + return { quote: sponsoredCCTPQuote, signature, hash: typedDataHash }; +} diff --git a/api/_bridges/cctp-sponsored/utils/signing.ts b/api/_bridges/cctp-sponsored/utils/signing.ts new file mode 100644 index 000000000..78a999db1 --- /dev/null +++ b/api/_bridges/cctp-sponsored/utils/signing.ts @@ -0,0 +1,100 @@ +import { BigNumberish, utils } from "ethers"; +import { signDigestWithSponsor } from "../../../_sponsorship-signature"; + +/** + * 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; + executionMode: number; + actionData: 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", + "uint8", + "bytes32", + ], + [ + quote.nonce, + quote.deadline, + quote.maxBpsToSponsor, + quote.maxUserSlippageBps, + quote.finalRecipient, + quote.finalToken, + quote.executionMode, + utils.keccak256(quote.actionData), + ] + ) + ); + + // 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/cctp/strategy.ts b/api/_bridges/cctp/strategy.ts index 6ba063a9a..e48dc37b0 100644 --- a/api/_bridges/cctp/strategy.ts +++ b/api/_bridges/cctp/strategy.ts @@ -33,7 +33,6 @@ import { getSVMRpc } from "../../_providers"; import { CCTP_SUPPORTED_CHAINS, CCTP_SUPPORTED_TOKENS, - CCTP_FILL_TIME_ESTIMATES, CCTP_FINALITY_THRESHOLDS, getCctpTokenMessengerAddress, getCctpMessageTransmitterAddress, @@ -52,6 +51,7 @@ import { encodeForwardHookData, getCctpFees, } from "./utils/hypercore"; +import { getEstimatedFillTime } from "./utils/fill-times"; const name = "cctp"; @@ -72,11 +72,6 @@ const capabilities: BridgeCapabilities = { * Supports Circle's CCTP for burning USDC on source chain. */ export function getCctpBridgeStrategy(): BridgeStrategy { - const getEstimatedFillTime = (originChainId: number): number => { - // CCTP fill time is determined by the origin chain attestation process - return CCTP_FILL_TIME_ESTIMATES[originChainId] || 19 * 60; // Default to 19 minutes - }; - const isRouteSupported = (params: { inputToken: Token; outputToken: Token; @@ -173,6 +168,7 @@ export function getCctpBridgeStrategy(): BridgeStrategy { let maxFee = BigNumber.from(0); let outputAmount: BigNumber; + let standardOrFast: "standard" | "fast" = "standard"; if (isToHyperCore(outputToken.chainId)) { // Query CCTP fee configuration for HyperCore destinations const minFinalityThreshold = getFinalityThreshold(outputToken.chainId); @@ -199,6 +195,7 @@ export function getCctpBridgeStrategy(): BridgeStrategy { amount: remainingInputAmount, recipient, }); + standardOrFast = "fast"; } else { // Standard conversion after fees const inputAfterFee = exactInputAmount.sub(maxFee); @@ -215,7 +212,10 @@ export function getCctpBridgeStrategy(): BridgeStrategy { inputAmount: exactInputAmount, outputAmount, minOutputAmount: outputAmount, - estimatedFillTimeSec: getEstimatedFillTime(inputToken.chainId), + estimatedFillTimeSec: getEstimatedFillTime( + inputToken.chainId, + standardOrFast + ), provider: name, fees: getCctpBridgeFees(inputToken, maxFee), }, @@ -253,8 +253,10 @@ export function getCctpBridgeStrategy(): BridgeStrategy { // Calculate how much to send from origin to cover CCTP fees let inputAmount: BigNumber; let maxFee = BigNumber.from(0); + let standardOrFast: "standard" | "fast" = "standard"; if (destinationIsHyperCore) { + standardOrFast = "fast"; const minFinalityThreshold = getFinalityThreshold(outputToken.chainId); const { transferFeeBps, forwardFee } = await getCctpFees({ inputToken, @@ -288,7 +290,10 @@ export function getCctpBridgeStrategy(): BridgeStrategy { inputAmount, outputAmount: minOutputAmount, minOutputAmount, - estimatedFillTimeSec: getEstimatedFillTime(inputToken.chainId), + estimatedFillTimeSec: getEstimatedFillTime( + inputToken.chainId, + standardOrFast + ), provider: name, fees: getCctpBridgeFees(inputToken, maxFee), }, diff --git a/api/_bridges/cctp/utils/constants.ts b/api/_bridges/cctp/utils/constants.ts index 108c685dd..570ac6dad 100644 --- a/api/_bridges/cctp/utils/constants.ts +++ b/api/_bridges/cctp/utils/constants.ts @@ -268,16 +268,33 @@ export const encodeDepositForBurnWithHook = (params: { }; // CCTP estimated fill times in seconds -// Soruce: https://developers.circle.com/cctp/required-block-confirmations -export const CCTP_FILL_TIME_ESTIMATES: Record = { - [CHAIN_IDs.MAINNET]: 19 * 60, - [CHAIN_IDs.ARBITRUM]: 19 * 60, - [CHAIN_IDs.BASE]: 19 * 60, - [CHAIN_IDs.HYPEREVM]: 5, - [CHAIN_IDs.INK]: 30 * 60, - [CHAIN_IDs.OPTIMISM]: 19 * 60, - [CHAIN_IDs.POLYGON]: 8, - [CHAIN_IDs.SOLANA]: 25, - [CHAIN_IDs.UNICHAIN]: 19 * 60, - [CHAIN_IDs.WORLD_CHAIN]: 19 * 60, +// Source: https://developers.circle.com/cctp/required-block-confirmations +export const CCTP_FILL_TIME_ESTIMATES: { + fast: Record; + standard: Record; +} = { + standard: { + [CHAIN_IDs.MAINNET]: 19 * 60, + [CHAIN_IDs.ARBITRUM]: 19 * 60, + [CHAIN_IDs.BASE]: 19 * 60, + [CHAIN_IDs.HYPEREVM]: 5, + [CHAIN_IDs.INK]: 30 * 60, + [CHAIN_IDs.OPTIMISM]: 19 * 60, + [CHAIN_IDs.POLYGON]: 8, + [CHAIN_IDs.SOLANA]: 25, + [CHAIN_IDs.UNICHAIN]: 19 * 60, + [CHAIN_IDs.WORLD_CHAIN]: 19 * 60, + }, + fast: { + [CHAIN_IDs.MAINNET]: 20, + [CHAIN_IDs.ARBITRUM]: 8, + [CHAIN_IDs.BASE]: 8, + [CHAIN_IDs.HYPEREVM]: 5, + [CHAIN_IDs.INK]: 8, + [CHAIN_IDs.OPTIMISM]: 8, + [CHAIN_IDs.POLYGON]: 8, + [CHAIN_IDs.SOLANA]: 8, + [CHAIN_IDs.UNICHAIN]: 8, + [CHAIN_IDs.WORLD_CHAIN]: 8, + }, }; diff --git a/api/_bridges/cctp/utils/fill-times.ts b/api/_bridges/cctp/utils/fill-times.ts new file mode 100644 index 000000000..a803e90e7 --- /dev/null +++ b/api/_bridges/cctp/utils/fill-times.ts @@ -0,0 +1,10 @@ +import { CCTP_FILL_TIME_ESTIMATES } from "./constants"; + +export const getEstimatedFillTime = ( + originChainId: number, + standardOrFast: "standard" | "fast" = "standard" +): number => { + const fallback = standardOrFast === "standard" ? 19 * 60 : 8; + // CCTP fill time is determined by the origin chain attestation process + return CCTP_FILL_TIME_ESTIMATES[standardOrFast][originChainId] || fallback; +}; diff --git a/api/_bridges/hypercore/strategy.ts b/api/_bridges/hypercore/strategy.ts index f37a8675d..701b0e03c 100644 --- a/api/_bridges/hypercore/strategy.ts +++ b/api/_bridges/hypercore/strategy.ts @@ -20,6 +20,7 @@ import { CORE_WRITER_EVM_ADDRESS, encodeTransferOnCoreCalldata, } from "../../_hypercore"; +import { getZeroBridgeFees } from "../utils"; const supportedTokens = [TOKEN_SYMBOLS_MAP["USDT-SPOT"]]; @@ -275,34 +276,3 @@ export function getHyperCoreBridgeStrategy(): BridgeStrategy { isRouteSupported, }; } - -function getZeroBridgeFees(inputToken: Token) { - 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: zeroBN, - token: inputToken, - }, - }; -} diff --git a/api/_bridges/index.ts b/api/_bridges/index.ts index 1289aad3e..83bb6840a 100644 --- a/api/_bridges/index.ts +++ b/api/_bridges/index.ts @@ -9,7 +9,8 @@ import { import { CHAIN_IDs } from "../_constants"; import { getCctpBridgeStrategy } from "./cctp/strategy"; import { routeStrategyForCctp } from "./cctp/utils/routing"; -import { routeStrategyForSponsorship } from "./oft-sponsored/utils/routing"; +import { routeStrategyForSponsorship } from "../_sponsorship-routing"; +import { getSponsoredCctpBridgeStrategy } from "./cctp-sponsored/strategy"; export const bridgeStrategies: BridgeStrategiesConfig = { default: getAcrossBridgeStrategy(), @@ -31,7 +32,8 @@ export const bridgeStrategies: BridgeStrategiesConfig = { [CHAIN_IDs.HYPERCORE_TESTNET]: getCctpBridgeStrategy(), }, [CHAIN_IDs.ARBITRUM_SEPOLIA]: { - [CHAIN_IDs.HYPERCORE_TESTNET]: getCctpBridgeStrategy(), + // @TODO: Remove this once we can correctly route via eligibility checks + [CHAIN_IDs.HYPERCORE_TESTNET]: getSponsoredCctpBridgeStrategy(), }, // SVM → HyperCore routes [CHAIN_IDs.SOLANA]: { diff --git a/api/_bridges/oft-sponsored/strategy.ts b/api/_bridges/oft-sponsored/strategy.ts index 51e556013..d334ca3be 100644 --- a/api/_bridges/oft-sponsored/strategy.ts +++ b/api/_bridges/oft-sponsored/strategy.ts @@ -326,11 +326,11 @@ async function buildTransaction(params: { 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); + // Convert slippage tolerance (expressed as 0 < slippage < 100, e.g. 1 = 1%) set by user to bps + const maxUserSlippageBps = Math.floor(crossSwap.slippageTolerance * 100); // Build signed quote with signature - const { quote, signature } = await buildSponsoredOFTQuote({ + const { quote, signature } = buildSponsoredOFTQuote({ inputToken: crossSwap.inputToken, outputToken: crossSwap.outputToken, inputAmount: bridgeQuote.inputAmount, diff --git a/api/_bridges/oft-sponsored/utils/constants.ts b/api/_bridges/oft-sponsored/utils/constants.ts index 2c3e0ea5b..3dc9794ec 100644 --- a/api/_bridges/oft-sponsored/utils/constants.ts +++ b/api/_bridges/oft-sponsored/utils/constants.ts @@ -1,15 +1,5 @@ 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 @@ -36,11 +26,6 @@ export const DST_OFT_HANDLER: Record = { 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 */ diff --git a/api/_bridges/oft-sponsored/utils/eligibility.ts b/api/_bridges/oft-sponsored/utils/eligibility.ts deleted file mode 100644 index f72c2ceb9..000000000 --- a/api/_bridges/oft-sponsored/utils/eligibility.ts +++ /dev/null @@ -1,39 +0,0 @@ -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/oft-sponsored/utils/quote-builder.ts b/api/_bridges/oft-sponsored/utils/quote-builder.ts index 6698f7dff..181df1eeb 100644 --- a/api/_bridges/oft-sponsored/utils/quote-builder.ts +++ b/api/_bridges/oft-sponsored/utils/quote-builder.ts @@ -1,5 +1,3 @@ -import { BigNumber, utils } from "ethers"; -import { Token } from "../../../_dexes/types"; import { SignedQuoteParams, UnsignedQuoteParams, @@ -9,49 +7,28 @@ import { 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; -} +import { + generateQuoteNonce, + BuildSponsoredQuoteParams, + DEFAULT_QUOTE_EXPIRY_SECONDS, + ExecutionMode, +} from "../../../_sponsorship-utils"; /** * 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 }> { +export function buildSponsoredOFTQuote(params: BuildSponsoredQuoteParams): { + quote: SponsoredOFTQuote; + signature: string; + hash: string; +} { const { inputToken, outputToken, @@ -107,7 +84,7 @@ export async function buildSponsoredOFTQuote( }; // Create signature - const { signature, hash } = await createOftSignature(signedParams); + const { signature, hash } = createOftSignature(signedParams); const quote: SponsoredOFTQuote = { signedParams, diff --git a/api/_bridges/oft-sponsored/utils/signing.ts b/api/_bridges/oft-sponsored/utils/signing.ts index a0bd5c423..34bdcb34e 100644 --- a/api/_bridges/oft-sponsored/utils/signing.ts +++ b/api/_bridges/oft-sponsored/utils/signing.ts @@ -48,9 +48,9 @@ export interface SponsoredOFTQuote { * @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 ( +export const createOftSignature = ( quote: SignedQuoteParams -): Promise<{ signature: string; hash: string }> => { +): { 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( diff --git a/api/_bridges/types.ts b/api/_bridges/types.ts index eb150a658..9ab03c391 100644 --- a/api/_bridges/types.ts +++ b/api/_bridges/types.ts @@ -138,7 +138,7 @@ export type GetBridgeStrategyParams = { export type RoutingRule = { name: string; shouldApply: (data: TEligibilityData) => boolean; - getStrategy: () => BridgeStrategy | null; + getStrategy: (inputToken?: Token) => BridgeStrategy | null; reason: string; }; diff --git a/api/_bridges/utils.ts b/api/_bridges/utils.ts index d1cd303a4..ae0f46617 100644 --- a/api/_bridges/utils.ts +++ b/api/_bridges/utils.ts @@ -7,6 +7,7 @@ import { BridgeStrategyData, BridgeStrategyDataParams, } from "../_bridges/types"; +import { Token } from "../_dexes/types"; const ACROSS_THRESHOLD = 10_000; // 10K USD const LARGE_DEPOSIT_THRESHOLD = 1_000_000; // 1M USD @@ -129,3 +130,34 @@ export async function getBridgeStrategyData({ return undefined; } } + +export function getZeroBridgeFees(inputToken: Token) { + 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: zeroBN, + token: inputToken, + }, + }; +} diff --git a/api/_constants.ts b/api/_constants.ts index 936343b0e..7f64bf783 100644 --- a/api/_constants.ts +++ b/api/_constants.ts @@ -18,13 +18,38 @@ 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", +export const TOKEN_SYMBOLS_MAP = { + ...constants.TOKEN_SYMBOLS_MAP, + USDH: { + name: "USDH", + symbol: "USDH", + decimals: 6, + addresses: { + [CHAIN_IDs.HYPEREVM]: "0x111111a1a0667d36bD57c0A9f569b98057111111", + }, + coingeckoId: "usdh-2", + }, + "USDH-SPOT": { + name: "USDH-SPOT", + symbol: "USDH-SPOT", + decimals: 8, + addresses: { + [CHAIN_IDs.HYPERCORE]: "0x2000000000000000000000000000000000000168", + [CHAIN_IDs.HYPERCORE_TESTNET]: + "0x2000000000000000000000000000000000000168", + }, + coingeckoId: "usdh-2", + }, + "USDC-SPOT": { + name: "USDC-SPOT", + symbol: "USDC-SPOT", + decimals: 8, + addresses: { + [CHAIN_IDs.HYPERCORE]: "0x2000000000000000000000000000000000000000", + [CHAIN_IDs.HYPERCORE_TESTNET]: + "0x2000000000000000000000000000000000000000", + }, + coingeckoId: "usd-coin", }, }; TOKEN_SYMBOLS_MAP.WHYPE = { @@ -339,6 +364,8 @@ export const CG_CONTRACTS_DEFERRED_TO_ID = new Set([ TOKEN_SYMBOLS_MAP["USDT-SPOT"].addresses[CHAIN_IDs.HYPERCORE], TOKEN_SYMBOLS_MAP.XPL.addresses[CHAIN_IDs.PLASMA], TOKEN_SYMBOLS_MAP.XPL.addresses[CHAIN_IDs.PLASMA_TESTNET], + TOKEN_SYMBOLS_MAP["USDC-SPOT"].addresses[CHAIN_IDs.HYPERCORE], + TOKEN_SYMBOLS_MAP["USDC-SPOT"].addresses[CHAIN_IDs.HYPERCORE_TESTNET], ]); // 1:1 because we don't need to handle underlying tokens on FE diff --git a/api/_dexes/types.ts b/api/_dexes/types.ts index c07daed5c..38d5f40d1 100644 --- a/api/_dexes/types.ts +++ b/api/_dexes/types.ts @@ -139,7 +139,12 @@ export type CrossSwapQuotes = { suggestedFees: Awaited>; } | { - provider: "hypercore" | "cctp" | "oft" | "sponsored-oft"; + provider: + | "hypercore" + | "cctp" + | "oft" + | "sponsored-oft" + | "sponsored-cctp"; } ); destinationSwapQuote?: SwapQuote; diff --git a/api/_hypercore.ts b/api/_hypercore.ts index 0f88e9b98..a2e523085 100644 --- a/api/_hypercore.ts +++ b/api/_hypercore.ts @@ -5,6 +5,7 @@ import { getProvider } from "./_providers"; import { CHAIN_IDs } from "./_constants"; const HYPERLIQUID_API_BASE_URL = "https://api.hyperliquid.xyz"; +const HYPERLIQUID_API_BASE_URL_TESTNET = "https://api.hyperliquid-testnet.xyz"; // Maps / to the coin identifier to be used to // retrieve the L2 order book for a given pair via the Hyperliquid API. @@ -131,10 +132,27 @@ export async function accountExistsOnHyperCore(params: { // https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint#l2-book-snapshot export async function getL2OrderBookForPair(params: { + chainId?: number; tokenInSymbol: string; tokenOutSymbol: string; }) { - const { tokenInSymbol, tokenOutSymbol } = params; + const { + chainId = CHAIN_IDs.HYPERCORE, + tokenInSymbol: _tokenInSymbol, + tokenOutSymbol: _tokenOutSymbol, + } = params; + + const tokenInSymbol = getNormalizedSpotTokenSymbol(_tokenInSymbol); + const tokenOutSymbol = getNormalizedSpotTokenSymbol(_tokenOutSymbol); + + if (![CHAIN_IDs.HYPERCORE, CHAIN_IDs.HYPERCORE_TESTNET].includes(chainId)) { + throw new Error("Can't get L2 order book for non-HyperCore chain"); + } + + const baseUrl = + chainId === CHAIN_IDs.HYPERCORE_TESTNET + ? HYPERLIQUID_API_BASE_URL_TESTNET + : HYPERLIQUID_API_BASE_URL; // Try both directions since the pair might be stored either way let coin = @@ -154,7 +172,7 @@ export async function getL2OrderBookForPair(params: { { 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`, { + }>(`${baseUrl}/info`, { type: "l2Book", coin, }); @@ -206,6 +224,7 @@ export type MarketOrderSimulationResult = { * }); */ export async function simulateMarketOrder(params: { + chainId?: number; tokenIn: { symbol: string; decimals: number; @@ -216,41 +235,50 @@ export async function simulateMarketOrder(params: { }; inputAmount: BigNumber; }): Promise { - const { tokenIn, tokenOut, inputAmount } = params; + const { + chainId = CHAIN_IDs.HYPERCORE, + tokenIn, + tokenOut, + inputAmount, + } = params; const orderBook = await getL2OrderBookForPair({ + chainId, tokenInSymbol: tokenIn.symbol, tokenOutSymbol: tokenOut.symbol, }); + const tokenInSymbol = getNormalizedSpotTokenSymbol(tokenIn.symbol); + const tokenOutSymbol = getNormalizedSpotTokenSymbol(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}`; + const pairKey = `${tokenInSymbol}/${tokenOutSymbol}`; + const reversePairKey = `${tokenOutSymbol}/${tokenInSymbol}`; let baseCurrency = ""; if (L2_ORDER_BOOK_COIN_MAP[pairKey]) { // Normal direction: tokenIn/tokenOut exists in map - baseCurrency = tokenIn.symbol; + baseCurrency = tokenInSymbol; } else if (L2_ORDER_BOOK_COIN_MAP[reversePairKey]) { // Reverse direction: tokenOut/tokenIn exists in map - baseCurrency = tokenOut.symbol; + baseCurrency = tokenOutSymbol; } else { throw new Error( - `No L2 order book key configured for pair ${tokenIn.symbol}/${tokenOut.symbol}` + `No L2 order book key configured for pair ${tokenInSymbol}/${tokenOutSymbol}` ); } // 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 isBuyingBase = tokenOutSymbol === 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}` + `No liquidity available for ${tokenInSymbol}/${tokenOutSymbol}` ); } @@ -373,3 +401,9 @@ export async function simulateMarketOrder(params: { fullyFilled, }; } + +export function getNormalizedSpotTokenSymbol(symbol: string): string { + return ["USDC-SPOT", "USDT-SPOT", "USDH-SPOT"].includes(symbol.toUpperCase()) + ? symbol.toUpperCase().replace("-SPOT", "") + : symbol.toUpperCase(); +} diff --git a/api/_bridges/oft-sponsored/utils/routing.ts b/api/_sponsorship-routing.ts similarity index 83% rename from api/_bridges/oft-sponsored/utils/routing.ts rename to api/_sponsorship-routing.ts index a94a17cdf..e071498c9 100644 --- a/api/_bridges/oft-sponsored/utils/routing.ts +++ b/api/_sponsorship-routing.ts @@ -1,14 +1,16 @@ -import { getOftSponsoredBridgeStrategy } from "../strategy"; +import { getSponsoredCctpBridgeStrategy } from "./_bridges/cctp-sponsored/strategy"; +import { getOftSponsoredBridgeStrategy } from "./_bridges/oft-sponsored/strategy"; import { BridgeStrategy, BridgeStrategyDataParams, RoutingRule, -} from "../../types"; +} from "./_bridges/types"; import { getSponsorshipEligibilityData, SponsorshipEligibilityData, -} from "./eligibility"; -import { getLogger } from "../../../_utils"; +} from "./_sponsorship-utils"; +import { getLogger } from "./_utils"; +import { Token } from "./_dexes/types"; type SponsorshipRoutingRule = RoutingRule< NonNullable @@ -54,7 +56,14 @@ const SPONSORSHIP_ROUTING_RULES: SponsorshipRoutingRule[] = [ data.hasVaultBalance && data.isSlippageAcceptable && data.isAccountCreationValid, - getStrategy: getOftSponsoredBridgeStrategy, + getStrategy: (inputToken?: Token) => { + if (inputToken?.symbol === "USDT") { + return getOftSponsoredBridgeStrategy(); + } else if (inputToken?.symbol === "USDC") { + return getSponsoredCctpBridgeStrategy(); + } + return null; + }, reason: "All sponsorship eligibility criteria met", }, ]; @@ -95,7 +104,7 @@ export async function routeStrategyForSponsorship( return null; } - const strategy = applicableRule.getStrategy(); + const strategy = applicableRule.getStrategy(params.inputToken); logger.debug({ at: "routeStrategyForSponsorship", diff --git a/api/_sponsorship-utils.ts b/api/_sponsorship-utils.ts new file mode 100644 index 000000000..36099fc54 --- /dev/null +++ b/api/_sponsorship-utils.ts @@ -0,0 +1,82 @@ +import { BigNumber, utils } from "ethers"; + +import { Token } from "./_dexes/types"; + +/** + * Parameters for building a sponsored quote + */ +export type BuildSponsoredQuoteParams = { + inputToken: Token; + outputToken: Token; + inputAmount: BigNumber; + recipient: string; + depositor: string; + refundRecipient: string; + maxBpsToSponsor: BigNumber; + maxUserSlippageBps: number; +}; + +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; +}; + +/** + * 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 +} + +/** + * Default quote expiry time (15 minutes) + */ +export const DEFAULT_QUOTE_EXPIRY_SECONDS = 15 * 60; + +/** + * 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; +} + +/** + * Generates a unique nonce for a quote + * Uses keccak256 hash of timestamp in milliseconds + depositor address + */ +export function generateQuoteNonce(depositor: string): string { + const timestamp = Date.now(); + const encoded = utils.defaultAbiCoder.encode( + ["uint256", "address"], + [timestamp, depositor] + ); + return utils.keccak256(encoded); +} diff --git a/api/_utils.ts b/api/_utils.ts index 1f7cd6fa7..29d1f3a84 100644 --- a/api/_utils.ts +++ b/api/_utils.ts @@ -94,6 +94,7 @@ import { getMulticall3, getMulticall3Address } from "./_multicall"; import { isMessageTooLong } from "./_message"; import { getSvmTokenInfo } from "./_svm-tokens"; import { Span } from "@opentelemetry/api"; +import { getNormalizedSpotTokenSymbol } from "./_hypercore"; export const { Profiler, toAddressType } = sdk.utils; export { @@ -924,9 +925,10 @@ export const getCachedTokenPrice = async (params: { if (symbol) { try { + const resolvedSymbol = getNormalizedSpotTokenSymbol(symbol); const response = await axios(`${baseUrl}`, { params: { - symbol, + symbol: resolvedSymbol, baseCurrency, date: historicalDateISO, }, diff --git a/test/api/_bridges/cctp-sponsored/strategy.test.ts b/test/api/_bridges/cctp-sponsored/strategy.test.ts new file mode 100644 index 000000000..09c7dd03b --- /dev/null +++ b/test/api/_bridges/cctp-sponsored/strategy.test.ts @@ -0,0 +1,751 @@ +import { BigNumber, utils } from "ethers"; +import { + buildEvmTxForAllowanceHolder, + calculateMaxBpsToSponsor, + getQuoteForExactInput, + getQuoteForOutput, + isRouteSupported, +} from "../../../../api/_bridges/cctp-sponsored/strategy"; +import { CHAIN_IDs, TOKEN_SYMBOLS_MAP } from "../../../../api/_constants"; +import { + Token, + CrossSwapQuotes, + CrossSwap, +} from "../../../../api/_dexes/types"; +import * as hypercore from "../../../../api/_hypercore"; +import { ConvertDecimals } from "../../../../api/_utils"; +import { AMOUNT_TYPE } from "../../../../api/_dexes/utils"; +import * as cctpHypercore from "../../../../api/_bridges/cctp/utils/hypercore"; +import { SPONSORED_CCTP_SRC_PERIPHERY_ADDRESSES } from "../../../../api/_bridges/cctp-sponsored/utils/constants"; + +describe("api/_bridges/cctp-sponsored/strategy", () => { + const arbitrumUSDC: Token = { + ...TOKEN_SYMBOLS_MAP.USDC, + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.ARBITRUM_SEPOLIA], + chainId: CHAIN_IDs.ARBITRUM_SEPOLIA, + }; + + const hyperCoreUSDCSpot: Token = { + symbol: "USDC-SPOT", + decimals: 8, + address: + TOKEN_SYMBOLS_MAP["USDC-SPOT"].addresses[CHAIN_IDs.HYPERCORE_TESTNET], + chainId: CHAIN_IDs.HYPERCORE_TESTNET, + }; + + const hyperCoreUSDHSpot: Token = { + ...TOKEN_SYMBOLS_MAP["USDH-SPOT"], + address: + TOKEN_SYMBOLS_MAP["USDH-SPOT"].addresses[CHAIN_IDs.HYPERCORE_TESTNET], + chainId: CHAIN_IDs.HYPERCORE_TESTNET, + }; + + const baseWETH: Token = { + ...TOKEN_SYMBOLS_MAP.WETH, + address: TOKEN_SYMBOLS_MAP.WETH.addresses[CHAIN_IDs.BASE], + chainId: CHAIN_IDs.BASE, + }; + + describe("#isRouteSupported()", () => { + test("should return true for Arbitrum USDC -> HyperCore USDC-SPOT", () => { + expect( + isRouteSupported({ + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDCSpot, + }) + ).toBe(true); + }); + + test("should return true for Arbitrum USDC -> HyperCore USDH-SPOT", () => { + expect( + isRouteSupported({ + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDHSpot, + }) + ).toBe(true); + }); + + test("should return false for Arbitrum USDC -> HyperCore USDT-SPOT", () => { + expect( + isRouteSupported({ + inputToken: arbitrumUSDC, + outputToken: { + ...TOKEN_SYMBOLS_MAP.USDT, + address: TOKEN_SYMBOLS_MAP.USDT.addresses[CHAIN_IDs.BASE], + chainId: CHAIN_IDs.BASE, + }, + }) + ).toBe(false); + }); + }); + + describe("#calculateMaxBpsToSponsor()", () => { + const inputAmount = utils.parseUnits("1", arbitrumUSDC.decimals); + const maxFee = utils.parseUnits("0.0001", arbitrumUSDC.decimals); // 0.01% = 1 bps + + describe("USDC-SPOT output (no swap needed)", () => { + test("should return correct maxFeeBps", async () => { + const result = await calculateMaxBpsToSponsor({ + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDCSpot, + maxFee, + inputAmount, + }); + + // maxFeeBps = 0.0001 / 1 * 10000 = 1 bps + expect(result).toBe(1); + }); + + test("should return correct maxFeeBps for different maxFee amounts", async () => { + const largerMaxFee = utils.parseUnits("0.005", arbitrumUSDC.decimals); // 0.5% = 50 bps + + const result = await calculateMaxBpsToSponsor({ + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDCSpot, + maxFee: largerMaxFee, + inputAmount, + }); + + // maxFeeBps = 0.005 / 1 * 10000 = 50 bps + expect(result).toBe(50); + }); + + test("should return correct maxFeeBps for different input amount", async () => { + const largerInputAmount = utils.parseUnits("10", arbitrumUSDC.decimals); // 10 USDC + + const result = await calculateMaxBpsToSponsor({ + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDCSpot, + maxFee, + inputAmount: largerInputAmount, + }); + + // maxFeeBps = 0.0001 / 10 * 10000 = 0.1 bps + expect(result).toBe(0.1); + }); + }); + + describe("USDH-SPOT output (swap flow)", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("should return maxFeeBps when swap has no loss", async () => { + // Mock simulateMarketOrder to return exactly 1:1 output (no loss) + const bridgeOutputAmountInputTokenDecimals = inputAmount.sub(maxFee); + const bridgeOutputAmountOutputTokenDecimals = ConvertDecimals( + arbitrumUSDC.decimals, + hyperCoreUSDHSpot.decimals + )(bridgeOutputAmountInputTokenDecimals); + + jest.spyOn(hypercore, "simulateMarketOrder").mockResolvedValue({ + outputAmount: bridgeOutputAmountOutputTokenDecimals, + inputAmount: bridgeOutputAmountOutputTokenDecimals, + averageExecutionPrice: "1.0", + slippagePercent: 0, + bestPrice: "1.0", + levelsConsumed: 1, + fullyFilled: true, + }); + + const result = await calculateMaxBpsToSponsor({ + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDHSpot, + maxFee, + inputAmount, + }); + + // maxFeeBps = 0.0001 / 1 * 10000 = 1 bps, slippage = 0, so result = 1 bps + expect(result).toBe(1); + }); + + test("should return maxFeeBps when swap has profit", async () => { + const bridgeOutputAmountInputTokenDecimals = inputAmount.sub(maxFee); + const bridgeOutputAmountOutputTokenDecimals = ConvertDecimals( + arbitrumUSDC.decimals, + hyperCoreUSDHSpot.decimals + )(bridgeOutputAmountInputTokenDecimals); + + // Mock simulateMarketOrder to return more than expected (profit scenario) + jest.spyOn(hypercore, "simulateMarketOrder").mockResolvedValue({ + outputAmount: utils.parseUnits("1.1", hyperCoreUSDHSpot.decimals), // 1.1 USDH (8 decimals) + inputAmount: bridgeOutputAmountOutputTokenDecimals, + averageExecutionPrice: "1.1", + slippagePercent: -1, + bestPrice: "1.1", + levelsConsumed: 1, + fullyFilled: true, + }); + + const result = await calculateMaxBpsToSponsor({ + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDHSpot, + maxFee, + inputAmount, + }); + + // maxFeeBps = 0.0001 / 1 * 10000 = 1 bps, slippage is negative (profit), so result = 1 bps + expect(result).toBe(1); + }); + + test("should calculate correct bps when swap has 1% loss", async () => { + const bridgeOutputAmountInputTokenDecimals = inputAmount.sub(maxFee); + const bridgeOutputAmountOutputTokenDecimals = ConvertDecimals( + arbitrumUSDC.decimals, + hyperCoreUSDHSpot.decimals + )(bridgeOutputAmountInputTokenDecimals); + + // Mock simulateMarketOrder to return 0.99 output (1% loss) + jest.spyOn(hypercore, "simulateMarketOrder").mockResolvedValue({ + outputAmount: utils.parseUnits("0.98901", hyperCoreUSDHSpot.decimals), // input amount - maxFee - 1% loss + inputAmount: bridgeOutputAmountOutputTokenDecimals, + averageExecutionPrice: "0.99", + slippagePercent: 1, + bestPrice: "0.99", + levelsConsumed: 1, + fullyFilled: true, + }); + + const result = await calculateMaxBpsToSponsor({ + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDHSpot, + maxFee, + inputAmount, + }); + + // maxFeeBps = 0.0001 / 1 * 10000 = 1 bps, slippage = 1% = 100 bps, so result = 1 + 100 = 101 bps + expect(result).toBe(101); + }); + + test("should calculate correct bps when swap has 0.5% loss", async () => { + const bridgeOutputAmountInputTokenDecimals = inputAmount.sub(maxFee); + const bridgeOutputAmountOutputTokenDecimals = ConvertDecimals( + arbitrumUSDC.decimals, + hyperCoreUSDHSpot.decimals + )(bridgeOutputAmountInputTokenDecimals); + + // Mock simulateMarketOrder to return 0.995 output (0.5% loss) + jest.spyOn(hypercore, "simulateMarketOrder").mockResolvedValue({ + outputAmount: utils.parseUnits( + "0.994005", + hyperCoreUSDHSpot.decimals + ), // input amount - maxFee - 0.5% loss + inputAmount: bridgeOutputAmountOutputTokenDecimals, + averageExecutionPrice: "0.995", + slippagePercent: 0.5, + bestPrice: "0.995", + levelsConsumed: 1, + fullyFilled: true, + }); + + const result = await calculateMaxBpsToSponsor({ + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDHSpot, + maxFee, + inputAmount, + }); + + // maxFeeBps = 0.0001 / 1 * 10000 = 1 bps, slippage = 0.5% = 50 bps, so result = 1 + 50 = 51 bps + expect(result).toBe(51); + }); + + test("should handle fractional slippage correctly", async () => { + const bridgeOutputAmountInputTokenDecimals = inputAmount.sub(maxFee); + const bridgeOutputAmountOutputTokenDecimals = ConvertDecimals( + arbitrumUSDC.decimals, + hyperCoreUSDHSpot.decimals + )(bridgeOutputAmountInputTokenDecimals); + + // Mock simulateMarketOrder to return 0.01% loss + jest.spyOn(hypercore, "simulateMarketOrder").mockResolvedValue({ + outputAmount: utils.parseUnits( + "0.9989001", + hyperCoreUSDHSpot.decimals + ), // input amount - maxFee - 0.01% loss + inputAmount: bridgeOutputAmountOutputTokenDecimals, + averageExecutionPrice: "0.9999", + slippagePercent: 0.01, + bestPrice: "0.9999", + levelsConsumed: 1, + fullyFilled: true, + }); + + const result = await calculateMaxBpsToSponsor({ + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDHSpot, + maxFee, + inputAmount, + }); + + // maxFeeBps = 0.0001 / 1 * 10000 = 1 bps, slippage = 0.01% = 1 bps, so result = 1 + 1 = 2 bps + expect(result).toBe(2); + }); + + test("should handle zero slippage correctly", async () => { + const bridgeOutputAmountInputTokenDecimals = inputAmount.sub(maxFee); + const bridgeOutputAmountOutputTokenDecimals = ConvertDecimals( + arbitrumUSDC.decimals, + hyperCoreUSDHSpot.decimals + )(bridgeOutputAmountInputTokenDecimals); + + jest.spyOn(hypercore, "simulateMarketOrder").mockResolvedValue({ + outputAmount: bridgeOutputAmountOutputTokenDecimals, + inputAmount: bridgeOutputAmountOutputTokenDecimals, + averageExecutionPrice: "1.0", + slippagePercent: 0, + bestPrice: "1.0", + levelsConsumed: 1, + fullyFilled: true, + }); + + const result = await calculateMaxBpsToSponsor({ + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDHSpot, + maxFee, + inputAmount, + }); + + // maxFeeBps = 0.0001 / 1 * 10000 = 1 bps, slippage = 0, so result = 1 bps + expect(result).toBe(1); + }); + }); + + describe("Unsupported route", () => { + test("should throw error for unsupported route", async () => { + const unsupportedToken: Token = { + address: "0x1234567890123456789012345678901234567890", + symbol: "WETH", + decimals: 18, + chainId: CHAIN_IDs.ARBITRUM, + }; + + await expect( + calculateMaxBpsToSponsor({ + inputToken: arbitrumUSDC, + outputToken: unsupportedToken, + maxFee, + inputAmount, + }) + ).rejects.toThrow(); + }); + }); + }); + + describe("#getQuoteForExactInput()", () => { + test("should return correct bridge quote with converted decimals", async () => { + const exactInputAmount = utils.parseUnits("1", arbitrumUSDC.decimals); + + const result = await getQuoteForExactInput({ + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDCSpot, + exactInputAmount, + }); + + expect(result.bridgeQuote.inputToken).toEqual(arbitrumUSDC); + expect(result.bridgeQuote.outputToken).toEqual(hyperCoreUSDCSpot); + expect(result.bridgeQuote.inputAmount).toEqual(exactInputAmount); + // Output should be converted from 6 decimals to 8 decimals + expect(result.bridgeQuote.outputAmount.toString()).toBe("100000000"); // 1 USDC in 8 decimals + expect(result.bridgeQuote.minOutputAmount).toEqual( + result.bridgeQuote.outputAmount + ); + expect(result.bridgeQuote.provider).toBe("sponsored-cctp"); + expect(result.bridgeQuote.estimatedFillTimeSec).toBeGreaterThan(0); + }); + + test("should throw error for unsupported route", async () => { + await expect( + getQuoteForExactInput({ + inputToken: arbitrumUSDC, + outputToken: baseWETH, + exactInputAmount: utils.parseUnits("1", arbitrumUSDC.decimals), + }) + ).rejects.toThrow(); + }); + }); + + describe("#getQuoteForOutput()", () => { + test("should return correct bridge quote with converted decimals", async () => { + const minOutputAmount = utils.parseUnits("1", hyperCoreUSDCSpot.decimals); + + const result = await getQuoteForOutput({ + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDCSpot, + minOutputAmount, + }); + + expect(result.bridgeQuote.inputToken).toEqual(arbitrumUSDC); + expect(result.bridgeQuote.outputToken).toEqual(hyperCoreUSDCSpot); + // Input should be converted from 8 decimals to 6 decimals + expect(result.bridgeQuote.inputAmount.toString()).toBe("1000000"); // 1 USDC in 6 decimals + expect(result.bridgeQuote.outputAmount).toEqual(minOutputAmount); + expect(result.bridgeQuote.minOutputAmount).toEqual(minOutputAmount); + expect(result.bridgeQuote.provider).toBe("sponsored-cctp"); + expect(result.bridgeQuote.estimatedFillTimeSec).toBeGreaterThan(0); + }); + + test("should throw error for unsupported route", async () => { + await expect( + getQuoteForOutput({ + inputToken: arbitrumUSDC, + outputToken: baseWETH, + minOutputAmount: utils.parseUnits("1", baseWETH.decimals), + }) + ).rejects.toThrow(); + }); + }); + + describe("#buildEvmTxForAllowanceHolder()", () => { + const depositor = "0x0000000000000000000000000000000000000001"; + const recipient = "0x0000000000000000000000000000000000000002"; + const inputAmount = utils.parseUnits("1", arbitrumUSDC.decimals); + const outputAmount = utils.parseUnits("1", hyperCoreUSDCSpot.decimals); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("should build transaction correctly for USDC-SPOT output", async () => { + const crossSwap: CrossSwap = { + amount: inputAmount, + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDCSpot, + depositor, + recipient, + slippageTolerance: 1, + type: AMOUNT_TYPE.EXACT_INPUT, + refundOnOrigin: false, + embeddedActions: [], + strictTradeType: false, + }; + + const quotes: CrossSwapQuotes = { + crossSwap, + bridgeQuote: { + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDCSpot, + inputAmount, + outputAmount, + minOutputAmount: outputAmount, + estimatedFillTimeSec: 300, + provider: "sponsored-cctp", + fees: { + totalRelay: { + total: BigNumber.from(0), + pct: BigNumber.from(0), + token: arbitrumUSDC, + }, + relayerCapital: { + total: BigNumber.from(0), + pct: BigNumber.from(0), + token: arbitrumUSDC, + }, + relayerGas: { + total: BigNumber.from(0), + pct: BigNumber.from(0), + token: arbitrumUSDC, + }, + lp: { + total: BigNumber.from(0), + pct: BigNumber.from(0), + token: arbitrumUSDC, + }, + bridgeFee: { + total: BigNumber.from(0), + pct: BigNumber.from(0), + token: arbitrumUSDC, + }, + }, + }, + contracts: { + depositEntryPoint: { + address: "0x0000000000000000000000000000000000000000", + name: "SpokePoolPeriphery", + }, + }, + }; + + // Mock getCctpFees + jest.spyOn(cctpHypercore, "getCctpFees").mockResolvedValue({ + transferFeeBps: 10, + forwardFee: BigNumber.from( + utils.parseUnits("0.1", arbitrumUSDC.decimals) + ), + }); + + const result = await buildEvmTxForAllowanceHolder({ + quotes, + }); + + expect(result.chainId).toBe(arbitrumUSDC.chainId); + expect(result.from).toBe(depositor); + expect(result.to).toBe( + SPONSORED_CCTP_SRC_PERIPHERY_ADDRESSES[CHAIN_IDs.ARBITRUM_SEPOLIA] + ); + expect(result.value).toEqual(BigNumber.from(0)); + expect(result.ecosystem).toBe("evm"); + expect(result.data).toBeTruthy(); + expect(typeof result.data).toBe("string"); + }); + + test("should build transaction correctly for USDH-SPOT output (with swap)", async () => { + const crossSwap: CrossSwap = { + amount: inputAmount, + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDHSpot, + depositor, + recipient, + slippageTolerance: 1, + type: AMOUNT_TYPE.EXACT_INPUT, + refundOnOrigin: false, + embeddedActions: [], + strictTradeType: false, + }; + + const bridgeOutputAmount = ConvertDecimals( + arbitrumUSDC.decimals, + hyperCoreUSDHSpot.decimals + )(inputAmount); + + const quotes: CrossSwapQuotes = { + crossSwap, + bridgeQuote: { + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDHSpot, + inputAmount, + outputAmount: bridgeOutputAmount, + minOutputAmount: bridgeOutputAmount, + estimatedFillTimeSec: 300, + provider: "sponsored-cctp", + fees: { + totalRelay: { + total: BigNumber.from(0), + pct: BigNumber.from(0), + token: arbitrumUSDC, + }, + relayerCapital: { + total: BigNumber.from(0), + pct: BigNumber.from(0), + token: arbitrumUSDC, + }, + relayerGas: { + total: BigNumber.from(0), + pct: BigNumber.from(0), + token: arbitrumUSDC, + }, + lp: { + total: BigNumber.from(0), + pct: BigNumber.from(0), + token: arbitrumUSDC, + }, + bridgeFee: { + total: BigNumber.from(0), + pct: BigNumber.from(0), + token: arbitrumUSDC, + }, + }, + }, + contracts: { + depositEntryPoint: { + address: "0x0000000000000000000000000000000000000000", + name: "SpokePoolPeriphery", + }, + }, + }; + + // Mock getCctpFees + jest.spyOn(cctpHypercore, "getCctpFees").mockResolvedValue({ + transferFeeBps: 10, + forwardFee: BigNumber.from( + utils.parseUnits("0.1", arbitrumUSDC.decimals) + ), + }); + + // Mock simulateMarketOrder (called inside calculateMaxBpsToSponsor) + // Calculate maxFee the same way buildEvmTxForAllowanceHolder does + const transferFee = inputAmount.mul(10).div(10_000); + const forwardFee = BigNumber.from( + utils.parseUnits("0.1", arbitrumUSDC.decimals) + ); + const maxFee = transferFee.add(forwardFee); + const bridgeOutputAmountInputTokenDecimals = inputAmount.sub(maxFee); + const bridgeOutputAmountOutputTokenDecimals = ConvertDecimals( + arbitrumUSDC.decimals, + hyperCoreUSDHSpot.decimals + )(bridgeOutputAmountInputTokenDecimals); + + jest.spyOn(hypercore, "simulateMarketOrder").mockResolvedValue({ + outputAmount: bridgeOutputAmountOutputTokenDecimals, + inputAmount: bridgeOutputAmountOutputTokenDecimals, + averageExecutionPrice: "1.0", + slippagePercent: 0, + bestPrice: "1.0", + levelsConsumed: 1, + fullyFilled: true, + }); + + const result = await buildEvmTxForAllowanceHolder({ + quotes, + }); + + expect(result.chainId).toBe(arbitrumUSDC.chainId); + expect(result.from).toBe(depositor); + expect(result.to).toBe( + SPONSORED_CCTP_SRC_PERIPHERY_ADDRESSES[CHAIN_IDs.ARBITRUM_SEPOLIA] + ); + expect(result.value).toEqual(BigNumber.from(0)); + expect(result.ecosystem).toBe("evm"); + expect(result.data).toBeTruthy(); + }); + + test("should throw error when app fee is provided", async () => { + const crossSwap: CrossSwap = { + amount: inputAmount, + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDCSpot, + depositor, + recipient, + slippageTolerance: 1, + type: AMOUNT_TYPE.EXACT_INPUT, + refundOnOrigin: false, + embeddedActions: [], + strictTradeType: false, + }; + + const quotes: CrossSwapQuotes = { + crossSwap, + bridgeQuote: { + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDCSpot, + inputAmount, + outputAmount, + minOutputAmount: outputAmount, + estimatedFillTimeSec: 300, + provider: "sponsored-cctp", + fees: { + totalRelay: { + total: BigNumber.from(0), + pct: BigNumber.from(0), + token: arbitrumUSDC, + }, + relayerCapital: { + total: BigNumber.from(0), + pct: BigNumber.from(0), + token: arbitrumUSDC, + }, + relayerGas: { + total: BigNumber.from(0), + pct: BigNumber.from(0), + token: arbitrumUSDC, + }, + lp: { + total: BigNumber.from(0), + pct: BigNumber.from(0), + token: arbitrumUSDC, + }, + bridgeFee: { + total: BigNumber.from(0), + pct: BigNumber.from(0), + token: arbitrumUSDC, + }, + }, + }, + contracts: { + depositEntryPoint: { + address: "0x0000000000000000000000000000000000000000", + name: "SpokePoolPeriphery", + }, + }, + appFee: { + feeAmount: BigNumber.from(1), + feeToken: arbitrumUSDC, + feeActions: [], + }, + }; + + jest.spyOn(cctpHypercore, "getCctpFees").mockResolvedValue({ + transferFeeBps: 10, + forwardFee: BigNumber.from( + utils.parseUnits("0.1", arbitrumUSDC.decimals) + ), + }); + + await expect(buildEvmTxForAllowanceHolder({ quotes })).rejects.toThrow( + "App fee is not supported" + ); + }); + + test("should throw error when origin swap quote is provided", async () => { + const crossSwap: CrossSwap = { + amount: inputAmount, + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDCSpot, + depositor, + recipient, + slippageTolerance: 1, + type: AMOUNT_TYPE.EXACT_INPUT, + refundOnOrigin: false, + embeddedActions: [], + strictTradeType: false, + }; + + const quotes: CrossSwapQuotes = { + crossSwap, + bridgeQuote: { + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDCSpot, + inputAmount, + outputAmount, + minOutputAmount: outputAmount, + estimatedFillTimeSec: 300, + provider: "sponsored-cctp", + fees: { + totalRelay: { + total: BigNumber.from(0), + pct: BigNumber.from(0), + token: arbitrumUSDC, + }, + relayerCapital: { + total: BigNumber.from(0), + pct: BigNumber.from(0), + token: arbitrumUSDC, + }, + relayerGas: { + total: BigNumber.from(0), + pct: BigNumber.from(0), + token: arbitrumUSDC, + }, + lp: { + total: BigNumber.from(0), + pct: BigNumber.from(0), + token: arbitrumUSDC, + }, + bridgeFee: { + total: BigNumber.from(0), + pct: BigNumber.from(0), + token: arbitrumUSDC, + }, + }, + }, + contracts: { + depositEntryPoint: { + address: "0x0000000000000000000000000000000000000000", + name: "SpokePoolPeriphery", + }, + }, + originSwapQuote: {} as any, + }; + + jest.spyOn(cctpHypercore, "getCctpFees").mockResolvedValue({ + transferFeeBps: 10, + forwardFee: BigNumber.from( + utils.parseUnits("0.1", arbitrumUSDC.decimals) + ), + }); + + await expect(buildEvmTxForAllowanceHolder({ quotes })).rejects.toThrow( + "Origin/destination swaps are not supported" + ); + }); + }); +}); From 14f2588505dddd8fbab9d458f44343f860200ff4 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Mon, 3 Nov 2025 13:26:21 +0100 Subject: [PATCH 2/8] fix: remove notion of USDC-SPOT --- api/_bridges/cctp-sponsored/strategy.ts | 2 +- .../cctp-sponsored/utils/constants.ts | 2 +- api/_bridges/oft-sponsored/strategy.ts | 22 +++++---- api/_bridges/oft-sponsored/utils/constants.ts | 2 +- api/_constants.ts | 19 +++----- api/_hypercore.ts | 4 +- .../_bridges/cctp-sponsored/strategy.test.ts | 46 +++++++++---------- test/api/_bridges/cctp/strategy.test.ts | 6 +-- .../_bridges/oft-sponsored/strategy.test.ts | 12 ++--- 9 files changed, 55 insertions(+), 60 deletions(-) diff --git a/api/_bridges/cctp-sponsored/strategy.ts b/api/_bridges/cctp-sponsored/strategy.ts index b113b848c..ca499ebcf 100644 --- a/api/_bridges/cctp-sponsored/strategy.ts +++ b/api/_bridges/cctp-sponsored/strategy.ts @@ -262,7 +262,7 @@ export async function calculateMaxBpsToSponsor(params: { let maxBpsToSponsor = maxFeeBps; // Simple transfer flow: no swap needed, therefore `maxBpsToSponsor` is `maxFee` in bps - if (outputToken.symbol === "USDC-SPOT") { + if (outputToken.symbol === "USDC") { maxBpsToSponsor = maxFeeBps; } // Swap flow: `maxBpsToSponsor` is `maxFee` + est. swap slippage if slippage is positive diff --git a/api/_bridges/cctp-sponsored/utils/constants.ts b/api/_bridges/cctp-sponsored/utils/constants.ts index 584a5317a..f28ae3d13 100644 --- a/api/_bridges/cctp-sponsored/utils/constants.ts +++ b/api/_bridges/cctp-sponsored/utils/constants.ts @@ -20,7 +20,7 @@ export const SPONSORED_CCTP_ORIGIN_CHAINS = CCTP_SUPPORTED_CHAINS.filter( export const SPONSORED_CCTP_INPUT_TOKENS = ["USDC"]; -export const SPONSORED_CCTP_OUTPUT_TOKENS = ["USDC-SPOT", "USDH-SPOT"]; +export const SPONSORED_CCTP_OUTPUT_TOKENS = ["USDC", "USDH-SPOT"]; export const SPONSORED_CCTP_DESTINATION_CHAINS = [ CHAIN_IDs.HYPERCORE, diff --git a/api/_bridges/oft-sponsored/strategy.ts b/api/_bridges/oft-sponsored/strategy.ts index d334ca3be..e0fb273c2 100644 --- a/api/_bridges/oft-sponsored/strategy.ts +++ b/api/_bridges/oft-sponsored/strategy.ts @@ -21,7 +21,7 @@ import { getFallbackRecipient, } from "../../_dexes/utils"; import { InvalidParamError } from "../../_errors"; -import { simulateMarketOrder } from "../../_hypercore"; +import { simulateMarketOrder, SPOT_TOKEN_DECIMALS } from "../../_hypercore"; import { tagIntegratorId, tagSwapApiMarker } from "../../_integrator-id"; import { ConvertDecimals, getCachedTokenInfo } from "../../_utils"; import { getNativeTokenInfo } from "../../swap/_utils"; @@ -240,7 +240,7 @@ export async function getSponsoredOftQuoteForOutput( /** * 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 outputTokenSymbol - The symbol of the output token (e.g., "USDT-SPOT", "USDC") * @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 @@ -257,20 +257,23 @@ export async function calculateMaxBpsToSponsor(params: { return BigNumber.from(0); } - if (outputTokenSymbol === "USDC-SPOT") { + if (outputTokenSymbol === "USDC") { // 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, + decimals: SPOT_TOKEN_DECIMALS, // Spot token decimals always 8 }, tokenOut: { symbol: "USDC", - decimals: TOKEN_SYMBOLS_MAP["USDT-SPOT"].decimals, // TODO: Update to use USDC-SPOT when available + decimals: SPOT_TOKEN_DECIMALS, // Spot token decimals always 8 }, - inputAmount: bridgeOutputAmount, + inputAmount: ConvertDecimals( + TOKEN_SYMBOLS_MAP.USDT.decimals, + SPOT_TOKEN_DECIMALS + )(bridgeOutputAmount), // Convert USDT to USDT-SPOT, as `bridgeOutputAmount` is in USDT decimals }); // Expected output (1:1): same amount as initial input after decimal conversion @@ -278,7 +281,7 @@ export async function calculateMaxBpsToSponsor(params: { const swapOutput = simulation.outputAmount; const swapOutputInInputDecimals = ConvertDecimals( - TOKEN_SYMBOLS_MAP["USDT-SPOT"].decimals, + SPOT_TOKEN_DECIMALS, TOKEN_SYMBOLS_MAP.USDT.decimals )(swapOutput); @@ -376,11 +379,10 @@ export function getOftSponsoredBridgeStrategy(): BridgeStrategy { }, getCrossSwapTypes: ({ inputToken, outputToken }) => { - // Routes supported: USDT → USDT-SPOT or USDC-SPOT + // Routes supported: USDT → USDT-SPOT or USDC if ( inputToken.symbol === "USDT" && - (outputToken.symbol === "USDT-SPOT" || - outputToken.symbol === "USDC-SPOT") + (outputToken.symbol === "USDT-SPOT" || outputToken.symbol === "USDC") ) { return [CROSS_SWAP_TYPE.BRIDGEABLE_TO_BRIDGEABLE]; } diff --git a/api/_bridges/oft-sponsored/utils/constants.ts b/api/_bridges/oft-sponsored/utils/constants.ts index 3dc9794ec..4f396f0d6 100644 --- a/api/_bridges/oft-sponsored/utils/constants.ts +++ b/api/_bridges/oft-sponsored/utils/constants.ts @@ -34,7 +34,7 @@ export const SPONSORED_OFT_INPUT_TOKENS = ["USDT"]; /** * Supported output tokens for sponsored OFT flows */ -export const SPONSORED_OFT_OUTPUT_TOKENS = ["USDT-SPOT", "USDC-SPOT"]; +export const SPONSORED_OFT_OUTPUT_TOKENS = ["USDT-SPOT", "USDC"]; /** * Supported destination chains for sponsored OFT flows diff --git a/api/_constants.ts b/api/_constants.ts index 7f64bf783..a79d63c72 100644 --- a/api/_constants.ts +++ b/api/_constants.ts @@ -40,16 +40,13 @@ export const TOKEN_SYMBOLS_MAP = { }, coingeckoId: "usdh-2", }, - "USDC-SPOT": { - name: "USDC-SPOT", - symbol: "USDC-SPOT", - decimals: 8, - addresses: { - [CHAIN_IDs.HYPERCORE]: "0x2000000000000000000000000000000000000000", - [CHAIN_IDs.HYPERCORE_TESTNET]: - "0x2000000000000000000000000000000000000000", - }, - coingeckoId: "usd-coin", +}; +TOKEN_SYMBOLS_MAP.USDC = { + ...TOKEN_SYMBOLS_MAP.USDC, + addresses: { + ...TOKEN_SYMBOLS_MAP.USDC.addresses, + [CHAIN_IDs.HYPERCORE]: "0x2000000000000000000000000000000000000000", + [CHAIN_IDs.HYPERCORE_TESTNET]: "0x2000000000000000000000000000000000000000", }, }; TOKEN_SYMBOLS_MAP.WHYPE = { @@ -364,8 +361,6 @@ export const CG_CONTRACTS_DEFERRED_TO_ID = new Set([ TOKEN_SYMBOLS_MAP["USDT-SPOT"].addresses[CHAIN_IDs.HYPERCORE], TOKEN_SYMBOLS_MAP.XPL.addresses[CHAIN_IDs.PLASMA], TOKEN_SYMBOLS_MAP.XPL.addresses[CHAIN_IDs.PLASMA_TESTNET], - TOKEN_SYMBOLS_MAP["USDC-SPOT"].addresses[CHAIN_IDs.HYPERCORE], - TOKEN_SYMBOLS_MAP["USDC-SPOT"].addresses[CHAIN_IDs.HYPERCORE_TESTNET], ]); // 1:1 because we don't need to handle underlying tokens on FE diff --git a/api/_hypercore.ts b/api/_hypercore.ts index a2e523085..dd4097a1b 100644 --- a/api/_hypercore.ts +++ b/api/_hypercore.ts @@ -7,6 +7,8 @@ import { CHAIN_IDs } from "./_constants"; const HYPERLIQUID_API_BASE_URL = "https://api.hyperliquid.xyz"; const HYPERLIQUID_API_BASE_URL_TESTNET = "https://api.hyperliquid-testnet.xyz"; +export const SPOT_TOKEN_DECIMALS = 8; + // 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 @@ -403,7 +405,7 @@ export async function simulateMarketOrder(params: { } export function getNormalizedSpotTokenSymbol(symbol: string): string { - return ["USDC-SPOT", "USDT-SPOT", "USDH-SPOT"].includes(symbol.toUpperCase()) + return ["USDT-SPOT", "USDH-SPOT"].includes(symbol.toUpperCase()) ? symbol.toUpperCase().replace("-SPOT", "") : symbol.toUpperCase(); } diff --git a/test/api/_bridges/cctp-sponsored/strategy.test.ts b/test/api/_bridges/cctp-sponsored/strategy.test.ts index 09c7dd03b..d73b38e5a 100644 --- a/test/api/_bridges/cctp-sponsored/strategy.test.ts +++ b/test/api/_bridges/cctp-sponsored/strategy.test.ts @@ -25,11 +25,9 @@ describe("api/_bridges/cctp-sponsored/strategy", () => { chainId: CHAIN_IDs.ARBITRUM_SEPOLIA, }; - const hyperCoreUSDCSpot: Token = { - symbol: "USDC-SPOT", - decimals: 8, - address: - TOKEN_SYMBOLS_MAP["USDC-SPOT"].addresses[CHAIN_IDs.HYPERCORE_TESTNET], + const hyperCoreUSDC: Token = { + ...TOKEN_SYMBOLS_MAP["USDC"], + address: TOKEN_SYMBOLS_MAP["USDC"].addresses[CHAIN_IDs.HYPERCORE_TESTNET], chainId: CHAIN_IDs.HYPERCORE_TESTNET, }; @@ -47,11 +45,11 @@ describe("api/_bridges/cctp-sponsored/strategy", () => { }; describe("#isRouteSupported()", () => { - test("should return true for Arbitrum USDC -> HyperCore USDC-SPOT", () => { + test("should return true for Arbitrum USDC -> HyperCore USDC", () => { expect( isRouteSupported({ inputToken: arbitrumUSDC, - outputToken: hyperCoreUSDCSpot, + outputToken: hyperCoreUSDC, }) ).toBe(true); }); @@ -83,11 +81,11 @@ describe("api/_bridges/cctp-sponsored/strategy", () => { const inputAmount = utils.parseUnits("1", arbitrumUSDC.decimals); const maxFee = utils.parseUnits("0.0001", arbitrumUSDC.decimals); // 0.01% = 1 bps - describe("USDC-SPOT output (no swap needed)", () => { + describe("USDC output (no swap needed)", () => { test("should return correct maxFeeBps", async () => { const result = await calculateMaxBpsToSponsor({ inputToken: arbitrumUSDC, - outputToken: hyperCoreUSDCSpot, + outputToken: hyperCoreUSDC, maxFee, inputAmount, }); @@ -101,7 +99,7 @@ describe("api/_bridges/cctp-sponsored/strategy", () => { const result = await calculateMaxBpsToSponsor({ inputToken: arbitrumUSDC, - outputToken: hyperCoreUSDCSpot, + outputToken: hyperCoreUSDC, maxFee: largerMaxFee, inputAmount, }); @@ -115,7 +113,7 @@ describe("api/_bridges/cctp-sponsored/strategy", () => { const result = await calculateMaxBpsToSponsor({ inputToken: arbitrumUSDC, - outputToken: hyperCoreUSDCSpot, + outputToken: hyperCoreUSDC, maxFee, inputAmount: largerInputAmount, }); @@ -337,12 +335,12 @@ describe("api/_bridges/cctp-sponsored/strategy", () => { const result = await getQuoteForExactInput({ inputToken: arbitrumUSDC, - outputToken: hyperCoreUSDCSpot, + outputToken: hyperCoreUSDC, exactInputAmount, }); expect(result.bridgeQuote.inputToken).toEqual(arbitrumUSDC); - expect(result.bridgeQuote.outputToken).toEqual(hyperCoreUSDCSpot); + expect(result.bridgeQuote.outputToken).toEqual(hyperCoreUSDC); expect(result.bridgeQuote.inputAmount).toEqual(exactInputAmount); // Output should be converted from 6 decimals to 8 decimals expect(result.bridgeQuote.outputAmount.toString()).toBe("100000000"); // 1 USDC in 8 decimals @@ -366,16 +364,16 @@ describe("api/_bridges/cctp-sponsored/strategy", () => { describe("#getQuoteForOutput()", () => { test("should return correct bridge quote with converted decimals", async () => { - const minOutputAmount = utils.parseUnits("1", hyperCoreUSDCSpot.decimals); + const minOutputAmount = utils.parseUnits("1", hyperCoreUSDC.decimals); const result = await getQuoteForOutput({ inputToken: arbitrumUSDC, - outputToken: hyperCoreUSDCSpot, + outputToken: hyperCoreUSDC, minOutputAmount, }); expect(result.bridgeQuote.inputToken).toEqual(arbitrumUSDC); - expect(result.bridgeQuote.outputToken).toEqual(hyperCoreUSDCSpot); + expect(result.bridgeQuote.outputToken).toEqual(hyperCoreUSDC); // Input should be converted from 8 decimals to 6 decimals expect(result.bridgeQuote.inputAmount.toString()).toBe("1000000"); // 1 USDC in 6 decimals expect(result.bridgeQuote.outputAmount).toEqual(minOutputAmount); @@ -399,17 +397,17 @@ describe("api/_bridges/cctp-sponsored/strategy", () => { const depositor = "0x0000000000000000000000000000000000000001"; const recipient = "0x0000000000000000000000000000000000000002"; const inputAmount = utils.parseUnits("1", arbitrumUSDC.decimals); - const outputAmount = utils.parseUnits("1", hyperCoreUSDCSpot.decimals); + const outputAmount = utils.parseUnits("1", hyperCoreUSDC.decimals); beforeEach(() => { jest.clearAllMocks(); }); - test("should build transaction correctly for USDC-SPOT output", async () => { + test("should build transaction correctly for USDC output", async () => { const crossSwap: CrossSwap = { amount: inputAmount, inputToken: arbitrumUSDC, - outputToken: hyperCoreUSDCSpot, + outputToken: hyperCoreUSDC, depositor, recipient, slippageTolerance: 1, @@ -423,7 +421,7 @@ describe("api/_bridges/cctp-sponsored/strategy", () => { crossSwap, bridgeQuote: { inputToken: arbitrumUSDC, - outputToken: hyperCoreUSDCSpot, + outputToken: hyperCoreUSDC, inputAmount, outputAmount, minOutputAmount: outputAmount, @@ -602,7 +600,7 @@ describe("api/_bridges/cctp-sponsored/strategy", () => { const crossSwap: CrossSwap = { amount: inputAmount, inputToken: arbitrumUSDC, - outputToken: hyperCoreUSDCSpot, + outputToken: hyperCoreUSDC, depositor, recipient, slippageTolerance: 1, @@ -616,7 +614,7 @@ describe("api/_bridges/cctp-sponsored/strategy", () => { crossSwap, bridgeQuote: { inputToken: arbitrumUSDC, - outputToken: hyperCoreUSDCSpot, + outputToken: hyperCoreUSDC, inputAmount, outputAmount, minOutputAmount: outputAmount, @@ -679,7 +677,7 @@ describe("api/_bridges/cctp-sponsored/strategy", () => { const crossSwap: CrossSwap = { amount: inputAmount, inputToken: arbitrumUSDC, - outputToken: hyperCoreUSDCSpot, + outputToken: hyperCoreUSDC, depositor, recipient, slippageTolerance: 1, @@ -693,7 +691,7 @@ describe("api/_bridges/cctp-sponsored/strategy", () => { crossSwap, bridgeQuote: { inputToken: arbitrumUSDC, - outputToken: hyperCoreUSDCSpot, + outputToken: hyperCoreUSDC, inputAmount, outputAmount, minOutputAmount: outputAmount, diff --git a/test/api/_bridges/cctp/strategy.test.ts b/test/api/_bridges/cctp/strategy.test.ts index 9d8b59f5b..6b90e4cb3 100644 --- a/test/api/_bridges/cctp/strategy.test.ts +++ b/test/api/_bridges/cctp/strategy.test.ts @@ -64,17 +64,15 @@ describe("bridges/cctp/strategy", () => { }; const outputTokenHyperCore = { - ...TOKEN_SYMBOLS_MAP.USDC, - address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.HYPERCORE], + ...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 diff --git a/test/api/_bridges/oft-sponsored/strategy.test.ts b/test/api/_bridges/oft-sponsored/strategy.test.ts index 62b7fc902..6b2eca68c 100644 --- a/test/api/_bridges/oft-sponsored/strategy.test.ts +++ b/test/api/_bridges/oft-sponsored/strategy.test.ts @@ -192,7 +192,7 @@ describe("Sponsored OFT Strategy", () => { }); }); - describe("USDC-SPOT output", () => { + describe("USDC output", () => { beforeEach(() => { jest.clearAllMocks(); }); @@ -210,7 +210,7 @@ describe("Sponsored OFT Strategy", () => { }); const result = await calculateMaxBpsToSponsor({ - outputTokenSymbol: "USDC-SPOT", + outputTokenSymbol: "USDC", bridgeInputAmount, bridgeOutputAmount, }); @@ -231,7 +231,7 @@ describe("Sponsored OFT Strategy", () => { }); const result = await calculateMaxBpsToSponsor({ - outputTokenSymbol: "USDC-SPOT", + outputTokenSymbol: "USDC", bridgeInputAmount, bridgeOutputAmount, }); @@ -252,7 +252,7 @@ describe("Sponsored OFT Strategy", () => { }); const result = await calculateMaxBpsToSponsor({ - outputTokenSymbol: "USDC-SPOT", + outputTokenSymbol: "USDC", bridgeInputAmount, bridgeOutputAmount, }); @@ -274,7 +274,7 @@ describe("Sponsored OFT Strategy", () => { }); const result = await calculateMaxBpsToSponsor({ - outputTokenSymbol: "USDC-SPOT", + outputTokenSymbol: "USDC", bridgeInputAmount, bridgeOutputAmount, }); @@ -296,7 +296,7 @@ describe("Sponsored OFT Strategy", () => { }); const result = await calculateMaxBpsToSponsor({ - outputTokenSymbol: "USDC-SPOT", + outputTokenSymbol: "USDC", bridgeInputAmount, bridgeOutputAmount, }); From bff473972fd3a5f931ca2775fd43168e82512fc1 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Mon, 3 Nov 2025 13:51:19 +0100 Subject: [PATCH 3/8] requested changes --- api/_bridges/cctp-sponsored/strategy.ts | 4 ++-- api/_bridges/cctp-sponsored/utils/constants.ts | 14 +++++++++++++- api/_bridges/cctp-sponsored/utils/quote-builder.ts | 9 ++++++--- test/api/_bridges/cctp-sponsored/strategy.test.ts | 4 ++-- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/api/_bridges/cctp-sponsored/strategy.ts b/api/_bridges/cctp-sponsored/strategy.ts index ca499ebcf..8834826eb 100644 --- a/api/_bridges/cctp-sponsored/strategy.ts +++ b/api/_bridges/cctp-sponsored/strategy.ts @@ -23,7 +23,7 @@ import { SPONSORED_CCTP_OUTPUT_TOKENS, SPONSORED_CCTP_SRC_PERIPHERY_ADDRESSES, } from "./utils/constants"; -import { simulateMarketOrder } from "../../_hypercore"; +import { simulateMarketOrder, SPOT_TOKEN_DECIMALS } from "../../_hypercore"; import { SPONSORED_CCTP_SRC_PERIPHERY_ABI } from "./utils/abi"; import { tagIntegratorId, tagSwapApiMarker } from "../../_integrator-id"; @@ -281,7 +281,7 @@ export async function calculateMaxBpsToSponsor(params: { chainId: outputToken.chainId, tokenIn: { symbol: "USDC", - decimals: 8, + decimals: SPOT_TOKEN_DECIMALS, }, tokenOut: { symbol: outputToken.symbol, diff --git a/api/_bridges/cctp-sponsored/utils/constants.ts b/api/_bridges/cctp-sponsored/utils/constants.ts index f28ae3d13..99d3154cc 100644 --- a/api/_bridges/cctp-sponsored/utils/constants.ts +++ b/api/_bridges/cctp-sponsored/utils/constants.ts @@ -1,5 +1,12 @@ +import { ethers } from "ethers"; + import { CHAIN_IDs } from "../../../_constants"; import { CCTP_SUPPORTED_CHAINS } from "../../cctp/utils/constants"; +import { getEnvs } from "../../../_env"; + +export const SPONSORED_CCTP_QUOTE_FINALIZER_ADDRESS = + getEnvs().SPONSORED_CCTP_QUOTE_FINALIZER_ADDRESS || + ethers.constants.AddressZero; // TODO: Use actual addresses for zero addresses export const SPONSORED_CCTP_SRC_PERIPHERY_ADDRESSES = { @@ -15,7 +22,12 @@ export const SPONSORED_CCTP_DST_PERIPHERY_ADDRESSES = { export const SPONSORED_CCTP_ORIGIN_CHAINS = CCTP_SUPPORTED_CHAINS.filter( (chainId) => - ![CHAIN_IDs.HYPERCORE, CHAIN_IDs.HYPERCORE_TESTNET].includes(chainId) + ![ + CHAIN_IDs.HYPERCORE, + CHAIN_IDs.HYPERCORE_TESTNET, + CHAIN_IDs.HYPEREVM, + CHAIN_IDs.HYPEREVM_TESTNET, + ].includes(chainId) ); export const SPONSORED_CCTP_INPUT_TOKENS = ["USDC"]; diff --git a/api/_bridges/cctp-sponsored/utils/quote-builder.ts b/api/_bridges/cctp-sponsored/utils/quote-builder.ts index 6ecaccead..8069cbc85 100644 --- a/api/_bridges/cctp-sponsored/utils/quote-builder.ts +++ b/api/_bridges/cctp-sponsored/utils/quote-builder.ts @@ -13,7 +13,10 @@ import { getCctpDomainId, } from "../../cctp/utils/constants"; import { isToHyperCore } from "../../cctp/utils/hypercore"; -import { SPONSORED_CCTP_DST_PERIPHERY_ADDRESSES } from "./constants"; +import { + SPONSORED_CCTP_DST_PERIPHERY_ADDRESSES, + SPONSORED_CCTP_QUOTE_FINALIZER_ADDRESS, +} from "./constants"; /** * Builds a complete sponsored CCTP quote with signature @@ -62,8 +65,8 @@ export function buildSponsoredCCTPQuote( const sponsoredCCTPQuote: SponsoredCCTPQuote = { sourceDomain: getCctpDomainId(inputToken.chainId), destinationDomain: getCctpDomainId(intermediaryChainId), - destinationCaller: toBytes32(sponsoredCCTPDstPeripheryAddress), - mintRecipient: toBytes32(recipient), + destinationCaller: toBytes32(SPONSORED_CCTP_QUOTE_FINALIZER_ADDRESS), + mintRecipient: toBytes32(sponsoredCCTPDstPeripheryAddress), amount: inputAmount, burnToken: toBytes32(inputToken.address), maxFee, diff --git a/test/api/_bridges/cctp-sponsored/strategy.test.ts b/test/api/_bridges/cctp-sponsored/strategy.test.ts index d73b38e5a..3fe0705ac 100644 --- a/test/api/_bridges/cctp-sponsored/strategy.test.ts +++ b/test/api/_bridges/cctp-sponsored/strategy.test.ts @@ -342,8 +342,8 @@ describe("api/_bridges/cctp-sponsored/strategy", () => { expect(result.bridgeQuote.inputToken).toEqual(arbitrumUSDC); expect(result.bridgeQuote.outputToken).toEqual(hyperCoreUSDC); expect(result.bridgeQuote.inputAmount).toEqual(exactInputAmount); - // Output should be converted from 6 decimals to 8 decimals - expect(result.bridgeQuote.outputAmount.toString()).toBe("100000000"); // 1 USDC in 8 decimals + // Output should be converted from 6 decimals to 6 decimals (HyperCore USDC perp) + expect(result.bridgeQuote.outputAmount.toString()).toBe("1000000"); // 1 USDC in 6 decimals expect(result.bridgeQuote.minOutputAmount).toEqual( result.bridgeQuote.outputAmount ); From a888d06eaae15d6dc37e5157500134b7185532d1 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Mon, 3 Nov 2025 14:05:35 +0100 Subject: [PATCH 4/8] test: fix sponsorship pk --- test/api/_bridges/cctp-sponsored/strategy.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/api/_bridges/cctp-sponsored/strategy.test.ts b/test/api/_bridges/cctp-sponsored/strategy.test.ts index 3fe0705ac..4cc5e5020 100644 --- a/test/api/_bridges/cctp-sponsored/strategy.test.ts +++ b/test/api/_bridges/cctp-sponsored/strategy.test.ts @@ -1,4 +1,4 @@ -import { BigNumber, utils } from "ethers"; +import { BigNumber, ethers, utils } from "ethers"; import { buildEvmTxForAllowanceHolder, calculateMaxBpsToSponsor, @@ -17,6 +17,7 @@ import { ConvertDecimals } from "../../../../api/_utils"; import { AMOUNT_TYPE } from "../../../../api/_dexes/utils"; import * as cctpHypercore from "../../../../api/_bridges/cctp/utils/hypercore"; import { SPONSORED_CCTP_SRC_PERIPHERY_ADDRESSES } from "../../../../api/_bridges/cctp-sponsored/utils/constants"; +import { getEnvs } from "../../../../api/_env"; describe("api/_bridges/cctp-sponsored/strategy", () => { const arbitrumUSDC: Token = { @@ -398,9 +399,15 @@ describe("api/_bridges/cctp-sponsored/strategy", () => { const recipient = "0x0000000000000000000000000000000000000002"; const inputAmount = utils.parseUnits("1", arbitrumUSDC.decimals); const outputAmount = utils.parseUnits("1", hyperCoreUSDC.decimals); + const TEST_WALLET = ethers.Wallet.createRandom(); + const TEST_PRIVATE_KEY = TEST_WALLET.privateKey; beforeEach(() => { jest.clearAllMocks(); + // 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, + }); }); test("should build transaction correctly for USDC output", async () => { From e48a32fa37b44a5760ac657e764dcf197e23a030 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Mon, 3 Nov 2025 14:36:10 +0100 Subject: [PATCH 5/8] fixup --- test/api/_bridges/cctp-sponsored/strategy.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/api/_bridges/cctp-sponsored/strategy.test.ts b/test/api/_bridges/cctp-sponsored/strategy.test.ts index 4cc5e5020..1afbad8ea 100644 --- a/test/api/_bridges/cctp-sponsored/strategy.test.ts +++ b/test/api/_bridges/cctp-sponsored/strategy.test.ts @@ -19,6 +19,11 @@ import * as cctpHypercore from "../../../../api/_bridges/cctp/utils/hypercore"; import { SPONSORED_CCTP_SRC_PERIPHERY_ADDRESSES } from "../../../../api/_bridges/cctp-sponsored/utils/constants"; import { getEnvs } from "../../../../api/_env"; +// Mock the environment variables to ensure tests are deterministic. +jest.mock("../../../../api/_env", () => ({ + getEnvs: jest.fn(), +})); + describe("api/_bridges/cctp-sponsored/strategy", () => { const arbitrumUSDC: Token = { ...TOKEN_SYMBOLS_MAP.USDC, From 87a67bd59a400e426828f0708e4a9beba1f78a05 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Mon, 3 Nov 2025 14:53:18 +0100 Subject: [PATCH 6/8] fixup --- test/api/_bridges/cctp-sponsored/strategy.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/api/_bridges/cctp-sponsored/strategy.test.ts b/test/api/_bridges/cctp-sponsored/strategy.test.ts index 1afbad8ea..e376076fe 100644 --- a/test/api/_bridges/cctp-sponsored/strategy.test.ts +++ b/test/api/_bridges/cctp-sponsored/strategy.test.ts @@ -21,9 +21,12 @@ import { getEnvs } from "../../../../api/_env"; // Mock the environment variables to ensure tests are deterministic. jest.mock("../../../../api/_env", () => ({ - getEnvs: jest.fn(), + getEnvs: jest.fn().mockReturnValue({}), })); +const TEST_WALLET = ethers.Wallet.createRandom(); +const TEST_PRIVATE_KEY = TEST_WALLET.privateKey; + describe("api/_bridges/cctp-sponsored/strategy", () => { const arbitrumUSDC: Token = { ...TOKEN_SYMBOLS_MAP.USDC, @@ -404,8 +407,6 @@ describe("api/_bridges/cctp-sponsored/strategy", () => { const recipient = "0x0000000000000000000000000000000000000002"; const inputAmount = utils.parseUnits("1", arbitrumUSDC.decimals); const outputAmount = utils.parseUnits("1", hyperCoreUSDC.decimals); - const TEST_WALLET = ethers.Wallet.createRandom(); - const TEST_PRIVATE_KEY = TEST_WALLET.privateKey; beforeEach(() => { jest.clearAllMocks(); From d85ccb7e9023bebc3ac1a864f65dda021c2fc9db Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Wed, 5 Nov 2025 09:06:01 +0100 Subject: [PATCH 7/8] fixup --- api/_bridges/cctp-sponsored/strategy.ts | 16 ++++++++++------ api/_bridges/cctp-sponsored/utils/constants.ts | 3 +++ .../cctp-sponsored/utils/quote-builder.ts | 4 ++-- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/api/_bridges/cctp-sponsored/strategy.ts b/api/_bridges/cctp-sponsored/strategy.ts index 8834826eb..175c9282c 100644 --- a/api/_bridges/cctp-sponsored/strategy.ts +++ b/api/_bridges/cctp-sponsored/strategy.ts @@ -22,6 +22,7 @@ import { SPONSORED_CCTP_ORIGIN_CHAINS, SPONSORED_CCTP_OUTPUT_TOKENS, SPONSORED_CCTP_SRC_PERIPHERY_ADDRESSES, + CCTP_TRANSFER_MODE, } from "./utils/constants"; import { simulateMarketOrder, SPOT_TOKEN_DECIMALS } from "../../_hypercore"; import { SPONSORED_CCTP_SRC_PERIPHERY_ABI } from "./utils/abi"; @@ -41,9 +42,6 @@ const capabilities: BridgeCapabilities = { }, }; -// TODO: Should this always be fast? -const cctpMode = "fast" as const; - /** * Sponsored CCTP bridge strategy */ @@ -112,7 +110,10 @@ export async function getQuoteForExactInput({ inputAmount: exactInputAmount, outputAmount, minOutputAmount: outputAmount, - estimatedFillTimeSec: getEstimatedFillTime(inputToken.chainId, cctpMode), + estimatedFillTimeSec: getEstimatedFillTime( + inputToken.chainId, + CCTP_TRANSFER_MODE + ), provider: name, fees: getZeroBridgeFees(inputToken), }, @@ -139,7 +140,10 @@ export async function getQuoteForOutput({ inputAmount, outputAmount: minOutputAmount, minOutputAmount, - estimatedFillTimeSec: getEstimatedFillTime(inputToken.chainId, cctpMode), + estimatedFillTimeSec: getEstimatedFillTime( + inputToken.chainId, + CCTP_TRANSFER_MODE + ), provider: name, fees: getZeroBridgeFees(inputToken), }, @@ -185,7 +189,7 @@ export async function buildEvmTxForAllowanceHolder(params: { }); } - const minFinalityThreshold = CCTP_FINALITY_THRESHOLDS[cctpMode]; + const minFinalityThreshold = CCTP_FINALITY_THRESHOLDS[CCTP_TRANSFER_MODE]; // Calculate `maxFee` as required by `depositForBurnWithHook` const { transferFeeBps, forwardFee } = await getCctpFees({ diff --git a/api/_bridges/cctp-sponsored/utils/constants.ts b/api/_bridges/cctp-sponsored/utils/constants.ts index 99d3154cc..94fe5da04 100644 --- a/api/_bridges/cctp-sponsored/utils/constants.ts +++ b/api/_bridges/cctp-sponsored/utils/constants.ts @@ -4,6 +4,9 @@ import { CHAIN_IDs } from "../../../_constants"; import { CCTP_SUPPORTED_CHAINS } from "../../cctp/utils/constants"; import { getEnvs } from "../../../_env"; +// NOTE: For now, we always use fast CCTP mode +export const CCTP_TRANSFER_MODE = "fast" as const; + export const SPONSORED_CCTP_QUOTE_FINALIZER_ADDRESS = getEnvs().SPONSORED_CCTP_QUOTE_FINALIZER_ADDRESS || ethers.constants.AddressZero; diff --git a/api/_bridges/cctp-sponsored/utils/quote-builder.ts b/api/_bridges/cctp-sponsored/utils/quote-builder.ts index 8069cbc85..dfebb02bb 100644 --- a/api/_bridges/cctp-sponsored/utils/quote-builder.ts +++ b/api/_bridges/cctp-sponsored/utils/quote-builder.ts @@ -16,6 +16,7 @@ import { isToHyperCore } from "../../cctp/utils/hypercore"; import { SPONSORED_CCTP_DST_PERIPHERY_ADDRESSES, SPONSORED_CCTP_QUOTE_FINALIZER_ADDRESS, + CCTP_TRANSFER_MODE, } from "./constants"; /** @@ -70,8 +71,7 @@ export function buildSponsoredCCTPQuote( amount: inputAmount, burnToken: toBytes32(inputToken.address), maxFee, - // TODO: Should this always be fast? - minFinalityThreshold: CCTP_FINALITY_THRESHOLDS.fast, + minFinalityThreshold: CCTP_FINALITY_THRESHOLDS[CCTP_TRANSFER_MODE], nonce, deadline, maxBpsToSponsor, From e212d9bcd3cc42d9acaedc608e70463381b5a5f6 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Wed, 5 Nov 2025 12:35:09 +0100 Subject: [PATCH 8/8] fixup --- api/_bridges/cctp-sponsored/strategy.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/api/_bridges/cctp-sponsored/strategy.ts b/api/_bridges/cctp-sponsored/strategy.ts index 175c9282c..1a80aa4f9 100644 --- a/api/_bridges/cctp-sponsored/strategy.ts +++ b/api/_bridges/cctp-sponsored/strategy.ts @@ -7,7 +7,7 @@ import { GetOutputBridgeQuoteParams, } from "../types"; import { CrossSwap, CrossSwapQuotes, Token } from "../../_dexes/types"; -import { AMOUNT_TYPE, AppFee, CROSS_SWAP_TYPE } from "../../_dexes/utils"; +import { AppFee, CROSS_SWAP_TYPE } from "../../_dexes/utils"; import { CCTP_FINALITY_THRESHOLDS } from "../cctp/utils/constants"; import { InvalidParamError } from "../../_errors"; import { ConvertDecimals } from "../../_utils"; @@ -205,10 +205,7 @@ export async function buildEvmTxForAllowanceHolder(params: { inputToken: crossSwap.inputToken, outputToken: crossSwap.outputToken, maxFee, - inputAmount: - crossSwap.type === AMOUNT_TYPE.EXACT_INPUT - ? bridgeQuote.inputAmount - : bridgeQuote.inputAmount.add(maxFee), + inputAmount: bridgeQuote.inputAmount, }); const maxBpsToSponsorBn = BigNumber.from(Math.ceil(maxBpsToSponsor));