linera-bridge/DEPLOYMENT.md
How to deploy the Linera→EVM bridge contracts, wire their governance, verify the result, and operate them over their lifetime (timelocked upgrades, emergency pause, committee retirement, and forced migrations).
This document is deploy-environment-agnostic. For relayer provisioning and
day-to-day relay operations on mainnet, see
docker/README.mainnet.md; this runbook covers
the contracts that runbook deliberately leaves "out-of-band".
The EVM contracts are not upgradeable proxies. Upgradeability is achieved by swapping two narrow, governed seams (the light client and the burn-event decoder) behind a timelock. Everything else — including the governance addresses themselves — is immutable and can only change by deploying a fresh bridge (a migration). Choose the governance configuration carefully; it is the one thing you cannot change later without moving TVL.
| Contract | Role | Cardinality |
|---|---|---|
LightClient | Verifies Linera block headers + committee rotation; holds no funds. Shared by every consumer bridge on the same Linera network. | One per Linera network |
FungibleBridge (extends Microchain) | Holds the locked ERC-20 TVL; releases tokens on proven Linera burns; accepts deposits. | One per bridged token |
FungibleBurnEventDecoderV1 | pure contract that decodes a BurnEvent payload into (recipient, amount). Deployed automatically by the FungibleBridge deploy script. | One per decoder schema version |
| ERC-20 token | The token being bridged (LineraToken for tests, or a real ERC-20). | One per bridged token |
Microchain is the abstract governance base FungibleBridge inherits; it is not
deployed on its own.
Deploy order: ERC-20 (or use existing) → LightClient → FungibleBridge
(which deploys its decoder). The bridge constructor takes the light-client
address, so the light client must exist first.
The account that signs the deploy transactions gets no special on-chain power afterwards — the contracts have no owner. Privilege lives only in the three governance roles below.
Three roles, each with deliberately narrow powers. All are set at construction and are immutable.
| Role | Powers | Cannot |
|---|---|---|
| Pause Guardian | emergencyPause / emergencyUnpause (auto-expiring, ≤ 14 days) | Move funds, change any address, freeze indefinitely |
| Proposer | Propose a new light client / decoder (timelocked); call expireEpochsBelow (immediate) | Execute its own proposal early; move funds directly |
| Canceller | Cancel a pending proposal during the delay window | Anything else |
Core safety property: no governance action moves or redirects funds without the full timelock elapsing. The worst a fully-compromised proposer can do is propose a malicious light client/decoder and wait out the delay — during which the canceller (a disjoint signer set) revokes and the guardian can pause.
| Role | Suggested Safe | Notes |
|---|---|---|
| Pause Guardian | 2-of-3, hardware wallets, on-call rotation | Fast response; bounded blast radius |
| Proposer | 5-of-7 across ≥3 orgs / ≥3 time zones | EIP-712 verification, no blind-signing |
| Canceller | 2-of-3, ≥1 org disjoint from the proposer set | Lower threshold — cancelling is a safety action |
proposer and canceller must be different addresses (enforced on-chain).
timelockDelay is immutable and bounded to [1 day, 90 days].
Each role is just an address the contracts check (msg.sender == proposer, …),
so each role is held by its own Safe multisig on
Base. The Safe address is exactly what you pass to the deploy args (§4–5). A Safe
executes a governance call once threshold owners have signed it off-chain
(EIP-712), after which anyone can submit it on-chain.
Setup (once):
SafeProxyFactory), with the
owners + threshold from the table above; hardware-wallet signers; spread the
proposer set across ≥3 orgs / ≥3 time zones.--pause-guardian / --proposer
(LightClient args, §4) and PAUSE_GUARDIAN / PROPOSER / CANCELLER
(FungibleBridge env, §5). proposer and canceller must be distinct Safes.Exercising an action: build the call with the Safe Transaction Builder
(paste the verified contract address; the ABI auto-loads; pick e.g.
proposeLightClientUpdate and fill the argument), collect the threshold of
signatures, then execute. For scripted or air-gapped operations use safe-cli
or the Safe{Core} SDK against the Safe Transaction Service. A Safe can batch
calls atomically — e.g. pause $BRIDGE and $LIGHT_CLIENT in one guardian tx.
No blind-signing: every signer independently recomputes the EIP-712 SafeTx
hash before approving (e.g.
pcaversaccio/safe-tx-hashes-util).
The propose/execute split is asymmetric. executeLightClientUpdate /
executeDecoderUpdate are permissionless — they check nothing about the
caller. So the proposer Safe only runs its M-of-N ceremony for the propose
leg; once the delay elapses, anyone (an EOA, a bot, a cron) can execute.
Proposer-Safe liveness is never on the critical path for the second leg.
Cancellation and pause/unpause are immediate Safe actions by the canceller /
guardian.
The contracts store each role address immutably, but a Safe's signers are not immutable — which splits "rotating governance" into two very different operations:
addOwner / removeOwner / changeThreshold on the
Safe. No bridge change; the role address is unchanged. This is how you
on/offboard signers, respond to a key compromise, or adjust a threshold.In practice you almost always do the former: keep the role addresses (the Safe proxies) stable for the life of the bridge and manage trust by managing each Safe's owners. The immutable-address design is what guarantees a compromised signer set still can't move funds faster than the timelock.
Decide and record, before touching a deploy script:
proposer ≠ canceller and none is the zero address.[86400, 7776000] (1–90 days).fungibleApplicationId — the wrapped-fungible app on Linera (the required deposit target).bridgeApplicationId — the EVM-bridge app whose "burns" stream this bridge releases against.LineraToken first.⚠️ The test/demo configs (
docker-compose.bridge-test.yml,examples/bridge-demo/setup.sh) use throwaway placeholder governance addresses (0x…dEaD,0x…bEEF,0x…Ca11) and a 1-day timelock. Never use these in production — they are publicly known and controlled by no one.
LightClientThe constructor needs the genesis committee (validators + weights), the admin chain ID, the starting epoch, and the pause guardian + proposer addresses. The first four are read from the Linera faucet; the governance addresses you supply.
light-client-args.jsonUse the bundled CLI — it queries the faucet for the committee and admin chain, and folds in the two governance addresses:
linera-bridge init-light-client \
--faucet-url "$FAUCET_URL" \
--pause-guardian "$PAUSE_GUARDIAN_SAFE" \
--proposer "$PROPOSER_SAFE" \
--output light-client-args.json
Resulting file (validators/weights/admin_chain_id/epoch from the faucet, the last two from your flags):
{
"validators": ["0x…", "0x…"],
"weights": [100, 100],
"admin_chain_id": "0x…",
"epoch": "0",
"pause_guardian": "0x…",
"proposer": "0x…"
}
cd linera-bridge/src/solidity
LIGHT_CLIENT_ARGS_JSON_FILE=../../../light-client-args.json \
forge script script/DeployLightClient.s.sol \
--rpc-url "$RPC_URL" \
--private-key "$EVM_PRIVATE_KEY" \
--broadcast
The script asserts lc.adminChainId() == admin_chain_id post-deploy. Record the
deployed address as $LIGHT_CLIENT.
Optional explorer verification: export
EXPLORER_API_KEY+VERIFIER_URLbefore the script to append--verify(seeREADME.mainnet.md).
FungibleBridgeThe deploy script reads everything from the environment, deploys a fresh
FungibleBurnEventDecoderV1, then deploys the bridge wired to it.
| Env var | Meaning |
|---|---|
LIGHT_CLIENT | The LightClient from §4 |
BRIDGE_CHAIN_ID | Linera chain this bridge settles (32-byte hex) |
TOKEN_ADDRESS | The ERC-20 to bridge |
FUNGIBLE_APP_ID | Wrapped-fungible app ID (deposit target) |
BRIDGE_APP_ID | EVM-bridge app ID (burns-stream source) |
PAUSE_GUARDIAN | Guardian Safe |
PROPOSER | Proposer Safe |
CANCELLER | Canceller Safe (≠ proposer) |
TIMELOCK_DELAY | Seconds, [86400, 7776000] |
cd linera-bridge/src/solidity
LIGHT_CLIENT="$LIGHT_CLIENT" \
BRIDGE_CHAIN_ID="$BRIDGE_CHAIN_ID" \
TOKEN_ADDRESS="$TOKEN_ADDRESS" \
FUNGIBLE_APP_ID="$FUNGIBLE_APP_ID" \
BRIDGE_APP_ID="$BRIDGE_APP_ID" \
PAUSE_GUARDIAN="$PAUSE_GUARDIAN_SAFE" \
PROPOSER="$PROPOSER_SAFE" \
CANCELLER="$CANCELLER_SAFE" \
TIMELOCK_DELAY="2592000" \
forge script script/DeployFungibleBridge.s.sol \
--rpc-url "$RPC_URL" \
--private-key "$EVM_PRIVATE_KEY" \
--broadcast
The script asserts the bridge points at $LIGHT_CLIENT and at the decoder it
just deployed. Record the bridge address as $BRIDGE.
The broadcast artifact contains two
CREATEs (decoder, then bridge). Select the bridge bycontractName(FungibleBridge), nottransactions[0].
After deploy: fund $BRIDGE with the ERC-20 it will release, register the bridge
address with the Linera-side evm-bridge app, and populate the relayer .env
(EVM_BRIDGE_ADDRESS, MONITOR_START_BLOCK = the bridge's deploy block, etc.) —
see README.mainnet.md §3–4.
# Bridge wiring
cast call "$BRIDGE" "lightClient()(address)" --rpc-url "$RPC_URL" # == $LIGHT_CLIENT
cast call "$BRIDGE" "decoder()(address)" --rpc-url "$RPC_URL" # the V1 decoder
cast call "$BRIDGE" "token()(address)" --rpc-url "$RPC_URL" # == $TOKEN_ADDRESS
# Governance (immutable) — confirm the Safes, not the placeholders
cast call "$BRIDGE" "pauseGuardian()(address)" --rpc-url "$RPC_URL"
cast call "$BRIDGE" "proposer()(address)" --rpc-url "$RPC_URL"
cast call "$BRIDGE" "canceller()(address)" --rpc-url "$RPC_URL"
cast call "$BRIDGE" "timelockDelay()(uint256)" --rpc-url "$RPC_URL" # your delay, in seconds
# Light client governance + network binding
cast call "$LIGHT_CLIENT" "adminChainId()(bytes32)" --rpc-url "$RPC_URL"
cast call "$LIGHT_CLIENT" "pauseGuardian()(address)" --rpc-url "$RPC_URL"
cast call "$LIGHT_CLIENT" "proposer()(address)" --rpc-url "$RPC_URL"
Confirm every governance field is a real Safe and the timelock matches intent before funding the bridge.
All proposals are public on-chain; set up monitoring/alerting on the
*UpdateProposed, *UpdateExecuted, *UpdateCancelled, and
EmergencyPaused/EmergencyUnpaused events.
In production each
castcall below is executed as a Safe transaction by the named role (§2): thepropose*/cancel*/emergencyPause/expireEpochsBelowcalls need the role Safe's threshold of signatures, while theexecute*calls are permissionless and can be sent by anyone (no Safe) after the delay. The raw calls are shown for clarity.
No TVL migration — the per-burn replay key is independent of which client verified the block, so a swap cannot double-release.
LightClient for the same Linera network (same
adminChainId) and catch its committee set up to the live epoch
(addCommittee) — do this before the timelock elapses.cast send "$BRIDGE" "proposeLightClientUpdate(address)" "$NEW_LIGHT_CLIENT" --rpc-url "$RPC_URL"
timelockDelay. Anyone can then execute (permissionless):
cast send "$BRIDGE" "executeLightClientUpdate()" --rpc-url "$RPC_URL"
processBurns. No user action; TVL never moves.To abort during the window (canceller or proposer):
cast send "$BRIDGE" "cancelLightClientUpdate()".
applicationId)No TVL migration. Audit the new decoder during the 30-day window.
# proposer:
cast send "$BRIDGE" "proposeDecoderUpdate(address)" "$NEW_DECODER" --rpc-url "$RPC_URL"
# after timelockDelay, anyone:
cast send "$BRIDGE" "executeDecoderUpdate()" --rpc-url "$RPC_URL"
# abort (canceller/proposer):
cast send "$BRIDGE" "cancelDecoderUpdate()" --rpc-url "$RPC_URL"
Auto-expires (≤ 14 days); the guardian cannot freeze indefinitely.
# guardian — duration in seconds (e.g. 14 days = 1209600):
cast send "$BRIDGE" "emergencyPause(uint256)" 1209600 --rpc-url "$RPC_URL" # halts deposit + processBurns
cast send "$BRIDGE" "emergencyUnpause()" --rpc-url "$RPC_URL" # lift early
Blast radius. Pausing the bridge halts that one bridge's
deposit+processBurns. Pausing the sharedLightClient(cast send "$LIGHT_CLIENT" "emergencyPause(uint256)" …) haltsregisterBlockfor every consumer bridge on the network — reserve it for network/protocol incidents. Re-pausing before expiry extends the window.
Immediate-effect, proposer-gated; monotonic and capped at currentEpoch (can
never retire the live committee):
cast send "$LIGHT_CLIENT" "expireEpochsBelow(uint32)" "$NEW_MIN_EPOCH" --rpc-url "$RPC_URL"
Required only for changes the timelock can't absorb (see §8). Outline:
emergencyPause deposits on the old bridge (extend by re-pausing).registerBlock + processBurns path; redeposit into the new
bridge if desired.minAcceptedEpoch);
accept a long-tail of unmigrated TVL.| Change | Action | Migration? |
|---|---|---|
| Validator set rotation / epoch advance | addCommittee (permissionless) | No |
| Retire a compromised retired committee | expireEpochsBelow (proposer) | No |
| Block / header / signature-scheme change | setLightClient (§7.1) | No |
BurnEvent payload schema change, same appIds | setDecoder (§7.2) | No |
fungibleApplicationId or bridgeApplicationId rotation | New bridge + drain | Yes |
| Decoder interface widening (burns gain fields the bridge must act on) | New bridge + new decoder interface | Yes |
Rotate pauseGuardian / proposer / canceller / timelockDelay | New bridge | Yes |
Bug in FungibleBridge settlement logic | New bridge + drain | Yes |
| Exploitable bug in light client / decoder | emergencyPause, fix, swap after audit | Maybe |
| New ERC-20 token / new bridge type | New independent instance | No |
Enforced by the constructors / governance functions:
proposer ≠ canceller.timelockDelay ∈ [1 day, 90 days] ([86400, 7776000] s).∈ (0, 14 days]; auto-expires.adminChainId ("same network").deposit rejects amounts above u128::MAX (the Linera side holds U128).