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 @@