Skip to content

Commit 37c4c20

Browse files
rabbitson87claude
andcommitted
perf(rpc): answer gettxoutproof from the txindex instead of scanning the chain
Called without a block hash, the handler deep-copied every BlockRecord, then loaded, deserialized and hashed every block on the chain to answer one call. At tip that is roughly a million block loads, almost all of them discarded -- unbounded work for one authenticated RPC call, which stalls the node and evicts everything else from cache while it runs. Context.indexer already knows which block confirms a txid. Resolve the confirming height, build the proof from that block alone, and take the same route Bitcoin Core does: it requires the block hash unless txindex is enabled. The scan is kept whole as proof_from_records. It is the fallback whenever the index cannot answer -- no indexer, an unresolved or stale row, a pruned body, or a candidate block that does not hold every wanted txid -- and it is the oracle the equivalence tests compare against. That last fallback is not belt and braces: BIP30 duplicate coinbase txids mean a txid can confirm in more than one block, so a block chosen from a single txid is a candidate, never a verdict. IndexerLike gains resolve_transaction_height, defaulting to Ok(None), so every existing implementation keeps compiling and no caller may drop its fallback. Measured on an idle machine, both arms in one Criterion run over one fixture: 21.118 ms -> 32.983 us for the scan worst case, and within noise of each other for its best case, because the index arm is flat at 28-33 us regardless of position. Extrapolating the scan's 10.56 us per record to a mainnet-sized chain gives about 10 seconds per call, and that is a floor -- fixture blocks hold 8 small transactions where real ones hold thousands. See docs/benchmarks/gettxoutproof.md. The twelve tests were audited by mutation rather than trusted for being green. That audit found the three crates/index tests were not running at all, because their module is gated on a feature the crate does not enable by default, and that one of them passed vacuously when both resolvers returned None. Both are fixed here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a157e09 commit 37c4c20

6 files changed

Lines changed: 805 additions & 25 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/index/src/index.rs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,40 @@ impl<S: KvStore> Indexer<S> {
436436
Ok(None)
437437
}
438438

439+
/// Resolves the height of the block confirming `txid`, without
440+
/// materializing the transaction.
441+
///
442+
/// Same candidate walk as [`Self::resolve_transaction`] — positions first,
443+
/// then a full-block scan that answers the rare stale row or 8-byte
444+
/// txid-prefix collision — but it reports *which block* rather than which
445+
/// transaction, which is what a merkle-proof caller needs. Returns `None`
446+
/// when no candidate row resolves to `txid`, and "not found" is never
447+
/// reported on the strength of positions alone.
448+
pub fn resolve_transaction_height<B: BlockSource + ?Sized>(
449+
&self,
450+
txid: bitcoin::Txid,
451+
source: &B,
452+
) -> Result<Option<u32>, IndexError> {
453+
let rows = self.iter_txid_rows_with_values(&txid)?;
454+
for (row, value) in &rows {
455+
let height = row.height();
456+
if let Some(positions) = crate::types::TxPositionValue::decode(value)
457+
&& positions
458+
.iter()
459+
.filter_map(|position| transaction_at(height, *position, source))
460+
.any(|tx| tx.compute_txid() == txid)
461+
{
462+
return Ok(Some(height));
463+
}
464+
if let Some(block) = source.block_at_height(height)
465+
&& block.txdata.iter().any(|tx| tx.compute_txid() == txid)
466+
{
467+
return Ok(Some(height));
468+
}
469+
}
470+
Ok(None)
471+
}
472+
439473
/// Naive reference implementation of [`Self::resolve_transaction`].
440474
///
441475
/// Loads and fully decodes the block for each candidate row, then computes
@@ -1442,6 +1476,20 @@ pub trait IndexerLike: Send + Sync {
14421476
Ok(None)
14431477
}
14441478

1479+
/// Resolves the height of the block confirming `txid` via `source`.
1480+
///
1481+
/// Default implementations may return `Ok(None)` when the concrete indexer
1482+
/// does not support transaction lookup, so a caller that must answer
1483+
/// correctly without an index keeps its own fallback path.
1484+
fn resolve_transaction_height(
1485+
&self,
1486+
txid: bitcoin::Txid,
1487+
source: &dyn BlockSource,
1488+
) -> Result<Option<u32>, IndexError> {
1489+
let _ = (txid, source);
1490+
Ok(None)
1491+
}
1492+
14451493
/// Resolves the satoshi value of the transaction output at `outpoint` via
14461494
/// `source`. Returns `Ok(None)` when the transaction is not indexed or the
14471495
/// `vout` is out of range.
@@ -1594,6 +1642,14 @@ impl<S: KvStore + Send + Sync + 'static> IndexerLike for Indexer<S> {
15941642
Self::resolve_transaction(self, txid, source)
15951643
}
15961644

1645+
fn resolve_transaction_height(
1646+
&self,
1647+
txid: bitcoin::Txid,
1648+
source: &dyn BlockSource,
1649+
) -> Result<Option<u32>, IndexError> {
1650+
Self::resolve_transaction_height(self, txid, source)
1651+
}
1652+
15971653
fn resolve_outpoint_value(
15981654
&self,
15991655
outpoint: bitcoin::OutPoint,
@@ -1961,6 +2017,71 @@ mod tests {
19612017
Ok(())
19622018
}
19632019

2020+
#[test]
2021+
fn resolve_transaction_height_returns_the_confirming_height()
2022+
-> Result<(), Box<dyn std::error::Error>> {
2023+
let block = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest);
2024+
let Some(tx) = block.txdata.first() else {
2025+
return Err(std::io::Error::other("genesis block has no transactions").into());
2026+
};
2027+
let txid = tx.compute_txid();
2028+
let (_dir, mut indexer) = indexer()?;
2029+
2030+
indexer.ingest_block(&serialize(&block), 0)?;
2031+
2032+
let source = FakeSource {
2033+
block,
2034+
target_height: 0,
2035+
};
2036+
2037+
assert_eq!(indexer.resolve_transaction_height(txid, &source)?, Some(0));
2038+
Ok(())
2039+
}
2040+
2041+
#[test]
2042+
fn resolve_transaction_height_returns_none_for_unknown_txid()
2043+
-> Result<(), Box<dyn std::error::Error>> {
2044+
let (_dir, indexer) = indexer()?;
2045+
let txid = bitcoin::Txid::from_byte_array([0xff; 32]);
2046+
let source = FakeSource {
2047+
block: bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest),
2048+
target_height: 0,
2049+
};
2050+
2051+
assert_eq!(indexer.resolve_transaction_height(txid, &source)?, None);
2052+
Ok(())
2053+
}
2054+
2055+
#[test]
2056+
fn resolve_transaction_height_agrees_with_the_transaction_resolver()
2057+
-> Result<(), Box<dyn std::error::Error>> {
2058+
// The two resolvers walk the same candidate rows; a caller that picks a
2059+
// block by height must land on the block the transaction resolver would
2060+
// have read the transaction out of.
2061+
let block = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest);
2062+
let Some(tx) = block.txdata.first() else {
2063+
return Err(std::io::Error::other("genesis block has no transactions").into());
2064+
};
2065+
let txid = tx.compute_txid();
2066+
let (_dir, mut indexer) = indexer()?;
2067+
2068+
indexer.ingest_block(&serialize(&block), 0)?;
2069+
2070+
let source = FakeSource {
2071+
block,
2072+
target_height: 0,
2073+
};
2074+
2075+
let by_height = indexer.resolve_transaction_height(txid, &source)?;
2076+
let by_transaction = indexer.resolve_tx_with_height(txid, &source)?;
2077+
2078+
// Both resolvers returning `None` would satisfy the equality below while
2079+
// proving nothing, so pin the resolved value before comparing.
2080+
assert_eq!(by_height, Some(0));
2081+
assert_eq!(by_height, by_transaction.map(|(_, height)| height));
2082+
Ok(())
2083+
}
2084+
19642085
#[test]
19652086
fn resolve_outpoint_value_returns_genesis_coinbase_subsidy()
19662087
-> Result<(), Box<dyn std::error::Error>> {

crates/rpc/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,9 @@ tracing.workspace = true
4747
[dev-dependencies]
4848
proptest.workspace = true
4949
tempfile = "3"
50+
criterion.workspace = true
51+
52+
[[bench]]
53+
name = "txoutproof"
54+
harness = false
55+
required-features = ["rocksdb"]

0 commit comments

Comments
 (0)