From 57e829127884a376db485737bc892c3ec327e530 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Tue, 29 Sep 2026 11:19:36 +0200 Subject: [PATCH] Resolve unconfirmed melts in-session, block re-paying an unresolved quote A melt whose request and follow-up quote check both failed (mint unreachable) left its reservation open but unowned: inputs stayed locked until the next app restart and the user saw "Lightning payment failed". The reservation is now held and handed to the interrupted-operation resolver right away. It retries on each performChecks until the mint answers, then rolls back (tx REVERTED) or hands over to refresh. The user is told the payment status is unknown and the ecash is locked until it is confirmed. TransferOperationApi.prepare now refuses a transfer while another attempt for the same melt quote or invoice (payment hash) is PENDING, EXECUTING, or held by the resolver. The screen's Pay button re-uses the same quote after a failure. A mint settles a quote once, so this could not pay twice, but after the mint rejects the second melt, _handleExecuteError reads the shared quote state and would settle the second attempt's never-spent inputs as SPENT. The check runs before the draft is created and clears once the earlier attempt is resolved. Sentry noise: the "handing to resolver" logs and the resolver's per-sweep retry log are now warn. The thrown MintError (the AppError constructor logs it) and the failing mint call already report each incident once. Verified on the simulator: network cut mid-melt, retry refused, resolved to REVERTED with the balance restored once back online. Co-Authored-By: Claude Opus 5.5 --- __tests__/interruptedReservations.test.ts | 24 +++++++++++ src/services/db/index.ts | 2 + src/services/db/transactionsRepo.ts | 24 +++++++++++ .../operations/interruptedOperations.ts | 4 +- .../wallet/operations/sendOperationApi.ts | 3 +- .../wallet/operations/transferOperationApi.ts | 41 ++++++++++++++++--- 6 files changed, 90 insertions(+), 8 deletions(-) diff --git a/__tests__/interruptedReservations.test.ts b/__tests__/interruptedReservations.test.ts index bad11777..ce6f993f 100644 --- a/__tests__/interruptedReservations.test.ts +++ b/__tests__/interruptedReservations.test.ts @@ -182,3 +182,27 @@ describe('revertAbandonedDrafts', () => { expect(proofsStore.getBySecret('swapIn1')!.state).toBe('PENDING') }) }) + +describe('getTransactionsByQuoteOrPaymentId', () => { + const insert = (id: number, status: string, quote: string | null, paymentId: string | null) => + Database.getInstance().execute( + `INSERT INTO transactions (id, type, amount, fee, unit, mint, status, quote, paymentId, data, createdAt) + VALUES (?, 'TRANSFER', 1, 0, 'sat', ?, ?, ?, ?, '[]', ?)`, + [id, MINT_URL, status, quote, paymentId, new Date().toISOString()], + ) + + test('matches the same quote, or the same invoice under a different quote', () => { + Database.getInstance().execute('DELETE FROM transactions') + insert(50, 'ERROR', 'q1', 'hash1') // first attempt, e.g. held for the resolver + insert(51, 'PENDING', 'q2', 'hash1') // same invoice, new quote + insert(52, 'PENDING', 'q3', 'hash3') // unrelated + insert(53, 'DRAFT', 'q1', null) + + const ids = (quote: string, hash?: string) => + Database.getTransactionsByQuoteOrPaymentId(quote, hash).map(t => t.id).sort() + + expect(ids('q1', 'hash1')).toEqual([50, 51, 53]) + expect(ids('q1')).toEqual([50, 53]) // no payment hash (onchain): quote only + expect(ids('q9', 'hash9')).toEqual([]) + }) +}) diff --git a/src/services/db/index.ts b/src/services/db/index.ts index 59f71380..3b841747 100644 --- a/src/services/db/index.ts +++ b/src/services/db/index.ts @@ -11,6 +11,7 @@ import { getTransactionsCount, getTransactionById, getAbandonedDraftTransactions, + getTransactionsByQuoteOrPaymentId, getLastTransactionBy, getRecentTransactionsByUnitAsync, getTransactionsAsync, @@ -119,6 +120,7 @@ export const Database = { getTransactionsCount, getTransactionById, getAbandonedDraftTransactions, + getTransactionsByQuoteOrPaymentId, getLastTransactionBy, getRecentTransactionsByUnitAsync, getTransactionsAsync, diff --git a/src/services/db/transactionsRepo.ts b/src/services/db/transactionsRepo.ts index c3d53640..8a401f44 100644 --- a/src/services/db/transactionsRepo.ts +++ b/src/services/db/transactionsRepo.ts @@ -555,6 +555,30 @@ export const getAbandonedDraftTransactions = function (): Array<{id: number; dat } } +/** + * Transactions paying the same melt quote, or the same invoice (payment hash). + * Any status: the caller decides which ones are still unresolved. + */ +export const getTransactionsByQuoteOrPaymentId = function ( + quote: string, + paymentId?: string, +): Array<{id: number; status: TransactionStatus}> { + try { + const {rows} = getInstance().execute( + `SELECT id, status FROM transactions WHERE quote = ? OR (? IS NOT NULL AND paymentId = ?)`, + [quote, paymentId ?? null, paymentId ?? null], + ) + const result: Array<{id: number; status: TransactionStatus}> = [] + for (let i = 0; i < (rows?.length ?? 0); i++) { + const row = rows!.item(i) + result.push({id: row.id, status: row.status}) + } + return result + } catch (e: any) { + throw dbError('Could not read transactions by quote', e) + } +} + export const getTransactionById = function (id: number) { try { const query = ` diff --git a/src/services/wallet/operations/interruptedOperations.ts b/src/services/wallet/operations/interruptedOperations.ts index a7d2915b..b08141e4 100644 --- a/src/services/wallet/operations/interruptedOperations.ts +++ b/src/services/wallet/operations/interruptedOperations.ts @@ -74,7 +74,9 @@ const resolveInterruptedOperationsTask = async function (): Promise // The mint may have executed the swap: its outputs would exist only // there. Hand the reservation to the resolver, which asks the mint and // restores them from the recorded counter range. - log.error('[SendOperationApi.execute] Swap outcome unknown, handing to resolver', { + // warn: the MintError returned below already logs (and reports) the error itself. + log.warn('[SendOperationApi.execute] Swap outcome unknown, handing to resolver', { transactionId: tx.id, error: e.message, }) diff --git a/src/services/wallet/operations/transferOperationApi.ts b/src/services/wallet/operations/transferOperationApi.ts index d6e9b669..a95b1cce 100644 --- a/src/services/wallet/operations/transferOperationApi.ts +++ b/src/services/wallet/operations/transferOperationApi.ts @@ -215,6 +215,26 @@ async function prepare(input: PrepareTransferInput): Promise + t.id !== draftTransactionId && + (t.status === TransactionStatus.PENDING || + t.status === TransactionStatus.EXECUTING || + proofsStore.isTransactionInterrupted(t.id)), + ) + if (unresolved) { + throw new ValidationError( + 'A previous attempt to pay this invoice is still being confirmed with the mint. Please wait for it to finish.', + {previousTransactionId: unresolved.id, status: unresolved.status}, + ) + } + // Second line of defence on the destination network. The Pay screen already refuses // non-mainnet addresses, but this is the last point before real money moves and an // onchain payment cannot be taken back — so the check lives here too, where every @@ -413,7 +433,8 @@ async function prepare(input: PrepareTransferInput): Promise