Skip to content

fix(rpc): verify fetched mempool txids before caching and document trust assumption - #2316

Open
tvpeter wants to merge 1 commit into
bitcoindevkit:masterfrom
tvpeter:fix/rpc-verify-mempool-txid
Open

tvpeter wants to merge 1 commit into
bitcoindevkit:masterfrom
tvpeter:fix/rpc-verify-mempool-txid

Conversation

@tvpeter

@tvpeter tvpeter commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Description

This PR adds a check to validate fetched raw mempool transaction hashes against the requested txid before caching in Emitter::mempool_at, preventing cache poisoning and state corruption from unverified RPC responses. It also adds a module level documentation that bdk_bitcoind_rpc assumes connection to a trusted bitcoind node.

This addresses only the missing txid-validation part of #2282, following discussions on #2283.

Notes to the reviewers

  • The check tx.compute_txid() != txid returns the existing Error::UnexpectedStructure on mismatch, so it is non-breaking.

Changelog notice

  • Emitter::mempool/mempool_at: verify that a fetched transaction's computed txid matches the requested txid before caching, rejecting a mismatch with Error::UnexpectedStructure.

Checklists

All Submissions:

New Features:

  • I've added tests for the new feature
  • I've added docs for the new feature

Bugfixes:

  • This pull request breaks the existing API
  • I've added tests to reproduce the issue which are now passing
  • I'm linking the issue being fixed by this PR

@tvpeter
tvpeter requested a review from evanlinjin as a code owner September 16, 2026 07:45
@tvpeter tvpeter changed the title fix(rpc): verify fetched mempool tx txids and document trust assumption fix(rpc): verify fetched mempool txids before caching and document trust assumption Sep 16, 2026
@codecov

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 78.85%. Comparing base (e417c43) to head (3fba250).

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #2316   +/-   ##
=======================================
  Coverage   78.84%   78.85%           
=======================================
  Files          31       31           
  Lines        6060     6062    +2     
  Branches      288      289    +1     
=======================================
+ Hits         4778     4780    +2     
  Misses       1203     1203           
  Partials       79       79           
Flag Coverage Δ
rust 78.85% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tvpeter

tvpeter commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

The MSRV job is failing because of a transitive dependency yoke-derive (via esplora-client -> reqwest -> url -> idna-> icu -> yoke -> yoke-derive) that was updated yesterday (2026-09-15) to 0.8.3 and requires Rustc 1.87 to build. So we can either pin it to 0.8.2 in pin-msrv or bump the MSRV to 1.87.

Happy to open a tiny PR for the pin if that's preferred.

@evanlinjin

Copy link
Copy Markdown
Member

@tvpeter I also experienced the CI failure and opened this PR: #2317

- Add `tx.compute_txid() == txid` check to validate that fetched raw
mempool transaction hashes against their requested txid before caching
in Emitter::mempool_at. Returns `Error::UnexpectedStructure` for a
mismatch.

- Add a regression test for the above check.

- Document that the crate assumes a trusted `bitcoind` connection.
@tvpeter
tvpeter force-pushed the fix/rpc-verify-mempool-txid branch from 2141610 to 3fba250 Compare September 18, 2026 12:10

@Dmenec Dmenec left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approach ACK

I was thinking if the validation should retry (maybe 3 times at most) when failing so you don't throw the whole poll, but I think in this specific scenario it would be more annoying than useful.

Not sure if compute_txid cover the witness, so maybe a node could still serve a different witness for the same txid? If not, maybe worth a word in the doc so it doesn't read as the whole tx being verified.

Also, checking the code I noticed that next_block fetches blocks by hash but never checks the returned block actually hashes to what was requested:

pub fn next_block(&mut self) -> Result<Option<BlockEvent<Block>>, bitcoincore_rpc::Error> {
if let Some((checkpoint, block)) = poll(self, move |hash, client| client.get_block(hash))? {
// Stop tracking unconfirmed transactions that have been confirmed in this block.
for tx in &block.txdata {
self.mempool_snapshot.remove(&tx.compute_txid());
}
return Ok(Some(BlockEvent { block, checkpoint }));
}
Ok(None)
}

I think in the same way we should check the block hash, merkle root and witness commitment. Checking only the hash isn't enough, since it only covers the header, so a node could serve the genuine header with forged txs. I saw your benchmarks in #2283 so, as much, it should be a few ms per block. Given the scope was narrowed there, maybe better as a follow-up?

Also, just a few nits I've seen through

Ok(())
}

//A fetched mempool tx whose body does not match the requested txid is rejected.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
//A fetched mempool tx whose body does not match the requested txid is rejected.
// A fetched mempool tx whose body does not match the requested txid is rejected.

///
/// This is the no-std version of [`mempool`](Self::mempool).
///
/// # Errors

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: I would add smthg like this in mempool() so callers also have this information.

/// See [`mempool_at`](Self::mempool_at#errors) for errors.

Comment on lines +575 to +599
impl RpcApi for LyingNode {
fn call<T: for<'a> serde::de::Deserialize<'a>>(
&self,
_cmd: &str,
_args: &[serde_json::Value],
) -> Result<T, Error> {
unreachable!()
}
fn get_block_count(&self) -> Result<u64, Error> {
Ok(100)
}
fn get_block_hash(&self, _height: u64) -> Result<BlockHash, Error> {
Ok(self.tip_hash)
}
fn get_raw_mempool(&self) -> Result<Vec<Txid>, Error> {
Ok(vec![self.announced])
}
fn get_raw_transaction(
&self,
_txid: &Txid,
_block_hash: Option<&BlockHash>,
) -> Result<Transaction, Error> {
Ok(self.served.clone())
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: since all RpcApi methods default to call, the mock could implement only call and match on the command name. Reads like a table and gets rid of the unreachable!()

fn call<T: for<'a> serde::de::Deserialize<'a>>(
    &self,
    cmd: &str,
    _args: &[serde_json::Value],
) -> Result<T, Error> {
    let value = match cmd {
        "getblockcount" => serde_json::json!(100),
        "getblockhash" => serde_json::to_value(self.tip_hash)?,
        "getrawmempool" => serde_json::to_value([self.announced])?,
        "getrawtransaction" => serialize_hex(&self.served).into(),
        _ => unimplemented!("unexpected RPC call {cmd}"),
    };
    Ok(serde_json::from_value(value)?)
}

should need use bitcoin::consensus::encode::serialize_hex; in the test module

Comment on lines +27 to +28
serde = "1"
serde_json = "1"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: these deps aren't needed, bitcoincore_rpc already re-exports both. In the test module this should be enough

use bitcoincore_rpc::jsonrpc::{serde, serde_json};

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants