diff --git a/examples/wrapped-fungible/src/contract.rs b/examples/wrapped-fungible/src/contract.rs index 672f37adf0d5..d812d68a29b3 100644 --- a/examples/wrapped-fungible/src/contract.rs +++ b/examples/wrapped-fungible/src/contract.rs @@ -147,9 +147,17 @@ impl Contract for WrappedFungibleTokenContract { if is_bouncing { self.state.credit(source, amount).await; } else if let (true, AccountOwner::Address20(addr)) = (on_mint_chain, target) { + let source_chain_id = self + .runtime + .message_origin_chain_id() + .expect("Message::Credit must be executed in a message context"); self.runtime.emit( StreamName::from("burns"), &BurnEvent { + source: Account { + chain_id: source_chain_id, + owner: source, + }, target: addr, amount, }, diff --git a/linera-bridge/Cargo.toml b/linera-bridge/Cargo.toml index 1cc5c98c3e87..dd2776f47fe7 100644 --- a/linera-bridge/Cargo.toml +++ b/linera-bridge/Cargo.toml @@ -18,7 +18,6 @@ offchain = [ "dep:alloy", "dep:alloy-sol-types", "dep:async-trait", - "dep:bcs", "dep:linera-execution", "dep:op-alloy-network", "dep:thiserror", @@ -65,6 +64,7 @@ alloy-primitives.workspace = true alloy-rlp.workspace = true alloy-trie.workspace = true anyhow.workspace = true +bcs.workspace = true linera-base.workspace = true serde.workspace = true thiserror = { workspace = true, optional = true } @@ -81,7 +81,6 @@ alloy = { workspace = true, optional = true, default-features = false, features "reqwest-rustls-tls", ] } alloy-sol-types = { workspace = true, optional = true } -bcs = { workspace = true, optional = true } linera-execution = { workspace = true, optional = true } op-alloy-network = { workspace = true, optional = true } tokio = { workspace = true, optional = true, features = ["full"] } diff --git a/linera-bridge/contracts/evm-bridge/Cargo.lock b/linera-bridge/contracts/evm-bridge/Cargo.lock index b4dc04da02ac..71b382db2bfb 100644 --- a/linera-bridge/contracts/evm-bridge/Cargo.lock +++ b/linera-bridge/contracts/evm-bridge/Cargo.lock @@ -3278,6 +3278,7 @@ dependencies = [ "alloy-rlp", "alloy-trie", "anyhow", + "bcs", "linera-base", "serde", ] diff --git a/linera-bridge/contracts/evm-bridge/src/contract.rs b/linera-bridge/contracts/evm-bridge/src/contract.rs index dc53c69a5d5f..5797db55b51b 100644 --- a/linera-bridge/contracts/evm-bridge/src/contract.rs +++ b/linera-bridge/contracts/evm-bridge/src/contract.rs @@ -8,7 +8,7 @@ use evm_bridge::{ BridgeInstantiationArgument, BridgeOperation, BridgeParameters, DepositKey, EvmBridgeAbi, }; use fungible::Account; -use linera_bridge::proof; +use linera_bridge::proof::{self, RefundKey}; use linera_sdk::{ ethereum::{ContractEthereumClient, EthereumQueries}, linera_base_types::{ApplicationId, U128, WithContractAbi}, @@ -24,6 +24,7 @@ use wrapped_fungible::{WrappedFungibleOperation, WrappedFungibleTokenAbi}; #[view(context = ViewStorageContext)] pub struct BridgeState { pub processed_deposits: SetView<[u8; 32]>, + pub processed_refunds: SetView<[u8; 32]>, pub verified_block_hashes: SetView<[u8; 32]>, pub fungible_app_id: RegisterView>, pub bridge_contract_address: RegisterView>, @@ -127,6 +128,22 @@ impl Contract for EvmBridgeContract { .expect("SetRpcEndpoint requires an authenticated signer"); self.state.rpc_endpoint.set(rpc_endpoint); } + BridgeOperation::RefundBurn { + block_header_rlp, + receipt_rlp, + proof_nodes, + tx_index, + log_index, + } => { + self.process_refund( + &block_header_rlp, + &receipt_rlp, + &proof_nodes, + tx_index, + log_index, + ) + .await; + } } } @@ -184,9 +201,24 @@ impl EvmBridgeContract { let (block_hash, receipts_root) = proof::decode_block_header(block_header_rlp).expect("invalid block header RLP"); - // 1b. Finality check: when an endpoint is configured, verify the block hash - // is finalized. Uses cached result if a previous deposit from this block - // was already processed. + // 2. Replay protection — cheap; reject before the RPC finality call + // and MPT verification so a duplicate proof is rejected without + // cost. + let deposit_key = DepositKey::new(params.source_chain_id, block_hash, tx_index, log_index); + let deposit_hash = deposit_key.hash(); + assert!( + !self + .state + .processed_deposits + .contains(&deposit_hash) + .await + .expect("failed to check processed deposits"), + "deposit already processed" + ); + + // 3. Finality check: when an endpoint is configured, verify the block hash + // is finalized. Uses cached result if a previous deposit from this block + // was already processed. if self.state.rpc_endpoint.get().is_empty() { log::warn!("rpc_endpoint is empty — skipping block finality verification."); } else if !self @@ -199,7 +231,7 @@ impl EvmBridgeContract { self.verify_block_hash(block_hash.0).await; } - // 2. Verify receipt inclusion via MPT proof + // 4. Verify receipt inclusion via MPT proof let proof_bytes: Vec = proof_nodes .iter() .map(|n| Bytes::copy_from_slice(n)) @@ -207,7 +239,7 @@ impl EvmBridgeContract { proof::verify_receipt_inclusion(receipts_root, tx_index, receipt_rlp, &proof_bytes) .expect("receipt inclusion proof failed"); - // 3. Decode receipt logs and parse the deposit event + // 5. Decode receipt logs and parse the deposit event let logs = proof::decode_receipt_logs(receipt_rlp).expect("failed to decode receipt logs"); assert!( (log_index as usize) < logs.len(), @@ -223,7 +255,7 @@ impl EvmBridgeContract { let deposit = proof::parse_deposit_event(&logs[log_index as usize], bridge_contract) .expect("failed to parse DepositInitiated event"); - // 4. Validate deposit fields against bridge parameters + // 6. Validate deposit fields against bridge parameters assert_eq!( deposit.source_chain_id.as_limbs()[0], params.source_chain_id, @@ -235,30 +267,13 @@ impl EvmBridgeContract { "token address mismatch" ); - // 5. Replay protection - let deposit_key = DepositKey { - source_chain_id: params.source_chain_id, - block_hash, - tx_index, - log_index, - }; - let deposit_hash = deposit_key.hash(); - assert!( - !self - .state - .processed_deposits - .contains(&deposit_hash) - .await - .expect("failed to check processed deposits"), - "deposit already processed" - ); + // 7. Record the deposit as processed and cache the verified block + // hash so subsequent deposits from the same block skip the RPC + // finality check. self.state .processed_deposits .insert(&deposit_hash) .expect("failed to insert deposit hash"); - - // 5b. Cache the verified block hash so subsequent deposits from the same - // block skip the RPC finality check. if !self.state.rpc_endpoint.get().is_empty() { self.state .verified_block_hashes @@ -266,7 +281,7 @@ impl EvmBridgeContract { .expect("failed to cache verified block hash"); } - // 6. Convert deposit fields to Linera types and call Mint + // 8. Convert deposit fields to Linera types and call Mint let amount = U128( deposit .amount @@ -292,4 +307,102 @@ impl EvmBridgeContract { self.runtime .call_application(true, fungible_app_id, &mint_op); } + + async fn process_refund( + &mut self, + block_header_rlp: &[u8], + receipt_rlp: &[u8], + proof_nodes: &[Vec], + tx_index: u64, + log_index: u64, + ) { + let params = self.runtime.application_parameters(); + + // 1. Decode block header → (block_hash, receipts_root) + let (block_hash, receipts_root) = + proof::decode_block_header(block_header_rlp).expect("invalid block header RLP"); + + // 2. Replay protection — cheap; reject before the RPC finality call + // and MPT verification so a duplicate proof is rejected without + // cost. + let refund_key = RefundKey::new(params.source_chain_id, block_hash, tx_index, log_index); + let refund_hash = refund_key.hash(); + assert!( + !self + .state + .processed_refunds + .contains(&refund_hash) + .await + .expect("failed to check processed refunds"), + "refund already processed" + ); + + // 3. Finality check (cached when an earlier proof from the same block was processed). + if self.state.rpc_endpoint.get().is_empty() { + log::warn!("rpc_endpoint is empty — skipping block finality verification."); + } else if !self + .state + .verified_block_hashes + .contains(&block_hash.0) + .await + .expect("failed to check verified block hashes") + { + self.verify_block_hash(block_hash.0).await; + } + + // 4. Verify receipt inclusion via MPT proof + let proof_bytes: Vec = proof_nodes + .iter() + .map(|n| Bytes::copy_from_slice(n)) + .collect(); + proof::verify_receipt_inclusion(receipts_root, tx_index, receipt_rlp, &proof_bytes) + .expect("receipt inclusion proof failed"); + + // 5. Decode receipt logs and parse the BurnBlocked event + let logs = proof::decode_receipt_logs(receipt_rlp).expect("failed to decode receipt logs"); + assert!( + (log_index as usize) < logs.len(), + "log_index {} out of range (receipt has {} logs)", + log_index, + logs.len() + ); + let bridge_contract_bytes = + self.state.bridge_contract_address.get().expect( + "bridge contract address not registered — call RegisterFungibleBridge first", + ); + let bridge_contract = alloy_primitives::Address::from(bridge_contract_bytes); + let fields = proof::parse_burn_blocked_event(&logs[log_index as usize], bridge_contract) + .expect("failed to parse BurnBlocked event"); + + // 6. Record the refund as processed and cache the verified block + // hash so subsequent proofs from the same block skip the RPC + // finality check. + self.state + .processed_refunds + .insert(&refund_hash) + .expect("failed to insert refund hash"); + if !self.state.rpc_endpoint.get().is_empty() { + self.state + .verified_block_hashes + .insert(&block_hash.0) + .expect("failed to cache verified block hash"); + } + + // 7. Mint refund to the original burner on the source Linera chain. + let mint_op = WrappedFungibleOperation::Mint { + target_account: Account { + chain_id: fields.source_chain_id, + owner: fields.source_owner, + }, + amount: U128(fields.amount.to_attos()), + }; + let fungible_app_id = self + .state + .fungible_app_id + .get() + .expect("fungible app not registered — call RegisterFungibleApp first") + .with_abi::(); + self.runtime + .call_application(true, fungible_app_id, &mint_op); + } } diff --git a/linera-bridge/contracts/evm-bridge/src/service.rs b/linera-bridge/contracts/evm-bridge/src/service.rs index 99497ae1871e..be91a00dfdf6 100644 --- a/linera-bridge/contracts/evm-bridge/src/service.rs +++ b/linera-bridge/contracts/evm-bridge/src/service.rs @@ -22,6 +22,7 @@ use linera_sdk::{ #[view(context = ViewStorageContext)] pub struct BridgeState { pub processed_deposits: SetView<[u8; 32]>, + pub processed_refunds: SetView<[u8; 32]>, pub verified_block_hashes: SetView<[u8; 32]>, pub fungible_app_id: RegisterView>, pub bridge_contract_address: RegisterView>, @@ -105,6 +106,22 @@ impl EvmBridgeService { .expect("failed to check processed deposits") } + /// Whether a refund with the given hash has been processed. + /// + /// The hash is the hex-encoded keccak-256 of the refund key + /// (see [`evm_bridge::RefundKey::hash`]). + async fn is_refund_processed(&self, hash: String) -> bool { + let bytes: [u8; 32] = hex::decode(hash.strip_prefix("0x").unwrap_or(&hash)) + .expect("invalid hex") + .try_into() + .expect("hash must be 32 bytes"); + self.state + .processed_refunds + .contains(&bytes) + .await + .expect("failed to check processed refunds") + } + /// Verifies that the given EVM block hash is finalized on the source chain. /// /// Makes the EVM JSON-RPC calls in the service runtime so that the contract diff --git a/linera-bridge/contracts/evm-bridge/tests/process_deposit.rs b/linera-bridge/contracts/evm-bridge/tests/process_deposit.rs index 5291c974af9b..612f6475b5f5 100644 --- a/linera-bridge/contracts/evm-bridge/tests/process_deposit.rs +++ b/linera-bridge/contracts/evm-bridge/tests/process_deposit.rs @@ -22,7 +22,9 @@ use linera_sdk::{ use serde::Deserialize; use wrapped_fungible::{WrappedFungibleTokenAbi, WrappedParameters}; -/// Helper to query an account balance on the wrapped-fungible app. +/// Queries an account balance on the wrapped-fungible app. The state stores +/// raw `U128` (no decimal scaling), so we return that directly rather than +/// re-parsing as `Amount` (which would multiply by 10^18). async fn query_balance( app_id: ApplicationId, chain: &ActiveChain, @@ -59,8 +61,12 @@ struct TestBridge { impl TestBridge { async fn setup() -> Self { - let (validator, bridge_module_id) = - TestValidator::with_current_module::().await; + let (validator, bridge_module_id) = TestValidator::with_current_module::< + EvmBridgeAbi, + BridgeParameters, + BridgeInstantiationArgument, + >() + .await; let mut chain = validator.new_chain().await; let chain_owner = AccountOwner::from(chain.public_key()); @@ -73,7 +79,12 @@ impl TestBridge { token_address, }; let bridge_app_id = chain - .create_application(bridge_module_id, bridge_params, BridgeInstantiationArgument::default(), vec![]) + .create_application( + bridge_module_id, + bridge_params, + BridgeInstantiationArgument::default(), + vec![], + ) .await; // 2. Deploy wrapped-fungible with the bridge's app ID @@ -198,8 +209,12 @@ impl TestBridge { /// Like `setup`, but skips the FungibleBridge address registration so callers /// can verify that `ProcessDeposit` panics when the address has not been set. async fn setup_without_bridge_address() -> Self { - let (validator, bridge_module_id) = - TestValidator::with_current_module::().await; + let (validator, bridge_module_id) = TestValidator::with_current_module::< + EvmBridgeAbi, + BridgeParameters, + BridgeInstantiationArgument, + >() + .await; let mut chain = validator.new_chain().await; let chain_owner = AccountOwner::from(chain.public_key()); @@ -211,7 +226,12 @@ impl TestBridge { token_address, }; let bridge_app_id = chain - .create_application(bridge_module_id, bridge_params, BridgeInstantiationArgument::default(), vec![]) + .create_application( + bridge_module_id, + bridge_params, + BridgeInstantiationArgument::default(), + vec![], + ) .await; let fungible_module_id = chain @@ -667,8 +687,12 @@ async fn test_replay_different_log_index_succeeds() { /// instantiation should fail because the chain ID check cannot succeed. #[tokio::test] async fn test_instantiation_fails_with_unreachable_endpoint() { - let (validator, bridge_module_id) = - TestValidator::with_current_module::().await; + let (validator, bridge_module_id) = TestValidator::with_current_module::< + EvmBridgeAbi, + BridgeParameters, + BridgeInstantiationArgument, + >() + .await; let mut chain = validator.new_chain().await; let token_address = [0xA0; 20]; @@ -680,7 +704,14 @@ async fn test_instantiation_fails_with_unreachable_endpoint() { token_address, }; let result = chain - .try_create_application(bridge_module_id, bridge_params, BridgeInstantiationArgument { rpc_endpoint: "http://localhost:8545".to_string() }, vec![]) + .try_create_application( + bridge_module_id, + bridge_params, + BridgeInstantiationArgument { + rpc_endpoint: "http://localhost:8545".to_string(), + }, + vec![], + ) .await; assert!( @@ -719,8 +750,12 @@ async fn setup_bridge_with_anvil( ApplicationId, ApplicationId, ) { - let (mut validator, bridge_module_id) = - TestValidator::with_current_module::().await; + let (mut validator, bridge_module_id) = TestValidator::with_current_module::< + EvmBridgeAbi, + BridgeParameters, + BridgeInstantiationArgument, + >() + .await; // Allow the contract to make HTTP requests to the Anvil host. validator @@ -744,7 +779,14 @@ async fn setup_bridge_with_anvil( token_address, }; let bridge_app_id = chain - .create_application(bridge_module_id, bridge_params, BridgeInstantiationArgument { rpc_endpoint: anvil_endpoint.to_string() }, vec![]) + .create_application( + bridge_module_id, + bridge_params, + BridgeInstantiationArgument { + rpc_endpoint: anvil_endpoint.to_string(), + }, + vec![], + ) .await; // 2. Deploy wrapped-fungible with bridge's app ID @@ -909,8 +951,12 @@ async fn test_process_deposit_rejects_when_bridge_address_unregistered() { #[tokio::test] async fn test_register_fungible_bridge_is_one_shot() { - let (validator, bridge_module_id) = - TestValidator::with_current_module::().await; + let (validator, bridge_module_id) = TestValidator::with_current_module::< + EvmBridgeAbi, + BridgeParameters, + BridgeInstantiationArgument, + >() + .await; let mut chain = validator.new_chain().await; let bridge_params = BridgeParameters { @@ -918,7 +964,12 @@ async fn test_register_fungible_bridge_is_one_shot() { token_address: [0xA0; 20], }; let bridge_app_id = chain - .create_application(bridge_module_id, bridge_params, BridgeInstantiationArgument::default(), vec![]) + .create_application( + bridge_module_id, + bridge_params, + BridgeInstantiationArgument::default(), + vec![], + ) .await; chain diff --git a/linera-bridge/contracts/evm-bridge/tests/refund_burn.rs b/linera-bridge/contracts/evm-bridge/tests/refund_burn.rs new file mode 100644 index 000000000000..338c82b29764 --- /dev/null +++ b/linera-bridge/contracts/evm-bridge/tests/refund_burn.rs @@ -0,0 +1,359 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for the EVM bridge app's RefundBurn operation. +//! +//! Mirrors the ProcessDeposit fixture: builds a one-tx EVM block whose +//! receipt contains a single BurnBlocked log, hands it to the bridge as +//! an MPT proof, and asserts that wrapped tokens are minted back to the +//! original burner on the source Linera chain. + +#![cfg(not(target_arch = "wasm32"))] + +use alloy_primitives::{Address, B256}; +use evm_bridge::{BridgeInstantiationArgument, BridgeOperation, BridgeParameters, EvmBridgeAbi}; +use fungible::{InitialState, InitialStateBuilder}; +use linera_bridge::proof::{ + burn_blocked_event_signature, deposit_event_signature, + testing::{ + build_burn_blocked_event_data, build_deposit_event_data, build_receipt_trie, + build_test_header, build_test_receipt, + }, + ReceiptLog, +}; +use linera_sdk::{ + bcs, + linera_base_types::{AccountOwner, ApplicationId, U128}, + test::{ActiveChain, QueryOutcome, TestValidator}, +}; +use wrapped_fungible::{WrappedFungibleTokenAbi, WrappedParameters}; + +/// Queries an account balance on the wrapped-fungible app. The wrapped-fungible +/// state stores raw `U128` (no decimal scaling), so we return that directly +/// rather than re-parsing as `Amount` (which would multiply by 10^18). +async fn query_balance( + app_id: ApplicationId, + chain: &ActiveChain, + owner: AccountOwner, +) -> Option { + use async_graphql::InputType; + + let query = format!( + "query {{ accounts {{ entry(key: {}) {{ value }} }} }}", + owner.to_value() + ); + let QueryOutcome { response, .. } = chain.graphql_query(app_id, query).await; + let balance = response["accounts"]["entry"]["value"].as_str()?; + Some(U128(balance.parse().expect("balance must be a u128"))) +} + +/// Fixture for refund tests: a registered bridge + fungible app pair on a +/// fresh chain, plus the test-only EVM identifiers and source-account fields +/// the refund flow encodes into the BurnBlocked event. +struct RefundFixture { + chain: ActiveChain, + bridge_app_id: ApplicationId, + fungible_app_id: ApplicationId, + bridge_contract: Address, + source_chain_b256: B256, + source_owner: AccountOwner, + source_owner_bcs: Vec, +} + +impl RefundFixture { + async fn setup() -> Self { + let (validator, bridge_module_id) = TestValidator::with_current_module::< + EvmBridgeAbi, + BridgeParameters, + BridgeInstantiationArgument, + >() + .await; + let mut chain = validator.new_chain().await; + let chain_owner = AccountOwner::from(chain.public_key()); + + let token_address = [0xA0; 20]; + let source_chain_id = 8453u64; + + let bridge_params = BridgeParameters { + source_chain_id, + token_address, + }; + let bridge_app_id = chain + .create_application( + bridge_module_id, + bridge_params, + BridgeInstantiationArgument::default(), + vec![], + ) + .await; + + let fungible_module_id = chain + .publish_bytecode_files_in::( + "../../../examples/wrapped-fungible", + ) + .await; + let wrapped_params = WrappedParameters { + ticker_symbol: "wUSDC".to_string(), + decimals: 6, + minter: Some(chain_owner), + mint_chain_id: Some(chain.id()), + evm_token_address: token_address, + evm_source_chain_id: source_chain_id, + bridge_app_id: Some(bridge_app_id.forget_abi()), + }; + let fungible_app_id = chain + .create_application( + fungible_module_id, + wrapped_params, + InitialStateBuilder::default().build(), + vec![], + ) + .await; + + chain + .add_block(|block| { + block.with_operation( + bridge_app_id, + BridgeOperation::RegisterFungibleApp { + app_id: fungible_app_id.forget_abi(), + }, + ); + }) + .await; + + let bridge_contract_bytes = [0xBB; 20]; + chain + .add_block(|block| { + block.with_operation( + bridge_app_id, + BridgeOperation::RegisterFungibleBridge { + address: bridge_contract_bytes, + }, + ); + }) + .await; + + // The BurnBlocked event credits the original burner, identified by their + // (source chain id, source owner) tuple. Mirror this chain's id and the + // chain-owner's account as that tuple so we can assert mint succeeded. + let chain_id_bytes: [u8; 32] = chain.id().0.into(); + let source_chain_b256 = B256::from(chain_id_bytes); + let source_owner = chain_owner; + let source_owner_bcs = bcs::to_bytes(&source_owner).expect("bcs encode source owner"); + + RefundFixture { + chain, + bridge_app_id, + fungible_app_id, + bridge_contract: Address::from(bridge_contract_bytes), + source_chain_b256, + source_owner, + source_owner_bcs, + } + } + + /// Builds a valid receipt + MPT proof for a BurnBlocked log carrying the + /// given amount. Returns `(block_header, receipt, proof_nodes, tx_index, log_index)`. + fn build_valid_refund(&self, amount: u128) -> (Vec, Vec, Vec>, u64, u64) { + let log = self.build_burn_blocked_log(amount); + self.build_refund_with_logs(&[log]) + } + + /// Builds a single valid BurnBlocked log with the fixture's source chain/owner. + fn build_burn_blocked_log(&self, amount: u128) -> ReceiptLog { + let event_data = + build_burn_blocked_event_data(self.source_chain_b256, &self.source_owner_bcs, amount); + ReceiptLog { + address: self.bridge_contract, + topics: vec![ + burn_blocked_event_signature(), + uint_topic(1, 8), // height + uint_topic(0, 4), // event_index + blocked_by_topic(Address::from([0xCC; 20])), // blocked_by + ], + data: event_data, + } + } + + /// Builds a receipt + MPT proof from the given logs. + fn build_refund_with_logs( + &self, + logs: &[ReceiptLog], + ) -> (Vec, Vec, Vec>, u64, u64) { + let receipt = build_test_receipt(logs); + let tx_index = 1u64; + let (receipts_root, proof_bytes) = + build_receipt_trie(&[(tx_index, receipt.clone())], tx_index); + let proof_nodes: Vec> = proof_bytes.into_iter().map(|b| b.to_vec()).collect(); + let block_header = build_test_header(receipts_root, 12345); + (block_header, receipt, proof_nodes, tx_index, 0) + } +} + +/// Builds an indexed-uint topic by left-padding a big-endian integer to 32 bytes. +fn uint_topic(value: u128, byte_len: usize) -> B256 { + let mut topic = [0u8; 32]; + let be = value.to_be_bytes(); + topic[32 - byte_len..32].copy_from_slice(&be[16 - byte_len..16]); + B256::from(topic) +} + +/// Builds a `blocked_by` topic (left-padded address in a 32-byte word). +fn blocked_by_topic(addr: Address) -> B256 { + let mut topic = [0u8; 32]; + topic[12..32].copy_from_slice(addr.as_slice()); + B256::from(topic) +} + +#[tokio::test] +async fn refund_burn_success_mints_to_source() { + let f = RefundFixture::setup().await; + let (block_header, receipt, proof_nodes, tx_index, log_index) = f.build_valid_refund(7_000_000); + + f.chain + .add_block(|block| { + block.with_operation( + f.bridge_app_id, + BridgeOperation::RefundBurn { + block_header_rlp: block_header, + receipt_rlp: receipt, + proof_nodes, + tx_index, + log_index, + }, + ); + }) + .await; + + assert_eq!( + query_balance(f.fungible_app_id, &f.chain, f.source_owner).await, + Some(U128(7_000_000u128)), + ); +} + +#[tokio::test] +async fn refund_burn_replay_rejected() { + let f = RefundFixture::setup().await; + let (block_header, receipt, proof_nodes, tx_index, log_index) = f.build_valid_refund(7_000_000); + + f.chain + .add_block(|block| { + block.with_operation( + f.bridge_app_id, + BridgeOperation::RefundBurn { + block_header_rlp: block_header.clone(), + receipt_rlp: receipt.clone(), + proof_nodes: proof_nodes.clone(), + tx_index, + log_index, + }, + ); + }) + .await; + + let result = f + .chain + .try_add_block(|block| { + block.with_operation( + f.bridge_app_id, + BridgeOperation::RefundBurn { + block_header_rlp: block_header, + receipt_rlp: receipt, + proof_nodes, + tx_index, + log_index, + }, + ); + }) + .await; + + assert!(result.is_err(), "replay refund should be rejected"); +} + +#[tokio::test] +async fn refund_burn_wrong_bridge_address() { + let f = RefundFixture::setup().await; + + // Same valid event data, but emit from the wrong contract address. + let event_data = + build_burn_blocked_event_data(f.source_chain_b256, &f.source_owner_bcs, 1_000_000); + let wrong_emitter = Address::from([0xCC; 20]); + let log = ReceiptLog { + address: wrong_emitter, + topics: vec![ + burn_blocked_event_signature(), + uint_topic(1, 8), + uint_topic(0, 4), + blocked_by_topic(Address::from([0xCC; 20])), + ], + data: event_data, + }; + let (block_header, receipt, proof_nodes, tx_index, log_index) = f.build_refund_with_logs(&[log]); + + let result = f + .chain + .try_add_block(|block| { + block.with_operation( + f.bridge_app_id, + BridgeOperation::RefundBurn { + block_header_rlp: block_header, + receipt_rlp: receipt, + proof_nodes, + tx_index, + log_index, + }, + ); + }) + .await; + + assert!( + result.is_err(), + "log from wrong emitter must be rejected by parse_burn_blocked_event" + ); +} + +#[tokio::test] +async fn refund_burn_wrong_topic() { + let f = RefundFixture::setup().await; + + // The proof points at a DepositInitiated log, not a BurnBlocked one — parse must reject. + let deposit_data = build_deposit_event_data( + 8453, + f.source_chain_b256, + B256::ZERO, + B256::from([0x33; 32]), + Address::from([0xA0; 20]), + 1_000_000, + 0, + ); + let depositor = Address::from([0xDD; 20]); + let mut depositor_topic_bytes = [0u8; 32]; + depositor_topic_bytes[12..32].copy_from_slice(depositor.as_slice()); + let log = ReceiptLog { + address: f.bridge_contract, + topics: vec![deposit_event_signature(), B256::from(depositor_topic_bytes)], + data: deposit_data, + }; + let (block_header, receipt, proof_nodes, tx_index, log_index) = f.build_refund_with_logs(&[log]); + + let result = f + .chain + .try_add_block(|block| { + block.with_operation( + f.bridge_app_id, + BridgeOperation::RefundBurn { + block_header_rlp: block_header, + receipt_rlp: receipt, + proof_nodes, + tx_index, + log_index, + }, + ); + }) + .await; + + assert!( + result.is_err(), + "DepositInitiated topic must be rejected by parse_burn_blocked_event" + ); +} diff --git a/linera-bridge/src/abi.rs b/linera-bridge/src/abi.rs index 120ffd63aacd..59eaf570b3fd 100644 --- a/linera-bridge/src/abi.rs +++ b/linera-bridge/src/abi.rs @@ -43,6 +43,14 @@ pub enum BridgeOperation { /// Replace the bridge's RPC endpoint. Chain-owner-only. /// Empty string disables finality verification. SetRpcEndpoint { rpc_endpoint: String }, + /// Verify a BurnBlocked proof and mint refund to the original burner. + RefundBurn { + block_header_rlp: Vec, + receipt_rlp: Vec, + proof_nodes: Vec>, + tx_index: u64, + log_index: u64, + }, } /// Initial state passed at `create_application`. diff --git a/linera-bridge/src/fungible_bridge.rs b/linera-bridge/src/fungible_bridge.rs index fea7ae148702..9592a4f4e522 100644 --- a/linera-bridge/src/fungible_bridge.rs +++ b/linera-bridge/src/fungible_bridge.rs @@ -9,11 +9,13 @@ mod tests { use linera_base::{ crypto::{CryptoHash, TestString, ValidatorSecretKey}, data_types::{BlockHeight, U128}, + identifiers::{AccountOwner, ChainId}, }; use revm::{ database::{CacheDB, EmptyDB}, primitives::Address, }; + use wrapped_fungible::Account; use crate::{evm::microchain::addBlockCall, test_helpers::*}; @@ -126,7 +128,16 @@ mod tests { target: [u8; 20], amount: U128, ) -> (Vec, u64) { - let evt = burn_event(self.app_id, target, amount, 0); + let evt = burn_event( + self.app_id, + Account { + chain_id: ChainId(self.chain_id), + owner: AccountOwner::CHAIN, + }, + target, + amount, + 0, + ); self.submit_block_with_events(vec![vec![evt]]) } @@ -229,7 +240,16 @@ mod tests { let mut t = TestBridge::new(); let other_app_id = CryptoHash::new(&TestString::new("other_app")); - let evt = burn_event(other_app_id, TEST_TARGET, U128(50u128 * 10u128.pow(18)), 0); + let evt = burn_event( + other_app_id, + Account { + chain_id: ChainId(t.chain_id), + owner: AccountOwner::CHAIN, + }, + TEST_TARGET, + U128(50u128 * 10u128.pow(18)), + 0, + ); t.submit_block_with_events(vec![vec![evt]]); assert_eq!( diff --git a/linera-bridge/src/monitor/db.rs b/linera-bridge/src/monitor/db.rs index dc99123c9e7c..5352495b04f7 100644 --- a/linera-bridge/src/monitor/db.rs +++ b/linera-bridge/src/monitor/db.rs @@ -17,14 +17,18 @@ use std::path::Path; use alloy::primitives::{Address, B256, U256}; use anyhow::{Context as _, Result}; -use linera_base::data_types::BlockHeight; +use linera_base::{ + crypto::CryptoHash, + data_types::{Amount, BlockHeight}, + identifiers::{Account, ChainId}, +}; use sqlx::{ sqlite::{SqliteConnectOptions, SqlitePoolOptions}, Row, SqlitePool, }; -use super::{PendingBurn, PendingDeposit}; -use crate::proof::DepositKey; +use super::{PendingBurn, PendingDeposit, PendingRefund}; +use crate::proof::{DepositKey, RefundKey}; /// Persistent SQLite store for bridging requests. pub struct BridgeDb { @@ -133,6 +137,40 @@ impl BridgeDb { .execute(&self.pool) .await?; + sqlx::query( + "CREATE TABLE IF NOT EXISTS pending_refunds ( + key BLOB PRIMARY KEY, + evm_tx_hash BLOB NOT NULL, + block_hash BLOB NOT NULL, + tx_index INTEGER NOT NULL, + log_index INTEGER NOT NULL, + source_chain_id BLOB NOT NULL, + source_owner_bcs BLOB NOT NULL, + amount_bcs BLOB NOT NULL, + retries INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending' + )", + ) + .execute(&self.pool) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS finished_refunds ( + key BLOB PRIMARY KEY, + evm_tx_hash BLOB NOT NULL, + block_hash BLOB NOT NULL, + tx_index INTEGER NOT NULL, + log_index INTEGER NOT NULL, + source_chain_id BLOB NOT NULL, + source_owner_bcs BLOB NOT NULL, + amount_bcs BLOB NOT NULL, + finished_at INTEGER NOT NULL, + status TEXT NOT NULL + )", + ) + .execute(&self.pool) + .await?; + Ok(()) } @@ -308,13 +346,13 @@ impl BridgeDb { let nonce: String = row.get(7); out.push(PendingDeposit { - key: DepositKey { - source_chain_id: source_chain_id as u64, - block_hash: B256::try_from(block_hash.as_slice()) + key: DepositKey::new( + source_chain_id as u64, + B256::try_from(block_hash.as_slice()) .context("invalid block_hash in deposits row")?, - tx_index: tx_index as u64, - log_index: log_index as u64, - }, + tx_index as u64, + log_index as u64, + ), tx_hash: B256::try_from(tx_hash.as_slice()) .context("invalid tx_hash in deposits row")?, depositor: Address::try_from(depositor.as_slice()) @@ -393,6 +431,148 @@ impl BridgeDb { .await?; Ok(()) } + + /// Inserts a new pending refund. Ignores duplicates (idempotent). + pub async fn store_pending_refund(&self, refund: &PendingRefund) -> Result<()> { + let key_hash = refund.key.hash(); + let source_chain_id_bytes = refund.source.chain_id.0.as_bytes().as_slice().to_vec(); + let source_owner_bcs = bcs::to_bytes(&refund.source.owner) + .context("failed to serialize refund source owner")?; + let amount_bcs = + bcs::to_bytes(&refund.amount).context("failed to serialize refund amount")?; + sqlx::query( + "INSERT OR IGNORE INTO pending_refunds + (key, evm_tx_hash, block_hash, tx_index, log_index, + source_chain_id, source_owner_bcs, amount_bcs) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(key_hash.as_slice()) + .bind(refund.evm_tx_hash.as_slice()) + .bind(refund.key.block_hash.as_slice()) + .bind(refund.key.tx_index as i64) + .bind(refund.key.log_index as i64) + .bind(source_chain_id_bytes) + .bind(source_owner_bcs) + .bind(amount_bcs) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Atomically moves a refund from `pending_refunds` to `finished_refunds` + /// with the given terminal `status` (`"completed"` or `"failed"`). + /// + /// If `finished_refunds` already has a row for this key (replay scenario), + /// the existing record is preserved and a warning is logged. The matching + /// pending row is still deleted so the refund does not stay queued. + async fn update_refund_status(&self, key: &RefundKey, status: &str) -> Result<()> { + let key_hash = key.hash(); + let finished_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let mut tx = self.pool.begin().await?; + let inserted = sqlx::query( + "INSERT OR IGNORE INTO finished_refunds + (key, evm_tx_hash, block_hash, tx_index, log_index, + source_chain_id, source_owner_bcs, amount_bcs, + finished_at, status) + SELECT key, evm_tx_hash, block_hash, tx_index, log_index, + source_chain_id, source_owner_bcs, amount_bcs, + ?, ? + FROM pending_refunds + WHERE key = ?", + ) + .bind(finished_at) + .bind(status) + .bind(key_hash.as_slice()) + .execute(&mut *tx) + .await? + .rows_affected(); + let deleted = sqlx::query("DELETE FROM pending_refunds WHERE key = ?") + .bind(key_hash.as_slice()) + .execute(&mut *tx) + .await? + .rows_affected(); + tx.commit().await?; + if deleted > 0 && inserted == 0 { + tracing::warn!( + ?key, + "Replay detected: refund already in finished_refunds, original record kept" + ); + } + Ok(()) + } + + /// Marks a refund completed and moves it to `finished_refunds`. + pub async fn mark_refund_completed(&self, key: &RefundKey) -> Result<()> { + self.update_refund_status(key, "completed").await + } + + /// Marks a refund failed and moves it to `finished_refunds`. + pub async fn mark_refund_failed(&self, key: &RefundKey) -> Result<()> { + self.update_refund_status(key, "failed").await + } + + /// Loads every pending refund, used at relay startup to repopulate the + /// in-memory `MonitorState`. `source_chain_id` is the EVM chain id of the + /// configured source — it is not stored per row because a relayer instance + /// only ever services a single source chain. + pub async fn load_pending_refunds(&self, source_chain_id: u64) -> Result> { + let rows = sqlx::query( + "SELECT evm_tx_hash, block_hash, tx_index, log_index, + source_chain_id, source_owner_bcs, amount_bcs + FROM pending_refunds", + ) + .fetch_all(&self.pool) + .await?; + + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + let evm_tx_hash: Vec = row.get(0); + let block_hash: Vec = row.get(1); + let tx_index: i64 = row.get(2); + let log_index: i64 = row.get(3); + let source_chain_id_bytes: Vec = row.get(4); + let source_owner_bcs: Vec = row.get(5); + let amount_bcs: Vec = row.get(6); + + let block_hash = B256::try_from(block_hash.as_slice()) + .context("invalid block_hash in refunds row")?; + let evm_tx_hash = B256::try_from(evm_tx_hash.as_slice()) + .context("invalid evm_tx_hash in refunds row")?; + let source_chain_id_inner = CryptoHash::try_from(source_chain_id_bytes.as_slice()) + .context("invalid source_chain_id in refunds row")?; + let owner = bcs::from_bytes(&source_owner_bcs) + .context("invalid source_owner_bcs in refunds row")?; + let amount: Amount = + bcs::from_bytes(&amount_bcs).context("invalid amount_bcs in refunds row")?; + + out.push(PendingRefund { + key: RefundKey::new( + source_chain_id, + block_hash, + tx_index as u64, + log_index as u64, + ), + evm_tx_hash, + source: Account { + chain_id: ChainId(source_chain_id_inner), + owner, + }, + amount, + }); + } + Ok(out) + } + + /// Returns the number of rows currently in `pending_refunds`. + pub async fn pending_refunds_count(&self) -> Result { + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM pending_refunds") + .fetch_one(&self.pool) + .await?; + Ok(count) + } } #[cfg(test)] @@ -419,12 +599,7 @@ mod tests { } fn test_deposit_key() -> DepositKey { - DepositKey { - source_chain_id: 8453, - block_hash: B256::from([0xAA; 32]), - tx_index: 5, - log_index: 0, - } + DepositKey::new(8453, B256::from([0xAA; 32]), 5, 0) } fn test_deposit() -> PendingDeposit { diff --git a/linera-bridge/src/monitor/evm.rs b/linera-bridge/src/monitor/evm.rs index fa6229d8bc8f..2d29893b913f 100644 --- a/linera-bridge/src/monitor/evm.rs +++ b/linera-bridge/src/monitor/evm.rs @@ -7,11 +7,12 @@ use std::{sync::Arc, time::Duration}; use alloy::providers::Provider; +use linera_base::identifiers::Account; use tokio::sync::{Notify, RwLock}; -use super::{MonitorState, PendingDeposit}; +use super::{MonitorState, PendingDeposit, PendingRefund}; use crate::{ - proof::{parse_deposit_event, DepositKey, ReceiptLog}, + proof::{parse_burn_blocked_event, parse_deposit_event, DepositKey, ReceiptLog, RefundKey}, relay::{self, evm::EvmClient, linera::LineraClient}, }; @@ -23,27 +24,35 @@ pub async fn evm_scan_loop( evm_client: Arc>, linera_client: Arc>, deposit_notify: Arc, + refund_notify: Arc, scan_interval: Duration, ) { loop { - let (scan_result, completion_result) = tokio::join!( - evm_scan_iteration(&monitor, &evm_client, &deposit_notify), + let (scan_result, deposit_completion, refund_completion) = tokio::join!( + evm_scan_iteration(&monitor, &evm_client, &deposit_notify, &refund_notify), check_deposit_completion(&monitor, &linera_client), + check_refund_completion(&monitor, &linera_client), ); if let Err(error) = scan_result { tracing::warn!(error = ?error, "EVM scan iteration failed"); } - if let Err(error) = completion_result { + if let Err(error) = deposit_completion { tracing::warn!(error = ?error, "Deposit completion check failed"); } + if let Err(error) = refund_completion { + tracing::warn!(error = ?error, "Refund completion check failed"); + } let summary = monitor.read().await.status_summary(); tracing::trace!( - pending = summary.deposits_pending, - completed = summary.deposits_completed, + deposits_pending = summary.deposits_pending, + deposits_completed = summary.deposits_completed, + refunds_pending = summary.refunds_pending, + refunds_completed = summary.refunds_completed, + refunds_failed = summary.refunds_failed, last_block = summary.last_scanned_evm_block, - "EVM deposit scan complete" + "EVM scan complete" ); tokio::time::sleep(scan_interval).await; @@ -127,7 +136,8 @@ pub(crate) async fn process_pending_deposits, evm_client: &EvmClient, - notify: &Notify, + deposit_notify: &Notify, + refund_notify: &Notify, ) -> anyhow::Result<()> { let last_block = monitor.read().await.last_scanned_evm_block; @@ -141,7 +151,8 @@ async fn evm_scan_iteration( .await?; let bridge_addr = evm_client.bridge_addr(); - let mut tracked_any = false; + let mut tracked_deposit = false; + let mut tracked_refund = false; for log in &logs { let block_hash = match log.block_hash { Some(h) => h, @@ -173,12 +184,12 @@ async fn evm_scan_iteration( } }; - let key = DepositKey { - source_chain_id: deposit.source_chain_id.to::(), + let key = DepositKey::new( + deposit.source_chain_id.to::(), block_hash, tx_index, log_index, - }; + ); let was_new = monitor .write() @@ -191,15 +202,70 @@ async fn evm_scan_iteration( nonce: deposit.nonce, }) .await; - tracked_any |= was_new; + tracked_deposit |= was_new; + } + + let burn_blocked_logs = evm_client + .get_burn_blocked_logs(last_block + 1, current_block) + .await?; + let source_chain_id = monitor.read().await.source_chain_id(); + for log in &burn_blocked_logs { + let block_hash = match log.block_hash { + Some(h) => h, + None => continue, + }; + let tx_hash = match log.transaction_hash { + Some(h) => h, + None => continue, + }; + let tx_index = match log.transaction_index { + Some(i) => i, + None => continue, + }; + let log_index = match log.log_index { + Some(i) => i, + None => continue, + }; + + let receipt_log = ReceiptLog { + address: log.address(), + topics: log.data().topics().to_vec(), + data: log.data().data.to_vec(), + }; + let parsed = match parse_burn_blocked_event(&receipt_log, bridge_addr) { + Ok(p) => p, + Err(error) => { + tracing::warn!(%tx_hash, ?error, "Failed to parse BurnBlocked log"); + continue; + } + }; + + let key = RefundKey::new(source_chain_id, block_hash, tx_index, log_index); + let was_new = monitor + .write() + .await + .track_refund(PendingRefund { + key, + evm_tx_hash: tx_hash, + source: Account { + chain_id: parsed.source_chain_id, + owner: parsed.source_owner, + }, + amount: parsed.amount, + }) + .await; + tracked_refund |= was_new; } let mut state = monitor.write().await; state.last_scanned_evm_block = current_block; crate::relay::metrics::set_last_scanned_evm_block(current_block); - if tracked_any { - notify.notify_one(); + if tracked_deposit { + deposit_notify.notify_one(); + } + if tracked_refund { + refund_notify.notify_one(); } Ok(()) } @@ -225,3 +291,79 @@ async fn check_deposit_completion( Ok(()) } + +async fn check_refund_completion( + monitor: &RwLock, + linera_client: &LineraClient, +) -> anyhow::Result<()> { + let pending: Vec = { + let state = monitor.read().await; + state + .pending_refunds() + .into_iter() + .map(|r| r.value.key.clone()) + .collect() + }; + + for key in pending { + if linera_client.query_refund_processed(&key).await? { + monitor.write().await.complete_refund(&key).await; + } + } + + Ok(()) +} + +/// Drains `MonitorState.refunds` for items ready for retry, processing one at +/// a time. Mirrors [`process_pending_deposits`] but submits `RefundBurn` +/// operations built from `BurnBlocked` MPT proofs. +pub(crate) async fn process_pending_refunds( + monitor: &RwLock, + linera_client: &LineraClient, + proof_client: &crate::proof::gen::HttpDepositProofClient, + notify: &Notify, + poll_interval: Duration, + max_retries: u32, +) -> anyhow::Result<()> { + use crate::proof::gen::BurnBlockedProofClient as _; + + loop { + let pending = monitor.read().await.next_refund_for_retry(max_retries); + let Some(pending) = pending else { + tracing::trace!( + ?poll_interval, + "EVM refunds processor sleeping until notified or poll interval elapses" + ); + tokio::select! { + _ = notify.notified() => { + tracing::trace!("EVM refunds processor notified about new pending item"); + } + _ = tokio::time::sleep(poll_interval) => {} + } + continue; + }; + + let tx_hash = pending.evm_tx_hash; + tracing::info!(%tx_hash, "Processing pending refund..."); + match proof_client.generate_burn_blocked_proof(tx_hash).await { + Ok(proof) => match linera_client.process_refund(proof).await { + Ok(()) => { + tracing::info!(%tx_hash, "Refund processed successfully"); + relay::update_linera_balance_metric(linera_client).await; + } + Err(e) => { + tracing::warn!(%tx_hash, "Refund processing failed: {e}"); + } + }, + Err(error) => { + tracing::warn!(%tx_hash, ?error, "Burn-blocked proof generation failed"); + } + } + + monitor + .write() + .await + .mark_refund_retried(&pending.key, max_retries) + .await; + } +} diff --git a/linera-bridge/src/monitor/mod.rs b/linera-bridge/src/monitor/mod.rs index eccf93a369a5..a3a53b1163c7 100644 --- a/linera-bridge/src/monitor/mod.rs +++ b/linera-bridge/src/monitor/mod.rs @@ -22,13 +22,13 @@ use alloy::primitives::{Address, B256, U256}; use anyhow::Context as _; use linera_base::{ crypto::CryptoHash, - data_types::{BlockHeight, U128}, - identifiers::ApplicationId, + data_types::{Amount, BlockHeight, U128}, + identifiers::{Account, ApplicationId}, }; use linera_execution::{Query, QueryResponse}; use tokio::sync::RwLock; -use crate::proof::DepositKey; +use crate::proof::{DepositKey, RefundKey}; /// Queries the evm-bridge app to check whether a deposit has been processed on Linera. pub async fn query_deposit_processed( @@ -48,6 +48,24 @@ pub async fn query_deposit_processed( Ok(response["data"]["isDepositProcessed"].as_bool() == Some(true)) } +/// Queries the evm-bridge app to check whether a refund has been processed on Linera. +pub async fn query_refund_processed( + chain_client: &linera_core::client::ChainClient, + bridge_app_id: ApplicationId, + refund_key: &RefundKey, +) -> anyhow::Result { + let hash_hex = format!("0x{}", hex::encode(refund_key.hash())); + let gql = format!(r#"{{ isRefundProcessed(hash: "{hash_hex}") }}"#); + let query = Query::user_without_abi(bridge_app_id, &GqlRequest { query: gql })?; + let (outcome, _) = chain_client.query_application(query, None).await?; + let response_bytes = match outcome.response { + QueryResponse::User(bytes) => bytes, + other => anyhow::bail!("unexpected query response: {other:?}"), + }; + let response: serde_json::Value = serde_json::from_slice(&response_bytes)?; + Ok(response["data"]["isRefundProcessed"].as_bool() == Some(true)) +} + /// Queries the wrapped-fungible app for its declared source-ERC-20 decimals. pub async fn query_wrapped_fungible_decimals( chain_client: &linera_core::client::ChainClient, @@ -137,6 +155,18 @@ impl Tracked { pub type TrackedDeposit = Tracked; pub type TrackedBurn = Tracked; +pub type TrackedRefund = Tracked; + +/// A refund detected by the EVM scanner from a `BurnBlocked` log. The relayer +/// builds an MPT receipt proof of this log and submits a `RefundBurn` operation +/// on the bridge chain so the original burner is credited back on Linera. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PendingRefund { + pub key: RefundKey, + pub evm_tx_hash: B256, + pub source: Account, + pub amount: Amount, +} /// 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 @@ -186,22 +216,34 @@ impl PartialOrd for PendingBurnsAtHeight { pub struct MonitorState { pub(crate) deposits: HashMap, pub(crate) burns: HashMap<(BlockHeight, u32), TrackedBurn>, + pub(crate) refunds: HashMap, pub(crate) last_scanned_evm_block: u64, pub(crate) last_scanned_linera_height: BlockHeight, + /// EVM chain id of the source the relayer is connected to. Baked into + /// every `RefundKey` so refund dedup is domain-separated from deposits. + source_chain_id: u64, db: Option, } impl MonitorState { - pub fn new(start_evm_block: u64) -> Self { + pub fn new(start_evm_block: u64, source_chain_id: u64) -> Self { Self { deposits: HashMap::new(), burns: HashMap::new(), + refunds: HashMap::new(), last_scanned_evm_block: start_evm_block, last_scanned_linera_height: BlockHeight(0), + source_chain_id, db: None, } } + /// Returns the EVM source chain id this monitor was constructed with. + /// Refund-scan loops use it to build [`RefundKey`] values. + pub fn source_chain_id(&self) -> u64 { + self.source_chain_id + } + /// Sets the persistent SQLite database for write-through storage. pub fn set_db(&mut self, db: db::BridgeDb) { self.db = Some(db); @@ -418,8 +460,10 @@ impl MonitorState { }; let deposits = db.load_pending_deposits().await?; let burns = db.load_pending_burns().await?; + let refunds = db.load_pending_refunds(self.source_chain_id).await?; let n_deposits = deposits.len(); let n_burns = burns.len(); + let n_refunds = refunds.len(); for d in deposits { self.deposits.insert(d.key.clone(), Tracked::new(d)); } @@ -427,9 +471,13 @@ impl MonitorState { self.burns .insert((b.height, b.event_index), Tracked::new(b)); } + for r in refunds { + self.refunds.insert(r.key.clone(), Tracked::new(r)); + } tracing::info!( deposits = n_deposits, burns = n_burns, + refunds = n_refunds, "Recovered pending bridge requests from SQLite WAL" ); Ok(()) @@ -521,12 +569,116 @@ impl MonitorState { } } + /// Tracks a refund detected from a `BurnBlocked` log. Returns `true` if + /// this is a newly discovered refund. Mirrors `track_deposit`/`track_burn`: + /// the `Entry` API protects existing retry state. SQLite persistence is + /// wired in by the refund-DB task. + pub async fn track_refund(&mut self, pending: PendingRefund) -> bool { + match self.refunds.entry(pending.key.clone()) { + Entry::Occupied(_) => false, + Entry::Vacant(e) => { + if let Some(db) = &self.db { + if let Err(error) = db.store_pending_refund(&pending).await { + tracing::warn!(?error, "Failed to persist refund to SQLite"); + } + } + e.insert(Tracked::new(pending)); + crate::relay::metrics::refund_detected(); + true + } + } + } + + pub async fn complete_refund(&mut self, key: &RefundKey) { + if let Some(r) = self.refunds.get_mut(key) { + r.forwarded = true; + crate::relay::metrics::refund_completed(); + if let Some(db) = &self.db { + if let Err(error) = db.mark_refund_completed(key).await { + tracing::warn!(?key, ?error, "Failed to update refund status in SQLite"); + } + } + } else { + tracing::warn!(refund_id = ?key, "Attempted to complete unknown refund"); + } + } + + pub fn all_refunds(&self) -> Vec<&TrackedRefund> { + self.refunds.values().collect() + } + + pub fn pending_refunds(&self) -> Vec<&TrackedRefund> { + self.refunds.values().filter(|r| !r.forwarded).collect() + } + + pub fn completed_refunds(&self) -> Vec<&TrackedRefund> { + self.refunds.values().filter(|r| r.forwarded).collect() + } + + pub fn refunds_ready_for_retry(&self, max_retries: u32) -> Vec<&TrackedRefund> { + self.refunds + .values() + .filter(|r| { + !r.forwarded + && !r.failed + && retry_eligible(r.retry_count, r.last_retry_at, max_retries) + }) + .collect() + } + + /// Returns one pending refund whose backoff has elapsed, cloned so the + /// caller can drop the read lock before doing slow work. + pub fn next_refund_for_retry(&self, max_retries: u32) -> Option { + self.refunds + .values() + .find(|r| { + !r.forwarded + && !r.failed + && retry_eligible(r.retry_count, r.last_retry_at, max_retries) + }) + .map(|r| r.value.clone()) + } + + /// Bumps the refund's retry counter; if the bump exhausts `max_retries`, + /// the refund is marked `failed`. Mirrors `mark_burn_retried`. + pub async fn mark_refund_retried(&mut self, key: &RefundKey, max_retries: u32) { + let exhausted = if let Some(r) = self.refunds.get_mut(key) { + r.retry_count += 1; + r.last_retry_at = Some(Instant::now()); + r.retry_count >= max_retries + } else { + false + }; + if exhausted { + self.mark_refund_failed(key).await; + } + } + + pub async fn mark_refund_failed(&mut self, key: &RefundKey) { + if let Some(r) = self.refunds.get_mut(key) { + r.failed = true; + crate::relay::metrics::refund_failed(); + if let Some(db) = &self.db { + if let Err(error) = db.mark_refund_failed(key).await { + tracing::warn!(?key, ?error, "Failed to update refund status in SQLite"); + } + } + } + } + pub fn status_summary(&self) -> StatusSummary { StatusSummary { deposits_pending: self.deposits.values().filter(|d| !d.forwarded).count(), deposits_completed: self.deposits.values().filter(|d| d.forwarded).count(), burns_pending: self.burns.values().filter(|b| !b.forwarded).count(), burns_forwarded: self.burns.values().filter(|b| b.forwarded).count(), + refunds_pending: self + .refunds + .values() + .filter(|r| !r.forwarded && !r.failed) + .count(), + refunds_completed: self.refunds.values().filter(|r| r.forwarded).count(), + refunds_failed: self.refunds.values().filter(|r| r.failed).count(), last_scanned_evm_block: self.last_scanned_evm_block, last_scanned_linera_height: self.last_scanned_linera_height, } @@ -539,14 +691,18 @@ pub struct StatusSummary { pub deposits_completed: usize, pub burns_pending: usize, pub burns_forwarded: usize, + pub refunds_pending: usize, + pub refunds_completed: usize, + pub refunds_failed: usize, pub last_scanned_evm_block: u64, pub last_scanned_linera_height: BlockHeight, } -/// Runs the deposit and burn processing loops concurrently. Each loop reads -/// pending work from `MonitorState` (the SQLite WAL is the source of truth) -/// and is woken either by a `Notify` signal from the corresponding scanner or -/// by a periodic poll for items whose retry backoff has just elapsed. +/// Runs the deposit, burn, and refund processing loops concurrently. Each loop +/// reads pending work from `MonitorState` (the SQLite WAL is the source of +/// truth) and is woken either by a `Notify` signal from the corresponding +/// scanner or by a periodic poll for items whose retry backoff has just +/// elapsed. #[allow(clippy::too_many_arguments)] pub(crate) async fn retry_loop( monitor: Arc>, @@ -555,6 +711,7 @@ pub(crate) async fn retry_loop>, deposit_notify: Arc, burn_notify: Arc, + refund_notify: Arc, poll_interval: Duration, max_retries: u32, ) -> anyhow::Result<()> { @@ -565,6 +722,9 @@ pub(crate) async fn retry_loop result, + result = evm::process_pending_refunds( + &monitor, &linera_client, &proof_client, &refund_notify, poll_interval, max_retries, + ) => result, } } @@ -590,18 +750,17 @@ fn retry_eligible(retry_count: u32, last_retry_at: Option, max_retries: #[cfg(test)] mod tests { use alloy::primitives::{Address, B256, U256}; - use linera_base::data_types::BlockHeight; + use linera_base::{ + crypto::CryptoHash, + data_types::{Amount, BlockHeight}, + identifiers::{AccountOwner, ChainId}, + }; use super::*; #[test] fn test_deposit_key_hash_matches_evm_bridge() { - let key = DepositKey { - source_chain_id: 8453, - block_hash: B256::from([0xAA; 32]), - tx_index: 5, - log_index: 0, - }; + let key = DepositKey::new(8453, B256::from([0xAA; 32]), 5, 0); let hash = key.hash(); assert_eq!(hash, key.hash()); assert_ne!(hash, [0u8; 32]); @@ -609,31 +768,16 @@ mod tests { #[test] fn test_deposit_key_different_log_index_different_hash() { - let key1 = DepositKey { - source_chain_id: 8453, - block_hash: B256::from([0xAA; 32]), - tx_index: 5, - log_index: 0, - }; - let key2 = DepositKey { - source_chain_id: 8453, - block_hash: B256::from([0xAA; 32]), - tx_index: 5, - log_index: 1, - }; + let key1 = DepositKey::new(8453, B256::from([0xAA; 32]), 5, 0); + let key2 = DepositKey::new(8453, B256::from([0xAA; 32]), 5, 1); assert_ne!(key1.hash(), key2.hash()); } #[tokio::test] async fn test_monitor_state_track_and_complete() { - let mut state = MonitorState::new(0); + let mut state = MonitorState::new(0, 0); - let key = DepositKey { - source_chain_id: 8453, - block_hash: B256::from([0xAA; 32]), - tx_index: 1, - log_index: 0, - }; + let key = DepositKey::new(8453, B256::from([0xAA; 32]), 1, 0); state .track_deposit(PendingDeposit { key: key.clone(), @@ -655,7 +799,7 @@ mod tests { #[tokio::test] async fn test_monitor_state_track_and_forward_burn() { - let mut state = MonitorState::new(0); + let mut state = MonitorState::new(0, 0); state .track_burn(PendingBurn { @@ -680,14 +824,9 @@ mod tests { #[tokio::test] async fn test_status_summary() { - let mut state = MonitorState::new(100); + let mut state = MonitorState::new(100, 0); - let key = DepositKey { - source_chain_id: 1, - block_hash: B256::ZERO, - tx_index: 0, - log_index: 0, - }; + let key = DepositKey::new(1, B256::ZERO, 0, 0); state .track_deposit(PendingDeposit { key: key.clone(), @@ -709,11 +848,27 @@ mod tests { }) .await; + let refund_key = RefundKey::new(1, B256::ZERO, 0, 1); + state + .track_refund(PendingRefund { + key: refund_key.clone(), + evm_tx_hash: B256::ZERO, + source: Account { + chain_id: ChainId(CryptoHash::from([0u8; 32])), + owner: AccountOwner::CHAIN, + }, + amount: Amount::ZERO, + }) + .await; + let summary = state.status_summary(); assert_eq!(summary.deposits_pending, 1); assert_eq!(summary.deposits_completed, 0); assert_eq!(summary.burns_pending, 1); assert_eq!(summary.burns_forwarded, 0); + assert_eq!(summary.refunds_pending, 1); + assert_eq!(summary.refunds_completed, 0); + assert_eq!(summary.refunds_failed, 0); assert_eq!(summary.last_scanned_evm_block, 100); } @@ -741,13 +896,8 @@ mod tests { #[tokio::test] async fn test_deposits_ready_for_retry() { - let mut state = MonitorState::new(0); - let key = DepositKey { - source_chain_id: 1, - block_hash: B256::ZERO, - tx_index: 0, - log_index: 0, - }; + let mut state = MonitorState::new(0, 0); + let key = DepositKey::new(1, B256::ZERO, 0, 0); state .track_deposit(PendingDeposit { key: key.clone(), @@ -773,13 +923,8 @@ mod tests { /// that have been completed. #[tokio::test] async fn next_deposit_for_retry_returns_pending_then_respects_backoff() { - let mut state = MonitorState::new(0); - let key = DepositKey { - source_chain_id: 1, - block_hash: B256::ZERO, - tx_index: 0, - log_index: 0, - }; + let mut state = MonitorState::new(0, 0); + let key = DepositKey::new(1, B256::ZERO, 0, 0); state .track_deposit(PendingDeposit { key: key.clone(), @@ -807,18 +952,13 @@ mod tests { /// on its next poll regardless of backpressure. #[tokio::test] async fn scanner_writes_directly_to_state_so_processor_sees_them() { - let mut state = MonitorState::new(100); + let mut state = MonitorState::new(100, 0); // Simulate the scanner discovering many deposits in a single iteration. for i in 0..128u64 { state .track_deposit(PendingDeposit { - key: DepositKey { - source_chain_id: 1, - block_hash: B256::ZERO, - tx_index: i, - log_index: 0, - }, + key: DepositKey::new(1, B256::ZERO, i, 0), tx_hash: B256::ZERO, depositor: Address::ZERO, amount: U256::ZERO, @@ -838,12 +978,7 @@ mod tests { #[tokio::test] async fn load_from_db_recovers_pending_items_on_startup() { let db = db::BridgeDb::open_in_memory().await.unwrap(); - let key = DepositKey { - source_chain_id: 1, - block_hash: B256::from([0xAA; 32]), - tx_index: 7, - log_index: 0, - }; + let key = DepositKey::new(1, B256::from([0xAA; 32]), 7, 0); db.insert_deposit(&PendingDeposit { key: key.clone(), tx_hash: B256::from([0xBB; 32]), @@ -865,7 +1000,7 @@ mod tests { .await .unwrap(); - let mut state = MonitorState::new(0); + let mut state = MonitorState::new(0, 0); state.set_db(db); state.load_from_db().await.unwrap(); @@ -879,7 +1014,7 @@ mod tests { /// Same as the deposit version, but for the burn pipeline. #[tokio::test] async fn next_burn_for_retry_returns_pending_then_respects_backoff() { - let mut state = MonitorState::new(0); + let mut state = MonitorState::new(0, 0); let height = BlockHeight(101); state .track_burn(PendingBurn { @@ -904,7 +1039,7 @@ mod tests { #[tokio::test] async fn pending_burns_by_height_and_tx_groups_and_sorts() { - let mut state = MonitorState::new(0); + let mut state = MonitorState::new(0, 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. @@ -979,7 +1114,7 @@ mod tests { // `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); + let mut state = MonitorState::new(0, 0); state .track_burn(PendingBurn { height: BlockHeight(5), @@ -1019,7 +1154,7 @@ mod tests { #[tokio::test] async fn event_index_for_pos_matches_tracked_burn() { - let mut state = MonitorState::new(0); + let mut state = MonitorState::new(0, 0); state .track_burn(PendingBurn { height: BlockHeight(5), diff --git a/linera-bridge/src/proof/burn_blocked.rs b/linera-bridge/src/proof/burn_blocked.rs new file mode 100644 index 000000000000..a8545e056df9 --- /dev/null +++ b/linera-bridge/src/proof/burn_blocked.rs @@ -0,0 +1,458 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Parser for the `BurnBlocked(uint64,uint32,address,bytes32,bytes,uint128)` +//! event emitted by `FungibleBridge.blockBurn`. +//! +//! Off-chain relayers consume this event to build a Linera-side refund proof +//! without re-fetching the certificate. The Wasm bridge app then verifies the +//! MPT receipt proof and credits the refund. + +use alloy_primitives::{keccak256, Address, B256}; +use anyhow::{ensure, Result}; +use linera_base::{ + crypto::CryptoHash, + data_types::Amount, + identifiers::{AccountOwner, ChainId}, +}; + +use crate::proof::{KeyDomain, ReceiptLog}; + +/// Parsed `BurnBlocked` event data. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BurnBlockedFields { + pub height: u64, + pub event_index: u32, + pub blocked_by: Address, + pub source_chain_id: ChainId, + pub source_owner: AccountOwner, + pub amount: Amount, +} + +/// Replay-protection key for a refund. +/// +/// Distinct from [`super::DepositKey`] via the [`KeyDomain`] tag mixed into +/// [`RefundKey::hash`], so the two key spaces cannot collide even though +/// their field layouts happen to match. +#[derive( + Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize, +)] +pub struct RefundKey { + #[serde(skip, default = "KeyDomain::refund")] + domain: KeyDomain, + pub source_chain_id: u64, + pub block_hash: B256, + pub tx_index: u64, + pub log_index: u64, +} + +impl RefundKey { + pub fn new(source_chain_id: u64, block_hash: B256, tx_index: u64, log_index: u64) -> Self { + Self { + domain: KeyDomain::Refund, + source_chain_id, + block_hash, + tx_index, + log_index, + } + } + + /// Deterministic keccak-256 hash of the refund key fields. + pub fn hash(&self) -> [u8; 32] { + let mut data = [0u8; 57]; + data[0] = self.domain as u8; + data[1..9].copy_from_slice(&self.source_chain_id.to_le_bytes()); + data[9..41].copy_from_slice(self.block_hash.as_slice()); + data[41..49].copy_from_slice(&self.tx_index.to_le_bytes()); + data[49..57].copy_from_slice(&self.log_index.to_le_bytes()); + keccak256(data).0 + } +} + +/// Returns the keccak256 hash of the `BurnBlocked` event signature. +pub fn burn_blocked_event_signature() -> B256 { + keccak256(b"BurnBlocked(uint64,uint32,address,bytes32,bytes,uint128)") +} + +/// Parses a `BurnBlocked` event from a receipt log. +/// +/// Verifies that `topic[0]` matches the event signature, that the log was emitted +/// by the `expected_emitter` (bridge contract address), and ABI-decodes the data +/// fields. The `height`, `eventIndex`, and `blocked_by` fields are indexed +/// (stored in `topics[1..4]`); the remaining parameters are non-indexed and +/// encoded in the log data. +pub fn parse_burn_blocked_event( + log: &ReceiptLog, + expected_emitter: Address, +) -> Result { + ensure!( + log.address == expected_emitter, + "log emitter {:?} does not match expected bridge contract {:?}", + log.address, + expected_emitter + ); + ensure!( + log.topics.first() == Some(&burn_blocked_event_signature()), + "event topic does not match BurnBlocked signature" + ); + ensure!( + log.topics.len() == 4, + "expected exactly 4 topics (signature + 3 indexed fields), got {}", + log.topics.len() + ); + + // Indexed `height` (uint64) in topics[1], left-padded to 32 bytes. + let height_topic = log.topics[1]; + ensure!( + height_topic.as_slice()[..24] == [0u8; 24], + "invalid ABI encoding: height topic padding bytes (0..24) must be zero" + ); + let mut height_bytes = [0u8; 8]; + height_bytes.copy_from_slice(&height_topic.as_slice()[24..32]); + let height = u64::from_be_bytes(height_bytes); + + // Indexed `eventIndex` (uint32) in topics[2], left-padded to 32 bytes. + let event_index_topic = log.topics[2]; + ensure!( + event_index_topic.as_slice()[..28] == [0u8; 28], + "invalid ABI encoding: eventIndex topic padding bytes (0..28) must be zero" + ); + let mut event_index_bytes = [0u8; 4]; + event_index_bytes.copy_from_slice(&event_index_topic.as_slice()[28..32]); + let event_index = u32::from_be_bytes(event_index_bytes); + + // Indexed `blocked_by` (address) in topics[3], left-padded to 32 bytes. + let blocked_by_topic = log.topics[3]; + ensure!( + blocked_by_topic.as_slice()[..12] == [0u8; 12], + "invalid ABI encoding: blocked_by topic padding bytes (0..12) must be zero" + ); + let blocked_by = Address::from_slice(&blocked_by_topic.as_slice()[12..32]); + + // Non-indexed data: head is 3 words (96 bytes), then `bytes` tail. + let d = &log.data; + ensure!( + d.len() >= 96, + "expected at least 96 bytes of event data head, got {}", + d.len() + ); + + // Head[0..32]: source_chain_id (bytes32). + let mut source_chain_id_bytes = [0u8; 32]; + source_chain_id_bytes.copy_from_slice(&d[0..32]); + let source_chain_id = ChainId(CryptoHash::from(source_chain_id_bytes)); + + // Head[32..64]: offset to `source_owner_bcs` tail, MUST be 96 (0x60) — three head words. + let mut offset_bytes = [0u8; 32]; + offset_bytes.copy_from_slice(&d[32..64]); + ensure!( + offset_bytes[..24] == [0u8; 24], + "invalid ABI encoding: source_owner_bcs offset high bytes (0..24) must be zero" + ); + let mut offset_low = [0u8; 8]; + offset_low.copy_from_slice(&offset_bytes[24..32]); + let offset = u64::from_be_bytes(offset_low); + ensure!( + offset == 96, + "invalid ABI encoding: source_owner_bcs offset must be 96, got {offset}" + ); + + // Head[64..96]: amount (uint128), left-padded to 32 bytes. + ensure!( + d[64..80] == [0u8; 16], + "invalid ABI encoding: amount high bytes (64..80) must be zero" + ); + let mut amount_bytes = [0u8; 16]; + amount_bytes.copy_from_slice(&d[80..96]); + let amount = Amount::from_attos(u128::from_be_bytes(amount_bytes)); + + // Tail at offset 96: 32-byte length, then `length` bytes padded to a 32-byte boundary. + ensure!( + d.len() >= 128, + "expected at least 128 bytes of event data for bytes length, got {}", + d.len() + ); + ensure!( + d[96..120] == [0u8; 24], + "invalid ABI encoding: source_owner_bcs length high bytes (96..120) must be zero" + ); + let mut len_bytes = [0u8; 8]; + len_bytes.copy_from_slice(&d[120..128]); + let bcs_len = u64::from_be_bytes(len_bytes) as usize; + + let padded_len = bcs_len.div_ceil(32) * 32; + ensure!( + d.len() == 128 + padded_len, + "expected exactly {} bytes of event data (head + length + padded bytes), got {}", + 128 + padded_len, + d.len() + ); + let source_owner_bcs = &d[128..128 + bcs_len]; + + // Padding tail beyond the declared length must be zero. + ensure!( + d[128 + bcs_len..].iter().all(|b| *b == 0), + "invalid ABI encoding: source_owner_bcs trailing padding must be zero" + ); + + let source_owner = bcs::from_bytes::(source_owner_bcs) + .map_err(|e| anyhow::anyhow!("malformed source_owner_bcs: {e}"))?; + + Ok(BurnBlockedFields { + height, + event_index, + blocked_by, + source_chain_id, + source_owner, + amount, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::proof::testing::build_burn_blocked_event_data; + + /// Builds a `blocked_by` topic (left-padded address in a 32-byte word). + fn blocked_by_topic(addr: Address) -> B256 { + let mut topic = [0u8; 32]; + topic[12..32].copy_from_slice(addr.as_slice()); + B256::from(topic) + } + + /// Builds an indexed-uint topic by left-padding a big-endian integer to 32 bytes. + fn uint_topic(value: u128, byte_len: usize) -> B256 { + let mut topic = [0u8; 32]; + let be = value.to_be_bytes(); + topic[32 - byte_len..32].copy_from_slice(&be[16 - byte_len..16]); + B256::from(topic) + } + + /// A constant bridge address used in burn-blocked parsing tests. + const TEST_BRIDGE: Address = Address::new([0xFF; 20]); + + #[test] + fn test_parse_burn_blocked_event_valid() { + let source_chain_id = B256::from([0x11; 32]); + let source_owner_bcs = bcs::to_bytes(&AccountOwner::CHAIN).unwrap(); + let blocked_by = Address::from([0x55; 20]); + + let data = build_burn_blocked_event_data( + source_chain_id, + &source_owner_bcs, + 1_000_000_000_000_000_000u128, + ); + + let log = ReceiptLog { + address: TEST_BRIDGE, + topics: vec![ + burn_blocked_event_signature(), + uint_topic(42, 8), + uint_topic(5, 4), + blocked_by_topic(blocked_by), + ], + data, + }; + + let fields = parse_burn_blocked_event(&log, TEST_BRIDGE).unwrap(); + assert_eq!(fields.height, 42); + assert_eq!(fields.event_index, 5); + assert_eq!(fields.blocked_by, blocked_by); + assert_eq!( + fields.source_chain_id, + ChainId(CryptoHash::from(source_chain_id.0)) + ); + assert_eq!(fields.source_owner, AccountOwner::CHAIN); + assert_eq!(fields.amount.to_attos(), 1_000_000_000_000_000_000u128); + } + + #[test] + fn test_parse_burn_blocked_event_wrong_emitter() { + let source_owner_bcs = bcs::to_bytes(&AccountOwner::CHAIN).unwrap(); + let data = build_burn_blocked_event_data(B256::ZERO, &source_owner_bcs, 1); + let log = ReceiptLog { + address: Address::from([0xAA; 20]), + topics: vec![ + burn_blocked_event_signature(), + uint_topic(0, 8), + uint_topic(0, 4), + blocked_by_topic(Address::ZERO), + ], + data, + }; + assert!( + parse_burn_blocked_event(&log, TEST_BRIDGE).is_err(), + "should reject log from wrong emitter" + ); + } + + #[test] + fn test_parse_burn_blocked_event_wrong_topic() { + let log = ReceiptLog { + address: TEST_BRIDGE, + topics: vec![ + B256::from([0xFF; 32]), + uint_topic(0, 8), + uint_topic(0, 4), + blocked_by_topic(Address::ZERO), + ], + data: vec![0; 128], + }; + assert!(parse_burn_blocked_event(&log, TEST_BRIDGE).is_err()); + } + + #[test] + fn test_parse_burn_blocked_event_no_topics() { + let log = ReceiptLog { + address: TEST_BRIDGE, + topics: vec![], + data: vec![0; 128], + }; + assert!(parse_burn_blocked_event(&log, TEST_BRIDGE).is_err()); + } + + #[test] + fn test_parse_burn_blocked_event_missing_indexed_topics() { + let log = ReceiptLog { + address: TEST_BRIDGE, + topics: vec![burn_blocked_event_signature(), uint_topic(1, 8)], + data: vec![0; 128], + }; + assert!(parse_burn_blocked_event(&log, TEST_BRIDGE).is_err()); + } + + #[test] + fn test_parse_burn_blocked_event_extra_topic() { + let log = ReceiptLog { + address: TEST_BRIDGE, + topics: vec![ + burn_blocked_event_signature(), + uint_topic(0, 8), + uint_topic(0, 4), + blocked_by_topic(Address::ZERO), + B256::ZERO, // extra topic + ], + data: vec![0; 128], + }; + assert!(parse_burn_blocked_event(&log, TEST_BRIDGE).is_err()); + } + + #[test] + fn test_parse_burn_blocked_event_short_data() { + let log = ReceiptLog { + address: TEST_BRIDGE, + topics: vec![ + burn_blocked_event_signature(), + uint_topic(0, 8), + uint_topic(0, 4), + blocked_by_topic(Address::ZERO), + ], + data: vec![0; 50], + }; + assert!(parse_burn_blocked_event(&log, TEST_BRIDGE).is_err()); + } + + #[test] + fn test_parse_burn_blocked_event_wrong_offset() { + let source_owner_bcs = bcs::to_bytes(&AccountOwner::CHAIN).unwrap(); + let mut data = build_burn_blocked_event_data(B256::ZERO, &source_owner_bcs, 1); + // Corrupt the offset word: set last byte to a non-96 value. + data[63] = 0x40; + let log = ReceiptLog { + address: TEST_BRIDGE, + topics: vec![ + burn_blocked_event_signature(), + uint_topic(0, 8), + uint_topic(0, 4), + blocked_by_topic(Address::ZERO), + ], + data, + }; + assert!(parse_burn_blocked_event(&log, TEST_BRIDGE).is_err()); + } + + #[test] + fn test_parse_burn_blocked_event_nonzero_amount_padding() { + let source_owner_bcs = bcs::to_bytes(&AccountOwner::CHAIN).unwrap(); + let mut data = build_burn_blocked_event_data(B256::ZERO, &source_owner_bcs, 1); + // Bytes 64..80 are the high 16 bytes of the uint128 word and must be zero. + data[64] = 0xFF; + let log = ReceiptLog { + address: TEST_BRIDGE, + topics: vec![ + burn_blocked_event_signature(), + uint_topic(0, 8), + uint_topic(0, 4), + blocked_by_topic(Address::ZERO), + ], + data, + }; + assert!(parse_burn_blocked_event(&log, TEST_BRIDGE).is_err()); + } + + #[test] + fn test_parse_burn_blocked_event_nonzero_tail_padding() { + let source_owner_bcs = bcs::to_bytes(&AccountOwner::CHAIN).unwrap(); + let mut data = build_burn_blocked_event_data(B256::ZERO, &source_owner_bcs, 1); + // Corrupt the padding zero byte just past the declared bcs length. + let pad_start = 128 + source_owner_bcs.len(); + if pad_start < data.len() { + data[pad_start] = 0xFF; + let log = ReceiptLog { + address: TEST_BRIDGE, + topics: vec![ + burn_blocked_event_signature(), + uint_topic(0, 8), + uint_topic(0, 4), + blocked_by_topic(Address::ZERO), + ], + data, + }; + assert!(parse_burn_blocked_event(&log, TEST_BRIDGE).is_err()); + } + } + + #[test] + fn test_parse_burn_blocked_event_malformed_owner() { + let bogus_owner_bcs = vec![0xFFu8]; + let data = build_burn_blocked_event_data(B256::ZERO, &bogus_owner_bcs, 1); + let log = ReceiptLog { + address: TEST_BRIDGE, + topics: vec![ + burn_blocked_event_signature(), + uint_topic(0, 8), + uint_topic(0, 4), + blocked_by_topic(Address::ZERO), + ], + data, + }; + assert!(parse_burn_blocked_event(&log, TEST_BRIDGE).is_err()); + } + + #[test] + fn test_refund_key_hash_is_deterministic() { + let key = RefundKey::new(8453, B256::repeat_byte(0x11), 7, 3); + let h = key.hash(); + assert_eq!(h, key.hash(), "hash must be deterministic"); + assert_ne!( + h, + RefundKey::new(1, key.block_hash, key.tx_index, key.log_index).hash() + ); + } + + /// A `RefundKey` and `DepositKey` with identical field values must produce + /// distinct hashes thanks to [`KeyDomain`]. This guarantees the + /// `processed_deposits` and `processed_refunds` key spaces are disjoint + /// even if a future refactor stored them in a shared SetView. + #[test] + fn test_refund_and_deposit_key_hashes_are_domain_separated() { + let refund = RefundKey::new(8453, B256::repeat_byte(0x11), 7, 3); + let deposit = crate::proof::DepositKey::new( + refund.source_chain_id, + refund.block_hash, + refund.tx_index, + refund.log_index, + ); + assert_ne!(refund.hash(), deposit.hash()); + } +} diff --git a/linera-bridge/src/proof/gen.rs b/linera-bridge/src/proof/gen.rs index 4e0e8ff06c82..cc4b4625e9a6 100644 --- a/linera-bridge/src/proof/gen.rs +++ b/linera-bridge/src/proof/gen.rs @@ -53,6 +53,25 @@ pub struct DepositProof { pub log_indices: Vec, } +/// All data needed to submit a `RefundBurn` operation to the evm-bridge app. +/// +/// Mirrors [`DepositProof`] but pins the single `BurnBlocked` log inside the +/// receipt via `log_index` — `blockBurn` emits exactly one such event per +/// transaction. +#[derive(Debug, Clone)] +pub struct BurnBlockedProof { + /// RLP-encoded block header (keccak256 of this = block hash). + pub block_header_rlp: Vec, + /// Canonical receipt bytes (EIP-2718 encoded, as stored in the receipts trie). + pub receipt_rlp: Vec, + /// MPT proof nodes for the receipt at `tx_index`. + pub proof_nodes: Vec>, + /// Transaction index within the block. + pub tx_index: u64, + /// Index of the single `BurnBlocked` log within the receipt. + pub log_index: u64, +} + /// Client interface for generating deposit proofs. /// /// Implementations query EVM chain data and construct the cryptographic @@ -66,6 +85,22 @@ pub trait DepositProofClient { async fn generate_deposit_proof(&self, tx_hash: B256) -> Result; } +/// Client interface for generating `BurnBlocked` refund proofs. +/// +/// Mirrors [`DepositProofClient`] but locates a single `BurnBlocked` log +/// emitted by `FungibleBridge.blockBurn`. +#[async_trait] +pub trait BurnBlockedProofClient { + /// Generate a complete burn-blocked refund proof from a transaction hash. + /// + /// The implementation fetches the receipt, locates the (exactly one) + /// `BurnBlocked` event log automatically, and constructs the MPT proof. + async fn generate_burn_blocked_proof( + &self, + tx_hash: B256, + ) -> Result; +} + /// HTTP-based deposit proof client that queries an EVM JSON-RPC endpoint. /// /// Works with any standard Ethereum-compatible RPC (including Base L2). @@ -200,6 +235,131 @@ impl DepositProofClient for HttpDepositProofClient { } } +#[async_trait] +impl BurnBlockedProofClient for HttpDepositProofClient { + async fn generate_burn_blocked_proof( + &self, + tx_hash: B256, + ) -> Result { + // 1. Get transaction receipt → block hash, tx index + let receipt = self + .provider + .get_transaction_receipt(tx_hash) + .await + .map_err(|e| ProofError::Transient(e.into()))? + .ok_or_else(|| { + ProofError::Transient(anyhow::anyhow!( + "transaction receipt not found for {tx_hash}" + )) + })?; + + let block_hash = receipt.inner.block_hash.ok_or_else(|| { + ProofError::Transient(anyhow::anyhow!("receipt missing block_hash (pending tx?)")) + })?; + let tx_index = receipt.inner.transaction_index.ok_or_else(|| { + ProofError::Transient(anyhow::anyhow!("receipt missing transaction_index")) + })?; + + // 2. Get full block → header RLP + let block = self + .provider + .get_block_by_hash(block_hash) + .await + .map_err(|e| ProofError::Transient(e.into()))? + .ok_or_else(|| { + ProofError::Transient(anyhow::anyhow!("block not found for hash {block_hash}")) + })?; + + let mut block_header_rlp = Vec::new(); + block.header.inner.encode(&mut block_header_rlp); + + let computed_hash = alloy_primitives::keccak256(&block_header_rlp); + if computed_hash != block_hash { + return Err(ProofError::Permanent(anyhow::anyhow!( + "header RLP hash mismatch: computed {computed_hash}, expected {block_hash}. \ + This may indicate the RPC returned non-standard header fields." + ))); + } + + // 3. Get all block receipts + let all_receipts = self + .provider + .get_block_receipts(block.header.number.into()) + .await + .map_err(|e| ProofError::Transient(e.into()))? + .ok_or_else(|| { + ProofError::Transient(anyhow::anyhow!( + "block receipts not found for block {block_hash}" + )) + })?; + + // 4. Encode each receipt to canonical EIP-2718 form (as stored in the trie). + let canonical_receipts: Vec<(u64, Vec)> = all_receipts + .into_iter() + .enumerate() + .map(|(idx, r)| { + let consensus_receipt = r.inner.inner.map_logs(|log| log.inner); + let encoded = consensus_receipt.encoded_2718(); + (idx as u64, encoded) + }) + .collect(); + + // 5. Build receipt trie and generate proof + let receipt_rlp = canonical_receipts + .iter() + .find(|(idx, _)| *idx == tx_index) + .map(|(_, rlp)| rlp.clone()) + .ok_or_else(|| { + ProofError::Permanent(anyhow::anyhow!( + "tx_index {tx_index} not found in block receipts" + )) + })?; + + let (receipts_root, proof_nodes) = + crate::proof::build_receipt_proof(&canonical_receipts, tx_index); + + if receipts_root != block.header.inner.receipts_root { + return Err(ProofError::Permanent(anyhow::anyhow!( + "receipts root mismatch: computed {receipts_root}, \ + header says {}. Receipt encoding may be incorrect.", + block.header.inner.receipts_root + ))); + } + + // Locate the single BurnBlocked log within the receipt. `blockBurn` + // emits exactly one such event per transaction; zero or more than one + // means the caller pointed at the wrong tx hash. + let logs = + crate::proof::decode_receipt_logs(&receipt_rlp).map_err(ProofError::Permanent)?; + let burn_blocked_sig = crate::proof::burn_blocked_event_signature(); + let mut matching = logs + .iter() + .enumerate() + .filter(|(_, log)| log.topics.first() == Some(&burn_blocked_sig)); + let log_index = match (matching.next(), matching.next()) { + (None, _) => { + return Err(ProofError::Permanent(anyhow::anyhow!( + "no BurnBlocked event found in receipt for tx {tx_hash}" + ))); + } + (Some(_), Some(_)) => { + return Err(ProofError::Permanent(anyhow::anyhow!( + "expected exactly one BurnBlocked event in receipt for tx {tx_hash}, found multiple" + ))); + } + (Some((idx, _)), None) => idx as u64, + }; + + Ok(BurnBlockedProof { + block_header_rlp, + receipt_rlp, + proof_nodes, + tx_index, + log_index, + }) + } +} + #[cfg(test)] mod tests { use alloy_primitives::{Address, B256, U256}; diff --git a/linera-bridge/src/proof/mod.rs b/linera-bridge/src/proof/mod.rs index fdeebae5f649..be2df9d7fc16 100644 --- a/linera-bridge/src/proof/mod.rs +++ b/linera-bridge/src/proof/mod.rs @@ -70,15 +70,44 @@ #[cfg(feature = "offchain")] pub mod gen; +mod burn_blocked; use alloy_primitives::{keccak256, Address, Bytes, B256, U256}; use alloy_rlp::Encodable; use alloy_trie::{proof::ProofRetainer, HashBuilder, Nibbles}; use anyhow::{anyhow, ensure, Result}; +pub use burn_blocked::{ + burn_blocked_event_signature, parse_burn_blocked_event, BurnBlockedFields, RefundKey, +}; use linera_base::{ crypto::CryptoHash, identifiers::{AccountOwner, ApplicationId, ChainId}, }; +/// Domain tag for storage key hash inputs. +/// +/// Every replay-protection key type picks one unique variant. Assignments here +/// are permanent: changing or reusing a discriminant would silently invalidate +/// all previously stored hashes. +/// +/// Add a new variant when introducing a new key type; the compiler then ensures +/// you cannot forget to assign a tag. +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum KeyDomain { + Deposit = 0x01, + Refund = 0x02, +} + +impl KeyDomain { + fn deposit() -> Self { + KeyDomain::Deposit + } + + fn refund() -> Self { + KeyDomain::Refund + } +} + /// A decoded log from an EVM transaction receipt. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReceiptLog { @@ -112,6 +141,8 @@ pub struct DepositEvent { Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize, )] pub struct DepositKey { + #[serde(skip, default = "KeyDomain::deposit")] + domain: KeyDomain, pub source_chain_id: u64, pub block_hash: B256, pub tx_index: u64, @@ -119,13 +150,24 @@ pub struct DepositKey { } impl DepositKey { + pub fn new(source_chain_id: u64, block_hash: B256, tx_index: u64, log_index: u64) -> Self { + Self { + domain: KeyDomain::Deposit, + source_chain_id, + block_hash, + tx_index, + log_index, + } + } + /// Deterministic keccak-256 hash of the deposit key fields. pub fn hash(&self) -> [u8; 32] { - let mut data = [0u8; 56]; - data[0..8].copy_from_slice(&self.source_chain_id.to_le_bytes()); - data[8..40].copy_from_slice(self.block_hash.as_slice()); - data[40..48].copy_from_slice(&self.tx_index.to_le_bytes()); - data[48..56].copy_from_slice(&self.log_index.to_le_bytes()); + let mut data = [0u8; 57]; + data[0] = self.domain as u8; + data[1..9].copy_from_slice(&self.source_chain_id.to_le_bytes()); + data[9..41].copy_from_slice(self.block_hash.as_slice()); + data[41..49].copy_from_slice(&self.tx_index.to_le_bytes()); + data[49..57].copy_from_slice(&self.log_index.to_le_bytes()); keccak256(data).0 } } @@ -267,6 +309,35 @@ pub mod testing { data } + /// Builds ABI-encoded data for a `BurnBlocked` event: + /// `bytes32 source_chain_id, bytes source_owner_bcs, uint128 amount`. + pub fn build_burn_blocked_event_data( + source_chain_id: B256, + source_owner_bcs: &[u8], + amount: u128, + ) -> Vec { + let mut data = Vec::new(); + // Head[0..32]: source_chain_id + data.extend_from_slice(source_chain_id.as_slice()); + // Head[32..64]: offset to source_owner_bcs tail = 96 + let mut offset = [0u8; 32]; + offset[24..32].copy_from_slice(&96u64.to_be_bytes()); + data.extend_from_slice(&offset); + // Head[64..96]: amount (uint128 left-padded to 32 bytes) + let mut amount_word = [0u8; 32]; + amount_word[16..32].copy_from_slice(&amount.to_be_bytes()); + data.extend_from_slice(&amount_word); + // Tail[96..128]: bytes length + let mut len_word = [0u8; 32]; + len_word[24..32].copy_from_slice(&(source_owner_bcs.len() as u64).to_be_bytes()); + data.extend_from_slice(&len_word); + // Tail[128..]: bytes data, padded to a 32-byte boundary + data.extend_from_slice(source_owner_bcs); + let pad = (32 - source_owner_bcs.len() % 32) % 32; + data.extend(std::iter::repeat_n(0u8, pad)); + data + } + /// Builds a receipts MPT trie from `(tx_index, receipt_rlp)` pairs and returns /// the trie root and proof nodes for `target_tx_index`. pub fn build_receipt_trie( diff --git a/linera-bridge/src/relay/evm.rs b/linera-bridge/src/relay/evm.rs index 33988703396b..ef4b47595101 100644 --- a/linera-bridge/src/relay/evm.rs +++ b/linera-bridge/src/relay/evm.rs @@ -13,7 +13,7 @@ use alloy_sol_types::SolCall; use anyhow::{Context as _, Result}; use linera_base::data_types::{BlockHeight, Epoch}; -use crate::proof::deposit_event_signature; +use crate::proof::{burn_blocked_event_signature, deposit_event_signature}; sol! { #[sol(rpc)] @@ -81,6 +81,14 @@ impl EvmClient

{ Ok(self.provider.get_block_number().await?) } + /// Returns the chain ID reported by the connected EVM node. + pub async fn get_chain_id(&self) -> Result { + self.provider + .get_chain_id() + .await + .context("failed to query EVM chain id") + } + /// Returns the relayer's ETH balance in wei. pub async fn get_relayer_balance(&self) -> Result { Ok(self.provider.get_balance(self.relayer_addr).await?) @@ -121,6 +129,26 @@ impl EvmClient

{ Ok(all_logs) } + /// Queries `BurnBlocked` events emitted by `FungibleBridge.blockBurn` in + /// chunked ranges. Returned in the same shape as deposit logs so the + /// scanner can hand them to `parse_burn_blocked_event`. + pub async fn get_burn_blocked_logs(&self, from: u64, to: u64) -> Result> { + let filter_base = Filter::new() + .address(self.bridge_addr) + .event_signature(burn_blocked_event_signature()); + + let mut all_logs = Vec::new(); + let mut cursor = from; + while cursor <= to { + let chunk_end = (cursor + MAX_LOG_BLOCK_RANGE - 1).min(to); + let filter = filter_base.clone().from_block(cursor).to_block(chunk_end); + let logs = self.provider.get_logs(&filter).await?; + all_logs.extend(logs); + cursor = chunk_end + 1; + } + Ok(all_logs) + } + /// Returns whether the FungibleBridge has already released the burn /// at `(height, event_index)` — i.e. whether the corresponding /// `_onBlock` loop iteration ran to completion in some prior `addBlock` diff --git a/linera-bridge/src/relay/linera.rs b/linera-bridge/src/relay/linera.rs index 1f67193e07fd..ae5937e9c1d8 100644 --- a/linera-bridge/src/relay/linera.rs +++ b/linera-bridge/src/relay/linera.rs @@ -15,11 +15,12 @@ use linera_chain::types::ConfirmedBlockCertificate; use linera_core::client::ChainClient; use tokio::sync::{mpsc, oneshot}; -use crate::proof::DepositKey; +use crate::proof::{DepositKey, RefundKey}; /// A write operation to be executed on the bridge chain. /// Sent to the main loop which serializes all chain mutations. #[derive(Debug)] +#[allow(clippy::enum_variant_names)] pub(crate) enum ChainOperation { ProcessInbox { response: oneshot::Sender>>, @@ -28,6 +29,10 @@ pub(crate) enum ChainOperation { proof: crate::proof::gen::DepositProof, response: oneshot::Sender>, }, + ProcessRefund { + proof: crate::proof::gen::BurnBlockedProof, + response: oneshot::Sender>, + }, } /// Centralized client for Linera chain interactions. @@ -113,6 +118,11 @@ impl LineraClient { .await } + pub async fn query_refund_processed(&self, refund_key: &RefundKey) -> Result { + crate::monitor::query_refund_processed(&self.chain_client, self.bridge_app_id, refund_key) + .await + } + // ── Write operations (sent to main loop via channel) ── pub async fn process_deposit(&self, proof: crate::proof::gen::DepositProof) -> Result<()> { @@ -127,6 +137,18 @@ impl LineraClient { resp_rx.await.with_context(|| "Response channel closed")? } + pub async fn process_refund(&self, proof: crate::proof::gen::BurnBlockedProof) -> Result<()> { + let (resp_tx, resp_rx) = oneshot::channel(); + self.op_tx + .send(ChainOperation::ProcessRefund { + proof, + response: resp_tx, + }) + .await + .with_context(|| "Chain operation channel closed")?; + resp_rx.await.with_context(|| "Response channel closed")? + } + pub async fn process_inbox(&self) -> Result> { let (resp_tx, resp_rx) = oneshot::channel(); self.op_tx diff --git a/linera-bridge/src/relay/metrics.rs b/linera-bridge/src/relay/metrics.rs index 4154fa0536d6..2eb20d1f56a6 100644 --- a/linera-bridge/src/relay/metrics.rs +++ b/linera-bridge/src/relay/metrics.rs @@ -60,6 +60,34 @@ static BURNS_FAILED: LazyLock = LazyLock::new(|| { prometheus::register_int_gauge!(opts).unwrap() }); +static REFUNDS_DETECTED: LazyLock = LazyLock::new(|| { + let opts = Opts::new( + "bridge_refunds_detected", + "Total refunds found by EVM scanner from BurnBlocked logs", + ) + .namespace("linera"); + prometheus::register_int_counter!(opts).unwrap() +}); + +static REFUNDS_COMPLETED: LazyLock = LazyLock::new(|| { + let opts = Opts::new( + "bridge_refunds_completed", + "Refunds credited back on Linera via RefundBurn", + ) + .namespace("linera"); + prometheus::register_int_counter!(opts).unwrap() +}); + +static REFUNDS_PENDING: LazyLock = LazyLock::new(|| { + let opts = Opts::new("bridge_refunds_pending", "Currently pending refunds").namespace("linera"); + prometheus::register_int_gauge!(opts).unwrap() +}); + +static REFUNDS_FAILED: LazyLock = LazyLock::new(|| { + let opts = Opts::new("bridge_refunds_failed", "Permanently failed refunds").namespace("linera"); + prometheus::register_int_gauge!(opts).unwrap() +}); + static LAST_SCANNED_EVM_BLOCK: LazyLock = LazyLock::new(|| { let opts = Opts::new( "bridge_last_scanned_evm_block", @@ -126,6 +154,21 @@ pub(crate) fn burn_failed() { BURNS_FAILED.inc(); } +pub(crate) fn refund_detected() { + REFUNDS_DETECTED.inc(); + REFUNDS_PENDING.inc(); +} + +pub(crate) fn refund_completed() { + REFUNDS_COMPLETED.inc(); + REFUNDS_PENDING.dec(); +} + +pub(crate) fn refund_failed() { + REFUNDS_PENDING.dec(); + REFUNDS_FAILED.inc(); +} + pub(crate) fn set_last_scanned_evm_block(block: u64) { LAST_SCANNED_EVM_BLOCK.set(block as i64); } diff --git a/linera-bridge/src/relay/mod.rs b/linera-bridge/src/relay/mod.rs index 3a4884158b3b..b935a3aa0b57 100644 --- a/linera-bridge/src/relay/mod.rs +++ b/linera-bridge/src/relay/mod.rs @@ -54,6 +54,36 @@ pub(crate) async fn update_balance_metrics< update_linera_balance_metric(linera_client).await; } +/// Polls the EVM RPC for the chain id with bounded exponential backoff +/// so a brief RPC outage at startup does not kill the relayer. Backs +/// off 1s, 2s, 4s, 8s, 16s; gives up after ~31s with the underlying +/// error so the operator still sees a clean failure on a hard outage. +async fn fetch_evm_chain_id_with_retry( + evm_client: &evm::EvmClient

, +) -> Result { + let mut delay = Duration::from_secs(1); + let max_delay = Duration::from_secs(16); + let mut last_error: Option = None; + for attempt in 1..=5 { + match evm_client.get_chain_id().await { + Ok(id) => return Ok(id), + Err(error) => { + tracing::warn!( + attempt, + ?delay, + ?error, + "EVM chain id query failed; retrying after backoff" + ); + last_error = Some(error); + tokio::time::sleep(delay).await; + delay = (delay * 2).min(max_delay); + } + } + } + Err(last_error.unwrap_or_else(|| anyhow::anyhow!("EVM chain id query failed"))) + .context("failed to query EVM chain id for monitor state after retries") +} + pub(crate) async fn update_evm_balance_metric( evm_client: &evm::EvmClient

, ) { @@ -317,7 +347,8 @@ async fn serve_loop( let mut chain_listener_handle = tokio::spawn(listener); // ── Monitor state + scan/retry ── - let mut monitor_state = MonitorState::new(monitor_start_block); + let source_chain_id = fetch_evm_chain_id_with_retry(&evm_client).await?; + let mut monitor_state = MonitorState::new(monitor_start_block, source_chain_id); let default_sqlite_path = storage_dir .parent() .unwrap_or(storage_dir) @@ -340,17 +371,20 @@ async fn serve_loop( let monitor = Arc::new(RwLock::new(monitor_state)); let deposit_notify = Arc::new(Notify::new()); let burn_notify = Arc::new(Notify::new()); + let refund_notify = Arc::new(Notify::new()); let mut evm_scan_handle = { let monitor = Arc::clone(&monitor); let evm_client = Arc::clone(&evm_client); let linera_client = Arc::clone(&linera_client); let deposit_notify = Arc::clone(&deposit_notify); + let refund_notify = Arc::clone(&refund_notify); tokio::spawn(monitor::evm::evm_scan_loop( monitor, evm_client, linera_client, deposit_notify, + refund_notify, monitor_scan_interval, )) }; @@ -380,6 +414,7 @@ async fn serve_loop( linera_client, deposit_notify, burn_notify, + refund_notify, monitor_scan_interval, max_retries, )) @@ -554,6 +589,44 @@ async fn serve_loop( } update_balance_metrics(&evm_client, &linera_client).await; } + linera::ChainOperation::ProcessRefund { proof, response } => { + let result = async { + let op = crate::abi::BridgeOperation::RefundBurn { + block_header_rlp: proof.block_header_rlp, + receipt_rlp: proof.receipt_rlp, + proof_nodes: proof.proof_nodes, + tx_index: proof.tx_index, + log_index: proof.log_index, + }; + let op_bytes = bcs::to_bytes(&op) + .expect("failed to BCS-serialize BridgeOperation"); + chain_client.synchronize_from_validators().await + .context("failed to synchronize")?; + let outcome = chain_client + .execute_operations( + vec![Operation::User { + application_id: bridge_app_id, + bytes: op_bytes, + }], + vec![], + ) + .await?; + match outcome { + linera_core::data_types::ClientOutcome::Committed(cert) => { + tracing::info!( + height = %cert.block().header.height, + "RefundBurn committed" + ); + Ok(()) + } + other => anyhow::bail!("RefundBurn not committed: {other:?}"), + } + }.await; + if response.send(result).is_err() { + tracing::debug!("ProcessRefund response receiver dropped"); + } + update_balance_metrics(&evm_client, &linera_client).await; + } } } } diff --git a/linera-bridge/src/solidity/FungibleBridge.sol b/linera-bridge/src/solidity/FungibleBridge.sol index 336f577a1e0e..fbc5c20281b5 100644 --- a/linera-bridge/src/solidity/FungibleBridge.sol +++ b/linera-bridge/src/solidity/FungibleBridge.sol @@ -34,6 +34,21 @@ contract FungibleBridge is Microchain { /// the source Linera block (matches the on-chain dedup key). event BurnReleased(uint64 indexed height, uint32 indexed eventIndex, address indexed target, uint256 amount); + /// Emitted when a burn at `(height, eventIndex)` is administratively + /// blocked from settlement by `blocked_by`. Subsequent `addBlock` / + /// `processBurns` calls skip the burn instead of releasing tokens. + /// Includes the decoded `source` chain id + owner and the burn + /// `amount` so the off-chain relayer can build a Linera-side refund + /// proof without re-fetching the certificate. + event BurnBlocked( + uint64 indexed height, + uint32 indexed eventIndex, + address indexed blocked_by, + bytes32 source_chain_id, + bytes source_owner_bcs, + uint128 amount + ); + // WrappedFungible application ID on Linera, // used to identify Burn events in the block stream. bytes32 public immutable fungibleApplicationId; @@ -47,6 +62,11 @@ contract FungibleBridge is Microchain { /// `_onBlock` after the burn's `token.transfer` succeeds. mapping(bytes32 => bool) internal processedBurns; + /// Per-burn block flag keyed identically to `processedBurns`. Once + /// set, `_onBlock` and `processBurns` skip the burn instead of + /// releasing tokens. One-way: there is no unblock entry point. + mapping(bytes32 => bool) internal blockedBurns; + constructor(address _lightClient, bytes32 _chainId, address _token, bytes32 _fungibleApplicationId) Microchain(_lightClient, _chainId) { @@ -63,6 +83,53 @@ contract FungibleBridge is Microchain { return processedBurns[_burnKey(height, eventIndex)]; } + /// Returns whether the burn at `(height, eventIndex)` is blocked from + /// settlement. Blocked burns are silently skipped by `addBlock` and + /// `processBurns`. + function isBurnBlocked(uint64 height, uint32 eventIndex) external view returns (bool) { + return blockedBurns[_burnKey(height, eventIndex)]; + } + + /// Verifies `cert` once, decodes the burn at (txIndex, eventPosInTx), + /// and marks it blocked from settlement. The decoded `source` and + /// `amount` are emitted alongside `(height, eventIndex)` so the + /// off-chain relayer can build a Linera-side refund proof without + /// going back to the cert. + /// + /// Reverts with `"already processed"` if the burn was already settled — + /// the caller intended to block, not process, so the mismatch is signalled + /// loudly. No-op if the burn is already blocked (idempotent re-block). + /// One-way — no unblock entry point. Currently public (no access control); + /// see follow-up. + function blockBurn(bytes calldata cert, uint32 txIndex, uint32 eventPosInTx) external { + (BridgeTypes.Block memory blockValue,) = lightClient.verifyBlock(cert); + require(blockValue.header.chain_id.value.value == chainId, "chain id mismatch"); + require(txIndex < blockValue.body.events.length, "txIndex out of range"); + + BridgeTypes.Event[] memory txEvents = blockValue.body.events[txIndex]; + require(eventPosInTx < txEvents.length, "eventPos out of range"); + + bytes32 burnsHash = keccak256("burns"); + BridgeTypes.Event memory evt = txEvents[eventPosInTx]; + require(_isMatchingBurn(evt, burnsHash), "not a matching burn"); + + uint64 height = blockValue.header.height.value; + bytes32 key = _burnKey(height, evt.index); + require(!processedBurns[key], "already processed"); + if (blockedBurns[key]) return; + blockedBurns[key] = true; + + WrappedFungibleTypes.BurnEvent memory burnEvt = WrappedFungibleTypes.bcs_deserialize_BurnEvent(evt.value); + emit BurnBlocked( + height, + evt.index, + msg.sender, + burnEvt.source.chain_id.value.value, + BridgeTypes.bcs_serialize_AccountOwner(burnEvt.source.owner), + burnEvt.amount + ); + } + /// Locks ERC-20 tokens in the bridge and emits a DepositInitiated event. /// Caller must first approve this contract to spend `amount` tokens. /// The emitted event is consumed by the off-chain relayer to produce an @@ -100,7 +167,8 @@ contract FungibleBridge is Microchain { /// Idempotent: each burn's release is gated on /// `processedBurns[keccak256(abi.encode(height, evt.index))]`, so /// re-submitting the same cert is a no-op for burns already released - /// by a prior call. + /// by a prior call. Burns flagged in `blockedBurns` are silently + /// skipped. function _onBlock(BridgeTypes.Block memory blockValue) internal override { bytes32 burnsHash = keccak256("burns"); uint64 height = blockValue.header.height.value; @@ -112,6 +180,7 @@ contract FungibleBridge is Microchain { bytes32 key = _burnKey(height, evt.index); if (processedBurns[key]) continue; + if (blockedBurns[key]) continue; _releaseBurn(evt, key, height); } @@ -137,6 +206,8 @@ 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 position already in `blockedBurns` (`"burn blocked"`) — caller + /// intent (process) conflicts with the existing block /// - any failed `token.transfer` (`"safeTransfer failed"`) function processBurns(bytes calldata data, uint32 txIndex, uint32[] calldata eventPositionsInTx) external { require(eventPositionsInTx.length > 0, "empty positions"); @@ -156,6 +227,7 @@ contract FungibleBridge is Microchain { bytes32 key = _burnKey(height, evt.index); if (processedBurns[key]) continue; + require(!blockedBurns[key], "burn blocked"); _releaseBurn(evt, key, height); } diff --git a/linera-bridge/src/solidity/WrappedFungibleTypes.sol b/linera-bridge/src/solidity/WrappedFungibleTypes.sol index 85b141e4d477..c12bba599f64 100644 --- a/linera-bridge/src/solidity/WrappedFungibleTypes.sol +++ b/linera-bridge/src/solidity/WrappedFungibleTypes.sol @@ -4,12 +4,14 @@ import "BridgeTypes.sol"; library WrappedFungibleTypes { struct BurnEvent { + BridgeTypes.Account source; bytes20 target; uint128 amount; } function bcs_serialize_BurnEvent(BurnEvent memory input) internal pure returns (bytes memory) { - bytes memory result = bcs_serialize_bytes20(input.target); + bytes memory result = BridgeTypes.bcs_serialize_Account(input.source); + result = abi.encodePacked(result, bcs_serialize_bytes20(input.target)); return abi.encodePacked(result, bcs_serialize_uint128(input.amount)); } @@ -19,11 +21,13 @@ library WrappedFungibleTypes { returns (uint256, BurnEvent memory) { uint256 new_pos; + BridgeTypes.Account memory source; + (new_pos, source) = BridgeTypes.bcs_deserialize_offset_Account(pos, input); bytes20 target; - (new_pos, target) = bcs_deserialize_offset_bytes20(pos, input); + (new_pos, target) = bcs_deserialize_offset_bytes20(new_pos, input); uint128 amount; (new_pos, amount) = bcs_deserialize_offset_uint128(new_pos, input); - return (new_pos, BurnEvent(target, amount)); + return (new_pos, BurnEvent(source, target, amount)); } function bcs_deserialize_BurnEvent(bytes memory input) internal pure returns (BurnEvent memory) { diff --git a/linera-bridge/src/solidity/test/FungibleBridge.t.sol b/linera-bridge/src/solidity/test/FungibleBridge.t.sol index 75d862ca763a..2c24130e3930 100644 --- a/linera-bridge/src/solidity/test/FungibleBridge.t.sol +++ b/linera-bridge/src/solidity/test/FungibleBridge.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.30; -import {Test} from "forge-std/Test.sol"; +import {Test, Vm} from "forge-std/Test.sol"; import {FungibleBridge} from "../FungibleBridge.sol"; import {BridgeTypes} from "../BridgeTypes.sol"; import {WrappedFungibleTypes} from "../WrappedFungibleTypes.sol"; @@ -20,6 +20,12 @@ address constant RECIP_1 = address(0xA1); address constant RECIP_2 = address(0xA2); bytes32 constant APP_ID = bytes32(uint256(0xF00D)); +// Stable test source — chain id and Address32 owner — used by the mocks +// for the burn event's `source: Account` field. Tests that assert on the +// emitted BurnBlocked payload compare against these. +bytes32 constant SOURCE_CHAIN_ID = bytes32(uint256(0xC2)); +bytes32 constant SOURCE_OWNER_ADDR32 = bytes32(uint256(0xBA5EBA11)); + // ------------------------------------------------------------------ // MockLightClientForBurns // @@ -77,6 +83,10 @@ contract MockLightClientForBurns { function _encodeBurn(address target, uint128 amount) private pure returns (bytes memory) { WrappedFungibleTypes.BurnEvent memory burnEvt; + burnEvt.source.chain_id.value.value = SOURCE_CHAIN_ID; + // Address32 variant of AccountOwner (choice index 1 per BridgeTypes). + burnEvt.source.owner.choice = 1; + burnEvt.source.owner.address32.value = SOURCE_OWNER_ADDR32; burnEvt.target = bytes20(target); burnEvt.amount = amount; return WrappedFungibleTypes.bcs_serialize_BurnEvent(burnEvt); @@ -118,6 +128,9 @@ contract MockLightClientForNonBurn { evt.stream_id.stream_name.value = bytes("deposits"); evt.index = 5; WrappedFungibleTypes.BurnEvent memory burnEvt; + burnEvt.source.chain_id.value.value = SOURCE_CHAIN_ID; + burnEvt.source.owner.choice = 1; + burnEvt.source.owner.address32.value = SOURCE_OWNER_ADDR32; burnEvt.target = bytes20(recipBase); burnEvt.amount = amountPerBurn; evt.value = WrappedFungibleTypes.bcs_serialize_BurnEvent(burnEvt); @@ -127,6 +140,79 @@ contract MockLightClientForNonBurn { } } +// ------------------------------------------------------------------ +// MockLightClientWithOwner +// +// Like MockLightClientForBurns but with a fully parametric AccountOwner +// so each variant can be exercised independently. +// ------------------------------------------------------------------ +contract MockLightClientWithOwner { + bytes32 public immutable chainIdRet; + uint64 public immutable heightRet; + uint32 public immutable txIndexUsed; + bytes32 public immutable fungibleAppIdRet; + uint128 public immutable amountPerBurn; + address public immutable recipBase; + uint8 public immutable ownerChoice; + uint8 public immutable ownerReserved; + bytes32 public immutable ownerAddr32; + bytes20 public immutable ownerAddr20; + + constructor( + bytes32 _chainId, + uint64 _height, + uint32 _txIndex, + bytes32 _fungibleAppId, + uint128 _amountPerBurn, + address _recipBase, + uint8 _ownerChoice, + uint8 _ownerReserved, + bytes32 _ownerAddr32, + bytes20 _ownerAddr20 + ) { + chainIdRet = _chainId; + heightRet = _height; + txIndexUsed = _txIndex; + fungibleAppIdRet = _fungibleAppId; + amountPerBurn = _amountPerBurn; + recipBase = _recipBase; + ownerChoice = _ownerChoice; + ownerReserved = _ownerReserved; + ownerAddr32 = _ownerAddr32; + ownerAddr20 = _ownerAddr20; + } + + 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[][](uint256(txIndexUsed) + 1); + b.body.events[txIndexUsed] = 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; + evt.stream_id.stream_name.value = bytes("burns"); + evt.index = 5; + evt.value = _encodeBurn(recipBase, amountPerBurn); + b.body.events[txIndexUsed][0] = evt; + + sigHash = bytes32(uint256(0x1234)); + } + + function _encodeBurn(address target, uint128 amount) private view returns (bytes memory) { + WrappedFungibleTypes.BurnEvent memory burnEvt; + burnEvt.source.chain_id.value.value = SOURCE_CHAIN_ID; + burnEvt.source.owner.choice = ownerChoice; + burnEvt.source.owner.reserved = ownerReserved; + burnEvt.source.owner.address32.value = ownerAddr32; + burnEvt.source.owner.address20 = ownerAddr20; + burnEvt.target = bytes20(target); + burnEvt.amount = amount; + return WrappedFungibleTypes.bcs_serialize_BurnEvent(burnEvt); + } +} + // ------------------------------------------------------------------ // Helpers // ------------------------------------------------------------------ @@ -144,12 +230,30 @@ function _u32s_single(uint32 a) pure returns (uint32[] memory) { return arr; } +/// Mirrors the `AccountOwner` value the mocks bake into every BurnEvent, +/// then runs it through the same codegen serializer that `blockBurn` +/// uses when emitting `BurnBlocked.source_owner_bcs`. +function _expectedOwnerBcs() pure returns (bytes memory) { + BridgeTypes.CryptoHash memory address32; + address32.value = SOURCE_OWNER_ADDR32; + BridgeTypes.AccountOwner memory owner = BridgeTypes.AccountOwner_case_address32(address32); + return BridgeTypes.bcs_serialize_AccountOwner(owner); +} + // ------------------------------------------------------------------ // Test contract // ------------------------------------------------------------------ contract FungibleBridgeProcessBurnsTest is Test { event BurnReleased(uint64 indexed height, uint32 indexed eventIndex, address indexed target, uint256 amount); + event BurnBlocked( + uint64 indexed height, + uint32 indexed eventIndex, + address indexed blocked_by, + bytes32 source_chain_id, + bytes source_owner_bcs, + uint128 amount + ); // Deploy a bridge backed by `lc`, with a LineraToken that has // `supply` tokens pre-minted to the bridge. @@ -279,4 +383,152 @@ contract FungibleBridgeProcessBurnsTest is Test { assertEq(tok.balanceOf(RECIP_0), AMOUNT, "pos 0 released once to its recipient"); assertEq(tok.balanceOf(recip1), AMOUNT, "pos 1 not double-released"); } + + function test_processBurns_reverts_on_blocked_position() public { + // 2 burns. Block (HEIGHT, 5) at position 0; then processBurns([0, 1]) + // must revert with "burn blocked" — caller's intent (process) conflicts + // with the existing block. Revert is atomic: neither position is + // marked processed and no tokens are released. + MockLightClientForBurns lc = new MockLightClientForBurns(CHAIN_ID, HEIGHT, TX, APP_ID, 2, AMOUNT, RECIP_0); + (FungibleBridge bridge, LineraToken tok) = _deployBridge(address(lc), AMOUNT * 10); + + bridge.blockBurn(hex"deadbeef", TX, 0); + assertTrue(bridge.isBurnBlocked(HEIGHT, 5), "burn at index 5 should be blocked"); + + vm.expectRevert(bytes("burn blocked")); + bridge.processBurns(hex"deadbeef", TX, _u32s(0, 1)); + + assertFalse(bridge.isBurnProcessed(HEIGHT, 5), "blocked burn must not be marked processed"); + assertFalse(bridge.isBurnProcessed(HEIGHT, 6), "unblocked burn must also be rolled back"); + assertEq(tok.balanceOf(RECIP_0), 0, "blocked recipient holds no released tokens"); + address recip1 = address(uint160(RECIP_0) + 1); + assertEq(tok.balanceOf(recip1), 0, "unblocked recipient holds no tokens - batch reverted"); + } + + function test_blockBurn_prevents_addBlock_release() public { + // Same as above but through the addBlock path: block (HEIGHT, 5) + // via position 0, then addBlock(cert) — pos 0 skipped, pos 1 released. + MockLightClientForBurns lc = new MockLightClientForBurns(CHAIN_ID, HEIGHT, TX, APP_ID, 2, AMOUNT, RECIP_0); + (FungibleBridge bridge, LineraToken tok) = _deployBridge(address(lc), AMOUNT * 10); + + bridge.blockBurn(hex"deadbeef", TX, 0); + bridge.addBlock(hex"deadbeef"); + + assertFalse(bridge.isBurnProcessed(HEIGHT, 5), "blocked burn must not be marked processed"); + assertTrue(bridge.isBurnProcessed(HEIGHT, 6), "unblocked burn settles"); + assertEq(tok.balanceOf(RECIP_0), 0, "blocked recipient holds no released tokens"); + } + + function test_blockBurn_idempotent_emits_once() public { + // First blockBurn sets the flag and emits with the decoded + // source + amount. Second is a no-op (no second event, flag stays set). + MockLightClientForBurns lc = new MockLightClientForBurns(CHAIN_ID, HEIGHT, TX, APP_ID, 1, AMOUNT, RECIP_0); + (FungibleBridge bridge,) = _deployBridge(address(lc), AMOUNT * 10); + + vm.expectEmit(true, true, true, true, address(bridge)); + emit BurnBlocked(HEIGHT, 5, address(this), SOURCE_CHAIN_ID, _expectedOwnerBcs(), AMOUNT); + bridge.blockBurn(hex"deadbeef", TX, 0); + assertTrue(bridge.isBurnBlocked(HEIGHT, 5)); + + vm.recordLogs(); + bridge.blockBurn(hex"deadbeef", TX, 0); + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(logs.length, 0, "second blockBurn must be silent"); + } + + function test_blockBurn_chain_id_mismatch_reverts() public { + MockLightClientForBurns lc = + new MockLightClientForBurns(bytes32(uint256(0xDEAD)), HEIGHT, TX, APP_ID, 1, AMOUNT, RECIP_0); + (FungibleBridge bridge,) = _deployBridge(address(lc), AMOUNT * 10); + vm.expectRevert(bytes("chain id mismatch")); + bridge.blockBurn(hex"deadbeef", TX, 0); + } + + function test_blockBurn_tx_index_out_of_range_reverts() public { + 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.blockBurn(hex"deadbeef", 99, 0); + } + + function test_blockBurn_event_pos_out_of_range_reverts() public { + 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.blockBurn(hex"deadbeef", TX, 99); + } + + function test_blockBurn_non_burn_event_reverts() public { + 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.blockBurn(hex"deadbeef", 0, 0); + } + + function test_blockBurn_reverts_on_already_processed() public { + // After processBurns has settled (HEIGHT, 5), blockBurn for the same + // position must revert with "already processed" — the caller intended + // to block, but the burn has already been processed. Surfacing the + // mismatch is more useful than a silent no-op. + 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)); + assertTrue(bridge.isBurnProcessed(HEIGHT, 5)); + + vm.expectRevert(bytes("already processed")); + bridge.blockBurn(hex"deadbeef", TX, 0); + + assertFalse(bridge.isBurnBlocked(HEIGHT, 5), "blocked flag must not flip after revert"); + } + + /// blockBurn must emit a BurnBlocked event whose `source_owner_bcs` field + /// round-trips correctly for every AccountOwner variant. + /// + /// Variant 0 — Reserved/CHAIN: choice byte 0x00 + one reserved byte 0x00 (2 bytes total). + /// Variant 1 — Address32: choice byte 0x01 + 32-byte CryptoHash (33 bytes total). + /// Variant 2 — Address20: choice byte 0x02 + 20-byte EVM address (21 bytes total). + function test_blockBurn_emits_BurnBlocked_with_decoded_fields() public { + // --- Variant 0: Reserved/CHAIN (choice=0, reserved=0) --- + { + MockLightClientWithOwner lc = new MockLightClientWithOwner( + CHAIN_ID, HEIGHT, TX, APP_ID, AMOUNT, RECIP_0, 0, 0, bytes32(0), bytes20(0) + ); + (FungibleBridge bridge,) = _deployBridge(address(lc), AMOUNT * 10); + bytes memory expectedBcs = BridgeTypes.bcs_serialize_AccountOwner(BridgeTypes.AccountOwner_case_reserved(0)); + vm.expectEmit(true, true, true, true, address(bridge)); + emit BurnBlocked(HEIGHT, 5, address(this), SOURCE_CHAIN_ID, expectedBcs, AMOUNT); + bridge.blockBurn(hex"deadbeef", TX, 0); + assertTrue(bridge.isBurnBlocked(HEIGHT, 5), "Reserved/CHAIN variant must be blocked"); + } + + // --- Variant 1: Address32 (choice=1) --- + { + bytes32 addr32 = bytes32(uint256(0xBEEF1234CAFE5678)); + MockLightClientWithOwner lc = + new MockLightClientWithOwner(CHAIN_ID, HEIGHT, TX, APP_ID, AMOUNT, RECIP_0, 1, 0, addr32, bytes20(0)); + (FungibleBridge bridge,) = _deployBridge(address(lc), AMOUNT * 10); + BridgeTypes.CryptoHash memory ch; + ch.value = addr32; + bytes memory expectedBcs = + BridgeTypes.bcs_serialize_AccountOwner(BridgeTypes.AccountOwner_case_address32(ch)); + vm.expectEmit(true, true, true, true, address(bridge)); + emit BurnBlocked(HEIGHT, 5, address(this), SOURCE_CHAIN_ID, expectedBcs, AMOUNT); + bridge.blockBurn(hex"deadbeef", TX, 0); + assertTrue(bridge.isBurnBlocked(HEIGHT, 5), "Address32 variant must be blocked"); + } + + // --- Variant 2: Address20 (choice=2) --- + { + bytes20 addr20 = bytes20(address(0xAAAA0000BBBBCCCCDDDD)); + MockLightClientWithOwner lc = + new MockLightClientWithOwner(CHAIN_ID, HEIGHT, TX, APP_ID, AMOUNT, RECIP_0, 2, 0, bytes32(0), addr20); + (FungibleBridge bridge,) = _deployBridge(address(lc), AMOUNT * 10); + bytes memory expectedBcs = + BridgeTypes.bcs_serialize_AccountOwner(BridgeTypes.AccountOwner_case_address20(addr20)); + vm.expectEmit(true, true, true, true, address(bridge)); + emit BurnBlocked(HEIGHT, 5, address(this), SOURCE_CHAIN_ID, expectedBcs, AMOUNT); + bridge.blockBurn(hex"deadbeef", TX, 0); + assertTrue(bridge.isBurnBlocked(HEIGHT, 5), "Address20 variant must be blocked"); + } + } } diff --git a/linera-bridge/src/test_helpers.rs b/linera-bridge/src/test_helpers.rs index 8685a67895b3..dca4da632781 100644 --- a/linera-bridge/src/test_helpers.rs +++ b/linera-bridge/src/test_helpers.rs @@ -220,6 +220,7 @@ pub fn fungible_message_transaction( /// Creates a BurnEvent as a linera_base Event on the "burns" stream for a given application. pub fn burn_event( application_id: CryptoHash, + source: wrapped_fungible::Account, target: [u8; 20], amount: U128, index: u32, @@ -231,7 +232,12 @@ pub fn burn_event( stream_name: StreamName(b"burns".to_vec()), }, index, - value: bcs::to_bytes(&wrapped_fungible::BurnEvent { target, amount }).unwrap(), + value: bcs::to_bytes(&wrapped_fungible::BurnEvent { + source, + target, + amount, + }) + .unwrap(), } } diff --git a/linera-bridge/tests/e2e/tests/auto_deposit_scan.rs b/linera-bridge/tests/e2e/tests/auto_deposit_scan.rs index 5f2f00712947..44a46dbfd23d 100644 --- a/linera-bridge/tests/e2e/tests/auto_deposit_scan.rs +++ b/linera-bridge/tests/e2e/tests/auto_deposit_scan.rs @@ -401,14 +401,14 @@ async fn test_auto_deposit_scan() -> anyhow::Result<()> { .map(|i| i as u64) .expect("DepositInitiated event not found in deposit receipt"); - let deposit_key = linera_bridge::proof::DepositKey { - source_chain_id: 31337, - block_hash: deposit_receipt.block_hash.unwrap(), - tx_index: deposit_receipt + let deposit_key = linera_bridge::proof::DepositKey::new( + 31337, + deposit_receipt.block_hash.unwrap(), + deposit_receipt .transaction_index .expect("transaction_index missing"), log_index, - }; + ); // Wait for relay to auto-process the deposit. tracing::info!("Waiting for relay scanner to auto-process the deposit..."); 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 ba7d94aa71b2..5474682cbff8 100644 --- a/linera-bridge/tests/e2e/tests/evm_to_linera_bridge.rs +++ b/linera-bridge/tests/e2e/tests/evm_to_linera_bridge.rs @@ -307,12 +307,12 @@ async fn test_evm_to_linera_bridge() -> anyhow::Result<()> { // Build the DepositKey for completion checks. let tx_index = proof.tx_index; let log_index = proof.log_indices[0]; - let deposit_key = linera_bridge::proof::DepositKey { - source_chain_id: 31337, // Anvil chain ID - block_hash: deposit_receipt.block_hash.unwrap(), + let deposit_key = linera_bridge::proof::DepositKey::new( + 31337, // Anvil chain ID + deposit_receipt.block_hash.unwrap(), tx_index, log_index, - }; + ); // ── Phase 7a: Verify deposit is NOT yet processed ── assert!( diff --git a/linera-bridge/tests/e2e/tests/refund_after_block.rs b/linera-bridge/tests/e2e/tests/refund_after_block.rs new file mode 100644 index 000000000000..e9bde91d404f --- /dev/null +++ b/linera-bridge/tests/e2e/tests/refund_after_block.rs @@ -0,0 +1,617 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! End-to-end coverage of the blocked-burn refund flow. +//! +//! Chain B burns wrapped tokens targeting an EVM recipient. Before the +//! relayer's normal `addBlock` lands, the test driver calls +//! `FungibleBridge.blockBurn(cert, txIndex, eventPosInTx)` directly, +//! which emits `BurnBlocked` and flips `isBurnBlocked` to true on the +//! bridge contract. The relayer's EVM scanner picks up that log, builds +//! a refund proof, and submits `RefundBurn` on the evm-bridge Linera +//! app, which calls back into the wrapped-fungible app to mint the +//! burned amount back to the original burner on chain B. +//! +//! Final state: +//! - Chain B owner_b balance is back at the pre-burn level. +//! - EVM recipient ERC-20 balance stayed at 0 (no `_onBlock` release). +//! - `isBurnProcessed(height, eventIndex)` is `false`. +//! - `isBurnBlocked(height, eventIndex)` is `true`. +//! - `linera_bridge_refunds_completed` reached 1. + +#![recursion_limit = "512"] + +use std::{collections::BTreeMap, path::PathBuf, time::Duration}; + +use alloy::{ + network::EthereumWallet, + primitives::{Address, U256}, + providers::ProviderBuilder, + signers::local::PrivateKeySigner, + sol, +}; +use anyhow::Context as _; +use linera_base::{ + crypto::InMemorySigner, + data_types::{Bytecode, Event, U128}, + identifiers::{AccountOwner, ApplicationId, GenericApplicationId}, + vm::VmRuntime, +}; +use linera_bridge::{ + abi::{BridgeInstantiationArgument, BridgeOperation, BridgeParameters}, + proof::{parse_burn_blocked_event, ReceiptLog, RefundKey}, +}; +use linera_bridge_e2e::{ + compose_file_path, deploy_fungible_bridge, deploy_linera_token, fetch_latest_cert, + fund_bridge_erc20, light_client_address, parse_metric_value, start_compose, + wait_for_light_client, wait_for_relay_http_ready, ANVIL_PRIVATE_KEY, +}; +use linera_client::{chain_listener::ClientContext as _, client_context::ClientContext}; +use linera_core::environment::wallet::Memory; +use linera_execution::{Operation, Query, QueryResponse, WasmRuntime}; +use linera_faucet_client::Faucet; +use linera_storage::{DbStorage, StorageCacheConfig}; +use linera_views::backends::memory::{MemoryDatabase, MemoryStoreConfig}; +use wrapped_fungible::{ + Account, BurnEvent, InitialState, WrappedFungibleOperation, WrappedParameters, +}; + +sol! { + #[sol(rpc)] + interface IERC20 { + function balanceOf(address account) external view returns (uint256); + } + + #[sol(rpc)] + interface IFungibleBridge { + function blockBurn(bytes calldata cert, uint32 txIndex, uint32 eventPosInTx) external; + function isBurnBlocked(uint64 height, uint32 eventIndex) external view returns (bool); + function isBurnProcessed(uint64 height, uint32 eventIndex) external view returns (bool); + } +} + +/// Initial wrapped-token balance minted to `owner_b` on chain B. Sized +/// large enough that the post-burn pre-refund snapshot is non-zero and +/// the post-refund snapshot must equal it exactly. +const INITIAL_BALANCE_TOKENS: u128 = 100; +/// One whole wrapped token (1e18 sub-units) — the smallest realistic +/// burn that still exercises the full encoder path. +const BURN_AMOUNT_TOKENS: u128 = 1; + +/// Locates the burn `BurnEvent` for `fungible_app_id` inside a confirmed +/// block. Returns `(tx_index, event_pos_in_tx, event_index, burn)` for +/// the first match. The test triggers exactly one burn so this is the +/// only event of interest. +/// +/// Mirrors `linera_bridge::relay::linera::find_burn_events` (which is +/// `pub(crate)`) — duplicated here intentionally instead of widening +/// the public surface for a single test. +fn find_first_burn( + events: &[Vec], + fungible_app_id: ApplicationId, +) -> Option<(u32, u32, u32, BurnEvent)> { + 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; + } + if event.stream_id.stream_name.0 != b"burns" { + continue; + } + if let Ok(burn) = bcs::from_bytes::(&event.value) { + return Some((tx_index, event_pos, event.index, burn)); + } + } + } + None +} + +/// Queries the wrapped-fungible service for `owner`'s balance on the +/// chain the `chain_client` is bound to. Returns 0 (`U128(0)`) when the +/// account entry is absent rather than failing — matches the +/// wrapped-fungible semantics for uncredited accounts. +async fn query_wrapped_balance( + chain_client: &linera_core::client::ChainClient, + fungible_app_id: ApplicationId, + owner: AccountOwner, +) -> anyhow::Result +where + E: linera_core::environment::Environment, +{ + #[derive(serde::Serialize)] + struct GqlRequest { + query: String, + } + + let gql = format!(r#"query {{ accounts {{ entry(key: "{owner}") {{ value }} }} }}"#); + let query = Query::user_without_abi(fungible_app_id, &GqlRequest { query: gql })?; + let (outcome, _) = chain_client.query_application(query, None).await?; + let response_bytes = match outcome.response { + QueryResponse::User(bytes) => bytes, + other => anyhow::bail!("unexpected query response: {other:?}"), + }; + let response: serde_json::Value = serde_json::from_slice(&response_bytes)?; + match response["data"]["accounts"]["entry"]["value"].as_str() { + Some(balance_str) => balance_str.parse::().context("parse U128"), + None => Ok(U128(0)), + } +} + +#[tokio::test] +#[ignore] // Requires pre-built docker images, Wasm, and relay binary. +async fn relayer_refunds_blocked_burn() -> 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-refund-after-block-test"; + + let compose = start_compose(&compose_file, project_name).await; + wait_for_light_client(&compose, project_name, &compose_file).await; + + 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, + "refund-after-block-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?; + + // Chain A hosts both the evm-bridge and the wrapped-fungible apps + // and is the chain the relayer drives. Chain B is the user chain + // that initiates the burn. + 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?; + + // ── Deploy LineraToken (EVM) ── + let erc20_addr = deploy_linera_token(&compose, project_name, &compose_file).await?; + + // ── Publish & deploy the evm-bridge Wasm app on chain A ── + let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .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 wasm_dir = repo_root.join("examples/target/wasm32-unknown-unknown/release"); + + 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) + .await? + .expect("publish evm-bridge module committed"); + cc_a.synchronize_from_validators().await?; + cc_a.process_inbox().await?; + + let (bridge_app_id, _) = cc_a + .create_application_untyped( + eb_module_id, + serde_json::to_vec(&BridgeParameters { + source_chain_id: 31337, + token_address: erc20_addr.0 .0, + })?, + serde_json::to_vec(&BridgeInstantiationArgument { + rpc_endpoint: String::new(), + })?, + vec![], + ) + .await? + .expect("create evm-bridge app committed"); + + // ── Publish & create wrapped-fungible app on chain B so the + // InitialState executes there, pre-funding owner_b on chain B + // with INITIAL_BALANCE_TOKENS. The burn then leaves a + // deterministic shortfall the refund must restore. ── + let wf_contract = Bytecode::load_from_file(wasm_dir.join("wrapped_fungible_contract.wasm"))?; + let wf_service = Bytecode::load_from_file(wasm_dir.join("wrapped_fungible_service.wasm"))?; + let (wf_module_id, _) = cc_b + .publish_module(wf_contract, wf_service, VmRuntime::Wasm) + .await? + .expect("publish wrapped-fungible module committed"); + cc_b.synchronize_from_validators().await?; + cc_b.process_inbox().await?; + + let initial_balance = U128(INITIAL_BALANCE_TOKENS * 10u128.pow(18)); + let (fungible_app_id, _) = cc_b + .create_application_untyped( + wf_module_id, + serde_json::to_vec(&WrappedParameters { + ticker_symbol: "wTEST".to_string(), + decimals: 18, + minter: Some(owner_a), + mint_chain_id: Some(chain_a), + evm_token_address: erc20_addr.0 .0, + evm_source_chain_id: 31337, + bridge_app_id: Some(bridge_app_id), + })?, + serde_json::to_vec(&InitialState { + accounts: BTreeMap::from([(owner_b, initial_balance)]), + })?, + vec![], + ) + .await? + .expect("create wrapped-fungible app committed"); + + // Register the wrapped-fungible app on the evm-bridge so RefundBurn + // can call back into it. + let register_fungible_op = Operation::User { + application_id: bridge_app_id, + bytes: bcs::to_bytes(&BridgeOperation::RegisterFungibleApp { + app_id: fungible_app_id, + })?, + }; + cc_a.execute_operations(vec![register_fungible_op], vec![]) + .await? + .expect("register fungible app committed"); + + // ── Deploy FungibleBridge on EVM with the real wrapped-fungible + // app ID baked into the constructor. ── + 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 the bridge so an unexpected `_onBlock` release would succeed + // — eliminates "insufficient ERC-20" as an alternative explanation + // for the EVM recipient's zero balance assertion below. + fund_bridge_erc20( + &compose, + project_name, + &compose_file, + erc20_addr, + bridge_addr, + 1_000_000_000_000_000_000_000u128, + ) + .await; + + // Register the FungibleBridge address on the evm-bridge so the + // RefundBurn proof verification can match the log emitter. + let register_bridge_op = Operation::User { + application_id: bridge_app_id, + bytes: bcs::to_bytes(&BridgeOperation::RegisterFungibleBridge { + address: bridge_addr.0 .0, + })?, + }; + cc_a.execute_operations(vec![register_bridge_op], vec![]) + .await? + .expect("register bridge address committed"); + + // ── Trigger the burn: chain B does a cross-chain Transfer to an + // Address20 owner on chain A. Chain A's wrapped-fungible app + // receives the Credit and emits a BurnEvent on the "burns" + // stream. ── + let evm_recipient: Address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8".parse()?; + let evm_recipient_owner = AccountOwner::Address20(evm_recipient.0 .0); + let burn_amount = U128(BURN_AMOUNT_TOKENS * 10u128.pow(18)); + + cc_b.synchronize_from_validators().await?; + let transfer_bytes = bcs::to_bytes(&WrappedFungibleOperation::Transfer { + owner: owner_b, + amount: burn_amount, + target_account: Account { + chain_id: chain_a, + owner: evm_recipient_owner, + }, + })?; + cc_b.execute_operations( + vec![Operation::User { + application_id: fungible_app_id, + bytes: transfer_bytes, + }], + vec![], + ) + .await? + .expect("cross-chain burn committed on chain B"); + + // Snapshot owner_b's balance immediately after the burn debit — + // this is the pre-refund baseline. The refund must restore it back + // to `initial_balance`, recovering exactly `burn_amount`. + let post_burn_balance = query_wrapped_balance(&cc_b, fungible_app_id, owner_b).await?; + assert_eq!( + post_burn_balance, + U128(initial_balance.0 - burn_amount.0), + "post-burn balance must reflect the debit" + ); + + // Drive chain A's inbox so the Credit message materialises as a + // BurnEvent cert on chain A. + cc_a.synchronize_from_validators().await?; + cc_a.process_inbox().await?; + cc_a.synchronize_from_validators().await?; + + let burn_cert = fetch_latest_cert(&cc_a).await?; + let burn_block = burn_cert.block(); + let burn_height = burn_block.header.height; + let (tx_index, event_pos_in_tx, event_index, burn_event) = + find_first_burn(&burn_block.body.events, fungible_app_id) + .expect("burn event must be present in chain A's latest cert"); + + assert_eq!( + burn_event.amount, burn_amount, + "decoded burn amount mismatch" + ); + assert_eq!( + burn_event.source.chain_id, chain_b, + "burn source chain id mismatch" + ); + assert_eq!( + burn_event.source.owner, owner_b, + "burn source owner mismatch" + ); + + // ── Call `blockBurn` directly from the test signer BEFORE + // spawning the relayer, so the relayer never sees an unblocked + // burn cert. The cert payload is the same BCS-serialized + // `ConfirmedBlockCertificate` the relayer ships to `addBlock`. ── + let rpc_url = "http://localhost:8545".parse()?; + let test_signer: PrivateKeySigner = ANVIL_PRIVATE_KEY.parse()?; + let test_signer_addr = test_signer.address(); + let wallet = EthereumWallet::from(test_signer); + let provider = ProviderBuilder::new().wallet(wallet).connect_http(rpc_url); + let bridge = IFungibleBridge::new(bridge_addr, &provider); + + let cert_bytes = bcs::to_bytes(burn_cert.as_ref()) + .context("BCS-serialize ConfirmedBlockCertificate for blockBurn")?; + let block_burn_receipt = bridge + .blockBurn(cert_bytes.into(), tx_index, event_pos_in_tx) + .send() + .await? + .get_receipt() + .await?; + assert!(block_burn_receipt.status(), "blockBurn tx must succeed"); + + // Locate the BurnBlocked log in the receipt and parse it with the + // same routine the relayer uses, then check every decoded field + // against the burn certificate so a regression in either the + // emitter or the parser surfaces here. + let event_sig = linera_bridge::proof::burn_blocked_event_signature(); + let (log_index, alloy_log) = block_burn_receipt + .inner + .logs() + .iter() + .enumerate() + .find(|(_, log)| log.address() == bridge_addr && log.topic0() == Some(&event_sig)) + .map(|(i, log)| (i as u64, log)) + .expect("BurnBlocked log not found in blockBurn receipt"); + + let receipt_log = ReceiptLog { + address: alloy_log.address(), + topics: alloy_log.data().topics().to_vec(), + data: alloy_log.data().data.to_vec(), + }; + let fields = parse_burn_blocked_event(&receipt_log, bridge_addr) + .context("parse BurnBlocked log")?; + assert_eq!( + fields.height, burn_height.0, + "BurnBlocked.height mismatch" + ); + assert_eq!( + fields.event_index, event_index, + "BurnBlocked.eventIndex mismatch" + ); + assert_eq!( + fields.blocked_by, test_signer_addr, + "BurnBlocked.blocked_by must match the caller" + ); + assert_eq!( + fields.source_chain_id, chain_b, + "BurnBlocked.source_chain_id must equal chain B" + ); + assert_eq!( + fields.source_owner, owner_b, + "BurnBlocked.source_owner must BCS-decode to chain B's burner" + ); + assert_eq!( + fields.amount.to_attos(), + burn_amount.0, + "BurnBlocked.amount mismatch" + ); + + // Sanity: contract views agree with the event. + assert!( + bridge + .isBurnBlocked(burn_height.0, event_index) + .call() + .await?, + "isBurnBlocked must be true after blockBurn" + ); + assert!( + !bridge + .isBurnProcessed(burn_height.0, event_index) + .call() + .await?, + "isBurnProcessed must remain false — release was blocked" + ); + + // Snapshot the BurnBlocked tx coordinates for the RefundKey poll. + let refund_key = RefundKey::new( + 31337, + block_burn_receipt.block_hash.context("block_hash missing")?, + block_burn_receipt + .transaction_index + .context("transaction_index missing")?, + log_index, + ); + + // ── Now spawn the relayer. Its EVM scanner picks up the + // BurnBlocked log, builds a refund proof, and submits + // RefundBurn on the evm-bridge app. ── + 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 = 3006u16; + let bridge_addr_str = format!("{bridge_addr}"); + let bridge_app_str = format!("{bridge_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); + } + + // Poll for refund completion on the bridge chain. The refund + // pipeline is purely on-chain once the relayer submits the proof, + // so `isRefundProcessed` is the authoritative success signal. + let refund_deadline = std::time::Instant::now() + Duration::from_secs(120); + let mut refund_processed = false; + while std::time::Instant::now() < refund_deadline { + if relay_handle.is_finished() { + anyhow::bail!("Relay exited unexpectedly: {:?}", relay_handle.await); + } + // Sync chain B so the cross-chain Mint message lands and the + // balance assertion below sees the refunded credit. + cc_b.synchronize_from_validators().await?; + cc_b.process_inbox().await?; + match linera_bridge::monitor::query_refund_processed(&cc_a, bridge_app_id, &refund_key) + .await + { + Ok(true) => { + refund_processed = true; + break; + } + Ok(false) => {} + Err(error) => tracing::warn!(?error, "query_refund_processed failed; retrying"), + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + if !refund_processed { + relay_handle.abort(); + anyhow::bail!("Refund was not processed within 120s"); + } + + // One more sync so the Mint cross-chain message is definitely + // applied to chain B's wrapped-fungible state before we read it. + cc_b.synchronize_from_validators().await?; + cc_b.process_inbox().await?; + + let final_balance = query_wrapped_balance(&cc_b, fungible_app_id, owner_b).await?; + let token = IERC20::new(erc20_addr, &provider); + let evm_recipient_balance = token.balanceOf(evm_recipient).call().await?; + let still_blocked = bridge + .isBurnBlocked(burn_height.0, event_index) + .call() + .await?; + let processed = bridge + .isBurnProcessed(burn_height.0, event_index) + .call() + .await?; + let final_metrics = http + .get(format!("{relay_url}/metrics")) + .send() + .await? + .text() + .await?; + let refunds_completed = parse_metric_value(&final_metrics, "linera_bridge_refunds_completed"); + + relay_handle.abort(); + + assert_eq!( + final_balance, initial_balance, + "owner_b's wrapped balance must return to the pre-burn level" + ); + assert_eq!( + evm_recipient_balance, + U256::ZERO, + "EVM recipient must hold no ERC-20 — _onBlock was blocked from releasing" + ); + assert!( + still_blocked, + "isBurnBlocked must remain true after the refund" + ); + assert!( + !processed, + "isBurnProcessed must remain false — refund does not flip the release flag" + ); + assert!( + refunds_completed >= 1, + "linera_bridge_refunds_completed must reach 1; got {refunds_completed}" + ); + + Ok(()) +} diff --git a/linera-bridge/tests/snapshots/format_wrapped_fungible__format_wrapped_fungible.yaml.snap b/linera-bridge/tests/snapshots/format_wrapped_fungible__format_wrapped_fungible.yaml.snap index 4832a3f6a4cd..401e1f4d8ec2 100644 --- a/linera-bridge/tests/snapshots/format_wrapped_fungible__format_wrapped_fungible.yaml.snap +++ b/linera-bridge/tests/snapshots/format_wrapped_fungible__format_wrapped_fungible.yaml.snap @@ -25,6 +25,8 @@ AccountOwner: SIZE: 20 BurnEvent: STRUCT: + - source: + TYPENAME: Account - target: TUPLEARRAY: CONTENT: U8 diff --git a/linera-sdk/src/abis/wrapped_fungible.rs b/linera-sdk/src/abis/wrapped_fungible.rs index d95d61051708..cc1fe611c042 100644 --- a/linera-sdk/src/abis/wrapped_fungible.rs +++ b/linera-sdk/src/abis/wrapped_fungible.rs @@ -40,6 +40,8 @@ pub struct WrappedParameters { /// to EVM to release the corresponding ERC-20 tokens. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct BurnEvent { + /// The source account whose tokens were burned (for refund if release fails) + pub source: Account, /// The Ethereum address to receive the unlocked ERC-20 tokens pub target: [u8; 20], /// Amount of tokens burned, in raw sub-units of the source ERC-20.