diff --git a/.github/workflows/core_tests.yml b/.github/workflows/core_tests.yml index e9704e86b..3c0c1f14a 100644 --- a/.github/workflows/core_tests.yml +++ b/.github/workflows/core_tests.yml @@ -6,12 +6,14 @@ on: - master paths: - 'packages/core/**' + - 'packages/adapter-tests/**' - 'package.json' - 'bun.lock' - '.github/workflows/core_tests.yml' pull_request: paths: - 'packages/core/**' + - 'packages/adapter-tests/**' - 'package.json' - 'bun.lock' - '.github/workflows/core_tests.yml' @@ -31,6 +33,11 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Build core and adapter tests + run: | + bun run --filter='@cashu/coco-core' build + bun run --filter='@cashu/coco-adapter-tests' build + - name: Run core tests with coverage run: bun run test:coverage:core diff --git a/packages/adapter-tests/src/index.ts b/packages/adapter-tests/src/index.ts index 586300b8f..22567b6c4 100644 --- a/packages/adapter-tests/src/index.ts +++ b/packages/adapter-tests/src/index.ts @@ -16,6 +16,9 @@ import { type ReceiveOperation, type SendOperation, type AuthSession, + type MintSwapOperation, + type MintSwapRepositoryCapability, + type OperationEventOutboxRecord, QuoteIdentityConflictError, } from '@cashu/coco-core/adapter'; @@ -76,7 +79,95 @@ export async function runRepositoryTransactionContract( } }); + it('preserves binary keypair payloads across transaction round trips', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const preExistingSecretKey = new Uint8Array(32); + for (let i = 0; i < preExistingSecretKey.length; i++) preExistingSecretKey[i] = i + 1; + await repositories.keyRingRepository.setPersistedKeyPair({ + publicKeyHex: '02contractkeypairbeforetransaction', + secretKey: preExistingSecretKey, + purpose: 'nut20_mint_quote', + }); + + const committedSecretKey = new Uint8Array(16); + for (let i = 0; i < committedSecretKey.length; i++) committedSecretKey[i] = 255 - i; + await repositories.withTransaction(async (tx) => { + await tx.keyRingRepository.setPersistedKeyPair({ + publicKeyHex: '02contractkeypairinsidetransaction', + secretKey: committedSecretKey, + purpose: 'p2pk', + }); + }); + + const preserved = await repositories.keyRingRepository.getPersistedKeyPair( + '02contractkeypairbeforetransaction', + 'nut20_mint_quote', + ); + expect(preserved).toBeDefined(); + expect(preserved?.secretKey instanceof Uint8Array).toBe(true); + expect(preserved?.secretKey).toHaveLength(32); + expect(sameBytes(preserved?.secretKey, preExistingSecretKey)).toBe(true); + + const committed = await repositories.keyRingRepository.getPersistedKeyPair( + '02contractkeypairinsidetransaction', + 'p2pk', + ); + expect(committed).toBeDefined(); + expect(committed?.secretKey instanceof Uint8Array).toBe(true); + expect(committed?.secretKey).toHaveLength(16); + expect(sameBytes(committed?.secretKey, committedSecretKey)).toBe(true); + } finally { + await dispose(); + } + }); + if (options.testConcurrentRootOperationIsolation) { + it('preserves a concurrent root repository write after transaction commit', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const transactionEntered = createDeferred(); + const releaseTransaction = createDeferred(); + const mintInTransaction = { + ...createDummyMint(), + mintUrl: 'https://mint-in-committed-transaction.test', + }; + const outsideMint = { + ...createDummyMint(), + mintUrl: 'https://outside-committed-transaction.test', + }; + + const transactionPromise = repositories.withTransaction(async (tx) => { + await tx.mintRepository.addOrUpdateMint(mintInTransaction); + transactionEntered.resolve(); + await releaseTransaction.promise; + }); + + await transactionEntered.promise; + + let outsideWriteResolved = false; + const outsideWritePromise = repositories.mintRepository + .addOrUpdateMint(outsideMint) + .then(() => { + outsideWriteResolved = true; + }); + + await flushMicrotasks(); + expect(outsideWriteResolved).toBe(false); + + releaseTransaction.resolve(); + await transactionPromise; + await outsideWritePromise; + + const mints = await repositories.mintRepository.getAllMints(); + expect(mints).toHaveLength(2); + expect(mints.some(({ mintUrl }) => mintUrl === mintInTransaction.mintUrl)).toBe(true); + expect(mints.some(({ mintUrl }) => mintUrl === outsideMint.mintUrl)).toBe(true); + } finally { + await dispose(); + } + }); + it('does not include concurrent root repository writes in active transactions', async () => { const { repositories, dispose } = await options.createRepositories(); try { @@ -107,10 +198,7 @@ export async function runRepositoryTransactionContract( outsideWriteResolved = true; }); - await Promise.race([ - outsideWritePromise, - new Promise((resolve) => setTimeout(resolve, 25)), - ]); + await flushMicrotasks(); expect(outsideWriteResolved).toBe(false); releaseTransaction.resolve(); @@ -128,6 +216,29 @@ export async function runRepositoryTransactionContract( }); } +export async function runMintSwapCapabilityAbsenceContract( + options: ContractOptions, + runner: ContractRunner, +): Promise { + const { describe, it, expect } = runner; + + describe('optional Mint Swap repository capability contract', () => { + it('keeps ordinary repositories and transactions compatible when absent', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + expect(repositories.mintSwap).toBe(undefined); + await repositories.withTransaction(async (tx) => { + expect(tx.mintSwap).toBe(undefined); + await tx.mintRepository.addOrUpdateMint(createDummyMint()); + }); + expect(await repositories.mintRepository.getAllMints()).toHaveLength(1); + } finally { + await dispose(); + } + }); + }); +} + export type ContractRunner = { describe(name: string, fn: () => void): void; it(name: string, fn: () => Promise | void): void; @@ -179,6 +290,18 @@ function createDeferred() { return { promise, resolve, reject } as const; } +async function flushMicrotasks(turns = 10): Promise { + for (let turn = 0; turn < turns; turn++) await Promise.resolve(); +} + +function sameBytes(actual: Uint8Array | undefined, expected: Uint8Array): boolean { + if (!actual || actual.length !== expected.length) return false; + for (let i = 0; i < expected.length; i++) { + if (actual[i] !== expected[i]) return false; + } + return true; +} + export function createDummyMint(): Mint { return { mintUrl: 'https://mint.test', @@ -635,6 +758,423 @@ export async function runMintQuoteRepositoryContract( }); } +export function createDummyPreparingMintSwapOperation( + overrides: Partial = {}, +): MintSwapOperation { + const now = 1_700_000_000_000; + return { + id: 'mint-swap-preparing', + state: 'preparing', + revision: 0, + sourceMintUrl: 'https://source-mint.test', + destinationMintUrl: 'https://destination-mint.test', + unit: 'sat', + destinationAmount: Amount.from(100), + destinationNut20Key: { publicKey: `02${'ab'.repeat(32)}`, derivationIndex: 42 }, + preparationLease: { + ownerId: 'adapter-contract-worker', + token: 'adapter-contract-lease', + stage: 'destination_quote', + acquiredAt: now, + expiresAt: now + 1_000, + }, + retry: { attemptCount: 0 }, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +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); + const now = 1_700_000_000_000; + return { + id: 'mint-swap-op', + state: 'prepared', + revision: 0, + sourceMintUrl: 'https://source-mint.test', + destinationMintUrl: 'https://destination-mint.test', + unit: 'sat', + destinationAmount, + destinationNut20Key: { publicKey: `02${'ab'.repeat(32)}`, derivationIndex: 42 }, + 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', + preparedPlan: { + fingerprint: 'ab'.repeat(32), + dispatchDeadlineSeconds: Math.floor(now / 1_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: now, + updatedAt: now, + ...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('Mint Swap repository capability contract', () => { + it('round-trips decimal amounts and child lookups', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const capability = requireMintSwapCapability(repositories.mintSwap); + const operation = createDummyMintSwapOperation(); + await capability.mintSwapOperationRepository.create(operation); + const stored = await capability.mintSwapOperationRepository.getById(operation.id); + const byDestination = + await capability.mintSwapOperationRepository.getByDestinationMintOperationId( + 'destination-mint-op', + ); + const bySource = + await capability.mintSwapOperationRepository.getBySourceMeltOperationId('source-melt-op'); + + expect(stored?.destinationAmount.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 capability = requireMintSwapCapability(repositories.mintSwap); + const operation = createDummyPreparingMintSwapOperation(); + await capability.mintSwapOperationRepository.create(operation); + const next = { + ...operation, + revision: 1, + retry: { attemptCount: 1 }, + updatedAt: operation.updatedAt + 1, + } satisfies MintSwapOperation; + const results = await Promise.all([ + capability.mintSwapOperationRepository.compareAndSet(next, 0), + capability.mintSwapOperationRepository.compareAndSet(next, 0), + ]); + + expect(results.filter(Boolean)).toHaveLength(1); + } finally { + await dispose(); + } + }); + + it('excludes live preparation leases and returns stale work in due order', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const capability = requireMintSwapCapability(repositories.mintSwap); + const now = 1_700_000_010_000; + const makeDue = (id: string, expiresAt: number): MintSwapOperation => + createDummyPreparingMintSwapOperation({ + id, + preparationLease: { + ...createDummyPreparingMintSwapOperation().preparationLease!, + acquiredAt: expiresAt - 1_000, + expiresAt, + }, + createdAt: expiresAt - 1_000, + updatedAt: expiresAt - 1_000, + }); + await capability.mintSwapOperationRepository.create(makeDue('due-later', now)); + await capability.mintSwapOperationRepository.create(makeDue('due-first', now - 1)); + await capability.mintSwapOperationRepository.create(makeDue('live', now + 1)); + + const due = await capability.mintSwapOperationRepository.getDue(now, 10); + expect(due).toHaveLength(2); + expect(due[0]?.id).toBe('due-first'); + expect(due[1]?.id).toBe('due-later'); + } finally { + await dispose(); + } + }); + + it('enforces unique parent child references and child repository ownership', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const capability = requireMintSwapCapability(repositories.mintSwap); + await capability.mintSwapOperationRepository.create(createDummyMintSwapOperation()); + await expectThrows( + () => + capability.mintSwapOperationRepository.create( + createDummyMintSwapOperation({ id: 'other-mint-swap-op' }), + ), + expect, + ); + const standaloneMint = createDummyMintOperation({ + id: 'standalone-mint', + quoteId: 'standalone-mint-quote', + }); + await repositories.mintOperationRepository.create(standaloneMint); + await expectThrows( + () => + repositories.mintOperationRepository.update({ + ...standaloneMint, + parentSwapOperationId: 'late-mint-parent', + }), + expect, + ); + const standaloneMelt = createDummyMeltOperation({ + id: 'standalone-melt', + quoteId: 'standalone-melt-quote', + }); + await repositories.meltOperationRepository.create(standaloneMelt); + await expectThrows( + () => + repositories.meltOperationRepository.update({ + ...standaloneMelt, + parentSwapOperationId: 'late-melt-parent', + }), + expect, + ); + const mintChild = createDummyMintOperation({ + id: 'owned-mint', + quoteId: 'owned-mint-quote', + parentSwapOperationId: 'mint-parent', + pubkey: `02${'ab'.repeat(32)}`, + outputData: { + keep: [ + { + blindedMessage: { amount: 3, id: 'owned-keyset', B_: 'owned-B' }, + blindingFactor: '01', + secret: '6f776e65642d6f7574707574', + }, + ], + send: [], + }, + }); + await repositories.mintOperationRepository.create(mintChild); + expect( + (await repositories.mintOperationRepository.getById(mintChild.id))?.parentSwapOperationId, + ).toBe('mint-parent'); + await expectThrows( + () => + repositories.mintOperationRepository.update({ + ...mintChild, + amount: mintChild.amount.add(1), + }), + expect, + ); + const fetchedMintChild = await repositories.mintOperationRepository.getById(mintChild.id); + if (!fetchedMintChild || fetchedMintChild.state === 'init') { + throw new Error('Expected a persisted pending mint child'); + } + fetchedMintChild.outputData.keep.push({ + blindedMessage: { amount: 1, id: 'mutated-keyset', B_: 'mutated-B' }, + blindingFactor: '01', + secret: '61', + }); + const refetchedMintChild = await repositories.mintOperationRepository.getById(mintChild.id); + if (!refetchedMintChild || refetchedMintChild.state === 'init') { + throw new Error('Expected a persisted pending mint child'); + } + expect(refetchedMintChild.outputData.keep.length).toBe(1); + await expectThrows( + () => + repositories.mintOperationRepository.update({ + ...mintChild, + parentSwapOperationId: 'different-parent', + }), + expect, + ); + await expectThrows( + () => + repositories.mintOperationRepository.create( + createDummyMintOperation({ + id: 'second-owned-mint', + quoteId: 'second-owned-mint-quote', + parentSwapOperationId: 'mint-parent', + pubkey: `02${'ab'.repeat(32)}`, + outputData: { + keep: [ + { + blindedMessage: { amount: 3, id: 'owned-keyset', B_: 'owned-B-2' }, + blindingFactor: '02', + secret: '7365636f6e642d6f7574707574', + }, + ], + send: [], + }, + }), + ), + expect, + ); + await expectThrows(() => repositories.mintOperationRepository.delete(mintChild.id), expect); + + const meltChild = createDummyMeltOperation({ + id: 'owned-melt', + quoteId: 'owned-melt-quote', + parentSwapOperationId: 'melt-parent', + }); + await repositories.meltOperationRepository.create(meltChild); + expect( + (await repositories.meltOperationRepository.getById(meltChild.id))?.parentSwapOperationId, + ).toBe('melt-parent'); + await expectThrows( + () => + repositories.meltOperationRepository.update({ + ...meltChild, + parentSwapOperationId: 'different-parent', + }), + expect, + ); + await expectThrows( + () => + repositories.meltOperationRepository.update({ + ...meltChild, + parentExecutionPhase: 'melt_authorized', + }), + expect, + ); + await expectThrows( + () => + repositories.meltOperationRepository.create( + createDummyMeltOperation({ + id: 'second-owned-melt', + quoteId: 'second-owned-melt-quote', + parentSwapOperationId: 'melt-parent', + }), + ), + expect, + ); + await expectThrows(() => repositories.meltOperationRepository.delete(meltChild.id), expect); + } finally { + await dispose(); + } + }); + + it('rolls parent, child, and outbox writes back together', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + await expectThrows( + () => + repositories.withTransaction(async (tx) => { + const capability = requireMintSwapCapability(tx.mintSwap); + await capability.mintSwapOperationRepository.create(createDummyMintSwapOperation()); + await capability.operationEventOutboxRepository.enqueue( + createDummyOperationEventOutboxRecord(), + ); + await tx.mintOperationRepository.create( + createDummyMintOperation({ + id: 'rolled-back-child', + quoteId: 'rolled-back-child-quote', + parentSwapOperationId: 'mint-swap-op', + pubkey: `02${'ab'.repeat(32)}`, + }), + ); + throw new Error('injected rollback'); + }), + expect, + ); + const capability = requireMintSwapCapability(repositories.mintSwap); + expect(await capability.mintSwapOperationRepository.getById('mint-swap-op')).toBe(null); + expect(await capability.operationEventOutboxRepository.getUnpublished(10)).toHaveLength(0); + expect(await repositories.mintOperationRepository.getById('rolled-back-child')).toBe(null); + } finally { + await dispose(); + } + }); + + it('enforces outbox logical uniqueness and durable publication state', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const capability = requireMintSwapCapability(repositories.mintSwap); + await capability.operationEventOutboxRepository.enqueue( + createDummyOperationEventOutboxRecord(), + ); + await expectThrows( + () => + capability.operationEventOutboxRepository.enqueue( + createDummyOperationEventOutboxRecord({ id: 'duplicate-logical-event' }), + ), + expect, + ); + await capability.operationEventOutboxRepository.recordPublishFailure( + 'mint-swap-event', + 1_700_000_000_010, + 'temporarily unavailable', + ); + expect( + await capability.operationEventOutboxRepository.getUnpublished(10, 1_700_000_000_009), + ).toHaveLength(0); + await capability.operationEventOutboxRepository.markPublished( + 'mint-swap-event', + 1_700_000_000_011, + ); + expect(await capability.operationEventOutboxRepository.getUnpublished(10)).toHaveLength(0); + const published = + await capability.operationEventOutboxRepository.getById('mint-swap-event'); + expect(published?.publishedAt).toBe(1_700_000_000_011); + expect(published?.publishAttempts).toBe(2); + expect(published?.lastError).toBe(undefined); + await expectThrows( + () => + capability.operationEventOutboxRepository.enqueue( + createDummyOperationEventOutboxRecord({ id: 'published-logical-event' }), + ), + expect, + ); + } finally { + await dispose(); + } + }); + }); +} + +function requireMintSwapCapability( + capability: MintSwapRepositoryCapability | undefined, +): MintSwapRepositoryCapability { + if (!capability) throw new Error('Mint Swap repository capability is required by this contract'); + return capability; +} export async function runMintOperationRepositoryContract( options: ContractOptions, runner: ContractRunner, diff --git a/packages/core/adapter.ts b/packages/core/adapter.ts index f6b69c042..976ed8a4f 100644 --- a/packages/core/adapter.ts +++ b/packages/core/adapter.ts @@ -11,6 +11,9 @@ export type { MintOperationRepository, MintQuoteRepository, MintRepository, + MintSwapOperationRepository, + MintSwapRepositoryCapability, + OperationEventOutboxRepository, PaymentRequestReceiveAttemptRepository, PaymentRequestReceiveOperationRepository, ProofRepository, @@ -20,6 +23,7 @@ export type { RepositoryTransactionScope, SendOperationRepository, } from './repositories/index.ts'; +export { requireMintSwapRepositoryCapability } from './repositories/index.ts'; export type { AuthSession, Counter, @@ -37,6 +41,10 @@ export type { MintQuoteRef, QuoteIdentity, } from './models/index.ts'; +export type { + MintSwapEventPayload, + OperationEventOutboxRecord, +} from './models/OperationEventOutbox.ts'; export { applyBolt11MintQuoteStateFallback, compareHistoryEntries, @@ -73,6 +81,41 @@ export type { SendOperation, SendOperationState, } from './operations/index.ts'; +export type { + MintSwapAttentionReason, + MintSwapAttentionRecord, + MintSwapEventType, + MintSwapNut20KeyRef, + MintSwapOperation, + MintSwapOperationState, + MintSwapPreparationLease, + MintSwapPreparationStage, + MintSwapPreparedPlan, + MintSwapQuoteRef, + MintSwapRetry, + MintSwapSettlement, + MintSwapTerminalFailure, +} from './operations/mintSwap/MintSwapOperation.ts'; +export { + assertMintSwapOperationUpdate, + createMintSwapPreparedPlanFingerprint, + getMintSwapOperationDueAt, + isMintSwapOperationDue, + isTerminalMintSwapState, + validateMintSwapOperation, +} from './operations/mintSwap/MintSwapOperation.ts'; +export { + assertParentOwnedMeltOperationInvariant, + assertParentOwnedMeltOperationUpdate, + assertParentOwnedMintOperationInvariant, + assertParentOwnedMintOperationUpdate, +} from './operations/mintSwap/ChildOperationOwnership.ts'; +export { + isOperationEventDue, + isOperationEventPublished, + operationEventLogicalKey, + validateOperationEventOutboxRecord, +} from './models/OperationEventOutbox.ts'; export type { MeltMethodRemoteState } from './operations/melt/MeltMethodHandler.ts'; export { normalizeMeltMethodData } from './operations/index.ts'; export type { BalanceQuery, CoreProof, ProofState } from './types.ts'; diff --git a/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts b/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts index a450a8212..c69ee6da3 100644 --- a/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts +++ b/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts @@ -10,11 +10,13 @@ import { type SerializedBlindedSignature, } from '@cashu/cashu-ts'; import { MintOperationError, ProofValidationError } from '@core/models'; +import type { Logger } from '../../../logging/Logger.ts'; import type { BasePrepareContext, CreateMeltQuoteContext, ExecuteContext, ExecutionResult, + ExecutingMeltOperation, FetchRemoteMeltQuoteContext, FinalizeContext, FinalizeResult, @@ -23,6 +25,9 @@ import type { MeltMethod, MeltMethodQuoteSnapshot, MeltMethodRemoteState, + OwnedMeltRemoteContext, + OwnedMeltRemoteResult, + ApplyOwnedMeltRemoteContext, PendingCheckResult, PendingContext, PreparedMeltOperation, @@ -30,8 +35,10 @@ import type { RollbackContext, } from '@core/operations/melt'; import { + assertProofsMatchSerializedOutputs, computeYHexForSecrets, deserializeOutputData, + getSecretsFromSerializedOutputData, mapProofToCoreProof, serializeOutputData, type SerializedOutputData, @@ -74,7 +81,7 @@ export abstract class BaseQuoteMeltHandler implements Melt ): Promise>; protected abstract executeMelt( - ctx: ExecuteContext, + ctx: Pick, 'operation' | 'wallet' | 'mintAdapter' | 'logger'>, proofsToMelt: Proof[], changeOutputs: OutputDataLike[], quoteId: string, @@ -360,9 +367,6 @@ export abstract class BaseQuoteMeltHandler implements Melt const blankOutputs = await this.createChangeOutputs(amount, sendAmount, ctx); - // FIXME: This relies on the 10% swap threshold buffer to cover the future melt input fee. - // Pathological fee/output combinations can still make the fee-inflated send side exceed - // the amount validated above. const swapOutputData = await ctx.proofService.createOutputsAndIncrementCounters( mintUrl, { @@ -466,6 +470,193 @@ export abstract class BaseQuoteMeltHandler implements Melt return this.handleMeltResponse(ctx, res, proofsToMelt); } + /** + * Execute exactly one authorized remote step for a parent-owned melt child. + * + * No repository services are present in this context. A pre-swap result must be applied and + * durably checkpointed before a later call is allowed to dispatch the melt. + */ + async executeOwnedRemote(ctx: OwnedMeltRemoteContext): Promise> { + const { operation } = ctx; + if (operation.parentExecutionPhase === 'pre_swap_authorized') { + if (!operation.needsSwap || !operation.swapOutputData) { + throw new Error(`Melt child ${operation.id} has an invalid pre-swap authorization`); + } + const swapData = deserializeOutputData(operation.swapOutputData); + const sendAmount = OutputData.sumOutputAmounts(swapData.send); + const outputConfig: OutputConfig = { + send: { type: 'custom', data: swapData.send }, + keep: { type: 'custom', data: swapData.keep }, + }; + const { send, keep } = await ctx.wallet.send(sendAmount, ctx.proofs, undefined, outputConfig); + return { + operationId: operation.id, + phase: 'pre_swap', + observedAt: Date.now(), + sendProofs: send, + keepProofs: keep, + }; + } + + if (operation.parentExecutionPhase !== 'melt_authorized') { + throw new Error(`Melt child ${operation.id} has no authorized remote step`); + } + const changeOutputData = deserializeOutputData(operation.changeOutputData); + const response = await this.executeMelt( + ctx, + ctx.proofs, + changeOutputData.keep, + operation.quoteId, + ); + return { operationId: operation.id, phase: 'melt', observedAt: Date.now(), response }; + } + + /** Apply one remote result using transaction-scoped proof services supplied by the parent. */ + async applyOwnedRemote( + ctx: ApplyOwnedMeltRemoteContext, + result: OwnedMeltRemoteResult, + ): Promise | (ExecutingMeltOperation & MeltMethodMeta)> { + const { operation } = ctx; + if (result.operationId !== operation.id) { + throw new Error(`Melt result operation ${result.operationId} does not match ${operation.id}`); + } + + if (result.phase === 'pre_swap') { + if (operation.parentExecutionPhase !== 'pre_swap_authorized' || !operation.swapOutputData) { + throw new Error(`Melt child ${operation.id} is not awaiting a pre-swap result`); + } + const expected = getSecretsFromSerializedOutputData(operation.swapOutputData); + assertProofsMatchSerializedOutputs( + result.sendProofs, + operation.swapOutputData.send, + 'Melt pre-swap send', + ); + assertProofsMatchSerializedOutputs( + result.keepProofs, + operation.swapOutputData.keep, + 'Melt pre-swap keep', + ); + + await ctx.proofService.setProofState(operation.mintUrl, operation.inputProofSecrets, 'spent'); + const newProofs = [ + ...mapProofToCoreProof(operation.mintUrl, 'ready', result.keepProofs, { + unit: operation.unit, + createdByOperationId: operation.id, + }), + ...mapProofToCoreProof(operation.mintUrl, 'inflight', result.sendProofs, { + unit: operation.unit, + createdByOperationId: operation.id, + }), + ]; + const expectedSecrets = [...expected.keepSecrets, ...expected.sendSecrets]; + const existing = await ctx.proofRepository.getProofsBySecrets( + operation.mintUrl, + expectedSecrets, + ); + if (existing.length === 0) { + await ctx.proofService.saveProofs(operation.mintUrl, newProofs); + } else if (existing.length !== expectedSecrets.length) { + throw new Error(`Melt child ${operation.id} has a partial pre-swap output set`); + } else if ( + existing.some((proof) => { + const expectedState = expected.keepSecrets.includes(proof.secret) ? 'ready' : 'inflight'; + return ( + proof.createdByOperationId !== operation.id || + proof.state !== expectedState || + proof.unit !== operation.unit || + ![...result.keepProofs, ...result.sendProofs].some( + (remote) => + remote.secret === proof.secret && + remote.id === proof.id && + Amount.from(remote.amount).equals(proof.amount) && + remote.C === proof.C, + ) + ); + }) + ) { + throw new Error(`Melt child ${operation.id} has an invalid pre-swap output set`); + } + return { ...operation, parentExecutionPhase: 'melt_authorized' }; + } + + if (operation.parentExecutionPhase !== 'melt_authorized') { + throw new Error(`Melt child ${operation.id} is not awaiting a melt result`); + } + const proofsToMelt = operation.needsSwap + ? getSecretsFromSerializedOutputData(operation.swapOutputData!).sendSecrets + : operation.inputProofSecrets; + + switch (result.response.state) { + case 'PAID': { + const meltInputAmount = this.getMeltInputAmount(operation); + const { changeAmount, effectiveFee } = this.calculateSettlementAmounts( + meltInputAmount, + operation.amount, + result.response.change, + ); + const changeSignatures = result.response.change ?? []; + const changeProofs = result.changeProofs ?? []; + assertProofsMatchSerializedOutputs( + changeProofs, + operation.changeOutputData.keep.slice(0, changeSignatures.length), + `Melt child ${operation.id} change`, + ); + if (!sumProofs(changeProofs).equals(changeAmount)) { + throw new Error(`Melt child ${operation.id} change proofs do not reconcile`); + } + const meltInputSecrets = this.getMeltInputSecrets(operation); + await ctx.proofService.setProofState(operation.mintUrl, meltInputSecrets, 'spent'); + if (changeProofs.length > 0) { + const expectedSecrets = changeProofs.map(({ secret }) => secret); + const existing = await ctx.proofRepository.getProofsBySecrets( + operation.mintUrl, + expectedSecrets, + ); + if (existing.length === 0) { + await ctx.proofService.saveProofs( + operation.mintUrl, + mapProofToCoreProof(operation.mintUrl, 'ready', changeProofs, { + unit: operation.unit, + createdByOperationId: operation.id, + }), + ); + } else if ( + existing.length !== changeProofs.length || + existing.some( + (proof) => + proof.createdByOperationId !== operation.id || + proof.state !== 'ready' || + proof.unit !== operation.unit || + !changeProofs.some( + (remote) => + remote.secret === proof.secret && + remote.id === proof.id && + Amount.from(remote.amount).equals(proof.amount) && + remote.C === proof.C, + ), + ) + ) { + throw new Error(`Melt child ${operation.id} has an invalid change proof set`); + } + } + return buildPaidResult(operation, { + changeAmount, + effectiveFee, + finalizedData: this.buildFinalizedData(result.response), + }); + } + case 'PENDING': + return buildPendingResult(operation); + case 'UNPAID': + await ctx.proofService.restoreProofsToReady(operation.mintUrl, proofsToMelt); + return buildFailedResult(operation); + default: + throw new Error( + `Unexpected melt response state ${String(result.response.state)} for ${operation.id}`, + ); + } + } + /** * Handle the melt response and return the appropriate execution result. */ @@ -640,7 +831,14 @@ export abstract class BaseQuoteMeltHandler implements Melt * Called immediately when melt returns PAID, or later when a pending melt succeeds. */ private async finalizeOperation( - ctx: ExecuteContext | FinalizeContext | RecoverExecutingContext, + ctx: { + operation: + | ExecuteContext['operation'] + | FinalizeContext['operation'] + | RecoverExecutingContext['operation']; + proofService: ExecuteContext['proofService']; + logger?: Logger; + }, change?: SerializedBlindedSignature[], ): Promise { const { diff --git a/packages/core/infra/handlers/mint/MintBolt11Handler.ts b/packages/core/infra/handlers/mint/MintBolt11Handler.ts index d6d501785..7dc3a3fe1 100644 --- a/packages/core/infra/handlers/mint/MintBolt11Handler.ts +++ b/packages/core/infra/handlers/mint/MintBolt11Handler.ts @@ -8,6 +8,7 @@ import type { PrepareContext, MintMethodHandler, MintExecutionResult, + OwnedMintRemoteContext, PendingMintOperation, RecoverExecutingResult, RecoverExecutingContext, @@ -120,6 +121,14 @@ export class MintBolt11Handler implements MintMethodHandler<'bolt11'> { } async execute(ctx: ExecuteContext<'bolt11'>): Promise { + return this.executeRemote(ctx); + } + + async executeOwnedRemote(ctx: OwnedMintRemoteContext<'bolt11'>): Promise { + return this.executeRemote(ctx); + } + + private async executeRemote(ctx: OwnedMintRemoteContext<'bolt11'>): Promise { const outputData = deserializeOutputData(ctx.operation.outputData); const signingOptions = await this.getMintQuoteSigningOptions(ctx.operation.pubkey); diff --git a/packages/core/models/Error.ts b/packages/core/models/Error.ts index a1cea0dad..ccba08956 100644 --- a/packages/core/models/Error.ts +++ b/packages/core/models/Error.ts @@ -113,6 +113,21 @@ export class OperationInProgressError extends Error { } } +/** Raised when a parent-owned child saga is advanced outside its owning 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/OperationEventOutbox.ts b/packages/core/models/OperationEventOutbox.ts new file mode 100644 index 000000000..30e36fc35 --- /dev/null +++ b/packages/core/models/OperationEventOutbox.ts @@ -0,0 +1,192 @@ +import { Amount } from '@cashu/cashu-ts'; + +import { + type MintSwapEventType, + type MintSwapOperationState, +} from '../operations/mintSwap/MintSwapOperation'; +import { normalizeMintUrl } from '../utils'; + +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; +} + +const EVENT_STATE: Partial> = { + 'mint-swap-op:prepared': 'prepared', + 'mint-swap-op:source-inflight': 'source_inflight', + 'mint-swap-op:destination-funded': 'destination_funded', + 'mint-swap-op:issuing': 'issuing', + 'mint-swap-op:completed': 'completed', + 'mint-swap-op:cancelled': 'cancelled', + 'mint-swap-op:failed': 'failed', + 'mint-swap-op:needs-attention': 'needs_attention', +}; +const EVENT_TYPES = new Set([ + ...(Object.keys(EVENT_STATE) as MintSwapEventType[]), + 'mint-swap-op:delayed', +]); +const OPERATION_STATES = new Set([ + 'preparing', + 'prepared', + 'source_inflight', + 'destination_funded', + 'issuing', + 'completed', + 'cancelled', + 'failed', + 'needs_attention', +]); +const DELAYED_STATES = new Set([ + 'preparing', + 'source_inflight', + 'destination_funded', + 'issuing', +]); +const REASON_REQUIRED_EVENTS = new Set([ + 'mint-swap-op:failed', + 'mint-swap-op:needs-attention', + 'mint-swap-op:delayed', +]); + +export function operationEventLogicalKey( + record: Pick, +): string { + return `${record.operationId}\u0000${record.revision}\u0000${record.eventType}`; +} + +export function isOperationEventPublished( + record: Pick, +): boolean { + return record.publishedAt !== undefined; +} + +export function isOperationEventDue( + record: Pick, + now: number, +): boolean { + assertTimestamp(now, 'Outbox due check time'); + return !isOperationEventPublished(record) && (record.nextAttemptAt ?? 0) <= now; +} + +export function validateOperationEventOutboxRecord( + record: OperationEventOutboxRecord, +): OperationEventOutboxRecord { + assertNonEmpty(record.id, 'Outbox id'); + assertNonEmpty(record.operationId, 'Outbox operation id'); + assertSafeInteger(record.revision, 'Outbox revision'); + assertTimestamp(record.createdAt, 'Outbox createdAt'); + assertSafeInteger(record.publishAttempts, 'Outbox publish attempts'); + if (!EVENT_TYPES.has(record.eventType)) { + throw new Error(`Unknown operation outbox event type: ${String(record.eventType)}`); + } + if (!OPERATION_STATES.has(record.payload.state)) { + throw new Error(`Unknown mint swap event state: ${String(record.payload.state)}`); + } + + if ( + record.payload.operationId !== record.operationId || + record.payload.revision !== record.revision + ) { + throw new Error('Outbox payload identity must match its logical event key'); + } + + const expectedState = EVENT_STATE[record.eventType]; + if (expectedState !== undefined && record.payload.state !== expectedState) { + throw new Error(`Outbox ${record.eventType} payload must contain state ${expectedState}`); + } + if (record.eventType === 'mint-swap-op:delayed' && !DELAYED_STATES.has(record.payload.state)) { + throw new Error('Delayed mint swap events require an automatic operation state'); + } + + if (record.payload.unit !== 'sat') throw new Error('Outbox mint swap unit must be sat'); + const destinationAmount = Amount.from(record.payload.destinationAmount); + if ( + destinationAmount.isZero() || + destinationAmount.toString().startsWith('-') || + destinationAmount.toString() !== record.payload.destinationAmount + ) { + throw new Error('Outbox destination amount must be a positive canonical decimal string'); + } + + const sourceMintUrl = normalizeMintUrl(record.payload.sourceMintUrl); + const destinationMintUrl = normalizeMintUrl(record.payload.destinationMintUrl); + if ( + record.payload.sourceMintUrl !== sourceMintUrl || + record.payload.destinationMintUrl !== destinationMintUrl + ) { + throw new Error('Outbox mint URLs must be normalized'); + } + if (sourceMintUrl === destinationMintUrl) { + throw new Error('Outbox source and destination mints must be distinct'); + } + + if (record.payload.reasonCode !== undefined) { + assertNonEmpty(record.payload.reasonCode, 'Outbox reason code'); + } + if (REASON_REQUIRED_EVENTS.has(record.eventType) && record.payload.reasonCode === undefined) { + throw new Error(`Outbox ${record.eventType} requires a reason code`); + } + if (record.lastError !== undefined) assertNonEmpty(record.lastError, 'Outbox last error'); + + if (record.nextAttemptAt !== undefined) { + assertTimestamp(record.nextAttemptAt, 'Outbox nextAttemptAt'); + if (record.nextAttemptAt < record.createdAt) { + throw new Error('Outbox nextAttemptAt cannot precede createdAt'); + } + } + if (record.publishedAt !== undefined) { + assertTimestamp(record.publishedAt, 'Outbox publishedAt'); + if (record.publishedAt < record.createdAt) { + throw new Error('Outbox publishedAt cannot precede createdAt'); + } + if (record.nextAttemptAt !== undefined || record.lastError !== undefined) { + throw new Error('Published outbox records cannot retain retry scheduling'); + } + if (record.publishAttempts === 0) { + throw new Error('Published outbox records must record at least one publish attempt'); + } + } else if (record.publishAttempts === 0) { + if (record.nextAttemptAt !== undefined || record.lastError !== undefined) { + throw new Error('An unattempted outbox record cannot contain retry scheduling'); + } + } else if (record.nextAttemptAt === undefined || record.lastError === undefined) { + throw new Error('A failed outbox publication requires retry time and error evidence'); + } + + return record; +} + +function assertTimestamp(value: number, name: string): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${name} must be a non-negative Unix-millisecond timestamp`); + } +} + +function assertSafeInteger(value: number, name: string): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${name} must be a non-negative safe integer`); + } +} + +function assertNonEmpty(value: string, name: string): void { + if (!value.trim()) throw new Error(`${name} cannot be empty`); +} diff --git a/packages/core/operations/melt/MeltMethodHandler.ts b/packages/core/operations/melt/MeltMethodHandler.ts index 62a28306d..8da48efa8 100644 --- a/packages/core/operations/melt/MeltMethodHandler.ts +++ b/packages/core/operations/melt/MeltMethodHandler.ts @@ -7,6 +7,7 @@ import { type MeltQuoteOnchainResponse, type Wallet, type Proof, + type SerializedBlindedSignature, } from '@cashu/cashu-ts'; import type { ProofRepository } from '../../repositories'; import type { ProofService } from '../../services/ProofService'; @@ -142,6 +143,52 @@ export interface ExecuteContext extends BaseH reservedProofs: Proof[]; } +/** + * Minimal context for one authorized remote source effect. + * + * Repository and proof services are deliberately absent so remote commands cannot perform local + * writes before their result is applied in a composing transaction. + */ +export interface OwnedMeltRemoteContext { + operation: ExecutingMeltOperation & MeltMethodMeta; + wallet: Wallet; + mintAdapter: MintAdapter; + proofs: Proof[]; + logger?: Logger; +} + +export type OwnedMeltRemoteResult = + | { + operationId: string; + phase: 'pre_swap'; + observedAt?: number; + sendProofs: Proof[]; + keepProofs: Proof[]; + } + | { + operationId: string; + phase: 'melt'; + observedAt?: number; + /** Fully unblinded outside the transaction; persisted only while applying the result. */ + changeProofs?: Proof[]; + response: { + state: MeltMethodRemoteState; + change?: SerializedBlindedSignature[]; + payment_preimage?: string | null; + outpoint?: string | null; + }; + }; + +export interface ApplyOwnedMeltRemoteContext { + operation: ExecutingMeltOperation & MeltMethodMeta; + proofRepository: ProofRepository; + proofService: Pick< + ProofService, + 'setProofState' | 'restoreProofsToReady' | 'saveProofs' | 'releaseProofs' + >; + logger?: Logger; +} + export interface PendingContext extends BaseHandlerDeps { operation: PendingMeltOperation & MeltMethodMeta; wallet: Wallet; @@ -201,6 +248,11 @@ export interface MeltMethodHandler { fetchRemoteQuote(ctx: FetchRemoteMeltQuoteContext): Promise>; prepare(ctx: BasePrepareContext): Promise>; execute(ctx: ExecuteContext): Promise>; + executeOwnedRemote?(ctx: OwnedMeltRemoteContext): Promise>; + applyOwnedRemote?( + ctx: ApplyOwnedMeltRemoteContext, + result: OwnedMeltRemoteResult, + ): Promise | (ExecutingMeltOperation & MeltMethodMeta)>; finalize?(ctx: FinalizeContext): Promise>; rollback?(ctx: RollbackContext): Promise; checkPending?(ctx: PendingContext): Promise; diff --git a/packages/core/operations/melt/MeltOperation.ts b/packages/core/operations/melt/MeltOperation.ts index e76107051..44027827b 100644 --- a/packages/core/operations/melt/MeltOperation.ts +++ b/packages/core/operations/melt/MeltOperation.ts @@ -58,6 +58,16 @@ interface MeltOperationBase extends MeltMethodMeta { /** Error message if the operation failed */ error?: string; + + /** Owning parent swap. Parent-owned children may only be advanced by that coordinator. */ + parentSwapOperationId?: string; + + /** + * Durable authorization checkpoint for a parent-owned remote source step. + * + * A pre-swap response must be applied locally before this advances to `melt_authorized`. + */ + parentExecutionPhase?: 'pre_swap_authorized' | 'melt_authorized'; } /** @@ -306,7 +316,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 +326,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..b65b6d419 100644 --- a/packages/core/operations/melt/MeltOperationService.ts +++ b/packages/core/operations/melt/MeltOperationService.ts @@ -1,10 +1,17 @@ -import type { MeltOperationRepository, ProofRepository } from '../../repositories'; +import { Amount, type SerializedBlindedSignature, type Wallet } from '@cashu/cashu-ts'; +import type { + MeltOperationRepository, + ProofRepository, + RepositoryTransactionScope, +} from '../../repositories'; +import { requireMintSwapRepositoryCapability } from '../../repositories'; import type { MeltOperation, InitMeltOperation, PreparedMeltOperation, ExecutingMeltOperation, PendingMeltOperation, + FailedMeltOperation, FinalizedMeltOperation, RollingBackMeltOperation, RolledBackMeltOperation, @@ -16,6 +23,7 @@ import type { MeltMethodData, MeltMethodInputData, PendingCheckResult, + OwnedMeltRemoteResult, } from './MeltMethodHandler'; import { normalizeMeltMethodData } from './MeltMethodHandler'; import type { MintService } from '../../services/MintService'; @@ -24,7 +32,13 @@ import type { ProofService } from '../../services/ProofService'; import type { EventBus } from '../../events/EventBus'; import type { CoreEvents } from '../../events/types'; import type { Logger } from '../../logging/Logger'; -import { generateSubId, normalizeMintUrl } from '../../utils'; +import { + assertProofsMatchSerializedOutputs, + deserializeOutputData, + generateSubId, + getSecretsFromSerializedOutputData, + normalizeMintUrl, +} from '../../utils'; import { UnknownMintError, ProofValidationError } from '../../models/Error'; import type { MintAdapter } from '@core/infra'; import type { MeltHandlerProvider } from '../../infra/handlers/melt'; @@ -33,8 +47,30 @@ import { MintScopedLock } from '../MintScopedLock'; import { OperationIdLock } from '../OperationIdLock'; import { DEFAULT_UNIT, normalizeUnit } from '../../amounts.ts'; import type { QuoteLifecycle } from '../../quotes/QuoteLifecycle'; -import { resolveOnchainMeltFeeOption, type MeltQuote } from '../../models/MeltQuote.ts'; +import { + meltQuoteToMethodSnapshot, + resolveOnchainMeltFeeOption, + type MeltQuote, +} from '../../models/MeltQuote.ts'; import type { MeltQuoteRef, QuoteIdentity } from '../../models/QuoteIdentity.ts'; +import { resolveAndPersistMeltQuoteObservation } from '../../quotes/QuoteLifecycle.ts'; +import { + assertChildOperationAccess, + assertParentOwnedMeltOperationInvariant, +} from '../mintSwap/ChildOperationOwnership.ts'; + +export interface PrepareOwnedMeltOperationCommand { + operationId: string; + parentSwapOperationId: string; + quote: MeltQuote; + preparedOperation: PreparedMeltOperation; + repositories: RepositoryTransactionScope; +} + +export type PlanOwnedMeltOperationCommand = Omit< + PrepareOwnedMeltOperationCommand, + 'preparedOperation' | 'repositories' +> & { wallet: Wallet }; /** * MeltOperationService orchestrates melt sagas while delegating @@ -82,10 +118,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 +310,397 @@ export class MeltOperationService { } } + /** Build deterministic source work outside the parent transaction without reserving proofs. */ + async planOwnedPreparation( + command: PlanOwnedMeltOperationCommand, + ): Promise { + const { quote, operationId, parentSwapOperationId, wallet } = command; + if (quote.method !== 'bolt11' || quote.unit !== 'sat') { + throw new Error('Mint swaps require a sat-denominated BOLT11 source quote'); + } + await this.mintService.assertMethodUnitSupported(quote.mintUrl, 5, 'bolt11', quote.unit); + const initOperation = createMeltOperation( + operationId, + quote.mintUrl, + { method: 'bolt11', methodData: this.methodDataFromMeltQuote(quote) }, + quote.unit, + { quoteId: quote.quoteId, parentSwapOperationId }, + ); + const planningProofService = { + selectProofsToSend: this.proofService.selectProofsToSend.bind(this.proofService), + createBlankOutputs: this.proofService.createBlankOutputs.bind(this.proofService), + createOutputsAndIncrementCounters: this.proofService.createOutputsAndIncrementCounters.bind( + this.proofService, + ), + reserveProofs: async () => ({ amount: Amount.zero(), unit: quote.unit }), + }; + const prepared = await this.handlerProvider.get('bolt11').prepare({ + ...this.buildDeps(), + proofService: planningProofService as never, + operation: initOperation as never, + wallet, + quote: meltQuoteToMethodSnapshot(quote as MeltQuote<'bolt11'>), + }); + const preparedOperation: PreparedMeltOperation = { + ...prepared, + id: operationId, + parentSwapOperationId, + state: 'prepared', + updatedAt: Date.now(), + }; + return preparedOperation; + } + + /** Reserve the preflighted inputs and persist the source child in one local transaction. */ + async prepareOwnedInTransaction( + command: PrepareOwnedMeltOperationCommand, + ): Promise { + const { quote, operationId, parentSwapOperationId, repositories, preparedOperation } = command; + requireMintSwapRepositoryCapability(repositories); + if ( + quote.method !== 'bolt11' || + quote.unit !== 'sat' || + preparedOperation.id !== operationId || + preparedOperation.parentSwapOperationId !== parentSwapOperationId || + preparedOperation.mintUrl !== quote.mintUrl || + preparedOperation.method !== 'bolt11' || + preparedOperation.quoteId !== quote.quoteId || + preparedOperation.unit !== quote.unit || + !preparedOperation.amount.equals(quote.amount) || + !preparedOperation.fee_reserve.equals(quote.fee_reserve) || + !('invoice' in preparedOperation.methodData) || + preparedOperation.methodData.invoice !== quote.request + ) { + throw new Error('Preflighted source child does not match its owned command'); + } + assertParentOwnedMeltOperationInvariant(preparedOperation); + const inputs = await repositories.proofRepository.getProofsBySecrets( + preparedOperation.mintUrl, + preparedOperation.inputProofSecrets, + ); + if ( + inputs.length !== preparedOperation.inputProofSecrets.length || + inputs.some( + (proof) => + proof.state !== 'ready' || + proof.usedByOperationId !== undefined || + proof.unit !== preparedOperation.unit, + ) + ) { + throw new Error(`Melt child ${operationId} cannot reserve its preflighted input set`); + } + const inputAmount = inputs.reduce((total, proof) => total.add(proof.amount), Amount.zero()); + if (!inputAmount.equals(preparedOperation.inputAmount)) { + throw new Error(`Melt child ${operationId} preflighted input amount does not reconcile`); + } + const scopedProofService = this.proofService.forTransaction(repositories); + await scopedProofService.reserveProofs( + preparedOperation.mintUrl, + preparedOperation.inputProofSecrets, + operationId, + { unit: preparedOperation.unit }, + ); + await repositories.meltOperationRepository.create(preparedOperation); + return preparedOperation; + } + + /** + * Persist source execution authorization and mark its original inputs inflight. + * + * For swap-then-melt plans this authorizes only the pre-swap. Applying that result creates the + * separate durable `melt_authorized` checkpoint. + */ + async authorizeOwnedExecutionInTransaction( + operationId: string, + parentSwapOperationId: string, + repositories: RepositoryTransactionScope, + ): Promise { + requireMintSwapRepositoryCapability(repositories); + 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 inputs = await repositories.proofRepository.getProofsBySecrets( + operation.mintUrl, + operation.inputProofSecrets, + ); + if ( + inputs.length !== operation.inputProofSecrets.length || + inputs.some( + (proof) => + proof.usedByOperationId !== operation.id || + proof.state !== 'ready' || + proof.unit !== operation.unit, + ) + ) { + throw new Error(`Melt child ${operation.id} does not own its complete reserved input set`); + } + const scopedProofService = this.proofService.forTransaction(repositories); + await scopedProofService.setProofState( + operation.mintUrl, + operation.inputProofSecrets, + 'inflight', + ); + const executing: ExecutingMeltOperation = { + ...operation, + state: 'executing', + parentExecutionPhase: operation.needsSwap ? 'pre_swap_authorized' : 'melt_authorized', + updatedAt: Date.now(), + }; + assertParentOwnedMeltOperationInvariant(executing); + await repositories.meltOperationRepository.update(executing); + return executing; + } + + /** + * Perform exactly one authorized remote source effect. + * + * This command receives no transaction scope and performs no repository writes. + */ + async executeOwnedRemoteStep( + operationOrId: string | ExecutingMeltOperation, + parentSwapOperationId: string, + ): Promise { + const operationId = typeof operationOrId === 'string' ? operationOrId : operationOrId.id; + const operation = await this.meltOperationRepository.getById(operationId); + if (!operation || operation.state !== 'executing') { + throw new Error( + `Cannot execute melt child ${operationId} from ${operation?.state ?? 'missing'}`, + ); + } + assertChildOperationAccess(operation, parentSwapOperationId); + assertParentOwnedMeltOperationInvariant(operation); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId( + operation.mintUrl, + operation.unit, + ); + const proofSecrets = + operation.parentExecutionPhase === 'pre_swap_authorized' + ? operation.inputProofSecrets + : operation.needsSwap + ? getSecretsFromSerializedOutputData(operation.swapOutputData!).sendSecrets + : operation.inputProofSecrets; + const proofs = await this.proofRepository.getProofsBySecrets(operation.mintUrl, proofSecrets); + const invalidProof = proofs.some((proof) => { + if (proof.state !== 'inflight' || proof.unit !== operation.unit) return true; + return operation.parentExecutionPhase === 'pre_swap_authorized' || !operation.needsSwap + ? proof.usedByOperationId !== operation.id + : proof.createdByOperationId !== operation.id; + }); + if (proofs.length !== proofSecrets.length || invalidProof) { + throw new Error(`Authorized melt step ${operation.id} does not own valid inflight proofs`); + } + const handler = this.handlerProvider.get(operation.method); + if (!handler.executeOwnedRemote) { + throw new Error(`Melt method ${operation.method} does not support owned remote execution`); + } + const handlerResult = await handler.executeOwnedRemote({ + operation: operation as never, + wallet, + mintAdapter: this.mintAdapter, + proofs, + logger: this.logger, + }); + const expectedPhase = + operation.parentExecutionPhase === 'pre_swap_authorized' ? 'pre_swap' : 'melt'; + if (handlerResult.operationId !== operation.id || handlerResult.phase !== expectedPhase) { + throw new Error(`Melt handler returned a result for the wrong owned operation phase`); + } + const result = { ...handlerResult, observedAt: handlerResult.observedAt ?? Date.now() }; + if ( + result.phase === 'melt' && + result.response.state === 'PAID' && + result.response.change?.length + ) { + const changeOutputData = deserializeOutputData(operation.changeOutputData).keep; + const changeProofs = await this.proofService.unblindChangeProofs( + operation.mintUrl, + changeOutputData, + result.response.change, + { unit: operation.unit, createdByOperationId: operation.id }, + ); + return { ...result, changeProofs }; + } + return result; + } + + /** Apply one remote source result atomically with the composing parent transition. */ + async applyOwnedRemoteStepInTransaction( + operationOrId: string | ExecutingMeltOperation, + parentSwapOperationId: string, + result: OwnedMeltRemoteResult, + repositories: RepositoryTransactionScope, + ): Promise< + ExecutingMeltOperation | PendingMeltOperation | FinalizedMeltOperation | FailedMeltOperation + > { + const operationId = typeof operationOrId === 'string' ? operationOrId : operationOrId.id; + requireMintSwapRepositoryCapability(repositories); + if (result.operationId !== operationId) { + throw new Error(`Melt result operation ${result.operationId} does not match ${operationId}`); + } + const current = await repositories.meltOperationRepository.getById(operationId); + if (!current || current.state !== 'executing') { + throw new Error(`Cannot apply melt child ${operationId} from ${current?.state ?? 'missing'}`); + } + assertChildOperationAccess(current, parentSwapOperationId); + const expectedResultPhase = + current.parentExecutionPhase === 'pre_swap_authorized' ? 'pre_swap' : 'melt'; + if (result.phase !== expectedResultPhase) { + throw new Error(`Melt child ${operationId} advanced before its remote result was applied`); + } + const observedAt = result.observedAt; + if (observedAt === undefined || !Number.isSafeInteger(observedAt) || observedAt < 0) { + throw new Error(`Melt result for ${operationId} has an invalid observation time`); + } + + let canonicalResult = result; + if (result.phase === 'melt') { + const quote = await repositories.meltQuoteRepository.getMeltQuote( + current.mintUrl, + current.method, + current.quoteId, + ); + if (!quote || quote.method !== 'bolt11') { + throw new Error(`Canonical melt quote for child ${current.id} was not found`); + } + if (!quote.amount.equals(current.amount) || quote.unit !== current.unit) { + throw new Error(`Canonical melt quote does not match child ${current.id}`); + } + const observation: MeltQuote<'bolt11'> = { + ...quote, + state: result.response.state as 'PAID' | 'PENDING' | 'UNPAID', + change: result.response.change, + payment_preimage: result.response.payment_preimage, + lastObservedRemoteState: result.response.state as 'PAID' | 'PENDING' | 'UNPAID', + lastObservedRemoteStateAt: observedAt, + updatedAt: Math.max(quote.updatedAt, observedAt), + }; + const { quote: canonicalQuote } = await resolveAndPersistMeltQuoteObservation( + repositories.meltQuoteRepository, + observation, + ); + if (canonicalQuote.state !== result.response.state) { + throw new Error( + `Melt result ${result.response.state} conflicts with canonical ${canonicalQuote.state}`, + ); + } + if ( + canonicalQuote.state === 'PAID' && + result.response.state === 'PAID' && + Array.isArray(canonicalQuote.change) && + Array.isArray(result.response.change) && + meltChangeKey(canonicalQuote.change) !== meltChangeKey(result.response.change) + ) { + throw new Error(`Melt result settlement conflicts with canonical quote ${current.quoteId}`); + } + if ( + canonicalQuote.method !== 'onchain' && + canonicalQuote.payment_preimage != null && + result.response.payment_preimage != null && + canonicalQuote.payment_preimage !== result.response.payment_preimage + ) { + throw new Error(`Melt result preimage conflicts with canonical quote ${current.quoteId}`); + } + canonicalResult = { + ...result, + response: { + ...result.response, + state: canonicalQuote.state, + change: canonicalQuote.change, + payment_preimage: + canonicalQuote.method === 'onchain' ? undefined : canonicalQuote.payment_preimage, + }, + } as OwnedMeltRemoteResult; + } + if (canonicalResult.phase === 'pre_swap') { + assertProofsMatchSerializedOutputs( + canonicalResult.sendProofs, + current.swapOutputData!.send, + `Melt child ${current.id} pre-swap send`, + ); + assertProofsMatchSerializedOutputs( + canonicalResult.keepProofs, + current.swapOutputData!.keep, + `Melt child ${current.id} pre-swap keep`, + ); + } else if (canonicalResult.response.state === 'PAID') { + assertProofsMatchSerializedOutputs( + canonicalResult.changeProofs ?? [], + current.changeOutputData.keep.slice(0, canonicalResult.response.change?.length ?? 0), + `Melt child ${current.id} change`, + ); + } else if (canonicalResult.changeProofs?.length) { + throw new Error(`Non-paid melt result for ${current.id} cannot contain change proofs`); + } + const handler = this.handlerProvider.get(current.method); + if (!handler.applyOwnedRemote) { + throw new Error(`Melt method ${current.method} does not support owned result application`); + } + const scopedProofService = this.proofService.forTransaction(repositories); + const ownedProofService = { + setProofState: scopedProofService.setProofState.bind(scopedProofService), + restoreProofsToReady: scopedProofService.restoreProofsToReady.bind(scopedProofService), + saveProofs: scopedProofService.saveProofs.bind(scopedProofService), + releaseProofs: scopedProofService.releaseProofs.bind(scopedProofService), + }; + const applied = await handler.applyOwnedRemote( + { + proofRepository: repositories.proofRepository, + proofService: ownedProofService, + logger: this.logger, + operation: current as never, + }, + canonicalResult as never, + ); + const next = + 'status' in applied + ? applied.status === 'PAID' + ? applied.finalized + : applied.status === 'PENDING' + ? applied.pending + : applied.failed + : applied; + assertChildOperationAccess(next, parentSwapOperationId); + assertParentOwnedMeltOperationInvariant(next); + await repositories.meltOperationRepository.update(next); + return next as + | ExecutingMeltOperation + | PendingMeltOperation + | FinalizedMeltOperation + | FailedMeltOperation; + } + + /** Roll back an undispatched parent-owned source child inside the parent transaction. */ + async rollbackOwnedPreparedInTransaction( + operationId: string, + parentSwapOperationId: string, + repositories: RepositoryTransactionScope, + reason = 'Parent mint swap cancelled', + ): Promise { + requireMintSwapRepositoryCapability(repositories); + 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.proofService + .forTransaction(repositories) + .releaseProofs(operation.mintUrl, operation.inputProofSecrets); + 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 +719,7 @@ export class MeltOperationService { }'`, ); } + assertChildOperationAccess(operation); const initOp = operation as InitMeltOperation; const releaseMintLock = await this.mintScopedLock.acquire(initOp.mintUrl); @@ -363,6 +794,7 @@ export class MeltOperationService { }'`, ); } + assertChildOperationAccess(operation); const preparedOp = operation as PreparedMeltOperation; @@ -463,6 +895,7 @@ export class MeltOperationService { if (!operation) { throw new Error(`Operation ${operationId} not found`); } + assertChildOperationAccess(operation); if (operation.state === 'finalized') { this.logger?.debug('Operation already finalized', { operationId }); const finalizedOp = operation as FinalizedMeltOperation; @@ -538,6 +971,7 @@ export class MeltOperationService { if (!operation) { throw new Error(`Operation ${operationId} not found`); } + assertChildOperationAccess(operation); if ( operation.state === 'finalized' || @@ -624,6 +1058,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 +1066,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 +1075,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 +1090,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 +1105,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 +1140,7 @@ export class MeltOperationService { }'`, ); } + assertChildOperationAccess(op); const persistedQuote = await this.quoteLifecycle.getMeltQuote( op.mintUrl, op.method, @@ -808,6 +1248,7 @@ export class MeltOperationService { op: ExecutingMeltOperation, options?: { skipLock?: boolean }, ): Promise { + assertChildOperationAccess(op); const releaseLock = options?.skipLock ? undefined : await this.acquireOperationLock(op.id); try { const current = await this.meltOperationRepository.getById(op.id); @@ -972,11 +1413,26 @@ export class MeltOperationService { } async getPendingOperations(): Promise { - return this.meltOperationRepository.getPending(); + const operations = await this.meltOperationRepository.getPending(); + return operations.filter((operation) => operation.parentSwapOperationId === undefined); } async getPreparedOperations(): Promise { const ops = await this.meltOperationRepository.getByState('prepared'); - return ops.filter((op): op is PreparedMeltOperation => op.state === 'prepared'); + return ops.filter( + (op): op is PreparedMeltOperation => + op.state === 'prepared' && op.parentSwapOperationId === undefined, + ); } } + +function meltChangeKey(change: readonly SerializedBlindedSignature[]): string { + return JSON.stringify( + change.map((signature) => ({ + amount: Amount.from(signature.amount).toString(), + id: signature.id, + C_: signature.C_, + dleq: signature.dleq, + })), + ); +} diff --git a/packages/core/operations/mint/MintMethodHandler.ts b/packages/core/operations/mint/MintMethodHandler.ts index 3ad19f012..da289388c 100644 --- a/packages/core/operations/mint/MintMethodHandler.ts +++ b/packages/core/operations/mint/MintMethodHandler.ts @@ -157,6 +157,12 @@ export interface ExecuteContext extends BaseH wallet: Wallet; } +/** Repository-free context for a parent-authorized remote mint request. */ +export type OwnedMintRemoteContext = Pick< + ExecuteContext, + 'operation' | 'wallet' | 'mintAdapter' | 'logger' +>; + export interface RecoverExecutingContext< M extends MintMethod = MintMethod, > extends BaseHandlerDeps { @@ -187,6 +193,8 @@ export type MintExecutionResult = error?: string; }; +export type OwnedMintExecutionResult = MintExecutionResult & { operationId: string }; + export type RecoverExecutingResult = | { status: 'FINALIZED' } | { status: 'TERMINAL'; error: string } @@ -227,6 +235,8 @@ export interface MintMethodHandler { validateQuoteForPrepare?(quote: MintQuote): Promise | void; prepare(ctx: PrepareContext): Promise>; execute(ctx: ExecuteContext): Promise; + /** Opt-in composition seam that cannot access repositories during remote I/O. */ + executeOwnedRemote?(ctx: OwnedMintRemoteContext): Promise; recoverExecuting(ctx: RecoverExecutingContext): Promise; checkPending(ctx: PendingContext): Promise>; } diff --git a/packages/core/operations/mint/MintOperation.ts b/packages/core/operations/mint/MintOperation.ts index 19c3285c4..de2804eb7 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 coordinator. */ + 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,9 @@ export function createMintOperation( amount: intent.amount, unit: normalizeUnit(intent.unit), quoteId: options.quoteId, + ...(options.parentSwapOperationId + ? { parentSwapOperationId: options.parentSwapOperationId } + : {}), id, state: 'init', mintUrl, diff --git a/packages/core/operations/mint/MintOperationService.ts b/packages/core/operations/mint/MintOperationService.ts index 67538f916..0e264695b 100644 --- a/packages/core/operations/mint/MintOperationService.ts +++ b/packages/core/operations/mint/MintOperationService.ts @@ -1,5 +1,10 @@ -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 { requireMintSwapRepositoryCapability } from '../../repositories'; import type { ExecutingMintOperation, FailedMintOperation, @@ -28,7 +33,12 @@ import type { ProofService } from '../../services/ProofService'; import type { EventBus } from '../../events/EventBus'; import type { CoreEvents } from '../../events/types'; import type { Logger } from '../../logging/Logger'; -import { generateSubId, mapProofToCoreProof, normalizeMintUrl } from '../../utils'; +import { + assertProofsMatchSerializedOutputs, + generateSubId, + mapProofToCoreProof, + normalizeMintUrl, +} from '../../utils'; import { OperationInProgressError, ProofValidationError, @@ -39,13 +49,38 @@ import type { MintAdapter } from '../../infra'; import type { MintHandlerProvider } from '../../infra/handlers/mint'; import { MintScopedLock } from '../MintScopedLock'; import { OperationIdLock } from '../OperationIdLock'; -import { getMintQuoteAmount, type MintQuote } from '../../models/MintQuote'; +import { + deriveBolt11MintQuoteState, + getMintQuoteAvailableAmount, + getMintQuoteAmount, + mintQuoteToMethodSnapshot, + type MintQuote, +} from '../../models/MintQuote'; import { assessMintQuoteClaimability, type MintQuoteClaimabilityAssessment, } from '../../models/MintQuoteClaimability.ts'; import type { MintQuoteRef } from '../../models/QuoteIdentity'; import type { QuoteLifecycle } from '../../quotes/QuoteLifecycle'; +import { + assertChildOperationAccess, + assertParentOwnedMintOperationInvariant, +} from '../mintSwap/ChildOperationOwnership.ts'; + +export interface PrepareOwnedMintOperationCommand { + operationId: string; + parentSwapOperationId: string; + quote: MintQuote; + amount: Amount; + destinationNut20PublicKey: string; + preparedOperation: PendingMintOperation; + repositories: RepositoryTransactionScope; +} + +export type PlanOwnedMintOperationCommand = Omit< + PrepareOwnedMintOperationCommand, + 'preparedOperation' | 'repositories' +> & { wallet: Wallet }; export interface ClaimMintQuoteOptions { autoClaimRemaining?: boolean; @@ -96,10 +131,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, @@ -246,6 +284,284 @@ export class MintOperationService { return this.prepareInitOperation(initOperation.id); } + /** + * Prepare and persist a parent-owned destination child using transaction-scoped local writes. + * + * @internal + */ + async planOwnedPreparation( + command: PlanOwnedMintOperationCommand, + ): Promise { + const { quote, parentSwapOperationId, operationId, wallet } = command; + if (quote.method !== 'bolt11') { + throw new Error('Mint swaps require a BOLT11 destination quote'); + } + if (quote.pubkey !== command.destinationNut20PublicKey) { + throw new Error('Destination quote is not locked to the parent NUT-20 key'); + } + const amount = Amount.from(command.amount); + const fixedAmount = getMintQuoteAmount(quote); + if (!fixedAmount?.equals(amount) || quote.unit !== 'sat') { + throw new Error('Destination quote does not match the mint swap intent'); + } + await this.mintService.assertMethodUnitSupported(quote.mintUrl, 4, 'bolt11', { + amount, + unit: quote.unit, + }); + const handler = this.handlerProvider.get('bolt11'); + await handler.validateQuoteForPrepare?.(quote as MintQuote<'bolt11'>); + + const initOperation = createMintOperation( + operationId, + quote.mintUrl, + { method: 'bolt11', methodData: {} }, + { amount, unit: quote.unit }, + { quoteId: quote.quoteId, parentSwapOperationId }, + ); + const pending = await handler.prepare({ + ...this.buildDeps(), + operation: initOperation, + wallet, + importedQuote: mintQuoteToMethodSnapshot<'bolt11'>(quote as MintQuote<'bolt11'>), + }); + const pendingOperation: PendingMintOperation = { + ...pending, + id: operationId, + parentSwapOperationId, + state: 'pending', + updatedAt: Date.now(), + }; + if (pendingOperation.pubkey !== command.destinationNut20PublicKey) { + throw new Error('Destination mint child lost its parent NUT-20 key binding'); + } + assertParentOwnedMintOperationInvariant(pendingOperation); + return pendingOperation; + } + + /** Persist a preflighted destination plan using transaction-scoped local writes only. */ + async prepareOwnedInTransaction( + command: PrepareOwnedMintOperationCommand, + ): Promise { + const { + quote, + repositories, + parentSwapOperationId, + operationId, + preparedOperation, + destinationNut20PublicKey, + } = command; + requireMintSwapRepositoryCapability(repositories); + if ( + quote.method !== 'bolt11' || + quote.unit !== 'sat' || + quote.pubkey !== destinationNut20PublicKey || + !quote.amount.equals(command.amount) + ) { + throw new Error('Destination quote does not match the mint swap intent'); + } + if ( + preparedOperation.id !== operationId || + preparedOperation.parentSwapOperationId !== parentSwapOperationId || + preparedOperation.mintUrl !== quote.mintUrl || + preparedOperation.method !== 'bolt11' || + preparedOperation.quoteId !== quote.quoteId || + preparedOperation.unit !== 'sat' || + !preparedOperation.amount.equals(command.amount) || + preparedOperation.pubkey !== destinationNut20PublicKey || + preparedOperation.request !== quote.request || + preparedOperation.expiry !== quote.expiry + ) { + throw new Error('Preflighted destination child does not match its owned command'); + } + assertParentOwnedMintOperationInvariant(preparedOperation); + await repositories.mintOperationRepository.create(preparedOperation); + return preparedOperation; + } + + /** Persist destination issuance authorization before the remote mint request. */ + async authorizeOwnedExecutionInTransaction( + operationId: string, + parentSwapOperationId: string, + repositories: RepositoryTransactionScope, + ): Promise { + requireMintSwapRepositoryCapability(repositories); + 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); + assertParentOwnedMintOperationInvariant(operation); + const executing: ExecutingMintOperation = { + ...operation, + state: 'executing', + updatedAt: Date.now(), + error: undefined, + }; + await repositories.mintOperationRepository.update(executing); + return executing; + } + + /** + * Perform the remote destination issuance after authorization has committed. + * + * This command receives no transaction scope and performs no repository writes. + */ + async executeOwnedRemote( + operationOrId: string | ExecutingMintOperation, + parentSwapOperationId: string, + ): Promise { + const operationId = typeof operationOrId === 'string' ? operationOrId : operationOrId.id; + const operation = await this.mintOperationRepository.getById(operationId); + if (!operation || operation.state !== 'executing') { + throw new Error( + `Cannot execute mint child ${operationId} from ${operation?.state ?? 'missing'}`, + ); + } + assertChildOperationAccess(operation, parentSwapOperationId); + assertParentOwnedMintOperationInvariant(operation); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId( + operation.mintUrl, + operation.unit, + ); + const handler = this.handlerProvider.get(operation.method); + if (!handler.executeOwnedRemote) { + throw new Error(`Mint method ${operation.method} does not support owned remote execution`); + } + const result = await handler.executeOwnedRemote({ + operation: operation as never, + wallet, + mintAdapter: this.mintAdapter, + logger: this.logger, + }); + if (result.status === 'ALREADY_ISSUED') { + const recovered = await this.proofService.recoverProofsFromOutputData( + operation.mintUrl, + operation.outputData, + { + unit: operation.unit, + createdByOperationId: operation.id, + persistRecoveredProofs: false, + }, + ); + if (recovered.length > 0) { + assertProofsMatchSerializedOutputs( + recovered, + [...operation.outputData.keep, ...operation.outputData.send], + `Recovered mint child ${operation.id}`, + ); + return { operationId: operation.id, status: 'ISSUED', proofs: recovered }; + } + } + return { ...result, operationId: operation.id }; + } + + /** Apply a remote issuance result atomically with the composing parent transition. */ + async applyOwnedExecutionInTransaction( + operationOrId: string | ExecutingMintOperation, + parentSwapOperationId: string, + result: import('./MintMethodHandler.ts').OwnedMintExecutionResult, + repositories: RepositoryTransactionScope, + ): Promise { + const operationId = typeof operationOrId === 'string' ? operationOrId : operationOrId.id; + requireMintSwapRepositoryCapability(repositories); + if (result.operationId !== operationId) { + throw new Error(`Mint result operation ${result.operationId} does not match ${operationId}`); + } + const current = await repositories.mintOperationRepository.getById(operationId); + if (!current || current.state !== 'executing') { + throw new Error(`Cannot apply mint child ${operationId} from ${current?.state ?? 'missing'}`); + } + assertChildOperationAccess(current, parentSwapOperationId); + assertParentOwnedMintOperationInvariant(current); + + if (result.status === 'FAILED') { + throw new Error(result.error ?? 'Mint execution failed'); + } + if (result.status === 'ALREADY_ISSUED') { + return current; + } + + assertProofsMatchSerializedOutputs( + result.proofs, + [...current.outputData.keep, ...current.outputData.send], + `Mint child ${current.id}`, + ); + const expectedSecrets = [...getOutputProofSecrets(current)].sort(); + + const scopedProofService = this.proofService.forTransaction(repositories); + const existing = await repositories.proofRepository.getProofsBySecrets( + current.mintUrl, + expectedSecrets, + ); + if (existing.length === 0) { + await scopedProofService.saveProofs( + current.mintUrl, + mapProofToCoreProof(current.mintUrl, 'ready', result.proofs, { + unit: current.unit, + createdByOperationId: current.id, + }), + ); + } else if (existing.length !== expectedSecrets.length) { + throw new Error(`Mint child ${current.id} has a partial deterministic output set`); + } else if ( + existing.some( + (proof) => + proof.createdByOperationId !== current.id || + proof.state !== 'ready' || + proof.unit !== current.unit || + !result.proofs.some( + (remote) => + remote.secret === proof.secret && + remote.id === proof.id && + Amount.from(remote.amount).equals(proof.amount) && + remote.C === proof.C, + ), + ) + ) { + throw new Error(`Mint child ${current.id} has an invalid deterministic output set`); + } + + if (current.method === 'bolt11') { + const quote = await repositories.mintQuoteRepository.getMintQuote( + current.mintUrl, + current.method, + current.quoteId, + ); + if (!quote || quote.method !== 'bolt11') { + throw new Error(`Canonical mint quote for child ${current.id} was not found`); + } + if (!quote.amount.equals(current.amount)) { + throw new Error(`Canonical mint quote amount does not match child ${current.id}`); + } + if (current.pubkey === undefined || quote.pubkey !== current.pubkey) { + throw new Error(`Canonical mint quote key does not match child ${current.id}`); + } + const amountPaid = quote.amountPaid.greaterThan(current.amount) + ? quote.amountPaid + : current.amount; + const amountIssued = quote.amountIssued.greaterThan(current.amount) + ? quote.amountIssued + : current.amount; + await repositories.mintQuoteRepository.upsertMintQuote({ + ...quote, + state: deriveBolt11MintQuoteState(amountPaid, amountIssued), + amountPaid, + amountIssued, + updatedAt: Math.max(quote.updatedAt, Date.now()), + }); + } + const finalized: FinalizedMintOperation = { + ...current, + state: 'finalized', + updatedAt: Date.now(), + error: undefined, + }; + await repositories.mintOperationRepository.update(finalized); + return finalized; + } + private async prepareInitOperation( operationId: string, options?: { @@ -339,6 +655,7 @@ export class MintOperationService { if (!operation) { throw new Error(`Operation ${operationId} not found`); } + assertChildOperationAccess(operation); if (isTerminalOperation(operation)) { return operation; @@ -403,6 +720,7 @@ export class MintOperationService { if (!(await this.mintService.isTrustedMint(operation.mintUrl))) { throw new UnknownMintError(`Mint ${operation.mintUrl} is not trusted`); } + assertChildOperationAccess(operation); const pendingOp = operation as PendingMintOperation; const executing: ExecutingMintOperation = { @@ -438,7 +756,6 @@ export class MintOperationService { } return await this.finalizeIssuedOperation(executing); case 'ALREADY_ISSUED': { - //CODEX: Where does recovery actually happen? const proofsRecovered = await this.ensureOutputsSaved(executing); const error = proofsRecovered ? undefined @@ -477,6 +794,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 }); @@ -523,6 +841,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++; @@ -542,6 +861,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); @@ -562,6 +882,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++; @@ -595,6 +916,7 @@ export class MintOperationService { op: ExecutingMintOperation, options?: { skipLock?: boolean }, ): Promise { + assertChildOperationAccess(op); const releaseLock = options?.skipLock ? undefined : await this.acquireOperationLock(op.id); try { const current = await this.mintOperationRepository.getById(op.id); @@ -776,7 +1098,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; } @@ -871,6 +1193,7 @@ export class MintOperationService { if (current) return current; throw new Error(`Operation ${operation.id} not found`); } + assertChildOperationAccess(current); const pending = current as PendingMintOperation; const quote = @@ -984,7 +1307,10 @@ export class MintOperationService { async getPendingOperations(): Promise { const ops = await this.mintOperationRepository.getByState('pending'); - return ops.filter((op): op is PendingMintOperation => op.state === 'pending'); + return ops.filter( + (op): op is PendingMintOperation => + op.state === 'pending' && op.parentSwapOperationId === undefined, + ); } private async tryRecoverInitOperation(op: InitMintOperation): Promise { @@ -1179,6 +1505,7 @@ export class MintOperationService { }'`, ); } + assertChildOperationAccess(op); const handler = this.handlerProvider.get(op.method); const observation = await handler.checkPending({ diff --git a/packages/core/operations/mintSwap/ChildOperationOwnership.ts b/packages/core/operations/mintSwap/ChildOperationOwnership.ts new file mode 100644 index 000000000..be018a505 --- /dev/null +++ b/packages/core/operations/mintSwap/ChildOperationOwnership.ts @@ -0,0 +1,325 @@ +import { Amount } from '@cashu/cashu-ts'; + +import { ParentOwnedOperationError } from '../../models/Error.ts'; +import { getSecretsFromSerializedOutputData } from '../../utils.ts'; +import type { MeltOperation } from '../melt/MeltOperation.ts'; +import type { MintOperation } from '../mint/MintOperation.ts'; + +export interface ParentOwnedChildOperation { + id: string; + parentSwapOperationId?: string; +} + +/** + * Verify that a child is standalone or is being advanced by its recorded parent. + * + * The parent id is a composition guard, not an authentication mechanism. Parent-owned command + * methods remain internal service seams. + */ +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); + } +} + +/** Validate the durable authorization phase carried by a parent-owned melt child. */ +export function assertParentOwnedMeltOperationInvariant(operation: MeltOperation): void { + const owner = operation.parentSwapOperationId; + const phase = operation.parentExecutionPhase; + if (!owner) { + if (phase !== undefined) { + throw new Error(`Standalone melt operation ${operation.id} cannot have a parent phase`); + } + return; + } + + if (operation.state === 'executing') { + if (phase === undefined) { + throw new Error(`Parent-owned executing melt operation ${operation.id} requires a phase`); + } + } else if ( + operation.state === 'pending' || + operation.state === 'failed' || + operation.state === 'finalized' + ) { + if (phase !== 'melt_authorized') { + throw new Error( + `Parent-owned settled melt operation ${operation.id} requires melt authorization`, + ); + } + } else if (phase !== undefined) { + throw new Error( + `Melt operation ${operation.id} cannot retain a parent phase in ${operation.state}`, + ); + } + + if (phase === 'pre_swap_authorized') { + if ( + operation.state !== 'executing' || + !operation.needsSwap || + operation.swapOutputData === undefined + ) { + throw new Error(`Melt operation ${operation.id} has an invalid pre-swap authorization`); + } + } + + if (operation.state !== 'init') { + assertUniqueNonEmpty(operation.inputProofSecrets, `Melt operation ${operation.id} inputs`); + const changeSecrets = getSecretsFromSerializedOutputData(operation.changeOutputData); + if (changeSecrets.sendSecrets.length > 0) { + throw new Error(`Melt operation ${operation.id} change outputs cannot contain send outputs`); + } + assertUniqueNonEmpty( + changeSecrets.keepSecrets, + `Melt operation ${operation.id} change outputs`, + { + allowEmpty: true, + }, + ); + if (operation.needsSwap) { + if (!operation.swapOutputData) { + throw new Error(`Melt operation ${operation.id} requires persisted swap outputs`); + } + const swapSecrets = getSecretsFromSerializedOutputData(operation.swapOutputData); + assertUniqueNonEmpty( + swapSecrets.sendSecrets, + `Melt operation ${operation.id} swap send outputs`, + ); + assertUniqueNonEmpty( + [...swapSecrets.keepSecrets, ...swapSecrets.sendSecrets], + `Melt operation ${operation.id} swap outputs`, + ); + } else if (operation.swapOutputData !== undefined) { + throw new Error(`Direct melt operation ${operation.id} cannot contain swap outputs`); + } + + const requiredMeltAmount = operation.amount.add(operation.fee_reserve); + if (operation.inputAmount.lessThan(requiredMeltAmount)) { + throw new Error(`Melt operation ${operation.id} input amount cannot cover its quote`); + } + if (operation.needsSwap) { + const swapOutputAmount = [ + ...operation.swapOutputData!.keep, + ...operation.swapOutputData!.send, + ].reduce( + (total, output) => total.add(Amount.from(output.blindedMessage.amount)), + Amount.zero(), + ); + if (!swapOutputAmount.add(operation.swap_fee).equals(operation.inputAmount)) { + throw new Error(`Melt operation ${operation.id} swap outputs do not conserve value`); + } + const swapSendAmount = operation.swapOutputData!.send.reduce( + (total, output) => total.add(Amount.from(output.blindedMessage.amount)), + Amount.zero(), + ); + if (swapSendAmount.lessThan(requiredMeltAmount)) { + throw new Error(`Melt operation ${operation.id} swap send outputs cannot cover its quote`); + } + } else if (!operation.swap_fee.isZero()) { + throw new Error(`Direct melt operation ${operation.id} cannot contain a swap fee`); + } + } +} + +/** Ensure a persisted parent-owned destination child is locked BOLT11/sat work. */ +export function assertParentOwnedMintOperationInvariant(operation: MintOperation): void { + if (!operation.parentSwapOperationId) return; + if ( + operation.state === 'init' || + operation.method !== 'bolt11' || + operation.unit !== 'sat' || + operation.pubkey === undefined + ) { + throw new Error(`Parent-owned mint operation ${operation.id} must be locked BOLT11/sat work`); + } + const secrets = getSecretsFromSerializedOutputData(operation.outputData); + if (secrets.sendSecrets.length > 0) { + throw new Error(`Parent-owned mint operation ${operation.id} cannot contain send outputs`); + } + assertUniqueNonEmpty(secrets.keepSecrets, `Mint operation ${operation.id} outputs`); + const outputAmount = operation.outputData.keep.reduce( + (total, output) => total.add(Amount.from(output.blindedMessage.amount)), + Amount.zero(), + ); + if (!outputAmount.equals(operation.amount)) { + throw new Error(`Parent-owned mint operation ${operation.id} output amount does not reconcile`); + } +} + +function assertUniqueNonEmpty( + values: readonly string[], + label: string, + options: { allowEmpty?: boolean } = {}, +): void { + if (!options.allowEmpty && values.length === 0) throw new Error(`${label} cannot be empty`); + if (values.some((value) => value.length === 0) || new Set(values).size !== values.length) { + throw new Error(`${label} must contain unique non-empty values`); + } +} + +const PARENT_MINT_TRANSITIONS: Record< + MintOperation['state'], + ReadonlySet +> = { + init: new Set(['pending']), + pending: new Set(['executing', 'failed']), + executing: new Set(['executing', 'finalized', 'failed']), + finalized: new Set(), + failed: new Set(), +}; + +const PARENT_MELT_TRANSITIONS: Record< + MeltOperation['state'], + ReadonlySet +> = { + init: new Set(['prepared', 'rolled_back']), + prepared: new Set(['executing', 'rolled_back']), + executing: new Set(['executing', 'pending', 'failed', 'finalized']), + pending: new Set(['pending', 'failed', 'finalized', 'rolling_back']), + failed: new Set(), + finalized: new Set(), + rolling_back: new Set(['rolled_back']), + rolled_back: new Set(), +}; + +/** Enforce immutable economic facts and forward-only state for an owned destination child. */ +export function assertParentOwnedMintOperationUpdate( + current: MintOperation, + next: MintOperation, +): void { + if (current.parentSwapOperationId !== next.parentSwapOperationId) { + throw new Error(`Parent-owned mint parent ownership is immutable`); + } + if (!current.parentSwapOperationId) return; + assertParentOwnedMintOperationInvariant(next); + assertOwnedBaseFields(current, next, 'mint'); + assertOwnedStateTransition(PARENT_MINT_TRANSITIONS, current, next, 'mint'); + if (current.state === 'init') return; + + const currentPending = current as Exclude; + const nextPending = next as Exclude; + assertImmutableValue(currentPending.quoteId, nextPending.quoteId, 'mint quote id'); + assertImmutableValue( + currentPending.amount.toString(), + nextPending.amount.toString(), + 'mint amount', + ); + assertImmutableValue(currentPending.unit, nextPending.unit, 'mint unit'); + assertImmutableValue(currentPending.request, nextPending.request, 'mint request'); + assertImmutableValue(currentPending.expiry, nextPending.expiry, 'mint expiry'); + assertImmutableValue(currentPending.pubkey, nextPending.pubkey, 'mint quote key'); + assertImmutableValue( + JSON.stringify(currentPending.outputData), + JSON.stringify(nextPending.outputData), + 'mint deterministic outputs', + ); +} + +/** Enforce immutable economic facts and forward-only state for an owned source child. */ +export function assertParentOwnedMeltOperationUpdate( + current: MeltOperation, + next: MeltOperation, +): void { + if (current.parentSwapOperationId !== next.parentSwapOperationId) { + throw new Error(`Parent-owned melt parent ownership is immutable`); + } + if (!current.parentSwapOperationId) return; + assertParentOwnedMeltOperationInvariant(next); + assertOwnedBaseFields(current, next, 'melt'); + assertOwnedStateTransition(PARENT_MELT_TRANSITIONS, current, next, 'melt'); + if (current.state === 'init') return; + + const currentPrepared = current as Exclude; + const nextPrepared = next as Exclude; + for (const [left, right, name] of [ + [currentPrepared.quoteId, nextPrepared.quoteId, 'melt quote id'], + [currentPrepared.amount.toString(), nextPrepared.amount.toString(), 'melt amount'], + [ + currentPrepared.fee_reserve.toString(), + nextPrepared.fee_reserve.toString(), + 'melt fee reserve', + ], + [currentPrepared.swap_fee.toString(), nextPrepared.swap_fee.toString(), 'melt swap fee'], + [ + currentPrepared.inputAmount.toString(), + nextPrepared.inputAmount.toString(), + 'melt input amount', + ], + [currentPrepared.needsSwap, nextPrepared.needsSwap, 'melt swap requirement'], + [ + JSON.stringify(currentPrepared.inputProofSecrets), + JSON.stringify(nextPrepared.inputProofSecrets), + 'melt input proofs', + ], + [ + JSON.stringify(currentPrepared.changeOutputData), + JSON.stringify(nextPrepared.changeOutputData), + 'melt change outputs', + ], + [ + JSON.stringify(currentPrepared.swapOutputData), + JSON.stringify(nextPrepared.swapOutputData), + 'melt swap outputs', + ], + ] as const) { + assertImmutableValue(left, right, name); + } + const phaseOrder = { pre_swap_authorized: 1, melt_authorized: 2 } as const; + const currentPhase = current.parentExecutionPhase ? phaseOrder[current.parentExecutionPhase] : 0; + const nextPhase = next.parentExecutionPhase ? phaseOrder[next.parentExecutionPhase] : 0; + if (nextPhase < currentPhase) { + throw new Error('Parent-owned melt execution authorization cannot regress'); + } +} + +function assertOwnedBaseFields( + current: MintOperation | MeltOperation, + next: MintOperation | MeltOperation, + label: string, +): void { + for (const [left, right, name] of [ + [current.id, next.id, 'id'], + [current.parentSwapOperationId, next.parentSwapOperationId, 'parent ownership'], + [current.mintUrl, next.mintUrl, 'mint URL'], + [current.unit, next.unit, 'unit'], + [current.method, next.method, 'method'], + [JSON.stringify(current.methodData), JSON.stringify(next.methodData), 'method data'], + [current.createdAt, next.createdAt, 'createdAt'], + ] as const) { + assertImmutableValue(left, right, `${label} ${name}`); + } + if (next.updatedAt < current.updatedAt) { + throw new Error(`Parent-owned ${label} updatedAt cannot regress`); + } +} + +function assertOwnedStateTransition( + transitions: Record>, + current: T, + next: T, + label: string, +): void { + if (current.state !== next.state && !transitions[current.state]?.has(next.state)) { + throw new Error(`Illegal parent-owned ${label} transition: ${current.state} -> ${next.state}`); + } + if (current.state === next.state && !transitions[current.state]?.has(next.state)) { + throw new Error(`Parent-owned ${label} state ${current.state} is immutable`); + } +} + +function assertImmutableValue(left: unknown, right: unknown, name: string): void { + if (left !== right) throw new Error(`Parent-owned ${name} is immutable`); +} diff --git a/packages/core/operations/mintSwap/MintSwapOperation.ts b/packages/core/operations/mintSwap/MintSwapOperation.ts new file mode 100644 index 000000000..0786bf11c --- /dev/null +++ b/packages/core/operations/mintSwap/MintSwapOperation.ts @@ -0,0 +1,1162 @@ +import { Amount } from '@cashu/cashu-ts'; +import { bytesToHex } from '@noble/curves/utils.js'; +import { sha256 } from '@noble/hashes/sha2.js'; + +import { normalizeMintUrl } from '../../utils'; +import type { SerializedOutputData } from '../../utils'; + +export type MintSwapOperationState = + | 'preparing' + | 'prepared' + | 'source_inflight' + | 'destination_funded' + | 'issuing' + | 'completed' + | 'cancelled' + | 'failed' + | 'needs_attention'; + +/** + * The local preparation step protected by a fenced lease. + * + * A coordinator persists the next stage before beginning it. Attaching that + * stage's result and advancing to the next stage is one CAS update. + */ +export type MintSwapPreparationStage = + | 'destination_quote' + | 'destination_child' + | 'source_quote' + | 'source_child'; + +export interface MintSwapPreparationLease { + ownerId: string; + /** Unique fencing token. A stale worker must not commit with an old token. */ + token: string; + stage: MintSwapPreparationStage; + acquiredAt: number; + expiresAt: number; +} + +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; + dispatchDeadlineSeconds: 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; + /** + * Reference to the fresh NUT-20 key persisted before destination quote I/O. + * The private key remains in the keyring and is never copied into this model. + */ + destinationNut20Key: MintSwapNut20KeyRef; + preparationLease?: MintSwapPreparationLease; + destinationQuoteRef?: MintSwapQuoteRef; + destinationMintOperationId?: string; + sourceQuoteRef?: MintSwapQuoteRef; + sourceMeltOperationId?: string; + preparedPlan?: MintSwapPreparedPlan; + settlement?: MintSwapSettlement; + sourceDispatchAuthorizedAt?: number; + /** Durable evidence that authorized source inputs were reclaimed before value-neutral exit. */ + sourceReclaimedAt?: 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; + destinationNut20Key: MintSwapNut20KeyRef; + destinationAmount: Amount; + unit: 'sat'; + sourceInputProofSecrets: readonly string[]; + destinationOutputData: SerializedOutputData; + sourceOutputData: SerializedOutputData; + sourceMeltAmount: Amount; + sourceFeeReserve: Amount; + sourcePreparationFee: Amount; + sourceMeltInputFee: Amount; + minimumSourceDebit: Amount; + maximumSourceDebit: Amount; + reservedSourceAmount: Amount; + dispatchDeadlineSeconds: number; + requiredDispatchWindowSeconds: number; +} + +const TERMINAL_STATES = new Set(['completed', 'cancelled', 'failed']); +const ALL_STATES = new Set([ + 'preparing', + 'prepared', + 'source_inflight', + 'destination_funded', + 'issuing', + 'completed', + 'cancelled', + 'failed', + 'needs_attention', +]); +const ATTENTION_REASONS = new Set([ + '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', +]); +const AUTOMATIC_STATES = new Set([ + 'preparing', + 'source_inflight', + 'destination_funded', + 'issuing', +]); +const PREPARED_REQUIRED_STATES = new Set([ + 'prepared', + 'source_inflight', + 'destination_funded', + 'issuing', + 'completed', +]); +const PREPARATION_STAGE_ORDER: readonly MintSwapPreparationStage[] = [ + 'destination_quote', + 'destination_child', + 'source_quote', + 'source_child', +]; + +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(), + // S2 deliberately makes attention quiescent. S4 may add explicit, audited repair commands. + needs_attention: new Set(), +}; + +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 { + if (from === to) return isAutomaticMintSwapState(from); + return 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 isMintSwapPreparationLeaseActive( + operation: Pick, + now: number, +): boolean { + assertTimestamp(now, 'Mint swap lease check time'); + return operation.state === 'preparing' && (operation.preparationLease?.expiresAt ?? 0) > now; +} + +export function assertMintSwapPreparationLeaseOwner( + operation: Pick, + ownerId: string, + token: string, + now?: number, +): void { + const lease = operation.preparationLease; + if ( + operation.state !== 'preparing' || + !lease || + lease.ownerId !== ownerId || + lease.token !== token + ) { + throw new Error(`Mint swap ${operation.id} preparation lease is not owned by this worker`); + } + if (now !== undefined && !isMintSwapPreparationLeaseActive(operation, now)) { + throw new Error(`Mint swap ${operation.id} preparation lease has expired`); + } +} + +/** + * Return the earliest durable time at which automatic work may be claimed. + * `null` identifies caller-driven, quiescent, or terminal states. + */ +export function getMintSwapOperationDueAt( + operation: Pick, +): number | null { + if (operation.state === 'preparing') { + if (!operation.preparationLease) return null; + return Math.max(operation.preparationLease.expiresAt, operation.retry.nextAttemptAt ?? 0); + } + if ( + operation.state === 'source_inflight' || + operation.state === 'destination_funded' || + operation.state === 'issuing' + ) { + return operation.retry.nextAttemptAt ?? 0; + } + return null; +} + +export function isMintSwapOperationDue( + operation: Pick, + now: number, +): boolean { + assertTimestamp(now, 'Mint swap due check time'); + const dueAt = getMintSwapOperationDueAt(operation); + return dueAt !== null && dueAt <= now; +} + +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'); + } + + for (const [name, amount] of Object.entries({ + sourcePaymentFee: settlement.sourcePaymentFee, + totalSourceFee: settlement.totalSourceFee, + sourceMeltChangeAmount: settlement.sourceMeltChangeAmount, + sourceKeepAmount: settlement.sourceKeepAmount, + sourceReturnedAmount: settlement.sourceReturnedAmount, + finalSourceDebit: settlement.finalSourceDebit, + destinationAmountIssued: settlement.destinationAmountIssued, + })) { + if (amount !== undefined) assertNonNegativeAmount(amount, `Mint swap ${name}`); + } + + 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'); + if (!ALL_STATES.has(operation.state)) { + throw new Error(`Unknown mint swap state: ${String(operation.state)}`); + } + 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'); + assertNonNegativeAmount(operation.destinationAmount, 'Mint swap destination amount'); + if (operation.destinationAmount.isZero()) { + throw new Error('Mint swap destination amount must be positive'); + } + + validateNut20Key(operation.destinationNut20Key); + validateRetry(operation.retry); + if (operation.retry.lastAttemptAt !== undefined) { + assertOperationTimestampOrder( + operation, + operation.retry.lastAttemptAt, + 'Mint swap retry last attempt', + ); + } + if (operation.retry.lastSuccessfulObservationAt !== undefined) { + assertOperationTimestampOrder( + operation, + operation.retry.lastSuccessfulObservationAt, + 'Mint swap retry last successful observation', + ); + } + validateQuoteRef(operation.destinationQuoteRef, destinationMintUrl, 'destination'); + validateQuoteRef(operation.sourceQuoteRef, sourceMintUrl, 'source'); + validateAttachmentOrder(operation); + + if (operation.state === 'preparing') { + validatePreparationLease(operation); + } else if (operation.preparationLease) { + throw new Error(`Mint swap state ${operation.state} cannot retain a preparation lease`); + } + + if (PREPARED_REQUIRED_STATES.has(operation.state) || operation.preparedPlan) { + requirePreparedFields(operation); + } + if (operation.state === 'needs_attention' && operation.attention?.lastSafeState !== 'preparing') { + requirePreparedFields(operation); + } + + const progressState = getMintSwapProgressState(operation); + if (operation.sourceDispatchAuthorizedAt !== undefined) { + assertTimestamp( + operation.sourceDispatchAuthorizedAt, + 'Mint swap source dispatch authorization', + ); + assertOperationTimestampOrder( + operation, + operation.sourceDispatchAuthorizedAt, + 'Mint swap source dispatch authorization', + ); + if (progressState === 'preparing' || progressState === 'prepared') { + throw new Error(`Mint swap state ${operation.state} cannot authorize source dispatch`); + } + } + if ( + progressState === 'source_inflight' || + progressState === 'destination_funded' || + progressState === 'issuing' || + progressState === 'completed' + ) { + assertTimestamp( + operation.sourceDispatchAuthorizedAt, + 'Mint swap source dispatch authorization', + ); + } + + if (operation.destinationIssueAuthorizedAt !== undefined) { + assertTimestamp( + operation.destinationIssueAuthorizedAt, + 'Mint swap destination issue authorization', + ); + assertOperationTimestampOrder( + operation, + operation.destinationIssueAuthorizedAt, + 'Mint swap destination issue authorization', + ); + if (progressState !== 'issuing' && progressState !== 'completed') { + throw new Error(`Mint swap state ${operation.state} cannot authorize destination issuance`); + } + if ( + operation.sourceDispatchAuthorizedAt === undefined || + operation.destinationIssueAuthorizedAt < operation.sourceDispatchAuthorizedAt + ) { + throw new Error('Mint swap destination issuance authorization must follow source dispatch'); + } + } + + if (operation.sourceReclaimedAt !== undefined) { + assertTimestamp(operation.sourceReclaimedAt, 'Mint swap source reclamation'); + assertOperationTimestampOrder( + operation, + operation.sourceReclaimedAt, + 'Mint swap source reclamation', + ); + if (operation.state !== 'failed' && operation.state !== 'cancelled') { + throw new Error( + 'Source reclamation evidence is valid only for value-neutral terminal states', + ); + } + if ( + operation.sourceDispatchAuthorizedAt === undefined || + operation.sourceReclaimedAt < operation.sourceDispatchAuthorizedAt + ) { + throw new Error('Mint swap source reclamation must follow source dispatch authorization'); + } + } + if (progressState === 'issuing' || progressState === 'completed') { + assertTimestamp( + operation.destinationIssueAuthorizedAt, + 'Mint swap destination issue authorization', + ); + } + + if ( + progressState === 'destination_funded' || + progressState === 'issuing' || + progressState === 'completed' + ) { + validateMintSwapAccounting(operation); + } else if (operation.settlement) { + validateMintSwapAccounting(operation); + } + + if (operation.cancellationRequestedAt !== undefined) { + assertTimestamp(operation.cancellationRequestedAt, 'Mint swap cancellation request'); + assertOperationTimestampOrder( + operation, + operation.cancellationRequestedAt, + 'Mint swap cancellation request', + ); + } + if (operation.cancelledAt !== undefined) { + assertTimestamp(operation.cancelledAt, 'Mint swap cancellation completion'); + assertOperationTimestampOrder( + operation, + operation.cancelledAt, + 'Mint swap cancellation completion', + ); + if (operation.state !== 'cancelled') { + throw new Error('Only a cancelled mint swap may have cancelledAt'); + } + } + if (operation.state === 'cancelled') { + assertTimestamp(operation.cancellationRequestedAt, 'Mint swap cancellation request'); + assertTimestamp(operation.cancelledAt, 'Mint swap cancellation completion'); + if (operation.cancelledAt! < operation.cancellationRequestedAt!) { + throw new Error('Mint swap cancellation completion must follow its request'); + } + } + + if (operation.completedAt !== undefined) { + assertTimestamp(operation.completedAt, 'Mint swap completion time'); + assertOperationTimestampOrder(operation, operation.completedAt, 'Mint swap completion time'); + if (operation.state !== 'completed') { + throw new Error('Only a completed mint swap may have completedAt'); + } + } + if (operation.state === 'completed') { + assertTimestamp(operation.completedAt, 'Mint swap completion time'); + if (operation.completedAt! < operation.destinationIssueAuthorizedAt!) { + throw new Error('Mint swap completion must follow destination issuance authorization'); + } + } + + if (operation.state === 'failed' && !operation.terminalFailure) { + throw new Error('Failed mint swap requires terminal failure details'); + } + if (operation.terminalFailure) { + if (operation.state !== 'failed') { + throw new Error('Only a failed mint swap may have terminal failure details'); + } + validateTerminalFailure(operation.terminalFailure); + assertOperationTimestampOrder( + operation, + operation.terminalFailure.at, + 'Mint swap terminal failure time', + ); + if ( + operation.sourceReclaimedAt !== undefined && + operation.terminalFailure.at < operation.sourceReclaimedAt + ) { + throw new Error('Mint swap terminal failure must follow source reclamation'); + } + } + + if (operation.state === 'needs_attention' && !operation.attention) { + throw new Error('Mint swap needing attention requires structured evidence'); + } + if (operation.attention) { + if (operation.state !== 'needs_attention') { + throw new Error('Only a mint swap needing attention may contain attention evidence'); + } + validateAttention(operation.attention); + assertOperationTimestampOrder(operation, operation.attention.at, 'Mint swap attention time'); + } + + if ((operation.state === 'failed' || operation.state === 'cancelled') && operation.settlement) { + throw new Error( + `Mint swap state ${operation.state} cannot contain transferred-value settlement`, + ); + } + if ( + (operation.state === 'failed' || operation.state === 'cancelled') && + operation.sourceDispatchAuthorizedAt !== undefined && + operation.sourceReclaimedAt === undefined + ) { + throw new Error('Value-neutral terminal mint swap requires source reclamation evidence'); + } + if ( + (operation.state === 'failed' || operation.state === 'cancelled') && + operation.destinationIssueAuthorizedAt !== undefined + ) { + throw new Error(`Mint swap state ${operation.state} cannot authorize destination issuance`); + } + + return operation; +} + +/** + * Validate a CAS replacement against the currently stored operation. + * + * Repositories should call this before committing a winning revision. + */ +export function assertMintSwapOperationUpdate( + current: MintSwapOperation, + next: MintSwapOperation, +): void { + validateMintSwapOperation(current); + validateMintSwapOperation(next); + if (current.id !== next.id) throw new Error('Mint swap id is immutable'); + if (next.revision !== current.revision + 1) { + throw new Error('Mint swap update must advance revision exactly once'); + } + if (next.updatedAt < current.updatedAt) { + throw new Error('Mint swap updatedAt cannot regress'); + } + assertMintSwapTransition(current.state, next.state); + assertAlwaysImmutable(current, next); + assertAttachedReferencesImmutable(current, next); + assertAuthorizationImmutable(current, next); + assertSettlementImmutable(current, next); + assertPreparedMintSwapImmutable(current, next); + assertPreparationLeaseUpdate(current, next); + assertRetryUpdate(current, next); + assertCancellationRequestUpdate(current, next); +} + +export function assertPreparedMintSwapImmutable( + current: MintSwapOperation, + next: MintSwapOperation, +): void { + if (!current.preparedPlan) return; + const fields: Array<[unknown, unknown, string]> = [ + [current.preparedPlan.fingerprint, next.preparedPlan?.fingerprint, 'prepared fingerprint'], + [ + current.preparedPlan.dispatchDeadlineSeconds, + next.preparedPlan?.dispatchDeadlineSeconds, + '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 validatePreparationLease(operation: MintSwapOperation): void { + const lease = operation.preparationLease; + if (!lease) throw new Error('Preparing mint swap requires a durable preparation lease'); + assertNonEmpty(lease.ownerId, 'Mint swap preparation lease owner'); + assertNonEmpty(lease.token, 'Mint swap preparation lease token'); + assertTimestamp(lease.acquiredAt, 'Mint swap preparation lease acquiredAt'); + assertTimestamp(lease.expiresAt, 'Mint swap preparation lease expiresAt'); + if (lease.expiresAt <= lease.acquiredAt) { + throw new Error('Mint swap preparation lease must expire after it is acquired'); + } + if (lease.acquiredAt < operation.createdAt || lease.acquiredAt > operation.updatedAt) { + throw new Error('Mint swap preparation lease acquisition is outside the operation timeline'); + } + if (lease.expiresAt <= operation.updatedAt) { + throw new Error('A newly persisted preparation lease must still be active'); + } + if (!PREPARATION_STAGE_ORDER.includes(lease.stage)) { + throw new Error(`Unknown mint swap preparation stage: ${String(lease.stage)}`); + } + + const hasDestinationQuote = operation.destinationQuoteRef !== undefined; + const hasDestinationChild = operation.destinationMintOperationId !== undefined; + const hasSourceQuote = operation.sourceQuoteRef !== undefined; + const hasSourceChild = operation.sourceMeltOperationId !== undefined; + const stageFacts: Record = { + destination_quote: [ + !hasDestinationQuote, + !hasDestinationChild, + !hasSourceQuote, + !hasSourceChild, + ], + destination_child: [ + hasDestinationQuote, + !hasDestinationChild, + !hasSourceQuote, + !hasSourceChild, + ], + source_quote: [hasDestinationQuote, hasDestinationChild, !hasSourceQuote, !hasSourceChild], + source_child: [hasDestinationQuote, hasDestinationChild, hasSourceQuote, !hasSourceChild], + }; + if (!stageFacts[lease.stage].every(Boolean)) { + throw new Error(`Mint swap preparation stage ${lease.stage} contradicts attached records`); + } + if (operation.preparedPlan) { + throw new Error('Preparing mint swap cannot contain a completed prepared plan'); + } +} + +function assertPreparationLeaseUpdate(current: MintSwapOperation, next: MintSwapOperation): void { + if (current.state !== 'preparing' || next.state !== 'preparing') return; + const currentLease = current.preparationLease!; + const nextLease = next.preparationLease!; + const currentStage = PREPARATION_STAGE_ORDER.indexOf(currentLease.stage); + const nextStage = PREPARATION_STAGE_ORDER.indexOf(nextLease.stage); + if (nextStage < currentStage || nextStage > currentStage + 1) { + throw new Error('Mint swap preparation stage must advance at most one step'); + } + + if (currentLease.token === nextLease.token) { + if ( + currentLease.ownerId !== nextLease.ownerId || + currentLease.acquiredAt !== nextLease.acquiredAt + ) { + throw new Error('Mint swap preparation lease identity is immutable for one token'); + } + if (nextLease.expiresAt < currentLease.expiresAt) { + throw new Error('Mint swap preparation lease expiry cannot regress'); + } + if (next.updatedAt >= currentLease.expiresAt) { + throw new Error('Mint swap preparation lease cannot be renewed or advanced after expiry'); + } + return; + } + + if (nextLease.acquiredAt < currentLease.expiresAt) { + throw new Error('Mint swap preparation lease cannot be taken over before expiry'); + } +} + +function requirePreparedFields(operation: MintSwapOperation): void { + if ( + !operation.destinationQuoteRef || + !operation.destinationMintOperationId || + !operation.sourceQuoteRef || + !operation.sourceMeltOperationId || + !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'); + const plan = operation.preparedPlan; + if (!/^[0-9a-f]{64}$/.test(plan.fingerprint)) { + throw new Error('Mint swap prepared fingerprint must be canonical SHA-256 hex'); + } + assertUnixSeconds(plan.dispatchDeadlineSeconds, 'Mint swap dispatch deadline'); + if (plan.dispatchDeadlineSeconds < Math.floor(operation.createdAt / 1_000)) { + throw new Error('Mint swap dispatch deadline cannot precede operation creation'); + } + if ( + !Number.isSafeInteger(plan.requiredDispatchWindowSeconds) || + plan.requiredDispatchWindowSeconds < 30 + ) { + throw new Error('Mint swap required dispatch window must be at least 30 seconds'); + } + if ( + operation.state === 'prepared' && + plan.dispatchDeadlineSeconds < + Math.floor(operation.updatedAt / 1_000) + plan.requiredDispatchWindowSeconds + ) { + throw new Error('Prepared mint swap does not retain its required dispatch safety window'); + } + for (const [name, amount] of Object.entries({ + sourceMeltAmount: plan.sourceMeltAmount, + sourceFeeReserve: plan.sourceFeeReserve, + sourcePreparationFee: plan.sourcePreparationFee, + sourceMeltInputFee: plan.sourceMeltInputFee, + minimumSourceDebit: plan.minimumSourceDebit, + maximumSourceDebit: plan.maximumSourceDebit, + reservedSourceAmount: plan.reservedSourceAmount, + })) { + assertNonNegativeAmount(amount, `Mint swap ${name}`); + } + + assertAmountEquals(plan.sourceMeltAmount, operation.destinationAmount, 'source melt amount'); + const minimum = operation.destinationAmount + .add(plan.sourcePreparationFee) + .add(plan.sourceMeltInputFee); + assertAmountEquals(plan.minimumSourceDebit, minimum, 'minimum source debit'); + 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'); + } + + const reserveBound = plan.minimumSourceDebit.add(plan.sourceFeeReserve); + if ( + !plan.maximumSourceDebit.equals(reserveBound) && + !plan.maximumSourceDebit.equals(plan.reservedSourceAmount) + ) { + throw new Error( + 'Mint swap maximum source debit must use the fee-reserve or reserved-input bound', + ); + } +} + +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}`); + } + if (retry.lastError !== undefined) assertNonEmpty(retry.lastError, 'Mint swap retry last error'); +} + +function validateNut20Key(key: MintSwapNut20KeyRef): void { + if (!key) throw new Error('Mint swap requires a persisted NUT-20 key reference'); + assertNonEmpty(key.publicKey, 'Mint swap NUT-20 public key'); + if (!/^(02|03)[0-9a-f]{64}$/.test(key.publicKey)) { + throw new Error('Mint swap NUT-20 public key must be canonical compressed hex'); + } + if (!Number.isSafeInteger(key.derivationIndex) || key.derivationIndex < 0) { + throw new Error('Mint swap NUT-20 derivation index must be a non-negative safe integer'); + } +} + +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 validateAttachmentOrder(operation: MintSwapOperation): void { + if (operation.destinationMintOperationId && !operation.destinationQuoteRef) { + throw new Error('Mint swap destination child requires its quote reference'); + } + if (operation.sourceQuoteRef && !operation.destinationMintOperationId) { + throw new Error('Mint swap source quote requires the prepared destination child'); + } + if (operation.sourceMeltOperationId && !operation.sourceQuoteRef) { + throw new Error('Mint swap source child requires its quote reference'); + } +} + +function validateAttention(attention: MintSwapAttentionRecord): void { + if (!ATTENTION_REASONS.has(attention.reason)) { + throw new Error(`Unknown mint swap attention reason: ${String(attention.reason)}`); + } + if (!ALL_STATES.has(attention.lastSafeState)) { + throw new Error(`Unknown mint swap last safe state: ${String(attention.lastSafeState)}`); + } + if ( + attention.lastSafeState === 'completed' || + attention.lastSafeState === 'cancelled' || + attention.lastSafeState === 'failed' || + attention.lastSafeState === 'needs_attention' + ) { + throw new Error('Mint swap attention last safe state must be a non-terminal progress state'); + } + assertNonEmpty(attention.message, 'Mint swap attention message'); + assertNonEmpty(attention.violatedInvariant, 'Mint swap violated invariant'); + assertTimestamp(attention.at, 'Mint swap attention time'); + for (const [key, value] of Object.entries(attention.evidence)) { + assertNonEmpty(key, 'Mint swap attention evidence key'); + if ( + value !== null && + typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'boolean' + ) { + throw new Error('Mint swap attention evidence must be scalar'); + } + if (typeof value === 'number' && !Number.isFinite(value)) { + throw new Error('Mint swap attention evidence number must be finite'); + } + } +} + +function validateTerminalFailure(failure: MintSwapTerminalFailure): void { + assertNonEmpty(failure.code, 'Mint swap terminal failure code'); + assertNonEmpty(failure.reason, 'Mint swap terminal failure reason'); + assertTimestamp(failure.at, 'Mint swap terminal failure time'); +} + +function assertAlwaysImmutable(current: MintSwapOperation, next: MintSwapOperation): void { + const fields: Array<[unknown, unknown, string]> = [ + [current.createdAt, next.createdAt, 'createdAt'], + [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.destinationNut20Key.publicKey, + next.destinationNut20Key.publicKey, + 'NUT-20 public key', + ], + [ + current.destinationNut20Key.derivationIndex, + next.destinationNut20Key.derivationIndex, + 'NUT-20 derivation index', + ], + ]; + const changed = fields.find(([left, right]) => left !== right); + if (changed) throw new Error(`Mint swap ${changed[2]} is immutable`); +} + +function assertRetryUpdate(current: MintSwapOperation, next: MintSwapOperation): void { + if (next.retry.attemptCount < current.retry.attemptCount) { + throw new Error('Mint swap retry attempt count cannot regress'); + } + for (const [currentValue, nextValue, name] of [ + [current.retry.lastAttemptAt, next.retry.lastAttemptAt, 'last attempt'], + [ + current.retry.lastSuccessfulObservationAt, + next.retry.lastSuccessfulObservationAt, + 'last successful observation', + ], + ] as const) { + if (currentValue !== undefined && (nextValue === undefined || nextValue < currentValue)) { + throw new Error(`Mint swap retry ${name} cannot regress`); + } + } +} + +function assertCancellationRequestUpdate( + current: MintSwapOperation, + next: MintSwapOperation, +): void { + if ( + current.cancellationRequestedAt === undefined && + next.cancellationRequestedAt !== undefined && + current.state !== 'preparing' && + current.state !== 'prepared' && + current.state !== 'source_inflight' + ) { + throw new Error(`Cannot newly request cancellation from mint swap state ${current.state}`); + } +} + +function getMintSwapProgressState(operation: MintSwapOperation): MintSwapOperationState { + return operation.state === 'needs_attention' + ? (operation.attention?.lastSafeState ?? operation.state) + : operation.state; +} + +function assertAttachedReferencesImmutable( + current: MintSwapOperation, + next: MintSwapOperation, +): void { + const fields: Array<[unknown, unknown, string]> = [ + [ + quoteRefKey(current.destinationQuoteRef), + quoteRefKey(next.destinationQuoteRef), + 'destination quote', + ], + [current.destinationMintOperationId, next.destinationMintOperationId, 'destination child'], + [quoteRefKey(current.sourceQuoteRef), quoteRefKey(next.sourceQuoteRef), 'source quote'], + [current.sourceMeltOperationId, next.sourceMeltOperationId, 'source child'], + ]; + const removedOrChanged = fields.find( + ([currentValue, nextValue]) => currentValue !== undefined && currentValue !== nextValue, + ); + if (removedOrChanged) { + throw new Error(`Mint swap attached ${removedOrChanged[2]} is immutable`); + } +} + +function assertAuthorizationImmutable(current: MintSwapOperation, next: MintSwapOperation): void { + for (const [currentValue, nextValue, name] of [ + [ + current.sourceDispatchAuthorizedAt, + next.sourceDispatchAuthorizedAt, + 'source dispatch authorization', + ], + [ + current.destinationIssueAuthorizedAt, + next.destinationIssueAuthorizedAt, + 'destination issue authorization', + ], + [current.cancellationRequestedAt, next.cancellationRequestedAt, 'cancellation request'], + [current.sourceReclaimedAt, next.sourceReclaimedAt, 'source reclamation evidence'], + ] as const) { + if (currentValue !== undefined && currentValue !== nextValue) { + throw new Error(`Mint swap ${name} is immutable`); + } + } +} + +function assertSettlementImmutable(current: MintSwapOperation, next: MintSwapOperation): void { + if (!current.settlement) return; + if (!next.settlement) throw new Error('Mint swap settlement cannot be removed'); + for (const [currentValue, nextValue, name] of [ + [current.settlement.sourcePaymentFee, next.settlement.sourcePaymentFee, 'source payment fee'], + [current.settlement.totalSourceFee, next.settlement.totalSourceFee, 'total source fee'], + [ + current.settlement.sourceMeltChangeAmount, + next.settlement.sourceMeltChangeAmount, + 'source melt change', + ], + [current.settlement.sourceKeepAmount, next.settlement.sourceKeepAmount, 'source keep amount'], + [ + current.settlement.sourceReturnedAmount, + next.settlement.sourceReturnedAmount, + 'source returned amount', + ], + [current.settlement.finalSourceDebit, next.settlement.finalSourceDebit, 'final source debit'], + ] as const) { + if (!currentValue.equals(nextValue)) throw new Error(`Mint swap ${name} is immutable`); + } + if (current.settlement.destinationAmountIssued) { + if ( + !next.settlement.destinationAmountIssued || + !current.settlement.destinationAmountIssued.equals(next.settlement.destinationAmountIssued) + ) { + throw new Error('Mint swap destination issued amount is immutable once observed'); + } + } +} + +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 assertNonNegativeAmount(amount: Amount, name: string): void { + Amount.from(amount); + if (amount.toString().startsWith('-')) throw new Error(`${name} cannot be negative`); +} + +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): asserts value is number { + if (value === undefined || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${name} must be a non-negative Unix-millisecond timestamp`); + } +} + +function assertUnixSeconds(value: number | undefined, name: string): asserts value is number { + if (value === undefined || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${name} must be a non-negative Unix-seconds timestamp`); + } +} + +function assertNonEmpty(value: string, name: string): void { + if (!value.trim()) throw new Error(`${name} cannot be empty`); +} + +function assertOperationTimestampOrder( + operation: Pick, + value: number, + name: string, +): void { + if (value < operation.createdAt || value > operation.updatedAt) { + throw new Error(`${name} must be within the operation timeline`); + } +} + +function canonicalizeForFingerprint(value: unknown, seen = new Set()): string { + if (value instanceof Amount) return JSON.stringify(value.toString()); + if (typeof value === 'bigint') return JSON.stringify(value.toString()); + if (value === null || typeof value === 'string' || typeof value === 'boolean') { + return JSON.stringify(value); + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new Error('Mint swap fingerprint numbers must be finite'); + return JSON.stringify(value); + } + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + throw new Error('Mint swap fingerprint input must be serializable'); + } + if (typeof value !== 'object') { + throw new Error('Mint swap fingerprint input contains an unsupported value'); + } + if (seen.has(value)) throw new Error('Mint swap fingerprint input cannot be cyclic'); + seen.add(value); + try { + if (Array.isArray(value)) { + return `[${value.map((item) => canonicalizeForFingerprint(item, seen)).join(',')}]`; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error('Mint swap fingerprint input must contain only plain objects and arrays'); + } + const entries = Object.entries(value as Record) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, item]) => `${JSON.stringify(key)}:${canonicalizeForFingerprint(item, seen)}`); + return `{${entries.join(',')}}`; + } finally { + seen.delete(value); + } +} diff --git a/packages/core/quotes/QuoteLifecycle.ts b/packages/core/quotes/QuoteLifecycle.ts index 11472b4b1..1bca0e1d6 100644 --- a/packages/core/quotes/QuoteLifecycle.ts +++ b/packages/core/quotes/QuoteLifecycle.ts @@ -325,7 +325,7 @@ function mergePaidMeltQuoteSettlement(existing: MeltQuote, incoming: MeltQuote): lastObservedRemoteState: incoming.lastObservedRemoteState ?? existing.lastObservedRemoteState, lastObservedRemoteStateAt: incoming.lastObservedRemoteStateAt ?? existing.lastObservedRemoteStateAt, - updatedAt: incoming.updatedAt, + updatedAt: Math.max(existing.updatedAt, incoming.updatedAt), } as MeltQuote; if (!Array.isArray(existing.change) && Array.isArray(incoming.change)) { @@ -348,6 +348,53 @@ function mergePaidMeltQuoteSettlement(existing: MeltQuote, incoming: MeltQuote): return changed ? merged : null; } +/** + * Persist one attributable melt observation without emitting events or performing remote I/O. + * This is the transaction-safe canonicalization seam used by parent-owned melt commands. + */ +export async function resolveAndPersistMeltQuoteObservation( + repository: MeltQuoteRepository, + canonicalQuote: MeltQuote, +): Promise<{ quote: MeltQuote; remoteQuoteChanged: boolean }> { + const existing = await repository.getMeltQuote( + canonicalQuote.mintUrl, + canonicalQuote.method, + canonicalQuote.quoteId, + ); + + const existingObservedAt = existing?.lastObservedRemoteStateAt; + const incomingObservedAt = canonicalQuote.lastObservedRemoteStateAt; + if ( + existing && + existingObservedAt !== undefined && + incomingObservedAt !== undefined && + incomingObservedAt < existingObservedAt + ) { + return { quote: existing, remoteQuoteChanged: false }; + } + + if (existing?.state === 'PAID') { + const enrichedQuote = mergePaidMeltQuoteSettlement(existing, canonicalQuote); + if (!enrichedQuote) return { quote: existing, remoteQuoteChanged: false }; + const persisted = await persistCanonicalMeltQuote(repository, enrichedQuote); + return { quote: persisted, remoteQuoteChanged: true }; + } + + const remoteQuoteChanged = getMeltQuoteChange(existing, canonicalQuote); + if (!remoteQuoteChanged && existing) { + return { quote: existing, remoteQuoteChanged: false }; + } + const persisted = await persistCanonicalMeltQuote(repository, canonicalQuote); + return { quote: persisted, remoteQuoteChanged }; +} + +async function persistCanonicalMeltQuote( + repository: MeltQuoteRepository, + canonicalQuote: MeltQuote, +): Promise { + return repository.upsertMeltQuote(canonicalQuote); +} + export interface QuoteLifecycleDeps { mintHandlerProvider: MintHandlerProvider; meltHandlerProvider: MeltHandlerProvider; @@ -1460,38 +1507,7 @@ export class QuoteLifecycle { private async resolveAndPersistMeltQuoteObservation( canonicalQuote: MeltQuote, ): Promise<{ quote: MeltQuote; remoteQuoteChanged: boolean }> { - const existing = await this.meltQuoteRepository.getMeltQuote( - canonicalQuote.mintUrl, - canonicalQuote.method, - canonicalQuote.quoteId, - ); - - if (existing?.state === 'PAID') { - const enrichedQuote = mergePaidMeltQuoteSettlement(existing, canonicalQuote); - if (enrichedQuote) { - const persisted = await this.persistCanonicalMeltQuote(enrichedQuote); - return { - quote: persisted, - remoteQuoteChanged: true, - }; - } - - return { - quote: existing, - remoteQuoteChanged: false, - }; - } - - const remoteQuoteChanged = getMeltQuoteChange(existing, canonicalQuote); - if (!remoteQuoteChanged && existing) { - return { - quote: existing, - remoteQuoteChanged: false, - }; - } - - const persisted = await this.persistCanonicalMeltQuote(canonicalQuote); - return { quote: persisted, remoteQuoteChanged }; + return resolveAndPersistMeltQuoteObservation(this.meltQuoteRepository, canonicalQuote); } private async persistCanonicalMintQuote( @@ -1524,10 +1540,6 @@ export class QuoteLifecycle { }); } - private async persistCanonicalMeltQuote(canonicalQuote: MeltQuote): Promise { - return this.meltQuoteRepository.upsertMeltQuote(canonicalQuote); - } - private async emitMeltQuoteUpdatedIfNeeded( quote: MeltQuote, remoteQuoteChanged: boolean, diff --git a/packages/core/repositories/index.ts b/packages/core/repositories/index.ts index 74f0276f2..b23e37d02 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; @@ -354,6 +359,46 @@ 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; + getById(id: string): Promise; + getUnpublished(limit: number, now?: number): Promise; + markPublished(id: string, publishedAt: number): Promise; + recordPublishFailure(id: string, nextAttemptAt: number, lastError: string): Promise; +} + +/** + * Optional durable storage used by the Mint Swap feature. + * + * Keeping the repositories together as one capability prevents a runtime from + * observing a partially configured parent/outbox persistence boundary. + */ +export interface MintSwapRepositoryCapability { + mintSwapOperationRepository: MintSwapOperationRepository; + operationEventOutboxRepository: OperationEventOutboxRepository; +} + +/** Fail a Mint Swap command before it can reserve value or perform remote I/O. */ +export function requireMintSwapRepositoryCapability( + repositories: Pick, +): MintSwapRepositoryCapability { + if (!repositories.mintSwap) { + throw new Error('Mint Swap requires the optional durable repository capability'); + } + return repositories.mintSwap; +} + interface RepositoriesBase { mintRepository: MintRepository; keyRingRepository: KeyRingRepository; @@ -371,6 +416,7 @@ interface RepositoriesBase { receiveOperationRepository: ReceiveOperationRepository; paymentRequestReceiveOperationRepository: PaymentRequestReceiveOperationRepository; paymentRequestReceiveAttemptRepository: PaymentRequestReceiveAttemptRepository; + mintSwap?: MintSwapRepositoryCapability; } export interface Repositories extends RepositoriesBase { diff --git a/packages/core/repositories/memory/MemoryMeltOperationRepository.ts b/packages/core/repositories/memory/MemoryMeltOperationRepository.ts index e237b5cb6..0dba2b79e 100644 --- a/packages/core/repositories/memory/MemoryMeltOperationRepository.ts +++ b/packages/core/repositories/memory/MemoryMeltOperationRepository.ts @@ -1,5 +1,10 @@ import type { MeltOperationRepository } from '..'; import type { MeltOperation, MeltOperationState } from '../../operations/melt/MeltOperation'; +import { + assertParentOwnedMeltOperationInvariant, + assertParentOwnedMeltOperationUpdate, +} from '../../operations/mintSwap/ChildOperationOwnership.ts'; +import { cloneMemoryValue } from './clone.ts'; const getOperationQuoteId = (operation: MeltOperation): string | undefined => 'quoteId' in operation && operation.quoteId ? operation.quoteId : undefined; @@ -8,31 +13,37 @@ export class MemoryMeltOperationRepository implements MeltOperationRepository { private readonly operations = new Map(); async create(operation: MeltOperation): Promise { + assertParentOwnedMeltOperationInvariant(operation); if (this.operations.has(operation.id)) { throw new Error(`MeltOperation with id ${operation.id} already exists`); } this.assertNoDuplicateQuoteOperation(operation); - this.operations.set(operation.id, { ...operation }); + this.assertUniqueParentOwnership(operation); + this.operations.set(operation.id, cloneMemoryValue(operation)); } async update(operation: MeltOperation): Promise { - if (!this.operations.has(operation.id)) { + assertParentOwnedMeltOperationInvariant(operation); + const existing = this.operations.get(operation.id); + if (!existing) { throw new Error(`MeltOperation with id ${operation.id} not found`); } + assertParentOwnedMeltOperationUpdate(existing, operation); this.assertNoDuplicateQuoteOperation(operation); - this.operations.set(operation.id, { ...operation, updatedAt: Date.now() }); + this.assertUniqueParentOwnership(operation); + this.operations.set(operation.id, cloneMemoryValue(operation)); } async getById(id: string): Promise { const operation = this.operations.get(id); - return operation ? { ...operation } : null; + return operation ? cloneMemoryValue(operation) : null; } async getByState(state: MeltOperationState): Promise { const results: MeltOperation[] = []; for (const operation of this.operations.values()) { if (operation.state === state) { - results.push({ ...operation }); + results.push(cloneMemoryValue(operation)); } } return results; @@ -42,7 +53,7 @@ export class MemoryMeltOperationRepository implements MeltOperationRepository { const results: MeltOperation[] = []; for (const operation of this.operations.values()) { if (operation.state === 'executing' || operation.state === 'pending') { - results.push({ ...operation }); + results.push(cloneMemoryValue(operation)); } } return results; @@ -52,7 +63,7 @@ export class MemoryMeltOperationRepository implements MeltOperationRepository { const results: MeltOperation[] = []; for (const operation of this.operations.values()) { if (operation.mintUrl === mintUrl) { - results.push({ ...operation }); + results.push(cloneMemoryValue(operation)); } } return results; @@ -66,17 +77,21 @@ export class MemoryMeltOperationRepository implements MeltOperationRepository { 'quoteId' in operation && operation.quoteId === quoteId ) { - results.push({ ...operation }); + results.push(cloneMemoryValue(operation)); } } return results; } async getAll(): Promise { - return Array.from(this.operations.values(), (operation) => ({ ...operation })); + return Array.from(this.operations.values(), (operation) => cloneMemoryValue(operation)); } async delete(id: string): Promise { + const operation = this.operations.get(id); + if (operation?.parentSwapOperationId) { + throw new Error(`Cannot delete parent-owned MeltOperation ${id}`); + } this.operations.delete(id); } @@ -96,4 +111,18 @@ export class MemoryMeltOperationRepository implements MeltOperationRepository { } } } + + private assertUniqueParentOwnership(operation: MeltOperation): void { + if (!operation.parentSwapOperationId) return; + for (const existing of this.operations.values()) { + if ( + existing.id !== operation.id && + existing.parentSwapOperationId === operation.parentSwapOperationId + ) { + throw new Error( + `Mint swap ${operation.parentSwapOperationId} already owns source MeltOperation ${existing.id}`, + ); + } + } + } } diff --git a/packages/core/repositories/memory/MemoryMintOperationRepository.ts b/packages/core/repositories/memory/MemoryMintOperationRepository.ts index 8b88854c6..30298df46 100644 --- a/packages/core/repositories/memory/MemoryMintOperationRepository.ts +++ b/packages/core/repositories/memory/MemoryMintOperationRepository.ts @@ -1,33 +1,44 @@ import type { MintOperationRepository } from '..'; import type { MintOperation, MintOperationState } from '../../operations/mint/MintOperation'; +import { + assertParentOwnedMintOperationInvariant, + assertParentOwnedMintOperationUpdate, +} from '../../operations/mintSwap/ChildOperationOwnership.ts'; +import { cloneMemoryValue } from './clone.ts'; export class MemoryMintOperationRepository implements MintOperationRepository { private readonly operations = new Map(); async create(operation: MintOperation): Promise { + assertParentOwnedMintOperationInvariant(operation); if (this.operations.has(operation.id)) { throw new Error(`MintOperation with id ${operation.id} already exists`); } - this.operations.set(operation.id, { ...operation }); + this.assertUniqueParentOwnership(operation); + this.operations.set(operation.id, cloneMemoryValue(operation)); } async update(operation: MintOperation): Promise { - if (!this.operations.has(operation.id)) { + assertParentOwnedMintOperationInvariant(operation); + const existing = this.operations.get(operation.id); + if (!existing) { throw new Error(`MintOperation with id ${operation.id} not found`); } - this.operations.set(operation.id, { ...operation, updatedAt: Date.now() }); + assertParentOwnedMintOperationUpdate(existing, operation); + this.assertUniqueParentOwnership(operation); + this.operations.set(operation.id, cloneMemoryValue(operation)); } async getById(id: string): Promise { const operation = this.operations.get(id); - return operation ? { ...operation } : null; + return operation ? cloneMemoryValue(operation) : null; } async getByState(state: MintOperationState): Promise { const results: MintOperation[] = []; for (const operation of this.operations.values()) { if (operation.state === state) { - results.push({ ...operation }); + results.push(cloneMemoryValue(operation)); } } return results; @@ -37,7 +48,7 @@ export class MemoryMintOperationRepository implements MintOperationRepository { const results: MintOperation[] = []; for (const operation of this.operations.values()) { if (operation.state === 'pending' || operation.state === 'executing') { - results.push({ ...operation }); + results.push(cloneMemoryValue(operation)); } } return results; @@ -47,7 +58,7 @@ export class MemoryMintOperationRepository implements MintOperationRepository { const results: MintOperation[] = []; for (const operation of this.operations.values()) { if (operation.mintUrl === mintUrl) { - results.push({ ...operation }); + results.push(cloneMemoryValue(operation)); } } return results; @@ -62,17 +73,35 @@ export class MemoryMintOperationRepository implements MintOperationRepository { 'quoteId' in operation && operation.quoteId === quoteId ) { - results.push({ ...operation }); + results.push(cloneMemoryValue(operation)); } } return results.sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)); } async getAll(): Promise { - return Array.from(this.operations.values(), (operation) => ({ ...operation })); + return Array.from(this.operations.values(), (operation) => cloneMemoryValue(operation)); } async delete(id: string): Promise { + const operation = this.operations.get(id); + if (operation?.parentSwapOperationId) { + throw new Error(`Cannot delete parent-owned MintOperation ${id}`); + } this.operations.delete(id); } + + private assertUniqueParentOwnership(operation: MintOperation): void { + if (!operation.parentSwapOperationId) return; + for (const existing of this.operations.values()) { + if ( + existing.id !== operation.id && + existing.parentSwapOperationId === operation.parentSwapOperationId + ) { + throw new Error( + `Mint swap ${operation.parentSwapOperationId} already owns destination MintOperation ${existing.id}`, + ); + } + } + } } diff --git a/packages/core/repositories/memory/MemoryMintSwapOperationRepository.ts b/packages/core/repositories/memory/MemoryMintSwapOperationRepository.ts new file mode 100644 index 000000000..457e22dcd --- /dev/null +++ b/packages/core/repositories/memory/MemoryMintSwapOperationRepository.ts @@ -0,0 +1,115 @@ +import type { MintSwapOperationRepository } from '..'; +import { + assertMintSwapOperationUpdate, + getMintSwapOperationDueAt, + isMintSwapOperationDue, + 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.assertUniqueChildOwnership(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 { + assertNonNegativeSafeInteger(now, 'Due time'); + assertNonNegativeSafeInteger(limit, 'Due limit'); + return this.sorted((operation) => isMintSwapOperationDue(operation, 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 { + assertNonNegativeSafeInteger(expectedRevision, 'Expected revision'); + const current = this.operations.get(operation.id); + if (!current || current.revision !== expectedRevision) return false; + assertMintSwapOperationUpdate(current, operation); + this.assertUniqueChildOwnership(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 assertUniqueChildOwnership(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 = + (getMintSwapOperationDueAt(left) ?? 0) - (getMintSwapOperationDueAt(right) ?? 0); + if (due !== 0) return due; + } + return left.createdAt - right.createdAt || left.id.localeCompare(right.id); + }) + .map((operation) => cloneMemoryValue(operation)); + } +} + +function assertNonNegativeSafeInteger(value: number, name: string): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${name} must be a non-negative safe integer`); + } +} diff --git a/packages/core/repositories/memory/MemoryOperationEventOutboxRepository.ts b/packages/core/repositories/memory/MemoryOperationEventOutboxRepository.ts new file mode 100644 index 000000000..4e99842d9 --- /dev/null +++ b/packages/core/repositories/memory/MemoryOperationEventOutboxRepository.ts @@ -0,0 +1,86 @@ +import type { OperationEventOutboxRepository } from '..'; +import { + isOperationEventDue, + isOperationEventPublished, + operationEventLogicalKey, + validateOperationEventOutboxRecord, + type OperationEventOutboxRecord, +} from '../../models/OperationEventOutbox'; +import { cloneMemoryValue } from './clone'; + +export class MemoryOperationEventOutboxRepository implements OperationEventOutboxRepository { + private readonly events = new Map(); + + async enqueue(event: OperationEventOutboxRecord): Promise { + validateOperationEventOutboxRecord(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 getById(id: string): Promise { + const event = this.events.get(id); + return event ? cloneMemoryValue(event) : null; + } + + async getUnpublished(limit: number, now = Date.now()): Promise { + assertNonNegativeSafeInteger(limit, 'Outbox limit'); + assertNonNegativeSafeInteger(now, 'Outbox due time'); + return Array.from(this.events.values()) + .filter((event) => isOperationEventDue(event, now)) + .sort( + (left, right) => + (left.nextAttemptAt ?? 0) - (right.nextAttemptAt ?? 0) || + 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 (isOperationEventPublished(event)) return; + const published = { + ...event, + publishedAt, + publishAttempts: event.publishAttempts + 1, + lastError: undefined, + nextAttemptAt: undefined, + }; + validateOperationEventOutboxRecord(published); + this.events.set(id, cloneMemoryValue(published)); + } + + async recordPublishFailure(id: string, nextAttemptAt: number, lastError: string): Promise { + const event = this.requireEvent(id); + if (isOperationEventPublished(event)) return; + const failed = { + ...event, + publishAttempts: event.publishAttempts + 1, + nextAttemptAt, + lastError, + }; + validateOperationEventOutboxRecord(failed); + this.events.set(id, cloneMemoryValue(failed)); + } + + 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 assertNonNegativeSafeInteger(value: number, name: string): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${name} must be a non-negative safe integer`); + } +} diff --git a/packages/core/repositories/memory/MemoryRepositories.ts b/packages/core/repositories/memory/MemoryRepositories.ts index 65b72e67f..9b64af140 100644 --- a/packages/core/repositories/memory/MemoryRepositories.ts +++ b/packages/core/repositories/memory/MemoryRepositories.ts @@ -17,6 +17,7 @@ import type { PaymentRequestReceiveAttemptRepository, PaymentRequestReceiveOperationRepository, ReceiveOperationRepository, + MintSwapRepositoryCapability, } from '..'; import { MemoryAuthSessionRepository } from './MemoryAuthSessionRepository'; import { MemoryCounterRepository } from './MemoryCounterRepository'; @@ -36,6 +37,14 @@ import { MemoryPaymentRequestReceiveAttemptRepository, MemoryPaymentRequestReceiveOperationRepository, } from './MemoryPaymentRequestReceiveRepository'; +import { + applyMemoryRepositoryState, + copyMemoryRepositoryState, + snapshotMemoryRepositoryState, +} from './clone'; +import { MemoryRepositoryCoordinator } from './MemoryRepositoryCoordinator'; +import { MemoryMintSwapOperationRepository } from './MemoryMintSwapOperationRepository'; +import { MemoryOperationEventOutboxRepository } from './MemoryOperationEventOutboxRepository'; export class MemoryRepositories implements Repositories { mintRepository: MintRepository; @@ -54,37 +63,43 @@ export class MemoryRepositories implements Repositories { receiveOperationRepository: ReceiveOperationRepository; paymentRequestReceiveOperationRepository: PaymentRequestReceiveOperationRepository; paymentRequestReceiveAttemptRepository: PaymentRequestReceiveAttemptRepository; + mintSwap: MintSwapRepositoryCapability; - constructor() { - this.mintRepository = new MemoryMintRepository(); - this.keyRingRepository = new MemoryKeyRingRepository(); - this.counterRepository = new MemoryCounterRepository(); - this.keysetRepository = new MemoryKeysetRepository(); - this.proofRepository = new MemoryProofRepository(); - const sendOperationRepository = new MemorySendOperationRepository(); - const meltOperationRepository = new MemoryMeltOperationRepository(); - const mintOperationRepository = new MemoryMintOperationRepository(); - const receiveOperationRepository = new MemoryReceiveOperationRepository(); + private readonly coordinator = new MemoryRepositoryCoordinator(); + private readonly rawScope: RepositoryTransactionScope; - this.sendOperationRepository = sendOperationRepository; - this.meltOperationRepository = meltOperationRepository; - this.mintOperationRepository = mintOperationRepository; - this.receiveOperationRepository = receiveOperationRepository; - this.mintQuoteRepository = new MemoryMintQuoteRepository(); - this.legacyMintQuoteRepository = new MemoryLegacyMintQuoteRepository(); - this.meltQuoteRepository = new MemoryMeltQuoteRepository(); - this.historyRepository = new MemoryHistoryRepository({ - sendOperationRepository, - meltOperationRepository, - mintOperationRepository, - mintQuoteRepository: this.mintQuoteRepository, - receiveOperationRepository, - }); - this.authSessionRepository = new MemoryAuthSessionRepository(); - this.paymentRequestReceiveOperationRepository = - new MemoryPaymentRequestReceiveOperationRepository(); - this.paymentRequestReceiveAttemptRepository = - new MemoryPaymentRequestReceiveAttemptRepository(); + constructor() { + this.rawScope = createMemoryRepositoryScope(); + this.mintRepository = this.coordinator.wrap(this.rawScope.mintRepository); + this.keyRingRepository = this.coordinator.wrap(this.rawScope.keyRingRepository); + this.counterRepository = this.coordinator.wrap(this.rawScope.counterRepository); + this.keysetRepository = this.coordinator.wrap(this.rawScope.keysetRepository); + this.proofRepository = this.coordinator.wrap(this.rawScope.proofRepository); + this.mintQuoteRepository = this.coordinator.wrap(this.rawScope.mintQuoteRepository); + this.legacyMintQuoteRepository = this.coordinator.wrap(this.rawScope.legacyMintQuoteRepository); + this.meltQuoteRepository = this.coordinator.wrap(this.rawScope.meltQuoteRepository); + this.historyRepository = this.coordinator.wrap(this.rawScope.historyRepository); + this.sendOperationRepository = this.coordinator.wrap(this.rawScope.sendOperationRepository); + this.meltOperationRepository = this.coordinator.wrap(this.rawScope.meltOperationRepository); + this.authSessionRepository = this.coordinator.wrap(this.rawScope.authSessionRepository); + this.mintOperationRepository = this.coordinator.wrap(this.rawScope.mintOperationRepository); + this.receiveOperationRepository = this.coordinator.wrap( + this.rawScope.receiveOperationRepository, + ); + this.paymentRequestReceiveOperationRepository = this.coordinator.wrap( + this.rawScope.paymentRequestReceiveOperationRepository, + ); + this.paymentRequestReceiveAttemptRepository = this.coordinator.wrap( + this.rawScope.paymentRequestReceiveAttemptRepository, + ); + const rawMintSwap = this.rawScope.mintSwap; + if (!rawMintSwap) throw new Error('Memory Mint Swap repositories were not initialized'); + this.mintSwap = { + mintSwapOperationRepository: this.coordinator.wrap(rawMintSwap.mintSwapOperationRepository), + operationEventOutboxRepository: this.coordinator.wrap( + rawMintSwap.operationEventOutboxRepository, + ), + }; } async init(): Promise { @@ -92,6 +107,128 @@ export class MemoryRepositories implements Repositories { } async withTransaction(fn: (repos: RepositoryTransactionScope) => Promise): Promise { - return fn(this); + return this.coordinator.runExclusive(async () => { + const staged = createMemoryRepositoryScope(); + copyRepositoryScope(this.rawScope, staged); + const result = await fn(staged); + commitRepositoryScope(staged, this.rawScope); + return result; + }); + } +} + +function commitRepositoryScope( + source: RepositoryTransactionScope, + target: RepositoryTransactionScope, +): void { + const entries = getRepositoryStateEntries(source, target); + // Clone every repository first. A cloning failure cannot leave a partially committed scope. + const prepared = entries.map(({ sourceRepository, targetRepository, excludedKeys }) => ({ + targetRepository, + snapshot: snapshotMemoryRepositoryState(sourceRepository, excludedKeys), + })); + for (const { targetRepository, snapshot } of prepared) { + applyMemoryRepositoryState(targetRepository, snapshot); + } +} + +function createMemoryRepositoryScope(): RepositoryTransactionScope { + const mintRepository = new MemoryMintRepository(); + const keyRingRepository = new MemoryKeyRingRepository(); + const counterRepository = new MemoryCounterRepository(); + const keysetRepository = new MemoryKeysetRepository(); + const proofRepository = new MemoryProofRepository(); + const sendOperationRepository = new MemorySendOperationRepository(); + const meltOperationRepository = new MemoryMeltOperationRepository(); + const mintOperationRepository = new MemoryMintOperationRepository(); + const receiveOperationRepository = new MemoryReceiveOperationRepository(); + const mintQuoteRepository = new MemoryMintQuoteRepository(); + const legacyMintQuoteRepository = new MemoryLegacyMintQuoteRepository(); + const meltQuoteRepository = new MemoryMeltQuoteRepository(); + const historyRepository = new MemoryHistoryRepository({ + sendOperationRepository, + meltOperationRepository, + mintOperationRepository, + mintQuoteRepository, + receiveOperationRepository, + }); + + return { + mintRepository, + keyRingRepository, + counterRepository, + keysetRepository, + proofRepository, + mintQuoteRepository, + legacyMintQuoteRepository, + meltQuoteRepository, + historyRepository, + sendOperationRepository, + meltOperationRepository, + authSessionRepository: new MemoryAuthSessionRepository(), + mintOperationRepository, + receiveOperationRepository, + paymentRequestReceiveOperationRepository: new MemoryPaymentRequestReceiveOperationRepository(), + paymentRequestReceiveAttemptRepository: new MemoryPaymentRequestReceiveAttemptRepository(), + mintSwap: { + mintSwapOperationRepository: new MemoryMintSwapOperationRepository(), + operationEventOutboxRepository: new MemoryOperationEventOutboxRepository(), + }, + }; +} + +function copyRepositoryScope( + source: RepositoryTransactionScope, + target: RepositoryTransactionScope, +): void { + for (const { sourceRepository, targetRepository, excludedKeys } of getRepositoryStateEntries( + source, + target, + )) { + copyMemoryRepositoryState(sourceRepository, targetRepository, excludedKeys); + } +} + +function getRepositoryStateEntries( + source: RepositoryTransactionScope, + target: RepositoryTransactionScope, +): Array<{ sourceRepository: object; targetRepository: object; excludedKeys: readonly string[] }> { + const repositoryKeys: Array> = [ + 'mintRepository', + 'keyRingRepository', + 'counterRepository', + 'keysetRepository', + 'proofRepository', + 'mintQuoteRepository', + 'legacyMintQuoteRepository', + 'meltQuoteRepository', + 'historyRepository', + 'sendOperationRepository', + 'meltOperationRepository', + 'authSessionRepository', + 'mintOperationRepository', + 'receiveOperationRepository', + 'paymentRequestReceiveOperationRepository', + 'paymentRequestReceiveAttemptRepository', + ]; + if (!source.mintSwap || !target.mintSwap) { + throw new Error('Memory Mint Swap repository capability is missing'); } + return [ + ...repositoryKeys.map((key) => ({ + sourceRepository: source[key], + targetRepository: target[key], + excludedKeys: key === 'historyRepository' ? ['operationRepositories'] : [], + })), + { + sourceRepository: source.mintSwap.mintSwapOperationRepository, + targetRepository: target.mintSwap.mintSwapOperationRepository, + excludedKeys: [], + }, + { + sourceRepository: source.mintSwap.operationEventOutboxRepository, + targetRepository: target.mintSwap.operationEventOutboxRepository, + excludedKeys: [], + }, + ]; } diff --git a/packages/core/repositories/memory/MemoryRepositoryCoordinator.ts b/packages/core/repositories/memory/MemoryRepositoryCoordinator.ts new file mode 100644 index 000000000..43db8cf3d --- /dev/null +++ b/packages/core/repositories/memory/MemoryRepositoryCoordinator.ts @@ -0,0 +1,37 @@ +/** + * Serializes access to the root in-memory repositories while transactions operate on + * isolated staged repositories. This prevents a transaction commit or rollback from + * clobbering a root write that raced with the transaction. + */ +export class MemoryRepositoryCoordinator { + private tail: Promise = Promise.resolve(); + + async runExclusive(fn: () => Promise): Promise { + let release!: () => void; + const previous = this.tail; + this.tail = new Promise((resolve) => { + release = resolve; + }); + await previous; + + try { + return await fn(); + } finally { + release(); + } + } + + wrap(repository: T): T { + const coordinator = this; + return new Proxy(repository, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if (typeof value !== 'function') return value; + return (...args: unknown[]) => + coordinator.runExclusive(() => + Promise.resolve(Reflect.apply(value, target, args) as unknown), + ); + }, + }) as T; + } +} diff --git a/packages/core/repositories/memory/clone.ts b/packages/core/repositories/memory/clone.ts new file mode 100644 index 000000000..ae3b9b12d --- /dev/null +++ b/packages/core/repositories/memory/clone.ts @@ -0,0 +1,87 @@ +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; + if (value instanceof ArrayBuffer) { + const result = value.slice(0); + seen.set(value, result); + return result as T; + } + if (ArrayBuffer.isView(value)) { + const buffer = cloneMemoryValue(value.buffer as ArrayBuffer, seen); + const result = + value instanceof DataView + ? new DataView(buffer, value.byteOffset, value.byteLength) + : new (value.constructor as new ( + buffer: ArrayBuffer, + byteOffset: number, + length: number, + ) => ArrayBufferView)( + buffer, + value.byteOffset, + (value as unknown as { length: number }).length, + ); + seen.set(value, result); + return result 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 { + applyMemoryRepositoryState(target, snapshotMemoryRepositoryState(source, excludedKeys)); +} + +export function snapshotMemoryRepositoryState( + source: object, + excludedKeys: readonly string[] = [], +): Record { + const excluded = new Set(excludedKeys); + const sourceRecord = source as Record; + const snapshot: Record = {}; + for (const key of Object.keys(sourceRecord)) { + if (!excluded.has(key)) snapshot[key] = cloneMemoryValue(sourceRecord[key]); + } + return snapshot; +} + +export function applyMemoryRepositoryState( + target: object, + snapshot: Readonly>, +): void { + const targetRecord = target as Record; + for (const [key, value] of Object.entries(snapshot)) targetRecord[key] = value; +} diff --git a/packages/core/repositories/memory/index.ts b/packages/core/repositories/memory/index.ts index f959a0f6b..d10a23310 100644 --- a/packages/core/repositories/memory/index.ts +++ b/packages/core/repositories/memory/index.ts @@ -15,3 +15,5 @@ export * from './MemoryMeltQuoteRepository'; export * from './MemoryMintOperationRepository'; export * from './MemoryReceiveOperationRepository'; export * from './MemoryPaymentRequestReceiveRepository'; +export * from './MemoryMintSwapOperationRepository'; +export * from './MemoryOperationEventOutboxRepository'; diff --git a/packages/core/services/ProofService.ts b/packages/core/services/ProofService.ts index 2ca742374..35b2108b2 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,26 @@ export class ProofService { this.outputDataCreator = outputDataCreator ?? OutputData; } + /** + * Bind local proof and counter writes to a repository transaction. + * + * Events are intentionally suppressed: a composing parent publishes only after its complete + * transaction, including child and parent state, has committed. + */ + 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. @@ -218,10 +239,13 @@ export class ProofService { unit, }); const feeAmount = sendAmount.subtract(requestedSend); - // Adjust keep amount: if send increases due to fees, keep decreases - keepAmount = requestedKeep.greaterThanOrEqual(feeAmount) - ? requestedKeep.subtract(feeAmount) - : Amount.zero(); + if (requestedKeep.lessThan(feeAmount)) { + throw new ProofValidationError( + 'Keep amount cannot cover the receiver fee added to send outputs', + ); + } + // Preserve value by moving the receiver fee from keep to send. + keepAmount = requestedKeep.subtract(feeAmount); this.logger?.debug('Fee calculation for send amount', { mintUrl, unit, @@ -942,6 +966,32 @@ export class ProofService { changeSignatures: SerializedBlindedSignature[], options: { unit: string; createdByOperationId?: string }, ): Promise { + const proofs = await this.unblindChangeProofs(mintUrl, outputData, changeSignatures, options); + const coreProofs = mapProofToCoreProof(mintUrl, 'ready', proofs, { + unit: options.unit, + createdByOperationId: options.createdByOperationId, + }); + await this.saveProofs(mintUrl, coreProofs); + + this.logger?.info('Change proofs unblinded and saved', { + mintUrl, + unit: normalizeUnit(options.unit), + count: coreProofs.length, + operationId: options.createdByOperationId, + }); + return coreProofs; + } + + /** + * Unblind all returned change signatures without writing repositories. + * Callers may perform this outside a transaction and persist the validated result atomically. + */ + async unblindChangeProofs( + mintUrl: string, + outputData: OutputDataLike[], + changeSignatures: SerializedBlindedSignature[], + options: { unit: string; createdByOperationId?: string }, + ): Promise { if (!mintUrl || mintUrl.trim().length === 0) { throw new ProofValidationError('mintUrl is required'); } @@ -960,46 +1010,29 @@ export class ProofService { keysetMap[ks.id] = ks; }); + if (changeSignatures.length > outputData.length) { + throw new ProofValidationError('Mint returned more change signatures than prepared outputs'); + } + // Slice output data to match signature count const matchedOutputs = outputData.slice(0, changeSignatures.length); // Unblind each signature to create proofs - const proofs: Proof[] = matchedOutputs.flatMap((output, i) => { + const proofs: Proof[] = matchedOutputs.map((output, i) => { const sig = changeSignatures[i]; const keyset = keysetMap[output.blindedMessage.id]; if (!sig || !keyset) { const reason = !sig ? 'missing signature' : 'missing keyset'; - this.logger?.warn('Failed to create change proof', { reason, index: i }); - return []; + throw new ProofValidationError(`Failed to create change proof: ${reason} at index ${i}`); } assertSameUnit( normalizeUnit(keyset.unit, { defaultUnit: DEFAULT_UNIT }), unit, 'Change proof keyset', ); - return [output.toProof(sig, { id: keyset.id, keys: keyset.keypairs as Keys })]; - }); - - if (proofs.length === 0) { - return []; - } - - // Map to CoreProof and save - const coreProofs = mapProofToCoreProof(mintUrl, 'ready', proofs, { - unit, - createdByOperationId: options?.createdByOperationId, + return output.toProof(sig, { id: keyset.id, keys: keyset.keypairs as Keys }); }); - - await this.saveProofs(mintUrl, coreProofs); - - this.logger?.info('Change proofs unblinded and saved', { - mintUrl, - unit, - count: coreProofs.length, - operationId: options?.createdByOperationId, - }); - - return coreProofs; + return proofs; } /** diff --git a/packages/core/test/fixtures/MintSwap.ts b/packages/core/test/fixtures/MintSwap.ts new file mode 100644 index 000000000..8ddcf4597 --- /dev/null +++ b/packages/core/test/fixtures/MintSwap.ts @@ -0,0 +1,175 @@ +import { Amount } from '@cashu/cashu-ts'; + +import { + createMintSwapPreparedPlanFingerprint, + type MintSwapOperation, +} from '../../operations/mintSwap/MintSwapOperation'; +import type { OperationEventOutboxRecord } from '../../models/OperationEventOutbox'; + +export const MINT_SWAP_TEST_NOW = 1_700_000_000_000; + +const destinationNut20Key = { + publicKey: `02${'00'.repeat(32)}`, + derivationIndex: 7, +} as const; + +export function makePreparingMintSwapOperation( + overrides: Partial = {}, +): MintSwapOperation { + return { + id: 'mint-swap-op', + state: 'preparing', + revision: 0, + sourceMintUrl: 'https://source.mint.test', + destinationMintUrl: 'https://destination.mint.test', + unit: 'sat', + destinationAmount: Amount.from(1_000), + destinationNut20Key: { ...destinationNut20Key }, + preparationLease: { + ownerId: 'worker-a', + token: 'lease-token-a', + stage: 'destination_quote', + acquiredAt: MINT_SWAP_TEST_NOW, + expiresAt: MINT_SWAP_TEST_NOW + 30_000, + }, + retry: { attemptCount: 0 }, + createdAt: MINT_SWAP_TEST_NOW, + updatedAt: MINT_SWAP_TEST_NOW, + ...overrides, + }; +} + +export function makePreparedMintSwapOperation( + overrides: Partial = {}, +): MintSwapOperation { + const destinationAmount = Amount.from(1_000); + const sourcePreparationFee = Amount.from(2); + const sourceMeltInputFee = Amount.from(3); + const sourceFeeReserve = Amount.from(20); + const minimumSourceDebit = Amount.from(1_005); + const maximumSourceDebit = Amount.from(1_025); + const reservedSourceAmount = Amount.from(1_040); + const destinationQuoteRef = { + mintUrl: 'https://destination.mint.test', + method: 'bolt11' as const, + quoteId: 'destination-quote', + }; + const sourceQuoteRef = { + mintUrl: 'https://source.mint.test', + method: 'bolt11' as const, + quoteId: 'source-quote', + }; + const fingerprint = createMintSwapPreparedPlanFingerprint({ + destinationMintOperationId: 'destination-mint-op', + sourceMeltOperationId: 'source-melt-op', + destinationQuoteRef, + sourceQuoteRef, + destinationNut20Key, + destinationAmount, + unit: 'sat', + sourceInputProofSecrets: ['source-proof-a', 'source-proof-b'], + destinationOutputData: { + keep: [ + { + blindedMessage: { amount: '1000', id: 'destination-keyset', B_: 'destination-B' }, + blindingFactor: '01', + secret: '64657374696e6174696f6e2d6f7574707574', + }, + ], + send: [], + }, + sourceOutputData: { + keep: [], + send: [ + { + blindedMessage: { amount: '1025', id: 'source-keyset', B_: 'source-B' }, + blindingFactor: '02', + secret: '736f757263652d6f7574707574', + }, + ], + }, + sourceMeltAmount: destinationAmount, + sourceFeeReserve, + sourcePreparationFee, + sourceMeltInputFee, + minimumSourceDebit, + maximumSourceDebit, + reservedSourceAmount, + dispatchDeadlineSeconds: Math.floor(MINT_SWAP_TEST_NOW / 1_000) + 120, + requiredDispatchWindowSeconds: 120, + }); + + return { + id: 'mint-swap-op', + state: 'prepared', + revision: 1, + sourceMintUrl: 'https://source.mint.test', + destinationMintUrl: 'https://destination.mint.test', + unit: 'sat', + destinationAmount, + destinationNut20Key: { ...destinationNut20Key }, + destinationQuoteRef, + destinationMintOperationId: 'destination-mint-op', + sourceQuoteRef, + sourceMeltOperationId: 'source-melt-op', + preparedPlan: { + fingerprint, + dispatchDeadlineSeconds: Math.floor(MINT_SWAP_TEST_NOW / 1_000) + 120, + requiredDispatchWindowSeconds: 120, + sourceMeltAmount: destinationAmount, + sourceFeeReserve, + sourcePreparationFee, + sourceMeltInputFee, + minimumSourceDebit, + maximumSourceDebit, + reservedSourceAmount, + }, + retry: { attemptCount: 0 }, + createdAt: MINT_SWAP_TEST_NOW, + updatedAt: MINT_SWAP_TEST_NOW + 1, + ...overrides, + }; +} + +export function makeSettledMintSwapOperation( + overrides: Partial = {}, +): MintSwapOperation { + return makePreparedMintSwapOperation({ + state: 'destination_funded', + revision: 2, + sourceDispatchAuthorizedAt: MINT_SWAP_TEST_NOW + 2, + settlement: { + sourcePaymentFee: Amount.from(5), + totalSourceFee: Amount.from(10), + sourceMeltChangeAmount: Amount.from(20), + sourceKeepAmount: Amount.from(10), + sourceReturnedAmount: Amount.from(30), + finalSourceDebit: Amount.from(1_010), + }, + updatedAt: MINT_SWAP_TEST_NOW + 3, + ...overrides, + }); +} + +export function makeMintSwapOutboxRecord( + 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: '1000', + }, + createdAt: MINT_SWAP_TEST_NOW + 1, + publishAttempts: 0, + ...overrides, + }; +} diff --git a/packages/core/test/unit/MeltBolt11Handler.test.ts b/packages/core/test/unit/MeltBolt11Handler.test.ts index 1451fca45..5f6b11e8d 100644 --- a/packages/core/test/unit/MeltBolt11Handler.test.ts +++ b/packages/core/test/unit/MeltBolt11Handler.test.ts @@ -212,6 +212,7 @@ describe('MeltBolt11Handler', () => { // Mock ProofRepository proofRepository = { getProofsByOperationId: mock(() => Promise.resolve([])), + getProofsBySecrets: mock(() => Promise.resolve([])), } as unknown as ProofRepository; // Mock ProofService @@ -1483,6 +1484,73 @@ describe('MeltBolt11Handler', () => { // Edge Cases // ============================================================================ + describe('parent-owned remote phases', () => { + it('separates pre-swap network execution from transactional result application', async () => { + const operation = makeExecutingOp('owned-pre-swap', { + parentSwapOperationId: 'mint-swap-parent', + parentExecutionPhase: 'pre_swap_authorized', + needsSwap: true, + inputProofSecrets: ['input-1'], + swapOutputData: createMockOutputData(['keep-1'], ['send-1']), + }); + (mockWallet.send as Mock).mockResolvedValueOnce({ + keep: [makeProof('keep-1', 10)], + send: [makeProof('send-1', 10)], + }); + + const remoteResult = await handler.executeOwnedRemote!({ + operation, + wallet: mockWallet, + mintAdapter, + proofs: [makeProof('input-1', 110)], + logger, + }); + + expect(remoteResult).toMatchObject({ + operationId: operation.id, + phase: 'pre_swap', + }); + expect(mockWallet.send).toHaveBeenCalledTimes(1); + expect(mintAdapter.customMeltBolt11).not.toHaveBeenCalled(); + expect(proofService.setProofState).not.toHaveBeenCalled(); + expect(proofService.saveProofs).not.toHaveBeenCalled(); + + const applied = await handler.applyOwnedRemote!( + { + operation, + proofRepository, + proofService, + logger, + }, + remoteResult, + ); + + expect('status' in applied).toBe(false); + if ('status' in applied) throw new Error('Expected an executing melt child'); + expect(applied.parentExecutionPhase).toBe('melt_authorized'); + expect(proofService.setProofState).toHaveBeenCalledWith( + mintUrl, + operation.inputProofSecrets, + 'spent', + ); + expect(proofService.saveProofs).toHaveBeenCalledWith( + mintUrl, + expect.arrayContaining([ + expect.objectContaining({ + secret: 'keep-1', + state: 'ready', + createdByOperationId: operation.id, + }), + expect.objectContaining({ + secret: 'send-1', + state: 'inflight', + createdByOperationId: operation.id, + }), + ]), + ); + }); + }); + describe('edge cases', () => { it('should throw if input proofs count does not match', async () => { const operation = makeExecutingOp('op-1', { diff --git a/packages/core/test/unit/MeltOperationService.test.ts b/packages/core/test/unit/MeltOperationService.test.ts index e165d11ed..21babc6bd 100644 --- a/packages/core/test/unit/MeltOperationService.test.ts +++ b/packages/core/test/unit/MeltOperationService.test.ts @@ -4,6 +4,7 @@ import { MeltOperationService } from '../../operations/melt/MeltOperationService import { MemoryMeltOperationRepository } from '../../repositories/memory/MemoryMeltOperationRepository.ts'; import { MemoryMeltQuoteRepository } from '../../repositories/memory/MemoryMeltQuoteRepository.ts'; import { MemoryProofRepository } from '../../repositories/memory/MemoryProofRepository.ts'; +import { MemoryRepositories } from '../../repositories/memory/MemoryRepositories.ts'; import { EventBus } from '../../events/EventBus.ts'; import type { CoreEvents } from '../../events/types.ts'; import type { ProofService } from '../../services/ProofService.ts'; @@ -37,6 +38,7 @@ import { UnknownMintError, ProofValidationError, OperationInProgressError, + ParentOwnedOperationError, QuoteIdentityConflictError, } from '../../models/Error.ts'; @@ -259,6 +261,26 @@ describe('MeltOperationService', () => { state: 'pending', } as PendingMeltOperation, })), + executeOwnedRemote: mock(async ({ operation }) => ({ + operationId: operation.id, + phase: 'melt', + response: { state: 'PENDING' }, + })), + applyOwnedRemote: mock(async ({ operation }, result) => + result.phase === 'pre_swap' + ? { + ...operation, + parentExecutionPhase: 'melt_authorized', + updatedAt: Date.now(), + } + : { + status: 'PENDING', + pending: { + ...operation, + state: 'pending', + }, + }, + ), } as MeltMethodHandler; handlerProvider = { @@ -267,6 +289,40 @@ describe('MeltOperationService', () => { proofService = { releaseProofs: mock(async () => {}), + selectProofsToSend: mock(async () => [makeProof('planned-input')]), + createBlankOutputs: mock(async () => []), + createOutputsAndIncrementCounters: mock(async () => ({ + keep: [], + send: [], + keepAmount: Amount.zero(), + sendAmount: Amount.zero(), + })), + forTransaction: mock((repositories) => ({ + setProofState: mock( + async ( + proofMintUrl: string, + secrets: string[], + state: 'inflight' | 'ready' | 'spent', + ) => { + await repositories.proofRepository.setProofState(proofMintUrl, secrets, state); + }, + ), + saveProofs: mock(async (proofMintUrl: string, proofs: CoreProof[]) => { + await repositories.proofRepository.saveProofs(proofMintUrl, proofs); + }), + restoreProofsToReady: mock(async (proofMintUrl: string, secrets: string[]) => { + await repositories.proofRepository.setProofState(proofMintUrl, secrets, 'ready'); + }), + reserveProofs: mock( + async (proofMintUrl: string, secrets: string[], operationId: string) => { + await repositories.proofRepository.reserveProofs(proofMintUrl, secrets, operationId); + return { amount: Amount.from(secrets.length), unit: 'sat' }; + }, + ), + releaseProofs: mock(async (proofMintUrl: string, secrets: string[]) => { + await repositories.proofRepository.releaseProofs(proofMintUrl, secrets); + }), + })), } as unknown as ProofService; mintService = { @@ -370,6 +426,411 @@ describe('MeltOperationService', () => { }); }); + describe('parent-owned orchestration commands', () => { + const parentSwapOperationId = 'mint-swap-parent'; + + it('keeps standalone execution from advancing a parent-owned child', async () => { + const operation = makePreparedOp('owned-direct-execute', { + parentSwapOperationId, + }); + await meltOperationRepository.create(operation); + + await expect(service.execute(operation.id)).rejects.toBeInstanceOf(ParentOwnedOperationError); + + expect((await meltOperationRepository.getById(operation.id))?.state).toBe('prepared'); + expect(handler.execute).not.toHaveBeenCalled(); + }); + + it('plans outside the transaction and reserves source proofs only while persisting', async () => { + const repositories = new MemoryRepositories(); + const quote = meltQuoteFromBolt11Response(mintUrl, { + quote: 'owned-plan-quote', + request: invoice, + amount: Amount.from(100), + unit: 'sat', + fee_reserve: Amount.from(1), + expiry: Math.floor(Date.now() / 1_000) + 3_600, + state: 'UNPAID', + payment_preimage: null, + }); + const plannedInput = makeProof('planned-input', { amount: Amount.from(101) }); + await repositories.proofRepository.saveProofs(mintUrl, [plannedInput]); + const ownedService = new MeltOperationService( + handlerProvider, + repositories.meltOperationRepository, + quoteLifecycle, + repositories.proofRepository, + proofService, + mintService, + walletService, + mintAdapter, + eventBus, + logger, + ); + (handler.prepare as Mock).mockImplementationOnce(async ({ operation }: any) => + makePreparedOp(operation.id, { + mintUrl, + quoteId: quote.quoteId, + parentSwapOperationId, + inputAmount: plannedInput.amount, + inputProofSecrets: [plannedInput.secret], + }), + ); + + const planned = await ownedService.planOwnedPreparation({ + operationId: 'owned-planned-source', + parentSwapOperationId, + quote, + wallet: {} as never, + }); + expect( + (await repositories.proofRepository.getProofBySecret(mintUrl, plannedInput.secret)) + ?.usedByOperationId, + ).toBe(undefined); + + await repositories.withTransaction((transaction) => + ownedService.prepareOwnedInTransaction({ + operationId: planned.id, + parentSwapOperationId, + quote, + preparedOperation: planned, + repositories: transaction, + }), + ); + + expect( + (await repositories.proofRepository.getProofBySecret(mintUrl, plannedInput.secret)) + ?.usedByOperationId, + ).toBe(planned.id); + expect((await repositories.meltOperationRepository.getById(planned.id))?.state).toBe( + 'prepared', + ); + }); + + it('persists each authorization checkpoint before starting its repository-free remote phase', async () => { + const repositories = new MemoryRepositories(); + const operation = makePreparedOp('owned-pre-swap-checkpoint', { + parentSwapOperationId, + needsSwap: true, + inputProofSecrets: ['owned-input'], + swapOutputData: { + keep: [], + send: [ + { + blindedMessage: { amount: 101, id: keysetId, B_: 'swap-send-B' }, + blindingFactor: '01', + secret: '737761702d73656e64', + }, + ], + }, + }); + await repositories.proofRepository.saveProofs(mintUrl, [ + makeProof('owned-input', { amount: Amount.from(101) }), + ]); + await repositories.proofRepository.reserveProofs( + mintUrl, + operation.inputProofSecrets, + operation.id, + ); + await repositories.meltOperationRepository.create(operation); + const ownedService = new MeltOperationService( + handlerProvider, + repositories.meltOperationRepository, + quoteLifecycle, + repositories.proofRepository, + proofService, + mintService, + walletService, + mintAdapter, + eventBus, + logger, + ); + let authorizationTransactionReturned = false; + let applyTransactionReturned = false; + + const authorized = await repositories.withTransaction((transaction) => + ownedService.authorizeOwnedExecutionInTransaction( + operation.id, + parentSwapOperationId, + transaction, + ), + ); + authorizationTransactionReturned = true; + + expect((await repositories.meltOperationRepository.getById(operation.id))?.state).toBe( + 'executing', + ); + expect(authorized.parentExecutionPhase).toBe('pre_swap_authorized'); + expect(handler.executeOwnedRemote).not.toHaveBeenCalled(); + + (handler.executeOwnedRemote as Mock).mockImplementationOnce( + async (context: Record) => { + expect(authorizationTransactionReturned).toBe(true); + expect('proofRepository' in context).toBe(false); + expect('proofService' in context).toBe(false); + expect('meltOperationRepository' in context).toBe(false); + expect( + (await repositories.meltOperationRepository.getById(operation.id)) + ?.parentExecutionPhase, + ).toBe('pre_swap_authorized'); + return { + operationId: operation.id, + phase: 'pre_swap', + keepProofs: [], + sendProofs: [makeProof('swap-send', { amount: Amount.from(101) })], + }; + }, + ); + + (handler.applyOwnedRemote as Mock).mockImplementationOnce( + async (context: any, result: any) => { + const { operation: current, proofService: scopedProofService } = context; + await scopedProofService.setProofState( + current.mintUrl, + current.inputProofSecrets, + 'spent', + ); + await scopedProofService.saveProofs(current.mintUrl, [ + { + ...result.sendProofs[0], + mintUrl: current.mintUrl, + unit: current.unit, + state: 'inflight', + createdByOperationId: current.id, + }, + ]); + return { + ...current, + parentExecutionPhase: 'melt_authorized', + updatedAt: Date.now(), + }; + }, + ); + + const preSwapResult = await ownedService.executeOwnedRemoteStep( + authorized, + parentSwapOperationId, + ); + const meltAuthorized = await repositories.withTransaction((transaction) => + ownedService.applyOwnedRemoteStepInTransaction( + authorized, + parentSwapOperationId, + preSwapResult, + transaction, + ), + ); + applyTransactionReturned = true; + + expect(meltAuthorized.state).toBe('executing'); + expect( + meltAuthorized.state === 'executing' ? meltAuthorized.parentExecutionPhase : undefined, + ).toBe('melt_authorized'); + expect( + (await repositories.meltOperationRepository.getById(operation.id))?.parentExecutionPhase, + ).toBe('melt_authorized'); + await expect( + repositories.meltOperationRepository.update({ + ...(meltAuthorized as ExecutingMeltOperation), + parentExecutionPhase: 'pre_swap_authorized', + updatedAt: Date.now() + 1, + }), + ).rejects.toThrow('authorization cannot regress'); + + (handler.executeOwnedRemote as Mock).mockImplementationOnce( + async ({ operation: remoteOperation, ...context }: Record) => { + expect(applyTransactionReturned).toBe(true); + expect(remoteOperation.parentExecutionPhase).toBe('melt_authorized'); + expect('proofRepository' in context).toBe(false); + expect('proofService' in context).toBe(false); + return { + operationId: operation.id, + phase: 'melt', + response: { state: 'PENDING' }, + }; + }, + ); + + await ownedService.executeOwnedRemoteStep( + meltAuthorized as ExecutingMeltOperation, + parentSwapOperationId, + ); + }); + + it('persists the canonical source observation before advancing the owned child', async () => { + const repositories = new MemoryRepositories(); + const operation = { + ...makePreparedOp('owned-canonical-observation', { parentSwapOperationId }), + state: 'executing' as const, + parentExecutionPhase: 'melt_authorized' as const, + }; + await repositories.meltOperationRepository.create(operation); + await repositories.meltQuoteRepository.upsertMeltQuote( + meltQuoteFromBolt11Response(mintUrl, { + quote: operation.quoteId, + request: invoice, + amount: operation.amount, + unit: operation.unit, + fee_reserve: operation.fee_reserve, + expiry: Math.floor(Date.now() / 1_000) + 3_600, + state: 'UNPAID', + payment_preimage: null, + }), + ); + const ownedService = new MeltOperationService( + handlerProvider, + repositories.meltOperationRepository, + quoteLifecycle, + repositories.proofRepository, + proofService, + mintService, + walletService, + mintAdapter, + eventBus, + logger, + ); + const observedAt = Date.now() + 1; + + const applied = await repositories.withTransaction((transaction) => + ownedService.applyOwnedRemoteStepInTransaction( + operation.id, + parentSwapOperationId, + { + operationId: operation.id, + phase: 'melt', + observedAt, + response: { state: 'PENDING' }, + }, + transaction, + ), + ); + + expect(applied.state).toBe('pending'); + const quote = await repositories.meltQuoteRepository.getMeltQuote( + mintUrl, + 'bolt11', + operation.quoteId, + ); + expect(quote?.state).toBe('PENDING'); + expect(quote?.lastObservedRemoteStateAt).toBe(observedAt); + }); + + it('does not let a stale owned result downgrade a canonical paid quote', async () => { + const repositories = new MemoryRepositories(); + const operation = { + ...makePreparedOp('owned-stale-observation', { parentSwapOperationId }), + state: 'executing' as const, + parentExecutionPhase: 'melt_authorized' as const, + }; + await repositories.meltOperationRepository.create(operation); + await repositories.meltQuoteRepository.upsertMeltQuote( + meltQuoteFromBolt11Response(mintUrl, { + quote: operation.quoteId, + request: invoice, + amount: operation.amount, + unit: operation.unit, + fee_reserve: operation.fee_reserve, + expiry: Math.floor(Date.now() / 1_000) + 3_600, + state: 'PAID', + payment_preimage: 'paid-preimage', + change: [], + }), + ); + const ownedService = new MeltOperationService( + handlerProvider, + repositories.meltOperationRepository, + quoteLifecycle, + repositories.proofRepository, + proofService, + mintService, + walletService, + mintAdapter, + eventBus, + logger, + ); + + await expect( + repositories.withTransaction((transaction) => + ownedService.applyOwnedRemoteStepInTransaction( + operation.id, + parentSwapOperationId, + { + operationId: operation.id, + phase: 'melt', + observedAt: Date.now() + 1, + response: { state: 'UNPAID' }, + }, + transaction, + ), + ), + ).rejects.toThrow('conflicts with canonical PAID'); + expect((await repositories.meltOperationRepository.getById(operation.id))?.state).toBe( + 'executing', + ); + expect( + (await repositories.meltQuoteRepository.getMeltQuote(mintUrl, 'bolt11', operation.quoteId)) + ?.state, + ).toBe('PAID'); + }); + + it('rejects paid owned results that contradict canonical settlement evidence', async () => { + const repositories = new MemoryRepositories(); + const operation = { + ...makePreparedOp('owned-conflicting-settlement', { parentSwapOperationId }), + state: 'executing' as const, + parentExecutionPhase: 'melt_authorized' as const, + }; + await repositories.meltOperationRepository.create(operation); + await repositories.meltQuoteRepository.upsertMeltQuote( + meltQuoteFromBolt11Response(mintUrl, { + quote: operation.quoteId, + request: invoice, + amount: operation.amount, + unit: operation.unit, + fee_reserve: operation.fee_reserve, + expiry: Math.floor(Date.now() / 1_000) + 3_600, + state: 'PAID', + payment_preimage: 'canonical-preimage', + change: [], + }), + ); + const ownedService = new MeltOperationService( + handlerProvider, + repositories.meltOperationRepository, + quoteLifecycle, + repositories.proofRepository, + proofService, + mintService, + walletService, + mintAdapter, + eventBus, + logger, + ); + + await expect( + repositories.withTransaction((transaction) => + ownedService.applyOwnedRemoteStepInTransaction( + operation.id, + parentSwapOperationId, + { + operationId: operation.id, + phase: 'melt', + observedAt: Date.now() + 1, + response: { + state: 'PAID', + change: [], + payment_preimage: 'conflicting-preimage', + }, + }, + transaction, + ), + ), + ).rejects.toThrow('preimage conflicts with canonical quote'); + expect((await repositories.meltOperationRepository.getById(operation.id))?.state).toBe( + 'executing', + ); + }); + }); + describe('quotes', () => { it('creates and persists a canonical melt quote without creating an operation', async () => { const events: Array = []; diff --git a/packages/core/test/unit/MemoryMintSwapRepositories.test.ts b/packages/core/test/unit/MemoryMintSwapRepositories.test.ts new file mode 100644 index 000000000..71ce80cf6 --- /dev/null +++ b/packages/core/test/unit/MemoryMintSwapRepositories.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'bun:test'; +import { + runMintSwapCapabilityAbsenceContract, + runMintSwapRepositoryContract, + runRepositoryTransactionContract, +} from '@cashu/coco-adapter-tests'; + +import type { Repositories, RepositoryTransactionScope } from '../../repositories'; +import { MemoryRepositories } from '../../repositories/memory/MemoryRepositories'; + +async function createRepositories() { + return { + repositories: new MemoryRepositories(), + dispose: async () => {}, + }; +} + +async function createRepositoriesWithoutMintSwap() { + const memory = new MemoryRepositories(); + const repositories = new Proxy(memory, { + get(target, property, receiver) { + if (property === 'mintSwap') return undefined; + if (property === 'withTransaction') { + return (fn: (scope: RepositoryTransactionScope) => Promise) => + target.withTransaction((scope) => fn(hideMintSwapCapability(scope))); + } + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }) as Repositories; + return { + repositories, + dispose: async () => {}, + }; +} + +function hideMintSwapCapability(scope: RepositoryTransactionScope): RepositoryTransactionScope { + return new Proxy(scope, { + get(target, property, receiver) { + if (property === 'mintSwap') return undefined; + return Reflect.get(target, property, receiver); + }, + }); +} + +runRepositoryTransactionContract( + { + createRepositories, + testConcurrentRootOperationIsolation: true, + }, + { describe, it, expect }, +); + +runMintSwapRepositoryContract({ createRepositories }, { describe, it, expect }); + +runMintSwapCapabilityAbsenceContract( + { createRepositories: createRepositoriesWithoutMintSwap }, + { describe, it, expect }, +); diff --git a/packages/core/test/unit/MintOperationService.test.ts b/packages/core/test/unit/MintOperationService.test.ts index a7ca45687..4883c4877 100644 --- a/packages/core/test/unit/MintOperationService.test.ts +++ b/packages/core/test/unit/MintOperationService.test.ts @@ -18,6 +18,7 @@ import type { PendingMintOperation, } from '../../operations/mint/MintOperation'; import type { + ExecuteContext, MintExecutionResult, MintMethodHandler, MintMethodQuoteImportSnapshot, @@ -29,6 +30,7 @@ import type { MintHandlerProvider } from '../../infra/handlers/mint'; import { MemoryMintOperationRepository } from '../../repositories/memory/MemoryMintOperationRepository'; import { MemoryMintQuoteRepository } from '../../repositories/memory/MemoryMintQuoteRepository'; import { MemoryProofRepository } from '../../repositories/memory/MemoryProofRepository'; +import { MemoryRepositories } from '../../repositories/memory/MemoryRepositories'; import { getMintQuoteAvailableAmount } from '../../models/MintQuote'; import { mintQuoteObservationFromOnchainResponse } from '../../models/MintQuoteObservationFactory'; import { @@ -46,12 +48,17 @@ import type { MintAdapter } from '../../infra/MintAdapter'; import type { Logger } from '../../logging/Logger'; import { serializeOutputData } from '../../utils'; import type { CoreProof } from '../../types'; -import { MintQuoteValidationError, QuoteIdentityConflictError } from '../../models/Error'; +import { + MintQuoteValidationError, + ParentOwnedOperationError, + QuoteIdentityConflictError, +} from '../../models/Error'; describe('MintOperationService', () => { const mintUrl = 'https://mint.test'; const quoteId = 'quote-1'; const keysetId = 'keyset-1'; + const destinationNut20PublicKey = `02${'11'.repeat(32)}`; let operationRepo: MemoryMintOperationRepository; let quoteRepo: MemoryMintQuoteRepository; @@ -324,6 +331,7 @@ describe('MintOperationService', () => { ), prepare: mockPrepare, execute: mockExecute, + executeOwnedRemote: mockExecute, recoverExecuting: mockRecoverExecuting, checkPending: mockCheckPending, }; @@ -336,11 +344,18 @@ describe('MintOperationService', () => { saveProofs: mock(async (_mintUrl: string, proofs: CoreProof[]) => { await proofRepo.saveProofs(mintUrl, proofs); }), + forTransaction: mock((repositories) => ({ + saveProofs: mock(async (proofMintUrl: string, proofs: CoreProof[]) => { + await repositories.proofRepository.saveProofs(proofMintUrl, proofs); + }), + })), recoverProofsFromOutputData: mock(async (_mintUrl: string, _outputData, options) => { if (!options?.createdByOperationId) { return []; } - await proofRepo.saveProofs(mintUrl, [toCoreProof('out-1', options.createdByOperationId)]); + if (options.persistRecoveredProofs !== false) { + await proofRepo.saveProofs(mintUrl, [toCoreProof('out-1', options.createdByOperationId)]); + } return [makeProof('out-1')]; }), } as unknown as ProofService; @@ -3220,4 +3235,330 @@ describe('MintOperationService', () => { expect(pendingEvents).toHaveLength(0); expect(handler.checkPending).not.toHaveBeenCalled(); }); + + describe('parent-owned orchestration commands', () => { + const parentSwapOperationId = 'mint-swap-parent'; + + const makeOwnedPending = (id: string, secret = 'owned-output'): PendingMintOperation => ({ + ...makePendingOp(id, secret), + parentSwapOperationId, + pubkey: destinationNut20PublicKey, + }); + + it('binds destination preparation to the parent NUT-20 key', async () => { + const repositories = new MemoryRepositories(); + const quote = mintQuoteFromBolt11Response(mintUrl, { + quote: quoteId, + request: 'lnbc1test', + amount: Amount.from(10), + unit: 'sat', + expiry: Math.floor(Date.now() / 1000) + 3600, + state: 'UNPAID', + pubkey: destinationNut20PublicKey, + }); + const { wallet } = await walletService.getWalletWithActiveKeysetId(mintUrl, 'sat'); + (handler.prepare as Mock).mockImplementationOnce( + async ({ + operation, + importedQuote, + }: { + operation: InitMintOperation; + importedQuote: MintMethodQuoteSnapshot<'bolt11'>; + }) => ({ + ...makePendingOp(operation.id, 'owned-locked-output'), + pubkey: importedQuote.pubkey, + }), + ); + + const planned = await service.planOwnedPreparation({ + operationId: 'owned-locked-destination', + parentSwapOperationId, + quote, + amount: Amount.from(10), + destinationNut20PublicKey, + wallet, + }); + const prepared = await repositories.withTransaction((transaction) => + service.prepareOwnedInTransaction({ + operationId: 'owned-locked-destination', + parentSwapOperationId, + quote, + amount: Amount.from(10), + destinationNut20PublicKey, + preparedOperation: planned, + repositories: transaction, + }), + ); + + expect(prepared.pubkey).toBe(destinationNut20PublicKey); + expect(prepared.parentSwapOperationId).toBe(parentSwapOperationId); + }); + + it('rejects a destination quote that is not locked to the parent NUT-20 key', async () => { + const repositories = new MemoryRepositories(); + const quote = mintQuoteFromBolt11Response(mintUrl, { + quote: quoteId, + request: 'lnbc1test', + amount: Amount.from(10), + unit: 'sat', + expiry: Math.floor(Date.now() / 1000) + 3600, + state: 'UNPAID', + pubkey: `03${'22'.repeat(32)}`, + }); + const { wallet } = await walletService.getWalletWithActiveKeysetId(mintUrl, 'sat'); + + await expect( + service.planOwnedPreparation({ + operationId: 'owned-wrong-destination-key', + parentSwapOperationId, + quote, + amount: Amount.from(10), + destinationNut20PublicKey, + wallet, + }), + ).rejects.toThrow('not locked to the parent NUT-20 key'); + expect(handler.prepare).not.toHaveBeenCalled(); + }); + + it('keeps standalone execution from advancing a parent-owned child or emitting events', async () => { + const operation = makeOwnedPending('owned-direct-execute'); + const executingEvents: Array = []; + eventBus.on('mint-op:executing', (event) => { + executingEvents.push(event); + }); + await operationRepo.create(operation); + + await expect(service.execute(operation.id)).rejects.toBeInstanceOf(ParentOwnedOperationError); + + expect((await operationRepo.getById(operation.id))?.state).toBe('pending'); + expect(handler.execute).not.toHaveBeenCalled(); + expect(executingEvents).toHaveLength(0); + }); + + it('rejects owned commands before mutation when the durable capability is absent', async () => { + const repositories = new MemoryRepositories(); + const operation = makeOwnedPending('owned-capability-gate'); + await repositories.mintOperationRepository.create(operation); + + await expect( + repositories.withTransaction((transaction) => + service.authorizeOwnedExecutionInTransaction(operation.id, parentSwapOperationId, { + ...transaction, + mintSwap: undefined, + }), + ), + ).rejects.toThrow('optional durable repository capability'); + expect((await repositories.mintOperationRepository.getById(operation.id))?.state).toBe( + 'pending', + ); + expect(handler.executeOwnedRemote).not.toHaveBeenCalled(); + }); + + it('commits authorization before network execution and gives the remote phase no repositories', async () => { + const repositories = new MemoryRepositories(); + const operation = makeOwnedPending('owned-authorize-before-remote'); + await repositories.mintOperationRepository.create(operation); + let transactionReturned = false; + + const authorized = await repositories.withTransaction((transaction) => + service.authorizeOwnedExecutionInTransaction( + operation.id, + parentSwapOperationId, + transaction, + ), + ); + transactionReturned = true; + + const persistedAuthorization = await repositories.mintOperationRepository.getById( + operation.id, + ); + await operationRepo.create(authorized); + expect(persistedAuthorization?.state).toBe('executing'); + expect(handler.executeOwnedRemote).not.toHaveBeenCalled(); + + (handler.executeOwnedRemote as Mock).mockImplementationOnce(async (context: object) => { + expect(transactionReturned).toBe(true); + expect('proofRepository' in context).toBe(false); + expect('proofService' in context).toBe(false); + expect('mintOperationRepository' in context).toBe(false); + expect((await repositories.mintOperationRepository.getById(operation.id))?.state).toBe( + 'executing', + ); + return { status: 'ISSUED', proofs: [makeProof('owned-output')] }; + }); + + await service.executeOwnedRemote(authorized, parentSwapOperationId); + + expect(await repositories.mintOperationRepository.getById(operation.id)).toEqual( + persistedAuthorization, + ); + expect(await repositories.proofRepository.getAllReadyProofs()).toHaveLength(0); + }); + + it('requires an explicit repository-free seam from custom mint handlers', async () => { + const operation = { + ...makeExecutingOp('owned-custom-handler', 'owned-custom-output'), + parentSwapOperationId, + pubkey: destinationNut20PublicKey, + }; + const customHandler: MintMethodHandler<'bolt11'> = { + ...handler, + executeOwnedRemote: undefined, + execute: mock(async (context: ExecuteContext<'bolt11'>): Promise => { + void context.proofService; + return { status: 'ISSUED', proofs: [makeProof('owned-custom-output')] }; + }), + }; + await operationRepo.create(operation); + (handlerProvider.get as Mock).mockReturnValueOnce(customHandler); + + await expect(service.executeOwnedRemote(operation, parentSwapOperationId)).rejects.toThrow( + 'does not support owned remote execution', + ); + expect(customHandler.execute).not.toHaveBeenCalled(); + }); + + it('restores already-issued deterministic outputs outside the transaction without saving', async () => { + const operation = { + ...makeExecutingOp('owned-restore-issued', 'restored-output'), + parentSwapOperationId, + pubkey: destinationNut20PublicKey, + }; + await operationRepo.create(operation); + (handler.executeOwnedRemote as Mock).mockResolvedValueOnce({ + status: 'ALREADY_ISSUED', + }); + (proofService.recoverProofsFromOutputData as Mock).mockResolvedValueOnce([ + makeProof('restored-output'), + ]); + + const result = await service.executeOwnedRemote(operation.id, parentSwapOperationId); + + expect(result).toEqual({ + operationId: operation.id, + status: 'ISSUED', + proofs: [makeProof('restored-output')], + }); + expect(proofService.recoverProofsFromOutputData).toHaveBeenCalledWith( + operation.mintUrl, + operation.outputData, + { + unit: operation.unit, + createdByOperationId: operation.id, + persistRecoveredProofs: false, + }, + ); + expect(await proofRepo.getProofBySecret(mintUrl, 'restored-output')).toBeNull(); + }); + + it('atomically rolls back local result application when its composing transaction fails', async () => { + const repositories = new MemoryRepositories(); + const operation = { + ...makeExecutingOp('owned-atomic-apply', 'atomic-output'), + parentSwapOperationId, + pubkey: destinationNut20PublicKey, + }; + await repositories.mintOperationRepository.create(operation); + await repositories.mintQuoteRepository.upsertMintQuote( + mintQuoteFromBolt11Response(mintUrl, { + quote: quoteId, + request: 'lnbc1test', + amount: Amount.from(10), + unit: 'sat', + expiry: Math.floor(Date.now() / 1000) + 3600, + state: 'PAID', + pubkey: destinationNut20PublicKey, + amount_paid: Amount.from(10), + amount_issued: Amount.zero(), + updated_at: 10, + }), + ); + + await expect( + repositories.withTransaction(async (transaction) => { + await service.applyOwnedExecutionInTransaction( + operation, + parentSwapOperationId, + { + operationId: operation.id, + status: 'ISSUED', + proofs: [makeProof('atomic-output')], + }, + transaction, + ); + throw new Error('rollback composing parent transition'); + }), + ).rejects.toThrow('rollback composing parent transition'); + + expect((await repositories.mintOperationRepository.getById(operation.id))?.state).toBe( + 'executing', + ); + expect( + await repositories.proofRepository.getProofBySecret(mintUrl, 'atomic-output'), + ).toBeNull(); + expect( + (await repositories.mintQuoteRepository.getMintQuote(mintUrl, 'bolt11', quoteId))?.state, + ).toBe('PAID'); + }); + + it('reuses a complete deterministic proof set when replaying result application', async () => { + const repositories = new MemoryRepositories(); + const operation = { + ...makeExecutingOp('owned-idempotent-apply', 'existing-output'), + parentSwapOperationId, + pubkey: destinationNut20PublicKey, + }; + await repositories.mintOperationRepository.create(operation); + await repositories.proofRepository.saveProofs(mintUrl, [ + toCoreProof('existing-output', operation.id), + ]); + await repositories.mintQuoteRepository.upsertMintQuote( + mintQuoteFromBolt11Response(mintUrl, { + quote: quoteId, + request: 'lnbc1test', + amount: Amount.from(10), + unit: 'sat', + expiry: Math.floor(Date.now() / 1000) + 3600, + state: 'PAID', + pubkey: destinationNut20PublicKey, + amount_paid: Amount.from(10), + amount_issued: Amount.zero(), + updated_at: 10, + }), + ); + const scopedSaveProofs = mock(async () => {}); + (proofService.forTransaction as Mock).mockImplementationOnce(() => ({ + saveProofs: scopedSaveProofs, + })); + + await repositories.withTransaction((transaction) => + service.applyOwnedExecutionInTransaction( + operation, + parentSwapOperationId, + { + operationId: operation.id, + status: 'ISSUED', + proofs: [makeProof('existing-output')], + }, + transaction, + ), + ); + + expect(scopedSaveProofs).not.toHaveBeenCalled(); + expect((await repositories.mintOperationRepository.getById(operation.id))?.state).toBe( + 'finalized', + ); + expect( + await repositories.proofRepository.getProofBySecret(mintUrl, 'existing-output'), + ).toMatchObject({ createdByOperationId: operation.id, state: 'ready' }); + const canonicalQuote = await repositories.mintQuoteRepository.getMintQuote( + mintUrl, + 'bolt11', + quoteId, + ); + expect(canonicalQuote?.amountPaid.equals(Amount.from(10))).toBe(true); + expect(canonicalQuote?.amountIssued.equals(Amount.from(10))).toBe(true); + expect(canonicalQuote?.remoteUpdatedAt).toBe(10); + }); + }); }); diff --git a/packages/core/test/unit/MintSwapOperation.test.ts b/packages/core/test/unit/MintSwapOperation.test.ts new file mode 100644 index 000000000..80055d00b --- /dev/null +++ b/packages/core/test/unit/MintSwapOperation.test.ts @@ -0,0 +1,552 @@ +import { Amount } from '@cashu/cashu-ts'; +import { describe, expect, it } from 'bun:test'; + +import { + assertMintSwapOperationUpdate, + assertMintSwapPreparationLeaseOwner, + canTransitionMintSwap, + createMintSwapPreparedPlanFingerprint, + getMintSwapOperationDueAt, + isMintSwapOperationDue, + validateMintSwapOperation, + type MintSwapOperation, +} from '../../operations/mintSwap/MintSwapOperation'; +import { + isOperationEventDue, + isOperationEventPublished, + operationEventLogicalKey, + validateOperationEventOutboxRecord, +} from '../../models/OperationEventOutbox'; +import { + makeMintSwapOutboxRecord, + makePreparedMintSwapOperation, + makePreparingMintSwapOperation, + makeSettledMintSwapOperation, + MINT_SWAP_TEST_NOW as now, +} from '../fixtures/MintSwap'; + +describe('MintSwapOperation', () => { + it('validates every parent state shape', () => { + const preparing = makePreparingMintSwapOperation(); + const prepared = makePreparedMintSwapOperation(); + const sourceInflight = makePreparedMintSwapOperation({ + state: 'source_inflight', + sourceDispatchAuthorizedAt: now + 2, + updatedAt: now + 2, + }); + const destinationFunded = makeSettledMintSwapOperation(); + const issuing = makeSettledMintSwapOperation({ + state: 'issuing', + destinationIssueAuthorizedAt: now + 4, + updatedAt: now + 4, + }); + const completedBase = makeSettledMintSwapOperation(); + const completed: MintSwapOperation = { + ...completedBase, + state: 'completed', + destinationIssueAuthorizedAt: now + 4, + completedAt: now + 5, + updatedAt: now + 5, + settlement: { + ...completedBase.settlement!, + destinationAmountIssued: Amount.from(1_000), + }, + }; + const cancelled = makePreparingMintSwapOperation({ + state: 'cancelled', + preparationLease: undefined, + cancellationRequestedAt: now + 1, + cancelledAt: now + 2, + updatedAt: now + 2, + }); + const failed = makePreparingMintSwapOperation({ + state: 'failed', + preparationLease: undefined, + terminalFailure: { code: 'PREPARATION_FAILED', reason: 'No value moved', at: now + 1 }, + updatedAt: now + 1, + }); + const attention = makePreparingMintSwapOperation({ + state: 'needs_attention', + preparationLease: undefined, + attention: { + reason: 'canonical_observation_conflict', + message: 'Conflicting preparation evidence', + lastSafeState: 'preparing', + violatedInvariant: 'canonical observations are monotonic', + evidence: { stage: 'destination_quote' }, + at: now + 1, + }, + updatedAt: now + 1, + }); + + for (const operation of [ + preparing, + prepared, + sourceInflight, + destinationFunded, + issuing, + completed, + cancelled, + failed, + attention, + ]) { + expect(validateMintSwapOperation(operation)).toBe(operation); + } + }); + + it('makes terminal records immutable while permitting active same-state revisions', () => { + expect(canTransitionMintSwap('issuing', 'issuing')).toBe(true); + expect(canTransitionMintSwap('prepared', 'prepared')).toBe(false); + expect(canTransitionMintSwap('needs_attention', 'needs_attention')).toBe(false); + expect(canTransitionMintSwap('completed', 'completed')).toBe(false); + expect(canTransitionMintSwap('cancelled', 'cancelled')).toBe(false); + expect(canTransitionMintSwap('failed', 'failed')).toBe(false); + + const settled = makeSettledMintSwapOperation(); + const completed: MintSwapOperation = { + ...settled, + state: 'completed', + destinationIssueAuthorizedAt: now + 4, + completedAt: now + 5, + updatedAt: now + 5, + settlement: { + ...settled.settlement!, + destinationAmountIssued: Amount.from(1_000), + }, + }; + expect(() => + assertMintSwapOperationUpdate(completed, { + ...completed, + revision: completed.revision + 1, + updatedAt: completed.updatedAt + 1, + }), + ).toThrow('Illegal mint swap transition'); + }); + + it('fences preparation ownership and excludes live leases from due work', () => { + const preparing = makePreparingMintSwapOperation(); + expect(getMintSwapOperationDueAt(preparing)).toBe(now + 30_000); + expect(isMintSwapOperationDue(preparing, now + 29_999)).toBe(false); + expect(isMintSwapOperationDue(preparing, now + 30_000)).toBe(true); + expect(() => + assertMintSwapPreparationLeaseOwner(preparing, 'worker-a', 'lease-token-a', now + 29_999), + ).not.toThrow(); + expect(() => + assertMintSwapPreparationLeaseOwner(preparing, 'worker-b', 'lease-token-a'), + ).toThrow('not owned'); + + const attached = { + ...preparing, + revision: 1, + destinationQuoteRef: { + mintUrl: preparing.destinationMintUrl, + method: 'bolt11' as const, + quoteId: 'destination-quote', + }, + preparationLease: { + ...preparing.preparationLease!, + stage: 'destination_child' as const, + expiresAt: now + 60_000, + }, + updatedAt: now + 1, + }; + expect(() => assertMintSwapOperationUpdate(preparing, attached)).not.toThrow(); + + expect(() => + assertMintSwapOperationUpdate(preparing, { + ...preparing, + revision: 1, + preparationLease: { + ownerId: 'worker-b', + token: 'lease-token-b', + stage: 'destination_quote', + acquiredAt: now + 10_000, + expiresAt: now + 40_000, + }, + updatedAt: now + 10_000, + }), + ).toThrow('cannot be taken over before expiry'); + + expect(() => + assertMintSwapOperationUpdate(preparing, { + ...preparing, + revision: 1, + preparationLease: { + ownerId: 'worker-b', + token: 'lease-token-b', + stage: 'destination_quote', + acquiredAt: now + 30_000, + expiresAt: now + 60_000, + }, + updatedAt: now + 30_000, + }), + ).not.toThrow(); + + expect(() => + assertMintSwapOperationUpdate(preparing, { + ...preparing, + revision: 1, + preparationLease: { + ...preparing.preparationLease!, + expiresAt: now + 60_000, + }, + updatedAt: now + 30_000, + }), + ).toThrow('cannot be renewed or advanced after expiry'); + }); + + it('rejects preparation stages that contradict attached durable facts', () => { + expect(() => + validateMintSwapOperation( + makePreparingMintSwapOperation({ + destinationQuoteRef: { + mintUrl: 'https://destination.mint.test', + method: 'bolt11', + quoteId: 'already-attached', + }, + }), + ), + ).toThrow('contradicts attached records'); + }); + + it('enforces the prepared and settled accounting equations', () => { + expect(() => + validateMintSwapOperation( + makePreparedMintSwapOperation({ + preparedPlan: { + ...makePreparedMintSwapOperation().preparedPlan!, + minimumSourceDebit: Amount.from(1_006), + }, + }), + ), + ).toThrow('minimum source debit does not reconcile'); + + expect(() => + validateMintSwapOperation( + makeSettledMintSwapOperation({ + settlement: { + ...makeSettledMintSwapOperation().settlement!, + finalSourceDebit: Amount.from(1_011), + }, + }), + ), + ).toThrow('final source debit from fees does not reconcile'); + }); + + it('accepts only the fee-reserve or full-reserved maximum bound', () => { + const prepared = makePreparedMintSwapOperation(); + expect(() => + validateMintSwapOperation({ + ...prepared, + preparedPlan: { + ...prepared.preparedPlan!, + maximumSourceDebit: Amount.from(1_024), + }, + }), + ).toThrow('must use the fee-reserve or reserved-input bound'); + + expect(() => + validateMintSwapOperation({ + ...prepared, + preparedPlan: { + ...prepared.preparedPlan!, + maximumSourceDebit: prepared.preparedPlan!.reservedSourceAmount, + }, + }), + ).not.toThrow(); + }); + + it('keeps attached quote, child, key, plan, and settlement facts immutable', () => { + const current = makePreparedMintSwapOperation(); + const update = (overrides: Partial): MintSwapOperation => ({ + ...current, + state: 'source_inflight', + revision: current.revision + 1, + sourceDispatchAuthorizedAt: now + 2, + updatedAt: current.updatedAt + 1, + ...overrides, + }); + + expect(() => + assertMintSwapOperationUpdate( + current, + update({ + destinationNut20Key: { ...current.destinationNut20Key, derivationIndex: 8 }, + }), + ), + ).toThrow('NUT-20 derivation index is immutable'); + expect(() => + assertMintSwapOperationUpdate( + current, + update({ + sourceQuoteRef: { ...current.sourceQuoteRef!, quoteId: 'replacement' }, + }), + ), + ).toThrow('attached source quote is immutable'); + expect(() => + assertMintSwapOperationUpdate( + current, + update({ + preparedPlan: { + ...current.preparedPlan!, + maximumSourceDebit: Amount.from(1_040), + }, + }), + ), + ).toThrow('maximum source debit is immutable'); + }); + + it('rejects impossible authorization projections and unaudited attention recovery', () => { + expect(() => + validateMintSwapOperation( + makePreparedMintSwapOperation({ sourceDispatchAuthorizedAt: now + 1 }), + ), + ).toThrow('cannot authorize source dispatch'); + expect(() => + validateMintSwapOperation( + makePreparingMintSwapOperation({ destinationIssueAuthorizedAt: now }), + ), + ).toThrow('cannot authorize destination issuance'); + expect(() => + validateMintSwapOperation( + makeSettledMintSwapOperation({ + state: 'issuing', + destinationIssueAuthorizedAt: now + 1, + }), + ), + ).toThrow('must follow source dispatch'); + expect(() => + validateMintSwapOperation( + makePreparedMintSwapOperation({ + state: 'cancelled', + cancellationRequestedAt: now + 2, + cancelledAt: now + 1, + updatedAt: now + 2, + }), + ), + ).toThrow('cancellation completion must follow its request'); + + const attention = makePreparedMintSwapOperation({ + state: 'needs_attention', + attention: { + reason: 'accounting_mismatch', + message: 'Issuing evidence is incomplete', + lastSafeState: 'issuing', + violatedInvariant: 'issuance requires durable authorization', + evidence: {}, + at: now + 2, + }, + updatedAt: now + 2, + }); + expect(() => validateMintSwapOperation(attention)).toThrow('source dispatch authorization'); + expect(canTransitionMintSwap('needs_attention', 'completed')).toBe(false); + + const sourceInflight = makePreparedMintSwapOperation({ + state: 'source_inflight', + sourceDispatchAuthorizedAt: now + 2, + updatedAt: now + 2, + }); + expect(() => + validateMintSwapOperation({ + ...sourceInflight, + state: 'failed', + terminalFailure: { code: 'UNPAID', reason: 'Source returned unpaid', at: now + 3 }, + updatedAt: now + 3, + }), + ).toThrow('requires source reclamation evidence'); + expect(() => + validateMintSwapOperation({ + ...sourceInflight, + state: 'failed', + sourceReclaimedAt: now + 3, + terminalFailure: { code: 'UNPAID', reason: 'Source returned unpaid', at: now + 3 }, + updatedAt: now + 3, + }), + ).not.toThrow(); + }); + + it('keeps creation and retry evidence monotonic across CAS updates', () => { + const current = makePreparingMintSwapOperation({ + createdAt: now - 10, + retry: { attemptCount: 2, lastAttemptAt: now, lastSuccessfulObservationAt: now }, + }); + const next = { + ...current, + revision: current.revision + 1, + preparationLease: { ...current.preparationLease!, expiresAt: now + 60_000 }, + updatedAt: now + 1, + }; + expect(() => + assertMintSwapOperationUpdate(current, { ...next, createdAt: current.createdAt - 1 }), + ).toThrow('createdAt is immutable'); + expect(() => + assertMintSwapOperationUpdate(current, { + ...next, + retry: { attemptCount: 1, lastAttemptAt: now, lastSuccessfulObservationAt: now }, + }), + ).toThrow('attempt count cannot regress'); + expect(() => + assertMintSwapOperationUpdate(current, { + ...next, + retry: { attemptCount: 2, lastAttemptAt: now - 1, lastSuccessfulObservationAt: now }, + }), + ).toThrow('last attempt cannot regress'); + }); + + it('fingerprints canonical object keys while remaining sensitive to ordered plans and keys', () => { + const prepared = makePreparedMintSwapOperation(); + const common = { + destinationMintOperationId: prepared.destinationMintOperationId!, + sourceMeltOperationId: prepared.sourceMeltOperationId!, + destinationQuoteRef: prepared.destinationQuoteRef!, + sourceQuoteRef: prepared.sourceQuoteRef!, + destinationNut20Key: prepared.destinationNut20Key, + destinationAmount: prepared.destinationAmount, + unit: 'sat' as const, + sourceInputProofSecrets: ['a', 'b'], + sourceOutputData: { + keep: [], + send: [ + { + blindedMessage: { amount: '1025', id: 'source-keyset', B_: 'source-B' }, + blindingFactor: '02', + secret: '62', + }, + ], + }, + sourceMeltAmount: prepared.preparedPlan!.sourceMeltAmount, + sourceFeeReserve: prepared.preparedPlan!.sourceFeeReserve, + sourcePreparationFee: prepared.preparedPlan!.sourcePreparationFee, + sourceMeltInputFee: prepared.preparedPlan!.sourceMeltInputFee, + minimumSourceDebit: prepared.preparedPlan!.minimumSourceDebit, + maximumSourceDebit: prepared.preparedPlan!.maximumSourceDebit, + reservedSourceAmount: prepared.preparedPlan!.reservedSourceAmount, + dispatchDeadlineSeconds: prepared.preparedPlan!.dispatchDeadlineSeconds, + requiredDispatchWindowSeconds: prepared.preparedPlan!.requiredDispatchWindowSeconds, + }; + const first = createMintSwapPreparedPlanFingerprint({ + ...common, + destinationOutputData: { + keep: [ + { + blindedMessage: { amount: '1000', id: 'destination-keyset', B_: 'destination-B' }, + blindingFactor: '01', + secret: '61', + }, + ], + send: [], + }, + }); + const reordered = createMintSwapPreparedPlanFingerprint({ + ...common, + destinationOutputData: { + send: [], + keep: [ + { + secret: '61', + blindingFactor: '01', + blindedMessage: { B_: 'destination-B', id: 'destination-keyset', amount: '1000' }, + }, + ], + }, + }); + const changedKey = createMintSwapPreparedPlanFingerprint({ + ...common, + destinationNut20Key: { ...prepared.destinationNut20Key, derivationIndex: 8 }, + destinationOutputData: firstOutputData(), + }); + const changedOrder = createMintSwapPreparedPlanFingerprint({ + ...common, + sourceInputProofSecrets: ['b', 'a'], + destinationOutputData: firstOutputData(), + }); + const changedDeadline = createMintSwapPreparedPlanFingerprint({ + ...common, + dispatchDeadlineSeconds: common.dispatchDeadlineSeconds + 1, + destinationOutputData: firstOutputData(), + }); + const changedWindow = createMintSwapPreparedPlanFingerprint({ + ...common, + requiredDispatchWindowSeconds: common.requiredDispatchWindowSeconds + 1, + destinationOutputData: firstOutputData(), + }); + + expect(first).toBe(reordered); + expect(changedKey).not.toBe(first); + expect(changedOrder).not.toBe(first); + expect(changedDeadline).not.toBe(first); + expect(changedWindow).not.toBe(first); + expect( + createMintSwapPreparedPlanFingerprint({ + ...common, + sourceFeeReserve: common.sourceFeeReserve.add(1), + destinationOutputData: firstOutputData(), + }), + ).not.toBe(first); + expect(() => + createMintSwapPreparedPlanFingerprint({ + ...common, + destinationOutputData: new Map() as never, + }), + ).toThrow('plain objects'); + }); +}); + +function firstOutputData() { + return { + keep: [ + { + blindedMessage: { amount: '1000', id: 'destination-keyset', B_: 'destination-B' }, + blindingFactor: '01', + secret: '61', + }, + ], + send: [], + }; +} + +describe('OperationEventOutbox', () => { + it('validates a sanitized logical event and derives its unique key', () => { + const event = makeMintSwapOutboxRecord(); + expect(validateOperationEventOutboxRecord(event)).toBe(event); + expect(operationEventLogicalKey(event)).toBe('mint-swap-op\u00001\u0000mint-swap-op:prepared'); + }); + + it('uses explicit publication and due semantics', () => { + expect(isOperationEventPublished({ publishedAt: 0 })).toBe(true); + expect(isOperationEventDue({ nextAttemptAt: now + 10 }, now + 9)).toBe(false); + expect(isOperationEventDue({ nextAttemptAt: now + 10 }, now + 10)).toBe(true); + expect(isOperationEventDue({ publishedAt: now, nextAttemptAt: now - 1 }, now + 10)).toBe(false); + }); + + it('rejects mismatched transition payloads and published retry residue', () => { + const event = makeMintSwapOutboxRecord(); + expect(() => + validateOperationEventOutboxRecord({ + ...event, + payload: { ...event.payload, state: 'issuing' }, + }), + ).toThrow('payload must contain state prepared'); + expect(() => + validateOperationEventOutboxRecord({ + ...event, + publishedAt: now + 2, + nextAttemptAt: now + 3, + }), + ).toThrow('cannot retain retry scheduling'); + expect(() => + validateOperationEventOutboxRecord({ + ...event, + eventType: 'mint-swap-op:delayed', + payload: { ...event.payload, state: 'completed', reasonCode: 'RETRY_EXHAUSTED' }, + }), + ).toThrow('automatic operation state'); + expect(() => + validateOperationEventOutboxRecord({ + ...event, + eventType: 'mint-swap-op:delayed', + payload: { ...event.payload, state: 'source_inflight' }, + }), + ).toThrow('requires a reason code'); + }); +}); diff --git a/packages/core/test/unit/PollingTransport.test.ts b/packages/core/test/unit/PollingTransport.test.ts index 9c0582882..25a122f80 100644 --- a/packages/core/test/unit/PollingTransport.test.ts +++ b/packages/core/test/unit/PollingTransport.test.ts @@ -583,7 +583,10 @@ describe('PollingTransport mint quote batching', () => { transport.on(mintUrl, 'message', () => {}); subscribeToQuotes(transport, mintUrl, 'bolt11', 'cadence-sub', ['quote-a']); - await waitFor(() => startedAt.length === 2, 2_000); + // The full coverage suite runs many CPU-heavy files concurrently, which can starve this real + // timer well beyond its 20 ms interval on CI. Keep the cadence assertion strict while allowing + // enough wall-clock time for the second turn to be scheduled under runner contention. + await waitFor(() => startedAt.length === 2, 10_000); expect(startedAt[1]! - startedAt[0]!).toBeGreaterThanOrEqual(18); transport.closeAll(); diff --git a/packages/core/test/unit/ProofService.test.ts b/packages/core/test/unit/ProofService.test.ts index 7c695351f..ee63ca2f8 100644 --- a/packages/core/test/unit/ProofService.test.ts +++ b/packages/core/test/unit/ProofService.test.ts @@ -345,6 +345,36 @@ describe('ProofService', () => { const counter = await counterRepo.getCounter(mintUrl, 'usd-keyset'); expect(counter?.counter).toBe(1); }); + + it('rejects fee-inflated send outputs when keep cannot fund the added fee', async () => { + const createDeterministicData = mock(() => [] as OutputDataLike[]); + const service = new ProofService( + counterService, + proofRepo, + walletService as any, + mintService as any, + keyRingService as any, + seedService, + undefined, + bus, + makeOutputDataCreator({ createDeterministicData }), + ); + service.calculateSendAmountWithFees = mock(async () => Amount.from(12)); + + await expect( + service.createOutputsAndIncrementCounters( + mintUrl, + { keep: unitAmount(1), send: unitAmount(10) }, + { includeFees: true }, + ), + ).rejects.toThrow('Keep amount cannot cover the receiver fee'); + expect(createDeterministicData).not.toHaveBeenCalled(); + await expect(counterRepo.getCounter(mintUrl, keysetId)).resolves.toEqual({ + mintUrl, + keysetId, + counter: 0, + }); + }); }); describe('createBlankOutputs', () => { diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index bca681f79..cecc90279 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -29,6 +29,9 @@ "noPropertyAccessFromIndexSignature": false, "baseUrl": ".", "paths": { + "@cashu/coco-adapter-tests": ["../adapter-tests/src/index.ts"], + "@cashu/coco-core": ["./index.ts"], + "@cashu/coco-core/adapter": ["./adapter.ts"], "@core/*": ["./*"], "@core/models": ["./models/index.ts"], "@core/services": ["./services/index.ts"], diff --git a/packages/core/utils.ts b/packages/core/utils.ts index 8370550d2..43d1f4a52 100644 --- a/packages/core/utils.ts +++ b/packages/core/utils.ts @@ -161,6 +161,36 @@ export function getProofStateInputsFromSerializedOutputs( })); } +/** Validate that remote proofs are exactly the proofs described by persisted output data. */ +export function assertProofsMatchSerializedOutputs( + proofs: readonly Proof[], + outputs: readonly SerializedOutput[], + label: string, +): void { + if (proofs.length !== outputs.length) { + throw new Error(`${label} proof count does not match deterministic outputs`); + } + const expectedBySecret = new Map( + outputs.map((output) => [decodeSecretHex(output.secret), output] as const), + ); + if (expectedBySecret.size !== outputs.length) { + throw new Error(`${label} deterministic outputs contain duplicate secrets`); + } + const seen = new Set(); + for (const proof of proofs) { + if (seen.has(proof.secret)) throw new Error(`${label} proofs contain duplicate secrets`); + seen.add(proof.secret); + const output = expectedBySecret.get(proof.secret); + if (!output) throw new Error(`${label} proof secret does not match deterministic outputs`); + if (proof.id !== output.blindedMessage.id) { + throw new Error(`${label} proof keyset does not match deterministic outputs`); + } + if (!Amount.from(proof.amount).equals(Amount.from(output.blindedMessage.amount))) { + throw new Error(`${label} proof amount does not match deterministic outputs`); + } + } +} + export function mapProofToCoreProof( mintUrl: string, state: ProofState,