Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
329 changes: 329 additions & 0 deletions src/utils/CCTPUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,329 @@
import { PUBLIC_NETWORKS, CCTP_NO_DOMAIN, PRODUCTION_NETWORKS, TEST_NETWORKS } from "@across-protocol/constants";
import { BigNumber, ethers } from "ethers";

import { Log } from "@ethersproject/abstract-provider";
import { isDefined } from "./TypeGuards";
import axios from "axios";
import { chainIsProd } from "./NetworkUtils";
import assert from "assert";
import { bnZero } from "./BigNumberUtils";
/** ********************************************************************************************************************
*
* CONSTANTS
*
******************************************************************************************************************* **/

export type CCTPMessageStatus = "finalized" | "ready" | "pending";
export const CCTPV2_FINALITY_THRESHOLD_STANDARD = 2000;
export const CCTPV2_FINALITY_THRESHOLD_FAST = 1000;
/** ********************************************************************************************************************
*
* CCTP SMART CONTRACT EVENT TYPES
*
******************************************************************************************************************* **/

// Params shared by Message and DepositForBurn events.
type CommonMessageData = {
// `cctpVersion` is nuanced. cctpVersion returned from API are 1 or 2 (v1 and v2 accordingly). The bytes responsible for a version within the message itself though are 0 or 1 (v1 and v2 accordingly) :\
cctpVersion: number;
sourceDomain: number;
destinationDomain: number;
sender: string;
recipient: string;
messageHash: string;
messageBytes: string;
nonce: number; // This nonce makes sense only for v1 events, as it's emitted on src chain send
nonceHash: string;
};
type DepositForBurnMessageData = CommonMessageData & { amount: string; mintRecipient: string; burnToken: string };
type CommonMessageEvent = CommonMessageData & { log: Log };
type DepositForBurnMessageEvent = DepositForBurnMessageData & { log: Log };
type CCTPMessageEvent = CommonMessageEvent | DepositForBurnMessageEvent;

const CCTP_MESSAGE_SENT_TOPIC_HASH = ethers.utils.id("MessageSent(bytes)");
const CCTP_DEPOSIT_FOR_BURN_TOPIC_HASH_V1 = ethers.utils.id(
"DepositForBurn(uint64,address,uint256,address,bytes32,uint32,bytes32,bytes32)"
);
const CCTP_DEPOSIT_FOR_BURN_TOPIC_HASH_V2 = ethers.utils.id(
"DepositForBurn(address,uint256,address,bytes32,uint32,bytes32,bytes32,uint256,uint32,bytes)"
);

/** ********************************************************************************************************************
*
* CCTP API TYPES
*
******************************************************************************************************************* **/

// CCTP V2 /burn/USDC/fees/{sourceDomainId}/{destDomainId} response
type CCTPV2APIGetFeesResponse = { finalityThreshold: number; minimumFee: number }[];

// CCTP V2 /fastBurn/USDC/allowance response
type CCTPV2APIGetFastBurnAllowanceResponse = { allowance: number };

// CCTP V2 /messages/{sourceDomainId} response
type CCTPV2APIAttestation = {
status: string;
attestation: string;
message: string;
eventNonce: string;
cctpVersion: number;
decodedMessage: {
recipient: string;
destinationDomain: number;
decodedMessageBody: {
amount: string;
mintRecipient: string;
messageSender: string;
};
};
};
type CCTPV2APIGetAttestationResponse = { messages: CCTPV2APIAttestation[] };

/** ********************************************************************************************************************
*
* Exported functions and constants:
*
******************************************************************************************************************* **/

export type AttestedCCTPMessage = CCTPMessageEvent & { status: CCTPMessageStatus; attestation?: string };
export type AttestedCCTPDeposit = DepositForBurnMessageEvent & { status: CCTPMessageStatus; attestation?: string };

/**
* @notice Converts an ETH Address string to a 32-byte hex string.
* @param address The address to convert.
* @returns The 32-byte hex string representation of the address - required for CCTP messages.
*/
export function cctpAddressToBytes32(address: string): string {
return ethers.utils.hexZeroPad(address, 32);
}

/**
* Converts a 32-byte hex string with padding to a standard ETH address.
* @param bytes32 The 32-byte hex string to convert.
* @returns The ETH address representation of the 32-byte hex string.
*/
export function cctpBytes32ToAddress(bytes32: string): string {
// Grab the last 20 bytes of the 32-byte hex string
return ethers.utils.getAddress(ethers.utils.hexDataSlice(bytes32, 12));
}

/**
* @notice Returns the CCTP domain for a given chain ID. Throws if the chain ID is not a CCTP domain.
* @param chainId
* @returns CCTP Domain ID
*/
export function getCctpDomainForChainId(chainId: number): number {
const cctpDomain = PUBLIC_NETWORKS[chainId]?.cctpDomain;
if (!isDefined(cctpDomain) || cctpDomain === CCTP_NO_DOMAIN) {
throw new Error(`No CCTP domain found for chainId: ${chainId}`);
}
return cctpDomain;
}

/**
* @notice Returns the chain ID for a given CCTP domain. Inverse functionof `getCctpDomainForChainId()`. However,
* since CCTP Domains are shared between production and test networks, we need to use the `productionNetworks` flag
* to determine whether to return the production or test network chain ID.
* @param domain CCTP domain ID.
* @param productionNetworks Whether to return the production or test network chain ID.
* @returns Chain ID.
*/
export function getCctpDestinationChainFromDomain(domain: number, productionNetworks: boolean): number {
if (domain === CCTP_NO_DOMAIN) {
throw new Error("Cannot input CCTP_NO_DOMAIN to getCctpDestinationChainFromDomain");
}
// Test and Production networks use the same CCTP domain, so we need to use the flag passed in to
// determine whether to use the Test or Production networks.
const networks = productionNetworks ? PRODUCTION_NETWORKS : TEST_NETWORKS;
const otherNetworks = productionNetworks ? TEST_NETWORKS : PRODUCTION_NETWORKS;
const chainId = Object.keys(networks).find(
(key) => networks[Number(key)].cctpDomain.toString() === domain.toString()
);
if (!isDefined(chainId)) {
const chainId = Object.keys(otherNetworks).find(
(key) => otherNetworks[Number(key)].cctpDomain.toString() === domain.toString()
);
if (!isDefined(chainId)) {
throw new Error(`No chainId found for domain: ${domain}`);
}
return parseInt(chainId);
}
return parseInt(chainId);
}

/**
* @notice Typeguard. Returns whether the event is a CCTP deposit for burn event. Should work for V1 and V2
* @param event CCTP message event.
* @returns True if the event is a CCTP deposit for burn event.
*/
export function isDepositForBurnEvent(event: CCTPMessageEvent): event is DepositForBurnMessageEvent {
return "amount" in event && "mintRecipient" in event && "burnToken" in event;
}

/**
* @notice Fetches CCTP V2 attestations for a given list of transaction hashes. If a transaction hash
* contains multiple CCTP messages, this will return an object where each key is a transaction hash and
* a value is an array of attestations.
* @param depositForBurnTxnHashes List of transaction hashes to fetch attestations for.
* @param sourceChainId Source chain ID of the transaction hashes.
* @returns Object with transaction hashes as keys and CCTP V2 attestations as values.
*/
export async function fetchCctpV2Attestations(
depositForBurnTxnHashes: string[],
sourceChainId: number
): Promise<{ [sourceTxnHash: string]: CCTPV2APIGetAttestationResponse }> {
// For v2, we fetch an API response for every txn hash we have. API returns an array of both v1 and v2 attestations
const sourceDomainId = getCctpDomainForChainId(sourceChainId);
const isMainnet = chainIsProd(sourceChainId);

// Circle rate limit is 35 requests / second. To avoid getting banned, batch calls into chunks with 1 second delay between chunks
// For v2, this is actually required because we don't know if message is finalized or not before hitting the API. Therefore as our
// CCTP v2 list of chains grows, we might require more than 35 calls here to fetch all attestations
const attestationResponses: { [sourceTxnHash: string]: CCTPV2APIGetAttestationResponse } = {};
const chunkSize = process.env.CCTP_API_REQUEST_CHUNK_SIZE ? parseInt(process.env.CCTP_API_REQUEST_CHUNK_SIZE) : 8;
for (let i = 0; i < depositForBurnTxnHashes.length; i += chunkSize) {
const chunk = depositForBurnTxnHashes.slice(i, i + chunkSize);

await Promise.all(
chunk.map(async (txHash) => {
const attestations = await fetchAttestationsForTxn(sourceDomainId, txHash, isMainnet);

// If multiple deposit for burn events, there will be multiple attestations.
attestationResponses[txHash] = attestations;
})
);

if (i + chunkSize < depositForBurnTxnHashes.length) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
return attestationResponses;
}

/**
* @notice Returns the status of a CCTP attestation.
* @param attestation Attestation to get the status of.
* @returns "finalized","pending" or "ready".
*/
export function getPendingAttestationStatus(attestation: CCTPV2APIAttestation): CCTPMessageStatus {
if (!isDefined(attestation.attestation)) {
return "pending";
} else {
return attestation.status === "pending_confirmations" || attestation.attestation === "PENDING"
? "pending"
: "ready";
}
}

/**
* @notice Checks if a CCTP message has been processed by a given contract.
* @param nonceHash Nonce hash to check.
* @param contract
* @returns True if the message has been processed, false otherwise.
*/
export async function hasCCTPMessageBeenProcessedEvm(nonceHash: string, contract: ethers.Contract): Promise<boolean> {
const resultingCall: BigNumber = await contract.callStatic.usedNonces(nonceHash);
// If the resulting call is 1, the message has been processed. If it is 0, the message has not been processed.
return (resultingCall ?? bnZero).toNumber() === 1;
}

/**
* @notice The maximum amount of USDC that can be sent using a fast transfer.
* @param isMainnet Toggles whether to call CCTP API on mainnet or sandbox environment.
* @returns USDC amount in units of USDC.
* @link https://developers.circle.com/api-reference/cctp/all/get-fast-burn-usdc-allowance
*/
export async function getV2FastBurnAllowance(isMainnet: boolean): Promise<string> {
const httpResponse = await axios.get<CCTPV2APIGetFastBurnAllowanceResponse>(
`https://iris-api${isMainnet ? "" : "-sandbox"}.circle.com/v2/fastBurn/USDC/allowance`
);
return httpResponse.data.allowance.toString();
}

/**
* Returns the minimum transfer fees required for a transfer to be relayed. When calling depositForBurn(), the maxFee
* parameter must be greater than or equal to the minimum fee.
* @param sourceChainId The source chain ID of the transfer.
* @param destinationChainId The destination chain ID of the transfer.
* @param isMainnet Toggles whether to call CCTP API on mainnet or sandbox environment.
* @returns The standard and fast transfer fees for the given source and destination chains.
* @link https://developers.circle.com/api-reference/cctp/all/get-burn-usdc-fees
*/
export async function getV2MinTransferFees(
sourceChainId: number,
destinationChainId: number
): Promise<{ standard: BigNumber; fast: BigNumber }> {
const isMainnet = chainIsProd(destinationChainId);
const sourceDomain = getCctpDomainForChainId(sourceChainId);
const destinationDomain = getCctpDomainForChainId(destinationChainId);
const endpoint = `https://iris-api${
isMainnet ? "" : "-sandbox"
}.circle.com/v2/burn/USDC/fees/${sourceDomain}/${destinationDomain}`;
const httpResponse = await axios.get<CCTPV2APIGetFeesResponse>(endpoint);
const standardFee = httpResponse.data.find((fee) => fee.finalityThreshold === CCTPV2_FINALITY_THRESHOLD_STANDARD);
assert(
isDefined(standardFee?.minimumFee),
`CCTPUtils#getTransferFees: Standard fee not found in API response: ${endpoint}`
);
const fastFee = httpResponse.data.find((fee) => fee.finalityThreshold === CCTPV2_FINALITY_THRESHOLD_FAST);
assert(isDefined(fastFee?.minimumFee), `CCTPUtils#getTransferFees: Fast fee not found in API response: ${endpoint}`);
return {
standard: BigNumber.from(standardFee.minimumFee),
fast: BigNumber.from(fastFee.minimumFee),
};
}

/**
* @notice Fetches attestations for a given transaction hash. If transaction hash contains multiple CCTP
* messages, this will return an array of attestations. Should work for both v1 and v2.
* @param sourceDomainId
* @param transactionHash
* @param isMainnet
* @returns Attestation response, list of messages with attestations.
*/
export async function fetchAttestationsForTxn(
sourceDomainId: number,
transactionHash: string,
isMainnet: boolean
): Promise<CCTPV2APIGetAttestationResponse> {
const httpResponse = await axios.get<CCTPV2APIGetAttestationResponse>(
`https://iris-api${
isMainnet ? "" : "-sandbox"
}.circle.com/v2/messages/${sourceDomainId}?transactionHash=${transactionHash}`
);

return httpResponse.data;
}

/**
* @notice Returns the CCTP version of the `MessageSent` event.
* @param log CCTP event log.
* @returns 0 for v1 `MessageSent` event, 1 for v2, -1 for other events
*/
export function getMessageSentVersion(log: ethers.providers.Log): number {
if (log.topics[0] !== CCTP_MESSAGE_SENT_TOPIC_HASH) {
return -1;
}
// v1 and v2 have the same topic hash, so we have to do a bit of decoding here to understand the version
const messageBytes = ethers.utils.defaultAbiCoder.decode(["bytes"], log.data)[0];
// Source: https://developers.circle.com/stablecoins/message-format
const version = parseInt(messageBytes.slice(2, 10), 16); // read version: first 4 bytes (skipping '0x')
return version;
}

/**
* @notice Returns the CCTP version of the `DepositForBurn` event.
* @param log CCTP event log.
* @returns 0 for v1 `DepositForBurn` event, 1 for v2, -1 for other events
*/
export function getDepositForBurnVersion(log: ethers.providers.Log): number {
const topic = log.topics[0];
switch (topic) {
case CCTP_DEPOSIT_FOR_BURN_TOPIC_HASH_V1:
return 0;
case CCTP_DEPOSIT_FOR_BURN_TOPIC_HASH_V2:
return 1;
default:
return -1;
}
}