Source code for btclib_node.chainstate.utxo_index

# Copyright (c) The btclib developers
# Distributed under the MIT software license, see the accompanying
# LICENSE file or https://opensource.org/license/mit for the full text.

"""`UtxoIndex`, the set of transaction outputs a spend may still reference.

`add_block` applies one block's own spends and creations, returning the
prevouts each transaction consumed -- what `interpreter.check_transactions`
validates against -- and the `block_db.RevBlock` a reorg away from this
block would need to undo it.
"""

from typing import TYPE_CHECKING, Any

from btclib.exceptions import BTClibValueError
from btclib.tx.out_point import OutPoint

from btclib_node.block_db import Coin, RevBlock
from btclib_node.chainstate.muhash import (
    CoinStats,
    is_bip30_unspendable,
    is_unspendable,
)
from btclib_node.exceptions import ChainstateInconsistencyError, InvalidBlockInputError

if TYPE_CHECKING:
    from btclib.block import Block
    from btclib.tx.tx import Tx

    from btclib_node.db import KeyValueStore
    from btclib_node.log import Logger

__all__ = ["UtxoIndex"]

# What `_undo_log` records in place of a dict entry's own prior value,
# for a key `_set` finds absent -- `None` cannot stand in for this, a
# `Coin` being a legitimate value nothing else ever stores under a key
# and `None` still being a distinct, real "was there" answer for the
# set entries `_add` logs the same way.
_UNSET = object()

# how many entries removed_utxos and updated_utxo_set may hold together
# before should_flush says it is time to write them out. In entries and
# not in bytes: Core's own -dbcache bounds CCoinsViewCache in memory,
# 450 MiB by default (validation.h, DEFAULT_DB_CACHE), but what backs
# these two here is a pair of plain Python dicts, and a dict entry's
# real footprint -- the Coin, the key, the object header, the table
# slot -- is not one a reader can check the way an entry count can be
# counted directly off should_flush's own two len() calls. A wrong
# bound in entries costs memory or flushes; a wrong bound in bytes,
# believed as bytes, costs a reader trusting an estimate nobody can
# verify against the object it is about.
#
# Measured rather than estimated: 500,000 (serialized OutPoint, Coin)
# pairs in a plain dict, built and held while `tracemalloc` traces the
# process, come to about 229 MB -- the same order as Core's own 450 MiB
# default above, and the bound this tree chose costs proportionately
# less because `updated_utxo_set` never holds a whole UTXO set at once,
# only what has connected since the last flush.
#
# 500,000, sized against the block this tree has actually measured
# (btclib-org/btclib-node#586): height 964,000 staged 7,778 deletes and
# 8,100 puts, 15,878 entries, so the bound holds a little over thirty
# blocks that dense before a flush is due -- amortizing each flush's own
# fixed cost (one write_batch, one BlockIndex/FilterIndex write) across
# that many blocks instead of one. A block far earlier in the chain, an
# order of magnitude smaller, holds proportionally more of them staged
# at once, which is the other side of the same bound rather than a
# second one: what it costs on a crash is argued in db.py's docstring.
_FLUSH_BOUND = 500_000

# UtxoIndex's own key into KeyValueStore's meta column family
# (db.py's own get_meta/put_meta): CoinStats.serialize's own bytes,
# restored on __init__ and written by finalize in the same write_batch
# as the coins it commits to (db.py's docstring argues why).
_COIN_STATS_META_KEY = b"coinstats"

# `_undo_log`'s own entry shape is one of two, discriminated by the
# first element: a dict/set mutation, whose second and third elements
# are the key and the prior value `_put`/`_pop`/`_mark_removed`/
# `_unmark_removed` above already carry; or a `coin_stats` mutation,
# whose second element is `True` for an insert and `False` for a
# remove (`rollback` below reads it that way) and whose third is the
# `(out_point_bytes, coin)` pair `_hash_insert`/`_hash_remove` replay
# the opposite call with -- not a prior value, `coin_stats` needing
# none (the class docstring's own paragraph on it argues why).
_DictOrSetUndoEntry = tuple[dict[bytes, Any] | set[bytes], bytes, Any]
_CoinStatsUndoEntry = tuple[CoinStats, bool, tuple[bytes, Coin]]
_UndoEntry = _DictOrSetUndoEntry | _CoinStatsUndoEntry


[docs] class UtxoIndex: """The set of spendable outputs, staged in memory until `finalize`. `removed_utxos` and `updated_utxo_set` hold what a batch of `add_block`/`apply_rev_block` calls has changed since the last `finalize`; the module docstring above is where `add_block`'s own return value is argued. The staging now survives more than one block -- `should_flush` is what tells a caller it is time to stop piling more of it on and write, and `db.py`'s docstring is where what a crash before that costs is decided. That survival is what makes `rollback` unable to stay a blanket wipe: a trial `main.update_chain` rolls back may run against staging several *earlier*, already-succeeded blocks left behind, unflushed, and wiping the two dicts to empty would discard those too -- state a failed trial never touched and has no claim over. `_undo_log` is what tells the two apart: every mutation `add_block` and `apply_rev_block` make is recorded there as it happens, and `rollback(mark)` undoes only what was recorded since `mark` (`trial_mark`'s own reading, taken before the trial that might fail began), in reverse, leaving anything recorded before it standing. `coin_stats` is the running commitment to the same set -- `chainstate/muhash.py`'s own `CoinStats`, restored from `parent_db`'s meta column family here and staged the same way the two dicts above are: every `_hash_insert`/`_hash_remove` this class makes is logged into the same `_undo_log`, `CoinStats.insert` and `.remove` being each other's exact undo regardless of order (`muhash.py`'s own docstring argues why), so `rollback` needs no prior accumulator state recorded, only that the opposite call replays. """ def __init__(self, parent_db: KeyValueStore, logger: Logger) -> None: """Start with nothing staged, using `parent_db` for reads and writes.""" self.db = parent_db self.removed_utxos: set[bytes] = set() self.updated_utxo_set: dict[bytes, Coin] = {} self._undo_log: list[_UndoEntry] = [] stored_stats = parent_db.get_meta(_COIN_STATS_META_KEY) self.coin_stats = ( CoinStats.deserialize(stored_stats) if stored_stats is not None else CoinStats() ) self.logger = logger def _bip30_violation(self, out_point_bytes: bytes) -> bool: """Whether `out_point_bytes` already names a still-unspent coin. `_check_bip30` below asks this of every output the block is about to create, before staging any of them -- the same "not yet mutated" state `add_block`'s own prevout resolution reads, so a transaction earlier in this same block being processed can never make a later one's check see its own not-yet-applied write. Checking `removed_utxos` before `updated_utxo_set` is safe only because the two are disjoint: no outpoint bytes value is ever staged in both at once, because every `_put` call site in this module -- `apply_rev_block`'s own `to_add` loop and both of `add_block`'s own creation loops -- runs `_unmark_removed` on that same key first. A key that reaches `_put` still marked removed would otherwise answer `False` here on that account alone, before this order ever reaches `updated_utxo_set` or the store, hiding a genuine BIP30 duplicate of that prevout rather than only the double-spend-guard failure `apply_rev_block`'s own docstring names (btclib-org/btclib-node#586). """ if out_point_bytes in self.removed_utxos: return False return out_point_bytes in self.updated_utxo_set or bool( self.db.get(b"utxo-" + out_point_bytes) ) def _check_bip30(self, block: Block) -> None: """Refuse `block` if it duplicates a still-unspent output. A method of its own rather than a loop inline in `add_block`, which ruff's own `complex-structure`/`too-many-branches` already count every statement here against -- `add_block`'s own docstring is where the check itself, its ordering and its two historical exceptions are all argued. """ for tx in block.transactions: for i in range(len(tx.vout)): out_point_bytes = OutPoint(tx.id, i, check_validity=False).serialize( check_validity=False ) if self._bip30_violation(out_point_bytes): err_msg = "bad-txns-BIP30" raise InvalidBlockInputError(err_msg)
[docs] def trial_mark(self) -> int: """Return a point in the undo log a failed trial can be rolled back to. Taken before `add_block`/`apply_rev_block` are ever called for that trial; `rollback` undoes back to exactly this point, `main.update_chain`'s own docstring above being the one caller. """ return len(self._undo_log)
def _put(self, out_point_bytes: bytes, coin: Coin) -> None: """`updated_utxo_set[out_point_bytes] = coin`, logged for `rollback`.""" self._undo_log.append( ( self.updated_utxo_set, out_point_bytes, self.updated_utxo_set.get(out_point_bytes, _UNSET), ) ) self.updated_utxo_set[out_point_bytes] = coin def _pop(self, out_point_bytes: bytes) -> Coin: """`updated_utxo_set.pop(out_point_bytes)`, logged for `rollback`.""" coin = self.updated_utxo_set.pop(out_point_bytes) self._undo_log.append((self.updated_utxo_set, out_point_bytes, coin)) return coin def _mark_removed(self, out_point_bytes: bytes) -> None: """`removed_utxos.add(out_point_bytes)`, logged for `rollback`.""" self._undo_log.append( (self.removed_utxos, out_point_bytes, out_point_bytes in self.removed_utxos) ) self.removed_utxos.add(out_point_bytes) def _unmark_removed(self, out_point_bytes: bytes) -> None: """`removed_utxos.discard(out_point_bytes)`, logged for `rollback`. `apply_rev_block` below is the one caller: restoring a prevout a block spent has to undo whichever of `_pop` or `_mark_removed` `add_block` used to stage that spend, and only `_mark_removed` touches `removed_utxos` -- so this runs unconditionally, the same way `_mark_removed` itself logs unconditionally, and is a no-op precisely when the spend it undoes never reached `removed_utxos` in the first place. """ self._undo_log.append( (self.removed_utxos, out_point_bytes, out_point_bytes in self.removed_utxos) ) self.removed_utxos.discard(out_point_bytes) def _stored_prevout(self, prevout_bytes: bytes) -> Coin | None: """Return the `Coin` a stored `utxo-` record still resolves to. `None` both for no such key and for a key whose bytes `Coin.parse` cannot read: a `utxo-` key is one only this node's own `finalize` ever writes, so a parse failure here is over this node's own stored bytes, never over a caller's content -- but RocksDB's own per-block checksum (btclib-org/btclib-node#641) has already turned a genuinely corrupted record into `StoreCorruptionError` before this call is ever reached, so what a parse failure means here is a checksum-clean record that still does not deserialize: exactly the fault `CDBWrapper::Read`/`CCoinsViewDB::GetCoin` answer absent to (`src/dbwrapper.h`, `src/txdb.cpp`, at bitcoin/bitcoin@ca7162cde5). Matching that answer with `None` rather than raising `ChainstateInconsistencyError` is btclib-org/btclib-node#650's own decision, argued in that class's own docstring; `add_block` and `get_coin`, this method's own two callers, are where it used to be raised. """ prevout_data = self.db.get(b"utxo-" + prevout_bytes) if prevout_data is None: return None try: return Coin.parse(prevout_data, check_validity=False) except BTClibValueError: return None def _hash_insert(self, out_point_bytes: bytes, coin: Coin) -> None: """`coin_stats.insert(out_point_bytes, coin)`, logged for `rollback`. The class docstring's own paragraph on `coin_stats` is where the undo -- `_hash_remove` below, replayed rather than a prior value restored -- is argued. """ self.coin_stats.insert(out_point_bytes, coin) self._undo_log.append((self.coin_stats, True, (out_point_bytes, coin))) def _hash_remove(self, out_point_bytes: bytes, coin: Coin) -> None: """`coin_stats.remove(out_point_bytes, coin)`, logged for `rollback`.""" self.coin_stats.remove(out_point_bytes, coin) self._undo_log.append((self.coin_stats, False, (out_point_bytes, coin))) def _stage_creation( self, out_point_bytes: bytes, coin: Coin, *, hash_it: bool ) -> bool: """Unmark, put, and usually hash in, one created output. Returns whether the output was staged at all: `False`, a no-op, for a provably unspendable one (`is_unspendable`) -- matching `CCoinsViewCache::AddCoin` (`src/coins.cpp:82`, at bitcoin/bitcoin@ca7162cde5), which returns without adding such an output to Core's own UTXO set in the first place (`muhash.py`'s own "What is inserted, and what is not" argues why the digest already agreed with Core on this before the store did, btclib-org/btclib-node#667). `add_block`'s own two creation loops use the return value to keep such an outpoint out of `added` too, so `apply_rev_block` is never asked to undo a creation this call never staged -- an unspendable output that is never stored is never restored. `add_block`'s own two creation loops share this rather than each repeating `_unmark_removed` + `_put` + a conditional `_hash_insert` inline -- what ruff's own `complex-structure` already counts every branch of `add_block` itself against. `hash_it=False` only ever for the coinbase loop's own `is_bip30_unspendable` gate, and moot for an unspendable output either way, this method returning before that check is ever reached. """ if is_unspendable(coin.tx_out.script_pub_key.script): return False self._unmark_removed(out_point_bytes) self._put(out_point_bytes, coin) if hash_it: self._hash_insert(out_point_bytes, coin) return True def _stage_added( self, added: list[OutPoint], out_point: OutPoint, coin: Coin, *, hash_it: bool ) -> None: """Stage a creation and record it in `added` where it was stored. `add_block`'s own two creation loops share this the same way they already share `_stage_creation` above: an inline `if` around each call site is exactly the extra branch ruff's own `complex-structure` counts against `add_block` itself, at any layout the check were written in instead. """ out_point_bytes = out_point.serialize(check_validity=False) if self._stage_creation(out_point_bytes, coin, hash_it=hash_it): added.append(out_point)
[docs] def should_flush(self) -> bool: """Whether the staged size has reached `_FLUSH_BOUND`. `main._finalize_fork` is the one caller, and it is not asking this alone: `Chainstate.flush` writes `BlockIndex` and `FilterIndex` in the same batch this triggers, which is what keeps the three in step (`db.py`'s own docstring argues why). """ return len(self.removed_utxos) + len(self.updated_utxo_set) >= _FLUSH_BOUND
[docs] def add_block( self, block: Block, height: int, *, check_bip30: bool = True ) -> tuple[list[tuple[list[Coin], Tx]], RevBlock]: """Apply `block`'s own spends and creations, staged rather than written. `height` is this block's own height, on whichever branch it is being tried -- what every output it creates is stamped with, coin and coinbase alike, and never the height a later reorg disconnects or reconnects it at: `apply_rev_block` below restores a `Coin` exactly as this call staged it for removal, height and coinbase bit included, rather than recomputing either. `check_bip30` refuses a block that "overwrites" an output still unspent from anywhere earlier on the chain -- Core's own `bad-txns-BIP30` (`ConnectBlock`, `src/validation.cpp:2401-2431`, at bitcoin/bitcoin@204256c73f), CVE-2012-1909's shape: without it, a coinbase sharing an already-mined, still-unspent txid overwrites that output in place, and a reorg away from the second block deletes an output the first block's own branch still carries. Checked over every transaction the block carries, coinbase included, matching Core's own loop -- and before either of the two loops below stages a single write, since Core's own check runs against the view exactly as it stood before this block, coinbase and ordinary spends alike. `False` only for the two 2010 blocks `Chain.bip30_exceptions` names, which predate BIP34 (btclib-org/btclib-node#571) and so predate the property that makes a new violation of this kind unreachable once BIP34 binds: a block's own coinbase commits to its own real height, which two different heights can never share, so the outpoint a block's own coinbase creates cannot already belong to an earlier block's coinbase -- and `UtxoIndex.add_block` un-stages an entire block atomically on any raise, this one included, so a refused duplicate never reaches the two loops below that would otherwise stage a write over it. Both loops below call `_unmark_removed` on an outpoint's own bytes before every `_put` of it, the same order `apply_rev_block`'s own `to_add` loop uses to restore one: a key this call creates can coincide with one `removed_utxos` still carries only when the two share a txid, `check_bip30` above being what refuses that for every case but the two historical exceptions -- so the unmark is a no-op everywhere else, and is what keeps `removed_utxos` and `updated_utxo_set` disjoint rather than leaving a recreated outpoint staged in both at once (`_bip30_violation`'s own docstring is where that invariant is used, and btclib-org/btclib-node#586 is where staging it in both broke a later `apply_rev_block` on a coin that was legitimately unspent). Returns each non-coinbase transaction paired with the prevouts its own inputs consumed -- what `interpreter.check_transactions` validates against -- and the `RevBlock` that undoes this call. Every output this stages and every prevout it spends also moves `coin_stats`, the running commitment to the set -- inserted for a creation, removed for a spend, `Coin.parse`'s own two raises above unaffected since they run before either ever touches it. The coinbase creation loop's own `_hash_insert` is skipped for the two mainnet blocks `is_bip30_unspendable` names, matching `CoinStatsIndex::CustomAppend` -- `muhash.py`'s own "The two blocks history exempts" argues why, and why this is a different pair from `check_bip30`'s own exception above. Neither creation loop stages a provably unspendable output at all -- `_stage_creation`'s own docstring is where that gate, and why it is what keeps `added` (and so `RevBlock.to_remove`) from ever naming an outpoint this call never wrote, is argued (btclib-org/btclib-node#667). A block's own spend loop below needs no matching gate: no valid witness satisfies a provably unspendable output's own script, so no block this method is ever asked to connect legitimately spends one, and one that tried would find it absent -- `"prevout not found"`, the same answer Core's own `ConnectBlock` reaches through `Consensus::CheckTxInputs`/`HaveInputs` for an output its own `AddCoin` never added either. """ if check_bip30: self._check_bip30(block) removed: list[tuple[OutPoint, Coin]] = [] added: list[OutPoint] = [] complete_transactions: list[tuple[list[Coin], Tx]] = [] block_hash = block.header.hash skip_coinbase_hash = is_bip30_unspendable(height, block_hash) for i, tx_out in enumerate(block.transactions[0].vout): out_point = OutPoint(block.transactions[0].id, i, check_validity=False) coin = Coin(tx_out, height, is_coinbase=True) self._stage_added(added, out_point, coin, hash_it=not skip_coinbase_hash) for tx in block.transactions[1:]: tx_id = tx.id prev_coins: list[Coin] = [] for tx_in in tx.vin: prevout_bytes = tx_in.prev_out.serialize(check_validity=False) if prevout_bytes in self.removed_utxos: err_msg = "prevout already spent in this batch" raise InvalidBlockInputError(err_msg) if prevout_bytes in self.updated_utxo_set: coin = self._pop(prevout_bytes) prev_coins.append(coin) else: # _stored_prevout's own docstring is where # answering "prevout not found" for a # checksum-clean record Coin.parse still cannot # read is argued (btclib-org/btclib-node#650). resolved = self._stored_prevout(prevout_bytes) if resolved is None: err_msg = "prevout not found" raise InvalidBlockInputError(err_msg) coin = resolved prev_coins.append(coin) self._mark_removed(prevout_bytes) removed.append((tx_in.prev_out, coin)) self._hash_remove(prevout_bytes, coin) for i, tx_out in enumerate(tx.vout): out_point = OutPoint(tx_id, i, check_validity=False) created_coin = Coin(tx_out, height, is_coinbase=False) self._stage_added(added, out_point, created_coin, hash_it=True) complete_transactions.append((prev_coins, tx)) rev_block = RevBlock(hash=block_hash, to_add=removed, to_remove=added) return complete_transactions, rev_block
[docs] def apply_rev_block(self, rev_block: RevBlock) -> None: """Undo `add_block` for the block `rev_block` was returned for. Removes every outpoint it created and restores every prevout it spent, staged the same way `add_block` stages its own changes. `to_add` runs before `to_remove`, not the reverse order `add_block` itself builds the two lists in, because an ordinary chained transaction -- one spending an output another transaction earlier in the *same* block created -- puts that output's outpoint in both: `to_remove` from being created, `to_add` from being spent before this block ever finalized it to disk. Popping it in `to_remove` first would look it up while it is in neither `updated_utxo_set` nor the database -- its net effect on the persisted set is nothing, both before this block and after it -- and raise `"output not found"` on a block that did nothing wrong. Restoring it in `to_add` first stages it back into `updated_utxo_set`, where `to_remove`'s own `_pop` then finds and removes it, netting to the same nothing `add_block` itself computed. Every other entry is unaffected by the order: `to_add`'s outpoints predate this block and never collide with `to_remove`'s own, which this block alone created, a valid block spending a given outpoint at most once. Core's `DisconnectBlock` (`src/validation.cpp`, at bitcoin/bitcoin@05e49b342f) reaches the same result walking one transaction at a time in reverse block order -- spend its own outputs, then restore its own inputs -- rather than in the two flat passes here (btclib-org/btclib-node#634). A restored prevout is unmarked from `removed_utxos` before it is put back, not merely put back: `add_block` staged that spend with `_mark_removed` whenever the prevout was already durable (found in `self.db` rather than in `updated_utxo_set`), and leaving that flag set here would put the same outpoint bytes in both `removed_utxos` and `updated_utxo_set` at once -- still "removed" as far as a later `add_block` call's own guard is concerned, even though this call just made it spendable again. Staging now survives across trials (this outpoint can sit restored for up to `_FLUSH_BOUND` entries' worth of blocks before `finalize` clears both dicts), so a stale flag here is no longer erased by the next trial boundary the way the old, per-trial `finalize` used to erase it -- it stays wrong until a legitimate later spend of the same output hits `add_block`'s `"prevout already spent in this batch"` guard and gets rejected as a double spend, invalidating that block and, through `update_header_index` -> `BlockIndex.invalidate`, everything built on it. Independently of that, the same stale flag hides a genuine BIP30 duplicate too: `_bip30_violation` reads `removed_utxos` first and answers "no violation" on a hit, so a block recreating the restored outpoint -- still unspent once this call has put it back -- would connect instead of being refused `bad-txns-BIP30` (btclib-org/btclib-node#586). `coin_stats` moves the opposite way `add_block` moved it for the same coin: `to_add` restores a prevout that block spent, so it is inserted back into the commitment; `to_remove` drops an output that block created, so it is removed from it -- both skipped for a coin that is both `coin.is_coinbase` and named by `is_bip30_unspendable`, matching `add_block`'s own gate, which withholds only the coinbase creation loop (`skip_coinbase_hash`, `add_block`'s own docstring) and never an ordinary transaction sharing the same block. `is_coinbase` alone is what `is_bip30_unspendable` cannot answer for: it checks only the coin's own height and the block's hash, so without this condition every non-coinbase output *of the exempt block itself* -- stamped with the same height by `add_block` -- would be wrongly withheld here on undo despite having been correctly hashed in on the way in, leaving `coin_stats` permanently off after a reorg through that block. Matches `CoinStatsIndex::CustomAppend`'s own `is_coinbase && IsBIP30Unspendable(...)` (`src/index/coinstatsindex.cpp:129`, at bitcoin/bitcoin@ca7162cde5), which gates on exactly the same conjunction rather than the hash/height pair alone. `to_remove` parses the stored `Coin` for this alone where the rest of this loop only ever needed to know the record existed. A parse failure here raises `ChainstateInconsistencyError`, unlike the identical fault reached through `_stored_prevout` (`add_block`'s own prevout resolution, `get_coin`), which answers `None` instead (btclib-org/btclib-node#650, `_stored_prevout`'s own docstring). The two share only the *attribution* -- that the fault is this node's own corrupted record, never a caller's content (btclib-org/btclib-node#620, btclib-org/btclib-node#631, btclib-org/btclib-node#636) -- not the *outcome*: undoing a block this node already connected has no legitimate "not found" reading the way an ordinary prevout lookup does, this loop's own `"output not found"` raise immediately above already treating absence itself as this node's own bookkeeping fault rather than a candidate's. """ for out_point, coin in rev_block.to_add: out_point_bytes = out_point.serialize(check_validity=False) self._unmark_removed(out_point_bytes) self._put(out_point_bytes, coin) if not ( coin.is_coinbase and is_bip30_unspendable(coin.height, rev_block.hash) ): self._hash_insert(out_point_bytes, coin) for out_point in rev_block.to_remove: out_point_bytes = out_point.serialize(check_validity=False) if out_point_bytes in self.removed_utxos: err_msg = "output already removed" raise ChainstateInconsistencyError(err_msg) if out_point_bytes in self.updated_utxo_set: coin = self._pop(out_point_bytes) else: coin_data = self.db.get(b"utxo-" + out_point_bytes) if not coin_data: err_msg = "output not found" raise ChainstateInconsistencyError(err_msg) try: coin = Coin.parse(coin_data, check_validity=False) except BTClibValueError as exc: err_msg = "stored utxo- record failed to parse" raise ChainstateInconsistencyError(err_msg) from exc self._mark_removed(out_point_bytes) if not ( coin.is_coinbase and is_bip30_unspendable(coin.height, rev_block.hash) ): self._hash_remove(out_point_bytes, coin)
[docs] def get_coin(self, prevout_bytes: bytes) -> Coin | None: """Return the `Coin` a serialized outpoint still resolves to, or `None`. Checks `updated_utxo_set` and `removed_utxos` first, the same order `add_block` and `apply_rev_block` already read staged state in, before falling to the store: a coin several blocks' own worth of staging have created or already taken is real regardless of whether `finalize` has written it out yet, and a caller reading `self.db` directly -- `main.verify_mempool_acceptance` used to -- would miss exactly what staying staged across more than one block (btclib-org/btclib-node#586) makes ordinary. """ if prevout_bytes in self.updated_utxo_set: return self.updated_utxo_set[prevout_bytes] if prevout_bytes in self.removed_utxos: return None # _stored_prevout's own docstring is where answering `None` # for a checksum-clean record `Coin.parse` still cannot read # is argued (btclib-org/btclib-node#650). return self._stored_prevout(prevout_bytes)
[docs] def finalize(self, wb: KeyValueStore | None = None) -> None: """Write every staged change into `wb`, or into `self.db` if none. Everything staged is durable after this, so nothing recorded before it can ever be rolled back to: `_undo_log` is cleared along with the two dicts it was tracking. `coin_stats` is written alongside them, into the same store's meta column family (`db.py`'s own `put_meta`) -- inside `wb`'s own batch when a caller passes one, which is what keeps the commitment and the coins it commits to landing together or not at all (`db.py`'s docstring argues why). """ db = wb or self.db for x in self.removed_utxos: db.delete(b"utxo-" + x) for out_point_bytes, coin in self.updated_utxo_set.items(): db.put(b"utxo-" + out_point_bytes, coin.serialize()) db.put_meta(_COIN_STATS_META_KEY, self.coin_stats.serialize()) self.removed_utxos = set() self.updated_utxo_set = {} self._undo_log = []
[docs] def rollback(self, mark: int = 0) -> None: """Undo every mutation recorded since `mark`, in reverse. `mark` defaults to the very start -- a caller with nothing staged before its own trial began, which is every direct test of this method -- and `trial_mark`'s own docstring is where a caller with something to protect gets a real one from. """ while len(self._undo_log) > mark: entry = self._undo_log.pop() if isinstance(entry[0], CoinStats): _, was_insert, (out_point_bytes, coin) = entry if was_insert: # this entry logged a _hash_insert, so undoing it # removes the same (out_point_bytes, coin) pair -- # muhash.py's own docstring is where insert and # remove being each other's exact inverse, # regardless of order, is argued self.coin_stats.remove(out_point_bytes, coin) else: self.coin_stats.insert(out_point_bytes, coin) continue container, key, prior = entry if isinstance(container, set): if prior: container.add(key) else: container.discard(key) elif prior is _UNSET: container.pop(key, None) else: container[key] = prior