Files
minibits_wallet/src/services/db/transactionsRepo.ts
T
minibits-cashandClaude Opus 5.5 57e8291278 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>
2026-09-29 11:19:36 +02:00

844 lines
24 KiB
TypeScript

import {
INCOMING_TRANSACTION_TYPES,
OUTGOING_TRANSACTION_TYPES,
Transaction,
TransactionDirection,
TransactionStatus,
TransactionType,
} from '../../models/Transaction'
import AppError, {Err} from '../../utils/AppError'
import {log} from '../logService'
import {getInstance} from './instance'
import {dbError} from './errors'
import {normalizeTransactionRecord, normalizeTransactionRows} from './mappers'
export const updateTransaction = function (id: number, fields: Partial<Transaction>): Transaction {
const allowedColumns = ['amount','fee','unit','data', 'keysetId', 'sentFrom','sentTo','profile','memo','paymentId','quote','paymentRequest','zapRequest','inputToken','outputToken','proof','balanceAfter','noteToSelf','tags','status','expiresAt','outpoint'];
try {
// Filter keys against allowed columns
const validKeys = Object.keys(fields).filter(key => allowedColumns.includes(key))
if (validKeys.length === 0) {
// No valid keys to update, return existing transaction
return getTransactionById(id)
}
// Build SET clauses and parameters
const setClauses = validKeys.map(key => `${key} = ?`).join(', ')
const params = validKeys.map(key => {
const value = fields[key as keyof Transaction]
if (key === 'expiresAt' && value instanceof Date) {
return value.toISOString()
}
return value;
})
params.push(id) // Add id at the end for WHERE clause
const query = `
UPDATE transactions
SET ${setClauses}
WHERE id = ?
`
const db = getInstance()
db.execute(query, params)
const updated = getTransactionById(id) // already normalized
log.trace('[updateTransaction] Transaction updated in the database', {id: updated.id, status: updated.status})
return updated as Transaction
} catch (e: any) {
throw dbError('Could not update transaction in database', e)
}
}
/**
* One-time backfill of `mintId` from the url a transaction happened at (the v38
* seed).
*
* Only fills NULLs, so it is idempotent and can never re-point a resolved row at
* whichever mint now answers a url. Rows left NULL belong to mints no longer in the
* wallet — legitimate history, which keeps its `mint` url and simply has no mint to
* act on.
*
* Matching on url is sound ONLY here, at the moment of upgrade: until now a mint's
* url could not change without in-flight rows being rewritten to match, so the two
* still agree. This spends that join once, at rest, instead of on every rename.
*/
export const backfillTransactionMintIds = function (
mints: Array<{id: string; mintUrl: string}>,
): {updated: number} {
if (mints.length === 0) return {updated: 0}
try {
const db = getInstance()
let updated = 0
for (const mint of mints) {
const {rowsAffected} = db.execute(
`UPDATE transactions SET mintId = ? WHERE mint = ? AND mintId IS NULL`,
[mint.id, mint.mintUrl],
)
updated += rowsAffected ?? 0
}
log.info('[backfillTransactionMintIds]', 'Backfilled transaction mint ids', {updated})
return {updated}
} catch (e: any) {
throw dbError('Transaction mintIds could not be backfilled', e)
}
}
export const getTransactionsAsync = async function (limit: number, offset: number, onlyPending: boolean = false) {
let query: string = ''
try {
query = `
SELECT *
FROM transactions
ORDER BY id DESC
LIMIT ? OFFSET ?
`
if(onlyPending) {
query = `
SELECT *
FROM transactions
WHERE status = 'PENDING'
ORDER BY id DESC
LIMIT ? OFFSET ?
`
}
const params = [limit, offset]
// log.trace(query, params)
const db = getInstance()
const {rows} = await db.executeAsync(query, params)
log.trace(`[getTransactionsAsync], Returned ${rows?.length} rows`)
return normalizeTransactionRows(rows)
} catch (e: any) {
throw dbError('Transactions could not be retrieved from the database', e)
}
}
export type TransactionSearchFilters = {
amount: boolean
incoming: boolean
outgoing: boolean
pending: boolean
}
// Shared with the NIP-47 `type` field over NWC — see models/Transaction.ts. The
// onchain types used to be missing here, which silently hid every onchain topup
// and melt from the incoming/outgoing search filters.
const INCOMING_TYPES: string[] = INCOMING_TRANSACTION_TYPES
const OUTGOING_TYPES: string[] = OUTGOING_TRANSACTION_TYPES
const buildSearchWhere = (term: string, filters: TransactionSearchFilters): {clause: string; params: any[]} => {
const conditions: string[] = []
const params: any[] = []
const trimmed = term.trim()
if (trimmed.length > 0) {
if (filters.amount) {
const n = parseInt(trimmed, 10)
if (!isNaN(n)) {
conditions.push('amount = ?')
params.push(n)
}
} else {
const like = `%${trimmed}%`
conditions.push('(memo LIKE ? OR noteToSelf LIKE ? OR sentFrom LIKE ? OR sentTo LIKE ?)')
params.push(like, like, like, like)
}
}
if (filters.incoming) {
const placeholders = INCOMING_TYPES.map(() => '?').join(',')
conditions.push(`type IN (${placeholders})`)
params.push(...INCOMING_TYPES)
}
if (filters.outgoing) {
const placeholders = OUTGOING_TYPES.map(() => '?').join(',')
conditions.push(`type IN (${placeholders})`)
params.push(...OUTGOING_TYPES)
}
if (filters.pending) {
conditions.push("status = 'PENDING'")
}
if (conditions.length === 0) return {clause: '', params: []}
return {clause: 'WHERE ' + conditions.join(' AND '), params}
}
export const searchTransactionsAsync = async function (
term: string,
filters: TransactionSearchFilters,
limit: number,
offset: number,
) {
try {
const {clause, params} = buildSearchWhere(term, filters)
const query = `
SELECT *
FROM transactions
${clause}
ORDER BY id DESC
LIMIT ? OFFSET ?
`
const db = getInstance()
const {rows} = await db.executeAsync(query, [...params, limit, offset])
log.trace(`[searchTransactionsAsync] Returned ${rows?.length} rows`)
return normalizeTransactionRows(rows)
} catch (e: any) {
throw dbError('Transactions search failed', e)
}
}
export const searchTransactionsCount = function (
term: string,
filters: TransactionSearchFilters,
): number {
try {
const {clause, params} = buildSearchWhere(term, filters)
const query = `SELECT COUNT(*) AS total FROM transactions ${clause}`
const db = getInstance()
const {rows} = db.execute(query, params)
return (rows?.item(0)?.total as number) || 0
} catch (e: any) {
throw dbError('Transactions search count failed', e)
}
}
export type NwcTransactionQuery = {
/** NWC speaks sats only, so the caller pins the unit. */
unit: string
/** Restrict to one NIP-47 direction; both when omitted. */
direction?: TransactionDirection
/** Inclusive lower/upper bounds on createdAt. */
from?: Date
until?: Date
/** NIP-47 `unpaid`: also return transactions that have not settled yet. */
unpaid?: boolean
limit: number
offset: number
}
/**
* A page of transactions for a NIP-47 `list_transactions` reply.
*
* Queries SQLite rather than reading transactionsStore.history, for two reasons:
* history keeps only the last `maxTransactionsInHistory` rows, and on a lean
* background NWC wake it is not hydrated at all — so paging over it could never
* answer the request, and off a background wake it would have answered "no
* transactions" for a wallet full of them.
*
* `createdAt` is stored as an ISO-8601 UTC string, so the range bounds compare
* lexicographically.
*/
export const getTransactionsForNwc = function (q: NwcTransactionQuery): Transaction[] {
try {
const types =
q.direction === 'incoming'
? INCOMING_TYPES
: q.direction === 'outgoing'
? OUTGOING_TYPES
: [...INCOMING_TYPES, ...OUTGOING_TYPES]
// RECOVERED settled too — it is a stuck operation that did complete (a mint
// quote paid after expiry), and the money moved.
const statuses = [TransactionStatus.COMPLETED, TransactionStatus.RECOVERED]
if (q.unpaid) statuses.push(TransactionStatus.PENDING)
const conditions = [
'unit = ?',
`type IN (${types.map(() => '?').join(',')})`,
`status IN (${statuses.map(() => '?').join(',')})`,
]
const params: any[] = [q.unit, ...types, ...statuses]
if (q.from) {
conditions.push('createdAt >= ?')
params.push(q.from.toISOString())
}
if (q.until) {
conditions.push('createdAt <= ?')
params.push(q.until.toISOString())
}
const query = `
SELECT *
FROM transactions
WHERE ${conditions.join(' AND ')}
ORDER BY createdAt DESC, id DESC
LIMIT ? OFFSET ?
`
const db = getInstance()
const {rows} = db.execute(query, [...params, q.limit, q.offset])
log.trace(`[getTransactionsForNwc] Returned ${rows?.length} rows`)
return normalizeTransactionRows(rows)
} catch (e: any) {
throw dbError('Transactions could not be retrieved from the database', e)
}
}
export const getPendingTopups = function () {
try {
const query = `
SELECT *
FROM transactions
WHERE status = 'PENDING'
AND type = 'TOPUP'
ORDER BY id DESC
`
const db = getInstance()
const {rows} = db.execute(query)
log.trace(`[getPendingTopups], Returned ${rows?.length} rows`)
return normalizeTransactionRows(rows)
} catch (e: any) {
throw dbError('Transactions could not be retrieved from the database', e)
}
}
export const getPendingTransfers = function () {
try {
const query = `
SELECT *
FROM transactions
WHERE status = 'PENDING'
AND type = 'TRANSFER'
ORDER BY id DESC
`
const db = getInstance()
const {rows} = db.execute(query)
log.trace(`[getPendingTransfers], Returned ${rows?.length} rows`)
return normalizeTransactionRows(rows)
} catch (e: any) {
throw dbError('Transactions could not be retrieved from the database', e)
}
}
/**
* PENDING onchain melts — payments the mint has taken but the chain has not confirmed.
*
* Separate from `getPendingTransfers` (which filters `type = 'TRANSFER'`) rather than
* folded into it, because the two are watched for different reasons and on different
* clocks: a bolt11 transfer is watched to catch a stuck payment and can be EXPIRED,
* while an onchain transfer is waiting on blocks and must never be expired — the melt
* quote's expiry bounds executing the quote, not confirming the payment.
*/
export const getPendingOnchainTransfers = function () {
try {
const query = `
SELECT *
FROM transactions
WHERE status = 'PENDING'
AND type = 'TRANSFER_ONCHAIN'
ORDER BY id DESC
`
const db = getInstance()
const {rows} = db.execute(query)
log.trace(`[getPendingOnchainTransfers], Returned ${rows?.length} rows`)
return normalizeTransactionRows(rows)
} catch (e: any) {
throw dbError('Transactions could not be retrieved from the database', e)
}
}
export const getPendingTopupsCount = function () {
let query: string = ''
try {
query = `
SELECT COUNT(*)
AS total
FROM transactions
WHERE status = 'PENDING'
AND type = 'TOPUP'
`
const db = getInstance()
const {rows} = db.execute(query)
log.trace(`[getPendingTopupsCount], Returned ${rows?.item(0)}`)
return rows?.item(0)['total'] as number
} catch (e: any) {
throw dbError('Transactions could not be retrieved from the database', e)
}
}
export const getPendingTransfersCount = function () {
let query: string = ''
try {
query = `
SELECT COUNT(*)
AS total
FROM transactions
WHERE status = 'PENDING'
AND type = 'TRANSFER'
`
const db = getInstance()
const {rows} = db.execute(query)
log.trace(`[getPendingTransfersCount], Returned ${rows?.item(0)}`)
return rows?.item(0)['total'] as number
} catch (e: any) {
throw dbError('Transactions could not be retrieved from the database', e)
}
}
export const getTransactionsCount = function (status?: TransactionStatus) {
let query: string
let params: any[] = []
try {
if (status) {
// Query to get the count for a specific status along with the total
query = `
WITH total_count AS (
SELECT COUNT(*) AS total FROM transactions
)
SELECT status, COUNT(*) AS count, (SELECT total FROM total_count) AS total
FROM transactions
WHERE status = ?
GROUP BY status
`
params = [status]
} else {
// Query to get the count per status along with the total
query = `
WITH total_count AS (
SELECT COUNT(*) AS total FROM transactions
)
SELECT status, COUNT(*) AS count, (SELECT total FROM total_count) AS total
FROM transactions
GROUP BY status
`
}
const db = getInstance()
const { rows } = db.execute(query, params)
// Convert rows to an object with status counts and a total count
if(rows) {
const counts: Record<string, number> = { total: 0 }
for (let i = 0; i < rows.length; i++) {
const row = rows.item(i)
counts[row.status] = row.count
counts.total = row.total
}
return counts
} else {
return {total: 0}
}
} catch (e: any) {
throw dbError('Transaction count error', e)
}
}
export const getRecentTransactionsByUnitAsync = async (countRecent: number) => {
try {
const query = `
SELECT *
FROM (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY unit ORDER BY createdAt DESC) as row_num
FROM transactions
)
WHERE row_num <= ?
ORDER BY unit, createdAt DESC
`
const params = [countRecent]
const db = getInstance()
const { rows } = await db.executeAsync(query, params)
return normalizeTransactionRows(rows)
} catch (e: any) {
throw dbError('Error retrieving last 3 transactions by unit', e)
}
}
export const getPendingAmount = function () {
try {
const query = `
SELECT
SUM(amount)
FROM transactions
WHERE status = ?
`
const params = [TransactionStatus.PENDING]
const db = getInstance()
const {rows} = db.execute(query, params)
return rows?.item(0)['SUM(amount)']
} catch (e: any) {
throw dbError('Transaction not found', e)
}
}
/**
* Outgoing operations a previous process abandoned before reaching the mint: still
* DRAFT or PREPARED, with no open reservation left (startup already rolled back or
* is holding every reservation). Run at startup only, before any operation can
* start — a live prepare() is DRAFT without a reservation too.
*
* TRANSFERs qualify only once prepare() stamped a quote: a DRAFT with no quote is an
* invoice received over Nostr, legitimately waiting for the user to pay it. EXECUTING
* is deliberately excluded — a mint call may have happened, so it cannot be declared
* unpaid without asking the mint.
*/
export const getAbandonedDraftTransactions = function (): Array<{id: number; data: string}> {
try {
const {rows} = getInstance().execute(
`SELECT id, data FROM transactions
WHERE status IN (?, ?)
AND (type = ? OR (type IN (?, ?) AND quote IS NOT NULL))
AND id NOT IN (SELECT transactionId FROM reservations)`,
[
TransactionStatus.DRAFT,
TransactionStatus.PREPARED,
TransactionType.SEND,
TransactionType.TRANSFER,
TransactionType.TRANSFER_ONCHAIN,
],
)
const result: Array<{id: number; data: string}> = []
for (let i = 0; i < (rows?.length ?? 0); i++) {
const row = rows!.item(i)
result.push({id: row.id, data: row.data})
}
return result
} catch (e: any) {
throw dbError('Could not read abandoned draft transactions', e)
}
}
/**
* 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 = `
SELECT * FROM transactions WHERE id = ?
`
const params = [id]
const db = getInstance()
const {rows} = db.execute(query, params)
return normalizeTransactionRecord(rows?.item(0))
} catch (e: any) {
throw dbError('Transaction not found', e)
}
}
export const getLastTransactionBy = function (
criteria: { paymentId?: string; quote?: string; paymentRequest?: string }
): Transaction {
try {
// === 1. Validate exactly one search criterion ===
const provided = Object.values(criteria).filter(v => v != null)
if (provided.length !== 1) {
throw new AppError(
Err.DATABASE_ERROR,
'Exactly one of paymentId, quote, or paymentRequest must be provided',
)
}
// === 2. Build query with ORDER BY createdAt DESC + LIMIT 1 ===
let query: string
let params: string[]
if (criteria.paymentId != null) {
query = `
SELECT * FROM transactions
WHERE paymentId = ?
ORDER BY createdAt DESC
LIMIT 1
`
params = [criteria.paymentId]
} else if (criteria.quote != null) {
query = `
SELECT * FROM transactions
WHERE quote = ?
ORDER BY createdAt DESC
LIMIT 1
`
params = [criteria.quote]
} else if (criteria.paymentRequest != null
) {
query = `
SELECT * FROM transactions
WHERE paymentRequest = ?
ORDER BY createdAt DESC
LIMIT 1
`
params = [criteria.paymentRequest]
} else {
// This should never happen due to validation above
throw new AppError(Err.DATABASE_ERROR, 'No valid criterion provided')
}
// === 3. Execute ===
const db = getInstance()
const result = db.execute(query, params) // assuming this returns { rows: Row[] }
if (!result.rows || result.rows.length === 0) {
throw new AppError(Err.NOTFOUND_ERROR, `No transaction found for given criteria`)
}
const row = result.rows.item(0) // now guaranteed to be the LATEST one
log.trace('[getLastTransactionBy]', {
criteria,
foundTransactionId: row.id,
createdAt: row.createdAt,
})
return normalizeTransactionRecord(row)
} catch (e: any) {
// dbError passes through the deliberate NOTFOUND/validation AppErrors above
// and wraps anything else as a DATABASE_ERROR.
throw dbError('Failed to fetch transaction', e)
}
}
export const addTransactionAsync = async function (tx: Partial<Transaction>): Promise<Transaction> {
try {
const {type, amount, fee, unit, data, memo, mint, mintId, status} = tx
const now = new Date()
const query = `
INSERT INTO transactions (type, amount, fee, unit, data, memo, mint, mintId, status, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
const params = [type, amount, fee, unit, data, memo, mint, mintId ?? null, status, now.toISOString()]
const db = getInstance()
const result = await db.executeAsync(query, params)
log.debug('[addTransactionAsync] New transaction added to the database', {id: result.insertId, type, mint, mintId, status})
return getTransactionById(result.insertId as number) // already normalized
} catch (e: any) {
throw dbError('Could not store transaction in the database', e)
}
}
// This updates status and appends data to the existing transaction data
export const updateStatusesAsync = async function (
transactionIds: number[],
status: TransactionStatus,
data: string,
) {
if (transactionIds.length === 0) {
return
}
// Bind the ids as parameters rather than interpolating them into the SQL.
const placeholders = transactionIds.map(() => '?').join(',')
const selectQuery = `
SELECT data
FROM transactions
WHERE id IN (${placeholders})
`
try {
const db = getInstance()
const result1 = await db.executeAsync(selectQuery, transactionIds)
if (!result1.rows) {
return
}
const updatedDataArray = []
// We prepare appended transaction data for each transaction retrieved into array
for (const row of result1.rows?._array) {
const currentData = row.data
const updatedData = currentData.slice(0, -1) + ', ' + data + ']'
updatedDataArray.push(updatedData)
}
const updateQuery = `
UPDATE transactions
SET status = ?, data = ?
WHERE id IN (${placeholders})
`
// We update one by one from the array
const params = [status, updatedDataArray.join(','), ...transactionIds]
const result2 = await db.executeAsync(updateQuery, params)
log.info('[updateStatusesAsync]', `Transactions statuses updated in the database`, {numUpdates: result2.rowsAffected, status})
return result2
} catch (e: any) {
throw dbError('Could not update transaction statuses in the database', e)
}
}
export const expireAllAfterRecovery = async function () {
const updateQuery = `
UPDATE transactions
SET status = ?
`
const params = [TransactionStatus.EXPIRED]
const db = getInstance()
const result = await db.executeAsync(updateQuery, params)
log.info('[expireAllAfterRecovery]', `Transactions statuses set to EXPIRED.`)
return result
}
export const deleteTransactionsByStatus = function (status: TransactionStatus) {
try {
const query = `
DELETE FROM transactions
WHERE status = ?
`
const params = [status]
const db = getInstance()
const {rows} = db.execute(query, params)
log.debug('[deleteTransactionsByStatus]', 'Transactions were deleted', {status})
return rows
} catch (e: any) {
throw dbError('Could not delete transactions.', e)
}
}
export const getIncomingPendingCount = function () {
try {
const query = `
SELECT COUNT(*) AS total
FROM transactions
WHERE status = 'PENDING' AND type IN ('RECEIVE', 'RECEIVE_BY_PAYMENT_REQUEST')
`
const db = getInstance()
const {rows} = db.execute(query)
return rows?.item(0)['total'] as number
} catch (e: any) {
throw dbError('Could not get incoming pending count.', e)
}
}
export const deleteIncomingPending = function () {
try {
const query = `
DELETE FROM transactions
WHERE status = 'PENDING' AND type IN ('RECEIVE', 'RECEIVE_BY_PAYMENT_REQUEST')
`
const db = getInstance()
const {rows} = db.execute(query)
log.debug('[deleteIncomingPending]', 'Pending incoming transactions were deleted')
return rows
} catch (e: any) {
throw dbError('Could not delete incoming pending transactions.', e)
}
}
export const deleteTransactionById = function (id: number) {
try {
const query = `
DELETE FROM transactions
WHERE id = ?
LIMIT 1
`
const params = [id]
const db = getInstance()
const {rows} = db.execute(query, params)
log.debug('[deleteTransactionById]', 'Transaction has been deleted', {id})
return rows
} catch (e: any) {
throw dbError('Could not delete transaction.', e)
}
}