Skip to content

wallet: mint keyset rotation mid-flight wedges receives forever — redeem path never calls refresh_keysets() (Inactive Keyset) #667

Description

@gudnuf

What happened (field-observed, ember seat, 2026-08-11)

minibits went down (HTTP 502) and came back having rotated its keysets. Since 10:38:37Z every buffered payment receive on the seat fails with:

seller node wrap event=…: receive refused for job … (wallet operation failed: Inactive Keyset)

54 such events; the last 40 retries are 37× Inactive Keyset + 3× transport failure. Three delivered-and-unpaid jobs are stuck behind this, and the seat's own 423 sats at that mint are unspendable through the normal path.

The seat is otherwise fine — it kept trading at the other accepted mint (6 jobs, 851 sats, all clean).

The mechanism

Inactive Keyset is raised by cdk on outputs, not inputs:

// cdk-0.17.2/src/mint/verification.rs:72-77
if !keyset.active {
    tracing::debug!("Transaction attempted with inactive keyset in outputs: {}",);
    return Err(Error::InactiveKeyset);
}

Spending old proofs is legal — that is what rotation is for. What the mint refuses is being asked to sign new outputs under a keyset it has retired. So the wallet is constructing blinded outputs against a stale cached keyset and retrying that same doomed request every ~5 minutes, forever.

Manually proven on the seat that the underlying swap is fine:

wallet send 1 --mint <minibits>   ->  sent_sats=1 balance_sats=422   # old inputs accepted

The mint accepted the old inputs and issued a token. So there IS an active keyset and the funds are recoverable — the automatic path just never asks for it.

Why it never self-heals

cdk already provides the machinery — refresh_keysets(), fetch_active_keyset(), get_active_keyset() (cdk-0.17.2/src/wallet/wallet_trait.rs:81-102).

maxplayer calls it in exactly one place, and it is not the redeem path:

// crates/maxplayer-core/src/payment_wallet.rs:586
// "Live active-keyset redeem FEE for `proof_count` inputs"
let keyset = wallet.fetch_active_keyset().await?;

That is fee calculation. The receive/redeem path itself never refreshes, so a rotation mid-flight is unrecoverable without operator intervention.

Suggested fix

On InactiveKeyset from a receive/redeem, call refresh_keysets() and retry once against the now-active keyset, rather than re-queueing the identical request. The error is specific and unambiguous, so this does not need heuristics — and cdk already exposes everything required.

Worth also surfacing it to the operator: today the only signal is a repeating log line that reads like a generic wallet failure.

Please be surgical about WHEN keysets are fetched

The fix is error-triggered, not periodic. Do not turn this into a keyset poll, a refresh-before-every-operation, or a startup-wide prefetch:

  • Fetch only when the mint has actually told us the cached keyset is stale — i.e. on an InactiveKeyset error. That is the one moment we know a refresh is warranted. Rotation is rare; every other request should keep using the cached keyset.
  • One refresh + one retry per failure, then give up and surface it. No refresh loops — if the retry also fails, that is a real error and should be reported, not hidden behind more fetching.
  • Don't refresh on unrelated failures. Transport errors and 5xx are downtime, not rotation; refreshing on those adds network round-trips to a mint that is already struggling, on exactly the path that is failing. (This seat saw both error classes interleaved from the same mint — 37× Inactive Keyset and 3× transport failure — so the distinction is not hypothetical.)
  • Don't add a background keyset poller. It would spend requests continuously to catch an event that happens maybe once a year per mint, and it re-introduces the "monitor the mint" pattern that was explicitly ruled out in fix(buyer): graceful cancel when the mint is offline on first request #664.

The goal is a wallet that reacts correctly to a keyset rotation, not one that keeps checking whether a rotation has happened.

Priority

Not beta-blocking (gudnuf's call) — the seat keeps trading at other mints and the funds are recoverable manually. Filing so it is not lost: it is a silent money-stuck path that a friend running a seat would experience as "payments just never arrived."

Environment

  • maxplayer v0.2.1, macOS/seatbelt seat ember
  • cdk =0.17.2
  • mint: https://mint.minibits.cash/Bitcoin

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions