Persistent global coin state database for the DIG Network L2 blockchain.
Manages the authoritative database of all spent and unspent coins using the coinset model (UTXO-like). Accepts validated blocks, applies state transitions, and provides a rich query API for coin lookups by ID, puzzle hash, hint, parent, and height.
Crate boundary: Input = pre-validated BlockData. Output = CoinRecords, CoinStates, state roots, Merkle proofs. This crate does NOT run CLVM, produce blocks, or manage the mempool.
Derived from Chia's CoinStore and HintStore, with improvements: Merkle-committed state, embedded KV storage, snapshot/restore, tiered archival, and in-memory caching.
use dig_coinstore::coin_store::CoinStore;
use dig_coinstore::{BlockData, CoinAddition, Coin, Bytes32};
// Open or create the coinstate database.
let mut store = CoinStore::new("./data/coinstate")?;
// Bootstrap the chain with genesis coins.
let genesis_coin = Coin::new(parent_hash, puzzle_hash, 1_000_000);
let state_root = store.init_genesis(vec![(genesis_coin, false)], timestamp)?;
// Apply a block (pre-validated additions, removals, hints).
let result = store.apply_block(block_data)?;
println!("New state root: {:?}, coins created: {}", result.state_root, result.coins_created);
// Query coins.
let record = store.get_coin_record(&coin_id)?;
let by_puzzle = store.get_coin_records_by_puzzle_hash(true, &puzzle_hash, 0, u64::MAX)?;
// Rollback for chain reorganization.
let rollback = store.rollback_to_block(target_height)?;Feature-gated at compile time:
| Feature | Backend | Default | Notes |
|---|---|---|---|
rocksdb-storage |
RocksDB | Yes | LSM tree, bloom filters, write-optimized |
lmdb-storage |
LMDB | No | Memory-mapped, read-optimized |
full-storage |
Both | No | LMDB preferred when both enabled |
[dependencies]
dig-coinstore = "0.1" # RocksDB (default)
dig-coinstore = { version = "0.1", features = ["lmdb-storage"] } # LMDBThese types are re-exported at the crate root so consumers don't need direct chia-protocol or dig-clvm dependencies:
| Type | Source | Description |
|---|---|---|
Coin |
chia-protocol via dig-clvm |
Coin identity: parent_coin_info, puzzle_hash, amount. coin_id() = sha256(parent || puzzle_hash || amount) |
Bytes32 |
chia-protocol via dig-clvm |
32-byte hash for coin IDs, puzzle hashes, block hashes, state roots |
CoinState |
chia-protocol via dig-clvm |
Lightweight sync view: coin, created_height: Option<u32>, spent_height: Option<u32> |
CoinStateFilters |
chia-protocol |
Batch query filters: include_spent, include_unspent, include_hinted, min_amount |
Full lifecycle state of one coin in the store. Persists after spending for history and rollback.
pub struct CoinRecord {
pub coin: Coin, // Immutable coin identity and value
pub confirmed_height: u64, // Height where created
pub spent_height: Option<u64>, // None = unspent, Some(h) = spent at height h
pub coinbase: bool, // Block reward vs transaction output
pub timestamp: u64, // Block timestamp at creation
pub ff_eligible: bool, // Singleton fast-forward candidate
}Key methods:
CoinRecord::new(coin, confirmed_height, timestamp, coinbase)— new unspent coinis_spent() -> bool—spent_height.is_some()spend(height)— mark spentcoin_id() -> CoinId— delegates toCoin::coin_id()to_coin_state() -> CoinState— lightweight sync viewfrom_chia_coin_record(ChiaCoinRecord) -> Self— Chia interop (import)to_chia_coin_record() -> ChiaCoinRecord— Chia interop (export)
Input to apply_block(). Pre-extracted state changes from a validated block.
pub struct BlockData {
pub height: u64, // Must be current_height + 1
pub timestamp: u64, // Unix seconds
pub block_hash: Bytes32, // This block's header hash
pub parent_hash: Bytes32, // Must match current tip hash
pub additions: Vec<CoinAddition>, // Transaction-created coins
pub removals: Vec<CoinId>, // Spent coin IDs
pub coinbase_coins: Vec<Coin>, // Block rewards (≥ 2 for non-genesis)
pub hints: Vec<(CoinId, Bytes32)>, // CREATE_COIN hints for wallet indexing
pub expected_state_root: Option<Bytes32>, // Optional post-apply root check
}pub struct CoinAddition {
pub coin_id: CoinId, // sha256(parent || puzzle_hash || amount)
pub coin: Coin, // The created coin
pub same_as_parent: bool, // true → ff_eligible (singleton fast-forward)
}Constructor: CoinAddition::from_coin(coin, same_as_parent) — computes coin_id via Coin::coin_id().
Returned on successful apply_block().
pub struct ApplyBlockResult {
pub state_root: Bytes32, // Merkle root after this block
pub coins_created: usize, // additions.len() + coinbase_coins.len()
pub coins_spent: usize, // removals.len()
pub height: u64, // New chain tip height
}Returned on successful rollback_to_block().
pub struct RollbackResult {
pub modified_coins: HashMap<CoinId, CoinRecord>, // Pre-rollback snapshots
pub coins_deleted: usize, // Coins created after target (removed)
pub coins_unspent: usize, // Coins spent after target (un-spent)
pub new_height: u64, // Chain tip after rollback
}Aggregated chain metrics from stats().
pub struct CoinStoreStats {
pub height: u64,
pub timestamp: u64,
pub unspent_count: u64,
pub spent_count: u64,
pub total_unspent_value: u64,
pub state_root: Bytes32,
pub tip_hash: Bytes32,
pub hint_count: u64,
pub snapshot_count: usize,
}Serializable checkpoint for fast sync / backup / restore.
pub struct CoinStoreSnapshot {
pub height: u64,
pub block_hash: Bytes32,
pub state_root: Bytes32,
pub timestamp: u64,
pub coins: HashMap<CoinId, CoinRecord>,
pub hints: Vec<(CoinId, Bytes32)>,
pub total_coins: u64,
pub total_value: u64,
}pub type CoinId = Bytes32; // sha256(parent || puzzle_hash || amount)
pub type PuzzleHash = Bytes32; // sha256(serialized CLVM puzzle)All fallible methods return Result<T, CoinStoreError>. Variants:
| Variant | Trigger | Fields |
|---|---|---|
HeightMismatch |
block.height != current + 1 |
expected: u64, got: u64 |
ParentHashMismatch |
block.parent_hash != tip_hash |
expected: Bytes32, got: Bytes32 |
StateRootMismatch |
Computed root != expected_state_root |
expected: Bytes32, computed: Bytes32 |
CoinNotFound |
Removal references missing coin | CoinId |
CoinAlreadyExists |
Addition duplicates existing coin | CoinId |
DoubleSpend |
Removal references already-spent coin | CoinId |
SpendCountMismatch |
Updated rows != expected removals | expected: usize, actual: usize |
InvalidRewardCoinCount |
Wrong coinbase count for height | expected: String, got: usize |
HintTooLong |
Hint > 32 bytes | length: usize, max: usize |
GenesisAlreadyInitialized |
Double init_genesis() call |
— |
NotInitialized |
Operation before init_genesis() |
— |
RollbackAboveTip |
target_height > current_height |
target: i64, current: u64 |
PuzzleHashBatchTooLarge |
Batch query exceeds limit | size: usize, max: usize |
StorageError |
Backend I/O failure | String |
SerializationError |
Bincode encode failure | String |
DeserializationError |
Bincode decode failure | String |
impl CoinStore {
/// Open/create store with default config at path.
fn new(path: impl AsRef<Path>) -> Result<Self, CoinStoreError>;
/// Open/create with custom configuration.
fn with_config(config: CoinStoreConfig) -> Result<Self, CoinStoreError>;
/// Bootstrap chain with genesis coins. Called once.
/// Returns the genesis state root.
fn init_genesis(
&mut self,
initial_coins: Vec<(Coin, bool)>, // (coin, is_coinbase)
timestamp: u64,
) -> Result<Bytes32, CoinStoreError>;
}impl CoinStore {
/// Apply a validated block. Atomic: all-or-nothing.
///
/// Phase 1 (validation): height, parent hash, reward count, removals exist+unspent,
/// additions unique, hints valid.
/// Phase 2 (mutation): insert coins, mark spends, store hints, update Merkle tree,
/// commit chain tip. All in one WriteBatch.
/// Phase 3 (observability): performance logging.
fn apply_block(&mut self, block: BlockData) -> Result<ApplyBlockResult, CoinStoreError>;
}impl CoinStore {
/// Revert to target_height. Negative = full reset.
/// Deletes coins confirmed after target, un-spends coins spent after target,
/// cleans up hints, rebuilds Merkle tree.
fn rollback_to_block(&mut self, target_height: i64) -> Result<RollbackResult, CoinStoreError>;
/// Convenience: rollback_to_block(height - n).
fn rollback_n_blocks(&mut self, n: u64) -> Result<RollbackResult, CoinStoreError>;
}impl CoinStore {
// --- Point lookups (QRY-001) ---
fn get_coin_record(&self, coin_id: &CoinId) -> Result<Option<CoinRecord>, CoinStoreError>;
fn get_coin_records(&self, coin_ids: &[CoinId]) -> Result<Vec<CoinRecord>, CoinStoreError>;
// --- By puzzle hash (QRY-002) ---
fn get_coin_records_by_puzzle_hash(
&self, include_spent: bool, puzzle_hash: &Bytes32,
start_height: u64, end_height: u64,
) -> Result<Vec<CoinRecord>, CoinStoreError>;
fn get_coin_records_by_puzzle_hashes(
&self, include_spent: bool, puzzle_hashes: &[Bytes32],
start_height: u64, end_height: u64,
) -> Result<Vec<CoinRecord>, CoinStoreError>;
// --- By height (QRY-003) ---
fn get_coins_added_at_height(&self, height: u64) -> Result<Vec<CoinRecord>, CoinStoreError>;
fn get_coins_removed_at_height(&self, height: u64) -> Result<Vec<CoinRecord>, CoinStoreError>;
// --- By parent (QRY-004) ---
fn get_coin_records_by_parent_ids(
&self, include_spent: bool, parent_ids: &[CoinId],
start_height: u64, end_height: u64,
) -> Result<Vec<CoinRecord>, CoinStoreError>;
// --- By names with filters (QRY-005) ---
fn get_coin_records_by_names(
&self, include_spent: bool, names: &[CoinId],
start_height: u64, end_height: u64,
) -> Result<Vec<CoinRecord>, CoinStoreError>;
// --- Lightweight CoinState (QRY-006) ---
fn get_coin_states_by_ids(
&self, include_spent: bool, coin_ids: &[CoinId],
min_height: u64, max_height: u64, max_items: usize,
) -> Result<Vec<CoinState>, CoinStoreError>;
fn get_coin_states_by_puzzle_hashes(
&self, include_spent: bool, puzzle_hashes: &[Bytes32],
min_height: u64, max_items: usize,
) -> Result<Vec<CoinState>, CoinStoreError>;
// --- Paginated batch with CoinStateFilters (QRY-007) ---
fn batch_coin_states_by_puzzle_hashes(
&self, puzzle_hashes: &[Bytes32], min_height: u64,
filters: CoinStateFilters, max_items: usize,
) -> Result<(Vec<CoinState>, Option<u64>), CoinStoreError>;
// Returns (results, next_height). next_height=None means last page.
// Enforces MAX_PUZZLE_HASH_BATCH_SIZE (990).
// Supports include_hinted join, min_amount, deterministic sort, block boundary preservation.
// --- Singleton lineage (QRY-008) ---
fn get_unspent_lineage_info_for_puzzle_hash(
&self, puzzle_hash: &Bytes32,
) -> Result<Option<UnspentLineageInfo>, CoinStoreError>;
// Returns None if != exactly 1 unspent coin matches the puzzle hash.
}impl CoinStore {
fn num_unspent(&self) -> Result<u64, CoinStoreError>;
fn total_unspent_value(&self) -> Result<u128, CoinStoreError>;
fn num_total(&self) -> Result<u64, CoinStoreError>;
fn aggregate_unspent_by_puzzle_hash(
&self,
) -> Result<HashMap<Bytes32, (u64, usize)>, CoinStoreError>;
// Returns puzzle_hash → (total_amount, coin_count) for all puzzle hashes with unspent coins.
}impl CoinStore {
fn height(&self) -> u64;
fn tip_hash(&self) -> Bytes32;
fn timestamp(&self) -> u64;
fn state_root(&mut self) -> Bytes32; // Recomputes if dirty
fn is_initialized(&self) -> bool;
fn is_empty(&self) -> bool;
fn is_unspent(&self, coin_id: &CoinId) -> bool; // O(1) HashSet lookup
fn config(&self) -> &CoinStoreConfig;
fn stats(&self) -> CoinStoreStats;
}impl CoinStore {
/// Insert hint for a coin. Idempotent (duplicate = no-op).
fn add_hint(&self, coin_id: &CoinId, hint: &[u8]) -> Result<(), CoinStoreError>;
/// Coins associated with a 32-byte hint.
fn get_coin_ids_by_hint(&self, hint: &Bytes32, max_items: usize) -> Result<Vec<CoinId>, CoinStoreError>;
/// Batch: coins associated with any of the hints.
fn get_coin_ids_by_hints(&self, hints: &[Bytes32], max_items: usize) -> Result<Vec<CoinId>, CoinStoreError>;
/// Hints associated with the given coin IDs.
fn get_hints_for_coin_ids(&self, coin_ids: &[CoinId]) -> Result<HashMap<CoinId, Vec<Bytes32>>, CoinStoreError>;
/// Total hint count.
fn count_hints(&self) -> Result<u64, CoinStoreError>;
/// Remove all hints for given coins (used during rollback).
fn remove_hints_for_coins(&self, coin_ids: &[CoinId]) -> Result<u64, CoinStoreError>;
/// Query by variable-length hint (1-32 bytes).
fn get_coin_ids_by_hint_bytes(&self, hint: &[u8], max_items: usize) -> Result<Vec<CoinId>, CoinStoreError>;
}
/// Standalone hint validation.
pub fn validate_hint(hint: &[u8]) -> Result<HintAction, HintError>;
pub const MAX_HINT_LENGTH: usize = 32;impl CoinStore {
/// Capture full coinstate snapshot.
fn snapshot(&self) -> Result<CoinStoreSnapshot, CoinStoreError>;
/// Replace all state from snapshot. Validates Merkle root.
fn restore(&mut self, snapshot: CoinStoreSnapshot) -> Result<(), CoinStoreError>;
/// Persist snapshot to storage, auto-prune old ones.
fn save_snapshot(&self) -> Result<(), CoinStoreError>;
/// Load snapshot by height.
fn load_snapshot(&self, height: u64) -> Result<Option<CoinStoreSnapshot>, CoinStoreError>;
/// Load the most recent snapshot.
fn load_latest_snapshot(&self) -> Result<Option<CoinStoreSnapshot>, CoinStoreError>;
/// Available snapshot heights (ascending).
fn available_snapshot_heights(&self) -> Vec<u64>;
}let config = CoinStoreConfig::default() // or CoinStoreConfig::default_with_path("./data")
.with_backend(StorageBackend::RocksDb) // or StorageBackend::Lmdb
.with_storage_path("./data/coinstate")
.with_max_snapshots(10) // auto-prune older
.with_max_query_results(50_000) // batch query cap
.with_lmdb_map_size(10 * 1024 * 1024 * 1024) // 10 GiB
.with_rocksdb_write_buffer_size(64 * 1024 * 1024)
.with_rocksdb_max_open_files(1000)
.with_bloom_filter(true); // 10 bits/key, ~1% FP
let store = CoinStore::with_config(config)?;12 column families (RocksDB) / named databases (LMDB):
| CF | Key | Value | Purpose |
|---|---|---|---|
coin_records |
coin_id (32B) |
bincode CoinRecord |
Primary store |
coin_by_puzzle_hash |
puzzle_hash || coin_id (64B) |
coin_id |
Puzzle hash index |
unspent_by_puzzle_hash |
puzzle_hash || coin_id (64B) |
empty | Unspent-only index |
coin_by_parent |
parent_id || coin_id (64B) |
coin_id |
Parent index |
coin_by_confirmed_height |
height_BE || coin_id (40B) |
coin_id |
Creation height index |
coin_by_spent_height |
height_BE || coin_id (40B) |
coin_id |
Spend height index |
hints |
coin_id || hint (up to 64B) |
empty | Forward hint index |
hints_by_value |
hint || coin_id (up to 64B) |
empty | Reverse hint index |
merkle_nodes |
level(1B) || path(32B) |
hash (32B) |
Merkle internal nodes |
archive_coin_records |
coin_id (32B) |
bincode CoinRecord |
Archived spent coins |
state_snapshots |
height_BE (8B) |
bincode CoinStoreSnapshot |
Checkpoints |
metadata |
string key | bytes | Chain tip, counters, config |
All height keys use big-endian encoding so lexicographic byte comparison matches numeric order.
256-level sparse Merkle tree over all coin records, providing cryptographic state commitment.
- Leaf hash:
SHA256(0x00 || coin_record_bytes)— domain-separated - Node hash:
SHA256(0x01 || left || right)— domain-separated - Empty subtree: pre-computed for all 257 levels via
OnceLock(O(1) lookup) - Deferred root recomputation: mutations mark the tree dirty; root computed lazily on
root()call - Proof generation:
SparseMerkleProofwith 256 sibling hashes for inclusion/exclusion proofs - Proof verification: static computation — no tree state needed, only proof + trusted root
Phase 1 — Validation (no writes):
- Height continuity:
block.height == current + 1 - Parent hash:
block.parent_hash == tip_hash - Reward coins: ≥ 2 coinbase for non-genesis
- Removals: each exists and is unspent
- Additions: no duplicates
- Hints: length ≤ 32 bytes
Phase 2 — Mutation (atomic WriteBatch):
7. Insert coinbase + addition records with FF-eligible tracking
8. Mark removals as spent
9. Verify expected_state_root if provided
10. Store hints in forward + reverse indices
11. Batch update Merkle tree
12. Commit chain tip (height, hash, timestamp)
Phase 3 — Observability: 13. Log warning if elapsed > 10 seconds
Atomicity: If any validation fails, no mutations occur. If Phase 2 fails, the WriteBatch is not committed.
- Delete coins confirmed after target height (all indices)
- Clean up hints for deleted coins (forward + reverse)
- Un-spend coins spent after target height (clear
spent_height, re-add to unspent index) - Recompute FF-eligible for un-spent coins (parent EXISTS check)
- Rebuild Merkle tree (batch remove + update)
- Atomic commit via WriteBatch
Negative target_height triggers full reset to height 0.
CoinStore is Send + Sync. The Rust borrow checker enforces shared reads (&self) and exclusive writes (&mut self) at compile time. For runtime concurrency via Arc<RwLock<CoinStore>>, parking_lot::RwLock is recommended.
Full specification: docs/resources/SPEC.md
Requirements: docs/requirements/IMPLEMENTATION_ORDER.md
81 requirements across 9 phases, verified by 493 tests in 75 test files.