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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ control-token
config.json
.gitnexus/


# lane-local scratch (never committed)
.lane/
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.195.0"
version = "0.197.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
24 changes: 15 additions & 9 deletions SPEC.md

Large diffs are not rendered by default.

261 changes: 247 additions & 14 deletions crates/dig-node-service/src/control.rs

Large diffs are not rendered by default.

182 changes: 174 additions & 8 deletions crates/dig-node-service/src/control_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@
WalletSyncStatus,
/// `control.wallet.peak` — the READ-ONLY chain peak height the node can see.
WalletPeak,
/// `control.wallet.resetCoinDb` — **DESTRUCTIVE.** Drop the cached coin database and force a
/// re-sync from chain (dig-node#384).
///
/// Discards only chain-derived rows, which a re-sync reproduces; it NEVER touches a seed, a
/// device key or any other key material. It refuses while a spend is in flight. Requires
/// `confirm: true`, so a mistyped verb cannot wipe the cache by accident.
WalletResetCoinDb { confirm: bool },
/// `control.wallet.broadcast` — push an ALREADY-SIGNED spend bundle. The node signs nothing.
WalletBroadcast { signed_bundle_hex: String },
/// `control.wallet.watch` — register PUBLIC keys whose addresses this node should follow.
Expand Down Expand Up @@ -224,6 +231,7 @@
ControlAction::WalletCoinsByParent { .. } => "control.wallet.coinsByParent",
ControlAction::WalletArrivals { .. } => "control.wallet.arrivals",
ControlAction::WalletPeak => "control.wallet.peak",
ControlAction::WalletResetCoinDb { .. } => "control.wallet.resetCoinDb",
ControlAction::WalletSyncStatus => "control.wallet.syncStatus",
ControlAction::WalletBroadcast { .. } => "control.wallet.broadcast",
ControlAction::WalletWatch { .. } => "control.wallet.watch",
Expand Down Expand Up @@ -311,6 +319,10 @@
ControlAction::WalletBalance { address, asset } => {
json!({ "address": address, "asset": asset_to_wire(asset) })
}
// The confirmation travels as a REQUIRED field rather than being asserted CLI-side,
// so every client of the control plane faces the same gate. A destructive method that
// only the CLI guards is a destructive method with no guard (dig-node#384).
ControlAction::WalletResetCoinDb { confirm } => json!({ "confirm": confirm }),
// Split from the balance arm because this read is PAGED. The two page fields are
// OMITTED when unset rather than sent as null, so the node applies the CONTRACT's
// default page size -- sending a number this CLI invented would make `dign wallet
Expand Down Expand Up @@ -499,6 +511,7 @@
}
.method(),
ControlAction::WalletPeak.method(),
ControlAction::WalletResetCoinDb { confirm: false }.method(),
ControlAction::WalletSyncStatus.method(),
ControlAction::WalletBroadcast {
signed_bundle_hex: String::new(),
Expand Down Expand Up @@ -735,17 +748,21 @@
),
"control.wallet.balance" => format!(
"balance {} · pending {} · {}",
result["balance"].as_u64().unwrap_or(0),
result["pending"].as_u64().unwrap_or(0),
if result["synced"].as_bool().unwrap_or(false) {
"synced"
} else {
"syncing"
},
amount(&result["balance"]),
amount(&result["pending"]),
balance_freshness(result),
),
// `result["coin"]` yields `Null` for a missing key, but indexing the INNER map would
// panic on one — so every field is read with `get`, and a coin record short of a field
// prints an honest unknown instead of aborting the CLI.
"control.wallet.resetCoinDb" => format!(
concat!(
"coin database reset · {} coin(s) and {} staged discovery row(s) discarded ",
"· the replica is no longer authoritative and will re-sync from chain"
),
amount(&result["coins_dropped"]),
amount(&result["staged_dropped"]),
),
"control.wallet.arrivals" => {
let n = result["arrivals"].as_array().map(Vec::len).unwrap_or(0);
format!(
Expand Down Expand Up @@ -1388,6 +1405,67 @@
}
}

/// A balance figure for a human line: the number, or `unknown` when the field is missing or is
/// not a number.
///
/// NEVER `0` on a miss (dig-node#416). A zero balance is a real claim about money — *you hold
/// none* — so printing one for a field the CLI could not read asserts a fact it does not have,
/// and does it in the direction a reader acts on. This is the same rule [`mojos`] states, applied
/// to the field a person actually looks at when asking what they own.
fn amount(v: &Value) -> String {
match v.as_u64() {
Some(a) => a.to_string(),
None => "unknown".to_string(),
}
}

/// How much a rendered balance can be trusted (dig-node#416).
///
/// # The defect this exists to remove
///
/// A stale replica answered `balance 0, synced false, source "fallback", peak_height null` for a
/// wallet, and the human line rendered that as `balance 0 · pending 0 · syncing`. A funded wallet
/// on a node ~8,380 blocks behind its peers produces exactly that line, and `syncing` reads as
/// reassuring progress rather than as *this number may be wrong*. The distinguishing fields were
/// on the wire the whole time and nothing a person reads used them.
///
/// # The four cases, which are four different claims
///
/// - **current** — the replica produced the figure and is following the chain. The number is an
/// answer.
/// - **as of height H, N blocks behind** — a real figure with a freshness bound. Usable, and the
/// reader can decide whether N matters to them.
/// - **as of height H, distance from the network unknown** — a bounded figure, but no held Chia
/// peer has announced a peak, so the node cannot say how far behind it is.
/// - **NOT CURRENT — this node cannot say what height this reflects** — the ticket's reading.
/// Nothing bounds the figure at all, so it is not evidence of anything, least of all emptiness.
///
/// Every non-current case is prefixed `NOT CURRENT` so the qualifier cannot be missed beside the
/// digit, and the last one says outright that the figure may not reflect the wallet — because
/// that is the case in which a reader is most likely to conclude they own nothing.
fn balance_freshness(result: &Value) -> String {
if result["synced"].as_bool().unwrap_or(false) {
return match result["peak_height"].as_u64() {
Some(h) => format!("current as of height {h}"),
None => "current".to_string(),
};
}
match (result["peak_height"].as_u64(), result["stale_by"].as_u64()) {
(Some(h), Some(0)) => format!("NOT CURRENT — as of height {h}, level with the network"),
(Some(h), Some(n)) => {
format!("NOT CURRENT — as of height {h}, {n} blocks behind the network")
}
(Some(h), None) => {
format!("NOT CURRENT — as of height {h}, distance from the network unknown")
}
(None, _) => concat!(
"NOT CURRENT — this node cannot say what height this reflects; the figure may ",
"not reflect the wallet"
)
.to_string(),
}
}

/// A coin amount for a human line: `N mojos`, or `amount unknown` when the field is missing or is
/// not a number.
///
Expand Down Expand Up @@ -2324,7 +2402,95 @@
assert!(s.contains("12345"), "got: {s}");
assert!(s.contains('6'), "got: {s}");
assert!(!s.contains('?'), "must not fall back to `?`: {s}");
assert!(s.contains("synced"), "got: {s}");
assert!(s.contains("current"), "got: {s}");
Comment thread
MichaelTaylor3d marked this conversation as resolved.
}

/// dig-node#416 — the money lie, asserted at the surface a person reads.
///
/// A stale-replica zero and an empty-wallet zero rendered the SAME line
/// (`balance 0 · pending 0 · syncing`). This asserts the two lines DIFFER, and it asserts
/// the specific direction: the unbounded one must not read as an answer.
///
/// The empty-wallet control is what makes this load-bearing. An implementation that
/// appended a scary qualifier to every balance would satisfy "the stale line warns" on its
/// own; it cannot satisfy "and the synced line does not".
#[test]
fn a_stale_zero_and_an_empty_wallet_zero_do_not_render_alike() {
// The measured reading from the ticket: a fallback answer with no height at all.
let unknown = summarize(
"control.wallet.balance",
&json!({
"balance": 0, "pending": 0, "synced": false,
"source": "fallback", "peak_height": null, "stale_by": null,
}),
);
// The honest zero: a synced replica saying the wallet holds nothing.
let empty = summarize(
"control.wallet.balance",
&json!({
"balance": 0, "pending": 0, "synced": true,
"source": "db", "peak_height": 9_220_177u64, "stale_by": 0,
}),
);

assert_ne!(
unknown, empty,
"a stale zero must not read like an empty wallet"
);
assert!(
unknown.contains("NOT CURRENT"),
"an unbounded zero must be marked not current: {unknown}"
Comment thread
MichaelTaylor3d marked this conversation as resolved.
);
assert!(
!empty.contains("NOT CURRENT"),
"a synced zero is a real answer and must NOT be scare-marked: {empty}"
Comment thread
MichaelTaylor3d marked this conversation as resolved.
);

// A bounded-but-behind answer is a THIRD line: usable, and it names the gap.
let stale = summarize(
"control.wallet.balance",
&json!({
"balance": 0, "pending": 0, "synced": false,
"source": "db", "peak_height": 9_211_798u64, "stale_by": 8_380,
}),
);
assert!(stale.contains("8380"), "the gap must be named: {stale}");
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
MichaelTaylor3d marked this conversation as resolved.
assert!(
stale.contains("9211798"),
"the as-of height must be named: {stale}"
Comment thread
MichaelTaylor3d marked this conversation as resolved.
);
assert_ne!(
stale, unknown,
"a bounded stale figure differs from an unbounded one"
);
}

/// dig-node#416: an ABSENT balance field renders `unknown`, never `0`.
///
/// The old summary read it with `.as_u64().unwrap_or(0)`, so a response short of the field —
/// or carrying it in any other JSON type — printed a confident zero balance. The synced
/// control in the same test proves the renderer still prints real zeros as `0`, so this
/// cannot be satisfied by never printing zero at all.
#[test]
fn an_unreadable_balance_field_renders_unknown_not_zero() {
let missing = summarize(
"control.wallet.balance",
&json!({ "synced": true, "peak_height": 42 }),
);
assert!(missing.contains("unknown"), "got: {missing}");
Comment thread
MichaelTaylor3d marked this conversation as resolved.
assert!(
!missing.contains("balance 0"),
"an absent field must not print a zero balance: {missing}"
Comment thread
MichaelTaylor3d marked this conversation as resolved.
);

let real_zero = summarize(
"control.wallet.balance",
&json!({ "balance": 0, "pending": 0, "synced": true, "peak_height": 42 }),
);
assert!(
real_zero.contains("balance 0"),
"a measured zero must still print as 0: {real_zero}"
Comment thread
MichaelTaylor3d marked this conversation as resolved.
);
}

/// REGRESSION (dig-node#260): a wallet mTLS listener that LOST its port must be
Expand Down
20 changes: 20 additions & 0 deletions crates/dig-node-service/src/entrypoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,21 @@ enum WalletCommand {
},
/// Print the chain peak this node reads against (READ-ONLY).
Peak,
/// DESTRUCTIVE: discard this node's cached coin database and re-sync it from chain.
///
/// Use when a coin's asset never resolves — a parent spend that could not be fetched is
/// skipped and never retried, so its value stays missing from asset-scoped balances for the
/// life of the database. An ordinary stale cache repairs itself and needs no reset.
///
/// Discards CHAIN-DERIVED rows only; they come back by syncing. It never touches your seed,
/// your device key, or any other key material. It refuses while a spend is in flight.
///
/// Requires `--confirm`.
ResetCoinDb {
/// Acknowledge that this discards the cached coin database.
#[arg(long)]
confirm: bool,
},
/// Print the wallet's chain-sync phase, replica height and Chia peer count (READ-ONLY).
///
/// Distinct from `dig-node sync status`, which is about DIG stores, not the chain.
Expand Down Expand Up @@ -1063,6 +1078,7 @@ fn wallet_action(cmd: WalletCommand) -> Option<ControlAction> {
ControlAction::WalletArrivals { after_seq, limit }
}
WalletCommand::Peak => ControlAction::WalletPeak,
WalletCommand::ResetCoinDb { confirm } => ControlAction::WalletResetCoinDb { confirm },
WalletCommand::SyncStatus => ControlAction::WalletSyncStatus,
WalletCommand::Broadcast { signed_bundle_hex } => {
ControlAction::WalletBroadcast { signed_bundle_hex }
Expand Down Expand Up @@ -1791,6 +1807,10 @@ mod tests {
"control.wallet.arrivals",
),
(vec!["dig-node", "wallet", "peak"], "control.wallet.peak"),
(
vec!["dig-node", "wallet", "reset-coin-db", "--confirm"],
"control.wallet.resetCoinDb",
),
(
vec!["dig-node", "wallet", "broadcast", "deadbeef"],
"control.wallet.broadcast",
Expand Down
4 changes: 4 additions & 0 deletions crates/dig-node-service/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ pub mod wallet_authz;
/// Never fatal, never a fallback. See [`wallet_bootstrap`].
pub mod wallet_bootstrap;

/// Latching the fact that the node's own wallet has held funds, so no surface calls a funded
/// auto-created wallet disposable (dig-node#286). See [`wallet_funded`].
pub mod wallet_funded;

/// The Sage-parity wallet mTLS listener: its bring-up and the state `dign info` reports
/// when it could not take its port (dig-node#260). See [`wallet_mtls`].
pub mod wallet_mtls;
Expand Down
26 changes: 26 additions & 0 deletions crates/dig-node-service/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2769,6 +2769,32 @@ fn spawn_mirror_passes(
// synchronous and sees one disk state and one balance throughout.
let capsules = lifecycle::observe_disk(&node).await;
let dig_balance = lifecycle::observe_dig_balance(&wallet, owner_puzzle_hash).await;

// dig-node#286: the ONLY caller of the funded latch. `latch_ever_funded` was written,
// persisted and tested, and nothing invoked it — so in the shipped build a funded
// auto-created wallet was still described as disposable, for ever.
//
// This pass is the observation point because it already reads the operator wallet's
// own balance on a timer, so the latch costs no extra chain read and cannot drift from
// the figure the node acts on. `synced` gates ONLY the zero case (see
// `FundingObservation::should_latch`), so a stale or fallback answer showing money
// still latches immediately.
{
use crate::wallet_funded::FundingObservation;
let synced = wallet
.wallet_sync_status()
.await
.is_ok_and(|s| s.phase == dig_wallet::sage::sync_supervisor::SyncPhase::Synced);
let observation = match &dig_balance {
Ok(base_units) => {
FundingObservation::classify(u128::from(*base_units), 0, synced)
}
// An unreadable balance is not a zero balance. It says nothing, and the latch
// is monotonic, so the next pass that CAN read decides.
Err(_) => FundingObservation::CannotSay,
};
crate::wallet_funded::observe(&paths, observation);
}
// ONE reading of what is already committed, for the whole pass — the analogue of the
// wallet selector's reservation prune (dig_ecosystem#2763), which the chain cannot
// offer: a broadcast coin stays unspent in the chain's view for the entire confirmation
Expand Down
Loading
Loading