Skip to content

Commit a093ceb

Browse files
committed
wip(swap-api): add cctp bridge strategy
1 parent fbd89ab commit a093ceb

File tree

3 files changed

+395
-1
lines changed

3 files changed

+395
-1
lines changed

api/_bridges/cctp/strategy.ts

Lines changed: 392 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,392 @@
1+
import { BigNumber, ethers } from "ethers";
2+
3+
import {
4+
BridgeStrategy,
5+
GetExactInputBridgeQuoteParams,
6+
BridgeCapabilities,
7+
GetOutputBridgeQuoteParams,
8+
} from "../types";
9+
import { CrossSwap, CrossSwapQuotes } from "../../_dexes/types";
10+
import { AppFee, CROSS_SWAP_TYPE } from "../../_dexes/utils";
11+
import { Token } from "../../_dexes/types";
12+
import { InvalidParamError } from "../../_errors";
13+
import { CHAIN_IDs, TOKEN_SYMBOLS_MAP, CHAINS } from "../../_constants";
14+
import { ConvertDecimals } from "../../_utils";
15+
import { tagIntegratorId, tagSwapApiMarker } from "../../_integrator-id";
16+
import { toBytes32 } from "../../_address";
17+
18+
const name = "cctp";
19+
20+
const CCTP_SUPPORTED_CHAINS = [
21+
CHAIN_IDs.MAINNET,
22+
CHAIN_IDs.ARBITRUM,
23+
CHAIN_IDs.BASE,
24+
CHAIN_IDs.HYPEREVM,
25+
CHAIN_IDs.INK,
26+
CHAIN_IDs.OPTIMISM,
27+
CHAIN_IDs.POLYGON,
28+
CHAIN_IDs.SOLANA,
29+
CHAIN_IDs.UNICHAIN,
30+
CHAIN_IDs.WORLD_CHAIN,
31+
];
32+
33+
const CCTP_SUPPORTED_TOKENS = [TOKEN_SYMBOLS_MAP.USDC];
34+
35+
const CCTP_FINALITY_THRESHOLDS = {
36+
fast: 1000,
37+
standard: 2000,
38+
};
39+
40+
// CCTP TokenMessenger contract addresses
41+
const DEFAULT_CCTP_TOKEN_MESSENGER_ADDRESS =
42+
"0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d";
43+
44+
const CCTP_TOKEN_MESSENGER_ADDRESS_OVERRIDES: Record<number, string> = {
45+
[CHAIN_IDs.SOLANA]: "CCTPV2vPZJS2u2BBsUoscuikbYjnpFmbFsvVuJdgUMQe",
46+
};
47+
48+
const getCctpTokenMessengerAddress = (chainId: number): string => {
49+
return (
50+
CCTP_TOKEN_MESSENGER_ADDRESS_OVERRIDES[chainId] ||
51+
DEFAULT_CCTP_TOKEN_MESSENGER_ADDRESS
52+
);
53+
};
54+
55+
const getCctpDomainId = (chainId: number): number => {
56+
const chainInfo = CHAINS[chainId];
57+
if (!chainInfo || typeof chainInfo.cctpDomain !== "number") {
58+
throw new InvalidParamError({
59+
message: `CCTP domain not found for chain ID ${chainId}`,
60+
});
61+
}
62+
return chainInfo.cctpDomain;
63+
};
64+
65+
// CCTP TokenMessenger depositForBurn ABI
66+
const CCTP_DEPOSIT_FOR_BURN_ABI = {
67+
inputs: [
68+
{
69+
internalType: "uint256",
70+
name: "amount",
71+
type: "uint256",
72+
},
73+
{
74+
internalType: "uint32",
75+
name: "destinationDomain",
76+
type: "uint32",
77+
},
78+
{
79+
internalType: "bytes32",
80+
name: "mintRecipient",
81+
type: "bytes32",
82+
},
83+
{
84+
internalType: "address",
85+
name: "burnToken",
86+
type: "address",
87+
},
88+
{
89+
internalType: "bytes32",
90+
name: "destinationCaller",
91+
type: "bytes32",
92+
},
93+
{
94+
internalType: "uint256",
95+
name: "maxFee",
96+
type: "uint256",
97+
},
98+
{
99+
internalType: "uint32",
100+
name: "minFinalityThreshold",
101+
type: "uint32",
102+
},
103+
],
104+
name: "depositForBurn",
105+
outputs: [],
106+
stateMutability: "nonpayable",
107+
type: "function",
108+
};
109+
110+
const encodeDepositForBurn = (params: {
111+
amount: BigNumber;
112+
destinationDomain: number;
113+
mintRecipient: string;
114+
burnToken: string;
115+
destinationCaller: string;
116+
maxFee: BigNumber; // Required, use BigNumber.from(0) for standard transfer
117+
minFinalityThreshold: number; // use 2000 for standard transfer
118+
}): string => {
119+
const iface = new ethers.utils.Interface([CCTP_DEPOSIT_FOR_BURN_ABI]);
120+
121+
return iface.encodeFunctionData("depositForBurn", [
122+
params.amount,
123+
params.destinationDomain,
124+
toBytes32(params.mintRecipient),
125+
params.burnToken,
126+
toBytes32(params.destinationCaller),
127+
params.maxFee,
128+
params.minFinalityThreshold,
129+
]);
130+
};
131+
132+
// CCTP estimated fill times in seconds
133+
// Soruce: https://developers.circle.com/cctp/required-block-confirmations
134+
const CCTP_FILL_TIME_ESTIMATES: Record<number, number> = {
135+
[CHAIN_IDs.MAINNET]: 19 * 60,
136+
[CHAIN_IDs.ARBITRUM]: 19 * 60,
137+
[CHAIN_IDs.BASE]: 19 * 60,
138+
[CHAIN_IDs.HYPEREVM]: 5,
139+
[CHAIN_IDs.INK]: 30 * 60,
140+
[CHAIN_IDs.OPTIMISM]: 19 * 60,
141+
[CHAIN_IDs.POLYGON]: 8,
142+
[CHAIN_IDs.SOLANA]: 25,
143+
[CHAIN_IDs.UNICHAIN]: 19 * 60,
144+
[CHAIN_IDs.WORLD_CHAIN]: 19 * 60,
145+
};
146+
147+
const capabilities: BridgeCapabilities = {
148+
ecosystems: ["evm", "svm"],
149+
supports: {
150+
A2A: false,
151+
A2B: false,
152+
B2A: false,
153+
B2B: true, // Only USDC-USDC routes are supported
154+
B2BI: false,
155+
crossChainMessage: false,
156+
},
157+
};
158+
159+
/**
160+
* CCTP (Cross-Chain Transfer Protocol) bridge strategy for native USDC transfers.
161+
* Supports Circle's CCTP for burning USDC on source chain and minting on destination chain.
162+
*/
163+
export function getCctpBridgeStrategy(): BridgeStrategy {
164+
const getEstimatedFillTime = (originChainId: number): number => {
165+
// CCTP fill time is determined by the origin chain attestation process
166+
return CCTP_FILL_TIME_ESTIMATES[originChainId] || 19 * 60; // Default to 19 minutes
167+
};
168+
169+
const isRouteSupported = (params: {
170+
inputToken: Token;
171+
outputToken: Token;
172+
}) => {
173+
// Check if input and output tokens are CCTP-supported
174+
const isInputTokenSupported = CCTP_SUPPORTED_TOKENS.some(
175+
(supportedToken) =>
176+
supportedToken.addresses[params.inputToken.chainId]?.toLowerCase() ===
177+
params.inputToken.address.toLowerCase()
178+
);
179+
180+
const isOutputTokenSupported = CCTP_SUPPORTED_TOKENS.some(
181+
(supportedToken) =>
182+
supportedToken.addresses[params.outputToken.chainId]?.toLowerCase() ===
183+
params.outputToken.address.toLowerCase()
184+
);
185+
186+
if (!isInputTokenSupported || !isOutputTokenSupported) {
187+
return false;
188+
}
189+
190+
// Check if both chains are CCTP-supported
191+
const isOriginChainSupported = CCTP_SUPPORTED_CHAINS.includes(
192+
params.inputToken.chainId
193+
);
194+
const isDestinationChainSupported = CCTP_SUPPORTED_CHAINS.includes(
195+
params.outputToken.chainId
196+
);
197+
198+
if (!isOriginChainSupported || !isDestinationChainSupported) {
199+
return false;
200+
}
201+
};
202+
203+
const assertSupportedRoute = (params: {
204+
inputToken: Token;
205+
outputToken: Token;
206+
}) => {
207+
if (!isRouteSupported(params)) {
208+
throw new InvalidParamError({
209+
message: `CCTP: Route ${params.inputToken.symbol} -> ${params.outputToken.symbol} is not supported`,
210+
});
211+
}
212+
};
213+
214+
return {
215+
name,
216+
capabilities,
217+
218+
originTxNeedsAllowance: true, // CCTP requires allowance for token burning
219+
220+
getCrossSwapTypes: (params: {
221+
inputToken: Token;
222+
outputToken: Token;
223+
isInputNative: boolean;
224+
isOutputNative: boolean;
225+
}) => {
226+
if (
227+
isRouteSupported({
228+
inputToken: params.inputToken,
229+
outputToken: params.outputToken,
230+
})
231+
) {
232+
return [CROSS_SWAP_TYPE.BRIDGEABLE_TO_BRIDGEABLE];
233+
}
234+
return [];
235+
},
236+
237+
getBridgeQuoteRecipient: (crossSwap: CrossSwap) => {
238+
return crossSwap.recipient;
239+
},
240+
241+
getBridgeQuoteMessage: (_crossSwap: CrossSwap, _appFee?: AppFee) => {
242+
return "0x";
243+
},
244+
245+
getQuoteForExactInput: async ({
246+
inputToken,
247+
outputToken,
248+
exactInputAmount,
249+
recipient: _recipient,
250+
message: _message,
251+
}: GetExactInputBridgeQuoteParams) => {
252+
assertSupportedRoute({ inputToken, outputToken });
253+
254+
const outputAmount = ConvertDecimals(
255+
inputToken.decimals,
256+
outputToken.decimals
257+
)(exactInputAmount);
258+
259+
return {
260+
bridgeQuote: {
261+
inputToken,
262+
outputToken,
263+
inputAmount: exactInputAmount,
264+
outputAmount,
265+
minOutputAmount: outputAmount,
266+
estimatedFillTimeSec: getEstimatedFillTime(inputToken.chainId),
267+
provider: name,
268+
fees: getCctpBridgeFees(inputToken),
269+
},
270+
};
271+
},
272+
273+
getQuoteForOutput: async ({
274+
inputToken,
275+
outputToken,
276+
minOutputAmount,
277+
forceExactOutput: _forceExactOutput,
278+
recipient: _recipient,
279+
message: _message,
280+
}: GetOutputBridgeQuoteParams) => {
281+
assertSupportedRoute({ inputToken, outputToken });
282+
283+
const inputAmount = ConvertDecimals(
284+
outputToken.decimals,
285+
inputToken.decimals
286+
)(minOutputAmount);
287+
288+
return {
289+
bridgeQuote: {
290+
inputToken,
291+
outputToken,
292+
inputAmount,
293+
outputAmount: minOutputAmount,
294+
minOutputAmount,
295+
estimatedFillTimeSec: getEstimatedFillTime(inputToken.chainId),
296+
provider: name,
297+
fees: getCctpBridgeFees(inputToken),
298+
},
299+
};
300+
},
301+
302+
buildTxForAllowanceHolder: async (params: {
303+
quotes: CrossSwapQuotes;
304+
integratorId?: string;
305+
}) => {
306+
const {
307+
bridgeQuote,
308+
crossSwap,
309+
originSwapQuote,
310+
destinationSwapQuote,
311+
appFee,
312+
} = params.quotes;
313+
314+
// CCTP validations
315+
if (appFee?.feeAmount.gt(0)) {
316+
throw new InvalidParamError({
317+
message: "CCTP: App fee handling not implemented yet",
318+
});
319+
}
320+
321+
if (originSwapQuote || destinationSwapQuote) {
322+
throw new InvalidParamError({
323+
message: "CCTP: Origin/destination swaps not implemented yet",
324+
});
325+
}
326+
327+
const originChainId = crossSwap.inputToken.chainId;
328+
const destinationChainId = crossSwap.outputToken.chainId;
329+
330+
// Get CCTP contract address for origin chain
331+
const tokenMessengerAddress = getCctpTokenMessengerAddress(originChainId);
332+
333+
// Get CCTP domain IDs
334+
const destinationDomain = getCctpDomainId(destinationChainId);
335+
336+
// Get burn token address (USDC on origin chain)
337+
const burnTokenAddress = crossSwap.inputToken.address;
338+
339+
// Encode the depositForBurn call
340+
const callData = encodeDepositForBurn({
341+
amount: bridgeQuote.inputAmount,
342+
destinationDomain,
343+
mintRecipient: crossSwap.recipient,
344+
burnToken: burnTokenAddress,
345+
destinationCaller: ethers.constants.AddressZero, // Anyone can finalize the message on domain when this is set to bytes32(0)
346+
maxFee: BigNumber.from(0), // maxFee set to 0 so this will be a "standard" speed transfer
347+
minFinalityThreshold: CCTP_FINALITY_THRESHOLDS.standard, // Hardcoded minFinalityThreshold value for standard transfer
348+
});
349+
350+
// Handle integrator ID and swap API marker tagging
351+
const callDataWithIntegratorId = params.integratorId
352+
? tagIntegratorId(params.integratorId, callData)
353+
: callData;
354+
const callDataWithMarkers = tagSwapApiMarker(callDataWithIntegratorId);
355+
356+
return {
357+
chainId: originChainId,
358+
from: crossSwap.depositor,
359+
to: tokenMessengerAddress,
360+
data: callDataWithMarkers,
361+
value: BigNumber.from(0), // No native value for USDC burns
362+
ecosystem: "evm" as const,
363+
};
364+
},
365+
};
366+
}
367+
368+
function getCctpBridgeFees(inputToken: Token) {
369+
const zeroBN = BigNumber.from(0);
370+
return {
371+
totalRelay: {
372+
pct: zeroBN,
373+
total: zeroBN,
374+
token: inputToken,
375+
},
376+
relayerCapital: {
377+
pct: zeroBN,
378+
total: zeroBN,
379+
token: inputToken,
380+
},
381+
relayerGas: {
382+
pct: zeroBN,
383+
total: zeroBN,
384+
token: inputToken,
385+
},
386+
lp: {
387+
pct: zeroBN,
388+
total: zeroBN,
389+
token: inputToken,
390+
},
391+
};
392+
}

0 commit comments

Comments
 (0)