Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/advisory-mint-quote-expiry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@cashu/coco-core': patch
---

Treat Mint Quote expiry as advisory payment-deadline metadata so Mint Quote Accounting remains
observable and elapsed expiry does not block Mint Quote Claimability, issuance, or Operation
Recovery.
5 changes: 3 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,9 @@ but the mint has not yet reported as issued.
_Avoid_: Remote issued amount, proof reservation

**Quote Expiry**:
The time after which a quote can no longer receive a new payment. Expiry does not prevent claiming
value that was already paid before expiry.
The payment deadline advertised by a quote. For a Mint Quote it is advisory metadata for initiating
new payments, not a determinant of Mint Quote Claimability, issuance, Operation Recovery, or Quote
Observation.
_Avoid_: Claim deadline, quote invalidity

**Background Watcher**:
Expand Down
20 changes: 20 additions & 0 deletions docs/adr/0008-treat-mint-quote-expiry-as-advisory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Treat Mint Quote expiry as an advisory payment deadline

Status: accepted

Mint Quote expiry is advisory payment-deadline metadata, so Coco trusts canonical Mint Quote
Accounting regardless of when it is observed and does not use its local clock to block
claimability, issuance, Operation Recovery, or Quote Observation. NUT-23 defines BOLT11 expiry as
the deadline for payment, NUT-30 permits on-chain accounting to increase after expiry while a
detected payment resolves, and NUT-04 derives mintable balance from paid and issued amounts.

## Considered Options

We rejected interpreting elapsed Mint Quote expiry as a terminal wallet state because that can hide
valid accounting increases and strand paid value even when the mint still recognizes the payment.
Melt Quote expiry remains unchanged.

## Consequences

Without a reliable remote terminal signal, Mint Quotes may remain observed indefinitely. Retention,
archival, and watcher-aging policy require a separate recovery design.
4 changes: 1 addition & 3 deletions packages/core/infra/HybridTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import type { RealTimeTransport, TransportEvent } from './RealTimeTransport.ts';
import type { WsRequest } from './SubscriptionProtocol.ts';
import type { WebSocketFactory } from './WsConnectionManager.ts';
import type { Logger } from '../logging/Logger.ts';
import { isMintQuoteExpired } from '../models/MintQuoteExpiry.ts';
import type { MintAdapter } from './MintAdapter.ts';
import { WsTransport } from './WsTransport.ts';
import { PollingTransport } from './PollingTransport.ts';
Expand Down Expand Up @@ -293,7 +292,6 @@ export class HybridTransport implements RealTimeTransport {
return 'no-expiry';
}

const status = isMintQuoteExpired({ expiry: payload.expiry }) ? 'expired' : 'active';
return `${payload.expiry}:${status}`;
return String(payload.expiry);
}
}
26 changes: 0 additions & 26 deletions packages/core/infra/handlers/mint/MintBolt12Handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ 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 { isMintQuoteExpired } from '../../../models/MintQuoteExpiry';
import type {
CreateMintQuoteContext,
ExecuteContext,
Expand Down Expand Up @@ -187,13 +186,6 @@ export class MintBolt12Handler implements MintMethodHandler<'bolt12'> {
};
}

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

const quoteKey = await this.keyRingService.getMintQuoteKeyPair(expectedPubkey);
if (!quoteKey) {
return {
Expand Down Expand Up @@ -295,20 +287,6 @@ export class MintBolt12Handler implements MintMethodHandler<'bolt12'> {
};
}

if (this.isExpired(remoteQuote)) {
return {
observedRemoteStateAt,
quoteSnapshot: remoteQuote,
category: 'terminal',
terminalFailure: {
reason: `BOLT12 mint quote ${operation.quoteId} expired before operation ${operation.id} could be minted`,
code: 'quote_expired',
retryable: false,
observedAt: observedRemoteStateAt,
},
};
}

return {
observedRemoteStateAt,
quoteSnapshot: remoteQuote,
Expand Down Expand Up @@ -423,10 +401,6 @@ export class MintBolt12Handler implements MintMethodHandler<'bolt12'> {
return Amount.from(quote.amount_paid).subtract(Amount.from(quote.amount_issued));
}

private isExpired(quote: MintQuoteBolt12Response): boolean {
return isMintQuoteExpired(quote);
}

private isAlreadyIssuedError(error: unknown): boolean {
if (error instanceof MintOperationError && (error.code === 20002 || error.code === 11003)) {
return true;
Expand Down
26 changes: 0 additions & 26 deletions packages/core/infra/handlers/mint/MintOnchainHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
type MintQuoteOnchainResponse,
} from '../../../models/MintQuote';
import { mintQuoteObservationFromOnchainResponse } from '../../../models/MintQuoteObservationFactory';
import { isMintQuoteExpired } from '../../../models/MintQuoteExpiry';
import type {
CreateMintQuoteContext,
ExecuteContext,
Expand Down Expand Up @@ -175,13 +174,6 @@ export class MintOnchainHandler implements MintMethodHandler<'onchain'> {
};
}

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

const quoteKey = await this.keyRingService.getMintQuoteKeyPair(expectedPubkey);
if (!quoteKey) {
return {
Expand Down Expand Up @@ -283,20 +275,6 @@ export class MintOnchainHandler implements MintMethodHandler<'onchain'> {
};
}

if (this.isExpired(remoteQuote)) {
return {
observedRemoteStateAt,
quoteSnapshot: remoteQuote,
category: 'terminal',
terminalFailure: {
reason: `Onchain mint quote ${operation.quoteId} expired before operation ${operation.id} could be minted`,
code: 'quote_expired',
retryable: false,
observedAt: observedRemoteStateAt,
},
};
}

return {
observedRemoteStateAt,
quoteSnapshot: remoteQuote,
Expand Down Expand Up @@ -393,10 +371,6 @@ export class MintOnchainHandler implements MintMethodHandler<'onchain'> {
);
}

private isExpired(quote: MintQuoteOnchainResponse): boolean {
return isMintQuoteExpired(quote);
}

private isAlreadyIssuedError(error: unknown): boolean {
if (error instanceof MintOperationError && (error.code === 20002 || error.code === 11003)) {
return true;
Expand Down
14 changes: 0 additions & 14 deletions packages/core/models/MintQuoteExpiry.ts

This file was deleted.

2 changes: 1 addition & 1 deletion packages/core/operations/mint/MintOperation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* - pending: Deterministic outputData persisted; quote may now settle remotely
* - executing: Mint or recovery call in progress
* - finalized: Quote reached terminal ISSUED state; proofs were saved when recoverable
* - failed: Operation reached a terminal non-issued state (for example, quote expiry)
* - failed: Operation reached a terminal non-issued state (for example, invalid quote data)
*/
export type MintOperationState = 'init' | 'pending' | 'executing' | 'finalized' | 'failed';

Expand Down
5 changes: 0 additions & 5 deletions packages/core/operations/mint/MintOperationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ import {
getMintQuoteAmount,
type MintQuote,
} from '../../models/MintQuote';
import { isMintQuoteExpired } from '../../models/MintQuoteExpiry';
import type { MintQuoteRef } from '../../models/QuoteIdentity';
import type { QuoteLifecycle } from '../../quotes/QuoteLifecycle';

Expand Down Expand Up @@ -863,10 +862,6 @@ export class MintOperationService {
quote: MintQuote,
targetOperationId?: string,
): Promise<Amount> {
if (isMintQuoteExpired(quote)) {
return Amount.zero();
}

let remoteAvailable = getMintQuoteAvailableAmount(quote);
const siblings = await this.mintOperationRepository.getByQuoteId(
quote.mintUrl,
Expand Down
5 changes: 0 additions & 5 deletions packages/core/quotes/QuoteLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import {
mintQuoteObservationFromOnchainResponse,
} from '../models/MintQuoteObservationFactory.ts';
import { meltQuoteToMethodSnapshot, type MeltQuote } from '../models/MeltQuote';
import { isMintQuoteExpired } from '../models/MintQuoteExpiry';
import {
HttpResponseError,
MintOperationError,
Expand Down Expand Up @@ -1551,10 +1550,6 @@ export class QuoteLifecycle {
}

private assertMintQuoteCanPrepare(quote: MintQuote, context: string): void {
if (isMintQuoteExpired(quote)) {
throw new Error(`Cannot prepare ${context}: quote is expired`);
}

if (isStatefulMintQuote(quote) && quote.state === 'ISSUED') {
throw new Error(`Cannot prepare ${context}: quote is terminal`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import type {
} from '@core/operations/mint';
import type { SubscriptionKind } from '@core/infra/SubscriptionProtocol.ts';
import { mintQuoteToMethodSnapshot, type MintQuote } from '../../models/MintQuote.ts';
import { isMintQuoteExpired } from '../../models/MintQuoteExpiry.ts';
import type { QuoteLifecycle } from '../../quotes/QuoteLifecycle.ts';

type QuoteKey = string; // `${mintUrl}::${method}::${quoteId}`
Expand All @@ -34,22 +33,22 @@ const mintQuoteWatchPolicies: {
subscriptionKind: 'bolt11_mint_quote',
getPayloadQuoteId: (payload) => payload.quote,
shouldRecordPayload: (payload) => payload.state === 'PAID' || payload.state === 'ISSUED',
shouldStopWatching: (payload) => payload.state === 'ISSUED' || isMintQuoteExpired(payload),
shouldStopWatching: (payload) => payload.state === 'ISSUED',
},
onchain: {
subscriptionKind: 'onchain_mint_quote',
getPayloadQuoteId: (payload) => payload.quote,
shouldRecordPayload: (payload) =>
payload.amount_paid !== undefined && payload.amount_issued !== undefined,
shouldStopWatching: (payload) => isMintQuoteExpired(payload),
shouldStopWatching: () => false,
keepWatchingWithoutOperationInterest: true,
},
bolt12: {
subscriptionKind: 'bolt12_mint_quote',
getPayloadQuoteId: (payload) => payload.quote,
shouldRecordPayload: (payload) =>
payload.amount_paid !== undefined && payload.amount_issued !== undefined,
shouldStopWatching: (payload) => isMintQuoteExpired(payload),
shouldStopWatching: () => false,
keepWatchingWithoutOperationInterest: true,
},
};
Expand Down
4 changes: 2 additions & 2 deletions packages/core/test/unit/HybridTransport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ describe('HybridTransport', () => {
expect(messageHandler.mock.calls.length).toBe(countAfterFirst + 1);
});

it('should not dedupe unchanged onchain quote counters when expiry status changes', async () => {
it('should dedupe unchanged onchain quote counters when only wall-clock expiry changes', async () => {
const messageHandler = mock(() => {});
transport.on(mintUrl, 'message', messageHandler);

Expand All @@ -421,7 +421,7 @@ describe('HybridTransport', () => {

await new Promise((resolve) => setTimeout(resolve, Math.max(0, expiryMs - Date.now() + 1)));
mockSocket.triggerMessage(notification);
expect(messageHandler.mock.calls.length).toBe(countAfterFirst + 1);
expect(messageHandler.mock.calls.length).toBe(countAfterFirst);
});

it('should bypass dedupe when no state or complete amount counters are present', async () => {
Expand Down
19 changes: 16 additions & 3 deletions packages/core/test/unit/MintBolt12Handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ describe('MintBolt12Handler', () => {
expect(result.category).toBe('ready');
});

it('treats a funded quote with a past positive expiry as terminal', async () => {
it('keeps a funded quote claimable after expiry', async () => {
const result = await handler.checkPending(
buildPendingContext(
quote({
Expand All @@ -397,8 +397,8 @@ describe('MintBolt12Handler', () => {
),
);

expect(result.category).toBe('terminal');
expect(result.terminalFailure?.code).toBe('quote_expired');
expect(result.category).toBe('ready');
expect(result.terminalFailure).toBeUndefined();
});

it('mints with the keyring private key and custom outputs', async () => {
Expand Down Expand Up @@ -456,4 +456,17 @@ describe('MintBolt12Handler', () => {
expect(result.error).toContain(`BOLT12 mint quote ${quoteId} returned pubkey ${changedPubkey}`);
expect(wallet.mintProofsBolt12).not.toHaveBeenCalled();
});

it('retries funded execution recovery after expiry', async () => {
const expiredQuote = quote({
expiry: Math.floor(Date.now() / 1000) - 1,
amount_paid: Amount.from(10),
});

const result = await handler.recoverExecuting(buildRecoverContext(expiredQuote));

expect(result).toEqual({ status: 'FINALIZED' });
expect(wallet.mintProofsBolt12).toHaveBeenCalled();
expect(proofService.saveProofs).toHaveBeenCalled();
});
});
17 changes: 9 additions & 8 deletions packages/core/test/unit/MintOnchainHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,18 +380,17 @@ describe('MintOnchainHandler', () => {
expect(proofService.recoverProofsFromOutputData).toHaveBeenCalledTimes(2);
});

it('marks expired onchain executing recovery as terminal after restore misses', async () => {
it('retries funded onchain execution recovery after expiry', async () => {
(mintAdapter.checkMintQuote as Mock<any>).mockResolvedValueOnce({
...remoteQuote,
expiry: Math.floor(Date.now() / 1000) - 1,
});

const result = await handler.recoverExecuting(buildRecoverContext());

expect(result).toEqual({
status: 'TERMINAL',
error: `Recovered: onchain quote ${quoteId} expired while executing mint`,
});
expect(result).toEqual({ status: 'FINALIZED' });
expect(wallet.mintProofsOnchain).toHaveBeenCalled();
expect(proofService.saveProofs).toHaveBeenCalled();
});

it('checks pending onchain operations as ready when the quote can cover the amount', async () => {
Expand Down Expand Up @@ -425,15 +424,17 @@ describe('MintOnchainHandler', () => {
expect(result.quoteSnapshot?.amount_paid?.equals(Amount.from(8))).toBe(true);
});

it('checks expired pending onchain operations as terminal', async () => {
it('keeps an unfunded onchain operation waiting after expiry', async () => {
(mintAdapter.checkMintQuote as Mock<any>).mockResolvedValueOnce({
...remoteQuote,
expiry: Math.floor(Date.now() / 1000) - 1,
amount_paid: Amount.from(8),
amount_issued: Amount.from(8),
});

const result = await handler.checkPending(buildPendingContext());

expect(result.category).toBe('terminal');
expect(result.terminalFailure?.code).toBe('quote_expired');
expect(result.category).toBe('waiting');
expect(result.terminalFailure).toBeUndefined();
});
});
Loading
Loading