mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
Replaces react-native-quick-sqlite (8.2.7) with @op-engineering/op-sqlite
(16.2.0). The motivating bug: quick-sqlite's JSI binding silently dropped
non-bindable JS objects (e.g. a cashu-ts Amount instance), shifting the
parameter array and corrupting proof rows with no exception.
A new connection adapter (services/db/connection.ts) is the single seam to
the native library and reconciles op-sqlite's API differences so sqlite.ts
is nearly untouched:
- op-sqlite execute() is async; sync is executeSync() -> our execute maps
to executeSync, executeAsync maps to execute
- executeBatch() is async-only with no insertId -> synchronous atomic batch
emulated with BEGIN/COMMIT/ROLLBACK over executeSync
- rows is a plain array -> re-wrapped into the WebSQL { _array, length,
item() } shape the existing query code expects
The adapter also centralizes parameter sanitization (sanitizeParams): every
bind is coerced deliberately (Date -> ISO, numeric-like objects -> Number) or
rejected loudly, so the silent-skip footgun is structurally impossible.
Also collapses 33 repetitive try/catch AppError blocks into a dbError()
helper, which uniformly passes deliberate AppErrors (e.g. NOTFOUND_ERROR)
through instead of flattening them to DATABASE_ERROR (-109 net lines).
Tests: 121/121 pass, no regressions. Note: native rebuild and on-device
verification of the minibits.db file location are still required before ship.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1538 lines
42 KiB
TypeScript
1538 lines
42 KiB
TypeScript
import {
|
|
DbConnection,
|
|
open,
|
|
SQLBatchTuple,
|
|
} from './db/connection'
|
|
import {Proof, ProofState} from '../models/Proof'
|
|
import {CashuProof} from './cashu/cashuUtils'
|
|
import {
|
|
Transaction,
|
|
TransactionStatus,
|
|
} from '../models/Transaction'
|
|
import AppError, {Err} from '../utils/AppError'
|
|
import {log} from './logService'
|
|
import {ProofRecord} from '../models/Proof'
|
|
import { isAlive } from 'mobx-state-tree'
|
|
|
|
/**
|
|
* Normalize an arbitrary caught error into an AppError for the DB layer.
|
|
*
|
|
* Existing AppErrors (e.g. a NOTFOUND_ERROR raised deliberately inside a query)
|
|
* pass through unchanged so their specific `name`/code isn't flattened into a
|
|
* generic DATABASE_ERROR. Everything else is wrapped with the supplied message.
|
|
*/
|
|
const dbError = (message: string, e: any): AppError =>
|
|
e instanceof AppError ? e : new AppError(Err.DATABASE_ERROR, message, e?.message)
|
|
|
|
// Helper functions to normalize transaction records with Date objects
|
|
const normalizeTransactionRecord = function (r: any) {
|
|
if (r.createdAt) r.createdAt = new Date(r.createdAt);
|
|
if (r.expiresAt) r.expiresAt = r.expiresAt ? new Date(r.expiresAt) : null;
|
|
return r as Transaction;
|
|
}
|
|
|
|
const normalizeTransactionRows = function(rows: any) {
|
|
return rows?._array.map(normalizeTransactionRecord) as Transaction[];
|
|
}
|
|
|
|
let _db: DbConnection
|
|
|
|
const _dbVersion = 26 // Update this if db changes require migrations
|
|
|
|
const getInstance = function () {
|
|
if (!_db) {
|
|
// 1. creates database
|
|
_db = _createDatabaseInstance() as DbConnection
|
|
|
|
// 2. Runs possible migrations and sets version
|
|
_createOrUpdateSchema(_db)
|
|
}
|
|
|
|
return _db
|
|
}
|
|
|
|
const _createDatabaseInstance = function () {
|
|
try {
|
|
const instance = open({name: 'minibits.db'})
|
|
return instance as DbConnection
|
|
} catch (e: any) {
|
|
throw dbError('Could not create or open database', e)
|
|
}
|
|
}
|
|
|
|
const _createOrUpdateSchema = function (db: DbConnection) {
|
|
const creationQueries = [
|
|
[
|
|
`CREATE TABLE IF NOT EXISTS transactions (
|
|
id INTEGER PRIMARY KEY NOT NULL,
|
|
paymentId TEXT,
|
|
type TEXT,
|
|
amount INTEGER,
|
|
unit TEXT,
|
|
fee INTEGER,
|
|
data TEXT,
|
|
keysetId TEXT,
|
|
sentFrom TEXT,
|
|
sentTo TEXT,
|
|
profile TEXT,
|
|
memo TEXT,
|
|
mint TEXT,
|
|
quote TEXT,
|
|
paymentRequest TEXT,
|
|
zapRequest TEXT,
|
|
inputToken TEXT,
|
|
outputToken TEXT,
|
|
proof TEXT,
|
|
balanceAfter INTEGER,
|
|
noteToSelf TEXT,
|
|
tags TEXT,
|
|
status TEXT,
|
|
expiresAt TEXT,
|
|
createdAt TEXT
|
|
)`,
|
|
],
|
|
[
|
|
`CREATE TABLE IF NOT EXISTS proofs (
|
|
id TEXT NOT NULL,
|
|
amount INTEGER NOT NULL,
|
|
secret TEXT PRIMARY KEY NOT NULL,
|
|
C TEXT NOT NULL,
|
|
dleq_r TEXT,
|
|
dleq_s TEXT,
|
|
dleq_e TEXT,
|
|
unit TEXT,
|
|
tId INTEGER,
|
|
mintUrl TEXT,
|
|
state TEXT NOT NULL DEFAULT 'UNSPENT',
|
|
updatedAt TEXT
|
|
)`,
|
|
],
|
|
[
|
|
`CREATE TABLE IF NOT EXISTS dbversion (
|
|
id INTEGER PRIMARY KEY NOT NULL,
|
|
version INTEGER,
|
|
createdAt TEXT
|
|
)`,
|
|
],
|
|
[
|
|
// Open outgoing-operation reservations. A row exists only while a
|
|
// reservation is in-flight (between reserve() and commit()/rollback()).
|
|
// Orphans (process died mid-operation) are detected and rolled back
|
|
// at startup.
|
|
`CREATE TABLE IF NOT EXISTS reservations (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
transactionId INTEGER NOT NULL,
|
|
mintUrl TEXT NOT NULL,
|
|
unit TEXT NOT NULL,
|
|
operationType TEXT NOT NULL,
|
|
lockedProofs TEXT NOT NULL,
|
|
createdAt TEXT NOT NULL
|
|
)`,
|
|
],
|
|
] as SQLBatchTuple[]
|
|
|
|
try {
|
|
const {rowsAffected} = db.executeBatch(creationQueries)
|
|
|
|
if (rowsAffected && rowsAffected > 0) {
|
|
log.info('[_createOrUpdateSchema] New database schema created')
|
|
}
|
|
|
|
const {version} = getDatabaseVersion(db)
|
|
log.info('[_createOrUpdateSchema]', `Device database version: ${version}`)
|
|
|
|
// Trigger migrations if there is versions mismatch
|
|
if (version < _dbVersion) {
|
|
_runMigrations(db)
|
|
}
|
|
|
|
} catch (e: any) {
|
|
throw dbError('Could not create or update database schema', e)
|
|
}
|
|
}
|
|
|
|
// Run database migrations in case on device version of schema is not yet set or outdated
|
|
|
|
const _runMigrations = function (db: DbConnection) {
|
|
const now = new Date()
|
|
const {version} = getDatabaseVersion(db)
|
|
|
|
let currentVersion = version
|
|
let migrationQueries: SQLBatchTuple[] = []
|
|
|
|
// Database migrations sequence based on local version numbers
|
|
if (currentVersion < 19) {
|
|
migrationQueries.push([
|
|
`DROP TABLE usersettings`,
|
|
])
|
|
|
|
log.info(`Prepared database migrations from ${currentVersion} -> 19`)
|
|
}
|
|
|
|
if (currentVersion < 20) {
|
|
migrationQueries.push([
|
|
`ALTER TABLE transactions
|
|
ADD COLUMN paymentId TEXT`,
|
|
])
|
|
|
|
log.info(`Prepared database migrations from ${currentVersion} -> 20`)
|
|
}
|
|
|
|
if (currentVersion < 21) {
|
|
migrationQueries.push([
|
|
`ALTER TABLE transactions
|
|
ADD COLUMN quote TEXT`,
|
|
])
|
|
|
|
log.info(`Prepared database migrations from ${currentVersion} -> 21`)
|
|
}
|
|
|
|
if (currentVersion < 22) {
|
|
migrationQueries.push([
|
|
`ALTER TABLE transactions
|
|
ADD COLUMN paymentRequest TEXT`
|
|
])
|
|
|
|
migrationQueries.push([
|
|
`ALTER TABLE transactions
|
|
ADD COLUMN expiresAt TEXT`
|
|
])
|
|
|
|
log.info(`Prepared database migrations from ${currentVersion} -> 22`)
|
|
}
|
|
|
|
if (currentVersion < 23) {
|
|
migrationQueries.push([
|
|
`ALTER TABLE proofs
|
|
ADD COLUMN dleq_r TEXT`
|
|
])
|
|
|
|
migrationQueries.push([
|
|
`ALTER TABLE proofs
|
|
ADD COLUMN dleq_s TEXT`
|
|
])
|
|
|
|
migrationQueries.push([
|
|
`ALTER TABLE proofs
|
|
ADD COLUMN dleq_e TEXT`
|
|
])
|
|
|
|
log.info(`Prepared database migrations from ${currentVersion} -> 23`)
|
|
}
|
|
|
|
if (currentVersion < 24) {
|
|
migrationQueries.push([
|
|
`ALTER TABLE transactions
|
|
ADD COLUMN keysetId TEXT`
|
|
])
|
|
log.info(`Prepared database migrations from ${currentVersion} -> 24`)
|
|
}
|
|
|
|
if (currentVersion < 25) {
|
|
// Replace isPending/isSpent boolean columns with a single state TEXT column.
|
|
// SQLite does not support DROP COLUMN in older versions, so we recreate the table.
|
|
migrationQueries.push([
|
|
`CREATE TABLE proofs_v25 (
|
|
id TEXT NOT NULL,
|
|
amount INTEGER NOT NULL,
|
|
secret TEXT PRIMARY KEY NOT NULL,
|
|
C TEXT NOT NULL,
|
|
dleq_r TEXT,
|
|
dleq_s TEXT,
|
|
dleq_e TEXT,
|
|
unit TEXT,
|
|
tId INTEGER,
|
|
mintUrl TEXT,
|
|
state TEXT NOT NULL DEFAULT 'UNSPENT',
|
|
updatedAt TEXT
|
|
)`,
|
|
])
|
|
migrationQueries.push([
|
|
`INSERT INTO proofs_v25
|
|
(id, amount, secret, C, dleq_r, dleq_s, dleq_e, unit, tId, mintUrl, state, updatedAt)
|
|
SELECT
|
|
id, amount, secret, C, dleq_r, dleq_s, dleq_e, unit, tId, mintUrl,
|
|
CASE
|
|
WHEN isSpent = 1 THEN 'SPENT'
|
|
WHEN isPending = 1 THEN 'PENDING'
|
|
ELSE 'UNSPENT'
|
|
END,
|
|
updatedAt
|
|
FROM proofs`,
|
|
])
|
|
migrationQueries.push([`DROP TABLE proofs`])
|
|
migrationQueries.push([`ALTER TABLE proofs_v25 RENAME TO proofs`])
|
|
|
|
log.info(`Prepared database migrations from ${currentVersion} -> 25`)
|
|
}
|
|
|
|
if (currentVersion < 26) {
|
|
// Add reservations table for atomic proof reservations (Phase 5).
|
|
migrationQueries.push([
|
|
`CREATE TABLE IF NOT EXISTS reservations (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
transactionId INTEGER NOT NULL,
|
|
mintUrl TEXT NOT NULL,
|
|
unit TEXT NOT NULL,
|
|
operationType TEXT NOT NULL,
|
|
lockedProofs TEXT NOT NULL,
|
|
createdAt TEXT NOT NULL
|
|
)`,
|
|
])
|
|
|
|
log.info(`Prepared database migrations from ${currentVersion} -> 26`)
|
|
}
|
|
|
|
// Update db version as a part of migration sqls
|
|
migrationQueries.push([
|
|
`INSERT OR REPLACE INTO dbversion (id, version, createdAt)
|
|
VALUES (?, ?, ?)`,
|
|
[1, _dbVersion, now.toISOString()],
|
|
])
|
|
|
|
try {
|
|
const {rowsAffected} = db.executeBatch(migrationQueries)
|
|
|
|
if (rowsAffected && rowsAffected > 0) {
|
|
log.info(`Completed database migrations to version ${_dbVersion}`)
|
|
}
|
|
} catch (e: any) {
|
|
// silent
|
|
log.info(
|
|
Err.DATABASE_ERROR,
|
|
'Database migrations error: ' + JSON.stringify(e),
|
|
)
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Exported functions
|
|
*/
|
|
|
|
const cleanAll = function () {
|
|
const dropQueries = [
|
|
['DROP TABLE transactions'],
|
|
['DROP TABLE proofs'],
|
|
['DROP TABLE dbversion'],
|
|
] as SQLBatchTuple[]
|
|
|
|
try {
|
|
const db = getInstance()
|
|
const {rowsAffected} = db.executeBatch(dropQueries)
|
|
|
|
if (rowsAffected && rowsAffected > 0) {
|
|
log.info('[cleanAll]', 'Database tables were deleted')
|
|
}
|
|
} catch (e: any) {
|
|
throw dbError('Could not delete database schema', e)
|
|
}
|
|
}
|
|
|
|
|
|
const getDatabaseVersion = function (db: DbConnection): {version: number} {
|
|
try {
|
|
const query = `
|
|
SELECT version FROM dbVersion
|
|
`
|
|
|
|
const {rows} = db.execute(query)
|
|
|
|
if (!rows?.item(0)) {
|
|
// On first run, insert current version record
|
|
const now = new Date()
|
|
const insertQuery = `
|
|
INSERT OR REPLACE INTO dbversion (id, version, createdAt)
|
|
VALUES (?, ?, ?)
|
|
`
|
|
const params = [1, _dbVersion, now.toISOString()]
|
|
db.execute(insertQuery, params)
|
|
|
|
return {version: _dbVersion}
|
|
}
|
|
|
|
return rows?.item(0)
|
|
|
|
} catch (e: any) {
|
|
throw dbError('Could not get database version', e)
|
|
}
|
|
}
|
|
/*
|
|
* Transactions
|
|
*/
|
|
|
|
|
|
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'];
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
|
|
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
|
|
}
|
|
|
|
const INCOMING_TYPES = ['RECEIVE', 'RECEIVE_OFFLINE', 'RECEIVE_BY_PAYMENT_REQUEST', 'RECEIVE_NOSTR', 'TOPUP']
|
|
const OUTGOING_TYPES = ['SEND', 'TRANSFER']
|
|
|
|
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}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
|
|
const addTransactionAsync = async function (tx: Partial<Transaction>): Promise<Transaction> {
|
|
try {
|
|
const {type, amount, fee, unit, data, memo, mint, status} = tx
|
|
const now = new Date()
|
|
|
|
const query = `
|
|
INSERT INTO transactions (type, amount, fee, unit, data, memo, mint, status, createdAt)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`
|
|
const params = [type, amount, fee, unit, data, memo, mint, status, now.toISOString()]
|
|
|
|
const db = getInstance()
|
|
const result = await db.executeAsync(query, params)
|
|
|
|
log.info('[addTransactionAsync]', 'New transaction added to the database', {id: result.insertId, type, mint, 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
|
|
const updateStatusesAsync = async function (
|
|
transactionIds: number[],
|
|
status: TransactionStatus,
|
|
data: string,
|
|
) {
|
|
const transactionIdsString = transactionIds.join(',')
|
|
|
|
const selectQuery = `
|
|
SELECT data
|
|
FROM transactions
|
|
WHERE id IN (${transactionIdsString})
|
|
`
|
|
|
|
try {
|
|
const db = getInstance()
|
|
const result1 = await db.executeAsync(selectQuery)
|
|
|
|
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 (${transactionIdsString})
|
|
`
|
|
// We update one by one from the array
|
|
const params = [status, updatedDataArray.join(',')]
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
const expireAllAfterRecovery = async function () {
|
|
const updateQuery = `
|
|
UPDATE transactions
|
|
SET status = ?
|
|
`
|
|
const params = [TransactionStatus.EXPIRED]
|
|
const result = await _db.executeAsync(updateQuery, params)
|
|
log.info('[expireAllAfterRecovery]', `Transactions statuses set to EXPIRED.`)
|
|
return result
|
|
}
|
|
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Proofs
|
|
*/
|
|
const addOrUpdateProof = function (
|
|
proof: Proof,
|
|
state: ProofState = 'UNSPENT',
|
|
) {
|
|
try {
|
|
const now = new Date()
|
|
|
|
const query = `
|
|
INSERT OR REPLACE INTO proofs (id, amount, secret, C, dleq_r, dleq_s, dleq_e, unit, tId, mintUrl, state, updatedAt)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`
|
|
const params = [
|
|
proof.id,
|
|
proof.amount,
|
|
proof.secret,
|
|
proof.C,
|
|
proof.dleq ? proof.dleq.r : null,
|
|
proof.dleq ? proof.dleq.s : null,
|
|
proof.dleq ? proof.dleq.e : null,
|
|
proof.unit,
|
|
proof.tId,
|
|
proof.mintUrl,
|
|
state,
|
|
now.toISOString(),
|
|
]
|
|
|
|
const db = getInstance()
|
|
const result = db.execute(query, params)
|
|
// DO NOT log proof secrets to Sentry
|
|
log.info('[addOrUpdateProof]', `Proof added or updated in the database`,
|
|
{id: result.insertId, tId: proof.tId, state},
|
|
)
|
|
|
|
const newProof = getProofById(result.insertId as number)
|
|
|
|
return newProof as ProofRecord
|
|
} catch (e: any) {
|
|
throw dbError('Could not store proof into the database', e)
|
|
}
|
|
}
|
|
|
|
const addOrUpdateProofs = function (
|
|
proofs: Proof[],
|
|
state: ProofState = 'UNSPENT',
|
|
): number | undefined {
|
|
try {
|
|
const now = new Date()
|
|
let insertQueries: SQLBatchTuple[] = []
|
|
|
|
if (proofs.length === 0) {
|
|
log.error('[addOrUpdateProofs] Empty proof array passed')
|
|
return 0
|
|
}
|
|
|
|
for (const proof of proofs) {
|
|
if (!isAlive(proof)) {
|
|
log.error('[addOrUpdateProofs] Proof is not alive', {id: proof.id})
|
|
continue
|
|
}
|
|
|
|
insertQueries.push([
|
|
`INSERT OR REPLACE INTO proofs (id, amount, secret, C, dleq_r, dleq_s, dleq_e, unit, tId, mintUrl, state, updatedAt)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
proof.id,
|
|
proof.amount,
|
|
proof.secret,
|
|
proof.C,
|
|
proof.dleq ? proof.dleq.r : null,
|
|
proof.dleq ? proof.dleq.s : null,
|
|
proof.dleq ? proof.dleq.e : null,
|
|
proof.unit,
|
|
proof.tId,
|
|
proof.mintUrl,
|
|
state,
|
|
now.toISOString(),
|
|
],
|
|
])
|
|
}
|
|
|
|
const db = getInstance()
|
|
const {rowsAffected} = db.executeBatch(insertQueries)
|
|
|
|
// DO NOT log proof secrets to Sentry
|
|
log.info('[addOrUpdateProofs]',
|
|
`${rowsAffected} ${state} proofs were added or updated in the database`,
|
|
)
|
|
|
|
return rowsAffected
|
|
} catch (e: any) {
|
|
throw dbError('Could not insert or update proofs into the database', e)
|
|
}
|
|
}
|
|
|
|
|
|
const updateProofsMintUrl = function (currentMintUrl: string, updatedMintUrl: string) {
|
|
try {
|
|
const query = `
|
|
UPDATE proofs
|
|
SET mintUrl = ?
|
|
WHERE mintUrl = ?
|
|
`
|
|
const params = [updatedMintUrl, currentMintUrl]
|
|
|
|
const db = getInstance()
|
|
db.execute(query, params)
|
|
|
|
log.debug('[updateMintUrl]', 'Proof mintUrl updated', {currentMintUrl, updatedMintUrl})
|
|
|
|
|
|
} catch (e: any) {
|
|
throw dbError('Could not update proof mintUrl in database', e)
|
|
}
|
|
}
|
|
|
|
const removeAllProofs = async function () {
|
|
try {
|
|
const query = `
|
|
DELETE FROM proofs
|
|
`
|
|
const db = getInstance()
|
|
db.execute(query)
|
|
|
|
log.info('[removeAllProofs]', 'All proofs were removed from the database.')
|
|
|
|
return true
|
|
} catch (e: any) {
|
|
throw dbError('Could not remove proofs from the database', e)
|
|
}
|
|
}
|
|
|
|
const getProofById = function (id: number) {
|
|
try {
|
|
const query = `
|
|
SELECT * FROM proofs WHERE id = ?
|
|
`
|
|
const params = [id]
|
|
|
|
const db = getInstance()
|
|
const {rows} = db.execute(query, params)
|
|
|
|
return rows?.item(0) as ProofRecord
|
|
} catch (e: any) {
|
|
throw dbError('proof not found', e)
|
|
}
|
|
}
|
|
|
|
const getProofs = async (
|
|
includeUnspent: boolean,
|
|
includePending: boolean,
|
|
includeSpent: boolean,
|
|
): Promise<ProofRecord[]> => {
|
|
if (!includeUnspent && !includePending && !includeSpent) {
|
|
return []
|
|
}
|
|
|
|
const states: string[] = []
|
|
if (includeUnspent) states.push("'UNSPENT'")
|
|
if (includePending) states.push("'PENDING'")
|
|
if (includeSpent) states.push("'SPENT'")
|
|
|
|
const query = `
|
|
SELECT *
|
|
FROM proofs
|
|
WHERE state IN (${states.join(', ')})
|
|
ORDER BY id DESC
|
|
`
|
|
|
|
try {
|
|
const db = getInstance()
|
|
const { rows } = await db.executeAsync(query)
|
|
return (rows?._array ?? []) as ProofRecord[]
|
|
} catch (e: any) {
|
|
throw dbError('Proofs could not be retrieved from the database', e)
|
|
}
|
|
}
|
|
|
|
const getProofsByTransaction = function (transactionId: number): ProofRecord[] {
|
|
try {
|
|
const query = `
|
|
SELECT *
|
|
FROM proofs
|
|
WHERE tId = ?
|
|
`
|
|
const params = [transactionId]
|
|
|
|
const db = getInstance()
|
|
const {rows} = db.execute(query, params)
|
|
|
|
return rows?._array as ProofRecord[]
|
|
} catch (e: any) {
|
|
throw dbError('Proofs could not be retrieved from the database', e)
|
|
}
|
|
}
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Proof reservations (Phase 5 of refactoring).
|
|
//
|
|
// A reservation snapshots the pre-operation state of a set of proofs and locks
|
|
// them as PENDING in a single SQLite transaction. The reservation row stays in
|
|
// the DB for the entire lifetime of the operation so that an orphan (a row left
|
|
// behind by a process that died mid-operation) can be detected on next startup
|
|
// and rolled back deterministically.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export type LockedProofSnapshot = {
|
|
secret: string
|
|
originalState: ProofState
|
|
/**
|
|
* The proof's tId AT RESERVE TIME — i.e. the transaction that previously
|
|
* owned this proof (typically the original RECEIVE/TOPUP that minted it).
|
|
*
|
|
* When the reservation opens, the proof's tId is reassigned to the NEW
|
|
* operation's transactionId so downstream sync sweeps can correctly group
|
|
* spent proofs under the right transaction. On rollback, originalTId is
|
|
* restored.
|
|
*
|
|
* `null` for proofs that had no prior transaction reference.
|
|
*/
|
|
originalTId: number | null
|
|
}
|
|
|
|
export type ReservationRow = {
|
|
id: string
|
|
transactionId: number
|
|
mintUrl: string
|
|
unit: string
|
|
operationType: string
|
|
lockedProofs: LockedProofSnapshot[]
|
|
createdAt: Date
|
|
}
|
|
|
|
/**
|
|
* Open a reservation: insert the reservation row and move the locked proofs to
|
|
* PENDING — all in a single SQLite transaction (via executeBatch).
|
|
*
|
|
* If the batch fails, SQLite rolls back automatically and no partial state
|
|
* exists in the database.
|
|
*/
|
|
const openReservation = function (
|
|
reservation: {
|
|
id: string
|
|
transactionId: number
|
|
mintUrl: string
|
|
unit: string
|
|
operationType: string
|
|
lockedProofs: LockedProofSnapshot[]
|
|
},
|
|
proofsToLock: Proof[],
|
|
): void {
|
|
try {
|
|
const now = new Date().toISOString()
|
|
const batch: SQLBatchTuple[] = []
|
|
|
|
batch.push([
|
|
`INSERT INTO reservations (id, transactionId, mintUrl, unit, operationType, lockedProofs, createdAt)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
reservation.id,
|
|
reservation.transactionId,
|
|
reservation.mintUrl,
|
|
reservation.unit,
|
|
reservation.operationType,
|
|
JSON.stringify(reservation.lockedProofs),
|
|
now,
|
|
],
|
|
])
|
|
|
|
for (const proof of proofsToLock) {
|
|
if (!isAlive(proof)) continue
|
|
batch.push([
|
|
`INSERT OR REPLACE INTO proofs (id, amount, secret, C, dleq_r, dleq_s, dleq_e, unit, tId, mintUrl, state, updatedAt)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
proof.id,
|
|
proof.amount,
|
|
proof.secret,
|
|
proof.C,
|
|
proof.dleq ? proof.dleq.r : null,
|
|
proof.dleq ? proof.dleq.s : null,
|
|
proof.dleq ? proof.dleq.e : null,
|
|
proof.unit,
|
|
// Reassign tId to the new operation. The previous tId (which may
|
|
// point to e.g. the original RECEIVE that minted this proof) is
|
|
// captured in lockedProofs[i].originalTId for rollback restoration.
|
|
reservation.transactionId,
|
|
proof.mintUrl,
|
|
'PENDING',
|
|
now,
|
|
],
|
|
])
|
|
}
|
|
|
|
const db = getInstance()
|
|
db.executeBatch(batch)
|
|
|
|
log.info('[openReservation]', 'Reservation opened', {
|
|
id: reservation.id,
|
|
transactionId: reservation.transactionId,
|
|
lockedCount: proofsToLock.length,
|
|
operationType: reservation.operationType,
|
|
})
|
|
} catch (e: any) {
|
|
throw dbError('Could not open proof reservation', e)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Optional transaction-row update atomically batched with a reservation commit.
|
|
*
|
|
* Only the named columns can be set; this is intentionally narrower than the
|
|
* full Transaction shape so the API is predictable and only covers fields that
|
|
* legitimately need to land atomically with a proof-state finalize.
|
|
*/
|
|
export type ReservationTransactionUpdate = {
|
|
id: number
|
|
status?: TransactionStatus
|
|
data?: string
|
|
amount?: number
|
|
fee?: number
|
|
balanceAfter?: number
|
|
outputToken?: string
|
|
keysetId?: string
|
|
proof?: string
|
|
}
|
|
|
|
/**
|
|
* Commit a reservation: apply the supplied state transitions, optionally a
|
|
* transaction-row update, and delete the reservation row — all in a single
|
|
* SQLite transaction.
|
|
*
|
|
* Passing `transactionUpdate` closes the proofs-table vs transactions-table
|
|
* atomicity window: a crash between proof-state finalize and tx-status update
|
|
* would otherwise leave a transaction stuck in PENDING/PREPARED while its
|
|
* underlying proofs are SPENT.
|
|
*/
|
|
const commitReservation = function (
|
|
reservationId: string,
|
|
changes: {
|
|
toSpent?: Proof[]
|
|
toUnspent?: Proof[]
|
|
newProofs?: Array<{
|
|
proofs: Proof[] | CashuProof[]
|
|
state: ProofState
|
|
mintUrl: string
|
|
unit: string
|
|
tId: number
|
|
}>
|
|
transactionUpdate?: ReservationTransactionUpdate
|
|
},
|
|
): void {
|
|
try {
|
|
const now = new Date().toISOString()
|
|
const batch: SQLBatchTuple[] = []
|
|
|
|
for (const proof of changes.toSpent ?? []) {
|
|
if (!isAlive(proof)) continue
|
|
batch.push([
|
|
`UPDATE proofs SET state = ?, updatedAt = ? WHERE secret = ?`,
|
|
['SPENT', now, proof.secret],
|
|
])
|
|
}
|
|
|
|
for (const proof of changes.toUnspent ?? []) {
|
|
if (!isAlive(proof)) continue
|
|
batch.push([
|
|
`UPDATE proofs SET state = ?, updatedAt = ? WHERE secret = ?`,
|
|
['UNSPENT', now, proof.secret],
|
|
])
|
|
}
|
|
|
|
for (const group of changes.newProofs ?? []) {
|
|
for (const proof of group.proofs) {
|
|
// proof.amount may be a cashu-ts `Amount` class instance (when the
|
|
// group came straight from `cashuWallet.send/mint/melt` responses)
|
|
// rather than a plain number. Coerce explicitly — SQLite's JSI
|
|
// binding can't bind non-primitive objects to an INTEGER column and
|
|
// would silently drop the row, leaving the proof in MST but absent
|
|
// from the database (lost on the next restart).
|
|
const amount = typeof proof.amount === 'number' ? proof.amount : Number(proof.amount)
|
|
batch.push([
|
|
`INSERT OR REPLACE INTO proofs (id, amount, secret, C, dleq_r, dleq_s, dleq_e, unit, tId, mintUrl, state, updatedAt)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
proof.id,
|
|
amount,
|
|
proof.secret,
|
|
proof.C,
|
|
proof.dleq ? proof.dleq.r : null,
|
|
proof.dleq ? proof.dleq.s : null,
|
|
proof.dleq ? proof.dleq.e : null,
|
|
group.unit,
|
|
group.tId,
|
|
group.mintUrl,
|
|
group.state,
|
|
now,
|
|
],
|
|
])
|
|
}
|
|
}
|
|
|
|
if (changes.transactionUpdate) {
|
|
const tu = changes.transactionUpdate
|
|
const setClauses: string[] = []
|
|
const params: (string | number | null)[] = []
|
|
|
|
// Whitelist of fields that can be set atomically. Order matters only for
|
|
// readability — params must match clause order.
|
|
const setIfDefined = (col: string, value: string | number | undefined) => {
|
|
if (value !== undefined) {
|
|
setClauses.push(`${col} = ?`)
|
|
params.push(value)
|
|
}
|
|
}
|
|
setIfDefined('status', tu.status)
|
|
setIfDefined('data', tu.data)
|
|
setIfDefined('amount', tu.amount)
|
|
setIfDefined('fee', tu.fee)
|
|
setIfDefined('balanceAfter', tu.balanceAfter)
|
|
setIfDefined('outputToken', tu.outputToken)
|
|
setIfDefined('keysetId', tu.keysetId)
|
|
setIfDefined('proof', tu.proof)
|
|
|
|
if (setClauses.length > 0) {
|
|
params.push(tu.id)
|
|
batch.push([
|
|
`UPDATE transactions SET ${setClauses.join(', ')} WHERE id = ?`,
|
|
params,
|
|
])
|
|
}
|
|
}
|
|
|
|
batch.push([`DELETE FROM reservations WHERE id = ?`, [reservationId]])
|
|
|
|
const db = getInstance()
|
|
db.executeBatch(batch)
|
|
|
|
log.info('[commitReservation] ', 'Reservation committed to DB', {
|
|
id: reservationId,
|
|
toSpent: changes.toSpent?.length ?? 0,
|
|
toUnspent: changes.toUnspent?.length ?? 0,
|
|
newGroups: changes.newProofs?.length ?? 0,
|
|
txUpdate: changes.transactionUpdate ? changes.transactionUpdate.id : undefined,
|
|
})
|
|
} catch (e: any) {
|
|
throw dbError('Could not commit proof reservation', e)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rollback a reservation: restore each locked proof to its originalState AND
|
|
* originalTId, then delete the reservation row — all in a single SQLite
|
|
* transaction.
|
|
*/
|
|
const rollbackReservation = function (
|
|
reservationId: string,
|
|
lockedProofs: LockedProofSnapshot[],
|
|
): void {
|
|
try {
|
|
const now = new Date().toISOString()
|
|
const batch: SQLBatchTuple[] = []
|
|
|
|
for (const snap of lockedProofs) {
|
|
batch.push([
|
|
`UPDATE proofs SET state = ?, tId = ?, updatedAt = ? WHERE secret = ?`,
|
|
[snap.originalState, snap.originalTId, now, snap.secret],
|
|
])
|
|
}
|
|
|
|
batch.push([`DELETE FROM reservations WHERE id = ?`, [reservationId]])
|
|
|
|
const db = getInstance()
|
|
db.executeBatch(batch)
|
|
|
|
log.info('[rollbackReservation]', 'Reservation rolled back', {
|
|
id: reservationId,
|
|
restoredCount: lockedProofs.length,
|
|
})
|
|
} catch (e: any) {
|
|
throw dbError('Could not rollback proof reservation', e)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return all reservations currently in the DB. Used at startup to roll back
|
|
* orphans (operations whose process died before they could commit or rollback).
|
|
*/
|
|
const getOpenReservations = function (): ReservationRow[] {
|
|
try {
|
|
const db = getInstance()
|
|
const {rows} = db.execute(`SELECT * FROM reservations`)
|
|
if (!rows) return []
|
|
|
|
const result: ReservationRow[] = []
|
|
for (let i = 0; i < rows.length; i++) {
|
|
const row = rows.item(i)
|
|
let lockedProofs: LockedProofSnapshot[] = []
|
|
try {
|
|
lockedProofs = JSON.parse(row.lockedProofs)
|
|
} catch (e) {
|
|
log.warn('[getOpenReservations] Could not parse lockedProofs JSON', {id: row.id})
|
|
}
|
|
result.push({
|
|
id: row.id,
|
|
transactionId: row.transactionId,
|
|
mintUrl: row.mintUrl,
|
|
unit: row.unit,
|
|
operationType: row.operationType,
|
|
lockedProofs,
|
|
createdAt: new Date(row.createdAt),
|
|
})
|
|
}
|
|
return result
|
|
} catch (e: any) {
|
|
throw dbError('Could not read open reservations', e)
|
|
}
|
|
}
|
|
|
|
export const Database = {
|
|
getInstance,
|
|
getDatabaseVersion,
|
|
cleanAll,
|
|
getTransactionsCount,
|
|
getTransactionById,
|
|
getLastTransactionBy,
|
|
getRecentTransactionsByUnitAsync,
|
|
getTransactionsAsync,
|
|
searchTransactionsAsync,
|
|
searchTransactionsCount,
|
|
getPendingTopups,
|
|
getPendingTopupsCount,
|
|
getPendingTransfers,
|
|
getPendingTransfersCount,
|
|
addTransactionAsync,
|
|
updateTransaction,
|
|
expireAllAfterRecovery,
|
|
updateStatusesAsync,
|
|
deleteTransactionsByStatus,
|
|
deleteTransactionById,
|
|
getIncomingPendingCount,
|
|
deleteIncomingPending,
|
|
getPendingAmount,
|
|
addOrUpdateProof,
|
|
addOrUpdateProofs,
|
|
updateProofsMintUrl,
|
|
removeAllProofs,
|
|
getProofById,
|
|
getProofs,
|
|
getProofsByTransaction,
|
|
openReservation,
|
|
commitReservation,
|
|
rollbackReservation,
|
|
getOpenReservations,
|
|
}
|