fix(ecash): stop Minibits LN-address claims from being silently lost

A Minibits /claim response consumes the payment server-side the instant
it's returned — it can never be re-fetched. claim_and_redeem previously
decrypted/redeemed each claim inline and just warn!-logged any failure,
so a mint-unreachable blip, a stale cached server key, or an operator
who'd edited their accepted-mints list to drop the default mint (via
streaming.configure-mints) could make a real payment vanish with
nothing but a log line to show for it — claimed_count/received_sats
still came back as a clean 0, identical to "nothing arrived."

Now: every fetched claim is persisted to MinibitsState.pending_claims
before decrypt/redeem is attempted, survives failures across polls
instead of being dropped, and claim_and_redeem no longer bails out on a
fetch error without first retrying whatever was already pending.
ensure_mint_accepted self-heals the accepted-mints allow-list so the
Minibits mint (the address is inherently backed by it) can't be
excluded out from under a claim. ClaimOutcome gains failed_count,
threaded through wallet.ecash-lnaddress-claim and shown in
ReceiveBitcoinModal so a stuck claim is visible instead of silent.

Also fixes the server_nostur_pubkey field-name typo (no live state to
migrate — this feature hasn't shipped yet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
This commit is contained in:
2026-09-08 13:24:11 +00:00
co-authored by Claude Sonnet 5
parent 6effc6b574
commit 76d565fb18
5 changed files with 174 additions and 39 deletions
@@ -119,6 +119,9 @@
<p v-if="lnClaimedSats > 0" class="text-green-400 text-sm mt-2">
{{ t('receiveBitcoin.lnAddressReceived', { amount: lnClaimedSats.toLocaleString() }) }}
</p>
<p v-if="lnPendingClaims > 0" class="text-orange-400 text-sm mt-2">
{{ t('receiveBitcoin.lnAddressPendingRetry', { count: lnPendingClaims }) }}
</p>
</div>
<div v-else-if="lnAddressLoading" class="mb-4 text-center text-white/50 text-sm py-4">
{{ t('receiveBitcoin.lnAddressLoading') }}
@@ -201,6 +204,7 @@ watch(() => props.show, (open) => {
lnAddressLoading.value = false
lnAddressError.value = false
lnClaimedSats.value = 0
lnPendingClaims.value = 0
error.value = ''
processing.value = false
if (props.autoGenerate && receiveMethod.value === 'onchain') {
@@ -233,6 +237,10 @@ const lnAddress = ref('')
const lnAddressLoading = ref(false)
const lnAddressError = ref(false)
const lnClaimedSats = ref(0)
// A payment the backend fetched (and so already consumed at Minibits) but
// couldn't redeem yet — it's queued for automatic retry, not lost, but the
// operator should see it rather than have it be a silent, unbounded wait.
const lnPendingClaims = ref(0)
let lnClaimTimer: ReturnType<typeof setInterval> | null = null
async function loadLnAddress() {
@@ -274,13 +282,14 @@ async function pollLnClaims() {
return
}
try {
const res = await rpcClient.call<{ received_sats?: number }>({
const res = await rpcClient.call<{ received_sats?: number; failed_count?: number }>({
method: 'wallet.ecash-lnaddress-claim',
})
if (res?.received_sats && res.received_sats > 0) {
lnClaimedSats.value += res.received_sats
emit('received')
}
lnPendingClaims.value = res?.failed_count || 0
} catch {
// Transient poll failure (offline, mint busy) — keep polling.
}
@@ -415,6 +424,7 @@ function close() {
ecashResult.value = ''
lnAddress.value = ''
lnClaimedSats.value = 0
lnPendingClaims.value = 0
error.value = ''
emit('close')
}