linera-bridge/deploy/README.md
Provision a complete EVM↔Linera bridge against a real EVM network (Base, Ethereum, …) and a real Linera network, by running a sequence of copy-pasteable commands inside the project's pre-built Docker images. No host toolchain beyond Docker (and a Rust toolchain once, to build the Wasm modules).
Every step runs in one of three images, through a thin shell wrapper you paste
once (docker-foundry, docker-linera, docker-linera-bridge). Values produced
by one step (addresses, app IDs, the bridge chain) are copied forward as
environment variables in a single shell session — what you see is what runs.
Deploying a bridge is not the same as running it. This runbook provisions contracts, apps, and registrations, then emits a relayer env file. To operate the relayer afterwards, see
docker/README.testnet.md.
A bridge spans both chains, and the pieces have a strict creation order because later steps consume earlier outputs:
Linera faucet ──init-light-client──▶ committee args (+ pause-guardian, proposer)
│
EVM: LightClient ◀───────────────────────┘
Token (reuse existing ERC-20, or deploy LineraToken)
Linera: request bridge chain ──▶ chain id + owner
wrapped-fungible app ──▶ WRAPPED_APP_ID (needs token, chain ids)
evm-bridge app ──▶ BRIDGE_APP_ID (needs WRAPPED_APP_ID)
EVM: FungibleBridge ◀── LightClient + chain id + token + app ids + governance
│
Linera: register on both sides ◀──────────┘
(wrapped: authorize bridge; evm-bridge: record FungibleBridge addr)
| Side | Artifact | Notes |
|---|---|---|
| EVM | LightClient | Verifies Linera certificates; genesis committee from the faucet |
| EVM | ERC-20 token | An existing token, or a freshly-deployed LineraToken |
| EVM | FungibleBridge | Locks deposited ERC-20; releases it on verified BurnEvents |
| Linera | bridge chain | A chain the relayer owns and drives |
| Linera | wrapped-fungible app | Mints/burns wrapped tokens |
| Linera | evm-bridge app | Verifies EVM deposit proofs; coordinates mint/burn |
| Wrapper | Image | Built by | Provides |
|---|---|---|---|
docker-foundry | foundry-jq | Dockerfile.foundry | forge, cast, jq |
docker-linera | linera-test | Dockerfile (--target runtime) | the linera client (/linera) |
docker-linera-bridge | linera-bridge | Dockerfile.bridge | the linera-bridge CLI |
On the host running the deploy:
$OUT below must live inside the repo, not /tmp).forge can compile the contracts:
git submodule update --init --recursive
make -C linera-bridge build-all
foundry-jq, linera-test, linera-bridge, and (via its
build-wasm dependency) the four .wasm modules that publish-and-create
needs. Building the Wasm requires a Rust toolchain with the
wasm32-unknown-unknown target (rustup target add wasm32-unknown-unknown);
it is the one host toolchain this runbook assumes. Verify the modules exist:
ls examples/target/wasm32-unknown-unknown/release/wrapped_fungible_{contract,service}.wasm \
linera-bridge/contracts/evm-bridge/target/wasm32-unknown-unknown/release/evm_bridge_{contract,service}.wasm
addBlock gas).PAUSE_GUARDIAN, a PROPOSER, and a CANCELLER (see
Governance roles).Run every command below from the repo root, in a single shell session (the wrappers and the copied-forward
exports live in that session).
Fill in the per-deployment values, then paste the wrappers and helpers.
# ── Per-deployment configuration ──
export NETWORK=base-sepolia
export RPC_URL=https://sepolia.base.org # target EVM JSON-RPC
export FAUCET_URL=https://faucet.<your-linera-net> # faucet of the Linera network you bridge to
export EVM_PRIVATE_KEY=0x... # funded deployer key
# Governance roles (EVM addresses you control)
export PAUSE_GUARDIAN=0x...
export PROPOSER=0x...
export CANCELLER=0x...
export TIMELOCK_DELAY=172800 # FungibleBridge timelock, seconds (e.g. 48h)
# RPC the Linera validators use for EVM finality (must be allow-listed; defaults to RPC_URL)
export EVM_RPC_ENDPOINT_FOR_LINERA="${EVM_RPC_ENDPOINT_FOR_LINERA:-$RPC_URL}"
# Output dir — holds the bridge-chain wallet. BACK THIS UP. Must be under the repo.
export OUT="$PWD/linera-bridge/deploy/out/$NETWORK"
mkdir -p "$OUT/wallet"
# ── Wrappers (paste once) ──
# forge / cast / jq. Its entrypoint is `sh -c`, so the command is one string;
# $RPC_URL and $EVM_PRIVATE_KEY are expanded INSIDE the container (forwarded via -e).
docker-foundry() {
docker run --rm \
-e RPC_URL -e EVM_PRIVATE_KEY \
-e LIGHT_CLIENT_ARGS_JSON_FILE \
-e TOKEN_ADDRESS \
-v "$PWD/linera-bridge/src/solidity:/contracts" \
-v "$OUT:/shared" \
-w /contracts \
foundry-jq "$*"
}
# linera client. Wallet/keystore/storage persist in $OUT/wallet across calls.
# Wasm modules are reachable under /repo.
docker-linera() {
docker run --rm \
-e LINERA_WALLET=/data/wallet.json \
-e LINERA_KEYSTORE=/data/keystore.json \
-e LINERA_STORAGE=rocksdb:/data/client.db \
-v "$OUT/wallet:/data" \
-v "$PWD:/repo" \
linera-test /linera "$@"
}
# linera-bridge CLI (its image entrypoint is the relayer; override it).
docker-linera-bridge() {
docker run --rm \
-v "$OUT:/shared" \
--entrypoint linera-bridge \
linera-bridge "$@"
}
# Extract a deployed address from a forge broadcast file by contract name:
# deployed_addr <ScriptFile.s.sol> <ContractName>
deployed_addr() {
docker-foundry "jq -r '.transactions[]|select(.contractName==\"$2\")|.contractAddress' broadcast/$1/$EVM_CHAIN_ID/run-latest.json"
}
# Convert a 0x EVM address to the JSON byte array the Linera apps expect.
addr_to_bytes() {
local h=${1#0x} out="" i
for (( i=0; i<${#h}; i+=2 )); do out="$out,$(( 16#${h:$i:2} ))"; done
echo "[${out#,}]"
}
Detect the EVM chain id (used by later steps and the broadcast paths):
export EVM_CHAIN_ID=$(docker-foundry 'cast chain-id --rpc-url $RPC_URL')
echo "EVM chain id: $EVM_CHAIN_ID"
Query the faucet and write the LightClient genesis constructor args (validator
addresses, weights, admin chain, epoch, and the pause-guardian/proposer roles)
to $OUT/light-client-args.json:
docker-linera-bridge init-light-client \
--faucet-url "$FAUCET_URL" \
--output /shared/light-client-args.json \
--pause-guardian "$PAUSE_GUARDIAN" \
--proposer "$PROPOSER"
export LIGHT_CLIENT_ARGS_JSON_FILE=/shared/light-client-args.json
docker-foundry 'forge script script/DeployLightClient.s.sol \
--rpc-url $RPC_URL --private-key $EVM_PRIVATE_KEY --broadcast'
export LIGHT_CLIENT_ADDRESS=$(deployed_addr DeployLightClient.s.sol LightClient)
echo "LightClient: $LIGHT_CLIENT_ADDRESS"
To verify the contract on a block explorer, append
--verify --etherscan-api-key $KEY --verifier-url $URL to the forge command
(see Per-network notes).
Reuse an existing ERC-20 (the common case) — set its address, then read
decimals() straight from the contract so the wrapped app can't drift from it:
export TOKEN_ADDRESS=0x... # existing ERC-20 on THIS network
export TOKEN_DECIMALS=$(docker-foundry 'cast call $TOKEN_ADDRESS "decimals()(uint8)" --rpc-url $RPC_URL')
echo "Token $TOKEN_ADDRESS has $TOKEN_DECIMALS decimals"
Sanity-check the address actually has code on this network — a mainnet-only
address (or an EOA) returns 0x here, and the relayer would later abort on its
decimals() query:
docker-foundry 'cast code $TOKEN_ADDRESS --rpc-url $RPC_URL' | head -c 12; echo # 0x → not a contract here, stop
…or deploy a fresh LineraToken — here you choose the new token's
decimals; the same value feeds the token and the wrapped app, so they match.
These TOKEN_* vars are read only by DeployLineraToken, so they're passed on
this one step instead of cluttering the shared wrapper:
export TOKEN_NAME=LineraToken TOKEN_SYMBOL=LIN TOKEN_DECIMALS=18 \
TOKEN_SUPPLY=1000000000000000000000
docker run --rm \
-v "$PWD/linera-bridge/src/solidity:/contracts" -w /contracts \
-e RPC_URL -e EVM_PRIVATE_KEY \
-e TOKEN_NAME -e TOKEN_SYMBOL -e TOKEN_DECIMALS -e TOKEN_SUPPLY \
foundry-jq 'forge script script/DeployLineraToken.s.sol \
--rpc-url $RPC_URL --private-key $EVM_PRIVATE_KEY --broadcast'
export TOKEN_ADDRESS=$(deployed_addr DeployLineraToken.s.sol LineraToken)
echo "Token: $TOKEN_ADDRESS ($TOKEN_DECIMALS decimals)"
docker-linera wallet init --faucet "$FAUCET_URL"
docker-linera wallet request-chain --faucet "$FAUCET_URL" --set-default
--set-default makes the new chain the wallet's default, so the
publish-and-create steps below (which have no --chain-id flag) target it.
From the request-chain output, copy the 64-hex chain id (first line) and
the 0x… owner (second line) into:
export BRIDGE_CHAIN_ID=<64-hex chain id>
export BRIDGE_CHAIN_OWNER=0x<64-hex owner>
docker-linera sync || true
docker-linera process-inbox || true
export WRAPPED_PARAMS="{\"ticker_symbol\":\"$TICKER_SYMBOL\",\"decimals\":$TOKEN_DECIMALS,\"mint_chain_id\":\"$BRIDGE_CHAIN_ID\",\"evm_token_address\":$(addr_to_bytes $TOKEN_ADDRESS),\"evm_source_chain_id\":$EVM_CHAIN_ID}"
docker-linera publish-and-create \
/repo/examples/target/wasm32-unknown-unknown/release/wrapped_fungible_contract.wasm \
/repo/examples/target/wasm32-unknown-unknown/release/wrapped_fungible_service.wasm \
--json-parameters "$WRAPPED_PARAMS" \
--json-argument '{"accounts":{}}'
Copy the printed 64-hex application id:
export WRAPPED_APP_ID=<64-hex app id>
docker-linera sync || true
docker-linera process-inbox || true
export BRIDGE_PARAMS="{\"source_chain_id\":$EVM_CHAIN_ID,\"token_address\":$(addr_to_bytes $TOKEN_ADDRESS),\"bridge_chain_id\":\"$BRIDGE_CHAIN_ID\",\"fungible_app_id\":\"$WRAPPED_APP_ID\"}"
export BRIDGE_ARG="{\"rpc_endpoint\":\"\"}" # Fill in with EVM endpoint when Linera network is allowed to query it
docker-linera publish-and-create \
/repo/linera-bridge/contracts/evm-bridge/target/wasm32-unknown-unknown/release/evm_bridge_contract.wasm \
/repo/linera-bridge/contracts/evm-bridge/target/wasm32-unknown-unknown/release/evm_bridge_service.wasm \
--json-parameters "$BRIDGE_PARAMS" \
--json-argument "$BRIDGE_ARG" \
--required-application-ids "$WRAPPED_APP_ID"
Copy the printed 64-hex application id:
export BRIDGE_APP_ID=<64-hex app id>
This is the one step that doesn't use the docker-foundry wrapper: the
constructor inputs are passed as explicit -e env (and the app IDs / chain id
need a 0x prefix that the Linera-side values don't carry):
docker run --rm \
-v "$PWD/linera-bridge/src/solidity:/contracts" -w /contracts \
-e RPC_URL -e EVM_PRIVATE_KEY \
-e LIGHT_CLIENT="$LIGHT_CLIENT_ADDRESS" \
-e TOKEN_ADDRESS="$TOKEN_ADDRESS" \
-e BRIDGE_CHAIN_ID="0x$BRIDGE_CHAIN_ID" \
-e FUNGIBLE_APP_ID="0x$WRAPPED_APP_ID" \
-e BRIDGE_APP_ID="0x$BRIDGE_APP_ID" \
-e PAUSE_GUARDIAN -e PROPOSER -e CANCELLER -e TIMELOCK_DELAY \
foundry-jq 'forge script script/DeployFungibleBridge.s.sol \
--rpc-url $RPC_URL --private-key $EVM_PRIVATE_KEY --broadcast'
export BRIDGE_ADDRESS=$(deployed_addr DeployFungibleBridge.s.sol FungibleBridge)
echo "FungibleBridge: $BRIDGE_ADDRESS"
Authorize the bridge on the wrapped app, then record the FungibleBridge address
in the evm-bridge app. Both operations run on the bridge chain. (The operation
bytes are the BCS variant tag followed by the payload: 08 +
evm-bridge app id for RegisterAuthorizedCaller; 02 + 20-byte EVM address
for RegisterFungibleBridge.)
# wrapped-fungible: RegisterAuthorizedCaller(evm-bridge app id)
docker-linera execute-operation \
--application-id "$WRAPPED_APP_ID" \
--operation "08$BRIDGE_APP_ID" \
--chain-id "$BRIDGE_CHAIN_ID"
# evm-bridge: RegisterFungibleBridge(FungibleBridge address)
export BRIDGE_ADDR_HEX=$(echo "${BRIDGE_ADDRESS#0x}" | tr 'A-Z' 'a-z')
docker-linera execute-operation \
--application-id "$BRIDGE_APP_ID" \
--operation "02$BRIDGE_ADDR_HEX" \
--chain-id "$BRIDGE_CHAIN_ID"
These registrations are set-once; re-running them against an already-registered bridge fails at the app level — expected.
You do not pre-fund the FungibleBridge. It holds no float of its own — it is
collateralized 1:1 by deposits: deposit() locks the sender's ERC-20 into the
bridge (_safeTransferFrom(msg.sender, address(this), …)) while the evm-bridge
app mints the wrapped token on Linera; a Linera-side burn then releases exactly
that locked amount back out (_safeTransfer(target, …)). Every withdrawal is
therefore backed by a prior deposit — there is nothing to seed on the bridge
itself.
What you do need is an EVM account holding the ERC-20 to make that first deposit:
Reused token (e.g. Base Sepolia USDC): get test tokens from the token's own faucet (Circle's Base Sepolia USDC faucet) into the account you'll bridge from.
Fresh LineraToken: the deployer holds the entire supply — send some to the
test account:
export RECIPIENT=0x... # the account you'll deposit from
export SEED_AMOUNT=100000000000000000000 # 100 LIN at 18 decimals
docker run --rm -e RPC_URL -e EVM_PRIVATE_KEY foundry-jq \
"cast send $TOKEN_ADDRESS 'transfer(address,uint256)' $RECIPIENT $SEED_AMOUNT \
--rpc-url \$RPC_URL --private-key \$EVM_PRIVATE_KEY"
# Deploy block seeds the relayer's start block (hex → decimal).
RAW_BLOCK=$(docker-foundry "jq -r '.receipts[0].blockNumber' broadcast/DeployFungibleBridge.s.sol/$EVM_CHAIN_ID/run-latest.json")
export MONITOR_START_BLOCK=$(( RAW_BLOCK ))
cat > "$OUT/relayer.env" <<EOF
# Relayer env for the '$NETWORK' bridge deployment.
# Wallet paths are CONTAINER paths: bind-mount the host $OUT/wallet at /data.
# EVM_PRIVATE_KEY is intentionally NOT written here — supply it separately.
RPC_URL=$RPC_URL
FAUCET_URL=$FAUCET_URL
EVM_BRIDGE_ADDRESS=$BRIDGE_ADDRESS
LINERA_BRIDGE_APP=$BRIDGE_APP_ID
LINERA_FUNGIBLE_APP=$WRAPPED_APP_ID
LINERA_BRIDGE_CHAIN_ID=$BRIDGE_CHAIN_ID
LINERA_BRIDGE_CHAIN_OWNER=$BRIDGE_CHAIN_OWNER
LINERA_WALLET=/data/wallet.json
LINERA_KEYSTORE=/data/keystore.json
LINERA_STORAGE=rocksdb:/data/client.db
MONITOR_SCAN_INTERVAL=30
MONITOR_START_BLOCK=$MONITOR_START_BLOCK
MAX_RETRIES=10
# eth_getLogs chunk size — match your RPC's max getLogs range. 2000 works with the
# public Base Sepolia RPC; raise it for providers that allow larger ranges. Alchemy's
# free tier caps at 10 (impractical) — use the public RPC or a paid plan.
MAX_LOG_BLOCK_RANGE=10
PORT=3001
EOF
echo "Wrote $OUT/relayer.env"
cat "$OUT/relayer.env"
| Output | Where |
|---|---|
LightClient (EVM) | $LIGHT_CLIENT_ADDRESS |
FungibleBridge (EVM) | $BRIDGE_ADDRESS |
| Token (EVM) | $TOKEN_ADDRESS |
evm-bridge (Linera) | $BRIDGE_APP_ID |
wrapped-fungible (Linera) | $WRAPPED_APP_ID |
| Bridge chain id / owner | $BRIDGE_CHAIN_ID / $BRIDGE_CHAIN_OWNER |
| Bridge-chain wallet | $OUT/wallet/ — back this up |
| Relayer env | $OUT/relayer.env |
The same linera-bridge image runs the relayer. It consumes relayer.env
directly, reads the bridge-chain wallet from a bind-mount at /data, and takes
EVM_PRIVATE_KEY from the environment (it is deliberately not written to
relayer.env). No GCP or compose stack is needed — just docker run:
docker run -d --name linera-relay \
--env-file "$OUT/relayer.env" \
-e EVM_PRIVATE_KEY \
-v "$OUT/wallet:/data" \
-p 3001:3001 \
linera-bridge
The image's entrypoint builds linera-bridge serve from those env vars
(relayer.env already points LINERA_WALLET / LINERA_KEYSTORE /
LINERA_STORAGE at /data/..., and -p matches its PORT=3001). Check it
came up and is scanning both chains:
docker logs -f linera-relay # follow startup
curl -sI http://127.0.0.1:3001/health | head -1 # HTTP/1.1 200 OK
curl -s http://127.0.0.1:3001/metrics | grep '^linera_bridge_' | head
Stop and remove it with docker rm -f linera-relay; re-run the docker run
above to restart — the wallet and relay state persist in $OUT/wallet/.
The relayer signs
addBlocktransactions on the EVM side, so theEVM_PRIVATE_KEYaccount must hold gas on the target network. Iflinera_bridge_evm_balance_weireads low, top it up (no restart needed).
For operating the relayer as a long-running service on a VM — GCP Secret Manager
for the key, Prometheus alerts, backups — see
docker/README.testnet.md.
PAUSE_GUARDIAN — can emergency-pause block registration on the
LightClient/bridge; a governance role that cannot move funds.PROPOSER — gates committee-epoch maintenance (expireEpochsBelow) on the
LightClient, and proposes timelocked changes on the FungibleBridge.CANCELLER — can cancel a pending timelocked change on the FungibleBridge
(must differ from the proposer).TIMELOCK_DELAY — seconds a queued FungibleBridge governance change must
wait before execution.Use addresses you control (ideally multisigs). They are baked into the contracts at deploy time.
| Network | EVM RPC | Explorer verifier URL |
|---|---|---|
| Base Sepolia | https://sepolia.base.org | https://api-sepolia.basescan.org/api |
| Ethereum Sepolia | a Sepolia RPC | https://api-sepolia.etherscan.io/api |
| Base / Ethereum mainnet | a mainnet RPC | basescan/etherscan …/api |
For mainnet you almost always reuse an existing ERC-20 (step 3, first option).
The bridge needs no liquidity seeding — it is collateralized by deposits (see
step 9). To verify contracts, append
--verify --etherscan-api-key <KEY> --verifier-url <URL> to the forge commands.
The evm-bridge app verifies EVM deposit finality by calling the configured RPC
endpoint (EVM_RPC_ENDPOINT_FOR_LINERA) from inside the Linera validators.
That hostname must be on the validators' HTTP request allow-list, or deposits
fail with UnauthorizedHttpRequest. Coordinate with the network operators before
going live.
LightClient's constructor takes the
genesis committee on faith; thereafter committee changes require a certified
CreateCommittee. Generate the args from a faucet you trust.$OUT/. It holds the bridge-chain wallet. Losing the wallet means
losing control of the bridge chain (no recovery — re-provisioning is a fresh
bridge). It is gitignored; back it up securely.EVM_PRIVATE_KEY is forwarded to
containers from your shell env and is never written to relayer.env. Keeping
it out of shell history is your responsibility (e.g. read -rs EVM_PRIVATE_KEY).| Symptom | Likely cause | Action |
|---|---|---|
/shared is empty in a container | $OUT not under a Docker-shared path | keep $OUT inside the repo (not /tmp) |
cannot reach EVM RPC | bad RPC_URL / network | docker-foundry 'cast chain-id --rpc-url $RPC_URL' |
forge cannot read the args file | committee args not at /shared | re-run step 1; LIGHT_CLIENT_ARGS_JSON_FILE=/shared/light-client-args.json |
vm.readFile permission denied | path outside fs_permissions | the args file must be under /shared (allow-listed in foundry.toml) |
publish-and-create fails / no Wasm | modules not built | make -C linera-bridge build-wasm; check the ls in Prerequisites |
deployed_addr prints empty | wrong $EVM_CHAIN_ID or contract name | confirm $EVM_CHAIN_ID; the broadcast dir is broadcast/<script>/<chainid>/ |
Deposits fail UnauthorizedHttpRequest | RPC host not allow-listed | see HTTP allow-list |
Relayer: eth_getLogs ... exceeds max block range / 10-block limit | RPC caps the getLogs range | lower MAX_LOG_BLOCK_RANGE in relayer.env to ≤ the RPC's cap (2000 for public Base Sepolia) and restart; Alchemy's free tier (10) is impractical — use the public RPC or a paid plan |