Skip to content

Conversation

@jolestar
Copy link
Contributor

Summary

This PR implements Bitcoin header-only import mode for Rooch, significantly reducing state bloat by skipping transaction processing and UTXO object creation. It also adds Merkle proof verification to allow on-demand transaction validation.

Closes #3923

Changes

Core Modifications

  1. Modified execute_l1_block in bitcoin.move

    • Now only processes block headers, skipping pending_block and transaction processing
    • Directly calls process_block_header instead of going through the full block processing pipeline
    • Maintains timestamp updates for network time synchronization
  2. Added execute_l1_block_header entry function

    • New public entry function for future Relayer use
    • Accepts only block header bytes instead of full block data
    • Provides a clean interface for header-only synchronization

Merkle Proof Verification

  1. Created merkle_proof.move module

    • verify_merkle_proof(): Verifies a Merkle proof against a known root
    • verify_tx_in_block(): Verifies a transaction is included in a specific block
    • Includes unit test for basic Merkle tree verification
  2. Added Merkle proof data structures to types.move

    • ProofNode: Represents a node in the Merkle proof path
    • MerkleProof: Contains the full proof path from transaction to merkle_root
    • Helper functions for construction and access
  3. Added sha256d_concat to bitcoin_hash.move

    • Concatenates two hashes and computes double SHA256
    • Essential for Merkle tree verification

On-Demand Verification

  1. Implemented submit_tx_with_proof entry function
    • Minimal version that verifies transaction inclusion via Merkle proof
    • Emits TxVerifiedEvent upon successful verification
    • Future versions will support UTXO/Inscription creation

Impact

State Reduction

  • Before: Every Bitcoin transaction creates/destroys UTXO objects
  • After: No UTXO objects created automatically, only block headers stored
  • Expected savings: Hundreds of GB of state data

Affected Features

  • UTXO queries will no longer work automatically (requires indexer or Bitcoin node)
  • Inscription indexing will no longer work automatically
  • Time-based features continue to work (headers contain timestamps)

Testing

All existing tests pass (64 tests):

test result: ok. 64 passed; 0 failed; 0 ignored; 0 measured

Next Steps

After this PR is merged:

  1. Framework release and genesis upgrade
  2. Implement [rooch-relayer] Header-only mode for Bitcoin block sync #3924 (Rust Relayer optimization to fetch only headers)
  3. Add more comprehensive Merkle proof tests
  4. Implement UTXO/Inscription creation in submit_tx_with_proof

Related Issues

…ification

- Modified execute_l1_block to skip transaction processing and only process block headers
- Added execute_l1_block_header entry function for future Relayer use
- Created merkle_proof.move module with verify_tx_in_block and verify_merkle_proof functions
- Added MerkleProof and ProofNode data structures to types.move
- Added sha256d_concat helper function to bitcoin_hash.move
- Implemented submit_tx_with_proof entry function for minimal transaction verification
- Significantly reduces state bloat by not creating UTXO objects automatically

Closes #3923
Copilot AI review requested due to automatic review settings January 10, 2026 09:53
@vercel
Copy link

vercel bot commented Jan 10, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
rooch-portal-v2.1 Ready Ready Preview, Comment Jan 11, 2026 1:06am
test-portal Ready Ready Preview, Comment Jan 11, 2026 1:06am
1 Skipped Deployment
Project Deployment Review Updated (UTC)
rooch Ignored Ignored Preview Jan 11, 2026 1:06am

@github-actions
Copy link

github-actions bot commented Jan 10, 2026

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements Bitcoin header-only import for Rooch, significantly reducing state bloat by skipping transaction processing and UTXO object creation. It introduces Merkle proof verification to enable on-demand transaction validation after headers have been synced.

Changes:

  • Modified execute_l1_block to process only block headers, bypassing pending blocks and transaction processing
  • Added execute_l1_block_header entry function for future relayer use to submit only header bytes
  • Implemented Merkle proof verification module with verify_merkle_proof and verify_tx_in_block functions
  • Added submit_tx_with_proof entry function for on-demand transaction verification via Merkle proofs

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.

File Description
frameworks/bitcoin-move/sources/bitcoin.move Refactored execute_l1_block to skip transaction processing; added execute_l1_block_header entry function and submit_tx_with_proof for on-demand verification
frameworks/bitcoin-move/sources/merkle_proof.move New module implementing Merkle proof verification logic with helper functions and basic test
frameworks/bitcoin-move/sources/types.move Added ProofNode and MerkleProof data structures with accessor functions
frameworks/bitcoin-move/sources/bitcoin_hash.move Added sha256d_concat function for Merkle tree hash computation

Comment on lines 11 to 12
const ErrorInvalidProof: u64 = 1;
const ErrorBlockNotFound: u64 = 2;
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error constants ErrorInvalidProof and ErrorBlockNotFound are defined but never used in the module. Consider either using these constants in the verification functions (e.g., in verify_tx_in_block when the block is not found, or in assertions when proofs are invalid), or removing them if they're not needed.

Copilot uses AI. Check for mistakes.
Comment on lines 263 to 301
fun execute_l1_block(block_height: u64, block_hash: address, block_bytes: vector<u8>){
let btc_block_store_obj = borrow_block_store();
let btc_block_store = object::borrow(btc_block_store_obj);
let btc_block_store_obj = borrow_block_store_mut();
let btc_block_store = object::borrow_mut(btc_block_store_obj);

assert!(!table::contains(&btc_block_store.height_to_hash, block_height), ErrorReorgTooDeep);

let block = bcs::from_bytes<Block>(block_bytes);
let block_header = types::header(&block);
let time = types::time(block_header);
if(pending_block::add_pending_block(block_height, block_hash, block)){
//We do not update the timestamp via bitcoin block header in testnet
//Because the testnet block time is not accurate
if(!chain_id::is_test()){
//We directly update the global time do not wait the pending block to be confirmed
//The reorg do not affect the global time
let timestamp_seconds = (time as u64);
let module_signer = signer::module_signer<BitcoinBlockStore>();
timestamp::try_update_global_time(&module_signer, timestamp::seconds_to_milliseconds(timestamp_seconds));
}
};
let block_header = *types::header(&block);

// Directly process block header, skip pending_block and transaction processing
process_block_header(btc_block_store, block_height, block_hash, block_header);

// Update global time
if(!chain_id::is_test()){
let timestamp_seconds = (types::time(&block_header) as u64);
let module_signer = signer::module_signer<BitcoinBlockStore>();
timestamp::try_update_global_time(&module_signer, timestamp::seconds_to_milliseconds(timestamp_seconds));
}
}

/// Entry function for future use by Relayer to submit only block headers
public entry fun execute_l1_block_header(block_height: u64, block_hash: address, header_bytes: vector<u8>){
let btc_block_store_obj = borrow_block_store_mut();
let btc_block_store = object::borrow_mut(btc_block_store_obj);

assert!(!table::contains(&btc_block_store.height_to_hash, block_height), ErrorReorgTooDeep);

let header = bcs::from_bytes<Header>(header_bytes);

// Process block header
process_block_header(btc_block_store, block_height, block_hash, header);

// Update global time
if(!chain_id::is_test()){
let timestamp_seconds = (types::time(&header) as u64);
let module_signer = signer::module_signer<BitcoinBlockStore>();
timestamp::try_update_global_time(&module_signer, timestamp::seconds_to_milliseconds(timestamp_seconds));
}
}
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The functions execute_l1_block and execute_l1_block_header contain significant code duplication. Both check height_to_hash, call process_block_header, and update global time with identical logic. Consider extracting the common logic into a helper function to improve maintainability and reduce the risk of inconsistencies between the two functions.

Copilot uses AI. Check for mistakes.
Comment on lines 455 to 458
assert!(
bitcoin_move::merkle_proof::verify_tx_in_block(block_hash, &tx, &proof),
ErrorBlockProcessError
);
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The submit_tx_with_proof function uses ErrorBlockProcessError which is a generic error code that doesn't clearly indicate the issue is with Merkle proof verification. Consider defining a more specific error constant like ErrorInvalidMerkleProof to provide clearer error messages to callers.

Copilot uses AI. Check for mistakes.
Comment on lines 446 to 465
public entry fun submit_tx_with_proof(
block_hash: address,
tx_bytes: vector<u8>,
proof_bytes: vector<u8>,
) {
let tx = bcs::from_bytes<Transaction>(tx_bytes);
let proof = bcs::from_bytes<types::MerkleProof>(proof_bytes);

// Verify Merkle proof
assert!(
bitcoin_move::merkle_proof::verify_tx_in_block(block_hash, &tx, &proof),
ErrorBlockProcessError
);

// Emit verification success event
event::emit(TxVerifiedEvent {
block_hash,
txid: types::tx_id(&tx),
});
}
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new entry function submit_tx_with_proof lacks test coverage. Given that the repository has comprehensive testing for other modules (64 tests pass), and this function introduces critical new functionality for on-demand transaction verification, it should include at least basic test coverage to verify correct behavior with valid proofs and error handling with invalid proofs.

Copilot uses AI. Check for mistakes.
Comment on lines +31 to +55
public fun verify_merkle_proof(
tx_hash: address,
merkle_root: address,
proof: &MerkleProof
): bool {
let current_hash = tx_hash;
let proof_nodes = types::proof_nodes(proof);
let i = 0;
let len = vector::length(proof_nodes);

while (i < len) {
let node = vector::borrow(proof_nodes, i);
let sibling_hash = types::proof_node_hash(node);
let is_left = types::proof_node_is_left(node);

current_hash = if (is_left) {
bitcoin_hash::sha256d_concat(sibling_hash, current_hash)
} else {
bitcoin_hash::sha256d_concat(current_hash, sibling_hash)
};
i = i + 1;
};

current_hash == merkle_root
}
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The verify_merkle_proof function does not validate the proof length or handle the edge case where an empty proof is provided. An empty proof would result in returning tx_hash == merkle_root, which could allow a transaction to be falsely verified if the transaction hash matches the merkle root (e.g., a single-transaction block). Consider adding validation to ensure the proof is non-empty or explicitly handle the single-transaction case.

Copilot uses AI. Check for mistakes.
Comment on lines +57 to +73
#[test]
fun test_verify_merkle_proof() {
// Simple test with a 2-transaction Merkle tree
// Root = hash(hash(tx1) || hash(tx2))
let tx1_hash = @0x1111111111111111111111111111111111111111111111111111111111111111;
let tx2_hash = @0x2222222222222222222222222222222222222222222222222222222222222222;

// Calculate expected root
let expected_root = bitcoin_hash::sha256d_concat(tx1_hash, tx2_hash);

// Proof for tx1: sibling is tx2 (on the right)
let proof_nodes = vector::empty();
vector::push_back(&mut proof_nodes, types::new_proof_node(tx2_hash, false));
let proof = types::new_merkle_proof(proof_nodes);

assert!(verify_merkle_proof(tx1_hash, expected_root, &proof), 1);
}
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test test_verify_merkle_proof only tests the happy path with a simple 2-transaction Merkle tree. Given the security-critical nature of Merkle proof verification, the test coverage should be expanded to include: (1) empty proof validation, (2) incorrect proof verification (should fail), (3) wrong sibling position handling, and (4) multi-level Merkle tree verification.

Copilot uses AI. Check for mistakes.
Comment on lines 283 to 300
/// Entry function for future use by Relayer to submit only block headers
public entry fun execute_l1_block_header(block_height: u64, block_hash: address, header_bytes: vector<u8>){
let btc_block_store_obj = borrow_block_store_mut();
let btc_block_store = object::borrow_mut(btc_block_store_obj);

assert!(!table::contains(&btc_block_store.height_to_hash, block_height), ErrorReorgTooDeep);

let header = bcs::from_bytes<Header>(header_bytes);

// Process block header
process_block_header(btc_block_store, block_height, block_hash, header);

// Update global time
if(!chain_id::is_test()){
let timestamp_seconds = (types::time(&header) as u64);
let module_signer = signer::module_signer<BitcoinBlockStore>();
timestamp::try_update_global_time(&module_signer, timestamp::seconds_to_milliseconds(timestamp_seconds));
}
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

execute_l1_block_header is a public entry that allows any caller to mutate the global BitcoinBlockStore and indirectly update the system time without any authorization or validation that the submitted header is part of the canonical Bitcoin chain. An attacker can submit arbitrary header_bytes (with a forged time, prev_blockhash, and merkle_root) for any unused block_height, causing process_block_header to record fake headers and timestamp::try_update_global_time to advance global time based on attacker-controlled data. This breaks the trust model for Bitcoin headers and time-based logic across the system; this entry function should be restricted to a trusted relayer/sequencer (e.g., via signer-based checks or system-only access) and perform proper header validation before updating state or global time.

Copilot uses AI. Check for mistakes.
- Fixed Move syntax error: removed 'mut' keyword in bitcoin_hash.move
- Removed execute_l1_block_header function due to security concerns (no authorization check)
- Fixed circular dependency between merkle_proof and bitcoin modules
- Added comprehensive Merkle proof tests:
  * Empty proof validation
  * Incorrect proof verification (should fail)
  * Wrong sibling position handling
  * Multi-level Merkle tree verification
- Added ErrorBlockNotFound error constant
- Cleaned up unused imports
Move tests:
- Update execute_l1_block_for_test to only process headers
- Add comprehensive Merkle proof tests

Rust tests:
- Simplify bitcoin_test.rs to only test header processing
- Remove check_utxo, test_block_process, test_real_bocks functions
- Update binding_test.rs:
  - execute_l1_block now returns empty Vec
  - execute_l1_block_and_tx now returns empty results

Note: bitcoin_block_tester.rs will need separate handling as it
relies on full transaction processing for UTXO/inscription verification
- Remove unused tracing::info import from binding_test.rs
- All tests passing (Move and Rust)
Added @ignore tags to integration test scenarios that rely on Bitcoin
UTXO creation, which is no longer supported in header-only mode:

- multisign.feature: multisign_account scenario
- bitcoin.feature: all 4 scenarios
  - rooch bitcoin test
  - rooch bitcoin_reorg_test
  - rooch bitcoin_reorg_failed_test
  - rooch bitcoin api test

These tests will need to be updated or rewritten to work with the
new header-only import mode and on-demand transaction verification.
Renamed feature files to .bak extension to properly disable them:
- bitcoin.feature -> bitcoin.feature.bak
- multisign.feature -> multisign.feature.bak

The @ignore tag approach doesn't work in this project's test setup.
Using .bak extension follows the existing pattern (bitseed.feature.bak,
ord.feature.bak) for disabled tests.
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.

[bitcoin-move] Header-only import and Merkle proof verification

2 participants