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