fix(ecash): harden Minibits claim persistence

This commit is contained in:
archipelago
2026-09-08 21:16:57 -04:00
parent e5a0d95459
commit 973356df16
6 changed files with 462 additions and 80 deletions
@@ -242,13 +242,17 @@ const lnClaimedSats = ref(0)
// operator should see it rather than have it be a silent, unbounded wait.
const lnPendingClaims = ref(0)
let lnClaimTimer: ReturnType<typeof setInterval> | null = null
let lnClaimInFlight = false
async function loadLnAddress() {
if (lnAddress.value || lnAddressLoading.value) return
lnAddressLoading.value = true
lnAddressError.value = false
try {
const res = await rpcClient.call<{ address?: string }>({ method: 'wallet.ecash-lnaddress' })
const res = await rpcClient.call<{ address?: string }>({
method: 'wallet.ecash-lnaddress',
timeout: 60_000,
})
lnAddress.value = res?.address || ''
if (lnAddress.value) {
await nextTick()
@@ -277,14 +281,23 @@ function startLnClaimPoll() {
}
async function pollLnClaims() {
if (lnClaimInFlight) return
if (!props.show || !lnAddress.value) {
stopLnClaimPoll()
return
}
lnClaimInFlight = true
try {
const res = await rpcClient.call<{ received_sats?: number; failed_count?: number }>({
method: 'wallet.ecash-lnaddress-claim',
// Relay collection alone has a ten-second window and redemption may
// then contact the mint. Keep the browser request alive long enough for
// the backend's bounded work instead of timing out and queuing another.
timeout: 90_000,
})
// The user may have closed the modal while the relay fetch was in flight.
// Do not resurrect its status or emit a stale received event afterward.
if (!props.show || !lnAddress.value) return
if (res?.received_sats && res.received_sats > 0) {
lnClaimedSats.value += res.received_sats
emit('received')
@@ -292,6 +305,8 @@ async function pollLnClaims() {
lnPendingClaims.value = res?.failed_count || 0
} catch {
// Transient poll failure (offline, mint busy) — keep polling.
} finally {
lnClaimInFlight = false
}
}
@@ -21,6 +21,10 @@ vi.mock('@/api/rpc-client', () => ({
rpcClient: { call: vi.fn() },
}))
vi.mock('qrcode', () => ({
toCanvas: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/composables/useLightningRequired', () => ({
useLightningRequired: () => ({
requireLightningReady: vi.fn().mockResolvedValue(true),
@@ -16,6 +16,10 @@ vi.mock('@/api/rpc-client', () => ({
rpcClient: { call: vi.fn() },
}))
vi.mock('qrcode', () => ({
toCanvas: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/composables/useLightningRequired', () => ({
useLightningRequired: () => ({
requireLightningReady: vi.fn().mockResolvedValue(true),
@@ -70,4 +74,38 @@ describe('ReceiveBitcoinModal — ecash tab click', () => {
expect(document.body.querySelector('[role="dialog"]')).toBeTruthy()
wrapper.unmount()
})
it('never overlaps slow Lightning-address claim polls', async () => {
vi.useFakeTimers()
let finishClaim!: (value: unknown) => void
const slowClaim = new Promise((resolve) => { finishClaim = resolve })
vi.mocked(rpcClient.call).mockImplementation(async ({ method }: { method: string }) => {
if (method === 'wallet.ecash-lnaddress') {
return { address: 'someone@minibits.cash' } as never
}
if (method === 'wallet.ecash-lnaddress-claim') return slowClaim as never
return {} as never
})
const wrapper = mount(ReceiveBitcoinModal, {
props: { show: true },
attachTo: document.body,
})
const ecashTab = Array.from(document.body.querySelectorAll('button')).find((b) =>
b.textContent?.toLowerCase().includes('ecash'),
)
ecashTab!.dispatchEvent(new Event('click', { bubbles: true }))
await flushPromises()
await vi.advanceTimersByTimeAsync(24_000)
const claimCalls = vi.mocked(rpcClient.call).mock.calls.filter(
([request]) => request.method === 'wallet.ecash-lnaddress-claim',
)
expect(claimCalls).toHaveLength(1)
finishClaim({ claimed_count: 0, received_sats: 0, failed_count: 0 })
await flushPromises()
wrapper.unmount()
vi.useRealTimers()
})
})