Spike: refresh button, and what the mempool is doing to each row - #302
Conversation
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
|
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
Deep review pass — four fixed, three flagged for the fiddle listFixed in
Flagged, not fixed — these are display-policy calls that belong to the fiddle session:
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
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
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 plainissuance, and we don't guess intent.useAssetDetails. The node does not protect against this:get_balancereads 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.BalanceHeadersites show spendable.The safety spine underneath
DEBIT/CREDITevents (/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.quantity_normalized— no divisibility assumed anywhere (a wrongtrueis a 1e8 error in a figure that gates spending).tracksPendingLedgerDebits) since its balance already nets the mempool viafunded − spent + memFunded − memSpent.Review guide — what to click
Known gaps, deliberate
countUnreadableis exported+tested but unused — kept for a future "N unreadable events" detail line; delete if you'd rather not carry it.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