feat(core)!: defer dust and unreachable-mint receives, redeem in batches - #316
Draft
Kelbie wants to merge 26 commits into
Draft
feat(core)!: defer dust and unreachable-mint receives, redeem in batches#316Kelbie wants to merge 26 commits into
Kelbie wants to merge 26 commits into
Conversation
…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>
…nto feat/receive-later
… 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 detectedLatest commit: a189db7 The changes in this PR will be included in the next version bump. This PR includes changesets to release 8 packages
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #46
Problem
Two kinds of incoming tokens are dead on arrival today:
ceil(sum(input_fee_ppk) / 1000), so alone 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.
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
deferredstate on thereceive operation saga (
deferredReason: 'dust' | 'mint-unreachable') instead offailing, 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 largerreceives. Every member still finalizes as its own operation with its own
receive-op:finalizedevent and history entry.receive()for the same mint+unit drainsthe 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 belowtheir combined fee stay queued.
batchIdand are never re-executedsolo (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 acrash between persisting members or a keyset fee change requeues the group
instead of replaying an unbalanced swap.
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.
TokenServicenow decodes tokens from a known mint usingcached keysets when the refresh fails, which is what makes the
mint-unreachabledeferral possible. Fees are still computed from a livewallet, and the original fetch failure is preserved as the
TokenValidationErrorcause.ops.receive.listDeferred(),ops.receive.redeemDeferred(filter?),cancel()accepts deferred operations,listInFlight()includes them, a newreceive-op:deferredevent, anduseReceiveOperationpassthroughs in theReact package.
037_receive_operations_deferredrebuilds thereceive table (SQLite cannot alter a CHECK constraint) adding the widened state
union plus nullable
deferredReason/batchId; IndexedDB persists the samefields with no Dexie version bump. Contract coverage added in adapter-tests.
runs under the same Manager-injected
MintScopedLockasprepare()and thesend/melt/mint services.
Design notes
receive_latertable;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").
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
KeyPairNotFoundErrorat init. A missing key is a configuration errorthe 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.
PreparedData; fees and outputs are recomputed atredemption time, so a stale fee is never replayed.
Breaking changes (major changesets on core)
ReceiveOperationState/ReceiveOperationunions gaindeferred—exhaustive state handling downstream must account for it.
wallet.receive()/ReceiveOperationService.receive()returnFinalizedReceiveOperation | DeferredReceiveOperation(previouslyPromise<void>), andops.receive.prepare()can return a deferred operationcallers must branch on.
KeyPairNotFoundErrorinstead of a plainErrorwhen the key pair is missing.Verification
bun run typecheck— all 8 packages passbun --cwd packages/core test test/unit— 1,062 passbun --cwd packages/react run test— 49 passbun --cwd packages/sql-storage test— 25 pass (includes migration-list and037 schema coverage)
bun --cwd packages/indexeddb run test:browser— contract suite passes(Chromium); mint-backed integration suites (
./scripts/test-integration.sh)run in CI
testnut.cashu.space(100 ppk sat keyset): a real1-sat token deferred as
dust; an explicitredeemDeferred()correctly leftthe 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
batchIdwith their ownevents, 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 patchdeferred-receive-sql-storage— sql-storage minor + sqlite/sqlite-bun/expo-sqlite patch (migration 037)deferred-receive-indexeddb— indexeddb minordeferred-receive-react-hook— react minordeferred-receive-contract-tests— adapter-tests patch🤖 Generated with Claude Code