Skip to content
Closed
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
10 changes: 10 additions & 0 deletions .changeset/canonical-mint-quote-claimability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@cashu/coco-core': patch
'@cashu/coco-adapter-tests': patch
'@cashu/coco-sqlite': patch
'@cashu/coco-sqlite-bun': patch
'@cashu/coco-expo-sqlite': 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.
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
21 changes: 21 additions & 0 deletions FEATURE_TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# 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.
32 changes: 32 additions & 0 deletions packages/adapter-tests/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,38 @@ export async function runMintOperationRepositoryContract(
}
});

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

const pending = await repositories.mintQuoteRepository.getPendingMintQuotes('bolt11');

expect(pending).toHaveLength(1);
expect(pending[0]?.quoteId).toBe('accounting-waiting');
} finally {
await dispose();
}
});

it('looks up canonical mint quotes by identity without method', async () => {
const { repositories, dispose } = await options.createRepositories();
try {
Expand Down
13 changes: 10 additions & 3 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 Down Expand Up @@ -765,12 +766,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
96 changes: 56 additions & 40 deletions packages/core/infra/handlers/mint/MintBolt11Handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { deserializeOutputData, mapProofToCoreProof, serializeOutputData } from
import { Amount, type MintQuoteBolt11Response } from '@cashu/cashu-ts';
import { mintQuoteFromBolt11Response, type MintQuote } from '../../../models/MintQuote';
import { mintQuoteObservationFromBolt11Response } from '../../../models/MintQuoteObservationFactory';
import { assessMintQuoteClaimability } from '../../../models/MintQuoteClaimability.ts';

export class MintBolt11Handler implements MintMethodHandler<'bolt11'> {
async createQuote(ctx: CreateMintQuoteContext<'bolt11'>): Promise<MintQuote<'bolt11'>> {
Expand Down Expand Up @@ -126,7 +127,27 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> {
};
}

if (remoteQuote.state === 'PAID') {
const quote = mintQuoteObservationFromBolt11Response(mintUrl, remoteQuote);
const assessment = assessMintQuoteClaimability(quote, {
...ctx.localClaimabilityFacts,
requestedAmount: ctx.operation.amount,
});

if (assessment.status === 'invalid') {
return {
status: 'TERMINAL',
error: `Recovered: quote ${quoteId} has invalid claimability accounting`,
};
}

if (assessment.status === 'waiting') {
return {
status: 'PENDING',
error: `Recovered: quote ${quoteId} is not yet claimable`,
};
}

if (assessment.status === 'claimable') {
const outputData = deserializeOutputData(ctx.operation.outputData);
try {
const proofs = await ctx.wallet.mintProofsBolt11(
Expand All @@ -152,11 +173,6 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> {
if (err instanceof MintOperationError) {
if (err.code === 20002) {
// Quote already issued; fall through to proof recovery
} else if (err.code === 20007) {
return {
status: 'TERMINAL',
error: `Recovered: quote ${quoteId} expired while executing mint`,
};
} else {
return {
status: 'PENDING',
Expand All @@ -170,16 +186,6 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> {
};
}
}
} else if (remoteQuote.state === 'UNPAID') {
return {
status: 'PENDING',
error: `Recovered: quote ${quoteId} is still UNPAID`,
};
} else if (remoteQuote.state !== 'ISSUED') {
return {
status: 'PENDING',
error: `Recovered: quote ${quoteId} remains in remote state ${remoteQuote.state}`,
};
}

try {
Expand Down Expand Up @@ -211,32 +217,42 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> {
ctx.logger?.info('Checking pending mint operation', { mintUrl, quoteId });

const quote = await ctx.mintAdapter.checkMintQuote(mintUrl, 'bolt11', quoteId);
ctx.logger?.info('Pending mint quote state', { mintUrl, quoteId, state: quote.state });
const observedRemoteStateAt = Date.now();
const canonicalQuote = mintQuoteObservationFromBolt11Response(mintUrl, quote, {
now: observedRemoteStateAt,
});
const assessment = assessMintQuoteClaimability(canonicalQuote, {
requestedAmount: ctx.operation.amount,
});
ctx.logger?.info('Pending mint quote claimability assessed', {
mintUrl,
quoteId,
status: assessment.status,
});

switch (quote.state) {
case 'UNPAID':
return {
observedRemoteState: quote.state,
observedRemoteStateAt,
category: 'waiting',
};
case 'PAID':
return {
observedRemoteState: quote.state,
observedRemoteStateAt,
category: 'ready',
};
case 'ISSUED':
return {
observedRemoteState: quote.state,
observedRemoteStateAt,
category: 'completed',
};
default:
throw new Error(
`Unexpected mint quote state: ${quote.state} for quote ${quoteId} at mint ${mintUrl}`,
);
if (assessment.status === 'invalid') {
return {
observedRemoteStateAt,
quoteSnapshot: quote,
category: 'terminal',
terminalFailure: {
reason: `BOLT11 mint quote ${quoteId} has invalid claimability accounting`,
code: 'invalid_quote',
retryable: false,
observedAt: observedRemoteStateAt,
},
};
}

return {
observedRemoteStateAt,
quoteSnapshot: quote,
category:
assessment.status === 'claimable'
? 'ready'
: assessment.status === 'complete'
? 'completed'
: 'waiting',
};
}
}
71 changes: 39 additions & 32 deletions packages/core/infra/handlers/mint/MintBolt12Handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { bytesToHex } from '@noble/curves/utils.js';
import { MintOperationError } from '../../../models/Error';
import { mintQuoteFromBolt12Response, type MintQuote } from '../../../models/MintQuote';
import { mintQuoteObservationFromBolt12Response } from '../../../models/MintQuoteObservationFactory';
import { assessMintQuoteClaimability } from '../../../models/MintQuoteClaimability.ts';
import type {
CreateMintQuoteContext,
ExecuteContext,
Expand Down Expand Up @@ -194,11 +195,20 @@ export class MintBolt12Handler implements MintMethodHandler<'bolt12'> {
};
}

const available = this.getAvailableAmount(remoteQuote);
if (available.lessThan(operation.amount)) {
const assessment = assessMintQuoteClaimability(
mintQuoteObservationFromBolt12Response(operation.mintUrl, remoteQuote),
{ ...ctx.localClaimabilityFacts, requestedAmount: operation.amount },
);
if (assessment.status === 'invalid') {
return {
status: 'TERMINAL',
error: `Recovered: BOLT12 quote ${operation.quoteId} has invalid claimability accounting`,
};
}
if (assessment.status !== 'claimable') {
return {
status: 'PENDING',
error: `Recovered: BOLT12 quote ${operation.quoteId} has ${available} available, requested ${operation.amount}`,
error: `Recovered: BOLT12 quote ${operation.quoteId} has ${assessment.remoteAvailable} remotely available, requested ${operation.amount}`,
};
}

Expand Down Expand Up @@ -231,13 +241,6 @@ export class MintBolt12Handler implements MintMethodHandler<'bolt12'> {
);
}

if (this.isExpiredMintError(error)) {
return {
status: 'TERMINAL',
error: `Recovered: BOLT12 quote ${operation.quoteId} expired while executing mint`,
};
}

return {
status: 'PENDING',
error: error instanceof Error ? error.message : String(error),
Expand Down Expand Up @@ -287,12 +290,35 @@ export class MintBolt12Handler implements MintMethodHandler<'bolt12'> {
};
}

const assessment = assessMintQuoteClaimability(
mintQuoteObservationFromBolt12Response(operation.mintUrl, remoteQuote, {
now: observedRemoteStateAt,
}),
{ requestedAmount: operation.amount },
);
if (assessment.status === 'invalid') {
return {
observedRemoteStateAt,
quoteSnapshot: remoteQuote,
category: 'terminal',
terminalFailure: {
reason: `BOLT12 mint quote ${operation.quoteId} has invalid claimability accounting`,
code: 'invalid_quote',
retryable: false,
observedAt: observedRemoteStateAt,
},
};
}

return {
observedRemoteStateAt,
quoteSnapshot: remoteQuote,
category: this.getAvailableAmount(remoteQuote).greaterThanOrEqual(operation.amount)
? 'ready'
: 'waiting',
category:
assessment.status === 'claimable'
? 'ready'
: assessment.status === 'complete'
? 'completed'
: 'waiting',
};
}

Expand Down Expand Up @@ -334,12 +360,6 @@ export class MintBolt12Handler implements MintMethodHandler<'bolt12'> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Revalidate accounting on the execute-time quote

BOLT12 and on-chain execution fetch a fresh remote quote after the service-level claimability assessment, but removing this check from assertQuoteMatchesRequest means that fresh response is no longer rejected when amount_issued exceeds amount_paid. If the mint returns contradictory accounting between readiness and execution, the handlers now pass that invalid quote into mintProofsBolt12 or mintProofsOnchain and sign/send outputs instead of stopping before mutation. Assess the execute-time quote or retain the accounting invariant check here.

AGENTS.md reference: AGENTS.md:L163-L163

Useful? React with 👍 / 👎.

assertSameUnit(quote.unit, expectedUnit, `BOLT12 mint quote ${quote.quote}`);
this.assertQuoteAmount(quote, expectedAmount);

if (Amount.from(quote.amount_paid).lessThan(Amount.from(quote.amount_issued))) {
throw new Error(
`BOLT12 mint quote ${quote.quote} has amount_issued greater than amount_paid`,
);
}
}

private assertQuoteAmount(quote: MintQuoteBolt12Response, expectedAmount?: Amount): void {
Expand Down Expand Up @@ -397,10 +417,6 @@ export class MintBolt12Handler implements MintMethodHandler<'bolt12'> {
}
}

private getAvailableAmount(quote: MintQuoteBolt12Response): Amount {
return Amount.from(quote.amount_paid).subtract(Amount.from(quote.amount_issued));
}

private isAlreadyIssuedError(error: unknown): boolean {
if (error instanceof MintOperationError && (error.code === 20002 || error.code === 11003)) {
return true;
Expand All @@ -409,13 +425,4 @@ export class MintBolt12Handler implements MintMethodHandler<'bolt12'> {
const message = error instanceof Error ? error.message : String(error);
return /already (issued|signed)|outputs? already/i.test(message);
}

private isExpiredMintError(error: unknown): boolean {
if (error instanceof MintOperationError && error.code === 20007) {
return true;
}

const message = error instanceof Error ? error.message : String(error);
return /expired/i.test(message);
}
}
Loading
Loading