/Docsv1.0Built on Arc Β· by Circle
Technical

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.

Circle CCTP v2Base SepoliaArc Domain 26CCTPReceiver ContractRelay Server~20–40s E2E
1-click
Bridge + buy in one action
~30s
Typical end-to-end time
1%
Bridge buffer (refunded)
4
Source chains supported

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.

πŸ’‘
No manual bridging requiredThe buyer never interacts with a bridge UI. They click Bridge & Buy on the NFT page, sign two transactions on their source chain (approve + burn), and receive the NFT on Arc automatically.

The 6-Step Purchase Flow

1
Initiate β€” Lock the listing
The buyer clicks Bridge & Buy. The frontend calls 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.
2
Approve + Burn β€” Source chain transactions
The buyer signs two transactions on their source chain (e.g. Base Sepolia): first an ERC-20 approve for Circle's TokenMessenger, then a depositForBurn call that burns the USDC. The burn amount is 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.
3
Attest β€” Circle signs the burn
After the burn transaction is confirmed, the relay polls Circle's iris-api v2 attestation endpoint every 3 seconds (up to 2 minutes) until Circle's notary service has signed the CCTP message. The resulting attestationBytes is the cryptographic proof that the burn happened and USDC can be minted on Arc.
4
Relay β€” Mint USDC on Arc
The relay server calls 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.
5
Execute β€” Buy NFT & forward to buyer
The relay then calls 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.
6
Confirm β€” UI refreshes, XP awarded
The purchase status is updated to 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.

FunctionCallerDescription
executePurchaseTreasury (relayer)Approves marketplace, calls buyNFT, forwards NFT to buyer, refunds USDC dust. Emits PurchaseExecuted or PurchaseFailed β€” never reverts.
emergencyNFTTransferOwner onlyManually recovers an NFT stuck in the contract if the forward step failed.
emergencyWithdrawOwner onlyRecovers any ERC-20 token (e.g. stuck USDC) from the contract.
setRefundRecipientOwner onlyUpdates the address that receives USDC refunds on failed purchases.
setPausedOwner onlyPauses the contract β€” executePurchase reverts while paused.
πŸ”’
executePurchase never reverts externallyAll marketplace and NFT transfer errors are caught internally with a try/catch. The transaction always succeeds on-chain, but emits either PurchaseExecuted or PurchaseFailed. The relay parses these events to determine the real outcome β€” not just receipt.status.

Events

Solidity
// 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

⛓️Arc Testnet (Destination)CCTP Domain 26
5042002
rpc.testnet.arc.network
0xE737e5...CE275
0x8FE6B9...2DAA
0x360000...0000
6 (same as all chains)
πŸ”΅Base Sepolia (Source β€” Testnet)CCTP Domain 6
84532
faucet.circle.com β†’ Base Sepolia
Official Circle address
iris-api-sandbox.circle.com

API Routes

RouteMethodDescription
/api/cctp/initiatePOSTCreates purchase row, locks listing for 90s. Returns purchaseId.
/api/cctp/relayPOSTCalled after burn tx. Polls Circle for attestation, calls receiveMessage + executePurchase on Arc.
/api/cctp/statusGETPoll by purchaseId or wallet. Used by frontend status hook every 3s.
/api/admin/cctpPOSTAdmin actions: reset_for_retry, recover_nft, mark_failed.
/api/admin/cctpGETList 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.

StatusMeaningNext
initiatedPurchase created, listing locked, waiting for buyer to burn USDCattesting
attestingBurn tx confirmed, polling Circle iris-api for attestationrelaying
relayingAttestation received, submitting receiveMessage on Arccomplete or failed
completeNFT delivered to buyer's Arc wallet. Terminal state.β€”
failedAn error occurred. failure_reason column has details. USDC refunded to treasury.Admin can reset_for_retry
expired90s 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.

FlagEffect when ON
cctp_cross_chain_buyEnables the entire CCTP system. Shows Bridge & Buy button to users on foreign chains.
cctp_pay_from_another_chain_toggleShows the "Pay from another chain" option within BuyButton for users already on Arc. Allows manually switching to CCTP flow.

Important Implementation Details

⚠️
Circle V2 doesn't forward messageBody
Circle's CCTP V2 iris-api does not return a custom 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.
πŸ”„
usdcAmount = price, not bridge_amount
The relay passes 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.
πŸ“‘
Event parsing, not receipt.status
The relay detects success by scanning logs for the 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.
πŸ”
UI refresh uses refreshKey, not router.refresh()
After a CCTP purchase completes, the NFT detail page increments a 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.

TypeScript
// 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.

SQL
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
);
πŸš€
Deploying CCTPReceiverUse 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.