diff --git a/api/_bridges/cctp-sponsored/strategy.ts b/api/_bridges/cctp-sponsored/strategy.ts new file mode 100644 index 000000000..d2098c3ca --- /dev/null +++ b/api/_bridges/cctp-sponsored/strategy.ts @@ -0,0 +1,334 @@ +import { BigNumber, ethers, utils } from "ethers"; + +import { + BridgeStrategy, + GetExactInputBridgeQuoteParams, + BridgeCapabilities, + GetOutputBridgeQuoteParams, +} from "../types"; +import { CrossSwap, CrossSwapQuotes, Token } from "../../_dexes/types"; +import { 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, + CCTP_TRANSFER_MODE, +} from "./utils/constants"; +import { simulateMarketOrder, SPOT_TOKEN_DECIMALS } from "../../_hypercore"; +import { SPONSORED_CCTP_SRC_PERIPHERY_ABI } from "./utils/abi"; +import { tagIntegratorId, tagSwapApiMarker } from "../../_integrator-id"; +import { getSlippage } from "../../_slippage"; + +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, + }, +}; + +/** + * 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, + CCTP_TRANSFER_MODE + ), + 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, + CCTP_TRANSFER_MODE + ), + 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[CCTP_TRANSFER_MODE]; + + // 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: bridgeQuote.inputAmount, + }); + 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( + getSlippage({ + tokenIn: { + ...crossSwap.inputToken, + chainId: crossSwap.outputToken.chainId, + }, + tokenOut: crossSwap.outputToken, + slippageTolerance: crossSwap.slippageTolerance, + originOrDestination: "destination", + }) * 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") { + 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: SPOT_TOKEN_DECIMALS, + }, + 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..a3214a8af --- /dev/null +++ b/api/_bridges/cctp-sponsored/utils/constants.ts @@ -0,0 +1,50 @@ +import { ethers } from "ethers"; + +import { CHAIN_IDs, TOKEN_SYMBOLS_MAP } 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; + +export const SPONSORED_CCTP_SRC_PERIPHERY_ADDRESSES = { + [CHAIN_IDs.ARBITRUM]: "0x5450fd941ec43b4fc419f12fe81d3b0a88af7461", + [CHAIN_IDs.BASE]: "0x97f097369fbf9e0d9a4800ca924074f814db81e9", + [CHAIN_IDs.ARBITRUM_SEPOLIA]: "0x79176E2E91c77b57AC11c6fe2d2Ab2203D87AF85", +}; + +export const SPONSORED_CCTP_DST_PERIPHERY_ADDRESSES = { + [CHAIN_IDs.HYPEREVM]: "0x7b164050bbc8e7ef3253e7db0d74b713ba3f1c95", + [CHAIN_IDs.HYPEREVM_TESTNET]: "0x0000000000000000000000000000000000000000", +}; + +export const SPONSORED_CCTP_ORIGIN_CHAINS = CCTP_SUPPORTED_CHAINS.filter( + (chainId) => + ![ + CHAIN_IDs.HYPERCORE, + CHAIN_IDs.HYPERCORE_TESTNET, + CHAIN_IDs.HYPEREVM, + CHAIN_IDs.HYPEREVM_TESTNET, + ].includes(chainId) +); + +export const SPONSORED_CCTP_INPUT_TOKENS = ["USDC"]; + +export const SPONSORED_CCTP_OUTPUT_TOKENS = ["USDC", "USDH-SPOT"]; + +export const SPONSORED_CCTP_FINAL_TOKEN_PER_OUTPUT_TOKEN: Record< + string, + (typeof TOKEN_SYMBOLS_MAP)[keyof typeof TOKEN_SYMBOLS_MAP] +> = { + USDC: TOKEN_SYMBOLS_MAP.USDC, + "USDH-SPOT": TOKEN_SYMBOLS_MAP.USDH, +}; + +export const SPONSORED_CCTP_DESTINATION_CHAINS = [ + CHAIN_IDs.HYPERCORE, + CHAIN_IDs.HYPERCORE_TESTNET, +]; diff --git a/api/_bridges/cctp-sponsored/utils/final-token.ts b/api/_bridges/cctp-sponsored/utils/final-token.ts new file mode 100644 index 000000000..5a5ef0407 --- /dev/null +++ b/api/_bridges/cctp-sponsored/utils/final-token.ts @@ -0,0 +1,21 @@ +import { SPONSORED_CCTP_FINAL_TOKEN_PER_OUTPUT_TOKEN } from "./constants"; + +export function getSponsoredCctpFinalTokenAddress( + outputTokenSymbol: string, + intermediaryChainId: number +) { + const finalToken = + SPONSORED_CCTP_FINAL_TOKEN_PER_OUTPUT_TOKEN[outputTokenSymbol]; + if (!finalToken) { + throw new Error( + `'finalToken' not found for output token ${outputTokenSymbol}` + ); + } + const finalTokenAddress = finalToken.addresses[intermediaryChainId]; + if (!finalTokenAddress) { + throw new Error( + `'finalTokenAddress' not found for ${finalToken.symbol} on chain ${intermediaryChainId}` + ); + } + return finalTokenAddress; +} 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..ee846191a --- /dev/null +++ b/api/_bridges/cctp-sponsored/utils/quote-builder.ts @@ -0,0 +1,94 @@ +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, + SPONSORED_CCTP_QUOTE_FINALIZER_ADDRESS, + CCTP_TRANSFER_MODE, +} from "./constants"; +import { getSponsoredCctpFinalTokenAddress } from "./final-token"; + +/** + * 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 finalToken = getSponsoredCctpFinalTokenAddress( + outputToken.symbol, + intermediaryChainId + ); + + const sponsoredCCTPQuote: SponsoredCCTPQuote = { + sourceDomain: getCctpDomainId(inputToken.chainId), + destinationDomain: getCctpDomainId(intermediaryChainId), + destinationCaller: toBytes32(SPONSORED_CCTP_QUOTE_FINALIZER_ADDRESS), + mintRecipient: toBytes32(sponsoredCCTPDstPeripheryAddress), + amount: inputAmount, + burnToken: toBytes32(inputToken.address), + maxFee, + minFinalityThreshold: CCTP_FINALITY_THRESHOLDS[CCTP_TRANSFER_MODE], + nonce, + deadline, + maxBpsToSponsor, + maxUserSlippageBps, + finalRecipient: toBytes32(recipient), + finalToken: toBytes32(finalToken), + 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/sponsorship/cctp.ts b/api/_bridges/cctp-sponsored/utils/signing.ts similarity index 88% rename from api/_bridges/sponsorship/cctp.ts rename to api/_bridges/cctp-sponsored/utils/signing.ts index c3eb0b7e3..78a999db1 100644 --- a/api/_bridges/sponsorship/cctp.ts +++ b/api/_bridges/cctp-sponsored/utils/signing.ts @@ -1,5 +1,5 @@ import { BigNumberish, utils } from "ethers"; -import { signDigestWithSponsor } from "../../_sponsorship-signature"; +import { signDigestWithSponsor } from "../../../_sponsorship-signature"; /** * Represents the parameters for a sponsored CCTP quote. @@ -22,6 +22,8 @@ export interface SponsoredCCTPQuote { maxUserSlippageBps: BigNumberish; finalRecipient: string; finalToken: string; + executionMode: number; + actionData: string; } /** @@ -65,7 +67,16 @@ export const createCctpSignature = ( const hash2 = utils.keccak256( utils.defaultAbiCoder.encode( - ["bytes32", "uint256", "uint256", "uint256", "bytes32", "bytes32"], + [ + "bytes32", + "uint256", + "uint256", + "uint256", + "bytes32", + "bytes32", + "uint8", + "bytes32", + ], [ quote.nonce, quote.deadline, @@ -73,6 +84,8 @@ export const createCctpSignature = ( quote.maxUserSlippageBps, quote.finalRecipient, quote.finalToken, + quote.executionMode, + utils.keccak256(quote.actionData), ] ) ); diff --git a/api/_bridges/cctp/strategy.ts b/api/_bridges/cctp/strategy.ts index 990fc188a..cff09540d 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 0c9876cfe..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,12 +276,3 @@ export function getHyperCoreBridgeStrategy(): BridgeStrategy { isRouteSupported, }; } - -function getZeroBridgeFees(inputToken: Token) { - const zeroBN = BigNumber.from(0); - return { - pct: zeroBN, - amount: zeroBN, - token: inputToken, - }; -} diff --git a/api/_bridges/index.ts b/api/_bridges/index.ts index 1289aad3e..dd987b8a1 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(), @@ -30,8 +31,12 @@ export const bridgeStrategies: BridgeStrategiesConfig = { [CHAIN_IDs.SEPOLIA]: { [CHAIN_IDs.HYPERCORE_TESTNET]: getCctpBridgeStrategy(), }, + // @TODO: Remove this once we can correctly route via eligibility checks [CHAIN_IDs.ARBITRUM_SEPOLIA]: { - [CHAIN_IDs.HYPERCORE_TESTNET]: getCctpBridgeStrategy(), + [CHAIN_IDs.HYPERCORE_TESTNET]: getSponsoredCctpBridgeStrategy(), + }, + [CHAIN_IDs.ARBITRUM]: { + [CHAIN_IDs.HYPERCORE]: getSponsoredCctpBridgeStrategy(), }, // SVM → HyperCore routes [CHAIN_IDs.SOLANA]: { diff --git a/api/_bridges/oft-sponsored/strategy.ts b/api/_bridges/oft-sponsored/strategy.ts index 641a621fe..feaa4204e 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 "../../_token-info"; @@ -241,7 +241,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 @@ -258,20 +258,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 @@ -279,7 +282,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); @@ -330,15 +333,18 @@ async function buildTransaction(params: { // Convert slippage tolerance to bps (slippageTolerance is a decimal, e.g., 0.5 = 0.5% = 50 bps) const maxUserSlippageBps = Math.floor( getSlippage({ - tokenIn: crossSwap.inputToken, + tokenIn: { + ...crossSwap.inputToken, + chainId: crossSwap.outputToken.chainId, + }, tokenOut: crossSwap.outputToken, slippageTolerance: crossSwap.slippageTolerance, - originOrDestination: "origin", + originOrDestination: "destination", }) * 100 ); // Build signed quote with signature - const { quote, signature } = await buildSponsoredOFTQuote({ + const { quote, signature } = buildSponsoredOFTQuote({ inputToken: crossSwap.inputToken, outputToken: crossSwap.outputToken, inputAmount: bridgeQuote.inputAmount, @@ -384,11 +390,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 2c3e0ea5b..4f396f0d6 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 */ @@ -49,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/_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/sponsorship/index.ts b/api/_bridges/sponsorship/index.ts deleted file mode 100644 index 812ff06d5..000000000 --- a/api/_bridges/sponsorship/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./cctp"; -export * from "./oft"; -export * from "./utils"; diff --git a/api/_bridges/sponsorship/oft.ts b/api/_bridges/sponsorship/oft.ts deleted file mode 100644 index 261d37ee7..000000000 --- a/api/_bridges/sponsorship/oft.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { BigNumberish, utils } from "ethers"; -import { signMessageWithSponsor } from "./utils"; - -/** - * Represents the signed parameters of a sponsored OFT quote. - * This structure is signed by the sponsor and validated by the destination contract. - * For more details on the struct, see the original contract: - * @see https://github.com/across-protocol/contracts/blob/7b37bbee4e8c71f2d3cffb28defe1c1e26583cb0/contracts/periphery/mintburn/sponsored-oft/Structs.sol - */ -export interface SignedQuoteParams { - srcEid: number; - dstEid: number; - destinationHandler: string; - amountLD: BigNumberish; - nonce: string; - deadline: BigNumberish; - maxBpsToSponsor: BigNumberish; - finalRecipient: string; - finalToken: string; - lzReceiveGasLimit: BigNumberish; - lzComposeGasLimit: BigNumberish; -} - -/** - * Creates a signature for a sponsored OFT quote. - * The signing process follows the `validateSignature` function in the `QuoteSignLib` contract. - * It involves ABI-encoding all the parameters, hashing the result, and then signing the EIP-191 prefixed hash. - * @param quote The sponsored OFT quote parameters to sign. - * @returns A promise that resolves to an object containing the signature and the hash that was signed. - * @see https://github.com/across-protocol/contracts/blob/7b37bbee4e8c71f2d3cffb28defe1c1e26583cb0/contracts/periphery/mintburn/sponsored-oft/QuoteSignLib.sol - */ -export const createOftSignature = async ( - quote: SignedQuoteParams -): Promise<{ signature: string; hash: string }> => { - // ABI-encode all parameters and hash the result to create the digest to be signed. - const encodedData = utils.defaultAbiCoder.encode( - [ - "uint32", - "uint32", - "bytes32", - "uint256", - "bytes32", - "uint256", - "uint256", - "bytes32", - "bytes32", - "uint256", - "uint256", - ], - [ - quote.srcEid, - quote.dstEid, - quote.destinationHandler, - quote.amountLD, - quote.nonce, - quote.deadline, - quote.maxBpsToSponsor, - quote.finalRecipient, - quote.finalToken, - quote.lzReceiveGasLimit, - quote.lzComposeGasLimit, - ] - ); - - const hash = utils.keccak256(encodedData); - // The OFT contract expects an EIP-191 compliant signature, so we sign the prefixed hash of the digest. - const signature = await signMessageWithSponsor(utils.arrayify(hash)); - return { signature, hash }; -}; diff --git a/api/_bridges/sponsorship/utils/index.ts b/api/_bridges/sponsorship/utils/index.ts deleted file mode 100644 index a47dc3e25..000000000 --- a/api/_bridges/sponsorship/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./signature"; diff --git a/api/_bridges/sponsorship/utils/signature.ts b/api/_bridges/sponsorship/utils/signature.ts deleted file mode 100644 index 28a52015b..000000000 --- a/api/_bridges/sponsorship/utils/signature.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { ethers, utils } from "ethers"; -import { getEnvs } from "../../../../api/_env"; - -let sponsorshipSigner: ethers.Wallet | undefined; - -/** - * Retrieves the sponsorship signer wallet instance. - * This function caches the signer instance in memory to avoid re-creating it on every call. - * The private key for the signer is fetched from environment variables. - * @returns {ethers.Wallet} The sponsorship signer wallet instance. - * @throws {Error} If the SPONSORSHIP_SIGNER_PRIVATE_KEY environment variable is not set. - */ -export const getSponsorshipSigner = (): ethers.Wallet => { - if (sponsorshipSigner) return sponsorshipSigner; - - const { SPONSORSHIP_SIGNER_PRIVATE_KEY } = getEnvs(); - if (!SPONSORSHIP_SIGNER_PRIVATE_KEY) { - throw new Error("SPONSORSHIP_SIGNER_PRIVATE_KEY is not set"); - } - sponsorshipSigner = new ethers.Wallet(SPONSORSHIP_SIGNER_PRIVATE_KEY); - return sponsorshipSigner; -}; - -/** - * Signs a raw digest with the sponsorship signer. - * This is used for CCTP signatures where the contract expects a signature on the unprefixed hash. - * @param {string} digest The raw digest to sign. - * @returns {string} The signature string. - */ -export const signDigestWithSponsor = (digest: string): string => { - const signer = getSponsorshipSigner(); - // We use `_signingKey().signDigest` to sign the raw digest, as this is what the CCTP contract expects. - // This is necessary because `signer.signMessage` would prefix the hash. - const signature = signer._signingKey().signDigest(digest); - return utils.joinSignature(signature); -}; - -/** - * Signs a message with the sponsorship signer. - * This is used for OFT signatures where the contract expects a signature on the EIP-191 prefixed hash. - * @param {Uint8Array} message The message to sign. - * @returns {Promise} The signature string. - */ -export const signMessageWithSponsor = ( - message: Uint8Array -): Promise => { - const signer = getSponsorshipSigner(); - return signer.signMessage(message); -}; diff --git a/api/_bridges/types.ts b/api/_bridges/types.ts index e39e2b42c..4dc0ef406 100644 --- a/api/_bridges/types.ts +++ b/api/_bridges/types.ts @@ -139,7 +139,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..8e38a0693 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,12 @@ export async function getBridgeStrategyData({ return undefined; } } + +export function getZeroBridgeFees(inputToken: Token) { + const zeroBN = BigNumber.from(0); + return { + amount: zeroBN, + token: inputToken, + pct: zeroBN, + }; +} diff --git a/api/_constants.ts b/api/_constants.ts index 07789e808..f4bab957f 100644 --- a/api/_constants.ts +++ b/api/_constants.ts @@ -18,11 +18,35 @@ export const CHAIN_IDs = { ...constants.CHAIN_IDs, HYPERCORE_TESTNET: 13372, }; -export const TOKEN_SYMBOLS_MAP = constants.TOKEN_SYMBOLS_MAP; +export const TOKEN_SYMBOLS_MAP = { + ...constants.TOKEN_SYMBOLS_MAP, + USDH: { + name: "USDH", + symbol: "USDH", + decimals: 6, + addresses: { + [CHAIN_IDs.HYPEREVM]: "0x111111a1a0667d36bD57c0A9f569b98057111111", + [CHAIN_IDs.HYPEREVM_TESTNET]: + "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", + }, +}; TOKEN_SYMBOLS_MAP.USDC = { - ...constants.TOKEN_SYMBOLS_MAP.USDC, + ...TOKEN_SYMBOLS_MAP.USDC, addresses: { - ...constants.TOKEN_SYMBOLS_MAP.USDC.addresses, + ...TOKEN_SYMBOLS_MAP.USDC.addresses, [CHAIN_IDs.HYPERCORE]: "0x2000000000000000000000000000000000000000", [CHAIN_IDs.HYPERCORE_TESTNET]: "0x2000000000000000000000000000000000000000", }, diff --git a/api/_dexes/types.ts b/api/_dexes/types.ts index 30422071a..ece20c89b 100644 --- a/api/_dexes/types.ts +++ b/api/_dexes/types.ts @@ -152,7 +152,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..dd4097a1b 100644 --- a/api/_hypercore.ts +++ b/api/_hypercore.ts @@ -5,6 +5,9 @@ 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"; + +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. @@ -131,10 +134,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 +174,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 +226,7 @@ export type MarketOrderSimulationResult = { * }); */ export async function simulateMarketOrder(params: { + chainId?: number; tokenIn: { symbol: string; decimals: number; @@ -216,41 +237,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 +403,9 @@ export async function simulateMarketOrder(params: { fullyFilled, }; } + +export function getNormalizedSpotTokenSymbol(symbol: string): string { + return ["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..dac1fde0b --- /dev/null +++ b/test/api/_bridges/cctp-sponsored/strategy.test.ts @@ -0,0 +1,674 @@ +import { BigNumber, ethers, 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"; +import { getEnvs } from "../../../../api/_env"; + +// Mock the environment variables to ensure tests are deterministic. +jest.mock("../../../../api/_env", () => ({ + 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, + address: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.ARBITRUM_SEPOLIA], + chainId: CHAIN_IDs.ARBITRUM_SEPOLIA, + }; + + const hyperCoreUSDC: Token = { + ...TOKEN_SYMBOLS_MAP["USDC"], + address: TOKEN_SYMBOLS_MAP["USDC"].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", () => { + expect( + isRouteSupported({ + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDC, + }) + ).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 output (no swap needed)", () => { + test("should return correct maxFeeBps", async () => { + const result = await calculateMaxBpsToSponsor({ + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDC, + 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: hyperCoreUSDC, + 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: hyperCoreUSDC, + 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: hyperCoreUSDC, + exactInputAmount, + }); + + 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 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 + ); + 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", hyperCoreUSDC.decimals); + + const result = await getQuoteForOutput({ + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDC, + minOutputAmount, + }); + + expect(result.bridgeQuote.inputToken).toEqual(arbitrumUSDC); + 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); + 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", hyperCoreUSDC.decimals); + + 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 () => { + const crossSwap: CrossSwap = { + amount: inputAmount, + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDC, + depositor, + recipient, + slippageTolerance: 1, + type: AMOUNT_TYPE.EXACT_INPUT, + refundOnOrigin: false, + embeddedActions: [], + strictTradeType: false, + }; + + const quotes: CrossSwapQuotes = { + crossSwap, + bridgeQuote: { + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDC, + inputAmount, + outputAmount, + minOutputAmount: outputAmount, + estimatedFillTimeSec: 300, + provider: "sponsored-cctp", + fees: { + token: arbitrumUSDC, + pct: BigNumber.from(0), + amount: BigNumber.from(0), + }, + }, + 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: { + amount: BigNumber.from(0), + token: arbitrumUSDC, + pct: BigNumber.from(0), + }, + }, + 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: hyperCoreUSDC, + depositor, + recipient, + slippageTolerance: 1, + type: AMOUNT_TYPE.EXACT_INPUT, + refundOnOrigin: false, + embeddedActions: [], + strictTradeType: false, + }; + + const quotes: CrossSwapQuotes = { + crossSwap, + bridgeQuote: { + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDC, + inputAmount, + outputAmount, + minOutputAmount: outputAmount, + estimatedFillTimeSec: 300, + provider: "sponsored-cctp", + fees: { + amount: BigNumber.from(0), + token: arbitrumUSDC, + pct: BigNumber.from(0), + }, + }, + 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: hyperCoreUSDC, + depositor, + recipient, + slippageTolerance: 1, + type: AMOUNT_TYPE.EXACT_INPUT, + refundOnOrigin: false, + embeddedActions: [], + strictTradeType: false, + }; + + const quotes: CrossSwapQuotes = { + crossSwap, + bridgeQuote: { + inputToken: arbitrumUSDC, + outputToken: hyperCoreUSDC, + inputAmount, + outputAmount, + minOutputAmount: outputAmount, + estimatedFillTimeSec: 300, + provider: "sponsored-cctp", + fees: { + amount: BigNumber.from(0), + token: arbitrumUSDC, + pct: BigNumber.from(0), + }, + }, + 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" + ); + }); + }); +}); diff --git a/test/api/_bridges/sponsorship/cctp.test.ts b/test/api/_bridges/cctp-sponsored/utils/signing.test.ts similarity index 90% rename from test/api/_bridges/sponsorship/cctp.test.ts rename to test/api/_bridges/cctp-sponsored/utils/signing.test.ts index 488f34826..0b6e5bcb7 100644 --- a/test/api/_bridges/sponsorship/cctp.test.ts +++ b/test/api/_bridges/cctp-sponsored/utils/signing.test.ts @@ -1,14 +1,14 @@ import { ethers, utils } from "ethers"; import { recoverAddress } from "viem"; -import { getEnvs } from "../../../../api/_env"; +import { getEnvs } from "../../../../../api/_env"; import { createCctpSignature, SponsoredCCTPQuote, -} from "../../../../api/_bridges/sponsorship/cctp"; +} from "../../../../../api/_bridges/cctp-sponsored/utils/signing"; // Mock the environment variables to ensure tests are deterministic. -jest.mock("../../../../api/_env", () => ({ +jest.mock("../../../../../api/_env", () => ({ getEnvs: jest.fn(), })); @@ -45,6 +45,8 @@ describe("CCTP Signature", () => { maxUserSlippageBps: 10, finalRecipient: randomAddress(), finalToken: randomAddress(), + executionMode: 0, + actionData: "0x", }; // Create the signature and get the hash that was signed. diff --git a/test/api/_bridges/cctp/strategy.test.ts b/test/api/_bridges/cctp/strategy.test.ts index 8514f0974..13f8f7085 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/cctp/utils/routing.test.ts b/test/api/_bridges/cctp/utils/routing.test.ts index 9711cea86..9992e7726 100644 --- a/test/api/_bridges/cctp/utils/routing.test.ts +++ b/test/api/_bridges/cctp/utils/routing.test.ts @@ -6,6 +6,16 @@ import { BridgeStrategyData } from "../../../../../api/_bridges/types"; jest.mock("../../../../../api/_bridges/utils"); +// mock the logger +jest.mock("../../../../../api/_logger", () => ({ + getLogger: jest.fn().mockReturnValue({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), +})); + const mockedGetBridgeStrategyData = bridgeUtils.getBridgeStrategyData as jest.MockedFunction< typeof bridgeUtils.getBridgeStrategyData diff --git a/test/api/_bridges/oft-sponsored/strategy.test.ts b/test/api/_bridges/oft-sponsored/strategy.test.ts index 60d7e30c9..218746540 100644 --- a/test/api/_bridges/oft-sponsored/strategy.test.ts +++ b/test/api/_bridges/oft-sponsored/strategy.test.ts @@ -193,7 +193,7 @@ describe("Sponsored OFT Strategy", () => { }); }); - describe("USDC-SPOT output", () => { + describe("USDC output", () => { beforeEach(() => { jest.clearAllMocks(); }); @@ -211,7 +211,7 @@ describe("Sponsored OFT Strategy", () => { }); const result = await calculateMaxBpsToSponsor({ - outputTokenSymbol: "USDC-SPOT", + outputTokenSymbol: "USDC", bridgeInputAmount, bridgeOutputAmount, }); @@ -232,7 +232,7 @@ describe("Sponsored OFT Strategy", () => { }); const result = await calculateMaxBpsToSponsor({ - outputTokenSymbol: "USDC-SPOT", + outputTokenSymbol: "USDC", bridgeInputAmount, bridgeOutputAmount, }); @@ -253,7 +253,7 @@ describe("Sponsored OFT Strategy", () => { }); const result = await calculateMaxBpsToSponsor({ - outputTokenSymbol: "USDC-SPOT", + outputTokenSymbol: "USDC", bridgeInputAmount, bridgeOutputAmount, }); @@ -275,7 +275,7 @@ describe("Sponsored OFT Strategy", () => { }); const result = await calculateMaxBpsToSponsor({ - outputTokenSymbol: "USDC-SPOT", + outputTokenSymbol: "USDC", bridgeInputAmount, bridgeOutputAmount, }); @@ -297,7 +297,7 @@ describe("Sponsored OFT Strategy", () => { }); const result = await calculateMaxBpsToSponsor({ - outputTokenSymbol: "USDC-SPOT", + outputTokenSymbol: "USDC", bridgeInputAmount, bridgeOutputAmount, }); diff --git a/test/api/_bridges/sponsorship/oft.test.ts b/test/api/_bridges/oft-sponsored/utils/signing.test.ts similarity index 92% rename from test/api/_bridges/sponsorship/oft.test.ts rename to test/api/_bridges/oft-sponsored/utils/signing.test.ts index 9d62fa91b..b2b17809f 100644 --- a/test/api/_bridges/sponsorship/oft.test.ts +++ b/test/api/_bridges/oft-sponsored/utils/signing.test.ts @@ -1,13 +1,13 @@ import { ethers, utils } from "ethers"; -import { getEnvs } from "../../../../api/_env"; +import { getEnvs } from "../../../../../api/_env"; import { createOftSignature, SignedQuoteParams, -} from "../../../../api/_bridges/oft-sponsored/utils/signing"; +} from "../../../../../api/_bridges/oft-sponsored/utils/signing"; // Mock the environment variables to ensure tests are deterministic. -jest.mock("../../../../api/_env", () => ({ +jest.mock("../../../../../api/_env", () => ({ getEnvs: jest.fn(), }));