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.