From fdbc84c2fca290167c5b5ee23abf6719a5b97cce Mon Sep 17 00:00:00 2001 From: IgboPharaoh Date: Thu, 30 Jul 2026 09:25:51 +0100 Subject: [PATCH 01/15] feat(core): define durable mint swap contracts --- packages/core/adapter.ts | 22 + packages/core/models/OperationEventOutbox.ts | 172 +++ .../operations/mintSwap/MintSwapOperation.ts | 1027 +++++++++++++++++ packages/core/repositories/index.ts | 35 + packages/core/test/fixtures/MintSwap.ts | 151 +++ .../core/test/unit/MintSwapOperation.test.ts | 382 ++++++ 6 files changed, 1789 insertions(+) create mode 100644 packages/core/models/OperationEventOutbox.ts create mode 100644 packages/core/operations/mintSwap/MintSwapOperation.ts create mode 100644 packages/core/test/fixtures/MintSwap.ts create mode 100644 packages/core/test/unit/MintSwapOperation.test.ts 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/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/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/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/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'); + }); +}); From 4b00b0f9f746762cefed0e9647053722d7e6967c Mon Sep 17 00:00:00 2001 From: IgboPharaoh Date: Thu, 30 Jul 2026 09:26:17 +0100 Subject: [PATCH 02/15] feat(core): add transaction-aware child commands --- .../handlers/melt/BaseQuoteMeltHandler.ts | 155 ++++++++++- packages/core/models/Error.ts | 15 ++ .../core/operations/melt/MeltMethodHandler.ts | 44 +++ .../core/operations/melt/MeltOperation.ts | 15 +- .../operations/melt/MeltOperationService.ts | 252 +++++++++++++++++- .../core/operations/mint/MintMethodHandler.ts | 4 +- .../core/operations/mint/MintOperation.ts | 7 +- .../operations/mint/MintOperationService.ts | 220 ++++++++++++++- .../mintSwap/ChildOperationOwnership.ts | 31 +++ .../memory/MemoryMeltOperationRepository.ts | 26 +- .../memory/MemoryMintOperationRepository.ts | 26 +- packages/core/services/ProofService.ts | 23 +- .../core/test/unit/MeltBolt11Handler.test.ts | 68 +++++ .../test/unit/MeltOperationService.test.ts | 161 +++++++++++ .../core/test/unit/MintBolt11Handler.test.ts | 5 - .../test/unit/MintOperationService.test.ts | 160 ++++++++++- 16 files changed, 1182 insertions(+), 30 deletions(-) create mode 100644 packages/core/operations/mintSwap/ChildOperationOwnership.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/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/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..553290ad 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,22 @@ 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 } 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 +103,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 +295,203 @@ 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(), + }; + 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); + 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); + 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 +510,7 @@ export class MeltOperationService { }'`, ); } + assertChildOperationAccess(operation); const initOp = operation as InitMeltOperation; const releaseMintLock = await this.mintScopedLock.acquire(initOp.mintUrl); @@ -363,6 +585,7 @@ export class MeltOperationService { }'`, ); } + assertChildOperationAccess(operation); const preparedOp = operation as PreparedMeltOperation; @@ -463,6 +686,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 +762,7 @@ export class MeltOperationService { if (!operation) { throw new Error(`Operation ${operationId} not found`); } + assertChildOperationAccess(operation); if ( operation.state === 'finalized' || @@ -624,6 +849,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 +857,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 +866,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 +881,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 +896,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 +931,7 @@ export class MeltOperationService { }'`, ); } + assertChildOperationAccess(op); const persistedQuote = await this.quoteLifecycle.getMeltQuote( op.mintUrl, op.method, @@ -808,6 +1039,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 +1204,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..b4502d97 100644 --- a/packages/core/operations/mint/MintMethodHandler.ts +++ b/packages/core/operations/mint/MintMethodHandler.ts @@ -152,9 +152,11 @@ export interface PrepareContext extends BaseH importedQuote?: MintMethodQuoteSnapshot; } -export interface ExecuteContext extends BaseHandlerDeps { +export interface ExecuteContext { operation: ExecutingMintOperation; wallet: Wallet; + mintAdapter: MintAdapter; + logger?: Logger; } export interface RecoverExecutingContext< 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..e9005852 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,28 @@ 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 { + 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 } from '../mintSwap/ChildOperationOwnership.ts'; + +export interface PrepareOwnedMintOperationCommand { + operationId: string; + parentSwapOperationId: string; + quote: MintQuote; + amount: Amount; + wallet: Wallet; + repositories: RepositoryTransactionScope; +} export interface ClaimMintQuoteOptions { autoClaimRemaining?: boolean; @@ -96,10 +115,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 +268,176 @@ 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'); + } + 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(), + }; + 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); + 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); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId( + operation.mintUrl, + operation.unit, + ); + return this.handlerProvider.get(operation.method).execute({ + 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); + 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); + + 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') { + await repositories.mintQuoteRepository.setMintQuoteState( + current.mintUrl, + current.method, + current.quoteId, + 'ISSUED', + 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 +531,7 @@ export class MintOperationService { if (!operation) { throw new Error(`Operation ${operationId} not found`); } + assertChildOperationAccess(operation); if (isTerminalOperation(operation)) { return operation; @@ -403,6 +596,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 +671,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 +718,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 +738,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 +759,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 +793,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 +975,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 +1070,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 +1184,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 +1382,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..814f8fac --- /dev/null +++ b/packages/core/operations/mintSwap/ChildOperationOwnership.ts @@ -0,0 +1,31 @@ +import { ParentOwnedOperationError } from '../../models/Error.ts'; + +export interface ParentOwnedChildOperation { + id: string; + parentSwapOperationId?: string; +} + +/** + * Verify that a child is standalone or is being advanced by its recorded parent. + * + * 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); + } +} diff --git a/packages/core/repositories/memory/MemoryMeltOperationRepository.ts b/packages/core/repositories/memory/MemoryMeltOperationRepository.ts index e237b5cb..34df0ff6 100644 --- a/packages/core/repositories/memory/MemoryMeltOperationRepository.ts +++ b/packages/core/repositories/memory/MemoryMeltOperationRepository.ts @@ -12,14 +12,20 @@ export class MemoryMeltOperationRepository implements MeltOperationRepository { 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)) { + 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 +83,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 +106,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..d62c2064 100644 --- a/packages/core/repositories/memory/MemoryMintOperationRepository.ts +++ b/packages/core/repositories/memory/MemoryMintOperationRepository.ts @@ -8,13 +8,19 @@ export class MemoryMintOperationRepository implements MintOperationRepository { 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)) { + 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 +79,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/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/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/MintBolt11Handler.test.ts b/packages/core/test/unit/MintBolt11Handler.test.ts index c295e75c..f768a2a7 100644 --- a/packages/core/test/unit/MintBolt11Handler.test.ts +++ b/packages/core/test/unit/MintBolt11Handler.test.ts @@ -181,11 +181,6 @@ describe('MintBolt11Handler', () => { operation: operationOverride, wallet, mintAdapter, - proofService, - proofRepository, - walletService, - mintService, - eventBus, logger, }); diff --git a/packages/core/test/unit/MintOperationService.test.ts b/packages/core/test/unit/MintOperationService.test.ts index a7ca4568..ab3bb0a6 100644 --- a/packages/core/test/unit/MintOperationService.test.ts +++ b/packages/core/test/unit/MintOperationService.test.ts @@ -29,6 +29,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,7 +47,11 @@ 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'; @@ -336,6 +341,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 +3230,152 @@ 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, + }); + + 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.execute).not.toHaveBeenCalled(); + + (handler.execute 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('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, + }; + 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', + }), + ); + + 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, + }; + 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', + }), + ); + 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' }); + }); + }); }); From 2f3d88c2abf9502fcdcf7af7db34b3911d62fd90 Mon Sep 17 00:00:00 2001 From: IgboPharaoh Date: Thu, 30 Jul 2026 09:26:40 +0100 Subject: [PATCH 03/15] fix(core): isolate memory repository transactions --- .../MemoryMintSwapOperationRepository.ts | 115 ++++++++++++ .../MemoryOperationEventOutboxRepository.ts | 80 +++++++++ .../repositories/memory/MemoryRepositories.ts | 164 ++++++++++++++---- .../memory/MemoryRepositoryCoordinator.ts | 37 ++++ packages/core/repositories/memory/clone.ts | 49 ++++++ packages/core/repositories/memory/index.ts | 3 + 6 files changed, 418 insertions(+), 30 deletions(-) create mode 100644 packages/core/repositories/memory/MemoryMintSwapOperationRepository.ts create mode 100644 packages/core/repositories/memory/MemoryOperationEventOutboxRepository.ts create mode 100644 packages/core/repositories/memory/MemoryRepositoryCoordinator.ts create mode 100644 packages/core/repositories/memory/clone.ts 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..74719f93 --- /dev/null +++ b/packages/core/repositories/memory/clone.ts @@ -0,0 +1,49 @@ +export function cloneMemoryValue(value: T, seen = new Map()): T { + if (value === null || typeof value !== 'object') return value; + if (seen.has(value)) return seen.get(value) as T; + + if (value instanceof Map) { + const result = new Map(); + seen.set(value, result); + for (const [key, item] of value) { + result.set(cloneMemoryValue(key, seen), cloneMemoryValue(item, seen)); + } + return result as T; + } + if (value instanceof Set) { + const result = new Set(); + seen.set(value, result); + for (const item of value) result.add(cloneMemoryValue(item, seen)); + return result as T; + } + if (Array.isArray(value)) { + const result: unknown[] = []; + seen.set(value, result); + for (const item of value) result.push(cloneMemoryValue(item, seen)); + return result as T; + } + if (value instanceof Date) return new Date(value.getTime()) as T; + + const result = Object.create(Object.getPrototypeOf(value)) as Record; + seen.set(value, result); + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor) continue; + if ('value' in descriptor) descriptor.value = cloneMemoryValue(descriptor.value, seen); + Object.defineProperty(result, key, descriptor); + } + return result as T; +} + +export function copyMemoryRepositoryState( + source: object, + target: object, + excludedKeys: readonly string[] = [], +): void { + const excluded = new Set(excludedKeys); + const sourceRecord = source as Record; + const targetRecord = target as Record; + for (const key of Object.keys(sourceRecord)) { + if (!excluded.has(key)) targetRecord[key] = cloneMemoryValue(sourceRecord[key]); + } +} diff --git a/packages/core/repositories/memory/index.ts b/packages/core/repositories/memory/index.ts index 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'; From 98db969c8b8c194a500d168e0149c48fe5f28dfc Mon Sep 17 00:00:00 2001 From: IgboPharaoh Date: Thu, 30 Jul 2026 09:26:59 +0100 Subject: [PATCH 04/15] test(adapter-tests): add mint swap repository contracts --- packages/adapter-tests/src/index.ts | 407 +++++++++++++++++- .../unit/MemoryMintSwapRepositories.test.ts | 59 +++ packages/core/tsconfig.json | 2 + 3 files changed, 464 insertions(+), 4 deletions(-) create mode 100644 packages/core/test/unit/MemoryMintSwapRepositories.test.ts diff --git a/packages/adapter-tests/src/index.ts b/packages/adapter-tests/src/index.ts index 586300b8..285280c9 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'; @@ -77,6 +80,51 @@ export async function runRepositoryTransactionContract( }); 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 +155,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 +173,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 +247,10 @@ function createDeferred() { return { promise, resolve, reject } as const; } +async function flushMicrotasks(turns = 10): Promise { + for (let turn = 0; turn < turns; turn++) await Promise.resolve(); +} + export function createDummyMint(): Mint { return { mintUrl: 'https://mint.test', @@ -635,6 +707,333 @@ 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', + }); + 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', + }), + ), + 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.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', + }), + ); + 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/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/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"], From bd3e7805f0536cb09ed1902a74ccaf2906aeed8f Mon Sep 17 00:00:00 2001 From: IgboPharaoh Date: Tue, 4 Aug 2026 16:27:43 +0100 Subject: [PATCH 05/15] fix(core): align parent-owned operation invariants --- .../infra/handlers/mint/MintBolt11Handler.ts | 9 ++ .../operations/melt/MeltOperationService.ts | 8 +- .../core/operations/mint/MintMethodHandler.ts | 12 +- .../operations/mint/MintOperationService.ts | 50 +++++++- .../mintSwap/ChildOperationOwnership.ts | 57 +++++++++ .../memory/MemoryMeltOperationRepository.ts | 3 + .../memory/MemoryMintOperationRepository.ts | 3 + .../core/test/unit/MintBolt11Handler.test.ts | 5 + .../test/unit/MintOperationService.test.ts | 118 +++++++++++++++++- 9 files changed, 254 insertions(+), 11 deletions(-) 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/operations/melt/MeltOperationService.ts b/packages/core/operations/melt/MeltOperationService.ts index 553290ad..359611f1 100644 --- a/packages/core/operations/melt/MeltOperationService.ts +++ b/packages/core/operations/melt/MeltOperationService.ts @@ -46,7 +46,10 @@ import { type MeltQuote, } from '../../models/MeltQuote.ts'; import type { MeltQuoteRef, QuoteIdentity } from '../../models/QuoteIdentity.ts'; -import { assertChildOperationAccess } from '../mintSwap/ChildOperationOwnership.ts'; +import { + assertChildOperationAccess, + assertParentOwnedMeltOperationInvariant, +} from '../mintSwap/ChildOperationOwnership.ts'; export interface PrepareOwnedMeltOperationCommand { operationId: string; @@ -373,6 +376,7 @@ export class MeltOperationService { parentExecutionPhase: operation.needsSwap ? 'pre_swap_authorized' : 'melt_authorized', updatedAt: Date.now(), }; + assertParentOwnedMeltOperationInvariant(executing); await repositories.meltOperationRepository.update(executing); return executing; } @@ -387,6 +391,7 @@ export class MeltOperationService { parentSwapOperationId: string, ): Promise { assertChildOperationAccess(operation, parentSwapOperationId); + assertParentOwnedMeltOperationInvariant(operation); const { wallet } = await this.walletService.getWalletWithActiveKeysetId( operation.mintUrl, operation.unit, @@ -454,6 +459,7 @@ export class MeltOperationService { : applied.failed : applied; assertChildOperationAccess(next, parentSwapOperationId); + assertParentOwnedMeltOperationInvariant(next); await repositories.meltOperationRepository.update(next); return next as | ExecutingMeltOperation diff --git a/packages/core/operations/mint/MintMethodHandler.ts b/packages/core/operations/mint/MintMethodHandler.ts index b4502d97..e9e44aea 100644 --- a/packages/core/operations/mint/MintMethodHandler.ts +++ b/packages/core/operations/mint/MintMethodHandler.ts @@ -152,13 +152,17 @@ export interface PrepareContext extends BaseH importedQuote?: MintMethodQuoteSnapshot; } -export interface ExecuteContext { +export interface ExecuteContext extends BaseHandlerDeps { operation: ExecutingMintOperation; wallet: Wallet; - mintAdapter: MintAdapter; - logger?: Logger; } +/** 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 { @@ -229,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/MintOperationService.ts b/packages/core/operations/mint/MintOperationService.ts index e9005852..a02638b5 100644 --- a/packages/core/operations/mint/MintOperationService.ts +++ b/packages/core/operations/mint/MintOperationService.ts @@ -44,6 +44,7 @@ import type { MintHandlerProvider } from '../../infra/handlers/mint'; import { MintScopedLock } from '../MintScopedLock'; import { OperationIdLock } from '../OperationIdLock'; import { + deriveBolt11MintQuoteState, getMintQuoteAvailableAmount, getMintQuoteAmount, mintQuoteToMethodSnapshot, @@ -55,13 +56,17 @@ import { } from '../../models/MintQuoteClaimability.ts'; import type { MintQuoteRef } from '../../models/QuoteIdentity'; import type { QuoteLifecycle } from '../../quotes/QuoteLifecycle'; -import { assertChildOperationAccess } from '../mintSwap/ChildOperationOwnership.ts'; +import { + assertChildOperationAccess, + assertParentOwnedMintOperationInvariant, +} from '../mintSwap/ChildOperationOwnership.ts'; export interface PrepareOwnedMintOperationCommand { operationId: string; parentSwapOperationId: string; quote: MintQuote; amount: Amount; + destinationNut20PublicKey: string; wallet: Wallet; repositories: RepositoryTransactionScope; } @@ -280,6 +285,9 @@ export class MintOperationService { 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') { @@ -312,6 +320,10 @@ export class MintOperationService { 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; } @@ -329,6 +341,7 @@ export class MintOperationService { ); } assertChildOperationAccess(operation, parentSwapOperationId); + assertParentOwnedMintOperationInvariant(operation); const executing: ExecutingMintOperation = { ...operation, state: 'executing', @@ -349,11 +362,16 @@ export class MintOperationService { parentSwapOperationId: string, ): Promise { assertChildOperationAccess(operation, parentSwapOperationId); + assertParentOwnedMintOperationInvariant(operation); const { wallet } = await this.walletService.getWalletWithActiveKeysetId( operation.mintUrl, operation.unit, ); - return this.handlerProvider.get(operation.method).execute({ + 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, @@ -369,6 +387,7 @@ export class MintOperationService { repositories: RepositoryTransactionScope, ): Promise { assertChildOperationAccess(operation, parentSwapOperationId); + assertParentOwnedMintOperationInvariant(operation); const current = await repositories.mintOperationRepository.getById(operation.id); if (!current || current.state !== 'executing') { throw new Error( @@ -376,6 +395,7 @@ export class MintOperationService { ); } assertChildOperationAccess(current, parentSwapOperationId); + assertParentOwnedMintOperationInvariant(current); if (result.status === 'FAILED') { throw new Error(result.error ?? 'Mint execution failed'); @@ -420,13 +440,33 @@ export class MintOperationService { } if (current.method === 'bolt11') { - await repositories.mintQuoteRepository.setMintQuoteState( + const quote = await repositories.mintQuoteRepository.getMintQuote( current.mintUrl, current.method, current.quoteId, - 'ISSUED', - Date.now(), ); + 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, diff --git a/packages/core/operations/mintSwap/ChildOperationOwnership.ts b/packages/core/operations/mintSwap/ChildOperationOwnership.ts index 814f8fac..0e700846 100644 --- a/packages/core/operations/mintSwap/ChildOperationOwnership.ts +++ b/packages/core/operations/mintSwap/ChildOperationOwnership.ts @@ -1,4 +1,6 @@ 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; @@ -29,3 +31,58 @@ export function assertChildOperationAccess( 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/repositories/memory/MemoryMeltOperationRepository.ts b/packages/core/repositories/memory/MemoryMeltOperationRepository.ts index 34df0ff6..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,6 +9,7 @@ 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`); } @@ -17,6 +19,7 @@ export class MemoryMeltOperationRepository implements MeltOperationRepository { } async update(operation: MeltOperation): Promise { + assertParentOwnedMeltOperationInvariant(operation); const existing = this.operations.get(operation.id); if (!existing) { throw new Error(`MeltOperation with id ${operation.id} not found`); diff --git a/packages/core/repositories/memory/MemoryMintOperationRepository.ts b/packages/core/repositories/memory/MemoryMintOperationRepository.ts index d62c2064..42c98c5b 100644 --- a/packages/core/repositories/memory/MemoryMintOperationRepository.ts +++ b/packages/core/repositories/memory/MemoryMintOperationRepository.ts @@ -1,10 +1,12 @@ 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`); } @@ -13,6 +15,7 @@ export class MemoryMintOperationRepository implements MintOperationRepository { } async update(operation: MintOperation): Promise { + assertParentOwnedMintOperationInvariant(operation); const existing = this.operations.get(operation.id); if (!existing) { throw new Error(`MintOperation with id ${operation.id} not found`); diff --git a/packages/core/test/unit/MintBolt11Handler.test.ts b/packages/core/test/unit/MintBolt11Handler.test.ts index f768a2a7..c295e75c 100644 --- a/packages/core/test/unit/MintBolt11Handler.test.ts +++ b/packages/core/test/unit/MintBolt11Handler.test.ts @@ -181,6 +181,11 @@ describe('MintBolt11Handler', () => { operation: operationOverride, wallet, mintAdapter, + proofService, + proofRepository, + walletService, + mintService, + eventBus, logger, }); diff --git a/packages/core/test/unit/MintOperationService.test.ts b/packages/core/test/unit/MintOperationService.test.ts index ab3bb0a6..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, @@ -57,6 +58,7 @@ 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; @@ -329,6 +331,7 @@ describe('MintOperationService', () => { ), prepare: mockPrepare, execute: mockExecute, + executeOwnedRemote: mockExecute, recoverExecuting: mockRecoverExecuting, checkPending: mockCheckPending, }; @@ -3237,6 +3240,77 @@ describe('MintOperationService', () => { 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 () => { @@ -3273,9 +3347,9 @@ describe('MintOperationService', () => { operation.id, ); expect(persistedAuthorization?.state).toBe('executing'); - expect(handler.execute).not.toHaveBeenCalled(); + expect(handler.executeOwnedRemote).not.toHaveBeenCalled(); - (handler.execute as Mock).mockImplementationOnce(async (context: object) => { + (handler.executeOwnedRemote as Mock).mockImplementationOnce(async (context: object) => { expect(transactionReturned).toBe(true); expect('proofRepository' in context).toBe(false); expect('proofService' in context).toBe(false); @@ -3294,11 +3368,34 @@ describe('MintOperationService', () => { 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( @@ -3309,6 +3406,10 @@ describe('MintOperationService', () => { 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, }), ); @@ -3340,6 +3441,7 @@ describe('MintOperationService', () => { const operation = { ...makeExecutingOp('owned-idempotent-apply', 'existing-output'), parentSwapOperationId, + pubkey: destinationNut20PublicKey, }; await repositories.mintOperationRepository.create(operation); await repositories.proofRepository.saveProofs(mintUrl, [ @@ -3353,6 +3455,10 @@ describe('MintOperationService', () => { 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 () => {}); @@ -3376,6 +3482,14 @@ describe('MintOperationService', () => { 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); }); }); }); From 806d33ff3381fa20ab52b33875dfcf1cad6f957f Mon Sep 17 00:00:00 2001 From: IgboPharaoh Date: Tue, 4 Aug 2026 16:27:52 +0100 Subject: [PATCH 06/15] test(adapter-tests): enforce child operation invariants --- packages/adapter-tests/src/index.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/adapter-tests/src/index.ts b/packages/adapter-tests/src/index.ts index 285280c9..34c1bae4 100644 --- a/packages/adapter-tests/src/index.ts +++ b/packages/adapter-tests/src/index.ts @@ -903,6 +903,7 @@ export async function runMintSwapRepositoryContract( id: 'owned-mint', quoteId: 'owned-mint-quote', parentSwapOperationId: 'mint-parent', + pubkey: `02${'ab'.repeat(32)}`, }); await repositories.mintOperationRepository.create(mintChild); expect( @@ -923,6 +924,7 @@ export async function runMintSwapRepositoryContract( id: 'second-owned-mint', quoteId: 'second-owned-mint-quote', parentSwapOperationId: 'mint-parent', + pubkey: `02${'ab'.repeat(32)}`, }), ), expect, @@ -946,6 +948,14 @@ export async function runMintSwapRepositoryContract( }), expect, ); + await expectThrows( + () => + repositories.meltOperationRepository.update({ + ...meltChild, + parentExecutionPhase: 'melt_authorized', + }), + expect, + ); await expectThrows( () => repositories.meltOperationRepository.create( @@ -979,6 +989,7 @@ export async function runMintSwapRepositoryContract( id: 'rolled-back-child', quoteId: 'rolled-back-child-quote', parentSwapOperationId: 'mint-swap-op', + pubkey: `02${'ab'.repeat(32)}`, }), ); throw new Error('injected rollback'); From 1e6105f55467f361149078a63eb632f6c2fd603f Mon Sep 17 00:00:00 2001 From: IgboPharaoh Date: Wed, 5 Aug 2026 13:04:34 +0100 Subject: [PATCH 07/15] fix(core): preserve binary payloads in memory transaction clones --- packages/adapter-tests/src/index.ts | 51 ++++++++++++++++++++++ packages/core/repositories/memory/clone.ts | 20 +++++++++ 2 files changed, 71 insertions(+) diff --git a/packages/adapter-tests/src/index.ts b/packages/adapter-tests/src/index.ts index 34c1bae4..193ab078 100644 --- a/packages/adapter-tests/src/index.ts +++ b/packages/adapter-tests/src/index.ts @@ -79,6 +79,49 @@ 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(); @@ -251,6 +294,14 @@ 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', diff --git a/packages/core/repositories/memory/clone.ts b/packages/core/repositories/memory/clone.ts index 74719f93..991a6c5c 100644 --- a/packages/core/repositories/memory/clone.ts +++ b/packages/core/repositories/memory/clone.ts @@ -23,6 +23,26 @@ export function cloneMemoryValue(value: T, seen = new Map()) 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); From adc61b2f306d406517f324d026d6bf82edbdf6de Mon Sep 17 00:00:00 2001 From: IgboPharaoh Date: Wed, 5 Aug 2026 13:04:34 +0100 Subject: [PATCH 08/15] ci(core): build packages before core unit tests --- .github/workflows/core_tests.yml | 7 +++++++ packages/core/repositories/memory/clone.ts | 16 +++++++++------- 2 files changed, 16 insertions(+), 7 deletions(-) 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/core/repositories/memory/clone.ts b/packages/core/repositories/memory/clone.ts index 991a6c5c..f3cc49c4 100644 --- a/packages/core/repositories/memory/clone.ts +++ b/packages/core/repositories/memory/clone.ts @@ -33,13 +33,15 @@ export function cloneMemoryValue(value: T, seen = new Map()) 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); + : 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; } From ee6ea06f3148cffa2b7bdd77baa6c41615171f94 Mon Sep 17 00:00:00 2001 From: IgboPharaoh Date: Fri, 7 Aug 2026 13:42:49 +0100 Subject: [PATCH 09/15] feat(core): realign swap orchestration to incorporate mintswap protocol foundations --- packages/adapter-tests/src/index.ts | 83 ++++- packages/core/adapter.ts | 16 + .../handlers/melt/BaseQuoteMeltHandler.ts | 85 +++-- packages/core/models/OperationEventOutbox.ts | 20 ++ .../core/operations/melt/MeltMethodHandler.ts | 14 +- .../operations/melt/MeltOperationService.ts | 272 ++++++++++++++-- .../core/operations/mint/MintMethodHandler.ts | 2 + .../operations/mint/MintOperationService.ts | 137 ++++++-- .../mintSwap/ChildOperationOwnership.ts | 237 ++++++++++++++ .../operations/mintSwap/MintSwapOperation.ts | 161 ++++++++- packages/core/quotes/QuoteLifecycle.ts | 86 ++--- packages/core/repositories/index.ts | 11 + .../memory/MemoryMeltOperationRepository.ts | 26 +- .../memory/MemoryMintOperationRepository.ts | 26 +- .../MemoryOperationEventOutboxRepository.ts | 6 + .../repositories/memory/MemoryRepositories.ts | 67 +++- packages/core/repositories/memory/clone.ts | 20 +- packages/core/repositories/memory/index.ts | 1 - packages/core/services/ProofService.ts | 70 ++-- packages/core/test/fixtures/MintSwap.ts | 28 +- .../core/test/unit/MeltBolt11Handler.test.ts | 8 +- .../test/unit/MeltOperationService.test.ts | 306 +++++++++++++++++- .../test/unit/MintOperationService.test.ts | 99 +++++- .../core/test/unit/MintSwapOperation.test.ts | 184 ++++++++++- packages/core/test/unit/ProofService.test.ts | 30 ++ packages/core/tsconfig.json | 1 + packages/core/utils.ts | 30 ++ 27 files changed, 1792 insertions(+), 234 deletions(-) diff --git a/packages/adapter-tests/src/index.ts b/packages/adapter-tests/src/index.ts index 193ab078..22567b6c 100644 --- a/packages/adapter-tests/src/index.ts +++ b/packages/adapter-tests/src/index.ts @@ -814,7 +814,7 @@ export function createDummyMintSwapOperation( }, sourceMeltOperationId: 'source-melt-op', preparedPlan: { - fingerprint: 'adapter-contract-fingerprint', + fingerprint: 'ab'.repeat(32), dispatchDeadlineSeconds: Math.floor(now / 1_000) + 600, requiredDispatchWindowSeconds: 120, sourceMeltAmount: destinationAmount, @@ -949,17 +949,74 @@ export async function runMintSwapRepositoryContract( ), expect, ); - + const standaloneMint = createDummyMintOperation({ + id: 'standalone-mint', + quoteId: 'standalone-mint-quote', + }); + await repositories.mintOperationRepository.create(standaloneMint); + await expectThrows( + () => + repositories.mintOperationRepository.update({ + ...standaloneMint, + parentSwapOperationId: 'late-mint-parent', + }), + expect, + ); + const standaloneMelt = createDummyMeltOperation({ + id: 'standalone-melt', + quoteId: 'standalone-melt-quote', + }); + await repositories.meltOperationRepository.create(standaloneMelt); + await expectThrows( + () => + repositories.meltOperationRepository.update({ + ...standaloneMelt, + parentSwapOperationId: 'late-melt-parent', + }), + expect, + ); const mintChild = createDummyMintOperation({ id: 'owned-mint', quoteId: 'owned-mint-quote', parentSwapOperationId: 'mint-parent', pubkey: `02${'ab'.repeat(32)}`, + outputData: { + keep: [ + { + blindedMessage: { amount: 3, id: 'owned-keyset', B_: 'owned-B' }, + blindingFactor: '01', + secret: '6f776e65642d6f7574707574', + }, + ], + send: [], + }, }); await repositories.mintOperationRepository.create(mintChild); expect( (await repositories.mintOperationRepository.getById(mintChild.id))?.parentSwapOperationId, ).toBe('mint-parent'); + await expectThrows( + () => + repositories.mintOperationRepository.update({ + ...mintChild, + amount: mintChild.amount.add(1), + }), + expect, + ); + const fetchedMintChild = await repositories.mintOperationRepository.getById(mintChild.id); + if (!fetchedMintChild || fetchedMintChild.state === 'init') { + throw new Error('Expected a persisted pending mint child'); + } + fetchedMintChild.outputData.keep.push({ + blindedMessage: { amount: 1, id: 'mutated-keyset', B_: 'mutated-B' }, + blindingFactor: '01', + secret: '61', + }); + const refetchedMintChild = await repositories.mintOperationRepository.getById(mintChild.id); + if (!refetchedMintChild || refetchedMintChild.state === 'init') { + throw new Error('Expected a persisted pending mint child'); + } + expect(refetchedMintChild.outputData.keep.length).toBe(1); await expectThrows( () => repositories.mintOperationRepository.update({ @@ -976,6 +1033,16 @@ export async function runMintSwapRepositoryContract( quoteId: 'second-owned-mint-quote', parentSwapOperationId: 'mint-parent', pubkey: `02${'ab'.repeat(32)}`, + outputData: { + keep: [ + { + blindedMessage: { amount: 3, id: 'owned-keyset', B_: 'owned-B-2' }, + blindingFactor: '02', + secret: '7365636f6e642d6f7574707574', + }, + ], + send: [], + }, }), ), expect, @@ -1083,6 +1150,18 @@ export async function runMintSwapRepositoryContract( 1_700_000_000_011, ); expect(await capability.operationEventOutboxRepository.getUnpublished(10)).toHaveLength(0); + const published = + await capability.operationEventOutboxRepository.getById('mint-swap-event'); + expect(published?.publishedAt).toBe(1_700_000_000_011); + expect(published?.publishAttempts).toBe(2); + expect(published?.lastError).toBe(undefined); + await expectThrows( + () => + capability.operationEventOutboxRepository.enqueue( + createDummyOperationEventOutboxRecord({ id: 'published-logical-event' }), + ), + expect, + ); } finally { await dispose(); } diff --git a/packages/core/adapter.ts b/packages/core/adapter.ts index d313eb44..65aaaf4e 100644 --- a/packages/core/adapter.ts +++ b/packages/core/adapter.ts @@ -23,6 +23,7 @@ export type { RepositoryTransactionScope, SendOperationRepository, } from './repositories/index.ts'; +export { requireMintSwapRepositoryCapability } from './repositories/index.ts'; export type { AuthSession, Counter, @@ -95,6 +96,21 @@ export type { MintSwapSettlement, MintSwapTerminalFailure, } from './operations/mintSwap/MintSwapOperation.ts'; +export { + assertMintSwapOperationUpdate, + createMintSwapPreparedPlanFingerprint, + validateMintSwapOperation, +} from './operations/mintSwap/MintSwapOperation.ts'; +export { + assertParentOwnedMeltOperationInvariant, + assertParentOwnedMeltOperationUpdate, + assertParentOwnedMintOperationInvariant, + assertParentOwnedMintOperationUpdate, +} from './operations/mintSwap/ChildOperationOwnership.ts'; +export { + operationEventLogicalKey, + validateOperationEventOutboxRecord, +} from './models/OperationEventOutbox.ts'; export type { MeltMethodRemoteState } from './operations/melt/MeltMethodHandler.ts'; export { normalizeMeltMethodData } from './operations/index.ts'; export type { BalanceQuery, CoreProof, ProofState } from './types.ts'; diff --git a/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts b/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts index 5b2b7c68..c69ee6da 100644 --- a/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts +++ b/packages/core/infra/handlers/melt/BaseQuoteMeltHandler.ts @@ -35,6 +35,7 @@ import type { RollbackContext, } from '@core/operations/melt'; import { + assertProofsMatchSerializedOutputs, computeYHexForSecrets, deserializeOutputData, getSecretsFromSerializedOutputData, @@ -366,9 +367,6 @@ export abstract class BaseQuoteMeltHandler implements Melt const blankOutputs = await this.createChangeOutputs(amount, sendAmount, ctx); - // FIXME: This relies on the 10% swap threshold buffer to cover the future melt input fee. - // Pathological fee/output combinations can still make the fee-inflated send side exceed - // the amount validated above. const swapOutputData = await ctx.proofService.createOutputsAndIncrementCounters( mintUrl, { @@ -494,6 +492,7 @@ export abstract class BaseQuoteMeltHandler implements Melt return { operationId: operation.id, phase: 'pre_swap', + observedAt: Date.now(), sendProofs: send, keepProofs: keep, }; @@ -509,7 +508,7 @@ export abstract class BaseQuoteMeltHandler implements Melt changeOutputData.keep, operation.quoteId, ); - return { operationId: operation.id, phase: 'melt', response }; + return { operationId: operation.id, phase: 'melt', observedAt: Date.now(), response }; } /** Apply one remote result using transaction-scoped proof services supplied by the parent. */ @@ -527,8 +526,16 @@ export abstract class BaseQuoteMeltHandler implements Melt 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'); + assertProofsMatchSerializedOutputs( + result.sendProofs, + operation.swapOutputData.send, + 'Melt pre-swap send', + ); + assertProofsMatchSerializedOutputs( + result.keepProofs, + operation.swapOutputData.keep, + 'Melt pre-swap keep', + ); await ctx.proofService.setProofState(operation.mintUrl, operation.inputProofSecrets, 'spent'); const newProofs = [ @@ -556,7 +563,14 @@ export abstract class BaseQuoteMeltHandler implements Melt return ( proof.createdByOperationId !== operation.id || proof.state !== expectedState || - proof.unit !== operation.unit + proof.unit !== operation.unit || + ![...result.keepProofs, ...result.sendProofs].some( + (remote) => + remote.secret === proof.secret && + remote.id === proof.id && + Amount.from(remote.amount).equals(proof.amount) && + remote.C === proof.C, + ) ); }) ) { @@ -580,7 +594,51 @@ export abstract class BaseQuoteMeltHandler implements Melt operation.amount, result.response.change, ); - await this.finalizeOperation(ctx, result.response.change); + const changeSignatures = result.response.change ?? []; + const changeProofs = result.changeProofs ?? []; + assertProofsMatchSerializedOutputs( + changeProofs, + operation.changeOutputData.keep.slice(0, changeSignatures.length), + `Melt child ${operation.id} change`, + ); + if (!sumProofs(changeProofs).equals(changeAmount)) { + throw new Error(`Melt child ${operation.id} change proofs do not reconcile`); + } + const meltInputSecrets = this.getMeltInputSecrets(operation); + await ctx.proofService.setProofState(operation.mintUrl, meltInputSecrets, 'spent'); + if (changeProofs.length > 0) { + const expectedSecrets = changeProofs.map(({ secret }) => secret); + const existing = await ctx.proofRepository.getProofsBySecrets( + operation.mintUrl, + expectedSecrets, + ); + if (existing.length === 0) { + await ctx.proofService.saveProofs( + operation.mintUrl, + mapProofToCoreProof(operation.mintUrl, 'ready', changeProofs, { + unit: operation.unit, + createdByOperationId: operation.id, + }), + ); + } else if ( + existing.length !== changeProofs.length || + existing.some( + (proof) => + proof.createdByOperationId !== operation.id || + proof.state !== 'ready' || + proof.unit !== operation.unit || + !changeProofs.some( + (remote) => + remote.secret === proof.secret && + remote.id === proof.id && + Amount.from(remote.amount).equals(proof.amount) && + remote.C === proof.C, + ), + ) + ) { + throw new Error(`Melt child ${operation.id} has an invalid change proof set`); + } + } return buildPaidResult(operation, { changeAmount, effectiveFee, @@ -599,17 +657,6 @@ export abstract class BaseQuoteMeltHandler implements Melt } } - 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. */ diff --git a/packages/core/models/OperationEventOutbox.ts b/packages/core/models/OperationEventOutbox.ts index c416f8c3..30e36fc3 100644 --- a/packages/core/models/OperationEventOutbox.ts +++ b/packages/core/models/OperationEventOutbox.ts @@ -55,6 +55,17 @@ const OPERATION_STATES = new Set([ 'failed', 'needs_attention', ]); +const DELAYED_STATES = new Set([ + 'preparing', + 'source_inflight', + 'destination_funded', + 'issuing', +]); +const REASON_REQUIRED_EVENTS = new Set([ + 'mint-swap-op:failed', + 'mint-swap-op:needs-attention', + 'mint-swap-op:delayed', +]); export function operationEventLogicalKey( record: Pick, @@ -102,6 +113,9 @@ export function validateOperationEventOutboxRecord( if (expectedState !== undefined && record.payload.state !== expectedState) { throw new Error(`Outbox ${record.eventType} payload must contain state ${expectedState}`); } + if (record.eventType === 'mint-swap-op:delayed' && !DELAYED_STATES.has(record.payload.state)) { + throw new Error('Delayed mint swap events require an automatic operation state'); + } if (record.payload.unit !== 'sat') throw new Error('Outbox mint swap unit must be sat'); const destinationAmount = Amount.from(record.payload.destinationAmount); @@ -128,6 +142,9 @@ export function validateOperationEventOutboxRecord( if (record.payload.reasonCode !== undefined) { assertNonEmpty(record.payload.reasonCode, 'Outbox reason code'); } + if (REASON_REQUIRED_EVENTS.has(record.eventType) && record.payload.reasonCode === undefined) { + throw new Error(`Outbox ${record.eventType} requires a reason code`); + } if (record.lastError !== undefined) assertNonEmpty(record.lastError, 'Outbox last error'); if (record.nextAttemptAt !== undefined) { @@ -144,6 +161,9 @@ export function validateOperationEventOutboxRecord( if (record.nextAttemptAt !== undefined || record.lastError !== undefined) { throw new Error('Published outbox records cannot retain retry scheduling'); } + if (record.publishAttempts === 0) { + throw new Error('Published outbox records must record at least one publish attempt'); + } } else if (record.publishAttempts === 0) { if (record.nextAttemptAt !== undefined || record.lastError !== undefined) { throw new Error('An unattempted outbox record cannot contain retry scheduling'); diff --git a/packages/core/operations/melt/MeltMethodHandler.ts b/packages/core/operations/melt/MeltMethodHandler.ts index cdc7c76b..8da48efa 100644 --- a/packages/core/operations/melt/MeltMethodHandler.ts +++ b/packages/core/operations/melt/MeltMethodHandler.ts @@ -161,12 +161,16 @@ export type OwnedMeltRemoteResult = | { operationId: string; phase: 'pre_swap'; + observedAt?: number; sendProofs: Proof[]; keepProofs: Proof[]; } | { operationId: string; phase: 'melt'; + observedAt?: number; + /** Fully unblinded outside the transaction; persisted only while applying the result. */ + changeProofs?: Proof[]; response: { state: MeltMethodRemoteState; change?: SerializedBlindedSignature[]; @@ -175,10 +179,14 @@ export type OwnedMeltRemoteResult = }; }; -export interface ApplyOwnedMeltRemoteContext< - M extends MeltMethod = MeltMethod, -> extends BaseHandlerDeps { +export interface ApplyOwnedMeltRemoteContext { operation: ExecutingMeltOperation & MeltMethodMeta; + proofRepository: ProofRepository; + proofService: Pick< + ProofService, + 'setProofState' | 'restoreProofsToReady' | 'saveProofs' | 'releaseProofs' + >; + logger?: Logger; } export interface PendingContext extends BaseHandlerDeps { diff --git a/packages/core/operations/melt/MeltOperationService.ts b/packages/core/operations/melt/MeltOperationService.ts index 359611f1..b65b6d41 100644 --- a/packages/core/operations/melt/MeltOperationService.ts +++ b/packages/core/operations/melt/MeltOperationService.ts @@ -1,9 +1,10 @@ -import type { Wallet } from '@cashu/cashu-ts'; +import { Amount, type SerializedBlindedSignature, type Wallet } from '@cashu/cashu-ts'; import type { MeltOperationRepository, ProofRepository, RepositoryTransactionScope, } from '../../repositories'; +import { requireMintSwapRepositoryCapability } from '../../repositories'; import type { MeltOperation, InitMeltOperation, @@ -31,7 +32,13 @@ import type { ProofService } from '../../services/ProofService'; import type { EventBus } from '../../events/EventBus'; import type { CoreEvents } from '../../events/types'; import type { Logger } from '../../logging/Logger'; -import { generateSubId, getSecretsFromSerializedOutputData, normalizeMintUrl } from '../../utils'; +import { + assertProofsMatchSerializedOutputs, + deserializeOutputData, + generateSubId, + getSecretsFromSerializedOutputData, + normalizeMintUrl, +} from '../../utils'; import { UnknownMintError, ProofValidationError } from '../../models/Error'; import type { MintAdapter } from '@core/infra'; import type { MeltHandlerProvider } from '../../infra/handlers/melt'; @@ -46,6 +53,7 @@ import { type MeltQuote, } from '../../models/MeltQuote.ts'; import type { MeltQuoteRef, QuoteIdentity } from '../../models/QuoteIdentity.ts'; +import { resolveAndPersistMeltQuoteObservation } from '../../quotes/QuoteLifecycle.ts'; import { assertChildOperationAccess, assertParentOwnedMeltOperationInvariant, @@ -55,11 +63,15 @@ export interface PrepareOwnedMeltOperationCommand { operationId: string; parentSwapOperationId: string; quote: MeltQuote; - wallet: Wallet; + preparedOperation: PreparedMeltOperation; repositories: RepositoryTransactionScope; - feeIndex?: number; } +export type PlanOwnedMeltOperationCommand = Omit< + PrepareOwnedMeltOperationCommand, + 'preparedOperation' | 'repositories' +> & { wallet: Wallet }; + /** * MeltOperationService orchestrates melt sagas while delegating * method-specific behavior to MeltMethodHandlers. @@ -298,11 +310,11 @@ export class MeltOperationService { } } - /** Prepare and persist a parent-owned source child using transaction-scoped local writes. */ - async prepareOwnedInTransaction( - command: PrepareOwnedMeltOperationCommand, + /** Build deterministic source work outside the parent transaction without reserving proofs. */ + async planOwnedPreparation( + command: PlanOwnedMeltOperationCommand, ): Promise { - const { quote, operationId, parentSwapOperationId, repositories, wallet } = command; + const { quote, operationId, parentSwapOperationId, wallet } = command; if (quote.method !== 'bolt11' || quote.unit !== 'sat') { throw new Error('Mint swaps require a sat-denominated BOLT11 source quote'); } @@ -314,8 +326,17 @@ export class MeltOperationService { quote.unit, { quoteId: quote.quoteId, parentSwapOperationId }, ); + const planningProofService = { + selectProofsToSend: this.proofService.selectProofsToSend.bind(this.proofService), + createBlankOutputs: this.proofService.createBlankOutputs.bind(this.proofService), + createOutputsAndIncrementCounters: this.proofService.createOutputsAndIncrementCounters.bind( + this.proofService, + ), + reserveProofs: async () => ({ amount: Amount.zero(), unit: quote.unit }), + }; const prepared = await this.handlerProvider.get('bolt11').prepare({ - ...this.buildDeps(repositories), + ...this.buildDeps(), + proofService: planningProofService as never, operation: initOperation as never, wallet, quote: meltQuoteToMethodSnapshot(quote as MeltQuote<'bolt11'>), @@ -327,6 +348,58 @@ export class MeltOperationService { state: 'prepared', updatedAt: Date.now(), }; + return preparedOperation; + } + + /** Reserve the preflighted inputs and persist the source child in one local transaction. */ + async prepareOwnedInTransaction( + command: PrepareOwnedMeltOperationCommand, + ): Promise { + const { quote, operationId, parentSwapOperationId, repositories, preparedOperation } = command; + requireMintSwapRepositoryCapability(repositories); + if ( + quote.method !== 'bolt11' || + quote.unit !== 'sat' || + preparedOperation.id !== operationId || + preparedOperation.parentSwapOperationId !== parentSwapOperationId || + preparedOperation.mintUrl !== quote.mintUrl || + preparedOperation.method !== 'bolt11' || + preparedOperation.quoteId !== quote.quoteId || + preparedOperation.unit !== quote.unit || + !preparedOperation.amount.equals(quote.amount) || + !preparedOperation.fee_reserve.equals(quote.fee_reserve) || + !('invoice' in preparedOperation.methodData) || + preparedOperation.methodData.invoice !== quote.request + ) { + throw new Error('Preflighted source child does not match its owned command'); + } + assertParentOwnedMeltOperationInvariant(preparedOperation); + const inputs = await repositories.proofRepository.getProofsBySecrets( + preparedOperation.mintUrl, + preparedOperation.inputProofSecrets, + ); + if ( + inputs.length !== preparedOperation.inputProofSecrets.length || + inputs.some( + (proof) => + proof.state !== 'ready' || + proof.usedByOperationId !== undefined || + proof.unit !== preparedOperation.unit, + ) + ) { + throw new Error(`Melt child ${operationId} cannot reserve its preflighted input set`); + } + const inputAmount = inputs.reduce((total, proof) => total.add(proof.amount), Amount.zero()); + if (!inputAmount.equals(preparedOperation.inputAmount)) { + throw new Error(`Melt child ${operationId} preflighted input amount does not reconcile`); + } + const scopedProofService = this.proofService.forTransaction(repositories); + await scopedProofService.reserveProofs( + preparedOperation.mintUrl, + preparedOperation.inputProofSecrets, + operationId, + { unit: preparedOperation.unit }, + ); await repositories.meltOperationRepository.create(preparedOperation); return preparedOperation; } @@ -342,6 +415,7 @@ export class MeltOperationService { parentSwapOperationId: string, repositories: RepositoryTransactionScope, ): Promise { + requireMintSwapRepositoryCapability(repositories); const operation = await repositories.meltOperationRepository.getById(operationId); if (!operation || operation.state !== 'prepared') { throw new Error( @@ -387,9 +461,16 @@ export class MeltOperationService { * This command receives no transaction scope and performs no repository writes. */ async executeOwnedRemoteStep( - operation: ExecutingMeltOperation, + operationOrId: string | ExecutingMeltOperation, parentSwapOperationId: string, ): Promise { + const operationId = typeof operationOrId === 'string' ? operationOrId : operationOrId.id; + const operation = await this.meltOperationRepository.getById(operationId); + if (!operation || operation.state !== 'executing') { + throw new Error( + `Cannot execute melt child ${operationId} from ${operation?.state ?? 'missing'}`, + ); + } assertChildOperationAccess(operation, parentSwapOperationId); assertParentOwnedMeltOperationInvariant(operation); const { wallet } = await this.walletService.getWalletWithActiveKeysetId( @@ -403,52 +484,176 @@ export class MeltOperationService { ? 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 invalidProof = proofs.some((proof) => { + if (proof.state !== 'inflight' || proof.unit !== operation.unit) return true; + return operation.parentExecutionPhase === 'pre_swap_authorized' || !operation.needsSwap + ? proof.usedByOperationId !== operation.id + : proof.createdByOperationId !== operation.id; + }); + if (proofs.length !== proofSecrets.length || invalidProof) { + throw new Error(`Authorized melt step ${operation.id} does not own valid inflight proofs`); } const handler = this.handlerProvider.get(operation.method); if (!handler.executeOwnedRemote) { throw new Error(`Melt method ${operation.method} does not support owned remote execution`); } - return handler.executeOwnedRemote({ + const handlerResult = await handler.executeOwnedRemote({ operation: operation as never, wallet, mintAdapter: this.mintAdapter, proofs, logger: this.logger, }); + const expectedPhase = + operation.parentExecutionPhase === 'pre_swap_authorized' ? 'pre_swap' : 'melt'; + if (handlerResult.operationId !== operation.id || handlerResult.phase !== expectedPhase) { + throw new Error(`Melt handler returned a result for the wrong owned operation phase`); + } + const result = { ...handlerResult, observedAt: handlerResult.observedAt ?? Date.now() }; + if ( + result.phase === 'melt' && + result.response.state === 'PAID' && + result.response.change?.length + ) { + const changeOutputData = deserializeOutputData(operation.changeOutputData).keep; + const changeProofs = await this.proofService.unblindChangeProofs( + operation.mintUrl, + changeOutputData, + result.response.change, + { unit: operation.unit, createdByOperationId: operation.id }, + ); + return { ...result, changeProofs }; + } + return result; } /** Apply one remote source result atomically with the composing parent transition. */ async applyOwnedRemoteStepInTransaction( - operation: ExecutingMeltOperation, + operationOrId: string | ExecutingMeltOperation, parentSwapOperationId: string, result: OwnedMeltRemoteResult, repositories: RepositoryTransactionScope, ): Promise< ExecutingMeltOperation | PendingMeltOperation | FinalizedMeltOperation | FailedMeltOperation > { - assertChildOperationAccess(operation, parentSwapOperationId); - const current = await repositories.meltOperationRepository.getById(operation.id); + const operationId = typeof operationOrId === 'string' ? operationOrId : operationOrId.id; + requireMintSwapRepositoryCapability(repositories); + if (result.operationId !== operationId) { + throw new Error(`Melt result operation ${result.operationId} does not match ${operationId}`); + } + const current = await repositories.meltOperationRepository.getById(operationId); if (!current || current.state !== 'executing') { - throw new Error( - `Cannot apply melt child ${operation.id} from ${current?.state ?? 'missing'}`, - ); + throw new Error(`Cannot apply melt child ${operationId} 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 expectedResultPhase = + current.parentExecutionPhase === 'pre_swap_authorized' ? 'pre_swap' : 'melt'; + if (result.phase !== expectedResultPhase) { + throw new Error(`Melt child ${operationId} advanced before its remote result was applied`); + } + const observedAt = result.observedAt; + if (observedAt === undefined || !Number.isSafeInteger(observedAt) || observedAt < 0) { + throw new Error(`Melt result for ${operationId} has an invalid observation time`); + } + + let canonicalResult = result; + if (result.phase === 'melt') { + const quote = await repositories.meltQuoteRepository.getMeltQuote( + current.mintUrl, + current.method, + current.quoteId, + ); + if (!quote || quote.method !== 'bolt11') { + throw new Error(`Canonical melt quote for child ${current.id} was not found`); + } + if (!quote.amount.equals(current.amount) || quote.unit !== current.unit) { + throw new Error(`Canonical melt quote does not match child ${current.id}`); + } + const observation: MeltQuote<'bolt11'> = { + ...quote, + state: result.response.state as 'PAID' | 'PENDING' | 'UNPAID', + change: result.response.change, + payment_preimage: result.response.payment_preimage, + lastObservedRemoteState: result.response.state as 'PAID' | 'PENDING' | 'UNPAID', + lastObservedRemoteStateAt: observedAt, + updatedAt: Math.max(quote.updatedAt, observedAt), + }; + const { quote: canonicalQuote } = await resolveAndPersistMeltQuoteObservation( + repositories.meltQuoteRepository, + observation, + ); + if (canonicalQuote.state !== result.response.state) { + throw new Error( + `Melt result ${result.response.state} conflicts with canonical ${canonicalQuote.state}`, + ); + } + if ( + canonicalQuote.state === 'PAID' && + result.response.state === 'PAID' && + Array.isArray(canonicalQuote.change) && + Array.isArray(result.response.change) && + meltChangeKey(canonicalQuote.change) !== meltChangeKey(result.response.change) + ) { + throw new Error(`Melt result settlement conflicts with canonical quote ${current.quoteId}`); + } + if ( + canonicalQuote.method !== 'onchain' && + canonicalQuote.payment_preimage != null && + result.response.payment_preimage != null && + canonicalQuote.payment_preimage !== result.response.payment_preimage + ) { + throw new Error(`Melt result preimage conflicts with canonical quote ${current.quoteId}`); + } + canonicalResult = { + ...result, + response: { + ...result.response, + state: canonicalQuote.state, + change: canonicalQuote.change, + payment_preimage: + canonicalQuote.method === 'onchain' ? undefined : canonicalQuote.payment_preimage, + }, + } as OwnedMeltRemoteResult; + } + if (canonicalResult.phase === 'pre_swap') { + assertProofsMatchSerializedOutputs( + canonicalResult.sendProofs, + current.swapOutputData!.send, + `Melt child ${current.id} pre-swap send`, + ); + assertProofsMatchSerializedOutputs( + canonicalResult.keepProofs, + current.swapOutputData!.keep, + `Melt child ${current.id} pre-swap keep`, + ); + } else if (canonicalResult.response.state === 'PAID') { + assertProofsMatchSerializedOutputs( + canonicalResult.changeProofs ?? [], + current.changeOutputData.keep.slice(0, canonicalResult.response.change?.length ?? 0), + `Melt child ${current.id} change`, + ); + } else if (canonicalResult.changeProofs?.length) { + throw new Error(`Non-paid melt result for ${current.id} cannot contain change proofs`); } const handler = this.handlerProvider.get(current.method); if (!handler.applyOwnedRemote) { throw new Error(`Melt method ${current.method} does not support owned result application`); } + const scopedProofService = this.proofService.forTransaction(repositories); + const ownedProofService = { + setProofState: scopedProofService.setProofState.bind(scopedProofService), + restoreProofsToReady: scopedProofService.restoreProofsToReady.bind(scopedProofService), + saveProofs: scopedProofService.saveProofs.bind(scopedProofService), + releaseProofs: scopedProofService.releaseProofs.bind(scopedProofService), + }; const applied = await handler.applyOwnedRemote( { - ...this.buildDeps(repositories), + proofRepository: repositories.proofRepository, + proofService: ownedProofService, + logger: this.logger, operation: current as never, }, - result as never, + canonicalResult as never, ); const next = 'status' in applied @@ -472,10 +677,10 @@ export class MeltOperationService { async rollbackOwnedPreparedInTransaction( operationId: string, parentSwapOperationId: string, - wallet: Wallet, repositories: RepositoryTransactionScope, reason = 'Parent mint swap cancelled', ): Promise { + requireMintSwapRepositoryCapability(repositories); const operation = await repositories.meltOperationRepository.getById(operationId); if (!operation || operation.state !== 'prepared') { throw new Error( @@ -483,11 +688,9 @@ export class MeltOperationService { ); } assertChildOperationAccess(operation, parentSwapOperationId); - await this.handlerProvider.get(operation.method).rollback?.({ - ...this.buildDeps(repositories), - operation, - wallet, - }); + await this.proofService + .forTransaction(repositories) + .releaseProofs(operation.mintUrl, operation.inputProofSecrets); const rolledBack: RolledBackMeltOperation = { ...operation, state: 'rolled_back', @@ -1222,3 +1425,14 @@ export class MeltOperationService { ); } } + +function meltChangeKey(change: readonly SerializedBlindedSignature[]): string { + return JSON.stringify( + change.map((signature) => ({ + amount: Amount.from(signature.amount).toString(), + id: signature.id, + C_: signature.C_, + dleq: signature.dleq, + })), + ); +} diff --git a/packages/core/operations/mint/MintMethodHandler.ts b/packages/core/operations/mint/MintMethodHandler.ts index e9e44aea..da289388 100644 --- a/packages/core/operations/mint/MintMethodHandler.ts +++ b/packages/core/operations/mint/MintMethodHandler.ts @@ -193,6 +193,8 @@ export type MintExecutionResult = error?: string; }; +export type OwnedMintExecutionResult = MintExecutionResult & { operationId: string }; + export type RecoverExecutingResult = | { status: 'FINALIZED' } | { status: 'TERMINAL'; error: string } diff --git a/packages/core/operations/mint/MintOperationService.ts b/packages/core/operations/mint/MintOperationService.ts index a02638b5..0e264695 100644 --- a/packages/core/operations/mint/MintOperationService.ts +++ b/packages/core/operations/mint/MintOperationService.ts @@ -4,6 +4,7 @@ import type { ProofRepository, RepositoryTransactionScope, } from '../../repositories'; +import { requireMintSwapRepositoryCapability } from '../../repositories'; import type { ExecutingMintOperation, FailedMintOperation, @@ -32,7 +33,12 @@ import type { ProofService } from '../../services/ProofService'; import type { EventBus } from '../../events/EventBus'; import type { CoreEvents } from '../../events/types'; import type { Logger } from '../../logging/Logger'; -import { generateSubId, mapProofToCoreProof, normalizeMintUrl } from '../../utils'; +import { + assertProofsMatchSerializedOutputs, + generateSubId, + mapProofToCoreProof, + normalizeMintUrl, +} from '../../utils'; import { OperationInProgressError, ProofValidationError, @@ -67,10 +73,15 @@ export interface PrepareOwnedMintOperationCommand { quote: MintQuote; amount: Amount; destinationNut20PublicKey: string; - wallet: Wallet; + preparedOperation: PendingMintOperation; repositories: RepositoryTransactionScope; } +export type PlanOwnedMintOperationCommand = Omit< + PrepareOwnedMintOperationCommand, + 'preparedOperation' | 'repositories' +> & { wallet: Wallet }; + export interface ClaimMintQuoteOptions { autoClaimRemaining?: boolean; } @@ -278,10 +289,10 @@ export class MintOperationService { * * @internal */ - async prepareOwnedInTransaction( - command: PrepareOwnedMintOperationCommand, + async planOwnedPreparation( + command: PlanOwnedMintOperationCommand, ): Promise { - const { quote, repositories, parentSwapOperationId, operationId, wallet } = command; + const { quote, parentSwapOperationId, operationId, wallet } = command; if (quote.method !== 'bolt11') { throw new Error('Mint swaps require a BOLT11 destination quote'); } @@ -308,7 +319,7 @@ export class MintOperationService { { quoteId: quote.quoteId, parentSwapOperationId }, ); const pending = await handler.prepare({ - ...this.buildDeps(repositories), + ...this.buildDeps(), operation: initOperation, wallet, importedQuote: mintQuoteToMethodSnapshot<'bolt11'>(quote as MintQuote<'bolt11'>), @@ -324,16 +335,56 @@ export class MintOperationService { throw new Error('Destination mint child lost its parent NUT-20 key binding'); } assertParentOwnedMintOperationInvariant(pendingOperation); - await repositories.mintOperationRepository.create(pendingOperation); return pendingOperation; } + /** Persist a preflighted destination plan using transaction-scoped local writes only. */ + async prepareOwnedInTransaction( + command: PrepareOwnedMintOperationCommand, + ): Promise { + const { + quote, + repositories, + parentSwapOperationId, + operationId, + preparedOperation, + destinationNut20PublicKey, + } = command; + requireMintSwapRepositoryCapability(repositories); + if ( + quote.method !== 'bolt11' || + quote.unit !== 'sat' || + quote.pubkey !== destinationNut20PublicKey || + !quote.amount.equals(command.amount) + ) { + throw new Error('Destination quote does not match the mint swap intent'); + } + if ( + preparedOperation.id !== operationId || + preparedOperation.parentSwapOperationId !== parentSwapOperationId || + preparedOperation.mintUrl !== quote.mintUrl || + preparedOperation.method !== 'bolt11' || + preparedOperation.quoteId !== quote.quoteId || + preparedOperation.unit !== 'sat' || + !preparedOperation.amount.equals(command.amount) || + preparedOperation.pubkey !== destinationNut20PublicKey || + preparedOperation.request !== quote.request || + preparedOperation.expiry !== quote.expiry + ) { + throw new Error('Preflighted destination child does not match its owned command'); + } + assertParentOwnedMintOperationInvariant(preparedOperation); + await repositories.mintOperationRepository.create(preparedOperation); + return preparedOperation; + } + /** Persist destination issuance authorization before the remote mint request. */ async authorizeOwnedExecutionInTransaction( operationId: string, parentSwapOperationId: string, repositories: RepositoryTransactionScope, ): Promise { + requireMintSwapRepositoryCapability(repositories); const operation = await repositories.mintOperationRepository.getById(operationId); if (!operation || operation.state !== 'pending') { throw new Error( @@ -358,9 +409,16 @@ export class MintOperationService { * This command receives no transaction scope and performs no repository writes. */ async executeOwnedRemote( - operation: ExecutingMintOperation, + operationOrId: string | ExecutingMintOperation, parentSwapOperationId: string, - ): Promise { + ): Promise { + const operationId = typeof operationOrId === 'string' ? operationOrId : operationOrId.id; + const operation = await this.mintOperationRepository.getById(operationId); + if (!operation || operation.state !== 'executing') { + throw new Error( + `Cannot execute mint child ${operationId} from ${operation?.state ?? 'missing'}`, + ); + } assertChildOperationAccess(operation, parentSwapOperationId); assertParentOwnedMintOperationInvariant(operation); const { wallet } = await this.walletService.getWalletWithActiveKeysetId( @@ -371,28 +429,49 @@ export class MintOperationService { if (!handler.executeOwnedRemote) { throw new Error(`Mint method ${operation.method} does not support owned remote execution`); } - return handler.executeOwnedRemote({ + const result = await handler.executeOwnedRemote({ operation: operation as never, wallet, mintAdapter: this.mintAdapter, logger: this.logger, }); + if (result.status === 'ALREADY_ISSUED') { + const recovered = await this.proofService.recoverProofsFromOutputData( + operation.mintUrl, + operation.outputData, + { + unit: operation.unit, + createdByOperationId: operation.id, + persistRecoveredProofs: false, + }, + ); + if (recovered.length > 0) { + assertProofsMatchSerializedOutputs( + recovered, + [...operation.outputData.keep, ...operation.outputData.send], + `Recovered mint child ${operation.id}`, + ); + return { operationId: operation.id, status: 'ISSUED', proofs: recovered }; + } + } + return { ...result, operationId: operation.id }; } /** Apply a remote issuance result atomically with the composing parent transition. */ async applyOwnedExecutionInTransaction( - operation: ExecutingMintOperation, + operationOrId: string | ExecutingMintOperation, parentSwapOperationId: string, - result: import('./MintMethodHandler.ts').MintExecutionResult, + result: import('./MintMethodHandler.ts').OwnedMintExecutionResult, repositories: RepositoryTransactionScope, ): Promise { - assertChildOperationAccess(operation, parentSwapOperationId); - assertParentOwnedMintOperationInvariant(operation); - const current = await repositories.mintOperationRepository.getById(operation.id); + const operationId = typeof operationOrId === 'string' ? operationOrId : operationOrId.id; + requireMintSwapRepositoryCapability(repositories); + if (result.operationId !== operationId) { + throw new Error(`Mint result operation ${result.operationId} does not match ${operationId}`); + } + const current = await repositories.mintOperationRepository.getById(operationId); if (!current || current.state !== 'executing') { - throw new Error( - `Cannot apply mint child ${operation.id} from ${current?.state ?? 'missing'}`, - ); + throw new Error(`Cannot apply mint child ${operationId} from ${current?.state ?? 'missing'}`); } assertChildOperationAccess(current, parentSwapOperationId); assertParentOwnedMintOperationInvariant(current); @@ -404,14 +483,12 @@ export class MintOperationService { return current; } + assertProofsMatchSerializedOutputs( + result.proofs, + [...current.outputData.keep, ...current.outputData.send], + `Mint child ${current.id}`, + ); 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( @@ -433,7 +510,14 @@ export class MintOperationService { (proof) => proof.createdByOperationId !== current.id || proof.state !== 'ready' || - proof.unit !== current.unit, + proof.unit !== current.unit || + !result.proofs.some( + (remote) => + remote.secret === proof.secret && + remote.id === proof.id && + Amount.from(remote.amount).equals(proof.amount) && + remote.C === proof.C, + ), ) ) { throw new Error(`Mint child ${current.id} has an invalid deterministic output set`); @@ -672,7 +756,6 @@ export class MintOperationService { } return await this.finalizeIssuedOperation(executing); case 'ALREADY_ISSUED': { - //CODEX: Where does recovery actually happen? const proofsRecovered = await this.ensureOutputsSaved(executing); const error = proofsRecovered ? undefined diff --git a/packages/core/operations/mintSwap/ChildOperationOwnership.ts b/packages/core/operations/mintSwap/ChildOperationOwnership.ts index 0e700846..be018a50 100644 --- a/packages/core/operations/mintSwap/ChildOperationOwnership.ts +++ b/packages/core/operations/mintSwap/ChildOperationOwnership.ts @@ -1,4 +1,7 @@ +import { Amount } from '@cashu/cashu-ts'; + import { ParentOwnedOperationError } from '../../models/Error.ts'; +import { getSecretsFromSerializedOutputData } from '../../utils.ts'; import type { MeltOperation } from '../melt/MeltOperation.ts'; import type { MintOperation } from '../mint/MintOperation.ts'; @@ -72,6 +75,63 @@ export function assertParentOwnedMeltOperationInvariant(operation: MeltOperation throw new Error(`Melt operation ${operation.id} has an invalid pre-swap authorization`); } } + + if (operation.state !== 'init') { + assertUniqueNonEmpty(operation.inputProofSecrets, `Melt operation ${operation.id} inputs`); + const changeSecrets = getSecretsFromSerializedOutputData(operation.changeOutputData); + if (changeSecrets.sendSecrets.length > 0) { + throw new Error(`Melt operation ${operation.id} change outputs cannot contain send outputs`); + } + assertUniqueNonEmpty( + changeSecrets.keepSecrets, + `Melt operation ${operation.id} change outputs`, + { + allowEmpty: true, + }, + ); + if (operation.needsSwap) { + if (!operation.swapOutputData) { + throw new Error(`Melt operation ${operation.id} requires persisted swap outputs`); + } + const swapSecrets = getSecretsFromSerializedOutputData(operation.swapOutputData); + assertUniqueNonEmpty( + swapSecrets.sendSecrets, + `Melt operation ${operation.id} swap send outputs`, + ); + assertUniqueNonEmpty( + [...swapSecrets.keepSecrets, ...swapSecrets.sendSecrets], + `Melt operation ${operation.id} swap outputs`, + ); + } else if (operation.swapOutputData !== undefined) { + throw new Error(`Direct melt operation ${operation.id} cannot contain swap outputs`); + } + + const requiredMeltAmount = operation.amount.add(operation.fee_reserve); + if (operation.inputAmount.lessThan(requiredMeltAmount)) { + throw new Error(`Melt operation ${operation.id} input amount cannot cover its quote`); + } + if (operation.needsSwap) { + const swapOutputAmount = [ + ...operation.swapOutputData!.keep, + ...operation.swapOutputData!.send, + ].reduce( + (total, output) => total.add(Amount.from(output.blindedMessage.amount)), + Amount.zero(), + ); + if (!swapOutputAmount.add(operation.swap_fee).equals(operation.inputAmount)) { + throw new Error(`Melt operation ${operation.id} swap outputs do not conserve value`); + } + const swapSendAmount = operation.swapOutputData!.send.reduce( + (total, output) => total.add(Amount.from(output.blindedMessage.amount)), + Amount.zero(), + ); + if (swapSendAmount.lessThan(requiredMeltAmount)) { + throw new Error(`Melt operation ${operation.id} swap send outputs cannot cover its quote`); + } + } else if (!operation.swap_fee.isZero()) { + throw new Error(`Direct melt operation ${operation.id} cannot contain a swap fee`); + } + } } /** Ensure a persisted parent-owned destination child is locked BOLT11/sat work. */ @@ -85,4 +145,181 @@ export function assertParentOwnedMintOperationInvariant(operation: MintOperation ) { throw new Error(`Parent-owned mint operation ${operation.id} must be locked BOLT11/sat work`); } + const secrets = getSecretsFromSerializedOutputData(operation.outputData); + if (secrets.sendSecrets.length > 0) { + throw new Error(`Parent-owned mint operation ${operation.id} cannot contain send outputs`); + } + assertUniqueNonEmpty(secrets.keepSecrets, `Mint operation ${operation.id} outputs`); + const outputAmount = operation.outputData.keep.reduce( + (total, output) => total.add(Amount.from(output.blindedMessage.amount)), + Amount.zero(), + ); + if (!outputAmount.equals(operation.amount)) { + throw new Error(`Parent-owned mint operation ${operation.id} output amount does not reconcile`); + } +} + +function assertUniqueNonEmpty( + values: readonly string[], + label: string, + options: { allowEmpty?: boolean } = {}, +): void { + if (!options.allowEmpty && values.length === 0) throw new Error(`${label} cannot be empty`); + if (values.some((value) => value.length === 0) || new Set(values).size !== values.length) { + throw new Error(`${label} must contain unique non-empty values`); + } +} + +const PARENT_MINT_TRANSITIONS: Record< + MintOperation['state'], + ReadonlySet +> = { + init: new Set(['pending']), + pending: new Set(['executing', 'failed']), + executing: new Set(['executing', 'finalized', 'failed']), + finalized: new Set(), + failed: new Set(), +}; + +const PARENT_MELT_TRANSITIONS: Record< + MeltOperation['state'], + ReadonlySet +> = { + init: new Set(['prepared', 'rolled_back']), + prepared: new Set(['executing', 'rolled_back']), + executing: new Set(['executing', 'pending', 'failed', 'finalized']), + pending: new Set(['pending', 'failed', 'finalized', 'rolling_back']), + failed: new Set(), + finalized: new Set(), + rolling_back: new Set(['rolled_back']), + rolled_back: new Set(), +}; + +/** Enforce immutable economic facts and forward-only state for an owned destination child. */ +export function assertParentOwnedMintOperationUpdate( + current: MintOperation, + next: MintOperation, +): void { + if (current.parentSwapOperationId !== next.parentSwapOperationId) { + throw new Error(`Parent-owned mint parent ownership is immutable`); + } + if (!current.parentSwapOperationId) return; + assertParentOwnedMintOperationInvariant(next); + assertOwnedBaseFields(current, next, 'mint'); + assertOwnedStateTransition(PARENT_MINT_TRANSITIONS, current, next, 'mint'); + if (current.state === 'init') return; + + const currentPending = current as Exclude; + const nextPending = next as Exclude; + assertImmutableValue(currentPending.quoteId, nextPending.quoteId, 'mint quote id'); + assertImmutableValue( + currentPending.amount.toString(), + nextPending.amount.toString(), + 'mint amount', + ); + assertImmutableValue(currentPending.unit, nextPending.unit, 'mint unit'); + assertImmutableValue(currentPending.request, nextPending.request, 'mint request'); + assertImmutableValue(currentPending.expiry, nextPending.expiry, 'mint expiry'); + assertImmutableValue(currentPending.pubkey, nextPending.pubkey, 'mint quote key'); + assertImmutableValue( + JSON.stringify(currentPending.outputData), + JSON.stringify(nextPending.outputData), + 'mint deterministic outputs', + ); +} + +/** Enforce immutable economic facts and forward-only state for an owned source child. */ +export function assertParentOwnedMeltOperationUpdate( + current: MeltOperation, + next: MeltOperation, +): void { + if (current.parentSwapOperationId !== next.parentSwapOperationId) { + throw new Error(`Parent-owned melt parent ownership is immutable`); + } + if (!current.parentSwapOperationId) return; + assertParentOwnedMeltOperationInvariant(next); + assertOwnedBaseFields(current, next, 'melt'); + assertOwnedStateTransition(PARENT_MELT_TRANSITIONS, current, next, 'melt'); + if (current.state === 'init') return; + + const currentPrepared = current as Exclude; + const nextPrepared = next as Exclude; + for (const [left, right, name] of [ + [currentPrepared.quoteId, nextPrepared.quoteId, 'melt quote id'], + [currentPrepared.amount.toString(), nextPrepared.amount.toString(), 'melt amount'], + [ + currentPrepared.fee_reserve.toString(), + nextPrepared.fee_reserve.toString(), + 'melt fee reserve', + ], + [currentPrepared.swap_fee.toString(), nextPrepared.swap_fee.toString(), 'melt swap fee'], + [ + currentPrepared.inputAmount.toString(), + nextPrepared.inputAmount.toString(), + 'melt input amount', + ], + [currentPrepared.needsSwap, nextPrepared.needsSwap, 'melt swap requirement'], + [ + JSON.stringify(currentPrepared.inputProofSecrets), + JSON.stringify(nextPrepared.inputProofSecrets), + 'melt input proofs', + ], + [ + JSON.stringify(currentPrepared.changeOutputData), + JSON.stringify(nextPrepared.changeOutputData), + 'melt change outputs', + ], + [ + JSON.stringify(currentPrepared.swapOutputData), + JSON.stringify(nextPrepared.swapOutputData), + 'melt swap outputs', + ], + ] as const) { + assertImmutableValue(left, right, name); + } + const phaseOrder = { pre_swap_authorized: 1, melt_authorized: 2 } as const; + const currentPhase = current.parentExecutionPhase ? phaseOrder[current.parentExecutionPhase] : 0; + const nextPhase = next.parentExecutionPhase ? phaseOrder[next.parentExecutionPhase] : 0; + if (nextPhase < currentPhase) { + throw new Error('Parent-owned melt execution authorization cannot regress'); + } +} + +function assertOwnedBaseFields( + current: MintOperation | MeltOperation, + next: MintOperation | MeltOperation, + label: string, +): void { + for (const [left, right, name] of [ + [current.id, next.id, 'id'], + [current.parentSwapOperationId, next.parentSwapOperationId, 'parent ownership'], + [current.mintUrl, next.mintUrl, 'mint URL'], + [current.unit, next.unit, 'unit'], + [current.method, next.method, 'method'], + [JSON.stringify(current.methodData), JSON.stringify(next.methodData), 'method data'], + [current.createdAt, next.createdAt, 'createdAt'], + ] as const) { + assertImmutableValue(left, right, `${label} ${name}`); + } + if (next.updatedAt < current.updatedAt) { + throw new Error(`Parent-owned ${label} updatedAt cannot regress`); + } +} + +function assertOwnedStateTransition( + transitions: Record>, + current: T, + next: T, + label: string, +): void { + if (current.state !== next.state && !transitions[current.state]?.has(next.state)) { + throw new Error(`Illegal parent-owned ${label} transition: ${current.state} -> ${next.state}`); + } + if (current.state === next.state && !transitions[current.state]?.has(next.state)) { + throw new Error(`Parent-owned ${label} state ${current.state} is immutable`); + } +} + +function assertImmutableValue(left: unknown, right: unknown, name: string): void { + if (left !== right) throw new Error(`Parent-owned ${name} is immutable`); } diff --git a/packages/core/operations/mintSwap/MintSwapOperation.ts b/packages/core/operations/mintSwap/MintSwapOperation.ts index f6a686fa..0786bf11 100644 --- a/packages/core/operations/mintSwap/MintSwapOperation.ts +++ b/packages/core/operations/mintSwap/MintSwapOperation.ts @@ -3,6 +3,7 @@ import { bytesToHex } from '@noble/curves/utils.js'; import { sha256 } from '@noble/hashes/sha2.js'; import { normalizeMintUrl } from '../../utils'; +import type { SerializedOutputData } from '../../utils'; export type MintSwapOperationState = | 'preparing' @@ -137,6 +138,8 @@ export interface MintSwapOperation { preparedPlan?: MintSwapPreparedPlan; settlement?: MintSwapSettlement; sourceDispatchAuthorizedAt?: number; + /** Durable evidence that authorized source inputs were reclaimed before value-neutral exit. */ + sourceReclaimedAt?: number; destinationIssueAuthorizedAt?: number; cancellationRequestedAt?: number; cancelledAt?: number; @@ -157,9 +160,15 @@ export interface MintSwapPreparedPlanFingerprintInput { destinationAmount: Amount; unit: 'sat'; sourceInputProofSecrets: readonly string[]; - destinationOutputData: unknown; - sourceOutputData: unknown; + destinationOutputData: SerializedOutputData; + sourceOutputData: SerializedOutputData; + sourceMeltAmount: Amount; + sourceFeeReserve: Amount; + sourcePreparationFee: Amount; + sourceMeltInputFee: Amount; + minimumSourceDebit: Amount; maximumSourceDebit: Amount; + reservedSourceAmount: Amount; dispatchDeadlineSeconds: number; requiredDispatchWindowSeconds: number; } @@ -217,7 +226,8 @@ const TRANSITIONS: Record = [ + [current.createdAt, next.createdAt, 'createdAt'], [current.sourceMintUrl, next.sourceMintUrl, 'source mint URL'], [current.destinationMintUrl, next.destinationMintUrl, 'destination mint URL'], [current.unit, next.unit, 'unit'], @@ -879,6 +970,45 @@ function assertAlwaysImmutable(current: MintSwapOperation, next: MintSwapOperati if (changed) throw new Error(`Mint swap ${changed[2]} is immutable`); } +function assertRetryUpdate(current: MintSwapOperation, next: MintSwapOperation): void { + if (next.retry.attemptCount < current.retry.attemptCount) { + throw new Error('Mint swap retry attempt count cannot regress'); + } + for (const [currentValue, nextValue, name] of [ + [current.retry.lastAttemptAt, next.retry.lastAttemptAt, 'last attempt'], + [ + current.retry.lastSuccessfulObservationAt, + next.retry.lastSuccessfulObservationAt, + 'last successful observation', + ], + ] as const) { + if (currentValue !== undefined && (nextValue === undefined || nextValue < currentValue)) { + throw new Error(`Mint swap retry ${name} cannot regress`); + } + } +} + +function assertCancellationRequestUpdate( + current: MintSwapOperation, + next: MintSwapOperation, +): void { + if ( + current.cancellationRequestedAt === undefined && + next.cancellationRequestedAt !== undefined && + current.state !== 'preparing' && + current.state !== 'prepared' && + current.state !== 'source_inflight' + ) { + throw new Error(`Cannot newly request cancellation from mint swap state ${current.state}`); + } +} + +function getMintSwapProgressState(operation: MintSwapOperation): MintSwapOperationState { + return operation.state === 'needs_attention' + ? (operation.attention?.lastSafeState ?? operation.state) + : operation.state; +} + function assertAttachedReferencesImmutable( current: MintSwapOperation, next: MintSwapOperation, @@ -914,6 +1044,7 @@ function assertAuthorizationImmutable(current: MintSwapOperation, next: MintSwap 'destination issue authorization', ], [current.cancellationRequestedAt, next.cancellationRequestedAt, 'cancellation request'], + [current.sourceReclaimedAt, next.sourceReclaimedAt, 'source reclamation evidence'], ] as const) { if (currentValue !== undefined && currentValue !== nextValue) { throw new Error(`Mint swap ${name} is immutable`); @@ -1017,8 +1148,12 @@ function canonicalizeForFingerprint(value: unknown, seen = new Set()): s if (Array.isArray(value)) { return `[${value.map((item) => canonicalizeForFingerprint(item, seen)).join(',')}]`; } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error('Mint swap fingerprint input must contain only plain objects and arrays'); + } const entries = Object.entries(value as Record) - .sort(([left], [right]) => left.localeCompare(right)) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) .map(([key, item]) => `${JSON.stringify(key)}:${canonicalizeForFingerprint(item, seen)}`); return `{${entries.join(',')}}`; } finally { diff --git a/packages/core/quotes/QuoteLifecycle.ts b/packages/core/quotes/QuoteLifecycle.ts index 11472b4b..1bca0e1d 100644 --- a/packages/core/quotes/QuoteLifecycle.ts +++ b/packages/core/quotes/QuoteLifecycle.ts @@ -325,7 +325,7 @@ function mergePaidMeltQuoteSettlement(existing: MeltQuote, incoming: MeltQuote): lastObservedRemoteState: incoming.lastObservedRemoteState ?? existing.lastObservedRemoteState, lastObservedRemoteStateAt: incoming.lastObservedRemoteStateAt ?? existing.lastObservedRemoteStateAt, - updatedAt: incoming.updatedAt, + updatedAt: Math.max(existing.updatedAt, incoming.updatedAt), } as MeltQuote; if (!Array.isArray(existing.change) && Array.isArray(incoming.change)) { @@ -348,6 +348,53 @@ function mergePaidMeltQuoteSettlement(existing: MeltQuote, incoming: MeltQuote): return changed ? merged : null; } +/** + * Persist one attributable melt observation without emitting events or performing remote I/O. + * This is the transaction-safe canonicalization seam used by parent-owned melt commands. + */ +export async function resolveAndPersistMeltQuoteObservation( + repository: MeltQuoteRepository, + canonicalQuote: MeltQuote, +): Promise<{ quote: MeltQuote; remoteQuoteChanged: boolean }> { + const existing = await repository.getMeltQuote( + canonicalQuote.mintUrl, + canonicalQuote.method, + canonicalQuote.quoteId, + ); + + const existingObservedAt = existing?.lastObservedRemoteStateAt; + const incomingObservedAt = canonicalQuote.lastObservedRemoteStateAt; + if ( + existing && + existingObservedAt !== undefined && + incomingObservedAt !== undefined && + incomingObservedAt < existingObservedAt + ) { + return { quote: existing, remoteQuoteChanged: false }; + } + + if (existing?.state === 'PAID') { + const enrichedQuote = mergePaidMeltQuoteSettlement(existing, canonicalQuote); + if (!enrichedQuote) return { quote: existing, remoteQuoteChanged: false }; + const persisted = await persistCanonicalMeltQuote(repository, enrichedQuote); + return { quote: persisted, remoteQuoteChanged: true }; + } + + const remoteQuoteChanged = getMeltQuoteChange(existing, canonicalQuote); + if (!remoteQuoteChanged && existing) { + return { quote: existing, remoteQuoteChanged: false }; + } + const persisted = await persistCanonicalMeltQuote(repository, canonicalQuote); + return { quote: persisted, remoteQuoteChanged }; +} + +async function persistCanonicalMeltQuote( + repository: MeltQuoteRepository, + canonicalQuote: MeltQuote, +): Promise { + return repository.upsertMeltQuote(canonicalQuote); +} + export interface QuoteLifecycleDeps { mintHandlerProvider: MintHandlerProvider; meltHandlerProvider: MeltHandlerProvider; @@ -1460,38 +1507,7 @@ export class QuoteLifecycle { private async resolveAndPersistMeltQuoteObservation( canonicalQuote: MeltQuote, ): Promise<{ quote: MeltQuote; remoteQuoteChanged: boolean }> { - const existing = await this.meltQuoteRepository.getMeltQuote( - canonicalQuote.mintUrl, - canonicalQuote.method, - canonicalQuote.quoteId, - ); - - if (existing?.state === 'PAID') { - const enrichedQuote = mergePaidMeltQuoteSettlement(existing, canonicalQuote); - if (enrichedQuote) { - const persisted = await this.persistCanonicalMeltQuote(enrichedQuote); - return { - quote: persisted, - remoteQuoteChanged: true, - }; - } - - return { - quote: existing, - remoteQuoteChanged: false, - }; - } - - const remoteQuoteChanged = getMeltQuoteChange(existing, canonicalQuote); - if (!remoteQuoteChanged && existing) { - return { - quote: existing, - remoteQuoteChanged: false, - }; - } - - const persisted = await this.persistCanonicalMeltQuote(canonicalQuote); - return { quote: persisted, remoteQuoteChanged }; + return resolveAndPersistMeltQuoteObservation(this.meltQuoteRepository, canonicalQuote); } private async persistCanonicalMintQuote( @@ -1524,10 +1540,6 @@ export class QuoteLifecycle { }); } - private async persistCanonicalMeltQuote(canonicalQuote: MeltQuote): Promise { - return this.meltQuoteRepository.upsertMeltQuote(canonicalQuote); - } - private async emitMeltQuoteUpdatedIfNeeded( quote: MeltQuote, remoteQuoteChanged: boolean, diff --git a/packages/core/repositories/index.ts b/packages/core/repositories/index.ts index 0454fb55..b23e37d0 100644 --- a/packages/core/repositories/index.ts +++ b/packages/core/repositories/index.ts @@ -372,6 +372,7 @@ export interface MintSwapOperationRepository { export interface OperationEventOutboxRepository { enqueue(event: OperationEventOutboxRecord): Promise; + getById(id: string): Promise; getUnpublished(limit: number, now?: number): Promise; markPublished(id: string, publishedAt: number): Promise; recordPublishFailure(id: string, nextAttemptAt: number, lastError: string): Promise; @@ -388,6 +389,16 @@ export interface MintSwapRepositoryCapability { operationEventOutboxRepository: OperationEventOutboxRepository; } +/** Fail a Mint Swap command before it can reserve value or perform remote I/O. */ +export function requireMintSwapRepositoryCapability( + repositories: Pick, +): MintSwapRepositoryCapability { + if (!repositories.mintSwap) { + throw new Error('Mint Swap requires the optional durable repository capability'); + } + return repositories.mintSwap; +} + interface RepositoriesBase { mintRepository: MintRepository; keyRingRepository: KeyRingRepository; diff --git a/packages/core/repositories/memory/MemoryMeltOperationRepository.ts b/packages/core/repositories/memory/MemoryMeltOperationRepository.ts index 8fa4b918..0dba2b79 100644 --- a/packages/core/repositories/memory/MemoryMeltOperationRepository.ts +++ b/packages/core/repositories/memory/MemoryMeltOperationRepository.ts @@ -1,6 +1,10 @@ import type { MeltOperationRepository } from '..'; import type { MeltOperation, MeltOperationState } from '../../operations/melt/MeltOperation'; -import { assertParentOwnedMeltOperationInvariant } from '../../operations/mintSwap/ChildOperationOwnership.ts'; +import { + assertParentOwnedMeltOperationInvariant, + assertParentOwnedMeltOperationUpdate, +} from '../../operations/mintSwap/ChildOperationOwnership.ts'; +import { cloneMemoryValue } from './clone.ts'; const getOperationQuoteId = (operation: MeltOperation): string | undefined => 'quoteId' in operation && operation.quoteId ? operation.quoteId : undefined; @@ -15,7 +19,7 @@ export class MemoryMeltOperationRepository implements MeltOperationRepository { } this.assertNoDuplicateQuoteOperation(operation); this.assertUniqueParentOwnership(operation); - this.operations.set(operation.id, { ...operation }); + this.operations.set(operation.id, cloneMemoryValue(operation)); } async update(operation: MeltOperation): Promise { @@ -24,24 +28,22 @@ export class MemoryMeltOperationRepository implements MeltOperationRepository { 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}`); - } + assertParentOwnedMeltOperationUpdate(existing, operation); this.assertNoDuplicateQuoteOperation(operation); this.assertUniqueParentOwnership(operation); - this.operations.set(operation.id, { ...operation, updatedAt: Date.now() }); + this.operations.set(operation.id, cloneMemoryValue(operation)); } async getById(id: string): Promise { const operation = this.operations.get(id); - return operation ? { ...operation } : null; + return operation ? cloneMemoryValue(operation) : null; } async getByState(state: MeltOperationState): Promise { const results: MeltOperation[] = []; for (const operation of this.operations.values()) { if (operation.state === state) { - results.push({ ...operation }); + results.push(cloneMemoryValue(operation)); } } return results; @@ -51,7 +53,7 @@ export class MemoryMeltOperationRepository implements MeltOperationRepository { const results: MeltOperation[] = []; for (const operation of this.operations.values()) { if (operation.state === 'executing' || operation.state === 'pending') { - results.push({ ...operation }); + results.push(cloneMemoryValue(operation)); } } return results; @@ -61,7 +63,7 @@ export class MemoryMeltOperationRepository implements MeltOperationRepository { const results: MeltOperation[] = []; for (const operation of this.operations.values()) { if (operation.mintUrl === mintUrl) { - results.push({ ...operation }); + results.push(cloneMemoryValue(operation)); } } return results; @@ -75,14 +77,14 @@ export class MemoryMeltOperationRepository implements MeltOperationRepository { 'quoteId' in operation && operation.quoteId === quoteId ) { - results.push({ ...operation }); + results.push(cloneMemoryValue(operation)); } } return results; } async getAll(): Promise { - return Array.from(this.operations.values(), (operation) => ({ ...operation })); + return Array.from(this.operations.values(), (operation) => cloneMemoryValue(operation)); } async delete(id: string): Promise { diff --git a/packages/core/repositories/memory/MemoryMintOperationRepository.ts b/packages/core/repositories/memory/MemoryMintOperationRepository.ts index 42c98c5b..30298df4 100644 --- a/packages/core/repositories/memory/MemoryMintOperationRepository.ts +++ b/packages/core/repositories/memory/MemoryMintOperationRepository.ts @@ -1,6 +1,10 @@ import type { MintOperationRepository } from '..'; import type { MintOperation, MintOperationState } from '../../operations/mint/MintOperation'; -import { assertParentOwnedMintOperationInvariant } from '../../operations/mintSwap/ChildOperationOwnership.ts'; +import { + assertParentOwnedMintOperationInvariant, + assertParentOwnedMintOperationUpdate, +} from '../../operations/mintSwap/ChildOperationOwnership.ts'; +import { cloneMemoryValue } from './clone.ts'; export class MemoryMintOperationRepository implements MintOperationRepository { private readonly operations = new Map(); @@ -11,7 +15,7 @@ export class MemoryMintOperationRepository implements MintOperationRepository { throw new Error(`MintOperation with id ${operation.id} already exists`); } this.assertUniqueParentOwnership(operation); - this.operations.set(operation.id, { ...operation }); + this.operations.set(operation.id, cloneMemoryValue(operation)); } async update(operation: MintOperation): Promise { @@ -20,23 +24,21 @@ export class MemoryMintOperationRepository implements MintOperationRepository { 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}`); - } + assertParentOwnedMintOperationUpdate(existing, operation); this.assertUniqueParentOwnership(operation); - this.operations.set(operation.id, { ...operation, updatedAt: Date.now() }); + this.operations.set(operation.id, cloneMemoryValue(operation)); } async getById(id: string): Promise { const operation = this.operations.get(id); - return operation ? { ...operation } : null; + return operation ? cloneMemoryValue(operation) : null; } async getByState(state: MintOperationState): Promise { const results: MintOperation[] = []; for (const operation of this.operations.values()) { if (operation.state === state) { - results.push({ ...operation }); + results.push(cloneMemoryValue(operation)); } } return results; @@ -46,7 +48,7 @@ export class MemoryMintOperationRepository implements MintOperationRepository { const results: MintOperation[] = []; for (const operation of this.operations.values()) { if (operation.state === 'pending' || operation.state === 'executing') { - results.push({ ...operation }); + results.push(cloneMemoryValue(operation)); } } return results; @@ -56,7 +58,7 @@ export class MemoryMintOperationRepository implements MintOperationRepository { const results: MintOperation[] = []; for (const operation of this.operations.values()) { if (operation.mintUrl === mintUrl) { - results.push({ ...operation }); + results.push(cloneMemoryValue(operation)); } } return results; @@ -71,14 +73,14 @@ export class MemoryMintOperationRepository implements MintOperationRepository { 'quoteId' in operation && operation.quoteId === quoteId ) { - results.push({ ...operation }); + results.push(cloneMemoryValue(operation)); } } return results.sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)); } async getAll(): Promise { - return Array.from(this.operations.values(), (operation) => ({ ...operation })); + return Array.from(this.operations.values(), (operation) => cloneMemoryValue(operation)); } async delete(id: string): Promise { diff --git a/packages/core/repositories/memory/MemoryOperationEventOutboxRepository.ts b/packages/core/repositories/memory/MemoryOperationEventOutboxRepository.ts index 62816ad4..4e99842d 100644 --- a/packages/core/repositories/memory/MemoryOperationEventOutboxRepository.ts +++ b/packages/core/repositories/memory/MemoryOperationEventOutboxRepository.ts @@ -25,6 +25,11 @@ export class MemoryOperationEventOutboxRepository implements OperationEventOutbo this.events.set(event.id, cloneMemoryValue(event)); } + async getById(id: string): Promise { + const event = this.events.get(id); + return event ? cloneMemoryValue(event) : null; + } + async getUnpublished(limit: number, now = Date.now()): Promise { assertNonNegativeSafeInteger(limit, 'Outbox limit'); assertNonNegativeSafeInteger(now, 'Outbox due time'); @@ -46,6 +51,7 @@ export class MemoryOperationEventOutboxRepository implements OperationEventOutbo const published = { ...event, publishedAt, + publishAttempts: event.publishAttempts + 1, lastError: undefined, nextAttemptAt: undefined, }; diff --git a/packages/core/repositories/memory/MemoryRepositories.ts b/packages/core/repositories/memory/MemoryRepositories.ts index ce62ee0d..9b64af14 100644 --- a/packages/core/repositories/memory/MemoryRepositories.ts +++ b/packages/core/repositories/memory/MemoryRepositories.ts @@ -37,7 +37,11 @@ import { MemoryPaymentRequestReceiveAttemptRepository, MemoryPaymentRequestReceiveOperationRepository, } from './MemoryPaymentRequestReceiveRepository'; -import { copyMemoryRepositoryState } from './clone'; +import { + applyMemoryRepositoryState, + copyMemoryRepositoryState, + snapshotMemoryRepositoryState, +} from './clone'; import { MemoryRepositoryCoordinator } from './MemoryRepositoryCoordinator'; import { MemoryMintSwapOperationRepository } from './MemoryMintSwapOperationRepository'; import { MemoryOperationEventOutboxRepository } from './MemoryOperationEventOutboxRepository'; @@ -107,12 +111,27 @@ export class MemoryRepositories implements Repositories { const staged = createMemoryRepositoryScope(); copyRepositoryScope(this.rawScope, staged); const result = await fn(staged); - copyRepositoryScope(staged, this.rawScope); + commitRepositoryScope(staged, this.rawScope); return result; }); } } +function commitRepositoryScope( + source: RepositoryTransactionScope, + target: RepositoryTransactionScope, +): void { + const entries = getRepositoryStateEntries(source, target); + // Clone every repository first. A cloning failure cannot leave a partially committed scope. + const prepared = entries.map(({ sourceRepository, targetRepository, excludedKeys }) => ({ + targetRepository, + snapshot: snapshotMemoryRepositoryState(sourceRepository, excludedKeys), + })); + for (const { targetRepository, snapshot } of prepared) { + applyMemoryRepositoryState(targetRepository, snapshot); + } +} + function createMemoryRepositoryScope(): RepositoryTransactionScope { const mintRepository = new MemoryMintRepository(); const keyRingRepository = new MemoryKeyRingRepository(); @@ -162,6 +181,18 @@ function copyRepositoryScope( source: RepositoryTransactionScope, target: RepositoryTransactionScope, ): void { + for (const { sourceRepository, targetRepository, excludedKeys } of getRepositoryStateEntries( + source, + target, + )) { + copyMemoryRepositoryState(sourceRepository, targetRepository, excludedKeys); + } +} + +function getRepositoryStateEntries( + source: RepositoryTransactionScope, + target: RepositoryTransactionScope, +): Array<{ sourceRepository: object; targetRepository: object; excludedKeys: readonly string[] }> { const repositoryKeys: Array> = [ 'mintRepository', 'keyRingRepository', @@ -180,22 +211,24 @@ function copyRepositoryScope( '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, - ); + return [ + ...repositoryKeys.map((key) => ({ + sourceRepository: source[key], + targetRepository: target[key], + excludedKeys: key === 'historyRepository' ? ['operationRepositories'] : [], + })), + { + sourceRepository: source.mintSwap.mintSwapOperationRepository, + targetRepository: target.mintSwap.mintSwapOperationRepository, + excludedKeys: [], + }, + { + sourceRepository: source.mintSwap.operationEventOutboxRepository, + targetRepository: target.mintSwap.operationEventOutboxRepository, + excludedKeys: [], + }, + ]; } diff --git a/packages/core/repositories/memory/clone.ts b/packages/core/repositories/memory/clone.ts index f3cc49c4..ae3b9b12 100644 --- a/packages/core/repositories/memory/clone.ts +++ b/packages/core/repositories/memory/clone.ts @@ -62,10 +62,26 @@ export function copyMemoryRepositoryState( target: object, excludedKeys: readonly string[] = [], ): void { + applyMemoryRepositoryState(target, snapshotMemoryRepositoryState(source, excludedKeys)); +} + +export function snapshotMemoryRepositoryState( + source: object, + excludedKeys: readonly string[] = [], +): Record { const excluded = new Set(excludedKeys); const sourceRecord = source as Record; - const targetRecord = target as Record; + const snapshot: Record = {}; for (const key of Object.keys(sourceRecord)) { - if (!excluded.has(key)) targetRecord[key] = cloneMemoryValue(sourceRecord[key]); + if (!excluded.has(key)) snapshot[key] = cloneMemoryValue(sourceRecord[key]); } + return snapshot; +} + +export function applyMemoryRepositoryState( + target: object, + snapshot: Readonly>, +): void { + const targetRecord = target as Record; + for (const [key, value] of Object.entries(snapshot)) targetRecord[key] = value; } diff --git a/packages/core/repositories/memory/index.ts b/packages/core/repositories/memory/index.ts index c3ca59b3..d10a2331 100644 --- a/packages/core/repositories/memory/index.ts +++ b/packages/core/repositories/memory/index.ts @@ -15,6 +15,5 @@ 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 47449c67..35b2108b 100644 --- a/packages/core/services/ProofService.ts +++ b/packages/core/services/ProofService.ts @@ -239,10 +239,13 @@ export class ProofService { unit, }); const feeAmount = sendAmount.subtract(requestedSend); - // Adjust keep amount: if send increases due to fees, keep decreases - keepAmount = requestedKeep.greaterThanOrEqual(feeAmount) - ? requestedKeep.subtract(feeAmount) - : Amount.zero(); + if (requestedKeep.lessThan(feeAmount)) { + throw new ProofValidationError( + 'Keep amount cannot cover the receiver fee added to send outputs', + ); + } + // Preserve value by moving the receiver fee from keep to send. + keepAmount = requestedKeep.subtract(feeAmount); this.logger?.debug('Fee calculation for send amount', { mintUrl, unit, @@ -963,6 +966,32 @@ export class ProofService { changeSignatures: SerializedBlindedSignature[], options: { unit: string; createdByOperationId?: string }, ): Promise { + const proofs = await this.unblindChangeProofs(mintUrl, outputData, changeSignatures, options); + const coreProofs = mapProofToCoreProof(mintUrl, 'ready', proofs, { + unit: options.unit, + createdByOperationId: options.createdByOperationId, + }); + await this.saveProofs(mintUrl, coreProofs); + + this.logger?.info('Change proofs unblinded and saved', { + mintUrl, + unit: normalizeUnit(options.unit), + count: coreProofs.length, + operationId: options.createdByOperationId, + }); + return coreProofs; + } + + /** + * Unblind all returned change signatures without writing repositories. + * Callers may perform this outside a transaction and persist the validated result atomically. + */ + async unblindChangeProofs( + mintUrl: string, + outputData: OutputDataLike[], + changeSignatures: SerializedBlindedSignature[], + options: { unit: string; createdByOperationId?: string }, + ): Promise { if (!mintUrl || mintUrl.trim().length === 0) { throw new ProofValidationError('mintUrl is required'); } @@ -981,46 +1010,29 @@ export class ProofService { keysetMap[ks.id] = ks; }); + if (changeSignatures.length > outputData.length) { + throw new ProofValidationError('Mint returned more change signatures than prepared outputs'); + } + // Slice output data to match signature count const matchedOutputs = outputData.slice(0, changeSignatures.length); // Unblind each signature to create proofs - const proofs: Proof[] = matchedOutputs.flatMap((output, i) => { + const proofs: Proof[] = matchedOutputs.map((output, i) => { const sig = changeSignatures[i]; const keyset = keysetMap[output.blindedMessage.id]; if (!sig || !keyset) { const reason = !sig ? 'missing signature' : 'missing keyset'; - this.logger?.warn('Failed to create change proof', { reason, index: i }); - return []; + throw new ProofValidationError(`Failed to create change proof: ${reason} at index ${i}`); } assertSameUnit( normalizeUnit(keyset.unit, { defaultUnit: DEFAULT_UNIT }), unit, 'Change proof keyset', ); - return [output.toProof(sig, { id: keyset.id, keys: keyset.keypairs as Keys })]; - }); - - if (proofs.length === 0) { - return []; - } - - // Map to CoreProof and save - const coreProofs = mapProofToCoreProof(mintUrl, 'ready', proofs, { - unit, - createdByOperationId: options?.createdByOperationId, + return output.toProof(sig, { id: keyset.id, keys: keyset.keypairs as Keys }); }); - - await this.saveProofs(mintUrl, coreProofs); - - this.logger?.info('Change proofs unblinded and saved', { - mintUrl, - unit, - count: coreProofs.length, - operationId: options?.createdByOperationId, - }); - - return coreProofs; + return proofs; } /** diff --git a/packages/core/test/fixtures/MintSwap.ts b/packages/core/test/fixtures/MintSwap.ts index a1c0434d..8ddcf459 100644 --- a/packages/core/test/fixtures/MintSwap.ts +++ b/packages/core/test/fixtures/MintSwap.ts @@ -68,9 +68,33 @@ export function makePreparedMintSwapOperation( 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' }] }, + destinationOutputData: { + keep: [ + { + blindedMessage: { amount: '1000', id: 'destination-keyset', B_: 'destination-B' }, + blindingFactor: '01', + secret: '64657374696e6174696f6e2d6f7574707574', + }, + ], + send: [], + }, + sourceOutputData: { + keep: [], + send: [ + { + blindedMessage: { amount: '1025', id: 'source-keyset', B_: 'source-B' }, + blindingFactor: '02', + secret: '736f757263652d6f7574707574', + }, + ], + }, + sourceMeltAmount: destinationAmount, + sourceFeeReserve, + sourcePreparationFee, + sourceMeltInputFee, + minimumSourceDebit, maximumSourceDebit, + reservedSourceAmount, dispatchDeadlineSeconds: Math.floor(MINT_SWAP_TEST_NOW / 1_000) + 120, requiredDispatchWindowSeconds: 120, }); diff --git a/packages/core/test/unit/MeltBolt11Handler.test.ts b/packages/core/test/unit/MeltBolt11Handler.test.ts index f13fb111..5f6b11e8 100644 --- a/packages/core/test/unit/MeltBolt11Handler.test.ts +++ b/packages/core/test/unit/MeltBolt11Handler.test.ts @@ -1493,6 +1493,10 @@ describe('MeltBolt11Handler', () => { inputProofSecrets: ['input-1'], swapOutputData: createMockOutputData(['keep-1'], ['send-1']), }); + (mockWallet.send as Mock).mockResolvedValueOnce({ + keep: [makeProof('keep-1', 10)], + send: [makeProof('send-1', 10)], + }); const remoteResult = await handler.executeOwnedRemote!({ operation, @@ -1516,10 +1520,6 @@ describe('MeltBolt11Handler', () => { operation, proofRepository, proofService, - walletService, - mintService, - mintAdapter, - eventBus, logger, }, remoteResult, diff --git a/packages/core/test/unit/MeltOperationService.test.ts b/packages/core/test/unit/MeltOperationService.test.ts index a688ca14..21babc6b 100644 --- a/packages/core/test/unit/MeltOperationService.test.ts +++ b/packages/core/test/unit/MeltOperationService.test.ts @@ -289,6 +289,14 @@ describe('MeltOperationService', () => { proofService = { releaseProofs: mock(async () => {}), + selectProofsToSend: mock(async () => [makeProof('planned-input')]), + createBlankOutputs: mock(async () => []), + createOutputsAndIncrementCounters: mock(async () => ({ + keep: [], + send: [], + keepAmount: Amount.zero(), + sendAmount: Amount.zero(), + })), forTransaction: mock((repositories) => ({ setProofState: mock( async ( @@ -305,6 +313,15 @@ describe('MeltOperationService', () => { restoreProofsToReady: mock(async (proofMintUrl: string, secrets: string[]) => { await repositories.proofRepository.setProofState(proofMintUrl, secrets, 'ready'); }), + reserveProofs: mock( + async (proofMintUrl: string, secrets: string[], operationId: string) => { + await repositories.proofRepository.reserveProofs(proofMintUrl, secrets, operationId); + return { amount: Amount.from(secrets.length), unit: 'sat' }; + }, + ), + releaseProofs: mock(async (proofMintUrl: string, secrets: string[]) => { + await repositories.proofRepository.releaseProofs(proofMintUrl, secrets); + }), })), } as unknown as ProofService; @@ -424,15 +441,92 @@ describe('MeltOperationService', () => { expect(handler.execute).not.toHaveBeenCalled(); }); + it('plans outside the transaction and reserves source proofs only while persisting', async () => { + const repositories = new MemoryRepositories(); + const quote = meltQuoteFromBolt11Response(mintUrl, { + quote: 'owned-plan-quote', + request: invoice, + amount: Amount.from(100), + unit: 'sat', + fee_reserve: Amount.from(1), + expiry: Math.floor(Date.now() / 1_000) + 3_600, + state: 'UNPAID', + payment_preimage: null, + }); + const plannedInput = makeProof('planned-input', { amount: Amount.from(101) }); + await repositories.proofRepository.saveProofs(mintUrl, [plannedInput]); + const ownedService = new MeltOperationService( + handlerProvider, + repositories.meltOperationRepository, + quoteLifecycle, + repositories.proofRepository, + proofService, + mintService, + walletService, + mintAdapter, + eventBus, + logger, + ); + (handler.prepare as Mock).mockImplementationOnce(async ({ operation }: any) => + makePreparedOp(operation.id, { + mintUrl, + quoteId: quote.quoteId, + parentSwapOperationId, + inputAmount: plannedInput.amount, + inputProofSecrets: [plannedInput.secret], + }), + ); + + const planned = await ownedService.planOwnedPreparation({ + operationId: 'owned-planned-source', + parentSwapOperationId, + quote, + wallet: {} as never, + }); + expect( + (await repositories.proofRepository.getProofBySecret(mintUrl, plannedInput.secret)) + ?.usedByOperationId, + ).toBe(undefined); + + await repositories.withTransaction((transaction) => + ownedService.prepareOwnedInTransaction({ + operationId: planned.id, + parentSwapOperationId, + quote, + preparedOperation: planned, + repositories: transaction, + }), + ); + + expect( + (await repositories.proofRepository.getProofBySecret(mintUrl, plannedInput.secret)) + ?.usedByOperationId, + ).toBe(planned.id); + expect((await repositories.meltOperationRepository.getById(planned.id))?.state).toBe( + 'prepared', + ); + }); + it('persists each authorization checkpoint before starting its repository-free remote phase', async () => { const repositories = new MemoryRepositories(); const operation = makePreparedOp('owned-pre-swap-checkpoint', { parentSwapOperationId, needsSwap: true, inputProofSecrets: ['owned-input'], - swapOutputData: { keep: [], send: [] }, + swapOutputData: { + keep: [], + send: [ + { + blindedMessage: { amount: 101, id: keysetId, B_: 'swap-send-B' }, + blindingFactor: '01', + secret: '737761702d73656e64', + }, + ], + }, }); - await repositories.proofRepository.saveProofs(mintUrl, [makeProof('owned-input')]); + await repositories.proofRepository.saveProofs(mintUrl, [ + makeProof('owned-input', { amount: Amount.from(101) }), + ]); await repositories.proofRepository.reserveProofs( mintUrl, operation.inputProofSecrets, @@ -483,7 +577,32 @@ describe('MeltOperationService', () => { operationId: operation.id, phase: 'pre_swap', keepProofs: [], - sendProofs: [], + sendProofs: [makeProof('swap-send', { amount: Amount.from(101) })], + }; + }, + ); + + (handler.applyOwnedRemote as Mock).mockImplementationOnce( + async (context: any, result: any) => { + const { operation: current, proofService: scopedProofService } = context; + await scopedProofService.setProofState( + current.mintUrl, + current.inputProofSecrets, + 'spent', + ); + await scopedProofService.saveProofs(current.mintUrl, [ + { + ...result.sendProofs[0], + mintUrl: current.mintUrl, + unit: current.unit, + state: 'inflight', + createdByOperationId: current.id, + }, + ]); + return { + ...current, + parentExecutionPhase: 'melt_authorized', + updatedAt: Date.now(), }; }, ); @@ -509,6 +628,13 @@ describe('MeltOperationService', () => { expect( (await repositories.meltOperationRepository.getById(operation.id))?.parentExecutionPhase, ).toBe('melt_authorized'); + await expect( + repositories.meltOperationRepository.update({ + ...(meltAuthorized as ExecutingMeltOperation), + parentExecutionPhase: 'pre_swap_authorized', + updatedAt: Date.now() + 1, + }), + ).rejects.toThrow('authorization cannot regress'); (handler.executeOwnedRemote as Mock).mockImplementationOnce( async ({ operation: remoteOperation, ...context }: Record) => { @@ -529,6 +655,180 @@ describe('MeltOperationService', () => { parentSwapOperationId, ); }); + + it('persists the canonical source observation before advancing the owned child', async () => { + const repositories = new MemoryRepositories(); + const operation = { + ...makePreparedOp('owned-canonical-observation', { parentSwapOperationId }), + state: 'executing' as const, + parentExecutionPhase: 'melt_authorized' as const, + }; + await repositories.meltOperationRepository.create(operation); + await repositories.meltQuoteRepository.upsertMeltQuote( + meltQuoteFromBolt11Response(mintUrl, { + quote: operation.quoteId, + request: invoice, + amount: operation.amount, + unit: operation.unit, + fee_reserve: operation.fee_reserve, + expiry: Math.floor(Date.now() / 1_000) + 3_600, + state: 'UNPAID', + payment_preimage: null, + }), + ); + const ownedService = new MeltOperationService( + handlerProvider, + repositories.meltOperationRepository, + quoteLifecycle, + repositories.proofRepository, + proofService, + mintService, + walletService, + mintAdapter, + eventBus, + logger, + ); + const observedAt = Date.now() + 1; + + const applied = await repositories.withTransaction((transaction) => + ownedService.applyOwnedRemoteStepInTransaction( + operation.id, + parentSwapOperationId, + { + operationId: operation.id, + phase: 'melt', + observedAt, + response: { state: 'PENDING' }, + }, + transaction, + ), + ); + + expect(applied.state).toBe('pending'); + const quote = await repositories.meltQuoteRepository.getMeltQuote( + mintUrl, + 'bolt11', + operation.quoteId, + ); + expect(quote?.state).toBe('PENDING'); + expect(quote?.lastObservedRemoteStateAt).toBe(observedAt); + }); + + it('does not let a stale owned result downgrade a canonical paid quote', async () => { + const repositories = new MemoryRepositories(); + const operation = { + ...makePreparedOp('owned-stale-observation', { parentSwapOperationId }), + state: 'executing' as const, + parentExecutionPhase: 'melt_authorized' as const, + }; + await repositories.meltOperationRepository.create(operation); + await repositories.meltQuoteRepository.upsertMeltQuote( + meltQuoteFromBolt11Response(mintUrl, { + quote: operation.quoteId, + request: invoice, + amount: operation.amount, + unit: operation.unit, + fee_reserve: operation.fee_reserve, + expiry: Math.floor(Date.now() / 1_000) + 3_600, + state: 'PAID', + payment_preimage: 'paid-preimage', + change: [], + }), + ); + const ownedService = new MeltOperationService( + handlerProvider, + repositories.meltOperationRepository, + quoteLifecycle, + repositories.proofRepository, + proofService, + mintService, + walletService, + mintAdapter, + eventBus, + logger, + ); + + await expect( + repositories.withTransaction((transaction) => + ownedService.applyOwnedRemoteStepInTransaction( + operation.id, + parentSwapOperationId, + { + operationId: operation.id, + phase: 'melt', + observedAt: Date.now() + 1, + response: { state: 'UNPAID' }, + }, + transaction, + ), + ), + ).rejects.toThrow('conflicts with canonical PAID'); + expect((await repositories.meltOperationRepository.getById(operation.id))?.state).toBe( + 'executing', + ); + expect( + (await repositories.meltQuoteRepository.getMeltQuote(mintUrl, 'bolt11', operation.quoteId)) + ?.state, + ).toBe('PAID'); + }); + + it('rejects paid owned results that contradict canonical settlement evidence', async () => { + const repositories = new MemoryRepositories(); + const operation = { + ...makePreparedOp('owned-conflicting-settlement', { parentSwapOperationId }), + state: 'executing' as const, + parentExecutionPhase: 'melt_authorized' as const, + }; + await repositories.meltOperationRepository.create(operation); + await repositories.meltQuoteRepository.upsertMeltQuote( + meltQuoteFromBolt11Response(mintUrl, { + quote: operation.quoteId, + request: invoice, + amount: operation.amount, + unit: operation.unit, + fee_reserve: operation.fee_reserve, + expiry: Math.floor(Date.now() / 1_000) + 3_600, + state: 'PAID', + payment_preimage: 'canonical-preimage', + change: [], + }), + ); + const ownedService = new MeltOperationService( + handlerProvider, + repositories.meltOperationRepository, + quoteLifecycle, + repositories.proofRepository, + proofService, + mintService, + walletService, + mintAdapter, + eventBus, + logger, + ); + + await expect( + repositories.withTransaction((transaction) => + ownedService.applyOwnedRemoteStepInTransaction( + operation.id, + parentSwapOperationId, + { + operationId: operation.id, + phase: 'melt', + observedAt: Date.now() + 1, + response: { + state: 'PAID', + change: [], + payment_preimage: 'conflicting-preimage', + }, + }, + transaction, + ), + ), + ).rejects.toThrow('preimage conflicts with canonical quote'); + expect((await repositories.meltOperationRepository.getById(operation.id))?.state).toBe( + 'executing', + ); + }); }); describe('quotes', () => { diff --git a/packages/core/test/unit/MintOperationService.test.ts b/packages/core/test/unit/MintOperationService.test.ts index 5dd22901..4883c487 100644 --- a/packages/core/test/unit/MintOperationService.test.ts +++ b/packages/core/test/unit/MintOperationService.test.ts @@ -353,7 +353,9 @@ describe('MintOperationService', () => { if (!options?.createdByOperationId) { return []; } - await proofRepo.saveProofs(mintUrl, [toCoreProof('out-1', options.createdByOperationId)]); + if (options.persistRecoveredProofs !== false) { + await proofRepo.saveProofs(mintUrl, [toCoreProof('out-1', options.createdByOperationId)]); + } return [makeProof('out-1')]; }), } as unknown as ProofService; @@ -3268,6 +3270,14 @@ describe('MintOperationService', () => { }), ); + const planned = await service.planOwnedPreparation({ + operationId: 'owned-locked-destination', + parentSwapOperationId, + quote, + amount: Amount.from(10), + destinationNut20PublicKey, + wallet, + }); const prepared = await repositories.withTransaction((transaction) => service.prepareOwnedInTransaction({ operationId: 'owned-locked-destination', @@ -3275,7 +3285,7 @@ describe('MintOperationService', () => { quote, amount: Amount.from(10), destinationNut20PublicKey, - wallet, + preparedOperation: planned, repositories: transaction, }), ); @@ -3298,17 +3308,14 @@ describe('MintOperationService', () => { 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, - }), - ), + service.planOwnedPreparation({ + operationId: 'owned-wrong-destination-key', + parentSwapOperationId, + quote, + amount: Amount.from(10), + destinationNut20PublicKey, + wallet, + }), ).rejects.toThrow('not locked to the parent NUT-20 key'); expect(handler.prepare).not.toHaveBeenCalled(); }); @@ -3328,6 +3335,25 @@ describe('MintOperationService', () => { expect(executingEvents).toHaveLength(0); }); + it('rejects owned commands before mutation when the durable capability is absent', async () => { + const repositories = new MemoryRepositories(); + const operation = makeOwnedPending('owned-capability-gate'); + await repositories.mintOperationRepository.create(operation); + + await expect( + repositories.withTransaction((transaction) => + service.authorizeOwnedExecutionInTransaction(operation.id, parentSwapOperationId, { + ...transaction, + mintSwap: undefined, + }), + ), + ).rejects.toThrow('optional durable repository capability'); + expect((await repositories.mintOperationRepository.getById(operation.id))?.state).toBe( + 'pending', + ); + expect(handler.executeOwnedRemote).not.toHaveBeenCalled(); + }); + it('commits authorization before network execution and gives the remote phase no repositories', async () => { const repositories = new MemoryRepositories(); const operation = makeOwnedPending('owned-authorize-before-remote'); @@ -3346,6 +3372,7 @@ describe('MintOperationService', () => { const persistedAuthorization = await repositories.mintOperationRepository.getById( operation.id, ); + await operationRepo.create(authorized); expect(persistedAuthorization?.state).toBe('executing'); expect(handler.executeOwnedRemote).not.toHaveBeenCalled(); @@ -3382,6 +3409,7 @@ describe('MintOperationService', () => { return { status: 'ISSUED', proofs: [makeProof('owned-custom-output')] }; }), }; + await operationRepo.create(operation); (handlerProvider.get as Mock).mockReturnValueOnce(customHandler); await expect(service.executeOwnedRemote(operation, parentSwapOperationId)).rejects.toThrow( @@ -3390,6 +3418,39 @@ describe('MintOperationService', () => { expect(customHandler.execute).not.toHaveBeenCalled(); }); + it('restores already-issued deterministic outputs outside the transaction without saving', async () => { + const operation = { + ...makeExecutingOp('owned-restore-issued', 'restored-output'), + parentSwapOperationId, + pubkey: destinationNut20PublicKey, + }; + await operationRepo.create(operation); + (handler.executeOwnedRemote as Mock).mockResolvedValueOnce({ + status: 'ALREADY_ISSUED', + }); + (proofService.recoverProofsFromOutputData as Mock).mockResolvedValueOnce([ + makeProof('restored-output'), + ]); + + const result = await service.executeOwnedRemote(operation.id, parentSwapOperationId); + + expect(result).toEqual({ + operationId: operation.id, + status: 'ISSUED', + proofs: [makeProof('restored-output')], + }); + expect(proofService.recoverProofsFromOutputData).toHaveBeenCalledWith( + operation.mintUrl, + operation.outputData, + { + unit: operation.unit, + createdByOperationId: operation.id, + persistRecoveredProofs: false, + }, + ); + expect(await proofRepo.getProofBySecret(mintUrl, 'restored-output')).toBeNull(); + }); + it('atomically rolls back local result application when its composing transaction fails', async () => { const repositories = new MemoryRepositories(); const operation = { @@ -3418,7 +3479,11 @@ describe('MintOperationService', () => { await service.applyOwnedExecutionInTransaction( operation, parentSwapOperationId, - { status: 'ISSUED', proofs: [makeProof('atomic-output')] }, + { + operationId: operation.id, + status: 'ISSUED', + proofs: [makeProof('atomic-output')], + }, transaction, ); throw new Error('rollback composing parent transition'); @@ -3470,7 +3535,11 @@ describe('MintOperationService', () => { service.applyOwnedExecutionInTransaction( operation, parentSwapOperationId, - { status: 'ISSUED', proofs: [makeProof('existing-output')] }, + { + operationId: operation.id, + status: 'ISSUED', + proofs: [makeProof('existing-output')], + }, transaction, ), ); diff --git a/packages/core/test/unit/MintSwapOperation.test.ts b/packages/core/test/unit/MintSwapOperation.test.ts index c41ef608..80055d00 100644 --- a/packages/core/test/unit/MintSwapOperation.test.ts +++ b/packages/core/test/unit/MintSwapOperation.test.ts @@ -296,6 +296,103 @@ describe('MintSwapOperation', () => { ).toThrow('maximum source debit is immutable'); }); + it('rejects impossible authorization projections and unaudited attention recovery', () => { + expect(() => + validateMintSwapOperation( + makePreparedMintSwapOperation({ sourceDispatchAuthorizedAt: now + 1 }), + ), + ).toThrow('cannot authorize source dispatch'); + expect(() => + validateMintSwapOperation( + makePreparingMintSwapOperation({ destinationIssueAuthorizedAt: now }), + ), + ).toThrow('cannot authorize destination issuance'); + expect(() => + validateMintSwapOperation( + makeSettledMintSwapOperation({ + state: 'issuing', + destinationIssueAuthorizedAt: now + 1, + }), + ), + ).toThrow('must follow source dispatch'); + expect(() => + validateMintSwapOperation( + makePreparedMintSwapOperation({ + state: 'cancelled', + cancellationRequestedAt: now + 2, + cancelledAt: now + 1, + updatedAt: now + 2, + }), + ), + ).toThrow('cancellation completion must follow its request'); + + const attention = makePreparedMintSwapOperation({ + state: 'needs_attention', + attention: { + reason: 'accounting_mismatch', + message: 'Issuing evidence is incomplete', + lastSafeState: 'issuing', + violatedInvariant: 'issuance requires durable authorization', + evidence: {}, + at: now + 2, + }, + updatedAt: now + 2, + }); + expect(() => validateMintSwapOperation(attention)).toThrow('source dispatch authorization'); + expect(canTransitionMintSwap('needs_attention', 'completed')).toBe(false); + + const sourceInflight = makePreparedMintSwapOperation({ + state: 'source_inflight', + sourceDispatchAuthorizedAt: now + 2, + updatedAt: now + 2, + }); + expect(() => + validateMintSwapOperation({ + ...sourceInflight, + state: 'failed', + terminalFailure: { code: 'UNPAID', reason: 'Source returned unpaid', at: now + 3 }, + updatedAt: now + 3, + }), + ).toThrow('requires source reclamation evidence'); + expect(() => + validateMintSwapOperation({ + ...sourceInflight, + state: 'failed', + sourceReclaimedAt: now + 3, + terminalFailure: { code: 'UNPAID', reason: 'Source returned unpaid', at: now + 3 }, + updatedAt: now + 3, + }), + ).not.toThrow(); + }); + + it('keeps creation and retry evidence monotonic across CAS updates', () => { + const current = makePreparingMintSwapOperation({ + createdAt: now - 10, + retry: { attemptCount: 2, lastAttemptAt: now, lastSuccessfulObservationAt: now }, + }); + const next = { + ...current, + revision: current.revision + 1, + preparationLease: { ...current.preparationLease!, expiresAt: now + 60_000 }, + updatedAt: now + 1, + }; + expect(() => + assertMintSwapOperationUpdate(current, { ...next, createdAt: current.createdAt - 1 }), + ).toThrow('createdAt is immutable'); + expect(() => + assertMintSwapOperationUpdate(current, { + ...next, + retry: { attemptCount: 1, lastAttemptAt: now, lastSuccessfulObservationAt: now }, + }), + ).toThrow('attempt count cannot regress'); + expect(() => + assertMintSwapOperationUpdate(current, { + ...next, + retry: { attemptCount: 2, lastAttemptAt: now - 1, lastSuccessfulObservationAt: now }, + }), + ).toThrow('last attempt cannot regress'); + }); + it('fingerprints canonical object keys while remaining sensitive to ordered plans and keys', () => { const prepared = makePreparedMintSwapOperation(); const common = { @@ -307,38 +404,71 @@ describe('MintSwapOperation', () => { destinationAmount: prepared.destinationAmount, unit: 'sat' as const, sourceInputProofSecrets: ['a', 'b'], - sourceOutputData: { keep: [], send: [{ amount: '1025' }] }, + sourceOutputData: { + keep: [], + send: [ + { + blindedMessage: { amount: '1025', id: 'source-keyset', B_: 'source-B' }, + blindingFactor: '02', + secret: '62', + }, + ], + }, + sourceMeltAmount: prepared.preparedPlan!.sourceMeltAmount, + sourceFeeReserve: prepared.preparedPlan!.sourceFeeReserve, + sourcePreparationFee: prepared.preparedPlan!.sourcePreparationFee, + sourceMeltInputFee: prepared.preparedPlan!.sourceMeltInputFee, + minimumSourceDebit: prepared.preparedPlan!.minimumSourceDebit, maximumSourceDebit: prepared.preparedPlan!.maximumSourceDebit, + reservedSourceAmount: prepared.preparedPlan!.reservedSourceAmount, dispatchDeadlineSeconds: prepared.preparedPlan!.dispatchDeadlineSeconds, requiredDispatchWindowSeconds: prepared.preparedPlan!.requiredDispatchWindowSeconds, }; const first = createMintSwapPreparedPlanFingerprint({ ...common, - destinationOutputData: { z: 1, a: { second: 2, first: 1 } }, + destinationOutputData: { + keep: [ + { + blindedMessage: { amount: '1000', id: 'destination-keyset', B_: 'destination-B' }, + blindingFactor: '01', + secret: '61', + }, + ], + send: [], + }, }); const reordered = createMintSwapPreparedPlanFingerprint({ ...common, - destinationOutputData: { a: { first: 1, second: 2 }, z: 1 }, + destinationOutputData: { + send: [], + keep: [ + { + secret: '61', + blindingFactor: '01', + blindedMessage: { B_: 'destination-B', id: 'destination-keyset', amount: '1000' }, + }, + ], + }, }); const changedKey = createMintSwapPreparedPlanFingerprint({ ...common, destinationNut20Key: { ...prepared.destinationNut20Key, derivationIndex: 8 }, - destinationOutputData: { z: 1, a: { second: 2, first: 1 } }, + destinationOutputData: firstOutputData(), }); const changedOrder = createMintSwapPreparedPlanFingerprint({ ...common, sourceInputProofSecrets: ['b', 'a'], - destinationOutputData: { z: 1, a: { second: 2, first: 1 } }, + destinationOutputData: firstOutputData(), }); const changedDeadline = createMintSwapPreparedPlanFingerprint({ ...common, dispatchDeadlineSeconds: common.dispatchDeadlineSeconds + 1, - destinationOutputData: { z: 1, a: { second: 2, first: 1 } }, + destinationOutputData: firstOutputData(), }); const changedWindow = createMintSwapPreparedPlanFingerprint({ ...common, requiredDispatchWindowSeconds: common.requiredDispatchWindowSeconds + 1, - destinationOutputData: { z: 1, a: { second: 2, first: 1 } }, + destinationOutputData: firstOutputData(), }); expect(first).toBe(reordered); @@ -346,9 +476,35 @@ describe('MintSwapOperation', () => { expect(changedOrder).not.toBe(first); expect(changedDeadline).not.toBe(first); expect(changedWindow).not.toBe(first); + expect( + createMintSwapPreparedPlanFingerprint({ + ...common, + sourceFeeReserve: common.sourceFeeReserve.add(1), + destinationOutputData: firstOutputData(), + }), + ).not.toBe(first); + expect(() => + createMintSwapPreparedPlanFingerprint({ + ...common, + destinationOutputData: new Map() as never, + }), + ).toThrow('plain objects'); }); }); +function firstOutputData() { + return { + keep: [ + { + blindedMessage: { amount: '1000', id: 'destination-keyset', B_: 'destination-B' }, + blindingFactor: '01', + secret: '61', + }, + ], + send: [], + }; +} + describe('OperationEventOutbox', () => { it('validates a sanitized logical event and derives its unique key', () => { const event = makeMintSwapOutboxRecord(); @@ -378,5 +534,19 @@ describe('OperationEventOutbox', () => { nextAttemptAt: now + 3, }), ).toThrow('cannot retain retry scheduling'); + expect(() => + validateOperationEventOutboxRecord({ + ...event, + eventType: 'mint-swap-op:delayed', + payload: { ...event.payload, state: 'completed', reasonCode: 'RETRY_EXHAUSTED' }, + }), + ).toThrow('automatic operation state'); + expect(() => + validateOperationEventOutboxRecord({ + ...event, + eventType: 'mint-swap-op:delayed', + payload: { ...event.payload, state: 'source_inflight' }, + }), + ).toThrow('requires a reason code'); }); }); diff --git a/packages/core/test/unit/ProofService.test.ts b/packages/core/test/unit/ProofService.test.ts index 7c695351..ee63ca2f 100644 --- a/packages/core/test/unit/ProofService.test.ts +++ b/packages/core/test/unit/ProofService.test.ts @@ -345,6 +345,36 @@ describe('ProofService', () => { const counter = await counterRepo.getCounter(mintUrl, 'usd-keyset'); expect(counter?.counter).toBe(1); }); + + it('rejects fee-inflated send outputs when keep cannot fund the added fee', async () => { + const createDeterministicData = mock(() => [] as OutputDataLike[]); + const service = new ProofService( + counterService, + proofRepo, + walletService as any, + mintService as any, + keyRingService as any, + seedService, + undefined, + bus, + makeOutputDataCreator({ createDeterministicData }), + ); + service.calculateSendAmountWithFees = mock(async () => Amount.from(12)); + + await expect( + service.createOutputsAndIncrementCounters( + mintUrl, + { keep: unitAmount(1), send: unitAmount(10) }, + { includeFees: true }, + ), + ).rejects.toThrow('Keep amount cannot cover the receiver fee'); + expect(createDeterministicData).not.toHaveBeenCalled(); + await expect(counterRepo.getCounter(mintUrl, keysetId)).resolves.toEqual({ + mintUrl, + keysetId, + counter: 0, + }); + }); }); describe('createBlankOutputs', () => { diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 484765fd..cecc9027 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -30,6 +30,7 @@ "baseUrl": ".", "paths": { "@cashu/coco-adapter-tests": ["../adapter-tests/src/index.ts"], + "@cashu/coco-core": ["./index.ts"], "@cashu/coco-core/adapter": ["./adapter.ts"], "@core/*": ["./*"], "@core/models": ["./models/index.ts"], diff --git a/packages/core/utils.ts b/packages/core/utils.ts index 8370550d..43d1f4a5 100644 --- a/packages/core/utils.ts +++ b/packages/core/utils.ts @@ -161,6 +161,36 @@ export function getProofStateInputsFromSerializedOutputs( })); } +/** Validate that remote proofs are exactly the proofs described by persisted output data. */ +export function assertProofsMatchSerializedOutputs( + proofs: readonly Proof[], + outputs: readonly SerializedOutput[], + label: string, +): void { + if (proofs.length !== outputs.length) { + throw new Error(`${label} proof count does not match deterministic outputs`); + } + const expectedBySecret = new Map( + outputs.map((output) => [decodeSecretHex(output.secret), output] as const), + ); + if (expectedBySecret.size !== outputs.length) { + throw new Error(`${label} deterministic outputs contain duplicate secrets`); + } + const seen = new Set(); + for (const proof of proofs) { + if (seen.has(proof.secret)) throw new Error(`${label} proofs contain duplicate secrets`); + seen.add(proof.secret); + const output = expectedBySecret.get(proof.secret); + if (!output) throw new Error(`${label} proof secret does not match deterministic outputs`); + if (proof.id !== output.blindedMessage.id) { + throw new Error(`${label} proof keyset does not match deterministic outputs`); + } + if (!Amount.from(proof.amount).equals(Amount.from(output.blindedMessage.amount))) { + throw new Error(`${label} proof amount does not match deterministic outputs`); + } + } +} + export function mapProofToCoreProof( mintUrl: string, state: ProofState, From 7abe2558d2356a6e14a1cfe89cec785ddcc6ff4d Mon Sep 17 00:00:00 2001 From: IgboPharaoh Date: Sun, 9 Aug 2026 18:10:28 +0100 Subject: [PATCH 10/15] fix(core): expose mint swap persistence predicates --- packages/core/adapter.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/core/adapter.ts b/packages/core/adapter.ts index 65aaaf4e..cc9be9d4 100644 --- a/packages/core/adapter.ts +++ b/packages/core/adapter.ts @@ -99,6 +99,9 @@ export type { export { assertMintSwapOperationUpdate, createMintSwapPreparedPlanFingerprint, + getMintSwapOperationDueAt, + isMintSwapOperationDue, + isTerminalMintSwapState, validateMintSwapOperation, } from './operations/mintSwap/MintSwapOperation.ts'; export { From 9690a4755493dd3add437083486c6bfeabd3db15 Mon Sep 17 00:00:00 2001 From: IgboPharaoh Date: Sun, 9 Aug 2026 18:11:48 +0100 Subject: [PATCH 11/15] fix(core): expose mint swap outbox predicates --- packages/core/adapter.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/adapter.ts b/packages/core/adapter.ts index cc9be9d4..976ed8a4 100644 --- a/packages/core/adapter.ts +++ b/packages/core/adapter.ts @@ -111,6 +111,8 @@ export { assertParentOwnedMintOperationUpdate, } from './operations/mintSwap/ChildOperationOwnership.ts'; export { + isOperationEventDue, + isOperationEventPublished, operationEventLogicalKey, validateOperationEventOutboxRecord, } from './models/OperationEventOutbox.ts'; From 70b191671bec36fbca6a89ed36d35583fbf65e27 Mon Sep 17 00:00:00 2001 From: IgboPharaoh Date: Sun, 9 Aug 2026 18:34:04 +0100 Subject: [PATCH 12/15] feat(storage): persist mint swap capability in SQL adapters --- bun.lock | 3 + packages/adapter-tests/src/index.ts | 272 ++++++++++++++++++ packages/expo-sqlite/package.json | 3 +- packages/expo-sqlite/src/index.ts | 2 + .../expo-sqlite/src/test/contract.test.ts | 33 +++ packages/sql-storage/src/index.ts | 2 + packages/sql-storage/src/repositories.ts | 11 + .../repositories/MeltOperationRepository.ts | 35 ++- .../repositories/MintOperationRepository.ts | 35 ++- .../MintSwapOperationRepository.ts | 258 +++++++++++++++++ .../OperationEventOutboxRepository.ts | 124 ++++++++ packages/sql-storage/src/schema.ts | 59 ++++ packages/sql-storage/src/test/schema.test.ts | 64 +++++ packages/sqlite-bun/package.json | 3 +- packages/sqlite-bun/src/index.ts | 2 + packages/sqlite-bun/src/test/contract.test.ts | 29 ++ packages/sqlite-bun/tsconfig.json | 2 +- packages/sqlite3/package.json | 1 + packages/sqlite3/src/index.ts | 2 + packages/sqlite3/src/test/contract.test.ts | 29 ++ 20 files changed, 947 insertions(+), 22 deletions(-) create mode 100644 packages/sql-storage/src/repositories/MintSwapOperationRepository.ts create mode 100644 packages/sql-storage/src/repositories/OperationEventOutboxRepository.ts diff --git a/bun.lock b/bun.lock index f41019d2..58cd9c98 100644 --- a/bun.lock +++ b/bun.lock @@ -77,6 +77,7 @@ "devDependencies": { "@cashu/coco-adapter-tests": "1.0.0", "@cashu/coco-sql-storage": "1.0.0", + "@types/node": "^25.0.9", }, "peerDependencies": { "@cashu/cashu-ts": "5.0.0-rc.4", @@ -145,6 +146,7 @@ "devDependencies": { "@cashu/coco-adapter-tests": "1.0.0", "@cashu/coco-sql-storage": "1.0.0", + "@types/node": "^25.0.9", }, "peerDependencies": { "@cashu/cashu-ts": "5.0.0-rc.4", @@ -162,6 +164,7 @@ "devDependencies": { "@cashu/coco-adapter-tests": "1.0.0", "@cashu/coco-sql-storage": "1.0.0", + "@types/node": "^25.0.9", "vitest": "^2.1.9", }, "peerDependencies": { diff --git a/packages/adapter-tests/src/index.ts b/packages/adapter-tests/src/index.ts index 22567b6c..953abe86 100644 --- a/packages/adapter-tests/src/index.ts +++ b/packages/adapter-tests/src/index.ts @@ -32,6 +32,14 @@ type ContractOptions = { testConcurrentRootOperationIsolation?: boolean; }; +type RepositoryPairOptions = { + createRepositoryPair(): Promise<{ + first: TRepositories; + second: TRepositories; + dispose(): Promise; + }>; +}; + export async function runRepositoryTransactionContract( options: ContractOptions, runner: ContractRunner, @@ -251,6 +259,7 @@ type Expectation = { type ExpectApi = { toBe(value: unknown): void; + toEqual(value: unknown): void; toHaveLength(len: number): void; toBeGreaterThan(value: number): void; toBeDefined(): void; @@ -866,6 +875,9 @@ export async function runMintSwapRepositoryContract( const { repositories, dispose } = await options.createRepositories(); try { const capability = requireMintSwapCapability(repositories.mintSwap); + await repositories.withTransaction(async (tx) => { + expect(tx.mintSwap).toBeDefined(); + }); const operation = createDummyMintSwapOperation(); await capability.mintSwapOperationRepository.create(operation); const stored = await capability.mintSwapOperationRepository.getById(operation.id); @@ -880,6 +892,52 @@ export async function runMintSwapRepositoryContract( expect(stored?.preparedPlan?.maximumSourceDebit.toString()).toBe('9007199254741006'); expect(byDestination?.id).toBe(operation.id); expect(bySource?.id).toBe(operation.id); + expect( + (await capability.mintSwapOperationRepository.getByState('prepared')).map(({ id }) => id), + ).toEqual([operation.id]); + expect( + (await capability.mintSwapOperationRepository.getActive()).map(({ id }) => id), + ).toEqual([operation.id]); + if (!stored) throw new Error('Expected persisted mint swap operation'); + stored.retry.attemptCount = 99; + expect( + (await capability.mintSwapOperationRepository.getById(operation.id))?.retry.attemptCount, + ).toBe(0); + } finally { + await dispose(); + } + }); + + it('round-trips completed settlement amounts without numeric coercion', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const capability = requireMintSwapCapability(repositories.mintSwap); + const prepared = createDummyMintSwapOperation(); + const completed = { + ...prepared, + state: 'completed', + revision: 0, + sourceDispatchAuthorizedAt: prepared.updatedAt + 1, + destinationIssueAuthorizedAt: prepared.updatedAt + 2, + settlement: { + sourcePaymentFee: Amount.from(5), + totalSourceFee: Amount.from(8), + sourceMeltChangeAmount: Amount.from(3), + sourceKeepAmount: Amount.from(2), + sourceReturnedAmount: Amount.from(5), + finalSourceDebit: prepared.destinationAmount.add(Amount.from(8)), + destinationAmountIssued: prepared.destinationAmount, + }, + completedAt: prepared.updatedAt + 3, + updatedAt: prepared.updatedAt + 3, + } satisfies MintSwapOperation; + + await capability.mintSwapOperationRepository.create(completed); + const stored = await capability.mintSwapOperationRepository.getById(completed.id); + + expect(stored?.settlement?.finalSourceDebit.toString()).toBe('9007199254741001'); + expect(stored?.settlement?.destinationAmountIssued?.toString()).toBe('9007199254740993'); + expect(await capability.mintSwapOperationRepository.getActive()).toHaveLength(0); } finally { await dispose(); } @@ -949,6 +1007,16 @@ export async function runMintSwapRepositoryContract( ), expect, ); + await expectThrows( + () => + capability.mintSwapOperationRepository.create( + createDummyMintSwapOperation({ + id: 'source-conflict-mint-swap-op', + destinationMintOperationId: 'other-destination-mint-op', + }), + ), + expect, + ); const standaloneMint = createDummyMintOperation({ id: 'standalone-mint', quoteId: 'standalone-mint-quote', @@ -1049,6 +1117,28 @@ export async function runMintSwapRepositoryContract( ); await expectThrows(() => repositories.mintOperationRepository.delete(mintChild.id), expect); + const executingMeltChild = { + ...createDummyMeltOperation({ + id: 'executing-owned-melt', + parentSwapOperationId: 'executing-melt-parent', + }), + state: 'executing', + quoteId: 'executing-owned-melt-quote', + amount: Amount.from(3), + fee_reserve: Amount.from(1), + swap_fee: Amount.zero(), + needsSwap: false, + inputAmount: Amount.from(4), + inputProofSecrets: ['executing-owned-input'], + changeOutputData: { keep: [], send: [] }, + parentExecutionPhase: 'melt_authorized', + } satisfies MeltOperation; + await repositories.meltOperationRepository.create(executingMeltChild); + expect( + (await repositories.meltOperationRepository.getById(executingMeltChild.id)) + ?.parentExecutionPhase, + ).toBe('melt_authorized'); + const meltChild = createDummyMeltOperation({ id: 'owned-melt', quoteId: 'owned-melt-quote', @@ -1110,6 +1200,18 @@ export async function runMintSwapRepositoryContract( pubkey: `02${'ab'.repeat(32)}`, }), ); + await tx.meltOperationRepository.create( + createDummyMeltOperation({ + id: 'rolled-back-source-child', + quoteId: 'rolled-back-source-child-quote', + parentSwapOperationId: 'mint-swap-op', + }), + ); + await tx.mintRepository.addOrUpdateMint(createDummyMint()); + await tx.keysetRepository.addKeyset(createDummyKeyset()); + await tx.counterRepository.setCounter('https://mint.test', 'keyset-id', 7); + await tx.proofRepository.saveProofs('https://mint.test', [createDummyProof()]); + await tx.mintQuoteRepository.upsertMintQuote(createDummyMintQuote()); throw new Error('injected rollback'); }), expect, @@ -1118,6 +1220,21 @@ export async function runMintSwapRepositoryContract( 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); + expect(await repositories.meltOperationRepository.getById('rolled-back-source-child')).toBe( + null, + ); + expect(await repositories.mintRepository.getAllMints()).toHaveLength(0); + expect( + await repositories.counterRepository.getCounter('https://mint.test', 'keyset-id'), + ).toBe(null); + expect(await repositories.proofRepository.getAllReadyProofs()).toHaveLength(0); + expect( + await repositories.mintQuoteRepository.getMintQuote( + 'https://mint.test', + 'bolt11', + 'quote-id', + ), + ).toBe(null); } finally { await dispose(); } @@ -1166,6 +1283,152 @@ export async function runMintSwapRepositoryContract( await dispose(); } }); + + it('returns unpublished outbox work in durable retry order', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const outbox = requireMintSwapCapability( + repositories.mintSwap, + ).operationEventOutboxRepository; + await outbox.enqueue( + createDummyOperationEventOutboxRecord({ + id: 'outbox-due-later', + operationId: 'mint-swap-later', + payload: { + ...createDummyOperationEventOutboxRecord().payload, + operationId: 'mint-swap-later', + }, + publishAttempts: 1, + nextAttemptAt: 1_700_000_000_020, + lastError: 'retry later', + }), + ); + await outbox.enqueue( + createDummyOperationEventOutboxRecord({ + id: 'outbox-due-first', + operationId: 'mint-swap-first', + payload: { + ...createDummyOperationEventOutboxRecord().payload, + operationId: 'mint-swap-first', + }, + publishAttempts: 1, + nextAttemptAt: 1_700_000_000_010, + lastError: 'retry first', + }), + ); + await outbox.enqueue( + createDummyOperationEventOutboxRecord({ + id: 'outbox-future', + operationId: 'mint-swap-future', + payload: { + ...createDummyOperationEventOutboxRecord().payload, + operationId: 'mint-swap-future', + }, + publishAttempts: 1, + nextAttemptAt: 1_700_000_000_021, + lastError: 'retry future', + }), + ); + + const due = await outbox.getUnpublished(10, 1_700_000_000_020); + expect(due.map(({ id }) => id)).toEqual(['outbox-due-first', 'outbox-due-later']); + } finally { + await dispose(); + } + }); + }); +} + +export async function runMintSwapRepositoryConcurrencyContract( + options: RepositoryPairOptions, + runner: ContractRunner, +): Promise { + const { describe, it, expect } = runner; + + describe('Mint Swap persistent multi-root contract', () => { + it('allows one CAS winner across repository roots', async () => { + const { first, second, dispose } = await options.createRepositoryPair(); + try { + const firstCapability = requireMintSwapCapability(first.mintSwap); + const secondCapability = requireMintSwapCapability(second.mintSwap); + const operation = createDummyPreparingMintSwapOperation(); + await firstCapability.mintSwapOperationRepository.create(operation); + const next = { + ...operation, + revision: 1, + retry: { attemptCount: 1 }, + updatedAt: operation.updatedAt + 1, + } satisfies MintSwapOperation; + + const results = await Promise.all([ + firstCapability.mintSwapOperationRepository.compareAndSet(next, 0), + secondCapability.mintSwapOperationRepository.compareAndSet(next, 0), + ]); + + expect(results.filter(Boolean)).toHaveLength(1); + expect( + (await secondCapability.mintSwapOperationRepository.getById(operation.id))?.revision, + ).toBe(1); + } finally { + await dispose(); + } + }); + + it('enforces one destination child per parent across repository roots', async () => { + const { first, second, dispose } = await options.createRepositoryPair(); + try { + const makeChild = (id: string, quoteId: string): MintOperation => + createDummyMintOperation({ + id, + quoteId, + parentSwapOperationId: 'shared-parent', + pubkey: `02${'ab'.repeat(32)}`, + outputData: { + keep: [ + { + blindedMessage: { amount: 3, id: 'owned-keyset', B_: `owned-${id}` }, + blindingFactor: '01', + secret: id === 'first-child' ? '6669727374' : '7365636f6e64', + }, + ], + send: [], + }, + }); + + const results = await Promise.all([ + resolves(() => first.mintOperationRepository.create(makeChild('first-child', 'quote-a'))), + resolves(() => + second.mintOperationRepository.create(makeChild('second-child', 'quote-b')), + ), + ]); + + expect(results.filter(Boolean)).toHaveLength(1); + } finally { + await dispose(); + } + }); + + it('enforces one source child per parent across repository roots', async () => { + const { first, second, dispose } = await options.createRepositoryPair(); + try { + const makeChild = (id: string, quoteId: string): MeltOperation => + createDummyMeltOperation({ + id, + quoteId, + parentSwapOperationId: 'shared-source-parent', + }); + + const results = await Promise.allSettled([ + first.meltOperationRepository.create(makeChild('source-child-a', 'source-quote-a')), + second.meltOperationRepository.create(makeChild('source-child-b', 'source-quote-b')), + ]); + + expect(results.filter(({ status }) => status === 'fulfilled')).toHaveLength(1); + expect(results.filter(({ status }) => status === 'rejected')).toHaveLength(1); + } finally { + await dispose(); + } + }); }); } @@ -1175,6 +1438,15 @@ function requireMintSwapCapability( if (!capability) throw new Error('Mint Swap repository capability is required by this contract'); return capability; } + +async function resolves(fn: () => Promise): Promise { + try { + await fn(); + return true; + } catch { + return false; + } +} export async function runMintOperationRepositoryContract( options: ContractOptions, runner: ContractRunner, diff --git a/packages/expo-sqlite/package.json b/packages/expo-sqlite/package.json index 36c7977e..bcdca2f0 100644 --- a/packages/expo-sqlite/package.json +++ b/packages/expo-sqlite/package.json @@ -16,7 +16,8 @@ ], "devDependencies": { "@cashu/coco-adapter-tests": "1.0.0", - "@cashu/coco-sql-storage": "1.0.0" + "@cashu/coco-sql-storage": "1.0.0", + "@types/node": "^25.0.9" }, "exports": { ".": { diff --git a/packages/expo-sqlite/src/index.ts b/packages/expo-sqlite/src/index.ts index 39eaa9f2..d006e82c 100644 --- a/packages/expo-sqlite/src/index.ts +++ b/packages/expo-sqlite/src/index.ts @@ -24,6 +24,7 @@ export class SqliteRepositories implements Repositories { readonly receiveOperationRepository: Repositories['receiveOperationRepository']; readonly paymentRequestReceiveOperationRepository: Repositories['paymentRequestReceiveOperationRepository']; readonly paymentRequestReceiveAttemptRepository: Repositories['paymentRequestReceiveAttemptRepository']; + readonly mintSwap: NonNullable; private readonly db: ExpoSqliteDb; private readonly repositories: SqlStorageRepositories; @@ -49,6 +50,7 @@ export class SqliteRepositories implements Repositories { this.repositories.paymentRequestReceiveOperationRepository; this.paymentRequestReceiveAttemptRepository = this.repositories.paymentRequestReceiveAttemptRepository; + this.mintSwap = this.repositories.mintSwap; } async init(): Promise { diff --git a/packages/expo-sqlite/src/test/contract.test.ts b/packages/expo-sqlite/src/test/contract.test.ts index 2a427f81..de96cf9b 100644 --- a/packages/expo-sqlite/src/test/contract.test.ts +++ b/packages/expo-sqlite/src/test/contract.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from 'bun:test'; import { Database } from 'bun:sqlite'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { runRepositoryTransactionContract, runAuthSessionRepositoryContract, @@ -11,6 +14,8 @@ import { runSendOperationRepositoryContract, runMeltOperationRepositoryContract, runMeltQuoteRepositoryContract, + runMintSwapRepositoryContract, + runMintSwapRepositoryConcurrencyContract, createDummyMint, } from '@cashu/coco-adapter-tests'; import { runSqlDatabaseContract } from '@cashu/coco-sql-storage/test'; @@ -132,6 +137,30 @@ async function createRepositories() { } as const; } +async function createRepositoryPair() { + const directory = await mkdtemp(join(tmpdir(), 'coco-mint-swap-expo-')); + const filename = join(directory, 'wallet.sqlite'); + const firstDatabase = new BunExpoSqliteDatabaseShim(filename); + const secondDatabase = new BunExpoSqliteDatabaseShim(filename); + const first = new Repositories({ + database: firstDatabase as unknown as SqliteRepositoriesOptions['database'], + }); + const second = new Repositories({ + database: secondDatabase as unknown as SqliteRepositoriesOptions['database'], + }); + await first.init(); + await second.init(); + return { + first, + second, + dispose: async () => { + await firstDatabase.closeAsync(); + await secondDatabase.closeAsync(); + await rm(directory, { recursive: true, force: true }); + }, + }; +} + runSqlDatabaseContract( { createDatabase() { @@ -177,6 +206,10 @@ runMeltQuoteRepositoryContract({ createRepositories }, { describe, it, expect }) runPaymentRequestReceiveRepositoryContract({ createRepositories }, { describe, it, expect }); +runMintSwapRepositoryContract({ createRepositories }, { describe, it, expect }); + +runMintSwapRepositoryConcurrencyContract({ createRepositoryPair }, { describe, it, expect }); + describe('expo-sqlite web transaction compatibility', () => { it('uses withTransactionAsync when exclusive transactions are unavailable on web', async () => { const windowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window'); diff --git a/packages/sql-storage/src/index.ts b/packages/sql-storage/src/index.ts index 59687581..2759d755 100644 --- a/packages/sql-storage/src/index.ts +++ b/packages/sql-storage/src/index.ts @@ -39,6 +39,8 @@ export { SqliteReceiveOperationRepository, SqlitePaymentRequestReceiveOperationRepository, SqlitePaymentRequestReceiveAttemptRepository, + SqliteMintSwapOperationRepository, + SqliteOperationEventOutboxRepository, } from './repositories.ts'; export type { SqlStorageRepositoriesOptions } from './repositories.ts'; export { ensureSchema, ensureSchemaUpTo, MIGRATIONS } from './schema.ts'; diff --git a/packages/sql-storage/src/repositories.ts b/packages/sql-storage/src/repositories.ts index d022614f..219e38c7 100644 --- a/packages/sql-storage/src/repositories.ts +++ b/packages/sql-storage/src/repositories.ts @@ -17,6 +17,7 @@ import type { ReceiveOperationRepository, PaymentRequestReceiveAttemptRepository, PaymentRequestReceiveOperationRepository, + MintSwapRepositoryCapability, } from '@cashu/coco-core/adapter'; import type { SqlDatabase } from './index.ts'; import { ensureSchema } from './schema.ts'; @@ -38,6 +39,8 @@ import { SqlitePaymentRequestReceiveAttemptRepository, SqlitePaymentRequestReceiveOperationRepository, } from './repositories/PaymentRequestReceiveRepository.ts'; +import { SqliteMintSwapOperationRepository } from './repositories/MintSwapOperationRepository.ts'; +import { SqliteOperationEventOutboxRepository } from './repositories/OperationEventOutboxRepository.ts'; export interface SqlStorageRepositoriesOptions { database: SqlDatabase; @@ -65,6 +68,10 @@ function createRepositoryScope(database: SqlDatabase): RepositoryTransactionScop paymentRequestReceiveAttemptRepository: new SqlitePaymentRequestReceiveAttemptRepository( database, ), + mintSwap: { + mintSwapOperationRepository: new SqliteMintSwapOperationRepository(database), + operationEventOutboxRepository: new SqliteOperationEventOutboxRepository(database), + }, }; } @@ -85,6 +92,7 @@ export class SqlStorageRepositories implements Repositories { readonly receiveOperationRepository: ReceiveOperationRepository; readonly paymentRequestReceiveOperationRepository: PaymentRequestReceiveOperationRepository; readonly paymentRequestReceiveAttemptRepository: PaymentRequestReceiveAttemptRepository; + readonly mintSwap: MintSwapRepositoryCapability; readonly database: SqlDatabase; constructor(options: SqlStorageRepositoriesOptions) { @@ -108,6 +116,7 @@ export class SqlStorageRepositories implements Repositories { repositories.paymentRequestReceiveOperationRepository; this.paymentRequestReceiveAttemptRepository = repositories.paymentRequestReceiveAttemptRepository; + this.mintSwap = repositories.mintSwap!; } async init(): Promise { @@ -136,4 +145,6 @@ export { SqliteReceiveOperationRepository, SqlitePaymentRequestReceiveOperationRepository, SqlitePaymentRequestReceiveAttemptRepository, + SqliteMintSwapOperationRepository, + SqliteOperationEventOutboxRepository, }; diff --git a/packages/sql-storage/src/repositories/MeltOperationRepository.ts b/packages/sql-storage/src/repositories/MeltOperationRepository.ts index e421763d..b8dc6fcd 100644 --- a/packages/sql-storage/src/repositories/MeltOperationRepository.ts +++ b/packages/sql-storage/src/repositories/MeltOperationRepository.ts @@ -1,5 +1,7 @@ import type { MeltMethodInputData, MeltOperationRepository } from '@cashu/coco-core/adapter'; import { + assertParentOwnedMeltOperationInvariant, + assertParentOwnedMeltOperationUpdate, deserializeAmount, normalizeMeltMethodData, normalizeUnit, @@ -44,6 +46,8 @@ interface MeltOperationRow { changeAmount: string | number | null; effectiveFee: string | number | null; finalizedDataJson: string | null; + parentSwapOperationId: string | null; + parentExecutionPhase: 'pre_swap_authorized' | 'melt_authorized' | null; } const preparedStates: MeltOperationState[] = [ @@ -70,6 +74,8 @@ const rowToOperation = (row: MeltOperationRow): MeltOperation => { createdAt: row.createdAt * 1000, updatedAt: row.updatedAt * 1000, error: row.error ?? undefined, + parentSwapOperationId: row.parentSwapOperationId ?? undefined, + parentExecutionPhase: row.parentExecutionPhase ?? undefined, }; if (!isPreparedState(row.state)) { @@ -140,6 +146,8 @@ const operationToParams = (operation: MeltOperation): SqlValue[] => { null, null, null, + operation.parentSwapOperationId ?? null, + operation.parentExecutionPhase ?? null, ]; } @@ -179,6 +187,8 @@ const operationToParams = (operation: MeltOperation): SqlValue[] => { changeAmount, effectiveFee, finalizedDataJson, + operation.parentSwapOperationId ?? null, + operation.parentExecutionPhase ?? null, ]; }; @@ -193,6 +203,7 @@ export class SqliteMeltOperationRepository implements MeltOperationRepository { if (operation.state === 'failed') { throw new Error('Cannot persist failed melt operation'); } + assertParentOwnedMeltOperationInvariant(operation); const exists = await this.db.get<{ id: string }>( 'SELECT id FROM coco_cashu_melt_operations WHERE id = ? LIMIT 1', @@ -207,8 +218,8 @@ export class SqliteMeltOperationRepository implements MeltOperationRepository { const params = operationToParams(operation); await this.db.run( `INSERT INTO coco_cashu_melt_operations - (id, mintUrl, state, createdAt, updatedAt, error, method, methodDataJson, quoteId, unit, amount, fee_reserve, swap_fee, needsSwap, inputAmount, inputProofSecretsJson, changeOutputDataJson, swapOutputDataJson, changeAmount, effectiveFee, finalizedDataJson) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (id, mintUrl, state, createdAt, updatedAt, error, method, methodDataJson, quoteId, unit, amount, fee_reserve, swap_fee, needsSwap, inputAmount, inputProofSecretsJson, changeOutputDataJson, swapOutputDataJson, changeAmount, effectiveFee, finalizedDataJson, parentSwapOperationId, parentExecutionPhase) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, params, ); } @@ -218,13 +229,11 @@ export class SqliteMeltOperationRepository implements MeltOperationRepository { throw new Error('Cannot persist failed melt operation'); } - const exists = await this.db.get<{ id: string }>( - 'SELECT id FROM coco_cashu_melt_operations WHERE id = ? LIMIT 1', - [operation.id], - ); - if (!exists) { + const current = await this.getById(operation.id); + if (!current) { throw new Error(`MeltOperation with id ${operation.id} not found`); } + assertParentOwnedMeltOperationUpdate(current, operation); await this.assertNoDuplicateQuoteOperation(operation); @@ -233,7 +242,7 @@ export class SqliteMeltOperationRepository implements MeltOperationRepository { if (operation.state === 'init') { await this.db.run( `UPDATE coco_cashu_melt_operations - SET state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, quoteId = ?, unit = ? + SET state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, quoteId = ?, unit = ?, parentSwapOperationId = ?, parentExecutionPhase = ? WHERE id = ?`, [ operation.state, @@ -243,6 +252,8 @@ export class SqliteMeltOperationRepository implements MeltOperationRepository { stringifyJson(operation.methodData), operation.quoteId ?? null, operation.unit, + operation.parentSwapOperationId ?? null, + operation.parentExecutionPhase ?? null, operation.id, ], ); @@ -253,7 +264,7 @@ export class SqliteMeltOperationRepository implements MeltOperationRepository { await this.db.run( `UPDATE coco_cashu_melt_operations - SET state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, quoteId = ?, unit = ?, amount = ?, fee_reserve = ?, swap_fee = ?, needsSwap = ?, inputAmount = ?, inputProofSecretsJson = ?, changeOutputDataJson = ?, swapOutputDataJson = ?, changeAmount = ?, effectiveFee = ?, finalizedDataJson = ? + SET state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, quoteId = ?, unit = ?, amount = ?, fee_reserve = ?, swap_fee = ?, needsSwap = ?, inputAmount = ?, inputProofSecretsJson = ?, changeOutputDataJson = ?, swapOutputDataJson = ?, changeAmount = ?, effectiveFee = ?, finalizedDataJson = ?, parentSwapOperationId = ?, parentExecutionPhase = ? WHERE id = ?`, [ operation.state, @@ -280,6 +291,8 @@ export class SqliteMeltOperationRepository implements MeltOperationRepository { operation.state === 'finalized' && settlement.finalizedData !== undefined ? JSON.stringify(settlement.finalizedData) : null, + operation.parentSwapOperationId ?? null, + operation.parentExecutionPhase ?? null, operation.id, ], ); @@ -325,6 +338,10 @@ export class SqliteMeltOperationRepository implements MeltOperationRepository { } async delete(id: string): Promise { + const operation = await this.getById(id); + if (operation?.parentSwapOperationId) { + throw new Error(`Cannot delete parent-owned MeltOperation ${id}`); + } await this.db.run('DELETE FROM coco_cashu_melt_operations WHERE id = ?', [id]); } diff --git a/packages/sql-storage/src/repositories/MintOperationRepository.ts b/packages/sql-storage/src/repositories/MintOperationRepository.ts index e1a37e44..ac5596e2 100644 --- a/packages/sql-storage/src/repositories/MintOperationRepository.ts +++ b/packages/sql-storage/src/repositories/MintOperationRepository.ts @@ -1,5 +1,11 @@ import type { MintOperationRepository } from '@cashu/coco-core/adapter'; -import { deserializeAmount, serializeAmount, stringifyJson } from '@cashu/coco-core/adapter'; +import { + assertParentOwnedMintOperationInvariant, + assertParentOwnedMintOperationUpdate, + deserializeAmount, + serializeAmount, + stringifyJson, +} from '@cashu/coco-core/adapter'; import type { SqlDatabase, SqlValue } from '../index.ts'; import { getUnixTimeSeconds } from '../utils.ts'; @@ -28,6 +34,7 @@ interface MintOperationRow { lastObservedRemoteStateAt: number | null; terminalFailureJson: string | null; outputDataJson: string | null; + parentSwapOperationId: string | null; } const persistedStates = ['pending', 'executing', 'finalized', 'failed'] as const; @@ -60,6 +67,7 @@ const rowToOperation = (row: MintOperationRow): MintOperation => { createdAt: row.createdAt * 1000, updatedAt: row.updatedAt * 1000, error: row.error ?? undefined, + parentSwapOperationId: row.parentSwapOperationId ?? undefined, ...(row.terminalFailureJson ? { terminalFailure: JSON.parse(row.terminalFailureJson) as MintOperationFailure } : {}), @@ -116,6 +124,7 @@ const operationToParams = (operation: MintOperation): SqlValue[] => { null, operation.terminalFailure ? JSON.stringify(operation.terminalFailure) : null, null, + operation.parentSwapOperationId ?? null, ]; } @@ -138,6 +147,7 @@ const operationToParams = (operation: MintOperation): SqlValue[] => { null, operation.terminalFailure ? JSON.stringify(operation.terminalFailure) : null, JSON.stringify(operation.outputData), + operation.parentSwapOperationId ?? null, ]; }; @@ -149,6 +159,7 @@ export class SqliteMintOperationRepository implements MintOperationRepository { } async create(operation: MintOperation): Promise { + assertParentOwnedMintOperationInvariant(operation); const exists = await this.db.get<{ id: string }>( 'SELECT id FROM coco_cashu_mint_operations WHERE id = ? LIMIT 1', [operation.id], @@ -160,27 +171,25 @@ export class SqliteMintOperationRepository implements MintOperationRepository { const params = operationToParams(operation); await this.db.run( `INSERT INTO coco_cashu_mint_operations - (id, mintUrl, quoteId, state, createdAt, updatedAt, error, method, methodDataJson, amount, unit, request, expiry, pubkey, lastObservedRemoteState, lastObservedRemoteStateAt, terminalFailureJson, outputDataJson) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (id, mintUrl, quoteId, state, createdAt, updatedAt, error, method, methodDataJson, amount, unit, request, expiry, pubkey, lastObservedRemoteState, lastObservedRemoteStateAt, terminalFailureJson, outputDataJson, parentSwapOperationId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, params, ); } async update(operation: MintOperation): Promise { - const exists = await this.db.get<{ id: string }>( - 'SELECT id FROM coco_cashu_mint_operations WHERE id = ? LIMIT 1', - [operation.id], - ); - if (!exists) { + const current = await this.getById(operation.id); + if (!current) { throw new Error(`MintOperation with id ${operation.id} not found`); } + assertParentOwnedMintOperationUpdate(current, operation); const updatedAtSeconds = getUnixTimeSeconds(); if (operation.state === 'init') { await this.db.run( `UPDATE coco_cashu_mint_operations - SET quoteId = ?, state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, amount = ?, unit = ?, terminalFailureJson = ? + SET quoteId = ?, state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, amount = ?, unit = ?, terminalFailureJson = ?, parentSwapOperationId = ? WHERE id = ?`, [ operation.quoteId, @@ -192,6 +201,7 @@ export class SqliteMintOperationRepository implements MintOperationRepository { serializeAmount(operation.amount), operation.unit, operation.terminalFailure ? JSON.stringify(operation.terminalFailure) : null, + operation.parentSwapOperationId ?? null, operation.id, ], ); @@ -200,7 +210,7 @@ export class SqliteMintOperationRepository implements MintOperationRepository { await this.db.run( `UPDATE coco_cashu_mint_operations - SET quoteId = ?, state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, amount = ?, unit = ?, request = ?, expiry = ?, pubkey = ?, lastObservedRemoteState = ?, lastObservedRemoteStateAt = ?, terminalFailureJson = ?, outputDataJson = ? + SET quoteId = ?, state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, amount = ?, unit = ?, request = ?, expiry = ?, pubkey = ?, lastObservedRemoteState = ?, lastObservedRemoteStateAt = ?, terminalFailureJson = ?, outputDataJson = ?, parentSwapOperationId = ? WHERE id = ?`, [ operation.quoteId, @@ -218,6 +228,7 @@ export class SqliteMintOperationRepository implements MintOperationRepository { null, operation.terminalFailure ? JSON.stringify(operation.terminalFailure) : null, JSON.stringify(operation.outputData), + operation.parentSwapOperationId ?? null, operation.id, ], ); @@ -265,6 +276,10 @@ export class SqliteMintOperationRepository implements MintOperationRepository { } async delete(id: string): Promise { + const operation = await this.getById(id); + if (operation?.parentSwapOperationId) { + throw new Error(`Cannot delete parent-owned MintOperation ${id}`); + } await this.db.run('DELETE FROM coco_cashu_mint_operations WHERE id = ?', [id]); } } diff --git a/packages/sql-storage/src/repositories/MintSwapOperationRepository.ts b/packages/sql-storage/src/repositories/MintSwapOperationRepository.ts new file mode 100644 index 00000000..0f1c6bd9 --- /dev/null +++ b/packages/sql-storage/src/repositories/MintSwapOperationRepository.ts @@ -0,0 +1,258 @@ +import { Amount } from '@cashu/cashu-ts'; +import { type MintSwapOperation, type MintSwapOperationState } from '@cashu/coco-core/adapter'; +import { + assertMintSwapOperationUpdate, + getMintSwapOperationDueAt, + validateMintSwapOperation, + type MintSwapOperationRepository, +} from '@cashu/coco-core/adapter'; + +import type { SqlDatabase } from '../index.ts'; + +interface MintSwapOperationRow { + id: string; + state: MintSwapOperationState; + revision: number; + sourceMintUrl: string; + destinationMintUrl: string; + destinationMintOperationId: string | null; + sourceMeltOperationId: string | null; + dueAt: number | null; + createdAt: number; + updatedAt: number; + recordJson: string; +} + +const SELECT_COLUMNS = ` + id, state, revision, sourceMintUrl, destinationMintUrl, destinationMintOperationId, + sourceMeltOperationId, dueAt, createdAt, updatedAt, recordJson +`; + +export class SqliteMintSwapOperationRepository implements MintSwapOperationRepository { + constructor(private readonly db: SqlDatabase) {} + + async create(operation: MintSwapOperation): Promise { + validateMintSwapOperation(operation); + if (operation.revision !== 0) { + throw new Error('New mint swap operation must start at revision 0'); + } + const row = toRow(operation); + await this.db.run( + `INSERT INTO coco_cashu_mint_swap_operations ( + id, state, revision, sourceMintUrl, destinationMintUrl, destinationMintOperationId, + sourceMeltOperationId, dueAt, createdAt, updatedAt, recordJson + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + rowParams(row), + ); + } + + async getById(id: string): Promise { + const row = await this.db.get( + `SELECT ${SELECT_COLUMNS} FROM coco_cashu_mint_swap_operations WHERE id = ?`, + [id], + ); + return row ? fromRow(row) : null; + } + + async getByState(state: MintSwapOperationState): Promise { + return this.query( + `SELECT ${SELECT_COLUMNS} FROM coco_cashu_mint_swap_operations + WHERE state = ? ORDER BY createdAt ASC, id ASC`, + [state], + ); + } + + async getActive(): Promise { + return this.query( + `SELECT ${SELECT_COLUMNS} FROM coco_cashu_mint_swap_operations + WHERE state NOT IN ('completed', 'cancelled', 'failed') + ORDER BY createdAt ASC, id ASC`, + ); + } + + async getDue(now: number, limit: number): Promise { + if (!Number.isSafeInteger(now) || now < 0) throw new Error('Due time must be non-negative'); + if (!Number.isSafeInteger(limit) || limit < 0) + throw new Error('Due limit must be non-negative'); + const operations = await this.query( + `SELECT ${SELECT_COLUMNS} FROM coco_cashu_mint_swap_operations + WHERE dueAt IS NOT NULL AND dueAt <= ? + ORDER BY dueAt ASC, createdAt ASC, id ASC + LIMIT ?`, + [now, limit], + ); + return operations; + } + + async getByDestinationMintOperationId(id: string): Promise { + return this.getByChild('destinationMintOperationId', id); + } + + async getBySourceMeltOperationId(id: string): Promise { + return this.getByChild('sourceMeltOperationId', id); + } + + async compareAndSet(operation: MintSwapOperation, expectedRevision: number): Promise { + assertNonNegativeSafeInteger(expectedRevision, 'Expected revision'); + const current = await this.getById(operation.id); + if (!current || current.revision !== expectedRevision) return false; + assertMintSwapOperationUpdate(current, operation); + const row = toRow(operation); + const result = await this.db.run( + `UPDATE coco_cashu_mint_swap_operations SET + state = ?, revision = ?, sourceMintUrl = ?, destinationMintUrl = ?, + destinationMintOperationId = ?, sourceMeltOperationId = ?, dueAt = ?, + createdAt = ?, updatedAt = ?, recordJson = ? + WHERE id = ? AND revision = ?`, + [ + row.state, + row.revision, + row.sourceMintUrl, + row.destinationMintUrl, + row.destinationMintOperationId, + row.sourceMeltOperationId, + row.dueAt, + row.createdAt, + row.updatedAt, + row.recordJson, + row.id, + expectedRevision, + ], + ); + return result.changes === 1; + } + + private async getByChild( + column: 'destinationMintOperationId' | 'sourceMeltOperationId', + id: string, + ): Promise { + const row = await this.db.get( + `SELECT ${SELECT_COLUMNS} FROM coco_cashu_mint_swap_operations WHERE ${column} = ?`, + [id], + ); + return row ? fromRow(row) : null; + } + + private async query(sql: string, params: readonly (string | number)[] = []) { + const rows = await this.db.all(sql, params); + return rows.map(fromRow); + } +} + +function toRow(operation: MintSwapOperation): MintSwapOperationRow { + return { + id: operation.id, + state: operation.state, + revision: operation.revision, + sourceMintUrl: operation.sourceMintUrl, + destinationMintUrl: operation.destinationMintUrl, + destinationMintOperationId: operation.destinationMintOperationId ?? null, + sourceMeltOperationId: operation.sourceMeltOperationId ?? null, + dueAt: getMintSwapOperationDueAt(operation), + createdAt: operation.createdAt, + updatedAt: operation.updatedAt, + recordJson: JSON.stringify(serializeOperation(operation)), + }; +} + +function rowParams(row: MintSwapOperationRow) { + return [ + row.id, + row.state, + row.revision, + row.sourceMintUrl, + row.destinationMintUrl, + row.destinationMintOperationId, + row.sourceMeltOperationId, + row.dueAt, + row.createdAt, + row.updatedAt, + row.recordJson, + ] as const; +} + +function serializeOperation(operation: MintSwapOperation): unknown { + return { + ...operation, + destinationAmount: operation.destinationAmount.toString(), + preparedPlan: operation.preparedPlan + ? mapAmountsToStrings(operation.preparedPlan, [ + 'sourceMeltAmount', + 'sourceFeeReserve', + 'sourcePreparationFee', + 'sourceMeltInputFee', + 'minimumSourceDebit', + 'maximumSourceDebit', + 'reservedSourceAmount', + ]) + : undefined, + settlement: operation.settlement + ? mapAmountsToStrings(operation.settlement, [ + 'sourcePaymentFee', + 'totalSourceFee', + 'sourceMeltChangeAmount', + 'sourceKeepAmount', + 'sourceReturnedAmount', + 'finalSourceDebit', + 'destinationAmountIssued', + ]) + : undefined, + }; +} + +function fromRow(row: MintSwapOperationRow): MintSwapOperation { + const parsed = JSON.parse(row.recordJson) as Record & { + destinationAmount: string; + preparedPlan?: Record; + settlement?: Record; + }; + const operation = { + ...parsed, + destinationAmount: Amount.from(parsed.destinationAmount), + preparedPlan: parsed.preparedPlan + ? mapStringsToAmounts(parsed.preparedPlan, [ + 'sourceMeltAmount', + 'sourceFeeReserve', + 'sourcePreparationFee', + 'sourceMeltInputFee', + 'minimumSourceDebit', + 'maximumSourceDebit', + 'reservedSourceAmount', + ]) + : undefined, + settlement: parsed.settlement + ? mapStringsToAmounts(parsed.settlement, [ + 'sourcePaymentFee', + 'totalSourceFee', + 'sourceMeltChangeAmount', + 'sourceKeepAmount', + 'sourceReturnedAmount', + 'finalSourceDebit', + 'destinationAmountIssued', + ]) + : undefined, + } as MintSwapOperation; + return validateMintSwapOperation(operation); +} + +function mapAmountsToStrings(value: T, keys: readonly string[]): object { + const result = { ...value } as Record; + for (const key of keys) { + if (result[key] !== undefined) result[key] = Amount.from(result[key] as Amount).toString(); + } + return result; +} + +function mapStringsToAmounts(value: Record, keys: readonly string[]): object { + const result = { ...value }; + for (const key of keys) { + if (result[key] !== undefined) result[key] = Amount.from(result[key] as string); + } + return result; +} + +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/sql-storage/src/repositories/OperationEventOutboxRepository.ts b/packages/sql-storage/src/repositories/OperationEventOutboxRepository.ts new file mode 100644 index 00000000..6944dbeb --- /dev/null +++ b/packages/sql-storage/src/repositories/OperationEventOutboxRepository.ts @@ -0,0 +1,124 @@ +import type { + OperationEventOutboxRecord, + OperationEventOutboxRepository, +} from '@cashu/coco-core/adapter'; +import { + isOperationEventPublished, + validateOperationEventOutboxRecord, +} from '@cashu/coco-core/adapter'; + +import type { SqlDatabase } from '../index.ts'; + +interface OutboxRow { + id: string; + operationId: string; + revision: number; + eventType: OperationEventOutboxRecord['eventType']; + payloadJson: string; + createdAt: number; + publishedAt: number | null; + publishAttempts: number; + nextAttemptAt: number | null; + lastError: string | null; +} + +const SELECT_COLUMNS = ` + id, operationId, revision, eventType, payloadJson, createdAt, publishedAt, + publishAttempts, nextAttemptAt, lastError +`; + +export class SqliteOperationEventOutboxRepository implements OperationEventOutboxRepository { + constructor(private readonly db: SqlDatabase) {} + + async enqueue(event: OperationEventOutboxRecord): Promise { + validateOperationEventOutboxRecord(event); + await this.db.run( + `INSERT INTO coco_cashu_operation_event_outbox ( + id, operationId, revision, eventType, payloadJson, createdAt, publishedAt, + publishAttempts, nextAttemptAt, lastError + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + event.id, + event.operationId, + event.revision, + event.eventType, + JSON.stringify(event.payload), + event.createdAt, + event.publishedAt ?? null, + event.publishAttempts, + event.nextAttemptAt ?? null, + event.lastError ?? null, + ], + ); + } + + async getById(id: string): Promise { + const row = await this.db.get( + `SELECT ${SELECT_COLUMNS} FROM coco_cashu_operation_event_outbox WHERE id = ?`, + [id], + ); + return row ? fromRow(row) : null; + } + + async getUnpublished(limit: number, now = Date.now()): Promise { + assertNonNegativeSafeInteger(limit, 'Outbox limit'); + assertNonNegativeSafeInteger(now, 'Outbox due time'); + const rows = await this.db.all( + `SELECT ${SELECT_COLUMNS} FROM coco_cashu_operation_event_outbox + WHERE publishedAt IS NULL AND COALESCE(nextAttemptAt, 0) <= ? + ORDER BY COALESCE(nextAttemptAt, 0) ASC, createdAt ASC, id ASC LIMIT ?`, + [now, limit], + ); + return rows.map(fromRow); + } + + async markPublished(id: string, publishedAt: number): Promise { + const event = await this.requireEvent(id); + if (isOperationEventPublished(event)) return; + await this.db.run( + `UPDATE coco_cashu_operation_event_outbox + SET publishedAt = ?, publishAttempts = publishAttempts + 1, + nextAttemptAt = NULL, lastError = NULL + WHERE id = ? AND publishedAt IS NULL`, + [publishedAt, id], + ); + } + + async recordPublishFailure(id: string, nextAttemptAt: number, lastError: string): Promise { + const event = await this.requireEvent(id); + if (isOperationEventPublished(event)) return; + await this.db.run( + `UPDATE coco_cashu_operation_event_outbox + SET publishAttempts = publishAttempts + 1, nextAttemptAt = ?, lastError = ? + WHERE id = ? AND publishedAt IS NULL`, + [nextAttemptAt, lastError, id], + ); + } + + private async requireEvent(id: string): Promise { + const event = await this.getById(id); + if (!event) throw new Error(`Operation event outbox record with id ${id} not found`); + return event; + } +} + +function fromRow(row: OutboxRow): OperationEventOutboxRecord { + return validateOperationEventOutboxRecord({ + id: row.id, + operationId: row.operationId, + revision: row.revision, + eventType: row.eventType, + payload: JSON.parse(row.payloadJson), + createdAt: row.createdAt, + publishedAt: row.publishedAt ?? undefined, + publishAttempts: row.publishAttempts, + nextAttemptAt: row.nextAttemptAt ?? undefined, + lastError: row.lastError ?? undefined, + }); +} + +function 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/sql-storage/src/schema.ts b/packages/sql-storage/src/schema.ts index 510cf64c..fecb90e2 100644 --- a/packages/sql-storage/src/schema.ts +++ b/packages/sql-storage/src/schema.ts @@ -1490,6 +1490,65 @@ const MIGRATIONS: readonly Migration[] = [ END; `, }, + { + id: '038_mint_swap_operations_and_outbox', + sql: ` + CREATE TABLE IF NOT EXISTS coco_cashu_mint_swap_operations ( + id TEXT PRIMARY KEY, + state TEXT NOT NULL CHECK (state IN ( + 'preparing', 'prepared', 'source_inflight', 'destination_funded', 'issuing', + 'completed', 'cancelled', 'failed', 'needs_attention' + )), + revision INTEGER NOT NULL CHECK (revision >= 0), + sourceMintUrl TEXT NOT NULL, + destinationMintUrl TEXT NOT NULL, + destinationMintOperationId TEXT UNIQUE, + sourceMeltOperationId TEXT UNIQUE, + dueAt INTEGER, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + recordJson TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_swap_operations_state + ON coco_cashu_mint_swap_operations(state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_swap_operations_due + ON coco_cashu_mint_swap_operations(dueAt, createdAt, id) + WHERE dueAt IS NOT NULL; + + CREATE TABLE IF NOT EXISTS coco_cashu_operation_event_outbox ( + id TEXT PRIMARY KEY, + operationId TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision >= 0), + eventType TEXT NOT NULL, + payloadJson TEXT NOT NULL, + createdAt INTEGER NOT NULL, + publishedAt INTEGER, + publishAttempts INTEGER NOT NULL DEFAULT 0 CHECK (publishAttempts >= 0), + nextAttemptAt INTEGER, + lastError TEXT, + UNIQUE (operationId, revision, eventType) + ); + + CREATE INDEX IF NOT EXISTS idx_coco_cashu_operation_event_outbox_unpublished + ON coco_cashu_operation_event_outbox(publishedAt, nextAttemptAt, createdAt, id); + `, + }, + { + id: '039_mint_swap_child_ownership', + sql: ` + ALTER TABLE coco_cashu_mint_operations ADD COLUMN parentSwapOperationId TEXT; + ALTER TABLE coco_cashu_melt_operations ADD COLUMN parentSwapOperationId TEXT; + ALTER TABLE coco_cashu_melt_operations ADD COLUMN parentExecutionPhase TEXT; + + CREATE UNIQUE INDEX IF NOT EXISTS ux_coco_cashu_mint_operations_parent_swap + ON coco_cashu_mint_operations(parentSwapOperationId) + WHERE parentSwapOperationId IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS ux_coco_cashu_melt_operations_parent_swap + ON coco_cashu_melt_operations(parentSwapOperationId) + WHERE parentSwapOperationId IS NOT NULL; + `, + }, ]; // Export for testing diff --git a/packages/sql-storage/src/test/schema.test.ts b/packages/sql-storage/src/test/schema.test.ts index 944c3bee..dbf2caf1 100644 --- a/packages/sql-storage/src/test/schema.test.ts +++ b/packages/sql-storage/src/test/schema.test.ts @@ -49,6 +49,8 @@ const EXPECTED_MIGRATION_IDS = [ '035_duplicate_quote_ids', '036_quote_identity_unique_indexes', '037_mint_quote_accounting', + '038_mint_swap_operations_and_outbox', + '039_mint_swap_child_ownership', ] as const; const RECEIVE_OPERATIONS_SQL = ` @@ -188,6 +190,68 @@ describe('shared SQL schema migrations', () => { ); }); + itWithDatabase( + 'adds dormant mint-swap storage without changing standalone children', + async (db) => { + await ensureSchemaUpTo(db, '038_mint_swap_operations_and_outbox'); + await db.run( + `INSERT INTO coco_cashu_mint_operations + (id, mintUrl, quoteId, state, createdAt, updatedAt, method, methodDataJson, amount, unit) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + 'legacy-mint-child', + 'https://mint.test', + 'mint-quote', + 'init', + 1, + 1, + 'bolt11', + '{}', + '1', + 'sat', + ], + ); + await insertMeltOperationRow(db, 'legacy-melt-child', 'melt-quote'); + + await ensureSchemaUpTo(db); + + expect(await getColumnNames(db, 'coco_cashu_mint_swap_operations')).toContain('dueAt'); + expect(await getColumnNames(db, 'coco_cashu_operation_event_outbox')).toContain( + 'payloadJson', + ); + expect(await getColumnNames(db, 'coco_cashu_mint_operations')).toContain( + 'parentSwapOperationId', + ); + expect(await getColumnNames(db, 'coco_cashu_melt_operations')).toContain( + 'parentExecutionPhase', + ); + expect(await getIndexNames(db, 'coco_cashu_mint_swap_operations')).toContain( + 'idx_coco_cashu_mint_swap_operations_due', + ); + expect(await getIndexNames(db, 'coco_cashu_mint_operations')).toContain( + 'ux_coco_cashu_mint_operations_parent_swap', + ); + expect(await getIndexNames(db, 'coco_cashu_melt_operations')).toContain( + 'ux_coco_cashu_melt_operations_parent_swap', + ); + + const mintChild = await db.get<{ parentSwapOperationId: string | null }>( + 'SELECT parentSwapOperationId FROM coco_cashu_mint_operations WHERE id = ?', + ['legacy-mint-child'], + ); + const meltChild = await db.get<{ + parentSwapOperationId: string | null; + parentExecutionPhase: string | null; + }>( + `SELECT parentSwapOperationId, parentExecutionPhase + FROM coco_cashu_melt_operations WHERE id = ?`, + ['legacy-melt-child'], + ); + expect(mintChild).toEqual({ parentSwapOperationId: null }); + expect(meltChild).toEqual({ parentSwapOperationId: null, parentExecutionPhase: null }); + }, + ); + itWithDatabase( 'backfills canonical Mint Quote Accounting without inventing remote time', async (db) => { diff --git a/packages/sqlite-bun/package.json b/packages/sqlite-bun/package.json index 50c88542..c79c946a 100644 --- a/packages/sqlite-bun/package.json +++ b/packages/sqlite-bun/package.json @@ -16,7 +16,8 @@ ], "devDependencies": { "@cashu/coco-adapter-tests": "1.0.0", - "@cashu/coco-sql-storage": "1.0.0" + "@cashu/coco-sql-storage": "1.0.0", + "@types/node": "^25.0.9" }, "exports": { ".": { diff --git a/packages/sqlite-bun/src/index.ts b/packages/sqlite-bun/src/index.ts index 90485ebb..173d3b37 100644 --- a/packages/sqlite-bun/src/index.ts +++ b/packages/sqlite-bun/src/index.ts @@ -24,6 +24,7 @@ export class SqliteRepositories implements Repositories { readonly receiveOperationRepository: Repositories['receiveOperationRepository']; readonly paymentRequestReceiveOperationRepository: Repositories['paymentRequestReceiveOperationRepository']; readonly paymentRequestReceiveAttemptRepository: Repositories['paymentRequestReceiveAttemptRepository']; + readonly mintSwap: NonNullable; private readonly db: SqliteDb; private readonly repositories: SqlStorageRepositories; @@ -49,6 +50,7 @@ export class SqliteRepositories implements Repositories { this.repositories.paymentRequestReceiveOperationRepository; this.paymentRequestReceiveAttemptRepository = this.repositories.paymentRequestReceiveAttemptRepository; + this.mintSwap = this.repositories.mintSwap; } async init(): Promise { diff --git a/packages/sqlite-bun/src/test/contract.test.ts b/packages/sqlite-bun/src/test/contract.test.ts index bfa168e4..b8044f74 100644 --- a/packages/sqlite-bun/src/test/contract.test.ts +++ b/packages/sqlite-bun/src/test/contract.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from 'bun:test'; import { Database } from 'bun:sqlite'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { runRepositoryTransactionContract, runAuthSessionRepositoryContract, @@ -11,6 +14,8 @@ import { runSendOperationRepositoryContract, runMeltOperationRepositoryContract, runMeltQuoteRepositoryContract, + runMintSwapRepositoryContract, + runMintSwapRepositoryConcurrencyContract, } from '@cashu/coco-adapter-tests'; import { runSqlDatabaseContract } from '@cashu/coco-sql-storage/test'; import { SqliteRepositories as Repositories } from '../index.ts'; @@ -28,6 +33,26 @@ async function createRepositories() { }; } +async function createRepositoryPair() { + const directory = await mkdtemp(join(tmpdir(), 'coco-mint-swap-bun-')); + const filename = join(directory, 'wallet.sqlite'); + const firstDatabase = new Database(filename); + const secondDatabase = new Database(filename); + const first = new Repositories({ database: firstDatabase }); + const second = new Repositories({ database: secondDatabase }); + await first.init(); + await second.init(); + return { + first, + second, + dispose: async () => { + firstDatabase.close(); + secondDatabase.close(); + await rm(directory, { recursive: true, force: true }); + }, + }; +} + runSqlDatabaseContract( { createDatabase() { @@ -69,6 +94,10 @@ runMeltQuoteRepositoryContract({ createRepositories }, { describe, it, expect }) runPaymentRequestReceiveRepositoryContract({ createRepositories }, { describe, it, expect }); +runMintSwapRepositoryContract({ createRepositories }, { describe, it, expect }); + +runMintSwapRepositoryConcurrencyContract({ createRepositoryPair }, { describe, it, expect }); + describe('hydration corruption guard', () => { it('throws when send operation has prepared state but null financial fields', async () => { const { repositories, dispose } = await createRepositories(); diff --git a/packages/sqlite-bun/tsconfig.json b/packages/sqlite-bun/tsconfig.json index 63b10b35..a0186476 100644 --- a/packages/sqlite-bun/tsconfig.json +++ b/packages/sqlite-bun/tsconfig.json @@ -13,7 +13,7 @@ "preserveSymlinks": true, "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, - "types": ["bun"], + "types": ["bun", "node"], "noEmit": true, // Best practices diff --git a/packages/sqlite3/package.json b/packages/sqlite3/package.json index bb96f6f7..58af70bc 100644 --- a/packages/sqlite3/package.json +++ b/packages/sqlite3/package.json @@ -17,6 +17,7 @@ "devDependencies": { "@cashu/coco-adapter-tests": "1.0.0", "@cashu/coco-sql-storage": "1.0.0", + "@types/node": "^25.0.9", "vitest": "^2.1.9" }, "exports": { diff --git a/packages/sqlite3/src/index.ts b/packages/sqlite3/src/index.ts index 5e4f127d..ae0fd251 100644 --- a/packages/sqlite3/src/index.ts +++ b/packages/sqlite3/src/index.ts @@ -24,6 +24,7 @@ export class SqliteRepositories implements Repositories { readonly receiveOperationRepository: Repositories['receiveOperationRepository']; readonly paymentRequestReceiveOperationRepository: Repositories['paymentRequestReceiveOperationRepository']; readonly paymentRequestReceiveAttemptRepository: Repositories['paymentRequestReceiveAttemptRepository']; + readonly mintSwap: NonNullable; private readonly db: SqliteDb; private readonly repositories: SqlStorageRepositories; @@ -49,6 +50,7 @@ export class SqliteRepositories implements Repositories { this.repositories.paymentRequestReceiveOperationRepository; this.paymentRequestReceiveAttemptRepository = this.repositories.paymentRequestReceiveAttemptRepository; + this.mintSwap = this.repositories.mintSwap; } async init(): Promise { diff --git a/packages/sqlite3/src/test/contract.test.ts b/packages/sqlite3/src/test/contract.test.ts index 8d5f69a6..505bc300 100644 --- a/packages/sqlite3/src/test/contract.test.ts +++ b/packages/sqlite3/src/test/contract.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from 'vitest'; import Database from 'better-sqlite3'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { runRepositoryTransactionContract, runAuthSessionRepositoryContract, @@ -11,6 +14,8 @@ import { runSendOperationRepositoryContract, runMeltOperationRepositoryContract, runMeltQuoteRepositoryContract, + runMintSwapRepositoryContract, + runMintSwapRepositoryConcurrencyContract, } from '@cashu/coco-adapter-tests'; import { runSqlDatabaseContract } from '@cashu/coco-sql-storage/test'; import { SqliteRepositories as Repositories } from '../index.ts'; @@ -28,6 +33,26 @@ async function createRepositories() { }; } +async function createRepositoryPair() { + const directory = await mkdtemp(join(tmpdir(), 'coco-mint-swap-sqlite3-')); + const filename = join(directory, 'wallet.sqlite'); + const firstDatabase = new Database(filename); + const secondDatabase = new Database(filename); + const first = new Repositories({ database: firstDatabase }); + const second = new Repositories({ database: secondDatabase }); + await first.init(); + await second.init(); + return { + first, + second, + dispose: async () => { + firstDatabase.close(); + secondDatabase.close(); + await rm(directory, { recursive: true, force: true }); + }, + }; +} + runSqlDatabaseContract( { createDatabase() { @@ -69,6 +94,10 @@ runMeltQuoteRepositoryContract({ createRepositories }, { describe, it, expect }) runPaymentRequestReceiveRepositoryContract({ createRepositories }, { describe, it, expect }); +runMintSwapRepositoryContract({ createRepositories }, { describe, it, expect }); + +runMintSwapRepositoryConcurrencyContract({ createRepositoryPair }, { describe, it, expect }); + describe('hydration corruption guard', () => { it('throws when send operation has prepared state but null financial fields', async () => { const { repositories, dispose } = await createRepositories(); From 20f12d2eadd045d9216a0ac3b9147ea79690b1c0 Mon Sep 17 00:00:00 2001 From: IgboPharaoh Date: Sun, 9 Aug 2026 18:34:13 +0100 Subject: [PATCH 13/15] feat(indexeddb): persist mint swap capability --- packages/indexeddb/src/index.ts | 15 ++ packages/indexeddb/src/lib/db.ts | 41 ++++ packages/indexeddb/src/lib/schema.ts | 59 ++++++ .../repositories/MeltOperationRepository.ts | 14 ++ .../repositories/MintOperationRepository.ts | 17 +- .../MintSwapOperationRepository.ts | 196 ++++++++++++++++++ .../OperationEventOutboxRepository.ts | 121 +++++++++++ packages/indexeddb/src/test/contract.test.ts | 23 ++ .../src/test/mintSwapMigration.test.ts | 71 +++++++ 9 files changed, 556 insertions(+), 1 deletion(-) create mode 100644 packages/indexeddb/src/repositories/MintSwapOperationRepository.ts create mode 100644 packages/indexeddb/src/repositories/OperationEventOutboxRepository.ts create mode 100644 packages/indexeddb/src/test/mintSwapMigration.test.ts diff --git a/packages/indexeddb/src/index.ts b/packages/indexeddb/src/index.ts index 9feaa348..0f155d42 100644 --- a/packages/indexeddb/src/index.ts +++ b/packages/indexeddb/src/index.ts @@ -16,6 +16,7 @@ import type { PaymentRequestReceiveOperationRepository, ReceiveOperationRepository, RepositoryTransactionScope, + MintSwapRepositoryCapability, } from '@cashu/coco-core/adapter'; import { IdbDb, type IdbDbOptions } from './lib/db.ts'; import { ensureSchema } from './lib/schema.ts'; @@ -37,6 +38,8 @@ import { IdbPaymentRequestReceiveAttemptRepository, IdbPaymentRequestReceiveOperationRepository, } from './repositories/PaymentRequestReceiveRepository.ts'; +import { IdbMintSwapOperationRepository } from './repositories/MintSwapOperationRepository.ts'; +import { IdbOperationEventOutboxRepository } from './repositories/OperationEventOutboxRepository.ts'; export interface IndexedDbRepositoriesOptions extends IdbDbOptions {} @@ -57,6 +60,7 @@ export class IndexedDbRepositories implements Repositories { readonly receiveOperationRepository: ReceiveOperationRepository; readonly paymentRequestReceiveOperationRepository: PaymentRequestReceiveOperationRepository; readonly paymentRequestReceiveAttemptRepository: PaymentRequestReceiveAttemptRepository; + readonly mintSwap: MintSwapRepositoryCapability; readonly db: IdbDb; private initialized = false; @@ -82,6 +86,7 @@ export class IndexedDbRepositories implements Repositories { this.paymentRequestReceiveAttemptRepository = new IdbPaymentRequestReceiveAttemptRepository( this.db, ); + this.mintSwap = createMintSwapCapability(this.db); } async init(): Promise { @@ -119,6 +124,7 @@ export class IndexedDbRepositories implements Repositories { paymentRequestReceiveAttemptRepository: new IdbPaymentRequestReceiveAttemptRepository( scopedDb, ), + mintSwap: createMintSwapCapability(scopedDb), }; return fn(scopedRepositories); }); @@ -144,4 +150,13 @@ export { IdbReceiveOperationRepository, IdbPaymentRequestReceiveOperationRepository, IdbPaymentRequestReceiveAttemptRepository, + IdbMintSwapOperationRepository, + IdbOperationEventOutboxRepository, }; + +function createMintSwapCapability(db: IdbDb): MintSwapRepositoryCapability { + return { + mintSwapOperationRepository: new IdbMintSwapOperationRepository(db), + operationEventOutboxRepository: new IdbOperationEventOutboxRepository(db), + }; +} diff --git a/packages/indexeddb/src/lib/db.ts b/packages/indexeddb/src/lib/db.ts index 228396f2..a84826d5 100644 --- a/packages/indexeddb/src/lib/db.ts +++ b/packages/indexeddb/src/lib/db.ts @@ -298,6 +298,8 @@ export interface MeltOperationRow { changeAmount?: string | number | null; effectiveFee?: string | number | null; finalizedDataJson?: string | null; + parentSwapOperationId?: string; + parentExecutionPhase?: 'pre_swap_authorized' | 'melt_authorized'; } export interface AuthSessionRow { @@ -328,4 +330,43 @@ export interface MintOperationRow { lastObservedRemoteStateAt?: number | null; terminalFailureJson?: string | null; outputDataJson?: string | null; + parentSwapOperationId?: string; +} + +export interface MintSwapOperationRow { + id: string; + state: + | 'preparing' + | 'prepared' + | 'source_inflight' + | 'destination_funded' + | 'issuing' + | 'completed' + | 'cancelled' + | 'failed' + | 'needs_attention'; + revision: number; + sourceMintUrl: string; + destinationMintUrl: string; + destinationMintOperationId?: string; + sourceMeltOperationId?: string; + dueAt?: number; + createdAt: number; + updatedAt: number; + recordJson: string; +} + +export interface OperationEventOutboxRow { + id: string; + operationId: string; + revision: number; + eventType: string; + payloadJson: string; + createdAt: number; + publishedAt?: number; + publishAttempts: number; + nextAttemptAt?: number; + lastError?: string; + publicationState: 'pending' | 'published'; + dueAt: number; } diff --git a/packages/indexeddb/src/lib/schema.ts b/packages/indexeddb/src/lib/schema.ts index dac1997e..47d47289 100644 --- a/packages/indexeddb/src/lib/schema.ts +++ b/packages/indexeddb/src/lib/schema.ts @@ -1147,4 +1147,63 @@ export async function ensureSchema(db: IdbDb): Promise { }, ); }); + + // Version 33: Add recoverable mint-swap parents and the durable operation event outbox. + db.version(33).stores({ + coco_cashu_mints: '&mintUrl, name, updatedAt, trusted', + coco_cashu_keysets: '&[mintUrl+id], mintUrl, id, updatedAt, unit', + coco_cashu_counters: '&[mintUrl+keysetId]', + coco_cashu_proofs: + '&[mintUrl+secret], [mintUrl+state], [mintUrl+unit+state], [mintUrl+id+state], [mintUrl+id+unit+state], [mintUrl+unit+id+state], [unit+state], state, mintUrl, unit, id, usedByOperationId, createdByOperationId', + coco_cashu_mint_quotes: '&[mintUrl+quote], state, mintUrl', + coco_cashu_canonical_mint_quotes: + '&[mintUrl+method+quoteId], &[mintUrl+quoteId], state, mintUrl, method', + coco_cashu_melt_quotes: '&[mintUrl+method+quoteId], &[mintUrl+quoteId], state, mintUrl, method', + coco_cashu_history: + '++id, mintUrl, type, createdAt, [mintUrl+quoteId+type], [mintUrl+operationId]', + coco_cashu_keypairs: '&publicKey, createdAt, derivationIndex', + coco_cashu_send_operations: '&id, state, mintUrl, createdAt', + coco_cashu_melt_operations: '&id, state, mintUrl, createdAt, [mintUrl+quoteId]', + coco_cashu_receive_operations: '&id, state, mintUrl, createdAt', + coco_cashu_auth_sessions: '&mintUrl', + coco_cashu_mint_operations: + '&id, state, mintUrl, createdAt, [mintUrl+quoteId], [mintUrl+method+quoteId]', + coco_cashu_payment_request_receive_operations: '&id, state, requestId', + coco_cashu_payment_request_receive_attempts: + '&id, requestOperationId, requestId, state, &[requestOperationId+payloadHash], [requestId+payloadHash], &transportMessageId, &receiveOperationId', + coco_cashu_mint_swap_operations: + '&id, state, revision, &destinationMintOperationId, &sourceMeltOperationId, dueAt, [dueAt+createdAt+id], createdAt', + coco_cashu_operation_event_outbox: + '&id, &[operationId+revision+eventType], publicationState, dueAt, [publicationState+dueAt+createdAt+id], createdAt', + }); + + // Version 34: Index durable ownership of mint-swap child operations. + db.version(34).stores({ + coco_cashu_mints: '&mintUrl, name, updatedAt, trusted', + coco_cashu_keysets: '&[mintUrl+id], mintUrl, id, updatedAt, unit', + coco_cashu_counters: '&[mintUrl+keysetId]', + coco_cashu_proofs: + '&[mintUrl+secret], [mintUrl+state], [mintUrl+unit+state], [mintUrl+id+state], [mintUrl+id+unit+state], [mintUrl+unit+id+state], [unit+state], state, mintUrl, unit, id, usedByOperationId, createdByOperationId', + coco_cashu_mint_quotes: '&[mintUrl+quote], state, mintUrl', + coco_cashu_canonical_mint_quotes: + '&[mintUrl+method+quoteId], &[mintUrl+quoteId], state, mintUrl, method', + coco_cashu_melt_quotes: '&[mintUrl+method+quoteId], &[mintUrl+quoteId], state, mintUrl, method', + coco_cashu_history: + '++id, mintUrl, type, createdAt, [mintUrl+quoteId+type], [mintUrl+operationId]', + coco_cashu_keypairs: '&publicKey, createdAt, derivationIndex', + coco_cashu_send_operations: '&id, state, mintUrl, createdAt', + coco_cashu_melt_operations: + '&id, state, mintUrl, createdAt, &parentSwapOperationId, [mintUrl+quoteId]', + coco_cashu_receive_operations: '&id, state, mintUrl, createdAt', + coco_cashu_auth_sessions: '&mintUrl', + coco_cashu_mint_operations: + '&id, state, mintUrl, createdAt, &parentSwapOperationId, [mintUrl+quoteId], [mintUrl+method+quoteId]', + coco_cashu_payment_request_receive_operations: '&id, state, requestId', + coco_cashu_payment_request_receive_attempts: + '&id, requestOperationId, requestId, state, &[requestOperationId+payloadHash], [requestId+payloadHash], &transportMessageId, &receiveOperationId', + coco_cashu_mint_swap_operations: + '&id, state, revision, &destinationMintOperationId, &sourceMeltOperationId, dueAt, [dueAt+createdAt+id], createdAt', + coco_cashu_operation_event_outbox: + '&id, &[operationId+revision+eventType], publicationState, dueAt, [publicationState+dueAt+createdAt+id], createdAt', + }); } diff --git a/packages/indexeddb/src/repositories/MeltOperationRepository.ts b/packages/indexeddb/src/repositories/MeltOperationRepository.ts index bf7056bd..bcafdb7b 100644 --- a/packages/indexeddb/src/repositories/MeltOperationRepository.ts +++ b/packages/indexeddb/src/repositories/MeltOperationRepository.ts @@ -1,5 +1,7 @@ import type { MeltMethodInputData, MeltOperationRepository } from '@cashu/coco-core/adapter'; import { + assertParentOwnedMeltOperationInvariant, + assertParentOwnedMeltOperationUpdate, deserializeAmount, normalizeMeltMethodData, normalizeUnit, @@ -46,6 +48,8 @@ const rowToOperation = (row: MeltOperationRow): MeltOperation => { createdAt: row.createdAt * 1000, updatedAt: row.updatedAt * 1000, error: row.error ?? undefined, + parentSwapOperationId: row.parentSwapOperationId, + parentExecutionPhase: row.parentExecutionPhase, }; if (!isPreparedState(row.state)) { @@ -124,6 +128,8 @@ const operationToRow = (operation: MeltOperation): MeltOperationRow => { changeOutputDataJson: null, swapOutputDataJson: null, finalizedDataJson: null, + parentSwapOperationId: operation.parentSwapOperationId, + parentExecutionPhase: operation.parentExecutionPhase, }; } @@ -160,6 +166,8 @@ const operationToRow = (operation: MeltOperation): MeltOperationRow => { operation.state === 'finalized' && settlement.finalizedData !== undefined ? JSON.stringify(settlement.finalizedData) : null, + parentSwapOperationId: operation.parentSwapOperationId, + parentExecutionPhase: operation.parentExecutionPhase, }; }; @@ -171,6 +179,7 @@ export class IdbMeltOperationRepository implements MeltOperationRepository { } async create(operation: MeltOperation): Promise { + assertParentOwnedMeltOperationInvariant(operation); await this.db.runTransaction('rw', ['coco_cashu_melt_operations'], async (tx) => { const table = tx.table('coco_cashu_melt_operations'); const existing = await table.get(operation.id); @@ -202,6 +211,7 @@ export class IdbMeltOperationRepository implements MeltOperationRepository { if (!existing) { throw new Error(`MeltOperation with id ${operation.id} not found`); } + assertParentOwnedMeltOperationUpdate(rowToOperation(existing), operation); const quoteId = getOperationQuoteId(operation); if (quoteId) { @@ -268,6 +278,10 @@ export class IdbMeltOperationRepository implements MeltOperationRepository { async delete(id: string): Promise { await this.db.runTransaction('rw', ['coco_cashu_melt_operations'], async (tx) => { const table = tx.table('coco_cashu_melt_operations'); + const existing = (await table.get(id)) as MeltOperationRow | undefined; + if (existing?.parentSwapOperationId) { + throw new Error(`Cannot delete parent-owned MeltOperation ${id}`); + } await table.delete(id); }); } diff --git a/packages/indexeddb/src/repositories/MintOperationRepository.ts b/packages/indexeddb/src/repositories/MintOperationRepository.ts index 33090066..eaa91d32 100644 --- a/packages/indexeddb/src/repositories/MintOperationRepository.ts +++ b/packages/indexeddb/src/repositories/MintOperationRepository.ts @@ -1,5 +1,11 @@ import type { MintOperationRepository } from '@cashu/coco-core/adapter'; -import { deserializeAmount, serializeAmount, stringifyJson } from '@cashu/coco-core/adapter'; +import { + assertParentOwnedMintOperationInvariant, + assertParentOwnedMintOperationUpdate, + deserializeAmount, + serializeAmount, + stringifyJson, +} from '@cashu/coco-core/adapter'; import type { IdbDb, MintOperationRow } from '../lib/db.ts'; import { getUnixTimeSeconds } from '../lib/db.ts'; @@ -38,6 +44,7 @@ const rowToOperation = (row: MintOperationRow): MintOperation => { createdAt: row.createdAt * 1000, updatedAt: row.updatedAt * 1000, error: row.error ?? undefined, + parentSwapOperationId: row.parentSwapOperationId, ...(row.terminalFailureJson ? { terminalFailure: JSON.parse(row.terminalFailureJson) as MintOperationFailure } : {}), @@ -91,6 +98,7 @@ const operationToRow = (operation: MintOperation): MintOperationRow => { ? JSON.stringify(operation.terminalFailure) : null, outputDataJson: null, + parentSwapOperationId: operation.parentSwapOperationId, }; } @@ -115,6 +123,7 @@ const operationToRow = (operation: MintOperation): MintOperationRow => { ? JSON.stringify(operation.terminalFailure) : null, outputDataJson: JSON.stringify(operation.outputData), + parentSwapOperationId: operation.parentSwapOperationId, }; }; @@ -126,6 +135,7 @@ export class IdbMintOperationRepository implements MintOperationRepository { } async create(operation: MintOperation): Promise { + assertParentOwnedMintOperationInvariant(operation); await this.db.runTransaction('rw', ['coco_cashu_mint_operations'], async (tx) => { const table = tx.table('coco_cashu_mint_operations'); const existing = await table.get(operation.id); @@ -143,6 +153,7 @@ export class IdbMintOperationRepository implements MintOperationRepository { if (!existing) { throw new Error(`MintOperation with id ${operation.id} not found`); } + assertParentOwnedMintOperationUpdate(rowToOperation(existing), operation); const row = operationToRow(operation); row.updatedAt = getUnixTimeSeconds(); @@ -198,6 +209,10 @@ export class IdbMintOperationRepository implements MintOperationRepository { async delete(id: string): Promise { await this.db.runTransaction('rw', ['coco_cashu_mint_operations'], async (tx) => { const table = tx.table('coco_cashu_mint_operations'); + const existing = (await table.get(id)) as MintOperationRow | undefined; + if (existing?.parentSwapOperationId) { + throw new Error(`Cannot delete parent-owned MintOperation ${id}`); + } await table.delete(id); }); } diff --git a/packages/indexeddb/src/repositories/MintSwapOperationRepository.ts b/packages/indexeddb/src/repositories/MintSwapOperationRepository.ts new file mode 100644 index 00000000..baf36bf7 --- /dev/null +++ b/packages/indexeddb/src/repositories/MintSwapOperationRepository.ts @@ -0,0 +1,196 @@ +import { Amount } from '@cashu/cashu-ts'; +import Dexie from 'dexie'; +import { + assertMintSwapOperationUpdate, + getMintSwapOperationDueAt, + isTerminalMintSwapState, + validateMintSwapOperation, + type MintSwapOperation, + type MintSwapOperationState, + type MintSwapOperationRepository, +} from '@cashu/coco-core/adapter'; + +import { IdbDb, type MintSwapOperationRow } from '../lib/db.ts'; + +const STORE = 'coco_cashu_mint_swap_operations'; + +export class IdbMintSwapOperationRepository implements MintSwapOperationRepository { + constructor(private readonly db: IdbDb) {} + + async create(operation: MintSwapOperation): Promise { + validateMintSwapOperation(operation); + if (operation.revision !== 0) { + throw new Error('New mint swap operation must start at revision 0'); + } + await this.table().add(toRow(operation)); + } + + async getById(id: string): Promise { + const row = await this.table().get(id); + return row ? fromRow(row) : null; + } + + async getByState(state: MintSwapOperationState): Promise { + const rows = await this.table().where('state').equals(state).toArray(); + return sortRows(rows).map(fromRow); + } + + async getActive(): Promise { + const rows = await this.table().toArray(); + return sortRows(rows) + .map(fromRow) + .filter((operation) => !isTerminalMintSwapState(operation.state)); + } + + async getDue(now: number, limit: number): Promise { + if (!Number.isSafeInteger(now) || now < 0) throw new Error('Due time must be non-negative'); + if (!Number.isSafeInteger(limit) || limit < 0) + throw new Error('Due limit must be non-negative'); + if (limit === 0) return []; + const rows = await this.table() + .where('[dueAt+createdAt+id]') + .between([Dexie.minKey, Dexie.minKey, Dexie.minKey], [now, Dexie.maxKey, Dexie.maxKey]) + .limit(limit) + .toArray(); + return rows.map(fromRow); + } + + async getByDestinationMintOperationId(id: string): Promise { + return this.getByChild('destinationMintOperationId', id); + } + + async getBySourceMeltOperationId(id: string): Promise { + return this.getByChild('sourceMeltOperationId', id); + } + + async compareAndSet(operation: MintSwapOperation, expectedRevision: number): Promise { + assertNonNegativeSafeInteger(expectedRevision, 'Expected revision'); + return this.db.runTransaction('rw', [STORE], async () => { + const currentRow = await this.table().get(operation.id); + if (!currentRow || currentRow.revision !== expectedRevision) return false; + const current = fromRow(currentRow); + assertMintSwapOperationUpdate(current, operation); + await this.table().put(toRow(operation)); + return true; + }); + } + + private async getByChild( + index: 'destinationMintOperationId' | 'sourceMeltOperationId', + id: string, + ): Promise { + const row = await this.table().where(index).equals(id).first(); + return row ? fromRow(row) : null; + } + + private table() { + return this.db.table(STORE); + } +} + +function sortRows(rows: MintSwapOperationRow[]): MintSwapOperationRow[] { + return rows.sort( + (left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id), + ); +} + +function toRow(operation: MintSwapOperation): MintSwapOperationRow { + return { + id: operation.id, + state: operation.state, + revision: operation.revision, + sourceMintUrl: operation.sourceMintUrl, + destinationMintUrl: operation.destinationMintUrl, + destinationMintOperationId: operation.destinationMintOperationId, + sourceMeltOperationId: operation.sourceMeltOperationId, + dueAt: getMintSwapOperationDueAt(operation) ?? undefined, + createdAt: operation.createdAt, + updatedAt: operation.updatedAt, + recordJson: JSON.stringify(serializeOperation(operation)), + }; +} + +function serializeOperation(operation: MintSwapOperation): unknown { + return { + ...operation, + destinationAmount: operation.destinationAmount.toString(), + preparedPlan: operation.preparedPlan + ? mapAmountsToStrings(operation.preparedPlan, [ + 'sourceMeltAmount', + 'sourceFeeReserve', + 'sourcePreparationFee', + 'sourceMeltInputFee', + 'minimumSourceDebit', + 'maximumSourceDebit', + 'reservedSourceAmount', + ]) + : undefined, + settlement: operation.settlement + ? mapAmountsToStrings(operation.settlement, [ + 'sourcePaymentFee', + 'totalSourceFee', + 'sourceMeltChangeAmount', + 'sourceKeepAmount', + 'sourceReturnedAmount', + 'finalSourceDebit', + 'destinationAmountIssued', + ]) + : undefined, + }; +} + +function fromRow(row: MintSwapOperationRow): MintSwapOperation { + const parsed = JSON.parse(row.recordJson) as Record & { + destinationAmount: string; + preparedPlan?: Record; + settlement?: Record; + }; + return validateMintSwapOperation({ + ...parsed, + destinationAmount: Amount.from(parsed.destinationAmount), + preparedPlan: parsed.preparedPlan + ? mapStringsToAmounts(parsed.preparedPlan, [ + 'sourceMeltAmount', + 'sourceFeeReserve', + 'sourcePreparationFee', + 'sourceMeltInputFee', + 'minimumSourceDebit', + 'maximumSourceDebit', + 'reservedSourceAmount', + ]) + : undefined, + settlement: parsed.settlement + ? mapStringsToAmounts(parsed.settlement, [ + 'sourcePaymentFee', + 'totalSourceFee', + 'sourceMeltChangeAmount', + 'sourceKeepAmount', + 'sourceReturnedAmount', + 'finalSourceDebit', + 'destinationAmountIssued', + ]) + : undefined, + } as MintSwapOperation); +} + +function mapAmountsToStrings(value: T, keys: readonly string[]): object { + const result = { ...value } as Record; + for (const key of keys) { + if (result[key] !== undefined) result[key] = Amount.from(result[key] as Amount).toString(); + } + return result; +} + +function mapStringsToAmounts(value: Record, keys: readonly string[]): object { + const result = { ...value }; + for (const key of keys) { + if (result[key] !== undefined) result[key] = Amount.from(result[key] as string); + } + return result; +} + +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/indexeddb/src/repositories/OperationEventOutboxRepository.ts b/packages/indexeddb/src/repositories/OperationEventOutboxRepository.ts new file mode 100644 index 00000000..b6e24d6e --- /dev/null +++ b/packages/indexeddb/src/repositories/OperationEventOutboxRepository.ts @@ -0,0 +1,121 @@ +import type { + OperationEventOutboxRecord, + OperationEventOutboxRepository, +} from '@cashu/coco-core/adapter'; +import { + isOperationEventPublished, + validateOperationEventOutboxRecord, +} from '@cashu/coco-core/adapter'; +import Dexie from 'dexie'; + +import { IdbDb, type OperationEventOutboxRow } from '../lib/db.ts'; + +const STORE = 'coco_cashu_operation_event_outbox'; + +export class IdbOperationEventOutboxRepository implements OperationEventOutboxRepository { + constructor(private readonly db: IdbDb) {} + + async enqueue(event: OperationEventOutboxRecord): Promise { + validateOperationEventOutboxRecord(event); + await this.table().add(toRow(event)); + } + + async getById(id: string): Promise { + const row = await this.table().get(id); + return row ? fromRow(row) : null; + } + + async getUnpublished(limit: number, now = Date.now()): Promise { + assertNonNegativeSafeInteger(limit, 'Outbox limit'); + assertNonNegativeSafeInteger(now, 'Outbox due time'); + if (limit === 0) return []; + const rows = await this.table() + .where('[publicationState+dueAt+createdAt+id]') + .between( + ['pending', Dexie.minKey, Dexie.minKey, Dexie.minKey], + ['pending', now, Dexie.maxKey, Dexie.maxKey], + ) + .limit(limit) + .toArray(); + return rows.map(fromRow); + } + + async markPublished(id: string, publishedAt: number): Promise { + await this.db.runTransaction('rw', [STORE], async () => { + const event = await this.requireEvent(id); + if (isOperationEventPublished(event)) return; + const published = { + ...event, + publishedAt, + publishAttempts: event.publishAttempts + 1, + nextAttemptAt: undefined, + lastError: undefined, + }; + validateOperationEventOutboxRecord(published); + await this.table().put(toRow(published)); + }); + } + + async recordPublishFailure(id: string, nextAttemptAt: number, lastError: string): Promise { + await this.db.runTransaction('rw', [STORE], async () => { + const event = await this.requireEvent(id); + if (isOperationEventPublished(event)) return; + const failed = { + ...event, + publishAttempts: event.publishAttempts + 1, + nextAttemptAt, + lastError, + }; + validateOperationEventOutboxRecord(failed); + await this.table().put(toRow(failed)); + }); + } + + private async requireEvent(id: string): Promise { + const event = await this.getById(id); + if (!event) throw new Error(`Operation event outbox record with id ${id} not found`); + return event; + } + + private table() { + return this.db.table(STORE); + } +} + +function toRow(event: OperationEventOutboxRecord): OperationEventOutboxRow { + return { + id: event.id, + operationId: event.operationId, + revision: event.revision, + eventType: event.eventType, + payloadJson: JSON.stringify(event.payload), + createdAt: event.createdAt, + publishedAt: event.publishedAt, + publishAttempts: event.publishAttempts, + nextAttemptAt: event.nextAttemptAt, + lastError: event.lastError, + publicationState: event.publishedAt === undefined ? 'pending' : 'published', + dueAt: event.nextAttemptAt ?? 0, + }; +} + +function fromRow(row: OperationEventOutboxRow): OperationEventOutboxRecord { + return validateOperationEventOutboxRecord({ + id: row.id, + operationId: row.operationId, + revision: row.revision, + eventType: row.eventType as OperationEventOutboxRecord['eventType'], + payload: JSON.parse(row.payloadJson), + createdAt: row.createdAt, + publishedAt: row.publishedAt, + publishAttempts: row.publishAttempts, + nextAttemptAt: row.nextAttemptAt, + lastError: row.lastError, + }); +} + +function 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/indexeddb/src/test/contract.test.ts b/packages/indexeddb/src/test/contract.test.ts index 73c5ad97..0a79d5f5 100644 --- a/packages/indexeddb/src/test/contract.test.ts +++ b/packages/indexeddb/src/test/contract.test.ts @@ -11,6 +11,8 @@ import { runSendOperationRepositoryContract, runMeltOperationRepositoryContract, runMeltQuoteRepositoryContract, + runMintSwapRepositoryContract, + runMintSwapRepositoryConcurrencyContract, } from '@cashu/coco-adapter-tests'; import { IndexedDbRepositories } from '../index.ts'; @@ -26,6 +28,23 @@ async function createRepositories() { }; } +async function createRepositoryPair() { + const dbName = `coco_cashu_pair_${Date.now()}_${dbCounter++}`; + const first = new IndexedDbRepositories({ name: dbName }); + const second = new IndexedDbRepositories({ name: dbName }); + await first.init(); + await second.init(); + return { + first, + second, + dispose: async () => { + first.db.close(); + second.db.close(); + await Dexie.delete(dbName); + }, + }; +} + async function expectRejects(fn: () => Promise) { let didThrow = false; try { @@ -61,6 +80,10 @@ runMeltQuoteRepositoryContract({ createRepositories }, { describe, it, expect }) runPaymentRequestReceiveRepositoryContract({ createRepositories }, { describe, it, expect }); +runMintSwapRepositoryContract({ createRepositories }, { describe, it, expect }); + +runMintSwapRepositoryConcurrencyContract({ createRepositoryPair }, { describe, it, expect }); + describe('indexeddb quote storage constraints', () => { it('migrates canonical Mint Quote Accounting without inventing remote time', async () => { const dbName = `coco_cashu_migration_${Date.now()}_${dbCounter++}`; diff --git a/packages/indexeddb/src/test/mintSwapMigration.test.ts b/packages/indexeddb/src/test/mintSwapMigration.test.ts new file mode 100644 index 00000000..83771001 --- /dev/null +++ b/packages/indexeddb/src/test/mintSwapMigration.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import Dexie from 'dexie'; + +import { IndexedDbRepositories } from '../index.ts'; + +describe('IndexedDB mint-swap migration', () => { + it('upgrades version 32 with dormant stores while preserving standalone children', async () => { + const name = `coco_cashu_mint_swap_migration_${Date.now()}`; + const legacy = new Dexie(name); + legacy.version(32).stores({ + coco_cashu_mint_operations: + '&id, state, mintUrl, createdAt, [mintUrl+quoteId], [mintUrl+method+quoteId]', + coco_cashu_melt_operations: '&id, state, mintUrl, createdAt, [mintUrl+quoteId]', + }); + await legacy.open(); + await legacy.table('coco_cashu_mint_operations').add({ + id: 'legacy-mint-child', + mintUrl: 'https://mint.test', + quoteId: 'mint-quote', + state: 'init', + createdAt: 1, + updatedAt: 1, + method: 'bolt11', + methodDataJson: '{}', + amount: '1', + unit: 'sat', + }); + await legacy.table('coco_cashu_melt_operations').add({ + id: 'legacy-melt-child', + mintUrl: 'https://mint.test', + quoteId: 'melt-quote', + state: 'init', + createdAt: 1, + updatedAt: 1, + method: 'bolt11', + methodDataJson: '{}', + unit: 'sat', + }); + legacy.close(); + + const repositories = new IndexedDbRepositories({ name }); + try { + await repositories.init(); + + expect(repositories.db.verno).toBe(34); + const parent = repositories.db.table('coco_cashu_mint_swap_operations'); + const outbox = repositories.db.table('coco_cashu_operation_event_outbox'); + expect(parent.schema.primKey.name).toBe('id'); + expect(parent.schema.idxByName.destinationMintOperationId?.unique).toBe(true); + expect(parent.schema.idxByName.sourceMeltOperationId?.unique).toBe(true); + expect(parent.schema.idxByName['[dueAt+createdAt+id]']).toBeDefined(); + expect(outbox.schema.idxByName['[operationId+revision+eventType]']?.unique).toBe(true); + expect( + repositories.db.table('coco_cashu_mint_operations').schema.idxByName.parentSwapOperationId, + ).toMatchObject({ unique: true }); + expect( + repositories.db.table('coco_cashu_melt_operations').schema.idxByName.parentSwapOperationId, + ).toMatchObject({ unique: true }); + expect(outbox.schema.idxByName['[publicationState+dueAt+createdAt+id]']).toBeDefined(); + + expect( + await repositories.db.table('coco_cashu_mint_operations').get('legacy-mint-child'), + ).toMatchObject({ id: 'legacy-mint-child' }); + expect( + await repositories.db.table('coco_cashu_melt_operations').get('legacy-melt-child'), + ).toMatchObject({ id: 'legacy-melt-child' }); + } finally { + repositories.db.close(); + } + }); +}); From c43ca74ad2f5c8ed63497ff1c4eeb2e42eb9c0f1 Mon Sep 17 00:00:00 2001 From: IgboPharaoh Date: Sun, 9 Aug 2026 18:42:27 +0100 Subject: [PATCH 14/15] fix(storage): persist owned melt failure outcomes --- packages/adapter-tests/src/index.ts | 10 +++ packages/indexeddb/src/lib/db.ts | 1 + .../src/repositories/HistoryRepository.ts | 2 +- .../repositories/MeltOperationRepository.ts | 5 +- .../repositories/MeltOperationRepository.ts | 8 +-- packages/sql-storage/src/schema.ts | 64 +++++++++++++++++++ packages/sql-storage/src/test/schema.test.ts | 1 + 7 files changed, 79 insertions(+), 12 deletions(-) diff --git a/packages/adapter-tests/src/index.ts b/packages/adapter-tests/src/index.ts index 953abe86..ddc2ce77 100644 --- a/packages/adapter-tests/src/index.ts +++ b/packages/adapter-tests/src/index.ts @@ -1138,6 +1138,16 @@ export async function runMintSwapRepositoryContract( (await repositories.meltOperationRepository.getById(executingMeltChild.id)) ?.parentExecutionPhase, ).toBe('melt_authorized'); + const failedMeltChild = { + ...executingMeltChild, + state: 'failed', + error: 'Canonical source quote is unpaid', + updatedAt: executingMeltChild.updatedAt + 1, + } satisfies MeltOperation; + await repositories.meltOperationRepository.update(failedMeltChild); + expect( + (await repositories.meltOperationRepository.getById(executingMeltChild.id))?.state, + ).toBe('failed'); const meltChild = createDummyMeltOperation({ id: 'owned-melt', diff --git a/packages/indexeddb/src/lib/db.ts b/packages/indexeddb/src/lib/db.ts index a84826d5..d04c445c 100644 --- a/packages/indexeddb/src/lib/db.ts +++ b/packages/indexeddb/src/lib/db.ts @@ -277,6 +277,7 @@ export interface MeltOperationRow { | 'prepared' | 'executing' | 'pending' + | 'failed' | 'finalized' | 'rolling_back' | 'rolled_back'; diff --git a/packages/indexeddb/src/repositories/HistoryRepository.ts b/packages/indexeddb/src/repositories/HistoryRepository.ts index 1db1216a..4861bb92 100644 --- a/packages/indexeddb/src/repositories/HistoryRepository.ts +++ b/packages/indexeddb/src/repositories/HistoryRepository.ts @@ -39,7 +39,7 @@ type LegacyHistoryRow = { }; type OperationRow = SendOperationRow | MeltOperationRow | MintOperationRow | ReceiveOperationRow; -type HistoryVisibleMeltState = Exclude; +type HistoryVisibleMeltState = Exclude; const stores = [ 'coco_cashu_send_operations', diff --git a/packages/indexeddb/src/repositories/MeltOperationRepository.ts b/packages/indexeddb/src/repositories/MeltOperationRepository.ts index bcafdb7b..ca0c3502 100644 --- a/packages/indexeddb/src/repositories/MeltOperationRepository.ts +++ b/packages/indexeddb/src/repositories/MeltOperationRepository.ts @@ -28,6 +28,7 @@ const preparedStates: MeltOperationState[] = [ 'prepared', 'executing', 'pending', + 'failed', 'finalized', 'rolling_back', 'rolled_back', @@ -99,10 +100,6 @@ const rowToOperation = (row: MeltOperationRow): MeltOperation => { }; const operationToRow = (operation: MeltOperation): MeltOperationRow => { - if (operation.state === 'failed') { - throw new Error('Cannot persist failed melt operation'); - } - const createdAtSeconds = Math.floor(operation.createdAt / 1000); const updatedAtSeconds = Math.floor(operation.updatedAt / 1000); const methodDataJson = stringifyJson(operation.methodData); diff --git a/packages/sql-storage/src/repositories/MeltOperationRepository.ts b/packages/sql-storage/src/repositories/MeltOperationRepository.ts index b8dc6fcd..30349464 100644 --- a/packages/sql-storage/src/repositories/MeltOperationRepository.ts +++ b/packages/sql-storage/src/repositories/MeltOperationRepository.ts @@ -54,6 +54,7 @@ const preparedStates: MeltOperationState[] = [ 'prepared', 'executing', 'pending', + 'failed', 'finalized', 'rolling_back', 'rolled_back', @@ -200,9 +201,6 @@ export class SqliteMeltOperationRepository implements MeltOperationRepository { } async create(operation: MeltOperation): Promise { - if (operation.state === 'failed') { - throw new Error('Cannot persist failed melt operation'); - } assertParentOwnedMeltOperationInvariant(operation); const exists = await this.db.get<{ id: string }>( @@ -225,10 +223,6 @@ export class SqliteMeltOperationRepository implements MeltOperationRepository { } async update(operation: MeltOperation): Promise { - if (operation.state === 'failed') { - throw new Error('Cannot persist failed melt operation'); - } - const current = await this.getById(operation.id); if (!current) { throw new Error(`MeltOperation with id ${operation.id} not found`); diff --git a/packages/sql-storage/src/schema.ts b/packages/sql-storage/src/schema.ts index fecb90e2..11f1b88a 100644 --- a/packages/sql-storage/src/schema.ts +++ b/packages/sql-storage/src/schema.ts @@ -1549,6 +1549,70 @@ const MIGRATIONS: readonly Migration[] = [ WHERE parentSwapOperationId IS NOT NULL; `, }, + { + id: '040_parent_owned_melt_failure_state', + sql: ` + ALTER TABLE coco_cashu_melt_operations + RENAME TO coco_cashu_melt_operations_pre_parent_failure; + + CREATE TABLE coco_cashu_melt_operations ( + id TEXT PRIMARY KEY, + mintUrl TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ( + 'init', 'prepared', 'executing', 'pending', 'failed', 'finalized', + 'rolling_back', 'rolled_back' + )), + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + error TEXT, + method TEXT NOT NULL, + methodDataJson TEXT NOT NULL, + quoteId TEXT, + amount TEXT, + fee_reserve TEXT, + swap_fee TEXT, + needsSwap INTEGER, + inputAmount TEXT, + inputProofSecretsJson TEXT, + changeOutputDataJson TEXT, + swapOutputDataJson TEXT, + changeAmount TEXT, + effectiveFee TEXT, + finalizedDataJson TEXT, + unit TEXT, + parentSwapOperationId TEXT, + parentExecutionPhase TEXT + ); + + INSERT INTO coco_cashu_melt_operations ( + id, mintUrl, state, createdAt, updatedAt, error, method, methodDataJson, quoteId, + amount, fee_reserve, swap_fee, needsSwap, inputAmount, inputProofSecretsJson, + changeOutputDataJson, swapOutputDataJson, changeAmount, effectiveFee, finalizedDataJson, + unit, parentSwapOperationId, parentExecutionPhase + ) + SELECT + id, mintUrl, state, createdAt, updatedAt, error, method, methodDataJson, quoteId, + amount, fee_reserve, swap_fee, needsSwap, inputAmount, inputProofSecretsJson, + changeOutputDataJson, swapOutputDataJson, changeAmount, effectiveFee, finalizedDataJson, + unit, parentSwapOperationId, parentExecutionPhase + FROM coco_cashu_melt_operations_pre_parent_failure; + + DROP TABLE coco_cashu_melt_operations_pre_parent_failure; + + CREATE INDEX IF NOT EXISTS idx_coco_cashu_melt_operations_state + ON coco_cashu_melt_operations(state); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_melt_operations_mint + ON coco_cashu_melt_operations(mintUrl); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_melt_operations_createdAt + ON coco_cashu_melt_operations(createdAt DESC, id DESC); + CREATE UNIQUE INDEX IF NOT EXISTS ux_coco_cashu_melt_operations_mint_quote + ON coco_cashu_melt_operations(mintUrl, quoteId) + WHERE quoteId IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS ux_coco_cashu_melt_operations_parent_swap + ON coco_cashu_melt_operations(parentSwapOperationId) + WHERE parentSwapOperationId IS NOT NULL; + `, + }, ]; // Export for testing diff --git a/packages/sql-storage/src/test/schema.test.ts b/packages/sql-storage/src/test/schema.test.ts index dbf2caf1..a6e02e13 100644 --- a/packages/sql-storage/src/test/schema.test.ts +++ b/packages/sql-storage/src/test/schema.test.ts @@ -51,6 +51,7 @@ const EXPECTED_MIGRATION_IDS = [ '037_mint_quote_accounting', '038_mint_swap_operations_and_outbox', '039_mint_swap_child_ownership', + '040_parent_owned_melt_failure_state', ] as const; const RECEIVE_OPERATIONS_SQL = ` From b02604ec2cded48e40e82f0565074fe69de9c8f5 Mon Sep 17 00:00:00 2001 From: IgboPharaoh Date: Sun, 9 Aug 2026 19:17:42 +0100 Subject: [PATCH 15/15] feat(core): coordinate recoverable mint swaps --- packages/adapter-tests/src/index.ts | 2 + .../operations/melt/MeltOperationService.ts | 152 +- .../mintSwap/ChildOperationOwnership.ts | 14 +- .../operations/mintSwap/MintSwapOperation.ts | 21 +- .../mintSwap/MintSwapOperationService.ts | 1378 +++++++++++++++++ .../operations/mintSwap/MintSwapPolicy.ts | 48 + packages/core/test/fixtures/MintSwap.ts | 21 +- .../test/unit/MeltOperationService.test.ts | 69 + .../core/test/unit/MintSwapOperation.test.ts | 19 +- .../unit/MintSwapOperationService.test.ts | 429 +++++ 10 files changed, 2129 insertions(+), 24 deletions(-) create mode 100644 packages/core/operations/mintSwap/MintSwapOperationService.ts create mode 100644 packages/core/operations/mintSwap/MintSwapPolicy.ts create mode 100644 packages/core/test/unit/MintSwapOperationService.test.ts diff --git a/packages/adapter-tests/src/index.ts b/packages/adapter-tests/src/index.ts index ddc2ce77..82314813 100644 --- a/packages/adapter-tests/src/index.ts +++ b/packages/adapter-tests/src/index.ts @@ -779,6 +779,7 @@ export function createDummyPreparingMintSwapOperation( destinationMintUrl: 'https://destination-mint.test', unit: 'sat', destinationAmount: Amount.from(100), + requiredDispatchWindowSeconds: 120, destinationNut20Key: { publicKey: `02${'ab'.repeat(32)}`, derivationIndex: 42 }, preparationLease: { ownerId: 'adapter-contract-worker', @@ -809,6 +810,7 @@ export function createDummyMintSwapOperation( destinationMintUrl: 'https://destination-mint.test', unit: 'sat', destinationAmount, + requiredDispatchWindowSeconds: 120, destinationNut20Key: { publicKey: `02${'ab'.repeat(32)}`, derivationIndex: 42 }, destinationQuoteRef: { mintUrl: 'https://destination-mint.test', diff --git a/packages/core/operations/melt/MeltOperationService.ts b/packages/core/operations/melt/MeltOperationService.ts index b65b6d41..0b9f3d81 100644 --- a/packages/core/operations/melt/MeltOperationService.ts +++ b/packages/core/operations/melt/MeltOperationService.ts @@ -34,6 +34,7 @@ import type { CoreEvents } from '../../events/types'; import type { Logger } from '../../logging/Logger'; import { assertProofsMatchSerializedOutputs, + computeYHexForSecrets, deserializeOutputData, generateSubId, getSecretsFromSerializedOutputData, @@ -72,6 +73,10 @@ export type PlanOwnedMeltOperationCommand = Omit< 'preparedOperation' | 'repositories' > & { wallet: Wallet }; +export type OwnedMeltRecoveryObservation = + | { status: 'REMOTE_RESULT'; result: OwnedMeltRemoteResult } + | { status: 'ORIGINAL_INPUTS_RECLAIMABLE'; observedAt: number }; + /** * MeltOperationService orchestrates melt sagas while delegating * method-specific behavior to MeltMethodHandlers. @@ -527,6 +532,148 @@ export class MeltOperationService { return result; } + /** + * Observe an authorized owned source effect after restart without writing repositories. + * + * The composing parent applies the returned observation in its own transaction. This keeps + * canonical parent, child, proof, and outbox state on one durability boundary. + */ + async observeOwnedRecovery( + operationId: string, + parentSwapOperationId: string, + ): Promise { + const operation = await this.meltOperationRepository.getById(operationId); + if (!operation || (operation.state !== 'executing' && operation.state !== 'pending')) { + throw new Error( + `Cannot recover melt child ${operationId} from ${operation?.state ?? 'missing'}`, + ); + } + assertChildOperationAccess(operation, parentSwapOperationId); + assertParentOwnedMeltOperationInvariant(operation); + + if (operation.parentExecutionPhase === 'pre_swap_authorized') { + const proofStates = await this.mintAdapter.checkProofStates( + operation.mintUrl, + computeYHexForSecrets(operation.inputProofSecrets), + ); + const spentCount = proofStates.filter(({ state }) => state === 'SPENT').length; + if (spentCount === 0) { + return { status: 'ORIGINAL_INPUTS_RECLAIMABLE', observedAt: Date.now() }; + } + if (spentCount !== operation.inputProofSecrets.length || !operation.swapOutputData) { + throw new Error(`Melt child ${operation.id} has an ambiguous pre-swap proof state`); + } + + const recovered = await this.proofService.recoverProofsFromOutputData( + operation.mintUrl, + operation.swapOutputData, + { + unit: operation.unit, + createdByOperationId: operation.id, + persistRecoveredProofs: false, + }, + ); + assertProofsMatchSerializedOutputs( + recovered, + [...operation.swapOutputData.keep, ...operation.swapOutputData.send], + `Recovered melt child ${operation.id} pre-swap`, + ); + const sendSecrets = new Set( + getSecretsFromSerializedOutputData(operation.swapOutputData).sendSecrets, + ); + return { + status: 'REMOTE_RESULT', + result: { + operationId: operation.id, + phase: 'pre_swap', + observedAt: Date.now(), + sendProofs: recovered.filter(({ secret }) => sendSecrets.has(secret)), + keepProofs: recovered.filter(({ secret }) => !sendSecrets.has(secret)), + }, + }; + } + + if (operation.parentExecutionPhase !== 'melt_authorized') { + throw new Error(`Melt child ${operation.id} has no authorized recovery phase`); + } + const quote = await this.quoteLifecycle.refreshMeltQuote( + operation.mintUrl, + operation.method, + operation.quoteId, + ); + if (quote.method !== operation.method || quote.unit !== operation.unit) { + throw new Error(`Recovered quote does not match melt child ${operation.id}`); + } + const response = { + state: quote.state, + change: quote.change, + payment_preimage: quote.method === 'onchain' ? undefined : quote.payment_preimage, + outpoint: quote.method === 'onchain' ? quote.outpoint : undefined, + } as Extract['response']; + let changeProofs; + if (response.state === 'PAID' && response.change?.length) { + changeProofs = await this.proofService.unblindChangeProofs( + operation.mintUrl, + deserializeOutputData(operation.changeOutputData).keep, + response.change, + { unit: operation.unit, createdByOperationId: operation.id }, + ); + } + return { + status: 'REMOTE_RESULT', + result: { + operationId: operation.id, + phase: 'melt', + observedAt: Date.now(), + response, + changeProofs, + }, + }; + } + + /** Reclaim an authorized pre-swap whose original proofs remain unspent. */ + async reclaimOwnedPreSwapInTransaction( + operationId: string, + parentSwapOperationId: string, + repositories: RepositoryTransactionScope, + reason = 'Authorized pre-swap was not executed', + ): Promise { + requireMintSwapRepositoryCapability(repositories); + const operation = await repositories.meltOperationRepository.getById(operationId); + if ( + !operation || + operation.state !== 'executing' || + operation.parentExecutionPhase !== 'pre_swap_authorized' + ) { + throw new Error( + `Cannot reclaim pre-swap ${operationId} from ${operation?.state ?? 'missing'}`, + ); + } + assertChildOperationAccess(operation, parentSwapOperationId); + const proofs = await repositories.proofRepository.getProofsBySecrets( + operation.mintUrl, + operation.inputProofSecrets, + ); + if ( + proofs.length !== operation.inputProofSecrets.length || + proofs.some((proof) => proof.state !== 'inflight' || proof.usedByOperationId !== operation.id) + ) { + throw new Error(`Melt child ${operation.id} cannot reclaim its original inputs`); + } + await this.proofService + .forTransaction(repositories) + .restoreProofsToReady(operation.mintUrl, operation.inputProofSecrets); + const failed: FailedMeltOperation = { + ...operation, + state: 'failed', + updatedAt: Date.now(), + error: reason, + }; + assertParentOwnedMeltOperationInvariant(failed); + await repositories.meltOperationRepository.update(failed); + return failed; + } + /** Apply one remote source result atomically with the composing parent transition. */ async applyOwnedRemoteStepInTransaction( operationOrId: string | ExecutingMeltOperation, @@ -542,9 +689,12 @@ export class MeltOperationService { throw new Error(`Melt result operation ${result.operationId} does not match ${operationId}`); } const current = await repositories.meltOperationRepository.getById(operationId); - if (!current || current.state !== 'executing') { + if (!current || (current.state !== 'executing' && current.state !== 'pending')) { throw new Error(`Cannot apply melt child ${operationId} from ${current?.state ?? 'missing'}`); } + if (current.state === 'pending' && result.phase !== 'melt') { + throw new Error(`Pending melt child ${operationId} can only apply a melt observation`); + } assertChildOperationAccess(current, parentSwapOperationId); const expectedResultPhase = current.parentExecutionPhase === 'pre_swap_authorized' ? 'pre_swap' : 'melt'; diff --git a/packages/core/operations/mintSwap/ChildOperationOwnership.ts b/packages/core/operations/mintSwap/ChildOperationOwnership.ts index be018a50..64284485 100644 --- a/packages/core/operations/mintSwap/ChildOperationOwnership.ts +++ b/packages/core/operations/mintSwap/ChildOperationOwnership.ts @@ -50,16 +50,18 @@ export function assertParentOwnedMeltOperationInvariant(operation: MeltOperation 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' - ) { + } else if (operation.state === 'pending' || operation.state === 'finalized') { if (phase !== 'melt_authorized') { throw new Error( `Parent-owned settled melt operation ${operation.id} requires melt authorization`, ); } + } else if (operation.state === 'failed') { + if (phase !== 'pre_swap_authorized' && phase !== 'melt_authorized') { + throw new Error( + `Parent-owned failed melt operation ${operation.id} requires source authorization`, + ); + } } else if (phase !== undefined) { throw new Error( `Melt operation ${operation.id} cannot retain a parent phase in ${operation.state}`, @@ -68,7 +70,7 @@ export function assertParentOwnedMeltOperationInvariant(operation: MeltOperation if (phase === 'pre_swap_authorized') { if ( - operation.state !== 'executing' || + (operation.state !== 'executing' && operation.state !== 'failed') || !operation.needsSwap || operation.swapOutputData === undefined ) { diff --git a/packages/core/operations/mintSwap/MintSwapOperation.ts b/packages/core/operations/mintSwap/MintSwapOperation.ts index 0786bf11..a43fa666 100644 --- a/packages/core/operations/mintSwap/MintSwapOperation.ts +++ b/packages/core/operations/mintSwap/MintSwapOperation.ts @@ -125,6 +125,8 @@ export interface MintSwapOperation { destinationMintUrl: string; unit: 'sat'; destinationAmount: Amount; + /** Caller-selected safety window persisted before any remote preparation I/O. */ + requiredDispatchWindowSeconds: number; /** * 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. @@ -161,7 +163,10 @@ export interface MintSwapPreparedPlanFingerprintInput { unit: 'sat'; sourceInputProofSecrets: readonly string[]; destinationOutputData: SerializedOutputData; - sourceOutputData: SerializedOutputData; + sourceOutputData: { + change: SerializedOutputData; + swap?: SerializedOutputData; + }; sourceMeltAmount: Amount; sourceFeeReserve: Amount; sourcePreparationFee: Amount; @@ -410,6 +415,12 @@ export function validateMintSwapOperation(operation: MintSwapOperation): MintSwa if (operation.destinationAmount.isZero()) { throw new Error('Mint swap destination amount must be positive'); } + if ( + !Number.isSafeInteger(operation.requiredDispatchWindowSeconds) || + operation.requiredDispatchWindowSeconds < 30 + ) { + throw new Error('Mint swap required dispatch window must be at least 30 seconds'); + } validateNut20Key(operation.destinationNut20Key); validateRetry(operation.retry); @@ -814,6 +825,9 @@ function requirePreparedFields(operation: MintSwapOperation): void { ) { throw new Error('Mint swap required dispatch window must be at least 30 seconds'); } + if (plan.requiredDispatchWindowSeconds !== operation.requiredDispatchWindowSeconds) { + throw new Error('Prepared mint swap dispatch window must match its preparation policy'); + } if ( operation.state === 'prepared' && plan.dispatchDeadlineSeconds < @@ -955,6 +969,11 @@ function assertAlwaysImmutable(current: MintSwapOperation, next: MintSwapOperati [current.destinationMintUrl, next.destinationMintUrl, 'destination mint URL'], [current.unit, next.unit, 'unit'], [current.destinationAmount.toString(), next.destinationAmount.toString(), 'destination amount'], + [ + current.requiredDispatchWindowSeconds, + next.requiredDispatchWindowSeconds, + 'required dispatch window', + ], [ current.destinationNut20Key.publicKey, next.destinationNut20Key.publicKey, diff --git a/packages/core/operations/mintSwap/MintSwapOperationService.ts b/packages/core/operations/mintSwap/MintSwapOperationService.ts new file mode 100644 index 00000000..6000d779 --- /dev/null +++ b/packages/core/operations/mintSwap/MintSwapOperationService.ts @@ -0,0 +1,1378 @@ +import { Amount, type AmountLike, type Proof, type Wallet } from '@cashu/cashu-ts'; + +import type { Logger } from '../../logging/Logger.ts'; +import { getMintQuoteAmount, type MintQuote } from '../../models/MintQuote.ts'; +import type { MeltQuote } from '../../models/MeltQuote.ts'; +import type { OperationEventOutboxRecord } from '../../models/OperationEventOutbox.ts'; +import type { QuoteLifecycle } from '../../quotes/QuoteLifecycle.ts'; +import type { Repositories, RepositoryTransactionScope } from '../../repositories/index.ts'; +import { requireMintSwapRepositoryCapability } from '../../repositories/index.ts'; +import type { KeyRingService } from '../../services/KeyRingService.ts'; +import type { MintService } from '../../services/MintService.ts'; +import type { WalletService } from '../../services/WalletService.ts'; +import { generateSubId, normalizeMintUrl } from '../../utils.ts'; +import type { MeltOperationService } from '../melt/MeltOperationService.ts'; +import type { + ExecutingMeltOperation, + FailedMeltOperation, + FinalizedMeltOperation, + PreparedMeltOperation, +} from '../melt/MeltOperation.ts'; +import type { OwnedMeltRemoteResult } from '../melt/MeltMethodHandler.ts'; +import type { MintOperationService } from '../mint/MintOperationService.ts'; +import type { + ExecutingMintOperation, + FinalizedMintOperation, + PendingMintOperation, +} from '../mint/MintOperation.ts'; +import { + assertMintSwapPreparationLeaseOwner, + createMintSwapPreparedPlanFingerprint, + isMintSwapPreparationLeaseActive, + isTerminalMintSwapState, + validateMintSwapAccounting, + type MintSwapAttentionReason, + type MintSwapEventType, + type MintSwapOperation, + type MintSwapOperationState, + type MintSwapPreparationLease, + type MintSwapPreparedPlan, + type MintSwapSettlement, +} from './MintSwapOperation.ts'; +import { + DEFAULT_MINT_SWAP_DISPATCH_WINDOW_SECONDS, + evaluateMintSwapDispatchWindow, +} from './MintSwapPolicy.ts'; + +export interface PrepareMintSwapInput { + sourceMintUrl: string; + destinationMintUrl: string; + amount: AmountLike; + unit?: 'sat'; + requiredDispatchWindowSeconds?: number; +} + +export interface ListMintSwapInput { + state?: MintSwapOperationState; + mintUrl?: string; +} + +export interface MintSwapOperationServiceOptions { + now?: () => number; + workerId?: string; + leaseDurationMs?: number; + generateId?: () => string; +} + +export class MintSwapPreparationError extends Error { + readonly operationId: string; + + constructor(operationId: string, cause: unknown) { + super(`Mint swap ${operationId} could not be prepared`, { + cause: new Error('Mint swap preparation failed; inspect durable operation state'), + }); + this.name = 'MintSwapPreparationError'; + this.operationId = operationId; + void cause; + } +} + +class MintSwapCasError extends Error {} + +/** Internal, dormant coordinator for the durable exact-receive Mint Swap saga. */ +export class MintSwapOperationService { + private readonly now: () => number; + private readonly workerId: string; + private readonly leaseDurationMs: number; + private readonly generateId: () => string; + + constructor( + private readonly repositories: Repositories, + private readonly quoteLifecycle: QuoteLifecycle, + private readonly mintOperationService: MintOperationService, + private readonly meltOperationService: MeltOperationService, + private readonly mintService: MintService, + private readonly walletService: WalletService, + private readonly keyRingService: KeyRingService, + private readonly logger?: Logger, + options: MintSwapOperationServiceOptions = {}, + ) { + requireMintSwapRepositoryCapability(repositories); + this.now = options.now ?? Date.now; + this.generateId = options.generateId ?? generateSubId; + this.workerId = options.workerId ?? `mint-swap-worker:${this.generateId()}`; + this.leaseDurationMs = options.leaseDurationMs ?? 60_000; + if (!Number.isSafeInteger(this.leaseDurationMs) || this.leaseDurationMs <= 0) { + throw new Error('Mint swap preparation lease duration must be a positive safe integer'); + } + } + + async prepare(input: PrepareMintSwapInput): Promise { + const sourceMintUrl = normalizeMintUrl(input.sourceMintUrl); + const destinationMintUrl = normalizeMintUrl(input.destinationMintUrl); + const destinationAmount = Amount.from(input.amount); + if ((input.unit ?? 'sat') !== 'sat') throw new Error('Mint swaps support only sat'); + if (destinationAmount.isZero() || destinationAmount.toString().startsWith('-')) { + throw new Error('Mint swap destination amount must be positive'); + } + if (sourceMintUrl === destinationMintUrl) { + throw new Error('Mint swap source and destination mints must be distinct'); + } + await this.assertPreflight(sourceMintUrl, destinationMintUrl, destinationAmount); + const requiredDispatchWindowSeconds = + input.requiredDispatchWindowSeconds ?? DEFAULT_MINT_SWAP_DISPATCH_WINDOW_SECONDS; + if ( + !Number.isSafeInteger(requiredDispatchWindowSeconds) || + requiredDispatchWindowSeconds < 30 + ) { + throw new Error('Mint swap dispatch window must be at least 30 seconds'); + } + + const keyPair = await this.keyRingService.generateMintQuoteKeyPair(); + if (keyPair.derivationIndex === undefined) { + throw new Error('Mint swap NUT-20 key is missing its derivation index'); + } + const operationId = this.generateId(); + const now = this.now(); + const initial: MintSwapOperation = { + id: operationId, + state: 'preparing', + revision: 0, + sourceMintUrl, + destinationMintUrl, + unit: 'sat', + destinationAmount, + requiredDispatchWindowSeconds, + destinationNut20Key: { + publicKey: keyPair.publicKeyHex, + derivationIndex: keyPair.derivationIndex, + }, + preparationLease: this.newLease('destination_quote', now), + retry: { attemptCount: 0 }, + createdAt: now, + updatedAt: now, + }; + await this.parentRepository().create(initial); + + try { + return await this.resumePreparation(operationId); + } catch (error) { + await this.failPreparation(operationId, error); + throw new MintSwapPreparationError(operationId, error); + } + } + + async get(operationId: string): Promise { + return this.parentRepository().getById(operationId); + } + + async list(input: ListMintSwapInput = {}): Promise { + const operations = input.state + ? await this.parentRepository().getByState(input.state) + : await this.listAllStates(); + if (!input.mintUrl) return operations; + const mintUrl = normalizeMintUrl(input.mintUrl); + return operations.filter( + (operation) => + operation.sourceMintUrl === mintUrl || operation.destinationMintUrl === mintUrl, + ); + } + + async execute(operationId: string): Promise { + const current = await this.requireOperation(operationId); + if (current.state !== 'prepared') return this.reconcile(operationId); + try { + await this.assertPreparedPlan(current); + } catch { + return this.moveToAttention( + current, + 'prepared_plan_mismatch', + 'Mint swap child data no longer matches its authorized prepared plan', + 'prepared plan fingerprint and ownership', + ); + } + try { + this.assertDispatchWindow(current); + } catch { + return this.failPreparedBeforeDispatch(current); + } + + const authorized = await this.repositories.withTransaction(async (scope) => { + const operation = await this.requireOperationInScope(scope, operationId); + if (operation.state !== 'prepared') return false; + await this.meltOperationService.authorizeOwnedExecutionInTransaction( + operation.sourceMeltOperationId!, + operation.id, + scope, + ); + const now = this.now(); + await this.replaceInScope( + scope, + operation, + { + ...operation, + state: 'source_inflight', + sourceDispatchAuthorizedAt: now, + retry: { + attemptCount: 0, + lastSuccessfulObservationAt: now, + nextAttemptAt: now + this.leaseDurationMs, + }, + }, + 'mint-swap-op:source-inflight', + ); + return true; + }); + if (!authorized) return this.reconcile(operationId); + return this.driveSource(operationId, true); + } + + async reconcile(operationId: string): Promise { + const operation = await this.requireOperation(operationId); + switch (operation.state) { + case 'preparing': + return this.resumePreparation(operationId); + case 'prepared': + case 'completed': + case 'cancelled': + case 'failed': + case 'needs_attention': + return operation; + case 'source_inflight': + if ((operation.retry.nextAttemptAt ?? 0) > this.now()) return operation; + return this.driveSource(operationId, false); + case 'destination_funded': + if ((operation.retry.nextAttemptAt ?? 0) > this.now()) return operation; + return this.authorizeAndIssueDestination(operationId); + case 'issuing': + if ((operation.retry.nextAttemptAt ?? 0) > this.now()) return operation; + return this.issueDestination(operationId); + } + } + + async cancel(operationId: string, reason = 'Cancelled by caller'): Promise { + const requestedAt = this.now(); + return this.repositories.withTransaction(async (scope) => { + const operation = await this.requireOperationInScope(scope, operationId); + if (isTerminalMintSwapState(operation.state) || operation.state === 'needs_attention') { + return operation; + } + if (operation.state === 'prepared') { + await this.meltOperationService.rollbackOwnedPreparedInTransaction( + operation.sourceMeltOperationId!, + operation.id, + scope, + reason, + ); + return this.replaceInScope( + scope, + operation, + { + ...operation, + state: 'cancelled', + cancellationRequestedAt: requestedAt, + cancelledAt: requestedAt, + }, + 'mint-swap-op:cancelled', + ); + } + if (operation.state === 'preparing') { + return this.replaceInScope( + scope, + operation, + { + ...operation, + state: 'cancelled', + preparationLease: undefined, + cancellationRequestedAt: requestedAt, + cancelledAt: requestedAt, + }, + 'mint-swap-op:cancelled', + ); + } + if (operation.cancellationRequestedAt !== undefined) return operation; + return this.replaceInScope(scope, operation, { + ...operation, + cancellationRequestedAt: requestedAt, + }); + }); + } + + private async resumePreparation(operationId: string): Promise { + for (let step = 0; step < 12; step++) { + let operation = await this.requireOperation(operationId); + if (operation.state !== 'preparing') return operation; + operation = await this.claimPreparationLease(operation); + if (operation.state !== 'preparing') return operation; + const lease = operation.preparationLease!; + + switch (lease.stage) { + case 'destination_quote': + await this.prepareDestinationQuote(operation, lease); + break; + case 'destination_child': + await this.prepareDestinationChild(operation, lease); + break; + case 'source_quote': + await this.prepareSourceQuote(operation, lease); + break; + case 'source_child': + await this.prepareSourceChild(operation, lease); + break; + } + } + throw new Error(`Mint swap ${operationId} preparation did not converge`); + } + + private async claimPreparationLease(operation: MintSwapOperation): Promise { + const now = this.now(); + const lease = operation.preparationLease!; + if (isMintSwapPreparationLeaseActive(operation, now)) { + if (lease.ownerId !== this.workerId) { + throw new Error(`Mint swap ${operation.id} preparation is leased by another worker`); + } + return operation; + } + const next: MintSwapOperation = { + ...operation, + revision: operation.revision + 1, + updatedAt: now, + preparationLease: this.newLease(lease.stage, now), + }; + if (!(await this.parentRepository().compareAndSet(next, operation.revision))) { + return this.requireOperation(operation.id); + } + return next; + } + + private async prepareDestinationQuote( + operation: MintSwapOperation, + lease: MintSwapPreparationLease, + ): Promise { + const quote = await this.quoteLifecycle.createMintQuote( + operation.destinationMintUrl, + 'bolt11', + { + amount: { amount: operation.destinationAmount, unit: 'sat' }, + ownedPubkey: operation.destinationNut20Key.publicKey, + }, + ); + this.assertDestinationQuote(quote, operation); + await this.advancePreparation(operation.id, lease, { + destinationQuoteRef: this.quoteRef(quote), + nextStage: 'destination_child', + }); + } + + private async prepareDestinationChild( + operation: MintSwapOperation, + lease: MintSwapPreparationLease, + ): Promise { + const quote = await this.requireDestinationQuote(operation); + const { wallet } = await this.walletService.getWalletWithActiveKeysetId( + operation.destinationMintUrl, + 'sat', + ); + const operationId = this.childId(operation.id, 'destination'); + const child = await this.mintOperationService.planOwnedPreparation({ + operationId, + parentSwapOperationId: operation.id, + quote, + amount: operation.destinationAmount, + destinationNut20PublicKey: operation.destinationNut20Key.publicKey, + wallet, + }); + + await this.repositories.withTransaction(async (scope) => { + const current = await this.requirePreparingLeaseInScope(scope, operation.id, lease); + await this.mintOperationService.prepareOwnedInTransaction({ + operationId, + parentSwapOperationId: current.id, + quote, + amount: current.destinationAmount, + destinationNut20PublicKey: current.destinationNut20Key.publicKey, + preparedOperation: child, + repositories: scope, + }); + await this.replaceInScope(scope, current, { + ...current, + destinationMintOperationId: operationId, + preparationLease: this.advanceLease(current.preparationLease!, 'source_quote'), + }); + }); + } + + private async prepareSourceQuote( + operation: MintSwapOperation, + lease: MintSwapPreparationLease, + ): Promise { + const destinationQuote = await this.requireDestinationQuote(operation); + const quote = await this.quoteLifecycle.createMeltQuote( + operation.sourceMintUrl, + 'bolt11', + { invoice: destinationQuote.request }, + 'sat', + ); + this.assertSourceQuote(quote, destinationQuote, operation); + await this.advancePreparation(operation.id, lease, { + sourceQuoteRef: this.quoteRef(quote), + nextStage: 'source_child', + }); + } + + private async prepareSourceChild( + operation: MintSwapOperation, + lease: MintSwapPreparationLease, + ): Promise { + const [destinationQuote, sourceQuote, sourceWalletResult] = await Promise.all([ + this.requireDestinationQuote(operation), + this.requireSourceQuote(operation), + this.walletService.getWalletWithActiveKeysetId(operation.sourceMintUrl, 'sat'), + ]); + const sourceWallet = sourceWalletResult.wallet; + const operationId = this.childId(operation.id, 'source'); + const child = await this.meltOperationService.planOwnedPreparation({ + operationId, + parentSwapOperationId: operation.id, + quote: sourceQuote, + wallet: sourceWallet, + }); + const plan = await this.buildPreparedPlan( + operation, + child, + destinationQuote, + sourceQuote, + sourceWallet, + operation.requiredDispatchWindowSeconds, + ); + + await this.repositories.withTransaction(async (scope) => { + const current = await this.requirePreparingLeaseInScope(scope, operation.id, lease); + await this.meltOperationService.prepareOwnedInTransaction({ + operationId, + parentSwapOperationId: current.id, + quote: sourceQuote, + preparedOperation: child, + repositories: scope, + }); + const destinationChild = await scope.mintOperationRepository.getById( + current.destinationMintOperationId!, + ); + if (!destinationChild || !('outputData' in destinationChild)) { + throw new Error('Mint swap destination child recovery material is missing'); + } + const preparedPlan: MintSwapPreparedPlan = { + ...plan, + fingerprint: createMintSwapPreparedPlanFingerprint({ + destinationMintOperationId: destinationChild.id, + sourceMeltOperationId: child.id, + destinationQuoteRef: current.destinationQuoteRef!, + sourceQuoteRef: current.sourceQuoteRef!, + destinationNut20Key: current.destinationNut20Key, + destinationAmount: current.destinationAmount, + unit: 'sat', + sourceInputProofSecrets: child.inputProofSecrets, + destinationOutputData: destinationChild.outputData, + sourceOutputData: this.sourceOutputData(child), + ...plan, + }), + }; + await this.replaceInScope( + scope, + current, + { + ...current, + state: 'prepared', + preparationLease: undefined, + sourceMeltOperationId: operationId, + preparedPlan, + retry: { attemptCount: 0, lastSuccessfulObservationAt: this.now() }, + }, + 'mint-swap-op:prepared', + ); + }); + } + + private async advancePreparation( + operationId: string, + lease: MintSwapPreparationLease, + change: + | { + destinationQuoteRef: MintSwapOperation['destinationQuoteRef']; + nextStage: 'destination_child'; + } + | { sourceQuoteRef: MintSwapOperation['sourceQuoteRef']; nextStage: 'source_child' }, + ): Promise { + await this.repositories.withTransaction(async (scope) => { + const current = await this.requirePreparingLeaseInScope(scope, operationId, lease); + await this.replaceInScope(scope, current, { + ...current, + ...('destinationQuoteRef' in change + ? { destinationQuoteRef: change.destinationQuoteRef } + : { sourceQuoteRef: change.sourceQuoteRef }), + preparationLease: this.advanceLease(current.preparationLease!, change.nextStage), + }); + }); + } + + private async driveSource( + operationId: string, + dispatchAuthorizedStep: boolean, + ): Promise { + const parent = await this.requireOperation(operationId); + if (parent.state !== 'source_inflight') return parent; + const child = await this.repositories.meltOperationRepository.getById( + parent.sourceMeltOperationId!, + ); + if (!child || child.parentSwapOperationId !== parent.id) { + return this.moveToAttention( + parent, + 'ownership_conflict', + 'Mint swap source child ownership no longer matches its parent', + 'source child ownership', + ); + } + if (child.state === 'finalized') return this.advanceSourceFunded(parent, child); + if (child.state === 'failed') return this.finishReclaimedSource(parent, this.now()); + if (child.state === 'pending') dispatchAuthorizedStep = false; + if (child.state !== 'executing' && child.state !== 'pending') { + return this.moveToAttention( + parent, + 'canonical_observation_conflict', + 'Mint swap source child is in an unexpected state', + 'source child progress state', + { childState: child.state }, + ); + } + + try { + if (!dispatchAuthorizedStep) { + const observation = await this.meltOperationService.observeOwnedRecovery( + child.id, + parent.id, + ); + if (observation.status === 'ORIGINAL_INPUTS_RECLAIMABLE') { + return this.repositories.withTransaction(async (scope) => { + const current = await this.requireOperationInScope(scope, parent.id); + if (current.state !== 'source_inflight') return current; + await this.meltOperationService.reclaimOwnedPreSwapInTransaction( + child.id, + parent.id, + scope, + ); + return this.finishReclaimedSourceInScope(scope, current, observation.observedAt); + }); + } + return this.applySourceResult(parent.id, observation.result); + } + const result = await this.meltOperationService.executeOwnedRemoteStep(child.id, parent.id); + return this.applySourceResult(parent.id, result); + } catch (error) { + await this.recordRetry(parent.id, error); + throw error; + } + } + + private async applySourceResult( + parentId: string, + result: OwnedMeltRemoteResult, + ): Promise { + const outcome = await this.repositories.withTransaction(async (scope) => { + const parent = await this.requireOperationInScope(scope, parentId); + if (parent.state !== 'source_inflight') return { parent, child: null }; + const child = await this.meltOperationService.applyOwnedRemoteStepInTransaction( + parent.sourceMeltOperationId!, + parent.id, + result, + scope, + ); + if (child.state === 'finalized') { + const next = await this.advanceSourceFundedInScope(scope, parent, child); + return { parent: next, child }; + } + if (child.state === 'failed') { + const next = await this.finishReclaimedSourceInScope( + scope, + parent, + result.observedAt ?? this.now(), + ); + return { parent: next, child }; + } + const observedAt = result.observedAt ?? this.now(); + const next = await this.replaceInScope(scope, parent, { + ...parent, + retry: { + attemptCount: 0, + lastAttemptAt: observedAt, + lastSuccessfulObservationAt: observedAt, + nextAttemptAt: observedAt + 1_000, + }, + }); + return { parent: next, child }; + }); + + if ( + outcome.parent.state === 'source_inflight' && + outcome.child?.state === 'executing' && + outcome.child.parentExecutionPhase === 'melt_authorized' + ) { + return this.driveSource(parentId, true); + } + if (outcome.parent.state === 'destination_funded') { + return this.authorizeAndIssueDestination(parentId); + } + return outcome.parent; + } + + private async advanceSourceFunded( + parent: MintSwapOperation, + child: FinalizedMeltOperation, + ): Promise { + return this.repositories.withTransaction(async (scope) => { + const current = await this.requireOperationInScope(scope, parent.id); + if (current.state !== 'source_inflight') return current; + const persisted = await scope.meltOperationRepository.getById(child.id); + if (!persisted || persisted.state !== 'finalized') { + throw new Error('Canonical finalized source child is missing'); + } + return this.advanceSourceFundedInScope(scope, current, persisted); + }); + } + + private async advanceSourceFundedInScope( + scope: RepositoryTransactionScope, + parent: MintSwapOperation, + child: FinalizedMeltOperation, + ): Promise { + const settlement = this.calculateSettlement(parent, child); + const candidate: MintSwapOperation = { + ...parent, + state: 'destination_funded', + settlement, + retry: { attemptCount: 0, lastSuccessfulObservationAt: this.now() }, + }; + validateMintSwapAccounting({ + ...candidate, + revision: parent.revision + 1, + updatedAt: Math.max(parent.updatedAt, this.now()), + }); + return this.replaceInScope(scope, parent, candidate, 'mint-swap-op:destination-funded'); + } + + private async finishReclaimedSource( + parent: MintSwapOperation, + reclaimedAt: number, + ): Promise { + return this.repositories.withTransaction(async (scope) => { + const current = await this.requireOperationInScope(scope, parent.id); + if (current.state !== 'source_inflight') return current; + return this.finishReclaimedSourceInScope(scope, current, reclaimedAt); + }); + } + + private finishReclaimedSourceInScope( + scope: RepositoryTransactionScope, + parent: MintSwapOperation, + reclaimedAt: number, + ): Promise { + const cancelled = parent.cancellationRequestedAt !== undefined; + const at = Math.max(this.now(), reclaimedAt, parent.updatedAt); + return this.replaceInScope( + scope, + parent, + cancelled + ? { + ...parent, + state: 'cancelled', + sourceReclaimedAt: reclaimedAt, + cancelledAt: at, + } + : { + ...parent, + state: 'failed', + sourceReclaimedAt: reclaimedAt, + terminalFailure: { + code: 'source_unpaid', + reason: 'Source payment was not completed and its proofs were reclaimed', + at, + }, + }, + cancelled ? 'mint-swap-op:cancelled' : 'mint-swap-op:failed', + cancelled ? undefined : 'source_unpaid', + ); + } + + private async authorizeAndIssueDestination(operationId: string): Promise { + const authorized = await this.repositories.withTransaction(async (scope) => { + const parent = await this.requireOperationInScope(scope, operationId); + if (parent.state !== 'destination_funded') return false; + await this.mintOperationService.authorizeOwnedExecutionInTransaction( + parent.destinationMintOperationId!, + parent.id, + scope, + ); + const now = this.now(); + await this.replaceInScope( + scope, + parent, + { + ...parent, + state: 'issuing', + destinationIssueAuthorizedAt: now, + retry: { + attemptCount: 0, + lastSuccessfulObservationAt: now, + nextAttemptAt: now + this.leaseDurationMs, + }, + }, + 'mint-swap-op:issuing', + ); + return true; + }); + if (!authorized) return this.requireOperation(operationId); + return this.issueDestination(operationId); + } + + private async issueDestination(operationId: string): Promise { + const parent = await this.requireOperation(operationId); + if (parent.state !== 'issuing') return parent; + const child = await this.repositories.mintOperationRepository.getById( + parent.destinationMintOperationId!, + ); + if (!child || child.parentSwapOperationId !== parent.id) { + return this.moveToAttention( + parent, + 'ownership_conflict', + 'Mint swap destination child ownership no longer matches its parent', + 'destination child ownership', + ); + } + if (child.state === 'finalized') return this.completeDestination(parent, child); + if (child.state !== 'executing') { + return this.moveToAttention( + parent, + 'source_paid_destination_terminal', + 'Paid source cannot advance its destination child', + 'destination child progress state', + { childState: child.state }, + ); + } + + try { + const result = await this.mintOperationService.executeOwnedRemote(child.id, parent.id); + if (result.status === 'ALREADY_ISSUED') { + return this.moveToAttention( + parent, + 'destination_proofs_unrecoverable', + 'Destination issuance was consumed but deterministic proofs were not recoverable', + 'destination proof restoration', + ); + } + if (result.status === 'FAILED') { + return this.moveToAttention( + parent, + 'source_paid_destination_terminal', + 'Destination mint rejected issuance after source payment', + 'source-paid destination delivery', + ); + } + return this.repositories.withTransaction(async (scope) => { + const current = await this.requireOperationInScope(scope, parent.id); + if (current.state !== 'issuing') return current; + const finalized = await this.mintOperationService.applyOwnedExecutionInTransaction( + child.id, + parent.id, + result, + scope, + ); + if (finalized.state !== 'finalized') return current; + return this.completeDestinationInScope(scope, current, finalized); + }); + } catch (error) { + await this.recordRetry(parent.id, error); + throw error; + } + } + + private async completeDestination( + parent: MintSwapOperation, + child: FinalizedMintOperation, + ): Promise { + return this.repositories.withTransaction(async (scope) => { + const current = await this.requireOperationInScope(scope, parent.id); + if (current.state !== 'issuing') return current; + return this.completeDestinationInScope(scope, current, child); + }); + } + + private completeDestinationInScope( + scope: RepositoryTransactionScope, + parent: MintSwapOperation, + child: FinalizedMintOperation, + ): Promise { + if (!parent.settlement || !child.amount.equals(parent.destinationAmount)) { + return this.moveToAttentionInScope( + scope, + parent, + 'accounting_mismatch', + 'Destination issuance amount does not match the exact receive amount', + 'destination exact-receive accounting', + ); + } + const completedAt = this.now(); + return this.replaceInScope( + scope, + parent, + { + ...parent, + state: 'completed', + settlement: { + ...parent.settlement, + destinationAmountIssued: child.amount, + }, + completedAt, + retry: { attemptCount: 0, lastSuccessfulObservationAt: completedAt }, + }, + 'mint-swap-op:completed', + ); + } + + private async buildPreparedPlan( + parent: MintSwapOperation, + child: PreparedMeltOperation, + destinationQuote: MintQuote<'bolt11'>, + sourceQuote: MeltQuote<'bolt11'>, + sourceWallet: Wallet, + requiredDispatchWindowSeconds?: number, + ): Promise> { + const inputs = await this.repositories.proofRepository.getProofsBySecrets( + child.mintUrl, + child.inputProofSecrets, + ); + if (inputs.length !== child.inputProofSecrets.length) { + throw new Error('Prepared source inputs are no longer available'); + } + const sourcePreparationFee = child.swap_fee; + const sourceMeltInputFee = this.sourceMeltInputFee(child, inputs, sourceWallet); + const sourceKeepAmount = this.sourceKeepAmount(child); + const minimumSourceDebit = child.amount.add(sourcePreparationFee).add(sourceMeltInputFee); + const maximumSourceDebit = child.needsSwap + ? child.inputAmount.subtract(sourceKeepAmount) + : child.inputAmount; + if (maximumSourceDebit.greaterThan(child.inputAmount)) { + throw new Error('Prepared source plan does not reserve its maximum debit'); + } + const dispatch = evaluateMintSwapDispatchWindow({ + expiries: [destinationQuote.expiry, sourceQuote.expiry], + now: Math.floor(this.now() / 1_000), + requiredWindowSeconds: requiredDispatchWindowSeconds, + }); + if (!dispatch.canDispatch) throw new Error('Mint swap quote expiry window is too short'); + return { + dispatchDeadlineSeconds: dispatch.dispatchDeadlineSeconds, + requiredDispatchWindowSeconds: dispatch.requiredWindowSeconds, + sourceMeltAmount: child.amount, + sourceFeeReserve: child.fee_reserve, + sourcePreparationFee, + sourceMeltInputFee, + minimumSourceDebit, + maximumSourceDebit, + reservedSourceAmount: child.inputAmount, + }; + } + + private calculateSettlement( + parent: MintSwapOperation, + child: FinalizedMeltOperation, + ): MintSwapSettlement { + const plan = parent.preparedPlan!; + if (child.effectiveFee === undefined) { + throw new Error('Finalized source child is missing canonical settlement amounts'); + } + if (child.effectiveFee.lessThan(plan.sourceMeltInputFee)) { + throw new Error('Source effective fee is below its persisted melt input fee'); + } + const sourcePaymentFee = child.effectiveFee.subtract(plan.sourceMeltInputFee); + const totalSourceFee = plan.sourcePreparationFee + .add(plan.sourceMeltInputFee) + .add(sourcePaymentFee); + const sourceMeltChangeAmount = child.changeAmount ?? Amount.zero(); + const sourceKeepAmount = this.sourceKeepAmount(child); + const sourceReturnedAmount = sourceKeepAmount.add(sourceMeltChangeAmount); + const finalSourceDebit = plan.reservedSourceAmount.subtract(sourceReturnedAmount); + return { + sourcePaymentFee, + totalSourceFee, + sourceMeltChangeAmount, + sourceKeepAmount, + sourceReturnedAmount, + finalSourceDebit, + }; + } + + private sourceMeltInputFee( + child: PreparedMeltOperation, + inputs: Proof[], + wallet: Wallet, + ): Amount { + if (!child.needsSwap) return wallet.getFeesForProofs(inputs); + if (!child.swapOutputData) throw new Error('Source pre-swap output plan is missing'); + return this.sumOutputs(child.swapOutputData.send) + .subtract(child.amount) + .subtract(child.fee_reserve); + } + + private sourceKeepAmount(child: PreparedMeltOperation | FinalizedMeltOperation): Amount { + return child.swapOutputData ? this.sumOutputs(child.swapOutputData.keep) : Amount.zero(); + } + + private async assertPreparedPlan(operation: MintSwapOperation): Promise { + const [destinationChild, sourceChild] = await Promise.all([ + this.repositories.mintOperationRepository.getById(operation.destinationMintOperationId!), + this.repositories.meltOperationRepository.getById(operation.sourceMeltOperationId!), + ]); + if ( + !destinationChild || + destinationChild.parentSwapOperationId !== operation.id || + !sourceChild || + sourceChild.parentSwapOperationId !== operation.id + ) { + throw new Error('Mint swap child ownership no longer matches the prepared plan'); + } + if (!('outputData' in destinationChild) || !('inputProofSecrets' in sourceChild)) { + throw new Error('Mint swap child recovery material is incomplete'); + } + const plan = operation.preparedPlan!; + const fingerprint = createMintSwapPreparedPlanFingerprint({ + destinationMintOperationId: destinationChild.id, + sourceMeltOperationId: sourceChild.id, + destinationQuoteRef: operation.destinationQuoteRef!, + sourceQuoteRef: operation.sourceQuoteRef!, + destinationNut20Key: operation.destinationNut20Key, + destinationAmount: operation.destinationAmount, + unit: 'sat', + sourceInputProofSecrets: sourceChild.inputProofSecrets, + destinationOutputData: destinationChild.outputData, + sourceOutputData: this.sourceOutputData(sourceChild), + sourceMeltAmount: plan.sourceMeltAmount, + sourceFeeReserve: plan.sourceFeeReserve, + sourcePreparationFee: plan.sourcePreparationFee, + sourceMeltInputFee: plan.sourceMeltInputFee, + minimumSourceDebit: plan.minimumSourceDebit, + maximumSourceDebit: plan.maximumSourceDebit, + reservedSourceAmount: plan.reservedSourceAmount, + dispatchDeadlineSeconds: plan.dispatchDeadlineSeconds, + requiredDispatchWindowSeconds: plan.requiredDispatchWindowSeconds, + }); + if (fingerprint !== plan.fingerprint) { + throw new Error('Mint swap child data no longer matches the prepared plan'); + } + } + + private assertDispatchWindow(operation: MintSwapOperation): void { + const plan = operation.preparedPlan!; + const remaining = plan.dispatchDeadlineSeconds - Math.floor(this.now() / 1_000); + if (remaining < plan.requiredDispatchWindowSeconds) { + throw new Error('Mint swap dispatch safety window has elapsed'); + } + } + + private async assertPreflight( + sourceMintUrl: string, + destinationMintUrl: string, + amount: Amount, + ): Promise { + const [sourceTrusted, destinationTrusted] = await Promise.all([ + this.mintService.isTrustedMint(sourceMintUrl), + this.mintService.isTrustedMint(destinationMintUrl), + ]); + if (!sourceTrusted || !destinationTrusted) { + throw new Error('Mint swap requires two explicitly trusted mints'); + } + await Promise.all([ + this.mintService.assertMethodUnitSupported(destinationMintUrl, 4, 'bolt11', { + amount, + unit: 'sat', + }), + this.mintService.assertMethodUnitSupported(sourceMintUrl, 5, 'bolt11', { + amount, + unit: 'sat', + }), + this.mintService.assertNutSupported(destinationMintUrl, 20, 'mint swap destination claim'), + ]); + } + + private assertDestinationQuote( + quote: MintQuote, + operation: MintSwapOperation, + ): asserts quote is MintQuote<'bolt11'> { + const amount = getMintQuoteAmount(quote); + if ( + quote.method !== 'bolt11' || + quote.mintUrl !== operation.destinationMintUrl || + quote.unit !== 'sat' || + !amount?.equals(operation.destinationAmount) || + quote.pubkey !== operation.destinationNut20Key.publicKey + ) { + throw new Error('Destination quote does not match the locked mint swap intent'); + } + } + + private assertSourceQuote( + quote: MeltQuote, + destinationQuote: MintQuote<'bolt11'>, + operation: MintSwapOperation, + ): asserts quote is MeltQuote<'bolt11'> { + if ( + quote.method !== 'bolt11' || + quote.mintUrl !== operation.sourceMintUrl || + quote.unit !== 'sat' || + !quote.amount.equals(operation.destinationAmount) || + quote.request !== destinationQuote.request + ) { + throw new Error('Source quote does not pay the locked destination invoice exactly'); + } + } + + private async requireDestinationQuote( + operation: MintSwapOperation, + ): Promise> { + const ref = operation.destinationQuoteRef!; + const quote = await this.quoteLifecycle.getMintQuote(ref.mintUrl, ref.method, ref.quoteId); + if (!quote) throw new Error('Mint swap destination quote is missing'); + this.assertDestinationQuote(quote, operation); + return quote; + } + + private async requireSourceQuote(operation: MintSwapOperation): Promise> { + const ref = operation.sourceQuoteRef!; + const quote = await this.quoteLifecycle.getMeltQuote(ref.mintUrl, ref.method, ref.quoteId); + if (!quote) throw new Error('Mint swap source quote is missing'); + const destinationQuote = await this.requireDestinationQuote(operation); + this.assertSourceQuote(quote, destinationQuote, operation); + return quote; + } + + private async moveToAttention( + operation: MintSwapOperation, + reason: MintSwapAttentionReason, + message: string, + violatedInvariant: string, + evidence: Record = {}, + ): Promise { + return this.repositories.withTransaction(async (scope) => { + const current = await this.requireOperationInScope(scope, operation.id); + if (current.state === 'needs_attention' || isTerminalMintSwapState(current.state)) { + return current; + } + return this.moveToAttentionInScope( + scope, + current, + reason, + message, + violatedInvariant, + evidence, + ); + }); + } + + private moveToAttentionInScope( + scope: RepositoryTransactionScope, + operation: MintSwapOperation, + reason: MintSwapAttentionReason, + message: string, + violatedInvariant: string, + evidence: Record = {}, + ): Promise { + const at = this.now(); + return this.replaceInScope( + scope, + operation, + { + ...operation, + state: 'needs_attention', + preparationLease: undefined, + attention: { + reason, + message, + lastSafeState: operation.state, + violatedInvariant, + evidence, + at, + }, + }, + 'mint-swap-op:needs-attention', + reason, + ); + } + + private async failPreparation(operationId: string, error: unknown): Promise { + try { + await this.repositories.withTransaction(async (scope) => { + const operation = await this.requireOperationInScope(scope, operationId); + if (operation.state !== 'preparing') return; + const at = this.now(); + await this.replaceInScope( + scope, + operation, + { + ...operation, + state: 'failed', + preparationLease: undefined, + terminalFailure: { + code: 'preparation_failed', + reason: 'Mint swap preparation could not be completed', + at, + }, + }, + 'mint-swap-op:failed', + 'preparation_failed', + ); + }); + } catch (recoveryError) { + this.logger?.warn('Mint swap preparation cleanup failed', { + operationId, + error: recoveryError instanceof Error ? recoveryError.message : String(recoveryError), + }); + } + this.logger?.warn('Mint swap preparation failed', { + operationId, + error: error instanceof Error ? error.message : String(error), + }); + } + + private failPreparedBeforeDispatch(operation: MintSwapOperation): Promise { + return this.repositories.withTransaction(async (scope) => { + const current = await this.requireOperationInScope(scope, operation.id); + if (current.state !== 'prepared') return current; + await this.meltOperationService.rollbackOwnedPreparedInTransaction( + current.sourceMeltOperationId!, + current.id, + scope, + 'Mint swap dispatch safety window elapsed', + ); + const at = this.now(); + return this.replaceInScope( + scope, + current, + { + ...current, + state: 'failed', + terminalFailure: { + code: 'dispatch_window_elapsed', + reason: 'Mint swap dispatch safety window elapsed before source authorization', + at, + }, + }, + 'mint-swap-op:failed', + 'dispatch_window_elapsed', + ); + }); + } + + private async recordRetry(operationId: string, error: unknown): Promise { + try { + await this.repositories.withTransaction(async (scope) => { + const operation = await this.requireOperationInScope(scope, operationId); + if ( + operation.state !== 'preparing' && + operation.state !== 'source_inflight' && + operation.state !== 'destination_funded' && + operation.state !== 'issuing' + ) { + return; + } + const now = this.now(); + const attemptCount = operation.retry.attemptCount + 1; + const delay = Math.min(60_000, 1_000 * 2 ** Math.min(attemptCount - 1, 6)); + await this.replaceInScope( + scope, + operation, + { + ...operation, + retry: { + ...operation.retry, + attemptCount, + lastAttemptAt: now, + nextAttemptAt: now + delay, + lastError: 'Mint swap reconciliation failed; retry is scheduled', + }, + }, + 'mint-swap-op:delayed', + 'retry_scheduled', + ); + }); + } catch (retryError) { + this.logger?.warn('Mint swap retry state could not be recorded', { + operationId, + error: retryError instanceof Error ? retryError.message : String(retryError), + }); + } + void error; + } + + private async replaceInScope( + scope: RepositoryTransactionScope, + current: MintSwapOperation, + candidate: MintSwapOperation, + eventType?: MintSwapEventType, + reasonCode?: string, + ): Promise { + const now = Math.max( + current.updatedAt, + this.now(), + candidate.retry.lastAttemptAt ?? 0, + candidate.retry.lastSuccessfulObservationAt ?? 0, + candidate.sourceDispatchAuthorizedAt ?? 0, + candidate.sourceReclaimedAt ?? 0, + candidate.destinationIssueAuthorizedAt ?? 0, + candidate.cancellationRequestedAt ?? 0, + candidate.cancelledAt ?? 0, + candidate.completedAt ?? 0, + candidate.attention?.at ?? 0, + candidate.terminalFailure?.at ?? 0, + ); + const next: MintSwapOperation = { + ...candidate, + revision: current.revision + 1, + updatedAt: now, + }; + const repository = requireMintSwapRepositoryCapability(scope).mintSwapOperationRepository; + if (!(await repository.compareAndSet(next, current.revision))) { + throw new MintSwapCasError(`Mint swap ${current.id} lost revision ${current.revision}`); + } + if (eventType) { + await requireMintSwapRepositoryCapability(scope).operationEventOutboxRepository.enqueue( + this.outbox(next, eventType, reasonCode), + ); + } + return next; + } + + private outbox( + operation: MintSwapOperation, + eventType: MintSwapEventType, + reasonCode?: string, + ): OperationEventOutboxRecord { + return { + id: `${operation.id}:${operation.revision}:${eventType}`, + operationId: operation.id, + revision: operation.revision, + eventType, + payload: { + operationId: operation.id, + revision: operation.revision, + state: operation.state, + sourceMintUrl: operation.sourceMintUrl, + destinationMintUrl: operation.destinationMintUrl, + unit: 'sat', + destinationAmount: operation.destinationAmount.toString(), + reasonCode, + }, + createdAt: operation.updatedAt, + publishAttempts: 0, + }; + } + + private async requirePreparingLeaseInScope( + scope: RepositoryTransactionScope, + operationId: string, + expected: MintSwapPreparationLease, + ): Promise { + const current = await this.requireOperationInScope(scope, operationId); + assertMintSwapPreparationLeaseOwner(current, expected.ownerId, expected.token, this.now()); + if (current.preparationLease?.stage !== expected.stage) { + throw new MintSwapCasError(`Mint swap ${operationId} preparation stage changed`); + } + return current; + } + + private newLease( + stage: MintSwapPreparationLease['stage'], + now: number, + ): MintSwapPreparationLease { + return { + ownerId: this.workerId, + token: this.generateId(), + stage, + acquiredAt: now, + expiresAt: now + this.leaseDurationMs, + }; + } + + private advanceLease( + lease: MintSwapPreparationLease, + stage: MintSwapPreparationLease['stage'], + ): MintSwapPreparationLease { + return { + ...lease, + stage, + expiresAt: Math.max(lease.expiresAt, this.now() + this.leaseDurationMs), + }; + } + + private quoteRef(quote: MintQuote<'bolt11'> | MeltQuote<'bolt11'>) { + return { mintUrl: quote.mintUrl, method: 'bolt11' as const, quoteId: quote.quoteId }; + } + + private childId(parentId: string, role: 'source' | 'destination'): string { + return `${parentId}:${role}`; + } + + private sumOutputs(outputs: Array<{ blindedMessage: { amount: string | number } }>): Amount { + return outputs.reduce( + (total, output) => total.add(Amount.from(output.blindedMessage.amount)), + Amount.zero(), + ); + } + + private sourceOutputData( + child: Pick, + ) { + return child.swapOutputData + ? { change: child.changeOutputData, swap: child.swapOutputData } + : { change: child.changeOutputData }; + } + + private parentRepository() { + return requireMintSwapRepositoryCapability(this.repositories).mintSwapOperationRepository; + } + + private async requireOperation(operationId: string): Promise { + const operation = await this.parentRepository().getById(operationId); + if (!operation) throw new Error(`Mint swap ${operationId} was not found`); + return operation; + } + + private async requireOperationInScope( + scope: RepositoryTransactionScope, + operationId: string, + ): Promise { + const operation = + await requireMintSwapRepositoryCapability(scope).mintSwapOperationRepository.getById( + operationId, + ); + if (!operation) throw new Error(`Mint swap ${operationId} was not found`); + return operation; + } + + private async listAllStates(): Promise { + const states: MintSwapOperationState[] = [ + 'preparing', + 'prepared', + 'source_inflight', + 'destination_funded', + 'issuing', + 'completed', + 'cancelled', + 'failed', + 'needs_attention', + ]; + const operations = ( + await Promise.all(states.map((state) => this.parentRepository().getByState(state))) + ).flat(); + return operations.sort( + (left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id), + ); + } +} diff --git a/packages/core/operations/mintSwap/MintSwapPolicy.ts b/packages/core/operations/mintSwap/MintSwapPolicy.ts new file mode 100644 index 00000000..ed9c67ef --- /dev/null +++ b/packages/core/operations/mintSwap/MintSwapPolicy.ts @@ -0,0 +1,48 @@ +export const DEFAULT_MINT_SWAP_DISPATCH_WINDOW_SECONDS = 120; +export const MIN_MINT_SWAP_DISPATCH_WINDOW_SECONDS = 30; + +export interface MintSwapDispatchWindowInput { + expiries: Array; + now?: number; + requiredWindowSeconds?: number; +} + +export interface MintSwapDispatchWindow { + dispatchDeadlineSeconds: number; + remainingSeconds: number; + requiredWindowSeconds: number; + canDispatch: boolean; +} + +/** Evaluate the earliest finite quote deadline before source payment. */ +export function evaluateMintSwapDispatchWindow( + input: MintSwapDispatchWindowInput, +): MintSwapDispatchWindow { + const requiredWindowSeconds = + input.requiredWindowSeconds ?? DEFAULT_MINT_SWAP_DISPATCH_WINDOW_SECONDS; + if ( + !Number.isSafeInteger(requiredWindowSeconds) || + requiredWindowSeconds < MIN_MINT_SWAP_DISPATCH_WINDOW_SECONDS + ) { + throw new Error( + `Mint swap dispatch window must be at least ${MIN_MINT_SWAP_DISPATCH_WINDOW_SECONDS} seconds`, + ); + } + const deadlines = input.expiries.filter( + (expiry): expiry is number => expiry !== null && expiry !== undefined && expiry !== 0, + ); + if ( + deadlines.some((expiry) => !Number.isSafeInteger(expiry) || expiry < 0) || + deadlines.length === 0 + ) { + throw new Error('Mint swap dispatch requires valid finite quote expiry evidence'); + } + const dispatchDeadlineSeconds = Math.min(...deadlines); + const remainingSeconds = dispatchDeadlineSeconds - (input.now ?? Math.floor(Date.now() / 1_000)); + return { + dispatchDeadlineSeconds, + remainingSeconds, + requiredWindowSeconds, + canDispatch: remainingSeconds >= requiredWindowSeconds, + }; +} diff --git a/packages/core/test/fixtures/MintSwap.ts b/packages/core/test/fixtures/MintSwap.ts index 8ddcf459..87e1ce90 100644 --- a/packages/core/test/fixtures/MintSwap.ts +++ b/packages/core/test/fixtures/MintSwap.ts @@ -24,6 +24,7 @@ export function makePreparingMintSwapOperation( destinationMintUrl: 'https://destination.mint.test', unit: 'sat', destinationAmount: Amount.from(1_000), + requiredDispatchWindowSeconds: 120, destinationNut20Key: { ...destinationNut20Key }, preparationLease: { ownerId: 'worker-a', @@ -79,14 +80,17 @@ export function makePreparedMintSwapOperation( send: [], }, sourceOutputData: { - keep: [], - send: [ - { - blindedMessage: { amount: '1025', id: 'source-keyset', B_: 'source-B' }, - blindingFactor: '02', - secret: '736f757263652d6f7574707574', - }, - ], + change: { keep: [], send: [] }, + swap: { + keep: [], + send: [ + { + blindedMessage: { amount: '1025', id: 'source-keyset', B_: 'source-B' }, + blindingFactor: '02', + secret: '736f757263652d6f7574707574', + }, + ], + }, }, sourceMeltAmount: destinationAmount, sourceFeeReserve, @@ -107,6 +111,7 @@ export function makePreparedMintSwapOperation( destinationMintUrl: 'https://destination.mint.test', unit: 'sat', destinationAmount, + requiredDispatchWindowSeconds: 120, destinationNut20Key: { ...destinationNut20Key }, destinationQuoteRef, destinationMintOperationId: 'destination-mint-op', diff --git a/packages/core/test/unit/MeltOperationService.test.ts b/packages/core/test/unit/MeltOperationService.test.ts index 21babc6b..dac2a2f5 100644 --- a/packages/core/test/unit/MeltOperationService.test.ts +++ b/packages/core/test/unit/MeltOperationService.test.ts @@ -312,6 +312,7 @@ describe('MeltOperationService', () => { }), restoreProofsToReady: mock(async (proofMintUrl: string, secrets: string[]) => { await repositories.proofRepository.setProofState(proofMintUrl, secrets, 'ready'); + await repositories.proofRepository.releaseProofs(proofMintUrl, secrets); }), reserveProofs: mock( async (proofMintUrl: string, secrets: string[], operationId: string) => { @@ -829,6 +830,74 @@ describe('MeltOperationService', () => { 'executing', ); }); + + it('observes an unspent authorized pre-swap and reclaims it transactionally', async () => { + const repositories = new MemoryRepositories(); + const operation = { + ...makePreparedOp('owned-unspent-pre-swap', { + parentSwapOperationId, + needsSwap: true, + inputProofSecrets: ['owned-unspent-input'], + swapOutputData: { + keep: [], + send: [ + { + blindedMessage: { amount: 101, id: keysetId, B_: 'owned-recovery-B' }, + blindingFactor: '01', + secret: '6f776e65642d7265636f76657279', + }, + ], + }, + }), + state: 'executing' as const, + parentExecutionPhase: 'pre_swap_authorized' as const, + }; + await repositories.proofRepository.saveProofs(mintUrl, [ + makeProof('owned-unspent-input', { + amount: Amount.from(101), + state: 'inflight', + usedByOperationId: operation.id, + }), + ]); + await repositories.meltOperationRepository.create(operation); + const recoveryAdapter = { + checkProofStates: mock(async () => [{ state: 'UNSPENT' }]), + } as unknown as MintAdapter; + const ownedService = new MeltOperationService( + handlerProvider, + repositories.meltOperationRepository, + quoteLifecycle, + repositories.proofRepository, + proofService, + mintService, + walletService, + recoveryAdapter, + eventBus, + logger, + ); + + const observation = await ownedService.observeOwnedRecovery( + operation.id, + parentSwapOperationId, + ); + expect(observation.status).toBe('ORIGINAL_INPUTS_RECLAIMABLE'); + + await repositories.withTransaction((transaction) => + ownedService.reclaimOwnedPreSwapInTransaction( + operation.id, + parentSwapOperationId, + transaction, + ), + ); + + const stored = await repositories.meltOperationRepository.getById(operation.id); + expect(stored?.state).toBe('failed'); + expect(stored?.parentExecutionPhase).toBe('pre_swap_authorized'); + expect( + (await repositories.proofRepository.getProofBySecret(mintUrl, 'owned-unspent-input')) + ?.state, + ).toBe('ready'); + }); }); describe('quotes', () => { diff --git a/packages/core/test/unit/MintSwapOperation.test.ts b/packages/core/test/unit/MintSwapOperation.test.ts index 80055d00..3503b62c 100644 --- a/packages/core/test/unit/MintSwapOperation.test.ts +++ b/packages/core/test/unit/MintSwapOperation.test.ts @@ -405,14 +405,17 @@ describe('MintSwapOperation', () => { unit: 'sat' as const, sourceInputProofSecrets: ['a', 'b'], sourceOutputData: { - keep: [], - send: [ - { - blindedMessage: { amount: '1025', id: 'source-keyset', B_: 'source-B' }, - blindingFactor: '02', - secret: '62', - }, - ], + change: { keep: [], send: [] }, + swap: { + keep: [], + send: [ + { + blindedMessage: { amount: '1025', id: 'source-keyset', B_: 'source-B' }, + blindingFactor: '02', + secret: '62', + }, + ], + }, }, sourceMeltAmount: prepared.preparedPlan!.sourceMeltAmount, sourceFeeReserve: prepared.preparedPlan!.sourceFeeReserve, diff --git a/packages/core/test/unit/MintSwapOperationService.test.ts b/packages/core/test/unit/MintSwapOperationService.test.ts new file mode 100644 index 00000000..0323d4f9 --- /dev/null +++ b/packages/core/test/unit/MintSwapOperationService.test.ts @@ -0,0 +1,429 @@ +import { Amount, type Proof } from '@cashu/cashu-ts'; +import { beforeEach, describe, expect, it } from 'bun:test'; + +import { meltQuoteFromBolt11Response } from '../../models/MeltQuote.ts'; +import { mintQuoteFromBolt11Response } from '../../models/MintQuote.ts'; +import type { RepositoryTransactionScope } from '../../repositories/index.ts'; +import { MemoryRepositories } from '../../repositories/memory/MemoryRepositories.ts'; +import type { + ExecutingMeltOperation, + FinalizedMeltOperation, + PreparedMeltOperation, +} from '../../operations/melt/MeltOperation.ts'; +import type { + ExecutingMintOperation, + FinalizedMintOperation, + PendingMintOperation, +} from '../../operations/mint/MintOperation.ts'; +import { MintSwapOperationService } from '../../operations/mintSwap/MintSwapOperationService.ts'; + +describe('MintSwapOperationService', () => { + const sourceMintUrl = 'https://source.mint.test'; + const destinationMintUrl = 'https://destination.mint.test'; + const destinationKey = `02${'11'.repeat(32)}`; + const now = 1_800_000_000_000; + let clock: number; + let repositories: MemoryRepositories; + let service: MintSwapOperationService; + let secondService: MintSwapOperationService; + let remoteSourceCalls: number; + let remoteDestinationCalls: number; + let sourceRemoteState: 'PAID' | 'PENDING' | 'UNPAID'; + + beforeEach(async () => { + repositories = new MemoryRepositories(); + await repositories.proofRepository.saveProofs(sourceMintUrl, [ + { + mintUrl: sourceMintUrl, + id: 'source-keyset', + amount: Amount.from(106), + secret: 'source-input', + C: 'source-C', + unit: 'sat', + state: 'ready', + }, + ]); + remoteSourceCalls = 0; + remoteDestinationCalls = 0; + sourceRemoteState = 'PAID'; + clock = now; + + const destinationQuote = mintQuoteFromBolt11Response(destinationMintUrl, { + quote: 'destination-quote', + request: 'lnbc1locked', + method: 'bolt11', + amount: Amount.from(100), + unit: 'sat', + expiry: Math.floor(now / 1_000) + 600, + state: 'UNPAID', + pubkey: destinationKey, + amount_paid: Amount.zero(), + amount_issued: Amount.zero(), + updated_at: null, + }); + const sourceQuote = meltQuoteFromBolt11Response(sourceMintUrl, { + quote: 'source-quote', + request: destinationQuote.request, + amount: Amount.from(100), + unit: 'sat', + fee_reserve: Amount.from(5), + expiry: Math.floor(now / 1_000) + 600, + state: 'UNPAID', + payment_preimage: null, + }); + const quoteLifecycle = { + createMintQuote: async () => { + await repositories.mintQuoteRepository.upsertMintQuote(destinationQuote); + return destinationQuote; + }, + createMeltQuote: async () => { + await repositories.meltQuoteRepository.upsertMeltQuote(sourceQuote); + return sourceQuote; + }, + getMintQuote: (...args: Parameters) => + repositories.mintQuoteRepository.getMintQuote(...args), + getMeltQuote: (...args: Parameters) => + repositories.meltQuoteRepository.getMeltQuote(...args), + }; + + const mintOperationService = { + planOwnedPreparation: async ({ operationId, parentSwapOperationId }: any) => + ({ + id: operationId, + state: 'pending', + mintUrl: destinationMintUrl, + method: 'bolt11', + methodData: {}, + quoteId: destinationQuote.quoteId, + amount: Amount.from(100), + unit: 'sat', + request: destinationQuote.request, + expiry: destinationQuote.expiry, + pubkey: destinationKey, + outputData: { + keep: [ + { + blindedMessage: { amount: '100', id: 'destination-keyset', B_: 'destination-B' }, + blindingFactor: '01', + secret: 'destination-output', + }, + ], + send: [], + }, + parentSwapOperationId, + createdAt: now, + updatedAt: now, + }) as PendingMintOperation, + prepareOwnedInTransaction: async ({ preparedOperation, repositories: scope }: any) => { + await scope.mintOperationRepository.create(preparedOperation); + return preparedOperation; + }, + authorizeOwnedExecutionInTransaction: async ( + id: string, + _parentId: string, + scope: RepositoryTransactionScope, + ) => { + const current = (await scope.mintOperationRepository.getById(id)) as PendingMintOperation; + const executing = { ...current, state: 'executing' as const }; + await scope.mintOperationRepository.update(executing); + return executing; + }, + executeOwnedRemote: async (id: string) => { + remoteDestinationCalls++; + return { + operationId: id, + status: 'ISSUED' as const, + proofs: [ + { + id: 'destination-keyset', + amount: Amount.from(100), + secret: 'destination-output', + C: 'destination-C', + } as Proof, + ], + }; + }, + applyOwnedExecutionInTransaction: async ( + id: string, + _parentId: string, + _result: unknown, + scope: RepositoryTransactionScope, + ) => { + const current = (await scope.mintOperationRepository.getById(id)) as ExecutingMintOperation; + const finalized = { ...current, state: 'finalized' as const }; + await scope.mintOperationRepository.update(finalized); + return finalized as FinalizedMintOperation; + }, + }; + + const meltOperationService = { + planOwnedPreparation: async ({ operationId, parentSwapOperationId }: any) => + ({ + id: operationId, + state: 'prepared', + mintUrl: sourceMintUrl, + method: 'bolt11', + methodData: { invoice: destinationQuote.request }, + quoteId: sourceQuote.quoteId, + amount: Amount.from(100), + fee_reserve: Amount.from(5), + swap_fee: Amount.zero(), + needsSwap: false, + inputAmount: Amount.from(106), + inputProofSecrets: ['source-input'], + changeOutputData: { keep: [], send: [] }, + unit: 'sat', + parentSwapOperationId, + createdAt: now, + updatedAt: now, + }) as PreparedMeltOperation, + prepareOwnedInTransaction: async ({ preparedOperation, repositories: scope }: any) => { + await scope.proofRepository.reserveProofs( + sourceMintUrl, + preparedOperation.inputProofSecrets, + preparedOperation.id, + ); + await scope.meltOperationRepository.create(preparedOperation); + return preparedOperation; + }, + rollbackOwnedPreparedInTransaction: async ( + id: string, + _parentId: string, + scope: RepositoryTransactionScope, + ) => { + const current = (await scope.meltOperationRepository.getById(id)) as PreparedMeltOperation; + await scope.proofRepository.releaseProofs(sourceMintUrl, current.inputProofSecrets); + const rolledBack = { ...current, state: 'rolled_back' as const }; + await scope.meltOperationRepository.update(rolledBack); + return rolledBack; + }, + authorizeOwnedExecutionInTransaction: async ( + id: string, + _parentId: string, + scope: RepositoryTransactionScope, + ) => { + const current = (await scope.meltOperationRepository.getById(id)) as PreparedMeltOperation; + await scope.proofRepository.setProofState( + sourceMintUrl, + current.inputProofSecrets, + 'inflight', + ); + const executing: ExecutingMeltOperation = { + ...current, + state: 'executing', + parentExecutionPhase: 'melt_authorized', + }; + await scope.meltOperationRepository.update(executing); + return executing; + }, + executeOwnedRemoteStep: async (id: string) => { + remoteSourceCalls++; + return { + operationId: id, + phase: 'melt' as const, + observedAt: clock + 10, + response: { state: sourceRemoteState, change: [], payment_preimage: 'preimage' }, + }; + }, + observeOwnedRecovery: async (id: string) => ({ + status: 'REMOTE_RESULT' as const, + result: { + operationId: id, + phase: 'melt' as const, + observedAt: clock, + response: { state: sourceRemoteState, change: [], payment_preimage: 'preimage' }, + }, + }), + applyOwnedRemoteStepInTransaction: async ( + id: string, + _parentId: string, + _result: unknown, + scope: RepositoryTransactionScope, + ) => { + const current = (await scope.meltOperationRepository.getById(id)) as ExecutingMeltOperation; + if (sourceRemoteState === 'PENDING') { + const pending = { ...current, state: 'pending' as const }; + await scope.meltOperationRepository.update(pending); + return pending; + } + if (sourceRemoteState === 'UNPAID') { + await scope.proofRepository.setProofState( + sourceMintUrl, + current.inputProofSecrets, + 'ready', + ); + await scope.proofRepository.releaseProofs(sourceMintUrl, current.inputProofSecrets); + const failed = { ...current, state: 'failed' as const }; + await scope.meltOperationRepository.update(failed); + return failed; + } + await scope.proofRepository.setProofState( + sourceMintUrl, + current.inputProofSecrets, + 'spent', + ); + const finalized = { + ...current, + state: 'finalized', + changeAmount: Amount.zero(), + effectiveFee: Amount.from(6), + finalizedData: { preimage: 'preimage' }, + } as unknown as FinalizedMeltOperation<'bolt11'>; + await scope.meltOperationRepository.update(finalized); + return finalized; + }, + }; + + const makeService = (workerId: string, idPrefix: string) => + new MintSwapOperationService( + repositories, + quoteLifecycle as never, + mintOperationService as never, + meltOperationService as never, + { + isTrustedMint: async () => true, + assertMethodUnitSupported: async () => {}, + assertNutSupported: async () => {}, + } as never, + { + getWalletWithActiveKeysetId: async () => ({ + wallet: { getFeesForProofs: () => Amount.from(1) }, + }), + } as never, + { + generateMintQuoteKeyPair: async () => ({ + publicKeyHex: destinationKey, + secretKey: new Uint8Array(32), + derivationIndex: 1, + purpose: 'nut20_mint_quote', + }), + } as never, + { + debug: () => {}, + info: () => {}, + error: () => {}, + warn: (message, context) => { + if (process.env.DEBUG_MINT_SWAP_TEST) console.warn(message, context); + }, + }, + { + now: () => clock, + workerId, + generateId: (() => { + let id = 0; + return () => `${idPrefix}-${++id}`; + })(), + }, + ); + service = makeService('worker-a', 'generated'); + secondService = makeService('worker-b', 'second'); + }); + + it('prepares value-neutrally and completes source before destination issuance', async () => { + const prepared = await service.prepare({ + sourceMintUrl, + destinationMintUrl, + amount: 100, + requiredDispatchWindowSeconds: 180, + }); + + expect(prepared.state).toBe('prepared'); + expect(prepared.requiredDispatchWindowSeconds).toBe(180); + expect(prepared.preparedPlan?.requiredDispatchWindowSeconds).toBe(180); + expect(prepared.preparedPlan?.maximumSourceDebit.toString()).toBe('106'); + expect(remoteSourceCalls).toBe(0); + expect(remoteDestinationCalls).toBe(0); + expect( + (await repositories.proofRepository.getProofBySecret(sourceMintUrl, 'source-input')) + ?.usedByOperationId, + ).toBe(prepared.sourceMeltOperationId); + + const completed = await service.execute(prepared.id); + + expect(completed.state).toBe('completed'); + expect(completed.settlement?.finalSourceDebit.toString()).toBe('106'); + expect(completed.settlement?.destinationAmountIssued?.toString()).toBe('100'); + expect(remoteSourceCalls).toBe(1); + expect(remoteDestinationCalls).toBe(1); + }); + + it('cancels an undispatched plan and releases its source reservation', async () => { + const prepared = await service.prepare({ + sourceMintUrl, + destinationMintUrl, + amount: 100, + }); + const cancelled = await service.cancel(prepared.id); + + expect(cancelled.state).toBe('cancelled'); + expect(remoteSourceCalls).toBe(0); + expect( + (await repositories.proofRepository.getProofBySecret(sourceMintUrl, 'source-input')) + ?.usedByOperationId, + ).toBeUndefined(); + }); + + it('fails an expired prepared plan without dispatching or retaining value', async () => { + const prepared = await service.prepare({ + sourceMintUrl, + destinationMintUrl, + amount: 100, + }); + clock += 600_000; + + const failed = await service.execute(prepared.id); + + expect(failed.state).toBe('failed'); + expect(failed.terminalFailure?.code).toBe('dispatch_window_elapsed'); + expect(remoteSourceCalls).toBe(0); + expect( + (await repositories.proofRepository.getProofBySecret(sourceMintUrl, 'source-input')) + ?.usedByOperationId, + ).toBeUndefined(); + }); + + it('allows only one manager to dispatch each authorized remote effect', async () => { + const prepared = await service.prepare({ + sourceMintUrl, + destinationMintUrl, + amount: 100, + }); + + const [first, second] = await Promise.all([ + service.execute(prepared.id), + secondService.execute(prepared.id), + ]); + const stored = await service.get(prepared.id); + + expect([first.state, second.state]).toContain('completed'); + expect(stored?.state).toBe('completed'); + expect(remoteSourceCalls).toBe(1); + expect(remoteDestinationCalls).toBe(1); + }); + + it('cancels inflight work only after canonical UNPAID reclamation', async () => { + sourceRemoteState = 'PENDING'; + const prepared = await service.prepare({ + sourceMintUrl, + destinationMintUrl, + amount: 100, + }); + const pending = await service.execute(prepared.id); + + expect(pending.state).toBe('source_inflight'); + expect( + (await repositories.meltOperationRepository.getById(pending.sourceMeltOperationId!))?.state, + ).toBe('pending'); + expect((await service.cancel(prepared.id)).state).toBe('source_inflight'); + + sourceRemoteState = 'UNPAID'; + clock += 2_000; + const cancelled = await secondService.reconcile(prepared.id); + + expect(cancelled.state).toBe('cancelled'); + expect(cancelled.sourceReclaimedAt).toBe(clock); + expect( + (await repositories.proofRepository.getProofBySecret(sourceMintUrl, 'source-input'))?.state, + ).toBe('ready'); + }); +});