Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ edition = "2021"
# the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a
# release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet)
# keep their own independent versions — only the released binary tracks the workspace version.
version = "0.162.0"
version = "0.163.0"

# Release hardening, matching digstore: keep integer-overflow checks ON in release.
# The node parses untrusted serialized input and does offset/length arithmetic over
Expand Down
63 changes: 52 additions & 11 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -5398,12 +5398,18 @@ self-custody is again the only model, §18.20.)
18.11. **NFT/DID/CAT reconstruction.** A raw `CoinState` does not reveal a coin's asset kind — that lives
in the coin's puzzle, revealed only when its parent is spent. Reconstruction uncurries the parent spend
(via the `Nft`/`Did`/`Cat` driver parsers) to populate the `nfts`/`dids`/`nft_collections` tables and to
attribute CAT coins to their asset id (TAIL hash) in the `coins` table (so `get_cats`/`get_token` become
complete). Parent spends are fetched through a `LineageSource` (out-of-DB lineage reads, B.5). Reads only.
The sync loop runs this attribution as a post-apply step (`sync::CatAttributor`, threaded into
`run_update_loop`): every `coin_state_update` is followed by an attribution pass that uncurries the
newly-synced candidate coins, so a synced CAT coin — stored initially with `asset_id: None` — gains its
TAIL and surfaces in `get_cats` (this is how `$DIG` resolves from the node).
attribute CAT coins to their asset id (TAIL hash) in the `coins` table, which is what makes such a coin
visible to `get_cats`/`get_token` at all. Parent spends are fetched through a `LineageSource` (out-of-DB
lineage reads, B.5). Reads only. Neither reader becomes COMPLETE by this: a coin the replica never
ingested cannot be attributed by a pass over rows it does not hold, and a row whose parent could not be
read is left for a later pass.

The attributor is owned by the SUPERVISOR, which builds it from the subscription set it resolved for the
current attempt and threads it into the update loop; a supervisor with no lineage source attaches none,
and that absence MUST be honest rather than silent. The pass also runs ONCE after a completed catch-up,
so a replica that syncs and then receives no further pushes still attributes what it holds. The pass MUST
NOT run after a frame that was refused before any database write — otherwise an empty, already-refused
frame buys a whole-replica scan and a chain read per candidate row.

18.11a. **CAT discovery is not CAT authenticity — staged admission (#380, #394).** A `CoinState` carries a
parent, a puzzle hash and an amount, and **no hint**, so a wallet cannot recognise its own CAT coins from
Expand Down Expand Up @@ -5514,17 +5520,52 @@ The two states are held in **different tables**, and this separation is normativ
- Staged rows are rolled back with the coins they describe. A reorg deletes every staged row created
above the fork and clears any spend recorded above it.

**Where promotion runs today.** On the shipped node the promotion pass runs on the POINT-READ tier
(`refresh_tracked_coins`) only. The peer frame path's promotion is reachable but its `CatAttributor` is
constructed under `cfg(test)` alone, so it does not run in production; wiring it is #382. A staged coin
therefore becomes spendable on a point-read refresh rather than on the frame that delivered it. This is a
statement of current behaviour, not a licence: nothing above is relaxed by it.
**Where promotion runs.** The promotion pass runs on BOTH tiers. On the point-read tier it is driven by
`refresh_tracked_coins`. On the peer path it is driven by the supervisor's `CatAttributor`, which runs it
once after a completed catch-up and again after every frame that actually WROTE something — so a staged
coin becomes spendable on the sync that delivers it rather than waiting for a point-read refresh. Until
#382 the peer path's attributor was constructed under `cfg(test)` alone and the production call site
passed a hard-coded `None`, so this tier existed and never ran; that is fixed, and the wording here is
the behaviour, not a licence — nothing above is relaxed by it.

**The stated failure mode is INCOMPLETENESS.** A real coin that cannot yet be proven is *absent* — not
counted as its asset, and in particular not counted as XCH, which `asset_id IS NULL` means and which
feeds coin selection. A wallet may under-report; it must never report a figure that is wrong.


18.11b. **A parent spend binds to the coin it was asked for.** A coin id is self-certifying —
`SHA256(parent ‖ puzzle_hash ‖ amount)` — so a `LineageSource` MUST check that the coin a spend answer
carries hashes to the coin that was requested, and MUST NOT return one that does not. Where it does not,
the coin is repaired from the coin record; where it still does not bind, the answer is NO LINEAGE rather
than a placeholder. This is a correctness requirement and not defence-in-depth: every CAT/singleton
driver derives its children's coin ids FROM that coin, so a placeholder makes `Cat::parse_children`
compute children matching nothing and the caller conclude the coin is not a CAT.

18.11c. **The attribution pass remembers its OUTCOMES, and distinguishes an absence from an outage.**
A coin's parent spend is settled chain history, so a row a pass RESOLVED and could not attribute answers
identically for ever. Those rows are ordinary: an NFT or DID coin row keeps `asset_id` NULL because the
reconstruction is written to its own table, and an odd-amount plain coin at the wallet's own p2 hash
reconstructs to nothing. A memory of failed LOOKUPS cannot cover them, because their lookups succeed — so
without an outcome mark each costs one outbound chain read per push frame for the life of the replica. A
row whose parent could NOT be read MUST NOT be marked: nothing was learned about it. The pass MUST
therefore cost work proportional to newly-arrived rows.

**A lineage answer distinguishes ABSENT from UNAVAILABLE, and the SOURCE must be able to tell them
apart.** "A source answered and there is no such spend" and "no source could be reached" MUST NOT be the
same value. Only an absence may be remembered or treated as a settled judgement; an unavailability is a
statement about this node's reachability and MUST be treated as *unknown*, so that a later pass asks
again.

The distinction MUST be carried by the chain READ, not merely by the enum. A source that reads spends
through an API which collapses "no such spend" into the same error as "the read failed" cannot produce an
absence at all, whatever its mapping says. The production source MUST therefore use an absence-aware,
corroborated read (`chia-query`'s `get_coin_spend_opt`), whose `Ok(None)` requires agreement across
independent sources and whose every transport failure, rejection and disagreement remains an error.

**A failed lineage read is NO LINEAGE, never an error.** An error propagates out of the attribution pass
and ends the peer session, which hands a denial of service to whoever made the read fail. The same
reasoning binds §18.11b's repair read, which fails to NO LINEAGE rather than propagating.

18.12. **Live broadcaster bring-up — real mainnet $DIG spends behind a config gate (#428).** The
node-custodied wallet BUILDS + SIGNS + VALIDATES spends (§18.9/§18.21) and the tip engine (§18.23)
reserves + caps them, but on the shipped node NO broadcaster is attached, so no `$DIG` moves. This
Expand Down
2 changes: 1 addition & 1 deletion crates/dig-wallet/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "dig-wallet"
version = "0.40.0"
version = "0.41.0"
edition = "2021"
license = "GPL-2.0-only"
description = "DIG Browser built-in Chia wallet sidecar: a local axum server (using digstore-chain + chia-wallet-sdk over coinset.org) that serves a Sage-mirroring wallet UI. Native Rust so BLS signing works; the browser opens it at 127.0.0.1."
Expand Down
168 changes: 157 additions & 11 deletions crates/dig-wallet/src/sage/cat_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ use std::collections::{HashMap, HashSet};
use chia_protocol::{Bytes32, Coin, CoinState};

use super::db::{CoinRow, PromotedSingleton, StagedCatRow, WalletDb};
use super::singleton::{coin_from_row, LineageSource, Reconstructed};
use super::singleton::{coin_from_row, LineageAnswer, LineageSource, Reconstructed};
use super::{singleton, Result};

/// How many staged coins one promotion pass will read parent spends for.
Expand Down Expand Up @@ -339,18 +339,34 @@ pub async fn promote_staged_cats(
.parent_spend(&row.parent_coin_info, created as u32)
.await
{
Ok(Some(parent)) => parent,
// `Ok(None)` is "the source ANSWERED, and has no spend for this parent" — which is
// consistent with an invented ancestry AND with a source that is merely behind.
// Treating it as a disproof would delete real coins whenever a source is behind, so it
// is a deferral; the cost of retrying it for ever is bounded by the cooldown instead.
Ok(None) => {
Ok(LineageAnswer::Found(parent)) => *parent,
// A source ANSWERED, and has no spend for this parent.
//
// #393 reasoned about this as `Ok(None)` and DEFERRED it, and that stays exactly
// right even though the answer is strictly stronger now: with `LineageAnswer` this
// arm is a CORROBORATED absence (chia-query settles an uncorroborated one as an
// `Err`), so one hostile peer can no longer produce it. Corroboration is agreement,
// NOT currency -- every source can agree and every source can be behind the chain,
// which is the ordinary case for a coin created seconds ago. So a corroborated
// absence still does not disprove ancestry, and promoting it to `Disproven` would
// delete real coins whenever the sources lag. Deferred, with the cooldown bounding
// the cost of retrying for ever.
Ok(LineageAnswer::Absent) => {
db.record_promotion_attempt(&row.coin_id, now).await?;
stats.deferred += 1;
continue;
}
// The source did not answer at all — transport, timeout, a malformed reply. Strictly
// less informative than `Ok(None)`, and handled the same way.
// No source answered at all -- transport, timeout, an uncorroborated claim, or two
// sources that contradict each other. Strictly less informative than an absence, and
// handled the same way.
Ok(LineageAnswer::Unavailable) => {
db.record_promotion_attempt(&row.coin_id, now).await?;
stats.deferred += 1;
continue;
}
// A source answered with something unusable (a malformed reveal, undecodable hex).
// Narrower than #393's `Err` arm, which also caught outages; those are `Unavailable`
// above. Handled identically, so no promotion decision changes.
Err(e) => {
tracing::debug!(
coin_id = %row.coin_id,
Expand Down Expand Up @@ -645,6 +661,24 @@ mod tests {
HashSet::new()
}

/// What a MISS from [`CountingLineage`] means. Named rather than left implicit because the
/// two are different chain facts that `Option<ParentSpend>` could not tell apart: `Absent` is
/// "the sources agree there is no such spend", `Unavailable` is "nothing could be learned".
///
/// The field exists so a double is not forced to pick one and pretend it is both. A fixture
/// map cannot know which a missing key represents, so it has to say -- and a double that can
/// only express one of production's two miss modes makes every test over it a partial green
/// (see [`LineageAnswer::from_lookup`]).
#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)]
enum Miss {
/// Nothing could be learned. The default, because it is the WEAKER claim: a double that
/// silently asserted a settled absence would let a test read a deferral as a disproof.
#[default]
Unavailable,
/// The sources agree there is no such spend.
Absent,
}

/// A [`LineageSource`] over a fixed parent map, which COUNTS its reads and can be told to fail
/// for one specific parent.
///
Expand All @@ -657,6 +691,8 @@ mod tests {
by_parent: HashMap<String, ParentSpend>,
reads: AtomicUsize,
fail_for: Option<String>,
/// What a parent absent from `by_parent` reports. See [`Miss`].
miss: Miss,
}

impl CountingLineage {
Expand All @@ -671,12 +707,18 @@ mod tests {
&self,
parent_coin_id: &str,
_spent_height: u32,
) -> Result<Option<ParentSpend>> {
) -> Result<LineageAnswer> {
self.reads.fetch_add(1, Ordering::SeqCst);
if self.fail_for.as_deref() == Some(parent_coin_id) {
return Err(crate::sage::Error::internal("parent unreadable"));
}
Ok(self.by_parent.get(parent_coin_id).cloned())
Ok(LineageAnswer::from_lookup(
self.by_parent.get(parent_coin_id).cloned(),
match self.miss {
Miss::Unavailable => LineageAnswer::Unavailable,
Miss::Absent => LineageAnswer::Absent,
},
))
}
}

Expand Down Expand Up @@ -1594,4 +1636,108 @@ mod tests {
"a second pass inside the cooldown must not touch it again: {second:?}"
);
}

/// **Proves:** a CORROBORATED absence defers exactly like an unreadable parent -- it never
/// disproves the coin, and never discards the staged row.
///
/// # Why this test exists
///
/// `LineageAnswer` split what used to be one `Ok(None)` into `Absent` ("the sources AGREE
/// there is no such spend") and `Unavailable` ("nothing could be learned"). `Absent` is the
/// stronger claim, and the tempting next step is to treat it as a disproof and discard the
/// row. That would be wrong, and wrong in the money-losing direction: corroboration is
/// agreement, NOT currency. Every source can agree and every source can be behind the chain,
/// which is the ordinary case for a coin created seconds ago -- so discarding on `Absent`
/// deletes real coins whenever the sources lag. This test goes red on that change.
///
/// # The fixture varies ONE actor
///
/// The honest CAT is present in both runs with its parent resolvable, so it must promote in
/// both. Only the SECOND coin's miss answer varies. A fixture where every parent missed would
/// be the blindest possible one here: with no promotion left to observe, a pass that deferred
/// correctly and a pass that abandoned the whole table on the first miss look identical.
#[tokio::test]
async fn a_corroborated_absence_defers_the_row_exactly_as_an_unreadable_parent_does() {
/// Run one promotion pass in which the honest CAT resolves and a second staged coin
/// MISSES, with the miss reported as `miss`. Returns
/// `(stats, rows still staged, rows believed)`.
async fn pass_with(miss: Miss) -> (PromoteStats, i64, usize) {
let f = real_cat();
let db = WalletDb::open_in_memory().await.unwrap();
let derived = DerivedCats::derive(&[f.owner_p2], &[f.asset_id]);
// A second coin at the SAME derived hash, so it is staged for the same reason the
// honest one is; only its parent differs, and that parent is deliberately absent from
// the lineage map.
let missing = fabricated_at(f.child.puzzle_hash, 1_234, 0xCD);

let rows = stage_from_states(
&[
state(f.child, Some(10), None),
state(missing, Some(11), None),
],
&derived,
|_| false,
);
assert_eq!(rows.len(), 2, "both coins are staged, neither believed");
db.stage_cat_admissions(&rows).await.unwrap();

let mut lineage = CountingLineage {
miss,
..Default::default()
};
// ONLY the honest coin's parent is resolvable. The other falls through to `miss`.
lineage
.by_parent
.insert(hex::encode(f.child.parent_coin_info), f.parent.clone());

let stats = promote_staged_cats(&db, &lineage, &owned()).await.unwrap();
let staged_left = db.staged_cat_admission_count().await.unwrap();
let believed = db.all_coins().await.unwrap().len();
(stats, staged_left, believed)
}

let (absent, absent_staged, absent_believed) = pass_with(Miss::Absent).await;
let (unavail, unavail_staged, unavail_believed) = pass_with(Miss::Unavailable).await;

// The truthful control: the honest CAT promotes under BOTH miss answers. Without this, a
// pass that abandoned the table on the first miss would satisfy every assertion below.
assert_eq!(
(absent.promoted, unavail.promoted),
(1, 1),
"the honest CAT must promote regardless of what the OTHER coin's parent reported -- \
one unresolvable row must never abandon the pass: absent={absent:?} \
unavailable={unavail:?}"
);

// The missing coin is DEFERRED under both, never refused.
assert_eq!(
(absent.deferred, absent.refused),
(1, 0),
"a corroborated absence must DEFER the row, not disprove it: sources can agree and \
still be behind the chain, so refusing here deletes real coins whenever they lag. \
{absent:?}"
);
assert_eq!(
(absent.deferred, absent.refused),
(unavail.deferred, unavail.refused),
"and it must be handled identically to an unreadable parent -- the promotion path \
deliberately does not act on the Absent/Unavailable distinction. absent={absent:?} \
unavailable={unavail:?}"
);

// The row SURVIVES, so a later pass can promote it once the sources catch up. This is the
// assertion a discard-on-Absent implementation fails.
assert_eq!(
(absent_staged, unavail_staged),
(1, 1),
"the unresolved row must stay STAGED under both answers, or a source that is merely \
behind permanently deletes a real coin"
);
assert_eq!(
(absent_believed, unavail_believed),
(1, 1),
"and exactly one coin is believed -- the proven one. An unresolved coin must never \
enter `coins`, where a NULL asset id would read as XCH"
);
}
}
Loading
Loading