#!/usr/bin/env node
/**
* Script: Call CctpExtension.batchDepositForBurnWithAuth
* - Generates EIP-3009 receiveWithAuthorization signature
* - Executes a single or batched CCTP burn via the extension
* - Supports Forwarder hook data to auto-forward to a final recipient
*
* Requires: npm i ethers
* Ethers v6
*/
const { ethers } = require("ethers");
const crypto = require("crypto");
// -------- Configuration (env or inline) --------
const CONFIG = {
RPC_URL: process.env.RPC_URL || "https://sepolia-rollup.arbitrum.io/rpc",
PRIVATE_KEY: process.env.PRIVATE_KEY || "PRIVATE_KEY_HERE",
// Contracts
CCTP_EXTENSION:
process.env.CCTP_EXTENSION || "0x8E4e3d0E95C1bEC4F3eC7F69aa48473E0Ab6eB8D",
USDC_TOKEN:
process.env.USDC_TOKEN || "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d",
// Amounts (USDC has 6 decimals)
TOTAL_AMOUNT_USDC: process.env.TOTAL_AMOUNT_USDC || "10", // total to authorize and burn
BATCH_SIZE_USDC: process.env.BATCH_SIZE_USDC || "10", // per burn (must divide TOTAL exactly)
// CCTP params
DEST_DOMAIN: Number(process.env.DEST_DOMAIN || 19), // destination CCTP domain id
DEST_MINT_RECIPIENT: (
process.env.DEST_MINT_RECIPIENT ||
"0x02e39ECb8368b41bF68FF99ff351aC9864e5E2a2"
).toLowerCase(), // CCTP Forwader Contract address
DEST_CALLER:
process.env.DEST_CALLER || "0x02e39ECb8368b41bF68FF99ff351aC9864e5E2a2", // CCTP Forwader Contract address
MAX_FEE_USDC: process.env.MAX_FEE_USDC || "0.5", // max fee (in USDC units)
MIN_FINALITY_THRESHOLD: Number(process.env.MIN_FINALITY_THRESHOLD || 1000),
// EIP-3009 validity window (unix seconds)
VALID_AFTER: Number(
process.env.VALID_AFTER || Math.floor(Date.now() / 1000) - 3600,
),
VALID_BEFORE: Number(
process.env.VALID_BEFORE || Math.floor(Date.now() / 1000) + 3600,
),
// Optional hook data (set to "0x" for none). If omitted and FORWARD_RECIPIENT is set, we will build forwarder hook data to forwad automatically from ARB -> HyperEVM -> HyperCore.
HOOK_DATA_HEX: process.env.HOOK_DATA_HEX || "0x",
// Optional: final recipient to forward to via Forwarder hook (EVM address on destination chain)
FORWARD_RECIPIENT:
process.env.FORWARD_RECIPIENT ||
"0x74328769aD582E2eEA366936f80bb101c7ee5A55",
};
// -------- ABIs (minimal) --------
const CCTP_EXTENSION_ABI = [
{
inputs: [
{
components: [
{ internalType: "uint256", name: "amount", type: "uint256" },
{ internalType: "uint256", name: "authValidAfter", type: "uint256" },
{ internalType: "uint256", name: "authValidBefore", type: "uint256" },
{ internalType: "bytes32", name: "authNonce", type: "bytes32" },
{ internalType: "uint8", name: "v", type: "uint8" },
{ internalType: "bytes32", name: "r", type: "bytes32" },
{ internalType: "bytes32", name: "s", type: "bytes32" },
],
internalType: "struct ICctpExtension.ReceiveWithAuthorizationData",
name: "_receiveWithAuthorizationData",
type: "tuple",
},
{
components: [
{ internalType: "uint256", name: "amount", type: "uint256" },
{ internalType: "uint32", name: "destinationDomain", type: "uint32" },
{ internalType: "bytes32", name: "mintRecipient", type: "bytes32" },
{
internalType: "bytes32",
name: "destinationCaller",
type: "bytes32",
},
{ internalType: "uint256", name: "maxFee", type: "uint256" },
{
internalType: "uint32",
name: "minFinalityThreshold",
type: "uint32",
},
{ internalType: "bytes", name: "hookData", type: "bytes" },
],
internalType: "struct ICctpExtension.DepositForBurnWithHookData",
name: "_depositForBurnData",
type: "tuple",
},
],
name: "batchDepositForBurnWithAuth",
outputs: [],
stateMutability: "nonpayable",
type: "function",
},
];
const USDC_ABI = [
{
inputs: [],
name: "DOMAIN_SEPARATOR",
outputs: [{ internalType: "bytes32", name: "", type: "bytes32" }],
stateMutability: "view",
type: "function",
},
{
inputs: [{ internalType: "address", name: "account", type: "address" }],
name: "balanceOf",
outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
stateMutability: "view",
type: "function",
},
{
inputs: [],
name: "decimals",
outputs: [{ internalType: "uint8", name: "", type: "uint8" }],
stateMutability: "view",
type: "function",
},
];
// -------- EIP-3009 constants --------
const RECEIVE_WITH_AUTHORIZATION_TYPEHASH = ethers.keccak256(
ethers.toUtf8Bytes(
"ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)",
),
);
// -------- Helpers --------
function toUSDC(amountStr) {
return ethers.parseUnits(amountStr, 6);
}
function randomNonce32() {
return ethers.hexlify(crypto.randomBytes(32));
}
function toBytes32Address(addr) {
return ethers.zeroPadValue(ethers.getAddress(addr), 32);
}
// Build Forwarder hook data (CctpForwarderHookData spec):
// Bytes 0-23: "cctp-forward" (right-padded with zeros)
// Bytes 24-27: uint32 version = 0 (big-endian)
// Bytes 28-31: uint32 length = 24 (big-endian)
// Bytes 32-51: address forwardRecipient (20 bytes)
// Bytes 52-55: uint32 destinationId (big-endian)
function generateForwarderHookData(forwardRecipientAddress, destinationId = 0) {
const magicText = "cctp-forward";
const magicBytes = new Uint8Array(24);
for (let i = 0; i < magicText.length; i++) {
magicBytes[i] = magicText.charCodeAt(i);
}
const version = new Uint8Array([0, 0, 0, 0]);
const length = new Uint8Array([0, 0, 0, 24]);
const recipient = ethers.getBytes(ethers.getAddress(forwardRecipientAddress));
const dest = new Uint8Array(4);
// big-endian uint32
dest[0] = (destinationId >>> 24) & 0xff;
dest[1] = (destinationId >>> 16) & 0xff;
dest[2] = (destinationId >>> 8) & 0xff;
dest[3] = destinationId & 0xff;
return ethers.concat([magicBytes, version, length, recipient, dest]);
}
async function getDomainSeparator(usdc) {
try {
return await usdc.DOMAIN_SEPARATOR();
} catch {
// Fallback build (commonly works for USDC v2-style)
const domain = {
name: "USD Coin",
version: "2",
chainId: await usdc.provider.getNetwork().then((n) => n.chainId),
verifyingContract: await usdc.getAddress(),
};
return ethers._TypedDataEncoder.hashDomain(domain);
}
}
async function signReceiveWithAuthorization({
from,
to,
value,
validAfter,
validBefore,
nonce,
domainSeparator,
privateKey,
}) {
const structHash = ethers.keccak256(
ethers.AbiCoder.defaultAbiCoder().encode(
[
"bytes32",
"address",
"address",
"uint256",
"uint256",
"uint256",
"bytes32",
],
[
RECEIVE_WITH_AUTHORIZATION_TYPEHASH,
from,
to,
value,
validAfter,
validBefore,
nonce,
],
),
);
const digest = ethers.keccak256(
ethers.concat(["0x1901", domainSeparator, structHash]),
);
const signer = new ethers.SigningKey(privateKey);
const sig = signer.sign(digest);
return { v: sig.v, r: sig.r, s: sig.s };
}
// -------- Main --------
async function main() {
const {
RPC_URL,
PRIVATE_KEY,
CCTP_EXTENSION,
USDC_TOKEN,
TOTAL_AMOUNT_USDC,
BATCH_SIZE_USDC,
DEST_DOMAIN,
DEST_MINT_RECIPIENT,
DEST_CALLER,
MAX_FEE_USDC,
MIN_FINALITY_THRESHOLD,
VALID_AFTER,
VALID_BEFORE,
HOOK_DATA_HEX,
FORWARD_RECIPIENT,
} = CONFIG;
if (!ethers.isAddress(CCTP_EXTENSION))
throw new Error("Invalid CCTP_EXTENSION");
if (!ethers.isAddress(USDC_TOKEN)) throw new Error("Invalid USDC_TOKEN");
if (!ethers.isAddress(DEST_MINT_RECIPIENT))
throw new Error("Invalid DEST_MINT_RECIPIENT address");
if (!PRIVATE_KEY || PRIVATE_KEY === "0xYourPrivateKey")
throw new Error("Set PRIVATE_KEY");
const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
const from = wallet.address;
const extension = new ethers.Contract(
CCTP_EXTENSION,
CCTP_EXTENSION_ABI,
wallet,
);
const usdc = new ethers.Contract(USDC_TOKEN, USDC_ABI, provider);
const totalAmount = toUSDC(TOTAL_AMOUNT_USDC);
const batchSize = toUSDC(BATCH_SIZE_USDC);
const maxFee = toUSDC(MAX_FEE_USDC);
if (totalAmount === 0n) throw new Error("TOTAL_AMOUNT_USDC must be > 0");
if (batchSize === 0n) throw new Error("BATCH_SIZE_USDC must be > 0");
if (totalAmount % batchSize !== 0n)
throw new Error("TOTAL_AMOUNT_USDC must be divisible by BATCH_SIZE_USDC");
console.log("User:", from);
console.log("Extension:", CCTP_EXTENSION);
console.log("USDC:", USDC_TOKEN);
console.log(
"Total (USDC):",
TOTAL_AMOUNT_USDC,
"Batch Size (USDC):",
BATCH_SIZE_USDC,
"Batches:",
totalAmount / batchSize,
);
console.log(
"Dest Domain:",
DEST_DOMAIN,
"Mint Recipient:",
DEST_MINT_RECIPIENT,
);
console.log(
"Max Fee (USDC):",
MAX_FEE_USDC,
"Min Finality:",
MIN_FINALITY_THRESHOLD,
);
// Compute hook data: prefer explicit HOOK_DATA_HEX; otherwise, if FORWARD_RECIPIENT is provided, build forwarder hook
let hookData = HOOK_DATA_HEX;
if (
(!hookData || hookData === "0x") &&
FORWARD_RECIPIENT &&
FORWARD_RECIPIENT.length > 0
) {
if (!ethers.isAddress(FORWARD_RECIPIENT))
throw new Error("Invalid FORWARD_RECIPIENT");
hookData = generateForwarderHookData(FORWARD_RECIPIENT);
console.log(
"Forwarder hook enabled -> Final recipient:",
FORWARD_RECIPIENT,
);
}
console.log("Hook Data:", hookData);
// destinationCaller must be bytes32 per ICctpExtension.DepositForBurnWithHookData
const destinationCallerBytes32 =
DEST_CALLER && DEST_CALLER !== ethers.ZeroHash
? ethers.zeroPadValue(ethers.getAddress(DEST_CALLER), 32)
: ethers.ZeroHash;
console.log("Destination Caller (bytes32):", destinationCallerBytes32);
// Check USDC balance
const bal = await usdc.balanceOf(from);
if (bal < totalAmount)
throw new Error(
`Insufficient USDC: have ${ethers.formatUnits(
bal,
6,
)}, need ${TOTAL_AMOUNT_USDC}`,
);
const domainSeparator = await getDomainSeparator(usdc);
const nonce = randomNonce32();
// EIP-3009 sign
const { v, r, s } = await signReceiveWithAuthorization({
from,
to: CCTP_EXTENSION,
value: totalAmount,
validAfter: VALID_AFTER,
validBefore: VALID_BEFORE,
nonce,
domainSeparator,
privateKey: PRIVATE_KEY,
});
// Structs
const receiveWithAuth = {
amount: totalAmount,
authValidAfter: VALID_AFTER,
authValidBefore: VALID_BEFORE,
authNonce: nonce,
v,
r,
s,
};
const burnData = {
amount: batchSize,
destinationDomain: DEST_DOMAIN,
mintRecipient: toBytes32Address(DEST_MINT_RECIPIENT),
destinationCaller: destinationCallerBytes32,
maxFee: maxFee,
minFinalityThreshold: MIN_FINALITY_THRESHOLD,
hookData: hookData, // either explicit or forwarder hook
};
// Estimate and send
const gas = await extension.batchDepositForBurnWithAuth.estimateGas(
receiveWithAuth,
burnData,
);
console.log("Estimated gas:", gas.toString());
const tx = await extension.batchDepositForBurnWithAuth(
receiveWithAuth,
burnData,
{
gasLimit: (gas * 120n) / 100n, // +20%
},
);
console.log("Tx hash:", tx.hash);
const receipt = await tx.wait();
console.log("Status:", receipt.status === 1 ? "SUCCESS" : "FAILED");
console.log(
"Block:",
receipt.blockNumber,
"Gas Used:",
receipt.gasUsed.toString(),
);
}
// Run
if (require.main === module) {
main().catch((e) => {
console.error("Error:", e.message);
process.exit(1);
});
}