Skip to content

feat(mirror): construct the bond-state observation (#412 step 7) - #419

Merged
MichaelTaylor3d merged 20 commits into
mainfrom
loop/412-step7-observation
Aug 30, 2026
Merged

feat(mirror): construct the bond-state observation (#412 step 7)#419
MichaelTaylor3d merged 20 commits into
mainfrom
loop/412-step7-observation

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

DO NOT MERGE — DRAFT until the gate round returns. This is a money surface: it opens the
operator wallet at bring-up and schedules an unattended reconcile pass. Full gate.

What this does

dig-node#412 step 7: construct the mirror-coin observation, wire it to control.mirror.bondStates,
bring up the lifecycle, and schedule the pass.

Before this, two shipped pieces had no production caller and therefore no effect:
control.mirror.bondStates (shipped in 60bc1275) returned unknown { reason: "chain_unreadable" }
on every call, and the pass runner (b547c709) was never constructed. Both were waiting on one
thing — an observation built from a ChainSource and the operator wallet's puzzle hash.

The design decision that shaped it

Step 6 refused to unseal the operator wallet on a read path to obtain the owner puzzle hash: a
token-gated read that unseals a spending key is a privilege escalation, and its gate confirmed no
non-sealing source existed. That refusal is preserved and generalised rather than worked around.

The puzzle hash is derived ONCE, at bring-up, in the scheduler task where the operator wallet is
already being opened under the device key — signer.owner_puzzle_hash() when a wallet opened, and
dig_wallet::operator_wallet::operator_puzzle_hash(&paths) (which constructs no WalletSigner at
all, the distinction held by its RETURN TYPE) when one did not. It is then held for the life of the
task. No request path unseals anything, and no later edit to a request path can, because the
request path has no access to the seed.

control.mirror.bondStates serves a published SNAPSHOT rather than observing per request, and
that is a security property rather than a cache. The step-6 gate warned that wiring observe behind
the method would turn one ~200-byte paired-token request into a seed unseal, a PBKDF2, up to
dig_mirror_coin::MAX_CANDIDATES chain lookups and an oracle read, on a branch with no ingress
limiter — a paired token being a much weaker predicate than "trusted". The chain work now happens on
the round timer whether anybody asks or not; asking more often costs a lock read.

The four properties that had to survive the wiring, and how each is held

  • The signer is module-private and is NOT installed on the general WalletBackend. Held inside
    NodeMirrorEffects. Asserted structurally over the crate's own source — WalletBackend's
    signer field and current_signer() are both private, so a runtime assertion from this crate
    cannot reach the property — that neither lifecycle.rs, signer.rs nor server.rs contains
    .with_signer( or .with_broadcaster(. A second test proves the guard can fail and is reading
    the right files. Without this, bring-up silently switches DEFAULT-ON auto-tipping live as a side
    effect of collateralising capsules.
  • MirrorSigner::sign(&MirrorSpends, &SpendJournal) is not relaxed. A RecordedSpend is
    obtainable only from SpendJournal::begin, so the lifecycle cannot sign without journaling — not
    by promise, but because no other call is expressible.
  • Reclaims run first and are never gated on funds, including on the funds READ. The balance is
    handed to NodeMirrorEffects as a Result, and an Err defers creates while reclaims proceed at
    fee = 0 with no fee coins (§25.4.4). an_unreadable_balance_defers_creates_and_still_reclaims
    covers it.
  • The Relayed filter stays at the source. split_by_provenance is applied at observation and
    PassInputs::held/::relayed remain separate fields, so the create path is structurally unable to
    see a relayed capsule.

Two further gate findings consumed rather than deferred

  • B — attacker-driven truncation under-reports LOCKED money. MirrorInventory::is_complete()
    now gates the pass: an incomplete scan aborts it, and the surface keeps its previous answer
    rather than publishing a short one. Anyone can create dust coins at the shared mirror puzzle hash,
    so the truncation point is purchasable, and a short inventory reports less locked $DIG than is
    actually locked — money shown as free while it sits on chain.
  • D — a second unseal route, on a key with no Drop. Removed by construction, per the design
    above: the second route never comes into existence.

What this PR deliberately does NOT do

  • Creates are REFUSED, by name. dig_mirror_coin::create takes its Vec<Cat> from the caller,
    and the only $DIG selector this process has (WalletBackend::select_cats) reads the
    node-custodied replica's coins rather than the operator wallet's. Funding a mirror coin from those
    would be a real spend of the wrong wallet's money that looks entirely successful. So
    NodeMirrorEffects::create returns a named PassError::Wallet, the pass reports it in
    stopped_at, and §25.8 keeps reporting the bond as uncovered — which is true.
    Mirror creates need an operator-scoped $DIG CAT coin selector #421
  • The DHT pointer is not attached, and this is an upstream block. Re-measured against
    index.crates.io today: dig-download 0.21.0 and dig-peer-selector 0.10.0 both require
    dig-dht ^0.13; ProviderRecord::unverified_mirror_coin_id is in dig-dht 0.15. ^0.13 and
    0.15 are semver-incompatible on a 0.x line, so declaring 0.15 here resolves two dig-dht
    lines while cargo prints success
    . The cascade was NOT collapsed into a version edit that would
    appear to work, and no shim bridges the two lines.
    Attach unverified_mirror_coin_id at the DHT announce (blocked on the dig-dht 0.15 cascade) #422

Blast radius checked

gitnexus was not used; this is §2.0's stated fallback — grep plus direct read — and the radius is
narrow enough to enumerate:

  • ControlCtx — exactly one construction site (server::control_ctx); one field added.
  • mirror_bond_observation — exactly one caller before this change, two after
    (control.mirror.bondStates and control.collateral.buffer).
  • mirror_bond_states / collateral_buffer / MirrorEffects::observe_disk — all crate-private
    signatures; every call site is in this diff.
  • PassRunner — gained with_presence/into_presence, lost nothing. Needed because the production
    scheduler rebuilds its effects each round (the chain source is per-round), and a fresh
    PresenceTracker suppresses every capsule it has ever seen exactly once — so without carrying it,
    no bond would ever settle while the node looked like it was reconciling normally.
  • OperatorWallet — gained synthetic_key(), a public value.

Verification

cargo test -p dig-node-service --lib mirror88 passed, 0 failed locally. Full gates on CI.

One test failed on its own fixture and was fixed rather than weakened: the structural
signer-isolation guard searched its own file for a literal that a sibling test had written into
itself. That is a pleasing proof the search works and useless as a standing guard, so the needles are
now assembled with concat! and the source carries only fragments.

Reconciliation

control.collateral.buffer treated the served set as unknown while bondStates asserted it was
knowable. Both were correct for different reasons; now both read the same published observation,
so they cannot disagree. None there remains the absence of an observation and is never a served set
of zero.

Closes

Nothing. closingIssuesReferences is empty and must stay empty. dig-node#412 still owns step 8,
and dig-node#377 closes only on the real-machine proof. #412 was closed prematurely once already by a
Closes on a PR doing a quarter of its scope.


Gate round 2 — the two gating findings (8f93bd6, e22e8ae)

1. The production effects were built with broadcaster: None, so no reclaim could reach chain — while the node logged that it could. Fixed by making the reporting honest rather than by turning on unattended mainnet spends. production_broadcaster() is now the ONE seam: open_signer derives the capability from it and spawn_mirror_passes passes the same call into NodeMirrorEffects::new, so what the node announces and what a spend can reach cannot be two answers. New SpendCapability::BroadcasterUnwired (a missing wiring, not a switch the operator can flip); the refusal names dig-node#424 instead of naming the flag the operator must already have set to reach it. The broadcaster check in sign_and_broadcast is untouched and still runs before anything is signed. Wiring it needs ChainTransport::shared_client, which is pub(crate) to dig-wallet — a live-money change that gets its own PR (#424).

2. The presence carry had no test. It does: the_presence_tracker_carries_between_runners_and_a_fresh_one_suppresses landed in 9a5d392, after the gate read ab6b25a. Proven red by dropping the carry (left: [], right: [Bond{…}]), then restored.

Non-gating, taken in the same pass: the signer-installation guard now scans control.rs and wallet_mtls.rs; the_installation_guard_can_actually_fail no longer asserts a string contains itself; collateral_buffer counts bondable rows rather than every served row.

Blast radius checked

Per-symbol, over the whole worktree (grep + direct read; the per-worktree gitnexus index was not built for this scoped round, and §2.0 permits the fallback — stated rather than implied). Every touched symbol has exactly one call site and all of them are inside dig-node-service:

symbol callers radius
open_signer server.rs:2691 1, plus its own tests
NodeMirrorEffects::new server.rs:2767 1 (sole production construction)
sign_and_broadcast lifecycle.rs:300 (reclaim) 1, module-private
SpendCapability server.rs:2712-2726 match exhaustive match, new arm added
with_presence / into_presence server.rs:2778-2782 1 pair, plus the carry test
collateral_buffer control.rs dispatch 1

No public wire surface changes: SpendCapability is not serialised, and control.mirror.bondStates' shape is untouched. production_broadcaster and spend_capability are new pub items on a binary crate with no external consumers.

Risk called out: none HIGH/CRITICAL. The one behaviour change a reader should look at twice is collateral_buffer's count, which is latent today (spendable_dig_base_units is None) and becomes a money figure when the balance is wired.

Evidence

  • cargo test -p dig-node-service --lib mirror::86 passed, 0 failed.
  • Revert-proof, capability: hard-code Available back into open_signerleft: Available, right: BroadcasterUnwired, 1 failed. Restored → 7 passed.
  • Revert-proof, presence: drop the carry from with_presence → the carry test fails on an empty create list. Restored → green.
  • cargo fmt --all -- --check clean.

A false green worth recording: the first version of the capability test asserted a property of the helper rather than of open_signer, and survived its own revert-proof green. It was replaced with one that mints a real operator wallet and calls open_signer itself.

Adds `mirror::observe` — a pure function over values that turns the capsules
on disk, the mirror coins on chain, this node's open creates, and its spendable
$DIG into the `BondObservation` the §25.8 surface pages.

It takes no `MirrorEffects`, so it holds no `create` and no `reclaim`: the
read surface cannot reach a spend because no spend capability is constructed
on its path. States come from `pass::decide`, the same pure decision a real
pass takes, rather than a second derivation that would drift.

Supporting reads, both keyless:

- `dig_wallet::operator_puzzle_hash` derives this node's public puzzle hash
  without ever producing a `WalletSigner`.
- `WalletBackend::dig_balance_base_units` reads spendable $DIG at that hash.
  `None` is UNKNOWN, never zero — the two render as opposite claims.

Constructing or scheduling a pass, and wiring a `MirrorSigner`, are step 8.

Refs #412
…n lookup fail loudly

`build(deps)` 2614d07 saved crates/dig-node-core/Cargo.toml with CRLF line
endings. Not one declaration changed — 1147 of its 1175 changed lines were
line-ending churn — but `dependency_tree.rs` reads that manifest through
`include_str!` and searches it for "\n[dependencies]\n", which CRLF bytes
never match.

The lookup's `unwrap_or(0)` then turned a failed search into a silent
fallback to offset 0, so the guard searched `[package]` instead, found no
`dig-download` there, and panicked claiming it was absent from the production
tree. `dig-download = "0.21"` had never moved: it is on line 448 of the
`[dependencies]` section, exactly where it was before.

Two changes, both at the cause:

- the manifest is normalised to LF before any search, so these assertions stay
  about DECLARATIONS rather than about how a file happened to be saved;
- the lookup panics naming the section it could not read. A lookup that FAILED
  is not evidence about any dependency, and offset 0 makes every "X is a
  production dependency" assertion report absence with total confidence.

The assertion itself is unchanged and no expect was loosened.

Also bumps the workspace to 0.173.0: main took 0.172.0 while this branch was
open, and step 7 adds a capability.

Refs #412
…real types

The lane that wrote `mirror::observe` died before it ever built, so its test
fixtures were written against remembered shapes rather than the published ones.
Three errors, all in the test module and none in `observe` itself:

- `CollateralRequirementResult::Known` has no `census_height`. Replaced with
  the real fields — `protocol_version`, `multiplier_micros` and
  `handicap_dig_base_units` — matching `pass::tests::known_at`, so the two
  fixtures describe the same requirement.
- `BondState::Bonded` is a struct variant carrying the coin and the amount, not
  a unit value, so it cannot be compared with `==`.

Both `Bonded` assertions were rewritten to match rather than weakened. One now
pins the PAYLOAD — the coin id, the epoch, and the 600 that coin locks — because
a bare variant check cannot see a row that names the other coin or that reports
this epoch's requirement instead of the coin's own amount, and both are the
plausible wrong answers here.

That assertion needed a fixture change to be able to fail: every fixture coin
shared the id `"cc" * 32`, so a claim about "the coin bonding aa/11" held
equally against the coin bonding bb/22. Coin ids are now distinct per
`(store, root)`.

Also applies rustfmt to `operator_puzzle_hash`.

Refs #412
@MichaelTaylor3d MichaelTaylor3d changed the title feat(mirror): construct the bond-state observation and attach the DHT mirror-coin pointer feat(mirror): construct the bond-state observation (#412 step 7) Aug 30, 2026
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Correctness gate — IN PROGRESS, not the verdict

Head read: 01bc66a40f1dd1cda31d2d1b998f5506942ed720. Posting as I establish each item so nothing is lost to a stall.

Confirmed so far, by reading the diff at that head:

  1. locked_dig_base_units is summed over the whole chain observationmirror/observe.rs:104-107 folds on_chain.iter().map(|c| c.collateral_dig_base_units) with saturating_add, before pass::decide is called and independent of its plan. It reads each coin's own amount, not ctx.requirement. Matches the claim.
  2. dig_balance_base_units is Option and threaded through as-isobserve.rs:118 passes it straight to PassInputs; nothing substitutes zero. dig-wallet/src/sage/rpc.rs:1229 returns None on a read error via .ok()?.
  3. The cast is narrowing, not saturatingsage/rpc.rs:1236 is u64::try_from(read.balance).ok(), which yields None on overflow rather than a confident u64::MAX. This is the correct direction: an unrepresentable balance becomes UNKNOWN, not a funding decision made on a wrong number.
  4. No second derivationobserve calls pass::decide once and takes only decision.states; there is no local re-derivation of coverage anywhere in the module.
  5. The coin fixture id is genuinely distinct per pairobserve.rs test helper coin() builds format!("{store}{root}").repeat(16), so aa/11 and bb/22 produce different 64-char ids, and the first Bonded assertion guards coin_id, epoch AND amount_dig_base_units inside the matches!. That assertion would fail against a row naming the other coin or reporting the epoch requirement (1000) instead of the coin's own 600.

Still open: executing the dependency_tree mutation probes (does the guard still go red when dig-download is genuinely removed, and does a CRLF-saved manifest now pass rather than panic), and the mirror::observe suite run. Verdict to follow.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Correctness gate — IN PROGRESS (2/3), not the verdict

Executed probes at 01bc66a4, in a throwaway worktree (C:/tmp/worktrees/gate-419), never in a shared checkout.

Baseline, both green:

  • cargo test -p dig-node-core --test dependency_tree — 4 passed.
  • cargo test -p dig-node-service --lib mirror::observe — 5 passed.

The dependency guard can still FAIL — proven by mutation, not by reading:

  • PROBE B — added features = ["testkit"] to the production dig-download entry (crates/dig-node-core/Cargo.toml:448). Result: FAILED, at dependency_tree.rs:68, with the intended message naming the mutated line. The guard is live against the exact attack it was written for.
  • PROBE C — commented out the production dig-download entry entirely. Result: red (error: could not compile dig-node-core (lib) due to 56 previous errors). Removal is unmissable, though via the compiler rather than via the assertion.

Reading the four tests in that file for the same vacuity class: each carries its own anti-vacuity guard already — the_fail_open_… .expects presence and asserts the dev entry does enable testkit (so a manifest missing both entries fails); the_workspace_carries_exactly_one_module_wire_crate asserts len == 1 plus a 0.10. prefix; the_peer_client_and_pull_engine_are_not_duplicated asserts len == 1 per crate; no_dig_constants_copy_predates_the_real_genesis_challenge asserts non-empty before applying the floor. The unwrap_or(0)panic! change is the right shape: a lookup that failed now says so instead of silently searching [package].

Lock, checked against the stated scope: Cargo.lock resolves exactly one dig-dht 0.13.0, one dig-nat 0.21.0, one dig-rpc-protocol 0.10.2. No shim bridges two lines. The unverified_mirror_coin_id deferral is correctly out of scope.

Remaining: a genuine CRLF-manifest probe (the first attempt's sed did not actually insert CRs — re-running with perl), and two mutation probes on observe itself. Verdict follows.

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PASS — correctness gate

Head read: 01bc66a40f1dd1cda31d2d1b998f5506942ed720 (resolved from the remote, not from the dispatch brief). Verdict recorded as a --comment review because a verdict-bearing review event is 422 on a same-identity PR. No gating findings; nothing here bars merge. The PR remains DRAFT and I have not undrafted or merged it.

Every claim below was executed, in a throwaway worktree (C:/tmp/worktrees/gate-419), left clean at 01bc66a4 with an empty git status --porcelain. No shared checkout was mutated.

Baseline

  • cargo test -p dig-node-core --test dependency_tree4 passed
  • cargo test -p dig-node-service --lib mirror::observe5 passed

The dependency guard can still FAIL, and now passes on CRLF — both proven by mutation

probe mutation expected observed
A2 manifest rewritten with 591 genuine CRLF pairs (perl -pi -e 's/\n/\r\n/') green, no panic 4 passed — the \r\n -> \n normalisation in manifest() does the job
B features = ["testkit"] added to the production dig-download entry (crates/dig-node-core/Cargo.toml:448) red FAILED at dependency_tree.rs:68, message naming the mutated line
C production dig-download entry commented out red redcould not compile dig-node-core (lib) due to 56 previous errors

Probe B is the one that matters: the guard is live against the exact attack it exists for. Probe C shows removal is unmissable, though it is the compiler rather than the assertion that catches it — the expect("dig-download is a production dependency of this crate") arm is unreachable in this workspace precisely because the crate is genuinely used, which is fine.

The unwrap_or(0) -> panic! change (dependency_tree.rs:47) is the right shape: a lookup that FAILED now says which section it could not read, instead of silently searching [package] and reporting absence with total confidence.

All four tests in that file checked for the same vacuity class — none is vacuous. the_fail_open_... .expects presence and asserts the dev entry does enable testkit, so a manifest missing both entries fails rather than passing an absence check. the_workspace_carries_exactly_one_module_wire_crate asserts len == 1 plus a 0.10. prefix. the_peer_client_and_pull_engine_are_not_duplicated asserts len == 1 per crate over six crates. no_dig_constants_copy_predates_the_real_genesis_challenge asserts non-empty before applying the floor, and uses a floor rather than an equality check.

Money honesty in mirror::observe — confirmed, and the tests discriminate

  • locked_dig_base_units is summed over the whole chain observation. mirror/observe.rs:99-102 folds on_chain.iter().map(|c| c.collateral_dig_base_units) with saturating_add, before pass::decide and independent of its plan, reading each coin's own amount rather than ctx.requirement. PROBE D: truncating that fold to the first coin made the_locked_total_includes_a_coin_the_plan_is_about_to_reclaim fail (left: 600, right: 1000). The test is not a restatement of the implementation.
  • None is UNKNOWN, never zero. observe.rs:111 threads the Option through untouched; pass.rs:313 maps is_none() to BondState::FundsUnknown while Some(0) reaches Unfunded. PROBE E: inserting .or(Some(0)) at that line made an_unreadable_balance_defers_only_the_rows_it_prices fail with exactly the money lie it guards — left: Some(Unfunded { short_dig_base_units: 1000 }), right: Some(FundsUnknown).
  • The cast is narrowed, not saturated. dig-wallet/src/sage/rpc.rs:1236 is u64::try_from(read.balance).ok(). An unrepresentable balance becomes None (UNKNOWN), never a confident u64::MAX on a funding decision. Correct direction.
  • No second derivation. observe calls pass::decide exactly once and takes only decision.states; there is no local re-derivation of coverage anywhere in the module.

The suspect-by-provenance fixtures

  • The shared-id defect is genuinely fixed, not restated. observe.rs test helper coin() builds format!("{store}{root}").repeat(16), so aa/11 and bb/22 yield different 64-char ids. The first Bonded assertion guards coin_id, epoch AND amount_dig_base_units inside the matches!, so a row naming the other coin, or reporting the epoch requirement 1000 instead of the coin's own 600, fails.
  • Checked the other fixture fields for the same uniformity. store_id/root vary per pair; epoch is varied deliberately (3 in the epoch test vs 7 elsewhere, which is what makes that test discriminate); collateral varies 600/400/REQUIRED. margin_bp is 0 in every fixture and creates_enabled is true in every fixture — neither is a property observe decides (both are pass-through parameters covered in pass.rs's own suite), so this is a coverage boundary rather than a defect.
  • The two Bonded assertions are NOT equally strong, and that is fine. The first is a full-payload matches! with three field guards. The second (an_unreadable_balance_defers_only_the_rows_it_prices) is Some(BondState::Bonded { .. }) — weaker than an equality check, but it is asserting non-disturbance rather than payload, and PROBE E proved that test still discriminates.

Scope claim sanity-checked

Cargo.lock resolves exactly one dig-dht 0.13.0, one dig-nat 0.21.0, one dig-rpc-protocol 0.10.2. No shim bridges two lines. The unverified_mirror_coin_id deferral is correctly out of scope and correctly explained in the root Cargo.toml comment.

Public API

split_by_provenance widened to pub(super) — crate-internal, no published surface. observe/held_mirrors are new in a workspace-versioned in-repo crate. dig-wallet's dig_balance_base_units and operator_puzzle_hash are additive. No published crate's public API changed. Workspace version bumped 0.172.0 -> 0.173.0 (minor, correct for new capability).

Non-gating notes (posted inline and resolved by me — they must not block merge)

  1. .step7-wip is a tracked lane-marker file committed to the branch. Cruft under §2.5; delete it before merge or gitignore it.
  2. crates/dig-wallet/Cargo.toml:3 stays at 0.41.0 while the crate gains a public async method. In-repo, workspace-consumed, so nothing breaks — worth a bump for hygiene.
  3. observe, held_mirrors and operator_puzzle_hash have no production caller yet (grep confirms). Expected for step 7; step 8 wires them. Stated so nobody later reads the dormancy as a defect.

What I did NOT cover

  • The full dig-node-service / dig-node-core suites — I ran only the two filters named in the brief plus the mutations above.
  • Cargo.toml / Cargo.lock dependency-tier correctness beyond the single-line assertions and the three crates spot-checked in the lock.
  • Any runtime or end-to-end behaviour: observe is a pure function with no caller, so there is nothing to run end-to-end at this step.
  • professional-ui — no UI surface in this diff.

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two non-gating hygiene notes, posted inline so they are durable. I am resolving both myself so they cannot block the merge under required_conversation_resolution.

Comment thread .step7-wip Outdated
Comment thread crates/dig-wallet/src/sage/rpc.rs
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict

Audited head: 01bc66a40f1dd1cda31d2d1b998f5506942ed720 (resolved from gh pr view 419 --json headRefOid).

Interim finding 1 — REACHABILITY: nothing in this diff is reachable from the control plane yet

observe, held_mirrors, ObserveContext, operator_puzzle_hash and dig_balance_base_units have
zero production callers at this head. Measured with git grep against the head tree, excluding the
defining files and their own #[cfg(test)] modules.

The gating fact is crates/dig-node-service/src/control.rs:3530:

fn mirror_bond_observation() -> Result<BondObservation, MirrorBondStatesUnknownReason> {
    Err(MirrorBondStatesUnknownReason::ChainUnreadable)
}

Still an unconditional Err. control.mirror.bondStates therefore answers unknown{chain_unreadable}
on every call at this head, and the observation path this PR adds is not on it. So:

  • No attacker-reachable path exists today into observe, into the locked_dig_base_units sum, or into
    held_mirrors' BigInt handling. Whatever this audit finds in those functions is a latent issue
    that becomes live when step 8 wires mirror_bond_observation, not a live one now.
  • The disclosure question (a bond page naming this operator's coin ids, epochs, locked $DIG and
    spendable balance) is likewise not yet answerable by the surface. It is still worth settling before
    step 8, and I am continuing to settle it, but it does not gate this diff.

I am recording this first because it re-scopes every other crux: this is a capability-construction
audit, not an exploit-path audit.

Interim finding 2 — repo hygiene, TICKET-CANDIDATE (not gating)

.step7-wip (1 line, "step 7 lane: mirror bond observation + DHT pointer. WIP.") is committed at the
repo root. Non-sensitive content — no credential, path or host in it — so this is not a secrets finding.
But a squash-merge lands it on main permanently. Suggest dropping it before merge or filing it as
cleanup; either is fine, it is not a security defect.

Continuing: capability boundary on operator_puzzle_hash, money-figure honesty, untrusted input into the
sums, the split_by_provenance widening, and the dependency posture.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict (2/n)

Head audited: 01bc66a40f1dd1cda31d2d1b998f5506942ed720.

Interim finding 3 — DEPENDENCY POSTURE: verifies clean, no new supply-chain surface

Measured from the resolved Cargo.lock at both ends, not from carets.

The dig-gossip git pin is a tag-to-tag move, not a mid-branch commit. Verified against the
remote:

rev resolves to is
base 48a14ed2ed395a41c913d5c8f944e6ea0b747075 dig-gossip 0.30.0 tag v0.30.0
head 1a3391662ecce1a3cbe8b74122a52bbb1b28d3ee dig-gossip 0.32.0 tag v0.32.0, tip of main, authored dig-release-bot, 2026-08-29

So the pin equals what the PR claims. The git-dep shape itself is the pre-existing NC-7 exception
(dig_ecosystem#2647 — dig-gossip cannot publish while its native-tls [patch.crates-io] fork stands,
because cargo publish strips it); this PR does not change that shape, and dig-node's own root manifest
declares no [patch.crates-io] section, so nothing new is being redirected.

dig-dht resolves to exactly ONE line, and nothing shims across two. Full family counts, base → head:

dig-dht            1 [0.12.1]        -> 1 [0.13.0]
dig-nat            1 [0.20.0]        -> 1 [0.21.0]
dig-peer           1 [0.12.0]        -> 1 [0.13.0]
dig-download       1 [0.20.4]        -> 1 [0.21.0]
dig-peer-selector  1 [0.9.2]         -> 1 [0.10.0]
dig-tls            1 [0.4.0]         -> 1 [0.4.0]
chia-protocol      2                 -> 2   (unchanged)
chia-bls           5                 -> 5   (unchanged)
dig-constants      3                 -> 3   (unchanged)

The multi-line families (chia-bls, chia-protocol, dig-constants) are pre-existing and this PR does
not widen any of them. The uplift in fact narrows three: dig-identity 2 lines → 1, dig-message
2 → 1, chia-sdk-utils 3 → 2. Whole-lock package delta: zero added, zero removed. No new dependency
enters the graph, so there is no new code-provenance surface to audit beyond dig-gossip's own delta.

dig-gossip v0.30.0 → v0.32.0 delta, reviewed as peer-facing code (2 commits, 18 files; source files
service/gossip_handle.rs, service/peer_pool.rs, service/state.rs, util/ip_address.rs). Direction
of travel is tightening, not loosening:

  • New adopt_direct_inbound_handle entry point, with self-connection refusal, ban check, pool cap,
    a refusal of TraversalKind::Relayed on the direct path, a no-demote rule against dialable slots, and
    a per-source-group bound (IPv4 /16 / IPv6 /48) against the free-identity eclipse. dig-node does
    not call it
    at this head, so it is present but not reachable from this binary.
  • max_relayed_inbound is re-derived from the shared inbound budget rather than from max_connections,
    which at the default max_connections = 8 moves the relayed cap 6 → 5 and adds a new aggregate
    inbound cap of 6. Strictly more restrictive on the one path dig-node does exercise. No loosening found.

Nothing here is gating.

Still open and in progress: the operator_puzzle_hash capability boundary (I have a real observation
forming there), money-figure honesty under adversarial reads, untrusted input into the sums, and the
split_by_provenance widening.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict (3/n) — PRIMARY FINDING

Head audited: 01bc66a40f1dd1cda31d2d1b998f5506942ed720.

Finding A — dig_balance_base_units returns Some(stale) where its own doc promises None

crates/dig-wallet/src/sage/rpc.rs:1232-1241, doc claim at :1218-1219.

The function's entire reason to exist is the None-is-not-zero rule. It does not carry that rule, and
its doc-comment asserts that it does.

The claim (rpc.rs:1218):

None when the balance could not be read — an unreachable chain source, an unsynced replica that
cannot answer for this address
, a figure too large for a u64.

The code (rpc.rs:1234-1240) keeps only read.balance and discards read.source, read.synced
and read.peak_height. All three are pub on WalletBalanceResult (rpc.rs:399-419), so they were
available and were dropped.

Why "an unsynced replica" does not in fact produce an Err. balance_for_address
(rpc.rs:1360) routes on two INDEPENDENT questions:

  • replica_is_authoritative() (rpc.rs:908-931) = initial_sync_complete && covered.covers(followed_set).
    This decides eligibility to answer. It says nothing about how far behind the replica is now.
  • replica_answer_is_current(peak_height) (rpc.rs:1072-1080) = the replica peak vs the chia PEER
    peak. This becomes the synced field — it is not an error.

So on the Source::Db arm the function returns Ok(WalletBalanceResult { balance, synced: false, .. }).
Err(BalanceError::NotSynced) is reachable only from the Source::Fallback arm when no live
fallback is attached (rpc.rs:1445-1451). The in-scope-but-behind case — the ordinary one — is an Ok.

Concrete failure scenario, no attacker required.

  1. Node completes its initial catch-up over its followed set, which includes the operator's own address
    (it must, for deposits to be seen). replica_is_authoritative() == true, scoped == true.
  2. Operator funds the wallet.
  3. Chain sync falls behind — chia peers drop, the host sleeps, the supervisor stalls. Note
    replica_answer_is_current returns false when the peer peak is merely unobservable
    (rpc.rs:1076-1078), so this is a common state, not an exotic one.
  4. balance_for_addressSource::Db, Ok(balance: <pre-funding, e.g. 0>, synced: false).
  5. dig_balance_base_unitsSome(0).
  6. pass::decide (pass.rs:213) → split_by_funds(create, 0, per_coin)affordable_count == 0
    → every uncovered bond lands in split.shortBondState::Unfunded.
  7. control.mirror.bondStates raises the out-of-funds alarm about a fully funded wallet — the
    dig-app#300 conflation, manufactured by the surface built to remove it.

The naive fix is wrong, and this is the part worth writing down. !read.synced cannot be the
discriminator: the Source::Fallback arm hard-codes synced: false on every answer
(rpc.rs:1478-1482), including a perfectly good live chain read. Returning None on !synced would
report UNKNOWN for every fallback-served balance. synced conflates three states — replica genuinely
behind, peer peak unobservable, and fallback-answered — so it cannot express "this figure may be
stale" on its own. The discrimination needs source as well, roughly:

Source::Fallback              -> Some(balance)   // a live chain read
Source::Db if read.synced     -> Some(balance)
Source::Db if !read.synced    -> None            // stale replica; UNKNOWN, not zero

Severity and gating call. The behaviour is not live: this function has zero production callers
and mirror_bond_observation() (control.rs:3530) still returns Err(ChainUnreadable) unconditionally,
so no surface lies today. I am therefore not asking for the staleness plumbing in this PR.

What I do gate on is narrow and cheap: rpc.rs:1218-1219 is a false claim on a money path, in the
very commit that writes it.
Step 8 will call this function precisely because that sentence says the
None/Some split already carries the unsynced case, and will not add the check. Correct the sentence
to say what the function actually does, and state the staleness discrimination as the caller's
obligation — the same way held_mirrors correctly delegates MirrorInventory::is_complete.

Continuing: the operator_puzzle_hash capability boundary and step-8 cost asymmetry.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict (4/n) — remaining findings + what cleared

Head audited: 01bc66a40f1dd1cda31d2d1b998f5506942ed720.

Finding B — TICKET (step-8 precondition): held_mirrors drops the completeness signal, and truncation is attacker-driven

crates/dig-node-service/src/mirror/observe.rs:139-155.

held_mirrors maps inventory.coins() and its doc correctly delegates MirrorInventory::skipped /
is_complete to the caller. There is no caller. The delegation is sound design; the risk is that step 8
does not honour it.

Why it matters, from the upstream crate at dig-mirror-coin-0.7.0/src/query.rs:250-257: both queries walk
lists that anyone may add to for the price of a dust coin, so the bound is a stop rather than a refusal,
and a query that reaches it returns what it found and says plainly that it stopped early.

MAX_CANDIDATES = 10_000, and list takes only the first MAX_CANDIDATES of
coin_records_by_puzzle_hash(mirror_coin_puzzle_hash()) — a puzzle hash shared by every mirror coin in
existence
. A stranger places more than 10,000 dust coins there; the victim own coins beyond the cut are
never examined; held_mirrors returns a short list; locked_dig_base_units (observe.rs:97-101)
under-reports, which is the one direction observe.rs:33-38 says a money figure must never be wrong in.

The published contract already makes honouring this mandatory
(dig-node-control-interface-0.27.0/src/method.rs:594): a node that cannot enumerate its bonds answers
unknown for the WHOLE call WITH the reason, with no per-row unknown and no empty-list fallback, because
a truncated list and a complete one read the same
.

So step 8 MUST fail closed on a false inventory.is_complete() — which is skipped.is_empty() && !truncated (query.rs:212) — and answer unknown. Not gating here, since nothing is wired, but it is a
hard contract obligation rather than a nicety.

Finding C — TICKET: cost asymmetry that step 8 would create on a token-gated read

Once mirror_bond_observation (control.rs:3530) is wired, one ~200-byte control.mirror.bondStates
request would trigger: a seed unseal plus BIP-39 PBKDF2 plus BLS derivation (operator_puzzle_hash), a
chain scan of up to 10,000 candidates each needing a ChainSource lookup of its creating spend, and a
wallet balance read that may reach the coinset oracle.

The ingress limiter does not cover it: control_ingress_admits guards only is_open_control_read
methods (server.rs:1141), and bondStates is on the token-gated branch (server.rs:1152 onward), which
has no rate bound. The method is reachable by a paired token, not only the master token — verified at
dig-node-control-interface-0.27.0/src/method.rs:437-443, where the master tier is pairing-admin plus
ChiaPeersAdd / ChiaPeersRemove only. A compromised or hostile paired controller could poll it in a loop
and amplify onto the third-party chain oracle, the egress class WALLET_RATE_LIMITED exists to bound
elsewhere. Bound it before wiring, and cache the observation.

Finding D — TICKET (defense-in-depth): operator_puzzle_hash is a second route that unseals the mnemonic

crates/dig-wallet/src/operator_wallet.rs:130-137.

The claimed boundary verifies: the return type is Bytes32, no WalletSigner is constructed anywhere
on the path, and OperatorWallet::open remains the only way to obtain one.

Two precise qualifications, neither a doc-honesty defect — the doc claims only that the key is dropped at
the end of the function and never leaves it, which is true, and it never claims zeroization:

  • The callers of autoseed::open_operator_phrase go from 1 to 2 (base 60bc1275 to head), and the new
    one is intended for a control-plane read path. The boundary is "no signer escapes", not "no key material
    is touched": digstore_chain::keys::derive_wallet_keys returns a struct containing synthetic_sk, which
    is materialised and then dropped.
  • chia_bls::SecretKey has no Drop impl and no zeroization — checked in the chia-bls 0.26.0, 0.36.1
    and 0.42.1 sources — so that key is left in freed memory. Identical to the pre-existing
    OperatorWallet::from_phrase, so this PR does not regress it; it adds a second occurrence, on a path that
    step 8 may call per request.

Cheaper and safer shape for step 8: the operator puzzle hash is a public, stable value. Cache it once at
bring-up so a read surface never unseals the mnemonic at all.

Finding E — TICKET (hygiene, non-sensitive): .step7-wip committed at the repo root

Already noted in my first comment. Content is a one-line WIP marker, no credential or host. A squash-merge
lands it on main.


What I checked and found CLEAR

  • Untrusted input, panics, overflow. The workspace sets overflow-checks = true in release
    (Cargo.toml:38), so an overflow would PANIC on a read path rather than wrap — and every arithmetic op on
    the observe path is checked or saturating. locked_dig_base_units uses fold(0, u64::saturating_add)
    (observe.rs:97-101). apply_safety_margin widens to u128, uses saturating_mul and saturating_add,
    then try_from(..).unwrap_or(u64::MAX) (dig-mirror-collateral-0.3.0/src/margin.rs:28-34).
    split_by_funds uses checked_div and .min(create.len()) before split_at, so the split cannot
    panic, plus saturating_mul for the shortfall (plan.rs:234-243). No indexing, no slicing, no unwrap
    on adversarial input, no allocation unbounded beyond input size. No panic path found.
  • Epoch handling. epoch_as_i64 (observe.rs:161-163) drops rather than clamps an out-of-range
    BigInt, so a stranger who places a dust coin declaring an arbitrary epoch cannot make it claim the
    current one. Asserted from both sides at both bounds (observe.rs:317-322).
  • The coin-inflation direction is closed upstream. A stranger cannot make this node count somebody else
    coin as its own locked $DIG: dig_mirror_coin::list keys ownership on the lineage proof
    parent_inner_puzzle_hash, so ownership comes from executed on-chain code rather than from a hint
    (query.rs:261-264), and Err(MirrorError::NotDigCollateral) is discarded, so a non-$DIG coin cannot be
    summed as $DIG.
  • split_by_provenance widening (runner.rs:339) is safe. pub(super) scopes it to crate::mirror;
    the parameter type is unchanged (&[ObservedCapsule]); the body is total — a for loop with an exhaustive
    two-arm match and a canonical() that strips an optional 0x prefix and lowercases, with no fixed-width
    or index assumption. No input class is reachable that the private version was protected from, and since
    observe itself has no production caller there is no new reachability at all.
  • Authorization tiering is correct. control.mirror.bondStates is absent from is_open_control_read
    (control.rs:148-159), so both transports gate it: HTTP at server.rs:1152 onward and WS at
    server.rs:1548-1560, each requiring the master token OR a paired token. The contract reaches the same
    conclusion for the same reason — token-gated although it is a read, because the caller supplies nothing and
    the answer is this node OWN bond set and funding position — consistent with control.wallet.arrivals being
    gated while control.wallet.balance is open. Correct call: a bond page discloses the node-to-address
    association, from which an observer could derive the operator wallet and then use the OPEN balance read
    against it.
  • locked_dig_base_units conforms to the contract: whole-set total including reclaiming coins, computed
    node-side, read from each coin own amount rather than from this epoch requirement.
  • Dependencies: see my previous comment — pin verified tag-to-tag, one dig-dht line, zero packages
    added or removed, three families de-duplicated.

Not covered — stated explicitly

  • No executed probe for Finding A. Reproducing Source::Db with synced == false needs a
    WalletBackend with a seeded replica and a chia-peer tier double; I judged the compile cost not worth it
    against control flow this direct, and cited file:line for every step instead. If you want it proven
    before step 8, the test is: authoritative and scoped replica, peer peak ahead of replica peak, assert
    dig_balance_base_units returns None. It will currently return Some.
  • I did not audit states::page cursor and paging (pre-existing, unchanged by this diff, already gated),
    nor re-run the correctness gate mutation probes.
  • I did not audit dig-gossip v0.32.0 new adopt_direct_inbound_handle in depth beyond confirming that
    dig-node does not call it and that the cap changes tighten rather than loosen.
  • Neither dig_balance_base_units nor operator_puzzle_hash ships with a test in this diff.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security VERDICT: CHANGES-REQUIRED (one narrow gating item)

Head audited: 01bc66a40f1dd1cda31d2d1b998f5506942ed720 — resolved from
gh pr view 419 --json headRefOid at the start AND re-confirmed unchanged at the end of this audit.
Read entirely from git objects (git show <sha>:<path>); no shared checkout was mutated, no worktree cut.

The gating item — ONE, and it is a one-sentence fix

crates/dig-wallet/src/sage/rpc.rs:1218-1219 states a money-path guarantee the function does not have.

The doc says None is returned for "an unsynced replica that cannot answer for this address". It is not.
balance_for_address returns Err(BalanceError::NotSynced) only from the Source::Fallback arm with no
live fallback (rpc.rs:1445-1451). The ordinary in-scope-but-behind case returns
Ok(WalletBalanceResult { balance, synced: false, .. }) from the Source::Db arm, because eligibility
(replica_is_authoritative, rpc.rs:908-931) and currency (replica_answer_is_current, rpc.rs:1072-1080)
are two independent questions and only the first can produce an error.

dig_balance_base_units (rpc.rs:1232-1241) keeps read.balance and discards read.source,
read.synced and read.peak_height — all three pub on WalletBalanceResult (rpc.rs:399-419). So it
returns Some(stale) where its own contract promises None, and Some(0) is the out-of-funds alarm:
pass::decidesplit_by_funds(create, 0, per_coin)affordable_count == 0 → every uncovered bond →
BondState::Unfunded. That is the dig-app#300 conflation, produced by the function written to remove it.

Why I gate on this despite nothing being reachable. The behaviour is latent — dig_balance_base_units
has zero production callers and mirror_bond_observation (control.rs:3530) still returns
Err(ChainUnreadable) unconditionally, so no surface lies today, and I am not asking for the staleness
plumbing in this PR. I gate because the sentence is a born-false claim on a money path, written in the same
commit that creates it, and step 8 will call this function because that sentence says the None/Some
split already carries the unsynced case. The gate is the cheapest possible remedy: make the doc say what the
code does, and name the staleness discrimination as the caller obligation — exactly as held_mirrors
correctly delegates MirrorInventory::is_complete.

Do not "fix" it with if !read.synced { None }. That is wrong and would ship a different lie: the
Source::Fallback arm hard-codes synced: false on every answer including a good live chain read
(rpc.rs:1478-1482), and replica_answer_is_current also returns false when the peer peak is merely
unobservable (rpc.rs:1076-1078). synced conflates three states, so the discrimination needs source too:
FallbackSome; Db and synced → Some; Db and not synced → None.

Not gating — ticket candidates, log them and move (details in my 4th comment)

# Finding file:line Why not gating
B held_mirrors delegates is_complete() to a caller that does not exist yet; truncation is attacker-driven for the price of dust coins at the shared mirror puzzle hash (MAX_CANDIDATES = 10_000) mirror/observe.rs:139-155 Delegation is correct design and the contract already mandates failing closed; nothing wired. Hard precondition on step 8.
C Cost asymmetry step 8 would create: ~200-byte request → seed unseal + PBKDF2 + up to 10,000 chain lookups + an oracle read, on a branch with no ingress limiter, reachable by a paired token server.rs:1141 vs :1152; tier at dig-node-control-interface-0.27.0/src/method.rs:437-443 Path is inert today; bound and cache before wiring
D operator_puzzle_hash is a second route that unseals the operator mnemonic (callers of open_operator_phrase 1 → 2); chia_bls::SecretKey has no Drop/zeroize operator_wallet.rs:130-137 Boundary claim itself verifies (no WalletSigner on the path); no regression vs from_phrase. Cache the public puzzle hash at bring-up
E .step7-wip committed at the repo root repo root Non-sensitive; squash lands it on main

Cleared

  • Reachability — nothing in this diff is reachable from the control plane. observe, held_mirrors,
    ObserveContext, operator_puzzle_hash, dig_balance_base_units have zero production callers.
  • AuthZ / disclosurecontrol.mirror.bondStates is absent from is_open_control_read
    (control.rs:148-159) and gated on both transports (server.rs:1152, server.rs:1548-1560).
    Correct tiering: the node names the address, so it belongs on the gated side beside
    control.wallet.arrivals, not the open side beside control.wallet.balance.
  • Capability boundary — verified at the type level: return type Bytes32, no WalletSigner
    constructed anywhere on the path, OperatorWallet::open still the only route to one. §908 untouched;
    this is the §16.4 machine wallet, no user seed involved.
  • Untrusted input — no panic, no overflow, no unbounded allocation, no slice/index on the observe
    path, and this matters because overflow-checks = true in release (Cargo.toml:38) would turn an
    overflow into a panic. Every op is saturating_* / checked_div / u128-widened, and split_at is
    bounded by .min(len) first.
  • Coin inflation is closed upstream — ownership from the lineage proof, not a hint, and non-$DIG
    collateral discarded. A stranger cannot make this node report their coin as its own locked $DIG.
  • split_by_provenance wideningpub(super) reaches only crate::mirror, same parameter type,
    total body, no new input class, and no new reachability.
  • Dependencies — the dig-gossip pin moves tag-to-tag (48a14ed2 = v0.30.0 → 1a339166 = v0.32.0,
    tip of main, dig-release-bot); dig-dht resolves to exactly one line (0.13.0) and nothing shims
    across two; zero packages added or removed workspace-wide; three families de-duplicated
    (dig-identity 2→1, dig-message 2→1, chia-sdk-utils 3→2); the dig-gossip source delta tightens
    inbound caps rather than loosening them.

Not covered

No executed probe for the gating finding (control flow read directly, every step cited); states::page
paging not re-audited (pre-existing, unchanged); dig-gossip v0.32.0's new adopt_direct_inbound_handle not
audited in depth beyond confirming dig-node does not call it. Neither new wallet function ships with a test.

To clear this gate

Correct rpc.rs:1218-1219 so it describes actual behaviour and names the caller obligation. That is a
docs-only change to a function with no callers, so re-gate scope is this leg only — it needs no
correctness re-run and no adversarial leg (§1.10). Findings B–E are ticket candidates, explicitly
not merge blockers.

MichaelTaylor3d and others added 3 commits August 30, 2026 06:47
The doc promised `None` for "an unsynced replica that cannot answer for
this address". `balance_for_address` returns `Err(NotSynced)` only from
the `Source::Fallback` arm with no live fallback; the ordinary
in-scope-but-behind case returns `Ok(WalletBalanceResult { synced:
false, .. })`, so this returns `Some` of a possibly-stale figure.

Authority for an address and currency with the chain are independent
questions and only the first can fail the read, so staleness was never
covered by `None`. Step 8 will call this on a funding decision because
this sentence said the case was handled; correcting the claim now costs
nothing and removes a born-false guarantee from a money path.

Notes the residue rather than silencing it: discriminating on `synced`
alone would report UNKNOWN for every fallback-served answer, since that
arm hard-codes it false.

Also drops the tracked `.step7-wip` scratch marker, which a squash would
otherwise land on main.

Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Superseded by #420 for the merge — this branch stays with the live lane

What happened, recorded because the diagnosis was wrong in a specific way. This lane read as dead:
its session ended, and the orchestrator marked it complete off a green check set. It had actually
stalled on the 600s silence watchdog and then resumed — a stall is not a death. While the gate
round was returning it pushed 63c83d5, 457 new lines of mirror/lifecycle.rs for the step-7
wiring work, with three more files dirty in C:/tmp/worktrees/dn-412-s7.

A squash-merge of this PR would have carried that ungated code into main under a gate verdict that
never saw it.

Resolution: nothing here is reverted, rewritten, or force-pushed. The reviewed content now ships
from #420, whose branch is 66f637c8 — exactly the tree both gates audited (c649ce9) plus
main's own chore(release): v0.172.0. This branch and its worktree remain the wiring lane's; the
merged commits will simply be ancestors of its next diff.

The orchestrator's own error, stated plainly: it edited and pushed into a live lane's worktree.
That is the single-writer rule, and it held only by luck — c649ce9 happened to land cleanly. The
lane's agent id came from a dead session, so it could not be messaged to coordinate.

Gate verdicts (they carry over to #420 unchanged)

  • loop-reviewer PASS, on executed mutation probes: removing dig-download still turns the
    anchor guard red at dependency_tree.rs:68; a manifest rewritten with 591 genuine CRLF pairs now
    yields 4 passed and no panic; truncating the locked-total fold and inserting .or(Some(0)) on the
    balance each make their own test fail with exactly the lie it guards.
  • loop-security CHANGES-REQUIRED on one gating item, cleared at c649ce9 — a born-false money
    claim in the dig_balance_base_units doc. Three non-gating findings are logged as step-8
    preconditions on Mirror-coin lifecycle: the pass runner, the state surface, the DHT pointer, and the real-machine proof #412 (comment).

Leaving this PR open and DRAFT for the lane to repurpose or close as it prefers.

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gate — dig-node PR #419 (step 7), correctness + security review

Verdict: CHANGES-REQUIRED.
Head read: ab6b25a128861171d16b28ccaab352d3221495c2 (resolved from the remote; matches the brief).

Two gating findings, both about the WIRED pass rather than the observation logic. Inline threads carry the detail and the required property of each fix.

What I verified and found CORRECT (no action)

  • The signer never reaches the served backend — the claim holds. MirrorSigner is built only in spawn_mirror_passes via lifecycle::open_signer and borrowed into NodeMirrorEffects; with_signer(/with_broadcaster( appear nowhere in the tree outside their definitions in dig-wallet/src/sage/rpc.rs:710,752 and that file's own tests. Default-on auto-tipping is not enabled as a side effect. The concat! fix is real and the guard still discriminates — see the guard thread for the one coverage gap and the tautological half of its sibling test.
  • The puzzle-hash derivation. operator_puzzle_hash constructs no WalletSigner on any path — it derives owner_puzzle_hash from the phrase and drops the secret. Derived once in the scheduler task, held as the public value it is. Step 6's refusal survived.
  • No request path unseals anything. mirror_bond_observation(ctx) is a lock read over the published BondSnapshot; the chain work is on the round timer. .ok() correctly collapses a poisoned lock and a not-yet-observed slot to the same absence of an observation.
  • The four invariants. sign(&MirrorSpends, &SpendJournal) is unrelaxed and is the only route to a RecordedSpend. is_complete() gates the pass and an incomplete scan returns PassError::Chain rather than a short answer. split_by_provenance stays the single owner of the Relayed exclusion. The create refusal is by name (PassError::Wallet, naming #421) and attempts no spend — §25.8 keeps reporting the bond uncovered, which is true.
  • The wire-shape test asserts the CONTRACT. entries[n]["bond_state"] and entries[1]["reason"] are asserted at the flattened spelling dig-app#289/#300 parse, on two rows with different states, against a locked_dig_base_units no sum over the rows produces. It would not have gone green on a nested envelope.
  • closingIssuesReferences is [] — asked the API, not the prose. #412 still owns step 8.
  • Measured zero vs absent zero holds on the empty-wallet path: dig_balance_base_units returns None for an unreadable read and that becomes Err -> deferred{balance_unreadable}, distinct from Some(0) -> unfunded. No fabricated zero anywhere on the path.

Gating

  1. server.rs:2774broadcaster: None unconditionally. Reclaims cannot broadcast in production, while SpendCapability::Available and its log line claim they can, and the refusal blames an env var the operator already set. The "reclaims are fully implemented" claim is true of the builder and false of the wiring.
  2. The presence carry is untested. Its failure mode is a silent stall that no other signal shows; dropping the carry keeps the suite green.

Non-gating (posted and resolved by me; they do not block merge)

  1. The install guard's file list omits the file that constructs the served backend; its sibling "can actually fail" test is tautological in its needle half.
  2. collateral_buffer counts Withheld rows into a lock calculation — latent behind balance_unknown, conservative in direction.

Merge preconditions (not a verdict — restated for the orchestrator)

Analyze (rust), Test + coverage and build .pkg (macos-universal) were still running at read time. Base is 6e8bfa74; a 0.172.0 release is in flight, so re-check the base and re-run check-merge-preconditions.sh (unpiped, --allow-draft while it stays draft) before any merge. I have not edited, merged or undrafted anything.

Comment thread crates/dig-node-service/src/server.rs Outdated
Comment thread crates/dig-node-service/src/mirror/runner.rs
Comment thread crates/dig-node-service/src/mirror/lifecycle.rs Outdated
Comment thread crates/dig-node-service/src/control.rs Outdated
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Addressed at 9a5d392the_presence_tracker_carries_between_runners_and_a_fresh_one_suppresses,
in runner.rs at the PassRunner level, as specified.

It is the discriminating PAIR you asked for, plus a control:

  1. Pass 1 at FIRST_SEEN_MS — capsule just appeared, nothing settles, nothing created. The
    control: it shows the window is doing something before the carry is tested at all.
  2. Pass 2 at FIRST_SEEN_MS + SETTLING_WINDOW_MS, CARRYING the tracker — the window began at
    FIRST_SEEN_MS and has elapsed, so the bond settles and is created.
  3. The SAME pass, at the SAME instant, with a FRESH tracker — the window restarts now, so nothing
    is created. The regression itself, reproduced.

Step 3 is what makes step 2 mean anything: the carried half alone is satisfied by an implementation
with no debounce at all, which would create on pass 1 too. The fixture is built so the ONLY reason
not to create is the debounce — wallet funded at REQUIRED * 10, requirement known, creates on,
nothing on chain, and a fresh empty SpendLog per runner so §25.4.6's in-flight suppression cannot be
the thing doing the work.

Proved load-bearing by reverting only the fix

Committed first (git checkout is destructive, and there is uncommitted work in this worktree from
another writer), then took a file copy, neutered with_presence to a no-op, and re-ran:

EXIT=101
assertion `left == right` failed: the carried tracker remembers when the capsule appeared,
so one window later it settles and is bonded: []
  left: []
  right: [Bond { store_id: "aa00…", root: "1100…" }]
test result: FAILED. 0 passed; 1 failed

It failed on the carried assertion with an empty created set — exactly the silent stall, and for
the right reason rather than a compile error or a panic elsewhere. Restored from the copy afterwards;
runner.rs is clean against its commit.

with_presence's doc already said the failure is invisible on every other signal. It now has the one
signal that sees it.

…r the pass actually gets

The sole production `NodeMirrorEffects::new` passed `broadcaster: None` unconditionally, so
`sign_and_broadcast` short-circuited and no reclaim could reach chain -- while `open_signer`
reported `SpendCapability::Available` and bring-up logged "this node may create and reclaim
collateral". Two answers to one question, with only one of them on the path the money takes.

The refusal compounded it: it named `DIG_WALLET_ENABLE_LIVE_BROADCAST`, the flag an operator must
already have set to reach `Available` at all, so a person who set the flag was told to set the flag.

The broadcaster is not wired here rather than wired live: `ChiaQueryBroadcaster` needs the
`Arc<ChiaQuery>` held behind `ChainTransport::shared_client`, which is `pub(crate)` to dig-wallet,
so reaching it is a dig-wallet API decision and a live-mainnet behaviour change -- both outside a
gate fix on an observation step. So the reporting is made honest instead, and the wiring has a
ticket (dig-node#424).

- `production_broadcaster()` is now the ONE seam. `open_signer` derives the capability from it and
  the scheduler passes the same call into the effects, so the two cannot disagree.
- New `SpendCapability::BroadcasterUnwired`, distinct from `BroadcastDisabled`: one is a switch the
  operator can flip and the other is not.
- The refusal names the missing wiring and its ticket, the way `create` names dig-node#421.
- `spend_capability` is separated so BOTH branches are testable; the wired branch is unreachable
  through `open_signer` on this build, and an untestable branch is how the wrong answer shipped.

Also, non-gating, from the same review:

- The signer-installation guard now scans `control.rs` and `wallet_mtls.rs`, which hold the same
  served `Arc<WalletBackend>`. dig-wallet's own construction site stays out of scope with the reason
  stated: an `include_str!` outside the package would leave the crate unpackageable.
- `the_installation_guard_can_actually_fail` no longer interpolates the needle into its own fixture
  and then asserts the fixture contains it, which held for every needle including a wrong one. It
  matches each needle against a sample line assembled independently, and asserts a near-miss
  spelling matches nothing.
- `collateral_buffer` counts BONDABLE rows rather than every served row. A `Withheld` row is relayed
  on a stranger's behalf and locks nothing, and the count feeds `one_epoch_lock` -- an amount of $DIG
  the operator must have available to LOCK. Latent today (the balance is still `None`), a wrong money
  figure the moment it is wired.

Refs #412, #424
… fail

The first version asserted `spend_capability(production_broadcaster().is_some())` against
`production_broadcaster().is_some()` -- a property of the helper, not of the caller. Reverting the
fix by hard-coding `Available` back into `open_signer` left it GREEN, because the helper it
asserted over was untouched. It pinned the outcome where the fix is a PLACEMENT.

The replacement mints a real operator wallet into a temp layout and calls `open_signer` with live
broadcast ON, which is the only path a production node takes. Hard-code `Available` there and it
goes red on the reported capability. The fixture asserts the wallet actually OPENED first, since the
`WalletUnavailable` path would otherwise satisfy every remaining assertion without reaching the
decision under test -- the same vacuity, one door along.

The both-branches assertion stays as its own test: the wired branch is unreachable on this build,
and a branch no fixture can take reads as covered while never having run.

Refs #412, #424
A concurrent commit on this branch (9a5d392) swept in TWO tests of the same property -- its own
`the_presence_tracker_carries_between_runners_and_a_fresh_one_suppresses` and an in-progress
duplicate of mine. Two tests of one property test the module twice and the rest of it not at all, so
the duplicate is removed. The survivor is the better fixture: it gives each runner its OWN empty
SpendLog, so §25.4.6's in-flight suppression cannot be what makes a create disappear.

The survivor also failed `clippy -D warnings` on `held(&[capsule.clone()])`, which would have
blocked the required lint check. It borrows now.

Refs #412
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

IN PROGRESS — not the verdict. Combined re-gate of the two-author delta on this branch. Head read: e22e8aee2f507306ff23ec9048aae9c3384a93a2.

Finding 1 (GATING, mechanical) — Clippy is RED at this head

check-merge-preconditions.sh --repo DIG-Network/dig-node --pr 419 --allow-draft (run unpiped) → EXIT 1, BLOCKED:

Lint commit messages               SUCCESS
Check version increment            SUCCESS
Rustfmt                            SUCCESS
Clippy                             NOT GREEN - COMPLETED/FAILURE
Test + coverage                    NOT GREEN - IN_PROGRESS/

Clippy job https://github.com/DIG-Network/dig-node/actions/runs/33318712286/job/99276817576 fails with two instances of -D clippy::cloned-ref-to-slice-refs:

  • crates/dig-node-service/src/mirror/runner.rs:616disk: held(&[capsule.clone()])
  • crates/dig-node-service/src/mirror/runner.rs:1158disk: held(&[settling.clone()])

Suggested fix in both cases: std::slice::from_ref(&capsule) / std::slice::from_ref(&settling).

Note the second site is inside the newly added presence-carry test, so this is introduced by this round's fix, not pre-existing drift. It is a merge blocker regardless of the correctness verdict below, since a red required check bars merge on its own (SS2.4a).

Correctness review of the combined delta continues; further findings posted as they resolve.

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CHANGES-REQUIRED

Head SHA read: e22e8aee2f507306ff23ec9048aae9c3384a93a2 (resolved from gh pr view 419 --json headRefOid; unchanged from the dispatch quote, and unchanged throughout this review).

Combined re-gate of the two-author delta as one diff. Two GATING findings, three non-gating.

Required checks, asserted BY NAME

check-merge-preconditions.sh --repo DIG-Network/dig-node --pr 419 --allow-draft, run unpiped, exit 1:

context state
Lint commit messages SUCCESS
Check version increment SUCCESS
Rustfmt SUCCESS
Clippy COMPLETED/FAILURE
Test + coverage IN_PROGRESS

unresolvedReviewThreads=0 before this review; draft=true. No required context is absent.

The branch is 0 commits behind main (compare/main...e22e8aee -> status: ahead, behind_by: 0), so despite v0.172.0 landing, no rebase is owed.

Findings

# severity location finding
1 GATING runner.rs:616, runner.rs:1158 Clippy -D warnings fails on cloned-ref-to-slice-refs; introduced by this round
2 GATING control.rs:3464 bondable_pairs - money-figure predicate with zero tests; reverting the fix keeps the suite green
3 non-gating lifecycle.rs:561-574 doc-comment describes a test shape e22e8aee deleted
4 non-gating lifecycle.rs:~700 zip truncates silently if the needle list grows
5 non-gating runner.rs:601 / :1141 two near-duplicate tests of one property

What I verified as GOOD - do not re-derive

The broadcaster seam (dispatch finding 1) is genuinely fixed, and the honesty holds end to end.

production_broadcaster() is ONE seam returning Option<&'static dyn Broadcaster>, read by both the reported capability (open_signer -> spend_capability(production_broadcaster().is_some())) and the effects the scheduler builds (server.rs:2782, replacing the unconditional None). Disagreement between what the node announces and what a spend can reach is now inexpressible rather than merely fixed. Available is unreachable while the seam is None, so the enum no longer lies at any use site - which was the right lesson to draw.

The refusal text and the BroadcasterUnwired log line both name the missing wiring and dig-node#424, and deliberately do not name DIG_WALLET_ENABLE_LIVE_BROADCAST - correct, since reaching that arm requires the flag to be set already. The variant's own doc states that distinction explicitly.

The capability test is revert-proof, and the last commit is what made it so. As of 8f93bd63 the test never called open_signer at all, so its own claim - "hard-code Available back into open_signer and this fails" - was false in the commit that wrote it. e22e8aee fixes exactly that: an_opened_wallet_with_broadcast_enabled_still_may_not_spend_with_no_broadcaster_wired mints a real wallet via ensure_wallet_seed_at, asserts BootstrapState is Created | Opened so the test cannot pass down the WalletUnavailable path, then asserts open_signer's answer equals the seam's. Hard-coding Available back in now fails on that equality. The vacuity guard is the part that matters and it is present.

The presence fixture design (dispatch finding 2) is correct, including the specific risk flagged. I checked the SS25.4.6 interaction directly rather than taking it on the comment. In the first test the log is fresh per runner (log("first"), log("carried"), log("restarted")). The second test shares one log.clone() across all three rounds, which would be the flagged false green - except that a planned create writes no audit record: records are planted explicitly via journal.begin(...) (see an_in_flight_create_is_suppressed_across_a_restart), the runner only ever reads the ledger through in_flight_creates, and the new test binds its journal to _journal and never uses it. So the shared log stays empty and suppression cannot fire. Sound, but sound by accident rather than by construction - which is part of why I raise finding 5.

The window is real in both: PassRunner::new defaults settling_window_ms to SETTLING_WINDOW_MS (runner.rs:202), not to the 0 the shared runner() helper uses. A zero-window fixture is what would have made the carry unobservable, and neither test has one.

Both prior non-gating items were addressed, one consciously.

  • The signer guard's file list now includes ../control.rs and ../wallet_mtls.rs via guarded_sources(). dig-wallet's sage/service.rs is not scanned, and the doc says so plainly with its reason - an include_str! reaching outside the package would leave the crate unpackageable, which is a worse defect than the one it guards, and the property holds there today. Stated rather than implied, with the right home named. Accepted as consciously left.
  • The tautological needle assertion is genuinely repaired: sample_installations() is built independently of the needles with concat! (so the file does not trip its own guard), and a near_miss negative check proves a subtly-wrong needle is visible as a failure. This is the fix, not a restatement of the problem.

The seam between the two authors is clean. File sets are disjoint apart from control.rs, which both ab6b25a1 (wire spelling) and 8f93bd63 (bondable_pairs) touch in different functions with no overlapping hunks. I found no interaction defect between the two lanes' work - the risk was real but did not materialise.

To clear this gate

  1. Fix both clippy sites (finding 1) - mechanical.
  2. Add the direct bondable_pairs test (finding 2) - the discriminating fixture already exists in the file.
  3. Findings 3-5 at the lane's discretion; I will resolve them as non-blocking on reply.

Then let Test + coverage reach a terminal state and re-request. Note this PR is the base for #423, so both gating findings propagate there.

Comment thread crates/dig-node-service/src/mirror/runner.rs Outdated
Comment thread crates/dig-node-service/src/mirror/runner.rs Outdated
Comment thread crates/dig-node-service/src/control.rs
Comment thread crates/dig-node-service/src/mirror/lifecycle.rs
Comment thread crates/dig-node-service/src/mirror/lifecycle.rs
Comment thread crates/dig-node-service/src/mirror/runner.rs
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Verdict posted: CHANGES-REQUIRED at head e22e8aee2f507306ff23ec9048aae9c3384a93a2 (review #419 (review)).

Thread state, so the next reader does not have to derive it:

  • OPEN (blocking): findings 1 and 2 - the two clippy sites (runner.rs:616, :1158) and the untested bondable_pairs (control.rs:3464).
  • RESOLVED by me as non-blocking: findings 3, 4, 5. They are real and worth doing, but none should bar a merge, and an unresolved non-gating thread bars one exactly as a real blocker does under required_conversation_resolution.
  • All six threads from the previous round are already resolved.

Both gating findings are cheap. The substantive work in this round - the production_broadcaster seam and the presence-carry proof - I verified as genuinely correct and revert-proof; details in the review body, and worth reading before re-doing that analysis.

…e prices

`bondable_pairs` shipped untested, which was conspicuous given its own doc says it was named and
separated so the distinction would be testable without a state directory. It was made testable and
then not tested: delete the filter and the whole suite stayed green.

The fixture varies the STATE across two rows rather than the row count, because `len()` and the
filtered count agree on every set that is entirely bondable or entirely withheld -- a single-row
fixture distinguishes nothing. It asserts `bondable_pairs` directly rather than routing through
`collateral_buffer`, which returns early on the absent balance and would pass without the count
ever being read.

Refs #412
…riminates more

Two lanes fixed the same gating finding concurrently and `360366b` swallowed both tests, so the
branch carried two tests of one property -- which tests `bondable_pairs` twice and everything else
not at all. The same duplication `2401100` removed from the runner tests, one commit earlier.

The survivor is the fixture that separates more wrong implementations. Both are honest about the
defect that shipped, but a two-row fixture of `Withheld` then `FundsUnknown` answers 1 under the
contract AND under "count the last row" AND under "count only the unfunded row" -- it pins the
answer without pinning the predicate. Four rows carrying three DIFFERENT bondable states answer 3
under the contract and 1 under both of those, so only the true predicate passes.

The removed test's reasoning is preserved: assert `bondable_pairs` directly rather than through
`collateral_buffer`, which returns early on the absent balance and would go green without the
count ever being read.

Refs #412
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant