fix(wallet): an incoming payment no longer disappears before you look
Demo images / Build & push demo images (push) Failing after 2m10s

Chasing the "selecting incoming clears a pending token, and there's a
timeout if you don't click" report led here. Instant rails — Lightning,
Cashu, Fedimint, Ark — settle immediately, so there is no confirmation
to wait for and no natural moment for a receipt to leave the Incoming
badge. It was leaving on a five-minute wall clock instead.

So a payment could arrive, raise the badge, and evaporate before anyone
looked; and opening the panel a few minutes late showed nothing, because
the payment you came to check on had already aged out. Worse, once the
count hit zero the badge silently changed meaning — the same click that
opened the panel now navigated to the transactions view instead.

For ecash that is the worst case available. It leaves no public ledger
entry, so this panel was the only place the receipt was ever shown; once
it timed out there was nowhere left to look.

Instant-rail receipts now stay until they have actually been seen, which
is the same unread model the mesh inbox uses. Closing the panel is what
marks them seen, not opening it — marking on open would make a row
vanish under the cursor of someone still reading it. On-chain is
untouched: a confirmation count is a real signal and already does this
job.

Also keys the list on a derived id. Instant rails have no txid, so
`:key="tx.tx_hash"` was `""` for every one of them.

This is my reading of the reported symptoms rather than a confirmed
repro — the operator should check it matches what they saw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-17 08:30:16 -04:00
co-authored by Claude Opus 5
parent e30516316b
commit fc98c1d8dd
+40 -13
View File
@@ -22,7 +22,7 @@
</div>
<div class="flex items-center gap-2">
<button
@click="incomingTxCount > 0 ? (showIncomingTxPanel = !showIncomingTxPanel) : $emit('showTransactions')"
@click="incomingTxCount > 0 || showIncomingTxPanel ? toggleIncomingPanel() : $emit('showTransactions')"
:class="incomingTxCount > 0 ? 'incoming-tx-badge' : 'text-white/50 hover:text-white/80 text-xs px-2 py-1 rounded-lg bg-white/5 hover:bg-white/10 transition-colors'"
class="shrink-0"
>
@@ -59,14 +59,14 @@
<div v-if="showIncomingTxPanel && incomingTransactions.length > 0" class="mb-4 rounded-xl overflow-hidden border border-green-500/20">
<div class="px-4 py-2.5 bg-green-500/10 border-b border-green-500/15 flex items-center justify-between">
<span class="text-xs font-medium text-green-400 uppercase tracking-wide">Incoming Transactions</span>
<button @click="showIncomingTxPanel = false" class="text-white/40 hover:text-white/70 transition-colors">
<button @click="toggleIncomingPanel" class="text-white/40 hover:text-white/70 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div class="divide-y divide-white/5">
<div
v-for="tx in incomingTransactions"
:key="tx.tx_hash"
:key="txKey(tx)"
class="flex items-center justify-between gap-3 px-4 py-3 hover:bg-white/5 transition-colors"
:class="isOnchain(tx) ? 'cursor-pointer' : ''"
@click="isOnchain(tx) && $emit('openInMempool', tx.tx_hash)"
@@ -198,7 +198,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -249,24 +249,51 @@ function isOnchain(tx: WalletTransaction): boolean {
return !tx.kind || tx.kind === 'onchain'
}
// Instant rails (lightning/cashu/fedimint/ark) settle immediately there is
// no confirmation to wait for, so they only get the "incoming" treatment for
// a short window after receipt instead of sitting in the badge forever.
const INSTANT_INCOMING_WINDOW_SECS = 5 * 60
const nowSecs = ref(Math.floor(Date.now() / 1000))
let nowTimer: ReturnType<typeof setInterval> | null = null
onMounted(() => { nowTimer = setInterval(() => { nowSecs.value = Math.floor(Date.now() / 1000) }, 30000) })
onUnmounted(() => { if (nowTimer) clearInterval(nowTimer) })
// Instant rails (lightning/cashu/fedimint/ark) settle immediately, so there is
// no confirmation to wait for and no natural moment for them to leave the
// badge. They used to drop out on a five-minute wall clock, which meant a
// receipt could appear and then silently disappear before anyone looked at it
// — and if you opened the panel a few minutes late, the payment you came to
// check on had already evaporated. For ecash that is the worst case
// available: it leaves no public ledger entry, so this panel was the only
// place the receipt was ever shown.
//
// So they stay until they have actually been *seen*. Opening the panel is
// 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>())
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}`
}
const incomingTransactions = computed(() =>
props.walletTransactions.filter(tx => {
if (tx.direction !== 'incoming') return false
if (isOnchain(tx)) return tx.num_confirmations < 3
return nowSecs.value - tx.time_stamp < INSTANT_INCOMING_WINDOW_SECS
return !seenIncoming.value.has(txKey(tx))
})
)
const incomingTxCount = computed(() => incomingTransactions.value.length)
/// Open or close the panel. Closing is what acknowledges the instant-rail
/// receipts currently listed — marking them on *open* would make a row vanish
/// under the cursor of someone still reading it.
function toggleIncomingPanel() {
if (showIncomingTxPanel.value) {
for (const tx of incomingTransactions.value) {
if (!isOnchain(tx)) seenIncoming.value.add(txKey(tx))
}
// Vue tracks Set mutations, but reassigning keeps the dependency obvious.
seenIncoming.value = new Set(seenIncoming.value)
showIncomingTxPanel.value = false
return
}
showIncomingTxPanel.value = true
}
function railBadge(tx: WalletTransaction): string {
if (tx.kind === 'lightning') return ' Instant'
if (tx.kind === 'cashu') return 'Cashu'