Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/operation-ownership-primitives.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@cashu/coco-core': minor

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Release the required repository method as a major

For consumers that implement the public MintOperationRepository contract, upgrading to this minor release makes their adapters fail to typecheck because assignMintSwapParentIfUnparented is a new required method. This repository treats required adapter-contract changes as major releases (for example, the existing atomic key-allocation changeset), so mark @cashu/coco-core as major to avoid shipping a breaking API change under a compatible version range.

Useful? React with 👍 / 👎.

'@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.
336 changes: 336 additions & 0 deletions packages/adapter-tests/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,7 @@ function createDummySendOperationsByState(unit: string): SendOperation[] {
}

type PendingMintOperation = Extract<MintOperation, { state: 'pending' }>;
type PreparedMeltOperation = Extract<MeltOperation, { state: 'prepared' }>;

export function createDummyMintQuote(
overrides?: Partial<MintQuote<'bolt11'>>,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -844,6 +933,189 @@ 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 claimed = await repositories.mintOperationRepository.claimForMintSwap(
staleOperation.id,
'pending',
'concurrent-mint-swap',
);
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('mint-swap');
expect(stored?.parent?.id).toBe('concurrent-mint-swap');
} 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 claimedResult = await repositories.mintOperationRepository.claimForMintSwap(
operation.id,
'pending',
'mint-swap-1',
);

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.claimForMintSwap(
operation.id,
'executing',
'mint-swap-1',
),
).toBe(false);
expect(
await repositories.mintOperationRepository.claimForMintSwap(
operation.id,
'pending',
'mint-swap-2',
),
).toBe(false);
expect(
await repositories.mintOperationRepository.claimForMintSwap(
operation.id,
'pending',
'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.claimForMintSwap(
operation.id,
'pending',
'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 {
Expand Down Expand Up @@ -1770,6 +2042,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 {
Expand Down
1 change: 1 addition & 0 deletions packages/core/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export type {
MintMethodRemoteState,
MintOperation,
MintOperationState,
MintSwapOperationParent,
PaymentRequestReceiveAttempt,
PaymentRequestReceiveAttemptState,
PaymentRequestReceiveOperation,
Expand Down
1 change: 1 addition & 0 deletions packages/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export type {
MeltMethodFinalizedData,
TerminalMeltOperation,
} from './operations/melt/MeltOperation.ts';
export type { MintSwapOperationParent } from './operations/MintSwapOperationParent.ts';
export type {
ReceiveOperationSource,
InitReceiveOperation,
Expand Down
5 changes: 5 additions & 0 deletions packages/core/operations/MintSwapOperationParent.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading