0xtrails
Version:
SDK for Trails
1,067 lines (968 loc) • 28.4 kB
text/typescript
import type { Chain, PublicClient, WalletClient } from "viem"
import {
createPublicClient,
encodeFunctionData,
erc20Abi,
getAddress,
http,
maxUint256,
} from "viem"
import {
mainnet,
sepolia,
avalanche,
avalancheFuji,
optimism,
optimismSepolia,
arbitrum,
arbitrumSepolia,
base,
baseSepolia,
polygon,
polygonAmoy,
unichain,
unichainSepolia,
linea,
lineaSepolia,
worldchain,
worldchainSepolia,
} from "viem/chains"
import { attemptSwitchChain } from "./chainSwitch.js"
import { getIsTestnetChainId } from "./chains.js"
import { logger } from "./logger.js"
const domains: Record<number, number> = {
[mainnet.id]: 0,
[sepolia.id]: 0,
[avalanche.id]: 1,
[avalancheFuji.id]: 1,
[optimism.id]: 2,
[optimismSepolia.id]: 2,
[arbitrum.id]: 3,
[arbitrumSepolia.id]: 3,
[base.id]: 6,
[baseSepolia.id]: 6,
[polygon.id]: 7,
[polygonAmoy.id]: 7,
[unichain.id]: 10,
[unichainSepolia.id]: 10,
[linea.id]: 11,
[lineaSepolia.id]: 11,
[worldchain.id]: 14,
[worldchainSepolia.id]: 14,
}
const tokenAddresses: Record<number, string> = {
// Mainnet USDC addresses from Circle CCTP documentation
[mainnet.id]: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // Ethereum
[avalanche.id]: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", // Avalanche C-Chain
[arbitrum.id]: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", // Arbitrum
[base.id]: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // Base
[optimism.id]: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", // OP Mainnet
[polygon.id]: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", // Polygon PoS
[unichain.id]: "0x078D782b760474a361dDA0AF3839290b0EF57AD6", // Unichain
// Testnet USDC addresses from Circle CCTP documentation
[sepolia.id]: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", // Ethereum Sepolia
[avalancheFuji.id]: "0x5425890298aed601595a70AB815c96711a31Bc65", // Avalanche Fuji
[arbitrumSepolia.id]: "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d", // Arbitrum Sepolia
[baseSepolia.id]: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", // Base Sepolia
[optimismSepolia.id]: "0x5fd84259d66Cd46123540766Be93DFE6D43130D7", // OP Sepolia
[polygonAmoy.id]: "0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582", // Polygon PoS Amoy
[unichainSepolia.id]: "0x31d0220469e10c4E71834a79b1f276d740d3768F", // Unichain Sepolia
[lineaSepolia.id]: "0xFEce4462D57bD51A6A552365A011b95f0E16d9B7", // Linea Sepolia
[worldchainSepolia.id]: "0x66145f38cBAC35Ca6F1Dfb4914dF98F1614aeA88", // World Chain Sepolia
// Linea and World Chain mainnet (from Circle docs)
[linea.id]: "0x176211869cA2b568f2A7D4EE941E073a821EE1ff", // Linea
[worldchain.id]: "0x79A02482A880bCe3F13E09da970dC34dB4cD24D1", // World Chain
}
const tokenMessengers: Record<number, string> = {
[mainnet.id]: "0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d",
[sepolia.id]: "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA",
[avalanche.id]: "0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d",
[avalancheFuji.id]: "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA",
[optimism.id]: "0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d",
[optimismSepolia.id]: "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA",
[arbitrum.id]: "0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d",
[arbitrumSepolia.id]: "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA",
[base.id]: "0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d",
[baseSepolia.id]: "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA",
[polygon.id]: "0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d",
[polygonAmoy.id]: "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA",
[unichain.id]: "0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d",
[unichainSepolia.id]: "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA",
[linea.id]: "0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d",
[lineaSepolia.id]: "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA",
[worldchain.id]: "0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d",
[worldchainSepolia.id]: "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA",
}
const messageTransmitters: Record<number, string> = {
[mainnet.id]: "0x81D40F21F12A8F0E3252Bccb954D722d4c464B64",
[sepolia.id]: "0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275",
[avalanche.id]: "0x81D40F21F12A8F0E3252Bccb954D722d4c464B64",
[avalancheFuji.id]: "0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275",
[optimism.id]: "0x81D40F21F12A8F0E3252Bccb954D722d4c464B64",
[optimismSepolia.id]: "0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275",
[arbitrum.id]: "0x81D40F21F12A8F0E3252Bccb954D722d4c464B64",
[arbitrumSepolia.id]: "0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275",
[base.id]: "0x81D40F21F12A8F0E3252Bccb954D722d4c464B64",
[baseSepolia.id]: "0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275",
[polygon.id]: "0x81D40F21F12A8F0E3252Bccb954D722d4c464B64",
[polygonAmoy.id]: "0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275",
[unichain.id]: "0x81D40F21F12A8F0E3252Bccb954D722d4c464B64",
[unichainSepolia.id]: "0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275",
[linea.id]: "0x81D40F21F12A8F0E3252Bccb954D722d4c464B64",
[lineaSepolia.id]: "0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275",
[worldchain.id]: "0x81D40F21F12A8F0E3252Bccb954D722d4c464B64",
[worldchainSepolia.id]: "0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275",
}
const customCctpRelayerAddress: Record<number, string> = {
//[chains.arbitrumSepolia.id]: "0x58FEFe8A057E736CD361272BA2283DAfD8646198",
[arbitrumSepolia.id]: "0x05F3AcC7a7BB0e888Bb1bDE014bD61AfAfaC6943",
}
export type Attestation = {
attestation: `0x${string}`
message: `0x${string}`
}
export function getDomain(chainId: number): number | null {
return domains[chainId] ?? null
}
export function getUSDCTokenAddress(chainId: number): string | null {
return tokenAddresses[chainId] ?? null
}
export function getTokenMessenger(chainId: number): string | null {
return tokenMessengers[chainId] ?? null
}
export function getMessageTransmitter(chainId: number): string | null {
return messageTransmitters[chainId] ?? null
}
export async function cctpTransfer({
walletClient,
originChain,
destinationChain,
amount,
}: {
walletClient: WalletClient
originChain: Chain
destinationChain: Chain
amount: bigint
}): Promise<{
waitForAttestation: () => Promise<Attestation>
txHash: `0x${string}`
}> {
const originToken = getUSDCTokenAddress(originChain.id)
const originDomain = getDomain(originChain.id)
const destinationDomain = getDomain(destinationChain.id)
const originTokenMessenger = getTokenMessenger(originChain.id)
const destinationAddress = walletClient.account?.address
if (
!originToken ||
originDomain === null ||
!originTokenMessenger ||
destinationDomain === null ||
!destinationAddress
) {
logger.console.error(
"[trails-sdk] cctpTransfer: Invalid origin chain config",
{
originToken,
originDomain,
originTokenMessenger,
destinationDomain,
destinationAddress,
},
)
throw new Error("Invalid origin chain config")
}
const originClient = createPublicClient({
chain: originChain,
transport: http(),
})
await attemptSwitchChain({
walletClient,
desiredChainId: originChain.id,
})
const account = walletClient.account?.address
if (!account) {
throw new Error("No account found")
}
const needsApproval = await getNeedsApproval({
publicClient: originClient,
token: originToken,
account,
spender: originTokenMessenger,
amount: amount,
})
if (needsApproval) {
const txHash = await approveERC20({
walletClient,
tokenAddress: originToken,
spender: originTokenMessenger,
amount: maxUint256,
chain: originChain,
})
logger.console.log("waiting for approve", txHash)
await originClient.waitForTransactionReceipt({
hash: txHash,
})
logger.console.log("approve done")
}
const maxFee = getMaxFee()
const txHash = await burnUSDC({
walletClient,
tokenMessenger: originTokenMessenger,
destinationDomain,
destinationAddress,
amount,
burnToken: originToken,
maxFee,
chain: originChain,
})
return {
waitForAttestation: async () => {
await originClient.waitForTransactionReceipt({
hash: txHash,
})
const testnet = getIsTestnetChainId(originChain.id)
const attestation = await waitForAttestation({
domain: originDomain,
transactionHash: txHash,
testnet,
})
if (!attestation) {
throw new Error("Failed to retrieve attestation")
}
return attestation
},
txHash: txHash,
}
}
export async function cctpDestinationTx({
relayerClient,
destinationChain,
attestation,
}: {
relayerClient: WalletClient
destinationChain: Chain
attestation: Attestation
}): Promise<`0x${string}`> {
const destinationTokenMessenger = getMessageTransmitter(destinationChain.id)
if (!destinationTokenMessenger) {
throw new Error("Invalid destination chain")
}
await attemptSwitchChain({
walletClient: relayerClient,
desiredChainId: destinationChain.id,
})
const txHash = await mintUSDC({
walletClient: relayerClient,
tokenMessenger: destinationTokenMessenger,
attestation,
chain: destinationChain,
})
logger.console.log("[trails-sdk] minted USDC")
return txHash
}
export function getMaxFee(): bigint {
return 500n // Set fast transfer max fee in 10^6 subunits (0.0005 USDC; change as needed)
}
export async function getNeedsApproval({
publicClient,
token,
account,
spender,
amount,
}: {
publicClient: PublicClient
token: string
account: string
spender: string
amount: bigint
}): Promise<boolean> {
const allowance = await publicClient.readContract({
address: token as `0x${string}`,
abi: erc20Abi,
functionName: "allowance",
args: [getAddress(account), getAddress(spender)],
})
return allowance < amount
}
export async function approveERC20({
walletClient,
tokenAddress,
spender,
amount,
chain,
}: {
walletClient: WalletClient
tokenAddress: string
spender: string
amount: bigint
chain: Chain
}): Promise<`0x${string}`> {
const approvalData = await getApproveERC20Data({
tokenAddress,
spender,
amount,
})
logger.console.log("[trails-sdk] approving ERC20 transfer", approvalData)
await attemptSwitchChain({
walletClient,
desiredChainId: chain.id,
})
const account = walletClient.account?.address
if (!account) {
throw new Error("No account found")
}
const txHash = await walletClient.sendTransaction({
...approvalData,
account: account as `0x${string}`,
chain,
})
if (!txHash) {
throw new Error(
"No transaction hash returned from walletClient.sendTransaction",
)
}
return txHash
}
async function getApproveERC20Data({
tokenAddress,
spender,
amount,
}: {
tokenAddress: string
spender: string
amount: bigint
}): Promise<{ to: `0x${string}`; data: `0x${string}`; value: bigint }> {
logger.console.log("[trails-sdk] get approve ERC20 transfer data", {
tokenAddress,
spender,
amount,
})
return {
to: tokenAddress as `0x${string}`,
value: BigInt(0),
data: encodeFunctionData({
abi: [
{
type: "function",
name: "approve",
stateMutability: "nonpayable",
inputs: [
{ name: "spender", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ name: "", type: "bool" }],
},
],
functionName: "approve",
args: [spender as `0x${string}`, amount],
}),
}
}
export async function burnUSDC({
walletClient,
tokenMessenger,
destinationDomain,
destinationAddress,
amount,
burnToken,
maxFee,
chain,
}: {
walletClient: WalletClient
tokenMessenger: string
destinationDomain: number
destinationAddress: string
amount: bigint
burnToken: string
maxFee: bigint
chain: Chain
}): Promise<`0x${string}`> {
const burnData = await getBurnUSDCData({
tokenMessenger,
destinationDomain,
destinationAddress,
amount,
burnToken,
maxFee: maxFee,
})
await attemptSwitchChain({
walletClient,
desiredChainId: chain.id,
})
const account = walletClient.account?.address
if (!account) {
throw new Error("No account found")
}
return walletClient.sendTransaction({
...burnData,
account: account as `0x${string}`,
chain,
})
}
export async function getBurnUSDCData({
tokenMessenger,
destinationDomain,
destinationAddress,
amount,
burnToken,
maxFee,
}: {
tokenMessenger: string
destinationDomain: number
destinationAddress: string
amount: bigint
burnToken: string
maxFee: bigint
}): Promise<{ to: `0x${string}`; data: `0x${string}`; value: bigint }> {
logger.console.log("[trails-sdk] get burn USDC data", {
tokenMessenger,
destinationDomain,
destinationAddress,
amount,
burnToken,
maxFee,
})
// Bytes32 Formatted Parameters
const DESTINATION_ADDRESS_BYTES32 = `0x000000000000000000000000${destinationAddress.slice(2)}` // Destination address in bytes32 format
const DESTINATION_CALLER_BYTES32 =
"0x0000000000000000000000000000000000000000000000000000000000000000" // Empty bytes32 allows any address to call MessageTransmitterV2.receiveMessage()
return {
to: tokenMessenger as `0x${string}`,
value: BigInt(0),
data: encodeFunctionData({
abi: [
{
type: "function",
name: "depositForBurn",
stateMutability: "nonpayable",
inputs: [
{ name: "amount", type: "uint256" },
{ name: "destinationDomain", type: "uint32" },
{ name: "mintRecipient", type: "bytes32" },
{ name: "burnToken", type: "address" },
{ name: "destinationCaller", type: "bytes32" },
{ name: "maxFee", type: "uint256" },
{ name: "minFinalityThreshold", type: "uint32" },
],
outputs: [],
},
],
functionName: "depositForBurn",
args: [
amount,
destinationDomain,
DESTINATION_ADDRESS_BYTES32 as `0x${string}`,
burnToken as `0x${string}`,
DESTINATION_CALLER_BYTES32 as `0x${string}`,
maxFee,
1000, // minFinalityThreshold (1000 or less for Fast Transfer)
],
}),
}
}
export async function retrieveAttestation({
domain,
transactionHash,
testnet,
}: {
domain: number
transactionHash: `0x${string}`
testnet: boolean
}): Promise<Attestation | null> {
logger.console.log("[trails-sdk] retrieving attestation", {
domain,
transactionHash,
})
const url = `https://iris-api${testnet ? "-sandbox" : ""}.circle.com/v2/messages/${domain}?transactionHash=${transactionHash}`
while (true) {
try {
const response = await fetch(url)
const data = await response.json()
if (response.status === 404) {
logger.console.log("[trails-sdk] waiting for attestation...")
}
if (data?.messages?.[0]?.status === "complete") {
logger.console.log("[trails-sdk] attestation retrieved successfully!")
return data.messages[0]
}
logger.console.log("[trails-sdk] waiting for attestation...")
await new Promise((resolve) => setTimeout(resolve, 5000))
} catch (error: unknown) {
logger.console.error(
"[trails-sdk] error fetching attestation:",
error instanceof Error ? error.message : String(error),
)
await new Promise((resolve) => setTimeout(resolve, 5000))
}
}
}
export async function mintUSDC({
walletClient,
tokenMessenger,
attestation,
chain,
}: {
walletClient: WalletClient
tokenMessenger: string
attestation: Attestation
chain: Chain
}): Promise<`0x${string}`> {
const mintData = await getMintUSDCData({
tokenMessenger,
attestation,
})
await attemptSwitchChain({
walletClient,
desiredChainId: chain.id,
})
const account = walletClient.account?.address
if (!account) {
throw new Error("No account found")
}
return walletClient.sendTransaction({
...mintData,
account: account as `0x${string}`,
chain,
})
}
export async function getMintUSDCData({
tokenMessenger,
attestation,
}: {
tokenMessenger: string
attestation: Attestation
}): Promise<{ to: `0x${string}`; data: `0x${string}`; value: bigint }> {
logger.console.log("[trails-sdk] get mint USDC data", {
tokenMessenger,
attestation,
})
return {
to: tokenMessenger as `0x${string}`,
value: BigInt(0),
data: encodeFunctionData({
abi: [
{
type: "function",
name: "receiveMessage",
stateMutability: "nonpayable",
inputs: [
{ name: "message", type: "bytes" },
{ name: "attestation", type: "bytes" },
],
outputs: [],
},
],
functionName: "receiveMessage",
args: [attestation.message, attestation.attestation],
}),
}
}
export async function waitForAttestation({
domain,
transactionHash,
testnet,
}: {
domain: number
transactionHash: `0x${string}`
testnet: boolean
}): Promise<Attestation | null> {
while (true) {
const attestation = await retrieveAttestation({
domain,
transactionHash,
testnet,
})
if (attestation) {
return attestation
}
logger.console.log("[trails-sdk] waiting for attestation...")
await new Promise((resolve) => setTimeout(resolve, 1000))
}
}
export function getIsUsdcAddress(address: string, chainId: number): boolean {
return address?.toLowerCase() === tokenAddresses[chainId]?.toLowerCase()
}
export async function cctpTransferWithCustomCall({
walletClient,
originChain,
destinationChain,
amount,
}: {
walletClient: WalletClient
originChain: Chain
destinationChain: Chain
amount: bigint
}): Promise<{
waitForAttestation: () => Promise<Attestation>
txHash: `0x${string}`
}> {
const destinationContract = customCctpRelayerAddress[destinationChain.id]
if (!destinationContract) {
logger.console.error(
"[trails-sdk] cctpTransferWithCustomCall: No custom CCTP relayer address found for this chain",
{
originChain,
destinationChain,
},
)
throw new Error("No custom CCTP relayer address found for this chain")
}
const originToken = getUSDCTokenAddress(originChain.id)
const originDomain = getDomain(originChain.id)
const destinationDomain = getDomain(destinationChain.id)
const originTokenMessenger = getTokenMessenger(originChain.id)
if (
!originToken ||
originDomain === null ||
!originTokenMessenger ||
destinationDomain === null
) {
logger.console.error(
"[trails-sdk] cctpTransferWithCustomCall: Invalid origin chain config",
)
throw new Error("Invalid origin chain config")
}
const originClient = createPublicClient({
chain: originChain,
transport: http(),
})
await attemptSwitchChain({
walletClient,
desiredChainId: originChain.id,
})
const account = walletClient.account?.address
if (!account) {
throw new Error("No account found")
}
const needsApproval = await getNeedsApproval({
publicClient: originClient,
token: originToken,
account,
spender: originTokenMessenger,
amount: amount,
})
if (needsApproval) {
const txHash = await approveERC20({
walletClient,
tokenAddress: originToken,
spender: originTokenMessenger,
amount: maxUint256,
chain: originChain,
})
logger.console.log("waiting for approve", txHash)
await originClient.waitForTransactionReceipt({
hash: txHash,
})
logger.console.log("approve done")
}
const maxFee = getMaxFee()
// Send USDC to your CCTPRelayer contract instead of user wallet
const txHash = await burnUSDCToContract({
walletClient,
tokenMessenger: originTokenMessenger,
destinationDomain,
destinationContract, // CCTPRelayer contract address
amount,
burnToken: originToken,
maxFee,
chain: originChain,
})
return {
waitForAttestation: async () => {
await originClient.waitForTransactionReceipt({
hash: txHash,
})
const testnet = getIsTestnetChainId(originChain.id)
const attestation = await waitForAttestation({
domain: originDomain,
transactionHash: txHash,
testnet,
})
if (!attestation) {
throw new Error("Failed to retrieve attestation")
}
return attestation
},
txHash: txHash,
}
}
export async function burnUSDCToContract({
walletClient,
tokenMessenger,
destinationDomain,
destinationContract,
amount,
burnToken,
maxFee,
chain,
}: {
walletClient: WalletClient
tokenMessenger: string
destinationDomain: number
destinationContract: string
amount: bigint
burnToken: string
maxFee: bigint
chain: Chain
}): Promise<`0x${string}`> {
const burnData = await getBurnUSDCToContractData({
tokenMessenger,
destinationDomain,
destinationContract,
amount,
burnToken,
maxFee: maxFee,
})
await attemptSwitchChain({
walletClient,
desiredChainId: chain.id,
})
const account = walletClient.account?.address
if (!account) {
throw new Error("No account found")
}
return walletClient.sendTransaction({
...burnData,
account: account as `0x${string}`,
chain,
})
}
export async function getBurnUSDCToContractData({
tokenMessenger,
destinationDomain,
destinationContract,
amount,
burnToken,
maxFee,
}: {
tokenMessenger: string
destinationDomain: number
destinationContract: string
amount: bigint
burnToken: string
maxFee: bigint
}): Promise<{ to: `0x${string}`; data: `0x${string}`; value: bigint }> {
logger.console.log("[trails-sdk] get burn USDC to contract data", {
tokenMessenger,
destinationDomain,
destinationContract,
amount,
burnToken,
maxFee,
})
// Format destination contract address as bytes32
const DESTINATION_CONTRACT_BYTES32 = `0x000000000000000000000000${destinationContract.slice(2)}`
const DESTINATION_CALLER_BYTES32 =
"0x0000000000000000000000000000000000000000000000000000000000000000" // Empty bytes32 allows any address to call
return {
to: tokenMessenger as `0x${string}`,
value: BigInt(0),
data: encodeFunctionData({
abi: [
{
type: "function",
name: "depositForBurn",
stateMutability: "nonpayable",
inputs: [
{ name: "amount", type: "uint256" },
{ name: "destinationDomain", type: "uint32" },
{ name: "mintRecipient", type: "bytes32" },
{ name: "burnToken", type: "address" },
{ name: "destinationCaller", type: "bytes32" },
{ name: "maxFee", type: "uint256" },
{ name: "minFinalityThreshold", type: "uint32" },
],
outputs: [],
},
],
functionName: "depositForBurn",
args: [
amount,
destinationDomain,
DESTINATION_CONTRACT_BYTES32 as `0x${string}`,
burnToken as `0x${string}`,
DESTINATION_CALLER_BYTES32 as `0x${string}`,
maxFee,
1000, // minFinalityThreshold (1000 or less for Fast Transfer)
],
}),
}
}
export async function executeCustomCallWithCCTP({
relayerClient,
destinationChain,
attestation,
targetContract,
calldata,
gasLimit = 500000n,
}: {
relayerClient: WalletClient
destinationChain: Chain
attestation: Attestation
targetContract: string
calldata: `0x${string}`
gasLimit?: bigint
}): Promise<`0x${string}`> {
await attemptSwitchChain({
walletClient: relayerClient,
desiredChainId: destinationChain.id,
})
const account = relayerClient.account?.address
if (!account) {
throw new Error("No account found")
}
const cctpRelayerAddress = customCctpRelayerAddress[destinationChain.id]
if (!cctpRelayerAddress) {
throw new Error("No custom CCTP relayer address found for this chain")
}
const relayData = encodeFunctionData({
abi: [
{
type: "function",
name: "relayWithCustomCall",
inputs: [
{
type: "tuple",
name: "request",
components: [
{ name: "message", type: "bytes" },
{ name: "attestation", type: "bytes" },
{ name: "targetContract", type: "address" },
{ name: "data", type: "bytes" },
{ name: "gasLimit", type: "uint256" },
],
},
],
},
],
functionName: "relayWithCustomCall",
args: [
{
message: attestation.message,
attestation: attestation.attestation,
targetContract: targetContract as `0x${string}`,
data: calldata,
gasLimit,
},
],
})
return relayerClient.sendTransaction({
to: cctpRelayerAddress as `0x${string}`,
data: relayData,
account: account as `0x${string}`,
chain: destinationChain,
})
}
// Complete flow function
export async function cctpTransferCaller({
walletClient,
relayerClient, // Can be same as walletClient or different
originChain,
destinationChain,
amount,
targetContract, // The contract you want to call
calldata, // The function call data
gasLimit = 500000n,
}: {
walletClient: WalletClient
relayerClient?: WalletClient
originChain: Chain
destinationChain: Chain
amount: bigint
targetContract: string
calldata: `0x${string}`
gasLimit?: bigint
}): Promise<{
burnTxHash: `0x${string}`
executeTxHash: `0x${string}`
}> {
// Use walletClient as relayerClient if not provided
const actualRelayerClient = relayerClient || walletClient
logger.console.log("[trails-sdk] Starting CCTP transfer with custom call")
// Step 1: Burn USDC on origin chain (send to CCTPRelayer)
const { waitForAttestation, txHash: burnTxHash } =
await cctpTransferWithCustomCall({
walletClient,
originChain,
destinationChain,
amount,
})
logger.console.log("[trails-sdk] Burn transaction sent:", burnTxHash)
// Step 2: Wait for attestation
logger.console.log("[trails-sdk] Waiting for attestation...")
const attestation = await waitForAttestation()
logger.console.log("[trails-sdk] Attestation received, executing custom call")
// Step 3: Execute the relayed call on destination chain
const executeTxHash = await executeCustomCallWithCCTP({
relayerClient: actualRelayerClient,
destinationChain,
attestation,
targetContract,
calldata,
gasLimit,
})
logger.console.log("[trails-sdk] Custom call executed:", executeTxHash)
return {
burnTxHash,
executeTxHash,
}
}
export async function getCCTPRelayerCallData({
attestation,
targetContract,
calldata,
gasLimit = 500000n,
destinationChain,
}: {
attestation: Attestation
targetContract: string
calldata: `0x${string}`
gasLimit?: bigint
destinationChain: Chain
}): Promise<{ to: `0x${string}`; data: `0x${string}`; value: bigint }> {
const cctpRelayerAddress = customCctpRelayerAddress[destinationChain.id]
if (!cctpRelayerAddress) {
throw new Error("No custom CCTP relayer address found for this chain")
}
logger.console.log("[trails-sdk] get CCTP relayer call data", {
cctpRelayerAddress,
targetContract,
gasLimit,
})
const relayData = encodeFunctionData({
abi: [
{
type: "function",
name: "relayWithCustomCall",
inputs: [
{
type: "tuple",
name: "request",
components: [
{ name: "message", type: "bytes" },
{ name: "attestation", type: "bytes" },
{ name: "targetContract", type: "address" },
{ name: "data", type: "bytes" },
{ name: "gasLimit", type: "uint256" },
],
},
],
},
],
functionName: "relayWithCustomCall",
args: [
{
message: attestation.message,
attestation: attestation.attestation,
targetContract: targetContract as `0x${string}`,
data: calldata,
gasLimit,
},
],
})
return {
to: cctpRelayerAddress as `0x${string}`,
data: relayData,
value: BigInt(0),
}
}