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.
This commit is contained in:
minibits-cash
2026-07-14 16:30:15 +02:00
parent b0b75e0607
commit 6b5b1da356
21 changed files with 1780 additions and 140 deletions
+236
View File
@@ -0,0 +1,236 @@
import {
decodeBitcoinAddress,
isBitcoinAddress,
isPayableBitcoinAddress,
parseBip21,
findBitcoinAddress,
} from '../src/services/bitcoin/bitcoinUtils'
/**
* A REAL deposit address handed out by the CDK fakewallet backend for an onchain
* topup quote. Anyone testing this wallet ends up with one of these in their
* clipboard, so it is the single most likely wrong thing to be pasted into Pay.
*/
const FAKEWALLET_REGTEST = 'bcrt1qq723ledhgscxenun8z2pt3atxtnqef3csv0hl9'
// BIP-173 / BIP-350 test vectors plus real-world addresses.
const P2PKH = '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2'
const P2SH = '3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy'
const P2WPKH = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'
const P2WSH = 'bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3'
const P2TR = 'bc1p5d7rjq7g6rdk2yhzks9smlaqtedr4dekq08ge8ztwac72sfr9rusxg3297'
const TESTNET_P2WPKH = 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx'
const TESTNET_P2PKH = 'mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn'
describe('decodeBitcoinAddress', () => {
it('decodes mainnet legacy addresses', () => {
expect(decodeBitcoinAddress(P2PKH)).toEqual({
address: P2PKH,
network: 'mainnet',
kind: 'P2PKH',
})
expect(decodeBitcoinAddress(P2SH)).toEqual({
address: P2SH,
network: 'mainnet',
kind: 'P2SH',
})
})
it('decodes segwit v0 addresses and distinguishes P2WPKH from P2WSH by program length', () => {
expect(decodeBitcoinAddress(P2WPKH)).toEqual({
address: P2WPKH,
network: 'mainnet',
kind: 'P2WPKH',
})
expect(decodeBitcoinAddress(P2WSH)).toEqual({
address: P2WSH,
network: 'mainnet',
kind: 'P2WSH',
})
})
it('decodes taproot (bech32m)', () => {
expect(decodeBitcoinAddress(P2TR)).toEqual({
address: P2TR,
network: 'mainnet',
kind: 'P2TR',
})
})
it('decodes testnet addresses', () => {
expect(decodeBitcoinAddress(TESTNET_P2WPKH)?.network).toBe('testnet')
expect(decodeBitcoinAddress(TESTNET_P2PKH)?.network).toBe('testnet')
})
it('accepts uppercase segwit and normalizes it', () => {
// QR encoders uppercase bech32 to stay in alphanumeric mode.
expect(decodeBitcoinAddress(P2WPKH.toUpperCase())?.address).toBe(P2WPKH)
})
it('rejects mixed-case segwit (BIP-173)', () => {
const mixed = 'bc1QW508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'
expect(decodeBitcoinAddress(mixed)).toBeUndefined()
})
// The checksum is the whole point: a wallet that accepts a typo'd address is a
// wallet that sends money nowhere. Both encodings must actually verify.
it('rejects a corrupted base58 checksum', () => {
expect(decodeBitcoinAddress('1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN3')).toBeUndefined()
})
it('rejects a corrupted bech32 checksum', () => {
expect(decodeBitcoinAddress('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5')).toBeUndefined()
})
// Witness version selects the checksum constant. Accepting either constant for
// either version would let a corrupted address through whenever it happened to
// satisfy the other one.
it('rejects a v0 address encoded with bech32m', () => {
// BIP-350 invalid vector: v0 witness with bech32m checksum.
expect(
decodeBitcoinAddress('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kemeawh'),
).toBeUndefined()
})
it('rejects a v1 address encoded with bech32', () => {
// BIP-350 invalid vector: v1 witness with bech32 (not bech32m) checksum.
expect(
decodeBitcoinAddress('bc1p38j9r5y49hruaue7wxjce0updqjuyyx0kh56v8s25huc6995vvpql3jow4'),
).toBeUndefined()
})
it('rejects an unknown human-readable prefix', () => {
expect(decodeBitcoinAddress('ltc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4')).toBeUndefined()
})
it('rejects lightning invoices, empty strings and noise', () => {
expect(decodeBitcoinAddress('')).toBeUndefined()
expect(decodeBitcoinAddress(' ')).toBeUndefined()
expect(decodeBitcoinAddress('lnbc1u1p...')).toBeUndefined()
expect(decodeBitcoinAddress('not an address')).toBeUndefined()
})
it('isBitcoinAddress is a predicate over the same rules', () => {
expect(isBitcoinAddress(P2TR)).toBe(true)
expect(isBitcoinAddress('nope')).toBe(false)
})
it('decodes the CDK fakewallet regtest address rather than rejecting it outright', () => {
// Recognising it is what lets the pay flow say "wrong network" instead of
// "unknown data". Refusing to PAY it is isPayableBitcoinAddress's job.
expect(decodeBitcoinAddress(FAKEWALLET_REGTEST)).toEqual({
address: FAKEWALLET_REGTEST,
network: 'regtest',
kind: 'P2WPKH',
})
})
})
describe('isPayableBitcoinAddress', () => {
it('accepts mainnet addresses of every script type', () => {
for (const address of [P2PKH, P2SH, P2WPKH, P2WSH, P2TR]) {
expect(isPayableBitcoinAddress(address)).toBe(true)
}
})
/**
* The loss vector this exists for: a CDK fakewallet topup hands the user a REGTEST
* deposit address, it sits in their clipboard, and Pay auto-pastes it. A regtest
* address is never a payment — it is a mistake, and an irreversible one if a mint
* broadcasts against it.
*/
it('refuses the CDK fakewallet regtest address', () => {
expect(isPayableBitcoinAddress(FAKEWALLET_REGTEST)).toBe(false)
})
it('refuses every non-mainnet address', () => {
expect(isPayableBitcoinAddress(TESTNET_P2WPKH)).toBe(false)
expect(isPayableBitcoinAddress(TESTNET_P2PKH)).toBe(false)
expect(isPayableBitcoinAddress('bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7k1234a')).toBe(false)
})
it('refuses input that is not an address at all', () => {
expect(isPayableBitcoinAddress('nope')).toBe(false)
expect(isPayableBitcoinAddress('')).toBe(false)
})
})
describe('parseBip21', () => {
it('parses a bare bitcoin: URI', () => {
expect(parseBip21(`bitcoin:${P2WPKH}`)).toEqual({address: P2WPKH})
})
it('converts the BTC amount to sats', () => {
expect(parseBip21(`bitcoin:${P2WPKH}?amount=0.0001`)?.amountSat).toBe(10000)
expect(parseBip21(`bitcoin:${P2WPKH}?amount=1`)?.amountSat).toBe(100000000)
})
// 0.0001 * 1e8 is 9999.999999999999 in binary floating point. Truncating would
// under-request by a sat; the round-trip through buildBip21Uri must be stable.
it('rounds rather than truncates the float conversion', () => {
expect(parseBip21(`bitcoin:${P2WPKH}?amount=0.00010000`)?.amountSat).toBe(10000)
expect(parseBip21(`bitcoin:${P2WPKH}?amount=0.00000001`)?.amountSat).toBe(1)
})
it('parses label, message and a unified lightning invoice', () => {
const parsed = parseBip21(
`bitcoin:${P2WPKH}?amount=0.001&label=Alice&message=Thanks&lightning=LNBC1U1PABC`,
)
expect(parsed).toEqual({
address: P2WPKH,
amountSat: 100000,
label: 'Alice',
message: 'Thanks',
lightning: 'lnbc1u1pabc',
})
})
it('accepts an uppercase scheme', () => {
expect(parseBip21(`BITCOIN:${P2WPKH.toUpperCase()}`)?.address).toBe(P2WPKH)
})
it('ignores an unusable amount instead of failing the URI', () => {
expect(parseBip21(`bitcoin:${P2WPKH}?amount=abc`)?.amountSat).toBeUndefined()
expect(parseBip21(`bitcoin:${P2WPKH}?amount=-1`)?.amountSat).toBeUndefined()
expect(parseBip21(`bitcoin:${P2WPKH}?amount=0`)?.amountSat).toBeUndefined()
})
it('rejects a URI whose address does not check out', () => {
expect(parseBip21('bitcoin:not-an-address')).toBeUndefined()
expect(parseBip21('bitcoin:1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN3')).toBeUndefined()
})
// A lightning-only unified URI is legal BIP21, but an onchain melt cannot use it.
it('rejects an addressless URI', () => {
expect(parseBip21('bitcoin:?lightning=lnbc1u1pabc')).toBeUndefined()
})
it('rejects non-BIP21 input', () => {
expect(parseBip21(P2WPKH)).toBeUndefined()
expect(parseBip21('lightning:lnbc1')).toBeUndefined()
})
})
describe('findBitcoinAddress', () => {
it('finds a bare address', () => {
expect(findBitcoinAddress(P2TR)).toBe(P2TR)
})
it('finds a BIP21 URI inside surrounding text', () => {
const found = findBitcoinAddress(`Pay me here: bitcoin:${P2WPKH}?amount=0.001 thanks!`)
expect(found).toBe(`bitcoin:${P2WPKH}?amount=0.001`)
})
it('finds an address embedded in pasted prose', () => {
expect(findBitcoinAddress(`send to ${P2PKH} please`)).toBe(P2PKH)
})
it('skips candidates that fail their checksum', () => {
expect(findBitcoinAddress('send to 1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN3 please')).toBeUndefined()
})
it('returns undefined for text with no address', () => {
expect(findBitcoinAddress('just some words')).toBeUndefined()
expect(findBitcoinAddress('')).toBeUndefined()
})
})
+154
View File
@@ -0,0 +1,154 @@
/**
* Onchain (NUT-30) melt arithmetic: fee-tier selection and the payout floor.
*
* Same split as the topup arithmetic tests — jest pins the pure decisions, device
* testing covers the orchestration. What matters here is that the wallet never
* silently spends more of the user's money on miner fees than it was asked to, and
* never ranks fee tiers by a field that is not a rank.
*
* @jest-environment node
*/
import {
findFeeOption,
normalizeFeeOptions,
onchainMeltFloor,
onchainMeltTotal,
selectDefaultFeeOption,
MINIBITS_ONCHAIN_MELT_FLOOR_SAT,
} from '../src/services/wallet/operations/onchainAmounts'
/** cashu-ts hands `fee_reserve` over as an Amount object, not a number. */
const amount = (n: number) => ({toNumber: () => n})
describe('normalizeFeeOptions', () => {
it('unwraps cashu-ts Amount objects into plain numbers', () => {
const options = normalizeFeeOptions([
{fee_index: 0, fee_reserve: amount(400), estimated_blocks: 6},
])
expect(options).toEqual([{feeIndex: 0, feeReserve: 400, estimatedBlocks: 6}])
})
it('accepts plain numbers too', () => {
const options = normalizeFeeOptions([
{fee_index: 0, fee_reserve: 400, estimated_blocks: 6},
])
expect(options[0].feeReserve).toBe(400)
})
it('sorts cheapest first', () => {
const options = normalizeFeeOptions([
{fee_index: 0, fee_reserve: amount(2100), estimated_blocks: 1},
{fee_index: 1, fee_reserve: amount(400), estimated_blocks: 6},
{fee_index: 2, fee_reserve: amount(900), estimated_blocks: 3},
])
expect(options.map(o => o.feeReserve)).toEqual([400, 900, 2100])
})
// fee_index is the mint's IDENTIFIER for a tier, not its rank. A mint is free to
// hand back the expensive tier as fee_index 0 — selecting by position without
// sorting first would then pick the most expensive option as the "cheap" default.
it('does not assume fee_index encodes the ranking', () => {
const options = normalizeFeeOptions([
{fee_index: 7, fee_reserve: amount(2100), estimated_blocks: 1},
{fee_index: 3, fee_reserve: amount(400), estimated_blocks: 6},
])
expect(options[0].feeIndex).toBe(3)
expect(options[0].feeReserve).toBe(400)
})
it('handles an empty list without throwing', () => {
expect(normalizeFeeOptions([])).toEqual([])
})
})
describe('selectDefaultFeeOption', () => {
const tiers = (...reserves: number[]) =>
normalizeFeeOptions(
reserves.map((r, i) => ({
fee_index: i,
fee_reserve: amount(r),
estimated_blocks: reserves.length - i,
})),
)
// The CDK fakewallet returns exactly one option. The picker must not ask the user
// to choose from a list of one.
it('returns the only option when the mint offers one tier', () => {
const selected = selectDefaultFeeOption(tiers(400))
expect(selected?.feeReserve).toBe(400)
})
it('picks the middle tier when there is a true middle', () => {
expect(selectDefaultFeeOption(tiers(400, 900, 2100))?.feeReserve).toBe(900)
expect(selectDefaultFeeOption(tiers(100, 200, 300, 400, 500))?.feeReserve).toBe(300)
})
// With an even count there is no true middle. Round DOWN: the user can always
// choose to pay more, but a wallet must never round a fee up on their behalf.
it('rounds to the cheaper side when there is no true middle', () => {
expect(selectDefaultFeeOption(tiers(400, 2100))?.feeReserve).toBe(400)
expect(selectDefaultFeeOption(tiers(100, 200, 300, 400))?.feeReserve).toBe(200)
})
it('is undefined when the mint returned no tiers', () => {
// NUT-30 forbids this ("The mint MUST return at least one fee_options item"),
// so callers treat it as a broken quote rather than inventing a fee.
expect(selectDefaultFeeOption([])).toBeUndefined()
})
})
describe('findFeeOption', () => {
const options = normalizeFeeOptions([
{fee_index: 7, fee_reserve: amount(2100), estimated_blocks: 1},
{fee_index: 3, fee_reserve: amount(400), estimated_blocks: 6},
])
it('looks a tier up by the mint\'s fee_index, not by position', () => {
expect(findFeeOption(options, 7)?.feeReserve).toBe(2100)
expect(findFeeOption(options, 3)?.feeReserve).toBe(400)
})
it('is undefined for a fee_index the mint never offered', () => {
// The mint MUST reject a melt with an unoffered fee_index, so catching it here
// saves a round-trip and a burned quote.
expect(findFeeOption(options, 0)).toBeUndefined()
})
})
describe('onchainMeltFloor', () => {
it('applies our own floor when the mint asks for less', () => {
expect(onchainMeltFloor('sat', 1)).toBe(MINIBITS_ONCHAIN_MELT_FLOOR_SAT)
expect(onchainMeltFloor('sat', 546)).toBe(MINIBITS_ONCHAIN_MELT_FLOOR_SAT)
})
it('defers to the mint when it asks for more', () => {
expect(onchainMeltFloor('sat', 50000)).toBe(50000)
})
it('applies our floor when the mint advertises nothing usable', () => {
expect(onchainMeltFloor('sat')).toBe(MINIBITS_ONCHAIN_MELT_FLOOR_SAT)
expect(onchainMeltFloor('sat', 0)).toBe(MINIBITS_ONCHAIN_MELT_FLOOR_SAT)
expect(onchainMeltFloor('sat', null)).toBe(MINIBITS_ONCHAIN_MELT_FLOOR_SAT)
})
// The floor is denominated in sats, so it means nothing for other units.
it('defers entirely to the mint for non-sat units', () => {
expect(onchainMeltFloor('usd', 5)).toBe(5)
expect(onchainMeltFloor('usd')).toBe(0)
})
it('clears every script type\'s dust limit', () => {
// P2PKH dust is 546, P2WSH 330. An output below that is unspendable.
expect(MINIBITS_ONCHAIN_MELT_FLOOR_SAT).toBeGreaterThan(546)
})
})
describe('onchainMeltTotal', () => {
it('is amount + fee reserve + input fee, per NUT-30', () => {
expect(onchainMeltTotal(10000, 400, 2)).toBe(10402)
})
it('treats the input fee as optional', () => {
expect(onchainMeltTotal(10000, 400)).toBe(10400)
})
})
+7 -2
View File
@@ -138,13 +138,18 @@ describe('TransferOperationApi surface (compile-time)', () => {
'unit',
'amountToTransfer',
'meltQuote',
'invoiceExpiry',
'path',
'method',
// Rail-specific facts (fee reserve, expiry, tx type, quote id) resolved once,
// so the shared lifecycle never branches on `method` to find them.
'resolved',
'proofsToMeltFrom',
'proofsToMeltFromAmount',
'meltFeeReserve',
'lightningFeeReserve',
// Was `lightningFeeReserve`. Renamed when onchain melt landed: on that rail it
// is a miner fee, and it comes from the SELECTED fee tier rather than from a
// single field on the quote.
'feeReserve',
'preemptiveSwapFeePaid',
'nwcEvent',
]
+3
View File
@@ -682,6 +682,9 @@
"transactionCommon_youSent": "You sent",
"transactionResult_lightningInvoicePaidFee": "Lightning invoice has been paid. Fee was %{fee}.",
"transactionResult_lightningPaymentFailed": "Lightning payment failed. Reserved ecash has been returned to spendable balance.",
"transactionResult_onchainPaymentBroadcast": "Bitcoin payment has been broadcast. It will complete once confirmed on the blockchain.",
"transactionResult_onchainPaymentConfirmed": "Bitcoin payment confirmed on the blockchain.",
"transactionResult_onchainPaymentFailed": "Bitcoin payment failed. Reserved ecash has been returned to spendable balance.",
"transferScreen_donationSuccessMessage": "Donation for %{donationForName} has been successfully paid and your wallet address has been updated. Thank you!",
"transferScreen_insufficientFunds": "There is not enough balance in %{currency} to pay the invoice amount and expected fees: %{amount} %{currency}",
"transferScreen_LUD18unsupported": "Minibits does not yet support entering of payer identity data (LUD18).",
+3
View File
@@ -681,6 +681,9 @@
"transactionCommon_youSent": "Tú enviaste",
"transactionResult_lightningInvoicePaidFee": "La factura Lightning ha sido pagada. La tarifa fue %{fee}.",
"transactionResult_lightningPaymentFailed": "El pago Lightning falló. El ecash reservado ha sido devuelto al saldo disponible.",
"transactionResult_onchainPaymentBroadcast": "El pago de Bitcoin ha sido transmitido. Se completará una vez confirmado en la blockchain.",
"transactionResult_onchainPaymentConfirmed": "Pago de Bitcoin confirmado en la blockchain.",
"transactionResult_onchainPaymentFailed": "El pago de Bitcoin falló. El ecash reservado ha sido devuelto al saldo disponible.",
"transferScreen_donationSuccessMessage": "La donación para %{donationForName} se ha realizado correctamente y la dirección de tu billetera se ha actualizado. ¡Gracias!",
"transferScreen_insufficientFunds": "No hay suficiente saldo en %{currency} para pagar el importe de la factura y las tarifas previstas: %{amount} %{currency}",
"transferScreen_LUD18unsupported": "Minibits aún no admite la introducción de datos de identidad del pagador (LUD18).",
+3
View File
@@ -682,6 +682,9 @@
"transactionCommon_youSent": "Você enviou",
"transactionResult_lightningInvoicePaidFee": "A fatura Lightning foi paga. A taxa foi %{fee}.",
"transactionResult_lightningPaymentFailed": "O pagamento Lightning falhou. O ecash reservado foi devolvido ao saldo disponível.",
"transactionResult_onchainPaymentBroadcast": "O pagamento Bitcoin foi transmitido. Será concluído assim que confirmado na blockchain.",
"transactionResult_onchainPaymentConfirmed": "Pagamento Bitcoin confirmado na blockchain.",
"transactionResult_onchainPaymentFailed": "O pagamento Bitcoin falhou. O ecash reservado foi devolvido ao saldo disponível.",
"transferScreen_donationSuccessMessage": "Doação para %{donationForName} paga com sucesso e endereço atualizado. Obrigado!",
"transferScreen_insufficientFunds": "Saldo insuficiente em %{currency} para pagar invoice e taxas: %{amount} %{currency}",
"transferScreen_LUD18unsupported": "Minibits ainda não suporta dados de identidade do pagador (LUD18).",
+3
View File
@@ -682,6 +682,9 @@
"transactionCommon_youSent": "Poslal si",
"transactionResult_lightningInvoicePaidFee": "Lightning faktúra bola zaplatená. Poplatok bol %{fee}.",
"transactionResult_lightningPaymentFailed": "Platba cez Lightning zlyhala. Rezervovaný ecash bol vrátený do disponibilného zostatku.",
"transactionResult_onchainPaymentBroadcast": "Bitcoinová platba bola odoslaná do siete. Dokončí sa po potvrdení v blockchaine.",
"transactionResult_onchainPaymentConfirmed": "Bitcoinová platba bola potvrdená v blockchaine.",
"transactionResult_onchainPaymentFailed": "Bitcoinová platba zlyhala. Rezervovaný ecash bol vrátený do disponibilného zostatku.",
"transferScreen_donationSuccessMessage": "Dar pre %{donationForName} bol úspešne zaplatený a vaša adresa peňaženky bola aktualizovaná. Ďakujeme!",
"transferScreen_insufficientFunds": "Nie je dostatočný zostatok %{currency} na zaplatenie sumy invoice a poplatku: %{amount} %{currency}",
"transferScreen_LUD18unsupported": "Minibits ešte nepodporuje zadanie údajov platiteľa (LUD1á)",
+6
View File
@@ -84,6 +84,12 @@ export const TransactionsStoreModel = types
const dbTransfers = Database.getPendingTransfers()
return dbTransfers.map(t => TransactionModel.create({ ...t }))
},
/** Onchain melts the mint has taken but the chain has not yet confirmed. */
getPendingOnchainTransfers(): Transaction[] {
const dbTransfers = Database.getPendingOnchainTransfers()
return dbTransfers.map(t => TransactionModel.create({ ...t }))
},
}))
+206
View File
@@ -4,6 +4,7 @@ import {
Wallet as CashuWallet,
KeyChain as CashuKeyChain,
MeltQuoteBolt11Response,
MeltQuoteOnchainResponse,
setGlobalRequestOptions,
type MintKeys,
type MintKeyset,
@@ -1306,6 +1307,211 @@ export const WalletStoreModel = types
)
}
}),
/**
* Ask the mint what it would charge to send `amount` to a Bitcoin address (NUT-30).
*
* Unlike bolt11 the amount is not carried by the payment request, so this cannot be
* called until the user has entered one. The quote comes back with a list of
* `fee_options` tiers rather than a single `fee_reserve`; they are fixed for the
* quote's lifetime, and the caller picks one at execute time.
*/
createOnchainMeltQuote: flow(function* createOnchainMeltQuote(
mintUrl: string,
unit: MintUnit,
address: string,
amount: number,
) {
try {
const cashuMint: CashuMint = yield self.getMint(mintUrl)
const onchainQuote: MeltQuoteOnchainResponse = yield cashuMint.createMeltQuoteOnchain({
unit,
request: address,
amount,
})
log.info('[createOnchainMeltQuote]', {mintUrl, unit, amount}, {onchainQuote})
return onchainQuote
} catch (e: any) {
let message = 'The mint could not return the onchain melt quote.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
throw new AppError(
Err.MINT_ERROR,
message,
{
message: e.message,
caller: 'createOnchainMeltQuote',
request: {mintUrl, unit, address, amount},
}
)
}
}),
/**
* Execute an onchain melt (NUT-30).
*
* Deliberately the same two-step shape as `payLightningMelt`, because the reason for
* the split is the same: `prepareMelt` derives the NUT-08 change outputs from the
* keyset counter, and if the app dies between submitting the melt and receiving the
* response, the ONLY way to reconstruct that change is the meltPreview we wrote to
* SQLite before submitting. cashu-ts' one-shot `meltProofsOnchain` hides the split and
* would leave nothing to recover from.
*
* Two things differ from bolt11:
* - `fee_index` rides along as `extraPayload` on the melt request. It is not part of
* the quote and not part of prepare; the mint locks it into `selected_fee_index`
* when it executes, and MUST NOT execute the same quote again with a different one.
* - No `preferAsync`. NUT-30 mandates asynchrony ("The mint MUST return a PENDING
* state after validating the melt request and then broadcast in the background"),
* so there is no faster path to ask for.
*/
payOnchainMelt: flow(function* payOnchainMelt(
mintUrl: string,
unit: MintUnit,
meltQuote: MeltQuoteOnchainResponse,
proofsToMeltFrom: Proof[],
feeIndex: number,
transactionId: number,
options?: {
increaseCounterBy?: number,
}
) {
const mintInstance = self.getMintModelInstance(mintUrl)
if(!mintInstance) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing mint instance', {mintUrl})
}
const cashuWallet = yield self.getWallet(
mintUrl,
unit,
{
withSeed: true,
}
)
const currentCounter = mintInstance.getProofsCounterByKeysetId!(cashuWallet.keysetId)
// outputs error healing
if(options && options.increaseCounterBy) {
currentCounter.increaseProofsCounter(options.increaseCounterBy)
}
yield cashuWallet.counters.advanceToAtLeast(cashuWallet.keysetId, currentCounter.counter)
log.trace('[WalletStore.payOnchainMelt] Preparing melt', {
localCounter: currentCounter.counter,
proofsCount: proofsToMeltFrom.length,
feeIndex,
})
let reservedCounters: OperationCounters | undefined
// Step 1: prepare (derives the deterministic change outputs)
const meltPreview: MeltPreview<MeltQuoteOnchainResponse> = yield cashuWallet.prepareMelt(
'onchain',
meltQuote,
CashuUtils.exportProofs(proofsToMeltFrom),
{
keysetId: cashuWallet.keysetId,
onCountersReserved: (info: OperationCounters) => {
reservedCounters = info
log.debug('[payOnchainMelt] Counters reserved', info)
}
}
)
// Synchronous SQLite write BEFORE the melt is submitted, so the change is
// recoverable even if the app dies the moment after.
Database.addMeltRecovery(
transactionId,
mintUrl,
cashuWallet.keysetId,
CashuUtils.serializeMeltPreview(meltPreview),
)
if (reservedCounters) {
currentCounter.setProofsCounter(reservedCounters.next)
log.debug('[payOnchainMelt] Updated counter', {
keysetId: reservedCounters.keysetId,
start: reservedCounters.start,
count: reservedCounters.count,
next: reservedCounters.next
})
}
try {
// Step 2: submit. fee_index is the onchain-specific part of the request body.
const meltResponse: MeltProofsResponse<MeltQuoteOnchainResponse> =
yield cashuWallet.completeMelt(meltPreview, undefined, {
extraPayload: {fee_index: feeIndex},
})
// The mint answers PENDING (spec-mandated) but MAY already have returned the
// change, because it knows its actual fee the moment it builds the transaction.
// Keep the preview only while there is still change left to reconstruct later.
if (meltResponse.change.length > 0) {
Database.removeMeltRecovery(transactionId)
}
log.trace('[payOnchainMelt]', {meltResponse})
return meltResponse
} catch (e: any) {
if(!e.message.toLowerCase().includes('timeout') &&
!e.message.toLowerCase().includes('network request failed')) {
Database.removeMeltRecovery(transactionId)
}
let message = 'Onchain payment failed.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
throw new AppError(
Err.MINT_ERROR,
message,
{
message: e.message,
caller: 'payOnchainMelt',
mintUrl,
code: e.code || undefined,
}
)
}
}),
/**
* Current state of an onchain melt quote.
*
* This — not the state of the input proofs — is what says whether an onchain payment
* has settled. The mint spending the inputs means it BROADCAST, not that the
* transaction confirmed. Only `PAID` means confirmed.
*/
checkOnchainMeltQuote: flow(function* checkOnchainMeltQuote(
mintUrl: string,
quote: string,
) {
try {
const cashuMint: CashuMint = yield self.getMint(mintUrl)
const quoteResponse: MeltQuoteOnchainResponse = yield cashuMint.checkMeltQuoteOnchain(
quote
)
log.info('[checkOnchainMeltQuote]', {quoteResponse})
return quoteResponse
} catch (e: any) {
let message = 'The mint could not return the state of an onchain melt quote.'
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
throw new AppError(
Err.MINT_ERROR,
message,
{
message: e.message,
caller: 'checkOnchainMeltQuote',
mintUrl,
}
)
}
}),
restore: flow(function* restore(
mintUrl: string,
seed: Uint8Array,
+252
View File
@@ -0,0 +1,252 @@
/**
* Bitcoin address and BIP21 URI parsing.
*
* Used on the way OUT (NUT-30 onchain melt): the user pastes or scans something and
* the wallet has to decide what it is before it can route them anywhere. Onchain
* payments are irreversible, so this errs towards refusing input it does not fully
* understand — the checksums are verified locally rather than left for the mint to
* catch, so a mistyped address fails on the screen the user is looking at instead of
* one round-trip later.
*
* Checksums catch typos, not mistakes: a valid address for the wrong recipient looks
* exactly like a valid address. Nothing here can help with that.
*/
import {bech32, bech32m, createBase58check} from '@scure/base'
import {sha256} from '@noble/hashes/sha2.js'
const base58Check = createBase58check(sha256)
export type BitcoinNetwork = 'mainnet' | 'testnet' | 'regtest'
export type BitcoinAddressInfo = {
address: string
network: BitcoinNetwork
/** Human-readable script type, for display and for logs. */
kind: 'P2PKH' | 'P2SH' | 'P2WPKH' | 'P2WSH' | 'P2TR' | 'SEGWIT'
}
/** Bech32 human-readable prefixes, per BIP-173 / BIP-350. */
const SEGWIT_PREFIXES: Record<string, BitcoinNetwork> = {
bc: 'mainnet',
tb: 'testnet',
bcrt: 'regtest',
}
/** Base58 version bytes. */
const BASE58_VERSIONS: Record<number, {network: BitcoinNetwork; kind: 'P2PKH' | 'P2SH'}> = {
0x00: {network: 'mainnet', kind: 'P2PKH'}, // 1...
0x05: {network: 'mainnet', kind: 'P2SH'}, // 3...
0x6f: {network: 'testnet', kind: 'P2PKH'}, // m... / n...
0xc4: {network: 'testnet', kind: 'P2SH'}, // 2...
}
/**
* Decode a segwit (bech32 / bech32m) address.
*
* The witness version decides the checksum constant: v0 MUST use bech32, v1+ (taproot
* and anything after it) MUST use bech32m. They are different checksums over the same
* alphabet, so accepting either for both versions would let a v0 address with a
* corrupted checksum through as long as it happened to satisfy the other constant.
* We decode with both and then insist the one that worked matches the version.
*/
const decodeSegwitAddress = (address: string): BitcoinAddressInfo | undefined => {
const lower = address.toLowerCase()
// BIP-173: mixed case is invalid. Checked before lowercasing loses the evidence.
if (address !== lower && address !== address.toUpperCase()) return undefined
const separator = lower.lastIndexOf('1')
if (separator < 1) return undefined
const network = SEGWIT_PREFIXES[lower.slice(0, separator)]
if (!network) return undefined
let words: number[]
let usedBech32m = false
try {
words = bech32.decode(lower as `${string}1${string}`, 90).words
} catch {
try {
words = bech32m.decode(lower as `${string}1${string}`, 90).words
usedBech32m = true
} catch {
return undefined
}
}
const version = words[0]
if (version === undefined || version > 16) return undefined
if (version === 0 && usedBech32m) return undefined
if (version > 0 && !usedBech32m) return undefined
let program: Uint8Array
try {
program = bech32.fromWords(words.slice(1))
} catch {
return undefined
}
if (program.length < 2 || program.length > 40) return undefined
// v0 is only ever defined for P2WPKH (20 bytes) and P2WSH (32).
if (version === 0 && program.length !== 20 && program.length !== 32) return undefined
let kind: BitcoinAddressInfo['kind'] = 'SEGWIT'
if (version === 0) kind = program.length === 20 ? 'P2WPKH' : 'P2WSH'
else if (version === 1 && program.length === 32) kind = 'P2TR'
return {address: lower, network, kind}
}
/** Decode a legacy base58check address (P2PKH / P2SH). */
const decodeBase58Address = (address: string): BitcoinAddressInfo | undefined => {
let decoded: Uint8Array
try {
decoded = base58Check.decode(address)
} catch {
return undefined
}
// version byte + 20-byte hash (the 4-byte checksum is consumed by the decoder)
if (decoded.length !== 21) return undefined
const version = BASE58_VERSIONS[decoded[0]]
if (!version) return undefined
return {address, network: version.network, kind: version.kind}
}
/**
* Decode a bare Bitcoin address, verifying its checksum.
*
* Returns undefined rather than throwing, so it can be used as a predicate while
* sniffing unknown input.
*/
export const decodeBitcoinAddress = (
address: string,
): BitcoinAddressInfo | undefined => {
const trimmed = address.trim()
if (trimmed.length === 0) return undefined
return decodeSegwitAddress(trimmed) ?? decodeBase58Address(trimmed)
}
export const isBitcoinAddress = (address: string): boolean =>
decodeBitcoinAddress(address) !== undefined
/**
* Is this an address Minibits is allowed to pay?
*
* Mainnet only. The wallet holds mainnet-backed ecash and the mints melt to the real
* chain, so a testnet or regtest address is never a payment — it is a mistake, and an
* irreversible one if a mint broadcasts against it.
*
* This is not hypothetical. The CDK fakewallet backend hands out REGTEST deposit
* addresses (`bcrt1q…`) for onchain topup quotes, so anyone testing this wallet ends
* up with one in their clipboard. Pasting it back into Pay must fail loudly, not
* quietly reach the mint.
*
* Kept separate from `decodeBitcoinAddress` on purpose: decoding tells you WHAT an
* address is (and needs to recognise testnet in order to say so), while this decides
* whether we are willing to send money to it. Collapsing the two would leave us
* unable to tell "that is not an address" apart from "that is not OUR network", and
* the second deserves its own error message.
*/
export const isPayableBitcoinAddress = (address: string): boolean =>
decodeBitcoinAddress(address)?.network === 'mainnet'
export type Bip21Data = {
address: string
/** Amount in SATS, converted from the BIP21 `amount` (which is in BTC). */
amountSat?: number
label?: string
message?: string
/** A BOLT11 invoice carried alongside the address in a unified QR. */
lightning?: string
}
/**
* Parse a BIP21 `bitcoin:` URI.
*
* Only the address is required; everything else is a hint the wallet may use or
* ignore. Returns undefined if the URI is not BIP21 or the address does not check
* out — a `bitcoin:` URI with an address we cannot verify is not something to pass
* along half-understood.
*
* The scheme is case-insensitive (BIP21 allows `BITCOIN:`, which is what QR encoders
* emit to stay in the alphanumeric mode).
*/
export const parseBip21 = (uri: string): Bip21Data | undefined => {
const trimmed = uri.trim()
if (!/^bitcoin:/i.test(trimmed)) return undefined
const body = trimmed.slice('bitcoin:'.length)
const [addressPart, queryPart] = body.split('?', 2)
// `bitcoin:?lightning=...` (no address) is a legal BOLT11-only unified URI, but
// it is not something an onchain melt can use — the caller wants an address.
const decoded = decodeBitcoinAddress(addressPart)
if (!decoded) return undefined
// Take the DECODED address, not the raw text: QR encoders uppercase bech32 to stay
// in alphanumeric mode, and this string is what we hand to the mint.
const result: Bip21Data = {address: decoded.address}
if (!queryPart) return result
const params = new URLSearchParams(queryPart)
const amount = params.get('amount')
if (amount) {
const btc = Number(amount)
// BIP21 amounts are decimal BTC. Round rather than truncate: 0.0001 parses to
// 9999.999999999999 sats in binary floating point, and a truncating conversion
// would quietly under-request by one sat.
if (Number.isFinite(btc) && btc > 0) result.amountSat = Math.round(btc * 100_000_000)
}
const label = params.get('label')
if (label) result.label = label
const message = params.get('message')
if (message) result.message = message
const lightning = params.get('lightning')
if (lightning) result.lightning = lightning.toLowerCase()
return result
}
/**
* Find a Bitcoin address or BIP21 URI inside arbitrary pasted text.
*
* Mirrors `LightningUtils.findEncodedLightningInvoice` — clipboards carry surrounding
* prose, and QR payloads sometimes carry a URI inside a larger string.
*/
export const findBitcoinAddress = (text: string): string | undefined => {
const trimmed = text.trim()
const uriMatch = trimmed.match(/bitcoin:[^\s]+/i)
if (uriMatch && parseBip21(uriMatch[0])) return uriMatch[0]
if (decodeBitcoinAddress(trimmed)) return trimmed
// Bare address embedded in text. The candidate pattern is deliberately loose —
// `decodeBitcoinAddress` is the actual filter, so a false candidate costs a failed
// checksum, not a false positive.
const candidates = trimmed.match(/\b(bc1|tb1|bcrt1)[a-z0-9]{6,87}\b|\b[13mn2][a-km-zA-HJ-NP-Z1-9]{25,39}\b/gi)
if (!candidates) return undefined
for (const candidate of candidates) {
if (decodeBitcoinAddress(candidate)) return candidate
}
return undefined
}
export const BitcoinUtils = {
decodeBitcoinAddress,
isBitcoinAddress,
isPayableBitcoinAddress,
parseBip21,
findBitcoinAddress,
}
+2
View File
@@ -19,6 +19,7 @@ import {
getPendingTopupsCount,
getPendingTransfers,
getPendingTransfersCount,
getPendingOnchainTransfers,
addTransactionAsync,
updateTransaction,
expireAllAfterRecovery,
@@ -107,6 +108,7 @@ export const Database = {
getPendingTopupsCount,
getPendingTransfers,
getPendingTransfersCount,
getPendingOnchainTransfers,
addTransactionAsync,
updateTransaction,
expireAllAfterRecovery,
+32
View File
@@ -219,6 +219,38 @@ export const getPendingTransfers = function () {
}
/**
* PENDING onchain melts — payments the mint has taken but the chain has not confirmed.
*
* Separate from `getPendingTransfers` (which filters `type = 'TRANSFER'`) rather than
* folded into it, because the two are watched for different reasons and on different
* clocks: a bolt11 transfer is watched to catch a stuck payment and can be EXPIRED,
* while an onchain transfer is waiting on blocks and must never be expired — the melt
* quote's expiry bounds executing the quote, not confirming the payment.
*/
export const getPendingOnchainTransfers = function () {
try {
const query = `
SELECT *
FROM transactions
WHERE status = 'PENDING'
AND type = 'TRANSFER_ONCHAIN'
ORDER BY id DESC
`
const db = getInstance()
const {rows} = db.execute(query)
log.trace(`[getPendingOnchainTransfers], Returned ${rows?.length} rows`)
return normalizeTransactionRows(rows)
} catch (e: any) {
throw dbError('Transactions could not be retrieved from the database', e)
}
}
export const getPendingTopupsCount = function () {
let query: string = ''
try {
@@ -320,11 +320,19 @@ const handleInFlightByMintTask = async (mint: Mint): Promise<WalletTaskResult> =
break
}
// TRANSFER (melt / lightning out retry)
// COMMENTED OUT — solved by syncStateWithMintTask which recovers change
// from pending-yet-paid transfers. Request params (meltPreview) is stored
// in proofsCounter.meltCounterValues, not inFlightRequests.
case TransactionType.TRANSFER: {
// TRANSFER / TRANSFER_ONCHAIN (melt retry)
// NO-OP — solved by syncStateWithMintTask which recovers change from
// pending-yet-paid transfers. Request params (meltPreview) is stored in
// proofsCounter.meltCounterValues, not inFlightRequests.
//
// Melts need no replay for the reason mints do. A lost mint RESPONSE
// strands issued ecash (the mint counts it as issued, we never see it),
// so TOPUP replays the request against the mint's NUT-19 cache. A lost
// melt response strands nothing: the money is either gone (mint paid, and
// sync recovers the change) or still ours (mint did not, and sync returns
// the proofs). Replaying a melt would risk paying twice to fix nothing.
case TransactionType.TRANSFER:
case TransactionType.TRANSFER_ONCHAIN: {
break
}
@@ -1,6 +1,7 @@
import {isBefore} from 'date-fns'
import {
MeltQuoteBolt11Response,
MeltQuoteOnchainResponse,
MeltQuoteState,
getEncodedToken,
} from '@cashu/cashu-ts'
@@ -14,13 +15,15 @@ import {
Transaction,
TransactionData,
TransactionStatus,
TransactionType,
} from '../../../models/Transaction'
import {MintBalance} from '../../../models/Mint'
import {Proof} from '../../../models/Proof'
import {CashuUtils} from '../../cashu/cashuUtils'
import {NostrEvent} from '../../nostrService'
import {MintUnit, formatCurrency, getCurrency} from '../currency'
import {transferTask} from '../transferTask'
import {transferOnchainTask, transferTask} from '../transferTask'
import {SyncQueue} from '../../syncQueueService'
import {WalletUtils} from '../utils'
import {createQueueAwaitable} from '../queueHelper'
import {TransactionTaskResult} from '../types'
@@ -61,12 +64,60 @@ const transferQueueAwaitable = (
})
/**
* Recover change from a paid melt quote (lightning out)
* Onchain melt, run through the SyncQueue.
*
* The queue is not a nicety. Melting derives its NUT-08 change outputs from the keyset
* counter, exactly as minting derives its blinded secrets from it: two melts running
* concurrently on one keyset both advance to the SAME counter and derive the SAME
* blinded outputs. SyncQueue runs at concurrency 1, and going through it is what makes
* that impossible. Every melting path must.
*/
const transferOnchainQueueAwaitable = (
mintBalanceToTransferFrom: MintBalance,
amountToTransfer: number,
unit: MintUnit,
meltQuote: MeltQuoteOnchainResponse,
feeIndex: number,
memo: string,
quoteExpiry: Date,
address: string,
nwcEvent?: NostrEvent,
draftTransactionId?: number,
): Promise<TransactionTaskResult> =>
createQueueAwaitable<TransactionTaskResult>({
taskFunction: 'transferOnchainTask',
timeoutMessage: 'transferOnchainQueue timed out',
task: () =>
transferOnchainTask(
mintBalanceToTransferFrom,
amountToTransfer,
unit,
meltQuote,
feeIndex,
memo,
quoteExpiry,
address,
nwcEvent,
draftTransactionId,
),
})
/**
* Recover change from a paid melt quote (lightning or onchain out).
*
* The recovery itself is rail-agnostic: it reconstructs the change from the
* meltPreview we persisted before submitting, using the blind signatures the mint
* reports on the resolved quote. Neither of those is bolt11-specific.
*
* Only the STRING form is: given just a quote id we have to ask the mint about it,
* and there is no id to tell us which endpoint to ask. That form is reached from the
* manual recovery screen, which is lightning-only. Callers with an onchain quote pass
* the resolved object.
*/
const recoverMeltQuoteChange = async (
params: {
mintUrl: string
meltQuote: string | MeltQuoteBolt11Response
meltQuote: string | MeltQuoteBolt11Response | MeltQuoteOnchainResponse
},
): Promise<{recoveredAmount: number}> => {
const {mintUrl, meltQuote} = params
@@ -79,7 +130,7 @@ const recoverMeltQuoteChange = async (
log.trace('[recoverMeltQuoteChange] start', {mintUrl, meltQuote})
const meltQuoteResponse: MeltQuoteBolt11Response =
const meltQuoteResponse: MeltQuoteBolt11Response | MeltQuoteOnchainResponse =
typeof meltQuote === 'string'
? await walletStore.checkLightningMeltQuote(mintUrl, meltQuote)
: meltQuote
@@ -396,11 +447,77 @@ const handlePendingMeltTask = async (params: {
// MeltQuoteState.PENDING: no-op, ws/poller will call again
}
/**
* Re-check every PENDING onchain transfer with its mint.
*
* The onchain equivalent of the bolt11 websocket + poller, and deliberately not either
* of those. An onchain melt settles when the transaction is mined, so the wait is
* measured in blocks: the existing ~60s pending-check cadence (app start, foreground,
* WalletScreen focus) is already far finer-grained than the thing it waits for, and a
* 15-second poller would only burn requests to learn nothing.
*
* Each check goes through SyncQueue for the counter-serialisation reason above —
* `refresh` can reconstruct NUT-08 change, and change reconstruction reads the keyset
* counter. Queueing per-transaction also means one unreachable mint cannot stall the
* others.
*/
const handlePendingOnchainTransferQueue = async (): Promise<void> => {
const pending = transactionsStore.getPendingOnchainTransfers()
if (pending.length === 0) {
log.trace('[handlePendingOnchainTransferQueue] No pending onchain transfers')
return
}
log.trace('[handlePendingOnchainTransferQueue] start', {pending: pending.length})
for (const tx of pending) {
enqueuePendingOnchainTransferCheck(tx.id)
}
}
/**
* Queue a single onchain transfer re-check. THE ONLY WAY one may be started.
*
* Duplicate tasks for the same transaction are harmless: they run in sequence, and
* `refresh` no-ops on anything that is no longer PENDING.
*/
const enqueuePendingOnchainTransferCheck = (transactionId: number) => {
const taskId = `handlePendingOnchainTransferTask-${transactionId}-${Date.now()}`
return SyncQueue.addTask(taskId, () => handlePendingOnchainTransferTask(transactionId))
}
/**
* Re-check one onchain transfer. Errors are swallowed and logged: an offline mint, or
* one transfer failing, must not abort the sweep — the next tick simply tries again.
*/
const handlePendingOnchainTransferTask = async (transactionId: number) => {
try {
const {TransferOperationApi} = await import('./transferOperationApi')
return await TransferOperationApi.refresh(transactionId)
} catch (e: any) {
log.warn('[handlePendingOnchainTransferTask]', {transactionId, error: e.message})
return undefined
}
}
/**
* Expire lightning transfers whose invoices have passed. Used by handlePendingQueue.
*
* Lightning only, and that is load-bearing rather than incidental. An onchain melt quote
* also carries an expiry, but it bounds EXECUTING the quote, not SETTLING the payment:
* once the mint has broadcast, the transaction confirms on the chain's schedule and can
* easily outlive the quote it came from. Expiring a transfer on that basis would mark a
* real, in-flight, irreversible payment dead and hide it from the user.
*
* The caller passes only bolt11 transfers (`getPendingTransfers` filters on
* `type = 'TRANSFER'`), so onchain never reaches here — but the guarantee is stated
* here because this is where it would be violated.
*/
const expirePendingTransfers = (pendingTransfers: Transaction[]): void => {
for (const tx of pendingTransfers) {
if (tx.type !== TransactionType.TRANSFER) continue
if (tx.expiresAt && isBefore(tx.expiresAt, new Date())) {
log.debug('[MeltOperationService] Expiring transfer', {paymentId: tx.paymentId})
@@ -428,7 +545,10 @@ const expirePendingTransfers = (pendingTransfers: Transaction[]): void => {
export const MeltOperationService = {
transferQueueAwaitable,
transferOnchainQueueAwaitable,
recoverMeltQuoteChange,
handlePendingMeltTask,
handlePendingOnchainTransferQueue,
enqueuePendingOnchainTransferCheck,
expirePendingTransfers,
}
@@ -1,10 +1,11 @@
/**
* Pure arithmetic for onchain (NUT-30) minting.
* Pure arithmetic for onchain (NUT-30) minting and melting.
*
* Kept free of stores, database and cashu-ts on purpose: these two functions decide
* how much money to mint, so they are worth being able to test in isolation. Both
* Kept free of stores, database and cashu-ts on purpose: these functions decide how
* much money to mint, how much to spend on miner fees, and whether an amount is even
* worth sending, so they are worth being able to test in isolation. Most of them
* exist to refuse a mint response we did not expect, rather than passing it through
* into a mint request.
* into a request.
*/
/**
@@ -82,3 +83,114 @@ export const buildBip21Uri = (address: string, amountSat?: number): string => {
return `bitcoin:${address}?amount=${btc}`
}
// ─────────────────────────────────────────────────────────────────────────────
// Melt (paying out onchain)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Minibits' own minimum for paying out onchain, in sats.
*
* Lower than the topup floor, and for a different reason. The topup floor is high
* because the mint credits deposits PER UTXO and dust below its minimum is
* unrecoverable — money can actually be lost. Nothing like that happens on the way
* out: the mint either accepts the melt or refuses it.
*
* What this floor protects against is creating an output nobody can afford to spend.
* 1000 sat clears every script type's dust limit (546 for P2PKH, 330 for P2WSH) with
* enough margin that the recipient's output is still economically spendable. The
* mint's own `min_amount` wins whenever it is higher — but as with topup, it is not
* trusted to be sane on its own.
*/
export const MINIBITS_ONCHAIN_MELT_FLOOR_SAT = 1000
/**
* The smallest amount worth paying out onchain: `max(our floor, the mint's minimum)`.
*
* Denominated in sats, so applied only to sat payouts; any other unit defers to the
* mint. Mirrors `onchainTopupFloor`.
*/
export const onchainMeltFloor = (
unit: string,
mintMinAmount?: number | null,
): number => {
const mintFloor = mintMinAmount && mintMinAmount > 0 ? Number(mintMinAmount) : 0
if (unit !== 'sat') return mintFloor
return Math.max(MINIBITS_ONCHAIN_MELT_FLOOR_SAT, mintFloor)
}
/**
* A NUT-30 melt fee tier, normalized to plain numbers.
*
* cashu-ts hands us `fee_reserve` as an `Amount` object. Everything here works in
* numbers so the selection logic stays testable without pulling cashu-ts (and the
* store graph behind it) into the test.
*/
export type OnchainFeeOption = {
feeIndex: number
feeReserve: number
estimatedBlocks: number
}
/** Shape of a `fee_options` entry as it arrives from cashu-ts. */
type RawFeeOption = {
fee_index: number
fee_reserve: number | {toNumber: () => number}
estimated_blocks: number
}
/**
* Normalize a quote's `fee_options` into plain numbers, sorted cheapest first.
*
* Sorting is not cosmetic — `selectDefaultFeeOption` picks by position, and the mint
* is under no obligation to return the tiers in any particular order. `fee_index` is
* the mint's identifier for a tier, NOT its rank, so it must never be used as one.
*/
export const normalizeFeeOptions = (options: RawFeeOption[]): OnchainFeeOption[] =>
options
.map(o => ({
feeIndex: o.fee_index,
feeReserve:
typeof o.fee_reserve === 'number' ? o.fee_reserve : o.fee_reserve.toNumber(),
estimatedBlocks: o.estimated_blocks,
}))
.sort((a, b) => a.feeReserve - b.feeReserve)
/**
* Which fee tier to pre-select: the middle one, rounding to the cheaper side.
*
* The "normal" choice — fast enough not to strand the payment, cheap enough not to
* quietly overspend. With an even number of tiers there is no true middle, so we
* take the cheaper of the two: the user is always free to pay more, and a wallet
* should never round a fee UP on the user's behalf without being asked.
*
* Expects the sorted output of `normalizeFeeOptions`. Returns undefined only when the
* mint returned no tiers at all, which the spec forbids ("The mint MUST return at
* least one fee_options item") — callers treat that as a broken quote rather than
* inventing a fee.
*/
export const selectDefaultFeeOption = (
options: OnchainFeeOption[],
): OnchainFeeOption | undefined => {
if (options.length === 0) return undefined
return options[Math.floor((options.length - 1) / 2)]
}
/** Look up a tier by the mint's `fee_index`. Undefined if the mint never offered it. */
export const findFeeOption = (
options: OnchainFeeOption[],
feeIndex: number,
): OnchainFeeOption | undefined => options.find(o => o.feeIndex === feeIndex)
/**
* Total that must be covered by the inputs of an onchain melt.
*
* `amount + fee_reserve + input_fee`, per NUT-30. The mint may keep the whole
* `fee_reserve` ("the mint is entitled to claim the full selected_fee_reserve as the
* actual fee") — anything it does not spend comes back as NUT-08 change.
*/
export const onchainMeltTotal = (
amount: number,
feeReserve: number,
inputFee: number = 0,
): number => amount + feeReserve + inputFee
@@ -7,17 +7,24 @@ import {OnchainOperationService} from './onchainOperations'
const {transactionsStore} = rootStoreInstance
/**
* Process all pending topups and expired lightning transfers, and check for
* onchain deposits.
* Process all pending topups and expired lightning transfers, and check on both
* directions of onchain money: deposits coming in, and melts going out.
*
* Topup polling is delegated to MintOperationService (mint quote lifecycle).
* Transfer expiry is delegated to MeltOperationService (lightning out lifecycle).
* Transfer expiry and the onchain melt sweep are delegated to MeltOperationService.
* Onchain deposits are delegated to OnchainOperationService.
*
* Note the onchain sweep is driven by QUOTES, not by pending transactions: an
* onchain address can be paid again after its transaction has COMPLETED, so
* walking pending transactions (as the bolt11 path does) would miss precisely the
* deposits that need catching.
* The two onchain sweeps are driven differently, and the asymmetry is deliberate:
*
* - DEPOSITS are QUOTE-driven. An onchain address can be paid again after its
* transaction has COMPLETED, so walking pending transactions (as the bolt11 path
* does) would miss precisely the deposits that need catching.
* - MELTS are TRANSACTION-driven. A melt quote is one-shot and terminal, so the
* pending transaction IS the outstanding work, and there is nothing to find that a
* transaction does not already point at.
*
* Neither uses a websocket or a poller: onchain settlement is bounded by block times,
* so this ~60s cadence is already far finer-grained than what it waits for.
*/
const handlePendingQueue = async (): Promise<void> => {
const pendingTopups = transactionsStore.getPendingTopups()
@@ -39,6 +46,7 @@ const handlePendingQueue = async (): Promise<void> => {
}
await OnchainOperationService.handleOnchainQuoteQueue()
await MeltOperationService.handlePendingOnchainTransferQueue()
}
export const PendingOperationService = {
@@ -169,12 +169,28 @@ const syncStateWithMintTask = async function (
try {
await _dispatchFinalize(tx)
completedTxIds.push(tId)
// Report the status the dispatch actually reached, not the one the
// SPENT proofs implied. They are the same on every rail but one:
// an onchain melt whose inputs are spent has only been BROADCAST,
// and `refresh` deliberately leaves it PENDING until the mint
// reports the transaction confirmed. Announcing COMPLETED here
// would tell the user their Bitcoin payment had landed while it was
// still sitting unconfirmed in the mempool.
const settled = transactionsStore.findById(tId) ?? tx
const reachedStatus = settled.status
if (reachedStatus === TransactionStatus.COMPLETED) {
completedTxIds.push(tId)
} else {
pendingTxIds.push(tId)
}
transactionStateUpdates.push({
tId,
amount: tx.amount,
spentByMintAmount: spentAmount,
updatedStatus: TransactionStatus.COMPLETED,
updatedStatus: reachedStatus,
})
} catch (e: any) {
log.error('[syncStateWithMintTask] finalize dispatch failed', {
@@ -261,7 +277,10 @@ const syncStateWithMintTask = async function (
const tx = transactionsStore.findById(tId)
if (!tx) continue
if (tx.type !== TransactionType.TRANSFER) {
if (
tx.type !== TransactionType.TRANSFER &&
tx.type !== TransactionType.TRANSFER_ONCHAIN
) {
log.warn(
'[syncStateWithMintTask] Unexpected non-TRANSFER tx in branch 3',
{tId, type: tx.type},
@@ -326,13 +345,21 @@ 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).
* Route a sync-confirmed-SPENT transaction to the appropriate operation API. 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.
* Only SEND, TRANSFER and TRANSFER_ONCHAIN are expected here — other types don't park
* proofs in PENDING that sync could later observe as SPENT.
*
* TRANSFER_ONCHAIN goes to `refresh`, NOT `finalize`, and the distinction is the whole
* point. On every other rail, the mint spending our inputs IS settlement. On onchain it
* is not: the mint takes the inputs when it BROADCASTS, and the payment is not settled
* until the transaction is mined — which may be many blocks later, or never, if the
* transaction is dropped. Finalizing here would report an unconfirmed payment as
* COMPLETED at exactly the moment it is least certain. `refresh` asks the mint for the
* quote state and only completes on PAID, leaving the transaction PENDING otherwise.
*/
async function _dispatchFinalize(tx: Transaction): Promise<void> {
switch (tx.type) {
@@ -342,6 +369,9 @@ async function _dispatchFinalize(tx: Transaction): Promise<void> {
case TransactionType.TRANSFER:
await TransferOperationApi.finalize(tx.id)
return
case TransactionType.TRANSFER_ONCHAIN:
await TransferOperationApi.refresh(tx.id)
return
default:
log.warn('[syncStateWithMintTask] Unexpected tx type in finalize dispatch', {
tId: tx.id,
@@ -7,15 +7,21 @@
* without growing a parameter for each new rail.
*
* Today's methods:
* - `bolt11`: lightning invoice melt (NUT-05, the only payment rail Minibits
* currently supports).
* - `bolt11`: lightning invoice melt (NUT-05).
* - `onchain`: Bitcoin onchain melt (NUT-30).
*
* 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.
* The two rails share ONE lifecycle. That is the point of this file: proof
* reservation, the preemptive swap, and the execute-error recovery matrix (re-check
* the quote, distinguish paid-despite-error from already-spent from pending-at-mint)
* are the most safety-critical code in the wallet, and there is exactly one copy of
* them. What actually differs between rails is small and local — where the fee
* reserve comes from, what identifies the payment, what the destination is called —
* and lives in `resolveTransferMethod` below.
*/
import {MeltQuoteBolt11Response} from '@cashu/cashu-ts'
import {MeltQuoteBolt11Response, MeltQuoteOnchainResponse} from '@cashu/cashu-ts'
import {TransactionType} from '../../../models/Transaction'
import {LightningUtils} from '../../lightning/lightningUtils'
export interface TransferMethodOptions {
/**
@@ -31,6 +37,26 @@ export interface TransferMethodOptions {
meltQuote: MeltQuoteBolt11Response
invoiceExpiry: Date
}
/**
* NUT-30 onchain melt.
* - `address`: the Bitcoin address the mint will pay. MAINNET only — see
* `BitcoinUtils.isPayableBitcoinAddress`.
* - `meltQuote`: the mint's melt quote. Unlike bolt11 it carries no single
* `fee_reserve` but a list of `fee_options` tiers, fixed for the quote's life.
* - `feeIndex`: the tier the user picked, by the mint's `fee_index` (which is an
* identifier, NOT a rank). Locks on execute — once the mint sets
* `selected_fee_index` it MUST NOT execute the quote with a different one.
* - `quoteExpiry`: when the QUOTE stops being executable. Emphatically not when
* the payment stops being settleable: a broadcast transaction can take many
* blocks to confirm, long after this passes. Nothing may expire a transfer on
* the strength of it once the melt has been submitted.
*/
onchain: {
address: string
meltQuote: MeltQuoteOnchainResponse
feeIndex: number
quoteExpiry: Date
}
}
export type TransferMethod = keyof TransferMethodOptions
@@ -44,8 +70,90 @@ export type TransferMethodPayload<M extends TransferMethod = TransferMethod> =
*
* Example:
* { method: 'bolt11', options: { encodedInvoice, meltQuote, invoiceExpiry } }
* { method: 'onchain', options: { address, meltQuote, feeIndex, quoteExpiry } }
*/
export type TransferMethodInput = {
method: 'bolt11'
options: TransferMethodOptions['bolt11']
[M in TransferMethod]: {method: M; options: TransferMethodOptions[M]}
}[TransferMethod]
/**
* Everything the shared transfer lifecycle needs to know about a rail, resolved from
* the method payload in one place.
*
* The lifecycle reads these instead of branching on `method` at each site it needs a
* fee or an expiry, so adding a rail means adding a case here rather than hunting for
* every `if (method === 'bolt11')` in a 1200-line file.
*/
export interface ResolvedTransferMethod {
method: TransferMethod
/** Transaction type this rail records. */
transactionType: TransactionType
/** Quote id, as the mint knows it. */
quoteId: string
/**
* The fee the mint may charge to settle the payment, on top of the amount.
* bolt11: the quote's `fee_reserve`. onchain: the SELECTED tier's `fee_reserve`.
* Either way the mint returns whatever it does not spend as NUT-08 change.
*/
feeReserve: number
/** Where the money is going, as the user typed or scanned it. */
paymentRequest: string
/** Rail-native payment identifier: the payment hash for bolt11, none for onchain. */
paymentId?: string
/** When the quote stops being executable. */
expiry: Date
/**
* Does an expired quote mean the transfer itself is dead?
*
* bolt11: yes — an expired invoice cannot be paid, so there is nothing to wait for.
* onchain: NO. The expiry bounds executing the QUOTE, not confirming the PAYMENT.
* Once the mint has broadcast, the transaction confirms on the chain's schedule
* and may well outlive the quote. Expiring the transfer then would mark a real,
* in-flight payment dead and hide it from the user.
*/
expiresPendingTransfer: boolean
}
export const resolveTransferMethod = (
input: TransferMethodInput,
): ResolvedTransferMethod => {
switch (input.method) {
case 'bolt11': {
const {meltQuote, encodedInvoice, invoiceExpiry} = input.options
return {
method: 'bolt11',
transactionType: TransactionType.TRANSFER,
quoteId: meltQuote.quote,
feeReserve: meltQuote.fee_reserve.toNumber(),
paymentRequest: encodedInvoice,
paymentId: LightningUtils.getInvoiceData(
LightningUtils.decodeInvoice(encodedInvoice),
).payment_hash,
expiry: invoiceExpiry,
expiresPendingTransfer: true,
}
}
case 'onchain': {
const {meltQuote, address, feeIndex, quoteExpiry} = input.options
const tier = meltQuote.fee_options.find(o => o.fee_index === feeIndex)
if (!tier) {
// The mint MUST reject a fee_index it never offered, so failing here
// saves a round-trip and a burned quote.
throw new Error(
`Fee index ${feeIndex} was not offered by the mint for quote ${meltQuote.quote}`,
)
}
return {
method: 'onchain',
transactionType: TransactionType.TRANSFER_ONCHAIN,
quoteId: meltQuote.quote,
feeReserve: tier.fee_reserve.toNumber(),
paymentRequest: address,
expiry: quoteExpiry,
expiresPendingTransfer: false,
}
}
}
}
@@ -1,7 +1,26 @@
/**
* Transfer (lightning melt) operation lifecycle API.
* Transfer (melt) operation lifecycle API — one lifecycle, two payment rails.
*
* Splits the historical monolithic `transferTask` into explicit lifecycle methods:
* Handles BOLT11 lightning melt (NUT-05) and Bitcoin onchain melt (NUT-30). The rails
* share every step below; what differs between them is resolved once, in
* `resolveTransferMethod` (see transferMethods.ts), 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 `_handleExecuteError` — that error matrix
* (paid-despite-error / already-spent / pending-at-mint / clean-unpaid) is the most
* safety-critical code in the wallet and must not be forked per rail.
*
* The one place the rails genuinely diverge is settlement:
*
* bolt11 — usually settles synchronously (PAID on the melt response). When it does
* not, a websocket + poller watch the quote.
* onchain — NEVER settles synchronously. NUT-30 mandates that the mint answer
* PENDING and broadcast in the background, so every onchain melt goes
* through the PENDING path and is resolved later by the pending-queue
* sweep. Confirmation is bounded by block times, so there is no websocket
* and no poller: a ~60s sweep is already far finer-grained than the thing
* it waits for. (Same reasoning as the onchain deposit watcher.)
*
* Lifecycle methods:
*
* prepare() → PreparedTransferData (DRAFT → PREPARED, melt reservation OPEN,
* preemptive swap done if beneficial)
@@ -30,6 +49,7 @@ import {
normalizeProofAmounts,
MeltProofsResponse,
MeltQuoteBolt11Response,
MeltQuoteOnchainResponse,
MeltQuoteState,
Mint as CashuMint,
Wallet as CashuWallet,
@@ -67,7 +87,16 @@ import {ProofReservation} from '../proofReservation'
import {Database, ReservationRow} from '../../sqlite'
import {poller} from '../../../utils/poller'
import {Err} from '../../../utils/AppError'
import {TransferMethodInput} from './transferMethods'
import {
ResolvedTransferMethod,
TransferMethod,
TransferMethodInput,
resolveTransferMethod,
} from './transferMethods'
import {BitcoinUtils} from '../../bitcoin/bitcoinUtils'
/** Any melt quote, whichever rail produced it. */
type AnyMeltQuote = MeltQuoteBolt11Response | MeltQuoteOnchainResponse
const {mintsStore, proofsStore, transactionsStore, walletStore} = rootStoreInstance
@@ -77,11 +106,11 @@ const {mintsStore, proofsStore, transactionsStore, walletStore} = rootStoreInsta
export interface PrepareTransferInput {
mintBalance: MintBalance
/** Amount the recipient receives (excludes lightning + mint fees). */
/** Amount the recipient receives (excludes network + mint fees). */
amount: number
unit: MintUnit
memo: string
/** Transfer method discriminator (currently only `bolt11`). */
/** Transfer method discriminator: `bolt11` or `onchain`. */
method: TransferMethodInput
/** NWC request that triggered this transfer (optional). */
nwcEvent?: NostrEvent
@@ -109,22 +138,40 @@ export interface PreparedTransferData {
mintUrl: string
unit: MintUnit
amountToTransfer: number
meltQuote: MeltQuoteBolt11Response
invoiceExpiry: Date
meltQuote: AnyMeltQuote
path: TransferPath
method: TransferMethodInput
/** The rail's facts, resolved once (fee reserve, expiry, tx type, quote id). */
resolved: ResolvedTransferMethod
/** 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
/**
* The network fee the mint may charge to settle: the quote's `fee_reserve` for
* bolt11, the SELECTED tier's `fee_reserve` for onchain. Whatever the mint does
* not spend comes back as NUT-08 change.
*/
feeReserve: number
/** Fee paid for the preemptive swap (0 if no swap ran). */
preemptiveSwapFeePaid: number
nwcEvent?: NostrEvent
}
/**
* Which audit-trail keys a rail writes its fees under.
*
* The numbers mean the same thing on both rails, but a transaction's data is read by
* humans looking at a support ticket — calling a miner fee "lightningFeePaid" would be
* actively misleading. bolt11 keeps its historical names so existing history renders
* unchanged.
*/
const FEE_KEYS: Record<TransferMethod, {reserve: string; paid: string}> = {
bolt11: {reserve: 'lightningFeeReserve', paid: 'lightningFeePaid'},
onchain: {reserve: 'onchainFeeReserve', paid: 'onchainFeePaid'},
}
// ─────────────────────────────────────────────────────────────────────────────
// prepare()
// ─────────────────────────────────────────────────────────────────────────────
@@ -135,17 +182,29 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
if (amount <= 0) {
throw new ValidationError('Amount to transfer must be above zero.')
}
if (method.method !== 'bolt11') {
if (method.method !== 'bolt11' && method.method !== 'onchain') {
throw new ValidationError(`Unsupported transfer method: ${(method as any).method}`)
}
const {meltQuote, encodedInvoice, invoiceExpiry} = method.options
const resolved = resolveTransferMethod(method)
const meltQuote = method.options.meltQuote
const mintUrl = mintBalance.mintUrl
const mintInstance = mintsStore.findByUrl(mintUrl)
if (!mintInstance) {
throw new ValidationError('Could not find mint', {mintUrl})
}
// Second line of defence on the destination network. The Pay screen already refuses
// non-mainnet addresses, but this is the last point before real money moves and an
// onchain payment cannot be taken back — so the check lives here too, where every
// caller (screen, NWC, a future one) must pass through it.
if (method.method === 'onchain' && !BitcoinUtils.isPayableBitcoinAddress(resolved.paymentRequest)) {
throw new ValidationError(
'Not a mainnet Bitcoin address. Minibits will not pay to testnet or regtest addresses.',
{address: resolved.paymentRequest},
)
}
// ── Create or load the draft transaction ────────────────────────────
let transaction: Transaction | undefined
let transactionData: TransactionData[] = []
@@ -168,9 +227,9 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
createdAt: new Date(),
})
transaction = await transactionsStore.addTransaction({
type: TransactionType.TRANSFER,
type: resolved.transactionType,
amount,
fee: meltQuote.fee_reserve.toNumber(),
fee: resolved.feeReserve,
unit,
data: JSON.stringify(transactionData),
memo,
@@ -183,23 +242,31 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
}
const transactionId = transaction.id
const paymentHash = LightningUtils.getInvoiceData(
LightningUtils.decodeInvoice(encodedInvoice),
).payment_hash
transaction.update({paymentId: paymentHash, quote: meltQuote.quote})
transaction.update({
quote: resolved.quoteId,
// The destination, so the transaction detail can show it (and, for onchain,
// so the user can check where their money actually went).
paymentRequest: resolved.paymentRequest,
// bolt11 has a payment hash; onchain has nothing equivalent until the mint
// broadcasts, at which point it gets an `outpoint` instead.
...(resolved.paymentId && {paymentId: resolved.paymentId}),
})
// ── Validations ─────────────────────────────────────────────────────
const lightningFeeReserve = meltQuote.fee_reserve.toNumber()
if (amount + lightningFeeReserve > mintBalance.balances[unit]!) {
const feeReserve = resolved.feeReserve
if (amount + feeReserve > mintBalance.balances[unit]!) {
throw new ValidationError(
'Mint balance is insufficient to cover the amount to transfer with the expected Lightning fees.',
'Mint balance is insufficient to cover the amount to transfer with the expected network fees.',
{transactionId},
)
}
if (isBefore(invoiceExpiry, new Date())) {
if (isBefore(resolved.expiry, new Date())) {
throw new ValidationError(
'This invoice has already expired and can not be paid.',
{invoiceExpiry, transactionId},
resolved.method === 'bolt11'
? 'This invoice has already expired and can not be paid.'
: 'This payment quote has expired. Please request a new one.',
{expiry: resolved.expiry, transactionId},
)
}
@@ -209,9 +276,10 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
const walletInstance = (await walletStore.getWallet(mintUrl, unit, {withSeed: true})) as CashuWallet
// Select proofs covering amount + lightning fee_reserve + the mint's
// per-proof input fee on the selected proofs. The helper iterates to a fixed
// point so the inputs always cover their own input fee — without it, the fee
// Select proofs covering amount + the network fee_reserve + the mint's per-proof
// input fee on the selected proofs — `amount + fee_reserve + input_fee`, which is
// what both NUT-05 and NUT-30 require the inputs to cover. The helper iterates to a
// fixed point so the inputs always cover their own input fee — without it, the fee
// computed on the first selection can be too low for the (larger) re-selected
// set and the mint rejects with "not enough inputs provided for melt".
let proofsToMeltFrom: Proof[]
@@ -219,7 +287,7 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
try {
;({proofsToSend: proofsToMeltFrom, feeReserve: meltFeeReserve} =
CashuUtils.selectProofsToSendWithFeeReserve(
amount + lightningFeeReserve,
amount + feeReserve,
proofsFromMint,
selected => walletInstance.getFeesForProofs(selected).toNumber(),
{caller: 'TransferOperationApi.prepare'},
@@ -233,7 +301,7 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
})
}
let amountWithFees = amount + lightningFeeReserve + meltFeeReserve
let amountWithFees = amount + feeReserve + meltFeeReserve
let proofsToMeltFromAmount = CashuUtils.getProofsAmount(proofsToMeltFrom)
// ── Preemptive swap path ────────────────────────────────────────────
@@ -319,10 +387,11 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
transactionData.push({
status: TransactionStatus.PREPARED,
proofsToMeltFromAmount,
lightningFeeReserve,
[FEE_KEYS[resolved.method].reserve]: feeReserve,
meltFeeReserve,
path,
method: method.method,
...(method.method === 'onchain' && {feeIndex: method.options.feeIndex}),
...(preemptiveSwapFeePaid > 0 && {preemptiveSwapFeePaid}),
createdAt: new Date(),
})
@@ -349,10 +418,11 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
log.debug('[TransferOperationApi.prepare]', 'Prepared', {
transactionId,
method: resolved.method,
path,
amount,
meltFeeReserve,
lightningFeeReserve,
feeReserve,
preemptiveSwapFeePaid,
lockedCount: proofsToMeltFrom.length,
})
@@ -364,13 +434,13 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
unit,
amountToTransfer: amount,
meltQuote,
invoiceExpiry,
path,
method,
resolved,
proofsToMeltFrom,
proofsToMeltFromAmount,
meltFeeReserve,
lightningFeeReserve,
feeReserve,
preemptiveSwapFeePaid,
nwcEvent,
}
@@ -379,12 +449,16 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
// ─────────────────────────────────────────────────────────────────────────────
// execute()
//
// Marks tx EXECUTING, calls payLightningMelt, then commits atomically based on
// the mint's quote state:
// Marks tx EXECUTING, submits the melt, 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.
// - PENDING: inputs stay PENDING, change committed IF the mint already returned
// any, tx → PENDING; the watcher/monitor resolves it later.
// - UNPAID: rollback reservation (proofs → UNSPENT) and throw.
//
// An onchain melt ALWAYS lands in PENDING — NUT-30 requires the mint to answer
// PENDING and broadcast in the background. It is never PAID here.
//
// 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.
@@ -418,6 +492,8 @@ async function execute(
unit,
amountToTransfer,
meltQuote,
method,
resolved,
proofsToMeltFrom,
proofsToMeltFromAmount,
meltFeeReserve,
@@ -425,12 +501,29 @@ async function execute(
tx.update({status: TransactionStatus.EXECUTING})
let meltResponse: MeltProofsResponse
try {
meltResponse = await walletStore.payLightningMelt(
/**
* Submit the melt on whichever rail this transfer is on.
*
* `increaseCounterBy` is the shared outputs-error healing path: the mint says our
* blinded outputs were already signed, so we skip the counter forward and retry.
*/
const submitMelt = (increaseCounterBy?: number): Promise<MeltProofsResponse> => {
if (method.method === 'onchain') {
return walletStore.payOnchainMelt(
mintUrl,
unit,
method.options.meltQuote,
proofsToMeltFrom,
method.options.feeIndex,
tx.id,
increaseCounterBy ? {increaseCounterBy} : undefined,
)
}
return walletStore.payLightningMelt(
mintUrl,
unit,
meltQuote,
method.options.meltQuote,
proofsToMeltFrom,
tx.id,
// Always async — including NWC. The mint ACKs immediately and the
@@ -438,22 +531,20 @@ async function execute(
// the lightning round-trip. NWC pay_invoice waits a bounded time for
// the preimage (see NwcStore.payInvoice); zaps confirm via the NIP-57
// receipt regardless.
{preferAsync: true},
{preferAsync: true, ...(increaseCounterBy && {increaseCounterBy})},
)
}
let meltResponse: MeltProofsResponse
try {
meltResponse = await submitMelt()
} catch (e: any) {
if (WalletUtils.shouldHealOutputsError(e)) {
log.error(
'[TransferOperationApi.execute] Increasing proofsCounter outdated values and repeating payLightningMelt.',
'[TransferOperationApi.execute] Increasing proofsCounter outdated values and repeating the melt.',
)
try {
meltResponse = await walletStore.payLightningMelt(
mintUrl,
unit,
meltQuote,
proofsToMeltFrom,
tx.id,
{increaseCounterBy: 10, preferAsync: true},
)
meltResponse = await submitMelt(10)
} catch (e2: any) {
return _handleExecuteError(e2, {
tx,
@@ -473,11 +564,14 @@ async function execute(
}
// ── PAID synchronously → finalize now ───────────────────────────────
// bolt11 only. An onchain melt is never PAID at this point (NUT-30 mandates the
// mint answer PENDING and broadcast in the background).
if (meltResponse.quote.state === MeltQuoteState.PAID) {
const returnedAmount = CashuUtils.getProofsAmount(meltResponse.change)
const totalFeePaid = proofsToMeltFromAmount - amountToTransfer - returnedAmount
const lightningFeePaid = totalFeePaid - meltFeeReserve
const networkFeePaid = totalFeePaid - meltFeeReserve
const meltFeePaid = meltFeeReserve
const preimage = _preimageOf(meltResponse.quote)
let outputToken: string | undefined
if (meltResponse.change.length > 0) {
@@ -493,11 +587,10 @@ async function execute(
transactionData.push({
status: TransactionStatus.COMPLETED,
lightningFeePaid,
[FEE_KEYS[resolved.method].paid]: networkFeePaid,
meltFeePaid,
returnedAmount,
//@ts-ignore — payment_preimage is loosely typed in cashu-ts
preimage: meltResponse.quote.payment_preimage,
preimage,
createdAt: new Date(),
})
@@ -514,40 +607,77 @@ async function execute(
fee: totalFeePaid,
balanceAfter,
...(outputToken && {outputToken}),
//@ts-ignore — payment_preimage is loosely typed in cashu-ts
...(meltResponse.quote.payment_preimage && {proof: meltResponse.quote.payment_preimage}),
...(preimage && {proof: preimage}),
},
})
log.debug('[TransferOperationApi.execute] Invoice PAID', {transactionId: tx.id, totalFeePaid})
log.debug('[TransferOperationApi.execute] Payment PAID', {transactionId: tx.id, totalFeePaid})
return _assertCompleted(tx, tx.id)
}
// ── PENDING async → tx PENDING, monitor will finalize via refresh ───
// ── PENDING async → tx PENDING; the watcher/monitor finalizes via refresh ───
if (meltResponse.quote.state === MeltQuoteState.PENDING) {
const outpoint = _outpointOf(meltResponse.quote)
// CHANGE MAY ALREADY BE HERE. On bolt11 a PENDING melt has no change yet — the
// fee is not known until the payment settles. On onchain it can: the mint knows
// exactly what it is paying in miner fees the moment it builds the transaction,
// so it can return the unclaimed reserve straight away, with the PENDING
// response. Dropping it (as the bolt11 path safely does) would strand those
// proofs — they are signed, they are ours, and nothing would ever look for them
// again, because `refresh` only reconstructs change it has not already taken.
const change = meltResponse.change ?? []
const returnedAmount = CashuUtils.getProofsAmount(change)
let outputToken: string | undefined
if (change.length > 0) {
outputToken = getEncodedToken({mint: mintUrl, proofs: change, unit})
}
const currentSpendable = proofsStore.getUnitBalance(unit)?.unitBalance ?? 0
const balanceAfter = currentSpendable + returnedAmount
transactionData.push({
status: TransactionStatus.PENDING,
...(outpoint && {outpoint}),
...(change.length > 0 && {returnedAmount}),
createdAt: new Date(),
})
proofsStore.commitReservation(reservation, {
// Inputs stay PENDING: the mint has taken them but the payment has not
// settled. Only `refresh` (on a PAID quote) moves them to SPENT.
newProofs:
change.length > 0
? [{proofs: change, state: 'UNSPENT', tId: tx.id}]
: [],
transactionUpdate: {
id: tx.id,
status: TransactionStatus.PENDING,
data: JSON.stringify(transactionData),
...(outpoint && {outpoint}),
...(change.length > 0 && {balanceAfter, outputToken}),
},
})
_monitorAsyncMeltQuote({
mintUrl,
unit,
quoteId: meltResponse.quote.quote,
transactionId: tx.id,
})
// bolt11 gets a websocket + poller. Onchain does not: confirmation is bounded by
// block times, so the ~60s pending-queue sweep is already far finer-grained than
// the thing it waits for, and a 2-minute poller would just burn requests.
if (resolved.method === 'bolt11') {
_monitorAsyncMeltQuote({
mintUrl,
unit,
quoteId: meltResponse.quote.quote,
transactionId: tx.id,
})
}
log.debug('[TransferOperationApi.execute] Invoice PENDING, async melt in progress', {
log.debug('[TransferOperationApi.execute] Payment PENDING, async melt in progress', {
method: resolved.method,
quoteId: meltResponse.quote.quote,
transactionId: tx.id,
outpoint,
returnedAmount,
})
const refreshed = transactionsStore.findById(tx.id)!
@@ -563,10 +693,15 @@ async function execute(
// ── 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,
})
throw new MintError(
resolved.method === 'onchain'
? 'The onchain payment has not been made.'
: 'Lightning payment has not been paid.',
{
meltResponseQuote: meltResponse.quote,
transactionId: tx.id,
},
)
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -669,7 +804,7 @@ async function finalize(transactionId: number): Promise<CompletedTransaction> {
throw new ValidationError('Transfer has no quote id; cannot finalize.', {transactionId})
}
const quote = await walletStore.checkLightningMeltQuote(tx.mint, tx.quote)
const quote = await _checkQuote(tx, tx.quote)
if (quote.state !== MeltQuoteState.PAID) {
throw new MintError(
`Cannot finalize transfer; mint reports quote state ${quote.state}.`,
@@ -704,25 +839,32 @@ async function refresh(transactionId: number): Promise<Transaction> {
return tx
}
const quote = await walletStore.checkLightningMeltQuote(tx.mint, tx.quote)
const isOnchain = _isOnchainTransfer(tx)
const quote = await _checkQuote(tx, tx.quote)
if (quote.state === MeltQuoteState.PAID) {
const completed = await _finalizePaid(tx, quote)
EventEmitter.emit('ev_asyncMeltResult', {
transactionId,
status: TransactionStatus.COMPLETED,
message: translate('transactionResult_lightningInvoicePaidFee', {
fee: `${formatCurrency(tx.fee, getCurrency(tx.unit).code)} ${getCurrency(tx.unit).code}`,
}),
message: isOnchain
? translate('transactionResult_onchainPaymentConfirmed')
: translate('transactionResult_lightningInvoicePaidFee', {
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.
// The payment 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.)
//
// For onchain this means the mint never broadcast, or dropped the transaction
// before it was mined. A CONFIRMED payment can never come back here: once it is
// in a block the mint reports PAID, and PAID is terminal.
const pendingProofs = proofsStore
.getByTransactionId(tx.id)
.filter(p => p.state === 'PENDING')
@@ -730,10 +872,14 @@ async function refresh(transactionId: number): Promise<Transaction> {
proofsStore.revertToSpendable(pendingProofs)
}
const failureMessage = isOnchain
? translate('transactionResult_onchainPaymentFailed')
: translate('transactionResult_lightningPaymentFailed')
const txData = _parseData(tx)
txData.push({
status: TransactionStatus.REVERTED,
message: translate('transactionResult_lightningPaymentFailed'),
message: failureMessage,
createdAt: new Date(),
})
tx.update({status: TransactionStatus.REVERTED, data: JSON.stringify(txData)})
@@ -743,12 +889,26 @@ async function refresh(transactionId: number): Promise<Transaction> {
EventEmitter.emit('ev_asyncMeltResult', {
transactionId,
status: TransactionStatus.REVERTED,
message: translate('transactionResult_lightningPaymentFailed'),
message: failureMessage,
})
return tx
}
// PENDING: ws/poller will call back later.
// ── Still PENDING ───────────────────────────────────────────────────
// For onchain, the mint may only have broadcast between our last check and this
// one — so the outpoint can appear now, while the state has not moved. Record it
// as soon as it exists: it is the only way the user can follow their payment on a
// block explorer, independently of the mint, and it is the thing they will ask for
// if the mint goes quiet.
const outpoint = _outpointOf(quote)
if (outpoint && !tx.outpoint) {
tx.update({outpoint})
log.debug('[TransferOperationApi.refresh] Onchain payment broadcast', {
transactionId,
outpoint,
})
}
return tx
}
@@ -756,6 +916,50 @@ async function refresh(transactionId: number): Promise<Transaction> {
// Private helpers
// ─────────────────────────────────────────────────────────────────────────────
/**
* The proof-of-payment a rail produces, if any.
*
* bolt11 settles with a preimage. Onchain has no preimage — its evidence is the
* `outpoint`, handled separately — so this is simply absent there, and the tx's
* `proof` column stays empty rather than holding something invented.
*/
function _preimageOf(quote: object): string | undefined {
const preimage = (quote as MeltQuoteBolt11Response).payment_preimage
return preimage ?? undefined
}
/**
* `txid:vout` of the onchain payment, once the mint has broadcast it.
*
* Null until then, and never present on bolt11. This is the only handle the user has
* on an onchain payment: with it they can watch the transaction confirm on any block
* explorer, independently of the mint.
*/
function _outpointOf(quote: object): string | undefined {
const outpoint = (quote as MeltQuoteOnchainResponse).outpoint
return outpoint ?? undefined
}
/** Is this transaction an onchain melt? The tx type is the discriminator. */
function _isOnchainTransfer(tx: Transaction): boolean {
return tx.type === TransactionType.TRANSFER_ONCHAIN
}
/**
* Ask the mint for the current state of a transfer's quote, on the right rail.
*
* For onchain this — and ONLY this — is what says whether the payment settled. The
* mint spending our inputs means it BROADCAST; it does not mean the transaction
* confirmed. Reading settlement off proof state (as sync does for bolt11) would
* complete an onchain transfer the moment it left the mint, which is exactly when it
* is least certain.
*/
async function _checkQuote(tx: Transaction, quoteId: string): Promise<AnyMeltQuote> {
return _isOnchainTransfer(tx)
? await walletStore.checkOnchainMeltQuote(tx.mint, quoteId)
: await walletStore.checkLightningMeltQuote(tx.mint, quoteId)
}
/**
* 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
@@ -771,11 +975,11 @@ async function _handleExecuteError(
},
): Promise<never> {
const {tx, transactionData, reservation, prepared} = ctx
const {mintUrl, unit, meltQuote, proofsToMeltFrom, proofsToMeltFromAmount} = prepared
const {mintUrl, unit, resolved, proofsToMeltFrom, proofsToMeltFromAmount} = prepared
let meltQuoteCheck: MeltQuoteBolt11Response
let meltQuoteCheck: AnyMeltQuote
try {
meltQuoteCheck = await walletStore.checkLightningMeltQuote(mintUrl, meltQuote.quote)
meltQuoteCheck = await _checkQuote(tx, resolved.quoteId)
} catch (checkError: any) {
// Quote check itself failed — leave the reservation as-is, the orphan
// recovery sweep + sync will reconcile on the next startup.
@@ -891,11 +1095,12 @@ async function _handleExecuteError(
*/
async function _finalizePaid(
tx: Transaction,
quote: MeltQuoteBolt11Response,
quote: AnyMeltQuote,
): Promise<CompletedTransaction> {
const transactionId = tx.id
const mintUrl = tx.mint
const unit = tx.unit
const method: TransferMethod = _isOnchainTransfer(tx) ? 'onchain' : 'bolt11'
// 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
@@ -915,9 +1120,14 @@ async function _finalizePaid(
: (_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
// An onchain melt may have had its change returned ALREADY, on the PENDING melt
// response (the mint knows its miner fee as soon as it builds the transaction).
// That change is banked and `_unblindMeltChange` will correctly find nothing left
// to reconstruct — but it is still not fee. Counting it here would report the
// user's own returned money as money they spent. Read it before pushing this
// status entry, which writes a `returnedAmount` of its own.
const alreadyReturned = _readNumberFromData(tx, 'returnedAmount') ?? 0
// Unblind change BEFORE opening the reservation; same fallback behaviour as
// the pre-reservation code — change recovery failure doesn't block finalize.
@@ -929,26 +1139,32 @@ async function _finalizePaid(
quoteChange: quote.change,
})
let returnedAmount = 0
let returnedNow = 0
let outputToken: string | undefined
if (unblinded.change.length > 0) {
returnedAmount = CashuUtils.getProofsAmount(unblinded.change)
returnedNow = CashuUtils.getProofsAmount(unblinded.change)
outputToken = getEncodedToken({mint: mintUrl, proofs: unblinded.change, unit})
totalFeePaid -= returnedAmount
lightningFeePaid = totalFeePaid - meltFeeReserve
}
const returnedAmount = alreadyReturned + returnedNow
const totalFeePaid = proofsToMeltFromAmount - amountToTransfer - returnedAmount
const networkFeePaid = totalFeePaid - meltFeeReserve
const meltFeePaid = meltFeeReserve
const currentSpendable = proofsStore.getUnitBalance(unit)?.unitBalance ?? 0
const balanceAfter = currentSpendable + returnedAmount
const balanceAfter = currentSpendable + returnedNow
const preimage = _preimageOf(quote)
const outpoint = _outpointOf(quote)
const txData = _parseData(tx)
txData.push({
status: TransactionStatus.COMPLETED,
lightningFeePaid,
[FEE_KEYS[method].paid]: networkFeePaid,
meltFeePaid,
returnedAmount,
//@ts-ignore
preimage: quote.payment_preimage,
...(preimage && {preimage}),
...(outpoint && {outpoint}),
createdAt: new Date(),
})
@@ -986,14 +1202,16 @@ async function _finalizePaid(
fee: totalFeePaid,
balanceAfter,
...(outputToken && {outputToken}),
//@ts-ignore — payment_preimage is loosely typed in cashu-ts
...(quote.payment_preimage && {proof: quote.payment_preimage}),
...(preimage && {proof: preimage}),
...(outpoint && {outpoint}),
},
})
log.debug('[TransferOperationApi._finalizePaid] Transaction completed', {
transactionId,
method,
totalFeePaid,
returnedAmount,
})
return _assertCompleted(tx, transactionId)
}
+119 -1
View File
@@ -1,4 +1,4 @@
import {MeltQuoteBolt11Response} from '@cashu/cashu-ts'
import {MeltQuoteBolt11Response, MeltQuoteOnchainResponse} from '@cashu/cashu-ts'
import {rootStoreInstance} from '../../models'
import {TransactionTaskResult} from '../walletService'
import {MintBalance} from '../../models/Mint'
@@ -12,6 +12,7 @@ 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.
@@ -134,3 +135,120 @@ export const transferTask = async function (
} 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
}
}
+15 -2
View File
@@ -6,7 +6,7 @@ import {Proof, ProofState} from '../models/Proof'
import {
Transaction,
} from '../models/Transaction'
import {MeltQuoteBolt11Response, TokenMetadata} from '@cashu/cashu-ts'
import {MeltQuoteBolt11Response, MeltQuoteOnchainResponse, TokenMetadata} from '@cashu/cashu-ts'
import {Mint, MintBalance} from '../models/Mint'
import {NostrEvent} from './nostrService'
import {Contact} from '../models/Contact'
@@ -86,6 +86,18 @@ type WalletTaskService = {
nwcEvent?: NostrEvent,
draftTransactionId?: number,
) => Promise<TransactionTaskResult>
transferOnchainQueueAwaitable: (
mintBalanceToTransferFrom: MintBalance,
amountToTransfer: number,
unit: MintUnit,
meltQuote: MeltQuoteOnchainResponse,
feeIndex: number,
memo: string,
quoteExpiry: Date,
address: string,
nwcEvent?: NostrEvent,
draftTransactionId?: number,
) => Promise<TransactionTaskResult>
receiveQueueAwaitable: (
mint: Mint,
tokenMetadata: TokenMetadata,
@@ -133,7 +145,7 @@ type WalletTaskService = {
}) => Promise<{recoveredAmount: number}>
recoverMeltQuoteChange: (params: {
mintUrl: string
meltQuote: string | MeltQuoteBolt11Response
meltQuote: string | MeltQuoteBolt11Response | MeltQuoteOnchainResponse
}) => Promise<{recoveredAmount: number}>
handlePendingMeltTask: (params: {
mintUrl: string
@@ -191,6 +203,7 @@ export const WalletTask: WalletTaskService = {
recoverMintQuote: MintOperationService.recoverMintQuote,
// Melt (transfer)
transferQueueAwaitable: MeltOperationService.transferQueueAwaitable,
transferOnchainQueueAwaitable: MeltOperationService.transferOnchainQueueAwaitable,
recoverMeltQuoteChange: MeltOperationService.recoverMeltQuoteChange,
handlePendingMeltTask: MeltOperationService.handlePendingMeltTask,
// Revert