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 <noreply@anthropic.com>
This commit is contained in:
minibits-cash
2026-09-29 11:19:36 +02:00
co-authored by Claude Opus 5.5
parent a9f4a6e7a4
commit 57e8291278
6 changed files with 90 additions and 8 deletions
+24
View File
@@ -182,3 +182,27 @@ describe('revertAbandonedDrafts', () => {
expect(proofsStore.getBySecret('swapIn1')!.state).toBe('PENDING') 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([])
})
})
+2
View File
@@ -11,6 +11,7 @@ import {
getTransactionsCount, getTransactionsCount,
getTransactionById, getTransactionById,
getAbandonedDraftTransactions, getAbandonedDraftTransactions,
getTransactionsByQuoteOrPaymentId,
getLastTransactionBy, getLastTransactionBy,
getRecentTransactionsByUnitAsync, getRecentTransactionsByUnitAsync,
getTransactionsAsync, getTransactionsAsync,
@@ -119,6 +120,7 @@ export const Database = {
getTransactionsCount, getTransactionsCount,
getTransactionById, getTransactionById,
getAbandonedDraftTransactions, getAbandonedDraftTransactions,
getTransactionsByQuoteOrPaymentId,
getLastTransactionBy, getLastTransactionBy,
getRecentTransactionsByUnitAsync, getRecentTransactionsByUnitAsync,
getTransactionsAsync, getTransactionsAsync,
+24
View File
@@ -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) { export const getTransactionById = function (id: number) {
try { try {
const query = ` const query = `
@@ -74,7 +74,9 @@ const resolveInterruptedOperationsTask = async function (): Promise<WalletTaskRe
} catch (e: any) { } catch (e: any) {
// Held until the next sweep — most often the mint is unreachable. // Held until the next sweep — most often the mint is unreachable.
errors.push(`tId=${row.transactionId}: ${e.message}`) errors.push(`tId=${row.transactionId}: ${e.message}`)
log.error('[resolveInterruptedOperationsTask] Could not resolve, will retry', { // warn: repeats on every sweep while the mint is unreachable; the failing
// mint call has already reported the error.
log.warn('[resolveInterruptedOperationsTask] Could not resolve, will retry', {
transactionId: row.transactionId, transactionId: row.transactionId,
operationType: row.operationType, operationType: row.operationType,
error: e.message, error: e.message,
@@ -389,7 +389,8 @@ async function execute(prepared: PreparedSendData): Promise<PendingTransaction>
// The mint may have executed the swap: its outputs would exist only // The mint may have executed the swap: its outputs would exist only
// there. Hand the reservation to the resolver, which asks the mint and // there. Hand the reservation to the resolver, which asks the mint and
// restores them from the recorded counter range. // 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, transactionId: tx.id,
error: e.message, error: e.message,
}) })
@@ -215,6 +215,26 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
throw new ValidationError('Could not find mint', {mintUrl}) throw new ValidationError('Could not find mint', {mintUrl})
} }
// One unresolved attempt per quote / invoice. A mint settles a quote at most
// once, so a second melt cannot pay twice — but after the mint rejects it,
// _handleExecuteError reads the SHARED quote state (PAID / PENDING) and would
// settle the second attempt's never-spent inputs as SPENT. Before creating a
// draft, so a refused retry leaves nothing behind. Frees up once the earlier
// attempt is resolved (e.g. REVERTED because it never reached the mint).
const unresolved = Database.getTransactionsByQuoteOrPaymentId(resolved.quoteId, resolved.paymentId).find(
t =>
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 // 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 // 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 // onchain payment cannot be taken back — so the check lives here too, where every
@@ -413,7 +433,8 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
// (they may be spent) nor melting them is safe: hand the reservation // (they may be spent) nor melting them is safe: hand the reservation
// to the resolver, which asks the mint and restores the outputs from // to the resolver, which asks the mint and restores the outputs from
// the recorded counter range if the swap went through. // the recorded counter range if the swap went through.
log.error('[TransferOperationApi.prepare] Preemptive swap outcome unknown, handing to resolver', { // warn: the MintError thrown below already logs (and reports) the error itself.
log.warn('[TransferOperationApi.prepare] Preemptive swap outcome unknown, handing to resolver', {
transactionId, transactionId,
error: swapError.message, error: swapError.message,
}) })
@@ -1050,13 +1071,21 @@ async function _handleExecuteError(
try { try {
meltQuoteCheck = await _checkQuote(tx, resolved.quoteId) meltQuoteCheck = await _checkQuote(tx, resolved.quoteId)
} catch (checkError: any) { } catch (checkError: any) {
// Quote check itself failed — leave the reservation as-is, the orphan // Neither the melt nor the quote check got an answer: the mint may be
// recovery sweep + sync will reconcile on the next startup. // paying right now. Keep the inputs locked and hand the reservation to the
log.error( // resolver, which asks the mint (retrying until it is reachable) and either
'[TransferOperationApi.execute] Quote re-check failed after execute error; reservation left open for recovery', // rolls back or hands over to refresh — no restart needed.
// warn: the MintError thrown below already logs (and reports) the error itself.
log.warn(
'[TransferOperationApi.execute] Quote re-check failed after execute error; handing to resolver',
{transactionId: tx.id, originalError: e.message, checkError: checkError.message}, {transactionId: tx.id, originalError: e.message, checkError: checkError.message},
) )
throw e proofsStore.holdInterruptedReservation(reservation)
WalletTask.resolveInterruptedQueue()
throw new MintError(
'The mint could not be reached, so the payment status is unknown. Your ecash stays locked until the wallet confirms the payment with the mint.',
{transactionId: tx.id, caller: 'TransferOperationApi.execute', cause: e.message},
)
} }
// ── PAID despite client error → recover change, mark RECOVERED ────── // ── PAID despite client error → recover change, mark RECOVERED ──────