Skip to content

fix(ui): read the spendable half of the wallet payload when refreshing your own balance - #261

Merged
argszero merged 1 commit into
mainfrom
fix/ops-self-topup-refresh-available
Sep 15, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/ops-self-topup-refresh-available

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

The ops self-top-up refresh read the wrong half of the wallet payload.

D.USER.balance is the client-side carrier of "how many spendable points do I have left" — the sidebar, the wallet view and the chat footer all print it. Its definition is given by the product:

// src/routes/wallet.rs
"available": balance + gift_balance   // gift points are spendable and expire — gift.rs really takes them away

Every writer of that fact honours it — loadSession (w.available), refreshWallet (Live.wallet.available), and the two clearly-labelled demo paths (relative moves) — except one:

// ui/js/app.js  inlineOpsTopup, self-refresh branch
try { const w = await api.get("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/api/wallet"); if (w) D.USER.balance = w.balance;  } catch (e) {}

w.balance is the permanent half. gift::ensure_daily_gift (GIFT_DAILY_AMOUNT = 1.0) runs on every authenticated request and on GET /api/wallet, regardless of role, so an ops/admin member always has gift_balance > 0. Topping up their own row therefore dropped the displayed spendable balance by the gift amount, it never self-heals (loadSession only runs when the session is built), and the branch also bypassed the wallet cache's single writer, leaving Live.wallet on the pre-top-up payload.

  1. Sign in as an ops/admin member (any authenticated account gets the daily gift).
  2. Ops → Users → top up your own row by 100 (start from balance=100, gift=1).
  3. The sidebar shows 200 (and bumps), the truth is 201; Live.wallet.available is still 101.

Related Issue

None — found by this task's Recon pass (axis C2145).

Changes

  • ui/js/app.js: the self-refresh branch now calls refreshWallet() — the single writer of both Live.wallet and D.USER.balance — instead of inventing a second source for the same fact.
  • src/state_gate.rs: new static gate the_session_balance_has_one_source_and_it_is_the_spendable_half, three rules with independent teeth: (1) every absolute D.USER.balance = … must take available (relative moves and the literal 0 error fallback aside); (2) the set of functions that fetch the wallet payload is exactly {loadSession, refreshWallet}; (3) Live.wallet still has exactly one writer. Rule 1 only inspects the first statement of the right-hand side — judging the whole line let the trailing read (D.fmt(D.USER.balance)) disguise w.balance as a relative assignment.
  • ui/index.html: cache-bust token for app.js.
  • ui/README.md: the convention (one source, and it is the spendable half).
  • No new i18n keys; no config/data-structure change.

Tests

  • cargo test289 passed (288 before), including the new gate and its synthetic self-tests.
  • cargo fmt --check clean; cargo clippy shows only the pre-existing protocol.rs:662 warning.
  • node --check ui/js/app.js clean.

New tests added. Static gate above, plus:

  • A/B on the gate (in-place mutations of ui/js/app.js, restored byte-exact; app.js md5 329a4285411c2644174454f48e1cccea before and after): unfixed / variant → rule 1 red; m1 (refreshWallet reads the permanent half) → rule 1 red; m_inline competitor (keep the extra fetch, just read w.available) → rule 2 red; m3 (a second writer of Live.wallet) → rule 3 red. Each rule has a mutation that only it rejects.
  • DOM probe (tmp/c2145_probe.js, jsdom, real index.html + the four real scripts, fetch stubbed and logged, drives the real navbar / ops tab / per-row button / inline form, expect declared per check): unfixed tree 9/9 as declared with exactly A2/A3 red (side="200" vs 201; Live.wallet.available = 101), fixed tree 9/9, competitor 8/9 — rejected by A3. Control legs: baseline equals available; topping up another row leaves your own number alone; a role=user session has no ops entry and issues no ops request. The probe pins the direction; the static gate pins the shape, because CI has no JS runner.

Checklist

  • Branch naming follows the convention (fix/…)
  • Commit message uses Conventional Commits
  • Single responsibility, minimal change

…g your own balance

The ops self-top-up refresh (`inlineOpsTopup`) fetched `/api/wallet` a second
time and took `w.balance` — the *permanent* half — where every other writer of
that fact takes `available = balance + gift_balance` (`wallet.rs`). Gift points
are spendable and expire (`gift.rs` really takes them away) and
`gift::ensure_daily_gift` runs on every authenticated request regardless of
role, so an ops member always has `gift_balance > 0`: topping up their own row
dropped the sidebar number by the gift amount (measured `balance=100 + gift=1`,
top-up +100 => screen 200, truth 201), it never self-heals (`loadSession` only
runs when the session is built), and the branch also bypassed the wallet
cache's single writer, leaving `Live.wallet` on the pre-top-up payload.

Fix: call `refreshWallet()` — the single writer of both `Live.wallet` and
`D.USER.balance` — instead of inventing a second source for the same fact.

New static gate
`state_gate::the_session_balance_has_one_source_and_it_is_the_spendable_half`,
three rules with independent teeth:
  1. every absolute `D.USER.balance = …` must take `available` (relative
     assignments and the literal `0` error fallback aside);
  2. the set of functions that *fetch* the wallet payload is exactly
     `{loadSession, refreshWallet}`;
  3. `Live.wallet` still has exactly one writer, `refreshWallet`.

Rule 1 only looks at the first statement of the right-hand side: the common
shape here is "assign, then immediately print it", and judging the whole line
let the trailing *read* (`D.fmt(D.USER.balance)`) disguise `w.balance` as a
relative assignment — on the unfixed tree rule 1 stayed silent.

A/B (in-place mutations of `ui/js/app.js`, restored byte-exact):
  - unfixed `/` variant (rule 1 + rule 2) and `m1` (refreshWallet reads the
    permanent half) => rule 1 red;
  - `m_inline` competitor (keep the extra fetch, just read `w.available`) =>
    rule 2 red;
  - `m3` (a second writer of `Live.wallet`) => rule 3 red.
DOM probe `tmp/c2145_probe.js` (jsdom, real page + 4 real scripts, `fetch`
stubbed and logged, drives the real navbar / ops tab / row button / inline
form): unfixed 9/9 as declared with exactly `A2`/`A3` red, fixed tree 9/9,
competitor 8/9 — rejected by `A3` (the cache is still stale). The probe pins
the direction; the static gate pins the shape, since CI has no JS runner.

`cargo test` 288 -> 289.
@argszero

Copy link
Copy Markdown
Owner Author

Self-review (this task runs as Committer with allow_self_merge: true; per this repo's own practice a self-review lands as a comment, since GitHub rejects --approve on your own PR).

Scope — one defect, one PR: the ops self-top-up branch read the permanent half of the wallet payload. 4 files, +246/−2, no production Rust changed (the only Rust change is a test-only static gate); no new i18n keys, no config or data-structure change.

What I verified

  • The fix is not "make the number match": it removes the second source entirely by calling refreshWallet(), the single writer of both Live.wallet and D.USER.balance. The cheaper competitor (keep the extra fetch, switch the field to w.available) makes the sidebar right but leaves the cache stale — the probe rejects it on A3, the gate rejects it on rule 2.
  • Each of the gate's three rules was shown to have an independent tooth by an in-place mutation of ui/js/app.js (restored byte-exact; app.js md5 329a4285411c2644174454f48e1cccea before and after, git diff --stat unchanged):
    • unfixed / variant and m1 (refreshWallet reads Live.wallet.balance) → rule 1;
    • m_inline competitor → rule 2;
    • m3 (a second writer of Live.wallet) → rule 3.
  • The probe (tmp/c2145_probe.js) declares its expect per leg, so "designed to be red" cannot be confused with "acceptance failed": unfixed tree 9/9 as declared with exactly A2/A3 red, fixed tree 9/9.

Honest limitations

  • The gate is lexical: it proves the right-hand side mentions available and that there are exactly two functions fetching the payload; it does not prove the arithmetic. A hypothetical + balance - gift_balance would pass. Arithmetic is covered by the probe, which only runs locally — CI has no JS runner, so this split is the same trade-off already recorded for C2142.
  • Rule 1 needed sharpening during this round: judging the whole line let the trailing read D.fmt(D.USER.balance) (the "assign, then print it" shape) disguise w.balance as a relative assignment. It now inspects only the first statement; the synthetic self-test covers that shape, and on the unfixed tree rule 1 now fires before rule 2 does.
  • Not touched on purpose: #wallet-forever reading w.balance is correct — that cell is explicitly labelled "permanent points". GET /api/ops/users returns both fields while the ops member table prints only u.balance under a caption reading "balance (points)"; that display-wording ambiguity is a separate question and is not decided here.

Awaiting CI.

@argszero
argszero merged commit 13898d6 into main Sep 15, 2026
1 check passed
@argszero
argszero deleted the fix/ops-self-topup-refresh-available branch September 15, 2026 03:57
@argszero argszero mentioned this pull request Sep 21, 2026
12 tasks
argszero added a commit that referenced this pull request Sep 21, 2026
Ships the 10 PRs merged since v0.7.25 (#261-#270). No schema change, no config
change, so the deployment-side config.toml needs no edit.

One fact, one source / display must equal what it consumes (frontend, 6 places)
- #261 read the spendable half of the wallet payload when refreshing your own
  balance; #262 the transactions payload signature covers the time range, with
  one reload trigger shared by the four controls; #263 the settings controls are
  either wired or explicitly inert; #265 the re-list outcome comes from the same
  entry as its action; #268 the sharing form shows a plan's label, not its
  config id; #269 the ops card stops reading a key's status count as a health
  verdict.

i18n reachability
- #266 every pack key must reach a consumer (the gate), and #270 drops the 23
  keys that gate proved unreachable: ZH/EN key count 811 -> 788, sunset list
  59 -> 36.

Gateway
- #267 applies the body limit where axum actually reads it (per-route
  DefaultBodyLimit, 8 MiB on the three gateway routes; unauthenticated
  endpoints keep the 2 MiB default). This is the application half of rant
  2026-09-18T09:14:18. It also corrects the false v0.7.10 "raised to 70MB"
  CHANGELOG line, which described installing a layer rather than raising a limit.

- Cargo.toml / Cargo.lock: 0.7.25 -> 0.7.26.
- CHANGELOG.md: v0.7.26 entry plus the v0.7.10 correction.
- ui/index.html cache-bust left as-is: this release touches no UI file; the live
  values are app.js 20260921-2 / i18n.js 20260921-2.

cargo test 302 passed; cargo fmt --check clean; clippy -D warnings clean.
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