mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 11:18:24 +00:00
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.
255 lines
9.7 KiB
TypeScript
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
|
|
}
|
|
}
|