Skip to content

fix(wallet): distinguish unknown from zero across the wallet surface - #454

Draft
MichaelTaylor3d wants to merge 5 commits into
mainfrom
loop/batch-wallet
Draft

fix(wallet): distinguish unknown from zero across the wallet surface#454
MichaelTaylor3d wants to merge 5 commits into
mainfrom
loop/batch-wallet

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

DRAFT — DO NOT MERGE. The gate round has not returned.

Closes #416
Closes #306
Closes #286
Closes #256
Closes #384
Closes #390

#396 is NOT closed by this PR — see the verdict on that ticket. Its deliverable is a real
mainnet wallet holding a $DIG CAT and an NFT, which is not reachable from a worktree.


The through-line

Every ticket here is a surface that could not distinguish "I do not know" from "the answer is
zero / nothing / fine."
Each fix restores that distinction rather than papering over the symptom.

# The conflation Now
416 a stale-replica 0 rendered identically to an empty wallet the gap is on the wire and the human line says NOT CURRENT
306 "this tier declined to look" returned as "you hold no $DIG" the fallback tier is asset-scoped and answers
286 a funded wallet described as disposable, for ever funds latch the flag; an unknown defers, a measured zero does not
256 a clamped or zero-row write reported as success the derivation floors in force are reported; a zero-row write errors
384 no way to recover a coin whose parent read failed once a reset that clears the authoritative flag in the same transaction

Per ticket

#416 — the stale-replica zero (mvp)

Two halves; one is fixed here and one is not, stated plainly.

Fixed — making staleness legible where a person reads a balance. control.wallet.balance now
carries network_peak_height (the peak this node's own held Chia peers announced) and stale_by
(how far behind that peak the figure is). Both additive per §5.1, asserted against a consumer struct
that ignores them.

stale_by is None unless BOTH heights are known, because a zero and an absence are opposite
claims
: zero says this figure is level with the network, absence says nothing bounds this
figure
. The ticket's measured reading — balance 0, synced false, peak_height null — is the
second, and previously had no way to say so.

The human line was the actual defect surface. It read balance 0 · pending 0 · syncing, built from
result["balance"].as_u64().unwrap_or(0) — so a missing field printed a confident zero balance —
and syncing reads as reassuring progress rather than as a warning. It now renders unknown for an
unreadable field and prefixes every non-current figure with NOT CURRENT, naming the as-of height
and the gap.

NOT fixed — "establish why the replica is not catching up." That is a diagnosis on a specific real
machine's node, which this lane does not have and did not re-measure. Unverified; needs a lane
with the host.

#306 — CATs read as zero while unsynced

wallet_coins's fallback arm did return Ok(Vec::new()) for any CAT. Its stated blocker —
"CAT asset attribution while syncing needs puzzle uncurrying"does not exist: a CAT coin is
identified by where it sits. Wired to asset_scoped_fallback_coins, the same helper
balance_for_address and coins_for_address already use, rather than re-derived — the balance and
the coin list behind it must not be able to scope to different assets.

The ticket's spend-selector warning does not apply as written, and this is worth recording. It
cites rpc.rs:2933,:3319 as spend-input selection reached through wallet_coins. On today's tree
wallet_coins has exactly two callers, get_coins and get_spendable_coin_count; those line
numbers are now unrelated code. So this widens two READS and does not change what a spend can select.

#286 — the latch nothing called

latch_ever_funded was written, persisted and tested, with no production caller. A new
wallet_funded module holds the decision as a pure function; the mirror pass is the observation
point because it already reads the operator wallet's balance on a timer.

Deviation from the ticket's wording, taken deliberately. #286 says "if it is unclear whether
funds were observed, latch."
Implemented literally, that latches on CannotSay — which is the state
every node is in on its first pass, so every auto wallet would latch immediately and
is_disposable would be vacuously false for ever. That is the exact vacuity the ticket's own body
cites as the pattern to avoid.

The instruction's purpose survives without that cost, because nothing ever records "not funded":
the latch is monotonic, so declining to latch on an unknown defers a decision rather than making the
wrong one, and the next observation that sees money latches. The direction that matters is covered
without a currency gate — a non-zero figure classifies as funded from either tier, so a stale or
fallback answer showing money latches at once. synced gates only the zero case. SPEC §16.4's
NOT YET SATISFIED block is deleted and replaced with the observation-point contract.

#256 — a no-op indistinguishable from success

Enumerated from the dispatch table: 19 sites, not the four the comment implied.

Only increase_derivation_index is changed. Its no-op is reachable and money-class: the write is
MAX(col, ?), so a request below the floor changes nothing by design, and WHERE id = 0 against an
absent settings row updates zero rows while execute returns Ok — the floor is never raised,
the operator is told it was, and funds at higher indices stay invisible with no error and no retry.

It now returns the floors in force, per tree, with None for a tree not asked about — never
Some(0), which would assert that tree scans nothing. Modelled on ChiaPeerRemovalOutcome: numbers
a consumer must read, no bool companion. A zero-row update is an error.

The other 18 are documented rather than changed, per the ticket's Sage-parity constraint: the
settings/theme/peer/metadata writes have no observable difference between "changed it" and "it
already said that", so there is nothing a richer response could truthfully report.
redownload_nft/update_nft on an unknown id are named as the real remaining candidates, gated on
establishing Sage's own response shape first.

#384 — reset the coin database (mvp, kind:business)

control.wallet.resetCoinDb plus dign wallet reset-coin-db --confirm.

  • The money hazard is closed structurally. initial_sync_complete and the recorded coverage are
    cleared in the same transaction that empties the coins, so a crash between them cannot leave an
    empty replica that is still authoritative — the balance 0, synced true state.
  • Refuses while a spend is in flight, counting inside the transaction so a reservation taken
    during a pre-check cannot slip the gap. Liveness is judged by expiry against the node's own
    clock
    , not by row presence: one lapsed unpruned hold must not permanently deny the only recovery
    this feature provides. The instant is not caller-supplied — that would be a lapse oracle.
  • A refusal is an error, not a success with a flag. A caller ignoring a refused: true field
    would read "your cache was reset" and act on it.
  • No key material is reachable. The table list is explicit and chain-derived only; a test proves a
    user theme survives while the coins do not.
  • confirm: true travels on the WIRE, not asserted in the CLI — a guard only the CLI applies is
    not a guard.

#390 — ALREADY SHIPPED, not rebuilt

The §2.0 already-shipped check found this ticket's design merged in 4523894 (PR #393) and wired
on both paths
stage_from_states at sync.rs:772, promote_staged_cats at sync.rs:833 and
rpc.rs:3281, cat_admission_pending at db.rs:421, 18 tests. Full evidence on the ticket. The
Closes above is bookkeeping.

Blast radius

.gitnexus is registered against the primary checkout and is stale, so impact would return a
false-safe zero. Done by grep plus direct read instead, and stated as such:

  • balance_wire — 4 call sites, all in this file (1 production, 3 tests). All updated.
  • raise_derivation_floor — 2 production callers, both in actions.rs; 2 test callers in db.rs.
    Return type () to u32; all updated.
  • increase_derivation_index — 1 production caller (rpc.rs), reachable only via the Sage-parity
    dispatch. Response widened additively.
  • wallet_coins — exactly 2 callers, get_coins and get_spendable_coin_count. Both reads.
  • latch_ever_funded — had zero callers; now one.
  • New symbols (reset_chain_cache, reset_coin_db, wallet_reset_coin_db, stale_by,
    FundingObservation, amount, balance_freshness) have no prior callers.

A tier decision worth the gate's attention, and a CI finding that forced it.

control.wallet.resetCoinDb is destructive, so the master token looked right, and
requires_master_token fails CLOSED for an unpublished name — it landed there by default. CI
refused it
: the_contract_publishes_every_control_method_the_node_serves rejects a
served-but-unpublished method, and its sanctioned escape, KNOWN_UNPUBLISHED, is deliberately the
same constant the token gate reads
— so tolerating the publish drift and granting a paired token
access are ONE decision, by design.

That forced the question rather than allowing a default, and the default was wrong: #384 exists to
put a reset button in the DIG App, and the App holds a paired token.
Master-tiering it makes the
feature unreachable by the only consumer it was built for — a guard so tight it removes the
capability is a deletion, not a guard. It is therefore on the paired tier, recorded in
KNOWN_UNPUBLISHED_CONTROL_METHODS with the reasoning and a removal condition.

What bounds the damage is the combination the method does enforce: loopback-only + a token +
confirm: true on the wire + a refusal while any spend is in flight + a blast radius containing no
key material and nothing a re-sync cannot rebuild.
Not tier alone. Flagged explicitly for the
gate
— if the reviewer judges a destructive method must be master-tier regardless of reachability,
that is a coherent position and it means #384 cannot ship until the contract publishes.

An interim change is REVERTED and is not in the diff: the master-tier drift assertion was briefly
relaxed from equality to containment, and equality is restored.

Required cross-repo sibling (§1.3b / §4.1): dig-node-control-interface must publish
control.wallet.resetCoinDb. Until it does, the method sits in KNOWN_UNPUBLISHED, and
the_unpublished_list_still_describes_real_drift fails the moment it IS published unless the entry
is removed — so this cannot rot. Needs an orchestrator-dispatched lane.

Dependencies (§2.4b)

Checked against index.crates.io with the required User-Agent. Every dig-* and chia-*
declaration in dig-wallet is already at latest
dig-node-control-interface 0.27.0,
dig-offers 0.3.0, dig-clvm 0.4.0, dig-keystore 0.13.0, chia-query 0.20.0. The chia-* set
moves together and sits uniformly at 0.36.1 / 0.36.0, the stated ceiling pending
chia-wallet-sdk publishing against 0.48. No bumps were owed; none were made.

SemVer

minordig-wallet 0.43.0 to 0.44.0, workspace 0.189.0 to 0.190.0. New capability, and
every wire change is additive (§5.1): new fields on the balance result, a new control method and CLI
verb, a widened increase_derivation_index response. raise_derivation_floor and
actions::increase_derivation_index changed return type, but both are internal to the crate.

SPEC

control.wallet.resetCoinDb row added with the same-transaction requirement, the
expiry-not-presence refusal rule and the master tier; the balance row extended with
network_peak_height/stale_by and the zero-is-not-absence rule; the CLI verb mapping added;
§16.4's NOT YET SATISFIED block replaced with the observation-point contract.

Adds the staleness gap to `control.wallet.balance` (`network_peak_height`,
`stale_by`, both additive) and makes the human line say what it knows: an
unreadable balance renders `unknown` rather than a confident `0`, and a
non-current figure is marked NOT CURRENT with its as-of height and its distance
from the network.

Also adds the missing caller for the funded latch, so a funded auto-created
wallet stops being described as disposable.

Refs #416 #286
Comment thread crates/dig-node-service/src/control_cli.rs
Comment thread crates/dig-node-service/src/control_cli.rs
Comment thread crates/dig-node-service/src/control_cli.rs
Comment thread crates/dig-node-service/src/control_cli.rs Fixed
Comment thread crates/dig-node-service/src/control_cli.rs Fixed
Comment thread crates/dig-node-service/src/control_cli.rs
Comment thread crates/dig-node-service/src/control_cli.rs
Comment thread crates/dig-node-service/src/control_cli.rs
#306: the Sage-parity coin read returned an empty set for ANY CAT while
unsynced, so a real $DIG holder read as holding none. Wired to the same
asset-scoped hint read the balance already uses.

#256: increase_derivation_index returned the shared empty ActionResponse, so a
clamped or zero-row write was indistinguishable from success -- and the
derivation floor decides which addresses this node scans. It now reports the
floors in force, and a zero-row update is an error.

#384: control.wallet.resetCoinDb + `dign wallet reset-coin-db --confirm`.
Clears the authoritative flag in the SAME transaction as the coins, refuses
while a spend is in flight, and touches no key material.

Refs #306 #256 #384
…ded latch

SPEC gains the control.wallet.resetCoinDb row (the same-transaction clearing of
the authoritative flag, the expiry-not-presence refusal, the master tier), the
balance row gains network_peak_height and stale_by with the rule that an absent
gap and a zero gap are opposite claims, and 16.4's NOT YET SATISFIED block is
replaced by the observation-point contract now that a caller exists.

Bumps dig-wallet 0.43.0 -> 0.44.0 and the workspace 0.189.0 -> 0.190.0: new
capability, every wire change additive.

Refs #416 #286 #384
Comment thread crates/dig-node-service/src/control_cli.rs
Comment thread crates/dig-node-service/src/control_cli.rs
…sumer is

The contract-conformance gate refused a served-but-unpublished method, and its
sanctioned escape -- KNOWN_UNPUBLISHED -- is deliberately the SAME constant the
token gate reads, so tolerating the publish drift and granting a paired token
access are one decision.

Weighed rather than inherited: #384 exists to put a reset button in the DIG App,
and the App holds a paired token, so reserving this to the master token would
make the feature unreachable by the only consumer it was built for. What bounds
a destructive method here is loopback-only + a token + confirm:true on the wire
+ a refusal while a spend is in flight + a blast radius holding no key material,
not tier alone.

This also restores the master-tier drift assertion to equality: the containment
relaxation it needed is no longer required.

Refs #384
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict

Audited head: 405ef503060d02a616200f4c048d7223d0a80099 (resolved from gh pr view 454 --json headRefOid).
Merge base 3e480dd302ae0a8518ce535e5b1bc4a563b960ff = current origin/main.
Read from git objects in a private scratch; the lane's worktree was not touched.

Confirmed clean so far

  • No .loop/ residue. The 15-file diff contains no .loop/ path and no sibling-lane scratch file. .gitignore adds .lane/, which is this lane's own private scratch — correct.
  • Authorship on all 5 commits is Michael Taylor <michael@michaeltaylor.dev> — the one correct identity, no fabricated address.
  • Commit subjects are 68/72/74/77/78 chars, all under the 100-char commitlint ceiling.
  • The tier mechanism is as the lane describes it. requires_master_token_given (control.rs:381-386) is match ControlMethod::from_name(method) { Some(p) => p.requires_master_token(), None => !exempt.contains(&method) }. So an unpublished name defaults to master, and adding it to KNOWN_UNPUBLISHED_CONTROL_METHODS demotes it to paired. Tolerating publish drift and granting paired access really are one edit. The lane's account of the mechanism is accurate; the tier decision is still under review.
  • control.wallet.resetCoinDb is absent from is_open_control_read (control.rs:148-160) — it is token-gated, not an open read.

FINDING 1 — string-continuation damage IS present, in two user-facing messages (MEDIUM)

The hazard the brief warned about did not miss this diff. Two distinct corruptions, both in strings a user sees:

1a. crates/dig-node-service/src/control.rs:2334 — the confirm: true refusal. The source contains literal \n escapes followed by 13 spaces, so the emitted message is physically broken across lines with ragged indentation:

control.wallet.resetCoinDb is DESTRUCTIVE: it discards this node's cached coin database \n             and re-syncs from chain. Pass params.confirm = true to proceed. No key \n             material is affected.

This is the line-continuation \ + newline that a heredoc turned into a literal \n escape plus the indentation it was supposed to swallow. It compiles, and every gate is blind to it.

1b. crates/dig-wallet/src/sage/db.rs:2688ResetRefusal::Display, the other half of the same corruption: here the backslash was eaten outright, leaving runs of 18 literal spaces mid-sentence:

"refused: {reservations} coin reservation(s) are in flight. Resetting now would                  wipe the coins an unconfirmed spend was built on. Wait for them to confirm or                  expire, then retry."

This string is not decorative — wallet_reset_coin_db returns it verbatim as the control_error message (control.rs:2358), so it is what the DIG App renders when it refuses a reset.

Two test assertion messages carry the same 14-space runs (db.rs:5119, db.rs:5147) — cosmetic, but same root cause, so fix them in the same pass or cargo fmt will not save you.

Not a security defect — no money lie, no attacker leverage. Recorded as MEDIUM because 1a/1b are user-facing product text on a destructive operation's only two failure paths, and the brief asked specifically.

Continuing: #384 atomicity under concurrency, the open-read amplification question on wallet_balance, the 8 GHAS dismissals, and #286/#256/#306.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security gate — IN PROGRESS, not the verdict

Auditing head 405ef503060d02a616200f4c048d7223d0a80099 (resolved from gh pr view 454 --json headRefOid),
against merge-base 3e480dd302ae0a8518ce535e5b1bc4a563b960ff. Diff is 15 files / +1299 / -53.

Finding 1 — MANGLED LINE-CONTINUATION IN THE DESTRUCTIVE-ACTION WARNING (LOW, cosmetic, confirmed by byte dump)

crates/dig-node-service/src/control.rs:2332

The Rust string literal contains the two-character escape \n followed by 13 literal spaces, twice.
od -c on the blob at head confirms bytes \ n SP x13 — i.e. this is not a source-formatting
artifact, it is what the compiler sees. The intended construct was almost certainly a Rust
line-continuation (\ at end-of-line, which strips the newline and leading indentation).

What a user of control.wallet.resetCoinDb actually receives when they omit confirm:

control.wallet.resetCoinDb is DESTRUCTIVE: it discards this node's cached coin database 
             and re-syncs from chain. Pass params.confirm = true to proceed. No key 
             material is affected.

This is the confirmation warning for the only destructive method in the diff, and it is rendered
into a JSON-RPC error string that the DIG App shows verbatim. It compiles, cargo fmt will not
correct it, and no gate can see it — which is exactly why it is being called out rather than left.

Not a security defect and not a lie about money — the text is accurate, only badly wrapped. Rated
LOW / cosmetic, but recommend fixing in this PR since it is a one-character-per-site edit and the
message is a destructive-action gate.

It is the only instance in the diff — a full scan for \n\s{4,} over the 1759-line diff returns
this line and nothing else.

Hazard cleared — no .loop/ residue

git diff --name-only 3e480dd3..405ef503 returns 15 paths, zero under .loop/. No sibling-lane
scratch file is in this diff.

Continuing: #384 atomicity, the tier decision, the 8 GHAS dismissals, #286, #416's closing keyword.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

IN PROGRESS — not the verdict. CORRECTION to Finding 1.

My previous comment said the mangled line-continuation was "the only instance in the diff." That was
wrong
— my scan pattern required a literal \n, and it missed every site where the backslash was
eaten entirely, leaving only the space run. An exhaustive scan (^\+.*\S {5,}\S) finds nine,
of which four are user-facing, and they are the two most load-bearing honesty strings in this PR.

Finding 1 (REVISED) — nine mangled string literals; four reach a user (LOW, cosmetic, not a lie)

site reaches rendered defect
crates/dig-node-service/src/control.rs:2332 JSON-RPC error, DIG App \n + 13 spaces, twice — the destructive-reset confirmation warning
crates/dig-wallet/src/sage/db.rs ResetRefusal::Display wire, via refusal.to_string() at control.rs:288 18 spaces — the spend-in-flight refusal
crates/dig-node-service/src/control_cli.rs:732 dign wallet reset-coin-db stdout 14 spaces — the reset success line
crates/dig-node-service/src/control_cli.rs:1432 dign wallet balance stdout 23 spaces — NOT CURRENT — this node cannot say what height this reflects, i.e. #416's entire deliverable for the exact reading the ticket filed

The other five are assert! messages in #[cfg(test)] code (db.rs, rpc.rs) and only degrade test
output.

Not a security defect and not a money lie — every sentence is true, merely wrapped with a gap
mid-sentence. So this does not gate on the §2.6 money/custody carve-out. But it is worth fixing in
this PR rather than filing: the four user-facing sites are precisely the honesty surfaces #416 and
#384 exist to build, cargo fmt will not repair them (it has been observed to reintroduce this
pattern on a sibling lane), and no gate downstream can see it because it compiles cleanly.

Severity LOW. Recommend fixing before merge; not gating on its own.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

IN PROGRESS — not the verdict. Finding 2, and it is the significant one.

Finding 2 — #384: the reset is atomic, but an IN-FLIGHT CATCH-UP RE-ARMS synced OVER THE EMPTIED REPLICA (HIGH, money lie, live)

The atomicity claim itself checks out. WalletDb::reset_chain_cache (crates/dig-wallet/src/sage/db.rs:2729)
opens one self.pool.begin(), runs the reservation count, all eleven DELETEs and the
UPDATE sync_state SET initial_sync_complete = 0, covered_puzzle_hashes = '' on that same &mut *tx,
and commits once. A crash or driver error between the delete and the flag clear rolls back both. The
refusal path returns before any write and drops the transaction uncommitted. That half is correct,
and the prior gate's separate concern — that the reset might not rebuild — is also correctly refuted:
sync.rs:1097-1098 sets previous_height = None / header_hash = genesis_challenge, so a catch-up
replays from genesis.

But the invariant the method's own doc comment states is false, and that is where the defect is.
db.rs says:

a crash between them cannot leave an empty authoritative replica; reads fall back to the chain tier
until a genuine catch-up re-establishes the flag.

A genuine catch-up re-establishing the flag no longer implies a populated replica, because the reset
can delete that catch-up's own writes out from under it.

Where the two writes are split (this is the whole bug):

  • crates/dig-wallet/src/sage/sync.rs:1152apply_coin_states(db, ...) writes each batch's coins in
    its own transaction, once per batch.
  • crates/dig-wallet/src/sage/sync.rs:1160 — on respond.is_finished, a separate transaction
    db.complete_catch_up(...)crates/dig-wallet/src/sage/db.rs:1405, which is
    UPDATE sync_state SET peak_height = ?, initial_sync_complete = 1, covered_puzzle_hashes = ? WHERE id = 0.

Nothing serialises those against the reset. grep -n 'Mutex\|RwLock\|Semaphore' crates/dig-wallet/src/sage/db.rs
returns nothing, and WalletBackend::reset_coin_db (rpc.rs:1124) is a bare pass-through. The
supervisor runs a catch-up unconditionally per peer session (sync_supervisor.rs:1450) — it is not
gated on initial_sync_complete — so a catch-up is in flight routinely, not exceptionally.

The interleaving, with no attacker and no oracle misbehaviour:

t actor effect
T0 peer session's initial_sync_with_authority replaying from genesis; N batches of the user's real coins are now in coins
T1 user presses the #384 reset button (DIG App / dign wallet reset-coin-db --confirm) no spend in flight, so it proceeds: coins emptied, initial_sync_complete = 0, coverage ''. Commits. Prints "the replica is no longer authoritative and will re-sync from chain"
T2 the same session receives its final batch, is_finished = true complete_catch_up commits initial_sync_complete = 1, covered_puzzle_hashes = <full address set>, peak_height = <peak>

Resulting state: coins EMPTY, initial_sync_complete = 1, coverage FULL. Wallet-scoped reads
route to the local replica as authoritative and answer balance 0, synced true on a funded wallet
verbatim the state reset_chain_cache's own doc comment names as "the money-lie class this repo already
documents" and the state this entire PR exists to make unreachable.

Impact. A synced: true zero is not "I don't know", it is "the answer is nothing" — the exact
conflation this PR's through-line is built to remove. It lands at the worst moment: immediately after a
destructive action, while the user watches to see their funds return. stale_by does not rescue it —
the replica genuinely believes it is level with the network, so the #416 NOT CURRENT prefix does not
fire either. Both new honesty surfaces read as reassuring.

It self-heals, which caps this at HIGH rather than CRITICAL: the next peer session's catch-up
replays from genesis and repopulates, because catch-up is not gated on the flag. The window is one
peer-session cycle — minutes to hours, and longest on a node with few peers, which is the node most
likely to have prompted a reset in the first place.

Secondary effect on a wallet whose baseline was never armed: complete_catch_up arms
arrival_baseline_height as COALESCE(existing, MAX(?peak, COALESCE((SELECT MAX(created_height) FROM coins), 0))).
With coins emptied that inner MAX is NULL → 0, so the baseline arms at the replay peak, and every
coin re-synced afterwards at a lower height is classified backfill rather than arrival. Arming is
once-per-wallet, so that is permanent. Lower severity, same root cause.

Cheapest correct fix — a reset epoch. Add a monotonically-increasing reset_epoch to sync_state;
reset_chain_cache bumps it inside its existing transaction; initial_sync_with_authority captures it
before its first batch and passes it into CatchUpReplay; complete_catch_up becomes
... WHERE id = 0 AND reset_epoch = ?captured, and reports zero rows updated as "superseded by a reset,
replica stays non-authoritative". A reset then invalidates any catch-up that began before it, which is
the true intent. A lock is the wrong shape — the reset would block for up to CATCH_UP_DEADLINE.

Adjacent, PRE-EXISTING, not introduced here — recording it because #384 makes it newly reachable on a
funded wallet.
refresh_tracked_coins (rpc.rs:3301) is the second production writer of the flag
and its own comment states it "DOES latch over zero rows … declaring an empty table authoritative"
(dig_ecosystem#2514). Before #384 an empty coins table was mostly a fresh-install condition; #384 makes
it a user-triggerable state on a funded wallet, so #2514's window now opens on demand. That one is
defense-in-depth and a follow-up ticket, not a gate on this PR.

Still to finish: the three escalated decisions, the 8 GHAS dismissals, #390/#416 closing keywords.

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