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