From 445480202a2ae9a0e24b99af70b79fc64d618f66 Mon Sep 17 00:00:00 2001 From: Egge Date: Wed, 19 Aug 2026 08:27:49 +0000 Subject: [PATCH 1/2] feat: add mint swap operation ownership --- .changeset/operation-ownership-primitives.md | 12 + packages/adapter-tests/src/index.ts | 339 ++++++++++++++++++ packages/core/adapter.ts | 1 + packages/core/index.ts | 1 + packages/core/operations/OperationParent.ts | 5 + packages/core/operations/index.ts | 1 + .../core/operations/melt/MeltOperation.ts | 4 + .../core/operations/mint/MintOperation.ts | 3 + packages/core/repositories/index.ts | 15 +- .../memory/MemoryMeltOperationRepository.ts | 9 +- .../memory/MemoryMintOperationRepository.ts | 24 +- .../MemoryMintOperationRepository.test.ts | 60 ++++ packages/indexeddb/src/lib/db.ts | 3 + .../repositories/MeltOperationRepository.ts | 21 +- .../repositories/MintOperationRepository.ts | 34 +- .../repositories/MeltOperationRepository.ts | 27 +- .../repositories/MintOperationRepository.ts | 60 +++- packages/sql-storage/src/schema.ts | 12 + packages/sql-storage/src/test/schema.test.ts | 45 +++ 19 files changed, 655 insertions(+), 21 deletions(-) create mode 100644 .changeset/operation-ownership-primitives.md create mode 100644 packages/core/operations/OperationParent.ts create mode 100644 packages/core/test/unit/MemoryMintOperationRepository.test.ts diff --git a/.changeset/operation-ownership-primitives.md b/.changeset/operation-ownership-primitives.md new file mode 100644 index 00000000..ca9bae3f --- /dev/null +++ b/.changeset/operation-ownership-primitives.md @@ -0,0 +1,12 @@ +--- +'@cashu/coco-core': minor +'@cashu/coco-adapter-tests': minor +'@cashu/coco-sqlite': minor +'@cashu/coco-sqlite-bun': minor +'@cashu/coco-expo-sqlite': minor +'@cashu/coco-indexeddb': minor +--- + +Add durable Mint Swap parent references to quote-backed Mint and Melt Operations and a narrow +conditional Mint Operation claim for safe ownership composition in persistent repository +transactions. Regular Mint and Melt Operation updates preserve stored ownership metadata. diff --git a/packages/adapter-tests/src/index.ts b/packages/adapter-tests/src/index.ts index 1f9a6817..ba079ad0 100644 --- a/packages/adapter-tests/src/index.ts +++ b/packages/adapter-tests/src/index.ts @@ -494,6 +494,7 @@ function createDummySendOperationsByState(unit: string): SendOperation[] { } type PendingMintOperation = Extract; +type PreparedMeltOperation = Extract; export function createDummyMintQuote( overrides?: Partial>, @@ -583,6 +584,94 @@ export function createDummyInitMintOperation( } satisfies InitMintOperation; } +function createDummyMintOperationsByState(): MintOperation[] { + const pending = createDummyMintOperation({ + id: 'mint-op-pending-ownership', + quoteId: 'quote-pending-ownership', + }); + + return [ + createDummyInitMintOperation({ + id: 'mint-op-init-ownership', + quoteId: 'quote-init-ownership', + }), + pending, + { + ...pending, + id: 'mint-op-executing-ownership', + quoteId: 'quote-executing-ownership', + state: 'executing', + }, + { + ...pending, + id: 'mint-op-finalized-ownership', + quoteId: 'quote-finalized-ownership', + state: 'finalized', + }, + { + ...pending, + id: 'mint-op-failed-ownership', + quoteId: 'quote-failed-ownership', + state: 'failed', + }, + ] satisfies MintOperation[]; +} + +function createDummyPersistedMeltOperationsByState(): MeltOperation[] { + const init = createDummyMeltOperation({ + id: 'melt-op-init-ownership', + quoteId: 'melt-quote-init-ownership', + }); + const prepared = { + ...init, + id: 'melt-op-prepared-ownership', + quoteId: 'melt-quote-prepared-ownership', + state: 'prepared', + amount: Amount.from(3), + fee_reserve: Amount.from(1), + swap_fee: Amount.zero(), + needsSwap: false, + inputAmount: Amount.from(4), + inputProofSecrets: ['melt-input-secret'], + changeOutputData: { keep: [], send: [] }, + } satisfies PreparedMeltOperation; + + return [ + init, + prepared, + { + ...prepared, + id: 'melt-op-executing-ownership', + quoteId: 'melt-quote-executing-ownership', + state: 'executing', + }, + { + ...prepared, + id: 'melt-op-pending-ownership', + quoteId: 'melt-quote-pending-ownership', + state: 'pending', + }, + { + ...prepared, + id: 'melt-op-finalized-ownership', + quoteId: 'melt-quote-finalized-ownership', + state: 'finalized', + }, + { + ...prepared, + id: 'melt-op-rolling-back-ownership', + quoteId: 'melt-quote-rolling-back-ownership', + state: 'rolling_back', + }, + { + ...prepared, + id: 'melt-op-rolled-back-ownership', + quoteId: 'melt-quote-rolled-back-ownership', + state: 'rolled_back', + }, + ] satisfies MeltOperation[]; +} + export function createDummyReceiveOperation(): ReceiveOperation { return { id: 'receive-op', @@ -844,6 +933,192 @@ export async function runMintOperationRepositoryContract( const { describe, it, expect } = runner; describe('MintOperationRepository contract', () => { + it('round-trips ownership metadata across mint operation states', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const operations = createDummyMintOperationsByState().map((operation, index) => ({ + ...operation, + parent: { kind: 'mint-swap' as const, id: `mint-swap-${index}` }, + })) satisfies MintOperation[]; + + for (const operation of operations) { + await repositories.mintOperationRepository.create(operation); + const stored = await repositories.mintOperationRepository.getById(operation.id); + + expect(stored).toBeDefined(); + expect(stored!.parent?.kind).toBe(operation.parent?.kind); + expect(stored!.parent?.id).toBe(operation.parent?.id); + } + } finally { + await dispose(); + } + }); + + it('loads mint operations without ownership metadata as standalone', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const operation = createDummyMintOperation({ id: 'legacy-mint-op' }); + await repositories.mintOperationRepository.create(operation); + + const stored = await repositories.mintOperationRepository.getById(operation.id); + + expect(stored).toBeDefined(); + expect(stored!.parent).toBe(undefined); + } finally { + await dispose(); + } + }); + + it('preserves mint ownership metadata through regular state updates', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const operation = { + ...createDummyMintOperation({ id: 'updated-mint-ownership' }), + parent: { kind: 'mint-swap' as const, id: 'updating-mint-swap' }, + } satisfies MintOperation; + await repositories.mintOperationRepository.create(operation); + + await repositories.mintOperationRepository.update({ + ...operation, + state: 'executing', + }); + + const stored = await repositories.mintOperationRepository.getById(operation.id); + expect(stored?.state).toBe('executing'); + expect(stored?.parent?.kind).toBe('mint-swap'); + expect(stored?.parent?.id).toBe('updating-mint-swap'); + } finally { + await dispose(); + } + }); + + it('preserves a concurrent ownership claim through a stale regular update', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const staleOperation = createDummyMintOperation({ id: 'stale-mint-ownership' }); + await repositories.mintOperationRepository.create(staleOperation); + + const parent = { kind: 'mint-swap' as const, id: 'concurrent-mint-swap' }; + const claimed = await repositories.mintOperationRepository.assignMintSwapParentIfUnparented( + staleOperation.id, + 'pending', + parent, + ); + expect(claimed).toBe(true); + + await repositories.mintOperationRepository.update({ + ...staleOperation, + state: 'executing', + }); + + const stored = await repositories.mintOperationRepository.getById(staleOperation.id); + expect(stored?.state).toBe('executing'); + expect(stored?.parent?.kind).toBe(parent.kind); + expect(stored?.parent?.id).toBe(parent.id); + } finally { + await dispose(); + } + }); + + it('assigns a Mint Swap parent when the expected state matches and the operation is unparented', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const operation = createDummyMintOperation({ id: 'conditional-mint-op' }); + await repositories.mintOperationRepository.create(operation); + + const parent = { kind: 'mint-swap' as const, id: 'mint-swap-1' }; + const claimedResult = + await repositories.mintOperationRepository.assignMintSwapParentIfUnparented( + operation.id, + 'pending', + parent, + ); + + expect(claimedResult).toBe(true); + + const stored = await repositories.mintOperationRepository.getById(operation.id); + expect(stored?.state).toBe('pending'); + expect(stored?.parent?.kind).toBe('mint-swap'); + expect(stored?.parent?.id).toBe('mint-swap-1'); + } finally { + await dispose(); + } + }); + + it('rejects conditional Mint Swap claims when state or current parent does not match', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const parent = { kind: 'mint-swap' as const, id: 'mint-swap-1' }; + const operation = { + ...createDummyMintOperation({ id: 'conditional-mismatch-mint-op' }), + parent, + } satisfies MintOperation; + await repositories.mintOperationRepository.create(operation); + + expect( + await repositories.mintOperationRepository.assignMintSwapParentIfUnparented( + operation.id, + 'executing', + parent, + ), + ).toBe(false); + expect( + await repositories.mintOperationRepository.assignMintSwapParentIfUnparented( + operation.id, + 'pending', + { kind: 'mint-swap', id: 'mint-swap-2' }, + ), + ).toBe(false); + expect( + await repositories.mintOperationRepository.assignMintSwapParentIfUnparented( + operation.id, + 'pending', + { kind: 'mint-swap', id: 'mint-swap-3' }, + ), + ).toBe(false); + + const stored = await repositories.mintOperationRepository.getById(operation.id); + expect(stored?.state).toBe('pending'); + expect(stored?.parent?.kind).toBe('mint-swap'); + expect(stored?.parent?.id).toBe('mint-swap-1'); + } finally { + await dispose(); + } + }); + + it('rolls back conditional Mint Swap claims with the surrounding repository transaction', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const operation = createDummyMintOperation({ id: 'conditional-rollback-mint-op' }); + await repositories.mintOperationRepository.create(operation); + + await expectThrows( + () => + repositories.withTransaction(async (tx) => { + const updated = await tx.mintOperationRepository.assignMintSwapParentIfUnparented( + operation.id, + 'pending', + { kind: 'mint-swap', id: 'rolled-back-mint-swap' }, + ); + expect(updated).toBe(true); + await tx.mintRepository.addOrUpdateMint({ + ...createDummyMint(), + mintUrl: 'https://rolled-back-mint.test', + }); + throw new Error('roll back ownership claim'); + }), + expect, + ); + + const stored = await repositories.mintOperationRepository.getById(operation.id); + const mints = await repositories.mintRepository.getAllMints(); + expect(stored?.parent).toBe(undefined); + expect(mints).toHaveLength(0); + } finally { + await dispose(); + } + }); + it('round-trips init mint operation quote ids', async () => { const { repositories, dispose } = await options.createRepositories(); try { @@ -1770,6 +2045,70 @@ export async function runMeltOperationRepositoryContract( const { describe, it, expect } = runner; describe('MeltOperationRepository contract', () => { + it('round-trips Mint Swap ownership across persisted melt operation states', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const operations = createDummyPersistedMeltOperationsByState().map((operation, index) => ({ + ...operation, + parent: { kind: 'mint-swap' as const, id: `mint-swap-${index}` }, + })) satisfies MeltOperation[]; + + for (const operation of operations) { + await repositories.meltOperationRepository.create(operation); + const stored = await repositories.meltOperationRepository.getById(operation.id); + + expect(stored).toBeDefined(); + expect(stored!.parent?.kind).toBe('mint-swap'); + expect(stored!.parent?.id).toBe(operation.parent?.id); + } + } finally { + await dispose(); + } + }); + + it('loads melt operations without ownership metadata as standalone', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const operation = createDummyMeltOperation({ id: 'legacy-melt-op' }); + await repositories.meltOperationRepository.create(operation); + + const stored = await repositories.meltOperationRepository.getById(operation.id); + + expect(stored).toBeDefined(); + expect(stored!.parent).toBe(undefined); + } finally { + await dispose(); + } + }); + + it('preserves Mint Swap ownership through stale regular melt state updates', async () => { + const { repositories, dispose } = await options.createRepositories(); + try { + const init = { + ...createDummyMeltOperation({ + id: 'updated-melt-ownership', + quoteId: 'updated-melt-quote', + }), + parent: { kind: 'mint-swap' as const, id: 'updating-mint-swap' }, + } satisfies MeltOperation; + const prepared = { + ...createDummyPersistedMeltOperationsByState()[1]!, + id: init.id, + quoteId: 'updated-melt-quote', + } satisfies MeltOperation; + await repositories.meltOperationRepository.create(init); + + await repositories.meltOperationRepository.update(prepared); + + const stored = await repositories.meltOperationRepository.getById(init.id); + expect(stored?.state).toBe('prepared'); + expect(stored?.parent?.kind).toBe('mint-swap'); + expect(stored?.parent?.id).toBe('updating-mint-swap'); + } finally { + await dispose(); + } + }); + it('round-trips custom-unit init melt operations', async () => { const { repositories, dispose } = await options.createRepositories(); try { diff --git a/packages/core/adapter.ts b/packages/core/adapter.ts index f7b32c42..a5fbdbf3 100644 --- a/packages/core/adapter.ts +++ b/packages/core/adapter.ts @@ -62,6 +62,7 @@ export type { MintMethodRemoteState, MintOperation, MintOperationState, + MintSwapOperationParent, PaymentRequestReceiveAttempt, PaymentRequestReceiveAttemptState, PaymentRequestReceiveOperation, diff --git a/packages/core/index.ts b/packages/core/index.ts index 7399cda1..b207e743 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -83,6 +83,7 @@ export type { MeltMethodFinalizedData, TerminalMeltOperation, } from './operations/melt/MeltOperation.ts'; +export type { MintSwapOperationParent } from './operations/OperationParent.ts'; export type { ReceiveOperationSource, InitReceiveOperation, diff --git a/packages/core/operations/OperationParent.ts b/packages/core/operations/OperationParent.ts new file mode 100644 index 00000000..6322f989 --- /dev/null +++ b/packages/core/operations/OperationParent.ts @@ -0,0 +1,5 @@ +/** Durable reference to the Mint Swap Operation that owns a Mint or Melt Operation. */ +export interface MintSwapOperationParent { + kind: 'mint-swap'; + id: string; +} diff --git a/packages/core/operations/index.ts b/packages/core/operations/index.ts index 7ffc9b8f..c850e4da 100644 --- a/packages/core/operations/index.ts +++ b/packages/core/operations/index.ts @@ -1,4 +1,5 @@ export type { MeltOperation, MeltOperationState } from './melt/MeltOperation.ts'; +export type { MintSwapOperationParent } from './OperationParent.ts'; export type { MeltMethod, MeltMethodData, MeltMethodInputData } from './melt/MeltMethodHandler.ts'; export { normalizeMeltMethodData } from './melt/MeltMethodHandler.ts'; export { MeltOperationService } from './melt/MeltOperationService.ts'; diff --git a/packages/core/operations/melt/MeltOperation.ts b/packages/core/operations/melt/MeltOperation.ts index e7610705..bda04e12 100644 --- a/packages/core/operations/melt/MeltOperation.ts +++ b/packages/core/operations/melt/MeltOperation.ts @@ -32,6 +32,7 @@ import type { Amount } from '@cashu/cashu-ts'; import { getSecretsFromSerializedOutputData, type SerializedOutputData } from '../../utils'; import type { MeltMethod, MeltMethodData, MeltMethodMeta } from './MeltMethodHandler'; import { DEFAULT_UNIT, normalizeUnit } from '../../amounts.ts'; +import type { MintSwapOperationParent } from '../OperationParent.ts'; // ============================================================================ // Base and Data Interfaces @@ -58,6 +59,9 @@ interface MeltOperationBase extends MeltMethodMeta { /** Error message if the operation failed */ error?: string; + + /** Parent Mint Swap Operation that owns this operation, when present. */ + parent?: MintSwapOperationParent; } /** diff --git a/packages/core/operations/mint/MintOperation.ts b/packages/core/operations/mint/MintOperation.ts index 19c3285c..7a49a9d1 100644 --- a/packages/core/operations/mint/MintOperation.ts +++ b/packages/core/operations/mint/MintOperation.ts @@ -18,6 +18,7 @@ import type { SerializedOutputData } from '../../utils'; import { getSecretsFromSerializedOutputData } from '../../utils'; import type { MintMethod, MintMethodMeta } from './MintMethodHandler'; import { normalizeUnit, type UnitAmount } from '../../amounts.ts'; +import type { MintSwapOperationParent } from '../OperationParent.ts'; interface MintOperationBase extends MintMethodMeta { id: string; @@ -26,6 +27,8 @@ interface MintOperationBase extends MintMetho updatedAt: number; error?: string; terminalFailure?: MintOperationFailure; + /** Durable Mint Swap Operation ownership assigned at creation or by a conditional claim. */ + parent?: MintSwapOperationParent; } export interface MintOperationFailure { diff --git a/packages/core/repositories/index.ts b/packages/core/repositories/index.ts index 787b4d5b..3d9b8c17 100644 --- a/packages/core/repositories/index.ts +++ b/packages/core/repositories/index.ts @@ -6,6 +6,7 @@ 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 { MintSwapOperationParent } from '@core/operations/OperationParent'; import type { ReceiveOperation, ReceiveOperationState, @@ -253,7 +254,7 @@ export interface MeltOperationRepository { /** Create a new melt operation */ create(operation: MeltOperation): Promise; - /** Update an existing melt operation */ + /** Update an existing melt operation while preserving its stored ownership metadata. */ update(operation: MeltOperation): Promise; /** Get a melt operation by ID */ @@ -288,9 +289,19 @@ export interface MintOperationRepository { /** Create a new mint operation */ create(operation: MintOperation): Promise; - /** Update an existing mint operation */ + /** Update an existing mint operation while preserving its stored ownership metadata. */ update(operation: MintOperation): Promise; + /** + * Assign a Mint Swap parent only when the stored state still matches the expected value and the + * operation remains unparented. + */ + assignMintSwapParentIfUnparented( + operationId: string, + expectedState: MintOperationState, + parent: MintSwapOperationParent, + ): Promise; + /** Get a mint operation by ID */ getById(id: string): Promise; diff --git a/packages/core/repositories/memory/MemoryMeltOperationRepository.ts b/packages/core/repositories/memory/MemoryMeltOperationRepository.ts index e237b5cb..3d7e3dce 100644 --- a/packages/core/repositories/memory/MemoryMeltOperationRepository.ts +++ b/packages/core/repositories/memory/MemoryMeltOperationRepository.ts @@ -16,11 +16,16 @@ export class MemoryMeltOperationRepository implements MeltOperationRepository { } async update(operation: MeltOperation): Promise { - if (!this.operations.has(operation.id)) { + const current = this.operations.get(operation.id); + if (!current) { throw new Error(`MeltOperation with id ${operation.id} not found`); } this.assertNoDuplicateQuoteOperation(operation); - this.operations.set(operation.id, { ...operation, updatedAt: Date.now() }); + this.operations.set(operation.id, { + ...operation, + parent: current.parent, + updatedAt: Date.now(), + }); } async getById(id: string): Promise { diff --git a/packages/core/repositories/memory/MemoryMintOperationRepository.ts b/packages/core/repositories/memory/MemoryMintOperationRepository.ts index 8b88854c..51b39258 100644 --- a/packages/core/repositories/memory/MemoryMintOperationRepository.ts +++ b/packages/core/repositories/memory/MemoryMintOperationRepository.ts @@ -1,5 +1,6 @@ import type { MintOperationRepository } from '..'; import type { MintOperation, MintOperationState } from '../../operations/mint/MintOperation'; +import type { MintSwapOperationParent } from '../../operations/OperationParent.ts'; export class MemoryMintOperationRepository implements MintOperationRepository { private readonly operations = new Map(); @@ -12,10 +13,29 @@ export class MemoryMintOperationRepository implements MintOperationRepository { } async update(operation: MintOperation): Promise { - if (!this.operations.has(operation.id)) { + const current = this.operations.get(operation.id); + if (!current) { throw new Error(`MintOperation with id ${operation.id} not found`); } - this.operations.set(operation.id, { ...operation, updatedAt: Date.now() }); + this.operations.set(operation.id, { + ...operation, + parent: current.parent, + updatedAt: Date.now(), + }); + } + + async assignMintSwapParentIfUnparented( + operationId: string, + expectedState: MintOperationState, + parent: MintSwapOperationParent, + ): Promise { + const current = this.operations.get(operationId); + if (!current || current.state !== expectedState || current.parent) { + return false; + } + + this.operations.set(operationId, { ...current, parent, updatedAt: Date.now() }); + return true; } async getById(id: string): Promise { diff --git a/packages/core/test/unit/MemoryMintOperationRepository.test.ts b/packages/core/test/unit/MemoryMintOperationRepository.test.ts new file mode 100644 index 00000000..0873fa4f --- /dev/null +++ b/packages/core/test/unit/MemoryMintOperationRepository.test.ts @@ -0,0 +1,60 @@ +import { Amount } from '@cashu/cashu-ts'; +import { describe, expect, it } from 'bun:test'; +import type { PendingMintOperation } from '../../operations/mint/MintOperation'; +import { MemoryMintOperationRepository } from '../../repositories/memory/MemoryMintOperationRepository'; + +const createPendingOperation = (id: string): PendingMintOperation => ({ + id, + state: 'pending', + mintUrl: 'https://mint.test', + method: 'bolt11', + methodData: {}, + amount: Amount.from(30), + unit: 'sat', + quoteId: `${id}-quote`, + request: 'lnbc30', + expiry: null, + outputData: { keep: [], send: [] }, + createdAt: 1_000, + updatedAt: 1_000, +}); + +describe('MemoryMintOperationRepository', () => { + it('preserves a concurrent ownership claim through a stale regular update', async () => { + const repository = new MemoryMintOperationRepository(); + const staleOperation = createPendingOperation('stale-ownership'); + await repository.create(staleOperation); + + const parent = { kind: 'mint-swap' as const, id: 'concurrent-mint-swap' }; + expect( + await repository.assignMintSwapParentIfUnparented(staleOperation.id, 'pending', parent), + ).toBe(true); + + await repository.update({ ...staleOperation, state: 'executing' }); + + const stored = await repository.getById(staleOperation.id); + expect(stored?.state).toBe('executing'); + expect(stored?.parent).toEqual(parent); + }); + + it('rejects a duplicate Mint Swap claim for a parented operation', async () => { + const repository = new MemoryMintOperationRepository(); + const operation = createPendingOperation('duplicate-mint-swap-claim'); + await repository.create(operation); + const parent = { kind: 'mint-swap' as const, id: 'first-mint-swap' }; + + expect(await repository.assignMintSwapParentIfUnparented(operation.id, 'pending', parent)).toBe( + true, + ); + + expect( + await repository.assignMintSwapParentIfUnparented(operation.id, 'pending', { + kind: 'mint-swap', + id: 'second-mint-swap', + }), + ).toBe(false); + + const stored = await repository.getById(operation.id); + expect(stored?.parent).toEqual(parent); + }); +}); diff --git a/packages/indexeddb/src/lib/db.ts b/packages/indexeddb/src/lib/db.ts index 228396f2..10716bdd 100644 --- a/packages/indexeddb/src/lib/db.ts +++ b/packages/indexeddb/src/lib/db.ts @@ -1,5 +1,6 @@ import Dexie, { type Transaction as DexieTransaction } from 'dexie'; import type { SerializedBlindedSignature } from '@cashu/cashu-ts'; +import type { MintSwapOperationParent } from '@cashu/coco-core/adapter'; export interface IdbDbOptions { name?: string; @@ -298,6 +299,7 @@ export interface MeltOperationRow { changeAmount?: string | number | null; effectiveFee?: string | number | null; finalizedDataJson?: string | null; + parent?: MintSwapOperationParent; } export interface AuthSessionRow { @@ -328,4 +330,5 @@ export interface MintOperationRow { lastObservedRemoteStateAt?: number | null; terminalFailureJson?: string | null; outputDataJson?: string | null; + parent?: MintSwapOperationParent; } diff --git a/packages/indexeddb/src/repositories/MeltOperationRepository.ts b/packages/indexeddb/src/repositories/MeltOperationRepository.ts index bf7056bd..6baa2753 100644 --- a/packages/indexeddb/src/repositories/MeltOperationRepository.ts +++ b/packages/indexeddb/src/repositories/MeltOperationRepository.ts @@ -1,4 +1,8 @@ -import type { MeltMethodInputData, MeltOperationRepository } from '@cashu/coco-core/adapter'; +import type { + MeltMethodInputData, + MeltOperationRepository, + MintSwapOperationParent, +} from '@cashu/coco-core/adapter'; import { deserializeAmount, normalizeMeltMethodData, @@ -36,7 +40,17 @@ const isPreparedState = (state: MeltOperationState) => preparedStates.includes(s const parseMethodData = (row: MeltOperationRow): MeltMethodData => normalizeMeltMethodData(JSON.parse(row.methodDataJson) as MeltMethodInputData); +const parseParent = (row: MeltOperationRow): MintSwapOperationParent | undefined => { + if (!row.parent) return undefined; + if (row.parent.kind !== 'mint-swap' || !row.parent.id) { + throw new Error(`MeltOperation ${row.id} has invalid parent metadata`); + } + + return row.parent; +}; + const rowToOperation = (row: MeltOperationRow): MeltOperation => { + const parent = parseParent(row); const base = { id: row.id, mintUrl: row.mintUrl, @@ -46,6 +60,7 @@ const rowToOperation = (row: MeltOperationRow): MeltOperation => { createdAt: row.createdAt * 1000, updatedAt: row.updatedAt * 1000, error: row.error ?? undefined, + ...(parent ? { parent } : {}), }; if (!isPreparedState(row.state)) { @@ -124,6 +139,7 @@ const operationToRow = (operation: MeltOperation): MeltOperationRow => { changeOutputDataJson: null, swapOutputDataJson: null, finalizedDataJson: null, + ...(operation.parent ? { parent: operation.parent } : {}), }; } @@ -160,6 +176,7 @@ const operationToRow = (operation: MeltOperation): MeltOperationRow => { operation.state === 'finalized' && settlement.finalizedData !== undefined ? JSON.stringify(settlement.finalizedData) : null, + ...(operation.parent ? { parent: operation.parent } : {}), }; }; @@ -217,6 +234,8 @@ export class IdbMeltOperationRepository implements MeltOperationRepository { } const row = operationToRow(operation); + if (existing.parent) row.parent = existing.parent; + else delete row.parent; row.updatedAt = getUnixTimeSeconds(); await table.put(row); }); diff --git a/packages/indexeddb/src/repositories/MintOperationRepository.ts b/packages/indexeddb/src/repositories/MintOperationRepository.ts index 33090066..0261c883 100644 --- a/packages/indexeddb/src/repositories/MintOperationRepository.ts +++ b/packages/indexeddb/src/repositories/MintOperationRepository.ts @@ -1,4 +1,4 @@ -import type { MintOperationRepository } from '@cashu/coco-core/adapter'; +import type { MintOperationRepository, MintSwapOperationParent } from '@cashu/coco-core/adapter'; import { deserializeAmount, serializeAmount, stringifyJson } from '@cashu/coco-core/adapter'; import type { IdbDb, MintOperationRow } from '../lib/db.ts'; import { getUnixTimeSeconds } from '../lib/db.ts'; @@ -8,6 +8,15 @@ type MintOperationState = Parameters[0]; type MintMethodData = MintOperation['methodData']; type MintOperationFailure = NonNullable; +const parseParent = (row: MintOperationRow): MintSwapOperationParent | undefined => { + if (!row.parent) return undefined; + if (!row.parent.id || row.parent.kind !== 'mint-swap') { + throw new Error(`MintOperation ${row.id} has invalid parent metadata`); + } + + return row.parent; +}; + const persistedStates = ['pending', 'executing', 'finalized', 'failed'] as const; const isPersistedState = (state: string): state is (typeof persistedStates)[number] => @@ -30,6 +39,7 @@ const requireQuoteId = (row: MintOperationRow): string => { const rowToOperation = (row: MintOperationRow): MintOperation => { const quoteId = requireQuoteId(row); + const parent = parseParent(row); const base = { id: row.id, mintUrl: row.mintUrl, @@ -41,6 +51,7 @@ const rowToOperation = (row: MintOperationRow): MintOperation => { ...(row.terminalFailureJson ? { terminalFailure: JSON.parse(row.terminalFailureJson) as MintOperationFailure } : {}), + ...(parent ? { parent } : {}), }; const intent = { @@ -91,6 +102,7 @@ const operationToRow = (operation: MintOperation): MintOperationRow => { ? JSON.stringify(operation.terminalFailure) : null, outputDataJson: null, + ...(operation.parent ? { parent: operation.parent } : {}), }; } @@ -115,6 +127,7 @@ const operationToRow = (operation: MintOperation): MintOperationRow => { ? JSON.stringify(operation.terminalFailure) : null, outputDataJson: JSON.stringify(operation.outputData), + ...(operation.parent ? { parent: operation.parent } : {}), }; }; @@ -145,11 +158,30 @@ export class IdbMintOperationRepository implements MintOperationRepository { } const row = operationToRow(operation); + if (existing.parent) row.parent = existing.parent; + else delete row.parent; row.updatedAt = getUnixTimeSeconds(); await table.put(row); }); } + async assignMintSwapParentIfUnparented( + operationId: string, + expectedState: MintOperationState, + parent: MintSwapOperationParent, + ): Promise { + return this.db.runTransaction('rw', ['coco_cashu_mint_operations'], async (tx) => { + const table = tx.table('coco_cashu_mint_operations'); + const existing = (await table.get(operationId)) as MintOperationRow | undefined; + if (!existing || existing.state !== expectedState || existing.parent) { + return false; + } + + await table.update(operationId, { parent, updatedAt: getUnixTimeSeconds() }); + return true; + }); + } + async getById(id: string): Promise { const row = (await (this.db as any).table('coco_cashu_mint_operations').get(id)) as | MintOperationRow diff --git a/packages/sql-storage/src/repositories/MeltOperationRepository.ts b/packages/sql-storage/src/repositories/MeltOperationRepository.ts index e421763d..a325782a 100644 --- a/packages/sql-storage/src/repositories/MeltOperationRepository.ts +++ b/packages/sql-storage/src/repositories/MeltOperationRepository.ts @@ -1,4 +1,8 @@ -import type { MeltMethodInputData, MeltOperationRepository } from '@cashu/coco-core/adapter'; +import type { + MeltMethodInputData, + MeltOperationRepository, + MintSwapOperationParent, +} from '@cashu/coco-core/adapter'; import { deserializeAmount, normalizeMeltMethodData, @@ -44,6 +48,8 @@ interface MeltOperationRow { changeAmount: string | number | null; effectiveFee: string | number | null; finalizedDataJson: string | null; + parentKind: string | null; + parentId: string | null; } const preparedStates: MeltOperationState[] = [ @@ -60,7 +66,17 @@ const isPreparedState = (state: MeltOperationState) => preparedStates.includes(s const parseMethodData = (row: MeltOperationRow): MeltMethodData => normalizeMeltMethodData(JSON.parse(row.methodDataJson) as MeltMethodInputData); +const parseParent = (row: MeltOperationRow): MintSwapOperationParent | undefined => { + if (row.parentKind === null && row.parentId === null) return undefined; + if (row.parentKind !== 'mint-swap' || !row.parentId) { + throw new Error(`MeltOperation ${row.id} has invalid parent metadata`); + } + + return { kind: row.parentKind, id: row.parentId }; +}; + const rowToOperation = (row: MeltOperationRow): MeltOperation => { + const parent = parseParent(row); const base = { id: row.id, mintUrl: row.mintUrl, @@ -70,6 +86,7 @@ const rowToOperation = (row: MeltOperationRow): MeltOperation => { createdAt: row.createdAt * 1000, updatedAt: row.updatedAt * 1000, error: row.error ?? undefined, + ...(parent ? { parent } : {}), }; if (!isPreparedState(row.state)) { @@ -140,6 +157,8 @@ const operationToParams = (operation: MeltOperation): SqlValue[] => { null, null, null, + operation.parent?.kind ?? null, + operation.parent?.id ?? null, ]; } @@ -179,6 +198,8 @@ const operationToParams = (operation: MeltOperation): SqlValue[] => { changeAmount, effectiveFee, finalizedDataJson, + operation.parent?.kind ?? null, + operation.parent?.id ?? null, ]; }; @@ -207,8 +228,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, parentKind, parentId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, params, ); } diff --git a/packages/sql-storage/src/repositories/MintOperationRepository.ts b/packages/sql-storage/src/repositories/MintOperationRepository.ts index e1a37e44..986cb8bd 100644 --- a/packages/sql-storage/src/repositories/MintOperationRepository.ts +++ b/packages/sql-storage/src/repositories/MintOperationRepository.ts @@ -1,4 +1,4 @@ -import type { MintOperationRepository } from '@cashu/coco-core/adapter'; +import type { MintOperationRepository, MintSwapOperationParent } from '@cashu/coco-core/adapter'; import { deserializeAmount, serializeAmount, stringifyJson } from '@cashu/coco-core/adapter'; import type { SqlDatabase, SqlValue } from '../index.ts'; import { getUnixTimeSeconds } from '../utils.ts'; @@ -28,6 +28,8 @@ interface MintOperationRow { lastObservedRemoteStateAt: number | null; terminalFailureJson: string | null; outputDataJson: string | null; + parentKind: string | null; + parentId: string | null; } const persistedStates = ['pending', 'executing', 'finalized', 'failed'] as const; @@ -50,8 +52,18 @@ const requireQuoteId = (row: MintOperationRow): string => { return row.quoteId; }; +const parseParent = (row: MintOperationRow): MintSwapOperationParent | undefined => { + if (row.parentKind === null && row.parentId === null) return undefined; + if (!row.parentId || row.parentKind !== 'mint-swap') { + throw new Error(`MintOperation ${row.id} has invalid parent metadata`); + } + + return { kind: row.parentKind, id: row.parentId }; +}; + const rowToOperation = (row: MintOperationRow): MintOperation => { const quoteId = requireQuoteId(row); + const parent = parseParent(row); const base = { id: row.id, mintUrl: row.mintUrl, @@ -63,6 +75,7 @@ const rowToOperation = (row: MintOperationRow): MintOperation => { ...(row.terminalFailureJson ? { terminalFailure: JSON.parse(row.terminalFailureJson) as MintOperationFailure } : {}), + ...(parent ? { parent } : {}), }; const intent = { @@ -116,6 +129,8 @@ const operationToParams = (operation: MintOperation): SqlValue[] => { null, operation.terminalFailure ? JSON.stringify(operation.terminalFailure) : null, null, + operation.parent?.kind ?? null, + operation.parent?.id ?? null, ]; } @@ -138,6 +153,8 @@ const operationToParams = (operation: MintOperation): SqlValue[] => { null, operation.terminalFailure ? JSON.stringify(operation.terminalFailure) : null, JSON.stringify(operation.outputData), + operation.parent?.kind ?? null, + operation.parent?.id ?? null, ]; }; @@ -160,8 +177,8 @@ 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, parentKind, parentId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, params, ); } @@ -175,13 +192,35 @@ export class SqliteMintOperationRepository implements MintOperationRepository { throw new Error(`MintOperation with id ${operation.id} not found`); } + await this.updateWhere(operation, 'id = ?', [operation.id]); + } + + async assignMintSwapParentIfUnparented( + operationId: string, + expectedState: MintOperationState, + parent: MintSwapOperationParent, + ): Promise { + const result = await this.db.run( + `UPDATE coco_cashu_mint_operations + SET parentKind = ?, parentId = ?, updatedAt = ? + WHERE id = ? AND state = ? AND parentKind IS NULL AND parentId IS NULL`, + [parent.kind, parent.id, getUnixTimeSeconds(), operationId, expectedState], + ); + return result.changes > 0; + } + + private async updateWhere( + operation: MintOperation, + where: string, + whereParams: SqlValue[], + ): Promise { const updatedAtSeconds = getUnixTimeSeconds(); if (operation.state === 'init') { - await this.db.run( + const result = await this.db.run( `UPDATE coco_cashu_mint_operations SET quoteId = ?, state = ?, updatedAt = ?, error = ?, method = ?, methodDataJson = ?, amount = ?, unit = ?, terminalFailureJson = ? - WHERE id = ?`, + WHERE ${where}`, [ operation.quoteId, operation.state, @@ -192,16 +231,16 @@ export class SqliteMintOperationRepository implements MintOperationRepository { serializeAmount(operation.amount), operation.unit, operation.terminalFailure ? JSON.stringify(operation.terminalFailure) : null, - operation.id, + ...whereParams, ], ); - return; + return result.changes; } - await this.db.run( + const result = 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 = ? - WHERE id = ?`, + WHERE ${where}`, [ operation.quoteId, operation.state, @@ -218,9 +257,10 @@ export class SqliteMintOperationRepository implements MintOperationRepository { null, operation.terminalFailure ? JSON.stringify(operation.terminalFailure) : null, JSON.stringify(operation.outputData), - operation.id, + ...whereParams, ], ); + return result.changes; } async getById(id: string): Promise { diff --git a/packages/sql-storage/src/schema.ts b/packages/sql-storage/src/schema.ts index 19deb32c..8ed3d655 100644 --- a/packages/sql-storage/src/schema.ts +++ b/packages/sql-storage/src/schema.ts @@ -1506,6 +1506,18 @@ const MIGRATIONS: readonly Migration[] = [ GROUP BY purpose; `, }, + { + id: '039_operation_ownership', + sql: ` + ALTER TABLE coco_cashu_mint_operations + ADD COLUMN parentKind TEXT CHECK (parentKind IS NULL OR parentKind = 'mint-swap'); + ALTER TABLE coco_cashu_mint_operations ADD COLUMN parentId TEXT; + + ALTER TABLE coco_cashu_melt_operations + ADD COLUMN parentKind TEXT CHECK (parentKind IS NULL OR parentKind = 'mint-swap'); + ALTER TABLE coco_cashu_melt_operations ADD COLUMN parentId TEXT; + `, + }, ]; // Export for testing diff --git a/packages/sql-storage/src/test/schema.test.ts b/packages/sql-storage/src/test/schema.test.ts index da771ef9..f29c993e 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 = [ '036_quote_identity_unique_indexes', '037_mint_quote_accounting', '038_keypair_derivation_allocations', + '039_operation_ownership', ] as const; function deriveKeyPair(derivationIndex: number, purpose: 'p2pk' | 'nut20_mint_quote') { @@ -189,6 +190,50 @@ function itWithDatabase(name: string, fn: (db: SqlDatabase) => Promise): v } describe('shared SQL schema migrations', () => { + itWithDatabase('leaves existing operations unparented', async (db) => { + await ensureSchemaUpTo(db, '039_operation_ownership'); + await db.run( + `INSERT INTO coco_cashu_mint_operations + (id, mintUrl, quoteId, state, createdAt, updatedAt, method, methodDataJson, amount, unit) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + 'legacy-mint-operation-ownership', + 'https://mint.test', + 'legacy-mint-quote-ownership', + 'init', + 1, + 1, + 'bolt11', + '{}', + '1', + 'sat', + ], + ); + await insertMeltOperationRow( + db, + 'legacy-melt-operation-ownership', + 'legacy-melt-quote-ownership', + ); + + await ensureSchemaUpTo(db); + + const mint = await db.get<{ parentKind: string | null; parentId: string | null }>( + `SELECT parentKind, parentId + FROM coco_cashu_mint_operations + WHERE id = ?`, + ['legacy-mint-operation-ownership'], + ); + const melt = await db.get<{ parentKind: string | null; parentId: string | null }>( + `SELECT parentKind, parentId + FROM coco_cashu_melt_operations + WHERE id = ?`, + ['legacy-melt-operation-ownership'], + ); + + expect(mint).toEqual({ parentKind: null, parentId: null }); + expect(melt).toEqual({ parentKind: null, parentId: null }); + }); + itWithDatabase('preserves the migration list and applies all migration ids', async (db) => { expect(MIGRATIONS.map((migration) => migration.id)).toEqual(EXPECTED_MIGRATION_IDS); From 471631e45ff7fb25635158002638c29e3b0948b4 Mon Sep 17 00:00:00 2001 From: Egge Date: Wed, 19 Aug 2026 08:47:02 +0000 Subject: [PATCH 2/2] refactor: scope operation ownership interface to mint swaps --- packages/adapter-tests/src/index.ts | 37 +++++++++---------- packages/core/index.ts | 2 +- ...onParent.ts => MintSwapOperationParent.ts} | 0 packages/core/operations/index.ts | 2 +- .../core/operations/melt/MeltOperation.ts | 2 +- .../core/operations/mint/MintOperation.ts | 2 +- packages/core/repositories/index.ts | 9 ++--- .../memory/MemoryMintOperationRepository.ts | 11 ++++-- .../MemoryMintOperationRepository.test.ts | 20 ++++------ .../repositories/MintOperationRepository.ts | 9 +++-- .../repositories/MintOperationRepository.ts | 6 +-- 11 files changed, 48 insertions(+), 52 deletions(-) rename packages/core/operations/{OperationParent.ts => MintSwapOperationParent.ts} (100%) diff --git a/packages/adapter-tests/src/index.ts b/packages/adapter-tests/src/index.ts index ba079ad0..c1530f51 100644 --- a/packages/adapter-tests/src/index.ts +++ b/packages/adapter-tests/src/index.ts @@ -998,11 +998,10 @@ export async function runMintOperationRepositoryContract( const staleOperation = createDummyMintOperation({ id: 'stale-mint-ownership' }); await repositories.mintOperationRepository.create(staleOperation); - const parent = { kind: 'mint-swap' as const, id: 'concurrent-mint-swap' }; - const claimed = await repositories.mintOperationRepository.assignMintSwapParentIfUnparented( + const claimed = await repositories.mintOperationRepository.claimForMintSwap( staleOperation.id, 'pending', - parent, + 'concurrent-mint-swap', ); expect(claimed).toBe(true); @@ -1013,8 +1012,8 @@ export async function runMintOperationRepositoryContract( const stored = await repositories.mintOperationRepository.getById(staleOperation.id); expect(stored?.state).toBe('executing'); - expect(stored?.parent?.kind).toBe(parent.kind); - expect(stored?.parent?.id).toBe(parent.id); + expect(stored?.parent?.kind).toBe('mint-swap'); + expect(stored?.parent?.id).toBe('concurrent-mint-swap'); } finally { await dispose(); } @@ -1026,13 +1025,11 @@ export async function runMintOperationRepositoryContract( const operation = createDummyMintOperation({ id: 'conditional-mint-op' }); await repositories.mintOperationRepository.create(operation); - const parent = { kind: 'mint-swap' as const, id: 'mint-swap-1' }; - const claimedResult = - await repositories.mintOperationRepository.assignMintSwapParentIfUnparented( - operation.id, - 'pending', - parent, - ); + const claimedResult = await repositories.mintOperationRepository.claimForMintSwap( + operation.id, + 'pending', + 'mint-swap-1', + ); expect(claimedResult).toBe(true); @@ -1056,24 +1053,24 @@ export async function runMintOperationRepositoryContract( await repositories.mintOperationRepository.create(operation); expect( - await repositories.mintOperationRepository.assignMintSwapParentIfUnparented( + await repositories.mintOperationRepository.claimForMintSwap( operation.id, 'executing', - parent, + 'mint-swap-1', ), ).toBe(false); expect( - await repositories.mintOperationRepository.assignMintSwapParentIfUnparented( + await repositories.mintOperationRepository.claimForMintSwap( operation.id, 'pending', - { kind: 'mint-swap', id: 'mint-swap-2' }, + 'mint-swap-2', ), ).toBe(false); expect( - await repositories.mintOperationRepository.assignMintSwapParentIfUnparented( + await repositories.mintOperationRepository.claimForMintSwap( operation.id, 'pending', - { kind: 'mint-swap', id: 'mint-swap-3' }, + 'mint-swap-3', ), ).toBe(false); @@ -1095,10 +1092,10 @@ export async function runMintOperationRepositoryContract( await expectThrows( () => repositories.withTransaction(async (tx) => { - const updated = await tx.mintOperationRepository.assignMintSwapParentIfUnparented( + const updated = await tx.mintOperationRepository.claimForMintSwap( operation.id, 'pending', - { kind: 'mint-swap', id: 'rolled-back-mint-swap' }, + 'rolled-back-mint-swap', ); expect(updated).toBe(true); await tx.mintRepository.addOrUpdateMint({ diff --git a/packages/core/index.ts b/packages/core/index.ts index b207e743..6d015002 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -83,7 +83,7 @@ export type { MeltMethodFinalizedData, TerminalMeltOperation, } from './operations/melt/MeltOperation.ts'; -export type { MintSwapOperationParent } from './operations/OperationParent.ts'; +export type { MintSwapOperationParent } from './operations/MintSwapOperationParent.ts'; export type { ReceiveOperationSource, InitReceiveOperation, diff --git a/packages/core/operations/OperationParent.ts b/packages/core/operations/MintSwapOperationParent.ts similarity index 100% rename from packages/core/operations/OperationParent.ts rename to packages/core/operations/MintSwapOperationParent.ts diff --git a/packages/core/operations/index.ts b/packages/core/operations/index.ts index c850e4da..02aa78de 100644 --- a/packages/core/operations/index.ts +++ b/packages/core/operations/index.ts @@ -1,5 +1,5 @@ export type { MeltOperation, MeltOperationState } from './melt/MeltOperation.ts'; -export type { MintSwapOperationParent } from './OperationParent.ts'; +export type { MintSwapOperationParent } from './MintSwapOperationParent.ts'; export type { MeltMethod, MeltMethodData, MeltMethodInputData } from './melt/MeltMethodHandler.ts'; export { normalizeMeltMethodData } from './melt/MeltMethodHandler.ts'; export { MeltOperationService } from './melt/MeltOperationService.ts'; diff --git a/packages/core/operations/melt/MeltOperation.ts b/packages/core/operations/melt/MeltOperation.ts index bda04e12..f3853598 100644 --- a/packages/core/operations/melt/MeltOperation.ts +++ b/packages/core/operations/melt/MeltOperation.ts @@ -32,7 +32,7 @@ import type { Amount } from '@cashu/cashu-ts'; import { getSecretsFromSerializedOutputData, type SerializedOutputData } from '../../utils'; import type { MeltMethod, MeltMethodData, MeltMethodMeta } from './MeltMethodHandler'; import { DEFAULT_UNIT, normalizeUnit } from '../../amounts.ts'; -import type { MintSwapOperationParent } from '../OperationParent.ts'; +import type { MintSwapOperationParent } from '../MintSwapOperationParent.ts'; // ============================================================================ // Base and Data Interfaces diff --git a/packages/core/operations/mint/MintOperation.ts b/packages/core/operations/mint/MintOperation.ts index 7a49a9d1..a489849f 100644 --- a/packages/core/operations/mint/MintOperation.ts +++ b/packages/core/operations/mint/MintOperation.ts @@ -18,7 +18,7 @@ import type { SerializedOutputData } from '../../utils'; import { getSecretsFromSerializedOutputData } from '../../utils'; import type { MintMethod, MintMethodMeta } from './MintMethodHandler'; import { normalizeUnit, type UnitAmount } from '../../amounts.ts'; -import type { MintSwapOperationParent } from '../OperationParent.ts'; +import type { MintSwapOperationParent } from '../MintSwapOperationParent.ts'; interface MintOperationBase extends MintMethodMeta { id: string; diff --git a/packages/core/repositories/index.ts b/packages/core/repositories/index.ts index 3d9b8c17..07a6ca95 100644 --- a/packages/core/repositories/index.ts +++ b/packages/core/repositories/index.ts @@ -6,7 +6,6 @@ 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 { MintSwapOperationParent } from '@core/operations/OperationParent'; import type { ReceiveOperation, ReceiveOperationState, @@ -293,13 +292,13 @@ export interface MintOperationRepository { update(operation: MintOperation): Promise; /** - * Assign a Mint Swap parent only when the stored state still matches the expected value and the - * operation remains unparented. + * Claim an unparented operation for a Mint Swap when its stored state still matches the expected + * value. */ - assignMintSwapParentIfUnparented( + claimForMintSwap( operationId: string, expectedState: MintOperationState, - parent: MintSwapOperationParent, + mintSwapOperationId: string, ): Promise; /** Get a mint operation by ID */ diff --git a/packages/core/repositories/memory/MemoryMintOperationRepository.ts b/packages/core/repositories/memory/MemoryMintOperationRepository.ts index 51b39258..3bb546a9 100644 --- a/packages/core/repositories/memory/MemoryMintOperationRepository.ts +++ b/packages/core/repositories/memory/MemoryMintOperationRepository.ts @@ -1,6 +1,5 @@ import type { MintOperationRepository } from '..'; import type { MintOperation, MintOperationState } from '../../operations/mint/MintOperation'; -import type { MintSwapOperationParent } from '../../operations/OperationParent.ts'; export class MemoryMintOperationRepository implements MintOperationRepository { private readonly operations = new Map(); @@ -24,17 +23,21 @@ export class MemoryMintOperationRepository implements MintOperationRepository { }); } - async assignMintSwapParentIfUnparented( + async claimForMintSwap( operationId: string, expectedState: MintOperationState, - parent: MintSwapOperationParent, + mintSwapOperationId: string, ): Promise { const current = this.operations.get(operationId); if (!current || current.state !== expectedState || current.parent) { return false; } - this.operations.set(operationId, { ...current, parent, updatedAt: Date.now() }); + this.operations.set(operationId, { + ...current, + parent: { kind: 'mint-swap', id: mintSwapOperationId }, + updatedAt: Date.now(), + }); return true; } diff --git a/packages/core/test/unit/MemoryMintOperationRepository.test.ts b/packages/core/test/unit/MemoryMintOperationRepository.test.ts index 0873fa4f..c8e7c661 100644 --- a/packages/core/test/unit/MemoryMintOperationRepository.test.ts +++ b/packages/core/test/unit/MemoryMintOperationRepository.test.ts @@ -25,36 +25,30 @@ describe('MemoryMintOperationRepository', () => { const staleOperation = createPendingOperation('stale-ownership'); await repository.create(staleOperation); - const parent = { kind: 'mint-swap' as const, id: 'concurrent-mint-swap' }; expect( - await repository.assignMintSwapParentIfUnparented(staleOperation.id, 'pending', parent), + await repository.claimForMintSwap(staleOperation.id, 'pending', 'concurrent-mint-swap'), ).toBe(true); await repository.update({ ...staleOperation, state: 'executing' }); const stored = await repository.getById(staleOperation.id); expect(stored?.state).toBe('executing'); - expect(stored?.parent).toEqual(parent); + expect(stored?.parent).toEqual({ kind: 'mint-swap', id: 'concurrent-mint-swap' }); }); it('rejects a duplicate Mint Swap claim for a parented operation', async () => { const repository = new MemoryMintOperationRepository(); const operation = createPendingOperation('duplicate-mint-swap-claim'); await repository.create(operation); - const parent = { kind: 'mint-swap' as const, id: 'first-mint-swap' }; - - expect(await repository.assignMintSwapParentIfUnparented(operation.id, 'pending', parent)).toBe( + expect(await repository.claimForMintSwap(operation.id, 'pending', 'first-mint-swap')).toBe( true, ); - expect( - await repository.assignMintSwapParentIfUnparented(operation.id, 'pending', { - kind: 'mint-swap', - id: 'second-mint-swap', - }), - ).toBe(false); + expect(await repository.claimForMintSwap(operation.id, 'pending', 'second-mint-swap')).toBe( + false, + ); const stored = await repository.getById(operation.id); - expect(stored?.parent).toEqual(parent); + expect(stored?.parent).toEqual({ kind: 'mint-swap', id: 'first-mint-swap' }); }); }); diff --git a/packages/indexeddb/src/repositories/MintOperationRepository.ts b/packages/indexeddb/src/repositories/MintOperationRepository.ts index 0261c883..46468c5d 100644 --- a/packages/indexeddb/src/repositories/MintOperationRepository.ts +++ b/packages/indexeddb/src/repositories/MintOperationRepository.ts @@ -165,10 +165,10 @@ export class IdbMintOperationRepository implements MintOperationRepository { }); } - async assignMintSwapParentIfUnparented( + async claimForMintSwap( operationId: string, expectedState: MintOperationState, - parent: MintSwapOperationParent, + mintSwapOperationId: string, ): Promise { return this.db.runTransaction('rw', ['coco_cashu_mint_operations'], async (tx) => { const table = tx.table('coco_cashu_mint_operations'); @@ -177,7 +177,10 @@ export class IdbMintOperationRepository implements MintOperationRepository { return false; } - await table.update(operationId, { parent, updatedAt: getUnixTimeSeconds() }); + await table.update(operationId, { + parent: { kind: 'mint-swap', id: mintSwapOperationId }, + updatedAt: getUnixTimeSeconds(), + }); return true; }); } diff --git a/packages/sql-storage/src/repositories/MintOperationRepository.ts b/packages/sql-storage/src/repositories/MintOperationRepository.ts index 986cb8bd..250da616 100644 --- a/packages/sql-storage/src/repositories/MintOperationRepository.ts +++ b/packages/sql-storage/src/repositories/MintOperationRepository.ts @@ -195,16 +195,16 @@ export class SqliteMintOperationRepository implements MintOperationRepository { await this.updateWhere(operation, 'id = ?', [operation.id]); } - async assignMintSwapParentIfUnparented( + async claimForMintSwap( operationId: string, expectedState: MintOperationState, - parent: MintSwapOperationParent, + mintSwapOperationId: string, ): Promise { const result = await this.db.run( `UPDATE coco_cashu_mint_operations SET parentKind = ?, parentId = ?, updatedAt = ? WHERE id = ? AND state = ? AND parentKind IS NULL AND parentId IS NULL`, - [parent.kind, parent.id, getUnixTimeSeconds(), operationId, expectedState], + ['mint-swap', mintSwapOperationId, getUnixTimeSeconds(), operationId, expectedState], ); return result.changes > 0; }