feat(rewards): four-state commitments reading (#3290) - #422
Conversation
SPEC.md §2.6 clause 5 requires the surface distinguish "nothing is recoverable, because nothing was committed" from "the commitments could not be read." Neither state had any representation in dig-app. Adds `CommitmentsReading` (Unreadable/NoDistributor/NothingCommitted/ Committed) derived in `wire.rs`, glued to a live chain read in `chain_read.rs::commitments_reading`, and rendered in `clawback.rs::commitments_reading_sentence` through four new fluent keys (all 14 catalogs, byte-exact English per dig-app#419 precedent). Per dig_ecosystem#3439 (confirmed against a real validator mid-review): this deliberately carries NO recoverable-share figure. An earlier revision derived `rewards_base_units * observed withdrawal_share_bps / 10_000`, which reports a nonzero recoverable amount for a commitment the chain will still refuse (the puzzle's own compiled-in `ASSERT_BEFORE_SECONDS_ABSOLUTE(epoch_start)`), so that derivation is left out until #3439 closes. Also corrects the wire.rs:81-87 doc comment, which stated SPEC bans any in-app computation of a recoverable share; it bans only a compiled-in one, not an observed/curried one (dig_ecosystem#3262 precedent) -- and separately notes #3439's not-yet-safe-to-render finding. Bumps the workspace version 15.14.8 -> 15.15.0 (new capability, not a patch). Refs DIG-Network/dig_ecosystem#3290 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Formatting changes only: line breaks on imports, function parameters, and match expressions per Rust formatting standards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Changed `crate::rewards::client::RewardsClient` to `super::client::RewardsClient` in the module doc for wire.rs — module doc intra-doc links resolve one level up, so the super:: path resolves correctly from wire.rs's perspective. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixed two more broken intra-doc links inside the commitment module's impl block: - super::super::client::RewardsClient -> super::super::super::client::RewardsClient - super::super::clawback::ClawbackAuthority -> super::super::super::clawback::ClawbackAuthority These links are nested three levels deep (wire::commitment::impl), so they need three super:: levels to resolve from wire module to rewards module. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rustdoc cannot resolve intra-doc links to private modules. Converted [\`commitment\`] references to plain backticks (\`commitment\`) throughout the RewardDistributorCommitment doc comments, since commitment is a private module inside wire.rs that is not part of the public API. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…_slots Changed super::wire::commitments_reading_from_slots to just commitments_reading_from_slots since the function is in the same wire module, not a sibling of wire. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CommittedSlot's doc comment links to RewardDistributorCommitment which is pub(crate) and not public documentation. Changed [\`RewardDistributorCommitment\`] to plain backticks (\`RewardDistributorCommitment\`) to avoid the rustdoc private_intra_doc_links warning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
Verdict: PASS
Head reviewed: bf9fa2a9cb12adc4379b95ed9aba3c8790ff2622
Revert-probe result (the main ask)
Ran the probe statically (no cold build available — see note at bottom) by tracing each assertion against the nearest wrong implementation rather than executing a mutation:
- Collapsing
NothingCommitted/Unreadableat the source (chain_read.rs:113-120,commitments_reading): theErr(err) => CommitmentsReading::Unreadable(err.to_string())arm is exercised directly bychain_read.rs's new testa_chain_source_error_makes_commitments_reading_unreadable(chain_read.rs:196-207), which assertsmatches!(reading, CommitmentsReading::Unreadable(_))off aMockChainSource::fail_with(...). If that arm were changed to returnNothingCommittedinstead, this test fails outright — real discrimination, not vacuous. This mirrors the #420 precedent (returned-error-variant proof). - Collapsing the render (
clawback.rs:334-353,commitments_reading_sentence):every_commitments_reading_variant_renders_distinct_nonempty_text(clawback.rs:663-687) builds all four variants and asserts pairwiseassert_ne!on every rendered string. Any two arms of thematchproducing the sameMsg/copy key collapses this test — again a real discriminator, not tautological.unreadable_never_renders_the_same_as_nothing_committedadditionally asserts the unreadable sentence never contains a bare'0'character (guards #3427's "never a bare zero" rule). - The weakest of the seven,
unreadable_and_nothing_committed_are_never_equal(wire.rs:522-527), is close to tautological given#[derive(PartialEq)]on a multi-variant enum — it would only fail if someone hand-wrote a brokenPartialEq, which nobody did here. Not blocking (the two tests above already carry the real weight), but flagging so it isn't cited as the vacuity-proof on its own.
Net: the requirement (§2.6 clause 5) is guarded by tests that would actually fail under the nearest wrong implementation, both at the point of construction (chain_read.rs) and at the point of render (clawback.rs). Not vacuous.
Checked against the brief
- Four states distinct at the rendered surface — traced
Unreadable → COMMITMENTS_UNREADABLE,NoDistributor → COMMITMENTS_NO_DISTRIBUTOR,NothingCommitted → COMMITMENTS_NOTHING_COMMITTED,Committed → COMMITMENTS_COMMITTED_SUMMARY(clawback.rs:334-353) — four distinct Fluent keys, confirmed non-colliding by the test above. Good. - No derived money figure —
CommittedSlot(wire.rs:352-360) carries onlyepoch_start,clawback_puzzle_hash,rewards_base_units. Norecoverable_base_units, nowithdrawal_share_bpsanywhere in the new code.commitments_reading_from_slots(wire.rs:433-455) does no division/multiplication on rewards. Confirmed clean — #3439 respected. wire.rsdoc correction — new text (wire.rs:82-105) correctly narrows the ban to a compiled-in bps, citesdig_ecosystem#3439and#3262(current_distributor_epoch_start, verified atclient.rs:118-127, matches the cited precedent), and explicitly states the crate "currently derives no recoverable-share figure anywhere, on purpose" — does not imply the observed value is safe to display today. The five trailing doc-link-fix commits (1ff0f1c6..bf9fa2a9) touch only link syntax ([\crate::...`]→ ``commitment``, path depth fixes) — diffed the substance across the full range and the#3439/#3262` prose is byte-identical to what the feature commit wrote; no softening.- i18n — all 14 catalogs carry all 4 new keys; confirmed byte-identical on the new lines via
md5sum(notgrep -c $'\r') acrossen/de/es/fr/hi/id/ja/ko/pt-BR/ru/tr/vi/zh-CN/zh-TW— all hash todef739ec2c61acb648a6fb58810e3902.filereports UTF-8 text, no BOM;od -con line 20 confirms plain ASCII "The commitments..." with no stray bytes.ALL_KEYSincopy.rs:305-308includes all four. - No
dig-rpc-protocoldependency — confirmed absent fromCargo.tomlandCargo.lockentirely (grep returns nothing).dig-account = "0.34"(Cargo.toml:156) anddig-rewards-coin = "0.9"(Cargo.toml:399) pins unmoved; diff shows only the version-bump hunks inCargo.lock. - Version —
15.14.8→15.15.0at workspace root only; grepped the whole tree for the stale literal, zero hits remaining.
Not re-raised (per brief)
ProvenClawback::open's latent wire-recoverable_base_unitsconsumer (clawback.rs:264/270/307) — still no production caller, this PR doesn't add one. Out of scope, tracked on #3439.- §12.5 clause 7 vs. clause 5 — confirmed not in tension;
wire.rs:378-386's own doc comment states the distinction correctly. no_display_can_hide_body_text_without_a_scrollbar— pre-existing flake, unrelated to this diff.
Not run
cargo build/cargo test -p dig-app-core --lib— started in background per the brief's allowance but did not finish compiling within this review's window (cold build); did not wait on it. All findings above are from static tracing of the diff plus a hand-traced re-derivation of the test assertions against the nearest wrong implementation, not from an executed test run. CI's own Test+coverage jobs were still running at review time (3 pending per the brief) and should be the authoritative green before merge.
No blocking findings. No inline threads opened — nothing here reaches the "fix before merge" bar.
loop-security — CHANGES-REQUIREDHead audited: Finding 1 (LIVE, blocking) — verbatim untrusted chain-source text rendered at a money decision
Err(err) => CommitmentsReading::Unreadable(err.to_string()),flows straight to CommitmentsReading::Unreadable(reason) => {
copy::COMMITMENTS_UNREADABLE.with(&Args::new().text("reason", reason.clone()))
}and into
This is the exact defect family this epic has already fixed twice (#3374's raw fragment,
pub enum PaneReading<T> {
...
/// The call was made and failed. Carries the reason for [`PaneNote::Unreachable`].
Unreachable(&'static str),— a closed, curated set of static reasons, never the backend's own string. Concrete exploit: a hostile or misbehaving chain-source peer returns a
Remedy direction (not prescriptive): map Cleared (tried to break, could not)
Not covered
|
…ments sentence (#3290) Red against the err.to_string() arm: the backend text flows verbatim into the rewards-commitments-unreadable placeable. Refs DIG-Network/dig_ecosystem#3290 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…290) loop-security: Unreadable carried err.to_string(), so a hostile chain-source peer could write unbounded Unicode (bidi, zero-width) into the rewards-commitments-unreadable sentence. Unreadable now holds a &'static str chosen by an exhaustive match on the RewardsError variant class (same shape as PaneReading::Unreachable). Fixtures updated; wire.rs doc now points current_distributor_epoch_start at client.rs. Refs DIG-Network/dig_ecosystem#3290 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
Verdict: PASS
Head reviewed: a27c7ab (full diff 56fba32..a27c7ab, weighted to bf9fa2a..a27c7ab). Read-only static audit; nothing built.
Finding 1 (chain-source text into the rendered sentence): CLOSED.
chain_read.rsunreadable_reasonmatches on the variant only (never Display, never the payload) and returns&'static strfrom a closed 3-string set. The_arm (RewardsError is non_exhaustive) falls to a static string, so a future variant cannot leak text.CommitmentsReading::Unreadable(&'static str)makes the type itself unable to carry runtime text.commitments_reading_sentenceis the only renderer; its other placeables arehumanize::agoandslots.len(), both numeric. No Debug formatting on any rendered path.- Bidi, zero-width and oversize text: none can reach the sentence; the placeable is one of three ASCII literals.
RED-first of 9b00fb8, proven statically. dig-rewards-coin 0.9.1 state.rs:78 builds ChainUnavailable(error.to_string()). error.rs:23 Display is "chain source could not answer: {0}" and error.rs:28 is "malformed chain data: {0}". dig-chainsource-interface Display includes the payload ("chain source transport error: {0}", "malformed chain data: {0}"). On the old arm Unreadable(err.to_string()) the sentence would contain "FUNDS-SAFE", U+202E and U+200B, so the test's !contains assertions fail. The test is not vacuous. It also asserts the starts_with prefix, which is correct (Fluent isolation marks sit around the placeable, after the prefix).
Other lenses.
- Secrets, custody, crypto: none touched.
commitments_reading_sentenceis read-only and carries no authority. I found no production caller ofcommitments_readingor the sentence outside these three files. - Boundary: slots are copied as raw typed fields; no recoverable-share figure is derived (#3439 stays out of scope).
- i18n: 14 locale files add 4 keys, with
ALL_KEYSupdated. The only placeable that could carry text isreason, and it is now static English text. - Dependencies: Cargo.toml only bumps the workspace version, 15.14.8 to 15.15.0.
NON-BLOCKING 1 (pre-existing, not reached by this diff; defence-in-depth, ticket it). chain_read.rs:84 Err(err) => Err(RewardsClientError(err.to_string())) carries the peer-controlled text in a pub String. This line is unchanged by the PR. I found no production consumer that renders it: pane.rs and create_card.rs do not use it, and client.rs only clones it in mocks. It becomes live the day a surface renders RewardsClientError.0. Recommend a ticket to make it closed-set or opaque, in the same shape as this fix.
NON-BLOCKING 2 (doc drift). copy.rs doc on COMMITMENTS_UNREADABLE still says "the underlying chain-read error, verbatim". That is now false and invites a regression. Reword it to "a static reason from a closed set".
Not covered: executed CI (9b00fb8's run was cancelled), runtime Fluent rendering of the 13 non-English locales (the placeable is identical in each).
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
Verdict: PASS
Head: a27c7ab (delta bf9fa2a..a27c7ab reviewed in full; whole PR 56fba32..a27c7ab re-checked). The earlier PASS at bf9fa2a is superseded by this one.
Delta
chain_read.rs:114-132: theUnreadablearm now callsunreadable_reason(&err). That function matches on the variant class only, with noDisplayand no payload. The_arm covers the#[non_exhaustive]RewardsError. The reason set is closed at 3 static strings.wire.rs:386-391andclawback.rs:341-343: the type is nowUnreadable(&'static str). The call site passes*reason. The type itself makes it impossible to carry aStringof peer text.wire.rs:90: the doc fixchain_read.rs->client.rsforcurrent_distributor_epoch_startis correct.
Traps checked
- Discrimination tests (
clawback.rs:639-670,wire.rs:553-558): they still discriminate. The property they assert is that the Unreadable template differs from the NothingCommitted template, and that every variant renders distinct non-empty text. That depends on the Fluent message ids (rewards-commitments-unreadablevs-nothing-committed), not on the reason string. The reason is just{ $reason }inside one template. They would still fail if the Unreadable arm were rendered with the NothingCommitted copy. - 9b00fb8 test (
chain_read.rs:235-266): it fails on the old arm, by static trace.ChainSourceError::Transport(hostile)is turned bydig-rewards-coinstate.rs:78intoChainUnavailable(error.to_string()). Olderr.to_string()then gave "chain source could not answer: ". That string went into{ $reason }, so the sentence containedFUNDS-SAFE, U+202E and U+200B, and the!containsasserts would fire. The test works at the decision level (reading, then sentence). It also asserts theThe commitments could not be read:prefix, so a blank sentence cannot satisfy it. It covers both the Transport and Malformed source errors. - i18n: the delta touches no
.ftl. I re-checked the 14 catalogs at a27c7ab. The fourrewards-commitments-*lines are byte-identical across all 14 (one md5), with the same keys and placeables ($reason,$observed_ago,$slot_count). - Scope:
recoverable_base_unitsu64 decoding is owned by dig_ecosystem#3446 and is out of scope. Not flagged.
Non-blocking notes (no thread)
state.rs:78folds every ChainSource error intoChainUnavailable, so the "malformed answer" reason is only reachable ifread_distributoritself returnsMalformed(slot bookkeeping). It is correct, just narrower than the name suggests.- Cargo.lock pins dig-rewards-coin 0.9.1. I read 0.10.0 from the local registry for the variants. Both have
ChainUnavailableandMalformed, and CI is green.
Ticket criteria
Four-state CommitmentsReading carries Unreadable vs NothingCommitted vs NoDistributor vs Committed distinctly (SPEC §2.6 clause 5). Unreadable is never rendered as nothing committed. The sentence is built from a closed set. CI is green at a27c7ab, including coverage >=80% and clippy. loop-security PASS at a27c7ab (2026-10-01T00:42:22Z).
Not run: no local build or test (static review, relying on green CI). Threads opened by me: 0. Unresolved: 0.
Adversarial gate (loop-decider, third leg) — REFUTEDHead read: Q1 — when does
|
…ributor (#3290) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
PASS - loop-security re-gate, head 189ac1d69e98330760c616c4ae6d28c9c05e8f50, delta a27c7ab..189ac1d (18 files, +45/-35).
- Outcomes:
read_distributorOk(Some) -> chain-backed NothingCommitted/Committed; Ok(None) -> Unreadable(UNCONFIRMED_ABSENCE_REASON); Err -> Unreadable(unreadable_reason). No path renders a peer-controlled absence as "nothing committed". Variant gone from the enum, so no match arm can reintroduce it. - Reasons: UNCONFIRMED_ABSENCE_REASON is a
&'static strconst; no error Display or peer text enters it. - Catalog:
rewards-commitments-no-distributorremoved from all 14 .ftl,COMMITMENTS_NO_DISTRIBUTORremoved from copy.rs and ALL_KEYS; repo grep finds no remaining reference (remaining "no distributor" hits are unrelated pre-existing store-pane tests).rewards-commitments-unreadablestill present with its$reasonplaceable. - No new derived money figure.
Out of scope, not flagged: ChainReadRewardsClient::distributor() collapse (dig_ecosystem#3447). Not re-run: cargo tests (read-only audit; implementer measured 186 pass).
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
PASS at head 189ac1d (delta a27c7ab..189ac1d).
- No NoDistributor / rewards-commitments-no-distributor left (variant, copy.rs const + ALL_KEYS, clawback arm, tests). Only a pre-existing, unrelated test name in store_rewards_tests.rs:164.
- 14 .ftl catalogs: identical key sets (verified by sorted key-hash, one distinct hash).
- Test
an_unwarranted_absent_launcher_reads_unreadable_not_no_distributordiscriminates: under the old mapping the reading is NoDistributor, so thematches!(Unreadable)assert fails; the string-replace comparison is a secondary check, not the sole proof. - Doc comments true, except one stale non-gating nit (copy.rs:74 "Four states"), posted inline and resolved.
- CI at 189ac1d: Test+coverage, Clippy, Rustfmt, headless, doc-link, version, lint, macOS confirmer pass; Windows confirmer was still pending at review time.
chain_read.rs ChainReadRewardsClient::distributor() collapse is out of scope (dig_ecosystem#3447).
Adversarial re-verify (loop-decider) — NOT-REFUTED at 189ac1dHead read:
|
Summary
SPEC.md (dig-rewards-coin) §2.6 clause 5 (byte-identical across v0.3.0/0.7.0/0.9.0):
Before this change, dig-app had no representation of either state: no commitment-collection type existed in
wire.rs/clawback.rs, andProvenClawback::openhad no production caller.What this adds
A three-state
CommitmentsReadingenum (crates/dig-app-core/src/rewards/wire.rs):Unreadable(reason)— the chain source could not answer at all.NothingCommitted { observed_at }— the distributor exists, read succeeded, zero outstanding commitment slots.Committed { slots, observed_at, epoch_seconds }— one or more outstanding commitment slots.Derived at
wire.rs::commitments_reading_from_slots(pure, unit-tested directly against hand-builtRewardDistributorCommitmentSlotValuevalues), glued to a live chain read atchain_read.rs::commitments_reading(thin wrapper overdig_rewards_coin::state::read_distributor), and rendered atclawback.rs::commitments_reading_sentencethrough three new fluent keys added to all 14 locale catalogs (English text copied byte-exact per dig-app#419 precedent — money copy is never machine-translated).A chain-read failure renders as "could not be read," never as a bare zero (dig_ecosystem#3427), and never collapses into "nothing committed" (dig_ecosystem#3290 itself).
Why there is no
NoDistributorstateAn earlier revision had a fourth state,
NoDistributor, forread_distributor(..) == Ok(None). The adversarial gate refuted it: the only production source,ControlChainSource::coin_record(chain/source.rs), answerscoinByIdfrom the fallback tier withsynced: falseon every reply, soOk(None)carries no warrant that the distributor does not exist. Rendering it as "no distributor exists" would be an unconfirmable claim on a money surface. It now maps toUnreadable("the chain source could not confirm the distributor exists"), a member of the closed static reason set. (ChainReadRewardsClient::distributor()has the same pre-existing defect and is filed separately.)dig_ecosystem#3439 — no recoverable-share figure in this PR
While this PR was in progress, dig_ecosystem#3439 (priority:1-high, confirmed against a real validator) found that
rewards_base_units * observed withdrawal_share_bps / 10_000— the formula this PR originally computed off the chain-observed, curried bps — reports a nonzero recoverable amount for a commitment the chain will still refuse: a clawback against an already-started epoch is rejected by the puzzle's own compiled-inASSERT_BEFORE_SECONDS_ABSOLUTE(epoch_start), which that arithmetic takes no account of. Offering a recoverable figure the chain will not pay is worse than offering none, at the moment a user is deciding about money (same rule as dig_ecosystem#3427).CommittedSlottherefore carries only the raw, chain-observed fields (epoch_start,clawback_puzzle_hash,rewards_base_units) — no derived recoverable amount is computed, carried or rendered anywhere in this PR. The nothing-committed vs could-not-be-read distinction SPEC §2.6 clause 5 asks for is still exactly what's built; only the recoverable-amount derivation is withheld pending #3439.The
wire.rs:81-87doc correctionThe existing doc comment said computing
recoverable_base_unitsin-app is "exactly the banned defect." That's imprecise: SPEC §2.6 clause 2 bans a compiled-in share (a literal or crate constant), not one derived from an observed, curried value read fresh off a chain walk —chain_read.rs'scurrent_distributor_epoch_startalready sets this precedent (dig_ecosystem#3262). The doc now states that distinction, and separately notes that an observed-and-curried bps is not automatically safe to render (dig_ecosystem#3439) — a different concern from clause 2's compiled-in ban.Version bump
Workspace root
Cargo.tomlversion:15.14.8→15.15.0(new capability, not a patch).Blast radius
Touched:
wire.rs,chain_read.rs,clawback.rs,copy.rs, all 14.ftlcatalogs, workspaceCargo.toml/Cargo.lock(version-only). No dependency pins changed (dig-account = "0.34",dig-rewards-coin = "0.9"untouched, already at the versions this ticket's brief specifies). Nodig-rpc-protocoldependency added. No clawback control painted, no unsigned path called,src/control.rsuntouched.Tests added
wire.rs:empty_slots_read_as_nothing_committed_not_unreadable,nonempty_slots_read_as_committed_with_no_recoverable_figure,unreadable_and_nothing_committed_are_never_equal.chain_read.rs:a_chain_source_error_makes_commitments_reading_unreadable,an_unwarranted_absent_launcher_reads_unreadable_not_no_distributor.clawback.rs:unreadable_never_renders_the_same_as_nothing_committed,every_commitments_reading_variant_renders_distinct_nonempty_text.Verified locally
cargo fmt --all -- --checkclean;cargo test -p dig-app-core --lib rewards::186 passed.cargo clippy -D warningsnot run locally; CI covers it.Out of scope, not chased here
epoch_indexdisplay-humanization gap inclawback.rs) — pre-existing, unaffected by this change.CommitmentsReadingsurface specifically (it constructs noRewardDistributorCommitment, so it is outside that type's forging boundary) — noted for awareness only, not widened here.Refs DIG-Network/dig_ecosystem#3290
🤖 Generated with Claude Code