Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/safe-pending-rollbacks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@cashu/coco-core': patch
---

Keep pending send rollback mint-backed and report an explicit unsafe-offline error when reclaim
cannot complete safely.
12 changes: 12 additions & 0 deletions packages/core/models/Error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,18 @@ export class PaymentRequestError extends Error {
}
}

export class PendingSendRollbackError extends Error {
readonly operationId: string;
override readonly cause?: unknown;

constructor(operationId: string, cause?: unknown) {
super('Cannot roll back safely without mint connection. Token might have been spent.');
this.name = 'PendingSendRollbackError';
this.operationId = operationId;
this.cause = cause;
}
}

/**
* This error is thrown when attempting to modify an operation that is already in progress.
*/
Expand Down
18 changes: 13 additions & 5 deletions packages/core/operations/send/SendOperationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { generateSubId } from '../../utils';
import {
UnknownMintError,
ProofValidationError,
PendingSendRollbackError,
OperationInProgressError,
} from '../../models/Error';
import { MintScopedLock } from '../MintScopedLock';
Expand Down Expand Up @@ -489,11 +490,18 @@ export class SendOperationService {
opForRollback = rollingBack;
}

await handler.rollback({
...this.buildDeps(),
operation: opForRollback,
wallet,
});
try {
await handler.rollback({
...this.buildDeps(),
operation: opForRollback,
wallet,
});
} catch (error) {
if (operation.state === 'pending') {
throw new PendingSendRollbackError(operation.id, error);
}
throw error;
}

await this.markAsRolledBack(opForRollback, reason);
} finally {
Expand Down
41 changes: 38 additions & 3 deletions packages/core/test/unit/DefaultSendHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ describe('DefaultSendHandler', () => {
})),
});

const useOperationProofs = (proofs: CoreProof[]) => {
proofRepository = {
...proofRepository,
getProofsByOperationId: mock(() => Promise.resolve(proofs)),
} as ProofRepository;
};

const makeInitOp = (id: string, overrides?: Partial<InitSendOperation>): InitSendOperation => ({
id,
state: 'init',
Expand Down Expand Up @@ -195,6 +202,7 @@ describe('DefaultSendHandler', () => {
}),
),
setProofState: mock(() => Promise.resolve()),
restoreProofsToReady: mock(() => Promise.resolve()),
saveProofs: mock(() => Promise.resolve()),
recoverProofsFromOutputData: mock(() => Promise.resolve([])),
} as unknown as ProofService;
Expand Down Expand Up @@ -454,9 +462,7 @@ describe('DefaultSendHandler', () => {
createdByOperationId: 'op-pending',
});

(proofRepository.getProofsByOperationId as Mock<any>).mockImplementation(() =>
Promise.resolve([sendProof]),
);
useOperationProofs([sendProof]);
(mockWallet.receive as Mock<any>).mockImplementation(() =>
Promise.resolve([makeProof('reclaim-1', 99)]),
);
Expand Down Expand Up @@ -491,6 +497,35 @@ describe('DefaultSendHandler', () => {
expect(proofService.releaseProofs).toHaveBeenCalledWith(mintUrl, ['input-1']);
expect(proofService.releaseProofs).toHaveBeenCalledWith(mintUrl, ['keep-1']);
});

it('reclaims exact-match pending proofs through the mint', async () => {
const operation = makePendingOp('op-exact-pending', {
needsSwap: false,
fee: Amount.zero(),
inputAmount: Amount.from(100),
inputProofSecrets: ['proof-100'],
outputData: undefined,
});
const sendProof = makeCoreProof('proof-100', 100, {
state: 'inflight',
createdByOperationId: 'op-exact-pending',
});

useOperationProofs([sendProof]);

await handler.rollback(buildRollbackContext(operation));

expect(proofService.restoreProofsToReady).not.toHaveBeenCalled();
expect(proofService.createOutputsAndIncrementCounters).toHaveBeenCalledWith(
mintUrl,
{
keep: { amount: Amount.from(99), unit: 'sat' },
send: { amount: Amount.zero(), unit: 'sat' },
},
{},
);
expect(mockWallet.receive).toHaveBeenCalled();
});
});

describe('recoverExecuting', () => {
Expand Down
70 changes: 70 additions & 0 deletions packages/core/test/unit/SendOperationService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { MintService } from '../../services/MintService';
import type { WalletService } from '../../services/WalletService';
import type { Logger } from '../../logging/Logger';
import type { CoreProof } from '../../types';
import { PendingSendRollbackError } from '../../models/Error';
import type {
PreparedSendOperation,
PendingSendOperation,
Expand Down Expand Up @@ -123,6 +124,11 @@ describe('SendOperationService', () => {
keepAmount: Amount.zero(),
})),
setProofState: mock(async () => {}),
restoreProofsToReady: mock((selectedMintUrl: string, secrets: string[]) =>
proofRepo
.setProofState(selectedMintUrl, secrets, 'ready')
.then(() => proofRepo.releaseProofs(selectedMintUrl, secrets)),
),
saveProofs: mock(async () => {}),
} as unknown as ProofService;

Expand Down Expand Up @@ -447,4 +453,68 @@ describe('SendOperationService', () => {
expect(persistedStateDuringFinalize).toBe('pending');
expect((await sendOpRepo.getById(pendingOp.id))?.state).toBe('finalized');
});

it('wraps failed pending reclaim with a safe rollback error', async () => {
const pendingOp: PendingSendOperation = {
id: 'send-op-reclaim-offline',
state: 'pending',
mintUrl,
amount: Amount.from(100),
unit: 'sat',
createdAt: Date.now(),
updatedAt: Date.now(),
needsSwap: true,
fee: Amount.zero(),
inputAmount: Amount.from(100),
inputProofSecrets: ['proof-1'],
outputData: {
keep: [],
send: [
{
blindedMessage: { amount: 100, id: keysetId, B_: 'B_send_1' },
blindingFactor: 'abc123',
secret: Buffer.from('send-secret-1').toString('hex'),
},
],
},
method: 'default',
methodData: {},
};
await sendOpRepo.create(pendingOp);
const cause = new Error('Network request failed');

const customHandler: SendMethodHandler<'default'> = {
prepare: mock(async () => {
throw new Error('not used');
}),
execute: mock(async () => {
throw new Error('not used');
}),
rollback: mock(async () => {
throw cause;
}),
recoverExecuting: mock(async () => {
throw new Error('not used');
}),
};
handlerProvider.register('default', customHandler);

let thrown: unknown;
try {
await service.rollback(pendingOp.id);
} catch (error) {
thrown = error;
}

expect(thrown).toBeInstanceOf(Error);
expect(thrown).toBeInstanceOf(PendingSendRollbackError);
expect((thrown as Error).message).toBe(
'Cannot roll back safely without mint connection. Token might have been spent.',
);
expect((thrown as PendingSendRollbackError).operationId).toBe(pendingOp.id);
expect((thrown as PendingSendRollbackError).cause).toBe(cause);
expect(customHandler.rollback).toHaveBeenCalledTimes(1);
const persisted = await sendOpRepo.getById(pendingOp.id);
expect(persisted?.state).toBe('rolling_back');
});
});
12 changes: 12 additions & 0 deletions packages/core/test/unit/SendOpsApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,18 @@ describe('SendOpsApi', () => {

await api.reclaim(pendingOperation.id);
expect(sendOperationService.rollback).toHaveBeenCalledWith(pendingOperation.id);

const rollingBackOperation: SendOperation = {
...pendingOperation,
state: 'rolling_back',
updatedAt: Date.now(),
};
(sendOperationService.getOperation as unknown as ReturnType<typeof mock>).mockResolvedValueOnce(
rollingBackOperation as SendOperation,
);

await expect(api.reclaim(rollingBackOperation.id)).rejects.toThrow("Expected 'pending'");
expect(sendOperationService.rollback).toHaveBeenCalledTimes(1);
});

it('finalize delegates directly to the service', async () => {
Expand Down
Loading