Skip to content

Spike: refresh button, and what the mempool is doing to each row - #302

Merged
droplister merged 6 commits into
mainfrom
spike/balance-refresh
Aug 12, 2026
Merged

Spike: refresh button, and what the mempool is doing to each row#302
droplister merged 6 commits into
mainfrom
spike/balance-refresh

Conversation

@droplister

@droplister droplister commented Aug 12, 2026

Copy link
Copy Markdown
Member

Started as a refresh button; grew — with explicit decisions along the way — into the wallet's answer to "what does the number next to an asset mean". The rule this PR lands: everywhere, that number is what you can spend right now.

What's in it, in commit order

  1. Refresh button replaces the pinned-assets shortcut on the home header (pinning stays at Settings → Pinned Assets). Clears both balance caches (Counterparty response cache + the separate BTC cache — clearing one repaints the same stale number) then reloads the tab you're looking at. Per-tab counters, so it doesn't reload the two lists you aren't viewing; a counter not a flag, so pressing twice is two refreshes.
  2. Pending status verbs on balance/UTXO cards — Sending, Attaching, Detaching, Moving, Minting — bottom-right, italic. On UTXO cards the verb replaces the txid while active. Words come only from core's own ledger actions; an operation and its fee collapse to one word; unknown actions show "Pending" rather than vanishing. "Locking" deliberately absent — core reports a lock as plain issuance, and we don't guess intent.
  3. Max offers spendable (confirmed − mempool-committed) in every form: send, attach, destroy, pool deposit both sides, dispenser escrow, swap, order both sides, dividend per-unit, and fairmint max-lots (a second missed Max found in the final sweep — XCP already committed could buy lots twice). Wired once in useAssetDetails. The node does not protect against this: get_balance reads confirmed ledger and compose-time validation doesn't check sufficiency, so the wallet is the only place the double-spend-a-pending-balance mistake can be caught.
  4. Displays follow: the balance list, balance detail page, asset overview, and all eight BalanceHeader sites show spendable.
  5. A signed inline note explains the difference:
    Balance: 9.00000000 (−1 pending)      ← leaving; already excluded from the figure
    Balance: 9.00000000 (+5 incoming)     ← arriving; never included until confirmed
    Balance: 10.00000000 (pending amount unknown)
    
    Signed because unsigned was ambiguous ("9 (1 pending)" reads as about-to-be-10 as easily as 8). The plus note is the answer to the original "tx incoming?" complaint, at the spot people stare while waiting.

The safety spine underneath

  • Pending figures come from core's own mempool DEBIT/CREDIT events (/v2/addresses/mempool) — no re-derivation of consensus debit rules. Core deletes mempool rows atomically with block-parse, so confirmed+pending double-counting cannot be observed.
  • Amounts use core's quantity_normalizedno divisibility assumed anywhere (a wrong true is a 1e8 error in a figure that gates spending).
  • A total missing a term is unknown, not smaller: one unreadable debit nulls the whole figure and nothing is subtracted (a partial subtraction would let the overspend through while looking handled). Unknown outgoing keeps its note; unknown incoming shows nothing (good news needs no disclaimer).
  • Every unknown errs toward offering less; pending > confirmed (impossible per ledger ⇒ reads disagree) offers zero, never negative; missing spendable degrades to confirmed — the pre-feature behaviour — never to a Max of 0.
  • Ownership filtering is positive (the mempool endpoint LIKE-matches, returning a superset); BTC is excluded by name (tracksPendingLedgerDebits) since its balance already nets the mempool via funded − spent + memFunded − memSpent.

Review guide — what to click

  • Home: press refresh on each tab; watch the spinner stop; switch tabs after refreshing.
  • With something in the mempool: card verb, header note, Max on the send form, fairmint lots.
  • The judgment calls to veto: the −/+ wording and size, the verb-replaces-txid choice on UTXO cards, and whether "(pending amount unknown)" earns its place.

Known gaps, deliberate

  • Compose forms read pending through the ordinary 60s cache; only home-refresh clears it early.
  • countUnreadable is exported+tested but unused — kept for a future "N unreadable events" detail line; delete if you'd rather not carry it.
  • Everything here is test-verified but this branch has never been browser-exercised — that's this review.

Full suite, tsc, biome clean; oxlint at its 17-warning baseline. Branch is merged current with main (includes #303/#304).

https://claude.ai/code/session_01QJS9Bj6uAMoYPATvfr6GZ1

A spike into what @davesta hit: mint 400,000 STARMONEY for 4 XCP, and the 4 XCP
is still sitting in your balance looking spendable until the mint confirms.
Compose a DEX order against the same coins in the meantime and whichever
confirms second fails.

The thing not to do here is re-derive Counterparty's debit rules in the wallet.
Which message types debit what, in what order, under which activations, is
consensus logic; a second implementation of it would be wrong eventually and
wrong silently. It turns out not to be necessary: core parses mempool
transactions and emits the same DEBIT and CREDIT events it emits for confirmed
ones, with action/calling_function naming the reason. So the wallet asks rather
than derives, and a fairmint's XCP debit arrives already labelled.

Verified against a live node and against core's source:

  - /v2/addresses/mempool returns those events per address. Confirmed live: a
    pending issuance shows its CREDIT of the new asset and its DEBIT of the XCP
    fee, both with readable reasons.
  - Double counting is core's problem and core handles it. When a block is
    parsed, mempool rows for the transactions it contained are deleted in the
    same database transaction that marks the block parsed, specifically so an API
    reader cannot see a transaction as both confirmed and pending. There is no
    window to defend against.
  - That endpoint matches addresses with SQL LIKE against a joined column, so its
    results are a superset. Every event is filtered on its own params.address
    here; without that a neighbour's debit could be subtracted from your balance.
  - Quantities are unsigned 64-bit and stay bigint throughout. Rounding one
    through a double is the precise failure this exists to prevent.

Two display decisions are baked in and are the part worth arguing with. The
confirmed balance is never adjusted -- spendable is a separate figure -- because
a headline number that quietly changes meaning is its own defect. And pending
debits exceeding the confirmed balance is reported as a disagreement rather than
rendered as a negative: the ledger says it cannot happen, so seeing it means the
two reads disagree, and a confident negative would be a lie.

Pure and tested without a browser; no UI yet, deliberately.

Claude-Session: https://claude.ai/code/session_01QJS9Bj6uAMoYPATvfr6GZ1
Two halves of the same complaint. Nothing tells you a balance already has
something in flight against it, and there is nothing on the screen to press while
you wait.

A refresh button replaces the pinned-assets shortcut in the balances header, and
pinning moves to Settings, where it already lived. Pressing it drops what is
cached for the address and reloads. Clearing is the whole job: two independent
caches feed this screen -- the API client's response cache for Counterparty
balances, and core/bitcoin/balance's own keyed cache for BTC -- and a reload
without dropping both repaints the same stale numbers inside their TTL. BTC is
the row someone waiting on a deposit is staring at, and it is the one served by
the second cache. That pairing is now one named operation so the next cache added
has an obvious place to join.

The header is shared by all three tabs and all three lists stay mounted behind
display:none, so the counter is per tab. One shared counter would reload the two
lists you are not looking at as well; a single counter handed only to the active
list would read as a change to the others every time you switched tabs. A counter
rather than a flag, so pressing twice is two refreshes -- a flag swallows the
second press while the first is in flight, which is when people press again.

Beside that, each row now says what is happening to it: Sending, Attaching,
Detaching, Moving, Minting. Bottom right, italic, quiet. On a UTXO card it takes
the place of the transaction id, because while an output is being detached that
is the more useful of the two and the id is one tap away.

The word comes from core's own ledger action, never from inference. An operation
and its fee collapse to one word rather than dissolving into a generic because
two reasons differed, and an action the table does not know shows as "Pending"
rather than vanishing. "Locking" is deliberately absent: core reports a lock as
plain issuance, so that word would be guessing at intent the ledger never stated.

Read when the list loads and again on refresh. No polling, no background work, no
permission, nothing written to disk -- a status only has to be right while someone
is looking at it, and by then they are. A node that cannot answer leaves the rows
unannotated rather than putting an error banner on the home screen.

Not done here, and it needs its own pass: Max still offers the confirmed balance.
Core will compose a send that spends coins already committed in the mempool --
get_balance reads the confirmed ledger and compose-time validation does not check
sufficiency at all -- so the wallet is the only thing that can stop it, and Max
should offer what is actually spendable while the card keeps showing what is held.

Claude-Session: https://claude.ai/code/session_01QJS9Bj6uAMoYPATvfr6GZ1
@droplister

Copy link
Copy Markdown
Member Author

Full suite on this branch: 4431 passed, 49 skipped, across 258 files (emulator suite excluded — it needs a Trezor emulator on localhost:9001).

That's the count I owed the PR body. Earlier in the session I called a run green off an exit code alone, without seeing the summary — this is the run I actually read.

Hold 10 XCP with 1 committed in the mempool and Max offered 10. The node will
not stop it: get_balance reads the confirmed ledger and a send's compose-time
validation does not check sufficiency at all -- that happens at consensus, by
which point the losing transaction is broadcast and its fee spent. The wallet is
the only place this can be caught.

So every form that offers an amount now offers the confirmed balance less what
the mempool has committed: send, attach, destroy, both sides of a pool deposit,
dispenser escrow, swap, both sides of an order, and the dividend per-unit
ceiling. Wired once, in useAssetDetails, rather than eight times in eight forms.

The balance list shows the same figure, with the italic status beside it saying
why it is lower. The alternative -- confirmed on the card, spendable in the form
-- meant two screens disagreeing about the same asset, and one number that
differs from an explorer but is explained beats two that differ from each other.

What makes the arithmetic trustworthy:

  - No divisibility is assumed anywhere. The pending figure is core's own
    quantity_normalized, computed against its ledger. Converting base units here
    would lean on a divisibility flag that defaults to true when unknown, and a
    wrong true is a factor-of-1e8 error in the number that gates spending.
  - A total missing a term is unknown, not smaller. One unreadable debit poisons
    the whole figure to null and nothing is subtracted -- subtracting a partial
    total understates what is committed and lets the overspend through while
    looking handled.
  - Every unknown errs toward offering less. Under-offering costs nothing (send
    again later); over-offering broadcasts a transaction that fails at consensus
    and burns its fee. Pending above confirmed -- impossible per the ledger, so
    the two reads disagree -- offers zero, never a negative.
  - A missing spendable figure degrades to the confirmed balance, the behaviour
    this had before pending existed. The first draft degraded to zero, which a
    form test caught: a Max of nothing is a worse failure than a stale Max.
  - BigNumber renders two satoshis as "2e-8"; amounts are forced to plain
    decimal before they reach an input.

BTC is deliberately excluded, and needs no equivalent: its balance already nets
the mempool in. The blockstream/mempool.space parser computes funded - spent +
memFunded - memSpent, where mempool spent is the full inputs and mempool funded
includes the change returning -- so an unconfirmed send already subtracts
exactly payment plus fee. Subtracting Counterparty DEBITs from that figure would
double-count across two unrelated systems, and tracksPendingLedgerDebits says
so by name.

Claude-Session: https://claude.ai/code/session_01QJS9Bj6uAMoYPATvfr6GZ1
Reviewing the branch as a whole -- it grew through several mid-flight
redirections, which is where seams hide -- found four things worth fixing and
two worth writing down instead.

onRefreshed said "when a requested refresh has finished" and fired on every
load completion: mount, address changes, pinned-asset changes. The current
caller tolerated the lie (stopping a spinner that was not running is a no-op),
which is exactly how contract drift survives until a second caller trusts the
words. It also never fired on the loaders' early-return paths, so a refresh
racing the address going away stranded the spinner. All three lists now record
that a refresh was asked for and settle it on every exit, including the paths
that load nothing.

pendingByUtxo excluded events whose utxo_address was present and different,
which admits any event that simply omits the field -- and the endpoint returns
a LIKE-matched superset, so strangers' events do arrive. Ownership is now
positive: an event counts only when it names this address in utxo_address or
address. Regression test included for the omitted-field case.

The delta-to-label reduction existed twice, in the hook and inline in
BalanceList; the copies agreed today and would have drifted the first time one
was edited. One definition now (labelsFromDeltas).

PendingStatus was a live region per row. Every row can carry one and a single
refresh updates them all, so a list of role="status" elements announces each
change over the last -- noise, not information. Plain span; the text still
reads with its row.

Claude-Session: https://claude.ai/code/session_01QJS9Bj6uAMoYPATvfr6GZ1
@droplister

Copy link
Copy Markdown
Member Author

Deep review pass — four fixed, three flagged for the fiddle list

Fixed in 05f48fac:

  1. onRefreshed lied about its contract. Documented as "when a requested refresh has finished", it fired on every load completion — mount, address change, pinned-asset change. The current caller tolerated it (stopping a spinner that isn't running is a no-op), which is exactly how contract drift survives until a second caller trusts the words. It also never fired on the loaders' early-return paths, so a refresh racing the address going away stranded the spinner. All three lists now record the request and settle it on every exit.

  2. pendingByUtxo ownership was negative-only. It excluded events whose utxo_address was present-and-different — admitting any event that simply omits the field, and the endpoint returns a LIKE-matched superset, so strangers' events do arrive. Ownership is now positive (utxo_address === address || address === address), with a regression test for the omitted-field case.

  3. Label derivation existed twice (hook + inline in BalanceList). The copies agreed today and would have drifted on first edit. One definition: labelsFromDeltas.

  4. A live region per row. One refresh updates every row's status at once; N role="status" elements announce over each other. Plain span now — the text still reads with its row.

Flagged, not fixed — these are display-policy calls that belong to the fiddle session:

  • Cross-page disagreement remains: the balance list now shows spendable, but the balance detail page and the BalanceHeader on compose forms still show confirmed. Same asset, two numbers, one tap apart. Extending "the number means what you can spend" to those surfaces is a policy decision about headline numbers I didn't want to make unilaterally.
  • The send form still has no "N pending" line. Max offers 9 while the header says 10, and nothing on the form states the difference. countUnreadable (exported, tested, unused) was built for exactly this line and is dead until it exists.
  • Compose-form staleness window: forms read pending through the ordinary 60s cache; only the home-screen refresh clears it. Acceptable for a spike; worth knowing while fiddling.

Verification: 643 tests across balances/hooks/domain components; tsc, biome clean; oxlint at the 17-warning baseline. CI was 38/38 green before this push and will re-run on it.

The decision this lands: the number next to an asset means the same thing on
every screen -- what you can spend right now. The balance list already said it;
now the balance detail page, the asset overview, and every compose form header
say it too. The sweep also caught a Max the earlier pass missed: max fairmint
lots are derived from the XCP balance, which was still the confirmed figure, so
XCP already committed in the mempool could buy lots twice.

Beside the number, BalanceHeader gains a small parenthetical -- inline, not a
second line, because the header has no vertical space to spend:

  Balance: 9.00000000 (-1 pending)
  Balance: 9.00000000 (+5 incoming)
  Balance: 10.00000000 (pending amount unknown)

Signed, because unsigned was genuinely ambiguous: "9 (1 pending)" reads as
about-to-be-10 as easily as about-to-be-8. Minus means leaving and already
excluded from the figure; plus means arriving and not yet included --
unconfirmed money in is never part of the spendable number, and the plus note
is what answers "is it here yet", which is where this whole feature started.

Incoming display totals ride the same null-poisoning rule as outgoing: a total
missing a term is unknown, not smaller. Unknown incoming shows nothing rather
than a warning -- good news does not need an unknown-state disclaimer -- while
unknown outgoing keeps its note, because there the figure it qualifies gates
spending.

Claude-Session: https://claude.ai/code/session_01QJS9Bj6uAMoYPATvfr6GZ1
@droplister

Copy link
Copy Markdown
Member Author

Full suite on the merged branch (current with main, includes #303/#304): 4478 passed, 49 skipped, across 261 files, emulator suite excluded as always. Summary read from captured output. Branch is built, gates clean, ready for browser review.

@droplister
droplister marked this pull request as ready for review August 12, 2026 18:43
@droplister
droplister merged commit fead546 into main Aug 12, 2026
38 checks passed
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