mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 11:18:24 +00:00
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>
844 lines
24 KiB
TypeScript
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)
|
|
}
|
|
}
|