fix(wallet): a seen receipt stays seen across refreshes
Demo images / Build & push demo images (push) Failing after 2m6s

fc98c1d8 replaced the five-minute timer with "stays until seen", but
kept "seen" in component state — so every page load forgot it and the
entire ecash history came back as new. That is worse than the timer it
replaced: the old behaviour at least let receipts go, this one resurrected
them on every refresh. Reported from the node, and correctly.

Acknowledgement now lives in localStorage, capped at 300 keys.

That opens the opposite trap: on a browser with nothing stored, treating
the whole history as unseen is the same wall of old receipts from the
other direction. So a first run seeds everything older than five minutes
as already seen — the window survives as a first-run heuristic, not as
an expiry. Unreadable storage takes the same path, because reading a
corrupt value as "nothing acknowledged" is the refresh bug wearing a hat.

Also guards the balance readout against NaN. `sats == null` does not
catch it, and arithmetic over a missing field produces it, so it would
have rendered as the literal text "NaN sats" — worse than the zero the
component exists to prevent, since a zero at least looks like a number.

Frontend: 1000 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-17 09:30:57 -04:00
co-authored by Claude Opus 5
parent 603291008b
commit 59440ef1ac
4 changed files with 134 additions and 9 deletions
+13 -3
View File
@@ -52,16 +52,26 @@ function cellDelay(i: number): string {
* 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.
*/
/**
* Is there a figure to show at all?
*
* `null`/`undefined` mean "not known yet" — but so does a NaN or an Infinity,
* which is what arithmetic on a missing field quietly produces. Those render
* as the literal text "NaN sats", which is worse than the zero this component
* exists to prevent: at least a zero looks like a number.
*/
const known = computed(() => props.sats != null && Number.isFinite(props.sats))
const display = computed(() => {
if (props.sats == null) return ''
const figure = props.sats.toLocaleString()
if (!known.value) return ''
const figure = (props.sats as number).toLocaleString()
return props.suffix ? `${figure} ${props.suffix}` : figure
})
</script>
<template>
<span
v-if="props.sats == null"
v-if="!known"
class="balance-pixels"
role="status"
aria-live="polite"
@@ -24,6 +24,18 @@ describe('BalanceAmount', () => {
expect(w.find('.balance-pixels').exists()).toBe(true)
})
it('never prints NaN at a person', () => {
// Arithmetic over a missing field produces NaN, which is not caught by a
// null check and renders as the literal text "NaN sats" — worse than the
// zero this component exists to prevent, because at least a zero looks
// like a number.
for (const bad of [NaN, Infinity, -Infinity]) {
const w = mount(BalanceAmount, { props: { sats: bad } })
expect(w.find('.balance-pixels').exists()).toBe(true)
expect(w.text()).toBe('')
}
})
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.
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import i18n from '@/i18n'
import HomeWalletCard from '@/views/home/HomeWalletCard.vue'
@@ -49,10 +49,16 @@ const mountCard = (transactions: ReturnType<typeof tx>[]) =>
global: { plugins: [i18n] },
})
// Acknowledgement is stored per-browser, so each case starts from a clean
// slate — otherwise one test's "seen" set silently satisfies the next.
beforeEach(() => localStorage.clear())
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.
it('retires a receipt on acknowledgement, never on age alone', async () => {
// On a browser that has acknowledged before, an unacknowledged receipt
// stays put however old it is — age is not the signal, being seen is.
// (A brand-new browser is the separate first-run case below.)
localStorage.setItem('archy-seen-incoming-v1', JSON.stringify(['some-earlier-receipt']))
const w = mountCard([tx({ time_stamp: now() - 7200 })])
expect(w.text()).toContain('Incoming 1')
})
@@ -67,6 +73,7 @@ describe('incoming payments', () => {
})
it('marks it seen once the panel is closed again', async () => {
localStorage.setItem('archy-seen-incoming-v1', JSON.stringify([]))
const w = mountCard([tx()])
const badge = w.find('button')
await badge.trigger('click') // open
@@ -108,6 +115,46 @@ describe('incoming payments', () => {
expect(w.text()).toContain('Incoming 3')
})
it('does not show a receipt again after a refresh', async () => {
// The bug this whole model was supposed to prevent, and briefly caused:
// "seen" lived in component state, so every page load forgot it and the
// entire ecash history came back as new. Remounting is a refresh.
const received = tx({ amount_sats: 4200, time_stamp: now() - 30 })
const first = mountCard([received])
const badge = first.find('button')
await badge.trigger('click')
await badge.trigger('click')
expect(first.text()).not.toContain('Incoming 1')
const afterRefresh = mountCard([received])
expect(afterRefresh.text()).not.toContain('Incoming 1')
})
it('does not greet a brand-new browser with the whole history', () => {
// Nothing acknowledged yet and a long history: treating all of it as
// unseen would be the same wall of old receipts, just from the other
// direction. Only what is genuinely recent counts as news on a first run.
const old = [
tx({ amount_sats: 100, time_stamp: now() - 86400 }),
tx({ amount_sats: 200, time_stamp: now() - 3600 }),
tx({ amount_sats: 300, time_stamp: now() - 600 }),
]
expect(mountCard(old).text()).not.toContain('Incoming')
// …but a receipt from moments ago still is.
localStorage.clear()
expect(mountCard([...old, tx({ amount_sats: 400, time_stamp: now() - 5 })]).text())
.toContain('Incoming 1')
})
it('survives unreadable storage without resurrecting the history', () => {
// A corrupt value must not read as "nothing has been acknowledged" — that
// is precisely the refresh bug wearing a different hat.
localStorage.setItem('archy-seen-incoming-v1', '{not json')
const w = mountCard([tx({ amount_sats: 100, time_stamp: now() - 86400 })])
expect(w.text()).not.toContain('Incoming')
})
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
+58 -2
View File
@@ -198,7 +198,7 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import BalanceAmount from '@/components/BalanceAmount.vue'
import { useI18n } from 'vue-i18n'
@@ -270,13 +270,68 @@ function isOnchain(tx: WalletTransaction): boolean {
// what marks them seen, which is the same unread model the mesh inbox uses.
// On-chain is unchanged — a confirmation count is a real signal and does the
// job by itself.
const seenIncoming = ref(new Set<string>())
//
// "Seen" has to outlive the page, or this is worse than the timer it
// replaced: held only in component state, every refresh forgot the
// acknowledgement and the whole ecash history came back as new. It is stored
// per-node in localStorage, capped so it cannot grow without bound.
const SEEN_KEY = 'archy-seen-incoming-v1'
const SEEN_CAP = 300
// On a browser that has never stored an acknowledgement, treating the entire
// history as unseen would greet the user with a wall of old receipts. Only
// what arrived in the last few minutes is genuinely news on a first run.
const FIRST_RUN_GRACE_SECS = 5 * 60
function loadSeen(): { seen: Set<string>; firstRun: boolean } {
try {
const raw = localStorage.getItem(SEEN_KEY)
if (raw === null) return { seen: new Set(), firstRun: true }
const parsed: unknown = JSON.parse(raw)
return { seen: new Set(Array.isArray(parsed) ? (parsed as string[]) : []), firstRun: false }
} catch {
// Unreadable storage must not resurrect the history — treat it as a fresh
// start rather than as "nothing acknowledged".
return { seen: new Set(), firstRun: true }
}
}
const initial = loadSeen()
const seenIncoming = ref(initial.seen)
let needsFirstRunSeeding = initial.firstRun
function persistSeen() {
try {
// Keep the newest entries; the oldest can never resurface anyway, because
// an instant-rail receipt that far back is long gone from the history the
// backend returns.
const keys = [...seenIncoming.value].slice(-SEEN_CAP)
seenIncoming.value = new Set(keys)
localStorage.setItem(SEEN_KEY, JSON.stringify(keys))
} catch { /* storage full or unavailable — acknowledgement stays in-session */ }
}
function txKey(tx: WalletTransaction): string {
// Instant rails have no txid to key on, so fall back to rail+time+amount.
return tx.tx_hash || `${tx.kind ?? 'onchain'}:${tx.time_stamp}:${tx.amount_sats}`
}
// Seed the first run once history actually arrives — at mount the list is
// still empty, so there is nothing to judge yet.
watch(
() => props.walletTransactions,
(txs) => {
if (!needsFirstRunSeeding || txs.length === 0) return
needsFirstRunSeeding = false
const cutoff = Math.floor(Date.now() / 1000) - FIRST_RUN_GRACE_SECS
for (const tx of txs) {
if (tx.direction !== 'incoming' || isOnchain(tx)) continue
if (tx.time_stamp < cutoff) seenIncoming.value.add(txKey(tx))
}
persistSeen()
},
{ immediate: true },
)
const incomingTransactions = computed(() =>
props.walletTransactions.filter(tx => {
if (tx.direction !== 'incoming') return false
@@ -296,6 +351,7 @@ function toggleIncomingPanel() {
}
// Vue tracks Set mutations, but reassigning keeps the dependency obvious.
seenIncoming.value = new Set(seenIncoming.value)
persistSeen()
showIncomingTxPanel.value = false
return
}