dig_coinstore/types.rs
1//! Domain types for dig-coinstore.
2//!
3//! Core data structures: [`CoinRecord`], [`ChiaCoinRecord`], [`BlockData`], [`CoinAddition`],
4//! [`ApplyBlockResult`], [`RollbackResult`], [`CoinStoreStats`],
5//! [`CoinStoreSnapshot`], [`UnspentLineageInfo`].
6//!
7//! Also defines type aliases: [`CoinId`] = [`Bytes32`], [`PuzzleHash`] = [`Bytes32`] (see API-009).
8//!
9//! # Requirements
10//! - **API-002:** [`CoinRecord`], [`ChiaCoinRecord`], [`CoinId`]
11//! - **API-005:** [`BlockData`], [`CoinAddition`]
12//! - **API-006:** [`ApplyBlockResult`], [`RollbackResult`]
13//! - **API-007:** [`CoinStoreStats`]
14//! - **API-008:** [`CoinStoreSnapshot`]
15//! - **API-009:** [`CoinId`], [`PuzzleHash`], [`UnspentLineageInfo`]
16//!
17//! ## `ChiaCoinRecord` vs `chia_protocol::CoinRecord`
18//!
19//! The upstream streamable type is documented at
20//! [docs.rs `chia_protocol::CoinRecord`](https://docs.rs/chia-protocol/latest/chia_protocol/struct.CoinRecord.html).
21//! This crate pins `chia-protocol` **0.26** together with [`dig_clvm`](https://github.com/DIG-Network/dig-clvm)
22//! for a single `Coin` / [`Bytes32`] identity graph. That protocol release does **not** yet export
23//! `CoinRecord`, so we define [`ChiaCoinRecord`] here with **identical fields and semantics** to the
24//! current Chia reference implementation. When `dig-clvm` upgrades `chia-protocol`, [`ChiaCoinRecord`]
25//! should become `pub use chia_protocol::CoinRecord as ChiaCoinRecord` (STR-005).
26
27use std::collections::HashMap;
28
29use serde::{Deserialize, Serialize};
30
31use crate::Bytes32;
32use crate::Coin;
33use crate::CoinState;
34
35// ─────────────────────────────────────────────────────────────────────────────
36// Type aliases (API-002 / API-009)
37// ─────────────────────────────────────────────────────────────────────────────
38
39/// 32-byte coin identifier: `sha256(parent_coin_info || puzzle_hash || amount)`.
40///
41/// Alias of [`Bytes32`] for readable APIs (`get_coin_record(&CoinId)`).
42///
43/// See: [`Coin::coin_id`], docs/resources/SPEC.md §2.1
44pub type CoinId = Bytes32;
45
46/// Puzzle hash (SHA256 of serialized puzzle program). Same underlying type as [`CoinId`].
47///
48/// Fully specified under API-009; exported early for `CoinRecord::coin_id()` return type clarity.
49pub type PuzzleHash = Bytes32;
50
51// ─────────────────────────────────────────────────────────────────────────────
52// Chia wire-shaped coin row (interop)
53// ─────────────────────────────────────────────────────────────────────────────
54
55/// Row-shaped coin metadata matching Chia full-node / light-wallet `CoinRecord` streamable layout.
56///
57/// **Why this exists:** `chia-protocol` 0.26 (bundled via `dig-clvm`) does not define this struct;
58/// Chia’s reference layout is still the contract for RPC and cross-repo interop. Field names mirror
59/// [`chia_protocol::CoinRecord`](https://docs.rs/chia-protocol/latest/chia_protocol/struct.CoinRecord.html).
60///
61/// **`spent_block_index` sentinel:** `0` means *unspent* in Chia’s encoding; positive values are
62/// spent heights. Fast-forward–eligible rows (Python `spent_index == -1`) are not represented here;
63/// dig-coinstore uses [`CoinRecord::ff_eligible`] instead once ingested.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65pub struct ChiaCoinRecord {
66 /// The coin identity and payload (parent id, puzzle hash, amount).
67 pub coin: Coin,
68 /// Height at which the coin was included (confirmed).
69 pub confirmed_block_index: u32,
70 /// `0` = unspent; otherwise spent at this height.
71 pub spent_block_index: u32,
72 /// Block-reward coin vs transaction output.
73 pub coinbase: bool,
74 /// Block timestamp at `confirmed_block_index`.
75 pub timestamp: u64,
76}
77
78impl ChiaCoinRecord {
79 /// Construct a protocol-shaped row (mainly for tests and RPC adapters).
80 #[inline]
81 pub const fn new(
82 coin: Coin,
83 confirmed_block_index: u32,
84 spent_block_index: u32,
85 coinbase: bool,
86 timestamp: u64,
87 ) -> Self {
88 Self {
89 coin,
90 confirmed_block_index,
91 spent_block_index,
92 coinbase,
93 timestamp,
94 }
95 }
96}
97
98// ─────────────────────────────────────────────────────────────────────────────
99// CoinRecord — authoritative stored row (API-002)
100// ─────────────────────────────────────────────────────────────────────────────
101
102/// Full lifecycle state of one coin in the coinstore.
103///
104/// Persists after spending for history + rollback (SPEC.md §2.2). Prefer [`Option<u64>`] for
105/// [`CoinRecord::spent_height`] over Chia’s `spent_block_index == 0` sentinel to keep Rust matches
106/// exhaustive and avoid double meanings for `0`.
107///
108/// See: [`API-002`](../../docs/requirements/domains/crate_api/specs/API-002.md),
109/// Chia reference: <https://github.com/Chia-Network/chia-blockchain/blob/main/chia/full_node/coin_store.py>
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct CoinRecord {
112 /// Immutable coin identity and value.
113 pub coin: Coin,
114 /// Height where the coin was created / confirmed.
115 pub confirmed_height: u64,
116 /// Spend height when spent; [`None`] if still unspent.
117 pub spent_height: Option<u64>,
118 /// Whether this coin came from the block reward (not a normal tx output).
119 pub coinbase: bool,
120 /// Timestamp of the confirming block.
121 pub timestamp: u64,
122 /// Singleton fast-forward candidate (set at ingestion when `same_as_parent`; recomputed on rollback).
123 pub ff_eligible: bool,
124}
125
126impl CoinRecord {
127 /// New **unspent** coin at `confirmed_height` with `ff_eligible = false`.
128 ///
129 /// Callers set [`CoinRecord::ff_eligible`] later (e.g. `apply_block` when `CoinAddition::same_as_parent`).
130 #[must_use]
131 pub fn new(coin: Coin, confirmed_height: u64, timestamp: u64, coinbase: bool) -> Self {
132 Self {
133 coin,
134 confirmed_height,
135 spent_height: None,
136 coinbase,
137 timestamp,
138 ff_eligible: false,
139 }
140 }
141
142 /// `true` iff [`CoinRecord::spent_height`] is present.
143 #[must_use]
144 pub fn is_spent(&self) -> bool {
145 self.spent_height.is_some()
146 }
147
148 /// Mark spent at `height` (struct does **not** assert double-spend; pipeline validates).
149 pub fn spend(&mut self, height: u64) {
150 self.spent_height = Some(height);
151 }
152
153 /// Same digest as [`Coin::coin_id`] on the embedded coin (spec: never reimplement ID math).
154 #[must_use]
155 pub fn coin_id(&self) -> CoinId {
156 self.coin.coin_id()
157 }
158
159 /// Lightweight sync view: maps heights to [`Option<u32>`] per [`CoinState`] wire encoding.
160 ///
161 /// **Truncation note:** `u64` heights are cast to `u32`. For practical chains this fits; debug
162 /// builds assert no loss when truncating `confirmed_height`.
163 #[must_use]
164 pub fn to_coin_state(&self) -> CoinState {
165 debug_assert!(self.confirmed_height <= u64::from(u32::MAX));
166 let created = Some(self.confirmed_height as u32);
167 let spent = self.spent_height.map(|h| {
168 debug_assert!(h <= u64::from(u32::MAX));
169 h as u32
170 });
171 CoinState::new(self.coin, spent, created)
172 }
173
174 /// Ingest a Chia-shaped row into the native coinstore model.
175 ///
176 /// Mapping rules match API-002 / SPEC §2.2:
177 /// - `spent_block_index == 0` → [`None`] spent height
178 /// - `spent_block_index > 0` → [`Some`] as `u64`
179 /// - `ff_eligible` is always reset to `false` (not carried on wire)
180 #[must_use]
181 pub fn from_chia_coin_record(record: ChiaCoinRecord) -> Self {
182 let spent_height = if record.spent_block_index == 0 {
183 None
184 } else {
185 Some(u64::from(record.spent_block_index))
186 };
187 Self {
188 coin: record.coin,
189 confirmed_height: u64::from(record.confirmed_block_index),
190 spent_height,
191 coinbase: record.coinbase,
192 timestamp: record.timestamp,
193 ff_eligible: false,
194 }
195 }
196
197 /// Export to Chia wire-shaped row. Loses [`CoinRecord::ff_eligible`].
198 ///
199 /// # Panics
200 /// If `confirmed_height` or `spent_height` exceed `u32::MAX` (should not occur before ~4B blocks).
201 #[must_use]
202 pub fn to_chia_coin_record(&self) -> ChiaCoinRecord {
203 assert!(self.confirmed_height <= u64::from(u32::MAX));
204 let spent_block_index = match self.spent_height {
205 None => 0,
206 Some(h) => {
207 assert!(h <= u64::from(u32::MAX));
208 h as u32
209 }
210 };
211 ChiaCoinRecord::new(
212 self.coin,
213 self.confirmed_height as u32,
214 spent_block_index,
215 self.coinbase,
216 self.timestamp,
217 )
218 }
219}
220
221// ─────────────────────────────────────────────────────────────────────────────
222// Block application input (API-005)
223// ─────────────────────────────────────────────────────────────────────────────
224//
225// See: docs/requirements/domains/crate_api/specs/API-005.md,
226// docs/resources/SPEC.md §2.4, Chia `coin_store.py` `new_block()` parameters.
227
228/// One transaction-created coin plus ingestion metadata for [`BlockData::additions`].
229///
230/// **`same_as_parent`:** `true` when this coin’s [`Coin::puzzle_hash`] and [`Coin::amount`] match the
231/// spent parent’s puzzle hash and amount — the block pipeline uses this for singleton **fast-forward**
232/// eligibility ([`CoinRecord::ff_eligible`], BLK-007).
233///
234/// **`coin_id`:** For valid blocks this MUST equal [`Coin::coin_id`] on [`Self::coin`]. The struct does not
235/// enforce equality at construction time; BLK-* / `apply_block` validation rejects mismatches so callers
236/// cannot poison the store with an inconsistent ID ([API-005 test plan](docs/requirements/domains/crate_api/specs/API-005.md#verification)).
237///
238/// **Chia reference:** `tx_additions` tuples in
239/// [`coin_store.py`](https://github.com/Chia-Network/chia-blockchain/blob/main/chia/full_node/coin_store.py).
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241pub struct CoinAddition {
242 /// Coin ID (`sha256(parent || puzzle_hash || amount)`) — use [`Coin::coin_id`], never a custom hash.
243 pub coin_id: CoinId,
244 /// The created coin (parent id, puzzle hash, amount).
245 pub coin: Coin,
246 /// Same puzzle hash and amount as the parent coin being spent in this block.
247 pub same_as_parent: bool,
248}
249
250impl CoinAddition {
251 /// Build from a [`Coin`] using [`Coin::coin_id`] as [`Self::coin_id`] (recommended for callers).
252 ///
253 /// **Rationale:** Centralizes the “no custom coin ID” rule ([STR-005](docs/requirements/domains/crate_structure/specs/STR-005.md),
254 /// project `start.md` hard rules).
255 #[must_use]
256 pub fn from_coin(coin: Coin, same_as_parent: bool) -> Self {
257 let coin_id = coin.coin_id();
258 Self {
259 coin_id,
260 coin,
261 same_as_parent,
262 }
263 }
264}
265
266/// Pre-validated block state changes: input to `CoinStore::apply_block` (BLK-*).
267///
268/// The coinstore **does not** run CLVM — the caller extracts additions, removals, coinbase rewards, and
269/// hints from execution results, then fills this struct ([API-005](docs/requirements/domains/crate_api/specs/API-005.md#summary)).
270///
271/// | Field | Role |
272/// |-------|------|
273/// | `height` / `timestamp` / `block_hash` / `parent_hash` | Chain linkage + time (validated in BLK-002, BLK-003) |
274/// | `additions` / `removals` | UTXO delta |
275/// | `coinbase_coins` | Farmer + pool rewards (count rules: BLK-004) |
276/// | `hints` | CREATE_COIN hint bytes for the hint index (HNT-*) |
277/// | `expected_state_root` | Optional post-apply root check (BLK-009) |
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279pub struct BlockData {
280 /// Block height; must be `current_height + 1` when applied ([BLK-002](docs/requirements/domains/block_application/specs/BLK-002.md)).
281 pub height: u64,
282 /// Unix timestamp (seconds) of the block.
283 pub timestamp: u64,
284 /// This block’s header hash (tip / chain tracking).
285 pub block_hash: Bytes32,
286 /// Parent block header hash; must match current tip ([BLK-003](docs/requirements/domains/block_application/specs/BLK-003.md)).
287 pub parent_hash: Bytes32,
288 /// Transaction-created coins (+ metadata); Chia `tx_additions`.
289 pub additions: Vec<CoinAddition>,
290 /// Spent coin IDs from transaction spends in this block.
291 pub removals: Vec<CoinId>,
292 /// Block reward outputs (empty at genesis; ≥ 2 after — [BLK-004](docs/requirements/domains/block_application/specs/BLK-004.md)).
293 pub coinbase_coins: Vec<Coin>,
294 /// Hint bytes per coin id from CREATE_COIN conditions (wallet / subscription index).
295 pub hints: Vec<(CoinId, Bytes32)>,
296 /// If set, `apply_block` verifies the computed state root matches ([BLK-009](docs/requirements/domains/block_application/specs/BLK-009.md)).
297 pub expected_state_root: Option<Bytes32>,
298}
299
300/// Summary returned after a successful [`crate::coin_store::CoinStore::apply_block`] (success path of
301/// `Result<ApplyBlockResult, CoinStoreError>`).
302///
303/// **Source of truth:** [`docs/resources/SPEC.md`](../../docs/resources/SPEC.md) §3.2. Field meanings:
304/// post-apply Merkle **state root**, how many coins were **created** (tx additions + coinbase),
305/// how many were **marked spent**, and the new tip **height** (= input block height when validation passes).
306///
307/// **Chia note:** Chia’s `new_block()` updates storage in place and returns nothing; this struct is the
308/// dig-coinstore contract for observability and tests ([API-006](docs/requirements/domains/crate_api/specs/API-006.md)).
309///
310/// # Requirement: API-006
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312pub struct ApplyBlockResult {
313 /// Merkle root after inserting additions, marking removals, and batch-updating the tree (BLK-013).
314 pub state_root: Bytes32,
315 /// `block.additions.len() + block.coinbase_coins.len()` after successful apply (API-006 field table).
316 pub coins_created: usize,
317 /// `block.removals.len()` — each removal marks one coin spent at this height.
318 pub coins_spent: usize,
319 /// New chain tip height (same as applied [`BlockData::height`] on success).
320 pub height: u64,
321}
322
323/// Summary returned after a successful rollback ([`crate::coin_store::CoinStore::rollback_to_block`],
324/// [`crate::coin_store::CoinStore::rollback_n_blocks`]).
325///
326/// **`modified_coins`:** Chia’s `rollback_to_block` returns `dict[bytes32, CoinRecord]`
327/// ([`coin_store.py:567`](https://github.com/Chia-Network/chia-blockchain/blob/6e7a4954edccd8ab83fcacf938cfc42ddfcad7f2/chia/full_node/coin_store.py#L567)).
328/// dig-coinstore keeps that map and adds explicit **`coins_deleted`** / **`coins_unspent`** counts
329/// (SPEC §1.6 improvement #11; [API-006](docs/requirements/domains/crate_api/specs/API-006.md)).
330///
331/// **Count invariant (well-formed results):** For each entry in `modified_coins`, the rollback either
332/// **deleted** a coin confirmed after the target height (`coins_deleted`) or **reverted a spend**
333/// for a coin spent after the target (`coins_unspent`). Callers assembling this struct should ensure
334/// `coins_deleted + coins_unspent == modified_coins.len()` when every modified coin is accounted for once.
335///
336/// # Requirement: API-006
337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
338pub struct RollbackResult {
339 /// Affected coin IDs → post-rollback–relevant [`CoinRecord`] snapshot (deleted or un-spent row).
340 pub modified_coins: HashMap<CoinId, CoinRecord>,
341 /// Coins removed from the store (created strictly after the rollback target height).
342 pub coins_deleted: usize,
343 /// Coins whose `spent_height` was cleared (were spent strictly after target height).
344 pub coins_unspent: usize,
345 /// Chain tip height after rollback (equals target height on success).
346 pub new_height: u64,
347}
348
349/// Aggregated chain + coinset metrics returned by [`crate::coin_store::CoinStore::stats`] (API-007 / QRY-010).
350///
351/// **Design goal (SPEC §1.6 #18):** eventually all aggregate fields are **O(1)** materialized counters
352/// updated in the same write batch as `apply_block` / rollback ([`docs/resources/SPEC.md`](../../docs/resources/SPEC.md)).
353/// Until PRF-003 lands, [`CoinStore::stats`](crate::coin_store::CoinStore::stats) may derive some fields by
354/// scanning `coin_records` (documented on that method) while still returning this single struct shape.
355///
356/// **Operational use:** dashboards, health checks, and mempool admission logic read one snapshot instead of
357/// issuing multiple Chia-style COUNT queries ([`coin_store.py:96-103`](https://github.com/Chia-Network/chia-blockchain/blob/6e7a4954edccd8ab83fcacf938cfc42ddfcad7f2/chia/full_node/coin_store.py#L96)).
358///
359/// # Requirement: API-007
360/// # Spec: docs/requirements/domains/crate_api/specs/API-007.md
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362pub struct CoinStoreStats {
363 /// Current chain tip height (same source as [`crate::coin_store::CoinStore::height`]).
364 pub height: u64,
365 /// Timestamp (seconds) of the current tip block.
366 pub timestamp: u64,
367 /// Count of coins with [`CoinRecord::spent_height`] == [`None`].
368 pub unspent_count: u64,
369 /// Count of coins with [`CoinRecord::spent_height`] present (historical spends retained).
370 pub spent_count: u64,
371 /// Sum of [`Coin::amount`](crate::Coin::amount) over all unspent [`CoinRecord`] rows.
372 pub total_unspent_value: u64,
373 /// Sparse Merkle root over coin record leaves ([`crate::merkle::SparseMerkleTree`]).
374 pub state_root: Bytes32,
375 /// Header hash of the current tip block.
376 pub tip_hash: Bytes32,
377 /// Rows in the forward hint index ([`crate::storage::schema::CF_HINTS`]).
378 pub hint_count: u64,
379 /// Rows in [`crate::storage::schema::CF_STATE_SNAPSHOTS`] (retained checkpoints).
380 pub snapshot_count: usize,
381}
382
383/// Serializable checkpoint of the full coinstate at one instant (fast sync / backup / restore).
384///
385/// **Why it exists:** Chia has no first-class snapshot object; nodes replay from genesis. dig-coinstore
386/// standardizes this struct for bincode persistence and future checkpoint sync ([`SPEC.md`](../../docs/resources/SPEC.md)
387/// §1.6 improvement #6, §3.14; [API-008](docs/requirements/domains/crate_api/specs/API-008.md)).
388///
389/// **`block_hash`:** Tip header hash at capture time (same role as [`crate::coin_store::CoinStore::tip_hash`]
390/// when produced by a future `CoinStore::snapshot` implementation — tracked under PRF-008).
391///
392/// **`coins` / `hints`:** Full materialized rows and `(coin_id, hint)` pairs — same `hints` element type as
393/// [`BlockData::hints`] for consistency across APIs.
394///
395/// **`total_coins` / `total_value`:** Per API-008 field table, `total_coins` SHOULD equal `coins.len()` as `u64`,
396/// and `total_value` SHOULD be the sum of **unspent** [`CoinRecord`] amounts at capture time. The type does
397/// not enforce these invariants; `CoinStore::snapshot` (PRF-008) must populate them coherently.
398///
399/// # Requirement: API-008
400/// # Spec: docs/requirements/domains/crate_api/specs/API-008.md
401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
402pub struct CoinStoreSnapshot {
403 /// Tip height when the snapshot was taken.
404 pub height: u64,
405 /// Tip block header hash when the snapshot was taken.
406 pub block_hash: Bytes32,
407 /// Sparse Merkle root over coin record leaves at capture time.
408 pub state_root: Bytes32,
409 /// Tip block unix timestamp (seconds).
410 pub timestamp: u64,
411 /// Every coin row (spent and unspent) needed to rebuild indices and history.
412 pub coins: HashMap<CoinId, CoinRecord>,
413 /// Hint pairs carried on coins at capture time (same shape as [`BlockData::hints`]).
414 pub hints: Vec<(CoinId, Bytes32)>,
415 /// Count of coin rows (`coins.len()` when built by [`crate::coin_store::CoinStore::snapshot`]).
416 pub total_coins: u64,
417 /// Sum of unspent coin amounts (mojos) at capture time.
418 pub total_value: u64,
419}
420
421/// Lineage chain for **one unspent coin**, used when resolving singleton **fast-forward** candidates
422/// ([`SPEC.md`](../../docs/resources/SPEC.md) §2.5, [`QRY-008`](../../docs/requirements/domains/queries/specs/QRY-008.md)).
423///
424/// # Field semantics
425///
426/// - **`coin_id`:** The unspent leaf’s identity (`sha256(parent || puzzle_hash || amount)`), same as
427/// [`Coin::coin_id()`](crate::Coin::coin_id).
428/// - **`parent_id`:** That coin’s `parent_coin_info` (the parent coin’s name / id in Chia terminology).
429/// - **`parent_parent_id`:** The parent coin row’s `parent_coin_info` (grandparent link in the lineage chain).
430///
431/// # Genesis / missing rows
432///
433/// Chia encodes coinbase parents with fixed sentinel bytes; there may be **no** grandparent coin row in the
434/// coinset. Callers still store a [`CoinId`] value (often all-zero bytes) for `parent_parent_id` when the
435/// wallet or mempool has no further ancestor ([`API-009.md`](../../docs/requirements/domains/crate_api/specs/API-009.md) implementation notes).
436///
437/// **Serde:** Not required by API-009; QRY-008 returns this in-process. Add `Serialize`/`Deserialize` only if
438/// an RPC boundary needs a stable wire shape.
439///
440/// # Requirement: API-009
441/// # Spec: docs/requirements/domains/crate_api/specs/API-009.md
442#[derive(Debug, Clone, PartialEq)]
443pub struct UnspentLineageInfo {
444 /// This coin’s id (the unspent singleton or leaf being queried).
445 pub coin_id: CoinId,
446 /// `parent_coin_info` of the coin identified by [`Self::coin_id`].
447 pub parent_id: CoinId,
448 /// `parent_coin_info` of the coin identified by [`Self::parent_id`].
449 pub parent_parent_id: CoinId,
450}