| Endpoint | Local | Production |
|---|---|---|
| Ethereum JSON-RPC | http://localhost:8081/eth |
https://<your-deployment>/eth |
| User Registration | http://localhost:8081/register |
https://<your-deployment>/register |
| Splice Registry API | http://localhost:8081/registry/transfer-instruction/v1/transfer-factory |
https://<your-deployment>/registry/transfer-instruction/v1/transfer-factory |
| Health Check | http://localhost:8081/health |
https://<your-deployment>/health |
For local development, use http://localhost:8081. For production, replace with your deployed API server URL.
The Canton Bridge API provides an Ethereum-compatible JSON-RPC interface for interacting with bridged ERC-20 tokens on the Canton Network. It enables users to:
- Register with their Ethereum wallet (Web3 login)
- Query balances of bridged tokens via standard ERC-20 methods
- Transfer tokens using Ethereum transactions
- Access token metadata (name, symbol, decimals, total supply)
The bridge uses a custodial model where:
- User-Owned Holdings: Each user has their own Canton party ID; CIP56Holdings belong to the user's party
- Custodial Key Management: The API server generates and holds Canton signing keys (secp256k1) on behalf of users
- Fingerprint Mapping: Users are identified by a cryptographic fingerprint derived from their Ethereum address (
keccak256(address)) - CIP-56 Tokens: Tokens follow the CIP-56 standard on Canton, enabling compliant asset management
- MetaMask Authentication: Users authenticate with their existing Ethereum wallets—the API server signs Canton transactions on their behalf
┌─────────────┐ Deposit ┌─────────────┐ Mint ┌─────────────┐
│ Ethereum │ ──────────────────▶│ Middleware │ ───────────────▶ │ Canton │
│ (ERC-20) │ │ Relayer │ │ (CIP-56) │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
│ 1. User deposits tokens │ 2. Relayer detects event │
│ to bridge contract │ and mints on Canton │
│ │ │
│◀─────────────────────────────────│◀────────────────────────────────│
│ Withdrawal │ Burn │
│ 4. Relayer releases tokens │ 3. User initiates withdrawal │
The /eth endpoint provides MetaMask-compatible Ethereum JSON-RPC methods. Connect to it just like you would connect to any Ethereum RPC endpoint.
All transaction submissions via eth_sendRawTransaction require sender addresses to be whitelisted:
- Signature Verification: Transaction signature is verified and sender address extracted using cryptographic recovery
- Whitelist Check: Sender address is checked against the whitelist database
- Rejection: Non-whitelisted transactions are rejected with a clear error message
To submit transactions, users must first:
- Get their address whitelisted by an administrator
- Register via the
/registerendpoint (see User Registration section)
Query methods remain unauthenticated for MetaMask compatibility. The following methods can be called by anyone:
eth_getBalance,eth_call,eth_getTransactionCount,eth_getLogseth_getTransactionByHash,eth_getTransactionReceipt,eth_getBlockByNumber- All other read-only methods listed below
When transaction submission fails due to whitelist restrictions:
- "sender address X is not whitelisted for transactions" - The sender address needs to be whitelisted and registered
- "invalid sender" - Invalid transaction signature (signature does not match transaction data)
- "whitelist check failed" - Internal error during whitelist verification (check server logs)
// Add network to MetaMask (local development)
await window.ethereum.request({
method: 'wallet_addEthereumChain',
params: [{
chainId: '0x7A69', // 31337 in hex
chainName: 'Canton Local',
rpcUrls: ['http://localhost:8081/eth'],
nativeCurrency: {
name: 'Ether',
symbol: 'ETH',
decimals: 18
}
}]
});For production, replace http://localhost:8081/eth with your deployed API server URL.
The following standard Ethereum JSON-RPC methods are supported:
eth_chainId- Returns the chain IDeth_blockNumber- Returns the latest block numbereth_gasPrice- Returns the current gas priceeth_maxPriorityFeePerGas- Returns the suggested priority feeeth_estimateGas- Estimates gas for a transactioneth_getBalance- Returns the ETH balance (synthetic for registered users)eth_getTransactionCount- Returns the nonce for an addresseth_getCode- Returns the code at an addresseth_syncing- Returns sync status (always false)eth_call- Executes a call without creating a transactioneth_getTransactionByHash- Returns a transaction by hasheth_getTransactionReceipt- Returns a transaction receipteth_getLogs- Returns logs matching filter criteriaeth_getBlockByNumber- Returns a block by numbereth_getBlockByHash- Returns a block by hash
net_version- Returns the network IDnet_listening- Returns true (always listening)net_peerCount- Returns 0 (no P2P network)web3_clientVersion- Returns the client version stringweb3_sha3- Returns Keccak-256 hash of input
eth_sendRawTransaction- Submits a signed transaction
The bridged ERC-20 token is available at the configured token address. You can interact with it using standard ERC-20 methods:
// Get token balance
const balanceOf = await ethersProvider.call({
to: tokenAddress,
data: iface.encodeFunctionData('balanceOf', [userAddress])
});
// Get token name
const name = await ethersProvider.call({
to: tokenAddress,
data: iface.encodeFunctionData('name', [])
});
// Get token symbol
const symbol = await ethersProvider.call({
to: tokenAddress,
data: iface.encodeFunctionData('symbol', [])
});
// Get token decimals
const decimals = await ethersProvider.call({
to: tokenAddress,
data: iface.encodeFunctionData('decimals', [])
});
// Get total supply
const totalSupply = await ethersProvider.call({
to: tokenAddress,
data: iface.encodeFunctionData('totalSupply', [])
});// Create and sign transaction
const tx = await wallet.signTransaction({
to: tokenAddress,
data: iface.encodeFunctionData('transfer', [recipientAddress, amount]),
gasLimit: 21000,
gasPrice: await provider.getGasPrice(),
nonce: await provider.getTransactionCount(wallet.address),
chainId: 31337
});
// Send transaction
const txHash = await provider.send('eth_sendRawTransaction', [tx]);
// Wait for receipt
const receipt = await provider.waitForTransaction(txHash);Before users can interact with bridged tokens, they must register their Ethereum address.
POST /register
Registration requires an EIP-191 personal signature from the user's Ethereum wallet.
{
"signature": "0x...",
"message": "registration:1234567890"
}Headers:
X-Signature: EIP-191 signature (hex string with0xprefix)X-Message: The signed message
Body:
{}The message can be any string. Common formats:
Register for Canton Bridge(used by bootstrap scripts)registration:1234567890(timestamp-based)
Success (200 OK) - Standard EVM Registration:
{
"party": "user_f39Fd6e5::1220...",
"fingerprint": "0x...",
"mapping_cid": "0x...",
"evm_address": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
}Success (200 OK) - Canton Native User Registration:
For native Canton users (registered via party ID instead of EVM signature), the response includes credentials for MetaMask import:
{
"party": "native_alice::1220...",
"fingerprint": "0x...",
"mapping_cid": "0x...",
"evm_address": "0x2a1f1b7334144A1d706ca901f4cC496f012b74F7",
"private_key": "0x..."
}Note: The
private_keyfield is only returned for Canton native user registrations, allowing the user to import their generated EVM identity into MetaMask.
Errors:
401 Unauthorized- Invalid signature or missing authentication403 Forbidden- Address not whitelisted409 Conflict- User already registered500 Internal Server Error- Registration failed
// Sign message
const timestamp = Math.floor(Date.now() / 1000);
const message = `registration:${timestamp}`;
const signature = await wallet.signMessage(message);
// Register user
const response = await fetch('/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Signature': signature,
'X-Message': message
},
body: JSON.stringify({})
});
const result = await response.json();
console.log('Registered with fingerprint:', result.fingerprint);Before users can register, their Ethereum address must be whitelisted by an administrator. This provides access control for the bridge during controlled rollout phases.
Here's a complete example using ethers.js v6:
import { ethers } from 'ethers';
// Configuration (local development)
const RPC_URL = 'http://localhost:8081/eth';
const REGISTER_URL = 'http://localhost:8081/register';
const CHAIN_ID = 31337;
// Token addresses (local Anvil deployment)
const PROMPT_TOKEN = '0x5FbDB2315678afecb367f032d93F642f64180aa3'; // Bridged ERC-20
const DEMO_TOKEN = '0xDE30000000000000000000000000000000000001'; // Native Canton token
// ERC20 ABI (minimal)
const ERC20_ABI = [
'function balanceOf(address) view returns (uint256)',
'function transfer(address to, uint256 amount) returns (bool)',
'function name() view returns (string)',
'function symbol() view returns (string)',
'function decimals() view returns (uint8)',
'function totalSupply() view returns (uint256)'
];
async function main() {
// Connect to the API
const provider = new ethers.JsonRpcProvider(RPC_URL);
// Connect wallet (e.g., from MetaMask or private key)
const wallet = new ethers.Wallet('0x...', provider);
// Step 1: Register user
console.log('Registering user...');
const timestamp = Math.floor(Date.now() / 1000);
const message = `registration:${timestamp}`;
const signature = await wallet.signMessage(message);
const registerResponse = await fetch(REGISTER_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Signature': signature,
'X-Message': message
},
body: JSON.stringify({})
});
if (!registerResponse.ok) {
const error = await registerResponse.json();
console.error('Registration failed:', error);
return;
}
const registration = await registerResponse.json();
console.log('Registered! Fingerprint:', registration.fingerprint);
// Step 2: Get token contract (use DEMO_TOKEN or PROMPT_TOKEN)
const token = new ethers.Contract(DEMO_TOKEN, ERC20_ABI, wallet);
// Step 3: Read token info
const [name, symbol, decimals, balance] = await Promise.all([
token.name(),
token.symbol(),
token.decimals(),
token.balanceOf(wallet.address)
]);
console.log(`Token: ${name} (${symbol})`);
console.log(`Decimals: ${decimals}`);
console.log(`Balance: ${ethers.formatUnits(balance, decimals)}`);
// Step 4: Transfer tokens
const recipientAddress = '0x...';
const amount = ethers.parseUnits('10', decimals);
console.log('Transferring tokens...');
const tx = await token.transfer(recipientAddress, amount);
console.log('Transaction hash:', tx.hash);
const receipt = await tx.wait();
console.log('Transfer confirmed in block:', receipt.blockNumber);
}
main().catch(console.error);The Splice Registry API enables external wallets (such as Canton Loop) to discover the TransferFactory contract needed for Splice-standard token transfers. External wallets use the returned created_event_blob for explicit contract disclosure -- a Splice mechanism where one party shares contract state with another so they can exercise choices on it.
POST /registry/transfer-instruction/v1/transfer-factory
Returns the active CIP56TransferFactory contract ID, its CreatedEventBlob (for explicit disclosure), and the template identifier.
No request body is required. The endpoint looks up the factory contract visible to the relayer party.
curl -X POST http://localhost:8081/registry/transfer-instruction/v1/transfer-factorySuccess (200 OK):
{
"contract_id": "0021766b56d142d3c80cf362ec14a170b336edacc75dbe46a8606cbde227ab8bb4ca...",
"created_event_blob": "CgMyLjESqwMKRQAhdmtW0ULTyAzzYuw...",
"template_id": {
"package_id": "168483ce8a80e76f69f7392ceaa9ff57b1036b8fb41ccb3d410b087048195a92",
"module_name": "CIP56.TransferFactory",
"entity_name": "CIP56TransferFactory"
}
}| Field | Type | Description |
|---|---|---|
contract_id |
string | The active CIP56TransferFactory contract ID on the Canton ledger |
created_event_blob |
string | Base64-encoded CreatedEventBlob for explicit contract disclosure. External wallets include this in their DisclosedContracts when submitting transfer commands |
template_id |
object | Daml template identifier (package_id, module_name, entity_name) |
| HTTP Status | Error | Description |
|---|---|---|
| 404 | Not Found | No active CIP56TransferFactory contract exists on the ledger |
| 405 | Method Not Allowed | Only POST is accepted |
| 500 | Internal Server Error | Canton connection error or other internal failure |
External wallets like Canton Loop follow this flow to transfer tokens:
- Discover the TransferFactory -- call this endpoint to get the
contract_idandcreated_event_blob - Build the transfer command -- construct a
TransferFactory_Transferexercise command with the transfer details (sender, receiver, amount, instrument, input holdings) - Attach disclosed contracts -- include the
created_event_blobin the command'sDisclosedContractsso the submitting participant can validate the factory contract - Submit via Interactive Submission -- prepare, sign, and execute the transaction
External Wallet (Canton Loop)
|
v
POST /registry/.../transfer-factory → { contract_id, created_event_blob }
|
v
Build ExerciseCommand(TransferFactory_Transfer)
|
v
PrepareSubmission (with DisclosedContracts) → sign → ExecuteSubmission
|
v
Canton Ledger (CIP-56 Holding transfer)
External wallets also need the InstrumentID to identify which token to transfer. This is configured on the middleware and consists of:
| Field | Description | Example |
|---|---|---|
instrument_admin |
The party that administers the token instrument (typically the relayer/issuer) | BridgeIssuer::1220854a01c53e23e1437c35f6f82ae54682c30de013dbabc81131534... |
instrument_id |
The unique identifier for the token instrument | PROMPT or DEMO |
These values are set in config.e2e-local.yaml (or equivalent) under canton.instrument_admin and canton.instrument_id, and are auto-detected by the bootstrap script.
| HTTP Status | Error | Description |
|---|---|---|
| 400 | Bad Request | Invalid JSON or missing required fields |
| 401 | Unauthorized | Invalid or missing signature |
| 403 | Forbidden | Address not whitelisted |
| 409 | Conflict | User already registered |
| 500 | Internal Server Error | Database or Canton connection error |
Standard Ethereum JSON-RPC error codes:
-32700- Parse error-32600- Invalid request-32601- Method not found-32602- Invalid params-32603- Internal error
For issues and questions:
- GitHub Issues: chainsafe/canton-middleware