Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions .changeset/canonical-mint-quote-claimability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@cashu/coco-core': patch
'@cashu/coco-adapter-tests': patch
'@cashu/coco-sqlite': patch
'@cashu/coco-sqlite-bun': patch
'@cashu/coco-expo-sqlite': patch
'@cashu/coco-indexeddb': patch
---

Centralize Mint Quote Claimability on canonical accounting so atomic BOLT11 and balance-based
BOLT12/on-chain callers share readiness, reservation, scheduling, and recovery behavior.
13 changes: 13 additions & 0 deletions .changeset/secure-bolt11-foundations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@cashu/coco-core': minor
'@cashu/coco-sql-storage': patch
'@cashu/coco-sqlite': patch
'@cashu/coco-sqlite-bun': patch
'@cashu/coco-expo-sqlite': patch
'@cashu/coco-indexeddb': patch
'@cashu/coco-adapter-tests': patch
---

Add canonical BOLT11 accounting, opt-in NUT-20 locked quote handling, readable diagnostics, and
quote-free structured logging. Keep SQL and IndexedDB quote state projections aligned with the
canonical accounting fields.
6 changes: 3 additions & 3 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@ older `PENDING` observation.
_Avoid_: Payment status, melt lifecycle

**Mint Quote Claimability**:
Whether a mint quote currently has paid value that coco can claim into proofs. BOLT11 mint quotes
are claimable when their state is `PAID`; reusable mint quotes are claimable when their paid amount
exceeds their issued amount.
Whether Mint Quote Accounting leaves paid value that Coco may claim into proofs after accounting
for completed local issuance and Mint Quote Reservations. Fixed BOLT11 quotes are all-or-nothing;
BOLT12 and on-chain quotes can remain claimable across partial balance claims.
_Avoid_: Mint quote paid state, payment status

**Mint Quote Accounting**:
Expand Down
34 changes: 34 additions & 0 deletions FEATURE_TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Issue #387 — Centralize Mint Quote Claimability

- [x] Fetch #387, parent #294, blockers, domain glossary, and relevant ADRs.
- [x] Confirm #300 and #390 are closed and move `ready-for-agent` to #387.
- [x] Create a fresh worktree from `origin/master` at `1601cf7`.
- [x] Inventory all Mint Quote Claimability callers and existing tests.
- [x] Test-drive one pure Claimability interface for atomic and balance policies.
- [x] Migrate model helpers, operation orchestration, watchers, handlers, lifecycle, and manager.
- [x] Rewrite superseded tests around the common assessment and preserve method-specific coverage.
- [x] Rewrite only the `Mint Quote Claimability` glossary entry in `CONTEXT.md`.
- [x] Run core unit tests, typecheck, build, formatting, and affected adapter suites.
- [x] Review against repository standards and issue #387.

## Scope boundaries

- Do not introduce Mint Issuance Attempts, Mint Batches, or the Mint Issuance Engine.
- Do not redesign the full `MintMethodHandler` interface.
- Do not change Melt Quote semantics.
- Keep BOLT11 `state` only for compatibility projection and import behavior.
- Keep Mint Quote expiry out of Claimability and caller decisions.
- Preserve Quote Observation persistence before Quote-backed Operation advancement.

## PR #400 stack reconciliation

- [x] Refresh and pin PR #400 at `7bacc48df5e9012b3fcc1e43a05648eaa264f1b3`.
- [x] Rebase the PR #398 delta onto the pinned PR #400 head.
- [x] Remove competing BOLT11-specific runtime claimability predicates.
- [x] Preserve locked BOLT11 creation, signing, and exact-output recovery through the common seam.
- [x] Keep ownership contradictions ambiguity-preserving for future `needs_attention` recovery.
- [x] Restore transactional, monotonic repository observation and compatibility-state updates.
- [x] Fix explicit execution versus Background Watcher/processor coordination.
- [x] Run focused, adapter contract, build, typecheck, and formatting verification.
- [ ] Run Docker-backed live-mint integration tests (Docker is unavailable in this environment).
- [x] Review the combined stack against issues #387 and #365.
121 changes: 120 additions & 1 deletion packages/adapter-tests/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,123 @@ export function createDummyAuthSession(overrides?: Partial<AuthSession>): AuthSe
};
}

export async function runMintQuoteRepositoryContract(
options: ContractOptions,
runner: ContractRunner,
): Promise<void> {
const { describe, it, expect } = runner;

describe('MintQuoteRepository contract', () => {
it('keeps canonical BOLT11 accounting authoritative over legacy state updates', async () => {
const { repositories, dispose } = await options.createRepositories();
try {
const quote = createDummyMintQuote({
state: 'UNPAID',
amountPaid: Amount.from(3),
amountIssued: Amount.zero(),
remoteUpdatedAt: 10,
});
await repositories.mintQuoteRepository.upsertMintQuote(quote);
await repositories.mintQuoteRepository.setMintQuoteState(
quote.mintUrl,
quote.method,
quote.quoteId,
'UNPAID',
20,
);

const stored = await repositories.mintQuoteRepository.getMintQuote(
quote.mintUrl,
quote.method,
quote.quoteId,
);

expect(stored?.method).toBe('bolt11');
if (stored?.method !== 'bolt11') throw new Error('Expected BOLT11 quote');
expect(stored.state).toBe('PAID');
expect(stored.amountPaid.equals(Amount.from(3))).toBe(true);
expect(stored.amountIssued.isZero()).toBe(true);
expect(stored.remoteUpdatedAt).toBe(10);
} finally {
await dispose();
}
});

it('applies legacy BOLT11 state updates monotonically', async () => {
const { repositories, dispose } = await options.createRepositories();
try {
const quote = createDummyMintQuote({
state: 'UNPAID',
amountPaid: Amount.zero(),
amountIssued: Amount.zero(),
remoteUpdatedAt: null,
updatedAt: 10,
});
await repositories.mintQuoteRepository.upsertMintQuote(quote);
await repositories.mintQuoteRepository.setMintQuoteState(
quote.mintUrl,
quote.method,
quote.quoteId,
'ISSUED',
30,
);
await repositories.mintQuoteRepository.setMintQuoteState(
quote.mintUrl,
quote.method,
quote.quoteId,
'UNPAID',
20,
);

const stored = await repositories.mintQuoteRepository.getMintQuote(
quote.mintUrl,
quote.method,
quote.quoteId,
);

expect(stored?.method).toBe('bolt11');
if (stored?.method !== 'bolt11') throw new Error('Expected BOLT11 quote');
expect(stored.state).toBe('ISSUED');
expect(stored.amountPaid.equals(quote.amount)).toBe(true);
expect(stored.amountIssued.equals(quote.amount)).toBe(true);
expect(stored.updatedAt).toBe(30);
} finally {
await dispose();
}
});

it('selects pending BOLT11 quotes from accounting instead of stored state', async () => {
const { repositories, dispose } = await options.createRepositories();
try {
const pending = createDummyMintQuote({
quoteId: 'accounting-pending',
quote: 'accounting-pending',
state: 'ISSUED',
amountPaid: Amount.from(3),
amountIssued: Amount.zero(),
});
const issued = createDummyMintQuote({
quoteId: 'accounting-issued',
quote: 'accounting-issued',
state: 'UNPAID',
amountPaid: Amount.from(3),
amountIssued: Amount.from(3),
});
await repositories.mintQuoteRepository.upsertMintQuote(pending);
await repositories.mintQuoteRepository.upsertMintQuote(issued);

const storedPending = await repositories.mintQuoteRepository.getPendingMintQuotes('bolt11');
const pendingIds = storedPending.map((quote) => quote.quoteId);

expect(pendingIds.includes('accounting-pending')).toBe(true);
expect(pendingIds.includes('accounting-issued')).toBe(false);
} finally {
await dispose();
}
});
});
}

export async function runMintOperationRepositoryContract(
options: ContractOptions,
runner: ContractRunner,
Expand Down Expand Up @@ -583,14 +700,16 @@ export async function runMintOperationRepositoryContract(
mintUrl: 'https://mint.test/',
quoteId: 'canonical-quote',
quote: 'canonical-quote',
state: 'UNPAID',
amountPaid: Amount.from(3),
remoteUpdatedAt: 10,
});
await repositories.mintQuoteRepository.upsertMintQuote(quote);
await repositories.mintQuoteRepository.setMintQuoteState(
'https://mint.test',
'bolt11',
'canonical-quote',
'PAID',
'UNPAID',
20,
);

Expand Down
19 changes: 12 additions & 7 deletions packages/core/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import {
isStatefulMintQuote,
mintQuoteToMethodSnapshot,
} from './models/MintQuote.ts';
import { assessMintQuoteClaimability } from './models/MintQuoteClaimability.ts';

/**
* Configuration options for initializing the Coco Cashu manager
Expand Down Expand Up @@ -629,7 +630,7 @@ export class Manager {
continue;
}

if (quote.state === 'ISSUED') {
if (assessMintQuoteClaimability(quote).status === 'complete') {
skipped.push(quote.quote);
continue;
}
Expand All @@ -638,7 +639,6 @@ export class Manager {
if (!trusted) {
this.logger.debug('Skipping legacy mint quote reconciliation for untrusted mint', {
mintUrl: quote.mintUrl,
quoteId: quote.quote,
});
skipped.push(quote.quote);
continue;
Expand Down Expand Up @@ -670,8 +670,7 @@ export class Manager {
} catch (err) {
this.logger.warn('Failed to reconcile legacy mint quote', {
mintUrl: quote.mintUrl,
quoteId: quote.quote,
err,
errorName: err instanceof Error ? err.name : typeof err,
});
skipped.push(quote.quote);
}
Expand Down Expand Up @@ -765,12 +764,18 @@ export class Manager {
for (const operation of pendingOperations) {
if (mintUrl && operation.mintUrl !== mintUrl) continue;

const quote = await this.quoteLifecycle.getMintQuote(
const assessment = await this.mintOperationService.getMintQuoteClaimability(
operation.mintUrl,
operation.method,
operation.quoteId,
{
requestedAmount: operation.amount,
targetOperationId: operation.id,
},
);
if (!quote || !isStatefulMintQuote(quote) || quote.state !== 'PAID') continue;
if (!assessment || (assessment.status !== 'claimable' && assessment.status !== 'complete')) {
continue;
}

const trusted = await this.mintService.isTrustedMint(operation.mintUrl);
if (!trusted) {
Expand Down Expand Up @@ -969,7 +974,7 @@ export class Manager {
onchain: new MeltOnchainHandler(),
});
const mintHandlerProvider = new MintHandlerProvider({
bolt11: new MintBolt11Handler(),
bolt11: new MintBolt11Handler(keyRingService),
onchain: new MintOnchainHandler(keyRingService),
bolt12: new MintBolt12Handler(keyRingService),
});
Expand Down
2 changes: 2 additions & 0 deletions packages/core/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ export type {
QuoteIdentity,
} from './models/index.ts';
export {
applyBolt11MintQuoteStateFallback,
compareHistoryEntries,
deriveBolt11MintQuoteState,
getMintQuoteAmount,
getMintQuoteRemoteState,
isMintQuotePending,
Expand Down
3 changes: 3 additions & 0 deletions packages/core/api/QuoteApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export type CreateMintQuoteInput =
method: 'bolt11';
amount: UnitAmountLike;
unit?: string;
/** Create a NUT-20 quote locked to a fresh, persisted wallet key. */
locked?: boolean;
}
| {
mintUrl: string;
Expand Down Expand Up @@ -65,6 +67,7 @@ export class MintQuoteApi {
const parsed = parseUnitAmount(input.amount, { explicitUnit: input.unit });
return this.quoteLifecycle.createMintQuote(input.mintUrl, input.method, {
amount: parsed,
...(input.locked === true ? { locked: true } : {}),
});
}

Expand Down
3 changes: 1 addition & 2 deletions packages/core/infra/SubscriptionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,7 @@ export class SubscriptionManager {
} catch (err) {
this.logger?.error('Failed to normalize BOLT11 mint quote subscription payload', {
mintUrl,
quoteId,
err,
errorName: err instanceof Error ? err.name : typeof err,
});
return;
}
Expand Down
Loading
Loading