Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
999763d
Add an endpoint that allows for blocking certain Linera Burn events
deuszx May 27, 2026
778702a
wrapped-fungible: add source: Account to BurnEvent
deuszx May 27, 2026
1509a48
linera-bridge: regenerate wrapped-fungible format snapshot
deuszx May 27, 2026
37faf6e
linera-bridge: regenerate Solidity types for new BurnEvent
deuszx May 27, 2026
859f42d
FungibleBridge: blockBurn requires cert; emit decoded source + amount
deuszx May 27, 2026
441b918
FungibleBridge tests: cover cert-verified blockBurn + decoded BurnBlo…
deuszx May 27, 2026
ddcd1d6
feat(bridge): add BurnBlocked proof type for refund flow
deuszx May 27, 2026
4a5234c
linera-bridge: add BridgeOperation::RefundBurn variant
deuszx May 27, 2026
0a57840
evm-bridge tests: drop stale rpc_endpoint BridgeParameters field
deuszx May 27, 2026
75aeeae
linera-bridge: expose BurnBlocked test helpers for downstream tests
deuszx May 27, 2026
ceb5fb2
evm-bridge tests: fix process_deposit fixture path and balance assert…
deuszx May 27, 2026
c62ef8d
evm-bridge: implement RefundBurn operation with MPT proof verification
deuszx May 27, 2026
10a5af6
relay: scan BurnBlocked logs and track pending refunds
deuszx May 27, 2026
8478a05
relay: persist pending refunds in SQLite
deuszx May 27, 2026
c4a4c90
relay: submit RefundBurn and track refund completion
deuszx May 27, 2026
a84ccd5
e2e: refund flow after blockBurn
deuszx May 27, 2026
80ae018
e2e refund test: create wrapped-fungible on chain B so owner_b is fun…
deuszx May 27, 2026
10b1ace
forge fmt: collapse blockBurn revert-test constructor onto one line
deuszx May 27, 2026
31f67de
relay: silence enum_variant_names on ChainOperation
deuszx May 27, 2026
241f83b
evm-bridge: check processed_refunds before RPC/MPT verification
deuszx May 28, 2026
0657031
evm-bridge: check processed_deposits before RPC/MPT verification
deuszx May 28, 2026
7a42386
relay: include refunds in StatusSummary and EVM scan trace log
deuszx May 28, 2026
5f7ce5d
relay: retry EVM chain id query with backoff at startup
deuszx May 28, 2026
b5276e7
Add a domain separator to avoid hash collision for different keys
deuszx May 28, 2026
1188346
test(solidity): cover all AccountOwner variants in blockBurn BurnBloc…
deuszx Jun 1, 2026
5e6afa4
refactor(bridge): enforce key domain via private field and constructor
deuszx Jun 1, 2026
44070ae
feat(bridge): revert blockBurn on processed, processBurns on blocked
deuszx Jun 1, 2026
02b9bb5
refactor(bridge): drop underscore from private domain field
deuszx Jun 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions examples/wrapped-fungible/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
3 changes: 1 addition & 2 deletions linera-bridge/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 }
Expand All @@ -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"] }
Expand Down
1 change: 1 addition & 0 deletions linera-bridge/contracts/evm-bridge/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

169 changes: 141 additions & 28 deletions linera-bridge/contracts/evm-bridge/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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<Option<ApplicationId>>,
pub bridge_contract_address: RegisterView<Option<[u8; 20]>>,
Expand Down Expand Up @@ -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;
}
}
}

Expand Down Expand Up @@ -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
Expand All @@ -199,15 +231,15 @@ 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<Bytes> = 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");

// 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(),
Expand All @@ -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,
Expand All @@ -235,38 +267,21 @@ 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
.insert(&block_hash.0)
.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
Expand All @@ -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<u8>],
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<Bytes> = 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::<WrappedFungibleTokenAbi>();
self.runtime
.call_application(true, fungible_app_id, &mint_op);
}
}
17 changes: 17 additions & 0 deletions linera-bridge/contracts/evm-bridge/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<ApplicationId>>,
pub bridge_contract_address: RegisterView<Option<[u8; 20]>>,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading