Add /tx/:txid/witness-merkle-proof (+ blockchain.transaction.get_witness_merkle)#235
Add /tx/:txid/witness-merkle-proof (+ blockchain.transaction.get_witness_merkle)#235ordspv wants to merge 1 commit into
Conversation
New: GET /tx/:txid/witness-merkle-proof and
blockchain.transaction.get_witness_merkle(txid, height). Both return a
merkle inclusion proof over the block's BIP-141 witness tree (leaf i =
wtxid(tx_i), leaf 0 zeroed for the coinbase) as { block_height, merkle[],
pos, witness_root }.
Why: a txid merkle proof binds only the stripped serialization. Witness
data is unbound, so protocols whose payload lives in the witness (ordinals
envelopes are the largest) cannot get SPV assurance from the existing
endpoint: a lying server can swap the witness and the txid proof still
verifies. The consensus fix (prove against the witness tree, check the
coinbase witness commitment) needs witness-tree branches, which no public
API serves today, so light clients download whole raw blocks instead. One
response of about 1 KB replaces a 1-2 MB block download.
Implementation: ChainQuery::get_block_wtxids (block txids -> lookup_txns
batch -> compute_wtxid, coinbase zeroed; same DB pattern as the raw-block
reconstruction path) and electrum_merkle::get_tx_witness_merkle_proof,
reusing the existing branch fold (now pub for the bench). Handlers mirror
their txid-proof twins. Everything is cfg(not(feature = "liquid")):
witness commitments are bitcoin-specific here. No new index rows, no
migration.
Tests: the REST integration test folds the wallet tx's wtxid and the
zeroed coinbase leaf to witness_root and verifies sha256d(root ||
reserved) against the coinbase 6a24aa21a9ed commitment, the full BIP-141
loop. The Electrum raw-socket test mirrors get_merkle semantics including
the invalid-height error. Criterion benches on mainnet block 702861
(~1000 txs): ~5.25 ms wtxid computation plus ~1.53 ms branch construction
per uncached proof.
|
Thanks for the detailed PR. The main concern with this is request amplification. Each uncached request appears to load and deserialize every transaction in the block, calculate all wtxids, and rebuild the tree. The CPU benchmark looks reasonable, but it excludes database access. On a public REST or Electrum server, repeated requests for large blocks could create significant disk/CPU pressure. Would it make sense to add a bounded per-block witness-tree/wtxid cache, concurrency limit, or benchmark covering the complete database-backed request? It may also be worth clarifying or testing blocks without a usable BIP141 commitment—particularly pre-SegWit blocks and blocks where the commitment is optional. A witness tree can still be constructed, but without the coinbase commitment it is not bound to the block header. Clients should also be told not to trust the returned witness_root directly, but to derive it from the branch and verify it against the applicable coinbase commitment. Additional tests for odd transaction counts, single-transaction blocks, commitment absence, and reorg behavior would make this easier to evaluate for production use. Overall, the idea looks sound; the concern is mainly production hardening and ensuring the API cannot imply a header-bound proof when no witness commitment exists. Given that a cache miss loads and deserializes every transaction in the block, would it make sense to make this feature opt-in, at least initially? Separate REST and Electrum flags may be useful: REST deployments can place the endpoint behind CDN/reverse-proxy caching and rate limits, whereas Electrum RPC is often directly exposed. An opt-in flag would not replace a bounded cache or concurrency controls, but it would prevent existing operators from unexpectedly exposing a new request-amplification path after upgrading. |
|
Thanks for the careful read. All four concerns are actionable with plan below, revised commits to follow. Amplification. Agreed on all three mitigations:
One piece of context that argues for your opt-in suggestion rather than against any mitigation: the uncached cost profile is the same load-and-deserialize-every-tx shape as the existing raw-block reconstruction path, so this adds another instance of an existing amplification pattern, not a novel one. New surface, same caution applies. Commitment absence. Agreed this is the important correctness point and the current response shape makes misuse too easy. Two changes:
Tests. Adding: odd transaction counts, single-transaction block (the zero coinbase leaf is the root), commitment-absent block (constructed fixture, or a segwit activation-height override in regtest), and reorg behavior mirroring the txid-proof twins ( Opt-in. Agreed, and separate flags match the exposure difference you describe: Two preference questions before I push revisions: (1) for the commitment-absent case, is a plain 404 with a distinct message in line with esplora error conventions, or would you prefer a different status so it cannot be confused with an unknown txid? (2) any objection to the two-boolean flag shape versus a single multi-value flag? |
What
A witness-tree twin of the existing
/tx/:txid/merkle-proof:and the Electrum-protocol equivalent
blockchain.transaction.get_witness_merkle(txid, height)mirroringblockchain.transaction.get_merkle.The proof is over the block's witness tree: leaf
iiswtxid(tx_i), except leaf 0 (coinbase), which is the zero hash per BIP-141. That tree's root is what the coinbase witness commitment (sha256d(root ‖ reserved)behind6a24aa21a9ed) commits to.Why
A txid merkle proof binds a transaction's stripped serialization to a header. It does not bind witness data: two different witnesses for the same inputs produce the same txid. Any protocol whose payload LIVES in the witness (ordinals inscription envelopes being the largest today) therefore can't get SPV-grade assurance from
/tx/:txid/merkle-proofalone: a lying server can swap the witness and the txid proof still verifies.The fix has always been available in consensus data (prove the tx against the witness tree, prove the coinbase against the txid tree, check the coinbase's witness commitment), but no public API serves witness-tree branches, so light clients today must download the entire raw block (~1–2 MB) to verify ~1 KB of content. This endpoint replaces that with one ~1 KB response. Esplora already serves every other ingredient (
merkle-proof,/block/:hash/header,/tx/:txid/hex, coinbase txid via/block/:hash/txid/0).Concretely, the consumer flow (implemented in a public verifier library that motivated this PR, github.com/ordspv/ordspv; a full L3 verification is header PoW, a txid proof of the coinbase, the witness commitment check, and this endpoint's branch):
GET /tx/:txid/witness-merkle-proof→ branch, pos, witness_rootwtxid(tx)(or the zero leaf for the coinbase) up the branch → must equalwitness_rootsha256d(witness_root ‖ coinbase.witness[0])must equal the coinbase's6a24aa21a9edcommitment; coinbase bound by an ordinary txid proof at pos 0Implementation
ChainQuery::get_block_wtxids(hash): block txids →lookup_txnsbatch → per-txcompute_wtxid(), coinbase leaf zeroed. Same DB access pattern and cost as the existingGET /block/:hash/rawreconstruction path.electrum_merkle::get_tx_witness_merkle_proofreuses the existingcreate_merkle_branch_and_rootfold (madepubfor the bench).#[cfg(not(feature = "liquid"))](witness commitments are bitcoin-specific here; elements has its own commitment structure).Tests
test_rest_tx_witness_merkle_proof(regtest, corepc-node): folds the wallet tx's wtxid through the returned branch towitness_root, folds the ZEROED coinbase leaf to the same root, and checkssha256d(root ‖ reserved) == coinbase 6a24aa21a9ed commitment. That is the full BIP-141 loop rather than shape assertions alone; plus 404 for unknown txids.test_electrum_get_witness_merkle(raw socket): result fields + invalid height → invalid-params error, mirroringget_merklebehavior.Benchmarks (criterion,
benches/benches.rs, real mainnet block 702861, ~1 000 txs)CPU cost ≈ 7 ms per uncached proof on a ~1 000-tx block (Apple M-series; DB lookups equal the existing raw-block path). Responses are immutable and CDN-cacheable; a per-block wtxid cache would amortize the first term across transactions of the same block if needed.
API.md addition (Blockstream/esplora)