mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
Transaction lifecycle methods
This commit is contained in:
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,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)
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 = {
|
||||||
@@ -23,7 +24,20 @@ 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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -1,98 +1,54 @@
|
|||||||
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
|
||||||
|
|
||||||
transaction.update({
|
const {transaction, cashuPaymentRequest, encodedCashuPaymentRequest} =
|
||||||
status: TransactionStatus.PENDING,
|
await CashuPaymentRequestApi.execute(prepared)
|
||||||
data: JSON.stringify(transactionData),
|
|
||||||
paymentId: cashuPrId
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
taskFunction: CASHU_PAYMENT_REQUEST_TASK,
|
taskFunction: CASHU_PAYMENT_REQUEST_TASK,
|
||||||
@@ -100,29 +56,33 @@ export const cashuPaymentRequestTask = async function (
|
|||||||
transaction,
|
transaction,
|
||||||
message: '',
|
message: '',
|
||||||
cashuPaymentRequest,
|
cashuPaymentRequest,
|
||||||
encodedCashuPaymentRequest: encoded,
|
encodedCashuPaymentRequest,
|
||||||
} as TransactionTaskResult
|
} as TransactionTaskResult
|
||||||
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
if (transactionIdForRecovery) {
|
||||||
if (transaction) {
|
const tx = transactionsStore.findById(transactionIdForRecovery)
|
||||||
transactionData.push({
|
if (tx && tx.status !== TransactionStatus.ERROR) {
|
||||||
status: TransactionStatus.ERROR,
|
let transactionData: TransactionData[] = []
|
||||||
error: WalletUtils.formatError(e),
|
try { transactionData = JSON.parse(tx.data) } catch {}
|
||||||
createdAt: new Date()
|
transactionData.push({
|
||||||
})
|
status: TransactionStatus.ERROR,
|
||||||
|
error: WalletUtils.formatError(e),
|
||||||
transaction.update({
|
createdAt: new Date(),
|
||||||
status: TransactionStatus.ERROR,
|
})
|
||||||
data: JSON.stringify(transactionData)
|
tx.update({
|
||||||
})
|
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})
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
+158
-510
@@ -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
|
|
||||||
)
|
|
||||||
|
|
||||||
|
const swapFeePaid = completed.fee ?? 0
|
||||||
// Finally, update completed transaction
|
const receivedAmount = completed.amount
|
||||||
transactionData.push({
|
// receiveBatchTask sums these to track progress across split batches.
|
||||||
status: TransactionStatus.COMPLETED,
|
const {proofsStore} = rootStoreInstance
|
||||||
swapFeePaid,
|
const receivedProofsCount = proofsStore
|
||||||
receivedAmount,
|
.getByTransactionId(completed.id)
|
||||||
unit,
|
.filter(p => p.state === 'UNSPENT').length
|
||||||
createdAt: new Date(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
|
|
||||||
|
|
||||||
transaction.update({
|
|
||||||
status: TransactionStatus.COMPLETED,
|
|
||||||
data: JSON.stringify(transactionData),
|
|
||||||
keysetId: receivedProofs[0].id,
|
|
||||||
outputToken,
|
|
||||||
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 (
|
export const receiveOfflineCompleteTask = async function (
|
||||||
transactionId: number
|
transactionId: number,
|
||||||
) {
|
): Promise<TransactionTaskResult> {
|
||||||
let mintToReceive = ''
|
const tx = transactionsStore.findById(transactionId)
|
||||||
const transaction = transactionsStore.findById(transactionId)
|
let mintToReceive = tx?.mint ?? ''
|
||||||
|
|
||||||
|
const {ReceiveOperationApi, receiveSuccessMessage, loadPreparedForOfflineComplete} =
|
||||||
|
await import('./operations/receiveOperationApi')
|
||||||
|
|
||||||
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})
|
||||||
}
|
}
|
||||||
|
mintToReceive = tx.mint
|
||||||
|
|
||||||
let transactionData = [] as unknown as TransactionData
|
const prepared = loadPreparedForOfflineComplete(transactionId)
|
||||||
|
|
||||||
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'})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure mint is in wallet state — may be missing for offline receives from new mints
|
|
||||||
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
|
const swapFeePaid = completed.fee ?? 0
|
||||||
transactionData.push({
|
const receivedAmount = completed.amount
|
||||||
status: TransactionStatus.COMPLETED,
|
|
||||||
receivedAmount,
|
|
||||||
swapFeePaid,
|
|
||||||
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,
|
|
||||||
...(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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+59
-418
@@ -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,
|
||||||
@@ -87,126 +63,67 @@ export const sendTask = async function (
|
|||||||
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) {
|
// Lazy import avoids a circular dep: sendOperationApi imports from
|
||||||
throw new AppError(Err.VALIDATION_ERROR, 'Amount to send must be above zero.')
|
// this file (the keyset helpers) and Phase 7 wires walletService events
|
||||||
}
|
// through the same module graph.
|
||||||
|
const {SendOperationApi} = await import('./operations/sendOperationApi')
|
||||||
|
|
||||||
let transaction: Transaction | undefined = undefined
|
let transactionIdForRecovery: number | undefined
|
||||||
let transactionData: TransactionData[] = []
|
|
||||||
|
|
||||||
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 = {
|
const prepared = await SendOperationApi.prepare({
|
||||||
type: TransactionType.SEND,
|
mintBalance: mintBalanceToSendFrom,
|
||||||
amount: amountToSend,
|
amount: amountToSend,
|
||||||
fee: 0,
|
|
||||||
unit,
|
|
||||||
data: JSON.stringify(transactionData),
|
|
||||||
memo,
|
|
||||||
mint: mintUrl,
|
|
||||||
status: TransactionStatus.DRAFT,
|
|
||||||
}
|
|
||||||
|
|
||||||
// store tx in db and in the model
|
|
||||||
transaction = await transactionsStore.addTransaction(newTransaction)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -1,195 +1,96 @@
|
|||||||
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) {
|
||||||
if (transaction) {
|
const tx = transactionsStore.findById(transactionIdForRecovery)
|
||||||
transactionData.push({
|
if (tx && tx.status !== TransactionStatus.ERROR) {
|
||||||
status: TransactionStatus.ERROR,
|
let transactionData: TransactionData[] = []
|
||||||
error: WalletUtils.formatError(e),
|
try { transactionData = JSON.parse(tx.data) } catch {}
|
||||||
createdAt: new Date()
|
transactionData.push({
|
||||||
})
|
status: TransactionStatus.ERROR,
|
||||||
|
error: WalletUtils.formatError(e),
|
||||||
// Update status and data on error
|
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: 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
|
||||||
|
|||||||
@@ -1,74 +1,32 @@
|
|||||||
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,
|
||||||
@@ -78,565 +36,98 @@ export const transferTask = async function (
|
|||||||
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
|
|
||||||
transactionData.push({
|
|
||||||
status: TransactionStatus.PREPARED,
|
|
||||||
proofsToMeltFromAmount,
|
|
||||||
lightningFeeReserve: meltQuote.fee_reserve.toNumber(),
|
|
||||||
meltFeeReserve,
|
|
||||||
...(swapFeePaid > 0 && {preemptiveSwapFeePaid: swapFeePaid}),
|
|
||||||
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,
|
|
||||||
unit,
|
|
||||||
meltQuote,
|
|
||||||
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,
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
// execute returns either COMPLETED (sync PAID) or PENDING (async).
|
||||||
|
if (settled.status === TransactionStatus.COMPLETED) {
|
||||||
|
const totalFeePaid = settled.fee ?? 0
|
||||||
|
const meltFeePaid = prepared.meltFeeReserve + prepared.preemptiveSwapFeePaid
|
||||||
|
const lightningFeePaid = totalFeePaid - meltFeePaid
|
||||||
return {
|
return {
|
||||||
taskFunction: TRANSFER_TASK,
|
taskFunction: TRANSFER_TASK,
|
||||||
mintUrl,
|
mintUrl,
|
||||||
transaction,
|
transaction: settled,
|
||||||
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}.`,
|
message: `Lightning invoice has been successfully paid and settled with your Minibits ecash. Fee has been ${formatCurrency(totalFeePaid, getCurrency(unit).code)} ${getCurrency(unit).code}.`,
|
||||||
lightningFeePaid,
|
lightningFeePaid,
|
||||||
meltFeePaid,
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user