From fd02b6963499ac6a80d7750f42f959eed63fb6a8 Mon Sep 17 00:00:00 2001 From: deuszx Date: Wed, 13 May 2026 14:49:27 +0200 Subject: [PATCH 01/31] Drop Microchain.verifiedBlocks so subclasses own dedup --- linera-bridge/src/solidity/Microchain.sol | 24 +++------ .../src/solidity/test/Microchain.t.sol | 53 +++++++++++++++++++ 2 files changed, 61 insertions(+), 16 deletions(-) create mode 100644 linera-bridge/src/solidity/test/Microchain.t.sol diff --git a/linera-bridge/src/solidity/Microchain.sol b/linera-bridge/src/solidity/Microchain.sol index 5f5226dcb666..fd58a286e413 100644 --- a/linera-bridge/src/solidity/Microchain.sol +++ b/linera-bridge/src/solidity/Microchain.sol @@ -7,32 +7,24 @@ import "LightClient.sol"; abstract contract Microchain { LightClient public immutable lightClient; bytes32 public immutable chainId; - mapping(bytes32 => bool) public verifiedBlocks; constructor(address _lightClient, bytes32 _chainId) { lightClient = LightClient(_lightClient); chainId = _chainId; } - /// Verifies a certificate and accepts the block if it matches this chain. - /// - /// 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. - /// Blocks can be submitted in any order; the `verifiedBlocks` mapping - /// prevents duplicate processing. + /// Verifies a certificate and dispatches to the subclass. Subclasses + /// MUST be idempotent under repeated calls for the same block: this + /// contract no longer prevents re-entry on the same `signedHash`. + /// The off-chain relayer relies on this idempotency to safely + /// re-submit `addBlock(cert)` after partial settlement. function addBlock(bytes calldata data) external { - (BridgeTypes.Block memory blockValue, bytes32 signedHash) = lightClient.verifyBlock(data); - - require(!verifiedBlocks[signedHash], "block already verified"); + (BridgeTypes.Block memory blockValue, ) = lightClient.verifyBlock(data); require(blockValue.header.chain_id.value.value == chainId, "chain id mismatch"); - - verifiedBlocks[signedHash] = true; _onBlock(blockValue); } - /// Called after a block has been verified and accepted. Subcontracts implement - /// this to extract and store application-specific data from the block. + /// Called after a block has been verified and accepted. Subcontracts + /// implement this to extract and store application-specific data. function _onBlock(BridgeTypes.Block memory blockValue) internal virtual; } diff --git a/linera-bridge/src/solidity/test/Microchain.t.sol b/linera-bridge/src/solidity/test/Microchain.t.sol new file mode 100644 index 000000000000..c310f236f782 --- /dev/null +++ b/linera-bridge/src/solidity/test/Microchain.t.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.30; + +import {Test} from "forge-std/Test.sol"; +import {Microchain} from "../Microchain.sol"; +import {BridgeTypes} from "../BridgeTypes.sol"; + +/// Returns a hand-built `BridgeTypes.Block` directly from `verifyBlock` +/// without going through `vm.mockCall(...abi.encode(Block)...)`, which +/// triggers a solc 0.8.30 stack-too-deep on the `BridgeTypes.Block` +/// struct's via_ir codegen. +contract MockLightClient { + bytes32 public immutable expectedChainId; + + constructor(bytes32 _chainId) { + expectedChainId = _chainId; + } + + function verifyBlock(bytes calldata) + external + view + returns (BridgeTypes.Block memory b, bytes32 sigHash) + { + b.header.chain_id.value.value = expectedChainId; + sigHash = bytes32(uint256(0x1234)); + } +} + +/// Counts `_onBlock` invocations so the test can assert re-entry is allowed. +contract CountingMicrochain is Microchain { + uint256 public onBlockCalls; + + constructor(address _lc, bytes32 _cid) Microchain(_lc, _cid) {} + + function _onBlock(BridgeTypes.Block memory) internal override { + onBlockCalls += 1; + } +} + +contract MicrochainIdempotencyTest is Test { + bytes32 constant CHAIN_ID = bytes32(uint256(0xC1)); + + function test_addBlock_can_be_called_repeatedly_for_same_cert() public { + MockLightClient lc = new MockLightClient(CHAIN_ID); + CountingMicrochain mc = new CountingMicrochain(address(lc), CHAIN_ID); + + bytes memory cert = hex"deadbeef"; + mc.addBlock(cert); + mc.addBlock(cert); + + assertEq(mc.onBlockCalls(), 2, "addBlock must accept repeated calls; subclass owns dedup"); + } +} From 764bf5e1260d2c6fe0862799b28355b88e797661 Mon Sep 17 00:00:00 2001 From: deuszx Date: Wed, 13 May 2026 14:55:39 +0200 Subject: [PATCH 02/31] Add FungibleBridge.processBurns for per-tx chunked settlement --- linera-bridge/src/solidity/FungibleBridge.sol | 57 ++++ .../src/solidity/test/FungibleBridge.t.sol | 268 ++++++++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 linera-bridge/src/solidity/test/FungibleBridge.t.sol diff --git a/linera-bridge/src/solidity/FungibleBridge.sol b/linera-bridge/src/solidity/FungibleBridge.sol index 9433ade043ad..83aeffe1a045 100644 --- a/linera-bridge/src/solidity/FungibleBridge.sol +++ b/linera-bridge/src/solidity/FungibleBridge.sol @@ -125,6 +125,63 @@ contract FungibleBridge is Microchain { } } + /// Processes burns at the requested `eventPositionsInTx` positions + /// within transaction `txIndex` of `cert`. Verifies the cert once + /// and uses direct array access (`body.events[txIndex][pos]`) for + /// every burn — no nested-loop scan. The off-chain relayer uses + /// this when `addBlock(cert)` would not fit in a single EVM tx, + /// chunking burns per-tx-then-by-gas. + /// + /// Reverts (atomically — no `processedBurns` flag is set if the call + /// reverts) on: + /// - `txIndex` out of range (`"txIndex out of range"`) + /// - any position out of range (`"eventPos out of range"`) + /// - any position whose event is not a matching burn for this app + /// (`"not a matching burn"`) + /// - any burn already processed (`"burn already processed"`) + /// - any failed `token.transfer` (`"token transfer failed"`) + function processBurns( + bytes calldata data, + uint32 txIndex, + uint32[] calldata eventPositionsInTx + ) external { + (BridgeTypes.Block memory blockValue, ) = lightClient.verifyBlock(data); + require(blockValue.header.chain_id.value.value == chainId, "chain id mismatch"); + require(txIndex < blockValue.body.events.length, "txIndex out of range"); + + uint64 height = blockValue.header.height.value; + bytes32 burnsHash = keccak256("burns"); + BridgeTypes.Event[] memory txEvents = blockValue.body.events[txIndex]; + + for (uint256 k = 0; k < eventPositionsInTx.length; k++) { + uint32 pos = eventPositionsInTx[k]; + require(pos < txEvents.length, "eventPos out of range"); + BridgeTypes.Event memory evt = txEvents[pos]; + + if (evt.stream_id.application_id.choice != 1) { + revert("not a matching burn"); + } + if (evt.stream_id.application_id.user.application_description_hash.value + != fungibleApplicationId) { + revert("not a matching burn"); + } + if (keccak256(evt.stream_id.stream_name.value) != burnsHash) { + revert("not a matching burn"); + } + + bytes32 key = keccak256(abi.encode(height, evt.index)); + require(!processedBurns[key], "burn already processed"); + + WrappedFungibleTypes.BurnEvent memory burnEvt = + WrappedFungibleTypes.bcs_deserialize_BurnEvent(evt.value); + require( + token.transfer(address(burnEvt.target), burnEvt.amount.value), + "token transfer failed" + ); + processedBurns[key] = true; + } + } + /// @dev Calls transferFrom and handles tokens that don't return a boolean. function _safeTransferFrom(address from, address to, uint256 amount_) internal { (bool success, bytes memory data) = diff --git a/linera-bridge/src/solidity/test/FungibleBridge.t.sol b/linera-bridge/src/solidity/test/FungibleBridge.t.sol new file mode 100644 index 000000000000..a875f2b292c0 --- /dev/null +++ b/linera-bridge/src/solidity/test/FungibleBridge.t.sol @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.30; + +import {Test} from "forge-std/Test.sol"; +import {FungibleBridge} from "../FungibleBridge.sol"; +import {BridgeTypes} from "../BridgeTypes.sol"; +import {WrappedFungibleTypes} from "../WrappedFungibleTypes.sol"; +import {LineraToken} from "../LineraToken.sol"; + +// ------------------------------------------------------------------ +// Constants +// ------------------------------------------------------------------ + +bytes32 constant CHAIN_ID = bytes32(uint256(0xC1)); +uint64 constant HEIGHT = 42; +uint32 constant TX = 0; +uint128 constant AMOUNT = 1_000_000_000_000_000_000; // 1e18 +address constant RECIP_0 = address(0xA0); +address constant RECIP_1 = address(0xA1); +address constant RECIP_2 = address(0xA2); +bytes32 constant APP_ID = bytes32(uint256(0xF00D)); + +// ------------------------------------------------------------------ +// MockLightClientForBurns +// +// Returns a Block that has `numBurns` matching burn events at +// tx-slot `txIndexUsed` (preceding tx-slots are empty). +// Stream indices for the burns are 5, 6, ..., 4+numBurns. +// ------------------------------------------------------------------ +contract MockLightClientForBurns { + bytes32 public immutable chainIdRet; + uint64 public immutable heightRet; + uint32 public immutable txIndexUsed; + bytes32 public immutable fungibleAppIdRet; + uint32 public immutable numBurns; + uint128 public immutable amountPerBurn; + address public immutable recipBase; + + constructor( + bytes32 _chainId, + uint64 _height, + uint32 _txIndex, + bytes32 _fungibleAppId, + uint32 _numBurns, + uint128 _amountPerBurn, + address _recipBase + ) { + chainIdRet = _chainId; + heightRet = _height; + txIndexUsed = _txIndex; + fungibleAppIdRet = _fungibleAppId; + numBurns = _numBurns; + amountPerBurn = _amountPerBurn; + recipBase = _recipBase; + } + + function verifyBlock(bytes calldata) + external + view + returns (BridgeTypes.Block memory b, bytes32 sigHash) + { + b.header.chain_id.value.value = chainIdRet; + b.header.height.value = heightRet; + + // Allocate txIndexUsed + 1 tx-slots; all before txIndexUsed are empty. + b.body.events = new BridgeTypes.Event[][](uint256(txIndexUsed) + 1); + b.body.events[txIndexUsed] = new BridgeTypes.Event[](numBurns); + + for (uint32 i = 0; i < numBurns; i++) { + BridgeTypes.Event memory evt; + evt.stream_id.application_id.choice = 1; // User + evt.stream_id.application_id.user.application_description_hash.value = fungibleAppIdRet; + evt.stream_id.stream_name.value = bytes("burns"); + evt.index = 5 + i; // stream index differs from positional index + evt.value = _encodeBurn(address(uint160(recipBase) + i), amountPerBurn); + b.body.events[txIndexUsed][i] = evt; + } + + sigHash = bytes32(uint256(0x1234)); + } + + function _encodeBurn(address target, uint128 amount) private pure returns (bytes memory) { + WrappedFungibleTypes.BurnEvent memory burnEvt; + burnEvt.target = bytes20(target); + burnEvt.amount = BridgeTypes.Amount(amount); + return WrappedFungibleTypes.bcs_serialize_BurnEvent(burnEvt); + } +} + +// ------------------------------------------------------------------ +// MockLightClientForNonBurn +// +// Returns a Block whose single event has stream_name == "deposits" +// (not "burns"), so FungibleBridge.processBurns must reject it. +// ------------------------------------------------------------------ +contract MockLightClientForNonBurn { + bytes32 public immutable chainIdRet; + uint64 public immutable heightRet; + bytes32 public immutable fungibleAppIdRet; + uint128 public immutable amountPerBurn; + address public immutable recipBase; + + constructor( + bytes32 _chainId, + uint64 _height, + bytes32 _fungibleAppId, + uint128 _amountPerBurn, + address _recipBase + ) { + chainIdRet = _chainId; + heightRet = _height; + fungibleAppIdRet = _fungibleAppId; + amountPerBurn = _amountPerBurn; + recipBase = _recipBase; + } + + function verifyBlock(bytes calldata) + external + view + returns (BridgeTypes.Block memory b, bytes32 sigHash) + { + b.header.chain_id.value.value = chainIdRet; + b.header.height.value = heightRet; + + b.body.events = new BridgeTypes.Event[][](1); + b.body.events[0] = new BridgeTypes.Event[](1); + + BridgeTypes.Event memory evt; + evt.stream_id.application_id.choice = 1; + evt.stream_id.application_id.user.application_description_hash.value = fungibleAppIdRet; + // Wrong stream name — should cause "not a matching burn" + evt.stream_id.stream_name.value = bytes("deposits"); + evt.index = 5; + WrappedFungibleTypes.BurnEvent memory burnEvt; + burnEvt.target = bytes20(recipBase); + burnEvt.amount = BridgeTypes.Amount(amountPerBurn); + evt.value = WrappedFungibleTypes.bcs_serialize_BurnEvent(burnEvt); + b.body.events[0][0] = evt; + + sigHash = bytes32(uint256(0x1234)); + } +} + +// ------------------------------------------------------------------ +// Helpers +// ------------------------------------------------------------------ + +function _u32s(uint32 a, uint32 b) pure returns (uint32[] memory) { + uint32[] memory arr = new uint32[](2); + arr[0] = a; + arr[1] = b; + return arr; +} + +function _u32s_single(uint32 a) pure returns (uint32[] memory) { + uint32[] memory arr = new uint32[](1); + arr[0] = a; + return arr; +} + +// ------------------------------------------------------------------ +// Test contract +// ------------------------------------------------------------------ + +contract FungibleBridgeProcessBurnsTest is Test { + // Deploy a bridge backed by `lc`, with a LineraToken that has + // `supply` tokens pre-minted to the bridge. + function _deployBridge(address lc, uint256 supply) + internal + returns (FungibleBridge bridge, LineraToken tok) + { + tok = new LineraToken("Test", "TST", supply); + bridge = new FungibleBridge(lc, CHAIN_ID, address(tok), APP_ID); + // Send all tokens to the bridge so transfer() calls succeed. + tok.transfer(address(bridge), supply); + } + + // ------------------------------------------------------------------ + + function test_processBurns_single_position_marks_processed() public { + // 2 burns in tx TX at positions 0 and 1 with stream indices 5 and 6. + // Settle only position 0; assert (HEIGHT, 5) is flipped, (HEIGHT, 6) stays false. + MockLightClientForBurns lc = + new MockLightClientForBurns(CHAIN_ID, HEIGHT, TX, APP_ID, 2, AMOUNT, RECIP_0); + (FungibleBridge bridge,) = _deployBridge(address(lc), AMOUNT * 10); + + bridge.processBurns(hex"deadbeef", TX, _u32s_single(0)); + + assertTrue( bridge.isBurnProcessed(HEIGHT, 5), "stream index 5 should be processed"); + assertFalse(bridge.isBurnProcessed(HEIGHT, 6), "stream index 6 should not be processed yet"); + } + + function test_processBurns_multi_position_marks_both_processed() public { + // 2 burns; settle both positions; both flags true. + MockLightClientForBurns lc = + new MockLightClientForBurns(CHAIN_ID, HEIGHT, TX, APP_ID, 2, AMOUNT, RECIP_0); + (FungibleBridge bridge,) = _deployBridge(address(lc), AMOUNT * 10); + + bridge.processBurns(hex"deadbeef", TX, _u32s(0, 1)); + + assertTrue(bridge.isBurnProcessed(HEIGHT, 5), "stream index 5 should be processed"); + assertTrue(bridge.isBurnProcessed(HEIGHT, 6), "stream index 6 should be processed"); + } + + function test_processBurns_already_processed_reverts() public { + // 1 burn; settle; settle again → revert "burn already processed". + MockLightClientForBurns lc = + new MockLightClientForBurns(CHAIN_ID, HEIGHT, TX, APP_ID, 1, AMOUNT, RECIP_0); + (FungibleBridge bridge,) = _deployBridge(address(lc), AMOUNT * 10); + + bridge.processBurns(hex"deadbeef", TX, _u32s_single(0)); + + vm.expectRevert(bytes("burn already processed")); + bridge.processBurns(hex"deadbeef", TX, _u32s_single(0)); + } + + function test_processBurns_tx_index_out_of_range_reverts() public { + // Block has 1 tx; processBurns with txIndex=99 → revert "txIndex out of range". + MockLightClientForBurns lc = + new MockLightClientForBurns(CHAIN_ID, HEIGHT, TX, APP_ID, 1, AMOUNT, RECIP_0); + (FungibleBridge bridge,) = _deployBridge(address(lc), AMOUNT * 10); + + vm.expectRevert(bytes("txIndex out of range")); + bridge.processBurns(hex"deadbeef", 99, _u32s_single(0)); + } + + function test_processBurns_event_pos_out_of_range_reverts() public { + // 2 burns at positions 0,1; processBurns with position=99 → revert "eventPos out of range". + MockLightClientForBurns lc = + new MockLightClientForBurns(CHAIN_ID, HEIGHT, TX, APP_ID, 2, AMOUNT, RECIP_0); + (FungibleBridge bridge,) = _deployBridge(address(lc), AMOUNT * 10); + + vm.expectRevert(bytes("eventPos out of range")); + bridge.processBurns(hex"deadbeef", TX, _u32s_single(99)); + } + + function test_processBurns_non_burn_event_reverts() public { + // MockLightClient returns a Block whose only event has the wrong + // stream_name ("deposits") → processBurns(tx=0, [0]) → revert "not a matching burn". + MockLightClientForNonBurn lc = + new MockLightClientForNonBurn(CHAIN_ID, HEIGHT, APP_ID, AMOUNT, RECIP_0); + (FungibleBridge bridge,) = _deployBridge(address(lc), AMOUNT * 10); + + vm.expectRevert(bytes("not a matching burn")); + bridge.processBurns(hex"deadbeef", 0, _u32s_single(0)); + } + + function test_processBurns_partial_revert_is_atomic() public { + // 2 burns at positions 0,1. + // Step 1: settle position 1 alone (succeeds). + // Step 2: settle [0, 1] → reverts on position 1 ("burn already processed"). + // Assert (HEIGHT, stream-index-of-pos-0) is STILL false (revert rolled back pos-0 update). + MockLightClientForBurns lc = + new MockLightClientForBurns(CHAIN_ID, HEIGHT, TX, APP_ID, 2, AMOUNT, RECIP_0); + (FungibleBridge bridge,) = _deployBridge(address(lc), AMOUNT * 10); + + // Settle position 1 (stream index 6). + bridge.processBurns(hex"deadbeef", TX, _u32s_single(1)); + assertTrue(bridge.isBurnProcessed(HEIGHT, 6), "pos 1 should now be processed"); + + // Attempt to settle [0, 1] — position 1 will revert. + vm.expectRevert(bytes("burn already processed")); + bridge.processBurns(hex"deadbeef", TX, _u32s(0, 1)); + + // Position 0 (stream index 5) must be rolled back. + assertFalse(bridge.isBurnProcessed(HEIGHT, 5), "pos 0 must be rolled back by the revert"); + } +} From fd936d6a4012161a45b212d9940df8052f616448 Mon Sep 17 00:00:00 2001 From: deuszx Date: Wed, 13 May 2026 15:01:47 +0200 Subject: [PATCH 03/31] Add EvmClient gas-estimate + processBurns helpers --- linera-bridge/src/relay/evm.rs | 83 ++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/linera-bridge/src/relay/evm.rs b/linera-bridge/src/relay/evm.rs index 971122dfd43a..5b09fb451edb 100644 --- a/linera-bridge/src/relay/evm.rs +++ b/linera-bridge/src/relay/evm.rs @@ -19,6 +19,7 @@ sol! { #[sol(rpc)] interface IFungibleBridge { function addBlock(bytes calldata data) external; + function processBurns(bytes calldata data, uint32 txIndex, uint32[] calldata eventPositionsInTx) external; function lightClient() external view returns (address); function isBurnProcessed(uint64 height, uint32 eventIndex) external view returns (bool); } @@ -137,6 +138,72 @@ impl EvmClient

{ Ok(()) } + /// Dry-runs `addBlock(cert)` against the EVM to estimate the gas it + /// would consume. `Ok(g)` means the call would fit under the node's + /// current block gas limit (the value is the estimate); a gas-exceeded + /// RPC error indicates the call would not fit — see `is_gas_exceeded_error`. + /// Other RPC errors bubble up. + pub async fn estimate_add_block_gas( + &self, + cert: &linera_chain::types::ConfirmedBlockCertificate, + ) -> alloy::contract::Result { + let cert_bytes = bcs::to_bytes(cert).expect("BCS-serialize cert"); + tracing::trace!(size = cert_bytes.len(), "Estimating gas for addBlock"); + let bridge = IFungibleBridge::new(self.bridge_addr, &self.provider); + bridge.addBlock(cert_bytes.into()).estimate_gas().await + } + + /// Same as `estimate_add_block_gas` but for + /// `processBurns(cert, tx_index, positions_in_tx)`. + pub async fn estimate_process_burns_gas( + &self, + cert: &linera_chain::types::ConfirmedBlockCertificate, + tx_index: u32, + positions_in_tx: &[u32], + ) -> alloy::contract::Result { + let cert_bytes = bcs::to_bytes(cert).expect("BCS-serialize cert"); + tracing::trace!( + tx_index, + count = positions_in_tx.len(), + size = cert_bytes.len(), + "Estimating gas for processBurns" + ); + let bridge = IFungibleBridge::new(self.bridge_addr, &self.provider); + bridge + .processBurns(cert_bytes.into(), tx_index, positions_in_tx.to_vec()) + .estimate_gas() + .await + } + + /// Submits `processBurns(cert, tx_index, positions_in_tx)` and waits + /// for the receipt. Used after `split_to_fit` returns a chunk. + pub async fn process_burns( + &self, + cert: &linera_chain::types::ConfirmedBlockCertificate, + tx_index: u32, + positions_in_tx: &[u32], + ) -> Result<()> { + let cert_bytes = bcs::to_bytes(cert).expect("BCS-serialize cert"); + let bridge = IFungibleBridge::new(self.bridge_addr, &self.provider); + tracing::info!( + tx_index, + count = positions_in_tx.len(), + size = cert_bytes.len(), + "Calling processBurns on FungibleBridge..." + ); + let pending_tx = bridge + .processBurns(cert_bytes.into(), tx_index, positions_in_tx.to_vec()) + .send() + .await + .context("processBurns send failed")?; + let receipt = pending_tx + .get_receipt() + .await + .context("processBurns receipt failed")?; + tracing::info!(tx = ?receipt.transaction_hash, "processBurns transaction confirmed"); + Ok(()) + } + /// Discovers the LightClient contract address from the FungibleBridge. pub async fn get_light_client_address(&self) -> Result

{ self.light_client_addr @@ -197,3 +264,19 @@ impl EvmClient

{ Ok(receipt.transaction_hash) } } + +/// Returns true if the `alloy::contract::Error` reports that the call would not +/// fit under the node's block gas limit — i.e. the node refused to +/// estimate because the work required more gas than a single block can +/// hold. Substring-matches on the node's error message; pinned to the +/// known wordings for anvil, geth, and reth as of writing. +pub fn is_gas_exceeded_error(error: &alloy::contract::Error) -> bool { + let msg = error.to_string().to_lowercase(); + [ + "gas required exceeds", // anvil / geth + "exceeds block gas limit", // reth, foundry forks + "out of gas", // generic fallback + ] + .iter() + .any(|needle| msg.contains(needle)) +} From f7ce026c64dec7f5d8764920690b88a5583b73b8 Mon Sep 17 00:00:00 2001 From: deuszx Date: Wed, 13 May 2026 15:46:01 +0200 Subject: [PATCH 04/31] Add split_to_fit + estimate_fits pure helpers --- linera-bridge/src/evm/microchain.rs | 6 +- linera-bridge/src/relay/mod.rs | 1 + linera-bridge/src/relay/settlement.rs | 184 ++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 linera-bridge/src/relay/settlement.rs diff --git a/linera-bridge/src/evm/microchain.rs b/linera-bridge/src/evm/microchain.rs index ccade5f40a45..3a958d7c2c90 100644 --- a/linera-bridge/src/evm/microchain.rs +++ b/linera-bridge/src/evm/microchain.rs @@ -68,7 +68,7 @@ mod tests { } #[test] - fn test_microchain_rejects_duplicate_block() { + fn test_microchain_accepts_duplicate_block() { let mut microchain = TestMicrochain::new(); microchain.add_block(BlockHeight(1)); @@ -76,8 +76,8 @@ mod tests { assert!( microchain .try_add_block(microchain.chain_id, BlockHeight(1)) - .is_err(), - "should reject duplicate block" + .is_ok(), + "Microchain must accept duplicate addBlock calls; subclass owns dedup" ); } diff --git a/linera-bridge/src/relay/mod.rs b/linera-bridge/src/relay/mod.rs index 49fbb2b41dba..ab8d6421bfae 100644 --- a/linera-bridge/src/relay/mod.rs +++ b/linera-bridge/src/relay/mod.rs @@ -17,6 +17,7 @@ mod committee; pub mod evm; pub mod linera; pub(crate) mod metrics; +pub(crate) mod settlement; use std::{path::Path, sync::Arc, time::Duration}; diff --git a/linera-bridge/src/relay/settlement.rs b/linera-bridge/src/relay/settlement.rs new file mode 100644 index 000000000000..c598e03ed369 --- /dev/null +++ b/linera-bridge/src/relay/settlement.rs @@ -0,0 +1,184 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Pure routing helpers for the per-burn fallback path. +//! +//! `split_to_fit` decides how to chunk a list of pending-burn positions +//! within a single Linera-block transaction given an async fits-predicate. +//! The predicate carries all IO (real `eth_estimateGas` against an EVM +//! node in production, a synthetic closure in tests). No code in this +//! module touches the network or the file system. + +use linera_chain::types::ConfirmedBlockCertificate; + +/// One pending burn whose single-position `processBurns(cert, tx_index, [pos])` +/// chunk also fails the predicate — recovery is impossible at this +/// layer (Phase 2 inclusion proofs are the next rung). +#[derive(Debug, thiserror::Error)] +#[error("burn at tx {tx_index} position {pos_in_tx} does not fit")] +pub struct SingleBurnTooLarge { + pub tx_index: u32, + pub pos_in_tx: u32, +} + +/// Splits `positions_in_tx` into the minimum number of chunks such that +/// every chunk satisfies `fits_fn(cert, tx_index, &chunk).await`. +/// Operates strictly within one tx — each `processBurns` call targets a +/// single transaction. Callers group pending burns by tx first and +/// invoke this function per group. Returns chunks in input order. +/// Iterative LIFO traversal — no recursion. +pub async fn split_to_fit( + cert: &ConfirmedBlockCertificate, + tx_index: u32, + positions_in_tx: &[u32], + fits_fn: F, +) -> Result>, SingleBurnTooLarge> +where + F: AsyncFn(&ConfirmedBlockCertificate, u32, &[u32]) -> bool, +{ + if positions_in_tx.is_empty() { + return Ok(Vec::new()); + } + let mut chunks: Vec> = Vec::new(); + let mut stack: Vec<&[u32]> = vec![positions_in_tx]; + while let Some(slice) = stack.pop() { + if fits_fn(cert, tx_index, slice).await { + chunks.push(slice.to_vec()); + continue; + } + if slice.len() == 1 { + return Err(SingleBurnTooLarge { + tx_index, + pos_in_tx: slice[0], + }); + } + let mid = slice.len() / 2; + let (left, right) = slice.split_at(mid); + stack.push(right); + stack.push(left); + } + Ok(chunks) +} + +/// Turns a raw `eth_estimateGas` result into a fits/doesn't-fit decision. +/// `Ok(_)` means the node already accepted the estimate. A gas-exceeded +/// RPC error means the call wouldn't fit. Other errors bubble up. +pub fn estimate_fits(r: Result) -> anyhow::Result { + use crate::relay::evm::is_gas_exceeded_error; + match r { + Ok(_) => Ok(true), + Err(e) if is_gas_exceeded_error(&e) => Ok(false), + Err(e) => Err(e.into()), + } +} + +#[cfg(test)] +mod tests { + use linera_base::crypto::ValidatorSecretKey; + + use super::*; + use crate::test_helpers::create_signed_certificate; + + fn dummy_cert() -> ConfirmedBlockCertificate { + let secret = ValidatorSecretKey::generate(); + let public = secret.public(); + create_signed_certificate(&secret, &public) + } + + const TX: u32 = 7; + + #[tokio::test] + async fn split_to_fit_returns_one_chunk_when_everything_fits() { + let cert = dummy_cert(); + let positions = vec![0u32, 1, 2, 3, 4]; + let chunks = split_to_fit(&cert, TX, &positions, async |_, _, _| true) + .await + .unwrap(); + assert_eq!(chunks, vec![positions]); + } + + #[tokio::test] + async fn split_to_fit_chunks_until_predicate_accepts() { + let cert = dummy_cert(); + let positions = vec![0u32, 1, 2, 3, 4]; + let chunks = split_to_fit(&cert, TX, &positions, async |_, _, c: &[u32]| c.len() <= 2) + .await + .unwrap(); + // Halving order: [0,1,2,3,4] → [0,1] + [2,3,4]; LIFO visits [0,1] + // first (accepted whole), then [2,3,4] → [2] + [3,4] (both + // accepted). Input-order chunks: [[0, 1], [2], [3, 4]]. + assert_eq!(chunks, vec![vec![0, 1], vec![2], vec![3, 4]]); + } + + #[tokio::test] + async fn split_to_fit_per_burn_when_only_singletons_fit() { + let cert = dummy_cert(); + let positions = vec![10u32, 20, 30]; + let chunks = split_to_fit(&cert, TX, &positions, async |_, _, c: &[u32]| c.len() == 1) + .await + .unwrap(); + assert_eq!(chunks, vec![vec![10], vec![20], vec![30]]); + } + + #[tokio::test] + async fn split_to_fit_errors_when_single_burn_does_not_fit() { + let cert = dummy_cert(); + let positions = vec![42u32]; + let err = split_to_fit(&cert, TX, &positions, async |_, _, _| false) + .await + .unwrap_err(); + assert_eq!(err.tx_index, TX); + assert_eq!(err.pos_in_tx, 42); + } + + #[tokio::test] + async fn split_to_fit_empty_input_returns_empty_output() { + let cert = dummy_cert(); + let chunks = split_to_fit(&cert, TX, &[], async |_, _, _| true) + .await + .unwrap(); + assert!(chunks.is_empty()); + } + + #[tokio::test] + async fn split_to_fit_predicate_only_ever_called_with_one_tx_index() { + use std::cell::RefCell; + let cert = dummy_cert(); + let positions = vec![0u32, 1, 2, 3]; + let seen = RefCell::new(Vec::::new()); + let _chunks: Vec> = split_to_fit(&cert, TX, &positions, async |_, tx, _| { + seen.borrow_mut().push(tx); + true + }) + .await + .unwrap(); + let seen = seen.into_inner(); + assert!(!seen.is_empty()); + for tx in seen { + assert_eq!(tx, TX, "split_to_fit must never cross tx boundaries"); + } + } + + #[test] + fn estimate_fits_ok_returns_true() { + assert!(estimate_fits(Ok(123_456)).unwrap()); + assert!(estimate_fits(Ok(0)).unwrap()); + } + + #[test] + fn estimate_fits_gas_exceeded_error_returns_false() { + use alloy::transports::TransportErrorKind; + let transport_err = + TransportErrorKind::custom_str("gas required exceeds allowance (30000000)"); + let contract_err = alloy::contract::Error::TransportError(transport_err); + assert!(!estimate_fits(Err(contract_err)).unwrap()); + } + + #[test] + fn estimate_fits_other_error_bubbles_up() { + use alloy::transports::TransportErrorKind; + let transport_err = TransportErrorKind::custom_str("nonce too low"); + let contract_err = alloy::contract::Error::TransportError(transport_err); + assert!(estimate_fits(Err(contract_err)).is_err()); + } +} From 2261d0966575b97cdf9f187209546d3c340a8481 Mon Sep 17 00:00:00 2001 From: deuszx Date: Wed, 13 May 2026 16:34:27 +0200 Subject: [PATCH 05/31] Add tx_index + event_pos_in_tx to PendingBurn schema and scanner --- linera-bridge/src/monitor/db.rs | 37 ++++++---- linera-bridge/src/monitor/linera.rs | 10 ++- linera-bridge/src/monitor/mod.rs | 106 ++++++++++++++++++++++++++++ linera-bridge/src/relay/linera.rs | 18 ++--- 4 files changed, 148 insertions(+), 23 deletions(-) diff --git a/linera-bridge/src/monitor/db.rs b/linera-bridge/src/monitor/db.rs index bbefcccf0f02..1aec23d8d867 100644 --- a/linera-bridge/src/monitor/db.rs +++ b/linera-bridge/src/monitor/db.rs @@ -100,7 +100,9 @@ impl BridgeDb { sqlx::query( "CREATE TABLE IF NOT EXISTS pending_burns ( linera_height INTEGER NOT NULL, - event_index INTEGER NOT NULL, + tx_index INTEGER NOT NULL, + event_pos_in_tx INTEGER NOT NULL, + event_index INTEGER NOT NULL, evm_recipient TEXT NOT NULL, amount TEXT NOT NULL, raw_cert BLOB, @@ -114,7 +116,9 @@ impl BridgeDb { sqlx::query( "CREATE TABLE IF NOT EXISTS finished_burns ( linera_height INTEGER NOT NULL, - event_index INTEGER NOT NULL, + tx_index INTEGER NOT NULL, + event_pos_in_tx INTEGER NOT NULL, + event_index INTEGER NOT NULL, evm_recipient TEXT NOT NULL, amount TEXT NOT NULL, raw_cert BLOB, @@ -217,10 +221,13 @@ impl BridgeDb { /// Inserts a new pending burn. Ignores duplicates (idempotent). pub async fn insert_burn(&self, burn: &PendingBurn) -> Result<()> { sqlx::query( - "INSERT OR IGNORE INTO pending_burns (linera_height, event_index, evm_recipient, amount) - VALUES (?, ?, ?, ?)", + "INSERT OR IGNORE INTO pending_burns + (linera_height, tx_index, event_pos_in_tx, event_index, evm_recipient, amount) + VALUES (?, ?, ?, ?, ?, ?)", ) .bind(burn.height.0 as i64) + .bind(burn.tx_index as i64) + .bind(burn.event_pos_in_tx as i64) .bind(burn.event_index as i64) .bind(format!("{:#x}", burn.evm_recipient)) .bind(burn.amount.to_string()) @@ -244,10 +251,10 @@ impl BridgeDb { let mut tx = self.pool.begin().await?; let inserted = sqlx::query( "INSERT OR IGNORE INTO finished_burns - (linera_height, event_index, evm_recipient, amount, raw_cert, - status, created_at) - SELECT linera_height, event_index, evm_recipient, amount, raw_cert, - ?, created_at + (linera_height, tx_index, event_pos_in_tx, event_index, + evm_recipient, amount, raw_cert, status, created_at) + SELECT linera_height, tx_index, event_pos_in_tx, event_index, + evm_recipient, amount, raw_cert, ?, created_at FROM pending_burns WHERE linera_height = ? AND event_index = ?", ) @@ -321,7 +328,7 @@ impl BridgeDb { /// in-memory `MonitorState`. pub async fn load_pending_burns(&self) -> Result> { let rows = sqlx::query( - "SELECT linera_height, event_index, evm_recipient, amount + "SELECT linera_height, tx_index, event_pos_in_tx, event_index, evm_recipient, amount FROM pending_burns", ) .fetch_all(&self.pool) @@ -330,12 +337,16 @@ impl BridgeDb { let mut out = Vec::with_capacity(rows.len()); for row in rows { let height: i64 = row.get(0); - let event_index: i64 = row.get(1); - let evm_recipient: String = row.get(2); - let amount: String = row.get(3); + let tx_index: i64 = row.get(1); + let event_pos_in_tx: i64 = row.get(2); + let event_index: i64 = row.get(3); + let evm_recipient: String = row.get(4); + let amount: String = row.get(5); out.push(PendingBurn { height: BlockHeight(height as u64), + tx_index: tx_index as u32, + event_pos_in_tx: event_pos_in_tx as u32, event_index: event_index as u32, evm_recipient: evm_recipient .parse() @@ -422,6 +433,8 @@ mod tests { fn test_burn() -> PendingBurn { PendingBurn { height: BlockHeight(100), + tx_index: 0, + event_pos_in_tx: 0, event_index: 0, evm_recipient: "0xabcdef1234567890abcdef1234567890abcdef12" .parse() diff --git a/linera-bridge/src/monitor/linera.rs b/linera-bridge/src/monitor/linera.rs index f8118fdf333a..11c6df278216 100644 --- a/linera-bridge/src/monitor/linera.rs +++ b/linera-bridge/src/monitor/linera.rs @@ -194,9 +194,11 @@ async fn linera_scan_iteration( for block in &blocks { let height = block.block().header.height; let burn_events = find_burn_events(&block.block().body.events, fungible_app_id); - for (event_index, burn_event) in burn_events { + for (tx_index, event_pos_in_tx, event_index, burn_event) in burn_events { new_burns.push(( height, + tx_index, + event_pos_in_tx, event_index, Address::from(burn_event.target), burn_event.amount, @@ -205,13 +207,15 @@ async fn linera_scan_iteration( } let mut tracked_any = false; - for (height, event_index, recipient, amount) in &new_burns { - tracing::info!(?height, event_index, %recipient, %amount, "Discovered burn"); + for (height, tx_index, event_pos_in_tx, event_index, recipient, amount) in &new_burns { + tracing::info!(?height, tx_index, event_pos_in_tx, event_index, %recipient, %amount, "Discovered burn"); let was_new = monitor .write() .await .track_burn(PendingBurn { height: *height, + tx_index: *tx_index, + event_pos_in_tx: *event_pos_in_tx, event_index: *event_index, evm_recipient: *recipient, amount: *amount, diff --git a/linera-bridge/src/monitor/mod.rs b/linera-bridge/src/monitor/mod.rs index 221155484f56..53f1cce8f3a0 100644 --- a/linera-bridge/src/monitor/mod.rs +++ b/linera-bridge/src/monitor/mod.rs @@ -65,6 +65,14 @@ pub struct PendingDeposit { #[derive(Debug, Clone, serde::Serialize)] pub struct PendingBurn { pub height: BlockHeight, + /// Position of this burn's transaction within `body.events`. + /// Used by `processBurns(cert, tx_index, ...)`. + pub tx_index: u32, + /// Position of this burn within `body.events[tx_index]`. + /// Used by `processBurns(cert, tx_index, [event_pos_in_tx, ...])`. + pub event_pos_in_tx: u32, + /// `Event.index` — the stream index of this burn, unique within + /// `(stream, height)`. Off-chain and on-chain dedup key. pub event_index: u32, pub evm_recipient: Address, pub amount: Amount, @@ -226,6 +234,39 @@ impl MonitorState { self.burns.values().filter(|b| b.forwarded).collect() } + /// Returns pending burns grouped by `(height, tx_index)`. Outer Vec + /// is in ascending height order; inner Vec is in ascending tx-index + /// order; positions within each (height, tx) group are sorted by + /// `event_pos_in_tx`. Burns that have exceeded `max_retries` are + /// skipped. Used by `process_pending_burns` to batch all burns at a + /// height through `addBlock` first, then chunked `processBurns` per + /// (tx) group on no-fit. + pub fn pending_burns_by_height_and_tx( + &self, + max_retries: u32, + ) -> Vec<(BlockHeight, Vec<(u32, Vec)>)> { + use std::collections::BTreeMap; + let mut tree: BTreeMap>> = BTreeMap::new(); + for tracked in self.pending_burns() { + if tracked.retry_count >= max_retries { + continue; + } + tree.entry(tracked.value.height) + .or_default() + .entry(tracked.value.tx_index) + .or_default() + .push(tracked.value.event_pos_in_tx); + } + for by_tx in tree.values_mut() { + for positions in by_tx.values_mut() { + positions.sort_unstable(); + } + } + tree.into_iter() + .map(|(h, by_tx)| (h, by_tx.into_iter().collect())) + .collect() + } + pub fn deposits_ready_for_retry(&self, max_retries: u32) -> Vec<&TrackedDeposit> { self.deposits .values() @@ -482,6 +523,8 @@ mod tests { state .track_burn(PendingBurn { height: BlockHeight(10), + tx_index: 0, + event_pos_in_tx: 0, event_index: 0, evm_recipient: Address::from([0xab; 20]), amount: Amount::from_attos(500), @@ -519,6 +562,8 @@ mod tests { state .track_burn(PendingBurn { height: BlockHeight(5), + tx_index: 0, + event_pos_in_tx: 0, event_index: 0, evm_recipient: Address::from([0x12; 20]), amount: Amount::from_attos(100), @@ -671,6 +716,8 @@ mod tests { .unwrap(); db.insert_burn(&PendingBurn { height: BlockHeight(99), + tx_index: 0, + event_pos_in_tx: 0, event_index: 2, evm_recipient: Address::from([0xDD; 20]), amount: Amount::from_attos(7), @@ -697,6 +744,8 @@ mod tests { state .track_burn(PendingBurn { height, + tx_index: 0, + event_pos_in_tx: 0, event_index: 0, evm_recipient: Address::from([0xab; 20]), amount: Amount::from_attos(500), @@ -711,4 +760,61 @@ mod tests { state.complete_burn(height, 0).await; assert!(state.next_burn_for_retry(10).is_none()); } + + #[tokio::test] + async fn pending_burns_by_height_and_tx_groups_and_sorts() { + let mut state = MonitorState::new(0); + let burns = [ + // Two burns at height 5: tx 0 has positions 1 then 0 (out of + // order so the helper's sort is tested); tx 1 has one burn. + PendingBurn { + height: BlockHeight(5), + tx_index: 0, + event_pos_in_tx: 1, + event_index: 11, + evm_recipient: Address::ZERO, + amount: Amount::ZERO, + }, + PendingBurn { + height: BlockHeight(5), + tx_index: 0, + event_pos_in_tx: 0, + event_index: 10, + evm_recipient: Address::ZERO, + amount: Amount::ZERO, + }, + PendingBurn { + height: BlockHeight(5), + tx_index: 1, + event_pos_in_tx: 0, + event_index: 12, + evm_recipient: Address::ZERO, + amount: Amount::ZERO, + }, + // One burn at a later height. + PendingBurn { + height: BlockHeight(7), + tx_index: 0, + event_pos_in_tx: 0, + event_index: 0, + evm_recipient: Address::ZERO, + amount: Amount::ZERO, + }, + ]; + for b in burns { + state.track_burn(b).await; + } + + let groups = state.pending_burns_by_height_and_tx(/* max_retries */ 10); + assert_eq!( + groups, + vec![ + ( + BlockHeight(5), + vec![(0u32, vec![0u32, 1]), (1u32, vec![0u32]),] + ), + (BlockHeight(7), vec![(0u32, vec![0u32]),]), + ], + ); + } } diff --git a/linera-bridge/src/relay/linera.rs b/linera-bridge/src/relay/linera.rs index fe51a997d5a6..7c242b50fae7 100644 --- a/linera-bridge/src/relay/linera.rs +++ b/linera-bridge/src/relay/linera.rs @@ -148,17 +148,19 @@ impl Clone for LineraClient { } } -/// Finds all `BurnEvent`s in a block's event streams for a given application, -/// returning each burn paired with the underlying `Event.index` — the -/// position of the event within its stream. That index is what the -/// `FungibleBridge` contract keys its per-burn dedup mapping on. +/// Finds all `BurnEvent`s in a block's event streams for a given application. +/// +/// Returns `(tx_index, event_pos_in_tx, event_index, BurnEvent)` for each burn: +/// - `tx_index`: position of the transaction within `body.events` (outer index) +/// - `event_pos_in_tx`: position of the event within `body.events[tx_index]` (inner index) +/// - `event_index`: `Event.index` — the stream index, used as the on-chain dedup key pub(crate) fn find_burn_events( events: &[Vec], fungible_app_id: ApplicationId, -) -> Vec<(u32, wrapped_fungible::BurnEvent)> { +) -> Vec<(u32, u32, u32, wrapped_fungible::BurnEvent)> { let mut result = Vec::new(); - for tx_events in events { - for event in tx_events { + for (i, tx_events) in events.iter().enumerate() { + for (j, event) in tx_events.iter().enumerate() { if event.stream_id.application_id != GenericApplicationId::User(fungible_app_id) { continue; } @@ -166,7 +168,7 @@ pub(crate) fn find_burn_events( continue; } if let Ok(burn) = bcs::from_bytes::(&event.value) { - result.push((event.index, burn)); + result.push((i as u32, j as u32, event.index, burn)); } } } From 41946d0c3a58f89716496866328f3067dc56ca62 Mon Sep 17 00:00:00 2001 From: deuszx Date: Wed, 13 May 2026 16:44:05 +0200 Subject: [PATCH 06/31] Rewire process_pending_burns to use addBlock-then-chunked-processBurns --- linera-bridge/src/monitor/linera.rs | 225 +++++++++++++++++++--------- 1 file changed, 151 insertions(+), 74 deletions(-) diff --git a/linera-bridge/src/monitor/linera.rs b/linera-bridge/src/monitor/linera.rs index 11c6df278216..f48b00a802f0 100644 --- a/linera-bridge/src/monitor/linera.rs +++ b/linera-bridge/src/monitor/linera.rs @@ -16,6 +16,7 @@ use crate::relay::{ self, evm::EvmClient, linera::{find_burn_events, LineraClient}, + settlement::estimate_fits, }; /// Background task that scans Linera block history for BurnEvent stream @@ -53,8 +54,9 @@ pub async fn linera_scan_loop( monitor: &RwLock, @@ -65,8 +67,11 @@ pub(crate) async fn process_pending_burns anyhow::Result<()> { loop { - let pending = monitor.read().await.next_burn_for_retry(max_retries); - let Some(pending) = pending else { + let groups = monitor + .read() + .await + .pending_burns_by_height_and_tx(max_retries); + if groups.is_empty() { tracing::trace!( ?poll_interval, "Linera burns processor sleeping until notified or poll interval elapses" @@ -78,86 +83,158 @@ pub(crate) async fn process_pending_burns {} } continue; - }; + } - let credit_height = pending.height; - let event_index = pending.event_index; - tracing::info!(?credit_height, event_index, "Processing burn..."); + for (height, by_tx) in groups { + // `event_index` (stream-index) values for every pending burn at this + // height. `mark_burn_retried` and `store_burn_raw` key on event_index; + // the per-tx chunking driven below uses (tx_index, pos_in_tx). + let event_indices_at_height: Vec = monitor + .read() + .await + .pending_burns() + .iter() + .filter(|t| t.value.height == height) + .map(|t| t.value.event_index) + .collect(); - // Read the certificate at the burn's block height (already contains the auto-burn). - let cert = match async { - linera_client.sync().await?; - let info = linera_client.chain_info().await?; - let mut hash = info.block_hash; - loop { - let Some(h) = hash else { - anyhow::bail!("Block at height {credit_height} not found"); - }; - let c = linera_client.read_certificate(h).await?; - if c.block().header.height == credit_height { - break Ok(c); + let cert = match fetch_cert_at_height(linera_client, height).await { + Ok(cert) => cert, + Err(e) => { + tracing::warn!(?height, "Failed to read certificate: {e:#}"); + let mut state = monitor.write().await; + for ei in &event_indices_at_height { + state.mark_burn_retried(height, *ei); + } + continue; + } + }; + + persist_cert_bytes(monitor, height, &event_indices_at_height, &cert).await; + + // Dry-run addBlock. If it fits, one tx settles everything in the block. + match estimate_fits(evm_client.estimate_add_block_gas(&cert).await) { + Ok(true) => match evm_client.forward_cert(&cert).await { + Ok(()) => { + tracing::info!( + ?height, + count = event_indices_at_height.len(), + "Burns forwarded via addBlock" + ); + relay::update_balance_metrics(evm_client, linera_client).await; + } + Err(e) => { + let msg = format!("{e:#}"); + if msg.contains("already verified") { + tracing::trace!(?height, "Block already verified on EVM"); + } else { + tracing::warn!(?height, "addBlock submission failed: {e:#}"); + } + } + }, + Ok(false) => { + 'tx_loop: for (tx_index, positions) in &by_tx { + // Iterative LIFO split-to-fit per tx group. Mirrors + // `relay::settlement::split_to_fit` but inlined to + // avoid the `AsyncFn` predicate's HRTB Send issue when + // the future captures `&EvmClient` across awaits. + let mut stack: Vec> = vec![positions.clone()]; + let mut chunks: Vec> = Vec::new(); + let mut single_too_large: Option = None; + while let Some(slice) = stack.pop() { + let est = evm_client + .estimate_process_burns_gas(&cert, *tx_index, &slice) + .await; + let fits = estimate_fits(est).unwrap_or(false); + if fits { + chunks.push(slice); + continue; + } + if slice.len() == 1 { + single_too_large = Some(slice[0]); + break; + } + let mid = slice.len() / 2; + let (left, right) = slice.split_at(mid); + stack.push(right.to_vec()); + stack.push(left.to_vec()); + } + if let Some(pos) = single_too_large { + tracing::error!( + tx_index, + pos_in_tx = pos, + "single burn does not fit under the EVM block gas limit" + ); + continue 'tx_loop; + } + // Pop-order yields input order because we push right then left. + for chunk in chunks { + if let Err(e) = evm_client.process_burns(&cert, *tx_index, &chunk).await + { + tracing::warn!( + tx_index, + ?chunk, + "processBurns submission failed: {e:#}" + ); + continue 'tx_loop; + } + } + } + relay::update_balance_metrics(evm_client, linera_client).await; + } + Err(e) => { + tracing::warn!(?height, "estimate_add_block_gas failed: {e:#}"); } - hash = c.block().header.previous_block_hash; - } - } - .await - { - Ok(cert) => cert, - Err(e) => { - tracing::warn!( - ?credit_height, - event_index, - "Failed to read certificate: {e:#}" - ); - monitor - .write() - .await - .mark_burn_retried(credit_height, event_index); - continue; } - }; - // Persist raw BCS cert bytes so burns can be replayed without the relayer. - let cert_bytes = - bcs::to_bytes(&cert).expect("failed to BCS-serialize ConfirmedBlockCertificate"); - if let Some(db) = monitor.read().await.db() { - if let Err(e) = db - .store_burn_raw(credit_height, event_index, &cert_bytes) - .await - { - tracing::warn!( - ?credit_height, - event_index, - "Failed to store burn raw bytes: {e:#}" - ); + let mut state = monitor.write().await; + for ei in &event_indices_at_height { + state.mark_burn_retried(height, *ei); } } + } +} - // Forward cert to EVM. `addBlock` returning Ok only proves the - // EVM tx didn't revert; it does NOT prove that this specific - // burn's `token.transfer` ran inside `_onBlock` (e.g. if the - // event match silently rejects the burn event). Per-burn - // completion is decided by `check_burn_completion` polling - // on-chain state. Here we only forward and retry. - match evm_client.forward_cert(&cert).await { - Ok(()) => { - tracing::info!(?credit_height, event_index, "Burn forwarded to EVM"); - relay::update_balance_metrics(evm_client, linera_client).await; - } - Err(e) => { - let msg = format!("{e:#}"); - if msg.contains("already verified") { - tracing::trace!(?credit_height, event_index, "Block already verified on EVM"); - } else { - tracing::warn!(?credit_height, event_index, "EVM forwarding failed: {e:#}"); - } - } +/// Walks the chain history backwards from the head until the certificate at +/// `target_height` is found. Extracted from the prior per-burn body so all +/// burns at a height share one fetch. +async fn fetch_cert_at_height( + linera_client: &LineraClient, + target_height: BlockHeight, +) -> anyhow::Result> { + linera_client.sync().await?; + let info = linera_client.chain_info().await?; + let mut hash = info.block_hash; + loop { + let Some(h) = hash else { + anyhow::bail!("Block at height {} not found", target_height); + }; + let c = linera_client.read_certificate(h).await?; + if c.block().header.height == target_height { + return Ok(c); } + hash = c.block().header.previous_block_hash; + } +} - monitor - .write() - .await - .mark_burn_retried(credit_height, event_index); +/// Stores BCS cert bytes for every pending burn at `height`. +async fn persist_cert_bytes( + monitor: &RwLock, + height: BlockHeight, + event_indices: &[u32], + cert: &linera_chain::types::ConfirmedBlockCertificate, +) { + let cert_bytes = bcs::to_bytes(cert).expect("BCS-serialize cert"); + let state = monitor.read().await; + let Some(db) = state.db() else { return }; + for ei in event_indices { + if let Err(e) = db.store_burn_raw(height, *ei, &cert_bytes).await { + tracing::warn!( + ?height, + event_index = ei, + "Failed to store burn raw bytes: {e:#}" + ); + } } } From 8b47891d66079fa350a8ee1c9043f438307dc13e Mon Sep 17 00:00:00 2001 From: deuszx Date: Wed, 13 May 2026 17:02:05 +0200 Subject: [PATCH 07/31] Add e2e test exercising chunked processBurns fallback --- linera-bridge/src/relay/evm.rs | 16 +- linera-bridge/src/relay/settlement.rs | 144 +------- .../tests/e2e/tests/multi_tx_burn_chunking.rs | 322 ++++++++++++++++++ 3 files changed, 339 insertions(+), 143 deletions(-) create mode 100644 linera-bridge/tests/e2e/tests/multi_tx_burn_chunking.rs diff --git a/linera-bridge/src/relay/evm.rs b/linera-bridge/src/relay/evm.rs index 5b09fb451edb..a78db2d1f2cf 100644 --- a/linera-bridge/src/relay/evm.rs +++ b/linera-bridge/src/relay/evm.rs @@ -266,12 +266,20 @@ impl EvmClient

{ } /// Returns true if the `alloy::contract::Error` reports that the call would not -/// fit under the node's block gas limit — i.e. the node refused to -/// estimate because the work required more gas than a single block can -/// hold. Substring-matches on the node's error message; pinned to the -/// known wordings for anvil, geth, and reth as of writing. +/// fit under the node's block gas limit — i.e. the node refused to estimate +/// because the work required more gas than a single block can hold. +/// Substring-matches on the node's error message; pinned to the known wordings +/// for anvil, geth, and reth as of writing. +/// +/// Anvil's `eth_estimateGas` caps execution at the block gas limit. When the +/// call would exceed it, anvil returns `execution reverted, data: "0x"` (the +/// EVM hit OOG mid-call) rather than the canonical `"gas required exceeds"`. +/// We treat that combination as the same fit-doesn't-fit signal here. pub fn is_gas_exceeded_error(error: &alloy::contract::Error) -> bool { let msg = error.to_string().to_lowercase(); + if msg.contains("execution reverted") && msg.contains("data: \"0x\"") { + return true; + } [ "gas required exceeds", // anvil / geth "exceeds block gas limit", // reth, foundry forks diff --git a/linera-bridge/src/relay/settlement.rs b/linera-bridge/src/relay/settlement.rs index c598e03ed369..e4ebc5cb56b4 100644 --- a/linera-bridge/src/relay/settlement.rs +++ b/linera-bridge/src/relay/settlement.rs @@ -3,62 +3,11 @@ //! Pure routing helpers for the per-burn fallback path. //! -//! `split_to_fit` decides how to chunk a list of pending-burn positions -//! within a single Linera-block transaction given an async fits-predicate. -//! The predicate carries all IO (real `eth_estimateGas` against an EVM -//! node in production, a synthetic closure in tests). No code in this -//! module touches the network or the file system. - -use linera_chain::types::ConfirmedBlockCertificate; - -/// One pending burn whose single-position `processBurns(cert, tx_index, [pos])` -/// chunk also fails the predicate — recovery is impossible at this -/// layer (Phase 2 inclusion proofs are the next rung). -#[derive(Debug, thiserror::Error)] -#[error("burn at tx {tx_index} position {pos_in_tx} does not fit")] -pub struct SingleBurnTooLarge { - pub tx_index: u32, - pub pos_in_tx: u32, -} - -/// Splits `positions_in_tx` into the minimum number of chunks such that -/// every chunk satisfies `fits_fn(cert, tx_index, &chunk).await`. -/// Operates strictly within one tx — each `processBurns` call targets a -/// single transaction. Callers group pending burns by tx first and -/// invoke this function per group. Returns chunks in input order. -/// Iterative LIFO traversal — no recursion. -pub async fn split_to_fit( - cert: &ConfirmedBlockCertificate, - tx_index: u32, - positions_in_tx: &[u32], - fits_fn: F, -) -> Result>, SingleBurnTooLarge> -where - F: AsyncFn(&ConfirmedBlockCertificate, u32, &[u32]) -> bool, -{ - if positions_in_tx.is_empty() { - return Ok(Vec::new()); - } - let mut chunks: Vec> = Vec::new(); - let mut stack: Vec<&[u32]> = vec![positions_in_tx]; - while let Some(slice) = stack.pop() { - if fits_fn(cert, tx_index, slice).await { - chunks.push(slice.to_vec()); - continue; - } - if slice.len() == 1 { - return Err(SingleBurnTooLarge { - tx_index, - pos_in_tx: slice[0], - }); - } - let mid = slice.len() / 2; - let (left, right) = slice.split_at(mid); - stack.push(right); - stack.push(left); - } - Ok(chunks) -} +//! `estimate_fits` turns a raw `eth_estimateGas` result into a fits / +//! doesn't-fit decision. The actual chunking algorithm is inlined in +//! `monitor::linera::process_pending_burns` to avoid an `AsyncFn` +//! predicate's HRTB Send issue when the future captures `&EvmClient` +//! across await points. /// Turns a raw `eth_estimateGas` result into a fits/doesn't-fit decision. /// `Ok(_)` means the node already accepted the estimate. A gas-exceeded @@ -74,90 +23,7 @@ pub fn estimate_fits(r: Result) -> anyhow::Result ConfirmedBlockCertificate { - let secret = ValidatorSecretKey::generate(); - let public = secret.public(); - create_signed_certificate(&secret, &public) - } - - const TX: u32 = 7; - - #[tokio::test] - async fn split_to_fit_returns_one_chunk_when_everything_fits() { - let cert = dummy_cert(); - let positions = vec![0u32, 1, 2, 3, 4]; - let chunks = split_to_fit(&cert, TX, &positions, async |_, _, _| true) - .await - .unwrap(); - assert_eq!(chunks, vec![positions]); - } - - #[tokio::test] - async fn split_to_fit_chunks_until_predicate_accepts() { - let cert = dummy_cert(); - let positions = vec![0u32, 1, 2, 3, 4]; - let chunks = split_to_fit(&cert, TX, &positions, async |_, _, c: &[u32]| c.len() <= 2) - .await - .unwrap(); - // Halving order: [0,1,2,3,4] → [0,1] + [2,3,4]; LIFO visits [0,1] - // first (accepted whole), then [2,3,4] → [2] + [3,4] (both - // accepted). Input-order chunks: [[0, 1], [2], [3, 4]]. - assert_eq!(chunks, vec![vec![0, 1], vec![2], vec![3, 4]]); - } - - #[tokio::test] - async fn split_to_fit_per_burn_when_only_singletons_fit() { - let cert = dummy_cert(); - let positions = vec![10u32, 20, 30]; - let chunks = split_to_fit(&cert, TX, &positions, async |_, _, c: &[u32]| c.len() == 1) - .await - .unwrap(); - assert_eq!(chunks, vec![vec![10], vec![20], vec![30]]); - } - - #[tokio::test] - async fn split_to_fit_errors_when_single_burn_does_not_fit() { - let cert = dummy_cert(); - let positions = vec![42u32]; - let err = split_to_fit(&cert, TX, &positions, async |_, _, _| false) - .await - .unwrap_err(); - assert_eq!(err.tx_index, TX); - assert_eq!(err.pos_in_tx, 42); - } - - #[tokio::test] - async fn split_to_fit_empty_input_returns_empty_output() { - let cert = dummy_cert(); - let chunks = split_to_fit(&cert, TX, &[], async |_, _, _| true) - .await - .unwrap(); - assert!(chunks.is_empty()); - } - - #[tokio::test] - async fn split_to_fit_predicate_only_ever_called_with_one_tx_index() { - use std::cell::RefCell; - let cert = dummy_cert(); - let positions = vec![0u32, 1, 2, 3]; - let seen = RefCell::new(Vec::::new()); - let _chunks: Vec> = split_to_fit(&cert, TX, &positions, async |_, tx, _| { - seen.borrow_mut().push(tx); - true - }) - .await - .unwrap(); - let seen = seen.into_inner(); - assert!(!seen.is_empty()); - for tx in seen { - assert_eq!(tx, TX, "split_to_fit must never cross tx boundaries"); - } - } #[test] fn estimate_fits_ok_returns_true() { diff --git a/linera-bridge/tests/e2e/tests/multi_tx_burn_chunking.rs b/linera-bridge/tests/e2e/tests/multi_tx_burn_chunking.rs new file mode 100644 index 000000000000..22ae5a861155 --- /dev/null +++ b/linera-bridge/tests/e2e/tests/multi_tx_burn_chunking.rs @@ -0,0 +1,322 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Verifies that when a Linera block's `addBlock(cert)` would exceed the +//! EVM block gas limit, the relayer falls back to chunked +//! `processBurns(cert, txIndex, positionsInTx)` calls and every burn +//! still settles correctly. +//! +//! Setup: anvil's block gas limit is dialled down (via +//! `evm_setBlockGasLimit`) before the bridge is deployed, so `addBlock` +//! at `NUM_BURNS = 8` does not fit and the relayer must take the +//! `processBurns` chunk path. Chain B submits one block with N +//! `Transfer` operations to N distinct recipients on chain A. After the +//! relayer is spawned, every recipient must hold the correct ERC-20 +//! balance and `linera_bridge_burns_completed` must reach N. + +#![recursion_limit = "512"] + +use std::time::Duration; + +use alloy::{primitives::U256, providers::ProviderBuilder, sol}; +use linera_base::{crypto::InMemorySigner, data_types::Amount, identifiers::AccountOwner}; +use linera_bridge_e2e::{ + compose_file_path, deploy_fungible_bridge, deploy_linera_token, fund_bridge_erc20, + light_client_address, parse_metric_value, publish_and_create_wrapped_fungible, + set_anvil_block_gas_limit, start_compose, wait_for_light_client, wait_for_relay_http_ready, + wait_for_relay_metrics, 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, StorageCacheConfig}; +use linera_views::backends::memory::{MemoryDatabase, MemoryStoreConfig}; +use wrapped_fungible::{Account, WrappedFungibleOperation}; + +sol! { + #[sol(rpc)] + interface IERC20 { + function balanceOf(address account) external view returns (uint256); + } +} + +const NUM_BURNS: usize = 8; +const BURN_AMOUNT_TOKENS: u128 = 1; +/// Per-block gas ceiling sized to live between `processBurns(cert, tx, [single])` +/// (which is dominated by cert verification, ~2–2.5M gas) and `addBlock(cert)` +/// for `NUM_BURNS` burns (~3.3M gas observed in `burns_per_evm_tx`). The +/// relayer must therefore route through chunked `processBurns` per tx. +const ANVIL_BLOCK_GAS_LIMIT: u64 = 3_000_000; + +#[tokio::test] +#[ignore] // Requires pre-built docker images, Wasm, and relay binary. +async fn relayer_falls_back_to_chunked_process_burns() -> anyhow::Result<()> { + tracing_subscriber::fmt().with_test_writer().try_init().ok(); + linera_bridge_e2e::ensure_rustls_provider(); + let compose_file = compose_file_path(); + let project_name = "linera-multi-tx-burn-chunking-test"; + + let compose = start_compose(&compose_file, project_name).await; + wait_for_light_client(&compose, project_name, &compose_file).await; + + // Provider is held for the post-deploy gas-limit drop and for the + // final balance reads. Deploy txs need anvil's default (high) limit; + // we tighten it once the bridge is live. + let rpc_url = "http://localhost:8545".parse()?; + let provider = ProviderBuilder::new().connect_http(rpc_url); + + let faucet = Faucet::new("http://localhost:8080".to_string()); + let genesis_config = faucet.genesis_config().await?; + let relay_genesis_config = genesis_config.clone(); + + let store_config = MemoryStoreConfig { + max_stream_queries: 10, + kill_on_drop: true, + }; + let mut storage = DbStorage::::maybe_create_and_connect( + &store_config, + "multi-tx-burn-chunking-e2e", + Some(WasmRuntime::default()), + StorageCacheConfig { + blob_cache_size: 1000, + confirmed_block_cache_size: 1000, + certificate_cache_size: 1000, + certificate_raw_cache_size: 1000, + event_cache_size: 1000, + cache_cleanup_interval_secs: linera_storage::DEFAULT_CLEANUP_INTERVAL_SECS, + }, + ) + .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?; + + 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?; + + 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?; + + let erc20_addr = deploy_linera_token(&compose, project_name, &compose_file).await?; + let fungible_app_id = + publish_and_create_wrapped_fungible(&cc_b, owner_b, chain_a, erc20_addr, 1_000).await?; + + let app_id_bytes32 = format!("0x{}", fungible_app_id.application_description_hash); + let chain_a_bytes32 = format!("0x{chain_a}"); + let bridge_addr = deploy_fungible_bridge( + &compose, + project_name, + &compose_file, + light_client_address(), + &chain_a_bytes32, + erc20_addr, + &app_id_bytes32, + ) + .await?; + + fund_bridge_erc20( + &compose, + project_name, + &compose_file, + erc20_addr, + bridge_addr, + 1_000_000_000_000_000_000_000u128, + ) + .await; + + // Now tighten the block gas limit so `addBlock(cert)` for `NUM_BURNS` + // doesn't fit and the relayer is forced through the chunked + // `processBurns` path. Deploy + funding txs already landed. + set_anvil_block_gas_limit(&provider, ANVIL_BLOCK_GAS_LIMIT).await?; + + // Distinct recipients so each burn produces a distinct ERC-20 balance. + let recipients: [alloy::primitives::Address; NUM_BURNS] = [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8".parse()?, + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC".parse()?, + "0x90F79bf6EB2c4f870365E785982E1f101E93b906".parse()?, + "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65".parse()?, + "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc".parse()?, + "0x976EA74026E726554dB657fA54763abd0C3a0aa9".parse()?, + "0x14dC79964da2C08b23698B3D3cc7Ca32193d9955".parse()?, + "0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f".parse()?, + ]; + let burn_amount = Amount::from_tokens(BURN_AMOUNT_TOKENS); + + // Bundle every Transfer into one chain-B block; chain-A's + // process_inbox produces a single chain-A block with N BurnEvents. + let operations = recipients + .iter() + .map(|recipient| { + let owner = AccountOwner::Address20(recipient.0 .0); + let withdraw_bytes = bcs::to_bytes(&WrappedFungibleOperation::Transfer { + owner: owner_b, + amount: burn_amount, + target_account: Account { + chain_id: chain_a, + owner, + }, + }) + .expect("BCS serialization"); + Operation::User { + application_id: fungible_app_id, + bytes: withdraw_bytes, + } + }) + .collect(); + + cc_b.synchronize_from_validators().await?; + cc_b.execute_operations(operations, vec![]) + .await? + .expect("multi-burn block committed on chain B"); + + cc_a.synchronize_from_validators().await?; + let height_before_inbox = cc_a.chain_info().await?.next_block_height; + cc_a.process_inbox().await?; + let height_after_inbox = cc_a.chain_info().await?.next_block_height; + assert_eq!( + height_after_inbox.0, + height_before_inbox.0 + 1, + "chain A must produce exactly ONE block carrying all {NUM_BURNS} BurnEvents \ + (before={height_before_inbox}, after={height_after_inbox})", + ); + + 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_config = format!("rocksdb:{}", relay_dir.path().join("client.db").display()); + let sqlite_path = relay_dir.path().join("relay.sqlite3"); + + { + use linera_persistent::Persist; + let mut ks = linera_persistent::File::new(&keystore_path, signer.clone())?; + ks.persist().await?; + } + linera_wallet_json::PersistentWallet::create(&wallet_path, relay_genesis_config)?; + + let relay_port = 3009u16; + let bridge_addr_str = format!("{bridge_addr}"); + let bridge_app_str = format!("{fungible_app_id}"); + let fungible_app_str = format!("{fungible_app_id}"); + let sqlite_path_for_relay = sqlite_path.clone(); + let relay_handle = tokio::spawn(async move { + Box::pin(linera_bridge::relay::run( + "http://localhost:8545", + Some(wallet_path.as_path()), + Some(keystore_path.as_path()), + Some(&storage_config), + chain_a, + owner_a, + &bridge_addr_str, + &bridge_app_str, + &fungible_app_str, + ANVIL_PRIVATE_KEY, + None, + relay_port, + &linera_storage_runtime::CommonStorageOptions::with_defaults(), + Duration::from_secs(2), + 0, + 5, + Some(sqlite_path_for_relay.as_path()), + )) + .await + }); + + let relay_url = format!("http://localhost:{relay_port}"); + let http = reqwest::Client::new(); + if let Err(error) = wait_for_relay_http_ready(&http, &relay_url, Duration::from_secs(60)).await + { + relay_handle.abort(); + return Err(error); + } + + let num_burns_i64 = i64::try_from(NUM_BURNS).unwrap(); + if let Err(error) = wait_for_relay_metrics( + &http, + &relay_url, + |detected, _completed, _pending, _failed| detected >= num_burns_i64, + Duration::from_secs(60), + ) + .await + { + relay_handle.abort(); + return Err(error); + } + // Wait for the chunked-processBurns path to settle every burn. + // Allow more wall time than the single-addBlock test because each + // chunk is its own EVM tx + receipt round-trip. + if let Err(error) = wait_for_relay_metrics( + &http, + &relay_url, + |_detected, completed, _pending, _failed| completed >= num_burns_i64, + Duration::from_secs(180), + ) + .await + { + relay_handle.abort(); + return Err(error); + } + + let token = IERC20::new(erc20_addr, &provider); + let one_burn = U256::from(BURN_AMOUNT_TOKENS) * U256::from(10u128.pow(18)); + let mut observed_balances = Vec::with_capacity(recipients.len()); + for recipient in &recipients { + let balance = token.balanceOf(*recipient).call().await?; + observed_balances.push((*recipient, balance)); + } + + let final_metrics = http + .get(format!("{relay_url}/metrics")) + .send() + .await? + .text() + .await?; + let burns_detected = parse_metric_value(&final_metrics, "linera_bridge_burns_detected"); + let burns_completed = parse_metric_value(&final_metrics, "linera_bridge_burns_completed"); + + relay_handle.abort(); + + tracing::info!( + ?observed_balances, + burns_detected, + burns_completed, + "Final state" + ); + + assert_eq!( + burns_detected, num_burns_i64, + "relayer must have detected all {NUM_BURNS} burns; got {burns_detected}" + ); + assert_eq!( + burns_completed, num_burns_i64, + "relayer must have completed all {NUM_BURNS} burns; got {burns_completed}" + ); + + for (recipient, balance) in &observed_balances { + assert_eq!( + *balance, one_burn, + "recipient {recipient:?} balance {balance}, expected {one_burn}" + ); + } + + Ok(()) +} From 0914f4c71f71d0a88f9dd65ffdb02fe85e9bef48 Mon Sep 17 00:00:00 2001 From: deuszx Date: Wed, 13 May 2026 17:12:48 +0200 Subject: [PATCH 08/31] Drop unreachable already-verified branch in process_pending_burns --- linera-bridge/src/monitor/linera.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/linera-bridge/src/monitor/linera.rs b/linera-bridge/src/monitor/linera.rs index f48b00a802f0..845cca4ad8a3 100644 --- a/linera-bridge/src/monitor/linera.rs +++ b/linera-bridge/src/monitor/linera.rs @@ -124,12 +124,7 @@ pub(crate) async fn process_pending_burns { - let msg = format!("{e:#}"); - if msg.contains("already verified") { - tracing::trace!(?height, "Block already verified on EVM"); - } else { - tracing::warn!(?height, "addBlock submission failed: {e:#}"); - } + tracing::warn!(?height, "addBlock submission failed: {e:#}"); } }, Ok(false) => { From 14ed288c61a1c0051e29b10b7d348eeb9fd7a875 Mon Sep 17 00:00:00 2001 From: deuszx Date: Wed, 13 May 2026 17:15:46 +0200 Subject: [PATCH 09/31] Apply checks-effects-interactions to burn dedup in FungibleBridge --- linera-bridge/src/solidity/FungibleBridge.sol | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/linera-bridge/src/solidity/FungibleBridge.sol b/linera-bridge/src/solidity/FungibleBridge.sol index 83aeffe1a045..845461424982 100644 --- a/linera-bridge/src/solidity/FungibleBridge.sol +++ b/linera-bridge/src/solidity/FungibleBridge.sol @@ -119,8 +119,12 @@ contract FungibleBridge is Microchain { WrappedFungibleTypes.BurnEvent memory burnEvt = WrappedFungibleTypes.bcs_deserialize_BurnEvent(evt.value); address target = address(burnEvt.target); - require(token.transfer(target, burnEvt.amount.value), "token transfer failed"); + // Checks-effects-interactions: flip the dedup flag BEFORE + // the external `token.transfer` call so a malicious token + // that re-enters `addBlock` / `processBurns` cannot trigger + // a second release for the same burn. processedBurns[key] = true; + require(token.transfer(target, burnEvt.amount.value), "token transfer failed"); } } } @@ -174,11 +178,15 @@ contract FungibleBridge is Microchain { WrappedFungibleTypes.BurnEvent memory burnEvt = WrappedFungibleTypes.bcs_deserialize_BurnEvent(evt.value); + // Checks-effects-interactions: flip the dedup flag BEFORE the + // external `token.transfer` call so a malicious token that + // re-enters `processBurns` / `addBlock` cannot trigger a + // second release for the same burn. + processedBurns[key] = true; require( token.transfer(address(burnEvt.target), burnEvt.amount.value), "token transfer failed" ); - processedBurns[key] = true; } } From 5c9b710778135093e6ecbcc8f42fc86cd84fd0a7 Mon Sep 17 00:00:00 2001 From: deuszx Date: Wed, 13 May 2026 17:26:46 +0200 Subject: [PATCH 10/31] Extract _burnKey + _isMatchingBurn + _releaseBurn helpers --- linera-bridge/src/solidity/FungibleBridge.sol | 85 +++++++++---------- 1 file changed, 42 insertions(+), 43 deletions(-) diff --git a/linera-bridge/src/solidity/FungibleBridge.sol b/linera-bridge/src/solidity/FungibleBridge.sol index 845461424982..f0bd31fac6a0 100644 --- a/linera-bridge/src/solidity/FungibleBridge.sol +++ b/linera-bridge/src/solidity/FungibleBridge.sol @@ -55,7 +55,7 @@ contract FungibleBridge is Microchain { /// `Event.index` from the Linera block body — the same value the /// off-chain relayer pulls from the certificate. function isBurnProcessed(uint64 height, uint32 eventIndex) external view returns (bool) { - return processedBurns[keccak256(abi.encode(height, eventIndex))]; + return processedBurns[_burnKey(height, eventIndex)]; } /// Locks ERC-20 tokens in the bridge and emits a DepositInitiated event. @@ -103,28 +103,12 @@ contract FungibleBridge is Microchain { BridgeTypes.Event[] memory txEvents = blockValue.body.events[i]; for (uint256 j = 0; j < txEvents.length; j++) { BridgeTypes.Event memory evt = txEvents[j]; + if (!_isMatchingBurn(evt, burnsHash)) continue; - // choice==1 is User application - if (evt.stream_id.application_id.choice != 1) continue; - if (evt.stream_id.application_id.user.application_description_hash.value != fungibleApplicationId) { - continue; - } - - // Check stream name is "burns" - if (keccak256(evt.stream_id.stream_name.value) != burnsHash) continue; - - bytes32 key = keccak256(abi.encode(height, evt.index)); + bytes32 key = _burnKey(height, evt.index); if (processedBurns[key]) continue; - WrappedFungibleTypes.BurnEvent memory burnEvt = - WrappedFungibleTypes.bcs_deserialize_BurnEvent(evt.value); - address target = address(burnEvt.target); - // Checks-effects-interactions: flip the dedup flag BEFORE - // the external `token.transfer` call so a malicious token - // that re-enters `addBlock` / `processBurns` cannot trigger - // a second release for the same burn. - processedBurns[key] = true; - require(token.transfer(target, burnEvt.amount.value), "token transfer failed"); + _releaseBurn(evt, key); } } } @@ -161,35 +145,50 @@ contract FungibleBridge is Microchain { uint32 pos = eventPositionsInTx[k]; require(pos < txEvents.length, "eventPos out of range"); BridgeTypes.Event memory evt = txEvents[pos]; + require(_isMatchingBurn(evt, burnsHash), "not a matching burn"); - if (evt.stream_id.application_id.choice != 1) { - revert("not a matching burn"); - } - if (evt.stream_id.application_id.user.application_description_hash.value - != fungibleApplicationId) { - revert("not a matching burn"); - } - if (keccak256(evt.stream_id.stream_name.value) != burnsHash) { - revert("not a matching burn"); - } - - bytes32 key = keccak256(abi.encode(height, evt.index)); + bytes32 key = _burnKey(height, evt.index); require(!processedBurns[key], "burn already processed"); - WrappedFungibleTypes.BurnEvent memory burnEvt = - WrappedFungibleTypes.bcs_deserialize_BurnEvent(evt.value); - // Checks-effects-interactions: flip the dedup flag BEFORE the - // external `token.transfer` call so a malicious token that - // re-enters `processBurns` / `addBlock` cannot trigger a - // second release for the same burn. - processedBurns[key] = true; - require( - token.transfer(address(burnEvt.target), burnEvt.amount.value), - "token transfer failed" - ); + _releaseBurn(evt, key); } } + /// Dedup key for a burn at `(height, eventIndex)`. `eventIndex` is the + /// underlying Linera `Event.index`. + function _burnKey(uint64 height, uint32 eventIndex) private pure returns (bytes32) { + return keccak256(abi.encode(height, eventIndex)); + } + + /// Returns true if `evt` belongs to the configured wrapped-fungible + /// application's "burns" stream. + function _isMatchingBurn(BridgeTypes.Event memory evt, bytes32 burnsHash) + private view returns (bool) + { + // choice == 1 is User application + if (evt.stream_id.application_id.choice != 1) return false; + if (evt.stream_id.application_id.user.application_description_hash.value + != fungibleApplicationId) { + return false; + } + if (keccak256(evt.stream_id.stream_name.value) != burnsHash) return false; + return true; + } + + /// Releases the ERC-20 tokens for the burn described by `evt`. Sets + /// the dedup flag BEFORE the external `token.transfer` call + /// (checks-effects-interactions) so a malicious token that re-enters + /// `addBlock` / `processBurns` cannot trigger a second release. + function _releaseBurn(BridgeTypes.Event memory evt, bytes32 key) private { + WrappedFungibleTypes.BurnEvent memory burnEvt = + WrappedFungibleTypes.bcs_deserialize_BurnEvent(evt.value); + processedBurns[key] = true; + require( + token.transfer(address(burnEvt.target), burnEvt.amount.value), + "token transfer failed" + ); + } + /// @dev Calls transferFrom and handles tokens that don't return a boolean. function _safeTransferFrom(address from, address to, uint256 amount_) internal { (bool success, bytes memory data) = From dbd111fb65e6f53fdf50f86e91a4fd6fd6cd3cc4 Mon Sep 17 00:00:00 2001 From: deuszx Date: Wed, 13 May 2026 18:03:08 +0200 Subject: [PATCH 11/31] Print error when wrapped fungible app publish fails --- examples/bridge-demo/setup.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/bridge-demo/setup.sh b/examples/bridge-demo/setup.sh index bf6fb72be314..aba062d9e44e 100755 --- a/examples/bridge-demo/setup.sh +++ b/examples/bridge-demo/setup.sh @@ -299,6 +299,7 @@ for attempt in 1 2 3; do --json-parameters "$WRAPPED_PARAMS" \ --json-argument '{"accounts":{}}' 2>&1) && break echo " Attempt $attempt failed, retrying..." >&2 + echo "$WRAPPED_APP_OUTPUT" >&2 sleep 2 done [[ -z "$WRAPPED_APP_OUTPUT" ]] && { echo "ERROR: publish-and-create wrapped-fungible failed after retries" >&2; exit 1; } From 12127b7e99f6aaf5128c6ac3eddfd2ac9fe64757 Mon Sep 17 00:00:00 2001 From: deuszx Date: Wed, 13 May 2026 18:16:18 +0200 Subject: [PATCH 12/31] Remove references to old code behavior --- linera-bridge/src/monitor/linera.rs | 12 ++++++------ linera-bridge/src/solidity/Microchain.sol | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/linera-bridge/src/monitor/linera.rs b/linera-bridge/src/monitor/linera.rs index 845cca4ad8a3..e2ba827723d9 100644 --- a/linera-bridge/src/monitor/linera.rs +++ b/linera-bridge/src/monitor/linera.rs @@ -129,10 +129,11 @@ pub(crate) async fn process_pending_burns { 'tx_loop: for (tx_index, positions) in &by_tx { - // Iterative LIFO split-to-fit per tx group. Mirrors - // `relay::settlement::split_to_fit` but inlined to - // avoid the `AsyncFn` predicate's HRTB Send issue when - // the future captures `&EvmClient` across awaits. + // Iterative LIFO split-to-fit per tx group. The + // algorithm is inlined (rather than factored into a + // pure helper that takes an async predicate) because + // an `AsyncFn` closure capturing `&EvmClient` across + // awaits trips an HRTB Send bound under `tokio::spawn`. let mut stack: Vec> = vec![positions.clone()]; let mut chunks: Vec> = Vec::new(); let mut single_too_large: Option = None; @@ -191,8 +192,7 @@ pub(crate) async fn process_pending_burns( linera_client: &LineraClient, target_height: BlockHeight, diff --git a/linera-bridge/src/solidity/Microchain.sol b/linera-bridge/src/solidity/Microchain.sol index fd58a286e413..ca6a4dd0d642 100644 --- a/linera-bridge/src/solidity/Microchain.sol +++ b/linera-bridge/src/solidity/Microchain.sol @@ -15,9 +15,9 @@ abstract contract Microchain { /// Verifies a certificate and dispatches to the subclass. Subclasses /// MUST be idempotent under repeated calls for the same block: this - /// contract no longer prevents re-entry on the same `signedHash`. - /// The off-chain relayer relies on this idempotency to safely - /// re-submit `addBlock(cert)` after partial settlement. + /// contract does not gate on `signedHash`. The off-chain relayer + /// relies on that idempotency to safely re-submit `addBlock(cert)` + /// after partial settlement. function addBlock(bytes calldata data) external { (BridgeTypes.Block memory blockValue, ) = lightClient.verifyBlock(data); require(blockValue.header.chain_id.value.value == chainId, "chain id mismatch"); From 44abab54fe7a3e294dd5dc12ac965ab4284f36ac Mon Sep 17 00:00:00 2001 From: deuszx Date: Wed, 13 May 2026 18:19:57 +0200 Subject: [PATCH 13/31] forge fmt --- linera-bridge/src/solidity/FungibleBridge.sol | 23 ++-- linera-bridge/src/solidity/Microchain.sol | 2 +- .../src/solidity/test/FungibleBridge.t.sol | 102 +++++++----------- .../src/solidity/test/Microchain.t.sol | 6 +- 4 files changed, 47 insertions(+), 86 deletions(-) diff --git a/linera-bridge/src/solidity/FungibleBridge.sol b/linera-bridge/src/solidity/FungibleBridge.sol index f0bd31fac6a0..25877f3f70c3 100644 --- a/linera-bridge/src/solidity/FungibleBridge.sol +++ b/linera-bridge/src/solidity/FungibleBridge.sol @@ -128,12 +128,8 @@ contract FungibleBridge is Microchain { /// (`"not a matching burn"`) /// - any burn already processed (`"burn already processed"`) /// - any failed `token.transfer` (`"token transfer failed"`) - function processBurns( - bytes calldata data, - uint32 txIndex, - uint32[] calldata eventPositionsInTx - ) external { - (BridgeTypes.Block memory blockValue, ) = lightClient.verifyBlock(data); + function processBurns(bytes calldata data, uint32 txIndex, uint32[] calldata eventPositionsInTx) external { + (BridgeTypes.Block memory blockValue,) = lightClient.verifyBlock(data); require(blockValue.header.chain_id.value.value == chainId, "chain id mismatch"); require(txIndex < blockValue.body.events.length, "txIndex out of range"); @@ -162,13 +158,10 @@ contract FungibleBridge is Microchain { /// Returns true if `evt` belongs to the configured wrapped-fungible /// application's "burns" stream. - function _isMatchingBurn(BridgeTypes.Event memory evt, bytes32 burnsHash) - private view returns (bool) - { + function _isMatchingBurn(BridgeTypes.Event memory evt, bytes32 burnsHash) private view returns (bool) { // choice == 1 is User application if (evt.stream_id.application_id.choice != 1) return false; - if (evt.stream_id.application_id.user.application_description_hash.value - != fungibleApplicationId) { + if (evt.stream_id.application_id.user.application_description_hash.value != fungibleApplicationId) { return false; } if (keccak256(evt.stream_id.stream_name.value) != burnsHash) return false; @@ -180,13 +173,9 @@ contract FungibleBridge is Microchain { /// (checks-effects-interactions) so a malicious token that re-enters /// `addBlock` / `processBurns` cannot trigger a second release. function _releaseBurn(BridgeTypes.Event memory evt, bytes32 key) private { - WrappedFungibleTypes.BurnEvent memory burnEvt = - WrappedFungibleTypes.bcs_deserialize_BurnEvent(evt.value); + WrappedFungibleTypes.BurnEvent memory burnEvt = WrappedFungibleTypes.bcs_deserialize_BurnEvent(evt.value); processedBurns[key] = true; - require( - token.transfer(address(burnEvt.target), burnEvt.amount.value), - "token transfer failed" - ); + require(token.transfer(address(burnEvt.target), burnEvt.amount.value), "token transfer failed"); } /// @dev Calls transferFrom and handles tokens that don't return a boolean. diff --git a/linera-bridge/src/solidity/Microchain.sol b/linera-bridge/src/solidity/Microchain.sol index ca6a4dd0d642..e7371ac91e3c 100644 --- a/linera-bridge/src/solidity/Microchain.sol +++ b/linera-bridge/src/solidity/Microchain.sol @@ -19,7 +19,7 @@ abstract contract Microchain { /// relies on that idempotency to safely re-submit `addBlock(cert)` /// after partial settlement. function addBlock(bytes calldata data) external { - (BridgeTypes.Block memory blockValue, ) = lightClient.verifyBlock(data); + (BridgeTypes.Block memory blockValue,) = lightClient.verifyBlock(data); require(blockValue.header.chain_id.value.value == chainId, "chain id mismatch"); _onBlock(blockValue); } diff --git a/linera-bridge/src/solidity/test/FungibleBridge.t.sol b/linera-bridge/src/solidity/test/FungibleBridge.t.sol index a875f2b292c0..8fb633337c28 100644 --- a/linera-bridge/src/solidity/test/FungibleBridge.t.sol +++ b/linera-bridge/src/solidity/test/FungibleBridge.t.sol @@ -12,13 +12,13 @@ import {LineraToken} from "../LineraToken.sol"; // ------------------------------------------------------------------ bytes32 constant CHAIN_ID = bytes32(uint256(0xC1)); -uint64 constant HEIGHT = 42; -uint32 constant TX = 0; -uint128 constant AMOUNT = 1_000_000_000_000_000_000; // 1e18 -address constant RECIP_0 = address(0xA0); -address constant RECIP_1 = address(0xA1); -address constant RECIP_2 = address(0xA2); -bytes32 constant APP_ID = bytes32(uint256(0xF00D)); +uint64 constant HEIGHT = 42; +uint32 constant TX = 0; +uint128 constant AMOUNT = 1_000_000_000_000_000_000; // 1e18 +address constant RECIP_0 = address(0xA0); +address constant RECIP_1 = address(0xA1); +address constant RECIP_2 = address(0xA2); +bytes32 constant APP_ID = bytes32(uint256(0xF00D)); // ------------------------------------------------------------------ // MockLightClientForBurns @@ -29,38 +29,34 @@ bytes32 constant APP_ID = bytes32(uint256(0xF00D)); // ------------------------------------------------------------------ contract MockLightClientForBurns { bytes32 public immutable chainIdRet; - uint64 public immutable heightRet; - uint32 public immutable txIndexUsed; + uint64 public immutable heightRet; + uint32 public immutable txIndexUsed; bytes32 public immutable fungibleAppIdRet; - uint32 public immutable numBurns; + uint32 public immutable numBurns; uint128 public immutable amountPerBurn; address public immutable recipBase; constructor( bytes32 _chainId, - uint64 _height, - uint32 _txIndex, + uint64 _height, + uint32 _txIndex, bytes32 _fungibleAppId, - uint32 _numBurns, + uint32 _numBurns, uint128 _amountPerBurn, address _recipBase ) { - chainIdRet = _chainId; - heightRet = _height; - txIndexUsed = _txIndex; + chainIdRet = _chainId; + heightRet = _height; + txIndexUsed = _txIndex; fungibleAppIdRet = _fungibleAppId; - numBurns = _numBurns; - amountPerBurn = _amountPerBurn; - recipBase = _recipBase; + numBurns = _numBurns; + amountPerBurn = _amountPerBurn; + recipBase = _recipBase; } - function verifyBlock(bytes calldata) - external - view - returns (BridgeTypes.Block memory b, bytes32 sigHash) - { + function verifyBlock(bytes calldata) external view returns (BridgeTypes.Block memory b, bytes32 sigHash) { b.header.chain_id.value.value = chainIdRet; - b.header.height.value = heightRet; + b.header.height.value = heightRet; // Allocate txIndexUsed + 1 tx-slots; all before txIndexUsed are empty. b.body.events = new BridgeTypes.Event[][](uint256(txIndexUsed) + 1); @@ -95,32 +91,22 @@ contract MockLightClientForBurns { // ------------------------------------------------------------------ contract MockLightClientForNonBurn { bytes32 public immutable chainIdRet; - uint64 public immutable heightRet; + uint64 public immutable heightRet; bytes32 public immutable fungibleAppIdRet; uint128 public immutable amountPerBurn; address public immutable recipBase; - constructor( - bytes32 _chainId, - uint64 _height, - bytes32 _fungibleAppId, - uint128 _amountPerBurn, - address _recipBase - ) { - chainIdRet = _chainId; - heightRet = _height; + constructor(bytes32 _chainId, uint64 _height, bytes32 _fungibleAppId, uint128 _amountPerBurn, address _recipBase) { + chainIdRet = _chainId; + heightRet = _height; fungibleAppIdRet = _fungibleAppId; - amountPerBurn = _amountPerBurn; - recipBase = _recipBase; + amountPerBurn = _amountPerBurn; + recipBase = _recipBase; } - function verifyBlock(bytes calldata) - external - view - returns (BridgeTypes.Block memory b, bytes32 sigHash) - { + function verifyBlock(bytes calldata) external view returns (BridgeTypes.Block memory b, bytes32 sigHash) { b.header.chain_id.value.value = chainIdRet; - b.header.height.value = heightRet; + b.header.height.value = heightRet; b.body.events = new BridgeTypes.Event[][](1); b.body.events[0] = new BridgeTypes.Event[](1); @@ -165,11 +151,8 @@ function _u32s_single(uint32 a) pure returns (uint32[] memory) { contract FungibleBridgeProcessBurnsTest is Test { // Deploy a bridge backed by `lc`, with a LineraToken that has // `supply` tokens pre-minted to the bridge. - function _deployBridge(address lc, uint256 supply) - internal - returns (FungibleBridge bridge, LineraToken tok) - { - tok = new LineraToken("Test", "TST", supply); + function _deployBridge(address lc, uint256 supply) internal returns (FungibleBridge bridge, LineraToken tok) { + tok = new LineraToken("Test", "TST", supply); bridge = new FungibleBridge(lc, CHAIN_ID, address(tok), APP_ID); // Send all tokens to the bridge so transfer() calls succeed. tok.transfer(address(bridge), supply); @@ -180,20 +163,18 @@ contract FungibleBridgeProcessBurnsTest is Test { function test_processBurns_single_position_marks_processed() public { // 2 burns in tx TX at positions 0 and 1 with stream indices 5 and 6. // Settle only position 0; assert (HEIGHT, 5) is flipped, (HEIGHT, 6) stays false. - MockLightClientForBurns lc = - new MockLightClientForBurns(CHAIN_ID, HEIGHT, TX, APP_ID, 2, AMOUNT, RECIP_0); + MockLightClientForBurns lc = new MockLightClientForBurns(CHAIN_ID, HEIGHT, TX, APP_ID, 2, AMOUNT, RECIP_0); (FungibleBridge bridge,) = _deployBridge(address(lc), AMOUNT * 10); bridge.processBurns(hex"deadbeef", TX, _u32s_single(0)); - assertTrue( bridge.isBurnProcessed(HEIGHT, 5), "stream index 5 should be processed"); + assertTrue(bridge.isBurnProcessed(HEIGHT, 5), "stream index 5 should be processed"); assertFalse(bridge.isBurnProcessed(HEIGHT, 6), "stream index 6 should not be processed yet"); } function test_processBurns_multi_position_marks_both_processed() public { // 2 burns; settle both positions; both flags true. - MockLightClientForBurns lc = - new MockLightClientForBurns(CHAIN_ID, HEIGHT, TX, APP_ID, 2, AMOUNT, RECIP_0); + MockLightClientForBurns lc = new MockLightClientForBurns(CHAIN_ID, HEIGHT, TX, APP_ID, 2, AMOUNT, RECIP_0); (FungibleBridge bridge,) = _deployBridge(address(lc), AMOUNT * 10); bridge.processBurns(hex"deadbeef", TX, _u32s(0, 1)); @@ -204,8 +185,7 @@ contract FungibleBridgeProcessBurnsTest is Test { function test_processBurns_already_processed_reverts() public { // 1 burn; settle; settle again → revert "burn already processed". - MockLightClientForBurns lc = - new MockLightClientForBurns(CHAIN_ID, HEIGHT, TX, APP_ID, 1, AMOUNT, RECIP_0); + MockLightClientForBurns lc = new MockLightClientForBurns(CHAIN_ID, HEIGHT, TX, APP_ID, 1, AMOUNT, RECIP_0); (FungibleBridge bridge,) = _deployBridge(address(lc), AMOUNT * 10); bridge.processBurns(hex"deadbeef", TX, _u32s_single(0)); @@ -216,8 +196,7 @@ contract FungibleBridgeProcessBurnsTest is Test { function test_processBurns_tx_index_out_of_range_reverts() public { // Block has 1 tx; processBurns with txIndex=99 → revert "txIndex out of range". - MockLightClientForBurns lc = - new MockLightClientForBurns(CHAIN_ID, HEIGHT, TX, APP_ID, 1, AMOUNT, RECIP_0); + MockLightClientForBurns lc = new MockLightClientForBurns(CHAIN_ID, HEIGHT, TX, APP_ID, 1, AMOUNT, RECIP_0); (FungibleBridge bridge,) = _deployBridge(address(lc), AMOUNT * 10); vm.expectRevert(bytes("txIndex out of range")); @@ -226,8 +205,7 @@ contract FungibleBridgeProcessBurnsTest is Test { function test_processBurns_event_pos_out_of_range_reverts() public { // 2 burns at positions 0,1; processBurns with position=99 → revert "eventPos out of range". - MockLightClientForBurns lc = - new MockLightClientForBurns(CHAIN_ID, HEIGHT, TX, APP_ID, 2, AMOUNT, RECIP_0); + MockLightClientForBurns lc = new MockLightClientForBurns(CHAIN_ID, HEIGHT, TX, APP_ID, 2, AMOUNT, RECIP_0); (FungibleBridge bridge,) = _deployBridge(address(lc), AMOUNT * 10); vm.expectRevert(bytes("eventPos out of range")); @@ -237,8 +215,7 @@ contract FungibleBridgeProcessBurnsTest is Test { function test_processBurns_non_burn_event_reverts() public { // MockLightClient returns a Block whose only event has the wrong // stream_name ("deposits") → processBurns(tx=0, [0]) → revert "not a matching burn". - MockLightClientForNonBurn lc = - new MockLightClientForNonBurn(CHAIN_ID, HEIGHT, APP_ID, AMOUNT, RECIP_0); + MockLightClientForNonBurn lc = new MockLightClientForNonBurn(CHAIN_ID, HEIGHT, APP_ID, AMOUNT, RECIP_0); (FungibleBridge bridge,) = _deployBridge(address(lc), AMOUNT * 10); vm.expectRevert(bytes("not a matching burn")); @@ -250,8 +227,7 @@ contract FungibleBridgeProcessBurnsTest is Test { // Step 1: settle position 1 alone (succeeds). // Step 2: settle [0, 1] → reverts on position 1 ("burn already processed"). // Assert (HEIGHT, stream-index-of-pos-0) is STILL false (revert rolled back pos-0 update). - MockLightClientForBurns lc = - new MockLightClientForBurns(CHAIN_ID, HEIGHT, TX, APP_ID, 2, AMOUNT, RECIP_0); + MockLightClientForBurns lc = new MockLightClientForBurns(CHAIN_ID, HEIGHT, TX, APP_ID, 2, AMOUNT, RECIP_0); (FungibleBridge bridge,) = _deployBridge(address(lc), AMOUNT * 10); // Settle position 1 (stream index 6). diff --git a/linera-bridge/src/solidity/test/Microchain.t.sol b/linera-bridge/src/solidity/test/Microchain.t.sol index c310f236f782..c61b6cd79ed6 100644 --- a/linera-bridge/src/solidity/test/Microchain.t.sol +++ b/linera-bridge/src/solidity/test/Microchain.t.sol @@ -16,11 +16,7 @@ contract MockLightClient { expectedChainId = _chainId; } - function verifyBlock(bytes calldata) - external - view - returns (BridgeTypes.Block memory b, bytes32 sigHash) - { + function verifyBlock(bytes calldata) external view returns (BridgeTypes.Block memory b, bytes32 sigHash) { b.header.chain_id.value.value = expectedChainId; sigHash = bytes32(uint256(0x1234)); } From 044b7beaf836725e2e5e785e0975032119bdeebc Mon Sep 17 00:00:00 2001 From: deuszx Date: Thu, 14 May 2026 12:11:34 +0200 Subject: [PATCH 14/31] cargo fmt linera-bridge/tests/e2e --- linera-bridge/tests/e2e/src/lib.rs | 25 ++++++++++++++++--- .../tests/e2e/tests/auto_deposit_scan.rs | 16 +++++++----- ...n_completion_requires_on_chain_evidence.rs | 11 ++------ .../tests/e2e/tests/burns_per_evm_tx.rs | 6 ++++- .../tests/e2e/tests/committee_rotation.rs | 6 ++--- .../tests/e2e/tests/evm_to_linera_bridge.rs | 9 ++++--- .../e2e/tests/multiple_burns_same_block.rs | 14 +++++------ ...iple_burns_same_recipient_across_blocks.rs | 11 +++----- 8 files changed, 55 insertions(+), 43 deletions(-) diff --git a/linera-bridge/tests/e2e/src/lib.rs b/linera-bridge/tests/e2e/src/lib.rs index df30e647b770..0c99124dbeb7 100644 --- a/linera-bridge/tests/e2e/src/lib.rs +++ b/linera-bridge/tests/e2e/src/lib.rs @@ -196,7 +196,13 @@ pub async fn deploy_linera_token( compose_file, ) .await; - parse_broadcast_address(compose, project_name, compose_file, "DeployLineraToken.s.sol").await + parse_broadcast_address( + compose, + project_name, + compose_file, + "DeployLineraToken.s.sol", + ) + .await } /// Same as [`deploy_linera_token`] but overrides the initial token supply @@ -224,7 +230,13 @@ pub async fn deploy_linera_token_with_supply( compose_file, ) .await; - parse_broadcast_address(compose, project_name, compose_file, "DeployLineraToken.s.sol").await + parse_broadcast_address( + compose, + project_name, + compose_file, + "DeployLineraToken.s.sol", + ) + .await } /// Deploys FungibleBridge via the `DeployFungibleBridge.s.sol` forge @@ -256,8 +268,13 @@ pub async fn deploy_fungible_bridge( compose_file, ) .await; - parse_broadcast_address(compose, project_name, compose_file, "DeployFungibleBridge.s.sol") - .await + parse_broadcast_address( + compose, + project_name, + compose_file, + "DeployFungibleBridge.s.sol", + ) + .await } /// Queries the evm-bridge app to check whether a deposit has been processed. diff --git a/linera-bridge/tests/e2e/tests/auto_deposit_scan.rs b/linera-bridge/tests/e2e/tests/auto_deposit_scan.rs index ce981d9459b1..3a4adc08d73f 100644 --- a/linera-bridge/tests/e2e/tests/auto_deposit_scan.rs +++ b/linera-bridge/tests/e2e/tests/auto_deposit_scan.rs @@ -153,11 +153,13 @@ async fn test_auto_deposit_scan() -> anyhow::Result<()> { .nth(3) .context("manifest dir has fewer than 3 ancestors")? .to_path_buf(); - let evm_bridge_wasm_dir = repo_root.join("linera-bridge/contracts/evm-bridge/target/wasm32-unknown-unknown/release"); + let evm_bridge_wasm_dir = + repo_root.join("linera-bridge/contracts/evm-bridge/target/wasm32-unknown-unknown/release"); let wasm_dir = repo_root.join("examples/target/wasm32-unknown-unknown/release"); tracing::info!("Publishing evm-bridge module..."); - let eb_contract = Bytecode::load_from_file(evm_bridge_wasm_dir.join("evm_bridge_contract.wasm"))?; + let eb_contract = + Bytecode::load_from_file(evm_bridge_wasm_dir.join("evm_bridge_contract.wasm"))?; let eb_service = Bytecode::load_from_file(evm_bridge_wasm_dir.join("evm_bridge_service.wasm"))?; let (eb_module_id, _) = cc_a .publish_module(eb_contract, eb_service, VmRuntime::Wasm) @@ -316,9 +318,9 @@ async fn test_auto_deposit_scan() -> anyhow::Result<()> { None, relay_port, &linera_storage_runtime::CommonStorageOptions::with_defaults(), - std::time::Duration::from_secs(5), // monitor_scan_interval - 0, // monitor_start_block - 5, // max_retries + std::time::Duration::from_secs(5), // monitor_scan_interval + 0, // monitor_start_block + 5, // max_retries None, )) .await @@ -401,7 +403,9 @@ async fn test_auto_deposit_scan() -> anyhow::Result<()> { let deposit_key = linera_bridge::proof::DepositKey { source_chain_id: 31337, block_hash: deposit_receipt.block_hash.unwrap(), - tx_index: deposit_receipt.transaction_index.expect("transaction_index missing"), + tx_index: deposit_receipt + .transaction_index + .expect("transaction_index missing"), log_index, }; diff --git a/linera-bridge/tests/e2e/tests/burn_completion_requires_on_chain_evidence.rs b/linera-bridge/tests/e2e/tests/burn_completion_requires_on_chain_evidence.rs index f2b8b3f4e010..dc0cd664e3cb 100644 --- a/linera-bridge/tests/e2e/tests/burn_completion_requires_on_chain_evidence.rs +++ b/linera-bridge/tests/e2e/tests/burn_completion_requires_on_chain_evidence.rs @@ -16,11 +16,7 @@ #![recursion_limit = "512"] -use std::{ - collections::BTreeMap, - path::PathBuf, - time::{Duration, Instant}, -}; +use std::time::{Duration, Instant}; use alloy::{ primitives::U256, @@ -28,10 +24,7 @@ use alloy::{ signers::local::PrivateKeySigner, sol, }; -use anyhow::Context as _; -use linera_base::{ - crypto::InMemorySigner, data_types::Amount, identifiers::AccountOwner, -}; +use linera_base::{crypto::InMemorySigner, data_types::Amount, identifiers::AccountOwner}; use linera_bridge_e2e::{ compose_file_path, deploy_fungible_bridge, deploy_linera_token, fund_bridge_erc20, light_client_address, parse_metric_value, publish_and_create_wrapped_fungible, start_compose, diff --git a/linera-bridge/tests/e2e/tests/burns_per_evm_tx.rs b/linera-bridge/tests/e2e/tests/burns_per_evm_tx.rs index 86371438b351..e3090981498d 100644 --- a/linera-bridge/tests/e2e/tests/burns_per_evm_tx.rs +++ b/linera-bridge/tests/e2e/tests/burns_per_evm_tx.rs @@ -200,7 +200,11 @@ async fn burns_per_evm_tx( tracing::info!(burn_ops = next_hi, gas, "search: found upper bound"); break; } - tracing::info!(burn_ops = next_hi, gas, "search: doubling, still under limit"); + tracing::info!( + burn_ops = next_hi, + gas, + "search: doubling, still under limit" + ); if hi == MAX_SEARCH_N { break; } diff --git a/linera-bridge/tests/e2e/tests/committee_rotation.rs b/linera-bridge/tests/e2e/tests/committee_rotation.rs index c53e0a7ec078..a8c15a68d9eb 100644 --- a/linera-bridge/tests/e2e/tests/committee_rotation.rs +++ b/linera-bridge/tests/e2e/tests/committee_rotation.rs @@ -97,9 +97,9 @@ async fn test_committee_rotation_updates_evm_light_client() -> anyhow::Result<() Some(&light_client.to_string()), relay_port, &linera_storage_runtime::CommonStorageOptions::with_defaults(), - std::time::Duration::from_secs(5), // monitor_scan_interval - 0, // monitor_start_block - 5, // max_retries + std::time::Duration::from_secs(5), // monitor_scan_interval + 0, // monitor_start_block + 5, // max_retries None, )) .await 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 a30d5e00d153..bce26cf2dd95 100644 --- a/linera-bridge/tests/e2e/tests/evm_to_linera_bridge.rs +++ b/linera-bridge/tests/e2e/tests/evm_to_linera_bridge.rs @@ -27,8 +27,7 @@ use linera_bridge::{ }; use linera_bridge_e2e::{ compose_file_path, deploy_fungible_bridge, deploy_linera_token, light_client_address, - start_compose, wait_for_light_client, - ANVIL_PRIVATE_KEY, + start_compose, wait_for_light_client, ANVIL_PRIVATE_KEY, }; use linera_client::{chain_listener::ClientContext as _, client_context::ClientContext}; use linera_core::environment::wallet::Memory; @@ -133,7 +132,8 @@ async fn test_evm_to_linera_bridge() -> anyhow::Result<()> { .nth(3) .context("manifest dir has fewer than 3 ancestors")? .to_path_buf(); - let evm_bridge_wasm_dir = repo_root.join("linera-bridge/contracts/evm-bridge/target/wasm32-unknown-unknown/release"); + let evm_bridge_wasm_dir = + repo_root.join("linera-bridge/contracts/evm-bridge/target/wasm32-unknown-unknown/release"); let wasm_dir = repo_root.join("examples/target/wasm32-unknown-unknown/release"); // 4a. Publish and create wrapped-fungible app @@ -151,7 +151,8 @@ async fn test_evm_to_linera_bridge() -> anyhow::Result<()> { // 4b. Publish and create evm-bridge app first (so wrapped-fungible can reference it) tracing::info!("Publishing evm-bridge module..."); - let eb_contract = Bytecode::load_from_file(evm_bridge_wasm_dir.join("evm_bridge_contract.wasm"))?; + let eb_contract = + Bytecode::load_from_file(evm_bridge_wasm_dir.join("evm_bridge_contract.wasm"))?; let eb_service = Bytecode::load_from_file(evm_bridge_wasm_dir.join("evm_bridge_service.wasm"))?; let (eb_module_id, _) = cc diff --git a/linera-bridge/tests/e2e/tests/multiple_burns_same_block.rs b/linera-bridge/tests/e2e/tests/multiple_burns_same_block.rs index f3d5459d7570..75132e0e7f88 100644 --- a/linera-bridge/tests/e2e/tests/multiple_burns_same_block.rs +++ b/linera-bridge/tests/e2e/tests/multiple_burns_same_block.rs @@ -17,9 +17,7 @@ use std::time::Duration; use alloy::{primitives::U256, providers::ProviderBuilder, sol}; -use linera_base::{ - crypto::InMemorySigner, data_types::Amount, identifiers::AccountOwner, -}; +use linera_base::{crypto::InMemorySigner, data_types::Amount, identifiers::AccountOwner}; use linera_bridge_e2e::{ compose_file_path, deploy_fungible_bridge, deploy_linera_token, fund_bridge_erc20, light_client_address, parse_metric_value, publish_and_create_wrapped_fungible, start_compose, @@ -274,13 +272,13 @@ async fn relayer_processes_every_burn_in_one_block() -> anyhow::Result<()> { // Each occurrence in `recipients` is one expected transfer. A // recipient listed twice should accumulate two transfers, etc. - let mut expected_per_recipient: std::collections::BTreeMap< - alloy::primitives::Address, - U256, - > = std::collections::BTreeMap::new(); + let mut expected_per_recipient: std::collections::BTreeMap = + std::collections::BTreeMap::new(); let one_burn = U256::from(BURN_AMOUNT_TOKENS) * U256::from(10u128.pow(18)); for recipient in &recipients { - *expected_per_recipient.entry(*recipient).or_insert(U256::ZERO) += one_burn; + *expected_per_recipient + .entry(*recipient) + .or_insert(U256::ZERO) += one_burn; } let mut observed_balances = Vec::with_capacity(expected_per_recipient.len()); diff --git a/linera-bridge/tests/e2e/tests/multiple_burns_same_recipient_across_blocks.rs b/linera-bridge/tests/e2e/tests/multiple_burns_same_recipient_across_blocks.rs index 9f03f9bd0c55..a9615c22e815 100644 --- a/linera-bridge/tests/e2e/tests/multiple_burns_same_recipient_across_blocks.rs +++ b/linera-bridge/tests/e2e/tests/multiple_burns_same_recipient_across_blocks.rs @@ -25,9 +25,7 @@ use std::time::Duration; use alloy::{primitives::U256, providers::ProviderBuilder, sol}; -use linera_base::{ - crypto::InMemorySigner, data_types::Amount, identifiers::AccountOwner, -}; +use linera_base::{crypto::InMemorySigner, data_types::Amount, identifiers::AccountOwner}; use linera_bridge_e2e::{ compose_file_path, deploy_fungible_bridge, deploy_linera_token, fund_bridge_erc20, light_client_address, parse_metric_value, publish_and_create_wrapped_fungible, start_compose, @@ -230,9 +228,7 @@ async fn relayer_processes_every_burn_to_same_recipient() -> anyhow::Result<()> let settle_result = wait_for_relay_metrics( &http, &relay_url, - |_detected, completed, pending, _failed| { - pending == 0 && completed >= i64::from(NUM_BURNS) - }, + |_detected, completed, pending, _failed| pending == 0 && completed >= i64::from(NUM_BURNS), Duration::from_secs(240), ) .await; @@ -282,8 +278,7 @@ async fn relayer_processes_every_burn_to_same_recipient() -> anyhow::Result<()> // means at least one `PendingBurn` was marked complete without // its `token.transfer` actually landing on-chain — the UI-demo bug. assert_eq!( - token_balance, - expected_balance, + token_balance, expected_balance, "recipient must accumulate every burn; got {token_balance}, expected {expected_balance}" ); From 8dc74b5a5a31885837ea61b470aa82fc7f5f73f1 Mon Sep 17 00:00:00 2001 From: deuszx Date: Sat, 16 May 2026 15:29:30 +0200 Subject: [PATCH 15/31] processBurns: reject empty positions array Empty eventPositionsInTx silently paid for cert verification with no work to do. Reject eagerly so caller bugs surface early. --- linera-bridge/src/solidity/FungibleBridge.sol | 2 ++ linera-bridge/src/solidity/test/FungibleBridge.t.sol | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/linera-bridge/src/solidity/FungibleBridge.sol b/linera-bridge/src/solidity/FungibleBridge.sol index 25877f3f70c3..7a6c85c0af3e 100644 --- a/linera-bridge/src/solidity/FungibleBridge.sol +++ b/linera-bridge/src/solidity/FungibleBridge.sol @@ -122,6 +122,7 @@ contract FungibleBridge is Microchain { /// /// Reverts (atomically — no `processedBurns` flag is set if the call /// reverts) on: + /// - empty `eventPositionsInTx` (`"empty positions"`) /// - `txIndex` out of range (`"txIndex out of range"`) /// - any position out of range (`"eventPos out of range"`) /// - any position whose event is not a matching burn for this app @@ -129,6 +130,7 @@ contract FungibleBridge is Microchain { /// - any burn already processed (`"burn already processed"`) /// - any failed `token.transfer` (`"token transfer failed"`) function processBurns(bytes calldata data, uint32 txIndex, uint32[] calldata eventPositionsInTx) external { + require(eventPositionsInTx.length > 0, "empty positions"); (BridgeTypes.Block memory blockValue,) = lightClient.verifyBlock(data); require(blockValue.header.chain_id.value.value == chainId, "chain id mismatch"); require(txIndex < blockValue.body.events.length, "txIndex out of range"); diff --git a/linera-bridge/src/solidity/test/FungibleBridge.t.sol b/linera-bridge/src/solidity/test/FungibleBridge.t.sol index 8fb633337c28..1ad11a95ff3b 100644 --- a/linera-bridge/src/solidity/test/FungibleBridge.t.sol +++ b/linera-bridge/src/solidity/test/FungibleBridge.t.sol @@ -222,6 +222,17 @@ contract FungibleBridgeProcessBurnsTest is Test { bridge.processBurns(hex"deadbeef", 0, _u32s_single(0)); } + function test_processBurns_empty_positions_reverts() public { + // An empty positions array would silently pay for cert verification + // with no work to do. Reject it eagerly so caller bugs surface. + MockLightClientForBurns lc = new MockLightClientForBurns(CHAIN_ID, HEIGHT, TX, APP_ID, 1, AMOUNT, RECIP_0); + (FungibleBridge bridge,) = _deployBridge(address(lc), AMOUNT * 10); + + uint32[] memory empty = new uint32[](0); + vm.expectRevert(bytes("empty positions")); + bridge.processBurns(hex"deadbeef", TX, empty); + } + function test_processBurns_partial_revert_is_atomic() public { // 2 burns at positions 0,1. // Step 1: settle position 1 alone (succeeds). From 4fb7a64cd593483727f072a7d420fc12e98288ff Mon Sep 17 00:00:00 2001 From: deuszx Date: Sat, 16 May 2026 15:30:14 +0200 Subject: [PATCH 16/31] processBurns: skip duplicates instead of reverting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors _onBlock's idempotent semantics. Overlap with a prior addBlock or a racing/retrying processBurns no longer reverts the whole chunk — already-released burns are skipped silently and the rest still settle. Removes the all-or-nothing fragility flagged in PR review. --- linera-bridge/src/solidity/FungibleBridge.sol | 9 ++++- .../src/solidity/test/FungibleBridge.t.sol | 38 +++++++++++-------- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/linera-bridge/src/solidity/FungibleBridge.sol b/linera-bridge/src/solidity/FungibleBridge.sol index 7a6c85c0af3e..2767d3059dc1 100644 --- a/linera-bridge/src/solidity/FungibleBridge.sol +++ b/linera-bridge/src/solidity/FungibleBridge.sol @@ -120,6 +120,12 @@ contract FungibleBridge is Microchain { /// this when `addBlock(cert)` would not fit in a single EVM tx, /// chunking burns per-tx-then-by-gas. /// + /// Idempotent like `_onBlock`: positions already in `processedBurns` + /// are skipped silently rather than reverted. Lets the relayer recover + /// from overlap with a prior `addBlock` (or a racing/retrying + /// `processBurns`) instead of losing the whole chunk to a single + /// duplicate. + /// /// Reverts (atomically — no `processedBurns` flag is set if the call /// reverts) on: /// - empty `eventPositionsInTx` (`"empty positions"`) @@ -127,7 +133,6 @@ contract FungibleBridge is Microchain { /// - any position out of range (`"eventPos out of range"`) /// - any position whose event is not a matching burn for this app /// (`"not a matching burn"`) - /// - any burn already processed (`"burn already processed"`) /// - any failed `token.transfer` (`"token transfer failed"`) function processBurns(bytes calldata data, uint32 txIndex, uint32[] calldata eventPositionsInTx) external { require(eventPositionsInTx.length > 0, "empty positions"); @@ -146,7 +151,7 @@ contract FungibleBridge is Microchain { require(_isMatchingBurn(evt, burnsHash), "not a matching burn"); bytes32 key = _burnKey(height, evt.index); - require(!processedBurns[key], "burn already processed"); + if (processedBurns[key]) continue; _releaseBurn(evt, key); } diff --git a/linera-bridge/src/solidity/test/FungibleBridge.t.sol b/linera-bridge/src/solidity/test/FungibleBridge.t.sol index 1ad11a95ff3b..678bc299b1fe 100644 --- a/linera-bridge/src/solidity/test/FungibleBridge.t.sol +++ b/linera-bridge/src/solidity/test/FungibleBridge.t.sol @@ -183,15 +183,22 @@ contract FungibleBridgeProcessBurnsTest is Test { assertTrue(bridge.isBurnProcessed(HEIGHT, 6), "stream index 6 should be processed"); } - function test_processBurns_already_processed_reverts() public { - // 1 burn; settle; settle again → revert "burn already processed". + function test_processBurns_already_processed_skips() public { + // Idempotent like `_onBlock`: re-processing the same burn must be a + // no-op, not a revert. Keeps the relayer robust to overlap between + // an addBlock-path settlement and a racing/retrying processBurns + // call covering the same (height, tx, pos). MockLightClientForBurns lc = new MockLightClientForBurns(CHAIN_ID, HEIGHT, TX, APP_ID, 1, AMOUNT, RECIP_0); - (FungibleBridge bridge,) = _deployBridge(address(lc), AMOUNT * 10); + (FungibleBridge bridge, LineraToken tok) = _deployBridge(address(lc), AMOUNT * 10); bridge.processBurns(hex"deadbeef", TX, _u32s_single(0)); + uint256 firstBal = tok.balanceOf(RECIP_0); + assertEq(firstBal, AMOUNT, "first call should have released to recipient"); - vm.expectRevert(bytes("burn already processed")); + // Second call: must not revert and must not double-release. bridge.processBurns(hex"deadbeef", TX, _u32s_single(0)); + assertEq(tok.balanceOf(RECIP_0), firstBal, "second call must not double-release"); + assertTrue(bridge.isBurnProcessed(HEIGHT, 5), "burn stays marked processed"); } function test_processBurns_tx_index_out_of_range_reverts() public { @@ -233,23 +240,24 @@ contract FungibleBridgeProcessBurnsTest is Test { bridge.processBurns(hex"deadbeef", TX, empty); } - function test_processBurns_partial_revert_is_atomic() public { - // 2 burns at positions 0,1. - // Step 1: settle position 1 alone (succeeds). - // Step 2: settle [0, 1] → reverts on position 1 ("burn already processed"). - // Assert (HEIGHT, stream-index-of-pos-0) is STILL false (revert rolled back pos-0 update). + function test_processBurns_partial_overlap_releases_remaining() public { + // 2 burns at positions 0,1. Settle pos 1 first; then call + // processBurns([0, 1]). Under skip-on-duplicate semantics pos 0 must + // be released and pos 1 silently skipped — no revert, no double-release. MockLightClientForBurns lc = new MockLightClientForBurns(CHAIN_ID, HEIGHT, TX, APP_ID, 2, AMOUNT, RECIP_0); - (FungibleBridge bridge,) = _deployBridge(address(lc), AMOUNT * 10); + (FungibleBridge bridge, LineraToken tok) = _deployBridge(address(lc), AMOUNT * 10); - // Settle position 1 (stream index 6). bridge.processBurns(hex"deadbeef", TX, _u32s_single(1)); assertTrue(bridge.isBurnProcessed(HEIGHT, 6), "pos 1 should now be processed"); + address recip1 = address(uint160(RECIP_0) + 1); + assertEq(tok.balanceOf(recip1), AMOUNT, "pos 1 recipient should hold released amount"); - // Attempt to settle [0, 1] — position 1 will revert. - vm.expectRevert(bytes("burn already processed")); + // Overlapping call — pos 0 settles, pos 1 silently skipped. bridge.processBurns(hex"deadbeef", TX, _u32s(0, 1)); - // Position 0 (stream index 5) must be rolled back. - assertFalse(bridge.isBurnProcessed(HEIGHT, 5), "pos 0 must be rolled back by the revert"); + assertTrue(bridge.isBurnProcessed(HEIGHT, 5), "pos 0 should now be processed"); + assertTrue(bridge.isBurnProcessed(HEIGHT, 6), "pos 1 stays processed"); + assertEq(tok.balanceOf(RECIP_0), AMOUNT, "pos 0 released once to its recipient"); + assertEq(tok.balanceOf(recip1), AMOUNT, "pos 1 not double-released"); } } From ac3f6483b47d1c563a8dcb2c2ff4bef77a0f44a5 Mon Sep 17 00:00:00 2001 From: deuszx Date: Sat, 16 May 2026 15:32:33 +0200 Subject: [PATCH 17/31] is_gas_exceeded_error: drop "out of gas" generic fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare "out of gas" message can fire for reasons unrelated to the block gas limit — e.g. too-low tx gas cap, or contract state consuming more than estimated. Matching it as "doesn't fit" sent the relayer down the chunking path and masked real misconfigurations behind retry churn. Keep the node-specific signals ("gas required exceeds", "exceeds block gas limit", anvil's "execution reverted" + data: "0x"). --- linera-bridge/src/relay/evm.rs | 42 +++++++------- linera-bridge/src/relay/settlement.rs | 82 +++++++++++++++++++++++---- 2 files changed, 94 insertions(+), 30 deletions(-) diff --git a/linera-bridge/src/relay/evm.rs b/linera-bridge/src/relay/evm.rs index a78db2d1f2cf..b1b199b04e9b 100644 --- a/linera-bridge/src/relay/evm.rs +++ b/linera-bridge/src/relay/evm.rs @@ -265,26 +265,28 @@ impl EvmClient

{ } } -/// Returns true if the `alloy::contract::Error` reports that the call would not -/// fit under the node's block gas limit — i.e. the node refused to estimate -/// because the work required more gas than a single block can hold. -/// Substring-matches on the node's error message; pinned to the known wordings -/// for anvil, geth, and reth as of writing. +/// Returns true if the error is a JSON-RPC error reporting that the call +/// would not fit under the node's block gas limit — i.e. the node refused to +/// estimate because the work required more gas than a single block can hold. /// -/// Anvil's `eth_estimateGas` caps execution at the block gas limit. When the -/// call would exceed it, anvil returns `execution reverted, data: "0x"` (the -/// EVM hit OOG mid-call) rather than the canonical `"gas required exceeds"`. -/// We treat that combination as the same fit-doesn't-fit signal here. +/// Structurally pattern-matches `RpcError::ErrorResp` and substring-checks +/// the `ErrorPayload`'s `message` against node-specific wordings observed +/// from geth, erigon, alchemy, reth, and anvil (1.6, both calldata-too-big +/// and infinite-loop constructors). Transport-level failures (HTTP, +/// timeouts) and non-RPC errors return `false`. +/// +/// We deliberately do NOT match a bare `"out of gas"` — a regular tx can OOG +/// for reasons unrelated to the block gas limit (e.g. a too-low tx gas cap, or +/// contract state consuming more gas than estimated). Treating those as +/// "doesn't fit" would mask real misconfigurations behind retry churn down +/// the chunking path. pub fn is_gas_exceeded_error(error: &alloy::contract::Error) -> bool { - let msg = error.to_string().to_lowercase(); - if msg.contains("execution reverted") && msg.contains("data: \"0x\"") { - return true; - } - [ - "gas required exceeds", // anvil / geth - "exceeds block gas limit", // reth, foundry forks - "out of gas", // generic fallback - ] - .iter() - .any(|needle| msg.contains(needle)) + let alloy::contract::Error::TransportError(transport_err) = error else { + return false; + }; + let Some(payload) = transport_err.as_error_resp() else { + return false; + }; + let msg = payload.message.to_lowercase(); + msg.contains("gas required exceeds") || msg.contains("exceeds block gas limit") } diff --git a/linera-bridge/src/relay/settlement.rs b/linera-bridge/src/relay/settlement.rs index e4ebc5cb56b4..b3b4020e9f95 100644 --- a/linera-bridge/src/relay/settlement.rs +++ b/linera-bridge/src/relay/settlement.rs @@ -23,8 +23,25 @@ pub fn estimate_fits(r: Result) -> anyhow::Result>) -> ContractError { + let payload: ErrorPayload = ErrorPayload { + code: -32000, + message: message.to_string().into(), + data, + }; + ContractError::TransportError(RpcError::ErrorResp(payload)) + } + #[test] fn estimate_fits_ok_returns_true() { assert!(estimate_fits(Ok(123_456)).unwrap()); @@ -32,19 +49,64 @@ mod tests { } #[test] - fn estimate_fits_gas_exceeded_error_returns_false() { - use alloy::transports::TransportErrorKind; - let transport_err = - TransportErrorKind::custom_str("gas required exceeds allowance (30000000)"); - let contract_err = alloy::contract::Error::TransportError(transport_err); - assert!(!estimate_fits(Err(contract_err)).unwrap()); + fn estimate_fits_gas_required_exceeds_returns_false() { + // Geth / Erigon / Alchemy wording for an estimate that would not fit. + let err = rpc_error("gas required exceeds allowance (30000000)", None); + assert!(!estimate_fits(Err(err)).unwrap()); + } + + #[test] + fn estimate_fits_exceeds_block_gas_limit_returns_false() { + // Reth / foundry forks wording. + let err = rpc_error("call exceeds block gas limit", None); + assert!(!estimate_fits(Err(err)).unwrap()); + } + + #[test] + fn estimate_fits_anvil_out_of_gas_returns_false() { + // Anvil 1.6's eth_estimateGas wording for a block-gas-limit hit + // (verified empirically against both calldata-too-large and + // infinite-loop constructors). Substring "gas required exceeds" + // matches the geth/anvil/alchemy branch. + let err = rpc_error("Out of gas: gas required exceeds allowance: 100000", None); + assert!(!estimate_fits(Err(err)).unwrap()); + } + + #[test] + fn estimate_fits_real_contract_revert_bubbles_up() { + // `execution reverted` is a real on-chain revert (REVERT opcode), + // not a block-gas-limit signal — verified on anvil 1.6, which + // returns this message with `data: "0x"` (i.e. `Some`, not `None`). + let revert_data: Box = serde_json::from_str(r#""0x""#).unwrap(); + let err = rpc_error("execution reverted", Some(revert_data)); + assert!(estimate_fits(Err(err)).is_err()); + } + + /// A bare "out of gas" message is NOT a fits/doesn't-fit signal — it can + /// also be raised when the relayer's tx gas cap is too low or the + /// contract state consumes more gas than estimated. Treating it as + /// "doesn't fit" would mask those real misconfigurations behind retry + /// churn down the chunking path. + #[test] + fn estimate_fits_bare_out_of_gas_bubbles_up() { + let err = rpc_error("transaction reverted: out of gas", None); + assert!(estimate_fits(Err(err)).is_err()); + } + + #[test] + fn estimate_fits_unrelated_rpc_error_bubbles_up() { + let err = rpc_error("nonce too low", None); + assert!(estimate_fits(Err(err)).is_err()); } #[test] - fn estimate_fits_other_error_bubbles_up() { + fn estimate_fits_transport_layer_error_bubbles_up() { + // HTTP-level / connection failure — not a JSON-RPC ErrorResp; must + // not be classified as gas-exceeded. use alloy::transports::TransportErrorKind; - let transport_err = TransportErrorKind::custom_str("nonce too low"); - let contract_err = alloy::contract::Error::TransportError(transport_err); - assert!(estimate_fits(Err(contract_err)).is_err()); + let err = ContractError::TransportError(TransportErrorKind::custom_str( + "connection reset by peer", + )); + assert!(estimate_fits(Err(err)).is_err()); } } From a49bed6bffa07ba1aed308518323c74ca23ae7b2 Mon Sep 17 00:00:00 2001 From: deuszx Date: Sat, 16 May 2026 15:33:32 +0200 Subject: [PATCH 18/31] pending_burns_by_height_and_tx: unify event_indices snapshot Returns (height, event_indices, by_tx) per height under a single monitor read so process_pending_burns drives chunking and retry/cert persistence off one consistent snapshot. Previously the second view came from another read with a different (no max_retries) filter, risking TOCTOU drift between the two views. --- linera-bridge/src/monitor/linera.rs | 18 +++------ linera-bridge/src/monitor/mod.rs | 60 ++++++++++++++++++----------- 2 files changed, 44 insertions(+), 34 deletions(-) diff --git a/linera-bridge/src/monitor/linera.rs b/linera-bridge/src/monitor/linera.rs index e2ba827723d9..5d1828646151 100644 --- a/linera-bridge/src/monitor/linera.rs +++ b/linera-bridge/src/monitor/linera.rs @@ -85,18 +85,12 @@ pub(crate) async fn process_pending_burns = monitor - .read() - .await - .pending_burns() - .iter() - .filter(|t| t.value.height == height) - .map(|t| t.value.event_index) - .collect(); + for (height, event_indices_at_height, by_tx) in groups { + // `event_indices_at_height` are the stream-index values for every + // pending burn at this height under the same `max_retries` snapshot + // as `by_tx`. `mark_burn_retried` and `store_burn_raw` key on + // event_index; the per-tx chunking driven below uses + // (tx_index, pos_in_tx). let cert = match fetch_cert_at_height(linera_client, height).await { Ok(cert) => cert, diff --git a/linera-bridge/src/monitor/mod.rs b/linera-bridge/src/monitor/mod.rs index 53f1cce8f3a0..12ee912d7d94 100644 --- a/linera-bridge/src/monitor/mod.rs +++ b/linera-bridge/src/monitor/mod.rs @@ -106,6 +106,11 @@ impl Tracked { pub type TrackedDeposit = Tracked; pub type TrackedBurn = Tracked; +/// One height's slice of `pending_burns_by_height_and_tx`: the block height, +/// sorted `event_index` values at that height, and per-`tx_index` sorted +/// positions for the chunked `processBurns` fallback. +pub type PendingBurnsAtHeight = (BlockHeight, Vec, Vec<(u32, Vec)>); + /// In-memory monitoring state shared across scan loops and HTTP handlers. pub struct MonitorState { pub(crate) deposits: HashMap, @@ -234,36 +239,43 @@ impl MonitorState { self.burns.values().filter(|b| b.forwarded).collect() } - /// Returns pending burns grouped by `(height, tx_index)`. Outer Vec - /// is in ascending height order; inner Vec is in ascending tx-index - /// order; positions within each (height, tx) group are sorted by - /// `event_pos_in_tx`. Burns that have exceeded `max_retries` are - /// skipped. Used by `process_pending_burns` to batch all burns at a - /// height through `addBlock` first, then chunked `processBurns` per - /// (tx) group on no-fit. - pub fn pending_burns_by_height_and_tx( - &self, - max_retries: u32, - ) -> Vec<(BlockHeight, Vec<(u32, Vec)>)> { + /// Returns pending burns grouped by `(height, tx_index)`, plus the + /// sorted list of `event_index` values at each height. Outer Vec is in + /// ascending height order; inner Vec is in ascending tx-index order; + /// positions within each (height, tx) group are sorted by + /// `event_pos_in_tx`; the per-height event_indices are sorted ascending. + /// Burns that have exceeded `max_retries` are skipped. Both views derive + /// from the same snapshot under one read so `process_pending_burns` can + /// drive both the addBlock/processBurns chunking and retry/cert + /// persistence consistently. + pub fn pending_burns_by_height_and_tx(&self, max_retries: u32) -> Vec { use std::collections::BTreeMap; - let mut tree: BTreeMap>> = BTreeMap::new(); + #[derive(Default)] + struct HeightAccum { + by_tx: BTreeMap>, + event_indices: Vec, + } + let mut tree: BTreeMap = BTreeMap::new(); for tracked in self.pending_burns() { if tracked.retry_count >= max_retries { continue; } - tree.entry(tracked.value.height) - .or_default() + let entry = tree.entry(tracked.value.height).or_default(); + entry + .by_tx .entry(tracked.value.tx_index) .or_default() .push(tracked.value.event_pos_in_tx); - } - for by_tx in tree.values_mut() { - for positions in by_tx.values_mut() { - positions.sort_unstable(); - } + entry.event_indices.push(tracked.value.event_index); } tree.into_iter() - .map(|(h, by_tx)| (h, by_tx.into_iter().collect())) + .map(|(h, mut accum)| { + for positions in accum.by_tx.values_mut() { + positions.sort_unstable(); + } + accum.event_indices.sort_unstable(); + (h, accum.event_indices, accum.by_tx.into_iter().collect()) + }) .collect() } @@ -806,14 +818,18 @@ mod tests { } let groups = state.pending_burns_by_height_and_tx(/* max_retries */ 10); + // Same retry filter applies to both views — event_indices is the + // sorted list of `event_index` values at each height, used by + // `process_pending_burns` for retry accounting and cert persistence. assert_eq!( groups, vec![ ( BlockHeight(5), - vec![(0u32, vec![0u32, 1]), (1u32, vec![0u32]),] + vec![10u32, 11, 12], + vec![(0u32, vec![0u32, 1]), (1u32, vec![0u32])], ), - (BlockHeight(7), vec![(0u32, vec![0u32]),]), + (BlockHeight(7), vec![0u32], vec![(0u32, vec![0u32])]), ], ); } From 960f67927de49801d96b5916de707edb68a7a47b Mon Sep 17 00:00:00 2001 From: deuszx Date: Sat, 16 May 2026 15:34:14 +0200 Subject: [PATCH 19/31] MonitorState: add event_index_for_pos + filter failed in snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit event_index_for_pos maps a (height, tx_index, pos_in_tx) triple back to the event_index that mark_burn_retried/mark_burn_failed need — letting the chunked processBurns path bump retry per-chunk and mark oversized burns failed without a second snapshot read. pending_burns_by_height_and_tx now filters burns whose failed flag is set, so a once-marked-failed burn (next commit's oversized path) drops out of subsequent snapshots instead of poisoning the chunking loop on every pass. --- linera-bridge/src/monitor/mod.rs | 79 +++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/linera-bridge/src/monitor/mod.rs b/linera-bridge/src/monitor/mod.rs index 12ee912d7d94..87f28b16a876 100644 --- a/linera-bridge/src/monitor/mod.rs +++ b/linera-bridge/src/monitor/mod.rs @@ -257,7 +257,9 @@ impl MonitorState { } let mut tree: BTreeMap = BTreeMap::new(); for tracked in self.pending_burns() { - if tracked.retry_count >= max_retries { + // Failed burns (e.g. permanently oversized) and burns past the + // retry budget are no longer eligible for processing. + if tracked.failed || tracked.retry_count >= max_retries { continue; } let entry = tree.entry(tracked.value.height).or_default(); @@ -373,6 +375,24 @@ impl MonitorState { } } + /// Looks up the `event_index` of the pending burn at + /// `(height, tx_index, pos_in_tx)`. Used by `process_pending_burns` + /// to map per-chunk positions back to the stream-index keys that + /// `mark_burn_retried` / `mark_burn_failed` expect. + pub fn event_index_for_pos( + &self, + height: BlockHeight, + tx_index: u32, + pos_in_tx: u32, + ) -> Option { + self.burns.values().find_map(|b| { + (b.value.height == height + && b.value.tx_index == tx_index + && b.value.event_pos_in_tx == pos_in_tx) + .then_some(b.value.event_index) + }) + } + pub fn mark_burn_retried(&mut self, height: BlockHeight, event_index: u32) { if let Some(b) = self.burns.get_mut(&(height, event_index)) { b.retry_count += 1; @@ -833,4 +853,61 @@ mod tests { ], ); } + + #[tokio::test] + async fn pending_burns_by_height_and_tx_excludes_failed_burns() { + // After a burn is marked `failed` (e.g. oversized in the chunked + // `processBurns` path), it must not reappear in subsequent retry + // snapshots — otherwise the chunking loop would keep re-discovering + // it as oversized and burn estimate-RPC budget on every pass. + let mut state = MonitorState::new(0); + state + .track_burn(PendingBurn { + height: BlockHeight(5), + tx_index: 0, + event_pos_in_tx: 0, + event_index: 10, + evm_recipient: Address::ZERO, + amount: Amount::ZERO, + }) + .await; + state + .track_burn(PendingBurn { + height: BlockHeight(5), + tx_index: 0, + event_pos_in_tx: 1, + event_index: 11, + evm_recipient: Address::ZERO, + amount: Amount::ZERO, + }) + .await; + + state.mark_burn_failed(BlockHeight(5), 10).await; + + let groups = state.pending_burns_by_height_and_tx(/* max_retries */ 10); + assert_eq!( + groups, + vec![(BlockHeight(5), vec![11u32], vec![(0u32, vec![1u32])])], + ); + } + + #[tokio::test] + async fn event_index_for_pos_matches_tracked_burn() { + let mut state = MonitorState::new(0); + state + .track_burn(PendingBurn { + height: BlockHeight(5), + tx_index: 2, + event_pos_in_tx: 1, + event_index: 42, + evm_recipient: Address::ZERO, + amount: Amount::ZERO, + }) + .await; + + assert_eq!(state.event_index_for_pos(BlockHeight(5), 2, 1), Some(42)); + assert_eq!(state.event_index_for_pos(BlockHeight(5), 2, 0), None); + assert_eq!(state.event_index_for_pos(BlockHeight(5), 0, 1), None); + assert_eq!(state.event_index_for_pos(BlockHeight(6), 2, 1), None); + } } From fe5f60251b00fec6ff4472ed97d5119d6fccb8c3 Mon Sep 17 00:00:00 2001 From: deuszx Date: Sat, 16 May 2026 15:36:27 +0200 Subject: [PATCH 20/31] process_pending_burns: per-burn retry accounting; isolate oversized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retry budget previously bumped unconditionally for all burns at a height once per pass — a handful of transient RPC blips (cert fetch timeout, gas-estimate failure) could push burns past max_retries and silently strand them. Now: - Cert fetch / gas-estimate failures: skip the height, bump nothing. These are infrastructure-level, not burn-level. - addBlock submit Ok: no retry bump (completion is async). - addBlock submit Err: bump retry for every burn at the height. - Chunked processBurns submit Err: bump retry for that chunk's burns only; remaining chunks still attempt (each is an independent tx). A single-position chunk that still doesn't fit (oversized burn) used to break out of the per-tx loop, discarding sibling chunks already known to fit and burning every retry pass re-rediscovering it. Now: - Continue draining the stack so other slices still find chunks. - Mark the oversized burn so the next pending_burns_by_height snapshot excludes it — no more poisoning its tx group every pass. - Still submit the chunks that did fit before/around it. --- linera-bridge/src/monitor/linera.rs | 91 +++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 25 deletions(-) diff --git a/linera-bridge/src/monitor/linera.rs b/linera-bridge/src/monitor/linera.rs index 5d1828646151..c247a88fdcae 100644 --- a/linera-bridge/src/monitor/linera.rs +++ b/linera-bridge/src/monitor/linera.rs @@ -87,19 +87,19 @@ pub(crate) async fn process_pending_burns cert, Err(e) => { + // Cert fetch is an infrastructure-level failure (e.g. node + // sync). Skip the height — no burn was attempted, so don't + // consume any retry budget. tracing::warn!(?height, "Failed to read certificate: {e:#}"); - let mut state = monitor.write().await; - for ei in &event_indices_at_height { - state.mark_burn_retried(height, *ei); - } continue; } }; @@ -110,6 +110,9 @@ pub(crate) async fn process_pending_burns match evm_client.forward_cert(&cert).await { Ok(()) => { + // Completion is async via `check_burn_completion`, so + // we don't mark the burns completed here — and don't + // bump retry either, because the call succeeded. tracing::info!( ?height, count = event_indices_at_height.len(), @@ -118,11 +121,18 @@ pub(crate) async fn process_pending_burns { + // addBlock attempted every burn at this height — all of + // them just took a failed attempt, so bump retry once + // per burn. tracing::warn!(?height, "addBlock submission failed: {e:#}"); + let mut state = monitor.write().await; + for ei in &event_indices_at_height { + state.mark_burn_retried(height, *ei); + } } }, Ok(false) => { - 'tx_loop: for (tx_index, positions) in &by_tx { + for (tx_index, positions) in &by_tx { // Iterative LIFO split-to-fit per tx group. The // algorithm is inlined (rather than factored into a // pure helper that takes an async predicate) because @@ -130,7 +140,11 @@ pub(crate) async fn process_pending_burns> = vec![positions.clone()]; let mut chunks: Vec> = Vec::new(); - let mut single_too_large: Option = None; + // Positions that cannot fit even on their own — these + // get marked `failed` so they drop out of subsequent + // `pending_burns_by_height_and_tx` snapshots instead + // of poisoning their tx group forever. + let mut oversized: Vec = Vec::new(); while let Some(slice) = stack.pop() { let est = evm_client .estimate_process_burns_gas(&cert, *tx_index, &slice) @@ -141,23 +155,41 @@ pub(crate) async fn process_pending_burns = { + let state = monitor.read().await; + oversized + .iter() + .filter_map(|&pos| { + state.event_index_for_pos(height, *tx_index, pos) + }) + .collect() + }; + let mut state = monitor.write().await; + for ei in to_fail { + tracing::error!( + ?height, + tx_index, + event_index = ei, + "single burn does not fit under the EVM block gas limit; marking failed" + ); + state.mark_burn_failed(height, ei).await; + } } + // Pop-order yields input order because we push right then left. + // Each chunk is an independent processBurns tx, so a failure + // on one does not block the rest — only that chunk's burns + // consume retry budget. for chunk in chunks { if let Err(e) = evm_client.process_burns(&cert, *tx_index, &chunk).await { @@ -166,21 +198,30 @@ pub(crate) async fn process_pending_burns = { + let state = monitor.read().await; + chunk + .iter() + .filter_map(|&pos| { + state.event_index_for_pos(height, *tx_index, pos) + }) + .collect() + }; + let mut state = monitor.write().await; + for ei in to_bump { + state.mark_burn_retried(height, ei); + } } } } relay::update_balance_metrics(evm_client, linera_client).await; } Err(e) => { + // Like cert-fetch, estimate-gas is infrastructure-level — + // no burn was attempted, so don't bump retry. tracing::warn!(?height, "estimate_add_block_gas failed: {e:#}"); } } - - let mut state = monitor.write().await; - for ei in &event_indices_at_height { - state.mark_burn_retried(height, *ei); - } } } } From 467e6ead893e8d60bda28789d54c7a89a394429b Mon Sep 17 00:00:00 2001 From: deuszx Date: Sat, 16 May 2026 15:37:28 +0200 Subject: [PATCH 21/31] PendingBurnsAtHeight: tuple to struct; return BTreeSet ordered by height Tuple form (BlockHeight, Vec, Vec<(u32, Vec)>) was easy to misuse positionally. Named fields document each role. Ord keyed on height makes BTreeSet height-sorted structurally and rejects a second entry for the same height (which never happens in practice). --- linera-bridge/src/monitor/linera.rs | 7 +- linera-bridge/src/monitor/mod.rs | 100 +++++++++++++++++++--------- 2 files changed, 74 insertions(+), 33 deletions(-) diff --git a/linera-bridge/src/monitor/linera.rs b/linera-bridge/src/monitor/linera.rs index c247a88fdcae..aeb9b49dfbec 100644 --- a/linera-bridge/src/monitor/linera.rs +++ b/linera-bridge/src/monitor/linera.rs @@ -85,7 +85,12 @@ pub(crate) async fn process_pending_burns Tracked { pub type TrackedDeposit = Tracked; pub type TrackedBurn = Tracked; -/// One height's slice of `pending_burns_by_height_and_tx`: the block height, -/// sorted `event_index` values at that height, and per-`tx_index` sorted -/// positions for the chunked `processBurns` fallback. -pub type PendingBurnsAtHeight = (BlockHeight, Vec, Vec<(u32, Vec)>); +/// One height's slice of `pending_burns_by_height_and_tx`. The two views +/// (`event_indices` and `by_tx`) describe the same set of burns under one +/// retry-filter snapshot. +/// +/// `Ord` is keyed solely on `height`, so a `BTreeSet` +/// is naturally height-sorted and structurally rejects a second entry for +/// the same height (which never happens in practice — there is at most one +/// entry per height). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingBurnsAtHeight { + pub height: BlockHeight, + /// Stream indices (`Event.index`) of every pending burn at this height, + /// sorted ascending. Used for retry accounting and cert persistence. + pub event_indices: Vec, + /// Pending burns grouped by `tx_index`, in ascending `tx_index` order; + /// the `Vec` inside each entry is the sorted `event_pos_in_tx` + /// positions for that tx — input to the chunked `processBurns` + /// fallback when `addBlock` would not fit. + pub by_tx: Vec<(u32, Vec)>, +} + +impl Ord for PendingBurnsAtHeight { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.height.cmp(&other.height) + } +} + +impl PartialOrd for PendingBurnsAtHeight { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} /// In-memory monitoring state shared across scan loops and HTTP handlers. pub struct MonitorState { @@ -239,17 +267,13 @@ impl MonitorState { self.burns.values().filter(|b| b.forwarded).collect() } - /// Returns pending burns grouped by `(height, tx_index)`, plus the - /// sorted list of `event_index` values at each height. Outer Vec is in - /// ascending height order; inner Vec is in ascending tx-index order; - /// positions within each (height, tx) group are sorted by - /// `event_pos_in_tx`; the per-height event_indices are sorted ascending. - /// Burns that have exceeded `max_retries` are skipped. Both views derive - /// from the same snapshot under one read so `process_pending_burns` can - /// drive both the addBlock/processBurns chunking and retry/cert - /// persistence consistently. - pub fn pending_burns_by_height_and_tx(&self, max_retries: u32) -> Vec { - use std::collections::BTreeMap; + /// Returns one `PendingBurnsAtHeight` per height with pending burns, + /// in ascending height order. Burns are skipped if they are `failed` + /// (e.g. permanently oversized) or have exceeded `max_retries`. + pub fn pending_burns_by_height_and_tx( + &self, + max_retries: u32, + ) -> BTreeSet { #[derive(Default)] struct HeightAccum { by_tx: BTreeMap>, @@ -276,7 +300,11 @@ impl MonitorState { positions.sort_unstable(); } accum.event_indices.sort_unstable(); - (h, accum.event_indices, accum.by_tx.into_iter().collect()) + PendingBurnsAtHeight { + height: h, + event_indices: accum.event_indices, + by_tx: accum.by_tx.into_iter().collect(), + } }) .collect() } @@ -841,17 +869,21 @@ mod tests { // Same retry filter applies to both views — event_indices is the // sorted list of `event_index` values at each height, used by // `process_pending_burns` for retry accounting and cert persistence. - assert_eq!( - groups, - vec![ - ( - BlockHeight(5), - vec![10u32, 11, 12], - vec![(0u32, vec![0u32, 1]), (1u32, vec![0u32])], - ), - (BlockHeight(7), vec![0u32], vec![(0u32, vec![0u32])]), - ], - ); + let expected: BTreeSet = [ + PendingBurnsAtHeight { + height: BlockHeight(5), + event_indices: vec![10u32, 11, 12], + by_tx: vec![(0u32, vec![0u32, 1]), (1u32, vec![0u32])], + }, + PendingBurnsAtHeight { + height: BlockHeight(7), + event_indices: vec![0u32], + by_tx: vec![(0u32, vec![0u32])], + }, + ] + .into_iter() + .collect(); + assert_eq!(groups, expected); } #[tokio::test] @@ -885,10 +917,14 @@ mod tests { state.mark_burn_failed(BlockHeight(5), 10).await; let groups = state.pending_burns_by_height_and_tx(/* max_retries */ 10); - assert_eq!( - groups, - vec![(BlockHeight(5), vec![11u32], vec![(0u32, vec![1u32])])], - ); + let expected: BTreeSet = [PendingBurnsAtHeight { + height: BlockHeight(5), + event_indices: vec![11u32], + by_tx: vec![(0u32, vec![1u32])], + }] + .into_iter() + .collect(); + assert_eq!(groups, expected); } #[tokio::test] From 07547c5f03a75dfcd38b93281127b8a077a21b98 Mon Sep 17 00:00:00 2001 From: deuszx Date: Sat, 16 May 2026 15:39:20 +0200 Subject: [PATCH 22/31] Extract submit_addblock/submit_chunked/split_to_fit helpers process_pending_burns's per-height body collapsed to: cert fetch + persist + dispatch. Each branch of the dispatch now lives in its own helper: - submit_addblock: forward_cert + per-burn retry bump on failure - submit_chunked: per-tx loop, calls split + mark_oversized + submit - split_to_fit: pure binary-search chunking, returns (chunks, oversized) - mark_oversized_failed: looks up event_indices, marks each failed - submit_chunks_with_retry: per-chunk submit + per-chunk retry bump --- linera-bridge/src/monitor/linera.rs | 278 ++++++++++++++++------------ 1 file changed, 156 insertions(+), 122 deletions(-) diff --git a/linera-bridge/src/monitor/linera.rs b/linera-bridge/src/monitor/linera.rs index aeb9b49dfbec..f780b16f3ff3 100644 --- a/linera-bridge/src/monitor/linera.rs +++ b/linera-bridge/src/monitor/linera.rs @@ -87,150 +87,184 @@ pub(crate) async fn process_pending_burns cert, Err(e) => { - // Cert fetch is an infrastructure-level failure (e.g. node - // sync). Skip the height — no burn was attempted, so don't - // consume any retry budget. tracing::warn!(?height, "Failed to read certificate: {e:#}"); continue; } }; - persist_cert_bytes(monitor, height, &event_indices_at_height, &cert).await; + persist_cert_bytes(monitor, height, &event_indices, &cert).await; - // Dry-run addBlock. If it fits, one tx settles everything in the block. match estimate_fits(evm_client.estimate_add_block_gas(&cert).await) { - Ok(true) => match evm_client.forward_cert(&cert).await { - Ok(()) => { - // Completion is async via `check_burn_completion`, so - // we don't mark the burns completed here — and don't - // bump retry either, because the call succeeded. - tracing::info!( - ?height, - count = event_indices_at_height.len(), - "Burns forwarded via addBlock" - ); - relay::update_balance_metrics(evm_client, linera_client).await; - } - Err(e) => { - // addBlock attempted every burn at this height — all of - // them just took a failed attempt, so bump retry once - // per burn. - tracing::warn!(?height, "addBlock submission failed: {e:#}"); - let mut state = monitor.write().await; - for ei in &event_indices_at_height { - state.mark_burn_retried(height, *ei); - } - } - }, + Ok(true) => { + submit_addblock(monitor, evm_client, &cert, height, &event_indices).await; + relay::update_balance_metrics(evm_client, linera_client).await; + } Ok(false) => { - for (tx_index, positions) in &by_tx { - // Iterative LIFO split-to-fit per tx group. The - // algorithm is inlined (rather than factored into a - // pure helper that takes an async predicate) because - // an `AsyncFn` closure capturing `&EvmClient` across - // awaits trips an HRTB Send bound under `tokio::spawn`. - let mut stack: Vec> = vec![positions.clone()]; - let mut chunks: Vec> = Vec::new(); - // Positions that cannot fit even on their own — these - // get marked `failed` so they drop out of subsequent - // `pending_burns_by_height_and_tx` snapshots instead - // of poisoning their tx group forever. - let mut oversized: Vec = Vec::new(); - while let Some(slice) = stack.pop() { - let est = evm_client - .estimate_process_burns_gas(&cert, *tx_index, &slice) - .await; - let fits = estimate_fits(est).unwrap_or(false); - if fits { - chunks.push(slice); - continue; - } - if slice.len() == 1 { - oversized.push(slice[0]); - continue; - } - let mid = slice.len() / 2; - let (left, right) = slice.split_at(mid); - stack.push(right.to_vec()); - stack.push(left.to_vec()); - } - - if !oversized.is_empty() { - let to_fail: Vec = { - let state = monitor.read().await; - oversized - .iter() - .filter_map(|&pos| { - state.event_index_for_pos(height, *tx_index, pos) - }) - .collect() - }; - let mut state = monitor.write().await; - for ei in to_fail { - tracing::error!( - ?height, - tx_index, - event_index = ei, - "single burn does not fit under the EVM block gas limit; marking failed" - ); - state.mark_burn_failed(height, ei).await; - } - } - - // Pop-order yields input order because we push right then left. - // Each chunk is an independent processBurns tx, so a failure - // on one does not block the rest — only that chunk's burns - // consume retry budget. - for chunk in chunks { - if let Err(e) = evm_client.process_burns(&cert, *tx_index, &chunk).await - { - tracing::warn!( - tx_index, - ?chunk, - "processBurns submission failed: {e:#}" - ); - let to_bump: Vec = { - let state = monitor.read().await; - chunk - .iter() - .filter_map(|&pos| { - state.event_index_for_pos(height, *tx_index, pos) - }) - .collect() - }; - let mut state = monitor.write().await; - for ei in to_bump { - state.mark_burn_retried(height, ei); - } - } - } - } + submit_chunked(monitor, evm_client, &cert, height, &by_tx).await; relay::update_balance_metrics(evm_client, linera_client).await; } - Err(e) => { - // Like cert-fetch, estimate-gas is infrastructure-level — - // no burn was attempted, so don't bump retry. - tracing::warn!(?height, "estimate_add_block_gas failed: {e:#}"); + Err(error) => { + tracing::warn!(?height, ?error, "estimate_add_block_gas failed"); } } } } } +/// Submits the cert via `addBlock`. On success, leaves retry counts alone +/// (completion is observed asynchronously by `check_burn_completion`). On +/// failure, bumps retry once for every burn at the height — addBlock +/// attempted all of them. +async fn submit_addblock( + monitor: &RwLock, + evm_client: &EvmClient

, + cert: &linera_chain::types::ConfirmedBlockCertificate, + height: BlockHeight, + event_indices: &[u32], +) { + match evm_client.forward_cert(cert).await { + Ok(()) => { + tracing::info!( + ?height, + count = event_indices.len(), + "Burns forwarded via addBlock" + ); + } + Err(e) => { + tracing::warn!(?height, "addBlock submission failed: {e:#}"); + let mut state = monitor.write().await; + for ei in event_indices { + state.mark_burn_retried(height, *ei); + } + } + } +} + +/// Per-tx chunked fallback: split each tx group's positions to fit under +/// the block gas limit, mark any individually-oversized burn as `failed`, +/// then submit each fitting chunk as an independent `processBurns` tx. +async fn submit_chunked( + monitor: &RwLock, + evm_client: &EvmClient

, + cert: &linera_chain::types::ConfirmedBlockCertificate, + height: BlockHeight, + by_tx: &[(u32, Vec)], +) { + for (tx_index, positions) in by_tx { + let (chunks, oversized) = split_to_fit(evm_client, cert, *tx_index, positions).await; + mark_oversized_failed(monitor, height, *tx_index, &oversized).await; + submit_chunks_with_retry(monitor, evm_client, cert, height, *tx_index, chunks).await; + } +} + +/// Iterative LIFO binary search: keep halving slices that don't fit until +/// each one either fits as a chunk or shrinks to a single oversized +/// position. Inlined as a free fn rather than factored behind an async +/// predicate so it doesn't need an `AsyncFn` closure capturing +/// `&EvmClient` across awaits (which trips an HRTB Send bound under +/// `tokio::spawn`). +/// +/// Returns `(chunks, oversized)`. Chunks are in input order because each +/// split pushes right then left. +async fn split_to_fit( + evm_client: &EvmClient

, + cert: &linera_chain::types::ConfirmedBlockCertificate, + tx_index: u32, + positions: &[u32], +) -> (Vec>, Vec) { + let mut stack: Vec> = vec![positions.to_vec()]; + let mut chunks: Vec> = Vec::new(); + let mut oversized: Vec = Vec::new(); + while let Some(slice) = stack.pop() { + let est = evm_client + .estimate_process_burns_gas(cert, tx_index, &slice) + .await; + let fits = estimate_fits(est).unwrap_or(false); + if fits { + chunks.push(slice); + continue; + } + if slice.len() == 1 { + oversized.push(slice[0]); + continue; + } + let mid = slice.len() / 2; + let (left, right) = slice.split_at(mid); + stack.push(right.to_vec()); + stack.push(left.to_vec()); + } + (chunks, oversized) +} + +/// Marks oversized positions `failed` so they drop out of subsequent +/// `pending_burns_by_height_and_tx` snapshots instead of poisoning their +/// tx group on every retry pass. +async fn mark_oversized_failed( + monitor: &RwLock, + height: BlockHeight, + tx_index: u32, + oversized: &[u32], +) { + if oversized.is_empty() { + return; + } + let to_fail: Vec = { + let state = monitor.read().await; + oversized + .iter() + .filter_map(|&pos| state.event_index_for_pos(height, tx_index, pos)) + .collect() + }; + let mut state = monitor.write().await; + for ei in to_fail { + tracing::error!( + ?height, + tx_index, + event_index = ei, + "single burn does not fit under the EVM block gas limit; marking failed" + ); + state.mark_burn_failed(height, ei).await; + } +} + +/// Submits each chunk as its own `processBurns` tx. A chunk's failure only +/// consumes that chunk's retry budget — remaining chunks still attempt +/// submission, because each is independent on-chain. +async fn submit_chunks_with_retry( + monitor: &RwLock, + evm_client: &EvmClient

, + cert: &linera_chain::types::ConfirmedBlockCertificate, + height: BlockHeight, + tx_index: u32, + chunks: Vec>, +) { + for chunk in chunks { + if let Err(e) = evm_client.process_burns(cert, tx_index, &chunk).await { + tracing::warn!(tx_index, ?chunk, "processBurns submission failed: {e:#}"); + let to_bump: Vec = { + let state = monitor.read().await; + chunk + .iter() + .filter_map(|&pos| state.event_index_for_pos(height, tx_index, pos)) + .collect() + }; + let mut state = monitor.write().await; + for ei in to_bump { + state.mark_burn_retried(height, ei); + } + } + } +} + /// Walks the chain history backwards from the head until the certificate at /// `target_height` is found. async fn fetch_cert_at_height( From 9e88993c1b805fd0a3a82c3c972e90d7ef39490d Mon Sep 17 00:00:00 2001 From: deuszx Date: Mon, 18 May 2026 12:32:19 +0200 Subject: [PATCH 23/31] Clarify comments and renames --- linera-bridge/src/monitor/linera.rs | 19 +++++++++++++------ linera-bridge/src/monitor/mod.rs | 7 +++++-- linera-bridge/src/solidity/FungibleBridge.sol | 9 ++++----- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/linera-bridge/src/monitor/linera.rs b/linera-bridge/src/monitor/linera.rs index f780b16f3ff3..fdfb7f0a42cc 100644 --- a/linera-bridge/src/monitor/linera.rs +++ b/linera-bridge/src/monitor/linera.rs @@ -84,7 +84,6 @@ pub(crate) async fn process_pending_burns( cert: &linera_chain::types::ConfirmedBlockCertificate, height: BlockHeight, tx_index: u32, - chunks: Vec>, + events_chunks: Vec>, ) { - for chunk in chunks { - if let Err(e) = evm_client.process_burns(cert, tx_index, &chunk).await { - tracing::warn!(tx_index, ?chunk, "processBurns submission failed: {e:#}"); + for events_chunk in events_chunks { + if let Err(error) = evm_client + .process_burns(cert, tx_index, &events_chunk) + .await + { + tracing::warn!( + tx_index, + ?events_chunk, + ?error, + "processBurns submission failed" + ); let to_bump: Vec = { let state = monitor.read().await; - chunk + events_chunk .iter() .filter_map(|&pos| state.event_index_for_pos(height, tx_index, pos)) .collect() diff --git a/linera-bridge/src/monitor/mod.rs b/linera-bridge/src/monitor/mod.rs index d41d1417c3c1..66b30411fd4f 100644 --- a/linera-bridge/src/monitor/mod.rs +++ b/linera-bridge/src/monitor/mod.rs @@ -71,8 +71,11 @@ pub struct PendingBurn { /// Position of this burn within `body.events[tx_index]`. /// Used by `processBurns(cert, tx_index, [event_pos_in_tx, ...])`. pub event_pos_in_tx: u32, - /// `Event.index` — the stream index of this burn, unique within - /// `(stream, height)`. Off-chain and on-chain dedup key. + /// `Event.index` — sequential position of this burn within its stream + /// (the "burns" stream of the configured fungible app on the configured + /// Linera chain). Unique for the lifetime of that stream, so unique + /// across all heights within the relayer's scope. Off-chain and on-chain + /// dedup key. pub event_index: u32, pub evm_recipient: Address, pub amount: Amount, diff --git a/linera-bridge/src/solidity/FungibleBridge.sol b/linera-bridge/src/solidity/FungibleBridge.sol index 2767d3059dc1..86cf31a7837a 100644 --- a/linera-bridge/src/solidity/FungibleBridge.sol +++ b/linera-bridge/src/solidity/FungibleBridge.sol @@ -120,11 +120,10 @@ contract FungibleBridge is Microchain { /// this when `addBlock(cert)` would not fit in a single EVM tx, /// chunking burns per-tx-then-by-gas. /// - /// Idempotent like `_onBlock`: positions already in `processedBurns` - /// are skipped silently rather than reverted. Lets the relayer recover - /// from overlap with a prior `addBlock` (or a racing/retrying - /// `processBurns`) instead of losing the whole chunk to a single - /// duplicate. + /// Idempotent like `_onBlock`: positions already in `processedBurns` are + /// skipped silently rather than reverted. Lets the relayer recover from + /// overlap with a prior `addBlock` (or a racing/retrying `processBurns`) + /// instead of losing the whole chunk to a single duplicate. /// /// Reverts (atomically — no `processedBurns` flag is set if the call /// reverts) on: From 61ce1bef84936d240d32cef3365d473b40cc7419 Mon Sep 17 00:00:00 2001 From: deuszx Date: Mon, 18 May 2026 18:39:39 +0200 Subject: [PATCH 24/31] Add cert_hash to PendingBurnsAtHeight --- linera-bridge/src/monitor/db.rs | 30 ++++++++++++++-------- linera-bridge/src/monitor/linera.rs | 40 +++++++++-------------------- linera-bridge/src/monitor/mod.rs | 33 ++++++++++++++++++++++-- 3 files changed, 62 insertions(+), 41 deletions(-) diff --git a/linera-bridge/src/monitor/db.rs b/linera-bridge/src/monitor/db.rs index 1aec23d8d867..2062fb033947 100644 --- a/linera-bridge/src/monitor/db.rs +++ b/linera-bridge/src/monitor/db.rs @@ -100,6 +100,7 @@ impl BridgeDb { sqlx::query( "CREATE TABLE IF NOT EXISTS pending_burns ( linera_height INTEGER NOT NULL, + block_hash TEXT NOT NULL, tx_index INTEGER NOT NULL, event_pos_in_tx INTEGER NOT NULL, event_index INTEGER NOT NULL, @@ -116,6 +117,7 @@ impl BridgeDb { sqlx::query( "CREATE TABLE IF NOT EXISTS finished_burns ( linera_height INTEGER NOT NULL, + block_hash TEXT NOT NULL, tx_index INTEGER NOT NULL, event_pos_in_tx INTEGER NOT NULL, event_index INTEGER NOT NULL, @@ -222,10 +224,11 @@ impl BridgeDb { pub async fn insert_burn(&self, burn: &PendingBurn) -> Result<()> { sqlx::query( "INSERT OR IGNORE INTO pending_burns - (linera_height, tx_index, event_pos_in_tx, event_index, evm_recipient, amount) - VALUES (?, ?, ?, ?, ?, ?)", + (linera_height, block_hash, tx_index, event_pos_in_tx, event_index, evm_recipient, amount) + VALUES (?, ?, ?, ?, ?, ?, ?)", ) .bind(burn.height.0 as i64) + .bind(burn.block_hash.to_string()) .bind(burn.tx_index as i64) .bind(burn.event_pos_in_tx as i64) .bind(burn.event_index as i64) @@ -251,9 +254,9 @@ impl BridgeDb { let mut tx = self.pool.begin().await?; let inserted = sqlx::query( "INSERT OR IGNORE INTO finished_burns - (linera_height, tx_index, event_pos_in_tx, event_index, + (linera_height, block_hash, tx_index, event_pos_in_tx, event_index, evm_recipient, amount, raw_cert, status, created_at) - SELECT linera_height, tx_index, event_pos_in_tx, event_index, + SELECT linera_height, block_hash, tx_index, event_pos_in_tx, event_index, evm_recipient, amount, raw_cert, ?, created_at FROM pending_burns WHERE linera_height = ? AND event_index = ?", @@ -328,7 +331,7 @@ impl BridgeDb { /// in-memory `MonitorState`. pub async fn load_pending_burns(&self) -> Result> { let rows = sqlx::query( - "SELECT linera_height, tx_index, event_pos_in_tx, event_index, evm_recipient, amount + "SELECT linera_height, block_hash, tx_index, event_pos_in_tx, event_index, evm_recipient, amount FROM pending_burns", ) .fetch_all(&self.pool) @@ -337,14 +340,18 @@ impl BridgeDb { let mut out = Vec::with_capacity(rows.len()); for row in rows { let height: i64 = row.get(0); - let tx_index: i64 = row.get(1); - let event_pos_in_tx: i64 = row.get(2); - let event_index: i64 = row.get(3); - let evm_recipient: String = row.get(4); - let amount: String = row.get(5); + let block_hash: String = row.get(1); + let tx_index: i64 = row.get(2); + let event_pos_in_tx: i64 = row.get(3); + let event_index: i64 = row.get(4); + let evm_recipient: String = row.get(5); + let amount: String = row.get(6); out.push(PendingBurn { height: BlockHeight(height as u64), + block_hash: block_hash + .parse() + .context("invalid block_hash in burns row")?, tx_index: tx_index as u32, event_pos_in_tx: event_pos_in_tx as u32, event_index: event_index as u32, @@ -393,7 +400,7 @@ mod tests { use std::sync::atomic::{AtomicU32, Ordering}; use alloy::primitives::{Address, B256, U256}; - use linera_base::data_types::Amount; + use linera_base::{crypto::CryptoHash, data_types::Amount}; use test_case::test_case; use super::*; @@ -433,6 +440,7 @@ mod tests { fn test_burn() -> PendingBurn { PendingBurn { height: BlockHeight(100), + block_hash: CryptoHash::from([0u8; 32]), tx_index: 0, event_pos_in_tx: 0, event_index: 0, diff --git a/linera-bridge/src/monitor/linera.rs b/linera-bridge/src/monitor/linera.rs index fdfb7f0a42cc..93241f5af654 100644 --- a/linera-bridge/src/monitor/linera.rs +++ b/linera-bridge/src/monitor/linera.rs @@ -86,16 +86,17 @@ pub(crate) async fn process_pending_burns cert, - Err(e) => { - tracing::warn!(?height, "Failed to read certificate: {e:#}"); + Err(error) => { + tracing::warn!(?height, ?block_hash, ?error, "Failed to read certificate"); continue; } }; @@ -272,27 +273,6 @@ async fn submit_chunks_with_retry( } } -/// Walks the chain history backwards from the head until the certificate at -/// `target_height` is found. -async fn fetch_cert_at_height( - linera_client: &LineraClient, - target_height: BlockHeight, -) -> anyhow::Result> { - linera_client.sync().await?; - let info = linera_client.chain_info().await?; - let mut hash = info.block_hash; - loop { - let Some(h) = hash else { - anyhow::bail!("Block at height {} not found", target_height); - }; - let c = linera_client.read_certificate(h).await?; - if c.block().header.height == target_height { - return Ok(c); - } - hash = c.block().header.previous_block_hash; - } -} - /// Stores BCS cert bytes for every pending burn at `height`. async fn persist_cert_bytes( monitor: &RwLock, @@ -339,17 +319,18 @@ async fn linera_scan_iteration( break; } hash = block.block().header.previous_block_hash; - blocks.push(block); + blocks.push((h, block)); } blocks.reverse(); let mut new_burns = Vec::new(); - for block in &blocks { + for (block_hash, block) in &blocks { let height = block.block().header.height; let burn_events = find_burn_events(&block.block().body.events, fungible_app_id); for (tx_index, event_pos_in_tx, event_index, burn_event) in burn_events { new_burns.push(( height, + *block_hash, tx_index, event_pos_in_tx, event_index, @@ -360,13 +341,16 @@ async fn linera_scan_iteration( } let mut tracked_any = false; - for (height, tx_index, event_pos_in_tx, event_index, recipient, amount) in &new_burns { - tracing::info!(?height, tx_index, event_pos_in_tx, event_index, %recipient, %amount, "Discovered burn"); + for (height, block_hash, tx_index, event_pos_in_tx, event_index, recipient, amount) in + &new_burns + { + tracing::info!(?height, ?block_hash, tx_index, event_pos_in_tx, event_index, %recipient, %amount, "Discovered burn"); let was_new = monitor .write() .await .track_burn(PendingBurn { height: *height, + block_hash: *block_hash, tx_index: *tx_index, event_pos_in_tx: *event_pos_in_tx, event_index: *event_index, diff --git a/linera-bridge/src/monitor/mod.rs b/linera-bridge/src/monitor/mod.rs index 66b30411fd4f..35981d875bc9 100644 --- a/linera-bridge/src/monitor/mod.rs +++ b/linera-bridge/src/monitor/mod.rs @@ -20,6 +20,7 @@ use std::{ use alloy::primitives::{Address, B256, U256}; use linera_base::{ + crypto::CryptoHash, data_types::{Amount, BlockHeight}, identifiers::ApplicationId, }; @@ -65,6 +66,10 @@ pub struct PendingDeposit { #[derive(Debug, Clone, serde::Serialize)] pub struct PendingBurn { pub height: BlockHeight, + /// Hash of the Linera block that produced this burn. Lets the relayer + /// fetch the certificate via a direct `linera_client.read_certificate` + /// call instead of walking the chain backwards from head. + pub block_hash: CryptoHash, /// Position of this burn's transaction within `body.events`. /// Used by `processBurns(cert, tx_index, ...)`. pub tx_index: u32, @@ -120,6 +125,9 @@ pub type TrackedBurn = Tracked; #[derive(Debug, Clone, PartialEq, Eq)] pub struct PendingBurnsAtHeight { pub height: BlockHeight, + /// Hash of the Linera block at `height` — lets `process_pending_burns` + /// pull the certificate via a direct `read_certificate` call. + pub block_hash: CryptoHash, /// Stream indices (`Event.index`) of every pending burn at this height, /// sorted ascending. Used for retry accounting and cert persistence. pub event_indices: Vec, @@ -277,8 +285,8 @@ impl MonitorState { &self, max_retries: u32, ) -> BTreeSet { - #[derive(Default)] struct HeightAccum { + block_hash: CryptoHash, by_tx: BTreeMap>, event_indices: Vec, } @@ -289,7 +297,13 @@ impl MonitorState { if tracked.failed || tracked.retry_count >= max_retries { continue; } - let entry = tree.entry(tracked.value.height).or_default(); + // All burns at a given height share the same block (cert) hash, + // so the first one populates it and the rest just append. + let entry = tree.entry(tracked.value.height).or_insert(HeightAccum { + block_hash: tracked.value.block_hash, + by_tx: BTreeMap::new(), + event_indices: Vec::new(), + }); entry .by_tx .entry(tracked.value.tx_index) @@ -305,6 +319,7 @@ impl MonitorState { accum.event_indices.sort_unstable(); PendingBurnsAtHeight { height: h, + block_hash: accum.block_hash, event_indices: accum.event_indices, by_tx: accum.by_tx.into_iter().collect(), } @@ -586,6 +601,7 @@ mod tests { state .track_burn(PendingBurn { height: BlockHeight(10), + block_hash: CryptoHash::from([0u8; 32]), tx_index: 0, event_pos_in_tx: 0, event_index: 0, @@ -625,6 +641,7 @@ mod tests { state .track_burn(PendingBurn { height: BlockHeight(5), + block_hash: CryptoHash::from([0u8; 32]), tx_index: 0, event_pos_in_tx: 0, event_index: 0, @@ -779,6 +796,7 @@ mod tests { .unwrap(); db.insert_burn(&PendingBurn { height: BlockHeight(99), + block_hash: CryptoHash::from([0u8; 32]), tx_index: 0, event_pos_in_tx: 0, event_index: 2, @@ -807,6 +825,7 @@ mod tests { state .track_burn(PendingBurn { height, + block_hash: CryptoHash::from([0u8; 32]), tx_index: 0, event_pos_in_tx: 0, event_index: 0, @@ -832,6 +851,7 @@ mod tests { // order so the helper's sort is tested); tx 1 has one burn. PendingBurn { height: BlockHeight(5), + block_hash: CryptoHash::from([0u8; 32]), tx_index: 0, event_pos_in_tx: 1, event_index: 11, @@ -840,6 +860,7 @@ mod tests { }, PendingBurn { height: BlockHeight(5), + block_hash: CryptoHash::from([0u8; 32]), tx_index: 0, event_pos_in_tx: 0, event_index: 10, @@ -848,6 +869,7 @@ mod tests { }, PendingBurn { height: BlockHeight(5), + block_hash: CryptoHash::from([0u8; 32]), tx_index: 1, event_pos_in_tx: 0, event_index: 12, @@ -857,6 +879,7 @@ mod tests { // One burn at a later height. PendingBurn { height: BlockHeight(7), + block_hash: CryptoHash::from([0u8; 32]), tx_index: 0, event_pos_in_tx: 0, event_index: 0, @@ -875,11 +898,13 @@ mod tests { let expected: BTreeSet = [ PendingBurnsAtHeight { height: BlockHeight(5), + block_hash: CryptoHash::from([0u8; 32]), event_indices: vec![10u32, 11, 12], by_tx: vec![(0u32, vec![0u32, 1]), (1u32, vec![0u32])], }, PendingBurnsAtHeight { height: BlockHeight(7), + block_hash: CryptoHash::from([0u8; 32]), event_indices: vec![0u32], by_tx: vec![(0u32, vec![0u32])], }, @@ -899,6 +924,7 @@ mod tests { state .track_burn(PendingBurn { height: BlockHeight(5), + block_hash: CryptoHash::from([0u8; 32]), tx_index: 0, event_pos_in_tx: 0, event_index: 10, @@ -909,6 +935,7 @@ mod tests { state .track_burn(PendingBurn { height: BlockHeight(5), + block_hash: CryptoHash::from([0u8; 32]), tx_index: 0, event_pos_in_tx: 1, event_index: 11, @@ -922,6 +949,7 @@ mod tests { let groups = state.pending_burns_by_height_and_tx(/* max_retries */ 10); let expected: BTreeSet = [PendingBurnsAtHeight { height: BlockHeight(5), + block_hash: CryptoHash::from([0u8; 32]), event_indices: vec![11u32], by_tx: vec![(0u32, vec![1u32])], }] @@ -936,6 +964,7 @@ mod tests { state .track_burn(PendingBurn { height: BlockHeight(5), + block_hash: CryptoHash::from([0u8; 32]), tx_index: 2, event_pos_in_tx: 1, event_index: 42, From 4c5f706adffc784de383dc4da30f555ffc2328e2 Mon Sep 17 00:00:00 2001 From: deuszx Date: Mon, 18 May 2026 18:40:00 +0200 Subject: [PATCH 25/31] Regenerate linera-bridge/evm-contracts Cargo.lock --- linera-bridge/contracts/evm-bridge/Cargo.lock | 3 +++ 1 file changed, 3 insertions(+) diff --git a/linera-bridge/contracts/evm-bridge/Cargo.lock b/linera-bridge/contracts/evm-bridge/Cargo.lock index c07d39ceef64..4bb3c30747be 100644 --- a/linera-bridge/contracts/evm-bridge/Cargo.lock +++ b/linera-bridge/contracts/evm-bridge/Cargo.lock @@ -3473,11 +3473,14 @@ dependencies = [ name = "linera-storage" version = "0.15.17" dependencies = [ + "anyhow", "async-trait", "bcs", "cfg-if", "cfg_aliases", + "clap", "futures", + "hex", "itertools 0.14.0", "linera-base", "linera-cache", From 8ac2aefa3a4ba7c55ecd84280c018dfc8f2c2e88 Mon Sep 17 00:00:00 2001 From: deuszx Date: Mon, 18 May 2026 20:17:40 +0200 Subject: [PATCH 26/31] Align tracing logging pattern --- linera-bridge/src/monitor/evm.rs | 12 ++++++------ linera-bridge/src/monitor/linera.rs | 18 ++++++++++-------- linera-bridge/src/monitor/mod.rs | 26 ++++++++++++++------------ linera-bridge/src/relay/committee.rs | 4 ++-- linera-bridge/src/relay/mod.rs | 4 ++-- 5 files changed, 34 insertions(+), 30 deletions(-) diff --git a/linera-bridge/src/monitor/evm.rs b/linera-bridge/src/monitor/evm.rs index 805ab0e167eb..d9fdbac7c7b9 100644 --- a/linera-bridge/src/monitor/evm.rs +++ b/linera-bridge/src/monitor/evm.rs @@ -94,8 +94,8 @@ pub(crate) async fn process_pending_deposits { - tracing::warn!(%tx_hash, "Proof generation failed: {e:#}"); + Err(error) => { + tracing::warn!(%tx_hash, ?error, "Proof generation failed"); } } @@ -163,8 +163,8 @@ async fn evm_scan_iteration( }; 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:#}"); + Err(error) => { + tracing::warn!(%tx_hash, ?error, "Failed to parse DepositInitiated log"); continue; } }; diff --git a/linera-bridge/src/monitor/linera.rs b/linera-bridge/src/monitor/linera.rs index 93241f5af654..31820f764002 100644 --- a/linera-bridge/src/monitor/linera.rs +++ b/linera-bridge/src/monitor/linera.rs @@ -139,8 +139,8 @@ async fn submit_addblock( "Burns forwarded via addBlock" ); } - Err(e) => { - tracing::warn!(?height, "addBlock submission failed: {e:#}"); + Err(error) => { + tracing::warn!(?height, ?error, "addBlock submission failed"); let mut state = monitor.write().await; for ei in event_indices { state.mark_burn_retried(height, *ei); @@ -283,12 +283,13 @@ async fn persist_cert_bytes( let cert_bytes = bcs::to_bytes(cert).expect("BCS-serialize cert"); let state = monitor.read().await; let Some(db) = state.db() else { return }; - for ei in event_indices { - if let Err(e) = db.store_burn_raw(height, *ei, &cert_bytes).await { + for event_index in event_indices { + if let Err(error) = db.store_burn_raw(height, *event_index, &cert_bytes).await { tracing::warn!( ?height, - event_index = ei, - "Failed to store burn raw bytes: {e:#}" + ?event_index, + ?error, + "Failed to store burn raw bytes" ); } } @@ -402,11 +403,12 @@ async fn check_burn_completion( .await; } Ok(false) => {} - Err(e) => { + Err(error) => { tracing::warn!( ?height, event_index, - "is_burn_processed query failed: {e:#}" + ?error, + "is_burn_processed query failed" ); } } diff --git a/linera-bridge/src/monitor/mod.rs b/linera-bridge/src/monitor/mod.rs index 35981d875bc9..60d7a7ce3cc8 100644 --- a/linera-bridge/src/monitor/mod.rs +++ b/linera-bridge/src/monitor/mod.rs @@ -188,8 +188,8 @@ impl MonitorState { 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:#}"); + if let Err(error) = db.insert_deposit(&pending).await { + tracing::warn!(?error, "Failed to persist deposit to SQLite"); } } e.insert(Tracked::new(pending)); @@ -204,8 +204,8 @@ impl MonitorState { 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:#}"); + if let Err(error) = db.update_deposit_status(key, "completed").await { + tracing::warn!(?key, ?error, "Failed to update deposit status in SQLite"); } } } else { @@ -222,8 +222,8 @@ impl MonitorState { 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:#}"); + if let Err(error) = db.insert_burn(&pending).await { + tracing::warn!(?error, "Failed to persist burn to SQLite"); } } e.insert(Tracked::new(pending)); @@ -238,14 +238,15 @@ impl MonitorState { b.forwarded = true; crate::relay::metrics::burn_completed(); if let Some(db) = &self.db { - if let Err(e) = db + if let Err(error) = db .update_burn_status(height, event_index, "completed") .await { tracing::warn!( ?height, event_index, - "Failed to update burn status in SQLite: {e:#}" + ?error, + "Failed to update burn status in SQLite" ); } } @@ -414,8 +415,8 @@ impl MonitorState { 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:#}"); + if let Err(error) = db.update_deposit_status(key, "failed").await { + tracing::warn!(?key, ?error, "Failed to update deposit status in SQLite"); } } } @@ -451,11 +452,12 @@ impl MonitorState { b.failed = true; crate::relay::metrics::burn_failed(); if let Some(db) = &self.db { - if let Err(e) = db.update_burn_status(height, event_index, "failed").await { + if let Err(error) = db.update_burn_status(height, event_index, "failed").await { tracing::warn!( ?height, event_index, - "Failed to update burn status in SQLite: {e:#}" + ?error, + "Failed to update burn status in SQLite" ); } } diff --git a/linera-bridge/src/relay/committee.rs b/linera-bridge/src/relay/committee.rs index 79ff4d503c29..f5f58178cf9e 100644 --- a/linera-bridge/src/relay/committee.rs +++ b/linera-bridge/src/relay/committee.rs @@ -69,8 +69,8 @@ where { let current_epoch = match evm_client.get_current_epoch().await { Ok(epoch) => epoch, - Err(e) => { - tracing::info!("LightClient not initialized yet, skipping catch-up: {e:#}"); + Err(error) => { + tracing::info!(?error, "LightClient not initialized yet, skipping catch-up"); return Ok(()); } }; diff --git a/linera-bridge/src/relay/mod.rs b/linera-bridge/src/relay/mod.rs index ab8d6421bfae..9bad1e7f901d 100644 --- a/linera-bridge/src/relay/mod.rs +++ b/linera-bridge/src/relay/mod.rs @@ -63,7 +63,7 @@ pub(crate) async fn update_evm_balance_metric( let wei: u128 = balance.to(); metrics::set_relayer_evm_balance(wei as f64); } - Err(e) => tracing::warn!("Failed to query EVM relayer balance: {e:#}"), + Err(error) => tracing::warn!(?error, "Failed to query EVM relayer balance"), } } @@ -72,7 +72,7 @@ pub(crate) async fn update_linera_balance_metric metrics::set_relayer_linera_balance(u128::from(balance) as f64), - Err(e) => tracing::warn!("Failed to query Linera chain balance: {e:#}"), + Err(error) => tracing::warn!(?error, "Failed to query Linera chain balance"), } } From 9a70db9483c04de918667ba0541441a534b7eb5b Mon Sep 17 00:00:00 2001 From: deuszx Date: Mon, 18 May 2026 20:36:43 +0200 Subject: [PATCH 27/31] Move is_gas_exceeded to where it's used --- linera-bridge/src/relay/evm.rs | 30 ++------------------------- linera-bridge/src/relay/settlement.rs | 27 +++++++++++++++++++++++- 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/linera-bridge/src/relay/evm.rs b/linera-bridge/src/relay/evm.rs index b1b199b04e9b..b4bdd2926e01 100644 --- a/linera-bridge/src/relay/evm.rs +++ b/linera-bridge/src/relay/evm.rs @@ -141,8 +141,8 @@ impl EvmClient

{ /// Dry-runs `addBlock(cert)` against the EVM to estimate the gas it /// would consume. `Ok(g)` means the call would fit under the node's /// current block gas limit (the value is the estimate); a gas-exceeded - /// RPC error indicates the call would not fit — see `is_gas_exceeded_error`. - /// Other RPC errors bubble up. + /// RPC error indicates the call would not fit. Other RPC errors bubble + /// up. Classification is done by `relay::settlement::estimate_fits`. pub async fn estimate_add_block_gas( &self, cert: &linera_chain::types::ConfirmedBlockCertificate, @@ -264,29 +264,3 @@ impl EvmClient

{ Ok(receipt.transaction_hash) } } - -/// Returns true if the error is a JSON-RPC error reporting that the call -/// would not fit under the node's block gas limit — i.e. the node refused to -/// estimate because the work required more gas than a single block can hold. -/// -/// Structurally pattern-matches `RpcError::ErrorResp` and substring-checks -/// the `ErrorPayload`'s `message` against node-specific wordings observed -/// from geth, erigon, alchemy, reth, and anvil (1.6, both calldata-too-big -/// and infinite-loop constructors). Transport-level failures (HTTP, -/// timeouts) and non-RPC errors return `false`. -/// -/// We deliberately do NOT match a bare `"out of gas"` — a regular tx can OOG -/// for reasons unrelated to the block gas limit (e.g. a too-low tx gas cap, or -/// contract state consuming more gas than estimated). Treating those as -/// "doesn't fit" would mask real misconfigurations behind retry churn down -/// the chunking path. -pub fn is_gas_exceeded_error(error: &alloy::contract::Error) -> bool { - let alloy::contract::Error::TransportError(transport_err) = error else { - return false; - }; - let Some(payload) = transport_err.as_error_resp() else { - return false; - }; - let msg = payload.message.to_lowercase(); - msg.contains("gas required exceeds") || msg.contains("exceeds block gas limit") -} diff --git a/linera-bridge/src/relay/settlement.rs b/linera-bridge/src/relay/settlement.rs index b3b4020e9f95..0852ee4d9973 100644 --- a/linera-bridge/src/relay/settlement.rs +++ b/linera-bridge/src/relay/settlement.rs @@ -13,7 +13,6 @@ /// `Ok(_)` means the node already accepted the estimate. A gas-exceeded /// RPC error means the call wouldn't fit. Other errors bubble up. pub fn estimate_fits(r: Result) -> anyhow::Result { - use crate::relay::evm::is_gas_exceeded_error; match r { Ok(_) => Ok(true), Err(e) if is_gas_exceeded_error(&e) => Ok(false), @@ -21,6 +20,32 @@ pub fn estimate_fits(r: Result) -> anyhow::Result bool { + let alloy::contract::Error::TransportError(transport_err) = error else { + return false; + }; + let Some(payload) = transport_err.as_error_resp() else { + return false; + }; + let msg = payload.message.to_lowercase(); + msg.contains("gas required exceeds") || msg.contains("exceeds block gas limit") +} + #[cfg(test)] mod tests { use alloy::{ From 16f8fd08a1297f77ad76d2e29ca9773a1a2714c4 Mon Sep 17 00:00:00 2001 From: deuszx Date: Mon, 18 May 2026 20:51:53 +0200 Subject: [PATCH 28/31] Assert that we created multiple blocks to relay Linera Burns --- .../tests/e2e/tests/multi_tx_burn_chunking.rs | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/linera-bridge/tests/e2e/tests/multi_tx_burn_chunking.rs b/linera-bridge/tests/e2e/tests/multi_tx_burn_chunking.rs index 22ae5a861155..18e0c8feeb42 100644 --- a/linera-bridge/tests/e2e/tests/multi_tx_burn_chunking.rs +++ b/linera-bridge/tests/e2e/tests/multi_tx_burn_chunking.rs @@ -18,7 +18,14 @@ use std::time::Duration; -use alloy::{primitives::U256, providers::ProviderBuilder, sol}; +use std::collections::HashSet; + +use alloy::{ + primitives::{B256, U256}, + providers::{Provider, ProviderBuilder}, + rpc::types::Filter, + sol, +}; use linera_base::{crypto::InMemorySigner, data_types::Amount, identifiers::AccountOwner}; use linera_bridge_e2e::{ compose_file_path, deploy_fungible_bridge, deploy_linera_token, fund_bridge_erc20, @@ -318,5 +325,35 @@ async fn relayer_falls_back_to_chunked_process_burns() -> anyhow::Result<()> { ); } + // The whole point of this test is the chunked-processBurns path, which + // splits the cert across multiple EVM transactions. Each chunk lands in + // its own EVM block. Verify by counting distinct block hashes among + // ERC-20 `Transfer` events emitted by the bridge contract: if `addBlock` + // had fit, all 8 transfers would share one EVM block. + // + // `Transfer(address,address,uint256)` topic0. + let transfer_sig = B256::from(alloy::primitives::keccak256( + "Transfer(address,address,uint256)", + )); + let bridge_topic = B256::left_padding_from(bridge_addr.as_slice()); + let transfer_logs = provider + .get_logs( + &Filter::new() + .address(erc20_addr) + .event_signature(transfer_sig) + .topic1(bridge_topic) + .from_block(0u64), + ) + .await?; + let transfer_blocks: HashSet = + transfer_logs.iter().filter_map(|l| l.block_hash).collect(); + assert!( + transfer_blocks.len() > 1, + "expected chunked processBurns path to span multiple EVM blocks; \ + saw {} distinct block(s) across {} bridge-originated Transfer logs", + transfer_blocks.len(), + transfer_logs.len(), + ); + Ok(()) } From df6e8a7596d0b1c6f78f6ec0d49806d901fb5149 Mon Sep 17 00:00:00 2001 From: deuszx Date: Tue, 19 May 2026 17:53:10 +0200 Subject: [PATCH 29/31] Mark deposits/burns as failed when they exhaust the retry limit --- linera-bridge/src/monitor/evm.rs | 6 ++++- linera-bridge/src/monitor/linera.rs | 30 ++++++++++++++++++---- linera-bridge/src/monitor/mod.rs | 39 +++++++++++++++++++++++------ 3 files changed, 62 insertions(+), 13 deletions(-) diff --git a/linera-bridge/src/monitor/evm.rs b/linera-bridge/src/monitor/evm.rs index d9fdbac7c7b9..fa6229d8bc8f 100644 --- a/linera-bridge/src/monitor/evm.rs +++ b/linera-bridge/src/monitor/evm.rs @@ -116,7 +116,11 @@ pub(crate) async fn process_pending_deposits { - submit_addblock(monitor, evm_client, &cert, height, &event_indices).await; + submit_addblock( + monitor, + evm_client, + &cert, + height, + &event_indices, + max_retries, + ) + .await; relay::update_balance_metrics(evm_client, linera_client).await; } Ok(false) => { - submit_chunked(monitor, evm_client, &cert, height, &by_tx).await; + submit_chunked(monitor, evm_client, &cert, height, &by_tx, max_retries).await; relay::update_balance_metrics(evm_client, linera_client).await; } Err(error) => { @@ -130,6 +138,7 @@ async fn submit_addblock( cert: &linera_chain::types::ConfirmedBlockCertificate, height: BlockHeight, event_indices: &[u32], + max_retries: u32, ) { match evm_client.forward_cert(cert).await { Ok(()) => { @@ -143,7 +152,7 @@ async fn submit_addblock( tracing::warn!(?height, ?error, "addBlock submission failed"); let mut state = monitor.write().await; for ei in event_indices { - state.mark_burn_retried(height, *ei); + state.mark_burn_retried(height, *ei, max_retries).await; } } } @@ -158,11 +167,21 @@ async fn submit_chunked( cert: &linera_chain::types::ConfirmedBlockCertificate, height: BlockHeight, by_tx: &[(u32, Vec)], + max_retries: u32, ) { for (tx_index, positions) in by_tx { let (chunks, oversized) = split_to_fit(evm_client, cert, *tx_index, positions).await; mark_oversized_failed(monitor, height, *tx_index, &oversized).await; - submit_chunks_with_retry(monitor, evm_client, cert, height, *tx_index, chunks).await; + submit_chunks_with_retry( + monitor, + evm_client, + cert, + height, + *tx_index, + chunks, + max_retries, + ) + .await; } } @@ -246,6 +265,7 @@ async fn submit_chunks_with_retry( height: BlockHeight, tx_index: u32, events_chunks: Vec>, + max_retries: u32, ) { for events_chunk in events_chunks { if let Err(error) = evm_client @@ -267,7 +287,7 @@ async fn submit_chunks_with_retry( }; let mut state = monitor.write().await; for ei in to_bump { - state.mark_burn_retried(height, ei); + state.mark_burn_retried(height, ei, max_retries).await; } } } diff --git a/linera-bridge/src/monitor/mod.rs b/linera-bridge/src/monitor/mod.rs index 60d7a7ce3cc8..ba050593a42d 100644 --- a/linera-bridge/src/monitor/mod.rs +++ b/linera-bridge/src/monitor/mod.rs @@ -403,10 +403,20 @@ impl MonitorState { Ok(()) } - pub fn mark_deposit_retried(&mut self, key: &DepositKey) { - if let Some(d) = self.deposits.get_mut(key) { + /// Bumps the deposit's retry counter; if the bump exhausts `max_retries`, + /// the deposit is marked `failed` (moved to `finished_deposits` in + /// SQLite) so it does not get loaded back as a pending item on the next + /// relayer start. + pub async fn mark_deposit_retried(&mut self, key: &DepositKey, max_retries: u32) { + let exhausted = if let Some(d) = self.deposits.get_mut(key) { d.retry_count += 1; d.last_retry_at = Some(Instant::now()); + d.retry_count >= max_retries + } else { + false + }; + if exhausted { + self.mark_deposit_failed(key).await; } } @@ -440,10 +450,25 @@ impl MonitorState { }) } - pub fn mark_burn_retried(&mut self, height: BlockHeight, event_index: u32) { - if let Some(b) = self.burns.get_mut(&(height, event_index)) { + /// Bumps the burn's retry counter; if the bump exhausts `max_retries`, + /// the burn is marked `failed` (moved to `finished_burns` in SQLite) so + /// it does not get loaded back as a pending item on the next relayer + /// start. + pub async fn mark_burn_retried( + &mut self, + height: BlockHeight, + event_index: u32, + max_retries: u32, + ) { + let exhausted = if let Some(b) = self.burns.get_mut(&(height, event_index)) { b.retry_count += 1; b.last_retry_at = Some(Instant::now()); + b.retry_count >= max_retries + } else { + false + }; + if exhausted { + self.mark_burn_failed(height, event_index).await; } } @@ -703,7 +728,7 @@ mod tests { assert_eq!(state.deposits_ready_for_retry(10).len(), 1); - state.mark_deposit_retried(&key); + state.mark_deposit_retried(&key, 10).await; assert_eq!(state.deposits_ready_for_retry(10).len(), 0); state.mark_deposit_failed(&key).await; @@ -736,7 +761,7 @@ mod tests { let next = state.next_deposit_for_retry(10); assert!(matches!(next, Some(p) if p.key == key)); - state.mark_deposit_retried(&key); + state.mark_deposit_retried(&key, 10).await; assert!(state.next_deposit_for_retry(10).is_none()); state.complete_deposit(&key).await; @@ -837,7 +862,7 @@ mod tests { .await; assert!(state.next_burn_for_retry(10).is_some()); - state.mark_burn_retried(height, 0); + state.mark_burn_retried(height, 0, 10).await; assert!(state.next_burn_for_retry(10).is_none()); // Once forwarded, the item is no longer offered for retry. From cef3519c4f9427f2763dfa6f7fcaf82101545d01 Mon Sep 17 00:00:00 2001 From: deuszx Date: Mon, 25 May 2026 16:31:58 +0200 Subject: [PATCH 30/31] Bump retry counter for blocks which fail the gas estimation as well --- linera-bridge/src/monitor/linera.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/linera-bridge/src/monitor/linera.rs b/linera-bridge/src/monitor/linera.rs index 92366ac32a55..afa6b51e1505 100644 --- a/linera-bridge/src/monitor/linera.rs +++ b/linera-bridge/src/monitor/linera.rs @@ -121,7 +121,17 @@ pub(crate) async fn process_pending_burns { + // Bumping the retry counter is critical: an `eth_estimateGas` + // revert is typically deterministic (the cert is invalid for + // the configured bridge), and without this the burn would be + // re-polled every scan interval forever. After `max_retries` + // the burns at this height transition to `failed` and stop + // being yielded by `pending_burns_by_height_and_tx`. tracing::warn!(?height, ?error, "estimate_add_block_gas failed"); + let mut state = monitor.write().await; + for ei in &event_indices { + state.mark_burn_retried(height, *ei, max_retries).await; + } } } } From de88969cac9c98b33ec2336e8022790fab8bc9ef Mon Sep 17 00:00:00 2001 From: deuszx Date: Mon, 25 May 2026 16:46:08 +0200 Subject: [PATCH 31/31] Address Andreas' comments --- linera-bridge/src/monitor/linera.rs | 20 ++++++++++--------- linera-bridge/src/monitor/mod.rs | 10 +++++++++- linera-bridge/src/relay/linera.rs | 6 +++--- .../e2e/tests/multiple_burns_same_block.rs | 4 ++-- 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/linera-bridge/src/monitor/linera.rs b/linera-bridge/src/monitor/linera.rs index afa6b51e1505..99f6a249520a 100644 --- a/linera-bridge/src/monitor/linera.rs +++ b/linera-bridge/src/monitor/linera.rs @@ -246,22 +246,22 @@ async fn mark_oversized_failed( if oversized.is_empty() { return; } - let to_fail: Vec = { + let to_fail = { let state = monitor.read().await; oversized .iter() .filter_map(|&pos| state.event_index_for_pos(height, tx_index, pos)) - .collect() + .collect::>() }; let mut state = monitor.write().await; - for ei in to_fail { + for event_index in to_fail { tracing::error!( ?height, tx_index, - event_index = ei, + event_index, "single burn does not fit under the EVM block gas limit; marking failed" ); - state.mark_burn_failed(height, ei).await; + state.mark_burn_failed(height, event_index).await; } } @@ -288,16 +288,18 @@ async fn submit_chunks_with_retry( ?error, "processBurns submission failed" ); - let to_bump: Vec = { + let to_bump = { let state = monitor.read().await; events_chunk .iter() .filter_map(|&pos| state.event_index_for_pos(height, tx_index, pos)) - .collect() + .collect::>() }; let mut state = monitor.write().await; - for ei in to_bump { - state.mark_burn_retried(height, ei, max_retries).await; + for event_index in to_bump { + state + .mark_burn_retried(height, event_index, max_retries) + .await; } } } diff --git a/linera-bridge/src/monitor/mod.rs b/linera-bridge/src/monitor/mod.rs index ba050593a42d..917ce4da4bd0 100644 --- a/linera-bridge/src/monitor/mod.rs +++ b/linera-bridge/src/monitor/mod.rs @@ -122,7 +122,7 @@ pub type TrackedBurn = Tracked; /// is naturally height-sorted and structurally rejects a second entry for /// the same height (which never happens in practice — there is at most one /// entry per height). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone)] pub struct PendingBurnsAtHeight { pub height: BlockHeight, /// Hash of the Linera block at `height` — lets `process_pending_burns` @@ -138,6 +138,14 @@ pub struct PendingBurnsAtHeight { pub by_tx: Vec<(u32, Vec)>, } +impl PartialEq for PendingBurnsAtHeight { + fn eq(&self, other: &Self) -> bool { + self.height == other.height + } +} + +impl Eq for PendingBurnsAtHeight {} + impl Ord for PendingBurnsAtHeight { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.height.cmp(&other.height) diff --git a/linera-bridge/src/relay/linera.rs b/linera-bridge/src/relay/linera.rs index 7c242b50fae7..1f67193e07fd 100644 --- a/linera-bridge/src/relay/linera.rs +++ b/linera-bridge/src/relay/linera.rs @@ -159,8 +159,8 @@ pub(crate) fn find_burn_events( fungible_app_id: ApplicationId, ) -> Vec<(u32, u32, u32, wrapped_fungible::BurnEvent)> { let mut result = Vec::new(); - for (i, tx_events) in events.iter().enumerate() { - for (j, event) in tx_events.iter().enumerate() { + for (tx_index, tx_events) in (0u32..).zip(events) { + for (event_pos, event) in (0u32..).zip(tx_events) { if event.stream_id.application_id != GenericApplicationId::User(fungible_app_id) { continue; } @@ -168,7 +168,7 @@ pub(crate) fn find_burn_events( continue; } if let Ok(burn) = bcs::from_bytes::(&event.value) { - result.push((i as u32, j as u32, event.index, burn)); + result.push((tx_index, event_pos, event.index, burn)); } } } diff --git a/linera-bridge/tests/e2e/tests/multiple_burns_same_block.rs b/linera-bridge/tests/e2e/tests/multiple_burns_same_block.rs index 75132e0e7f88..9e6a243be239 100644 --- a/linera-bridge/tests/e2e/tests/multiple_burns_same_block.rs +++ b/linera-bridge/tests/e2e/tests/multiple_burns_same_block.rs @@ -272,8 +272,8 @@ async fn relayer_processes_every_burn_in_one_block() -> anyhow::Result<()> { // Each occurrence in `recipients` is one expected transfer. A // recipient listed twice should accumulate two transfers, etc. - let mut expected_per_recipient: std::collections::BTreeMap = - std::collections::BTreeMap::new(); + let mut expected_per_recipient = + std::collections::BTreeMap::::new(); let one_burn = U256::from(BURN_AMOUNT_TOKENS) * U256::from(10u128.pow(18)); for recipient in &recipients { *expected_per_recipient