diff --git a/.github/workflows/core_tests.yml b/.github/workflows/core_tests.yml index e9704e86..3c0c1f14 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 586300b8..193ab078 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,344 @@ 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: 'adapter-contract-fingerprint', + 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 mintChild = createDummyMintOperation({ + id: 'owned-mint', + quoteId: 'owned-mint-quote', + parentSwapOperationId: 'mint-parent', + pubkey: `02${'ab'.repeat(32)}`, + }); + await repositories.mintOperationRepository.create(mintChild); + expect( + (await repositories.mintOperationRepository.getById(mintChild.id))?.parentSwapOperationId, + ).toBe('mint-parent'); + 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)}`, + }), + ), + 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); + } 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 f6b69c04..d313eb44 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, @@ -37,6 +40,10 @@ export type { MintQuoteRef, QuoteIdentity, } from './models/index.ts'; +export type { + MintSwapEventPayload, + OperationEventOutboxRecord, +} from './models/OperationEventOutbox.ts'; export { applyBolt11MintQuoteStateFallback, compareHistoryEntries, @@ -73,6 +80,21 @@ 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 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 a450a821..5b2b7c68 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, @@ -32,6 +37,7 @@ import type { import { computeYHexForSecrets, deserializeOutputData, + getSecretsFromSerializedOutputData, mapProofToCoreProof, serializeOutputData, type SerializedOutputData, @@ -74,7 +80,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, @@ -466,6 +472,144 @@ 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', + 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', 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); + this.assertProofSecrets(expected.sendSecrets, result.sendProofs, 'pre-swap send'); + this.assertProofSecrets(expected.keepSecrets, result.keepProofs, '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 + ); + }) + ) { + 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, + ); + await this.finalizeOperation(ctx, result.response.change); + 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}`, + ); + } + } + + private assertProofSecrets(expected: string[], proofs: Proof[], label: string): void { + const expectedSorted = [...expected].sort(); + const actualSorted = proofs.map(({ secret }) => secret).sort(); + if ( + expectedSorted.length !== actualSorted.length || + expectedSorted.some((secret, index) => secret !== actualSorted[index]) + ) { + throw new Error(`Melt ${label} proofs do not match deterministic outputs`); + } + } + /** * Handle the melt response and return the appropriate execution result. */ @@ -640,7 +784,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 d6d50178..7dc3a3fe 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 a1cea0da..ccba0895 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 00000000..c416f8c3 --- /dev/null +++ b/packages/core/models/OperationEventOutbox.ts @@ -0,0 +1,172 @@ +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', +]); + +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.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 (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'); + } + } 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 62a28306..cdc7c76b 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,44 @@ 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'; + sendProofs: Proof[]; + keepProofs: Proof[]; + } + | { + operationId: string; + phase: 'melt'; + response: { + state: MeltMethodRemoteState; + change?: SerializedBlindedSignature[]; + payment_preimage?: string | null; + outpoint?: string | null; + }; + }; + +export interface ApplyOwnedMeltRemoteContext< + M extends MeltMethod = MeltMethod, +> extends BaseHandlerDeps { + operation: ExecutingMeltOperation & MeltMethodMeta; +} + export interface PendingContext extends BaseHandlerDeps { operation: PendingMeltOperation & MeltMethodMeta; wallet: Wallet; @@ -201,6 +240,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 e7610705..44027827 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 77628205..359611f1 100644 --- a/packages/core/operations/melt/MeltOperationService.ts +++ b/packages/core/operations/melt/MeltOperationService.ts @@ -1,10 +1,16 @@ -import type { MeltOperationRepository, ProofRepository } from '../../repositories'; +import type { Wallet } from '@cashu/cashu-ts'; +import type { + MeltOperationRepository, + ProofRepository, + RepositoryTransactionScope, +} from '../../repositories'; import type { MeltOperation, InitMeltOperation, PreparedMeltOperation, ExecutingMeltOperation, PendingMeltOperation, + FailedMeltOperation, FinalizedMeltOperation, RollingBackMeltOperation, RolledBackMeltOperation, @@ -16,6 +22,7 @@ import type { MeltMethodData, MeltMethodInputData, PendingCheckResult, + OwnedMeltRemoteResult, } from './MeltMethodHandler'; import { normalizeMeltMethodData } from './MeltMethodHandler'; import type { MintService } from '../../services/MintService'; @@ -24,7 +31,7 @@ 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 { 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 +40,25 @@ 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 { + assertChildOperationAccess, + assertParentOwnedMeltOperationInvariant, +} from '../mintSwap/ChildOperationOwnership.ts'; + +export interface PrepareOwnedMeltOperationCommand { + operationId: string; + parentSwapOperationId: string; + quote: MeltQuote; + wallet: Wallet; + repositories: RepositoryTransactionScope; + feeIndex?: number; +} /** * MeltOperationService orchestrates melt sagas while delegating @@ -82,10 +106,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 +298,206 @@ export class MeltOperationService { } } + /** Prepare and persist a parent-owned source child using transaction-scoped local writes. */ + async prepareOwnedInTransaction( + command: PrepareOwnedMeltOperationCommand, + ): Promise { + const { quote, operationId, parentSwapOperationId, repositories, wallet } = command; + if (quote.method !== 'bolt11' || quote.unit !== 'sat') { + throw new Error('Mint swaps require a sat-denominated BOLT11 source quote'); + } + 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 prepared = await this.handlerProvider.get('bolt11').prepare({ + ...this.buildDeps(repositories), + operation: initOperation as never, + wallet, + quote: meltQuoteToMethodSnapshot(quote as MeltQuote<'bolt11'>), + }); + const preparedOperation: PreparedMeltOperation = { + ...prepared, + id: operationId, + parentSwapOperationId, + state: 'prepared', + updatedAt: Date.now(), + }; + 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 { + 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( + operation: ExecutingMeltOperation, + parentSwapOperationId: string, + ): Promise { + 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); + if (proofs.length !== proofSecrets.length) { + throw new Error(`Could not find all proofs for authorized melt step ${operation.id}`); + } + const handler = this.handlerProvider.get(operation.method); + if (!handler.executeOwnedRemote) { + throw new Error(`Melt method ${operation.method} does not support owned remote execution`); + } + return handler.executeOwnedRemote({ + operation: operation as never, + wallet, + mintAdapter: this.mintAdapter, + proofs, + logger: this.logger, + }); + } + + /** Apply one remote source result atomically with the composing parent transition. */ + async applyOwnedRemoteStepInTransaction( + operation: ExecutingMeltOperation, + parentSwapOperationId: string, + result: OwnedMeltRemoteResult, + repositories: RepositoryTransactionScope, + ): Promise< + ExecutingMeltOperation | PendingMeltOperation | FinalizedMeltOperation | FailedMeltOperation + > { + assertChildOperationAccess(operation, parentSwapOperationId); + const current = await repositories.meltOperationRepository.getById(operation.id); + if (!current || current.state !== 'executing') { + throw new Error( + `Cannot apply melt child ${operation.id} from ${current?.state ?? 'missing'}`, + ); + } + assertChildOperationAccess(current, parentSwapOperationId); + if (current.parentExecutionPhase !== operation.parentExecutionPhase) { + throw new Error(`Melt child ${operation.id} advanced before its remote result was applied`); + } + const handler = this.handlerProvider.get(current.method); + if (!handler.applyOwnedRemote) { + throw new Error(`Melt method ${current.method} does not support owned result application`); + } + const applied = await handler.applyOwnedRemote( + { + ...this.buildDeps(repositories), + operation: current as never, + }, + result 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, + wallet: Wallet, + repositories: RepositoryTransactionScope, + reason = 'Parent mint swap cancelled', + ): Promise { + const operation = await repositories.meltOperationRepository.getById(operationId); + if (!operation || operation.state !== 'prepared') { + throw new Error( + `Cannot roll back melt child ${operationId} from ${operation?.state ?? 'missing'}`, + ); + } + assertChildOperationAccess(operation, parentSwapOperationId); + await this.handlerProvider.get(operation.method).rollback?.({ + ...this.buildDeps(repositories), + operation, + wallet, + }); + const rolledBack: RolledBackMeltOperation = { + ...operation, + state: 'rolled_back', + updatedAt: Date.now(), + error: reason, + }; + await repositories.meltOperationRepository.update(rolledBack); + return rolledBack; + } + /** * Prepare the operation by reserving proofs and creating outputs. * After this step, the operation can be executed or rolled back. @@ -289,6 +516,7 @@ export class MeltOperationService { }'`, ); } + assertChildOperationAccess(operation); const initOp = operation as InitMeltOperation; const releaseMintLock = await this.mintScopedLock.acquire(initOp.mintUrl); @@ -363,6 +591,7 @@ export class MeltOperationService { }'`, ); } + assertChildOperationAccess(operation); const preparedOp = operation as PreparedMeltOperation; @@ -463,6 +692,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 +768,7 @@ export class MeltOperationService { if (!operation) { throw new Error(`Operation ${operationId} not found`); } + assertChildOperationAccess(operation); if ( operation.state === 'finalized' || @@ -624,6 +855,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 +863,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 +872,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 +887,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 +902,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 +937,7 @@ export class MeltOperationService { }'`, ); } + assertChildOperationAccess(op); const persistedQuote = await this.quoteLifecycle.getMeltQuote( op.mintUrl, op.method, @@ -808,6 +1045,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 +1210,15 @@ 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, + ); } } diff --git a/packages/core/operations/mint/MintMethodHandler.ts b/packages/core/operations/mint/MintMethodHandler.ts index 3ad19f01..e9e44aea 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 { @@ -227,6 +233,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 19c3285c..de2804eb 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 67538f91..a02638b5 100644 --- a/packages/core/operations/mint/MintOperationService.ts +++ b/packages/core/operations/mint/MintOperationService.ts @@ -1,5 +1,9 @@ -import { Amount, type Proof } from '@cashu/cashu-ts'; -import type { MintOperationRepository, ProofRepository } from '../../repositories'; +import { Amount, type Proof, type Wallet } from '@cashu/cashu-ts'; +import type { + MintOperationRepository, + ProofRepository, + RepositoryTransactionScope, +} from '../../repositories'; import type { ExecutingMintOperation, FailedMintOperation, @@ -39,13 +43,33 @@ 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; + wallet: Wallet; + repositories: RepositoryTransactionScope; +} export interface ClaimMintQuoteOptions { autoClaimRemaining?: boolean; @@ -96,10 +120,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 +273,211 @@ export class MintOperationService { return this.prepareInitOperation(initOperation.id); } + /** + * Prepare and persist a parent-owned destination child using transaction-scoped local writes. + * + * @internal + */ + async prepareOwnedInTransaction( + command: PrepareOwnedMintOperationCommand, + ): Promise { + const { quote, repositories, parentSwapOperationId, operationId, wallet } = command; + if (quote.method !== 'bolt11') { + throw new Error('Mint swaps require a BOLT11 destination quote'); + } + 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(repositories), + 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); + await repositories.mintOperationRepository.create(pendingOperation); + return pendingOperation; + } + + /** Persist destination issuance authorization before the remote mint request. */ + async authorizeOwnedExecutionInTransaction( + operationId: string, + parentSwapOperationId: string, + repositories: RepositoryTransactionScope, + ): Promise { + const operation = await repositories.mintOperationRepository.getById(operationId); + if (!operation || operation.state !== 'pending') { + throw new Error( + `Cannot authorize mint child ${operationId} from ${operation?.state ?? 'missing'}`, + ); + } + assertChildOperationAccess(operation, parentSwapOperationId); + 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( + operation: ExecutingMintOperation, + parentSwapOperationId: string, + ): Promise { + 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`); + } + return handler.executeOwnedRemote({ + operation: operation as never, + wallet, + mintAdapter: this.mintAdapter, + logger: this.logger, + }); + } + + /** Apply a remote issuance result atomically with the composing parent transition. */ + async applyOwnedExecutionInTransaction( + operation: ExecutingMintOperation, + parentSwapOperationId: string, + result: import('./MintMethodHandler.ts').MintExecutionResult, + repositories: RepositoryTransactionScope, + ): Promise { + assertChildOperationAccess(operation, parentSwapOperationId); + assertParentOwnedMintOperationInvariant(operation); + const current = await repositories.mintOperationRepository.getById(operation.id); + if (!current || current.state !== 'executing') { + throw new Error( + `Cannot apply mint child ${operation.id} 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; + } + + const expectedSecrets = [...getOutputProofSecrets(current)].sort(); + const receivedSecrets = result.proofs.map(({ secret }) => secret).sort(); + if ( + expectedSecrets.length !== receivedSecrets.length || + expectedSecrets.some((secret, index) => secret !== receivedSecrets[index]) + ) { + throw new Error(`Mint result does not match deterministic outputs for ${operation.id}`); + } + + const scopedProofService = this.proofService.forTransaction(repositories); + 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, + ) + ) { + 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 +571,7 @@ export class MintOperationService { if (!operation) { throw new Error(`Operation ${operationId} not found`); } + assertChildOperationAccess(operation); if (isTerminalOperation(operation)) { return operation; @@ -403,6 +636,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 = { @@ -477,6 +711,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 +758,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 +778,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 +799,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 +833,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 +1015,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 +1110,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 +1224,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 +1422,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 00000000..0e700846 --- /dev/null +++ b/packages/core/operations/mintSwap/ChildOperationOwnership.ts @@ -0,0 +1,88 @@ +import { ParentOwnedOperationError } from '../../models/Error.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`); + } + } +} + +/** 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`); + } +} diff --git a/packages/core/operations/mintSwap/MintSwapOperation.ts b/packages/core/operations/mintSwap/MintSwapOperation.ts new file mode 100644 index 00000000..f6a686fa --- /dev/null +++ b/packages/core/operations/mintSwap/MintSwapOperation.ts @@ -0,0 +1,1027 @@ +import { Amount } from '@cashu/cashu-ts'; +import { bytesToHex } from '@noble/curves/utils.js'; +import { sha256 } from '@noble/hashes/sha2.js'; + +import { normalizeMintUrl } from '../../utils'; + +export type MintSwapOperationState = + | 'preparing' + | 'prepared' + | 'source_inflight' + | 'destination_funded' + | 'issuing' + | 'completed' + | 'cancelled' + | 'failed' + | 'needs_attention'; + +/** + * 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; + 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: unknown; + sourceOutputData: unknown; + maximumSourceDebit: 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(), + needs_attention: new Set(['destination_funded', 'issuing', 'completed', 'cancelled', 'failed']), +}; + +export function isTerminalMintSwapState(state: MintSwapOperationState): boolean { + return TERMINAL_STATES.has(state); +} + +export function isAutomaticMintSwapState(state: MintSwapOperationState): boolean { + return AUTOMATIC_STATES.has(state); +} + +export function canTransitionMintSwap( + from: MintSwapOperationState, + to: MintSwapOperationState, +): boolean { + 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); + 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); + } + + if (operation.sourceDispatchAuthorizedAt !== undefined) { + assertTimestamp( + operation.sourceDispatchAuthorizedAt, + 'Mint swap source dispatch authorization', + ); + assertOperationTimestampOrder( + operation, + operation.sourceDispatchAuthorizedAt, + 'Mint swap source dispatch authorization', + ); + } + if ( + operation.state === 'source_inflight' || + operation.state === 'destination_funded' || + operation.state === 'issuing' || + operation.state === '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 (operation.state === 'issuing' || operation.state === 'completed') { + assertTimestamp( + operation.destinationIssueAuthorizedAt, + 'Mint swap destination issue authorization', + ); + } + + if ( + operation.state === 'destination_funded' || + operation.state === 'issuing' || + operation.state === '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.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.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.state === 'needs_attention' && !operation.attention) { + throw new Error('Mint swap needing attention requires structured evidence'); + } + if (operation.attention) { + 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.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); +} + +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; + assertNonEmpty(plan.fingerprint, 'Mint swap prepared fingerprint'); + 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'); + } + 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.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 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'], + ] 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 entries = Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${canonicalizeForFingerprint(item, seen)}`); + return `{${entries.join(',')}}`; + } finally { + seen.delete(value); + } +} diff --git a/packages/core/repositories/index.ts b/packages/core/repositories/index.ts index 74f0276f..0454fb55 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,35 @@ export interface PaymentRequestReceiveAttemptRepository { delete(id: string): Promise; } +export interface MintSwapOperationRepository { + create(operation: MintSwapOperation): Promise; + getById(id: string): Promise; + getByState(state: MintSwapOperationState): Promise; + getActive(): Promise; + getDue(now: number, limit: number): Promise; + getByDestinationMintOperationId(id: string): Promise; + getBySourceMeltOperationId(id: string): Promise; + compareAndSet(operation: MintSwapOperation, expectedRevision: number): Promise; +} + +export interface OperationEventOutboxRepository { + enqueue(event: OperationEventOutboxRecord): Promise; + getUnpublished(limit: number, now?: number): Promise; + markPublished(id: string, publishedAt: number): Promise; + recordPublishFailure(id: string, nextAttemptAt: number, lastError: string): Promise; +} + +/** + * 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; +} + interface RepositoriesBase { mintRepository: MintRepository; keyRingRepository: KeyRingRepository; @@ -371,6 +405,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 e237b5cb..8fa4b918 100644 --- a/packages/core/repositories/memory/MemoryMeltOperationRepository.ts +++ b/packages/core/repositories/memory/MemoryMeltOperationRepository.ts @@ -1,5 +1,6 @@ import type { MeltOperationRepository } from '..'; import type { MeltOperation, MeltOperationState } from '../../operations/melt/MeltOperation'; +import { assertParentOwnedMeltOperationInvariant } from '../../operations/mintSwap/ChildOperationOwnership.ts'; const getOperationQuoteId = (operation: MeltOperation): string | undefined => 'quoteId' in operation && operation.quoteId ? operation.quoteId : undefined; @@ -8,18 +9,26 @@ 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.assertUniqueParentOwnership(operation); this.operations.set(operation.id, { ...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`); } + if (existing.parentSwapOperationId !== operation.parentSwapOperationId) { + throw new Error(`Cannot change parent ownership of MeltOperation ${operation.id}`); + } this.assertNoDuplicateQuoteOperation(operation); + this.assertUniqueParentOwnership(operation); this.operations.set(operation.id, { ...operation, updatedAt: Date.now() }); } @@ -77,6 +86,10 @@ export class MemoryMeltOperationRepository implements MeltOperationRepository { } 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 +109,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 8b88854c..42c98c5b 100644 --- a/packages/core/repositories/memory/MemoryMintOperationRepository.ts +++ b/packages/core/repositories/memory/MemoryMintOperationRepository.ts @@ -1,20 +1,29 @@ import type { MintOperationRepository } from '..'; import type { MintOperation, MintOperationState } from '../../operations/mint/MintOperation'; +import { assertParentOwnedMintOperationInvariant } from '../../operations/mintSwap/ChildOperationOwnership.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.assertUniqueParentOwnership(operation); this.operations.set(operation.id, { ...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`); } + if (existing.parentSwapOperationId !== operation.parentSwapOperationId) { + throw new Error(`Cannot change parent ownership of MintOperation ${operation.id}`); + } + this.assertUniqueParentOwnership(operation); this.operations.set(operation.id, { ...operation, updatedAt: Date.now() }); } @@ -73,6 +82,24 @@ export class MemoryMintOperationRepository implements MintOperationRepository { } 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 00000000..457e22dc --- /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 00000000..62816ad4 --- /dev/null +++ b/packages/core/repositories/memory/MemoryOperationEventOutboxRepository.ts @@ -0,0 +1,80 @@ +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 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, + 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 65b72e67..ce62ee0d 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,10 @@ import { MemoryPaymentRequestReceiveAttemptRepository, MemoryPaymentRequestReceiveOperationRepository, } from './MemoryPaymentRequestReceiveRepository'; +import { copyMemoryRepositoryState } from './clone'; +import { MemoryRepositoryCoordinator } from './MemoryRepositoryCoordinator'; +import { MemoryMintSwapOperationRepository } from './MemoryMintSwapOperationRepository'; +import { MemoryOperationEventOutboxRepository } from './MemoryOperationEventOutboxRepository'; export class MemoryRepositories implements Repositories { mintRepository: MintRepository; @@ -54,37 +59,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 +103,99 @@ 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); + copyRepositoryScope(staged, this.rawScope); + return result; + }); + } +} + +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 { + const repositoryKeys: Array> = [ + 'mintRepository', + 'keyRingRepository', + 'counterRepository', + 'keysetRepository', + 'proofRepository', + 'mintQuoteRepository', + 'legacyMintQuoteRepository', + 'meltQuoteRepository', + 'historyRepository', + 'sendOperationRepository', + 'meltOperationRepository', + 'authSessionRepository', + 'mintOperationRepository', + 'receiveOperationRepository', + 'paymentRequestReceiveOperationRepository', + 'paymentRequestReceiveAttemptRepository', + ]; + for (const key of repositoryKeys) { + copyMemoryRepositoryState( + source[key], + target[key], + key === 'historyRepository' ? ['operationRepositories'] : [], + ); + } + if (!source.mintSwap || !target.mintSwap) { + throw new Error('Memory Mint Swap repository capability is missing'); } + copyMemoryRepositoryState( + source.mintSwap.mintSwapOperationRepository, + target.mintSwap.mintSwapOperationRepository, + ); + copyMemoryRepositoryState( + source.mintSwap.operationEventOutboxRepository, + target.mintSwap.operationEventOutboxRepository, + ); } diff --git a/packages/core/repositories/memory/MemoryRepositoryCoordinator.ts b/packages/core/repositories/memory/MemoryRepositoryCoordinator.ts new file mode 100644 index 00000000..43db8cf3 --- /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 00000000..f3cc49c4 --- /dev/null +++ b/packages/core/repositories/memory/clone.ts @@ -0,0 +1,71 @@ +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 { + const excluded = new Set(excludedKeys); + const sourceRecord = source as Record; + const targetRecord = target as Record; + for (const key of Object.keys(sourceRecord)) { + if (!excluded.has(key)) targetRecord[key] = cloneMemoryValue(sourceRecord[key]); + } +} diff --git a/packages/core/repositories/memory/index.ts b/packages/core/repositories/memory/index.ts index f959a0f6..c3ca59b3 100644 --- a/packages/core/repositories/memory/index.ts +++ b/packages/core/repositories/memory/index.ts @@ -15,3 +15,6 @@ export * from './MemoryMeltQuoteRepository'; export * from './MemoryMintOperationRepository'; export * from './MemoryReceiveOperationRepository'; export * from './MemoryPaymentRequestReceiveRepository'; +export * from './MemoryRepositoryCoordinator'; +export * from './MemoryMintSwapOperationRepository'; +export * from './MemoryOperationEventOutboxRepository'; diff --git a/packages/core/services/ProofService.ts b/packages/core/services/ProofService.ts index 2ca74237..47449c67 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. diff --git a/packages/core/test/fixtures/MintSwap.ts b/packages/core/test/fixtures/MintSwap.ts new file mode 100644 index 00000000..a1c0434d --- /dev/null +++ b/packages/core/test/fixtures/MintSwap.ts @@ -0,0 +1,151 @@ +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: [{ amount: '1000', secret: 'destination-output' }] }, + sourceOutputData: { keep: [], send: [{ amount: '1025', secret: 'source-output' }] }, + maximumSourceDebit, + 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 1451fca4..f13fb111 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']), + }); + + 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, + walletService, + mintService, + mintAdapter, + eventBus, + 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 e165d11e..a688ca14 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,23 @@ describe('MeltOperationService', () => { proofService = { releaseProofs: mock(async () => {}), + 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'); + }), + })), } as unknown as ProofService; mintService = { @@ -370,6 +409,128 @@ 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('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: [] }, + }); + await repositories.proofRepository.saveProofs(mintUrl, [makeProof('owned-input')]); + 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: [], + }; + }, + ); + + 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'); + + (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, + ); + }); + }); + 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 00000000..71ce80cf --- /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 a7ca4568..5dd22901 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,6 +344,11 @@ 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 []; @@ -3220,4 +3233,263 @@ 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 prepared = await repositories.withTransaction((transaction) => + service.prepareOwnedInTransaction({ + operationId: 'owned-locked-destination', + parentSwapOperationId, + quote, + amount: Amount.from(10), + destinationNut20PublicKey, + wallet, + 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( + repositories.withTransaction((transaction) => + service.prepareOwnedInTransaction({ + operationId: 'owned-wrong-destination-key', + parentSwapOperationId, + quote, + amount: Amount.from(10), + destinationNut20PublicKey, + wallet, + repositories: transaction, + }), + ), + ).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('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, + ); + 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')] }; + }), + }; + (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('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, + { 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, + { 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 00000000..c41ef608 --- /dev/null +++ b/packages/core/test/unit/MintSwapOperation.test.ts @@ -0,0 +1,382 @@ +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('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: [{ amount: '1025' }] }, + maximumSourceDebit: prepared.preparedPlan!.maximumSourceDebit, + dispatchDeadlineSeconds: prepared.preparedPlan!.dispatchDeadlineSeconds, + requiredDispatchWindowSeconds: prepared.preparedPlan!.requiredDispatchWindowSeconds, + }; + const first = createMintSwapPreparedPlanFingerprint({ + ...common, + destinationOutputData: { z: 1, a: { second: 2, first: 1 } }, + }); + const reordered = createMintSwapPreparedPlanFingerprint({ + ...common, + destinationOutputData: { a: { first: 1, second: 2 }, z: 1 }, + }); + const changedKey = createMintSwapPreparedPlanFingerprint({ + ...common, + destinationNut20Key: { ...prepared.destinationNut20Key, derivationIndex: 8 }, + destinationOutputData: { z: 1, a: { second: 2, first: 1 } }, + }); + const changedOrder = createMintSwapPreparedPlanFingerprint({ + ...common, + sourceInputProofSecrets: ['b', 'a'], + destinationOutputData: { z: 1, a: { second: 2, first: 1 } }, + }); + const changedDeadline = createMintSwapPreparedPlanFingerprint({ + ...common, + dispatchDeadlineSeconds: common.dispatchDeadlineSeconds + 1, + destinationOutputData: { z: 1, a: { second: 2, first: 1 } }, + }); + const changedWindow = createMintSwapPreparedPlanFingerprint({ + ...common, + requiredDispatchWindowSeconds: common.requiredDispatchWindowSeconds + 1, + destinationOutputData: { z: 1, a: { second: 2, first: 1 } }, + }); + + expect(first).toBe(reordered); + expect(changedKey).not.toBe(first); + expect(changedOrder).not.toBe(first); + expect(changedDeadline).not.toBe(first); + expect(changedWindow).not.toBe(first); + }); +}); + +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'); + }); +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index bca681f7..484765fd 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -29,6 +29,8 @@ "noPropertyAccessFromIndexSignature": false, "baseUrl": ".", "paths": { + "@cashu/coco-adapter-tests": ["../adapter-tests/src/index.ts"], + "@cashu/coco-core/adapter": ["./adapter.ts"], "@core/*": ["./*"], "@core/models": ["./models/index.ts"], "@core/services": ["./services/index.ts"],