fix(neode-ui): instant payments no longer stuck as 'Incoming' forever
Some checks failed
Demo images / Build & push demo images (push) Failing after 1m42s

The Home wallet card counted every incoming tx with num_confirmations < 3
in the pulsing 'Incoming N' badge. Lightning history hardcodes 1 conf and
ecash/fedimint/ark receives are normalized to 1 conf, so instant payments
qualified permanently — old receives showed as 'incoming' forever (seen
on .228 with week-old lightning orders).

- On-chain keeps confirmation-based logic (< 3 confs, expires naturally).
- Instant rails (lightning/cashu/fedimint/ark) now surface in the incoming
  panel only for 5 minutes after receipt — the nice arrival moment without
  the perpetual pending state.
- Panel rows: 'Unconfirmed / N conf' badge and mempool link are now
  on-chain-only; instant rows get a rail badge instead.
- Home now polls wallet balances+transactions every 30s (like Web5.vue),
  so pending on-chain receives actually flip to confirmed and the badge
  appears/expires without a manual wallet action.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-22 12:43:53 -04:00
parent 1931371058
commit 7e7b6bd474
2 changed files with 46 additions and 7 deletions

View File

@ -359,6 +359,7 @@ const line2Text = computed(() => showWelcomeBlock.value ? displayLine2.value : L
onBeforeUnmount(() => {
if (typingInterval) clearInterval(typingInterval)
if (systemStatsInterval) clearInterval(systemStatsInterval)
if (walletRefreshInterval) clearInterval(walletRefreshInterval)
})
watch(() => loginTransition.pendingWelcomeTyping, (pending) => { if (pending) showWelcomeBlock.value = true })
@ -519,6 +520,10 @@ const cloudFolderDisplay = computed(() => cloudFolderCount.value !== null ? Stri
onMounted(async () => {
try { const usage = await fileBrowserClient.getUsage(); cloudStorageUsed.value = usage.totalSize; cloudFolderCount.value = usage.folderCount } catch { /* not running */ }
loadSystemStats(); systemStatsInterval = setInterval(loadSystemStats, 10000); checkUpdateStatus(); loadWeb5Status()
// Poll wallet balances/transactions like Web5.vue does without this a
// pending on-chain receive (or a fresh instant payment) only shows up
// after a manual wallet action or a remount.
walletRefreshInterval = setInterval(loadWeb5Status, 30000)
})
// Wallet modals
@ -594,6 +599,7 @@ const systemStats = computed(() => ({
const systemUptimeDisplay = computed(() => { if (homeStatus.stats.uptimeSecs === 0) return t('home.systemMonitoring'); const days = Math.floor(homeStatus.stats.uptimeSecs / 86400); const hours = Math.floor((homeStatus.stats.uptimeSecs % 86400) / 3600); if (days > 0) return `Uptime: ${days}d ${hours}h`; const mins = Math.floor((homeStatus.stats.uptimeSecs % 3600) / 60); return `Uptime: ${hours}h ${mins}m` })
let systemStatsInterval: ReturnType<typeof setInterval> | null = null
let walletRefreshInterval: ReturnType<typeof setInterval> | null = null
async function loadSystemStats() {
await homeStatus.refresh(packages.value)

View File

@ -67,15 +67,16 @@
<div
v-for="tx in incomingTransactions"
:key="tx.tx_hash"
class="flex items-center justify-between gap-3 px-4 py-3 hover:bg-white/5 cursor-pointer transition-colors"
@click="$emit('openInMempool', tx.tx_hash)"
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)"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<div
class="w-7 h-7 rounded-full flex items-center justify-center shrink-0"
:class="tx.num_confirmations === 0 ? 'bg-yellow-500/15' : 'bg-green-500/15'"
:class="isOnchain(tx) && tx.num_confirmations === 0 ? 'bg-yellow-500/15' : 'bg-green-500/15'"
>
<svg class="w-3.5 h-3.5" :class="tx.num_confirmations === 0 ? 'text-yellow-400' : 'text-green-400'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-3.5 h-3.5" :class="isOnchain(tx) && tx.num_confirmations === 0 ? 'text-yellow-400' : 'text-green-400'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</div>
@ -83,18 +84,25 @@
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-green-400">+{{ tx.amount_sats.toLocaleString() }} sats</span>
<span
v-if="isOnchain(tx)"
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
:class="tx.num_confirmations === 0 ? 'bg-yellow-500/15 text-yellow-400' : 'bg-green-500/15 text-green-400'"
>
{{ tx.num_confirmations === 0 ? 'Unconfirmed' : tx.num_confirmations + ' conf' }}
</span>
<span
v-else
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-green-500/15 text-green-400"
>
{{ railBadge(tx) }}
</span>
</div>
<p class="text-[11px] text-white/40 font-mono truncate mt-0.5">{{ tx.tx_hash }}</p>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[11px] text-white/40">{{ formatTxTime(tx.time_stamp) }}</span>
<svg class="w-3.5 h-3.5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg v-if="isOnchain(tx)" class="w-3.5 h-3.5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</div>
@ -181,7 +189,7 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@ -224,11 +232,36 @@ defineEmits<{
const showIncomingTxPanel = ref(false)
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) })
const incomingTransactions = computed(() =>
props.walletTransactions.filter(tx => tx.direction === 'incoming' && tx.num_confirmations < 3)
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
})
)
const incomingTxCount = computed(() => incomingTransactions.value.length)
function railBadge(tx: WalletTransaction): string {
if (tx.kind === 'lightning') return '⚡ Instant'
if (tx.kind === 'cashu') return 'Cashu'
if (tx.kind === 'fedimint') return 'Fedimint'
if (tx.kind === 'ark') return 'Ark'
return ''
}
function formatTxTime(timestamp: number): string {
if (!timestamp) return ''
const date = new Date(timestamp * 1000)