fix(ecash): guard Minibits claim polls against races and stop replayed claims retrying forever
The UI polls wallet.ecash-lnaddress-claim every 8s, but a single poll (auth + /claim + relay fetch + redeem loop) can outlast that interval. Two overlapping claim_and_redeem runs then loaded the same last_dm_seen_at, fetched/redeemed the same claims, and last-writer-wins on save — rewinding the watermark and/or double-redeeming. A double-redeemed or state-loss-replayed claim then failed forever as "already spent" with no way to leave pending_claims, leaving a permanent orange retry banner. - STATE_LOCK (backend) + an in-flight guard (UI) serialize claim polls and the lnaddress registration/token-refresh path, so two callers can't race on minibits.json. - pending_claims now tracks per-claim attempts (PendingClaim, migrating transparently from the old plain-string shape); a claim that fails MAX_CLAIM_ATTEMPTS times is dropped instead of retried forever. - A redeem failure recognized as mint error 11001 (already redeemed) is treated as terminal and dropped immediately — the value was already swept, so retrying it is pointless. ClaimOutcome gains dropped_count so the two drop reasons (harmless vs. real loss) are visible to the caller. - save_state now writes via temp-file + rename instead of truncating minibits.json in place — the exact disk-full failure mode that corrupted this file on archy-x250-pa3, 2026-09-08, could otherwise destroy pending_claims tokens that /claim had already consumed server-side (unrecoverable, unlike relay DMs). - minibits_error no longer panics on a multi-byte UTF-8 boundary when truncating a server error body (was byte-slicing, not char-safe). - register_profile's name-collision check now matches the structured error.name == ALREADY_EXISTS instead of a raw "already" substring, so an unrelated error message doesn't burn a retry attempt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZnFgeUBKY5UAfyJFsYccS
This commit is contained in:
@@ -242,6 +242,10 @@ 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
|
||||
// A poll can outlast the 8s interval (backend auth + relay fetch + redeem
|
||||
// loop) — without this, the next tick fires on top of it and both calls hit
|
||||
// the backend's `minibits.json` at once.
|
||||
let lnPollInFlight = false
|
||||
|
||||
async function loadLnAddress() {
|
||||
if (lnAddress.value || lnAddressLoading.value) return
|
||||
@@ -281,6 +285,8 @@ async function pollLnClaims() {
|
||||
stopLnClaimPoll()
|
||||
return
|
||||
}
|
||||
if (lnPollInFlight) return
|
||||
lnPollInFlight = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ received_sats?: number; failed_count?: number }>({
|
||||
method: 'wallet.ecash-lnaddress-claim',
|
||||
@@ -292,6 +298,8 @@ async function pollLnClaims() {
|
||||
lnPendingClaims.value = res?.failed_count || 0
|
||||
} catch {
|
||||
// Transient poll failure (offline, mint busy) — keep polling.
|
||||
} finally {
|
||||
lnPollInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,3 +71,59 @@ describe('ReceiveBitcoinModal — ecash tab click', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
// Regression guard for the overlapping-claim race: a single
|
||||
// wallet.ecash-lnaddress-claim call can outlast the 8s poll interval (backend
|
||||
// auth + relay fetch + redeem loop), and a second call firing on top of it
|
||||
// raced on the backend's minibits.json (see minibits.rs STATE_LOCK).
|
||||
describe('ReceiveBitcoinModal — ecash claim poll', () => {
|
||||
it('does not start a second claim poll while one is still in flight', async () => {
|
||||
vi.useFakeTimers()
|
||||
let resolveClaim: (v: unknown) => void = () => {}
|
||||
vi.mocked(rpcClient.call).mockImplementation((args: unknown) => {
|
||||
const method = (args as { method?: string })?.method
|
||||
if (method === 'wallet.ecash-lnaddress') {
|
||||
return Promise.resolve({ address: 'someone@minibits.cash' } as never)
|
||||
}
|
||||
if (method === 'wallet.ecash-lnaddress-claim') {
|
||||
return new Promise((resolve) => {
|
||||
resolveClaim = resolve
|
||||
}) as never
|
||||
}
|
||||
return Promise.resolve({} as never)
|
||||
})
|
||||
|
||||
const wrapper = mount(ReceiveBitcoinModal, {
|
||||
props: { show: true },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const tabs = Array.from(document.body.querySelectorAll('button'))
|
||||
const ecashTab = tabs.find((b) => b.textContent?.toLowerCase().includes('ecash'))
|
||||
ecashTab!.dispatchEvent(new Event('click', { bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
const claimCalls = () =>
|
||||
vi
|
||||
.mocked(rpcClient.call)
|
||||
.mock.calls.filter(([a]) => (a as { method?: string })?.method === 'wallet.ecash-lnaddress-claim').length
|
||||
|
||||
await vi.advanceTimersByTimeAsync(8000)
|
||||
expect(claimCalls()).toBe(1)
|
||||
|
||||
// Second tick fires while the first claim call is still unresolved.
|
||||
await vi.advanceTimersByTimeAsync(8000)
|
||||
expect(claimCalls()).toBe(1)
|
||||
|
||||
resolveClaim({ received_sats: 0, failed_count: 0 })
|
||||
await flushPromises()
|
||||
|
||||
// Once the in-flight call finishes, the next tick is free to poll again.
|
||||
await vi.advanceTimersByTimeAsync(8000)
|
||||
expect(claimCalls()).toBe(2)
|
||||
|
||||
wrapper.unmount()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user