Skip to content
Draft
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
10 changes: 5 additions & 5 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.160.0"
version = "0.161.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
82 changes: 80 additions & 2 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -5402,8 +5402,86 @@ attribute CAT coins to their asset id (TAIL hash) in the `coins` table (so `get_
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).
newly-synced candidate coins. A CAT of a KNOWN asset is already attributed on arrival by §18.11a's
derived-hash subscription and needs no such read; this pass covers the rest — NFT and DID singletons, and
any CAT row already in the replica. The attributor is owned by
the SUPERVISOR, which builds it from the subscription set it resolved for the current attempt and threads
it into BOTH the catch-up and 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 from genesis and then receives no further pushes still attributes what it found.

18.11a. **A CAT coin is recognised by a puzzle hash the wallet DERIVED, not by a claim a peer makes.**
A CAT coin does not sit at its owner's p2 puzzle hash; it sits at the OUTER hash that curries the asset's
TAIL around that p2 hash. Because that curry commits to the asset AND the owner together, the wallet MUST
derive `cat_puzzle_hash(owner_p2, asset_id)` for each of its p2 hashes crossed with each asset id it
knows, and MUST add those hashes to the set it SUBSCRIBES. A coin arriving at one of them is that asset,
and is this wallet's, by construction.

Admission is therefore a set-membership test against hashes this node computed from its own key material,
and it MUST issue no chain read. A coin so admitted MUST be written with its `asset_id` AND its owner
`hint` already populated: the CAT balance query scopes by `hint`, so a row stored without one is present,
correctly typed, and still reads as zero.

No coin admitted from a peer frame may be written with a NULL `asset_id` unless the wallet means it as
XCH. `asset_id IS NULL` denotes XCH in this schema and is selected UNSCOPED by the spend-input selector,
so a row admitted "unattributed, to be attributed later" is offered to the coin selector as spendable XCH
that the wallet cannot solve. Deriving the hash before subscribing is what makes that state unreachable
rather than merely guarded against.

The derived hashes MUST widen the SUBSCRIPTION set only. They are not addresses and they are not hashes
the wallet can sign for: they MUST NOT enter the plain p2 set that marks a coin as an ordinary signable
XCH coin, MUST NOT be counted as watched addresses, MUST NOT be recorded as the addresses a catch-up
covered, and MUST NOT reach the signer's own puzzle-hash set. Each of those sets answers a different
question, and a derived CAT hash is a wrong answer to every one of them.

The failure direction is INCOMPLETENESS, never a wrong figure. An asset whose id the wallet does not know
in advance cannot have its outer hash derived, so its coins are not subscribed and not admitted, and that
asset reads as ABSENT. Discovering CATs whose asset ids are unknown in advance is OUT OF SCOPE of this
section; it cannot be done by local derivation and MUST NOT be attempted on the frame path, where a
remote peer sets the pace.

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 — the failure that
refused eight real `$DIG` coins on a funded wallet.

18.11c. **The out-of-band attribution pass remembers its OUTCOMES, and distinguishes an absence from an
outage.** Coins the wallet did not recognise on arrival — NFT and DID singletons, and any CAT row already
in the replica — are attributed by a pass over rows the replica already holds. That pass runs on this
node's own schedule over its own data; it is not on the frame path and a remote peer does not set its
pace.

**The pass remembers the attribution OUTCOME per coin row, not the lineage lookup.** 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, and MUST NOT run after a frame that was refused before any
database write.

**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 — so the ABSENT arm becomes unreachable in production for the
exact case it was written for. 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. Collapsing the
pair lets a transient outage be cached as a fact.

**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)
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.39.0"
version = "0.40.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
58 changes: 57 additions & 1 deletion crates/dig-wallet/src/sage/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,8 @@ CREATE TABLE IF NOT EXISTS coins (
asset_id TEXT,
hint TEXT,
created_timestamp INTEGER,
spent_timestamp INTEGER
spent_timestamp INTEGER,
attribution_examined INTEGER
);
CREATE INDEX IF NOT EXISTS idx_coins_ph ON coins (puzzle_hash);
CREATE INDEX IF NOT EXISTS idx_coins_asset ON coins (asset_id);
Expand Down Expand Up @@ -563,6 +564,10 @@ const ADD_COLUMN_MIGRATIONS: &[&str] = &[
// rows banned before this column existed, which sorts them first and so evicts them first --
// the right order, since they are by definition the oldest bans on the machine.
"ALTER TABLE peers ADD COLUMN banned_at INTEGER",
// Set once a CAT/singleton attribution pass has RESOLVED this coin's parent spend and acted
// on what it said (dig-node#383). NULL means "not examined yet", which is the right reading
// for every row written before this column existed: they are re-examined once and then settle.
"ALTER TABLE coins ADD COLUMN attribution_examined INTEGER",
];

// ---- one-shot data-migration ladder ---------------------------------------
Expand Down Expand Up @@ -625,6 +630,10 @@ const POST_MIGRATION_INDEXES: &[&str] = &[
// The eviction order is `ORDER BY last_used_at`, run on every cache write (dig_ecosystem#3035).
"CREATE INDEX IF NOT EXISTS idx_chain_read_cache_last_used ON chain_read_cache (last_used_at)",
"CREATE INDEX IF NOT EXISTS idx_chain_spend_cache_last_used ON chain_spend_cache (last_used_at)",
// `unexamined_attribution_candidates` runs after every applied push frame. Without this it is
// a full table scan per frame; with it, a quiet wallet's pass touches no rows at all.
"CREATE INDEX IF NOT EXISTS idx_coins_attribution_pending ON coins (attribution_examined) \
WHERE spent_height IS NULL AND asset_id IS NULL AND attribution_examined IS NULL",
];

// ---- chain-read cache budget (dig_ecosystem#3035) -------------------------
Expand Down Expand Up @@ -2606,6 +2615,53 @@ impl WalletDb {
Ok(())
}

/// Record that an attribution pass RESOLVED this coin's parent spend and acted on the answer,
/// so no later pass reads it again (dig-node#383).
///
/// # Why the outcome is remembered rather than the lookup
///
/// A CAT gains an `asset_id` and so is self-marking, but the other resolved outcomes are not:
/// [`Self::upsert_nft`] and [`Self::upsert_did`] write their own tables and leave
/// `coins.asset_id` NULL, and an odd-amount plain XCH coin at the wallet's own p2 hash
/// reconstructs to *nothing at all*. Every one of those rows is a candidate again on the next
/// pass, resolves again, and costs another outbound chain read — forever, at whatever cadence
/// a peer chooses to send frames at.
///
/// A cache over the *lookup* cannot fix that, because these lookups succeed. Only the
/// attribution OUTCOME is stable enough to remember, and it is stable for the strongest
/// possible reason: a coin's parent spend is settled chain history and cannot change.
///
/// The mark is set ONLY on a resolved read. A parent that could not be reached leaves the row
/// unmarked, because remembering "we could not ask" as "we asked" is how an outage turns into
/// a permanent wrong balance.
pub async fn mark_attribution_examined(&self, coin_id: &str) -> sqlx::Result<()> {
sqlx::query("UPDATE coins SET attribution_examined = 1 WHERE coin_id = ?")
.bind(Self::normalise_hex(coin_id))
.execute(&self.pool)
.await?;
Ok(())
}

/// The coins an attribution pass could still learn something from: unspent, unattributed,
/// confirmed, and not yet examined.
///
/// Narrowed in SQL rather than by filtering [`Self::all_coins`] in Rust. The pass runs after
/// every applied push frame, so a whole-table read there is a standing per-frame cost
/// proportional to everything the replica has ever synced; here it is proportional to what
/// has newly arrived, and on a quiet wallet it returns nothing.
pub async fn unexamined_attribution_candidates(&self) -> sqlx::Result<Vec<CoinRow>> {
let rows = sqlx::query(
"SELECT * FROM coins
WHERE spent_height IS NULL
AND asset_id IS NULL
AND created_height IS NOT NULL
AND attribution_examined IS NULL",
)
.fetch_all(&self.pool)
.await?;
Ok(rows.iter().map(Self::coin_from_row).collect())
}

// ---- NFTs -------------------------------------------------------------

/// Insert or update a reconstructed NFT (keyed by launcher id; a later coin overwrites
Expand Down
Loading
Loading