feat(wallet): a balance that isn't loaded yet says so, in pixels
Demo images / Build & push demo images (push) Failing after 2m18s

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) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-17 08:54:12 -04:00
co-authored by Claude Opus 5
parent fc98c1d8dd
commit fa6fe32ef9
10 changed files with 454 additions and 38 deletions
+6 -2
View File
@@ -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(),
},
],
+43 -6
View File
@@ -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 = '<span class="bal-pixels" role="status" aria-label="Loading balance">' +
Array.from({ length: 28 }, function (_, i) {
return '<i style="animation-delay:' + (i * 45) + 'ms"></i>';
}).join('') + '</span>';
// 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));
+104
View File
@@ -0,0 +1,104 @@
<script setup lang="ts">
import { computed } from 'vue'
/**
* A balance figure, or — while it is still unknown — a pixel readout in place
* of it.
*
* The problem this exists for: an unloaded balance used to render as `0`.
* Zero is not "loading", it is a *number*, and it is the one number that
* frightens people. Someone opening the dashboard while the RPCs are 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 not claim a figure we
* do not have yet.
*
* So `sats` is nullable, and `null` means "not known yet" rather than "none".
* Callers must keep that distinction alive: a balance ref should start at
* `null` and only become a number when a call actually succeeds.
*
* The placeholder is a small dot-matrix that scans in the rail's own colour —
* it inherits `currentColor`, so the on-chain row shimmers orange, Lightning
* yellow, Cashu purple, Fedimint blue and Ark teal with no colour mapping to
* keep in sync. It is deliberately about as wide as the figure it stands in
* for, so nothing jumps when the real number lands.
*/
const props = withDefaults(
defineProps<{
/** Balance in sats, or null/undefined while it is still unknown. */
sats: number | null | undefined
/** Trailing unit. Set to '' for bare figures. */
suffix?: string
/** Named for screen readers, e.g. "on-chain balance". */
label?: string
}>(),
{ suffix: 'sats', label: 'balance' },
)
// 14 columns × 2 rows. Enough to read as a matrix rather than a spinner, and
// close to the width of a five-figure sat amount.
const CELLS = 28
/**
* Built as one string rather than interpolated around a `<template>`, so the
* space before the unit cannot be eaten by Vue's whitespace condensing — and
* so a test reading `.text()` sees exactly what a person reads on screen.
*/
const display = computed(() => {
if (props.sats == null) return ''
const figure = props.sats.toLocaleString()
return props.suffix ? `${figure} ${props.suffix}` : figure
})
</script>
<template>
<span
v-if="props.sats == null"
class="balance-pixels"
role="status"
aria-live="polite"
:aria-label="`Loading ${props.label}`"
:title="`Loading ${props.label}…`"
>
<span v-for="i in CELLS" :key="i" class="balance-pixel" :style="{ '--i': i }" />
</span>
<span v-else>{{ display }}</span>
</template>
<style scoped>
.balance-pixels {
display: inline-grid;
grid-template-columns: repeat(14, 3px);
grid-auto-rows: 3px;
gap: 1px;
/* Sit on the text baseline so a row's height doesn't change when the real
figure replaces this. */
vertical-align: -1px;
}
.balance-pixel {
width: 3px;
height: 3px;
border-radius: 0.5px;
background: currentColor;
opacity: 0.16;
/* The wave runs left-to-right across columns; the two rows of a column are
offset slightly so it reads as a scan rather than a marching block. */
animation: balance-pixel-scan 1.4s ease-in-out infinite;
animation-delay: calc(var(--i) * 45ms);
}
@keyframes balance-pixel-scan {
0%, 70%, 100% { opacity: 0.16; }
25% { opacity: 1; }
45% { opacity: 0.42; }
}
/* Motion is decoration here — the dimmed matrix still reads as "no figure
yet", which is the part that carries the meaning. */
@media (prefers-reduced-motion: reduce) {
.balance-pixel {
animation: none;
opacity: 0.35;
}
}
</style>
@@ -221,15 +221,15 @@
<div v-if="arkStatus?.available" class="grid grid-cols-3 gap-2 mb-4">
<div class="p-3 bg-white/5 rounded-lg text-center">
<p class="text-[11px] text-white/40 mb-1">Spendable</p>
<p class="text-sm text-teal-400 font-medium">{{ (arkBalance?.spendable_sats ?? 0).toLocaleString() }} sats</p>
<p class="text-sm text-teal-400 font-medium"><BalanceAmount :sats="arkBalance?.spendable_sats" label="spendable Ark balance" /></p>
</div>
<div class="p-3 bg-white/5 rounded-lg text-center">
<p class="text-[11px] text-white/40 mb-1">Pending</p>
<p class="text-sm text-white/70 font-medium">{{ (arkBalance?.pending_sats ?? 0).toLocaleString() }} sats</p>
<p class="text-sm text-white/70 font-medium"><BalanceAmount :sats="arkBalance?.pending_sats" label="pending Ark balance" /></p>
</div>
<div class="p-3 bg-white/5 rounded-lg text-center">
<p class="text-[11px] text-white/40 mb-1">On-chain</p>
<p class="text-sm text-white/70 font-medium">{{ (arkBalance?.onchain_sats ?? 0).toLocaleString() }} sats</p>
<p class="text-sm text-white/70 font-medium"><BalanceAmount :sats="arkBalance?.onchain_sats" label="on-chain Ark balance" /></p>
</div>
</div>
@@ -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()
@@ -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<string, unknown>) =>
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')
})
})
+9 -5
View File
@@ -121,8 +121,8 @@
<div class="p-3 rounded-lg bg-white/5 border border-white/10">
<div class="flex items-center justify-between gap-3">
<span class="text-xs text-white/60">On-chain wallet balance</span>
<span class="text-sm font-mono" :class="walletOnchainSats >= ZEUS_CHANNEL_MIN_SATS ? 'text-green-400' : 'text-white/85'">
{{ walletOnchainSats.toLocaleString() }} sats
<span class="text-sm font-mono" :class="(walletOnchainSats ?? 0) >= ZEUS_CHANNEL_MIN_SATS ? 'text-green-400' : 'text-white/85'">
<BalanceAmount :sats="walletOnchainSats" label="on-chain balance" />
</span>
</div>
<p class="text-xs text-white/45 mt-1">Minimum 150,000 · maximum 1,500,000 on-chain sats required.</p>
@@ -136,10 +136,10 @@
</button>
<button
@click="completeFundStep(step)"
:disabled="walletOnchainSats <= 0"
:disabled="(walletOnchainSats ?? 0) <= 0"
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium disabled:opacity-40"
>
{{ walletOnchainSats > 0 ? 'Continue' : 'Waiting for funds…' }}
{{ walletOnchainSats == null ? 'Checking balance…' : walletOnchainSats > 0 ? 'Continue' : 'Waiting for funds…' }}
</button>
</div>
</template>
@@ -224,6 +224,7 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import BalanceAmount from '@/components/BalanceAmount.vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
@@ -435,7 +436,10 @@ function goBack() {
// Fund-wallet step: live sync status + on-chain balance
const showFundModal = ref(false)
const walletOnchainSats = ref(0)
// null while the first balance call is still out "0 sats" and "we haven't
// asked yet" must not look the same on a screen that then says
// "Waiting for funds".
const walletOnchainSats = ref<number | null>(null)
const hasFundStep = computed(() => goal.value?.steps.some((s) => s.action === 'fund') ?? false)
const fundStepActive = computed(() => {
+19 -7
View File
@@ -678,9 +678,19 @@ const showScanModal = ref(false); const showSendModal = ref(false); const showRe
async function devFaucet() { try { await rpcClient.call({ method: 'dev.faucet', params: { amount_sats: 1_000_000 } }); await loadWeb5Status() } catch { /* ignore */ } }
const walletConnected = ref(false); const walletOnchain = ref(0); const walletLightning = ref(0); const walletEcash = ref(0); const walletFedimint = ref(0)
// Balances start as `null`, not 0. Zero is a *number*, and it is the one
// number that frightens people rendering it before any call has returned
// told someone opening the dashboard, in the wallet's own typeface, that
// their money was gone. `null` means "not known yet" and paints a pixel
// readout instead; a rail only becomes a number when a call actually
// succeeds, so a real 0 is still a real 0.
const walletConnected = ref(false)
const walletOnchain = ref<number | null>(null)
const walletLightning = ref<number | null>(null)
const walletEcash = ref<number | null>(null)
const walletFedimint = ref<number | null>(null)
let walletInfoFailures = 0
const walletArk = ref(0)
const walletArk = ref<number | null>(null)
const walletTransactions = ref<WalletTransaction[]>([])
// Overlay the local Mempool app when it's running; otherwise route through
@@ -728,11 +738,13 @@ function hydrateWalletSnapshot() {
const raw = localStorage.getItem(WALLET_SNAPSHOT_KEY)
if (!raw) return
const s = JSON.parse(raw)
walletOnchain.value = s.onchain ?? 0
walletLightning.value = s.lightning ?? 0
walletEcash.value = s.ecash ?? 0
walletFedimint.value = s.fedimint ?? 0
walletArk.value = s.ark ?? 0
// A snapshot key that isn't there was never known leave it unknown
// rather than inventing a zero for it.
walletOnchain.value = s.onchain ?? null
walletLightning.value = s.lightning ?? null
walletEcash.value = s.ecash ?? null
walletFedimint.value = s.fedimint ?? null
walletArk.value = s.ark ?? null
walletConnected.value = s.connected === true
if (Array.isArray(s.transactions)) walletTransactions.value = s.transactions
} catch { /* corrupt/absent snapshot — fresh load fills in */ }
@@ -0,0 +1,125 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import i18n from '@/i18n'
import HomeWalletCard from '@/views/home/HomeWalletCard.vue'
/**
* Instant rails (Lightning, Cashu, Fedimint, Ark) settle immediately, so
* there is no confirmation count to retire an incoming receipt from the
* badge. It used to leave on a five-minute wall clock, which meant a payment
* could arrive and vanish before anyone looked and for ecash, which leaves
* no public ledger entry, this panel was the only place the receipt was ever
* shown.
*
* These cases pin the replacement: a receipt stays until it has been seen.
*/
const now = () => Math.floor(Date.now() / 1000)
function tx(over: Record<string, unknown> = {}) {
return {
tx_hash: '',
amount_sats: 1000,
direction: 'incoming' as const,
num_confirmations: 1,
time_stamp: now(),
total_fees: 0,
dest_addresses: [],
label: '',
block_height: 0,
kind: 'cashu' as const,
...over,
}
}
const base = {
animate: false,
walletConnected: true,
walletOnchain: 0,
walletLightning: 0,
walletEcash: 0,
walletFedimint: 0,
walletArk: 0,
isDev: false,
}
const mountCard = (transactions: ReturnType<typeof tx>[]) =>
mount(HomeWalletCard, {
props: { ...base, walletTransactions: transactions },
global: { plugins: [i18n] },
})
describe('incoming payments', () => {
it('keeps an hours-old instant receipt in the badge until it is seen', async () => {
// The regression: two hours past the old five-minute window. Nobody has
// looked at it yet, so it is still news.
const w = mountCard([tx({ time_stamp: now() - 7200 })])
expect(w.text()).toContain('Incoming 1')
})
it('does not clear the receipt merely because the panel was opened', async () => {
// "Selecting incoming clears a pending token" — opening must show it,
// not consume it. Someone reading the row must be able to keep reading.
const w = mountCard([tx()])
await w.find('button').trigger('click')
expect(w.text()).toContain('Incoming Transactions')
expect(w.text()).toContain('+1,000 sats')
})
it('marks it seen once the panel is closed again', async () => {
const w = mountCard([tx()])
const badge = w.find('button')
await badge.trigger('click') // open
await badge.trigger('click') // close — acknowledges
expect(w.text()).not.toContain('Incoming 1')
})
it('still surfaces a payment that arrives after an earlier one was seen', async () => {
const first = tx({ amount_sats: 1000, time_stamp: now() - 60 })
const w = mountCard([first])
const badge = w.find('button')
await badge.trigger('click')
await badge.trigger('click')
expect(w.text()).not.toContain('Incoming 1')
// A different payment must not inherit the first one's acknowledgement.
await w.setProps({ walletTransactions: [first, tx({ amount_sats: 2500, time_stamp: now() })] })
expect(w.text()).toContain('Incoming 1')
})
it('leaves on-chain transactions on their confirmation count', () => {
// On-chain has a real signal and is deliberately untouched: it drops out
// at three confirmations regardless of whether anyone looked.
const unconfirmed = mountCard([tx({ kind: 'onchain', num_confirmations: 0, tx_hash: 'abc' })])
expect(unconfirmed.text()).toContain('Incoming 1')
const settled = mountCard([tx({ kind: 'onchain', num_confirmations: 6, tx_hash: 'abc' })])
expect(settled.text()).not.toContain('Incoming 1')
})
it('lists several instant receipts separately even without txids', () => {
// Instant rails carry no txid, so keying the list on tx_hash gave every
// row the same empty key and Vue reused one node for all of them.
const w = mountCard([
tx({ amount_sats: 1000, time_stamp: now() - 10 }),
tx({ amount_sats: 2000, time_stamp: now() - 20, kind: 'lightning' }),
tx({ amount_sats: 3000, time_stamp: now() - 30, kind: 'fedimint' }),
])
expect(w.text()).toContain('Incoming 3')
})
it('does not silently turn into a navigation button while the panel is open', async () => {
// The badge is two controls in one: with receipts it toggles the panel,
// without them it navigates to the full transactions view. If the list
// empties while the panel is open, the click under the user's cursor used
// to change meaning and take them to another screen.
const w = mountCard([tx()])
const badge = w.find('button')
await badge.trigger('click')
expect(w.text()).toContain('Incoming Transactions')
await w.setProps({ walletTransactions: [] })
await badge.trigger('click')
expect(w.emitted('showTransactions')).toBeUndefined()
})
})
@@ -68,7 +68,9 @@ describe('AppHeroSection', () => {
it('disables app controls while a container action is running', () => {
const wrapper = mountHero({ pendingAction: 'restart' })
expect(wrapper.text()).toContain('Restarting...')
// A real ellipsis, not three dots — the label changed in 9ccc325a and
// this assertion was left behind.
expect(wrapper.text()).toContain('Restarting…')
expect(wrapper.findAll('button').every(button => button.attributes('disabled') !== undefined)).toBe(true)
})
+22 -14
View File
@@ -117,7 +117,7 @@
<span class="text-lg text-orange-500 font-bold">&#x20bf;</span>
<span class="text-sm font-medium text-white">{{ t('web5.totalBitcoin') }}</span>
</div>
<span class="text-white text-sm font-semibold">{{ walletTotal.toLocaleString() }} sats</span>
<span class="text-white text-sm font-semibold"><BalanceAmount :sats="walletTotal" label="total balance" /></span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
@@ -126,7 +126,7 @@
</svg>
<span class="text-sm text-white/80">{{ t('web5.onChain') }}</span>
</div>
<span class="text-orange-500 text-sm font-medium">{{ walletOnchain.toLocaleString() }} sats</span>
<span class="text-orange-500 text-sm font-medium"><BalanceAmount :sats="walletOnchain" label="on-chain balance" /></span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
@@ -135,7 +135,7 @@
</svg>
<span class="text-sm text-white/80">{{ t('web5.lightning') }}</span>
</div>
<span class="text-yellow-400 text-sm font-medium">{{ walletLightning.toLocaleString() }} sats</span>
<span class="text-yellow-400 text-sm font-medium"><BalanceAmount :sats="walletLightning" label="Lightning balance" /></span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
@@ -146,7 +146,7 @@
</svg>
<span class="text-sm text-white/80">Cashu</span>
</div>
<span class="text-purple-400 text-sm font-medium">{{ walletEcash.toLocaleString() }} sats</span>
<span class="text-purple-400 text-sm font-medium"><BalanceAmount :sats="walletEcash" label="Cashu balance" /></span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
@@ -155,7 +155,7 @@
</svg>
<span class="text-sm text-white/80">Fedimint</span>
</div>
<span class="text-blue-400 text-sm font-medium">{{ walletFedimint.toLocaleString() }} sats</span>
<span class="text-blue-400 text-sm font-medium"><BalanceAmount :sats="walletFedimint" label="Fedimint balance" /></span>
</div>
<!-- Only rendered once barkd reports a balance most nodes don't run the Ark sidecar -->
<div v-if="(walletArk ?? 0) > 0" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
@@ -165,7 +165,7 @@
</svg>
<span class="text-sm text-white/80">Ark</span>
</div>
<span class="text-teal-400 text-sm font-medium">{{ (walletArk ?? 0).toLocaleString() }} sats</span>
<span class="text-teal-400 text-sm font-medium"><BalanceAmount :sats="walletArk" label="Ark balance" /></span>
</div>
</div>
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
@@ -199,6 +199,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import BalanceAmount from '@/components/BalanceAmount.vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -220,11 +221,13 @@ export interface WalletTransaction {
const props = defineProps<{
animate: boolean
walletConnected: boolean
walletOnchain: number
walletLightning: number
walletEcash: number
walletFedimint: number
walletArk?: number
// `null` = not loaded yet, `0` = genuinely empty. Keeping those apart is
// what lets the card show a pixel readout instead of claiming a figure.
walletOnchain: number | null
walletLightning: number | null
walletEcash: number | null
walletFedimint: number | null
walletArk?: number | null
walletTransactions: WalletTransaction[]
isDev: boolean
}>()
@@ -241,9 +244,14 @@ defineEmits<{
const showIncomingTxPanel = ref(false)
const walletTotal = computed(() =>
props.walletOnchain + props.walletLightning + props.walletEcash + props.walletFedimint + (props.walletArk ?? 0)
)
// The total is only a number once every rail that makes it up is. Summing
// with nulls treated as 0 would quietly under-report the balance a total
// smaller than the rails beneath it is worse than showing nothing.
const walletTotal = computed<number | null>(() => {
const rails = [props.walletOnchain, props.walletLightning, props.walletEcash, props.walletFedimint]
if (rails.some(v => v == null)) return null
return rails.reduce((a: number, b) => a + (b as number), 0) + (props.walletArk ?? 0)
})
function isOnchain(tx: WalletTransaction): boolean {
return !tx.kind || tx.kind === 'onchain'