Transaction lifecycle methods

This commit is contained in:
minibits-cash
2026-05-26 00:08:47 +02:00
parent 586f5c098d
commit 3b91194025
33 changed files with 6023 additions and 2042 deletions
@@ -0,0 +1,111 @@
/**
* Cashu Payment Request (NUT-18) operation lifecycle — API contract tests.
*
* @jest-environment node
*/
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 type {
CashuPaymentRequestMethod,
CashuPaymentRequestMethodInput,
CashuPaymentRequestMethodOptions,
CashuPaymentRequestMethodPayload,
} from '../src/services/wallet/operations/cashuPaymentRequestMethods'
describe('CashuPaymentRequestMethod discriminator', () => {
test('CashuPaymentRequestMethod is a union of the registered keys', () => {
const knownMethods: CashuPaymentRequestMethod[] = ['nostr']
expect(knownMethods).toEqual(['nostr'])
})
test('CashuPaymentRequestMethodPayload narrows by method', () => {
const nostr: CashuPaymentRequestMethodPayload<'nostr'> = {}
expect(Object.keys(nostr)).toHaveLength(0)
})
test('CashuPaymentRequestMethodInput is a tagged union', () => {
const inputNostr: CashuPaymentRequestMethodInput = {method: 'nostr', options: {}}
expect(inputNostr.method).toBe('nostr')
if (inputNostr.method === 'nostr') {
// empty options
expect(Object.keys(inputNostr.options)).toHaveLength(0)
}
})
test('compile-time: unknown method tag rejected', () => {
// OK
const a: CashuPaymentRequestMethodInput = {method: 'nostr', options: {}}
// @ts-expect-error — nostr cannot have arbitrary fields
const b: CashuPaymentRequestMethodInput = {method: 'nostr', options: {target: 'x'}}
// @ts-expect-error — unknown method tag
const c: CashuPaymentRequestMethodInput = {method: 'http', options: {}}
expect(a.method).toBe('nostr')
expect(b.method).toBe('nostr')
expect(c.method).toBe('http')
})
test('CashuPaymentRequestMethodOptions interface keys match union', () => {
const optionKeys: Array<keyof CashuPaymentRequestMethodOptions> = ['nostr']
const methodKeys: CashuPaymentRequestMethod[] = ['nostr']
expect(optionKeys.sort()).toEqual(methodKeys.sort())
})
})
describe('CashuPaymentRequestApi surface (compile-time)', () => {
test('imports without runtime errors', () => {
type Api = typeof import('../src/services/wallet/operations/cashuPaymentRequestApi').CashuPaymentRequestApi
type Methods = keyof Api
const expected: Methods[] = [
'prepare',
'execute',
'cancel',
'reclaim',
'finalize',
'refresh',
]
expect(expected).toHaveLength(6)
})
test('PreparedCashuPaymentRequestData shape pins the fields execute() depends on', () => {
type PRD = import('../src/services/wallet/operations/cashuPaymentRequestApi').PreparedCashuPaymentRequestData
type RequiredKeys = keyof PRD
const requiredKeys: RequiredKeys[] = [
'transactionId',
'tx',
'mintUrl',
'unit',
'amount',
'memo',
'method',
]
expect(requiredKeys).toHaveLength(7)
})
test('ExecutedCashuPaymentRequestData shape pins the fields the wrapper depends on', () => {
type ERD = import('../src/services/wallet/operations/cashuPaymentRequestApi').ExecutedCashuPaymentRequestData
type RequiredKeys = keyof ERD
const requiredKeys: RequiredKeys[] = [
'transaction',
'cashuPaymentRequest',
'encodedCashuPaymentRequest',
]
expect(requiredKeys).toHaveLength(3)
})
})
+239
View File
@@ -39,9 +39,43 @@ const CREATE_RESERVATIONS = `CREATE TABLE reservations (
createdAt TEXT NOT NULL createdAt TEXT NOT NULL
)` )`
// Minimal transactions table for the two-table atomicity tests (Phase 5b).
const CREATE_TRANSACTIONS = `CREATE TABLE transactions (
id INTEGER PRIMARY KEY NOT NULL,
status TEXT,
data TEXT,
amount INTEGER,
fee INTEGER,
balanceAfter INTEGER,
outputToken TEXT,
keysetId TEXT,
proof TEXT
)`
function createSchema(db: DatabaseSync) { function createSchema(db: DatabaseSync) {
db.exec(CREATE_PROOFS) db.exec(CREATE_PROOFS)
db.exec(CREATE_RESERVATIONS) db.exec(CREATE_RESERVATIONS)
db.exec(CREATE_TRANSACTIONS)
}
function insertTransaction(db: DatabaseSync, id: number, status: string) {
db.prepare(`INSERT INTO transactions (id, status) VALUES (?, ?)`).run(id, status)
}
function getTransactionStatus(db: DatabaseSync, id: number): string {
const row = db.prepare('SELECT status FROM transactions WHERE id = ?').get(id) as
| {status: string}
| undefined
return row?.status ?? ''
}
function getTransactionRow(
db: DatabaseSync,
id: number,
): {status: string | null; data: string | null; balanceAfter: number | null} | undefined {
return db
.prepare('SELECT status, data, balanceAfter FROM transactions WHERE id = ?')
.get(id) as any
} }
function insertProof( function insertProof(
@@ -119,6 +153,18 @@ function openReservation(
} }
} }
type CommitTransactionUpdate = {
id: number
status?: string
data?: string
amount?: number
fee?: number
balanceAfter?: number
outputToken?: string
keysetId?: string
proof?: string
}
function commitReservation( function commitReservation(
db: DatabaseSync, db: DatabaseSync,
reservationId: string, reservationId: string,
@@ -130,6 +176,7 @@ function commitReservation(
amount: number amount: number
state: 'UNSPENT' | 'PENDING' | 'SPENT' state: 'UNSPENT' | 'PENDING' | 'SPENT'
}> }>
transactionUpdate?: CommitTransactionUpdate
}, },
) { ) {
const now = '2026-05-22T00:00:00.000Z' const now = '2026-05-22T00:00:00.000Z'
@@ -154,6 +201,34 @@ function commitReservation(
insertNew.run(p.amount, p.secret, p.state, now) insertNew.run(p.amount, p.secret, p.state, now)
} }
// Mirror the SQL the production code builds: dynamic UPDATE with only
// the supplied fields.
if (changes.transactionUpdate) {
const tu = changes.transactionUpdate
const setClauses: string[] = []
const params: (string | number | null)[] = []
const setIfDefined = (col: string, value: string | number | undefined) => {
if (value !== undefined) {
setClauses.push(`${col} = ?`)
params.push(value)
}
}
setIfDefined('status', tu.status)
setIfDefined('data', tu.data)
setIfDefined('amount', tu.amount)
setIfDefined('fee', tu.fee)
setIfDefined('balanceAfter', tu.balanceAfter)
setIfDefined('outputToken', tu.outputToken)
setIfDefined('keysetId', tu.keysetId)
setIfDefined('proof', tu.proof)
if (setClauses.length > 0) {
params.push(tu.id)
db.prepare(
`UPDATE transactions SET ${setClauses.join(', ')} WHERE id = ?`,
).run(...params)
}
}
db.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId) db.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId)
db.exec('COMMIT') db.exec('COMMIT')
} catch (e) { } catch (e) {
@@ -694,4 +769,168 @@ describe('Proof reservations', () => {
db.close() db.close()
}) })
}) })
// ─────────────────────────────────────────────────────────────────────
// Phase 5b: atomic two-table commit (proofs + transactions).
//
// A commit can optionally include a transactionUpdate that lands in the
// SAME SQLite transaction as the proof finalize. This closes the gap
// where a crash between proof commit and tx.update() left a transaction
// stuck in PENDING/PREPARED with its underlying proofs already SPENT.
// ─────────────────────────────────────────────────────────────────────
describe('atomic two-table commit (Phase 5b)', () => {
test('commit with transactionUpdate writes proofs AND transaction in one txn', () => {
const db = new DatabaseSync(':memory:')
createSchema(db)
insertProof(db, 'input', 100)
insertTransaction(db, 200, 'PREPARED')
openReservation(
db,
{
id: 'res-tx',
transactionId: 200,
mintUrl: 'https://mint.test',
unit: 'sat',
operationType: 'send-direct',
lockedProofs: [{secret: 'input', originalState: 'UNSPENT', originalTId: null}],
},
['input'],
)
expect(getTransactionStatus(db, 200)).toBe('PREPARED')
commitReservation(db, 'res-tx', {
toSpent: ['input'],
transactionUpdate: {
id: 200,
status: 'COMPLETED',
data: '[{"status":"COMPLETED"}]',
balanceAfter: 50,
},
})
// Both writes landed:
expect(getProofState(db, 'input')).toBe('SPENT')
const row = getTransactionRow(db, 200)!
expect(row.status).toBe('COMPLETED')
expect(row.data).toBe('[{"status":"COMPLETED"}]')
expect(row.balanceAfter).toBe(50)
expect(reservationCount(db)).toBe(0)
db.close()
})
test('a failed commit batch rolls back BOTH proof and transaction writes', () => {
const db = new DatabaseSync(':memory:')
createSchema(db)
insertProof(db, 'input', 100)
insertTransaction(db, 201, 'PREPARED')
openReservation(
db,
{
id: 'res-atomic',
transactionId: 201,
mintUrl: 'https://mint.test',
unit: 'sat',
operationType: 'send-direct',
lockedProofs: [{secret: 'input', originalState: 'UNSPENT', originalTId: null}],
},
['input'],
)
// Force a failure mid-batch by attempting to insert a duplicate
// reservation id alongside a valid tx update. SQLite rejects the
// whole batch.
expect(() => {
db.exec('BEGIN')
try {
db.prepare(`UPDATE proofs SET state = 'SPENT' WHERE secret = ?`).run('input')
db.prepare(`UPDATE transactions SET status = 'COMPLETED' WHERE id = ?`).run(201)
// This will conflict — reservation 'res-atomic' already exists
db.prepare(
`INSERT INTO reservations (id, transactionId, mintUrl, unit, operationType, lockedProofs, createdAt)
VALUES ('res-atomic', 999, '', '', '', '[]', '')`,
).run()
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
}).toThrow()
// Neither write survives — proof and tx are both in their
// pre-attempt state. This is the core safety property: if any
// statement in the batch fails, SQLite rolls back the entire txn.
expect(getProofState(db, 'input')).toBe('PENDING') // still locked
expect(getTransactionStatus(db, 201)).toBe('PREPARED') // still pre-finalize
db.close()
})
test('commit without transactionUpdate leaves transactions table untouched', () => {
const db = new DatabaseSync(':memory:')
createSchema(db)
insertProof(db, 'p1', 100)
insertTransaction(db, 202, 'PENDING')
openReservation(
db,
{
id: 'res-no-tx',
transactionId: 202,
mintUrl: 'https://mint.test',
unit: 'sat',
operationType: 'send-direct',
lockedProofs: [{secret: 'p1', originalState: 'UNSPENT', originalTId: null}],
},
['p1'],
)
commitReservation(db, 'res-no-tx', {toSpent: ['p1']})
expect(getProofState(db, 'p1')).toBe('SPENT')
// Transaction status untouched — the caller is responsible for
// any post-commit updates that don't need atomicity.
expect(getTransactionStatus(db, 202)).toBe('PENDING')
db.close()
})
test('partial transactionUpdate only sets the provided columns', () => {
const db = new DatabaseSync(':memory:')
createSchema(db)
insertProof(db, 'p2', 100)
db.prepare(
`INSERT INTO transactions (id, status, data, balanceAfter)
VALUES (203, 'PREPARED', 'old-data', 999)`,
).run()
openReservation(
db,
{
id: 'res-partial',
transactionId: 203,
mintUrl: 'https://mint.test',
unit: 'sat',
operationType: 'revert',
lockedProofs: [{secret: 'p2', originalState: 'PENDING', originalTId: 203}],
},
['p2'],
)
// Only update status — data and balanceAfter must remain as-is.
commitReservation(db, 'res-partial', {
toSpent: ['p2'],
transactionUpdate: {id: 203, status: 'REVERTED'},
})
const row = getTransactionRow(db, 203)!
expect(row.status).toBe('REVERTED')
expect(row.data).toBe('old-data')
expect(row.balanceAfter).toBe(999)
db.close()
})
})
}) })
+125
View File
@@ -0,0 +1,125 @@
/**
* Receive (cashu-token) operation lifecycle — API contract tests.
*
* Same pattern as the other lifecycle suites: jest pins down the discriminated
* union, the API surface, and the PreparedReceiveData shape. Runtime
* orchestration is verified on device.
*
* @jest-environment node
*/
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 type {
ReceiveMethod,
ReceiveMethodInput,
ReceiveMethodOptions,
ReceiveMethodPayload,
} from '../src/services/wallet/operations/receiveMethods'
import type {Token} from '@cashu/cashu-ts'
const stubToken = {} as Token
describe('ReceiveMethod discriminator', () => {
test('ReceiveMethod is a union of the registered keys', () => {
const knownMethods: ReceiveMethod[] = ['cashu-token']
expect(knownMethods).toEqual(['cashu-token'])
})
test('ReceiveMethodPayload narrows by method', () => {
const cashuToken: ReceiveMethodPayload<'cashu-token'> = {
token: stubToken,
encodedToken: 'cashuA...',
}
expect(cashuToken.encodedToken).toBe('cashuA...')
expect(cashuToken.offline).toBeUndefined()
const offline: ReceiveMethodPayload<'cashu-token'> = {
token: stubToken,
encodedToken: 'cashuA...',
offline: true,
}
expect(offline.offline).toBe(true)
})
test('ReceiveMethodInput is a tagged union', () => {
const input: ReceiveMethodInput = {
method: 'cashu-token',
options: {token: stubToken, encodedToken: 'cashuA...'},
}
expect(input.method).toBe('cashu-token')
if (input.method === 'cashu-token') {
// TS knows options has encodedToken here
expect(input.options.encodedToken).toBe('cashuA...')
}
})
test('compile-time: payload typed by method discriminator', () => {
// OK
const a: ReceiveMethodInput = {
method: 'cashu-token',
options: {token: stubToken, encodedToken: 'x'},
}
// @ts-expect-error — cashu-token options missing required encodedToken
const b: ReceiveMethodInput = {method: 'cashu-token', options: {token: stubToken}}
// @ts-expect-error — unknown method tag
const c: ReceiveMethodInput = {method: 'nut-18', options: {}}
expect(a.method).toBe('cashu-token')
expect(b.method).toBe('cashu-token')
expect(c.method).toBe('nut-18')
})
test('ReceiveMethodOptions interface keys match ReceiveMethod union', () => {
const optionKeys: Array<keyof ReceiveMethodOptions> = ['cashu-token']
const methodKeys: ReceiveMethod[] = ['cashu-token']
expect(optionKeys.sort()).toEqual(methodKeys.sort())
})
})
describe('ReceiveOperationApi surface (compile-time)', () => {
test('imports without runtime errors', () => {
type Api = typeof import('../src/services/wallet/operations/receiveOperationApi').ReceiveOperationApi
type Methods = keyof Api
const expected: Methods[] = [
'prepare',
'execute',
'cancel',
'reclaim',
'finalize',
'refresh',
]
expect(expected).toHaveLength(6)
})
test('PreparedReceiveData shape pins the fields execute() depends on', () => {
type PRD = import('../src/services/wallet/operations/receiveOperationApi').PreparedReceiveData
type RequiredKeys = keyof PRD
const requiredKeys: RequiredKeys[] = [
'transactionId',
'tx',
'mintUrl',
'unit',
'amountToReceive',
'memo',
'blocked',
'isOffline',
'method',
]
expect(requiredKeys).toHaveLength(9)
})
})
+151
View File
@@ -0,0 +1,151 @@
/**
* Send operation lifecycle — API contract tests.
*
* The orchestration body of `SendOperationApi.prepare/execute` exercises
* MST stores, SQLite, and cashu-ts mint calls — that's an end-to-end path
* best validated on a live device (see Step 6 of the lifecycle plan).
*
* What jest can meaningfully pin down without the full runtime is:
* 1. The discriminated union for send methods narrows correctly.
* 2. The exported API surface has the expected methods.
* 3. The PreparedSendData / SendPath shapes are stable.
*
* These are compile-time-mostly tests, enforced by `@ts-expect-error` and
* structural assertions. If a future refactor weakens the types, this file
* fails to compile — same trick used in `typedEventBus.test.ts`.
*
* @jest-environment node
*/
// Same module-graph mocks as other deep-import tests — see proofReservation.test.ts.
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 type {
SendMethod,
SendMethodInput,
SendMethodOptions,
SendMethodPayload,
} from '../src/services/wallet/operations/sendMethods'
describe('SendMethod discriminator', () => {
test('SendMethod is a union of the registered keys', () => {
// Runtime tag check — the keys at compile time must include these.
const knownMethods: SendMethod[] = ['default', 'p2pk']
expect(knownMethods).toEqual(['default', 'p2pk'])
})
test('SendMethodPayload narrows by method', () => {
// p2pk requires pubkey
const p2pk: SendMethodPayload<'p2pk'> = {pubkey: '02ab…'}
expect(p2pk.pubkey).toBe('02ab…')
// default is empty
const def: SendMethodPayload<'default'> = {}
expect(Object.keys(def)).toHaveLength(0)
})
test('SendMethodInput is a tagged union', () => {
const inputDefault: SendMethodInput = {method: 'default', options: {}}
const inputP2pk: SendMethodInput = {
method: 'p2pk',
options: {pubkey: '02ab…', locktime: 1700000000},
}
expect(inputDefault.method).toBe('default')
expect(inputP2pk.method).toBe('p2pk')
// Narrowing on method tag refines options:
if (inputP2pk.method === 'p2pk') {
// TS knows options has pubkey here
expect(inputP2pk.options.pubkey).toBe('02ab…')
}
})
test('compile-time: payload typed by method discriminator', () => {
// The four lines below would all fail to compile if the types
// weakened. They compile clean as written.
// OK — default with empty options
const a: SendMethodInput = {method: 'default', options: {}}
// OK — p2pk with required pubkey
const b: SendMethodInput = {method: 'p2pk', options: {pubkey: 'x'}}
// @ts-expect-error — p2pk options missing required pubkey
const c: SendMethodInput = {method: 'p2pk', options: {}}
// @ts-expect-error — default cannot have arbitrary fields
const d: SendMethodInput = {method: 'default', options: {pubkey: 'x'}}
// @ts-expect-error — unknown method tag
const e: SendMethodInput = {method: 'htlc', options: {}}
// Use them so TS doesn't flag as unused.
expect(a.method).toBe('default')
expect(b.method).toBe('p2pk')
expect(c.method).toBe('p2pk')
expect(d.method).toBe('default')
expect(e.method).toBe('htlc')
})
test('SendMethodOptions interface keys match SendMethod union', () => {
// Sanity: every method we claim exists must have an options entry.
const optionKeys: Array<keyof SendMethodOptions> = ['default', 'p2pk']
const methodKeys: SendMethod[] = ['default', 'p2pk']
expect(optionKeys.sort()).toEqual(methodKeys.sort())
})
})
describe('SendOperationApi surface (compile-time)', () => {
test('imports without runtime errors', () => {
// The api file imports MST stores at load time; we only do a type-only
// import here so jest doesn't try to bring up the root store.
type Api = typeof import('../src/services/wallet/operations/sendOperationApi').SendOperationApi
type Methods = keyof Api
// If any method is removed or renamed, this Methods union shrinks and
// the assertion below fails to typecheck.
const expected: Methods[] = [
'prepare',
'execute',
'cancel',
'reclaim',
'finalize',
'refresh',
]
expect(expected).toHaveLength(6)
})
test('PreparedSendData shape pins the fields execute() depends on', () => {
type PSD = import('../src/services/wallet/operations/sendOperationApi').PreparedSendData
// Required keys — removing any breaks execute() callers.
type RequiredKeys = keyof PSD
const requiredKeys: RequiredKeys[] = [
'transactionId',
'tx',
'sendAmount',
'swapFeeReserve',
'needsSwap',
'path',
'method',
'mintUrl',
'unit',
'lockedProofs',
]
expect(requiredKeys).toHaveLength(10)
})
test('SendPath union covers exactly the three implemented branches', () => {
type SendPath = import('../src/services/wallet/operations/sendOperationApi').SendPath
const paths: SendPath[] = ['offline', 'online-no-swap', 'online-swap']
expect(paths).toHaveLength(3)
})
})
+119
View File
@@ -0,0 +1,119 @@
/**
* Topup (lightning mint) operation lifecycle — API contract tests.
*
* Same trick as the SEND and TRANSFER lifecycle tests: jest pins down what it
* can verify without bringing up MST stores / cashu-ts / SQLite:
*
* 1. The discriminated union for topup methods narrows correctly.
* 2. The exported API surface has the expected methods.
* 3. The PreparedTopupData shape is stable.
*
* Compile-time regressions break the build; runtime orchestration is verified
* on device.
*
* @jest-environment node
*/
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 type {
TopupMethod,
TopupMethodInput,
TopupMethodOptions,
TopupMethodPayload,
} from '../src/services/wallet/operations/topupMethods'
describe('TopupMethod discriminator', () => {
test('TopupMethod is a union of the registered keys', () => {
const knownMethods: TopupMethod[] = ['bolt11']
expect(knownMethods).toEqual(['bolt11'])
})
test('TopupMethodPayload narrows by method', () => {
// bolt11 options are all optional (contactToSendTo only)
const bolt11Empty: TopupMethodPayload<'bolt11'> = {}
expect(Object.keys(bolt11Empty)).toHaveLength(0)
const bolt11WithContact: TopupMethodPayload<'bolt11'> = {
contactToSendTo: undefined,
}
expect(bolt11WithContact.contactToSendTo).toBeUndefined()
})
test('TopupMethodInput is a tagged union', () => {
const inputBolt11: TopupMethodInput = {method: 'bolt11', options: {}}
expect(inputBolt11.method).toBe('bolt11')
// Narrowing on method tag refines options:
if (inputBolt11.method === 'bolt11') {
// TS knows options has contactToSendTo (optional) here
expect(inputBolt11.options.contactToSendTo).toBeUndefined()
}
})
test('compile-time: unknown method tag rejected', () => {
// OK — bolt11 with empty options
const a: TopupMethodInput = {method: 'bolt11', options: {}}
// @ts-expect-error — unknown method tag
const b: TopupMethodInput = {method: 'onchain', options: {}}
// Use them so TS doesn't flag as unused.
expect(a.method).toBe('bolt11')
expect(b.method).toBe('onchain')
})
test('TopupMethodOptions interface keys match TopupMethod union', () => {
const optionKeys: Array<keyof TopupMethodOptions> = ['bolt11']
const methodKeys: TopupMethod[] = ['bolt11']
expect(optionKeys.sort()).toEqual(methodKeys.sort())
})
})
describe('TopupOperationApi surface (compile-time)', () => {
test('imports without runtime errors', () => {
// Type-only import — avoids bringing up the root store in jest.
type Api = typeof import('../src/services/wallet/operations/topupOperationApi').TopupOperationApi
type Methods = keyof Api
// If any method is removed or renamed, this Methods union shrinks and
// the assertion below fails to typecheck.
const expected: Methods[] = [
'prepare',
'execute',
'cancel',
'reclaim',
'finalize',
'refresh',
]
expect(expected).toHaveLength(6)
})
test('PreparedTopupData shape pins the fields execute() depends on', () => {
type PTD = import('../src/services/wallet/operations/topupOperationApi').PreparedTopupData
type RequiredKeys = keyof PTD
const requiredKeys: RequiredKeys[] = [
'transactionId',
'tx',
'mintUrl',
'unit',
'amountToTopup',
'quote',
'encodedInvoice',
'expiresAt',
'method',
'nwcEvent',
]
expect(requiredKeys).toHaveLength(10)
})
})
+171
View File
@@ -0,0 +1,171 @@
/**
* Type guard / state classification tests for TransactionStates.ts.
*
* Type guards are runtime functions that also narrow TypeScript's view.
* These tests verify the runtime side (correct boolean per status) and
* exhaustiveness of the categorical sets (terminal / in-flight / rollbackable).
*
* @jest-environment node
*/
// Transaction.ts -> services -> logService -> @sentry/react-native (ESM, not transformed).
// Mock the deep import chain so the test stays pure type/enum-only.
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'},
}))
jest.mock('../src/services', () => ({
log: {
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
trace: jest.fn(),
warn: jest.fn(),
},
Database: {},
}))
import {TransactionStatus} from '../src/models/Transaction'
import type {Transaction} from '../src/models/Transaction'
import {
isBlocked,
isCompleted,
isDraft,
isErrored,
isExecuting,
isExpired,
isInFlight,
isPending,
isPrepared,
isRecovered,
isReverted,
isRollbackable,
isRollingBack,
isTerminal,
} from '../src/models/TransactionStates'
/** Tiny helper — construct just enough of a Transaction shape for guard tests. */
function txWith(status: TransactionStatus): Transaction {
return {status} as unknown as Transaction
}
// All known statuses — keep in sync with the enum so we catch additions.
const ALL_STATUSES: TransactionStatus[] = [
TransactionStatus.DRAFT,
TransactionStatus.PREPARED,
TransactionStatus.PREPARED_OFFLINE,
TransactionStatus.EXECUTING,
TransactionStatus.PENDING,
TransactionStatus.ROLLING_BACK,
TransactionStatus.REVERTED,
TransactionStatus.RECOVERED,
TransactionStatus.COMPLETED,
TransactionStatus.ERROR,
TransactionStatus.BLOCKED,
TransactionStatus.EXPIRED,
]
describe('TransactionStatus enum', () => {
test('contains the two new lifecycle states', () => {
expect(TransactionStatus.EXECUTING).toBe('EXECUTING')
expect(TransactionStatus.ROLLING_BACK).toBe('ROLLING_BACK')
})
test('test fixture covers every status the enum exposes', () => {
const enumValues = new Set(Object.values(TransactionStatus))
for (const s of ALL_STATUSES) expect(enumValues.has(s)).toBe(true)
expect(ALL_STATUSES.length).toBe(enumValues.size)
})
})
describe('Per-state type guards', () => {
test.each([
['isDraft', isDraft, TransactionStatus.DRAFT],
['isExecuting', isExecuting, TransactionStatus.EXECUTING],
['isPending', isPending, TransactionStatus.PENDING],
['isRollingBack', isRollingBack, TransactionStatus.ROLLING_BACK],
['isReverted', isReverted, TransactionStatus.REVERTED],
['isCompleted', isCompleted, TransactionStatus.COMPLETED],
['isErrored', isErrored, TransactionStatus.ERROR],
['isExpired', isExpired, TransactionStatus.EXPIRED],
['isBlocked', isBlocked, TransactionStatus.BLOCKED],
['isRecovered', isRecovered, TransactionStatus.RECOVERED],
])('%s returns true only for its own status', (_name, guard, ownStatus) => {
for (const s of ALL_STATUSES) {
const expected = s === ownStatus
expect(guard(txWith(s))).toBe(expected)
}
})
test('isPrepared returns true for both PREPARED and PREPARED_OFFLINE', () => {
for (const s of ALL_STATUSES) {
const expected =
s === TransactionStatus.PREPARED || s === TransactionStatus.PREPARED_OFFLINE
expect(isPrepared(txWith(s))).toBe(expected)
}
})
})
describe('Categorical guards', () => {
test('isTerminal matches exactly the terminal-status set', () => {
const terminal = new Set([
TransactionStatus.COMPLETED,
TransactionStatus.REVERTED,
TransactionStatus.ERROR,
TransactionStatus.EXPIRED,
TransactionStatus.BLOCKED,
TransactionStatus.RECOVERED,
])
for (const s of ALL_STATUSES) {
expect(isTerminal(txWith(s))).toBe(terminal.has(s))
}
})
test('isInFlight matches exactly the in-flight-status set', () => {
const inFlight = new Set([
TransactionStatus.DRAFT,
TransactionStatus.PREPARED,
TransactionStatus.PREPARED_OFFLINE,
TransactionStatus.EXECUTING,
TransactionStatus.PENDING,
TransactionStatus.ROLLING_BACK,
])
for (const s of ALL_STATUSES) {
expect(isInFlight(txWith(s))).toBe(inFlight.has(s))
}
})
test('isRollbackable matches PREPARED variants and PENDING', () => {
const rollbackable = new Set([
TransactionStatus.PREPARED,
TransactionStatus.PREPARED_OFFLINE,
TransactionStatus.PENDING,
])
for (const s of ALL_STATUSES) {
expect(isRollbackable(txWith(s))).toBe(rollbackable.has(s))
}
})
test('terminal and in-flight are mutually exclusive and exhaustive', () => {
for (const s of ALL_STATUSES) {
const tx = txWith(s)
const terminal = isTerminal(tx)
const inFlight = isInFlight(tx)
// exactly one of the two must hold for every defined status
expect(terminal !== inFlight).toBe(true)
}
})
test('every rollbackable transaction is also in-flight', () => {
for (const s of ALL_STATUSES) {
const tx = txWith(s)
if (isRollbackable(tx)) expect(isInFlight(tx)).toBe(true)
}
})
})
@@ -0,0 +1,159 @@
/**
* Transfer (lightning melt) operation lifecycle — API contract tests.
*
* The orchestration body of `TransferOperationApi.prepare/execute` exercises
* MST stores, SQLite, and cashu-ts mint calls — best validated on a live
* device. Here we lock down at compile-time what jest can meaningfully pin:
*
* 1. The discriminated union for transfer methods narrows correctly.
* 2. The exported API surface has the expected methods.
* 3. The PreparedTransferData / TransferPath shapes are stable.
*
* Same trick as `sendOperationLifecycle.test.ts`: a type regression breaks
* the build.
*
* @jest-environment node
*/
// Same module-graph mocks as the other deep-import tests.
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 type {
TransferMethod,
TransferMethodInput,
TransferMethodOptions,
TransferMethodPayload,
} from '../src/services/wallet/operations/transferMethods'
import type {MeltQuoteBolt11Response} from '@cashu/cashu-ts'
// A minimal stub matching the bolt11 method options. The mint quote shape
// is loosely typed in cashu-ts (BigNumber-like for fee_reserve etc.); we
// only need a value-shaped object that satisfies TypeScript here.
const stubMeltQuote = {} as MeltQuoteBolt11Response
describe('TransferMethod discriminator', () => {
test('TransferMethod is a union of the registered keys', () => {
const knownMethods: TransferMethod[] = ['bolt11']
expect(knownMethods).toEqual(['bolt11'])
})
test('TransferMethodPayload narrows by method', () => {
const bolt11: TransferMethodPayload<'bolt11'> = {
encodedInvoice: 'lnbc...',
meltQuote: stubMeltQuote,
invoiceExpiry: new Date(),
}
expect(bolt11.encodedInvoice).toBe('lnbc...')
expect(bolt11.meltQuote).toBe(stubMeltQuote)
})
test('TransferMethodInput is a tagged union', () => {
const inputBolt11: TransferMethodInput = {
method: 'bolt11',
options: {
encodedInvoice: 'lnbc...',
meltQuote: stubMeltQuote,
invoiceExpiry: new Date(),
},
}
expect(inputBolt11.method).toBe('bolt11')
// Narrowing on method tag refines options:
if (inputBolt11.method === 'bolt11') {
// TS knows options has encodedInvoice here
expect(inputBolt11.options.encodedInvoice).toBe('lnbc...')
}
})
test('compile-time: payload typed by method discriminator', () => {
// The lines below would all fail to compile if the types weakened.
// They compile clean as written.
// OK — bolt11 with required fields
const a: TransferMethodInput = {
method: 'bolt11',
options: {
encodedInvoice: 'lnbc...',
meltQuote: stubMeltQuote,
invoiceExpiry: new Date(),
},
}
// @ts-expect-error — bolt11 options missing required encodedInvoice
const b: TransferMethodInput = {method: 'bolt11', options: {meltQuote: stubMeltQuote, invoiceExpiry: new Date()}}
// @ts-expect-error — unknown method tag
const c: TransferMethodInput = {method: 'onchain', options: {}}
// Use them so TS doesn't flag as unused.
expect(a.method).toBe('bolt11')
expect(b.method).toBe('bolt11')
expect(c.method).toBe('onchain')
})
test('TransferMethodOptions interface keys match TransferMethod union', () => {
// Sanity: every method we claim exists must have an options entry.
const optionKeys: Array<keyof TransferMethodOptions> = ['bolt11']
const methodKeys: TransferMethod[] = ['bolt11']
expect(optionKeys.sort()).toEqual(methodKeys.sort())
})
})
describe('TransferOperationApi surface (compile-time)', () => {
test('imports without runtime errors', () => {
// Type-only import — avoids bringing up the root store in jest.
type Api = typeof import('../src/services/wallet/operations/transferOperationApi').TransferOperationApi
type Methods = keyof Api
// If any method is removed or renamed, this Methods union shrinks
// and the assertion below fails to typecheck.
const expected: Methods[] = [
'prepare',
'execute',
'cancel',
'reclaim',
'finalize',
'refresh',
]
expect(expected).toHaveLength(6)
})
test('PreparedTransferData shape pins the fields execute() depends on', () => {
type PTD = import('../src/services/wallet/operations/transferOperationApi').PreparedTransferData
type RequiredKeys = keyof PTD
const requiredKeys: RequiredKeys[] = [
'transactionId',
'tx',
'mintUrl',
'unit',
'amountToTransfer',
'meltQuote',
'invoiceExpiry',
'path',
'method',
'proofsToMeltFrom',
'proofsToMeltFromAmount',
'meltFeeReserve',
'lightningFeeReserve',
'preemptiveSwapFeePaid',
'nwcEvent',
]
expect(requiredKeys).toHaveLength(15)
})
test('TransferPath union covers exactly the two implemented branches', () => {
type TransferPath = import('../src/services/wallet/operations/transferOperationApi').TransferPath
const paths: TransferPath[] = ['direct-melt', 'preemptive-swap-then-melt']
expect(paths).toHaveLength(2)
})
})
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Typed error hierarchy tests (Phase 2 of coco alignment). * Typed error hierarchy tests (Phase 2).
* *
* Verifies: * Verifies:
* - subclass instances are still instanceof AppError (backward compat) * - subclass instances are still instanceof AppError (backward compat)
+32 -2
View File
@@ -12,6 +12,7 @@ import {
import AppError, { Err } from '../utils/AppError' import AppError, { Err } from '../utils/AppError'
import { Mint, MintBalance } from './Mint' import { Mint, MintBalance } from './Mint'
import { Database } from '../services' import { Database } from '../services'
import { ReservationTransactionUpdate } from '../services/sqlite'
import { MintUnit } from '../services/wallet/currency' import { MintUnit } from '../services/wallet/currency'
import { CashuProof } from '../services/cashu/cashuUtils' import { CashuProof } from '../services/cashu/cashuUtils'
import { generateId } from '../utils/utils' import { generateId } from '../utils/utils'
@@ -402,6 +403,12 @@ import {
state: ProofState state: ProofState
tId: number tId: number
}> }>
/**
* Atomically apply a transaction-row update inside the same
* SQLite batch as the proof-state finalize. Closes the
* proofs-table ↔ transactions-table atomicity window.
*/
transactionUpdate?: ReservationTransactionUpdate
} = {}, } = {},
): { added: Proof[] } { ): { added: Proof[] } {
const mintsStore = getRootStore(self).mintsStore const mintsStore = getRootStore(self).mintsStore
@@ -413,7 +420,8 @@ import {
}) })
} }
// ATOMIC SQLite write of every state transition + reservation deletion. // ATOMIC SQLite write of every state transition + (optional)
// transaction-row update + reservation deletion.
Database.commitReservation(reservation.id, { Database.commitReservation(reservation.id, {
toSpent: changes.toSpent, toSpent: changes.toSpent,
toUnspent: changes.toUnspent, toUnspent: changes.toUnspent,
@@ -424,6 +432,7 @@ import {
unit: reservation.unit, unit: reservation.unit,
tId: group.tId, tId: group.tId,
})), })),
transactionUpdate: changes.transactionUpdate,
}) })
// Mirror to MST now that SQLite is durable. // Mirror to MST now that SQLite is durable.
@@ -491,11 +500,32 @@ import {
counter?.increaseProofsCounter(addedProofs.length) counter?.increaseProofsCounter(addedProofs.length)
} }
log.trace('[commitReservation]', 'Reservation committed', { // Mirror the (already-durable) transaction update to MST so the
// in-memory model reflects the new tx state immediately. Uses
// setProp to avoid re-writing SQLite (Database.commitReservation
// already wrote the UPDATE atomically with the proof batch).
if (changes.transactionUpdate) {
const tu = changes.transactionUpdate
const transactionsStore = getRootStore(self).transactionsStore
const tx = transactionsStore.findById(tu.id)
if (tx && isAlive(tx)) {
if (tu.status !== undefined) tx.setProp('status', tu.status)
if (tu.data !== undefined) tx.setProp('data', tu.data)
if (tu.amount !== undefined) tx.setProp('amount', tu.amount)
if (tu.fee !== undefined) tx.setProp('fee', tu.fee)
if (tu.balanceAfter !== undefined) tx.setProp('balanceAfter', tu.balanceAfter)
if (tu.outputToken !== undefined) tx.setProp('outputToken', tu.outputToken)
if (tu.keysetId !== undefined) tx.setProp('keysetId', tu.keysetId)
if (tu.proof !== undefined) tx.setProp('proof', tu.proof)
}
}
log.trace('[commitReservation] ', 'Reservation committed', {
id: reservation.id, id: reservation.id,
toSpent: changes.toSpent?.length ?? 0, toSpent: changes.toSpent?.length ?? 0,
toUnspent: changes.toUnspent?.length ?? 0, toUnspent: changes.toUnspent?.length ?? 0,
addedCount: added.length, addedCount: added.length,
txUpdate: changes.transactionUpdate?.id,
}) })
return { added } return { added }
+16 -1
View File
@@ -2,6 +2,7 @@ import { flow, Instance, isAlive, SnapshotIn, SnapshotOut, types } from 'mobx-st
import { log } from '../services/logService' import { log } from '../services/logService'
import { MintUnit } from '../services/wallet/currency' import { MintUnit } from '../services/wallet/currency'
import { Database } from '../services' import { Database } from '../services'
import { withSetPropAction } from './helpers/withSetPropAction'
export type TransactionData = { export type TransactionData = {
@@ -22,8 +23,21 @@ export enum TransactionType {
export enum TransactionStatus { export enum TransactionStatus {
DRAFT = 'DRAFT', DRAFT = 'DRAFT',
PREPARED = 'PREPARED', PREPARED = 'PREPARED',
PREPARED_OFFLINE = 'PREPARED_OFFLINE', // offline receive, safer to have if separate from prepared PREPARED_OFFLINE = 'PREPARED_OFFLINE', // offline receive, safer to have if separate from prepared
/**
* Mint call in flight, can't safely interrupt. Used by lifecycle-aware
* operations to mark the brief window between proof reservation and
* commit. On crash recovery, EXECUTING transactions need explicit
* reconciliation (the mint may or may not have processed the call).
*/
EXECUTING = 'EXECUTING',
PENDING = 'PENDING', PENDING = 'PENDING',
/**
* Transient rollback state — proofs are being reclaimed (via reclaim swap
* for PENDING ops, or via reservation rollback for PREPARED ops). On
* crash recovery during ROLLING_BACK, manual intervention may be needed.
*/
ROLLING_BACK = 'ROLLING_BACK',
REVERTED = 'REVERTED', REVERTED = 'REVERTED',
RECOVERED = 'RECOVERED', RECOVERED = 'RECOVERED',
COMPLETED = 'COMPLETED', COMPLETED = 'COMPLETED',
@@ -61,6 +75,7 @@ export const TransactionModel = types
createdAt: types.optional(types.Date, new Date()), createdAt: types.optional(types.Date, new Date()),
}) })
.views(self => ({})) .views(self => ({}))
.actions(withSetPropAction)
.actions(self => ({ .actions(self => ({
pruneInputToken(inputToken: string) { pruneInputToken(inputToken: string) {
self.inputToken = inputToken.slice(0, 40) self.inputToken = inputToken.slice(0, 40)
+232
View File
@@ -0,0 +1,232 @@
/**
* Typed transaction states (Phase: operation lifecycle).
*
* The Transaction model has a single `status` field whose value is one of
* `TransactionStatus`. This file layers a TypeScript-level state machine on
* top of the loose enum: a discriminated union by `status`, type guards that
* narrow at use sites, and categorical groupings (terminal, in-flight, etc.).
*
* No runtime change — these types refine TypeScript's view of existing
* Transaction instances. MST instances remain loose; narrowing happens at
* call sites that opt in.
*
* ```typescript
* const tx = transactionsStore.findById(id)
* if (!tx) return
*
* if (isPrepared(tx)) {
* // tx is PreparedTransaction here — TypeScript knows tx.status is one
* // of the PREPARED variants, and any prepare-phase-specific code can
* // assume the invariants documented on PreparedTransaction.
* }
* ```
*
* Field tightening per state is intentionally minimal in this base module.
* Operation-specific stronger guarantees (e.g. SEND's PendingTransaction has
* an outputToken) live closer to the operation API, layered on top.
*/
import {Transaction, TransactionStatus} from './Transaction'
// ─────────────────────────────────────────────────────────────────────────────
// Per-state intersection types
//
// Each is `Transaction & { status: <specific status value> }`. At runtime
// they're plain Transaction instances; the types differ only at compile
// time so guards can narrow.
// ─────────────────────────────────────────────────────────────────────────────
/** Just-created transaction; nothing reserved yet, no mint contact. */
export type DraftTransaction = Transaction & {
status: TransactionStatus.DRAFT
}
/**
* Proofs (for outgoing ops) or expectations (for incoming ops) reserved.
* Mint not yet contacted in the synchronous path. Safe to `cancel`.
*
* Covers both `PREPARED` and `PREPARED_OFFLINE` — the latter is used by
* offline-receive flows where preparation completes without the mint.
*/
export type PreparedTransaction = Transaction & {
status: TransactionStatus.PREPARED | TransactionStatus.PREPARED_OFFLINE
}
/**
* Mint call in flight. Cannot safely cancel from this state (the mint may
* have already processed the call). Crash recovery should reconcile by
* checking the mint, not by rolling back blindly.
*/
export type ExecutingTransaction = Transaction & {
status: TransactionStatus.EXECUTING
}
/**
* Operation accepted by the mint; awaiting settlement (claim by recipient,
* lightning settlement, future onchain confirmations). May still be
* rolled back via `reclaim` for SEND ops with method support.
*/
export type PendingTransaction = Transaction & {
status: TransactionStatus.PENDING
}
/**
* Transient state during rollback of a PENDING op (reclaim swap in flight).
* Crash here requires manual intervention or seed-based recovery.
*/
export type RollingBackTransaction = Transaction & {
status: TransactionStatus.ROLLING_BACK
}
/** Terminal — operation reversed by user/system; ecash back to spendable. */
export type RevertedTransaction = Transaction & {
status: TransactionStatus.REVERTED
}
/** Terminal — ecash settled / received successfully. */
export type CompletedTransaction = Transaction & {
status: TransactionStatus.COMPLETED
}
/** Terminal — failed; user action may be required. */
export type ErrorTransaction = Transaction & {
status: TransactionStatus.ERROR
}
/** Terminal — invoice/quote expired (lightning-specific). */
export type ExpiredTransaction = Transaction & {
status: TransactionStatus.EXPIRED
}
/** Terminal — input rejected (e.g. duplicate token, untrusted mint). */
export type BlockedTransaction = Transaction & {
status: TransactionStatus.BLOCKED
}
/** Terminal — operation recovered from a stuck state (e.g. mint quote PAID after expiry). */
export type RecoveredTransaction = Transaction & {
status: TransactionStatus.RECOVERED
}
// ─────────────────────────────────────────────────────────────────────────────
// Categorical unions
// ─────────────────────────────────────────────────────────────────────────────
/**
* Operation has reached a final state — no further automatic transitions.
* Sweeps and recovery should ignore these.
*/
export type TerminalTransaction =
| CompletedTransaction
| RevertedTransaction
| ErrorTransaction
| ExpiredTransaction
| BlockedTransaction
| RecoveredTransaction
/**
* Operation is in flight — needs continued attention from sweeps,
* websockets, or user action.
*/
export type InFlightTransaction =
| DraftTransaction
| PreparedTransaction
| ExecutingTransaction
| PendingTransaction
| RollingBackTransaction
/**
* Operation can transition to `REVERTED`:
* - `PREPARED`: via `cancel` (no mint call yet, just release reservation)
* - `PENDING`: via `reclaim` (swap the locked proofs for fresh ones)
*/
export type RollbackableTransaction = PreparedTransaction | PendingTransaction
// ─────────────────────────────────────────────────────────────────────────────
// Type guards
//
// Each guard returns true when the transaction's status matches and refines
// the caller's view to the corresponding typed variant.
// ─────────────────────────────────────────────────────────────────────────────
export function isDraft(tx: Transaction): tx is DraftTransaction {
return tx.status === TransactionStatus.DRAFT
}
export function isPrepared(tx: Transaction): tx is PreparedTransaction {
return (
tx.status === TransactionStatus.PREPARED ||
tx.status === TransactionStatus.PREPARED_OFFLINE
)
}
export function isExecuting(tx: Transaction): tx is ExecutingTransaction {
return tx.status === TransactionStatus.EXECUTING
}
export function isPending(tx: Transaction): tx is PendingTransaction {
return tx.status === TransactionStatus.PENDING
}
export function isRollingBack(tx: Transaction): tx is RollingBackTransaction {
return tx.status === TransactionStatus.ROLLING_BACK
}
export function isReverted(tx: Transaction): tx is RevertedTransaction {
return tx.status === TransactionStatus.REVERTED
}
export function isCompleted(tx: Transaction): tx is CompletedTransaction {
return tx.status === TransactionStatus.COMPLETED
}
export function isErrored(tx: Transaction): tx is ErrorTransaction {
return tx.status === TransactionStatus.ERROR
}
export function isExpired(tx: Transaction): tx is ExpiredTransaction {
return tx.status === TransactionStatus.EXPIRED
}
export function isBlocked(tx: Transaction): tx is BlockedTransaction {
return tx.status === TransactionStatus.BLOCKED
}
export function isRecovered(tx: Transaction): tx is RecoveredTransaction {
return tx.status === TransactionStatus.RECOVERED
}
const TERMINAL_STATUSES: ReadonlySet<TransactionStatus> = new Set([
TransactionStatus.COMPLETED,
TransactionStatus.REVERTED,
TransactionStatus.ERROR,
TransactionStatus.EXPIRED,
TransactionStatus.BLOCKED,
TransactionStatus.RECOVERED,
])
const IN_FLIGHT_STATUSES: ReadonlySet<TransactionStatus> = new Set([
TransactionStatus.DRAFT,
TransactionStatus.PREPARED,
TransactionStatus.PREPARED_OFFLINE,
TransactionStatus.EXECUTING,
TransactionStatus.PENDING,
TransactionStatus.ROLLING_BACK,
])
const ROLLBACKABLE_STATUSES: ReadonlySet<TransactionStatus> = new Set([
TransactionStatus.PREPARED,
TransactionStatus.PREPARED_OFFLINE,
TransactionStatus.PENDING,
])
export function isTerminal(tx: Transaction): tx is TerminalTransaction {
return TERMINAL_STATUSES.has(tx.status)
}
export function isInFlight(tx: Transaction): tx is InFlightTransaction {
return IN_FLIGHT_STATUSES.has(tx.status)
}
export function isRollbackable(tx: Transaction): tx is RollbackableTransaction {
return ROLLBACKABLE_STATUSES.has(tx.status)
}
+2 -2
View File
@@ -1092,7 +1092,7 @@ export const SendScreen = observer(function SendScreen({ route }: Props) {
const handleSendTaskResult = async (result: TransactionTaskResult) => { const handleSendTaskResult = async (result: TransactionTaskResult) => {
log.trace('[SendScreen] handleSendTaskResult start') log.trace('[SendScreen] handleSendTaskResult start')
const { transaction: txResult, error, encodedTokenToSend: token } = result const { transaction: txResult, error } = result
// ——— Error path ——— // ——— Error path ———
if (error || !txResult) { if (error || !txResult) {
@@ -1132,7 +1132,7 @@ export const SendScreen = observer(function SendScreen({ route }: Props) {
dispatch({ dispatch({
type: 'SEND_TASK_SUCCESS', type: 'SEND_TASK_SUCCESS',
token: token ?? '', token: txResult.outputToken ?? '',
transaction: txResult, transaction: txResult,
openTransport, openTransport,
}) })
+69 -4
View File
@@ -1290,8 +1290,33 @@ const openReservation = function (
} }
/** /**
* Commit a reservation: apply the supplied state transitions and delete the * Optional transaction-row update atomically batched with a reservation commit.
* reservation row — all in a single SQLite transaction. *
* Only the named columns can be set; this is intentionally narrower than the
* full Transaction shape so the API is predictable and only covers fields that
* legitimately need to land atomically with a proof-state finalize.
*/
export type ReservationTransactionUpdate = {
id: number
status?: TransactionStatus
data?: string
amount?: number
fee?: number
balanceAfter?: number
outputToken?: string
keysetId?: string
proof?: string
}
/**
* Commit a reservation: apply the supplied state transitions, optionally a
* transaction-row update, and delete the reservation row — all in a single
* SQLite transaction.
*
* Passing `transactionUpdate` closes the proofs-table vs transactions-table
* atomicity window: a crash between proof-state finalize and tx-status update
* would otherwise leave a transaction stuck in PENDING/PREPARED while its
* underlying proofs are SPENT.
*/ */
const commitReservation = function ( const commitReservation = function (
reservationId: string, reservationId: string,
@@ -1305,6 +1330,7 @@ const commitReservation = function (
unit: string unit: string
tId: number tId: number
}> }>
transactionUpdate?: ReservationTransactionUpdate
}, },
): void { ): void {
try { try {
@@ -1329,12 +1355,19 @@ const commitReservation = function (
for (const group of changes.newProofs ?? []) { for (const group of changes.newProofs ?? []) {
for (const proof of group.proofs) { for (const proof of group.proofs) {
// proof.amount may be a cashu-ts `Amount` class instance (when the
// group came straight from `cashuWallet.send/mint/melt` responses)
// rather than a plain number. Coerce explicitly — SQLite's JSI
// binding can't bind non-primitive objects to an INTEGER column and
// would silently drop the row, leaving the proof in MST but absent
// from the database (lost on the next restart).
const amount = typeof proof.amount === 'number' ? proof.amount : Number(proof.amount)
batch.push([ batch.push([
`INSERT OR REPLACE INTO proofs (id, amount, secret, C, dleq_r, dleq_s, dleq_e, unit, tId, mintUrl, state, updatedAt) `INSERT OR REPLACE INTO proofs (id, amount, secret, C, dleq_r, dleq_s, dleq_e, unit, tId, mintUrl, state, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
proof.id, proof.id,
proof.amount, amount,
proof.secret, proof.secret,
proof.C, proof.C,
proof.dleq ? proof.dleq.r : null, proof.dleq ? proof.dleq.r : null,
@@ -1350,16 +1383,48 @@ const commitReservation = function (
} }
} }
if (changes.transactionUpdate) {
const tu = changes.transactionUpdate
const setClauses: string[] = []
const params: (string | number | null)[] = []
// Whitelist of fields that can be set atomically. Order matters only for
// readability — params must match clause order.
const setIfDefined = (col: string, value: string | number | undefined) => {
if (value !== undefined) {
setClauses.push(`${col} = ?`)
params.push(value)
}
}
setIfDefined('status', tu.status)
setIfDefined('data', tu.data)
setIfDefined('amount', tu.amount)
setIfDefined('fee', tu.fee)
setIfDefined('balanceAfter', tu.balanceAfter)
setIfDefined('outputToken', tu.outputToken)
setIfDefined('keysetId', tu.keysetId)
setIfDefined('proof', tu.proof)
if (setClauses.length > 0) {
params.push(tu.id)
batch.push([
`UPDATE transactions SET ${setClauses.join(', ')} WHERE id = ?`,
params,
])
}
}
batch.push([`DELETE FROM reservations WHERE id = ?`, [reservationId]]) batch.push([`DELETE FROM reservations WHERE id = ?`, [reservationId]])
const db = getInstance() const db = getInstance()
db.executeBatch(batch) db.executeBatch(batch)
log.info('[commitReservation]', 'Reservation committed', { log.info('[commitReservation] ', 'Reservation committed to DB', {
id: reservationId, id: reservationId,
toSpent: changes.toSpent?.length ?? 0, toSpent: changes.toSpent?.length ?? 0,
toUnspent: changes.toUnspent?.length ?? 0, toUnspent: changes.toUnspent?.length ?? 0,
newGroups: changes.newProofs?.length ?? 0, newGroups: changes.newProofs?.length ?? 0,
txUpdate: changes.transactionUpdate ? changes.transactionUpdate.id : undefined,
}) })
} catch (e: any) { } catch (e: any) {
throw new AppError( throw new AppError(
+51 -91
View File
@@ -1,128 +1,88 @@
import {rootStoreInstance} from '../../models' import {rootStoreInstance} from '../../models'
import { TransactionTaskResult } from '../walletService' import {TransactionTaskResult} from '../walletService'
import { MintBalance } from '../../models/Mint' import {MintBalance} from '../../models/Mint'
import { Transaction, TransactionData, TransactionStatus, TransactionType } from '../../models/Transaction' import {TransactionData, TransactionStatus} from '../../models/Transaction'
import { log } from '../logService' import {log} from '../logService'
import { WalletUtils } from './utils' import {WalletUtils} from './utils'
import { MintUnit } from './currency' import {MintUnit} from './currency'
import { NostrClient } from '../nostrService'
import {
PaymentRequest as CashuPaymentRequest,
PaymentRequestTransport,
PaymentRequestTransportType
} from '@cashu/cashu-ts'
import QuickCrypto from 'react-native-quick-crypto'
const { const {transactionsStore} = rootStoreInstance
transactionsStore,
walletProfileStore,
relaysStore
} = rootStoreInstance
// const {walletStore} = nonPersistedStores
export const CASHU_PAYMENT_REQUEST_TASK = 'cashuPaymentRequestTask' export const CASHU_PAYMENT_REQUEST_TASK = 'cashuPaymentRequestTask'
/**
* Backward-compatible Cashu Payment Request creation task wrapper.
*
* Preserves the legacy `WalletTask.cashuPaymentRequestQueueAwaitable`
* contract: a single call that creates the draft, builds the encoded PR,
* and returns a `TransactionTaskResult` carrying the encoded string for the
* UI to display (QR / copy-paste).
*
* Internally delegates to the lifecycle API:
* `CashuPaymentRequestApi.prepare()` (DRAFT → PREPARED)
* `CashuPaymentRequestApi.execute()` (PREPARED → PENDING; builds PR)
*/
export const cashuPaymentRequestTask = async function ( export const cashuPaymentRequestTask = async function (
mintBalanceToReceiveTo: MintBalance, mintBalanceToReceiveTo: MintBalance,
amountToReceive: number, amountToReceive: number,
unit: MintUnit, unit: MintUnit,
memo: string, memo: string,
) : Promise<TransactionTaskResult> { ): Promise<TransactionTaskResult> {
log.info('[cashuPaymentRequestTask]', {mintBalanceToReceiveTo}) log.info('[cashuPaymentRequestTask]', {mintBalanceToReceiveTo})
log.info('[cashuPaymentRequestTask]', {amountToReceive, unit}) log.info('[cashuPaymentRequestTask]', {amountToReceive, unit})
// create draft transaction
const transactionData: TransactionData[] = [
{
status: TransactionStatus.DRAFT,
amountToReceive,
unit,
createdAt: new Date(),
},
]
let transaction: Transaction | undefined = undefined
const mintUrl = mintBalanceToReceiveTo.mintUrl const mintUrl = mintBalanceToReceiveTo.mintUrl
const {CashuPaymentRequestApi} = await import('./operations/cashuPaymentRequestApi')
let transactionIdForRecovery: number | undefined
try { try {
const newTransaction = { const prepared = await CashuPaymentRequestApi.prepare({
type: TransactionType.RECEIVE_BY_PAYMENT_REQUEST, mintBalance: mintBalanceToReceiveTo,
amount: amountToReceive, amount: amountToReceive,
fee: 0,
unit, unit,
data: JSON.stringify(transactionData),
memo, memo,
mint: mintUrl, method: {method: 'nostr', options: {}},
status: TransactionStatus.DRAFT,
}
// store tx in db and in the model
transaction = await transactionsStore.addTransaction(newTransaction)
const tags = [["n", "17"]]
const transport = [
{
type: PaymentRequestTransportType.NOSTR,
target: NostrClient.encodeNprofile(walletProfileStore.pubkey, relaysStore.allUrls),
tags,
},
] as PaymentRequestTransport[]
const cashuPrId = QuickCrypto.randomBytes(4).toString("hex")
const cashuPaymentRequest = new CashuPaymentRequest(
transport,
cashuPrId,
amountToReceive,
unit,
[mintUrl],
memo
)
const encoded = cashuPaymentRequest.toEncodedRequest()
log.trace("[cashuPaymentRequestTask] Creating cashu payment request", {cashuPaymentRequest, encoded})
transactionData.push({
status: TransactionStatus.PENDING,
cashuPaymentRequest,
createdAt: new Date()
}) })
transactionIdForRecovery = prepared.transactionId
const {transaction, cashuPaymentRequest, encodedCashuPaymentRequest} =
await CashuPaymentRequestApi.execute(prepared)
transaction.update({
status: TransactionStatus.PENDING,
data: JSON.stringify(transactionData),
paymentId: cashuPrId
})
return { return {
taskFunction: CASHU_PAYMENT_REQUEST_TASK, taskFunction: CASHU_PAYMENT_REQUEST_TASK,
mintUrl, mintUrl,
transaction, transaction,
message: '', message: '',
cashuPaymentRequest, cashuPaymentRequest,
encodedCashuPaymentRequest: encoded, encodedCashuPaymentRequest,
} as TransactionTaskResult } as TransactionTaskResult
} catch (e: any) {
} catch (e: any) { if (transactionIdForRecovery) {
const tx = transactionsStore.findById(transactionIdForRecovery)
if (transaction) { if (tx && tx.status !== TransactionStatus.ERROR) {
transactionData.push({ let transactionData: TransactionData[] = []
status: TransactionStatus.ERROR, try { transactionData = JSON.parse(tx.data) } catch {}
error: WalletUtils.formatError(e), transactionData.push({
createdAt: new Date() status: TransactionStatus.ERROR,
}) error: WalletUtils.formatError(e),
createdAt: new Date(),
transaction.update({ })
status: TransactionStatus.ERROR, tx.update({
data: JSON.stringify(transactionData) status: TransactionStatus.ERROR,
}) data: JSON.stringify(transactionData),
})
}
} }
log.error(e.name, e.message) log.error(e.name, e.message)
return { return {
taskFunction: CASHU_PAYMENT_REQUEST_TASK, taskFunction: CASHU_PAYMENT_REQUEST_TASK,
transaction, transaction: transactionIdForRecovery
? transactionsStore.findById(transactionIdForRecovery)
: undefined,
message: e.message, message: e.message,
error: WalletUtils.formatError(e), error: WalletUtils.formatError(e),
} as TransactionTaskResult } as TransactionTaskResult
@@ -0,0 +1,537 @@
/**
* Cashu Payment Request (NUT-18) operation lifecycle API.
*
* A Cashu Payment Request is the wallet *asking* an external party to pay it
* a specified amount in ecash. The lifecycle is:
*
* prepare() → PreparedCashuPaymentRequestData (DRAFT → PREPARED, draft
* tx created)
* execute() → PendingTransaction (PREPARED → PENDING; the
* PR object is built, the
* encoded request is
* returned for sharing)
* cancel() → RevertedTransaction (PREPARED|PENDING →
* REVERTED; user closes
* the PR before payment)
* reclaim() → never (not applicable)
* finalize() → CompletedTransaction (PENDING → COMPLETED;
* proofs arrived via the
* transport, swap with
* the mint and add as
* UNSPENT)
* refresh() → Transaction (no-op; payments arrive
* via push, not pull)
*
* The `finalize` here takes an additional `PaymentRequestPayload` parameter
* (the proofs that arrived via the Nostr transport) — necessary because the
* proofs aren't stored locally; they come from outside the wallet.
*/
import {
PaymentRequestPayload,
PaymentRequest as CashuPaymentRequest,
PaymentRequestTransport,
PaymentRequestTransportType,
getEncodedToken,
normalizeProofAmounts,
} from '@cashu/cashu-ts'
import QuickCrypto from 'react-native-quick-crypto'
import {log} from '../../logService'
import {MintError, ValidationError, WalletError} from '../../../utils/AppError'
import {rootStoreInstance} from '../../../models'
import {MintBalance} from '../../../models/Mint'
import {
Transaction,
TransactionData,
TransactionStatus,
TransactionType,
} from '../../../models/Transaction'
import {
CompletedTransaction,
PendingTransaction,
PreparedTransaction,
RevertedTransaction,
isCompleted,
isPending,
isPrepared,
isReverted,
} from '../../../models/TransactionStates'
import {CashuProof, CashuUtils} from '../../cashu/cashuUtils'
import {MintUnit, formatCurrency, getCurrency} from '../currency'
import {NostrClient} from '../../nostrService'
import {WalletUtils} from '../utils'
import {CashuPaymentRequestMethodInput} from './cashuPaymentRequestMethods'
const {mintsStore, proofsStore, transactionsStore, walletStore, walletProfileStore, relaysStore} =
rootStoreInstance
// ─────────────────────────────────────────────────────────────────────────────
// Public types
// ─────────────────────────────────────────────────────────────────────────────
export interface PrepareCashuPaymentRequestInput {
mintBalance: MintBalance
/** Amount to request (in `unit`). */
amount: number
unit: MintUnit
memo: string
/** Transport discriminator. Defaults to `nostr`. */
method?: CashuPaymentRequestMethodInput
}
/**
* Returned by `prepare`. Carries the draft tx + everything execute() needs to
* build the encoded payment request.
*/
export interface PreparedCashuPaymentRequestData {
transactionId: number
tx: PreparedTransaction
mintUrl: string
unit: MintUnit
amount: number
memo: string
method: CashuPaymentRequestMethodInput
}
/**
* Returned by `execute`. The PENDING transaction plus the encoded payment
* request the caller shares with the payer (QR code, copy-paste, etc.).
*/
export interface ExecutedCashuPaymentRequestData {
transaction: PendingTransaction
/** Built CashuPaymentRequest object. */
cashuPaymentRequest: CashuPaymentRequest
/** Encoded form ready to share / display. */
encodedCashuPaymentRequest: string
}
// ─────────────────────────────────────────────────────────────────────────────
// prepare()
// ─────────────────────────────────────────────────────────────────────────────
async function prepare(
input: PrepareCashuPaymentRequestInput,
): Promise<PreparedCashuPaymentRequestData> {
const {mintBalance, amount, unit, memo} = input
const method = input.method ?? {method: 'nostr' as const, options: {}}
if (amount <= 0) {
throw new ValidationError('Amount to request must be above zero.')
}
if (method.method !== 'nostr') {
throw new ValidationError(
`Unsupported payment request method: ${(method as any).method}`,
)
}
const mintUrl = mintBalance.mintUrl
const mintInstance = mintsStore.findByUrl(mintUrl)
if (!mintInstance) {
throw new ValidationError('Could not find mint', {mintUrl})
}
// Create draft + PREPARED in one step (no validation that requires a
// separate DRAFT pause — keeps the lifecycle visible in tx.data).
const transactionData: TransactionData[] = [
{
status: TransactionStatus.DRAFT,
amountToReceive: amount,
unit,
createdAt: new Date(),
},
]
const transaction = await transactionsStore.addTransaction({
type: TransactionType.RECEIVE_BY_PAYMENT_REQUEST,
amount,
fee: 0,
unit,
data: JSON.stringify(transactionData),
memo,
mint: mintUrl,
status: TransactionStatus.DRAFT,
})
if (!transaction) {
throw new ValidationError('Failed to create draft transaction')
}
transactionData.push({
status: TransactionStatus.PREPARED,
method: method.method,
createdAt: new Date(),
})
transaction.update({
status: TransactionStatus.PREPARED,
data: JSON.stringify(transactionData),
})
if (!isPrepared(transaction)) {
throw new WalletError('Failed to transition transaction to PREPARED', {
transactionId: transaction.id,
status: transaction.status,
})
}
log.debug('[CashuPaymentRequestApi.prepare]', 'Prepared', {
transactionId: transaction.id,
amount,
mintUrl,
})
return {
transactionId: transaction.id,
tx: transaction,
mintUrl,
unit,
amount,
memo,
method,
}
}
// ─────────────────────────────────────────────────────────────────────────────
// execute() — PREPARED → PENDING. Builds the PR and returns the encoded form.
// ─────────────────────────────────────────────────────────────────────────────
async function execute(
prepared: PreparedCashuPaymentRequestData,
): Promise<ExecutedCashuPaymentRequestData> {
const tx = transactionsStore.findById(prepared.transactionId)
if (!tx) {
throw new ValidationError('Transaction not found', {
transactionId: prepared.transactionId,
})
}
if (!isPrepared(tx)) {
throw new ValidationError(
`Cannot execute payment request in state ${tx.status}. Expected PREPARED.`,
{transactionId: tx.id, status: tx.status},
)
}
// ── Build the Nostr transport ───────────────────────────────────────
const tags = [['n', '17']]
const transport: PaymentRequestTransport[] = [
{
type: PaymentRequestTransportType.NOSTR,
target: NostrClient.encodeNprofile(walletProfileStore.pubkey, relaysStore.allUrls),
tags,
},
]
const cashuPrId = QuickCrypto.randomBytes(4).toString('hex')
const cashuPaymentRequest = new CashuPaymentRequest(
transport,
cashuPrId,
prepared.amount,
prepared.unit,
[prepared.mintUrl],
prepared.memo,
)
const encoded = cashuPaymentRequest.toEncodedRequest()
log.trace('[CashuPaymentRequestApi.execute]', 'Created cashu payment request', {
cashuPrId,
amount: prepared.amount,
})
// ── Transition PREPARED → PENDING ───────────────────────────────────
const txData = _parseData(tx)
txData.push({
status: TransactionStatus.PENDING,
cashuPaymentRequest,
createdAt: new Date(),
})
tx.update({
status: TransactionStatus.PENDING,
data: JSON.stringify(txData),
paymentId: cashuPrId,
})
const refreshed = transactionsStore.findById(tx.id)!
if (!isPending(refreshed)) {
throw new WalletError(
'Transaction did not transition to PENDING after execute',
{transactionId: tx.id, status: refreshed.status},
)
}
return {
transaction: refreshed,
cashuPaymentRequest,
encodedCashuPaymentRequest: encoded,
}
}
// ─────────────────────────────────────────────────────────────────────────────
// cancel() — PREPARED|PENDING → REVERTED
// ─────────────────────────────────────────────────────────────────────────────
async function cancel(transactionId: number): Promise<RevertedTransaction> {
const tx = transactionsStore.findById(transactionId)
if (!tx) {
throw new ValidationError('Transaction not found', {transactionId})
}
if (!isPrepared(tx) && !isPending(tx)) {
throw new ValidationError(
`Cannot cancel payment request in state ${tx.status}. Expected PREPARED or PENDING.`,
{transactionId, status: tx.status},
)
}
const txData = _parseData(tx)
txData.push({
status: TransactionStatus.REVERTED,
cancelledBy: 'user',
createdAt: new Date(),
})
tx.update({status: TransactionStatus.REVERTED, data: JSON.stringify(txData)})
log.info('[CashuPaymentRequestApi.cancel]', 'Cancelled', {transactionId})
return _assertReverted(tx, transactionId)
}
// ─────────────────────────────────────────────────────────────────────────────
// reclaim() — not applicable
// ─────────────────────────────────────────────────────────────────────────────
async function reclaim(_transactionId: number): Promise<never> {
throw new ValidationError(
'Cashu payment requests cannot be reclaimed. Use cancel() to abandon an open request.',
)
}
// ─────────────────────────────────────────────────────────────────────────────
// finalize() — PENDING → COMPLETED.
//
// Called when the payment arrives via the transport (Nostr DM in today's
// world). The payload carries the proofs from the payer; we swap them with
// the mint to lock them to our wallet, then atomic-commit proofs INSERT +
// tx UPDATE.
// ─────────────────────────────────────────────────────────────────────────────
async function finalize(
transactionId: number,
paymentRequestPayload: PaymentRequestPayload,
): Promise<CompletedTransaction> {
const tx = transactionsStore.findById(transactionId)
if (!tx) {
throw new ValidationError('Transaction not found', {transactionId})
}
if (tx.status === TransactionStatus.COMPLETED) {
return tx as CompletedTransaction
}
if (!isPending(tx)) {
throw new ValidationError(
`Cannot finalize payment request in state ${tx.status}. Expected PENDING or COMPLETED.`,
{transactionId, status: tx.status},
)
}
const {mint: mintUrl, unit: payloadUnit, proofs: proofsToReceive, id: paymentRequestId, memo: payloadMemo} =
paymentRequestPayload
const unit = payloadUnit as MintUnit
const memo = payloadMemo || `PR ${paymentRequestId}`
if (
!mintUrl ||
!unit ||
!Array.isArray(proofsToReceive) ||
proofsToReceive.length === 0
) {
throw new ValidationError('Payment request payload is invalid.', {
paymentRequestPayload,
})
}
const amountToReceive = CashuUtils.getProofsAmount(proofsToReceive)
if (tx.unit !== unit || tx.amount !== amountToReceive) {
throw new ValidationError(
'Related Payment request has different amount or unit than the incoming payment.',
{
expectedUnit: tx.unit,
expectedAmount: tx.amount,
amountToReceive,
unit,
paymentRequestId,
},
)
}
// Re-check blocked status — could have been blocked since the PR was issued.
if (mintsStore.isBlocked(mintUrl)) {
const txData = _parseData(tx)
txData.push({
status: TransactionStatus.BLOCKED,
message: 'Mint is blocked in your Settings, ecash has not been received.',
})
tx.update({status: TransactionStatus.BLOCKED, data: JSON.stringify(txData)})
throw new ValidationError(
`The mint ${mintUrl} is blocked. You can unblock it in Settings.`,
{transactionId},
)
}
// ── Swap proofs with mint (with outputs-error healing retry) ────────
const {proofs: receivedProofs, swapFeePaid} = await _receiveWithHealing(
mintUrl,
unit,
paymentRequestPayload,
transactionId,
)
const receivedAmount = receivedProofs.reduce((acc, p) => acc + Number(p.amount), 0)
const outputToken = getEncodedToken({
mint: mintUrl,
proofs: normalizeProofAmounts(receivedProofs),
unit,
memo,
})
const currentSpendable = proofsStore.getUnitBalance(unit)?.unitBalance ?? 0
const balanceAfter = currentSpendable + receivedAmount
const txData = _parseData(tx)
txData.push({
status: TransactionStatus.COMPLETED,
swapFeePaid,
receivedAmount,
unit,
createdAt: new Date(),
})
// Atomic: proofs INSERT + tx UPDATE in one SQLite transaction.
const reservation = proofsStore.reserve([], {
transactionId,
mintUrl,
unit,
operationType: 'cashu-payment-request-finalize',
rollbackTo: 'UNSPENT',
})
proofsStore.commitReservation(reservation, {
newProofs: [{proofs: receivedProofs, state: 'UNSPENT', tId: transactionId}],
transactionUpdate: {
id: transactionId,
status: TransactionStatus.COMPLETED,
data: JSON.stringify(txData),
keysetId: receivedProofs[0].id,
outputToken,
balanceAfter,
...(receivedAmount !== amountToReceive && {amount: receivedAmount}),
...(swapFeePaid > 0 && {fee: swapFeePaid}),
},
})
log.debug('[CashuPaymentRequestApi.finalize]', 'Payment request fulfilled', {
transactionId,
receivedAmount,
swapFeePaid,
})
return _assertCompleted(tx, transactionId)
}
// ─────────────────────────────────────────────────────────────────────────────
// refresh() — no-op; PR payments arrive via push transport.
// ─────────────────────────────────────────────────────────────────────────────
async function refresh(transactionId: number): Promise<Transaction> {
const tx = transactionsStore.findById(transactionId)
if (!tx) {
throw new ValidationError('Transaction not found', {transactionId})
}
return tx
}
// ─────────────────────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────────────────────
async function _receiveWithHealing(
mintUrl: string,
unit: MintUnit,
payload: PaymentRequestPayload,
transactionId: number,
): Promise<{proofs: CashuProof[]; swapFeePaid: number}> {
try {
return (await walletStore.receive(
mintUrl,
unit,
payload,
transactionId,
)) as unknown as {proofs: CashuProof[]; swapFeePaid: number}
} catch (e: any) {
if (WalletUtils.shouldHealOutputsError(e)) {
log.error(
'[CashuPaymentRequestApi] Increasing proofsCounter outdated values and repeating receive.',
)
return (await walletStore.receive(
mintUrl,
unit,
payload,
transactionId,
{increaseCounterBy: 10},
)) as unknown as {proofs: CashuProof[]; swapFeePaid: number}
}
throw e
}
}
function _parseData(tx: Transaction): TransactionData[] {
try {
return JSON.parse(tx.data)
} catch {
return []
}
}
function _assertReverted(tx: Transaction, transactionId: number): RevertedTransaction {
const refreshed = transactionsStore.findById(transactionId) ?? tx
if (!isReverted(refreshed)) {
throw new WalletError('Transaction did not transition to REVERTED', {
transactionId,
status: refreshed.status,
})
}
return refreshed
}
function _assertCompleted(tx: Transaction, transactionId: number): CompletedTransaction {
const refreshed = transactionsStore.findById(transactionId) ?? tx
if (!isCompleted(refreshed)) {
throw new WalletError('Transaction did not transition to COMPLETED', {
transactionId,
status: refreshed.status,
})
}
return refreshed
}
// ─────────────────────────────────────────────────────────────────────────────
// Public helpers
// ─────────────────────────────────────────────────────────────────────────────
/** Format the standard "payment request fulfilled" message. */
export function cashuPaymentRequestSuccessMessage(
paymentRequestId: string,
receivedAmount: number,
unit: MintUnit,
): string {
const code = getCurrency(unit).code
return `Payment request ${paymentRequestId} with amount of ${formatCurrency(receivedAmount, code)} ${code} has been paid.`
}
// ─────────────────────────────────────────────────────────────────────────────
// Public API export
// ─────────────────────────────────────────────────────────────────────────────
export const CashuPaymentRequestApi = {
prepare,
execute,
cancel,
reclaim,
finalize,
refresh,
}
@@ -0,0 +1,42 @@
/**
* Pluggable transport-method registry for Cashu Payment Requests (NUT-18).
*
* A Cashu Payment Request advertises "send me this much ecash, on this mint,
* via this transport." The transport determines where the payer sends the
* proofs. Each entry in `CashuPaymentRequestMethodOptions` is a transport
* channel.
*
* Today's methods:
* - `nostr`: NUT-18 transport via Nostr DM (NIP-17 / gift-wrap), the only
* transport Minibits currently supports.
*
* Future methods can add HTTP POST transports, BIP-353 lookups, etc.
*/
export interface CashuPaymentRequestMethodOptions {
/**
* NUT-18 Nostr-DM transport.
*
* No method-specific payload — the wallet's own Nostr profile and configured
* relays drive the transport target.
*/
nostr: Record<string, never>
}
export type CashuPaymentRequestMethod = keyof CashuPaymentRequestMethodOptions
export type CashuPaymentRequestMethodPayload<
M extends CashuPaymentRequestMethod = CashuPaymentRequestMethod,
> = CashuPaymentRequestMethodOptions[M]
/**
* Method-tagged payload, the canonical input shape for
* `CashuPaymentRequestApi.prepare`.
*
* Example:
* { method: 'nostr', options: {} }
*/
export type CashuPaymentRequestMethodInput = {
method: 'nostr'
options: CashuPaymentRequestMethodOptions['nostr']
}
@@ -1,6 +1,7 @@
import {isAlive} from 'mobx-state-tree' import {isAlive} from 'mobx-state-tree'
import {getEncodedToken, normalizeProofAmounts} from '@cashu/cashu-ts' import {getEncodedToken, normalizeProofAmounts} from '@cashu/cashu-ts'
import {log} from '../../logService' import {log} from '../../logService'
import {CashuUtils} from '../../cashu/cashuUtils'
import {rootStoreInstance} from '../../../models' import {rootStoreInstance} from '../../../models'
import {Mint} from '../../../models/Mint' import {Mint} from '../../../models/Mint'
import {Proof} from '../../../models/Proof' import {Proof} from '../../../models/Proof'
@@ -138,28 +139,39 @@ const handleInFlightByMintTask = async (mint: Mint): Promise<WalletTaskResult> =
{inFlightRequest: inFlight}, {inFlightRequest: inFlight},
) )
// Pre-compute everything that needs to land
// atomically. balanceAfter: locked inputs were
// PENDING (contribute 0 to UNSPENT); marking SPENT
// changes nothing. The returnedProofs (change) are
// added as UNSPENT, raising spendable.
const outputToken = getEncodedToken({
mint: mintUrl,
proofs: normalizeProofAmounts(proofsToSend),
unit,
})
const currentSpendable = proofsStore.getUnitBalance(unit)?.unitBalance ?? 0
const sumReturnedChange = CashuUtils.getProofsAmount(returnedProofs)
const balanceAfter = currentSpendable + sumReturnedChange
txData.push({status: TransactionStatus.PENDING, createdAt: new Date()})
// ATOMIC: inputs → SPENT, change → UNSPENT, // ATOMIC: inputs → SPENT, change → UNSPENT,
// proofsToSend → PENDING, reservation row deleted — // proofsToSend → PENDING, tx → PENDING, reservation
// single SQLite transaction. // row deleted — single SQLite transaction.
proofsStore.commitReservation(reservation, { proofsStore.commitReservation(reservation, {
toSpent: lockedInputs, toSpent: lockedInputs,
newProofs: [ newProofs: [
{ proofs: returnedProofs, state: 'UNSPENT', tId: tx.id }, { proofs: returnedProofs, state: 'UNSPENT', tId: tx.id },
{ proofs: proofsToSend, state: 'PENDING', tId: tx.id }, { proofs: proofsToSend, state: 'PENDING', tId: tx.id },
], ],
}) transactionUpdate: {
id: tx.id,
const outputToken = getEncodedToken({mint: mintUrl, proofs: normalizeProofAmounts(proofsToSend), unit}) status: TransactionStatus.PENDING,
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance data: JSON.stringify(txData),
outputToken,
txData.push({status: TransactionStatus.PENDING, createdAt: new Date()}) balanceAfter,
fee: swapFeePaid > 0 ? swapFeePaid : tx.fee,
tx.update({ },
status: TransactionStatus.PENDING,
data: JSON.stringify(txData),
outputToken,
balanceAfter,
fee: swapFeePaid > 0 ? swapFeePaid : tx.fee,
}) })
} catch (sendError: any) { } catch (sendError: any) {
// Rollback restores each input to its pre-reservation // Rollback restores each input to its pre-reservation
@@ -294,27 +294,14 @@ const handlePendingMeltTask = async (params: {
lightningFeePaid = totalFeePaid - meltFeeReserve lightningFeePaid = totalFeePaid - meltFeeReserve
} }
// Atomic finalize: proofsToMeltFrom move PENDING → SPENT and change (if any) // Pre-compute everything that needs to land atomically. balanceAfter
// is added as UNSPENT in a single SQLite transaction. Replaces the previous // is simulated: proofsToMeltFrom were PENDING (contribute 0 to UNSPENT
// two-step `moveToSpent` + `addOrUpdate(change)` which left a crash window // pool); moving them to SPENT changes nothing. The unblinded change is
// where ecash could be marked SPENT without the change being recorded. // newly added as UNSPENT, raising the spendable balance.
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 txData: TransactionData[] = transaction.data ? JSON.parse(transaction.data) : []
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance const currentSpendable = proofsStore.getUnitBalance(unit)?.unitBalance ?? 0
const balanceAfter = currentSpendable + returnedAmount
txData.push({ txData.push({
status: TransactionStatus.COMPLETED, status: TransactionStatus.COMPLETED,
lightningFeePaid, lightningFeePaid,
@@ -324,16 +311,33 @@ const handlePendingMeltTask = async (params: {
preimage: quote.payment_preimage, preimage: quote.payment_preimage,
createdAt: new Date(), createdAt: new Date(),
}) })
const updatePayload: any = {
status: TransactionStatus.COMPLETED, const reservation = proofsStore.reserve(proofsToMeltFrom, {
data: JSON.stringify(txData), transactionId,
fee: totalFeePaid, mintUrl,
balanceAfter, unit,
} operationType: 'pending-melt-finalize-paid',
if (outputToken) updatePayload.outputToken = outputToken rollbackTo: 'PENDING',
//@ts-ignore })
if (quote.payment_preimage) updatePayload.proof = quote.payment_preimage
transaction.update(updatePayload) // ATOMIC commit: proofsToMeltFrom → SPENT, change → UNSPENT, tx →
// COMPLETED, reservation row deleted — all one SQLite transaction.
proofsStore.commitReservation(reservation, {
toSpent: proofsToMeltFrom,
newProofs: unblinded.change.length > 0
? [{ proofs: unblinded.change, state: 'UNSPENT', tId: transactionId }]
: [],
transactionUpdate: {
id: transactionId,
status: TransactionStatus.COMPLETED,
data: JSON.stringify(txData),
fee: totalFeePaid,
balanceAfter,
...(outputToken && { outputToken }),
//@ts-ignore — payment_preimage is loosely typed in cashu-ts
...(quote.payment_preimage && { proof: quote.payment_preimage }),
},
})
log.debug('[handlePendingMelt] Transaction completed', {transactionId, totalFeePaid}) log.debug('[handlePendingMelt] Transaction completed', {transactionId, totalFeePaid})
+52 -121
View File
@@ -14,8 +14,8 @@ import {Contact} from '../../../models/Contact'
import {CashuProof, CashuUtils} from '../../cashu/cashuUtils' import {CashuProof, CashuUtils} from '../../cashu/cashuUtils'
import {LightningUtils} from '../../lightning/lightningUtils' import {LightningUtils} from '../../lightning/lightningUtils'
import {NostrEvent} from '../../nostrService' import {NostrEvent} from '../../nostrService'
import {pollerExists, stopPolling} from '../../../utils/poller' import {pollerExists} from '../../../utils/poller'
import {MintUnit, formatCurrency, getCurrency} from '../currency' import {MintUnit} from '../currency'
import {SyncQueue} from '../../syncQueueService' import {SyncQueue} from '../../syncQueueService'
import {topupTask} from '../topupTask' import {topupTask} from '../topupTask'
import {WalletUtils} from '../utils' import {WalletUtils} from '../utils'
@@ -25,7 +25,6 @@ import {
TransactionTaskResult, TransactionTaskResult,
WalletTaskResult, WalletTaskResult,
} from '../types' } from '../types'
import {sendTopupNotification} from '../notifications'
const { const {
mintsStore, mintsStore,
@@ -59,6 +58,14 @@ const topupQueueAwaitable = (
/** /**
* Check a single pending mint quote and mint proofs if paid — even after expiry * Check a single pending mint quote and mint proofs if paid — even after expiry
*/ */
/**
* Backward-compatible wrapper around `TopupOperationApi.refresh`.
*
* Used by the pending-queue sweep (via `enqueuePendingTopupCheck`) and via
* `WalletTask.handlePendingTopupTask` in the legacy task surface. The
* lifecycle API now owns the state-machine logic; this wrapper just adapts
* the result into the `WalletTaskResult` shape expected by callers.
*/
const handlePendingTopupTask = async ( const handlePendingTopupTask = async (
params: {transaction: Transaction}, params: {transaction: Transaction},
): Promise<WalletTaskResult> => { ): Promise<WalletTaskResult> => {
@@ -70,7 +77,6 @@ const handlePendingTopupTask = async (
amount, amount,
paymentId: paymentHash, paymentId: paymentHash,
quote: mintQuote, quote: mintQuote,
expiresAt,
} = tx } = tx
log.warn('[handlePendingTopupTask] start', {tx}) log.warn('[handlePendingTopupTask] start', {tx})
@@ -80,129 +86,45 @@ const handlePendingTopupTask = async (
throw new ValidationError('Invalid pending topup transaction', {tId}) throw new ValidationError('Invalid pending topup transaction', {tId})
} }
let txData: TransactionData = tx.data ? JSON.parse(tx.data) : [] const {TopupOperationApi, topupSuccessMessage} = await import('./topupOperationApi')
try { try {
const {state} = await walletStore.checkLightningMintQuote(mintUrl, mintQuote) const refreshed = await TopupOperationApi.refresh(tId)
const status = refreshed.status
const isExpired = expiresAt && isBefore(expiresAt, new Date()) let message: string
switch (status) {
if (isExpired && state !== MintQuoteState.PAID) { case TransactionStatus.COMPLETED: {
log.debug('[handlePendingTopupTask] Invoice expired and not paid', {paymentHash}) // Distinguish "minted now" from "already issued elsewhere" via the
// latest tx.data entry (refresh stamps a `note` for ISSUED).
txData.push({status: TransactionStatus.EXPIRED, message: 'Invoice expired', createdAt: new Date()}) const last = _lastDataEntry(refreshed)
tx.update({status: TransactionStatus.EXPIRED, data: JSON.stringify(txData)}) if (last?.note === 'Already issued') {
stopPolling(`handlePendingTopupPoller-${paymentHash}`) message = 'Ecash already issued'
} else {
return { const paidAfterExpiry =
taskFunction: HANDLE_PENDING_TOPUP_TASK, !!tx.expiresAt && isBefore(tx.expiresAt, new Date())
transaction: tx, message = topupSuccessMessage(amount, unit, paidAfterExpiry)
mintUrl, }
unit, break
amount,
paymentHash,
message: 'Topup invoice expired and was not paid',
} }
case TransactionStatus.EXPIRED:
message = 'Topup invoice expired and was not paid'
break
case TransactionStatus.PENDING:
message = 'Quote not paid yet'
break
default:
message = `Quote state resolved to ${status}`
} }
switch (state) { return {
case MintQuoteState.UNPAID: taskFunction: HANDLE_PENDING_TOPUP_TASK,
log.trace('[handlePendingTopupTask] Quote still unpaid', {mintQuote}) transaction: refreshed,
mintUrl,
if (isExpired) { unit,
txData.push({status: TransactionStatus.EXPIRED, message: 'Invoice expired (unpaid)', createdAt: new Date()}) amount,
tx.update({status: TransactionStatus.EXPIRED, data: JSON.stringify(txData)}) paymentHash,
stopPolling(`handlePendingTopupPoller-${paymentHash}`) message,
}
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) { } catch (e: any) {
log.error('[handlePendingTopupTask] failed', { log.error('[handlePendingTopupTask] failed', {
@@ -225,6 +147,15 @@ const handlePendingTopupTask = async (
} }
} }
function _lastDataEntry(tx: Transaction): TransactionData | undefined {
try {
const arr = JSON.parse(tx.data) as TransactionData[]
return Array.isArray(arr) && arr.length > 0 ? arr[arr.length - 1] : undefined
} catch {
return undefined
}
}
/** /**
* Manually recover minted ecash from a paid mint quote (e.g. lost topup) * Manually recover minted ecash from a paid mint quote (e.g. lost topup)
*/ */
@@ -0,0 +1,59 @@
/**
* Pluggable receive-method registry (extensibility hook).
*
* A "receive" brings ecash INTO the wallet from an external source. Each entry
* in `ReceiveMethodOptions` is an incoming-token format — the way the proofs
* arrive at the wallet boundary. The discriminator lets `ReceiveOperationApi`
* accept a typed payload per method without growing a parameter for each
* format.
*
* Today's methods:
* - `cashu-token`: standard NUT-00 token (V3/V4) handed to the wallet via
* copy-paste, NFC, deep link, or QR scan.
*
* The token's *fulfillment path* (online swap vs offline DLEQ-verify) is a
* mode flag on the options, not a separate method — both share the same
* underlying receive primitives.
*
* Future methods can be added (e.g. raw `proofs-array` from a custom transport,
* onchain mint receive after NUT-23). Cashu Payment Request fulfillment lives
* in `cashuPaymentRequestApi.ts` since it has a distinct lifecycle (the wallet
* issues a request, then waits for proofs to arrive).
*/
import type {Token} from '@cashu/cashu-ts'
export interface ReceiveMethodOptions {
/**
* NUT-00 cashu token receive.
*
* - `token`: pre-decoded token (saves a re-decode in execute).
* - `encodedToken`: the original encoded form (preserved on the transaction
* so an offline-prepared receive can be completed later from disk).
* - `offline`: when true, prepare runs DLEQ verification locally without
* contacting the mint. execute() must be called later when online to
* swap the proofs.
*/
'cashu-token': {
token: Token
encodedToken: string
offline?: boolean
}
}
export type ReceiveMethod = keyof ReceiveMethodOptions
export type ReceiveMethodPayload<M extends ReceiveMethod = ReceiveMethod> =
ReceiveMethodOptions[M]
/**
* Method-tagged payload, the canonical input shape for
* `ReceiveOperationApi.prepare`.
*
* Example:
* { method: 'cashu-token', options: { token, encodedToken, offline: false } }
*/
export type ReceiveMethodInput = {
method: 'cashu-token'
options: ReceiveMethodOptions['cashu-token']
}
@@ -0,0 +1,584 @@
/**
* Receive (cashu-token) operation lifecycle API.
*
* Splits the historical `receiveTask` / `receiveOfflinePrepareTask` /
* `receiveOfflineCompleteTask` trio into explicit lifecycle methods:
*
* prepare() → PreparedReceiveData (DRAFT → PREPARED for online mode, or
* DRAFT → PREPARED_OFFLINE after local
* DLEQ verification for offline mode)
* execute() → CompletedTransaction (PREPARED|PREPARED_OFFLINE → COMPLETED;
* swap proofs with mint, atomic commit)
* cancel() → RevertedTransaction (PREPARED|PREPARED_OFFLINE → REVERTED;
* user abandons before completing)
* reclaim() → never (not applicable — received ecash is
* already in the wallet)
* finalize() → CompletedTransaction (alias for execute — no async wait
* state for receive)
* refresh() → Transaction (no-op; receive doesn't poll)
*
* Mint-block check happens early in prepare(): blocked mints transition the
* tx directly to BLOCKED and skip prepare's normal exit (PREPARED). Callers
* see a BLOCKED transaction and handle the UI accordingly.
*
* Atomic commit on execute(): the proofs INSERT and the tx UPDATE land in
* one SQLite transaction via an empty reservation (same trick as topup
* finalize). Closes the proofs ↔ transactions atomicity window: a crash
* mid-execute used to leave proofs added without the tx COMPLETED stamp.
*/
import {
Token,
getDecodedToken,
getEncodedToken,
normalizeProofAmounts,
} from '@cashu/cashu-ts'
import {log} from '../../logService'
import {MintError, ValidationError, WalletError} from '../../../utils/AppError'
import {rootStoreInstance} from '../../../models'
import {
Transaction,
TransactionData,
TransactionStatus,
TransactionType,
} from '../../../models/Transaction'
import {
BlockedTransaction,
CompletedTransaction,
PreparedTransaction,
RevertedTransaction,
isBlocked,
isCompleted,
isPrepared,
isReverted,
} from '../../../models/TransactionStates'
import {CashuProof, CashuUtils} from '../../cashu/cashuUtils'
import {MintUnit, formatCurrency, getCurrency} from '../currency'
import {WalletUtils} from '../utils'
import {ReceiveMethodInput} from './receiveMethods'
const {mintsStore, proofsStore, transactionsStore, walletStore} = rootStoreInstance
// ─────────────────────────────────────────────────────────────────────────────
// Public types
// ─────────────────────────────────────────────────────────────────────────────
export interface PrepareReceiveInput {
/** Mint that issued the token (parsed from token.mint; pass here for the wrapper to skip a decode). */
mintUrl: string
/** Amount the user expects to receive (validated against the token total in execute). */
amount: number
unit: MintUnit
memo: string
/** Receive method discriminator (currently only `cashu-token`). */
method: ReceiveMethodInput
}
/**
* Returned by `prepare`. Carries the decoded token and enough metadata for
* execute() to swap proofs without re-decoding.
*
* For BLOCKED outcomes, the wrapper handles the surface — `prepare` resolves
* to a normal `PreparedReceiveData` with `blocked: true` and a tx already in
* `BLOCKED` state. Execute on a blocked tx will refuse.
*/
export interface PreparedReceiveData {
transactionId: number
/**
* Snapshot at prepare time. May be PREPARED, PREPARED_OFFLINE, or BLOCKED
* — execute() narrows back to PreparedTransaction via the status check.
*/
tx: Transaction
mintUrl: string
unit: MintUnit
amountToReceive: number
memo: string
/** True if the mint was blocked at prepare time (tx is in BLOCKED state). */
blocked: boolean
/** True if prepare ran in offline mode (no mint contact yet). */
isOffline: boolean
method: ReceiveMethodInput
}
// ─────────────────────────────────────────────────────────────────────────────
// prepare()
// ─────────────────────────────────────────────────────────────────────────────
async function prepare(input: PrepareReceiveInput): Promise<PreparedReceiveData> {
const {mintUrl, amount, unit, memo, method} = input
if (amount <= 0) {
throw new ValidationError('Amount to receive must be above zero.')
}
if (method.method !== 'cashu-token') {
throw new ValidationError(`Unsupported receive method: ${(method as any).method}`)
}
const {token, encodedToken, offline} = method.options
if (!mintUrl) {
throw new ValidationError('Token is missing a mint param.')
}
const isOffline = !!offline
// ── Create draft tx (RECEIVE_OFFLINE for offline mode, RECEIVE otherwise) ──
const transactionData: TransactionData[] = [
{
status: TransactionStatus.DRAFT,
amountToReceive: amount,
unit,
createdAt: new Date(),
},
]
const transaction = await transactionsStore.addTransaction({
type: isOffline ? TransactionType.RECEIVE_OFFLINE : TransactionType.RECEIVE,
amount,
fee: 0,
unit,
data: JSON.stringify(transactionData),
memo,
mint: mintUrl,
status: TransactionStatus.DRAFT,
})
if (!transaction) {
throw new ValidationError('Failed to create draft transaction')
}
const transactionId = transaction.id
// Stash the encoded token now so an offline-prepared tx can be completed
// later from disk (after restart) without the caller re-supplying it.
transaction.update({inputToken: encodedToken})
// ── Blocked mint short-circuit ───────────────────────────────────────
if (mintsStore.isBlocked(mintUrl)) {
transactionData.push({
status: TransactionStatus.BLOCKED,
mintToReceive: mintUrl,
createdAt: new Date(),
})
transaction.update({
status: TransactionStatus.BLOCKED,
data: JSON.stringify(transactionData),
})
return {
transactionId,
tx: transaction,
mintUrl,
unit,
amountToReceive: amount,
memo,
blocked: true,
isOffline,
method,
}
}
// ── Offline DLEQ verification (no mint contact) ──────────────────────
if (isOffline) {
const mintInstance = mintsStore.findByUrl(mintUrl)
if (!mintInstance) {
throw new ValidationError(
'This token cannot be verified offline because the mint is not saved in your wallet. Go online to add the mint or receive it online.',
{caller: 'ReceiveOperationApi.prepare', mintUrl},
)
}
if (
!mintInstance.keysetIds ||
mintInstance.keysetIds.length === 0 ||
!mintInstance.keys ||
mintInstance.keys.length === 0
) {
throw new ValidationError(
'This token cannot be verified offline because the mint keys are not saved. Sync the mint online first.',
{caller: 'ReceiveOperationApi.prepare', mintUrl},
)
}
CashuUtils.verifyProofsDleqOrThrow(token.proofs, mintInstance.keys)
transactionData.push({
status: TransactionStatus.PREPARED_OFFLINE,
createdAt: new Date(),
})
transaction.update({
status: TransactionStatus.PREPARED_OFFLINE,
data: JSON.stringify(transactionData),
})
} else {
transactionData.push({
status: TransactionStatus.PREPARED,
method: method.method,
createdAt: new Date(),
})
transaction.update({
status: TransactionStatus.PREPARED,
data: JSON.stringify(transactionData),
})
}
if (!isPrepared(transaction)) {
throw new WalletError('Failed to transition transaction to PREPARED', {
transactionId,
status: transaction.status,
})
}
log.debug('[ReceiveOperationApi.prepare]', 'Prepared', {
transactionId,
amount,
mintUrl,
isOffline,
})
return {
transactionId,
tx: transaction,
mintUrl,
unit,
amountToReceive: amount,
memo,
blocked: false,
isOffline,
method,
}
}
// ─────────────────────────────────────────────────────────────────────────────
// execute()
//
// PREPARED|PREPARED_OFFLINE → COMPLETED. Decodes the stored input token,
// swaps proofs with the mint, atomically commits proofs INSERT + tx UPDATE.
// ─────────────────────────────────────────────────────────────────────────────
async function execute(prepared: PreparedReceiveData): Promise<CompletedTransaction> {
const tx = transactionsStore.findById(prepared.transactionId)
if (!tx) {
throw new ValidationError('Transaction not found', {
transactionId: prepared.transactionId,
})
}
if (isBlocked(tx)) {
throw new ValidationError(
`Cannot execute receive: mint ${prepared.mintUrl} is blocked.`,
{transactionId: tx.id},
)
}
if (!isPrepared(tx)) {
throw new ValidationError(
`Cannot execute receive in state ${tx.status}. Expected PREPARED or PREPARED_OFFLINE.`,
{transactionId: tx.id, status: tx.status},
)
}
// Re-check blocked status — a mint could have been blocked between
// prepare (possibly long ago for offline-prepared txs) and execute.
if (mintsStore.isBlocked(prepared.mintUrl)) {
const txData = _parseData(tx)
txData.push({
status: TransactionStatus.BLOCKED,
mintToReceive: prepared.mintUrl,
createdAt: new Date(),
})
tx.update({status: TransactionStatus.BLOCKED, data: JSON.stringify(txData)})
throw new ValidationError(
`The mint ${prepared.mintUrl} is blocked. You can unblock it in Settings.`,
{transactionId: tx.id},
)
}
// Decode the stored token. For online flow this is the same token from
// prepare; for offline-complete-after-restart it comes from disk.
if (!tx.inputToken) {
throw new ValidationError('Could not find ecash token to redeem', {
transactionId: tx.id,
})
}
// Ensure the mint exists (offline-prepared receives from new mints may have
// not added it to the wallet yet — the original code did this too).
if (!mintsStore.alreadyExists(prepared.mintUrl)) {
await mintsStore.addMint(prepared.mintUrl)
}
const mintInstance = mintsStore.findByUrl(prepared.mintUrl)
if (!mintInstance) {
throw new ValidationError('Missing mint', {mintUrl: prepared.mintUrl})
}
const token = getDecodedToken(tx.inputToken, mintInstance.keysetIds ?? [])
// ── Swap proofs with the mint (with outputs-error healing retry) ────
const {proofs, swapFeePaid} = await _receiveWithHealing(
prepared.mintUrl,
prepared.unit,
token,
tx.id,
)
const receivedAmount = proofs.reduce((acc, p) => acc + Number(p.amount), 0)
const outputToken = getEncodedToken({
mint: prepared.mintUrl,
proofs: normalizeProofAmounts(proofs),
unit: prepared.unit,
memo: token.memo ?? undefined,
})
const currentSpendable = proofsStore.getUnitBalance(prepared.unit)?.unitBalance ?? 0
const balanceAfter = currentSpendable + receivedAmount
const txData = _parseData(tx)
txData.push({
status: TransactionStatus.COMPLETED,
swapFeePaid,
receivedAmount,
unit: prepared.unit,
createdAt: new Date(),
})
// Atomic commit: proofs INSERT (UNSPENT) + tx UPDATE (→ COMPLETED) in
// one SQLite transaction. Empty reservation used purely as the batch
// primitive (no local proofs to lock for a receive).
const reservation = proofsStore.reserve([], {
transactionId: tx.id,
mintUrl: prepared.mintUrl,
unit: prepared.unit,
operationType: 'receive-finalize',
rollbackTo: 'UNSPENT',
})
proofsStore.commitReservation(reservation, {
newProofs: [{proofs, state: 'UNSPENT', tId: tx.id}],
transactionUpdate: {
id: tx.id,
status: TransactionStatus.COMPLETED,
data: JSON.stringify(txData),
keysetId: proofs[0].id,
outputToken,
balanceAfter,
...(swapFeePaid > 0 && {fee: swapFeePaid}),
},
})
log.debug('[ReceiveOperationApi.execute]', 'Receive completed', {
transactionId: tx.id,
receivedAmount,
swapFeePaid,
})
return _assertCompleted(tx, tx.id)
}
// ─────────────────────────────────────────────────────────────────────────────
// cancel() — PREPARED|PREPARED_OFFLINE → REVERTED
//
// Marks an unfinalized receive as abandoned. The token isn't swapped at the
// mint, so nothing to undo on the mint side — just transitions the local tx.
// Useful for offline-prepared receives the user decides not to redeem.
// ─────────────────────────────────────────────────────────────────────────────
async function cancel(transactionId: number): Promise<RevertedTransaction> {
const tx = transactionsStore.findById(transactionId)
if (!tx) {
throw new ValidationError('Transaction not found', {transactionId})
}
if (!isPrepared(tx)) {
throw new ValidationError(
`Cannot cancel receive in state ${tx.status}. Expected PREPARED or PREPARED_OFFLINE.`,
{transactionId, status: tx.status},
)
}
const txData = _parseData(tx)
txData.push({
status: TransactionStatus.REVERTED,
cancelledBy: 'user',
createdAt: new Date(),
})
tx.update({status: TransactionStatus.REVERTED, data: JSON.stringify(txData)})
log.info('[ReceiveOperationApi.cancel]', 'Cancelled', {transactionId})
return _assertReverted(tx, transactionId)
}
// ─────────────────────────────────────────────────────────────────────────────
// reclaim() — not applicable to receive
//
// Once a receive completes, the ecash is in the wallet (UNSPENT). There's no
// "send it back" — that would be a SEND operation. Before completion, cancel()
// handles abandonment. Kept on the API surface for symmetry.
// ─────────────────────────────────────────────────────────────────────────────
async function reclaim(_transactionId: number): Promise<never> {
throw new ValidationError(
'Receive operations cannot be reclaimed. Use cancel() to abandon a PREPARED receive.',
)
}
// ─────────────────────────────────────────────────────────────────────────────
// finalize() — alias for execute
//
// Receive doesn't have a PENDING state (no async wait); finalize and execute
// are the same thing. Provided for API symmetry with other operations.
// ─────────────────────────────────────────────────────────────────────────────
async function finalize(transactionId: number): Promise<CompletedTransaction> {
const prepared = _reloadPrepared(transactionId)
return execute(prepared)
}
// ─────────────────────────────────────────────────────────────────────────────
// refresh() — no-op for receive (no async state to refresh against the mint)
// ─────────────────────────────────────────────────────────────────────────────
async function refresh(transactionId: number): Promise<Transaction> {
const tx = transactionsStore.findById(transactionId)
if (!tx) {
throw new ValidationError('Transaction not found', {transactionId})
}
return tx
}
// ─────────────────────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────────────────────
/**
* Wraps `walletStore.receive` with the same outputs-error healing retry the
* legacy `receiveSync` had. Returned proofs are plain cashu-ts proofs.
*/
async function _receiveWithHealing(
mintUrl: string,
unit: MintUnit,
token: Token,
transactionId: number,
): Promise<{proofs: CashuProof[]; swapFeePaid: number}> {
try {
return (await walletStore.receive(
mintUrl,
unit,
token,
transactionId,
)) as unknown as {proofs: CashuProof[]; swapFeePaid: number}
} catch (e: any) {
if (WalletUtils.shouldHealOutputsError(e)) {
log.error(
'[ReceiveOperationApi] Increasing proofsCounter outdated values and repeating receive.',
)
return (await walletStore.receive(
mintUrl,
unit,
token,
transactionId,
{increaseCounterBy: 10},
)) as unknown as {proofs: CashuProof[]; swapFeePaid: number}
}
throw e
}
}
/**
* Build a `PreparedReceiveData` from a persisted PREPARED|PREPARED_OFFLINE
* transaction (e.g. after app restart for an offline-prepared receive).
*
* Caller is responsible for ensuring the tx is in a prepared state — this
* helper just stitches together what execute() needs to proceed.
*/
function _reloadPrepared(transactionId: number): PreparedReceiveData {
const tx = transactionsStore.findById(transactionId)
if (!tx) {
throw new ValidationError('Transaction not found', {transactionId})
}
if (!tx.inputToken) {
throw new ValidationError('Transaction is missing input token', {transactionId})
}
const mintInstance = mintsStore.findByUrl(tx.mint)
const token = getDecodedToken(tx.inputToken, mintInstance?.keysetIds ?? [])
const isOffline = tx.status === TransactionStatus.PREPARED_OFFLINE
return {
transactionId,
tx,
mintUrl: tx.mint,
unit: tx.unit,
amountToReceive: tx.amount,
memo: tx.memo ?? '',
blocked: false,
isOffline,
method: {
method: 'cashu-token',
options: {
token,
encodedToken: tx.inputToken,
offline: isOffline,
},
},
}
}
function _parseData(tx: Transaction): TransactionData[] {
try {
return JSON.parse(tx.data)
} catch {
return []
}
}
function _assertReverted(tx: Transaction, transactionId: number): RevertedTransaction {
const refreshed = transactionsStore.findById(transactionId) ?? tx
if (!isReverted(refreshed)) {
throw new WalletError('Transaction did not transition to REVERTED', {
transactionId,
status: refreshed.status,
})
}
return refreshed
}
function _assertCompleted(tx: Transaction, transactionId: number): CompletedTransaction {
const refreshed = transactionsStore.findById(transactionId) ?? tx
if (!isCompleted(refreshed)) {
throw new WalletError('Transaction did not transition to COMPLETED', {
transactionId,
status: refreshed.status,
})
}
return refreshed
}
// ─────────────────────────────────────────────────────────────────────────────
// Public helpers
// ─────────────────────────────────────────────────────────────────────────────
/** Format the standard "you received X" message — exposed for the wrapper. */
export function receiveSuccessMessage(
receivedAmount: number,
unit: MintUnit,
swapFeePaid: number,
): string {
const code = getCurrency(unit).code
const feePart =
swapFeePaid > 0
? ` Swap fee paid was ${formatCurrency(swapFeePaid, code)} ${code}.`
: ''
return `You've received ${formatCurrency(receivedAmount, code)} ${code} to your Minibits wallet.${feePart}`
}
/** Loader for the offline-complete-from-id wrapper path. */
export function loadPreparedForOfflineComplete(
transactionId: number,
): PreparedReceiveData {
return _reloadPrepared(transactionId)
}
// ─────────────────────────────────────────────────────────────────────────────
// Public API export
// ─────────────────────────────────────────────────────────────────────────────
export const ReceiveOperationApi = {
prepare,
execute,
cancel,
reclaim,
finalize,
refresh,
}
@@ -0,0 +1,49 @@
/**
* Pluggable send-method registry (extensibility hook).
*
* Each entry in `SendMethodOptions` is a send "method" — the way a token is
* locked or made claimable. The discriminator lets `SendOperationApi` accept
* a typed payload per method without growing a parameter for each method.
*
* Today's methods:
* - `default`: plain sendable token (anyone with the token can claim)
* - `p2pk`: token locked to a public key (NUT-11)
*
* Future methods drop in by adding entries here — for example HTLC sends
* would add `htlc: { hash: string; timeout: number }`. The same shape will
* extend cleanly to onchain mint/melt method registries in a separate file
* (`mintMethods.ts` / `meltMethods.ts`) when those operations are migrated.
*/
export interface SendMethodOptions {
/** No method-specific payload — token is shareable as-is. */
default: Record<string, never>
/**
* NUT-11 Pay-to-Public-Key locked send.
* - `pubkey`: hex-encoded compressed secp256k1 public key the recipient holds.
* - `locktime`: optional unix-seconds after which the lock expires.
* - `refundKeys`: optional pubkeys allowed to claim after `locktime`.
*/
p2pk: {
pubkey: string
locktime?: number
refundKeys?: string[]
}
}
export type SendMethod = keyof SendMethodOptions
export type SendMethodPayload<M extends SendMethod = SendMethod> = SendMethodOptions[M]
/**
* Method-tagged payload, the canonical input shape for
* `SendOperationApi.prepare` (and the future per-method handler dispatch).
*
* Examples:
* { method: 'default', options: {} }
* { method: 'p2pk', options: { pubkey: '02ab…' } }
*/
export type SendMethodInput =
| {method: 'default'; options: SendMethodOptions['default']}
| {method: 'p2pk'; options: SendMethodOptions['p2pk']}
@@ -0,0 +1,740 @@
/**
* Send operation lifecycle API.
*
* Splits the historical monolithic `sendTask` into explicit lifecycle methods:
*
* prepare() → PreparedSendData (DRAFT → PREPARED, reservation OPEN)
* execute() → PendingTransaction (PREPARED → [EXECUTING →] PENDING, reservation COMMITTED)
* cancel() → RevertedTransaction (PREPARED → REVERTED, reservation ROLLED BACK)
* reclaim() → RevertedTransaction (PENDING → REVERTED via reclaim swap)
* finalize() → CompletedTransaction (PENDING → COMPLETED after mint confirms SPENT)
* refresh() → Transaction (re-check PENDING with mint, possibly transition)
*
* Between `prepare` and either `execute` or `cancel`, the reservation row
* stays in SQLite — so a crash leaves an orphan that startup recovery rolls
* back automatically. No proofs get stuck.
*
* The legacy `WalletTask.sendQueueAwaitable` continues to work via a thin
* wrapper in `sendTask.ts` that calls `prepare` then `execute` in one go.
*/
import {getEncodedToken, normalizeProofAmounts, CheckStateEnum, Wallet as CashuWallet, Mint as CashuMint, ProofState as CashuProofState} from '@cashu/cashu-ts'
import {log} from '../../logService'
import {ValidationError, MintError, WalletError} from '../../../utils/AppError'
import {rootStoreInstance} from '../../../models'
import {MintBalance} from '../../../models/Mint'
import {Proof} from '../../../models/Proof'
import {
Transaction,
TransactionData,
TransactionStatus,
TransactionType,
} from '../../../models/Transaction'
import {
CompletedTransaction,
PendingTransaction,
PreparedTransaction,
RevertedTransaction,
isCompleted,
isPending,
isPrepared,
isReverted,
} from '../../../models/TransactionStates'
import {CashuProof, CashuUtils} from '../../cashu/cashuUtils'
import {MintUnit} from '../currency'
import {SendMethodInput} from './sendMethods'
import {WalletUtils} from '../utils'
import {WalletTask, MAX_SWAP_INPUT_SIZE} from '../../walletService'
import {
getActiveKeysetIds,
getInactiveKeysetIds,
prioritizeFromInactiveKeysets,
} from '../sendTask'
import {Database, ReservationRow} from '../../sqlite'
import {ProofReservation} from '../proofReservation'
import {poller} from '../../../utils/poller'
import AppError, {Err} from '../../../utils/AppError'
const {mintsStore, proofsStore, transactionsStore, walletStore} = rootStoreInstance
// ─────────────────────────────────────────────────────────────────────────────
// Public types
// ─────────────────────────────────────────────────────────────────────────────
export interface PrepareSendInput {
mintBalance: MintBalance
amount: number
unit: MintUnit
memo: string
/**
* Offline send path: user-supplied exact proofs to send. When provided,
* `amount` must equal their sum. Set to `undefined` (or empty) for
* online auto-select.
*/
selectedProofs?: Proof[]
/**
* Send method discriminator. Defaults to plain (`default`) send.
* `p2pk` forces a swap so the new outputs can be locked to the recipient.
*/
method?: SendMethodInput
/** Resume from an existing DRAFT transaction (e.g. retry after error). */
draftTransactionId?: number
}
/** Path the prepared op will follow when executed. */
export type SendPath = 'offline' | 'online-no-swap' | 'online-swap'
/**
* Returned by `prepare`. Carries the transactionId for downstream lookup
* plus enough computed metadata for execute() to proceed without recomputing.
*
* Hold this in memory between `prepare` and `execute` for the common
* "prepare-then-immediately-execute" flow. For "user pauses" UX, store
* `transactionId` and re-fetch the latest state with `transactionsStore.findById`.
*/
export interface PreparedSendData {
transactionId: number
/** Snapshot of the tx at prepare time. Re-fetch by id for live state. */
tx: PreparedTransaction
/** Amount the recipient will receive (excludes mint swap fee). */
sendAmount: number
/** Mint swap fee reserved at prepare time (0 for offline / no-swap). */
swapFeeReserve: number
/** True iff execute() will contact the mint to swap proofs. */
needsSwap: boolean
path: SendPath
method: SendMethodInput
mintUrl: string
unit: MintUnit
/** Proofs that were locked under the reservation (the operation's inputs). */
lockedProofs: Proof[]
}
// ─────────────────────────────────────────────────────────────────────────────
// prepare()
// ─────────────────────────────────────────────────────────────────────────────
async function prepare(input: PrepareSendInput): Promise<PreparedSendData> {
const {
mintBalance,
amount,
unit,
memo,
selectedProofs,
method = {method: 'default', options: {}} as SendMethodInput,
draftTransactionId,
} = input
if (amount <= 0) {
throw new ValidationError('Amount to send must be above zero.')
}
const mintUrl = mintBalance.mintUrl
const mintInstance = mintsStore.findByUrl(mintUrl)
if (!mintInstance) {
throw new ValidationError('Could not find mint', {mintUrl})
}
const proofsFromMint = proofsStore.getByMint(mintUrl, {state: 'UNSPENT', unit})
const totalAmountFromMint = CashuUtils.getProofsAmount(proofsFromMint)
if (totalAmountFromMint < amount) {
throw new ValidationError('There is not enough funds to send this amount.', {
totalAmountFromMint,
amount,
})
}
// ── Create or load the draft transaction ────────────────────────────
let transaction: Transaction | undefined
let transactionData: TransactionData[] = []
if (draftTransactionId && draftTransactionId > 0) {
transaction = transactionsStore.findById(draftTransactionId)!
try {
transactionData = JSON.parse(transaction.data)
} catch (e) {
transactionData = []
}
} else {
transactionData.push({
status: TransactionStatus.DRAFT,
mintBalanceToSendFrom: mintBalance,
createdAt: new Date(),
})
transaction = await transactionsStore.addTransaction({
type: TransactionType.SEND,
amount,
fee: 0,
unit,
data: JSON.stringify(transactionData),
memo,
mint: mintUrl,
status: TransactionStatus.DRAFT,
})
}
if (!transaction) {
throw new ValidationError('Failed to create or load draft transaction')
}
// ── Decide path + select proofs ─────────────────────────────────────
let path: SendPath
let proofsToLock: Proof[]
let swapFeeReserve = 0
const isP2PK = method.method === 'p2pk'
const selectedAmount = selectedProofs?.length
? CashuUtils.getProofsAmount(selectedProofs)
: 0
if (selectedAmount > 0) {
// ── Offline path ────────────────────────────────────────────────
if (selectedAmount !== amount) {
throw new ValidationError(
'Requested amount to send does not equal sum of ecash denominations provided.',
{transactionId: transaction.id},
)
}
if (selectedProofs!.length > MAX_SWAP_INPUT_SIZE) {
throw new ValidationError(
`Number of proofs is above max of ${MAX_SWAP_INPUT_SIZE}. Visit Settings > Backup to optimize, then try again.`,
{transactionId: transaction.id},
)
}
path = 'offline'
proofsToLock = selectedProofs!
} else {
// ── Auto-select path ────────────────────────────────────────────
const inactiveKeysetIds = getInactiveKeysetIds(mintInstance)
let candidates: Proof[] = inactiveKeysetIds.length > 0
? prioritizeFromInactiveKeysets(mintInstance, amount, unit, proofsFromMint)
: CashuUtils.getProofsToSend(amount, proofsFromMint)
const candidatesAmount = CashuUtils.getProofsAmount(candidates)
const exactMatch = candidatesAmount === amount
if (isP2PK || !exactMatch) {
// Swap needed
const walletInstance = await walletStore.getWallet(mintUrl, unit, {withSeed: true}) as CashuWallet
swapFeeReserve = walletInstance.getFeesForProofs(candidates).toNumber()
const amountWithFees = amount + swapFeeReserve
if (totalAmountFromMint < amountWithFees) {
throw new ValidationError('There is not enough funds to send this amount.', {
totalAmountFromMint,
amountWithFees,
transactionId: transaction.id,
caller: 'SendOperationApi.prepare',
})
}
if (swapFeeReserve > 0) {
candidates = CashuUtils.getProofsToSend(amountWithFees, proofsFromMint)
}
path = 'online-swap'
proofsToLock = candidates
} else {
// Exact match, no mint call needed at execute time
if (candidates.length > MAX_SWAP_INPUT_SIZE) {
throw new ValidationError(
`Number of proofs is above max limit of ${MAX_SWAP_INPUT_SIZE}. Visit Backup to optimize your wallet, then try again.`,
{transactionId: transaction.id},
)
}
path = 'online-no-swap'
proofsToLock = candidates
}
}
// ── Open reservation (leaves the row OPEN — execute/cancel resolves it) ──
proofsStore.reserve(proofsToLock, {
transactionId: transaction.id,
mintUrl,
unit,
operationType: `send-${path}`,
rollbackTo: 'UNSPENT',
})
// ── Transition DRAFT → PREPARED ─────────────────────────────────────
transactionData.push({
status: TransactionStatus.PREPARED,
swapFeeReserve,
needsSwap: path === 'online-swap',
path,
method: method.method,
methodOptions: method.options,
createdAt: new Date(),
})
transaction.update({
status: TransactionStatus.PREPARED,
data: JSON.stringify(transactionData),
keysetId: proofsToLock[0].id,
})
if (!isPrepared(transaction)) {
// tx.update should have applied; defensive guard.
throw new WalletError('Failed to transition transaction to PREPARED', {
transactionId: transaction.id,
status: transaction.status,
})
}
log.debug('[SendOperationApi.prepare]', 'Prepared', {
transactionId: transaction.id,
path,
amount,
swapFeeReserve,
lockedCount: proofsToLock.length,
})
return {
transactionId: transaction.id,
tx: transaction,
sendAmount: amount,
swapFeeReserve,
needsSwap: path === 'online-swap',
path,
method,
mintUrl,
unit,
lockedProofs: proofsToLock,
}
}
// ─────────────────────────────────────────────────────────────────────────────
// execute()
//
// Commits the reservation atomically with the tx state transition.
// - offline / online-no-swap: outputToken = locked proofs; no mint call.
// - online-swap: tx → EXECUTING, mint.send, commit with inputs
// → SPENT, returnedProofs → UNSPENT, proofsToSend
// → PENDING, tx → PENDING (one SQLite txn).
// ─────────────────────────────────────────────────────────────────────────────
async function execute(prepared: PreparedSendData): Promise<PendingTransaction> {
const tx = transactionsStore.findById(prepared.transactionId)
if (!tx) {
throw new ValidationError('Transaction not found', {transactionId: prepared.transactionId})
}
if (!isPrepared(tx)) {
throw new ValidationError(
`Cannot execute send in state ${tx.status}. Expected PREPARED.`,
{transactionId: tx.id, status: tx.status},
)
}
// Look up the open reservation by transactionId.
const reservation = _findReservationForTx(tx.id)
if (!reservation) {
throw new ValidationError(
'No open reservation for transaction. The reservation may have been rolled back by orphan recovery.',
{transactionId: tx.id},
)
}
let transactionData: TransactionData[] = []
try {
transactionData = JSON.parse(tx.data)
} catch (e) {}
const {mintUrl, unit, sendAmount, swapFeeReserve, path, method, lockedProofs} = prepared
if (path === 'online-swap') {
// Mark EXECUTING (separate SQLite write — acceptable, this is the
// pre-mint-call boundary; the atomic landing happens at the commit).
tx.update({status: TransactionStatus.EXECUTING})
// ── Mint call ───────────────────────────────────────────────────
const p2pk = method.method === 'p2pk' ? method.options : undefined
let sendResult: {returnedProofs: CashuProof[]; proofsToSend: CashuProof[]; swapFeePaid: number}
try {
sendResult = await walletStore.send(
mintUrl,
sendAmount,
unit,
lockedProofs,
tx.id,
{p2pk: p2pk && p2pk.pubkey ? p2pk : undefined},
)
} catch (e: any) {
if (WalletUtils.shouldHealOutputsError(e)) {
log.error('[SendOperationApi.execute]', 'Increasing proofsCounter outdated values and repeating send.')
sendResult = await walletStore.send(
mintUrl,
sendAmount,
unit,
lockedProofs,
tx.id,
{p2pk: p2pk && p2pk.pubkey ? p2pk : undefined, increaseCounterBy: 10},
)
} else {
// Rollback restores the reservation (proofs back to UNSPENT, tx
// → REVERTED via the atomic reservation rollback).
proofsStore.rollbackReservation(reservation)
throw e
}
}
const {returnedProofs, proofsToSend, swapFeePaid} = sendResult
// cashu-ts may return some inputs unchanged; those stay UNSPENT, not SPENT.
const returnedSecrets = new Set(returnedProofs.map(p => p.secret))
const actuallySpentProofs = lockedProofs.filter(p => !returnedSecrets.has(p.secret))
const outputToken = getEncodedToken({
mint: mintUrl,
proofs: normalizeProofAmounts(proofsToSend),
unit,
memo: tx.memo ?? undefined,
})
const currentSpendable = proofsStore.getUnitBalance(unit)?.unitBalance ?? 0
// proofsToSend → PENDING (not spendable). returnedProofs are mixed:
// some are inputs that came back unchanged (already PENDING from
// reservation → stay PENDING from spendable POV) and some are fresh
// change. We need to count only the FRESH change as a balance gain.
const freshChangeAmount = CashuUtils.getProofsAmount(
returnedProofs.filter(p => !lockedProofs.some(lp => lp.secret === p.secret)),
)
const balanceAfter = currentSpendable + freshChangeAmount
transactionData.push({
status: TransactionStatus.PENDING,
swapFeeReserve,
swapFeePaid,
isSwapNeeded: true,
createdAt: new Date(),
})
// ATOMIC commit: inputs → SPENT, returnedProofs → UNSPENT (may
// restore some inputs), proofsToSend → PENDING, tx → PENDING.
proofsStore.commitReservation(reservation as any, {
toSpent: actuallySpentProofs,
newProofs: [
{proofs: returnedProofs, state: 'UNSPENT', tId: tx.id},
{proofs: proofsToSend, state: 'PENDING', tId: tx.id},
],
transactionUpdate: {
id: tx.id,
status: TransactionStatus.PENDING,
data: JSON.stringify(transactionData),
outputToken,
balanceAfter,
...(swapFeePaid > 0 && {fee: swapFeePaid}),
},
})
// Subscribe to ws for ongoing PENDING → SPENT transitions.
_monitorSentProofs({mintUrl, proofsToSend})
} else {
// ── offline / online-no-swap ────────────────────────────────────
// No mint call. Locked proofs ARE the outputs; they stay PENDING.
const outputToken = getEncodedToken({
mint: mintUrl,
proofs: normalizeProofAmounts(lockedProofs),
unit,
memo: tx.memo ?? undefined,
})
// balanceAfter unchanged: locked proofs were already PENDING (not
// spendable). No fresh proofs added.
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance ?? 0
transactionData.push({
status: TransactionStatus.PENDING,
swapFeeReserve: 0,
swapFeePaid: 0,
isSwapNeeded: false,
createdAt: new Date(),
})
// ATOMIC commit: no proof transitions, reservation row deleted,
// tx → PENDING with outputToken.
proofsStore.commitReservation(reservation as any, {
transactionUpdate: {
id: tx.id,
status: TransactionStatus.PENDING,
data: JSON.stringify(transactionData),
outputToken,
balanceAfter,
},
})
// Online no-swap also benefits from the ws monitor (recipient claim
// detection). Offline doesn't — recipient may be offline indefinitely.
if (path === 'online-no-swap') {
_monitorSentProofs({mintUrl, proofsToSend: CashuUtils.exportProofs(lockedProofs)})
}
}
const refreshed = transactionsStore.findById(tx.id)!
if (!isPending(refreshed)) {
throw new WalletError('Transaction did not transition to PENDING after execute', {
transactionId: tx.id,
status: refreshed.status,
})
}
return refreshed
}
// ─────────────────────────────────────────────────────────────────────────────
// cancel() — PREPARED → REVERTED
// ─────────────────────────────────────────────────────────────────────────────
async function cancel(transactionId: number): Promise<RevertedTransaction> {
const tx = transactionsStore.findById(transactionId)
if (!tx) {
throw new ValidationError('Transaction not found', {transactionId})
}
if (!isPrepared(tx)) {
throw new ValidationError(
`Cannot cancel send in state ${tx.status}. Expected PREPARED.`,
{transactionId, status: tx.status},
)
}
const reservation = _findReservationForTx(transactionId)
if (!reservation) {
// No reservation found — rare race or already rolled back. Just mark
// the tx as REVERTED so the UI reflects user intent.
const transactionData = _parseData(tx)
transactionData.push({status: TransactionStatus.REVERTED, message: 'No reservation to roll back', createdAt: new Date()})
tx.update({status: TransactionStatus.REVERTED, data: JSON.stringify(transactionData)})
log.warn('[SendOperationApi.cancel]', 'No open reservation found; marked tx REVERTED', {transactionId})
return _assertReverted(tx, transactionId)
}
const transactionData = _parseData(tx)
transactionData.push({
status: TransactionStatus.REVERTED,
cancelledBy: 'user',
createdAt: new Date(),
})
// The reservation rollback already atomically restores proofs to UNSPENT
// and deletes the reservation row. We still need to mark the tx REVERTED
// — separate SQLite write since rollback doesn't yet accept transactionUpdate.
proofsStore.rollbackReservation(reservation)
tx.update({
status: TransactionStatus.REVERTED,
data: JSON.stringify(transactionData),
})
log.info('[SendOperationApi.cancel]', 'Cancelled', {transactionId})
return _assertReverted(tx, transactionId)
}
// ─────────────────────────────────────────────────────────────────────────────
// reclaim() — PENDING → REVERTED via reclaim swap (today's revertTask logic)
// ─────────────────────────────────────────────────────────────────────────────
async function reclaim(transactionId: number): Promise<RevertedTransaction> {
const tx = transactionsStore.findById(transactionId)
if (!tx) {
throw new ValidationError('Transaction not found', {transactionId})
}
if (!isPending(tx)) {
throw new ValidationError(
`Cannot reclaim send in state ${tx.status}. Expected PENDING.`,
{transactionId, status: tx.status},
)
}
// For first cut, delegate to the existing revertTask path which already
// does the reclaim swap correctly. The current revertTask is invoked via
// the queue; here we want a direct call for the lifecycle API.
const {revertTask} = await import('../revertTask')
const result = await revertTask(tx)
if (result.error) {
throw new MintError(result.error.message ?? 'Reclaim failed', {transactionId})
}
const refreshed = transactionsStore.findById(transactionId)!
return refreshed as RevertedTransaction
}
// ─────────────────────────────────────────────────────────────────────────────
// finalize() — PENDING → COMPLETED (idempotent)
// ─────────────────────────────────────────────────────────────────────────────
async function finalize(transactionId: number): Promise<CompletedTransaction> {
const tx = transactionsStore.findById(transactionId)
if (!tx) {
throw new ValidationError('Transaction not found', {transactionId})
}
if (tx.status === TransactionStatus.COMPLETED) {
return tx as CompletedTransaction
}
if (!isPending(tx)) {
throw new ValidationError(
`Cannot finalize send in state ${tx.status}. Expected PENDING or COMPLETED.`,
{transactionId, status: tx.status},
)
}
// Confirm with the mint that the outgoing proofs are actually SPENT.
const sendProofs = proofsStore
.getByTransactionId(tx.id)
.filter(p => p.state === 'PENDING')
if (sendProofs.length === 0) {
// Already cleaned up by sync. Just flip the status.
const transactionData = _parseData(tx)
transactionData.push({status: TransactionStatus.COMPLETED, createdAt: new Date()})
tx.update({status: TransactionStatus.COMPLETED, data: JSON.stringify(transactionData)})
return _assertCompleted(tx, transactionId)
}
const result = await WalletTask.syncStateWithMintQueueAwaitable({
proofsToSync: sendProofs,
mintUrl: tx.mint,
proofState: 'PENDING',
})
if (result.completedTransactionIds.includes(transactionId)) {
return _assertCompleted(tx, transactionId)
}
// Mint didn't report all proofs SPENT yet — leave as PENDING.
throw new MintError('Send is not yet claimable as finalized — proofs are not all SPENT at the mint.', {
transactionId,
})
}
// ─────────────────────────────────────────────────────────────────────────────
// refresh() — re-check state with the mint
// ─────────────────────────────────────────────────────────────────────────────
async function refresh(transactionId: number): Promise<Transaction> {
const tx = transactionsStore.findById(transactionId)
if (!tx) {
throw new ValidationError('Transaction not found', {transactionId})
}
if (!isPending(tx)) {
return tx
}
const sendProofs = proofsStore
.getByTransactionId(tx.id)
.filter(p => p.state === 'PENDING')
if (sendProofs.length === 0) {
return tx
}
await WalletTask.syncStateWithMintQueueAwaitable({
proofsToSync: sendProofs,
mintUrl: tx.mint,
proofState: 'PENDING',
})
return transactionsStore.findById(transactionId) ?? tx
}
// ─────────────────────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────────────────────
function _findReservationForTx(transactionId: number): ProofReservation | undefined {
const all = Database.getOpenReservations()
const row = all.find(r => r.transactionId === transactionId)
return row ? _rowToReservation(row) : undefined
}
/**
* Project a stored ReservationRow back into a ProofReservation (the shape
* `proofsStore.commitReservation/rollbackReservation` expects). The only
* structural difference is `unit: string` vs `unit: MintUnit` and the extra
* `createdAt` field, both safe to narrow/drop.
*/
function _rowToReservation(row: ReservationRow): ProofReservation {
return {
id: row.id,
transactionId: row.transactionId,
mintUrl: row.mintUrl,
unit: row.unit as MintUnit,
operationType: row.operationType,
lockedProofs: row.lockedProofs,
}
}
function _parseData(tx: Transaction): TransactionData[] {
try {
return JSON.parse(tx.data)
} catch {
return []
}
}
/**
* After a status-changing `tx.update()`, the MST instance is at the new state
* at runtime but TypeScript still has its old narrowed view. These helpers
* re-fetch and use a type guard to refine the return type properly — failing
* loudly if the update somehow didn't take effect.
*/
function _assertReverted(tx: Transaction, transactionId: number): RevertedTransaction {
const refreshed = transactionsStore.findById(transactionId) ?? tx
if (!isReverted(refreshed)) {
throw new WalletError('Transaction did not transition to REVERTED', {
transactionId,
status: refreshed.status,
})
}
return refreshed
}
function _assertCompleted(tx: Transaction, transactionId: number): CompletedTransaction {
const refreshed = transactionsStore.findById(transactionId) ?? tx
if (!isCompleted(refreshed)) {
throw new WalletError('Transaction did not transition to COMPLETED', {
transactionId,
status: refreshed.status,
})
}
return refreshed
}
/**
* Subscribe to mint websocket for proofStateUpdates on the sent token.
* When the recipient claims (proof goes SPENT), enqueue a sync that
* finalizes our tx → COMPLETED via the existing sync path.
*
* Falls back to a poller if the websocket subscription fails (mints over
* Tor / firewalled environments).
*/
async function _monitorSentProofs(params: {mintUrl: string; proofsToSend: CashuProof[]}) {
const {mintUrl, proofsToSend} = params
const proofsToSync = proofsStore.getByMint(mintUrl, {state: 'PENDING'})
const wsMint = new CashuMint(mintUrl)
const wsWallet = new CashuWallet(wsMint)
try {
log.trace('[SendOperationApi]', 'Subscribing to proofStateUpdates', {secret: proofsToSend[0]?.secret})
const unsub = await wsWallet.on.proofStateUpdates(
normalizeProofAmounts([proofsToSend[0]]),
async (proofState: CashuProofState) => {
log.trace(`[SendOperationApi] Websocket: proof state updated: ${proofState.state} with secret: ${proofsToSend[0].secret}`)
if (proofState.state === CheckStateEnum.SPENT) {
WalletTask.syncStateWithMintQueueAwaitable({proofsToSync, mintUrl, proofState: 'PENDING'})
unsub()
}
},
async (error: any) => {
throw error
},
)
} catch (error: any) {
log.error(Err.NETWORK_ERROR, 'WebSocket subscription failed. Starting poller.', error.message)
poller(
`syncStateWithMintPoller-${mintUrl}`,
WalletTask.syncStateWithMintQueueAwaitable,
{interval: 10 * 1000, maxPolls: 3, maxErrors: 1},
{proofsToSync, mintUrl, proofState: 'PENDING' as const},
).then(() => log.trace('[SendOperationApi]', 'polling completed', {mintUrl}))
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Public API export
// ─────────────────────────────────────────────────────────────────────────────
export const SendOperationApi = {
prepare,
execute,
cancel,
reclaim,
finalize,
refresh,
}
+130 -68
View File
@@ -6,6 +6,8 @@ import {rootStoreInstance} from '../../../models'
import {Proof, ProofState} from '../../../models/Proof' import {Proof, ProofState} from '../../../models/Proof'
import {MintStatus} from '../../../models/Mint' import {MintStatus} from '../../../models/Mint'
import { import {
Transaction,
TransactionData,
TransactionStatus, TransactionStatus,
TransactionType, TransactionType,
} from '../../../models/Transaction' } from '../../../models/Transaction'
@@ -15,7 +17,8 @@ import {stopPolling} from '../../../utils/poller'
import {sendTask} from '../sendTask' import {sendTask} from '../sendTask'
import {createQueueAwaitable} from '../queueHelper' import {createQueueAwaitable} from '../queueHelper'
import {receiveBatchTask} from './receiveOperations' import {receiveBatchTask} from './receiveOperations'
import {MeltOperationService} from './meltOperations' import {SendOperationApi} from './sendOperationApi'
import {TransferOperationApi} from './transferOperationApi'
import { import {
MAX_SWAP_INPUT_SIZE, MAX_SWAP_INPUT_SIZE,
MAX_SYNC_INPUT_SIZE, MAX_SYNC_INPUT_SIZE,
@@ -114,6 +117,13 @@ const syncStateWithMintTask = async function (
} }
// 1. Proofs now SPENT at mint → transaction succeeded // 1. Proofs now SPENT at mint → transaction succeeded
//
// Bulk-move proofs to SPENT (one SQL write for N proofs), then
// dispatch per-tx to the appropriate operation API's `finalize`. Each
// finalize is idempotent and sync-aware: it sees no PENDING proofs
// left (we just bulk-moved them) and just flips the tx status, with
// any side-effects (e.g. melt change recovery for TRANSFER) handled
// atomically alongside the status update.
if (secrets.spent.size > 0) { if (secrets.spent.size > 0) {
const spentProofs = aliveProofs.filter(p => secrets.spent.has(p.secret)) const spentProofs = aliveProofs.filter(p => secrets.spent.has(p.secret))
const spentByTx = groupByTId(spentProofs) const spentByTx = groupByTId(spentProofs)
@@ -132,6 +142,11 @@ const syncStateWithMintTask = async function (
} }
if (spentAmount < tx.amount) { if (spentAmount < tx.amount) {
// Partial spend has no clean lifecycle equivalent — proofs
// were reused outside this wallet's control, so we stamp
// ERROR directly and release any still-UNSPENT siblings
// back to the spendable pool.
_markErrorBySync(tx, 'Partial spend detected – proofs reused')
errorTxIds.push(tId) errorTxIds.push(tId)
transactionStateUpdates.push({ transactionStateUpdates.push({
tId, tId,
@@ -145,58 +160,50 @@ const syncStateWithMintTask = async function (
const stillUnspent = aliveProofs.filter(p => secrets.unspent.has(p.secret)) const stillUnspent = aliveProofs.filter(p => secrets.unspent.has(p.secret))
proofsStore.revertToSpendable(stillUnspent) proofsStore.revertToSpendable(stillUnspent)
} }
} else if (tx.status !== TransactionStatus.REVERTED) { continue
}
if (tx.status === TransactionStatus.REVERTED) {
// Reclaim already happened — leave as REVERTED.
continue
}
try {
await _dispatchFinalize(tx)
completedTxIds.push(tId) completedTxIds.push(tId)
const update: TransactionStateUpdate = { transactionStateUpdates.push({
tId, tId,
amount: tx.amount, amount: tx.amount,
spentByMintAmount: spentAmount, spentByMintAmount: spentAmount,
updatedStatus: TransactionStatus.COMPLETED, 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 } catch (e: any) {
log.error('[syncStateWithMintTask] finalize dispatch failed', {
tId,
type: tx.type,
error: e.message,
})
_markErrorBySync(tx, e.message)
errorTxIds.push(tId)
transactionStateUpdates.push({
tId,
amount: tx.amount,
spentByMintAmount: spentAmount,
updatedStatus: TransactionStatus.ERROR,
message: `Finalize failed: ${e.message}`,
})
} }
} }
if (completedTxIds.length > 0) { if (completedTxIds.length > 0 || errorTxIds.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}`) stopPolling(`syncStateWithMintPoller-${mintUrl}`)
} }
} }
// 2. Proofs still PENDING at mint → keep pending in wallet // 2. Proofs still PENDING at mint → register as mint-pending, mark
// owning tx PENDING. No lifecycle equivalent — this is a sub-state
// (locally PENDING + mint-side PENDING) tracked via pendingByMintSecrets
// so branch 3 can later detect lightning failures.
if (secrets.pending.size > 0) { if (secrets.pending.size > 0) {
const newPendingProofs = aliveProofs.filter(p => secrets.pending.has(p.secret) && !proofsStore.pendingByMintSecrets.includes(p.secret)) const newPendingProofs = aliveProofs.filter(p => secrets.pending.has(p.secret) && !proofsStore.pendingByMintSecrets.includes(p.secret))
@@ -231,46 +238,59 @@ const syncStateWithMintTask = async function (
} }
} }
// 3. Proofs no longer pending at mint → lightning failed → revert // 3. Proofs no longer pending at mint → lightning failed.
//
// Dispatch each affected tx to `TransferOperationApi.refresh` — its
// UNPAID branch reverts proofs to spendable and stamps the tx
// REVERTED. (Only TRANSFER txs ever hit this branch; melts are the
// only operation that registers mint-pending state.)
const noLongerPendingSecrets = proofsStore.pendingByMintSecrets.filter( const noLongerPendingSecrets = proofsStore.pendingByMintSecrets.filter(
s => !secrets.pending.has(s), s => !secrets.pending.has(s),
) )
if (noLongerPendingSecrets.length > 0) { if (noLongerPendingSecrets.length > 0) {
const revertedProofs: Proof[] = [] // Unregister all at once; per-tx refresh handles the proof revert.
const revertedByTx = new Map<number, number>() const txsToRefresh = new Map<number, number>()
for (const secret of noLongerPendingSecrets) { for (const secret of noLongerPendingSecrets) {
const proof = proofsStore.getBySecret(secret) const proof = proofsStore.getBySecret(secret)
if (!proof || !proof.tId) continue if (!proof || !proof.tId) continue
txsToRefresh.set(proof.tId, (txsToRefresh.get(proof.tId) ?? 0) + proof.amount)
revertedProofs.push(proof)
revertedByTx.set(proof.tId, (revertedByTx.get(proof.tId) ?? 0) + proof.amount)
if (!revertedTxIds.includes(proof.tId)) revertedTxIds.push(proof.tId)
} }
proofsStore.unregisterFromPendingAtMint(new Set(noLongerPendingSecrets))
if (revertedProofs.length > 0) { for (const [tId, amount] of txsToRefresh) {
proofsStore.revertToSpendable(revertedProofs) const tx = transactionsStore.findById(tId)
proofsStore.unregisterFromPendingAtMint(new Set(noLongerPendingSecrets)) if (!tx) continue
for (const [tId, amount] of revertedByTx) { if (tx.type !== TransactionType.TRANSFER) {
log.warn(
'[syncStateWithMintTask] Unexpected non-TRANSFER tx in branch 3',
{tId, type: tx.type},
)
continue
}
try {
await TransferOperationApi.refresh(tId)
if (!revertedTxIds.includes(tId)) revertedTxIds.push(tId)
transactionStateUpdates.push({ transactionStateUpdates.push({
tId, tId,
movedToSpendableAmount: amount, movedToSpendableAmount: amount,
updatedStatus: TransactionStatus.REVERTED, updatedStatus: TransactionStatus.REVERTED,
}) })
} } catch (e: any) {
log.error('[syncStateWithMintTask] refresh dispatch failed', {
if (revertedTxIds.length > 0) { tId,
await transactionsStore.updateStatuses( error: e.message,
revertedTxIds, })
TransactionStatus.REVERTED, _markErrorBySync(tx, e.message)
JSON.stringify({ errorTxIds.push(tId)
message: 'Lightning payment failed – ecash returned to spendable balance', transactionStateUpdates.push({
revertedStateUpdates: transactionStateUpdates.filter(u => u.updatedStatus === TransactionStatus.REVERTED), tId,
createdAt: new Date(), movedToSpendableAmount: amount,
}), updatedStatus: TransactionStatus.ERROR,
) message: `Refresh failed: ${e.message}`,
})
} }
} }
} }
@@ -306,6 +326,48 @@ const syncStateWithMintTask = async function (
} }
} }
/**
* Route a sync-confirmed-SPENT transaction to the appropriate operation API's
* `finalize`. Sync has already bulk-moved the proofs to SPENT, so each
* finalize sees an empty PENDING set and just stamps the tx COMPLETED (and,
* for TRANSFER, recovers melt change atomically with the status update).
*
* Only SEND and TRANSFER are expected here — other types don't park proofs
* in PENDING that sync could later observe as SPENT.
*/
async function _dispatchFinalize(tx: Transaction): Promise<void> {
switch (tx.type) {
case TransactionType.SEND:
await SendOperationApi.finalize(tx.id)
return
case TransactionType.TRANSFER:
await TransferOperationApi.finalize(tx.id)
return
default:
log.warn('[syncStateWithMintTask] Unexpected tx type in finalize dispatch', {
tId: tx.id,
type: tx.type,
})
}
}
/**
* Stamp a transaction as ERROR with the supplied reason. Used for sync's
* type-agnostic failure paths (partial spend, finalize/refresh dispatch
* throws) — there's no lifecycle-method equivalent for "external system
* forced this tx into an error state."
*/
function _markErrorBySync(tx: Transaction, reason: string): void {
let txData: TransactionData[] = []
try { txData = JSON.parse(tx.data) } catch {}
txData.push({
status: TransactionStatus.ERROR,
message: reason,
createdAt: new Date(),
})
tx.update({status: TransactionStatus.ERROR, data: JSON.stringify(txData)})
}
const syncStateWithAllMintsTask = async function (options: { const syncStateWithAllMintsTask = async function (options: {
proofState: ProofState proofState: ProofState
}): Promise<SyncStateTaskResult> { }): Promise<SyncStateTaskResult> {
@@ -468,7 +530,7 @@ const swapAllTask = async function (): Promise<WalletTaskResult> {
batch, batch,
) )
const encodedTokenToReceive: string = sendResult.encodedTokenToSend const encodedTokenToReceive: string = sendResult.transaction?.outputToken ?? ''
const tokenToReceive = getDecodedToken(encodedTokenToReceive, mint.keysetIds) const tokenToReceive = getDecodedToken(encodedTokenToReceive, mint.keysetIds)
const tokenAmount = CashuUtils.getProofsAmount(tokenToReceive.proofs) const tokenAmount = CashuUtils.getProofsAmount(tokenToReceive.proofs)
@@ -501,7 +563,7 @@ const swapAllTask = async function (): Promise<WalletTaskResult> {
proofsToOptimize, proofsToOptimize,
) )
const encodedTokenToReceive: string = sendResult.encodedTokenToSend const encodedTokenToReceive: string = sendResult.transaction?.outputToken ?? ''
const tokenToReceive = getDecodedToken(encodedTokenToReceive, mint.keysetIds) const tokenToReceive = getDecodedToken(encodedTokenToReceive, mint.keysetIds)
const tokenAmount = CashuUtils.getProofsAmount(tokenToReceive.proofs) const tokenAmount = CashuUtils.getProofsAmount(tokenToReceive.proofs)
@@ -577,7 +639,7 @@ const swapByDenominationTask = async function (denomination: number): Promise<Wa
batch, batch,
) )
const encodedTokenToReceive: string = sendResult.encodedTokenToSend const encodedTokenToReceive: string = sendResult.transaction?.outputToken ?? ''
const tokenToReceive = getDecodedToken(encodedTokenToReceive, mint.keysetIds) const tokenToReceive = getDecodedToken(encodedTokenToReceive, mint.keysetIds)
const tokenAmount = CashuUtils.getProofsAmount(tokenToReceive.proofs) const tokenAmount = CashuUtils.getProofsAmount(tokenToReceive.proofs)
@@ -608,7 +670,7 @@ const swapByDenominationTask = async function (denomination: number): Promise<Wa
proofsToOptimize, proofsToOptimize,
) )
const encodedTokenToReceive: string = sendResult.encodedTokenToSend const encodedTokenToReceive: string = sendResult.transaction?.outputToken ?? ''
const tokenToReceive = getDecodedToken(encodedTokenToReceive, mint.keysetIds) const tokenToReceive = getDecodedToken(encodedTokenToReceive, mint.keysetIds)
const tokenAmount = CashuUtils.getProofsAmount(tokenToReceive.proofs) const tokenAmount = CashuUtils.getProofsAmount(tokenToReceive.proofs)
@@ -0,0 +1,47 @@
/**
* Pluggable topup-method registry (extensibility hook).
*
* A "topup" pays funds INTO the mint and receives freshly-minted ecash. Each
* entry in `TopupMethodOptions` is a funding rail — the way the user pays the
* mint. The discriminator lets `TopupOperationApi` accept a typed payload per
* method without growing a parameter for each new rail.
*
* Today's methods:
* - `bolt11`: lightning invoice mint (NUT-04, the only funding rail Minibits
* currently supports).
*
* Future methods drop in by adding entries here — for example NUT-23 onchain
* mint would add `onchain: { address, ... }` and the state machine in
* `TopupOperationApi` stays the same (only the wait/poll logic differs).
*/
import {Contact} from '../../../models/Contact'
export interface TopupMethodOptions {
/**
* NUT-04 BOLT11 lightning mint.
* - `contactToSendTo`: optional contact the invoice is shared with — stored
* on the transaction as `sentFrom` for display in the history (the
* contact is the one paying us, so they're the "from" party).
*/
bolt11: {
contactToSendTo?: Contact
}
}
export type TopupMethod = keyof TopupMethodOptions
export type TopupMethodPayload<M extends TopupMethod = TopupMethod> =
TopupMethodOptions[M]
/**
* Method-tagged payload, the canonical input shape for
* `TopupOperationApi.prepare`.
*
* Example:
* { method: 'bolt11', options: { contactToSendTo } }
*/
export type TopupMethodInput = {
method: 'bolt11'
options: TopupMethodOptions['bolt11']
}
@@ -0,0 +1,652 @@
/**
* Topup (lightning mint) operation lifecycle API.
*
* Splits the historical `topupTask` + `handlePendingTopupTask` pair into
* explicit lifecycle methods:
*
* prepare() → PreparedTopupData (DRAFT → PREPARED, mint quote created)
* execute() → PendingTransaction (PREPARED → PENDING, ws/poller armed
* to watch for external payment)
* cancel() → RevertedTransaction (PREPARED|PENDING → REVERTED, polling
* stopped)
* reclaim() → never (not applicable to topups — nothing to
* reclaim)
* finalize() → CompletedTransaction (PENDING → COMPLETED, mint proofs and
* add them as UNSPENT)
* refresh() → Transaction (re-check the mint quote; routes to
* finalize / EXPIRED / no-op based on
* quote state)
*
* Unlike SEND/TRANSFER, the "lock" here is a mint-side quote, not local
* proofs. There's no reservation row opened during prepare/execute — the
* waiting-for-payment window is driven by the mint quote's expiry.
*
* `finalize` opens a short-lived empty reservation purely to leverage the
* atomic-commit primitive (proofs INSERT + tx UPDATE in one SQLite txn).
*
* The legacy `WalletTask.topupQueueAwaitable` and `handlePendingTopupTask`
* continue to work via thin wrappers that call into this API.
*/
import {addSeconds, isBefore} from 'date-fns'
import {
Mint as CashuMint,
Wallet as CashuWallet,
MintQuoteBolt11Response,
MintQuoteState,
} from '@cashu/cashu-ts'
import {getSnapshot, isStateTreeNode} from 'mobx-state-tree'
import {log} from '../../logService'
import {MintError, ValidationError, WalletError} from '../../../utils/AppError'
import {rootStoreInstance} from '../../../models'
import {MintBalance} from '../../../models/Mint'
import {
Transaction,
TransactionData,
TransactionStatus,
TransactionType,
} from '../../../models/Transaction'
import {
CompletedTransaction,
PendingTransaction,
PreparedTransaction,
RevertedTransaction,
isCompleted,
isPending,
isPrepared,
isReverted,
} from '../../../models/TransactionStates'
import {CashuProof} from '../../cashu/cashuUtils'
import {LightningUtils} from '../../lightning/lightningUtils'
import {MintUnit, formatCurrency, getCurrency} from '../currency'
import {NostrEvent} from '../../nostrService'
import {WalletUtils} from '../utils'
import {poller, stopPolling} from '../../../utils/poller'
import {Err} from '../../../utils/AppError'
import {TopupMethodInput} from './topupMethods'
import {sendTopupNotification} from '../notifications'
const {mintsStore, proofsStore, transactionsStore, walletStore, walletProfileStore} =
rootStoreInstance
// ─────────────────────────────────────────────────────────────────────────────
// Public types
// ─────────────────────────────────────────────────────────────────────────────
export interface PrepareTopupInput {
mintBalance: MintBalance
/** Amount in `unit` to mint. */
amount: number
unit: MintUnit
memo: string
/** Topup method discriminator (currently only `bolt11`). */
method: TopupMethodInput
/** NWC request that triggered this topup (optional). */
nwcEvent?: NostrEvent
}
/**
* Returned by `prepare`. Carries the encoded invoice the user needs to pay
* externally, plus everything `execute` needs to start the watch.
*/
export interface PreparedTopupData {
transactionId: number
/** Snapshot of the tx at prepare time. Re-fetch by id for live state. */
tx: PreparedTransaction
mintUrl: string
unit: MintUnit
amountToTopup: number
/** Mint quote id — used for state checks and finalize. */
quote: string
/** BOLT11 invoice the user pays to fund the mint. */
encodedInvoice: string
/** Parsed invoice expiry (or 24h fallback if invoice has no expiry tag). */
expiresAt: Date
method: TopupMethodInput
nwcEvent?: NostrEvent
}
// ─────────────────────────────────────────────────────────────────────────────
// prepare()
// ─────────────────────────────────────────────────────────────────────────────
async function prepare(input: PrepareTopupInput): Promise<PreparedTopupData> {
const {mintBalance, amount, unit, memo, method, nwcEvent} = input
if (amount <= 0) {
throw new ValidationError('Amount to topup must be above zero.')
}
if (method.method !== 'bolt11') {
throw new ValidationError(`Unsupported topup method: ${(method as any).method}`)
}
const mintUrl = mintBalance.mintUrl
const mintInstance = mintsStore.findByUrl(mintUrl)
if (!mintInstance) {
throw new ValidationError('Could not find mint', {mintUrl})
}
// ── Create the draft transaction ────────────────────────────────────
const transactionData: TransactionData[] = [
{
status: TransactionStatus.DRAFT,
amountToTopup: amount,
unit,
createdAt: new Date(),
},
]
const transaction = await transactionsStore.addTransaction({
type: TransactionType.TOPUP,
amount,
fee: 0,
unit,
data: JSON.stringify(transactionData),
memo,
mint: mintUrl,
status: TransactionStatus.DRAFT,
})
if (!transaction) {
throw new ValidationError('Failed to create draft transaction')
}
const transactionId = transaction.id
// ── Ask the mint for a quote (the BOLT11 invoice the user will pay) ─
const {encodedInvoice, mintQuote} = await walletStore.createLightningMintQuote(
mintUrl,
unit,
amount,
memo,
)
const decodedInvoice = LightningUtils.decodeInvoice(encodedInvoice)
const {
payment_hash: paymentHash,
expiry,
timestamp,
} = LightningUtils.getInvoiceData(decodedInvoice)
// Fallback to 24h if the invoice has no expiry tag.
const expiresAt =
expiry && expiry > 0
? addSeconds(new Date(timestamp * 1000), expiry)
: addSeconds(new Date(timestamp * 1000), 86400)
// Snapshot the (private) contact for display in the history. Public
// contacts are plain objects already.
const contactTo = isStateTreeNode(method.options.contactToSendTo)
? getSnapshot(method.options.contactToSendTo)
: method.options.contactToSendTo
const sentFromValue = contactTo?.nip05 || contactTo?.name || ''
const sentToValue = walletProfileStore.nip05 || ''
// ── Transition DRAFT → PREPARED ─────────────────────────────────────
transactionData.push({
status: TransactionStatus.PREPARED,
quote: mintQuote,
method: method.method,
createdAt: new Date(),
})
transaction.update({
status: TransactionStatus.PREPARED,
quote: mintQuote,
paymentId: paymentHash,
paymentRequest: encodedInvoice,
expiresAt,
sentFrom: sentFromValue || undefined,
sentTo: sentToValue || undefined,
data: JSON.stringify(transactionData),
})
if (!isPrepared(transaction)) {
throw new WalletError('Failed to transition transaction to PREPARED', {
transactionId,
status: transaction.status,
})
}
log.debug('[TopupOperationApi.prepare]', 'Prepared', {
transactionId,
amount,
quote: mintQuote,
expiresAt,
})
return {
transactionId,
tx: transaction,
mintUrl,
unit,
amountToTopup: amount,
quote: mintQuote,
encodedInvoice,
expiresAt,
method,
nwcEvent,
}
}
// ─────────────────────────────────────────────────────────────────────────────
// execute()
//
// Transitions PREPARED → PENDING and arms the ws/poller watcher.
// The user pays the invoice externally; the watcher calls `refresh` when the
// mint reports state change.
// ─────────────────────────────────────────────────────────────────────────────
async function execute(prepared: PreparedTopupData): Promise<PendingTransaction> {
const tx = transactionsStore.findById(prepared.transactionId)
if (!tx) {
throw new ValidationError('Transaction not found', {transactionId: prepared.transactionId})
}
if (!isPrepared(tx)) {
throw new ValidationError(
`Cannot execute topup in state ${tx.status}. Expected PREPARED.`,
{transactionId: tx.id, status: tx.status},
)
}
const transactionData = _parseData(tx)
transactionData.push({
status: TransactionStatus.PENDING,
createdAt: new Date(),
})
tx.update({
status: TransactionStatus.PENDING,
data: JSON.stringify(transactionData),
})
// NWC-driven topups are short-lived requests; the caller polls the queue,
// so we don't set up a long-running ws/poller for them.
if (!prepared.nwcEvent) {
_monitorMintQuote({
mintUrl: prepared.mintUrl,
quote: prepared.quote,
paymentHash: tx.paymentId ?? prepared.quote,
transactionId: tx.id,
})
}
const refreshed = transactionsStore.findById(tx.id)!
if (!isPending(refreshed)) {
throw new WalletError(
'Transaction did not transition to PENDING after execute',
{transactionId: tx.id, status: refreshed.status},
)
}
return refreshed
}
// ─────────────────────────────────────────────────────────────────────────────
// cancel() — PREPARED|PENDING → REVERTED
//
// Marks the topup as abandoned. The mint quote stays open at the mint
// (no API to invalidate it), so if the user later pays the invoice anyway,
// the funds can be recovered via `recoverMintQuote`.
// ─────────────────────────────────────────────────────────────────────────────
async function cancel(transactionId: number): Promise<RevertedTransaction> {
const tx = transactionsStore.findById(transactionId)
if (!tx) {
throw new ValidationError('Transaction not found', {transactionId})
}
if (!isPrepared(tx) && !isPending(tx)) {
throw new ValidationError(
`Cannot cancel topup in state ${tx.status}. Expected PREPARED or PENDING.`,
{transactionId, status: tx.status},
)
}
const transactionData = _parseData(tx)
transactionData.push({
status: TransactionStatus.REVERTED,
cancelledBy: 'user',
createdAt: new Date(),
})
tx.update({
status: TransactionStatus.REVERTED,
data: JSON.stringify(transactionData),
})
if (tx.paymentId) stopPolling(`handlePendingTopupPoller-${tx.paymentId}`)
log.info('[TopupOperationApi.cancel]', 'Cancelled', {transactionId})
return _assertReverted(tx, transactionId)
}
// ─────────────────────────────────────────────────────────────────────────────
// reclaim() — not applicable to topup
//
// Once a topup is finalized (proofs minted), there's nothing to reclaim — the
// ecash is already in the wallet. Before finalize, cancel() handles abandonment.
// Kept on the API surface for symmetry with the other operation APIs.
// ─────────────────────────────────────────────────────────────────────────────
async function reclaim(_transactionId: number): Promise<never> {
throw new ValidationError(
'Topup operations cannot be reclaimed. Use cancel() to abandon a PENDING topup.',
)
}
// ─────────────────────────────────────────────────────────────────────────────
// finalize() — PENDING → COMPLETED (mint proofs after invoice is PAID).
//
// Verifies the quote is PAID, then mints proofs and atomically commits:
// - new proofs INSERT (UNSPENT)
// - tx UPDATE → COMPLETED + balanceAfter
// in one SQLite transaction (via an empty reservation used purely as the
// atomic-batch primitive).
// ─────────────────────────────────────────────────────────────────────────────
async function finalize(transactionId: number): Promise<CompletedTransaction> {
const tx = transactionsStore.findById(transactionId)
if (!tx) {
throw new ValidationError('Transaction not found', {transactionId})
}
if (tx.status === TransactionStatus.COMPLETED) {
return tx as CompletedTransaction
}
if (!isPending(tx)) {
throw new ValidationError(
`Cannot finalize topup in state ${tx.status}. Expected PENDING or COMPLETED.`,
{transactionId, status: tx.status},
)
}
if (!tx.quote) {
throw new ValidationError('Topup has no quote id; cannot finalize.', {transactionId})
}
const {state} = await walletStore.checkLightningMintQuote(tx.mint, tx.quote)
if (state !== MintQuoteState.PAID) {
throw new MintError(
`Cannot finalize topup; mint reports quote state ${state}.`,
{transactionId, state},
)
}
return _finalizePaid(tx)
}
// ─────────────────────────────────────────────────────────────────────────────
// refresh() — re-check PENDING topup with the mint.
//
// Mirrors the legacy `handlePendingTopupTask` flow:
// - quote PAID: → finalize (COMPLETED).
// - quote ISSUED: proofs already minted (likely from another device);
// mark COMPLETED with a note, don't try to re-mint.
// - quote UNPAID:
// - invoice expired → mark EXPIRED, stop polling.
// - invoice still valid → no-op, watcher will fire again later.
// ─────────────────────────────────────────────────────────────────────────────
async function refresh(transactionId: number): Promise<Transaction> {
const tx = transactionsStore.findById(transactionId)
if (!tx) {
throw new ValidationError('Transaction not found', {transactionId})
}
if (!isPending(tx)) {
return tx
}
if (!tx.quote) {
log.warn('[TopupOperationApi.refresh] PENDING topup missing quote id; skipping', {
transactionId,
})
return tx
}
const {state} = await walletStore.checkLightningMintQuote(tx.mint, tx.quote)
const isExpired = !!tx.expiresAt && isBefore(tx.expiresAt, new Date())
const paymentHash = tx.paymentId
if (isExpired && state !== MintQuoteState.PAID) {
const txData = _parseData(tx)
txData.push({
status: TransactionStatus.EXPIRED,
message: 'Invoice expired',
createdAt: new Date(),
})
tx.update({status: TransactionStatus.EXPIRED, data: JSON.stringify(txData)})
if (paymentHash) stopPolling(`handlePendingTopupPoller-${paymentHash}`)
log.debug('[TopupOperationApi.refresh] Invoice expired and not paid', {
transactionId,
paymentHash,
})
return tx
}
switch (state) {
case MintQuoteState.UNPAID:
log.trace('[TopupOperationApi.refresh] Quote still unpaid', {
transactionId,
quote: tx.quote,
})
return tx
case MintQuoteState.ISSUED: {
// Proofs already minted at the mint (likely from another device
// sharing the same seed). We can't double-mint, so just mark the
// tx COMPLETED with a note.
const txData = _parseData(tx)
txData.push({
status: TransactionStatus.COMPLETED,
note: 'Already issued',
createdAt: new Date(),
})
tx.update({status: TransactionStatus.COMPLETED, data: JSON.stringify(txData)})
if (paymentHash) stopPolling(`handlePendingTopupPoller-${paymentHash}`)
log.info(
'[TopupOperationApi.refresh] Proofs already issued (likely from another device)',
{transactionId, quote: tx.quote},
)
return tx
}
case MintQuoteState.PAID:
log.debug('[TopupOperationApi.refresh] Quote PAID, minting proofs', {
transactionId,
paymentHash,
})
return _finalizePaid(tx)
default:
log.error('[TopupOperationApi.refresh] Unknown quote state', {state})
return tx
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────────────────────
/**
* Atomic settle of a confirmed-PAID topup: mint proofs, then commit
* (proofs INSERT + tx UPDATE → COMPLETED) in one SQLite transaction.
*
* Uses an empty reservation purely as the atomic-batch primitive — there are
* no local proofs to lock for a topup (the "lock" is the mint-side quote).
*/
async function _finalizePaid(tx: Transaction): Promise<CompletedTransaction> {
const transactionId = tx.id
const mintUrl = tx.mint
const unit = tx.unit
const amount = tx.amount
const mintQuote = tx.quote!
const paymentHash = tx.paymentId
let proofs: CashuProof[] = []
try {
proofs = await walletStore.mintProofs(mintUrl, amount, unit, mintQuote, transactionId)
} catch (e: any) {
if (WalletUtils.shouldHealOutputsError(e)) {
log.error(
'[TopupOperationApi._finalizePaid] Increasing proofsCounter outdated values and repeating mintProofs.',
)
proofs = await walletStore.mintProofs(
mintUrl,
amount,
unit,
mintQuote,
transactionId,
{increaseCounterBy: 10},
)
} else {
throw e
}
}
if (proofs.length === 0) {
throw new MintError('Mint returned no proofs after payment', {transactionId})
}
// Empty reservation used purely for the atomic-commit primitive: the
// proofs INSERT and the tx UPDATE both live in one SQLite txn.
const reservation = proofsStore.reserve([], {
transactionId,
mintUrl,
unit,
operationType: 'topup-finalize-paid',
rollbackTo: 'UNSPENT',
})
const currentSpendable = proofsStore.getUnitBalance(unit)?.unitBalance ?? 0
const proofsAmount = proofs.reduce((acc, p) => acc + Number(p.amount), 0)
const balanceAfter = currentSpendable + proofsAmount
const txData = _parseData(tx)
txData.push({status: TransactionStatus.COMPLETED, createdAt: new Date()})
proofsStore.commitReservation(reservation, {
newProofs: [{proofs, state: 'UNSPENT', tId: transactionId}],
transactionUpdate: {
id: transactionId,
status: TransactionStatus.COMPLETED,
data: JSON.stringify(txData),
balanceAfter,
},
})
if (paymentHash) stopPolling(`handlePendingTopupPoller-${paymentHash}`)
sendTopupNotification(amount, unit)
log.debug('[TopupOperationApi._finalizePaid] Topup completed', {transactionId, amount})
return _assertCompleted(tx, transactionId)
}
/**
* Subscribe to mint websocket for mintQuotePaid events. When the mint signals
* PAID, route through the `handlePendingTopupTask` queue task (which calls
* `refresh` internally and emits `ev_handlePendingTopupTask_result` for the
* screen listener). Falls back to a poller if the websocket subscription fails.
*
* Going through the queue rather than calling `refresh` directly preserves the
* legacy event surface that screens depend on (TopupScreen, POSScreen,
* TranDetailScreen all listen for `ev_handlePendingTopupTask_result`).
*/
async function _monitorMintQuote(params: {
mintUrl: string
quote: string
paymentHash: string
transactionId: number
}) {
const {mintUrl, quote, paymentHash, transactionId} = params
const wsMint = new CashuMint(mintUrl)
const wsWallet = new CashuWallet(wsMint)
// Lazy import avoids a circular dep: mintOperations imports
// TopupOperationApi for the refresh delegation.
const enqueueRefresh = async () => {
const tx = transactionsStore.findById(transactionId)
if (!tx) return
const {MintOperationService} = await import('./mintOperations')
MintOperationService.enqueuePendingTopupCheck(tx)
}
try {
log.trace('[TopupOperationApi]', 'Subscribing to mintQuotePaid', {quote})
const unsub = await wsWallet.on.mintQuotePaid(
quote,
async (_m: MintQuoteBolt11Response) => {
try {
await enqueueRefresh()
} catch (e: any) {
log.error(
'[TopupOperationApi] enqueue refresh failed in ws callback',
{transactionId, error: e.message},
)
}
unsub()
},
async (error: any) => {
throw error
},
)
} catch (error: any) {
log.error(
Err.NETWORK_ERROR,
'[TopupOperationApi] WebSocket error for mint quote, starting poller.',
error.message,
)
poller(
`handlePendingTopupPoller-${paymentHash}`,
enqueueRefresh,
{interval: 10 * 1000, maxPolls: 6, maxErrors: 2},
).then(() => log.trace('[handlePendingTopupPoller] polling completed', {quote}))
}
}
function _parseData(tx: Transaction): TransactionData[] {
try {
return JSON.parse(tx.data)
} catch {
return []
}
}
function _assertReverted(tx: Transaction, transactionId: number): RevertedTransaction {
const refreshed = transactionsStore.findById(transactionId) ?? tx
if (!isReverted(refreshed)) {
throw new WalletError('Transaction did not transition to REVERTED', {
transactionId,
status: refreshed.status,
})
}
return refreshed
}
function _assertCompleted(tx: Transaction, transactionId: number): CompletedTransaction {
const refreshed = transactionsStore.findById(transactionId) ?? tx
if (!isCompleted(refreshed)) {
throw new WalletError('Transaction did not transition to COMPLETED', {
transactionId,
status: refreshed.status,
})
}
return refreshed
}
// ─────────────────────────────────────────────────────────────────────────────
// Public API export
// ─────────────────────────────────────────────────────────────────────────────
/**
* Format the topup result message in the same style as the legacy
* `handlePendingTopupTask`. Exposed for the wrapper.
*/
export function topupSuccessMessage(amount: number, unit: MintUnit, paidAfterExpiry: boolean) {
const currencyCode = getCurrency(unit).code
return `Topup successful: +${formatCurrency(amount, currencyCode)} ${currencyCode}${
paidAfterExpiry ? ' (paid after expiry)' : ''
}`
}
export const TopupOperationApi = {
prepare,
execute,
cancel,
reclaim,
finalize,
refresh,
}
@@ -0,0 +1,51 @@
/**
* Pluggable transfer-method registry (extensibility hook).
*
* A "transfer" pays funds OUT of the mint. Each entry in `TransferMethodOptions`
* is a payment rail — the way the mint settles the outgoing payment. The
* discriminator lets `TransferOperationApi` accept a typed payload per method
* without growing a parameter for each new rail.
*
* Today's methods:
* - `bolt11`: lightning invoice melt (NUT-05, the only payment rail Minibits
* currently supports).
*
* Future methods drop in by adding entries here — for example NUT-23 onchain
* melt would add `onchain: { address, meltQuote: MeltQuoteBtcOnchainResponse }`
* and the state machine in `TransferOperationApi` stays the same.
*/
import {MeltQuoteBolt11Response} from '@cashu/cashu-ts'
export interface TransferMethodOptions {
/**
* NUT-05 BOLT11 lightning melt.
* - `encodedInvoice`: BOLT11 string the user wants the mint to pay.
* - `meltQuote`: the mint's response to the melt-quote request (already
* fetched by the caller; carries `fee_reserve`, `quote` id, etc.).
* - `invoiceExpiry`: parsed expiry from the invoice — used to short-circuit
* expired invoices before contacting the mint.
*/
bolt11: {
encodedInvoice: string
meltQuote: MeltQuoteBolt11Response
invoiceExpiry: Date
}
}
export type TransferMethod = keyof TransferMethodOptions
export type TransferMethodPayload<M extends TransferMethod = TransferMethod> =
TransferMethodOptions[M]
/**
* Method-tagged payload, the canonical input shape for
* `TransferOperationApi.prepare`.
*
* Example:
* { method: 'bolt11', options: { encodedInvoice, meltQuote, invoiceExpiry } }
*/
export type TransferMethodInput = {
method: 'bolt11'
options: TransferMethodOptions['bolt11']
}
File diff suppressed because it is too large Load Diff
+161 -513
View File
@@ -1,657 +1,305 @@
import {log} from '../logService' import {log} from '../logService'
import { import {
Transaction, Transaction,
TransactionData, TransactionData,
TransactionStatus, TransactionStatus,
TransactionType,
} from '../../models/Transaction' } from '../../models/Transaction'
import {rootStoreInstance} from '../../models' import {rootStoreInstance} from '../../models'
import {CashuProof, CashuUtils} from '../cashu/cashuUtils' import {WalletUtils} from './utils'
import AppError, {Err} from '../../utils/AppError' import {TransactionTaskResult} from '../walletService'
import { TransactionTaskResult } from '../walletService' import {MintUnit, formatCurrency, getCurrency} from './currency'
import { WalletUtils } from './utils' import {PaymentRequestPayload, Token} from '@cashu/cashu-ts'
import { MintUnit, formatCurrency, getCurrency } from './currency'
import { PaymentRequestPayload, Token, getDecodedToken, getEncodedToken, normalizeProofAmounts } from '@cashu/cashu-ts'
const { const {transactionsStore} = rootStoreInstance
mintsStore,
proofsStore,
transactionsStore,
walletStore
} = rootStoreInstance
// function names to pass to task results // Task function names — preserved for the event-bus surface (`ev_<task>_result`).
export const RECEIVE_TASK = 'receiveTask' export const RECEIVE_TASK = 'receiveTask'
export const RECEIVE_OFFLINE_PREPARE_TASK = 'receiveOfflinePrepareTask' export const RECEIVE_OFFLINE_PREPARE_TASK = 'receiveOfflinePrepareTask'
export const RECEIVE_OFFLINE_COMPLETE_TASK = 'receiveOfflineCompleteTask' export const RECEIVE_OFFLINE_COMPLETE_TASK = 'receiveOfflineCompleteTask'
export const RECEIVE_BY_CASHU_PAYMENT_REQUEST_TASK = 'receiveByCashuPaymentRequestTask' export const RECEIVE_BY_CASHU_PAYMENT_REQUEST_TASK = 'receiveByCashuPaymentRequestTask'
/**
* Backward-compatible online-receive task wrapper.
*
* Preserves the legacy `WalletTask.receiveQueueAwaitable` contract: a single
* call that creates the draft, validates, swaps proofs with the mint, and
* returns a `TransactionTaskResult`.
*
* Internally delegates to the lifecycle API:
* `ReceiveOperationApi.prepare()` (DRAFT → PREPARED; BLOCKED short-circuit)
* `ReceiveOperationApi.execute()` (PREPARED → COMPLETED; atomic commit)
*/
export const receiveTask = async function ( export const receiveTask = async function (
token: Token, token: Token,
amountToReceive: number, amountToReceive: number,
memo: string, memo: string,
encodedToken: string, encodedToken: string,
): Promise<TransactionTaskResult> { ): Promise<TransactionTaskResult> {
const transactionData: TransactionData[] = [] const mintToReceive = token.mint
let transaction: Transaction | undefined = undefined const unit = ((token.unit as MintUnit) || 'sat') as MintUnit
let mintToReceive: string | undefined = undefined
const unit = token.unit as MintUnit || 'sat'
try { // Lazy import avoids a circular dep across the operations module graph.
mintToReceive = token.mint const {ReceiveOperationApi, receiveSuccessMessage} = await import(
'./operations/receiveOperationApi'
)
if (!mintToReceive) { let transactionIdForRecovery: number | undefined
throw new AppError(
Err.VALIDATION_ERROR,
'Token is missing a mint param.',
)
}
// Let's create new draft receive transaction in database try {
transactionData.push({ const prepared = await ReceiveOperationApi.prepare({
status: TransactionStatus.DRAFT, mintUrl: mintToReceive,
amountToReceive,
unit,
createdAt: new Date(),
})
const newTransaction = {
type: TransactionType.RECEIVE,
amount: amountToReceive, amount: amountToReceive,
fee: 0,
unit, unit,
data: JSON.stringify(transactionData),
memo, memo,
mint: mintToReceive, method: {method: 'cashu-token', options: {token, encodedToken, offline: false}},
status: TransactionStatus.DRAFT, })
} transactionIdForRecovery = prepared.transactionId
transaction = await transactionsStore.addTransaction(newTransaction)
transaction.update({inputToken: encodedToken})
// Handle blocked mint
const isBlocked = mintsStore.isBlocked(mintToReceive)
if (isBlocked) {
transactionData.push({
status: TransactionStatus.BLOCKED,
mintToReceive,
createdAt: new Date()
})
transaction.update({
status: TransactionStatus.BLOCKED,
data: JSON.stringify(transactionData),
})
if (prepared.blocked) {
return { return {
taskFunction: RECEIVE_TASK, taskFunction: RECEIVE_TASK,
transaction, mintUrl: mintToReceive,
transaction: prepared.tx,
message: `The mint ${mintToReceive} is blocked. You can unblock it in Settings.`, message: `The mint ${mintToReceive} is blocked. You can unblock it in Settings.`,
} as TransactionTaskResult } as TransactionTaskResult
} }
const { const completed = await ReceiveOperationApi.execute(prepared)
receivedAmount,
receivedProofs,
outputToken,
swapFeePaid,
} = await receiveSync(
mintToReceive,
token,
token.memo || '',
transaction.id
)
// Finally, update completed transaction
transactionData.push({
status: TransactionStatus.COMPLETED,
swapFeePaid,
receivedAmount,
unit,
createdAt: new Date(),
})
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance const swapFeePaid = completed.fee ?? 0
const receivedAmount = completed.amount
transaction.update({ // receiveBatchTask sums these to track progress across split batches.
status: TransactionStatus.COMPLETED, const {proofsStore} = rootStoreInstance
data: JSON.stringify(transactionData), const receivedProofsCount = proofsStore
keysetId: receivedProofs[0].id, .getByTransactionId(completed.id)
outputToken, .filter(p => p.state === 'UNSPENT').length
balanceAfter,
...(swapFeePaid > 0 && {fee: swapFeePaid})
})
return { return {
taskFunction: RECEIVE_TASK, taskFunction: RECEIVE_TASK,
mintUrl: mintToReceive, mintUrl: mintToReceive,
transaction, transaction: completed,
message: `You've received ${formatCurrency(amountToReceive, getCurrency(unit).code)} ${getCurrency(unit).code} to your Minibits wallet.${swapFeePaid > 0 ? ` Swap fee paid was ${formatCurrency(swapFeePaid, getCurrency(unit).code)} ${getCurrency(unit).code}.` : ''}`, message: receiveSuccessMessage(amountToReceive, unit, swapFeePaid),
receivedAmount, receivedAmount,
receivedProofsCount: receivedProofs.length receivedProofsCount,
} as TransactionTaskResult } as TransactionTaskResult
} catch (e: any) { } catch (e: any) {
if (transaction) { _stampErrorIfNeeded(transactionIdForRecovery, e)
transactionData.push({
status: TransactionStatus.ERROR,
error: WalletUtils.formatError(e),
errorToken: e.params?.errorToken || undefined
})
transaction.update({
status: TransactionStatus.ERROR,
data: JSON.stringify(transactionData)
})
}
log.error(e.name, e.message) log.error(e.name, e.message)
return { return {
taskFunction: RECEIVE_TASK, taskFunction: RECEIVE_TASK,
mintUrl: mintToReceive, mintUrl: mintToReceive,
transaction, transaction: transactionIdForRecovery
? transactionsStore.findById(transactionIdForRecovery)
: undefined,
message: e.message, message: e.message,
error: WalletUtils.formatError(e), error: WalletUtils.formatError(e),
} as TransactionTaskResult } as TransactionTaskResult
} }
} }
/**
* Backward-compatible offline-prepare task wrapper.
*
* Calls `ReceiveOperationApi.prepare({offline: true})` to DLEQ-verify the
* token locally without contacting the mint. The transaction lands in
* `PREPARED_OFFLINE` and can be completed later via `receiveOfflineCompleteTask`.
*/
export const receiveOfflinePrepareTask = async function ( export const receiveOfflinePrepareTask = async function (
mintUrl: string, mintUrl: string,
unit: MintUnit, unit: MintUnit,
amountToReceive: number, amountToReceive: number,
memo: string, memo: string,
encodedToken: string, encodedToken: string,
) { ): Promise<TransactionTaskResult> {
const transactionData: TransactionData[] = [] const mintToReceive = mintUrl.replace(/\/$/, '')
let transaction: Transaction | undefined = undefined
const mintToReceive = mintUrl.replace(/\/$/, '')
try { const {ReceiveOperationApi} = await import('./operations/receiveOperationApi')
const {getDecodedToken} = await import('@cashu/cashu-ts')
// Let's create new draft receive transaction in database let transactionIdForRecovery: number | undefined
transactionData.push({
status: TransactionStatus.DRAFT,
amountToReceive,
unit,
createdAt: new Date(),
})
const newTransaction = { try {
type: TransactionType.RECEIVE_OFFLINE, // Pre-decode the token so prepare() doesn't need the wallet's keysetIds
// separately — it'll re-verify DLEQ regardless.
const {mintsStore} = rootStoreInstance
const mintInstance = mintsStore.findByUrl(mintToReceive)
const token = getDecodedToken(encodedToken, mintInstance?.keysetIds ?? [])
const prepared = await ReceiveOperationApi.prepare({
mintUrl: mintToReceive,
amount: amountToReceive, amount: amountToReceive,
fee: 0,
unit, unit,
data: JSON.stringify(transactionData),
memo, memo,
mint: mintToReceive, method: {method: 'cashu-token', options: {token, encodedToken, offline: true}},
status: TransactionStatus.DRAFT, })
} transactionIdForRecovery = prepared.transactionId
transaction = await transactionsStore.addTransaction(newTransaction)
transaction.update({inputToken: encodedToken})
// Handle blocked mint
const isBlocked = mintsStore.isBlocked(mintToReceive)
if (isBlocked) {
transactionData.push({
status: TransactionStatus.BLOCKED,
mintToReceive,
createdAt: new Date()
})
transaction.update({
status: TransactionStatus.BLOCKED,
data: JSON.stringify(transactionData),
})
if (prepared.blocked) {
return { return {
taskFunction: RECEIVE_OFFLINE_PREPARE_TASK, taskFunction: RECEIVE_OFFLINE_PREPARE_TASK,
mintUrl: mintToReceive, mintUrl: mintToReceive,
transaction, transaction: prepared.tx,
message: `The mint ${mintToReceive} is blocked. You can unblock it in Settings.`, message: `The mint ${mintToReceive} is blocked. You can unblock it in Settings.`,
} as unknown as TransactionTaskResult } as TransactionTaskResult
} }
const mintInstance = mintsStore.findByUrl(mintToReceive) const code = getCurrency(unit).code
if (!mintInstance) {
throw new AppError(
Err.VALIDATION_ERROR,
'This token cannot be verified offline because the mint is not saved in your wallet. Go online to add the mint or receive it online.',
{caller: 'receiveOfflinePrepareTask', mintUrl: mintToReceive}
)
}
if (!mintInstance.keysetIds || mintInstance.keysetIds.length === 0 || !mintInstance.keys || mintInstance.keys.length === 0) {
throw new AppError(
Err.VALIDATION_ERROR,
'This token cannot be verified offline because the mint keys are not saved. Sync the mint online first.',
{caller: 'receiveOfflinePrepareTask', mintUrl: mintToReceive}
)
}
let token: Token
try {
token = getDecodedToken(encodedToken, mintInstance.keysetIds)
} catch (e: any) {
throw new AppError(
Err.VALIDATION_ERROR,
'Could not decode this ecash token for offline verification.',
{caller: 'receiveOfflinePrepareTask', mintUrl: mintToReceive, reason: e?.message}
)
}
CashuUtils.verifyProofsDleqOrThrow(token.proofs, mintInstance.keys)
// Update transaction status
transactionData.push({
status: TransactionStatus.PREPARED_OFFLINE,
createdAt: new Date(),
})
transaction.update( {
status: TransactionStatus.PREPARED_OFFLINE,
data: JSON.stringify(transactionData),
})
return { return {
taskFunction: RECEIVE_OFFLINE_PREPARE_TASK, taskFunction: RECEIVE_OFFLINE_PREPARE_TASK,
mintUrl: mintToReceive, mintUrl: mintToReceive,
transaction, transaction: prepared.tx,
message: `You received ${formatCurrency(amountToReceive, getCurrency(unit).code)} ${getCurrency(unit).code} while offline. You need to redeem them to your wallet when you will be online again.`, message: `You received ${formatCurrency(amountToReceive, code)} ${code} while offline. You need to redeem them to your wallet when you will be online again.`,
} as TransactionTaskResult } as TransactionTaskResult
} catch (e: any) { } catch (e: any) {
if (transaction) { _stampErrorIfNeeded(transactionIdForRecovery, e)
transactionData.push({
status: TransactionStatus.ERROR,
error: WalletUtils.formatError(e),
})
transaction.update({
status: TransactionStatus.ERROR,
data: JSON.stringify(transactionData),
})
}
log.error(e.name, e.message) log.error(e.name, e.message)
return { return {
taskFunction: RECEIVE_OFFLINE_PREPARE_TASK, taskFunction: RECEIVE_OFFLINE_PREPARE_TASK,
mintUrl: mintToReceive, mintUrl: mintToReceive,
transaction, transaction: transactionIdForRecovery
? transactionsStore.findById(transactionIdForRecovery)
: undefined,
message: '', message: '',
error: WalletUtils.formatError(e), error: WalletUtils.formatError(e),
} as TransactionTaskResult } as TransactionTaskResult
} }
} }
/**
* Backward-compatible offline-complete task wrapper.
*
* Resumes a previously-prepared offline receive: re-builds the prepared
* snapshot from disk (tx.inputToken) and runs `ReceiveOperationApi.execute`.
*/
export const receiveOfflineCompleteTask = async function (
transactionId: number,
): Promise<TransactionTaskResult> {
const tx = transactionsStore.findById(transactionId)
let mintToReceive = tx?.mint ?? ''
const {ReceiveOperationApi, receiveSuccessMessage, loadPreparedForOfflineComplete} =
await import('./operations/receiveOperationApi')
export const receiveOfflineCompleteTask = async function (
transactionId: number
) {
let mintToReceive = ''
const transaction = transactionsStore.findById(transactionId)
try { try {
if(!transaction) { if (!tx) {
const {default: AppError, Err} = await import('../../utils/AppError')
throw new AppError(Err.VALIDATION_ERROR, 'Could not retrieve transaction.', {transactionId}) throw new AppError(Err.VALIDATION_ERROR, 'Could not retrieve transaction.', {transactionId})
}
let transactionData = [] as unknown as TransactionData
try {
transactionData = JSON.parse(transaction.data)
} catch (e) {}
if (!transaction.inputToken) {
throw new AppError(Err.VALIDATION_ERROR, 'Could not find ecash token to redeem', {caller: 'receiveOfflineComplete'})
} }
mintToReceive = tx.mint
// Ensure mint is in wallet state — may be missing for offline receives from new mints const prepared = loadPreparedForOfflineComplete(transactionId)
if(!mintsStore.alreadyExists(transaction.mint)) {
await mintsStore.addMint(transaction.mint)
}
// keysetsV2 support
const mintKeysetIds = mintsStore.findByUrl(transaction.mint)?.keysetIds
if(!mintKeysetIds || mintKeysetIds.length === 0) {
throw new AppError(Err.NOTFOUND_ERROR, 'Missing keysetIds in the wallet state', {
mintUrl: transaction.mint
})
}
const token = getDecodedToken(transaction.inputToken, mintKeysetIds)
mintToReceive = token.mint
const unit = token.unit || 'sat'
if(unit !== transaction.unit) {
throw new AppError(Err.VALIDATION_ERROR, 'Transaction unit and token unit are not the same', {unit, transactionUnit: transaction.unit})
}
// Re-check blocked mint
const isBlocked = mintsStore.isBlocked(mintToReceive)
if (isBlocked) {
transactionData.push({
status: TransactionStatus.BLOCKED,
mintToReceive,
createdAt: new Date()
})
transaction.update({
status: TransactionStatus.BLOCKED,
data: JSON.stringify(transactionData),
})
if (prepared.blocked) {
return { return {
taskFunction: RECEIVE_OFFLINE_COMPLETE_TASK, taskFunction: RECEIVE_OFFLINE_COMPLETE_TASK,
mintUrl: mintToReceive, mintUrl: mintToReceive,
transaction, transaction: prepared.tx,
message: `The mint ${mintToReceive} is blocked. You can unblock it in Settings.`, message: `The mint ${mintToReceive} is blocked. You can unblock it in Settings.`,
} as TransactionTaskResult } as TransactionTaskResult
} }
const { const completed = await ReceiveOperationApi.execute(prepared)
receivedAmount,
receivedProofs,
outputToken,
swapFeePaid,
} = await receiveSync(
mintToReceive,
token,
token.memo || '',
transaction.id
)
// Finally, update completed transaction
transactionData.push({
status: TransactionStatus.COMPLETED,
receivedAmount,
swapFeePaid,
unit,
createdAt: new Date(),
})
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance const swapFeePaid = completed.fee ?? 0
const receivedAmount = completed.amount
transaction.update({
status: TransactionStatus.COMPLETED,
data: JSON.stringify(transactionData),
keysetId: receivedProofs[0].id,
outputToken,
balanceAfter,
...(swapFeePaid > 0 && {fee: swapFeePaid})
})
return { return {
taskFunction: RECEIVE_OFFLINE_COMPLETE_TASK, taskFunction: RECEIVE_OFFLINE_COMPLETE_TASK,
mintUrl: mintToReceive, mintUrl: mintToReceive,
transaction, transaction: completed,
message: `You've received ${formatCurrency(receivedAmount, getCurrency(unit).code)} ${getCurrency(unit).code} to your Minibits wallet.${swapFeePaid > 0 ? ` Swap fee paid was ${formatCurrency(swapFeePaid, getCurrency(unit).code)} ${getCurrency(unit).code}.` : ''}`, message: receiveSuccessMessage(receivedAmount, completed.unit, swapFeePaid),
receivedAmount, receivedAmount,
} as TransactionTaskResult } as TransactionTaskResult
} catch (e: any) { } catch (e: any) {
// release lock _stampErrorIfNeeded(transactionId, e)
if(transaction) {
let transactionData = [] as unknown as TransactionData
try {
transactionData = JSON.parse(transaction.data)
} catch (e) {}
transactionData.push({
status: TransactionStatus.ERROR,
error: WalletUtils.formatError(e),
})
transaction.update({
status: TransactionStatus.ERROR,
data: JSON.stringify(transactionData)
})
}
return { return {
taskFunction: RECEIVE_OFFLINE_COMPLETE_TASK, taskFunction: RECEIVE_OFFLINE_COMPLETE_TASK,
mintUrl: mintToReceive, mintUrl: mintToReceive,
transaction, transaction: transactionsStore.findById(transactionId),
message: '', message: '',
error: WalletUtils.formatError(e), error: WalletUtils.formatError(e),
} as TransactionTaskResult } as TransactionTaskResult
} }
} }
/**
* Backward-compatible Cashu Payment Request fulfillment wrapper.
*
* Triggered by NostrOperationService when a DM arrives matching one of our
* outstanding PRs. Delegates to `CashuPaymentRequestApi.finalize`.
*/
export const receiveByCashuPaymentRequestTask = async function ( export const receiveByCashuPaymentRequestTask = async function (
paymentRequestPayload: PaymentRequestPayload, paymentRequestPayload: PaymentRequestPayload,
): Promise<TransactionTaskResult> { ): Promise<TransactionTaskResult> {
const mintToReceive = paymentRequestPayload.mint
const paymentRequestId = paymentRequestPayload.id
const transactionData = [] as unknown as TransactionData // The tx was created by `cashuPaymentRequestTask` (now CashuPaymentRequestApi)
// let transaction: Transaction | undefined = undefined // and parked at PENDING with paymentId === paymentRequestId.
const unit = paymentRequestPayload.unit as MintUnit const tx = transactionsStore.findLastBy({paymentId: paymentRequestId}) as
const mintToReceive = paymentRequestPayload.mint | Transaction
const proofsToReceive = paymentRequestPayload.proofs | undefined
const paymentRequestId = paymentRequestPayload.id
// Let's find transaction with related payment request const {CashuPaymentRequestApi, cashuPaymentRequestSuccessMessage} = await import(
const transaction = transactionsStore.findLastBy({paymentId: paymentRequestId}) as Transaction | undefined './operations/cashuPaymentRequestApi'
)
try { try {
if (!mintToReceive || !unit || !Array.isArray(proofsToReceive) || proofsToReceive.length === 0) { if (!tx) {
throw new AppError( const {default: AppError, Err} = await import('../../utils/AppError')
Err.VALIDATION_ERROR,
'Payment request payload is invalid.',
{paymentRequestPayload}
)
}
if(!transaction) {
throw new AppError( throw new AppError(
Err.VALIDATION_ERROR, Err.VALIDATION_ERROR,
'Related Payment request could not be found in the wallet.', 'Related Payment request could not be found in the wallet.',
{paymentRequestPayload} {paymentRequestPayload},
) )
} }
const amountToReceive = CashuUtils.getProofsAmount(proofsToReceive) const completed = await CashuPaymentRequestApi.finalize(tx.id, paymentRequestPayload)
const memo = paymentRequestPayload.memo || 'PR ' + paymentRequestId
if(transaction.unit !== unit || transaction.amount !== amountToReceive) { const receivedAmount = completed.amount
throw new AppError( const message = cashuPaymentRequestSuccessMessage(
Err.VALIDATION_ERROR, paymentRequestId ?? '',
'Related Payment request has different amount or unit than the incoming payment.',
{
expectedUnit: transaction.unit,
expectedAmount: transaction.amount,
amountToReceive, unit,
paymentRequestId,
caller: 'receiveByCashuPaymentRequestTask'
}
)
}
// Handle blocked mint
const isBlocked = mintsStore.isBlocked(mintToReceive)
if (isBlocked) {
transactionData.push({
status: TransactionStatus.BLOCKED,
message: 'Mint is blocked in your Settings, ecash has not been received.',
})
transaction.update({
status: TransactionStatus.BLOCKED,
data: JSON.stringify(transactionData),
})
return {
taskFunction: RECEIVE_BY_CASHU_PAYMENT_REQUEST_TASK,
transaction,
message: `The mint ${mintToReceive} is blocked. You can unblock it in Settings.`,
} as TransactionTaskResult
}
// TODO rework to PR payload
const {
receivedAmount, receivedAmount,
receivedProofs, completed.unit,
outputToken,
swapFeePaid,
} = await receiveSync(
mintToReceive,
paymentRequestPayload,
memo,
transaction.id
) )
// Finally, update completed transaction
transactionData.push({
status: TransactionStatus.COMPLETED,
swapFeePaid,
receivedAmount,
unit,
createdAt: new Date(),
})
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
transaction.update({
status: TransactionStatus.COMPLETED,
data: JSON.stringify(transactionData),
keysetId: receivedProofs[0].id,
outputToken,
balanceAfter,
...(receivedAmount !== amountToReceive && {receivedAmount}),
...(swapFeePaid > 0 && {fee: swapFeePaid})
})
return { return {
taskFunction: RECEIVE_BY_CASHU_PAYMENT_REQUEST_TASK, taskFunction: RECEIVE_BY_CASHU_PAYMENT_REQUEST_TASK,
mintUrl: mintToReceive, mintUrl: mintToReceive,
transaction, transaction: completed,
message: `Payment request ${paymentRequestId} with amount of ${formatCurrency(receivedAmount, getCurrency(unit).code)} ${getCurrency(unit).code} has been paid.`, message,
receivedAmount, receivedAmount,
receivedProofsCount: receivedProofs.length
} as TransactionTaskResult } as TransactionTaskResult
} catch (e: any) {
} catch (e: any) {
log.error(e.name, e.message) log.error(e.name, e.message)
if (tx && tx.status !== TransactionStatus.ERROR && tx.status !== TransactionStatus.BLOCKED) {
transactionData.push({ _stampErrorIfNeeded(tx.id, e)
status: TransactionStatus.ERROR, }
error: WalletUtils.formatError(e),
errorToken: e.params?.errorToken || undefined
})
transaction && transaction.update({
status: TransactionStatus.ERROR,
data: JSON.stringify(transactionData)
})
return { return {
taskFunction: RECEIVE_BY_CASHU_PAYMENT_REQUEST_TASK, taskFunction: RECEIVE_BY_CASHU_PAYMENT_REQUEST_TASK,
mintUrl: mintToReceive, mintUrl: mintToReceive,
transaction, transaction: tx,
message: e.message, message: e.message,
error: WalletUtils.formatError(e), error: WalletUtils.formatError(e),
} as TransactionTaskResult } as TransactionTaskResult
} }
} }
function _stampErrorIfNeeded(transactionId: number | undefined, e: any) {
if (!transactionId) return
const tx = transactionsStore.findById(transactionId)
if (!tx) return
if (tx.status === TransactionStatus.ERROR || tx.status === TransactionStatus.BLOCKED) return
export const receiveSync = async function ( let transactionData: TransactionData[] = []
mintToReceive: string, try { transactionData = JSON.parse(tx.data) } catch {}
token: Token | PaymentRequestPayload, transactionData.push({
memo: string, status: TransactionStatus.ERROR,
transactionId: number error: WalletUtils.formatError(e),
) { errorToken: e?.params?.errorToken || undefined,
})
const unit = token.unit as MintUnit || 'sat' tx.update({
status: TransactionStatus.ERROR,
try { data: JSON.stringify(transactionData),
// missing mint, should not happen as it is now added before })
const alreadyExists = mintsStore.alreadyExists(mintToReceive)
if (!alreadyExists) {
await mintsStore.addMint(mintToReceive)
}
const mintInstance = mintsStore.findByUrl(mintToReceive)
if(!mintInstance) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing mint', {mintToReceive})
}
let receivedResult = {} as unknown as {proofs: CashuProof[], swapFeePaid: number}
try {
receivedResult = await walletStore.receive(
mintToReceive,
unit as MintUnit,
token,
transactionId
)
} catch (e: any) {
if (WalletUtils.shouldHealOutputsError(e)) {
log.error('[receiveSync] Increasing proofsCounter outdated values and repeating receive.')
receivedResult = await walletStore.receive(
mintToReceive,
unit as MintUnit,
token,
transactionId,
{increaseCounterBy: 10}
)
} else {
throw e
}
}
const receivedProofs = receivedResult!.proofs
const swapFeePaid = receivedResult!.swapFeePaid
const { updatedAmount: receivedAmount } = proofsStore.addOrUpdate(receivedProofs, {
mintUrl: mintToReceive,
unit,
tId: transactionId,
state: 'UNSPENT',
})
// store swapped proofs as encoded token in tx data
const outputToken = getEncodedToken({
mint: mintToReceive,
proofs: normalizeProofAmounts(receivedProofs),
unit,
memo,
})
return {
receivedAmount,
receivedProofs,
outputToken,
swapFeePaid
}
} catch (e: any) {
if (e instanceof AppError) {
throw e
} else {
throw new AppError(Err.WALLET_ERROR, e.message, e.stack.slice(0, 200))
}
}
} }
+20 -18
View File
@@ -91,31 +91,33 @@ try {
unit unit
}) })
// ATOMIC commit: original pending proofs → SPENT, new fresh proofs → // Pre-compute everything that needs to land atomically. balanceAfter is
// UNSPENT, reservation row deleted — single SQLite transaction. Replaces // simulated: the original pending proofs were PENDING (contribute 0 to
// the previous two-step moveToSpent + addOrUpdate sequence. // UNSPENT pool); moving them to SPENT changes nothing. The new fresh
const { added } = proofsStore.commitReservation(reservation, { // proofs added as UNSPENT raise the spendable balance.
toSpent: pendingProofs, const receivedAmount = receivedProofs.reduce((sum, p) => sum + Number(p.amount), 0)
newProofs: [{ proofs: receivedProofs, state: 'UNSPENT', tId: transaction.id }], const currentSpendable = proofsStore.getUnitBalance(unit)?.unitBalance ?? 0
}) const balanceAfter = currentSpendable + receivedAmount
const receivedAmount = added.reduce((sum, p) => sum + p.amount, 0)
// Update transaction status
transactionData.push({ transactionData.push({
status: TransactionStatus.REVERTED, status: TransactionStatus.REVERTED,
mintFeePaid, mintFeePaid,
createdAt: new Date(), createdAt: new Date(),
}) })
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance // ATOMIC commit: pending proofs → SPENT, new fresh proofs → UNSPENT,
// tx → REVERTED, reservation row deleted — single SQLite transaction.
transaction.update({ proofsStore.commitReservation(reservation, {
status: TransactionStatus.REVERTED, toSpent: pendingProofs,
data: JSON.stringify(transactionData), newProofs: [{ proofs: receivedProofs, state: 'UNSPENT', tId: transaction.id }],
outputToken, transactionUpdate: {
balanceAfter, id: transaction.id,
...(mintFeePaid > 0 && {fee: mintFeePaid}) status: TransactionStatus.REVERTED,
data: JSON.stringify(transactionData),
outputToken,
balanceAfter,
...(mintFeePaid > 0 && { fee: mintFeePaid }),
},
}) })
return { return {
+62 -421
View File
@@ -33,45 +33,21 @@ const {
export const SEND_TASK = 'sendTask' export const SEND_TASK = 'sendTask'
const _monitorSentProofs = async (params: { /**
mintUrl: string * Backward-compatible send task wrapper.
proofsToSend: CashuProof[] *
}) => { * This function preserves the historical `WalletTask.sendQueueAwaitable`
const { mintUrl, proofsToSend } = params * contract: a single call that creates the draft, reserves proofs, executes
const proofsToSync = proofsStore.getByMint(mintUrl, {state: 'PENDING'}) * the (optional) swap, and returns a `TransactionTaskResult` with the
const wsMint = new CashuMint(mintUrl) * encoded token.
const wsWallet = new CashuWallet(wsMint) *
* Internally it now delegates to the lifecycle API:
try { * `SendOperationApi.prepare()` (DRAFT → PREPARED, reservation opens)
log.trace('[send] Subscribing to proofStateUpdates for sent proof', {secret: proofsToSend[0]}) * `SendOperationApi.execute()` (PREPARED → [EXECUTING →] PENDING, atomic commit)
const unsub = await wsWallet.on.proofStateUpdates( *
normalizeProofAmounts([proofsToSend[0]]), * Screens that want fee preview / cancel-before-execute should call the
async (proofState: CashuProofState) => { * lifecycle methods directly instead of going through this wrapper.
log.trace(`Websocket: proof state updated: ${proofState.state} with secret: ${proofsToSend[0].secret}`) */
if (proofState.state == CheckStateEnum.SPENT) {
WalletTask.syncStateWithMintQueueAwaitable({proofsToSync, mintUrl, proofState: 'PENDING'})
unsub()
}
},
async (error: any) => {
throw error
},
)
} catch (error: any) {
log.error(Err.NETWORK_ERROR,
"Error in websocket subscription. Starting poller.",
error.message,
)
poller(
`syncStateWithMintPoller-${mintUrl}`,
WalletTask.syncStateWithMintQueueAwaitable,
{interval: 10 * 1000, maxPolls: 3, maxErrors: 1},
{proofsToSend, mintUrl, isPending: true},
)
.then(() => log.trace('[syncStateWithMintPoller]', 'polling completed', {mintUrl}))
}
}
export const sendTask = async function ( export const sendTask = async function (
mintBalanceToSendFrom: MintBalance, mintBalanceToSendFrom: MintBalance,
amountToSend: number, amountToSend: number,
@@ -85,128 +61,69 @@ export const sendTask = async function (
const mintUrl = mintBalanceToSendFrom.mintUrl const mintUrl = mintBalanceToSendFrom.mintUrl
log.trace('[sendTask]', 'mintBalanceToSendFrom', mintBalanceToSendFrom) log.trace('[sendTask]', 'mintBalanceToSendFrom', mintBalanceToSendFrom)
log.trace('[sendTask]', 'amountToSend', {amountToSend, unit}) log.trace('[sendTask]', 'amountToSend', {amountToSend, unit})
if(amountToSend <= 0) {
throw new AppError(Err.VALIDATION_ERROR, 'Amount to send must be above zero.')
}
let transaction: Transaction | undefined = undefined // Lazy import avoids a circular dep: sendOperationApi imports from
let transactionData: TransactionData[] = [] // this file (the keyset helpers) and Phase 7 wires walletService events
// through the same module graph.
const {SendOperationApi} = await import('./operations/sendOperationApi')
let transactionIdForRecovery: number | undefined
try { try {
if(draftTransactionId && draftTransactionId > 0) { const method = p2pk && p2pk.pubkey
transaction = transactionsStore.findById(draftTransactionId)! ? {method: 'p2pk' as const, options: p2pk}
} else { : {method: 'default' as const, options: {} as Record<string, never>}
// create draft transaction
transactionData.push({
status: TransactionStatus.DRAFT,
mintBalanceToSendFrom,
createdAt: new Date(),
})
const newTransaction = {
type: TransactionType.SEND,
amount: amountToSend,
fee: 0,
unit,
data: JSON.stringify(transactionData),
memo,
mint: mintUrl,
status: TransactionStatus.DRAFT,
}
// store tx in db and in the model const prepared = await SendOperationApi.prepare({
transaction = await transactionsStore.addTransaction(newTransaction) mintBalance: mintBalanceToSendFrom,
} amount: amountToSend,
// get proofs to send
const {
proofs: proofsToSend,
swapFeePaid,
swapFeeReserve,
isSwapNeeded,
} = await sendFromMintSync(
mintBalanceToSendFrom,
amountToSend,
unit,
selectedProofs,
transaction.id,
p2pk
)
// Update transaction status
transactionData.push({
status: TransactionStatus.PREPARED,
swapFeeReserve,
swapFeePaid,
isSwapNeeded,
createdAt: new Date(),
})
const outputToken = getEncodedToken({
mint: mintUrl,
proofs: normalizeProofAmounts(proofsToSend),
unit, unit,
memo, memo,
selectedProofs: selectedProofs.length > 0 ? selectedProofs : undefined,
method,
draftTransactionId: draftTransactionId ?? undefined,
}) })
transactionIdForRecovery = prepared.transactionId
transaction.update({ const pending = await SendOperationApi.execute(prepared)
status: TransactionStatus.PREPARED,
data: JSON.stringify(transactionData),
outputToken
})
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
transactionData.push({
status: TransactionStatus.PENDING,
createdAt: new Date(),
})
transaction.update({
status: TransactionStatus.PENDING,
data: JSON.stringify(transactionData),
keysetId: proofsToSend[0].id,
balanceAfter,
...(swapFeePaid > 0 && {fee: swapFeePaid})
})
log.trace('[send] totalBalance after', balanceAfter)
// Start monitoring for accepted payment if it is not an offline send
if(selectedProofs.length === 0) {
_monitorSentProofs({mintUrl, proofsToSend})
}
const swapFeePaid = pending.fee ?? 0
return { return {
taskFunction: SEND_TASK, taskFunction: SEND_TASK,
mintUrl, mintUrl,
transaction, transaction: pending,
message: '', message: '',
encodedTokenToSend: outputToken, swapFeePaid,
swapFeePaid
} as TransactionTaskResult } as TransactionTaskResult
} catch (e: any) { } catch (e: any) {
if (transaction) { // Mark the tx ERROR if one was created during prepare.
transactionData.push({ if (transactionIdForRecovery) {
status: TransactionStatus.ERROR, const tx = transactionsStore.findById(transactionIdForRecovery)
if (tx) {
let transactionData: TransactionData[] = []
try { transactionData = JSON.parse(tx.data) } catch {}
transactionData.push({
status: TransactionStatus.ERROR,
error: WalletUtils.formatError(e),
createdAt: new Date(),
})
tx.update({
status: TransactionStatus.ERROR,
data: JSON.stringify(transactionData),
})
}
return {
taskFunction: SEND_TASK,
mintUrl,
transaction: transactionsStore.findById(transactionIdForRecovery),
message: e.message,
error: WalletUtils.formatError(e), error: WalletUtils.formatError(e),
createdAt: new Date() } as TransactionTaskResult
}) }
transaction.update({
status: TransactionStatus.ERROR,
data: JSON.stringify(transactionData)
})
}
return { return {
taskFunction: SEND_TASK, taskFunction: SEND_TASK,
mintUrl, mintUrl,
transaction,
message: e.message, message: e.message,
error: WalletUtils.formatError(e), error: WalletUtils.formatError(e),
} as TransactionTaskResult } as TransactionTaskResult
@@ -214,283 +131,7 @@ export const sendTask = async function (
} }
export const prioritizeFromInactiveKeysets = function (
export const sendFromMintSync = async function (
mintBalance: MintBalance,
amountToSend: number,
unit: MintUnit,
selectedProofs: Proof[],
transactionId: number,
p2pk?: { pubkey: string; locktime?: number; refundKeys?: Array<string> }
) {
const mintUrl = mintBalance.mintUrl
const mintInstance = mintsStore.findByUrl(mintUrl)
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,
'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}
)
}
const reservation = proofsStore.reserve(selectedProofs, {
transactionId,
mintUrl,
unit,
operationType: 'send-offline',
rollbackTo: 'UNSPENT',
})
try {
// Commit with empty changes: deletes the reservation row, proofs
// stay PENDING (sent ecash remains locked until redeemed/reverted).
proofsStore.commitReservation(reservation)
return {
proofs: CashuUtils.exportProofs(selectedProofs),
swapFeeReserve: 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'},
)
}
if (swapFeeReserve > 0) {
proofsToSendFrom = CashuUtils.getProofsToSend(
amountWithFees,
proofsFromMint
)
proofsToSendFromAmount = CashuUtils.getProofsAmount(proofsToSendFrom)
}
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}
)
}
}
// 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: amountToSend + swapFeeReserve,
returnedAmount,
transactionId
})
let sendResult = {} as {
returnedProofs: CashuProof[]
proofsToSend: CashuProof[]
swapFeePaid: number
}
try {
sendResult = await walletStore.send(
mintUrl,
amountToSend,
unit,
proofsToSendFrom,
transactionId,
{p2pk: p2pk && p2pk.pubkey ? p2pk : undefined}
)
} catch (e: any) {
if (WalletUtils.shouldHealOutputsError(e)) {
log.error('[sendFromMintSync] Increasing proofsCounter outdated values and repeating send.')
sendResult = await walletStore.send(
mintUrl,
amountToSend,
unit,
proofsToSendFrom,
transactionId,
{p2pk: p2pk && p2pk.pubkey ? p2pk : undefined, increaseCounterBy: 10}
)
} else {
throw e
}
}
returnedProofs = sendResult.returnedProofs
proofsToSend = sendResult.proofsToSend
swapFeePaid = sendResult.swapFeePaid
// 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] swap finalize', {
inputCount: proofsToSendFrom.length,
returnedUnchangedCount: proofsToSendFrom.length - actuallySpentProofs.length,
actuallySpentCount: actuallySpentProofs.length,
returnedProofsCount: returnedProofs.length,
proofsToSendCount: proofsToSend.length,
})
// 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 {
// No swap: reserved proofs ARE the proofs to send; they remain PENDING.
log.trace('[sendFromMintSync] Swap is not necessary.', {transactionId})
proofsToSend = CashuUtils.exportProofs(proofsToSendFrom)
proofsStore.commitReservation(reservation)
}
return {
proofs: proofsToSend,
swapFeeReserve,
swapFeePaid,
isSwapNeeded
}
} 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,
proofState: 'PENDING',
})
await WalletTask.syncStateWithMintTask({
proofsToSync: proofsStore.getByMint(mintUrl, {state: 'PENDING', unit}),
mintUrl,
proofState: 'PENDING',
})
}
if (e instanceof AppError) {
throw e
} else {
throw new AppError(Err.WALLET_ERROR, e.message, e.stack.slice(0, 200))
}
}
}
const prioritizeFromInactiveKeysets = function (
mint: Mint, mint: Mint,
amountToSend: number, amountToSend: number,
unit: MintUnit, unit: MintUnit,
@@ -548,7 +189,7 @@ const prioritizeFromInactiveKeysets = function (
} }
const getActiveKeysetIds = function(mint: Mint) { export const getActiveKeysetIds = function(mint: Mint) {
if(mint.keysets) { if(mint.keysets) {
return mint.keysets return mint.keysets
@@ -560,7 +201,7 @@ const getActiveKeysetIds = function(mint: Mint) {
} }
const getInactiveKeysetIds = function(mint: Mint) { export const getInactiveKeysetIds = function(mint: Mint) {
if(mint.keysets) { if(mint.keysets) {
return mint.keysets return mint.keysets
+60 -159
View File
@@ -1,197 +1,98 @@
import {rootStoreInstance} from '../../models' import {rootStoreInstance} from '../../models'
import { TransactionTaskResult, WalletTask } from '../walletService' import {TransactionTaskResult} from '../walletService'
import { MintBalance } from '../../models/Mint' import {MintBalance} from '../../models/Mint'
import { poller } from '../../utils/poller' import {TransactionData, TransactionStatus} from '../../models/Transaction'
import { Transaction, TransactionData, TransactionStatus, TransactionType } from '../../models/Transaction' import {log} from '../logService'
import { log } from '../logService' import {Contact} from '../../models/Contact'
import { Contact } from '../../models/Contact' import {WalletUtils} from './utils'
import { LightningUtils } from '../lightning/lightningUtils' import {MintUnit} from './currency'
import { getSnapshot, isStateTreeNode } from 'mobx-state-tree' import {NostrEvent} from '../nostrService'
import { WalletUtils } from './utils'
import { MintUnit } from './currency'
import { NostrEvent } from '../nostrService'
import AppError, { Err } from '../../utils/AppError'
import {
MintQuoteBolt11Response,
Mint as CashuMint,
Wallet as CashuWallet,
} from '@cashu/cashu-ts'
import { addSeconds } from 'date-fns/addSeconds'
const { const {transactionsStore} = rootStoreInstance
transactionsStore,
walletProfileStore,
walletStore
} = rootStoreInstance
// const {walletStore} = nonPersistedStores
export const TOPUP_TASK = 'topupTask' export const TOPUP_TASK = 'topupTask'
/**
* Backward-compatible topup (lightning mint) task wrapper.
*
* Preserves the historical `WalletTask.topupQueueAwaitable` contract: a single
* call that creates the draft, fetches a mint quote, transitions the tx to
* PENDING, arms the ws/poller watch, and returns a `TransactionTaskResult`
* with the encoded invoice for the user to pay.
*
* Internally delegates to the lifecycle API:
* `TopupOperationApi.prepare()` (DRAFT → PREPARED, quote created)
* `TopupOperationApi.execute()` (PREPARED → PENDING, watcher armed)
*
* Screens that want to defer the watcher / surface the invoice before
* committing should call the lifecycle methods directly.
*/
export const topupTask = async function ( export const topupTask = async function (
mintBalanceToTopup: MintBalance, mintBalanceToTopup: MintBalance,
amountToTopup: number, amountToTopup: number,
unit: MintUnit, unit: MintUnit,
memo: string, memo: string,
contactToSendTo?: Contact, contactToSendTo?: Contact,
nwcEvent?: NostrEvent nwcEvent?: NostrEvent,
) : Promise<TransactionTaskResult> { ): Promise<TransactionTaskResult> {
log.info('[topupTask]', {mintBalanceToTopup}) log.info('[topupTask]', {mintBalanceToTopup})
log.info('[topupTask]', {amountToTopup, unit}) log.info('[topupTask]', {amountToTopup, unit})
// create draft transaction
const transactionData: TransactionData[] = [
{
status: TransactionStatus.DRAFT,
amountToTopup,
unit,
createdAt: new Date(),
},
]
let transaction: Transaction | undefined = undefined
const mintUrl = mintBalanceToTopup.mintUrl const mintUrl = mintBalanceToTopup.mintUrl
// Lazy import avoids a circular dep across the operations module graph.
const {TopupOperationApi} = await import('./operations/topupOperationApi')
let transactionIdForRecovery: number | undefined
try { try {
const newTransaction = { const prepared = await TopupOperationApi.prepare({
type: TransactionType.TOPUP, mintBalance: mintBalanceToTopup,
amount: amountToTopup, amount: amountToTopup,
fee: 0,
unit, unit,
data: JSON.stringify(transactionData),
memo, memo,
mint: mintUrl, method: {method: 'bolt11', options: {contactToSendTo}},
status: TransactionStatus.DRAFT, nwcEvent,
}
// store tx in db and in the model
transaction = await transactionsStore.addTransaction(newTransaction)
if(!transaction) {
throw new AppError(Err.DATABASE_ERROR, 'Could not store transaction.')
}
const {
encodedInvoice,
mintQuote
} = await walletStore.createLightningMintQuote(
mintUrl,
unit,
amountToTopup,
memo
)
const decodedInvoice = LightningUtils.decodeInvoice(encodedInvoice)
const {
amount,
payment_hash: paymentHash,
expiry,
timestamp
} = LightningUtils.getInvoiceData(decodedInvoice)
// Private contacts are stored in model, public ones are plain objects
// contactToSendTo is to whom to send the request
const contactTo = isStateTreeNode(contactToSendTo) ? getSnapshot(contactToSendTo) : contactToSendTo
// Bulk tx update
let expiresAtDate: Date | undefined = undefined
if(expiry && expiry > 0) {
expiresAtDate = addSeconds(new Date(timestamp * 1000), expiry)
} else {
expiresAtDate = addSeconds(new Date(timestamp * 1000), 86400)
}
const sentFromValue = contactTo?.nip05 || contactTo?.name || ''
const sentToValue = walletProfileStore.nip05 || ''
log.trace('[topupTask] invoice', {amount, paymentHash, expiry, timestamp, expiresAtDate})
transactionData.push({
status: TransactionStatus.PENDING,
quote: mintQuote,
createdAt: new Date()
}) })
transactionIdForRecovery = prepared.transactionId
const updateData = { const pending = await TopupOperationApi.execute(prepared)
quote: mintQuote,
paymentId: paymentHash,
paymentRequest: encodedInvoice,
expiresAt: expiresAtDate,
sentFrom: sentFromValue,
sentTo: sentToValue,
status: TransactionStatus.PENDING,
data: JSON.stringify(transactionData)
}
transaction.update(updateData)
if(!nwcEvent) {
const wsMint = new CashuMint(mintUrl)
const wsWallet = new CashuWallet(wsMint)
try {
const unsub = await wsWallet.on.mintQuotePaid(
mintQuote,
async (m: MintQuoteBolt11Response) => {
log.trace(`Websocket: mint quote PAID: ${m.quote}`)
WalletTask.handlePendingQueue()
unsub()
},
async (error: any) => {
throw error
}
)
} catch (error: any) {
log.error(Err.NETWORK_ERROR,
"Error in websocket subscription. Starting poller.",
error.message
)
poller(
`handlePendingTopupPoller-${paymentHash}`,
WalletTask.handlePendingQueue,
{
interval: 10 * 1000,
maxPolls: 6,
maxErrors: 2
},
{transaction})
.then(() => log.trace('Polling completed', [], `handlePendingTopupPoller`))
}
}
return { return {
taskFunction: TOPUP_TASK, taskFunction: TOPUP_TASK,
mintUrl, mintUrl,
transaction, transaction: pending,
message: '', message: '',
encodedInvoice, encodedInvoice: prepared.encodedInvoice,
nwcEvent nwcEvent,
} as TransactionTaskResult } as TransactionTaskResult
} catch (e: any) {
} catch (e: any) { if (transactionIdForRecovery) {
const tx = transactionsStore.findById(transactionIdForRecovery)
if (transaction) { if (tx && tx.status !== TransactionStatus.ERROR) {
transactionData.push({ let transactionData: TransactionData[] = []
status: TransactionStatus.ERROR, try { transactionData = JSON.parse(tx.data) } catch {}
error: WalletUtils.formatError(e), transactionData.push({
createdAt: new Date() status: TransactionStatus.ERROR,
}) error: WalletUtils.formatError(e),
createdAt: new Date(),
// Update status and data on error })
transaction.update({ tx.update({
status: TransactionStatus.ERROR, status: TransactionStatus.ERROR,
data: JSON.stringify(transactionData) data: JSON.stringify(transactionData),
}) })
}
} }
log.error(e.name, e.message) log.error(e.name, e.message)
return { return {
taskFunction: TOPUP_TASK, taskFunction: TOPUP_TASK,
transaction, transaction: transactionIdForRecovery
? transactionsStore.findById(transactionIdForRecovery)
: undefined,
message: e.message, message: e.message,
error: WalletUtils.formatError(e), error: WalletUtils.formatError(e),
} as TransactionTaskResult } as TransactionTaskResult
} }
} }
+87 -596
View File
@@ -1,642 +1,133 @@
import {CashuUtils} from '../cashu/cashuUtils' import {MeltQuoteBolt11Response} from '@cashu/cashu-ts'
import AppError, {Err} from '../../utils/AppError'
import {Mint as CashuMint, Wallet as CashuWallet, MeltProofsResponse, MeltQuoteBolt11Response, MeltQuoteState, getEncodedToken, normalizeProofAmounts} from '@cashu/cashu-ts'
import {rootStoreInstance} from '../../models' import {rootStoreInstance} from '../../models'
import { TransactionTaskResult, WalletTask } from '../walletService' import {TransactionTaskResult} from '../walletService'
import { MintBalance } from '../../models/Mint' import {MintBalance} from '../../models/Mint'
import { Proof } from '../../models/Proof' import {TransactionData, TransactionStatus} from '../../models/Transaction'
import { ProofReservation } from './proofReservation' import {log} from '../logService'
import { Transaction, TransactionData, TransactionStatus, TransactionType } from '../../models/Transaction' import {WalletUtils} from './utils'
import { log } from '../logService' import {MintUnit, formatCurrency, getCurrency} from './currency'
import { WalletUtils } from './utils' import {NostrEvent} from '../nostrService'
import { poller } from '../../utils/poller'
import {isBefore} from 'date-fns'
import { MintUnit, formatCurrency, getCurrency } from './currency'
import { NostrEvent } from '../nostrService'
import { LightningUtils } from '../lightning/lightningUtils'
const { const {transactionsStore} = rootStoreInstance
transactionsStore,
mintsStore,
proofsStore,
walletStore,
} = rootStoreInstance
// const {walletStore} = nonPersistedStores
export const TRANSFER_TASK = 'transferTask' export const TRANSFER_TASK = 'transferTask'
const _monitorAsyncMeltQuote = async (params: { /**
mintUrl: string * Backward-compatible transfer (lightning melt) task wrapper.
unit: MintUnit *
quoteId: string * Preserves the historical `WalletTask.transferQueueAwaitable` contract: a
proofsToMeltFrom: Proof[] * single call that creates the draft, reserves proofs, optionally swaps for
proofsToMeltFromAmount: number * tighter denominations, calls the mint to pay the invoice, and returns a
amountToTransfer: number * `TransactionTaskResult`.
meltFeeReserve: number *
transactionId: number * Internally delegates to the lifecycle API:
}) => { * `TransferOperationApi.prepare()` (DRAFT → PREPARED, reservation opens)
const { mintUrl, quoteId } = params * `TransferOperationApi.execute()` (PREPARED → EXECUTING → PENDING|COMPLETED)
const wsMint = new CashuMint(mintUrl) *
const wsWallet = new CashuWallet(wsMint) * Screens that want fee preview / cancel-before-execute should call the
* lifecycle methods directly instead of going through this wrapper.
try { */
log.trace('[transfer] Subscribing to meltQuoteUpdates for async melt', { quoteId })
const unsub = await wsWallet.on.meltQuoteUpdates(
[quoteId],
async (updatedQuote: MeltQuoteBolt11Response) => {
if (updatedQuote.state === MeltQuoteState.PAID ||
updatedQuote.state === MeltQuoteState.UNPAID) {
WalletTask.handlePendingMeltTask(params)
unsub()
}
},
async (error: any) => {
throw error
},
)
} catch (error: any) {
log.error(Err.NETWORK_ERROR,
'[transfer] WebSocket error for async melt, starting poller.',
error.message,
)
poller(
`meltQuotePoller-${quoteId}`,
WalletTask.handlePendingMeltTask,
{ interval: 15 * 1000, maxPolls: 8, maxErrors: 2 },
params,
).then(() => log.trace('[meltQuotePoller] polling completed', { quoteId }))
}
}
export const transferTask = async function ( export const transferTask = async function (
mintBalanceToTransferFrom: MintBalance, mintBalanceToTransferFrom: MintBalance,
amountToTransfer: number, amountToTransfer: number,
unit: MintUnit, unit: MintUnit,
meltQuote: MeltQuoteBolt11Response, meltQuote: MeltQuoteBolt11Response,
memo: string, memo: string,
invoiceExpiry: Date, invoiceExpiry: Date,
encodedInvoice: string, encodedInvoice: string,
nwcEvent?: NostrEvent, nwcEvent?: NostrEvent,
draftTransactionId?: number draftTransactionId?: number,
) : Promise<TransactionTaskResult> { ): Promise<TransactionTaskResult> {
const mintUrl = mintBalanceToTransferFrom.mintUrl const mintUrl = mintBalanceToTransferFrom.mintUrl
const mintInstance = mintsStore.findByUrl(mintUrl)
// TODO refresh - balance might be outdated if it waits in queue before other txs log.debug('[transferTask]', 'mintBalanceToTransferFrom', {mintBalanceToTransferFrom})
log.debug('[transfer]', 'mintBalanceToTransferFrom', {mintBalanceToTransferFrom}) log.debug('[transferTask]', 'amountToTransfer', {amountToTransfer})
log.debug('[transfer]', 'amountToTransfer', {amountToTransfer}) log.debug('[transferTask]', 'meltQuote', {meltQuote})
log.debug('[transfer]', 'meltQuote', {meltQuote})
// Lazy import avoids a circular dep across the operations module graph.
const {TransferOperationApi} = await import('./operations/transferOperationApi')
let transaction: Transaction | undefined = undefined let transactionIdForRecovery: number | undefined
let transactionData: TransactionData[] = []
let proofsToMeltFrom: Proof[] = []
let proofsToMeltFromAmount: number = 0
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 { try {
const prepared = await TransferOperationApi.prepare({
if(draftTransactionId && draftTransactionId > 0) { mintBalance: mintBalanceToTransferFrom,
transaction = transactionsStore.findById(draftTransactionId) amount: amountToTransfer,
} else {
// create draft transaction
transactionData.push({
status: TransactionStatus.DRAFT,
mintBalanceToTransferFrom,
amountToTransfer,
unit,
meltQuote,
//encodedInvoice,
isNwc: nwcEvent ? true : false,
createdAt: new Date(),
})
const newTransaction = {
type: TransactionType.TRANSFER,
amount: amountToTransfer,
fee: meltQuote.fee_reserve.toNumber(),
unit,
data: JSON.stringify(transactionData),
memo,
mint: mintBalanceToTransferFrom.mintUrl,
status: TransactionStatus.DRAFT,
}
// store tx in db and in the model
transaction = await transactionsStore.addTransaction(newTransaction)
}
if(!transaction) {
throw new AppError(Err.DATABASE_ERROR, 'Could not find or create transaction.')
}
const transactionId = transaction.id
const paymentHash = LightningUtils.getInvoiceData(LightningUtils.decodeInvoice(encodedInvoice)).payment_hash
// Replace individual setters with a single update
transaction.update({ paymentId: paymentHash, quote: meltQuote.quote })
if (amountToTransfer + meltQuote.fee_reserve.toNumber() > mintBalanceToTransferFrom.balances[unit]!) {
throw new AppError(
Err.VALIDATION_ERROR,
'Mint balance is insufficient to cover the amount to transfer with the expected Lightning fees.',
{transactionId}
)
}
if(isBefore(invoiceExpiry, new Date())) {
throw new AppError(
Err.VALIDATION_ERROR,
'This invoice has already expired and can not be paid.',
{invoiceExpiry, transactionId}
)
}
if (!mintInstance) {
throw new AppError(
Err.VALIDATION_ERROR,
'Could not find mint', {mintUrl, transactionId}
)
}
// calculate fees charged by mint for melt transaction to prepare enough proofs
const proofsFromMint = proofsStore.getByMint(mintUrl, {state: 'UNSPENT', unit})
const totalAmountFromMint = CashuUtils.getProofsAmount(proofsFromMint)
proofsToMeltFrom = CashuUtils.getProofsToSend(
amountToTransfer + meltQuote.fee_reserve.toNumber(),
proofsFromMint
)
proofsToMeltFromAmount = CashuUtils.getProofsAmount(proofsToMeltFrom)
const walletInstance = await walletStore.getWallet(mintUrl, unit, {withSeed: true})
meltFeeReserve = walletInstance.getFeesForProofs(proofsToMeltFrom).toNumber()
const amountWithFees = amountToTransfer + meltQuote.fee_reserve.toNumber() + meltFeeReserve
if (totalAmountFromMint < amountWithFees) {
throw new AppError(
Err.VALIDATION_ERROR,
'There is not enough funds to send this amount.',
{totalAmountFromMint, amountWithFees, transactionId, caller: 'transferTask'},
)
}
// exact match or min number of proofs that matches the amount
if(meltFeeReserve > 0) {
proofsToMeltFrom = CashuUtils.getProofsToSend(
amountWithFees,
proofsFromMint
)
proofsToMeltFromAmount = CashuUtils.getProofsAmount(proofsToMeltFrom)
}
// 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,
})
// 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,
swapInputProofs,
transactionId!,
)
// 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))
// 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! },
],
})
// 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
proofsToMeltFrom = pendingSwapProofs
didPreemptiveSwap = true
log.debug('[transfer] Preemptive swap completed', {
proofsToMeltFromAmount,
swapFeePaid,
meltFeeReserve,
})
} catch (swapError: any) {
// 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.rollbackReservation(swapReservation)
}
}
// 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, unit,
operationType: didPreemptiveSwap ? 'transfer-melt-after-swap' : 'transfer-melt', memo,
rollbackTo: 'UNSPENT', method: {
method: 'bolt11',
options: {encodedInvoice, meltQuote, invoiceExpiry},
},
nwcEvent,
draftTransactionId,
}) })
transactionIdForRecovery = prepared.transactionId
log.trace('[transfer]', 'Prepared proofsToMeltFrom proofs', { const settled = await TransferOperationApi.execute(prepared)
proofsToMeltFromAmount,
transactionId,
unit,
})
// Update transaction status and inputToken in one call // execute returns either COMPLETED (sync PAID) or PENDING (async).
transactionData.push({ if (settled.status === TransactionStatus.COMPLETED) {
status: TransactionStatus.PREPARED, const totalFeePaid = settled.fee ?? 0
proofsToMeltFromAmount, const meltFeePaid = prepared.meltFeeReserve + prepared.preemptiveSwapFeePaid
lightningFeeReserve: meltQuote.fee_reserve.toNumber(), const lightningFeePaid = totalFeePaid - meltFeePaid
meltFeeReserve, return {
...(swapFeePaid > 0 && {preemptiveSwapFeePaid: swapFeePaid}), taskFunction: TRANSFER_TASK,
createdAt: new Date(),
})
const inputToken = getEncodedToken({
mint: mintUrl,
proofs: normalizeProofAmounts(proofsToMeltFrom),
unit
})
transaction.update({
status: TransactionStatus.PREPARED,
data: JSON.stringify(transactionData),
keysetId: proofsToMeltFrom[0].id,
inputToken,
})
try {
meltResponse = await walletStore.payLightningMelt(
mintUrl, mintUrl,
unit, transaction: settled,
meltQuote, message: `Lightning invoice has been successfully paid and settled with your Minibits ecash. Fee has been ${formatCurrency(totalFeePaid, getCurrency(unit).code)} ${getCurrency(unit).code}.`,
proofsToMeltFrom,
transactionId,
{ preferAsync: nwcEvent ? false : true },
)
} catch (e: any) {
if (WalletUtils.shouldHealOutputsError(e)) {
log.error('[transferTask] Increasing proofsCounter outdated values and repeating payLightningMelt.')
meltResponse = await walletStore.payLightningMelt(
mintUrl,
unit,
meltQuote,
proofsToMeltFrom,
transactionId,
{ increaseCounterBy: 10, preferAsync: nwcEvent ? false : true },
)
} else {
throw e
}
}
if (meltResponse.quote.state === MeltQuoteState.PAID) {
log.debug('[transfer] Invoice PAID', {
transactionId
})
// compute fees and change
let totalFeePaid = proofsToMeltFromAmount - amountToTransfer
let lightningFeePaid = totalFeePaid - meltFeeReserve
let meltFeePaid = meltFeeReserve
let returnedAmount = CashuUtils.getProofsAmount(meltResponse.change)
let outputToken: string | undefined
// 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,
unit,
})
totalFeePaid = totalFeePaid - returnedAmount
lightningFeePaid = totalFeePaid - meltFeeReserve
}
//meltQuoteCheck = await walletStore.checkLightningMeltQuote(mintUrl, meltQuote.quote)
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
// build consolidated update payload
const completedDataItem: TransactionData = {
status: TransactionStatus.COMPLETED,
lightningFeePaid, lightningFeePaid,
meltFeePaid, meltFeePaid,
returnedAmount,
//@ts-ignore
preimage: meltResponse.quote.payment_preimage,
createdAt: new Date(),
}
transactionData.push(completedDataItem)
const updatePayload: any = {
status: TransactionStatus.COMPLETED,
data: JSON.stringify(transactionData),
fee: totalFeePaid,
balanceAfter,
}
if (outputToken) {
updatePayload.outputToken = outputToken
}
//@ts-ignore
if (meltResponse.quote.payment_preimage) {
//@ts-ignore
updatePayload.proof = meltResponse.quote.payment_preimage
}
transaction.update(updatePayload)
return {
taskFunction: TRANSFER_TASK,
mintUrl,
transaction,
message: `Lightning invoice has been successfully paid and settled with your Minibits ecash. Fee has been ${formatCurrency(transaction.fee, getCurrency(unit).code)} ${getCurrency(unit).code}.`,
lightningFeePaid,
meltFeePaid,
totalFeePaid, totalFeePaid,
meltQuote: meltResponse.quote, meltQuote,
//@ts-ignore //@ts-ignore
preimage: meltResponse.quote.payment_preimage, preimage: settled.proof ?? undefined,
nwcEvent
} as TransactionTaskResult
} else if(meltResponse.quote.state === MeltQuoteState.PENDING) {
log.debug('[transfer] Invoice PENDING, async melt in progress', {
quoteId: meltResponse.quote.quote,
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(),
})
transaction.update({
status: TransactionStatus.PENDING,
data: JSON.stringify(transactionData),
})
// Fire-and-forget: monitors quote via websocket (poller fallback) and
// updates the transaction + emits ev_asyncMeltResult when resolved.
_monitorAsyncMeltQuote({
mintUrl,
unit,
quoteId: meltResponse.quote.quote,
proofsToMeltFrom,
proofsToMeltFromAmount,
amountToTransfer,
meltFeeReserve,
transactionId,
})
return {
taskFunction: TRANSFER_TASK,
mintUrl,
transaction,
message: 'Lightning payment is in progress...',
meltQuote: meltResponse.quote,
nwcEvent, nwcEvent,
} as TransactionTaskResult } as TransactionTaskResult
} else {
// throw so that proper state of proofs is synced inside the catch block
throw new AppError(Err.MINT_ERROR, 'Lightning payment has not been paid.', {
meltResponseQuote: meltResponse.quote,
transactionId
})
} }
} catch (e: any) { // PENDING — async melt in progress; monitor will resolve via refresh.
let message = e.message return {
let taskResult: TransactionTaskResult = {
taskFunction: TRANSFER_TASK, taskFunction: TRANSFER_TASK,
mintUrl, mintUrl,
transaction, transaction: settled,
message, message: 'Lightning payment is in progress...',
nwcEvent meltQuote,
nwcEvent,
} as TransactionTaskResult } as TransactionTaskResult
let recovered: number = 0 } catch (e: any) {
let txAfterError = transactionIdForRecovery
? transactionsStore.findById(transactionIdForRecovery)
: undefined
if (transaction) { if (txAfterError && txAfterError.status !== TransactionStatus.PENDING) {
// If we threw AFTER opening the melt reservation, resolve it // execute()'s error handler may have already marked tx RECOVERED;
// (commit-no-changes or rollback) so the row doesn't become an // only stamp ERROR if execute() didn't.
// orphan. Errors thrown BEFORE the reservation opens (e.g. during if (
// proof selection) leave `meltReservation` undefined. txAfterError.status !== TransactionStatus.RECOVERED &&
txAfterError.status !== TransactionStatus.ERROR
meltQuoteCheck = await walletStore.checkLightningMeltQuote(mintUrl, meltQuote.quote) ) {
taskResult.meltQuote = meltQuoteCheck let transactionData: TransactionData[] = []
try { transactionData = JSON.parse(txAfterError.data) } catch {}
if (proofsToMeltFrom.length > 0) {
// --- PAID ---
if(meltQuoteCheck.state === MeltQuoteState.PAID) {
message = `Lightning invoice has been successfully paid, however some error occured: ${e.message}`
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,
meltQuoteCheck,
unit,
transactionId: transaction.id
})
recovered = recoveredAmount
// --- PENDING BY MINT ---
} else if(meltQuoteCheck.state === MeltQuoteState.PENDING) {
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}`,
unit,
meltQuoteCheck,
transactionId: transaction.id
})
// --- UNPAID ---
} else {
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 (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
})
await WalletTask.syncStateWithMintTask({
proofsToSync: proofsToMeltFrom, // includes some pending by mint proofs
mintUrl,
proofState: 'PENDING',
})
} else {
// 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."
log.error('[transfer]', message, {
proofsToMeltFromAmount,
transactionId: transaction.id
})
}
}
await WalletTask.syncStateWithMintTask({
proofsToSync: proofsStore.getByMint(mintUrl, {state: 'PENDING', unit}),
mintUrl,
proofState: 'PENDING',
})
}
if(meltQuoteCheck && meltQuoteCheck.state === MeltQuoteState.PAID) {
transactionData.push({
status: TransactionStatus.RECOVERED,
recoveredChangeAmount: recovered,
error: WalletUtils.formatError(e),
createdAt: new Date()
})
transaction.update({
status: TransactionStatus.RECOVERED,
data: JSON.stringify(transactionData),
})
} else {
transactionData.push({ transactionData.push({
status: TransactionStatus.ERROR, status: TransactionStatus.ERROR,
error: WalletUtils.formatError(e), error: WalletUtils.formatError(e),
createdAt: new Date() createdAt: new Date(),
}) })
txAfterError.update({
transaction.update({
status: TransactionStatus.ERROR, status: TransactionStatus.ERROR,
data: JSON.stringify(transactionData), data: JSON.stringify(transactionData),
}) })
taskResult.error = WalletUtils.formatError(e)
} }
} }
return taskResult return {
} taskFunction: TRANSFER_TASK,
} mintUrl,
transaction: txAfterError,
message: e.message,
error: WalletUtils.formatError(e),
nwcEvent,
} as TransactionTaskResult
}
}