diff --git a/.changeset/mint-swaps.md b/.changeset/mint-swaps.md new file mode 100644 index 000000000..3af97a77a --- /dev/null +++ b/.changeset/mint-swaps.md @@ -0,0 +1,13 @@ +--- +'@cashu/coco-core': patch +'@cashu/coco-react': patch +'@cashu/coco-adapter-tests': patch +'@cashu/coco-sql-storage': patch +'@cashu/coco-sqlite': patch +'@cashu/coco-sqlite-bun': patch +'@cashu/coco-indexeddb': patch +'@cashu/coco-expo-sqlite': patch +--- + +Add durable exact-receive BOLT11 mint swaps with locked destination quotes, atomic parent/child +persistence, recovery processing, grouped events/history, React bindings, and adapter migrations. diff --git a/packages/adapter-tests/src/index.ts b/packages/adapter-tests/src/index.ts index b896ee872..664f3130a 100644 --- a/packages/adapter-tests/src/index.ts +++ b/packages/adapter-tests/src/index.ts @@ -16,6 +16,8 @@ import { type ReceiveOperation, type SendOperation, type AuthSession, + type MintSwapOperation, + type OperationEventOutboxRecord, QuoteIdentityConflictError, } from '@cashu/coco-core/adapter'; @@ -445,6 +447,223 @@ export function createDummyAuthSession(overrides?: Partial): AuthSe }; } +export function createDummyMintSwapOperation( + overrides: Partial = {}, +): MintSwapOperation { + const destinationAmount = Amount.from(9_007_199_254_740_993n); + const sourcePreparationFee = Amount.from(1); + const sourceMeltInputFee = Amount.from(2); + return { + id: 'mint-swap-op', + state: 'prepared', + revision: 0, + sourceMintUrl: 'https://source-mint.test', + destinationMintUrl: 'https://destination-mint.test', + unit: 'sat', + destinationAmount, + destinationQuoteRef: { + mintUrl: 'https://destination-mint.test', + method: 'bolt11', + quoteId: 'destination-quote', + }, + destinationMintOperationId: 'destination-mint-op', + sourceQuoteRef: { + mintUrl: 'https://source-mint.test', + method: 'bolt11', + quoteId: 'source-melt-quote', + }, + sourceMeltOperationId: 'source-melt-op', + destinationNut20Key: { publicKey: '02adaptertest', derivationIndex: 42 }, + preparedPlan: { + fingerprint: 'adapter-contract-fingerprint', + dispatchDeadline: 1_700_000_600, + requiredDispatchWindowSeconds: 120, + sourceMeltAmount: destinationAmount, + sourceFeeReserve: Amount.from(10), + sourcePreparationFee, + sourceMeltInputFee, + minimumSourceDebit: destinationAmount.add(sourcePreparationFee).add(sourceMeltInputFee), + maximumSourceDebit: destinationAmount.add(Amount.from(13)), + reservedSourceAmount: destinationAmount.add(Amount.from(13)), + }, + retry: { attemptCount: 0 }, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + ...overrides, + }; +} + +export function createDummyOperationEventOutboxRecord( + overrides: Partial = {}, +): OperationEventOutboxRecord { + return { + id: 'mint-swap-event', + operationId: 'mint-swap-op', + revision: 1, + eventType: 'mint-swap-op:prepared', + payload: { + operationId: 'mint-swap-op', + revision: 1, + state: 'prepared', + sourceMintUrl: 'https://source-mint.test', + destinationMintUrl: 'https://destination-mint.test', + unit: 'sat', + destinationAmount: '9007199254740993', + }, + createdAt: 1_700_000_000_001, + publishAttempts: 0, + ...overrides, + }; +} + +export async function runMintSwapRepositoryContract( + options: ContractOptions, + runner: ContractRunner, +): Promise { + const { describe, it, expect } = runner; + + describe('MintSwapOperationRepository contract', () => { + it('round-trips decimal amounts and child lookups', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const operation = createDummyMintSwapOperation(); + await repositories.mintSwapOperationRepository.create(operation); + const stored = await repositories.mintSwapOperationRepository.getById(operation.id); + const byDestination = + await repositories.mintSwapOperationRepository.getByDestinationMintOperationId( + 'destination-mint-op', + ); + const bySource = + await repositories.mintSwapOperationRepository.getBySourceMeltOperationId( + 'source-melt-op', + ); + + expect(stored?.destinationAmount.toString()).toBe('9007199254740993'); + expect(stored?.preparedPlan?.sourceMeltAmount.toString()).toBe('9007199254740993'); + expect(stored?.preparedPlan?.maximumSourceDebit.toString()).toBe('9007199254741006'); + expect(byDestination?.id).toBe(operation.id); + expect(bySource?.id).toBe(operation.id); + } finally { + await dispose(); + } + }); + + it('allows one compare-and-set winner per revision', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const operation = createDummyMintSwapOperation(); + await repositories.mintSwapOperationRepository.create(operation); + const next = { + ...operation, + revision: 1, + retry: { attemptCount: 1, nextAttemptAt: 1_700_000_001_000 }, + updatedAt: 1_700_000_000_002, + } satisfies MintSwapOperation; + const results = await Promise.all([ + repositories.mintSwapOperationRepository.compareAndSet(next, 0), + repositories.mintSwapOperationRepository.compareAndSet(next, 0), + ]); + + expect(results.filter(Boolean)).toHaveLength(1); + } finally { + await dispose(); + } + }); + + it('enforces unique child ownership', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + await repositories.mintSwapOperationRepository.create(createDummyMintSwapOperation()); + await expectThrows( + () => + repositories.mintSwapOperationRepository.create( + createDummyMintSwapOperation({ id: 'other-mint-swap-op' }), + ), + expect, + ); + } finally { + await dispose(); + } + }); + + it('returns automatic due work in deterministic due order', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const base = createDummyMintSwapOperation(); + const preparing = (id: string, nextAttemptAt: number): MintSwapOperation => ({ + id, + state: 'preparing', + revision: 0, + sourceMintUrl: base.sourceMintUrl, + destinationMintUrl: base.destinationMintUrl, + unit: 'sat', + destinationAmount: base.destinationAmount, + retry: { attemptCount: 1, nextAttemptAt }, + createdAt: base.createdAt, + updatedAt: base.updatedAt, + }); + await repositories.mintSwapOperationRepository.create(preparing('due-later', 20)); + await repositories.mintSwapOperationRepository.create(preparing('due-first', 10)); + + const due = await repositories.mintSwapOperationRepository.getDue(20, 10); + expect(due.map(({ id }) => id).join(',')).toBe('due-first,due-later'); + } finally { + await dispose(); + } + }); + + it('rolls parent and outbox writes back together', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + await expectThrows( + () => + repositories.withTransaction(async (tx) => { + await tx.mintSwapOperationRepository.create(createDummyMintSwapOperation()); + await tx.operationEventOutboxRepository.enqueue( + createDummyOperationEventOutboxRecord(), + ); + throw new Error('injected rollback'); + }), + expect, + ); + expect(await repositories.mintSwapOperationRepository.getById('mint-swap-op')).toBe(null); + expect(await repositories.operationEventOutboxRepository.getUnpublished(10)).toHaveLength( + 0, + ); + } finally { + await dispose(); + } + }); + }); + + describe('OperationEventOutboxRepository contract', () => { + it('enforces logical uniqueness and durable publication state', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + await repositories.operationEventOutboxRepository.enqueue( + createDummyOperationEventOutboxRecord(), + ); + await expectThrows( + () => + repositories.operationEventOutboxRepository.enqueue( + createDummyOperationEventOutboxRecord({ id: 'duplicate-logical-event' }), + ), + expect, + ); + await repositories.operationEventOutboxRepository.markPublished( + 'mint-swap-event', + 1_700_000_000_010, + ); + expect(await repositories.operationEventOutboxRepository.getUnpublished(10)).toHaveLength( + 0, + ); + } finally { + await dispose(); + } + }); + }); +} + export async function runMintOperationRepositoryContract( options: ContractOptions, runner: ContractRunner, @@ -452,6 +671,28 @@ export async function runMintOperationRepositoryContract( const { describe, it, expect } = runner; describe('MintOperationRepository contract', () => { + it('round-trips mint-swap ownership', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const operation = createDummyMintOperation({ parentSwapOperationId: 'swap-parent' }); + await repositories.mintOperationRepository.create(operation); + + expect( + (await repositories.mintOperationRepository.getById(operation.id))?.parentSwapOperationId, + ).toBe('swap-parent'); + await expectThrows( + () => + repositories.mintOperationRepository.update({ + ...operation, + parentSwapOperationId: 'different-parent', + }), + expect, + ); + } finally { + await dispose(); + } + }); + it('round-trips init mint operation quote ids', async () => { const { repositories, dispose } = await options.createRepositories(); try { @@ -583,7 +824,7 @@ export async function runMintOperationRepositoryContract( mintUrl: 'https://mint.test/', quoteId: 'canonical-quote', quote: 'canonical-quote', - remoteUpdatedAt: 10, + remoteUpdatedAt: null, }); await repositories.mintQuoteRepository.upsertMintQuote(quote); await repositories.mintQuoteRepository.setMintQuoteState( @@ -612,7 +853,112 @@ export async function runMintOperationRepositoryContract( expect(stored!.quoteData.amount.equals(Amount.from(3))).toBe(true); expect(stored!.amountPaid.equals(Amount.from(3))).toBe(true); expect(stored!.amountIssued.equals(Amount.zero())).toBe(true); - expect(stored!.remoteUpdatedAt).toBe(10); + expect(stored!.remoteUpdatedAt).toBe(null); + + await repositories.mintQuoteRepository.setMintQuoteState( + 'https://mint.test', + 'bolt11', + 'canonical-quote', + 'UNPAID', + 30, + ); + const afterLegacyRegression = await repositories.mintQuoteRepository.getMintQuote( + 'https://mint.test', + 'bolt11', + 'canonical-quote', + ); + expect(afterLegacyRegression?.method).toBe('bolt11'); + if (afterLegacyRegression?.method !== 'bolt11') { + throw new Error('Expected BOLT11 quote after legacy fallback update'); + } + expect(afterLegacyRegression.state).toBe('PAID'); + expect(afterLegacyRegression.amountPaid.equals(Amount.from(3))).toBe(true); + expect(afterLegacyRegression.amountIssued.isZero()).toBe(true); + } finally { + await dispose(); + } + }); + + it('does not let legacy state override remotely ordered mint quote accounting', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const quote = createDummyMintQuote({ + mintUrl: 'https://mint.test/', + quoteId: 'ordered-canonical-quote', + quote: 'ordered-canonical-quote', + amountPaid: Amount.from(3), + amountIssued: Amount.zero(), + remoteUpdatedAt: 10, + }); + await repositories.mintQuoteRepository.upsertMintQuote(quote); + + await repositories.mintQuoteRepository.setMintQuoteState( + quote.mintUrl, + quote.method, + quote.quoteId, + 'ISSUED', + 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 remotely ordered 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('derives deprecated BOLT11 state from canonical quote accounting', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const quote = createDummyMintQuote({ + quoteId: 'canonical-accounting-authority', + quote: 'canonical-accounting-authority', + state: 'ISSUED', + amountPaid: Amount.from(3), + amountIssued: Amount.zero(), + }); + await repositories.mintQuoteRepository.upsertMintQuote(quote); + + const paid = await repositories.mintQuoteRepository.getMintQuote( + quote.mintUrl, + quote.method, + quote.quoteId, + ); + const pending = await repositories.mintQuoteRepository.getPendingMintQuotes('bolt11'); + expect(paid?.method).toBe('bolt11'); + if (paid?.method !== 'bolt11') throw new Error('Expected BOLT11 quote'); + expect(paid.state).toBe('PAID'); + expect(paid.amountPaid.equals(Amount.from(3))).toBe(true); + expect(paid.amountIssued.isZero()).toBe(true); + expect(pending).toHaveLength(1); + + await repositories.mintQuoteRepository.upsertMintQuote({ + ...quote, + state: 'UNPAID', + amountIssued: Amount.from(3), + }); + const issued = await repositories.mintQuoteRepository.getMintQuote( + quote.mintUrl, + quote.method, + quote.quoteId, + ); + const remaining = await repositories.mintQuoteRepository.getPendingMintQuotes('bolt11'); + expect(issued?.method).toBe('bolt11'); + if (issued?.method !== 'bolt11') throw new Error('Expected BOLT11 quote'); + expect(issued.state).toBe('ISSUED'); + expect(issued.amountIssued.equals(Amount.from(3))).toBe(true); + expect(remaining).toHaveLength(0); } finally { await dispose(); } @@ -1376,6 +1722,28 @@ export async function runMeltOperationRepositoryContract( const { describe, it, expect } = runner; describe('MeltOperationRepository contract', () => { + it('round-trips mint-swap ownership', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const operation = createDummyMeltOperation({ parentSwapOperationId: 'swap-parent' }); + await repositories.meltOperationRepository.create(operation); + + expect( + (await repositories.meltOperationRepository.getById(operation.id))?.parentSwapOperationId, + ).toBe('swap-parent'); + await expectThrows( + () => + repositories.meltOperationRepository.update({ + ...operation, + parentSwapOperationId: 'different-parent', + }), + expect, + ); + } finally { + await dispose(); + } + }); + it('round-trips custom-unit init melt operations', async () => { const { repositories, dispose } = await options.createRepositories(); try { diff --git a/packages/core/Manager.ts b/packages/core/Manager.ts index 99d987f8c..ef572b3a6 100644 --- a/packages/core/Manager.ts +++ b/packages/core/Manager.ts @@ -17,6 +17,7 @@ import { MintOperationProcessor, MeltQuoteWatcherService, MeltSettlementProcessor, + MintSwapOperationProcessor, ProofService, WalletService, SeedService, @@ -33,6 +34,7 @@ import { import { SendOperationService } from './operations/send/SendOperationService'; import { MeltOperationService } from './operations/melt/MeltOperationService'; import { MintOperationService } from './operations/mint/MintOperationService'; +import { MintSwapOperationService } from './operations/mintSwap/MintSwapOperationService.ts'; import { ReceiveOperationService } from './operations/receive/ReceiveOperationService'; import { MintScopedLock } from './operations/MintScopedLock'; import { @@ -67,6 +69,7 @@ import { ReceiveOpsApi, MeltOpsApi, MintOpsApi, + MintSwapOpsApi, QuoteApi, PaymentRequestsApi, } from './api'; @@ -76,6 +79,8 @@ import type { Plugin, PluginExtensions, ServiceMap } from './plugin.ts'; import { QuoteLifecycle } from './quotes/QuoteLifecycle.ts'; import { getMintQuoteAmount, + isBolt11MintQuoteIssued, + isBolt11MintQuotePaid, isStatefulMintQuote, mintQuoteToMethodSnapshot, } from './models/MintQuote.ts'; @@ -151,6 +156,20 @@ export interface CocoConfig { disabled?: boolean; initializeExistingPendingOperationsOnStart?: boolean; }; + /** Durable mint-swap reconciliation and outbox processor (enabled by default). */ + mintSwapOperationProcessor?: { + disabled?: boolean; + sweepIntervalMs?: number; + dueBatchSize?: number; + baseRetryDelayMs?: number; + maxRetryDelayMs?: number; + sourceBaseRetryDelayMs?: number; + sourceMaxRetryDelayMs?: number; + postPaymentBaseRetryDelayMs?: number; + postPaymentMaxRetryDelayMs?: number; + outboxBaseRetryDelayMs?: number; + outboxMaxRetryDelayMs?: number; + }; }; /** * Subscription transport configuration @@ -198,6 +217,29 @@ export async function initializeCoco(config: CocoConfig): Promise { // processor, or mint recovery path starts. await coco.reconcileLegacyMintQuotes(); + // Recover child sagas before any parent reconciliation or live watcher wake-up. + await coco.ops.send.recovery.run(); + await coco.ops.melt.recovery.run(); + await coco.recoverPendingPaymentRequestReceiveAttempts(); + await coco.recoverPendingMintOperations(); + await coco.ops.mintSwap.recovery.run(); + + // Start processors before watchers so durable sweeps cover any event-loss window. + const mintOperationProcessorConfig = config.processors?.mintOperationProcessor; + if (!mintOperationProcessorConfig?.disabled) { + await coco.enableMintOperationProcessor(mintOperationProcessorConfig); + } + + const meltSettlementProcessorConfig = config.processors?.meltSettlementProcessor; + if (!meltSettlementProcessorConfig?.disabled) { + await coco.enableMeltSettlementProcessor(meltSettlementProcessorConfig); + } + + const mintSwapOperationProcessorConfig = config.processors?.mintSwapOperationProcessor; + if (!mintSwapOperationProcessorConfig?.disabled) { + await coco.enableMintSwapOperationProcessor(mintSwapOperationProcessorConfig); + } + // Enable watchers (default: all enabled unless explicitly disabled) const mintOperationWatcherConfig = config.watchers?.mintOperationWatcher; if (!mintOperationWatcherConfig?.disabled) { @@ -214,29 +256,6 @@ export async function initializeCoco(config: CocoConfig): Promise { await coco.enableMeltQuoteWatcher(meltQuoteWatcherConfig); } - // Enable processors (default: all enabled unless explicitly disabled) - const mintOperationProcessorConfig = config.processors?.mintOperationProcessor; - if (!mintOperationProcessorConfig?.disabled) { - await coco.enableMintOperationProcessor(mintOperationProcessorConfig); - } - - const meltSettlementProcessorConfig = config.processors?.meltSettlementProcessor; - if (!meltSettlementProcessorConfig?.disabled) { - await coco.enableMeltSettlementProcessor(meltSettlementProcessorConfig); - } - - // Recover any pending send operations from previous session - await coco.ops.send.recovery.run(); - - // Recover any pending melt operations from previous session - await coco.ops.melt.recovery.run(); - - // Recover any pending receive operations and payment-request receive attempts from previous session - await coco.recoverPendingPaymentRequestReceiveAttempts(); - - // Recover any pending mint operations from previous session - await coco.recoverPendingMintOperations(); - return coco; } @@ -262,6 +281,7 @@ export class Manager { private mintOperationProcessor?: MintOperationProcessor; private meltQuoteWatcher?: MeltQuoteWatcherService; private meltSettlementProcessor?: MeltSettlementProcessor; + private mintSwapOperationProcessor?: MintSwapOperationProcessor; private legacyMintQuoteRepository: LegacyMintQuoteRepository; private quoteLifecycle: QuoteLifecycle; private proofStateWatcher?: ProofStateWatcherService; @@ -278,6 +298,7 @@ export class Manager { private meltOperationService: MeltOperationService; private meltOperationRepository: MeltOperationRepository; private mintOperationService: MintOperationService; + private mintSwapOperationService: MintSwapOperationService; private mintOperationRepository: MintOperationRepository; private receiveOperationService: ReceiveOperationService; private receiveOperationRepository: ReceiveOperationRepository; @@ -293,6 +314,7 @@ export class Manager { private disposed = false; private disposePromise?: Promise; private readonly outputDataCreator?: OutputDataCreator; + private readonly repositories: Repositories; constructor( repositories: Repositories, seedGetter: () => Promise, @@ -304,6 +326,7 @@ export class Manager { subscriptions?: CocoConfig['subscriptions'], outputDataCreator?: OutputDataCreator, ) { + this.repositories = repositories; this.logger = logger ?? new NullLogger(); this.eventBus = this.createEventBus(); this.outputDataCreator = outputDataCreator; @@ -347,6 +370,7 @@ export class Manager { this.authSessionService = core.authSessionService; this.authService = core.authService; this.mintOperationService = core.mintOperationService; + this.mintSwapOperationService = core.mintSwapOperationService; this.mintOperationRepository = core.mintOperationRepository; this.proofRepository = repositories.proofRepository; this.subscriptions = this.createSubscriptionManager(webSocketFactory, subscriptions); @@ -441,6 +465,7 @@ export class Manager { this.disposed = true; this.subscriptionsPaused = true; + await this.disableMintSwapOperationProcessor(); await this.disableMintOperationWatcher(); await this.disableProofStateWatcher(); await this.disableMeltSettlementProcessor(); @@ -576,6 +601,37 @@ export class Manager { this.meltSettlementProcessor = undefined; } + async enableMintSwapOperationProcessor(options?: { + sweepIntervalMs?: number; + dueBatchSize?: number; + baseRetryDelayMs?: number; + maxRetryDelayMs?: number; + sourceBaseRetryDelayMs?: number; + sourceMaxRetryDelayMs?: number; + postPaymentBaseRetryDelayMs?: number; + postPaymentMaxRetryDelayMs?: number; + outboxBaseRetryDelayMs?: number; + outboxMaxRetryDelayMs?: number; + }): Promise { + if (this.disposed) return false; + if (this.mintSwapOperationProcessor?.isRunning()) return false; + this.mintSwapOperationProcessor = new MintSwapOperationProcessor( + this.mintSwapOperationService, + this.repositories, + this.eventBus, + this.getChildLogger('MintSwapOperationProcessor'), + options, + ); + await this.mintSwapOperationProcessor.start(); + return true; + } + + async disableMintSwapOperationProcessor(): Promise { + if (!this.mintSwapOperationProcessor) return; + await this.mintSwapOperationProcessor.stop(); + this.mintSwapOperationProcessor = undefined; + } + async waitForMintOperationProcessor(): Promise { if (!this.mintOperationProcessor) return; await this.mintOperationProcessor.waitForCompletion(); @@ -629,7 +685,7 @@ export class Manager { continue; } - if (quote.state === 'ISSUED') { + if (isBolt11MintQuoteIssued(quote)) { skipped.push(quote.quote); continue; } @@ -697,7 +753,8 @@ export class Manager { // Pause transport layer this.subscriptions.pause(); - // Disable watchers + // Quiesce parent dispatch before child processors and watchers. + await this.disableMintSwapOperationProcessor(); await this.disableMintOperationWatcher(); await this.disableProofStateWatcher(); await this.disableMeltSettlementProcessor(); @@ -722,7 +779,30 @@ export class Manager { // Resume transport layer this.subscriptions.resume(); - // Re-enable watchers based on original configuration (idempotent) + // Recover children and parents before live wake-ups resume. + await this.ops.send.recovery.run(); + await this.ops.melt.recovery.run(); + await this.recoverPendingPaymentRequestReceiveAttempts(); + await this.recoverPendingMintOperations(); + await this.ops.mintSwap.recovery.run(); + + // Re-enable processors before watchers so sweeps close the event-loss window. + const mintOperationProcessorConfig = this.originalProcessorConfig?.mintOperationProcessor; + if (!mintOperationProcessorConfig?.disabled) { + await this.enableMintOperationProcessor(mintOperationProcessorConfig); + } + + const meltSettlementProcessorConfig = this.originalProcessorConfig?.meltSettlementProcessor; + if (!meltSettlementProcessorConfig?.disabled) { + await this.enableMeltSettlementProcessor(meltSettlementProcessorConfig); + } + + const mintSwapOperationProcessorConfig = + this.originalProcessorConfig?.mintSwapOperationProcessor; + if (!mintSwapOperationProcessorConfig?.disabled) { + await this.enableMintSwapOperationProcessor(mintSwapOperationProcessorConfig); + } + const mintOperationWatcherConfig = this.originalWatcherConfig?.mintOperationWatcher; if (!mintOperationWatcherConfig?.disabled) { await this.enableMintOperationWatcher(mintOperationWatcherConfig); @@ -738,19 +818,6 @@ export class Manager { await this.enableMeltQuoteWatcher(meltQuoteWatcherConfig); } - // Re-enable processor based on original configuration (idempotent) - const mintOperationProcessorConfig = this.originalProcessorConfig?.mintOperationProcessor; - if (!mintOperationProcessorConfig?.disabled) { - await this.enableMintOperationProcessor(mintOperationProcessorConfig); - } - - const meltSettlementProcessorConfig = this.originalProcessorConfig?.meltSettlementProcessor; - if (!meltSettlementProcessorConfig?.disabled) { - await this.enableMeltSettlementProcessor(meltSettlementProcessorConfig); - } - - await this.recoverPendingMintOperations(); - this.logger.info('Subscriptions resumed'); } @@ -770,7 +837,7 @@ export class Manager { operation.method, operation.quoteId, ); - if (!quote || !isStatefulMintQuote(quote) || quote.state !== 'PAID') continue; + if (!quote || !isStatefulMintQuote(quote) || !isBolt11MintQuotePaid(quote)) continue; const trusted = await this.mintService.isTrustedMint(operation.mintUrl); if (!trusted) { @@ -862,6 +929,7 @@ export class Manager { authService: AuthService; mintOperationService: MintOperationService; mintOperationRepository: MintOperationRepository; + mintSwapOperationService: MintSwapOperationService; } { const mintLogger = this.getChildLogger('MintService'); const walletLogger = this.getChildLogger('WalletService'); @@ -969,7 +1037,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), }); @@ -1019,10 +1087,23 @@ export class Manager { ); const mintOperationRepository = repositories.mintOperationRepository; + const mintSwapOperationService = new MintSwapOperationService( + repositories, + quoteLifecycle, + mintOperationService, + meltOperationService, + mintService, + walletService, + keyRingService, + mintScopedLock, + this.getChildLogger('MintSwapOperationService'), + ); + const historyService = new HistoryService( repositories.historyRepository, this.eventBus, historyLogger, + repositories.mintSwapOperationRepository, ); const legacyMintQuoteRepository = repositories.legacyMintQuoteRepository; @@ -1083,6 +1164,7 @@ export class Manager { authService, mintOperationService, mintOperationRepository, + mintSwapOperationService, }; } @@ -1113,7 +1195,8 @@ export class Manager { const receive = new ReceiveOpsApi(this.receiveOperationService); const mintOps = new MintOpsApi(this.mintOperationService); const melt = new MeltOpsApi(this.meltOperationService); - const ops = new OpsApi(send, receive, mintOps, melt); + const mintSwap = new MintSwapOpsApi(this.mintSwapOperationService, this.eventBus); + const ops = new OpsApi(send, receive, mintOps, melt, mintSwap); const quotes = new QuoteApi(this.quoteLifecycle); const auth = new AuthApi(this.authService); const paymentRequests = new PaymentRequestsApi( diff --git a/packages/core/adapter.ts b/packages/core/adapter.ts index f9678cb27..ec514b5ac 100644 --- a/packages/core/adapter.ts +++ b/packages/core/adapter.ts @@ -11,6 +11,8 @@ export type { MintOperationRepository, MintQuoteRepository, MintRepository, + MintSwapOperationRepository, + OperationEventOutboxRepository, PaymentRequestReceiveAttemptRepository, PaymentRequestReceiveOperationRepository, ProofRepository, @@ -36,8 +38,10 @@ export type { MintQuote, MintQuoteRef, QuoteIdentity, + OperationEventOutboxRecord, } from './models/index.ts'; export { + applyBolt11MintQuoteStateFallback, compareHistoryEntries, getMintQuoteAmount, getMintQuoteRemoteState, @@ -59,6 +63,17 @@ export type { MintMethodRemoteState, MintOperation, MintOperationState, + MintSwapAttentionReason, + MintSwapAttentionRecord, + MintSwapEventType, + MintSwapNut20KeyRef, + MintSwapOperation, + MintSwapOperationState, + MintSwapPreparedPlan, + MintSwapQuoteRef, + MintSwapRetry, + MintSwapSettlement, + MintSwapTerminalFailure, PaymentRequestReceiveAttempt, PaymentRequestReceiveAttemptState, PaymentRequestReceiveOperation, diff --git a/packages/core/api/HistoryApi.ts b/packages/core/api/HistoryApi.ts index f645000e4..80bbbfb39 100644 --- a/packages/core/api/HistoryApi.ts +++ b/packages/core/api/HistoryApi.ts @@ -1,4 +1,4 @@ -import type { HistoryEntry } from '@core/models/History'; +import type { HistoryEntry, HistoryFilter } from '@core/models/History'; import type { HistoryService } from '@core/services'; export class HistoryApi { @@ -8,8 +8,12 @@ export class HistoryApi { this.historyService = historyService; } - async getPaginatedHistory(offset = 0, limit = 25): Promise { - return this.historyService.getPaginatedHistory(offset, limit); + async getPaginatedHistory( + offset = 0, + limit = 25, + filter?: HistoryFilter, + ): Promise { + return this.historyService.getPaginatedHistory(offset, limit, filter); } async getHistoryEntryById(id: string): Promise { diff --git a/packages/core/api/MintSwapOpsApi.ts b/packages/core/api/MintSwapOpsApi.ts new file mode 100644 index 000000000..937209052 --- /dev/null +++ b/packages/core/api/MintSwapOpsApi.ts @@ -0,0 +1,142 @@ +import type { + ListMintSwapInput, + MintSwapOperation, + MintSwapOperationService, + PrepareMintSwapInput, +} from '../operations/mintSwap/index.ts'; +import type { EventBus, CoreEvents } from '../events/index.ts'; +import type { MintSwapOperationState } from '../operations/mintSwap/index.ts'; + +export interface WaitForMintSwapOptions { + states?: readonly MintSwapOperationState[]; + timeoutMs?: number; +} + +/** Public operation-oriented API for exact-receive cross-mint swaps. */ +export class MintSwapOpsApi { + readonly recovery; + readonly diagnostics; + + constructor( + private readonly service: MintSwapOperationService, + private readonly eventBus?: EventBus, + ) { + let recoveryPromise: Promise | undefined; + this.recovery = { + run: async () => { + if (recoveryPromise) return recoveryPromise; + recoveryPromise = (async () => { + const active = await this.service.listActive(); + await Promise.allSettled(active.map((operation) => this.service.refresh(operation.id))); + })(); + try { + await recoveryPromise; + } finally { + recoveryPromise = undefined; + } + }, + inProgress: () => recoveryPromise !== undefined, + }; + this.diagnostics = { + isLocked: (operationId: string) => this.service.isOperationLocked(operationId), + }; + } + + prepare(input: PrepareMintSwapInput): Promise { + return this.service.prepare(input); + } + + execute(operationOrId: MintSwapOperation | string): Promise { + return this.service.execute( + typeof operationOrId === 'string' ? operationOrId : operationOrId.id, + ); + } + + get(operationId: string): Promise { + return this.service.get(operationId); + } + + list(input?: ListMintSwapInput): Promise { + return this.service.list(input); + } + + listActive(): Promise { + return this.service.listActive(); + } + + refresh(operationId: string): Promise { + return this.service.refresh(operationId); + } + + retry(operationId: string): Promise { + return this.service.retry(operationId); + } + + cancel(operationId: string, reason?: string): Promise { + return this.service.cancel(operationId, reason); + } + + async waitFor( + operationId: string, + options: WaitForMintSwapOptions = {}, + ): Promise { + const states = new Set( + options.states ?? ['completed', 'cancelled', 'failed', 'needs_attention'], + ); + const initial = await this.service.get(operationId); + if (!initial) throw new Error(`Mint swap ${operationId} not found`); + if (states.has(initial.state)) return initial; + if (!this.eventBus) throw new Error('Mint swap event waiting is unavailable'); + + return new Promise((resolve, reject) => { + let settled = false; + let timeout: ReturnType | undefined; + const offs: Array<() => void> = []; + const cleanup = () => { + for (const off of offs) off(); + if (timeout) clearTimeout(timeout); + }; + const finish = (operation: MintSwapOperation) => { + if (settled || !states.has(operation.state)) return; + settled = true; + cleanup(); + resolve(operation); + }; + const observe = async (payload: { operationId: string }) => { + if (payload.operationId !== operationId || settled) return; + const operation = await this.service.get(operationId); + if (operation) finish(operation); + }; + const events = [ + 'mint-swap-op:prepared', + 'mint-swap-op:source-inflight', + 'mint-swap-op:destination-funded', + 'mint-swap-op:issuing', + 'mint-swap-op:completed', + 'mint-swap-op:cancelled', + 'mint-swap-op:failed', + 'mint-swap-op:needs-attention', + ] as const; + for (const event of events) offs.push(this.eventBus!.on(event, observe)); + if (options.timeoutMs !== undefined) { + timeout = setTimeout(() => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error(`Timed out waiting for mint swap ${operationId}`)); + }, options.timeoutMs); + } + void this.service + .get(operationId) + .then((operation) => { + if (operation) finish(operation); + }) + .catch((error) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }); + }); + } +} diff --git a/packages/core/api/OpsApi.ts b/packages/core/api/OpsApi.ts index b36fc8156..f29cdae49 100644 --- a/packages/core/api/OpsApi.ts +++ b/packages/core/api/OpsApi.ts @@ -2,6 +2,7 @@ import type { MintOpsApi } from './MintOpsApi'; import type { MeltOpsApi } from './MeltOpsApi'; import type { ReceiveOpsApi } from './ReceiveOpsApi'; import type { SendOpsApi } from './SendOpsApi'; +import type { MintSwapOpsApi } from './MintSwapOpsApi'; /** * Unified entry point for operation-based wallet workflows. @@ -32,5 +33,7 @@ export class OpsApi { * recovering outbound payment flows such as bolt11 melts. */ readonly melt: MeltOpsApi, + /** Exact-receive cross-mint swap operations. */ + readonly mintSwap: MintSwapOpsApi, ) {} } diff --git a/packages/core/api/index.ts b/packages/core/api/index.ts index ce07901b9..5ab6c225a 100644 --- a/packages/core/api/index.ts +++ b/packages/core/api/index.ts @@ -8,6 +8,7 @@ export * from './SendOpsApi.ts'; export * from './ReceiveOpsApi.ts'; export * from './MeltOpsApi.ts'; export * from './MintOpsApi.ts'; +export * from './MintSwapOpsApi.ts'; export * from './QuoteApi.ts'; export * from './OpsApi.ts'; export * from './PaymentRequestsApi.ts'; diff --git a/packages/core/events/types.ts b/packages/core/events/types.ts index a8345b483..6e9c522f3 100644 --- a/packages/core/events/types.ts +++ b/packages/core/events/types.ts @@ -15,9 +15,19 @@ import type { Keyset } from '../models/Keyset'; import type { Mint } from '../models/Mint'; import type { ReceiveOperation } from '../operations/receive/ReceiveOperation'; import type { SendOperation } from '../operations/send/SendOperation'; +import type { MintSwapEventPayload } from '../models/OperationEventOutbox.ts'; import type { CoreProof, ProofState } from '../types'; export interface CoreEvents { + 'mint-swap-op:prepared': MintSwapEventPayload; + 'mint-swap-op:source-inflight': MintSwapEventPayload; + 'mint-swap-op:destination-funded': MintSwapEventPayload; + 'mint-swap-op:issuing': MintSwapEventPayload; + 'mint-swap-op:completed': MintSwapEventPayload; + 'mint-swap-op:cancelled': MintSwapEventPayload; + 'mint-swap-op:failed': MintSwapEventPayload; + 'mint-swap-op:needs-attention': MintSwapEventPayload; + 'mint-swap-op:delayed': MintSwapEventPayload; 'mint:added': { mint: Mint; keysets: Keyset[] }; 'mint:updated': { mint: Mint; keysets: Keyset[] }; 'mint:metadata-refreshed': { mintUrl: string }; diff --git a/packages/core/index.ts b/packages/core/index.ts index 919be1334..2eb6a61de 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -15,7 +15,12 @@ export type { } from './types.ts'; export type { CoreEvents } from './events/types.ts'; export type { EventHandler } from './events/EventBus.ts'; -export { type Logger, ConsoleLogger } from './logging/index.ts'; +export { + type Logger, + ConsoleLogger, + redactSensitiveValue, + sensitiveValueFingerprint, +} from './logging/index.ts'; export { MemoryRepositories } from './repositories/memory/MemoryRepositories.ts'; export type { P2pkSendMethodData, @@ -61,6 +66,38 @@ export type { MintOperationState, TerminalMintOperation, } from './operations/mint/MintOperation.ts'; +export { + assertMintSwapTransition, + assertPreparedMintSwapImmutable, + canTransitionMintSwap, + createMintSwapPreparedPlanFingerprint, + isAutomaticMintSwapState, + isTerminalMintSwapState, + validateMintSwapAccounting, + validateMintSwapOperation, +} from './operations/mintSwap/MintSwapOperation.ts'; +export { + MintSwapOperationService, + MintSwapPreparationError, +} from './operations/mintSwap/MintSwapOperationService.ts'; +export type { + ListMintSwapInput, + PrepareMintSwapInput, +} from './operations/mintSwap/MintSwapOperationService.ts'; +export type { + MintSwapAttentionReason, + MintSwapAttentionRecord, + MintSwapEventType, + MintSwapNut20KeyRef, + MintSwapOperation, + MintSwapOperationState, + MintSwapPreparedPlan, + MintSwapPreparedPlanFingerprintInput, + MintSwapQuoteRef, + MintSwapRetry, + MintSwapSettlement, + MintSwapTerminalFailure, +} from './operations/mintSwap/MintSwapOperation.ts'; export type { MeltMethod, MeltMethodData, diff --git a/packages/core/infra/handlers/mint/MintBolt11Handler.ts b/packages/core/infra/handlers/mint/MintBolt11Handler.ts index 66522aeb7..2a4b71dda 100644 --- a/packages/core/infra/handlers/mint/MintBolt11Handler.ts +++ b/packages/core/infra/handlers/mint/MintBolt11Handler.ts @@ -1,3 +1,6 @@ +import { Amount, type MintQuoteBolt11Response } from '@cashu/cashu-ts'; +import { bytesToHex } from '@noble/curves/utils.js'; +import { assertSameUnit } from '@core/amounts'; import type { CreateMintQuoteContext, ExecuteContext, @@ -12,16 +15,34 @@ import type { PendingMintCheckResult, FetchRemoteMintQuoteContext, } from '@core/operations/mint'; -import { MintOperationError } from '../../../models/Error'; -import { assertSameUnit } from '@core/amounts'; import { deserializeOutputData, mapProofToCoreProof, serializeOutputData } from '@core/utils'; -import { Amount, type MintQuoteBolt11Response } from '@cashu/cashu-ts'; -import { mintQuoteFromBolt11Response, type MintQuote } from '../../../models/MintQuote'; +import { redactSensitiveValue } from '../../../logging/redaction'; +import { MintOperationError } from '../../../models/Error'; +import type { KeyRingService } from '../../../services/KeyRingService'; +import { + deriveBolt11MintQuoteState, + isBolt11MintQuoteIssued, + isBolt11MintQuotePaid, + isBolt11MintQuoteUnpaid, + mintQuoteFromBolt11Response, + type MintQuote, +} from '../../../models/MintQuote'; import { mintQuoteObservationFromBolt11Response } from '../../../models/MintQuoteObservationFactory'; export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { + constructor(private readonly keyRingService: KeyRingService) {} + async createQuote(ctx: CreateMintQuoteContext<'bolt11'>): Promise> { - const remoteQuote = await ctx.wallet.createMintQuoteBolt11(ctx.createQuoteData.amount.amount); + const { amount, pubkey } = ctx.createQuoteData; + if (pubkey) { + await ctx.mintService.assertNutSupported(ctx.mintUrl, 20, 'locked BOLT11 mint quote'); + } + const remoteQuote = pubkey + ? await ctx.wallet.createLockedMintQuote(amount.amount, pubkey) + : await ctx.wallet.createMintQuoteBolt11(amount.amount); + if (pubkey && remoteQuote.pubkey !== pubkey) { + throw new Error('Mint returned a BOLT11 quote with an unexpected NUT-20 public key'); + } return mintQuoteFromBolt11Response(ctx.mintUrl, remoteQuote); } @@ -39,26 +60,26 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { ): Promise & MintMethodMeta<'bolt11'>> { const quote = ctx.importedQuote; if (!quote) { - throw new Error(`Mint quote ${ctx.operation.quoteId ?? '(missing)'} was not provided`); + throw new Error('BOLT11 mint quote was not provided'); } + const quoteRef = redactSensitiveValue(quote.quote); if (!quote.amount || quote.amount.isZero()) { - throw new Error(`Mint quote ${quote.quote} has invalid amount`); + throw new Error(`Mint quote ${quoteRef} has invalid amount`); } if (ctx.operation.quoteId !== quote.quote) { - throw new Error( - `Mint quote ${quote.quote} does not match operation quote ${ctx.operation.quoteId}`, - ); + throw new Error(`Mint quote ${quoteRef} does not match the operation quote`); } if (!quote.amount.equals(ctx.operation.amount)) { throw new Error( - `Mint quote ${quote.quote} amount ${quote.amount} does not match requested amount ${ctx.operation.amount}`, + `Mint quote ${quoteRef} amount ${quote.amount} does not match requested amount ${ctx.operation.amount}`, ); } - assertSameUnit(quote.unit, ctx.operation.unit, `Mint quote ${quote.quote}`); + assertSameUnit(quote.unit, ctx.operation.unit, `Mint quote ${quoteRef}`); + await this.requireQuoteKey(quote.pubkey); const outputData = await ctx.proofService.createOutputsAndIncrementCounters( ctx.operation.mintUrl, @@ -88,12 +109,13 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { async execute(ctx: ExecuteContext<'bolt11'>): Promise { const outputData = deserializeOutputData(ctx.operation.outputData); + const mintConfig = await this.getMintConfig(ctx.operation.pubkey); try { const proofs = await ctx.wallet.mintProofsBolt11( ctx.operation.amount, ctx.operation.quoteId, - undefined, + mintConfig, { type: 'custom', data: outputData.keep, @@ -111,28 +133,43 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { async recoverExecuting(ctx: RecoverExecutingContext<'bolt11'>): Promise { const { mintUrl, quoteId } = ctx.operation; - let remoteQuote: MintQuoteBolt11Response; - try { - remoteQuote = await ctx.mintAdapter.checkMintQuote(mintUrl, 'bolt11', quoteId); - } catch (error) { - ctx.logger?.warn('Failed to check mint quote state during recovery', { - mintUrl, - quoteId, - error: error instanceof Error ? error.message : String(error), - }); + const quoteRef = redactSensitiveValue(quoteId); + let canonicalRemoteQuote: MintQuote<'bolt11'>; + if (ctx.canonicalQuote) { + canonicalRemoteQuote = ctx.canonicalQuote; + } else { + let remoteQuote: MintQuoteBolt11Response; + try { + remoteQuote = await ctx.mintAdapter.checkMintQuote(mintUrl, 'bolt11', quoteId); + } catch (error) { + ctx.logger?.warn('Failed to check mint quote state during recovery', { + mintUrl, + quoteRef, + error: error instanceof Error ? error.message : String(error), + }); + return { + status: 'PENDING', + error: error instanceof Error ? error.message : String(error), + }; + } + canonicalRemoteQuote = mintQuoteFromBolt11Response(mintUrl, remoteQuote); + } + + if (ctx.operation.pubkey && canonicalRemoteQuote.pubkey !== ctx.operation.pubkey) { return { - status: 'PENDING', - error: error instanceof Error ? error.message : String(error), + status: 'TERMINAL', + error: `Recovered: BOLT11 mint operation ${ctx.operation.id} has mismatched NUT-20 quote ownership`, }; } - if (remoteQuote.state === 'PAID') { + if (isBolt11MintQuotePaid(canonicalRemoteQuote)) { const outputData = deserializeOutputData(ctx.operation.outputData); try { + const mintConfig = await this.getMintConfig(ctx.operation.pubkey); const proofs = await ctx.wallet.mintProofsBolt11( ctx.operation.amount, ctx.operation.quoteId, - undefined, + mintConfig, { type: 'custom', data: outputData.keep, @@ -155,7 +192,7 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { } else if (err.code === 20007) { return { status: 'TERMINAL', - error: `Recovered: quote ${quoteId} expired while executing mint`, + error: `Recovered: quote ${quoteRef} expired while executing mint`, }; } else { return { @@ -170,15 +207,15 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { }; } } - } else if (remoteQuote.state === 'UNPAID') { + } else if (isBolt11MintQuoteUnpaid(canonicalRemoteQuote)) { return { status: 'PENDING', - error: `Recovered: quote ${quoteId} is still UNPAID`, + error: `Recovered: quote ${quoteRef} is still UNPAID`, }; - } else if (remoteQuote.state !== 'ISSUED') { + } else if (!isBolt11MintQuoteIssued(canonicalRemoteQuote)) { return { status: 'PENDING', - error: `Recovered: quote ${quoteId} remains in remote state ${remoteQuote.state}`, + error: `Recovered: quote ${quoteRef} has unresolved Mint Quote Accounting`, }; } @@ -194,7 +231,7 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { if (recovered.length === 0) { return { status: 'PENDING', - error: `Recovered: quote ${quoteId} issued remotely but proofs were not recoverable`, + error: `Recovered: quote ${quoteRef} issued remotely but proofs were not recoverable`, }; } return { status: 'FINALIZED' }; @@ -208,35 +245,77 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { async checkPending(ctx: PendingContext<'bolt11'>): Promise> { const { mintUrl, quoteId } = ctx.operation; - ctx.logger?.info('Checking pending mint operation', { mintUrl, quoteId }); + const quoteRef = redactSensitiveValue(quoteId); + ctx.logger?.info('Checking pending mint operation', { mintUrl, quoteRef }); const quote = await ctx.mintAdapter.checkMintQuote(mintUrl, 'bolt11', quoteId); - ctx.logger?.info('Pending mint quote state', { mintUrl, quoteId, state: quote.state }); + const canonicalQuote = mintQuoteFromBolt11Response(mintUrl, quote); + const remoteState = deriveBolt11MintQuoteState( + canonicalQuote.amountPaid, + canonicalQuote.amountIssued, + ); + ctx.logger?.info('Pending mint quote accounting', { + mintUrl, + quoteRef, + amountPaid: canonicalQuote.amountPaid.toString(), + amountIssued: canonicalQuote.amountIssued.toString(), + compatibilityState: remoteState, + }); const observedRemoteStateAt = Date.now(); - 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 (isBolt11MintQuoteUnpaid(canonicalQuote)) { + return { + observedRemoteState: remoteState, + observedRemoteStateAt, + quoteSnapshot: quote, + category: 'waiting', + }; + } + if (isBolt11MintQuotePaid(canonicalQuote)) { + return { + observedRemoteState: remoteState, + observedRemoteStateAt, + quoteSnapshot: quote, + category: 'ready', + }; + } + if (isBolt11MintQuoteIssued(canonicalQuote)) { + return { + observedRemoteState: remoteState, + observedRemoteStateAt, + quoteSnapshot: quote, + category: 'completed', + }; + } + + return { + observedRemoteState: remoteState, + observedRemoteStateAt, + quoteSnapshot: quote, + category: 'waiting', + }; + } + + async validateQuoteForPrepare(quote: MintQuote<'bolt11'>): Promise { + await this.requireQuoteKey(quote.pubkey); + } + + private async requireQuoteKey(pubkey: string | undefined): Promise { + if (!pubkey) return; + const key = await this.keyRingService.getMintQuoteKeyPair(pubkey); + if (!key) { + throw new Error('Missing NUT-20 mint quote key for locked BOLT11 quote'); + } + } + + private async getMintConfig( + pubkey: string | undefined, + ): Promise<{ privkey: string } | undefined> { + if (!pubkey) return undefined; + const key = await this.keyRingService.getMintQuoteKeyPair(pubkey); + if (!key) { + throw new Error('Missing NUT-20 mint quote key for locked BOLT11 quote'); } + return { privkey: bytesToHex(key.secretKey) }; } } diff --git a/packages/core/logging/index.ts b/packages/core/logging/index.ts index 6190045da..2462e5395 100644 --- a/packages/core/logging/index.ts +++ b/packages/core/logging/index.ts @@ -1,3 +1,4 @@ export * from './Logger.ts'; export * from './ConsoleLogger.ts'; export * from './NullLogger.ts'; +export * from './redaction.ts'; diff --git a/packages/core/logging/redaction.ts b/packages/core/logging/redaction.ts new file mode 100644 index 000000000..e947ef2f3 --- /dev/null +++ b/packages/core/logging/redaction.ts @@ -0,0 +1,19 @@ +import { bytesToHex } from '@noble/curves/utils.js'; +import { sha256 } from '@noble/hashes/sha2.js'; + +/** Returns a stable, non-reversible correlation token without exposing the sensitive value. */ +export function sensitiveValueFingerprint(value: string): string { + const digest = sha256(new TextEncoder().encode(value)); + return bytesToHex(digest).slice(0, 12); +} + +/** Suitable for logs and diagnostic errors that must not contain quote/payment secrets. */ +export function redactSensitiveValue(value: string): string { + return `[redacted:${sensitiveValueFingerprint(value)}]`; +} + +/** Converts an arbitrary failure into a correlation token without retaining its message. */ +export function redactError(error: unknown): string { + const value = error instanceof Error ? error.message : String(error); + return `error:${redactSensitiveValue(value)}`; +} diff --git a/packages/core/models/Error.ts b/packages/core/models/Error.ts index d333ac9ce..0b9bb46f1 100644 --- a/packages/core/models/Error.ts +++ b/packages/core/models/Error.ts @@ -97,6 +97,21 @@ export class OperationInProgressError extends Error { } } +/** Raised when a parent-owned child saga is advanced outside its owning swap coordinator. */ +export class ParentOwnedOperationError extends Error { + readonly operationId: string; + readonly parentSwapOperationId: string; + + constructor(operationId: string, parentSwapOperationId: string) { + super( + `Operation ${operationId} is owned by mint swap ${parentSwapOperationId} and cannot be advanced directly`, + ); + this.name = 'ParentOwnedOperationError'; + this.operationId = operationId; + this.parentSwapOperationId = parentSwapOperationId; + } +} + export class AuthSessionError extends Error { readonly mintUrl: string; constructor(mintUrl: string, message?: string, cause?: unknown) { diff --git a/packages/core/models/History.ts b/packages/core/models/History.ts index 21baef9e5..e521a549f 100644 --- a/packages/core/models/History.ts +++ b/packages/core/models/History.ts @@ -19,6 +19,10 @@ import type { SendOperation, SendOperationState, } from '../operations/send/SendOperation.ts'; +import type { + MintSwapOperation, + MintSwapOperationState, +} from '../operations/mintSwap/MintSwapOperation.ts'; export type HistoryType = 'mint' | 'melt' | 'send' | 'receive'; @@ -126,7 +130,34 @@ export type LegacyHistoryEntry = | LegacySendHistoryEntry | LegacyReceiveHistoryEntry; -export type HistoryEntry = OperationHistoryEntry | LegacyHistoryEntry; +export interface MintSwapHistoryEntry { + id: string; + source: 'operation'; + type: 'mint-swap'; + operationId: string; + createdAt: number; + updatedAt: number; + sourceMintUrl: string; + destinationMintUrl: string; + /** Source mint retained for compatibility with generic history consumers. */ + mintUrl: string; + unit: 'sat'; + amount: Amount; + state: MintSwapOperationState; + minimumSourceDebit?: Amount; + maximumSourceDebit?: Amount; + finalSourceDebit?: Amount; + totalSourceFee?: Amount; + reasonCode?: string; + error?: string; +} + +export type HistoryEntry = OperationHistoryEntry | LegacyHistoryEntry | MintSwapHistoryEntry; + +export interface HistoryFilter { + mintUrl?: string; + types?: readonly (HistoryType | 'mint-swap')[]; +} export type LegacyHistoryRowInput = { legacyHistoryId: string | number; @@ -144,7 +175,7 @@ export type LegacyHistoryRowInput = { }; export function isOperationHistoryEntry(entry: HistoryEntry): entry is OperationHistoryEntry { - return entry.source === 'operation'; + return entry.source === 'operation' && entry.type !== 'mint-swap'; } export function isLegacyHistoryEntry(entry: HistoryEntry): entry is LegacyHistoryEntry { @@ -184,6 +215,29 @@ export function compareHistoryEntries(a: HistoryEntry, b: HistoryEntry): number return b.id.localeCompare(a.id); } +export function projectMintSwapOperation(operation: MintSwapOperation): MintSwapHistoryEntry { + return { + id: `mint-swap:${operation.id}`, + source: 'operation', + type: 'mint-swap', + operationId: operation.id, + createdAt: operation.createdAt, + updatedAt: operation.updatedAt, + sourceMintUrl: operation.sourceMintUrl, + destinationMintUrl: operation.destinationMintUrl, + mintUrl: operation.sourceMintUrl, + unit: operation.unit, + amount: operation.destinationAmount, + state: operation.state, + minimumSourceDebit: operation.preparedPlan?.minimumSourceDebit, + maximumSourceDebit: operation.preparedPlan?.maximumSourceDebit, + finalSourceDebit: operation.settlement?.finalSourceDebit, + totalSourceFee: operation.settlement?.totalSourceFee, + reasonCode: operation.attention?.reason ?? operation.terminalFailure?.code, + error: operation.attention?.message ?? operation.terminalFailure?.reason, + }; +} + export function projectSendOperation(operation: SendOperation): SendHistoryEntry | null { if (operation.state === 'init') return null; diff --git a/packages/core/models/MintQuote.ts b/packages/core/models/MintQuote.ts index 4172648f1..de2b5d3ab 100644 --- a/packages/core/models/MintQuote.ts +++ b/packages/core/models/MintQuote.ts @@ -80,10 +80,86 @@ export function isStatefulMintQuote(quote: MintQuote): quote is MintQuote<'bolt1 return quote.method === 'bolt11'; } +/** Derives the deprecated BOLT11 state projection from canonical quote accounting. */ +export function deriveBolt11MintQuoteState( + amountPaid: Amount, + amountIssued: Amount, +): MintMethodRemoteState<'bolt11'> { + return amountPaid.isZero() && amountIssued.isZero() + ? 'UNPAID' + : amountPaid.greaterThan(amountIssued) + ? 'PAID' + : 'ISSUED'; +} + +/** Returns whether canonical BOLT11 accounting represents an unpaid quote. */ +export function isBolt11MintQuoteUnpaid(quote: MintQuote<'bolt11'>): boolean { + return quote.amountPaid.isZero() && quote.amountIssued.isZero(); +} + +/** Returns whether canonical BOLT11 accounting can fund the quote's exact mint operation. */ +export function isBolt11MintQuotePaid(quote: MintQuote<'bolt11'>): boolean { + return ( + quote.amountIssued.isZero() && + quote.amountPaid.greaterThanOrEqual(quote.amount) && + getMintQuoteAvailableAmount(quote).greaterThanOrEqual(quote.amount) + ); +} + +/** Returns whether canonical BOLT11 accounting has issued the quote's full fixed amount. */ +export function isBolt11MintQuoteIssued(quote: MintQuote<'bolt11'>): boolean { + return quote.amountIssued.greaterThanOrEqual(quote.amount); +} + +/** + * Applies a legacy BOLT11 state observation without allowing it to reduce canonical accounting. + * + * @deprecated Legacy state is a fallback for snapshots that do not carry Mint Quote Accounting. + */ +export function applyBolt11MintQuoteStateFallback( + quote: MintQuote<'bolt11'>, + state: MintMethodRemoteState<'bolt11'>, + observedAt = Date.now(), +): MintQuote<'bolt11'> { + const hasLegacyProjectionShape = + (quote.amountPaid.isZero() && quote.amountIssued.isZero()) || + (quote.amountPaid.equals(quote.amount) && quote.amountIssued.isZero()) || + (quote.amountPaid.equals(quote.amount) && quote.amountIssued.equals(quote.amount)); + if (quote.remoteUpdatedAt !== null || !hasLegacyProjectionShape) { + return { + ...quote, + state: deriveBolt11MintQuoteState(quote.amountPaid, quote.amountIssued), + updatedAt: observedAt, + }; + } + + const paidFallback = state === 'UNPAID' ? Amount.zero() : quote.amount; + const issuedFallback = state === 'ISSUED' ? quote.amount : Amount.zero(); + const amountPaid = quote.amountPaid.greaterThan(paidFallback) ? quote.amountPaid : paidFallback; + const amountIssued = quote.amountIssued.greaterThan(issuedFallback) + ? quote.amountIssued + : issuedFallback; + + return { + ...quote, + state: deriveBolt11MintQuoteState(amountPaid, amountIssued), + amountPaid, + amountIssued, + updatedAt: observedAt, + }; +} + +/** + * Returns the deprecated BOLT11 state projection for compatibility consumers. + * + * @deprecated Use `amountPaid` and `amountIssued`, or the canonical accounting predicates. + */ export function getMintQuoteRemoteState( quote: MintQuote, ): MintMethodRemoteState<'bolt11'> | undefined { - return isStatefulMintQuote(quote) ? quote.state : undefined; + return isStatefulMintQuote(quote) + ? deriveBolt11MintQuoteState(quote.amountPaid, quote.amountIssued) + : undefined; } /** @@ -101,16 +177,12 @@ export function getMintQuoteAmount(quote: MintQuote): Amount | undefined { } export function getMintQuoteAvailableAmount(quote: MintQuote): Amount { - if (quote.reusable) { - return quote.amountPaid.subtract(quote.amountIssued); - } - - return quote.state === 'PAID' ? quote.amount : Amount.zero(); + return quote.amountPaid.subtract(quote.amountIssued); } export function isMintQuotePending(quote: MintQuote): boolean { if (isStatefulMintQuote(quote)) { - return quote.state !== 'ISSUED'; + return !isBolt11MintQuoteIssued(quote); } return true; @@ -133,7 +205,11 @@ export function mintQuoteFromBolt11Response( quote: MintQuoteBolt11Response, options?: { now?: number }, ): MintQuote<'bolt11'> { - const canonicalQuote = mintQuoteObservationFromBolt11Response(mintUrl, quote, options); + const observation = mintQuoteObservationFromBolt11Response(mintUrl, quote, options); + const canonicalQuote: MintQuote<'bolt11'> = { + ...observation, + state: deriveBolt11MintQuoteState(observation.amountPaid, observation.amountIssued), + }; assertValidMintQuoteAccounting( canonicalQuote.quoteId, canonicalQuote.amountPaid, @@ -182,7 +258,7 @@ export function mintQuoteToMethodSnapshot( unit: quote.unit, expiry: quote.expiry, pubkey: quote.pubkey, - state: quote.state, + state: deriveBolt11MintQuoteState(quote.amountPaid, quote.amountIssued), amount_paid: quote.amountPaid, amount_issued: quote.amountIssued, updated_at: quote.remoteUpdatedAt, diff --git a/packages/core/models/MintQuoteState.ts b/packages/core/models/MintQuoteState.ts index 437d4b21f..b28de3ebd 100644 --- a/packages/core/models/MintQuoteState.ts +++ b/packages/core/models/MintQuoteState.ts @@ -1 +1,2 @@ +/** @deprecated Use canonical `amountPaid` and `amountIssued` Mint Quote Accounting. */ export type MintQuoteState = 'UNPAID' | 'PAID' | 'ISSUED'; diff --git a/packages/core/models/MintSwapPolicy.ts b/packages/core/models/MintSwapPolicy.ts new file mode 100644 index 000000000..774adb614 --- /dev/null +++ b/packages/core/models/MintSwapPolicy.ts @@ -0,0 +1,58 @@ +export const DEFAULT_MINT_SWAP_DISPATCH_WINDOW_SECONDS = 120; +export const MIN_MINT_SWAP_DISPATCH_WINDOW_SECONDS = 30; + +export interface MintSwapDispatchWindowInput { + expiries: Array; + now?: number; + requiredWindowSeconds?: number; +} + +export interface MintSwapDispatchWindow { + dispatchDeadline: number; + remainingSeconds: number; + requiredWindowSeconds: number; + canDispatch: boolean; +} + +/** + * Evaluates the earliest usable quote/invoice deadline before source payment. + * Expiries and `now` use Unix seconds; zero/null expiries are no-expiry sentinels. + */ +export function evaluateMintSwapDispatchWindow( + input: MintSwapDispatchWindowInput, +): MintSwapDispatchWindow { + const requiredWindowSeconds = + input.requiredWindowSeconds ?? DEFAULT_MINT_SWAP_DISPATCH_WINDOW_SECONDS; + if ( + !Number.isSafeInteger(requiredWindowSeconds) || + requiredWindowSeconds < MIN_MINT_SWAP_DISPATCH_WINDOW_SECONDS + ) { + throw new Error( + `Mint swap dispatch window must be at least ${MIN_MINT_SWAP_DISPATCH_WINDOW_SECONDS} seconds`, + ); + } + + const deadlines: number[] = []; + for (const expiry of input.expiries) { + if (expiry === null || expiry === undefined || expiry === 0) { + continue; + } + if (!Number.isSafeInteger(expiry) || expiry < 0) { + throw new Error('Mint swap expiry must be a positive Unix timestamp or a no-expiry sentinel'); + } + deadlines.push(expiry); + } + if (deadlines.length === 0) { + throw new Error('Mint swap dispatch requires at least one finite quote or invoice expiry'); + } + + const dispatchDeadline = Math.min(...deadlines); + const now = input.now ?? Math.floor(Date.now() / 1000); + const remainingSeconds = dispatchDeadline - now; + return { + dispatchDeadline, + remainingSeconds, + requiredWindowSeconds, + canDispatch: remainingSeconds >= requiredWindowSeconds, + }; +} diff --git a/packages/core/models/OperationEventOutbox.ts b/packages/core/models/OperationEventOutbox.ts new file mode 100644 index 000000000..4c7b2b316 --- /dev/null +++ b/packages/core/models/OperationEventOutbox.ts @@ -0,0 +1,34 @@ +import type { + MintSwapEventType, + MintSwapOperationState, +} from '../operations/mintSwap/MintSwapOperation'; + +export interface MintSwapEventPayload { + operationId: string; + revision: number; + state: MintSwapOperationState; + sourceMintUrl: string; + destinationMintUrl: string; + unit: 'sat'; + destinationAmount: string; + reasonCode?: string; +} + +export interface OperationEventOutboxRecord { + id: string; + operationId: string; + revision: number; + eventType: MintSwapEventType; + payload: MintSwapEventPayload; + createdAt: number; + publishedAt?: number; + publishAttempts: number; + nextAttemptAt?: number; + lastError?: string; +} + +export function operationEventLogicalKey( + record: Pick, +): string { + return `${record.operationId}\u0000${record.revision}\u0000${record.eventType}`; +} diff --git a/packages/core/models/index.ts b/packages/core/models/index.ts index e66b84c19..7b9fff9f0 100644 --- a/packages/core/models/index.ts +++ b/packages/core/models/index.ts @@ -8,4 +8,6 @@ export * from './Mint'; export * from './MeltQuote'; export * from './MintQuote'; export * from './MintQuoteState'; +export * from './MintSwapPolicy'; +export * from './OperationEventOutbox'; export * from './QuoteIdentity'; diff --git a/packages/core/operations/index.ts b/packages/core/operations/index.ts index 7ffc9b8fb..b428968fd 100644 --- a/packages/core/operations/index.ts +++ b/packages/core/operations/index.ts @@ -11,6 +11,7 @@ export type { PendingMintCheckResult, } from './mint/MintMethodHandler.ts'; export { MintOperationService } from './mint/MintOperationService.ts'; +export * from './mintSwap'; export * from './send'; export type { ReceiveOperation, ReceiveOperationState } from './receive/ReceiveOperation.ts'; export { ReceiveOperationService } from './receive/ReceiveOperationService.ts'; diff --git a/packages/core/operations/melt/MeltOperation.ts b/packages/core/operations/melt/MeltOperation.ts index e76107051..646494bfa 100644 --- a/packages/core/operations/melt/MeltOperation.ts +++ b/packages/core/operations/melt/MeltOperation.ts @@ -58,6 +58,9 @@ interface MeltOperationBase extends MeltMethodMeta { /** Error message if the operation failed */ error?: string; + + /** Owning parent swap. Parent-owned children may only be advanced by that parent. */ + parentSwapOperationId?: string; } /** @@ -306,7 +309,7 @@ export function createMeltOperation( mintUrl: string, meta: MeltMethodMeta, unit = DEFAULT_UNIT, - options?: { quoteId?: string }, + options?: { quoteId?: string; parentSwapOperationId?: string }, ): InitMeltOperation { const now = Date.now(); return { @@ -316,6 +319,9 @@ export function createMeltOperation( mintUrl, unit: normalizeUnit(unit, { defaultUnit: DEFAULT_UNIT }), ...(options?.quoteId ? { quoteId: options.quoteId } : {}), + ...(options?.parentSwapOperationId + ? { parentSwapOperationId: options.parentSwapOperationId } + : {}), createdAt: now, updatedAt: now, }; diff --git a/packages/core/operations/melt/MeltOperationService.ts b/packages/core/operations/melt/MeltOperationService.ts index 776282056..158d2a163 100644 --- a/packages/core/operations/melt/MeltOperationService.ts +++ b/packages/core/operations/melt/MeltOperationService.ts @@ -1,4 +1,9 @@ -import type { MeltOperationRepository, ProofRepository } from '../../repositories'; +import type { Wallet } from '@cashu/cashu-ts'; +import type { + MeltOperationRepository, + ProofRepository, + RepositoryTransactionScope, +} from '../../repositories'; import type { MeltOperation, InitMeltOperation, @@ -16,6 +21,7 @@ import type { MeltMethodData, MeltMethodInputData, PendingCheckResult, + ExecutionResult, } from './MeltMethodHandler'; import { normalizeMeltMethodData } from './MeltMethodHandler'; import type { MintService } from '../../services/MintService'; @@ -35,6 +41,16 @@ import { DEFAULT_UNIT, normalizeUnit } from '../../amounts.ts'; import type { QuoteLifecycle } from '../../quotes/QuoteLifecycle'; import { resolveOnchainMeltFeeOption, type MeltQuote } from '../../models/MeltQuote.ts'; import type { MeltQuoteRef, QuoteIdentity } from '../../models/QuoteIdentity.ts'; +import { assertChildOperationAccess } from '../mintSwap/ChildOperationOwnership.ts'; + +export interface PrepareOwnedMeltOperationCommand { + operationId: string; + parentSwapOperationId: string; + quote: MeltQuote; + wallet: Wallet; + repositories: RepositoryTransactionScope; + feeIndex?: number; +} /** * MeltOperationService orchestrates melt sagas while delegating @@ -82,10 +98,13 @@ export class MeltOperationService { this.mintScopedLock = mintScopedLock ?? new MintScopedLock(); } - private buildDeps() { + private buildDeps(repositories?: RepositoryTransactionScope) { + const proofService = repositories + ? this.proofService.forTransaction(repositories) + : this.proofService; return { - proofRepository: this.proofRepository, - proofService: this.proofService, + proofRepository: repositories?.proofRepository ?? this.proofRepository, + proofService, walletService: this.walletService, mintService: this.mintService, mintAdapter: this.mintAdapter, @@ -271,6 +290,142 @@ export class MeltOperationService { } } + /** Prepare and persist a parent-owned source child using transaction-scoped local writes. */ + async prepareOwnedInTransaction( + command: PrepareOwnedMeltOperationCommand, + ): Promise { + const { quote, operationId, parentSwapOperationId, repositories, wallet } = command; + if (quote.method !== 'bolt11' || quote.unit !== 'sat') { + throw new Error('Mint swaps require a sat-denominated BOLT11 source quote'); + } + const initOperation = createMeltOperation( + operationId, + quote.mintUrl, + { method: 'bolt11', methodData: this.methodDataFromMeltQuote(quote) }, + quote.unit, + { quoteId: quote.quoteId, parentSwapOperationId }, + ); + const prepared = await this.handlerProvider.get('bolt11').prepare({ + ...this.buildDeps(repositories), + operation: initOperation as any, + wallet, + quote: quote as any, + }); + const preparedOperation: PreparedMeltOperation = { + ...prepared, + id: operationId, + parentSwapOperationId, + state: 'prepared', + updatedAt: Date.now(), + }; + await repositories.meltOperationRepository.create(preparedOperation); + return preparedOperation; + } + + /** Persist payment authorization before performing any remote source effect. */ + async authorizeOwnedExecutionInTransaction( + operationId: string, + parentSwapOperationId: string, + repositories: RepositoryTransactionScope, + ): Promise { + const operation = await repositories.meltOperationRepository.getById(operationId); + if (!operation || operation.state !== 'prepared') { + throw new Error( + `Cannot authorize melt child ${operationId} from ${operation?.state ?? 'missing'}`, + ); + } + assertChildOperationAccess(operation, parentSwapOperationId); + const executing: ExecutingMeltOperation = { + ...operation, + state: 'executing', + updatedAt: Date.now(), + }; + await repositories.meltOperationRepository.update(executing); + return executing; + } + + /** Perform the remote melt after its authorization transaction has committed. */ + async executeOwnedRemote( + operation: ExecutingMeltOperation, + parentSwapOperationId: string, + ): Promise { + assertChildOperationAccess(operation, parentSwapOperationId); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId( + operation.mintUrl, + operation.unit, + ); + const proofs = await this.proofRepository.getProofsByOperationId( + operation.mintUrl, + operation.id, + ); + return this.handlerProvider.get(operation.method).execute({ + ...this.buildDeps(), + operation, + wallet, + reservedProofs: proofs.filter((proof) => proof.usedByOperationId === operation.id), + }); + } + + /** Apply the child state returned by the remote command in the parent's transaction. */ + async applyOwnedExecutionInTransaction( + operation: ExecutingMeltOperation, + parentSwapOperationId: string, + result: ExecutionResult, + repositories: RepositoryTransactionScope, + ): Promise { + assertChildOperationAccess(operation, parentSwapOperationId); + if (result.status === 'FAILED') { + throw new Error(result.failed.error ?? 'Melt execution failed'); + } + const next: PendingMeltOperation | FinalizedMeltOperation = + result.status === 'PAID' + ? { ...result.finalized, state: 'finalized', updatedAt: Date.now() } + : { ...result.pending, state: 'pending', updatedAt: Date.now() }; + if (next.id !== operation.id) { + throw new Error(`Melt result operation ${next.id} does not match ${operation.id}`); + } + assertChildOperationAccess(next, parentSwapOperationId); + await repositories.meltOperationRepository.update(next); + return next; + } + + async recoverOwnedExecuting( + operation: ExecutingMeltOperation, + parentSwapOperationId: string, + ): Promise { + return this.recoverExecutingOperation(operation, { parentSwapOperationId }); + } + + /** Roll back an undispatched source child inside the parent's cancellation transaction. */ + async rollbackOwnedPreparedInTransaction( + operationId: string, + parentSwapOperationId: string, + wallet: Wallet, + repositories: RepositoryTransactionScope, + reason = 'Parent mint swap cancelled', + ): Promise { + const operation = await repositories.meltOperationRepository.getById(operationId); + if (!operation || operation.state !== 'prepared') { + throw new Error( + `Cannot roll back melt child ${operationId} from ${operation?.state ?? 'missing'}`, + ); + } + assertChildOperationAccess(operation, parentSwapOperationId); + await this.handlerProvider.get(operation.method).rollback?.({ + ...this.buildDeps(repositories), + operation, + wallet, + }); + const rolledBack: RolledBackMeltOperation = { + ...operation, + state: 'rolled_back', + updatedAt: Date.now(), + error: reason, + }; + await repositories.meltOperationRepository.update(rolledBack); + return rolledBack; + } + /** * Prepare the operation by reserving proofs and creating outputs. * After this step, the operation can be executed or rolled back. @@ -289,6 +444,7 @@ export class MeltOperationService { }'`, ); } + assertChildOperationAccess(operation); const initOp = operation as InitMeltOperation; const releaseMintLock = await this.mintScopedLock.acquire(initOp.mintUrl); @@ -363,6 +519,7 @@ export class MeltOperationService { }'`, ); } + assertChildOperationAccess(operation); const preparedOp = operation as PreparedMeltOperation; @@ -455,7 +612,7 @@ export class MeltOperationService { async finalize( operationId: string, - options: { canonicalQuote?: MeltQuote } = {}, + options: { canonicalQuote?: MeltQuote; parentSwapOperationId?: string } = {}, ): Promise { const releaseLock = await this.acquireOperationLock(operationId); try { @@ -463,6 +620,7 @@ export class MeltOperationService { if (!operation) { throw new Error(`Operation ${operationId} not found`); } + assertChildOperationAccess(operation, options.parentSwapOperationId); if (operation.state === 'finalized') { this.logger?.debug('Operation already finalized', { operationId }); const finalizedOp = operation as FinalizedMeltOperation; @@ -530,7 +688,7 @@ export class MeltOperationService { async rollback( operationId: string, reason = 'Rolled back', - options: { canonicalQuote?: MeltQuote } = {}, + options: { canonicalQuote?: MeltQuote; parentSwapOperationId?: string } = {}, ): Promise { const releaseLock = await this.acquireOperationLock(operationId); try { @@ -538,6 +696,7 @@ export class MeltOperationService { if (!operation) { throw new Error(`Operation ${operationId} not found`); } + assertChildOperationAccess(operation, options.parentSwapOperationId); if ( operation.state === 'finalized' || @@ -564,7 +723,10 @@ export class MeltOperationService { if (operation.state === 'pending') { const pendingOp = operation as PendingMeltOperation; // Re-read the quote while holding the operation lock; a pre-lock snapshot may be stale. - const canonicalQuote = await this.resolvePendingSettlementQuote(pendingOp); + const canonicalQuote = await this.resolvePendingSettlementQuote( + pendingOp, + options.canonicalQuote, + ); const decision = await handler.checkPending?.({ ...this.buildDeps(), operation: pendingOp, @@ -624,6 +786,7 @@ export class MeltOperationService { // 1. Clean up failed init operations const initOps = await this.meltOperationRepository.getByState('init'); for (const op of initOps) { + if (op.parentSwapOperationId) continue; await this.recoverInitOperation(op as InitMeltOperation); initCount++; } @@ -631,6 +794,7 @@ export class MeltOperationService { // 2. Log warnings for prepared operations (leave for user to decide) const preparedOps = await this.meltOperationRepository.getByState('prepared'); for (const op of preparedOps) { + if (op.parentSwapOperationId) continue; this.logger?.warn('Found stale prepared operation, user can rollback manually', { operationId: op.id, }); @@ -639,6 +803,7 @@ export class MeltOperationService { // 3. Recover executing operations const executingOps = await this.meltOperationRepository.getByState('executing'); for (const op of executingOps) { + if (op.parentSwapOperationId) continue; try { await this.recoverExecutingOperation(op as ExecutingMeltOperation); executingCount++; @@ -653,6 +818,7 @@ export class MeltOperationService { // 4. Check pending operations const pendingOps = await this.meltOperationRepository.getByState('pending'); for (const op of pendingOps) { + if (op.parentSwapOperationId) continue; try { await this.checkPendingOperation(op.id); pendingCount++; @@ -667,6 +833,7 @@ export class MeltOperationService { // 5. Warn about rolling_back operations (need manual intervention) const rollingBackOps = await this.meltOperationRepository.getByState('rolling_back'); for (const op of rollingBackOps) { + if (op.parentSwapOperationId) continue; this.logger?.warn( 'Found operation stuck in rolling_back state. ' + 'This indicates a crash during rollback. Manual recovery may be needed.', @@ -701,6 +868,7 @@ export class MeltOperationService { }'`, ); } + assertChildOperationAccess(op); const persistedQuote = await this.quoteLifecycle.getMeltQuote( op.mintUrl, op.method, @@ -806,8 +974,9 @@ export class MeltOperationService { */ async recoverExecutingOperation( op: ExecutingMeltOperation, - options?: { skipLock?: boolean }, + options?: { skipLock?: boolean; parentSwapOperationId?: string }, ): Promise { + assertChildOperationAccess(op, options?.parentSwapOperationId); const releaseLock = options?.skipLock ? undefined : await this.acquireOperationLock(op.id); try { const current = await this.meltOperationRepository.getById(op.id); diff --git a/packages/core/operations/mint/MintMethodHandler.ts b/packages/core/operations/mint/MintMethodHandler.ts index b26c507b2..11b051fdc 100644 --- a/packages/core/operations/mint/MintMethodHandler.ts +++ b/packages/core/operations/mint/MintMethodHandler.ts @@ -60,10 +60,11 @@ export type CompatibleMintQuoteBolt12Response = Omit< export interface MintMethodDefinitions { bolt11: { methodData: Record; - createQuoteData: { amount: UnitAmount }; + createQuoteData: { amount: UnitAmount; pubkey?: string }; quoteData: { amount: Amount; }; + /** @deprecated Compatibility projection of canonical Mint Quote Accounting. */ remoteState: 'UNPAID' | 'PAID' | 'ISSUED'; quote: MintQuoteBolt11Response; }; @@ -156,6 +157,8 @@ export interface RecoverExecutingContext< > extends BaseHandlerDeps { operation: ExecutingMintOperation; wallet: Wallet; + /** Canonical quote persisted before operation recovery is allowed to advance. */ + canonicalQuote?: MintQuote; } export interface PendingContext extends BaseHandlerDeps { @@ -184,6 +187,7 @@ export type RecoverExecutingResult = export type PendingMintCheckCategory = 'waiting' | 'ready' | 'completed' | 'terminal'; export interface PendingMintCheckResult { + /** @deprecated Return `quoteSnapshot` with canonical accounting whenever available. */ observedRemoteState?: MintMethodRemoteState; observedRemoteStateAt: number; quoteSnapshot?: MintMethodQuoteSnapshot; diff --git a/packages/core/operations/mint/MintOperation.ts b/packages/core/operations/mint/MintOperation.ts index 262e5a487..3dceea2b2 100644 --- a/packages/core/operations/mint/MintOperation.ts +++ b/packages/core/operations/mint/MintOperation.ts @@ -26,6 +26,8 @@ interface MintOperationBase extends MintMetho updatedAt: number; error?: string; terminalFailure?: MintOperationFailure; + /** Owning parent swap. Parent-owned children may only be advanced by that parent. */ + parentSwapOperationId?: string; } export interface MintOperationFailure { @@ -118,7 +120,7 @@ export function createMintOperation( mintUrl: string, meta: MintMethodMeta, intent: UnitAmount, - options: { quoteId: string }, + options: { quoteId: string; parentSwapOperationId?: string }, ): InitMintOperation { const now = Date.now(); return { @@ -127,6 +129,7 @@ export function createMintOperation( amount: intent.amount, unit: normalizeUnit(intent.unit), quoteId: options.quoteId, + parentSwapOperationId: options.parentSwapOperationId, id, state: 'init', mintUrl, diff --git a/packages/core/operations/mint/MintOperationService.ts b/packages/core/operations/mint/MintOperationService.ts index be84a33c7..27f5863df 100644 --- a/packages/core/operations/mint/MintOperationService.ts +++ b/packages/core/operations/mint/MintOperationService.ts @@ -1,5 +1,9 @@ -import { Amount, type Proof } from '@cashu/cashu-ts'; -import type { MintOperationRepository, ProofRepository } from '../../repositories'; +import { Amount, type Proof, type Wallet } from '@cashu/cashu-ts'; +import type { + MintOperationRepository, + ProofRepository, + RepositoryTransactionScope, +} from '../../repositories'; import type { ExecutingMintOperation, FailedMintOperation, @@ -22,6 +26,7 @@ import type { MintMethodMeta, PendingMintCheckResult, MintMethodQuoteSnapshot, + MintExecutionResult, } from './MintMethodHandler'; import type { MintService } from '../../services/MintService'; import type { WalletService } from '../../services/WalletService'; @@ -43,11 +48,22 @@ import { OperationIdLock } from '../OperationIdLock'; import { getMintQuoteAvailableAmount, getMintQuoteAmount, + mintQuoteToMethodSnapshot, type MintQuote, } from '../../models/MintQuote'; import { isMintQuoteExpired } from '../../models/MintQuoteExpiry'; import type { MintQuoteRef } from '../../models/QuoteIdentity'; import type { QuoteLifecycle } from '../../quotes/QuoteLifecycle'; +import { assertChildOperationAccess } from '../mintSwap/ChildOperationOwnership.ts'; + +export interface PrepareOwnedMintOperationCommand { + operationId: string; + parentSwapOperationId: string; + quote: MintQuote; + amount: Amount; + wallet: Wallet; + repositories: RepositoryTransactionScope; +} export interface ClaimMintQuoteOptions { autoClaimRemaining?: boolean; @@ -98,10 +114,13 @@ export class MintOperationService { this.mintScopedLock = mintScopedLock ?? new MintScopedLock(); } - private buildDeps() { + private buildDeps(repositories?: RepositoryTransactionScope) { + const proofService = repositories + ? this.proofService.forTransaction(repositories) + : this.proofService; return { - proofRepository: this.proofRepository, - proofService: this.proofService, + proofRepository: repositories?.proofRepository ?? this.proofRepository, + proofService, walletService: this.walletService, mintService: this.mintService, mintAdapter: this.mintAdapter, @@ -254,6 +273,137 @@ export class MintOperationService { return this.prepareInitOperation(initOperation.id); } + /** Prepare and persist a parent-owned destination child using only transaction-scoped writes. */ + async prepareOwnedInTransaction( + command: PrepareOwnedMintOperationCommand, + ): Promise { + const { quote, repositories, parentSwapOperationId, operationId, wallet } = command; + if (quote.method !== 'bolt11') { + throw new Error('Mint swaps require a BOLT11 destination quote'); + } + const amount = Amount.from(command.amount); + const fixedAmount = getMintQuoteAmount(quote); + if (!fixedAmount?.equals(amount) || quote.unit !== 'sat') { + throw new Error(`Destination quote ${quote.quoteId} does not match the swap intent`); + } + + const initOperation = createMintOperation( + operationId, + quote.mintUrl, + { method: 'bolt11', methodData: {} }, + { amount, unit: quote.unit }, + { quoteId: quote.quoteId, parentSwapOperationId }, + ); + const handler = this.handlerProvider.get('bolt11'); + const pending = await handler.prepare({ + ...this.buildDeps(repositories), + operation: initOperation, + wallet, + importedQuote: mintQuoteToMethodSnapshot<'bolt11'>(quote as MintQuote<'bolt11'>), + }); + const pendingOperation: PendingMintOperation = { + ...pending, + id: operationId, + parentSwapOperationId, + state: 'pending', + updatedAt: Date.now(), + }; + await repositories.mintOperationRepository.create(pendingOperation); + return pendingOperation; + } + + /** Persist remote-effect authorization before the coordinator performs issuance. */ + async authorizeOwnedExecutionInTransaction( + operationId: string, + parentSwapOperationId: string, + repositories: RepositoryTransactionScope, + ): Promise { + const operation = await repositories.mintOperationRepository.getById(operationId); + if (!operation || operation.state !== 'pending') { + throw new Error( + `Cannot authorize mint child ${operationId} from ${operation?.state ?? 'missing'}`, + ); + } + assertChildOperationAccess(operation, parentSwapOperationId); + const executing: ExecutingMintOperation = { + ...operation, + state: 'executing', + updatedAt: Date.now(), + error: undefined, + }; + await repositories.mintOperationRepository.update(executing); + return executing; + } + + /** Perform the remote issuance call after its authorization transaction has committed. */ + async executeOwnedRemote( + operation: ExecutingMintOperation, + parentSwapOperationId: string, + ): Promise { + assertChildOperationAccess(operation, parentSwapOperationId); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId( + operation.mintUrl, + operation.unit, + ); + return this.handlerProvider.get(operation.method).execute({ + ...this.buildDeps(), + operation: operation as any, + wallet, + }); + } + + /** Apply an issuance result atomically with the parent's next local transition. */ + async applyOwnedExecutionInTransaction( + operation: ExecutingMintOperation, + parentSwapOperationId: string, + result: MintExecutionResult, + repositories: RepositoryTransactionScope, + ): Promise { + assertChildOperationAccess(operation, parentSwapOperationId); + if (result.status === 'FAILED') { + throw new Error(result.error ?? 'Mint execution failed'); + } + if (result.status === 'ALREADY_ISSUED') { + return operation; + } + + const expectedSecrets = getOutputProofSecrets(operation).sort(); + const receivedSecrets = result.proofs.map((proof) => proof.secret).sort(); + if ( + expectedSecrets.length !== receivedSecrets.length || + expectedSecrets.some((secret, index) => secret !== receivedSecrets[index]) + ) { + throw new Error(`Mint result does not match deterministic outputs for ${operation.id}`); + } + + const scopedProofService = this.proofService.forTransaction(repositories); + await scopedProofService.saveProofs( + operation.mintUrl, + mapProofToCoreProof(operation.mintUrl, 'ready', result.proofs, { + unit: operation.unit, + createdByOperationId: operation.id, + }), + ); + if (operation.method === 'bolt11') { + await this.quoteLifecycle.recordMintQuoteIssuanceInTransaction(operation, repositories); + } + const finalized: FinalizedMintOperation = { + ...operation, + state: 'finalized', + updatedAt: Date.now(), + error: undefined, + }; + await repositories.mintOperationRepository.update(finalized); + return finalized; + } + + async recoverOwnedExecuting( + operation: ExecutingMintOperation, + parentSwapOperationId: string, + ): Promise { + return this.recoverExecutingOperation(operation, { parentSwapOperationId }); + } + private async prepareInitOperation( operationId: string, options?: { @@ -343,6 +493,7 @@ export class MintOperationService { async execute(operationId: string): Promise { const operation = await this.mintOperationRepository.getById(operationId); + if (operation) assertChildOperationAccess(operation); if (operation?.state === 'pending') { const quote = await this.quoteLifecycle.getMintQuote( operation.mintUrl, @@ -368,6 +519,7 @@ export class MintOperationService { }'`, ); } + assertChildOperationAccess(operation); const pendingOp = operation as PendingMintOperation; const executing: ExecutingMintOperation = { @@ -442,6 +594,7 @@ export class MintOperationService { if (!operation) { throw new Error(`Operation ${operationId} not found`); } + assertChildOperationAccess(operation); if (isTerminalOperation(operation)) { this.logger?.debug('Operation already finalized', { operationId }); @@ -488,6 +641,7 @@ export class MintOperationService { const initOps = await this.mintOperationRepository.getByState('init'); for (const op of initOps) { + if (op.parentSwapOperationId) continue; try { await this.recoverInitOperation(op as InitMintOperation); initCount++; @@ -507,6 +661,7 @@ export class MintOperationService { const pendingOps = await this.mintOperationRepository.getByState('pending'); for (const op of pendingOps) { + if (op.parentSwapOperationId) continue; try { if (await this.mintService.isTrustedMint(op.mintUrl)) { await this.checkPendingOperation(op.id); @@ -527,6 +682,7 @@ export class MintOperationService { const executingOps = await this.mintOperationRepository.getByState('executing'); for (const op of executingOps) { + if (op.parentSwapOperationId) continue; try { await this.recoverExecutingOperation(op as ExecutingMintOperation); executingCount++; @@ -558,8 +714,9 @@ export class MintOperationService { async recoverExecutingOperation( op: ExecutingMintOperation, - options?: { skipLock?: boolean }, + options?: { skipLock?: boolean; parentSwapOperationId?: string }, ): Promise { + assertChildOperationAccess(op, options?.parentSwapOperationId); const releaseLock = options?.skipLock ? undefined : await this.acquireOperationLock(op.id); try { const current = await this.mintOperationRepository.getById(op.id); @@ -601,10 +758,24 @@ export class MintOperationService { executing.mintUrl, executing.unit, ); + const storedQuote = await this.quoteLifecycle.getMintQuote( + executing.mintUrl, + executing.method, + executing.quoteId, + ); + const canonicalQuote = + executing.method === 'bolt11' && storedQuote + ? await this.quoteLifecycle.refreshMintQuote( + executing.mintUrl, + executing.method, + executing.quoteId, + ) + : undefined; const result = await handler.recoverExecuting({ ...this.buildDeps(), operation: executing as any, wallet, + canonicalQuote: canonicalQuote as any, }); switch (result.status) { @@ -726,7 +897,7 @@ export class MintOperationService { const autoClaimRemaining = options.autoClaimRemaining ?? true; for (const operation of siblings) { - if (operation.state !== 'pending') { + if (operation.state !== 'pending' || operation.parentSwapOperationId) { continue; } @@ -1007,11 +1178,7 @@ export class MintOperationService { } if (current.method === 'bolt11') { - await this.quoteLifecycle.recordMintQuoteObservation( - current as PendingOrLaterOperation, - 'ISSUED', - Date.now(), - ); + await this.quoteLifecycle.recordMintQuoteIssuance(current as PendingOrLaterOperation); } const finalized: FinalizedMintOperation = { @@ -1125,6 +1292,7 @@ export class MintOperationService { }'`, ); } + assertChildOperationAccess(op); const handler = this.handlerProvider.get(op.method); const { wallet } = await this.walletService.getWalletWithActiveKeysetId(op.mintUrl, op.unit); @@ -1140,9 +1308,7 @@ export class MintOperationService { op.method, result.quoteSnapshot as MintMethodQuoteSnapshot, ); - } - - if (result.observedRemoteState !== undefined) { + } else if (result.observedRemoteState !== undefined) { await this.quoteLifecycle.recordMintQuoteObservation( op, result.observedRemoteState, @@ -1158,6 +1324,8 @@ export class MintOperationService { } async checkPendingOperation(operationId: string): Promise { + const operation = await this.mintOperationRepository.getById(operationId); + if (operation) assertChildOperationAccess(operation); const result = await this.observePendingOperation(operationId); if (result.category === 'ready' || result.category === 'completed') { diff --git a/packages/core/operations/mintSwap/ChildOperationOwnership.ts b/packages/core/operations/mintSwap/ChildOperationOwnership.ts new file mode 100644 index 000000000..6ffb1d38a --- /dev/null +++ b/packages/core/operations/mintSwap/ChildOperationOwnership.ts @@ -0,0 +1,26 @@ +import { ParentOwnedOperationError } from '../../models/Error.ts'; + +export interface ParentOwnedChildOperation { + id: string; + parentSwapOperationId?: string; +} + +/** Verify that a child is standalone or is being advanced by its recorded parent. */ +export function assertChildOperationAccess( + operation: ParentOwnedChildOperation, + expectedParentSwapOperationId?: string, +): void { + const owner = operation.parentSwapOperationId; + if (!owner) { + if (expectedParentSwapOperationId) { + throw new Error( + `Operation ${operation.id} is not owned by mint swap ${expectedParentSwapOperationId}`, + ); + } + return; + } + + if (owner !== expectedParentSwapOperationId) { + throw new ParentOwnedOperationError(operation.id, owner); + } +} diff --git a/packages/core/operations/mintSwap/MintSwapOperation.ts b/packages/core/operations/mintSwap/MintSwapOperation.ts new file mode 100644 index 000000000..62c8c02ef --- /dev/null +++ b/packages/core/operations/mintSwap/MintSwapOperation.ts @@ -0,0 +1,530 @@ +import { Amount } from '@cashu/cashu-ts'; +import { bytesToHex } from '@noble/curves/utils.js'; +import { sha256 } from '@noble/hashes/sha2.js'; + +import { normalizeMintUrl } from '../../utils'; + +export type MintSwapOperationState = + | 'preparing' + | 'prepared' + | 'source_inflight' + | 'destination_funded' + | 'issuing' + | 'completed' + | 'cancelled' + | 'failed' + | 'needs_attention'; + +export type MintSwapAttentionReason = + | 'ownership_conflict' + | 'prepared_plan_mismatch' + | 'source_paid_destination_terminal' + | 'destination_issued_source_not_paid' + | 'destination_proofs_unrecoverable' + | 'source_reclamation_unproven' + | 'accounting_mismatch' + | 'canonical_observation_conflict' + | 'required_recovery_capability_missing' + | 'missing_post_effect_recovery_material'; + +export type MintSwapEventType = + | 'mint-swap-op:prepared' + | 'mint-swap-op:source-inflight' + | 'mint-swap-op:destination-funded' + | 'mint-swap-op:issuing' + | 'mint-swap-op:completed' + | 'mint-swap-op:cancelled' + | 'mint-swap-op:failed' + | 'mint-swap-op:needs-attention' + | 'mint-swap-op:delayed'; + +export interface MintSwapQuoteRef { + mintUrl: string; + method: 'bolt11'; + quoteId: string; +} + +export interface MintSwapNut20KeyRef { + publicKey: string; + derivationIndex: number; +} + +export interface MintSwapPreparedPlan { + fingerprint: string; + dispatchDeadline: number; + requiredDispatchWindowSeconds: number; + sourceMeltAmount: Amount; + sourceFeeReserve: Amount; + sourcePreparationFee: Amount; + sourceMeltInputFee: Amount; + minimumSourceDebit: Amount; + maximumSourceDebit: Amount; + reservedSourceAmount: Amount; +} + +export interface MintSwapSettlement { + sourcePaymentFee: Amount; + totalSourceFee: Amount; + sourceMeltChangeAmount: Amount; + sourceKeepAmount: Amount; + sourceReturnedAmount: Amount; + finalSourceDebit: Amount; + destinationAmountIssued?: Amount; +} + +export interface MintSwapRetry { + attemptCount: number; + nextAttemptAt?: number; + lastAttemptAt?: number; + lastSuccessfulObservationAt?: number; + lastError?: string; +} + +export interface MintSwapAttentionRecord { + reason: MintSwapAttentionReason; + message: string; + lastSafeState: MintSwapOperationState; + violatedInvariant: string; + evidence: Record; + at: number; +} + +export interface MintSwapTerminalFailure { + code: string; + reason: string; + at: number; +} + +export interface MintSwapOperation { + id: string; + state: MintSwapOperationState; + revision: number; + sourceMintUrl: string; + destinationMintUrl: string; + unit: 'sat'; + destinationAmount: Amount; + destinationQuoteRef?: MintSwapQuoteRef; + destinationMintOperationId?: string; + sourceQuoteRef?: MintSwapQuoteRef; + sourceMeltOperationId?: string; + destinationNut20Key?: MintSwapNut20KeyRef; + preparedPlan?: MintSwapPreparedPlan; + settlement?: MintSwapSettlement; + sourceDispatchAuthorizedAt?: number; + destinationIssueAuthorizedAt?: number; + cancellationRequestedAt?: number; + cancelledAt?: number; + retry: MintSwapRetry; + attention?: MintSwapAttentionRecord; + terminalFailure?: MintSwapTerminalFailure; + createdAt: number; + updatedAt: number; + completedAt?: number; +} + +export interface MintSwapPreparedPlanFingerprintInput { + destinationMintOperationId: string; + sourceMeltOperationId: string; + destinationQuoteRef: MintSwapQuoteRef; + sourceQuoteRef: MintSwapQuoteRef; + destinationAmount: Amount; + unit: 'sat'; + sourceInputProofSecrets: readonly string[]; + destinationOutputData: unknown; + sourceOutputData: unknown; + maximumSourceDebit: Amount; +} + +const TERMINAL_STATES = new Set(['completed', 'cancelled', 'failed']); +const AUTOMATIC_STATES = new Set([ + 'preparing', + 'source_inflight', + 'destination_funded', + 'issuing', +]); + +const PREPARED_PLAN_STATES = new Set([ + 'prepared', + 'source_inflight', + 'destination_funded', + 'issuing', + 'completed', + 'needs_attention', +]); + +const TRANSITIONS: Record> = { + preparing: new Set(['prepared', 'cancelled', 'failed', 'needs_attention']), + prepared: new Set(['source_inflight', 'cancelled', 'failed', 'needs_attention']), + source_inflight: new Set(['destination_funded', 'cancelled', 'failed', 'needs_attention']), + destination_funded: new Set(['issuing', 'completed', 'needs_attention']), + issuing: new Set(['issuing', 'completed', 'needs_attention']), + completed: new Set(), + cancelled: new Set(), + failed: new Set(), + needs_attention: new Set(['destination_funded', 'issuing', 'completed', 'cancelled', 'failed']), +}; + +export function isTerminalMintSwapState(state: MintSwapOperationState): boolean { + return TERMINAL_STATES.has(state); +} + +export function isAutomaticMintSwapState(state: MintSwapOperationState): boolean { + return AUTOMATIC_STATES.has(state); +} + +export function canTransitionMintSwap( + from: MintSwapOperationState, + to: MintSwapOperationState, +): boolean { + return from === to || TRANSITIONS[from].has(to); +} + +export function assertMintSwapTransition( + from: MintSwapOperationState, + to: MintSwapOperationState, +): void { + if (!canTransitionMintSwap(from, to)) { + throw new Error(`Illegal mint swap transition: ${from} -> ${to}`); + } +} + +export function createMintSwapPreparedPlanFingerprint( + input: MintSwapPreparedPlanFingerprintInput, +): string { + const canonical = canonicalizeForFingerprint({ + ...input, + destinationQuoteRef: normalizeQuoteRef(input.destinationQuoteRef), + sourceQuoteRef: normalizeQuoteRef(input.sourceQuoteRef), + }); + return bytesToHex(sha256(new TextEncoder().encode(canonical))); +} + +export function validateMintSwapAccounting(operation: MintSwapOperation): void { + const plan = operation.preparedPlan; + const settlement = operation.settlement; + if (!plan || !settlement) { + throw new Error('Mint swap settlement requires a prepared plan'); + } + + const minimum = operation.destinationAmount + .add(plan.sourcePreparationFee) + .add(plan.sourceMeltInputFee); + assertAmountEquals(plan.minimumSourceDebit, minimum, 'minimum source debit'); + + const totalFee = plan.sourcePreparationFee + .add(plan.sourceMeltInputFee) + .add(settlement.sourcePaymentFee); + assertAmountEquals(settlement.totalSourceFee, totalFee, 'total source fee'); + + const debitFromFees = operation.destinationAmount.add(settlement.totalSourceFee); + assertAmountEquals(settlement.finalSourceDebit, debitFromFees, 'final source debit from fees'); + + const returned = settlement.sourceKeepAmount.add(settlement.sourceMeltChangeAmount); + assertAmountEquals(settlement.sourceReturnedAmount, returned, 'source returned amount'); + + if (settlement.sourceReturnedAmount.greaterThan(plan.reservedSourceAmount)) { + throw new Error('Mint swap source returned amount exceeds reserved source amount'); + } + const debitFromReturns = plan.reservedSourceAmount.subtract(settlement.sourceReturnedAmount); + assertAmountEquals( + settlement.finalSourceDebit, + debitFromReturns, + 'final source debit from returned value', + ); + + if (settlement.finalSourceDebit.greaterThan(plan.maximumSourceDebit)) { + throw new Error('Mint swap final source debit exceeds accepted maximum'); + } + + if (operation.state === 'completed') { + if (!settlement.destinationAmountIssued) { + throw new Error('Completed mint swap requires destination issued amount'); + } + assertAmountEquals( + settlement.destinationAmountIssued, + operation.destinationAmount, + 'destination issued amount', + ); + } +} + +export function validateMintSwapOperation(operation: MintSwapOperation): MintSwapOperation { + assertNonEmpty(operation.id, 'Mint swap id'); + assertTimestamp(operation.createdAt, 'Mint swap createdAt'); + assertTimestamp(operation.updatedAt, 'Mint swap updatedAt'); + if (operation.updatedAt < operation.createdAt) { + throw new Error('Mint swap updatedAt cannot precede createdAt'); + } + if (!Number.isSafeInteger(operation.revision) || operation.revision < 0) { + throw new Error('Mint swap revision must be a non-negative safe integer'); + } + + const sourceMintUrl = normalizeMintUrl(operation.sourceMintUrl); + const destinationMintUrl = normalizeMintUrl(operation.destinationMintUrl); + if (sourceMintUrl === destinationMintUrl) { + throw new Error('Mint swap source and destination mints must be distinct'); + } + if ( + operation.sourceMintUrl !== sourceMintUrl || + operation.destinationMintUrl !== destinationMintUrl + ) { + throw new Error('Mint swap mint URLs must be normalized'); + } + if (operation.unit !== 'sat') { + throw new Error('Mint swap unit must be sat'); + } + if (operation.destinationAmount.isZero()) { + throw new Error('Mint swap destination amount must be positive'); + } + + validateRetry(operation.retry); + validateQuoteRef(operation.destinationQuoteRef, destinationMintUrl, 'destination'); + validateQuoteRef(operation.sourceQuoteRef, sourceMintUrl, 'source'); + + if (operation.destinationNut20Key) { + assertNonEmpty(operation.destinationNut20Key.publicKey, 'Mint swap NUT-20 public key'); + if ( + !Number.isSafeInteger(operation.destinationNut20Key.derivationIndex) || + operation.destinationNut20Key.derivationIndex < 0 + ) { + throw new Error('Mint swap NUT-20 derivation index must be a non-negative safe integer'); + } + } + + if (PREPARED_PLAN_STATES.has(operation.state) || operation.preparedPlan) { + requirePreparedFields(operation); + } + if (operation.state === 'source_inflight') { + assertTimestamp( + operation.sourceDispatchAuthorizedAt, + 'Mint swap source dispatch authorization', + ); + } + if ( + operation.state === 'destination_funded' || + operation.state === 'issuing' || + operation.state === 'completed' + ) { + assertTimestamp( + operation.sourceDispatchAuthorizedAt, + 'Mint swap source dispatch authorization', + ); + validateMintSwapAccounting(operation); + } + if (operation.state === 'issuing' || operation.state === 'completed') { + assertTimestamp( + operation.destinationIssueAuthorizedAt, + 'Mint swap destination issue authorization', + ); + } + if (operation.state === 'completed') { + assertTimestamp(operation.completedAt, 'Mint swap completion time'); + } + if (operation.state === 'cancelled') { + assertTimestamp(operation.cancellationRequestedAt, 'Mint swap cancellation request'); + assertTimestamp(operation.cancelledAt, 'Mint swap cancellation completion'); + } + if (operation.state === 'failed' && !operation.terminalFailure) { + throw new Error('Failed mint swap requires terminal failure details'); + } + if (operation.terminalFailure) { + assertNonEmpty(operation.terminalFailure.code, 'Mint swap terminal failure code'); + assertNonEmpty(operation.terminalFailure.reason, 'Mint swap terminal failure reason'); + assertTimestamp(operation.terminalFailure.at, 'Mint swap terminal failure time'); + } + if (operation.state === 'needs_attention' && !operation.attention) { + throw new Error('Mint swap needing attention requires structured evidence'); + } + if (operation.attention) { + assertNonEmpty(operation.attention.message, 'Mint swap attention message'); + assertNonEmpty(operation.attention.violatedInvariant, 'Mint swap violated invariant'); + assertTimestamp(operation.attention.at, 'Mint swap attention time'); + } + + return operation; +} + +export function assertPreparedMintSwapImmutable( + current: MintSwapOperation, + next: MintSwapOperation, +): void { + if (!current.preparedPlan) return; + const fields: Array<[unknown, unknown, string]> = [ + [current.sourceMintUrl, next.sourceMintUrl, 'source mint URL'], + [current.destinationMintUrl, next.destinationMintUrl, 'destination mint URL'], + [current.unit, next.unit, 'unit'], + [current.destinationAmount.toString(), next.destinationAmount.toString(), 'destination amount'], + [current.destinationMintOperationId, next.destinationMintOperationId, 'destination child'], + [current.sourceMeltOperationId, next.sourceMeltOperationId, 'source child'], + [ + quoteRefKey(current.destinationQuoteRef), + quoteRefKey(next.destinationQuoteRef), + 'destination quote', + ], + [quoteRefKey(current.sourceQuoteRef), quoteRefKey(next.sourceQuoteRef), 'source quote'], + [current.preparedPlan.fingerprint, next.preparedPlan?.fingerprint, 'prepared fingerprint'], + [ + current.preparedPlan.dispatchDeadline, + next.preparedPlan?.dispatchDeadline, + 'dispatch deadline', + ], + [ + current.preparedPlan.requiredDispatchWindowSeconds, + next.preparedPlan?.requiredDispatchWindowSeconds, + 'dispatch window', + ], + [ + current.preparedPlan.sourceMeltAmount.toString(), + next.preparedPlan?.sourceMeltAmount.toString(), + 'source melt amount', + ], + [ + current.preparedPlan.sourceFeeReserve.toString(), + next.preparedPlan?.sourceFeeReserve.toString(), + 'source fee reserve', + ], + [ + current.preparedPlan.sourcePreparationFee.toString(), + next.preparedPlan?.sourcePreparationFee.toString(), + 'source preparation fee', + ], + [ + current.preparedPlan.sourceMeltInputFee.toString(), + next.preparedPlan?.sourceMeltInputFee.toString(), + 'source melt input fee', + ], + [ + current.preparedPlan.minimumSourceDebit.toString(), + next.preparedPlan?.minimumSourceDebit.toString(), + 'minimum source debit', + ], + [ + current.preparedPlan.maximumSourceDebit.toString(), + next.preparedPlan?.maximumSourceDebit.toString(), + 'maximum source debit', + ], + [ + current.preparedPlan.reservedSourceAmount.toString(), + next.preparedPlan?.reservedSourceAmount.toString(), + 'reserved source amount', + ], + ]; + const changed = fields.find(([left, right]) => left !== right); + if (changed) { + throw new Error(`Prepared mint swap ${changed[2]} is immutable`); + } +} + +function requirePreparedFields(operation: MintSwapOperation): void { + if ( + !operation.destinationQuoteRef || + !operation.destinationMintOperationId || + !operation.sourceQuoteRef || + !operation.sourceMeltOperationId || + !operation.destinationNut20Key || + !operation.preparedPlan + ) { + throw new Error(`Mint swap state ${operation.state} requires a complete prepared plan`); + } + assertNonEmpty(operation.destinationMintOperationId, 'Mint swap destination child id'); + assertNonEmpty(operation.sourceMeltOperationId, 'Mint swap source child id'); + assertNonEmpty(operation.preparedPlan.fingerprint, 'Mint swap prepared fingerprint'); + const plan = operation.preparedPlan; + assertTimestamp(plan.dispatchDeadline, 'Mint swap dispatch deadline'); + if ( + !Number.isSafeInteger(plan.requiredDispatchWindowSeconds) || + plan.requiredDispatchWindowSeconds < 30 + ) { + throw new Error('Mint swap required dispatch window must be at least 30 seconds'); + } + for (const [name, amount] of Object.entries({ + sourceFeeReserve: plan.sourceFeeReserve, + sourceMeltAmount: plan.sourceMeltAmount, + sourcePreparationFee: plan.sourcePreparationFee, + sourceMeltInputFee: plan.sourceMeltInputFee, + minimumSourceDebit: plan.minimumSourceDebit, + maximumSourceDebit: plan.maximumSourceDebit, + reservedSourceAmount: plan.reservedSourceAmount, + })) { + Amount.from(amount); + if (amount.toString().startsWith('-')) { + throw new Error(`Mint swap ${name} cannot be negative`); + } + } + const minimum = operation.destinationAmount + .add(plan.sourcePreparationFee) + .add(plan.sourceMeltInputFee); + assertAmountEquals(plan.minimumSourceDebit, minimum, 'minimum source debit'); + assertAmountEquals(plan.sourceMeltAmount, operation.destinationAmount, 'source melt amount'); + if (plan.maximumSourceDebit.lessThan(plan.minimumSourceDebit)) { + throw new Error('Mint swap maximum source debit is below minimum source debit'); + } + if (plan.maximumSourceDebit.greaterThan(plan.reservedSourceAmount)) { + throw new Error('Mint swap maximum source debit exceeds reserved source amount'); + } +} + +function validateRetry(retry: MintSwapRetry): void { + if (!retry || !Number.isSafeInteger(retry.attemptCount) || retry.attemptCount < 0) { + throw new Error('Mint swap retry attempt count must be a non-negative safe integer'); + } + for (const [name, value] of Object.entries({ + nextAttemptAt: retry.nextAttemptAt, + lastAttemptAt: retry.lastAttemptAt, + lastSuccessfulObservationAt: retry.lastSuccessfulObservationAt, + })) { + if (value !== undefined) assertTimestamp(value, `Mint swap retry ${name}`); + } +} + +function validateQuoteRef( + ref: MintSwapQuoteRef | undefined, + expectedMintUrl: string, + role: string, +): void { + if (!ref) return; + if (ref.method !== 'bolt11') { + throw new Error(`Mint swap ${role} quote method must be bolt11`); + } + if (normalizeMintUrl(ref.mintUrl) !== expectedMintUrl || ref.mintUrl !== expectedMintUrl) { + throw new Error(`Mint swap ${role} quote mint URL does not match its leg`); + } + assertNonEmpty(ref.quoteId, `Mint swap ${role} quote id`); +} + +function normalizeQuoteRef(ref: MintSwapQuoteRef): MintSwapQuoteRef { + return { ...ref, mintUrl: normalizeMintUrl(ref.mintUrl) }; +} + +function quoteRefKey(ref?: MintSwapQuoteRef): string | undefined { + return ref ? `${ref.mintUrl}\u0000${ref.method}\u0000${ref.quoteId}` : undefined; +} + +function assertAmountEquals(actual: Amount, expected: Amount, name: string): void { + if (!actual.equals(expected)) { + throw new Error(`Mint swap ${name} does not reconcile`); + } +} + +function assertTimestamp(value: number | undefined, name: string): void { + if (value === undefined || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${name} must be a non-negative Unix-millisecond timestamp`); + } +} + +function assertNonEmpty(value: string, name: string): void { + if (!value.trim()) throw new Error(`${name} cannot be empty`); +} + +function canonicalizeForFingerprint(value: unknown): string { + if (value instanceof Amount) return JSON.stringify(value.toString()); + if (typeof value === 'bigint') return JSON.stringify(value.toString()); + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) { + return `[${value.map((item) => canonicalizeForFingerprint(item)).join(',')}]`; + } + const entries = Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${canonicalizeForFingerprint(item)}`); + return `{${entries.join(',')}}`; +} diff --git a/packages/core/operations/mintSwap/MintSwapOperationService.ts b/packages/core/operations/mintSwap/MintSwapOperationService.ts new file mode 100644 index 000000000..261943a19 --- /dev/null +++ b/packages/core/operations/mintSwap/MintSwapOperationService.ts @@ -0,0 +1,1334 @@ +import { + Amount, + OutputData, + sumProofs, + type AmountLike, + type Proof, + type Wallet, +} from '@cashu/cashu-ts'; + +import type { Logger } from '../../logging/Logger.ts'; +import { redactError } from '../../logging/redaction.ts'; +import type { MeltQuote } from '../../models/MeltQuote.ts'; +import { + getMintQuoteAvailableAmount, + isStatefulMintQuote, + type MintQuote, +} from '../../models/MintQuote.ts'; +import type { OperationEventOutboxRecord } from '../../models/OperationEventOutbox.ts'; +import { evaluateMintSwapDispatchWindow } from '../../models/MintSwapPolicy.ts'; +import type { Repositories, RepositoryTransactionScope } from '../../repositories/index.ts'; +import type { KeyRingService } from '../../services/KeyRingService.ts'; +import type { MintService } from '../../services/MintService.ts'; +import type { WalletService } from '../../services/WalletService.ts'; +import { deserializeOutputData, generateSubId, normalizeMintUrl } from '../../utils.ts'; +import { MintScopedLock } from '../MintScopedLock.ts'; +import { OperationIdLock } from '../OperationIdLock.ts'; +import type { MeltOperationService } from '../melt/MeltOperationService.ts'; +import type { + ExecutingMeltOperation, + FinalizedMeltOperation, + PreparedMeltOperation, +} from '../melt/MeltOperation.ts'; +import type { MintOperationService } from '../mint/MintOperationService.ts'; +import type { ExecutingMintOperation, FinalizedMintOperation } from '../mint/MintOperation.ts'; +import type { QuoteLifecycle } from '../../quotes/QuoteLifecycle.ts'; +import { + createMintSwapPreparedPlanFingerprint, + isTerminalMintSwapState, + validateMintSwapAccounting, + type MintSwapAttentionReason, + type MintSwapEventType, + type MintSwapOperation, + type MintSwapOperationState, + type MintSwapPreparedPlan, + type MintSwapSettlement, +} from './MintSwapOperation.ts'; + +export interface PrepareMintSwapInput { + /** Trusted mint whose proofs will fund the Lightning payment. */ + sourceMintUrl: string; + /** Trusted mint that will issue the exact destination amount. */ + destinationMintUrl: string; + /** Exact amount to receive at the destination, not a source-spend budget. */ + amount: AmountLike; + /** Mint swaps are sat-only in the current protocol contract. */ + unit?: 'sat'; + /** Minimum time that must remain before the earliest quote expiry at dispatch. */ + requiredDispatchWindowSeconds?: number; +} + +export interface ListMintSwapInput { + /** Return only operations currently in this parent state. */ + state?: MintSwapOperationState; + /** Return operations where the normalized URL is either the source or destination mint. */ + mintUrl?: string; +} + +/** + * Preparation failed after a durable parent id had been allocated. + * + * The original cause is deliberately not exposed because protocol errors can contain invoices, + * quote ids, proofs, or other sensitive material. Use `operationId` to inspect the sanitized, + * durable failure record. + */ +export class MintSwapPreparationError extends Error { + readonly operationId: string; + + constructor(operationId: string, cause: unknown) { + super(`Mint swap ${operationId} could not be prepared`, { + cause: new Error('Mint swap preparation failed; inspect durable operation state'), + }); + this.name = 'MintSwapPreparationError'; + this.operationId = operationId; + } +} + +/** Signals canonical values that cannot satisfy the persisted mint-swap accounting contract. */ +class MintSwapAccountingContradictionError extends Error {} + +/** + * Coordinates an exact-receive cross-mint swap as one durable parent operation. + * + * The service owns parent state, accounting, child ownership, and authorization ordering. The + * existing mint and melt operation services continue to own their protocol-specific behavior. + * Remote calls are intentionally made only after an authorization transaction commits, and their + * results are applied in a later transaction; no repository transaction spans network I/O. + */ +export class MintSwapOperationService { + private readonly operationLock = new OperationIdLock(); + + constructor( + private readonly repositories: Repositories, + private readonly quoteLifecycle: QuoteLifecycle, + private readonly mintOperationService: MintOperationService, + private readonly meltOperationService: MeltOperationService, + private readonly mintService: MintService, + private readonly walletService: WalletService, + private readonly keyRingService: KeyRingService, + private readonly mintScopedLock: MintScopedLock, + private readonly logger?: Logger, + ) {} + + isOperationLocked(operationId: string): boolean { + return this.operationLock.isLocked(operationId); + } + + /** + * Builds and reserves an immutable exact-receive plan without sending the source payment. + * + * Preparation persists the NUT-20 destination key before quote creation, prepares deterministic + * destination outputs, creates the source melt quote from that locked invoice, reserves the + * source proof plan, and finally publishes the caller-reviewable debit bounds. + * + * @throws {MintSwapPreparationError} after a durable parent has been created but preparation + * cannot reach `prepared`. The error exposes the parent id for safe inspection. + */ + async prepare(input: PrepareMintSwapInput): Promise { + const sourceMintUrl = normalizeMintUrl(input.sourceMintUrl); + const destinationMintUrl = normalizeMintUrl(input.destinationMintUrl); + const amount = Amount.from(input.amount); + if ((input.unit ?? 'sat') !== 'sat') throw new Error('Mint swaps support only sat'); + if (amount.isZero()) throw new Error('Mint swap destination amount must be positive'); + if (sourceMintUrl === destinationMintUrl) { + throw new Error('Mint swap source and destination mints must be distinct'); + } + + await this.assertPreflight(sourceMintUrl, destinationMintUrl, amount); + const sourceSupportsNut08 = await this.mintService.supportsNut(sourceMintUrl, 8); + const operationId = generateSubId(); + const now = Date.now(); + const initial: MintSwapOperation = { + id: operationId, + state: 'preparing', + revision: 0, + sourceMintUrl, + destinationMintUrl, + unit: 'sat', + destinationAmount: amount, + retry: { attemptCount: 0 }, + createdAt: now, + updatedAt: now, + }; + + // The parent becomes visible to recovery as soon as it is created. Hold the same operation + // lock used by refresh/retry before persistence so a periodic sweep cannot mistake a live, + // long-running preparation for an interrupted one. + const releaseOperationLock = await this.operationLock.acquire(operationId); + try { + await this.repositories.mintSwapOperationRepository.create(initial); + + // Persist recovery-critical NUT-20 key material before creating the remote locked quote. + const keyPair = await this.keyRingService.generateMintQuoteKeyPair(); + if (keyPair.derivationIndex === undefined) { + throw new Error('Mint swap NUT-20 key is missing its derivation index'); + } + await this.mutate(operationId, (current) => ({ + ...current, + destinationNut20Key: { + publicKey: keyPair.publicKeyHex, + derivationIndex: keyPair.derivationIndex!, + }, + })); + + const destinationQuote = await this.quoteLifecycle.createMintQuote( + destinationMintUrl, + 'bolt11', + { amount: { amount, unit: 'sat' }, pubkey: keyPair.publicKeyHex }, + ); + this.assertDestinationQuote(destinationQuote, amount, keyPair.publicKeyHex); + await this.mutate(operationId, (current) => ({ + ...current, + destinationQuoteRef: this.quoteRef(destinationQuote), + })); + + // Destination outputs are prepared before the invoice is paid so retries reuse one plan. + const destinationWallet = await this.getWallet(destinationMintUrl); + const destinationChildId = generateSubId(); + await this.withMintLock(destinationMintUrl, async () => { + await this.repositories.withTransaction(async (scope) => { + const child = await this.mintOperationService.prepareOwnedInTransaction({ + operationId: destinationChildId, + parentSwapOperationId: operationId, + quote: destinationQuote, + amount, + wallet: destinationWallet, + repositories: scope, + }); + await this.casInScope(scope, operationId, (current) => ({ + ...current, + destinationMintOperationId: child.id, + })); + }); + }); + + // The source pays the exact invoice created by the destination; it never invents an amount. + const sourceQuote = await this.quoteLifecycle.createMeltQuote( + sourceMintUrl, + 'bolt11', + { invoice: destinationQuote.request }, + 'sat', + ); + this.assertSourceQuote(sourceQuote, amount); + await this.mutate(operationId, (current) => ({ + ...current, + sourceQuoteRef: this.quoteRef(sourceQuote), + })); + + const sourceWallet = await this.getWallet(sourceMintUrl); + const sourceChildId = generateSubId(); + await this.withMintLock(sourceMintUrl, async () => { + await this.repositories.withTransaction(async (scope) => { + const sourceChild = await this.meltOperationService.prepareOwnedInTransaction({ + operationId: sourceChildId, + parentSwapOperationId: operationId, + quote: sourceQuote, + wallet: sourceWallet, + repositories: scope, + }); + const destinationChild = await scope.mintOperationRepository.getById(destinationChildId); + if (!destinationChild || destinationChild.state !== 'pending') { + throw new Error('Prepared destination child is missing'); + } + // Linking both children and the immutable preview is one local transaction boundary. + const plan = await this.buildPreparedPlan( + sourceChild, + destinationChild.outputData, + destinationQuote, + sourceQuote, + sourceWallet, + sourceSupportsNut08, + input.requiredDispatchWindowSeconds, + scope, + ); + await this.casInScope( + scope, + operationId, + (current) => ({ + ...current, + state: 'prepared', + sourceMeltOperationId: sourceChild.id, + preparedPlan: plan, + }), + 'mint-swap-op:prepared', + ); + }); + }); + + return this.requireOperation(operationId); + } catch (cause) { + await this.failPreparation(operationId); + throw new MintSwapPreparationError(operationId, cause); + } finally { + releaseOperationLock(); + } + } + + /** + * Authorizes and dispatches the source payment for a prepared operation. + * + * Trust, capabilities, and the dispatch window are rechecked immediately before authorization. + * Calling this method for a state other than `prepared` is idempotent and returns the current + * durable operation. + */ + async execute(operationId: string): Promise { + return this.withOperationLock(operationId, async () => { + const operation = await this.requireOperation(operationId); + if (operation.state !== 'prepared') return operation; + const violation = await this.getPreparedPlanViolation(operation); + if (violation) { + return this.moveToAttention(operation, violation.reason, violation.message); + } + try { + await this.assertPreflight( + operation.sourceMintUrl, + operation.destinationMintUrl, + operation.destinationAmount, + ); + await this.requireDestinationRecoveryKey(operation); + this.assertDispatchWindow(operation); + } catch (error) { + await this.failPreparedBeforeDispatch( + operation, + 'dispatch_preflight_failed', + 'Mint swap dispatch requirements were no longer satisfied', + ); + throw error; + } + + // Commit both child and parent authorization before the irreversible remote melt call. + let executingChild!: ExecutingMeltOperation; + await this.repositories.withTransaction(async (scope) => { + executingChild = await this.meltOperationService.authorizeOwnedExecutionInTransaction( + operation.sourceMeltOperationId!, + operation.id, + scope, + ); + await this.casInScope( + scope, + operation.id, + (current) => ({ + ...current, + state: 'source_inflight', + sourceDispatchAuthorizedAt: Date.now(), + }), + 'mint-swap-op:source-inflight', + ); + }); + + // Network I/O is outside the transaction. Recovery can now prove this call was authorized. + const result = await this.meltOperationService.executeOwnedRemote( + executingChild, + operation.id, + ); + try { + await this.repositories.withTransaction(async (scope) => { + const child = await this.meltOperationService.applyOwnedExecutionInTransaction( + executingChild, + operation.id, + result, + scope, + ); + if (child.state === 'finalized') { + await this.advanceSourceFundedInScope(scope, operation.id, child); + } + }); + } catch (error) { + if (!(error instanceof MintSwapAccountingContradictionError)) throw error; + return this.moveToAttention(operation, 'accounting_mismatch', error.message); + } + return this.requireOperation(operation.id); + }); + } + + /** Reconciles one operation from durable child and canonical quote state. */ + async refresh(operationId: string): Promise { + return this.withOperationLock(operationId, () => this.refreshUnlocked(operationId)); + } + + /** Clears processor delay and immediately runs the state-specific reconciliation action. */ + async retry(operationId: string): Promise { + return this.withOperationLock(operationId, async () => { + const operation = await this.requireOperation(operationId); + if (isTerminalMintSwapState(operation.state) || operation.state === 'needs_attention') { + throw new Error(`Cannot retry mint swap in state ${operation.state}`); + } + await this.mutate(operationId, (current) => ({ + ...current, + retry: { ...current.retry, nextAttemptAt: undefined, lastError: undefined }, + })); + return this.refreshUnlocked(operationId); + }); + } + + /** + * Requests cancellation when it can still be proven value-safe. + * + * A prepared reservation is rolled back immediately. Once the source payment is in flight, the + * request is persisted and reconciliation waits for canonical source-mint evidence. Cancellation + * is rejected after destination funding because source payment is already proven. + */ + async cancel(operationId: string, reason = 'Cancelled by caller'): Promise { + return this.withOperationLock(operationId, async () => { + const operation = await this.requireOperation(operationId); + if (operation.state === 'destination_funded' || operation.state === 'issuing') { + throw new Error('Cannot cancel a mint swap after destination funding'); + } + if (isTerminalMintSwapState(operation.state) || operation.state === 'needs_attention') { + throw new Error(`Cannot cancel mint swap in state ${operation.state}`); + } + if (operation.state === 'source_inflight') { + return this.mutate(operationId, (current) => ({ + ...current, + cancellationRequestedAt: Date.now(), + retry: { ...current.retry, nextAttemptAt: Date.now(), lastError: reason }, + })); + } + + if (operation.sourceMeltOperationId) { + const wallet = await this.getWallet(operation.sourceMintUrl); + await this.repositories.withTransaction(async (scope) => { + await this.meltOperationService.rollbackOwnedPreparedInTransaction( + operation.sourceMeltOperationId!, + operation.id, + wallet, + scope, + reason, + ); + await this.casInScope( + scope, + operation.id, + (current) => ({ + ...current, + state: 'cancelled', + cancellationRequestedAt: Date.now(), + cancelledAt: Date.now(), + }), + 'mint-swap-op:cancelled', + ); + }); + } else { + await this.mutate( + operation.id, + (current) => ({ + ...current, + state: 'cancelled', + cancellationRequestedAt: Date.now(), + cancelledAt: Date.now(), + }), + 'mint-swap-op:cancelled', + ); + } + return this.requireOperation(operation.id); + }); + } + + /** Returns the durable parent operation, or `null` when the id is unknown. */ + get(operationId: string): Promise { + return this.repositories.mintSwapOperationRepository.getById(operationId); + } + + /** Lists parents, optionally filtered by state and either participating mint. */ + async list(input: ListMintSwapInput = {}): Promise { + const operations = input.state + ? await this.repositories.mintSwapOperationRepository.getByState(input.state) + : await this.listAllStates(); + if (!input.mintUrl) return operations; + const mintUrl = normalizeMintUrl(input.mintUrl); + return operations.filter( + (operation) => + operation.sourceMintUrl === mintUrl || operation.destinationMintUrl === mintUrl, + ); + } + + /** Returns every non-terminal parent, including operations waiting for manual attention. */ + listActive(): Promise { + return this.repositories.mintSwapOperationRepository.getActive(); + } + + /** @internal Persists durable processor backoff without changing economic state. */ + async recordProcessorFailure( + operationId: string, + error: string, + nextAttemptAt: number, + ): Promise { + return this.mutate( + operationId, + (current) => ({ + ...current, + retry: { + ...current.retry, + attemptCount: current.retry.attemptCount + 1, + lastAttemptAt: Date.now(), + nextAttemptAt, + lastError: error, + }, + }), + 'mint-swap-op:delayed', + ); + } + + /** @internal Clears durable processor backoff after a successful canonical observation. */ + async recordProcessorSuccess(operationId: string): Promise { + const operation = await this.requireOperation(operationId); + if ( + operation.retry.attemptCount === 0 && + operation.retry.nextAttemptAt === undefined && + operation.retry.lastError === undefined + ) { + return operation; + } + return this.mutate(operationId, (current) => ({ + ...current, + retry: { + attemptCount: 0, + lastAttemptAt: current.retry.lastAttemptAt, + lastSuccessfulObservationAt: Date.now(), + }, + })); + } + + private async refreshUnlocked(operationId: string): Promise { + const operation = await this.requireOperation(operationId); + if (operation.preparedPlan && operation.state !== 'needs_attention') { + const violation = await this.getPreparedPlanViolation(operation); + if (violation) { + return this.moveToAttention(operation, violation.reason, violation.message); + } + } + switch (operation.state) { + case 'preparing': + return this.recoverPreparing(operation); + case 'source_inflight': + return this.refreshSource(operation); + case 'destination_funded': + return this.issueDestination(operation); + case 'issuing': + return this.refreshDestination(operation); + default: + return operation; + } + } + + /** + * An interrupted preparation is never resumed with a partially constructed economic plan. + * Any linked source reservation is released atomically before the parent becomes failed. + */ + private async recoverPreparing(operation: MintSwapOperation): Promise { + if (operation.sourceMeltOperationId) { + const wallet = await this.getWallet(operation.sourceMintUrl); + await this.repositories.withTransaction(async (scope) => { + await this.meltOperationService.rollbackOwnedPreparedInTransaction( + operation.sourceMeltOperationId!, + operation.id, + wallet, + scope, + 'Interrupted mint swap preparation', + ); + await this.casInScope( + scope, + operation.id, + (current) => ({ + ...current, + state: 'failed', + terminalFailure: { + code: 'interrupted_preparation', + reason: 'Mint swap preparation was interrupted before becoming executable', + at: Date.now(), + }, + }), + 'mint-swap-op:failed', + ); + }); + return this.requireOperation(operation.id); + } + return this.markFailed( + operation.id, + 'interrupted_preparation', + 'Mint swap preparation was interrupted before becoming executable', + ); + } + + private async refreshSource(operation: MintSwapOperation): Promise { + const child = await this.repositories.meltOperationRepository.getById( + operation.sourceMeltOperationId!, + ); + if (!child || child.parentSwapOperationId !== operation.id) { + return this.moveToAttention(operation, 'ownership_conflict', 'Source child ownership failed'); + } + if (child.state === 'finalized') { + try { + await this.repositories.withTransaction((scope) => + this.advanceSourceFundedInScope(scope, operation.id, child), + ); + } catch (error) { + if (!(error instanceof MintSwapAccountingContradictionError)) throw error; + return this.moveToAttention(operation, 'accounting_mismatch', error.message); + } + return this.requireOperation(operation.id); + } + if (child.state === 'executing') { + await this.meltOperationService.recoverOwnedExecuting(child, operation.id); + return this.requireOperation(operation.id); + } + if (child.state === 'pending') { + const canonical = await this.quoteLifecycle.getMeltQuote( + child.mintUrl, + child.method, + child.quoteId, + ); + if (canonical?.state === 'PAID') { + await this.meltOperationService.finalize(child.id, { + canonicalQuote: canonical, + parentSwapOperationId: operation.id, + }); + const finalized = await this.repositories.meltOperationRepository.getById(child.id); + if (finalized?.state === 'finalized') { + await this.repositories.withTransaction((scope) => + this.advanceSourceFundedInScope(scope, operation.id, finalized), + ); + } + } else if (canonical?.state === 'UNPAID' && operation.cancellationRequestedAt) { + await this.meltOperationService.rollback(child.id, 'Mint swap cancellation requested', { + canonicalQuote: canonical, + parentSwapOperationId: operation.id, + }); + await this.markCancelled(operation.id); + } else { + await this.quoteLifecycle.refreshMeltQuote(child.mintUrl, child.method, child.quoteId); + } + return this.requireOperation(operation.id); + } + if (child.state === 'rolled_back') { + if (operation.cancellationRequestedAt) return this.markCancelled(operation.id); + return this.markFailed(operation.id, 'source_reclaimed', 'Source value was reclaimed'); + } + return this.moveToAttention( + operation, + 'accounting_mismatch', + `Unexpected source child state ${child.state}`, + ); + } + + private async issueDestination(operation: MintSwapOperation): Promise { + if (!(await this.hasDestinationRecoveryKey(operation))) { + return this.moveToAttention( + operation, + 'required_recovery_capability_missing', + 'Destination NUT-20 recovery key is unavailable after source payment', + ); + } + let quote: MintQuote<'bolt11'>; + try { + quote = await this.refreshDestinationAccounting(operation); + } catch (error) { + if (!(error instanceof MintSwapAccountingContradictionError)) throw error; + return this.moveToAttention(operation, 'canonical_observation_conflict', error.message); + } + if ( + quote.amountIssued.isZero() && + getMintQuoteAvailableAmount(quote).lessThan(operation.destinationAmount) + ) { + // Source payment is canonical, but the destination has not exposed enough mintable value. + // Stay forward-only and let durable recovery poll again without attempting issuance. + return this.requireOperation(operation.id); + } + + // As with the source leg, durable authorization precedes remote issuance. + let executingChild!: ExecutingMintOperation; + await this.repositories.withTransaction(async (scope) => { + executingChild = await this.mintOperationService.authorizeOwnedExecutionInTransaction( + operation.destinationMintOperationId!, + operation.id, + scope, + ); + await this.casInScope( + scope, + operation.id, + (current) => ({ + ...current, + state: 'issuing', + destinationIssueAuthorizedAt: Date.now(), + }), + 'mint-swap-op:issuing', + ); + }); + if (!quote.amountIssued.isZero()) { + // Any observed issuance makes another POST unsafe. Recover only the persisted output plan. + await this.mintOperationService.recoverOwnedExecuting(executingChild, operation.id); + return this.requireOperation(operation.id); + } + const result = await this.mintOperationService.executeOwnedRemote(executingChild, operation.id); + try { + await this.repositories.withTransaction(async (scope) => { + const child = await this.mintOperationService.applyOwnedExecutionInTransaction( + executingChild, + operation.id, + result, + scope, + ); + if (child.state === 'finalized') { + await this.completeInScope(scope, operation.id, child); + } + }); + } catch (error) { + if (!(error instanceof MintSwapAccountingContradictionError)) throw error; + return this.moveToAttention(operation, 'accounting_mismatch', error.message); + } + return this.requireOperation(operation.id); + } + + private async refreshDestination(operation: MintSwapOperation): Promise { + const child = await this.repositories.mintOperationRepository.getById( + operation.destinationMintOperationId!, + ); + if (!child || child.parentSwapOperationId !== operation.id) { + return this.moveToAttention( + operation, + 'ownership_conflict', + 'Destination child ownership failed', + ); + } + if (child.state === 'finalized') { + try { + await this.repositories.withTransaction((scope) => + this.completeInScope(scope, operation.id, child), + ); + } catch (error) { + if (!(error instanceof MintSwapAccountingContradictionError)) throw error; + return this.moveToAttention(operation, 'accounting_mismatch', error.message); + } + } else if ( + (child.state === 'executing' || child.state === 'pending') && + !(await this.hasDestinationRecoveryKey(operation)) + ) { + return this.moveToAttention( + operation, + 'required_recovery_capability_missing', + 'Destination NUT-20 recovery key is unavailable after source payment', + ); + } else if (child.state === 'executing') { + await this.mintOperationService.recoverOwnedExecuting(child, operation.id); + } else if (child.state === 'pending') { + return this.retryDestinationIssue(operation, child.id); + } else if (child.state === 'failed') { + return this.moveToAttention( + operation, + 'source_paid_destination_terminal', + 'Destination child became terminal after source payment', + ); + } + return this.requireOperation(operation.id); + } + + private async retryDestinationIssue( + operation: MintSwapOperation, + childId: string, + ): Promise { + let quote: MintQuote<'bolt11'>; + try { + quote = await this.refreshDestinationAccounting(operation); + } catch (error) { + if (!(error instanceof MintSwapAccountingContradictionError)) throw error; + return this.moveToAttention(operation, 'canonical_observation_conflict', error.message); + } + if ( + quote.amountIssued.isZero() && + getMintQuoteAvailableAmount(quote).lessThan(operation.destinationAmount) + ) { + return this.requireOperation(operation.id); + } + + let executing!: ExecutingMintOperation; + await this.repositories.withTransaction(async (scope) => { + executing = await this.mintOperationService.authorizeOwnedExecutionInTransaction( + childId, + operation.id, + scope, + ); + await this.casInScope(scope, operation.id, (current) => ({ + ...current, + destinationIssueAuthorizedAt: current.destinationIssueAuthorizedAt ?? Date.now(), + })); + }); + if (!quote.amountIssued.isZero()) { + await this.mintOperationService.recoverOwnedExecuting(executing, operation.id); + return this.requireOperation(operation.id); + } + const result = await this.mintOperationService.executeOwnedRemote(executing, operation.id); + await this.repositories.withTransaction(async (scope) => { + const finalized = await this.mintOperationService.applyOwnedExecutionInTransaction( + executing, + operation.id, + result, + scope, + ); + if (finalized.state === 'finalized') { + await this.completeInScope(scope, operation.id, finalized); + } + }); + return this.requireOperation(operation.id); + } + + /** + * Refreshes and validates the destination's canonical Mint Quote Accounting before any issuance + * or restoration decision. The deprecated BOLT11 state projection is intentionally ignored. + */ + private async refreshDestinationAccounting( + operation: MintSwapOperation, + ): Promise> { + const quoteRef = operation.destinationQuoteRef; + if (!quoteRef) { + throw new MintSwapAccountingContradictionError('Destination quote reference is missing'); + } + const quote = await this.quoteLifecycle.refreshMintQuote( + quoteRef.mintUrl, + quoteRef.method, + quoteRef.quoteId, + ); + if ( + !isStatefulMintQuote(quote) || + quote.mintUrl !== operation.destinationMintUrl || + quote.quoteId !== quoteRef.quoteId || + quote.unit !== operation.unit || + !quote.amount.equals(operation.destinationAmount) || + quote.pubkey !== operation.destinationNut20Key?.publicKey || + quote.remoteUpdatedAt === null + ) { + throw new MintSwapAccountingContradictionError( + 'Destination quote no longer matches the prepared mint swap or lacks current accounting', + ); + } + return quote; + } + + private async advanceSourceFundedInScope( + scope: RepositoryTransactionScope, + operationId: string, + child: FinalizedMeltOperation, + ): Promise { + const current = await this.requireOperationInScope(scope, operationId); + if (current.state === 'destination_funded' || current.state === 'issuing') return; + // Settlement and the funded event commit together; listeners never see unvalidated accounting. + const settlement = this.calculateSettlement(current, child); + const next = await this.casInScope( + scope, + operationId, + (operation) => ({ ...operation, state: 'destination_funded', settlement }), + 'mint-swap-op:destination-funded', + ); + try { + validateMintSwapAccounting(next); + } catch (error) { + throw new MintSwapAccountingContradictionError( + error instanceof Error ? error.message : String(error), + ); + } + } + + private async completeInScope( + scope: RepositoryTransactionScope, + operationId: string, + child: FinalizedMintOperation, + ): Promise { + const current = await this.requireOperationInScope(scope, operationId); + if (current.state === 'completed') return; + // Completion is authorized by durable proof value, not merely by a finalized child state. + const proofs = await scope.proofRepository.getProofsByOperationId(child.mintUrl, child.id); + const issued = sumProofs(proofs.filter((proof) => proof.createdByOperationId === child.id)); + if (!issued.equals(current.destinationAmount)) { + throw new MintSwapAccountingContradictionError( + 'Destination proof total does not match mint swap amount', + ); + } + const settlement = { ...current.settlement!, destinationAmountIssued: issued }; + const next = await this.casInScope( + scope, + operationId, + (operation) => ({ + ...operation, + state: 'completed', + settlement, + completedAt: Date.now(), + }), + 'mint-swap-op:completed', + ); + try { + validateMintSwapAccounting(next); + } catch (error) { + throw new MintSwapAccountingContradictionError( + error instanceof Error ? error.message : String(error), + ); + } + } + + private calculateSettlement( + operation: MintSwapOperation, + child: FinalizedMeltOperation, + ): MintSwapSettlement { + const plan = operation.preparedPlan!; + if (child.effectiveFee === undefined) { + throw new MintSwapAccountingContradictionError( + 'Finalized source child is missing effective fee accounting', + ); + } + if (child.effectiveFee.lessThan(plan.sourceMeltInputFee)) { + throw new MintSwapAccountingContradictionError( + 'Source effective fee is below its melt input fee', + ); + } + // Melt effectiveFee combines the melt-input fee and the settled payment-side cost. + const sourcePaymentFee = child.effectiveFee.subtract(plan.sourceMeltInputFee); + const totalSourceFee = plan.sourcePreparationFee + .add(plan.sourceMeltInputFee) + .add(sourcePaymentFee); + const sourceMeltChangeAmount = child.changeAmount ?? Amount.zero(); + const sourceKeepAmount = this.sourceKeepAmount(child); + const sourceReturnedAmount = sourceKeepAmount.add(sourceMeltChangeAmount); + // Debit is derived independently from returned value; the model validator also requires it to + // equal destinationAmount + totalSourceFee and remain within the accepted maximum. + const finalSourceDebit = plan.reservedSourceAmount.subtract(sourceReturnedAmount); + return { + sourcePaymentFee, + totalSourceFee, + sourceMeltChangeAmount, + sourceKeepAmount, + sourceReturnedAmount, + finalSourceDebit, + }; + } + + private async buildPreparedPlan( + sourceChild: PreparedMeltOperation, + destinationOutputData: unknown, + destinationQuote: MintQuote<'bolt11'>, + sourceQuote: MeltQuote<'bolt11'>, + sourceWallet: Wallet, + sourceSupportsNut08: boolean, + requiredDispatchWindowSeconds: number | undefined, + scope: RepositoryTransactionScope, + ): Promise { + const reservedProofs = ( + await scope.proofRepository.getProofsByOperationId(sourceChild.mintUrl, sourceChild.id) + ).filter((proof) => proof.usedByOperationId === sourceChild.id); + const sourcePreparationFee = sourceChild.swap_fee; + const sourceMeltInputFee = this.sourceMeltInputFee(sourceChild, reservedProofs, sourceWallet); + const sourceKeepAmount = this.sourceKeepAmount(sourceChild); + // The minimum contains only exact, known costs. fee_reserve is not treated as an estimate. + const minimumSourceDebit = sourceChild.amount.add(sourcePreparationFee).add(sourceMeltInputFee); + // A pre-swap removes denomination overage into local keep proofs. A direct NUT-08 plan can + // bound payment cost by fee_reserve; without NUT-08 the whole selected input is the safe bound. + const maximumSourceDebit = sourceChild.needsSwap + ? sourceChild.inputAmount.subtract(sourceKeepAmount) + : sourceSupportsNut08 + ? minimumSourceDebit.add(sourceChild.fee_reserve) + : sourceChild.inputAmount; + if (maximumSourceDebit.greaterThan(sourceChild.inputAmount)) { + throw new Error('Prepared source plan does not reserve its maximum debit'); + } + const dispatch = evaluateMintSwapDispatchWindow({ + expiries: [destinationQuote.expiry, sourceQuote.expiry], + requiredWindowSeconds: requiredDispatchWindowSeconds, + }); + if (!dispatch.canDispatch) throw new Error('Mint swap quote expiry window is too short'); + const current = await this.requireOperationInScope(scope, sourceChild.parentSwapOperationId!); + const fingerprint = createMintSwapPreparedPlanFingerprint({ + destinationMintOperationId: current.destinationMintOperationId!, + sourceMeltOperationId: sourceChild.id, + destinationQuoteRef: current.destinationQuoteRef!, + sourceQuoteRef: current.sourceQuoteRef!, + destinationAmount: current.destinationAmount, + unit: 'sat', + sourceInputProofSecrets: sourceChild.inputProofSecrets, + destinationOutputData, + sourceOutputData: { + changeOutputData: sourceChild.changeOutputData, + swapOutputData: sourceChild.swapOutputData, + }, + maximumSourceDebit, + }); + return { + fingerprint, + dispatchDeadline: dispatch.dispatchDeadline, + requiredDispatchWindowSeconds: dispatch.requiredWindowSeconds, + sourceMeltAmount: sourceChild.amount, + sourceFeeReserve: sourceChild.fee_reserve, + sourcePreparationFee, + sourceMeltInputFee, + minimumSourceDebit, + maximumSourceDebit, + reservedSourceAmount: sourceChild.inputAmount, + }; + } + + private sourceMeltInputFee( + child: PreparedMeltOperation, + reservedProofs: Proof[], + wallet: Wallet, + ): Amount { + if (!child.needsSwap) return wallet.getFeesForProofs(reservedProofs); + if (!child.swapOutputData) throw new Error('Source pre-swap output plan is missing'); + // Pre-swap send outputs were constructed as amount + reserve + future melt-input fee. + const meltInputAmount = OutputData.sumOutputAmounts( + deserializeOutputData(child.swapOutputData).send, + ); + return meltInputAmount.subtract(child.amount).subtract(child.fee_reserve); + } + + private sourceKeepAmount(child: PreparedMeltOperation | FinalizedMeltOperation): Amount { + if (!child.needsSwap) return Amount.zero(); + if (!child.swapOutputData) throw new Error('Source pre-swap output plan is missing'); + return OutputData.sumOutputAmounts(deserializeOutputData(child.swapOutputData).keep); + } + + private async assertPreflight( + sourceMintUrl: string, + destinationMintUrl: string, + amount: Amount, + ): Promise { + const [sourceTrusted, destinationTrusted] = await Promise.all([ + this.mintService.isTrustedMint(sourceMintUrl), + this.mintService.isTrustedMint(destinationMintUrl), + ]); + if (!sourceTrusted || !destinationTrusted) { + throw new Error('Mint swap requires two explicitly trusted mints'); + } + await Promise.all([ + this.mintService.assertMethodUnitSupported(destinationMintUrl, 4, 'bolt11', { + amount, + unit: 'sat', + }), + this.mintService.assertMethodUnitSupported(sourceMintUrl, 5, 'bolt11', { + amount, + unit: 'sat', + }), + this.mintService.assertNutSupported(sourceMintUrl, 7, 'mint swap recovery'), + this.mintService.assertNutSupported(sourceMintUrl, 9, 'mint swap recovery'), + this.mintService.assertNutSupported(destinationMintUrl, 9, 'mint swap recovery'), + this.mintService.assertNutSupported(destinationMintUrl, 20, 'mint swap destination claim'), + ]); + } + + private async getPreparedPlanViolation( + operation: MintSwapOperation, + ): Promise<{ reason: MintSwapAttentionReason; message: string } | null> { + const [destinationChild, sourceChild] = await Promise.all([ + this.repositories.mintOperationRepository.getById(operation.destinationMintOperationId!), + this.repositories.meltOperationRepository.getById(operation.sourceMeltOperationId!), + ]); + if ( + !destinationChild || + destinationChild.parentSwapOperationId !== operation.id || + !sourceChild || + sourceChild.parentSwapOperationId !== operation.id + ) { + return { + reason: 'ownership_conflict', + message: 'Mint swap child ownership no longer matches the prepared plan', + }; + } + if ( + !('outputData' in destinationChild) || + !('inputProofSecrets' in sourceChild) || + !('changeOutputData' in sourceChild) + ) { + return { + reason: 'missing_post_effect_recovery_material', + message: 'Mint swap child recovery material is incomplete', + }; + } + const fingerprint = createMintSwapPreparedPlanFingerprint({ + destinationMintOperationId: destinationChild.id, + sourceMeltOperationId: sourceChild.id, + destinationQuoteRef: operation.destinationQuoteRef!, + sourceQuoteRef: operation.sourceQuoteRef!, + destinationAmount: operation.destinationAmount, + unit: 'sat', + sourceInputProofSecrets: sourceChild.inputProofSecrets, + destinationOutputData: destinationChild.outputData, + sourceOutputData: { + changeOutputData: sourceChild.changeOutputData, + swapOutputData: sourceChild.swapOutputData, + }, + maximumSourceDebit: operation.preparedPlan!.maximumSourceDebit, + }); + if (fingerprint !== operation.preparedPlan!.fingerprint) { + return { + reason: 'prepared_plan_mismatch', + message: 'Mint swap child data no longer matches the authorized prepared plan', + }; + } + return null; + } + + private async hasDestinationRecoveryKey(operation: MintSwapOperation): Promise { + const keyRef = operation.destinationNut20Key; + if (!keyRef) return false; + const key = await this.keyRingService.getMintQuoteKeyPair(keyRef.publicKey); + return key?.derivationIndex === keyRef.derivationIndex; + } + + private async requireDestinationRecoveryKey(operation: MintSwapOperation): Promise { + if (!(await this.hasDestinationRecoveryKey(operation))) { + throw new Error('Destination NUT-20 recovery key is unavailable'); + } + } + + private assertDestinationQuote( + quote: MintQuote, + amount: Amount, + expectedPublicKey: string, + ): asserts quote is MintQuote<'bolt11'> { + if ( + quote.method !== 'bolt11' || + quote.unit !== 'sat' || + !quote.amount.equals(amount) || + quote.pubkey !== expectedPublicKey || + quote.remoteUpdatedAt === null + ) { + throw new Error( + 'Destination quote does not match the mint swap intent or lacks current accounting', + ); + } + } + + private assertSourceQuote( + quote: MeltQuote, + amount: Amount, + ): asserts quote is MeltQuote<'bolt11'> { + if (quote.method !== 'bolt11' || quote.unit !== 'sat' || !quote.amount.equals(amount)) { + throw new Error('Source quote does not match the mint swap intent'); + } + } + + private assertDispatchWindow(operation: MintSwapOperation): void { + const plan = operation.preparedPlan!; + const dispatch = evaluateMintSwapDispatchWindow({ + expiries: [plan.dispatchDeadline], + requiredWindowSeconds: plan.requiredDispatchWindowSeconds, + }); + if (!dispatch.canDispatch) throw new Error('Mint swap dispatch window has expired'); + } + + private quoteRef(quote: MintQuote<'bolt11'> | MeltQuote<'bolt11'>) { + return { mintUrl: quote.mintUrl, method: 'bolt11' as const, quoteId: quote.quoteId }; + } + + private async getWallet(mintUrl: string): Promise { + return (await this.walletService.getWalletWithActiveKeysetId(mintUrl, 'sat')).wallet; + } + + private async failPreparation(operationId: string): Promise { + try { + const operation = await this.get(operationId); + if (!operation || operation.state !== 'preparing') return; + await this.mutate( + operationId, + (current) => ({ + ...current, + state: 'failed', + terminalFailure: { + code: 'preparation_failed', + reason: 'Mint swap preparation failed before source dispatch', + at: Date.now(), + }, + }), + 'mint-swap-op:failed', + ); + } catch (error) { + this.logger?.warn('Failed to persist mint swap preparation failure', { + operationId, + error: redactError(error), + }); + } + } + + private async failPreparedBeforeDispatch( + operation: MintSwapOperation, + code: string, + reason: string, + ): Promise { + try { + const wallet = await this.getWallet(operation.sourceMintUrl); + await this.repositories.withTransaction(async (scope) => { + await this.meltOperationService.rollbackOwnedPreparedInTransaction( + operation.sourceMeltOperationId!, + operation.id, + wallet, + scope, + reason, + ); + await this.casInScope( + scope, + operation.id, + (current) => ({ + ...current, + state: 'failed', + terminalFailure: { code, reason, at: Date.now() }, + }), + 'mint-swap-op:failed', + ); + }); + return this.requireOperation(operation.id); + } catch { + return this.moveToAttention( + operation, + 'source_reclamation_unproven', + 'Source reservation could not be reclaimed before dispatch', + ); + } + } + + private async moveToAttention( + operation: MintSwapOperation, + reason: MintSwapAttentionReason, + message: string, + ): Promise { + return this.mutate( + operation.id, + (current) => ({ + ...current, + state: 'needs_attention', + attention: { + reason, + message, + lastSafeState: current.state, + violatedInvariant: reason, + evidence: { operationId: current.id }, + at: Date.now(), + }, + }), + 'mint-swap-op:needs-attention', + ); + } + + private async markCancelled(operationId: string): Promise { + return this.mutate( + operationId, + (current) => ({ + ...current, + state: 'cancelled', + cancellationRequestedAt: current.cancellationRequestedAt ?? Date.now(), + cancelledAt: Date.now(), + }), + 'mint-swap-op:cancelled', + ); + } + + private async markFailed( + operationId: string, + code: string, + reason: string, + ): Promise { + return this.mutate( + operationId, + (current) => ({ + ...current, + state: 'failed', + terminalFailure: { code, reason, at: Date.now() }, + }), + 'mint-swap-op:failed', + ); + } + + private async mutate( + operationId: string, + mutation: (current: MintSwapOperation) => MintSwapOperation, + eventType?: MintSwapEventType, + ): Promise { + return this.repositories.withTransaction((scope) => + this.casInScope(scope, operationId, mutation, eventType), + ); + } + + private async casInScope( + scope: RepositoryTransactionScope, + operationId: string, + mutation: (current: MintSwapOperation) => MintSwapOperation, + eventType?: MintSwapEventType, + ): Promise { + const current = await this.requireOperationInScope(scope, operationId); + const next = mutation(current); + next.revision = current.revision + 1; + next.updatedAt = Date.now(); + if (!(await scope.mintSwapOperationRepository.compareAndSet(next, current.revision))) { + throw new Error(`Concurrent mint swap update for ${operationId}`); + } + // Parent state and its logical event are atomic; publication happens after this transaction. + if (eventType) await scope.operationEventOutboxRepository.enqueue(this.outbox(next, eventType)); + return next; + } + + private outbox( + operation: MintSwapOperation, + eventType: MintSwapEventType, + ): OperationEventOutboxRecord { + return { + id: generateSubId(), + operationId: operation.id, + revision: operation.revision, + eventType, + payload: { + operationId: operation.id, + revision: operation.revision, + state: operation.state, + sourceMintUrl: operation.sourceMintUrl, + destinationMintUrl: operation.destinationMintUrl, + unit: operation.unit, + destinationAmount: operation.destinationAmount.toString(), + reasonCode: operation.attention?.reason ?? operation.terminalFailure?.code, + }, + createdAt: Date.now(), + publishAttempts: 0, + }; + } + + private requireOperation(operationId: string): Promise { + return this.requireOperationInScope(this.repositories, operationId); + } + + private async requireOperationInScope( + scope: Pick, + operationId: string, + ): Promise { + const operation = await scope.mintSwapOperationRepository.getById(operationId); + if (!operation) throw new Error(`Mint swap ${operationId} not found`); + return operation; + } + + private async listAllStates(): Promise { + const states: MintSwapOperationState[] = [ + 'preparing', + 'prepared', + 'source_inflight', + 'destination_funded', + 'issuing', + 'completed', + 'cancelled', + 'failed', + 'needs_attention', + ]; + const groups = await Promise.all( + states.map((state) => this.repositories.mintSwapOperationRepository.getByState(state)), + ); + return groups.flat().sort((left, right) => left.createdAt - right.createdAt); + } + + private async withOperationLock(operationId: string, fn: () => Promise): Promise { + const release = await this.operationLock.acquire(operationId); + try { + return await fn(); + } finally { + release(); + } + } + + private async withMintLock(mintUrl: string, fn: () => Promise): Promise { + const release = await this.mintScopedLock.acquire(mintUrl); + try { + return await fn(); + } finally { + release(); + } + } +} diff --git a/packages/core/operations/mintSwap/index.ts b/packages/core/operations/mintSwap/index.ts new file mode 100644 index 000000000..c6cf9aa6d --- /dev/null +++ b/packages/core/operations/mintSwap/index.ts @@ -0,0 +1,3 @@ +export * from './MintSwapOperation'; +export * from './ChildOperationOwnership'; +export * from './MintSwapOperationService'; diff --git a/packages/core/quotes/MintQuoteObservation.ts b/packages/core/quotes/MintQuoteObservation.ts index cd442c48b..bb91c82f1 100644 --- a/packages/core/quotes/MintQuoteObservation.ts +++ b/packages/core/quotes/MintQuoteObservation.ts @@ -1,10 +1,8 @@ -import { isStatefulMintQuote, type MintQuote } from '../models/MintQuote'; - -const MINT_QUOTE_STATE_RANK = { - UNPAID: 0, - PAID: 1, - ISSUED: 2, -} as const; +import { + deriveBolt11MintQuoteState, + isStatefulMintQuote, + type MintQuote, +} from '../models/MintQuote'; export type MintQuoteObservationDisposition = | 'accepted-meaningful-change' @@ -19,12 +17,12 @@ export interface MintQuoteObservationResolution { disposition: MintQuoteObservationDisposition; } -function isMintQuoteStateDowngrade(existing: MintQuote, incoming: MintQuote): boolean { - return ( - isStatefulMintQuote(existing) && - isStatefulMintQuote(incoming) && - MINT_QUOTE_STATE_RANK[incoming.state] < MINT_QUOTE_STATE_RANK[existing.state] - ); +function withCanonicalCompatibilityProjection(quote: MintQuote): MintQuote { + if (!isStatefulMintQuote(quote)) return quote; + return { + ...quote, + state: deriveBolt11MintQuoteState(quote.amountPaid, quote.amountIssued), + }; } function hasAccountingComponentDecrease(existing: MintQuote, incoming: MintQuote): boolean { @@ -51,8 +49,7 @@ function hasMeaningfulChange(existing: MintQuote | null, incoming: MintQuote): b } if (isStatefulMintQuote(existing) && isStatefulMintQuote(incoming)) { - // Until #387, non-terminal BOLT11 state changes still alter canonical claimability. - return !existing.amount.equals(incoming.amount) || existing.state !== incoming.state; + return !existing.amount.equals(incoming.amount); } if (existing.method === 'bolt12' && incoming.method === 'bolt12') { @@ -72,13 +69,19 @@ export function resolveMintQuoteObservation( ): MintQuoteObservationResolution { if (incoming.amountIssued.greaterThan(incoming.amountPaid)) { return { - resolvedQuote: existing ?? incoming, + resolvedQuote: existing ?? withCanonicalCompatibilityProjection(incoming), disposition: 'ignored-invalid-background', }; } + if (!existing || existing.method !== incoming.method || existing.quoteId !== incoming.quoteId) { + return { + resolvedQuote: withCanonicalCompatibilityProjection(incoming), + disposition: 'accepted-meaningful-change', + }; + } + if ( - existing && existing.remoteUpdatedAt !== null && incoming.remoteUpdatedAt !== null && incoming.remoteUpdatedAt < existing.remoteUpdatedAt @@ -90,7 +93,6 @@ export function resolveMintQuoteObservation( } if ( - existing && existing.remoteUpdatedAt !== null && incoming.remoteUpdatedAt !== null && incoming.remoteUpdatedAt === existing.remoteUpdatedAt && @@ -103,10 +105,13 @@ export function resolveMintQuoteObservation( }; } + // Once BOLT11 accounting has remote ordering, an unversioned compatibility observation + // must not replace it. Legacy `state` remains a projection rather than an authority. if ( - existing && - (hasAccountingComponentDecrease(existing, incoming) || - isMintQuoteStateDowngrade(existing, incoming)) + isStatefulMintQuote(existing) && + isStatefulMintQuote(incoming) && + existing.remoteUpdatedAt !== null && + incoming.remoteUpdatedAt === null ) { return { resolvedQuote: existing, @@ -114,7 +119,14 @@ export function resolveMintQuoteObservation( }; } - if (existing && (existing.remoteUpdatedAt === null || incoming.remoteUpdatedAt === null)) { + if (hasAccountingComponentDecrease(existing, incoming)) { + return { + resolvedQuote: existing, + disposition: 'ignored-stale', + }; + } + + if (existing.remoteUpdatedAt === null || incoming.remoteUpdatedAt === null) { const hasNewerAccounting = incoming.amountPaid .add(incoming.amountIssued) .greaterThan(existing.amountPaid.add(existing.amountIssued)); @@ -126,14 +138,15 @@ export function resolveMintQuoteObservation( } } - const resolvedQuote = - existing && existing.remoteUpdatedAt !== null && incoming.remoteUpdatedAt === null + const resolvedQuote = withCanonicalCompatibilityProjection( + existing.remoteUpdatedAt !== null && incoming.remoteUpdatedAt === null ? { ...incoming, remoteUpdatedAt: existing.remoteUpdatedAt } - : incoming; + : incoming, + ); if (hasMeaningfulChange(existing, resolvedQuote)) { return { resolvedQuote, disposition: 'accepted-meaningful-change' }; } - if (existing?.remoteUpdatedAt === resolvedQuote.remoteUpdatedAt) { + if (existing.remoteUpdatedAt === resolvedQuote.remoteUpdatedAt) { return { resolvedQuote: existing, disposition: 'ignored-unchanged' }; } return { resolvedQuote, disposition: 'accepted-freshness-only' }; diff --git a/packages/core/quotes/QuoteLifecycle.ts b/packages/core/quotes/QuoteLifecycle.ts index bf4646576..7c92d762e 100644 --- a/packages/core/quotes/QuoteLifecycle.ts +++ b/packages/core/quotes/QuoteLifecycle.ts @@ -7,8 +7,12 @@ import type { MintAdapter } from '../infra'; import type { MeltHandlerProvider } from '../infra/handlers/melt'; import type { MintHandlerProvider } from '../infra/handlers/mint'; import type { Logger } from '../logging/Logger'; +import { redactSensitiveValue } from '../logging/redaction'; import { + applyBolt11MintQuoteStateFallback, + deriveBolt11MintQuoteState, getMintQuoteAmount, + isBolt11MintQuoteIssued, mintQuoteFromBolt11Response, mintQuoteFromBolt12Response, mintQuoteFromOnchainResponse, @@ -31,7 +35,12 @@ import { QuoteIdentityConflictError, UnknownMintError, } from '../models/Error'; -import type { MeltQuoteRepository, MintQuoteRepository, ProofRepository } from '../repositories'; +import type { + MeltQuoteRepository, + MintQuoteRepository, + ProofRepository, + RepositoryTransactionScope, +} from '../repositories'; import type { MintService } from '../services/MintService'; import type { ProofService } from '../services/ProofService'; import type { WalletService } from '../services/WalletService'; @@ -520,7 +529,7 @@ export class QuoteLifecycle { (await this.mintQuoteRepository.getMintQuote(mintUrl, method, quote.quoteId)) ?? quote; this.logger?.info('Mint quote created', { mintUrl: persistedQuote.mintUrl, - quoteId: persistedQuote.quoteId, + quoteRef: redactSensitiveValue(persistedQuote.quoteId), method, amount: getMintQuoteAmount(persistedQuote)?.toString(), unit: persistedQuote.unit, @@ -1173,6 +1182,7 @@ export class QuoteLifecycle { private async resolveAndPersistMintQuoteObservation( canonicalQuote: MintQuote, beforePersist?: (quote: MintQuote) => Promise, + repository?: MintQuoteRepository, ): Promise<{ quote: MintQuote; remoteStateChanged: boolean; @@ -1182,12 +1192,14 @@ export class QuoteLifecycle { return this.resolveAndPersistMintQuoteObservationUnderLock( canonicalQuote, () => canonicalQuote, + repository, ); } private async resolveAndPersistMintQuoteObservationUnderLock( ref: MintQuoteRef, buildObservation: (existing: MintQuote | null) => MintQuote, + repositoryOverride?: MintQuoteRepository, ): Promise<{ quote: MintQuote; remoteStateChanged: boolean; @@ -1196,7 +1208,7 @@ export class QuoteLifecycle { const observationKey = [ref.mintUrl, ref.method, ref.quoteId].join('::'); const release = await this.mintQuoteObservationLock.acquire(observationKey); try { - return await this.withMintQuoteTransaction(async (repository) => { + const resolveWithRepository = async (repository: MintQuoteRepository) => { const existing = await repository.getMintQuote(ref.mintUrl, ref.method, ref.quoteId); const canonicalQuote = buildObservation(existing); const resolution = resolveMintQuoteObservation(existing, canonicalQuote); @@ -1224,7 +1236,10 @@ export class QuoteLifecycle { remoteStateChanged: resolution.disposition === 'accepted-meaningful-change', existingQuote: existing, }; - }); + }; + return repositoryOverride + ? await resolveWithRepository(repositoryOverride) + : await this.withMintQuoteTransaction(resolveWithRepository); } finally { release(); } @@ -1284,23 +1299,100 @@ export class QuoteLifecycle { `Cannot record quote observation: mint quote ${operation.quoteId} for ${operation.method} at ${operation.mintUrl} was not found`, ); } - if (!isStatefulMintQuote(existing)) return existing; - - return { - ...existing, - state, - amountPaid: state === 'UNPAID' ? Amount.zero() : existing.amount, - amountIssued: state === 'ISSUED' ? existing.amount : Amount.zero(), - remoteUpdatedAt: null, - updatedAt: observedAt, - }; + if (!isStatefulMintQuote(existing)) { + throw new Error( + `Cannot record legacy quote state for ${operation.method} mint quote ${operation.quoteId}`, + ); + } + return applyBolt11MintQuoteStateFallback(existing, state, observedAt); }, ); await this.emitMintQuoteUpdatedIfNeeded(quote, remoteStateChanged); + return quote; + } + + /** + * Records accounting established by successful local persistence of deterministic BOLT11 + * issuance. This is a first-class accounting update; legacy state remains only a projection. + */ + async recordMintQuoteIssuance( + operation: PendingOrLaterOperation, + observedAt = Date.now(), + ): Promise> { + await this.ensureMintQuoteRecordForOperation(operation); + const { quote, remoteStateChanged } = await this.recordMintQuoteIssuanceWithRepository( + operation, + this.mintQuoteRepository, + observedAt, + ); + await this.emitMintQuoteUpdatedIfNeeded(quote, remoteStateChanged); + return quote; + } + /** + * Persists successful BOLT11 issuance inside a parent-owned transaction. + * + * @internal The parent transition/outbox is the notification boundary for this path. + */ + async recordMintQuoteIssuanceInTransaction( + operation: PendingOrLaterOperation, + repositories: RepositoryTransactionScope, + observedAt = Date.now(), + ): Promise> { + const { quote } = await this.recordMintQuoteIssuanceWithRepository( + operation, + repositories.mintQuoteRepository, + observedAt, + ); return quote; } + private async recordMintQuoteIssuanceWithRepository( + operation: PendingOrLaterOperation, + repository: MintQuoteRepository, + observedAt: number, + ): Promise<{ quote: MintQuote<'bolt11'>; remoteStateChanged: boolean }> { + if (operation.method !== 'bolt11') { + throw new Error(`Cannot record BOLT11 issuance for ${operation.method} mint operation`); + } + const existing = await repository.getMintQuote( + operation.mintUrl, + operation.method, + operation.quoteId, + ); + if (!existing || !isStatefulMintQuote(existing)) { + throw new Error( + `Cannot record issuance: BOLT11 mint quote ${operation.quoteId} was not found`, + ); + } + + const amountPaid = existing.amountPaid.greaterThan(operation.amount) + ? existing.amountPaid + : operation.amount; + const amountIssued = existing.amountIssued.greaterThan(operation.amount) + ? existing.amountIssued + : operation.amount; + const canonicalObservation: MintQuote<'bolt11'> = { + ...existing, + state: deriveBolt11MintQuoteState(amountPaid, amountIssued), + amountPaid, + amountIssued, + updatedAt: observedAt, + }; + const resolution = await this.resolveAndPersistMintQuoteObservation( + canonicalObservation, + undefined, + repository, + ); + if (!isStatefulMintQuote(resolution.quote)) { + throw new Error(`Canonical quote ${operation.quoteId} changed method during issuance`); + } + return { + quote: resolution.quote, + remoteStateChanged: resolution.remoteStateChanged, + }; + } + async createMeltQuote( mintUrl: string, method: M, @@ -1555,7 +1647,7 @@ export class QuoteLifecycle { throw new Error(`Cannot prepare ${context}: quote is expired`); } - if (isStatefulMintQuote(quote) && quote.state === 'ISSUED') { + if (isStatefulMintQuote(quote) && isBolt11MintQuoteIssued(quote)) { throw new Error(`Cannot prepare ${context}: quote is terminal`); } } diff --git a/packages/core/repositories/index.ts b/packages/core/repositories/index.ts index c15a7ff9e..33f059deb 100644 --- a/packages/core/repositories/index.ts +++ b/packages/core/repositories/index.ts @@ -6,6 +6,10 @@ import type { MintQuote } from '@core/models/MintQuote'; import type { QuoteIdentity } from '@core/models/QuoteIdentity'; import type { MeltOperation, MeltOperationState } from '@core/operations/melt/MeltOperation'; import type { MintOperation, MintOperationState } from '@core/operations/mint/MintOperation'; +import type { + MintSwapOperation, + MintSwapOperationState, +} from '@core/operations/mintSwap/MintSwapOperation'; import type { ReceiveOperation, ReceiveOperationState, @@ -22,6 +26,7 @@ import type { Mint } from '../models/Mint'; import type { SendOperation, SendOperationState } from '../operations/send/SendOperation'; import type { CoreProof, ProofState } from '../types'; import type { MintMethodRemoteState } from '../operations/mint/MintMethodHandler'; +import type { OperationEventOutboxRecord } from '../models/OperationEventOutbox'; export interface ProofUnitFilter { unit?: string; @@ -147,7 +152,9 @@ export interface MintQuoteRepository { upsertMintQuote(quote: MintQuote): Promise; /** - * Update state for the exact method-scoped mint quote row. + * Update canonical BOLT11 accounting from a legacy state observation. + * + * @deprecated Persist a canonical quote observation with `amountPaid` and `amountIssued`. */ setMintQuoteState( mintUrl: string, @@ -352,6 +359,24 @@ export interface PaymentRequestReceiveAttemptRepository { delete(id: string): Promise; } +export interface MintSwapOperationRepository { + create(operation: MintSwapOperation): Promise; + getById(id: string): Promise; + getByState(state: MintSwapOperationState): Promise; + getActive(): Promise; + getDue(now: number, limit: number): Promise; + getByDestinationMintOperationId(id: string): Promise; + getBySourceMeltOperationId(id: string): Promise; + compareAndSet(operation: MintSwapOperation, expectedRevision: number): Promise; +} + +export interface OperationEventOutboxRepository { + enqueue(event: OperationEventOutboxRecord): Promise; + getUnpublished(limit: number, now?: number): Promise; + markPublished(id: string, publishedAt: number): Promise; + recordPublishFailure(id: string, nextAttemptAt: number, lastError: string): Promise; +} + interface RepositoriesBase { mintRepository: MintRepository; keyRingRepository: KeyRingRepository; @@ -369,6 +394,8 @@ interface RepositoriesBase { receiveOperationRepository: ReceiveOperationRepository; paymentRequestReceiveOperationRepository: PaymentRequestReceiveOperationRepository; paymentRequestReceiveAttemptRepository: PaymentRequestReceiveAttemptRepository; + mintSwapOperationRepository: MintSwapOperationRepository; + operationEventOutboxRepository: OperationEventOutboxRepository; } export interface Repositories extends RepositoriesBase { diff --git a/packages/core/repositories/memory/MemoryHistoryRepository.ts b/packages/core/repositories/memory/MemoryHistoryRepository.ts index 4e7b86af3..6fe897dd7 100644 --- a/packages/core/repositories/memory/MemoryHistoryRepository.ts +++ b/packages/core/repositories/memory/MemoryHistoryRepository.ts @@ -163,7 +163,7 @@ export class MemoryHistoryRepository implements HistoryProjectionRepository { const quoteKeys = new Set(); for (const entry of operationEntries) { - if (entry.source !== 'operation') continue; + if (entry.source !== 'operation' || entry.type === 'mint-swap') continue; operationKeys.add(this.operationKey(entry.type, entry.operationId)); if ((entry.type === 'mint' || entry.type === 'melt') && entry.quoteId) { quoteKeys.add(this.quoteKey(entry.type, entry.mintUrl, entry.quoteId)); diff --git a/packages/core/repositories/memory/MemoryMeltOperationRepository.ts b/packages/core/repositories/memory/MemoryMeltOperationRepository.ts index e237b5cb6..3284bc69c 100644 --- a/packages/core/repositories/memory/MemoryMeltOperationRepository.ts +++ b/packages/core/repositories/memory/MemoryMeltOperationRepository.ts @@ -16,9 +16,13 @@ export class MemoryMeltOperationRepository implements MeltOperationRepository { } async update(operation: MeltOperation): Promise { - if (!this.operations.has(operation.id)) { + const existing = this.operations.get(operation.id); + if (!existing) { throw new Error(`MeltOperation with id ${operation.id} not found`); } + if (existing.parentSwapOperationId !== operation.parentSwapOperationId) { + throw new Error(`Cannot change parent ownership of MeltOperation ${operation.id}`); + } this.assertNoDuplicateQuoteOperation(operation); this.operations.set(operation.id, { ...operation, updatedAt: Date.now() }); } diff --git a/packages/core/repositories/memory/MemoryMintOperationRepository.ts b/packages/core/repositories/memory/MemoryMintOperationRepository.ts index 8b88854c6..cc4ea4bfd 100644 --- a/packages/core/repositories/memory/MemoryMintOperationRepository.ts +++ b/packages/core/repositories/memory/MemoryMintOperationRepository.ts @@ -12,9 +12,13 @@ export class MemoryMintOperationRepository implements MintOperationRepository { } async update(operation: MintOperation): Promise { - if (!this.operations.has(operation.id)) { + const existing = this.operations.get(operation.id); + if (!existing) { throw new Error(`MintOperation with id ${operation.id} not found`); } + if (existing.parentSwapOperationId !== operation.parentSwapOperationId) { + throw new Error(`Cannot change parent ownership of MintOperation ${operation.id}`); + } this.operations.set(operation.id, { ...operation, updatedAt: Date.now() }); } diff --git a/packages/core/repositories/memory/MemoryMintQuoteRepository.ts b/packages/core/repositories/memory/MemoryMintQuoteRepository.ts index 69574c70d..8b8aeb616 100644 --- a/packages/core/repositories/memory/MemoryMintQuoteRepository.ts +++ b/packages/core/repositories/memory/MemoryMintQuoteRepository.ts @@ -1,6 +1,10 @@ -import { Amount } from '@cashu/cashu-ts'; import { QuoteIdentityConflictError } from '@core/models/Error'; -import { isMintQuotePending, isStatefulMintQuote, type MintQuote } from '@core/models/MintQuote'; +import { + applyBolt11MintQuoteStateFallback, + isMintQuotePending, + isStatefulMintQuote, + type MintQuote, +} from '@core/models/MintQuote'; import type { QuoteIdentity } from '@core/models/QuoteIdentity'; import type { MintMethodRemoteState } from '@core/operations/mint/MintMethodHandler'; import type { MintQuoteRepository } from '..'; @@ -73,13 +77,7 @@ export class MemoryMintQuoteRepository implements MintQuoteRepository { const existing = this.quotes.get(key); if (!existing) return; if (!isStatefulMintQuote(existing)) return; - this.quotes.set(key, { - ...existing, - state, - amountPaid: state === 'UNPAID' ? Amount.zero() : existing.amount, - amountIssued: state === 'ISSUED' ? existing.amount : Amount.zero(), - updatedAt: observedAt, - }); + this.quotes.set(key, applyBolt11MintQuoteStateFallback(existing, state, observedAt)); } async getPendingMintQuotes(method?: string): Promise { diff --git a/packages/core/repositories/memory/MemoryMintSwapOperationRepository.ts b/packages/core/repositories/memory/MemoryMintSwapOperationRepository.ts new file mode 100644 index 000000000..83a9459cc --- /dev/null +++ b/packages/core/repositories/memory/MemoryMintSwapOperationRepository.ts @@ -0,0 +1,117 @@ +import type { MintSwapOperationRepository } from '..'; +import { + assertMintSwapTransition, + assertPreparedMintSwapImmutable, + isAutomaticMintSwapState, + isTerminalMintSwapState, + validateMintSwapOperation, + type MintSwapOperation, + type MintSwapOperationState, +} from '../../operations/mintSwap/MintSwapOperation'; +import { cloneMemoryValue } from './clone'; + +export class MemoryMintSwapOperationRepository implements MintSwapOperationRepository { + private readonly operations = new Map(); + + async create(operation: MintSwapOperation): Promise { + validateMintSwapOperation(operation); + if (operation.revision !== 0) { + throw new Error('New mint swap operation must start at revision 0'); + } + if (this.operations.has(operation.id)) { + throw new Error(`Mint swap operation with id ${operation.id} already exists`); + } + this.assertUniqueOwnership(operation); + this.operations.set(operation.id, cloneMemoryValue(operation)); + } + + async getById(id: string): Promise { + const operation = this.operations.get(id); + return operation ? cloneMemoryValue(operation) : null; + } + + async getByState(state: MintSwapOperationState): Promise { + return this.sorted((operation) => operation.state === state); + } + + async getActive(): Promise { + return this.sorted((operation) => !isTerminalMintSwapState(operation.state)); + } + + async getDue(now: number, limit: number): Promise { + if (!Number.isSafeInteger(now) || now < 0) throw new Error('Due time must be non-negative'); + if (!Number.isSafeInteger(limit) || limit < 0) + throw new Error('Due limit must be non-negative'); + return this.sorted( + (operation) => + isAutomaticMintSwapState(operation.state) && (operation.retry.nextAttemptAt ?? 0) <= now, + true, + ).slice(0, limit); + } + + async getByDestinationMintOperationId(id: string): Promise { + return this.findByChild('destinationMintOperationId', id); + } + + async getBySourceMeltOperationId(id: string): Promise { + return this.findByChild('sourceMeltOperationId', id); + } + + async compareAndSet(operation: MintSwapOperation, expectedRevision: number): Promise { + const current = this.operations.get(operation.id); + if (!current || current.revision !== expectedRevision) return false; + if (operation.revision !== expectedRevision + 1) { + throw new Error('Mint swap compare-and-set must advance revision exactly once'); + } + assertMintSwapTransition(current.state, operation.state); + assertPreparedMintSwapImmutable(current, operation); + validateMintSwapOperation(operation); + this.assertUniqueOwnership(operation); + this.operations.set(operation.id, cloneMemoryValue(operation)); + return true; + } + + private async findByChild( + field: 'destinationMintOperationId' | 'sourceMeltOperationId', + id: string, + ): Promise { + for (const operation of this.operations.values()) { + if (operation[field] === id) return cloneMemoryValue(operation); + } + return null; + } + + private assertUniqueOwnership(candidate: MintSwapOperation): void { + for (const operation of this.operations.values()) { + if (operation.id === candidate.id) continue; + if ( + candidate.destinationMintOperationId && + operation.destinationMintOperationId === candidate.destinationMintOperationId + ) { + throw new Error('Destination mint operation is already owned by another mint swap'); + } + if ( + candidate.sourceMeltOperationId && + operation.sourceMeltOperationId === candidate.sourceMeltOperationId + ) { + throw new Error('Source melt operation is already owned by another mint swap'); + } + } + } + + private sorted( + predicate: (operation: MintSwapOperation) => boolean, + dueOrder = false, + ): MintSwapOperation[] { + return Array.from(this.operations.values()) + .filter(predicate) + .sort((left, right) => { + if (dueOrder) { + const due = (left.retry.nextAttemptAt ?? 0) - (right.retry.nextAttemptAt ?? 0); + if (due !== 0) return due; + } + return left.createdAt - right.createdAt || left.id.localeCompare(right.id); + }) + .map((operation) => cloneMemoryValue(operation)); + } +} diff --git a/packages/core/repositories/memory/MemoryOperationEventOutboxRepository.ts b/packages/core/repositories/memory/MemoryOperationEventOutboxRepository.ts new file mode 100644 index 000000000..95c1f12bb --- /dev/null +++ b/packages/core/repositories/memory/MemoryOperationEventOutboxRepository.ts @@ -0,0 +1,73 @@ +import type { OperationEventOutboxRepository } from '..'; +import { + operationEventLogicalKey, + type OperationEventOutboxRecord, +} from '../../models/OperationEventOutbox'; +import { cloneMemoryValue } from './clone'; + +export class MemoryOperationEventOutboxRepository implements OperationEventOutboxRepository { + private readonly events = new Map(); + + async enqueue(event: OperationEventOutboxRecord): Promise { + validateEvent(event); + if (this.events.has(event.id)) { + throw new Error(`Operation event outbox record with id ${event.id} already exists`); + } + const logicalKey = operationEventLogicalKey(event); + for (const existing of this.events.values()) { + if (operationEventLogicalKey(existing) === logicalKey) { + throw new Error('Operation event outbox logical key already exists'); + } + } + this.events.set(event.id, cloneMemoryValue(event)); + } + + async getUnpublished(limit: number, now = Date.now()): Promise { + if (!Number.isSafeInteger(limit) || limit < 0) + throw new Error('Outbox limit must be non-negative'); + return Array.from(this.events.values()) + .filter((event) => !event.publishedAt && (event.nextAttemptAt ?? 0) <= now) + .sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id)) + .slice(0, limit) + .map((event) => cloneMemoryValue(event)); + } + + async markPublished(id: string, publishedAt: number): Promise { + const event = this.requireEvent(id); + if (event.publishedAt) return; + this.events.set(id, { ...event, publishedAt, lastError: undefined, nextAttemptAt: undefined }); + } + + async recordPublishFailure(id: string, nextAttemptAt: number, lastError: string): Promise { + const event = this.requireEvent(id); + if (event.publishedAt) return; + this.events.set(id, { + ...event, + publishAttempts: event.publishAttempts + 1, + nextAttemptAt, + lastError, + }); + } + + private requireEvent(id: string): OperationEventOutboxRecord { + const event = this.events.get(id); + if (!event) throw new Error(`Operation event outbox record with id ${id} not found`); + return event; + } +} + +function validateEvent(event: OperationEventOutboxRecord): void { + if (!event.id || !event.operationId) throw new Error('Outbox record identity is required'); + if (!Number.isSafeInteger(event.revision) || event.revision < 0) { + throw new Error('Outbox revision must be a non-negative safe integer'); + } + if (!Number.isSafeInteger(event.publishAttempts) || event.publishAttempts < 0) { + throw new Error('Outbox publish attempts must be a non-negative safe integer'); + } + if ( + event.payload.operationId !== event.operationId || + event.payload.revision !== event.revision + ) { + throw new Error('Outbox payload identity must match its logical event key'); + } +} diff --git a/packages/core/repositories/memory/MemoryRepositories.ts b/packages/core/repositories/memory/MemoryRepositories.ts index 65b72e67f..66e17c6ec 100644 --- a/packages/core/repositories/memory/MemoryRepositories.ts +++ b/packages/core/repositories/memory/MemoryRepositories.ts @@ -17,6 +17,8 @@ import type { PaymentRequestReceiveAttemptRepository, PaymentRequestReceiveOperationRepository, ReceiveOperationRepository, + MintSwapOperationRepository, + OperationEventOutboxRepository, } from '..'; import { MemoryAuthSessionRepository } from './MemoryAuthSessionRepository'; import { MemoryCounterRepository } from './MemoryCounterRepository'; @@ -36,6 +38,9 @@ import { MemoryPaymentRequestReceiveAttemptRepository, MemoryPaymentRequestReceiveOperationRepository, } from './MemoryPaymentRequestReceiveRepository'; +import { MemoryMintSwapOperationRepository } from './MemoryMintSwapOperationRepository'; +import { MemoryOperationEventOutboxRepository } from './MemoryOperationEventOutboxRepository'; +import { copyMemoryRepositoryState } from './clone'; export class MemoryRepositories implements Repositories { mintRepository: MintRepository; @@ -54,6 +59,10 @@ export class MemoryRepositories implements Repositories { receiveOperationRepository: ReceiveOperationRepository; paymentRequestReceiveOperationRepository: PaymentRequestReceiveOperationRepository; paymentRequestReceiveAttemptRepository: PaymentRequestReceiveAttemptRepository; + mintSwapOperationRepository: MintSwapOperationRepository; + operationEventOutboxRepository: OperationEventOutboxRepository; + + private transactionTail: Promise = Promise.resolve(); constructor() { this.mintRepository = new MemoryMintRepository(); @@ -85,6 +94,8 @@ export class MemoryRepositories implements Repositories { new MemoryPaymentRequestReceiveOperationRepository(); this.paymentRequestReceiveAttemptRepository = new MemoryPaymentRequestReceiveAttemptRepository(); + this.mintSwapOperationRepository = new MemoryMintSwapOperationRepository(); + this.operationEventOutboxRepository = new MemoryOperationEventOutboxRepository(); } async init(): Promise { @@ -92,6 +103,51 @@ export class MemoryRepositories implements Repositories { } async withTransaction(fn: (repos: RepositoryTransactionScope) => Promise): Promise { - return fn(this); + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + + try { + const staged = new MemoryRepositories(); + this.copyRepositoryStates(this, staged); + const result = await fn(staged); + this.copyRepositoryStates(staged, this); + return result; + } finally { + release(); + } + } + + private copyRepositoryStates(source: MemoryRepositories, target: MemoryRepositories): void { + const repositoryKeys: Array = [ + 'mintRepository', + 'keyRingRepository', + 'counterRepository', + 'keysetRepository', + 'proofRepository', + 'mintQuoteRepository', + 'legacyMintQuoteRepository', + 'meltQuoteRepository', + 'historyRepository', + 'sendOperationRepository', + 'meltOperationRepository', + 'authSessionRepository', + 'mintOperationRepository', + 'receiveOperationRepository', + 'paymentRequestReceiveOperationRepository', + 'paymentRequestReceiveAttemptRepository', + 'mintSwapOperationRepository', + 'operationEventOutboxRepository', + ]; + for (const key of repositoryKeys) { + copyMemoryRepositoryState( + source[key], + target[key], + key === 'historyRepository' ? ['operationRepositories'] : [], + ); + } } } diff --git a/packages/core/repositories/memory/clone.ts b/packages/core/repositories/memory/clone.ts new file mode 100644 index 000000000..74719f93e --- /dev/null +++ b/packages/core/repositories/memory/clone.ts @@ -0,0 +1,49 @@ +export function cloneMemoryValue(value: T, seen = new Map()): T { + if (value === null || typeof value !== 'object') return value; + if (seen.has(value)) return seen.get(value) as T; + + if (value instanceof Map) { + const result = new Map(); + seen.set(value, result); + for (const [key, item] of value) { + result.set(cloneMemoryValue(key, seen), cloneMemoryValue(item, seen)); + } + return result as T; + } + if (value instanceof Set) { + const result = new Set(); + seen.set(value, result); + for (const item of value) result.add(cloneMemoryValue(item, seen)); + return result as T; + } + if (Array.isArray(value)) { + const result: unknown[] = []; + seen.set(value, result); + for (const item of value) result.push(cloneMemoryValue(item, seen)); + return result as T; + } + if (value instanceof Date) return new Date(value.getTime()) as T; + + const result = Object.create(Object.getPrototypeOf(value)) as Record; + seen.set(value, result); + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor) continue; + if ('value' in descriptor) descriptor.value = cloneMemoryValue(descriptor.value, seen); + Object.defineProperty(result, key, descriptor); + } + return result as T; +} + +export function copyMemoryRepositoryState( + source: object, + target: object, + excludedKeys: readonly string[] = [], +): void { + const excluded = new Set(excludedKeys); + const sourceRecord = source as Record; + const targetRecord = target as Record; + for (const key of Object.keys(sourceRecord)) { + if (!excluded.has(key)) targetRecord[key] = cloneMemoryValue(sourceRecord[key]); + } +} diff --git a/packages/core/repositories/memory/index.ts b/packages/core/repositories/memory/index.ts index f959a0f6b..f22108204 100644 --- a/packages/core/repositories/memory/index.ts +++ b/packages/core/repositories/memory/index.ts @@ -13,5 +13,7 @@ export * from './MemorySendOperationRepository'; export * from './MemoryMeltOperationRepository'; export * from './MemoryMeltQuoteRepository'; export * from './MemoryMintOperationRepository'; +export * from './MemoryMintSwapOperationRepository'; +export * from './MemoryOperationEventOutboxRepository'; export * from './MemoryReceiveOperationRepository'; export * from './MemoryPaymentRequestReceiveRepository'; diff --git a/packages/core/services/HistoryService.ts b/packages/core/services/HistoryService.ts index ab2202800..3644fcea9 100644 --- a/packages/core/services/HistoryService.ts +++ b/packages/core/services/HistoryService.ts @@ -1,10 +1,16 @@ -import type { HistoryProjectionRepository } from '../repositories'; +import type { HistoryProjectionRepository, MintSwapOperationRepository } from '../repositories'; import { EventBus } from '../events/EventBus'; import type { CoreEvents } from '../events/types'; -import type { HistoryEntry, OperationHistoryEntry } from '@core/models/History'; +import type { + HistoryFilter, + HistoryEntry, + MintSwapHistoryEntry, + OperationHistoryEntry, +} from '@core/models/History'; import { projectMeltOperation, projectMintOperation, + projectMintSwapOperation, projectReceiveOperation, projectSendOperation, } from '@core/models/History'; @@ -24,6 +30,7 @@ export class HistoryService { historyRepository: HistoryProjectionRepository, eventBus: EventBus, logger?: Logger, + private readonly mintSwapRepository?: MintSwapOperationRepository, ) { this.historyRepository = historyRepository; this.logger = logger; @@ -74,16 +81,94 @@ export class HistoryService { this.eventBus.on('receive-op:rolled-back', ({ mintUrl, operation }) => { return this.emitProjectedReceive(mintUrl, operation); }); + + const emitMintSwap = async ({ operationId }: { operationId: string }) => { + const operation = await this.mintSwapRepository?.getById(operationId); + if (!operation) return; + await this.eventBus.emit('history:updated', { + mintUrl: operation.sourceMintUrl, + entry: projectMintSwapOperation(operation), + }); + }; + this.eventBus.on('mint-swap-op:prepared', emitMintSwap); + this.eventBus.on('mint-swap-op:source-inflight', emitMintSwap); + this.eventBus.on('mint-swap-op:destination-funded', emitMintSwap); + this.eventBus.on('mint-swap-op:issuing', emitMintSwap); + this.eventBus.on('mint-swap-op:completed', emitMintSwap); + this.eventBus.on('mint-swap-op:cancelled', emitMintSwap); + this.eventBus.on('mint-swap-op:failed', emitMintSwap); + this.eventBus.on('mint-swap-op:needs-attention', emitMintSwap); } - async getPaginatedHistory(offset = 0, limit = 25): Promise { - return this.historyRepository.getPaginatedHistoryEntries(limit, offset); + async getPaginatedHistory( + offset = 0, + limit = 25, + filter: HistoryFilter = {}, + ): Promise { + if (!this.mintSwapRepository) { + if (!filter.mintUrl && !filter.types) { + return this.historyRepository.getPaginatedHistoryEntries(limit, offset); + } + const entries = await this.historyRepository.getPaginatedHistoryEntries(10_000, 0); + return entries + .filter((entry) => matchesHistoryFilter(entry, filter)) + .slice(offset, offset + limit); + } + const [children, parents] = await Promise.all([ + this.historyRepository.getPaginatedHistoryEntries(10_000, 0), + this.getMintSwapHistory(), + ]); + const visibleChildren: HistoryEntry[] = []; + for (const entry of children) { + if ( + entry.source === 'operation' && + entry.type === 'mint' && + (await this.mintSwapRepository.getByDestinationMintOperationId(entry.operationId)) + ) { + continue; + } + if ( + entry.source === 'operation' && + entry.type === 'melt' && + (await this.mintSwapRepository.getBySourceMeltOperationId(entry.operationId)) + ) { + continue; + } + visibleChildren.push(entry); + } + return [...visibleChildren, ...parents] + .filter((entry) => matchesHistoryFilter(entry, filter)) + .sort((left, right) => right.createdAt - left.createdAt || right.id.localeCompare(left.id)) + .slice(offset, offset + limit); } async getHistoryEntryById(id: string): Promise { + if (id.startsWith('mint-swap:') && this.mintSwapRepository) { + const operation = await this.mintSwapRepository.getById(id.slice('mint-swap:'.length)); + return operation ? projectMintSwapOperation(operation) : null; + } return this.historyRepository.getHistoryEntryById(id); } + private async getMintSwapHistory(): Promise { + if (!this.mintSwapRepository) return []; + const states = [ + 'preparing', + 'prepared', + 'source_inflight', + 'destination_funded', + 'issuing', + 'completed', + 'cancelled', + 'failed', + 'needs_attention', + ] as const; + const operations = ( + await Promise.all(states.map((state) => this.mintSwapRepository!.getByState(state))) + ).flat(); + return operations.map(projectMintSwapOperation); + } + /** * Get the operationId for a send history entry. * @throws Error if entry not found, is not a send entry, or has no operation id @@ -154,3 +239,12 @@ export class HistoryService { return operation; } } + +function matchesHistoryFilter(entry: HistoryEntry, filter: HistoryFilter): boolean { + if (filter.types && !filter.types.includes(entry.type)) return false; + if (!filter.mintUrl) return true; + if (entry.type === 'mint-swap') { + return entry.sourceMintUrl === filter.mintUrl || entry.destinationMintUrl === filter.mintUrl; + } + return entry.mintUrl === filter.mintUrl; +} diff --git a/packages/core/services/MintService.ts b/packages/core/services/MintService.ts index 8a04d8660..a5ab31b28 100644 --- a/packages/core/services/MintService.ts +++ b/packages/core/services/MintService.ts @@ -111,9 +111,11 @@ type NutMethodSettings = { }; type NutSupportSettings = { - supported?: boolean; + supported?: unknown; }; +export type TopLevelNutCapability = 7 | 8 | 9 | 11 | 17 | 20; + export class MintService { private readonly mintRepo: MintRepository; private readonly keysetRepo: KeysetRepository; @@ -258,16 +260,20 @@ export class MintService { /** * Returns whether a mint advertises support for a top-level NUT capability. * - * This currently supports NUT-11 checks only. Mint information is resolved via + * Supports boolean top-level capability metadata used by recovery and security + * preflight. Mint information is resolved via * `getMintInfo()`, so stale local records may be refreshed and fetch failures * propagate to the caller. Missing, malformed, or disabled settings return * `false` rather than throwing. */ - async supportsNut(mintUrl: string, nut: 11): Promise { + async supportsNut(mintUrl: string, nut: TopLevelNutCapability): Promise { this.assertSupportCapabilityNut(nut); const normalizedMintUrl = normalizeMintUrl(mintUrl); const mintInfo = await this.getMintInfo(normalizedMintUrl); const settings = this.getNutSupportSettings(mintInfo, nut); + if (nut === 17) { + return Array.isArray(settings?.supported) && settings.supported.length > 0; + } return settings?.supported === true; } @@ -303,11 +309,14 @@ export class MintService { /** * Requires a mint to advertise a top-level NUT capability. * - * This currently supports NUT-11 checks only. Returns when support is - * advertised, throws `ProofValidationError` when support is absent, and lets - * mint-info refresh/fetch failures propagate unchanged. + * Returns when support is advertised, throws `ProofValidationError` when + * support is absent, and lets mint-info refresh/fetch failures propagate. */ - async assertNutSupported(mintUrl: string, nut: 11, scope?: string): Promise { + async assertNutSupported( + mintUrl: string, + nut: TopLevelNutCapability, + scope?: string, + ): Promise { if (await this.supportsNut(mintUrl, nut)) { return; } @@ -513,7 +522,10 @@ export class MintService { return nuts?.[String(nut)] as NutMethodSettings | undefined; } - private getNutSupportSettings(mintInfo: MintInfo, nut: 11): NutSupportSettings | undefined { + private getNutSupportSettings( + mintInfo: MintInfo, + nut: TopLevelNutCapability, + ): NutSupportSettings | undefined { const nuts = mintInfo.nuts as Record | undefined; const settings = nuts?.[String(nut)]; if (!settings || typeof settings !== 'object') { @@ -530,8 +542,8 @@ export class MintService { } } - private assertSupportCapabilityNut(nut: number): asserts nut is 11 { - if (nut !== 11) { + private assertSupportCapabilityNut(nut: number): asserts nut is TopLevelNutCapability { + if (![7, 8, 9, 11, 17, 20].includes(nut)) { throw new ProofValidationError(`NUT-${nut} support capability checks are not implemented`); } } diff --git a/packages/core/services/OperationEventOutboxPublisher.ts b/packages/core/services/OperationEventOutboxPublisher.ts new file mode 100644 index 000000000..d08967b26 --- /dev/null +++ b/packages/core/services/OperationEventOutboxPublisher.ts @@ -0,0 +1,61 @@ +import type { EventBus, CoreEvents } from '../events/index.ts'; +import type { Logger } from '../logging/Logger.ts'; +import { redactError } from '../logging/redaction.ts'; +import type { MintSwapEventType } from '../operations/mintSwap/MintSwapOperation.ts'; +import type { OperationEventOutboxRepository } from '../repositories/index.ts'; + +export interface OperationEventOutboxPublisherOptions { + batchSize?: number; + baseRetryDelayMs?: number; + maxRetryDelayMs?: number; + /** @internal Deterministic jitter source for tests. */ + random?: () => number; +} + +export class OperationEventOutboxPublisher { + private readonly batchSize: number; + private readonly baseRetryDelayMs: number; + private readonly maxRetryDelayMs: number; + private readonly random: () => number; + + constructor( + private readonly repository: OperationEventOutboxRepository, + private readonly bus: EventBus, + private readonly logger?: Logger, + options: OperationEventOutboxPublisherOptions = {}, + ) { + this.batchSize = options.batchSize ?? 50; + this.baseRetryDelayMs = options.baseRetryDelayMs ?? 1_000; + this.maxRetryDelayMs = options.maxRetryDelayMs ?? 60_000; + this.random = options.random ?? Math.random; + } + + async publishDue(now = Date.now()): Promise { + const records = await this.repository.getUnpublished(this.batchSize, now); + for (const record of records) { + try { + await this.emit(record.eventType, record.payload); + await this.repository.markPublished(record.id, Date.now()); + } catch (error) { + const retryCeiling = Math.min( + this.maxRetryDelayMs, + this.baseRetryDelayMs * 2 ** Math.min(record.publishAttempts, 16), + ); + const delay = Math.floor(this.random() * Math.max(1, retryCeiling)); + const message = redactError(error); + await this.repository.recordPublishFailure(record.id, now + delay, message); + this.logger?.warn('Mint swap outbox publication delayed', { + operationId: record.operationId, + revision: record.revision, + eventType: record.eventType, + error: message, + }); + } + } + return records.length; + } + + private emit(event: MintSwapEventType, payload: CoreEvents[MintSwapEventType]): Promise { + return this.bus.emit(event, payload, { throwOnError: true }); + } +} diff --git a/packages/core/services/ProofService.ts b/packages/core/services/ProofService.ts index 2ca742374..8860e5ea3 100644 --- a/packages/core/services/ProofService.ts +++ b/packages/core/services/ProofService.ts @@ -19,9 +19,10 @@ import type { BalancesByUnit, CoreProof, } from '../types'; -import type { CounterService } from './CounterService'; +import { CounterService } from './CounterService'; import type { ProofUnitFilter } from '../repositories'; import type { ProofRepository } from '../repositories'; +import type { RepositoryTransactionScope } from '../repositories'; import { EventBus } from '../events/EventBus'; import type { CoreEvents } from '../events/types'; import { ProofOperationError, ProofValidationError } from '../models/Error'; @@ -81,6 +82,24 @@ export class ProofService { this.outputDataCreator = outputDataCreator ?? OutputData; } + /** + * Create an equivalent proof service whose local writes are confined to a repository + * transaction. Events are intentionally suppressed until the caller commits an outbox record. + */ + forTransaction(repositories: RepositoryTransactionScope): ProofService { + return new ProofService( + new CounterService(repositories.counterRepository, this.logger), + repositories.proofRepository, + this.walletService, + this.mintService, + this.keyRingService, + this.seedService, + this.logger, + undefined, + this.outputDataCreator, + ); + } + /** * Calculates the send amount including receiver fees. * This is used when the sender pays fees for the receiver. diff --git a/packages/core/services/index.ts b/packages/core/services/index.ts index d07dc6aea..f1dee4135 100644 --- a/packages/core/services/index.ts +++ b/packages/core/services/index.ts @@ -6,6 +6,7 @@ export * from './KeyRingService'; export * from './MintService'; export * from './PaymentRequestService'; export * from './PaymentRequestReceiveService'; +export * from './OperationEventOutboxPublisher'; export * from './ProofService'; export * from './SeedService'; export * from './WalletRestoreService'; diff --git a/packages/core/services/watchers/MintOperationProcessor.ts b/packages/core/services/watchers/MintOperationProcessor.ts index c2225310b..4b3b77203 100644 --- a/packages/core/services/watchers/MintOperationProcessor.ts +++ b/packages/core/services/watchers/MintOperationProcessor.ts @@ -2,7 +2,7 @@ import type { EventBus, CoreEvents } from '@core/events'; import type { Logger } from '../../logging/Logger.ts'; import type { MintMethod, MintOperationService } from '@core/operations/mint'; import { MintOperationError, NetworkError } from '../../models/Error'; -import { getMintQuoteRemoteState } from '../../models/MintQuote.ts'; +import { isBolt11MintQuotePaid, isStatefulMintQuote } from '../../models/MintQuote.ts'; import type { QuoteLifecycle } from '../../quotes/QuoteLifecycle.ts'; interface QueueItem { @@ -106,7 +106,7 @@ export class MintOperationProcessor { return; } - if (getMintQuoteRemoteState(quote) !== 'PAID') { + if (!isStatefulMintQuote(quote) || !isBolt11MintQuotePaid(quote)) { return; } @@ -139,7 +139,7 @@ export class MintOperationProcessor { return; } - if (quote && getMintQuoteRemoteState(quote) === 'PAID') { + if (quote && isStatefulMintQuote(quote) && isBolt11MintQuotePaid(quote)) { this.enqueue(mintUrl, operation.id, operation.method); } }); diff --git a/packages/core/services/watchers/MintOperationWatcherService.ts b/packages/core/services/watchers/MintOperationWatcherService.ts index ea2b1315d..1d451a5fa 100644 --- a/packages/core/services/watchers/MintOperationWatcherService.ts +++ b/packages/core/services/watchers/MintOperationWatcherService.ts @@ -1,3 +1,4 @@ +import { Amount } from '@cashu/cashu-ts'; import type { EventBus, CoreEvents } from '@core/events'; import type { Logger } from '../../logging/Logger.ts'; import type { SubscriptionManager, UnsubscribeHandler } from '@core/infra/SubscriptionManager.ts'; @@ -27,14 +28,23 @@ interface MintQuoteWatchPolicy { keepWatchingWithoutOperationInterest?: boolean; } +function hasBolt11CompletedIssuance(payload: MintMethodQuoteSnapshot<'bolt11'>): boolean { + if (payload.amount_paid === undefined || payload.amount_issued === undefined) return false; + const amount = Amount.from(payload.amount); + const amountIssued = Amount.from(payload.amount_issued); + return amountIssued.greaterThanOrEqual(amount); +} + const mintQuoteWatchPolicies: { [M in MintMethod]?: MintQuoteWatchPolicy; } = { bolt11: { subscriptionKind: 'bolt11_mint_quote', getPayloadQuoteId: (payload) => payload.quote, - shouldRecordPayload: (payload) => payload.state === 'PAID' || payload.state === 'ISSUED', - shouldStopWatching: (payload) => payload.state === 'ISSUED' || isMintQuoteExpired(payload), + shouldRecordPayload: (payload) => + payload.amount_paid !== undefined && payload.amount_issued !== undefined, + shouldStopWatching: (payload) => + hasBolt11CompletedIssuance(payload) || isMintQuoteExpired(payload), }, onchain: { subscriptionKind: 'onchain_mint_quote', diff --git a/packages/core/services/watchers/MintSwapOperationProcessor.ts b/packages/core/services/watchers/MintSwapOperationProcessor.ts new file mode 100644 index 000000000..615a8bff3 --- /dev/null +++ b/packages/core/services/watchers/MintSwapOperationProcessor.ts @@ -0,0 +1,220 @@ +import type { EventBus, CoreEvents } from '../../events/index.ts'; +import type { Logger } from '../../logging/Logger.ts'; +import { redactError } from '../../logging/redaction.ts'; +import type { MintSwapOperationService } from '../../operations/mintSwap/index.ts'; +import type { Repositories } from '../../repositories/index.ts'; +import { OperationInProgressError } from '../../models/Error.ts'; +import { OperationEventOutboxPublisher } from '../OperationEventOutboxPublisher.ts'; + +export interface MintSwapOperationProcessorOptions { + sweepIntervalMs?: number; + dueBatchSize?: number; + /** @deprecated Use the state-specific retry delay options. */ + baseRetryDelayMs?: number; + /** @deprecated Use the state-specific retry delay options. */ + maxRetryDelayMs?: number; + sourceBaseRetryDelayMs?: number; + sourceMaxRetryDelayMs?: number; + postPaymentBaseRetryDelayMs?: number; + postPaymentMaxRetryDelayMs?: number; + outboxBaseRetryDelayMs?: number; + outboxMaxRetryDelayMs?: number; + /** @internal Deterministic jitter source for tests. */ + random?: () => number; +} + +export class MintSwapOperationProcessor { + private readonly sweepIntervalMs: number; + private readonly dueBatchSize: number; + private readonly sourceBaseRetryDelayMs: number; + private readonly sourceMaxRetryDelayMs: number; + private readonly postPaymentBaseRetryDelayMs: number; + private readonly postPaymentMaxRetryDelayMs: number; + private readonly random: () => number; + private readonly outbox: OperationEventOutboxPublisher; + private running = false; + private sweeping = false; + private timer?: ReturnType; + private readonly tasks = new Set>(); + private readonly queued = new Set(); + private readonly offs: Array<() => void> = []; + + constructor( + private readonly service: MintSwapOperationService, + private readonly repositories: Repositories, + private readonly bus: EventBus, + private readonly logger?: Logger, + options: MintSwapOperationProcessorOptions = {}, + ) { + this.sweepIntervalMs = options.sweepIntervalMs ?? 5_000; + this.dueBatchSize = options.dueBatchSize ?? 50; + this.sourceBaseRetryDelayMs = + options.sourceBaseRetryDelayMs ?? options.baseRetryDelayMs ?? 1_000; + this.sourceMaxRetryDelayMs = options.sourceMaxRetryDelayMs ?? options.maxRetryDelayMs ?? 30_000; + this.postPaymentBaseRetryDelayMs = + options.postPaymentBaseRetryDelayMs ?? options.baseRetryDelayMs ?? 2_000; + this.postPaymentMaxRetryDelayMs = + options.postPaymentMaxRetryDelayMs ?? options.maxRetryDelayMs ?? 300_000; + this.random = options.random ?? Math.random; + this.outbox = new OperationEventOutboxPublisher( + repositories.operationEventOutboxRepository, + bus, + logger, + { + baseRetryDelayMs: options.outboxBaseRetryDelayMs, + maxRetryDelayMs: options.outboxMaxRetryDelayMs, + random: options.random, + }, + ); + } + + isRunning(): boolean { + return this.running; + } + + async start(): Promise { + if (this.running) return; + this.running = true; + this.subscribeWakeups(); + await this.sweep(); + this.schedule(); + this.logger?.info('MintSwapOperationProcessor started'); + } + + async stop(): Promise { + if (!this.running) return; + this.running = false; + if (this.timer) clearTimeout(this.timer); + this.timer = undefined; + for (const off of this.offs.splice(0)) off(); + await Promise.allSettled(Array.from(this.tasks)); + this.logger?.info('MintSwapOperationProcessor stopped'); + } + + async recover(): Promise { + const active = await this.repositories.mintSwapOperationRepository.getActive(); + for (const operation of active) this.enqueue(operation.id); + await Promise.allSettled(Array.from(this.tasks)); + await this.outbox.publishDue(); + } + + async sweep(now = Date.now()): Promise { + if (this.sweeping) return; + this.sweeping = true; + try { + const due = await this.repositories.mintSwapOperationRepository.getDue( + now, + this.dueBatchSize, + ); + for (const operation of due) this.enqueue(operation.id); + await this.outbox.publishDue(now); + } finally { + this.sweeping = false; + } + } + + private subscribeWakeups(): void { + const wakeByMintChild = async ({ operationId }: { operationId: string }) => { + const parent = + await this.repositories.mintSwapOperationRepository.getByDestinationMintOperationId( + operationId, + ); + if (parent) this.enqueue(parent.id); + }; + const wakeByMeltChild = async ({ operationId }: { operationId: string }) => { + const parent = + await this.repositories.mintSwapOperationRepository.getBySourceMeltOperationId(operationId); + if (parent) this.enqueue(parent.id); + }; + this.offs.push( + this.bus.on('mint-op:finalized', wakeByMintChild), + this.bus.on('mint-op:failed', wakeByMintChild), + this.bus.on('melt-op:finalized', wakeByMeltChild), + this.bus.on('melt-op:rolled-back', wakeByMeltChild), + this.bus.on('mint-quote:updated', async ({ mintUrl, method, quoteId }) => { + const active = await this.repositories.mintSwapOperationRepository.getActive(); + for (const operation of active) { + const ref = operation.destinationQuoteRef; + if (ref?.mintUrl === mintUrl && ref.method === method && ref.quoteId === quoteId) { + this.enqueue(operation.id); + } + } + }), + this.bus.on('melt-quote:updated', async ({ mintUrl, method, quoteId }) => { + const active = await this.repositories.mintSwapOperationRepository.getActive(); + for (const operation of active) { + const ref = operation.sourceQuoteRef; + if (ref?.mintUrl === mintUrl && ref.method === method && ref.quoteId === quoteId) { + this.enqueue(operation.id); + } + } + }), + ); + } + + private enqueue(operationId: string): void { + if (!this.running || this.queued.has(operationId)) return; + this.queued.add(operationId); + const task = this.process(operationId).finally(() => { + this.queued.delete(operationId); + this.tasks.delete(task); + }); + this.tasks.add(task); + } + + private async process(operationId: string): Promise { + try { + await this.service.refresh(operationId); + await this.service.recordProcessorSuccess(operationId); + await this.outbox.publishDue(); + } catch (error) { + // A foreground command owns the parent lock. It will persist the next durable state and a + // later event/sweep can reconcile it; this is not a remote failure and needs no backoff. + if (error instanceof OperationInProgressError) return; + const operation = await this.service.get(operationId); + if ( + !operation || + operation.state === 'completed' || + operation.state === 'cancelled' || + operation.state === 'failed' || + operation.state === 'needs_attention' + ) { + return; + } + const attempt = operation.retry.attemptCount + 1; + const afterPayment = + operation.state === 'destination_funded' || operation.state === 'issuing'; + const baseDelay = afterPayment + ? this.postPaymentBaseRetryDelayMs + : this.sourceBaseRetryDelayMs; + const maxDelay = afterPayment ? this.postPaymentMaxRetryDelayMs : this.sourceMaxRetryDelayMs; + const retryCeiling = Math.min(maxDelay, baseDelay * 2 ** Math.min(attempt - 1, 16)); + const delay = Math.floor(this.random() * Math.max(1, retryCeiling)); + const message = redactError(error); + await this.service.recordProcessorFailure(operationId, message, Date.now() + delay); + this.logger?.warn('Mint swap reconciliation delayed', { + operationId, + attempt, + nextAttemptInMs: delay, + error: message, + }); + } + } + + private schedule(): void { + if (!this.running) return; + this.timer = setTimeout(() => { + const task = this.sweep() + .catch((error) => { + this.logger?.warn('Mint swap periodic sweep failed', { + error: redactError(error), + }); + }) + .finally(() => { + this.tasks.delete(task); + this.schedule(); + }); + this.tasks.add(task); + }, this.sweepIntervalMs); + } +} diff --git a/packages/core/services/watchers/index.ts b/packages/core/services/watchers/index.ts index 38b16e18b..8701154c2 100644 --- a/packages/core/services/watchers/index.ts +++ b/packages/core/services/watchers/index.ts @@ -1,5 +1,6 @@ export * from './MintOperationWatcherService'; export * from './MintOperationProcessor'; +export * from './MintSwapOperationProcessor'; export * from './MeltQuoteWatcherService'; export * from './MeltSettlementProcessor'; export * from './ProofStateWatcherService'; diff --git a/packages/core/test/fixtures/MintSwap.ts b/packages/core/test/fixtures/MintSwap.ts new file mode 100644 index 000000000..c18d92d95 --- /dev/null +++ b/packages/core/test/fixtures/MintSwap.ts @@ -0,0 +1,48 @@ +import { Amount } from '@cashu/cashu-ts'; + +import type { MintSwapOperation } from '../../operations/mintSwap/MintSwapOperation'; + +export const MINT_SWAP_TEST_NOW = 1_700_000_000_000; + +export function makePreparedMintSwapOperation( + overrides: Partial = {}, +): MintSwapOperation { + return { + id: 'swap-1', + state: 'prepared', + revision: 1, + sourceMintUrl: 'https://source.test', + destinationMintUrl: 'https://destination.test', + unit: 'sat', + destinationAmount: Amount.from(100), + destinationQuoteRef: { + mintUrl: 'https://destination.test', + method: 'bolt11', + quoteId: 'destination-quote', + }, + destinationMintOperationId: 'destination-child', + sourceQuoteRef: { + mintUrl: 'https://source.test', + method: 'bolt11', + quoteId: 'source-quote', + }, + sourceMeltOperationId: 'source-child', + destinationNut20Key: { publicKey: '02abcdef', derivationIndex: 7 }, + preparedPlan: { + fingerprint: 'prepared-fingerprint', + dispatchDeadline: Math.floor(MINT_SWAP_TEST_NOW / 1000) + 600, + requiredDispatchWindowSeconds: 120, + sourceMeltAmount: Amount.from(100), + sourceFeeReserve: Amount.from(8), + sourcePreparationFee: Amount.from(1), + sourceMeltInputFee: Amount.from(1), + minimumSourceDebit: Amount.from(102), + maximumSourceDebit: Amount.from(110), + reservedSourceAmount: Amount.from(110), + }, + retry: { attemptCount: 0 }, + createdAt: MINT_SWAP_TEST_NOW, + updatedAt: MINT_SWAP_TEST_NOW, + ...overrides, + }; +} diff --git a/packages/core/test/fixtures/MintSwapHttpFixture.ts b/packages/core/test/fixtures/MintSwapHttpFixture.ts new file mode 100644 index 000000000..b9652f44b --- /dev/null +++ b/packages/core/test/fixtures/MintSwapHttpFixture.ts @@ -0,0 +1,98 @@ +export type FixtureMeltState = 'UNPAID' | 'PENDING' | 'PAID'; +export type FixtureMintState = 'UNPAID' | 'PAID' | 'ISSUED'; +export type FixtureFailurePoint = + | 'melt:before' + | 'melt:after-commit' + | 'mint:before' + | 'mint:after-commit' + | 'restore:before'; + +export interface MintSwapFixtureCall { + path: string; + method: string; + body?: unknown; +} + +/** Deterministic local protocol boundary used by mint-swap ambiguity/restart tests. */ +export class MintSwapHttpFixture { + meltState: FixtureMeltState = 'UNPAID'; + mintState: FixtureMintState = 'UNPAID'; + meltChange: unknown[] = []; + issuedSignatures: unknown[] = []; + restoredSignatures: unknown[] = []; + readonly calls: MintSwapFixtureCall[] = []; + private readonly failures = new Map(); + private server?: ReturnType; + + get url(): string { + if (!this.server) throw new Error('Mint swap fixture is not running'); + return this.server.url.toString().replace(/\/$/, ''); + } + + failNext(point: FixtureFailurePoint, count = 1): void { + this.failures.set(point, count); + } + + start(): void { + if (this.server) return; + this.server = Bun.serve({ port: 0, fetch: (request) => this.handle(request) }); + } + + stop(): void { + this.server?.stop(true); + this.server = undefined; + } + + private async handle(request: Request): Promise { + const url = new URL(request.url); + const body = request.method === 'GET' ? undefined : await request.json().catch(() => undefined); + this.calls.push({ path: url.pathname, method: request.method, body }); + + if (url.pathname === '/v1/info') { + return Response.json({ nuts: { 4: {}, 5: {}, 7: {}, 8: {}, 9: {}, 20: {} } }); + } + if (url.pathname === '/v1/melt/bolt11' && request.method === 'POST') { + if (this.consume('melt:before')) return unavailable(); + this.meltState = this.meltState === 'UNPAID' ? 'PENDING' : this.meltState; + if (this.consume('melt:after-commit')) return unavailable(); + return Response.json(this.meltResponse()); + } + if (url.pathname.startsWith('/v1/melt/quote/bolt11/')) { + return Response.json(this.meltResponse()); + } + if (url.pathname === '/v1/mint/bolt11' && request.method === 'POST') { + if (this.consume('mint:before')) return unavailable(); + if (this.mintState === 'PAID') this.mintState = 'ISSUED'; + if (this.consume('mint:after-commit')) return unavailable(); + return Response.json({ signatures: this.issuedSignatures }); + } + if (url.pathname === '/v1/restore' && request.method === 'POST') { + if (this.consume('restore:before')) return unavailable(); + return Response.json({ outputs: [], signatures: this.restoredSignatures }); + } + if (url.pathname === '/v1/checkstate' && request.method === 'POST') { + return Response.json({ states: [] }); + } + return Response.json({ detail: 'not found' }, { status: 404 }); + } + + private meltResponse() { + return { + quote: 'source-quote', + state: this.meltState, + payment_preimage: this.meltState === 'PAID' ? 'fixture-preimage' : null, + change: this.meltState === 'PAID' ? this.meltChange : undefined, + }; + } + + private consume(point: FixtureFailurePoint): boolean { + const remaining = this.failures.get(point) ?? 0; + if (remaining === 0) return false; + this.failures.set(point, remaining - 1); + return true; + } +} + +function unavailable(): Response { + return Response.json({ detail: 'injected unavailable response' }, { status: 503 }); +} diff --git a/packages/core/test/unit/Manager.test.ts b/packages/core/test/unit/Manager.test.ts index ee0b40fa2..396ef44fb 100644 --- a/packages/core/test/unit/Manager.test.ts +++ b/packages/core/test/unit/Manager.test.ts @@ -1390,6 +1390,7 @@ describe('initializeCoco', () => { expect(manager.mint).toBeDefined(); expect(manager.wallet).toBeDefined(); expect(manager.history).toBeDefined(); + expect(manager.ops.mintSwap).toBeDefined(); expect(manager.subscriptions).toBeDefined(); }); diff --git a/packages/core/test/unit/MeltOperationService.test.ts b/packages/core/test/unit/MeltOperationService.test.ts index e165d11ed..232ae8db2 100644 --- a/packages/core/test/unit/MeltOperationService.test.ts +++ b/packages/core/test/unit/MeltOperationService.test.ts @@ -1878,5 +1878,33 @@ describe('MeltOperationService', () => { meltOperationRepository.create(makePreparedOp('op-quote-2', { quoteId: 'quote-dupe' })), ).rejects.toThrow('MeltOperation already exists'); }); + + it('rejects direct execution of a parent-owned source child', async () => { + const operation = makePreparedOp('owned-melt', { + parentSwapOperationId: 'swap-parent', + }); + await meltOperationRepository.create(operation); + + await expect(service.execute(operation.id)).rejects.toThrow('owned by mint swap swap-parent'); + expect(handler.execute).not.toHaveBeenCalled(); + }); + + it('commits owned source authorization before remote execution', async () => { + const operation = makePreparedOp('owned-melt-barrier', { + parentSwapOperationId: 'swap-parent', + }); + await meltOperationRepository.create(operation); + + const executing = await service.authorizeOwnedExecutionInTransaction( + operation.id, + 'swap-parent', + { meltOperationRepository } as any, + ); + + expect((await meltOperationRepository.getById(operation.id))?.state).toBe('executing'); + expect(handler.execute).not.toHaveBeenCalled(); + await service.executeOwnedRemote(executing, 'swap-parent'); + expect(handler.execute).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/packages/core/test/unit/MemoryMintSwapRepositories.test.ts b/packages/core/test/unit/MemoryMintSwapRepositories.test.ts new file mode 100644 index 000000000..c95417150 --- /dev/null +++ b/packages/core/test/unit/MemoryMintSwapRepositories.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'bun:test'; + +import type { OperationEventOutboxRecord } from '../../models/OperationEventOutbox'; +import type { MintSwapOperation } from '../../operations/mintSwap/MintSwapOperation'; +import { MemoryRepositories } from '../../repositories/memory/MemoryRepositories'; +import { makePreparedMintSwapOperation } from '../fixtures/MintSwap'; + +function makePreparingOperation(overrides: Partial = {}): MintSwapOperation { + const prepared = makePreparedMintSwapOperation(); + return { + id: prepared.id, + state: 'preparing', + revision: 0, + sourceMintUrl: prepared.sourceMintUrl, + destinationMintUrl: prepared.destinationMintUrl, + unit: 'sat', + destinationAmount: prepared.destinationAmount, + retry: { attemptCount: 0 }, + createdAt: prepared.createdAt, + updatedAt: prepared.updatedAt, + ...overrides, + }; +} + +function makeEvent( + overrides: Partial = {}, +): OperationEventOutboxRecord { + return { + id: 'event-1', + operationId: 'swap-1', + revision: 1, + eventType: 'mint-swap-op:prepared', + payload: { + operationId: 'swap-1', + revision: 1, + state: 'prepared', + sourceMintUrl: 'https://source.test', + destinationMintUrl: 'https://destination.test', + unit: 'sat', + destinationAmount: '100', + }, + createdAt: 1_700_000_000_001, + publishAttempts: 0, + ...overrides, + }; +} + +describe('memory mint swap repositories', () => { + it('allows exactly one compare-and-set winner', async () => { + const repositories = new MemoryRepositories(); + await repositories.mintSwapOperationRepository.create(makePreparingOperation()); + const next = makePreparedMintSwapOperation(); + + const results = await Promise.all([ + repositories.mintSwapOperationRepository.compareAndSet(next, 0), + repositories.mintSwapOperationRepository.compareAndSet(next, 0), + ]); + + expect(results.sort()).toEqual([false, true]); + expect((await repositories.mintSwapOperationRepository.getById('swap-1'))?.revision).toBe(1); + }); + + it('enforces unique destination and source child ownership', async () => { + const repositories = new MemoryRepositories(); + await repositories.mintSwapOperationRepository.create( + makePreparedMintSwapOperation({ revision: 0 }), + ); + + await expect( + repositories.mintSwapOperationRepository.create( + makePreparedMintSwapOperation({ id: 'swap-2', revision: 0 }), + ), + ).rejects.toThrow('already owned'); + }); + + it('orders due automatic work and excludes prepared and attention states', async () => { + const repositories = new MemoryRepositories(); + await repositories.mintSwapOperationRepository.create( + makePreparingOperation({ id: 'late', retry: { attemptCount: 1, nextAttemptAt: 30 } }), + ); + await repositories.mintSwapOperationRepository.create( + makePreparingOperation({ id: 'early', retry: { attemptCount: 1, nextAttemptAt: 10 } }), + ); + await repositories.mintSwapOperationRepository.create( + makePreparedMintSwapOperation({ id: 'prepared', revision: 0 }), + ); + + expect( + (await repositories.mintSwapOperationRepository.getDue(30, 10)).map(({ id }) => id), + ).toEqual(['early', 'late']); + }); + + it('enforces outbox logical uniqueness and tracks durable publication attempts', async () => { + const repositories = new MemoryRepositories(); + await repositories.operationEventOutboxRepository.enqueue(makeEvent()); + + await expect( + repositories.operationEventOutboxRepository.enqueue(makeEvent({ id: 'event-2' })), + ).rejects.toThrow('logical key'); + + await repositories.operationEventOutboxRepository.recordPublishFailure( + 'event-1', + 1_700_000_000_100, + 'temporarily unavailable', + ); + expect( + await repositories.operationEventOutboxRepository.getUnpublished(10, 1_700_000_000_099), + ).toEqual([]); + const due = await repositories.operationEventOutboxRepository.getUnpublished( + 10, + 1_700_000_000_100, + ); + expect(due[0]?.publishAttempts).toBe(1); + + await repositories.operationEventOutboxRepository.markPublished('event-1', 1_700_000_000_101); + expect(await repositories.operationEventOutboxRepository.getUnpublished(10)).toEqual([]); + }); + + it('stages memory transactions invisibly and rolls all repositories back on error', async () => { + const repositories = new MemoryRepositories(); + let release!: () => void; + const barrier = new Promise((resolve) => { + release = resolve; + }); + let staged = false; + + const transaction = repositories.withTransaction(async (tx) => { + await tx.mintSwapOperationRepository.create(makePreparingOperation()); + await tx.operationEventOutboxRepository.enqueue(makeEvent()); + staged = true; + await barrier; + }); + while (!staged) await Promise.resolve(); + expect(await repositories.mintSwapOperationRepository.getById('swap-1')).toBeNull(); + expect(await repositories.operationEventOutboxRepository.getUnpublished(10)).toEqual([]); + release(); + await transaction; + expect(await repositories.mintSwapOperationRepository.getById('swap-1')).not.toBeNull(); + expect(await repositories.operationEventOutboxRepository.getUnpublished(10)).toHaveLength(1); + + await expect( + repositories.withTransaction(async (tx) => { + await tx.mintSwapOperationRepository.create( + makePreparingOperation({ id: 'rolled-back-swap' }), + ); + await tx.operationEventOutboxRepository.enqueue( + makeEvent({ + id: 'rolled-back-event', + operationId: 'rolled-back-swap', + payload: { ...makeEvent().payload, operationId: 'rolled-back-swap' }, + }), + ); + throw new Error('injected transaction failure'); + }), + ).rejects.toThrow('injected'); + expect(await repositories.mintSwapOperationRepository.getById('rolled-back-swap')).toBeNull(); + }); +}); diff --git a/packages/core/test/unit/MemoryQuoteRepository.test.ts b/packages/core/test/unit/MemoryQuoteRepository.test.ts index 1c851d3c7..75a0d0eed 100644 --- a/packages/core/test/unit/MemoryQuoteRepository.test.ts +++ b/packages/core/test/unit/MemoryQuoteRepository.test.ts @@ -102,6 +102,25 @@ describe('memory quote repositories', () => { ); }); + it('does not let a legacy BOLT11 state fallback regress canonical accounting', async () => { + const repository = new MemoryMintQuoteRepository(); + const quote = makeMintQuote({ + state: 'PAID', + amountPaid: Amount.from(1), + amountIssued: Amount.zero(), + }); + await repository.upsertMintQuote(quote); + + await repository.setMintQuoteState(quote.mintUrl, quote.method, quote.quoteId, 'UNPAID', 10); + const stored = await repository.getMintQuote(quote.mintUrl, quote.method, quote.quoteId); + + expect(stored?.method).toBe('bolt11'); + if (stored?.method !== 'bolt11') throw new Error('Expected BOLT11 quote'); + expect(stored.amountPaid.equals(Amount.from(1))).toBe(true); + expect(stored.amountIssued.isZero()).toBe(true); + expect(stored.state).toBe('PAID'); + }); + it('looks up melt quotes by canonical identity and rejects method collisions', async () => { const repository = new MemoryMeltQuoteRepository(); await repository.upsertMeltQuote(makeMeltQuote({ quoteId: 'identity-melt' })); diff --git a/packages/core/test/unit/MintBolt11Handler.test.ts b/packages/core/test/unit/MintBolt11Handler.test.ts index ff0030269..0a012551e 100644 --- a/packages/core/test/unit/MintBolt11Handler.test.ts +++ b/packages/core/test/unit/MintBolt11Handler.test.ts @@ -7,6 +7,7 @@ import { EventBus } from '../../events/EventBus'; import type { CoreEvents } from '../../events/types'; import type { CreateMintQuoteContext, + ExecuteContext, PendingContext, PrepareContext, RecoverExecutingContext, @@ -19,11 +20,16 @@ import type { MintService } from '../../services/MintService'; import type { MintAdapter } from '../../infra'; import type { ProofRepository } from '../../repositories'; import type { Logger } from '../../logging/Logger'; +import type { KeyRingService } from '../../services/KeyRingService'; +import { bytesToHex } from '@noble/curves/utils.js'; +import { redactSensitiveValue } from '../../logging/redaction'; describe('MintBolt11Handler', () => { const mintUrl = 'https://mint.test'; const quoteId = 'quote-1'; const keysetId = 'keyset-1'; + const quotePubkey = `02${'11'.repeat(32)}`; + const quoteSecretKey = new Uint8Array(32).fill(7); let handler: MintBolt11Handler; let wallet: Wallet; @@ -34,6 +40,7 @@ describe('MintBolt11Handler', () => { let mintService: MintService; let eventBus: EventBus; let logger: Logger; + let keyRingService: KeyRingService; const outputData = serializeOutputData({ keep: [ @@ -152,6 +159,20 @@ describe('MintBolt11Handler', () => { logger, }); + const buildExecuteContext = ( + operationOverride: ExecuteContext<'bolt11'>['operation'] = executingOperation, + ): ExecuteContext<'bolt11'> => ({ + operation: operationOverride, + wallet, + mintAdapter, + proofService, + proofRepository, + walletService, + mintService, + eventBus, + logger, + }); + const buildPendingContext = (): PendingContext<'bolt11'> => ({ operation: { ...executingOperation, @@ -168,10 +189,18 @@ describe('MintBolt11Handler', () => { }); beforeEach(() => { - handler = new MintBolt11Handler(); + keyRingService = { + getMintQuoteKeyPair: mock(async () => ({ + publicKeyHex: quotePubkey, + secretKey: quoteSecretKey, + purpose: 'nut20_mint_quote' as const, + })), + } as unknown as KeyRingService; + handler = new MintBolt11Handler(keyRingService); wallet = { createMintQuoteBolt11: mock(async () => quote), + createLockedMintQuote: mock(async () => ({ ...quote, pubkey: quotePubkey })), mintProofsBolt11: mock(async () => { throw new MintOperationError(20007, 'Quote expired'); }), @@ -189,7 +218,9 @@ describe('MintBolt11Handler', () => { proofRepository = {} as ProofRepository; walletService = {} as WalletService; - mintService = {} as MintService; + mintService = { + assertNutSupported: mock(async () => {}), + } as unknown as MintService; eventBus = new EventBus(); logger = { info: mock(() => {}) } as unknown as Logger; }); @@ -210,6 +241,44 @@ describe('MintBolt11Handler', () => { expect(result.quoteId).toBe(quoteId); expect(result.method).toBe('bolt11'); }); + + it('creates an explicitly locked BOLT11 quote after NUT-20 preflight', async () => { + const result = await handler.createQuote({ + ...buildCreateQuoteContext(), + createQuoteData: { + amount: { amount: Amount.from(10), unit: 'sat' }, + pubkey: quotePubkey, + }, + }); + + expect(mintService.assertNutSupported).toHaveBeenCalledWith( + mintUrl, + 20, + 'locked BOLT11 mint quote', + ); + expect(wallet.createLockedMintQuote).toHaveBeenCalledWith(Amount.from(10), quotePubkey); + expect(result.pubkey).toBe(quotePubkey); + expect((wallet.createMintQuoteBolt11 as Mock).mock.calls).toHaveLength(0); + }); + }); + + describe('execute', () => { + it('signs locked BOLT11 issuance with the persisted NUT-20 key', async () => { + (wallet.mintProofsBolt11 as Mock).mockImplementation(async () => []); + + await handler.execute(buildExecuteContext({ ...executingOperation, pubkey: quotePubkey })); + + expect(keyRingService.getMintQuoteKeyPair).toHaveBeenCalledWith(quotePubkey); + const call = (wallet.mintProofsBolt11 as Mock).mock.calls[0]; + expect(call?.[0]).toEqual(Amount.from(10)); + expect(call?.[1]).toBe(quoteId); + expect(call?.[2]).toEqual({ privkey: bytesToHex(quoteSecretKey) }); + const customOutputs = call?.[3] as + | { type: string; data: Array<{ blindedMessage: { B_: string } }> } + | undefined; + expect(customOutputs?.type).toBe('custom'); + expect(customOutputs?.data[0]?.blindedMessage.B_).toBe('B_out_1'); + }); }); describe('recoverExecuting', () => { @@ -218,17 +287,39 @@ describe('MintBolt11Handler', () => { expect(result).toEqual({ status: 'TERMINAL', - error: `Recovered: quote ${quoteId} expired while executing mint`, + error: `Recovered: quote ${redactSensitiveValue(quoteId)} expired while executing mint`, }); expect((wallet.mintProofsBolt11 as Mock).mock.calls.length).toBe(1); expect((proofService.saveProofs as Mock).mock.calls.length).toBe(0); }); + + it('recovers locked issuance using accounting authority and the persisted key', async () => { + (mintAdapter.checkMintQuote as Mock).mockImplementation(async () => ({ + ...quote, + state: 'UNPAID', + pubkey: quotePubkey, + amount_paid: Amount.from(10), + amount_issued: Amount.zero(), + updated_at: 10, + })); + (wallet.mintProofsBolt11 as Mock).mockImplementation(async () => []); + + const result = await handler.recoverExecuting({ + ...buildRecoverContext(), + operation: { ...executingOperation, pubkey: quotePubkey }, + }); + + expect(result).toEqual({ status: 'FINALIZED' }); + const call = (wallet.mintProofsBolt11 as Mock).mock.calls[0]; + expect(call?.[2]).toEqual({ privkey: bytesToHex(quoteSecretKey) }); + expect(proofService.saveProofs).toHaveBeenCalledTimes(1); + }); }); describe('prepare', () => { it('requires the service to provide an existing quote snapshot', async () => { await expect(handler.prepare(buildPrepareContext())).rejects.toThrow( - 'Mint quote quote-1 was not provided', + 'BOLT11 mint quote was not provided', ); expect((wallet.createMintQuoteBolt11 as Mock).mock.calls).toHaveLength(0); }); @@ -267,7 +358,12 @@ describe('MintBolt11Handler', () => { }); describe('checkPending', () => { - it('returns the observed remote state with a normalized ready category', async () => { + it('uses canonical accounting when the compatibility state is contradictory', async () => { + (mintAdapter.checkMintQuote as Mock).mockResolvedValueOnce({ + ...quote, + state: 'UNPAID', + }); + const result = await handler.checkPending(buildPendingContext()); expect(result.observedRemoteState).toBe('PAID'); diff --git a/packages/core/test/unit/MintOperationService.test.ts b/packages/core/test/unit/MintOperationService.test.ts index 0b018e60c..8b1f25744 100644 --- a/packages/core/test/unit/MintOperationService.test.ts +++ b/packages/core/test/unit/MintOperationService.test.ts @@ -773,7 +773,7 @@ describe('MintOperationService', () => { expect(persistedDuringEvent).toEqual(['21']); }); - it('refreshMintQuote ignores a stale direct BOLT11 observation', async () => { + it('refreshMintQuote preserves canonical BOLT11 accounting against a direct regression', async () => { await quoteRepo.upsertMintQuote( mintQuoteFromBolt11Response(mintUrl, { quote: 'quote-direct-refresh', @@ -811,7 +811,7 @@ describe('MintOperationService', () => { expect(quoteUpdatedEvents).toHaveLength(0); }); - it('refreshMintQuote ignores stale direct reusable accounting', async () => { + it('refreshMintQuote preserves canonical reusable accounting against a direct regression', async () => { const onchainQuoteId = 'onchain-quote-direct-refresh'; await persistOnchainQuote(onchainQuoteId, { paid: Amount.from(10), @@ -1654,6 +1654,126 @@ describe('MintOperationService', () => { expect(storedQuote?.state).toBe('PAID'); }); + it('keeps BOLT11 accounting monotonic and ignores lower remote updated_at values', async () => { + const accountingQuote = { + quote: 'quote-accounting-monotonic', + request: 'lnbc1accounting', + amount: Amount.from(12), + unit: 'sat', + expiry: Math.floor(Date.now() / 1000) + 3600, + state: 'UNPAID', + amount_paid: Amount.from(12), + amount_issued: Amount.from(4), + updated_at: 20, + } as unknown as MintQuoteBolt11Response; + await quoteLifecycle.importMintQuote(mintUrl, 'bolt11', accountingQuote); + + await quoteLifecycle.importMintQuote(mintUrl, 'bolt11', { + ...accountingQuote, + amount_paid: Amount.from(0), + amount_issued: Amount.from(0), + updated_at: 19, + } as unknown as MintQuoteBolt11Response); + + const stored = await quoteRepo.getMintQuote(mintUrl, 'bolt11', accountingQuote.quote); + expect(stored?.state).toBe('PAID'); + if (stored?.method !== 'bolt11') throw new Error('Expected BOLT11 quote'); + expect(stored.amountPaid.equals(Amount.from(12))).toBe(true); + expect(stored.amountIssued.equals(Amount.from(4))).toBe(true); + expect(stored.remoteUpdatedAt).toBe(20); + }); + + it('does not regress BOLT11 accounting when the compatibility state is unchanged', async () => { + const accountingQuote = { + quote: 'quote-accounting-same-state', + request: 'lnbc1accounting', + amount: Amount.from(12), + unit: 'sat', + expiry: Math.floor(Date.now() / 1000) + 3600, + state: 'PAID', + amount_paid: Amount.from(12), + amount_issued: Amount.from(4), + updated_at: 20, + } as unknown as MintQuoteBolt11Response; + await quoteLifecycle.importMintQuote(mintUrl, 'bolt11', accountingQuote); + + await quoteLifecycle.importMintQuote(mintUrl, 'bolt11', { + ...accountingQuote, + amount_issued: Amount.from(2), + updated_at: 21, + } as unknown as MintQuoteBolt11Response); + + const stored = await quoteRepo.getMintQuote(mintUrl, 'bolt11', accountingQuote.quote); + expect(stored?.state).toBe('PAID'); + if (stored?.method !== 'bolt11') throw new Error('Expected BOLT11 quote'); + expect(stored.amountPaid.equals(Amount.from(12))).toBe(true); + expect(stored.amountIssued.equals(Amount.from(4))).toBe(true); + expect(stored.remoteUpdatedAt).toBe(20); + }); + + it('ignores a compatibility-only snapshot after remotely ordered accounting', async () => { + const accountingQuote = { + quote: 'quote-accounting-before-legacy', + request: 'lnbc1accounting', + amount: Amount.from(12), + unit: 'sat', + expiry: Math.floor(Date.now() / 1000) + 3600, + state: 'PAID', + amount_paid: Amount.from(12), + amount_issued: Amount.from(4), + updated_at: 20, + } as unknown as MintQuoteBolt11Response; + await quoteLifecycle.importMintQuote(mintUrl, 'bolt11', accountingQuote); + + await quoteLifecycle.importMintQuote(mintUrl, 'bolt11', { + ...accountingQuote, + state: 'ISSUED', + amount_issued: Amount.from(12), + updated_at: null, + } as unknown as MintQuoteBolt11Response); + + const stored = await quoteRepo.getMintQuote(mintUrl, 'bolt11', accountingQuote.quote); + expect(stored?.method).toBe('bolt11'); + if (stored?.method !== 'bolt11') throw new Error('Expected BOLT11 quote'); + expect(stored.amountPaid.equals(Amount.from(12))).toBe(true); + expect(stored.amountIssued.equals(Amount.from(4))).toBe(true); + expect(stored.remoteUpdatedAt).toBe(20); + }); + + it('emits when BOLT11 accounting advances without changing compatibility state', async () => { + const accountingQuote = { + quote: 'quote-accounting-same-state-advance', + request: 'lnbc1accounting', + amount: Amount.from(12), + unit: 'sat', + expiry: Math.floor(Date.now() / 1000) + 3600, + state: 'UNPAID', + amount_paid: Amount.from(12), + amount_issued: Amount.from(2), + updated_at: 20, + } as unknown as MintQuoteBolt11Response; + await quoteLifecycle.importMintQuote(mintUrl, 'bolt11', accountingQuote); + const quoteUpdatedEvents: Array = []; + eventBus.on('mint-quote:updated', (event) => { + quoteUpdatedEvents.push(event); + }); + + await quoteLifecycle.importMintQuote(mintUrl, 'bolt11', { + ...accountingQuote, + state: 'ISSUED', + amount_issued: Amount.from(4), + updated_at: 21, + } as unknown as MintQuoteBolt11Response); + + const stored = await quoteRepo.getMintQuote(mintUrl, 'bolt11', accountingQuote.quote); + expect(stored?.state).toBe('PAID'); + if (stored?.method !== 'bolt11') throw new Error('Expected BOLT11 quote'); + expect(stored.amountIssued.equals(Amount.from(4))).toBe(true); + expect(quoteUpdatedEvents).toHaveLength(1); + expect(quoteUpdatedEvents[0]?.quote.state).toBe('PAID'); + expect(quoteUpdatedEvents[0]?.quote.amountIssued.equals(Amount.from(4))).toBe(true); + }); + it('quote import delegates unsupported quote units to capability validation', async () => { const importedQuote: MintMethodQuoteImportSnapshot<'bolt11'> = { quote: 'quote-usd', @@ -2407,6 +2527,36 @@ describe('MintOperationService', () => { ); }); + it('does not overwrite canonical BOLT11 accounting with a compatibility state', async () => { + const pendingOp = makePendingOp('pending-accounting-snapshot'); + await operationRepo.create(pendingOp); + (handler.checkPending as Mock).mockResolvedValueOnce({ + observedRemoteState: 'ISSUED', + observedRemoteStateAt: Date.now(), + quoteSnapshot: cashuNormalizedBolt11Fixture({ + quote: pendingOp.quoteId, + request: pendingOp.request, + amount: pendingOp.amount, + unit: pendingOp.unit, + expiry: pendingOp.expiry, + state: 'ISSUED', + amount_paid: Amount.from(10), + amount_issued: Amount.from(4), + updated_at: 20, + }), + category: 'ready', + } satisfies PendingMintCheckResult<'bolt11'>); + + await service.observePendingOperation(pendingOp.id); + + const quote = await quoteRepo.getMintQuote(mintUrl, 'bolt11', pendingOp.quoteId); + expect(quote?.method).toBe('bolt11'); + if (quote?.method !== 'bolt11') throw new Error('Expected BOLT11 quote'); + expect(quote.state).toBe('PAID'); + expect(quote.amountPaid.equals(Amount.from(10))).toBe(true); + expect(quote.amountIssued.equals(Amount.from(4))).toBe(true); + }); + it('checkPendingOperation records onchain quote snapshots without protocol state', async () => { const onchainQuoteId = 'onchain-quote-pending-check'; await persistOnchainQuote(onchainQuoteId, { paid: Amount.zero(), issued: Amount.zero() }); @@ -2511,7 +2661,7 @@ describe('MintOperationService', () => { expect(persistedDuringEvent).toEqual(['PAID']); }); - it('recordQuoteObservation applies compatibility state to the latest canonical quote', async () => { + it('recordQuoteObservation cannot override the latest remotely ordered accounting', async () => { const initialExpiry = Math.floor(Date.now() / 1000) + 3600; const newerExpiry = initialExpiry + 3600; await quoteRepo.upsertMintQuote( @@ -2565,8 +2715,8 @@ describe('MintOperationService', () => { await Promise.all([newerSnapshot, compatibilityObservation]); const stored = await quoteRepo.getMintQuote(mintUrl, 'bolt11', quoteId); - expect(stored?.state).toBe('ISSUED'); - expect(stored?.amountIssued.toString()).toBe('10'); + expect(stored?.state).toBe('PAID'); + expect(stored?.amountIssued.toString()).toBe('0'); expect(stored?.request).toBe('lnbc1newer'); expect(stored?.expiry).toBe(newerExpiry); expect(stored?.remoteUpdatedAt).toBe(21); @@ -2639,4 +2789,83 @@ describe('MintOperationService', () => { expect(pendingEvents).toHaveLength(0); expect(handler.checkPending).not.toHaveBeenCalled(); }); + + it('rejects direct execution of a parent-owned destination child', async () => { + const operation: PendingMintOperation = { + ...makePendingOp('owned-mint'), + parentSwapOperationId: 'swap-parent', + }; + await operationRepo.create(operation); + + await expect(service.execute(operation.id)).rejects.toThrow('owned by mint swap swap-parent'); + expect(handler.execute).not.toHaveBeenCalled(); + }); + + it('commits owned destination authorization before remote execution', async () => { + const operation: PendingMintOperation = { + ...makePendingOp('owned-mint-barrier'), + parentSwapOperationId: 'swap-parent', + }; + await operationRepo.create(operation); + + const executing = await service.authorizeOwnedExecutionInTransaction( + operation.id, + 'swap-parent', + { mintOperationRepository: operationRepo } as any, + ); + + expect((await operationRepo.getById(operation.id))?.state).toBe('executing'); + expect(handler.execute).not.toHaveBeenCalled(); + await service.executeOwnedRemote(executing, 'swap-parent'); + expect(handler.execute).toHaveBeenCalledTimes(1); + }); + + it('records canonical BOLT11 issuance when applying a parent-owned result', async () => { + const operation: ExecutingMintOperation = { + ...makeExecutingOp('owned-mint-accounting'), + parentSwapOperationId: 'swap-parent', + }; + await persistQuote(operation.quoteId); + await operationRepo.create(operation); + (proofService as any).forTransaction = mock(() => proofService); + + const finalized = await service.applyOwnedExecutionInTransaction( + operation, + 'swap-parent', + { status: 'ISSUED', proofs: [makeProof('out-1')] }, + { + mintQuoteRepository: quoteRepo, + mintOperationRepository: operationRepo, + proofRepository: proofRepo, + } as any, + ); + const quote = await quoteRepo.getMintQuote( + operation.mintUrl, + operation.method, + operation.quoteId, + ); + + expect(finalized.state).toBe('finalized'); + expect(quote?.method).toBe('bolt11'); + if (quote?.method !== 'bolt11') throw new Error('Expected BOLT11 quote'); + expect(quote.amountPaid.equals(operation.amount)).toBe(true); + expect(quote.amountIssued.equals(operation.amount)).toBe(true); + expect(quote.state).toBe('ISSUED'); + }); + + it('rejects issued proofs that do not match the prepared destination outputs', async () => { + const operation: ExecutingMintOperation = { + ...makeExecutingOp('owned-mint-output-check'), + parentSwapOperationId: 'swap-parent', + }; + + await expect( + service.applyOwnedExecutionInTransaction( + operation, + 'swap-parent', + { status: 'ISSUED', proofs: [makeProof('different-secret')] }, + {} as any, + ), + ).rejects.toThrow('does not match deterministic outputs'); + }); }); diff --git a/packages/core/test/unit/MintOperationWatcherService.test.ts b/packages/core/test/unit/MintOperationWatcherService.test.ts index 9229008a4..b85e89b14 100644 --- a/packages/core/test/unit/MintOperationWatcherService.test.ts +++ b/packages/core/test/unit/MintOperationWatcherService.test.ts @@ -359,7 +359,7 @@ describe('MintOperationWatcherService', () => { await watcher.stop(); }); - it('records PAID subscription updates without re-checking the quote remotely', async () => { + it('records paid accounting even when the compatibility state contradicts it', async () => { const operation = makePendingOperation(); const observePendingOperation = mock(async () => { throw new Error('should not re-check'); @@ -376,8 +376,8 @@ describe('MintOperationWatcherService', () => { expiry: quote.expiry, state: quote.state, reusable: false as const, - amountPaid: quote.state === 'UNPAID' ? Amount.zero() : quote.amount, - amountIssued: quote.state === 'ISSUED' ? quote.amount : Amount.zero(), + amountPaid: Amount.from(quote.amount_paid), + amountIssued: Amount.from(quote.amount_issued), remoteUpdatedAt: quote.updated_at, quoteData: { amount: quote.amount, @@ -419,7 +419,10 @@ describe('MintOperationWatcherService', () => { amount: operation.amount, unit: operation.unit, expiry: operation.expiry, - state: 'PAID', + state: 'UNPAID', + amount_paid: operation.amount, + amount_issued: Amount.zero(), + updated_at: 20, }); expect(getOperation).not.toHaveBeenCalled(); @@ -427,14 +430,19 @@ describe('MintOperationWatcherService', () => { expect(recordMintQuoteSnapshot).toHaveBeenCalledWith( mintUrl, 'bolt11', - expect.objectContaining({ quote: quoteId, state: 'PAID' }), + expect.objectContaining({ + quote: quoteId, + state: 'UNPAID', + amount_paid: operation.amount, + amount_issued: Amount.zero(), + }), ); expect(unsubscribe).not.toHaveBeenCalled(); await watcher.stop(); }); - it('stops watching expired subscription updates without recording unimportant states', async () => { + it('records canonical accounting before stopping an expired subscription', async () => { const operation = makePendingOperation(); const recordMintQuoteSnapshot = mock(async () => makeBolt11Quote()); @@ -463,15 +471,18 @@ describe('MintOperationWatcherService', () => { unit: operation.unit, expiry: Math.floor(Date.now() / 1000) - 1, state: 'UNPAID', + amount_paid: Amount.zero(), + amount_issued: Amount.zero(), + updated_at: 20, }); - expect(recordMintQuoteSnapshot).not.toHaveBeenCalled(); + expect(recordMintQuoteSnapshot).toHaveBeenCalledTimes(1); expect(unsubscribe).toHaveBeenCalledTimes(1); await watcher.stop(); }); - it('records ISSUED subscription updates and stops watching the operation', async () => { + it('stops on issued accounting even when the compatibility state contradicts it', async () => { const operation = makePendingOperation(); const recordMintQuoteSnapshot = mock( async (_mintUrl: string, _method: string, quote: MintQuoteBolt11Response) => ({ @@ -485,8 +496,8 @@ describe('MintOperationWatcherService', () => { expiry: quote.expiry, state: quote.state, reusable: false as const, - amountPaid: quote.state === 'UNPAID' ? Amount.zero() : quote.amount, - amountIssued: quote.state === 'ISSUED' ? quote.amount : Amount.zero(), + amountPaid: Amount.from(quote.amount_paid), + amountIssued: Amount.from(quote.amount_issued), remoteUpdatedAt: quote.updated_at, quoteData: { amount: quote.amount, @@ -523,13 +534,21 @@ describe('MintOperationWatcherService', () => { amount: operation.amount, unit: operation.unit, expiry: operation.expiry, - state: 'ISSUED', + state: 'PAID', + amount_paid: operation.amount, + amount_issued: operation.amount, + updated_at: 21, }); expect(recordMintQuoteSnapshot).toHaveBeenCalledWith( mintUrl, 'bolt11', - expect.objectContaining({ quote: quoteId, state: 'ISSUED' }), + expect.objectContaining({ + quote: quoteId, + state: 'PAID', + amount_paid: operation.amount, + amount_issued: operation.amount, + }), ); expect(unsubscribe).toHaveBeenCalledTimes(1); diff --git a/packages/core/test/unit/MintQuote.test.ts b/packages/core/test/unit/MintQuote.test.ts index 1e9d7d920..58e1c0a2b 100644 --- a/packages/core/test/unit/MintQuote.test.ts +++ b/packages/core/test/unit/MintQuote.test.ts @@ -6,9 +6,18 @@ import { import { describe, expect, it } from 'bun:test'; import { + applyBolt11MintQuoteStateFallback, + deriveBolt11MintQuoteState, + getMintQuoteAvailableAmount, getMintQuoteAmount, + getMintQuoteRemoteState, + isBolt11MintQuoteIssued, + isBolt11MintQuotePaid, + isBolt11MintQuoteUnpaid, + isMintQuotePending, mintQuoteFromBolt11Response, mintQuoteFromBolt12Response, + mintQuoteToMethodSnapshot, } from '../../models/MintQuote'; describe('MintQuote model', () => { @@ -33,6 +42,180 @@ describe('MintQuote model', () => { expect(quote.remoteUpdatedAt).toBe(null); }); + it('uses current BOLT11 accounting instead of deprecated state', () => { + const quote = mintQuoteFromBolt11Response('https://mint.test', { + quote: 'quote-accounting', + request: 'lnbc...', + amount: 100, + unit: 'sat', + expiry: 123, + state: 'UNPAID', + amount_paid: 100, + amount_issued: 40, + updated_at: 55, + } as unknown as MintQuoteBolt11Response); + + expect(quote.state).toBe('PAID'); + expect(quote.amountPaid.equals(Amount.from(100))).toBe(true); + expect(quote.amountIssued.equals(Amount.from(40))).toBe(true); + expect(quote.remoteUpdatedAt).toBe(55); + expect(getMintQuoteAvailableAmount(quote).equals(Amount.from(60))).toBe(true); + expect(isMintQuotePending(quote)).toBe(true); + }); + + it('rejects incomplete or contradictory BOLT11 accounting', () => { + const base = { + quote: 'quote-invalid-accounting', + request: 'lnbc...', + amount: 100, + unit: 'sat', + expiry: 123, + state: 'PAID' as const, + }; + + expect(() => + mintQuoteFromBolt11Response('https://mint.test', { + ...base, + amount_paid: 100, + } as unknown as MintQuoteBolt11Response), + ).toThrow(); + expect(() => + mintQuoteFromBolt11Response('https://mint.test', { + ...base, + amount_paid: 100, + amount_issued: 101, + } as unknown as MintQuoteBolt11Response), + ).toThrow('amount_issued greater than amount_paid'); + }); + + it('treats BOLT11 accounting as authoritative over the deprecated state projection', () => { + const canonical = mintQuoteFromBolt11Response('https://mint.test', { + quote: 'quote-accounting-authority', + request: 'lnbc...', + method: 'bolt11', + amount: Amount.from(100), + unit: 'sat', + expiry: 123, + amount_paid: Amount.from(100), + amount_issued: Amount.zero(), + updated_at: 42, + state: 'ISSUED', + } satisfies MintQuoteBolt11Response); + const contradictoryProjection = { + ...canonical, + state: 'ISSUED' as const, + }; + + expect(canonical.state).toBe('PAID'); + expect(deriveBolt11MintQuoteState(canonical.amountPaid, canonical.amountIssued)).toBe('PAID'); + expect(getMintQuoteRemoteState(contradictoryProjection)).toBe('PAID'); + expect(isBolt11MintQuoteUnpaid(contradictoryProjection)).toBe(false); + expect(isBolt11MintQuotePaid(contradictoryProjection)).toBe(true); + expect(isBolt11MintQuoteIssued(contradictoryProjection)).toBe(false); + expect(isMintQuotePending(contradictoryProjection)).toBe(true); + expect(getMintQuoteAvailableAmount(contradictoryProjection).equals(Amount.from(100))).toBe( + true, + ); + expect(mintQuoteToMethodSnapshot<'bolt11'>(contradictoryProjection).state).toBe('PAID'); + }); + + it('recognizes terminal BOLT11 issuance from accounting, not compatibility state', () => { + const quote = mintQuoteFromBolt11Response('https://mint.test', { + quote: 'quote-accounting-terminal', + request: 'lnbc...', + method: 'bolt11', + amount: Amount.from(100), + unit: 'sat', + expiry: 123, + amount_paid: Amount.from(100), + amount_issued: Amount.from(100), + updated_at: 43, + state: 'PAID', + } satisfies MintQuoteBolt11Response); + + expect(quote.state).toBe('ISSUED'); + expect(isBolt11MintQuoteIssued(quote)).toBe(true); + expect(isMintQuotePending(quote)).toBe(false); + expect(getMintQuoteAvailableAmount(quote).isZero()).toBe(true); + }); + + it('does not let a legacy state observation override remotely ordered accounting', () => { + const quote = mintQuoteFromBolt11Response('https://mint.test', { + quote: 'quote-ordered-accounting', + request: 'lnbc...', + method: 'bolt11', + amount: Amount.from(100), + unit: 'sat', + expiry: 123, + amount_paid: Amount.from(50), + amount_issued: Amount.zero(), + updated_at: 44, + state: 'PAID', + } satisfies MintQuoteBolt11Response); + + const retained = applyBolt11MintQuoteStateFallback(quote, 'ISSUED', 45); + + expect(retained.amountPaid.equals(Amount.from(50))).toBe(true); + expect(retained.amountIssued.isZero()).toBe(true); + expect(retained.state).toBe('PAID'); + expect(retained.remoteUpdatedAt).toBe(44); + }); + + it('does not let legacy state replace partial accounting without a remote order', () => { + const quote = mintQuoteFromBolt11Response('https://mint.test', { + quote: 'quote-partial-unordered-accounting', + request: 'lnbc...', + method: 'bolt11', + amount: Amount.from(100), + unit: 'sat', + expiry: 123, + amount_paid: Amount.from(50), + amount_issued: Amount.zero(), + updated_at: null, + state: 'PAID', + } satisfies MintQuoteBolt11Response); + + const retained = applyBolt11MintQuoteStateFallback(quote, 'ISSUED', 45); + + expect(retained.amountPaid.equals(Amount.from(50))).toBe(true); + expect(retained.amountIssued.isZero()).toBe(true); + expect(retained.state).toBe('PAID'); + }); + + it('does not treat partial BOLT11 accounting projections as ready or terminal', () => { + const partiallyPaid = mintQuoteFromBolt11Response('https://mint.test', { + quote: 'quote-partial-paid', + request: 'lnbc...', + method: 'bolt11', + amount: Amount.from(100), + unit: 'sat', + expiry: 123, + amount_paid: Amount.from(50), + amount_issued: Amount.zero(), + updated_at: 44, + state: 'PAID', + } satisfies MintQuoteBolt11Response); + const partiallyIssued = mintQuoteFromBolt11Response('https://mint.test', { + quote: 'quote-partial-issued', + request: 'lnbc...', + method: 'bolt11', + amount: Amount.from(100), + unit: 'sat', + expiry: 123, + amount_paid: Amount.from(50), + amount_issued: Amount.from(50), + updated_at: 45, + state: 'ISSUED', + } satisfies MintQuoteBolt11Response); + + expect(partiallyPaid.state).toBe('PAID'); + expect(isBolt11MintQuotePaid(partiallyPaid)).toBe(false); + expect(isMintQuotePending(partiallyPaid)).toBe(true); + expect(partiallyIssued.state).toBe('ISSUED'); + expect(isBolt11MintQuoteIssued(partiallyIssued)).toBe(false); + expect(isMintQuotePending(partiallyIssued)).toBe(true); + }); + it('keeps BOLT12 offer amounts separate from mint operation amounts', () => { const quote = mintQuoteFromBolt12Response('https://mint.test', { quote: 'quote-1', diff --git a/packages/core/test/unit/MintQuoteObservation.test.ts b/packages/core/test/unit/MintQuoteObservation.test.ts index 8d849105a..55ab80bee 100644 --- a/packages/core/test/unit/MintQuoteObservation.test.ts +++ b/packages/core/test/unit/MintQuoteObservation.test.ts @@ -11,7 +11,7 @@ describe('resolveMintQuoteObservation', () => { const mintUrl = 'https://mint.test'; const expiry = Math.floor(Date.now() / 1000) + 3600; - it('classifies a forward BOLT11 state transition as meaningful', () => { + it('treats a compatibility-only BOLT11 state transition as freshness-only', () => { const existing = mintQuoteFromBolt11Fixture(mintUrl, { quote: 'bolt11-state-change', request: 'lnbc1test', @@ -29,8 +29,9 @@ describe('resolveMintQuoteObservation', () => { const resolution = resolveMintQuoteObservation(existing, incoming); - expect(resolution.disposition).toBe('accepted-meaningful-change'); - expect(resolution.resolvedQuote).toBe(incoming); + expect(resolution.disposition).toBe('accepted-freshness-only'); + expect(resolution.resolvedQuote.state).toBe('PAID'); + expect(resolution.resolvedQuote.remoteUpdatedAt).toBe(21); }); it('classifies freshness-only changes for amountless BOLT12 quotes', () => { diff --git a/packages/core/test/unit/MintQuoteProcessor.test.ts b/packages/core/test/unit/MintQuoteProcessor.test.ts index 219db46a9..6311d37c0 100644 --- a/packages/core/test/unit/MintQuoteProcessor.test.ts +++ b/packages/core/test/unit/MintQuoteProcessor.test.ts @@ -1,3 +1,4 @@ +import { Amount } from '@cashu/cashu-ts'; import { describe, it, beforeEach, afterEach, expect } from 'bun:test'; import { MintOperationProcessor } from '../../services/watchers/MintOperationProcessor'; import { EventBus } from '../../events/EventBus'; @@ -21,6 +22,26 @@ describe('MintOperationProcessor', () => { const TEST_RETRY_DELAY = 100; const TEST_INITIAL_DELAY = 10; + const makeBolt11Quote = (quoteId: string, paid: boolean) => ({ + mintUrl: 'https://mint.test', + method: 'bolt11' as const, + quoteId, + quote: quoteId, + request: 'lnbc1test', + amount: Amount.from(10), + unit: 'sat', + expiry: null, + // Deliberately contradictory: processor decisions must use canonical accounting. + state: paid ? ('UNPAID' as const) : ('PAID' as const), + reusable: false as const, + amountPaid: paid ? Amount.from(10) : Amount.zero(), + amountIssued: Amount.zero(), + remoteUpdatedAt: null, + quoteData: { amount: Amount.from(10) }, + createdAt: 0, + updatedAt: 0, + }); + beforeEach(() => { bus = new EventBus(); finalizeCalls = []; @@ -56,19 +77,7 @@ describe('MintOperationProcessor', () => { mockQuoteLifecycle = { async getMintQuote() { - return { - mintUrl: 'https://mint.test', - method: 'bolt11', - quoteId: 'quote-2', - quote: 'quote-2', - request: 'lnbc1test', - amount: 10, - unit: 'sat', - expiry: null, - state: 'PAID', - reusable: false, - quoteData: { amount: 10 }, - } as any; + return makeBolt11Quote('quote-2', true); }, } as unknown as QuoteLifecycle; @@ -109,13 +118,7 @@ describe('MintOperationProcessor', () => { mintUrl: 'https://mint.test', method: 'bolt11', quoteId: 'quote-1', - quote: { - mintUrl: 'https://mint.test', - method: 'bolt11', - quoteId: 'quote-1', - quote: 'quote-1', - state: 'PAID', - } as any, + quote: makeBolt11Quote('quote-1', true), }); await sleep(TEST_PROCESS_INTERVAL * 2 + 50); @@ -165,13 +168,7 @@ describe('MintOperationProcessor', () => { mintUrl: 'https://mint.test', method: 'bolt11', quoteId: 'shared-quote', - quote: { - mintUrl: 'https://mint.test', - method: 'bolt11', - quoteId: 'shared-quote', - quote: 'shared-quote', - state: 'PAID', - } as any, + quote: makeBolt11Quote('shared-quote', true), }); await sleep(TEST_PROCESS_INTERVAL * 2 + 50); @@ -369,13 +366,7 @@ describe('MintOperationProcessor', () => { mintUrl: 'https://mint.test', method: 'bolt11', quoteId: 'quote-4', - quote: { - mintUrl: 'https://mint.test', - method: 'bolt11', - quoteId: 'quote-4', - quote: 'quote-4', - state: 'UNPAID', - } as any, + quote: makeBolt11Quote('quote-4', false), }); await sleep(TEST_PROCESS_INTERVAL + 20); @@ -391,13 +382,7 @@ describe('MintOperationProcessor', () => { mintUrl: 'https://mint.test', method: 'bolt11', quoteId: 'quote-5', - quote: { - mintUrl: 'https://mint.test', - method: 'bolt11', - quoteId: 'quote-5', - quote: 'quote-5', - state: 'PAID', - } as any, + quote: makeBolt11Quote('quote-5', true), }); } diff --git a/packages/core/test/unit/MintService.test.ts b/packages/core/test/unit/MintService.test.ts index 7905dd8f6..9fef3f2bf 100644 --- a/packages/core/test/unit/MintService.test.ts +++ b/packages/core/test/unit/MintService.test.ts @@ -334,6 +334,26 @@ describe('MintService', () => { ).resolves.toBeUndefined(); }); + it('checks recovery and security NUT capabilities through the same typed API', async () => { + useMintInfo({ + ...mockMintInfo, + nuts: { + ...mockMintInfo.nuts, + '7': { supported: true }, + '8': { supported: false }, + '9': { supported: true }, + '17': { supported: [{ method: 'bolt11', unit: 'sat', commands: ['subscribe'] }] }, + '20': { supported: true }, + }, + } as MintInfo); + + await expect(service.supportsNut(testMintUrl, 7)).resolves.toBe(true); + await expect(service.supportsNut(testMintUrl, 8)).resolves.toBe(false); + await expect(service.assertNutSupported(testMintUrl, 9)).resolves.toBeUndefined(); + await expect(service.assertNutSupported(testMintUrl, 17)).resolves.toBeUndefined(); + await expect(service.assertNutSupported(testMintUrl, 20)).resolves.toBeUndefined(); + }); + it('returns false and rejects when NUT-11 metadata is missing', async () => { useMintInfo({ ...mockMintInfo, diff --git a/packages/core/test/unit/MintSwapHttpFixture.test.ts b/packages/core/test/unit/MintSwapHttpFixture.test.ts new file mode 100644 index 000000000..087e027e8 --- /dev/null +++ b/packages/core/test/unit/MintSwapHttpFixture.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it } from 'bun:test'; + +import { MintSwapHttpFixture } from '../fixtures/MintSwapHttpFixture.ts'; + +describe('MintSwapHttpFixture', () => { + const fixtures: MintSwapHttpFixture[] = []; + afterEach(() => fixtures.splice(0).forEach((fixture) => fixture.stop())); + + it('preserves remote melt truth when the response is lost after commit', async () => { + const fixture = new MintSwapHttpFixture(); + fixtures.push(fixture); + fixture.start(); + fixture.failNext('melt:after-commit'); + + const lost = await fetch(`${fixture.url}/v1/melt/bolt11`, { + method: 'POST', + body: JSON.stringify({ quote: 'source-quote', inputs: [] }), + }); + expect(lost.status).toBe(503); + expect(fixture.meltState).toBe('PENDING'); + fixture.meltState = 'PAID'; + fixture.meltChange = [{ amount: 2 }]; + const observed = await fetch(`${fixture.url}/v1/melt/quote/bolt11/source-quote`); + expect(await observed.json()).toMatchObject({ state: 'PAID', change: [{ amount: 2 }] }); + expect(fixture.calls.filter((call) => call.path === '/v1/melt/bolt11')).toHaveLength(1); + }); + + it('restores issued signatures after an ambiguous destination response', async () => { + const fixture = new MintSwapHttpFixture(); + fixtures.push(fixture); + fixture.start(); + fixture.mintState = 'PAID'; + fixture.issuedSignatures = [{ id: 'keyset', amount: 100, C_: 'signature' }]; + fixture.restoredSignatures = fixture.issuedSignatures; + fixture.failNext('mint:after-commit'); + + const lost = await fetch(`${fixture.url}/v1/mint/bolt11`, { + method: 'POST', + body: JSON.stringify({ quote: 'destination-quote', outputs: [{ amount: 100 }] }), + }); + expect(lost.status).toBe(503); + expect(fixture.mintState as string).toBe('ISSUED'); + const restored = await fetch(`${fixture.url}/v1/restore`, { + method: 'POST', + body: JSON.stringify({ outputs: [{ amount: 100 }] }), + }); + expect(await restored.json()).toMatchObject({ signatures: fixture.issuedSignatures }); + expect(fixture.calls.filter((call) => call.path === '/v1/mint/bolt11')).toHaveLength(1); + }); +}); diff --git a/packages/core/test/unit/MintSwapOperation.test.ts b/packages/core/test/unit/MintSwapOperation.test.ts new file mode 100644 index 000000000..a06225abe --- /dev/null +++ b/packages/core/test/unit/MintSwapOperation.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'bun:test'; +import { Amount } from '@cashu/cashu-ts'; + +import { + assertMintSwapTransition, + createMintSwapPreparedPlanFingerprint, + validateMintSwapAccounting, + validateMintSwapOperation, + type MintSwapOperationState, +} from '../../operations/mintSwap/MintSwapOperation'; +import { makePreparedMintSwapOperation, MINT_SWAP_TEST_NOW as now } from '../fixtures/MintSwap'; + +describe('MintSwapOperation', () => { + it('accepts the normative transition graph and rejects terminal regression', () => { + const legal: Array<[MintSwapOperationState, MintSwapOperationState]> = [ + ['preparing', 'prepared'], + ['prepared', 'source_inflight'], + ['source_inflight', 'destination_funded'], + ['destination_funded', 'issuing'], + ['issuing', 'completed'], + ['source_inflight', 'needs_attention'], + ['needs_attention', 'issuing'], + ]; + for (const [from, to] of legal) expect(() => assertMintSwapTransition(from, to)).not.toThrow(); + + expect(() => assertMintSwapTransition('completed', 'issuing')).toThrow('Illegal'); + expect(() => assertMintSwapTransition('destination_funded', 'failed')).toThrow('Illegal'); + }); + + it('validates both settlement equations and exact destination issuance', () => { + const operation = makePreparedMintSwapOperation({ + state: 'completed', + sourceDispatchAuthorizedAt: now + 1, + destinationIssueAuthorizedAt: now + 2, + settlement: { + sourcePaymentFee: Amount.from(2), + totalSourceFee: Amount.from(4), + sourceMeltChangeAmount: Amount.from(6), + sourceKeepAmount: Amount.from(0), + sourceReturnedAmount: Amount.from(6), + finalSourceDebit: Amount.from(104), + destinationAmountIssued: Amount.from(100), + }, + completedAt: now + 3, + updatedAt: now + 3, + }); + + expect(() => validateMintSwapOperation(operation)).not.toThrow(); + expect(() => validateMintSwapAccounting(operation)).not.toThrow(); + + expect(() => + validateMintSwapAccounting({ + ...operation, + settlement: { ...operation.settlement!, finalSourceDebit: Amount.from(105) }, + }), + ).toThrow('does not reconcile'); + }); + + it('rejects incomplete state records and same-mint operations', () => { + expect(() => + validateMintSwapOperation({ + ...makePreparedMintSwapOperation(), + destinationMintOperationId: undefined, + }), + ).toThrow('complete prepared plan'); + expect(() => + validateMintSwapOperation({ + ...makePreparedMintSwapOperation(), + destinationMintUrl: 'https://source.test/', + }), + ).toThrow('distinct'); + }); + + it('creates stable fingerprints that are sensitive to economic and recovery inputs', () => { + const base = { + destinationMintOperationId: 'destination-child', + sourceMeltOperationId: 'source-child', + destinationQuoteRef: { + mintUrl: 'https://destination.test/', + method: 'bolt11' as const, + quoteId: 'destination-quote', + }, + sourceQuoteRef: { + mintUrl: 'https://source.test', + method: 'bolt11' as const, + quoteId: 'source-quote', + }, + destinationAmount: Amount.from(100), + unit: 'sat' as const, + sourceInputProofSecrets: ['secret-a', 'secret-b'], + destinationOutputData: { send: [{ amount: '64' }, { amount: '32' }, { amount: '4' }] }, + sourceOutputData: { keep: [{ amount: '6' }] }, + maximumSourceDebit: Amount.from(110), + }; + const first = createMintSwapPreparedPlanFingerprint(base); + const reorderedKeys = createMintSwapPreparedPlanFingerprint({ ...base }); + const changed = createMintSwapPreparedPlanFingerprint({ + ...base, + maximumSourceDebit: Amount.from(111), + }); + + expect(first).toBe(reorderedKeys); + expect(first).not.toBe(changed); + expect(first).toMatch(/^[0-9a-f]{64}$/); + }); +}); diff --git a/packages/core/test/unit/MintSwapOperationProcessor.test.ts b/packages/core/test/unit/MintSwapOperationProcessor.test.ts new file mode 100644 index 000000000..7ddf4bb83 --- /dev/null +++ b/packages/core/test/unit/MintSwapOperationProcessor.test.ts @@ -0,0 +1,222 @@ +import { Amount } from '@cashu/cashu-ts'; +import { describe, expect, it, mock } from 'bun:test'; + +import { EventBus, type CoreEvents } from '../../events/index.ts'; +import { OperationInProgressError } from '../../models/Error.ts'; +import type { OperationEventOutboxRecord } from '../../models/OperationEventOutbox.ts'; +import type { MintSwapOperationService } from '../../operations/mintSwap/index.ts'; +import { MemoryRepositories } from '../../repositories/memory/MemoryRepositories.ts'; +import { OperationEventOutboxPublisher } from '../../services/OperationEventOutboxPublisher.ts'; +import { MintSwapOperationProcessor } from '../../services/watchers/MintSwapOperationProcessor.ts'; +import { makePreparedMintSwapOperation } from '../fixtures/MintSwap.ts'; + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('OperationEventOutboxPublisher', () => { + it('publishes a durable event once and marks it only after listeners complete', async () => { + const repositories = new MemoryRepositories(); + const bus = new EventBus(); + const seen: number[] = []; + bus.on('mint-swap-op:completed', ({ revision }) => { + seen.push(revision); + }); + await repositories.operationEventOutboxRepository.enqueue(makeOutbox()); + + const publisher = new OperationEventOutboxPublisher( + repositories.operationEventOutboxRepository, + bus, + ); + expect(await publisher.publishDue()).toBe(1); + expect(await publisher.publishDue()).toBe(0); + expect(seen).toEqual([4]); + }); + + it('persists publication backoff and replays after a listener failure', async () => { + const repositories = new MemoryRepositories(); + const bus = new EventBus(); + let fail = true; + bus.on('mint-swap-op:completed', () => { + if (fail) throw new Error('listener unavailable'); + }); + await repositories.operationEventOutboxRepository.enqueue(makeOutbox()); + const publisher = new OperationEventOutboxPublisher( + repositories.operationEventOutboxRepository, + bus, + undefined, + { baseRetryDelayMs: 10, random: () => 0.999 }, + ); + + await publisher.publishDue(100); + expect(await repositories.operationEventOutboxRepository.getUnpublished(10, 100)).toHaveLength( + 0, + ); + fail = false; + expect(await publisher.publishDue(110)).toBe(1); + expect(await publisher.publishDue(111)).toBe(0); + }); +}); + +describe('MintSwapOperationProcessor', () => { + it('sweeps durable due work even when no wake-up event was observed', async () => { + const repositories = new MemoryRepositories(); + await repositories.mintSwapOperationRepository.create( + makePreparedMintSwapOperation({ + state: 'source_inflight', + revision: 0, + sourceDispatchAuthorizedAt: Date.now(), + }), + ); + const refresh = mock(async () => makePreparedMintSwapOperation({ state: 'completed' })); + const service = { + refresh, + get: mock(async () => null), + recordProcessorSuccess: mock(async () => makePreparedMintSwapOperation()), + recordProcessorFailure: mock(async () => makePreparedMintSwapOperation()), + } as unknown as MintSwapOperationService; + const processor = new MintSwapOperationProcessor( + service, + repositories, + new EventBus(), + undefined, + { sweepIntervalMs: 60_000 }, + ); + + await processor.start(); + await tick(); + await processor.stop(); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it('persists exponential retry timing without converting ambiguity into attention', async () => { + const repositories = new MemoryRepositories(); + const operation = makePreparedMintSwapOperation({ + state: 'source_inflight', + revision: 0, + sourceDispatchAuthorizedAt: Date.now(), + }); + await repositories.mintSwapOperationRepository.create(operation); + const recordFailure = mock(async (_id: string, _error: string, _nextAttemptAt: number) => + Promise.resolve(operation), + ); + const service = { + refresh: mock(async () => { + throw new Error('mint temporarily unavailable'); + }), + get: mock(async () => operation), + recordProcessorSuccess: mock(async () => operation), + recordProcessorFailure: recordFailure, + } as unknown as MintSwapOperationService; + const processor = new MintSwapOperationProcessor( + service, + repositories, + new EventBus(), + undefined, + { sweepIntervalMs: 60_000, baseRetryDelayMs: 100, random: () => 0.5 }, + ); + + const before = Date.now(); + await processor.start(); + await tick(); + await processor.stop(); + expect(recordFailure).toHaveBeenCalledTimes(1); + expect(recordFailure.mock.calls[0]![2]).toBeGreaterThanOrEqual(before + 50); + expect(recordFailure.mock.calls[0]![2]).toBeLessThanOrEqual(Date.now() + 50); + expect(recordFailure.mock.calls[0]![1]).not.toContain('mint temporarily unavailable'); + expect(recordFailure.mock.calls[0]![1]).toContain('[redacted:'); + }); + + it('uses the longer post-payment retry policy with full jitter', async () => { + const repositories = new MemoryRepositories(); + const operation = makePreparedMintSwapOperation({ + state: 'destination_funded', + revision: 0, + sourceDispatchAuthorizedAt: Date.now(), + settlement: { + sourcePaymentFee: Amount.from(6), + totalSourceFee: Amount.from(8), + sourceMeltChangeAmount: Amount.from(2), + sourceKeepAmount: Amount.zero(), + sourceReturnedAmount: Amount.from(2), + finalSourceDebit: Amount.from(108), + }, + }); + await repositories.mintSwapOperationRepository.create(operation); + const recordFailure = mock(async (_id: string, _error: string, _nextAttemptAt: number) => + Promise.resolve(operation), + ); + const service = { + refresh: mock(async () => { + throw new Error('destination temporarily unavailable'); + }), + get: mock(async () => operation), + recordProcessorSuccess: mock(async () => operation), + recordProcessorFailure: recordFailure, + } as unknown as MintSwapOperationService; + const processor = new MintSwapOperationProcessor( + service, + repositories, + new EventBus(), + undefined, + { sweepIntervalMs: 60_000, random: () => 0.5 }, + ); + + const before = Date.now(); + await processor.start(); + await tick(); + await processor.stop(); + + expect(recordFailure.mock.calls[0]![2]).toBeGreaterThanOrEqual(before + 1_000); + expect(recordFailure.mock.calls[0]![2]).toBeLessThanOrEqual(Date.now() + 1_000); + }); + + it('does not persist retry backoff while a foreground command owns the operation lock', async () => { + const repositories = new MemoryRepositories(); + const operation = makePreparedMintSwapOperation({ + state: 'source_inflight', + revision: 0, + sourceDispatchAuthorizedAt: Date.now(), + }); + await repositories.mintSwapOperationRepository.create(operation); + const recordFailure = mock(async () => operation); + const service = { + refresh: mock(async () => { + throw new OperationInProgressError(operation.id); + }), + get: mock(async () => operation), + recordProcessorSuccess: mock(async () => operation), + recordProcessorFailure: recordFailure, + } as unknown as MintSwapOperationService; + const processor = new MintSwapOperationProcessor( + service, + repositories, + new EventBus(), + undefined, + { sweepIntervalMs: 60_000 }, + ); + + await processor.start(); + await tick(); + await processor.stop(); + expect(recordFailure).not.toHaveBeenCalled(); + }); +}); + +function makeOutbox(): OperationEventOutboxRecord { + return { + id: 'event-1', + operationId: 'swap-1', + revision: 4, + eventType: 'mint-swap-op:completed', + payload: { + operationId: 'swap-1', + revision: 4, + state: 'completed', + sourceMintUrl: 'https://source.test', + destinationMintUrl: 'https://destination.test', + unit: 'sat', + destinationAmount: '100', + }, + createdAt: 1, + publishAttempts: 0, + }; +} diff --git a/packages/core/test/unit/MintSwapOperationService.test.ts b/packages/core/test/unit/MintSwapOperationService.test.ts new file mode 100644 index 000000000..16cc68ace --- /dev/null +++ b/packages/core/test/unit/MintSwapOperationService.test.ts @@ -0,0 +1,480 @@ +import { Amount, type Proof } from '@cashu/cashu-ts'; +import { beforeEach, describe, expect, it, mock } from 'bun:test'; + +import type { MintQuote } from '../../models/MintQuote.ts'; +import type { MeltQuote } from '../../models/MeltQuote.ts'; +import { + MintSwapOperationService, + type PrepareMintSwapInput, +} from '../../operations/mintSwap/MintSwapOperationService.ts'; +import type { ExecutingMintOperation } from '../../operations/mint/MintOperation.ts'; +import type { ExecutingMeltOperation } from '../../operations/melt/MeltOperation.ts'; +import { MintScopedLock } from '../../operations/MintScopedLock.ts'; +import { MemoryRepositories } from '../../repositories/memory/MemoryRepositories.ts'; + +describe('MintSwapOperationService', () => { + const sourceMintUrl = 'https://source.test'; + const destinationMintUrl = 'https://destination.test'; + const amount = Amount.from(100); + const futureExpiry = Math.floor(Date.now() / 1000) + 600; + const destinationQuote: MintQuote<'bolt11'> = { + mintUrl: destinationMintUrl, + method: 'bolt11', + quoteId: 'destination-quote', + quote: 'destination-quote', + request: 'lnbc1destination', + amount, + unit: 'sat', + expiry: futureExpiry, + state: 'UNPAID', + pubkey: '02destination', + reusable: false, + amountPaid: Amount.zero(), + amountIssued: Amount.zero(), + remoteUpdatedAt: 1, + quoteData: { amount }, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + const sourceQuote: MeltQuote<'bolt11'> = { + mintUrl: sourceMintUrl, + method: 'bolt11', + quoteId: 'source-quote', + quote: 'source-quote', + request: 'lnbc1destination', + amount, + unit: 'sat', + fee_reserve: Amount.from(8), + expiry: futureExpiry, + state: 'UNPAID', + payment_preimage: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + let repositories: MemoryRepositories; + let service: MintSwapOperationService; + let mintService: any; + let quoteLifecycle: any; + let mintOperationService: any; + let meltOperationService: any; + let keyRingService: any; + + const input: PrepareMintSwapInput = { + sourceMintUrl: `${sourceMintUrl}/`, + destinationMintUrl: `${destinationMintUrl}/`, + amount, + }; + + beforeEach(() => { + repositories = new MemoryRepositories(); + mintService = { + isTrustedMint: mock(async () => true), + assertMethodUnitSupported: mock(async () => {}), + assertNutSupported: mock(async () => {}), + supportsNut: mock(async () => true), + }; + quoteLifecycle = { + createMintQuote: mock(async () => ({ ...destinationQuote })), + createMeltQuote: mock(async () => ({ ...sourceQuote })), + refreshMintQuote: mock(async () => ({ + ...destinationQuote, + state: 'PAID', + amountPaid: amount, + })), + refreshMeltQuote: mock(async () => ({ ...sourceQuote, state: 'PAID' })), + getMeltQuote: mock(async () => sourceQuote), + }; + + mintOperationService = { + prepareOwnedInTransaction: mock(async (command: any) => { + const child = { + id: command.operationId, + state: 'pending', + mintUrl: destinationMintUrl, + method: 'bolt11', + methodData: {}, + quoteId: destinationQuote.quoteId, + amount, + unit: 'sat', + request: destinationQuote.request, + expiry: destinationQuote.expiry, + pubkey: destinationQuote.pubkey, + outputData: { keep: [], send: [] }, + parentSwapOperationId: command.parentSwapOperationId, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + await command.repositories.mintOperationRepository.create(child); + return child; + }), + authorizeOwnedExecutionInTransaction: mock( + async (id: string, _parentId: string, scope: any) => { + const child = await scope.mintOperationRepository.getById(id); + const executing = { ...child, state: 'executing', updatedAt: Date.now() }; + await scope.mintOperationRepository.update(executing); + return executing; + }, + ), + executeOwnedRemote: mock(async () => ({ status: 'ISSUED', proofs: [] })), + applyOwnedExecutionInTransaction: mock( + async (executing: ExecutingMintOperation, _parentId: string, _result: any, scope: any) => { + const proof = { + id: 'destination-keyset', + amount, + secret: 'destination-proof', + C: 'C-destination', + mintUrl: destinationMintUrl, + unit: 'sat', + state: 'ready', + createdByOperationId: executing.id, + }; + await scope.proofRepository.saveProofs(destinationMintUrl, [proof]); + const finalized = { ...executing, state: 'finalized', updatedAt: Date.now() }; + await scope.mintOperationRepository.update(finalized); + return finalized; + }, + ), + recoverOwnedExecuting: mock(async () => {}), + }; + + meltOperationService = { + prepareOwnedInTransaction: mock(async (command: any) => { + const child = { + id: command.operationId, + state: 'prepared', + mintUrl: sourceMintUrl, + method: 'bolt11', + methodData: { invoice: destinationQuote.request }, + quoteId: sourceQuote.quoteId, + amount, + unit: 'sat', + fee_reserve: Amount.from(8), + swap_fee: Amount.zero(), + needsSwap: false, + inputAmount: Amount.from(110), + inputProofSecrets: ['source-proof'], + changeOutputData: { keep: [], send: [] }, + parentSwapOperationId: command.parentSwapOperationId, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + const proof = { + id: 'source-keyset', + amount: Amount.from(110), + secret: 'source-proof', + C: 'C-source', + mintUrl: sourceMintUrl, + unit: 'sat', + state: 'ready', + usedByOperationId: child.id, + }; + await command.repositories.proofRepository.saveProofs(sourceMintUrl, [proof]); + await command.repositories.meltOperationRepository.create(child); + return child; + }), + authorizeOwnedExecutionInTransaction: mock( + async (id: string, _parentId: string, scope: any) => { + const child = await scope.meltOperationRepository.getById(id); + const executing = { ...child, state: 'executing', updatedAt: Date.now() }; + await scope.meltOperationRepository.update(executing); + return executing; + }, + ), + executeOwnedRemote: mock(async () => ({ status: 'PAID' })), + applyOwnedExecutionInTransaction: mock( + async (executing: ExecutingMeltOperation, _parentId: string, _result: any, scope: any) => { + const finalized = { + ...executing, + state: 'finalized', + changeAmount: Amount.from(4), + effectiveFee: Amount.from(6), + updatedAt: Date.now(), + }; + await scope.meltOperationRepository.update(finalized); + return finalized; + }, + ), + rollbackOwnedPreparedInTransaction: mock( + async (id: string, _parentId: string, _wallet: any, scope: any) => { + const child = await scope.meltOperationRepository.getById(id); + const rolledBack = { ...child, state: 'rolled_back', updatedAt: Date.now() }; + await scope.meltOperationRepository.update(rolledBack); + return rolledBack; + }, + ), + recoverOwnedExecuting: mock(async () => {}), + }; + + keyRingService = { + generateMintQuoteKeyPair: mock(async () => ({ + publicKeyHex: destinationQuote.pubkey, + secretKey: new Uint8Array(32), + derivationIndex: 1, + purpose: 'nut20_mint_quote', + })), + getMintQuoteKeyPair: mock(async () => ({ + publicKeyHex: destinationQuote.pubkey, + secretKey: new Uint8Array(32), + derivationIndex: 1, + purpose: 'nut20_mint_quote', + })), + }; + + service = new MintSwapOperationService( + repositories, + quoteLifecycle, + mintOperationService, + meltOperationService, + mintService, + { + getWalletWithActiveKeysetId: mock(async () => ({ + wallet: { getFeesForProofs: (_proofs: Proof[]) => Amount.from(2) }, + })), + } as any, + keyRingService, + new MintScopedLock(), + ); + }); + + it('prepares an immutable exact-receive plan without dispatching payment', async () => { + const operation = await service.prepare(input); + + expect(operation.state).toBe('prepared'); + expect(operation.preparedPlan?.minimumSourceDebit.toString()).toBe('102'); + expect(operation.preparedPlan?.maximumSourceDebit.toString()).toBe('110'); + expect(operation.preparedPlan?.reservedSourceAmount.toString()).toBe('110'); + expect(operation.destinationNut20Key?.derivationIndex).toBe(1); + expect(meltOperationService.executeOwnedRemote).not.toHaveBeenCalled(); + expect(await repositories.operationEventOutboxRepository.getUnpublished(10)).toHaveLength(1); + }); + + it('rejects a compatibility-only destination quote without remote accounting order', async () => { + quoteLifecycle.createMintQuote = mock(async () => ({ + ...destinationQuote, + remoteUpdatedAt: null, + })); + + await expect(service.prepare(input)).rejects.toThrow('could not be prepared'); + + const [failed] = await service.list({ state: 'failed' }); + expect(failed?.terminalFailure?.code).toBe('preparation_failed'); + expect(quoteLifecycle.createMeltQuote).not.toHaveBeenCalled(); + }); + + it('keeps a live preparation locked against recovery sweeps', async () => { + let releaseQuote!: () => void; + const quoteBarrier = new Promise((resolve) => { + releaseQuote = resolve; + }); + quoteLifecycle.createMintQuote = mock(async () => { + await quoteBarrier; + return { ...destinationQuote }; + }); + + const preparing = service.prepare(input); + let operation = (await service.list({ state: 'preparing' }))[0]; + while (!operation) { + await Promise.resolve(); + operation = (await service.list({ state: 'preparing' }))[0]; + } + + expect(service.isOperationLocked(operation.id)).toBe(true); + await expect(service.refresh(operation.id)).rejects.toThrow('already in progress'); + expect((await service.get(operation.id))?.state).toBe('preparing'); + + releaseQuote(); + expect((await preparing).state).toBe('prepared'); + }); + + it('commits source authorization, settles accounting, then issues exactly once', async () => { + const prepared = await service.prepare(input); + const funded = await service.execute(prepared.id); + + expect(funded.state).toBe('destination_funded'); + expect(funded.settlement?.finalSourceDebit.toString()).toBe('106'); + expect(funded.settlement?.totalSourceFee.toString()).toBe('6'); + const completed = await service.refresh(prepared.id); + expect(completed.state).toBe('completed'); + expect(completed.settlement?.destinationAmountIssued?.toString()).toBe('100'); + expect(meltOperationService.executeOwnedRemote).toHaveBeenCalledTimes(1); + expect(mintOperationService.executeOwnedRemote).toHaveBeenCalledTimes(1); + }); + + it('waits for canonical destination funding before authorizing issuance', async () => { + const prepared = await service.prepare(input); + await service.execute(prepared.id); + quoteLifecycle.refreshMintQuote = mock(async () => ({ ...destinationQuote })); + + const waiting = await service.refresh(prepared.id); + + expect(waiting.state).toBe('destination_funded'); + expect(mintOperationService.authorizeOwnedExecutionInTransaction).not.toHaveBeenCalled(); + expect(mintOperationService.executeOwnedRemote).not.toHaveBeenCalled(); + }); + + it('restores observed destination issuance without posting another mint request', async () => { + const prepared = await service.prepare(input); + await service.execute(prepared.id); + quoteLifecycle.refreshMintQuote = mock(async () => ({ + ...destinationQuote, + state: 'ISSUED', + amountPaid: amount, + amountIssued: amount, + })); + + const recovering = await service.refresh(prepared.id); + + expect(recovering.state).toBe('issuing'); + expect(mintOperationService.recoverOwnedExecuting).toHaveBeenCalledTimes(1); + expect(mintOperationService.executeOwnedRemote).not.toHaveBeenCalled(); + }); + + it('moves to attention when destination refresh loses the remote accounting order', async () => { + const prepared = await service.prepare(input); + await service.execute(prepared.id); + quoteLifecycle.refreshMintQuote = mock(async () => ({ + ...destinationQuote, + amountPaid: amount, + remoteUpdatedAt: null, + })); + + const attention = await service.refresh(prepared.id); + + expect(attention.state).toBe('needs_attention'); + expect(attention.attention?.reason).toBe('canonical_observation_conflict'); + expect(mintOperationService.executeOwnedRemote).not.toHaveBeenCalled(); + }); + + it('rolls back the source reservation when cancelling before dispatch', async () => { + const prepared = await service.prepare(input); + const cancelled = await service.cancel(prepared.id, 'changed mind'); + + expect(cancelled.state).toBe('cancelled'); + expect(meltOperationService.rollbackOwnedPreparedInTransaction).toHaveBeenCalledTimes(1); + expect(meltOperationService.executeOwnedRemote).not.toHaveBeenCalled(); + }); + + it('rejects untrusted mints before creating a parent', async () => { + mintService.isTrustedMint = mock(async (mintUrl: string) => mintUrl !== sourceMintUrl); + + await expect(service.prepare(input)).rejects.toThrow('explicitly trusted'); + expect(await service.list()).toHaveLength(0); + }); + + it('rechecks normalized trust before source dispatch', async () => { + const prepared = await service.prepare(input); + expect(mintService.isTrustedMint).toHaveBeenCalledWith(sourceMintUrl); + expect(mintService.isTrustedMint).toHaveBeenCalledWith(destinationMintUrl); + mintService.isTrustedMint = mock(async () => false); + + await expect(service.execute(prepared.id)).rejects.toThrow('explicitly trusted'); + expect((await service.get(prepared.id))?.state).toBe('failed'); + expect( + (await repositories.meltOperationRepository.getById(prepared.sourceMeltOperationId!))?.state, + ).toBe('rolled_back'); + expect(meltOperationService.executeOwnedRemote).not.toHaveBeenCalled(); + }); + + it('fails safely before dispatch when the destination recovery key is missing', async () => { + const prepared = await service.prepare(input); + keyRingService.getMintQuoteKeyPair = mock(async () => null); + + await expect(service.execute(prepared.id)).rejects.toThrow('recovery key is unavailable'); + expect((await service.get(prepared.id))?.state).toBe('failed'); + expect( + (await repositories.meltOperationRepository.getById(prepared.sourceMeltOperationId!))?.state, + ).toBe('rolled_back'); + expect(meltOperationService.executeOwnedRemote).not.toHaveBeenCalled(); + }); + + it('halts before dispatch when persisted child data no longer matches the prepared plan', async () => { + const prepared = await service.prepare(input); + const child = await repositories.mintOperationRepository.getById( + prepared.destinationMintOperationId!, + ); + await repositories.mintOperationRepository.update({ + ...child!, + outputData: { keep: [{ tampered: true }], send: [] }, + updatedAt: Date.now(), + } as any); + + const attention = await service.execute(prepared.id); + expect(attention.state).toBe('needs_attention'); + expect(attention.attention?.reason).toBe('prepared_plan_mismatch'); + expect(meltOperationService.executeOwnedRemote).not.toHaveBeenCalled(); + }); + + it('fails preparation when the immutable dispatch window is too short', async () => { + await expect( + service.prepare({ ...input, requiredDispatchWindowSeconds: 1_000 }), + ).rejects.toBeInstanceOf(Error); + + const [failed] = await service.list({ state: 'failed' }); + expect(failed?.terminalFailure?.code).toBe('preparation_failed'); + expect(meltOperationService.executeOwnedRemote).not.toHaveBeenCalled(); + }); + + it('moves to attention when the destination becomes terminal after source payment', async () => { + const prepared = await service.prepare(input); + const funded = await service.execute(prepared.id); + mintOperationService.executeOwnedRemote = mock(async () => ({ status: 'ALREADY_ISSUED' })); + mintOperationService.applyOwnedExecutionInTransaction = mock( + async (executing: ExecutingMintOperation) => executing, + ); + + const issuing = await service.refresh(funded.id); + expect(issuing.state).toBe('issuing'); + const destinationChild = await repositories.mintOperationRepository.getById( + issuing.destinationMintOperationId!, + ); + await repositories.mintOperationRepository.update({ + ...destinationChild!, + state: 'failed', + updatedAt: Date.now(), + } as any); + + const attention = await service.refresh(issuing.id); + expect(attention.state).toBe('needs_attention'); + expect(attention.attention?.reason).toBe('source_paid_destination_terminal'); + }); + + it('moves to attention when the destination recovery key is missing after source payment', async () => { + const prepared = await service.prepare(input); + const funded = await service.execute(prepared.id); + keyRingService.getMintQuoteKeyPair = mock(async () => null); + + const attention = await service.refresh(funded.id); + + expect(attention.state).toBe('needs_attention'); + expect(attention.attention?.reason).toBe('required_recovery_capability_missing'); + expect(mintOperationService.executeOwnedRemote).not.toHaveBeenCalled(); + }); + + it('rejects cancellation after source funding', async () => { + const prepared = await service.prepare(input); + const funded = await service.execute(prepared.id); + + await expect(service.cancel(funded.id)).rejects.toThrow('after destination funding'); + }); + + it('serializes execute against concurrent refresh and retry calls', async () => { + const prepared = await service.prepare(input); + let releaseRemote!: () => void; + const remoteBarrier = new Promise((resolve) => { + releaseRemote = resolve; + }); + meltOperationService.executeOwnedRemote = mock(async () => { + await remoteBarrier; + return { status: 'PAID' }; + }); + + const executing = service.execute(prepared.id); + while (!service.isOperationLocked(prepared.id)) await Promise.resolve(); + await expect(service.refresh(prepared.id)).rejects.toThrow('already in progress'); + await expect(service.retry(prepared.id)).rejects.toThrow('already in progress'); + releaseRemote(); + expect((await executing).state).toBe('destination_funded'); + expect(meltOperationService.executeOwnedRemote).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/test/unit/MintSwapPolicy.test.ts b/packages/core/test/unit/MintSwapPolicy.test.ts new file mode 100644 index 000000000..064e2521b --- /dev/null +++ b/packages/core/test/unit/MintSwapPolicy.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'bun:test'; +import { + DEFAULT_MINT_SWAP_DISPATCH_WINDOW_SECONDS, + evaluateMintSwapDispatchWindow, +} from '../../models/MintSwapPolicy'; +import { redactError, redactSensitiveValue } from '../../logging/redaction'; + +describe('mint swap protocol policy', () => { + it('uses the earliest finite expiry and the 120-second default', () => { + const result = evaluateMintSwapDispatchWindow({ + expiries: [0, null, 1_300, 1_250, 1_400], + now: 1_100, + }); + + expect(result.dispatchDeadline).toBe(1_250); + expect(result.remainingSeconds).toBe(150); + expect(result.requiredWindowSeconds).toBe(DEFAULT_MINT_SWAP_DISPATCH_WINDOW_SECONDS); + expect(result.canDispatch).toBe(true); + }); + + it('rejects dispatch inside the configured window and windows below 30 seconds', () => { + expect(evaluateMintSwapDispatchWindow({ expiries: [1_219], now: 1_100 }).canDispatch).toBe( + false, + ); + expect(() => + evaluateMintSwapDispatchWindow({ + expiries: [1_300], + now: 1_100, + requiredWindowSeconds: 29, + }), + ).toThrow('at least 30 seconds'); + }); + + it('rejects malformed finite expiries instead of silently weakening the deadline', () => { + expect(() => evaluateMintSwapDispatchWindow({ expiries: [-1, 1_300], now: 1_100 })).toThrow( + 'positive Unix timestamp', + ); + expect(() => + evaluateMintSwapDispatchWindow({ expiries: [Number.MAX_SAFE_INTEGER + 1], now: 1_100 }), + ).toThrow('positive Unix timestamp'); + }); + + it('redacts sensitive identifiers with a stable diagnostic fingerprint', () => { + const secret = 'quote-id-that-must-not-appear'; + const redacted = redactSensitiveValue(secret); + + expect(redacted).toBe(redactSensitiveValue(secret)); + expect(redacted).not.toContain(secret); + expect(redacted).toMatch(/^\[redacted:[0-9a-f]{12}\]$/); + }); + + it('redacts arbitrary error messages before persistence or logging', () => { + const secret = 'lnbc1-sensitive-invoice'; + const redacted = redactError(new Error(`Failed quote ${secret}`)); + + expect(redacted).toContain('[redacted:'); + expect(redacted).not.toContain(secret); + expect(redacted).not.toContain('Failed quote'); + }); +}); diff --git a/packages/core/test/unit/MintSwapPublicSurface.test.ts b/packages/core/test/unit/MintSwapPublicSurface.test.ts new file mode 100644 index 000000000..2be3a21da --- /dev/null +++ b/packages/core/test/unit/MintSwapPublicSurface.test.ts @@ -0,0 +1,119 @@ +import { Amount } from '@cashu/cashu-ts'; +import { describe, expect, it, mock } from 'bun:test'; + +import { MintSwapOpsApi } from '../../api/MintSwapOpsApi.ts'; +import { EventBus, type CoreEvents } from '../../events/index.ts'; +import type { HistoryEntry } from '../../models/History.ts'; +import { projectMintSwapOperation } from '../../models/History.ts'; +import type { MintSwapOperationService } from '../../operations/mintSwap/index.ts'; +import { MemoryRepositories } from '../../repositories/memory/MemoryRepositories.ts'; +import type { HistoryProjectionRepository } from '../../repositories/index.ts'; +import { HistoryService } from '../../services/HistoryService.ts'; +import { makePreparedMintSwapOperation } from '../fixtures/MintSwap.ts'; + +describe('mint swap public surface', () => { + it('projects one sanitized grouped history entry with preview and settlement facts', () => { + const operation = makePreparedMintSwapOperation({ + state: 'needs_attention', + attention: { + reason: 'accounting_mismatch', + message: 'Destination amount did not reconcile', + lastSafeState: 'destination_funded', + violatedInvariant: 'destination amount', + evidence: { operationId: 'swap-1' }, + at: Date.now(), + }, + }); + const entry = projectMintSwapOperation(operation); + expect(entry).toMatchObject({ + id: 'mint-swap:swap-1', + type: 'mint-swap', + sourceMintUrl: 'https://source.test', + destinationMintUrl: 'https://destination.test', + reasonCode: 'accounting_mismatch', + }); + expect(entry.minimumSourceDebit?.toString()).toBe('102'); + expect(entry).not.toHaveProperty('destinationNut20Key'); + }); + + it('suppresses parent-owned child rows from grouped history by default', async () => { + const repositories = new MemoryRepositories(); + const parent = makePreparedMintSwapOperation({ revision: 0 }); + await repositories.mintSwapOperationRepository.create(parent); + const entries: HistoryEntry[] = [ + childHistory('mint', parent.destinationMintOperationId!), + childHistory('melt', parent.sourceMeltOperationId!), + childHistory('send', 'standalone-child'), + ]; + const historyRepository: HistoryProjectionRepository = { + getPaginatedHistoryEntries: mock(async () => entries), + getHistoryEntryById: mock(async () => null), + }; + const service = new HistoryService( + historyRepository, + new EventBus(), + undefined, + repositories.mintSwapOperationRepository, + ); + + const history = await service.getPaginatedHistory(); + expect(history.map((entry) => entry.id).sort()).toEqual([ + 'mint-swap:swap-1', + 'send:standalone-child', + ]); + expect( + (await service.getPaginatedHistory(0, 25, { mintUrl: 'https://destination.test' })).map( + (entry) => entry.id, + ), + ).toEqual(['mint-swap:swap-1']); + }); + + it('closes the subscribe/recheck waiter race and returns durable terminal state', async () => { + const prepared = makePreparedMintSwapOperation(); + const completed = makePreparedMintSwapOperation({ + state: 'completed', + revision: 5, + settlement: { + sourcePaymentFee: Amount.from(4), + totalSourceFee: Amount.from(6), + sourceMeltChangeAmount: Amount.from(4), + sourceKeepAmount: Amount.zero(), + sourceReturnedAmount: Amount.from(4), + finalSourceDebit: Amount.from(106), + destinationAmountIssued: Amount.from(100), + }, + completedAt: Date.now(), + }); + let reads = 0; + const service = { + get: mock(async () => (++reads === 1 ? prepared : completed)), + } as unknown as MintSwapOperationService; + const api = new MintSwapOpsApi(service, new EventBus()); + + await expect(api.waitFor('swap-1', { timeoutMs: 100 })).resolves.toMatchObject({ + state: 'completed', + revision: 5, + }); + expect(reads).toBe(2); + }); +}); + +function childHistory(type: 'mint' | 'melt' | 'send', operationId: string): HistoryEntry { + const base = { + id: `${type}:${operationId}`, + source: 'operation' as const, + type, + operationId, + mintUrl: 'https://source.test', + unit: 'sat', + amount: Amount.from(100), + state: 'prepared', + createdAt: 1, + updatedAt: 1, + }; + if (type === 'mint') { + return { ...base, type, quoteId: 'q', paymentRequest: 'lnbc1' } as HistoryEntry; + } + if (type === 'melt') return { ...base, type, quoteId: 'q' } as HistoryEntry; + return { ...base, type } as HistoryEntry; +} diff --git a/packages/core/test/unit/PollingTransport.test.ts b/packages/core/test/unit/PollingTransport.test.ts index 8f3d767ba..9c0582882 100644 --- a/packages/core/test/unit/PollingTransport.test.ts +++ b/packages/core/test/unit/PollingTransport.test.ts @@ -583,7 +583,7 @@ describe('PollingTransport mint quote batching', () => { transport.on(mintUrl, 'message', () => {}); subscribeToQuotes(transport, mintUrl, 'bolt11', 'cadence-sub', ['quote-a']); - await waitFor(() => startedAt.length === 2); + await waitFor(() => startedAt.length === 2, 2_000); expect(startedAt[1]! - startedAt[0]!).toBeGreaterThanOrEqual(18); transport.closeAll(); diff --git a/packages/docs/.vitepress/config.ts b/packages/docs/.vitepress/config.ts index 23303421e..2d0e45836 100644 --- a/packages/docs/.vitepress/config.ts +++ b/packages/docs/.vitepress/config.ts @@ -57,6 +57,7 @@ export default defineConfig({ { text: 'Receive Operations', link: '/pages/receive-operations' }, { text: 'Mint Operations', link: '/pages/mint-operations' }, { text: 'Melt Operations', link: '/pages/melt-operations' }, + { text: 'Mint Swaps', link: '/pages/mint-swaps' }, { text: 'Coco Config', link: '/pages/coco-config' }, { text: 'Plugins', link: '/pages/plugins' }, ], diff --git a/packages/docs/pages/coco-config.md b/packages/docs/pages/coco-config.md index b4c317606..5f05e01eb 100644 --- a/packages/docs/pages/coco-config.md +++ b/packages/docs/pages/coco-config.md @@ -42,6 +42,21 @@ export interface CocoConfig { disabled?: boolean; initializeExistingPendingOperationsOnStart?: boolean; }; + mintSwapOperationProcessor?: { + disabled?: boolean; + sweepIntervalMs?: number; + dueBatchSize?: number; + /** @deprecated Prefer the state-specific retry options below. */ + baseRetryDelayMs?: number; + /** @deprecated Prefer the state-specific retry options below. */ + maxRetryDelayMs?: number; + sourceBaseRetryDelayMs?: number; + sourceMaxRetryDelayMs?: number; + postPaymentBaseRetryDelayMs?: number; + postPaymentMaxRetryDelayMs?: number; + outboxBaseRetryDelayMs?: number; + outboxMaxRetryDelayMs?: number; + }; }; } ``` diff --git a/packages/docs/pages/mint-swaps.md b/packages/docs/pages/mint-swaps.md new file mode 100644 index 000000000..7eb71e754 --- /dev/null +++ b/packages/docs/pages/mint-swaps.md @@ -0,0 +1,236 @@ +# Mint Swaps + +Mint swaps move an exact `sat` amount from one trusted mint to another through a locked BOLT11 +invoice. Coco treats the workflow as one durable parent operation. The owned source melt and +destination mint operations are implementation details and are hidden from grouped history. + +## Requirements + +Both mint URLs must be different after normalization and explicitly trusted. The source must +support BOLT11 melts plus NUT-07 and NUT-09 recovery. The destination must support BOLT11 minting, +NUT-09 recovery, and NUT-20 locked quotes. NUT-08 is optional; without it Coco uses a conservative +maximum source debit. + +Mint swaps are exact-receive and `sat`-only in this release. They do not provide exchange-rate +conversion, multi-source payment, or same-mint routing. + +## Prepare, review, and execute + +```ts +import { Amount } from '@cashu/coco-core'; + +const prepared = await coco.ops.mintSwap.prepare({ + sourceMintUrl: 'https://source.example', + destinationMintUrl: 'https://destination.example', + amount: Amount.from(10_000), +}); + +console.log({ + receive: prepared.destinationAmount.toString(), + minimumDebit: prepared.preparedPlan?.minimumSourceDebit.toString(), + maximumDebit: prepared.preparedPlan?.maximumSourceDebit.toString(), + dispatchDeadline: prepared.preparedPlan?.dispatchDeadline, +}); + +const current = await coco.ops.mintSwap.execute(prepared); +const terminal = await coco.ops.mintSwap.waitFor(current.id, { timeoutMs: 120_000 }); +``` + +`prepare()` reserves source value but never dispatches payment. Present the immutable minimum and +maximum source debit to the user before calling `execute()`. Execution rechecks trust, +capabilities, and the quote safety window before authorizing the source payment. + +## How fees are calculated + +The `amount` passed to `prepare()` is the **exact amount received at the destination**. It is not +the amount removed from the source. The source pays that amount plus the costs of selecting and +spending source proofs and paying the Lightning invoice. + +Mint swaps keep the costs separate because they become known at different times: + +| Field | What it pays for | When it is known | +| ---------------------- | -------------------------------------------------------------- | -------------------------------------------------- | +| `sourcePreparationFee` | NUT-02 input fee for an optional source-mint pre-swap | Exact at preparation; zero for a direct melt | +| `sourceMeltInputFee` | NUT-02 input fee for proofs sent to the source melt endpoint | Exact at preparation | +| `sourceFeeReserve` | Maximum payment allowance requested by the source mint | Exact quote value, but **not necessarily charged** | +| `sourcePaymentFee` | Settled payment-side cost after subtracting the melt-input fee | Known after source settlement | +| `totalSourceFee` | Preparation fee + melt-input fee + payment-side cost | Known after source settlement | + +When NUT-08 returns all unused value, `sourcePaymentFee` corresponds to the actual Lightning +routing cost. In degraded operation without that guarantee, it is intentionally broader: it may +also contain payment-side reserve or denomination value that the source mint did not return. + +### Before execution: a range, not an estimate + +The prepared plan exposes a lower and upper source debit: + +```text +minimumSourceDebit + = destinationAmount + + sourcePreparationFee + + sourceMeltInputFee +``` + +The minimum assumes a zero payment-side fee. Coco deliberately does not expose an “estimated fee” +because the source mint supplies a reserve ceiling, not a reliable routing-fee estimate. + +For a direct melt where the source supports NUT-08 change: + +```text +maximumSourceDebit = minimumSourceDebit + sourceFeeReserve +``` + +Without NUT-08, the mint is not guaranteed to return unused reserve or proof-denomination overage, +so a direct plan uses the conservative bound: + +```text +maximumSourceDebit = reservedSourceAmount +``` + +If Coco first performs a source-mint pre-swap, excess value is separated into local +`sourceKeepAmount` proofs. The maximum is therefore the reserved value minus those keep proofs, +which is equivalent to the minimum plus the quoted reserve. + +`reservedSourceAmount` can be greater than `maximumSourceDebit`. Reservation temporarily protects +the complete selected proof set from concurrent spending; it does not mean all of that value will +be consumed. + +### After settlement: the actual debit + +Unused source value can return in two places: + +- `sourceKeepAmount`: value separated locally by the optional pre-swap; +- `sourceMeltChangeAmount`: change returned by the source melt, normally through NUT-08. + +The final values must satisfy both views of the same debit: + +```text +sourcePaymentFee = effectiveFee - sourceMeltInputFee + +totalSourceFee + = sourcePreparationFee + + sourceMeltInputFee + + sourcePaymentFee + +sourceReturnedAmount = sourceKeepAmount + sourceMeltChangeAmount + +finalSourceDebit + = destinationAmount + totalSourceFee + = reservedSourceAmount - sourceReturnedAmount +``` + +Coco completes the operation only when these equations agree, the debit does not exceed +`maximumSourceDebit`, and persisted destination proofs total exactly `destinationAmount`. + +### Worked example + +Suppose the destination must receive `1,000 sat`. The direct source plan reserves `1,024 sat`, has +a `2 sat` melt-input fee, and the source mint asks for a `20 sat` fee reserve: + +```text +destinationAmount = 1,000 sat +sourcePreparationFee = 0 sat +sourceMeltInputFee = 2 sat +sourceFeeReserve = 20 sat +reservedSourceAmount = 1,024 sat + +minimumSourceDebit = 1,000 + 0 + 2 = 1,002 sat +maximumSourceDebit = 1,002 + 20 = 1,022 sat (with NUT-08) +``` + +If the settled payment-side cost is `6 sat`, the source returns `16 sat` as melt change: + +```text +totalSourceFee = 0 + 2 + 6 = 8 sat +sourceReturnedAmount = 0 + 16 = 16 sat +finalSourceDebit = 1,000 + 8 = 1,008 sat +balance check = 1,024 - 16 = 1,008 sat +``` + +Only `8 sat` was charged in total. The unused part of the `20 sat` reserve came back as change. +Without a NUT-08 guarantee, the preview would show the conservative `1,024 sat` maximum instead. + +Applications can render the preview and final settlement directly: + +```ts +const plan = prepared.preparedPlan; + +console.log(plan?.sourcePreparationFee.toString()); +console.log(plan?.sourceMeltInputFee.toString()); +console.log(plan?.sourceFeeReserve.toString()); +console.log(plan?.minimumSourceDebit.toString()); +console.log(plan?.maximumSourceDebit.toString()); + +const completed = await coco.ops.mintSwap.waitFor(prepared.id); +console.log(completed.settlement?.totalSourceFee.toString()); +console.log(completed.settlement?.finalSourceDebit.toString()); +``` + +## Recovery and terminal states + +`initializeCoco()` recovers child operations first, reconciles active mint swaps, starts the +durable mint-swap processor, then enables live watchers. Periodic repository sweeps mean WebSocket +events are an optimization rather than a correctness dependency. + +- `completed`: destination proofs and exact final source accounting are committed. +- `cancelled`: cancellation was proven safe before destination funding. +- `failed`: a value-neutral terminal outcome was proven. +- `needs_attention`: canonical evidence conflicts or automatic repair would risk value. + +Do not treat `source_inflight`, `destination_funded`, or `issuing` as failure. They are durable +recovery states. `retry()` requests immediate reconciliation; it does not create replacement +quotes, outputs, or child operations. + +## Events and history + +Subscribe through `coco.on('mint-swap-op:completed', handler)` and the other +`mint-swap-op:*` lifecycle events. Payloads contain the parent id, revision, state, normalized mint +URLs, amount, and a sanitized reason code. Proof secrets, invoices, signatures, keys, and quote ids +are not emitted. + +History returns one `mint-swap` entry with both mint identities, preview bounds, final debit/fees, +and sanitized terminal detail. Parent-owned mint and melt rows are hidden by default. + +## React + +```tsx +import { useMintSwapOperation } from '@cashu/coco-react'; + +const swap = useMintSwapOperation(); +await swap.prepare({ sourceMintUrl, destinationMintUrl, amount: 10_000 }); +await swap.execute(); +``` + +The hook follows only its bound parent, rejects stale revisions, exposes `needs_attention` as +operation state (not a hook exception), and removes listeners when unmounted. + +## Processor configuration + +The processor is enabled by default. Hosts can tune its durable sweep and retry cadence: + +```ts +await initializeCoco({ + repo, + seedGetter, + processors: { + mintSwapOperationProcessor: { + sweepIntervalMs: 5_000, + dueBatchSize: 50, + sourceBaseRetryDelayMs: 1_000, + sourceMaxRetryDelayMs: 30_000, + postPaymentBaseRetryDelayMs: 2_000, + postPaymentMaxRetryDelayMs: 300_000, + outboxBaseRetryDelayMs: 1_000, + outboxMaxRetryDelayMs: 60_000, + }, + }, +}); +``` + +Retries use exponential full jitter. Source-side reconciliation is capped at 30 seconds, while +post-payment destination recovery can back off to five minutes because it must keep retrying +ambiguous issuance without converting temporary unavailability into failure. Durable event +delivery uses its own one-minute cap. + +Disabling the processor stops automatic reconciliation and outbox publication; explicit +`refresh()` and startup recovery remain available. diff --git a/packages/docs/pages/react-hooks.md b/packages/docs/pages/react-hooks.md index 7efe8147a..ba951903c 100644 --- a/packages/docs/pages/react-hooks.md +++ b/packages/docs/pages/react-hooks.md @@ -33,6 +33,7 @@ separate React-only workflow model. One hook instance owns one active operation. For state-machine details, see [Send Operations](./send-operations.md), [Receive Operations](./receive-operations.md), [Mint Operations](./mint-operations.md), and [Melt Operations](./melt-operations.md). +Mint swaps are documented in [Mint Swaps](./mint-swaps.md). ## useSendOperation @@ -156,6 +157,23 @@ if (preparedMelt.state === 'prepared') { } ``` +## useMintSwapOperation + +Use this for an exact-receive transfer between two trusted mints. Review the prepared debit bounds +before execution; the hook then follows durable recovery events for its bound parent. + +```tsx +import { useMintSwapOperation } from '@cashu/coco-react'; + +const { prepare, execute, retry, cancel, currentOperation } = useMintSwapOperation(); + +await prepare({ sourceMintUrl, destinationMintUrl, amount: 10_000 }); +await execute(); +``` + +`needs_attention` is an operation state with sanitized evidence, distinct from the hook's local +`error` field. Event revisions older than `currentOperation.revision` are ignored. + ## Derived-data Hooks The existing derived-data hooks remain available for balance and history views. diff --git a/packages/docs/pages/watchers-processors.md b/packages/docs/pages/watchers-processors.md index 1e5f52e00..bfdbb7cdc 100644 --- a/packages/docs/pages/watchers-processors.md +++ b/packages/docs/pages/watchers-processors.md @@ -5,6 +5,7 @@ By default, when using `initializeCoco()`, all watchers and processors are autom ```ts await coco.enableMintOperationProcessor(); await coco.enableMeltSettlementProcessor(); +await coco.enableMintSwapOperationProcessor(); await coco.enableProofStateWatcher(); await coco.enableMintOperationWatcher(); await coco.enableMeltQuoteWatcher(); @@ -12,6 +13,7 @@ await coco.enableMeltQuoteWatcher(); `initializeCoco()` also recovers pending `coco.ops.send`, `coco.ops.receive`, and `coco.ops.melt` operations during startup, so most apps do not need to trigger recovery manually. +Mint-swap recovery runs after its owned children and before live watchers start. To disable them during initialization with `initializeCoco()`: @@ -27,6 +29,7 @@ const coco = await initializeCoco({ processors: { mintOperationProcessor: { disabled: true }, meltSettlementProcessor: { disabled: true }, + mintSwapOperationProcessor: { disabled: true }, }, }); ``` @@ -61,6 +64,13 @@ quote notifications or manual refresh after transient failures. This module will check the state of proofs known to coco and update their state automatically. +## MintSwapOperationProcessor + +This processor reconciles durable parent mint-swap states. Child and quote events wake exact +parents quickly, while periodic due-state sweeps close event-loss and restart windows. Retry timing +and event publication failures are persisted; retry counts never turn remote ambiguity into a +terminal result. It also publishes committed parent outbox events. + ## Pausing and Resuming Subscriptions For energy efficiency and battery savings (especially on mobile devices), you can pause and resume all subscriptions, watchers, and processors. This is particularly useful when your app is backgrounded or minimized: @@ -80,7 +90,7 @@ When `pauseSubscriptions()` is called: - All WebSocket connections are closed immediately - Reconnection attempts are disabled to save battery - All watchers (`MintOperationWatcher`, `MeltQuoteWatcher`, `ProofStateWatcher`) are stopped -- The `MintOperationProcessor` and `MeltSettlementProcessor` are stopped +- The `MintOperationProcessor`, `MeltSettlementProcessor`, and `MintSwapOperationProcessor` are stopped ### What happens during resume? diff --git a/packages/docs/starting/migrating-from-v1.md b/packages/docs/starting/migrating-from-v1.md index 96b897fda..4a0466c51 100644 --- a/packages/docs/starting/migrating-from-v1.md +++ b/packages/docs/starting/migrating-from-v1.md @@ -1,5 +1,21 @@ # Migrating from v1 +## Mint-swap storage and background recovery + +Current storage adapters automatically add durable mint-swap parent, outbox, and child-ownership +records during `init()`. Applications with custom adapters must implement the new +`mintSwapOperationRepository` and `operationEventOutboxRepository` contracts and include them in +the same transaction scope as proofs, mint operations, and melt operations. + +`initializeCoco()` enables `MintSwapOperationProcessor` by default. It recovers owned children +before parent reconciliation, publishes durable lifecycle events, and runs periodic due-state +sweeps. If an application previously assumed it controlled every background task, explicitly set +`processors.mintSwapOperationProcessor.disabled` and drive `ops.mintSwap.refresh()` itself. + +Grouped history now includes `type: 'mint-swap'`. Parent-owned mint and melt children are hidden +from the default history list, so consumers with exhaustive history-type switches must add the new +parent type. + This release is a v2 compatibility cut. It changes several user-facing boundaries that v1 applications, React apps, plugins, and custom storage adapters may depend on: diff --git a/packages/expo-sqlite/src/index.ts b/packages/expo-sqlite/src/index.ts index 39eaa9f2a..58e3f4cb6 100644 --- a/packages/expo-sqlite/src/index.ts +++ b/packages/expo-sqlite/src/index.ts @@ -24,6 +24,8 @@ export class SqliteRepositories implements Repositories { readonly receiveOperationRepository: Repositories['receiveOperationRepository']; readonly paymentRequestReceiveOperationRepository: Repositories['paymentRequestReceiveOperationRepository']; readonly paymentRequestReceiveAttemptRepository: Repositories['paymentRequestReceiveAttemptRepository']; + readonly mintSwapOperationRepository: Repositories['mintSwapOperationRepository']; + readonly operationEventOutboxRepository: Repositories['operationEventOutboxRepository']; private readonly db: ExpoSqliteDb; private readonly repositories: SqlStorageRepositories; @@ -49,6 +51,8 @@ export class SqliteRepositories implements Repositories { this.repositories.paymentRequestReceiveOperationRepository; this.paymentRequestReceiveAttemptRepository = this.repositories.paymentRequestReceiveAttemptRepository; + this.mintSwapOperationRepository = this.repositories.mintSwapOperationRepository; + this.operationEventOutboxRepository = this.repositories.operationEventOutboxRepository; } async init(): Promise { diff --git a/packages/expo-sqlite/src/test/contract.test.ts b/packages/expo-sqlite/src/test/contract.test.ts index c3be2f04d..d213d6260 100644 --- a/packages/expo-sqlite/src/test/contract.test.ts +++ b/packages/expo-sqlite/src/test/contract.test.ts @@ -10,6 +10,7 @@ import { runSendOperationRepositoryContract, runMeltOperationRepositoryContract, runMeltQuoteRepositoryContract, + runMintSwapRepositoryContract, createDummyMint, } from '@cashu/coco-adapter-tests'; import { runSqlDatabaseContract } from '@cashu/coco-sql-storage/test'; @@ -174,6 +175,8 @@ runMeltQuoteRepositoryContract({ createRepositories }, { describe, it, expect }) runPaymentRequestReceiveRepositoryContract({ createRepositories }, { describe, it, expect }); +runMintSwapRepositoryContract({ createRepositories }, { describe, it, expect }); + describe('expo-sqlite web transaction compatibility', () => { it('uses withTransactionAsync when exclusive transactions are unavailable on web', async () => { const windowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window'); diff --git a/packages/indexeddb/src/index.ts b/packages/indexeddb/src/index.ts index 9feaa3484..7b84d6f57 100644 --- a/packages/indexeddb/src/index.ts +++ b/packages/indexeddb/src/index.ts @@ -16,6 +16,8 @@ import type { PaymentRequestReceiveOperationRepository, ReceiveOperationRepository, RepositoryTransactionScope, + MintSwapOperationRepository, + OperationEventOutboxRepository, } from '@cashu/coco-core/adapter'; import { IdbDb, type IdbDbOptions } from './lib/db.ts'; import { ensureSchema } from './lib/schema.ts'; @@ -37,6 +39,8 @@ import { IdbPaymentRequestReceiveAttemptRepository, IdbPaymentRequestReceiveOperationRepository, } from './repositories/PaymentRequestReceiveRepository.ts'; +import { IdbMintSwapOperationRepository } from './repositories/MintSwapOperationRepository.ts'; +import { IdbOperationEventOutboxRepository } from './repositories/OperationEventOutboxRepository.ts'; export interface IndexedDbRepositoriesOptions extends IdbDbOptions {} @@ -57,6 +61,8 @@ export class IndexedDbRepositories implements Repositories { readonly receiveOperationRepository: ReceiveOperationRepository; readonly paymentRequestReceiveOperationRepository: PaymentRequestReceiveOperationRepository; readonly paymentRequestReceiveAttemptRepository: PaymentRequestReceiveAttemptRepository; + readonly mintSwapOperationRepository: MintSwapOperationRepository; + readonly operationEventOutboxRepository: OperationEventOutboxRepository; readonly db: IdbDb; private initialized = false; @@ -82,6 +88,8 @@ export class IndexedDbRepositories implements Repositories { this.paymentRequestReceiveAttemptRepository = new IdbPaymentRequestReceiveAttemptRepository( this.db, ); + this.mintSwapOperationRepository = new IdbMintSwapOperationRepository(this.db); + this.operationEventOutboxRepository = new IdbOperationEventOutboxRepository(this.db); } async init(): Promise { @@ -119,6 +127,8 @@ export class IndexedDbRepositories implements Repositories { paymentRequestReceiveAttemptRepository: new IdbPaymentRequestReceiveAttemptRepository( scopedDb, ), + mintSwapOperationRepository: new IdbMintSwapOperationRepository(scopedDb), + operationEventOutboxRepository: new IdbOperationEventOutboxRepository(scopedDb), }; return fn(scopedRepositories); }); @@ -144,4 +154,6 @@ export { IdbReceiveOperationRepository, IdbPaymentRequestReceiveOperationRepository, IdbPaymentRequestReceiveAttemptRepository, + IdbMintSwapOperationRepository, + IdbOperationEventOutboxRepository, }; diff --git a/packages/indexeddb/src/lib/db.ts b/packages/indexeddb/src/lib/db.ts index 228396f2b..fd08ba813 100644 --- a/packages/indexeddb/src/lib/db.ts +++ b/packages/indexeddb/src/lib/db.ts @@ -298,6 +298,7 @@ export interface MeltOperationRow { changeAmount?: string | number | null; effectiveFee?: string | number | null; finalizedDataJson?: string | null; + parentSwapOperationId?: string | null; } export interface AuthSessionRow { @@ -328,4 +329,41 @@ export interface MintOperationRow { lastObservedRemoteStateAt?: number | null; terminalFailureJson?: string | null; outputDataJson?: string | null; + parentSwapOperationId?: string | null; +} + +export interface MintSwapOperationRow { + id: string; + state: + | 'preparing' + | 'prepared' + | 'source_inflight' + | 'destination_funded' + | 'issuing' + | 'completed' + | 'cancelled' + | 'failed' + | 'needs_attention'; + revision: number; + sourceMintUrl: string; + destinationMintUrl: string; + destinationMintOperationId?: string; + sourceMeltOperationId?: string; + nextAttemptAt?: number; + createdAt: number; + updatedAt: number; + recordJson: string; +} + +export interface OperationEventOutboxRow { + id: string; + operationId: string; + revision: number; + eventType: string; + payloadJson: string; + createdAt: number; + publishedAt?: number; + publishAttempts: number; + nextAttemptAt?: number; + lastError?: string; } diff --git a/packages/indexeddb/src/lib/schema.ts b/packages/indexeddb/src/lib/schema.ts index dac1997ea..8b2318d04 100644 --- a/packages/indexeddb/src/lib/schema.ts +++ b/packages/indexeddb/src/lib/schema.ts @@ -1147,4 +1147,65 @@ export async function ensureSchema(db: IdbDb): Promise { }, ); }); + + // Version 33: Add recoverable mint-swap parents and the durable operation event outbox. + db.version(33).stores({ + coco_cashu_mints: '&mintUrl, name, updatedAt, trusted', + coco_cashu_keysets: '&[mintUrl+id], mintUrl, id, updatedAt, unit', + coco_cashu_counters: '&[mintUrl+keysetId]', + coco_cashu_proofs: + '&[mintUrl+secret], [mintUrl+state], [mintUrl+unit+state], [mintUrl+id+state], [mintUrl+id+unit+state], [mintUrl+unit+id+state], [unit+state], state, mintUrl, unit, id, usedByOperationId, createdByOperationId', + coco_cashu_mint_quotes: '&[mintUrl+quote], state, mintUrl', + coco_cashu_canonical_mint_quotes: + '&[mintUrl+method+quoteId], &[mintUrl+quoteId], state, mintUrl, method', + coco_cashu_melt_quotes: + '&[mintUrl+method+quoteId], &[mintUrl+quoteId], state, mintUrl, method', + coco_cashu_history: + '++id, mintUrl, type, createdAt, [mintUrl+quoteId+type], [mintUrl+operationId]', + coco_cashu_keypairs: '&publicKey, createdAt, derivationIndex', + coco_cashu_send_operations: '&id, state, mintUrl, createdAt', + coco_cashu_melt_operations: '&id, state, mintUrl, createdAt, [mintUrl+quoteId]', + coco_cashu_receive_operations: '&id, state, mintUrl, createdAt', + coco_cashu_auth_sessions: '&mintUrl', + coco_cashu_mint_operations: + '&id, state, mintUrl, createdAt, [mintUrl+quoteId], [mintUrl+method+quoteId]', + coco_cashu_payment_request_receive_operations: '&id, state, requestId', + coco_cashu_payment_request_receive_attempts: + '&id, requestOperationId, requestId, state, &[requestOperationId+payloadHash], [requestId+payloadHash], &transportMessageId, &receiveOperationId', + coco_cashu_mint_swap_operations: + '&id, state, revision, &destinationMintOperationId, &sourceMeltOperationId, nextAttemptAt, [state+nextAttemptAt], createdAt', + coco_cashu_operation_event_outbox: + '&id, &[operationId+revision+eventType], publishedAt, nextAttemptAt, [publishedAt+nextAttemptAt], createdAt', + }); + + // Version 34: Index durable ownership of mint-swap child operations. + db.version(34).stores({ + coco_cashu_mints: '&mintUrl, name, updatedAt, trusted', + coco_cashu_keysets: '&[mintUrl+id], mintUrl, id, updatedAt, unit', + coco_cashu_counters: '&[mintUrl+keysetId]', + coco_cashu_proofs: + '&[mintUrl+secret], [mintUrl+state], [mintUrl+unit+state], [mintUrl+id+state], [mintUrl+id+unit+state], [mintUrl+unit+id+state], [unit+state], state, mintUrl, unit, id, usedByOperationId, createdByOperationId', + coco_cashu_mint_quotes: '&[mintUrl+quote], state, mintUrl', + coco_cashu_canonical_mint_quotes: + '&[mintUrl+method+quoteId], &[mintUrl+quoteId], state, mintUrl, method', + coco_cashu_melt_quotes: + '&[mintUrl+method+quoteId], &[mintUrl+quoteId], state, mintUrl, method', + coco_cashu_history: + '++id, mintUrl, type, createdAt, [mintUrl+quoteId+type], [mintUrl+operationId]', + coco_cashu_keypairs: '&publicKey, createdAt, derivationIndex', + coco_cashu_send_operations: '&id, state, mintUrl, createdAt', + coco_cashu_melt_operations: + '&id, state, mintUrl, createdAt, parentSwapOperationId, [mintUrl+quoteId]', + coco_cashu_receive_operations: '&id, state, mintUrl, createdAt', + coco_cashu_auth_sessions: '&mintUrl', + coco_cashu_mint_operations: + '&id, state, mintUrl, createdAt, parentSwapOperationId, [mintUrl+quoteId], [mintUrl+method+quoteId]', + coco_cashu_payment_request_receive_operations: '&id, state, requestId', + coco_cashu_payment_request_receive_attempts: + '&id, requestOperationId, requestId, state, &[requestOperationId+payloadHash], [requestId+payloadHash], &transportMessageId, &receiveOperationId', + coco_cashu_mint_swap_operations: + '&id, state, revision, &destinationMintOperationId, &sourceMeltOperationId, nextAttemptAt, [state+nextAttemptAt], createdAt', + coco_cashu_operation_event_outbox: + '&id, &[operationId+revision+eventType], publishedAt, nextAttemptAt, [publishedAt+nextAttemptAt], createdAt', + }); } diff --git a/packages/indexeddb/src/repositories/MeltOperationRepository.ts b/packages/indexeddb/src/repositories/MeltOperationRepository.ts index bf7056bd8..f7c139413 100644 --- a/packages/indexeddb/src/repositories/MeltOperationRepository.ts +++ b/packages/indexeddb/src/repositories/MeltOperationRepository.ts @@ -46,6 +46,7 @@ const rowToOperation = (row: MeltOperationRow): MeltOperation => { createdAt: row.createdAt * 1000, updatedAt: row.updatedAt * 1000, error: row.error ?? undefined, + parentSwapOperationId: row.parentSwapOperationId ?? undefined, }; if (!isPreparedState(row.state)) { @@ -124,6 +125,7 @@ const operationToRow = (operation: MeltOperation): MeltOperationRow => { changeOutputDataJson: null, swapOutputDataJson: null, finalizedDataJson: null, + parentSwapOperationId: operation.parentSwapOperationId ?? null, }; } @@ -160,6 +162,7 @@ const operationToRow = (operation: MeltOperation): MeltOperationRow => { operation.state === 'finalized' && settlement.finalizedData !== undefined ? JSON.stringify(settlement.finalizedData) : null, + parentSwapOperationId: operation.parentSwapOperationId ?? null, }; }; @@ -202,6 +205,9 @@ export class IdbMeltOperationRepository implements MeltOperationRepository { if (!existing) { throw new Error(`MeltOperation with id ${operation.id} not found`); } + if ((existing.parentSwapOperationId ?? undefined) !== operation.parentSwapOperationId) { + throw new Error(`Cannot change parent ownership of MeltOperation ${operation.id}`); + } const quoteId = getOperationQuoteId(operation); if (quoteId) { diff --git a/packages/indexeddb/src/repositories/MintOperationRepository.ts b/packages/indexeddb/src/repositories/MintOperationRepository.ts index 330900667..c9037ae87 100644 --- a/packages/indexeddb/src/repositories/MintOperationRepository.ts +++ b/packages/indexeddb/src/repositories/MintOperationRepository.ts @@ -38,6 +38,7 @@ const rowToOperation = (row: MintOperationRow): MintOperation => { createdAt: row.createdAt * 1000, updatedAt: row.updatedAt * 1000, error: row.error ?? undefined, + parentSwapOperationId: row.parentSwapOperationId ?? undefined, ...(row.terminalFailureJson ? { terminalFailure: JSON.parse(row.terminalFailureJson) as MintOperationFailure } : {}), @@ -91,6 +92,7 @@ const operationToRow = (operation: MintOperation): MintOperationRow => { ? JSON.stringify(operation.terminalFailure) : null, outputDataJson: null, + parentSwapOperationId: operation.parentSwapOperationId ?? null, }; } @@ -115,6 +117,7 @@ const operationToRow = (operation: MintOperation): MintOperationRow => { ? JSON.stringify(operation.terminalFailure) : null, outputDataJson: JSON.stringify(operation.outputData), + parentSwapOperationId: operation.parentSwapOperationId ?? null, }; }; @@ -143,6 +146,9 @@ export class IdbMintOperationRepository implements MintOperationRepository { if (!existing) { throw new Error(`MintOperation with id ${operation.id} not found`); } + if ((existing.parentSwapOperationId ?? undefined) !== operation.parentSwapOperationId) { + throw new Error(`Cannot change parent ownership of MintOperation ${operation.id}`); + } const row = operationToRow(operation); row.updatedAt = getUnixTimeSeconds(); diff --git a/packages/indexeddb/src/repositories/MintQuoteRepository.ts b/packages/indexeddb/src/repositories/MintQuoteRepository.ts index 2773a7f83..a726f31d3 100644 --- a/packages/indexeddb/src/repositories/MintQuoteRepository.ts +++ b/packages/indexeddb/src/repositories/MintQuoteRepository.ts @@ -1,5 +1,6 @@ import type { MintQuoteRepository } from '@cashu/coco-core/adapter'; import { + applyBolt11MintQuoteStateFallback, deserializeAmount, getMintQuoteAmount, getMintQuoteRemoteState, @@ -188,13 +189,9 @@ export class IdbMintQuoteRepository implements MintQuoteRepository { .table('coco_cashu_canonical_mint_quotes') .get([normalizeMintUrl(mintUrl), method, quoteId])) as MintQuoteRow | undefined; if (!existing) return; - await (this.db as any).table('coco_cashu_canonical_mint_quotes').put({ - ...existing, - state, - amountPaid: state === 'UNPAID' ? '0' : existing.amount, - amountIssued: state === 'ISSUED' ? existing.amount : '0', - updatedAt: observedAt, - } as MintQuoteRow); + const quote = rowToMintQuote(existing); + if (!isStatefulMintQuote(quote)) return; + await this.upsertMintQuote(applyBolt11MintQuoteStateFallback(quote, state, observedAt)); } async getPendingMintQuotes(method?: string): Promise { diff --git a/packages/indexeddb/src/repositories/MintSwapOperationRepository.ts b/packages/indexeddb/src/repositories/MintSwapOperationRepository.ts new file mode 100644 index 000000000..53d34faf0 --- /dev/null +++ b/packages/indexeddb/src/repositories/MintSwapOperationRepository.ts @@ -0,0 +1,201 @@ +import { Amount } from '@cashu/cashu-ts'; +import { + assertMintSwapTransition, + assertPreparedMintSwapImmutable, + isAutomaticMintSwapState, + isTerminalMintSwapState, + validateMintSwapOperation, + type MintSwapOperation, + type MintSwapOperationState, +} from '@cashu/coco-core'; +import type { MintSwapOperationRepository } from '@cashu/coco-core/adapter'; + +import { IdbDb, type MintSwapOperationRow } from '../lib/db.ts'; + +const STORE = 'coco_cashu_mint_swap_operations'; + +export class IdbMintSwapOperationRepository implements MintSwapOperationRepository { + constructor(private readonly db: IdbDb) {} + + async create(operation: MintSwapOperation): Promise { + validateMintSwapOperation(operation); + if (operation.revision !== 0) { + throw new Error('New mint swap operation must start at revision 0'); + } + await this.table().add(toRow(operation)); + } + + async getById(id: string): Promise { + const row = await this.table().get(id); + return row ? fromRow(row) : null; + } + + async getByState(state: MintSwapOperationState): Promise { + const rows = await this.table().where('state').equals(state).toArray(); + return sortRows(rows).map(fromRow); + } + + async getActive(): Promise { + const rows = await this.table().toArray(); + return sortRows(rows) + .map(fromRow) + .filter((operation) => !isTerminalMintSwapState(operation.state)); + } + + async getDue(now: number, limit: number): Promise { + if (!Number.isSafeInteger(now) || now < 0) throw new Error('Due time must be non-negative'); + if (!Number.isSafeInteger(limit) || limit < 0) + throw new Error('Due limit must be non-negative'); + const rows = await this.table().toArray(); + return rows + .map(fromRow) + .filter( + (operation) => + isAutomaticMintSwapState(operation.state) && (operation.retry.nextAttemptAt ?? 0) <= now, + ) + .sort( + (left, right) => + (left.retry.nextAttemptAt ?? 0) - (right.retry.nextAttemptAt ?? 0) || + left.createdAt - right.createdAt || + left.id.localeCompare(right.id), + ) + .slice(0, limit); + } + + async getByDestinationMintOperationId(id: string): Promise { + return this.getByChild('destinationMintOperationId', id); + } + + async getBySourceMeltOperationId(id: string): Promise { + return this.getByChild('sourceMeltOperationId', id); + } + + async compareAndSet(operation: MintSwapOperation, expectedRevision: number): Promise { + return this.db.runTransaction('rw', [STORE], async () => { + const currentRow = await this.table().get(operation.id); + if (!currentRow || currentRow.revision !== expectedRevision) return false; + const current = fromRow(currentRow); + if (operation.revision !== expectedRevision + 1) { + throw new Error('Mint swap compare-and-set must advance revision exactly once'); + } + assertMintSwapTransition(current.state, operation.state); + assertPreparedMintSwapImmutable(current, operation); + validateMintSwapOperation(operation); + await this.table().put(toRow(operation)); + return true; + }); + } + + private async getByChild( + index: 'destinationMintOperationId' | 'sourceMeltOperationId', + id: string, + ): Promise { + const row = await this.table().where(index).equals(id).first(); + return row ? fromRow(row) : null; + } + + private table() { + return this.db.table(STORE); + } +} + +function sortRows(rows: MintSwapOperationRow[]): MintSwapOperationRow[] { + return rows.sort( + (left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id), + ); +} + +function toRow(operation: MintSwapOperation): MintSwapOperationRow { + return { + id: operation.id, + state: operation.state, + revision: operation.revision, + sourceMintUrl: operation.sourceMintUrl, + destinationMintUrl: operation.destinationMintUrl, + destinationMintOperationId: operation.destinationMintOperationId, + sourceMeltOperationId: operation.sourceMeltOperationId, + nextAttemptAt: operation.retry.nextAttemptAt, + createdAt: operation.createdAt, + updatedAt: operation.updatedAt, + recordJson: JSON.stringify(serializeOperation(operation)), + }; +} + +function serializeOperation(operation: MintSwapOperation): unknown { + return { + ...operation, + destinationAmount: operation.destinationAmount.toString(), + preparedPlan: operation.preparedPlan + ? mapAmountsToStrings(operation.preparedPlan, [ + 'sourceMeltAmount', + 'sourceFeeReserve', + 'sourcePreparationFee', + 'sourceMeltInputFee', + 'minimumSourceDebit', + 'maximumSourceDebit', + 'reservedSourceAmount', + ]) + : undefined, + settlement: operation.settlement + ? mapAmountsToStrings(operation.settlement, [ + 'sourcePaymentFee', + 'totalSourceFee', + 'sourceMeltChangeAmount', + 'sourceKeepAmount', + 'sourceReturnedAmount', + 'finalSourceDebit', + 'destinationAmountIssued', + ]) + : undefined, + }; +} + +function fromRow(row: MintSwapOperationRow): MintSwapOperation { + const parsed = JSON.parse(row.recordJson) as Record & { + destinationAmount: string; + preparedPlan?: Record; + settlement?: Record; + }; + return validateMintSwapOperation({ + ...parsed, + destinationAmount: Amount.from(parsed.destinationAmount), + preparedPlan: parsed.preparedPlan + ? mapStringsToAmounts(parsed.preparedPlan, [ + 'sourceMeltAmount', + 'sourceFeeReserve', + 'sourcePreparationFee', + 'sourceMeltInputFee', + 'minimumSourceDebit', + 'maximumSourceDebit', + 'reservedSourceAmount', + ]) + : undefined, + settlement: parsed.settlement + ? mapStringsToAmounts(parsed.settlement, [ + 'sourcePaymentFee', + 'totalSourceFee', + 'sourceMeltChangeAmount', + 'sourceKeepAmount', + 'sourceReturnedAmount', + 'finalSourceDebit', + 'destinationAmountIssued', + ]) + : undefined, + } as MintSwapOperation); +} + +function mapAmountsToStrings(value: T, keys: readonly string[]): object { + const result = { ...value } as Record; + for (const key of keys) { + if (result[key] !== undefined) result[key] = Amount.from(result[key] as Amount).toString(); + } + return result; +} + +function mapStringsToAmounts(value: Record, keys: readonly string[]): object { + const result = { ...value }; + for (const key of keys) { + if (result[key] !== undefined) result[key] = Amount.from(result[key] as string); + } + return result; +} diff --git a/packages/indexeddb/src/repositories/OperationEventOutboxRepository.ts b/packages/indexeddb/src/repositories/OperationEventOutboxRepository.ts new file mode 100644 index 000000000..0f734b393 --- /dev/null +++ b/packages/indexeddb/src/repositories/OperationEventOutboxRepository.ts @@ -0,0 +1,91 @@ +import type { + OperationEventOutboxRecord, + OperationEventOutboxRepository, +} from '@cashu/coco-core/adapter'; + +import { IdbDb, type OperationEventOutboxRow } from '../lib/db.ts'; + +const STORE = 'coco_cashu_operation_event_outbox'; + +export class IdbOperationEventOutboxRepository implements OperationEventOutboxRepository { + constructor(private readonly db: IdbDb) {} + + async enqueue(event: OperationEventOutboxRecord): Promise { + validateEvent(event); + await this.table().add(toRow(event)); + } + + async getUnpublished(limit: number, now = Date.now()): Promise { + const rows = await this.table().toArray(); + return rows + .filter((row) => row.publishedAt === undefined && (row.nextAttemptAt ?? 0) <= now) + .sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id)) + .slice(0, limit) + .map(fromRow); + } + + async markPublished(id: string, publishedAt: number): Promise { + const row = await this.table().get(id); + if (!row || row.publishedAt !== undefined) return; + await this.table().put({ + ...row, + publishedAt, + nextAttemptAt: undefined, + lastError: undefined, + }); + } + + async recordPublishFailure(id: string, nextAttemptAt: number, lastError: string): Promise { + const row = await this.table().get(id); + if (!row || row.publishedAt !== undefined) return; + await this.table().put({ + ...row, + publishAttempts: row.publishAttempts + 1, + nextAttemptAt, + lastError, + }); + } + + private table() { + return this.db.table(STORE); + } +} + +function toRow(event: OperationEventOutboxRecord): OperationEventOutboxRow { + return { + id: event.id, + operationId: event.operationId, + revision: event.revision, + eventType: event.eventType, + payloadJson: JSON.stringify(event.payload), + createdAt: event.createdAt, + publishedAt: event.publishedAt, + publishAttempts: event.publishAttempts, + nextAttemptAt: event.nextAttemptAt, + lastError: event.lastError, + }; +} + +function fromRow(row: OperationEventOutboxRow): OperationEventOutboxRecord { + return { + id: row.id, + operationId: row.operationId, + revision: row.revision, + eventType: row.eventType as OperationEventOutboxRecord['eventType'], + payload: JSON.parse(row.payloadJson), + createdAt: row.createdAt, + publishedAt: row.publishedAt, + publishAttempts: row.publishAttempts, + nextAttemptAt: row.nextAttemptAt, + lastError: row.lastError, + }; +} + +function validateEvent(event: OperationEventOutboxRecord): void { + if ( + event.payload.operationId !== event.operationId || + event.payload.revision !== event.revision + ) { + throw new Error('Outbox payload identity must match its logical event key'); + } +} diff --git a/packages/indexeddb/src/test/contract.test.ts b/packages/indexeddb/src/test/contract.test.ts index b08b9ac79..c96db894a 100644 --- a/packages/indexeddb/src/test/contract.test.ts +++ b/packages/indexeddb/src/test/contract.test.ts @@ -10,6 +10,7 @@ import { runSendOperationRepositoryContract, runMeltOperationRepositoryContract, runMeltQuoteRepositoryContract, + runMintSwapRepositoryContract, } from '@cashu/coco-adapter-tests'; import { IndexedDbRepositories } from '../index.ts'; @@ -58,6 +59,8 @@ runMeltQuoteRepositoryContract({ createRepositories }, { describe, it, expect }) runPaymentRequestReceiveRepositoryContract({ createRepositories }, { describe, it, expect }); +runMintSwapRepositoryContract({ createRepositories }, { describe, it, expect }); + describe('indexeddb quote storage constraints', () => { it('migrates canonical Mint Quote Accounting without inventing remote time', async () => { const dbName = `coco_cashu_migration_${Date.now()}_${dbCounter++}`; diff --git a/packages/indexeddb/src/test/mintSwapMigration.test.ts b/packages/indexeddb/src/test/mintSwapMigration.test.ts new file mode 100644 index 000000000..47ce529bd --- /dev/null +++ b/packages/indexeddb/src/test/mintSwapMigration.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import Dexie from 'dexie'; + +import { IndexedDbRepositories } from '../index.ts'; + +describe('IndexedDB mint-swap migration', () => { + it('upgrades a version 31 database with parent and outbox stores and unique indexes', async () => { + const name = `coco_cashu_mint_swap_migration_${Date.now()}`; + const legacy = new Dexie(name); + legacy.version(31).stores({ coco_cashu_mints: '&mintUrl' }); + await legacy.open(); + legacy.close(); + + const repositories = new IndexedDbRepositories({ name }); + try { + await repositories.init(); + + expect(repositories.db.verno).toBe(34); + const parent = repositories.db.table('coco_cashu_mint_swap_operations'); + const outbox = repositories.db.table('coco_cashu_operation_event_outbox'); + expect(parent.schema.primKey.name).toBe('id'); + expect(parent.schema.idxByName.destinationMintOperationId?.unique).toBe(true); + expect(parent.schema.idxByName.sourceMeltOperationId?.unique).toBe(true); + expect(outbox.schema.idxByName['[operationId+revision+eventType]']?.unique).toBe(true); + expect( + repositories.db.table('coco_cashu_mint_operations').schema.idxByName.parentSwapOperationId, + ).toBeDefined(); + expect( + repositories.db.table('coco_cashu_melt_operations').schema.idxByName.parentSwapOperationId, + ).toBeDefined(); + } finally { + repositories.db.close(); + await Dexie.delete(name); + } + }); +}); diff --git a/packages/react/src/lib/hooks/index.ts b/packages/react/src/lib/hooks/index.ts index 04c1b9f4e..a39c4d7df 100644 --- a/packages/react/src/lib/hooks/index.ts +++ b/packages/react/src/lib/hooks/index.ts @@ -3,6 +3,7 @@ export * from './operation-types'; export * from './useSendOperation'; export * from './useReceiveOperation'; export * from './useMintOperation'; +export * from './useMintSwapOperation'; export * from './useMeltOperation'; export { default as useBalances } from './useBalances'; export { default as useTrustedBalance } from './useTrustedBalance'; diff --git a/packages/react/src/lib/hooks/useMintSwapOperation.test.tsx b/packages/react/src/lib/hooks/useMintSwapOperation.test.tsx new file mode 100644 index 000000000..6fd773b77 --- /dev/null +++ b/packages/react/src/lib/hooks/useMintSwapOperation.test.tsx @@ -0,0 +1,96 @@ +import { Amount, type Manager, type MintSwapOperation } from '@cashu/coco-core'; +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createHookWrapper } from '../../test/testUtils.tsx'; +import { useMintSwapOperation } from './useMintSwapOperation.ts'; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('useMintSwapOperation', () => { + it('ignores stale revisions, fetches newer durable state, and cleans up listeners', async () => { + const listeners = new Map void | Promise>>(); + const on = vi.fn((event: string, handler: (payload: unknown) => void | Promise) => { + const handlers = listeners.get(event) ?? new Set(); + handlers.add(handler); + listeners.set(event, handlers); + return () => handlers.delete(handler); + }); + const prepared = makeOperation(); + const attention = makeOperation({ + revision: 3, + state: 'needs_attention', + attention: { + reason: 'accounting_mismatch', + message: 'Settlement contradiction', + lastSafeState: 'destination_funded', + violatedInvariant: 'settlement', + evidence: { operationId: 'swap-1' }, + at: 4, + }, + updatedAt: 4, + }); + const get = vi.fn(async () => attention); + const manager = { + on, + ops: { + mintSwap: { + get, + prepare: vi.fn(), + execute: vi.fn(), + refresh: vi.fn(), + retry: vi.fn(), + cancel: vi.fn(), + list: vi.fn(), + listActive: vi.fn(), + }, + }, + } as unknown as Manager; + const rendered = renderHook(() => useMintSwapOperation(prepared), { + wrapper: createHookWrapper(manager), + }); + + await emit(listeners, 'mint-swap-op:prepared', { operationId: 'swap-1', revision: 1 }); + expect(get).not.toHaveBeenCalled(); + await act(async () => { + await emit(listeners, 'mint-swap-op:needs-attention', { + operationId: 'swap-1', + revision: 3, + }); + }); + expect(rendered.result.current.currentOperation).toMatchObject({ + revision: 3, + state: 'needs_attention', + }); + + rendered.unmount(); + expect(Array.from(listeners.values()).every((handlers) => handlers.size === 0)).toBe(true); + }); +}); + +async function emit( + listeners: Map void | Promise>>, + event: string, + payload: unknown, +): Promise { + for (const handler of listeners.get(event) ?? []) await handler(payload); +} + +function makeOperation(overrides: Partial = {}): MintSwapOperation { + return { + id: 'swap-1', + state: 'prepared', + revision: 2, + sourceMintUrl: 'https://source.test', + destinationMintUrl: 'https://destination.test', + unit: 'sat', + destinationAmount: Amount.from(100), + retry: { attemptCount: 0 }, + createdAt: 1, + updatedAt: 2, + ...overrides, + }; +} diff --git a/packages/react/src/lib/hooks/useMintSwapOperation.ts b/packages/react/src/lib/hooks/useMintSwapOperation.ts new file mode 100644 index 000000000..36f935aec --- /dev/null +++ b/packages/react/src/lib/hooks/useMintSwapOperation.ts @@ -0,0 +1,166 @@ +import type { Manager } from '@cashu/coco-core'; +import { useCallback, useEffect, useRef } from 'react'; + +import { useManager } from '../contexts/ManagerContext'; +import type { OperationBinding, OperationHookResult } from './operation-types'; +import { + getInitialOperationFromBinding, + getInitialOperationIdFromBinding, + requireCurrentOperationId, + requireOperation, + requireUnboundOperationCreation, + useInitialOperationHydration, + useOperationHookState, +} from './operationHookUtils'; + +type MintSwapOps = Manager['ops']['mintSwap']; +type MintSwapOperation = NonNullable>>; + +export type MintSwapPrepareInput = Parameters[0]; +export type MintSwapListInput = Parameters[0]; + +export interface UseMintSwapOperationResult extends OperationHookResult< + MintSwapOperation, + MintSwapOperation +> { + prepare(input: MintSwapPrepareInput): Promise; + execute(): Promise; + retry(): Promise; + cancel(reason?: string): Promise; + list(input?: MintSwapListInput): ReturnType; + listActive(): ReturnType; +} + +export function useMintSwapOperation( + initialBinding?: OperationBinding | null, +): UseMintSwapOperationResult { + const manager = useManager(); + const initialBindingRef = useRef(initialBinding); + const boundIdRef = useRef(getInitialOperationIdFromBinding(initialBindingRef.current)); + const { + currentOperation, + executeResult, + status, + error, + isLoading, + isError, + replaceCurrentOperation, + replaceExecuteResult, + getCurrentOperation, + runStatefulAction, + reset: resetState, + } = useOperationHookState( + getInitialOperationFromBinding(initialBindingRef.current), + ); + + const bind = useCallback( + (operation: MintSwapOperation | null, clearExecuteResult = false) => { + if (!operation) { + boundIdRef.current = null; + replaceCurrentOperation(null, { clearExecuteResult }); + return; + } + if (boundIdRef.current && boundIdRef.current !== operation.id) return; + const current = getCurrentOperation(); + if (current?.id === operation.id && current.revision > operation.revision) return; + boundIdRef.current = operation.id; + replaceCurrentOperation(operation, { clearExecuteResult }); + }, + [getCurrentOperation, replaceCurrentOperation], + ); + + const hydrate = useCallback( + async (operationId: string) => { + const operation = await requireOperation((id) => manager.ops.mintSwap.get(id), operationId); + if (boundIdRef.current === operationId) bind(operation, true); + }, + [bind, manager], + ); + useInitialOperationHydration(initialBindingRef.current, hydrate); + + useEffect(() => { + let active = true; + const observe = async (payload: { operationId: string; revision: number }) => { + if (!active || payload.operationId !== boundIdRef.current) return; + const current = getCurrentOperation(); + if (current && payload.revision <= current.revision) return; + const operation = await manager.ops.mintSwap.get(payload.operationId); + if (active && operation) bind(operation); + }; + const events = [ + 'mint-swap-op:prepared', + 'mint-swap-op:source-inflight', + 'mint-swap-op:destination-funded', + 'mint-swap-op:issuing', + 'mint-swap-op:completed', + 'mint-swap-op:cancelled', + 'mint-swap-op:failed', + 'mint-swap-op:needs-attention', + 'mint-swap-op:delayed', + ] as const; + const offs = events.map((event) => manager.on(event, observe)); + return () => { + active = false; + for (const off of offs) off(); + }; + }, [bind, getCurrentOperation, manager]); + + const prepare = useCallback( + (input: MintSwapPrepareInput) => { + requireUnboundOperationCreation(boundIdRef.current, 'prepare'); + return runStatefulAction( + () => manager.ops.mintSwap.prepare(input), + (operation) => bind(operation, true), + ); + }, + [bind, manager, runStatefulAction], + ); + const runBound = useCallback( + (action: (operationId: string) => Promise) => { + const id = requireCurrentOperationId(getCurrentOperation(), 'mint swap action'); + return runStatefulAction( + () => action(id), + (operation) => bind(operation), + ); + }, + [bind, getCurrentOperation, runStatefulAction], + ); + const execute = useCallback(async () => { + const operation = await runBound((id) => manager.ops.mintSwap.execute(id)); + replaceExecuteResult(operation); + return operation; + }, [manager, replaceExecuteResult, runBound]); + const refresh = useCallback( + () => runBound((id) => manager.ops.mintSwap.refresh(id)), + [manager, runBound], + ); + const retry = useCallback( + () => runBound((id) => manager.ops.mintSwap.retry(id)), + [manager, runBound], + ); + const cancel = useCallback( + (reason?: string) => runBound((id) => manager.ops.mintSwap.cancel(id, reason)), + [manager, runBound], + ); + const reset = useCallback(() => { + boundIdRef.current = null; + resetState(); + }, [resetState]); + + return { + currentOperation, + executeResult, + status, + error, + isLoading, + isError, + prepare, + execute, + refresh, + retry, + cancel, + list: (input) => manager.ops.mintSwap.list(input), + listActive: () => manager.ops.mintSwap.listActive(), + reset, + }; +} diff --git a/packages/sql-storage/src/index.ts b/packages/sql-storage/src/index.ts index 59687581e..2759d7559 100644 --- a/packages/sql-storage/src/index.ts +++ b/packages/sql-storage/src/index.ts @@ -39,6 +39,8 @@ export { SqliteReceiveOperationRepository, SqlitePaymentRequestReceiveOperationRepository, SqlitePaymentRequestReceiveAttemptRepository, + SqliteMintSwapOperationRepository, + SqliteOperationEventOutboxRepository, } from './repositories.ts'; export type { SqlStorageRepositoriesOptions } from './repositories.ts'; export { ensureSchema, ensureSchemaUpTo, MIGRATIONS } from './schema.ts'; diff --git a/packages/sql-storage/src/repositories.ts b/packages/sql-storage/src/repositories.ts index d022614fd..f288ce885 100644 --- a/packages/sql-storage/src/repositories.ts +++ b/packages/sql-storage/src/repositories.ts @@ -17,6 +17,8 @@ import type { ReceiveOperationRepository, PaymentRequestReceiveAttemptRepository, PaymentRequestReceiveOperationRepository, + MintSwapOperationRepository, + OperationEventOutboxRepository, } from '@cashu/coco-core/adapter'; import type { SqlDatabase } from './index.ts'; import { ensureSchema } from './schema.ts'; @@ -38,6 +40,8 @@ import { SqlitePaymentRequestReceiveAttemptRepository, SqlitePaymentRequestReceiveOperationRepository, } from './repositories/PaymentRequestReceiveRepository.ts'; +import { SqliteMintSwapOperationRepository } from './repositories/MintSwapOperationRepository.ts'; +import { SqliteOperationEventOutboxRepository } from './repositories/OperationEventOutboxRepository.ts'; export interface SqlStorageRepositoriesOptions { database: SqlDatabase; @@ -65,6 +69,8 @@ function createRepositoryScope(database: SqlDatabase): RepositoryTransactionScop paymentRequestReceiveAttemptRepository: new SqlitePaymentRequestReceiveAttemptRepository( database, ), + mintSwapOperationRepository: new SqliteMintSwapOperationRepository(database), + operationEventOutboxRepository: new SqliteOperationEventOutboxRepository(database), }; } @@ -85,6 +91,8 @@ export class SqlStorageRepositories implements Repositories { readonly receiveOperationRepository: ReceiveOperationRepository; readonly paymentRequestReceiveOperationRepository: PaymentRequestReceiveOperationRepository; readonly paymentRequestReceiveAttemptRepository: PaymentRequestReceiveAttemptRepository; + readonly mintSwapOperationRepository: MintSwapOperationRepository; + readonly operationEventOutboxRepository: OperationEventOutboxRepository; readonly database: SqlDatabase; constructor(options: SqlStorageRepositoriesOptions) { @@ -108,6 +116,8 @@ export class SqlStorageRepositories implements Repositories { repositories.paymentRequestReceiveOperationRepository; this.paymentRequestReceiveAttemptRepository = repositories.paymentRequestReceiveAttemptRepository; + this.mintSwapOperationRepository = repositories.mintSwapOperationRepository; + this.operationEventOutboxRepository = repositories.operationEventOutboxRepository; } async init(): Promise { @@ -136,4 +146,6 @@ export { SqliteReceiveOperationRepository, SqlitePaymentRequestReceiveOperationRepository, SqlitePaymentRequestReceiveAttemptRepository, + SqliteMintSwapOperationRepository, + SqliteOperationEventOutboxRepository, }; diff --git a/packages/sql-storage/src/repositories/MeltOperationRepository.ts b/packages/sql-storage/src/repositories/MeltOperationRepository.ts index e421763d2..a11aa5be2 100644 --- a/packages/sql-storage/src/repositories/MeltOperationRepository.ts +++ b/packages/sql-storage/src/repositories/MeltOperationRepository.ts @@ -44,6 +44,7 @@ interface MeltOperationRow { changeAmount: string | number | null; effectiveFee: string | number | null; finalizedDataJson: string | null; + parentSwapOperationId: string | null; } const preparedStates: MeltOperationState[] = [ @@ -70,6 +71,7 @@ const rowToOperation = (row: MeltOperationRow): MeltOperation => { createdAt: row.createdAt * 1000, updatedAt: row.updatedAt * 1000, error: row.error ?? undefined, + parentSwapOperationId: row.parentSwapOperationId ?? undefined, }; if (!isPreparedState(row.state)) { @@ -140,6 +142,7 @@ const operationToParams = (operation: MeltOperation): SqlValue[] => { null, null, null, + operation.parentSwapOperationId ?? null, ]; } @@ -179,6 +182,7 @@ const operationToParams = (operation: MeltOperation): SqlValue[] => { changeAmount, effectiveFee, finalizedDataJson, + operation.parentSwapOperationId ?? null, ]; }; @@ -194,8 +198,8 @@ export class SqliteMeltOperationRepository implements MeltOperationRepository { throw new Error('Cannot persist failed melt operation'); } - const exists = await this.db.get<{ id: string }>( - 'SELECT id FROM coco_cashu_melt_operations WHERE id = ? LIMIT 1', + const exists = await this.db.get<{ id: string; parentSwapOperationId: string | null }>( + 'SELECT id, parentSwapOperationId FROM coco_cashu_melt_operations WHERE id = ? LIMIT 1', [operation.id], ); if (exists) { @@ -207,8 +211,8 @@ export class SqliteMeltOperationRepository implements MeltOperationRepository { const params = operationToParams(operation); await this.db.run( `INSERT INTO coco_cashu_melt_operations - (id, mintUrl, state, createdAt, updatedAt, error, method, methodDataJson, quoteId, unit, amount, fee_reserve, swap_fee, needsSwap, inputAmount, inputProofSecretsJson, changeOutputDataJson, swapOutputDataJson, changeAmount, effectiveFee, finalizedDataJson) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (id, mintUrl, state, createdAt, updatedAt, error, method, methodDataJson, quoteId, unit, amount, fee_reserve, swap_fee, needsSwap, inputAmount, inputProofSecretsJson, changeOutputDataJson, swapOutputDataJson, changeAmount, effectiveFee, finalizedDataJson, parentSwapOperationId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, params, ); } @@ -218,13 +222,16 @@ export class SqliteMeltOperationRepository implements MeltOperationRepository { throw new Error('Cannot persist failed melt operation'); } - const exists = await this.db.get<{ id: string }>( - 'SELECT id FROM coco_cashu_melt_operations WHERE id = ? LIMIT 1', + const exists = await this.db.get<{ id: string; parentSwapOperationId: string | null }>( + 'SELECT id, parentSwapOperationId FROM coco_cashu_melt_operations WHERE id = ? LIMIT 1', [operation.id], ); if (!exists) { throw new Error(`MeltOperation with id ${operation.id} not found`); } + if ((exists.parentSwapOperationId ?? undefined) !== operation.parentSwapOperationId) { + throw new Error(`Cannot change parent ownership of MeltOperation ${operation.id}`); + } await this.assertNoDuplicateQuoteOperation(operation); @@ -233,7 +240,7 @@ export class SqliteMeltOperationRepository implements MeltOperationRepository { if (operation.state === 'init') { await this.db.run( `UPDATE coco_cashu_melt_operations - SET state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, quoteId = ?, unit = ? + SET state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, quoteId = ?, unit = ?, parentSwapOperationId = ? WHERE id = ?`, [ operation.state, @@ -243,6 +250,7 @@ export class SqliteMeltOperationRepository implements MeltOperationRepository { stringifyJson(operation.methodData), operation.quoteId ?? null, operation.unit, + operation.parentSwapOperationId ?? null, operation.id, ], ); @@ -253,7 +261,7 @@ export class SqliteMeltOperationRepository implements MeltOperationRepository { await this.db.run( `UPDATE coco_cashu_melt_operations - SET state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, quoteId = ?, unit = ?, amount = ?, fee_reserve = ?, swap_fee = ?, needsSwap = ?, inputAmount = ?, inputProofSecretsJson = ?, changeOutputDataJson = ?, swapOutputDataJson = ?, changeAmount = ?, effectiveFee = ?, finalizedDataJson = ? + SET state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, quoteId = ?, unit = ?, amount = ?, fee_reserve = ?, swap_fee = ?, needsSwap = ?, inputAmount = ?, inputProofSecretsJson = ?, changeOutputDataJson = ?, swapOutputDataJson = ?, changeAmount = ?, effectiveFee = ?, finalizedDataJson = ?, parentSwapOperationId = ? WHERE id = ?`, [ operation.state, @@ -280,6 +288,7 @@ export class SqliteMeltOperationRepository implements MeltOperationRepository { operation.state === 'finalized' && settlement.finalizedData !== undefined ? JSON.stringify(settlement.finalizedData) : null, + operation.parentSwapOperationId ?? null, operation.id, ], ); diff --git a/packages/sql-storage/src/repositories/MintOperationRepository.ts b/packages/sql-storage/src/repositories/MintOperationRepository.ts index e1a37e44a..2128bd0c9 100644 --- a/packages/sql-storage/src/repositories/MintOperationRepository.ts +++ b/packages/sql-storage/src/repositories/MintOperationRepository.ts @@ -28,6 +28,7 @@ interface MintOperationRow { lastObservedRemoteStateAt: number | null; terminalFailureJson: string | null; outputDataJson: string | null; + parentSwapOperationId: string | null; } const persistedStates = ['pending', 'executing', 'finalized', 'failed'] as const; @@ -60,6 +61,7 @@ const rowToOperation = (row: MintOperationRow): MintOperation => { createdAt: row.createdAt * 1000, updatedAt: row.updatedAt * 1000, error: row.error ?? undefined, + parentSwapOperationId: row.parentSwapOperationId ?? undefined, ...(row.terminalFailureJson ? { terminalFailure: JSON.parse(row.terminalFailureJson) as MintOperationFailure } : {}), @@ -116,6 +118,7 @@ const operationToParams = (operation: MintOperation): SqlValue[] => { null, operation.terminalFailure ? JSON.stringify(operation.terminalFailure) : null, null, + operation.parentSwapOperationId ?? null, ]; } @@ -138,6 +141,7 @@ const operationToParams = (operation: MintOperation): SqlValue[] => { null, operation.terminalFailure ? JSON.stringify(operation.terminalFailure) : null, JSON.stringify(operation.outputData), + operation.parentSwapOperationId ?? null, ]; }; @@ -149,8 +153,8 @@ export class SqliteMintOperationRepository implements MintOperationRepository { } async create(operation: MintOperation): Promise { - const exists = await this.db.get<{ id: string }>( - 'SELECT id FROM coco_cashu_mint_operations WHERE id = ? LIMIT 1', + const exists = await this.db.get<{ id: string; parentSwapOperationId: string | null }>( + 'SELECT id, parentSwapOperationId FROM coco_cashu_mint_operations WHERE id = ? LIMIT 1', [operation.id], ); if (exists) { @@ -160,27 +164,30 @@ export class SqliteMintOperationRepository implements MintOperationRepository { const params = operationToParams(operation); await this.db.run( `INSERT INTO coco_cashu_mint_operations - (id, mintUrl, quoteId, state, createdAt, updatedAt, error, method, methodDataJson, amount, unit, request, expiry, pubkey, lastObservedRemoteState, lastObservedRemoteStateAt, terminalFailureJson, outputDataJson) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (id, mintUrl, quoteId, state, createdAt, updatedAt, error, method, methodDataJson, amount, unit, request, expiry, pubkey, lastObservedRemoteState, lastObservedRemoteStateAt, terminalFailureJson, outputDataJson, parentSwapOperationId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, params, ); } async update(operation: MintOperation): Promise { - const exists = await this.db.get<{ id: string }>( - 'SELECT id FROM coco_cashu_mint_operations WHERE id = ? LIMIT 1', + const exists = await this.db.get<{ id: string; parentSwapOperationId: string | null }>( + 'SELECT id, parentSwapOperationId FROM coco_cashu_mint_operations WHERE id = ? LIMIT 1', [operation.id], ); if (!exists) { throw new Error(`MintOperation with id ${operation.id} not found`); } + if ((exists.parentSwapOperationId ?? undefined) !== operation.parentSwapOperationId) { + throw new Error(`Cannot change parent ownership of MintOperation ${operation.id}`); + } const updatedAtSeconds = getUnixTimeSeconds(); if (operation.state === 'init') { await this.db.run( `UPDATE coco_cashu_mint_operations - SET quoteId = ?, state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, amount = ?, unit = ?, terminalFailureJson = ? + SET quoteId = ?, state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, amount = ?, unit = ?, terminalFailureJson = ?, parentSwapOperationId = ? WHERE id = ?`, [ operation.quoteId, @@ -192,6 +199,7 @@ export class SqliteMintOperationRepository implements MintOperationRepository { serializeAmount(operation.amount), operation.unit, operation.terminalFailure ? JSON.stringify(operation.terminalFailure) : null, + operation.parentSwapOperationId ?? null, operation.id, ], ); @@ -200,7 +208,7 @@ export class SqliteMintOperationRepository implements MintOperationRepository { await this.db.run( `UPDATE coco_cashu_mint_operations - SET quoteId = ?, state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, amount = ?, unit = ?, request = ?, expiry = ?, pubkey = ?, lastObservedRemoteState = ?, lastObservedRemoteStateAt = ?, terminalFailureJson = ?, outputDataJson = ? + SET quoteId = ?, state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, amount = ?, unit = ?, request = ?, expiry = ?, pubkey = ?, lastObservedRemoteState = ?, lastObservedRemoteStateAt = ?, terminalFailureJson = ?, outputDataJson = ?, parentSwapOperationId = ? WHERE id = ?`, [ operation.quoteId, @@ -218,6 +226,7 @@ export class SqliteMintOperationRepository implements MintOperationRepository { null, operation.terminalFailure ? JSON.stringify(operation.terminalFailure) : null, JSON.stringify(operation.outputData), + operation.parentSwapOperationId ?? null, operation.id, ], ); diff --git a/packages/sql-storage/src/repositories/MintQuoteRepository.ts b/packages/sql-storage/src/repositories/MintQuoteRepository.ts index adbc75ebc..03cb8d57f 100644 --- a/packages/sql-storage/src/repositories/MintQuoteRepository.ts +++ b/packages/sql-storage/src/repositories/MintQuoteRepository.ts @@ -1,4 +1,5 @@ import { + applyBolt11MintQuoteStateFallback, deserializeAmount, getMintQuoteAmount, getMintQuoteRemoteState, @@ -229,15 +230,9 @@ export class SqliteMintQuoteRepository implements MintQuoteRepository { state: MintMethodRemoteState, observedAt = Date.now(), ): Promise { - await this.db.run( - `UPDATE coco_cashu_canonical_mint_quotes - SET state = ?, - amountPaid = CASE WHEN ? IN ('PAID', 'ISSUED') THEN amount ELSE '0' END, - amountIssued = CASE WHEN ? = 'ISSUED' THEN amount ELSE '0' END, - updatedAt = ? - WHERE mintUrl = ? AND method = ? AND quoteId = ?`, - [state, state, state, observedAt, normalizeMintUrl(mintUrl), method, quoteId], - ); + const quote = await this.getMintQuote(mintUrl, method, quoteId); + if (!quote || !isStatefulMintQuote(quote)) return; + await this.upsertMintQuote(applyBolt11MintQuoteStateFallback(quote, state, observedAt)); } async getPendingMintQuotes(method?: string): Promise { @@ -246,7 +241,7 @@ export class SqliteMintQuoteRepository implements MintQuoteRepository { quoteDataJson, amountPaid, amountIssued, remoteUpdatedAt, reusable, createdAt, updatedAt FROM coco_cashu_canonical_mint_quotes - WHERE (state IS NULL OR state != 'ISSUED') ${method ? 'AND method = ?' : ''}`, + ${method ? 'WHERE method = ?' : ''}`, method ? [method] : [], ); return rows.map(rowToMintQuote).filter(isMintQuotePending); diff --git a/packages/sql-storage/src/repositories/MintSwapOperationRepository.ts b/packages/sql-storage/src/repositories/MintSwapOperationRepository.ts new file mode 100644 index 000000000..c263dd9c8 --- /dev/null +++ b/packages/sql-storage/src/repositories/MintSwapOperationRepository.ts @@ -0,0 +1,259 @@ +import { Amount } from '@cashu/cashu-ts'; +import { + assertMintSwapTransition, + assertPreparedMintSwapImmutable, + isAutomaticMintSwapState, + validateMintSwapOperation, + type MintSwapOperation, + type MintSwapOperationState, +} from '@cashu/coco-core'; +import type { MintSwapOperationRepository } from '@cashu/coco-core/adapter'; + +import type { SqlDatabase } from '../index.ts'; + +interface MintSwapOperationRow { + id: string; + state: MintSwapOperationState; + revision: number; + sourceMintUrl: string; + destinationMintUrl: string; + destinationMintOperationId: string | null; + sourceMeltOperationId: string | null; + nextAttemptAt: number | null; + createdAt: number; + updatedAt: number; + recordJson: string; +} + +const SELECT_COLUMNS = ` + id, state, revision, sourceMintUrl, destinationMintUrl, destinationMintOperationId, + sourceMeltOperationId, nextAttemptAt, createdAt, updatedAt, recordJson +`; + +export class SqliteMintSwapOperationRepository implements MintSwapOperationRepository { + constructor(private readonly db: SqlDatabase) {} + + async create(operation: MintSwapOperation): Promise { + validateMintSwapOperation(operation); + if (operation.revision !== 0) { + throw new Error('New mint swap operation must start at revision 0'); + } + const row = toRow(operation); + await this.db.run( + `INSERT INTO coco_cashu_mint_swap_operations ( + id, state, revision, sourceMintUrl, destinationMintUrl, destinationMintOperationId, + sourceMeltOperationId, nextAttemptAt, createdAt, updatedAt, recordJson + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + rowParams(row), + ); + } + + async getById(id: string): Promise { + const row = await this.db.get( + `SELECT ${SELECT_COLUMNS} FROM coco_cashu_mint_swap_operations WHERE id = ?`, + [id], + ); + return row ? fromRow(row) : null; + } + + async getByState(state: MintSwapOperationState): Promise { + return this.query( + `SELECT ${SELECT_COLUMNS} FROM coco_cashu_mint_swap_operations + WHERE state = ? ORDER BY createdAt ASC, id ASC`, + [state], + ); + } + + async getActive(): Promise { + return this.query( + `SELECT ${SELECT_COLUMNS} FROM coco_cashu_mint_swap_operations + WHERE state NOT IN ('completed', 'cancelled', 'failed') + ORDER BY createdAt ASC, id ASC`, + ); + } + + async getDue(now: number, limit: number): Promise { + if (!Number.isSafeInteger(now) || now < 0) throw new Error('Due time must be non-negative'); + if (!Number.isSafeInteger(limit) || limit < 0) + throw new Error('Due limit must be non-negative'); + const operations = await this.query( + `SELECT ${SELECT_COLUMNS} FROM coco_cashu_mint_swap_operations + WHERE state IN ('preparing', 'source_inflight', 'destination_funded', 'issuing') + AND COALESCE(nextAttemptAt, 0) <= ? + ORDER BY COALESCE(nextAttemptAt, 0) ASC, createdAt ASC, id ASC + LIMIT ?`, + [now, limit], + ); + return operations.filter((operation) => isAutomaticMintSwapState(operation.state)); + } + + async getByDestinationMintOperationId(id: string): Promise { + return this.getByChild('destinationMintOperationId', id); + } + + async getBySourceMeltOperationId(id: string): Promise { + return this.getByChild('sourceMeltOperationId', id); + } + + async compareAndSet(operation: MintSwapOperation, expectedRevision: number): Promise { + const current = await this.getById(operation.id); + if (!current || current.revision !== expectedRevision) return false; + if (operation.revision !== expectedRevision + 1) { + throw new Error('Mint swap compare-and-set must advance revision exactly once'); + } + assertMintSwapTransition(current.state, operation.state); + assertPreparedMintSwapImmutable(current, operation); + validateMintSwapOperation(operation); + const row = toRow(operation); + const result = await this.db.run( + `UPDATE coco_cashu_mint_swap_operations SET + state = ?, revision = ?, sourceMintUrl = ?, destinationMintUrl = ?, + destinationMintOperationId = ?, sourceMeltOperationId = ?, nextAttemptAt = ?, + createdAt = ?, updatedAt = ?, recordJson = ? + WHERE id = ? AND revision = ?`, + [ + row.state, + row.revision, + row.sourceMintUrl, + row.destinationMintUrl, + row.destinationMintOperationId, + row.sourceMeltOperationId, + row.nextAttemptAt, + row.createdAt, + row.updatedAt, + row.recordJson, + row.id, + expectedRevision, + ], + ); + return result.changes === 1; + } + + private async getByChild( + column: 'destinationMintOperationId' | 'sourceMeltOperationId', + id: string, + ): Promise { + const row = await this.db.get( + `SELECT ${SELECT_COLUMNS} FROM coco_cashu_mint_swap_operations WHERE ${column} = ?`, + [id], + ); + return row ? fromRow(row) : null; + } + + private async query(sql: string, params: readonly (string | number)[] = []) { + const rows = await this.db.all(sql, params); + return rows.map(fromRow); + } +} + +function toRow(operation: MintSwapOperation): MintSwapOperationRow { + return { + id: operation.id, + state: operation.state, + revision: operation.revision, + sourceMintUrl: operation.sourceMintUrl, + destinationMintUrl: operation.destinationMintUrl, + destinationMintOperationId: operation.destinationMintOperationId ?? null, + sourceMeltOperationId: operation.sourceMeltOperationId ?? null, + nextAttemptAt: operation.retry.nextAttemptAt ?? null, + createdAt: operation.createdAt, + updatedAt: operation.updatedAt, + recordJson: JSON.stringify(serializeOperation(operation)), + }; +} + +function rowParams(row: MintSwapOperationRow) { + return [ + row.id, + row.state, + row.revision, + row.sourceMintUrl, + row.destinationMintUrl, + row.destinationMintOperationId, + row.sourceMeltOperationId, + row.nextAttemptAt, + row.createdAt, + row.updatedAt, + row.recordJson, + ] as const; +} + +function serializeOperation(operation: MintSwapOperation): unknown { + return { + ...operation, + destinationAmount: operation.destinationAmount.toString(), + preparedPlan: operation.preparedPlan + ? mapAmountsToStrings(operation.preparedPlan, [ + 'sourceMeltAmount', + 'sourceFeeReserve', + 'sourcePreparationFee', + 'sourceMeltInputFee', + 'minimumSourceDebit', + 'maximumSourceDebit', + 'reservedSourceAmount', + ]) + : undefined, + settlement: operation.settlement + ? mapAmountsToStrings(operation.settlement, [ + 'sourcePaymentFee', + 'totalSourceFee', + 'sourceMeltChangeAmount', + 'sourceKeepAmount', + 'sourceReturnedAmount', + 'finalSourceDebit', + 'destinationAmountIssued', + ]) + : undefined, + }; +} + +function fromRow(row: MintSwapOperationRow): MintSwapOperation { + const parsed = JSON.parse(row.recordJson) as Record & { + destinationAmount: string; + preparedPlan?: Record; + settlement?: Record; + }; + const operation = { + ...parsed, + destinationAmount: Amount.from(parsed.destinationAmount), + preparedPlan: parsed.preparedPlan + ? mapStringsToAmounts(parsed.preparedPlan, [ + 'sourceMeltAmount', + 'sourceFeeReserve', + 'sourcePreparationFee', + 'sourceMeltInputFee', + 'minimumSourceDebit', + 'maximumSourceDebit', + 'reservedSourceAmount', + ]) + : undefined, + settlement: parsed.settlement + ? mapStringsToAmounts(parsed.settlement, [ + 'sourcePaymentFee', + 'totalSourceFee', + 'sourceMeltChangeAmount', + 'sourceKeepAmount', + 'sourceReturnedAmount', + 'finalSourceDebit', + 'destinationAmountIssued', + ]) + : undefined, + } as MintSwapOperation; + return validateMintSwapOperation(operation); +} + +function mapAmountsToStrings(value: T, keys: readonly string[]): object { + const result = { ...value } as Record; + for (const key of keys) { + if (result[key] !== undefined) result[key] = Amount.from(result[key] as Amount).toString(); + } + return result; +} + +function mapStringsToAmounts(value: Record, keys: readonly string[]): object { + const result = { ...value }; + for (const key of keys) { + if (result[key] !== undefined) result[key] = Amount.from(result[key] as string); + } + return result; +} diff --git a/packages/sql-storage/src/repositories/OperationEventOutboxRepository.ts b/packages/sql-storage/src/repositories/OperationEventOutboxRepository.ts new file mode 100644 index 000000000..eabda0074 --- /dev/null +++ b/packages/sql-storage/src/repositories/OperationEventOutboxRepository.ts @@ -0,0 +1,102 @@ +import type { + OperationEventOutboxRecord, + OperationEventOutboxRepository, +} from '@cashu/coco-core/adapter'; + +import type { SqlDatabase } from '../index.ts'; + +interface OutboxRow { + id: string; + operationId: string; + revision: number; + eventType: OperationEventOutboxRecord['eventType']; + payloadJson: string; + createdAt: number; + publishedAt: number | null; + publishAttempts: number; + nextAttemptAt: number | null; + lastError: string | null; +} + +const SELECT_COLUMNS = ` + id, operationId, revision, eventType, payloadJson, createdAt, publishedAt, + publishAttempts, nextAttemptAt, lastError +`; + +export class SqliteOperationEventOutboxRepository implements OperationEventOutboxRepository { + constructor(private readonly db: SqlDatabase) {} + + async enqueue(event: OperationEventOutboxRecord): Promise { + validateEvent(event); + await this.db.run( + `INSERT INTO coco_cashu_operation_event_outbox ( + id, operationId, revision, eventType, payloadJson, createdAt, publishedAt, + publishAttempts, nextAttemptAt, lastError + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + event.id, + event.operationId, + event.revision, + event.eventType, + JSON.stringify(event.payload), + event.createdAt, + event.publishedAt ?? null, + event.publishAttempts, + event.nextAttemptAt ?? null, + event.lastError ?? null, + ], + ); + } + + async getUnpublished(limit: number, now = Date.now()): Promise { + const rows = await this.db.all( + `SELECT ${SELECT_COLUMNS} FROM coco_cashu_operation_event_outbox + WHERE publishedAt IS NULL AND COALESCE(nextAttemptAt, 0) <= ? + ORDER BY createdAt ASC, id ASC LIMIT ?`, + [now, limit], + ); + return rows.map(fromRow); + } + + async markPublished(id: string, publishedAt: number): Promise { + await this.db.run( + `UPDATE coco_cashu_operation_event_outbox + SET publishedAt = COALESCE(publishedAt, ?), nextAttemptAt = NULL, lastError = NULL + WHERE id = ?`, + [publishedAt, id], + ); + } + + async recordPublishFailure(id: string, nextAttemptAt: number, lastError: string): Promise { + await this.db.run( + `UPDATE coco_cashu_operation_event_outbox + SET publishAttempts = publishAttempts + 1, nextAttemptAt = ?, lastError = ? + WHERE id = ? AND publishedAt IS NULL`, + [nextAttemptAt, lastError, id], + ); + } +} + +function fromRow(row: OutboxRow): OperationEventOutboxRecord { + return { + id: row.id, + operationId: row.operationId, + revision: row.revision, + eventType: row.eventType, + payload: JSON.parse(row.payloadJson), + createdAt: row.createdAt, + publishedAt: row.publishedAt ?? undefined, + publishAttempts: row.publishAttempts, + nextAttemptAt: row.nextAttemptAt ?? undefined, + lastError: row.lastError ?? undefined, + }; +} + +function validateEvent(event: OperationEventOutboxRecord): void { + if ( + event.payload.operationId !== event.operationId || + event.payload.revision !== event.revision + ) { + throw new Error('Outbox payload identity must match its logical event key'); + } +} diff --git a/packages/sql-storage/src/schema.ts b/packages/sql-storage/src/schema.ts index 510cf64cb..0fe1a18bd 100644 --- a/packages/sql-storage/src/schema.ts +++ b/packages/sql-storage/src/schema.ts @@ -1487,7 +1487,61 @@ const MIGRATIONS: readonly Migration[] = [ quoteDataJson = CASE WHEN json_valid(quoteDataJson) THEN quoteDataJson ELSE '{}' - END; + END; + `, + }, + { + id: '038_mint_swap_operations_and_outbox', + sql: ` + CREATE TABLE IF NOT EXISTS coco_cashu_mint_swap_operations ( + id TEXT PRIMARY KEY, + state TEXT NOT NULL CHECK (state IN ( + 'preparing', 'prepared', 'source_inflight', 'destination_funded', 'issuing', + 'completed', 'cancelled', 'failed', 'needs_attention' + )), + revision INTEGER NOT NULL CHECK (revision >= 0), + sourceMintUrl TEXT NOT NULL, + destinationMintUrl TEXT NOT NULL, + destinationMintOperationId TEXT UNIQUE, + sourceMeltOperationId TEXT UNIQUE, + nextAttemptAt INTEGER, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + recordJson TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_swap_operations_state + ON coco_cashu_mint_swap_operations(state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_swap_operations_due + ON coco_cashu_mint_swap_operations(state, nextAttemptAt, createdAt); + + CREATE TABLE IF NOT EXISTS coco_cashu_operation_event_outbox ( + id TEXT PRIMARY KEY, + operationId TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision >= 0), + eventType TEXT NOT NULL, + payloadJson TEXT NOT NULL, + createdAt INTEGER NOT NULL, + publishedAt INTEGER, + publishAttempts INTEGER NOT NULL DEFAULT 0 CHECK (publishAttempts >= 0), + nextAttemptAt INTEGER, + lastError TEXT, + UNIQUE (operationId, revision, eventType) + ); + + CREATE INDEX IF NOT EXISTS idx_coco_cashu_operation_event_outbox_unpublished + ON coco_cashu_operation_event_outbox(publishedAt, nextAttemptAt, createdAt); + `, + }, + { + id: '039_mint_swap_child_ownership', + sql: ` + ALTER TABLE coco_cashu_mint_operations ADD COLUMN parentSwapOperationId TEXT; + ALTER TABLE coco_cashu_melt_operations ADD COLUMN parentSwapOperationId TEXT; + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_parent_swap + ON coco_cashu_mint_operations(parentSwapOperationId); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_melt_operations_parent_swap + ON coco_cashu_melt_operations(parentSwapOperationId); `, }, ]; diff --git a/packages/sql-storage/src/test/schema.test.ts b/packages/sql-storage/src/test/schema.test.ts index 944c3beec..4aa288932 100644 --- a/packages/sql-storage/src/test/schema.test.ts +++ b/packages/sql-storage/src/test/schema.test.ts @@ -49,6 +49,8 @@ const EXPECTED_MIGRATION_IDS = [ '035_duplicate_quote_ids', '036_quote_identity_unique_indexes', '037_mint_quote_accounting', + '038_mint_swap_operations_and_outbox', + '039_mint_swap_child_ownership', ] as const; const RECEIVE_OPERATIONS_SQL = ` @@ -291,6 +293,40 @@ describe('shared SQL schema migrations', () => { }, ); + itWithDatabase('adds mint-swap parent and outbox constraints on upgrade', async (db) => { + await ensureSchemaUpTo(db, '038_mint_swap_operations_and_outbox'); + expect(await getColumnNames(db, 'coco_cashu_mint_swap_operations')).toEqual([]); + + await ensureSchemaUpTo(db); + + expect(await getColumnTypes(db, 'coco_cashu_mint_swap_operations')).toMatchObject({ + id: 'TEXT', + revision: 'INTEGER', + recordJson: 'TEXT', + }); + expect(await getIndexNames(db, 'coco_cashu_mint_swap_operations')).toEqual( + expect.arrayContaining([ + 'idx_coco_cashu_mint_swap_operations_state', + 'idx_coco_cashu_mint_swap_operations_due', + ]), + ); + expect(await getIndexNames(db, 'coco_cashu_operation_event_outbox')).toEqual( + expect.arrayContaining(['idx_coco_cashu_operation_event_outbox_unpublished']), + ); + expect(await getColumnNames(db, 'coco_cashu_mint_operations')).toContain( + 'parentSwapOperationId', + ); + expect(await getColumnNames(db, 'coco_cashu_melt_operations')).toContain( + 'parentSwapOperationId', + ); + expect(await getIndexNames(db, 'coco_cashu_mint_operations')).toContain( + 'idx_coco_cashu_mint_operations_parent_swap', + ); + expect(await getIndexNames(db, 'coco_cashu_melt_operations')).toContain( + 'idx_coco_cashu_melt_operations_parent_swap', + ); + }); + itWithDatabase('upgrades mint operations to allow failed state persistence', async (db) => { await ensureSchemaUpTo(db, '020_mint_operations_failed_state'); diff --git a/packages/sqlite-bun/src/index.ts b/packages/sqlite-bun/src/index.ts index 90485ebbf..7872da12b 100644 --- a/packages/sqlite-bun/src/index.ts +++ b/packages/sqlite-bun/src/index.ts @@ -24,6 +24,8 @@ export class SqliteRepositories implements Repositories { readonly receiveOperationRepository: Repositories['receiveOperationRepository']; readonly paymentRequestReceiveOperationRepository: Repositories['paymentRequestReceiveOperationRepository']; readonly paymentRequestReceiveAttemptRepository: Repositories['paymentRequestReceiveAttemptRepository']; + readonly mintSwapOperationRepository: Repositories['mintSwapOperationRepository']; + readonly operationEventOutboxRepository: Repositories['operationEventOutboxRepository']; private readonly db: SqliteDb; private readonly repositories: SqlStorageRepositories; @@ -49,6 +51,8 @@ export class SqliteRepositories implements Repositories { this.repositories.paymentRequestReceiveOperationRepository; this.paymentRequestReceiveAttemptRepository = this.repositories.paymentRequestReceiveAttemptRepository; + this.mintSwapOperationRepository = this.repositories.mintSwapOperationRepository; + this.operationEventOutboxRepository = this.repositories.operationEventOutboxRepository; } async init(): Promise { diff --git a/packages/sqlite-bun/src/test/contract.test.ts b/packages/sqlite-bun/src/test/contract.test.ts index 3462dee2d..3c8f967c0 100644 --- a/packages/sqlite-bun/src/test/contract.test.ts +++ b/packages/sqlite-bun/src/test/contract.test.ts @@ -10,6 +10,7 @@ import { runSendOperationRepositoryContract, runMeltOperationRepositoryContract, runMeltQuoteRepositoryContract, + runMintSwapRepositoryContract, } from '@cashu/coco-adapter-tests'; import { runSqlDatabaseContract } from '@cashu/coco-sql-storage/test'; import { SqliteRepositories as Repositories } from '../index.ts'; @@ -66,6 +67,8 @@ runMeltQuoteRepositoryContract({ createRepositories }, { describe, it, expect }) runPaymentRequestReceiveRepositoryContract({ createRepositories }, { describe, it, expect }); +runMintSwapRepositoryContract({ createRepositories }, { describe, it, expect }); + describe('hydration corruption guard', () => { it('throws when send operation has prepared state but null financial fields', async () => { const { repositories, dispose } = await createRepositories(); diff --git a/packages/sqlite3/src/index.ts b/packages/sqlite3/src/index.ts index 5e4f127d6..fa05a8bc6 100644 --- a/packages/sqlite3/src/index.ts +++ b/packages/sqlite3/src/index.ts @@ -24,6 +24,8 @@ export class SqliteRepositories implements Repositories { readonly receiveOperationRepository: Repositories['receiveOperationRepository']; readonly paymentRequestReceiveOperationRepository: Repositories['paymentRequestReceiveOperationRepository']; readonly paymentRequestReceiveAttemptRepository: Repositories['paymentRequestReceiveAttemptRepository']; + readonly mintSwapOperationRepository: Repositories['mintSwapOperationRepository']; + readonly operationEventOutboxRepository: Repositories['operationEventOutboxRepository']; private readonly db: SqliteDb; private readonly repositories: SqlStorageRepositories; @@ -49,6 +51,8 @@ export class SqliteRepositories implements Repositories { this.repositories.paymentRequestReceiveOperationRepository; this.paymentRequestReceiveAttemptRepository = this.repositories.paymentRequestReceiveAttemptRepository; + this.mintSwapOperationRepository = this.repositories.mintSwapOperationRepository; + this.operationEventOutboxRepository = this.repositories.operationEventOutboxRepository; } async init(): Promise { diff --git a/packages/sqlite3/src/test/contract.test.ts b/packages/sqlite3/src/test/contract.test.ts index 92579dc3c..a99066d54 100644 --- a/packages/sqlite3/src/test/contract.test.ts +++ b/packages/sqlite3/src/test/contract.test.ts @@ -10,6 +10,7 @@ import { runSendOperationRepositoryContract, runMeltOperationRepositoryContract, runMeltQuoteRepositoryContract, + runMintSwapRepositoryContract, } from '@cashu/coco-adapter-tests'; import { runSqlDatabaseContract } from '@cashu/coco-sql-storage/test'; import { SqliteRepositories as Repositories } from '../index.ts'; @@ -66,6 +67,8 @@ runMeltQuoteRepositoryContract({ createRepositories }, { describe, it, expect }) runPaymentRequestReceiveRepositoryContract({ createRepositories }, { describe, it, expect }); +runMintSwapRepositoryContract({ createRepositories }, { describe, it, expect }); + describe('hydration corruption guard', () => { it('throws when send operation has prepared state but null financial fields', async () => { const { repositories, dispose } = await createRepositories();