diff --git a/benches/benches.rs b/benches/benches.rs index 7c7798f3d..3f8538e4a 100644 --- a/benches/benches.rs +++ b/benches/benches.rs @@ -3,6 +3,54 @@ use criterion::{criterion_group, criterion_main, Criterion}; use electrs::new_index::schema::bench::*; use std::hint::black_box; +fn witness_proof_benchmark(c: &mut Criterion) { + use bitcoin::hashes::{sha256d, Hash}; + use electrs::util::electrum_merkle::create_merkle_branch_and_root; + + // CPU cost of a witness-merkle proof over a real mainnet block + // (block 702861: ~1000 txs): wtxid computation for every tx (the coinbase + // leaf is zeroed per BIP-141) plus branch construction. The DB-lookup cost + // of fetching the block's transactions is deployment-dependent and equals + // the existing GET /block/:hash/raw path (same lookup_txns batch). + let block_bytes = bitcoin_test_data::blocks::mainnet_702861(); + let block = Block::consensus_decode(&mut &block_bytes[..]).unwrap(); + + c.bench_function("witness_proof_wtxids_block_702861", |b| { + b.iter(|| { + let wtxids: Vec = block + .txdata + .iter() + .enumerate() + .map(|(i, tx)| { + if i == 0 { + sha256d::Hash::all_zeros() + } else { + tx.compute_wtxid().to_raw_hash() + } + }) + .collect(); + black_box(wtxids) + }) + }); + + c.bench_function("witness_proof_branch_block_702861", |b| { + let wtxids: Vec = block + .txdata + .iter() + .enumerate() + .map(|(i, tx)| { + if i == 0 { + sha256d::Hash::all_zeros() + } else { + tx.compute_wtxid().to_raw_hash() + } + }) + .collect(); + let mid = wtxids.len() / 2; + b.iter(|| black_box(create_merkle_branch_and_root(wtxids.clone(), mid))) + }); +} + fn criterion_benchmark(c: &mut Criterion) { c.bench_function("add_blocks", |b| { let block_bytes = bitcoin_test_data::blocks::mainnet_702861(); @@ -14,5 +62,5 @@ fn criterion_benchmark(c: &mut Criterion) { }); } -criterion_group!(benches, criterion_benchmark); +criterion_group!(benches, criterion_benchmark, witness_proof_benchmark); criterion_main!(benches); diff --git a/src/electrum/server.rs b/src/electrum/server.rs index 045ccf185..4bb816ab9 100644 --- a/src/electrum/server.rs +++ b/src/electrum/server.rs @@ -23,6 +23,8 @@ use crate::electrum::{get_electrum_height, ProtocolVersion}; use crate::errors::*; use crate::metrics::{Gauge, HistogramOpts, HistogramVec, MetricOpts, Metrics}; use crate::new_index::{Query, Utxo}; +#[cfg(not(feature = "liquid"))] +use crate::util::electrum_merkle::get_tx_witness_merkle_proof; use crate::util::electrum_merkle::{get_header_merkle_proof, get_id_from_pos, get_tx_merkle_proof}; use crate::util::{create_socket, spawn_thread, BlockId, BoolThen, Channel, FullHash, HeaderEntry}; @@ -525,6 +527,33 @@ impl Connection { })) } + /// Witness-tree twin of get_merkle: proves the tx against the block's + /// BIP-141 witness merkle tree (wtxids, zeroed coinbase leaf), binding the + /// full serialization including witness data. + #[cfg(not(feature = "liquid"))] + #[trace] + fn blockchain_transaction_get_witness_merkle(&self, params: &[Value]) -> Result { + let txid = Txid::from(hash_from_value(params.get(0))?); + let height = usize_from_value(params.get(1), "height")?; + let blockid = self + .query + .chain() + .tx_confirming_block(&txid) + .ok_or_else(|| "tx not found or is unconfirmed")?; + if blockid.height != height { + return Err(invalid_params("invalid confirmation height provided")); + } + let (merkle, pos, witness_root) = + get_tx_witness_merkle_proof(self.query.chain(), &txid, &blockid.hash) + .chain_err(|| "cannot create witness merkle proof")?; + Ok(json!({ + "block_height": blockid.height, + "merkle": merkle, + "pos": pos, + "witness_root": witness_root, + })) + } + fn blockchain_transaction_id_from_pos(&self, params: &[Value]) -> Result { let height = usize_from_value(params.get(0), "height")?; let tx_pos = usize_from_value(params.get(1), "tx_pos")?; @@ -569,6 +598,10 @@ impl Connection { } "blockchain.transaction.get" => self.blockchain_transaction_get(¶ms), "blockchain.transaction.get_merkle" => self.blockchain_transaction_get_merkle(¶ms), + #[cfg(not(feature = "liquid"))] + "blockchain.transaction.get_witness_merkle" => { + self.blockchain_transaction_get_witness_merkle(¶ms) + } "blockchain.transaction.id_from_pos" => { self.blockchain_transaction_id_from_pos(¶ms) } diff --git a/src/new_index/schema.rs b/src/new_index/schema.rs index 23193ba2d..e6b649193 100644 --- a/src/new_index/schema.rs +++ b/src/new_index/schema.rs @@ -579,6 +579,32 @@ impl ChainQuery { } } + /// Leaves of the block's BIP-141 witness tree: the coinbase leaf is the + /// zero hash (its wtxid is defined as 0x00…00 for commitment purposes), + /// every other leaf is the transaction's wtxid. + #[cfg(not(feature = "liquid"))] + pub fn get_block_wtxids( + &self, + hash: &BlockHash, + ) -> Result> { + use bitcoin::hashes::Hash; + let _timer = self.start_timer("get_block_wtxids"); + let txids = self.get_block_txids(hash).chain_err(|| "block not found")?; + let txids_with_blockhash: Vec<_> = txids.into_iter().map(|txid| (txid, *hash)).collect(); + let txs = self.lookup_txns(&txids_with_blockhash)?; + Ok(txs + .iter() + .enumerate() + .map(|(i, tx)| { + if i == 0 { + bitcoin::hashes::sha256d::Hash::all_zeros() + } else { + tx.compute_wtxid().to_raw_hash() + } + }) + .collect()) + } + pub fn get_block_txs( &self, hash: &BlockHash, diff --git a/src/rest.rs b/src/rest.rs index 850a3a79a..46838b20a 100644 --- a/src/rest.rs +++ b/src/rest.rs @@ -1013,6 +1013,29 @@ fn handle_request( ) } #[cfg(not(feature = "liquid"))] + (&Method::GET, Some(&"tx"), Some(hash), Some(&"witness-merkle-proof"), None, None) => { + let hash = Txid::from_str(hash)?; + let blockid = query.chain().tx_confirming_block(&hash).ok_or_else(|| { + HttpError::not_found("Transaction not found or is unconfirmed".to_string()) + })?; + let (merkle, pos, witness_root) = electrum_merkle::get_tx_witness_merkle_proof( + query.chain(), + &hash, + &blockid.hash, + )?; + let merkle: Vec = merkle.into_iter().map(|hash| hash.to_string()).collect(); + let ttl = ttl_by_depth(Some(blockid.height), query); + json_response( + json!({ + "block_height": blockid.height, + "merkle": merkle, + "pos": pos, + "witness_root": witness_root.to_string(), + }), + ttl, + ) + } + #[cfg(not(feature = "liquid"))] (&Method::GET, Some(&"tx"), Some(hash), Some(&"merkleblock-proof"), None, None) => { let hash = Txid::from_str(hash)?; diff --git a/src/util/electrum_merkle.rs b/src/util/electrum_merkle.rs index 52e0a825a..660005309 100644 --- a/src/util/electrum_merkle.rs +++ b/src/util/electrum_merkle.rs @@ -24,6 +24,32 @@ pub fn get_tx_merkle_proof( Ok((branch, pos)) } +/// Merkle proof of a transaction's inclusion in its block's WITNESS tree +/// (wtxids, coinbase leaf zeroed per BIP-141): the tree committed to by the +/// coinbase witness commitment. Together with a txid proof of the coinbase, +/// this binds a transaction's full serialization (witness included) to the +/// block header, which a txid proof alone cannot do for segwit data. +/// Returns (branch, position, witness_merkle_root). +#[cfg(not(feature = "liquid"))] +#[trace] +pub fn get_tx_witness_merkle_proof( + chain: &ChainQuery, + tx_hash: &Txid, + block_hash: &BlockHash, +) -> Result<(Vec, usize, Sha256dHash)> { + let txids = chain + .get_block_txids(block_hash) + .chain_err(|| format!("missing block txids for #{}", block_hash))?; + let pos = txids + .iter() + .position(|txid| txid == tx_hash) + .chain_err(|| format!("missing txid {}", tx_hash))?; + + let wtxids = chain.get_block_wtxids(block_hash)?; + let (branch, root) = create_merkle_branch_and_root(wtxids, pos); + Ok((branch, pos, root)) +} + #[trace] pub fn get_header_merkle_proof( chain: &ChainQuery, @@ -87,7 +113,7 @@ fn merklize(left: Sha256dHash, right: Sha256dHash) -> Sha256dHash { Sha256dHash::hash(&data) } -fn create_merkle_branch_and_root( +pub fn create_merkle_branch_and_root( mut hashes: Vec, mut index: usize, ) -> (Vec, Sha256dHash) { diff --git a/tests/electrum.rs b/tests/electrum.rs index 3f064f575..4fa08bcdf 100644 --- a/tests/electrum.rs +++ b/tests/electrum.rs @@ -211,6 +211,46 @@ fn test_electrum_raw() { assert_eq!(s, expected); } +#[cfg_attr(not(feature = "liquid"), test)] +#[cfg_attr(feature = "liquid", allow(dead_code))] +fn test_electrum_get_witness_merkle() { + let (_electrum_server, electrum_addr, mut tester) = common::init_electrum_tester().unwrap(); + + let addr = tester.newaddress().unwrap(); + let txid = tester.send(&addr, "0.31 BTC".parse().unwrap()).unwrap(); + tester.mine().unwrap(); + let height = tester.get_block_count().unwrap(); + + let mut stream = TcpStream::connect(electrum_addr).unwrap(); + let req = format!( + "{{\"jsonrpc\": \"2.0\", \"method\": \"blockchain.transaction.get_witness_merkle\", \"params\": [\"{}\", {}], \"id\": 1}}", + txid, height + ); + let s = write_and_read(&mut stream, &req); + let v: ::serde_json::Value = ::serde_json::from_str(&s).unwrap(); + assert!(v["error"].is_null(), "unexpected error: {}", s); + let result = &v["result"]; + assert_eq!(result["block_height"].as_u64(), Some(height)); + assert!(result["pos"].as_u64().unwrap() > 0); + let root = result["witness_root"].as_str().expect("witness_root"); + assert_eq!(root.len(), 64); + assert!(root.chars().all(|c| c.is_ascii_hexdigit())); + for entry in result["merkle"].as_array().expect("merkle array") { + let hex = entry.as_str().expect("merkle entry"); + assert_eq!(hex.len(), 64); + } + + // wrong height => invalid params error, mirroring get_merkle behavior + let req = format!( + "{{\"jsonrpc\": \"2.0\", \"method\": \"blockchain.transaction.get_witness_merkle\", \"params\": [\"{}\", {}], \"id\": 2}}", + txid, + height + 5 + ); + let s = write_and_read(&mut stream, &req); + let v: ::serde_json::Value = ::serde_json::from_str(&s).unwrap(); + assert!(!v["error"].is_null(), "expected invalid-height error, got: {}", s); +} + #[cfg_attr(not(feature = "liquid"), test)] #[cfg_attr(feature = "liquid", allow(dead_code))] fn test_electrum_jsonrpc_errors() { diff --git a/tests/rest.rs b/tests/rest.rs index 6d325f25a..b56160cf1 100644 --- a/tests/rest.rs +++ b/tests/rest.rs @@ -801,6 +801,94 @@ fn test_rest_tx_outspends() -> Result<()> { Ok(()) } +#[cfg(not(feature = "liquid"))] +#[test] +fn test_rest_tx_witness_merkle_proof() -> Result<()> { + use bitcoin::consensus::deserialize; + use bitcoin::hashes::sha256d; + use bitcoin::Transaction; + + // fold a merkle branch bottom-up, mirroring create_merkle_branch_and_root + fn fold_branch(mut hash: sha256d::Hash, branch: &[sha256d::Hash], mut pos: usize) -> sha256d::Hash { + for sibling in branch { + let concat = if pos % 2 == 0 { + [&hash[..], &sibling[..]].concat() + } else { + [&sibling[..], &hash[..]].concat() + }; + hash = sha256d::Hash::hash(&concat); + pos /= 2; + } + hash + } + fn parse_branch(res: &Value) -> Vec { + res["merkle"] + .as_array() + .expect("merkle array") + .iter() + .map(|v| v.as_str().unwrap().parse::().unwrap()) + .collect() + } + + let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); + + let addr1 = tester.newaddress()?; + let txid = tester.send(&addr1, "0.5 BTC".parse().unwrap())?; + tester.mine()?; + let mine_height = tester.get_block_count()?; + + let res = get_json(rest_addr, &format!("/tx/{}/witness-merkle-proof", txid))?; + assert_eq!(res["block_height"].as_u64(), Some(mine_height)); + let pos = res["pos"].as_u64().expect("pos") as usize; + assert!(pos > 0, "wallet tx cannot be the coinbase"); + let witness_root: sha256d::Hash = res["witness_root"] + .as_str() + .expect("witness_root string") + .parse() + .expect("valid hash"); + + // the tx's own wtxid must fold through the branch to the witness root + let tx_hex = get_plain(rest_addr, &format!("/tx/{}/hex", txid))?; + let tx: Transaction = + deserialize(&Vec::from_hex(&tx_hex).expect("hex")).expect("tx deserialize"); + assert!(tx.input.iter().any(|i| !i.witness.is_empty()), "test tx should be segwit"); + let folded = fold_branch(tx.compute_wtxid().to_raw_hash(), &parse_branch(&res), pos); + assert_eq!(folded, witness_root, "branch must fold to the witness root"); + + // the coinbase's proof uses the ZEROED leaf (BIP-141) and folds to the same root + let block_hash = get_plain(rest_addr, &format!("/block-height/{}", mine_height))?; + let txids = get_json(rest_addr, &format!("/block/{}/txids", block_hash))?; + let coinbase_txid = txids.as_array().expect("txids")[0].as_str().unwrap().to_string(); + let cb_res = get_json(rest_addr, &format!("/tx/{}/witness-merkle-proof", coinbase_txid))?; + assert_eq!(cb_res["pos"].as_u64(), Some(0)); + assert_eq!(cb_res["witness_root"].as_str().unwrap(), witness_root.to_string()); + let cb_folded = fold_branch(sha256d::Hash::all_zeros(), &parse_branch(&cb_res), 0); + assert_eq!(cb_folded, witness_root, "zeroed coinbase leaf must fold to the witness root"); + + // and the root must be the one committed in the coinbase (BIP-141): + // commitment output pushes sha256d(witness_root || witness_reserved_value) + let cb_hex = get_plain(rest_addr, &format!("/tx/{}/hex", coinbase_txid))?; + let coinbase: Transaction = + deserialize(&Vec::from_hex(&cb_hex).expect("hex")).expect("coinbase deserialize"); + let commitment_script = coinbase + .output + .iter() + .map(|o| o.script_pubkey.as_bytes()) + .find(|s| s.len() >= 38 && s[0..6] == [0x6a, 0x24, 0xaa, 0x21, 0xa9, 0xed]) + .expect("coinbase must carry a BIP-141 witness commitment"); + let reserved = &coinbase.input[0].witness[0]; + let expected = sha256d::Hash::hash(&[&witness_root[..], &reserved[..]].concat()); + assert_eq!(&commitment_script[6..38], &expected[..], "witness root must match the coinbase commitment"); + + // unconfirmed/unknown tx: 404 + let bogus = "0000000000000000000000000000000000000000000000000000000000000001"; + let err = get(rest_addr, &format!("/tx/{}/witness-merkle-proof", bogus)); + assert!(err.is_err(), "unknown txid should 404"); + + rest_handle.stop(); + Ok(()) +} + #[test] fn test_rest_tx_merkle_proof() -> Result<()> { let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap();