Integration Guide

For teams integrating the Asset Relayer directly: your own validator, your own UI, no Send wallet involved.

The bridge locks an ERC-20 in an escrow on Base and mints a .B Holding on Canton. Going the other way, it burns the Holding on Canton and releases the ERC-20 on Base. Everything you exercise on the Canton side lives in the send-utility-bridge-v0 DAR, module Send.Utility.Bridge.V0.Agreement.User.

Integration support

To discuss a deeper integration and receive support, reach out to bd@send.it with the following intake form:

Subject: Asset Relayer integration — [Organization name]
 
Organization:
Website:
Primary contact (name and email):
Integration overview:
Live on Canton mainnet (yes or no):
Live on Canton testnet (yes or no):
Canton participant UID(s):
What do you need help with?

Reference values

MainnetTestnet
Base chainBase (8453)Base Sepolia (84532)
domainId845384532
Escrow (xReserve)0xCaC6d06470Ce86d6B2AC82F1609294b93CaDf1cd0x948BD87E0F57830C8DAb7D46473e8e3a25173FC1
Utility registryhttps://api.utilities.digitalasset.comhttps://api.utilities.digitalasset-staging.com
Minimum amount0.0000010.000001
Payload precision6 decimals6 decimals
`domainId` is not a CCTP domain

domainId equals the EVM chain id: 8453 on mainnet, 84532 on testnet. It is not Circle's CCTP domain id (6 for Base). Using the CCTP value produces a withdrawal no attestor can release. The authoritative value is the domainId field on your BridgeUserAgreement, which is fixed per environment.

Supported assets (mainnet)

SymbolBase ERC-20Canton instrumentDecimals
SEND0xeab49138ba2ea6dd776220fe26b7b8e446638956SEND.B18
USDC0x833589fcd6edb6e08f4c7c32d4f71b54bda02913USDC.B6
frxUSD0xe5020a6d073a794b6e7f05678707de47986fb0b6FRXUSD.B18
USDT0xfde4c96c8593536e31f229ea8f37b2ada2699bb2USDT.B6
EURC0x60a3e35cc302bfa44cb288bc5a4f316fdb1adb42EURC.B6
USDS0x820c137fa70c8691f0e44dc420a5e53c168921dcUSDS.B18
USDe0x5d3a1ff2b6bab83b63cd9ad0787074081a52ef34USDE.B18
cbBTC0xcbb7c0000ab88b473b1f5afd9ef808440eed33bfCBBTC.B8
cbETH0x2ae3f1ec7f1f5012cfeab0185bfc7aa3cf0dec22CBETH.B18
QUID0x1a44233fae8d50f1aeb3a5d58dd426ff4814cb53QUID.B18

Every instrument's admin is the cross-chain representative party, which you can read off your own BridgeUserAgreement rather than hardcoding.

One-time: the bridge user agreement

One agreement per (Canton party, asset). Without it, deposits to that party are held rather than minted.

Create a BridgeUserAgreementRequest:

BridgeUserAgreementRequest with
  user                     = "<your party id>"
  bridgeOperator           = "<bridge operator party>"
  crossChainRepresentative = "<cross-chain representative party>"
  operator                 = "<utilities operator party>"
  instrumentId = InstrumentId with
    id    = "SEND.B"                               -- the Canton instrument
    admin = "<cross-chain representative party>"
  preApproval = True                               -- deposits mint automatically

Wait for the bridge operator and cross-chain representative to accept it. This takes a few minutes and creates the BridgeUserAgreement on your ACS.

Read your working values off it: domainId, minBurnValue, tokenId, instrumentId.

Base → Canton (deposit)

approve the escrow, then call:

depositToRemote(
  uint256 value,
  uint32  remoteDomain,     // 8453 mainnet / 84532 testnet
  bytes32 remoteRecipient,  // keccak256(utf8(cantonPartyId))
  address localToken,       // the Base ERC-20
  uint256 maxFee,           // 0
  bytes   hookData          // hex(utf8(cantonPartyId))
)

remoteRecipient is matched against userHash on your agreement; hookData carries the readable party id. Derive both from the same party string.

Precision. The bridge moves amounts at 6 decimal places, so anything finer is rejected with InvalidPrecision. 1.0000005 SEND fails; 1.000000 passes. In base units that means value must be a multiple of 10^(decimals - 6): 1e12 for the 18-decimal tokens, 100 for cbBTC, and nothing to do for the 6-decimal ones.

Watch the DepositedToRemote event for confirmation, then expect the Canton mint in roughly 3 minutes.

Canton → Base (withdrawal)

Exercise BridgeUserAgreement_Burn on your agreement. One agreement serves every withdrawal.

exercise bridgeUserAgreementCid BridgeUserAgreement_Burn with
  amount               = 1.000000
  destinationDomain    = 8453                     -- 84532 on testnet
  destinationRecipient = "<40 hex chars>"
  holdingCids          = [<holding contract ids>]
  requestId            = "<globally unique>"
  reference            = ""
  factoryCid           = <from the DA Utility registry>
  contextContractIds   = <from the DA Utility registry>
FieldNotes
amount> 0, at least minBurnValue, and an exact multiple of 0.000001
destinationDomain8453 / 84532. Not checked on-ledger, see below
destinationRecipient20-byte EVM address, 40 hex chars, 0x optional, not all zeros
holdingCidsHoldings to burn. Must total at least amount; the change is minted back to you
requestIdMust be globally unique; it becomes the EVM release nonce. See below
referenceFree-text, not used by the protocol
factoryCid, contextContractIdsResolved from the DA Utility registry

The choice archives the input Holdings and returns a WithdrawAttestation contract id plus any change Holding ids. Release on Base follows in roughly 3 minutes, and you don't pay the gas.

Two fields the ledger will not check

The choice validates every field except destinationDomain and requestId. Both are accepted as they are, the burn succeeds, and nothing shows up as wrong until the withdrawal fails to release.

  • A wrong destinationDomain produces an attestation no attestor can release.
  • A reused requestId produces a release nonce the escrow has already spent, so the release is rejected for good. The nonce is keccak256 of the string, so you have to guarantee it is unique.

Recovering from either needs operator involvement. Verify both before you burn.

You cannot cancel a withdrawal. If a release can't be completed, the attestation is revoked and you reclaim your Holding:

exercise bridgeUserAgreementCid BridgeUserAgreement_ReclaimHolding with
  revokedWithdrawAttestationCid = <the RevokedWithdrawAttestation>
  factoryCid                    = <from the DA Utility registry>
  contextContractIds            = <from the DA Utility registry>

It returns the reclaimed holdingCid.

Reading pause state

The bridge can be paused as a whole, or for deposits and withdrawals separately. Check both and reflect them in your UI, since deposits revert while they are paused.

function paused() view returns (bool)
function domainDepositsPaused(uint32 domain) view returns (bool)
function domainWithdrawalsPaused(uint32 domain) view returns (bool)
 
event DomainPauseStateUpdated(uint32 indexed domain, bool depositsPaused, bool withdrawalsPaused)
event Paused(address account)
event Unpaused(address account)

Deposits are enabled only when paused() and domainDepositsPaused(domain) are both false, and withdrawals only when paused() and domainWithdrawalsPaused(domain) are both false. Either one is enough to disable that direction. Subscribe to the three events rather than polling.

Pauses stop new deposits and releases; balances already on either side are unaffected.

FAQs

Are there fees? No protocol fee in either direction, for now. You pay Base gas on the deposit and nothing on the release.

How long does it take? About 3 minutes each way under normal operation. Canton → Base often settles faster.

Is there a minimum? 0.000001 of any token, both directions. It's the minBurnValue field on your agreement.

Does domainId ever change? It's fixed per environment and is not expected to change. Pinning it per environment is fine; reading it from your BridgeUserAgreement is safer still.

The burn went through but nothing arrived on Base. Almost always destinationDomain (a CCTP domain id instead of the chain id) or a malformed destinationRecipient. Check both on the burn's choice arguments.

Which choices should I be watching? Burns are BridgeUserAgreement / BridgeUserAgreement_Burn. Mints on the default path are BridgeUserAgreement / BridgeUserAgreement_DirectMint. BridgeUserAgreementRequest_Accept is the one-time onboarding event.

Do I need a separate agreement per asset? Yes, one per (party, asset). Most integrators create them during user onboarding so bridging never blocks on it.

What's the difference between USDC.B and USDCx? USDC.B is bridged from Base by the Asset Relayer. USDCx is bridged from Ethereum. They are separate instruments run on separate infrastructure by different operators, so they carry different operational risk and are not interchangeable without a swap. Pick the one your counterparties actually hold.