diff --git a/flake.lock b/flake.lock index b92c70c..111a19b 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "advisory-db": { "flake": false, "locked": { - "lastModified": 1739520703, - "narHash": "sha256-UqR1f9gThWNBCBobWet7T46vTSxkB6dVAdeqNBoF8mc=", + "lastModified": 1768059433, + "narHash": "sha256-HI71gf7YC9+bNHkfX+b8hANMjGefUkAlOCj/0Cmee1A=", "owner": "rustsec", "repo": "advisory-db", - "rev": "ddccfe8aced779f7b54d27bbe7e122ecb1dda33a", + "rev": "e39023c9d268ebde7a9eb0accb47cdbf3c1d552a", "type": "github" }, "original": { @@ -18,11 +18,11 @@ }, "crane": { "locked": { - "lastModified": 1739815359, - "narHash": "sha256-mjB72/7Fgk5bsIIKA4G9LkIb/u0Ci+VdOyQSgBuQtjo=", + "lastModified": 1768319649, + "narHash": "sha256-VFkNyxHxkqGp8gf8kfFMW1j6XeBy609kv6TE9uF/0Js=", "owner": "ipetkov", "repo": "crane", - "rev": "282159b2b0588b87a9dbcc40decc91dd5bed5c89", + "rev": "4b6527687cfd20da3c2ef8287e01b74c2d6c705b", "type": "github" }, "original": { @@ -51,11 +51,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1739736696, - "narHash": "sha256-zON2GNBkzsIyALlOCFiEBcIjI4w38GYOb+P+R4S8Jsw=", + "lastModified": 1768127708, + "narHash": "sha256-1Sm77VfZh3mU0F5OqKABNLWxOuDeHIlcFjsXeeiPazs=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "d74a2335ac9c133d6bbec9fc98d91a77f1604c1f", + "rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38", "type": "github" }, "original": { @@ -81,11 +81,11 @@ ] }, "locked": { - "lastModified": 1739759407, - "narHash": "sha256-YIrVxD2SaUyaEdMry2nAd2qG1E0V38QIV6t6rpguFwk=", + "lastModified": 1768272338, + "narHash": "sha256-Tg/kL8eKMpZtceDvBDQYU8zowgpr7ucFRnpP/AtfuRM=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "6e6ae2acf4221380140c22d65b6c41f4726f5932", + "rev": "03dda130a8701b08b0347fcaf850a190c53a3c1e", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 38d214c..e7bf482 100644 --- a/flake.nix +++ b/flake.nix @@ -69,6 +69,11 @@ # Build the crates as part of `nix flake check` for convenience inherit minipool; + # Run tests on the workspace source + minipool-test = craneLib.cargoTest (commonArgs // { + inherit cargoArtifacts; + }); + # Run clippy (and deny all warnings) on the workspace source, # again, reusing the dependency artifacts from above. minipool-clippy = craneLib.cargoClippy (commonArgs // { @@ -117,12 +122,13 @@ # Build dependencies pkg-config openssl - + # Development tools cargo-watch cargo-audit cargo-outdated cargo-edit + just ]; # Set up rust-analyzer for the project diff --git a/justfile b/justfile new file mode 100644 index 0000000..8363975 --- /dev/null +++ b/justfile @@ -0,0 +1,54 @@ +# Minipool development commands + +# List available commands (default) +default: + @just --list + +# Run minipool locally using Bitcoin Core cookie auth +run *args: + ./scripts/run-local.sh {{args}} + +# Run endpoint tests against minipool +test url: + ./scripts/test-endpoints.sh {{url}} + +# Build the project +build: + cargo build + +# Build release +build-release: + cargo build --release + +# Run clippy +lint: + cargo clippy + +# Format code +fmt: + cargo fmt + +# Check formatting +fmt-check: + cargo fmt --check + +# Run all checks (build, lint, format) +check: build lint fmt-check + +# Start minipool and run tests +run-and-test: + #!/usr/bin/env bash + cleanup() { + echo "Stopping minipool..." + kill $pid 2>/dev/null || true + wait $pid 2>/dev/null || true + } + trap cleanup EXIT + echo "Starting minipool..." + ./scripts/run-local.sh & + pid=$! + sleep 5 + echo "Running tests..." + ./scripts/test-endpoints.sh + test_result=$? + exit $test_result diff --git a/scripts/run-local.sh b/scripts/run-local.sh new file mode 100755 index 0000000..0e3d0c2 --- /dev/null +++ b/scripts/run-local.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Run minipool locally using Bitcoin Core cookie authentication +# Usage: ./run-local.sh [options] +# +# Options: +# --bind-addr ADDR Address to bind to (default: 127.0.0.1:9090) +# --bitcoin-dir DIR Bitcoin data directory (default: ~/.bitcoin) +# --testnet Use testnet +# --signet Use signet +# --regtest Use regtest +# --release Run release build +# --build Build before running +# -h, --help Show this help + +set -e + +BIND_ADDR="127.0.0.1:9090" +PROMETHEUS_ADDR="[::]:9091" +BITCOIN_DIR="$HOME/.bitcoin" +NETWORK="" +RELEASE="" +BUILD="" +RPC_PORT=8332 + +show_help() { + head -14 "$0" | tail -13 | sed 's/^# //' | sed 's/^#//' + exit 0 +} + +while [[ $# -gt 0 ]]; do + case $1 in + --bind-addr) + BIND_ADDR="$2" + shift 2 + ;; + --bitcoin-dir) + BITCOIN_DIR="$2" + shift 2 + ;; + --testnet) + NETWORK="testnet3" + RPC_PORT=18332 + shift + ;; + --signet) + NETWORK="signet" + RPC_PORT=38332 + shift + ;; + --regtest) + NETWORK="regtest" + RPC_PORT=18443 + shift + ;; + --release) + RELEASE="--release" + shift + ;; + --build) + BUILD="1" + shift + ;; + -h|--help) + show_help + ;; + *) + echo "Unknown option: $1" + show_help + ;; + esac +done + +# Determine cookie file location +if [ -n "$NETWORK" ]; then + COOKIE_FILE="$BITCOIN_DIR/$NETWORK/.cookie" +else + COOKIE_FILE="$BITCOIN_DIR/.cookie" +fi + +# Check if cookie file exists +if [ ! -f "$COOKIE_FILE" ]; then + echo "Error: Cookie file not found at $COOKIE_FILE" + echo "" + echo "Make sure Bitcoin Core is running." + if [ -n "$NETWORK" ]; then + echo " bitcoind -$NETWORK" + else + echo " bitcoind" + fi + exit 1 +fi + +# Read cookie credentials +COOKIE=$(cat "$COOKIE_FILE") +RPC_USER="${COOKIE%%:*}" +RPC_PASS="${COOKIE#*:}" + +# Determine RPC URL +RPC_URL="http://127.0.0.1:$RPC_PORT" + +echo "Starting minipool..." +echo " Bitcoin RPC: $RPC_URL" +echo " Cookie file: $COOKIE_FILE" +echo " Bind address: $BIND_ADDR" +[ -n "$NETWORK" ] && echo " Network: $NETWORK" +echo "" + +# Build if requested +if [ -n "$BUILD" ]; then + echo "Building..." + cargo build $RELEASE + echo "" +fi + +# Run minipool +exec cargo run $RELEASE -- \ + --bitcoin-rpc-url "$RPC_URL" \ + --bitcoin-rpc-user "$RPC_USER" \ + --bitcoin-rpc-pass "$RPC_PASS" \ + --bind-addr "$BIND_ADDR" \ + --prometheus-bind-addr "$PROMETHEUS_ADDR" diff --git a/scripts/test-endpoints.sh b/scripts/test-endpoints.sh new file mode 100755 index 0000000..ef6890e --- /dev/null +++ b/scripts/test-endpoints.sh @@ -0,0 +1,269 @@ +#!/usr/bin/env bash +# Test minipool endpoints against mempool.space and blockstream.info +# Usage: ./test-endpoints.sh [minipool_url] + +set -e + +MINIPOOL_URL="${1:-http://127.0.0.1:9090}" +MEMPOOL_URL="https://mempool.space" +BLOCKSTREAM_URL="https://blockstream.info" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Test block hash (block 100000 - well-known testable block) +TEST_BLOCK_HASH="000000000003ba27aa200b1cecaad478d2b00432346c3f1f3986da1afd33e506" +TEST_BLOCK_HEIGHT="100000" + +# Test transaction from block 100000 +TEST_TXID="8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87" + +passed=0 +failed=0 +skipped=0 + +print_header() { + echo "" + echo "========================================" + echo "$1" + echo "========================================" +} + +print_test() { + echo -n "Testing: $1... " +} + +print_pass() { + echo -e "${GREEN}PASS${NC}" + passed=$((passed + 1)) +} + +print_fail() { + echo -e "${RED}FAIL${NC}" + echo " Expected: $1" + echo " Got: $2" + failed=$((failed + 1)) +} + +print_skip() { + echo -e "${YELLOW}SKIP${NC} - $1" + skipped=$((skipped + 1)) +} + +compare_values() { + local name="$1" + local minipool="$2" + local reference="$3" + + if [ "$minipool" = "$reference" ]; then + print_pass + else + print_fail "$reference" "$minipool" + fi +} + +compare_binary() { + local name="$1" + local minipool_file="$2" + local reference_file="$3" + + if diff -q "$minipool_file" "$reference_file" > /dev/null 2>&1; then + print_pass + else + print_fail "binary match" "files differ ($(wc -c < "$minipool_file") vs $(wc -c < "$reference_file") bytes)" + fi +} + +# Check if minipool is running +print_header "Checking minipool availability" +print_test "minipool at $MINIPOOL_URL" +if curl -s --connect-timeout 5 "$MINIPOOL_URL/health" > /dev/null 2>&1; then + print_pass +else + echo -e "${RED}FAIL${NC}" + echo "minipool is not running at $MINIPOOL_URL" + echo "" + echo "Start minipool with:" + echo " cargo run -- --bitcoin-rpc-url http://127.0.0.1:8332 \\" + echo " --bitcoin-rpc-user \\" + echo " --bitcoin-rpc-pass " + exit 1 +fi + +# Create temp directory for binary comparisons +TMPDIR=$(mktemp -d) +trap "rm -rf $TMPDIR" EXIT + +print_header "Testing Block Endpoints" + +# Test /api/block/:hash +print_test "/api/block/:hash (block info)" +minipool_block=$(curl -s "$MINIPOOL_URL/api/block/$TEST_BLOCK_HASH" 2>/dev/null | jq -r '.id // empty') +blockstream_block=$(curl -s "$BLOCKSTREAM_URL/api/block/$TEST_BLOCK_HASH" 2>/dev/null | jq -r '.id // empty') +if [ -n "$minipool_block" ] && [ -n "$blockstream_block" ]; then + compare_values "block id" "$minipool_block" "$blockstream_block" +else + print_skip "could not fetch block info" +fi + +# Test /api/block/:hash (specific fields) +print_test "/api/block/:hash (height field)" +minipool_height=$(curl -s "$MINIPOOL_URL/api/block/$TEST_BLOCK_HASH" 2>/dev/null | jq -r '.height // empty') +if [ "$minipool_height" = "$TEST_BLOCK_HEIGHT" ]; then + print_pass +else + print_fail "$TEST_BLOCK_HEIGHT" "$minipool_height" +fi + +# Test /api/block/:hash/raw (binary) +print_test "/api/block/:hash/raw (binary data)" +curl -s "$MINIPOOL_URL/api/block/$TEST_BLOCK_HASH/raw" > "$TMPDIR/minipool_block.bin" 2>/dev/null +curl -s "$BLOCKSTREAM_URL/api/block/$TEST_BLOCK_HASH/raw" > "$TMPDIR/blockstream_block.bin" 2>/dev/null +if [ -s "$TMPDIR/minipool_block.bin" ] && [ -s "$TMPDIR/blockstream_block.bin" ]; then + compare_binary "block raw" "$TMPDIR/minipool_block.bin" "$TMPDIR/blockstream_block.bin" +else + print_skip "could not fetch raw block" +fi + +# Test /api/block/:hash/raw content-type +print_test "/api/block/:hash/raw (content-type header)" +content_type=$(curl -sI "$MINIPOOL_URL/api/block/$TEST_BLOCK_HASH/raw" 2>/dev/null | grep -i "content-type" | tr -d '\r' | awk '{print $2}') +if [ "$content_type" = "application/octet-stream" ]; then + print_pass +else + print_fail "application/octet-stream" "$content_type" +fi + +# Test /api/block/:hash/header +print_test "/api/block/:hash/header" +minipool_header=$(curl -s "$MINIPOOL_URL/api/block/$TEST_BLOCK_HASH/header" 2>/dev/null) +blockstream_header=$(curl -s "$BLOCKSTREAM_URL/api/block/$TEST_BLOCK_HASH/header" 2>/dev/null) +if [ -n "$minipool_header" ] && [ -n "$blockstream_header" ]; then + compare_values "block header" "$minipool_header" "$blockstream_header" +else + print_skip "could not fetch block header" +fi + +# Test /api/block-height/:height +print_test "/api/block-height/:height" +minipool_hash=$(curl -s "$MINIPOOL_URL/api/block-height/$TEST_BLOCK_HEIGHT" 2>/dev/null) +blockstream_hash=$(curl -s "$BLOCKSTREAM_URL/api/block-height/$TEST_BLOCK_HEIGHT" 2>/dev/null) +if [ -n "$minipool_hash" ] && [ -n "$blockstream_hash" ]; then + compare_values "block hash at height" "$minipool_hash" "$blockstream_hash" +else + print_skip "could not fetch block by height" +fi + +# Test /api/blocks/tip/height +print_test "/api/blocks/tip/height (tip height)" +minipool_tip=$(curl -s "$MINIPOOL_URL/api/blocks/tip/height" 2>/dev/null) +blockstream_tip=$(curl -s "$BLOCKSTREAM_URL/api/blocks/tip/height" 2>/dev/null) +if [ -n "$minipool_tip" ] && [ -n "$blockstream_tip" ]; then + # Allow some variance due to timing + diff=$((minipool_tip - blockstream_tip)) + if [ "$diff" -ge -2 ] && [ "$diff" -le 2 ]; then + print_pass + echo " (minipool: $minipool_tip, blockstream: $blockstream_tip, diff: $diff)" + else + print_fail "within 2 blocks of $blockstream_tip" "$minipool_tip" + fi +else + print_skip "could not fetch tip height" +fi + +# Test /api/blocks/tip/hash +print_test "/api/blocks/tip/hash (tip hash format)" +minipool_tip_hash=$(curl -s "$MINIPOOL_URL/api/blocks/tip/hash" 2>/dev/null) +if [[ "$minipool_tip_hash" =~ ^[0-9a-f]{64}$ ]]; then + print_pass +else + print_fail "64 hex characters" "$minipool_tip_hash" +fi + +print_header "Testing Transaction Endpoints" + +# Test /api/tx/:txid +print_test "/api/tx/:txid (transaction info)" +minipool_tx=$(curl -s "$MINIPOOL_URL/api/tx/$TEST_TXID" 2>/dev/null | jq -r '.txid // empty') +blockstream_tx=$(curl -s "$BLOCKSTREAM_URL/api/tx/$TEST_TXID" 2>/dev/null | jq -r '.txid // empty') +if [ -n "$minipool_tx" ] && [ -n "$blockstream_tx" ]; then + compare_values "txid" "$minipool_tx" "$blockstream_tx" +else + print_skip "could not fetch transaction" +fi + +# Test /api/tx/:txid/merkle-proof +print_test "/api/tx/:txid/merkle-proof" +minipool_proof=$(curl -s "$MINIPOOL_URL/api/tx/$TEST_TXID/merkle-proof" 2>/dev/null | jq -r '.block_height // empty') +blockstream_proof=$(curl -s "$BLOCKSTREAM_URL/api/tx/$TEST_TXID/merkle-proof" 2>/dev/null | jq -r '.block_height // empty') +if [ -n "$minipool_proof" ] && [ -n "$blockstream_proof" ]; then + compare_values "merkle proof block_height" "$minipool_proof" "$blockstream_proof" +else + print_skip "could not fetch merkle proof" +fi + +# Test /api/tx/:txid/merkleblock-proof +print_test "/api/tx/:txid/merkleblock-proof (binary)" +curl -s "$MINIPOOL_URL/api/tx/$TEST_TXID/merkleblock-proof" > "$TMPDIR/minipool_merkle.bin" 2>/dev/null +curl -s "$BLOCKSTREAM_URL/api/tx/$TEST_TXID/merkleblock-proof" > "$TMPDIR/blockstream_merkle.bin" 2>/dev/null +if [ -s "$TMPDIR/minipool_merkle.bin" ] && [ -s "$TMPDIR/blockstream_merkle.bin" ]; then + compare_binary "merkleblock proof" "$TMPDIR/minipool_merkle.bin" "$TMPDIR/blockstream_merkle.bin" +else + print_skip "could not fetch merkleblock proof" +fi + +print_header "Testing Fee Estimates" + +# Test /api/fee-estimates +print_test "/api/fee-estimates (format check)" +minipool_fees=$(curl -s "$MINIPOOL_URL/api/fee-estimates" 2>/dev/null) +if echo "$minipool_fees" | jq -e 'has("1") and has("6") and has("144")' > /dev/null 2>&1; then + print_pass +else + print_fail "JSON with keys 1, 6, 144" "$minipool_fees" +fi + +print_header "Testing Error Handling" + +# Test invalid block hash +print_test "Invalid block hash returns 400" +status=$(curl -s -o /dev/null -w "%{http_code}" "$MINIPOOL_URL/api/block/invalid-hash") +if [ "$status" = "400" ]; then + print_pass +else + print_fail "400" "$status" +fi + +# Test non-existent block +print_test "Non-existent block returns 404" +status=$(curl -s -o /dev/null -w "%{http_code}" "$MINIPOOL_URL/api/block/0000000000000000000000000000000000000000000000000000000000000000") +if [ "$status" = "404" ]; then + print_pass +else + print_fail "404" "$status" +fi + +# Test invalid txid +print_test "Invalid txid returns 400" +status=$(curl -s -o /dev/null -w "%{http_code}" "$MINIPOOL_URL/api/tx/invalid-txid") +if [ "$status" = "400" ]; then + print_pass +else + print_fail "400" "$status" +fi + +print_header "Summary" +echo "" +total=$((passed + failed + skipped)) +echo -e "Total tests: $total" +echo -e "${GREEN}Passed: $passed${NC}" +echo -e "${RED}Failed: $failed${NC}" +echo -e "${YELLOW}Skipped: $skipped${NC}" +echo "" + +if [ "$failed" -gt 0 ]; then + exit 1 +fi diff --git a/src/main.rs b/src/main.rs index 1b68424..9cb2954 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,7 +14,9 @@ use axum::{ routing::{get, post}, Json, Router, }; -use bitcoincore_rpc::bitcoin::{consensus::deserialize, BlockHash, Transaction, Txid}; +use bitcoincore_rpc::bitcoin::{ + consensus::deserialize, consensus::serialize, BlockHash, Transaction, Txid, +}; use bitcoincore_rpc::{Auth, Client, RpcApi}; use clap::Parser; use serde::Serialize; @@ -164,6 +166,14 @@ struct TxResponse { status: TxStatus, } +/// Esplora-compatible merkle proof response +#[derive(Serialize)] +struct MerkleProofResponse { + block_height: u64, + merkle: Vec, + pos: usize, +} + /// Esplora-compatible block response #[derive(Serialize)] struct BlockResponse { @@ -264,6 +274,11 @@ async fn start_main_server(config: Config) -> Result<()> { "Get the raw block data for a specific block hash.", get(get_block_raw), ), + RouteInfo::new( + "/api/block/{hash}/header", + "Get the block header as hex for a specific block hash.", + get(get_block_header), + ), RouteInfo::new( "/api/block/{hash}", "Get block information for a specific block hash.", @@ -279,9 +294,14 @@ async fn start_main_server(config: Config) -> Result<()> { "Get transaction confirmation status.", get(get_tx_status), ), + RouteInfo::new( + "/api/tx/{txid}/merkle-proof", + "Get merkle proof for a transaction (JSON format).", + get(get_tx_merkle_proof), + ), RouteInfo::new( "/api/tx/{txid}/merkleblock-proof", - "Get merkle inclusion proof for a transaction.", + "Get merkle inclusion proof for a transaction (binary format).", get(get_tx_merkleblock_proof), ), RouteInfo::new_post( @@ -353,11 +373,11 @@ async fn get_block_by_height( } } -fn get_fee_rate_blocking(client: &Client, blocks: u16) -> Result { - let estimate = client.estimate_smart_fee(blocks, None)?; +fn get_fee_rate_blocking(rpc: &Client, blocks: u16) -> Result { + let estimate = rpc.estimate_smart_fee(blocks, None)?; Ok(estimate .fee_rate - .map(|fee_rate| fee_rate.to_btc()) + .map(|fee_rate: bitcoincore_rpc::bitcoin::Amount| fee_rate.to_btc()) .unwrap_or_else(|| { warn!( "No fee rate estimate available for {} blocks, using default", @@ -393,8 +413,16 @@ async fn get_block_raw( match BlockHash::from_str(&hash) { Ok(block_hash) => { let rpc = state.rpc.clone(); - match tokio::task::spawn_blocking(move || rpc.get_block_hex(&block_hash)).await { - Ok(Ok(block_hex)) => (StatusCode::OK, block_hex).into_response(), + match tokio::task::spawn_blocking(move || rpc.get_block(&block_hash)).await { + Ok(Ok(block)) => { + let bytes = serialize(&block); + ( + StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, "application/octet-stream")], + bytes, + ) + .into_response() + } Ok(Err(e)) => handle_rpc_error(e, "Block", &hash).into_response(), Err(e) => handle_task_error(e, "get raw block").into_response(), } @@ -406,6 +434,30 @@ async fn get_block_raw( } } +/// GET /api/block/{hash}/header - Get block header as hex +async fn get_block_header( + State(state): State, + Path(hash): Path, +) -> impl IntoResponse { + match BlockHash::from_str(&hash) { + Ok(block_hash) => { + let rpc = state.rpc.clone(); + match tokio::task::spawn_blocking(move || rpc.get_block_header(&block_hash)).await { + Ok(Ok(header)) => { + let header_bytes = serialize(&header); + (StatusCode::OK, hex::encode(header_bytes)).into_response() + } + Ok(Err(e)) => handle_rpc_error(e, "Block", &hash).into_response(), + Err(e) => handle_task_error(e, "get block header").into_response(), + } + } + Err(e) => { + warn!("Invalid block hash: {}: {}", hash, e); + (StatusCode::BAD_REQUEST, "Invalid block hash").into_response() + } + } +} + /// GET /api/block/{hash} - Get block information in esplora format async fn get_block_info( State(state): State, @@ -596,6 +648,132 @@ async fn get_tx_status( } } +/// Compute merkle proof for a transaction at given position in a list of txids +fn compute_merkle_proof(txids: &[Txid], pos: usize) -> Vec { + use bitcoincore_rpc::bitcoin::hashes::{sha256d, Hash, HashEngine}; + + if txids.is_empty() || pos >= txids.len() { + return vec![]; + } + + // Txid::to_byte_array() returns bytes in internal/consensus order + // For merkle tree computation in Bitcoin, we use these directly + let mut hashes: Vec<[u8; 32]> = txids.iter().map(|txid| txid.to_byte_array()).collect(); + + let mut proof = Vec::new(); + let mut idx = pos; + + // Build merkle tree level by level + while hashes.len() > 1 { + // Get sibling hash for proof + let sibling_idx = if idx.is_multiple_of(2) { + idx + 1 + } else { + idx - 1 + }; + let sibling = if sibling_idx < hashes.len() { + hashes[sibling_idx] + } else { + // Odd number of elements, duplicate last one + hashes[idx] + }; + + // Reverse bytes from internal to display order for JSON output + let mut display_hash = sibling; + display_hash.reverse(); + proof.push(hex::encode(display_hash)); + + // Compute next level - hash pairs together + let mut next_level = Vec::new(); + for chunk in hashes.chunks(2) { + let left = &chunk[0]; + let right = if chunk.len() > 1 { + &chunk[1] + } else { + &chunk[0] + }; + + let mut engine = sha256d::Hash::engine(); + engine.input(left); + engine.input(right); + let hash = sha256d::Hash::from_engine(engine); + next_level.push(hash.to_byte_array()); + } + hashes = next_level; + idx /= 2; + } + + proof +} + +/// GET /api/tx/{txid}/merkle-proof - Get merkle proof in JSON format +async fn get_tx_merkle_proof( + State(state): State, + Path(txid): Path, +) -> impl IntoResponse { + match Txid::from_str(&txid) { + Ok(tx_id) => { + let rpc = state.rpc.clone(); + match tokio::task::spawn_blocking(move || { + // Get transaction info to find block hash + let tx_info = rpc.get_raw_transaction_info(&tx_id, None)?; + + let block_hash = match tx_info.blockhash { + Some(hash) => hash, + None => { + return Err(bitcoincore_rpc::Error::JsonRpc( + bitcoincore_rpc::jsonrpc::Error::Rpc( + bitcoincore_rpc::jsonrpc::error::RpcError { + code: -5, + message: "Transaction not yet in block".to_string(), + data: None, + }, + ), + )) + } + }; + + // Get block info for height and txids + let block_info = rpc.get_block_info(&block_hash)?; + + // Find position of tx in block + let pos = block_info + .tx + .iter() + .position(|t| *t == tx_id) + .ok_or_else(|| { + bitcoincore_rpc::Error::JsonRpc(bitcoincore_rpc::jsonrpc::Error::Rpc( + bitcoincore_rpc::jsonrpc::error::RpcError { + code: -5, + message: "Transaction not found in block".to_string(), + data: None, + }, + )) + })?; + + // Compute merkle proof + let merkle = compute_merkle_proof(&block_info.tx, pos); + + Ok::<_, bitcoincore_rpc::Error>(MerkleProofResponse { + block_height: block_info.height as u64, + merkle, + pos, + }) + }) + .await + { + Ok(Ok(response)) => Json(response).into_response(), + Ok(Err(e)) => handle_rpc_error(e, "Transaction", &txid).into_response(), + Err(e) => handle_task_error(e, "get merkle proof").into_response(), + } + } + Err(e) => { + warn!("Invalid txid: {}: {}", txid, e); + (StatusCode::BAD_REQUEST, "Invalid txid").into_response() + } + } +} + /// GET /api/tx/{txid}/merkleblock-proof - Get merkle inclusion proof async fn get_tx_merkleblock_proof( State(state): State, @@ -758,3 +936,121 @@ async fn index(State(state): State) -> impl IntoResponse { async fn fallback() -> impl IntoResponse { Redirect::temporary("/") } + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn test_compute_merkle_proof_block_100000() { + // Block 100000 has 4 transactions + // We test the merkle proof for the coinbase tx (position 0) + // Expected proof from blockstream.info: + // {"block_height":100000,"merkle":["fff2525b8931402dd09222c50775608f75787bd2b87e56995a7bdd30f79702c4","8e30899078ca1813be036a073bbf80b86cdddde1c96e9e9c99e9e3782df4ae49"],"pos":0} + + let txids: Vec = vec![ + Txid::from_str("8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87") + .unwrap(), + Txid::from_str("fff2525b8931402dd09222c50775608f75787bd2b87e56995a7bdd30f79702c4") + .unwrap(), + Txid::from_str("6359f0868171b1d194cbee1af2f16ea598ae8fad666d9b012c8ed2b79a236ec4") + .unwrap(), + Txid::from_str("e9a66845e05d5abc0ad04ec80f774a7e585c6e8db975962d069a522137b80c1d") + .unwrap(), + ]; + + // Test position 0 (coinbase tx) + let proof = compute_merkle_proof(&txids, 0); + assert_eq!(proof.len(), 2); + assert_eq!( + proof[0], + "fff2525b8931402dd09222c50775608f75787bd2b87e56995a7bdd30f79702c4" + ); + assert_eq!( + proof[1], + "8e30899078ca1813be036a073bbf80b86cdddde1c96e9e9c99e9e3782df4ae49" + ); + } + + #[test] + fn test_compute_merkle_proof_position_1() { + // Test merkle proof for position 1 (second tx in block 100000) + let txids: Vec = vec![ + Txid::from_str("8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87") + .unwrap(), + Txid::from_str("fff2525b8931402dd09222c50775608f75787bd2b87e56995a7bdd30f79702c4") + .unwrap(), + Txid::from_str("6359f0868171b1d194cbee1af2f16ea598ae8fad666d9b012c8ed2b79a236ec4") + .unwrap(), + Txid::from_str("e9a66845e05d5abc0ad04ec80f774a7e585c6e8db975962d069a522137b80c1d") + .unwrap(), + ]; + + let proof = compute_merkle_proof(&txids, 1); + assert_eq!(proof.len(), 2); + // At position 1, sibling is position 0 (the coinbase) + assert_eq!( + proof[0], + "8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87" + ); + } + + #[test] + fn test_compute_merkle_proof_single_tx() { + // Block with single transaction should have empty proof + let txids: Vec = vec![Txid::from_str( + "8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87", + ) + .unwrap()]; + + let proof = compute_merkle_proof(&txids, 0); + assert!(proof.is_empty()); + } + + #[test] + fn test_compute_merkle_proof_two_txs() { + // Block with two transactions + let txids: Vec = vec![ + Txid::from_str("8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87") + .unwrap(), + Txid::from_str("fff2525b8931402dd09222c50775608f75787bd2b87e56995a7bdd30f79702c4") + .unwrap(), + ]; + + // Position 0 should have tx1 as sibling + let proof = compute_merkle_proof(&txids, 0); + assert_eq!(proof.len(), 1); + assert_eq!( + proof[0], + "fff2525b8931402dd09222c50775608f75787bd2b87e56995a7bdd30f79702c4" + ); + + // Position 1 should have tx0 as sibling + let proof = compute_merkle_proof(&txids, 1); + assert_eq!(proof.len(), 1); + assert_eq!( + proof[0], + "8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87" + ); + } + + #[test] + fn test_compute_merkle_proof_empty() { + let txids: Vec = vec![]; + let proof = compute_merkle_proof(&txids, 0); + assert!(proof.is_empty()); + } + + #[test] + fn test_compute_merkle_proof_out_of_bounds() { + let txids: Vec = vec![Txid::from_str( + "8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87", + ) + .unwrap()]; + + // Position out of bounds should return empty + let proof = compute_merkle_proof(&txids, 5); + assert!(proof.is_empty()); + } +}