diff --git a/chronik/chronik-bridge/src/ffi.rs b/chronik/chronik-bridge/src/ffi.rs
index 8b596cfe0..446ec6aec 100644
--- a/chronik/chronik-bridge/src/ffi.rs
+++ b/chronik/chronik-bridge/src/ffi.rs
@@ -1,284 +1,308 @@
 // Copyright (c) 2022 The Bitcoin developers
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
 //! Module containing the cxx definitions for the bridge from C++ to Rust.
 
 pub use self::ffi_inner::*;
 
 #[allow(unsafe_code)]
 #[cxx::bridge(namespace = "chronik_bridge")]
 mod ffi_inner {
     /// Info about a block
     #[derive(Clone, Debug, Default, Eq, PartialEq)]
     pub struct BlockInfo {
         /// Hash of the block (or 000...000 if no block)
         pub hash: [u8; 32],
 
         /// Height of the block (or -1 if no block)
         pub height: i32,
     }
 
     /// Block coming from bitcoind to Chronik.
     ///
     /// We don't index all fields (e.g. hashMerkleRoot), only those that are
     /// needed when querying a range of blocks.
     ///
     /// Instead of storing all the block data for Chronik again, we only store
     /// file_num, data_pos and undo_pos of the block data of the node.
     ///
     /// This makes the index relatively small, as it's mostly just pointing to
     /// the data the node already stores.
     ///
     /// Note that this prohibits us from using Chronik in pruned mode.
     #[derive(Clone, Debug, Default, Eq, PartialEq)]
     pub struct Block {
         /// Block hash
         pub hash: [u8; 32],
         /// hashPrevBlock, hash of the previous block in the chain
         pub prev_hash: [u8; 32],
         /// nBits, difficulty of the header
         pub n_bits: u32,
         /// Timestamp of the block
         pub timestamp: i64,
         /// Height of the block in the chain.
         pub height: i32,
         /// File number of the block file this block is stored in.
         /// This can be used to later slice out transactions, so we don't have
         /// to index txs twice.
         pub file_num: u32,
         /// Position of the block within the block file, starting at the block
         /// header.
         pub data_pos: u32,
         /// Position of the undo data within the undo file.
         pub undo_pos: u32,
         /// Serialized size of the block
         pub size: u64,
         /// Txs of this block, including positions within the block/undo files.
         pub txs: Vec<BlockTx>,
     }
 
     /// Tx in a block
     #[derive(Clone, Debug, Default, Eq, PartialEq)]
     pub struct BlockTx {
         /// Tx (without disk data)
         pub tx: Tx,
         /// Where the tx is stored within the block file.
         pub data_pos: u32,
         /// Where the tx's undo data is stored within the block's undo file.
         pub undo_pos: u32,
     }
 
     /// CTransaction, in a block or in the mempool.
     #[derive(Clone, Debug, Default, Eq, PartialEq)]
     pub struct Tx {
         /// TxId of the tx.
         pub txid: [u8; 32],
         /// nVersion of the tx.
         pub version: i32,
         /// Tx inputs.
         pub inputs: Vec<TxInput>,
         /// Tx outputs.
         pub outputs: Vec<TxOutput>,
         /// Locktime of the tx.
         pub locktime: u32,
     }
 
     /// COutPoint, pointing to a coin being spent.
     #[derive(Clone, Debug, Default, Eq, PartialEq)]
     pub struct OutPoint {
         /// TxId of the output of the coin.
         pub txid: [u8; 32],
         /// Index in the outputs of the tx of the coin.
         pub out_idx: u32,
     }
 
     /// CTxIn, spending an unspent output.
     #[derive(Clone, Debug, Default, Eq, PartialEq)]
     pub struct TxInput {
         /// Points to an output being spent.
         pub prev_out: OutPoint,
         /// scriptSig unlocking the output.
         pub script: Vec<u8>,
         /// nSequence.
         pub sequence: u32,
         /// Coin being spent by this tx.
         pub coin: Coin,
     }
 
     /// CTxOut, creating a new output.
     #[derive(Clone, Debug, Default, Eq, PartialEq)]
     pub struct TxOutput {
         /// Value of the output.
         pub value: i64,
         /// Script locking the output.
         pub script: Vec<u8>,
     }
 
     /// Coin, can be spent by providing a valid unlocking script.
     #[derive(Clone, Debug, Default, Eq, PartialEq)]
     pub struct Coin {
         /// Output, locking the coins.
         pub output: TxOutput,
         /// Height of the coin in the chain.
         pub height: i32,
         /// Whether the coin is a coinbase.
         pub is_coinbase: bool,
     }
 
     #[allow(missing_debug_implementations)]
     unsafe extern "C++" {
         include!("blockindex.h");
         include!("chronik-cpp/chronik_bridge.h");
         include!("coins.h");
         include!("node/context.h");
         include!("primitives/block.h");
         include!("primitives/transaction.h");
 
         /// node::NodeContext from node/context.h
         #[namespace = "node"]
         type NodeContext;
 
         /// ::CBlockIndex from blockindex.h
         #[namespace = ""]
         type CBlockIndex;
 
         /// ::CBlock from primitives/block.h
         #[namespace = ""]
         type CBlock;
 
         /// ::Coin from coins.h (renamed to CCoin to prevent a name clash)
         #[namespace = ""]
         #[cxx_name = "Coin"]
         type CCoin;
 
         /// ::Config from config.h
         #[namespace = ""]
         type Config;
 
         /// ::CTransaction from primitives/transaction.h
         #[namespace = ""]
         type CTransaction;
 
         /// Bridge to bitcoind to access the node
         type ChronikBridge;
 
         /// Print the message to bitcoind's logs.
         fn log_print(
             logging_function: &str,
             source_file: &str,
             source_line: u32,
             msg: &str,
         );
 
         /// Print the message to bitcoind's logs under the BCLog::Chronik
         /// category.
         fn log_print_chronik(
             logging_function: &str,
             source_file: &str,
             source_line: u32,
             msg: &str,
         );
 
         /// Make the bridge given the NodeContext
         fn make_bridge(
             config: &Config,
             node: &NodeContext,
         ) -> UniquePtr<ChronikBridge>;
 
         /// Return the tip of the chain of the node.
         /// Returns hash=000...000, height=-1 if there's no block on the chain.
         fn get_chain_tip(self: &ChronikBridge) -> Result<&CBlockIndex>;
 
         /// Lookup the block index with the given hash, or throw an error
         /// if it couldn't be found.
         fn lookup_block_index(
             self: &ChronikBridge,
             hash: [u8; 32],
         ) -> Result<&CBlockIndex>;
 
         /// Load the CBlock data of this CBlockIndex from the disk
         fn load_block(
             self: &ChronikBridge,
             block_index: &CBlockIndex,
         ) -> Result<UniquePtr<CBlock>>;
 
         /// Find at which block the given block_index forks off from the node.
         fn find_fork(
             self: &ChronikBridge,
             block_index: &CBlockIndex,
         ) -> Result<&CBlockIndex>;
 
+        /// Lookup the spent coins of a tx and fill them in in-place.
+        /// - `not_found` will be the outpoints that couldn't be found in the
+        ///   node or the DB.
+        /// - `coins_to_uncache` will be the outpoints that need to be uncached
+        ///   if the tx doesn't end up being broadcast. This is so that clients
+        ///   can't fill our cache with useless old coins. It mirrors the
+        ///   behavior of `MemPoolAccept::PreChecks`, which uncaches the queried
+        ///   coins if they don't end up being spent.
+        fn lookup_spent_coins(
+            self: &ChronikBridge,
+            tx: &mut Tx,
+            not_found: &mut Vec<OutPoint>,
+            coins_to_uncache: &mut Vec<OutPoint>,
+        ) -> Result<()>;
+
+        /// Remove the coins from the coin cache.
+        /// This must be done after a call to `lookup_spent_coins` where the tx
+        /// wasn't broadcast, to avoid clients filling our cache with unneeded
+        /// coins.
+        fn uncache_coins(
+            self: &ChronikBridge,
+            coins: &[OutPoint],
+        ) -> Result<()>;
+
         /// Add the given tx to the mempool, and if that succeeds, broadcast it
         /// to all our peers.
         /// Also check the actual tx fee doesn't exceed max_fee.
         /// Note max_fee is absolute, not a fee rate (as in sendrawtransaction).
         fn broadcast_tx(
             self: &ChronikBridge,
             raw_tx: &[u8],
             max_fee: i64,
         ) -> Result<[u8; 32]>;
 
         /// Bridge CTransaction -> ffi::Tx, using the given spent coins.
         fn bridge_tx(
             tx: &CTransaction,
             spent_coins: &CxxVector<CCoin>,
         ) -> Result<Tx>;
 
         /// Bridge bitcoind's classes to the shared struct [`Block`].
         fn bridge_block(
             block: &CBlock,
             block_index: &CBlockIndex,
         ) -> Result<Block>;
 
         /// Load the CTransaction and CTxUndo data from disk and turn it into a
         /// bridged Tx, containing spent coins etc.
         fn load_tx(file_num: u32, data_pos: u32, undo_pos: u32) -> Result<Tx>;
 
         /// Load the CTransaction from disk and serialize it.
         fn load_raw_tx(file_num: u32, data_pos: u32) -> Result<Vec<u8>>;
 
         /// Get a BlockInfo for this CBlockIndex.
         fn get_block_info(block_index: &CBlockIndex) -> BlockInfo;
 
         /// CBlockIndex::GetAncestor
         fn get_block_ancestor(
             block_index: &CBlockIndex,
             height: i32,
         ) -> Result<&CBlockIndex>;
 
         /// Compress the given script using `ScriptCompression`.
         fn compress_script(script: &[u8]) -> Vec<u8>;
 
         /// Decompress the given script using `ScriptCompression`.
         fn decompress_script(compressed: &[u8]) -> Result<Vec<u8>>;
 
         /// Calls `InitError` from `node/ui_interface.h` to report an error to
         /// the user and then gracefully shut down the node.
         fn init_error(msg: &str) -> bool;
 
         /// Calls `AbortNode` from shutdown.h to gracefully shut down the node
         /// when an unrecoverable error occured.
         fn abort_node(msg: &str, user_msg: &str);
 
         /// Returns true if a shutdown is requested, false otherwise.
         /// See `ShutdownRequested` in `shutdown.h`.
         fn shutdown_requested() -> bool;
     }
 }
 
 /// SAFETY: All fields of ChronikBridge (const Consensus::Params &, const
 /// node::NodeContext &) can be moved betweed threads safely.
 #[allow(unsafe_code)]
 unsafe impl Send for ChronikBridge {}
 
 /// SAFETY: All fields of ChronikBridge (const Consensus::Params &, const
 /// node::NodeContext &) can be accessed from different threads safely.
 #[allow(unsafe_code)]
 unsafe impl Sync for ChronikBridge {}
 
 impl std::fmt::Debug for ChronikBridge {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         f.debug_struct("ChronikBridge").finish_non_exhaustive()
     }
 }
diff --git a/chronik/chronik-cpp/chronik_bridge.cpp b/chronik/chronik-cpp/chronik_bridge.cpp
index cb7e79b6d..dddb59873 100644
--- a/chronik/chronik-cpp/chronik_bridge.cpp
+++ b/chronik/chronik-cpp/chronik_bridge.cpp
@@ -1,323 +1,367 @@
 // Copyright (c) 2022 The Bitcoin developers
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
 #include <blockindex.h>
 #include <chainparams.h>
 #include <chronik-bridge/src/ffi.rs.h>
 #include <chronik-cpp/chronik_bridge.h>
 #include <chronik-cpp/util/collection.h>
 #include <chronik-cpp/util/hash.h>
 #include <compressor.h>
 #include <config.h>
 #include <logging.h>
 #include <node/blockstorage.h>
 #include <node/coin.h>
 #include <node/context.h>
 #include <node/transaction.h>
 #include <node/ui_interface.h>
 #include <shutdown.h>
 #include <streams.h>
 #include <undo.h>
 #include <util/error.h>
 #include <validation.h>
 
 chronik_bridge::OutPoint BridgeOutPoint(const COutPoint &outpoint) {
     return {
         .txid = chronik::util::HashToArray(outpoint.GetTxId()),
         .out_idx = outpoint.GetN(),
     };
 }
 
 chronik_bridge::TxOutput BridgeTxOutput(const CTxOut &output) {
     return {
         .value = output.nValue / Amount::satoshi(),
         .script = chronik::util::ToRustVec<uint8_t>(output.scriptPubKey),
     };
 }
 
 chronik_bridge::Coin BridgeCoin(const Coin &coin) {
     const int32_t nHeight =
         coin.GetHeight() == 0x7fff'ffff ? -1 : coin.GetHeight();
     return {
         .output = BridgeTxOutput(coin.GetTxOut()),
         .height = nHeight,
         .is_coinbase = coin.IsCoinBase(),
     };
 }
 
 rust::Vec<chronik_bridge::TxInput>
 BridgeTxInputs(bool isCoinbase, const std::vector<CTxIn> &inputs,
                const std::vector<Coin> &spent_coins) {
     rust::Vec<chronik_bridge::TxInput> bridged_inputs;
     bridged_inputs.reserve(inputs.size());
     for (size_t idx = 0; idx < inputs.size(); ++idx) {
         const CTxIn &input = inputs[idx];
         chronik_bridge::Coin bridge_coin{}; // empty coin
         if (!isCoinbase) {
             if (idx >= spent_coins.size()) {
                 throw std::runtime_error("Missing coin for input");
             }
             bridge_coin = BridgeCoin(spent_coins[idx]);
         }
         bridged_inputs.push_back({
             .prev_out = BridgeOutPoint(input.prevout),
             .script = chronik::util::ToRustVec<uint8_t>(input.scriptSig),
             .sequence = input.nSequence,
             .coin = std::move(bridge_coin),
         });
     }
     return bridged_inputs;
 }
 
 rust::Vec<chronik_bridge::TxOutput>
 BridgeTxOutputs(const std::vector<CTxOut> &outputs) {
     rust::Vec<chronik_bridge::TxOutput> bridged_outputs;
     bridged_outputs.reserve(outputs.size());
     for (const CTxOut &output : outputs) {
         bridged_outputs.push_back(BridgeTxOutput(output));
     }
     return bridged_outputs;
 }
 
 chronik_bridge::Tx BridgeTx(bool isCoinbase, const CTransaction &tx,
                             const std::vector<Coin> &spent_coins) {
     return {
         .txid = chronik::util::HashToArray(tx.GetId()),
         .version = tx.nVersion,
         .inputs = BridgeTxInputs(isCoinbase, tx.vin, spent_coins),
         .outputs = BridgeTxOutputs(tx.vout),
         .locktime = tx.nLockTime,
     };
 }
 
 chronik_bridge::BlockTx BridgeBlockTx(bool isCoinbase, const CTransaction &tx,
                                       const std::vector<Coin> &spent_coins,
                                       size_t data_pos, size_t undo_pos) {
     return {.tx = BridgeTx(isCoinbase, tx, spent_coins),
             .data_pos = uint32_t(data_pos),
             .undo_pos = uint32_t(isCoinbase ? 0 : undo_pos)};
 }
 
 size_t GetFirstBlockTxOffset(const CBlock &block, const CBlockIndex &bindex) {
     return bindex.nDataPos + ::GetSerializeSize(CBlockHeader()) +
            GetSizeOfCompactSize(block.vtx.size());
 }
 
 size_t GetFirstUndoOffset(const CBlock &block, const CBlockIndex &bindex) {
     // We have to -1 here, because coinbase txs don't have undo data.
     return bindex.nUndoPos + GetSizeOfCompactSize(block.vtx.size() - 1);
 }
 
 chronik_bridge::Block BridgeBlock(const CBlock &block,
                                   const CBlockIndex &bindex) {
     size_t data_pos = GetFirstBlockTxOffset(block, bindex);
     size_t undo_pos = 0;
     CBlockUndo block_undo;
 
     // Read undo data (genesis block doesn't have undo data)
     if (bindex.nHeight > 0) {
         undo_pos = GetFirstUndoOffset(block, bindex);
         if (!node::UndoReadFromDisk(block_undo, &bindex)) {
             throw std::runtime_error("Reading block undo data failed");
         }
     }
 
     rust::Vec<chronik_bridge::BlockTx> bridged_txs;
     for (size_t tx_idx = 0; tx_idx < block.vtx.size(); ++tx_idx) {
         const bool isCoinbase = tx_idx == 0;
         const CTransaction &tx = *block.vtx[tx_idx];
         if (!isCoinbase && tx_idx - 1 >= block_undo.vtxundo.size()) {
             throw std::runtime_error("Missing undo data for tx");
         }
         const std::vector<Coin> &spent_coins =
             isCoinbase ? std::vector<Coin>()
                        : block_undo.vtxundo[tx_idx - 1].vprevout;
         bridged_txs.push_back(
             BridgeBlockTx(isCoinbase, tx, spent_coins, data_pos, undo_pos));
 
         // advance data_pos and undo_pos positions
         data_pos += ::GetSerializeSize(tx);
         if (!isCoinbase) {
             undo_pos += ::GetSerializeSize(block_undo.vtxundo[tx_idx - 1]);
         }
     }
 
     return {.hash = chronik::util::HashToArray(block.GetHash()),
             .prev_hash = chronik::util::HashToArray(block.hashPrevBlock),
             .n_bits = block.nBits,
             .timestamp = block.GetBlockTime(),
             .height = bindex.nHeight,
             .file_num = uint32_t(bindex.nFile),
             .data_pos = bindex.nDataPos,
             .undo_pos = bindex.nUndoPos,
             .size = ::GetSerializeSize(block),
             .txs = bridged_txs};
 }
 
 namespace chronik_bridge {
 
 void log_print(const rust::Str logging_function, const rust::Str source_file,
                const uint32_t source_line, const rust::Str msg) {
     LogInstance().LogPrintStr(std::string(msg), std::string(logging_function),
                               std::string(source_file), source_line);
 }
 
 void log_print_chronik(const rust::Str logging_function,
                        const rust::Str source_file, const uint32_t source_line,
                        const rust::Str msg) {
     if (LogInstance().WillLogCategory(BCLog::CHRONIK)) {
         log_print(logging_function, source_file, source_line, msg);
     }
 }
 
 const CBlockIndex &ChronikBridge::get_chain_tip() const {
     const CBlockIndex *tip =
         WITH_LOCK(cs_main, return m_node.chainman->ActiveTip());
     if (tip == nullptr) {
         throw block_index_not_found();
     }
     return *tip;
 }
 
 const CBlockIndex &
 ChronikBridge::lookup_block_index(std::array<uint8_t, 32> hash) const {
     BlockHash block_hash{chronik::util::ArrayToHash(hash)};
     const CBlockIndex *pindex = WITH_LOCK(
         cs_main,
         return m_node.chainman->m_blockman.LookupBlockIndex(block_hash));
     if (!pindex) {
         throw block_index_not_found();
     }
     return *pindex;
 }
 
 std::unique_ptr<CBlock>
 ChronikBridge::load_block(const CBlockIndex &bindex) const {
     CBlock block;
     if (!node::ReadBlockFromDisk(block, &bindex, m_consensus)) {
         throw std::runtime_error("Reading block data failed");
     }
     return std::make_unique<CBlock>(std::move(block));
 }
 
 Tx bridge_tx(const CTransaction &tx, const std::vector<::Coin> &spent_coins) {
     return BridgeTx(false, tx, spent_coins);
 }
 
 const CBlockIndex &ChronikBridge::find_fork(const CBlockIndex &index) const {
     const CBlockIndex *fork = WITH_LOCK(
         cs_main,
         return m_node.chainman->ActiveChainstate().m_chain.FindFork(&index));
     if (!fork) {
         throw block_index_not_found();
     }
     return *fork;
 }
 
+void ChronikBridge::lookup_spent_coins(
+    Tx &tx, rust::Vec<OutPoint> &not_found,
+    rust::Vec<OutPoint> &coins_to_uncache) const {
+    not_found.clear();
+    coins_to_uncache.clear();
+    LOCK(cs_main);
+    CCoinsViewCache &coins_cache =
+        m_node.chainman->ActiveChainstate().CoinsTip();
+    CCoinsViewMemPool coin_view(&coins_cache, *m_node.mempool);
+    for (TxInput &input : tx.inputs) {
+        TxId txid = TxId(chronik::util::ArrayToHash(input.prev_out.txid));
+        COutPoint outpoint = COutPoint(txid, input.prev_out.out_idx);
+
+        // Remember if coin was already cached
+        const bool had_cached = coins_cache.HaveCoinInCache(outpoint);
+
+        ::Coin coin;
+        if (!coin_view.GetCoin(outpoint, coin)) {
+            not_found.push_back(input.prev_out);
+            continue;
+        }
+
+        if (!had_cached) {
+            // Only add if previously uncached.
+            // We don't check if the prev_out is now cached (which wouldn't be
+            // the case for a mempool UTXO), as uncaching an outpoint is cheap,
+            // so we save one extra cache lookup here.
+            coins_to_uncache.push_back(input.prev_out);
+        }
+        input.coin = BridgeCoin(coin);
+    }
+}
+
+void ChronikBridge::uncache_coins(
+    rust::Slice<const OutPoint> coins_to_uncache) const {
+    LOCK(cs_main);
+    CCoinsViewCache &coins_cache =
+        m_node.chainman->ActiveChainstate().CoinsTip();
+    for (const OutPoint &outpoint : coins_to_uncache) {
+        TxId txid = TxId(chronik::util::ArrayToHash(outpoint.txid));
+        coins_cache.Uncache(COutPoint(txid, outpoint.out_idx));
+    }
+}
+
 std::array<uint8_t, 32>
 ChronikBridge::broadcast_tx(rust::Slice<const uint8_t> raw_tx,
                             int64_t max_fee) const {
     std::vector<uint8_t> vec = chronik::util::FromRustSlice(raw_tx);
     CDataStream stream{vec, SER_NETWORK, PROTOCOL_VERSION};
     CMutableTransaction tx;
     stream >> tx;
     CTransactionRef tx_ref = MakeTransactionRef(tx);
     std::string err_str;
     TransactionError error = node::BroadcastTransaction(
         m_node, tx_ref, err_str, max_fee * Amount::satoshi(), /*relay=*/true,
         /*wait_callback=*/false);
     if (error != TransactionError::OK) {
         bilingual_str txErrorMsg = TransactionErrorString(error);
         if (err_str.empty()) {
             throw std::runtime_error(txErrorMsg.original.c_str());
         } else {
             std::string msg = strprintf("%s: %s", txErrorMsg.original, err_str);
             throw std::runtime_error(msg.c_str());
         }
     }
     return chronik::util::HashToArray(tx_ref->GetId());
 }
 
 std::unique_ptr<ChronikBridge> make_bridge(const Config &config,
                                            const node::NodeContext &node) {
     return std::make_unique<ChronikBridge>(
         config.GetChainParams().GetConsensus(), node);
 }
 
 chronik_bridge::Block bridge_block(const CBlock &block,
                                    const CBlockIndex &bindex) {
     return BridgeBlock(block, bindex);
 }
 
 Tx load_tx(uint32_t file_num, uint32_t data_pos, uint32_t undo_pos) {
     CMutableTransaction tx;
     CTxUndo txundo{};
     const bool isCoinbase = undo_pos == 0;
     if (!node::ReadTxFromDisk(tx, FlatFilePos(file_num, data_pos))) {
         throw std::runtime_error("Reading tx data from disk failed");
     }
     if (!isCoinbase) {
         if (!node::ReadTxUndoFromDisk(txundo,
                                       FlatFilePos(file_num, undo_pos))) {
             throw std::runtime_error("Reading tx undo data from disk failed");
         }
     }
     return BridgeTx(isCoinbase, CTransaction(std::move(tx)), txundo.vprevout);
 }
 
 rust::Vec<uint8_t> load_raw_tx(uint32_t file_num, uint32_t data_pos) {
     CMutableTransaction tx;
     if (!node::ReadTxFromDisk(tx, FlatFilePos(file_num, data_pos))) {
         throw std::runtime_error("Reading tx data from disk failed");
     }
     CDataStream raw_tx{SER_NETWORK, PROTOCOL_VERSION};
     raw_tx << tx;
     return chronik::util::ToRustVec<uint8_t>(raw_tx);
 }
 
 BlockInfo get_block_info(const CBlockIndex &bindex) {
     return {
         .hash = chronik::util::HashToArray(bindex.GetBlockHash()),
         .height = bindex.nHeight,
     };
 }
 
 const CBlockIndex &get_block_ancestor(const CBlockIndex &index,
                                       int32_t height) {
     const CBlockIndex *pindex = index.GetAncestor(height);
     if (!pindex) {
         throw block_index_not_found();
     }
     return *pindex;
 }
 
 rust::Vec<uint8_t> compress_script(rust::Slice<const uint8_t> bytecode) {
     std::vector<uint8_t> vec = chronik::util::FromRustSlice(bytecode);
     CScript script{vec.begin(), vec.end()};
     CDataStream compressed{SER_NETWORK, PROTOCOL_VERSION};
     compressed << Using<ScriptCompression>(script);
     return chronik::util::ToRustVec<uint8_t>(compressed);
 }
 
 rust::Vec<uint8_t> decompress_script(rust::Slice<const uint8_t> compressed) {
     std::vector<uint8_t> vec = chronik::util::FromRustSlice(compressed);
     CDataStream stream{vec, SER_NETWORK, PROTOCOL_VERSION};
     CScript script;
     stream >> Using<ScriptCompression>(script);
     return chronik::util::ToRustVec<uint8_t>(script);
 }
 
 bool init_error(const rust::Str msg) {
     return InitError(Untranslated(std::string(msg)));
 }
 
 void abort_node(const rust::Str msg, const rust::Str user_msg) {
     AbortNode(std::string(msg), Untranslated(std::string(user_msg)));
 }
 
 bool shutdown_requested() {
     return ShutdownRequested();
 }
 
 } // namespace chronik_bridge
diff --git a/chronik/chronik-cpp/chronik_bridge.h b/chronik/chronik-cpp/chronik_bridge.h
index f185e387f..770ee26d8 100644
--- a/chronik/chronik-cpp/chronik_bridge.h
+++ b/chronik/chronik-cpp/chronik_bridge.h
@@ -1,97 +1,102 @@
 // Copyright (c) 2022 The Bitcoin developers
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
 #ifndef BITCOIN_CHRONIK_CPP_CHRONIK_BRIDGE_H
 #define BITCOIN_CHRONIK_CPP_CHRONIK_BRIDGE_H
 
 #include <memory>
 #include <rust/cxx.h>
 #include <vector>
 
 class CBlock;
 class CBlockIndex;
 class Coin;
 class Config;
 class CTransaction;
 
 namespace Consensus {
 struct Params;
 } // namespace Consensus
 
 namespace node {
 struct NodeContext;
 } // namespace node
 class uint256;
 
 namespace chronik_bridge {
 
 struct BlockInfo;
 struct Block;
 struct Tx;
+struct OutPoint;
 
 class block_index_not_found : public std::exception {
 public:
     const char *what() const noexcept override {
         return "CBlockIndex not found";
     }
 };
 
 void log_print(const rust::Str logging_function, const rust::Str source_file,
                const uint32_t source_line, const rust::Str msg);
 
 void log_print_chronik(const rust::Str logging_function,
                        const rust::Str source_file, const uint32_t source_line,
                        const rust::Str msg);
 
 /**
  * Bridge to bitcoind to access the node.
  */
 class ChronikBridge {
     const Consensus::Params &m_consensus;
     const node::NodeContext &m_node;
 
 public:
     ChronikBridge(const Consensus::Params &consensus,
                   const node::NodeContext &node)
         : m_consensus(consensus), m_node(node) {}
 
     const CBlockIndex &get_chain_tip() const;
 
     const CBlockIndex &lookup_block_index(std::array<uint8_t, 32> hash) const;
 
     std::unique_ptr<CBlock> load_block(const CBlockIndex &bindex) const;
 
     const CBlockIndex &find_fork(const CBlockIndex &index) const;
 
+    void lookup_spent_coins(Tx &, rust::Vec<OutPoint> &not_found,
+                            rust::Vec<OutPoint> &coins_to_uncache) const;
+    void uncache_coins(rust::Slice<const OutPoint>) const;
+
     std::array<uint8_t, 32> broadcast_tx(rust::Slice<const uint8_t> raw_tx,
                                          int64_t max_fee) const;
 };
 
 std::unique_ptr<ChronikBridge> make_bridge(const Config &config,
                                            const node::NodeContext &node);
 
 Tx bridge_tx(const CTransaction &tx, const std::vector<Coin> &spent_coins);
 
 Block bridge_block(const CBlock &block, const CBlockIndex &bindex);
 
 Tx load_tx(uint32_t file_num, uint32_t data_pos, uint32_t undo_pos);
 rust::Vec<uint8_t> load_raw_tx(uint32_t file_num, uint32_t data_pos);
 
 BlockInfo get_block_info(const CBlockIndex &index);
 
 const CBlockIndex &get_block_ancestor(const CBlockIndex &index, int32_t height);
 
 rust::Vec<uint8_t> compress_script(rust::Slice<const uint8_t> script);
 
 rust::Vec<uint8_t> decompress_script(rust::Slice<const uint8_t> compressed);
 
 bool init_error(const rust::Str msg);
 
 void abort_node(const rust::Str msg, const rust::Str user_msg);
 
 bool shutdown_requested();
 
 } // namespace chronik_bridge
 
 #endif // BITCOIN_CHRONIK_CPP_CHRONIK_BRIDGE_H
diff --git a/chronik/test/chronikbridge_tests.cpp b/chronik/test/chronikbridge_tests.cpp
index a62caaf37..41117e1da 100644
--- a/chronik/test/chronikbridge_tests.cpp
+++ b/chronik/test/chronikbridge_tests.cpp
@@ -1,233 +1,334 @@
 // Copyright (c) 2022 The Bitcoin developers
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
 #include <chainparams.h>
 #include <chronik-bridge/src/ffi.rs.h>
 #include <chronik-cpp/chronik_bridge.h>
 #include <chronik-cpp/util/hash.h>
 #include <config.h>
 #include <streams.h>
 #include <util/strencodings.h>
 #include <validation.h>
 
 #include <test/util/setup_common.h>
 
 #include <boost/test/unit_test.hpp>
 
 BOOST_AUTO_TEST_SUITE(chronikbridge_tests)
 
 BOOST_FIXTURE_TEST_CASE(test_get_chain_tip_empty, ChainTestingSetup) {
     // Setup chainstate
     {
         LOCK(::cs_main);
         m_node.chainman->InitializeChainstate(m_node.mempool.get());
     }
     // Chain has no blocks yet:
     // get_chain_tip throws block_index_not_found
     const CChainParams &params = GetConfig().GetChainParams();
     const chronik_bridge::ChronikBridge bridge(params.GetConsensus(), m_node);
     BOOST_CHECK_THROW(bridge.get_chain_tip(),
                       chronik_bridge::block_index_not_found);
 }
 
 BOOST_FIXTURE_TEST_CASE(test_get_chain_tip_genesis, TestingSetup) {
     const CChainParams &params = GetConfig().GetChainParams();
     const chronik_bridge::ChronikBridge bridge(params.GetConsensus(), m_node);
     // Check for genesis block
     const CBlockIndex &bindex = bridge.get_chain_tip();
     BOOST_CHECK_EQUAL(bindex.GetBlockHash(), params.GenesisBlock().GetHash());
 }
 
 BOOST_FIXTURE_TEST_CASE(test_get_chain_tip_100, TestChain100Setup) {
     const CChainParams &params = GetConfig().GetChainParams();
     // Generate new block (at height 101)
     CBlock tip_block = CreateAndProcessBlock(
         {}, CScript() << std::vector<uint8_t>(33) << OP_CHECKSIG);
     const chronik_bridge::ChronikBridge bridge(params.GetConsensus(), m_node);
     // Check if block is 101th
     const CBlockIndex &bindex = bridge.get_chain_tip();
     BOOST_CHECK_EQUAL(bindex.GetBlockHash(), tip_block.GetHash());
 }
 
 BOOST_FIXTURE_TEST_CASE(test_lookup_block_index, TestChain100Setup) {
     const CChainParams &params = GetConfig().GetChainParams();
     const chronik_bridge::ChronikBridge bridge(params.GetConsensus(), m_node);
     BlockHash genesis_hash = params.GenesisBlock().GetHash();
     const CBlockIndex &bindex_genesis =
         bridge.lookup_block_index(chronik::util::HashToArray(genesis_hash));
     BOOST_CHECK_EQUAL(bindex_genesis.GetBlockHash(), genesis_hash);
 
     // Generate new block (at height 101)
     CBlock tip_block = CreateAndProcessBlock(
         {}, CScript() << std::vector<uint8_t>(33) << OP_CHECKSIG);
     // Query block
     const CBlockIndex &bindex_tip = bridge.lookup_block_index(
         chronik::util::HashToArray(tip_block.GetHash()));
     BOOST_CHECK_EQUAL(bindex_tip.GetBlockHash(), tip_block.GetHash());
 
     // Block 000...000 doesn't exist
     BOOST_CHECK_THROW(bridge.lookup_block_index({}),
                       chronik_bridge::block_index_not_found);
 }
 
 BOOST_FIXTURE_TEST_CASE(test_find_fork, TestChain100Setup) {
     const CChainParams &params = GetConfig().GetChainParams();
     const chronik_bridge::ChronikBridge bridge(params.GetConsensus(), m_node);
     ChainstateManager &chainman = *Assert(m_node.chainman);
     CBlockIndex *tip = chainman.ActiveTip();
 
     // Fork of the tip is the tip
     BOOST_CHECK_EQUAL(bridge.find_fork(*tip).GetBlockHash(),
                       tip->GetBlockHash());
     // Fork of the genesis block is the genesis block
     BOOST_CHECK_EQUAL(bridge.find_fork(*tip->GetAncestor(0)).GetBlockHash(),
                       params.GenesisBlock().GetHash());
 
     // Invalidate block in the middle of the chain
     BlockValidationState state;
     chainman.ActiveChainstate().InvalidateBlock(GetConfig(), state,
                                                 tip->GetAncestor(50));
 
     // Mine 100 blocks, up to height 150
     mineBlocks(100);
 
     // Fork of old tip is block 49
     BOOST_CHECK_EQUAL(bridge.find_fork(*tip).GetBlockHash(),
                       chainman.ActiveTip()->GetAncestor(49)->GetBlockHash());
 }
 
+BOOST_FIXTURE_TEST_CASE(test_lookup_spent_coin, TestChain100Setup) {
+    ChainstateManager &chainman = *Assert(m_node.chainman);
+    LOCK(cs_main);
+    const CChainParams &params = GetConfig().GetChainParams();
+    const chronik_bridge::ChronikBridge bridge(params.GetConsensus(), m_node);
+    CCoinsViewCache &coins_cache = chainman.ActiveChainstate().CoinsTip();
+
+    CScript anyoneScript = CScript() << OP_1;
+    CScript anyoneP2sh = GetScriptForDestination(ScriptHash(anyoneScript));
+    CBlock coinsBlock =
+        CreateAndProcessBlock({}, anyoneP2sh, &chainman.ActiveChainstate());
+    mineBlocks(100);
+
+    const CTransactionRef coinTx = coinsBlock.vtx[0];
+
+    CScript scriptPad = CScript() << OP_RETURN << std::vector<uint8_t>(100);
+    CMutableTransaction tx;
+    tx.vin = {CTxIn(
+        coinTx->GetId(), 0,
+        CScript() << std::vector(anyoneScript.begin(), anyoneScript.end()))};
+    tx.vout = {
+        CTxOut(1000 * SATOSHI, anyoneP2sh),
+        CTxOut(coinTx->vout[0].nValue - 10000 * SATOSHI, anyoneP2sh),
+    };
+    const MempoolAcceptResult result =
+        m_node.chainman->ProcessTransaction(MakeTransactionRef(tx));
+    BOOST_CHECK_EQUAL(result.m_result_type,
+                      MempoolAcceptResult::ResultType::VALID);
+    TxId txid = tx.GetId();
+
+    // Tx we look up coins for
+    chronik_bridge::Tx query_tx = {
+        .inputs = {
+            {.prev_out = {chronik::util::HashToArray(txid), 0}},
+            {.prev_out = {chronik::util::HashToArray(txid), 1}},
+            {.prev_out = {{}, 0x12345678}},
+        }};
+
+    // Do lookup
+    rust::Vec<chronik_bridge::OutPoint> not_found;
+    rust::Vec<chronik_bridge::OutPoint> coins_to_uncache;
+    bridge.lookup_spent_coins(query_tx, not_found, coins_to_uncache);
+
+    // One of the coins was not found
+    BOOST_CHECK_EQUAL(not_found.size(), 1);
+    BOOST_CHECK(not_found[0] == query_tx.inputs[2].prev_out);
+
+    // Mempool UTXOs aren't in the cache, so lookup_spent_coins thinks they need
+    // to be uncached, which seems weird but is intended behavior.
+    BOOST_CHECK_EQUAL(coins_to_uncache.size(), 2);
+    BOOST_CHECK(coins_to_uncache[0] == query_tx.inputs[0].prev_out);
+    BOOST_CHECK(coins_to_uncache[1] == query_tx.inputs[1].prev_out);
+    BOOST_CHECK(!coins_cache.HaveCoinInCache(COutPoint(txid, 0)));
+    BOOST_CHECK(!coins_cache.HaveCoinInCache(COutPoint(txid, 1)));
+
+    // lookup_spent_coins mutates our query_tx to set the queried coins
+    const rust::Vec<uint8_t> &script0 = query_tx.inputs[0].coin.output.script;
+    const rust::Vec<uint8_t> &script1 = query_tx.inputs[1].coin.output.script;
+    BOOST_CHECK_EQUAL(query_tx.inputs[0].coin.output.value, 1000);
+    BOOST_CHECK(CScript(script0.data(), script0.data() + script0.size()) ==
+                anyoneP2sh);
+    BOOST_CHECK_EQUAL(query_tx.inputs[1].coin.output.value,
+                      coinTx->vout[0].nValue / SATOSHI - 10000);
+    BOOST_CHECK(CScript(script1.data(), script1.data() + script1.size()) ==
+                anyoneP2sh);
+
+    // Mine tx
+    CreateAndProcessBlock({tx}, CScript() << OP_1,
+                          &chainman.ActiveChainstate());
+    // Coins are now in the cache
+    BOOST_CHECK(coins_cache.HaveCoinInCache(COutPoint(txid, 0)));
+    BOOST_CHECK(coins_cache.HaveCoinInCache(COutPoint(txid, 1)));
+
+    // Write cache to DB & clear cache
+    coins_cache.Flush();
+    BOOST_CHECK(!coins_cache.HaveCoinInCache(COutPoint(txid, 0)));
+    BOOST_CHECK(!coins_cache.HaveCoinInCache(COutPoint(txid, 1)));
+
+    // lookup puts the coins back into the cache
+    bridge.lookup_spent_coins(query_tx, not_found, coins_to_uncache);
+    BOOST_CHECK_EQUAL(coins_to_uncache.size(), 2);
+    BOOST_CHECK(coins_to_uncache[0] == query_tx.inputs[0].prev_out);
+    BOOST_CHECK(coins_to_uncache[1] == query_tx.inputs[1].prev_out);
+    BOOST_CHECK(coins_cache.HaveCoinInCache(COutPoint(txid, 0)));
+    BOOST_CHECK(coins_cache.HaveCoinInCache(COutPoint(txid, 1)));
+
+    // Now, we don't get any coins_to_uncache (because the call didn't add
+    // anything to it)
+    bridge.lookup_spent_coins(query_tx, not_found, coins_to_uncache);
+    BOOST_CHECK_EQUAL(coins_to_uncache.size(), 0);
+
+    // Call uncache_coins to uncache the 1st coin
+    const std::vector<chronik_bridge::OutPoint> uncache_outpoints{
+        query_tx.inputs[0].prev_out};
+    bridge.uncache_coins(
+        rust::Slice(uncache_outpoints.data(), uncache_outpoints.size()));
+    // Only the 2nd coin is now in cache
+    BOOST_CHECK(!coins_cache.HaveCoinInCache(COutPoint(txid, 0)));
+    BOOST_CHECK(coins_cache.HaveCoinInCache(COutPoint(txid, 1)));
+}
+
 BOOST_FIXTURE_TEST_CASE(test_load_block, TestChain100Setup) {
     const CChainParams &params = GetConfig().GetChainParams();
     const chronik_bridge::ChronikBridge bridge(params.GetConsensus(), m_node);
     ChainstateManager &chainman = *Assert(m_node.chainman);
     const CBlockIndex &tip = *chainman.ActiveTip();
 
     BOOST_CHECK_EQUAL(bridge.load_block(tip)->GetHash(), tip.GetBlockHash());
 
     {
         CDataStream expected(SER_NETWORK, PROTOCOL_VERSION);
         CDataStream actual(SER_NETWORK, PROTOCOL_VERSION);
         expected << params.GenesisBlock();
         actual << *bridge.load_block(*tip.GetAncestor(0));
         BOOST_CHECK_EQUAL(HexStr(actual), HexStr(expected));
     }
 }
 
 BOOST_FIXTURE_TEST_CASE(test_get_block_ancestor, TestChain100Setup) {
     const CChainParams &params = GetConfig().GetChainParams();
     ChainstateManager &chainman = *Assert(m_node.chainman);
     const CBlockIndex &tip = *chainman.ActiveTip();
 
     // Block 100 is the tip
     BOOST_CHECK_EQUAL(
         chronik_bridge::get_block_ancestor(tip, 100).GetBlockHash(),
         tip.GetBlockHash());
 
     // Block 99 is the prev of the tip
     BOOST_CHECK_EQUAL(
         chronik_bridge::get_block_ancestor(tip, 99).GetBlockHash(),
         tip.GetBlockHeader().hashPrevBlock);
 
     // Genesis block is block 0
     BOOST_CHECK_EQUAL(chronik_bridge::get_block_ancestor(tip, 0).GetBlockHash(),
                       params.GenesisBlock().GetHash());
 
     // Block -1 doesn't exist
     BOOST_CHECK_THROW(chronik_bridge::get_block_ancestor(tip, -1),
                       chronik_bridge::block_index_not_found);
 
     // Block 101 doesn't exist
     BOOST_CHECK_THROW(chronik_bridge::get_block_ancestor(tip, tip.nHeight + 1),
                       chronik_bridge::block_index_not_found);
 }
 
 BOOST_FIXTURE_TEST_CASE(test_get_block_info, TestChain100Setup) {
     const CChainParams &params = GetConfig().GetChainParams();
     const chronik_bridge::ChronikBridge bridge(params.GetConsensus(), m_node);
     ChainstateManager &chainman = *Assert(m_node.chainman);
     const CBlockIndex &tip = *chainman.ActiveTip();
 
     chronik_bridge::BlockInfo expected_genesis_info{
         .hash = chronik::util::HashToArray(params.GenesisBlock().GetHash()),
         .height = 0};
     BOOST_CHECK(chronik_bridge::get_block_info(*tip.GetAncestor(0)) ==
                 expected_genesis_info);
 
     chronik_bridge::BlockInfo expected_tip_info{
         .hash = chronik::util::HashToArray(tip.GetBlockHash()),
         .height = tip.nHeight};
     BOOST_CHECK(chronik_bridge::get_block_info(tip) == expected_tip_info);
 }
 
 BOOST_FIXTURE_TEST_CASE(test_bridge_broadcast_tx, TestChain100Setup) {
     ChainstateManager &chainman = *Assert(m_node.chainman);
     const CChainParams &params = GetConfig().GetChainParams();
     const chronik_bridge::ChronikBridge bridge(params.GetConsensus(), m_node);
 
     CScript anyoneScript = CScript() << OP_1;
     CScript anyoneP2sh = GetScriptForDestination(ScriptHash(anyoneScript));
     CBlock coinsBlock =
         CreateAndProcessBlock({}, anyoneP2sh, &chainman.ActiveChainstate());
     mineBlocks(100);
 
     const CTransactionRef coinTx = coinsBlock.vtx[0];
 
     CScript scriptPad = CScript() << OP_RETURN << std::vector<uint8_t>(100);
     CMutableTransaction tx;
     tx.vin = {CTxIn(coinTx->GetId(), 0)};
     tx.vout = {
         CTxOut(coinTx->vout[0].nValue - 10000 * SATOSHI, scriptPad),
     };
 
     {
         // Failed broadcast: mempool rejected tx
         CDataStream ss(SER_NETWORK, PROTOCOL_VERSION);
         ss << tx;
         rust::Slice<const uint8_t> raw_tx{(const uint8_t *)ss.data(),
                                           ss.size()};
         BOOST_CHECK_EXCEPTION(
             bridge.broadcast_tx(raw_tx, 10000), std::runtime_error,
             [](const std::runtime_error &ex) {
                 BOOST_CHECK_EQUAL(
                     ex.what(), "Transaction rejected by mempool: "
                                "mandatory-script-verify-flag-failed (Operation "
                                "not valid with the current stack size)");
                 return true;
             });
     }
 
     tx.vin[0].scriptSig =
         CScript() << std::vector(anyoneScript.begin(), anyoneScript.end());
 
     {
         // Failed broadcast from excessive fee (10000 > 9999).
         CDataStream ss(SER_NETWORK, PROTOCOL_VERSION);
         ss << tx;
         rust::Slice<const uint8_t> raw_tx{(const uint8_t *)ss.data(),
                                           ss.size()};
         BOOST_CHECK_EXCEPTION(
             bridge.broadcast_tx(raw_tx, 9999), std::runtime_error,
             [](const std::runtime_error &ex) {
                 BOOST_CHECK_EQUAL(ex.what(),
                                   "Fee exceeds maximum configured by user "
                                   "(e.g. -maxtxfee, maxfeerate)");
                 return true;
             });
     }
 
     {
         // Successful broadcast
         CDataStream ss(SER_NETWORK, PROTOCOL_VERSION);
         ss << tx;
         rust::Slice<const uint8_t> raw_tx{(const uint8_t *)ss.data(),
                                           ss.size()};
         BOOST_CHECK_EQUAL(HexStr(bridge.broadcast_tx(raw_tx, 10000)),
                           HexStr(chronik::util::HashToArray(tx.GetId())));
         // Broadcast again simply returns the hash (fee check skipped here)
         BOOST_CHECK_EQUAL(HexStr(bridge.broadcast_tx(raw_tx, 9999)),
                           HexStr(chronik::util::HashToArray(tx.GetId())));
     }
 }
 
 BOOST_AUTO_TEST_SUITE_END()