From e97f09f9e0069aad69614bac660362ba19d69260 Mon Sep 17 00:00:00 2001 From: deuszx <95355183+deuszx@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:45:24 +0000 Subject: [PATCH 01/13] Remove sequential block height requirement from Microchain contract (#5763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation With the monotonic increase of `nextBlockHeight` in `Microchain.sol` it's more complicated to keep the messages relayed in a correct order. ## Proposal BFT-finalized certificates already guarantee block canonicality — a quorum of validators cannot sign two conflicting blocks at the same height. The sequential height check added no security value while forcing the relayer to submit every block (including irrelevant ones) in strict order, wasting gas and adding fragility. The verifiedBlocks mapping continues to prevent duplicate processing. ## Test Plan CI ## Release Plan None ## Links - [reviewer checklist](https://github.com/linera-io/linera-protocol/blob/main/CONTRIBUTING.md#reviewer-checklist) --- linera-bridge/README.md | 14 ++++---- linera-bridge/src/evm/microchain.rs | 36 +++++++------------ linera-bridge/src/fungible_bridge.rs | 6 ++-- linera-bridge/src/gas.rs | 2 +- linera-bridge/src/relay.rs | 2 -- linera-bridge/src/solidity/FungibleBridge.sol | 3 +- linera-bridge/src/solidity/Microchain.sol | 17 ++++----- linera-bridge/src/test_helpers.rs | 11 ++---- linera-bridge/tests/anvil_deposit_proof.rs | 1 - .../tests/solidity/MicrochainTest.sol | 4 +-- 10 files changed, 34 insertions(+), 62 deletions(-) diff --git a/linera-bridge/README.md b/linera-bridge/README.md index c13eff844b4a..4438bb87e484 100644 --- a/linera-bridge/README.md +++ b/linera-bridge/README.md @@ -99,25 +99,25 @@ The constructor takes `(address[], uint64[], bytes32, uint32)` — the genesis c ### Microchain (abstract) -#### `constructor(address _lightClient, bytes32 _chainId, uint64 _latestHeight)` +#### `constructor(address _lightClient, bytes32 _chainId)` -Binds the contract to a specific `LightClient` instance, a Linera chain ID (a 32-byte `CryptoHash`), and an initial block height. +Binds the contract to a specific `LightClient` instance and a Linera chain ID (a 32-byte `CryptoHash`). #### `addBlock(bytes calldata data)` Verifies a certificate via `lightClient.verifyBlock(data)`, then enforces: +- **No duplicate blocks**: rejects certificates already processed via the `verifiedBlocks` mapping. - **Chain ID match**: the block's `header.chain_id` must equal this contract's `chainId`. -- **Sequential heights**: the block's height must equal `nextExpectedHeight`. -On success, calls the virtual `_onBlock(BridgeTypes.Block)` hook. Subcontracts override this to extract and store application-specific data from the verified block. +Blocks can be submitted in any order; sequential height enforcement is not required because BFT-finalized certificates guarantee canonicality. On success, calls the virtual `_onBlock(BridgeTypes.Block)` hook. Subcontracts override this to extract and store application-specific data from the verified block. ### FungibleBridge (concrete Microchain) A `Microchain` subcontract that bridges ERC-20 tokens from Linera to Ethereum. When a fungible `Credit` message targeting an Ethereum address (`Address20`) is received, the contract transfers tokens from its own balance to the recipient. -#### `constructor(address _lightClient, bytes32 _chainId, uint64 _latestHeight, bytes32 _applicationId, address _token)` +#### `constructor(address _lightClient, bytes32 _chainId, bytes32 _applicationId, address _token)` -Binds to a specific `LightClient`, chain, initial block height, Linera application ID, and ERC-20 token contract. Only messages targeting this `applicationId` are processed; all others are silently skipped. +Binds to a specific `LightClient`, chain, Linera application ID, and ERC-20 token contract. Only messages targeting this `applicationId` are processed; all others are silently skipped. #### `_onBlock(BridgeTypes.Block)` @@ -140,7 +140,7 @@ let calldata: Vec = call.abi_encode(); // Available call types: // light_client: addCommitteeCall, verifyBlockCall, currentEpochCall -// microchain: addBlockCall, nextExpectedHeightCall, lightClientCall, chainIdCall +// microchain: addBlockCall, lightClientCall, chainIdCall // Solidity sources (for compilation or deployment tooling): // BRIDGE_TYPES_SOURCE, WRAPPED_FUNGIBLE_TYPES_SOURCE, FUNGIBLE_BRIDGE_SOURCE diff --git a/linera-bridge/src/evm/microchain.rs b/linera-bridge/src/evm/microchain.rs index a00bd1f7857c..ccade5f40a45 100644 --- a/linera-bridge/src/evm/microchain.rs +++ b/linera-bridge/src/evm/microchain.rs @@ -9,8 +9,6 @@ pub const SOURCE: &str = include_str!("../solidity/Microchain.sol"); sol! { function addBlock(bytes calldata data) external; - function nextExpectedHeight() external view returns (uint64); - function lightClient() external view returns (address); function chainId() external view returns (bytes32); @@ -28,7 +26,7 @@ mod tests { primitives::Address, }; - use super::{addBlockCall, chainIdCall, lightClientCall, nextExpectedHeightCall}; + use super::{addBlockCall, chainIdCall, lightClientCall}; use crate::test_helpers::*; sol! { @@ -67,11 +65,6 @@ mod tests { microchain.add_block(BlockHeight(1)); assert_eq!(microchain.query_block_count(), 1, "block count should be 1"); - assert_eq!( - microchain.query_next_expected_height(), - 2, - "next expected height should be 2" - ); } #[test] @@ -102,13 +95,18 @@ mod tests { } #[test] - fn test_microchain_rejects_non_sequential_height() { + fn test_microchain_accepts_non_sequential_height() { let mut t = TestMicrochain::new(); - assert!( - t.try_add_block(t.chain_id, BlockHeight(5)).is_err(), - "should reject non-sequential block height" - ); + // Blocks can be submitted at any height, not just sequentially. + t.add_block(BlockHeight(5)); + assert_eq!(t.query_block_count(), 1, "block count should be 1"); + + t.add_block(BlockHeight(2)); + assert_eq!(t.query_block_count(), 2, "block count should be 2"); + + t.add_block(BlockHeight(100)); + assert_eq!(t.query_block_count(), 3, "block count should be 3"); } /// Common test state for Microchain tests. @@ -131,7 +129,7 @@ mod tests { let chain_id = CryptoHash::new(&TestString::new("test_chain")); let light_client = deploy_light_client(&mut db, deployer, &[addr], &[1], test_admin_chain_id(), 0); - let contract = deploy_microchain(&mut db, deployer, light_client, chain_id, 1); + let contract = deploy_microchain(&mut db, deployer, light_client, chain_id); Self { db, @@ -189,15 +187,5 @@ mod tests { ); count } - - fn query_next_expected_height(&mut self) -> u64 { - let (height, _, _) = call_contract( - &mut self.db, - self.deployer, - self.contract, - nextExpectedHeightCall {}, - ); - height - } } } diff --git a/linera-bridge/src/fungible_bridge.rs b/linera-bridge/src/fungible_bridge.rs index 88c6a9ce40d9..1a231b81e985 100644 --- a/linera-bridge/src/fungible_bridge.rs +++ b/linera-bridge/src/fungible_bridge.rs @@ -96,7 +96,7 @@ mod tests { let chain_id = CryptoHash::new(&TestString::new("test_chain")); let app_id = CryptoHash::new(&TestString::new("fungible_app")); let bridge = - deploy_fungible_bridge(&mut db, deployer, light_client, chain_id, 1, app_id, token); + deploy_fungible_bridge(&mut db, deployer, light_client, chain_id, app_id, token); // Fund the bridge with the full token supply call_contract( @@ -132,7 +132,7 @@ mod tests { self.submit_block(vec![txn]) } - /// Submits a block with the given transactions at the next sequential height. + /// Submits a block with the given transactions. fn submit_block( &mut self, transactions: Vec, @@ -311,7 +311,7 @@ mod tests { let chain_id = CryptoHash::new(&TestString::new("test_chain")); let app_id = CryptoHash::new(&TestString::new("fungible_app")); let bridge = - deploy_fungible_bridge(&mut db, deployer, light_client, chain_id, 1, app_id, token); + deploy_fungible_bridge(&mut db, deployer, light_client, chain_id, app_id, token); // Give depositor tokens (instead of funding the bridge) call_contract( diff --git a/linera-bridge/src/gas.rs b/linera-bridge/src/gas.rs index ffa183a1ce8b..99017a54f5fb 100644 --- a/linera-bridge/src/gas.rs +++ b/linera-bridge/src/gas.rs @@ -67,7 +67,7 @@ mod tests { let mut db = CacheDB::default(); let light_client = deploy_light_client(&mut db, deployer, &[addr], &[1], test_admin_chain_id(), 0); - let microchain = deploy_microchain(&mut db, deployer, light_client, chain_id, 1); + let microchain = deploy_microchain(&mut db, deployer, light_client, chain_id); let cert = create_signed_certificate_for_chain(&secret, &public, chain_id, BlockHeight(1)); let bcs_bytes = bcs::to_bytes(&cert).expect("BCS serialization failed"); diff --git a/linera-bridge/src/relay.rs b/linera-bridge/src/relay.rs index 8ad8659b551b..19ca35f7ddf9 100644 --- a/linera-bridge/src/relay.rs +++ b/linera-bridge/src/relay.rs @@ -564,8 +564,6 @@ pub async fn run( } }; - // Forward the deposit block to EVM so the Microchain - // height stays sequential. forward_cert_to_evm(&cert, bridge_addr, &provider).await; Ok::<(), anyhow::Error>(()) diff --git a/linera-bridge/src/solidity/FungibleBridge.sol b/linera-bridge/src/solidity/FungibleBridge.sol index 185cc8eacfb5..2d164c2820c7 100644 --- a/linera-bridge/src/solidity/FungibleBridge.sol +++ b/linera-bridge/src/solidity/FungibleBridge.sol @@ -36,11 +36,10 @@ contract FungibleBridge is Microchain { constructor( address _lightClient, bytes32 _chainId, - uint64 _nextExpectedHeight, bytes32 _applicationId, address _token ) - Microchain(_lightClient, _chainId, _nextExpectedHeight) + Microchain(_lightClient, _chainId) { applicationId = _applicationId; token = IERC20(_token); diff --git a/linera-bridge/src/solidity/Microchain.sol b/linera-bridge/src/solidity/Microchain.sol index 21f10a6a9284..5f5226dcb666 100644 --- a/linera-bridge/src/solidity/Microchain.sol +++ b/linera-bridge/src/solidity/Microchain.sol @@ -7,32 +7,27 @@ import "LightClient.sol"; abstract contract Microchain { LightClient public immutable lightClient; bytes32 public immutable chainId; - uint64 public nextExpectedHeight; mapping(bytes32 => bool) public verifiedBlocks; - constructor(address _lightClient, bytes32 _chainId, uint64 _nextExpectedHeight) { + constructor(address _lightClient, bytes32 _chainId) { lightClient = LightClient(_lightClient); chainId = _chainId; - nextExpectedHeight = _nextExpectedHeight; } - /// Verifies a certificate and accepts the block if it matches this chain and - /// the next expected height. + /// Verifies a certificate and accepts the block if it matches this chain. /// - /// Note: this contract does NOT check `previous_block_hash` to link blocks - /// into a hash chain. This is safe because `ConfirmedBlockCertificate` + /// Note: this contract does NOT check `previous_block_hash` or enforce + /// sequential block heights. This is safe because `ConfirmedBlockCertificate` /// implies BFT-finalized canonicality — a quorum of validators signed this /// specific block at this height, so no conflicting block can exist. - /// If this assumption ever changes at the protocol layer, a - /// `previous_block_hash` check should be added here. + /// Blocks can be submitted in any order; the `verifiedBlocks` mapping + /// prevents duplicate processing. function addBlock(bytes calldata data) external { (BridgeTypes.Block memory blockValue, bytes32 signedHash) = lightClient.verifyBlock(data); require(!verifiedBlocks[signedHash], "block already verified"); require(blockValue.header.chain_id.value.value == chainId, "chain id mismatch"); - require(blockValue.header.height.value == nextExpectedHeight, "block height must be sequential"); - nextExpectedHeight = blockValue.header.height.value + 1; verifiedBlocks[signedHash] = true; _onBlock(blockValue); } diff --git a/linera-bridge/src/test_helpers.rs b/linera-bridge/src/test_helpers.rs index 7195dff7d734..4f7edf9ee1eb 100644 --- a/linera-bridge/src/test_helpers.rs +++ b/linera-bridge/src/test_helpers.rs @@ -125,17 +125,12 @@ pub fn deploy_microchain( deployer: Address, light_client: Address, chain_id: CryptoHash, - next_expected_height: u64, ) -> Address { let test_source = std::fs::read_to_string("tests/solidity/MicrochainTest.sol") .expect("MicrochainTest.sol not found"); let bytecode = compile_contract(&test_source, "MicrochainTest.sol", "MicrochainTest"); - let constructor_args = ( - light_client, - <[u8; 32]>::from(*chain_id.as_bytes()), - next_expected_height, - ) - .abi_encode_params(); + let constructor_args = + (light_client, <[u8; 32]>::from(*chain_id.as_bytes())).abi_encode_params(); let mut deploy_data = bytecode; deploy_data.extend_from_slice(&constructor_args); deploy_contract(db, deployer, deploy_data) @@ -146,7 +141,6 @@ pub fn deploy_fungible_bridge( deployer: Address, light_client: Address, chain_id: CryptoHash, - next_expected_height: u64, application_id: CryptoHash, token: Address, ) -> Address { @@ -158,7 +152,6 @@ pub fn deploy_fungible_bridge( let constructor_args = ( light_client, <[u8; 32]>::from(*chain_id.as_bytes()), - next_expected_height, <[u8; 32]>::from(*application_id.as_bytes()), token, ) diff --git a/linera-bridge/tests/anvil_deposit_proof.rs b/linera-bridge/tests/anvil_deposit_proof.rs index 2a35281fcebe..f5d94a32af40 100644 --- a/linera-bridge/tests/anvil_deposit_proof.rs +++ b/linera-bridge/tests/anvil_deposit_proof.rs @@ -189,7 +189,6 @@ async fn test_deposit_proof_generation() -> Result<(), Box::from(target_chain_id), // chainId - 0u64, // nextExpectedHeight <[u8; 32]>::from(target_application_id), // applicationId token_address, // token ) diff --git a/linera-bridge/tests/solidity/MicrochainTest.sol b/linera-bridge/tests/solidity/MicrochainTest.sol index 2babb5a05629..874f6937417a 100644 --- a/linera-bridge/tests/solidity/MicrochainTest.sol +++ b/linera-bridge/tests/solidity/MicrochainTest.sol @@ -6,8 +6,8 @@ import "Microchain.sol"; contract MicrochainTest is Microchain { uint64 public blockCount; - constructor(address _lightClient, bytes32 _chainId, uint64 _nextExpectedHeight) - Microchain(_lightClient, _chainId, _nextExpectedHeight) + constructor(address _lightClient, bytes32 _chainId) + Microchain(_lightClient, _chainId) {} function _onBlock(BridgeTypes.Block memory) internal override { From e9794870adf328cd149ee6df92e888c8895dd054 Mon Sep 17 00:00:00 2001 From: deuszx <95355183+deuszx@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:39:39 +0000 Subject: [PATCH 02/13] Update e2e tests after removal of nextExpectedHeight (#5764) ## Motivation Bridge e2e tests are failing again. ## Proposal Update docker compose to not pass the nextExpectedHeight which was removed in #5763 ## Test Plan CI ## Release Plan None ## Links - [reviewer checklist](https://github.com/linera-io/linera-protocol/blob/main/CONTRIBUTING.md#reviewer-checklist) --- linera-bridge/tests/e2e/tests/evm_to_linera_bridge.rs | 1 - linera-bridge/tests/e2e/tests/fungible_bridge.rs | 1 - 2 files changed, 2 deletions(-) diff --git a/linera-bridge/tests/e2e/tests/evm_to_linera_bridge.rs b/linera-bridge/tests/e2e/tests/evm_to_linera_bridge.rs index 5f364c089220..d8ef8db80c44 100644 --- a/linera-bridge/tests/e2e/tests/evm_to_linera_bridge.rs +++ b/linera-bridge/tests/e2e/tests/evm_to_linera_bridge.rs @@ -173,7 +173,6 @@ async fn test_evm_to_linera_bridge() -> anyhow::Result<()> { --constructor-args \ {light_client} \ {chain_id_bytes32} \ - 0 \ {zero_bytes32} \ {erc20_addr}" ), diff --git a/linera-bridge/tests/e2e/tests/fungible_bridge.rs b/linera-bridge/tests/e2e/tests/fungible_bridge.rs index b84c08751a10..ac19e1116747 100644 --- a/linera-bridge/tests/e2e/tests/fungible_bridge.rs +++ b/linera-bridge/tests/e2e/tests/fungible_bridge.rs @@ -218,7 +218,6 @@ async fn test_fungible_bridge_transfers_to_evm() -> anyhow::Result<()> { --constructor-args \ {light_client} \ {chain_a_bytes32} \ - 0 \ {app_id_bytes32} \ {erc20_addr}" ), From 11cad757d34316ce1d649609b65bf9838f989f86 Mon Sep 17 00:00:00 2001 From: deuszx <95355183+deuszx@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:30:12 +0100 Subject: [PATCH 03/13] Fix bridge e2e (#5658) Post `testnet_conway` merge job is broken. Comiple the `evm-bridge` contract. CI None for now - [reviewer checklist](https://github.com/linera-io/linera-protocol/blob/main/CONTRIBUTING.md#reviewer-checklist) --- .github/workflows/bridge-e2e.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/bridge-e2e.yml b/.github/workflows/bridge-e2e.yml index 3a84b21671ed..5f8c2cc6a0da 100644 --- a/.github/workflows/bridge-e2e.yml +++ b/.github/workflows/bridge-e2e.yml @@ -121,7 +121,13 @@ jobs: load: true tags: linera-bridge:latest - - name: Build example Wasm for bridge tests + - name: Push bridge image to registry + if: steps.build-bridge.outcome == 'success' + run: | + BRANCH="${{ github.base_ref || github.ref_name }}" + docker push "${GCP_REGISTRY}/linera-bridge:${BRANCH}" + + - name: Build example Wasm modules for bridge tests run: cargo build --release --target wasm32-unknown-unknown -p wrapped-fungible -p evm-bridge working-directory: examples From dd3169112e2a6ded343e964bf109e2920e2f0a1d Mon Sep 17 00:00:00 2001 From: deuszx <95355183+deuszx@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:47:08 +0100 Subject: [PATCH 04/13] Add /health endpoint to the exporter (#5678) Right now exporters are crashing on the indexer's ack stream. Until we fix the root cause, add `/health` endpoint which returns 500 whenever we get a non-recoverable error on any of the streams. CI and manual - Test on conway, then backport. - [reviewer checklist](https://github.com/linera-io/linera-protocol/blob/main/CONTRIBUTING.md#reviewer-checklist) --- docker/docker-compose.bridge-test.yml | 3 +- linera-metrics/src/monitoring_server.rs | 15 +- linera-service/src/cli_wrappers/local_net.rs | 6 +- linera-service/src/exporter/main.rs | 190 +++++++++++++++++- .../exporter/runloops/block_processor/mod.rs | 11 +- .../runloops/indexer/indexer_exporter.rs | 7 +- linera-service/src/exporter/runloops/mod.rs | 19 +- .../src/exporter/runloops/task_manager.rs | 69 +++++-- 8 files changed, 293 insertions(+), 27 deletions(-) diff --git a/docker/docker-compose.bridge-test.yml b/docker/docker-compose.bridge-test.yml index 3b4ee0b51d72..376665ea663f 100644 --- a/docker/docker-compose.bridge-test.yml +++ b/docker/docker-compose.bridge-test.yml @@ -217,6 +217,7 @@ services: image: "${LINERA_EXPORTER_IMAGE:-linera-exporter}" ports: - "${BLOCK_EXPORTER_PORT:-8882}:${BLOCK_EXPORTER_PORT:-8882}" + - "${EXPORTER_METRICS_PORT:-9091}:9091" command: - sh - -c @@ -258,7 +259,7 @@ services: bridge-init: condition: service_completed_successfully healthcheck: - test: ["CMD-SHELL", "nc -z localhost ${BLOCK_EXPORTER_PORT:-8882}"] + test: ["CMD-SHELL", "wget -qO- http://localhost:9091/health || exit 1"] interval: 5s timeout: 3s retries: 10 diff --git a/linera-metrics/src/monitoring_server.rs b/linera-metrics/src/monitoring_server.rs index ac4ee60f0af7..8795fb3b05e9 100644 --- a/linera-metrics/src/monitoring_server.rs +++ b/linera-metrics/src/monitoring_server.rs @@ -81,7 +81,20 @@ pub fn start_metrics( shutdown_signal: CancellationToken, memory_profiling: MemoryProfiling, ) { - let app = metrics_router(memory_profiling); + start_metrics_with_extras(address, shutdown_signal, memory_profiling, None); +} + +pub fn start_metrics_with_extras( + address: impl ToSocketAddrs + Debug + Send + 'static, + shutdown_signal: CancellationToken, + memory_profiling: MemoryProfiling, + extra_routes: Option, +) { + let mut app = metrics_router(memory_profiling); + + if let Some(extra) = extra_routes { + app = app.merge(extra); + } tokio::spawn(async move { let listener = tokio::net::TcpListener::bind(address) diff --git a/linera-service/src/cli_wrappers/local_net.rs b/linera-service/src/cli_wrappers/local_net.rs index 762441a5da50..03032eeb20aa 100644 --- a/linera-service/src/cli_wrappers/local_net.rs +++ b/linera-service/src/cli_wrappers/local_net.rs @@ -518,8 +518,8 @@ impl LocalNet { test_offset_port() + 4000 + 1 } - fn block_exporter_metrics_port(exporter_id: usize) -> usize { - test_offset_port() + 4000 + exporter_id + 1 + fn block_exporter_metrics_port(&self, validator: usize, exporter_id: usize) -> usize { + test_offset_port() + 4000 + validator * self.num_shards + exporter_id + 1 } fn configuration_string(&self, server_number: usize) -> Result { @@ -639,7 +639,7 @@ impl LocalNet { let n = validator; let host = Network::Grpc.localhost(); let port = self.block_exporter_port(n, exporter_id as usize); - let metrics_port = Self::block_exporter_metrics_port(exporter_id as usize); + let metrics_port = self.block_exporter_metrics_port(n, exporter_id as usize); let mut config = format!( r#" id = {exporter_id} diff --git a/linera-service/src/exporter/main.rs b/linera-service/src/exporter/main.rs index 96085a56a91c..1ba2683b1a3b 100644 --- a/linera-service/src/exporter/main.rs +++ b/linera-service/src/exporter/main.rs @@ -1,7 +1,11 @@ // Copyright (c) Zefchain Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -use std::{path::PathBuf, time::Duration}; +use std::{ + path::PathBuf, + sync::{atomic::AtomicBool, Arc}, + time::Duration, +}; use anyhow::Result; use async_trait::async_trait; @@ -157,6 +161,59 @@ struct RunOptions { pub enable_memory_profiling: bool, } +async fn start_health_server( + address: std::net::SocketAddr, + shutdown_signal: CancellationToken, + health: Arc, + enable_memory_profiling: bool, +) { + let health_router = axum::Router::new().route( + "/health", + axum::routing::get(move || { + let is_healthy = health.load(std::sync::atomic::Ordering::Acquire); + async move { + if is_healthy { + (axum::http::StatusCode::OK, "OK") + } else { + (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "unhealthy") + } + } + }), + ); + + #[cfg(with_metrics)] + { + let memory_profiling = if enable_memory_profiling { + monitoring_server::MemoryProfiling::Enabled + } else { + monitoring_server::MemoryProfiling::Disabled + }; + monitoring_server::start_metrics_with_extras( + address, + shutdown_signal, + memory_profiling, + Some(health_router), + ); + } + + #[cfg(not(with_metrics))] + { + let listener = tokio::net::TcpListener::bind(address) + .await + .expect("Failed to bind health server"); + let addr = listener.local_addr().expect("Failed to get local address"); + tracing::info!("Serving /health on {:?}", addr); + tokio::spawn(async move { + if let Err(e) = axum::serve(listener, health_router) + .with_graceful_shutdown(shutdown_signal.cancelled_owned()) + .await + { + tracing::error!("Health server error: {}", e); + } + }); + } +} + struct ExporterContext { node_options: NodeOptions, config: BlockExporterConfig, @@ -175,11 +232,22 @@ impl Runnable for ExporterContext { let shutdown_notifier = CancellationToken::new(); tokio::spawn(listen_for_shutdown_signals(shutdown_notifier.clone())); - #[cfg(with_metrics)] - monitoring_server::start_metrics_with_profiling( + let health = Arc::new(AtomicBool::new(true)); + let enable_memory_profiling = { + #[cfg(with_metrics)] + { + self.enable_memory_profiling + } + #[cfg(not(with_metrics))] + { + false + } + }; + start_health_server( self.config.metrics_address(), shutdown_notifier.clone(), - self.enable_memory_profiling, + health.clone(), + enable_memory_profiling, ) .await; @@ -190,6 +258,7 @@ impl Runnable for ExporterContext { self.node_options, self.config.id, self.config.destination_config, + health, ); let service = ExporterService::new(sender); @@ -452,3 +521,116 @@ impl Runnable for DestinationsContext { Ok(()) } } + +#[cfg(test)] +mod health_tests { + use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }; + + use linera_base::port::get_free_port; + use linera_rpc::{config::TlsConfig, NodeOptions}; + use linera_service::{ + cli_wrappers::local_net::LocalNet, + config::{Destination, DestinationConfig, LimitsConfig}, + }; + use linera_storage::{DbStorage, TestClock}; + use linera_views::memory::MemoryDatabase; + use tokio::time::{sleep, Duration}; + use tokio_util::sync::CancellationToken; + + use super::start_health_server; + use crate::{ + common::ExporterCancellationSignal, + runloops::start_block_processor_task, + test_utils::{make_simple_state_with_blobs, DummyIndexer, TestDestination}, + }; + + #[test_log::test(tokio::test)] + async fn test_health_endpoint_reflects_exporter_errors() -> anyhow::Result<()> { + let cancellation_token = CancellationToken::new(); + let health = Arc::new(AtomicBool::new(true)); + + // Start the production health server on a free port. + let health_port = get_free_port().await?; + let health_addr = std::net::SocketAddr::from(([127, 0, 0, 1], health_port)); + start_health_server( + health_addr, + cancellation_token.clone(), + health.clone(), + false, + ) + .await; + + // Start a faulty indexer destination. + let indexer_port = get_free_port().await?; + let indexer = DummyIndexer::default(); + indexer.set_faulty(); + tokio::spawn( + indexer + .clone() + .start(indexer_port, cancellation_token.clone()), + ); + LocalNet::ensure_grpc_server_has_started("faulty indexer", indexer_port as usize, "http") + .await?; + + // Prepare storage with test blocks. + let storage = DbStorage::::make_test_storage(None).await; + let (notification, _state) = make_simple_state_with_blobs(&storage).await; + + // Start the block processor with the faulty indexer and shared health flag. + let signal = ExporterCancellationSignal::new(cancellation_token.clone()); + let (notifier, _handle) = start_block_processor_task( + storage, + signal, + LimitsConfig::default(), + NodeOptions { + send_timeout: Duration::from_millis(4000), + recv_timeout: Duration::from_millis(4000), + retry_delay: Duration::from_millis(1000), + max_retries: 10, + ..Default::default() + }, + 0, + DestinationConfig { + committee_destination: false, + destinations: vec![Destination::Indexer { + port: indexer_port, + tls: TlsConfig::ClearText, + endpoint: "127.0.0.1".to_owned(), + }], + }, + health.clone(), + ); + + let base = format!("http://127.0.0.1:{health_port}"); + let client = reqwest::Client::new(); + + // Before any errors, health should be 200. + let resp = client.get(format!("{base}/health")).send().await?; + assert_eq!(resp.status(), 200); + assert_eq!(resp.text().await?, "OK"); + + // Send a block notification — the faulty indexer will cause a stream error. + notifier.send(notification)?; + + // Wait for the error to propagate and flip the health flag. + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while health.load(Ordering::Acquire) { + assert!( + tokio::time::Instant::now() < deadline, + "health flag did not flip to unhealthy within timeout" + ); + sleep(Duration::from_millis(100)).await; + } + + // After the stream error, health should be 500. + let resp = client.get(format!("{base}/health")).send().await?; + assert_eq!(resp.status(), 500); + assert_eq!(resp.text().await?, "unhealthy"); + + cancellation_token.cancel(); + Ok(()) + } +} diff --git a/linera-service/src/exporter/runloops/block_processor/mod.rs b/linera-service/src/exporter/runloops/block_processor/mod.rs index b8ec848b4536..47e9393323c7 100644 --- a/linera-service/src/exporter/runloops/block_processor/mod.rs +++ b/linera-service/src/exporter/runloops/block_processor/mod.rs @@ -189,7 +189,10 @@ where #[cfg(test)] mod test { - use std::collections::HashSet; + use std::{ + collections::HashSet, + sync::{atomic::AtomicBool, Arc}, + }; use linera_base::{ crypto::CryptoHash, @@ -241,6 +244,7 @@ mod test { exporter_storage.clone()?, vec![], HashSet::new(), + Arc::new(AtomicBool::new(true)), ); let mut block_processor = BlockProcessor::new( exporters_tracker, @@ -378,6 +382,7 @@ mod test { exporter_storage.clone()?, vec![], HashSet::new(), + Arc::new(AtomicBool::new(true)), ); let mut block_processor = BlockProcessor::new( exporters_tracker, @@ -495,6 +500,7 @@ mod test { exporter_storage.clone()?, vec![], HashSet::new(), + Arc::new(AtomicBool::new(true)), ); let mut block_processor = BlockProcessor::new( exporters_tracker, @@ -583,6 +589,7 @@ mod test { exporter_storage.clone()?, vec![], HashSet::new(), + Arc::new(AtomicBool::new(true)), ); let mut block_processor = BlockProcessor::new( exporters_tracker, @@ -693,6 +700,7 @@ mod test { exporter_storage.clone()?, vec![], HashSet::new(), + Arc::new(AtomicBool::new(true)), ); let mut block_processor = BlockProcessor::new( exporters_tracker, @@ -783,6 +791,7 @@ mod test { exporter_storage.clone()?, vec![], HashSet::new(), + Arc::new(AtomicBool::new(true)), ); let mut block_processor = BlockProcessor::new( diff --git a/linera-service/src/exporter/runloops/indexer/indexer_exporter.rs b/linera-service/src/exporter/runloops/indexer/indexer_exporter.rs index e252dca94b3f..dbc56df69122 100644 --- a/linera-service/src/exporter/runloops/indexer/indexer_exporter.rs +++ b/linera-service/src/exporter/runloops/indexer/indexer_exporter.rs @@ -4,7 +4,7 @@ use std::{ future::IntoFuture, sync::{ - atomic::{AtomicU64, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, Arc, }, time::Duration, @@ -29,6 +29,7 @@ pub(crate) struct Exporter { options: NodeOptions, work_queue_size: usize, destination_id: DestinationId, + health: Arc, } impl Exporter { @@ -36,11 +37,13 @@ impl Exporter { destination_id: DestinationId, work_queue_size: usize, options: NodeOptions, + health: Arc, ) -> Exporter { Self { options, destination_id, work_queue_size, + health, } } @@ -87,6 +90,7 @@ impl Exporter { res = streamer.run() => { if let Err(error) = res { + self.health.store(false, Ordering::Release); tracing::error!(?error, "exporter stream error. re-trying to establish a stream"); client = IndexerClient::new(address, self.options)?; sleep(Duration::from_millis(500)).await; @@ -94,6 +98,7 @@ impl Exporter { }, res = acknowledgement_task.run() => { + self.health.store(false, Ordering::Release); match res { Err(error) => { tracing::error!(?error, "ack stream error. re-trying to establish a stream"); diff --git a/linera-service/src/exporter/runloops/mod.rs b/linera-service/src/exporter/runloops/mod.rs index f72646c5c4ac..cc82181408fe 100644 --- a/linera-service/src/exporter/runloops/mod.rs +++ b/linera-service/src/exporter/runloops/mod.rs @@ -4,6 +4,7 @@ use std::{ collections::HashSet, future::{Future, IntoFuture}, + sync::{atomic::AtomicBool, Arc}, }; use block_processor::BlockProcessor; @@ -39,6 +40,7 @@ pub(crate) fn start_block_processor_task( options: NodeOptions, block_exporter_id: u32, destination_config: DestinationConfig, + health: Arc, ) -> ( UnboundedSender, std::thread::JoinHandle>, @@ -62,6 +64,7 @@ where block_exporter_id, new_block_queue, destination_config, + health, ) }); @@ -91,6 +94,7 @@ impl NewBlockQueue { } #[tokio::main(flavor = "current_thread")] +#[expect(clippy::too_many_arguments)] async fn start_block_processor( storage: &S, shutdown_signal: F, @@ -99,6 +103,7 @@ async fn start_block_processor( block_exporter_id: u32, new_block_queue: NewBlockQueue, destination_config: DestinationConfig, + health: Arc, ) -> Result<(), ExporterError> where S: Storage + Clone + Send + Sync + 'static, @@ -142,6 +147,7 @@ where exporter_storage.clone()?, destination_config.destinations, startup_committee_destinations, + health, ); let mut block_processor = BlockProcessor::new( @@ -224,7 +230,14 @@ where #[cfg(test)] mod test { - use std::{collections::BTreeMap, sync::atomic::Ordering, time::Duration}; + use std::{ + collections::BTreeMap, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::Duration, + }; use linera_base::{ crypto::{AccountPublicKey, Secp256k1PublicKey}, @@ -314,6 +327,7 @@ mod test { committee_destination: false, destinations: vec![destination_address], }, + Arc::new(AtomicBool::new(true)), ); assert!( @@ -371,6 +385,7 @@ mod test { committee_destination: false, destinations: destinations.clone(), }, + Arc::new(AtomicBool::new(true)), ); assert!( @@ -427,6 +442,7 @@ mod test { destinations: destinations.clone(), committee_destination: false, }, + Arc::new(AtomicBool::new(true)), ); sleep(Duration::from_secs(4)).await; @@ -488,6 +504,7 @@ mod test { committee_destination: true, destinations: vec![], }, + Arc::new(AtomicBool::new(true)), ); let mut single_validator = BTreeMap::new(); diff --git a/linera-service/src/exporter/runloops/task_manager.rs b/linera-service/src/exporter/runloops/task_manager.rs index 0c585cec230b..1374d4a5fa0d 100644 --- a/linera-service/src/exporter/runloops/task_manager.rs +++ b/linera-service/src/exporter/runloops/task_manager.rs @@ -4,7 +4,10 @@ use std::{ collections::{HashMap, HashSet}, future::{Future, IntoFuture}, - sync::Arc, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, }; use linera_rpc::{grpc::GrpcNodeProvider, NodeOptions}; @@ -44,12 +47,14 @@ where storage: ExporterStorage, startup_destinations: Vec, current_committee_destinations: HashSet, + health: Arc, ) -> Self { let exporters_builder = ExporterBuilder::new( node_options, work_queue_size, shutdown_signal, &startup_destinations, + health, ); Self { exporters_builder, @@ -146,6 +151,7 @@ pub(super) struct ExporterBuilder { /// Full destination configs keyed by ID, needed for destinations that /// require more than just the address string (e.g. EvmChain). destination_configs: HashMap, + health: Arc, } impl ExporterBuilder @@ -158,6 +164,7 @@ where work_queue_size: usize, shutdown_signal: F, destinations: &[Destination], + health: Arc, ) -> Self { let node_provider = GrpcNodeProvider::new(options); let arced_node_provider = Arc::new(node_provider); @@ -169,6 +176,7 @@ where work_queue_size, node_provider: arced_node_provider, destination_configs, + health, } } @@ -180,14 +188,27 @@ where where S: Storage + Clone + Send + Sync + 'static, { + let shutdown_signal = self.shutdown_signal.clone(); + let health = self.health.clone(); + match id.kind() { DestinationKind::Indexer => { - let exporter_task = - super::IndexerExporter::new(id.clone(), self.work_queue_size, self.options); + let exporter_task = super::IndexerExporter::new( + id.clone(), + self.work_queue_size, + self.options, + self.health.clone(), + ); - tokio::task::spawn( - exporter_task.run_with_shutdown(self.shutdown_signal.clone(), storage), - ) + tokio::task::spawn(async move { + let result = exporter_task + .run_with_shutdown(shutdown_signal, storage) + .await; + if result.is_err() { + health.store(false, Ordering::Release); + } + result + }) } DestinationKind::Validator => { @@ -197,16 +218,28 @@ where self.work_queue_size, ); - tokio::task::spawn( - exporter_task.run_with_shutdown(self.shutdown_signal.clone(), storage), - ) + tokio::task::spawn(async move { + let result = exporter_task + .run_with_shutdown(shutdown_signal, storage) + .await; + if result.is_err() { + health.store(false, Ordering::Release); + } + result + }) } DestinationKind::Logging => { let exporter_task = LoggingExporter::new(id); - tokio::task::spawn( - exporter_task.run_with_shutdown(self.shutdown_signal.clone(), storage), - ) + tokio::task::spawn(async move { + let result = exporter_task + .run_with_shutdown(shutdown_signal, storage) + .await; + if result.is_err() { + health.store(false, Ordering::Release); + } + result + }) } DestinationKind::EvmChain => { @@ -215,9 +248,15 @@ where .get(&id) .expect("EvmChain destination config must exist"); let exporter_task = EvmChainExporter::new(id, destination.clone()); - tokio::task::spawn( - exporter_task.run_with_shutdown(self.shutdown_signal.clone(), storage), - ) + tokio::task::spawn(async move { + let result = exporter_task + .run_with_shutdown(shutdown_signal, storage) + .await; + if result.is_err() { + health.store(false, Ordering::Release); + } + result + }) } } } From 46727ada0c92c38c5e2c7ccfc980696ffabb8ab8 Mon Sep 17 00:00:00 2001 From: deuszx <95355183+deuszx@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:00:55 +0000 Subject: [PATCH 05/13] Bridge verify evm finality (#5684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge verifies EVM block finality before processing deposits. The source chain is Base (OP Stack L2, chain ID 8453), which changes the threat model: - L2 RPC providers more commonly misconfigure endpoints (Base Mainnet vs Sepolia vs other OP Stack chains) - The `"finalized"` tag has different semantics on OP Stack (tracks L1 finality of the L2 batch) - 2-second block times mean cache grows 6x faster than L1 - Add `rpc_endpoint` field to `BridgeParameters` and `verified_block_hashes` to contract state - `ProcessDeposit` verifies block hash finality inline when `rpc_endpoint` is configured, querying the source EVM chain (`eth_getBlockByHash` + `eth_getBlockByNumber("finalized")`) - Add `VerifyBlockHash` operation for pre-verification; caches the hash only when submitted by an authenticated signer (chain owner) to prevent state bloat - `ProcessDeposit` also caches verified block hashes after replay protection, so subsequent deposits from the same block skip the RPC check - Log a warning when `rpc_endpoint` is empty and finality verification is skipped - Add `get_chain_id()` to the `EthereumQueries` trait in `linera-ethereum` - Validate the RPC endpoint's chain ID matches `source_chain_id` during `instantiate()`, catching misconfigured endpoints at deploy time - Add `ProofError` enum (transient vs permanent) to deposit proof generation - Transient: receipt not found, block not found, RPC transport errors → retry with backoff - Permanent: header hash mismatch, receipts root mismatch, no DepositInitiated event → return 400 immediately - `thiserror` dependency gated behind `offchain` feature so it doesn't leak into examples - Retry deposit proof generation up to 5 times with linear backoff (public testnet RPCs may not have indexed the receipt immediately) - Permanent errors fail immediately with `400 BAD_REQUEST` instead of wasting 20s retrying - Add structured `tracing` logs throughout the deposit handler and main loop - Accurate for any EVM-compatible JSON-RPC endpoint, not just Ethereum L1 - Add fallible `try_create_application` to `ActiveChain` in `linera-sdk` test framework - Auto-detect `linera` / `linera-bridge` binaries from `target/debug`, `target/release`, or `$PATH` - Wait for EVM transaction inclusion after each `forge create` to avoid nonce races on public testnets - Add `sync` + `process-inbox` steps before Linera app deployments - Pass `EVM_RPC_URL` into evm-bridge application parameters - Run bridge e2e workflow on `pull_request` events - Update README with Conway testnet faucet URL and improved `SHARED_DIR` instructions - `cargo test -p linera-ethereum --features ethereum` — all Anvil tests pass including new `test_get_chain_id` - `cargo test --manifest-path examples/evm-bridge/Cargo.toml` — 9 unit/integration tests pass including `test_instantiation_fails_with_unreachable_endpoint` - `cargo test --manifest-path examples/evm-bridge/Cargo.toml -- --ignored` — 2 Anvil tests pass (`test_verify_block_hash_anvil`, `test_verify_block_hash_not_found`) - `cargo test -p linera-bridge` — proof/relay tests pass - `cargo clippy -p linera-bridge --lib --features chain --no-default-features` — confirms no `thiserror` leakage into examples - Nothing to do / These changes follow the usual release cycle. Closes https://github.com/linera-io/linera-protocol/issues/5629 - [reviewer checklist](https://github.com/linera-io/linera-protocol/blob/main/CONTRIBUTING.md#reviewer-checklist) --- .github/workflows/bridge-e2e.yml | 1 + Cargo.lock | 1 + examples/Cargo.lock | 2 + examples/Cargo.toml | 2 + examples/bridge-demo/setup.sh | 1 + examples/evm-bridge/Cargo.toml | 5 +- examples/evm-bridge/src/contract.rs | 79 ++++++- examples/evm-bridge/src/lib.rs | 10 + examples/evm-bridge/tests/process_deposit.rs | 201 +++++++++++++++++- linera-bridge/Cargo.toml | 2 + linera-bridge/src/proof/gen.rs | 85 +++++--- linera-bridge/src/proof/mod.rs | 9 +- linera-bridge/src/relay.rs | 12 +- .../tests/e2e/tests/evm_to_linera_bridge.rs | 9 +- linera-ethereum/src/client.rs | 38 +++- linera-ethereum/src/common.rs | 4 + linera-ethereum/tests/ethereum_test.rs | 10 + linera-sdk/src/test/chain.rs | 43 ++++ 18 files changed, 470 insertions(+), 44 deletions(-) diff --git a/.github/workflows/bridge-e2e.yml b/.github/workflows/bridge-e2e.yml index 5f8c2cc6a0da..90b2eec35425 100644 --- a/.github/workflows/bridge-e2e.yml +++ b/.github/workflows/bridge-e2e.yml @@ -3,6 +3,7 @@ name: Bridge E2E on: merge_group: workflow_dispatch: + pull_request: push: branches: [ 'testnet_*', 'main', 'bridge-main'] diff --git a/Cargo.lock b/Cargo.lock index 25533790cd74..bf468947f595 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5491,6 +5491,7 @@ dependencies = [ "serde_json", "serde_yaml 0.8.26", "tempfile", + "thiserror 1.0.69", "tokio", "tower-http 0.6.6", "tracing", diff --git a/examples/Cargo.lock b/examples/Cargo.lock index 453bf6690868..c5bdf5ecbc79 100644 --- a/examples/Cargo.lock +++ b/examples/Cargo.lock @@ -2550,7 +2550,9 @@ dependencies = [ "fungible", "hex", "linera-bridge", + "linera-ethereum", "linera-sdk", + "log", "serde", "tokio", "wrapped-fungible", diff --git a/examples/Cargo.toml b/examples/Cargo.toml index 12d0197c7014..ff8f30344a7a 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -40,6 +40,8 @@ getrandom = { version = "0.2.12", default-features = false, features = [ "custom", ] } hex = "0.4.3" +insta = { version = "1.36.1", features = ["yaml"] } +linera-ethereum = { path = "../linera-ethereum" } linera-sdk = { path = "../linera-sdk" } log = "0.4.20" num-bigint = "0.4.3" diff --git a/examples/bridge-demo/setup.sh b/examples/bridge-demo/setup.sh index 780e9ba97779..c37f60f1be63 100755 --- a/examples/bridge-demo/setup.sh +++ b/examples/bridge-demo/setup.sh @@ -463,6 +463,7 @@ params = { 'bridge_contract_address': hex_to_array(os.environ['BRIDGE_HEX']), 'fungible_app_id': os.environ['APP_ID'], 'token_address': hex_to_array(os.environ['TOKEN_HEX']), + 'rpc_endpoint': os.environ.get('EVM_RPC_URL', ''), } print(json.dumps(params)) ") diff --git a/examples/evm-bridge/Cargo.toml b/examples/evm-bridge/Cargo.toml index d8f6e9774868..52750fceaf15 100644 --- a/examples/evm-bridge/Cargo.toml +++ b/examples/evm-bridge/Cargo.toml @@ -15,7 +15,8 @@ hex.workspace = true linera-bridge = { path = "../../linera-bridge", default-features = false, features = [ "chain", ] } -linera-sdk.workspace = true +linera-sdk = { workspace = true, features = ["ethereum"] } +log.workspace = true serde.workspace = true wrapped-fungible.workspace = true @@ -27,7 +28,9 @@ linera-bridge = { path = "../../linera-bridge", default-features = false, featur "chain", "testing", ] } +linera-ethereum.workspace = true linera-sdk = { workspace = true, features = ["test", "wasmer"] } +serde.workspace = true tokio.workspace = true wrapped-fungible.workspace = true diff --git a/examples/evm-bridge/src/contract.rs b/examples/evm-bridge/src/contract.rs index 5e7cd76905a8..3a3a60e7af74 100644 --- a/examples/evm-bridge/src/contract.rs +++ b/examples/evm-bridge/src/contract.rs @@ -3,21 +3,23 @@ #![cfg_attr(target_arch = "wasm32", no_main)] -use alloy_primitives::Bytes; +use alloy_primitives::{Bytes, B256}; use evm_bridge::{BridgeOperation, BridgeParameters, DepositKey, EvmBridgeAbi}; use linera_bridge::proof; use linera_sdk::{ + ethereum::{ContractEthereumClient, EthereumQueries}, linera_base_types::{Account, AccountOwner, Amount, ChainId, WithContractAbi}, views::{linera_views, RootView, SetView, View, ViewStorageContext}, Contract, ContractRuntime, }; use wrapped_fungible::{WrappedFungibleOperation, WrappedFungibleTokenAbi}; -/// On-chain state: tracks processed deposits for replay protection. +/// On-chain state: tracks processed deposits and verified block hashes. #[derive(RootView)] #[view(context = ViewStorageContext)] pub struct BridgeState { pub processed_deposits: SetView, + pub verified_block_hashes: SetView<[u8; 32]>, } pub struct EvmBridgeContract { @@ -45,8 +47,19 @@ impl Contract for EvmBridgeContract { } async fn instantiate(&mut self, _argument: ()) { - // Validate parameters are present. - self.runtime.application_parameters(); + let params = self.runtime.application_parameters(); + if !params.rpc_endpoint.is_empty() { + let client = ContractEthereumClient::new(params.rpc_endpoint.clone()); + let chain_id = client + .get_chain_id() + .await + .expect("failed to query chain ID from RPC endpoint"); + assert_eq!( + chain_id, params.source_chain_id, + "RPC endpoint chain ID {chain_id} does not match configured source_chain_id {}", + params.source_chain_id + ); + } } async fn execute_operation(&mut self, operation: BridgeOperation) { @@ -67,6 +80,18 @@ impl Contract for EvmBridgeContract { ) .await; } + BridgeOperation::VerifyBlockHash { block_hash } => { + self.verify_block_hash(block_hash).await; + + // Only cache when called by an authenticated signer (chain owner), + // preventing unauthenticated callers from bloating state. + if self.runtime.authenticated_signer().is_some() { + self.state + .verified_block_hashes + .insert(&block_hash) + .expect("failed to insert verified block hash"); + } + } } } @@ -78,6 +103,28 @@ impl Contract for EvmBridgeContract { } impl EvmBridgeContract { + async fn verify_block_hash(&mut self, block_hash: [u8; 32]) { + let params = self.runtime.application_parameters(); + assert!( + !params.rpc_endpoint.is_empty(), + "rpc_endpoint must be configured to verify block hashes" + ); + + let client = ContractEthereumClient::new(params.rpc_endpoint.clone()); + assert!( + client + .is_block_hash_finalized(B256::from(block_hash)) + .await + .expect("failed to check block finality — block may not exist"), + "block is not finalized" + ); + + log::info!( + "verified block hash {} is finalized", + hex::encode(block_hash) + ); + } + async fn process_deposit( &mut self, block_header_rlp: &[u8], @@ -92,6 +139,21 @@ impl EvmBridgeContract { let (block_hash, receipts_root) = proof::decode_block_header(block_header_rlp).expect("invalid block header RLP"); + // 1b. Finality check: when an endpoint is configured, verify the block hash + // is finalized. Uses cached result if a previous deposit from this block + // was already processed. + if params.rpc_endpoint.is_empty() { + log::warn!("rpc_endpoint is empty — skipping block finality verification."); + } else if !self + .state + .verified_block_hashes + .contains(&block_hash.0) + .await + .expect("failed to check verified block hashes") + { + self.verify_block_hash(block_hash.0).await; + } + // 2. Verify receipt inclusion via MPT proof let proof_bytes: Vec = proof_nodes .iter() @@ -145,6 +207,15 @@ impl EvmBridgeContract { .insert(&deposit_key) .expect("failed to insert deposit key"); + // 5b. Cache the verified block hash so subsequent deposits from the same + // block skip the RPC finality check. + if !params.rpc_endpoint.is_empty() { + self.state + .verified_block_hashes + .insert(&block_hash.0) + .expect("failed to cache verified block hash"); + } + // 6. Convert deposit fields to Linera types and call Mint let target_chain_id = ChainId::try_from(deposit.target_chain_id.as_slice()).expect("invalid target chain ID"); diff --git a/examples/evm-bridge/src/lib.rs b/examples/evm-bridge/src/lib.rs index fa63547c7ba3..8624661dd443 100644 --- a/examples/evm-bridge/src/lib.rs +++ b/examples/evm-bridge/src/lib.rs @@ -21,6 +21,10 @@ pub struct BridgeParameters { pub fungible_app_id: ApplicationId, /// ERC-20 token address on the source EVM chain. pub token_address: [u8; 20], + /// JSON-RPC endpoint of the source EVM chain for finality verification. + /// When non-empty, `ProcessDeposit` requires the block hash to be verified first + /// via `VerifyBlockHash`. + pub rpc_endpoint: String, } /// Replay-protection key for processed deposits. @@ -43,6 +47,12 @@ pub enum BridgeOperation { tx_index: u64, log_index: u64, }, + /// Verify that an EVM block hash is authentic and finalized. + /// + /// Queries the EVM node to confirm the block exists and its number is at or below + /// the latest finalized block. Caches the hash only when submitted by an + /// authenticated signer (chain owner) to prevent state bloat. + VerifyBlockHash { block_hash: [u8; 32] }, } pub struct EvmBridgeAbi; diff --git a/examples/evm-bridge/tests/process_deposit.rs b/examples/evm-bridge/tests/process_deposit.rs index 9c7a62266c4e..17288ce90c0c 100644 --- a/examples/evm-bridge/tests/process_deposit.rs +++ b/examples/evm-bridge/tests/process_deposit.rs @@ -19,6 +19,7 @@ use linera_sdk::{ linera_base_types::{AccountOwner, Amount, ApplicationId}, test::{ActiveChain, TestValidator}, }; +use serde::Deserialize; use wrapped_fungible::{WrappedFungibleTokenAbi, WrappedParameters}; /// Helper to query an account balance on the wrapped-fungible app. @@ -93,6 +94,7 @@ impl TestBridge { bridge_contract_address: [0xBB; 20], fungible_app_id: fungible_app_id.forget_abi(), token_address, + rpc_endpoint: String::new(), }; let bridge_app_id = chain .create_application(bridge_module_id, bridge_params, (), vec![]) @@ -165,7 +167,7 @@ impl TestBridge { let (receipts_root, proof_bytes) = build_receipt_trie(&[(tx_index, receipt.clone())], tx_index); let proof_nodes: Vec> = proof_bytes.into_iter().map(|b| b.to_vec()).collect(); - let block_header = build_test_header(receipts_root); + let block_header = build_test_header(receipts_root, 12345); (block_header, receipt, proof_nodes, tx_index, 0) } } @@ -219,7 +221,7 @@ async fn test_process_deposit() { assert!(result.is_err(), "replay deposit should be rejected"); // Use a different (wrong) receipts root in the block header - let wrong_header = build_test_header(B256::from([0xFF; 32])); + let wrong_header = build_test_header(B256::from([0xFF; 32]), 12345); let result = tb .chain @@ -556,3 +558,198 @@ async fn test_replay_different_log_index_succeeds() { Some(Amount::from_attos(2_000_000u128)), ); } + +// -- finality verification tests -- + +/// When `rpc_endpoint` is set but the RPC endpoint is unreachable, +/// instantiation should fail because the chain ID check cannot succeed. +#[tokio::test] +async fn test_instantiation_fails_with_unreachable_endpoint() { + let (validator, bridge_module_id) = + TestValidator::with_current_module::().await; + let mut chain = validator.new_chain().await; + let chain_owner = AccountOwner::from(chain.public_key()); + + let fungible_module_id = chain + .publish_bytecode_files_in::( + "../wrapped-fungible", + ) + .await; + + let token_address = [0xA0; 20]; + let source_chain_id = 8453u64; + + let wrapped_params = WrappedParameters { + ticker_symbol: "wUSDC".to_string(), + minter: chain_owner, + mint_chain_id: chain.id(), + evm_token_address: token_address, + evm_source_chain_id: source_chain_id, + }; + let fungible_app_id = chain + .create_application( + fungible_module_id, + wrapped_params, + InitialStateBuilder::default().build(), + vec![], + ) + .await; + + // Non-empty endpoint that is unreachable → instantiation should fail + let bridge_params = BridgeParameters { + source_chain_id, + bridge_contract_address: [0xBB; 20], + fungible_app_id: fungible_app_id.forget_abi(), + token_address, + rpc_endpoint: "http://localhost:8545".to_string(), + }; + let result = chain + .try_create_application(bridge_module_id, bridge_params, (), vec![]) + .await; + + assert!( + result.is_err(), + "instantiation should fail with unreachable endpoint" + ); +} + +// -- Anvil-based finality verification tests -- +// These require `anvil` (from Foundry) to be installed. + +/// Minimal block response for extracting hash from an Anvil RPC call. +#[derive(Deserialize)] +struct EthBlock { + hash: B256, +} + +/// Queries Anvil for the latest block hash (outside the contract, for test setup). +async fn get_anvil_block_hash(endpoint: &str) -> B256 { + use linera_ethereum::client::JsonRpcClient; + let rpc = linera_ethereum::provider::EthereumClientSimplified::new(endpoint.to_string()); + let block: EthBlock = rpc + .request("eth_getBlockByNumber", ("latest", false)) + .await + .expect("failed to query Anvil for latest block"); + block.hash +} + +/// Sets up a bridge instance with Anvil as the EVM endpoint and the TestValidator +/// configured to allow HTTP requests to Anvil's host. +async fn setup_bridge_with_anvil( + anvil_endpoint: &str, +) -> ( + ActiveChain, + AccountOwner, + ApplicationId, + ApplicationId, +) { + let (mut validator, bridge_module_id) = + TestValidator::with_current_module::().await; + + // Allow the contract to make HTTP requests to the Anvil host. + validator + .change_resource_control_policy(|policy| { + policy + .http_request_allow_list + .insert("localhost".to_owned()); + }) + .await; + + let mut chain = validator.new_chain().await; + let chain_owner = AccountOwner::from(chain.public_key()); + + let fungible_module_id = chain + .publish_bytecode_files_in::( + "../wrapped-fungible", + ) + .await; + + let token_address = [0xA0; 20]; + // Anvil's default chain ID is 31337. + let source_chain_id = 31337u64; + + let wrapped_params = WrappedParameters { + ticker_symbol: "wUSDC".to_string(), + minter: chain_owner, + mint_chain_id: chain.id(), + evm_token_address: token_address, + evm_source_chain_id: source_chain_id, + }; + let fungible_app_id = chain + .create_application( + fungible_module_id, + wrapped_params, + InitialStateBuilder::default().build(), + vec![], + ) + .await; + + let bridge_params = BridgeParameters { + source_chain_id, + bridge_contract_address: [0xBB; 20], + fungible_app_id: fungible_app_id.forget_abi(), + token_address, + rpc_endpoint: anvil_endpoint.to_string(), + }; + let bridge_app_id = chain + .create_application(bridge_module_id, bridge_params, (), vec![]) + .await; + + (chain, chain_owner, bridge_app_id, fungible_app_id) +} + +/// VerifyBlockHash with a real finalized Anvil block hash should succeed. +#[tokio::test] +#[ignore] // requires `anvil` from Foundry +async fn test_verify_block_hash_anvil() { + let anvil = linera_ethereum::test_utils::get_anvil() + .await + .expect("failed to start anvil"); + + let block_hash = get_anvil_block_hash(&anvil.endpoint).await; + + let (chain, _chain_owner, bridge_app_id, _fungible_app_id) = + setup_bridge_with_anvil(&anvil.endpoint).await; + + // VerifyBlockHash should succeed — Anvil treats all blocks as finalized. + chain + .add_block(|block| { + block.with_operation( + bridge_app_id, + BridgeOperation::VerifyBlockHash { + block_hash: block_hash.0, + }, + ); + }) + .await; +} + +/// VerifyBlockHash with a non-existent block hash should fail. +#[tokio::test] +#[ignore] // requires `anvil` from Foundry +async fn test_verify_block_hash_not_found() { + let anvil = linera_ethereum::test_utils::get_anvil() + .await + .expect("failed to start anvil"); + + let (chain, _chain_owner, bridge_app_id, _fungible_app_id) = + setup_bridge_with_anvil(&anvil.endpoint).await; + + // VerifyBlockHash with a fake hash — Anvil will return null. + let fake_hash = [0xDE; 32]; + let result = chain + .try_add_block(|block| { + block.with_operation( + bridge_app_id, + BridgeOperation::VerifyBlockHash { + block_hash: fake_hash, + }, + ); + }) + .await; + + assert!( + result.is_err(), + "VerifyBlockHash with non-existent hash should fail" + ); +} diff --git a/linera-bridge/Cargo.toml b/linera-bridge/Cargo.toml index 1e6a020027ec..84ae688d2609 100644 --- a/linera-bridge/Cargo.toml +++ b/linera-bridge/Cargo.toml @@ -22,6 +22,7 @@ offchain = [ "dep:linera-base", "dep:linera-execution", "dep:op-alloy-network", + "dep:thiserror", "dep:tokio", "dep:url", ] @@ -66,6 +67,7 @@ alloy-primitives.workspace = true alloy-rlp.workspace = true alloy-trie.workspace = true anyhow.workspace = true +thiserror = { workspace = true, optional = true } async-trait = { workspace = true, optional = true } diff --git a/linera-bridge/src/proof/gen.rs b/linera-bridge/src/proof/gen.rs index 26f7fc8b8201..4e0e8ff06c82 100644 --- a/linera-bridge/src/proof/gen.rs +++ b/linera-bridge/src/proof/gen.rs @@ -23,10 +23,21 @@ use alloy::{ providers::{Provider, ProviderBuilder}, }; use alloy_rlp::Encodable; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result}; use async_trait::async_trait; use op_alloy_network::Optimism; +/// Errors from deposit proof generation, classified for retry logic. +#[derive(Debug, thiserror::Error)] +pub enum ProofError { + /// Retrying may succeed (e.g. receipt not yet indexed, RPC transport error). + #[error("transient error: {0:#}")] + Transient(anyhow::Error), + /// Retrying will not help (e.g. hash mismatch, missing deposit event). + #[error("permanent error: {0:#}")] + Permanent(anyhow::Error), +} + /// All data needed to submit a `ProcessDeposit` operation to the evm-bridge app. #[derive(Debug, Clone)] pub struct DepositProof { @@ -52,7 +63,7 @@ pub trait DepositProofClient { /// /// The implementation fetches the receipt, locates the `DepositInitiated` /// event log automatically, and constructs the MPT proof. - async fn generate_deposit_proof(&self, tx_hash: B256) -> Result; + async fn generate_deposit_proof(&self, tx_hash: B256) -> Result; } /// HTTP-based deposit proof client that queries an EVM JSON-RPC endpoint. @@ -79,29 +90,35 @@ impl HttpDepositProofClient { #[async_trait] impl DepositProofClient for HttpDepositProofClient { - async fn generate_deposit_proof(&self, tx_hash: B256) -> Result { + async fn generate_deposit_proof(&self, tx_hash: B256) -> Result { // 1. Get transaction receipt → block hash, tx index let receipt = self .provider .get_transaction_receipt(tx_hash) - .await? - .with_context(|| format!("transaction receipt not found for {tx_hash}"))?; - - let block_hash = receipt - .inner - .block_hash - .context("receipt missing block_hash (pending tx?)")?; - let tx_index = receipt - .inner - .transaction_index - .context("receipt missing transaction_index")?; + .await + .map_err(|e| ProofError::Transient(e.into()))? + .ok_or_else(|| { + ProofError::Transient(anyhow::anyhow!( + "transaction receipt not found for {tx_hash}" + )) + })?; + + let block_hash = receipt.inner.block_hash.ok_or_else(|| { + ProofError::Transient(anyhow::anyhow!("receipt missing block_hash (pending tx?)")) + })?; + let tx_index = receipt.inner.transaction_index.ok_or_else(|| { + ProofError::Transient(anyhow::anyhow!("receipt missing transaction_index")) + })?; // 2. Get full block → header RLP let block = self .provider .get_block_by_hash(block_hash) - .await? - .with_context(|| format!("block not found for hash {block_hash}"))?; + .await + .map_err(|e| ProofError::Transient(e.into()))? + .ok_or_else(|| { + ProofError::Transient(anyhow::anyhow!("block not found for hash {block_hash}")) + })?; let mut block_header_rlp = Vec::new(); block.header.inner.encode(&mut block_header_rlp); @@ -109,18 +126,23 @@ impl DepositProofClient for HttpDepositProofClient { // Sanity check: header RLP hashes to the expected block hash let computed_hash = alloy_primitives::keccak256(&block_header_rlp); if computed_hash != block_hash { - bail!( + return Err(ProofError::Permanent(anyhow::anyhow!( "header RLP hash mismatch: computed {computed_hash}, expected {block_hash}. \ This may indicate the RPC returned non-standard header fields." - ); + ))); } // 3. Get all block receipts let all_receipts = self .provider .get_block_receipts(block.header.number.into()) - .await? - .with_context(|| format!("block receipts not found for block {block_hash}"))?; + .await + .map_err(|e| ProofError::Transient(e.into()))? + .ok_or_else(|| { + ProofError::Transient(anyhow::anyhow!( + "block receipts not found for block {block_hash}" + )) + })?; // 4. Encode each receipt to canonical EIP-2718 form (as stored in the trie). // Convert RPC logs → primitives logs so Encodable2718 is available. @@ -140,28 +162,33 @@ impl DepositProofClient for HttpDepositProofClient { .iter() .find(|(idx, _)| *idx == tx_index) .map(|(_, rlp)| rlp.clone()) - .with_context(|| format!("tx_index {tx_index} not found in block receipts"))?; + .ok_or_else(|| { + ProofError::Permanent(anyhow::anyhow!( + "tx_index {tx_index} not found in block receipts" + )) + })?; let (receipts_root, proof_nodes) = crate::proof::build_receipt_proof(&canonical_receipts, tx_index); // Sanity check: computed receipts root matches block header if receipts_root != block.header.inner.receipts_root { - bail!( + return Err(ProofError::Permanent(anyhow::anyhow!( "receipts root mismatch: computed {receipts_root}, \ header says {}. Receipt encoding may be incorrect.", block.header.inner.receipts_root - ); + ))); } // Find all DepositInitiated log indices from the canonical receipt - let logs = crate::proof::decode_receipt_logs(&receipt_rlp) - .context("failed to decode receipt logs")?; + let logs = + crate::proof::decode_receipt_logs(&receipt_rlp).map_err(ProofError::Permanent)?; let log_indices = crate::proof::find_deposit_log_indices(&logs); - anyhow::ensure!( - !log_indices.is_empty(), - "no DepositInitiated event found in receipt for tx {tx_hash}" - ); + if log_indices.is_empty() { + return Err(ProofError::Permanent(anyhow::anyhow!( + "no DepositInitiated event found in receipt for tx {tx_hash}" + ))); + } Ok(DepositProof { block_header_rlp, diff --git a/linera-bridge/src/proof/mod.rs b/linera-bridge/src/proof/mod.rs index c89688983bb7..4c8f85f2c843 100644 --- a/linera-bridge/src/proof/mod.rs +++ b/linera-bridge/src/proof/mod.rs @@ -120,11 +120,12 @@ pub mod testing { use super::ReceiptLog; - /// Builds a minimal RLP-encoded Ethereum block header with the given receipts root. + /// Builds a minimal RLP-encoded Ethereum block header with the given receipts root + /// and block number. /// /// All other header fields are set to zero/default values. This produces a valid /// RLP list that can be decoded by [`super::decode_block_header`]. - pub fn build_test_header(receipts_root: B256) -> Vec { + pub fn build_test_header(receipts_root: B256, block_number: u64) -> Vec { let mut payload = Vec::new(); B256::ZERO.encode(&mut payload); // 0: parentHash B256::ZERO.encode(&mut payload); // 1: ommersHash @@ -134,7 +135,7 @@ pub mod testing { receipts_root.encode(&mut payload); // 5: receiptsRoot Bloom::ZERO.encode(&mut payload); // 6: logsBloom 0u64.encode(&mut payload); // 7: difficulty - 12345u64.encode(&mut payload); // 8: number + block_number.encode(&mut payload); // 8: number 30_000_000u64.encode(&mut payload); // 9: gasLimit 21_000u64.encode(&mut payload); // 10: gasUsed 1_700_000_000u64.encode(&mut payload); // 11: timestamp @@ -606,7 +607,7 @@ mod tests { #[test] fn test_decode_block_header() { let receipts_root = B256::from([0xAB; 32]); - let header_rlp = build_test_header(receipts_root); + let header_rlp = build_test_header(receipts_root, 12345); let (block_hash, decoded_root) = decode_block_header(&header_rlp).unwrap(); diff --git a/linera-bridge/src/relay.rs b/linera-bridge/src/relay.rs index 19ca35f7ddf9..b8271b52bad0 100644 --- a/linera-bridge/src/relay.rs +++ b/linera-bridge/src/relay.rs @@ -35,7 +35,7 @@ use linera_views::backends::memory::{MemoryDatabase, MemoryStoreConfig}; use tokio::sync::{mpsc, oneshot}; use tower_http::cors::CorsLayer; -use crate::proof::gen::{DepositProofClient, HttpDepositProofClient}; +use crate::proof::gen::{DepositProofClient, HttpDepositProofClient, ProofError}; // ── Alloy ABI for FungibleBridge.addBlock ── @@ -100,6 +100,7 @@ async fn deposit_handler( // Retry proof generation — on public testnets the RPC may not have // indexed the receipt yet when the frontend sends the tx hash. + // Permanent errors (invalid tx, missing deposit event) fail immediately. tracing::info!(%tx_hash, "Generating deposit proof..."); let mut proof = None; for attempt in 0..5 { @@ -114,7 +115,14 @@ async fn deposit_handler( proof = Some(p); break; } - Err(e) => { + Err(ProofError::Permanent(e)) => { + tracing::error!(%tx_hash, "Deposit proof generation failed permanently: {e:#}"); + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": format!("{e:#}")})), + ); + } + Err(ProofError::Transient(e)) => { if attempt < 4 { tracing::warn!( %tx_hash, attempt, "Deposit proof generation failed, retrying: {e:#}" diff --git a/linera-bridge/tests/e2e/tests/evm_to_linera_bridge.rs b/linera-bridge/tests/e2e/tests/evm_to_linera_bridge.rs index d8ef8db80c44..64ec5275405e 100644 --- a/linera-bridge/tests/e2e/tests/evm_to_linera_bridge.rs +++ b/linera-bridge/tests/e2e/tests/evm_to_linera_bridge.rs @@ -37,7 +37,9 @@ use linera_views::backends::memory::{MemoryDatabase, MemoryStoreConfig}; use serde::{Deserialize, Serialize}; use wrapped_fungible::{InitialState, WrappedParameters}; -// ── Inline evm-bridge types (avoids depending on evm-bridge crate) ────────── +// ── Inline evm-bridge types ───────────────────────────────────────────────── +// Inlined to avoid a dependency on evm-bridge, which pulls in linera-bridge +// with the `chain` feature — that disables `proof::gen` via feature unification. /// Must match `evm_bridge::BridgeParameters` field-for-field for BCS compatibility. #[derive(Clone, Debug, Deserialize, Serialize)] @@ -46,6 +48,7 @@ struct BridgeParameters { bridge_contract_address: [u8; 20], fungible_app_id: ApplicationId, token_address: [u8; 20], + rpc_endpoint: String, } /// Must match `evm_bridge::BridgeOperation` variant-for-variant for BCS compatibility. @@ -58,6 +61,9 @@ enum BridgeOperation { tx_index: u64, log_index: u64, }, + VerifyBlockHash { + block_hash: [u8; 32], + }, } // ── Solidity interfaces for EVM calls ─────────────────────────────────────── @@ -245,6 +251,7 @@ async fn test_evm_to_linera_bridge() -> anyhow::Result<()> { bridge_contract_address: bridge_addr.0 .0, fungible_app_id, token_address: erc20_addr.0 .0, + rpc_endpoint: String::new(), }; let (bridge_app_id, _) = cc .create_application_untyped( diff --git a/linera-ethereum/src/client.rs b/linera-ethereum/src/client.rs index cc577213fc23..1f1f8ac51161 100644 --- a/linera-ethereum/src/client.rs +++ b/linera-ethereum/src/client.rs @@ -7,7 +7,7 @@ use alloy::rpc::types::eth::{ request::{TransactionInput, TransactionRequest}, BlockId, BlockNumberOrTag, Filter, Log, }; -use alloy_primitives::{Address, Bytes, U256, U64}; +use alloy_primitives::{Address, Bytes, B256, U256, U64}; use async_trait::async_trait; use linera_base::ensure; use serde::{de::DeserializeOwned, Deserialize, Serialize}; @@ -121,6 +121,16 @@ pub trait EthereumQueries { from: &str, block: u64, ) -> Result; + + /// Returns the chain ID reported by the connected EVM node. + async fn get_chain_id(&self) -> Result; + + /// Checks whether a block hash is finalized on the EVM chain. + /// + /// Queries the node for the block (proving it exists), then compares its number + /// against the latest finalized block number. + /// Returns `Err(BlockNotFound)` if the hash does not exist on chain. + async fn is_block_hash_finalized(&self, block_hash: B256) -> Result; } pub(crate) fn get_block_id(block_number: u64) -> BlockId { @@ -149,6 +159,11 @@ where Ok(result.to::()) } + async fn get_chain_id(&self) -> Result { + let result = self.request::<_, U64>("eth_chainId", ()).await?; + Ok(result.to::()) + } + async fn get_balance(&self, address: &str, block_number: u64) -> Result { let address = address.parse::
()?; let tag = get_block_id(block_number); @@ -195,4 +210,25 @@ where let tag = get_block_id(block); Ok(self.request::<_, Bytes>("eth_call", (tx, tag)).await?) } + + async fn is_block_hash_finalized(&self, block_hash: B256) -> Result { + let block: Option = self + .request("eth_getBlockByHash", (block_hash, false)) + .await?; + let block = block.ok_or(EthereumServiceError::BlockNotFound)?; + let block_number = block.number.to::(); + + let finalized: EthBlockNumber = self + .request("eth_getBlockByNumber", ("finalized", false)) + .await?; + let finalized_number = finalized.number.to::(); + + Ok(block_number <= finalized_number) + } +} + +/// Minimal block response for extracting just the block number. +#[derive(Deserialize)] +struct EthBlockNumber { + number: U64, } diff --git a/linera-ethereum/src/common.rs b/linera-ethereum/src/common.rs index 38cfd67d3608..98fcd0c4270f 100644 --- a/linera-ethereum/src/common.rs +++ b/linera-ethereum/src/common.rs @@ -55,6 +55,10 @@ pub enum EthereumServiceError { #[error(transparent)] FromHexError(#[from] alloy_primitives::hex::FromHexError), + /// Block not found on chain + #[error("Block not found on chain")] + BlockNotFound, + /// `serde_json` error #[error(transparent)] JsonError(#[from] serde_json::Error), diff --git a/linera-ethereum/tests/ethereum_test.rs b/linera-ethereum/tests/ethereum_test.rs index a3c1e3d16319..81cec250c407 100644 --- a/linera-ethereum/tests/ethereum_test.rs +++ b/linera-ethereum/tests/ethereum_test.rs @@ -13,6 +13,16 @@ use { std::{collections::BTreeSet, str::FromStr}, }; +#[cfg(feature = "ethereum")] +#[tokio::test] +async fn test_get_chain_id() -> anyhow::Result<()> { + let anvil_test = get_anvil().await?; + let ethereum_client_simp = EthereumClientSimplified::new(anvil_test.endpoint); + let chain_id = ethereum_client_simp.get_chain_id().await?; + assert_eq!(chain_id, 31337, "Anvil default chain ID should be 31337"); + Ok(()) +} + #[cfg(feature = "ethereum")] #[tokio::test] async fn test_get_accounts_balance() -> anyhow::Result<()> { diff --git a/linera-sdk/src/test/chain.rs b/linera-sdk/src/test/chain.rs index 7839e3dada67..80887bfbdc28 100644 --- a/linera-sdk/src/test/chain.rs +++ b/linera-sdk/src/test/chain.rs @@ -573,6 +573,49 @@ impl ActiveChain { ApplicationId::<()>::from(&description).with_abi() } + /// Fallible version of [`create_application`](Self::create_application). + /// + /// Returns the [`ApplicationId`] on success, or a [`WorkerError`] if instantiation fails. + pub async fn try_create_application( + &mut self, + module_id: ModuleId, + parameters: Parameters, + instantiation_argument: InstantiationArgument, + required_application_ids: Vec, + ) -> Result, WorkerError> + where + Abi: ContractAbi, + Parameters: Serialize, + InstantiationArgument: Serialize, + { + let parameters = serde_json::to_vec(¶meters).unwrap(); + let instantiation_argument = serde_json::to_vec(&instantiation_argument).unwrap(); + + let (creation_certificate, _) = self + .try_add_block(|block| { + block.with_system_operation(SystemOperation::CreateApplication { + module_id: module_id.forget_abi(), + parameters: parameters.clone(), + instantiation_argument, + required_application_ids: required_application_ids.clone(), + }); + }) + .await?; + + let block = creation_certificate.inner().block(); + + let description = ApplicationDescription { + module_id: module_id.forget_abi(), + creator_chain_id: block.header.chain_id, + block_height: block.header.height, + application_index: 0, + parameters, + required_application_ids, + }; + + Ok(ApplicationId::<()>::from(&description).with_abi()) + } + /// Returns whether this chain has been closed. pub async fn is_closed(&self) -> bool { let chain = Box::pin(self.validator.worker().chain_state_view(self.id())) From 2017860eb85806880095ffeba1d9681de1323422 Mon Sep 17 00:00:00 2001 From: deuszx <95355183+deuszx@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:25:29 +0000 Subject: [PATCH 06/13] Improve linera bridge relayer API (#5754) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge relay (`linera-bridge serve`) used ephemeral in-memory storage and generated fresh keypairs on every startup. Every restart required re-claiming a chain from the faucet, invalidating all deployed EVM contracts that reference the old chain ID. On testnets this forced full redeployment of everything. Additionally, the relay's CLI was inconsistent with the `linera` binary — it used custom `--data-dir` and file-polling flags instead of the standard `--wallet`, `--keystore`, `--storage` pattern. `GenesisConfig` (from `linera-client`) and `PersistentWallet` (from `linera-service`) are moved to `linera-core` to break the cyclic dependency that prevented `linera-bridge` from using these types. Both are re-exported from their original locations for backward compatibility. A new `fs` feature on `linera-core` gates the `linera-persistent` dependency. The relay now accepts the same `--wallet`, `--keystore`, `--storage` flags and `LINERA_WALLET`/`LINERA_KEYSTORE`/`LINERA_STORAGE` env vars as the `linera` binary, defaulting to `~/.config/linera/`. It uses `PersistentWallet` for chain metadata and RocksDB for block storage. `--linera-bridge-chain-id` selects a pre-existing chain (syncs from validators, verifies the keystore contains an owner key). Without it, a new chain is claimed from the faucet. File polling is removed — `--evm-bridge-address`, `--linera-bridge-address`, and `--linera-fungible-address` are required directly. A new `bridge-chain-init` compose service claims the bridge chain before the relay starts. The `--http-request-allow-list` flag is added to `linera net up` (configurable via `HTTP_REQUEST_ALLOW_LIST` env var) for EVM finality verification. `setup.sh` accepts `--linera-wallet`, `--linera-keystore`, `--linera-storage` to use an existing wallet (or respects env vars). It no longer claims its own chain and prints all deployed addresses with a ready-to-copy relay command at the end. 1. `linera wallet init --faucet ` 2. `linera wallet request-chain --faucet ` 3. `setup.sh --linera-bridge-chain-id --relay-owner ...` 4. `linera-bridge serve --linera-bridge-chain-id ...` - `cargo test -p linera-bridge` — all 72 unit tests pass - `cargo clippy -p linera-bridge --features relay` — clean - All 3 Docker e2e tests pass (`committee_rotation`, `evm_to_linera_bridge`, `fungible_bridge`) - Manual testnet deployment verified (Base Sepolia + Conway testnet) - Nothing to do / These changes follow the usual release cycle. - Resolves #5704 - [reviewer checklist](https://github.com/linera-io/linera-protocol/blob/main/CONTRIBUTING.md#reviewer-checklist) --- .github/workflows/bridge-e2e.yml | 1 - CLI.md | 1 + Cargo.lock | 2 + docker/docker-compose.bridge-test.yml | 94 +++++++- examples/bridge-demo/README.md | 123 ++++++---- examples/bridge-demo/setup.sh | 130 +++++++---- linera-bridge/Cargo.toml | 8 +- linera-bridge/src/main.rs | 53 +++-- linera-bridge/src/relay.rs | 305 +++++++++++++++---------- linera-bridge/tests/e2e/Cargo.lock | 1 + linera-service/src/cli/command.rs | 4 + linera-service/src/cli/main.rs | 2 + linera-service/src/cli/net_up_utils.rs | 3 +- linera-service/src/wallet.rs | 9 - linera-storage/Cargo.toml | 1 + scripts/check_chain_loads.sh | 8 +- 16 files changed, 499 insertions(+), 246 deletions(-) diff --git a/.github/workflows/bridge-e2e.yml b/.github/workflows/bridge-e2e.yml index 90b2eec35425..5f8c2cc6a0da 100644 --- a/.github/workflows/bridge-e2e.yml +++ b/.github/workflows/bridge-e2e.yml @@ -3,7 +3,6 @@ name: Bridge E2E on: merge_group: workflow_dispatch: - pull_request: push: branches: [ 'testnet_*', 'main', 'bridge-main'] diff --git a/CLI.md b/CLI.md index 5f0ca4d206d1..e62a6b792d33 100644 --- a/CLI.md +++ b/CLI.md @@ -1281,6 +1281,7 @@ Start a Local Linera Network * `--exporter-port ` — The port on which to run the block exporter Default value: `8081` +* `--http-request-allow-list ` — Set the list of hosts that contracts and services can send HTTP requests to diff --git a/Cargo.lock b/Cargo.lock index bf468947f595..d2e5cae4c808 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5465,6 +5465,7 @@ dependencies = [ "axum", "bcs", "clap", + "dirs", "fs-err", "futures", "hex", @@ -5475,6 +5476,7 @@ dependencies = [ "linera-core", "linera-execution", "linera-faucet-client", + "linera-persistent", "linera-storage", "linera-views", "op-alloy-network", diff --git a/docker/docker-compose.bridge-test.yml b/docker/docker-compose.bridge-test.yml index 376665ea663f..0e14e91ccaa0 100644 --- a/docker/docker-compose.bridge-test.yml +++ b/docker/docker-compose.bridge-test.yml @@ -80,7 +80,8 @@ services: --faucet-port ${FAUCET_PORT:-8080} --with-block-exporter --exporter-address linera-block-exporter - --exporter-port ${BLOCK_EXPORTER_PORT:-8882}" + --exporter-port ${BLOCK_EXPORTER_PORT:-8882} + --http-request-allow-list ${HTTP_REQUEST_ALLOW_LIST:-anvil}" environment: - RUST_LOG=linera=info - FAUCET_PORT=${FAUCET_PORT:-8080} @@ -267,28 +268,97 @@ services: networks: - linera-network - # 6. Relay server - claims bridge chain, processes inbox, forwards blocks to EVM - # Bridge address is resolved from /shared/bridge-address (written by setup script). + # 6a. Bridge chain init - claims a chain for the relay before the relay starts. + # Writes bridge-chain-id and relay-owner to /shared/ so the setup script + # and the relay can use them. + bridge-chain-init: + image: "${LINERA_NETWORK_IMAGE:-linera-test}" + network_mode: "service:linera-network" + command: + - sh + - -c + - | + set -e + FAUCET=http://localhost:${FAUCET_PORT:-8080} + RELAY_WALLET=/shared/relay-wallet + + echo "Initializing relay wallet..." + mkdir -p $$RELAY_WALLET + LINERA_WALLET=$$RELAY_WALLET/wallet.json \ + LINERA_KEYSTORE=$$RELAY_WALLET/keystore.json \ + LINERA_STORAGE=rocksdb:$$RELAY_WALLET/client.db \ + ./linera wallet init --faucet "$$FAUCET" + + echo "Claiming bridge chain..." + OUTPUT=$$(LINERA_WALLET=$$RELAY_WALLET/wallet.json \ + LINERA_KEYSTORE=$$RELAY_WALLET/keystore.json \ + LINERA_STORAGE=rocksdb:$$RELAY_WALLET/client.db \ + ./linera wallet request-chain --faucet "$$FAUCET" 2>&1) + echo "$$OUTPUT" + + # Extract chain ID (64-char hex on its own line) + CHAIN_ID=$$(echo "$$OUTPUT" | grep -oE '^[a-f0-9]{64}$$' | tail -1) + # Extract owner (the AccountOwner printed by request-chain) + OWNER=$$(echo "$$OUTPUT" | grep -oE '0x[a-f0-9]{64}' | tail -1) + + echo "Bridge chain: $$CHAIN_ID" + echo "Relay owner: $$OWNER" + echo "$$CHAIN_ID" > /shared/bridge-chain-id + echo "$$OWNER" > /shared/relay-owner + environment: + - FAUCET_PORT=${FAUCET_PORT:-8080} + volumes: + - bridge-shared:/shared + depends_on: + linera-network: + condition: service_healthy + + # 6b. Relay server - processes inbox, forwards blocks to EVM. + # Uses the chain claimed by bridge-chain-init. + # Bridge address is resolved from /shared/bridge-address (written by setup script). # Uses network_mode: service:linera-network so that localhost:13001 reaches the # validator (whose address is hardcoded as "localhost" in the genesis config). linera-relay: image: "${LINERA_BRIDGE_IMAGE:-linera-bridge}" network_mode: "service:linera-network" command: - - linera-bridge - - serve - - --rpc-url=http://anvil:8545 - - --faucet-url=http://localhost:${FAUCET_PORT:-8080} - - --bridge-address-file=/shared/bridge-address - - --evm-private-key=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 - - --port=${RELAY_PORT:-3001} + - sh + - -c + - | + echo "Waiting for bridge chain ID..." + while [ ! -f /shared/bridge-chain-id ]; do sleep 1; done + CHAIN_ID=$$(cat /shared/bridge-chain-id | tr -d '[:space:]') + + echo "Waiting for bridge address and app IDs..." + while [ ! -f /shared/bridge-address ]; do sleep 1; done + while [ ! -f /shared/bridge-app-id ]; do sleep 1; done + while [ ! -f /shared/wrapped-app-id ]; do sleep 1; done + BRIDGE_ADDR=$$(cat /shared/bridge-address | tr -d '[:space:]') + BRIDGE_APP=$$(cat /shared/bridge-app-id | tr -d '[:space:]') + FUNGIBLE_APP=$$(cat /shared/wrapped-app-id | tr -d '[:space:]') + + echo "Starting relay: chain=$$CHAIN_ID bridge=$$BRIDGE_ADDR" + exec linera-bridge serve \ + --rpc-url=http://anvil:8545 \ + --faucet-url=http://localhost:${FAUCET_PORT:-8080} \ + --wallet=/shared/relay-wallet/wallet.json \ + --keystore=/shared/relay-wallet/keystore.json \ + --storage=rocksdb:/shared/relay-wallet/client.db \ + --linera-bridge-chain-id="$$CHAIN_ID" \ + --evm-bridge-address="$$BRIDGE_ADDR" \ + --linera-bridge-address="$$BRIDGE_APP" \ + --linera-fungible-address="$$FUNGIBLE_APP" \ + --evm-private-key=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ + --port=${RELAY_PORT:-3001} environment: - RUST_LOG=linera=info,linera_bridge=debug + - FAUCET_PORT=${FAUCET_PORT:-8080} + - RELAY_PORT=${RELAY_PORT:-3001} volumes: - bridge-shared:/shared depends_on: - linera-network: - condition: service_healthy + bridge-chain-init: + condition: service_completed_successfully anvil: condition: service_healthy diff --git a/examples/bridge-demo/README.md b/examples/bridge-demo/README.md index df2e3ce73834..04c696af7888 100644 --- a/examples/bridge-demo/README.md +++ b/examples/bridge-demo/README.md @@ -11,7 +11,7 @@ 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 | Claims a Linera chain, forwards blocks to EVM for withdrawals | +| **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 | ## Quick start (Docker) @@ -130,70 +130,83 @@ make build-wasm This compiles `fungible`, `wrapped-fungible`, and `evm-bridge` to `examples/target/wasm32-unknown-unknown/release/`. -### 2. Start the relay +### 2. Initialize wallet and claim a bridge chain -The relay claims a Linera chain and bridges events between EVM and Linera. It -needs to start first because `setup.sh` reads the bridge chain ID and relay -owner from files the relay writes. +```bash +export FAUCET_URL=https://faucet.testnet-conway.linera.net -Pick a shared directory for coordination files. The setup script generates a -timestamped directory by default (e.g. `/tmp/bridge-demo-20260313-112421`), so -you must use the same `SHARED_DIR` in both terminals: +# Initialize a Linera wallet from the faucet +linera wallet init --faucet "$FAUCET_URL" -```bash -export SHARED_DIR="/tmp/bridge-demo-$(date +%Y%m%d-%H%M%S)" -mkdir -p "$SHARED_DIR" +# Claim a chain that the relay will use as the "bridge chain" +linera wallet request-chain --faucet "$FAUCET_URL" ``` -> **Important:** Export the same `SHARED_DIR` value in both the relay terminal -> and the setup terminal. The relay polls this directory for app IDs written by -> `setup.sh`, and `setup.sh` reads the bridge chain ID written by the relay. +Note the **chain ID** and **owner** printed by `request-chain` — you'll need +them for both the setup script and the relay. + +### 3. Deploy contracts -Start the relay (it will poll the shared dir for contract/app addresses that -`setup.sh` writes later): +Pick a shared directory for coordination files between the setup script and +the relay: ```bash -linera-bridge serve \ - --rpc-url https://base-sepolia-rpc.publicnode.com \ - --faucet-url https://faucet.testnet-conway.linera.net \ - --evm-private-key 0x... \ - --bridge-address-file "$SHARED_DIR/bridge-address" \ - --bridge-app-id-file "$SHARED_DIR/bridge-app-id" \ - --fungible-app-id-file "$SHARED_DIR/wrapped-app-id" +export SHARED_DIR="/tmp/bridge-demo-$(date +%Y%m%d-%H%M%S)" +mkdir -p "$SHARED_DIR" ``` -When the relay starts it prints its **bridge chain ID** and **AccountOwner**. -Copy both for the next step. - -### 3. Run the setup script - -In a second terminal: +Run the setup script to deploy EVM contracts and Linera apps: ```bash cd examples/bridge-demo ./setup.sh \ - --evm-rpc-url https://base-sepolia-rpc.publicnode.com \ - --evm-private-key 0xfce057d5e1a3f8265745c95b0a3847e03831f861bec0f7b47a8cd4800ac92aa1 \ + --evm-rpc-url https://base-sepolia.g.alchemy.com/v2/YOUR_KEY \ + --evm-private-key 0x... \ --evm-chain-id 84532 \ - --bridge-chain-id <64-hex-chain-id-from-relay> \ - --relay-owner \ - --faucet-url https://faucet.testnet-conway.linera.net \ - --relay-url http://localhost:3001 \ + --linera-bridge-chain-id \ + --relay-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: -1. Initializes a Linera wallet from the faucet -2. Fetches validator info and deploys the **LightClient** contract -3. Deploys a **MockERC20** token (or pass `--token-address` to use an existing one) -4. Publishes **wrapped-fungible** and **evm-bridge** apps on the bridge chain -5. Deploys the **FungibleBridge** contract (referencing LightClient + apps) -6. Funds the bridge with ERC-20 tokens -7. Writes contract/app addresses to `$SHARED_DIR` (relay picks them up) and +1. Fetches validator info and deploys the **LightClient** contract +2. Deploys a **MockERC20** token (or pass `--token-address` to use an existing one) +3. Publishes **wrapped-fungible** and **evm-bridge** apps on the bridge chain +4. Deploys the **FungibleBridge** contract (referencing LightClient + apps) +5. Funds the bridge with ERC-20 tokens +6. Writes contract/app addresses to `$SHARED_DIR` (relay picks them up) and `.env.local` (frontend reads them) -### 4. Start the frontend +### 4. Start the relay + +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: + +```bash +linera-bridge serve \ + --rpc-url \ + --faucet-url "$FAUCET_URL" \ + --linera-bridge-chain-id \ + --linera-bridge-address \ + --linera-fungible-address \ + --evm-bridge-address \ + --evm-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. + +### 5. Start the frontend ```bash pnpm install && pnpm dev @@ -211,7 +224,7 @@ the deposit/withdraw forms. | `--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 | -| `--bridge-chain-id ID` | polled from relay | **required** | Linera bridge chain (64 hex chars) | +| `--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 | @@ -222,6 +235,9 @@ the deposit/withdraw forms. | `--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`) | ## How a deposit works @@ -260,6 +276,25 @@ LINERA_EVM_CHAIN_ID All are required -- the Vite dev server will refuse to start if any are missing. +## EVM finality verification + +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. + +- **Docker mode**: The compose file passes `--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`). +- **Testnet mode**: The Linera testnet validators must whitelist the RPC + hostname (e.g. `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). + ## Troubleshooting - **MetaMask shows 0 ETH** (local): make sure you switched to the Anvil diff --git a/examples/bridge-demo/setup.sh b/examples/bridge-demo/setup.sh index c37f60f1be63..aa820317458d 100755 --- a/examples/bridge-demo/setup.sh +++ b/examples/bridge-demo/setup.sh @@ -4,7 +4,7 @@ # Two modes: # Docker mode: ./setup.sh --compose-file ../../docker/docker-compose.bridge-test.yml # Direct mode: ./setup.sh --evm-rpc-url URL --evm-private-key KEY \ -# --light-client-address ADDR --bridge-chain-id ID +# --light-client-address ADDR --linera-bridge-chain-id ID # # Docker mode runs forge/cast/linera inside Docker containers (local dev). # Direct mode calls them directly on the host (real network deployments). @@ -38,6 +38,9 @@ EXTRA_WALLET_ID=1 REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" LINERA_BIN="" LINERA_BRIDGE_BIN="" +LINERA_WALLET_PATH="" +LINERA_KEYSTORE_PATH="" +LINERA_STORAGE_PATH="" die() { echo "ERROR: $*" >&2; exit 1; } @@ -67,7 +70,7 @@ Docker mode (local dev): Direct mode (real networks): $(basename "$0") --evm-rpc-url URL --evm-private-key KEY \\ - --bridge-chain-id ID --relay-owner OWNER --faucet-url URL + --linera-bridge-chain-id ID --relay-owner OWNER --faucet-url URL Options: --compose-file PATH Docker Compose file (enables Docker mode) @@ -80,7 +83,7 @@ Options: --light-client-address ADDR LightClient contract address (skip deploy) Docker mode reads from /shared/ Direct mode deploys if not provided - --bridge-chain-id ID Linera bridge chain ID (64 hex chars) + --linera-bridge-chain-id ID Linera bridge chain ID (64 hex chars) Docker mode polls /shared/ --token-address ADDR ERC20 token address (skip MockERC20 deploy) --wasm-dir PATH Directory with .wasm binaries @@ -98,8 +101,10 @@ Options: --fund-amount WEI Fund bridge with this many tokens; 0 to skip (default: 500000000000000000000) --shared-dir PATH Directory for shared state files (bridge-address, - app IDs). Relay polls these files. - Default: /tmp/bridge-demo- + app IDs). Default: /tmp/bridge-demo- + --linera-wallet PATH Path to existing Linera wallet.json + --linera-keystore PATH Path to existing Linera keystore.json + --linera-storage CONFIG Linera storage config (e.g. rocksdb:/path/to/client.db) --help Show this help EOF exit 0 @@ -113,7 +118,7 @@ while [[ $# -gt 0 ]]; do --evm-private-key) EVM_PRIVATE_KEY="$2"; shift 2 ;; --evm-chain-id) EVM_CHAIN_ID="$2"; shift 2 ;; --light-client-address) LIGHT_CLIENT_ADDR="$2"; shift 2 ;; - --bridge-chain-id) BRIDGE_CHAIN_ID="$2"; shift 2 ;; + --linera-bridge-chain-id) BRIDGE_CHAIN_ID="$2"; shift 2 ;; --token-address) TOKEN_ADDRESS="$2"; shift 2 ;; --wasm-dir) WASM_DIR="$2"; shift 2 ;; --contracts-dir) CONTRACTS_DIR="$2"; shift 2 ;; @@ -124,6 +129,9 @@ while [[ $# -gt 0 ]]; do --ticker-symbol) TICKER_SYMBOL="$2"; shift 2 ;; --fund-amount) FUND_AMOUNT="$2"; shift 2 ;; --shared-dir) SHARED_DIR="$2"; shift 2 ;; + --linera-wallet) LINERA_WALLET_PATH="$2"; shift 2 ;; + --linera-keystore) LINERA_KEYSTORE_PATH="$2"; shift 2 ;; + --linera-storage) LINERA_STORAGE_PATH="$2"; shift 2 ;; --help) usage ;; *) die "Unknown option: $1" ;; esac @@ -158,9 +166,9 @@ linera_exec() { LINERA_STORAGE="rocksdb:$WALLET_DIR/client_${EXTRA_WALLET_ID}.db" \ ./linera "$@" else - LINERA_WALLET="$LINERA_TMP_DIR/wallet.json" \ - LINERA_KEYSTORE="$LINERA_TMP_DIR/keystore.json" \ - LINERA_STORAGE="rocksdb:$LINERA_TMP_DIR/client.db" \ + LINERA_WALLET="$LINERA_WALLET_PATH" \ + LINERA_KEYSTORE="$LINERA_KEYSTORE_PATH" \ + LINERA_STORAGE="$LINERA_STORAGE_PATH" \ "$LINERA_BIN" "$@" fi } @@ -184,7 +192,7 @@ wait_for_tx() { fi echo " Waiting for tx $tx_hash..." evm_exec cast receipt --confirmations 1 \ - --rpc-url "$EVM_RPC_URL" "$tx_hash" >/dev/null 2>&1 || true + --rpc-url "$EVM_RPC_URL" "$tx_hash" >/dev/null } # ── Mode detection & defaults ── @@ -202,7 +210,7 @@ if [[ -n "$COMPOSE_FILE" ]]; then else echo "Mode: Direct" [[ -z "$EVM_PRIVATE_KEY" ]] && die "--evm-private-key is required in direct mode" - [[ -z "$BRIDGE_CHAIN_ID" ]] && die "--bridge-chain-id is required in direct mode" + [[ -z "$BRIDGE_CHAIN_ID" ]] && die "--linera-bridge-chain-id is required in direct mode" EVM_RPC_URL="${EVM_RPC_URL:-http://localhost:8545}" WASM_DIR="${WASM_DIR:-$SCRIPT_DIR/../../examples/target/wasm32-unknown-unknown/release}" CONTRACTS_DIR="${CONTRACTS_DIR:-$SCRIPT_DIR/../../linera-bridge/src/solidity}" @@ -245,20 +253,48 @@ echo " Shared dir: $SHARED_DIR" echo "=== Bridge Demo Setup ===" -# ── 0. Initialize Linera wallet (direct mode only) ── +# ── 0. Resolve Linera wallet paths (direct mode only) ── +# Resolve env var fallbacks early so we can print them. if [[ -z "$COMPOSE_FILE" ]]; then - LINERA_TMP_DIR="$SHARED_DIR/linera-wallet" - mkdir -p "$LINERA_TMP_DIR" - if [[ ! -f "$LINERA_TMP_DIR/wallet.json" ]]; then - echo "Initializing Linera wallet from faucet..." - linera_exec wallet init --faucet "$FAUCET_URL" - echo "Requesting a chain from faucet..." - linera_exec wallet request-chain --faucet "$FAUCET_URL" + LINERA_WALLET_PATH="${LINERA_WALLET_PATH:-${LINERA_WALLET:-}}" + LINERA_KEYSTORE_PATH="${LINERA_KEYSTORE_PATH:-${LINERA_KEYSTORE:-}}" + LINERA_STORAGE_PATH="${LINERA_STORAGE_PATH:-${LINERA_STORAGE:-}}" + if [[ -n "$LINERA_WALLET_PATH" ]]; then + # Use caller-provided wallet paths. + [[ -z "$LINERA_KEYSTORE_PATH" ]] && die "--linera-keystore is required when --linera-wallet is set" + [[ -z "$LINERA_STORAGE_PATH" ]] && die "--linera-storage is required when --linera-wallet is set" + echo " Using wallet at $LINERA_WALLET_PATH" else - echo " Using existing wallet at $LINERA_TMP_DIR" + # Create a temporary wallet. + LINERA_TMP_DIR="$SHARED_DIR/linera-wallet" + mkdir -p "$LINERA_TMP_DIR" + LINERA_WALLET_PATH="$LINERA_TMP_DIR/wallet.json" + LINERA_KEYSTORE_PATH="$LINERA_TMP_DIR/keystore.json" + LINERA_STORAGE_PATH="rocksdb:$LINERA_TMP_DIR/client.db" + if [[ ! -f "$LINERA_WALLET_PATH" ]]; then + echo "Initializing Linera wallet from faucet..." + linera_exec wallet init --faucet "$FAUCET_URL" + else + echo " Using existing wallet at $LINERA_TMP_DIR" + fi fi fi +echo "" +echo "Configuration:" +echo " EVM RPC URL: $EVM_RPC_URL" +echo " EVM chain ID: $EVM_CHAIN_ID" +echo " Bridge chain ID: ${BRIDGE_CHAIN_ID:-(will be read from /shared/)}" +echo " Relay owner: ${RELAY_OWNER:-(will be read from /shared/)}" +echo " Faucet URL: $FAUCET_URL" +echo " Shared dir: $SHARED_DIR" +if [[ -z "$COMPOSE_FILE" ]]; then +echo " Linera wallet: $LINERA_WALLET_PATH" +echo " Linera keystore: $LINERA_KEYSTORE_PATH" +echo " Linera storage: $LINERA_STORAGE_PATH" +fi +echo "" + # ── 1. Deploy or read LightClient ── if [[ -n "$COMPOSE_FILE" && -z "$LIGHT_CLIENT_ADDR" ]]; then echo "Reading LightClient address from /shared/..." @@ -301,9 +337,9 @@ echo " LightClient: $LIGHT_CLIENT_ADDR" # ── 2. Read bridge chain ID (Docker mode only) ── if [[ -n "$COMPOSE_FILE" && -z "$BRIDGE_CHAIN_ID" ]]; then - echo "Waiting for relay to claim bridge chain..." + echo "Waiting for bridge chain ID..." for i in $(seq 1 30); do - BRIDGE_CHAIN_ID=$(dc_exec linera-relay cat /shared/bridge-chain-id 2>/dev/null | tr -d '[:space:]' || true) + BRIDGE_CHAIN_ID=$(dc_exec foundry-tools cat /shared/bridge-chain-id 2>/dev/null | tr -d '[:space:]') BRIDGE_CHAIN_ID=$(normalize_hex "$BRIDGE_CHAIN_ID") if echo "$BRIDGE_CHAIN_ID" | grep -qE '^[a-f0-9]{64}$'; then break @@ -313,7 +349,7 @@ if [[ -n "$COMPOSE_FILE" && -z "$BRIDGE_CHAIN_ID" ]]; then sleep 2 done if [[ -z "$BRIDGE_CHAIN_ID" ]]; then - die "Relay did not write bridge chain ID within timeout" + die "Bridge chain ID not found within timeout" fi else BRIDGE_CHAIN_ID=$(normalize_hex "$BRIDGE_CHAIN_ID") @@ -355,14 +391,14 @@ TOKEN_ADDR_HEX=$(echo "$TOKEN_ADDRESS" | sed 's/^0x//') echo "Reading relay owner..." if [[ -n "$COMPOSE_FILE" ]]; then for i in $(seq 1 30); do - RELAY_OWNER=$(dc_exec linera-relay cat /shared/relay-owner 2>/dev/null | tr -d '[:space:]' || true) + RELAY_OWNER=$(dc_exec foundry-tools cat /shared/relay-owner 2>/dev/null | tr -d '[:space:]') if [[ -n "$RELAY_OWNER" ]]; then break fi echo " Waiting for relay owner... ($i/30)" sleep 2 done - [[ -z "$RELAY_OWNER" ]] && die "Relay did not write owner within timeout" + [[ -z "$RELAY_OWNER" ]] && die "Relay owner not found within timeout" else [[ -z "$RELAY_OWNER" ]] && die "--relay-owner is required in direct mode" fi @@ -370,8 +406,8 @@ echo " Relay owner (minter): $RELAY_OWNER" # ── 4. Publish and create wrapped-fungible app ── echo "Syncing chain state..." -linera_exec sync 2>&1 || true -linera_exec process-inbox 2>&1 || true +linera_exec sync 2>&1 +linera_exec process-inbox 2>&1 echo "Publishing and creating wrapped-fungible app..." WRAPPED_PARAMS=$( TICKER="$TICKER_SYMBOL" \ @@ -446,8 +482,8 @@ echo "$BRIDGE_ADDRESS" > "$SHARED_DIR/bridge-address" # ── 6. Publish and create evm-bridge app ── echo "Syncing chain state before evm-bridge deploy..." -linera_exec sync 2>&1 || true -linera_exec process-inbox 2>&1 || true +linera_exec sync 2>&1 +linera_exec process-inbox 2>&1 echo "Publishing and creating evm-bridge app..." BRIDGE_PARAMS=$( CHAIN_ID="$EVM_CHAIN_ID" \ @@ -479,8 +515,8 @@ BRIDGE_APP_OUTPUT=$(linera_exec publish-and-create \ exit 1 } BRIDGE_APP_ID=$(echo "$BRIDGE_APP_OUTPUT" | grep -oE '^[a-f0-9]{64}$' | tail -1) -validate_hex64 "EVM-bridge app ID" "$BRIDGE_APP_ID" -echo " EVM-bridge app: $BRIDGE_APP_ID" +validate_hex64 "Linera bridge app ID" "$BRIDGE_APP_ID" +echo " Linera bridge app: $BRIDGE_APP_ID" # Write bridge app ID to shared dir for relay. if [[ -n "$COMPOSE_FILE" ]]; then @@ -518,13 +554,29 @@ EOF echo "" echo "=== Setup Complete ===" echo "" -echo "Environment written to: $OUTPUT_FILE" -echo "Shared state dir: $SHARED_DIR" +echo "Addresses & IDs:" +echo " LightClient (EVM): $LIGHT_CLIENT_ADDR" +echo " FungibleBridge (EVM): $BRIDGE_ADDRESS" +echo " MockERC20 (EVM): $TOKEN_ADDRESS" +echo " evm-bridge (Linera): $BRIDGE_APP_ID" +echo " wrapped-fungible (Linera): $WRAPPED_APP_ID" +echo " Bridge chain ID: $BRIDGE_CHAIN_ID" +echo " Relay owner: $RELAY_OWNER" +echo " EVM chain ID: $EVM_CHAIN_ID" echo "" -echo "The relay should already be running (it provides --bridge-chain-id and" -echo "--relay-owner that this script requires). It will pick up the app IDs" -echo "from the shared dir automatically." +echo "Environment written to: $OUTPUT_FILE" +echo "Shared state dir: $SHARED_DIR" echo "" -echo "Start the frontend:" -echo " cd examples/bridge-demo" -echo " pnpm install && pnpm dev" +echo "Next steps:" +echo " 1. Start the relay:" +echo " linera-bridge serve \\" +echo " --rpc-url $EVM_RPC_URL \\" +echo " --faucet-url $FAUCET_URL \\" +echo " --linera-bridge-chain-id $BRIDGE_CHAIN_ID \\" +echo " --evm-bridge-address $BRIDGE_ADDRESS \\" +echo " --linera-bridge-address $BRIDGE_APP_ID \\" +echo " --linera-fungible-address $WRAPPED_APP_ID \\" +echo " --evm-private-key 0x..." +echo " 2. Start the frontend:" +echo " cd examples/bridge-demo" +echo " pnpm install && pnpm dev" diff --git a/linera-bridge/Cargo.toml b/linera-bridge/Cargo.toml index 84ae688d2609..09b261679961 100644 --- a/linera-bridge/Cargo.toml +++ b/linera-bridge/Cargo.toml @@ -44,14 +44,18 @@ relay = [ "offchain", "cli", "dep:axum", + "dep:dirs", "dep:futures", "dep:hex", "dep:linera-chain", "dep:linera-client", "dep:linera-core", "dep:linera-faucet-client", + "dep:linera-persistent", "dep:linera-storage", "dep:linera-views", + "linera-storage/rocksdb", + "linera-views/rocksdb", "dep:rustls", "dep:tower-http", "dep:tracing", @@ -96,12 +100,14 @@ serde_json = { workspace = true, optional = true } # relay feature deps axum = { workspace = true, optional = true } +dirs = { workspace = true, optional = true } futures = { workspace = true, optional = true } hex = { workspace = true, optional = true } linera-chain = { workspace = true, optional = true } linera-client = { workspace = true, optional = true } -linera-core = { workspace = true, optional = true } +linera-core = { workspace = true, optional = true, features = ["fs"] } linera-faucet-client = { workspace = true, optional = true } +linera-persistent = { workspace = true, optional = true, features = ["fs"] } linera-storage = { workspace = true, optional = true } linera-views = { workspace = true, optional = true } rustls = { version = "0.23", optional = true, features = ["ring"] } diff --git a/linera-bridge/src/main.rs b/linera-bridge/src/main.rs index fd0cb4f6f50b..5fb86547099e 100644 --- a/linera-bridge/src/main.rs +++ b/linera-bridge/src/main.rs @@ -58,22 +58,33 @@ struct ServeOptions { #[arg(long)] faucet_url: String, - /// Address of the FungibleBridge contract on EVM. - /// If omitted, reads from --bridge-address-file (polls until available). + /// Path to the wallet state file. + #[arg(long = "wallet", env = "LINERA_WALLET")] + wallet: Option, + + /// Path to the keystore file. + #[arg(long = "keystore", env = "LINERA_KEYSTORE")] + keystore: Option, + + /// Storage configuration for blockchain history (e.g. rocksdb:/path/to/db). + #[arg(long = "storage", env = "LINERA_STORAGE")] + storage: Option, + + /// Linera bridge chain ID. If omitted, claims a new chain from faucet. #[arg(long)] - bridge_address: Option, + linera_bridge_chain_id: Option, - /// File to read bridge address from (used when bridge is deployed after relay starts) - #[arg(long, default_value = "/shared/bridge-address")] - bridge_address_file: String, + /// Address of the FungibleBridge contract on EVM. + #[arg(long)] + evm_bridge_address: String, - /// File to read the evm-bridge ApplicationId from (written by setup script) - #[arg(long, default_value = "/shared/bridge-app-id")] - bridge_app_id_file: String, + /// evm-bridge Linera ApplicationId (hex). + #[arg(long)] + linera_bridge_address: String, - /// File to read the wrapped-fungible ApplicationId from (written by setup script) - #[arg(long, default_value = "/shared/wrapped-app-id")] - fungible_app_id_file: String, + /// wrapped-fungible Linera ApplicationId (hex). + #[arg(long)] + linera_fungible_address: String, /// EVM private key for signing addBlock transactions #[arg(long)] @@ -100,16 +111,20 @@ fn main() -> Result<()> { #[cfg(feature = "relay")] impl ServeOptions { async fn run(&self) -> Result<()> { - linera_bridge::relay::run( + Box::pin(linera_bridge::relay::run( &self.rpc_url, &self.faucet_url, - self.bridge_address.as_deref(), - &self.bridge_address_file, - &self.bridge_app_id_file, - &self.fungible_app_id_file, + self.wallet.as_deref(), + self.keystore.as_deref(), + self.storage.as_deref(), + self.linera_bridge_chain_id, + &self.evm_bridge_address, + &self.linera_bridge_address, + &self.linera_fungible_address, &self.evm_private_key, self.port, - ) + self.blob_cache_size, + )) .await } } @@ -133,7 +148,7 @@ impl GenerateDepositProofOptions { "block_header_rlp": alloy_primitives::hex::encode_prefixed(&proof.block_header_rlp), "receipt_rlp": alloy_primitives::hex::encode_prefixed(&proof.receipt_rlp), "proof_nodes": proof.proof_nodes.iter() - .map(|n| alloy_primitives::hex::encode_prefixed(n)) + .map(alloy_primitives::hex::encode_prefixed) .collect::>(), "tx_index": proof.tx_index, "log_indices": proof.log_indices, diff --git a/linera-bridge/src/relay.rs b/linera-bridge/src/relay.rs index b8271b52bad0..3a3a23b0e5b1 100644 --- a/linera-bridge/src/relay.rs +++ b/linera-bridge/src/relay.rs @@ -6,12 +6,12 @@ //! Responsibilities: //! - **HTTP**: `POST /deposit` — generates MPT deposit proofs and submits `ProcessDeposit` //! operations on the bridge chain. -//! - **Linera client**: claims a "bridge chain", listens for `NewIncomingBundle` notifications, +//! - **Linera client**: manages a "bridge chain", listens for `NewIncomingBundle` notifications, //! processes the inbox, and burns any Address20 credits so the EVM contract can release tokens. //! - **EVM forwarder**: after processing inbox and burns, BCS-serializes the resulting certificates //! and calls `FungibleBridge.addBlock(bytes)` on the EVM chain. -use std::{sync::Arc, time::Duration}; +use std::{path::Path, sync::Arc}; use alloy::{ network::EthereumWallet, primitives::Address, providers::ProviderBuilder, @@ -23,15 +23,22 @@ use futures::StreamExt as _; use linera_base::{ crypto::InMemorySigner, data_types::Amount, - identifiers::{AccountOwner, ApplicationId}, + identifiers::{AccountOwner, ApplicationId, ChainId}, }; use linera_chain::data_types::Transaction; use linera_client::{chain_listener::ClientContext as _, client_context::ClientContext}; -use linera_core::{environment::wallet::Memory, worker::Reason}; +use linera_core::{client::ChainClient, wallet::PersistentWallet, worker::Reason}; use linera_execution::{Message, Operation, WasmRuntime}; use linera_faucet_client::Faucet; +use linera_persistent::Persist; use linera_storage::DbStorage; -use linera_views::backends::memory::{MemoryDatabase, MemoryStoreConfig}; +use linera_views::{ + backends::{ + lru_caching::LruCachingConfig, + rocks_db::{PathWithGuard, RocksDbDatabase, RocksDbSpawnMode, RocksDbStoreInternalConfig}, + }, + lru_prefix_cache::StorageCacheConfig, +}; use tokio::sync::{mpsc, oneshot}; use tower_http::cors::CorsLayer; @@ -182,66 +189,6 @@ async fn deposit_handler( // ── Helpers ── -/// Resolve bridge address: use CLI arg if provided, otherwise poll a file. -async fn resolve_bridge_address( - bridge_address: Option<&str>, - bridge_address_file: &str, -) -> Result
{ - if let Some(addr) = bridge_address { - return addr.parse().context("invalid bridge address"); - } - - tracing::info!( - file = bridge_address_file, - "Bridge address not provided, polling file..." - ); - loop { - if let Ok(contents) = tokio::fs::read_to_string(bridge_address_file).await { - let addr_str = contents.trim(); - if !addr_str.is_empty() { - let addr: Address = addr_str.parse().context("invalid bridge address in file")?; - tracing::info!(%addr, "Read bridge address from file"); - return Ok(addr); - } - } - tokio::time::sleep(Duration::from_secs(2)).await; - } -} - -/// Poll a file for the evm-bridge ApplicationId (hex-encoded BCS). -async fn resolve_bridge_app_id(file_path: &str) -> Result { - tracing::info!(file = file_path, "Polling for bridge app ID..."); - loop { - if let Ok(contents) = tokio::fs::read_to_string(file_path).await { - let id_str = contents.trim(); - if !id_str.is_empty() { - let app_id: ApplicationId = - id_str.parse().context("invalid ApplicationId in file")?; - tracing::info!(%app_id, "Read bridge app ID from file"); - return Ok(app_id); - } - } - tokio::time::sleep(Duration::from_secs(2)).await; - } -} - -/// Poll a file for the wrapped-fungible ApplicationId (hex-encoded BCS). -async fn resolve_fungible_app_id(file_path: &str) -> Result { - tracing::info!(file = file_path, "Polling for wrapped-fungible app ID..."); - loop { - if let Ok(contents) = tokio::fs::read_to_string(file_path).await { - let id_str = contents.trim(); - if !id_str.is_empty() { - let app_id: ApplicationId = - id_str.parse().context("invalid ApplicationId in file")?; - tracing::info!(%app_id, "Read wrapped-fungible app ID from file"); - return Ok(app_id); - } - } - tokio::time::sleep(Duration::from_secs(2)).await; - } -} - /// Extract (owner, amount) from a fungible Credit message if the target is Address20. /// /// BCS layout: variant 0 (Credit) + target: AccountOwner + amount: Amount + source: AccountOwner @@ -307,15 +254,51 @@ async fn forward_cert_to_evm( } } +// ── RocksDB storage helper ── + +type RocksDbStorage = DbStorage; + +async fn create_rocksdb_storage(path: &Path, blob_cache_size: usize) -> Result { + let config = LruCachingConfig { + inner_config: RocksDbStoreInternalConfig { + path_with_guard: PathWithGuard::new(path.to_path_buf()), + spawn_mode: RocksDbSpawnMode::get_spawn_mode_from_runtime(), + max_stream_queries: 10, + }, + storage_cache_config: StorageCacheConfig { + max_cache_size: 10_000_000, + max_value_entry_size: 1_000_000, + max_find_keys_entry_size: 10_000_000, + max_find_key_values_entry_size: 10_000_000, + max_cache_entries: 1000, + max_cache_value_size: 10_000_000, + max_cache_find_keys_size: 10_000_000, + max_cache_find_key_values_size: 10_000_000, + }, + }; + let storage = DbStorage::::maybe_create_and_connect( + &config, + "bridge_relay", + Some(WasmRuntime::default()), + blob_cache_size, + ) + .await?; + Ok(storage) +} + // ── Entry point ── +#[allow(clippy::too_many_arguments)] pub async fn run( rpc_url: &str, faucet_url: &str, - bridge_address: Option<&str>, - bridge_address_file: &str, - bridge_app_id_file: &str, - fungible_app_id_file: &str, + wallet_path: Option<&Path>, + keystore_path: Option<&Path>, + storage_config: Option<&str>, + chain_id_arg: Option, + evm_bridge_address: &str, + linera_bridge_address: &str, + linera_fungible_address: &str, evm_private_key: &str, port: u16, ) -> Result<()> { @@ -328,36 +311,63 @@ pub async fn run( tracing::info!("Starting bridge relay server..."); - // ── 1. Set up Linera client ── + // ── Resolve paths (same defaults as linera binary: ~/.config/linera/) ── + let default_dir = dirs::config_dir() + .context("no config directory on this platform")? + .join("linera"); + let wallet_path = wallet_path + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| default_dir.join("wallet.json")); + let keystore_path = keystore_path + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| default_dir.join("keystore.json")); + let storage_path = storage_config + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("rocksdb:{}", default_dir.join("wallet.db").display())); + + tracing::info!( + wallet = %wallet_path.display(), + keystore = %keystore_path.display(), + storage = %storage_path, + "Resolved paths" + ); + + // ── Common init ── tracing::info!("Connecting to Linera faucet at {faucet_url}..."); let faucet = Faucet::new(faucet_url.to_string()); - tracing::info!("Fetching genesis config..."); let genesis_config = faucet.genesis_config().await?; tracing::info!("Genesis config received"); - let config = MemoryStoreConfig { - max_stream_queries: 10, - kill_on_drop: true, - }; - tracing::info!("Creating storage..."); - let mut storage = DbStorage::::maybe_create_and_connect( - &config, - "bridge-relay", - Some(WasmRuntime::default()), - ) - .await?; + let mut signer: InMemorySigner = + linera_persistent::File::::read(&keystore_path) + .context("failed to read keystore")? + .into_value(); + + // Parse storage path: expect "rocksdb:/path/to/db" + let db_path = storage_path + .strip_prefix("rocksdb:") + .context("storage config must start with 'rocksdb:'")?; + let mut storage = create_rocksdb_storage(Path::new(db_path), blob_cache_size).await?; + + // ── Wallet: load existing or create fresh ── + let wallet_exists = wallet_path.exists(); - tracing::info!("Initializing storage from genesis..."); + // Always initialize storage — this is a no-op if already initialized. genesis_config.initialize_storage(&mut storage).await?; - tracing::info!("Storage initialized"); - let admin_chain_id = genesis_config.admin_chain_id(); - let mut signer = InMemorySigner::new(None); + let wallet = if wallet_exists { + tracing::info!("Loading existing wallet from {}", wallet_path.display()); + PersistentWallet::read(&wallet_path).context("failed to read wallet")? + } else { + tracing::info!("Creating new wallet at {}", wallet_path.display()); + PersistentWallet::create(&wallet_path, genesis_config).context("failed to create wallet")? + }; - tracing::info!("Creating client context..."); + let admin_chain_id = wallet.genesis_config().admin_chain_id(); + let genesis_config = wallet.genesis_config().clone(); let mut ctx = ClientContext::new( storage, - Memory::default(), + wallet, signer.clone(), &Default::default(), None, @@ -366,13 +376,12 @@ pub async fn run( 10_000, ) .await?; - tracing::info!("Client context created"); - // ── 1b. Sync admin chain from validators ── + // ── Sync admin chain (always) ── tracing::info!(%admin_chain_id, "Syncing admin chain from validators..."); let committee = faucet.current_committee().await?; tracing::info!( - validators = committee.validators().into_iter().count(), + validators = committee.validators().iter().count(), "Fetched current committee, downloading chain state..." ); let admin_client = ctx.make_chain_client(admin_chain_id).await?; @@ -381,30 +390,83 @@ pub async fn run( .await?; tracing::info!("Admin chain synced"); - // ── 2. Claim bridge chain ── - tracing::info!("Claiming bridge chain from faucet..."); - let owner = AccountOwner::from(signer.generate_new()); - let chain_desc = faucet.claim(&owner).await?; - let chain_id = chain_desc.id(); - tracing::info!(%chain_id, %owner, "Chain claimed, extending wallet..."); - ctx.extend_with_chain(chain_desc, Some(owner)).await?; + // ── Resolve bridge chain ── + let (chain_id, _owner) = if let Some(cid) = chain_id_arg { + // Register in wallet if not already there. + if ctx.wallet().get(cid).is_none() { + let key_owner = signer.keys().first().context("keystore has no keys")?.0; + ctx.update_wallet_for_new_chain( + cid, + Some(key_owner), + linera_base::data_types::Timestamp::default(), + linera_base::data_types::Epoch::ZERO, + ) + .await?; + } + + // Sync from validators. + let chain_client = ctx.make_chain_client(cid).await?; + chain_client.synchronize_from_validators().await?; + + // Verify our keystore contains an owner key for this chain. + let ownership = chain_client.query_chain_ownership().await?; + let our_keys: Vec = signer.keys().into_iter().map(|(o, _)| o).collect(); + let owner = our_keys + .into_iter() + .find(|o| ownership.super_owners.contains(o) || ownership.owners.contains_key(o)) + .context("keystore has no key that is an owner of the specified --chain-id")?; + tracing::info!(%cid, %owner, "Using pre-existing chain"); + (cid, owner) + } else { + // Claim from faucet. + tracing::info!("Claiming bridge chain from faucet..."); + let owner = AccountOwner::from(signer.generate_new()); + let chain_desc = faucet.claim(&owner).await?; + let cid = chain_desc.id(); + tracing::info!(%cid, %owner, "Chain claimed, extending wallet..."); + ctx.extend_with_chain(chain_desc, Some(owner)).await?; + + // Save updated keystore (has new key from generate_new). + let mut ks_file = linera_persistent::File::new(&keystore_path, signer.clone())?; + ks_file.persist().await?; + + // Sync bridge chain. + let chain_client = ctx.make_chain_client(cid).await?; + chain_client.synchronize_from_validators().await?; + tracing::info!(%cid, "Bridge chain claimed and synced"); + (cid, owner) + }; - tracing::info!("Synchronizing bridge chain from validators..."); let chain_client = ctx.make_chain_client(chain_id).await?; - chain_client.synchronize_from_validators().await?; - tracing::info!(%chain_id, "Bridge chain claimed"); + Box::pin(serve_loop( + chain_client, + rpc_url, + evm_bridge_address, + linera_bridge_address, + linera_fungible_address, + evm_private_key, + port, + )) + .await +} - // Write chain ID and owner to /shared/ if the directory exists (Docker mode). - if let Ok(()) = fs_err::write("/shared/bridge-chain-id", chain_id.to_string()) { - tracing::info!("Wrote bridge chain ID to /shared/bridge-chain-id"); - } - if let Ok(()) = fs_err::write("/shared/relay-owner", owner.to_string()) { - tracing::info!("Wrote relay owner to /shared/relay-owner"); - } +// ── Main event loop ── - // ── 3. Set up EVM provider ── - let bridge_addr = resolve_bridge_address(bridge_address, bridge_address_file).await?; +#[allow(clippy::too_many_arguments)] +async fn serve_loop( + chain_client: ChainClient, + rpc_url: &str, + evm_bridge_address: &str, + linera_bridge_address: &str, + linera_fungible_address: &str, + evm_private_key: &str, + port: u16, +) -> Result<()> { + // ── Set up EVM provider ── + let bridge_addr: Address = evm_bridge_address + .parse() + .context("invalid --evm-bridge-address")?; let evm_signer: PrivateKeySigner = evm_private_key.parse().context("invalid EVM private key")?; let evm_wallet = EthereumWallet::from(evm_signer); @@ -413,16 +475,20 @@ pub async fn run( .with_simple_nonce_management() .connect_http(rpc_url.parse().context("invalid RPC URL")?); - // ── 4. Resolve app IDs ── - let bridge_app_id = resolve_bridge_app_id(bridge_app_id_file).await?; - let fungible_app_id = resolve_fungible_app_id(fungible_app_id_file).await?; + // ── Parse app IDs ── + let bridge_app_id: ApplicationId = linera_bridge_address + .parse() + .context("invalid --linera-bridge-address")?; + let fungible_app_id: ApplicationId = linera_fungible_address + .parse() + .context("invalid --linera-fungible-address")?; - // ── 5. Start notification listener ── + // ── Start notification listener ── let mut notifications = chain_client.subscribe()?; let (listener, _abort_handle, _) = chain_client.listen().await?; tokio::spawn(listener); - // ── 6. Start HTTP server ── + // ── Start HTTP server ── let proof_client = HttpDepositProofClient::new(rpc_url)?; let (deposit_tx, mut deposit_rx) = mpsc::channel::(16); let app_state = Arc::new(AppState { @@ -445,7 +511,14 @@ pub async fn run( } }); - // ── 7. Main loop: process notifications + deposit requests ── + tracing::info!( + %bridge_addr, + %bridge_app_id, + %fungible_app_id, + "Relay is ready" + ); + + // ── Main loop: process notifications + deposit requests ── tracing::info!("Listening for notifications and deposit requests..."); loop { tokio::select! { diff --git a/linera-bridge/tests/e2e/Cargo.lock b/linera-bridge/tests/e2e/Cargo.lock index 7df4bac1819e..cb32855d69bc 100644 --- a/linera-bridge/tests/e2e/Cargo.lock +++ b/linera-bridge/tests/e2e/Cargo.lock @@ -3637,6 +3637,7 @@ dependencies = [ "linera-base", "linera-execution", "op-alloy-network", + "thiserror 1.0.69", "tokio", "url", ] diff --git a/linera-service/src/cli/command.rs b/linera-service/src/cli/command.rs index 479bedd48d77..01292aaf5103 100644 --- a/linera-service/src/cli/command.rs +++ b/linera-service/src/cli/command.rs @@ -1234,6 +1234,10 @@ pub enum NetCommand { #[cfg(feature = "kubernetes")] #[arg(long, default_value = "false")] dual_store: bool, + + /// Set the list of hosts that contracts and services can send HTTP requests to. + #[arg(long, value_delimiter = ',')] + http_request_allow_list: Option>, }, /// Print a bash helper script to make `linera net up` easier to use. The script is diff --git a/linera-service/src/cli/main.rs b/linera-service/src/cli/main.rs index c91c35a683f7..9ae225ca4b45 100644 --- a/linera-service/src/cli/main.rs +++ b/linera-service/src/cli/main.rs @@ -2360,6 +2360,7 @@ async fn run(options: &Options) -> Result { with_block_exporter, exporter_address: block_exporter_address, exporter_port: block_exporter_port, + http_request_allow_list, .. } => { net_up_utils::handle_net_up_service( @@ -2380,6 +2381,7 @@ async fn run(options: &Options) -> Result { *with_faucet, *faucet_port, *faucet_amount, + http_request_allow_list.clone(), ) .boxed() .await?; diff --git a/linera-service/src/cli/net_up_utils.rs b/linera-service/src/cli/net_up_utils.rs index 202994ffc9e9..00d618048c18 100644 --- a/linera-service/src/cli/net_up_utils.rs +++ b/linera-service/src/cli/net_up_utils.rs @@ -207,6 +207,7 @@ pub async fn handle_net_up_service( with_faucet: bool, faucet_port: NonZeroU16, faucet_amount: Amount, + http_request_allow_list: Option>, ) -> anyhow::Result<()> { assert!( num_initial_validators >= 1, @@ -251,7 +252,7 @@ pub async fn handle_net_up_service( num_shards, num_proxies, policy_config, - http_request_allow_list: None, + http_request_allow_list, cross_chain_config, storage_config_builder, path_provider, diff --git a/linera-service/src/wallet.rs b/linera-service/src/wallet.rs index 56b5f9421c87..ce96c51cace7 100644 --- a/linera-service/src/wallet.rs +++ b/linera-service/src/wallet.rs @@ -46,9 +46,6 @@ impl ChainDetails { if self.is_admin { tags.push("ADMIN"); } - if self.user_chain.is_follow_only() { - tags.push("FOLLOW-ONLY"); - } if !tags.is_empty() { println!("{:<20} {}", "Tags:", tags.join(", ")); } @@ -71,12 +68,6 @@ impl ChainDetails { println!("{:<20} {}", "Timestamp:", self.user_chain.timestamp); println!("{:<20} {}", "Blocks:", self.user_chain.next_block_height); - if let Some(epoch) = self.user_chain.epoch { - println!("{:<20} {epoch}", "Epoch:"); - } else { - println!("{:<20} -", "Epoch:"); - } - if let Some(hash) = self.user_chain.block_hash { println!("{:<20} {hash}", "Latest block hash:"); } diff --git a/linera-storage/Cargo.toml b/linera-storage/Cargo.toml index 943bae489af3..37892f1abc7c 100644 --- a/linera-storage/Cargo.toml +++ b/linera-storage/Cargo.toml @@ -21,6 +21,7 @@ wasmer = ["linera-execution/wasmer"] wasmtime = ["linera-execution/wasmtime"] scylladb = ["linera-views/scylladb"] dynamodb = ["linera-views/dynamodb"] +rocksdb = ["linera-views/rocksdb"] metrics = [ "linera-base/metrics", "linera-chain/metrics", diff --git a/scripts/check_chain_loads.sh b/scripts/check_chain_loads.sh index 1fa2b72f6da7..22a4cd9cf7a8 100755 --- a/scripts/check_chain_loads.sh +++ b/scripts/check_chain_loads.sh @@ -38,10 +38,10 @@ if [ "$(grep 'linera-service/src/cli/main.rs' "$USAGES_FILE" | wc -l)" -eq 1 ]; sed -i -e '/linera-service\/src\/cli\/main\.rs/d' "$USAGES_FILE" fi -# The linera-client uses `create_chain` to initialize the storage from the genesis configuration, -# and this is only called by the `database_tool` -if [ "$(grep 'linera-client/src/config.rs' "$USAGES_FILE" | wc -l)" -eq 1 ]; then - sed -i -e '/linera-client\/src\/config\.rs/d' "$USAGES_FILE" +# GenesisConfig uses `create_chain` to initialize storage from the genesis configuration, +# and this is only called during initial setup +if [ "$(grep 'linera-core/src/genesis_config.rs' "$USAGES_FILE" | wc -l)" -eq 1 ]; then + sed -i -e '/linera-core\/src\/genesis_config\.rs/d' "$USAGES_FILE" fi # Client::extend_with_chain uses it to add a new chain to the tracked wallet. From 792564754a61674e467d450825e153033af408a3 Mon Sep 17 00:00:00 2001 From: deuszx <95355183+deuszx@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:29:24 +0000 Subject: [PATCH 07/13] Don't add --http-request-allow-list if not set (#5760) ## Motivation Bridge e2e task is currently failing - fix it. ## Proposal Don't add `--http-request-allow-list` in docker compose of the bridge test if it's not set. Should make the CI green and unblock docker image publishing. ## Test Plan CI ## Release Plan None. ## Links - [reviewer checklist](https://github.com/linera-io/linera-protocol/blob/main/CONTRIBUTING.md#reviewer-checklist) --- docker/docker-compose.bridge-test.yml | 28 +++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/docker/docker-compose.bridge-test.yml b/docker/docker-compose.bridge-test.yml index 0e14e91ccaa0..c3362785df5c 100644 --- a/docker/docker-compose.bridge-test.yml +++ b/docker/docker-compose.bridge-test.yml @@ -70,23 +70,27 @@ services: - "13001:13001" - "9090:9090" - "${RELAY_PORT:-3001}:${RELAY_PORT:-3001}" - command: > - sh -c "mkdir -p ${LINERA_NET_PATH:-/tmp/wallet} && - ./linera net - --storage scylladb:tcp:scylla:9042:table_default - up - --path ${LINERA_NET_PATH:-/tmp/wallet} - --with-faucet - --faucet-port ${FAUCET_PORT:-8080} - --with-block-exporter - --exporter-address linera-block-exporter - --exporter-port ${BLOCK_EXPORTER_PORT:-8882} - --http-request-allow-list ${HTTP_REQUEST_ALLOW_LIST:-anvil}" + command: + - sh + - -c + - | + mkdir -p ${LINERA_NET_PATH:-/tmp/wallet} && + ./linera net \ + --storage scylladb:tcp:scylla:9042:table_default \ + up \ + --path ${LINERA_NET_PATH:-/tmp/wallet} \ + --with-faucet \ + --faucet-port ${FAUCET_PORT:-8080} \ + --with-block-exporter \ + --exporter-address linera-block-exporter \ + --exporter-port ${BLOCK_EXPORTER_PORT:-8882} \ + $${HTTP_REQUEST_ALLOW_LIST:+--http-request-allow-list $$HTTP_REQUEST_ALLOW_LIST} environment: - RUST_LOG=linera=info - FAUCET_PORT=${FAUCET_PORT:-8080} - BLOCK_EXPORTER_PORT=${BLOCK_EXPORTER_PORT:-8882} - LINERA_NET_PATH=${LINERA_NET_PATH:-/tmp/wallet} + - HTTP_REQUEST_ALLOW_LIST=${HTTP_REQUEST_ALLOW_LIST:-} healthcheck: test: ["CMD-SHELL", "bash -c 'cat < /dev/null > /dev/tcp/localhost/'${FAUCET_PORT:-8080}"] interval: 5s From 8da906b5d350b17088b5b33650bc8197c94c45aa Mon Sep 17 00:00:00 2001 From: deuszx <95355183+deuszx@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:55:15 +0000 Subject: [PATCH 08/13] Actively scan EVM and Linera chains to detect new and completed bridging requests. (#5793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The linera-bridge relay had no visibility into the state of bridging requests. If a deposit was made on EVM but the relay crashed mid-processing, tokens would be locked on EVM with no minted counterpart on Linera — and nobody would know. Similarly, if a Linera→EVM burn was detected but the certificate failed to forward to EVM, the burn would be silently lost. Add active monitoring and automatic retry to the bridge relay, so it self-heals without operator intervention. Add `/metrics` endpoint where we serve Prometheus metrics. Replace scattered `provider`/`chain_client` clones with single `EvmClient` and `LineraClient` instances shared via `Arc`. All chain write operations (`ProcessDeposit`, `Burn`, `ProcessInbox`) go through an mpsc channel to the main loop, eliminating quorum conflicts from concurrent block proposals. - `EvmClient

`: wraps alloy Provider with typed API (`get_deposit_logs`, `get_transfer_logs`, `forward_cert`) - `LineraClient`: read operations use ChainClient directly (safe on clones), write operations serialized through channel - **EVM scan loop**: polls `DepositInitiated` events from the FungibleBridge contract, checks Linera for completion via `isDepositProcessed` GraphQL query - **Linera scan loop**: walks block history for Credit-to-Address20 messages, checks EVM for completion via ERC-20 `Transfer` events - Scan loops send newly discovered pending items to retry loops via channels - **Deposit retry**: generates MPT proof from `tx_hash` and submits `ProcessDeposit` to Linera (on-chain `processed_deposits` prevents double-minting) - **Burn retry**: submits Burn via channel to main loop, forwards cert to EVM directly (EVM `verifiedBlocks` prevents duplicates) - Exponential backoff (5s → 80s cap) with configurable max retries - Guards against duplicate processing: checks `forwarded` status and sets `last_retry_at` before starting work - **Fixed duplicate burn/deposit processing**: scan loop re-enqueue could race with in-progress retry, causing double Burn submissions. Now sets `last_retry_at` at processing start and skips already-completed items. - **Removed unnecessary cert forwarding**: inbox certs and deposit certs were forwarded to EVM but FungibleBridge ignored them (only burn certs trigger `_onBlock`) - **Fixed notification listener**: `extend_chain_mode()` was not called for pre-existing chains, preventing the relay from receiving `NewIncomingBundle` notifications - **Removed `/deposit` HTTP endpoint**: no longer needed since the scanner auto-detects deposits - **Frontend stale balance**: added 5s polling interval to catch relay-driven balance changes Replaced the monitor HTTP endpoints with a `/metrics` endpoint exposing: - `linera_bridge_deposits_detected`, `_completed`, `_pending`, `_failed` - `linera_bridge_burns_detected`, `_completed`, `_pending`, `_failed` - `linera_bridge_last_scanned_evm_block`, `_last_scanned_linera_height` - Split `monitor.rs` into `monitor/mod.rs`, `monitor/evm.rs`, `monitor/linera.rs` - Split `relay.rs` into `relay/mod.rs`, `relay/evm.rs`, `relay/linera.rs`, `relay/metrics.rs` - Introduced `Tracked` generic wrapper to deduplicate `TrackedDeposit` / `TrackedBurn` - Added `fungible` and `wrapped-fungible` as dependencies to use real types instead of inline BCS parsing - Anvil: `--slots-in-an-epoch 1 --block-time 1` for EVM finality - Validator: `anvil` added to HTTP request allow list - Relay waits for `setup-complete` marker before starting - setup.sh: retry `publish-and-create` up to 3 times, pass `EVM_RPC_URL` to evm-bridge - `--monitor-scan-interval ` (default: 30) - `--monitor-start-block ` (default: 0) - `--max-retries ` (default: 10) - [x] 10 unit tests for `MonitorState`, backoff logic, deposit key hashing - [x] E2E test (`test_evm_to_linera_bridge`): verifies `isDepositProcessed` query before and after deposit - [x] E2E test (`test_auto_deposit_scan`): bidirectional — deposits auto-scanned on EVM and processed on Linera, burns auto-detected and forwarded to EVM with ERC-20 balance verification - [x] All existing E2E tests pass (`committee_rotation`, `evm_to_linera_bridge`, `fungible_bridge`) - [x] Docker image rebuilt and tested --- .github/workflows/rust.yml | 4 +- Cargo.lock | 2 + docker/docker-compose.bridge-test.yml | 24 +- examples/Cargo.lock | 1 + examples/bridge-demo/README.md | 36 +- examples/bridge-demo/index.html | 34 +- examples/bridge-demo/setup.sh | 58 +- examples/evm-bridge/src/contract.rs | 9 +- examples/evm-bridge/src/lib.rs | 10 +- examples/evm-bridge/src/service.rs | 20 +- linera-bridge/Cargo.toml | 17 +- linera-bridge/src/lib.rs | 4 + linera-bridge/src/main.rs | 45 +- linera-bridge/src/monitor/evm.rs | 203 ++++++ linera-bridge/src/monitor/linera.rs | 243 +++++++ linera-bridge/src/monitor/mod.rs | 458 ++++++++++++ linera-bridge/src/proof/mod.rs | 26 + linera-bridge/src/relay.rs | 661 ------------------ linera-bridge/src/relay/evm.rs | 119 ++++ linera-bridge/src/relay/linera.rs | 213 ++++++ linera-bridge/src/relay/metrics.rs | 135 ++++ linera-bridge/src/relay/mod.rs | 523 ++++++++++++++ linera-bridge/tests/e2e/Cargo.lock | 101 ++- linera-bridge/tests/e2e/Cargo.toml | 6 + linera-bridge/tests/e2e/src/lib.rs | 27 + .../tests/e2e/tests/auto_deposit_scan.rs | 478 +++++++++++++ .../tests/e2e/tests/evm_to_linera_bridge.rs | 32 +- 27 files changed, 2730 insertions(+), 759 deletions(-) create mode 100644 linera-bridge/src/monitor/evm.rs create mode 100644 linera-bridge/src/monitor/linera.rs create mode 100644 linera-bridge/src/monitor/mod.rs delete mode 100644 linera-bridge/src/relay.rs create mode 100644 linera-bridge/src/relay/evm.rs create mode 100644 linera-bridge/src/relay/linera.rs create mode 100644 linera-bridge/src/relay/metrics.rs create mode 100644 linera-bridge/src/relay/mod.rs create mode 100644 linera-bridge/tests/e2e/tests/auto_deposit_scan.rs diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 0c32408994f4..7b77e731f717 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -213,14 +213,14 @@ jobs: repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Compile Wasm test modules for Witty integration tests run: | - cargo build -p linera-witty-test-modules --target wasm32-unknown-unknown + cargo build -p linera-witty-test-modules --target wasm32-unknown-unknown --locked - name: Run all tests using the default features (except storage-service) run: | # TODO(#2764): Actually link this to the default features cargo test --no-default-features --features fs,macros,wasmer,rocksdb --locked - name: Run Witty integration tests run: | - cargo test -p linera-witty --features wasmer,wasmtime + cargo test -p linera-witty --features wasmer,wasmtime --locked check-outdated-cli-md: needs: changed-files diff --git a/Cargo.lock b/Cargo.lock index d2e5cae4c808..fb21990cd3fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5467,6 +5467,7 @@ dependencies = [ "clap", "dirs", "fs-err", + "fungible", "futures", "hex", "insta", @@ -5480,6 +5481,7 @@ dependencies = [ "linera-storage", "linera-views", "op-alloy-network", + "prometheus", "proptest", "rand 0.8.5", "rand_chacha 0.3.1", diff --git a/docker/docker-compose.bridge-test.yml b/docker/docker-compose.bridge-test.yml index c3362785df5c..c75824443a91 100644 --- a/docker/docker-compose.bridge-test.yml +++ b/docker/docker-compose.bridge-test.yml @@ -2,7 +2,7 @@ services: # 0. Anvil - local EVM node for LightClient contract anvil: image: ghcr.io/foundry-rs/foundry:stable - entrypoint: ["anvil", "--host", "0.0.0.0", "--code-size-limit", "300000", "--hardfork", "shanghai"] + entrypoint: ["anvil", "--host", "0.0.0.0", "--code-size-limit", "300000", "--hardfork", "shanghai", "--slots-in-an-epoch", "1", "--block-time", "1"] ports: - "${ANVIL_PORT:-8545}:8545" healthcheck: @@ -90,7 +90,7 @@ services: - FAUCET_PORT=${FAUCET_PORT:-8080} - BLOCK_EXPORTER_PORT=${BLOCK_EXPORTER_PORT:-8882} - LINERA_NET_PATH=${LINERA_NET_PATH:-/tmp/wallet} - - HTTP_REQUEST_ALLOW_LIST=${HTTP_REQUEST_ALLOW_LIST:-} + - HTTP_REQUEST_ALLOW_LIST=${HTTP_REQUEST_ALLOW_LIST:-anvil} healthcheck: test: ["CMD-SHELL", "bash -c 'cat < /dev/null > /dev/tcp/localhost/'${FAUCET_PORT:-8080}"] interval: 5s @@ -329,14 +329,12 @@ services: - sh - -c - | - echo "Waiting for bridge chain ID..." - while [ ! -f /shared/bridge-chain-id ]; do sleep 1; done - CHAIN_ID=$$(cat /shared/bridge-chain-id | tr -d '[:space:]') + # Remove stale marker from previous runs. + rm -f /shared/setup-complete + echo "Waiting for setup to complete..." + while [ ! -f /shared/setup-complete ]; do sleep 1; done - echo "Waiting for bridge address and app IDs..." - while [ ! -f /shared/bridge-address ]; do sleep 1; done - while [ ! -f /shared/bridge-app-id ]; do sleep 1; done - while [ ! -f /shared/wrapped-app-id ]; do sleep 1; done + CHAIN_ID=$$(cat /shared/bridge-chain-id | tr -d '[:space:]') BRIDGE_ADDR=$$(cat /shared/bridge-address | tr -d '[:space:]') BRIDGE_APP=$$(cat /shared/bridge-app-id | tr -d '[:space:]') FUNGIBLE_APP=$$(cat /shared/wrapped-app-id | tr -d '[:space:]') @@ -353,11 +351,17 @@ services: --linera-bridge-address="$$BRIDGE_APP" \ --linera-fungible-address="$$FUNGIBLE_APP" \ --evm-private-key=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ - --port=${RELAY_PORT:-3001} + --port=${RELAY_PORT:-3001} \ + --monitor-scan-interval=${MONITOR_SCAN_INTERVAL:-5} \ + --monitor-start-block=${MONITOR_START_BLOCK:-0} \ + --max-retries=${MAX_RETRIES:-10} environment: - RUST_LOG=linera=info,linera_bridge=debug - FAUCET_PORT=${FAUCET_PORT:-8080} - RELAY_PORT=${RELAY_PORT:-3001} + - MONITOR_SCAN_INTERVAL=${MONITOR_SCAN_INTERVAL:-30} + - MONITOR_START_BLOCK=${MONITOR_START_BLOCK:-0} + - MAX_RETRIES=${MAX_RETRIES:-10} volumes: - bridge-shared:/shared depends_on: diff --git a/examples/Cargo.lock b/examples/Cargo.lock index c5bdf5ecbc79..eb6975ec10a1 100644 --- a/examples/Cargo.lock +++ b/examples/Cargo.lock @@ -3860,6 +3860,7 @@ dependencies = [ "alloy-rlp", "alloy-trie", "anyhow", + "serde", ] [[package]] diff --git a/examples/bridge-demo/README.md b/examples/bridge-demo/README.md index 04c696af7888..c096690dca6c 100644 --- a/examples/bridge-demo/README.md +++ b/examples/bridge-demo/README.md @@ -243,7 +243,7 @@ the deposit/withdraw forms. 1. User approves ERC-20 spend on `FungibleBridge` 2. User calls `FungibleBridge.deposit(chainId, appId, owner, amount)` -3. Frontend POSTs the tx hash to the relay's `/deposit` endpoint +3. Relay's EVM scanner detects the `DepositInitiated` event 4. Relay generates a receipt inclusion proof (MPT) and submits it to the `evm-bridge` Linera app 5. `evm-bridge` verifies the proof and tells `wrapped-fungible` to mint tokens @@ -259,6 +259,40 @@ the deposit/withdraw forms. 4. `FungibleBridge` verifies the block via `LightClient`, deserializes the `Credit`, and transfers ERC-20 tokens to the user's EVM address +## Active scanning and auto-retry + +The relay actively scans both chains to detect missed or failed bridging +requests and automatically retries them: + +- **EVM→Linera deposits**: the relay polls EVM for `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. +- **Linera→EVM burns**: the relay scans Linera blocks for Credit messages to + EVM addresses and checks EVM for matching ERC-20 `Transfer` events. + Unforwarded burns are retried by re-reading the burn execution block 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 | + ## Frontend environment The frontend reads these variables from `.env.local` (generated by `setup.sh`): diff --git a/examples/bridge-demo/index.html b/examples/bridge-demo/index.html index 716147c8aaed..3478c9b1df5e 100644 --- a/examples/bridge-demo/index.html +++ b/examples/bridge-demo/index.html @@ -405,7 +405,7 @@

Chain -

const FAUCET_URL = import.meta.env.LINERA_FAUCET_URL; const APP_ID = import.meta.env.LINERA_APPLICATION_ID; // wrapped-fungible const BRIDGE_APP_ID = import.meta.env.LINERA_BRIDGE_APP_ID; // evm-bridge - const RELAY_URL = import.meta.env.LINERA_RELAY_URL; + // RELAY_URL is no longer needed — the relay auto-detects deposits via active scanning. const BRIDGE_ADDRESS = import.meta.env.LINERA_BRIDGE_ADDRESS; // FungibleBridge on EVM const TOKEN_ADDRESS = import.meta.env.LINERA_TOKEN_ADDRESS; // ERC-20 on EVM const BRIDGE_CHAIN_ID = import.meta.env.LINERA_BRIDGE_CHAIN_ID; // relay's chain @@ -547,32 +547,20 @@

Chain -

const ownerHex = lineraOwner.replace(/^0x/, ''); const ownerBytes32 = '0x' + ownerHex.slice(0, 64).padStart(64, '0'); - // Step 1: Approve - setStatus('Step 1/3: Approving ERC-20 spend...'); + // Step 1: Approve + Deposit on EVM + setStatus('Step 1/2: Approving ERC-20 spend...'); const approveTx = await token.approve(BRIDGE_ADDRESS, amountWei); - setStatus('Step 1/3: Waiting for approval confirmation...'); + setStatus('Step 1/2: Waiting for approval confirmation...'); await waitForTx(approveTx.hash); - // Step 2: Deposit on EVM - setStatus('Step 2/3: Depositing to FungibleBridge...'); + setStatus('Step 1/2: Depositing to FungibleBridge...'); const depositTx = await bridge.deposit(chainIdBytes, appIdBytes32, ownerBytes32, amountWei); - setStatus('Step 2/3: Waiting for deposit confirmation...'); + setStatus('Step 1/2: Waiting for deposit confirmation...'); const receipt = await waitForTx(depositTx.hash); const txHash = receipt.hash; - // Step 3: Send to relay (generates proof + submits to Linera) - setStatus('Step 3/3: Relay processing deposit...'); - const depositResp = await fetch(`${RELAY_URL}/deposit`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tx_hash: txHash }), - }); - if (!depositResp.ok) { - const body = await depositResp.json().catch(() => ({})); - throw new Error(body.error || `Relay returned ${depositResp.status}`); - } - - setStatus('Deposit complete! Waiting for balance update...'); + // Step 3: Wait for relay to auto-detect and process the deposit + setStatus('Step 2/2: Waiting for relay to process deposit...'); await updateEvmBalance(); // Poll for balance update (cross-chain Credit may take a few seconds) const depositDeadline = Date.now() + 60_000; @@ -705,6 +693,12 @@

Chain -

} }); + // Periodically refresh balances to catch relay-driven changes. + setInterval(async () => { + await updateEvmBalance(); + await updateLineraBalance(); + }, 5_000); + setStatus('Ready. Connect MetaMask to begin.'); } diff --git a/examples/bridge-demo/setup.sh b/examples/bridge-demo/setup.sh index aa820317458d..bc35faa0b7a7 100755 --- a/examples/bridge-demo/setup.sh +++ b/examples/bridge-demo/setup.sh @@ -428,15 +428,16 @@ params = { } print(json.dumps(params)) ") -WRAPPED_APP_OUTPUT=$(linera_exec publish-and-create \ - "$WASM_DIR/wrapped_fungible_contract.wasm" \ - "$WASM_DIR/wrapped_fungible_service.wasm" \ - --json-parameters "$WRAPPED_PARAMS" \ - --json-argument '{"accounts":{}}' 2>&1) || { - echo "ERROR: publish-and-create wrapped-fungible failed:" >&2 - echo "$WRAPPED_APP_OUTPUT" >&2 - exit 1 -} +for attempt in 1 2 3; do + WRAPPED_APP_OUTPUT=$(linera_exec publish-and-create \ + "$WASM_DIR/wrapped_fungible_contract.wasm" \ + "$WASM_DIR/wrapped_fungible_service.wasm" \ + --json-parameters "$WRAPPED_PARAMS" \ + --json-argument '{"accounts":{}}' 2>&1) && break + echo " Attempt $attempt failed, retrying..." >&2 + sleep 2 +done +[[ -z "$WRAPPED_APP_OUTPUT" ]] && { echo "ERROR: publish-and-create wrapped-fungible failed after retries" >&2; exit 1; } # The application ID is the last 64-hex-char token on its own line. WRAPPED_APP_ID=$(echo "$WRAPPED_APP_OUTPUT" | grep -oE '^[a-f0-9]{64}$' | tail -1) validate_hex64 "Wrapped-fungible app ID" "$WRAPPED_APP_ID" @@ -465,7 +466,6 @@ BRIDGE_OUTPUT=$(evm_exec \ --constructor-args \ "$LIGHT_CLIENT_ADDR" \ "$CHAIN_BYTES32" \ - 0 \ "$APP_ID_BYTES32" \ "$TOKEN_ADDRESS") BRIDGE_ADDRESS=$(echo "$BRIDGE_OUTPUT" | parse_address) @@ -490,6 +490,7 @@ BRIDGE_PARAMS=$( BRIDGE_HEX="$BRIDGE_ADDR_HEX" \ APP_ID="$WRAPPED_APP_ID" \ TOKEN_HEX="$TOKEN_ADDR_HEX" \ + EVM_RPC_URL="$EVM_RPC_URL" \ python3 -c " import json, os def hex_to_array(h): @@ -504,16 +505,17 @@ params = { print(json.dumps(params)) ") -BRIDGE_APP_OUTPUT=$(linera_exec publish-and-create \ - "$WASM_DIR/evm_bridge_contract.wasm" \ - "$WASM_DIR/evm_bridge_service.wasm" \ - --json-parameters "$BRIDGE_PARAMS" \ - --json-argument 'null' \ - --required-application-ids "$WRAPPED_APP_ID" 2>&1) || { - echo "ERROR: publish-and-create evm-bridge failed:" >&2 - echo "$BRIDGE_APP_OUTPUT" >&2 - exit 1 -} +for attempt in 1 2 3; do + BRIDGE_APP_OUTPUT=$(linera_exec publish-and-create \ + "$WASM_DIR/evm_bridge_contract.wasm" \ + "$WASM_DIR/evm_bridge_service.wasm" \ + --json-parameters "$BRIDGE_PARAMS" \ + --json-argument 'null' \ + --required-application-ids "$WRAPPED_APP_ID" 2>&1) && break + echo " Attempt $attempt failed, retrying..." >&2 + sleep 2 +done +[[ -z "$BRIDGE_APP_OUTPUT" ]] && { echo "ERROR: publish-and-create evm-bridge failed after retries" >&2; exit 1; } BRIDGE_APP_ID=$(echo "$BRIDGE_APP_OUTPUT" | grep -oE '^[a-f0-9]{64}$' | tail -1) validate_hex64 "Linera bridge app ID" "$BRIDGE_APP_ID" echo " Linera bridge app: $BRIDGE_APP_ID" @@ -551,6 +553,11 @@ LINERA_BRIDGE_CHAIN_ID=$BRIDGE_CHAIN_ID LINERA_EVM_CHAIN_ID=$EVM_CHAIN_ID EOF +# Write setup-complete marker so the relay knows it's safe to start. +if [[ -n "$COMPOSE_FILE" ]]; then + dc_exec --user root foundry-tools sh -c "echo 'done' > /shared/setup-complete" +fi + echo "" echo "=== Setup Complete ===" echo "" @@ -568,15 +575,24 @@ echo "Environment written to: $OUTPUT_FILE" echo "Shared state dir: $SHARED_DIR" echo "" echo "Next steps:" +if [[ -n "$COMPOSE_FILE" ]]; then +echo " 1. The relay is running via docker-compose (linera-relay service)." +else echo " 1. Start the relay:" echo " linera-bridge serve \\" echo " --rpc-url $EVM_RPC_URL \\" echo " --faucet-url $FAUCET_URL \\" +echo " --wallet $LINERA_WALLET_PATH \\" +echo " --keystore $LINERA_KEYSTORE_PATH \\" +echo " --storage $LINERA_STORAGE_PATH \\" echo " --linera-bridge-chain-id $BRIDGE_CHAIN_ID \\" echo " --evm-bridge-address $BRIDGE_ADDRESS \\" echo " --linera-bridge-address $BRIDGE_APP_ID \\" echo " --linera-fungible-address $WRAPPED_APP_ID \\" -echo " --evm-private-key 0x..." +echo " --evm-private-key 0x... \\" +echo " --monitor-scan-interval 30 \\" +echo " --max-retries 10" +fi echo " 2. Start the frontend:" echo " cd examples/bridge-demo" echo " pnpm install && pnpm dev" diff --git a/examples/evm-bridge/src/contract.rs b/examples/evm-bridge/src/contract.rs index 3a3a60e7af74..cbfbd06965c8 100644 --- a/examples/evm-bridge/src/contract.rs +++ b/examples/evm-bridge/src/contract.rs @@ -18,7 +18,7 @@ use wrapped_fungible::{WrappedFungibleOperation, WrappedFungibleTokenAbi}; #[derive(RootView)] #[view(context = ViewStorageContext)] pub struct BridgeState { - pub processed_deposits: SetView, + pub processed_deposits: SetView<[u8; 32]>, pub verified_block_hashes: SetView<[u8; 32]>, } @@ -193,19 +193,20 @@ impl EvmBridgeContract { tx_index, log_index, }; + let deposit_hash = deposit_key.hash(); assert!( !self .state .processed_deposits - .contains(&deposit_key) + .contains(&deposit_hash) .await .expect("failed to check processed deposits"), "deposit already processed" ); self.state .processed_deposits - .insert(&deposit_key) - .expect("failed to insert deposit key"); + .insert(&deposit_hash) + .expect("failed to insert deposit hash"); // 5b. Cache the verified block hash so subsequent deposits from the same // block skip the RPC finality check. diff --git a/examples/evm-bridge/src/lib.rs b/examples/evm-bridge/src/lib.rs index 8624661dd443..05396a89fa1c 100644 --- a/examples/evm-bridge/src/lib.rs +++ b/examples/evm-bridge/src/lib.rs @@ -7,6 +7,7 @@ //! on EVM and mints wrapped tokens on Linera via the wrapped-fungible app. use async_graphql::{Request, Response}; +pub use linera_bridge::proof::DepositKey; use linera_sdk::linera_base_types::{ApplicationId, ContractAbi, ServiceAbi}; use serde::{Deserialize, Serialize}; @@ -27,15 +28,6 @@ pub struct BridgeParameters { pub rpc_endpoint: String, } -/// Replay-protection key for processed deposits. -#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] -pub struct DepositKey { - pub source_chain_id: u64, - pub block_hash: [u8; 32], - pub tx_index: u64, - pub log_index: u64, -} - /// Operations accepted by the bridge contract. #[derive(Debug, Deserialize, Serialize)] pub enum BridgeOperation { diff --git a/examples/evm-bridge/src/service.rs b/examples/evm-bridge/src/service.rs index 7571a861cf41..23806b256bfc 100644 --- a/examples/evm-bridge/src/service.rs +++ b/examples/evm-bridge/src/service.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use async_graphql::{EmptyMutation, EmptySubscription, Object, Request, Response, Schema}; -use evm_bridge::{BridgeParameters, DepositKey, EvmBridgeAbi}; +use evm_bridge::{BridgeParameters, EvmBridgeAbi}; use linera_sdk::{ linera_base_types::WithServiceAbi, views::{linera_views, RootView, SetView, View, ViewStorageContext}, @@ -17,7 +17,7 @@ use linera_sdk::{ #[derive(RootView)] #[view(context = ViewStorageContext)] pub struct BridgeState { - pub processed_deposits: SetView, + pub processed_deposits: SetView<[u8; 32]>, } #[derive(Clone)] @@ -71,4 +71,20 @@ impl EvmBridgeService { let params: BridgeParameters = self.runtime.application_parameters(); format!("0x{}", hex::encode(params.token_address)) } + + /// Whether a deposit with the given hash has been processed. + /// + /// The hash is the hex-encoded keccak-256 of the deposit key + /// (see [`evm_bridge::DepositKey::hash`]). + async fn is_deposit_processed(&self, hash: String) -> bool { + let bytes: [u8; 32] = hex::decode(hash.strip_prefix("0x").unwrap_or(&hash)) + .expect("invalid hex") + .try_into() + .expect("hash must be 32 bytes"); + self.state + .processed_deposits + .contains(&bytes) + .await + .expect("failed to check processed deposits") + } } diff --git a/linera-bridge/Cargo.toml b/linera-bridge/Cargo.toml index 09b261679961..9967c0625caa 100644 --- a/linera-bridge/Cargo.toml +++ b/linera-bridge/Cargo.toml @@ -32,20 +32,16 @@ codegen = [ "dep:serde-reflection", "dep:serde_yaml", ] -cli = [ - "offchain", - "dep:clap", - "dep:reqwest", - "dep:serde_json", - "dep:serde", - "dep:fs-err", -] +cli = ["offchain", "dep:clap", "dep:reqwest", "dep:serde_json", "dep:fs-err"] relay = [ "offchain", "cli", "dep:axum", "dep:dirs", + "dep:fungible", + "dep:wrapped-fungible", "dep:futures", + "dep:prometheus", "dep:hex", "dep:linera-chain", "dep:linera-client", @@ -71,6 +67,7 @@ alloy-primitives.workspace = true alloy-rlp.workspace = true alloy-trie.workspace = true anyhow.workspace = true +serde.workspace = true thiserror = { workspace = true, optional = true } async-trait = { workspace = true, optional = true } @@ -94,9 +91,10 @@ url = { workspace = true, optional = true } clap = { workspace = true, optional = true } fs-err = { workspace = true, optional = true } +fungible = { path = "../examples/fungible", optional = true } reqwest = { workspace = true, optional = true } -serde = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } +wrapped-fungible = { path = "../examples/wrapped-fungible", optional = true } # relay feature deps axum = { workspace = true, optional = true } @@ -110,6 +108,7 @@ linera-faucet-client = { workspace = true, optional = true } linera-persistent = { workspace = true, optional = true, features = ["fs"] } linera-storage = { workspace = true, optional = true } linera-views = { workspace = true, optional = true } +prometheus = { workspace = true, optional = true } rustls = { version = "0.23", optional = true, features = ["ring"] } tower-http = { workspace = true, optional = true, features = ["cors"] } tracing = { workspace = true, optional = true } diff --git a/linera-bridge/src/lib.rs b/linera-bridge/src/lib.rs index ee06ca06e690..c17d7d369d1b 100644 --- a/linera-bridge/src/lib.rs +++ b/linera-bridge/src/lib.rs @@ -14,6 +14,10 @@ pub mod proof; #[cfg(feature = "offchain")] pub mod evm; +/// Bridge monitoring: tracks in-flight EVM↔Linera bridging requests. +#[cfg(feature = "relay")] +pub mod monitor; + /// Relay server: HTTP proof endpoint + Linera chain inbox processing + EVM block forwarding. #[cfg(feature = "relay")] pub mod relay; diff --git a/linera-bridge/src/main.rs b/linera-bridge/src/main.rs index 5fb86547099e..84bc30bbb03b 100644 --- a/linera-bridge/src/main.rs +++ b/linera-bridge/src/main.rs @@ -18,7 +18,7 @@ enum Cli { GenerateDepositProof(GenerateDepositProofOptions), /// Run the relay server (proof generation + chain inbox processing + EVM forwarding) #[cfg(feature = "relay")] - Serve(ServeOptions), + Serve(Box), } #[derive(clap::Args, Debug, Clone)] @@ -93,6 +93,38 @@ struct ServeOptions { /// Port to listen on for HTTP requests #[arg(long, default_value = "3001")] port: u16, + + /// The maximal number of entries in the blob cache. + #[arg(long, default_value = "1000")] + blob_cache_size: usize, + + /// The maximal number of entries in the confirmed block cache. + #[arg(long, default_value = "1000")] + confirmed_block_cache_size: usize, + + /// The maximal number of entries in the lite certificate cache. + #[arg(long, default_value = "1000")] + lite_certificate_cache_size: usize, + + /// The maximal number of entries in the raw certificate cache. + #[arg(long, default_value = "1000")] + certificate_raw_cache_size: usize, + + /// The maximal number of entries in the event cache. + #[arg(long, default_value = "1000")] + event_cache_size: usize, + + /// Interval between monitor scan loops, in seconds. + #[arg(long, default_value = "30")] + monitor_scan_interval: u64, + + /// EVM block number to start scanning from for deposit monitoring. + #[arg(long, default_value = "0")] + monitor_start_block: u64, + + /// Maximum number of retry attempts for pending deposits and burns. + #[arg(long, default_value = "10")] + max_retries: u32, } fn main() -> Result<()> { @@ -123,7 +155,16 @@ impl ServeOptions { &self.linera_fungible_address, &self.evm_private_key, self.port, - self.blob_cache_size, + linera_storage::StorageCacheSizes { + blob_cache_size: self.blob_cache_size, + confirmed_block_cache_size: self.confirmed_block_cache_size, + lite_certificate_cache_size: self.lite_certificate_cache_size, + certificate_raw_cache_size: self.certificate_raw_cache_size, + event_cache_size: self.event_cache_size, + }, + self.monitor_scan_interval, + self.monitor_start_block, + self.max_retries, )) .await } diff --git a/linera-bridge/src/monitor/evm.rs b/linera-bridge/src/monitor/evm.rs new file mode 100644 index 000000000000..5c54925ebf75 --- /dev/null +++ b/linera-bridge/src/monitor/evm.rs @@ -0,0 +1,203 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! EVM-side monitoring: scans for `DepositInitiated` events, checks Linera for +//! completion, and retries pending deposits. + +use std::{sync::Arc, time::Duration}; + +use alloy::providers::Provider; +use tokio::sync::RwLock; + +use super::{MonitorState, PendingDeposit}; +use crate::{ + proof::{parse_deposit_event, DepositKey, ReceiptLog}, + relay::{evm::EvmClient, linera::LineraClient}, +}; + +/// Background task that polls EVM for `DepositInitiated` events and checks +/// Linera for completion. +pub async fn evm_scan_loop( + monitor: Arc>, + evm_client: Arc>, + linera_client: Arc>, + pending_deposit_tx: tokio::sync::mpsc::Sender, + scan_interval: Duration, + max_retries: u32, +) { + loop { + let (scan_result, completion_result) = tokio::join!( + evm_scan_iteration(&monitor, &evm_client, &pending_deposit_tx), + check_deposit_completion(&monitor, &linera_client), + ); + + if let Err(error) = scan_result { + tracing::warn!(error = ?error, "EVM scan iteration failed"); + } + if let Err(error) = completion_result { + tracing::warn!(error = ?error, "Deposit completion check failed"); + } + + // Re-enqueue deposits that are eligible for retry. + { + let state = monitor.read().await; + for d in state.deposits_ready_for_retry(max_retries) { + let _ = pending_deposit_tx.try_send(PendingDeposit { + key: d.value.key.clone(), + tx_hash: d.value.tx_hash, + depositor: d.value.depositor, + amount: d.value.amount, + nonce: d.value.nonce, + }); + } + } + + let summary = monitor.read().await.status_summary(); + tracing::trace!( + pending = summary.deposits_pending, + completed = summary.deposits_completed, + last_block = summary.last_scanned_evm_block, + "EVM deposit scan complete" + ); + + tokio::time::sleep(scan_interval).await; + } +} + +/// Receives pending deposits from the scanner and retries them. +pub(crate) async fn retry_pending_deposits( + monitor: &RwLock, + linera_client: &LineraClient, + proof_client: &crate::proof::gen::HttpDepositProofClient, + mut pending_rx: tokio::sync::mpsc::Receiver, +) -> anyhow::Result<()> { + use crate::proof::gen::DepositProofClient as _; + + while let Some(pending) = pending_rx.recv().await { + let tx_hash = pending.tx_hash; + { + let mut state = monitor.write().await; + if let Some(d) = state.deposits.get_mut(&pending.key) { + if d.forwarded { + tracing::trace!(%tx_hash, "Deposit already completed, skipping"); + continue; + } + d.last_retry_at = Some(std::time::Instant::now()); + } else { + state.track_deposit(pending.clone()); + } + } + + tracing::info!(%tx_hash, "Processing pending deposit..."); + match proof_client.generate_deposit_proof(tx_hash).await { + Ok(proof) => match linera_client.process_deposit(proof).await { + Ok(()) => { + tracing::info!(%tx_hash, "Deposit processed successfully"); + } + Err(e) => { + tracing::warn!(%tx_hash, "Deposit processing failed: {e}"); + } + }, + Err(e) => { + tracing::warn!(%tx_hash, "Proof generation failed: {e:#}"); + } + } + + monitor.write().await.mark_deposit_retried(&pending.key); + } + + anyhow::bail!("Pending deposit channel closed"); +} + +async fn evm_scan_iteration( + monitor: &RwLock, + evm_client: &EvmClient, + pending_tx: &tokio::sync::mpsc::Sender, +) -> anyhow::Result<()> { + let last_block = monitor.read().await.last_scanned_evm_block; + + let current_block = evm_client.get_block_number().await?; + if current_block <= last_block { + return Ok(()); + } + + let logs = evm_client + .get_deposit_logs(last_block + 1, current_block) + .await?; + let bridge_addr = evm_client.bridge_addr(); + + for log in &logs { + let block_hash = match log.block_hash { + Some(h) => h, + None => continue, + }; + let tx_hash = match log.transaction_hash { + Some(h) => h, + None => continue, + }; + let tx_index = match log.transaction_index { + Some(i) => i, + None => continue, + }; + let log_index = match log.log_index { + Some(i) => i, + None => continue, + }; + + let receipt_log = ReceiptLog { + address: log.address(), + topics: log.data().topics().to_vec(), + data: log.data().data.to_vec(), + }; + let deposit = match parse_deposit_event(&receipt_log, bridge_addr) { + Ok(d) => d, + Err(e) => { + tracing::warn!(%tx_hash, "Failed to parse DepositInitiated log: {e:#}"); + continue; + } + }; + + let key = DepositKey { + source_chain_id: deposit.source_chain_id.to::(), + block_hash: block_hash.0, + tx_index, + log_index, + }; + + let _ = pending_tx.try_send(PendingDeposit { + key, + tx_hash, + depositor: deposit.depositor, + amount: deposit.amount, + nonce: deposit.nonce, + }); + } + + let mut state = monitor.write().await; + state.last_scanned_evm_block = current_block; + crate::relay::metrics::set_last_scanned_evm_block(current_block); + + Ok(()) +} + +async fn check_deposit_completion( + monitor: &RwLock, + linera_client: &LineraClient, +) -> anyhow::Result<()> { + let pending: Vec = { + let state = monitor.read().await; + state + .pending_deposits() + .into_iter() + .map(|d| d.value.key.clone()) + .collect() + }; + + for key in pending { + if linera_client.query_deposit_processed(&key).await? { + monitor.write().await.complete_deposit(&key); + } + } + + Ok(()) +} diff --git a/linera-bridge/src/monitor/linera.rs b/linera-bridge/src/monitor/linera.rs new file mode 100644 index 000000000000..39fbcbc3f900 --- /dev/null +++ b/linera-bridge/src/monitor/linera.rs @@ -0,0 +1,243 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Linera-side monitoring: scans for Credit-to-Address20 messages (burns), +//! checks EVM for completion via ERC-20 Transfer events, and retries +//! unforwarded burns. + +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; + +use alloy::{primitives::Address, providers::Provider}; +use linera_base::{data_types::Amount, identifiers::AccountOwner}; +use tokio::sync::RwLock; + +use super::{MonitorState, PendingBurn}; +use crate::relay::{ + evm::EvmClient, + linera::{find_address20_credits, LineraClient}, +}; + +/// Background task that scans Linera block history for Credit messages +/// to Address20 owners and checks EVM for completion. +pub async fn linera_scan_loop( + monitor: Arc>, + evm_client: Arc>, + linera_client: Arc>, + pending_burn_tx: tokio::sync::mpsc::Sender, + scan_interval: Duration, + max_retries: u32, +) { + loop { + let (scan_result, completion_result) = tokio::join!( + linera_scan_iteration(&monitor, &linera_client, &pending_burn_tx), + check_burn_completion(&monitor, &evm_client), + ); + + if let Err(error) = scan_result { + tracing::warn!(?error, "Linera scan iteration failed"); + } + if let Err(error) = completion_result { + tracing::warn!(?error, "Burn completion check failed"); + } + + // Re-enqueue burns that are eligible for retry. + { + let state = monitor.read().await; + for b in state.burns_ready_for_retry(max_retries) { + let _ = pending_burn_tx.try_send(PendingBurn { + linera_height: b.value.linera_height, + burn_index: b.value.burn_index, + evm_recipient: b.value.evm_recipient.clone(), + amount: b.value.amount.clone(), + }); + } + } + + let summary = monitor.read().await.status_summary(); + tracing::trace!( + pending = summary.burns_pending, + completed = summary.burns_forwarded, + last_height = summary.last_scanned_linera_height, + "Linera burn scan complete" + ); + + tokio::time::sleep(scan_interval).await; + } +} + +/// Receives pending burns, submits Burn via LineraClient, forwards cert via EvmClient. +pub(crate) async fn retry_pending_burns( + monitor: &RwLock, + evm_client: &EvmClient, + linera_client: &LineraClient, + mut pending_rx: tokio::sync::mpsc::Receiver, +) -> anyhow::Result<()> { + while let Some(pending) = pending_rx.recv().await { + let credit_height = pending.linera_height; + let burn_index = pending.burn_index; + let owner: AccountOwner = match pending.evm_recipient.parse() { + Ok(o) => o, + Err(_) => { + tracing::warn!( + evm_recipient = %pending.evm_recipient, + "Invalid recipient, skipping burn" + ); + continue; + } + }; + let amount: Amount = match pending.amount.parse() { + Ok(a) => a, + Err(_) => { + tracing::warn!(amount = %pending.amount, "Invalid amount, skipping burn"); + continue; + } + }; + { + let mut state = monitor.write().await; + if let Some(b) = state.burns.get_mut(&(credit_height, burn_index)) { + if b.forwarded { + tracing::trace!( + credit_height, + burn_index, + "Burn already completed, skipping" + ); + continue; + } + b.last_retry_at = Some(Instant::now()); + } else { + state.track_burn(pending); + } + } + + tracing::info!(credit_height, burn_index, %owner, %amount, "Processing burn..."); + + // Step 1: Submit Burn on Linera via the main loop channel. + let cert = match linera_client.burn(owner, amount).await { + Ok(cert) => cert, + Err(e) => { + tracing::warn!(credit_height, burn_index, "Burn submission failed: {e:#}"); + monitor + .write() + .await + .mark_burn_retried(credit_height, burn_index); + continue; + } + }; + + // Step 2: Forward cert to EVM directly (no chain conflict). + match evm_client.forward_cert(&cert).await { + Ok(()) => { + tracing::info!(credit_height, burn_index, "Burn forwarded to EVM"); + monitor + .write() + .await + .complete_burn(credit_height, burn_index); + } + Err(e) => { + let msg = format!("{e:#}"); + if msg.contains("already verified") { + tracing::trace!(credit_height, burn_index, "Block already verified on EVM"); + monitor + .write() + .await + .complete_burn(credit_height, burn_index); + } else { + tracing::warn!(credit_height, burn_index, "EVM forwarding failed: {e:#}"); + monitor + .write() + .await + .mark_burn_retried(credit_height, burn_index); + } + } + } + } + + anyhow::bail!("Pending burn channel closed"); +} + +async fn linera_scan_iteration( + monitor: &RwLock, + linera_client: &LineraClient, + pending_burn_tx: &tokio::sync::mpsc::Sender, +) -> anyhow::Result<()> { + let last_height = monitor.read().await.last_scanned_linera_height; + + linera_client.sync().await?; + let info = linera_client.chain_info().await?; + let current_height = info.next_block_height.0; + if current_height == 0 || current_height <= last_height { + return Ok(()); + } + + let fungible_app_id = linera_client.fungible_app_id(); + + let mut blocks = Vec::new(); + let mut hash = info.block_hash; + while let Some(h) = hash { + let block = linera_client.read_confirmed_block(h).await?; + let height = block.block().header.height.0; + if height < last_height { + break; + } + hash = block.block().header.previous_block_hash; + blocks.push(block); + } + blocks.reverse(); + + let mut new_burns = Vec::new(); + for block in &blocks { + let height = block.block().header.height.0; + let credits = find_address20_credits(&block.block().body.transactions, fungible_app_id); + for (burn_index, (owner, amount)) in credits.into_iter().enumerate() { + new_burns.push((height, burn_index, format!("{owner}"), amount.to_string())); + } + } + + for (height, burn_index, recipient, amount) in &new_burns { + tracing::info!(height, burn_index, recipient, amount, "Discovered burn"); + let _ = pending_burn_tx.try_send(PendingBurn { + linera_height: *height, + burn_index: *burn_index, + evm_recipient: recipient.clone(), + amount: amount.clone(), + }); + } + + let mut state = monitor.write().await; + state.last_scanned_linera_height = current_height; + crate::relay::metrics::set_last_scanned_linera_height(current_height); + Ok(()) +} + +async fn check_burn_completion( + monitor: &RwLock, + evm_client: &EvmClient, +) -> anyhow::Result<()> { + let pending: Vec<(u64, usize, Address)> = { + let state = monitor.read().await; + state + .pending_burns() + .into_iter() + .filter_map(|b| { + let addr: Address = b.value.evm_recipient.parse().ok()?; + Some((b.value.linera_height, b.value.burn_index, addr)) + }) + .collect() + }; + + if pending.is_empty() { + return Ok(()); + } + + for (height, burn_index, recipient) in pending { + let logs = evm_client.get_transfer_logs(recipient).await?; + if !logs.is_empty() { + monitor.write().await.complete_burn(height, burn_index); + } + } + + Ok(()) +} diff --git a/linera-bridge/src/monitor/mod.rs b/linera-bridge/src/monitor/mod.rs new file mode 100644 index 000000000000..628cce37df7f --- /dev/null +++ b/linera-bridge/src/monitor/mod.rs @@ -0,0 +1,458 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Bridge monitoring: tracks in-flight EVM↔Linera bridging requests. +//! +//! Two background scan loops actively poll both chains: +//! - **EVM scan** ([`evm`]): queries `DepositInitiated` events, checks Linera for completion. +//! - **Linera scan** ([`linera`]): walks block history for Credit-to-Address20 messages, +//! checks EVM for completion via ERC-20 `Transfer` events. + +pub mod evm; +pub mod linera; + +use std::{ + collections::{hash_map::Entry, HashMap}, + sync::Arc, + time::{Duration, Instant}, +}; + +use alloy::primitives::{Address, B256, U256}; +use linera_base::identifiers::ApplicationId; +use linera_execution::{Query, QueryResponse}; +use tokio::sync::RwLock; + +use crate::proof::DepositKey; + +/// Queries the evm-bridge app to check whether a deposit has been processed on Linera. +pub async fn query_deposit_processed( + chain_client: &linera_core::client::ChainClient, + bridge_app_id: ApplicationId, + deposit_key: &DepositKey, +) -> anyhow::Result { + let hash_hex = format!("0x{}", hex::encode(deposit_key.hash())); + let gql = format!(r#"{{ isDepositProcessed(hash: "{hash_hex}") }}"#); + let query = Query::user_without_abi(bridge_app_id, &GqlRequest { query: gql })?; + let (outcome, _) = chain_client.query_application(query, None).await?; + let response_bytes = match outcome.response { + QueryResponse::User(bytes) => bytes, + other => anyhow::bail!("unexpected query response: {other:?}"), + }; + let response: serde_json::Value = serde_json::from_slice(&response_bytes)?; + Ok(response["data"]["isDepositProcessed"].as_bool() == Some(true)) +} + +/// A pending deposit detected by the EVM scanner, sent to the retry loop. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PendingDeposit { + pub key: DepositKey, + pub tx_hash: B256, + pub depositor: Address, + pub amount: U256, + pub nonce: U256, +} + +/// A pending burn detected by the Linera scanner, sent to the retry loop. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PendingBurn { + pub linera_height: u64, + pub burn_index: usize, + pub evm_recipient: String, + pub amount: String, +} + +/// Wraps a pending bridging request with tracking metadata. +#[derive(Debug, Clone, serde::Serialize)] +pub struct Tracked { + #[serde(flatten)] + pub value: T, + pub forwarded: bool, + pub failed: bool, + #[serde(skip)] + pub retry_count: u32, + #[serde(skip)] + pub last_retry_at: Option, +} + +impl Tracked { + fn new(value: T) -> Self { + Self { + value, + forwarded: false, + failed: false, + retry_count: 0, + last_retry_at: None, + } + } +} + +pub type TrackedDeposit = Tracked; +pub type TrackedBurn = Tracked; + +/// In-memory monitoring state shared across scan loops and HTTP handlers. +pub struct MonitorState { + pub(crate) deposits: HashMap, + pub(crate) burns: HashMap<(u64, usize), TrackedBurn>, + pub(crate) last_scanned_evm_block: u64, + pub(crate) last_scanned_linera_height: u64, +} + +impl MonitorState { + pub fn new(start_evm_block: u64) -> Self { + Self { + deposits: HashMap::new(), + burns: HashMap::new(), + last_scanned_evm_block: start_evm_block, + last_scanned_linera_height: 0, + } + } + + /// Tracks a deposit. Returns `true` if this is a newly discovered deposit. + /// Uses Entry API instead of insert() to avoid overwriting existing entries + /// that may have accumulated retry state. + pub fn track_deposit(&mut self, pending: PendingDeposit) -> bool { + match self.deposits.entry(pending.key.clone()) { + Entry::Occupied(_) => false, + Entry::Vacant(e) => { + e.insert(Tracked::new(pending)); + crate::relay::metrics::deposit_detected(); + true + } + } + } + + pub fn complete_deposit(&mut self, key: &DepositKey) { + if let Some(d) = self.deposits.get_mut(key) { + d.forwarded = true; + crate::relay::metrics::deposit_completed(); + } else { + tracing::warn!(deposit_id = ?key, "Attempted to complete unknown deposit"); + } + } + + /// Tracks a burn. Returns `true` if this is a newly discovered burn. + /// Uses Entry API instead of insert() to avoid overwriting existing entries + /// that may have accumulated retry state. + pub fn track_burn(&mut self, pending: PendingBurn) -> bool { + let key = (pending.linera_height, pending.burn_index); + match self.burns.entry(key) { + Entry::Occupied(_) => false, + Entry::Vacant(e) => { + e.insert(Tracked::new(pending)); + crate::relay::metrics::burn_detected(); + true + } + } + } + + pub fn complete_burn(&mut self, linera_height: u64, burn_index: usize) { + if let Some(b) = self.burns.get_mut(&(linera_height, burn_index)) { + b.forwarded = true; + crate::relay::metrics::burn_completed(); + } else { + tracing::warn!( + linera_height, + burn_index, + "Attempted to complete unknown burn" + ); + } + } + + pub fn all_deposits(&self) -> Vec<&TrackedDeposit> { + self.deposits.values().collect() + } + + pub fn pending_deposits(&self) -> Vec<&TrackedDeposit> { + self.deposits.values().filter(|d| !d.forwarded).collect() + } + + pub fn completed_deposits(&self) -> Vec<&TrackedDeposit> { + self.deposits.values().filter(|d| d.forwarded).collect() + } + + pub fn all_burns(&self) -> Vec<&TrackedBurn> { + self.burns.values().collect() + } + + pub fn pending_burns(&self) -> Vec<&TrackedBurn> { + self.burns.values().filter(|b| !b.forwarded).collect() + } + + pub fn completed_burns(&self) -> Vec<&TrackedBurn> { + self.burns.values().filter(|b| b.forwarded).collect() + } + + pub fn deposits_ready_for_retry(&self, max_retries: u32) -> Vec<&TrackedDeposit> { + self.deposits + .values() + .filter(|d| { + !d.forwarded + && !d.failed + && retry_eligible(d.retry_count, d.last_retry_at, max_retries) + }) + .collect() + } + + pub fn burns_ready_for_retry(&self, max_retries: u32) -> Vec<&TrackedBurn> { + self.burns + .values() + .filter(|b| { + !b.forwarded + && !b.failed + && retry_eligible(b.retry_count, b.last_retry_at, max_retries) + }) + .collect() + } + + pub fn mark_deposit_retried(&mut self, key: &DepositKey) { + if let Some(d) = self.deposits.get_mut(key) { + d.retry_count += 1; + d.last_retry_at = Some(Instant::now()); + } + } + + pub fn mark_deposit_failed(&mut self, key: &DepositKey) { + if let Some(d) = self.deposits.get_mut(key) { + d.failed = true; + crate::relay::metrics::deposit_failed(); + } + } + + pub fn mark_burn_retried(&mut self, height: u64, burn_index: usize) { + if let Some(b) = self.burns.get_mut(&(height, burn_index)) { + b.retry_count += 1; + b.last_retry_at = Some(Instant::now()); + } + } + + pub fn mark_burn_failed(&mut self, height: u64, burn_index: usize) { + if let Some(b) = self.burns.get_mut(&(height, burn_index)) { + b.failed = true; + crate::relay::metrics::burn_failed(); + } + } + + pub fn status_summary(&self) -> StatusSummary { + StatusSummary { + deposits_pending: self.deposits.values().filter(|d| !d.forwarded).count(), + deposits_completed: self.deposits.values().filter(|d| d.forwarded).count(), + burns_pending: self.burns.values().filter(|b| !b.forwarded).count(), + burns_forwarded: self.burns.values().filter(|b| b.forwarded).count(), + last_scanned_evm_block: self.last_scanned_evm_block, + last_scanned_linera_height: self.last_scanned_linera_height, + } + } +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct StatusSummary { + pub deposits_pending: usize, + pub deposits_completed: usize, + pub burns_pending: usize, + pub burns_forwarded: usize, + pub last_scanned_evm_block: u64, + pub last_scanned_linera_height: u64, +} + +/// Runs deposit and burn retry loops concurrently. +/// Returns if either encounters an unrecoverable error. +pub(crate) async fn retry_loop( + monitor: Arc>, + proof_client: crate::proof::gen::HttpDepositProofClient, + evm_client: Arc>, + linera_client: Arc>, + pending_deposit_rx: tokio::sync::mpsc::Receiver, + pending_burn_rx: tokio::sync::mpsc::Receiver, +) -> anyhow::Result<()> { + tokio::select! { + result = evm::retry_pending_deposits( + &monitor, &linera_client, &proof_client, pending_deposit_rx, + ) => result, + result = linera::retry_pending_burns( + &monitor, &evm_client, &linera_client, pending_burn_rx, + ) => result, + } +} + +/// GraphQL request body for application queries. +#[derive(serde::Serialize)] +struct GqlRequest { + query: String, +} + +/// Whether an item is eligible for retry based on exponential backoff. +/// Backoff schedule: 5s, 10s, 20s, 40s, 80s (capped). +fn retry_eligible(retry_count: u32, last_retry_at: Option, max_retries: u32) -> bool { + if retry_count >= max_retries { + return false; + } + let backoff = Duration::from_secs(5 * 2u64.pow(retry_count.min(4))); + match last_retry_at { + None => true, + Some(t) => t.elapsed() >= backoff, + } +} + +#[cfg(test)] +mod tests { + use alloy::primitives::{Address, B256, U256}; + + use super::*; + + #[test] + fn test_deposit_key_hash_matches_evm_bridge() { + let key = DepositKey { + source_chain_id: 8453, + block_hash: [0xAA; 32], + tx_index: 5, + log_index: 0, + }; + let hash = key.hash(); + assert_eq!(hash, key.hash()); + assert_ne!(hash, [0u8; 32]); + } + + #[test] + fn test_deposit_key_different_log_index_different_hash() { + let key1 = DepositKey { + source_chain_id: 8453, + block_hash: [0xAA; 32], + tx_index: 5, + log_index: 0, + }; + let key2 = DepositKey { + source_chain_id: 8453, + block_hash: [0xAA; 32], + tx_index: 5, + log_index: 1, + }; + assert_ne!(key1.hash(), key2.hash()); + } + + #[test] + fn test_monitor_state_track_and_complete() { + let mut state = MonitorState::new(0); + + let key = DepositKey { + source_chain_id: 8453, + block_hash: [0xAA; 32], + tx_index: 1, + log_index: 0, + }; + state.track_deposit(PendingDeposit { + key: key.clone(), + tx_hash: B256::ZERO, + depositor: Address::ZERO, + amount: U256::from(1000), + nonce: U256::from(0), + }); + + assert_eq!(state.pending_deposits().len(), 1); + assert_eq!(state.completed_deposits().len(), 0); + + state.complete_deposit(&key); + + assert_eq!(state.pending_deposits().len(), 0); + assert_eq!(state.completed_deposits().len(), 1); + } + + #[test] + fn test_monitor_state_track_and_forward_burn() { + let mut state = MonitorState::new(0); + + state.track_burn(PendingBurn { + linera_height: 10, + burn_index: 0, + evm_recipient: "0xabcd".to_string(), + amount: "500".to_string(), + }); + + assert_eq!(state.pending_burns().len(), 1); + assert_eq!(state.completed_burns().len(), 0); + + state.complete_burn(10, 0); + + assert_eq!(state.pending_burns().len(), 0); + assert_eq!(state.completed_burns().len(), 1); + } + + #[test] + fn test_status_summary() { + let mut state = MonitorState::new(100); + + let key = DepositKey { + source_chain_id: 1, + block_hash: [0; 32], + tx_index: 0, + log_index: 0, + }; + state.track_deposit(PendingDeposit { + key: key.clone(), + tx_hash: B256::ZERO, + depositor: Address::ZERO, + amount: U256::ZERO, + nonce: U256::ZERO, + }); + state.track_burn(PendingBurn { + linera_height: 5, + burn_index: 0, + evm_recipient: "0x1234".to_string(), + amount: "100".to_string(), + }); + + let summary = state.status_summary(); + assert_eq!(summary.deposits_pending, 1); + assert_eq!(summary.deposits_completed, 0); + assert_eq!(summary.burns_pending, 1); + assert_eq!(summary.burns_forwarded, 0); + assert_eq!(summary.last_scanned_evm_block, 100); + } + + #[test] + fn test_retry_eligible_first_attempt() { + assert!(retry_eligible(0, None, 10)); + } + + #[test] + fn test_retry_eligible_max_retries_exceeded() { + assert!(!retry_eligible(10, None, 10)); + } + + #[test] + fn test_retry_eligible_backoff_not_elapsed() { + let just_now = Instant::now(); + assert!(!retry_eligible(0, Some(just_now), 10)); + } + + #[test] + fn test_retry_eligible_backoff_elapsed() { + let long_ago = Instant::now() - Duration::from_secs(60); + assert!(retry_eligible(0, Some(long_ago), 10)); + } + + #[test] + fn test_deposits_ready_for_retry() { + let mut state = MonitorState::new(0); + let key = DepositKey { + source_chain_id: 1, + block_hash: [0; 32], + tx_index: 0, + log_index: 0, + }; + state.track_deposit(PendingDeposit { + key: key.clone(), + tx_hash: B256::ZERO, + depositor: Address::ZERO, + amount: U256::ZERO, + nonce: U256::ZERO, + }); + + assert_eq!(state.deposits_ready_for_retry(10).len(), 1); + + state.mark_deposit_retried(&key); + assert_eq!(state.deposits_ready_for_retry(10).len(), 0); + + state.mark_deposit_failed(&key); + assert_eq!(state.deposits_ready_for_retry(10).len(), 0); + } +} diff --git a/linera-bridge/src/proof/mod.rs b/linera-bridge/src/proof/mod.rs index 4c8f85f2c843..83362e066060 100644 --- a/linera-bridge/src/proof/mod.rs +++ b/linera-bridge/src/proof/mod.rs @@ -96,6 +96,32 @@ pub struct DepositEvent { pub nonce: U256, } +/// Replay-protection key for processed deposits. +/// +/// On-chain, only the [`DepositKey::hash`] is stored (32 bytes) rather than the +/// full struct, so the `processed_deposits` SetView uses `[u8; 32]`. +#[derive( + Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize, +)] +pub struct DepositKey { + pub source_chain_id: u64, + pub block_hash: [u8; 32], + pub tx_index: u64, + pub log_index: u64, +} + +impl DepositKey { + /// Deterministic keccak-256 hash of the deposit key fields. + pub fn hash(&self) -> [u8; 32] { + let mut data = [0u8; 56]; + data[0..8].copy_from_slice(&self.source_chain_id.to_le_bytes()); + data[8..40].copy_from_slice(&self.block_hash); + data[40..48].copy_from_slice(&self.tx_index.to_le_bytes()); + data[48..56].copy_from_slice(&self.log_index.to_le_bytes()); + keccak256(data).0 + } +} + /// Returns the keccak256 hash of the `DepositInitiated` event signature. pub fn deposit_event_signature() -> B256 { keccak256(b"DepositInitiated(address,uint256,bytes32,bytes32,bytes32,address,uint256,uint256)") diff --git a/linera-bridge/src/relay.rs b/linera-bridge/src/relay.rs deleted file mode 100644 index 3a3a23b0e5b1..000000000000 --- a/linera-bridge/src/relay.rs +++ /dev/null @@ -1,661 +0,0 @@ -// Copyright (c) Zefchain Labs, Inc. -// SPDX-License-Identifier: Apache-2.0 - -//! Relay server for the EVM↔Linera bridge demo. -//! -//! Responsibilities: -//! - **HTTP**: `POST /deposit` — generates MPT deposit proofs and submits `ProcessDeposit` -//! operations on the bridge chain. -//! - **Linera client**: manages a "bridge chain", listens for `NewIncomingBundle` notifications, -//! processes the inbox, and burns any Address20 credits so the EVM contract can release tokens. -//! - **EVM forwarder**: after processing inbox and burns, BCS-serializes the resulting certificates -//! and calls `FungibleBridge.addBlock(bytes)` on the EVM chain. - -use std::{path::Path, sync::Arc}; - -use alloy::{ - network::EthereumWallet, primitives::Address, providers::ProviderBuilder, - signers::local::PrivateKeySigner, sol, -}; -use anyhow::{Context as _, Result}; -use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::post, Json, Router}; -use futures::StreamExt as _; -use linera_base::{ - crypto::InMemorySigner, - data_types::Amount, - identifiers::{AccountOwner, ApplicationId, ChainId}, -}; -use linera_chain::data_types::Transaction; -use linera_client::{chain_listener::ClientContext as _, client_context::ClientContext}; -use linera_core::{client::ChainClient, wallet::PersistentWallet, worker::Reason}; -use linera_execution::{Message, Operation, WasmRuntime}; -use linera_faucet_client::Faucet; -use linera_persistent::Persist; -use linera_storage::DbStorage; -use linera_views::{ - backends::{ - lru_caching::LruCachingConfig, - rocks_db::{PathWithGuard, RocksDbDatabase, RocksDbSpawnMode, RocksDbStoreInternalConfig}, - }, - lru_prefix_cache::StorageCacheConfig, -}; -use tokio::sync::{mpsc, oneshot}; -use tower_http::cors::CorsLayer; - -use crate::proof::gen::{DepositProofClient, HttpDepositProofClient, ProofError}; - -// ── Alloy ABI for FungibleBridge.addBlock ── - -sol! { - #[sol(rpc)] - interface IFungibleBridge { - function addBlock(bytes calldata data) external; - } -} - -// ── BCS-compatible type matching evm_bridge::BridgeOperation ── - -/// Must match `evm_bridge::BridgeOperation` variant-for-variant for BCS compatibility. -#[derive(serde::Serialize)] -enum BridgeOperation { - ProcessDeposit { - block_header_rlp: Vec, - receipt_rlp: Vec, - proof_nodes: Vec>, - tx_index: u64, - log_index: u64, - }, -} - -// ── Channel types for deposit requests ── - -struct DepositRequest { - proof: crate::proof::gen::DepositProof, - response: oneshot::Sender>, -} - -// ── Shared state for the HTTP server ── - -struct AppState { - proof_client: HttpDepositProofClient, - deposit_tx: mpsc::Sender, -} - -// ── HTTP handlers ── - -#[derive(serde::Deserialize)] -struct DepositHttpRequest { - tx_hash: String, -} - -async fn deposit_handler( - State(state): State>, - Json(req): Json, -) -> impl IntoResponse { - tracing::info!(tx_hash = %req.tx_hash, "Received deposit request"); - - let tx_hash = match req.tx_hash.parse() { - Ok(h) => h, - Err(_) => { - tracing::error!(tx_hash = %req.tx_hash, "Invalid tx_hash format"); - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "invalid tx_hash"})), - ); - } - }; - - // Retry proof generation — on public testnets the RPC may not have - // indexed the receipt yet when the frontend sends the tx hash. - // Permanent errors (invalid tx, missing deposit event) fail immediately. - tracing::info!(%tx_hash, "Generating deposit proof..."); - let mut proof = None; - for attempt in 0..5 { - match state.proof_client.generate_deposit_proof(tx_hash).await { - Ok(p) => { - tracing::info!( - %tx_hash, - tx_index = p.tx_index, - log_count = p.log_indices.len(), - "Deposit proof generated" - ); - proof = Some(p); - break; - } - Err(ProofError::Permanent(e)) => { - tracing::error!(%tx_hash, "Deposit proof generation failed permanently: {e:#}"); - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": format!("{e:#}")})), - ); - } - Err(ProofError::Transient(e)) => { - if attempt < 4 { - tracing::warn!( - %tx_hash, attempt, "Deposit proof generation failed, retrying: {e:#}" - ); - tokio::time::sleep(std::time::Duration::from_secs(2 * (attempt + 1))).await; - } else { - tracing::error!(%tx_hash, "Deposit proof generation failed after 5 attempts: {e:#}"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("{e:#}")})), - ); - } - } - } - } - let proof = proof.unwrap(); - - tracing::info!(%tx_hash, "Sending deposit to processing channel..."); - let (resp_tx, resp_rx) = oneshot::channel(); - if state - .deposit_tx - .send(DepositRequest { - proof, - response: resp_tx, - }) - .await - .is_err() - { - tracing::error!(%tx_hash, "Relay deposit channel closed"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "relay channel closed"})), - ); - } - - match resp_rx.await { - Ok(Ok(())) => { - tracing::info!(%tx_hash, "Deposit processed successfully"); - (StatusCode::OK, Json(serde_json::json!({"status": "ok"}))) - } - Ok(Err(e)) => { - tracing::error!(%tx_hash, "Deposit processing failed: {e}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": e})), - ) - } - Err(_) => { - tracing::error!(%tx_hash, "Deposit response channel closed"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "channel closed"})), - ) - } - } -} - -// ── Helpers ── - -/// Extract (owner, amount) from a fungible Credit message if the target is Address20. -/// -/// BCS layout: variant 0 (Credit) + target: AccountOwner + amount: Amount + source: AccountOwner -fn try_parse_credit_to_address20(bytes: &[u8]) -> Option<(AccountOwner, Amount)> { - // Variant 0 = Credit - if bytes.first() != Some(&0) { - return None; - } - #[derive(serde::Deserialize)] - struct Credit { - target: AccountOwner, - amount: Amount, - _source: AccountOwner, - } - let credit: Credit = bcs::from_bytes(&bytes[1..]).ok()?; - if !matches!(credit.target, AccountOwner::Address20(_)) { - return None; - } - Some((credit.target, credit.amount)) -} - -/// BCS-serialize a WrappedFungibleOperation::Burn (variant index 7). -fn serialize_burn_operation(owner: &AccountOwner, amount: &Amount) -> Vec { - let mut buf = vec![7u8]; - buf.extend(bcs::to_bytes(owner).unwrap()); - buf.extend(bcs::to_bytes(amount).unwrap()); - buf -} - -// ── EVM forwarding helper ── - -/// BCS-serialize and forward a certified block to FungibleBridge on EVM. -async fn forward_cert_to_evm( - cert: &impl serde::Serialize, - bridge_addr: Address, - provider: &impl alloy::providers::Provider, -) { - let cert_bytes = match bcs::to_bytes(cert) { - Ok(b) => b, - Err(e) => { - tracing::error!("Failed to BCS-serialize certificate: {e}"); - return; - } - }; - - tracing::info!( - size = cert_bytes.len(), - "Calling addBlock on FungibleBridge..." - ); - - let bridge_contract = IFungibleBridge::new(bridge_addr, provider); - match bridge_contract.addBlock(cert_bytes.into()).send().await { - Ok(pending_tx) => match pending_tx.get_receipt().await { - Ok(receipt) => { - tracing::info!( - tx = ?receipt.transaction_hash, - "addBlock transaction confirmed" - ); - } - Err(e) => tracing::error!("addBlock receipt failed: {e}"), - }, - Err(e) => tracing::error!("addBlock send failed: {e}"), - } -} - -// ── RocksDB storage helper ── - -type RocksDbStorage = DbStorage; - -async fn create_rocksdb_storage(path: &Path, blob_cache_size: usize) -> Result { - let config = LruCachingConfig { - inner_config: RocksDbStoreInternalConfig { - path_with_guard: PathWithGuard::new(path.to_path_buf()), - spawn_mode: RocksDbSpawnMode::get_spawn_mode_from_runtime(), - max_stream_queries: 10, - }, - storage_cache_config: StorageCacheConfig { - max_cache_size: 10_000_000, - max_value_entry_size: 1_000_000, - max_find_keys_entry_size: 10_000_000, - max_find_key_values_entry_size: 10_000_000, - max_cache_entries: 1000, - max_cache_value_size: 10_000_000, - max_cache_find_keys_size: 10_000_000, - max_cache_find_key_values_size: 10_000_000, - }, - }; - let storage = DbStorage::::maybe_create_and_connect( - &config, - "bridge_relay", - Some(WasmRuntime::default()), - blob_cache_size, - ) - .await?; - Ok(storage) -} - -// ── Entry point ── - -#[allow(clippy::too_many_arguments)] -pub async fn run( - rpc_url: &str, - faucet_url: &str, - wallet_path: Option<&Path>, - keystore_path: Option<&Path>, - storage_config: Option<&str>, - chain_id_arg: Option, - evm_bridge_address: &str, - linera_bridge_address: &str, - linera_fungible_address: &str, - evm_private_key: &str, - port: u16, -) -> Result<()> { - tracing_subscriber::fmt::init(); - - // Tonic pulls in rustls 0.23 which requires an explicit crypto provider. - rustls::crypto::ring::default_provider() - .install_default() - .expect("failed to install rustls crypto provider"); - - tracing::info!("Starting bridge relay server..."); - - // ── Resolve paths (same defaults as linera binary: ~/.config/linera/) ── - let default_dir = dirs::config_dir() - .context("no config directory on this platform")? - .join("linera"); - let wallet_path = wallet_path - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| default_dir.join("wallet.json")); - let keystore_path = keystore_path - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| default_dir.join("keystore.json")); - let storage_path = storage_config - .map(|s| s.to_string()) - .unwrap_or_else(|| format!("rocksdb:{}", default_dir.join("wallet.db").display())); - - tracing::info!( - wallet = %wallet_path.display(), - keystore = %keystore_path.display(), - storage = %storage_path, - "Resolved paths" - ); - - // ── Common init ── - tracing::info!("Connecting to Linera faucet at {faucet_url}..."); - let faucet = Faucet::new(faucet_url.to_string()); - let genesis_config = faucet.genesis_config().await?; - tracing::info!("Genesis config received"); - - let mut signer: InMemorySigner = - linera_persistent::File::::read(&keystore_path) - .context("failed to read keystore")? - .into_value(); - - // Parse storage path: expect "rocksdb:/path/to/db" - let db_path = storage_path - .strip_prefix("rocksdb:") - .context("storage config must start with 'rocksdb:'")?; - let mut storage = create_rocksdb_storage(Path::new(db_path), blob_cache_size).await?; - - // ── Wallet: load existing or create fresh ── - let wallet_exists = wallet_path.exists(); - - // Always initialize storage — this is a no-op if already initialized. - genesis_config.initialize_storage(&mut storage).await?; - - let wallet = if wallet_exists { - tracing::info!("Loading existing wallet from {}", wallet_path.display()); - PersistentWallet::read(&wallet_path).context("failed to read wallet")? - } else { - tracing::info!("Creating new wallet at {}", wallet_path.display()); - PersistentWallet::create(&wallet_path, genesis_config).context("failed to create wallet")? - }; - - let admin_chain_id = wallet.genesis_config().admin_chain_id(); - let genesis_config = wallet.genesis_config().clone(); - let mut ctx = ClientContext::new( - storage, - wallet, - signer.clone(), - &Default::default(), - None, - genesis_config, - 10_000, - 10_000, - ) - .await?; - - // ── Sync admin chain (always) ── - tracing::info!(%admin_chain_id, "Syncing admin chain from validators..."); - let committee = faucet.current_committee().await?; - tracing::info!( - validators = committee.validators().iter().count(), - "Fetched current committee, downloading chain state..." - ); - let admin_client = ctx.make_chain_client(admin_chain_id).await?; - admin_client - .synchronize_chain_state_from_committee(committee) - .await?; - tracing::info!("Admin chain synced"); - - // ── Resolve bridge chain ── - let (chain_id, _owner) = if let Some(cid) = chain_id_arg { - // Register in wallet if not already there. - if ctx.wallet().get(cid).is_none() { - let key_owner = signer.keys().first().context("keystore has no keys")?.0; - ctx.update_wallet_for_new_chain( - cid, - Some(key_owner), - linera_base::data_types::Timestamp::default(), - linera_base::data_types::Epoch::ZERO, - ) - .await?; - } - - // Sync from validators. - let chain_client = ctx.make_chain_client(cid).await?; - chain_client.synchronize_from_validators().await?; - - // Verify our keystore contains an owner key for this chain. - let ownership = chain_client.query_chain_ownership().await?; - let our_keys: Vec = signer.keys().into_iter().map(|(o, _)| o).collect(); - let owner = our_keys - .into_iter() - .find(|o| ownership.super_owners.contains(o) || ownership.owners.contains_key(o)) - .context("keystore has no key that is an owner of the specified --chain-id")?; - tracing::info!(%cid, %owner, "Using pre-existing chain"); - (cid, owner) - } else { - // Claim from faucet. - tracing::info!("Claiming bridge chain from faucet..."); - let owner = AccountOwner::from(signer.generate_new()); - let chain_desc = faucet.claim(&owner).await?; - let cid = chain_desc.id(); - tracing::info!(%cid, %owner, "Chain claimed, extending wallet..."); - ctx.extend_with_chain(chain_desc, Some(owner)).await?; - - // Save updated keystore (has new key from generate_new). - let mut ks_file = linera_persistent::File::new(&keystore_path, signer.clone())?; - ks_file.persist().await?; - - // Sync bridge chain. - let chain_client = ctx.make_chain_client(cid).await?; - chain_client.synchronize_from_validators().await?; - tracing::info!(%cid, "Bridge chain claimed and synced"); - (cid, owner) - }; - - let chain_client = ctx.make_chain_client(chain_id).await?; - - Box::pin(serve_loop( - chain_client, - rpc_url, - evm_bridge_address, - linera_bridge_address, - linera_fungible_address, - evm_private_key, - port, - )) - .await -} - -// ── Main event loop ── - -#[allow(clippy::too_many_arguments)] -async fn serve_loop( - chain_client: ChainClient, - rpc_url: &str, - evm_bridge_address: &str, - linera_bridge_address: &str, - linera_fungible_address: &str, - evm_private_key: &str, - port: u16, -) -> Result<()> { - // ── Set up EVM provider ── - let bridge_addr: Address = evm_bridge_address - .parse() - .context("invalid --evm-bridge-address")?; - let evm_signer: PrivateKeySigner = - evm_private_key.parse().context("invalid EVM private key")?; - let evm_wallet = EthereumWallet::from(evm_signer); - let provider = ProviderBuilder::new() - .wallet(evm_wallet) - .with_simple_nonce_management() - .connect_http(rpc_url.parse().context("invalid RPC URL")?); - - // ── Parse app IDs ── - let bridge_app_id: ApplicationId = linera_bridge_address - .parse() - .context("invalid --linera-bridge-address")?; - let fungible_app_id: ApplicationId = linera_fungible_address - .parse() - .context("invalid --linera-fungible-address")?; - - // ── Start notification listener ── - let mut notifications = chain_client.subscribe()?; - let (listener, _abort_handle, _) = chain_client.listen().await?; - tokio::spawn(listener); - - // ── Start HTTP server ── - let proof_client = HttpDepositProofClient::new(rpc_url)?; - let (deposit_tx, mut deposit_rx) = mpsc::channel::(16); - let app_state = Arc::new(AppState { - proof_client, - deposit_tx, - }); - - let app = Router::new() - .route("/deposit", post(deposit_handler)) - .layer(CorsLayer::permissive()) - .with_state(app_state); - - let bind_addr = format!("0.0.0.0:{port}"); - tracing::info!("HTTP server listening on {bind_addr}"); - - let listener = tokio::net::TcpListener::bind(&bind_addr).await?; - tokio::spawn(async move { - if let Err(e) = axum::serve(listener, app).await { - tracing::error!("HTTP server error: {e}"); - } - }); - - tracing::info!( - %bridge_addr, - %bridge_app_id, - %fungible_app_id, - "Relay is ready" - ); - - // ── Main loop: process notifications + deposit requests ── - tracing::info!("Listening for notifications and deposit requests..."); - loop { - tokio::select! { - notification = notifications.next() => { - let notification = match notification { - Some(n) => n, - None => { - tracing::warn!("Notification stream ended, exiting"); - break; - } - }; - - if !matches!(notification.reason, Reason::NewIncomingBundle { .. }) { - continue; - } - - tracing::info!("Received NewIncomingBundle, processing inbox..."); - - if let Err(e) = chain_client.synchronize_from_validators().await { - tracing::error!("Failed to synchronize: {e}"); - continue; - } - - let certs = match chain_client.process_inbox().await { - Ok((certs, _)) => certs, - Err(e) => { - tracing::error!("Failed to process inbox: {e}"); - continue; - } - }; - - if certs.is_empty() { - tracing::info!("No certificates from inbox processing"); - continue; - } - - tracing::info!(count = certs.len(), "Processed inbox certificates"); - for cert in &certs { - forward_cert_to_evm(cert, bridge_addr, &provider).await; - } - - // Scan inbox certs for Credit messages to Address20 and submit Burns. - let mut burn_ops = vec![]; - for cert in &certs { - for txn in &cert.block().body.transactions { - if let Transaction::ReceiveMessages(bundle) = txn { - for posted in &bundle.bundle.messages { - if let Message::User { application_id, bytes } = &posted.message { - if application_id == &fungible_app_id { - if let Some((owner, amount)) = try_parse_credit_to_address20(bytes.as_slice()) { - burn_ops.push(Operation::User { - application_id: fungible_app_id, - bytes: serialize_burn_operation(&owner, &amount), - }); - } - } - } - } - } - } - } - - if !burn_ops.is_empty() { - tracing::info!(count = burn_ops.len(), "Submitting burn operations..."); - if let Err(e) = chain_client.synchronize_from_validators().await { - tracing::error!("Failed to synchronize before burn: {e}"); - continue; - } - match chain_client.execute_operations(burn_ops, vec![]).await { - Ok(linera_core::data_types::ClientOutcome::Committed(cert)) => { - tracing::info!( - height = %cert.block().header.height, - "Burn operations committed" - ); - forward_cert_to_evm(&cert, bridge_addr, &provider).await; - } - Ok(other) => tracing::error!("Burn not committed: {other:?}"), - Err(e) => tracing::error!("Burn submission failed: {e}"), - } - } - } - - Some(deposit_req) = deposit_rx.recv() => { - let result = async { - let proof = &deposit_req.proof; - - let operations: Vec<_> = proof.log_indices.iter().map(|&log_index| { - let op = BridgeOperation::ProcessDeposit { - block_header_rlp: proof.block_header_rlp.clone(), - receipt_rlp: proof.receipt_rlp.clone(), - proof_nodes: proof.proof_nodes.clone(), - tx_index: proof.tx_index, - log_index, - }; - let op_bytes = bcs::to_bytes(&op) - .expect("failed to BCS-serialize BridgeOperation"); - Operation::User { - application_id: bridge_app_id, - bytes: op_bytes, - } - }).collect(); - - tracing::info!( - count = operations.len(), - "Submitting ProcessDeposit operations on bridge chain..." - ); - - chain_client.synchronize_from_validators().await - .context("failed to synchronize")?; - - let outcome = chain_client - .execute_operations(operations, vec![]) - .await?; - let cert = match outcome { - linera_core::data_types::ClientOutcome::Committed(cert) => { - tracing::info!( - height = %cert.block().header.height, - "ProcessDeposit committed" - ); - cert - } - other => { - anyhow::bail!("ProcessDeposit not committed: {other:?}"); - } - }; - - forward_cert_to_evm(&cert, bridge_addr, &provider).await; - - Ok::<(), anyhow::Error>(()) - }.await; - - let _ = deposit_req.response.send( - result.map_err(|e| format!("{e:#}")) - ); - } - } - } - - Ok(()) -} diff --git a/linera-bridge/src/relay/evm.rs b/linera-bridge/src/relay/evm.rs new file mode 100644 index 000000000000..c9983b647734 --- /dev/null +++ b/linera-bridge/src/relay/evm.rs @@ -0,0 +1,119 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Centralized EVM client for all bridge EVM interactions. + +use alloy::{ + primitives::{Address, B256}, + providers::Provider, + rpc::types::{Filter, Log}, + sol, +}; +use anyhow::{Context as _, Result}; + +use crate::proof::deposit_event_signature; + +sol! { + #[sol(rpc)] + interface IFungibleBridge { + function addBlock(bytes calldata data) external; + } +} + +/// Must match `evm_bridge::BridgeOperation` variant-for-variant for BCS compatibility. +#[derive(serde::Serialize)] +pub(crate) enum BridgeOperation { + ProcessDeposit { + block_header_rlp: Vec, + receipt_rlp: Vec, + proof_nodes: Vec>, + tx_index: u64, + log_index: u64, + }, +} + +/// Maximum block range per `eth_getLogs` query. +const MAX_LOG_BLOCK_RANGE: u64 = 10_000; + +/// Centralized client for all EVM interactions. Safe to share via `Arc`. +pub struct EvmClient

{ + provider: P, + bridge_addr: Address, + deposit_event_sig: B256, +} + +impl EvmClient

{ + pub fn new(provider: P, bridge_addr: Address) -> Self { + Self { + provider, + bridge_addr, + deposit_event_sig: deposit_event_signature(), + } + } + + pub fn bridge_addr(&self) -> Address { + self.bridge_addr + } + + pub async fn get_block_number(&self) -> Result { + Ok(self.provider.get_block_number().await?) + } + + /// Queries `DepositInitiated` events in chunked ranges. + pub async fn get_deposit_logs(&self, from: u64, to: u64) -> Result> { + let filter_base = Filter::new() + .address(self.bridge_addr) + .event_signature(self.deposit_event_sig); + + let mut all_logs = Vec::new(); + let mut cursor = from; + while cursor <= to { + let chunk_end = (cursor + MAX_LOG_BLOCK_RANGE - 1).min(to); + let filter = filter_base.clone().from_block(cursor).to_block(chunk_end); + let logs = self.provider.get_logs(&filter).await?; + all_logs.extend(logs); + cursor = chunk_end + 1; + } + Ok(all_logs) + } + + /// Queries ERC-20 Transfer events from the bridge to a recipient. + pub async fn get_transfer_logs(&self, recipient: Address) -> Result> { + let transfer_sig = alloy::primitives::keccak256("Transfer(address,address,uint256)"); + let filter = Filter::new() + .address(self.bridge_addr) + .event_signature(transfer_sig) + .topic2(B256::left_padding_from(recipient.as_slice())); + Ok(self.provider.get_logs(&filter).await?) + } + + /// BCS-serialize and forward a certified block to FungibleBridge on EVM. + pub async fn forward_cert( + &self, + cert: &linera_chain::types::ConfirmedBlockCertificate, + ) -> Result<()> { + let cert_bytes = bcs::to_bytes(cert).context("failed to BCS-serialize certificate")?; + + tracing::info!( + size = cert_bytes.len(), + "Calling addBlock on FungibleBridge..." + ); + + let bridge_contract = IFungibleBridge::new(self.bridge_addr, &self.provider); + let pending_tx = bridge_contract + .addBlock(cert_bytes.into()) + .send() + .await + .context("addBlock send failed")?; + let receipt = pending_tx + .get_receipt() + .await + .context("addBlock receipt failed")?; + + tracing::info!( + tx = ?receipt.transaction_hash, + "addBlock transaction confirmed" + ); + Ok(()) + } +} diff --git a/linera-bridge/src/relay/linera.rs b/linera-bridge/src/relay/linera.rs new file mode 100644 index 000000000000..d448f4f17ed8 --- /dev/null +++ b/linera-bridge/src/relay/linera.rs @@ -0,0 +1,213 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Centralized Linera client for all bridge chain interactions. + +use anyhow::Result; +use linera_base::{ + crypto::CryptoHash, + data_types::Amount, + identifiers::{AccountOwner, ApplicationId}, +}; +use linera_chain::types::ConfirmedBlockCertificate; +use linera_core::client::ChainClient; +use tokio::sync::{mpsc, oneshot}; + +use crate::proof::DepositKey; + +/// A write operation to be executed on the bridge chain. +/// Sent to the main loop which serializes all chain mutations. +pub(crate) enum ChainOperation { + ProcessInbox { + response: oneshot::Sender, String>>, + }, + ProcessDeposit { + proof: crate::proof::gen::DepositProof, + response: oneshot::Sender>, + }, + Burn { + owner: AccountOwner, + amount: Amount, + response: oneshot::Sender>, + }, +} + +/// Centralized client for Linera chain interactions. +/// +/// Read operations use the `ChainClient` directly (safe on clones). +/// Write operations (block proposals) go through a channel to the main loop. +pub struct LineraClient { + chain_client: ChainClient, + op_tx: mpsc::Sender, + bridge_app_id: ApplicationId, + fungible_app_id: ApplicationId, +} + +impl LineraClient { + pub(crate) fn new( + chain_client: ChainClient, + op_tx: mpsc::Sender, + bridge_app_id: ApplicationId, + fungible_app_id: ApplicationId, + ) -> Self { + Self { + chain_client, + op_tx, + bridge_app_id, + fungible_app_id, + } + } + + pub fn bridge_app_id(&self) -> ApplicationId { + self.bridge_app_id + } + + pub fn fungible_app_id(&self) -> ApplicationId { + self.fungible_app_id + } + + // ── Read operations (safe on cloned chain_client) ── + + pub async fn sync(&self) -> Result<()> { + self.chain_client + .synchronize_from_validators() + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(()) + } + + pub async fn chain_info(&self) -> Result> { + Ok(self + .chain_client + .chain_info() + .await + .map_err(|e| anyhow::anyhow!(e))?) + } + + pub async fn read_confirmed_block( + &self, + hash: CryptoHash, + ) -> Result { + Ok(self + .chain_client + .read_confirmed_block(hash) + .await + .map_err(|e| anyhow::anyhow!(e))?) + } + + pub async fn read_certificate(&self, hash: CryptoHash) -> Result { + Ok(self + .chain_client + .read_certificate(hash) + .await + .map_err(|e| anyhow::anyhow!(e))?) + } + + pub async fn query_deposit_processed(&self, deposit_key: &DepositKey) -> Result { + crate::monitor::query_deposit_processed(&self.chain_client, self.bridge_app_id, deposit_key) + .await + } + + // ── Write operations (sent to main loop via channel) ── + + pub async fn process_deposit(&self, proof: crate::proof::gen::DepositProof) -> Result<()> { + let (resp_tx, resp_rx) = oneshot::channel(); + self.op_tx + .send(ChainOperation::ProcessDeposit { + proof, + response: resp_tx, + }) + .await + .map_err(|_| anyhow::anyhow!("Chain operation channel closed"))?; + resp_rx + .await + .map_err(|_| anyhow::anyhow!("Response channel closed"))? + .map_err(|e| anyhow::anyhow!(e)) + } + + pub async fn burn( + &self, + owner: AccountOwner, + amount: Amount, + ) -> Result { + let (resp_tx, resp_rx) = oneshot::channel(); + self.op_tx + .send(ChainOperation::Burn { + owner, + amount, + response: resp_tx, + }) + .await + .map_err(|_| anyhow::anyhow!("Chain operation channel closed"))?; + resp_rx + .await + .map_err(|_| anyhow::anyhow!("Response channel closed"))? + .map_err(|e| anyhow::anyhow!(e)) + } + + pub async fn process_inbox(&self) -> Result> { + let (resp_tx, resp_rx) = oneshot::channel(); + self.op_tx + .send(ChainOperation::ProcessInbox { response: resp_tx }) + .await + .map_err(|_| anyhow::anyhow!("Chain operation channel closed"))?; + resp_rx + .await + .map_err(|_| anyhow::anyhow!("Response channel closed"))? + .map_err(|e| anyhow::anyhow!(e)) + } +} + +impl Clone for LineraClient { + fn clone(&self) -> Self { + Self { + chain_client: self.chain_client.clone(), + op_tx: self.op_tx.clone(), + bridge_app_id: self.bridge_app_id, + fungible_app_id: self.fungible_app_id, + } + } +} + +/// Find all Credit-to-Address20 messages in a block's transactions for a given app. +pub(crate) fn find_address20_credits( + transactions: &[linera_chain::data_types::Transaction], + fungible_app_id: ApplicationId, +) -> Vec<(AccountOwner, Amount)> { + let mut credits = Vec::new(); + for txn in transactions { + if let linera_chain::data_types::Transaction::ReceiveMessages(bundle) = txn { + for posted in &bundle.bundle.messages { + if let linera_execution::Message::User { + application_id, + bytes, + } = &posted.message + { + if *application_id == fungible_app_id { + if let Some(credit) = try_parse_credit_to_address20(bytes.as_slice()) { + credits.push(credit); + } + } + } + } + } + } + credits +} + +/// BCS-serialize a Burn operation. +pub(crate) fn serialize_burn_operation(owner: &AccountOwner, amount: &Amount) -> Vec { + bcs::to_bytes(&wrapped_fungible::WrappedFungibleOperation::Burn { + owner: *owner, + amount: *amount, + }) + .expect("failed to BCS-serialize Burn operation") +} + +fn try_parse_credit_to_address20(bytes: &[u8]) -> Option<(AccountOwner, Amount)> { + if let Ok(fungible::Message::Credit { target, amount, .. }) = bcs::from_bytes(bytes) { + matches!(target, AccountOwner::Address20(_)).then_some((target, amount)) + } else { + None + } +} diff --git a/linera-bridge/src/relay/metrics.rs b/linera-bridge/src/relay/metrics.rs new file mode 100644 index 000000000000..bf0e36aac251 --- /dev/null +++ b/linera-bridge/src/relay/metrics.rs @@ -0,0 +1,135 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Prometheus metrics for the bridge relay. + +use std::sync::LazyLock; + +use axum::{http::StatusCode, response::IntoResponse, routing::get, Router}; +use prometheus::{IntCounter, IntGauge, Opts, TextEncoder}; +use tower_http::cors::CorsLayer; + +static DEPOSITS_DETECTED: LazyLock = LazyLock::new(|| { + let opts = Opts::new( + "bridge_deposits_detected", + "Total deposits found by EVM scanner", + ) + .namespace("linera"); + prometheus::register_int_counter!(opts).unwrap() +}); + +static DEPOSITS_COMPLETED: LazyLock = LazyLock::new(|| { + let opts = + Opts::new("bridge_deposits_completed", "Deposits confirmed on Linera").namespace("linera"); + prometheus::register_int_counter!(opts).unwrap() +}); + +static DEPOSITS_PENDING: LazyLock = LazyLock::new(|| { + let opts = + Opts::new("bridge_deposits_pending", "Currently pending deposits").namespace("linera"); + prometheus::register_int_gauge!(opts).unwrap() +}); + +static DEPOSITS_FAILED: LazyLock = LazyLock::new(|| { + let opts = + Opts::new("bridge_deposits_failed", "Permanently failed deposits").namespace("linera"); + prometheus::register_int_gauge!(opts).unwrap() +}); + +static BURNS_DETECTED: LazyLock = LazyLock::new(|| { + let opts = Opts::new( + "bridge_burns_detected", + "Total burns found by Linera scanner", + ) + .namespace("linera"); + prometheus::register_int_counter!(opts).unwrap() +}); + +static BURNS_COMPLETED: LazyLock = LazyLock::new(|| { + let opts = Opts::new("bridge_burns_completed", "Burns forwarded to EVM").namespace("linera"); + prometheus::register_int_counter!(opts).unwrap() +}); + +static BURNS_PENDING: LazyLock = LazyLock::new(|| { + let opts = Opts::new("bridge_burns_pending", "Currently pending burns").namespace("linera"); + prometheus::register_int_gauge!(opts).unwrap() +}); + +static BURNS_FAILED: LazyLock = LazyLock::new(|| { + let opts = Opts::new("bridge_burns_failed", "Permanently failed burns").namespace("linera"); + prometheus::register_int_gauge!(opts).unwrap() +}); + +static LAST_SCANNED_EVM_BLOCK: LazyLock = LazyLock::new(|| { + let opts = Opts::new( + "bridge_last_scanned_evm_block", + "Last scanned EVM block number", + ) + .namespace("linera"); + prometheus::register_int_gauge!(opts).unwrap() +}); + +static LAST_SCANNED_LINERA_HEIGHT: LazyLock = LazyLock::new(|| { + let opts = Opts::new( + "bridge_last_scanned_linera_height", + "Last scanned Linera block height", + ) + .namespace("linera"); + prometheus::register_int_gauge!(opts).unwrap() +}); + +pub(crate) fn deposit_detected() { + DEPOSITS_DETECTED.inc(); + DEPOSITS_PENDING.inc(); +} + +pub(crate) fn deposit_completed() { + DEPOSITS_COMPLETED.inc(); + DEPOSITS_PENDING.dec(); +} + +pub(crate) fn deposit_failed() { + DEPOSITS_PENDING.dec(); + DEPOSITS_FAILED.inc(); +} + +pub(crate) fn burn_detected() { + BURNS_DETECTED.inc(); + BURNS_PENDING.inc(); +} + +pub(crate) fn burn_completed() { + BURNS_COMPLETED.inc(); + BURNS_PENDING.dec(); +} + +pub(crate) fn burn_failed() { + BURNS_PENDING.dec(); + BURNS_FAILED.inc(); +} + +pub(crate) fn set_last_scanned_evm_block(block: u64) { + LAST_SCANNED_EVM_BLOCK.set(block as i64); +} + +pub(crate) fn set_last_scanned_linera_height(height: u64) { + LAST_SCANNED_LINERA_HEIGHT.set(height as i64); +} + +pub(crate) fn build_router() -> Router { + Router::new() + .route("/metrics", get(serve_metrics)) + .layer(CorsLayer::permissive()) +} + +async fn serve_metrics() -> impl IntoResponse { + let metric_families = prometheus::gather(); + match TextEncoder::new().encode_to_string(&metric_families) { + Ok(text) => (StatusCode::OK, text).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to encode metrics: {e}"), + ) + .into_response(), + } +} diff --git a/linera-bridge/src/relay/mod.rs b/linera-bridge/src/relay/mod.rs new file mode 100644 index 000000000000..6f70dcd4a194 --- /dev/null +++ b/linera-bridge/src/relay/mod.rs @@ -0,0 +1,523 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Relay server for the EVM↔Linera bridge demo. +//! +//! Responsibilities: +//! - **HTTP**: `POST /deposit` — generates MPT deposit proofs and submits `ProcessDeposit` +//! operations on the bridge chain. +//! - **Linera client**: manages a "bridge chain", listens for `NewIncomingBundle` notifications, +//! processes the inbox, and burns any Address20 credits so the EVM contract can release tokens. +//! - **EVM forwarder**: after processing inbox and burns, BCS-serializes the resulting certificates +//! and calls `FungibleBridge.addBlock(bytes)` on the EVM chain. + +pub mod evm; +pub mod linera; +pub(crate) mod metrics; + +use std::{path::Path, sync::Arc, time::Duration}; + +use alloy::{ + network::EthereumWallet, primitives::Address, providers::ProviderBuilder, + signers::local::PrivateKeySigner, +}; +use anyhow::{Context as _, Result}; +use futures::StreamExt as _; +use linera_base::{ + crypto::InMemorySigner, + identifiers::{AccountOwner, ApplicationId, ChainId}, +}; +use linera_client::{chain_listener::ClientContext as _, client_context::ClientContext}; +use linera_core::{client::ChainClient, wallet::PersistentWallet, worker::Reason}; +use linera_execution::{Operation, WasmRuntime}; +use linera_faucet_client::Faucet; +use linera_persistent::Persist; +use linera_storage::DbStorage; +use linera_views::{ + backends::{ + lru_caching::LruCachingConfig, + rocks_db::{PathWithGuard, RocksDbDatabase, RocksDbSpawnMode, RocksDbStoreInternalConfig}, + }, + lru_prefix_cache::StorageCacheConfig, +}; +use tokio::sync::{mpsc, RwLock}; + +use crate::{ + monitor::{self, MonitorState}, + proof::gen::HttpDepositProofClient, +}; + +#[allow(clippy::too_many_arguments)] +pub async fn run( + rpc_url: &str, + faucet_url: &str, + wallet_path: Option<&Path>, + keystore_path: Option<&Path>, + storage_config: Option<&str>, + chain_id_arg: Option, + evm_bridge_address: &str, + linera_bridge_address: &str, + linera_fungible_address: &str, + evm_private_key: &str, + port: u16, + cache_sizes: linera_storage::StorageCacheSizes, + monitor_scan_interval: u64, + monitor_start_block: u64, + max_retries: u32, +) -> Result<()> { + tracing_subscriber::fmt::init(); + + // Tonic pulls in rustls 0.23 which requires an explicit crypto provider. + rustls::crypto::ring::default_provider() + .install_default() + .expect("failed to install rustls crypto provider"); + + tracing::info!("Starting bridge relay server..."); + + // ── Resolve paths (same defaults as linera binary: ~/.config/linera/) ── + let default_dir = dirs::config_dir() + .context("no config directory on this platform")? + .join("linera"); + let wallet_path = + wallet_path.map_or_else(|| default_dir.join("wallet.json"), |p| p.to_path_buf()); + let keystore_path = + keystore_path.map_or_else(|| default_dir.join("keystore.json"), |p| p.to_path_buf()); + let storage_path = storage_config.map_or_else( + || format!("rocksdb:{}", default_dir.join("wallet.db").display()), + |s| s.to_string(), + ); + + tracing::info!( + wallet = %wallet_path.display(), + keystore = %keystore_path.display(), + storage = %storage_path, + "Resolved paths" + ); + + // ── Common init ── + tracing::info!("Connecting to Linera faucet at {faucet_url}..."); + let faucet = Faucet::new(faucet_url.to_string()); + let genesis_config = faucet.genesis_config().await?; + tracing::info!("Genesis config received"); + + let mut signer: InMemorySigner = + linera_persistent::File::::read(&keystore_path) + .context("failed to read keystore")? + .into_value(); + + // Parse storage path: expect "rocksdb:/path/to/db" + let db_path = storage_path + .strip_prefix("rocksdb:") + .context("storage config must start with 'rocksdb:'")?; + let mut storage = create_rocksdb_storage(Path::new(db_path), cache_sizes).await?; + + // ── Wallet: load existing or create fresh ── + let wallet_exists = wallet_path.exists(); + + // Always initialize storage — this is a no-op if already initialized. + genesis_config.initialize_storage(&mut storage).await?; + + let wallet = if wallet_exists { + tracing::info!("Loading existing wallet from {}", wallet_path.display()); + PersistentWallet::read(&wallet_path).context("failed to read wallet")? + } else { + tracing::info!("Creating new wallet at {}", wallet_path.display()); + PersistentWallet::create(&wallet_path, genesis_config).context("failed to create wallet")? + }; + + let admin_chain_id = wallet.genesis_config().admin_chain_id(); + let genesis_config = wallet.genesis_config().clone(); + let mut ctx = ClientContext::new( + storage, + wallet, + signer.clone(), + &Default::default(), + None, + genesis_config, + linera_core::worker::DEFAULT_BLOCK_CACHE_SIZE, + linera_core::worker::DEFAULT_EXECUTION_STATE_CACHE_SIZE, + ) + .await?; + + // ── Sync admin chain (always) ── + tracing::info!(%admin_chain_id, "Syncing admin chain from validators..."); + let committee = faucet.current_committee().await?; + tracing::info!( + validators = committee.validators().iter().count(), + "Fetched current committee, downloading chain state..." + ); + let admin_client = ctx.make_chain_client(admin_chain_id).await?; + admin_client + .synchronize_chain_state_from_committee(committee) + .await?; + tracing::info!("Admin chain synced"); + + // ── Resolve bridge chain ── + let (chain_id, _owner) = if let Some(cid) = chain_id_arg { + // Register in wallet if not already there. + if ctx.wallet().get(cid).is_none() { + let key_owner = signer.keys().first().context("keystore has no keys")?.0; + ctx.update_wallet_for_new_chain( + cid, + Some(key_owner), + linera_base::data_types::Timestamp::default(), + linera_base::data_types::Epoch::ZERO, + ) + .await?; + } + + // Register for notifications so the listener can connect to validators. + ctx.client + .extend_chain_mode(cid, linera_core::client::ListeningMode::FullChain); + + // Sync from validators. + let chain_client = ctx.make_chain_client(cid).await?; + chain_client.synchronize_from_validators().await?; + + // Verify our keystore contains an owner key for this chain. + let ownership = chain_client.query_chain_ownership().await?; + let our_keys: Vec = signer.keys().into_iter().map(|(o, _)| o).collect(); + let owner = our_keys + .into_iter() + .find(|o| ownership.super_owners.contains(o) || ownership.owners.contains_key(o)) + .context("keystore has no key that is an owner of the specified --chain-id")?; + tracing::info!(%cid, %owner, "Using pre-existing chain"); + (cid, owner) + } else { + // Claim from faucet. + tracing::info!("Claiming bridge chain from faucet..."); + let owner = AccountOwner::from(signer.generate_new()); + let chain_desc = faucet.claim(&owner).await?; + let cid = chain_desc.id(); + tracing::info!(%cid, %owner, "Chain claimed, extending wallet..."); + ctx.extend_with_chain(chain_desc, Some(owner)).await?; + + // Save updated keystore (has new key from generate_new). + let mut ks_file = linera_persistent::File::new(&keystore_path, signer.clone())?; + ks_file.persist().await?; + + // Sync bridge chain. + let chain_client = ctx.make_chain_client(cid).await?; + chain_client.synchronize_from_validators().await?; + tracing::info!(%cid, "Bridge chain claimed and synced"); + (cid, owner) + }; + + let chain_client = ctx.make_chain_client(chain_id).await?; + + Box::pin(serve_loop( + chain_client, + rpc_url, + evm_bridge_address, + linera_bridge_address, + linera_fungible_address, + evm_private_key, + port, + monitor_scan_interval, + monitor_start_block, + max_retries, + )) + .await +} + +type RocksDbStorage = DbStorage; + +async fn create_rocksdb_storage( + path: &Path, + cache_sizes: linera_storage::StorageCacheSizes, +) -> Result { + let config = LruCachingConfig { + inner_config: RocksDbStoreInternalConfig { + path_with_guard: PathWithGuard::new(path.to_path_buf()), + spawn_mode: RocksDbSpawnMode::get_spawn_mode_from_runtime(), + max_stream_queries: 10, + }, + storage_cache_config: StorageCacheConfig { + max_cache_size: 10_000_000, + max_value_entry_size: 1_000_000, + max_find_keys_entry_size: 10_000_000, + max_find_key_values_entry_size: 10_000_000, + max_cache_entries: 1000, + max_cache_value_size: 10_000_000, + max_cache_find_keys_size: 10_000_000, + max_cache_find_key_values_size: 10_000_000, + }, + }; + let storage = DbStorage::::maybe_create_and_connect( + &config, + "bridge_relay", + Some(WasmRuntime::default()), + cache_sizes, + ) + .await?; + Ok(storage) +} + +#[allow(clippy::too_many_arguments)] +async fn serve_loop( + chain_client: ChainClient, + rpc_url: &str, + evm_bridge_address: &str, + linera_bridge_address: &str, + linera_fungible_address: &str, + evm_private_key: &str, + port: u16, + monitor_scan_interval: u64, + monitor_start_block: u64, + max_retries: u32, +) -> Result<()> { + // ── Set up centralized clients ── + let bridge_addr: Address = evm_bridge_address + .parse() + .context("invalid --evm-bridge-address")?; + let evm_signer: PrivateKeySigner = + evm_private_key.parse().context("invalid EVM private key")?; + let evm_wallet = EthereumWallet::from(evm_signer); + let provider = ProviderBuilder::new() + .wallet(evm_wallet) + .with_simple_nonce_management() + .connect_http(rpc_url.parse().context("invalid RPC URL")?); + + let evm_client = Arc::new(evm::EvmClient::new(provider, bridge_addr)); + + let bridge_app_id: ApplicationId = linera_bridge_address + .parse() + .context("invalid --linera-bridge-address")?; + let fungible_app_id: ApplicationId = linera_fungible_address + .parse() + .context("invalid --linera-fungible-address")?; + + let (op_tx, mut op_rx) = mpsc::channel::(16); + let linera_client = Arc::new(linera::LineraClient::new( + chain_client.clone(), + op_tx, + bridge_app_id, + fungible_app_id, + )); + + // ── Start notification listener ── + let mut notifications = chain_client.subscribe()?; + let (listener, _abort_handle, _) = chain_client.listen().await?; + let chain_listener_handle = tokio::spawn(listener); + + // ── Monitor state + scan/retry ── + let monitor = Arc::new(RwLock::new(MonitorState::new(monitor_start_block))); + let scan_interval = Duration::from_secs(monitor_scan_interval); + let (pending_deposit_tx, pending_deposit_rx) = + tokio::sync::mpsc::channel::(64); + let (pending_burn_tx, pending_burn_rx) = tokio::sync::mpsc::channel::(64); + + let evm_scan_handle = { + let monitor = Arc::clone(&monitor); + let evm_client = Arc::clone(&evm_client); + let linera_client = Arc::clone(&linera_client); + tokio::spawn(monitor::evm::evm_scan_loop( + monitor, + evm_client, + linera_client, + pending_deposit_tx, + scan_interval, + max_retries, + )) + }; + let linera_scan_handle = { + let monitor = Arc::clone(&monitor); + let evm_client = Arc::clone(&evm_client); + let linera_client = Arc::clone(&linera_client); + tokio::spawn(monitor::linera::linera_scan_loop( + monitor, + evm_client, + linera_client, + pending_burn_tx, + scan_interval, + max_retries, + )) + }; + + let retry_handle = { + let monitor = Arc::clone(&monitor); + let evm_client = Arc::clone(&evm_client); + let linera_client = Arc::clone(&linera_client); + let proof_client = HttpDepositProofClient::new(rpc_url)?; + tokio::spawn(monitor::retry_loop( + monitor, + proof_client, + evm_client, + linera_client, + pending_deposit_rx, + pending_burn_rx, + )) + }; + + let app = metrics::build_router(); + + let bind_addr = format!("0.0.0.0:{port}"); + tracing::info!("HTTP server listening on {bind_addr}"); + + let tcp_listener = tokio::net::TcpListener::bind(&bind_addr).await?; + let http_server_handle = tokio::spawn(async move { + axum::serve(tcp_listener, app) + .await + .context("HTTP server error") + }); + + tracing::info!( + %bridge_addr, + %bridge_app_id, + %fungible_app_id, + "Relay is ready" + ); + + // ── Main loop: process chain operations + notifications ── + tracing::info!("Listening for chain operations and notifications..."); + let mut chain_listener_handle = chain_listener_handle; + let mut evm_scan_handle = evm_scan_handle; + let mut linera_scan_handle = linera_scan_handle; + let mut retry_handle = retry_handle; + let mut http_server_handle = http_server_handle; + loop { + tokio::select! { + result = &mut chain_listener_handle => { + anyhow::bail!("Chain listener exited unexpectedly: {result:?}"); + } + result = &mut evm_scan_handle => { + anyhow::bail!("EVM scan loop exited unexpectedly: {result:?}"); + } + result = &mut linera_scan_handle => { + anyhow::bail!("Linera scan loop exited unexpectedly: {result:?}"); + } + result = &mut retry_handle => { + anyhow::bail!("Retry loop exited unexpectedly: {result:?}"); + } + result = &mut http_server_handle => { + anyhow::bail!("HTTP server exited unexpectedly: {result:?}"); + } + notification = notifications.next() => { + let notification = match notification { + Some(n) => n, + None => { + tracing::warn!("Notification stream ended, exiting"); + break; + } + }; + + if !matches!(notification.reason, Reason::NewIncomingBundle { .. }) { + continue; + } + + tracing::info!("Received NewIncomingBundle, processing inbox..."); + + if let Err(e) = chain_client.synchronize_from_validators().await { + tracing::error!("Failed to synchronize: {e}"); + continue; + } + + let certs = match chain_client.process_inbox().await { + Ok((certs, _)) => certs, + Err(e) => { + tracing::error!("Failed to process inbox: {e}"); + continue; + } + }; + + if certs.is_empty() { + tracing::debug!("No certificates from inbox processing"); + continue; + } + + tracing::info!(count = certs.len(), "Processed inbox certificates"); + } + + Some(op) = op_rx.recv() => { + match op { + linera::ChainOperation::ProcessInbox { response } => { + let result = async { + chain_client.synchronize_from_validators().await + .context("failed to synchronize")?; + let (certs, _) = chain_client.process_inbox().await?; + Ok(certs) + }.await; + let _ = response.send(result.map_err(|e: anyhow::Error| format!("{e:#}"))); + } + linera::ChainOperation::ProcessDeposit { proof, response } => { + let result = async { + let operations: Vec<_> = proof.log_indices.iter().map(|&log_index| { + let op = evm::BridgeOperation::ProcessDeposit { + block_header_rlp: proof.block_header_rlp.clone(), + receipt_rlp: proof.receipt_rlp.clone(), + proof_nodes: proof.proof_nodes.clone(), + tx_index: proof.tx_index, + log_index, + }; + let op_bytes = bcs::to_bytes(&op) + .expect("failed to BCS-serialize BridgeOperation"); + Operation::User { + application_id: bridge_app_id, + bytes: op_bytes, + } + }).collect(); + + tracing::info!( + count = operations.len(), + "Submitting ProcessDeposit operations..." + ); + + chain_client.synchronize_from_validators().await + .context("failed to synchronize")?; + + let outcome = chain_client + .execute_operations(operations, vec![]) + .await?; + match outcome { + linera_core::data_types::ClientOutcome::Committed(cert) => { + tracing::info!( + height = %cert.block().header.height, + "ProcessDeposit committed" + ); + } + other => { + anyhow::bail!("ProcessDeposit not committed: {other:?}"); + } + }; + Ok(()) + }.await; + let _ = response.send(result.map_err(|e: anyhow::Error| format!("{e:#}"))); + } + linera::ChainOperation::Burn { owner, amount, response } => { + let result = async { + let burn_bytes = linera::serialize_burn_operation(&owner, &amount); + let burn_op = Operation::User { + application_id: fungible_app_id, + bytes: burn_bytes, + }; + + tracing::info!("Submitting Burn operation..."); + + chain_client.synchronize_from_validators().await + .context("failed to synchronize")?; + + let outcome = chain_client + .execute_operations(vec![burn_op], vec![]) + .await?; + match outcome { + linera_core::data_types::ClientOutcome::Committed(cert) => { + tracing::info!( + height = %cert.block().header.height, + "Burn operation committed" + ); + Ok(cert) + } + other => { + anyhow::bail!("Burn not committed: {other:?}"); + } + } + }.await; + let _ = response.send(result.map_err(|e: anyhow::Error| format!("{e:#}"))); + } + } + } + } + } + + Ok(()) +} diff --git a/linera-bridge/tests/e2e/Cargo.lock b/linera-bridge/tests/e2e/Cargo.lock index cb32855d69bc..1e86e85a7a84 100644 --- a/linera-bridge/tests/e2e/Cargo.lock +++ b/linera-bridge/tests/e2e/Cargo.lock @@ -2651,6 +2651,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "fs4" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7e180ac76c23b45e767bd7ae9579bc0bb458618c4bc71835926e098e61d15f8" +dependencies = [ + "rustix 0.38.44", + "windows-sys 0.52.0", +] + [[package]] name = "fungible" version = "0.1.0" @@ -3637,6 +3647,7 @@ dependencies = [ "linera-base", "linera-execution", "op-alloy-network", + "serde", "thiserror 1.0.69", "tokio", "url", @@ -3656,11 +3667,14 @@ dependencies = [ "linera-core", "linera-execution", "linera-faucet-client", + "linera-persistent", "linera-storage", "linera-views", "rand 0.8.5", + "reqwest 0.12.28", "serde", "serde_json", + "tempfile", "testcontainers", "tokio", "tracing", @@ -3668,6 +3682,17 @@ dependencies = [ "wrapped-fungible", ] +[[package]] +name = "linera-cache" +version = "0.15.15" +dependencies = [ + "cfg_aliases", + "linera-base", + "lru 0.15.0", + "prometheus", + "quick_cache", +] + [[package]] name = "linera-chain" version = "0.16.0" @@ -3742,12 +3767,12 @@ dependencies = [ "custom_debug_derive", "futures", "linera-base", + "linera-cache", "linera-chain", "linera-execution", "linera-storage", "linera-version", "linera-views", - "lru 0.15.0", "papaya", "prometheus", "rand 0.8.5", @@ -3824,6 +3849,23 @@ version = "0.45.1-linera.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9198100e9ce61acd3c714a2e61eb19fc5b8e2178dd645e2d9061e61e6e1feef" +[[package]] +name = "linera-persistent" +version = "0.15.15" +dependencies = [ + "cfg-if", + "cfg_aliases", + "derive_more 1.0.0", + "fs-err", + "fs4", + "serde", + "serde_json", + "thiserror 1.0.69", + "thiserror-context", + "tracing", + "trait-variant", +] + [[package]] name = "linera-rpc" version = "0.16.0" @@ -3910,6 +3952,7 @@ dependencies = [ "futures", "itertools 0.14.0", "linera-base", + "linera-cache", "linera-chain", "linera-execution", "linera-views", @@ -3996,9 +4039,9 @@ dependencies = [ [[package]] name = "linera-wasmer" -version = "4.4.0-linera.7" +version = "4.4.0-linera.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6652182476826343f0dd1e76a184ad34bcee57650a9c00c77574b993dd30529" +checksum = "6453ccd433866100d587f3db5ffb6c2116ebbcb97891df4197c1038708a551ba" dependencies = [ "bytes", "cfg-if", @@ -4026,9 +4069,9 @@ dependencies = [ [[package]] name = "linera-wasmer-compiler" -version = "4.4.0-linera.7" +version = "4.4.0-linera.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4781ce9fc4a892c9a9727f51ec92d19e1c5b54259da21573671aa49211ae80f" +checksum = "37d7e7b04c3cd3b94eccf13a1b57f631a5339dbf719e58618d03f30f52ff75e4" dependencies = [ "backtrace", "bytes", @@ -4057,9 +4100,9 @@ dependencies = [ [[package]] name = "linera-wasmer-compiler-cranelift" -version = "4.4.0-linera.7" +version = "4.4.0-linera.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8056c8bff8e1b5cafd21aac59b9009e93b30f35b7baab5592a6f4c7db120b490" +checksum = "5e5bf79646e59839a83c10b4c48456a86909596c37cc380c49c94c2632a9126c" dependencies = [ "cranelift-codegen", "cranelift-entity", @@ -4076,9 +4119,9 @@ dependencies = [ [[package]] name = "linera-wasmer-compiler-singlepass" -version = "4.4.0-linera.7" +version = "4.4.0-linera.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3635a86dd98e2c2fd6dd603054f40b8e379f84365a2238cc177d47547a83eebc" +checksum = "9d5ca769ec09d276da3b4580992ec5b96268f3b36b0d05a6fabc7dcb7cd3418a" dependencies = [ "byteorder", "dynasm", @@ -4095,9 +4138,9 @@ dependencies = [ [[package]] name = "linera-wasmer-vm" -version = "4.4.0-linera.7" +version = "4.4.0-linera.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f27d020717572fdb6222324ec46b10eeb49f6f4a120ee63cf7145f4392f12fd8" +checksum = "22ed6366c2d832e034052b6f037b5afe38efb1e9d8e9ef4b31b778751330e519" dependencies = [ "backtrace", "cc", @@ -4154,6 +4197,12 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -5082,6 +5131,17 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick_cache" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a70b1b8b47e31d0498ecbc3c5470bb931399a8bfed1fd79d1717a61ce7f96e3" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "parking_lot", +] + [[package]] name = "quinn" version = "0.11.9" @@ -5603,6 +5663,19 @@ dependencies = [ "semver 1.0.27", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -5612,7 +5685,7 @@ dependencies = [ "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -6443,7 +6516,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -8154,7 +8227,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.4", ] [[package]] diff --git a/linera-bridge/tests/e2e/Cargo.toml b/linera-bridge/tests/e2e/Cargo.toml index 349fc0db273f..df7f9babb543 100644 --- a/linera-bridge/tests/e2e/Cargo.toml +++ b/linera-bridge/tests/e2e/Cargo.toml @@ -31,11 +31,17 @@ linera-core = { path = "../../../linera-core", default-features = false, feature ] } linera-execution = { path = "../../../linera-execution" } linera-faucet-client = { path = "../../../linera-faucet/client" } +linera-persistent = { path = "../../../linera-persistent", features = ["fs"] } linera-storage = { path = "../../../linera-storage", features = ["wasmer"] } linera-views = { path = "../../../linera-views" } rand = { version = "0.8", default-features = false, features = ["std_rng"] } +reqwest = { version = "0.12", default-features = false, features = [ + "json", + "rustls-tls", +] } serde = { version = "1", features = ["derive"] } serde_json = "1" +tempfile = "3" testcontainers = { version = "0.27", features = ["docker-compose"] } tokio = { version = "1", features = ["full"] } tracing = "0.1" diff --git a/linera-bridge/tests/e2e/src/lib.rs b/linera-bridge/tests/e2e/src/lib.rs index 96ee26cd344e..adc2d047c083 100644 --- a/linera-bridge/tests/e2e/src/lib.rs +++ b/linera-bridge/tests/e2e/src/lib.rs @@ -164,6 +164,33 @@ pub fn parse_deployed_address(output: &str) -> anyhow::Result

{ anyhow::bail!("Could not find 'Deployed to:' in forge output:\n{output}"); } +/// Queries the evm-bridge app to check whether a deposit has been processed. +/// Mirrors `linera_bridge::monitor::query_deposit_processed` for use in tests +/// without enabling the `relay` feature. +pub async fn query_deposit_processed( + chain_client: &linera_core::client::ChainClient, + bridge_app_id: linera_base::identifiers::ApplicationId, + deposit_key: &linera_bridge::proof::DepositKey, +) -> anyhow::Result { + use linera_execution::{Query, QueryResponse}; + + #[derive(serde::Serialize)] + struct GqlRequest { + query: String, + } + + let hash_hex = format!("0x{}", alloy::primitives::hex::encode(deposit_key.hash())); + let gql = format!(r#"{{ isDepositProcessed(hash: "{hash_hex}") }}"#); + let query = Query::user_without_abi(bridge_app_id, &GqlRequest { query: gql })?; + let (outcome, _) = chain_client.query_application(query, None).await?; + let response_bytes = match outcome.response { + QueryResponse::User(bytes) => bytes, + other => anyhow::bail!("unexpected query response: {other:?}"), + }; + let response: serde_json::Value = serde_json::from_slice(&response_bytes)?; + Ok(response["data"]["isDepositProcessed"].as_bool() == Some(true)) +} + /// Starts docker compose stack with pre-cleanup of stale state. pub async fn start_compose(compose_file: &std::path::Path, project_name: &str) -> DockerCompose { let compose_file_str = compose_file diff --git a/linera-bridge/tests/e2e/tests/auto_deposit_scan.rs b/linera-bridge/tests/e2e/tests/auto_deposit_scan.rs new file mode 100644 index 000000000000..23c903d72839 --- /dev/null +++ b/linera-bridge/tests/e2e/tests/auto_deposit_scan.rs @@ -0,0 +1,478 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! End-to-end test for both bridge directions with automatic scanning: +//! 1. EVM→Linera: deposit on EVM without calling `/deposit`, relay scanner auto-processes. +//! 2. Linera→EVM: transfer tokens to Address20 cross-chain, relay detects burn and forwards. +//! +//! Chain layout: +//! - Chain A (relay): evm-bridge + wrapped-fungible apps, operated exclusively by the relay +//! - Chain B (user): test operates here, never touches chain A directly +//! +//! Deploy order (same as setup.sh): +//! 1. MockERC20 +//! 2. wrapped-fungible app (Linera) +//! 3. FungibleBridge with real applicationId (EVM) +//! 4. evm-bridge app with bridge address (Linera) + +use std::{collections::BTreeMap, path::PathBuf, time::Duration}; + +use alloy::{ + network::EthereumWallet, + primitives::{FixedBytes, U256}, + providers::ProviderBuilder, + signers::local::PrivateKeySigner, + sol, +}; +use anyhow::Context as _; +use linera_base::{ + crypto::InMemorySigner, + data_types::{Amount, Bytecode}, + identifiers::{AccountOwner, ApplicationId}, + vm::VmRuntime, +}; +use linera_bridge_e2e::{ + compose_file_path, exec_ok, exec_output, light_client_address, parse_deployed_address, + start_compose, ANVIL_PRIVATE_KEY, +}; +use linera_client::{chain_listener::ClientContext as _, client_context::ClientContext}; +use linera_core::environment::wallet::Memory; +use linera_execution::{Operation, WasmRuntime}; +use linera_faucet_client::Faucet; +use linera_storage::{DbStorage, StorageCacheSizes}; +use linera_views::backends::memory::{MemoryDatabase, MemoryStoreConfig}; +use serde::Serialize; +use wrapped_fungible::{Account, InitialState, WrappedFungibleOperation, WrappedParameters}; + +// ── Inline evm-bridge types ── + +#[derive(Clone, Debug, serde::Deserialize, Serialize)] +struct BridgeParameters { + source_chain_id: u64, + bridge_contract_address: [u8; 20], + fungible_app_id: ApplicationId, + token_address: [u8; 20], + rpc_endpoint: String, +} + +sol! { + #[sol(rpc)] + interface IERC20 { + function approve(address spender, uint256 amount) external returns (bool); + function balanceOf(address account) external view returns (uint256); + } + + #[sol(rpc)] + interface IFungibleBridge { + function deposit( + bytes32 target_chain_id, + bytes32 target_application_id, + bytes32 target_account_owner, + uint256 amount + ) external; + } +} + +#[tokio::test] +#[ignore] // Requires pre-built docker images, Wasm, and relay binary +async fn test_auto_deposit_scan() -> anyhow::Result<()> { + tracing_subscriber::fmt().with_test_writer().try_init().ok(); + let compose_file = compose_file_path(); + let project_name = "linera-auto-scan-test"; + + // ── Phase 1: Start docker compose stack ── + let compose = start_compose(&compose_file, project_name).await; + + // ── Phase 2: Create Linera client, claim chains ── + tracing::info!("Creating programmatic Linera client..."); + let faucet = Faucet::new("http://localhost:8080".to_string()); + let genesis_config = faucet.genesis_config().await?; + + let config = MemoryStoreConfig { + max_stream_queries: 10, + kill_on_drop: true, + }; + let mut storage = DbStorage::::maybe_create_and_connect( + &config, + "auto-scan-e2e-test", + Some(WasmRuntime::default()), + StorageCacheSizes { + blob_cache_size: 1000, + confirmed_block_cache_size: 1000, + lite_certificate_cache_size: 1000, + certificate_raw_cache_size: 1000, + event_cache_size: 1000, + }, + ) + .await?; + + genesis_config.initialize_storage(&mut storage).await?; + let mut signer = InMemorySigner::new(None); + + let mut ctx = ClientContext::new( + storage, + Memory::default(), + signer.clone(), + &Default::default(), + None, + genesis_config, + linera_core::worker::DEFAULT_BLOCK_CACHE_SIZE, + linera_core::worker::DEFAULT_EXECUTION_STATE_CACHE_SIZE, + ) + .await?; + + // Chain A: relay chain. + tracing::info!("Claiming chain A (relay)..."); + let owner_a = AccountOwner::from(signer.generate_new()); + let chain_a_desc = faucet.claim(&owner_a).await?; + let chain_a = chain_a_desc.id(); + ctx.extend_with_chain(chain_a_desc, Some(owner_a)).await?; + let cc_a = ctx.make_chain_client(chain_a).await?; + cc_a.synchronize_from_validators().await?; + tracing::info!(%chain_a, "Chain A claimed"); + + // Chain B: user chain. + tracing::info!("Claiming chain B (user)..."); + let owner_b = AccountOwner::from(signer.generate_new()); + let chain_b_desc = faucet.claim(&owner_b).await?; + let chain_b = chain_b_desc.id(); + ctx.extend_with_chain(chain_b_desc, Some(owner_b)).await?; + let cc_b = ctx.make_chain_client(chain_b).await?; + cc_b.synchronize_from_validators().await?; + tracing::info!(%chain_b, "Chain B claimed"); + + // ── Phase 3: Deploy MockERC20 ── + tracing::info!("Deploying MockERC20..."); + let erc20_output = exec_output( + &compose, + "foundry-tools", + &format!( + "forge create /contracts/MockERC20.sol:MockERC20 \ + --root /contracts --via-ir --optimize \ + --evm-version shanghai \ + --out /tmp/forge-out --cache-path /tmp/forge-cache \ + --rpc-url http://anvil:8545 \ + --broadcast \ + --private-key {ANVIL_PRIVATE_KEY} \ + --constructor-args \"TestToken\" \"TT\" 1000000000000000000000" + ), + project_name, + &compose_file, + ) + .await; + let erc20_addr = parse_deployed_address(&erc20_output)?; + tracing::info!(%erc20_addr, "MockERC20 deployed"); + + // ── Phase 4: Deploy wrapped-fungible app ── + let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(3) + .context("manifest dir has fewer than 3 ancestors")? + .to_path_buf(); + let wasm_dir = repo_root.join("examples/target/wasm32-unknown-unknown/release"); + + tracing::info!("Publishing wrapped-fungible module..."); + let wf_contract = Bytecode::load_from_file(wasm_dir.join("wrapped_fungible_contract.wasm"))?; + let wf_service = Bytecode::load_from_file(wasm_dir.join("wrapped_fungible_service.wasm"))?; + let (wf_module_id, _) = cc_a + .publish_module(wf_contract, wf_service, VmRuntime::Wasm) + .await? + .expect("publish wrapped-fungible module committed"); + cc_a.synchronize_from_validators().await?; + cc_a.process_inbox().await?; + + tracing::info!("Creating wrapped-fungible application..."); + let (fungible_app_id, _) = cc_a + .create_application_untyped( + wf_module_id, + serde_json::to_vec(&WrappedParameters { + ticker_symbol: "wTEST".to_string(), + minter: owner_a, + mint_chain_id: chain_a, + evm_token_address: erc20_addr.0 .0, + evm_source_chain_id: 31337, + })?, + serde_json::to_vec(&InitialState { + accounts: BTreeMap::new(), + })?, + vec![], + ) + .await? + .expect("create wrapped-fungible app committed"); + tracing::info!(%fungible_app_id, "wrapped-fungible app created"); + + // ── Phase 5: Deploy FungibleBridge with real applicationId ── + let chain_a_bytes32 = format!("0x{chain_a}"); + let app_id_bytes32 = format!("0x{}", fungible_app_id.application_description_hash); + let light_client = light_client_address(); + + tracing::info!("Deploying FungibleBridge..."); + let bridge_output = exec_output( + &compose, + "foundry-tools", + &format!( + "forge create /contracts/FungibleBridge.sol:FungibleBridge \ + --root /contracts --via-ir --optimize \ + --ignored-error-codes 6321 \ + --evm-version shanghai \ + --out /tmp/forge-out --cache-path /tmp/forge-cache \ + --rpc-url http://anvil:8545 \ + --private-key {ANVIL_PRIVATE_KEY} \ + --broadcast \ + --constructor-args \ + {light_client} \ + {chain_a_bytes32} \ + {app_id_bytes32} \ + {erc20_addr}" + ), + project_name, + &compose_file, + ) + .await; + let bridge_addr = parse_deployed_address(&bridge_output)?; + tracing::info!(%bridge_addr, "FungibleBridge deployed"); + + tracing::info!("Funding FungibleBridge with ERC20 tokens..."); + exec_ok( + &compose, + "foundry-tools", + &format!( + "cast send --rpc-url http://anvil:8545 \ + --private-key {ANVIL_PRIVATE_KEY} \ + {erc20_addr} \ + 'transfer(address,uint256)(bool)' \ + {bridge_addr} \ + 500000000000000000000" + ), + project_name, + &compose_file, + ) + .await; + + // ── Phase 6: Deploy evm-bridge app with bridge address ── + tracing::info!("Publishing evm-bridge module..."); + let eb_contract = Bytecode::load_from_file(wasm_dir.join("evm_bridge_contract.wasm"))?; + let eb_service = Bytecode::load_from_file(wasm_dir.join("evm_bridge_service.wasm"))?; + let (eb_module_id, _) = cc_a + .publish_module(eb_contract, eb_service, VmRuntime::Wasm) + .await? + .expect("publish evm-bridge module committed"); + cc_a.synchronize_from_validators().await?; + cc_a.process_inbox().await?; + + tracing::info!("Creating evm-bridge application..."); + let (bridge_app_id, _) = cc_a + .create_application_untyped( + eb_module_id, + serde_json::to_vec(&BridgeParameters { + source_chain_id: 31337, + bridge_contract_address: bridge_addr.0 .0, + fungible_app_id, + token_address: erc20_addr.0 .0, + rpc_endpoint: String::new(), + })?, + serde_json::to_vec(&())?, + vec![fungible_app_id], + ) + .await? + .expect("create evm-bridge app committed"); + tracing::info!(%bridge_app_id, "evm-bridge app created"); + + // ── Phase 7: Start relay ── + let rpc_url = "http://localhost:8545".parse()?; + let evm_signer: PrivateKeySigner = ANVIL_PRIVATE_KEY.parse()?; + let evm_wallet = EthereumWallet::from(evm_signer); + let provider = ProviderBuilder::new() + .wallet(evm_wallet) + .connect_http(rpc_url); + + let relay_binary = repo_root.join("target/debug/linera-bridge"); + anyhow::ensure!( + relay_binary.exists(), + "Relay binary not found at {relay_binary:?}. \ + Run: cargo build -p linera-bridge --features relay" + ); + + let relay_dir = tempfile::tempdir()?; + let wallet_path = relay_dir.path().join("wallet.json"); + let keystore_path = relay_dir.path().join("keystore.json"); + let storage_path = format!("rocksdb:{}", relay_dir.path().join("client.db").display()); + + { + use linera_persistent::Persist; + let mut ks = linera_persistent::File::new(&keystore_path, signer.clone())?; + ks.persist().await?; + } + + // The relay creates its own chain_client for chain A. + // We keep cc_a alive for diagnostics but don't create blocks on it. + + let relay_port = 3002; + tracing::info!("Starting relay binary..."); + let mut relay_process = tokio::process::Command::new(&relay_binary) + .args([ + "serve", + "--rpc-url", "http://localhost:8545", + "--faucet-url", "http://localhost:8080", + "--wallet", wallet_path.to_str().unwrap(), + "--keystore", keystore_path.to_str().unwrap(), + "--storage", &storage_path, + &format!("--linera-bridge-chain-id={chain_a}"), + &format!("--evm-bridge-address={bridge_addr}"), + &format!("--linera-bridge-address={bridge_app_id}"), + &format!("--linera-fungible-address={fungible_app_id}"), + &format!("--evm-private-key={ANVIL_PRIVATE_KEY}"), + &format!("--port={relay_port}"), + "--monitor-scan-interval", "5", + "--max-retries", "5", + ]) + .env("RUST_LOG", "linera=info,linera_bridge=debug") + .kill_on_drop(true) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .context("failed to spawn relay binary")?; + + let relay_url = format!("http://localhost:{relay_port}"); + let client = reqwest::Client::new(); + for attempt in 0..30 { + tokio::time::sleep(Duration::from_secs(2)).await; + if client.get(format!("{relay_url}/metrics")).send().await.is_ok() { + tracing::info!(attempt, "Relay is ready"); + break; + } + if attempt == 29 { + relay_process.kill().await.ok(); + anyhow::bail!("Relay did not become ready"); + } + } + + // Diagnostic: check chain A height from test's perspective. + cc_a.synchronize_from_validators().await?; + let info = cc_a.chain_info().await?; + tracing::info!(next_block_height = ?info.next_block_height, "Chain A height from test client"); + + // ══════════════════════════════════════════════════════════════════ + // Phase 8: EVM→Linera deposit targeting chain B + // ══════════════════════════════════════════════════════════════════ + let deposit_amount = U256::from(50u128 * 10u128.pow(18)); + + tracing::info!("Approving FungibleBridge..."); + let erc20_contract = IERC20::new(erc20_addr, &provider); + erc20_contract + .approve(bridge_addr, deposit_amount) + .send() + .await? + .get_receipt() + .await?; + + let chain_b_b256 = { + let bytes: [u8; 32] = chain_b.0.into(); + FixedBytes::<32>::from(bytes) + }; + let owner_b_b256 = match owner_b { + AccountOwner::Address32(hash) => { + let bytes: [u8; 32] = hash.into(); + FixedBytes::<32>::from(bytes) + } + _ => anyhow::bail!("expected Address32 owner"), + }; + + tracing::info!("Depositing on EVM targeting chain B..."); + let bridge_contract = IFungibleBridge::new(bridge_addr, &provider); + let deposit_receipt = bridge_contract + .deposit(chain_b_b256, app_id_bytes32.parse()?, owner_b_b256, deposit_amount) + .send() + .await? + .get_receipt() + .await?; + tracing::info!("Deposit confirmed on EVM"); + + let deposit_key = linera_bridge::proof::DepositKey { + source_chain_id: 31337, + block_hash: deposit_receipt.block_hash.unwrap().0, + tx_index: 0, + log_index: 0, + }; + + // Wait for relay to auto-process the deposit. + tracing::info!("Waiting for relay scanner to auto-process the deposit..."); + for attempt in 0..60 { + tokio::time::sleep(Duration::from_secs(5)).await; + + // Sync chain B to receive minted tokens. + cc_b.synchronize_from_validators().await?; + cc_b.process_inbox().await?; + + // Check on-chain whether the deposit was processed. + match linera_bridge_e2e::query_deposit_processed(&cc_a, bridge_app_id, &deposit_key).await + { + Ok(true) => { + tracing::info!(attempt, "Deposit auto-processed!"); + break; + } + Ok(false) => { + tracing::info!(attempt, "Deposit not yet processed, waiting..."); + } + Err(e) => { + tracing::warn!(attempt, "Deposit query failed: {e:#}"); + } + } + if attempt == 59 { + relay_process.kill().await.ok(); + anyhow::bail!("Deposit not auto-processed within timeout"); + } + } + + // ══════════════════════════════════════════════════════════════════ + // Phase 9: Linera→EVM burn via cross-chain transfer + // ══════════════════════════════════════════════════════════════════ + let evm_recipient = "70997970C51812dc3A010C7d01b50e0d17dc79C8"; + let receiver: AccountOwner = format!("0x{evm_recipient}").parse()?; + let withdraw_amount = Amount::from_tokens(25); + + tracing::info!("Sending cross-chain withdrawal from chain B to Address20 on chain A..."); + cc_b.synchronize_from_validators().await?; + let withdraw_bytes = bcs::to_bytes(&WrappedFungibleOperation::Transfer { + owner: owner_b, + amount: withdraw_amount, + target_account: Account { + chain_id: chain_a, + owner: receiver, + }, + })?; + cc_b.execute_operations( + vec![Operation::User { + application_id: fungible_app_id, + bytes: withdraw_bytes, + }], + vec![], + ) + .await? + .expect("withdrawal committed"); + tracing::info!("Cross-chain withdrawal committed on chain B"); + + // Wait for relay to burn and forward to EVM. + tracing::info!("Waiting for ERC-20 balance..."); + let evm_recipient_addr: alloy::primitives::Address = + format!("0x{evm_recipient}").parse()?; + let expected_balance = U256::from(25u128 * 10u128.pow(18)); + + for attempt in 0..60 { + tokio::time::sleep(Duration::from_secs(5)).await; + + let balance = erc20_contract.balanceOf(evm_recipient_addr).call().await?; + tracing::info!(attempt, ?balance, "ERC-20 balance"); + + if balance >= expected_balance { + relay_process.kill().await.ok(); + tracing::info!( + "Test passed! Both directions: EVM→Linera deposit + Linera→EVM burn." + ); + return Ok(()); + } + } + + relay_process.kill().await.ok(); + anyhow::bail!("Burn not forwarded to EVM within timeout"); +} diff --git a/linera-bridge/tests/e2e/tests/evm_to_linera_bridge.rs b/linera-bridge/tests/e2e/tests/evm_to_linera_bridge.rs index 64ec5275405e..dbab73f8b5af 100644 --- a/linera-bridge/tests/e2e/tests/evm_to_linera_bridge.rs +++ b/linera-bridge/tests/e2e/tests/evm_to_linera_bridge.rs @@ -318,14 +318,31 @@ async fn test_evm_to_linera_bridge() -> anyhow::Result<()> { "Deposit proof generated" ); - // ── Phase 7: Submit ProcessDeposit on Linera ── + // Build the DepositKey for completion checks. + let tx_index = proof.tx_index; + let log_index = proof.log_indices[0]; + let deposit_key = linera_bridge::proof::DepositKey { + source_chain_id: 31337, // Anvil chain ID + block_hash: deposit_receipt.block_hash.unwrap().0, + tx_index, + log_index, + }; + + // ── Phase 7a: Verify deposit is NOT yet processed ── + assert!( + !linera_bridge_e2e::query_deposit_processed(&cc, bridge_app_id, &deposit_key).await?, + "deposit should NOT be processed before ProcessDeposit" + ); + tracing::info!("Confirmed: deposit not yet processed."); + + // ── Phase 7b: Submit ProcessDeposit on Linera ── tracing::info!("Submitting ProcessDeposit operation..."); let bridge_op = BridgeOperation::ProcessDeposit { block_header_rlp: proof.block_header_rlp, receipt_rlp: proof.receipt_rlp, proof_nodes: proof.proof_nodes, - tx_index: proof.tx_index, - log_index: proof.log_indices[0], + tx_index, + log_index, }; let op_bytes = bcs::to_bytes(&bridge_op)?; let op = Operation::User { @@ -373,6 +390,13 @@ async fn test_evm_to_linera_bridge() -> anyhow::Result<()> { "wrapped-fungible balance should match the 100-token deposit" ); - tracing::info!(%balance, "Test passed! Wrapped-fungible balance matches deposit."); + tracing::info!(%balance, "Wrapped-fungible balance matches deposit."); + + // ── Phase 9: Verify deposit IS now processed ── + assert!( + linera_bridge_e2e::query_deposit_processed(&cc, bridge_app_id, &deposit_key).await?, + "deposit should be marked as processed after ProcessDeposit" + ); + tracing::info!("Test passed! Deposit confirmed as processed via GraphQL query."); Ok(()) } From 1a26828ed90e1a36133f643dea9bf47c39a58a17 Mon Sep 17 00:00:00 2001 From: deuszx <95355183+deuszx@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:09:58 +0000 Subject: [PATCH 09/13] Dockerfile for bridge relayer (#5814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation The bridge relayer needs to be deployable on infrastructure via Docker. The existing `Dockerfile.bridge` builds the binary but has no entrypoint, env var support, or exposed ports. Additionally, `--faucet-url` is unconditionally required even when running with a pre-existing wallet, which prevents standalone deployment without a faucet. ## Proposal - Add `bridge-entrypoint.sh` that maps environment variables to CLI args for the `serve` subcommand, with pass-through for other subcommands (`init-light-client`, `generate-deposit-proof`, `sh -c` wrappers). - Update `Dockerfile.bridge` runtime stage: ENTRYPOINT, CMD, EXPOSE 3001, ENV defaults for optional args, documented required/optional vars. - Make `--faucet-url` optional in the Rust code. It is now only required when the wallet doesn't exist (needs genesis config) or `--linera-bridge-chain-id` is not provided (needs to claim a chain). Admin chain sync uses `synchronize_from_validators()` instead of fetching the committee from the faucet. - Add Makefile targets: `build-relayer`, `relayer-up` (validates required env vars, prints config, runs container), `relayer-down`, `relayer-logs`. - Update `docker-compose.bridge-test.yml` relay service to use the new entrypoint. - Fix CI: add missing `cargo build -p linera-bridge --features relay` step for the `auto_deposit_scan` test which spawns a local relay binary. ## Test Plan - `cargo check -p linera-bridge --features relay` compiles clean - Docker image builds via `make -C linera-bridge build-relayer` - All bridge e2e tests pass locally: - `test_evm_to_linera_bridge` — EVM→Linera deposit with proof - `test_fungible_bridge` — Linera→EVM burn with ERC-20 balance verification - `test_auto_deposit_scan` — bidirectional: auto-scanned deposit + auto-forwarded burn ## Release Plan - Nothing to do / These changes follow the usual release cycle. ## Links - [reviewer checklist](https://github.com/linera-io/linera-protocol/blob/main/CONTRIBUTING.md#reviewer-checklist) --- .github/workflows/bridge-e2e.yml | 3 ++ docker/Dockerfile.bridge | 36 +++++++++++++ docker/bridge-entrypoint.sh | 42 +++++++++++++++ docker/docker-compose.bridge-test.yml | 41 ++++++--------- linera-bridge/Makefile | 76 ++++++++++++++++++++++++++- linera-bridge/src/main.rs | 6 +-- linera-bridge/src/relay/mod.rs | 41 ++++++++------- linera-bridge/tests/e2e/Cargo.lock | 12 ++--- 8 files changed, 201 insertions(+), 56 deletions(-) create mode 100644 docker/bridge-entrypoint.sh diff --git a/.github/workflows/bridge-e2e.yml b/.github/workflows/bridge-e2e.yml index 5f8c2cc6a0da..3c099642a174 100644 --- a/.github/workflows/bridge-e2e.yml +++ b/.github/workflows/bridge-e2e.yml @@ -131,6 +131,9 @@ jobs: run: cargo build --release --target wasm32-unknown-unknown -p wrapped-fungible -p evm-bridge working-directory: examples + - name: Build linera-bridge binary for local relay tests + run: cargo build -p linera-bridge --features linera-bridge/relay + - name: Run bridge E2E test working-directory: linera-bridge/tests/e2e env: diff --git a/docker/Dockerfile.bridge b/docker/Dockerfile.bridge index fe2ae465f5cf..ff48702564a4 100644 --- a/docker/Dockerfile.bridge +++ b/docker/Dockerfile.bridge @@ -99,3 +99,39 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ RUN apt-get clean && rm -rf /var/lib/apt/lists/* COPY --from=builder /usr/local/bin/linera-bridge /usr/local/bin/linera-bridge +COPY docker/bridge-entrypoint.sh /usr/local/bin/bridge-entrypoint.sh +RUN chmod +x /usr/local/bin/bridge-entrypoint.sh + +# ── Required (no defaults — must be set at runtime) ── +# RPC_URL EVM JSON-RPC endpoint +# EVM_BRIDGE_ADDRESS FungibleBridge contract address on EVM +# LINERA_BRIDGE_APP evm-bridge Linera ApplicationId (hex) +# LINERA_FUNGIBLE_APP wrapped-fungible Linera ApplicationId (hex) +# EVM_PRIVATE_KEY EVM private key for signing transactions + +# ── Conditionally required ── +# FAUCET_URL Linera faucet URL (required when wallet doesn't exist or chain ID not provided) + +# ── Optional (have defaults) ── +ENV PORT=3001 +ENV MONITOR_SCAN_INTERVAL=30 +ENV MONITOR_START_BLOCK=0 +ENV MAX_RETRIES=10 +ENV BLOB_CACHE_SIZE=1000 +ENV CONFIRMED_BLOCK_CACHE_SIZE=1000 +ENV LITE_CERTIFICATE_CACHE_SIZE=1000 +ENV CERTIFICATE_RAW_CACHE_SIZE=1000 +ENV EVENT_CACHE_SIZE=1000 + +# ── Optional Linera client paths (clap reads these directly) ── +# LINERA_WALLET Path to wallet state file +# LINERA_KEYSTORE Path to keystore file +# LINERA_STORAGE Storage config (e.g. rocksdb:/data/client.db) + +# ── Optional ── +# LINERA_BRIDGE_CHAIN_ID Linera bridge chain ID (claims new if omitted) + +EXPOSE 3001 + +ENTRYPOINT ["bridge-entrypoint.sh"] +CMD ["serve"] diff --git a/docker/bridge-entrypoint.sh b/docker/bridge-entrypoint.sh new file mode 100644 index 000000000000..46f0621c1819 --- /dev/null +++ b/docker/bridge-entrypoint.sh @@ -0,0 +1,42 @@ +#!/bin/sh +set -e + +# If the first argument is "serve" with no other args, build CLI from env vars. +# Otherwise, pass everything through as-is (supports "sh -c ...", direct CLI usage, etc). +if [ "$1" != "serve" ]; then + exec "$@" +fi + +# Build the CLI invocation from environment variables. +shift # consume "serve" + +set -- linera-bridge serve \ + --rpc-url="${RPC_URL:?RPC_URL is required}" \ + --evm-bridge-address="${EVM_BRIDGE_ADDRESS:?EVM_BRIDGE_ADDRESS is required}" \ + --linera-bridge-address="${LINERA_BRIDGE_APP:?LINERA_BRIDGE_APP is required}" \ + --linera-fungible-address="${LINERA_FUNGIBLE_APP:?LINERA_FUNGIBLE_APP is required}" \ + --evm-private-key="${EVM_PRIVATE_KEY:?EVM_PRIVATE_KEY is required}" \ + --port="${PORT:-3001}" \ + --monitor-scan-interval="${MONITOR_SCAN_INTERVAL:-30}" \ + --monitor-start-block="${MONITOR_START_BLOCK:-0}" \ + --max-retries="${MAX_RETRIES:-10}" \ + --blob-cache-size="${BLOB_CACHE_SIZE:-1000}" \ + --confirmed-block-cache-size="${CONFIRMED_BLOCK_CACHE_SIZE:-1000}" \ + --lite-certificate-cache-size="${LITE_CERTIFICATE_CACHE_SIZE:-1000}" \ + --certificate-raw-cache-size="${CERTIFICATE_RAW_CACHE_SIZE:-1000}" \ + --event-cache-size="${EVENT_CACHE_SIZE:-1000}" + +# Optional: faucet URL (required when wallet doesn't exist or chain ID not provided) +if [ -n "$FAUCET_URL" ]; then + set -- "$@" --faucet-url="$FAUCET_URL" +fi + +# Optional: bridge chain ID +if [ -n "$LINERA_BRIDGE_CHAIN_ID" ]; then + set -- "$@" --linera-bridge-chain-id="$LINERA_BRIDGE_CHAIN_ID" +fi + +# LINERA_WALLET, LINERA_KEYSTORE, LINERA_STORAGE are read directly by clap +# via `env = "..."`, so they don't need explicit --flags here. + +exec "$@" diff --git a/docker/docker-compose.bridge-test.yml b/docker/docker-compose.bridge-test.yml index c75824443a91..50df6cc093b1 100644 --- a/docker/docker-compose.bridge-test.yml +++ b/docker/docker-compose.bridge-test.yml @@ -325,41 +325,30 @@ services: linera-relay: image: "${LINERA_BRIDGE_IMAGE:-linera-bridge}" network_mode: "service:linera-network" + entrypoint: ["sh", "-c"] command: - - sh - - -c - | - # Remove stale marker from previous runs. rm -f /shared/setup-complete echo "Waiting for setup to complete..." while [ ! -f /shared/setup-complete ]; do sleep 1; done - CHAIN_ID=$$(cat /shared/bridge-chain-id | tr -d '[:space:]') - BRIDGE_ADDR=$$(cat /shared/bridge-address | tr -d '[:space:]') - BRIDGE_APP=$$(cat /shared/bridge-app-id | tr -d '[:space:]') - FUNGIBLE_APP=$$(cat /shared/wrapped-app-id | tr -d '[:space:]') + export RPC_URL=http://anvil:8545 + export FAUCET_URL=http://localhost:${FAUCET_PORT:-8080} + export EVM_BRIDGE_ADDRESS=$$(cat /shared/bridge-address | tr -d '[:space:]') + export LINERA_BRIDGE_APP=$$(cat /shared/bridge-app-id | tr -d '[:space:]') + export LINERA_FUNGIBLE_APP=$$(cat /shared/wrapped-app-id | tr -d '[:space:]') + export LINERA_BRIDGE_CHAIN_ID=$$(cat /shared/bridge-chain-id | tr -d '[:space:]') + export EVM_PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 + export LINERA_WALLET=/shared/relay-wallet/wallet.json + export LINERA_KEYSTORE=/shared/relay-wallet/keystore.json + export LINERA_STORAGE=rocksdb:/shared/relay-wallet/client.db - echo "Starting relay: chain=$$CHAIN_ID bridge=$$BRIDGE_ADDR" - exec linera-bridge serve \ - --rpc-url=http://anvil:8545 \ - --faucet-url=http://localhost:${FAUCET_PORT:-8080} \ - --wallet=/shared/relay-wallet/wallet.json \ - --keystore=/shared/relay-wallet/keystore.json \ - --storage=rocksdb:/shared/relay-wallet/client.db \ - --linera-bridge-chain-id="$$CHAIN_ID" \ - --evm-bridge-address="$$BRIDGE_ADDR" \ - --linera-bridge-address="$$BRIDGE_APP" \ - --linera-fungible-address="$$FUNGIBLE_APP" \ - --evm-private-key=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ - --port=${RELAY_PORT:-3001} \ - --monitor-scan-interval=${MONITOR_SCAN_INTERVAL:-5} \ - --monitor-start-block=${MONITOR_START_BLOCK:-0} \ - --max-retries=${MAX_RETRIES:-10} + echo "Starting relay: chain=$$LINERA_BRIDGE_CHAIN_ID bridge=$$EVM_BRIDGE_ADDRESS" + exec bridge-entrypoint.sh serve environment: - RUST_LOG=linera=info,linera_bridge=debug - - FAUCET_PORT=${FAUCET_PORT:-8080} - - RELAY_PORT=${RELAY_PORT:-3001} - - MONITOR_SCAN_INTERVAL=${MONITOR_SCAN_INTERVAL:-30} + - PORT=${RELAY_PORT:-3001} + - MONITOR_SCAN_INTERVAL=${MONITOR_SCAN_INTERVAL:-5} - MONITOR_START_BLOCK=${MONITOR_START_BLOCK:-0} - MAX_RETRIES=${MAX_RETRIES:-10} volumes: diff --git a/linera-bridge/Makefile b/linera-bridge/Makefile index 12e49e4e9bf7..cc3c8d568b8a 100644 --- a/linera-bridge/Makefile +++ b/linera-bridge/Makefile @@ -3,7 +3,81 @@ COMPOSE_FILE := $(DOCKER_DIR)/docker-compose.bridge-test.yml DEMO_DIR := ../examples/bridge-demo PROJECT := linera-bridge-demo -.PHONY: build-wasm build-all up down demo demo-setup demo-frontend demo-logs +.PHONY: build-wasm build-all build-relayer relayer-up relayer-down relayer-logs up down demo demo-setup demo-frontend demo-logs + +build-relayer: ## Build the bridge relayer docker image + cd $(DOCKER_DIR) && docker build -f ./Dockerfile.bridge \ + -t $(RELAYER_IMAGE) .. + +RELAYER_CONTAINER := linera-relay +RELAYER_IMAGE ?= linera-bridge + +# Required env vars for relayer-up (must be set by caller) +# RPC_URL, EVM_BRIDGE_ADDRESS, LINERA_BRIDGE_APP, LINERA_FUNGIBLE_APP, EVM_PRIVATE_KEY + +relayer-up: ## Run the bridge relayer container + @missing=""; \ + for var in RPC_URL EVM_BRIDGE_ADDRESS LINERA_BRIDGE_APP LINERA_FUNGIBLE_APP EVM_PRIVATE_KEY; do \ + eval val=\$$$$var; \ + if [ -z "$$val" ]; then missing="$$missing $$var"; fi; \ + done; \ + if [ -n "$$missing" ]; then \ + echo "ERROR: Missing required env vars:$$missing"; \ + echo ""; \ + echo "Usage:"; \ + echo " RPC_URL=... EVM_BRIDGE_ADDRESS=... \\"; \ + echo " LINERA_BRIDGE_APP=... LINERA_FUNGIBLE_APP=... EVM_PRIVATE_KEY=... \\"; \ + echo " make -C linera-bridge relayer-up"; \ + exit 1; \ + fi + @echo "============================================" + @echo " Starting bridge relayer" + @echo "============================================" + @echo "" + @echo "Required:" + @echo " RPC_URL = $(RPC_URL)" + @echo " EVM_BRIDGE_ADDRESS = $(EVM_BRIDGE_ADDRESS)" + @echo " LINERA_BRIDGE_APP = $(LINERA_BRIDGE_APP)" + @echo " LINERA_FUNGIBLE_APP = $(LINERA_FUNGIBLE_APP)" + @echo " EVM_PRIVATE_KEY = $(EVM_PRIVATE_KEY)" + @echo "" + @echo "Optional:" + @echo " FAUCET_URL = $(or $(FAUCET_URL),(not set))" + @echo " PORT = $(or $(PORT),3001)" + @echo " MONITOR_SCAN_INTERVAL= $(or $(MONITOR_SCAN_INTERVAL),30)" + @echo " MONITOR_START_BLOCK = $(or $(MONITOR_START_BLOCK),0)" + @echo " MAX_RETRIES = $(or $(MAX_RETRIES),10)" + @echo " LINERA_BRIDGE_CHAIN_ID = $(or $(LINERA_BRIDGE_CHAIN_ID),(auto-claim))" + @echo " LINERA_WALLET = $(or $(LINERA_WALLET),(default))" + @echo " LINERA_KEYSTORE = $(or $(LINERA_KEYSTORE),(default))" + @echo " LINERA_STORAGE = $(or $(LINERA_STORAGE),(default))" + @echo "============================================" + docker run -d --name $(RELAYER_CONTAINER) \ + -p $(or $(PORT),3001):$(or $(PORT),3001) \ + -e RPC_URL="$(RPC_URL)" \ + -e EVM_BRIDGE_ADDRESS="$(EVM_BRIDGE_ADDRESS)" \ + -e LINERA_BRIDGE_APP="$(LINERA_BRIDGE_APP)" \ + -e LINERA_FUNGIBLE_APP="$(LINERA_FUNGIBLE_APP)" \ + -e EVM_PRIVATE_KEY="$(EVM_PRIVATE_KEY)" \ + $(if $(FAUCET_URL),-e FAUCET_URL="$(FAUCET_URL)") \ + $(if $(PORT),-e PORT="$(PORT)") \ + $(if $(MONITOR_SCAN_INTERVAL),-e MONITOR_SCAN_INTERVAL="$(MONITOR_SCAN_INTERVAL)") \ + $(if $(MONITOR_START_BLOCK),-e MONITOR_START_BLOCK="$(MONITOR_START_BLOCK)") \ + $(if $(MAX_RETRIES),-e MAX_RETRIES="$(MAX_RETRIES)") \ + $(if $(LINERA_BRIDGE_CHAIN_ID),-e LINERA_BRIDGE_CHAIN_ID="$(LINERA_BRIDGE_CHAIN_ID)") \ + $(if $(LINERA_WALLET),-e LINERA_WALLET="$(LINERA_WALLET)") \ + $(if $(LINERA_KEYSTORE),-e LINERA_KEYSTORE="$(LINERA_KEYSTORE)") \ + $(if $(LINERA_STORAGE),-e LINERA_STORAGE="$(LINERA_STORAGE)") \ + $(if $(RUST_LOG),-e RUST_LOG="$(RUST_LOG)") \ + $(RELAYER_IMAGE) + @echo "" + @echo "Relayer started. Logs: make -C linera-bridge relayer-logs" + +relayer-down: ## Stop and remove the bridge relayer container + docker rm -f $(RELAYER_CONTAINER) 2>/dev/null || true + +relayer-logs: ## Tail logs from the standalone relayer container + docker logs -f $(RELAYER_CONTAINER) build-wasm: ## Build all Wasm files needed for bridge demo cd ../examples && cargo build --release --target wasm32-unknown-unknown \ diff --git a/linera-bridge/src/main.rs b/linera-bridge/src/main.rs index 84bc30bbb03b..ef8bd6d121b6 100644 --- a/linera-bridge/src/main.rs +++ b/linera-bridge/src/main.rs @@ -54,9 +54,9 @@ struct ServeOptions { #[arg(long)] rpc_url: String, - /// URL of the Linera faucet + /// URL of the Linera faucet (required when wallet doesn't exist or chain ID not provided) #[arg(long)] - faucet_url: String, + faucet_url: Option, /// Path to the wallet state file. #[arg(long = "wallet", env = "LINERA_WALLET")] @@ -145,7 +145,7 @@ impl ServeOptions { async fn run(&self) -> Result<()> { Box::pin(linera_bridge::relay::run( &self.rpc_url, - &self.faucet_url, + self.faucet_url.as_deref(), self.wallet.as_deref(), self.keystore.as_deref(), self.storage.as_deref(), diff --git a/linera-bridge/src/relay/mod.rs b/linera-bridge/src/relay/mod.rs index 6f70dcd4a194..f668cb946353 100644 --- a/linera-bridge/src/relay/mod.rs +++ b/linera-bridge/src/relay/mod.rs @@ -50,7 +50,7 @@ use crate::{ #[allow(clippy::too_many_arguments)] pub async fn run( rpc_url: &str, - faucet_url: &str, + faucet_url: Option<&str>, wallet_path: Option<&Path>, keystore_path: Option<&Path>, storage_config: Option<&str>, @@ -95,10 +95,10 @@ pub async fn run( ); // ── Common init ── - tracing::info!("Connecting to Linera faucet at {faucet_url}..."); - let faucet = Faucet::new(faucet_url.to_string()); - let genesis_config = faucet.genesis_config().await?; - tracing::info!("Genesis config received"); + let faucet = faucet_url.map(|url| { + tracing::info!("Using Linera faucet at {url}"); + Faucet::new(url.to_string()) + }); let mut signer: InMemorySigner = linera_persistent::File::::read(&keystore_path) @@ -112,16 +112,21 @@ pub async fn run( let mut storage = create_rocksdb_storage(Path::new(db_path), cache_sizes).await?; // ── Wallet: load existing or create fresh ── - let wallet_exists = wallet_path.exists(); - - // Always initialize storage — this is a no-op if already initialized. - genesis_config.initialize_storage(&mut storage).await?; - - let wallet = if wallet_exists { + let wallet = if wallet_path.exists() { tracing::info!("Loading existing wallet from {}", wallet_path.display()); - PersistentWallet::read(&wallet_path).context("failed to read wallet")? + let wallet = PersistentWallet::read(&wallet_path).context("failed to read wallet")?; + wallet + .genesis_config() + .initialize_storage(&mut storage) + .await?; + wallet } else { + let faucet = faucet + .as_ref() + .context("--faucet-url is required when no wallet exists")?; tracing::info!("Creating new wallet at {}", wallet_path.display()); + let genesis_config = faucet.genesis_config().await?; + genesis_config.initialize_storage(&mut storage).await?; PersistentWallet::create(&wallet_path, genesis_config).context("failed to create wallet")? }; @@ -141,15 +146,8 @@ pub async fn run( // ── Sync admin chain (always) ── tracing::info!(%admin_chain_id, "Syncing admin chain from validators..."); - let committee = faucet.current_committee().await?; - tracing::info!( - validators = committee.validators().iter().count(), - "Fetched current committee, downloading chain state..." - ); let admin_client = ctx.make_chain_client(admin_chain_id).await?; - admin_client - .synchronize_chain_state_from_committee(committee) - .await?; + admin_client.synchronize_from_validators().await?; tracing::info!("Admin chain synced"); // ── Resolve bridge chain ── @@ -185,6 +183,9 @@ pub async fn run( (cid, owner) } else { // Claim from faucet. + let faucet = faucet + .as_ref() + .context("--faucet-url is required when --linera-bridge-chain-id is not provided")?; tracing::info!("Claiming bridge chain from faucet..."); let owner = AccountOwner::from(signer.generate_new()); let chain_desc = faucet.claim(&owner).await?; diff --git a/linera-bridge/tests/e2e/Cargo.lock b/linera-bridge/tests/e2e/Cargo.lock index 1e86e85a7a84..3f637e992a91 100644 --- a/linera-bridge/tests/e2e/Cargo.lock +++ b/linera-bridge/tests/e2e/Cargo.lock @@ -4638,9 +4638,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "papaya" -version = "0.1.9" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aeb8b9616002a83f9779ea70a2a44364fe804f8b532b96989d0790a34ad76479" +checksum = "f92dd0b07c53a0a0c764db2ace8c541dc47320dad97c2200c2a637ab9dd2328f" dependencies = [ "equivalent", "seize", @@ -5673,7 +5673,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5917,12 +5917,12 @@ dependencies = [ [[package]] name = "seize" -version = "0.4.9" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d84b0c858bdd30cb56f5597f8b3bf702ec23829e652cc636a1e5a7b9de46ae93" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] From 209479a207681eaa888e0bb9b23b5119327c1722 Mon Sep 17 00:00:00 2001 From: deuszx <95355183+deuszx@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:30:45 +0000 Subject: [PATCH 10/13] Persist Burn-s and Deposit-s in the SQlite db (#5817) ## Motivation The bridge relayer currently stores all deposit/burn request state in-memory via `MonitorState`. On restart, all request history is lost. We need persistent storage so that: 1. Request history survives restarts for auditing and debugging. 2. Requests can be queried by source, recipient, amount, timestamp, and status. 3. Raw BCS-serialized operation bytes can be `SELECT`ed and uploaded to the chains **without the relayer running**. ## Proposal Add a SQLite write-through layer alongside the existing in-memory `MonitorState`. Every mutation (track, complete, fail) now persists to SQLite in addition to updating the `HashMap`. **New file: `linera-bridge/src/monitor/db.rs`** - `BridgeDb` struct wrapping a `SqlitePool` - Two tables: `deposits` and `burns` with columns for source, recipient, amount, status, timestamps, and raw operation bytes (BCS-serialized `BridgeOperation::ProcessDeposit` for deposits, `ConfirmedBlockCertificate` for burns) - All writes are idempotent (`INSERT OR IGNORE` / `UPDATE`) **Changes to `MonitorState`:** - Holds an `Option` (None in tests without a DB) - `track_deposit`, `complete_deposit`, `track_burn`, `complete_burn`, `mark_*_failed` are now `async` and write through to SQLite internally **Raw bytes storage:** - Deposit proofs: BCS-serialized after proof generation in `monitor/evm.rs` - Burn certs: BCS-serialized after `linera_client.burn()` in `monitor/linera.rs` (panics on serialization failure since the cert must be serializable to forward to EVM) **CLI:** - New `--sqlite-path` option to override the default location (`bridge_relay.sqlite3` next to the RocksDB storage directory) - SQLite open failure is fatal (exits the binary) ## Test Plan - 9 unit tests parameterized via `test_case` across both in-memory and file-backed SQLite backends: insert, complete, fail, idempotency, raw bytes storage for both deposits and burns - 1 dedicated file persistence test verifying data survives close and reopen - All 92 existing `linera-bridge` tests continue to pass (`cargo test -p linera-bridge --features relay`) ## Release Plan - Nothing to do / These changes follow the usual release cycle. ## Links - [reviewer checklist](https://github.com/linera-io/linera-protocol/blob/main/CONTRIBUTING.md#reviewer-checklist) --- Cargo.lock | 2 + linera-bridge/Cargo.toml | 7 + linera-bridge/src/main.rs | 6 + linera-bridge/src/monitor/db.rs | 436 ++++++++++++++++++++++++++++ linera-bridge/src/monitor/evm.rs | 36 ++- linera-bridge/src/monitor/linera.rs | 45 ++- linera-bridge/src/monitor/mod.rs | 164 +++++++---- linera-bridge/src/relay/mod.rs | 26 +- 8 files changed, 652 insertions(+), 70 deletions(-) create mode 100644 linera-bridge/src/monitor/db.rs diff --git a/Cargo.lock b/Cargo.lock index fb21990cd3fd..b3e37301049c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5494,7 +5494,9 @@ dependencies = [ "serde-reflection", "serde_json", "serde_yaml 0.8.26", + "sqlx", "tempfile", + "test-case", "thiserror 1.0.69", "tokio", "tower-http 0.6.6", diff --git a/linera-bridge/Cargo.toml b/linera-bridge/Cargo.toml index 9967c0625caa..4a1a8d441ef1 100644 --- a/linera-bridge/Cargo.toml +++ b/linera-bridge/Cargo.toml @@ -54,6 +54,7 @@ relay = [ "linera-views/rocksdb", "dep:rustls", "dep:tower-http", + "dep:sqlx", "dep:tracing", "dep:tracing-subscriber", ] @@ -110,6 +111,10 @@ linera-storage = { workspace = true, optional = true } linera-views = { workspace = true, optional = true } prometheus = { workspace = true, optional = true } rustls = { version = "0.23", optional = true, features = ["ring"] } +sqlx = { workspace = true, optional = true, features = [ + "runtime-tokio-rustls", + "sqlite", +] } tower-http = { workspace = true, optional = true, features = ["cors"] } tracing = { workspace = true, optional = true } tracing-subscriber = { workspace = true, optional = true, features = ["fmt"] } @@ -154,4 +159,6 @@ alloy = { workspace = true, default-features = false, features = [ "reqwest", ] } alloy-sol-types.workspace = true +sqlx = { workspace = true, features = ["runtime-tokio-rustls", "sqlite"] } +test-case.workspace = true tokio = { workspace = true, features = ["full"] } diff --git a/linera-bridge/src/main.rs b/linera-bridge/src/main.rs index ef8bd6d121b6..6525768ac177 100644 --- a/linera-bridge/src/main.rs +++ b/linera-bridge/src/main.rs @@ -125,6 +125,11 @@ struct ServeOptions { /// Maximum number of retry attempts for pending deposits and burns. #[arg(long, default_value = "10")] max_retries: u32, + + /// Path to the SQLite database for persistent request storage. + /// Defaults to `bridge_relay.sqlite3` next to the RocksDB storage directory. + #[arg(long)] + sqlite_path: Option, } fn main() -> Result<()> { @@ -165,6 +170,7 @@ impl ServeOptions { self.monitor_scan_interval, self.monitor_start_block, self.max_retries, + self.sqlite_path.as_deref(), )) .await } diff --git a/linera-bridge/src/monitor/db.rs b/linera-bridge/src/monitor/db.rs new file mode 100644 index 000000000000..698c1a383a16 --- /dev/null +++ b/linera-bridge/src/monitor/db.rs @@ -0,0 +1,436 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! SQLite persistent storage for bridge relayer deposit/burn requests. +//! +//! This is a write-through layer alongside the in-memory `MonitorState`. +//! It persists request metadata and raw operation bytes so they can be +//! queried and replayed without the relayer running. + +use std::path::Path; + +use anyhow::Result; +use sqlx::{ + sqlite::{SqliteConnectOptions, SqlitePoolOptions}, + SqlitePool, +}; + +use super::{PendingBurn, PendingDeposit}; +use crate::proof::DepositKey; + +/// Persistent SQLite store for bridging requests. +pub struct BridgeDb { + pool: SqlitePool, +} + +impl BridgeDb { + /// Opens (or creates) the SQLite database at `path` and runs migrations. + pub async fn open(path: &Path) -> Result { + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await?; + let db = Self { pool }; + db.create_tables().await?; + Ok(db) + } + + /// Opens an in-memory database for testing. + #[cfg(test)] + pub async fn open_in_memory() -> Result { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await?; + let db = Self { pool }; + db.create_tables().await?; + Ok(db) + } + + async fn create_tables(&self) -> Result<()> { + sqlx::query( + "CREATE TABLE IF NOT EXISTS deposits ( + source_chain_id INTEGER NOT NULL, + block_hash BLOB NOT NULL, + tx_index INTEGER NOT NULL, + log_index INTEGER NOT NULL, + tx_hash BLOB NOT NULL, + depositor BLOB NOT NULL, + amount TEXT NOT NULL, + nonce TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + raw_operation BLOB, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (source_chain_id, block_hash, tx_index, log_index) + )", + ) + .execute(&self.pool) + .await?; + + sqlx::query("CREATE INDEX IF NOT EXISTS idx_deposits_status ON deposits(status)") + .execute(&self.pool) + .await?; + + sqlx::query("CREATE INDEX IF NOT EXISTS idx_deposits_depositor ON deposits(depositor)") + .execute(&self.pool) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS burns ( + linera_height INTEGER NOT NULL, + burn_index INTEGER NOT NULL, + evm_recipient TEXT NOT NULL, + amount TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + raw_cert BLOB, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (linera_height, burn_index) + )", + ) + .execute(&self.pool) + .await?; + + sqlx::query("CREATE INDEX IF NOT EXISTS idx_burns_status ON burns(status)") + .execute(&self.pool) + .await?; + + sqlx::query("CREATE INDEX IF NOT EXISTS idx_burns_evm_recipient ON burns(evm_recipient)") + .execute(&self.pool) + .await?; + + Ok(()) + } + + /// Inserts a new deposit. Ignores duplicates (idempotent). + pub async fn insert_deposit(&self, deposit: &PendingDeposit) -> Result<()> { + sqlx::query( + "INSERT OR IGNORE INTO deposits + (source_chain_id, block_hash, tx_index, log_index, tx_hash, depositor, amount, nonce) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(deposit.key.source_chain_id as i64) + .bind(deposit.key.block_hash.as_slice()) + .bind(deposit.key.tx_index as i64) + .bind(deposit.key.log_index as i64) + .bind(deposit.tx_hash.as_slice()) + .bind(deposit.depositor.as_slice()) + .bind(deposit.amount.to_string()) + .bind(deposit.nonce.to_string()) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Updates a deposit's status and timestamp. + pub async fn update_deposit_status(&self, key: &DepositKey, status: &str) -> Result<()> { + sqlx::query( + "UPDATE deposits SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE source_chain_id = ? AND block_hash = ? AND tx_index = ? AND log_index = ?", + ) + .bind(status) + .bind(key.source_chain_id as i64) + .bind(key.block_hash.as_slice()) + .bind(key.tx_index as i64) + .bind(key.log_index as i64) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Stores raw BCS-serialized operation bytes for a deposit. + pub async fn store_deposit_raw(&self, key: &DepositKey, raw: &[u8]) -> Result<()> { + sqlx::query( + "UPDATE deposits SET raw_operation = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE source_chain_id = ? AND block_hash = ? AND tx_index = ? AND log_index = ?", + ) + .bind(raw) + .bind(key.source_chain_id as i64) + .bind(key.block_hash.as_slice()) + .bind(key.tx_index as i64) + .bind(key.log_index as i64) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Inserts a new burn. Ignores duplicates (idempotent). + pub async fn insert_burn(&self, burn: &PendingBurn) -> Result<()> { + sqlx::query( + "INSERT OR IGNORE INTO burns (linera_height, burn_index, evm_recipient, amount) + VALUES (?, ?, ?, ?)", + ) + .bind(burn.linera_height as i64) + .bind(burn.burn_index as i64) + .bind(&burn.evm_recipient) + .bind(&burn.amount) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Updates a burn's status and timestamp. + pub async fn update_burn_status(&self, height: u64, index: usize, status: &str) -> Result<()> { + sqlx::query( + "UPDATE burns SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE linera_height = ? AND burn_index = ?", + ) + .bind(status) + .bind(height as i64) + .bind(index as i64) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Stores raw BCS-serialized certificate bytes for a burn. + pub async fn store_burn_raw(&self, height: u64, index: usize, raw: &[u8]) -> Result<()> { + sqlx::query( + "UPDATE burns SET raw_cert = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE linera_height = ? AND burn_index = ?", + ) + .bind(raw) + .bind(height as i64) + .bind(index as i64) + .execute(&self.pool) + .await?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicU32, Ordering}; + + use alloy::primitives::{Address, B256, U256}; + use test_case::test_case; + + use super::*; + + static FILE_COUNTER: AtomicU32 = AtomicU32::new(0); + + async fn open_db(use_file: bool) -> BridgeDb { + if use_file { + let n = FILE_COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::path::PathBuf::from(format!("/tmp/bridge_db_test_{n}.sqlite3")); + let _ = std::fs::remove_file(&path); + BridgeDb::open(&path).await.unwrap() + } else { + BridgeDb::open_in_memory().await.unwrap() + } + } + + fn test_deposit_key() -> DepositKey { + DepositKey { + source_chain_id: 8453, + block_hash: [0xAA; 32], + tx_index: 5, + log_index: 0, + } + } + + fn test_deposit() -> PendingDeposit { + PendingDeposit { + key: test_deposit_key(), + tx_hash: B256::from([0xBB; 32]), + depositor: Address::from([0xCC; 20]), + amount: U256::from(1_000_000u64), + nonce: U256::from(42u64), + } + } + + fn test_burn() -> PendingBurn { + PendingBurn { + linera_height: 100, + burn_index: 0, + evm_recipient: "0xabcdef1234567890abcdef1234567890abcdef12".to_string(), + amount: "500000".to_string(), + } + } + + #[test_case(false; "in_memory")] + #[test_case(true; "file_backed")] + #[tokio::test] + async fn test_insert_and_query_deposit(use_file: bool) { + let db = open_db(use_file).await; + db.insert_deposit(&test_deposit()).await.unwrap(); + + let row: (String, Vec, String) = sqlx::query_as( + "SELECT amount, depositor, status FROM deposits WHERE source_chain_id = 8453", + ) + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(row.0, "1000000"); + assert_eq!(row.1, Address::from([0xCC; 20]).as_slice()); + assert_eq!(row.2, "pending"); + } + + #[test_case(false; "in_memory")] + #[test_case(true; "file_backed")] + #[tokio::test] + async fn test_complete_deposit(use_file: bool) { + let db = open_db(use_file).await; + db.insert_deposit(&test_deposit()).await.unwrap(); + db.update_deposit_status(&test_deposit_key(), "completed") + .await + .unwrap(); + + let (status,): (String,) = + sqlx::query_as("SELECT status FROM deposits WHERE source_chain_id = 8453") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(status, "completed"); + } + + #[test_case(false; "in_memory")] + #[test_case(true; "file_backed")] + #[tokio::test] + async fn test_fail_deposit(use_file: bool) { + let db = open_db(use_file).await; + db.insert_deposit(&test_deposit()).await.unwrap(); + db.update_deposit_status(&test_deposit_key(), "failed") + .await + .unwrap(); + + let (status,): (String,) = + sqlx::query_as("SELECT status FROM deposits WHERE source_chain_id = 8453") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(status, "failed"); + } + + #[test_case(false; "in_memory")] + #[test_case(true; "file_backed")] + #[tokio::test] + async fn test_insert_deposit_idempotent(use_file: bool) { + let db = open_db(use_file).await; + db.insert_deposit(&test_deposit()).await.unwrap(); + db.insert_deposit(&test_deposit()).await.unwrap(); + + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM deposits") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(count, 1); + } + + #[test_case(false; "in_memory")] + #[test_case(true; "file_backed")] + #[tokio::test] + async fn test_store_and_retrieve_deposit_raw(use_file: bool) { + let db = open_db(use_file).await; + db.insert_deposit(&test_deposit()).await.unwrap(); + + let raw_bytes = vec![1, 2, 3, 4, 5]; + db.store_deposit_raw(&test_deposit_key(), &raw_bytes) + .await + .unwrap(); + + let (raw,): (Vec,) = + sqlx::query_as("SELECT raw_operation FROM deposits WHERE source_chain_id = 8453") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(raw, raw_bytes); + } + + #[test_case(false; "in_memory")] + #[test_case(true; "file_backed")] + #[tokio::test] + async fn test_insert_and_query_burn(use_file: bool) { + let db = open_db(use_file).await; + db.insert_burn(&test_burn()).await.unwrap(); + + let row: (String, String, String) = sqlx::query_as( + "SELECT evm_recipient, amount, status FROM burns WHERE linera_height = 100", + ) + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(row.0, "0xabcdef1234567890abcdef1234567890abcdef12"); + assert_eq!(row.1, "500000"); + assert_eq!(row.2, "pending"); + } + + #[test_case(false; "in_memory")] + #[test_case(true; "file_backed")] + #[tokio::test] + async fn test_complete_burn(use_file: bool) { + let db = open_db(use_file).await; + db.insert_burn(&test_burn()).await.unwrap(); + db.update_burn_status(100, 0, "completed").await.unwrap(); + + let (status,): (String,) = + sqlx::query_as("SELECT status FROM burns WHERE linera_height = 100") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(status, "completed"); + } + + #[test_case(false; "in_memory")] + #[test_case(true; "file_backed")] + #[tokio::test] + async fn test_insert_burn_idempotent(use_file: bool) { + let db = open_db(use_file).await; + db.insert_burn(&test_burn()).await.unwrap(); + db.insert_burn(&test_burn()).await.unwrap(); + + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM burns") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(count, 1); + } + + #[test_case(false; "in_memory")] + #[test_case(true; "file_backed")] + #[tokio::test] + async fn test_store_and_retrieve_burn_raw(use_file: bool) { + let db = open_db(use_file).await; + db.insert_burn(&test_burn()).await.unwrap(); + + let cert_bytes = vec![10, 20, 30, 40, 50]; + db.store_burn_raw(100, 0, &cert_bytes).await.unwrap(); + + let (raw,): (Vec,) = + sqlx::query_as("SELECT raw_cert FROM burns WHERE linera_height = 100") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(raw, cert_bytes); + } + + #[tokio::test] + async fn test_file_persistence_survives_reopen() { + let path = std::path::PathBuf::from("/tmp/bridge_db_test_reopen.sqlite3"); + let _ = std::fs::remove_file(&path); + + { + let db = BridgeDb::open(&path).await.unwrap(); + db.insert_deposit(&test_deposit()).await.unwrap(); + db.update_deposit_status(&test_deposit_key(), "completed") + .await + .unwrap(); + } + + { + let db = BridgeDb::open(&path).await.unwrap(); + let (status,): (String,) = + sqlx::query_as("SELECT status FROM deposits WHERE source_chain_id = 8453") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(status, "completed"); + } + + let _ = std::fs::remove_file(&path); + } +} diff --git a/linera-bridge/src/monitor/evm.rs b/linera-bridge/src/monitor/evm.rs index 5c54925ebf75..4c04e0fc63c0 100644 --- a/linera-bridge/src/monitor/evm.rs +++ b/linera-bridge/src/monitor/evm.rs @@ -84,20 +84,40 @@ pub(crate) async fn retry_pending_deposits match linera_client.process_deposit(proof).await { - Ok(()) => { - tracing::info!(%tx_hash, "Deposit processed successfully"); + Ok(proof) => { + // Persist raw BCS operation bytes so deposits can be replayed without the relayer. + if let Some(db) = monitor.read().await.db() { + for &log_index in &proof.log_indices { + let op = crate::relay::evm::BridgeOperation::ProcessDeposit { + block_header_rlp: proof.block_header_rlp.clone(), + receipt_rlp: proof.receipt_rlp.clone(), + proof_nodes: proof.proof_nodes.clone(), + tx_index: proof.tx_index, + log_index, + }; + if let Ok(raw) = bcs::to_bytes(&op) { + if let Err(e) = db.store_deposit_raw(&pending.key, &raw).await { + tracing::warn!(%tx_hash, "Failed to store deposit raw bytes: {e:#}"); + } + } + } } - Err(e) => { - tracing::warn!(%tx_hash, "Deposit processing failed: {e}"); + + match linera_client.process_deposit(proof).await { + Ok(()) => { + tracing::info!(%tx_hash, "Deposit processed successfully"); + } + Err(e) => { + tracing::warn!(%tx_hash, "Deposit processing failed: {e}"); + } } - }, + } Err(e) => { tracing::warn!(%tx_hash, "Proof generation failed: {e:#}"); } @@ -195,7 +215,7 @@ async fn check_deposit_completion( for key in pending { if linera_client.query_deposit_processed(&key).await? { - monitor.write().await.complete_deposit(&key); + monitor.write().await.complete_deposit(&key).await; } } diff --git a/linera-bridge/src/monitor/linera.rs b/linera-bridge/src/monitor/linera.rs index 39fbcbc3f900..da48c4b729bd 100644 --- a/linera-bridge/src/monitor/linera.rs +++ b/linera-bridge/src/monitor/linera.rs @@ -108,7 +108,7 @@ pub(crate) async fn retry_pending_burns { tracing::info!(credit_height, burn_index, "Burn forwarded to EVM"); - monitor - .write() - .await - .complete_burn(credit_height, burn_index); + true } Err(e) => { let msg = format!("{e:#}"); if msg.contains("already verified") { tracing::trace!(credit_height, burn_index, "Block already verified on EVM"); - monitor - .write() - .await - .complete_burn(credit_height, burn_index); + true } else { tracing::warn!(credit_height, burn_index, "EVM forwarding failed: {e:#}"); monitor .write() .await .mark_burn_retried(credit_height, burn_index); + false } } + }; + + if completed { + monitor + .write() + .await + .complete_burn(credit_height, burn_index) + .await; } } @@ -235,7 +254,11 @@ async fn check_burn_completion( for (height, burn_index, recipient) in pending { let logs = evm_client.get_transfer_logs(recipient).await?; if !logs.is_empty() { - monitor.write().await.complete_burn(height, burn_index); + monitor + .write() + .await + .complete_burn(height, burn_index) + .await; } } diff --git a/linera-bridge/src/monitor/mod.rs b/linera-bridge/src/monitor/mod.rs index 628cce37df7f..8bb248ac4b0b 100644 --- a/linera-bridge/src/monitor/mod.rs +++ b/linera-bridge/src/monitor/mod.rs @@ -8,6 +8,7 @@ //! - **Linera scan** ([`linera`]): walks block history for Credit-to-Address20 messages, //! checks EVM for completion via ERC-20 `Transfer` events. +pub mod db; pub mod evm; pub mod linera; @@ -95,6 +96,7 @@ pub struct MonitorState { pub(crate) burns: HashMap<(u64, usize), TrackedBurn>, pub(crate) last_scanned_evm_block: u64, pub(crate) last_scanned_linera_height: u64, + db: Option, } impl MonitorState { @@ -104,16 +106,32 @@ impl MonitorState { burns: HashMap::new(), last_scanned_evm_block: start_evm_block, last_scanned_linera_height: 0, + db: None, } } + /// Sets the persistent SQLite database for write-through storage. + pub fn set_db(&mut self, db: db::BridgeDb) { + self.db = Some(db); + } + + /// Returns a reference to the database, if configured. + pub fn db(&self) -> Option<&db::BridgeDb> { + self.db.as_ref() + } + /// Tracks a deposit. Returns `true` if this is a newly discovered deposit. /// Uses Entry API instead of insert() to avoid overwriting existing entries /// that may have accumulated retry state. - pub fn track_deposit(&mut self, pending: PendingDeposit) -> bool { + pub async fn track_deposit(&mut self, pending: PendingDeposit) -> bool { match self.deposits.entry(pending.key.clone()) { Entry::Occupied(_) => false, Entry::Vacant(e) => { + if let Some(db) = &self.db { + if let Err(e) = db.insert_deposit(&pending).await { + tracing::warn!("Failed to persist deposit to SQLite: {e:#}"); + } + } e.insert(Tracked::new(pending)); crate::relay::metrics::deposit_detected(); true @@ -121,10 +139,15 @@ impl MonitorState { } } - pub fn complete_deposit(&mut self, key: &DepositKey) { + pub async fn complete_deposit(&mut self, key: &DepositKey) { if let Some(d) = self.deposits.get_mut(key) { d.forwarded = true; crate::relay::metrics::deposit_completed(); + if let Some(db) = &self.db { + if let Err(e) = db.update_deposit_status(key, "completed").await { + tracing::warn!(?key, "Failed to update deposit status in SQLite: {e:#}"); + } + } } else { tracing::warn!(deposit_id = ?key, "Attempted to complete unknown deposit"); } @@ -133,11 +156,16 @@ impl MonitorState { /// Tracks a burn. Returns `true` if this is a newly discovered burn. /// Uses Entry API instead of insert() to avoid overwriting existing entries /// that may have accumulated retry state. - pub fn track_burn(&mut self, pending: PendingBurn) -> bool { + pub async fn track_burn(&mut self, pending: PendingBurn) -> bool { let key = (pending.linera_height, pending.burn_index); match self.burns.entry(key) { Entry::Occupied(_) => false, Entry::Vacant(e) => { + if let Some(db) = &self.db { + if let Err(err) = db.insert_burn(&pending).await { + tracing::warn!("Failed to persist burn to SQLite: {err:#}"); + } + } e.insert(Tracked::new(pending)); crate::relay::metrics::burn_detected(); true @@ -145,10 +173,22 @@ impl MonitorState { } } - pub fn complete_burn(&mut self, linera_height: u64, burn_index: usize) { + pub async fn complete_burn(&mut self, linera_height: u64, burn_index: usize) { if let Some(b) = self.burns.get_mut(&(linera_height, burn_index)) { b.forwarded = true; crate::relay::metrics::burn_completed(); + if let Some(db) = &self.db { + if let Err(e) = db + .update_burn_status(linera_height, burn_index, "completed") + .await + { + tracing::warn!( + linera_height, + burn_index, + "Failed to update burn status in SQLite: {e:#}" + ); + } + } } else { tracing::warn!( linera_height, @@ -211,10 +251,15 @@ impl MonitorState { } } - pub fn mark_deposit_failed(&mut self, key: &DepositKey) { + pub async fn mark_deposit_failed(&mut self, key: &DepositKey) { if let Some(d) = self.deposits.get_mut(key) { d.failed = true; crate::relay::metrics::deposit_failed(); + if let Some(db) = &self.db { + if let Err(e) = db.update_deposit_status(key, "failed").await { + tracing::warn!(?key, "Failed to update deposit status in SQLite: {e:#}"); + } + } } } @@ -225,10 +270,19 @@ impl MonitorState { } } - pub fn mark_burn_failed(&mut self, height: u64, burn_index: usize) { + pub async fn mark_burn_failed(&mut self, height: u64, burn_index: usize) { if let Some(b) = self.burns.get_mut(&(height, burn_index)) { b.failed = true; crate::relay::metrics::burn_failed(); + if let Some(db) = &self.db { + if let Err(e) = db.update_burn_status(height, burn_index, "failed").await { + tracing::warn!( + height, + burn_index, + "Failed to update burn status in SQLite: {e:#}" + ); + } + } } } @@ -329,8 +383,8 @@ mod tests { assert_ne!(key1.hash(), key2.hash()); } - #[test] - fn test_monitor_state_track_and_complete() { + #[tokio::test] + async fn test_monitor_state_track_and_complete() { let mut state = MonitorState::new(0); let key = DepositKey { @@ -339,45 +393,49 @@ mod tests { tx_index: 1, log_index: 0, }; - state.track_deposit(PendingDeposit { - key: key.clone(), - tx_hash: B256::ZERO, - depositor: Address::ZERO, - amount: U256::from(1000), - nonce: U256::from(0), - }); + state + .track_deposit(PendingDeposit { + key: key.clone(), + tx_hash: B256::ZERO, + depositor: Address::ZERO, + amount: U256::from(1000), + nonce: U256::from(0), + }) + .await; assert_eq!(state.pending_deposits().len(), 1); assert_eq!(state.completed_deposits().len(), 0); - state.complete_deposit(&key); + state.complete_deposit(&key).await; assert_eq!(state.pending_deposits().len(), 0); assert_eq!(state.completed_deposits().len(), 1); } - #[test] - fn test_monitor_state_track_and_forward_burn() { + #[tokio::test] + async fn test_monitor_state_track_and_forward_burn() { let mut state = MonitorState::new(0); - state.track_burn(PendingBurn { - linera_height: 10, - burn_index: 0, - evm_recipient: "0xabcd".to_string(), - amount: "500".to_string(), - }); + state + .track_burn(PendingBurn { + linera_height: 10, + burn_index: 0, + evm_recipient: "0xabcd".to_string(), + amount: "500".to_string(), + }) + .await; assert_eq!(state.pending_burns().len(), 1); assert_eq!(state.completed_burns().len(), 0); - state.complete_burn(10, 0); + state.complete_burn(10, 0).await; assert_eq!(state.pending_burns().len(), 0); assert_eq!(state.completed_burns().len(), 1); } - #[test] - fn test_status_summary() { + #[tokio::test] + async fn test_status_summary() { let mut state = MonitorState::new(100); let key = DepositKey { @@ -386,19 +444,23 @@ mod tests { tx_index: 0, log_index: 0, }; - state.track_deposit(PendingDeposit { - key: key.clone(), - tx_hash: B256::ZERO, - depositor: Address::ZERO, - amount: U256::ZERO, - nonce: U256::ZERO, - }); - state.track_burn(PendingBurn { - linera_height: 5, - burn_index: 0, - evm_recipient: "0x1234".to_string(), - amount: "100".to_string(), - }); + state + .track_deposit(PendingDeposit { + key: key.clone(), + tx_hash: B256::ZERO, + depositor: Address::ZERO, + amount: U256::ZERO, + nonce: U256::ZERO, + }) + .await; + state + .track_burn(PendingBurn { + linera_height: 5, + burn_index: 0, + evm_recipient: "0x1234".to_string(), + amount: "100".to_string(), + }) + .await; let summary = state.status_summary(); assert_eq!(summary.deposits_pending, 1); @@ -430,8 +492,8 @@ mod tests { assert!(retry_eligible(0, Some(long_ago), 10)); } - #[test] - fn test_deposits_ready_for_retry() { + #[tokio::test] + async fn test_deposits_ready_for_retry() { let mut state = MonitorState::new(0); let key = DepositKey { source_chain_id: 1, @@ -439,20 +501,22 @@ mod tests { tx_index: 0, log_index: 0, }; - state.track_deposit(PendingDeposit { - key: key.clone(), - tx_hash: B256::ZERO, - depositor: Address::ZERO, - amount: U256::ZERO, - nonce: U256::ZERO, - }); + state + .track_deposit(PendingDeposit { + key: key.clone(), + tx_hash: B256::ZERO, + depositor: Address::ZERO, + amount: U256::ZERO, + nonce: U256::ZERO, + }) + .await; assert_eq!(state.deposits_ready_for_retry(10).len(), 1); state.mark_deposit_retried(&key); assert_eq!(state.deposits_ready_for_retry(10).len(), 0); - state.mark_deposit_failed(&key); + state.mark_deposit_failed(&key).await; assert_eq!(state.deposits_ready_for_retry(10).len(), 0); } } diff --git a/linera-bridge/src/relay/mod.rs b/linera-bridge/src/relay/mod.rs index f668cb946353..2f510c601f03 100644 --- a/linera-bridge/src/relay/mod.rs +++ b/linera-bridge/src/relay/mod.rs @@ -64,6 +64,7 @@ pub async fn run( monitor_scan_interval: u64, monitor_start_block: u64, max_retries: u32, + sqlite_path: Option<&Path>, ) -> Result<()> { tracing_subscriber::fmt::init(); @@ -217,6 +218,8 @@ pub async fn run( monitor_scan_interval, monitor_start_block, max_retries, + sqlite_path, + Path::new(db_path), )) .await } @@ -266,6 +269,8 @@ async fn serve_loop( monitor_scan_interval: u64, monitor_start_block: u64, max_retries: u32, + sqlite_path_override: Option<&Path>, + storage_dir: &Path, ) -> Result<()> { // ── Set up centralized clients ── let bridge_addr: Address = evm_bridge_address @@ -302,7 +307,26 @@ async fn serve_loop( let chain_listener_handle = tokio::spawn(listener); // ── Monitor state + scan/retry ── - let monitor = Arc::new(RwLock::new(MonitorState::new(monitor_start_block))); + let mut monitor_state = MonitorState::new(monitor_start_block); + let default_sqlite_path = storage_dir + .parent() + .unwrap_or(storage_dir) + .join("bridge_relay.sqlite3"); + let sqlite_path = match sqlite_path_override { + Some(p) => p, + None => &default_sqlite_path, + }; + let db = monitor::db::BridgeDb::open(sqlite_path) + .await + .with_context(|| { + format!( + "failed to open SQLite database at {}", + sqlite_path.display() + ) + })?; + tracing::info!(path = %sqlite_path.display(), "Opened bridge relay SQLite database"); + monitor_state.set_db(db); + let monitor = Arc::new(RwLock::new(monitor_state)); let scan_interval = Duration::from_secs(monitor_scan_interval); let (pending_deposit_tx, pending_deposit_rx) = tokio::sync::mpsc::channel::(64); From 8b647857eeb5ea676909284fbdfbfe5438eff47b Mon Sep 17 00:00:00 2001 From: deuszx <95355183+deuszx@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:59:31 +0000 Subject: [PATCH 11/13] Relayer specify bridge chain owner (#5821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation Bridge e2e tests are failing. Turns out the problem is non-deterministic choice of the owner from the wallet of bridge chain owner. Another issue was that logs were incorrectly sent to stdout which – if not consumed – would fill up the buffer (64k) and then block other processes from writing to it. Which, in our case, caused some problems down the line where things were read from the log output. ## Proposal For the logging issue, two fixes: 1. Initialize the tracing subscriber properly (using a method from `linera_base`) 2. For docker-compose based tests (via `test-containers` crate) we pipe do /dev/null. For the non-deteministic owner we add a new argument to the linera relayer `--linera-bridge-chain-owner` and specify exactly who's the owner. ## Test Plan CI ## Release Plan None for now. ## Links - [reviewer checklist](https://github.com/linera-io/linera-protocol/blob/main/CONTRIBUTING.md#reviewer-checklist) --- docker/Dockerfile.bridge | 1 + docker/bridge-entrypoint.sh | 3 +- docker/docker-compose.bridge-test.yml | 1 + examples/bridge-demo/setup.sh | 1 + linera-bridge/src/main.rs | 5 +++ linera-bridge/src/relay/mod.rs | 44 +++++++++++-------- .../tests/e2e/tests/auto_deposit_scan.rs | 3 +- 7 files changed, 37 insertions(+), 21 deletions(-) diff --git a/docker/Dockerfile.bridge b/docker/Dockerfile.bridge index ff48702564a4..f5dff8b798f3 100644 --- a/docker/Dockerfile.bridge +++ b/docker/Dockerfile.bridge @@ -130,6 +130,7 @@ ENV EVENT_CACHE_SIZE=1000 # ── Optional ── # LINERA_BRIDGE_CHAIN_ID Linera bridge chain ID (claims new if omitted) +# LINERA_BRIDGE_CHAIN_OWNER Owner for the bridge chain (required with LINERA_BRIDGE_CHAIN_ID) EXPOSE 3001 diff --git a/docker/bridge-entrypoint.sh b/docker/bridge-entrypoint.sh index 46f0621c1819..7cfc383013f2 100644 --- a/docker/bridge-entrypoint.sh +++ b/docker/bridge-entrypoint.sh @@ -31,9 +31,10 @@ if [ -n "$FAUCET_URL" ]; then set -- "$@" --faucet-url="$FAUCET_URL" fi -# Optional: bridge chain ID +# Optional: bridge chain ID + owner (owner is required when chain ID is provided) if [ -n "$LINERA_BRIDGE_CHAIN_ID" ]; then set -- "$@" --linera-bridge-chain-id="$LINERA_BRIDGE_CHAIN_ID" + set -- "$@" --linera-bridge-chain-owner="${LINERA_BRIDGE_CHAIN_OWNER:?LINERA_BRIDGE_CHAIN_OWNER is required when LINERA_BRIDGE_CHAIN_ID is set}" fi # LINERA_WALLET, LINERA_KEYSTORE, LINERA_STORAGE are read directly by clap diff --git a/docker/docker-compose.bridge-test.yml b/docker/docker-compose.bridge-test.yml index 50df6cc093b1..8212df94c29e 100644 --- a/docker/docker-compose.bridge-test.yml +++ b/docker/docker-compose.bridge-test.yml @@ -338,6 +338,7 @@ services: export LINERA_BRIDGE_APP=$$(cat /shared/bridge-app-id | tr -d '[:space:]') export LINERA_FUNGIBLE_APP=$$(cat /shared/wrapped-app-id | tr -d '[:space:]') export LINERA_BRIDGE_CHAIN_ID=$$(cat /shared/bridge-chain-id | tr -d '[:space:]') + export LINERA_BRIDGE_CHAIN_OWNER=$$(cat /shared/relay-owner | tr -d '[:space:]') export EVM_PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 export LINERA_WALLET=/shared/relay-wallet/wallet.json export LINERA_KEYSTORE=/shared/relay-wallet/keystore.json diff --git a/examples/bridge-demo/setup.sh b/examples/bridge-demo/setup.sh index bc35faa0b7a7..a2bd52139752 100755 --- a/examples/bridge-demo/setup.sh +++ b/examples/bridge-demo/setup.sh @@ -586,6 +586,7 @@ echo " --wallet $LINERA_WALLET_PATH \\" echo " --keystore $LINERA_KEYSTORE_PATH \\" echo " --storage $LINERA_STORAGE_PATH \\" echo " --linera-bridge-chain-id $BRIDGE_CHAIN_ID \\" +echo " --linera-bridge-chain-owner $RELAY_OWNER \\" echo " --evm-bridge-address $BRIDGE_ADDRESS \\" echo " --linera-bridge-address $BRIDGE_APP_ID \\" echo " --linera-fungible-address $WRAPPED_APP_ID \\" diff --git a/linera-bridge/src/main.rs b/linera-bridge/src/main.rs index 6525768ac177..dc181fa0ab8a 100644 --- a/linera-bridge/src/main.rs +++ b/linera-bridge/src/main.rs @@ -74,6 +74,10 @@ struct ServeOptions { #[arg(long)] linera_bridge_chain_id: Option, + /// Owner to use for the bridge chain. Required when --linera-bridge-chain-id is provided. + #[arg(long, requires = "linera_bridge_chain_id")] + linera_bridge_chain_owner: Option, + /// Address of the FungibleBridge contract on EVM. #[arg(long)] evm_bridge_address: String, @@ -155,6 +159,7 @@ impl ServeOptions { self.keystore.as_deref(), self.storage.as_deref(), self.linera_bridge_chain_id, + self.linera_bridge_chain_owner, &self.evm_bridge_address, &self.linera_bridge_address, &self.linera_fungible_address, diff --git a/linera-bridge/src/relay/mod.rs b/linera-bridge/src/relay/mod.rs index 2f510c601f03..b275b98d1ca1 100644 --- a/linera-bridge/src/relay/mod.rs +++ b/linera-bridge/src/relay/mod.rs @@ -11,6 +11,8 @@ //! - **EVM forwarder**: after processing inbox and burns, BCS-serializes the resulting certificates //! and calls `FungibleBridge.addBlock(bytes)` on the EVM chain. +use linera_base::crypto::Signer as _; + pub mod evm; pub mod linera; pub(crate) mod metrics; @@ -55,6 +57,7 @@ pub async fn run( keystore_path: Option<&Path>, storage_config: Option<&str>, chain_id_arg: Option, + chain_owner_arg: Option, evm_bridge_address: &str, linera_bridge_address: &str, linera_fungible_address: &str, @@ -66,7 +69,7 @@ pub async fn run( max_retries: u32, sqlite_path: Option<&Path>, ) -> Result<()> { - tracing_subscriber::fmt::init(); + linera_base::tracing::init("linera-bridge"); // Tonic pulls in rustls 0.23 which requires an explicit crypto provider. rustls::crypto::ring::default_provider() @@ -153,17 +156,27 @@ pub async fn run( // ── Resolve bridge chain ── let (chain_id, _owner) = if let Some(cid) = chain_id_arg { - // Register in wallet if not already there. - if ctx.wallet().get(cid).is_none() { - let key_owner = signer.keys().first().context("keystore has no keys")?.0; - ctx.update_wallet_for_new_chain( - cid, - Some(key_owner), - linera_base::data_types::Timestamp::default(), - linera_base::data_types::Epoch::ZERO, - ) - .await?; - } + let owner = chain_owner_arg.context( + "--linera-bridge-chain-owner is required when --linera-bridge-chain-id is provided", + )?; + + // Verify the keystore has the signing key for this owner. + anyhow::ensure!( + signer + .contains_key(&owner) + .await + .context("failed to query keystore")?, + "keystore does not contain a key for owner {owner}" + ); + + // Register the chain with the specified owner. + ctx.update_wallet_for_new_chain( + cid, + Some(owner), + linera_base::data_types::Timestamp::default(), + linera_base::data_types::Epoch::ZERO, + ) + .await?; // Register for notifications so the listener can connect to validators. ctx.client @@ -173,13 +186,6 @@ pub async fn run( let chain_client = ctx.make_chain_client(cid).await?; chain_client.synchronize_from_validators().await?; - // Verify our keystore contains an owner key for this chain. - let ownership = chain_client.query_chain_ownership().await?; - let our_keys: Vec = signer.keys().into_iter().map(|(o, _)| o).collect(); - let owner = our_keys - .into_iter() - .find(|o| ownership.super_owners.contains(o) || ownership.owners.contains_key(o)) - .context("keystore has no key that is an owner of the specified --chain-id")?; tracing::info!(%cid, %owner, "Using pre-existing chain"); (cid, owner) } else { diff --git a/linera-bridge/tests/e2e/tests/auto_deposit_scan.rs b/linera-bridge/tests/e2e/tests/auto_deposit_scan.rs index 23c903d72839..6bc4d572f3c2 100644 --- a/linera-bridge/tests/e2e/tests/auto_deposit_scan.rs +++ b/linera-bridge/tests/e2e/tests/auto_deposit_scan.rs @@ -318,6 +318,7 @@ async fn test_auto_deposit_scan() -> anyhow::Result<()> { "--keystore", keystore_path.to_str().unwrap(), "--storage", &storage_path, &format!("--linera-bridge-chain-id={chain_a}"), + &format!("--linera-bridge-chain-owner={owner_a}"), &format!("--evm-bridge-address={bridge_addr}"), &format!("--linera-bridge-address={bridge_app_id}"), &format!("--linera-fungible-address={fungible_app_id}"), @@ -328,7 +329,7 @@ async fn test_auto_deposit_scan() -> anyhow::Result<()> { ]) .env("RUST_LOG", "linera=info,linera_bridge=debug") .kill_on_drop(true) - .stdout(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::inherit()) .spawn() .context("failed to spawn relay binary")?; From 688ae0ad356891b65059ae2581db3891bcb7649e Mon Sep 17 00:00:00 2001 From: deuszx Date: Mon, 30 Mar 2026 13:19:18 +0100 Subject: [PATCH 12/13] Fix authenticated_signer -> authenticated_owner and Fn/FnMut mismatch --- examples/evm-bridge/src/contract.rs | 2 +- examples/evm-bridge/tests/process_deposit.rs | 4 ++-- linera-service/src/exporter/main.rs | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/evm-bridge/src/contract.rs b/examples/evm-bridge/src/contract.rs index cbfbd06965c8..69e9c1b04f75 100644 --- a/examples/evm-bridge/src/contract.rs +++ b/examples/evm-bridge/src/contract.rs @@ -85,7 +85,7 @@ impl Contract for EvmBridgeContract { // Only cache when called by an authenticated signer (chain owner), // preventing unauthenticated callers from bloating state. - if self.runtime.authenticated_signer().is_some() { + if self.runtime.authenticated_owner().is_some() { self.state .verified_block_hashes .insert(&block_hash) diff --git a/examples/evm-bridge/tests/process_deposit.rs b/examples/evm-bridge/tests/process_deposit.rs index 17288ce90c0c..12f67006c441 100644 --- a/examples/evm-bridge/tests/process_deposit.rs +++ b/examples/evm-bridge/tests/process_deposit.rs @@ -716,7 +716,7 @@ async fn test_verify_block_hash_anvil() { .add_block(|block| { block.with_operation( bridge_app_id, - BridgeOperation::VerifyBlockHash { + &BridgeOperation::VerifyBlockHash { block_hash: block_hash.0, }, ); @@ -741,7 +741,7 @@ async fn test_verify_block_hash_not_found() { .try_add_block(|block| { block.with_operation( bridge_app_id, - BridgeOperation::VerifyBlockHash { + &BridgeOperation::VerifyBlockHash { block_hash: fake_hash, }, ); diff --git a/linera-service/src/exporter/main.rs b/linera-service/src/exporter/main.rs index 1ba2683b1a3b..6d7809859dc3 100644 --- a/linera-service/src/exporter/main.rs +++ b/linera-service/src/exporter/main.rs @@ -161,6 +161,7 @@ struct RunOptions { pub enable_memory_profiling: bool, } +#[allow(unused_variables)] async fn start_health_server( address: std::net::SocketAddr, shutdown_signal: CancellationToken, From 6315426d279cbda055a2b89751ac346efbdb197e Mon Sep 17 00:00:00 2001 From: deuszx Date: Mon, 30 Mar 2026 15:22:12 +0100 Subject: [PATCH 13/13] Fix block exporter port collisions in local net tests block_exporter_port (+3000) collided with proxy_metrics_port (+3000) and block_exporter_metrics_port (+4000) collided with proxy_public_port (+4000). --- linera-service/src/cli_wrappers/local_net.rs | 4 ++-- linera-service/src/exporter/tests.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/linera-service/src/cli_wrappers/local_net.rs b/linera-service/src/cli_wrappers/local_net.rs index 03032eeb20aa..7ecb88193a7a 100644 --- a/linera-service/src/cli_wrappers/local_net.rs +++ b/linera-service/src/cli_wrappers/local_net.rs @@ -507,7 +507,7 @@ impl LocalNet { } fn block_exporter_port(&self, validator: usize, exporter_id: usize) -> usize { - test_offset_port() + 3000 + validator * self.num_shards + exporter_id + 1 + test_offset_port() + 5000 + validator * self.num_shards + exporter_id + 1 } pub fn proxy_public_port(&self, validator: usize, proxy_id: usize) -> usize { @@ -519,7 +519,7 @@ impl LocalNet { } fn block_exporter_metrics_port(&self, validator: usize, exporter_id: usize) -> usize { - test_offset_port() + 4000 + validator * self.num_shards + exporter_id + 1 + test_offset_port() + 6000 + validator * self.num_shards + exporter_id + 1 } fn configuration_string(&self, server_number: usize) -> Result { diff --git a/linera-service/src/exporter/tests.rs b/linera-service/src/exporter/tests.rs index 6aac15b4340e..14bce1eb7ad8 100644 --- a/linera-service/src/exporter/tests.rs +++ b/linera-service/src/exporter/tests.rs @@ -49,7 +49,7 @@ async fn test_linera_exporter(database: Database, network: Network) -> Result<() port: 0, }, limits: LimitsConfig::default(), - metrics_port: 1234, + metrics_port: 0, }; let config = LocalNetConfig {