Files
minibits_wallet/src/services/wallet/transferTask.ts
T
minibits-cash 6b5b1da356 Stage 6a: onchain melt service layer (NUT-30)
One melt lifecycle, two rails. TransferOperationApi now handles both bolt11
(NUT-05) and onchain (NUT-30) melts; what differs between them is resolved once
in resolveTransferMethod rather than branched on at each site that needs a fee or
an expiry. There is deliberately one copy of the proof reservation, the
preemptive swap, and the execute-error recovery matrix.

cashu-ts' prepareMelt is already method-agnostic (it derives the NUT-08 blank
count from inputs - quote.amount, not from fee_reserve), and fee_index rides
along as extraPayload on completeMelt. So the prepare -> persist meltPreview ->
complete split that melt-change recovery depends on survives intact.

Substance, beyond the plumbing:

- Onchain change can arrive at PENDING. The mint knows its miner fee the moment
  it builds the transaction, so it may return the unclaimed reserve with the
  spec-mandated PENDING response. bolt11's PENDING branch drops change on the
  floor (correctly - there is none yet); doing that here would strand signed
  proofs that nothing would ever look for again. execute() now commits change if
  present, and _finalizePaid subtracts what was already returned so banked change
  is not reported as fee.

- Settlement is quote-driven, not proof-driven. The mint spending our inputs
  means it BROADCAST, not that the transaction confirmed. sync's _dispatchFinalize
  therefore routes TRANSFER_ONCHAIN to refresh() (which asks the mint and only
  completes on PAID) rather than finalize(), and sync now reports the status the
  dispatch actually reached instead of assuming COMPLETED - otherwise it would
  announce a Bitcoin payment as landed while it sat unconfirmed in the mempool.

- Onchain transfers are never expired. The melt quote's expiry bounds executing
  the quote, not confirming the payment, which can outlive it by many blocks.

- Mainnet only. The CDK fakewallet hands out regtest deposit addresses for topup
  quotes, so testers end up with one in their clipboard; pasting it back into Pay
  must not spend. Refused in the parser and again in prepare(), so a screen that
  forgets the check cannot move money.

No websocket and no poller for onchain: settlement is bounded by block times, so
the existing ~60s pending sweep is already finer-grained than what it waits for.
Melts go through SyncQueue for the same counter-serialisation reason mints do.

87 tsc errors (unchanged baseline), 310/310 tests, i18n clean.
2026-07-14 16:30:15 +02:00

255 lines
9.7 KiB
TypeScript

import {MeltQuoteBolt11Response, MeltQuoteOnchainResponse} from '@cashu/cashu-ts'
import {rootStoreInstance} from '../../models'
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'
import { translate } from '../../i18n'
const {transactionsStore} = rootStoreInstance
export const TRANSFER_TASK = 'transferTask'
export const TRANSFER_ONCHAIN_TASK = 'transferOnchainTask'
/**
* 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,
encodedInvoice: string,
nwcEvent?: NostrEvent,
draftTransactionId?: number,
): Promise<TransactionTaskResult> {
const mintUrl = mintBalanceToTransferFrom.mintUrl
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 transactionIdForRecovery: number | undefined
try {
const prepared = await TransferOperationApi.prepare({
mintBalance: mintBalanceToTransferFrom,
amount: amountToTransfer,
unit,
memo,
method: {
method: 'bolt11',
options: {encodedInvoice, meltQuote, invoiceExpiry},
},
nwcEvent,
draftTransactionId,
})
transactionIdForRecovery = prepared.transactionId
const settled = await TransferOperationApi.execute(prepared)
// 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,
transaction: settled,
message: translate('transactionResult_lightningInvoicePaidFee', {
fee: `${formatCurrency(settled.fee, getCurrency(unit).code)} ${getCurrency(unit).code}`,
}),
lightningFeePaid,
meltFeePaid,
totalFeePaid,
meltQuote,
//@ts-ignore
preimage: settled.proof ?? undefined,
nwcEvent,
} as TransactionTaskResult
}
// PENDING — async melt in progress; monitor will resolve via refresh.
return {
taskFunction: TRANSFER_TASK,
mintUrl,
transaction: settled,
message: 'Lightning payment is in progress...',
meltQuote,
nwcEvent,
} as TransactionTaskResult
} catch (e: any) {
let txAfterError = transactionIdForRecovery
? transactionsStore.findById(transactionIdForRecovery)
: undefined
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(),
})
txAfterError.update({
status: TransactionStatus.ERROR,
data: JSON.stringify(transactionData),
})
}
}
return {
taskFunction: TRANSFER_TASK,
mintUrl,
transaction: txAfterError,
message: e.message,
error: WalletUtils.formatError(e),
nwcEvent,
} as TransactionTaskResult
}
}
/**
* Onchain (NUT-30) melt task.
*
* Same two-step lifecycle as `transferTask` — `prepare()` then `execute()`, sharing
* the one copy of the reservation, preemptive-swap and error-recovery machinery. Only
* the result mapping differs, and it differs because the RAILS differ:
*
* `transferTask` treats PENDING as the exception (lightning usually settles in the
* same round-trip). Here PENDING is the ONLY outcome. NUT-30 requires the mint to
* answer PENDING and broadcast in the background, so a COMPLETED transaction coming
* back from `execute()` would mean the mint did something the spec forbids — we still
* handle it rather than assert on it, since being wrong about a payment that already
* went through helps nobody.
*
* The transaction is resolved later by the pending-transfer sweep, which checks the
* quote until the mint reports PAID (confirmed).
*/
export const transferOnchainTask = async function (
mintBalanceToTransferFrom: MintBalance,
amountToTransfer: number,
unit: MintUnit,
meltQuote: MeltQuoteOnchainResponse,
feeIndex: number,
memo: string,
quoteExpiry: Date,
address: string,
nwcEvent?: NostrEvent,
draftTransactionId?: number,
): Promise<TransactionTaskResult> {
const mintUrl = mintBalanceToTransferFrom.mintUrl
log.debug('[transferOnchainTask]', {mintUrl, amountToTransfer, feeIndex, address})
// Lazy import avoids a circular dep across the operations module graph.
const {TransferOperationApi} = await import('./operations/transferOperationApi')
let transactionIdForRecovery: number | undefined
try {
const prepared = await TransferOperationApi.prepare({
mintBalance: mintBalanceToTransferFrom,
amount: amountToTransfer,
unit,
memo,
method: {
method: 'onchain',
options: {address, meltQuote, feeIndex, quoteExpiry},
},
nwcEvent,
draftTransactionId,
})
transactionIdForRecovery = prepared.transactionId
const settled = await TransferOperationApi.execute(prepared)
if (settled.status === TransactionStatus.COMPLETED) {
const totalFeePaid = settled.fee ?? 0
const meltFeePaid = prepared.meltFeeReserve + prepared.preemptiveSwapFeePaid
return {
taskFunction: TRANSFER_ONCHAIN_TASK,
mintUrl,
transaction: settled,
message: translate('transactionResult_onchainPaymentConfirmed'),
meltFeePaid,
totalFeePaid,
meltQuote,
nwcEvent,
} as TransactionTaskResult
}
// The normal path: broadcast, awaiting confirmations.
return {
taskFunction: TRANSFER_ONCHAIN_TASK,
mintUrl,
transaction: settled,
message: translate('transactionResult_onchainPaymentBroadcast'),
meltQuote,
nwcEvent,
} as TransactionTaskResult
} catch (e: any) {
const txAfterError = transactionIdForRecovery
? transactionsStore.findById(transactionIdForRecovery)
: undefined
// A PENDING transaction is in flight at the mint — never stamp it ERROR, that
// would hide a real payment. execute()'s handler may also already have marked it
// RECOVERED (paid despite a client error).
if (txAfterError && txAfterError.status !== TransactionStatus.PENDING) {
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(),
})
txAfterError.update({
status: TransactionStatus.ERROR,
data: JSON.stringify(transactionData),
})
}
}
return {
taskFunction: TRANSFER_ONCHAIN_TASK,
mintUrl,
transaction: txAfterError,
message: e.message,
error: WalletUtils.formatError(e),
nwcEvent,
} as TransactionTaskResult
}
}