Cross-Chain Buying
via CCTP
ArcaneFi uses Circle's Cross-Chain Transfer Protocol (CCTP) to let buyers purchase NFTs directly from Base, Ethereum, Polygon, and Avalanche β without ever manually bridging USDC to Arc first. The entire bridge-and-buy flow happens in one seamless experience.
How It Works
Arc Network is not yet listed on centralised exchanges and has no native USDC on-ramp. CCTP solves this by letting buyers burn USDC on their home chain (e.g. Base) and have Circle's protocol mint an equivalent amount on Arc β all trustlessly, via a cryptographic attestation signed by Circle's notary service.
A server-side relay monitors for confirmed burns, fetches the Circle attestation, and then calls the CCTPReceiver smart contract on Arc to complete the purchase and deliver the NFT to the buyer's wallet β all without any further action from the user.
The 6-Step Purchase Flow
POST /api/cctp/initiate which creates a cctp_purchases row in Supabase with status initiated and sets a 90-second listing lock β preventing a second buyer from purchasing the same NFT while the bridge is in flight.listing_price Γ 1.01 β the 1% buffer is held by CCTPReceiver and refunded to the buyer after the NFT is delivered. The burn specifies the CCTPReceiver contract on Arc as the mint recipient.attestationBytes is the cryptographic proof that the burn happened and USDC can be minted on Arc.receiveMessage on Arc's MessageTransmitter contract, passing the message bytes and Circle's attestation. This mints exactly bridge_amount USDC atoms directly into the CCTPReceiver contract. No third party holds funds β USDC goes straight to the contract that will immediately use it.CCTPReceiver.executePurchase(buyer, nftContract, tokenId, priceAtoms). The contract approves MarketplaceV2 for exactly the listing price, calls buyNFT (NFT lands in CCTPReceiver), then immediately safeTransferFroms it to the buyer's Arc wallet. Any USDC dust (the 1% buffer) is refunded to the buyer in the same transaction.complete in Supabase. The frontend's status poller detects completion, fires the success callback, and the NFT detail page re-fetches ownerOf on-chain to flip the UI from "Buy" to "You own this". XP is awarded for buy_nft and cross_chain_purchase.Smart Contracts
CCTPReceiver.sol
The core contract deployed on Arc Testnet. It is the mint recipient for all CCTP transfers β meaning Circle mints USDC directly here. It then atomically purchases the NFT and forwards it to the buyer.
| Function | Caller | Description |
|---|---|---|
| executePurchase | Treasury (relayer) | Approves marketplace, calls buyNFT, forwards NFT to buyer, refunds USDC dust. Emits PurchaseExecuted or PurchaseFailed β never reverts. |
| emergencyNFTTransfer | Owner only | Manually recovers an NFT stuck in the contract if the forward step failed. |
| emergencyWithdraw | Owner only | Recovers any ERC-20 token (e.g. stuck USDC) from the contract. |
| setRefundRecipient | Owner only | Updates the address that receives USDC refunds on failed purchases. |
| setPaused | Owner only | Pauses the contract β executePurchase reverts while paused. |
PurchaseExecuted or PurchaseFailed. The relay parses these events to determine the real outcome β not just receipt.status.Events
// Emitted on successful purchase + NFT delivery
event PurchaseExecuted(
address indexed buyer,
address indexed nftContract,
uint256 indexed tokenId,
uint256 usdcAmount
);
// Emitted when buyNFT or NFT forward fails β USDC refunded to treasury
event PurchaseFailed(
address indexed buyer,
address nftContract,
uint256 tokenId,
uint256 refunded,
string reason
);Network Configuration
API Routes
| Route | Method | Description |
|---|---|---|
| /api/cctp/initiate | POST | Creates purchase row, locks listing for 90s. Returns purchaseId. |
| /api/cctp/relay | POST | Called after burn tx. Polls Circle for attestation, calls receiveMessage + executePurchase on Arc. |
| /api/cctp/status | GET | Poll by purchaseId or wallet. Used by frontend status hook every 3s. |
| /api/admin/cctp | POST | Admin actions: reset_for_retry, recover_nft, mark_failed. |
| /api/admin/cctp | GET | List purchases filtered by status. Query: ?status=failed. |
Purchase Status Machine
Every CCTP purchase moves through a strict set of states stored in the cctp_purchases Supabase table. The relay endpoint only processes purchases in initiated or attesting states β it rejects any other status with a 409.
| Status | Meaning | Next |
|---|---|---|
| initiated | Purchase created, listing locked, waiting for buyer to burn USDC | attesting |
| attesting | Burn tx confirmed, polling Circle iris-api for attestation | relaying |
| relaying | Attestation received, submitting receiveMessage on Arc | complete or failed |
| complete | NFT delivered to buyer's Arc wallet. Terminal state. | β |
| failed | An error occurred. failure_reason column has details. USDC refunded to treasury. | Admin can reset_for_retry |
| expired | 90s listing lock elapsed before burn was confirmed. | β |
Feature Flags
The entire CCTP flow is gated behind two feature flags stored in Supabase. Toggle them from /admin/feature-flags without a deployment.
| Flag | Effect when ON |
|---|---|
| cctp_cross_chain_buy | Enables the entire CCTP system. Shows Bridge & Buy button to users on foreign chains. |
| cctp_pay_from_another_chain_toggle | Shows the "Pay from another chain" option within BuyButton for users already on Arc. Allows manually switching to CCTP flow. |
Important Implementation Details
messageBody with purchase metadata. All NFT purchase params (contract, tokenId, buyer) are stored in Supabase at initiation and read by the relay server β they are not encoded in the CCTP message itself.price_usdc (the exact listing price) to executePurchase, not bridge_amount. The contract's balance check is balance β₯ usdcAmount β passing bridge_amount would cause it to revert if Circle minted exactly the price.PurchaseExecuted event β not by checking receipt.status. The transaction always succeeds on-chain even when the NFT transfer fails. Checking only status would silently mark failed purchases as complete.refreshKey state variable, which is in the useEffect dependency array. This forces a fresh ownerOf RPC call. router.refresh() alone does not re-run client-side effects with stable deps.Admin Recovery Tools
When a purchase enters the failed state, the relay endpoint blocks retries with a 409. Use POST /api/admin/cctp to intervene.
// Reset a failed purchase so the relay can retry it
await fetch("/api/admin/cctp", {
method: "POST",
body: JSON.stringify({
action: "reset_for_retry",
purchaseId: "uuid-here",
}),
});
// Recover an NFT stuck inside CCTPReceiver
// (use when buyNFT succeeded but safeTransferFrom to buyer failed)
await fetch("/api/admin/cctp", {
method: "POST",
body: JSON.stringify({
action: "recover_nft",
purchaseId: "uuid-here",
recipientAddress: "0xBuyerWalletHere",
}),
});
// List all failed purchases
const { purchases } = await fetch("/api/admin/cctp?status=failed").then(r => r.json());Database Schema
All CCTP state is tracked in a single cctp_purchases Supabase table. Run schema-cctp.sql to create it.
create table cctp_purchases (
id uuid primary key default gen_random_uuid(),
buyer_wallet text not null, -- source chain wallet
arc_wallet text not null, -- recipient on Arc
source_chain_id integer not null,
source_chain_name text not null,
source_tx_hash text, -- burn tx hash
arc_tx_hash text, -- mint+buy tx hash on Arc
nft_contract text not null,
token_id integer not null,
price_usdc numeric not null, -- exact listing price
bridge_amount numeric not null, -- price Γ 1.01 (what buyer burns)
message_hash text, -- for Circle iris-api polling
message_bytes text, -- raw CCTP message hex
attestation_bytes text, -- Circle's notary signature
status text not null default 'initiated'
check (status in (
'initiated','attesting','relaying',
'complete','failed','expired'
)),
locked_until timestamptz, -- listing lock (~90s)
failure_reason text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
completed_at timestamptz
);DeployCCTPReceiver.s.sol with Foundry. Constructor args: (usdcAddress, marketplace, relayerAddress, refundRecipient). After deploy, set NEXT_PUBLIC_CCTP_RECEIVER_ADDRESS in frontend/.env.local and add the contract address to lib/cctp-config.ts.