Skip to content

Support blocking certain Burn s from releasing tokens on EVM. Refund the sender.#6400

Draft
deuszx wants to merge 28 commits into
testnet_conwayfrom
worktree-blocked-burn-refund
Draft

Support blocking certain Burn s from releasing tokens on EVM. Refund the sender.#6400
deuszx wants to merge 28 commits into
testnet_conwayfrom
worktree-blocked-burn-refund

Conversation

@deuszx

@deuszx deuszx commented May 27, 2026

Copy link
Copy Markdown
Contributor

Motivation

Support blocking certain Linera Burn-s from unlocking tokens on the EVM side (legal purposes for example). What's important, we can't take over those tokens – they need to be returned to the original account (that issued the transfer) on Linera side.

Proposal

End-to-end flow:

  1. User burns wrapped tokens on Linera. WrappedFungible emits BurnEvent { source: Account, target: Address20, amount } on the burns stream. The new source field carries the burner's chain + owner so a
    refund can be routed back.
  2. Anyone calls FungibleBridge.blockBurn(bytes cert, uint32 txIndex, uint32 eventPosInTx). Signature is now cert-verified — pre-emptive blocking is no longer supported. On success the contract sets
    blockedBurns[key] = true and emits an enriched BurnBlocked(uint64 indexed height, uint32 indexed eventIndex, address indexed blocked_by, bytes32 source_chain_id, bytes source_owner_bcs, uint128 amount). source_owner_bcs is the BCS encoding of AccountOwner, produced via the codegen helper.
  3. The relayer scans for BurnBlocked logs alongside DepositInitiated, persists each as a PendingRefund in SQLite (pending_refunds + finished_refunds tables), generates an MPT receipt proof, and submits
    BridgeOperation::RefundBurn { block_header_rlp, receipt_rlp, proof_nodes, tx_index, log_index }.
  4. The Linera-side evm-bridge contract verifies the proof (same scaffolding as ProcessDeposit), dedups via a new processed_refunds: SetView<[u8; 32]> keyed by RefundKey { source_chain_id, block_hash, tx_index, log_index }.hash(), and calls WrappedFungibleOperation::Mint { target_account: Account { chain_id: source_chain_id, owner: source_owner }, amount }.
  5. execute_mint cascades through the existing flow: local credit if the bridge runs on the burner's chain, otherwise a tracked Message::Credit to the burner's chain — same path the deposit-mint already uses.

Caller-intent mismatches now revert instead of silently no-op:

  • blockBurn reverts with "already processed" when the burn has already been settled — the caller intended to block, not accept settlement.
  • processBurns reverts with "burn blocked" when any position in the batch is in blockedBurns — atomic, no partial release.
  • Same-intent idempotency preserved: re-block on blocked is a silent no-op; re-process on already-processed is a silent skip (batch tolerance); _onBlock (addBlock path) still silently skips both — broader-scope
    handler that processes everything in a block.

Replay-protection keys are domain-separated by construction:

  • New KeyDomain enum (Deposit = 0x01, Refund = 0x02) — the single source of truth for hash-input tags. Adding a future key type requires adding a variant, so a tag cannot be forgotten or accidentally
    reused.
  • DepositKey and RefundKey carry a private _domain: KeyDomain field set by DepositKey::new(...) / RefundKey::new(...). Struct-literal construction is now a compile error, so callers can't bypass the
    tag.
  • hash() reads self._domain instead of a hardcoded constant.

Implementation notes:

  • The bcs dependency moved from offchain-only to unconditional in linera-bridge/Cargo.toml. The new parse_burn_blocked_event decodes AccountOwner under the chain feature so the Linera-side Wasm
    contract can verify refund proofs.
  • The bridge service exposes a new isRefundProcessed(refundHash: ...) GraphQL field that mirrors isDepositProcessed and the relayer polls for completion.
  • A new refund_completed IntCounter metric (mirrors burn_completed / deposit_completed).
  • Two pre-existing breakages on the parent branch were fixed in passing: stale BridgeParameters.rpc_endpoint fields in process_deposit.rs + set_rpc_endpoint.rs, and a burnEvt.amount.value access in
    _releaseBurn that didn't compile against the regenerated codegen (uint128 amount is bare, not wrapped). These ship as separate commits.

Test Plan

  • forge test on linera-bridge/src/solidity — 22 cases pass (5 suites). Notable cases for this PR:

  • test_blockBurn_emits_BurnBlocked_with_decoded_fields — exercises all three AccountOwner variants (Reserved/CHAIN, Address32, Address20) through a new parametric MockLightClientWithOwner; each
    variant verifies source_owner_bcs round-trips through BridgeTypes.bcs_serialize_AccountOwner.

  • test_blockBurn_reverts_on_already_processedblockBurn after a successful processBurns reverts with "already processed"; blocked flag does not flip.

  • test_processBurns_reverts_on_blocked_positionprocessBurns([0, 1]) over a position that was blocked reverts with "burn blocked"; both positions stay unprocessed, no tokens released (atomic revert).

  • Same-intent idempotency: test_blockBurn_idempotent_emits_once, test_processBurns_already_processed_skips, test_blockBurn_prevents_addBlock_release (addBlock path still silently skips blocked).

  • test_blockBurn_chain_id_mismatch_reverts, _tx_index_out_of_range_reverts, _event_pos_out_of_range_reverts, _non_burn_event_reverts.

  • cargo test -p linera-bridge — 88 passing. cargo test -p linera-bridge --features relay — 145 passing. New tests: 5 cases in proof::burn_blocked (happy path, wrong address / topic / arity / malformed
    owner), RefundKey::hash determinism, and test_refund_and_deposit_key_hashes_are_domain_separated proving KeyDomain keeps the two key spaces disjoint even with identical field values.

  • cargo test -p evm-bridge — 17 passing including 4 new refund_burn.rs cases:

  • refund_burn_success_mints_to_source — happy path; processed_refunds flag set, Mint issued with decoded source + amount.

  • refund_burn_replay_rejected — second submission of the same proof panics with "refund already processed".

  • refund_burn_wrong_bridge_address — log address differs from registered.

  • refund_burn_wrong_topic — proof points at a DepositInitiated log.

  • cargo clippy --locked --all-targets --all-features -- -D warnings — clean.

  • e2e: linera-bridge/tests/e2e/tests/refund_after_block.rs (#[ignore]d like sibling tests). Sets up chain A as bridge host + mint chain, chain B as the burner. Owner_b burns wrapped tokens to an EVM address.
    Test driver calls blockBurn(cert, txIndex, eventPosInTx) before the relayer's addBlock lands. Asserts BurnBlocked decoded fields match the BurnEvent payload, then polls query_refund_processed until the
    relayer submits the refund. Final assertions: isBurnProcessed == false, isBurnBlocked == true, ERC-20 recipient balance unchanged, owner_b's chain-B balance restored to the pre-burn level, refunds_completed
    metric incremented.

Release Plan

  • These changes should be backported to the main branch.

Links

@deuszx deuszx force-pushed the worktree-blocked-burn-refund branch 2 times, most recently from 667fc82 to 5ecb50c Compare May 28, 2026 12:18
deuszx added 27 commits June 1, 2026 13:16
Also corrects pre-existing burnEvt.amount.value access in _releaseBurn —
codegen exposes BurnEvent.amount as a bare uint128, not a wrapped struct.
Add a new proof variant that proves a burn was blocked by validators,
enabling the refund path in the fungible bridge. The proof carries
the burn parameters and a validator-signed attestation that the burn
could not be executed on the destination chain.

- New BurnBlocked proof module under linera-bridge/src/proof/
- Wire the variant into the proof enum and verification dispatch
- Update fungible_bridge call sites to handle the new variant
- Add bcs to linera-bridge deps for proof serialization
EvmClient gains get_chain_id and get_burn_blocked_logs. MonitorState
holds the EVM source chain id, plus a refunds map mirroring the burn
state machine (track / complete / retry / fail) with matching metrics.
The EVM scan iteration now picks up BurnBlocked logs alongside deposits
and feeds them into track_refund using RefundKey built from the cached
chain id. SQLite persistence and the retry-loop branch land in Tasks
10 and 11.
Revert the variant rename — Process prefix is intentional and mirrors
BridgeOperation::Process*. Use #[allow(clippy::enum_variant_names)]
instead.
Replay protection is a cheap SetView containment check; running it
before the RPC finality call and MPT receipt-inclusion proof lets
duplicate refund proofs fail fast without paying for the expensive
crypto.
Mirror the reorder applied to process_refund — cheap SetView
containment check runs before the expensive finality + MPT proof so
duplicate proofs fail fast.
StatusSummary gains refunds_pending / refunds_completed / refunds_failed.
The EVM scan loop already drives the refund completion check; extend
its periodic trace! summary to report the refund counters so operators
can see refund flow in the bridge logs and dashboards.
The startup path queried evm_client.get_chain_id() once and bailed on
failure; a brief RPC outage at boot would kill the relayer. Bounded
exponential backoff (1s, 2s, 4s, 8s, 16s — ~31s total) lets a flaky
RPC recover before we give up while still surfacing a hard outage as
a clean startup error.
…ked emission

Add MockLightClientWithOwner with owner fields as immutable constructor
params so Reserved/CHAIN, Address32, and Address20 can each be exercised.
test_blockBurn_emits_BurnBlocked_with_decoded_fields verifies the emitted
source_owner_bcs matches bcs_serialize_AccountOwner for every variant.
Add KeyDomain enum (Deposit=0x01, Refund=0x02) whose variants are the
single source of truth for hash domain tags. DepositKey and RefundKey
each carry a private _domain field set in their new() constructors —
struct-literal construction is now a compile error, so a future key
type cannot forget to assign a tag or accidentally reuse one.

hash() reads self._domain instead of a local constant.
Silent no-ops masked caller-intent mismatches: a blockBurn against an
already-processed burn used to succeed quietly, and processBurns across
a blocked position used to skip silently. Both now revert.
@deuszx deuszx force-pushed the worktree-blocked-burn-refund branch from 56190bf to 44070ae Compare June 1, 2026 11:31
Field is already non-pub; the leading underscore added nothing.
@deuszx deuszx marked this pull request as ready for review June 1, 2026 12:49
@deuszx deuszx marked this pull request as draft June 1, 2026 14:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant