Proofs reservation and rollback

This commit is contained in:
minibits-cash
2026-05-22 22:03:18 +02:00
parent 6d388947ef
commit 25330d3b73
41 changed files with 5682 additions and 3834 deletions
+543
View File
@@ -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()
})
})
})
+233
View File
@@ -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()
})
})
})
+228
View File
@@ -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'])
})
})
})
+199
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+16 -9
View File
@@ -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(
+10
View File
@@ -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()
+2 -2
View File
@@ -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) {
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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'})
}
}
+2 -4
View File
@@ -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) {
+2 -2
View File
@@ -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
+4 -4
View File
@@ -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.'
+1 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
+61
View File
@@ -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 {}
}
+87
View File
@@ -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,
}
+41
View File
@@ -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[]
}
+90
View File
@@ -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)
})
}
+1 -2
View File
@@ -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
+68 -44
View File
@@ -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
View File
@@ -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,
}
)
+114 -76
View File
@@ -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',
})
}
+58
View File
@@ -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[]
}
+41 -4
View File
@@ -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,
}
File diff suppressed because it is too large Load Diff
+84
View File
@@ -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
+37 -6
View File
@@ -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]