diff --git a/apps/web-wallet/app/features/receive/cashu-receive-swap-hooks.ts b/apps/web-wallet/app/features/receive/cashu-receive-swap-hooks.ts index f5c56f52e..e207b2ac5 100644 --- a/apps/web-wallet/app/features/receive/cashu-receive-swap-hooks.ts +++ b/apps/web-wallet/app/features/receive/cashu-receive-swap-hooks.ts @@ -12,6 +12,7 @@ import { useSelectItemsWithOnlineAccount, } from '../accounts/account-hooks'; import type { AgicashDbCashuReceiveSwap } from '../agicash-db/database'; +import { ConcurrencyError } from '../shared/error'; import { useUser } from '../user/user-hooks'; import type { CashuReceiveSwap } from './cashu-receive-swap'; import { useCashuReceiveSwapRepository } from './cashu-receive-swap-repository'; @@ -163,8 +164,13 @@ export function useProcessCashuReceiveSwapTasks() { const account = getCashuAccount(swap.accountId); await receiveSwapService.completeSwap(account, swap); }, - retry: 3, - throwOnError: true, + retry: (failureCount, error) => { + if (error instanceof ConcurrencyError) { + return true; + } + return failureCount < 3; + }, + throwOnError: (error) => !(error instanceof ConcurrencyError), onError: (error, tokenHash) => { console.error('Error finalizing receive swap', { cause: error, diff --git a/apps/web-wallet/app/features/receive/cashu-receive-swap-service.ts b/apps/web-wallet/app/features/receive/cashu-receive-swap-service.ts index 5fdd7d302..7b0166d0b 100644 --- a/apps/web-wallet/app/features/receive/cashu-receive-swap-service.ts +++ b/apps/web-wallet/app/features/receive/cashu-receive-swap-service.ts @@ -9,11 +9,13 @@ import { CashuErrorCodes, areMintUrlsEqual, getCashuUnit, + isTransientCashuSwapError, sumProofs, } from '~/lib/cashu'; import { Money } from '~/lib/money'; import type { CashuAccount } from '../accounts/account'; import { tokenToMoney } from '../shared/cashu'; +import { ConcurrencyError } from '../shared/error'; import type { CashuReceiveSwap } from './cashu-receive-swap'; import { type CashuReceiveSwapRepository, @@ -244,6 +246,11 @@ export class CashuReceiveSwapService { // TODO: make sure these proofs are not already in our balance and that they are not spent return proofs; } + + if (isTransientCashuSwapError(error)) { + throw new ConcurrencyError(error.message); + } + throw error; } } diff --git a/apps/web-wallet/app/features/send/cashu-send-swap-hooks.ts b/apps/web-wallet/app/features/send/cashu-send-swap-hooks.ts index 7455677a2..43bed385e 100644 --- a/apps/web-wallet/app/features/send/cashu-send-swap-hooks.ts +++ b/apps/web-wallet/app/features/send/cashu-send-swap-hooks.ts @@ -411,8 +411,13 @@ export function useProcessCashuSendSwapTasks() { account, }); }, - retry: 3, - throwOnError: true, + retry: (failureCount, error) => { + if (error instanceof ConcurrencyError) { + return true; + } + return failureCount < 3; + }, + throwOnError: (error) => !(error instanceof ConcurrencyError), onError: (error, swapId) => { console.error('Error swapping for proofs to send', { cause: error, diff --git a/apps/web-wallet/app/features/send/cashu-send-swap-service.ts b/apps/web-wallet/app/features/send/cashu-send-swap-service.ts index e611f55aa..d54a84cdb 100644 --- a/apps/web-wallet/app/features/send/cashu-send-swap-service.ts +++ b/apps/web-wallet/app/features/send/cashu-send-swap-service.ts @@ -12,6 +12,7 @@ import { type ExtendedCashuWallet, getCashuProtocolUnit, getCashuUnit, + isTransientCashuSwapError, sumProofs, } from '~/lib/cashu'; import { Money } from '~/lib/money'; @@ -21,7 +22,7 @@ import { } from '../receive/cashu-receive-swap-service'; import { getTokenHash } from '../shared/cashu'; import { getDefaultUnit } from '../shared/currencies'; -import { DomainError } from '../shared/error'; +import { ConcurrencyError, DomainError } from '../shared/error'; import type { CashuSendSwap } from './cashu-send-swap'; import { type CashuSendSwapRepository, @@ -457,6 +458,10 @@ export class CashuSendSwapService { }; } + if (isTransientCashuSwapError(error)) { + throw new ConcurrencyError(error.message); + } + throw error; } } diff --git a/apps/web-wallet/app/lib/cashu/error-codes.test.ts b/apps/web-wallet/app/lib/cashu/error-codes.test.ts new file mode 100644 index 000000000..c42f37295 --- /dev/null +++ b/apps/web-wallet/app/lib/cashu/error-codes.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'bun:test'; +import { MintOperationError } from '@cashu/cashu-ts'; +import { CashuErrorCodes, isTransientCashuSwapError } from './error-codes'; + +const mintError = (code: CashuErrorCodes) => + new MintOperationError(code, `mint error ${code}`); + +describe('isTransientCashuSwapError', () => { + test('treats OUTPUTS_ARE_PENDING (11004) as transient', () => { + expect( + isTransientCashuSwapError(mintError(CashuErrorCodes.OUTPUTS_ARE_PENDING)), + ).toBe(true); + }); + + test('treats PROOFS_ARE_PENDING (11002) as transient', () => { + expect( + isTransientCashuSwapError(mintError(CashuErrorCodes.PROOFS_ARE_PENDING)), + ).toBe(true); + }); + + test('does not treat OUTPUT_ALREADY_SIGNED (11003) as transient', () => { + expect( + isTransientCashuSwapError( + mintError(CashuErrorCodes.OUTPUT_ALREADY_SIGNED), + ), + ).toBe(false); + }); + + test('does not treat TOKEN_ALREADY_SPENT (11001) as transient', () => { + expect( + isTransientCashuSwapError(mintError(CashuErrorCodes.TOKEN_ALREADY_SPENT)), + ).toBe(false); + }); + + test('does not treat other mint operation errors as transient', () => { + expect( + isTransientCashuSwapError( + mintError(CashuErrorCodes.TRANSACTION_NOT_BALANCED), + ), + ).toBe(false); + expect( + isTransientCashuSwapError(mintError(CashuErrorCodes.KEYSET_INACTIVE)), + ).toBe(false); + }); + + test('does not treat non-mint errors as transient', () => { + expect(isTransientCashuSwapError(new Error('boom'))).toBe(false); + expect(isTransientCashuSwapError('outputs are pending')).toBe(false); + expect(isTransientCashuSwapError(undefined)).toBe(false); + }); +}); diff --git a/apps/web-wallet/app/lib/cashu/error-codes.ts b/apps/web-wallet/app/lib/cashu/error-codes.ts index 65fb677c8..725d4b54c 100644 --- a/apps/web-wallet/app/lib/cashu/error-codes.ts +++ b/apps/web-wallet/app/lib/cashu/error-codes.ts @@ -1,3 +1,5 @@ +import { MintOperationError } from '@cashu/cashu-ts'; + /** * Based on https://github.com/cashubtc/nuts/blob/main/error_codes.md */ @@ -216,3 +218,18 @@ export enum CashuErrorCodes { */ BAT_MINT_RATE_LIMIT_EXCEEDED = 31004, } + +/** + * Whether the error is a transient swap error returned by the mint: the outputs or + * input proofs are still in flight in a parallel operation (codes 11002 and 11004). + * The mint settles them on its own, so the swap should be retried rather than failed. + * @see https://github.com/cashubtc/nuts/blob/main/error_codes.md + */ +export const isTransientCashuSwapError = ( + error: unknown, +): error is MintOperationError => + error instanceof MintOperationError && + [ + CashuErrorCodes.PROOFS_ARE_PENDING, + CashuErrorCodes.OUTPUTS_ARE_PENDING, + ].includes(error.code);