feat(control): serve a coin read by coin id, including spent coins - #201
feat(control): serve a coin read by coin id, including spent coins#201MichaelTaylor3d wants to merge 10 commits into
Conversation
Co-Authored-By: Claude <noreply@anthropic.com>
`CoinsetFallback::coin_record_by_id` mapped EVERY `chia-query` failure onto `Ok(None)` — a dropped connection, a coinset 500, a TLS failure and a rate limit all reported "this coin does not exist". Under a mint poll that reads as awaiting forever, and a genuinely-spent funding coin can never report failure either. Both are money lies. The absence-aware `get_coin_record_by_name_opt` already carried the correct distinction one layer down (chia-query 0.6, already the pinned dep): a `success: true` envelope with a null record is provable absence; anything else is a failure. Use it and propagate the error. Both directions are pinned by tests, together — either alone is satisfiable by collapsing the other. Refs DIG-Network/dig_ecosystem#2392 Co-Authored-By: Claude <noreply@anthropic.com>
…les and CLI Salvage of the registration half interrupted by a session cap: method tables, open-read set, dispatch arm, CLI action + subcommand, the WalletBackend coin_by_id read and its four tests. May not yet compile; committed so the work survives. Refs #2392 Co-Authored-By: Claude <noreply@anthropic.com>
…nt_height Adds control.wallet.coinById to the exact-set auth pin, and a mapper test that feeds coins_wire a SPENT coin so reverting to a hardcoded null fails. Bumps the workspace to 0.104.0 (new capability) and dig-wallet to 0.15.0 (public API grew: coin_by_id, WalletCoinByIdResult, WalletCoin::spent_height). Refs #2392 Co-Authored-By: Claude <noreply@anthropic.com>
63a32f9 to
dda0d3e
Compare
… ordering `wallet_coin_id_param`, `coin_by_id_wire` and `wallet_coin_by_id` shipped with no tests. This closes that gap: * the validator's refusals (missing, non-string, empty, 63/65 hex, non-hex, `0x` over 63 hex) and its two acceptances (bare and `0x`-prefixed, both yielding the bare lowercase form); * uppercase hex REFUSED rather than normalized, in its own test, because that is the refusal most likely to be "fixed" into a contract violation; * `coin_by_id_wire`'s published shape, its always-null `asset`, and `coin: null` as a SUCCESS envelope rather than an error; * over the real HTTP surface, that a malformed `coin_id` is refused BEFORE the chain-source liveness check -- proved by the malformed and well-formed calls answering DIFFERENTLY on a chain-less node, not by the error code alone; * `control.wallet.coinById` added to the open-read routing loop with params valid for its own handler, keeping that loop's INVALID_PARAMS assertion discriminating. Co-Authored-By: Claude <noreply@anthropic.com>
…ection The conformance suite went green over a `control.wallet.coinById` the pinned contract had never declared. Every assertion in the file iterates the CONTRACT, so a method the pinned version does not know is not tested loosely -- it is not tested at all. `dig-node-control-interface = "0.6.0"` predates the method, so the one gate that exists to catch control-surface drift checked nothing about the very method the change added, and reported success. Two changes, because the version alone would fix this instance and not the class: - the requirement becomes `0.7` (a caret range), so the pin moves with the published catalog instead of silently narrowing what CI can see; - a converse assertion -- every `control.*` method the node SERVES is one the contract publishes -- so a stale pin fails loudly instead of passing vacuously. Verified load-bearing: restored to "0.6.0" it fails naming `control.wallet.coinById`, while the four pre-existing tests still pass. That direction also surfaced real drift the old suite could not see: `control.peers.ping` is served but unpublished. Listed in KNOWN_UNPUBLISHED alongside an honesty test, mirroring KNOWN_PREEXISTING_DRIFT, so the set can only shrink and publishing it stays a reviewed change rather than a blind one. The runtime design is unchanged (#2376): the node's `CONTROL_METHODS` remains the source of truth and the contract stays a TEST-only dependency pinning it. Refs: DIG-Network/dig_ecosystem#2392 Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
CHANGES-REQUIRED — reviewed at head 9b89004ae0db586dde860a5c41bd34842cf561db.
The heart of this PR is right. Verified by reading:
crates/dig-wallet/src/sage/fallback.rs:185-191no longer collapses an error into an absence, andchia-query0.6'sget_coin_record_by_name_optreally does carry the distinction end to end (router.rs:337->peer_then_coinset_optatrouter.rs:103-127errors when both tiers fail;peer/mod.rs:518treats only an empty coin-state list from a SUCCESSFUL response as absence, andrequest_coin_statereturns spent coins, so the ticket's "including spent coins" criterion holds on the peer tier too, not just via coinset).- I searched for the collapse pattern rather than trusting the two cited call sites. The only other
Err(_) => Ok(None)on a chain read indig-walletisChiaQueryLineage::parent_spend(fallback.rs:225), which is not on this path (non-gating note below). The twocoins_by_idscallers useawait?and propagate.ChainTransport::coin_record_by_id(chain.rs:179) delegates toCoinsetFallback, so it inherits the fix.EmptyFallbackreturnsOk(None)but reportsis_live() == false, socoin_by_idrefuses withNoChainSourcebefore ever reaching it. No surviving collapse found. - The node's
coin_idvalidator matches the published contract'snormalize_coin_id(dig-node-control-interface-0.7.0/src/params.rs:274-281) exactly, including the uppercase refusal the doc comment claims. That claim is true. - Test load-bearingness (reasoned, not executed — I did not run the suite):
an_unreachable_chain_is_an_error_never_a_missing_coinis load-bearing by construction (the reverted mapping returnsOk(None)on exactly the input it assertsis_err()for), and it is not vacuous because its paired absence test forbids satisfying it by always erroring.coins_wire_reports_a_spent_height_rather_than_asserting_nullgenuinely fails against the previous literalValue::Null.a_malformed_coin_id_is_refused_before_the_chain_is_ever_consulteddiscriminates via the two answers DIFFERING, which a reordered validator cannot satisfy.
Three findings block, ranked in the inline threads. None touch custody: §908 holds — this is a read, and no key material is anywhere on the path.
|
@MichaelTaylor3d I've opened a new pull request, #202, to work on those changes. Once the pull request is ready, I'll request review from you. |
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
NON-GATING (does not block merge; resolving myself). Two observations from the same sweep, neither in this PR's scope:
1. The same error-into-absence collapse survives one file away, on a different read. crates/dig-wallet/src/sage/fallback.rs:225, in ChiaQueryLineage::parent_spend:
Ok(cs) => cs,
// The parent spend is not available (unspent / not found) — a clean "no lineage".
Err(_) => return Ok(None),That is the identical shape this PR fixed in coin_record_by_id: a dropped connection, a coinset 500 or a rate-limit all report "there is no lineage". It is pre-existing, it is not on the coin_by_id path, and chia-query 0.6 already publishes the absence-aware get_puzzle_and_solution_opt that would fix it the same way. Worth its own ticket rather than widening this PR — the PR body's "no other path re-collapses it" is true of the coin_by_id path, which is what it claims.
2. The new CLI summary arm has no test and can print a false zero. crates/dig-node-service/src/control_cli.rs:327-337 adds a control.wallet.coinById arm to summarize, and crates/dig-node-service/src/control_cli.rs:530's summarize table was not extended to cover it — so the one new human-facing money line in this PR is unpinned. coin["amount"].as_u64().unwrap_or(0) would print 0 mojos for any amount that failed to parse, which is the same money-lie shape the PR exists to remove, in the human surface rather than the wire. Low severity (the wire mapper always emits a JSON number), but a row in the existing table asserting both the found-and-spent and the no such coin on chain lines would close it cheaply.
Both are tracked as follow-ups, not merge blockers.
The fallback coin-id read returned whatever the source answered with, and that WAS the answer. The tier underneath takes the first coin state a peer returns and never hashes it, over an unauthenticated pool of DNS-discovered mainnet nodes, so one hostile peer could answer a lookup for X with any other real coin -- and a caller polling a mint reads "coin present" as "the mint landed", recording a DID that is not on chain. A coin id is self-certifying, so the substitution is detectable locally. A record for a different coin is now a read FAILURE: never this coin's record, and never absence. Matches the idiom the crate already uses in sage/singleton.rs and sage/options.rs. Refs dig_ecosystem#2392 Co-Authored-By: Claude <noreply@anthropic.com>
…ng absence Three of the gate findings on #201, none of which CI could see. - The coin-id well-formedness rule was a second copy of what dig-node-control-interface publishes as WalletCoinByIdParams::validated(). The dep is promoted from dev to a real dependency and the copy deleted; the node contributes only what the contract type cannot -- reading the field off an untyped params value and shaping this node's error. - The doc comments told consumers `coin: null` meant a chain was consulted and provably has no such coin. It does not: chia-query 0.6 returns one peer's empty coin-state list without consulting coinset. The wording now says what absence means today and points at dig_ecosystem#2456. - The CLI `height` helper had stolen `pinned`'s doc comment; moved back. Also: the coinById summary printed `0 mojos` for an unreadable amount (a zero that is really an unknown), and indexing the inner coin map panicked outright on a missing field. Both fixed, both pinned by tests. Refs dig_ecosystem#2392 Co-Authored-By: Claude <noreply@anthropic.com>
SPEC.md still gave a CLOSED enumeration of exactly three open control reads, so it told a reimplementer this method is token-gated -- a contract split, since the node serves it open. Adds the open-read enumeration, the method table row (params, result, the tier fields, the binding rule and what `coin: null` does and does not prove), the CLI verb, and the -32040/-32042/-32043 catalog rows. Refs dig_ecosystem#2392 Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
Correctness re-gate: PASS
Head reviewed: 5dc5a391bdb6da5f6b90800c897e73bb927477de (resolved from gh pr view --json headRefOid).
All five round-1 gating findings verified CLOSED at this head, by reading the code and running the tests, not by reading the commit messages.
1. Coin-id binding (the root) — CLOSED, and the test is non-vacuous.
The guard is crates/dig-wallet/src/sage/fallback.rs:206, comparing map_record's recomputed id against norm_hex(coin_id). coin_id_of (fallback.rs:107-127) genuinely recomputes SHA256(parent ‖ puzzle_hash ‖ amount) via chia_protocol::Coin::coin_id() — it does not echo a source-supplied field — so the check is self-certifying and covers substitution of any of the three components.
I probed vacuity in my own worktree (C:/tmp/worktrees/g2392p, detached at the same SHA; the review checkout was never mutated). Neutering the guard to if false && ...:
test ...a_record_for_a_different_coin_is_an_error_never_this_coins_record ... FAILED
got Ok(Some(FallbackCoin { coin_id: "626443fb...", amount: 7, spent_height: Some(140), .. }))
The other three chain_failure_tests stayed green — so the test discriminates exactly this guard and nothing else.
On the max_peers: 0 trap raised in round 1: it does not apply to this guard, structurally.
The check does not live inside a tier. It is applied in dig-wallet to whatever chia_query::ChiaQuery::get_coin_record_by_name_opt returned, and that call is router.rs:337-347 → peer_then_coinset_opt — peer, peer-retry, then coinset. There is one call site and one check above the tier split, so a record substituted by the peer tier is rejected by the same line as one substituted by coinset. The 0-peer fixture is therefore a determinism/offline choice, not a coverage gap, for this property. (It would still be a gap for any property whose behaviour differs per tier — none is asserted here.)
2. False absence-guarantee wording — CLOSED. fallback.rs:177-193, rpc.rs:192-197, rpc.rs:944-..., control.rs:1424-... and the SPEC.md row all now say Ok(None)/coin: null means a source answered "no such coin" and explicitly that it is not proof of absence, deferring corroboration to dig_ecosystem#2456. I checked specifically for the over-claim the scope note warns about: the PR nowhere claims a guarantee only #2456 could provide. The positive direction is claimed as self-certifying, which is true locally and needs no second source.
3. SPEC closed-enumeration split — CLOSED at all four sites (SPEC.md §auth exceptions, the method table, the CLI verb list, and the -32040/-32042/-32043 error rows).
4. Duplicated coin-id rule — CLOSED. dig-node-control-interface promoted dev-dep → real dep at "0.7" (crates/dig-node-service/Cargo.toml:86; Cargo.lock resolves 0.7.0), and wallet_coin_id_param (control.rs:1346-1367) consumes WalletCoinByIdParams::validated(). The local function contributes only JSON extraction and error shaping — no second copy of the rule. The caret range plus the new the_contract_publishes_every_control_method_the_node_serves conformance test closes the "pinned at 0.6.0, so the gate iterated a catalog that had never heard of this method" hole.
5. pinned's doc comment — RESTORED (control_cli.rs:409); mojos/height each carry their own.
Extras verified: mojos() prints amount unknown rather than 0 mojos (pinned by coin_by_id_summary_never_prints_zero_for_an_unreadable_amount), and the record fields are read with Map::get rather than Map indexing — the panic path is real (serde_json::Map's Index<&str> panics on a missing key, unlike Value's) and is gone.
Tests run on this head (CARGO_TARGET_DIR outside the checkout):
cargo test -p dig-wallet --lib sage::fallback— 6 passedcargo test -p dig-node-service --lib control:: --features testkit— 36 passedcargo test -p dig-node-service --test control_contract_conformance --features testkit— 5 passedcargo test -p dig-node-service --test server --features testkit coin— 1 passed
All 15 required checks green, includingTest + coverageandClippy(coverage floor taken from the CI gate; I did not re-runllvm-covlocally).
dig-constants check. (1) Nothing in this diff is a shared/cross-repo value defined locally: the one cross-repo rule introduced (coin-id well-formedness) is consumed from dig-node-control-interface, which is the canonical home for the control-plane contract, not dig-constants. (2) Nothing here hardcodes a literal dig-constants publishes; DIG_ASSET_ID is untouched and still sourced from digstore_chain::dig. No finding.
§2.5 (beautiful code). The mappers are small, single-purpose and narratively ordered; every WHY comment states a real reason (the tier bypass, the asset: null non-classification, the spent_height literal removal). coins_wire emitting c.spent_height instead of a hardcoded null is a genuine improvement, and coins_wire_reports_a_spent_height_rather_than_asserting_null is a test that fails on reversion.
One non-gating nit posted inline and resolved by me.
…tate The rustdoc said a positive answer 'carries no such caveat' because a coin id is self-certifying. The binding does prove WHICH coin a record describes; it proves nothing about whether that coin is on chain, when it was created, or whether it has been spent -- and created_height/spent_height are the entire reason this method exists. A peer that watched the mempool knows a pending coin's preimage, so it can report a created_height for a coin that never landed and still pass the binding check. That is the #2392 lie inverted and in the more dangerous direction: a false negative keeps polling, a false positive stops and records. SPEC.md was already accurate -- it claims only that a record must be bound to the id asked for. This corrects the rustdoc to match. Co-Authored-By: Claude <noreply@anthropic.com>
|
Independent re-gate at Two fresh contexts re-gated this PR while Correctness leg — PASS. All five round-1 findings verified genuinely closed, not reworded. The coin-id binding was proved non-vacuous by a revert probe in a scratch worktree: neutering the check to Security leg — the HIGH, now addressed by What
Non-gating, for whenever the lines are next touched: Checked and clear: every Both gate checkouts were read-only and are removed. Posted by a second orchestrator session that has since stood down; this PR is yours. |
|
Reopening as a fresh PR from the same branch — GitHub has this PR permanently flagged as a stack parent and refuses every merge path with "this pull request is part of a stack and must be merged using the asynchronous merge REST API." The stack came from #202 (a WIP Copilot SPEC PR based on this branch), which I closed, and whose branch I have now deleted. Neither cleared the flag; Nothing is lost. Same branch, same head |
) * chore: open #2392 lane (serve coin read by coin id) Co-Authored-By: Claude <noreply@anthropic.com> * fix(wallet): an unreachable chain is an error, never a missing coin `CoinsetFallback::coin_record_by_id` mapped EVERY `chia-query` failure onto `Ok(None)` — a dropped connection, a coinset 500, a TLS failure and a rate limit all reported "this coin does not exist". Under a mint poll that reads as awaiting forever, and a genuinely-spent funding coin can never report failure either. Both are money lies. The absence-aware `get_coin_record_by_name_opt` already carried the correct distinction one layer down (chia-query 0.6, already the pinned dep): a `success: true` envelope with a null record is provable absence; anything else is a failure. Use it and propagate the error. Both directions are pinned by tests, together — either alone is satisfiable by collapsing the other. Refs DIG-Network/dig_ecosystem#2392 Co-Authored-By: Claude <noreply@anthropic.com> * feat(control): register control.wallet.coinById across the method tables and CLI Salvage of the registration half interrupted by a session cap: method tables, open-read set, dispatch arm, CLI action + subcommand, the WalletBackend coin_by_id read and its four tests. May not yet compile; committed so the work survives. Refs #2392 Co-Authored-By: Claude <noreply@anthropic.com> * test(control): pin the new open read and prove the mapper reports spent_height Adds control.wallet.coinById to the exact-set auth pin, and a mapper test that feeds coins_wire a SPENT coin so reverting to a hardcoded null fails. Bumps the workspace to 0.104.0 (new capability) and dig-wallet to 0.15.0 (public API grew: coin_by_id, WalletCoinByIdResult, WalletCoin::spent_height). Refs #2392 Co-Authored-By: Claude <noreply@anthropic.com> * test(control): pin coinById's validator, wire mapper, and pre-network ordering `wallet_coin_id_param`, `coin_by_id_wire` and `wallet_coin_by_id` shipped with no tests. This closes that gap: * the validator's refusals (missing, non-string, empty, 63/65 hex, non-hex, `0x` over 63 hex) and its two acceptances (bare and `0x`-prefixed, both yielding the bare lowercase form); * uppercase hex REFUSED rather than normalized, in its own test, because that is the refusal most likely to be "fixed" into a contract violation; * `coin_by_id_wire`'s published shape, its always-null `asset`, and `coin: null` as a SUCCESS envelope rather than an error; * over the real HTTP surface, that a malformed `coin_id` is refused BEFORE the chain-source liveness check -- proved by the malformed and well-formed calls answering DIFFERENTLY on a chain-less node, not by the error code alone; * `control.wallet.coinById` added to the open-read routing loop with params valid for its own handler, keeping that loop's INVALID_PARAMS assertion discriminating. Co-Authored-By: Claude <noreply@anthropic.com> * test(control): pin the contract at 0.7 and assert the unpublished direction The conformance suite went green over a `control.wallet.coinById` the pinned contract had never declared. Every assertion in the file iterates the CONTRACT, so a method the pinned version does not know is not tested loosely -- it is not tested at all. `dig-node-control-interface = "0.6.0"` predates the method, so the one gate that exists to catch control-surface drift checked nothing about the very method the change added, and reported success. Two changes, because the version alone would fix this instance and not the class: - the requirement becomes `0.7` (a caret range), so the pin moves with the published catalog instead of silently narrowing what CI can see; - a converse assertion -- every `control.*` method the node SERVES is one the contract publishes -- so a stale pin fails loudly instead of passing vacuously. Verified load-bearing: restored to "0.6.0" it fails naming `control.wallet.coinById`, while the four pre-existing tests still pass. That direction also surfaced real drift the old suite could not see: `control.peers.ping` is served but unpublished. Listed in KNOWN_UNPUBLISHED alongside an honesty test, mirroring KNOWN_PREEXISTING_DRIFT, so the set can only shrink and publishing it stays a reviewed change rather than a blind one. The runtime design is unchanged (#2376): the node's `CONTROL_METHODS` remains the source of truth and the contract stays a TEST-only dependency pinning it. Refs: DIG-Network/dig_ecosystem#2392 Co-Authored-By: Claude <noreply@anthropic.com> * fix(wallet): bind a coin-id read to the coin that was asked for The fallback coin-id read returned whatever the source answered with, and that WAS the answer. The tier underneath takes the first coin state a peer returns and never hashes it, over an unauthenticated pool of DNS-discovered mainnet nodes, so one hostile peer could answer a lookup for X with any other real coin -- and a caller polling a mint reads "coin present" as "the mint landed", recording a DID that is not on chain. A coin id is self-certifying, so the substitution is detectable locally. A record for a different coin is now a read FAILURE: never this coin's record, and never absence. Matches the idiom the crate already uses in sage/singleton.rs and sage/options.rs. Refs dig_ecosystem#2392 Co-Authored-By: Claude <noreply@anthropic.com> * fix(control): consume the published coin-id rule and stop over-claiming absence Three of the gate findings on #201, none of which CI could see. - The coin-id well-formedness rule was a second copy of what dig-node-control-interface publishes as WalletCoinByIdParams::validated(). The dep is promoted from dev to a real dependency and the copy deleted; the node contributes only what the contract type cannot -- reading the field off an untyped params value and shaping this node's error. - The doc comments told consumers `coin: null` meant a chain was consulted and provably has no such coin. It does not: chia-query 0.6 returns one peer's empty coin-state list without consulting coinset. The wording now says what absence means today and points at dig_ecosystem#2456. - The CLI `height` helper had stolen `pinned`'s doc comment; moved back. Also: the coinById summary printed `0 mojos` for an unreadable amount (a zero that is really an unknown), and indexing the inner coin map panicked outright on a missing field. Both fixed, both pinned by tests. Refs dig_ecosystem#2392 Co-Authored-By: Claude <noreply@anthropic.com> * docs(spec): declare control.wallet.coinById in SPEC.md SPEC.md still gave a CLOSED enumeration of exactly three open control reads, so it told a reimplementer this method is token-gated -- a contract split, since the node serves it open. Adds the open-read enumeration, the method table row (params, result, the tier fields, the binding rule and what `coin: null` does and does not prove), the CLI verb, and the -32040/-32042/-32043 catalog rows. Refs dig_ecosystem#2392 Co-Authored-By: Claude <noreply@anthropic.com> * docs(wallet): the coin-id binding authenticates identity, not chain state The rustdoc said a positive answer 'carries no such caveat' because a coin id is self-certifying. The binding does prove WHICH coin a record describes; it proves nothing about whether that coin is on chain, when it was created, or whether it has been spent -- and created_height/spent_height are the entire reason this method exists. A peer that watched the mempool knows a pending coin's preimage, so it can report a created_height for a coin that never landed and still pass the binding check. That is the #2392 lie inverted and in the more dangerous direction: a false negative keeps polling, a false positive stops and records. SPEC.md was already accurate -- it claims only that a record must be bound to the id asked for. This corrects the rustdoc to match. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Michael Taylor <michael.d.taylor3d@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
Serves
control.wallet.coinById— a chain read of ONE coin record BY COIN ID, including spent coins.Closes the dig-node half of DIG-Network/dig_ecosystem#2392. The contract half is released:
dig-node-control-interface0.7.0 is on crates.io and declaresWalletCoinById, so release-first is satisfied.Why it exists
control.wallet.coinsanswers by address and unspent-only, somint_statuscan observe neither the DID coin nor the funding coin the mint spends. Without this read a DID mint can be pushed — real XCH leaves the wallet — and never observed.The unspent-only behaviour is a filter, not a source limit:
rpc.rs:865andrpc.rs:899both drop spent coins after the read, andchia-query0.6 already exposes an absence-awareget_coin_record_by_name_opt.The money lie this fixes
CoinsetFallback::coin_record_by_idmappedErr(_) => Ok(None), so a dropped connection, a coinset 500, a TLS failure and an exhausted rate limit all returned "the coin does not exist." Undermint_statusthat means a mint readsAwaitingforever and can never reportFailed.Absence and unreachability are now distinct end to end:
coin: null⇔Ok(None)⇔ the chain answered{"success":true,"coin_record":null}— a real, checked absence;Err(_)⇔ a catalogued error, never a null coin.Verified through every layer to the wire:
coin_by_id(rpc.rs:961) maps the fallback error toBalanceError::ReadFailedvia?and constructscoinonly from a realSome; the handler maps that throughwallet_read_errorto-32042. Nothing between re-collapses it — no.ok(), nounwrap_or_default, no catch-all arm.Blast radius checked
coin_record_by_idhas two production callers besides the new one, both incoins_by_ids(rpc.rs:1337,:1345), and their behaviour changes for the better. They readif let Some(fc) = …await?, so under the old mapping an unreachable chain silently omitted those coins and returned a truncated list as though it were complete — a caller asking "do these coins exist" would conclude they were absent. The error now propagates, so a partial answer can no longer masquerade as a complete one. This is the same defect class as the coinById fix and is fixed by the same change.Six
ChainFallbackimpls exist (CoinsetFallback,ChainTransport,EmptyFallback, and three test doubles); the trait signature is unchanged, so onlyCoinsetFallback's mapping moved.Design
Served from coinset via
chia-query0.6 at the fallback tier, deliberately bypassingrouting::routeand never consulting the local SQLite replica. That table is filled only from the node's own subscriptions gated bydb.derivation_exists, so a miss there means "this node doesn't watch that coin" — which is not absence, and answering from it would make the method an ownership oracle whose answer varied with node-local state.Hence
sourceis always"fallback",syncedalwaysfalse,peak_heightalwaysnull; callers bound confirmations viacontrol.wallet.peak.It is an open, token-less read (
is_open_control_read), likebalance/coins/peak: the argument is a coin id — public chain data, never a seed, key, address or signature. Liveness and the shared coinset rate limiter (#1957) both gate it, and the parameter is validated before any network read.§908 holds absolutely: this is a chain READ. The node signs nothing and no key material is anywhere on this path.
The green that proved nothing
The contract-conformance gate was pinned at
dig-node-control-interface = "0.6.0"— a version predating this method. Every assertion in that suite iterates the CONTRACT, so a method the pinned version does not declare is not tested loosely; it is not tested at all. The suite reported success having checked nothing about the method this PR adds, including its auth posture.Two changes, because the version alone fixes the instance and not the class:
"0.7"(a caret range), so the pin moves with the published catalog instead of silently narrowing what CI can see;control.*method the node SERVES is one the contract publishes — so a stale pin fails loudly instead of vacuously.Proved load-bearing rather than asserted: restored to
"0.6.0", the new test fails naming["control.wallet.coinById"]while all four pre-existing tests still pass.That direction also surfaced real drift the old suite structurally could not see:
control.peers.pingis served but unpublished, now held inKNOWN_UNPUBLISHEDwith an honesty test mirroringKNOWN_PREEXISTING_DRIFTso the set can only shrink. Tracked as DIG-Network/dig_ecosystem#2455 — publishing it is its own reviewed change, not something this gate does blind.How verified
cargo test -p dig-wallet -p dig-node-service— green.fallback.rs): a dead port for unreachable, a one-shot local JSON server for provable absence, and a known coin mapped through with itsspent_height. Both directions are pinned together on purpose — either alone is satisfiable by collapsing the other (always-error passes the first; always-Ok(None)passes the second).tests/server.rsexercises the validator and the pre-network ordering over the real HTTP surface.Compiling dig-node-control-interface v0.7.0), not a stale fingerprint.Version
Workspace 0.104.0,
dig-wallet0.15.0 — minor: a new control method is a compatible new capability, and the contract surface is additive. Thecoins_by_idschange is a bug fix within that.Closes DIG-Network/dig_ecosystem#2392