From fa6fe32ef979b9e612f89db5c13edcd8402a902a Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 17 Aug 2026 08:54:12 -0400 Subject: [PATCH] feat(wallet): a balance that isn't loaded yet says so, in pixels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unloaded balance rendered as `0`. Zero is not a loading state — it is a number, and it is the one number that frightens people. Someone opening the dashboard while the RPCs were still in flight was told, in the wallet's own typeface, that their money was gone. There is no formatting fix for that. The fix is to stop claiming a figure we do not have, so `null` now means "not known yet" and `0` means "none", and the two are kept apart end to end: the refs start at null, a rail becomes a number only when its call actually succeeds, and a snapshot key that was never written stays unknown instead of becoming a zero. In place of the figure, a small dot-matrix scans in the rail's own colour. It inherits currentColor, so on-chain shimmers orange, Lightning yellow, Cashu purple, Fedimint blue and Ark teal with no colour table to keep in sync — and it is sized to the figure it stands in for, so nothing jumps when the real number lands. It carries role="status" and names what it is waiting for; a shimmering box with no text is nothing at all to a screen reader. Two consequences worth stating. The total is withheld until every rail that makes it up is known — summing nulls as zero would show a total *lower* than the rails beneath it, which is worse than showing nothing because it looks authoritative. And the Ark row stays hidden while its balance is unknown, since "unknown" must not be read as "> 0" on the many nodes with no Ark sidecar. The LND app UI had the same bug in a different shape: its tiles start as an em-dash, but renderBalances() runs on every poll including before the first response, and `num(null && …)` is 0 — so the dashes were painted over with "0 sats" almost immediately. Same treatment, in plain CSS. Also fixes a stale assertion in AppHeroSection's suite, which has been red since 9ccc325a changed "Restarting..." to a real ellipsis; and two test proofs that used a plausible-looking hex string for `C`. The V3 codec never parses that field so it went unnoticed, but the V4 encoder hands it to the reference implementation, which checks the point is actually on secp256k1. Real curve points now. Frontend: 996 tests green. Backend: 1436 green. Co-Authored-By: Claude Opus 5 (1M context) --- core/archipelago/src/wallet/cashu.rs | 8 +- docker/lnd-ui/index.html | 49 ++++++- neode-ui/src/components/BalanceAmount.vue | 104 +++++++++++++++ .../src/components/WalletSettingsModal.vue | 7 +- .../__tests__/BalanceAmount.test.ts | 119 +++++++++++++++++ neode-ui/src/views/GoalDetail.vue | 14 +- neode-ui/src/views/Home.vue | 26 +++- .../views/__tests__/incomingPayments.test.ts | 125 ++++++++++++++++++ .../__tests__/AppHeroSection.test.ts | 4 +- neode-ui/src/views/home/HomeWalletCard.vue | 36 +++-- 10 files changed, 454 insertions(+), 38 deletions(-) create mode 100644 neode-ui/src/components/BalanceAmount.vue create mode 100644 neode-ui/src/components/__tests__/BalanceAmount.test.ts create mode 100644 neode-ui/src/views/__tests__/incomingPayments.test.ts diff --git a/core/archipelago/src/wallet/cashu.rs b/core/archipelago/src/wallet/cashu.rs index 6460f02d..e59b9101 100644 --- a/core/archipelago/src/wallet/cashu.rs +++ b/core/archipelago/src/wallet/cashu.rs @@ -620,19 +620,23 @@ mod tests { let token = CashuToken { token: vec![TokenEntry { mint: "https://testnut.cashu.space".to_string(), + // Real curve points (G and 2G). The V3 codec never parses `C`, + // so its tests get away with a plausible-looking hex string — + // the V4 encoder hands it to the reference implementation, + // which checks the point is actually on secp256k1. proofs: vec![ Proof { amount: 8, id: "009a1f293253e41e".to_string(), secret: "abcdef1234567890".to_string(), - c: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24" + c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" .to_string(), }, Proof { amount: 2, id: "009a1f293253e41e".to_string(), secret: "fedcba0987654321".to_string(), - c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + c: "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5" .to_string(), }, ], diff --git a/docker/lnd-ui/index.html b/docker/lnd-ui/index.html index ee5d0511..bc31a5ae 100644 --- a/docker/lnd-ui/index.html +++ b/docker/lnd-ui/index.html @@ -190,6 +190,16 @@ .text-white-40 { color: rgba(255,255,255,0.4); } .text-green { color: #4ade80; } .text-orange { color: #fb923c; } .text-red { color: #f87171; } .text-purple { color: #a78bfa; } .text-yellow { color: #facc15; } .text-btc { color: #f7931a; } + + /* Pixel readout shown in place of a balance that isn't known yet. + An unloaded balance used to render as "0 sats" — zero is a number, + not a loading state, and it is the one number that frightens + people. It inherits currentColor, so each tile shimmers in its own + rail colour. */ + .bal-pixels { display: inline-grid; grid-template-columns: repeat(14, 4px); grid-auto-rows: 4px; gap: 1px; vertical-align: 0.15em; } + .bal-pixels i { width: 4px; height: 4px; border-radius: 0.5px; background: currentColor; opacity: 0.16; animation: bal-pixel-scan 1.4s ease-in-out infinite; } + @keyframes bal-pixel-scan { 0%, 70%, 100% { opacity: 0.16; } 25% { opacity: 1; } 45% { opacity: 0.42; } } + @media (prefers-reduced-motion: reduce) { .bal-pixels i { animation: none; opacity: 0.35; } } .bg-green { background: #4ade80; } .bg-yellow { background: #facc15; } .bg-red { background: #f87171; } .bg-grey { background: rgba(255,255,255,0.35); } @@ -1070,6 +1080,23 @@ } function setText(id, text) { const el = document.getElementById(id); if (el) el.textContent = text; } + + // 28 cells = 14 columns x 2 rows, delays staggered so the lit column + // travels across the matrix. + const BAL_PIXELS = '' + + Array.from({ length: 28 }, function (_, i) { + return ''; + }).join('') + ''; + + // Render a balance, or the pixel readout when it is not known yet. + // `sats` must be null/undefined for "not loaded" — passing 0 here + // means the node genuinely has nothing, and says so. + function setBalance(id, sats) { + const el = document.getElementById(id); + if (!el) return; + if (sats === null || sats === undefined) { el.innerHTML = BAL_PIXELS; return; } + el.textContent = fmtAmount(sats); + } function setHtml(id, html) { const el = document.getElementById(id); if (el) el.innerHTML = html; } // Classify a peer address the way Umbrel's peers table does. @@ -1216,12 +1243,22 @@ const lnRemote = num(cb && ((cb.remote_balance && cb.remote_balance.sat) ?? 0)); const lnPending = num(cb && ((cb.pending_open_local_balance && cb.pending_open_local_balance.sat) ?? 0)); - setText('balTotal', fmtAmount(onchainConfirmed + lnLocal)); - setText('balTotalSub', state.onchain || cb ? 'on-chain + lightning' : 'balances unavailable'); - setText('balLightning', fmtAmount(lnLocal)); - setText('balLightningSub', lnPending > 0 ? fmtAmount(lnPending) + ' pending open' : 'spendable over channels'); - setText('balOnchain', fmtAmount(onchainConfirmed)); - setText('balOnchainSub', onchainUnconfirmed > 0 ? fmtAmount(onchainUnconfirmed) + ' unconfirmed' : 'confirmed'); + // This function runs on every poll, including before the first + // response lands — at which point `state.onchain`/`state.chanbal` + // are still null and every figure below computed to 0. The tiles + // therefore claimed a zero balance on load. A rail with no data + // yet gets the pixel readout instead. + const haveOnchain = !!state.onchain; + const haveChan = !!cb; + + setBalance('balTotal', haveOnchain || haveChan ? onchainConfirmed + lnLocal : null); + setText('balTotalSub', haveOnchain || haveChan ? 'on-chain + lightning' : 'waiting for LND'); + setBalance('balLightning', haveChan ? lnLocal : null); + setText('balLightningSub', !haveChan ? 'waiting for LND' + : lnPending > 0 ? fmtAmount(lnPending) + ' pending open' : 'spendable over channels'); + setBalance('balOnchain', haveOnchain ? onchainConfirmed : null); + setText('balOnchainSub', !haveOnchain ? 'waiting for LND' + : onchainUnconfirmed > 0 ? fmtAmount(onchainUnconfirmed) + ' unconfirmed' : 'confirmed'); setText('liqLocal', fmtAmount(lnLocal)); setText('liqRemote', fmtAmount(lnRemote)); diff --git a/neode-ui/src/components/BalanceAmount.vue b/neode-ui/src/components/BalanceAmount.vue new file mode 100644 index 00000000..ae8bf6c7 --- /dev/null +++ b/neode-ui/src/components/BalanceAmount.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/neode-ui/src/components/WalletSettingsModal.vue b/neode-ui/src/components/WalletSettingsModal.vue index 2e41fd22..b4ec4a7e 100644 --- a/neode-ui/src/components/WalletSettingsModal.vue +++ b/neode-ui/src/components/WalletSettingsModal.vue @@ -221,15 +221,15 @@

Spendable

-

{{ (arkBalance?.spendable_sats ?? 0).toLocaleString() }} sats

+

Pending

-

{{ (arkBalance?.pending_sats ?? 0).toLocaleString() }} sats

+

On-chain

-

{{ (arkBalance?.onchain_sats ?? 0).toLocaleString() }} sats

+

@@ -318,6 +318,7 @@ import { useI18n } from 'vue-i18n' import { rpcClient } from '@/api/rpc-client' import BaseModal from '@/components/BaseModal.vue' import LightningChannelsPanel from '@/components/LightningChannelsPanel.vue' +import BalanceAmount from '@/components/BalanceAmount.vue' import { useTxExplorer, EXPLORER_PLACEHOLDER } from '@/composables/useTxExplorer' const { t } = useI18n() diff --git a/neode-ui/src/components/__tests__/BalanceAmount.test.ts b/neode-ui/src/components/__tests__/BalanceAmount.test.ts new file mode 100644 index 00000000..0d2a2c99 --- /dev/null +++ b/neode-ui/src/components/__tests__/BalanceAmount.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import BalanceAmount from '../BalanceAmount.vue' +import HomeWalletCard from '@/views/home/HomeWalletCard.vue' +import i18n from '@/i18n' + +/** + * The distinction this whole component exists to protect: `0` is a balance, + * `null` is the absence of one. Rendering the first when you mean the second + * tells someone their money is gone, in the wallet's own typeface. Every case + * below is really one assertion — that the two never get confused. + */ +describe('BalanceAmount', () => { + it('shows the pixel readout when the balance is not known yet', () => { + const w = mount(BalanceAmount, { props: { sats: null, label: 'on-chain balance' } }) + expect(w.find('.balance-pixels').exists()).toBe(true) + expect(w.text()).not.toContain('0') + }) + + it('treats undefined the same as null', () => { + // Optional props (`arkBalance?.spendable_sats`) arrive as undefined, not + // null, and must not fall through to a figure. + const w = mount(BalanceAmount, { props: { sats: undefined } }) + expect(w.find('.balance-pixels').exists()).toBe(true) + }) + + it('shows a genuine zero as a figure, not as loading', () => { + // The inverse mistake: a node that really has no coins must be told so + // plainly, not left shimmering forever. + const w = mount(BalanceAmount, { props: { sats: 0 } }) + expect(w.find('.balance-pixels').exists()).toBe(false) + expect(w.text()).toBe('0 sats') + }) + + it('formats a real balance with thousands separators', () => { + const w = mount(BalanceAmount, { props: { sats: 9922 } }) + expect(w.text()).toBe('9,922 sats') + }) + + it('can drop the unit for bare figures', () => { + const w = mount(BalanceAmount, { props: { sats: 21, suffix: '' } }) + expect(w.text()).toBe('21') + }) + + it('announces what is loading instead of being silently empty', () => { + // A shimmering box with no text is nothing at all to a screen reader. + const w = mount(BalanceAmount, { props: { sats: null, label: 'Cashu balance' } }) + const el = w.find('.balance-pixels') + expect(el.attributes('role')).toBe('status') + expect(el.attributes('aria-label')).toBe('Loading Cashu balance') + }) + + it('inherits the rail colour rather than hard-coding one', () => { + // The pixels are painted with currentColor, which is what makes the + // on-chain row orange and the Cashu row purple with no colour table to + // keep in sync. Guard the mechanism: a literal colour here would drift. + const w = mount(BalanceAmount, { props: { sats: null } }) + expect(w.find('.balance-pixel').exists()).toBe(true) + expect(w.html()).not.toMatch(/background:\s*#|rgb\(/) + }) + + it('renders enough cells to read as a matrix', () => { + const w = mount(BalanceAmount, { props: { sats: null } }) + expect(w.findAll('.balance-pixel').length).toBe(28) + }) +}) + +describe('HomeWalletCard balances', () => { + const base = { + animate: false, + walletConnected: true, + walletOnchain: null, + walletLightning: null, + walletEcash: null, + walletFedimint: null, + walletArk: null, + walletTransactions: [], + isDev: false, + } + + const mountCard = (props: Record) => + mount(HomeWalletCard, { props: { ...base, ...props }, global: { plugins: [i18n] } }) + + it('shows no figures at all before anything has loaded', () => { + const w = mountCard({}) + // Six rows could be showing 0 sats here; none of them may. + expect(w.findAll('.balance-pixels').length).toBeGreaterThan(0) + expect(w.text()).not.toMatch(/\b0 sats\b/) + }) + + it('withholds the total until every rail it sums is known', () => { + // A total computed with nulls as 0 would read *lower* than the rails + // beneath it — worse than showing nothing, because it looks authoritative. + const w = mountCard({ walletOnchain: 5000, walletLightning: null, walletEcash: 0, walletFedimint: 0 }) + expect(w.text()).not.toContain('5,000 sats\n') + expect(w.findAll('.balance-pixels').length).toBeGreaterThan(0) + }) + + it('sums the total once every rail has reported', () => { + const w = mountCard({ walletOnchain: 9000, walletLightning: 900, walletEcash: 22, walletFedimint: 0 }) + expect(w.text()).toContain('9,922 sats') + expect(w.findAll('.balance-pixels').length).toBe(0) + }) + + it('shows an empty wallet as zero rather than as loading', () => { + const w = mountCard({ walletOnchain: 0, walletLightning: 0, walletEcash: 0, walletFedimint: 0 }) + expect(w.findAll('.balance-pixels').length).toBe(0) + expect(w.text()).toContain('0 sats') + }) + + it('keeps the Ark row hidden while its balance is unknown', () => { + // Ark only appears once barkd reports something; "unknown" must not be + // read as "> 0" and conjure a row on the many nodes with no Ark sidecar. + const loaded = { walletOnchain: 1, walletLightning: 0, walletEcash: 0, walletFedimint: 0 } + expect(mountCard({ ...loaded, walletArk: null }).text()).not.toContain('Ark') + expect(mountCard({ ...loaded, walletArk: 0 }).text()).not.toContain('Ark') + expect(mountCard({ ...loaded, walletArk: 7 }).text()).toContain('Ark') + }) +}) diff --git a/neode-ui/src/views/GoalDetail.vue b/neode-ui/src/views/GoalDetail.vue index 11289c33..41036292 100644 --- a/neode-ui/src/views/GoalDetail.vue +++ b/neode-ui/src/views/GoalDetail.vue @@ -121,8 +121,8 @@
On-chain wallet balance - - {{ walletOnchainSats.toLocaleString() }} sats + +

Minimum 150,000 · maximum 1,500,000 on-chain sats required.

@@ -136,10 +136,10 @@
@@ -224,6 +224,7 @@