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
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 predicates and opt-in NUT-20 locked quote handling with readable
diagnostics. Keep SQL and IndexedDB quote state projections aligned with the canonical accounting
fields.
193 changes: 192 additions & 1 deletion packages/adapter-tests/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,195 @@ 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('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,
});
await repositories.mintQuoteRepository.upsertMintQuote(quote);

await repositories.mintQuoteRepository.setMintQuoteState(
quote.mintUrl,
quote.method,
quote.quoteId,
'PAID',
20,
);
await repositories.mintQuoteRepository.setMintQuoteState(
quote.mintUrl,
quote.method,
quote.quoteId,
'UNPAID',
30,
);

const paid = await repositories.mintQuoteRepository.getMintQuote(
quote.mintUrl,
quote.method,
quote.quoteId,
);
expect(paid?.method).toBe('bolt11');
if (paid?.method !== 'bolt11') throw new Error('Expected BOLT11 quote');
expect(paid.state).toBe('PAID');
expect(paid.amountPaid.equals(quote.amount)).toBe(true);
expect(paid.amountIssued.isZero()).toBe(true);
expect(paid.remoteUpdatedAt).toBe(null);
expect(paid.updatedAt).toBe(30);

await repositories.mintQuoteRepository.setMintQuoteState(
quote.mintUrl,
quote.method,
quote.quoteId,
'ISSUED',
40,
);

const issued = await repositories.mintQuoteRepository.getMintQuote(
quote.mintUrl,
quote.method,
quote.quoteId,
);
expect(issued?.method).toBe('bolt11');
if (issued?.method !== 'bolt11') throw new Error('Expected BOLT11 quote');
expect(issued.state).toBe('ISSUED');
expect(issued.amountPaid.equals(quote.amount)).toBe(true);
expect(issued.amountIssued.equals(quote.amount)).toBe(true);
expect(issued.remoteUpdatedAt).toBe(null);
expect(issued.updatedAt).toBe(40);
} finally {
await dispose();
}
});

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('keeps concurrent legacy state updates from overwriting canonical accounting', async () => {
const { repositories, dispose } = await options.createRepositories();
try {
for (let i = 0; i < 10; i++) {
const quote = createDummyMintQuote({
quoteId: `concurrent-accounting-${i}`,
quote: `concurrent-accounting-${i}`,
state: 'UNPAID',
amountPaid: Amount.zero(),
amountIssued: Amount.zero(),
remoteUpdatedAt: null,
});
const canonical = {
...quote,
state: 'PAID' as const,
amountPaid: Amount.from(3),
amountIssued: Amount.zero(),
remoteUpdatedAt: 10,
updatedAt: 10,
};
await repositories.mintQuoteRepository.upsertMintQuote(quote);

await Promise.all([
repositories.mintQuoteRepository.setMintQuoteState(
quote.mintUrl,
quote.method,
quote.quoteId,
'ISSUED',
20,
),
repositories.mintQuoteRepository.upsertMintQuote(canonical),
]);

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('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 +772,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
2 changes: 1 addition & 1 deletion packages/core/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -969,7 +969,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
5 changes: 5 additions & 0 deletions packages/core/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,14 @@ export type {
QuoteIdentity,
} from './models/index.ts';
export {
applyBolt11MintQuoteStateFallback,
compareHistoryEntries,
deriveBolt11MintQuoteState,
getMintQuoteAmount,
getMintQuoteRemoteState,
isBolt11MintQuoteIssued,
isBolt11MintQuotePaid,
isBolt11MintQuoteUnpaid,
isMintQuotePending,
isStatefulMintQuote,
operationHistoryId,
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
Loading
Loading