From 25f9e7232768f10c96bf315d451c9e958422838c Mon Sep 17 00:00:00 2001 From: Kelbie Date: Sat, 16 May 2026 16:16:01 +0100 Subject: [PATCH 1/2] fix(core): allow offline rollback of exact-match sends --- packages/core/api/SendOpsApi.ts | 7 ++- .../infra/handlers/send/DefaultSendHandler.ts | 8 +++ .../operations/send/SendOperationService.ts | 23 +++++--- .../core/test/unit/DefaultSendHandler.test.ts | 17 ++++++ .../test/unit/SendOperationService.test.ts | 56 +++++++++++++++++++ packages/core/test/unit/SendOpsApi.test.ts | 15 ++++- 6 files changed, 115 insertions(+), 11 deletions(-) diff --git a/packages/core/api/SendOpsApi.ts b/packages/core/api/SendOpsApi.ts index 95b84a12..94c421ba 100644 --- a/packages/core/api/SendOpsApi.ts +++ b/packages/core/api/SendOpsApi.ts @@ -145,13 +145,14 @@ export class SendOpsApi { * Attempts to reclaim a pending send operation. * * This is intended for sends that are already in flight but still support - * rollback according to the underlying send method. + * rollback according to the underlying send method. A previous interrupted + * reclaim may leave the operation in `rolling_back`; treat that as retryable. */ async reclaim(operationId: string): Promise { const operation = await this.requireOperation(operationId); - if (operation.state !== 'pending') { + if (operation.state !== 'pending' && operation.state !== 'rolling_back') { throw new Error( - `Cannot reclaim operation in state '${operation.state}'. Expected 'pending'.`, + `Cannot reclaim operation in state '${operation.state}'. Expected 'pending' or 'rolling_back'.`, ); } diff --git a/packages/core/infra/handlers/send/DefaultSendHandler.ts b/packages/core/infra/handlers/send/DefaultSendHandler.ts index 170e3a9d..f79e02fe 100644 --- a/packages/core/infra/handlers/send/DefaultSendHandler.ts +++ b/packages/core/infra/handlers/send/DefaultSendHandler.ts @@ -248,6 +248,14 @@ export class DefaultSendHandler implements SendMethodHandler<'default'> { operationId: operation.id, }); } else if (operation.state === 'pending' || operation.state === 'rolling_back') { + if (!operation.needsSwap) { + await proofService.restoreProofsToReady(mintUrl, inputProofSecrets); + logger?.info('Rolling back exact-match pending operation - restored original proofs', { + operationId: operation.id, + }); + return; + } + // Complex case: need to reclaim the send proofs by swapping them back const sendSecrets = getSendProofSecrets(operation); diff --git a/packages/core/operations/send/SendOperationService.ts b/packages/core/operations/send/SendOperationService.ts index 68d7b7f8..132db11f 100644 --- a/packages/core/operations/send/SendOperationService.ts +++ b/packages/core/operations/send/SendOperationService.ts @@ -452,7 +452,6 @@ export class SendOperationService { if ( operation.state === 'finalized' || operation.state === 'rolled_back' || - operation.state === 'rolling_back' || operation.state === 'init' || operation.state === 'executing' ) { @@ -469,7 +468,10 @@ export class SendOperationService { throw new Error(`Send operations of method ${operation.method} can not be rolled back`); } - if (operation.state === 'pending' && operation.method === 'p2pk') { + if ( + (operation.state === 'pending' || operation.state === 'rolling_back') && + operation.method === 'p2pk' + ) { throw new Error('Cannot rollback pending P2PK send operation'); } @@ -489,11 +491,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') { + await this.sendOperationRepository.update(operation); + } + throw error; + } await this.markAsRolledBack(opForRollback, reason); } finally { diff --git a/packages/core/test/unit/DefaultSendHandler.test.ts b/packages/core/test/unit/DefaultSendHandler.test.ts index 93437fcd..147df9e5 100644 --- a/packages/core/test/unit/DefaultSendHandler.test.ts +++ b/packages/core/test/unit/DefaultSendHandler.test.ts @@ -195,6 +195,7 @@ describe('DefaultSendHandler', () => { }), ), setProofState: mock(() => Promise.resolve()), + restoreProofsToReady: mock(() => Promise.resolve()), saveProofs: mock(() => Promise.resolve()), recoverProofsFromOutputData: mock(() => Promise.resolve([])), } as unknown as ProofService; @@ -491,6 +492,22 @@ describe('DefaultSendHandler', () => { expect(proofService.releaseProofs).toHaveBeenCalledWith(mintUrl, ['input-1']); expect(proofService.releaseProofs).toHaveBeenCalledWith(mintUrl, ['keep-1']); }); + + it('rolls back exact-match pending proofs locally without mint access', async () => { + const operation = makePendingOp('op-exact-pending', { + needsSwap: false, + fee: Amount.zero(), + inputAmount: Amount.from(100), + inputProofSecrets: ['proof-100'], + outputData: undefined, + }); + + await handler.rollback(buildRollbackContext(operation)); + + expect(proofService.restoreProofsToReady).toHaveBeenCalledWith(mintUrl, ['proof-100']); + expect(proofService.createOutputsAndIncrementCounters).not.toHaveBeenCalled(); + expect(mockWallet.receive).not.toHaveBeenCalled(); + }); }); describe('recoverExecuting', () => { diff --git a/packages/core/test/unit/SendOperationService.test.ts b/packages/core/test/unit/SendOperationService.test.ts index c3981b77..d3d2a7f1 100644 --- a/packages/core/test/unit/SendOperationService.test.ts +++ b/packages/core/test/unit/SendOperationService.test.ts @@ -123,6 +123,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; @@ -447,4 +452,55 @@ describe('SendOperationService', () => { expect(persistedStateDuringFinalize).toBe('pending'); expect((await sendOpRepo.getById(pendingOp.id))?.state).toBe('finalized'); }); + + it('restores a pending operation when reclaim rollback fails before completion', 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 customHandler: SendMethodHandler<'default'> = { + prepare: mock(async () => { + throw new Error('not used'); + }), + execute: mock(async () => { + throw new Error('not used'); + }), + rollback: mock(async () => { + throw new Error('Network request failed'); + }), + recoverExecuting: mock(async () => { + throw new Error('not used'); + }), + }; + handlerProvider.register('default', customHandler); + + await expect(service.rollback(pendingOp.id)).rejects.toThrow('Network request failed'); + + expect(customHandler.rollback).toHaveBeenCalledTimes(1); + const persisted = await sendOpRepo.getById(pendingOp.id); + expect(persisted?.state).toBe('pending'); + }); }); diff --git a/packages/core/test/unit/SendOpsApi.test.ts b/packages/core/test/unit/SendOpsApi.test.ts index acfd32f9..2f251fad 100644 --- a/packages/core/test/unit/SendOpsApi.test.ts +++ b/packages/core/test/unit/SendOpsApi.test.ts @@ -5,6 +5,7 @@ import type { FinalizedSendOperation, PendingSendOperation, PreparedSendOperation, + RollingBackSendOperation, SendOperation, } from '../../operations/send/SendOperation.ts'; import { SendOpsApi } from '../../api/SendOpsApi.ts'; @@ -147,13 +148,25 @@ describe('SendOpsApi', () => { await expect(api.cancel(pendingOperation.id)).rejects.toThrow("Expected 'prepared'"); }); - it('reclaim only allows pending operations', async () => { + it('reclaim allows pending and rolling_back operations', async () => { (sendOperationService.getOperation as unknown as ReturnType).mockResolvedValueOnce( pendingOperation as SendOperation, ); await api.reclaim(pendingOperation.id); expect(sendOperationService.rollback).toHaveBeenCalledWith(pendingOperation.id); + + const rollingBackOperation: RollingBackSendOperation = { + ...pendingOperation, + state: 'rolling_back', + updatedAt: Date.now(), + }; + (sendOperationService.getOperation as unknown as ReturnType).mockResolvedValueOnce( + rollingBackOperation as SendOperation, + ); + + await api.reclaim(rollingBackOperation.id); + expect(sendOperationService.rollback).toHaveBeenCalledWith(rollingBackOperation.id); }); it('finalize delegates directly to the service', async () => { From 6b56e77ff50e619317ba096e1f66ed8363568e54 Mon Sep 17 00:00:00 2001 From: Kelbie Date: Wed, 20 May 2026 17:42:36 +0100 Subject: [PATCH 2/2] fix(core): keep pending rollback mint-backed --- .changeset/safe-pending-rollbacks.md | 6 ++++ packages/core/api/SendOpsApi.ts | 7 ++-- .../infra/handlers/send/DefaultSendHandler.ts | 8 ----- packages/core/models/Error.ts | 12 +++++++ .../operations/send/SendOperationService.ts | 9 +++--- .../core/test/unit/DefaultSendHandler.test.ts | 32 +++++++++++++++---- .../test/unit/SendOperationService.test.ts | 24 +++++++++++--- packages/core/test/unit/SendOpsApi.test.ts | 9 +++--- 8 files changed, 73 insertions(+), 34 deletions(-) create mode 100644 .changeset/safe-pending-rollbacks.md diff --git a/.changeset/safe-pending-rollbacks.md b/.changeset/safe-pending-rollbacks.md new file mode 100644 index 00000000..dd630bf1 --- /dev/null +++ b/.changeset/safe-pending-rollbacks.md @@ -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. diff --git a/packages/core/api/SendOpsApi.ts b/packages/core/api/SendOpsApi.ts index 94c421ba..95b84a12 100644 --- a/packages/core/api/SendOpsApi.ts +++ b/packages/core/api/SendOpsApi.ts @@ -145,14 +145,13 @@ export class SendOpsApi { * Attempts to reclaim a pending send operation. * * This is intended for sends that are already in flight but still support - * rollback according to the underlying send method. A previous interrupted - * reclaim may leave the operation in `rolling_back`; treat that as retryable. + * rollback according to the underlying send method. */ async reclaim(operationId: string): Promise { const operation = await this.requireOperation(operationId); - if (operation.state !== 'pending' && operation.state !== 'rolling_back') { + if (operation.state !== 'pending') { throw new Error( - `Cannot reclaim operation in state '${operation.state}'. Expected 'pending' or 'rolling_back'.`, + `Cannot reclaim operation in state '${operation.state}'. Expected 'pending'.`, ); } diff --git a/packages/core/infra/handlers/send/DefaultSendHandler.ts b/packages/core/infra/handlers/send/DefaultSendHandler.ts index f79e02fe..170e3a9d 100644 --- a/packages/core/infra/handlers/send/DefaultSendHandler.ts +++ b/packages/core/infra/handlers/send/DefaultSendHandler.ts @@ -248,14 +248,6 @@ export class DefaultSendHandler implements SendMethodHandler<'default'> { operationId: operation.id, }); } else if (operation.state === 'pending' || operation.state === 'rolling_back') { - if (!operation.needsSwap) { - await proofService.restoreProofsToReady(mintUrl, inputProofSecrets); - logger?.info('Rolling back exact-match pending operation - restored original proofs', { - operationId: operation.id, - }); - return; - } - // Complex case: need to reclaim the send proofs by swapping them back const sendSecrets = getSendProofSecrets(operation); diff --git a/packages/core/models/Error.ts b/packages/core/models/Error.ts index 374a4240..0b07d8e2 100644 --- a/packages/core/models/Error.ts +++ b/packages/core/models/Error.ts @@ -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. */ diff --git a/packages/core/operations/send/SendOperationService.ts b/packages/core/operations/send/SendOperationService.ts index 132db11f..9c66f844 100644 --- a/packages/core/operations/send/SendOperationService.ts +++ b/packages/core/operations/send/SendOperationService.ts @@ -30,6 +30,7 @@ import { generateSubId } from '../../utils'; import { UnknownMintError, ProofValidationError, + PendingSendRollbackError, OperationInProgressError, } from '../../models/Error'; import { MintScopedLock } from '../MintScopedLock'; @@ -452,6 +453,7 @@ export class SendOperationService { if ( operation.state === 'finalized' || operation.state === 'rolled_back' || + operation.state === 'rolling_back' || operation.state === 'init' || operation.state === 'executing' ) { @@ -468,10 +470,7 @@ export class SendOperationService { throw new Error(`Send operations of method ${operation.method} can not be rolled back`); } - if ( - (operation.state === 'pending' || operation.state === 'rolling_back') && - operation.method === 'p2pk' - ) { + if (operation.state === 'pending' && operation.method === 'p2pk') { throw new Error('Cannot rollback pending P2PK send operation'); } @@ -499,7 +498,7 @@ export class SendOperationService { }); } catch (error) { if (operation.state === 'pending') { - await this.sendOperationRepository.update(operation); + throw new PendingSendRollbackError(operation.id, error); } throw error; } diff --git a/packages/core/test/unit/DefaultSendHandler.test.ts b/packages/core/test/unit/DefaultSendHandler.test.ts index 147df9e5..351229d6 100644 --- a/packages/core/test/unit/DefaultSendHandler.test.ts +++ b/packages/core/test/unit/DefaultSendHandler.test.ts @@ -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 => ({ id, state: 'init', @@ -455,9 +462,7 @@ describe('DefaultSendHandler', () => { createdByOperationId: 'op-pending', }); - (proofRepository.getProofsByOperationId as Mock).mockImplementation(() => - Promise.resolve([sendProof]), - ); + useOperationProofs([sendProof]); (mockWallet.receive as Mock).mockImplementation(() => Promise.resolve([makeProof('reclaim-1', 99)]), ); @@ -493,7 +498,7 @@ describe('DefaultSendHandler', () => { expect(proofService.releaseProofs).toHaveBeenCalledWith(mintUrl, ['keep-1']); }); - it('rolls back exact-match pending proofs locally without mint access', async () => { + it('reclaims exact-match pending proofs through the mint', async () => { const operation = makePendingOp('op-exact-pending', { needsSwap: false, fee: Amount.zero(), @@ -501,12 +506,25 @@ describe('DefaultSendHandler', () => { 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).toHaveBeenCalledWith(mintUrl, ['proof-100']); - expect(proofService.createOutputsAndIncrementCounters).not.toHaveBeenCalled(); - expect(mockWallet.receive).not.toHaveBeenCalled(); + 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(); }); }); diff --git a/packages/core/test/unit/SendOperationService.test.ts b/packages/core/test/unit/SendOperationService.test.ts index d3d2a7f1..7ca42bcc 100644 --- a/packages/core/test/unit/SendOperationService.test.ts +++ b/packages/core/test/unit/SendOperationService.test.ts @@ -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, @@ -453,7 +454,7 @@ describe('SendOperationService', () => { expect((await sendOpRepo.getById(pendingOp.id))?.state).toBe('finalized'); }); - it('restores a pending operation when reclaim rollback fails before completion', async () => { + it('wraps failed pending reclaim with a safe rollback error', async () => { const pendingOp: PendingSendOperation = { id: 'send-op-reclaim-offline', state: 'pending', @@ -480,6 +481,7 @@ describe('SendOperationService', () => { methodData: {}, }; await sendOpRepo.create(pendingOp); + const cause = new Error('Network request failed'); const customHandler: SendMethodHandler<'default'> = { prepare: mock(async () => { @@ -489,7 +491,7 @@ describe('SendOperationService', () => { throw new Error('not used'); }), rollback: mock(async () => { - throw new Error('Network request failed'); + throw cause; }), recoverExecuting: mock(async () => { throw new Error('not used'); @@ -497,10 +499,22 @@ describe('SendOperationService', () => { }; handlerProvider.register('default', customHandler); - await expect(service.rollback(pendingOp.id)).rejects.toThrow('Network request failed'); - + 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('pending'); + expect(persisted?.state).toBe('rolling_back'); }); }); diff --git a/packages/core/test/unit/SendOpsApi.test.ts b/packages/core/test/unit/SendOpsApi.test.ts index 2f251fad..05387bf6 100644 --- a/packages/core/test/unit/SendOpsApi.test.ts +++ b/packages/core/test/unit/SendOpsApi.test.ts @@ -5,7 +5,6 @@ import type { FinalizedSendOperation, PendingSendOperation, PreparedSendOperation, - RollingBackSendOperation, SendOperation, } from '../../operations/send/SendOperation.ts'; import { SendOpsApi } from '../../api/SendOpsApi.ts'; @@ -148,7 +147,7 @@ describe('SendOpsApi', () => { await expect(api.cancel(pendingOperation.id)).rejects.toThrow("Expected 'prepared'"); }); - it('reclaim allows pending and rolling_back operations', async () => { + it('reclaim only allows pending operations', async () => { (sendOperationService.getOperation as unknown as ReturnType).mockResolvedValueOnce( pendingOperation as SendOperation, ); @@ -156,7 +155,7 @@ describe('SendOpsApi', () => { await api.reclaim(pendingOperation.id); expect(sendOperationService.rollback).toHaveBeenCalledWith(pendingOperation.id); - const rollingBackOperation: RollingBackSendOperation = { + const rollingBackOperation: SendOperation = { ...pendingOperation, state: 'rolling_back', updatedAt: Date.now(), @@ -165,8 +164,8 @@ describe('SendOpsApi', () => { rollingBackOperation as SendOperation, ); - await api.reclaim(rollingBackOperation.id); - expect(sendOperationService.rollback).toHaveBeenCalledWith(rollingBackOperation.id); + await expect(api.reclaim(rollingBackOperation.id)).rejects.toThrow("Expected 'pending'"); + expect(sendOperationService.rollback).toHaveBeenCalledTimes(1); }); it('finalize delegates directly to the service', async () => {