Skip to content

feat(control): serve control.wallet.coinSpend and control.wallet.coinsByParent - #211

Merged
MichaelTaylor3d merged 7 commits into
mainfrom
feat/2572-serve-chain-reads
Aug 10, 2026
Merged

feat(control): serve control.wallet.coinSpend and control.wallet.coinsByParent#211
MichaelTaylor3d merged 7 commits into
mainfrom
feat/2572-serve-chain-reads

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

DO NOT MERGE — DRAFT pending the gate round (§2.4a).

Closes DIG-Network/dig_ecosystem#2572. Part of the dig-profile epic #2398.

Serves two OPEN control reads so a client can implement a full ChainSource over the node's
control plane, which is what makes a dig-profile mint possible through the node instead of via
third-party HTTPS.

  • control.wallet.coinSpend — the spend that spent a coin (coin + puzzle reveal + solution),
    named by that coin's own id. spend: null means a chain answered and holds no spend.
  • control.wallet.coinsByParent — one PAGE of the direct children a coin's spend created.
    One hop, never a walk.

Both are open for the same reason control.wallet.coinById is: the caller NAMES the subject with a
public coin id, so the answer discloses no node-to-address association.

Release-first

dig-node-control-interface 0.10.0 is published; the pin at crates/dig-node-service/Cargo.toml
moved "0.9" to "0.10" and Cargo.lock resolves it from the registry. No git dependency remains.

The pin is load-bearing rather than cosmetic: with it in place, removing either method from
is_open_control_read now turns the_node_and_the_contract_agree_on_the_token_less_wallet_surface
RED (verified, then restored). That failure was structurally unreachable at "0.9" — the
conformance suite iterates the CONTRACT, so a method the pinned version had never heard of could not
be compared at all.

How each answer is bound to the request

The tier underneath answers from one unauthenticated, DNS-discovered peer that never hashes what it
forwards, so every answer is checked locally before it is served:

Check Where On failure
Returned coin's id recomputed (SHA256(parent || puzzle_hash || amount)) and compared to the id asked for fallback.rs coin_spend WALLET_READ_FAILED — never that coin's spend, never absence
puzzle_reveal tree-hashed and compared to the spent coin's own puzzle_hash fallback.rs verified_reveal WALLET_READ_FAILED, and identically for a reveal that will not PARSE (fail closed)
Every child's parent_coin_info equals the requested parent fallback.rs coin_records_by_parent fails the WHOLE page, never a silent filter — a filtered page is a lineage with an invisible hole
A spend must agree with the coin record: the record exists AND calls the coin spent rpc.rs coin_spend WALLET_READ_FAILED rather than a spend carrying an invented or absent spent_height

The two spend bindings are independent and catch different lies: the id check says WHICH coin the
answer describes, the tree hash says the program really is that coin's puzzle. A substitution passing
one still fails the other — the reveal test's fixture has a genuine coin id precisely so only the
hash comparison can reject it.

No error is ever collapsed into an absence. Three values throughout: a value, a legitimate
absent, an error. spend: null tells a caller the coin is unspent — which is the go-ahead to spend
it — so a read failure wearing that shape invites a double-spend; an empty child page reads as that
spend created nothing
and ends a lineage walk. The bad precedent in this repo,
ChiaQueryLineage::parent_spend swallowing every error as Ok(None), is deliberately not copied.

Paging and the truncation signal

The contract paged this read, so the node does too rather than capping it: after_coin_id +
limit (1..=1000, default 100), children ASCENDING by coin_id, sorted node-side because the tier
underneath merges peer and coinset answers and promises no order.

  • complete derives from what REMAINS, never from whether the page filled. The naive
    coins.len() < limit agrees on every input except a child count that is an exact multiple of the
    page size, where it declares a truncated page whole.
    complete_distinguishes_a_full_page_from_the_whole_child_set uses 4 children at a limit of 2 for
    exactly that reason; a 5-and-2 fixture would pass under both derivations and prove nothing.
  • {complete: false, cursor: null} is unrepresentable — the contract permits it by omission and
    it would leave a caller looping forever or silently restarting. Asserted across every page of a
    real 7-child walk, not one sampled page.
  • An out-of-range limit is refused, never clamped, per the contract: the page boundary is what
    the caller resumes from. Pinned from BOTH sides — 1000 passes, 1001 and 0 fail.

Both reads consult the shared fallback_rate bucket (#1957).

Blast radius

gitnexus is not indexed for this worktree, so the radius was derived with ripgrep + direct reads —
the §2.0 sanctioned fallback, stated here because the obligation is the analysis, not the tool.

  • wallet_coin_id_param — the one symbol with existing callers that this PR refactors. Callers:
    1 production (wallet_coin_by_id) + 3 test sites, all inside control.rs. Behaviour preserved: it
    now delegates to a shared coin_id_field, and its three existing tests still pass unchanged.
  • ChainFallback — two methods added with NO default, so all six implementors must decide
    explicitly (CoinsetFallback, ChainTransport, EmptyFallback, and three test doubles). A
    defaulted method is what would let a future double silently answer a money-critical read.
  • Everything else is additive: new methods, new result types, new registration entries.
  • Untouched: every existing control method, the auth gate, the routing layer, all custody paths.

§908 holds: both are pure chain reads. One hex string in; no address, no key, no seed, no
signature. A puzzle reveal is a program the chain already published.

Registration — 13 points, derived from the enforcing tests

OWNED_CONTROL_METHODS · CONTROL_METHODS · is_open_control_read (+ its written-out expected
vector) · the dispatch_owned arm · the handler · the param validator · the wire mapper ·
ControlAction · method() · wire_params() · cli_covered_control_methods() · the CLI human
summary · clap WalletCommand + its wallet_action() arm · SPEC.md (method table, open-read list,
CLI verb table, error table). Contract pin + version bump on top.

One correction to the brief's checklist: coinsByParent could not reuse a plain single-coin-id
validator, because the published params carry three fields, not one.

The CLI-parity gate had a real hole: every_wallet_control_method_is_reachable_from_a_real_command_line
proves each LISTED verb parses and says nothing about one never listed — control.wallet.coinById
and control.wallet.syncStatus had both been missing from it for months while every gate stayed
green. Its table is now hoisted out and a second test asserts the table COVERS the declared wallet
surface, so a new method fails until a real command line exercises its verb.

Evidence

  • cargo test --workspacegreen, exit 0 (811 + 347 + the integration suites; 0 failures).
  • cargo clippy --workspace --all-targets — clean; cargo fmt --all applied.
  • 33 new tests, each documenting the defect it catches.
  • Load-bearing proofs (revert only that fix, confirm RED, restore — committed first): coin-id
    binding · reveal tree-hash · foreign-child refusal · the naive complete derivation · the
    ascending sort · the CLI-table completeness gate · the conformance gate at the 0.10 pin. The first
    attempt at the coin-id proof did NOT apply its own edit and passed vacuously; every proof after it
    chains a grep against the reverted line, so a no-op edit cannot read as a load-bearing test.

Two findings reported, not fixed

  1. WS/HTTP auth asymmetry (pre-existing, out of scope). ws_dispatch (server.rs:1305)
    token-gates every control.* without consulting is_open_control_read, while the HTTP path
    checks it at server.rs:926. Both new reads are therefore open over HTTP and gated over WS.
    Confirmed still present.
  2. every_open_read_that_leaves_the_node_passes_the_same_rate_bound ENUMERATES rather than
    derives.
    A new read that skipped the bucket would be absent from it, not caught by it. The two
    new reads were added to it and the limitation is now stated in the test itself; deriving the list
    is not possible today because these are inherent methods, not a trait a test could iterate.

MichaelTaylor3d and others added 7 commits August 10, 2026 09:39
…sByParent

Both are OPEN, caller-addressed chain reads: each names its subject with a
public coin id and discloses no node-to-address association.

Implementation notes:
- every answer is bound to the request (coin id recomputed, puzzle reveal
  tree-hash verified, every child's parent_coin_info asserted)
- an unreachable chain is never collapsed into an absence
- coinsByParent is paged per the published contract; complete is derived from
  what REMAINS, never from whether the page filled

Refs dig_ecosystem#2572
…r the two chain reads

Refs dig_ecosystem#2572
… set

Salvage commit: this work was complete and compiling in the worktree but
uncommitted when the lane stalled, and only pushed state survives.

Adds the control-plane wire-shape tests for `control.wallet.coinSpend` and
`control.wallet.coinsByParent`, and extends the written-out open-read list
with both. The list stays a literal rather than a derivation from
`is_open_read`, so it pins the SET and cannot be satisfied by the code it
is meant to constrain.

Refs #2572

Co-Authored-By: Claude <noreply@anthropic.com>
…te before the network

Also gate the FallbackCoinSpend import behind cfg(test): only the test doubles
name the type, and clippy runs with -D warnings.

Refs dig_ecosystem#2572
…g contract

feat: bump to 0.110.0 -- additive control methods, no existing behaviour changed

Refs dig_ecosystem#2572
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review August 10, 2026 18:25
@MichaelTaylor3d
MichaelTaylor3d merged commit d3c9d4e into main Aug 10, 2026
16 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the feat/2572-serve-chain-reads branch August 10, 2026 18:26
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