From 3b911940250ca7acfb1fb72f0a0f41a7ac0d4aff Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Tue, 26 May 2026 00:08:47 +0200 Subject: [PATCH] Transaction lifecycle methods --- .../cashuPaymentRequestLifecycle.test.ts | 111 ++ __tests__/proofReservation.test.ts | 239 ++++ __tests__/receiveOperationLifecycle.test.ts | 125 ++ __tests__/sendOperationLifecycle.test.ts | 151 +++ __tests__/topupOperationLifecycle.test.ts | 119 ++ __tests__/transactionStates.test.ts | 171 +++ __tests__/transferOperationLifecycle.test.ts | 159 +++ __tests__/typedErrors.test.ts | 2 +- src/models/ProofsStore.ts | 34 +- src/models/Transaction.ts | 17 +- src/models/TransactionStates.ts | 232 ++++ src/screens/SendScreen.tsx | 4 +- src/services/sqlite.ts | 73 +- .../wallet/cashuPaymentRequestTask.ts | 142 +- .../operations/cashuPaymentRequestApi.ts | 537 ++++++++ .../operations/cashuPaymentRequestMethods.ts | 42 + .../wallet/operations/inFlightOperations.ts | 42 +- .../wallet/operations/meltOperations.ts | 64 +- .../wallet/operations/mintOperations.ts | 173 +-- .../wallet/operations/receiveMethods.ts | 59 + .../wallet/operations/receiveOperationApi.ts | 584 +++++++++ src/services/wallet/operations/sendMethods.ts | 49 + .../wallet/operations/sendOperationApi.ts | 740 +++++++++++ .../wallet/operations/syncOperations.ts | 198 ++- .../wallet/operations/topupMethods.ts | 47 + .../wallet/operations/topupOperationApi.ts | 652 ++++++++++ .../wallet/operations/transferMethods.ts | 51 + .../wallet/operations/transferOperationApi.ts | 1151 +++++++++++++++++ src/services/wallet/receiveTask.ts | 674 +++------- src/services/wallet/revertTask.ts | 38 +- src/services/wallet/sendTask.ts | 483 +------ src/services/wallet/topupTask.ts | 219 +--- src/services/wallet/transferTask.ts | 683 ++-------- 33 files changed, 6023 insertions(+), 2042 deletions(-) create mode 100644 __tests__/cashuPaymentRequestLifecycle.test.ts create mode 100644 __tests__/receiveOperationLifecycle.test.ts create mode 100644 __tests__/sendOperationLifecycle.test.ts create mode 100644 __tests__/topupOperationLifecycle.test.ts create mode 100644 __tests__/transactionStates.test.ts create mode 100644 __tests__/transferOperationLifecycle.test.ts create mode 100644 src/models/TransactionStates.ts create mode 100644 src/services/wallet/operations/cashuPaymentRequestApi.ts create mode 100644 src/services/wallet/operations/cashuPaymentRequestMethods.ts create mode 100644 src/services/wallet/operations/receiveMethods.ts create mode 100644 src/services/wallet/operations/receiveOperationApi.ts create mode 100644 src/services/wallet/operations/sendMethods.ts create mode 100644 src/services/wallet/operations/sendOperationApi.ts create mode 100644 src/services/wallet/operations/topupMethods.ts create mode 100644 src/services/wallet/operations/topupOperationApi.ts create mode 100644 src/services/wallet/operations/transferMethods.ts create mode 100644 src/services/wallet/operations/transferOperationApi.ts diff --git a/__tests__/cashuPaymentRequestLifecycle.test.ts b/__tests__/cashuPaymentRequestLifecycle.test.ts new file mode 100644 index 00000000..b50e7707 --- /dev/null +++ b/__tests__/cashuPaymentRequestLifecycle.test.ts @@ -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 = ['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) + }) +}) diff --git a/__tests__/proofReservation.test.ts b/__tests__/proofReservation.test.ts index 714ba91a..4f937d68 100644 --- a/__tests__/proofReservation.test.ts +++ b/__tests__/proofReservation.test.ts @@ -39,9 +39,43 @@ const CREATE_RESERVATIONS = `CREATE TABLE reservations ( 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) { db.exec(CREATE_PROOFS) 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( @@ -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( db: DatabaseSync, reservationId: string, @@ -130,6 +176,7 @@ function commitReservation( amount: number state: 'UNSPENT' | 'PENDING' | 'SPENT' }> + transactionUpdate?: CommitTransactionUpdate }, ) { const now = '2026-05-22T00:00:00.000Z' @@ -154,6 +201,34 @@ function commitReservation( 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.exec('COMMIT') } catch (e) { @@ -694,4 +769,168 @@ describe('Proof reservations', () => { 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() + }) + }) }) diff --git a/__tests__/receiveOperationLifecycle.test.ts b/__tests__/receiveOperationLifecycle.test.ts new file mode 100644 index 00000000..45332ac6 --- /dev/null +++ b/__tests__/receiveOperationLifecycle.test.ts @@ -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 = ['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) + }) +}) diff --git a/__tests__/sendOperationLifecycle.test.ts b/__tests__/sendOperationLifecycle.test.ts new file mode 100644 index 00000000..85a2e16f --- /dev/null +++ b/__tests__/sendOperationLifecycle.test.ts @@ -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 = ['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) + }) +}) diff --git a/__tests__/topupOperationLifecycle.test.ts b/__tests__/topupOperationLifecycle.test.ts new file mode 100644 index 00000000..fef29b9b --- /dev/null +++ b/__tests__/topupOperationLifecycle.test.ts @@ -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 = ['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) + }) +}) diff --git a/__tests__/transactionStates.test.ts b/__tests__/transactionStates.test.ts new file mode 100644 index 00000000..8f22cfc9 --- /dev/null +++ b/__tests__/transactionStates.test.ts @@ -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) + } + }) +}) diff --git a/__tests__/transferOperationLifecycle.test.ts b/__tests__/transferOperationLifecycle.test.ts new file mode 100644 index 00000000..6f3da4f5 --- /dev/null +++ b/__tests__/transferOperationLifecycle.test.ts @@ -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 = ['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) + }) +}) diff --git a/__tests__/typedErrors.test.ts b/__tests__/typedErrors.test.ts index 0906840b..7c17dc65 100644 --- a/__tests__/typedErrors.test.ts +++ b/__tests__/typedErrors.test.ts @@ -1,5 +1,5 @@ /** - * Typed error hierarchy tests (Phase 2 of coco alignment). + * Typed error hierarchy tests (Phase 2). * * Verifies: * - subclass instances are still instanceof AppError (backward compat) diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts index f1291549..8607f6fc 100644 --- a/src/models/ProofsStore.ts +++ b/src/models/ProofsStore.ts @@ -12,6 +12,7 @@ import { import AppError, { Err } from '../utils/AppError' import { Mint, MintBalance } from './Mint' import { Database } from '../services' + import { ReservationTransactionUpdate } from '../services/sqlite' import { MintUnit } from '../services/wallet/currency' import { CashuProof } from '../services/cashu/cashuUtils' import { generateId } from '../utils/utils' @@ -402,6 +403,12 @@ import { state: ProofState 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[] } { 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, { toSpent: changes.toSpent, toUnspent: changes.toUnspent, @@ -424,6 +432,7 @@ import { unit: reservation.unit, tId: group.tId, })), + transactionUpdate: changes.transactionUpdate, }) // Mirror to MST now that SQLite is durable. @@ -491,11 +500,32 @@ import { 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, toSpent: changes.toSpent?.length ?? 0, toUnspent: changes.toUnspent?.length ?? 0, addedCount: added.length, + txUpdate: changes.transactionUpdate?.id, }) return { added } diff --git a/src/models/Transaction.ts b/src/models/Transaction.ts index 2ffe9cb1..a6f1712f 100644 --- a/src/models/Transaction.ts +++ b/src/models/Transaction.ts @@ -2,6 +2,7 @@ import { flow, Instance, isAlive, SnapshotIn, SnapshotOut, types } from 'mobx-st import { log } from '../services/logService' import { MintUnit } from '../services/wallet/currency' import { Database } from '../services' +import { withSetPropAction } from './helpers/withSetPropAction' export type TransactionData = { @@ -22,8 +23,21 @@ export enum TransactionType { export enum TransactionStatus { DRAFT = 'DRAFT', 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', + /** + * 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', RECOVERED = 'RECOVERED', COMPLETED = 'COMPLETED', @@ -61,6 +75,7 @@ export const TransactionModel = types createdAt: types.optional(types.Date, new Date()), }) .views(self => ({})) + .actions(withSetPropAction) .actions(self => ({ pruneInputToken(inputToken: string) { self.inputToken = inputToken.slice(0, 40) diff --git a/src/models/TransactionStates.ts b/src/models/TransactionStates.ts new file mode 100644 index 00000000..bcfb7a5d --- /dev/null +++ b/src/models/TransactionStates.ts @@ -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: }`. 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 = new Set([ + TransactionStatus.COMPLETED, + TransactionStatus.REVERTED, + TransactionStatus.ERROR, + TransactionStatus.EXPIRED, + TransactionStatus.BLOCKED, + TransactionStatus.RECOVERED, +]) + +const IN_FLIGHT_STATUSES: ReadonlySet = new Set([ + TransactionStatus.DRAFT, + TransactionStatus.PREPARED, + TransactionStatus.PREPARED_OFFLINE, + TransactionStatus.EXECUTING, + TransactionStatus.PENDING, + TransactionStatus.ROLLING_BACK, +]) + +const ROLLBACKABLE_STATUSES: ReadonlySet = 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) +} diff --git a/src/screens/SendScreen.tsx b/src/screens/SendScreen.tsx index 58166d4b..fc90d132 100644 --- a/src/screens/SendScreen.tsx +++ b/src/screens/SendScreen.tsx @@ -1092,7 +1092,7 @@ export const SendScreen = observer(function SendScreen({ route }: Props) { const handleSendTaskResult = async (result: TransactionTaskResult) => { log.trace('[SendScreen] handleSendTaskResult start') - const { transaction: txResult, error, encodedTokenToSend: token } = result + const { transaction: txResult, error } = result // ——— Error path ——— if (error || !txResult) { @@ -1132,7 +1132,7 @@ export const SendScreen = observer(function SendScreen({ route }: Props) { dispatch({ type: 'SEND_TASK_SUCCESS', - token: token ?? '', + token: txResult.outputToken ?? '', transaction: txResult, openTransport, }) diff --git a/src/services/sqlite.ts b/src/services/sqlite.ts index 29e7ef43..3c5ec764 100644 --- a/src/services/sqlite.ts +++ b/src/services/sqlite.ts @@ -1290,8 +1290,33 @@ const openReservation = function ( } /** - * Commit a reservation: apply the supplied state transitions and delete the - * reservation row — all in a single SQLite transaction. + * Optional transaction-row update atomically batched with a reservation commit. + * + * Only the named columns can be set; this is intentionally narrower than the + * full Transaction shape so the API is predictable and only covers fields that + * legitimately need to land atomically with a proof-state finalize. + */ +export type ReservationTransactionUpdate = { + id: number + status?: TransactionStatus + data?: string + amount?: number + fee?: number + balanceAfter?: number + outputToken?: string + keysetId?: string + proof?: string +} + +/** + * Commit a reservation: apply the supplied state transitions, optionally a + * transaction-row update, and delete the reservation row — all in a single + * SQLite transaction. + * + * Passing `transactionUpdate` closes the proofs-table vs transactions-table + * atomicity window: a crash between proof-state finalize and tx-status update + * would otherwise leave a transaction stuck in PENDING/PREPARED while its + * underlying proofs are SPENT. */ const commitReservation = function ( reservationId: string, @@ -1305,6 +1330,7 @@ const commitReservation = function ( unit: string tId: number }> + transactionUpdate?: ReservationTransactionUpdate }, ): void { try { @@ -1329,12 +1355,19 @@ const commitReservation = function ( for (const group of changes.newProofs ?? []) { for (const proof of group.proofs) { + // proof.amount may be a cashu-ts `Amount` class instance (when the + // group came straight from `cashuWallet.send/mint/melt` responses) + // rather than a plain number. Coerce explicitly — SQLite's JSI + // binding can't bind non-primitive objects to an INTEGER column and + // would silently drop the row, leaving the proof in MST but absent + // from the database (lost on the next restart). + const amount = typeof proof.amount === 'number' ? proof.amount : Number(proof.amount) batch.push([ `INSERT OR REPLACE INTO proofs (id, amount, secret, C, dleq_r, dleq_s, dleq_e, unit, tId, mintUrl, state, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ proof.id, - proof.amount, + amount, proof.secret, proof.C, 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]]) const db = getInstance() db.executeBatch(batch) - log.info('[commitReservation]', 'Reservation committed', { + log.info('[commitReservation] ', 'Reservation committed to DB', { id: reservationId, toSpent: changes.toSpent?.length ?? 0, toUnspent: changes.toUnspent?.length ?? 0, newGroups: changes.newProofs?.length ?? 0, + txUpdate: changes.transactionUpdate ? changes.transactionUpdate.id : undefined, }) } catch (e: any) { throw new AppError( diff --git a/src/services/wallet/cashuPaymentRequestTask.ts b/src/services/wallet/cashuPaymentRequestTask.ts index 444bcae2..c044276f 100644 --- a/src/services/wallet/cashuPaymentRequestTask.ts +++ b/src/services/wallet/cashuPaymentRequestTask.ts @@ -1,128 +1,88 @@ import {rootStoreInstance} from '../../models' -import { TransactionTaskResult } from '../walletService' -import { MintBalance } from '../../models/Mint' -import { Transaction, TransactionData, TransactionStatus, TransactionType } from '../../models/Transaction' -import { log } from '../logService' -import { WalletUtils } from './utils' -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' +import {TransactionTaskResult} from '../walletService' +import {MintBalance} from '../../models/Mint' +import {TransactionData, TransactionStatus} from '../../models/Transaction' +import {log} from '../logService' +import {WalletUtils} from './utils' +import {MintUnit} from './currency' -const { - transactionsStore, - walletProfileStore, - relaysStore -} = rootStoreInstance - -// const {walletStore} = nonPersistedStores +const {transactionsStore} = rootStoreInstance 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 ( mintBalanceToReceiveTo: MintBalance, amountToReceive: number, unit: MintUnit, memo: string, -) : Promise { +): Promise { log.info('[cashuPaymentRequestTask]', {mintBalanceToReceiveTo}) 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 {CashuPaymentRequestApi} = await import('./operations/cashuPaymentRequestApi') + + let transactionIdForRecovery: number | undefined + try { - const newTransaction = { - type: TransactionType.RECEIVE_BY_PAYMENT_REQUEST, + const prepared = await CashuPaymentRequestApi.prepare({ + mintBalance: mintBalanceToReceiveTo, amount: amountToReceive, - 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) - - 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() + method: {method: 'nostr', options: {}}, }) + transactionIdForRecovery = prepared.transactionId + + const {transaction, cashuPaymentRequest, encodedCashuPaymentRequest} = + await CashuPaymentRequestApi.execute(prepared) - transaction.update({ - status: TransactionStatus.PENDING, - data: JSON.stringify(transactionData), - paymentId: cashuPrId - }) - return { taskFunction: CASHU_PAYMENT_REQUEST_TASK, mintUrl, transaction, message: '', cashuPaymentRequest, - encodedCashuPaymentRequest: encoded, + encodedCashuPaymentRequest, } as TransactionTaskResult - - } catch (e: any) { - - if (transaction) { - transactionData.push({ - status: TransactionStatus.ERROR, - error: WalletUtils.formatError(e), - createdAt: new Date() - }) - - transaction.update({ - status: TransactionStatus.ERROR, - data: JSON.stringify(transactionData) - }) + } catch (e: any) { + if (transactionIdForRecovery) { + const tx = transactionsStore.findById(transactionIdForRecovery) + if (tx && tx.status !== TransactionStatus.ERROR) { + 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), + }) + } } log.error(e.name, e.message) return { taskFunction: CASHU_PAYMENT_REQUEST_TASK, - transaction, + transaction: transactionIdForRecovery + ? transactionsStore.findById(transactionIdForRecovery) + : undefined, message: e.message, error: WalletUtils.formatError(e), } as TransactionTaskResult diff --git a/src/services/wallet/operations/cashuPaymentRequestApi.ts b/src/services/wallet/operations/cashuPaymentRequestApi.ts new file mode 100644 index 00000000..b1990f28 --- /dev/null +++ b/src/services/wallet/operations/cashuPaymentRequestApi.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, +} diff --git a/src/services/wallet/operations/cashuPaymentRequestMethods.ts b/src/services/wallet/operations/cashuPaymentRequestMethods.ts new file mode 100644 index 00000000..274c2bf9 --- /dev/null +++ b/src/services/wallet/operations/cashuPaymentRequestMethods.ts @@ -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 +} + +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'] +} diff --git a/src/services/wallet/operations/inFlightOperations.ts b/src/services/wallet/operations/inFlightOperations.ts index c2b28de6..0f939c29 100644 --- a/src/services/wallet/operations/inFlightOperations.ts +++ b/src/services/wallet/operations/inFlightOperations.ts @@ -1,6 +1,7 @@ import {isAlive} from 'mobx-state-tree' import {getEncodedToken, normalizeProofAmounts} from '@cashu/cashu-ts' import {log} from '../../logService' +import {CashuUtils} from '../../cashu/cashuUtils' import {rootStoreInstance} from '../../../models' import {Mint} from '../../../models/Mint' import {Proof} from '../../../models/Proof' @@ -138,28 +139,39 @@ const handleInFlightByMintTask = async (mint: Mint): Promise = {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, - // proofsToSend → PENDING, reservation row deleted — - // single SQLite transaction. + // proofsToSend → PENDING, tx → PENDING, reservation + // row deleted — single SQLite transaction. proofsStore.commitReservation(reservation, { toSpent: lockedInputs, newProofs: [ { proofs: returnedProofs, state: 'UNSPENT', tId: tx.id }, { proofs: proofsToSend, state: 'PENDING', tId: tx.id }, ], - }) - - const outputToken = getEncodedToken({mint: mintUrl, proofs: normalizeProofAmounts(proofsToSend), unit}) - const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance - - txData.push({status: TransactionStatus.PENDING, createdAt: new Date()}) - - tx.update({ - status: TransactionStatus.PENDING, - data: JSON.stringify(txData), - outputToken, - balanceAfter, - fee: swapFeePaid > 0 ? swapFeePaid : tx.fee, + transactionUpdate: { + id: tx.id, + status: TransactionStatus.PENDING, + data: JSON.stringify(txData), + outputToken, + balanceAfter, + fee: swapFeePaid > 0 ? swapFeePaid : tx.fee, + }, }) } catch (sendError: any) { // Rollback restores each input to its pre-reservation diff --git a/src/services/wallet/operations/meltOperations.ts b/src/services/wallet/operations/meltOperations.ts index 02609a66..03cf0190 100644 --- a/src/services/wallet/operations/meltOperations.ts +++ b/src/services/wallet/operations/meltOperations.ts @@ -294,27 +294,14 @@ const handlePendingMeltTask = async (params: { lightningFeePaid = totalFeePaid - meltFeeReserve } - // Atomic finalize: proofsToMeltFrom move PENDING → SPENT and change (if any) - // is added as UNSPENT in a single SQLite transaction. Replaces the previous - // two-step `moveToSpent` + `addOrUpdate(change)` which left a crash window - // where ecash could be marked SPENT without the change being recorded. - const reservation = proofsStore.reserve(proofsToMeltFrom, { - transactionId, - mintUrl, - unit, - operationType: 'pending-melt-finalize-paid', - rollbackTo: 'PENDING', - }) - - proofsStore.commitReservation(reservation, { - toSpent: proofsToMeltFrom, - newProofs: unblinded.change.length > 0 - ? [{ proofs: unblinded.change, state: 'UNSPENT', tId: transactionId }] - : [], - }) - + // Pre-compute everything that needs to land atomically. balanceAfter + // is simulated: proofsToMeltFrom were PENDING (contribute 0 to UNSPENT + // pool); moving them to SPENT changes nothing. The unblinded change is + // newly added as UNSPENT, raising the spendable balance. 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({ status: TransactionStatus.COMPLETED, lightningFeePaid, @@ -324,16 +311,33 @@ const handlePendingMeltTask = async (params: { preimage: quote.payment_preimage, createdAt: new Date(), }) - const updatePayload: any = { - status: TransactionStatus.COMPLETED, - data: JSON.stringify(txData), - fee: totalFeePaid, - balanceAfter, - } - if (outputToken) updatePayload.outputToken = outputToken - //@ts-ignore - if (quote.payment_preimage) updatePayload.proof = quote.payment_preimage - transaction.update(updatePayload) + + const reservation = proofsStore.reserve(proofsToMeltFrom, { + transactionId, + mintUrl, + unit, + operationType: 'pending-melt-finalize-paid', + rollbackTo: 'PENDING', + }) + + // 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}) diff --git a/src/services/wallet/operations/mintOperations.ts b/src/services/wallet/operations/mintOperations.ts index 64bfded5..b2e66dee 100644 --- a/src/services/wallet/operations/mintOperations.ts +++ b/src/services/wallet/operations/mintOperations.ts @@ -14,8 +14,8 @@ import {Contact} from '../../../models/Contact' import {CashuProof, CashuUtils} from '../../cashu/cashuUtils' import {LightningUtils} from '../../lightning/lightningUtils' import {NostrEvent} from '../../nostrService' -import {pollerExists, stopPolling} from '../../../utils/poller' -import {MintUnit, formatCurrency, getCurrency} from '../currency' +import {pollerExists} from '../../../utils/poller' +import {MintUnit} from '../currency' import {SyncQueue} from '../../syncQueueService' import {topupTask} from '../topupTask' import {WalletUtils} from '../utils' @@ -25,7 +25,6 @@ import { TransactionTaskResult, WalletTaskResult, } from '../types' -import {sendTopupNotification} from '../notifications' const { mintsStore, @@ -59,6 +58,14 @@ const topupQueueAwaitable = ( /** * 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 ( params: {transaction: Transaction}, ): Promise => { @@ -70,7 +77,6 @@ const handlePendingTopupTask = async ( amount, paymentId: paymentHash, quote: mintQuote, - expiresAt, } = tx log.warn('[handlePendingTopupTask] start', {tx}) @@ -80,129 +86,45 @@ const handlePendingTopupTask = async ( throw new ValidationError('Invalid pending topup transaction', {tId}) } - let txData: TransactionData = tx.data ? JSON.parse(tx.data) : [] + const {TopupOperationApi, topupSuccessMessage} = await import('./topupOperationApi') 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()) - - if (isExpired && state !== MintQuoteState.PAID) { - log.debug('[handlePendingTopupTask] Invoice expired and not paid', {paymentHash}) - - txData.push({status: TransactionStatus.EXPIRED, message: 'Invoice expired', createdAt: new Date()}) - tx.update({status: TransactionStatus.EXPIRED, data: JSON.stringify(txData)}) - stopPolling(`handlePendingTopupPoller-${paymentHash}`) - - return { - taskFunction: HANDLE_PENDING_TOPUP_TASK, - transaction: tx, - mintUrl, - unit, - amount, - paymentHash, - message: 'Topup invoice expired and was not paid', + let message: string + switch (status) { + case TransactionStatus.COMPLETED: { + // Distinguish "minted now" from "already issued elsewhere" via the + // latest tx.data entry (refresh stamps a `note` for ISSUED). + const last = _lastDataEntry(refreshed) + if (last?.note === 'Already issued') { + message = 'Ecash already issued' + } else { + const paidAfterExpiry = + !!tx.expiresAt && isBefore(tx.expiresAt, new Date()) + message = topupSuccessMessage(amount, unit, paidAfterExpiry) + } + break } + 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) { - case MintQuoteState.UNPAID: - log.trace('[handlePendingTopupTask] Quote still unpaid', {mintQuote}) - - if (isExpired) { - txData.push({status: TransactionStatus.EXPIRED, message: 'Invoice expired (unpaid)', createdAt: new Date()}) - tx.update({status: TransactionStatus.EXPIRED, data: JSON.stringify(txData)}) - stopPolling(`handlePendingTopupPoller-${paymentHash}`) - } - - return { - taskFunction: HANDLE_PENDING_TOPUP_TASK, - transaction: tx, - mintUrl, - unit, - amount, - paymentHash, - message: isExpired ? 'Invoice expired (unpaid)' : 'Quote not paid yet', - } - - case MintQuoteState.ISSUED: - log.info('[handlePendingTopupTask] Proofs already issued (likely from another device)', {mintQuote}) - txData.push({status: TransactionStatus.COMPLETED, note: 'Already issued', createdAt: new Date()}) - tx.update({status: TransactionStatus.COMPLETED, data: JSON.stringify(txData)}) - stopPolling(`handlePendingTopupPoller-${paymentHash}`) - return { - taskFunction: HANDLE_PENDING_TOPUP_TASK, - transaction: tx, - mintUrl, - unit, - amount, - paymentHash, - message: 'Ecash already issued', - } - - case MintQuoteState.PAID: { - log.debug('[handlePendingTopupTask] Quote PAID – minting proofs (even if expired)', {paymentHash, amount, unit, isExpired}) - - let proofs: CashuProof[] = [] - - try { - proofs = await walletStore.mintProofs(mintUrl, amount, unit, mintQuote, tId) - } catch (e: any) { - if (WalletUtils.shouldHealOutputsError(e)) { - log.error('[handlePendingTopupTask] Increasing proofsCounter outdated values and repeating mintProofs.') - proofs = await walletStore.mintProofs(mintUrl, amount, unit, mintQuote, tId, {increaseCounterBy: 10}) - } else { - throw e - } - } - - if (proofs.length === 0) { - throw new MintError('Mint returned no proofs after payment') - } - - proofsStore.addOrUpdate(proofs, { - mintUrl, - unit, - tId, - state: 'UNSPENT', - }) - - const currencyCode = getCurrency(unit).code - const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance - - txData.push({status: TransactionStatus.COMPLETED, createdAt: new Date()}) - - tx.update({ - status: TransactionStatus.COMPLETED, - data: JSON.stringify(txData), - balanceAfter, - }) - - stopPolling(`handlePendingTopupPoller-${paymentHash}`) - sendTopupNotification(amount, unit) - - return { - taskFunction: HANDLE_PENDING_TOPUP_TASK, - transaction: tx, - mintUrl, - unit, - amount, - paymentHash, - message: `Topup successful: +${formatCurrency(amount, currencyCode)} ${currencyCode}${isExpired ? ' (paid after expiry)' : ''}`, - } - } - - default: - log.error('[handlePendingTopupTask] Unknown quote state', {state}) - return { - taskFunction: HANDLE_PENDING_TOPUP_TASK, - transaction: tx, - mintUrl, - unit, - amount, - paymentHash, - message: `Unknown quote state: ${state}`, - } + return { + taskFunction: HANDLE_PENDING_TOPUP_TASK, + transaction: refreshed, + mintUrl, + unit, + amount, + paymentHash, + message, } } catch (e: any) { 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) */ diff --git a/src/services/wallet/operations/receiveMethods.ts b/src/services/wallet/operations/receiveMethods.ts new file mode 100644 index 00000000..a355bb9c --- /dev/null +++ b/src/services/wallet/operations/receiveMethods.ts @@ -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 = + 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'] +} diff --git a/src/services/wallet/operations/receiveOperationApi.ts b/src/services/wallet/operations/receiveOperationApi.ts new file mode 100644 index 00000000..dab198b4 --- /dev/null +++ b/src/services/wallet/operations/receiveOperationApi.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, +} diff --git a/src/services/wallet/operations/sendMethods.ts b/src/services/wallet/operations/sendMethods.ts new file mode 100644 index 00000000..5ff83bfc --- /dev/null +++ b/src/services/wallet/operations/sendMethods.ts @@ -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 + + /** + * 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 = 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']} diff --git a/src/services/wallet/operations/sendOperationApi.ts b/src/services/wallet/operations/sendOperationApi.ts new file mode 100644 index 00000000..eaf1e42d --- /dev/null +++ b/src/services/wallet/operations/sendOperationApi.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, +} diff --git a/src/services/wallet/operations/syncOperations.ts b/src/services/wallet/operations/syncOperations.ts index 047a0078..293c686f 100644 --- a/src/services/wallet/operations/syncOperations.ts +++ b/src/services/wallet/operations/syncOperations.ts @@ -6,6 +6,8 @@ import {rootStoreInstance} from '../../../models' import {Proof, ProofState} from '../../../models/Proof' import {MintStatus} from '../../../models/Mint' import { + Transaction, + TransactionData, TransactionStatus, TransactionType, } from '../../../models/Transaction' @@ -15,7 +17,8 @@ import {stopPolling} from '../../../utils/poller' import {sendTask} from '../sendTask' import {createQueueAwaitable} from '../queueHelper' import {receiveBatchTask} from './receiveOperations' -import {MeltOperationService} from './meltOperations' +import {SendOperationApi} from './sendOperationApi' +import {TransferOperationApi} from './transferOperationApi' import { MAX_SWAP_INPUT_SIZE, MAX_SYNC_INPUT_SIZE, @@ -114,6 +117,13 @@ const syncStateWithMintTask = async function ( } // 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) { const spentProofs = aliveProofs.filter(p => secrets.spent.has(p.secret)) const spentByTx = groupByTId(spentProofs) @@ -132,6 +142,11 @@ const syncStateWithMintTask = async function ( } 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) transactionStateUpdates.push({ tId, @@ -145,58 +160,50 @@ const syncStateWithMintTask = async function ( const stillUnspent = aliveProofs.filter(p => secrets.unspent.has(p.secret)) 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) - const update: TransactionStateUpdate = { + transactionStateUpdates.push({ tId, amount: tx.amount, spentByMintAmount: spentAmount, updatedStatus: TransactionStatus.COMPLETED, - } - if (tx.type === TransactionType.TRANSFER && tx.quote) { - update.meltQuoteToRecover = tx.quote - } - transactionStateUpdates.push(update) - } - } - - for (const update of transactionStateUpdates) { - if (update.meltQuoteToRecover) { - const {recoveredAmount} = await MeltOperationService.recoverMeltQuoteChange({ - mintUrl, - meltQuote: update.meltQuoteToRecover, }) - update.recoveredChangeAmount = recoveredAmount + } 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) { - 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(), - }), - ) + if (completedTxIds.length > 0 || errorTxIds.length > 0) { 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) { 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( s => !secrets.pending.has(s), ) if (noLongerPendingSecrets.length > 0) { - const revertedProofs: Proof[] = [] - const revertedByTx = new Map() - + // Unregister all at once; per-tx refresh handles the proof revert. + const txsToRefresh = new Map() for (const secret of noLongerPendingSecrets) { const proof = proofsStore.getBySecret(secret) if (!proof || !proof.tId) continue - - revertedProofs.push(proof) - revertedByTx.set(proof.tId, (revertedByTx.get(proof.tId) ?? 0) + proof.amount) - if (!revertedTxIds.includes(proof.tId)) revertedTxIds.push(proof.tId) + txsToRefresh.set(proof.tId, (txsToRefresh.get(proof.tId) ?? 0) + proof.amount) } + proofsStore.unregisterFromPendingAtMint(new Set(noLongerPendingSecrets)) - if (revertedProofs.length > 0) { - proofsStore.revertToSpendable(revertedProofs) - proofsStore.unregisterFromPendingAtMint(new Set(noLongerPendingSecrets)) + for (const [tId, amount] of txsToRefresh) { + const tx = transactionsStore.findById(tId) + 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({ tId, movedToSpendableAmount: amount, updatedStatus: TransactionStatus.REVERTED, }) - } - - if (revertedTxIds.length > 0) { - await transactionsStore.updateStatuses( - revertedTxIds, - TransactionStatus.REVERTED, - JSON.stringify({ - message: 'Lightning payment failed – ecash returned to spendable balance', - revertedStateUpdates: transactionStateUpdates.filter(u => u.updatedStatus === TransactionStatus.REVERTED), - createdAt: new Date(), - }), - ) + } catch (e: any) { + log.error('[syncStateWithMintTask] refresh dispatch failed', { + tId, + error: e.message, + }) + _markErrorBySync(tx, e.message) + errorTxIds.push(tId) + transactionStateUpdates.push({ + tId, + 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 { + 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: { proofState: ProofState }): Promise { @@ -468,7 +530,7 @@ const swapAllTask = async function (): Promise { batch, ) - const encodedTokenToReceive: string = sendResult.encodedTokenToSend + const encodedTokenToReceive: string = sendResult.transaction?.outputToken ?? '' const tokenToReceive = getDecodedToken(encodedTokenToReceive, mint.keysetIds) const tokenAmount = CashuUtils.getProofsAmount(tokenToReceive.proofs) @@ -501,7 +563,7 @@ const swapAllTask = async function (): Promise { proofsToOptimize, ) - const encodedTokenToReceive: string = sendResult.encodedTokenToSend + const encodedTokenToReceive: string = sendResult.transaction?.outputToken ?? '' const tokenToReceive = getDecodedToken(encodedTokenToReceive, mint.keysetIds) const tokenAmount = CashuUtils.getProofsAmount(tokenToReceive.proofs) @@ -577,7 +639,7 @@ const swapByDenominationTask = async function (denomination: number): Promise = + 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'] +} diff --git a/src/services/wallet/operations/topupOperationApi.ts b/src/services/wallet/operations/topupOperationApi.ts new file mode 100644 index 00000000..023bb0e2 --- /dev/null +++ b/src/services/wallet/operations/topupOperationApi.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, +} diff --git a/src/services/wallet/operations/transferMethods.ts b/src/services/wallet/operations/transferMethods.ts new file mode 100644 index 00000000..8d950475 --- /dev/null +++ b/src/services/wallet/operations/transferMethods.ts @@ -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 = + 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'] +} diff --git a/src/services/wallet/operations/transferOperationApi.ts b/src/services/wallet/operations/transferOperationApi.ts new file mode 100644 index 00000000..acc7ddf2 --- /dev/null +++ b/src/services/wallet/operations/transferOperationApi.ts @@ -0,0 +1,1151 @@ +/** + * Transfer (lightning melt) operation lifecycle API. + * + * Splits the historical monolithic `transferTask` into explicit lifecycle methods: + * + * prepare() → PreparedTransferData (DRAFT → PREPARED, melt reservation OPEN, + * preemptive swap done if beneficial) + * execute() → PendingTransaction | + * CompletedTransaction (PREPARED → EXECUTING → + * PENDING/COMPLETED, atomic commit) + * cancel() → RevertedTransaction (PREPARED → REVERTED, reservation ROLLED BACK) + * reclaim() → RevertedTransaction (PENDING → REVERTED via existing revertTask) + * finalize() → CompletedTransaction (PENDING → COMPLETED after mint reports PAID) + * refresh() → Transaction (re-check PENDING with mint; mirrors the + * legacy handlePendingMeltTask: PAID → + * COMPLETED, UNPAID → ERROR + revert) + * + * Between `prepare` and either `execute` or `cancel`, the melt reservation row + * stays in SQLite — so a crash leaves an orphan that startup recovery rolls + * back automatically. No proofs get stuck. + * + * The legacy `WalletTask.transferQueueAwaitable` and `handlePendingMeltTask` + * continue to work via thin wrappers in `transferTask.ts` and + * `meltOperations.ts` that call into this API. + */ + +import {isBefore} from 'date-fns' +import { + getEncodedToken, + normalizeProofAmounts, + MeltProofsResponse, + MeltQuoteBolt11Response, + MeltQuoteState, + Mint as CashuMint, + Wallet as CashuWallet, +} from '@cashu/cashu-ts' +import {log} from '../../logService' +import {MintError, ValidationError, WalletError} from '../../../utils/AppError' +import EventEmitter from '../../../utils/eventEmitter' +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 {CashuUtils} from '../../cashu/cashuUtils' +import {LightningUtils} from '../../lightning/lightningUtils' +import {MintUnit, formatCurrency, getCurrency} from '../currency' +import {NostrEvent} from '../../nostrService' +import {WalletUtils} from '../utils' +import {WalletTask} from '../../walletService' +import {ProofReservation} from '../proofReservation' +import {Database, ReservationRow} from '../../sqlite' +import {poller} from '../../../utils/poller' +import {Err} from '../../../utils/AppError' +import {TransferMethodInput} from './transferMethods' + +const {mintsStore, proofsStore, transactionsStore, walletStore} = rootStoreInstance + +// ───────────────────────────────────────────────────────────────────────────── +// Public types +// ───────────────────────────────────────────────────────────────────────────── + +export interface PrepareTransferInput { + mintBalance: MintBalance + /** Amount the recipient receives (excludes lightning + mint fees). */ + amount: number + unit: MintUnit + memo: string + /** Transfer method discriminator (currently only `bolt11`). */ + method: TransferMethodInput + /** NWC request that triggered this transfer (optional). */ + nwcEvent?: NostrEvent + /** Resume from an existing DRAFT transaction (e.g. retry after error). */ + draftTransactionId?: number +} + +/** + * Path the prepared transfer will follow when executed. + * - `direct-melt`: locked proofs are paid out as-is. + * - `preemptive-swap-then-melt`: oversized inputs were swapped for tighter + * denominations during prepare to keep mint melt fees low; the swap output + * becomes the melt input. + */ +export type TransferPath = 'direct-melt' | 'preemptive-swap-then-melt' + +/** + * Returned by `prepare`. Holds enough computed metadata for execute() to + * proceed without recomputing fees or re-selecting proofs. + */ +export interface PreparedTransferData { + transactionId: number + /** Snapshot of the tx at prepare time. Re-fetch by id for live state. */ + tx: PreparedTransaction + mintUrl: string + unit: MintUnit + amountToTransfer: number + meltQuote: MeltQuoteBolt11Response + invoiceExpiry: Date + path: TransferPath + method: TransferMethodInput + /** Proofs locked under the melt reservation (the operation's inputs). */ + proofsToMeltFrom: Proof[] + proofsToMeltFromAmount: number + /** Mint swap fee charged for melting these specific proofs. */ + meltFeeReserve: number + /** Lightning fee reserve (mirror of meltQuote.fee_reserve). */ + lightningFeeReserve: number + /** Fee paid for the preemptive swap (0 if no swap ran). */ + preemptiveSwapFeePaid: number + nwcEvent?: NostrEvent +} + +// ───────────────────────────────────────────────────────────────────────────── +// prepare() +// ───────────────────────────────────────────────────────────────────────────── + +async function prepare(input: PrepareTransferInput): Promise { + const {mintBalance, amount, unit, memo, method, nwcEvent, draftTransactionId} = input + + if (amount <= 0) { + throw new ValidationError('Amount to transfer must be above zero.') + } + if (method.method !== 'bolt11') { + throw new ValidationError(`Unsupported transfer method: ${(method as any).method}`) + } + + const {meltQuote, encodedInvoice, invoiceExpiry} = method.options + const mintUrl = mintBalance.mintUrl + const mintInstance = mintsStore.findByUrl(mintUrl) + if (!mintInstance) { + throw new ValidationError('Could not find mint', {mintUrl}) + } + + // ── 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 { + transactionData = [] + } + } else { + transactionData.push({ + status: TransactionStatus.DRAFT, + mintBalanceToTransferFrom: mintBalance, + amountToTransfer: amount, + unit, + meltQuote, + isNwc: !!nwcEvent, + createdAt: new Date(), + }) + transaction = await transactionsStore.addTransaction({ + type: TransactionType.TRANSFER, + amount, + fee: meltQuote.fee_reserve.toNumber(), + unit, + data: JSON.stringify(transactionData), + memo, + mint: mintUrl, + status: TransactionStatus.DRAFT, + }) + } + if (!transaction) { + throw new ValidationError('Failed to create or load draft transaction') + } + + const transactionId = transaction.id + const paymentHash = LightningUtils.getInvoiceData( + LightningUtils.decodeInvoice(encodedInvoice), + ).payment_hash + transaction.update({paymentId: paymentHash, quote: meltQuote.quote}) + + // ── Validations ───────────────────────────────────────────────────── + const lightningFeeReserve = meltQuote.fee_reserve.toNumber() + if (amount + lightningFeeReserve > mintBalance.balances[unit]!) { + throw new ValidationError( + 'Mint balance is insufficient to cover the amount to transfer with the expected Lightning fees.', + {transactionId}, + ) + } + if (isBefore(invoiceExpiry, new Date())) { + throw new ValidationError( + 'This invoice has already expired and can not be paid.', + {invoiceExpiry, transactionId}, + ) + } + + // ── Select proofs and compute fees ────────────────────────────────── + const proofsFromMint = proofsStore.getByMint(mintUrl, {state: 'UNSPENT', unit}) + const totalAmountFromMint = CashuUtils.getProofsAmount(proofsFromMint) + + let proofsToMeltFrom = CashuUtils.getProofsToSend( + amount + lightningFeeReserve, + proofsFromMint, + ) + let proofsToMeltFromAmount = CashuUtils.getProofsAmount(proofsToMeltFrom) + + const walletInstance = (await walletStore.getWallet(mintUrl, unit, {withSeed: true})) as CashuWallet + let meltFeeReserve = walletInstance.getFeesForProofs(proofsToMeltFrom).toNumber() + const amountWithFees = amount + lightningFeeReserve + meltFeeReserve + + if (totalAmountFromMint < amountWithFees) { + throw new ValidationError('There is not enough funds to send this amount.', { + totalAmountFromMint, + amountWithFees, + transactionId, + caller: 'TransferOperationApi.prepare', + }) + } + + if (meltFeeReserve > 0) { + proofsToMeltFrom = CashuUtils.getProofsToSend(amountWithFees, proofsFromMint) + proofsToMeltFromAmount = CashuUtils.getProofsAmount(proofsToMeltFrom) + } + + // ── Preemptive swap path ──────────────────────────────────────────── + // Inputs that overshoot needed amount by >20% get swapped for tighter + // denominations so the mint charges lower per-proof melt fees. The swap + // is best-effort: on failure we keep the original proofs. + let path: TransferPath = 'direct-melt' + let preemptiveSwapFeePaid = 0 + + if (proofsToMeltFromAmount > amountWithFees * 1.2) { + log.info( + '[TransferOperationApi.prepare] proofsToMeltFromAmount overshoots amountWithFees by >20%, running preemptive swap', + {proofsToMeltFromAmount, amountWithFees}, + ) + + const swapInputProofs = proofsToMeltFrom + const swapReservation = proofsStore.reserve(swapInputProofs, { + transactionId, + mintUrl, + unit, + operationType: 'transfer-swap', + rollbackTo: 'UNSPENT', + }) + + try { + const swapResult = await walletStore.send( + mintUrl, + amountWithFees, + unit, + swapInputProofs, + transactionId, + ) + + const returnedSecrets = new Set(swapResult.returnedProofs.map(p => p.secret)) + const consumedBySwap = swapInputProofs.filter(p => !returnedSecrets.has(p.secret)) + + const {added} = proofsStore.commitReservation(swapReservation, { + toSpent: consumedBySwap, + newProofs: [ + {proofs: swapResult.returnedProofs, state: 'UNSPENT', tId: transactionId}, + {proofs: swapResult.proofsToSend, state: 'PENDING', tId: transactionId}, + ], + }) + + 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 + preemptiveSwapFeePaid = swapResult.swapFeePaid + proofsToMeltFrom = pendingSwapProofs + path = 'preemptive-swap-then-melt' + + log.debug('[TransferOperationApi.prepare] Preemptive swap completed', { + proofsToMeltFromAmount, + preemptiveSwapFeePaid, + meltFeeReserve, + }) + } catch (swapError: any) { + log.warn( + '[TransferOperationApi.prepare] Preemptive swap failed, continuing with original proofs', + {error: swapError.message}, + ) + proofsStore.rollbackReservation(swapReservation) + } + } + + // ── Open MELT reservation (left OPEN — execute/cancel resolves) ───── + // If the swap path ran, proofsToMeltFrom are already PENDING (from the + // swap commit). Reserving them again creates an orphan-recovery marker + // for the melt phase; rollback restores them to UNSPENT (the swap output + // is freshly-minted ecash, safely spendable on failure). + proofsStore.reserve(proofsToMeltFrom, { + transactionId, + mintUrl, + unit, + operationType: path === 'preemptive-swap-then-melt' ? 'transfer-melt-after-swap' : 'transfer-melt', + rollbackTo: 'UNSPENT', + }) + + // ── Transition DRAFT → PREPARED ───────────────────────────────────── + transactionData.push({ + status: TransactionStatus.PREPARED, + proofsToMeltFromAmount, + lightningFeeReserve, + meltFeeReserve, + path, + method: method.method, + ...(preemptiveSwapFeePaid > 0 && {preemptiveSwapFeePaid}), + 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, + }) + + if (!isPrepared(transaction)) { + throw new WalletError('Failed to transition transaction to PREPARED', { + transactionId, + status: transaction.status, + }) + } + + log.debug('[TransferOperationApi.prepare]', 'Prepared', { + transactionId, + path, + amount, + meltFeeReserve, + lightningFeeReserve, + preemptiveSwapFeePaid, + lockedCount: proofsToMeltFrom.length, + }) + + return { + transactionId, + tx: transaction, + mintUrl, + unit, + amountToTransfer: amount, + meltQuote, + invoiceExpiry, + path, + method, + proofsToMeltFrom, + proofsToMeltFromAmount, + meltFeeReserve, + lightningFeeReserve, + preemptiveSwapFeePaid, + nwcEvent, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// execute() +// +// Marks tx EXECUTING, calls payLightningMelt, then commits atomically based on +// the mint's quote state: +// - PAID: inputs → SPENT, change → UNSPENT, tx → COMPLETED. +// - PENDING: no proof changes, tx → PENDING; async ws/poller resolves later. +// - UNPAID: rollback reservation (proofs → UNSPENT) and throw. +// +// Errors are routed through `_handleExecuteError` which re-checks the quote +// (the mint may have paid even though the client errored) and chooses the +// right cleanup path. +// ───────────────────────────────────────────────────────────────────────────── + +async function execute( + prepared: PreparedTransferData, +): Promise { + 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 transfer in state ${tx.status}. Expected PREPARED.`, + {transactionId: tx.id, status: tx.status}, + ) + } + + 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[] = _parseData(tx) + const { + mintUrl, + unit, + amountToTransfer, + meltQuote, + proofsToMeltFrom, + proofsToMeltFromAmount, + meltFeeReserve, + nwcEvent, + } = prepared + + tx.update({status: TransactionStatus.EXECUTING}) + + let meltResponse: MeltProofsResponse + try { + meltResponse = await walletStore.payLightningMelt( + mintUrl, + unit, + meltQuote, + proofsToMeltFrom, + tx.id, + {preferAsync: nwcEvent ? false : true}, + ) + } catch (e: any) { + if (WalletUtils.shouldHealOutputsError(e)) { + log.error( + '[TransferOperationApi.execute] Increasing proofsCounter outdated values and repeating payLightningMelt.', + ) + try { + meltResponse = await walletStore.payLightningMelt( + mintUrl, + unit, + meltQuote, + proofsToMeltFrom, + tx.id, + {increaseCounterBy: 10, preferAsync: nwcEvent ? false : true}, + ) + } catch (e2: any) { + return _handleExecuteError(e2, { + tx, + transactionData, + reservation, + prepared, + }) + } + } else { + return _handleExecuteError(e, { + tx, + transactionData, + reservation, + prepared, + }) + } + } + + // ── PAID synchronously → finalize now ─────────────────────────────── + if (meltResponse.quote.state === MeltQuoteState.PAID) { + const returnedAmount = CashuUtils.getProofsAmount(meltResponse.change) + const totalFeePaid = proofsToMeltFromAmount - amountToTransfer - returnedAmount + const lightningFeePaid = totalFeePaid - meltFeeReserve + const meltFeePaid = meltFeeReserve + + let outputToken: string | undefined + if (meltResponse.change.length > 0) { + outputToken = getEncodedToken({ + mint: mintUrl, + proofs: meltResponse.change, + unit, + }) + } + + const currentSpendable = proofsStore.getUnitBalance(unit)?.unitBalance ?? 0 + const balanceAfter = currentSpendable + returnedAmount + + transactionData.push({ + status: TransactionStatus.COMPLETED, + lightningFeePaid, + meltFeePaid, + returnedAmount, + //@ts-ignore — payment_preimage is loosely typed in cashu-ts + preimage: meltResponse.quote.payment_preimage, + createdAt: new Date(), + }) + + proofsStore.commitReservation(reservation, { + toSpent: proofsToMeltFrom, + newProofs: + meltResponse.change.length > 0 + ? [{proofs: meltResponse.change, state: 'UNSPENT', tId: tx.id}] + : [], + transactionUpdate: { + id: tx.id, + status: TransactionStatus.COMPLETED, + data: JSON.stringify(transactionData), + fee: totalFeePaid, + balanceAfter, + ...(outputToken && {outputToken}), + //@ts-ignore — payment_preimage is loosely typed in cashu-ts + ...(meltResponse.quote.payment_preimage && {proof: meltResponse.quote.payment_preimage}), + }, + }) + + log.debug('[TransferOperationApi.execute] Invoice PAID', {transactionId: tx.id, totalFeePaid}) + return _assertCompleted(tx, tx.id) + } + + // ── PENDING async → tx PENDING, monitor will finalize via refresh ─── + if (meltResponse.quote.state === MeltQuoteState.PENDING) { + transactionData.push({ + status: TransactionStatus.PENDING, + createdAt: new Date(), + }) + + proofsStore.commitReservation(reservation, { + transactionUpdate: { + id: tx.id, + status: TransactionStatus.PENDING, + data: JSON.stringify(transactionData), + }, + }) + + _monitorAsyncMeltQuote({ + mintUrl, + unit, + quoteId: meltResponse.quote.quote, + transactionId: tx.id, + }) + + log.debug('[TransferOperationApi.execute] Invoice PENDING, async melt in progress', { + quoteId: meltResponse.quote.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 + } + + // ── UNPAID → throw so caller (wrapper) can mark ERROR. Rollback the + // reservation atomically to restore proofs to UNSPENT. + proofsStore.rollbackReservation(reservation) + throw new MintError('Lightning payment has not been paid.', { + meltResponseQuote: meltResponse.quote, + transactionId: tx.id, + }) +} + +// ───────────────────────────────────────────────────────────────────────────── +// cancel() — PREPARED → REVERTED +// ───────────────────────────────────────────────────────────────────────────── + +async function cancel(transactionId: number): Promise { + const tx = transactionsStore.findById(transactionId) + if (!tx) { + throw new ValidationError('Transaction not found', {transactionId}) + } + if (!isPrepared(tx)) { + throw new ValidationError( + `Cannot cancel transfer in state ${tx.status}. Expected PREPARED.`, + {transactionId, status: tx.status}, + ) + } + + const reservation = _findReservationForTx(transactionId) + if (!reservation) { + 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( + '[TransferOperationApi.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(), + }) + + proofsStore.rollbackReservation(reservation) + tx.update({ + status: TransactionStatus.REVERTED, + data: JSON.stringify(transactionData), + }) + + log.info('[TransferOperationApi.cancel]', 'Cancelled', {transactionId}) + return _assertReverted(tx, transactionId) +} + +// ───────────────────────────────────────────────────────────────────────────── +// reclaim() — PENDING → REVERTED via existing revertTask path +// ───────────────────────────────────────────────────────────────────────────── + +async function reclaim(transactionId: number): Promise { + const tx = transactionsStore.findById(transactionId) + if (!tx) { + throw new ValidationError('Transaction not found', {transactionId}) + } + if (!isPending(tx)) { + throw new ValidationError( + `Cannot reclaim transfer in state ${tx.status}. Expected PENDING.`, + {transactionId, status: tx.status}, + ) + } + 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 (after mint confirms PAID). +// +// Mirrors the COMPLETED branch of the legacy `handlePendingMeltTask`: unblinds +// the deterministic change proofs (using the meltPreview stashed at execute +// time), then atomically commits proofs SPENT, change UNSPENT, tx COMPLETED. +// ───────────────────────────────────────────────────────────────────────────── + +async function finalize(transactionId: number): Promise { + 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 transfer in state ${tx.status}. Expected PENDING or COMPLETED.`, + {transactionId, status: tx.status}, + ) + } + if (!tx.quote) { + throw new ValidationError('Transfer has no quote id; cannot finalize.', {transactionId}) + } + + const quote = await walletStore.checkLightningMeltQuote(tx.mint, tx.quote) + if (quote.state !== MeltQuoteState.PAID) { + throw new MintError( + `Cannot finalize transfer; mint reports quote state ${quote.state}.`, + {transactionId, state: quote.state}, + ) + } + + return _finalizePaid(tx, quote) +} + +// ───────────────────────────────────────────────────────────────────────────── +// refresh() — re-check PENDING transfer with the mint. +// +// Mirrors the full legacy `handlePendingMeltTask` flow: +// - quote PAID: → finalize (COMPLETED, recover change atomically). +// - quote UNPAID: revert proofs to UNSPENT and mark tx ERROR. +// - quote PENDING: no-op, async monitor will call again. +// ───────────────────────────────────────────────────────────────────────────── + +async function refresh(transactionId: number): Promise { + const tx = transactionsStore.findById(transactionId) + if (!tx) { + throw new ValidationError('Transaction not found', {transactionId}) + } + if (!isPending(tx)) { + return tx + } + if (!tx.quote) { + log.warn('[TransferOperationApi.refresh] PENDING transfer missing quote id; skipping', { + transactionId, + }) + return tx + } + + const quote = await walletStore.checkLightningMeltQuote(tx.mint, tx.quote) + + if (quote.state === MeltQuoteState.PAID) { + const completed = await _finalizePaid(tx, quote) + EventEmitter.emit('ev_asyncMeltResult', { + transactionId, + status: TransactionStatus.COMPLETED, + message: `Lightning invoice paid. Fee: ${formatCurrency(tx.fee, getCurrency(tx.unit).code)} ${getCurrency(tx.unit).code}.`, + }) + return completed + } + + if (quote.state === MeltQuoteState.UNPAID) { + // Lightning failed → proofs go back to spendable, tx is REVERTED. + // (Original `handlePendingMeltTask` stamped ERROR here, but sync has + // always used REVERTED for the same logical event — REVERTED is the + // accurate terminal status, since the ecash IS recoverable.) + const pendingProofs = proofsStore + .getByTransactionId(tx.id) + .filter(p => p.state === 'PENDING') + if (pendingProofs.length > 0) { + proofsStore.revertToSpendable(pendingProofs) + } + + const txData = _parseData(tx) + txData.push({ + status: TransactionStatus.REVERTED, + message: 'Lightning payment failed – ecash returned to spendable balance', + createdAt: new Date(), + }) + tx.update({status: TransactionStatus.REVERTED, data: JSON.stringify(txData)}) + + log.debug('[TransferOperationApi.refresh] Transaction reverted (UNPAID)', {transactionId}) + + EventEmitter.emit('ev_asyncMeltResult', { + transactionId, + status: TransactionStatus.REVERTED, + message: 'Lightning payment failed. Ecash returned to spendable balance.', + }) + return tx + } + + // PENDING: ws/poller will call back later. + return tx +} + +// ───────────────────────────────────────────────────────────────────────────── +// Private helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Centralised error-recovery flow for `execute`. The mint may have paid the + * invoice even though the client errored — so we re-check the quote and choose + * the right cleanup path before rethrowing. + */ +async function _handleExecuteError( + e: any, + ctx: { + tx: Transaction + transactionData: TransactionData[] + reservation: ProofReservation + prepared: PreparedTransferData + }, +): Promise { + const {tx, transactionData, reservation, prepared} = ctx + const {mintUrl, unit, meltQuote, proofsToMeltFrom, proofsToMeltFromAmount} = prepared + + let meltQuoteCheck: MeltQuoteBolt11Response + try { + meltQuoteCheck = await walletStore.checkLightningMeltQuote(mintUrl, meltQuote.quote) + } catch (checkError: any) { + // Quote check itself failed — leave the reservation as-is, the orphan + // recovery sweep + sync will reconcile on the next startup. + log.error( + '[TransferOperationApi.execute] Quote re-check failed after execute error; reservation left open for recovery', + {transactionId: tx.id, originalError: e.message, checkError: checkError.message}, + ) + throw e + } + + // ── PAID despite client error → recover change, mark RECOVERED ────── + if (meltQuoteCheck.state === MeltQuoteState.PAID) { + proofsStore.commitReservation(reservation, { + toSpent: proofsToMeltFrom, + }) + + let recoveredAmount = 0 + try { + const recovery = await WalletTask.recoverMeltQuoteChange({ + mintUrl, + meltQuote: meltQuoteCheck, + }) + recoveredAmount = recovery.recoveredAmount + } catch (recoverError: any) { + log.error('[TransferOperationApi.execute] Change recovery failed', { + transactionId: tx.id, + error: recoverError.message, + }) + } + + transactionData.push({ + status: TransactionStatus.RECOVERED, + recoveredChangeAmount: recoveredAmount, + error: WalletUtils.formatError(e), + createdAt: new Date(), + }) + tx.update({ + status: TransactionStatus.RECOVERED, + data: JSON.stringify(transactionData), + }) + + log.error('[TransferOperationApi.execute]', 'PAID despite error; recovered change.', { + recoveredAmount, + error: e.message, + transactionId: tx.id, + }) + throw e + } + + // ── PENDING by mint → leave proofs PENDING, drop reservation row ──── + if (meltQuoteCheck.state === MeltQuoteState.PENDING) { + proofsStore.commitReservation(reservation) + log.error( + '[TransferOperationApi.execute]', + 'Lightning payment did not complete in time. Will remain pending.', + {error: e.message, transactionId: tx.id}, + ) + throw e + } + + // ── UNPAID by mint ────────────────────────────────────────────────── + if (WalletUtils.isTokenAlreadySpentError(e)) { + // Mint says one of our inputs is already spent. Sync will reconcile; + // drop the reservation without restoring (proofs likely SPENT at mint). + proofsStore.commitReservation(reservation) + log.error( + '[TransferOperationApi.execute]', + 'Token already spent, sync will reconcile.', + {transactionId: tx.id}, + ) + await WalletTask.syncStateWithMintTask({ + proofsToSync: proofsStore.getByMint(mintUrl, {state: 'PENDING', unit}), + mintUrl, + proofState: 'PENDING', + }) + throw e + } + if (WalletUtils.isTokenPendingError(e)) { + // Mint says an input is pending in another in-flight melt. Don't + // release proofs — sync resolves it once the other op settles. + proofsStore.commitReservation(reservation) + log.error( + '[TransferOperationApi.execute]', + 'Pending proofs were used for this transaction, syncing.', + {transactionId: tx.id}, + ) + await WalletTask.syncStateWithMintTask({ + proofsToSync: proofsToMeltFrom, + mintUrl, + proofState: 'PENDING', + }) + await WalletTask.syncStateWithMintTask({ + proofsToSync: proofsStore.getByMint(mintUrl, {state: 'PENDING', unit}), + mintUrl, + proofState: 'PENDING', + }) + throw e + } + + // Clean unpaid: rollback restores proofs to UNSPENT. + proofsStore.rollbackReservation(reservation) + log.error( + '[TransferOperationApi.execute]', + 'Ecash reserved for this payment was returned to spendable balance.', + {proofsToMeltFromAmount, transactionId: tx.id}, + ) + throw e +} + +/** + * Atomic settle of a confirmed-PAID transfer: unblind change, commit + * (proofs → SPENT, change → UNSPENT, tx → COMPLETED) in one SQLite txn. + */ +async function _finalizePaid( + tx: Transaction, + quote: MeltQuoteBolt11Response, +): Promise { + const transactionId = tx.id + const mintUrl = tx.mint + const unit = tx.unit + + // pendingProofs may be empty when called from sync after a bulk SPENT + // marking — but we still need to unblind change and atomic-commit the tx + // transition. The empty-array branches inside reservation/commitReservation + // make `toSpent: []` a no-op, while change INSERT + tx UPDATE still land + // in one SQLite transaction. + const pendingProofs = proofsStore + .getByTransactionId(transactionId) + .filter(p => p.state === 'PENDING') + + // Fee math: when sync already moved proofs SPENT, the locally-PENDING set + // is empty, so reconstruct the input amount from the tx record (the + // original `proofsToMeltFromAmount` was stored on tx.data at PREPARED time). + const proofsToMeltFromAmount = + pendingProofs.length > 0 + ? CashuUtils.getProofsAmount(pendingProofs) + : (_readNumberFromData(tx, 'proofsToMeltFromAmount') ?? tx.amount) + const amountToTransfer = tx.amount + const meltFeeReserve = _readNumberFromData(tx, 'meltFeeReserve') ?? 0 + let totalFeePaid = proofsToMeltFromAmount - amountToTransfer + let lightningFeePaid = totalFeePaid - meltFeeReserve + const meltFeePaid = meltFeeReserve + + // Unblind change BEFORE opening the reservation; same fallback behaviour as + // the pre-reservation code — change recovery failure doesn't block finalize. + const unblinded = await _unblindMeltChange({ + mintUrl, + unit, + quoteId: quote.quote, + transaction: tx, + quoteChange: quote.change, + }) + + let returnedAmount = 0 + let outputToken: string | undefined + if (unblinded.change.length > 0) { + returnedAmount = CashuUtils.getProofsAmount(unblinded.change) + outputToken = getEncodedToken({mint: mintUrl, proofs: unblinded.change, unit}) + totalFeePaid -= returnedAmount + lightningFeePaid = totalFeePaid - meltFeeReserve + } + + const currentSpendable = proofsStore.getUnitBalance(unit)?.unitBalance ?? 0 + const balanceAfter = currentSpendable + returnedAmount + + const txData = _parseData(tx) + txData.push({ + status: TransactionStatus.COMPLETED, + lightningFeePaid, + meltFeePaid, + returnedAmount, + //@ts-ignore + preimage: quote.payment_preimage, + createdAt: new Date(), + }) + + const reservation = proofsStore.reserve(pendingProofs, { + transactionId, + mintUrl, + unit, + operationType: 'pending-melt-finalize-paid', + rollbackTo: 'PENDING', + }) + + proofsStore.commitReservation(reservation, { + toSpent: pendingProofs, + 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('[TransferOperationApi._finalizePaid] Transaction completed', { + transactionId, + totalFeePaid, + }) + return _assertCompleted(tx, transactionId) +} + +/** + * Reconstruct deterministic change proofs from the mint's quote.change blinded + * signatures using the meltPreview captured at execute time. Logs but never + * throws — failure just means no change recovery, matching pre-reservation + * behaviour. Lifted from `meltOperations.unblindPendingMeltChange`. + */ +async function _unblindMeltChange(params: { + mintUrl: string + unit: MintUnit + quoteId: string + transaction: Transaction + quoteChange: MeltQuoteBolt11Response['change'] +}) { + const {mintUrl, unit, quoteId, transaction, quoteChange} = params + try { + const mintInstance = mintsStore.findByUrl(mintUrl) + if (!mintInstance || !transaction.keysetId) return {change: []} + + const currentCounter = mintInstance.getProofsCounterByKeysetId!(transaction.keysetId) + const meltCounterValue = currentCounter?.getMeltCounterValue(transaction.id) + + if (!meltCounterValue?.meltPreview || !quoteChange?.length) { + return {change: []} + } + + const {meltPreview} = meltCounterValue + const cashuWallet = await walletStore.getWallet(mintUrl, unit, { + withSeed: true, + keysetId: meltPreview.keysetId, + }) + const keyset = cashuWallet.getKeyset(meltPreview.keysetId) + + const reconstructedOutputData = CashuUtils.deserializeOutputData(meltPreview.outputData) + const change = quoteChange.map((sig, i) => + reconstructedOutputData[i].toProof(sig, keyset), + ) + + log.trace('[TransferOperationApi._unblindMeltChange] Change unblinded', { + transactionId: transaction.id, + quoteId, + change, + }) + + currentCounter.removeMeltCounterValue(transaction.id) + return {change} + } catch (e: any) { + log.error( + '[TransferOperationApi._unblindMeltChange] Change recovery failed; completing without change', + {message: e.message}, + ) + return {change: []} + } +} + +/** + * Subscribe to mint websocket for meltQuoteUpdates. When the mint signals + * PAID or UNPAID, call `refresh` to resolve the tx. Falls back to a poller + * if the websocket subscription fails. + */ +async function _monitorAsyncMeltQuote(params: { + mintUrl: string + unit: MintUnit + quoteId: string + transactionId: number +}) { + const {mintUrl, quoteId, transactionId} = params + const wsMint = new CashuMint(mintUrl) + const wsWallet = new CashuWallet(wsMint) + + try { + log.trace('[TransferOperationApi]', 'Subscribing to meltQuoteUpdates', {quoteId}) + const unsub = await wsWallet.on.meltQuoteUpdates( + [quoteId], + async (updatedQuote: MeltQuoteBolt11Response) => { + if ( + updatedQuote.state === MeltQuoteState.PAID || + updatedQuote.state === MeltQuoteState.UNPAID + ) { + try { + await refresh(transactionId) + } catch (refreshError: any) { + log.error( + '[TransferOperationApi] refresh failed in ws callback', + {transactionId, error: refreshError.message}, + ) + } + unsub() + } + }, + async (error: any) => { + throw error + }, + ) + } catch (error: any) { + log.error( + Err.NETWORK_ERROR, + '[TransferOperationApi] WebSocket error for async melt, starting poller.', + error.message, + ) + poller( + `meltQuotePoller-${quoteId}`, + () => refresh(transactionId), + {interval: 15 * 1000, maxPolls: 8, maxErrors: 2}, + ).then(() => log.trace('[meltQuotePoller] polling completed', {quoteId})) + } +} + +function _findReservationForTx(transactionId: number): ProofReservation | undefined { + const all = Database.getOpenReservations() + const row = all.find(r => r.transactionId === transactionId) + return row ? _rowToReservation(row) : undefined +} + +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 [] + } +} + +/** Best-effort read of a numeric field from the most recent matching entry in tx.data. */ +function _readNumberFromData(tx: Transaction, field: string): number | undefined { + const arr = _parseData(tx) + for (let i = arr.length - 1; i >= 0; i--) { + const v = (arr[i] as any)[field] + if (typeof v === 'number') return v + } + return undefined +} + +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 +// ───────────────────────────────────────────────────────────────────────────── + +export const TransferOperationApi = { + prepare, + execute, + cancel, + reclaim, + finalize, + refresh, +} diff --git a/src/services/wallet/receiveTask.ts b/src/services/wallet/receiveTask.ts index 5e5745fd..593d3e9e 100644 --- a/src/services/wallet/receiveTask.ts +++ b/src/services/wallet/receiveTask.ts @@ -1,657 +1,305 @@ import {log} from '../logService' import { - Transaction, - TransactionData, - TransactionStatus, - TransactionType, + Transaction, + TransactionData, + TransactionStatus, } from '../../models/Transaction' import {rootStoreInstance} from '../../models' -import {CashuProof, CashuUtils} from '../cashu/cashuUtils' -import AppError, {Err} from '../../utils/AppError' -import { TransactionTaskResult } from '../walletService' -import { WalletUtils } from './utils' -import { MintUnit, formatCurrency, getCurrency } from './currency' -import { PaymentRequestPayload, Token, getDecodedToken, getEncodedToken, normalizeProofAmounts } from '@cashu/cashu-ts' +import {WalletUtils} from './utils' +import {TransactionTaskResult} from '../walletService' +import {MintUnit, formatCurrency, getCurrency} from './currency' +import {PaymentRequestPayload, Token} from '@cashu/cashu-ts' -const { - mintsStore, - proofsStore, - transactionsStore, - walletStore -} = rootStoreInstance +const {transactionsStore} = rootStoreInstance -// function names to pass to task results +// Task function names — preserved for the event-bus surface (`ev__result`). export const RECEIVE_TASK = 'receiveTask' export const RECEIVE_OFFLINE_PREPARE_TASK = 'receiveOfflinePrepareTask' export const RECEIVE_OFFLINE_COMPLETE_TASK = 'receiveOfflineCompleteTask' 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 ( token: Token, amountToReceive: number, memo: string, encodedToken: string, ): Promise { - const transactionData: TransactionData[] = [] - let transaction: Transaction | undefined = undefined - let mintToReceive: string | undefined = undefined - const unit = token.unit as MintUnit || 'sat' + const mintToReceive = token.mint + const unit = ((token.unit as MintUnit) || 'sat') as MintUnit - try { - mintToReceive = token.mint + // Lazy import avoids a circular dep across the operations module graph. + const {ReceiveOperationApi, receiveSuccessMessage} = await import( + './operations/receiveOperationApi' + ) - if (!mintToReceive) { - throw new AppError( - Err.VALIDATION_ERROR, - 'Token is missing a mint param.', - ) - } + let transactionIdForRecovery: number | undefined - // Let's create new draft receive transaction in database - transactionData.push({ - status: TransactionStatus.DRAFT, - amountToReceive, - unit, - createdAt: new Date(), - }) - - const newTransaction = { - type: TransactionType.RECEIVE, + try { + const prepared = await ReceiveOperationApi.prepare({ + mintUrl: mintToReceive, amount: amountToReceive, - fee: 0, unit, - data: JSON.stringify(transactionData), memo, - mint: mintToReceive, - status: TransactionStatus.DRAFT, - } - - 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), - }) + method: {method: 'cashu-token', options: {token, encodedToken, offline: false}}, + }) + transactionIdForRecovery = prepared.transactionId + if (prepared.blocked) { return { taskFunction: RECEIVE_TASK, - transaction, + mintUrl: mintToReceive, + transaction: prepared.tx, message: `The mint ${mintToReceive} is blocked. You can unblock it in Settings.`, } as TransactionTaskResult } - const { - receivedAmount, - receivedProofs, - outputToken, - swapFeePaid, - } = await receiveSync( - mintToReceive, - token, - token.memo || '', - transaction.id - ) - - - // Finally, update completed transaction - transactionData.push({ - status: TransactionStatus.COMPLETED, - swapFeePaid, - receivedAmount, - unit, - createdAt: new Date(), - }) + const completed = await ReceiveOperationApi.execute(prepared) - 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}) - }) + const swapFeePaid = completed.fee ?? 0 + const receivedAmount = completed.amount + // receiveBatchTask sums these to track progress across split batches. + const {proofsStore} = rootStoreInstance + const receivedProofsCount = proofsStore + .getByTransactionId(completed.id) + .filter(p => p.state === 'UNSPENT').length return { taskFunction: RECEIVE_TASK, mintUrl: mintToReceive, - transaction, - 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}.` : ''}`, + transaction: completed, + message: receiveSuccessMessage(amountToReceive, unit, swapFeePaid), receivedAmount, - receivedProofsCount: receivedProofs.length + receivedProofsCount, } as TransactionTaskResult - } catch (e: any) { - if (transaction) { - - transactionData.push({ - status: TransactionStatus.ERROR, - error: WalletUtils.formatError(e), - errorToken: e.params?.errorToken || undefined - }) - - transaction.update({ - status: TransactionStatus.ERROR, - data: JSON.stringify(transactionData) - }) - } - + _stampErrorIfNeeded(transactionIdForRecovery, e) log.error(e.name, e.message) - return { taskFunction: RECEIVE_TASK, mintUrl: mintToReceive, - transaction, + transaction: transactionIdForRecovery + ? transactionsStore.findById(transactionIdForRecovery) + : undefined, message: e.message, error: WalletUtils.formatError(e), } 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 ( mintUrl: string, unit: MintUnit, amountToReceive: number, memo: string, encodedToken: string, -) { - const transactionData: TransactionData[] = [] - let transaction: Transaction | undefined = undefined - const mintToReceive = mintUrl.replace(/\/$/, '') +): Promise { + 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 - transactionData.push({ - status: TransactionStatus.DRAFT, - amountToReceive, - unit, - createdAt: new Date(), - }) + let transactionIdForRecovery: number | undefined - const newTransaction = { - type: TransactionType.RECEIVE_OFFLINE, + try { + // 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, - fee: 0, unit, - data: JSON.stringify(transactionData), memo, - mint: mintToReceive, - status: TransactionStatus.DRAFT, - } - - 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), - }) + method: {method: 'cashu-token', options: {token, encodedToken, offline: true}}, + }) + transactionIdForRecovery = prepared.transactionId + if (prepared.blocked) { return { taskFunction: RECEIVE_OFFLINE_PREPARE_TASK, mintUrl: mintToReceive, - transaction, + transaction: prepared.tx, message: `The mint ${mintToReceive} is blocked. You can unblock it in Settings.`, - } as unknown as TransactionTaskResult + } as TransactionTaskResult } - const mintInstance = mintsStore.findByUrl(mintToReceive) - - 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), - }) - + const code = getCurrency(unit).code return { taskFunction: RECEIVE_OFFLINE_PREPARE_TASK, mintUrl: mintToReceive, - transaction, - 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.`, + transaction: prepared.tx, + 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 - } catch (e: any) { - if (transaction) { - transactionData.push({ - status: TransactionStatus.ERROR, - error: WalletUtils.formatError(e), - }) - - transaction.update({ - status: TransactionStatus.ERROR, - data: JSON.stringify(transactionData), - }) - } - + _stampErrorIfNeeded(transactionIdForRecovery, e) log.error(e.name, e.message) - return { taskFunction: RECEIVE_OFFLINE_PREPARE_TASK, mintUrl: mintToReceive, - transaction, + transaction: transactionIdForRecovery + ? transactionsStore.findById(transactionIdForRecovery) + : undefined, message: '', error: WalletUtils.formatError(e), } as TransactionTaskResult } } +/** + * Backward-compatible offline-complete task wrapper. + * + * Resumes a previously-prepared offline receive: re-builds the prepared + * snapshot from disk (tx.inputToken) and runs `ReceiveOperationApi.execute`. + */ +export const receiveOfflineCompleteTask = async function ( + transactionId: number, +): Promise { + const tx = transactionsStore.findById(transactionId) + let mintToReceive = tx?.mint ?? '' - - - -export const receiveOfflineCompleteTask = async function ( - transactionId: number -) { - let mintToReceive = '' - const transaction = transactionsStore.findById(transactionId) + const {ReceiveOperationApi, receiveSuccessMessage, loadPreparedForOfflineComplete} = + await import('./operations/receiveOperationApi') try { - if(!transaction) { + if (!tx) { + const {default: AppError, Err} = await import('../../utils/AppError') throw new AppError(Err.VALIDATION_ERROR, 'Could not retrieve transaction.', {transactionId}) - } - - let transactionData = [] as unknown as TransactionData - - try { - transactionData = JSON.parse(transaction.data) - } catch (e) {} - - if (!transaction.inputToken) { - throw new AppError(Err.VALIDATION_ERROR, 'Could not find ecash token to redeem', {caller: 'receiveOfflineComplete'}) } + mintToReceive = tx.mint - // Ensure mint is in wallet state — may be missing for offline receives from new mints - 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), - }) + const prepared = loadPreparedForOfflineComplete(transactionId) + if (prepared.blocked) { return { taskFunction: RECEIVE_OFFLINE_COMPLETE_TASK, mintUrl: mintToReceive, - transaction, + transaction: prepared.tx, message: `The mint ${mintToReceive} is blocked. You can unblock it in Settings.`, } as TransactionTaskResult } - const { - receivedAmount, - receivedProofs, - outputToken, - swapFeePaid, - } = await receiveSync( - mintToReceive, - token, - token.memo || '', - transaction.id - ) - - // Finally, update completed transaction - transactionData.push({ - status: TransactionStatus.COMPLETED, - receivedAmount, - swapFeePaid, - unit, - createdAt: new Date(), - }) + const completed = await ReceiveOperationApi.execute(prepared) - 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}) - }) + const swapFeePaid = completed.fee ?? 0 + const receivedAmount = completed.amount return { taskFunction: RECEIVE_OFFLINE_COMPLETE_TASK, mintUrl: mintToReceive, - transaction, - 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}.` : ''}`, + transaction: completed, + message: receiveSuccessMessage(receivedAmount, completed.unit, swapFeePaid), receivedAmount, } as TransactionTaskResult - } catch (e: any) { - // release lock - 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) - }) - } - + _stampErrorIfNeeded(transactionId, e) return { taskFunction: RECEIVE_OFFLINE_COMPLETE_TASK, mintUrl: mintToReceive, - transaction, + transaction: transactionsStore.findById(transactionId), message: '', error: WalletUtils.formatError(e), } 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 ( - paymentRequestPayload: PaymentRequestPayload, + paymentRequestPayload: PaymentRequestPayload, ): Promise { + const mintToReceive = paymentRequestPayload.mint + const paymentRequestId = paymentRequestPayload.id - const transactionData = [] as unknown as TransactionData - // let transaction: Transaction | undefined = undefined - const unit = paymentRequestPayload.unit as MintUnit - const mintToReceive = paymentRequestPayload.mint - const proofsToReceive = paymentRequestPayload.proofs - const paymentRequestId = paymentRequestPayload.id + // The tx was created by `cashuPaymentRequestTask` (now CashuPaymentRequestApi) + // and parked at PENDING with paymentId === paymentRequestId. + const tx = transactionsStore.findLastBy({paymentId: paymentRequestId}) as + | Transaction + | undefined - // Let's find transaction with related payment request - const transaction = transactionsStore.findLastBy({paymentId: paymentRequestId}) as Transaction | undefined + const {CashuPaymentRequestApi, cashuPaymentRequestSuccessMessage} = await import( + './operations/cashuPaymentRequestApi' + ) - try { - if (!mintToReceive || !unit || !Array.isArray(proofsToReceive) || proofsToReceive.length === 0) { - throw new AppError( - Err.VALIDATION_ERROR, - 'Payment request payload is invalid.', - {paymentRequestPayload} - ) - } - - if(!transaction) { + try { + if (!tx) { + const {default: AppError, Err} = await import('../../utils/AppError') throw new AppError( Err.VALIDATION_ERROR, 'Related Payment request could not be found in the wallet.', - {paymentRequestPayload} - ) + {paymentRequestPayload}, + ) } - const amountToReceive = CashuUtils.getProofsAmount(proofsToReceive) - const memo = paymentRequestPayload.memo || 'PR ' + paymentRequestId + const completed = await CashuPaymentRequestApi.finalize(tx.id, paymentRequestPayload) - if(transaction.unit !== unit || transaction.amount !== amountToReceive) { - throw new AppError( - Err.VALIDATION_ERROR, - '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 { + const receivedAmount = completed.amount + const message = cashuPaymentRequestSuccessMessage( + paymentRequestId ?? '', receivedAmount, - receivedProofs, - outputToken, - swapFeePaid, - } = await receiveSync( - mintToReceive, - paymentRequestPayload, - memo, - transaction.id + completed.unit, ) - - - // 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 { taskFunction: RECEIVE_BY_CASHU_PAYMENT_REQUEST_TASK, mintUrl: mintToReceive, - transaction, - message: `Payment request ${paymentRequestId} with amount of ${formatCurrency(receivedAmount, getCurrency(unit).code)} ${getCurrency(unit).code} has been paid.`, + transaction: completed, + message, receivedAmount, - receivedProofsCount: receivedProofs.length } as TransactionTaskResult - - } catch (e: any) { - + } catch (e: any) { log.error(e.name, e.message) - - transactionData.push({ - status: TransactionStatus.ERROR, - error: WalletUtils.formatError(e), - errorToken: e.params?.errorToken || undefined - }) - - transaction && transaction.update({ - status: TransactionStatus.ERROR, - data: JSON.stringify(transactionData) - }) - + if (tx && tx.status !== TransactionStatus.ERROR && tx.status !== TransactionStatus.BLOCKED) { + _stampErrorIfNeeded(tx.id, e) + } return { taskFunction: RECEIVE_BY_CASHU_PAYMENT_REQUEST_TASK, mintUrl: mintToReceive, - transaction, + transaction: tx, message: e.message, error: WalletUtils.formatError(e), } 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 ( - mintToReceive: string, - token: Token | PaymentRequestPayload, - memo: string, - transactionId: number -) { - - const unit = token.unit as MintUnit || 'sat' - - try { - // 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)) - } - } + let transactionData: TransactionData[] = [] + try { transactionData = JSON.parse(tx.data) } catch {} + transactionData.push({ + status: TransactionStatus.ERROR, + error: WalletUtils.formatError(e), + errorToken: e?.params?.errorToken || undefined, + }) + tx.update({ + status: TransactionStatus.ERROR, + data: JSON.stringify(transactionData), + }) } diff --git a/src/services/wallet/revertTask.ts b/src/services/wallet/revertTask.ts index 055d8dcc..c93fdb07 100644 --- a/src/services/wallet/revertTask.ts +++ b/src/services/wallet/revertTask.ts @@ -91,31 +91,33 @@ try { unit }) - // ATOMIC commit: original pending proofs → SPENT, new fresh proofs → - // UNSPENT, reservation row deleted — single SQLite transaction. Replaces - // the previous two-step moveToSpent + addOrUpdate sequence. - const { added } = proofsStore.commitReservation(reservation, { - toSpent: pendingProofs, - newProofs: [{ proofs: receivedProofs, state: 'UNSPENT', tId: transaction.id }], - }) + // Pre-compute everything that needs to land atomically. balanceAfter is + // simulated: the original pending proofs were PENDING (contribute 0 to + // UNSPENT pool); moving them to SPENT changes nothing. The new fresh + // proofs added as UNSPENT raise the spendable balance. + const receivedAmount = receivedProofs.reduce((sum, p) => sum + Number(p.amount), 0) + 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({ status: TransactionStatus.REVERTED, mintFeePaid, createdAt: new Date(), }) - const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance - - transaction.update({ - status: TransactionStatus.REVERTED, - data: JSON.stringify(transactionData), - outputToken, - balanceAfter, - ...(mintFeePaid > 0 && {fee: mintFeePaid}) + // ATOMIC commit: pending proofs → SPENT, new fresh proofs → UNSPENT, + // tx → REVERTED, reservation row deleted — single SQLite transaction. + proofsStore.commitReservation(reservation, { + toSpent: pendingProofs, + newProofs: [{ proofs: receivedProofs, state: 'UNSPENT', tId: transaction.id }], + transactionUpdate: { + id: transaction.id, + status: TransactionStatus.REVERTED, + data: JSON.stringify(transactionData), + outputToken, + balanceAfter, + ...(mintFeePaid > 0 && { fee: mintFeePaid }), + }, }) return { diff --git a/src/services/wallet/sendTask.ts b/src/services/wallet/sendTask.ts index 5aede65d..270237a6 100644 --- a/src/services/wallet/sendTask.ts +++ b/src/services/wallet/sendTask.ts @@ -33,45 +33,21 @@ const { export const SEND_TASK = 'sendTask' -const _monitorSentProofs = async (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('[send] Subscribing to proofStateUpdates for sent proof', {secret: proofsToSend[0]}) - const unsub = await wsWallet.on.proofStateUpdates( - normalizeProofAmounts([proofsToSend[0]]), - async (proofState: CashuProofState) => { - log.trace(`Websocket: proof state updated: ${proofState.state} with secret: ${proofsToSend[0].secret}`) - if (proofState.state == CheckStateEnum.SPENT) { - WalletTask.syncStateWithMintQueueAwaitable({proofsToSync, mintUrl, 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})) - } -} - +/** + * Backward-compatible send task wrapper. + * + * This function preserves the historical `WalletTask.sendQueueAwaitable` + * contract: a single call that creates the draft, reserves proofs, executes + * the (optional) swap, and returns a `TransactionTaskResult` with the + * encoded token. + * + * Internally it now delegates to the lifecycle API: + * `SendOperationApi.prepare()` (DRAFT → PREPARED, reservation opens) + * `SendOperationApi.execute()` (PREPARED → [EXECUTING →] PENDING, atomic commit) + * + * Screens that want fee preview / cancel-before-execute should call the + * lifecycle methods directly instead of going through this wrapper. + */ export const sendTask = async function ( mintBalanceToSendFrom: MintBalance, amountToSend: number, @@ -85,128 +61,69 @@ export const sendTask = async function ( const mintUrl = mintBalanceToSendFrom.mintUrl log.trace('[sendTask]', 'mintBalanceToSendFrom', mintBalanceToSendFrom) - log.trace('[sendTask]', 'amountToSend', {amountToSend, unit}) - - if(amountToSend <= 0) { - throw new AppError(Err.VALIDATION_ERROR, 'Amount to send must be above zero.') - } + log.trace('[sendTask]', 'amountToSend', {amountToSend, unit}) - let transaction: Transaction | undefined = undefined - let transactionData: TransactionData[] = [] + // Lazy import avoids a circular dep: sendOperationApi imports from + // this file (the keyset helpers) and Phase 7 wires walletService events + // through the same module graph. + const {SendOperationApi} = await import('./operations/sendOperationApi') + + let transactionIdForRecovery: number | undefined try { - if(draftTransactionId && draftTransactionId > 0) { - transaction = transactionsStore.findById(draftTransactionId)! - } else { - // create draft transaction - transactionData.push({ - status: TransactionStatus.DRAFT, - mintBalanceToSendFrom, - createdAt: new Date(), - }) - - const newTransaction = { - type: TransactionType.SEND, - amount: amountToSend, - fee: 0, - unit, - data: JSON.stringify(transactionData), - memo, - mint: mintUrl, - status: TransactionStatus.DRAFT, - } + const method = p2pk && p2pk.pubkey + ? {method: 'p2pk' as const, options: p2pk} + : {method: 'default' as const, options: {} as Record} - // 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), + const prepared = await SendOperationApi.prepare({ + mintBalance: mintBalanceToSendFrom, + amount: amountToSend, unit, memo, + selectedProofs: selectedProofs.length > 0 ? selectedProofs : undefined, + method, + draftTransactionId: draftTransactionId ?? undefined, }) + transactionIdForRecovery = prepared.transactionId - transaction.update({ - 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 pending = await SendOperationApi.execute(prepared) + const swapFeePaid = pending.fee ?? 0 return { taskFunction: SEND_TASK, mintUrl, - transaction, + transaction: pending, message: '', - encodedTokenToSend: outputToken, - swapFeePaid + swapFeePaid, } as TransactionTaskResult - } catch (e: any) { - if (transaction) { - transactionData.push({ - status: TransactionStatus.ERROR, + // Mark the tx ERROR if one was created during prepare. + if (transactionIdForRecovery) { + 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), - createdAt: new Date() - }) - - transaction.update({ - status: TransactionStatus.ERROR, - data: JSON.stringify(transactionData) - }) - } - + } as TransactionTaskResult + } return { taskFunction: SEND_TASK, mintUrl, - transaction, message: e.message, error: WalletUtils.formatError(e), } as TransactionTaskResult @@ -214,283 +131,7 @@ export const sendTask = async function ( } - -export const sendFromMintSync = async function ( - mintBalance: MintBalance, - amountToSend: number, - unit: MintUnit, - selectedProofs: Proof[], - transactionId: number, - p2pk?: { pubkey: string; locktime?: number; refundKeys?: Array } -) { - 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 ( +export const prioritizeFromInactiveKeysets = function ( mint: Mint, amountToSend: number, unit: MintUnit, @@ -548,7 +189,7 @@ const prioritizeFromInactiveKeysets = function ( } -const getActiveKeysetIds = function(mint: Mint) { +export const getActiveKeysetIds = function(mint: Mint) { if(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) { return mint.keysets diff --git a/src/services/wallet/topupTask.ts b/src/services/wallet/topupTask.ts index b10d6594..5714974e 100644 --- a/src/services/wallet/topupTask.ts +++ b/src/services/wallet/topupTask.ts @@ -1,197 +1,98 @@ import {rootStoreInstance} from '../../models' -import { TransactionTaskResult, WalletTask } from '../walletService' -import { MintBalance } from '../../models/Mint' -import { poller } from '../../utils/poller' -import { Transaction, TransactionData, TransactionStatus, TransactionType } from '../../models/Transaction' -import { log } from '../logService' -import { Contact } from '../../models/Contact' -import { LightningUtils } from '../lightning/lightningUtils' -import { getSnapshot, isStateTreeNode } from 'mobx-state-tree' -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' +import {TransactionTaskResult} from '../walletService' +import {MintBalance} from '../../models/Mint' +import {TransactionData, TransactionStatus} from '../../models/Transaction' +import {log} from '../logService' +import {Contact} from '../../models/Contact' +import {WalletUtils} from './utils' +import {MintUnit} from './currency' +import {NostrEvent} from '../nostrService' -const { - transactionsStore, - walletProfileStore, - walletStore -} = rootStoreInstance - -// const {walletStore} = nonPersistedStores +const {transactionsStore} = rootStoreInstance 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 ( mintBalanceToTopup: MintBalance, amountToTopup: number, unit: MintUnit, memo: string, contactToSendTo?: Contact, - nwcEvent?: NostrEvent -) : Promise { + nwcEvent?: NostrEvent, +): Promise { log.info('[topupTask]', {mintBalanceToTopup}) 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 + // Lazy import avoids a circular dep across the operations module graph. + const {TopupOperationApi} = await import('./operations/topupOperationApi') + + let transactionIdForRecovery: number | undefined + try { - const newTransaction = { - type: TransactionType.TOPUP, + const prepared = await TopupOperationApi.prepare({ + mintBalance: mintBalanceToTopup, amount: amountToTopup, - 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) - - 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() + method: {method: 'bolt11', options: {contactToSendTo}}, + nwcEvent, }) + transactionIdForRecovery = prepared.transactionId - const updateData = { - 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`)) - } - - } + const pending = await TopupOperationApi.execute(prepared) return { taskFunction: TOPUP_TASK, mintUrl, - transaction, + transaction: pending, message: '', - encodedInvoice, - nwcEvent + encodedInvoice: prepared.encodedInvoice, + nwcEvent, } as TransactionTaskResult - - } catch (e: any) { - - if (transaction) { - transactionData.push({ - status: TransactionStatus.ERROR, - error: WalletUtils.formatError(e), - createdAt: new Date() - }) - - // Update status and data on error - transaction.update({ - status: TransactionStatus.ERROR, - data: JSON.stringify(transactionData) - }) + } catch (e: any) { + if (transactionIdForRecovery) { + const tx = transactionsStore.findById(transactionIdForRecovery) + if (tx && tx.status !== TransactionStatus.ERROR) { + 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), + }) + } } log.error(e.name, e.message) return { taskFunction: TOPUP_TASK, - transaction, + transaction: transactionIdForRecovery + ? transactionsStore.findById(transactionIdForRecovery) + : undefined, message: e.message, error: WalletUtils.formatError(e), } as TransactionTaskResult } -} \ No newline at end of file +} diff --git a/src/services/wallet/transferTask.ts b/src/services/wallet/transferTask.ts index 76d5c67f..19fd8fca 100644 --- a/src/services/wallet/transferTask.ts +++ b/src/services/wallet/transferTask.ts @@ -1,642 +1,133 @@ -import {CashuUtils} from '../cashu/cashuUtils' -import AppError, {Err} from '../../utils/AppError' -import {Mint as CashuMint, Wallet as CashuWallet, MeltProofsResponse, MeltQuoteBolt11Response, MeltQuoteState, getEncodedToken, normalizeProofAmounts} from '@cashu/cashu-ts' +import {MeltQuoteBolt11Response} from '@cashu/cashu-ts' import {rootStoreInstance} from '../../models' -import { TransactionTaskResult, WalletTask } from '../walletService' -import { MintBalance } from '../../models/Mint' -import { Proof } from '../../models/Proof' -import { ProofReservation } from './proofReservation' -import { Transaction, TransactionData, TransactionStatus, TransactionType } from '../../models/Transaction' -import { log } from '../logService' -import { WalletUtils } from './utils' -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' +import {TransactionTaskResult} from '../walletService' +import {MintBalance} from '../../models/Mint' +import {TransactionData, TransactionStatus} from '../../models/Transaction' +import {log} from '../logService' +import {WalletUtils} from './utils' +import {MintUnit, formatCurrency, getCurrency} from './currency' +import {NostrEvent} from '../nostrService' -const { - transactionsStore, - mintsStore, - proofsStore, - walletStore, -} = rootStoreInstance - -// const {walletStore} = nonPersistedStores +const {transactionsStore} = rootStoreInstance export const TRANSFER_TASK = 'transferTask' -const _monitorAsyncMeltQuote = async (params: { - mintUrl: string - unit: MintUnit - quoteId: string - proofsToMeltFrom: Proof[] - proofsToMeltFromAmount: number - amountToTransfer: number - meltFeeReserve: number - transactionId: number -}) => { - const { mintUrl, quoteId } = params - const wsMint = new CashuMint(mintUrl) - const wsWallet = new CashuWallet(wsMint) - - 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 })) - } -} - +/** + * Backward-compatible transfer (lightning melt) task wrapper. + * + * Preserves the historical `WalletTask.transferQueueAwaitable` contract: a + * single call that creates the draft, reserves proofs, optionally swaps for + * tighter denominations, calls the mint to pay the invoice, and returns a + * `TransactionTaskResult`. + * + * Internally delegates to the lifecycle API: + * `TransferOperationApi.prepare()` (DRAFT → PREPARED, reservation opens) + * `TransferOperationApi.execute()` (PREPARED → EXECUTING → PENDING|COMPLETED) + * + * Screens that want fee preview / cancel-before-execute should call the + * lifecycle methods directly instead of going through this wrapper. + */ export const transferTask = async function ( mintBalanceToTransferFrom: MintBalance, amountToTransfer: number, unit: MintUnit, meltQuote: MeltQuoteBolt11Response, memo: string, - invoiceExpiry: Date, + invoiceExpiry: Date, encodedInvoice: string, nwcEvent?: NostrEvent, - draftTransactionId?: number -) : Promise { - + draftTransactionId?: number, +): Promise { 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('[transfer]', 'mintBalanceToTransferFrom', {mintBalanceToTransferFrom}) - log.debug('[transfer]', 'amountToTransfer', {amountToTransfer}) - log.debug('[transfer]', 'meltQuote', {meltQuote}) + log.debug('[transferTask]', 'mintBalanceToTransferFrom', {mintBalanceToTransferFrom}) + log.debug('[transferTask]', 'amountToTransfer', {amountToTransfer}) + log.debug('[transferTask]', '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 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 + let transactionIdForRecovery: number | undefined try { - - if(draftTransactionId && draftTransactionId > 0) { - transaction = transactionsStore.findById(draftTransactionId) - } 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, + const prepared = await TransferOperationApi.prepare({ + mintBalance: mintBalanceToTransferFrom, + amount: amountToTransfer, unit, - operationType: didPreemptiveSwap ? 'transfer-melt-after-swap' : 'transfer-melt', - rollbackTo: 'UNSPENT', + memo, + method: { + method: 'bolt11', + options: {encodedInvoice, meltQuote, invoiceExpiry}, + }, + nwcEvent, + draftTransactionId, }) + transactionIdForRecovery = prepared.transactionId - log.trace('[transfer]', 'Prepared proofsToMeltFrom proofs', { - proofsToMeltFromAmount, - transactionId, - unit, - }) + const settled = await TransferOperationApi.execute(prepared) - // 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( + // 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 { + taskFunction: TRANSFER_TASK, 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, + transaction: settled, + 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, meltFeePaid, - returnedAmount, - //@ts-ignore - preimage: meltResponse.quote.payment_preimage, - createdAt: new Date(), - } - - transactionData.push(completedDataItem) - - const updatePayload: any = { - status: TransactionStatus.COMPLETED, - data: JSON.stringify(transactionData), - fee: totalFeePaid, - balanceAfter, - } - - if (outputToken) { - updatePayload.outputToken = outputToken - } - - //@ts-ignore - if (meltResponse.quote.payment_preimage) { - //@ts-ignore - updatePayload.proof = meltResponse.quote.payment_preimage - } - - transaction.update(updatePayload) - - return { - taskFunction: TRANSFER_TASK, - mintUrl, - transaction, - message: `Lightning invoice has been successfully paid and settled with your Minibits ecash. Fee has been ${formatCurrency(transaction.fee, getCurrency(unit).code)} ${getCurrency(unit).code}.`, - lightningFeePaid, - meltFeePaid, totalFeePaid, - meltQuote: meltResponse.quote, + meltQuote, //@ts-ignore - preimage: meltResponse.quote.payment_preimage, - 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, + preimage: settled.proof ?? undefined, nwcEvent, } 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) { - let message = e.message - let taskResult: TransactionTaskResult = { + // PENDING — async melt in progress; monitor will resolve via refresh. + return { taskFunction: TRANSFER_TASK, mintUrl, - transaction, - message, - nwcEvent + transaction: settled, + message: 'Lightning payment is in progress...', + meltQuote, + nwcEvent, } as TransactionTaskResult - let recovered: number = 0 + } catch (e: any) { + let txAfterError = transactionIdForRecovery + ? transactionsStore.findById(transactionIdForRecovery) + : undefined - if (transaction) { - // If we threw AFTER opening the melt reservation, resolve it - // (commit-no-changes or rollback) so the row doesn't become an - // orphan. Errors thrown BEFORE the reservation opens (e.g. during - // proof selection) leave `meltReservation` undefined. - - meltQuoteCheck = await walletStore.checkLightningMeltQuote(mintUrl, meltQuote.quote) - taskResult.meltQuote = meltQuoteCheck - - 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 { + if (txAfterError && txAfterError.status !== TransactionStatus.PENDING) { + // execute()'s error handler may have already marked tx RECOVERED; + // only stamp ERROR if execute() didn't. + if ( + txAfterError.status !== TransactionStatus.RECOVERED && + txAfterError.status !== TransactionStatus.ERROR + ) { + let transactionData: TransactionData[] = [] + try { transactionData = JSON.parse(txAfterError.data) } catch {} transactionData.push({ status: TransactionStatus.ERROR, error: WalletUtils.formatError(e), - createdAt: new Date() + createdAt: new Date(), }) - - transaction.update({ + txAfterError.update({ status: TransactionStatus.ERROR, data: JSON.stringify(transactionData), }) - - taskResult.error = WalletUtils.formatError(e) } } - return taskResult - } -} \ No newline at end of file + return { + taskFunction: TRANSFER_TASK, + mintUrl, + transaction: txAfterError, + message: e.message, + error: WalletUtils.formatError(e), + nwcEvent, + } as TransactionTaskResult + } +}