Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e9b81e5
test(core): characterize dust, missing-key, and offline receive failures
Kelbie Jul 2, 2026
0144c63
feat(core): throw typed KeyPairNotFoundError from key ring signing
Kelbie Jul 2, 2026
9b86688
feat(core)!: add deferred state to the receive operation model
Kelbie Jul 2, 2026
c610607
feat(core): include deferred receives in pending queries
Kelbie Jul 2, 2026
7ec2f73
feat(sql-storage): persist deferred receive operations
Kelbie Jul 2, 2026
de71967
feat(indexeddb): persist deferred receive operations
Kelbie Jul 2, 2026
c8542e1
test(adapter-tests): contract coverage for deferred receive operations
Kelbie Jul 2, 2026
1a7ed10
fix(core): preserve mint fetch failures when decoding receive tokens
Kelbie Jul 2, 2026
a278376
feat(core)!: defer dust and unreachable-mint receives instead of failing
Kelbie Jul 2, 2026
9ceaaf9
feat(core): defer p2pk receives when the signing key is missing
Kelbie Jul 2, 2026
f8d655a
feat(core): deterministic fee apportionment for batched receives
Kelbie Jul 2, 2026
e28959f
feat(core): batch redemption executor for deferred receives
Kelbie Jul 2, 2026
1424744
feat(core): recover interrupted batch receive redemptions
Kelbie Jul 2, 2026
a989fb0
feat(core): trigger deferred redemption from receive flow and recover…
Kelbie Jul 2, 2026
b55f2dc
feat(core): expose deferred receives through the ops API
Kelbie Jul 2, 2026
37d7295
feat(react): surface deferred receives in useReceiveOperation
Kelbie Jul 2, 2026
d48363b
docs: document deferred receives
Kelbie Jul 2, 2026
d6fa0a3
test(adapter-tests): integration coverage for deferred receive redemp…
Kelbie Jul 2, 2026
fbb7838
style: format receive-later changes with prettier
Kelbie Jul 2, 2026
1e13ad7
feat(core)!: fail p2pk receives without the signing key instead of de…
Kelbie Jul 7, 2026
b1f8cf6
feat(core)!: fail p2pk receives without the signing key instead of de…
Kelbie Jul 7, 2026
71dfbad
Merge branch 'feat/receive-later' of https://github.com/Kelbie/coco i…
Kelbie Jul 7, 2026
d95efff
fix(core): harden deferred receive batching against crashes and spent…
Kelbie Jul 14, 2026
e229179
Merge remote-tracking branch 'upstream/master' into feat/receive-later
Kelbie Jul 14, 2026
3e92a72
fix(core): reconcile receive-later with upstream after merge
Kelbie Jul 14, 2026
a189db7
docs(changeset): align batch redemption changeset with shipped behavior
Kelbie Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/batch-deferred-redemption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@cashu/coco-core': minor
---

Add batched redemption for deferred receives: `redeemDeferred()` settles each viable
(mint, unit) group with one swap whose single NUT-02 fee is deterministically apportioned
across the members, while every member still finalizes as its own operation with its own
event and history entry. Groups below the combined fee stay queued. Queued members whose
inputs are already spent at the mint roll back before batching so one poisoned proof
cannot wedge the queue; on terminal mint errors spent members settle or roll back
individually while unspent members return to the queue, and a fresh receive that batched
with the queue falls back to a solo receive instead of failing. Recovery of an
interrupted batch re-executes the stored outputs only after verifying they still satisfy
the swap equation against a freshly computed fee.
7 changes: 7 additions & 0 deletions .changeset/cached-keyset-token-decode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@cashu/coco-core': patch
---

Token decoding for a known mint now falls back to cached keysets when the mint refresh
fails (e.g. offline), and mint fetch failures are preserved as the `cause` of the thrown
`TokenValidationError` instead of being swallowed.
12 changes: 12 additions & 0 deletions .changeset/defer-dust-offline-receives.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@cashu/coco-core': major
---

Defer dust and unreachable-mint receives instead of failing. `prepare` now transitions
init → `deferred` (reasons `dust` / `mint-unreachable`) and emits a new
`receive-op:deferred` event rather than throwing and deleting the operation.
`wallet.receive()` / `ReceiveOperationService.receive()` now return the finalized or
deferred operation (previously `Promise<void>`), `ops.receive.prepare()` can return a
deferred operation callers must branch on, and deferred operations can be cancelled via
rollback. Payment-request attempts whose child receive defers rest in `receiving` until a
later redemption sweep settles them.
6 changes: 6 additions & 0 deletions .changeset/deferred-receive-contract-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@cashu/coco-adapter-tests': patch
---

Add repository contract coverage for deferred receive operations: deferred round-trips,
`batchId` round-trips, and `getPending` including executing and deferred states.
6 changes: 6 additions & 0 deletions .changeset/deferred-receive-indexeddb.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@cashu/coco-indexeddb': minor
---

Persist deferred receive operations (`deferred` state, `deferredReason`, `batchId`) and
include them in pending queries. No Dexie schema version bump is required.
7 changes: 7 additions & 0 deletions .changeset/deferred-receive-ops-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@cashu/coco-core': minor
---

Expose deferred receives through the ops API: `ops.receive.listDeferred()`,
`ops.receive.redeemDeferred(filter?)`, `cancel()` now accepts deferred operations, and
`listInFlight()` includes deferred alongside executing operations.
7 changes: 7 additions & 0 deletions .changeset/deferred-receive-react-hook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@cashu/coco-react': minor
---

Surface deferred receives in `useReceiveOperation`: the hook binds deferred prepare
results, observes `receive-op:deferred` events, treats cancelled deferred operations like
cancelled inits, and adds `listDeferred()` / `redeemDeferred(filter?)` passthroughs.
10 changes: 10 additions & 0 deletions .changeset/deferred-receive-sql-storage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@cashu/coco-sql-storage': minor
'@cashu/coco-sqlite': patch
'@cashu/coco-sqlite-bun': patch
'@cashu/coco-expo-sqlite': patch
---

Persist deferred receive operations: migration `037_receive_operations_deferred` rebuilds
`coco_cashu_receive_operations` with a `deferred` state, `deferredReason`, and `batchId`
columns, and pending queries now include deferred operations.
9 changes: 9 additions & 0 deletions .changeset/deferred-receive-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@cashu/coco-core': major
---

Add a `deferred` state to the receive operation saga. Receives that cannot be settled yet
(dust below the swap fee, unreachable mints) are now modeled as
`DeferredReceiveOperation` with a `deferredReason`, and batched redemptions link members via
`batchId`. The `ReceiveOperationState` union and `ReceiveOperation` discriminated union are
widened, so downstream exhaustive state handling must account for `deferred`.
9 changes: 9 additions & 0 deletions .changeset/deferred-redemption-triggers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@cashu/coco-core': minor
---

Trigger deferred receive redemption automatically: an incoming `receive()` drains queued
deferred operations of the same mint and unit into its own batched swap (this is how
queued dust becomes redeemable), and the receive recovery sweep — already run at startup
and via `ops.receive.recovery.run()` — finishes by attempting to redeem every queued
group, tolerating unreachable mints.
6 changes: 6 additions & 0 deletions .changeset/typed-keypair-not-found.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@cashu/coco-core': minor
---

Throw a typed `KeyPairNotFoundError` (carrying the missing public key) from key ring
signing instead of a plain `Error`, so callers can branch on missing-key failures.
12 changes: 12 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@ The time after which a quote can no longer receive a new payment. Expiry does no
value that was already paid before expiry.
_Avoid_: Claim deadline, quote invalidity

**Deferred Receive**:
A persisted receive operation whose redemption is postponed until it can be settled
fee-efficiently or its prerequisites exist (dust below the swap fee, or an unreachable
mint).
_Avoid_: Queued token, pending receive, receive later table

**Batch Redemption**:
Settling several deferred receives with one mint swap whose single fee is apportioned
across the members. Each member still finalizes as its own operation with its own history
entry.
_Avoid_: Sweep, merge, combined receive

**Background Watcher**:
A session-scoped automatic observer that keeps wallet state progressing without a direct caller
waiting on a specific result. Disabling a Background Watcher does not disable explicit caller
Expand Down
85 changes: 85 additions & 0 deletions packages/adapter-tests/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,15 @@ export function createDummyReceiveOperation(): ReceiveOperation {
} satisfies ReceiveOperation;
}

export function createDummyDeferredReceiveOperation(): ReceiveOperation {
return {
...createDummyReceiveOperation(),
id: 'receive-op-deferred',
state: 'deferred',
deferredReason: 'dust',
} as ReceiveOperation;
}

export function createDummyPaymentRequestReceiveOperation(
overrides?: Partial<PaymentRequestReceiveOperation>,
): PaymentRequestReceiveOperation {
Expand Down Expand Up @@ -1118,6 +1127,82 @@ export async function runReceiveOperationRepositoryContract(
await dispose();
}
});

it('round-trips deferred operations without prepared data', async () => {
const { repositories, dispose } = await options.createRepositories();
try {
const operation = createDummyDeferredReceiveOperation();
await repositories.receiveOperationRepository.create(operation);

const stored = await repositories.receiveOperationRepository.getById(operation.id);

expect(stored).toBeDefined();
expect(stored!.state).toBe('deferred');
if (stored!.state === 'deferred') {
expect(stored!.deferredReason).toBe('dust');
}
expect(stored!.amount.equals(Amount.from(3))).toBe(true);
expect(stored!.inputProofs).toHaveLength(2);
} finally {
await dispose();
}
});

it('round-trips batchId on executing operations', async () => {
const { repositories, dispose } = await options.createRepositories();
try {
const operation = {
...createDummyReceiveOperation(),
id: 'receive-op-batch',
state: 'executing',
fee: Amount.from(1),
outputData: { keep: [], send: [] },
batchId: 'batch-1',
} satisfies ReceiveOperation;
await repositories.receiveOperationRepository.create(operation);

const stored = await repositories.receiveOperationRepository.getById(operation.id);

expect(stored).toBeDefined();
expect(stored!.batchId).toBe('batch-1');
} finally {
await dispose();
}
});

it('returns executing and deferred operations from getPending', async () => {
const { repositories, dispose } = await options.createRepositories();
try {
const repo = repositories.receiveOperationRepository;
await repo.create({ ...createDummyReceiveOperation(), id: 'receive-op-init' });
await repo.create(createDummyDeferredReceiveOperation());
await repo.create({
...createDummyReceiveOperation(),
id: 'receive-op-executing',
state: 'executing',
fee: Amount.from(1),
outputData: { keep: [], send: [] },
} satisfies ReceiveOperation);
await repo.create({
...createDummyReceiveOperation(),
id: 'receive-op-finalized',
state: 'finalized',
fee: Amount.from(1),
outputData: { keep: [], send: [] },
} satisfies ReceiveOperation);

const pending = await repo.getPending();

expect(
pending
.map((op) => op.id)
.sort()
.join(','),
).toBe('receive-op-deferred,receive-op-executing');
} finally {
await dispose();
}
});
});
}

Expand Down
140 changes: 140 additions & 0 deletions packages/adapter-tests/src/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3041,6 +3041,146 @@ export async function runIntegrationTests<TRepositories extends Repositories = R
});
});

describe('Deferred Receives', () => {
let repositoriesDispose: (() => Promise<void>) | undefined;
let repositories: Repositories | undefined;

beforeEach(async () => {
const created = await createRepositories();
repositories = created.repositories;
repositoriesDispose = created.dispose;
mgr = await initializeCoco({
repo: created.repositories,
seedGetter,
logger,
});

await mgr.mint.addMint(mintUrl, { trusted: true });
await mintAmount(mgr!, mintUrl, 200, testUnit);
});

afterEach(async () => {
if (repositoriesDispose) {
await repositoriesDispose();
repositoriesDispose = undefined;
}
repositories = undefined;
});

const sendToken = async (amount: number): Promise<Token> => {
const preparedSend = await mgr!.ops.send.prepare({
mintUrl,
amount: testAmount(amount),
});
const { token } = await mgr!.ops.send.execute(preparedSend.id);
return token;
};

it('defers a dust token and settles it together with the next receive', async () => {
// Send both tokens up front so the spendable balance baseline is
// not disturbed between the two receives.
const dustToken = await sendToken(1);
const secondToken = await sendToken(32);
const balanceBefore = await getMintSpendableBalance(mgr!, mintUrl, testUnit);

const deferredEventPromise = waitForEvent<{
operationId: string;
operation: { state: string };
}>(mgr!, 'receive-op:deferred', (payload) => payload.operation.state === 'deferred');

const dustResult = await mgr!.wallet.receive(dustToken);
if (dustResult.state === 'finalized') {
// The mint charges no input fees (e.g. the custom-unit run), so
// dust below the fee cannot exist; nothing to assert here.
return;
}

expect(dustResult.state).toBe('deferred');
expect(dustResult.deferredReason).toBe('dust');
const deferredEvent = await deferredEventPromise;
expect(deferredEvent.operationId).toBe(dustResult.id);

const inFlight = await mgr!.ops.receive.listInFlight();
expect(inFlight.map((op) => op.id)).toContain(dustResult.id);
const queued = await mgr!.ops.receive.listDeferred();
expect(queued.map((op) => op.id)).toContain(dustResult.id);

// No history entry and no balance change while queued.
const history = await mgr!.history.getPaginatedHistory(0, 50);
expect(
history.some((entry) => 'operationId' in entry && entry.operationId === dustResult.id),
).toBe(false);
expect(await getMintSpendableBalance(mgr!, mintUrl, testUnit)).toBe(balanceBefore);

// A second receive for the same mint and unit drains the queue.
const finalizedIds = new Set<string>();
const bothFinalized = new Promise<void>((resolve) => {
const unsubscribe = mgr!.on('receive-op:finalized', ({ operationId }) => {
finalizedIds.add(operationId);
if (finalizedIds.size >= 2) {
unsubscribe();
resolve();
}
});
});

const secondResult = await mgr!.wallet.receive(secondToken);
expect(secondResult.state).toBe('finalized');
await bothFinalized;

const dustAfter = await mgr!.ops.receive.get(dustResult.id);
expect(dustAfter?.state).toBe('finalized');
expect(dustAfter?.batchId).toBeDefined();
expect(dustAfter?.batchId).toBe(secondResult.batchId!);

// Two independent history entries, one per operation.
const historyAfter = await mgr!.history.getPaginatedHistory(0, 50);
const receiveEntries = historyAfter.filter(
(entry) =>
'operationId' in entry &&
(entry.operationId === dustResult.id || entry.operationId === secondResult.id),
);
expect(receiveEntries.length).toBe(2);

// 33 in, one 1-unit batch fee: net +32.
expect(await getMintSpendableBalance(mgr!, mintUrl, testUnit)).toBe(balanceBefore + 32);
}, 30000);

it('keeps queued dust across a restart and drains it with a later receive', async () => {
const dustToken = await sendToken(1);
const laterToken = await sendToken(32);
const balanceBefore = await getMintSpendableBalance(mgr!, mintUrl, testUnit);

const dustResult = await mgr!.wallet.receive(dustToken);
if (dustResult.state === 'finalized') {
// Fee-free mint: dust cannot exist.
return;
}
expect(dustResult.deferredReason).toBe('dust');

await mgr!.pauseSubscriptions();
await mgr!.dispose();

// Restarting on the same repositories runs the recovery sweep. A
// lone dust operation stays below the fee, so it must survive the
// restart still queued rather than being cleaned up or rolled back.
mgr = await initializeCoco({
repo: repositories!,
seedGetter,
logger,
});

const afterRestart = await mgr!.ops.receive.get(dustResult.id);
expect(afterRestart?.state).toBe('deferred');

// A fresh receive after the restart drains the persisted queue.
const laterResult = await mgr!.wallet.receive(laterToken);
expect(laterResult.state).toBe('finalized');
expect((await mgr!.ops.receive.get(dustResult.id))?.state).toBe('finalized');
expect(await getMintSpendableBalance(mgr!, mintUrl, testUnit)).toBe(balanceBefore + 32);
}, 30000);
});

describe('Wallet Restore', () => {
it('should sweep a mint from another seed', async () => {
const { repositories, dispose } = await createRepositories();
Expand Down
2 changes: 2 additions & 0 deletions packages/core/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export type {
PaymentRequestReceiveOperation,
PaymentRequestReceiveState,
PaymentRequestReceiveTransport,
DeferredReceiveOperation,
DeferredReceiveReason,
ReceiveOperation,
ReceiveOperationState,
SendMethod,
Expand Down
Loading
Loading