fix(mirror): resolve landed spends, feed the DHT collateral pointer, bound funding inputs - #457
fix(mirror): resolve landed spends, feed the DHT collateral pointer, bound funding inputs#457MichaelTaylor3d wants to merge 5 commits into
Conversation
…and bondable contract
…gh the sweep that masks it
Evidence
No mainnet spend was made by this lane. Commits are left unsquashed for the gate to read; they squash to one conventional commit before ready. |
a72b06d to
4370990
Compare
loop-security — IN PROGRESS, not the verdictAuditing head Three of the six priority checks resolve CLEAN so far. Recording them now so they survive a kill. 1. The resolver does not invent a confirmation — CLEAR
2.
|
loop-security — IN PROGRESS (2/3), not the verdictHead 4. #435 — the production
|
DRAFT — do not merge. The gate round has not returned. This is custody code that spends real $DIG and it takes the full triple gate.
Closes #412 (pieces 1 and 3) · Closes #435 · Closes #429 · Closes #427
Epic: https://github.com/DIG-Network/dig_ecosystem/issues/3166
1. #412 piece 1 — resolve landed mirror spends
journal.confirmedhad zero production call sites (all 8 were#[cfg(test)]), andsubmitted()does not setsettled, so every successfully broadcast mirror spend endedUnresolvedon drop.dign spendsshowed a node whose money had demonstrably moved as a node that did not know what it had done.The entry point, and why it is inside
spend_audit.SpendJournal::resolve_landed(id, coin_id, height) -> Resolution(spend_audit.rs).RecordedSpendhas no public constructor,SpendLog::appendis module-private, and aRecordedSpendis dropped at pass end — so no later pass can resolve an earlier pass's record, and a resolver outside the module could not write the file at all. Making the write path public to let it would have been a second producer ofConfirmed, the one status the module's honesty rules are built around.Erris kept distinct fromOk(None)at three layers, deliberately:ChainSource::coin_recordResult<Option<CoinRecord>, E>MirrorEffects::coin_confirmationResult<Option<u32>, PassError>—Err= could not ask;Ok(None)= absent or present with noconfirmed_heightyetmirror::resolveErr→ resolve nothing, countchain_unreadable, warn.Ok(None)→ resolve nothing, silently.Only
Ok(Some(height))reachesresolve_landed.lifecycle.rs's implementation propagates the source error rather than mapping it, and a malformed local coin id is deliberately NOT anErr— it is this node's bookkeeping being wrong, not the chain being down, and reporting it as an outage would hide a permanent defect behind a retry forever.Keys, per the measurement:
intended_coin_id = Some(reclaimed_coin_id(coin))already exists (lifecycle.rs:398). Onecoin_recordread.intended_coin_idisNoneby design, so the key is the coin's appearance in the pass's ownobserve_chain, matched on all three of(store, root, epoch), plus onecoin_recordfor the height. No coin id is ever derived or invented.Disappearance is not used as a key, for the three reasons the measurement gives: nothing to pass to
confirmed(); the mirror puzzle hash is shared, so a coin leaving the set proves someone spent it; and a short scan looks identical to a spend.Ambiguity resolves nothing. Two open records can name one coin (two reclaim attempts of one coin derive the same child id; two open creates for one bond are reachable precisely because §25.4.6 does not suppress on
Unresolved). At most one of those bundles landed and this node cannot tell which, so neither is resolved and both stayunresolved.Did I change the suppression set? NO.
runner.rs'smatches!(… Pending | Submitted)is untouched, andUnresolvedis still not suppressed. A resolver only fires when the coin appears; a bundle that never lands leaves its recordUnresolvedforever, so addingUnresolvedto the suppression would still be a suppression that never lifts and would still leave the bond permanently uncollateralised.runner.rs:404-414's asymmetry is unchanged and still correct. Resolution runs beforein_flight_creates, so a create this sweep confirms stops suppressing itself in the same pass rather than one pass later — which is the whole gain, without touching the rule.A resolver outage therefore causes no harm: it resolves nothing, suppresses nothing extra, and the next pass retries.
2. #435 / #412 piece 3 — something now supplies
MirrorCoinPointersThe only
implwas a test double atdht.rs:1734; production calledDhtHandle::new, hard-codingNone, so every live announce publishedunverified_mirror_coin_id = Noneandreannounce_on_epoch_rolloverreturned0on its first line every tick.mirror::pointers::SnapshotMirrorPointersreads theBondSnapshotthe last pass published — the same observationcontrol.mirror.bondStatesserves — rather than doing its owndig_mirror_coin::list. A second read would be a second answer, and an operator comparing the two would see a disagreement this node manufactured. (The measurement's "notPassReport.created" constraint is honoured: that isVec<Bond>with no coin id;BondState::Bondedcarriescoin_id.)Nodeslot (set_mirror_coin_pointers) set inserver.rsbeforespawn_peer_network, read atpeer.rs'sDhtHandle::with_mirror_pointers. A slot rather than an argument because the FFI/browser path constructs aNodewith no mirror lifecycle and no peer network.Bondedrow yields a pointer, and a whole-store announce carries none.dig-node-corenow re-exportsdig_dhtso the consumer does not declare a seconddig-dhtconstraint (§2.4b split-line risk on a trait's own type).3. #429 — a
Disabledbond IS bondableAnswered as the ticket's second permitted outcome, with the reasoning in the doc.
Withheldis a property of the capsule (a stranger's, never advertisable);Disabledis a reversible node-wide switch. Excluding it would drop the buffer advice to zero for the whole node the moment collateralisation is switched off — telling an operator who is about to re-enable that they need no $DIG, and stranding them short on the next pass. Under-stating money the operator must hold is the reassuring direction.Reclaimingis documented as counted for the same forward-looking reason.4. #427 — mirror funding inputs are bounded
MAX_SELECTED_FUNDING_COINS = 32, enforced after selection (free, in-memory) and beforeauthenticate(the per-inputcoin_spendchain read). The scan address is publicly derivable, so unbounded, the number of chain reads one automated pass performs is chosen by whoever paid dust to it.Direction: it fails CLOSED — the create is refused, the bond is uncollateralised for that pass, and a new
FundingError::TooManyInputs { needed, limit }says the wallet is not short, its $DIG is in too many pieces. Recoverable by retry and permanently by consolidation. Failing open is not recoverable by anything the operator can do. Bounding the candidate set instead was rejected: it would refuse a fundable create because a stranger sent dust that was never selected.Tests — what each catches, and each proven load-bearing
Eight mutations, each reverted individually against committed state; every one was killed by its own test.
a_landed_reclaim_is_confirmed_at_the_height_the_chain_reporteda_chain_that_cannot_answer_resolves_nothing_while_its_neighbour_still_resolvesErrintoOk(None); the control rules out a resolver that does nothing at all.or_else(|_| Ok(None))a_create_is_confirmed_only_against_a_coin_that_matches_store_root_and_epochstore_idalonetwo_open_spends_claiming_one_coin_resolve_neithera_spend_that_never_reached_the_network_is_not_confirmed_by_a_coin_that_matches_its_bondPendingrefusal removeda_current_epoch_bonded_capsule_publishes_its_coin_and_only_its_coinNonefor everything (= shipped behaviour); a third row rules out "return the only coin you have"a_coin_from_a_previous_epoch_publishes_no_pointerthe_whole_store_announce_carries_no_coin_because_no_coin_bonds_a_whole_storebefore_the_first_pass_there_are_no_pointers_and_the_epoch_cannot_collide_with_a_real_one0, a real epochbondable_pairs_counts_every_served_row_except_the_relayed_one_that_locks_nothingDisabled/ReclaimingDisabledadded to the exclusiona_node_with_collateralisation_switched_off_still_advises_for_the_pairs_it_will_bondDisabled→ advice of zeroa_create_needing_more_inputs_than_the_bound_is_refused_before_any_lineage_read>changed to>=One finding from the revert-proof itself, recorded because it nearly shipped a false green. The
Pendingrefusal existed in two places — the sweep's filter and the writer's guard — and the writer masks the sweep. Relaxing the sweep alone changed nothing observable and the test stayed green. The test now assertsSpendJournal::resolve_landeddirectly as well as through the sweep; the sweep's filter is kept deliberately (it stops aPendingrecord costing a chain read per pass to reach a refusal decidable for free) and is documented as redundant.Blast radius
gitnexus
impactwas not usable:list_reposreports the registereddig-nodeindex 301 commits behind and pointed at the primary checkout, and a stale index returns a false-safeimpactedCount: 0 / risk UNKNOWN(dig_ecosystem#3188). Per §2.0 bound (2), done by grep + direct read instead, and stated here.MirrorEffects— 2 impls, both#[cfg(test)]inrunner.rs; both extended. Gained a required method (breaking for any external implementor; there are none).SpendJournal— new method only;confirmed/submitted/failed/unresolveduntouched.FundingError— new variant. Exhaustive matches: theDisplayimpl (updated) and the tests. No other matcher in the tree.bondable_pairs— one caller,collateral_buffer.DhtHandle::new— 6 call sites; onlypeer.rs:3004changed, towith_mirror_pointers. The 5 test sites keepnew, which still means "no pointers".Nodestruct literals — 14, all updated with the new field.dig_node_core::dig_dhtre-export — additive.detect_changeswas likewise unavailable on a stale index; the diff was reviewed file-by-file and touches onlymirror/,spend_audit.rs,control.rs(one function + its tests),peer.rs(one line),lib.rs(one slot),server.rs(one block),SPEC.mdand the version.No HIGH/CRITICAL risk warning is available to report, because the tool could not answer — which is itself worth the gate's attention on custody code.
SPEC
SemVer — minor, 0.189.0 → 0.190.0
New capability (resolution, the pointer source) plus two technically-breaking surfaces: a required method on the public
MirrorEffectstrait and a newFundingErrorvariant. Under cargo's0.xrules a minor bump is already semver-incompatible, so it carries the break correctly for any caret dependant. Stated rather than assumed.Unverified
dign spendsafter a real cycle — is the orchestrator's, and it is the acceptance this PR unblocks.SnapshotMirrorPointers) and at the wiring, not by observing a publishedProviderRecordon a live network.#427bound at 32 is a judgement, not a measurement of real wallets.