Skip to content

feat(core)!: defer dust and unreachable-mint receives, redeem in batches - #316

Draft
Kelbie wants to merge 26 commits into
cashubtc:masterfrom
Kelbie:feat/receive-later
Draft

feat(core)!: defer dust and unreachable-mint receives, redeem in batches#316
Kelbie wants to merge 26 commits into
cashubtc:masterfrom
Kelbie:feat/receive-later

Conversation

@Kelbie

@Kelbie Kelbie commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Closes #46

Problem

Two kinds of incoming tokens are dead on arrival today:

  • Dust — under NUT-02 the swap fee is ceil(sum(input_fee_ppk) / 1000), so a
    lone 1-sat proof at 100 ppk pays a 1-sat fee and leaves zero outputs. It can
    never be received on its own, at any time.
  • Unreachable mint — when the mint's keysets cannot be fetched (e.g. offline),
    the token cannot even be decoded, so the receive throws and the token is lost
    unless the app keeps the string around.

Issue #46 proposed queueing these ("receive later") and batching them past the
fee ceiling.

Summary

Receives that cannot settle now persist as a new deferred state on the
receive operation saga (deferredReason: 'dust' | 'mint-unreachable') instead of
failing, and are redeemed later in batched swaps per mint and unit: one swap,
one NUT-02 fee, deterministically apportioned largest-first across members
(apportionFee.ts, order-independent invariants) so dust rides along with larger
receives. Every member still finalizes as its own operation with its own
receive-op:finalized event and history entry.

  • Redemption triggers: an incoming receive() for the same mint+unit drains
    the queue by batching with it (this is how queued dust becomes redeemable); the
    receive recovery sweep finishes with a redemption attempt; and
    ops.receive.redeemDeferred(filter?) runs it explicitly. Groups at or below
    their combined fee stay queued.
  • Crash safety: batch members share a batchId and are never re-executed
    solo (a member's fee share only balances inside its batch). Recovery settles a
    group by its inputs' mint state: spent inputs restore each member from its own
    output data; unspent inputs re-execute the combined swap — but only after
    verifying the stored outputs still satisfy the swap equation
    (sum(outputs) + fee == sum(inputs)) against a freshly computed fee, so a
    crash between persisting members or a keyset fee change requeues the group
    instead of replaying an unbalanced swap.
  • Queue hygiene: queued members' inputs are validated with the mint before
    each batch (one NUT-07 check per group). Already-spent members roll back
    terminally so one double-spent proof cannot wedge every future atomic swap;
    pending members sit the round out. A fresh receive that batched with the queue
    is never failed by the queue — on batch failure it falls back to the solo path.
  • Offline decode: TokenService now decodes tokens from a known mint using
    cached keysets when the refresh fails, which is what makes the
    mint-unreachable deferral possible. Fees are still computed from a live
    wallet, and the original fetch failure is preserved as the
    TokenValidationError cause.
  • API surface: ops.receive.listDeferred(), ops.receive.redeemDeferred(filter?),
    cancel() accepts deferred operations, listInFlight() includes them, a new
    receive-op:deferred event, and useReceiveOperation passthroughs in the
    React package.
  • Storage: SQL migration 037_receive_operations_deferred rebuilds the
    receive table (SQLite cannot alter a CHECK constraint) adding the widened state
    union plus nullable deferredReason / batchId; IndexedDB persists the same
    fields with no Dexie version bump. Contract coverage added in adapter-tests.
  • The NUT-13 counter serialization from fix(core): serialize receive prepare per mint to prevent NUT-13 counter collisions #307 is honored: batch output derivation
    runs under the same Manager-injected MintScopedLock as prepare() and the
    send/melt/mint services.

Design notes

  • Saga state, not a separate table. Improvement: Receive later #46 sketched a receive_later table;
    this models deferral as a state of the existing receive saga instead, which
    inherits durable persistence, crash recovery, locking, events, and the
    one-operation → one-history-entry projection — and matches the direction in
    recent PRDs of inferring state from operations rather than adding tables
    (e.g. Preserve reusable Mint Quote Reservations under canonical accounting #302's "no separate persisted reservation table").
  • Deviation from Improvement: Receive later #46's scope: p2pk is not deferred. Improvement: Receive later #46 listed p2pk proofs as
    a receive-later candidate. An earlier iteration deferred them; this PR
    deliberately reverts that: a p2pk receive without the signing key now throws a
    typed KeyPairNotFoundError at init. A missing key is a configuration error
    the app should surface, not a timing problem the wallet can resolve on its own.
    Happy to revisit if you'd rather keep p2pk deferral in scope.
  • Deferred operations carry no PreparedData; fees and outputs are recomputed at
    redemption time, so a stale fee is never replayed.

Breaking changes (major changesets on core)

  • ReceiveOperationState / ReceiveOperation unions gain deferred
    exhaustive state handling downstream must account for it.
  • wallet.receive() / ReceiveOperationService.receive() return
    FinalizedReceiveOperation | DeferredReceiveOperation (previously
    Promise<void>), and ops.receive.prepare() can return a deferred operation
    callers must branch on.
  • Key ring signing throws typed KeyPairNotFoundError instead of a plain
    Error when the key pair is missing.

Verification

  • bun run typecheck — all 8 packages pass
  • bun --cwd packages/core test test/unit — 1,062 pass
  • bun --cwd packages/react run test — 49 pass
  • bun --cwd packages/sql-storage test — 25 pass (includes migration-list and
    037 schema coverage)
  • bun --cwd packages/indexeddb run test:browser — contract suite passes
    (Chromium); mint-backed integration suites (./scripts/test-integration.sh)
    run in CI
  • Live end-to-end run against testnut.cashu.space (100 ppk sat keyset): a real
    1-sat token deferred as dust; an explicit redeemDeferred() correctly left
    the lone-dust group queued; a later 10-sat receive drained it in one batched
    swap — the 10-sat member absorbed the whole 1-sat fee (kept 9), the dust
    member kept its full 1 sat, both finalized under one batchId with their own
    events, and the queue emptied.

Changeset

  • defer-dust-offline-receives — core major (deferred prepare/receive flow)
  • deferred-receive-state — core major (state union widened)
  • batch-deferred-redemption — core minor (batch executor + safety guards)
  • deferred-redemption-triggers — core minor (auto-drain triggers)
  • deferred-receive-ops-api — core minor (listDeferred / redeemDeferred)
  • typed-keypair-not-found — core minor (typed p2pk signing error)
  • cached-keyset-token-decode — core patch (offline decode)
  • receive-recovery-spent-inputs — core patch
  • deferred-receive-sql-storage — sql-storage minor + sqlite/sqlite-bun/expo-sqlite patch (migration 037)
  • deferred-receive-indexeddb — indexeddb minor
  • deferred-receive-react-hook — react minor
  • deferred-receive-contract-tests — adapter-tests patch

🤖 Generated with Claude Code

Kelbie and others added 26 commits July 2, 2026 12:31
…ferring

Drop the p2pk-unsigned defer reason. When the unlock key is missing, init now
throws (typed KeyPairNotFoundError) as it did before deferred receives, rather
than persisting the raw proofs as a deferred operation for later re-signing.
Dust and mint-unreachable deferral are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ferring

Drop the p2pk-unsigned defer reason. When the unlock key is missing, init now
throws (typed KeyPairNotFoundError) as it did before deferred receives, rather
than persisting the raw proofs as a deferred operation for later re-signing.
Dust and mint-unreachable deferral are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… inputs

Three fixes from an audit of the receive-later work:

- Recovery of an interrupted batch now verifies the stored outputs still
  satisfy the swap equation (sum(outputs) + fee == sum(inputs)) before
  re-executing. A crash between persisting members could leave a subset
  whose outputs were sized against the whole batch fee; replaying that
  subset relied on the mint rejecting the imbalance. Unbalanced groups
  requeue and re-batch with fresh outputs, which also covers keyset fee
  changes during the interruption.

- Queued members are validated with the mint before each batch: members
  with spent inputs (e.g. sender double-spent a queued token) roll back
  terminally instead of failing every future atomic batch swap, and
  members with pending inputs sit the round out. markAsRolledBack now
  accepts deferred operations and persists an empty prepared payload.

- A fresh receive that batched with the queue is no longer failed by the
  queue: on batch failure the incoming operation is restored to init and
  receive() falls back to the solo path (or reports the operation queued),
  instead of throwing and parking a receivable token as deferred.

Also removes a stale p2pk reference from the redeemDeferred docstring and
documents the lock-ordering invariant that keeps the batch path and
prepare/execute deadlock-free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The merge of upstream/master auto-resolved without textual conflicts but
left three semantic defects:

- ReceiveOperationService kept both mint-scoped locks: our self-instantiated
  field and upstream's Manager-injected one (d4c8a99). Keep the injected
  instance so batch redemption serializes NUT-13 counter derivation on the
  same lock as prepare() and the send/melt/mint services.
- prepare() declared its result as PreparedReceiveOperation while
  prepareInternal can also return a deferred operation.
- The receive-op:prepared emit that upstream moved out of prepareInternal
  became unconditional; deferred operations already emit receive-op:deferred,
  so guard it to prepared results.

Also adds migration 037_receive_operations_deferred to the shared SQL
schema test's expected migration list (missed when the migration was
introduced).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop the stale p2pk re-signing sentence (that defer path was reverted) and
describe the pre-batch spent-input validation, solo fallback, and recovery
balance guard added by the audit fixes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a189db7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 8 packages
Name Type
@cashu/coco-core Major
@cashu/coco-adapter-tests Major
@cashu/coco-indexeddb Major
@cashu/coco-react Major
@cashu/coco-sql-storage Major
@cashu/coco-sqlite Major
@cashu/coco-sqlite-bun Major
@cashu/coco-expo-sqlite Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

Improvement: Receive later

1 participant