mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 11:18:24 +00:00
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:
co-authored by
Claude Opus 5.5
parent
a9f4a6e7a4
commit
57e8291278
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 ──────
|
||||||
|
|||||||
Reference in New Issue
Block a user