examples/bridge-demo/README.md
A cross-chain token bridge between an EVM chain and Linera. Deposits lock ERC-20 tokens on the EVM side and mint wrapped tokens on Linera; withdrawals burn wrapped tokens and release ERC-20s back to the user.
Three components cooperate:
| Component | EVM side | Linera side |
|---|---|---|
| Contracts / Apps | LightClient (verifies Linera blocks), FungibleBridge (holds ERC-20s, emits deposit events) | wrapped-fungible (mints/burns wrapped tokens), evm-bridge (coordinates messaging) |
| Relay | Watches for DepositInitiated events, submits receipt proofs | Manages a Linera chain (persistent state), forwards blocks to EVM for withdrawals |
| Frontend | MetaMask for EVM transactions | @linera/client for Linera queries and signing |
Everything runs in containers -- no Foundry, Linera CLI, or Solidity toolchain needed on the host.
From the linera-bridge/ directory:
# 1. Build Wasm binaries and Docker images
make demo
# 2. Start the frontend (separate terminal)
make demo-frontend
make demo is a convenience target that runs:
| Target | What it does |
|---|---|
make build-wasm | Compile fungible, wrapped-fungible, and evm-bridge Wasm binaries |
make build-all | Build Docker images (linera-test, linera-exporter, linera-bridge, foundry-jq) |
make up | Start Anvil, ScyllaDB, Linera validator+faucet, block exporter, and relay |
make demo-setup | Deploy contracts and Linera apps, write .env.local |
The frontend starts on http://localhost:5173.
The demo runs a local Anvil node that MetaMask doesn't know about. You need to configure three things:
MetaMask → Settings → Networks → Add Network → Add a network manually:
| Field | Value |
|---|---|
| Network name | Anvil |
| RPC URL | http://localhost:8545 |
| Chain ID | 31337 |
| Currency symbol | ETH |
Save and switch to the new network.
The setup script deploys the MockERC20 token from Anvil's default account (account 0), which holds the entire token supply (1000 TT). You need to import this account into MetaMask to have tokens available for deposits.
MetaMask → Account menu → Import account → Paste private key:
0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
This is Anvil's well-known dev key -- never use it on a real network.
After setup completes, the token contract address is printed in the terminal
and written to .env.local as LINERA_TOKEN_ADDRESS. To see your balance in
MetaMask:
MetaMask → Tokens → Import tokens:
| Field | Value |
|---|---|
| Token address | the LINERA_TOKEN_ADDRESS from .env.local |
| Token symbol | TT |
| Decimals | 18 |
You should see a balance of 1000 TT.
make demo-logs # tail relay logs
make down # stop everything and remove volumes
For real network deployments, run everything directly on the host instead of through Docker.
forge, cast)linera CLI (built from this repo or installed)linera-bridge CLI: cargo build -p linera-bridge --features relayFrom the linera-bridge/ directory:
make build-wasm
This compiles fungible, wrapped-fungible, and evm-bridge to
examples/target/wasm32-unknown-unknown/release/.
export FAUCET_URL=https://faucet.testnet-conway.linera.net
# Initialize a Linera wallet from the faucet
linera wallet init --faucet "$FAUCET_URL"
# Claim a chain that the relay will use as the "bridge chain"
linera wallet request-chain --faucet "$FAUCET_URL"
Note the chain ID and owner printed by request-chain — you'll need
them for both the setup script and the relay.
Pick a shared directory for coordination files between the setup script and the relay:
export SHARED_DIR="/tmp/bridge-demo-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$SHARED_DIR"
Run the setup script to deploy EVM contracts and Linera apps:
cd examples/bridge-demo
./setup.sh \
--evm-rpc-url https://base-sepolia.g.alchemy.com/v2/YOUR_KEY \
--evm-private-key 0x... \
--evm-chain-id 84532 \
--linera-bridge-chain-id <CHAIN_ID> \
--relay-owner <OWNER> \
--faucet-url "$FAUCET_URL" \
--linera-wallet ~/.config/linera/wallet.json \
--linera-keystore ~/.config/linera/keystore.json \
--linera-storage rocksdb:~/.config/linera/client.db \
--shared-dir "$SHARED_DIR"
The setup script:
--token-address to use an existing one)$SHARED_DIR (relay picks them up) and
.env.local (frontend reads them)The relay uses the same wallet, keystore, and storage as the linera CLI.
By default it reads from ~/.config/linera/ — the same location linera wallet init writes to. You can override with --wallet, --keystore,
--storage flags or LINERA_WALLET, LINERA_KEYSTORE, LINERA_STORAGE
env vars.
Pass the contract addresses and app IDs from the setup script output:
linera-bridge serve \
--rpc-url <evm-rpc> \
--faucet-url "$FAUCET_URL" \
--linera-bridge-chain-id <chain-id> \
--linera-bridge-address <bridge-app-id-on-linera> \
--linera-fungible-address <fungible-app-id-on-linera> \
--evm-bridge-address <bridge-contract-address-on-evm> \
--evm-private-key <relayer-private-key>
On restart, run the same command — the relay loads persistent state from the wallet and storage, and syncs from validators to catch up.
pnpm install && pnpm dev
Open http://localhost:5173, connect MetaMask to your EVM network, and use the deposit/withdraw forms.
| Flag | Default (Docker) | Default (Direct) | Description |
|---|---|---|---|
--compose-file PATH | -- | -- | Enables Docker mode |
--evm-rpc-url URL | http://anvil:8545 | http://localhost:8545 | EVM JSON-RPC endpoint |
--evm-private-key KEY | Anvil account 0 | required | Private key for EVM txs |
--evm-chain-id ID | 31337 | 31337 | EVM chain ID |
--light-client-address ADDR | read from /shared/ | deployed if omitted | Skip LightClient deploy |
--linera-bridge-chain-id ID | polled from relay | required | Linera bridge chain (64 hex chars) |
--token-address ADDR | deployed | deployed | Skip MockERC20 deploy |
--relay-owner OWNER | read from /shared/ | required | Relay's AccountOwner (minter) |
--faucet-url URL | http://localhost:8080 | http://localhost:8080 | Linera faucet |
--relay-url URL | http://localhost:3001 | http://localhost:3001 | Relay HTTP endpoint |
--ticker-symbol SYM | wTT | wTT | Wrapped token ticker |
--fund-amount WEI | 500e18 | 500e18 | Fund bridge with ERC-20; 0 to skip |
--shared-dir PATH | auto (/tmp/bridge-demo-*) | auto | Shared state directory for relay coordination |
--wasm-dir PATH | /wasm | ../../examples/target/wasm32-unknown-unknown/release | Directory with .wasm binaries |
--contracts-dir PATH | /contracts | ../../linera-bridge/src/solidity | Solidity source root |
--output PATH | .env.local | .env.local | Output env file |
--linera-wallet PATH | -- | auto (temp dir) | Path to existing Linera wallet.json |
--linera-keystore PATH | -- | auto (temp dir) | Path to existing Linera keystore.json |
--linera-storage CONFIG | -- | auto (temp dir) | Linera storage config (e.g. rocksdb:path/to/db) |
FungibleBridgeFungibleBridge.deposit(chainId, appId, owner, amount)DepositInitiated eventevm-bridge Linera appevm-bridge verifies the proof and tells wrapped-fungible to mint tokenswrapped-fungible.transfer targeting the relay's chain with an
Address20 owner (their EVM address)Credit message triggers an auto-burn and emits a
BurnEvent on the "burns" streamBurnEvent and forwards the Linera block to
FungibleBridge.addBlock()FungibleBridge verifies the block via LightClient, deserializes the
BurnEvent, and transfers ERC-20 tokens to the user's EVM addressThe relay actively scans both chains to detect missed or failed bridging requests and automatically retries them:
DepositInitiated events
and checks the Linera evm-bridge app to see if each deposit has been
processed. Unprocessed deposits are retried by regenerating the MPT proof
and resubmitting.BurnEvent events
on the "burns" stream and checks EVM for matching ERC-20 Transfer events.
Unforwarded burns are retried by re-reading the block containing the
auto-burn from chain storage and re-calling addBlock.This means the relay self-heals after crashes or transient RPC failures
without operator intervention. On-chain replay protection (processed_deposits
on Linera, verifiedBlocks on EVM) makes retries safe.
Monitoring endpoints:
| Endpoint | Description |
|---|---|
GET /monitor/status | Summary counts of pending/completed deposits and burns |
GET /monitor/deposits?status=pending | List pending deposits |
GET /monitor/burns?status=pending | List unforwarded burns |
Relay flags for tuning:
| Flag | Default | Description |
|---|---|---|
--monitor-scan-interval | 30 | Seconds between chain scan iterations |
--monitor-start-block | 0 | EVM block to start scanning from |
--max-retries | 10 | Max retry attempts before marking an item as failed |
The frontend reads these variables from .env.local (generated by setup.sh):
LINERA_FAUCET_URL
LINERA_APPLICATION_ID # wrapped-fungible app ID
LINERA_BRIDGE_APP_ID # evm-bridge app ID
LINERA_RELAY_URL
LINERA_BRIDGE_ADDRESS # FungibleBridge contract
LINERA_TOKEN_ADDRESS # ERC-20 token contract
LINERA_BRIDGE_CHAIN_ID
LINERA_EVM_CHAIN_ID
All are required -- the Vite dev server will refuse to start if any are missing.
The evm-bridge Linera app verifies that deposit blocks are finalized by
querying an EVM JSON-RPC endpoint (the rpc_endpoint parameter). This endpoint
hostname must be in the Linera network's HTTP request allow list.
--http-request-allow-list to
linera net up, defaulting to anvil. Override with the
HTTP_REQUEST_ALLOW_LIST env var (e.g.
HTTP_REQUEST_ALLOW_LIST=base-sepolia.g.alchemy.com).base-sepolia.g.alchemy.com). Contact the testnet operators
to add your RPC provider's hostname to the allow list.If the RPC hostname is not whitelisted, deposits will fail with
UnauthorizedHttpRequest. As a workaround, set rpc_endpoint to empty in the
evm-bridge parameters to skip finality verification (not recommended for
production).
.env.local, and that you are on the correct account.