This document describes how MetaMask, the API Server, the Relayer, and the Canton Ledger work together to enable ERC-20 compatible token operations on Canton Network.
┌─────────────────────────────────────────────────────────────────────────────┐
│ USER LAYER │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ 🦊 MetaMask │ │ Native Canton │ │
│ │ (EVM Wallet) │ │ User / CLI │ │
│ └────────┬─────────┘ └────────┬─────────┘ │
│ │ eth_sendRawTransaction │ eth_sendRawTransaction│
│ │ eth_call, eth_getBalance │ (via /eth endpoint) │
└─────────────┼─────────────────────────────────────────┼─────────────────────┘
│ │
▼ │
┌─────────────────────────────────────────────────────────────────────────────┐
│ MIDDLEWARE LAYER │
│ │
│ ┌─────────────────────────────┐ ┌─────────────────────────┐ │
│ │ API SERVER │ │ RELAYER │ │
│ │ (Port 8081) │ │ (Background Service) │ │
│ │ │ │ │ │
│ │ • /eth - JSON-RPC facade │ │ • Ethereum → Canton │ │
│ │ • /register - User signup │ │ • Canton → Ethereum │ │
│ │ • /health - Status check │ │ • Event processing │ │
│ │ │ │ │ │
│ │ Custodial key management: │ │ Bridges PROMPT token │ │
│ │ Holds Canton keys for all │ │ between chains │ │
│ │ registered users │ │ │ │
│ └──────────────┬──────────────┘ └──────────────┬──────────┘ │
│ │ │ │
│ └─────────────┬───────────────────────┘ │
│ │ │
│ ┌──────────▼──────────┐ │
│ │ PostgreSQL │ │
│ │ (Port 5432) │ │
│ │ │ │
│ │ • User registry │ │
│ │ • Balance cache │ │
│ │ • Transfer state │ │
│ │ • Chain offsets │ │
│ └──────────┬──────────┘ │
└───────────────────────────────┼──────────────────────────────────────────────┘
│
gRPC + OAuth2 │
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ CANTON LEDGER │
│ (Source of Truth) │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ DAML Smart Contracts (CIP-56) │ │
│ │ │ │
│ │ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ │
│ │ │ FingerprintMapping│ │ CIP56Holding │ │ TokenMeta │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Links EVM addr │ │ Actual token │ │ DEMO: Native │ │ │
│ │ │ to Canton Party │ │ balances per │ │ PROMPT: Bridged │ │ │
│ │ │ │ │ user per token │ │ │ │ │
│ │ └──────────────────┘ └──────────────────┘ └──────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
│ Ports: 5011 (gRPC), 5013 (HTTP) │
└─────────────────────────────────────────────────────────────────────────────┘
▲
│ Bridge Events (PROMPT only)
│
┌─────────────────────────────────────────────────────────────────────────────┐
│ ETHEREUM (Anvil Local / Sepolia Testnet) │
│ │
│ ┌──────────────────────────┐ ┌──────────────────────────┐ │
│ │ Bridge Contract │◄───│ PROMPT Token │ │
│ │ │ │ (ERC-20) │ │
│ │ • depositToCanton() │ │ │ │
│ │ • withdrawToEthereum() │ │ Local: 0x5FbDB231... │ │
│ │ │ │ │ │
│ │ Local: 0xe7f1725E... │ │ │ │
│ └──────────────────────────┘ └──────────────────────────┘ │
│ │
│ Port: 8545 (Anvil) │
└─────────────────────────────────────────────────────────────────────────────┘
The API Server provides an Ethereum JSON-RPC compatible interface that allows MetaMask and other EVM wallets to interact with Canton tokens.
| Endpoint | Purpose |
|---|---|
/eth |
JSON-RPC facade (eth_call, eth_sendRawTransaction, etc.) |
/register |
User registration with EIP-191 signature |
/health |
Health check endpoint |
Key responsibilities:
- Translate ERC-20 calls to CIP-56 DAML operations
- Manage custodial Canton keys for all registered users
- Cache balances in PostgreSQL for fast queries
- Reconcile database cache with Canton ledger periodically
The Relayer bridges PROMPT tokens between Ethereum and Canton.
Bidirectional processing:
- Ethereum → Canton: Watches for
depositToCanton()events, mints CIP-56 tokens - Canton → Ethereum: Watches for withdrawal requests, releases ERC-20 tokens
Design principles:
- At-least-once delivery with idempotency
- Crash recovery via persisted offsets
- Database-backed deduplication
Serves as a distributed indexer and cache:
- User registry (EVM address ↔ Canton Party mapping)
- Balance cache for fast MetaMask queries
- Transfer state tracking for idempotency
- Chain offsets for crash recovery
The source of truth for all token balances.
DAML Contracts:
FingerprintMapping- Links EVM addresses to Canton partiesCIP56Holding- Actual token balances (one contract per user per token)TokenMeta- Token configuration (DEMO native, PROMPT bridged)
| Token | Type | Virtual Address | Description |
|---|---|---|---|
| DEMO | Native Canton | 0xDE30000000000000000000000000000000000001 |
Created directly on Canton |
| PROMPT | Bridged ERC-20 | 0x5FbDB2315678afecb367f032d93F642f64180aa3 |
Bridged from Ethereum |
User sends tokens to another user via MetaMask.
┌──────────┐ ┌───────────┐ ┌──────────┐ ┌────────┐
│ MetaMask │ │API Server │ │PostgreSQL│ │ Canton │
└────┬─────┘ └─────┬─────┘ └────┬─────┘ └───┬────┘
│ │ │ │
│ eth_sendRawTx │ │ │
│ (ERC-20 transfer) │ │
│────────────────>│ │ │
│ │ │ │
│ │ Decode tx, verify whitelist │
│ │ Get sender/recipient parties │
│ │────────────────>│ │
│ │ │ │
│ │ TransferAsUserByFingerprint │
│ │───────────────────────────────>│
│ │ │ │
│ │ │ CIP56 Transfer
│ │ │ (exercises choice)
│ │ │ │
│ │<──────────────────────────────│
│ │ │ │
│ │ Update balance cache │
│ │────────────────>│ │
│ │ │ │
│ tx receipt │ │ │
│<────────────────│ │ │
│ │ │ │
User deposits PROMPT tokens from Ethereum to Canton.
┌──────────┐ ┌──────────┐ ┌─────────┐ ┌──────────┐ ┌────────┐
│ User │ │ Ethereum │ │ Relayer │ │PostgreSQL│ │ Canton │
└────┬─────┘ └────┬─────┘ └────┬────┘ └────┬─────┘ └───┬────┘
│ │ │ │ │
│ depositToCanton() │ │ │
│───────────────>│ │ │ │
│ │ │ │ │
│ │ Emit Deposit event │ │
│ │───────────────>│ │ │
│ │ │ │ │
│ │ │ Check if processed │
│ │ │───────────────>│ │
│ │ │ │ │
│ │ │ Create pending transfer │
│ │ │───────────────>│ │
│ │ │ │ │
│ │ │ BridgeMint (CIP56) │
│ │ │──────────────────────────────>│
│ │ │ │ │
│ │ │ │ Create Holding
│ │ │ │ │
│ │ │<─────────────────────────────│
│ │ │ │ │
│ │ │ Update status = completed │
│ │ │───────────────>│ │
│ │ │ │ │
API Server syncs database cache with Canton ledger.
┌───────────┐ ┌──────────┐ ┌────────┐
│API Server │ │PostgreSQL│ │ Canton │
└─────┬─────┘ └────┬─────┘ └───┬────┘
│ │ │
│ GetAllCIP56Holdings │
│───────────────────────────────>│
│ │ │
│<──────────────────────────────│
│ [list of all holdings] │
│ │ │
│ Group by party, sum by token │
│ │ │
│ For each registered user: │
│ UpdateBalanceByCantonPartyID │
│───────────────>│ │
│ │ │
│ Repeat... │ │
│───────────────>│ │
│ │ │
The Relayer uses a generic Processor pattern for bidirectional event handling:
sequenceDiagram
participant Engine
participant Processor
participant Source
participant Store as Database
participant Destination
Engine->>Processor: Start(offset)
Processor->>Source: StreamEvents(offset)
loop For each event
Source-->>Processor: Event
Processor->>Store: Check if processed
alt Not yet processed
Processor->>Store: CreateTransfer(Pending)
Processor->>Destination: SubmitTransfer(event)
Destination-->>Processor: txHash
Processor->>Store: UpdateStatus(Completed)
end
end
Two processor instances:
- Canton → Ethereum:
CantonSource+EthereumDestination - Ethereum → Canton:
EthereumSource+CantonDestination
All users are allocated as external parties on Canton. This is a key architectural decision that removes the ~200 internal party limit and enables interoperability with wallets like Canton Loop.
| Party Type | Key Management | Submission Method | Limit |
|---|---|---|---|
| Internal | Participant node holds keys | CommandService.SubmitAndWait |
~200 per participant |
| External (used) | API server holds keys custodially | Interactive Submission API | No practical limit |
All token transfers use the Interactive Submission flow:
1. PrepareSubmission → Canton returns transaction hash to sign
2. Sign hash → API server signs with user's custodial secp256k1 key
3. ExecuteSubmission → Canton executes the signed transaction
The API server stores each user's Canton signing key encrypted at rest (AES-256-GCM with CANTON_MASTER_KEY). When a user sends a transfer via /eth, the server decrypts their key, signs the prepared transaction, and submits it.
The middleware connects to Canton via the Daml Ledger gRPC API v2:
Canton Participant Node
│
├── Ledger API (gRPC, port 5011)
│ ├── InteractiveSubmissionService - Prepare/execute (external parties)
│ ├── CommandService - Submit transactions (relayer/operator)
│ ├── UpdateService - Stream events
│ └── StateService - Query active contracts
│
└── HTTP API (port 5013) - Health/version checks
Why gRPC (not JSON API):
- First-class, production-grade API
- Streaming with offsets for reliability
- Built-in deduplication via command IDs
- Generated Go stubs from protobuf definitions
Note: gRPC connections through ALBs/proxies require ALPN to be disabled (grpc-go >= 1.67 enforces ALPN by default). The SDK uses
expcreds.NewTLSWithALPNDisabledto handle this.
Canton uses JWT-based authorization via OAuth2:
// JWT claims structure
{
"actAs": ["BridgeOperatorParty"], // Can submit commands
"readAs": ["BridgeOperatorParty"], // Can read events
"exp": 1234567890
}The middleware authenticates via OAuth2 to obtain JWT tokens. User transfers are submitted by the operator party using Interactive Submission with the user's external party key.
- Bidirectional & Independent - Two separate one-way flows that don't depend on each other
- At-Least-Once Delivery - Every event is guaranteed to be processed at least once
- Idempotency - Database-backed deduplication prevents duplicate processing
- Crash Recovery - Persisted offsets allow resuming from exact position after restart
Offsets (Checkpoints):
- Canton:
LedgerOffset(absolute string) - Ethereum:
BlockNumber - Stored in
chain_statetable - Updated only after successful processing
Idempotency:
- Every event has a unique ID
- Canton→Eth: Canton Event ID
- Eth→Canton: Hash of
(TxHash, LogIndex) - Checked against
transferstable before processing
The API Server runs periodic reconciliation (every 5 minutes):
- Query all
CIP56Holdingcontracts from Canton - Group by party and token
- Update cached balances in PostgreSQL
- Log any stuck transfers for investigation
All users are external parties on Canton, with the API Server custodially holding their secp256k1 signing keys:
- Same elliptic curve as Ethereum (enables future MetaMask Snap trustless signing)
- Keys encrypted at rest with
CANTON_MASTER_KEY(AES-256-GCM) - Stored in PostgreSQL
userstable - Transactions use the Interactive Submission API (PrepareSubmission/ExecuteSubmission)
This enables MetaMask users to interact without Canton tooling and removes the ~200 internal party limit. Trade-off: Users trust the API Server with their Canton keys (mitigated by future MetaMask Snap integration).
- Fast balance queries from PostgreSQL
- Periodic reconciliation ensures consistency
- Canton ledger is always authoritative
Users are identified by keccak256(evmAddress):
- Links EVM identity to Canton party
- Enables cross-chain user lookup
- Stored in
FingerprintMappingDAML contract
Native Canton tokens use synthetic addresses:
- DEMO:
0xDE30000000000000000000000000000000000001 - Allows MetaMask to "import" Canton-native tokens
| Service | Port | Protocol |
|---|---|---|
| API Server | 8081 | HTTP (JSON-RPC) |
| Anvil (Ethereum) | 8545 | HTTP (JSON-RPC) |
| Canton gRPC | 5011 | gRPC |
| Canton HTTP | 5013 | HTTP |
| PostgreSQL | 5432 | PostgreSQL |
| Relayer Metrics | 9090 | HTTP |