Skip to content

Commit 050b3a8

Browse files
authored
feat: enable burn/mint (#2002)
1 parent db34e96 commit 050b3a8

File tree

42 files changed

+2454
-182
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+2454
-182
lines changed

api/_bridges/cctp/strategy.ts

Lines changed: 105 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BigNumber, ethers, utils } from "ethers";
1+
import { BigNumber, utils } from "ethers";
22
import * as sdk from "@across-protocol/sdk";
33
import { TokenMessengerMinterV2Client } from "@across-protocol/contracts";
44
import {
@@ -18,9 +18,8 @@ import {
1818
BridgeCapabilities,
1919
GetOutputBridgeQuoteParams,
2020
} from "../types";
21-
import { CrossSwap, CrossSwapQuotes } from "../../_dexes/types";
21+
import { CrossSwap, CrossSwapQuotes, Token } from "../../_dexes/types";
2222
import { AppFee, CROSS_SWAP_TYPE } from "../../_dexes/utils";
23-
import { Token } from "../../_dexes/types";
2423
import { InvalidParamError } from "../../_errors";
2524
import { ConvertDecimals } from "../../_utils";
2625
import {
@@ -34,12 +33,14 @@ import {
3433
CCTP_SUPPORTED_CHAINS,
3534
CCTP_SUPPORTED_TOKENS,
3635
CCTP_FINALITY_THRESHOLDS,
37-
CCTP_FILL_TIME_ESTIMATES,
36+
DEFAULT_CCTP_ACROSS_FINALIZER_ADDRESS,
3837
getCctpTokenMessengerAddress,
3938
getCctpMessageTransmitterAddress,
4039
getCctpDomainId,
4140
encodeDepositForBurn,
4241
} from "./utils/constants";
42+
import { getEstimatedFillTime, getTransferMode } from "./utils/fill-times";
43+
import { getCctpFees } from "./utils/fees";
4344

4445
const name = "cctp";
4546

@@ -59,12 +60,9 @@ const capabilities: BridgeCapabilities = {
5960
* CCTP (Cross-Chain Transfer Protocol) bridge strategy for native USDC transfers.
6061
* Supports Circle's CCTP for burning USDC on source chain.
6162
*/
62-
export function getCctpBridgeStrategy(): BridgeStrategy {
63-
const getEstimatedFillTime = (originChainId: number): number => {
64-
// CCTP fill time is determined by the origin chain attestation process
65-
return CCTP_FILL_TIME_ESTIMATES[originChainId] || 19 * 60; // Default to 19 minutes
66-
};
67-
63+
export function getCctpBridgeStrategy(
64+
requestedTransferMode: "standard" | "fast" = "fast"
65+
): BridgeStrategy {
6866
const isRouteSupported = (params: {
6967
inputToken: Token;
7068
outputToken: Token;
@@ -91,7 +89,12 @@ export function getCctpBridgeStrategy(): BridgeStrategy {
9189
const isDestinationChainSupported = CCTP_SUPPORTED_CHAINS.includes(
9290
params.outputToken.chainId
9391
);
94-
if (!isOriginChainSupported || !isDestinationChainSupported) {
92+
if (
93+
!isOriginChainSupported ||
94+
!isDestinationChainSupported ||
95+
// NOTE: Our finalizer doesn't support destination Solana yet. Block the route until we do.
96+
sdk.utils.chainIsSvm(params.outputToken.chainId)
97+
) {
9598
return false;
9699
}
97100

@@ -149,10 +152,33 @@ export function getCctpBridgeStrategy(): BridgeStrategy {
149152
}: GetExactInputBridgeQuoteParams) => {
150153
assertSupportedRoute({ inputToken, outputToken });
151154

155+
let maxFee = BigNumber.from(0);
156+
const transferMode = await getTransferMode(
157+
inputToken.chainId,
158+
requestedTransferMode,
159+
exactInputAmount,
160+
inputToken.decimals
161+
);
162+
163+
if (transferMode === "fast") {
164+
const { transferFeeBps, forwardFee } = await getCctpFees({
165+
inputToken,
166+
outputToken,
167+
transferMode,
168+
});
169+
170+
// Calculate actual fee:
171+
// transferFee = input * (bps / 10000)
172+
// maxFee = transferFee + forwardFee
173+
const transferFee = exactInputAmount.mul(transferFeeBps).div(10000);
174+
maxFee = transferFee.add(forwardFee);
175+
}
176+
177+
const remainingInputAmount = exactInputAmount.sub(maxFee);
152178
const outputAmount = ConvertDecimals(
153179
inputToken.decimals,
154180
outputToken.decimals
155-
)(exactInputAmount);
181+
)(remainingInputAmount);
156182

157183
return {
158184
bridgeQuote: {
@@ -161,9 +187,16 @@ export function getCctpBridgeStrategy(): BridgeStrategy {
161187
inputAmount: exactInputAmount,
162188
outputAmount,
163189
minOutputAmount: outputAmount,
164-
estimatedFillTimeSec: getEstimatedFillTime(inputToken.chainId),
190+
estimatedFillTimeSec: getEstimatedFillTime(
191+
inputToken.chainId,
192+
transferMode
193+
),
165194
provider: name,
166-
fees: getCctpBridgeFees(inputToken),
195+
fees: getCctpBridgeFees({
196+
inputToken,
197+
inputAmount: exactInputAmount,
198+
maxFee,
199+
}),
167200
},
168201
};
169202
},
@@ -178,10 +211,38 @@ export function getCctpBridgeStrategy(): BridgeStrategy {
178211
}: GetOutputBridgeQuoteParams) => {
179212
assertSupportedRoute({ inputToken, outputToken });
180213

181-
const inputAmount = ConvertDecimals(
214+
let inputAmount = ConvertDecimals(
182215
outputToken.decimals,
183216
inputToken.decimals
184217
)(minOutputAmount);
218+
let maxFee = BigNumber.from(0);
219+
220+
const transferMode = await getTransferMode(
221+
inputToken.chainId,
222+
requestedTransferMode,
223+
inputAmount,
224+
inputToken.decimals
225+
);
226+
227+
if (transferMode === "fast") {
228+
const { transferFeeBps, forwardFee } = await getCctpFees({
229+
inputToken,
230+
outputToken,
231+
transferMode,
232+
});
233+
234+
// Solve for required input based on the following equation:
235+
// inputAmount - (inputAmount * bps / 10000) - forwardFee = amountToArriveOnDestination
236+
// Rearranging: inputAmount * (1 - bps/10000) = amountToArriveOnDestination + forwardFee
237+
// Therefore: inputAmount = (amountToArriveOnDestination + forwardFee) * 10000 / (10000 - bps)
238+
// Note: 10000 converts basis points to the same scale as amounts (1 bps = 1/10000 of the total)
239+
const bpsFactor = BigNumber.from(10000).sub(transferFeeBps);
240+
inputAmount = inputAmount.add(forwardFee).mul(10000).div(bpsFactor);
241+
242+
// Calculate total CCTP fee (transfer fee + forward fee)
243+
const transferFee = inputAmount.mul(transferFeeBps).div(10000);
244+
maxFee = transferFee.add(forwardFee);
245+
}
185246

186247
return {
187248
bridgeQuote: {
@@ -190,9 +251,16 @@ export function getCctpBridgeStrategy(): BridgeStrategy {
190251
inputAmount,
191252
outputAmount: minOutputAmount,
192253
minOutputAmount,
193-
estimatedFillTimeSec: getEstimatedFillTime(inputToken.chainId),
254+
estimatedFillTimeSec: getEstimatedFillTime(
255+
inputToken.chainId,
256+
transferMode
257+
),
194258
provider: name,
195-
fees: getCctpBridgeFees(inputToken),
259+
fees: getCctpBridgeFees({
260+
inputToken,
261+
inputAmount,
262+
maxFee,
263+
}),
196264
},
197265
};
198266
},
@@ -227,15 +295,23 @@ export function getCctpBridgeStrategy(): BridgeStrategy {
227295
const destinationChainId = crossSwap.outputToken.chainId;
228296
const destinationDomain = getCctpDomainId(destinationChainId);
229297
const tokenMessenger = getCctpTokenMessengerAddress(originChainId);
298+
// Circle's API returns a minimum fee. Add 1 unit as buffer to ensure the transfer meets the threshold for fast mode eligibility.
299+
const hasFastFee = bridgeQuote.fees.amount.gt(0);
300+
const maxFee = hasFastFee
301+
? bridgeQuote.fees.amount.add(1)
302+
: bridgeQuote.fees.amount;
303+
const minFinalityThreshold = hasFastFee
304+
? CCTP_FINALITY_THRESHOLDS.fast
305+
: CCTP_FINALITY_THRESHOLDS.standard;
230306

231307
// depositForBurn input parameters
232308
const depositForBurnParams = {
233309
amount: bridgeQuote.inputAmount,
234310
destinationDomain,
235311
mintRecipient: crossSwap.recipient,
236-
destinationCaller: ethers.constants.AddressZero, // Anyone can finalize the message on domain when this is set to bytes32(0)
237-
maxFee: BigNumber.from(0), // maxFee set to 0 so this will be a "standard" speed transfer
238-
minFinalityThreshold: CCTP_FINALITY_THRESHOLDS.standard, // Hardcoded minFinalityThreshold value for standard transfer
312+
destinationCaller: DEFAULT_CCTP_ACROSS_FINALIZER_ADDRESS,
313+
maxFee,
314+
minFinalityThreshold,
239315
};
240316

241317
if (crossSwap.isOriginSvm) {
@@ -263,15 +339,19 @@ export function getCctpBridgeStrategy(): BridgeStrategy {
263339
};
264340
}
265341

266-
function getCctpBridgeFees(inputToken: Token) {
267-
const zeroBN = BigNumber.from(0);
342+
function getCctpBridgeFees(params: {
343+
inputToken: Token;
344+
inputAmount: BigNumber;
345+
maxFee?: BigNumber;
346+
}) {
347+
const { inputToken, inputAmount, maxFee = BigNumber.from(0) } = params;
348+
const pct = maxFee.mul(sdk.utils.fixedPointAdjustment).div(inputAmount);
268349
return {
269-
pct: zeroBN,
270-
amount: zeroBN,
350+
pct,
351+
amount: maxFee,
271352
token: inputToken,
272353
};
273354
}
274-
275355
/**
276356
* Builds CCTP deposit transaction for EVM chains
277357
*/

api/_bridges/cctp/utils/constants.ts

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ export const CCTP_SUPPORTED_CHAINS = [
99
CHAIN_IDs.ARBITRUM,
1010
CHAIN_IDs.BASE,
1111
CHAIN_IDs.HYPEREVM,
12-
CHAIN_IDs.INK,
12+
CHAIN_IDs.LINEA,
13+
CHAIN_IDs.MONAD,
1314
CHAIN_IDs.OPTIMISM,
1415
CHAIN_IDs.POLYGON,
1516
CHAIN_IDs.SOLANA,
@@ -34,6 +35,10 @@ export const CCTP_FINALITY_THRESHOLDS = {
3435
standard: 2000,
3536
};
3637

38+
// CCTP Across Finalizer address
39+
export const DEFAULT_CCTP_ACROSS_FINALIZER_ADDRESS =
40+
"0x72adB07A487f38321b6665c02D289C413610B081";
41+
3742
// CCTP TokenMessenger contract addresses
3843
// Source: https://developers.circle.com/cctp/evm-smart-contracts
3944
const DEFAULT_CCTP_TOKEN_MESSENGER_ADDRESS =
@@ -133,18 +138,3 @@ export const encodeDepositForBurn = (params: {
133138
params.minFinalityThreshold,
134139
]);
135140
};
136-
137-
// CCTP estimated fill times in seconds
138-
// Soruce: https://developers.circle.com/cctp/required-block-confirmations
139-
export const CCTP_FILL_TIME_ESTIMATES: Record<number, number> = {
140-
[CHAIN_IDs.MAINNET]: 19 * 60,
141-
[CHAIN_IDs.ARBITRUM]: 19 * 60,
142-
[CHAIN_IDs.BASE]: 19 * 60,
143-
[CHAIN_IDs.HYPEREVM]: 5,
144-
[CHAIN_IDs.INK]: 30 * 60,
145-
[CHAIN_IDs.OPTIMISM]: 19 * 60,
146-
[CHAIN_IDs.POLYGON]: 8,
147-
[CHAIN_IDs.SOLANA]: 25,
148-
[CHAIN_IDs.UNICHAIN]: 19 * 60,
149-
[CHAIN_IDs.WORLD_CHAIN]: 19 * 60,
150-
};

api/_bridges/cctp/utils/fees.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { BigNumber } from "ethers";
2+
import axios from "axios";
3+
4+
import { CCTP_FINALITY_THRESHOLDS, getCctpDomainId } from "./constants";
5+
import { Token } from "../../../_dexes/types";
6+
7+
/**
8+
* CCTP fee configuration type from Circle API
9+
*/
10+
type CctpFeeConfig = {
11+
finalityThreshold: number;
12+
minimumFee: number; // in bps
13+
forwardFee: {
14+
low: number; // in token units
15+
med: number;
16+
high: number;
17+
};
18+
};
19+
20+
/**
21+
* Queries Circle API to fetch CCTP fees for the specified finality threshold.
22+
*
23+
* Transfer fee: Variable fee in basis points of the transfer amount, collected at minting time.
24+
* - 0 bps for standard transfers (finality threshold > 1000)
25+
* - Varies by origin chain for fast transfers (finality threshold ≤ 1000)
26+
* - See: https://developers.circle.com/cctp/technical-guide#fees
27+
*
28+
* Forward fee: Fixed fee in token units charged when routing through CCTP forwarder (e.g., to HyperCore).
29+
* - Applies only to forwarded transfers via depositForBurnWithHook
30+
* - Returned in token decimals (e.g., 6 decimals for USDC)
31+
*
32+
* @param params.inputToken - Input token with chainId
33+
* @param params.outputToken - Output token with chainId
34+
* @param params.transferMode - Transfer mode: "standard" or "fast"
35+
* @param params.useSandbox - Whether to use the sandbox environment
36+
* @param params.useForwardFee - Whether to use the forward fee
37+
* @returns transferFeeBps (basis points) and forwardFee (in token units)
38+
*/
39+
export async function getCctpFees(params: {
40+
inputToken: Token;
41+
outputToken: Token;
42+
transferMode: "standard" | "fast";
43+
useSandbox?: boolean;
44+
useForwardFee?: boolean;
45+
}): Promise<{
46+
transferFeeBps: number;
47+
forwardFee: BigNumber;
48+
}> {
49+
const { inputToken, outputToken, transferMode, useSandbox, useForwardFee } =
50+
params;
51+
52+
// Get CCTP domain IDs
53+
const sourceDomainId = getCctpDomainId(inputToken.chainId);
54+
const destDomainId = getCctpDomainId(outputToken.chainId);
55+
56+
const endpoint = useSandbox ? "iris-api-sandbox" : "iris-api";
57+
const url = `https://${endpoint}.circle.com/v2/burn/USDC/fees/${sourceDomainId}/${destDomainId}`;
58+
const response = await axios.get<CctpFeeConfig[]>(url, {
59+
params: useForwardFee ? { forward: true } : undefined,
60+
});
61+
62+
const finalityThreshold = CCTP_FINALITY_THRESHOLDS[transferMode];
63+
64+
// Find config matching the requested finality threshold
65+
const transferConfig = response.data.find(
66+
(config) => config.finalityThreshold === finalityThreshold
67+
);
68+
69+
if (!transferConfig) {
70+
throw new Error(
71+
`Fee configuration not found for finality threshold ${finalityThreshold} in CCTP fee response`
72+
);
73+
}
74+
75+
const forwardFee = useForwardFee ? transferConfig.forwardFee.med : 0;
76+
77+
return {
78+
transferFeeBps: transferConfig.minimumFee,
79+
forwardFee: BigNumber.from(forwardFee),
80+
};
81+
}

0 commit comments

Comments
 (0)