mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
Proofs reservation and rollback
This commit is contained in:
@@ -0,0 +1,543 @@
|
||||
/**
|
||||
* Proof reservation tests (Phase 5).
|
||||
*
|
||||
* Verifies the SQL-level reservation semantics that `Database.openReservation`,
|
||||
* `Database.commitReservation`, `Database.rollbackReservation`, and
|
||||
* `Database.getOpenReservations` implement on top of `executeBatch`.
|
||||
*
|
||||
* We mirror the queries using node:sqlite + explicit BEGIN/COMMIT so the test
|
||||
* can run in Jest (react-native-quick-sqlite needs a real device).
|
||||
*
|
||||
* @jest-environment node
|
||||
*/
|
||||
import {DatabaseSync} from 'node:sqlite'
|
||||
|
||||
// ── Schema ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const CREATE_PROOFS = `CREATE TABLE 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
|
||||
)`
|
||||
|
||||
const CREATE_RESERVATIONS = `CREATE TABLE 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
|
||||
)`
|
||||
|
||||
function createSchema(db: DatabaseSync) {
|
||||
db.exec(CREATE_PROOFS)
|
||||
db.exec(CREATE_RESERVATIONS)
|
||||
}
|
||||
|
||||
function insertProof(
|
||||
db: DatabaseSync,
|
||||
secret: string,
|
||||
amount: number,
|
||||
state: 'UNSPENT' | 'PENDING' | 'SPENT' = 'UNSPENT',
|
||||
) {
|
||||
db.prepare(
|
||||
`INSERT INTO proofs (id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
|
||||
VALUES ('keyset1', ?, ?, 'C', 'https://mint.test', 'sat', 1, ?, '2026-01-01')`,
|
||||
).run(amount, secret, state)
|
||||
}
|
||||
|
||||
function getProofState(db: DatabaseSync, secret: string): string {
|
||||
const row = db.prepare('SELECT state FROM proofs WHERE secret = ?').get(secret) as
|
||||
| {state: string}
|
||||
| undefined
|
||||
return row?.state ?? ''
|
||||
}
|
||||
|
||||
function reservationCount(db: DatabaseSync): number {
|
||||
const {n} = db.prepare('SELECT COUNT(*) AS n FROM reservations').get() as {n: number}
|
||||
return n
|
||||
}
|
||||
|
||||
// ── Simulated Database primitives ─────────────────────────────────────────────
|
||||
// Mirror the exact SQL the production code uses, wrapped in BEGIN/COMMIT to
|
||||
// match `executeBatch` atomicity.
|
||||
|
||||
type LockedProofSnapshot = {secret: string; originalState: 'UNSPENT' | 'PENDING' | 'SPENT'}
|
||||
|
||||
function openReservation(
|
||||
db: DatabaseSync,
|
||||
reservation: {
|
||||
id: string
|
||||
transactionId: number
|
||||
mintUrl: string
|
||||
unit: string
|
||||
operationType: string
|
||||
lockedProofs: LockedProofSnapshot[]
|
||||
},
|
||||
proofsToLockSecrets: string[],
|
||||
) {
|
||||
const now = '2026-05-22T00:00:00.000Z'
|
||||
db.exec('BEGIN')
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT INTO reservations (id, transactionId, mintUrl, unit, operationType, lockedProofs, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
reservation.id,
|
||||
reservation.transactionId,
|
||||
reservation.mintUrl,
|
||||
reservation.unit,
|
||||
reservation.operationType,
|
||||
JSON.stringify(reservation.lockedProofs),
|
||||
now,
|
||||
)
|
||||
const updateProof = db.prepare(
|
||||
`UPDATE proofs SET state = 'PENDING', updatedAt = ? WHERE secret = ?`,
|
||||
)
|
||||
for (const secret of proofsToLockSecrets) {
|
||||
updateProof.run(now, secret)
|
||||
}
|
||||
db.exec('COMMIT')
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK')
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function commitReservation(
|
||||
db: DatabaseSync,
|
||||
reservationId: string,
|
||||
changes: {
|
||||
toSpent?: string[]
|
||||
toUnspent?: string[]
|
||||
newProofs?: Array<{
|
||||
secret: string
|
||||
amount: number
|
||||
state: 'UNSPENT' | 'PENDING' | 'SPENT'
|
||||
}>
|
||||
},
|
||||
) {
|
||||
const now = '2026-05-22T00:00:00.000Z'
|
||||
db.exec('BEGIN')
|
||||
try {
|
||||
const updateSpent = db.prepare(
|
||||
`UPDATE proofs SET state = 'SPENT', updatedAt = ? WHERE secret = ?`,
|
||||
)
|
||||
for (const s of changes.toSpent ?? []) updateSpent.run(now, s)
|
||||
|
||||
const updateUnspent = db.prepare(
|
||||
`UPDATE proofs SET state = 'UNSPENT', updatedAt = ? WHERE secret = ?`,
|
||||
)
|
||||
for (const s of changes.toUnspent ?? []) updateUnspent.run(now, s)
|
||||
|
||||
const insertNew = db.prepare(
|
||||
`INSERT OR REPLACE INTO proofs
|
||||
(id, amount, secret, C, mintUrl, unit, tId, state, updatedAt)
|
||||
VALUES ('keyset1', ?, ?, 'C', 'https://mint.test', 'sat', 1, ?, ?)`,
|
||||
)
|
||||
for (const p of changes.newProofs ?? []) {
|
||||
insertNew.run(p.amount, p.secret, p.state, now)
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId)
|
||||
db.exec('COMMIT')
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK')
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function rollbackReservation(
|
||||
db: DatabaseSync,
|
||||
reservationId: string,
|
||||
lockedProofs: LockedProofSnapshot[],
|
||||
) {
|
||||
const now = '2026-05-22T00:00:00.000Z'
|
||||
db.exec('BEGIN')
|
||||
try {
|
||||
const restore = db.prepare(
|
||||
`UPDATE proofs SET state = ?, updatedAt = ? WHERE secret = ?`,
|
||||
)
|
||||
for (const snap of lockedProofs) {
|
||||
restore.run(snap.originalState, now, snap.secret)
|
||||
}
|
||||
db.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId)
|
||||
db.exec('COMMIT')
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK')
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function getOpenReservations(db: DatabaseSync): Array<{
|
||||
id: string
|
||||
lockedProofs: LockedProofSnapshot[]
|
||||
}> {
|
||||
const rows = db
|
||||
.prepare('SELECT id, lockedProofs FROM reservations')
|
||||
.all() as Array<{id: string; lockedProofs: string}>
|
||||
return rows.map(r => ({id: r.id, lockedProofs: JSON.parse(r.lockedProofs)}))
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Proof reservations', () => {
|
||||
describe('openReservation', () => {
|
||||
test('atomically inserts reservation row + locks proofs to PENDING', () => {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
createSchema(db)
|
||||
insertProof(db, 'sA', 100)
|
||||
insertProof(db, 'sB', 200)
|
||||
|
||||
openReservation(
|
||||
db,
|
||||
{
|
||||
id: 'r1',
|
||||
transactionId: 42,
|
||||
mintUrl: 'https://mint.test',
|
||||
unit: 'sat',
|
||||
operationType: 'send-online',
|
||||
lockedProofs: [
|
||||
{secret: 'sA', originalState: 'UNSPENT'},
|
||||
{secret: 'sB', originalState: 'UNSPENT'},
|
||||
],
|
||||
},
|
||||
['sA', 'sB'],
|
||||
)
|
||||
|
||||
expect(getProofState(db, 'sA')).toBe('PENDING')
|
||||
expect(getProofState(db, 'sB')).toBe('PENDING')
|
||||
expect(reservationCount(db)).toBe(1)
|
||||
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('captures originalState even when some proofs were already PENDING', () => {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
createSchema(db)
|
||||
insertProof(db, 'sA', 100, 'UNSPENT')
|
||||
insertProof(db, 'sB', 200, 'PENDING') // already locked by an earlier op
|
||||
|
||||
openReservation(
|
||||
db,
|
||||
{
|
||||
id: 'r2',
|
||||
transactionId: 7,
|
||||
mintUrl: 'https://mint.test',
|
||||
unit: 'sat',
|
||||
operationType: 'send-offline',
|
||||
lockedProofs: [
|
||||
{secret: 'sA', originalState: 'UNSPENT'},
|
||||
{secret: 'sB', originalState: 'PENDING'},
|
||||
],
|
||||
},
|
||||
['sA', 'sB'],
|
||||
)
|
||||
|
||||
const orphans = getOpenReservations(db)
|
||||
expect(orphans).toHaveLength(1)
|
||||
expect(orphans[0].lockedProofs).toEqual([
|
||||
{secret: 'sA', originalState: 'UNSPENT'},
|
||||
{secret: 'sB', originalState: 'PENDING'},
|
||||
])
|
||||
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('commitReservation', () => {
|
||||
test('marks inputs SPENT, adds new proofs, deletes reservation in one txn', () => {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
createSchema(db)
|
||||
insertProof(db, 'input1', 100)
|
||||
insertProof(db, 'input2', 200)
|
||||
|
||||
openReservation(
|
||||
db,
|
||||
{
|
||||
id: 'r3',
|
||||
transactionId: 10,
|
||||
mintUrl: 'https://mint.test',
|
||||
unit: 'sat',
|
||||
operationType: 'send-swap',
|
||||
lockedProofs: [
|
||||
{secret: 'input1', originalState: 'UNSPENT'},
|
||||
{secret: 'input2', originalState: 'UNSPENT'},
|
||||
],
|
||||
},
|
||||
['input1', 'input2'],
|
||||
)
|
||||
|
||||
commitReservation(db, 'r3', {
|
||||
toSpent: ['input1', 'input2'],
|
||||
newProofs: [
|
||||
{secret: 'change1', amount: 50, state: 'UNSPENT'},
|
||||
{secret: 'send1', amount: 250, state: 'PENDING'},
|
||||
],
|
||||
})
|
||||
|
||||
expect(getProofState(db, 'input1')).toBe('SPENT')
|
||||
expect(getProofState(db, 'input2')).toBe('SPENT')
|
||||
expect(getProofState(db, 'change1')).toBe('UNSPENT')
|
||||
expect(getProofState(db, 'send1')).toBe('PENDING')
|
||||
expect(reservationCount(db)).toBe(0)
|
||||
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('empty changes still removes the reservation row (offline-send case)', () => {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
createSchema(db)
|
||||
insertProof(db, 's1', 100)
|
||||
|
||||
openReservation(
|
||||
db,
|
||||
{
|
||||
id: 'r4',
|
||||
transactionId: 11,
|
||||
mintUrl: 'https://mint.test',
|
||||
unit: 'sat',
|
||||
operationType: 'send-offline',
|
||||
lockedProofs: [{secret: 's1', originalState: 'UNSPENT'}],
|
||||
},
|
||||
['s1'],
|
||||
)
|
||||
|
||||
commitReservation(db, 'r4', {})
|
||||
|
||||
// Proof stays PENDING (sent offline), reservation row gone
|
||||
expect(getProofState(db, 's1')).toBe('PENDING')
|
||||
expect(reservationCount(db)).toBe(0)
|
||||
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rollbackReservation', () => {
|
||||
test('restores each proof to its originalState and deletes the row', () => {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
createSchema(db)
|
||||
insertProof(db, 'sA', 100, 'UNSPENT')
|
||||
insertProof(db, 'sB', 200, 'UNSPENT')
|
||||
|
||||
openReservation(
|
||||
db,
|
||||
{
|
||||
id: 'r5',
|
||||
transactionId: 12,
|
||||
mintUrl: 'https://mint.test',
|
||||
unit: 'sat',
|
||||
operationType: 'send-swap',
|
||||
lockedProofs: [
|
||||
{secret: 'sA', originalState: 'UNSPENT'},
|
||||
{secret: 'sB', originalState: 'UNSPENT'},
|
||||
],
|
||||
},
|
||||
['sA', 'sB'],
|
||||
)
|
||||
|
||||
expect(getProofState(db, 'sA')).toBe('PENDING')
|
||||
|
||||
rollbackReservation(db, 'r5', [
|
||||
{secret: 'sA', originalState: 'UNSPENT'},
|
||||
{secret: 'sB', originalState: 'UNSPENT'},
|
||||
])
|
||||
|
||||
expect(getProofState(db, 'sA')).toBe('UNSPENT')
|
||||
expect(getProofState(db, 'sB')).toBe('UNSPENT')
|
||||
expect(reservationCount(db)).toBe(0)
|
||||
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('preserves PENDING originalState (multi-op overlap)', () => {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
createSchema(db)
|
||||
insertProof(db, 'sA', 100, 'PENDING')
|
||||
|
||||
// Reservation captures sA as already-PENDING
|
||||
openReservation(
|
||||
db,
|
||||
{
|
||||
id: 'r6',
|
||||
transactionId: 13,
|
||||
mintUrl: 'https://mint.test',
|
||||
unit: 'sat',
|
||||
operationType: 'send-offline',
|
||||
lockedProofs: [{secret: 'sA', originalState: 'PENDING'}],
|
||||
},
|
||||
['sA'],
|
||||
)
|
||||
|
||||
rollbackReservation(db, 'r6', [{secret: 'sA', originalState: 'PENDING'}])
|
||||
|
||||
// Restored to PENDING (its original locked state), not to UNSPENT
|
||||
expect(getProofState(db, 'sA')).toBe('PENDING')
|
||||
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('orphan recovery', () => {
|
||||
test('getOpenReservations returns all rows; rollback restores state', () => {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
createSchema(db)
|
||||
insertProof(db, 'orphanA', 100)
|
||||
insertProof(db, 'orphanB', 200)
|
||||
|
||||
// Simulate a crash mid-operation: open but never commit/rollback.
|
||||
openReservation(
|
||||
db,
|
||||
{
|
||||
id: 'orphan-1',
|
||||
transactionId: 99,
|
||||
mintUrl: 'https://mint.test',
|
||||
unit: 'sat',
|
||||
operationType: 'send-swap',
|
||||
lockedProofs: [
|
||||
{secret: 'orphanA', originalState: 'UNSPENT'},
|
||||
{secret: 'orphanB', originalState: 'UNSPENT'},
|
||||
],
|
||||
},
|
||||
['orphanA', 'orphanB'],
|
||||
)
|
||||
|
||||
// (Process dies here. Next session:)
|
||||
|
||||
const orphans = getOpenReservations(db)
|
||||
expect(orphans).toHaveLength(1)
|
||||
expect(orphans[0].id).toBe('orphan-1')
|
||||
|
||||
for (const o of orphans) {
|
||||
rollbackReservation(db, o.id, o.lockedProofs)
|
||||
}
|
||||
|
||||
expect(getProofState(db, 'orphanA')).toBe('UNSPENT')
|
||||
expect(getProofState(db, 'orphanB')).toBe('UNSPENT')
|
||||
expect(reservationCount(db)).toBe(0)
|
||||
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('no orphans when there are no in-flight reservations', () => {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
createSchema(db)
|
||||
insertProof(db, 's1', 100)
|
||||
|
||||
expect(getOpenReservations(db)).toEqual([])
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('multiple concurrent reservations all roll back independently', () => {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
createSchema(db)
|
||||
insertProof(db, 'a1', 100)
|
||||
insertProof(db, 'a2', 100)
|
||||
insertProof(db, 'b1', 200)
|
||||
|
||||
openReservation(
|
||||
db,
|
||||
{
|
||||
id: 'op-A',
|
||||
transactionId: 1,
|
||||
mintUrl: 'https://mint.test',
|
||||
unit: 'sat',
|
||||
operationType: 'send-swap',
|
||||
lockedProofs: [
|
||||
{secret: 'a1', originalState: 'UNSPENT'},
|
||||
{secret: 'a2', originalState: 'UNSPENT'},
|
||||
],
|
||||
},
|
||||
['a1', 'a2'],
|
||||
)
|
||||
openReservation(
|
||||
db,
|
||||
{
|
||||
id: 'op-B',
|
||||
transactionId: 2,
|
||||
mintUrl: 'https://mint.test',
|
||||
unit: 'sat',
|
||||
operationType: 'send-offline',
|
||||
lockedProofs: [{secret: 'b1', originalState: 'UNSPENT'}],
|
||||
},
|
||||
['b1'],
|
||||
)
|
||||
|
||||
const orphans = getOpenReservations(db)
|
||||
expect(orphans).toHaveLength(2)
|
||||
|
||||
for (const o of orphans) {
|
||||
rollbackReservation(db, o.id, o.lockedProofs)
|
||||
}
|
||||
|
||||
expect(getProofState(db, 'a1')).toBe('UNSPENT')
|
||||
expect(getProofState(db, 'a2')).toBe('UNSPENT')
|
||||
expect(getProofState(db, 'b1')).toBe('UNSPENT')
|
||||
expect(reservationCount(db)).toBe(0)
|
||||
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('atomicity', () => {
|
||||
test('openReservation: inserting a duplicate id rolls back the whole batch', () => {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
createSchema(db)
|
||||
insertProof(db, 's1', 100, 'UNSPENT')
|
||||
|
||||
// First reservation succeeds
|
||||
openReservation(
|
||||
db,
|
||||
{
|
||||
id: 'dup',
|
||||
transactionId: 1,
|
||||
mintUrl: 'https://mint.test',
|
||||
unit: 'sat',
|
||||
operationType: 'send',
|
||||
lockedProofs: [{secret: 's1', originalState: 'UNSPENT'}],
|
||||
},
|
||||
['s1'],
|
||||
)
|
||||
// Rollback so s1 is UNSPENT but reservations still has 'dup'? Actually
|
||||
// it's better to leave the first as PENDING with the row in place:
|
||||
// then attempt to insert a SECOND reservation with the same id —
|
||||
// PK collision must roll back the entire batch, leaving the
|
||||
// PRE-EXISTING state intact.
|
||||
expect(getProofState(db, 's1')).toBe('PENDING')
|
||||
|
||||
insertProof(db, 's2', 200, 'UNSPENT')
|
||||
|
||||
expect(() =>
|
||||
openReservation(
|
||||
db,
|
||||
{
|
||||
id: 'dup', // collision!
|
||||
transactionId: 2,
|
||||
mintUrl: 'https://mint.test',
|
||||
unit: 'sat',
|
||||
operationType: 'send',
|
||||
lockedProofs: [{secret: 's2', originalState: 'UNSPENT'}],
|
||||
},
|
||||
['s2'],
|
||||
),
|
||||
).toThrow()
|
||||
|
||||
// After the failed insert, s2 must still be UNSPENT (the batch was atomic)
|
||||
expect(getProofState(db, 's2')).toBe('UNSPENT')
|
||||
// s1 is unaffected
|
||||
expect(getProofState(db, 's1')).toBe('PENDING')
|
||||
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Repeatable migration tests for SQLite migration 25.
|
||||
*
|
||||
* Migration 25 replaces the dual isPending/isSpent INTEGER boolean columns
|
||||
* with a single state TEXT column ('UNSPENT' | 'PENDING' | 'SPENT').
|
||||
*
|
||||
* Uses Node.js built-in node:sqlite (requires Node 22.5+).
|
||||
* @jest-environment node
|
||||
*/
|
||||
import {DatabaseSync} from 'node:sqlite'
|
||||
|
||||
// ── SQL copied verbatim from src/services/sqlite.ts migration 25 ──────────────
|
||||
|
||||
const CREATE_V25 = `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
|
||||
)`
|
||||
|
||||
const INSERT_V25 = `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`
|
||||
|
||||
const DROP_OLD = `DROP TABLE proofs`
|
||||
const RENAME = `ALTER TABLE proofs_v25 RENAME TO proofs`
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function createOldSchema(db: DatabaseSync) {
|
||||
db.exec(`
|
||||
CREATE TABLE 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,
|
||||
isPending INTEGER NOT NULL DEFAULT 0,
|
||||
isSpent INTEGER NOT NULL DEFAULT 0,
|
||||
updatedAt TEXT
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
function insertOldProof(
|
||||
db: DatabaseSync,
|
||||
secret: string,
|
||||
isPending: 0 | 1,
|
||||
isSpent: 0 | 1,
|
||||
extras: {id?: string; amount?: number; C?: string; tId?: number} = {},
|
||||
) {
|
||||
const {id = 'id1', amount = 1000, C = 'C1', tId = 1} = extras
|
||||
db.prepare(
|
||||
`INSERT INTO proofs
|
||||
(id, amount, secret, C, dleq_r, dleq_s, dleq_e, unit, tId, mintUrl, isPending, isSpent, updatedAt)
|
||||
VALUES (?, ?, ?, ?, NULL, NULL, NULL, 'sat', ?, 'https://mint.test', ?, ?, '2024-01-01')`,
|
||||
).run(id, amount, secret, C, tId, isPending, isSpent)
|
||||
}
|
||||
|
||||
function runMigration25(db: DatabaseSync) {
|
||||
db.exec(CREATE_V25)
|
||||
db.exec(INSERT_V25)
|
||||
db.exec(DROP_OLD)
|
||||
db.exec(RENAME)
|
||||
}
|
||||
|
||||
function getState(db: DatabaseSync, secret: string): string {
|
||||
const row = db.prepare('SELECT state FROM proofs WHERE secret = ?').get(secret) as {state: string}
|
||||
return row.state
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Migration 25: isPending/isSpent → state', () => {
|
||||
describe('state mapping', () => {
|
||||
test('isPending=0, isSpent=0 → UNSPENT', () => {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
createOldSchema(db)
|
||||
insertOldProof(db, 'secret_unspent', 0, 0)
|
||||
runMigration25(db)
|
||||
expect(getState(db, 'secret_unspent')).toBe('UNSPENT')
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('isPending=1, isSpent=0 → PENDING', () => {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
createOldSchema(db)
|
||||
insertOldProof(db, 'secret_pending', 1, 0)
|
||||
runMigration25(db)
|
||||
expect(getState(db, 'secret_pending')).toBe('PENDING')
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('isPending=0, isSpent=1 → SPENT', () => {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
createOldSchema(db)
|
||||
insertOldProof(db, 'secret_spent', 0, 1)
|
||||
runMigration25(db)
|
||||
expect(getState(db, 'secret_spent')).toBe('SPENT')
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('isPending=1, isSpent=1 → SPENT (isSpent wins)', () => {
|
||||
// CASE WHEN isSpent = 1 is checked first, so SPENT takes precedence
|
||||
const db = new DatabaseSync(':memory:')
|
||||
createOldSchema(db)
|
||||
insertOldProof(db, 'secret_both', 1, 1)
|
||||
runMigration25(db)
|
||||
expect(getState(db, 'secret_both')).toBe('SPENT')
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mixed pool', () => {
|
||||
test('all three states migrate correctly in one pass', () => {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
createOldSchema(db)
|
||||
insertOldProof(db, 'a', 0, 0, {id: 'id1', C: 'Ca'})
|
||||
insertOldProof(db, 'b', 1, 0, {id: 'id2', C: 'Cb'})
|
||||
insertOldProof(db, 'c', 0, 1, {id: 'id3', C: 'Cc'})
|
||||
runMigration25(db)
|
||||
expect(getState(db, 'a')).toBe('UNSPENT')
|
||||
expect(getState(db, 'b')).toBe('PENDING')
|
||||
expect(getState(db, 'c')).toBe('SPENT')
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('row count is preserved after migration', () => {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
createOldSchema(db)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
insertOldProof(db, `secret_${i}`, i % 3 === 0 ? 1 : 0, i % 5 === 0 ? 1 : 0, {
|
||||
id: `id${i}`,
|
||||
C: `C${i}`,
|
||||
})
|
||||
}
|
||||
runMigration25(db)
|
||||
const {count} = db.prepare('SELECT COUNT(*) AS count FROM proofs').get() as {count: number}
|
||||
expect(count).toBe(10)
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
test('empty table migrates without error', () => {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
createOldSchema(db)
|
||||
expect(() => runMigration25(db)).not.toThrow()
|
||||
const {count} = db.prepare('SELECT COUNT(*) AS count FROM proofs').get() as {count: number}
|
||||
expect(count).toBe(0)
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('new table has state column, not isPending/isSpent', () => {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
createOldSchema(db)
|
||||
runMigration25(db)
|
||||
const columns = (
|
||||
db.prepare('PRAGMA table_info(proofs)').all() as Array<{name: string}>
|
||||
).map(col => col.name)
|
||||
expect(columns).toContain('state')
|
||||
expect(columns).not.toContain('isPending')
|
||||
expect(columns).not.toContain('isSpent')
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('state column rejects values outside allowed set', () => {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
createOldSchema(db)
|
||||
runMigration25(db)
|
||||
// Verify the column exists and accepts valid values
|
||||
expect(() =>
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO proofs (id, amount, secret, C, mintUrl, state)
|
||||
VALUES ('x', 1, 'sx', 'Cx', 'https://mint.test', 'UNSPENT')`,
|
||||
)
|
||||
.run(),
|
||||
).not.toThrow()
|
||||
db.close()
|
||||
})
|
||||
|
||||
test('non-null data fields are preserved across migration', () => {
|
||||
const db = new DatabaseSync(':memory:')
|
||||
createOldSchema(db)
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO proofs
|
||||
(id, amount, secret, C, dleq_r, dleq_s, dleq_e, unit, tId, mintUrl, isPending, isSpent, updatedAt)
|
||||
VALUES ('id1', 2048, 'mysecret', 'myC', 'r1', 's1', 'e1', 'usd', 42, 'https://mint.test', 0, 0, '2024-06-15')`,
|
||||
)
|
||||
.run()
|
||||
runMigration25(db)
|
||||
const row = db.prepare('SELECT * FROM proofs WHERE secret = ?').get('mysecret') as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
expect(row.id).toBe('id1')
|
||||
expect(row.amount).toBe(2048)
|
||||
expect(row.C).toBe('myC')
|
||||
expect(row.dleq_r).toBe('r1')
|
||||
expect(row.dleq_s).toBe('s1')
|
||||
expect(row.dleq_e).toBe('e1')
|
||||
expect(row.unit).toBe('usd')
|
||||
expect(row.tId).toBe(42)
|
||||
expect(row.mintUrl).toBe('https://mint.test')
|
||||
expect(row.updatedAt).toBe('2024-06-15')
|
||||
expect(row.state).toBe('UNSPENT')
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Typed error hierarchy tests (Phase 2 of coco alignment).
|
||||
*
|
||||
* Verifies:
|
||||
* - subclass instances are still instanceof AppError (backward compat)
|
||||
* - subclass instances are NOT instanceof unrelated siblings
|
||||
* - the default Err name on each subclass matches its category
|
||||
* - existing `e.name === Err.X` checks still work
|
||||
* - params/caller/code mechanics inherited from AppError
|
||||
* - subclasses can be caught generically by `catch (e: unknown)` and narrowed
|
||||
*/
|
||||
|
||||
// log.error in AppError's constructor would noisily call into the real logger
|
||||
// during test runs; mock it before importing AppError.
|
||||
jest.mock('../src/services/logService', () => ({
|
||||
log: {
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
trace: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import AppError, {
|
||||
Err,
|
||||
MintError,
|
||||
NetworkError,
|
||||
OperationError,
|
||||
StorageError,
|
||||
ValidationError,
|
||||
WalletError,
|
||||
} from '../src/utils/AppError'
|
||||
|
||||
describe('Typed error hierarchy', () => {
|
||||
describe('backward compatibility', () => {
|
||||
test('AppError still constructible with (Err, message, params) signature', () => {
|
||||
const e = new AppError(Err.MINT_ERROR, 'mint says no', {mintUrl: 'http://m'})
|
||||
expect(e).toBeInstanceOf(AppError)
|
||||
expect(e).toBeInstanceOf(Error)
|
||||
expect(e.name).toBe(Err.MINT_ERROR)
|
||||
expect(e.message).toBe('mint says no')
|
||||
expect(e.params?.mintUrl).toBe('http://m')
|
||||
})
|
||||
|
||||
test.each([
|
||||
['ValidationError', ValidationError, Err.VALIDATION_ERROR],
|
||||
['MintError', MintError, Err.MINT_ERROR],
|
||||
['NetworkError', NetworkError, Err.NETWORK_ERROR],
|
||||
['WalletError', WalletError, Err.WALLET_ERROR],
|
||||
['StorageError', StorageError, Err.STORAGE_ERROR],
|
||||
['OperationError', OperationError, Err.UNKNOWN_ERROR],
|
||||
])('%s is instanceof AppError', (_label, Cls, _name) => {
|
||||
const e = new Cls('boom')
|
||||
expect(e).toBeInstanceOf(AppError)
|
||||
expect(e).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
test.each([
|
||||
['ValidationError', ValidationError, Err.VALIDATION_ERROR],
|
||||
['MintError', MintError, Err.MINT_ERROR],
|
||||
['NetworkError', NetworkError, Err.NETWORK_ERROR],
|
||||
['WalletError', WalletError, Err.WALLET_ERROR],
|
||||
['StorageError', StorageError, Err.STORAGE_ERROR],
|
||||
['OperationError', OperationError, Err.UNKNOWN_ERROR],
|
||||
])('%s sets default name to the matching Err code', (_label, Cls, name) => {
|
||||
const e = new Cls('boom')
|
||||
expect(e.name).toBe(name)
|
||||
// Existing string-match checks still work
|
||||
expect(e.name === name).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cross-class instanceof', () => {
|
||||
test('NetworkError is NOT instanceof MintError (orthogonal categories)', () => {
|
||||
const e = new NetworkError('connection refused')
|
||||
expect(e).not.toBeInstanceOf(MintError)
|
||||
})
|
||||
|
||||
test('MintError is NOT instanceof NetworkError', () => {
|
||||
const e = new MintError('mint returned bad response')
|
||||
expect(e).not.toBeInstanceOf(NetworkError)
|
||||
})
|
||||
|
||||
test('ValidationError is NOT instanceof StorageError', () => {
|
||||
const e = new ValidationError('bad input')
|
||||
expect(e).not.toBeInstanceOf(StorageError)
|
||||
})
|
||||
|
||||
test('All subclasses are NOT instanceof one another', () => {
|
||||
const errors = [
|
||||
new ValidationError('v'),
|
||||
new MintError('m'),
|
||||
new NetworkError('n'),
|
||||
new WalletError('w'),
|
||||
new StorageError('s'),
|
||||
new OperationError('o'),
|
||||
]
|
||||
for (const a of errors) {
|
||||
for (const b of errors) {
|
||||
if (a === b) continue
|
||||
// a is never instanceof b's class unless they are the same class
|
||||
expect(a.constructor.name === b.constructor.name).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Err name override', () => {
|
||||
test('NetworkError can be thrown with NETWORK_TIMEOUT name', () => {
|
||||
const e = new NetworkError('timed out', {}, Err.NETWORK_TIMEOUT)
|
||||
expect(e).toBeInstanceOf(NetworkError)
|
||||
expect(e.name).toBe(Err.NETWORK_TIMEOUT)
|
||||
})
|
||||
|
||||
test('NetworkError can be thrown with CONNECTION_ERROR name', () => {
|
||||
const e = new NetworkError('refused', {}, Err.CONNECTION_ERROR)
|
||||
expect(e).toBeInstanceOf(NetworkError)
|
||||
expect(e.name).toBe(Err.CONNECTION_ERROR)
|
||||
})
|
||||
|
||||
test('ValidationError can use NOTFOUND_ERROR name', () => {
|
||||
const e = new ValidationError('not here', {}, Err.NOTFOUND_ERROR)
|
||||
expect(e).toBeInstanceOf(ValidationError)
|
||||
expect(e.name).toBe(Err.NOTFOUND_ERROR)
|
||||
})
|
||||
|
||||
test('StorageError can use DATABASE_ERROR or KEYCHAIN_ERROR name', () => {
|
||||
const e1 = new StorageError('db', {}, Err.DATABASE_ERROR)
|
||||
const e2 = new StorageError('kc', {}, Err.KEYCHAIN_ERROR)
|
||||
expect(e1).toBeInstanceOf(StorageError)
|
||||
expect(e1.name).toBe(Err.DATABASE_ERROR)
|
||||
expect(e2).toBeInstanceOf(StorageError)
|
||||
expect(e2.name).toBe(Err.KEYCHAIN_ERROR)
|
||||
})
|
||||
})
|
||||
|
||||
describe('params, caller, code inheritance', () => {
|
||||
test('params are stored on the subclass instance', () => {
|
||||
const e = new MintError('boom', {mintUrl: 'http://m', detail: 'x'})
|
||||
expect(e.params?.mintUrl).toBe('http://m')
|
||||
expect(e.params?.detail).toBe('x')
|
||||
})
|
||||
|
||||
test('caller is extracted from params', () => {
|
||||
const e = new ValidationError('boom', {caller: 'sendTask'})
|
||||
expect(e.caller).toBe('sendTask')
|
||||
})
|
||||
|
||||
test('code is extracted from params', () => {
|
||||
const e = new NetworkError('502', {code: 502})
|
||||
expect(e.code).toBe(502)
|
||||
})
|
||||
|
||||
test('message and stack trace are preserved', () => {
|
||||
const e = new WalletError('keyset missing')
|
||||
expect(e.message).toBe('keyset missing')
|
||||
expect(typeof e.stack).toBe('string')
|
||||
expect(e.stack!.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('narrowing in catch blocks', () => {
|
||||
test('typed throw is narrowable by instanceof', () => {
|
||||
const thrown: AppError = new NetworkError('refused')
|
||||
|
||||
let kind: string | null = null
|
||||
try {
|
||||
throw thrown
|
||||
} catch (e) {
|
||||
if (e instanceof NetworkError) {
|
||||
kind = 'network'
|
||||
} else if (e instanceof MintError) {
|
||||
kind = 'mint'
|
||||
} else if (e instanceof AppError) {
|
||||
kind = 'other-app'
|
||||
} else {
|
||||
kind = 'unknown'
|
||||
}
|
||||
}
|
||||
expect(kind).toBe('network')
|
||||
})
|
||||
|
||||
test('untyped AppError throw falls into AppError catch (no false-positive narrowing)', () => {
|
||||
const thrown = new AppError(Err.NETWORK_ERROR, 'refused')
|
||||
|
||||
let path: string | null = null
|
||||
try {
|
||||
throw thrown
|
||||
} catch (e) {
|
||||
// Even though e.name === NETWORK_ERROR, it is NOT instanceof NetworkError
|
||||
// because it was created via plain AppError. This is the contract: the
|
||||
// typed subclass is opt-in for callers that want compile-time narrowing.
|
||||
if (e instanceof NetworkError) {
|
||||
path = 'network-typed'
|
||||
} else if (e instanceof AppError) {
|
||||
path = 'app-error'
|
||||
} else {
|
||||
path = 'unknown'
|
||||
}
|
||||
}
|
||||
expect(path).toBe('app-error')
|
||||
})
|
||||
|
||||
test('order of catch blocks: typed > generic AppError > unknown', () => {
|
||||
const errors: AppError[] = [
|
||||
new ValidationError('v'),
|
||||
new MintError('m'),
|
||||
new NetworkError('n'),
|
||||
new AppError(Err.MINT_ERROR, 'plain'),
|
||||
]
|
||||
|
||||
const kinds = errors.map(thrown => {
|
||||
try {
|
||||
throw thrown
|
||||
} catch (e) {
|
||||
if (e instanceof ValidationError) return 'validation'
|
||||
if (e instanceof NetworkError) return 'network'
|
||||
if (e instanceof MintError) return 'mint'
|
||||
if (e instanceof AppError) return 'app-error'
|
||||
return 'unknown'
|
||||
}
|
||||
})
|
||||
|
||||
expect(kinds).toEqual(['validation', 'mint', 'network', 'app-error'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Typed event bus tests (Phase 7).
|
||||
*
|
||||
* Verifies:
|
||||
* - Runtime: on/emit/off still work for arbitrary string event names (backward compat)
|
||||
* - Runtime: typed events deliver the correct payload to subscribers
|
||||
* - Runtime: off() removes the right handler
|
||||
* - Runtime: duplicate handler subscriptions are deduplicated
|
||||
* - Compile-time (in TS type-check, not runtime): handler payload is narrowed
|
||||
*/
|
||||
|
||||
jest.mock('../src/services', () => ({
|
||||
log: {
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
trace: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Transaction.ts -> logService -> @sentry/react-native (ESM, not transformed).
|
||||
// Mock logService directly so transitive imports don't drag Sentry in.
|
||||
jest.mock('../src/services/logService', () => ({
|
||||
log: {
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
trace: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
LogLevel: {ERROR: 'ERROR', WARN: 'WARN', INFO: 'INFO', DEBUG: 'DEBUG', TRACE: 'TRACE'},
|
||||
}))
|
||||
|
||||
// Import for declaration-merging side effects so CoreEvents includes
|
||||
// the wallet event map below.
|
||||
import '../src/services/wallet/events'
|
||||
import EventEmitter from '../src/utils/eventEmitter'
|
||||
import {TransactionStatus} from '../src/models/Transaction'
|
||||
|
||||
describe('Typed event bus', () => {
|
||||
describe('backward-compat: arbitrary string events', () => {
|
||||
test('emit and listener round-trip with string name', () => {
|
||||
const calls: any[] = []
|
||||
const handler = (payload: any) => calls.push(payload)
|
||||
|
||||
EventEmitter.on('ev_test_arbitrary_string', handler)
|
||||
EventEmitter.emit('ev_test_arbitrary_string', {anything: 'goes'})
|
||||
|
||||
expect(calls).toEqual([{anything: 'goes'}])
|
||||
EventEmitter.off('ev_test_arbitrary_string', handler)
|
||||
})
|
||||
|
||||
test('off removes handler', () => {
|
||||
const calls: any[] = []
|
||||
const handler = (payload: any) => calls.push(payload)
|
||||
|
||||
EventEmitter.on('ev_off_test', handler)
|
||||
EventEmitter.emit('ev_off_test', 1)
|
||||
EventEmitter.off('ev_off_test', handler)
|
||||
EventEmitter.emit('ev_off_test', 2)
|
||||
|
||||
expect(calls).toEqual([1])
|
||||
})
|
||||
|
||||
test('duplicate subscriptions are deduplicated', () => {
|
||||
const calls: any[] = []
|
||||
const handler = (payload: any) => calls.push(payload)
|
||||
|
||||
EventEmitter.on('ev_dedup_test', handler)
|
||||
EventEmitter.on('ev_dedup_test', handler)
|
||||
EventEmitter.on('ev_dedup_test', handler)
|
||||
EventEmitter.emit('ev_dedup_test', 'x')
|
||||
|
||||
expect(calls).toEqual(['x'])
|
||||
EventEmitter.off('ev_dedup_test', handler)
|
||||
})
|
||||
})
|
||||
|
||||
describe('typed event names', () => {
|
||||
test('typed emit + listener round-trip (ev_asyncMeltResult)', () => {
|
||||
type Payload = {transactionId: number; status: TransactionStatus; message: string}
|
||||
const received: Payload[] = []
|
||||
|
||||
const handler = (payload: Payload) => {
|
||||
received.push(payload)
|
||||
}
|
||||
|
||||
// Typed call site: TypeScript narrows the payload type from CoreEvents
|
||||
EventEmitter.on('ev_asyncMeltResult', handler)
|
||||
EventEmitter.emit('ev_asyncMeltResult', {
|
||||
transactionId: 42,
|
||||
status: TransactionStatus.COMPLETED,
|
||||
message: 'paid',
|
||||
})
|
||||
|
||||
expect(received).toEqual([
|
||||
{transactionId: 42, status: TransactionStatus.COMPLETED, message: 'paid'},
|
||||
])
|
||||
EventEmitter.off('ev_asyncMeltResult', handler)
|
||||
})
|
||||
|
||||
test('typed emit for an SyncQueue completion event (ev_sendTask_result)', () => {
|
||||
const received: any[] = []
|
||||
const handler = (payload: any) => received.push(payload)
|
||||
|
||||
EventEmitter.on('ev_sendTask_result', handler)
|
||||
EventEmitter.emit('ev_sendTask_result', {
|
||||
taskFunction: 'sendTask',
|
||||
mintUrl: 'http://mint',
|
||||
message: 'ok',
|
||||
})
|
||||
|
||||
expect(received).toHaveLength(1)
|
||||
expect(received[0].taskFunction).toBe('sendTask')
|
||||
EventEmitter.off('ev_sendTask_result', handler)
|
||||
})
|
||||
|
||||
test('emitting an event with no listeners is a no-op', () => {
|
||||
expect(() => {
|
||||
EventEmitter.emit('ev_handleClaimTask_result', {
|
||||
taskFunction: 'handleClaimTask',
|
||||
mintUrl: '',
|
||||
message: 'no listeners',
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('compile-time type safety (verified via @ts-expect-error)', () => {
|
||||
test('emit rejects payload that does not match the registered event shape', () => {
|
||||
// The runtime accepts anything, but tsc must reject these calls.
|
||||
// If tsc ever stops catching them, the @ts-expect-error pragmas turn red
|
||||
// and this whole file fails to compile.
|
||||
|
||||
// @ts-expect-error — missing required fields on ev_asyncMeltResult payload
|
||||
EventEmitter.emit('ev_asyncMeltResult', {foo: 'bar'})
|
||||
|
||||
// @ts-expect-error — transactionId must be number, not string
|
||||
EventEmitter.emit('ev_asyncMeltResult', {
|
||||
transactionId: 'oops',
|
||||
status: 'COMPLETED',
|
||||
message: 'x',
|
||||
})
|
||||
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test('on rejects handler whose param type does not match the event payload', () => {
|
||||
// @ts-expect-error — handler expects number, but payload is an object
|
||||
EventEmitter.on('ev_asyncMeltResult', (n: number) => void n)
|
||||
|
||||
// @ts-expect-error — handler expects TransactionTaskResult shape; using mismatched shape
|
||||
EventEmitter.on('ev_sendTask_result', (x: {totallyWrong: boolean}) => void x)
|
||||
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isolation between distinct event names', () => {
|
||||
test('handlers registered to one event do not receive others', () => {
|
||||
const a: any[] = []
|
||||
const b: any[] = []
|
||||
|
||||
const handlerA = (p: any) => a.push(p)
|
||||
const handlerB = (p: any) => b.push(p)
|
||||
|
||||
EventEmitter.on('ev_isolated_a', handlerA)
|
||||
EventEmitter.on('ev_isolated_b', handlerB)
|
||||
|
||||
EventEmitter.emit('ev_isolated_a', 'A')
|
||||
EventEmitter.emit('ev_isolated_b', 'B')
|
||||
|
||||
expect(a).toEqual(['A'])
|
||||
expect(b).toEqual(['B'])
|
||||
|
||||
EventEmitter.off('ev_isolated_a', handlerA)
|
||||
EventEmitter.off('ev_isolated_b', handlerB)
|
||||
})
|
||||
|
||||
test('multiple handlers on same event all receive the payload', () => {
|
||||
const a: any[] = []
|
||||
const b: any[] = []
|
||||
|
||||
const h1 = (p: any) => a.push(p)
|
||||
const h2 = (p: any) => b.push(p)
|
||||
|
||||
EventEmitter.on('ev_multi_handler', h1)
|
||||
EventEmitter.on('ev_multi_handler', h2)
|
||||
EventEmitter.emit('ev_multi_handler', 'shared')
|
||||
|
||||
expect(a).toEqual(['shared'])
|
||||
expect(b).toEqual(['shared'])
|
||||
|
||||
EventEmitter.off('ev_multi_handler', h1)
|
||||
EventEmitter.off('ev_multi_handler', h2)
|
||||
})
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "minibits_wallet",
|
||||
"version": "0.4.2-beta.7",
|
||||
"version": "0.4.2-beta.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android:clean": "cd android && ./gradlew clean",
|
||||
|
||||
+11
-6
@@ -4,11 +4,14 @@ import { MintUnit } from '../services/wallet/currency'
|
||||
import { CashuProof } from '../services/cashu/cashuUtils'
|
||||
import { SerializedDLEQ } from '@cashu/cashu-ts'
|
||||
|
||||
/**
|
||||
* Proof db record
|
||||
*/
|
||||
export type ProofState = 'UNSPENT' | 'PENDING' | 'SPENT'
|
||||
|
||||
export type ProofRecord = Proof & {
|
||||
/**
|
||||
* Proof db record — raw shape returned from SQLite queries.
|
||||
* The state column is always present after migration 25.
|
||||
*/
|
||||
export type ProofRecord = Omit<Proof, 'state'> & {
|
||||
state: ProofState
|
||||
dleq_r?: string
|
||||
dleq_s?: string
|
||||
dleq_e?: string
|
||||
@@ -31,8 +34,10 @@ export const ProofModel = types
|
||||
unit: types.frozen<MintUnit>(),
|
||||
tId: types.number,
|
||||
mintUrl: types.string,
|
||||
isPending: types.optional(types.boolean, false),
|
||||
isSpent: types.optional(types.boolean, false),
|
||||
state: types.optional(
|
||||
types.enumeration<ProofState>('ProofState', ['UNSPENT', 'PENDING', 'SPENT']),
|
||||
'UNSPENT'
|
||||
),
|
||||
})
|
||||
.actions(withSetPropAction)
|
||||
.actions(self => ({
|
||||
|
||||
+297
-73
@@ -6,7 +6,7 @@ import {
|
||||
isAlive,
|
||||
} from 'mobx-state-tree'
|
||||
import { withSetPropAction } from './helpers/withSetPropAction'
|
||||
import { ProofModel, Proof, ProofRecord } from './Proof' // Proof type now includes isPending/isSpent
|
||||
import { ProofModel, Proof, ProofRecord, ProofState } from './Proof'
|
||||
import { log } from '../services/logService'
|
||||
import { getRootStore } from './helpers/getRootStore'
|
||||
import AppError, { Err } from '../utils/AppError'
|
||||
@@ -14,11 +14,15 @@ import {
|
||||
import { Database } from '../services'
|
||||
import { MintUnit } from '../services/wallet/currency'
|
||||
import { CashuProof } from '../services/cashu/cashuUtils'
|
||||
import { generateId } from '../utils/utils'
|
||||
import { ProofReservation } from '../services/wallet/proofReservation'
|
||||
|
||||
export const ProofsStoreModel = types
|
||||
.model('ProofsStore', {
|
||||
proofs: types.optional(types.map(ProofModel), {}),
|
||||
// Only tracks secrets pending on the mint side
|
||||
// Tracks secrets that the mint itself reports as PENDING (lightning in-flight).
|
||||
// Distinct from proof.state === 'PENDING' (local lock): all mint-pending proofs are
|
||||
// locally PENDING, but not all locally-PENDING proofs are confirmed pending at the mint.
|
||||
pendingByMintSecrets: types.array(types.string),
|
||||
})
|
||||
.actions(withSetPropAction)
|
||||
@@ -42,15 +46,14 @@ import {
|
||||
return self.proofs.get(typeof proof === 'object' ? proof.secret : proof)
|
||||
},
|
||||
|
||||
// Helper views
|
||||
get unspentProofs() {
|
||||
return Array.from(self.proofs.values()).filter(p => !p.isSpent)
|
||||
return Array.from(self.proofs.values()).filter(p => p.state === 'UNSPENT')
|
||||
},
|
||||
get pendingProofs() {
|
||||
return Array.from(self.proofs.values()).filter(p => p.isPending && !p.isSpent)
|
||||
return Array.from(self.proofs.values()).filter(p => p.state === 'PENDING')
|
||||
},
|
||||
get spentProofs() {
|
||||
return Array.from(self.proofs.values()).filter(p => p.isSpent)
|
||||
return Array.from(self.proofs.values()).filter(p => p.state === 'SPENT')
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -72,20 +75,17 @@ import {
|
||||
getByMint(
|
||||
mintUrl: string,
|
||||
options: {
|
||||
isPending?: boolean
|
||||
state?: ProofState
|
||||
unit?: MintUnit
|
||||
keysetIds?: string[]
|
||||
ascending?: boolean
|
||||
} = {}
|
||||
): Proof[] {
|
||||
// Default to UNSPENT — the primary spendable pool
|
||||
const targetState = options.state ?? 'UNSPENT'
|
||||
|
||||
let proofs = Array.from(self.proofs.values())
|
||||
.filter(p => !p.isSpent)
|
||||
|
||||
proofs = proofs.filter(p => p.mintUrl === mintUrl)
|
||||
|
||||
// Default: only non-pending proofs. Explicit true returns only pending.
|
||||
const selectPending = options.isPending ?? false
|
||||
proofs = proofs.filter(p => p.isPending === selectPending)
|
||||
.filter(p => p.state === targetState && p.mintUrl === mintUrl)
|
||||
|
||||
if (options.keysetIds?.length) {
|
||||
proofs = proofs.filter(p => options.keysetIds!.includes(p.id))
|
||||
@@ -106,21 +106,17 @@ import {
|
||||
// ───────────────────── ACTIONS ─────────────────────
|
||||
.actions(self => ({
|
||||
loadProofsFromDatabase: flow(function* loadProofsFromDatabase(includeSpent: boolean = false) {
|
||||
// Load all proofs we care about (unspent + pending + optionally spent)
|
||||
const proofRecords: ProofRecord[] = yield Database.getProofs(
|
||||
true, // includeUnspent
|
||||
true, // includePending
|
||||
includeSpent
|
||||
)
|
||||
|
||||
// log.trace({proofRecords})
|
||||
|
||||
self.proofs.clear()
|
||||
|
||||
for (const record of proofRecords) {
|
||||
const {
|
||||
isPending: dbIsPending, // 0 or 1 from DB
|
||||
isSpent: dbIsSpent, // 0 or 1 from DB
|
||||
state,
|
||||
dleq_e,
|
||||
dleq_r,
|
||||
dleq_s,
|
||||
@@ -128,11 +124,6 @@ import {
|
||||
...coreProof
|
||||
} = record
|
||||
|
||||
// Normalize boolean fields: 0|1|undefined → proper boolean
|
||||
const isPending = !!dbIsPending
|
||||
const isSpent = !!dbIsSpent
|
||||
|
||||
// Reconstruct DLEQ only if mandatory parts exist
|
||||
const dleq = dleq_e && dleq_s
|
||||
? { e: dleq_e as string, r: dleq_r as string, s: dleq_s as string }
|
||||
: undefined
|
||||
@@ -140,8 +131,7 @@ import {
|
||||
self.proofs.put(
|
||||
ProofModel.create({
|
||||
...coreProof,
|
||||
isPending,
|
||||
isSpent,
|
||||
state: state ?? 'UNSPENT',
|
||||
dleq,
|
||||
})
|
||||
)
|
||||
@@ -149,9 +139,9 @@ import {
|
||||
|
||||
log.trace('[loadProofsFromDatabase]', {
|
||||
loaded: self.proofs.size,
|
||||
unspent: Array.from(self.proofs.values()).filter(p => !p.isSpent && !p.isPending).length,
|
||||
pending: Array.from(self.proofs.values()).filter(p => p.isPending).length,
|
||||
spent: Array.from(self.proofs.values()).filter(p => p.isSpent).length,
|
||||
unspent: Array.from(self.proofs.values()).filter(p => p.state === 'UNSPENT').length,
|
||||
pending: Array.from(self.proofs.values()).filter(p => p.state === 'PENDING').length,
|
||||
spent: Array.from(self.proofs.values()).filter(p => p.state === 'SPENT').length,
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -161,20 +151,14 @@ import {
|
||||
mintUrl: string,
|
||||
tId: number,
|
||||
unit: MintUnit
|
||||
isPending: boolean,
|
||||
isSpent: boolean,
|
||||
|
||||
}) : { updatedAmount: number; updatedProofs: Proof[] } {
|
||||
state: ProofState,
|
||||
}): { updatedAmount: number; updatedProofs: Proof[] } {
|
||||
|
||||
if (proofs.length === 0) return { updatedAmount: 0, updatedProofs: [] }
|
||||
|
||||
let updatedAmount = 0
|
||||
const updatedProofs: Proof[] = []
|
||||
const {isPending, isSpent, tId, unit, mintUrl} = update
|
||||
|
||||
if(isPending === true && isSpent === true) {
|
||||
throw new AppError(Err.VALIDATION_ERROR, 'Incorrect proof update', { update })
|
||||
}
|
||||
const { state, tId, unit, mintUrl } = update
|
||||
|
||||
const mintsStore = getRootStore(self).mintsStore
|
||||
const mintInstance = mintsStore.findByUrl(mintUrl)
|
||||
@@ -190,7 +174,7 @@ import {
|
||||
let proofNode = self.getBySecret(proof.secret)
|
||||
|
||||
if (proofNode) {
|
||||
if(proofNode.isSpent) continue // skip update if proof is spent already
|
||||
if (proofNode.state === 'SPENT') continue // never move a spent proof backward
|
||||
|
||||
if (!isAlive(proofNode)) {
|
||||
log.error('[addOrUpdate]', 'Proof instance is not alive, aborting state update', { secret: proofNode.secret })
|
||||
@@ -200,8 +184,7 @@ import {
|
||||
proofNode?.setProp('mintUrl', mintUrl)
|
||||
proofNode?.setProp('tId', tId)
|
||||
proofNode?.setProp('unit', unit)
|
||||
proofNode?.setProp('isPending', isPending)
|
||||
proofNode?.setProp('isSpent', isSpent)
|
||||
proofNode?.setProp('state', state)
|
||||
} else {
|
||||
proofNode = ProofModel.create({
|
||||
...proof,
|
||||
@@ -209,8 +192,7 @@ import {
|
||||
mintUrl,
|
||||
tId,
|
||||
unit,
|
||||
isPending,
|
||||
isSpent
|
||||
state,
|
||||
})
|
||||
self.proofs.put(proofNode)
|
||||
}
|
||||
@@ -221,8 +203,8 @@ import {
|
||||
proofsByKeyset.set(proof.id, (proofsByKeyset.get(proof.id) || []).concat(proofNode))
|
||||
}
|
||||
|
||||
// Update counters only for newly received, spendable proofs
|
||||
if (!isPending && !isSpent) {
|
||||
// Increment counters only when proofs become freshly spendable
|
||||
if (state === 'UNSPENT') {
|
||||
for (const [keysetId, proofs] of proofsByKeyset) {
|
||||
const counter = mintInstance.getProofsCounterByKeysetId(keysetId)
|
||||
counter?.increaseProofsCounter(proofs.length)
|
||||
@@ -230,29 +212,28 @@ import {
|
||||
}
|
||||
|
||||
if (updatedProofs.length > 0) {
|
||||
Database.addOrUpdateProofs(updatedProofs, isPending, isSpent)
|
||||
Database.addOrUpdateProofs(updatedProofs, state)
|
||||
}
|
||||
|
||||
log.trace('[addOrUpdate]', `Added or updated ${updatedProofs.length} ${isPending ? 'pending ' : ''} ${isSpent ? 'spent ' : ''} proofs`)
|
||||
log.trace('[addOrUpdate]', `Added or updated ${updatedProofs.length} ${state} proofs`)
|
||||
return { updatedAmount, updatedProofs }
|
||||
},
|
||||
|
||||
// Only call this when proofs are locally pending (ecash send, melt prepare, etc.)
|
||||
// Does NOT touch pendingByMintSecrets
|
||||
// Lock proofs locally during an outgoing operation (send, melt prepare, etc.)
|
||||
// Does NOT touch pendingByMintSecrets — that is mint-reported pending.
|
||||
moveToPending(proofs: Proof[]) {
|
||||
const liveProofs = proofs.filter(p => isAlive(p))
|
||||
if (liveProofs.length === 0) return
|
||||
|
||||
Database.addOrUpdateProofs(liveProofs, true, false)
|
||||
Database.addOrUpdateProofs(liveProofs, 'PENDING')
|
||||
|
||||
for (const p of liveProofs) {
|
||||
p.isPending = true
|
||||
p.isSpent = false
|
||||
p.state = 'PENDING'
|
||||
}
|
||||
},
|
||||
|
||||
// Only call this when mint explicitly reports PENDING state (lightning in flight)
|
||||
// This is the ONLY place that should add to pendingByMintSecrets
|
||||
// Called when the mint explicitly reports PENDING (lightning in-flight).
|
||||
// Only place that adds to pendingByMintSecrets.
|
||||
registerAsPendingAtMint(proofs: Proof[]) {
|
||||
for (const p of proofs) {
|
||||
if (!self.pendingByMintSecrets.includes(p.secret)) {
|
||||
@@ -261,7 +242,7 @@ import {
|
||||
}
|
||||
},
|
||||
|
||||
// Only call this when mint no longer reports PENDING (success or failure)
|
||||
// Called when the mint no longer reports PENDING (payment settled or failed).
|
||||
unregisterFromPendingAtMint(secrets: string[] | Set<string>) {
|
||||
const set = secrets instanceof Set ? secrets : new Set(secrets)
|
||||
self.pendingByMintSecrets.replace(
|
||||
@@ -272,13 +253,12 @@ import {
|
||||
const liveProofs = proofs.filter(p => isAlive(p))
|
||||
if (liveProofs.length === 0) return
|
||||
|
||||
Database.addOrUpdateProofs(liveProofs, false, true)
|
||||
Database.addOrUpdateProofs(liveProofs, 'SPENT')
|
||||
|
||||
for (const p of liveProofs) {
|
||||
p.isPending = false
|
||||
p.isSpent = true
|
||||
p.state = 'SPENT'
|
||||
}
|
||||
// Automatically clean if any were in mint-pending list
|
||||
// Clean mint-pending registry for any proofs that are now definitively spent
|
||||
const secrets = new Set(liveProofs.map(p => p.secret))
|
||||
self.pendingByMintSecrets.replace(
|
||||
self.pendingByMintSecrets.filter(s => !secrets.has(s))
|
||||
@@ -289,11 +269,10 @@ import {
|
||||
const liveProofs = proofs.filter(p => isAlive(p))
|
||||
if (liveProofs.length === 0) return
|
||||
|
||||
Database.addOrUpdateProofs(liveProofs, false, false)
|
||||
Database.addOrUpdateProofs(liveProofs, 'UNSPENT')
|
||||
|
||||
for (const p of liveProofs) {
|
||||
p.isPending = false
|
||||
p.isSpent = false
|
||||
p.state = 'UNSPENT'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -302,7 +281,7 @@ import {
|
||||
for (const proof of map.values()) {
|
||||
if (proof.mintUrl === currentMintUrl) {
|
||||
if (!isAlive(proof)) {
|
||||
log.error('[revertToSpendable]', 'Proof instance is not alive, aborting state update', { secret: proof.secret })
|
||||
log.error('[updateMintUrl]', 'Proof instance is not alive, aborting state update', { secret: proof.secret })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -317,13 +296,264 @@ import {
|
||||
log.trace('[updateMintUrl] Updated mint URL in proofs')
|
||||
},
|
||||
|
||||
// Action for importing proofs from backup without validation or side effects
|
||||
// Import proofs from backup without validation or side effects
|
||||
importProofs(proofs: Proof[]) {
|
||||
for (const proof of proofs) {
|
||||
self.proofs.put(ProofModel.create(proof))
|
||||
}
|
||||
log.trace('[importProofs]', `Imported ${proofs.length} proofs from backup`)
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Proof reservations (Phase 5)
|
||||
//
|
||||
// Wraps a sequence of state transitions in a single SQLite transaction
|
||||
// with deterministic rollback. See [src/services/wallet/proofReservation.ts]
|
||||
// for usage patterns.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lock `proofs` as PENDING under a new reservation id. Atomic in SQLite
|
||||
* (reservation row + state updates in one batch). MST is updated only
|
||||
* after SQLite commit succeeds.
|
||||
*/
|
||||
reserve(
|
||||
proofs: Proof[],
|
||||
opts: {
|
||||
transactionId: number
|
||||
mintUrl: string
|
||||
unit: MintUnit
|
||||
operationType: string
|
||||
/**
|
||||
* What state to restore the locked proofs to on rollback.
|
||||
* REQUIRED — every reservation site must declare its rollback
|
||||
* intent explicitly. This protects against silent bugs when
|
||||
* refactoring (e.g. an earlier commit changes proof state, so
|
||||
* the "restore current state" default would mis-rollback).
|
||||
*
|
||||
* - A `ProofState` value ('UNSPENT' | 'PENDING' | 'SPENT'):
|
||||
* restore ALL locked proofs to this state uniformly.
|
||||
* Use when the entire batch should reach the same state
|
||||
* on failure (the common case — e.g. release sent ecash
|
||||
* back to spendable: `'UNSPENT'`).
|
||||
*
|
||||
* - `'preserve'`: restore each proof to its individual state
|
||||
* at reserve time. Use when the batch is mixed (some
|
||||
* UNSPENT, some PENDING) and you literally want "undo".
|
||||
*/
|
||||
rollbackTo: ProofState | 'preserve'
|
||||
},
|
||||
): ProofReservation {
|
||||
const liveProofs = proofs.filter(p => isAlive(p))
|
||||
|
||||
const reservationId = generateId(16)
|
||||
const lockedProofs = liveProofs.map(p => ({
|
||||
secret: p.secret,
|
||||
originalState: opts.rollbackTo === 'preserve' ? p.state : opts.rollbackTo,
|
||||
}))
|
||||
|
||||
// ATOMIC: write reservation row + lock proofs to PENDING in one batch.
|
||||
Database.openReservation(
|
||||
{
|
||||
id: reservationId,
|
||||
transactionId: opts.transactionId,
|
||||
mintUrl: opts.mintUrl,
|
||||
unit: opts.unit,
|
||||
operationType: opts.operationType,
|
||||
lockedProofs,
|
||||
},
|
||||
liveProofs,
|
||||
)
|
||||
|
||||
// SQLite is durable — mirror into MST.
|
||||
for (const p of liveProofs) {
|
||||
if (isAlive(p) && p.state !== 'SPENT') {
|
||||
p.setProp('state', 'PENDING')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: reservationId,
|
||||
transactionId: opts.transactionId,
|
||||
mintUrl: opts.mintUrl,
|
||||
unit: opts.unit,
|
||||
operationType: opts.operationType,
|
||||
lockedProofs,
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Commit a reservation: apply final state transitions + add new proofs +
|
||||
* delete the reservation row, all in one SQLite transaction. MST is
|
||||
* mirrored after SQLite commit.
|
||||
*/
|
||||
commitReservation(
|
||||
reservation: ProofReservation,
|
||||
changes: {
|
||||
toSpent?: Proof[]
|
||||
toUnspent?: Proof[]
|
||||
newProofs?: Array<{
|
||||
proofs: CashuProof[]
|
||||
state: ProofState
|
||||
tId: number
|
||||
}>
|
||||
} = {},
|
||||
): { added: Proof[] } {
|
||||
const mintsStore = getRootStore(self).mintsStore
|
||||
const mintInstance = mintsStore.findByUrl(reservation.mintUrl)
|
||||
if (!mintInstance) {
|
||||
throw new AppError(Err.VALIDATION_ERROR, 'Mint not found for reservation', {
|
||||
mintUrl: reservation.mintUrl,
|
||||
reservationId: reservation.id,
|
||||
})
|
||||
}
|
||||
|
||||
// ATOMIC SQLite write of every state transition + reservation deletion.
|
||||
Database.commitReservation(reservation.id, {
|
||||
toSpent: changes.toSpent,
|
||||
toUnspent: changes.toUnspent,
|
||||
newProofs: changes.newProofs?.map(group => ({
|
||||
proofs: group.proofs,
|
||||
state: group.state,
|
||||
mintUrl: reservation.mintUrl,
|
||||
unit: reservation.unit,
|
||||
tId: group.tId,
|
||||
})),
|
||||
})
|
||||
|
||||
// Mirror to MST now that SQLite is durable.
|
||||
const added: Proof[] = []
|
||||
|
||||
for (const p of changes.toSpent ?? []) {
|
||||
if (isAlive(p)) p.setProp('state', 'SPENT')
|
||||
}
|
||||
for (const p of changes.toUnspent ?? []) {
|
||||
if (isAlive(p)) p.setProp('state', 'UNSPENT')
|
||||
}
|
||||
|
||||
// Clean pendingByMintSecrets for anything that just became SPENT.
|
||||
if (changes.toSpent && changes.toSpent.length > 0) {
|
||||
const spentSecrets = new Set(changes.toSpent.map(p => p.secret))
|
||||
self.pendingByMintSecrets.replace(
|
||||
self.pendingByMintSecrets.filter(s => !spentSecrets.has(s)),
|
||||
)
|
||||
}
|
||||
|
||||
const proofsByKeyset = new Map<string, Proof[]>()
|
||||
|
||||
for (const group of changes.newProofs ?? []) {
|
||||
for (const proof of group.proofs) {
|
||||
const existing = self.getBySecret(proof.secret)
|
||||
if (existing) {
|
||||
if (existing.state === 'SPENT') continue
|
||||
if (isAlive(existing)) {
|
||||
existing.setProp('mintUrl', reservation.mintUrl)
|
||||
existing.setProp('tId', group.tId)
|
||||
existing.setProp('unit', reservation.unit)
|
||||
existing.setProp('state', group.state)
|
||||
added.push(existing)
|
||||
if (group.state === 'UNSPENT') {
|
||||
proofsByKeyset.set(
|
||||
proof.id,
|
||||
(proofsByKeyset.get(proof.id) || []).concat(existing),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const node = ProofModel.create({
|
||||
...proof,
|
||||
amount: Number(proof.amount),
|
||||
mintUrl: reservation.mintUrl,
|
||||
tId: group.tId,
|
||||
unit: reservation.unit,
|
||||
state: group.state,
|
||||
})
|
||||
self.proofs.put(node)
|
||||
added.push(node)
|
||||
if (group.state === 'UNSPENT') {
|
||||
proofsByKeyset.set(
|
||||
proof.id,
|
||||
(proofsByKeyset.get(proof.id) || []).concat(node),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Increment keyset counters for proofs that became freshly spendable.
|
||||
for (const [keysetId, addedProofs] of proofsByKeyset) {
|
||||
const counter = mintInstance.getProofsCounterByKeysetId(keysetId)
|
||||
counter?.increaseProofsCounter(addedProofs.length)
|
||||
}
|
||||
|
||||
log.trace('[commitReservation]', 'Reservation committed', {
|
||||
id: reservation.id,
|
||||
toSpent: changes.toSpent?.length ?? 0,
|
||||
toUnspent: changes.toUnspent?.length ?? 0,
|
||||
addedCount: added.length,
|
||||
})
|
||||
|
||||
return { added }
|
||||
},
|
||||
|
||||
/**
|
||||
* Rollback a reservation: restore each locked proof to its originalState
|
||||
* and delete the reservation row. Safe to call multiple times; the second
|
||||
* call is a no-op because the reservation row has already been deleted.
|
||||
*/
|
||||
rollbackReservation(reservation: ProofReservation): void {
|
||||
Database.rollbackReservation(reservation.id, reservation.lockedProofs)
|
||||
|
||||
for (const snap of reservation.lockedProofs) {
|
||||
const node = self.getBySecret(snap.secret)
|
||||
if (node && isAlive(node) && node.state !== 'SPENT') {
|
||||
node.setProp('state', snap.originalState)
|
||||
}
|
||||
}
|
||||
|
||||
log.trace('[rollbackReservation]', 'Reservation rolled back', {
|
||||
id: reservation.id,
|
||||
restored: reservation.lockedProofs.length,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Detect orphan reservations (rows left behind by a process that died
|
||||
* before it could commit or rollback) and roll each one back.
|
||||
*
|
||||
* Intended to run once at startup, after proofs have been loaded from
|
||||
* the database. Idempotent.
|
||||
*/
|
||||
recoverOrphanReservations(): { recoveredCount: number } {
|
||||
const orphans = Database.getOpenReservations()
|
||||
if (orphans.length === 0) return { recoveredCount: 0 }
|
||||
|
||||
log.warn(
|
||||
`[recoverOrphanReservations] Found ${orphans.length} orphan reservations — rolling back`,
|
||||
)
|
||||
|
||||
for (const orphan of orphans) {
|
||||
try {
|
||||
Database.rollbackReservation(orphan.id, orphan.lockedProofs)
|
||||
// Mirror into MST (proofs were already reloaded from DB, so
|
||||
// the SQLite update above already changed their state on the
|
||||
// next reload; here we update in-place too if the nodes exist).
|
||||
for (const snap of orphan.lockedProofs) {
|
||||
const node = self.getBySecret(snap.secret)
|
||||
if (node && isAlive(node) && node.state !== 'SPENT') {
|
||||
node.setProp('state', snap.originalState)
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
log.error('[recoverOrphanReservations] rollback failed', {
|
||||
id: orphan.id,
|
||||
error: e.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { recoveredCount: orphans.length }
|
||||
},
|
||||
}))
|
||||
|
||||
// ───────────────────── DERIVED VIEWS ─────────────────────
|
||||
@@ -346,7 +576,6 @@ import {
|
||||
|
||||
const mints = getRootStore(self).mintsStore.allMints
|
||||
|
||||
// Collect all unique units across all mints
|
||||
const allUnits = new Set<MintUnit>()
|
||||
for (const mint of mints) {
|
||||
if (mint.units) {
|
||||
@@ -356,13 +585,11 @@ import {
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-seed unit maps with 0 for all known units → ensures zero values even if no proofs
|
||||
for (const unit of allUnits) {
|
||||
unitBalancesMap.set(unit, 0)
|
||||
unitPendingMap.set(unit, 0)
|
||||
}
|
||||
|
||||
// Pre-seed mint balances (you already did this correctly)
|
||||
for (const mint of mints) {
|
||||
const zero = Object.fromEntries(
|
||||
(mint.units ?? []).map(u => [u, 0])
|
||||
@@ -378,21 +605,18 @@ import {
|
||||
})
|
||||
}
|
||||
|
||||
// Now process proofs
|
||||
for (const proof of self.proofs.values()) {
|
||||
if (proof.isSpent) continue
|
||||
if (proof.state === 'SPENT') continue
|
||||
|
||||
const isPending = proof.isPending
|
||||
const isPending = proof.state === 'PENDING'
|
||||
const targetMintMap = isPending ? mintPendingMap : mintBalancesMap
|
||||
const targetUnitMap = isPending ? unitPendingMap : unitBalancesMap
|
||||
|
||||
// Update mint-specific balance
|
||||
const mintBalance = targetMintMap.get(proof.mintUrl)
|
||||
if (mintBalance) {
|
||||
mintBalance.balances[proof.unit]! += proof.amount
|
||||
}
|
||||
|
||||
// Update global unit balance (safe because pre-seeded)
|
||||
targetUnitMap.set(proof.unit, targetUnitMap.get(proof.unit)! + proof.amount)
|
||||
}
|
||||
|
||||
@@ -445,7 +669,7 @@ import {
|
||||
},
|
||||
}))
|
||||
|
||||
// Only persist the array of pending by mint secrets (proofs are loaded from DB on app start)
|
||||
// Proofs are loaded from DB on startup; only persist the mint-pending secrets list.
|
||||
.postProcessSnapshot(snapshot => ({
|
||||
proofs: {},
|
||||
pendingByMintSecrets: snapshot.pendingByMintSecrets,
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { JS_BUNDLE_VERSION } from '@env'
|
||||
import {KeyChain, MinibitsClient, WalletKeys} from '../services'
|
||||
import {log} from '../services/logService'
|
||||
import AppError, { Err } from '../utils/AppError'
|
||||
import AppError, { Err, MintError, NetworkError } from '../utils/AppError'
|
||||
import { Currencies, CurrencyCode, MintUnit } from '../services/wallet/currency'
|
||||
import { CashuProof, CashuUtils } from '../services/cashu/cashuUtils'
|
||||
import { Proof } from './Proof'
|
||||
@@ -697,15 +697,22 @@ export const WalletStoreModel = types
|
||||
} catch (e: any) {
|
||||
let message = 'Could not get response from the mint.'
|
||||
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
|
||||
throw new AppError(
|
||||
Err.MINT_ERROR,
|
||||
message,
|
||||
{
|
||||
message: e.message,
|
||||
|
||||
// Heuristic: if the inner cashu-ts cause looks like a transport
|
||||
// failure, surface a NetworkError so callers (e.g. syncStateWithMintTask)
|
||||
// can mark the mint OFFLINE via `instanceof NetworkError`.
|
||||
const innerMessage: string = e?.message ?? ''
|
||||
const isNetwork = /network|fetch|timeout|ECONNREFUSED|ETIMEDOUT|ENOTFOUND/i.test(innerMessage)
|
||||
|
||||
const params = {
|
||||
message: innerMessage,
|
||||
caller: 'WalletStore.getProofsStatesFromMint',
|
||||
mintUrl
|
||||
}
|
||||
)
|
||||
mintUrl,
|
||||
}
|
||||
|
||||
throw isNetwork
|
||||
? new NetworkError(message, params)
|
||||
: new MintError(message, params)
|
||||
}
|
||||
}),
|
||||
createLightningMintQuote: flow(function* createLightningMintQuote(
|
||||
|
||||
@@ -76,6 +76,16 @@ export async function setupRootStore(rootStore: RootStore) {
|
||||
|
||||
// hydrate unspent and pending ecash proofs to model from database
|
||||
await proofsStore.loadProofsFromDatabase()
|
||||
|
||||
// Roll back any orphan proof reservations from the last session.
|
||||
// An orphan is a reservation row whose owning operation died before
|
||||
// it could commit or rollback (process crash, force-quit, etc.).
|
||||
// Each orphan restores its locked proofs to their original state.
|
||||
const { recoveredCount } = proofsStore.recoverOrphanReservations()
|
||||
if (recoveredCount > 0) {
|
||||
log.warn(`[setupRootStore] Rolled back ${recoveredCount} orphan proof reservations`)
|
||||
}
|
||||
|
||||
// hydrate last transactions from database
|
||||
await transactionsStore.loadRecentFromDatabase()
|
||||
|
||||
|
||||
@@ -200,8 +200,8 @@ export const MintsScreen = observer(function MintsScreen({ route }: Props) {
|
||||
return
|
||||
}
|
||||
|
||||
const proofsByMint = proofsStore.getByMint(selectedMint.mintUrl, {isPending: false})
|
||||
const pendingProofsByMint = proofsStore.getByMint(selectedMint.mintUrl, {isPending: true})
|
||||
const proofsByMint = proofsStore.getByMint(selectedMint.mintUrl, {state: 'UNSPENT'})
|
||||
const pendingProofsByMint = proofsStore.getByMint(selectedMint.mintUrl, {state: 'PENDING'})
|
||||
let message: string = ''
|
||||
|
||||
if (proofsByMint && proofsByMint.length > 0) {
|
||||
|
||||
@@ -842,7 +842,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
|
||||
if (!isOnline.current) {
|
||||
// Try to pay exactly with available proofs (avoid swap)
|
||||
for (const balance of balances) {
|
||||
const proofs = proofsStore.getByMint(balance.mintUrl, { isPending: false, unit })
|
||||
const proofs = proofsStore.getByMint(balance.mintUrl, { state: 'UNSPENT', unit })
|
||||
const exactProofs = CashuUtils.getProofsToSend(amount, proofs)
|
||||
|
||||
if (CashuUtils.getProofsAmount(exactProofs) === amount) {
|
||||
|
||||
@@ -102,7 +102,7 @@ export const OptimizeEcashScreen = function OptimizeEcash(_props: Props) {
|
||||
|
||||
const buildDenominationCounts = () => {
|
||||
const allProofs = Array.from(proofsStore.proofs.values()).filter(
|
||||
p => !p.isSpent && !p.isPending,
|
||||
p => p.state === 'UNSPENT',
|
||||
)
|
||||
const countMap: Record<number, number> = {}
|
||||
for (const proof of allProofs) {
|
||||
|
||||
@@ -156,7 +156,7 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen(_:
|
||||
)
|
||||
} else {
|
||||
// iOS does not support fg notifications with long running tasks
|
||||
await WalletTask.syncStateWithAllMintsQueueAwaitable({isPending: false})
|
||||
await WalletTask.syncStateWithAllMintsQueueAwaitable({proofState: 'UNSPENT'})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -293,8 +293,7 @@ export const SeedRecoveryScreen = observer(function SeedRecoveryScreen({ route }
|
||||
mintUrl: recoveredMint.mintUrl,
|
||||
unit: selectedKeyset.unit as MintUnit,
|
||||
tId: transaction!.id,
|
||||
isPending: false,
|
||||
isSpent: false,
|
||||
state: 'UNSPENT',
|
||||
})
|
||||
|
||||
if (amount !== addedAmount) {
|
||||
@@ -349,8 +348,7 @@ export const SeedRecoveryScreen = observer(function SeedRecoveryScreen({ route }
|
||||
mintUrl: recoveredMint.mintUrl,
|
||||
unit: selectedKeyset.unit as MintUnit,
|
||||
tId: pendingTransaction!.id,
|
||||
isPending: true,
|
||||
isSpent: false,
|
||||
state: 'PENDING',
|
||||
})
|
||||
|
||||
if (pendingAmount !== addedAmount) {
|
||||
|
||||
@@ -1018,7 +1018,7 @@ export const SendScreen = observer(function SendScreen({ route }: Props) {
|
||||
const exactMatchProofs: Proof[] = []
|
||||
|
||||
if(!isOnlineRef.current) {
|
||||
const availableProofs = proofsStore.getByMint(mintBalanceToSendFrom.mintUrl, { isPending: false, unit: unitRef.current });
|
||||
const availableProofs = proofsStore.getByMint(mintBalanceToSendFrom.mintUrl, { state: 'UNSPENT', unit: unitRef.current });
|
||||
const autoSelectedProofs = CashuUtils.getProofsToSend(amountToSendInt, availableProofs)
|
||||
const autoSelectedAmount = CashuUtils.getProofsAmount(autoSelectedProofs)
|
||||
const isExactMatch = autoSelectedAmount === amountToSendInt
|
||||
@@ -1994,7 +1994,7 @@ const SelectProofsBlock = observer(function (props: {
|
||||
marginTop: spacing.small
|
||||
}}>
|
||||
<FlatList<Proof>
|
||||
data={proofsStore.getByMint(props.mintBalanceToSendFrom.mintUrl, { isPending: false, unit: props.unit })}
|
||||
data={proofsStore.getByMint(props.mintBalanceToSendFrom.mintUrl, { state: 'UNSPENT', unit: props.unit })}
|
||||
renderItem={({ item }) => {
|
||||
const isSelected = props.selectedProofs.some(
|
||||
p => p.secret === item.secret
|
||||
|
||||
@@ -62,9 +62,9 @@ import { TransferOption } from './TransferScreen'
|
||||
import { WalletUtils } from '../services/wallet/utils'
|
||||
|
||||
type ProofsByStatus = {
|
||||
isSpent: Proof[]
|
||||
isPending: Proof[]
|
||||
isReceived: Proof[]
|
||||
spent: Proof[]
|
||||
pending: Proof[]
|
||||
received: Proof[]
|
||||
}
|
||||
|
||||
type Props = StaticScreenProps<{
|
||||
@@ -1148,7 +1148,7 @@ const SendInfoBlock = function (props: {
|
||||
}
|
||||
|
||||
const allProofs = proofsStore.getByTransactionId(transaction.id)
|
||||
const pendingProofs = allProofs.filter(p => p.isPending === true)
|
||||
const pendingProofs = allProofs.filter(p => p.state === 'PENDING')
|
||||
|
||||
if(pendingProofs.length === 0) {
|
||||
const message = 'Could not get proofs related to the transaction from wallet state.'
|
||||
|
||||
@@ -316,7 +316,7 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
|
||||
|
||||
WalletTask.handleInFlightQueue()
|
||||
WalletTask.handlePendingQueue()
|
||||
await WalletTask.syncStateWithAllMintsQueueAwaitable({isPending: true})
|
||||
await WalletTask.syncStateWithAllMintsQueueAwaitable({proofState: 'PENDING'})
|
||||
|
||||
// Await claim queue to complete API call before refreshing exchange rate
|
||||
// (both may trigger token refresh, avoiding race condition)
|
||||
|
||||
+362
-54
@@ -3,7 +3,8 @@ import {
|
||||
open,
|
||||
SQLBatchTuple,
|
||||
} from 'react-native-quick-sqlite'
|
||||
import {Proof} from '../models/Proof'
|
||||
import {Proof, ProofState} from '../models/Proof'
|
||||
import {CashuProof} from './cashu/cashuUtils'
|
||||
import {
|
||||
Transaction,
|
||||
TransactionStatus,
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
import AppError, {Err} from '../utils/AppError'
|
||||
import {log} from './logService'
|
||||
import {ProofRecord} from '../models/Proof'
|
||||
import { isDate } from 'date-fns'
|
||||
import { isAlive } from 'mobx-state-tree'
|
||||
|
||||
// Helper functions to normalize transaction records with Date objects
|
||||
@@ -27,7 +27,7 @@ const normalizeTransactionRows = function(rows: any) {
|
||||
|
||||
let _db: QuickSQLiteConnection
|
||||
|
||||
const _dbVersion = 24 // Update this if db changes require migrations
|
||||
const _dbVersion = 26 // Update this if db changes require migrations
|
||||
|
||||
const getInstance = function () {
|
||||
if (!_db) {
|
||||
@@ -97,8 +97,7 @@ const _createOrUpdateSchema = function (db: QuickSQLiteConnection) {
|
||||
unit TEXT,
|
||||
tId INTEGER,
|
||||
mintUrl TEXT,
|
||||
isPending BOOLEAN,
|
||||
isSpent BOOLEAN,
|
||||
state TEXT NOT NULL DEFAULT 'UNSPENT',
|
||||
updatedAt TEXT
|
||||
)`,
|
||||
],
|
||||
@@ -109,6 +108,21 @@ const _createOrUpdateSchema = function (db: QuickSQLiteConnection) {
|
||||
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 {
|
||||
@@ -212,6 +226,61 @@ const _runMigrations = function (db: QuickSQLiteConnection) {
|
||||
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)
|
||||
@@ -329,7 +398,7 @@ const updateTransaction = function (id: number, fields: Partial<Transaction>): T
|
||||
`
|
||||
|
||||
const db = getInstance()
|
||||
const result = db.execute(query, params)
|
||||
db.execute(query, params)
|
||||
|
||||
const updated = getTransactionById(id) // already normalized
|
||||
|
||||
@@ -886,15 +955,14 @@ const deleteTransactionById = function (id: number) {
|
||||
*/
|
||||
const addOrUpdateProof = function (
|
||||
proof: Proof,
|
||||
isPending: boolean = false,
|
||||
isSpent: boolean = false,
|
||||
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, isPending, isSpent, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
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,
|
||||
@@ -907,16 +975,15 @@ const addOrUpdateProof = function (
|
||||
proof.unit,
|
||||
proof.tId,
|
||||
proof.mintUrl,
|
||||
isPending,
|
||||
isSpent,
|
||||
state,
|
||||
now.toISOString(),
|
||||
]
|
||||
|
||||
const db = getInstance()
|
||||
const result = db.execute(query, params)
|
||||
// DO NOT log proof secrets to Sentry
|
||||
log.info('[addOrUpdateProof]', `${isPending ? ' Pending' : ''} proof added or updated in the database`,
|
||||
{id: result.insertId, tId: proof.tId, isPending, isSpent},
|
||||
log.info('[addOrUpdateProof]', `Proof added or updated in the database`,
|
||||
{id: result.insertId, tId: proof.tId, state},
|
||||
)
|
||||
|
||||
const newProof = getProofById(result.insertId as number)
|
||||
@@ -933,34 +1000,26 @@ const addOrUpdateProof = function (
|
||||
|
||||
const addOrUpdateProofs = function (
|
||||
proofs: Proof[],
|
||||
isPending: boolean = false,
|
||||
isSpent: boolean = false,
|
||||
state: ProofState = 'UNSPENT',
|
||||
): number | undefined {
|
||||
try {
|
||||
const now = new Date()
|
||||
let insertQueries: SQLBatchTuple[] = []
|
||||
|
||||
if(isPending && isSpent) {
|
||||
throw new Error('Conflicting proof states')
|
||||
}
|
||||
|
||||
if(proofs.length === 0) {
|
||||
if (proofs.length === 0) {
|
||||
log.error('[addOrUpdateProofs] Empty proof array passed')
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
for (const proof of proofs) {
|
||||
if(!isAlive(proof)) {
|
||||
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, isPending, isSpent, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
`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,
|
||||
@@ -972,21 +1031,18 @@ const addOrUpdateProofs = function (
|
||||
proof.unit,
|
||||
proof.tId,
|
||||
proof.mintUrl,
|
||||
isPending,
|
||||
isSpent,
|
||||
state,
|
||||
now.toISOString(),
|
||||
],
|
||||
])
|
||||
}
|
||||
|
||||
// Execute the batch of SQL statements
|
||||
const db = getInstance()
|
||||
const {rowsAffected} = db.executeBatch(insertQueries)
|
||||
|
||||
// DO NOT log proof secrets to Sentry
|
||||
log.info('[addOrUpdateProofs]',
|
||||
`${rowsAffected}${isPending ? ' pending' : ''} ${isSpent ? ' spent' : ''} proofs were added or updated in the database`,
|
||||
{isPending, isSpent}
|
||||
`${rowsAffected} ${state} proofs were added or updated in the database`,
|
||||
)
|
||||
|
||||
return rowsAffected
|
||||
@@ -1065,44 +1121,34 @@ const getProofs = async (
|
||||
includePending: boolean,
|
||||
includeSpent: boolean,
|
||||
): Promise<ProofRecord[]> => {
|
||||
// If nothing is requested, return empty array early
|
||||
if (!includeUnspent && !includePending && !includeSpent) {
|
||||
return [];
|
||||
return []
|
||||
}
|
||||
|
||||
const conditions: string[] = [];
|
||||
|
||||
if (includeUnspent) {
|
||||
conditions.push('(isPending = 0 AND isSpent = 0)');
|
||||
}
|
||||
if (includePending) {
|
||||
conditions.push('(isPending = 1 AND isSpent = 0)');
|
||||
}
|
||||
if (includeSpent) {
|
||||
conditions.push('isSpent = 1');
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(' OR ');
|
||||
const states: string[] = []
|
||||
if (includeUnspent) states.push("'UNSPENT'")
|
||||
if (includePending) states.push("'PENDING'")
|
||||
if (includeSpent) states.push("'SPENT'")
|
||||
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM proofs
|
||||
WHERE ${whereClause}
|
||||
WHERE state IN (${states.join(', ')})
|
||||
ORDER BY id DESC
|
||||
`;
|
||||
`
|
||||
|
||||
try {
|
||||
const db = getInstance();
|
||||
const { rows } = await db.executeAsync(query);
|
||||
return (rows?._array ?? []) as ProofRecord[];
|
||||
const db = getInstance()
|
||||
const { rows } = await db.executeAsync(query)
|
||||
return (rows?._array ?? []) as ProofRecord[]
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Proofs could not be retrieved from the database',
|
||||
e.message,
|
||||
);
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const getProofsByTransaction = function (transactionId: number): ProofRecord[] {
|
||||
try {
|
||||
@@ -1127,6 +1173,264 @@ const getProofsByTransaction = function (transactionId: number): ProofRecord[] {
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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
|
||||
}
|
||||
|
||||
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,
|
||||
proof.tId,
|
||||
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 new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Could not open proof reservation',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a reservation: apply the supplied state transitions and delete the
|
||||
* reservation row — all in a single SQLite transaction.
|
||||
*/
|
||||
const commitReservation = function (
|
||||
reservationId: string,
|
||||
changes: {
|
||||
toSpent?: Proof[]
|
||||
toUnspent?: Proof[]
|
||||
newProofs?: Array<{
|
||||
proofs: Proof[] | CashuProof[]
|
||||
state: ProofState
|
||||
mintUrl: string
|
||||
unit: string
|
||||
tId: number
|
||||
}>
|
||||
},
|
||||
): 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) {
|
||||
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,
|
||||
group.unit,
|
||||
group.tId,
|
||||
group.mintUrl,
|
||||
group.state,
|
||||
now,
|
||||
],
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
batch.push([`DELETE FROM reservations WHERE id = ?`, [reservationId]])
|
||||
|
||||
const db = getInstance()
|
||||
db.executeBatch(batch)
|
||||
|
||||
log.info('[commitReservation]', 'Reservation committed', {
|
||||
id: reservationId,
|
||||
toSpent: changes.toSpent?.length ?? 0,
|
||||
toUnspent: changes.toUnspent?.length ?? 0,
|
||||
newGroups: changes.newProofs?.length ?? 0,
|
||||
})
|
||||
} catch (e: any) {
|
||||
throw new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Could not commit proof reservation',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback a reservation: restore each locked proof to its originalState and
|
||||
* 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 = ?, updatedAt = ? WHERE secret = ?`,
|
||||
[snap.originalState, 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 new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Could not rollback proof reservation',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 new AppError(
|
||||
Err.DATABASE_ERROR,
|
||||
'Could not read open reservations',
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const Database = {
|
||||
getInstance,
|
||||
getDatabaseVersion,
|
||||
@@ -1156,4 +1460,8 @@ export const Database = {
|
||||
getProofById,
|
||||
getProofs,
|
||||
getProofsByTransaction,
|
||||
openReservation,
|
||||
commitReservation,
|
||||
rollbackReservation,
|
||||
getOpenReservations,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Task, TaskId, TaskQueue, TaskStatus } from "taskon"
|
||||
import {log} from './logService'
|
||||
import EventEmitter from '../utils/eventEmitter'
|
||||
import { TransactionTaskResult, WalletTaskResult } from "./walletService"
|
||||
import { TransactionTaskResult, WalletTaskResult } from "./wallet/types"
|
||||
import { NotificationService, NWC_LISTENER_NAME } from "./notificationService"
|
||||
|
||||
let _queue: any = undefined
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Typed event registry for the wallet domain (Phase 7 of refactoring).
|
||||
*
|
||||
* Augments the `CoreEvents` interface declared in
|
||||
* [src/utils/eventEmitter.ts](src/utils/eventEmitter.ts) so that
|
||||
* `EventEmitter.on/emit/off` are type-checked when called with one of the
|
||||
* registered event names.
|
||||
*
|
||||
* This file is purely a type-side contribution — there is no runtime export.
|
||||
* It is imported for its side effects on the type system (declaration merging)
|
||||
* from [src/services/walletService.ts](src/services/walletService.ts).
|
||||
*/
|
||||
import {TransactionStatus} from '../../models/Transaction'
|
||||
import {
|
||||
SyncStateTaskResult,
|
||||
TransactionTaskResult,
|
||||
WalletTaskResult,
|
||||
} from './types'
|
||||
|
||||
// Auto-generated SyncQueue completion events: `ev_${result.taskFunction}_result`.
|
||||
// See syncQueueService._handleTaskResult.
|
||||
export type WalletEvents = {
|
||||
// TransactionTaskResult-shaped events
|
||||
ev_sendTask_result: TransactionTaskResult
|
||||
ev_receiveTask_result: TransactionTaskResult
|
||||
ev_receiveBatchTask_result: TransactionTaskResult
|
||||
ev_receiveOfflinePrepareTask_result: TransactionTaskResult
|
||||
ev_receiveOfflineCompleteTask_result: TransactionTaskResult
|
||||
ev_transferTask_result: TransactionTaskResult
|
||||
ev_topupTask_result: TransactionTaskResult
|
||||
ev_revertTask_result: TransactionTaskResult
|
||||
ev_cashuPaymentRequestTask_result: TransactionTaskResult
|
||||
|
||||
// SyncStateTaskResult-shaped events
|
||||
ev_syncSpendableStateTask_result: SyncStateTaskResult
|
||||
ev_syncStateWithMintTask_result: SyncStateTaskResult
|
||||
|
||||
// TransactionTaskResult-shaped (returns include mintUrl)
|
||||
ev_handleClaimTask_result: TransactionTaskResult
|
||||
ev_handleReceivedEventTask_result: TransactionTaskResult
|
||||
ev_handlePendingTopupTask_result: TransactionTaskResult
|
||||
ev_handleInFlightByMintTask_result: TransactionTaskResult
|
||||
|
||||
// WalletTaskResult-shaped (multi-mint or no specific mint context)
|
||||
ev_handleNwcRequestTask_result: WalletTaskResult
|
||||
ev_swapAllTask_result: WalletTaskResult
|
||||
ev_swapDenominationTask_result: WalletTaskResult
|
||||
ev_testTask_result: WalletTaskResult
|
||||
|
||||
// Manually emitted by MeltOperationService.handlePendingMeltTask
|
||||
ev_asyncMeltResult: {
|
||||
transactionId: number
|
||||
status: TransactionStatus
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
declare module '../../utils/eventEmitter' {
|
||||
// Merge wallet event names + payloads into the global CoreEvents map.
|
||||
interface CoreEvents extends WalletEvents {}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {Platform} from 'react-native'
|
||||
import {Contact} from '../../models/Contact'
|
||||
import {NotificationService} from '../notificationService'
|
||||
import {CurrencyCode, MintUnit, formatCurrency, getCurrency} from './currency'
|
||||
|
||||
export const sendTopupNotification = async function (amount: number, unit: MintUnit) {
|
||||
const currencyCode = getCurrency(unit).code
|
||||
await NotificationService.createLocalNotification(
|
||||
`⚡ ${formatCurrency(amount, currencyCode)} ${currencyCode} received!`,
|
||||
`Your invoice has been paid and your wallet balance credited with ${formatCurrency(amount, currencyCode)} ${currencyCode}.`,
|
||||
)
|
||||
}
|
||||
|
||||
export const sendReceiveNotification = async function (
|
||||
receivedAmount: number,
|
||||
feePaid: number,
|
||||
unit: MintUnit,
|
||||
isZap: boolean,
|
||||
sentFrom: string,
|
||||
sentFromPicture?: string,
|
||||
): Promise<void> {
|
||||
const getNotificationContent = (
|
||||
amount: number,
|
||||
currency: CurrencyCode,
|
||||
zap: boolean,
|
||||
sender: string,
|
||||
): {title: string; body: string} => {
|
||||
const title = Platform.OS === 'android'
|
||||
? `<b>⚡${formatCurrency(amount, currency)} ${currency}</b> received!`
|
||||
: `⚡${formatCurrency(amount, currency)} ${currency} received!`
|
||||
const body = Platform.OS === 'android'
|
||||
? `${zap ? 'Zap' : 'Ecash'} from <b>${sender || 'unknown payer'}</b> is now in your wallet.${feePaid > 0 ? ` Fee paid: ${formatCurrency(feePaid, currency)} ${currency}.` : ''}`
|
||||
: `${zap ? 'Zap' : 'Ecash'} from ${sender || 'unknown payer'} is now in your wallet.${feePaid > 0 ? ` Fee paid: ${formatCurrency(feePaid, currency)} ${currency}.` : ''}`
|
||||
return {title, body}
|
||||
}
|
||||
|
||||
const enabled = await NotificationService.areNotificationsEnabled()
|
||||
if (!enabled) return
|
||||
|
||||
const currencyCode = getCurrency(unit).code
|
||||
if (receivedAmount && receivedAmount > 0) {
|
||||
const {title, body} = getNotificationContent(receivedAmount, currencyCode, isZap, sentFrom)
|
||||
await NotificationService.createLocalNotification(title, body, sentFromPicture)
|
||||
}
|
||||
}
|
||||
|
||||
export const sendErrorReceiveNotification = async function (
|
||||
amountToReceive: number,
|
||||
unit: MintUnit,
|
||||
mint: string,
|
||||
): Promise<void> {
|
||||
const getNotificationContent = (
|
||||
amount: number,
|
||||
currency: CurrencyCode,
|
||||
): {title: string; body: string} => {
|
||||
const title = Platform.OS === 'android'
|
||||
? `<b>Received ${formatCurrency(amount, currency)} ${currency} ecash token from unknonw mint!</b>`
|
||||
: `Received ${formatCurrency(amount, currency)} ${currency} ecash token from unknonw mint!`
|
||||
const body = Platform.OS === 'android'
|
||||
? `Add <b>${mint}</b> to your wallet first to receive ecash over the Nostr network.`
|
||||
: `Add ${mint} to your wallet first to receive ecash over the Nostr network.`
|
||||
return {title, body}
|
||||
}
|
||||
|
||||
const enabled = await NotificationService.areNotificationsEnabled()
|
||||
if (!enabled) return
|
||||
|
||||
const currencyCode = getCurrency(unit).code
|
||||
if (amountToReceive && amountToReceive > 0) {
|
||||
const {title, body} = getNotificationContent(amountToReceive, currencyCode)
|
||||
await NotificationService.createLocalNotification(title, body)
|
||||
}
|
||||
}
|
||||
|
||||
export const sendIncomingInvoiceNotification = async function (
|
||||
amount: number,
|
||||
unit: MintUnit,
|
||||
from: Contact,
|
||||
) {
|
||||
await NotificationService.createLocalNotification(
|
||||
Platform.OS === 'android'
|
||||
? `⚡ Please pay <b>${formatCurrency(amount, getCurrency(unit).code)} ${getCurrency(unit).code}</b>!`
|
||||
: `⚡ Please pay ${formatCurrency(amount, getCurrency(unit).code)} ${getCurrency(unit).code}!`,
|
||||
`${from.nip05 || 'Unknown'} has sent you a request to pay.`,
|
||||
from.picture,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import {isAlive} from 'mobx-state-tree'
|
||||
import {getEncodedToken, normalizeProofAmounts} from '@cashu/cashu-ts'
|
||||
import {log} from '../../logService'
|
||||
import {rootStoreInstance} from '../../../models'
|
||||
import {Mint} from '../../../models/Mint'
|
||||
import {Proof} from '../../../models/Proof'
|
||||
import {
|
||||
TransactionData,
|
||||
TransactionStatus,
|
||||
TransactionType,
|
||||
} from '../../../models/Transaction'
|
||||
import {SyncQueue} from '../../syncQueueService'
|
||||
import {stopPolling} from '../../../utils/poller'
|
||||
import {
|
||||
HANDLE_INFLIGHT_BY_MINT_TASK,
|
||||
WalletTaskResult,
|
||||
} from '../types'
|
||||
|
||||
const {
|
||||
mintsStore,
|
||||
proofsStore,
|
||||
transactionsStore,
|
||||
walletStore,
|
||||
} = rootStoreInstance
|
||||
|
||||
/**
|
||||
* Recover proofs from in-flight mint/swap requests that failed due to network issues.
|
||||
* Uses mint's idempotent endpoints to safely retry and complete pending operations.
|
||||
*/
|
||||
const handleInFlightByMintTask = async (mint: Mint): Promise<WalletTaskResult> => {
|
||||
const mintUrl = mint.mintUrl
|
||||
const countersWithInFlight = mint.proofsCountersWithInFlightRequests || []
|
||||
|
||||
log.trace('[handleInFlightByMintTask] start', {
|
||||
mintUrl,
|
||||
counters: countersWithInFlight?.length,
|
||||
totalRequests: mint.allInFlightRequests?.length ?? 0,
|
||||
})
|
||||
|
||||
if (countersWithInFlight.length === 0) {
|
||||
return {
|
||||
taskFunction: HANDLE_INFLIGHT_BY_MINT_TASK,
|
||||
mintUrl,
|
||||
message: 'No in-flight requests found',
|
||||
}
|
||||
}
|
||||
|
||||
const errors: string[] = []
|
||||
|
||||
for (const counter of countersWithInFlight) {
|
||||
for (const inFlight of counter.allInFlightRequests) {
|
||||
|
||||
if (!isAlive(inFlight)) {
|
||||
log.error('[handleInFlightByMintTask]', 'InFlightRequest is not alive', {mintUrl})
|
||||
continue
|
||||
}
|
||||
|
||||
const tx = transactionsStore.findById(inFlight.transactionId)
|
||||
if (!tx) {
|
||||
counter.removeInFlightRequest(inFlight.transactionId)
|
||||
continue
|
||||
}
|
||||
|
||||
let txData: TransactionData[] = []
|
||||
try {
|
||||
txData = tx.data ? JSON.parse(tx.data) : []
|
||||
} catch (e) {
|
||||
log.warn('Failed to parse transaction.data', {tId: tx.id})
|
||||
}
|
||||
|
||||
const {unit} = tx
|
||||
|
||||
try {
|
||||
switch (tx.type) {
|
||||
case TransactionType.RECEIVE: {
|
||||
const {proofs, swapFeePaid} = await walletStore.receive(
|
||||
mintUrl,
|
||||
unit,
|
||||
inFlight.request.token,
|
||||
tx.id,
|
||||
{inFlightRequest: inFlight},
|
||||
)
|
||||
|
||||
const {updatedAmount: receivedAmount} = proofsStore.addOrUpdate(proofs, {
|
||||
mintUrl,
|
||||
tId: tx.id,
|
||||
unit,
|
||||
state: 'UNSPENT',
|
||||
})
|
||||
|
||||
const outputToken = getEncodedToken({mint: mintUrl, proofs: normalizeProofAmounts(proofs), unit})
|
||||
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
|
||||
|
||||
txData.push({status: TransactionStatus.COMPLETED, receivedAmount, swapFeePaid, createdAt: new Date()})
|
||||
|
||||
tx.update({
|
||||
amount: receivedAmount,
|
||||
status: TransactionStatus.COMPLETED,
|
||||
data: JSON.stringify(txData),
|
||||
outputToken,
|
||||
balanceAfter,
|
||||
fee: swapFeePaid > 0 ? swapFeePaid : tx.fee,
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case TransactionType.SEND: {
|
||||
// Look up the MST nodes for the persisted input proofs so
|
||||
// they can be reserved (and atomically transitioned to
|
||||
// SPENT below). In the common case all proofs exist and
|
||||
// are PENDING (left by the original send that died). Edge
|
||||
// case: a prior retry could have committed and only the
|
||||
// tx update failed — then they're already SPENT and
|
||||
// commit + rollback are no-ops for those entries.
|
||||
const lockedInputs = inFlight.request.proofs
|
||||
.map((p: {secret: string}) => proofsStore.getBySecret(p.secret))
|
||||
.filter((p: Proof | undefined): p is Proof => !!p && isAlive(p))
|
||||
|
||||
// rollbackTo: 'preserve' keeps each input at its actual
|
||||
// pre-reservation state on failure (PENDING stays PENDING,
|
||||
// SPENT stays SPENT — never un-spends).
|
||||
const reservation = proofsStore.reserve(lockedInputs, {
|
||||
transactionId: tx.id,
|
||||
mintUrl,
|
||||
unit,
|
||||
operationType: 'in-flight-send-retry',
|
||||
rollbackTo: 'preserve',
|
||||
})
|
||||
|
||||
try {
|
||||
const {returnedProofs, proofsToSend, swapFeePaid} = await walletStore.send(
|
||||
mintUrl,
|
||||
inFlight.request.amount,
|
||||
unit,
|
||||
inFlight.request.proofs,
|
||||
tx.id,
|
||||
{inFlightRequest: inFlight},
|
||||
)
|
||||
|
||||
// ATOMIC: inputs → SPENT, change → UNSPENT,
|
||||
// proofsToSend → PENDING, reservation row deleted —
|
||||
// single SQLite transaction.
|
||||
proofsStore.commitReservation(reservation, {
|
||||
toSpent: lockedInputs,
|
||||
newProofs: [
|
||||
{ proofs: returnedProofs, state: 'UNSPENT', tId: tx.id },
|
||||
{ proofs: proofsToSend, state: 'PENDING', tId: tx.id },
|
||||
],
|
||||
})
|
||||
|
||||
const outputToken = getEncodedToken({mint: mintUrl, proofs: normalizeProofAmounts(proofsToSend), unit})
|
||||
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
|
||||
|
||||
txData.push({status: TransactionStatus.PENDING, createdAt: new Date()})
|
||||
|
||||
tx.update({
|
||||
status: TransactionStatus.PENDING,
|
||||
data: JSON.stringify(txData),
|
||||
outputToken,
|
||||
balanceAfter,
|
||||
fee: swapFeePaid > 0 ? swapFeePaid : tx.fee,
|
||||
})
|
||||
} catch (sendError: any) {
|
||||
// Rollback restores each input to its pre-reservation
|
||||
// state (atomic with reservation row deletion).
|
||||
// inFlightRequest stays in place so the next sweep
|
||||
// retries this entry.
|
||||
proofsStore.rollbackReservation(reservation)
|
||||
throw sendError
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case TransactionType.TOPUP: {
|
||||
const proofs = await walletStore.mintProofs(
|
||||
mintUrl,
|
||||
inFlight.request.amount,
|
||||
unit,
|
||||
inFlight.request.quote,
|
||||
tx.id,
|
||||
{inFlightRequest: inFlight},
|
||||
)
|
||||
|
||||
proofsStore.addOrUpdate(proofs, {
|
||||
mintUrl,
|
||||
tId: tx.id,
|
||||
unit,
|
||||
state: 'UNSPENT',
|
||||
})
|
||||
|
||||
stopPolling(`handlePendingTopupPoller-${tx.paymentId}`)
|
||||
|
||||
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
|
||||
|
||||
txData.push({status: TransactionStatus.COMPLETED, createdAt: new Date()})
|
||||
|
||||
tx.update({
|
||||
status: TransactionStatus.COMPLETED,
|
||||
data: JSON.stringify(txData),
|
||||
balanceAfter,
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
// TRANSFER (melt / lightning out retry)
|
||||
// COMMENTED OUT — solved by syncStateWithMintTask which recovers change
|
||||
// from pending-yet-paid transfers. Request params (meltPreview) is stored
|
||||
// in proofsCounter.meltCounterValues, not inFlightRequests.
|
||||
case TransactionType.TRANSFER: {
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
log.error('[handleInFlightByMintTask] Unknown tx type', {type: tx.type, tId: tx.id})
|
||||
}
|
||||
|
||||
counter.removeInFlightRequest(inFlight.transactionId)
|
||||
|
||||
} catch (e: any) {
|
||||
log.error(`[handleInFlightByMintTask] ${tx.type} failed`, {
|
||||
tId: tx.id,
|
||||
error: e.name,
|
||||
message: e.message,
|
||||
})
|
||||
errors.push(`${tx.type} tId=${tx.id}: ${e.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalProcessed = mint.allInFlightRequests?.length ?? 0
|
||||
|
||||
return {
|
||||
taskFunction: HANDLE_INFLIGHT_BY_MINT_TASK,
|
||||
mintUrl,
|
||||
errors,
|
||||
message: `Processed ${totalProcessed} in-flight requests (${errors.length} failed)`,
|
||||
}
|
||||
}
|
||||
|
||||
const handleInFlightQueue = async function (): Promise<void> {
|
||||
log.trace('[handleInFlight] start')
|
||||
if (mintsStore.mintCount === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const mint of mintsStore.allMints) {
|
||||
|
||||
if (mint.proofsCountersWithInFlightRequests.length === 0) {
|
||||
log.trace('No proofCounters with inFlight requests, skipping...')
|
||||
continue
|
||||
}
|
||||
|
||||
const now = new Date().getTime()
|
||||
|
||||
SyncQueue.addTask(
|
||||
`${HANDLE_INFLIGHT_BY_MINT_TASK}-${now}`,
|
||||
async () => await handleInFlightByMintTask(mint),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const InFlightOperationService = {
|
||||
handleInFlightQueue,
|
||||
handleInFlightByMintTask,
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import {isBefore} from 'date-fns'
|
||||
import {
|
||||
MeltQuoteBolt11Response,
|
||||
MeltQuoteState,
|
||||
getEncodedToken,
|
||||
} from '@cashu/cashu-ts'
|
||||
import {log} from '../../logService'
|
||||
import {MintError, ValidationError} from '../../../utils/AppError'
|
||||
import EventEmitter from '../../../utils/eventEmitter'
|
||||
import {rootStoreInstance} from '../../../models'
|
||||
import {
|
||||
Transaction,
|
||||
TransactionData,
|
||||
TransactionStatus,
|
||||
} from '../../../models/Transaction'
|
||||
import {MintBalance} from '../../../models/Mint'
|
||||
import {Proof} from '../../../models/Proof'
|
||||
import {CashuUtils} from '../../cashu/cashuUtils'
|
||||
import {NostrEvent} from '../../nostrService'
|
||||
import {MintUnit, formatCurrency, getCurrency} from '../currency'
|
||||
import {transferTask} from '../transferTask'
|
||||
import {WalletUtils} from '../utils'
|
||||
import {createQueueAwaitable} from '../queueHelper'
|
||||
import {TransactionTaskResult} from '../types'
|
||||
|
||||
const {
|
||||
mintsStore,
|
||||
proofsStore,
|
||||
transactionsStore,
|
||||
walletStore,
|
||||
} = rootStoreInstance
|
||||
|
||||
const transferQueueAwaitable = (
|
||||
mintBalanceToTransferFrom: MintBalance,
|
||||
amountToTransfer: number,
|
||||
unit: MintUnit,
|
||||
meltQuote: MeltQuoteBolt11Response,
|
||||
memo: string,
|
||||
invoiceExpiry: Date,
|
||||
encodedInvoice: string,
|
||||
nwcEvent?: NostrEvent,
|
||||
draftTransactionId?: number,
|
||||
): Promise<TransactionTaskResult> =>
|
||||
createQueueAwaitable<TransactionTaskResult>({
|
||||
taskFunction: 'transferTask',
|
||||
timeoutMessage: 'transferQueue timed out',
|
||||
task: () =>
|
||||
transferTask(
|
||||
mintBalanceToTransferFrom,
|
||||
amountToTransfer,
|
||||
unit,
|
||||
meltQuote,
|
||||
memo,
|
||||
invoiceExpiry,
|
||||
encodedInvoice,
|
||||
nwcEvent,
|
||||
draftTransactionId,
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* Recover change from a paid melt quote (lightning out)
|
||||
*/
|
||||
const recoverMeltQuoteChange = async (
|
||||
params: {
|
||||
mintUrl: string
|
||||
meltQuote: string | MeltQuoteBolt11Response
|
||||
},
|
||||
): Promise<{recoveredAmount: number}> => {
|
||||
const {mintUrl, meltQuote} = params
|
||||
const mintInstance = mintsStore.findByUrl(mintUrl)
|
||||
const unit: MintUnit = 'sat'
|
||||
|
||||
if (!mintInstance || !meltQuote) {
|
||||
throw new ValidationError('Missing mint or melt quote', {mintUrl, meltQuote})
|
||||
}
|
||||
|
||||
log.trace('[recoverMeltQuoteChange] start', {mintUrl, meltQuote})
|
||||
|
||||
const meltQuoteResponse: MeltQuoteBolt11Response =
|
||||
typeof meltQuote === 'string'
|
||||
? await walletStore.checkLightningMeltQuote(mintUrl, meltQuote)
|
||||
: meltQuote
|
||||
|
||||
const {quote, state, change} = meltQuoteResponse
|
||||
|
||||
const tx = transactionsStore.findLastBy({quote})
|
||||
|
||||
if (!tx) {
|
||||
throw new ValidationError(`Original melt transaction not found for quote ${meltQuoteResponse.quote}`)
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case MeltQuoteState.UNPAID:
|
||||
if (tx.keysetId) {
|
||||
const currentCounter = mintInstance.getProofsCounterByKeysetId!(tx.keysetId)
|
||||
currentCounter.removeMeltCounterValue(tx.id)
|
||||
}
|
||||
|
||||
throw new ValidationError(`Melt quote ${meltQuote} was not paid`)
|
||||
|
||||
case MeltQuoteState.PENDING:
|
||||
throw new ValidationError(`Melt quote ${meltQuote} is still pending – cannot recover change yet`)
|
||||
|
||||
case MeltQuoteState.PAID: {
|
||||
if (!change || change.length === 0) {
|
||||
throw new ValidationError(`No change available for melt quote ${meltQuoteResponse.quote}`)
|
||||
}
|
||||
|
||||
let txData: TransactionData = tx.data ? JSON.parse(tx.data) : []
|
||||
|
||||
try {
|
||||
if (!tx.keysetId) {
|
||||
throw new ValidationError('Missing keysetId on transaction', {meltQuote})
|
||||
}
|
||||
|
||||
const currentCounter = mintInstance.getProofsCounterByKeysetId!(tx.keysetId)
|
||||
const meltCounterValue = currentCounter?.getMeltCounterValue(tx.id)
|
||||
|
||||
if (!meltCounterValue?.meltPreview) {
|
||||
throw new ValidationError('MeltPreview not found – this transaction may be from an older version', {meltQuote})
|
||||
}
|
||||
|
||||
const meltPreview = meltCounterValue.meltPreview
|
||||
const cashuWallet = await walletStore.getWallet(mintUrl, unit, {withSeed: true, keysetId: meltPreview.keysetId})
|
||||
const keyset = cashuWallet.getKeyset(meltPreview.keysetId)
|
||||
|
||||
const reconstructedOutputData = CashuUtils.deserializeOutputData(meltPreview.outputData)
|
||||
const recoveredChange = change.map((sig, i) => reconstructedOutputData[i].toProof(sig, keyset))
|
||||
currentCounter.removeMeltCounterValue(tx.id)
|
||||
|
||||
const newChange = recoveredChange.filter(proof => !proofsStore.alreadyExists(proof))
|
||||
|
||||
if (newChange.length === 0) {
|
||||
throw new MintError(`No new ecash proofs to recover from melt quote ${meltQuoteResponse.quote}, ${recoveredChange.length} proofs already in wallet.`)
|
||||
}
|
||||
|
||||
const {updatedAmount: recoveredAmount} = proofsStore.addOrUpdate(newChange, {
|
||||
mintUrl,
|
||||
unit,
|
||||
tId: tx.id,
|
||||
state: 'UNSPENT',
|
||||
})
|
||||
|
||||
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
|
||||
const outputToken = getEncodedToken({mint: mintUrl, proofs: newChange, unit})
|
||||
|
||||
txData.push({
|
||||
status: TransactionStatus.RECOVERED,
|
||||
recoveredAmount,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
tx.update({
|
||||
status: TransactionStatus.RECOVERED,
|
||||
amount: recoveredAmount,
|
||||
balanceAfter,
|
||||
outputToken,
|
||||
data: JSON.stringify(txData),
|
||||
})
|
||||
|
||||
log.debug('[recoverMeltQuoteChange] Success', {meltQuote, recoveredAmount})
|
||||
return {recoveredAmount}
|
||||
} catch (e: any) {
|
||||
log.error('[recoverMeltQuoteChange] Failed', {meltQuote, error: e.message})
|
||||
|
||||
txData.push({
|
||||
status: TransactionStatus.ERROR,
|
||||
error: WalletUtils.formatError(e),
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
tx.update({
|
||||
status: TransactionStatus.ERROR,
|
||||
data: JSON.stringify(txData),
|
||||
})
|
||||
|
||||
return {recoveredAmount: 0}
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
log.error('[recoverMeltQuoteChange] Unknown melt state', {state})
|
||||
throw new ValidationError(`Unknown melt quote state: ${state}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct change proofs returned by an async melt that just settled.
|
||||
*
|
||||
* Uses the meltPreview captured when the transfer was first prepared to
|
||||
* unblind the change signatures the mint reports on the resolved quote.
|
||||
*
|
||||
* Extracted as a standalone helper so its return type can flow back into the
|
||||
* caller via inference (avoiding explicit type-annotation gymnastics around
|
||||
* the cashu-ts strict-vs-loose Proof types).
|
||||
*
|
||||
* Returns `{ change: [...] }` — empty array if anything goes wrong; logs but
|
||||
* never throws, matching the pre-reservation behaviour where change-recovery
|
||||
* failure does not block the melt from finalizing.
|
||||
*/
|
||||
const unblindPendingMeltChange = async function (params: {
|
||||
mintUrl: string
|
||||
unit: MintUnit
|
||||
quoteId: string
|
||||
transaction: Transaction
|
||||
quoteChange: MeltQuoteBolt11Response['change']
|
||||
}) {
|
||||
const {mintUrl, unit, quoteId, transaction, quoteChange} = params
|
||||
try {
|
||||
const mintInstance = mintsStore.findByUrl(mintUrl)
|
||||
if (!mintInstance || !transaction.keysetId) return {change: []}
|
||||
|
||||
const currentCounter = mintInstance.getProofsCounterByKeysetId!(transaction.keysetId)
|
||||
const meltCounterValue = currentCounter?.getMeltCounterValue(transaction.id)
|
||||
|
||||
if (!meltCounterValue?.meltPreview || !quoteChange?.length) {
|
||||
return {change: []}
|
||||
}
|
||||
|
||||
log.trace('[handlePendingMelt] Unblinding change from quote response', {
|
||||
transactionId: transaction.id,
|
||||
quoteId,
|
||||
changeCount: quoteChange.length,
|
||||
})
|
||||
|
||||
const {meltPreview} = meltCounterValue
|
||||
const cashuWallet = await walletStore.getWallet(mintUrl, unit, {
|
||||
withSeed: true,
|
||||
keysetId: meltPreview.keysetId,
|
||||
})
|
||||
const keyset = cashuWallet.getKeyset(meltPreview.keysetId)
|
||||
|
||||
const reconstructedOutputData = CashuUtils.deserializeOutputData(meltPreview.outputData)
|
||||
const change = quoteChange.map((sig, i) => reconstructedOutputData[i].toProof(sig, keyset))
|
||||
|
||||
log.trace('[handlePendingMelt] Change unblinded', {transactionId: transaction.id, quoteId, change})
|
||||
|
||||
currentCounter.removeMeltCounterValue(transaction.id)
|
||||
|
||||
return {change}
|
||||
} catch (e: any) {
|
||||
log.error('[handlePendingMelt] change recovery failed, completing without change', {
|
||||
message: e.message,
|
||||
})
|
||||
return {change: []}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePendingMeltTask = async (params: {
|
||||
mintUrl: string
|
||||
unit: MintUnit
|
||||
quoteId: string
|
||||
proofsToMeltFrom: Proof[]
|
||||
proofsToMeltFromAmount: number
|
||||
amountToTransfer: number
|
||||
meltFeeReserve: number
|
||||
transactionId: number
|
||||
}): Promise<void> => {
|
||||
const {
|
||||
mintUrl,
|
||||
unit,
|
||||
quoteId,
|
||||
proofsToMeltFrom,
|
||||
proofsToMeltFromAmount,
|
||||
amountToTransfer,
|
||||
meltFeeReserve,
|
||||
transactionId,
|
||||
} = params
|
||||
|
||||
const transaction = transactionsStore.findById(transactionId)
|
||||
if (!transaction || transaction.status !== TransactionStatus.PENDING) return
|
||||
|
||||
const quote = await walletStore.checkLightningMeltQuote(mintUrl, quoteId)
|
||||
|
||||
if (quote.state === MeltQuoteState.PAID) {
|
||||
let totalFeePaid = proofsToMeltFromAmount - amountToTransfer
|
||||
let lightningFeePaid = totalFeePaid - meltFeeReserve
|
||||
const meltFeePaid = meltFeeReserve
|
||||
|
||||
// Unblind change BEFORE opening the reservation. Failure here just
|
||||
// means no change recovery — same fallback behaviour as the
|
||||
// pre-reservation code; the melt itself still finalizes.
|
||||
const unblinded = await unblindPendingMeltChange({
|
||||
mintUrl, unit, quoteId, transaction, quoteChange: quote.change,
|
||||
})
|
||||
|
||||
let returnedAmount = 0
|
||||
let outputToken: string | undefined
|
||||
if (unblinded.change.length > 0) {
|
||||
returnedAmount = CashuUtils.getProofsAmount(unblinded.change)
|
||||
outputToken = getEncodedToken({mint: mintUrl, proofs: unblinded.change, unit})
|
||||
totalFeePaid = totalFeePaid - returnedAmount
|
||||
lightningFeePaid = totalFeePaid - meltFeeReserve
|
||||
}
|
||||
|
||||
// Atomic finalize: proofsToMeltFrom move PENDING → SPENT and change (if any)
|
||||
// is added as UNSPENT in a single SQLite transaction. Replaces the previous
|
||||
// two-step `moveToSpent` + `addOrUpdate(change)` which left a crash window
|
||||
// where ecash could be marked SPENT without the change being recorded.
|
||||
const reservation = proofsStore.reserve(proofsToMeltFrom, {
|
||||
transactionId,
|
||||
mintUrl,
|
||||
unit,
|
||||
operationType: 'pending-melt-finalize-paid',
|
||||
rollbackTo: 'PENDING',
|
||||
})
|
||||
|
||||
proofsStore.commitReservation(reservation, {
|
||||
toSpent: proofsToMeltFrom,
|
||||
newProofs: unblinded.change.length > 0
|
||||
? [{ proofs: unblinded.change, state: 'UNSPENT', tId: transactionId }]
|
||||
: [],
|
||||
})
|
||||
|
||||
const txData: TransactionData[] = transaction.data ? JSON.parse(transaction.data) : []
|
||||
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
|
||||
txData.push({
|
||||
status: TransactionStatus.COMPLETED,
|
||||
lightningFeePaid,
|
||||
meltFeePaid,
|
||||
returnedAmount,
|
||||
//@ts-ignore
|
||||
preimage: quote.payment_preimage,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
const updatePayload: any = {
|
||||
status: TransactionStatus.COMPLETED,
|
||||
data: JSON.stringify(txData),
|
||||
fee: totalFeePaid,
|
||||
balanceAfter,
|
||||
}
|
||||
if (outputToken) updatePayload.outputToken = outputToken
|
||||
//@ts-ignore
|
||||
if (quote.payment_preimage) updatePayload.proof = quote.payment_preimage
|
||||
transaction.update(updatePayload)
|
||||
|
||||
log.debug('[handlePendingMelt] Transaction completed', {transactionId, totalFeePaid})
|
||||
|
||||
EventEmitter.emit('ev_asyncMeltResult', {
|
||||
transactionId,
|
||||
status: TransactionStatus.COMPLETED,
|
||||
message: `Lightning invoice paid. Fee: ${formatCurrency(transaction.fee, getCurrency(unit).code)} ${getCurrency(unit).code}.`,
|
||||
})
|
||||
|
||||
} else if (quote.state === MeltQuoteState.UNPAID) {
|
||||
// Single state transition (PENDING → UNSPENT); SQLite's per-write
|
||||
// atomicity is sufficient — no reservation needed.
|
||||
proofsStore.revertToSpendable(proofsToMeltFrom)
|
||||
|
||||
const txData: TransactionData[] = transaction.data ? JSON.parse(transaction.data) : []
|
||||
txData.push({
|
||||
status: TransactionStatus.ERROR,
|
||||
error: WalletUtils.formatError(new MintError('Async lightning payment failed.')),
|
||||
createdAt: new Date(),
|
||||
})
|
||||
transaction.update({status: TransactionStatus.ERROR, data: JSON.stringify(txData)})
|
||||
|
||||
log.debug('[handlePendingMelt] Transaction failed (UNPAID)', {transactionId})
|
||||
|
||||
EventEmitter.emit('ev_asyncMeltResult', {
|
||||
transactionId,
|
||||
status: TransactionStatus.ERROR,
|
||||
message: 'Lightning payment failed. Ecash returned to spendable balance.',
|
||||
})
|
||||
}
|
||||
// MeltQuoteState.PENDING: no-op, ws/poller will call again
|
||||
}
|
||||
|
||||
/**
|
||||
* Expire lightning transfers whose invoices have passed. Used by handlePendingQueue.
|
||||
*/
|
||||
const expirePendingTransfers = (pendingTransfers: Transaction[]): void => {
|
||||
for (const tx of pendingTransfers) {
|
||||
if (tx.expiresAt && isBefore(tx.expiresAt, new Date())) {
|
||||
log.debug('[MeltOperationService] Expiring transfer', {paymentId: tx.paymentId})
|
||||
|
||||
const update = {
|
||||
status: TransactionStatus.EXPIRED,
|
||||
message: 'Lightning invoice expired',
|
||||
createdAt: new Date(),
|
||||
}
|
||||
|
||||
try {
|
||||
const txData = tx.data ? [...JSON.parse(tx.data), update] : [update]
|
||||
tx.update({
|
||||
status: TransactionStatus.EXPIRED,
|
||||
data: JSON.stringify(txData),
|
||||
})
|
||||
} catch (e: any) {
|
||||
tx.update({
|
||||
status: TransactionStatus.EXPIRED,
|
||||
data: JSON.stringify(update),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const MeltOperationService = {
|
||||
transferQueueAwaitable,
|
||||
recoverMeltQuoteChange,
|
||||
handlePendingMeltTask,
|
||||
expirePendingTransfers,
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import {isBefore} from 'date-fns'
|
||||
import {MintQuoteState} from '@cashu/cashu-ts'
|
||||
import {log} from '../../logService'
|
||||
import {MintError, ValidationError} from '../../../utils/AppError'
|
||||
import {rootStoreInstance} from '../../../models'
|
||||
import {
|
||||
Transaction,
|
||||
TransactionData,
|
||||
TransactionStatus,
|
||||
TransactionType,
|
||||
} from '../../../models/Transaction'
|
||||
import {MintBalance} from '../../../models/Mint'
|
||||
import {Contact} from '../../../models/Contact'
|
||||
import {CashuProof, CashuUtils} from '../../cashu/cashuUtils'
|
||||
import {LightningUtils} from '../../lightning/lightningUtils'
|
||||
import {NostrEvent} from '../../nostrService'
|
||||
import {pollerExists, stopPolling} from '../../../utils/poller'
|
||||
import {MintUnit, formatCurrency, getCurrency} from '../currency'
|
||||
import {SyncQueue} from '../../syncQueueService'
|
||||
import {topupTask} from '../topupTask'
|
||||
import {WalletUtils} from '../utils'
|
||||
import {createQueueAwaitable} from '../queueHelper'
|
||||
import {
|
||||
HANDLE_PENDING_TOPUP_TASK,
|
||||
TransactionTaskResult,
|
||||
WalletTaskResult,
|
||||
} from '../types'
|
||||
import {sendTopupNotification} from '../notifications'
|
||||
|
||||
const {
|
||||
mintsStore,
|
||||
proofsStore,
|
||||
transactionsStore,
|
||||
walletStore,
|
||||
} = rootStoreInstance
|
||||
|
||||
const topupQueueAwaitable = (
|
||||
mintBalanceToTopup: MintBalance,
|
||||
amountToTopup: number,
|
||||
unit: MintUnit,
|
||||
memo: string,
|
||||
contactToSendTo?: Contact,
|
||||
nwcEvent?: NostrEvent,
|
||||
): Promise<TransactionTaskResult> =>
|
||||
createQueueAwaitable<TransactionTaskResult>({
|
||||
taskFunction: 'topupTask',
|
||||
timeoutMessage: 'Topup task timed out',
|
||||
task: () =>
|
||||
topupTask(
|
||||
mintBalanceToTopup,
|
||||
amountToTopup,
|
||||
unit,
|
||||
memo,
|
||||
contactToSendTo,
|
||||
nwcEvent,
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* Check a single pending mint quote and mint proofs if paid — even after expiry
|
||||
*/
|
||||
const handlePendingTopupTask = async (
|
||||
params: {transaction: Transaction},
|
||||
): Promise<WalletTaskResult> => {
|
||||
const {transaction: tx} = params
|
||||
const {
|
||||
id: tId,
|
||||
mint: mintUrl,
|
||||
unit,
|
||||
amount,
|
||||
paymentId: paymentHash,
|
||||
quote: mintQuote,
|
||||
expiresAt,
|
||||
} = tx
|
||||
|
||||
log.warn('[handlePendingTopupTask] start', {tx})
|
||||
|
||||
const mint = mintsStore.findByUrl(mintUrl)
|
||||
if (!mint || !mintQuote || !unit || !amount) {
|
||||
throw new ValidationError('Invalid pending topup transaction', {tId})
|
||||
}
|
||||
|
||||
let txData: TransactionData = tx.data ? JSON.parse(tx.data) : []
|
||||
|
||||
try {
|
||||
const {state} = await walletStore.checkLightningMintQuote(mintUrl, mintQuote)
|
||||
|
||||
const isExpired = expiresAt && isBefore(expiresAt, new Date())
|
||||
|
||||
if (isExpired && state !== MintQuoteState.PAID) {
|
||||
log.debug('[handlePendingTopupTask] Invoice expired and not paid', {paymentHash})
|
||||
|
||||
txData.push({status: TransactionStatus.EXPIRED, message: 'Invoice expired', createdAt: new Date()})
|
||||
tx.update({status: TransactionStatus.EXPIRED, data: JSON.stringify(txData)})
|
||||
stopPolling(`handlePendingTopupPoller-${paymentHash}`)
|
||||
|
||||
return {
|
||||
taskFunction: HANDLE_PENDING_TOPUP_TASK,
|
||||
transaction: tx,
|
||||
mintUrl,
|
||||
unit,
|
||||
amount,
|
||||
paymentHash,
|
||||
message: 'Topup invoice expired and was not paid',
|
||||
}
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case MintQuoteState.UNPAID:
|
||||
log.trace('[handlePendingTopupTask] Quote still unpaid', {mintQuote})
|
||||
|
||||
if (isExpired) {
|
||||
txData.push({status: TransactionStatus.EXPIRED, message: 'Invoice expired (unpaid)', createdAt: new Date()})
|
||||
tx.update({status: TransactionStatus.EXPIRED, data: JSON.stringify(txData)})
|
||||
stopPolling(`handlePendingTopupPoller-${paymentHash}`)
|
||||
}
|
||||
|
||||
return {
|
||||
taskFunction: HANDLE_PENDING_TOPUP_TASK,
|
||||
transaction: tx,
|
||||
mintUrl,
|
||||
unit,
|
||||
amount,
|
||||
paymentHash,
|
||||
message: isExpired ? 'Invoice expired (unpaid)' : 'Quote not paid yet',
|
||||
}
|
||||
|
||||
case MintQuoteState.ISSUED:
|
||||
log.info('[handlePendingTopupTask] Proofs already issued (likely from another device)', {mintQuote})
|
||||
txData.push({status: TransactionStatus.COMPLETED, note: 'Already issued', createdAt: new Date()})
|
||||
tx.update({status: TransactionStatus.COMPLETED, data: JSON.stringify(txData)})
|
||||
stopPolling(`handlePendingTopupPoller-${paymentHash}`)
|
||||
return {
|
||||
taskFunction: HANDLE_PENDING_TOPUP_TASK,
|
||||
transaction: tx,
|
||||
mintUrl,
|
||||
unit,
|
||||
amount,
|
||||
paymentHash,
|
||||
message: 'Ecash already issued',
|
||||
}
|
||||
|
||||
case MintQuoteState.PAID: {
|
||||
log.debug('[handlePendingTopupTask] Quote PAID – minting proofs (even if expired)', {paymentHash, amount, unit, isExpired})
|
||||
|
||||
let proofs: CashuProof[] = []
|
||||
|
||||
try {
|
||||
proofs = await walletStore.mintProofs(mintUrl, amount, unit, mintQuote, tId)
|
||||
} catch (e: any) {
|
||||
if (WalletUtils.shouldHealOutputsError(e)) {
|
||||
log.error('[handlePendingTopupTask] Increasing proofsCounter outdated values and repeating mintProofs.')
|
||||
proofs = await walletStore.mintProofs(mintUrl, amount, unit, mintQuote, tId, {increaseCounterBy: 10})
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
if (proofs.length === 0) {
|
||||
throw new MintError('Mint returned no proofs after payment')
|
||||
}
|
||||
|
||||
proofsStore.addOrUpdate(proofs, {
|
||||
mintUrl,
|
||||
unit,
|
||||
tId,
|
||||
state: 'UNSPENT',
|
||||
})
|
||||
|
||||
const currencyCode = getCurrency(unit).code
|
||||
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
|
||||
|
||||
txData.push({status: TransactionStatus.COMPLETED, createdAt: new Date()})
|
||||
|
||||
tx.update({
|
||||
status: TransactionStatus.COMPLETED,
|
||||
data: JSON.stringify(txData),
|
||||
balanceAfter,
|
||||
})
|
||||
|
||||
stopPolling(`handlePendingTopupPoller-${paymentHash}`)
|
||||
sendTopupNotification(amount, unit)
|
||||
|
||||
return {
|
||||
taskFunction: HANDLE_PENDING_TOPUP_TASK,
|
||||
transaction: tx,
|
||||
mintUrl,
|
||||
unit,
|
||||
amount,
|
||||
paymentHash,
|
||||
message: `Topup successful: +${formatCurrency(amount, currencyCode)} ${currencyCode}${isExpired ? ' (paid after expiry)' : ''}`,
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
log.error('[handlePendingTopupTask] Unknown quote state', {state})
|
||||
return {
|
||||
taskFunction: HANDLE_PENDING_TOPUP_TASK,
|
||||
transaction: tx,
|
||||
mintUrl,
|
||||
unit,
|
||||
amount,
|
||||
paymentHash,
|
||||
message: `Unknown quote state: ${state}`,
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
log.error('[handlePendingTopupTask] failed', {
|
||||
tId,
|
||||
paymentHash,
|
||||
error: e.name,
|
||||
message: e.message,
|
||||
})
|
||||
|
||||
return {
|
||||
taskFunction: HANDLE_PENDING_TOPUP_TASK,
|
||||
transaction: tx,
|
||||
mintUrl,
|
||||
unit,
|
||||
amount,
|
||||
paymentHash,
|
||||
error: WalletUtils.formatError(e),
|
||||
message: `Topup failed: ${e.message}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually recover minted ecash from a paid mint quote (e.g. lost topup)
|
||||
*/
|
||||
const recoverMintQuote = async (
|
||||
params: {mintUrl: string; mintQuote: string},
|
||||
): Promise<{recoveredAmount: number}> => {
|
||||
const {mintUrl, mintQuote} = params
|
||||
const mint = mintsStore.findByUrl(mintUrl)
|
||||
const unit: MintUnit = 'sat'
|
||||
|
||||
if (!mint || !mintQuote) {
|
||||
throw new ValidationError('Missing mint or mint quote', {mintUrl, mintQuote})
|
||||
}
|
||||
|
||||
log.trace('[recoverMintQuote] start', {mintUrl, mintQuote})
|
||||
|
||||
const {state, mintQuote: returnedQuote, encodedInvoice} = await walletStore.checkLightningMintQuote(mintUrl, mintQuote)
|
||||
|
||||
if (returnedQuote !== mintQuote) {
|
||||
throw new ValidationError('Mint returned mismatched quote', {mintQuote, returnedQuote})
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case MintQuoteState.UNPAID:
|
||||
throw new ValidationError(`Quote ${mintQuote} is not paid`)
|
||||
|
||||
case MintQuoteState.ISSUED:
|
||||
throw new ValidationError(`Quote ${mintQuote} already issued – nothing to recover`)
|
||||
|
||||
case MintQuoteState.PAID: {
|
||||
const invoice = LightningUtils.decodeInvoice(encodedInvoice)
|
||||
const {amount, description} = LightningUtils.getInvoiceData(invoice)
|
||||
|
||||
const txData: TransactionData[] = [
|
||||
{status: TransactionStatus.DRAFT, amount, unit, createdAt: new Date()},
|
||||
]
|
||||
|
||||
const tx = await transactionsStore.addTransaction({
|
||||
type: TransactionType.TOPUP,
|
||||
amount,
|
||||
fee: 0,
|
||||
unit,
|
||||
data: JSON.stringify(txData),
|
||||
memo: description || 'Recovered topup',
|
||||
mint: mintUrl,
|
||||
status: TransactionStatus.DRAFT,
|
||||
quote: mintQuote,
|
||||
})
|
||||
|
||||
let proofs: CashuProof[] = []
|
||||
|
||||
try {
|
||||
proofs = await walletStore.mintProofs(mintUrl, amount, unit, mintQuote, tx.id)
|
||||
} catch (e: any) {
|
||||
if (WalletUtils.shouldHealOutputsError(e)) {
|
||||
log.error('[recoverMintQuote] Increasing proofsCounter outdated values and repeating mintProofs')
|
||||
proofs = await walletStore.mintProofs(mintUrl, amount, unit, mintQuote, tx.id, {increaseCounterBy: 10})
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
if (proofs.length === 0) {
|
||||
throw new MintError('Mint returned no proofs to recover')
|
||||
}
|
||||
|
||||
const {updatedAmount: recoveredAmount} = proofsStore.addOrUpdate(proofs, {
|
||||
mintUrl,
|
||||
unit,
|
||||
tId: tx.id,
|
||||
state: 'UNSPENT',
|
||||
})
|
||||
|
||||
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
|
||||
|
||||
txData.push({status: TransactionStatus.RECOVERED, recoveredAmount, createdAt: new Date()})
|
||||
|
||||
tx.update({
|
||||
status: TransactionStatus.RECOVERED,
|
||||
amount: recoveredAmount,
|
||||
keysetId: proofs[0].id,
|
||||
balanceAfter,
|
||||
data: JSON.stringify(txData),
|
||||
})
|
||||
|
||||
log.debug('[recoverMintQuote] Success', {mintUrl, mintQuote, recoveredAmount})
|
||||
return {recoveredAmount}
|
||||
}
|
||||
|
||||
default:
|
||||
log.error('[recoverMintQuote] Unknown quote state', {state})
|
||||
throw new ValidationError(`Unknown quote state: ${state}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a pending topup check for a single transaction (used by handlePendingQueue).
|
||||
*/
|
||||
const enqueuePendingTopupCheck = (tx: Transaction): void => {
|
||||
if (pollerExists(`handlePendingTopupPoller-${tx.paymentId}`)) {
|
||||
log.trace('[MintOperationService] Skipping topup – poller active', {paymentId: tx.paymentId})
|
||||
return
|
||||
}
|
||||
|
||||
const taskId = `handlePendingTopupTask-${tx.id}-${Date.now()}`
|
||||
SyncQueue.addTask(taskId, () => handlePendingTopupTask({transaction: tx}))
|
||||
}
|
||||
|
||||
export const MintOperationService = {
|
||||
topupQueueAwaitable,
|
||||
handlePendingTopupTask,
|
||||
recoverMintQuote,
|
||||
enqueuePendingTopupCheck,
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
import {addSeconds} from 'date-fns'
|
||||
import {GiftWrap, EncryptedDirectMessage} from 'nostr-tools/kinds'
|
||||
import {UnsignedEvent} from 'nostr-tools'
|
||||
import {
|
||||
PaymentRequestPayload,
|
||||
Token,
|
||||
getDecodedToken,
|
||||
getTokenMetadata,
|
||||
decodePaymentRequest,
|
||||
} from '@cashu/cashu-ts'
|
||||
import {MINIBITS_NIP05_DOMAIN, MINIBIT_SERVER_NOSTR_PUBKEY} from '@env'
|
||||
import {log} from '../../logService'
|
||||
import {Err, ValidationError} from '../../../utils/AppError'
|
||||
import {rootStoreInstance} from '../../../models'
|
||||
import {Contact} from '../../../models/Contact'
|
||||
import {MintBalance} from '../../../models/Mint'
|
||||
import {
|
||||
TransactionData,
|
||||
TransactionStatus,
|
||||
TransactionType,
|
||||
} from '../../../models/Transaction'
|
||||
import {SyncQueue} from '../../syncQueueService'
|
||||
import {
|
||||
NostrClient,
|
||||
NostrEvent,
|
||||
NostrProfile,
|
||||
} from '../../nostrService'
|
||||
import {LightningUtils} from '../../lightning/lightningUtils'
|
||||
import {IncomingDataType, IncomingParser} from '../../incomingParser'
|
||||
import {MinibitsClient} from '../../minibitsService'
|
||||
import {MintUnit} from '../currency'
|
||||
import {
|
||||
receiveTask,
|
||||
receiveByCashuPaymentRequestTask,
|
||||
} from '../receiveTask'
|
||||
import {WalletUtils} from '../utils'
|
||||
import {
|
||||
HANDLE_CLAIM_TASK,
|
||||
HANDLE_RECEIVED_EVENT_TASK,
|
||||
TransactionTaskResult,
|
||||
WalletTaskResult,
|
||||
} from '../types'
|
||||
import {
|
||||
sendReceiveNotification,
|
||||
sendErrorReceiveNotification,
|
||||
sendIncomingInvoiceNotification,
|
||||
} from '../notifications'
|
||||
|
||||
const {
|
||||
userSettingsStore,
|
||||
walletProfileStore,
|
||||
mintsStore,
|
||||
proofsStore,
|
||||
transactionsStore,
|
||||
contactsStore,
|
||||
relaysStore,
|
||||
walletStore,
|
||||
nwcStore,
|
||||
} = rootStoreInstance
|
||||
|
||||
const extractZapSenderData = function (str: string) {
|
||||
const match = str.match(/\{[^}]*\}/)
|
||||
return match ? match[0] : null
|
||||
}
|
||||
|
||||
const handleClaimQueue = async function (): Promise<void> {
|
||||
log.info('[handleClaimQueue] start')
|
||||
const {isOwnProfile} = walletProfileStore
|
||||
|
||||
if (isOwnProfile) {
|
||||
log.info('[handleClaimQueue] Skipping claim queue, wallet uses own Nostr keys...')
|
||||
return
|
||||
}
|
||||
|
||||
const {isBatchClaimOn} = userSettingsStore
|
||||
const keys = await walletStore.getCachedWalletKeys()
|
||||
|
||||
const claimedTokens = await MinibitsClient.createClaim(
|
||||
keys.SEED.seedHash,
|
||||
isBatchClaimOn ? 5 : undefined,
|
||||
)
|
||||
|
||||
if (claimedTokens.length === 0) {
|
||||
log.debug('[handleClaimQueue] No claimed invoices returned from the server...')
|
||||
return
|
||||
}
|
||||
|
||||
log.debug(`[handleClaimQueue] Claimed ${claimedTokens.length} tokens from the server...`)
|
||||
|
||||
for (const claimedToken of claimedTokens) {
|
||||
const now = new Date().getTime()
|
||||
|
||||
SyncQueue.addTask(
|
||||
`handleClaimTask-${now}`,
|
||||
async () => await handleClaimTask({claimedToken}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClaimTask = async function (params: {
|
||||
claimedToken: {
|
||||
token: string
|
||||
zapSenderProfile?: string
|
||||
zapRequest?: string
|
||||
}
|
||||
}): Promise<WalletTaskResult> {
|
||||
let decoded: Token | undefined = undefined
|
||||
|
||||
try {
|
||||
const {claimedToken} = params
|
||||
|
||||
log.debug('[handleClaimTask] claimed token', {claimedToken})
|
||||
|
||||
if (!claimedToken.token) {
|
||||
throw new ValidationError('[handleClaimTask] Missing encodedToken to receive.')
|
||||
}
|
||||
|
||||
const encryptedToken = claimedToken.token
|
||||
const keys = (await walletStore.getCachedWalletKeys()).NOSTR
|
||||
const encodedToken = await NostrClient.decryptNip04(MINIBIT_SERVER_NOSTR_PUBKEY, encryptedToken, keys)
|
||||
|
||||
log.debug('[handleClaimTask] decrypted token', {encodedToken})
|
||||
|
||||
const tokenInfo = getTokenMetadata(encodedToken)
|
||||
const mintKeysetIds = mintsStore.findByUrl(tokenInfo.mint)?.keysetIds
|
||||
if (!mintKeysetIds || mintKeysetIds.length === 0) {
|
||||
throw new ValidationError('Missing keysetIds in the wallet state', {
|
||||
mintUrl: tokenInfo.mint,
|
||||
}, Err.NOTFOUND_ERROR)
|
||||
}
|
||||
|
||||
decoded = getDecodedToken(encodedToken, mintKeysetIds)
|
||||
|
||||
const result: TransactionTaskResult = await receiveTask(
|
||||
decoded,
|
||||
Number(tokenInfo.amount),
|
||||
tokenInfo.memo || 'Received to Lightning address',
|
||||
encodedToken,
|
||||
)
|
||||
|
||||
if (result && result.transaction) {
|
||||
const transaction = transactionsStore.findById(result.transaction.id!)
|
||||
const {zapSenderProfile, zapRequest} = claimedToken
|
||||
|
||||
if (transaction) {
|
||||
if (zapSenderProfile) {
|
||||
let sentFrom: string = ''
|
||||
try {
|
||||
const profile: NostrProfile = JSON.parse(zapSenderProfile)
|
||||
sentFrom = profile.nip05 ?? profile.name
|
||||
} catch (e: any) {}
|
||||
|
||||
transaction.update({profile: zapSenderProfile, sentFrom})
|
||||
}
|
||||
|
||||
if (zapRequest) {
|
||||
transaction.update({zapRequest})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mintUrl: decoded.mint,
|
||||
taskFunction: HANDLE_CLAIM_TASK,
|
||||
message: result.error ? result.error.message : 'Ecash sent to your lightning address has been received.',
|
||||
error: result.error || undefined,
|
||||
proofsCount: decoded.proofs.length,
|
||||
proofsAmount: result.transaction?.amount,
|
||||
} as WalletTaskResult
|
||||
|
||||
} catch (e: any) {
|
||||
log.error(e.name, e.message)
|
||||
|
||||
return {
|
||||
mintUrl: decoded ? decoded.mint : '',
|
||||
taskFunction: HANDLE_CLAIM_TASK,
|
||||
message: e.message,
|
||||
error: WalletUtils.formatError(e),
|
||||
} as WalletTaskResult
|
||||
}
|
||||
}
|
||||
|
||||
const handleNwcRequestQueue = async function (params: {requestEvent: NostrEvent}): Promise<void> {
|
||||
const {requestEvent} = params
|
||||
log.trace('[handleNwcRequestQueue] start')
|
||||
|
||||
const now = new Date().getTime()
|
||||
SyncQueue.addTask(
|
||||
`handleNwcRequestTask-${now}`,
|
||||
async () => await nwcStore.handleNwcRequestTask(requestEvent),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks with NOSTR relays whether there is ecash to be received or an invoice to be paid.
|
||||
*/
|
||||
const receiveEventsFromRelaysQueue = async function (): Promise<void> {
|
||||
log.trace('[receiveEventsFromRelays] starting listening for events')
|
||||
|
||||
if (!walletProfileStore.pubkey) {
|
||||
const message = `No wallet profile created.`
|
||||
log.trace('[receiveEventsFromRelays]', message)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const {lastPendingReceivedCheck} = contactsStore
|
||||
const TWO_DAYS = 2 * 24 * 60 * 60
|
||||
|
||||
const filter = {
|
||||
kinds: [GiftWrap, EncryptedDirectMessage],
|
||||
"#p": [walletProfileStore.pubkey],
|
||||
since: lastPendingReceivedCheck ? lastPendingReceivedCheck - TWO_DAYS : 0,
|
||||
}
|
||||
|
||||
log.trace('[receiveEventsFromRelays]', {filter})
|
||||
|
||||
contactsStore.setLastPendingReceivedCheck()
|
||||
const pool = NostrClient.getRelayPool()
|
||||
|
||||
if (relaysStore.allRelays.length < 3) {
|
||||
relaysStore.addDefaultRelays()
|
||||
}
|
||||
|
||||
let relaysToConnect = relaysStore.allUrls
|
||||
let eventsBatch: NostrEvent[] = []
|
||||
|
||||
pool.subscribeMany(relaysToConnect, [filter], {
|
||||
onevent(event) {
|
||||
if (eventsBatch.some(ev => ev.id === event.id)) {
|
||||
log.warn(
|
||||
Err.ALREADY_EXISTS_ERROR,
|
||||
'Duplicate event received by this subscription, skipping...',
|
||||
{id: event.id, created_at: event.created_at},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (relaysStore.eventAlreadyReceived(event.id)) {
|
||||
log.warn(
|
||||
Err.ALREADY_EXISTS_ERROR,
|
||||
'Event has been processed in the past, skipping...',
|
||||
{id: event.id, created_at: event.created_at},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
eventsBatch.push(event)
|
||||
relaysStore.addReceivedEventId(event.id)
|
||||
|
||||
const now = new Date().getTime()
|
||||
SyncQueue.addTask(
|
||||
`handleReceivedEventTask-${now}`,
|
||||
async () => await handleReceivedEventTask(event),
|
||||
)
|
||||
},
|
||||
oneose() {
|
||||
log.trace('[receiveEventsFromRelays]', `Eose: Got ${eventsBatch.length} receive events`)
|
||||
|
||||
const connections = pool.listConnectionStatus()
|
||||
for (const conn of Array.from(connections)) {
|
||||
const relayInstance = relaysStore.findByUrl(conn[0])
|
||||
if (conn[1] === true) {
|
||||
log.trace('[receiveEventsFromRelays] Connection is OPEN', {conn: conn[0]})
|
||||
relayInstance?.setStatus(WebSocket.OPEN)
|
||||
} else {
|
||||
log.trace('[receiveEventsFromRelays] Connection is CLOSED', {conn: conn[0]})
|
||||
relayInstance?.setStatus(WebSocket.CLOSED)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
} catch (e: any) {
|
||||
log.error(e.name, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReceivedEventTask = async function (encryptedEvent: NostrEvent): Promise<WalletTaskResult> {
|
||||
try {
|
||||
let directMessageEvent: NostrEvent | UnsignedEvent | undefined = undefined
|
||||
let decryptedMessage: string | undefined = undefined
|
||||
const keys = (await walletStore.getCachedWalletKeys()).NOSTR
|
||||
|
||||
if (encryptedEvent.kind === EncryptedDirectMessage) {
|
||||
directMessageEvent = encryptedEvent
|
||||
decryptedMessage = await NostrClient.decryptNip04(encryptedEvent.pubkey, encryptedEvent.content, keys)
|
||||
}
|
||||
|
||||
if (encryptedEvent.kind === GiftWrap) {
|
||||
directMessageEvent = await NostrClient.decryptDirectMessageNip17(encryptedEvent, keys)
|
||||
decryptedMessage = directMessageEvent.content
|
||||
}
|
||||
|
||||
if (!directMessageEvent || !decryptedMessage) {
|
||||
throw new ValidationError('Unrecognized direct message kind', {kind: encryptedEvent.kind})
|
||||
}
|
||||
|
||||
if (directMessageEvent.created_at < new Date().getTime() / 1000) {
|
||||
contactsStore.setLastPendingReceivedCheck(directMessageEvent.created_at)
|
||||
}
|
||||
|
||||
log.trace('[handleReceivedEventTask]', 'Received event', {directMessageEvent})
|
||||
|
||||
let sentFromPubkey = directMessageEvent.pubkey
|
||||
let sentFrom = NostrClient.getFirstTagValue(directMessageEvent.tags, 'from') as string | undefined
|
||||
let sentFromNpub = NostrClient.getNpubkey(sentFromPubkey)
|
||||
|
||||
if (userSettingsStore.isReceiveOnlyFromContactsOn
|
||||
&& sentFromPubkey !== MINIBIT_SERVER_NOSTR_PUBKEY) {
|
||||
|
||||
const contactInstance = contactsStore.findByPubkey(sentFromPubkey)
|
||||
|
||||
if (!contactInstance) {
|
||||
let message = 'Message received over Nostr has been blocked, the sender is not in your contacts.'
|
||||
log.error(message, {sentFromPubkey, sentFrom, decryptedMessage})
|
||||
|
||||
return {
|
||||
taskFunction: HANDLE_RECEIVED_EVENT_TASK,
|
||||
message,
|
||||
} as WalletTaskResult
|
||||
}
|
||||
}
|
||||
|
||||
let contactFrom: Contact | undefined = undefined
|
||||
let zapSenderProfile: NostrProfile | undefined = undefined
|
||||
let sentFromPicture: string | undefined = undefined
|
||||
|
||||
if (sentFrom
|
||||
&& sentFrom.includes(MINIBITS_NIP05_DOMAIN)
|
||||
&& userSettingsStore.isReceiveOnlyFromContactsOn === false
|
||||
) {
|
||||
try {
|
||||
const nostrProfile = await NostrClient.getProfileFromRelays(sentFromPubkey, NostrClient.getMinibitsRelays())
|
||||
|
||||
if (nostrProfile) {
|
||||
log.info('[handleReceivedEventTask]', 'Event sent from Minibits server user, adding to contacts...', {sentFrom, sentFromPubkey})
|
||||
|
||||
const {nip05, lud16, name, picture} = nostrProfile
|
||||
contactFrom = {
|
||||
pubkey: sentFromPubkey,
|
||||
npub: sentFromNpub,
|
||||
nip05,
|
||||
lud16,
|
||||
name,
|
||||
picture,
|
||||
isExternalDomain: false,
|
||||
} as Contact
|
||||
|
||||
contactsStore.addContact(contactFrom)
|
||||
}
|
||||
} catch (e: any) {
|
||||
log.error('[handleReceivedEventTask]', 'Failed to get sender profile from Minibits server, skipping adding to contacts...', {sentFrom, sentFromPubkey, message: e.message})
|
||||
}
|
||||
}
|
||||
|
||||
if (sentFromPubkey === MINIBIT_SERVER_NOSTR_PUBKEY) {
|
||||
log.info('[handleReceivedEventTask]', 'Event sent from Minibits server, extracting zap sender profile...')
|
||||
|
||||
const maybeZapSenderString = extractZapSenderData(decryptedMessage)
|
||||
|
||||
if (maybeZapSenderString) {
|
||||
try {
|
||||
zapSenderProfile = JSON.parse(maybeZapSenderString)
|
||||
|
||||
if (zapSenderProfile) {
|
||||
sentFromPubkey = zapSenderProfile.pubkey
|
||||
sentFrom = zapSenderProfile.nip05 ?? zapSenderProfile.name
|
||||
sentFromPicture = zapSenderProfile.picture
|
||||
const sentFromLud16 = zapSenderProfile.lud16
|
||||
|
||||
const contactInstance = contactsStore.findByPubkey(sentFromPubkey)
|
||||
if (contactInstance && sentFromLud16) {
|
||||
contactInstance.setLud16(sentFromLud16)
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
log.warn('[handleReceivedEventTask]', 'Could not get sender from zapRequest', {message: e.message, maybeZapSenderString})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const incoming = IncomingParser.findAndExtract(decryptedMessage)
|
||||
|
||||
log.trace('[handleReceivedEventTask]', 'Incoming data', {incoming})
|
||||
|
||||
// Receive token
|
||||
if (incoming.type === IncomingDataType.CASHU) {
|
||||
|
||||
const tokenInfo = getTokenMetadata(incoming.encoded)
|
||||
const amountToReceive = Number(tokenInfo.amount)
|
||||
const memo = tokenInfo.memo || 'Received over Nostr'
|
||||
const {unit, mint: mintUrl} = tokenInfo
|
||||
|
||||
if (!mintsStore.mintExists(mintUrl)) {
|
||||
let message = 'Receiving ecash token over Nostr from unknown mint is not allowed.'
|
||||
|
||||
const transactionData: TransactionData[] = []
|
||||
|
||||
transactionData.push({
|
||||
status: TransactionStatus.ERROR,
|
||||
amountToReceive,
|
||||
unit,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
const newTransaction = {
|
||||
type: TransactionType.RECEIVE,
|
||||
amount: amountToReceive,
|
||||
fee: 0,
|
||||
unit: unit as MintUnit,
|
||||
data: JSON.stringify(transactionData),
|
||||
memo,
|
||||
mint: mintUrl,
|
||||
status: TransactionStatus.DRAFT,
|
||||
}
|
||||
|
||||
const transaction = await transactionsStore.addTransaction(newTransaction)
|
||||
transaction.update({inputToken: incoming.encoded})
|
||||
|
||||
await sendErrorReceiveNotification(
|
||||
amountToReceive,
|
||||
unit as MintUnit,
|
||||
mintUrl,
|
||||
)
|
||||
|
||||
throw new ValidationError(message, {tokenInfo})
|
||||
}
|
||||
|
||||
const mintKeysetIds = mintsStore.findByUrl(mintUrl)?.keysetIds
|
||||
if (!mintKeysetIds || mintKeysetIds.length === 0) {
|
||||
throw new ValidationError('Missing keysetIds in the wallet state', {
|
||||
mintUrl: tokenInfo.mint,
|
||||
}, Err.NOTFOUND_ERROR)
|
||||
}
|
||||
|
||||
const decoded = getDecodedToken(incoming.encoded, mintKeysetIds)
|
||||
|
||||
const {transaction, receivedAmount} = await receiveTask(
|
||||
decoded,
|
||||
amountToReceive,
|
||||
memo,
|
||||
incoming.encoded as string,
|
||||
)
|
||||
|
||||
if (transaction && sentFrom) {
|
||||
if (contactFrom) {
|
||||
transaction.update({profile: JSON.stringify(contactFrom), sentFrom})
|
||||
}
|
||||
|
||||
if (zapSenderProfile) {
|
||||
transaction.update({profile: JSON.stringify(zapSenderProfile), sentFrom})
|
||||
}
|
||||
|
||||
const isZap = zapSenderProfile ? true : false
|
||||
|
||||
sendReceiveNotification(
|
||||
transaction.amount,
|
||||
transaction.fee,
|
||||
transaction.unit,
|
||||
isZap,
|
||||
sentFrom,
|
||||
sentFromPicture,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
mintUrl: decoded.mint,
|
||||
taskFunction: HANDLE_RECEIVED_EVENT_TASK,
|
||||
message: 'Incoming ecash token has been received.',
|
||||
proofsCount: decoded.proofs.length,
|
||||
proofsAmount: receivedAmount,
|
||||
transaction,
|
||||
} as WalletTaskResult
|
||||
}
|
||||
|
||||
// Receive bolt11 invoice
|
||||
else if (incoming.type === IncomingDataType.INVOICE) {
|
||||
const {
|
||||
pubkey,
|
||||
npub,
|
||||
name,
|
||||
picture,
|
||||
} = walletProfileStore
|
||||
|
||||
const contactTo: Contact = {
|
||||
pubkey,
|
||||
npub,
|
||||
name,
|
||||
picture,
|
||||
}
|
||||
|
||||
const decoded = LightningUtils.decodeInvoice(incoming.encoded)
|
||||
const {
|
||||
amount,
|
||||
description,
|
||||
expiry,
|
||||
payment_hash: paymentHash,
|
||||
timestamp,
|
||||
} = LightningUtils.getInvoiceData(decoded)
|
||||
|
||||
const maybeMemo = NostrClient.findMemo(decryptedMessage)
|
||||
|
||||
const defaultMintBalance: MintBalance | undefined = proofsStore.getMintBalanceWithMaxBalance('sat')
|
||||
|
||||
if (!defaultMintBalance) {
|
||||
let message = 'Wallet does not have any mint with SATS unit.'
|
||||
throw new ValidationError(message, {decoded})
|
||||
}
|
||||
|
||||
const transactionData: TransactionData[] = [
|
||||
{
|
||||
status: TransactionStatus.DRAFT,
|
||||
mintBalanceToTransferFrom: defaultMintBalance.mintUrl,
|
||||
amountToTransfer: amount,
|
||||
unit: 'sat',
|
||||
isNwc: false,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]
|
||||
|
||||
const newTransaction = {
|
||||
type: TransactionType.TRANSFER,
|
||||
amount,
|
||||
fee: 0,
|
||||
unit: 'sat' as MintUnit,
|
||||
data: JSON.stringify(transactionData),
|
||||
memo: maybeMemo || description,
|
||||
mint: defaultMintBalance.mintUrl,
|
||||
status: TransactionStatus.DRAFT,
|
||||
}
|
||||
|
||||
const transaction = await transactionsStore.addTransaction(newTransaction)
|
||||
|
||||
transaction.update({
|
||||
paymentId: paymentHash,
|
||||
paymentRequest: incoming.encoded,
|
||||
expiresAt: addSeconds(new Date(timestamp * 1000), expiry),
|
||||
profile: JSON.stringify(contactFrom),
|
||||
sentTo: contactFrom?.nip05 ?? contactFrom?.name,
|
||||
sentFrom: contactTo.nip05 ?? contactTo.name,
|
||||
})
|
||||
|
||||
if (contactFrom) sendIncomingInvoiceNotification(amount, 'sat', contactFrom)
|
||||
|
||||
return {
|
||||
mintUrl: '',
|
||||
taskFunction: HANDLE_RECEIVED_EVENT_TASK,
|
||||
message: 'Incoming Lightning payment request been received.',
|
||||
proofsCount: 0,
|
||||
proofsAmount: amount,
|
||||
} as WalletTaskResult
|
||||
}
|
||||
|
||||
// Receive cashu payment request to pay
|
||||
else if (incoming.type === IncomingDataType.CASHU_PAYMENT_REQUEST) {
|
||||
const {
|
||||
pubkey,
|
||||
npub,
|
||||
name,
|
||||
picture,
|
||||
} = walletProfileStore
|
||||
|
||||
const contactTo: Contact = {
|
||||
pubkey,
|
||||
npub,
|
||||
name,
|
||||
picture,
|
||||
}
|
||||
|
||||
const decoded = decodePaymentRequest(incoming.encoded)
|
||||
|
||||
if (!decoded.amount || !decoded.unit) {
|
||||
let message = 'Cashu payment request is missing amount or unit.'
|
||||
throw new ValidationError(message, {decoded})
|
||||
}
|
||||
|
||||
const {amount: rawAmount, unit, description, id, mints} = decoded
|
||||
const amount = Number(rawAmount)
|
||||
|
||||
const availableBalances: MintBalance[] = []
|
||||
|
||||
if (mints && mints.length > 0) {
|
||||
for (const mint of mints) {
|
||||
if (mintsStore.mintExists(mint)) {
|
||||
const mintBalance = proofsStore.getMintBalance(mint)
|
||||
if (mintBalance) {
|
||||
availableBalances.push(mintBalance)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const mintBalance = proofsStore.getMintBalanceWithMaxBalance(unit as MintUnit)
|
||||
if (!mintBalance) {
|
||||
let message = 'Wallet does not have any mint with this unit.'
|
||||
throw new ValidationError(message, {decoded})
|
||||
}
|
||||
availableBalances.push(mintBalance)
|
||||
}
|
||||
|
||||
if (availableBalances.length === 0) {
|
||||
let message = 'Wallet does not have any of the mints accepted by Cashu payment request.'
|
||||
throw new ValidationError(message, {decoded})
|
||||
}
|
||||
|
||||
const transactionData: TransactionData[] = [{
|
||||
status: TransactionStatus.DRAFT,
|
||||
mintBalanceToSendFrom: availableBalances[0],
|
||||
amountToSend: amount,
|
||||
unit,
|
||||
createdAt: new Date(),
|
||||
}]
|
||||
|
||||
const newTransaction = {
|
||||
type: TransactionType.SEND,
|
||||
amount,
|
||||
fee: 0,
|
||||
unit: unit as MintUnit,
|
||||
data: JSON.stringify(transactionData),
|
||||
memo: description,
|
||||
mint: availableBalances[0].mintUrl,
|
||||
status: TransactionStatus.DRAFT,
|
||||
}
|
||||
|
||||
const transaction = await transactionsStore.addTransaction(newTransaction)
|
||||
transaction.update({
|
||||
paymentId: id,
|
||||
paymentRequest: incoming.encoded,
|
||||
profile: JSON.stringify(contactFrom),
|
||||
sentTo: contactFrom?.nip05 ?? contactFrom?.name,
|
||||
sentFrom: contactTo.nip05 ?? contactTo.name,
|
||||
})
|
||||
|
||||
if (contactFrom) sendIncomingInvoiceNotification(amount, unit as MintUnit, contactFrom)
|
||||
|
||||
return {
|
||||
mintUrl: '',
|
||||
taskFunction: HANDLE_RECEIVED_EVENT_TASK,
|
||||
message: 'Incoming Cashu payment request been received.',
|
||||
proofsCount: 0,
|
||||
proofsAmount: amount,
|
||||
} as WalletTaskResult
|
||||
}
|
||||
|
||||
// Receive ecash from paid cashu payment request
|
||||
else if (incoming.type === IncomingDataType.CASHU_PAYMENT_REQUEST_PAYLOAD) {
|
||||
const decoded: PaymentRequestPayload = JSON.parse(incoming.encoded)
|
||||
log.trace('[handleReceivedEventTask]', 'Decoded payment request payload', {decoded})
|
||||
|
||||
const {transaction, receivedAmount, message} = await receiveByCashuPaymentRequestTask(
|
||||
decoded,
|
||||
)
|
||||
|
||||
if (transaction && sentFrom) {
|
||||
if (contactFrom) {
|
||||
transaction.update({profile: JSON.stringify(contactFrom), sentFrom})
|
||||
}
|
||||
|
||||
transaction.update({paymentId: decoded.id})
|
||||
|
||||
sendReceiveNotification(
|
||||
transaction.amount,
|
||||
transaction.fee,
|
||||
transaction.unit,
|
||||
false,
|
||||
sentFrom,
|
||||
sentFromPicture,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
mintUrl: decoded.mint,
|
||||
taskFunction: HANDLE_RECEIVED_EVENT_TASK,
|
||||
message,
|
||||
proofsCount: decoded.proofs.length,
|
||||
proofsAmount: receivedAmount,
|
||||
transaction,
|
||||
} as WalletTaskResult
|
||||
}
|
||||
else if (incoming.type === IncomingDataType.LNURL) {
|
||||
throw new ValidationError('LNURL support is not yet implemented.', {caller: HANDLE_RECEIVED_EVENT_TASK}, Err.NOTFOUND_ERROR)
|
||||
} else {
|
||||
throw new ValidationError('Received unknown event message', {caller: HANDLE_RECEIVED_EVENT_TASK, incoming}, Err.NOTFOUND_ERROR)
|
||||
}
|
||||
} catch (e: any) {
|
||||
// Catch-all error path needs a mintUrl to satisfy the TransactionTaskResult
|
||||
// shape registered for ev_handleReceivedEventTask_result. The empty string
|
||||
// is fine here — the caller wraps this in a WalletTaskResult-aware UI.
|
||||
return {
|
||||
mintUrl: '',
|
||||
taskFunction: HANDLE_RECEIVED_EVENT_TASK,
|
||||
message: e.message,
|
||||
error: WalletUtils.formatError(e),
|
||||
} as TransactionTaskResult
|
||||
}
|
||||
}
|
||||
|
||||
export const NostrOperationService = {
|
||||
handleClaimQueue,
|
||||
handleClaimTask,
|
||||
handleNwcRequestQueue,
|
||||
receiveEventsFromRelaysQueue,
|
||||
handleReceivedEventTask,
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {log} from '../../logService'
|
||||
import {rootStoreInstance} from '../../../models'
|
||||
import {MintOperationService} from './mintOperations'
|
||||
import {MeltOperationService} from './meltOperations'
|
||||
|
||||
const {transactionsStore} = rootStoreInstance
|
||||
|
||||
/**
|
||||
* Process all pending topups and expired lightning transfers.
|
||||
*
|
||||
* Topup polling is delegated to MintOperationService (mint quote lifecycle).
|
||||
* Transfer expiry is delegated to MeltOperationService (lightning out lifecycle).
|
||||
*/
|
||||
const handlePendingQueue = async (): Promise<void> => {
|
||||
const pendingTopups = transactionsStore.getPendingTopups()
|
||||
const pendingTransfers = transactionsStore.getPendingTransfers()
|
||||
|
||||
log.trace('[handlePendingQueue] start', {
|
||||
pendingTopups: pendingTopups.length,
|
||||
pendingTransfers: pendingTransfers.length,
|
||||
})
|
||||
|
||||
MeltOperationService.expirePendingTransfers(pendingTransfers)
|
||||
|
||||
for (const tx of pendingTopups) {
|
||||
MintOperationService.enqueuePendingTopupCheck(tx)
|
||||
}
|
||||
|
||||
if (pendingTopups.length === 0) {
|
||||
log.trace('[handlePendingQueue] No pending topups')
|
||||
}
|
||||
}
|
||||
|
||||
export const PendingOperationService = {
|
||||
handlePendingQueue,
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import {Token, TokenMetadata, getDecodedToken, getEncodedToken} from '@cashu/cashu-ts'
|
||||
import {Mint} from '../../../models/Mint'
|
||||
import {CashuUtils} from '../../cashu/cashuUtils'
|
||||
import {MintUnit} from '../currency'
|
||||
import {
|
||||
receiveTask,
|
||||
receiveOfflinePrepareTask,
|
||||
receiveOfflineCompleteTask,
|
||||
} from '../receiveTask'
|
||||
import {createQueueAwaitable} from '../queueHelper'
|
||||
import {
|
||||
MAX_SWAP_INPUT_SIZE,
|
||||
TransactionTaskResult,
|
||||
WalletTaskResult,
|
||||
} from '../types'
|
||||
|
||||
/**
|
||||
* Receive big tokens in batches to keep mint load reasonable.
|
||||
*/
|
||||
export const receiveBatchTask = async function (
|
||||
token: Token,
|
||||
amountToReceive: number,
|
||||
memo: string,
|
||||
encodedToken: string,
|
||||
): Promise<WalletTaskResult> {
|
||||
|
||||
const maxBatchSize = MAX_SWAP_INPUT_SIZE
|
||||
const mintUrl = token.mint
|
||||
const proofsToReceive = token.proofs
|
||||
const unit = token.unit
|
||||
|
||||
let receivedProofsCount = 0
|
||||
|
||||
if (proofsToReceive.length > maxBatchSize) {
|
||||
let index = 0
|
||||
for (let i = 0; i < proofsToReceive.length; i += maxBatchSize) {
|
||||
index++
|
||||
const batch = proofsToReceive.slice(i, i + maxBatchSize)
|
||||
const batchAmount = CashuUtils.getProofsAmount(batch)
|
||||
|
||||
const batchToken: Token = {
|
||||
mint: mintUrl,
|
||||
proofs: batch,
|
||||
memo: `${memo} #${index}`,
|
||||
unit,
|
||||
}
|
||||
|
||||
const batchEncodedToken = getEncodedToken(batchToken)
|
||||
|
||||
const result = await receiveTask(
|
||||
batchToken,
|
||||
batchAmount,
|
||||
`${memo} #${index}`,
|
||||
batchEncodedToken,
|
||||
)
|
||||
|
||||
if (result.receivedProofsCount) {
|
||||
receivedProofsCount += result.receivedProofsCount
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const result = await receiveTask(
|
||||
token,
|
||||
amountToReceive,
|
||||
memo,
|
||||
encodedToken,
|
||||
)
|
||||
|
||||
if (result.receivedProofsCount) {
|
||||
receivedProofsCount += result.receivedProofsCount
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
taskFunction: 'receiveBatchTask',
|
||||
mintUrl,
|
||||
message: 'receiveBatchTask completed. ',
|
||||
receivedProofsCount,
|
||||
}
|
||||
}
|
||||
|
||||
const receiveQueueAwaitable = (
|
||||
mint: Mint,
|
||||
tokenMetadata: TokenMetadata,
|
||||
encodedToken: string,
|
||||
): Promise<TransactionTaskResult> => {
|
||||
const {amount: rawAmount, memo, unit} = tokenMetadata
|
||||
const amount = Number(rawAmount)
|
||||
const proofsCount = tokenMetadata.incompleteProofs.length
|
||||
const useBatch = proofsCount > MAX_SWAP_INPUT_SIZE
|
||||
|
||||
return createQueueAwaitable<TransactionTaskResult>({
|
||||
taskFunction: useBatch ? 'receiveBatchTask' : 'receiveTask',
|
||||
timeoutMessage: 'receiveQueue timed out',
|
||||
task: async () => {
|
||||
const token = getDecodedToken(encodedToken, mint.keysetIds ?? [])
|
||||
return (useBatch
|
||||
? await receiveBatchTask(token, amount, memo || '', encodedToken)
|
||||
: await receiveTask(token, amount, memo || '', encodedToken)) as TransactionTaskResult
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const receiveOfflinePrepareQueueAwaitable = (
|
||||
tokenMetadata: TokenMetadata,
|
||||
encodedToken: string,
|
||||
): Promise<TransactionTaskResult> => {
|
||||
const {mint: mintUrl, amount: rawAmount, memo, unit} = tokenMetadata
|
||||
const amount = Number(rawAmount)
|
||||
|
||||
return createQueueAwaitable<TransactionTaskResult>({
|
||||
taskFunction: 'receiveOfflinePrepareTask',
|
||||
prioritized: false,
|
||||
timeoutMessage: 'Offline receive prepare timed out',
|
||||
task: () =>
|
||||
receiveOfflinePrepareTask(
|
||||
mintUrl,
|
||||
unit as MintUnit,
|
||||
amount,
|
||||
memo || '',
|
||||
encodedToken,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
const receiveOfflineCompleteQueueAwaitable = (
|
||||
transactionId: number,
|
||||
): Promise<TransactionTaskResult> =>
|
||||
createQueueAwaitable<TransactionTaskResult>({
|
||||
taskFunction: 'receiveOfflineCompleteTask',
|
||||
prioritized: false,
|
||||
timeoutMessage: 'Offline receive complete timed out',
|
||||
task: () => receiveOfflineCompleteTask(transactionId),
|
||||
})
|
||||
|
||||
export const ReceiveOperationService = {
|
||||
receiveQueueAwaitable,
|
||||
receiveOfflinePrepareQueueAwaitable,
|
||||
receiveOfflineCompleteQueueAwaitable,
|
||||
receiveBatchTask,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {Transaction} from '../../../models/Transaction'
|
||||
import {revertTask} from '../revertTask'
|
||||
import {createQueueAwaitable} from '../queueHelper'
|
||||
import {TransactionTaskResult} from '../types'
|
||||
|
||||
const revertQueueAwaitable = (
|
||||
transaction: Transaction,
|
||||
): Promise<TransactionTaskResult> =>
|
||||
createQueueAwaitable<TransactionTaskResult>({
|
||||
taskFunction: 'revertTask',
|
||||
timeoutMessage: 'Revert task timed out',
|
||||
task: () => revertTask(transaction),
|
||||
})
|
||||
|
||||
export const RevertOperationService = {
|
||||
revertQueueAwaitable,
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {MintBalance} from '../../../models/Mint'
|
||||
import {Proof} from '../../../models/Proof'
|
||||
import {MintUnit} from '../currency'
|
||||
import {sendTask} from '../sendTask'
|
||||
import {cashuPaymentRequestTask} from '../cashuPaymentRequestTask'
|
||||
import {createQueueAwaitable} from '../queueHelper'
|
||||
import {TransactionTaskResult} from '../types'
|
||||
|
||||
const sendQueueAwaitable = (
|
||||
mintBalanceToSendFrom: MintBalance,
|
||||
amountToSend: number,
|
||||
unit: MintUnit,
|
||||
memo: string,
|
||||
selectedProofs: Proof[],
|
||||
p2pk?: {pubkey: string; locktime?: number; refundKeys?: Array<string>},
|
||||
draftTransactionId?: number,
|
||||
): Promise<TransactionTaskResult> =>
|
||||
createQueueAwaitable<TransactionTaskResult>({
|
||||
taskFunction: 'sendTask',
|
||||
timeoutMessage: 'sendQueue timed out',
|
||||
task: () =>
|
||||
sendTask(
|
||||
mintBalanceToSendFrom,
|
||||
amountToSend,
|
||||
unit,
|
||||
memo,
|
||||
selectedProofs,
|
||||
p2pk,
|
||||
draftTransactionId,
|
||||
),
|
||||
})
|
||||
|
||||
const cashuPaymentRequestQueueAwaitable = (
|
||||
mintBalanceToReceiveTo: MintBalance,
|
||||
amountToRequest: number,
|
||||
unit: MintUnit,
|
||||
memo: string,
|
||||
): Promise<TransactionTaskResult> =>
|
||||
createQueueAwaitable<TransactionTaskResult>({
|
||||
taskFunction: 'cashuPaymentRequestTask',
|
||||
timeoutMessage: 'Cashu payment request timed out',
|
||||
task: () =>
|
||||
cashuPaymentRequestTask(
|
||||
mintBalanceToReceiveTo,
|
||||
amountToRequest,
|
||||
unit,
|
||||
memo,
|
||||
),
|
||||
})
|
||||
|
||||
export const SendOperationService = {
|
||||
sendQueueAwaitable,
|
||||
cashuPaymentRequestQueueAwaitable,
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
import {isAlive} from 'mobx-state-tree'
|
||||
import {getDecodedToken} from '@cashu/cashu-ts'
|
||||
import {log} from '../../logService'
|
||||
import {NetworkError} from '../../../utils/AppError'
|
||||
import {rootStoreInstance} from '../../../models'
|
||||
import {Proof, ProofState} from '../../../models/Proof'
|
||||
import {MintStatus} from '../../../models/Mint'
|
||||
import {
|
||||
TransactionStatus,
|
||||
TransactionType,
|
||||
} from '../../../models/Transaction'
|
||||
import {CashuUtils} from '../../cashu/cashuUtils'
|
||||
import {SyncQueue} from '../../syncQueueService'
|
||||
import {stopPolling} from '../../../utils/poller'
|
||||
import {sendTask} from '../sendTask'
|
||||
import {createQueueAwaitable} from '../queueHelper'
|
||||
import {receiveBatchTask} from './receiveOperations'
|
||||
import {MeltOperationService} from './meltOperations'
|
||||
import {
|
||||
MAX_SWAP_INPUT_SIZE,
|
||||
MAX_SYNC_INPUT_SIZE,
|
||||
SWAP_ALL_TASK,
|
||||
SWAP_DENOMINATION_TASK,
|
||||
SYNC_STATE_WITH_ALL_MINTS_TASK,
|
||||
SYNC_STATE_WITH_MINT_TASK,
|
||||
SyncStateTaskResult,
|
||||
TransactionStateUpdate,
|
||||
WalletTaskResult,
|
||||
} from '../types'
|
||||
|
||||
const {
|
||||
mintsStore,
|
||||
proofsStore,
|
||||
transactionsStore,
|
||||
walletStore,
|
||||
} = rootStoreInstance
|
||||
|
||||
/**
|
||||
* Sync wallet proof state with mint reality (SPENT / PENDING / UNSPENT)
|
||||
* Used both for pending proof resolution and recovery of broken wallet state.
|
||||
*/
|
||||
const syncStateWithMintTask = async function (
|
||||
options: {
|
||||
proofsToSync: Proof[]
|
||||
mintUrl: string
|
||||
proofState: ProofState
|
||||
},
|
||||
): Promise<SyncStateTaskResult> {
|
||||
const {proofsToSync, mintUrl, proofState} = options
|
||||
const mint = mintsStore.findByUrl(mintUrl)
|
||||
|
||||
log.trace('[syncStateWithMintTask] start', {mintUrl, proofCount: proofsToSync.length, proofState})
|
||||
|
||||
const transactionStateUpdates: TransactionStateUpdate[] = []
|
||||
const completedTxIds: number[] = []
|
||||
const errorTxIds: number[] = []
|
||||
const pendingTxIds: number[] = []
|
||||
const revertedTxIds: number[] = []
|
||||
|
||||
try {
|
||||
const aliveProofs = proofsToSync.filter(p => isAlive(p))
|
||||
|
||||
if (aliveProofs.length === 0) {
|
||||
const message = `No ${proofState === 'PENDING' ? 'pending ' : ''}proofs to sync with mint`
|
||||
log.trace('[syncStateWithMintTask]', message)
|
||||
return {
|
||||
taskFunction: SYNC_STATE_WITH_MINT_TASK,
|
||||
mintUrl,
|
||||
message,
|
||||
transactionStateUpdates,
|
||||
completedTransactionIds: [],
|
||||
errorTransactionIds: [],
|
||||
revertedTransactionIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
const statesFromMint = await walletStore.getProofsStatesFromMint(
|
||||
mintUrl,
|
||||
mint?.units?.[0] ?? 'sat',
|
||||
aliveProofs,
|
||||
)
|
||||
|
||||
if (mint) mint.setStatus(MintStatus.ONLINE)
|
||||
|
||||
const byMintState = {
|
||||
SPENT: statesFromMint.SPENT.map(p => p.secret),
|
||||
PENDING: statesFromMint.PENDING.map(p => p.secret),
|
||||
UNSPENT: statesFromMint.UNSPENT.map(p => p.secret),
|
||||
}
|
||||
|
||||
const secrets = {
|
||||
spent: new Set(byMintState.SPENT),
|
||||
pending: new Set(byMintState.PENDING),
|
||||
unspent: new Set(byMintState.UNSPENT),
|
||||
}
|
||||
|
||||
log.debug('[syncStateWithMintTask] Mint state', {
|
||||
spent: secrets.spent.size,
|
||||
pending: secrets.pending.size,
|
||||
unspent: secrets.unspent.size,
|
||||
proofState,
|
||||
})
|
||||
|
||||
const groupByTId = (proofs: Proof[]) => {
|
||||
const map = new Map<number, {proofs: Proof[]; amount: number}>()
|
||||
for (const p of proofs) {
|
||||
if (!p.tId) continue
|
||||
const entry = map.get(p.tId) ?? {proofs: [], amount: 0}
|
||||
entry.proofs.push(p)
|
||||
entry.amount += p.amount
|
||||
map.set(p.tId, entry)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// 1. Proofs now SPENT at mint → transaction succeeded
|
||||
if (secrets.spent.size > 0) {
|
||||
const spentProofs = aliveProofs.filter(p => secrets.spent.has(p.secret))
|
||||
const spentByTx = groupByTId(spentProofs)
|
||||
proofsStore.moveToSpent(spentProofs)
|
||||
|
||||
for (const [tId, {amount: spentAmount}] of spentByTx) {
|
||||
const tx = transactionsStore.findById(tId)
|
||||
if (!tx) {
|
||||
errorTxIds.push(tId)
|
||||
transactionStateUpdates.push({
|
||||
tId,
|
||||
updatedStatus: TransactionStatus.ERROR,
|
||||
message: 'Transaction not found in DB',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (spentAmount < tx.amount) {
|
||||
errorTxIds.push(tId)
|
||||
transactionStateUpdates.push({
|
||||
tId,
|
||||
amount: tx.amount,
|
||||
spentByMintAmount: spentAmount,
|
||||
updatedStatus: TransactionStatus.ERROR,
|
||||
message: 'Partial spend detected – proofs reused',
|
||||
})
|
||||
|
||||
if (proofState === 'PENDING') {
|
||||
const stillUnspent = aliveProofs.filter(p => secrets.unspent.has(p.secret))
|
||||
proofsStore.revertToSpendable(stillUnspent)
|
||||
}
|
||||
} else if (tx.status !== TransactionStatus.REVERTED) {
|
||||
completedTxIds.push(tId)
|
||||
const update: TransactionStateUpdate = {
|
||||
tId,
|
||||
amount: tx.amount,
|
||||
spentByMintAmount: spentAmount,
|
||||
updatedStatus: TransactionStatus.COMPLETED,
|
||||
}
|
||||
if (tx.type === TransactionType.TRANSFER && tx.quote) {
|
||||
update.meltQuoteToRecover = tx.quote
|
||||
}
|
||||
transactionStateUpdates.push(update)
|
||||
}
|
||||
}
|
||||
|
||||
for (const update of transactionStateUpdates) {
|
||||
if (update.meltQuoteToRecover) {
|
||||
const {recoveredAmount} = await MeltOperationService.recoverMeltQuoteChange({
|
||||
mintUrl,
|
||||
meltQuote: update.meltQuoteToRecover,
|
||||
})
|
||||
update.recoveredChangeAmount = recoveredAmount
|
||||
}
|
||||
}
|
||||
|
||||
if (completedTxIds.length > 0) {
|
||||
await transactionsStore.updateStatuses(
|
||||
completedTxIds,
|
||||
TransactionStatus.COMPLETED,
|
||||
JSON.stringify({
|
||||
status: TransactionStatus.COMPLETED,
|
||||
spentStateUpdates: transactionStateUpdates.filter(u => completedTxIds.includes(u.tId)),
|
||||
createdAt: new Date(),
|
||||
}),
|
||||
)
|
||||
stopPolling(`syncStateWithMintPoller-${mintUrl}`)
|
||||
}
|
||||
if (errorTxIds.length > 0) {
|
||||
await transactionsStore.updateStatuses(
|
||||
errorTxIds,
|
||||
TransactionStatus.ERROR,
|
||||
JSON.stringify({
|
||||
status: TransactionStatus.ERROR,
|
||||
spentStateUpdates: transactionStateUpdates.filter(u => errorTxIds.includes(u.tId)),
|
||||
createdAt: new Date(),
|
||||
}),
|
||||
)
|
||||
stopPolling(`syncStateWithMintPoller-${mintUrl}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Proofs still PENDING at mint → keep pending in wallet
|
||||
if (secrets.pending.size > 0) {
|
||||
const newPendingProofs = aliveProofs.filter(p => secrets.pending.has(p.secret) && !proofsStore.pendingByMintSecrets.includes(p.secret))
|
||||
|
||||
if (newPendingProofs.length > 0) {
|
||||
proofsStore.registerAsPendingAtMint(newPendingProofs)
|
||||
const pendingByTx = groupByTId(newPendingProofs)
|
||||
|
||||
for (const [tId, {amount: pendingAmount}] of pendingByTx) {
|
||||
if (!pendingTxIds.includes(tId)) pendingTxIds.push(tId)
|
||||
transactionStateUpdates.push({
|
||||
tId,
|
||||
pendingByMintAmount: pendingAmount,
|
||||
updatedStatus: TransactionStatus.PENDING,
|
||||
})
|
||||
}
|
||||
|
||||
if (proofState !== 'PENDING') {
|
||||
proofsStore.moveToPending(newPendingProofs)
|
||||
}
|
||||
|
||||
if (pendingTxIds.length > 0) {
|
||||
await transactionsStore.updateStatuses(
|
||||
pendingTxIds,
|
||||
TransactionStatus.PENDING,
|
||||
JSON.stringify({
|
||||
message: 'Waiting for a payment to settle',
|
||||
pendingStateUpdates: transactionStateUpdates.filter(u => u.updatedStatus === TransactionStatus.PENDING),
|
||||
createdAt: new Date(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Proofs no longer pending at mint → lightning failed → revert
|
||||
const noLongerPendingSecrets = proofsStore.pendingByMintSecrets.filter(
|
||||
s => !secrets.pending.has(s),
|
||||
)
|
||||
|
||||
if (noLongerPendingSecrets.length > 0) {
|
||||
const revertedProofs: Proof[] = []
|
||||
const revertedByTx = new Map<number, number>()
|
||||
|
||||
for (const secret of noLongerPendingSecrets) {
|
||||
const proof = proofsStore.getBySecret(secret)
|
||||
if (!proof || !proof.tId) continue
|
||||
|
||||
revertedProofs.push(proof)
|
||||
revertedByTx.set(proof.tId, (revertedByTx.get(proof.tId) ?? 0) + proof.amount)
|
||||
if (!revertedTxIds.includes(proof.tId)) revertedTxIds.push(proof.tId)
|
||||
}
|
||||
|
||||
if (revertedProofs.length > 0) {
|
||||
proofsStore.revertToSpendable(revertedProofs)
|
||||
proofsStore.unregisterFromPendingAtMint(new Set(noLongerPendingSecrets))
|
||||
|
||||
for (const [tId, amount] of revertedByTx) {
|
||||
transactionStateUpdates.push({
|
||||
tId,
|
||||
movedToSpendableAmount: amount,
|
||||
updatedStatus: TransactionStatus.REVERTED,
|
||||
})
|
||||
}
|
||||
|
||||
if (revertedTxIds.length > 0) {
|
||||
await transactionsStore.updateStatuses(
|
||||
revertedTxIds,
|
||||
TransactionStatus.REVERTED,
|
||||
JSON.stringify({
|
||||
message: 'Lightning payment failed – ecash returned to spendable balance',
|
||||
revertedStateUpdates: transactionStateUpdates.filter(u => u.updatedStatus === TransactionStatus.REVERTED),
|
||||
createdAt: new Date(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
taskFunction: SYNC_STATE_WITH_MINT_TASK,
|
||||
mintUrl,
|
||||
message: `Sync completed for ${aliveProofs.length} ${proofState === 'PENDING' ? 'pending ' : ''}proofs`,
|
||||
transactionStateUpdates,
|
||||
completedTransactionIds: completedTxIds,
|
||||
errorTransactionIds: errorTxIds,
|
||||
pendingTransactionIds: pendingTxIds,
|
||||
revertedTransactionIds: revertedTxIds,
|
||||
}
|
||||
} catch (e: any) {
|
||||
log.error('[syncStateWithMintTask] failed', {mintUrl, error: e.message, stack: e.stack})
|
||||
|
||||
if (mint && e instanceof NetworkError) {
|
||||
mint.setStatus(MintStatus.OFFLINE)
|
||||
}
|
||||
|
||||
return {
|
||||
taskFunction: SYNC_STATE_WITH_MINT_TASK,
|
||||
mintUrl,
|
||||
message: `Sync failed: ${e.message}`,
|
||||
error: e,
|
||||
transactionStateUpdates,
|
||||
completedTransactionIds: completedTxIds,
|
||||
errorTransactionIds: errorTxIds,
|
||||
pendingTransactionIds: pendingTxIds,
|
||||
revertedTransactionIds: revertedTxIds,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const syncStateWithAllMintsTask = async function (options: {
|
||||
proofState: ProofState
|
||||
}): Promise<SyncStateTaskResult> {
|
||||
log.trace('[syncStateWithAllMintsTask] start')
|
||||
if (mintsStore.mintCount === 0) {
|
||||
return {
|
||||
taskFunction: SYNC_STATE_WITH_ALL_MINTS_TASK,
|
||||
transactionStateUpdates: [],
|
||||
completedTransactionIds: [],
|
||||
errorTransactionIds: [],
|
||||
revertedTransactionIds: [],
|
||||
message: 'No mints',
|
||||
}
|
||||
}
|
||||
|
||||
const {proofState} = options
|
||||
const maxBatchSize = MAX_SYNC_INPUT_SIZE
|
||||
const transactionStateUpdates: TransactionStateUpdate[] = []
|
||||
const completedTransactionIds: number[] = []
|
||||
const errorTransactionIds: number[] = []
|
||||
const revertedTransactionIds: number[] = []
|
||||
const errors: string[] = []
|
||||
|
||||
for (const mint of mintsStore.allMints) {
|
||||
const proofsToSync = proofsStore.getByMint(mint.mintUrl, {state: proofState})
|
||||
const totalProofsCount = proofsToSync.length
|
||||
|
||||
if (totalProofsCount === 0) {
|
||||
log.trace('[syncStateWithAllMintsTask] No proofs to sync, skipping...', {mint: mint.mintUrl})
|
||||
continue
|
||||
}
|
||||
|
||||
if (totalProofsCount > maxBatchSize) {
|
||||
for (let i = 0; i < totalProofsCount; i += maxBatchSize) {
|
||||
const batch = proofsToSync.slice(i, i + maxBatchSize)
|
||||
const result = await syncStateWithMintTask({proofsToSync: batch, mintUrl: mint.mintUrl, proofState})
|
||||
|
||||
transactionStateUpdates.push(...result.transactionStateUpdates)
|
||||
completedTransactionIds.push(...result.completedTransactionIds)
|
||||
errorTransactionIds.push(...result.errorTransactionIds)
|
||||
revertedTransactionIds.push(...result.revertedTransactionIds)
|
||||
|
||||
if (result.error) {
|
||||
errors.push(result.error.message)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const result = await syncStateWithMintTask({proofsToSync, mintUrl: mint.mintUrl, proofState})
|
||||
|
||||
transactionStateUpdates.push(...result.transactionStateUpdates)
|
||||
completedTransactionIds.push(...result.completedTransactionIds)
|
||||
errorTransactionIds.push(...result.errorTransactionIds)
|
||||
revertedTransactionIds.push(...result.revertedTransactionIds)
|
||||
|
||||
if (result.error) {
|
||||
errors.push(result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let totalSpent = 0
|
||||
let message = ''
|
||||
|
||||
for (const update of transactionStateUpdates) {
|
||||
if (update.spentByMintAmount) {
|
||||
totalSpent += update.spentByMintAmount
|
||||
}
|
||||
}
|
||||
|
||||
if (proofState === 'PENDING') {
|
||||
message = 'Pending proofs were synced with the mints.'
|
||||
} else {
|
||||
message = `Sync completed with ${errors.length} errors. Spent ecash with ${totalSpent} amount was cleaned.`
|
||||
}
|
||||
|
||||
return {
|
||||
taskFunction: SYNC_STATE_WITH_ALL_MINTS_TASK,
|
||||
transactionStateUpdates,
|
||||
completedTransactionIds,
|
||||
errorTransactionIds,
|
||||
revertedTransactionIds,
|
||||
errors,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
const syncStateWithAllMintsQueueAwaitable = (
|
||||
options: {proofState: ProofState},
|
||||
): Promise<SyncStateTaskResult> =>
|
||||
createQueueAwaitable<SyncStateTaskResult>({
|
||||
taskFunction: 'syncSpendableStateTask',
|
||||
timeoutMessage: 'Sync all mints state timed out',
|
||||
task: () => syncStateWithAllMintsTask({proofState: options.proofState}),
|
||||
})
|
||||
|
||||
const syncStateWithMintQueueAwaitable = (
|
||||
options: {
|
||||
proofsToSync: Proof[]
|
||||
mintUrl: string
|
||||
proofState: ProofState
|
||||
},
|
||||
): Promise<SyncStateTaskResult> => {
|
||||
log.trace('[syncStateWithMintQueueAwaitable] start', {
|
||||
mintUrl: options.mintUrl,
|
||||
proofState: options.proofState,
|
||||
proofsToSyncCount: options.proofsToSync.length,
|
||||
})
|
||||
|
||||
return createQueueAwaitable<SyncStateTaskResult>({
|
||||
taskFunction: SYNC_STATE_WITH_MINT_TASK,
|
||||
prioritized: false,
|
||||
timeoutMessage: 'Sync mint state timed out',
|
||||
task: () => syncStateWithMintTask(options),
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* swapAllTask sends all proofs to pending and swaps them with the mint for standard amount preference
|
||||
* This decreases the total number of proofs held by the wallet. Used to optimize exported backup size.
|
||||
* Heavy, needs to run using the foreground service. May freeze the wallet.
|
||||
*/
|
||||
const swapAllTask = async function (): Promise<WalletTaskResult> {
|
||||
log.trace('[swapAllTask] start')
|
||||
|
||||
if (mintsStore.mintCount === 0) {
|
||||
return {
|
||||
taskFunction: SWAP_ALL_TASK,
|
||||
message: 'No mints to swap with.',
|
||||
}
|
||||
}
|
||||
|
||||
let initialProofsCount = 0
|
||||
let finalProofsCount = 0
|
||||
const errors: string[] = []
|
||||
const maxBatchSize = MAX_SWAP_INPUT_SIZE
|
||||
|
||||
for (const mint of mintsStore.allMints) {
|
||||
for (const unit of mint.units) {
|
||||
const proofsToOptimize = proofsStore.getByMint(mint.mintUrl, {state: 'UNSPENT', unit, ascending: true})
|
||||
|
||||
if (proofsToOptimize.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
initialProofsCount += proofsToOptimize.length
|
||||
const mintBalance = mint.balances
|
||||
|
||||
if (proofsToOptimize.length > maxBatchSize) {
|
||||
let index = 0
|
||||
for (let i = 0; i < proofsToOptimize.length; i += maxBatchSize) {
|
||||
index++
|
||||
const batch = proofsToOptimize.slice(i, i + maxBatchSize)
|
||||
const batchAmount = CashuUtils.getProofsAmount(batch)
|
||||
|
||||
const sendResult = await sendTask(
|
||||
mintBalance!,
|
||||
batchAmount,
|
||||
unit,
|
||||
`Optimize ecash #${index}`,
|
||||
batch,
|
||||
)
|
||||
|
||||
const encodedTokenToReceive: string = sendResult.encodedTokenToSend
|
||||
|
||||
const tokenToReceive = getDecodedToken(encodedTokenToReceive, mint.keysetIds)
|
||||
const tokenAmount = CashuUtils.getProofsAmount(tokenToReceive.proofs)
|
||||
|
||||
const receiveResult = await receiveBatchTask(
|
||||
tokenToReceive,
|
||||
tokenAmount,
|
||||
tokenToReceive.memo as string,
|
||||
encodedTokenToReceive,
|
||||
)
|
||||
|
||||
await syncStateWithMintTask({proofsToSync: batch, mintUrl: mint.mintUrl, proofState: 'PENDING'})
|
||||
|
||||
if (receiveResult.receivedProofsCount && receiveResult.receivedProofsCount > 0) {
|
||||
finalProofsCount += receiveResult.receivedProofsCount
|
||||
}
|
||||
|
||||
if (receiveResult.error) {
|
||||
errors.push(receiveResult.error.message)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const proofsAmount = CashuUtils.getProofsAmount(proofsToOptimize)
|
||||
|
||||
const sendResult = await sendTask(
|
||||
mintBalance!,
|
||||
proofsAmount,
|
||||
unit,
|
||||
`Optimize ecash`,
|
||||
proofsToOptimize,
|
||||
)
|
||||
|
||||
const encodedTokenToReceive: string = sendResult.encodedTokenToSend
|
||||
const tokenToReceive = getDecodedToken(encodedTokenToReceive, mint.keysetIds)
|
||||
const tokenAmount = CashuUtils.getProofsAmount(tokenToReceive.proofs)
|
||||
|
||||
const receiveResult = await receiveBatchTask(
|
||||
tokenToReceive,
|
||||
tokenAmount,
|
||||
tokenToReceive.memo as string,
|
||||
encodedTokenToReceive,
|
||||
)
|
||||
|
||||
if (receiveResult.receivedProofsCount && receiveResult.receivedProofsCount > 0) {
|
||||
finalProofsCount += receiveResult.receivedProofsCount
|
||||
}
|
||||
|
||||
if (receiveResult.error) {
|
||||
errors.push(receiveResult.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
taskFunction: SWAP_ALL_TASK,
|
||||
message: `Proofs optimization completed with ${errors.length} errors. Proofs number went from ${initialProofsCount} to ${finalProofsCount}`,
|
||||
initialProofsCount,
|
||||
finalProofsCount,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* swapByDenominationTask swaps only proofs with the given denomination (amount) across all mints.
|
||||
*/
|
||||
const swapByDenominationTask = async function (denomination: number): Promise<WalletTaskResult> {
|
||||
log.trace('[swapByDenominationTask] start', {denomination})
|
||||
|
||||
if (mintsStore.mintCount === 0) {
|
||||
return {
|
||||
taskFunction: SWAP_DENOMINATION_TASK,
|
||||
message: 'No mints to swap with.',
|
||||
}
|
||||
}
|
||||
|
||||
let initialProofsCount = 0
|
||||
let finalProofsCount = 0
|
||||
const errors: string[] = []
|
||||
const maxBatchSize = MAX_SWAP_INPUT_SIZE
|
||||
|
||||
for (const mint of mintsStore.allMints) {
|
||||
for (const unit of mint.units) {
|
||||
const allProofs = proofsStore.getByMint(mint.mintUrl, {state: 'UNSPENT', unit, ascending: true})
|
||||
const proofsToOptimize = allProofs.filter(p => p.amount === denomination)
|
||||
|
||||
if (proofsToOptimize.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
initialProofsCount += proofsToOptimize.length
|
||||
const mintBalance = mint.balances
|
||||
|
||||
if (proofsToOptimize.length > maxBatchSize) {
|
||||
let index = 0
|
||||
for (let i = 0; i < proofsToOptimize.length; i += maxBatchSize) {
|
||||
index++
|
||||
const batch = proofsToOptimize.slice(i, i + maxBatchSize)
|
||||
const batchAmount = CashuUtils.getProofsAmount(batch)
|
||||
|
||||
const sendResult = await sendTask(
|
||||
mintBalance!,
|
||||
batchAmount,
|
||||
unit,
|
||||
`Optimize denomination ${denomination} #${index}`,
|
||||
batch,
|
||||
)
|
||||
|
||||
const encodedTokenToReceive: string = sendResult.encodedTokenToSend
|
||||
const tokenToReceive = getDecodedToken(encodedTokenToReceive, mint.keysetIds)
|
||||
const tokenAmount = CashuUtils.getProofsAmount(tokenToReceive.proofs)
|
||||
|
||||
const receiveResult = await receiveBatchTask(
|
||||
tokenToReceive,
|
||||
tokenAmount,
|
||||
tokenToReceive.memo as string,
|
||||
encodedTokenToReceive,
|
||||
)
|
||||
|
||||
await syncStateWithMintTask({proofsToSync: batch, mintUrl: mint.mintUrl, proofState: 'PENDING'})
|
||||
|
||||
if (receiveResult.receivedProofsCount && receiveResult.receivedProofsCount > 0) {
|
||||
finalProofsCount += receiveResult.receivedProofsCount
|
||||
}
|
||||
if (receiveResult.error) {
|
||||
errors.push(receiveResult.error.message)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const proofsAmount = CashuUtils.getProofsAmount(proofsToOptimize)
|
||||
|
||||
const sendResult = await sendTask(
|
||||
mintBalance!,
|
||||
proofsAmount,
|
||||
unit,
|
||||
`Optimize denomination ${denomination}`,
|
||||
proofsToOptimize,
|
||||
)
|
||||
|
||||
const encodedTokenToReceive: string = sendResult.encodedTokenToSend
|
||||
const tokenToReceive = getDecodedToken(encodedTokenToReceive, mint.keysetIds)
|
||||
const tokenAmount = CashuUtils.getProofsAmount(tokenToReceive.proofs)
|
||||
|
||||
const receiveResult = await receiveBatchTask(
|
||||
tokenToReceive,
|
||||
tokenAmount,
|
||||
tokenToReceive.memo as string,
|
||||
encodedTokenToReceive,
|
||||
)
|
||||
|
||||
await syncStateWithMintTask({proofsToSync: proofsToOptimize, mintUrl: mint.mintUrl, proofState: 'PENDING'})
|
||||
|
||||
if (receiveResult.receivedProofsCount && receiveResult.receivedProofsCount > 0) {
|
||||
finalProofsCount += receiveResult.receivedProofsCount
|
||||
}
|
||||
if (receiveResult.error) {
|
||||
errors.push(receiveResult.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
taskFunction: SWAP_DENOMINATION_TASK,
|
||||
message: `Denomination ${denomination} optimization completed with ${errors.length} errors. Proofs went from ${initialProofsCount} to ${finalProofsCount}`,
|
||||
initialProofsCount,
|
||||
finalProofsCount,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
const swapAllQueue = async function (): Promise<void> {
|
||||
const now = new Date().getTime()
|
||||
return SyncQueue.addPrioritizedTask(
|
||||
`swapAllTask-${now}`,
|
||||
async () => await swapAllTask(),
|
||||
)
|
||||
}
|
||||
|
||||
const swapByDenominationQueue = async function (denomination: number): Promise<void> {
|
||||
const now = new Date().getTime()
|
||||
return SyncQueue.addPrioritizedTask(
|
||||
`swapDenominationTask-${denomination}-${now}`,
|
||||
async () => await swapByDenominationTask(denomination),
|
||||
)
|
||||
}
|
||||
|
||||
export const SyncOperationService = {
|
||||
syncStateWithAllMintsQueueAwaitable,
|
||||
syncStateWithMintQueueAwaitable,
|
||||
syncStateWithMintTask,
|
||||
swapAllQueue,
|
||||
swapByDenominationQueue,
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {LockedProofSnapshot} from '../sqlite'
|
||||
import {MintUnit} from './currency'
|
||||
|
||||
/**
|
||||
* Public type representing an in-flight proof reservation.
|
||||
*
|
||||
* A reservation is opened via `proofsStore.reserve(...)` and must be either
|
||||
* committed (`proofsStore.commitReservation(reservation, changes)`) or rolled
|
||||
* back (`proofsStore.rollbackReservation(reservation)`) before the operation
|
||||
* completes.
|
||||
*
|
||||
* If the process dies between open and commit/rollback, the reservation row in
|
||||
* SQLite is detected at the next startup and rolled back automatically by
|
||||
* `proofsStore.recoverOrphanReservations()`.
|
||||
*
|
||||
* The shape is intentionally a plain data record (no methods) so it can be
|
||||
* captured by closures, passed across MST action boundaries, and serialised
|
||||
* for diagnostic logging without surprises.
|
||||
*/
|
||||
export type ProofReservation = {
|
||||
/** Opaque id (hex). Also the primary key of the reservations table. */
|
||||
id: string
|
||||
|
||||
/** Wallet transaction this reservation belongs to. */
|
||||
transactionId: number
|
||||
|
||||
/** Mint the reserved proofs belong to. */
|
||||
mintUrl: string
|
||||
|
||||
/** Currency unit (sat, msat, usd, …) — used when committing new proofs. */
|
||||
unit: MintUnit
|
||||
|
||||
/** Free-form tag of the operation that opened the reservation (e.g. 'send'). */
|
||||
operationType: string
|
||||
|
||||
/**
|
||||
* Snapshot of the locked proofs at the moment of reservation: enough info
|
||||
* to restore their original state on rollback.
|
||||
*/
|
||||
lockedProofs: LockedProofSnapshot[]
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import EventEmitter from '../../utils/eventEmitter'
|
||||
import AppError, {Err} from '../../utils/AppError'
|
||||
import {SyncQueue} from '../syncQueueService'
|
||||
import {TASK_QUEUE_TIMEOUT, WalletTaskResult} from './types'
|
||||
|
||||
/**
|
||||
* Shared helper that wraps a task function with the SyncQueue + EventEmitter +
|
||||
* timeout pattern that every queue-awaitable wrapper repeats verbatim.
|
||||
*
|
||||
* The wrapper resolves either:
|
||||
* - when the SyncQueue's promise settles, or
|
||||
* - when an event with name `ev_${taskFunction}_result` is emitted
|
||||
* (whichever happens first — they carry the same result).
|
||||
*
|
||||
* Both paths share a single-resolution guard (resolveOnce / rejectOnce).
|
||||
*/
|
||||
export interface QueueAwaitableOptions<T extends WalletTaskResult> {
|
||||
/** Stable task name used in taskId and event name (matches result.taskFunction). */
|
||||
taskFunction: string
|
||||
/** The task body to run inside the queue. */
|
||||
task: () => Promise<T>
|
||||
/** Use addPrioritizedTask when true, otherwise addTask. Defaults to true. */
|
||||
prioritized?: boolean
|
||||
/** Timeout in ms before rejecting with TIMEOUT_ERROR. Defaults to TASK_QUEUE_TIMEOUT. */
|
||||
timeoutMs?: number
|
||||
/** Message used for the timeout error. */
|
||||
timeoutMessage?: string
|
||||
}
|
||||
|
||||
export const createQueueAwaitable = <T extends WalletTaskResult>(
|
||||
options: QueueAwaitableOptions<T>,
|
||||
): Promise<T> => {
|
||||
const {
|
||||
taskFunction,
|
||||
task,
|
||||
prioritized = true,
|
||||
timeoutMs = TASK_QUEUE_TIMEOUT,
|
||||
timeoutMessage = `${taskFunction} timed out`,
|
||||
} = options
|
||||
|
||||
const taskId = `${taskFunction}-${Date.now()}`
|
||||
const eventName = `ev_${taskFunction}_result`
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let resolved = false
|
||||
|
||||
const resolveOnce = (result: T) => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
EventEmitter.off(eventName, handler)
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
const rejectOnce = (error: any) => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
EventEmitter.off(eventName, handler)
|
||||
reject(error)
|
||||
}
|
||||
|
||||
const handler = (result: T) => resolveOnce(result)
|
||||
EventEmitter.on(eventName, handler)
|
||||
|
||||
const queued = prioritized
|
||||
? SyncQueue.addPrioritizedTask<T>(taskId, async () => {
|
||||
try {
|
||||
return await task()
|
||||
} catch (error) {
|
||||
rejectOnce(error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
: SyncQueue.addTask<T>(taskId, async () => {
|
||||
try {
|
||||
return await task()
|
||||
} catch (error) {
|
||||
rejectOnce(error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
queued.then(resolveOnce).catch(rejectOnce)
|
||||
|
||||
setTimeout(() => {
|
||||
if (!resolved) {
|
||||
rejectOnce(new AppError(Err.TIMEOUT_ERROR, timeoutMessage))
|
||||
}
|
||||
}, timeoutMs)
|
||||
})
|
||||
}
|
||||
@@ -629,8 +629,7 @@ export const receiveSync = async function (
|
||||
mintUrl: mintToReceive,
|
||||
unit,
|
||||
tId: transactionId,
|
||||
isPending: false,
|
||||
isSpent: false
|
||||
state: 'UNSPENT',
|
||||
})
|
||||
|
||||
// store swapped proofs as encoded token in tx data
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import {rootStoreInstance} from '../../models'
|
||||
import AppError, {Err} from '../../utils/AppError'
|
||||
import { TransactionTaskResult } from '../walletService'
|
||||
import { ProofReservation } from './proofReservation'
|
||||
import { WalletUtils } from './utils'
|
||||
import { MintUnit } from './currency'
|
||||
import { Token, getEncodedToken, normalizeProofAmounts } from '@cashu/cashu-ts'
|
||||
@@ -32,17 +33,21 @@ try {
|
||||
|
||||
const unit = transaction.unit as MintUnit
|
||||
|
||||
try {
|
||||
const allProofs = proofsStore.getByTransactionId(transaction.id)
|
||||
const pendingProofs = allProofs.filter(p => p.state === 'PENDING')
|
||||
const mintInstance = mintsStore.findByUrl(transaction.mint)
|
||||
|
||||
const allProofs = proofsStore.getByTransactionId(transaction.id)
|
||||
const pendingProofs = allProofs.filter(p => p.isPending === true)
|
||||
// Reservation is declared at the outer scope so the catch block can resolve it
|
||||
// (commit or rollback) if a failure happens after it's opened. Errors thrown
|
||||
// during the pre-validation below leave it undefined.
|
||||
let reservation: ProofReservation | undefined = undefined
|
||||
|
||||
try {
|
||||
|
||||
if(pendingProofs.length === 0) {
|
||||
throw new AppError(Err.VALIDATION_ERROR, 'Missing proofs to swap')
|
||||
}
|
||||
|
||||
const mintInstance = mintsStore.findByUrl(transaction.mint)
|
||||
|
||||
if(!mintInstance) {
|
||||
throw new AppError(Err.VALIDATION_ERROR, 'Missing mint')
|
||||
}
|
||||
@@ -55,6 +60,20 @@ try {
|
||||
unit
|
||||
}
|
||||
|
||||
// Open the reservation BEFORE the mint call. The pending proofs are
|
||||
// already PENDING; `rollbackTo: 'PENDING'` ensures they stay PENDING if
|
||||
// the recovery swap fails (user can retry). Without the override, rollback
|
||||
// would restore them to their pre-reservation state which is also PENDING
|
||||
// — but being explicit documents the intent and protects against future
|
||||
// callers passing UNSPENT proofs into revertTask by mistake.
|
||||
reservation = proofsStore.reserve(pendingProofs, {
|
||||
transactionId: transaction.id,
|
||||
mintUrl: mintInstance.mintUrl,
|
||||
unit,
|
||||
operationType: 'revert',
|
||||
rollbackTo: 'PENDING',
|
||||
})
|
||||
|
||||
const receivedResult = await walletStore.receive(
|
||||
transaction.mint,
|
||||
unit as MintUnit,
|
||||
@@ -62,54 +81,59 @@ try {
|
||||
transaction.id
|
||||
)
|
||||
|
||||
const receivedProofs = receivedResult.proofs
|
||||
const mintFeePaid = receivedResult.swapFeePaid
|
||||
const receivedProofs = receivedResult.proofs
|
||||
const mintFeePaid = receivedResult.swapFeePaid
|
||||
|
||||
// store freshed proofs as encoded token in tx data
|
||||
const outputToken = getEncodedToken({
|
||||
// store freshed proofs as encoded token in tx data
|
||||
const outputToken = getEncodedToken({
|
||||
mint: mintInstance.mintUrl,
|
||||
proofs: normalizeProofAmounts(receivedProofs),
|
||||
unit
|
||||
})
|
||||
})
|
||||
|
||||
// Remove original pending proofs
|
||||
proofsStore.moveToSpent(pendingProofs)
|
||||
// ATOMIC commit: original pending proofs → SPENT, new fresh proofs →
|
||||
// UNSPENT, reservation row deleted — single SQLite transaction. Replaces
|
||||
// the previous two-step moveToSpent + addOrUpdate sequence.
|
||||
const { added } = proofsStore.commitReservation(reservation, {
|
||||
toSpent: pendingProofs,
|
||||
newProofs: [{ proofs: receivedProofs, state: 'UNSPENT', tId: transaction.id }],
|
||||
})
|
||||
|
||||
// add fresh proofs to spendable wallet
|
||||
const { updatedAmount: receivedAmount } = proofsStore.addOrUpdate(receivedProofs, {
|
||||
const receivedAmount = added.reduce((sum, p) => sum + p.amount, 0)
|
||||
|
||||
// Update transaction status
|
||||
transactionData.push({
|
||||
status: TransactionStatus.REVERTED,
|
||||
mintFeePaid,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
|
||||
|
||||
transaction.update({
|
||||
status: TransactionStatus.REVERTED,
|
||||
data: JSON.stringify(transactionData),
|
||||
outputToken,
|
||||
balanceAfter,
|
||||
...(mintFeePaid > 0 && {fee: mintFeePaid})
|
||||
})
|
||||
|
||||
return {
|
||||
taskFunction: REVERT_TASK,
|
||||
mintUrl: mintInstance.mintUrl,
|
||||
unit,
|
||||
tId: transaction.id,
|
||||
isPending: false,
|
||||
isSpent: false
|
||||
})
|
||||
|
||||
// Update transaction status
|
||||
transactionData.push({
|
||||
status: TransactionStatus.REVERTED,
|
||||
mintFeePaid,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
|
||||
|
||||
transaction.update({
|
||||
status: TransactionStatus.REVERTED,
|
||||
data: JSON.stringify(transactionData),
|
||||
outputToken,
|
||||
balanceAfter,
|
||||
...(mintFeePaid > 0 && {fee: mintFeePaid})
|
||||
})
|
||||
|
||||
return {
|
||||
taskFunction: REVERT_TASK,
|
||||
mintUrl: mintInstance.mintUrl,
|
||||
transaction,
|
||||
message: `Transaction has been reverted and funds were returned to spendable balance.`,
|
||||
receivedAmount,
|
||||
} as TransactionTaskResult
|
||||
transaction,
|
||||
message: `Transaction has been reverted and funds were returned to spendable balance.`,
|
||||
receivedAmount,
|
||||
} as TransactionTaskResult
|
||||
|
||||
} catch (e: any) {
|
||||
// Resolve the reservation if we opened one (otherwise it becomes an
|
||||
// orphan). Rollback restores the proofs to PENDING (their pre-reservation
|
||||
// state) so the user can retry the revert.
|
||||
if (reservation) {
|
||||
proofsStore.rollbackReservation(reservation)
|
||||
}
|
||||
|
||||
if (transaction) {
|
||||
transactionData.push({
|
||||
status: TransactionStatus.ERROR,
|
||||
|
||||
+169
-176
@@ -13,7 +13,7 @@ import {
|
||||
Wallet as CashuWallet,
|
||||
CheckStateEnum,
|
||||
MintKeyset,
|
||||
ProofState,
|
||||
ProofState as CashuProofState,
|
||||
getEncodedToken,
|
||||
normalizeProofAmounts,
|
||||
} from '@cashu/cashu-ts'
|
||||
@@ -38,7 +38,7 @@ const _monitorSentProofs = async (params: {
|
||||
proofsToSend: CashuProof[]
|
||||
}) => {
|
||||
const { mintUrl, proofsToSend } = params
|
||||
const proofsToSync = proofsStore.getByMint(mintUrl, {isPending: true})
|
||||
const proofsToSync = proofsStore.getByMint(mintUrl, {state: 'PENDING'})
|
||||
const wsMint = new CashuMint(mintUrl)
|
||||
const wsWallet = new CashuWallet(wsMint)
|
||||
|
||||
@@ -46,10 +46,10 @@ const _monitorSentProofs = async (params: {
|
||||
log.trace('[send] Subscribing to proofStateUpdates for sent proof', {secret: proofsToSend[0]})
|
||||
const unsub = await wsWallet.on.proofStateUpdates(
|
||||
normalizeProofAmounts([proofsToSend[0]]),
|
||||
async (proofState: ProofState) => {
|
||||
async (proofState: CashuProofState) => {
|
||||
log.trace(`Websocket: proof state updated: ${proofState.state} with secret: ${proofsToSend[0].secret}`)
|
||||
if (proofState.state == CheckStateEnum.SPENT) {
|
||||
WalletTask.syncStateWithMintQueueAwaitable({proofsToSync, mintUrl, isPending: true})
|
||||
WalletTask.syncStateWithMintQueueAwaitable({proofsToSync, mintUrl, proofState: 'PENDING'})
|
||||
unsub()
|
||||
}
|
||||
},
|
||||
@@ -225,156 +225,172 @@ export const sendFromMintSync = async function (
|
||||
) {
|
||||
const mintUrl = mintBalance.mintUrl
|
||||
const mintInstance = mintsStore.findByUrl(mintUrl)
|
||||
let proofsToSendFrom: Proof[] = []
|
||||
let proofsToSend: CashuProof[] | Proof[] = []
|
||||
|
||||
try {
|
||||
if (!mintInstance) {
|
||||
if (!mintInstance) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
'Could not find mint', {mintUrl, transactionId}
|
||||
)
|
||||
}
|
||||
|
||||
const proofsFromMint = proofsStore.getByMint(mintUrl, {state: 'UNSPENT', unit})
|
||||
|
||||
log.debug('[sendFromMintSync]', {
|
||||
proofsFromMintCount: proofsFromMint.length,
|
||||
mintBalance: mintBalance.balances[unit],
|
||||
amountToSend,
|
||||
transactionId,
|
||||
unit
|
||||
})
|
||||
|
||||
const totalAmountFromMint = CashuUtils.getProofsAmount(proofsFromMint)
|
||||
|
||||
if (totalAmountFromMint < amountToSend) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
'There is not enough funds to send this amount.',
|
||||
{totalAmountFromMint, amountToSend, transactionId, caller: 'sendFromMintSync'},
|
||||
)
|
||||
}
|
||||
|
||||
// ─── OFFLINE SEND ────────────────────────────────────────────────
|
||||
// User selected specific ecash; no mint interaction. Lock the selection
|
||||
// as PENDING under a reservation and commit immediately. Crash before
|
||||
// commit → orphan recovery rolls the lock back.
|
||||
const selectedProofsAmount = CashuUtils.getProofsAmount(selectedProofs)
|
||||
if (selectedProofsAmount > 0) {
|
||||
if (amountToSend !== selectedProofsAmount) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
'Could not find mint', {mintUrl, transactionId}
|
||||
'Requested amount to send does not equal sum of ecash denominations provided.',
|
||||
{transactionId}
|
||||
)
|
||||
}
|
||||
|
||||
const proofsFromMint = proofsStore.getByMint(mintUrl, {isPending: false, unit})
|
||||
if (selectedProofs.length > MAX_SWAP_INPUT_SIZE) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
`Number of proofs is above max of ${MAX_SWAP_INPUT_SIZE}. Visit Settings > Backup to optimize, then try again.`,
|
||||
{transactionId}
|
||||
)
|
||||
}
|
||||
|
||||
log.debug('[sendFromMintSync]', {
|
||||
proofsFromMintCount: proofsFromMint.length,
|
||||
mintBalance: mintBalance.balances[unit],
|
||||
amountToSend,
|
||||
const reservation = proofsStore.reserve(selectedProofs, {
|
||||
transactionId,
|
||||
unit
|
||||
mintUrl,
|
||||
unit,
|
||||
operationType: 'send-offline',
|
||||
rollbackTo: 'UNSPENT',
|
||||
})
|
||||
|
||||
const totalAmountFromMint = CashuUtils.getProofsAmount(proofsFromMint)
|
||||
try {
|
||||
// Commit with empty changes: deletes the reservation row, proofs
|
||||
// stay PENDING (sent ecash remains locked until redeemed/reverted).
|
||||
proofsStore.commitReservation(reservation)
|
||||
|
||||
if (totalAmountFromMint < amountToSend) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
'There is not enough funds to send this amount.',
|
||||
{totalAmountFromMint, amountToSend, transactionId, caller: 'sendFromMintSync'},
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* OFFLINE SEND
|
||||
* if we have selected ecash to send in offline mode, we do not interact with the mint
|
||||
*/
|
||||
|
||||
const selectedProofsAmount = CashuUtils.getProofsAmount(selectedProofs)
|
||||
|
||||
if(selectedProofsAmount > 0) {
|
||||
if(amountToSend !== selectedProofsAmount) { // failsafe for some unknown ecash selection UX error
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
'Requested amount to send does not equal sum of ecash denominations provided.',
|
||||
{transactionId}
|
||||
)
|
||||
}
|
||||
|
||||
if(selectedProofs.length > MAX_SWAP_INPUT_SIZE) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
`Number of proofs is above max of ${MAX_SWAP_INPUT_SIZE}. Visit Settings > Backup to optimize, then try again.`,
|
||||
{transactionId}
|
||||
)
|
||||
}
|
||||
|
||||
// move sent proofs to pending and add tx references
|
||||
proofsStore.addOrUpdate(selectedProofs, {
|
||||
mintUrl,
|
||||
unit,
|
||||
tId: transactionId,
|
||||
isPending: true,
|
||||
isSpent: false
|
||||
})
|
||||
|
||||
// We return cleaned proofs to be encoded as a sendable token
|
||||
return {
|
||||
proofs: CashuUtils.exportProofs(selectedProofs),
|
||||
swapFeeReserve: 0,
|
||||
swapFeePaid: 0
|
||||
swapFeePaid: 0,
|
||||
}
|
||||
} catch (e: any) {
|
||||
proofsStore.rollbackReservation(reservation)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ONLINE SEND (auto-selected proofs, optional mint swap) ──────
|
||||
let proofsToSendFrom: Proof[] = []
|
||||
let proofsToSend: CashuProof[] | Proof[] = []
|
||||
|
||||
// Prioritize send from inactive keysets
|
||||
const inactiveKeysetIds = getInactiveKeysetIds(mintInstance)
|
||||
const activeKeysetIds = getActiveKeysetIds(mintInstance)
|
||||
|
||||
log.trace('[sendFromMintSync]', {inactiveKeysetIds, activeKeysetIds})
|
||||
|
||||
if (inactiveKeysetIds.length > 0) {
|
||||
proofsToSendFrom = prioritizeFromInactiveKeysets(
|
||||
mintInstance,
|
||||
amountToSend,
|
||||
unit,
|
||||
proofsFromMint
|
||||
)
|
||||
} else {
|
||||
proofsToSendFrom = CashuUtils.getProofsToSend(
|
||||
amountToSend,
|
||||
proofsFromMint
|
||||
)
|
||||
}
|
||||
|
||||
let proofsToSendFromAmount = CashuUtils.getProofsAmount(proofsToSendFrom)
|
||||
let swapFeeReserve: number = 0
|
||||
let returnedAmount = 0
|
||||
let swapFeePaid: number = 0
|
||||
let returnedProofs: CashuProof[] = []
|
||||
let isSwapNeeded: boolean = false
|
||||
|
||||
if ((p2pk && p2pk.pubkey) || proofsToSendFromAmount - amountToSend > 0) {
|
||||
isSwapNeeded = true
|
||||
}
|
||||
|
||||
log.trace('[sendFromMintSync]', {proofsToSendFromAmount, amountToSend})
|
||||
|
||||
// Re-select inputs if a swap fee will be needed (must happen BEFORE
|
||||
// opening the reservation so we lock the right set).
|
||||
if (isSwapNeeded) {
|
||||
const walletInstance = await walletStore.getWallet(mintUrl, unit, {withSeed: true}) as CashuWallet
|
||||
swapFeeReserve = walletInstance.getFeesForProofs(proofsToSendFrom).toNumber()
|
||||
const amountWithFees = amountToSend + swapFeeReserve
|
||||
|
||||
if (totalAmountFromMint < amountWithFees) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
'There is not enough funds to send this amount.',
|
||||
{totalAmountFromMint, amountWithFees, transactionId, caller: 'sendFromMintSync'},
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* SWAP or DIRECT SEND
|
||||
* if we did not selected ecash but amount and we might need a swap of ecash by the mint to match exact amount
|
||||
*/
|
||||
|
||||
// Prioritize send from inactive keysets
|
||||
const inactiveKeysetIds = getInactiveKeysetIds(mintInstance)
|
||||
const activeKeysetIds = getActiveKeysetIds(mintInstance)
|
||||
|
||||
log.trace('[sendFromMintSync]', {inactiveKeysetIds, activeKeysetIds})
|
||||
|
||||
if(inactiveKeysetIds.length > 0) {
|
||||
proofsToSendFrom = prioritizeFromInactiveKeysets(
|
||||
mintInstance,
|
||||
amountToSend,
|
||||
unit,
|
||||
proofsFromMint
|
||||
)
|
||||
} else {
|
||||
if (swapFeeReserve > 0) {
|
||||
proofsToSendFrom = CashuUtils.getProofsToSend(
|
||||
amountToSend,
|
||||
amountWithFees,
|
||||
proofsFromMint
|
||||
)
|
||||
proofsToSendFromAmount = CashuUtils.getProofsAmount(proofsToSendFrom)
|
||||
}
|
||||
|
||||
let proofsToSendFromAmount = CashuUtils.getProofsAmount(proofsToSendFrom)
|
||||
let swapFeeReserve: number = 0
|
||||
let returnedAmount = 0
|
||||
let swapFeePaid: number = 0
|
||||
let returnedProofs: CashuProof[] = []
|
||||
let isSwapNeeded: boolean = false
|
||||
|
||||
if((p2pk && p2pk.pubkey) || proofsToSendFromAmount - amountToSend > 0) {
|
||||
isSwapNeeded = true
|
||||
returnedAmount = proofsToSendFromAmount - (amountToSend + swapFeeReserve)
|
||||
} else {
|
||||
if (proofsToSendFrom.length > MAX_SWAP_INPUT_SIZE) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
`Number of proofs is above max limit of ${MAX_SWAP_INPUT_SIZE}. Visit Backup to optimize your wallet, then try again.`,
|
||||
{transactionId}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
log.trace('[sendFromMintSync]', {proofsToSendFromAmount, amountToSend})
|
||||
/*
|
||||
* SWAP is needed, could involve a fee
|
||||
* we could not find the denominations to match exact amount
|
||||
*/
|
||||
if(isSwapNeeded) {
|
||||
// Calculate feeReserve from mint fee rate
|
||||
const walletInstance = await walletStore.getWallet(mintUrl, unit, {withSeed: true}) as CashuWallet
|
||||
swapFeeReserve = walletInstance.getFeesForProofs(proofsToSendFrom).toNumber()
|
||||
const amountWithFees = amountToSend + swapFeeReserve
|
||||
|
||||
if (totalAmountFromMint < amountWithFees) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
'There is not enough funds to send this amount.',
|
||||
{totalAmountFromMint, amountWithFees, transactionId, caller: 'sendFromMintSync'},
|
||||
)
|
||||
}
|
||||
|
||||
if(swapFeeReserve > 0) {
|
||||
// re-select proofs for higher amount
|
||||
proofsToSendFrom = CashuUtils.getProofsToSend(
|
||||
amountWithFees,
|
||||
proofsFromMint
|
||||
)
|
||||
|
||||
proofsToSendFromAmount = CashuUtils.getProofsAmount(proofsToSendFrom)
|
||||
}
|
||||
|
||||
// This is expected to get back from mint as a split remainder
|
||||
returnedAmount = proofsToSendFromAmount - (amountToSend + swapFeeReserve)
|
||||
// Open reservation: locks proofsToSendFrom as PENDING atomically in SQLite.
|
||||
const reservation = proofsStore.reserve(proofsToSendFrom, {
|
||||
transactionId,
|
||||
mintUrl,
|
||||
unit,
|
||||
operationType: isSwapNeeded ? 'send-swap' : 'send-direct',
|
||||
rollbackTo: 'UNSPENT',
|
||||
})
|
||||
|
||||
try {
|
||||
if (isSwapNeeded) {
|
||||
log.debug('[sendFromMintSync] Swap is needed.', {
|
||||
proofsToSendFromAmount,
|
||||
amountWithFees,
|
||||
amountWithFees: amountToSend + swapFeeReserve,
|
||||
returnedAmount,
|
||||
transactionId
|
||||
})
|
||||
|
||||
let sendResult = {} as {
|
||||
returnedProofs: CashuProof[],
|
||||
proofsToSend: CashuProof[],
|
||||
returnedProofs: CashuProof[]
|
||||
proofsToSend: CashuProof[]
|
||||
swapFeePaid: number
|
||||
}
|
||||
|
||||
@@ -407,84 +423,61 @@ export const sendFromMintSync = async function (
|
||||
proofsToSend = sendResult.proofsToSend
|
||||
swapFeePaid = sendResult.swapFeePaid
|
||||
|
||||
// add proofs returned by the mint after the split
|
||||
log.trace('[sendFromMintSync] after swap: add returned proofs to spendable')
|
||||
proofsStore.addOrUpdate(returnedProofs, {
|
||||
mintUrl,
|
||||
unit,
|
||||
tId: transactionId,
|
||||
isPending: false,
|
||||
isSpent: false
|
||||
})
|
||||
|
||||
// !!! Filter out any proofs that were returned unchanged by the cashu-ts optimization
|
||||
// to avoid marking them as spent when they should remain spendable !!!
|
||||
// cashu-ts may "return unchanged" some inputs that didn't need splitting;
|
||||
// those should NOT be marked spent — they remain in the wallet.
|
||||
const returnedSecrets = new Set(returnedProofs.map(p => p.secret))
|
||||
const actuallySpentProofs = proofsToSendFrom.filter(p => !returnedSecrets.has(p.secret))
|
||||
|
||||
log.trace('[sendFromMintSync] after swap: move proofsToSendFrom to spent', {
|
||||
log.trace('[sendFromMintSync] swap finalize', {
|
||||
inputCount: proofsToSendFrom.length,
|
||||
returnedUnchangedCount: proofsToSendFrom.length - actuallySpentProofs.length,
|
||||
actuallySpentCount: actuallySpentProofs.length
|
||||
actuallySpentCount: actuallySpentProofs.length,
|
||||
returnedProofsCount: returnedProofs.length,
|
||||
proofsToSendCount: proofsToSend.length,
|
||||
})
|
||||
|
||||
proofsStore.addOrUpdate(actuallySpentProofs, {
|
||||
mintUrl,
|
||||
unit,
|
||||
tId: transactionId,
|
||||
isPending: false,
|
||||
isSpent: true
|
||||
// ATOMIC commit: inputs → SPENT, returned change → UNSPENT,
|
||||
// proofsToSend → PENDING, reservation row deleted — one SQLite txn.
|
||||
proofsStore.commitReservation(reservation, {
|
||||
toSpent: actuallySpentProofs,
|
||||
newProofs: [
|
||||
{ proofs: returnedProofs, state: 'UNSPENT', tId: transactionId },
|
||||
{ proofs: proofsToSend as CashuProof[], state: 'PENDING', tId: transactionId },
|
||||
],
|
||||
})
|
||||
|
||||
} else {
|
||||
// SWAP is NOT needed, we've found denominations that match exact amount
|
||||
// No swap: reserved proofs ARE the proofs to send; they remain PENDING.
|
||||
log.trace('[sendFromMintSync] Swap is not necessary.', {transactionId})
|
||||
|
||||
// If we selected whole balance, check if it is not above limit acceptable by wallet and mints.
|
||||
if(proofsToSendFrom.length > MAX_SWAP_INPUT_SIZE) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
`Number of proofs is above max limit of ${MAX_SWAP_INPUT_SIZE}. Visit Backup to optimize your wallet, then try again.`,
|
||||
{transactionId}
|
||||
)
|
||||
}
|
||||
|
||||
proofsToSend = CashuUtils.exportProofs(proofsToSendFrom)
|
||||
proofsStore.commitReservation(reservation)
|
||||
}
|
||||
|
||||
// move sent proofs to pending
|
||||
log.trace('[sendFromMintSync] move proofsToSend to pending')
|
||||
proofsStore.addOrUpdate(proofsToSend, {
|
||||
mintUrl,
|
||||
unit,
|
||||
tId: transactionId,
|
||||
isPending: true,
|
||||
isSpent: false
|
||||
})
|
||||
|
||||
// We return cleaned proofs to be encoded as a sendable token + fees
|
||||
return {
|
||||
proofs: proofsToSend,
|
||||
swapFeeReserve,
|
||||
swapFeePaid,
|
||||
isSwapNeeded
|
||||
}
|
||||
} catch (e: any) {
|
||||
// try to clean spent proofs if that was the swap error cause
|
||||
if (e.params && e.params.message && e.params.message.toLowerCase().includes('token already spent')) {
|
||||
} catch (e: any) {
|
||||
// Atomic rollback: restore reserved proofs to their original state +
|
||||
// delete the reservation row.
|
||||
proofsStore.rollbackReservation(reservation)
|
||||
|
||||
// Try to clean up already-spent proofs surfaced by the mint as the
|
||||
// root cause of the swap error.
|
||||
if (e.params && e.params.message && e.params.message.toLowerCase().includes('token already spent')) {
|
||||
log.error('[sendFromMintSync] Going to clean spent proofs from proofsToSendFrom and from pending', {transactionId})
|
||||
|
||||
await WalletTask.syncStateWithMintTask({
|
||||
proofsToSync: proofsToSend as Proof[],
|
||||
mintUrl,
|
||||
isPending: true
|
||||
proofState: 'PENDING',
|
||||
})
|
||||
|
||||
await WalletTask.syncStateWithMintTask({
|
||||
proofsToSync: proofsStore.getByMint(mintUrl, {isPending: true, unit}),
|
||||
proofsToSync: proofsStore.getByMint(mintUrl, {state: 'PENDING', unit}),
|
||||
mintUrl,
|
||||
isPending: true
|
||||
proofState: 'PENDING',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -493,7 +486,7 @@ export const sendFromMintSync = async function (
|
||||
} else {
|
||||
throw new AppError(Err.WALLET_ERROR, e.message, e.stack.slice(0, 200))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -509,17 +502,17 @@ const prioritizeFromInactiveKeysets = function (
|
||||
|
||||
const proofsFromInactiveKeysets = proofsStore.getByMint(
|
||||
mint.mintUrl, {
|
||||
isPending: false,
|
||||
state: 'UNSPENT',
|
||||
unit,
|
||||
keysetIds: inactiveKeysetIds
|
||||
keysetIds: inactiveKeysetIds,
|
||||
}
|
||||
)
|
||||
|
||||
const proofsFromActiveKeysets = proofsStore.getByMint(
|
||||
mint.mintUrl, {
|
||||
isPending: false,
|
||||
state: 'UNSPENT',
|
||||
unit,
|
||||
keysetIds: activeKeysetIds
|
||||
keysetIds: activeKeysetIds,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {rootStoreInstance} from '../../models'
|
||||
import { TransactionTaskResult, WalletTask } from '../walletService'
|
||||
import { MintBalance } from '../../models/Mint'
|
||||
import { Proof } from '../../models/Proof'
|
||||
import { ProofReservation } from './proofReservation'
|
||||
import { Transaction, TransactionData, TransactionStatus, TransactionType } from '../../models/Transaction'
|
||||
import { log } from '../logService'
|
||||
import { WalletUtils } from './utils'
|
||||
@@ -96,6 +97,9 @@ export const transferTask = async function (
|
||||
let meltFeeReserve: number = 0
|
||||
let meltResponse: MeltProofsResponse
|
||||
let meltQuoteCheck: MeltQuoteBolt11Response
|
||||
// Declared at the function scope so the catch block can resolve the
|
||||
// reservation (commit-no-changes or rollback) if we throw after opening it.
|
||||
let meltReservation: ProofReservation | undefined = undefined
|
||||
|
||||
try {
|
||||
|
||||
@@ -161,7 +165,7 @@ export const transferTask = async function (
|
||||
}
|
||||
|
||||
// calculate fees charged by mint for melt transaction to prepare enough proofs
|
||||
const proofsFromMint = proofsStore.getByMint(mintUrl, {isPending: false, unit})
|
||||
const proofsFromMint = proofsStore.getByMint(mintUrl, {state: 'UNSPENT', unit})
|
||||
const totalAmountFromMint = CashuUtils.getProofsAmount(proofsFromMint)
|
||||
|
||||
proofsToMeltFrom = CashuUtils.getProofsToSend(
|
||||
@@ -196,60 +200,58 @@ export const transferTask = async function (
|
||||
// Preemptive swap: if selected proofs overshoot needed amount by >20%, swap for
|
||||
// well-denominated proofs to avoid paying excessive melt fees.
|
||||
let swapFeePaid = 0
|
||||
let didPreemptiveSwap = false
|
||||
|
||||
if (proofsToMeltFromAmount > amountWithFees * 1.2) {
|
||||
log.info('[transfer] proofsToMeltFromAmount overshoots amountWithFees by >20%, running preemptive swap', {
|
||||
proofsToMeltFromAmount,
|
||||
amountWithFees,
|
||||
})
|
||||
try {
|
||||
const swapInputProofs = proofsToMeltFrom // save reference before reassignment
|
||||
|
||||
// SWAP reservation: lock the inputs atomically. On swap success, commit
|
||||
// the (inputs → SPENT, change → UNSPENT, swap-output → PENDING) batch in
|
||||
// a single SQLite transaction. On swap failure, rollback restores the
|
||||
// inputs to UNSPENT and we fall through to the no-swap path.
|
||||
const swapInputProofs = proofsToMeltFrom
|
||||
const swapReservation = proofsStore.reserve(swapInputProofs, {
|
||||
transactionId: transactionId!,
|
||||
mintUrl,
|
||||
unit,
|
||||
operationType: 'transfer-swap',
|
||||
rollbackTo: 'UNSPENT',
|
||||
})
|
||||
|
||||
try {
|
||||
const swapResult = await walletStore.send(
|
||||
mintUrl,
|
||||
amountWithFees,
|
||||
unit,
|
||||
proofsToMeltFrom,
|
||||
swapInputProofs,
|
||||
transactionId!,
|
||||
)
|
||||
|
||||
// Change proofs from swap go back to spendable wallet
|
||||
if (swapResult.returnedProofs.length > 0) {
|
||||
proofsStore.addOrUpdate(swapResult.returnedProofs, {
|
||||
mintUrl,
|
||||
unit,
|
||||
tId: transactionId,
|
||||
isPending: false,
|
||||
isSpent: false,
|
||||
})
|
||||
}
|
||||
|
||||
// Mark original input proofs as spent — the swap consumed them.
|
||||
// Exclude any returned unchanged by cashu-ts optimisation (they appear in returnedProofs).
|
||||
// cashu-ts may return some inputs unchanged; those should NOT be marked SPENT.
|
||||
const returnedSecrets = new Set(swapResult.returnedProofs.map(p => p.secret))
|
||||
const consumedBySwap = swapInputProofs.filter(p => !returnedSecrets.has(p.secret))
|
||||
proofsStore.addOrUpdate(consumedBySwap, {
|
||||
mintUrl,
|
||||
tId: transactionId,
|
||||
unit,
|
||||
isPending: false,
|
||||
isSpent: true,
|
||||
|
||||
// ATOMIC commit of the swap state transitions + reservation deletion.
|
||||
const { added } = proofsStore.commitReservation(swapReservation, {
|
||||
toSpent: consumedBySwap,
|
||||
newProofs: [
|
||||
{ proofs: swapResult.returnedProofs, state: 'UNSPENT', tId: transactionId! },
|
||||
{ proofs: swapResult.proofsToSend, state: 'PENDING', tId: transactionId! },
|
||||
],
|
||||
})
|
||||
|
||||
// Track the true net debit: swap target + swap fee paid to the mint
|
||||
// Adopt the swap output as the new proofsToMeltFrom.
|
||||
const swapOutputSecrets = new Set(swapResult.proofsToSend.map(p => p.secret))
|
||||
const pendingSwapProofs = added.filter(p => swapOutputSecrets.has(p.secret))
|
||||
|
||||
proofsToMeltFromAmount = CashuUtils.getProofsAmount(swapResult.proofsToSend) + swapResult.swapFeePaid
|
||||
meltFeeReserve += swapResult.swapFeePaid
|
||||
swapFeePaid = swapResult.swapFeePaid
|
||||
|
||||
// Mark swap outputs as pending — addOrUpdate returns them as MST nodes directly.
|
||||
const { updatedProofs: pendingSwapProofs } = proofsStore.addOrUpdate(swapResult.proofsToSend, {
|
||||
mintUrl,
|
||||
tId: transactionId,
|
||||
unit,
|
||||
isPending: true,
|
||||
isSpent: false,
|
||||
})
|
||||
proofsToMeltFrom = pendingSwapProofs
|
||||
didPreemptiveSwap = true
|
||||
|
||||
log.debug('[transfer] Preemptive swap completed', {
|
||||
proofsToMeltFromAmount,
|
||||
@@ -257,29 +259,30 @@ export const transferTask = async function (
|
||||
meltFeeReserve,
|
||||
})
|
||||
} catch (swapError: any) {
|
||||
// Swap is an optimisation — fall back to original proofs, mark them pending
|
||||
// Swap is an optimisation — restore the inputs to UNSPENT and fall
|
||||
// through to the no-swap path with the original proofs.
|
||||
log.warn('[transfer] Preemptive swap failed, continuing with original proofs', {
|
||||
error: swapError.message,
|
||||
})
|
||||
proofsStore.addOrUpdate(proofsToMeltFrom, {
|
||||
mintUrl,
|
||||
tId: transaction.id,
|
||||
unit,
|
||||
isPending: true,
|
||||
isSpent: false,
|
||||
})
|
||||
proofsStore.rollbackReservation(swapReservation)
|
||||
}
|
||||
} else {
|
||||
// No swap needed — move original proofs to pending
|
||||
proofsStore.addOrUpdate(proofsToMeltFrom, {
|
||||
mintUrl,
|
||||
tId: transaction.id,
|
||||
unit,
|
||||
isPending: true,
|
||||
isSpent: false,
|
||||
})
|
||||
}
|
||||
|
||||
// MELT reservation: locks proofsToMeltFrom as PENDING atomically.
|
||||
//
|
||||
// If the swap path ran, proofsToMeltFrom are already PENDING — we still
|
||||
// open a reservation on them so the melt phase has its own orphan-recovery
|
||||
// marker. `rollbackTo: 'UNSPENT'` releases them back to spendable on
|
||||
// failure (they're not the user's original ecash but freshly swapped
|
||||
// outputs intended for the melt).
|
||||
meltReservation = proofsStore.reserve(proofsToMeltFrom, {
|
||||
transactionId: transactionId!,
|
||||
mintUrl,
|
||||
unit,
|
||||
operationType: didPreemptiveSwap ? 'transfer-melt-after-swap' : 'transfer-melt',
|
||||
rollbackTo: 'UNSPENT',
|
||||
})
|
||||
|
||||
log.trace('[transfer]', 'Prepared proofsToMeltFrom proofs', {
|
||||
proofsToMeltFromAmount,
|
||||
transactionId,
|
||||
@@ -340,9 +343,6 @@ export const transferTask = async function (
|
||||
transactionId
|
||||
})
|
||||
|
||||
// Spend pending proofs that were used to settle the lightning invoice
|
||||
proofsStore.moveToSpent(proofsToMeltFrom)
|
||||
|
||||
// compute fees and change
|
||||
let totalFeePaid = proofsToMeltFromAmount - amountToTransfer
|
||||
let lightningFeePaid = totalFeePaid - meltFeeReserve
|
||||
@@ -351,18 +351,16 @@ export const transferTask = async function (
|
||||
|
||||
let outputToken: string | undefined
|
||||
|
||||
if(meltResponse.change.length > 0) {
|
||||
|
||||
proofsStore.addOrUpdate(meltResponse.change,
|
||||
{
|
||||
mintUrl,
|
||||
tId: transaction.id,
|
||||
unit,
|
||||
isPending: false,
|
||||
isSpent: false
|
||||
}
|
||||
)
|
||||
// ATOMIC commit: inputs PENDING → SPENT, change → UNSPENT, reservation
|
||||
// row deleted — single SQLite transaction.
|
||||
proofsStore.commitReservation(meltReservation, {
|
||||
toSpent: proofsToMeltFrom,
|
||||
newProofs: meltResponse.change.length > 0
|
||||
? [{ proofs: meltResponse.change, state: 'UNSPENT', tId: transaction.id }]
|
||||
: [],
|
||||
})
|
||||
|
||||
if (meltResponse.change.length > 0) {
|
||||
outputToken = getEncodedToken({
|
||||
mint: mintUrl,
|
||||
proofs: meltResponse.change,
|
||||
@@ -430,6 +428,12 @@ export const transferTask = async function (
|
||||
transactionId,
|
||||
})
|
||||
|
||||
// Commit the reservation with no proof transitions: proofs stay PENDING
|
||||
// and the reservation row is removed. The async handler (_monitorAsyncMeltQuote
|
||||
// → handlePendingMeltTask) takes over from here without a reservation
|
||||
// because its lifecycle is driven by ws/poller callbacks, not by this task.
|
||||
proofsStore.commitReservation(meltReservation)
|
||||
|
||||
transactionData.push({
|
||||
status: TransactionStatus.PENDING,
|
||||
createdAt: new Date(),
|
||||
@@ -482,6 +486,11 @@ export const transferTask = async function (
|
||||
let recovered: number = 0
|
||||
|
||||
if (transaction) {
|
||||
// If we threw AFTER opening the melt reservation, resolve it
|
||||
// (commit-no-changes or rollback) so the row doesn't become an
|
||||
// orphan. Errors thrown BEFORE the reservation opens (e.g. during
|
||||
// proof selection) leave `meltReservation` undefined.
|
||||
|
||||
meltQuoteCheck = await walletStore.checkLightningMeltQuote(mintUrl, meltQuote.quote)
|
||||
taskResult.meltQuote = meltQuoteCheck
|
||||
|
||||
@@ -495,12 +504,21 @@ export const transferTask = async function (
|
||||
taskResult.preimage = meltQuoteCheck.payment_preimage
|
||||
taskResult.message = message
|
||||
|
||||
// Inputs must move PENDING → SPENT atomically with the
|
||||
// reservation row deletion. Change recovery happens
|
||||
// separately via recoverMeltQuoteChange (adds change as
|
||||
// UNSPENT) — it manages its own writes.
|
||||
if (meltReservation) {
|
||||
proofsStore.commitReservation(meltReservation, {
|
||||
toSpent: proofsToMeltFrom,
|
||||
})
|
||||
}
|
||||
|
||||
const {recoveredAmount} = await WalletTask.recoverMeltQuoteChange({
|
||||
mintUrl,
|
||||
meltQuote: meltQuoteCheck,
|
||||
})
|
||||
|
||||
|
||||
log.error('[transfer]', message, {
|
||||
recoveredAmount,
|
||||
error: e.message,
|
||||
@@ -517,8 +535,14 @@ export const transferTask = async function (
|
||||
message = 'Lightning payment did not complete in time. Your ecash will remain pending until the payment completes or fails.'
|
||||
taskResult.message = message
|
||||
|
||||
// Proofs stay PENDING; async resolution path takes over.
|
||||
// Drop the reservation row so startup doesn't see an orphan.
|
||||
if (meltReservation) {
|
||||
proofsStore.commitReservation(meltReservation)
|
||||
}
|
||||
|
||||
log.error('[transfer]', message, {
|
||||
error: `${e.message}: ${e.params.message}`,
|
||||
error: `${e.message}: ${e.params?.message}`,
|
||||
unit,
|
||||
meltQuoteCheck,
|
||||
transactionId: transaction.id
|
||||
@@ -526,20 +550,31 @@ export const transferTask = async function (
|
||||
|
||||
// --- UNPAID ---
|
||||
} else {
|
||||
if (e.params && e.params.message && e.params.message.toLowerCase().includes('token already spent')) {
|
||||
|
||||
if (WalletUtils.isTokenAlreadySpentError(e)) {
|
||||
// NUT-00 11001: at least one input is spent at the
|
||||
// mint. Sync will reconcile (mark them SPENT). Drop the
|
||||
// reservation row without restoring.
|
||||
message = 'Token already spent, going to sync wallet pending proofs with the mint.'
|
||||
taskResult.message = message
|
||||
|
||||
if (meltReservation) {
|
||||
proofsStore.commitReservation(meltReservation)
|
||||
}
|
||||
|
||||
log.error('[transfer]', message, {
|
||||
transactionId: transaction.id
|
||||
})
|
||||
} else if (e.params && e.params.message && e.params.message.toLowerCase().includes('proofs are pending')) {
|
||||
// if melt quote is UNPAID wallet used already pending by mint proofs for this transaction
|
||||
// we do not want to return them to spendable but keep them pending
|
||||
} else if (WalletUtils.isTokenPendingError(e)) {
|
||||
// NUT-00 11006: an input is pending in another in-flight
|
||||
// melt. Do NOT release — sync resolves it once the
|
||||
// other operation settles.
|
||||
message = 'Pending proofs were used for this transaction, going to sync proofsToMeltFrom with the mint.'
|
||||
taskResult.message = message
|
||||
|
||||
if (meltReservation) {
|
||||
proofsStore.commitReservation(meltReservation)
|
||||
}
|
||||
|
||||
log.error('[transfer]', message, {
|
||||
transactionId: transaction.id
|
||||
})
|
||||
@@ -547,12 +582,15 @@ export const transferTask = async function (
|
||||
await WalletTask.syncStateWithMintTask({
|
||||
proofsToSync: proofsToMeltFrom, // includes some pending by mint proofs
|
||||
mintUrl,
|
||||
isPending: true
|
||||
proofState: 'PENDING',
|
||||
})
|
||||
|
||||
} else {
|
||||
// if melt quote is UNPAID return proofs from pending to spendable balance
|
||||
proofsStore.revertToSpendable(proofsToMeltFrom)
|
||||
// Clean unpaid: rollback atomically releases the
|
||||
// reserved proofs to UNSPENT (and deletes the row).
|
||||
if (meltReservation) {
|
||||
proofsStore.rollbackReservation(meltReservation)
|
||||
}
|
||||
|
||||
message = "Ecash reserved for this payment was returned to spendable balance."
|
||||
|
||||
@@ -564,9 +602,9 @@ export const transferTask = async function (
|
||||
}
|
||||
|
||||
await WalletTask.syncStateWithMintTask({
|
||||
proofsToSync: proofsStore.getByMint(mintUrl, {isPending: true, unit}),
|
||||
proofsToSync: proofsStore.getByMint(mintUrl, {state: 'PENDING', unit}),
|
||||
mintUrl,
|
||||
isPending: true
|
||||
proofState: 'PENDING',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import {MeltQuoteBaseResponse} from '@cashu/cashu-ts'
|
||||
import {NostrEvent} from '../nostrService'
|
||||
import {Transaction, TransactionStatus} from '../../models/Transaction'
|
||||
import AppError from '../../utils/AppError'
|
||||
|
||||
export const DEFAULT_DENOMINATION_TARGET = 2
|
||||
|
||||
export const MAX_SWAP_INPUT_SIZE = 100
|
||||
export const MAX_SYNC_INPUT_SIZE = 200 // 1000 hard mint limit
|
||||
export const TASK_QUEUE_TIMEOUT = 30 * 1000
|
||||
|
||||
export const TEST_TASK = 'testTask'
|
||||
export const SWAP_ALL_TASK = 'swapAllTask'
|
||||
export const SWAP_DENOMINATION_TASK = 'swapDenominationTask'
|
||||
export const SYNC_STATE_WITH_ALL_MINTS_TASK = 'syncStateWithAllMintsTask'
|
||||
export const SYNC_STATE_WITH_MINT_TASK = 'syncStateWithMintTask'
|
||||
export const HANDLE_NWC_REQUEST_TASK = 'handleNwcRequestTask'
|
||||
export const HANDLE_CLAIM_TASK = 'handleClaimTask'
|
||||
export const HANDLE_PENDING_TOPUP_TASK = 'handlePendingTopupTask'
|
||||
export const HANDLE_INFLIGHT_BY_MINT_TASK = 'handleInFlightByMintTask'
|
||||
export const HANDLE_RECEIVED_EVENT_TASK = 'handleReceivedEventTask'
|
||||
|
||||
export interface WalletTaskResult {
|
||||
taskFunction: string
|
||||
message: string
|
||||
mintUrl?: string
|
||||
error?: AppError
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface TransactionTaskResult extends WalletTaskResult {
|
||||
mintUrl: string
|
||||
transaction?: Transaction
|
||||
swapFeePaid?: number
|
||||
lightningFeePaid?: number
|
||||
meltFeePaid?: number
|
||||
meltQuote?: MeltQuoteBaseResponse
|
||||
nwcEvent?: NostrEvent
|
||||
}
|
||||
|
||||
export interface TransactionStateUpdate {
|
||||
tId: number
|
||||
amount?: number
|
||||
spentByMintAmount?: number
|
||||
pendingByMintAmount?: number
|
||||
movedToSpendableAmount?: number
|
||||
meltQuoteToRecover?: string
|
||||
recoveredChangeAmount?: number
|
||||
message?: string
|
||||
updatedStatus: TransactionStatus
|
||||
}
|
||||
|
||||
export interface SyncStateTaskResult extends WalletTaskResult {
|
||||
transactionStateUpdates: TransactionStateUpdate[]
|
||||
completedTransactionIds: number[]
|
||||
errorTransactionIds: number[]
|
||||
revertedTransactionIds: number[]
|
||||
}
|
||||
@@ -1,6 +1,21 @@
|
||||
import AppError from "../../utils/AppError"
|
||||
import { CashuUtils } from '../cashu/cashuUtils'
|
||||
|
||||
/**
|
||||
* Cashu protocol error codes (NUT-00).
|
||||
* See https://github.com/cashubtc/nuts/blob/main/error_codes.md
|
||||
*
|
||||
* cashu-ts surfaces these on `MintOperationError.code`; our WalletStore copies
|
||||
* the code into `AppError.params.code` (and AppError's constructor exposes it
|
||||
* on `e.code`).
|
||||
*/
|
||||
export const CashuErrorCode = {
|
||||
OUTPUTS_ALREADY_SIGNED: 10002,
|
||||
TOKEN_ALREADY_SPENT: 11001,
|
||||
QUOTE_PENDING: 11005,
|
||||
TOKEN_PENDING: 11006,
|
||||
} as const
|
||||
|
||||
const formatError = function (e: AppError) {
|
||||
return {
|
||||
name: e.name,
|
||||
@@ -19,15 +34,37 @@ const shouldHealOutputsError = function (e: any): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
if(e.code && e.code === 10002) {
|
||||
if(e.code && e.code === CashuErrorCode.OUTPUTS_ALREADY_SIGNED) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export const WalletUtils = {
|
||||
formatError,
|
||||
shouldHealOutputsError
|
||||
/**
|
||||
* True if the cashu mint reported that one of the input proofs is already
|
||||
* spent (NUT-00 code 11001). Inputs to this op are stale — sync the wallet
|
||||
* to reconcile.
|
||||
*/
|
||||
const isTokenAlreadySpentError = function (e: any): boolean {
|
||||
if (!(e instanceof AppError)) return false
|
||||
return e.code === CashuErrorCode.TOKEN_ALREADY_SPENT
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the cashu mint reported that one of the input proofs is pending
|
||||
* in another in-flight melt operation (NUT-00 code 11006). Do not release
|
||||
* these — sync will resolve once the other operation settles.
|
||||
*/
|
||||
const isTokenPendingError = function (e: any): boolean {
|
||||
if (!(e instanceof AppError)) return false
|
||||
return e.code === CashuErrorCode.TOKEN_PENDING
|
||||
}
|
||||
|
||||
export const WalletUtils = {
|
||||
formatError,
|
||||
shouldHealOutputsError,
|
||||
isTokenAlreadySpentError,
|
||||
isTokenPendingError,
|
||||
}
|
||||
|
||||
|
||||
+148
-3229
File diff suppressed because it is too large
Load Diff
@@ -63,5 +63,89 @@ export class AppError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Typed error subclasses (Phase 2 of refactoring).
|
||||
//
|
||||
// All subclasses extend AppError, so every existing `catch(e: AppError)` and
|
||||
// `e.name === Err.X` check keeps working unchanged. New code can narrow with
|
||||
// `instanceof` to handle categories without string-matching on names/messages.
|
||||
//
|
||||
// Each subclass picks a sensible default Err name but accepts an override so a
|
||||
// caller can throw e.g. NetworkError with Err.NETWORK_TIMEOUT or
|
||||
// Err.CONNECTION_ERROR when the more specific code matters.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Bad input, missing field, not-found, already-exists — preconditions failed. */
|
||||
export class ValidationError extends AppError {
|
||||
constructor(
|
||||
message: string,
|
||||
params?: Record<string, any>,
|
||||
name: Err = Err.VALIDATION_ERROR,
|
||||
) {
|
||||
super(name, message, params)
|
||||
Object.setPrototypeOf(this, ValidationError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
/** Mint returned a protocol-level error (NUT error body, bad response shape). */
|
||||
export class MintError extends AppError {
|
||||
constructor(
|
||||
message: string,
|
||||
params?: Record<string, any>,
|
||||
name: Err = Err.MINT_ERROR,
|
||||
) {
|
||||
super(name, message, params)
|
||||
Object.setPrototypeOf(this, MintError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
/** Transport layer failed (DNS, TLS, timeout, 5xx, connection refused).
|
||||
* Orthogonal to MintError — a network failure can happen against any host. */
|
||||
export class NetworkError extends AppError {
|
||||
constructor(
|
||||
message: string,
|
||||
params?: Record<string, any>,
|
||||
name: Err = Err.NETWORK_ERROR,
|
||||
) {
|
||||
super(name, message, params)
|
||||
Object.setPrototypeOf(this, NetworkError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
/** Cashu-ts / proof construction / keyset / DLEQ errors. */
|
||||
export class WalletError extends AppError {
|
||||
constructor(
|
||||
message: string,
|
||||
params?: Record<string, any>,
|
||||
name: Err = Err.WALLET_ERROR,
|
||||
) {
|
||||
super(name, message, params)
|
||||
Object.setPrototypeOf(this, WalletError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
/** SQLite / MMKV / keychain — persistence layer failures. */
|
||||
export class StorageError extends AppError {
|
||||
constructor(
|
||||
message: string,
|
||||
params?: Record<string, any>,
|
||||
name: Err = Err.STORAGE_ERROR,
|
||||
) {
|
||||
super(name, message, params)
|
||||
Object.setPrototypeOf(this, StorageError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
/** Queue / lifecycle / polling failures and unexpected operation errors. */
|
||||
export class OperationError extends AppError {
|
||||
constructor(
|
||||
message: string,
|
||||
params?: Record<string, any>,
|
||||
name: Err = Err.UNKNOWN_ERROR,
|
||||
) {
|
||||
super(name, message, params)
|
||||
Object.setPrototypeOf(this, OperationError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
export default AppError
|
||||
@@ -1,5 +1,28 @@
|
||||
import { log } from "../services"
|
||||
|
||||
/**
|
||||
* Type-side registry of wallet events. Empty by default — service-layer files
|
||||
* use `declare module '../../utils/eventEmitter' { interface CoreEvents { ... } }`
|
||||
* to register their event names and payload shapes.
|
||||
*
|
||||
* See [src/services/wallet/events.ts](src/services/wallet/events.ts) for the
|
||||
* canonical wallet event map.
|
||||
*/
|
||||
export interface CoreEvents {}
|
||||
|
||||
type EventKey = keyof CoreEvents & string
|
||||
|
||||
/**
|
||||
* Resolves to the input string when it is NOT a registered event name, otherwise
|
||||
* to `never`. Used in the untyped fallback overload so a literal string that
|
||||
* matches a known event name cannot bypass the typed signature.
|
||||
*
|
||||
* UnknownEventName<'ev_asyncMeltResult'> = never
|
||||
* UnknownEventName<'something_else'> = 'something_else'
|
||||
* UnknownEventName<string> = string (no widening hazard)
|
||||
*/
|
||||
type UnknownEventName<S extends string> = S extends EventKey ? never : S
|
||||
|
||||
class EventEmitter {
|
||||
private events: {[eventName: string]: Function[]}
|
||||
|
||||
@@ -9,9 +32,13 @@ class EventEmitter {
|
||||
|
||||
/**
|
||||
* Subscribe to an event.
|
||||
* @param eventName The name of the event to subscribe to.
|
||||
* @param handler The callback function to be called when the event is emitted.
|
||||
*
|
||||
* Two overloads:
|
||||
* - Known event name (`keyof CoreEvents`): handler payload is type-checked.
|
||||
* - Arbitrary string: falls back to untyped Function — backward compatible.
|
||||
*/
|
||||
on<K extends EventKey>(eventName: K, handler: (payload: CoreEvents[K]) => void): void
|
||||
on<S extends string>(eventName: UnknownEventName<S>, handler: Function): void
|
||||
on(eventName: string, handler: Function) {
|
||||
log.trace(`[EventEmitter.on] Subscribing to ${eventName}`)
|
||||
|
||||
@@ -28,9 +55,13 @@ class EventEmitter {
|
||||
|
||||
/**
|
||||
* Emit an event.
|
||||
* @param eventName The name of the event to emit.
|
||||
* @param args Additional arguments to pass to the event handlers.
|
||||
*
|
||||
* Two overloads:
|
||||
* - Known event name: requires a single payload of the registered type.
|
||||
* - Arbitrary string: accepts any args — backward compatible.
|
||||
*/
|
||||
emit<K extends EventKey>(eventName: K, payload: CoreEvents[K]): void
|
||||
emit<S extends string>(eventName: UnknownEventName<S>, ...args: any[]): void
|
||||
emit(eventName: string, ...args: any[]) {
|
||||
const handlers = this.events[eventName]
|
||||
|
||||
@@ -41,9 +72,9 @@ class EventEmitter {
|
||||
|
||||
/**
|
||||
* Unsubscribe from an event.
|
||||
* @param eventName The name of the event to unsubscribe from.
|
||||
* @param handler The callback function to remove from the event handlers.
|
||||
*/
|
||||
off<K extends EventKey>(eventName: K, handler: (payload: CoreEvents[K]) => void): void
|
||||
off<S extends string>(eventName: UnknownEventName<S>, handler: Function): void
|
||||
off(eventName: string, handler: Function) {
|
||||
const handlers = this.events[eventName]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user