@circle-fin/provider-cctpx
v1.0.2
Published
Circle's CCTPx provider for non-USDC cross-chain token transfers via the CrossChainTokenService
Readme
CCTPx Bridging Provider
Circle's CCTPx provider for Bridge Kit
Cross-chain transfers of non-USDC tokens via the CrossChainTokenService.
Table of Contents
Overview
The CCTPx Bridging Provider is a strongly-typed implementation of Circle's CCTPx
protocol. Where the CCTPv2 provider
moves native USDC, CCTPx moves non-USDC tokens across chains
through the CrossChainTokenService. Which tokens and routes are available is
driven by the CCTPx token registry; KNOWN_TOKEN_SYMBOLS lists the symbols the
provider resolves on the caller's behalf.
A token is identified by an opaque protocol-native identifier (a bytes32
token id), not by a symbol tied to a single chain address. The provider resolves
a known symbol ('cirBTC' / 'wETH' / 'EURC') to that id internally, validates the
route against the CCTPx token registry, fetches a server-signed fee quote, and
runs the full approve → transfer → attestation → relayer-forward flow.
While primarily designed to power the Bridge Kit, the provider can be used directly in applications that need fine-grained control.
Installation
npm install @circle-fin/provider-cctpx
# or
yarn add @circle-fin/provider-cctpxNote: This provider is included by default with the Bridge Kit. Install it directly only if you need a custom CCTPx integration.
Usage
With Bridge Kit (recommended)
The Bridge Kit registers the CCTPx provider by default, after the CCTPv2
provider. A bare 'USDC' token continues to route through CCTPv2; a known
CCTPx symbol or bytes32 token id routes through CCTPx. The example below uses a
testnet route (Ethereum_Sepolia → Arc_Testnet):
import { BridgeKit } from '@circle-fin/bridge-kit'
const kit = new BridgeKit()
const result = await kit.bridge({
from: { adapter: sourceAdapter, chain: 'Ethereum_Sepolia' },
to: { adapter: destAdapter, chain: 'Arc_Testnet' },
amount: '0.05',
token: 'wETH',
})
if (result.state === 'success') {
console.log(result.steps.map((step) => step.name))
}Pass 'cirBTC', 'wETH', 'EURC', or a 32-byte hex token id (0x plus 64
hex characters). The CCTPx token registry decides whether that id is routable.
Unknown symbols, short/malformed ids, and { provider, id } objects throw at
bridge / estimate. See Supported Chains & Tokens
for the routable chains.
Estimating a transfer
estimate returns a server-signed fee quote alongside a single-entry fees[]
summary. The shared result type leaves quote opaque, so narrow it to this
provider's QuoteEnvelope to read the breakdown. CCTPx fees are quote-driven,
so the gas-fee estimate is empty:
import type { QuoteEnvelope } from '@circle-fin/provider-cctpx'
const estimate = await kit.estimate({
from: { adapter: sourceAdapter, chain: 'Ethereum_Sepolia' },
to: { adapter: destAdapter, chain: 'Arc_Testnet' },
amount: '0.05',
token: 'wETH',
})
// `quote` is opaque on the shared result type. Narrow it to this provider's
// envelope to read the fee breakdown.
const quote = estimate.quote as QuoteEnvelope | undefined
console.log(quote?.totalAmount, estimate.fees[0]?.token)Pass that quote back into bridge to reuse it. CCTPx reuses a quote while it
is unexpired and matches the requested speed; otherwise it fetches a fresh one
and adds a QUOTE_NOT_REUSED warning to result.warnings.
Direct provider usage
supportsRoute takes two chain definitions. The published chain definitions
are re-exported from @circle-fin/bridge-kit (which bundles this provider), so a
direct integrator imports them from there:
import { CCTPXBridgingProvider } from '@circle-fin/provider-cctpx'
import { EthereumSepolia, ArcTestnet } from '@circle-fin/bridge-kit'
const provider = new CCTPXBridgingProvider()
// Route checks read the CCTPx token registry (latency-bounded, cached).
const canRoute = await provider.supportsRoute(
EthereumSepolia,
ArcTestnet,
'wETH',
)A route check resolves to true/false for a definitive answer. If the token
registry is unreachable with a cold cache, supportsRoute instead throws a
retryable KitError (SERVICE_ROUTE_CHECK_UNAVAILABLE) — "couldn't check",
distinct from a definitive false — so a direct integrator should be ready to
catch it. The Bridge Kit's routing layer does this and surfaces a retryable
route-check error rather than a misleading "unsupported route".
Public API
The Bridge Kit re-exports the CCTPx surface most integrators need:
import {
CCTPXBridgingProvider,
KNOWN_TOKEN_SYMBOLS,
type CCTPXBridgingProviderConfig,
type BridgeToken,
type CCTPXTokenId,
type CCTPXRouteToken,
type KnownTokenSymbol,
type CCTPXChainConfig,
type ChainDefinitionWithCCTPX,
} from '@circle-fin/bridge-kit'CCTPXBridgingProvider— the provider class (registered by default).KNOWN_TOKEN_SYMBOLS— the runtime list of symbols resolvable without abytes32id (['cirBTC', 'wETH', 'EURC']).CCTPXBridgingProviderConfig— the constructor options, for wiring a provider instance explicitly instead of relying on the default registration.BridgeToken— the Bridge Kit token input:'USDC', a known CCTPx symbol, or a bytes32 CCTPx token id.CCTPXTokenId— thebytes32token-id type.CCTPXRouteToken— what a route check accepts: aCCTPXTokenIdor aKnownTokenSymbol. Which ids are routable is decided by the IRIS registry at call time, so this is a shape, not a validity guarantee.KnownTokenSymbol— the union of those symbols, derived from the list above so the type and the runtime set never drift.CCTPXChainConfig— the per-chain CCTPx configuration shape (the CrossChainTokenService address and supported tokens).ChainDefinitionWithCCTPX— a narrowedChainDefinitiontype for chains confirmed to carry a CCTPx deployment (aChainDefinitionwhosecctpxconfig is present), not a runtime guard.
The provider package additionally exports the registry fetcher, the fee-quote
fetcher, the action-map type (CCTPXActions), the forward-state and
attestation-status unions, and the IRIS API path constants. See the package
entry point for the full surface.
Supported Chains & Tokens
Call provider.getSupportedChains() for the current set instead of a static
list — it returns every chain whose definition in @core/chains
(core/chains/src/definitions) carries a cctpx.serviceAddress (a deployed
CrossChainTokenService), the same signal isCCTPXSupported reports. A chain
becomes CCTPx-eligible as soon as its definition gains that field.
Eligibility is per chain; routability is per token. A route is accepted only when both endpoints are EVM chains on the same network, both carry a CCTP domain and a CrossChainTokenService deployment, and the registry lists the token with a deployment on both endpoints' domains. An eligible pair of chains is therefore not by itself a usable route for a given token.
Known symbols: the symbols listed in KNOWN_TOKEN_SYMBOLS. A symbol
resolves to one canonical bridge, so it routes only between the chains that
bridge is registered on; a symbol that maps to more than one bridge resolves to
the canonical one. Any registry-listed token — including a non-canonical bridge
for a known symbol — routes when passed by its bytes32 id.
The IRIS API
The provider talks to Circle's public IRIS API for five things:
- Token registry —
GET /v2/cctpx/tokenslists the CCTPx tokens and their per-chain deployments. Route checks read this (cached, stale-while-revalidate);provider.refreshTokenRegistry()forces a refresh. - Fee quote — a
POSTto the CCTPx quote endpoint returns a server-signed quote that the on-chain transfer carries. Quotes expire. - Fast-burn allowance —
GET /v2/cctpx/allowancesreports the remaining fast-burn allowance per token. A FAST transfer probes this before signing and degrades to SLOW when the remaining allowance cannot carry the transfer amount or when the probe itself fails; see Observability for the signals that fire. - Attestation and forward state —
GET /v2/messages/{sourceDomain}returns the signed attestation for the source transfer once it is available. The destination leg polls the same endpoint a second time for the relayer'sforwardState; see Troubleshooting for that vocabulary. - Re-attestation —
POST /v2/reattest/{nonce}asks IRIS to re-sign a message whose attestation never arrived.provider.reAttest(source, txHash)is the operator escape hatch: it resolves the nonce via the attestation endpoint, posts the re-attest request, then re-polls until the attestation value actually changes.
Hosts: production is https://iris-api.circle.com; the sandbox is
https://iris-api-sandbox.circle.com. The provider selects the host per route
from the source chain's network (testnet → sandbox, mainnet → production). Both
can be overridden via CCTPXBridgingProviderConfig.
Observability
The provider emits on three channels at each of the sites below — a log line,
an events.emit({ name, data }) event, and a metrics.counter(name).inc()
increment. All three default to
no-ops, so emissions are dropped unless you wire a runtime backend.
Wiring the runtime backends
Pass a runtime with your backends via the kit's invocation metadata, or
construct the provider directly with logger / events / metrics:
import { CCTPXBridgingProvider } from '@circle-fin/provider-cctpx'
// Node — e.g. pino logs + an OpenTelemetry counter + your event bus.
const provider = new CCTPXBridgingProvider({
logger: { warn: (msg, fields) => pino.warn(fields, msg) /* ...rest */ },
metrics: { counter: (name) => ({ inc: () => otelCounter(name).add(1) }) },
events: { emit: (event) => myBus.publish(event.name, event.data) },
})// Browser — e.g. forward warnings to Sentry and events to Datadog RUM.
const provider = new CCTPXBridgingProvider({
logger: {
warn: (msg, fields) => Sentry.captureMessage(msg, { extra: fields }),
},
events: { emit: (event) => datadogRum.addAction(event.name, event.data) },
})The kit threads its runtime through the bridge flow, so wiring
Runtime.{ logger, events, metrics } once captures every site below.
Emission sites
Each emission site fires one or more of the signals below. The registry and
fee-quote sites additionally emit a higher-priority
cctpx.registry.invalidpayload / cctpx.feequote.invalidpayload signal when a
200 OK body can't be parsed or validated.
| Event name | Metric | When it fires | Action |
| ---------------------------- | -------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| cctpx.registry.failed | cctpx.registry.errors | The token registry could not be read and the cache was cold. | Check IRIS reachability; the route check throws a retryable error until the registry is readable. |
| cctpx.feequote.failed | cctpx.feequote.errors | A fee quote could not be fetched from IRIS. | Check IRIS reachability; the quote fetch fails with a directed error until IRIS is readable. |
| cctpx.quote.refetched | cctpx.quote.refetched | A signed quote was stale at submit time and was transparently re-fetched. | Informational; the flow rebuilds the calldata. Repeated firing suggests slow signing/submission. |
| cctpx.allowance.failed | cctpx.allowance.errors | The fast-burn allowance probe could not be read (network / HTTP / bad body). | The probe degrades the request to SLOW (the safe choice); check IRIS reachability. |
| cctpx.allowance.degraded | cctpx.allowance.degraded | A probe failure forced a FAST request to fall back to SLOW. | Informational; the transfer proceeds at SLOW. Pairs with cctpx.allowance.failed. |
| cctpx.fastburn.unavailable | cctpx.fastburn.degraded | Fast-burn was not confirmed available, so a FAST request degraded to SLOW. | Informational; the transfer proceeds at SLOW. |
| cctpx.quote.reused | cctpx.quote.reused | A caller-supplied quote passed the reuse checks and was used verbatim. | Informational; logged at debug. No fresh quote was fetched. |
| cctpx.quote.reuserejected | cctpx.quote.reuserejected| A caller-supplied quote could not be reused, so a fresh quote was fetched. | Check the quote's freshness and speed; the transfer proceeds on the fresh quote. |
Troubleshooting
A CCTPx route returns "unsupported" unexpectedly. The route check requires both chains to carry a CrossChainTokenService deployment and the token to be registered on both domains. If
cctpx.registry.failedfired, IRIS was unreachable with a cold cache — the route check throws a retryableSERVICE_ROUTE_CHECK_UNAVAILABLEerror (not a definitivefalse) until the registry can be read. Callprovider.refreshTokenRegistry()to force a fresh read on a running instance.A quote keeps re-fetching (
cctpx.quote.refetched). Quotes expire; a stale quote at submit time is re-fetched transparently. Frequent re-fetches usually mean signing or submission is slow relative to the quote TTL.A FAST transfer ran at SLOW (
cctpx.fastburn.unavailable). Fast-burn could not be confirmed available — either the remaining allowance was short or the probe failed — so it degraded to SLOW. This is expected behaviour, not an error.A FAST transfer ran at SLOW after a probe error (
cctpx.allowance.failed/cctpx.allowance.degraded). The fast-burn allowance probe could not be read (IRIS unreachable, a non-OK status, or an unusable response body), so the request degraded to SLOW. An over-allowance FAST transfer is not rejected on-chain — it settles at finality with thePRE_FINALITYpremium already paid — so when the remaining allowance is unknown, SLOW is the correctly priced choice. Check IRIS reachability if the probe keeps failing.The destination forward stalls. If the destination leg never reaches a terminal state —
CONFIRMEDorCOMPLETEcarrying aforwardTxHash— the transfer is waiting on Circle's relayer to execute the CCTPx message; the poll continues until it does. A terminalFAILEDsurfaces to the caller with anyforwardErrorCode/forwardErrorDetails. The full forward-state vocabulary isPENDING | SENT | CONFIRMED | COMPLETE | FAILED; the terminal states areCONFIRMEDandCOMPLETE(once aforwardTxHashis reported), andFAILED.bridge()resolves at whichever it reaches first, rather than holding forCOMPLETE— the delivery transaction is on chain atCONFIRMED, andCOMPLETEonly adds destination finality, which IRIS can report minutes later. The returnedforwardStatesays which of the two the poll saw.A custom fee was requested on a CCTPx route. Custom fees are a USDC-only feature: the fee is collected on top of the transfer amount and split through CCTPv2's USDC flow, so it cannot apply to a CCTPx (non-USDC) token. Requesting one — through the kit-wide policy or a per-call
config.customFee— is rejected withINPUT_VALIDATION_FAILEDbefore anything is submitted, onestimateas well asbridge. Bridge USDC, or drop the fee for that route.feePayment: 'destination'was passed. CCTPx prepays its fee on the source leg, as part of the same signed quote the transfer commits to, so there is no destination-side collection to select.'source'and an omitted value are accepted; any other value — including a near-miss such as'Destination'— is rejected withINPUT_VALIDATION_FAILEDbefore anything is submitted, onestimateas well asbridge.useForwarder: falsewas passed. Every CCTPx transfer pays a forwarding fee in its quote, and that payment is what dispatches the relayer, so forwarding is bought before the transfer reaches the chain and cannot be turned off per call. Omitted andtrueare accepted; anything else is rejected withINPUT_VALIDATION_FAILEDbefore anything is submitted, onestimateas well asbridge.
Development
This package is part of the App Kits monorepo.
# Build
nx build @circle-fin/provider-cctpx
# Test
nx test @circle-fin/provider-cctpxLicense
This project is licensed under the Apache 2.0 License. Contact support for details.
Ready for cross-chain bridging?
Join Discord • Visit our Help-Desk
Built with ❤️ by Circle
