From 6b5b1da3565c1d9f8618963f0ea654f32090190a Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Tue, 14 Jul 2026 16:30:15 +0200 Subject: [PATCH] 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. --- __tests__/bitcoinUtils.test.ts | 236 ++++++++++ __tests__/onchainMeltAmounts.test.ts | 154 +++++++ __tests__/transferOperationLifecycle.test.ts | 9 +- src/i18n_messages/en.json | 3 + src/i18n_messages/es.json | 3 + src/i18n_messages/pt.json | 3 + src/i18n_messages/sk.json | 3 + src/models/TransactionsStore.ts | 6 + src/models/WalletStore.ts | 214 ++++++++- src/services/bitcoin/bitcoinUtils.ts | 252 +++++++++++ src/services/db/index.ts | 2 + src/services/db/transactionsRepo.ts | 32 ++ .../wallet/operations/inFlightOperations.ts | 18 +- .../wallet/operations/meltOperations.ts | 128 +++++- .../wallet/operations/onchainAmounts.ts | 120 +++++- .../wallet/operations/pendingOperations.ts | 22 +- .../wallet/operations/syncOperations.ts | 48 ++- .../wallet/operations/transferMethods.ts | 124 +++++- .../wallet/operations/transferOperationApi.ts | 406 ++++++++++++++---- src/services/wallet/transferTask.ts | 120 +++++- src/services/walletService.ts | 17 +- 21 files changed, 1780 insertions(+), 140 deletions(-) create mode 100644 __tests__/bitcoinUtils.test.ts create mode 100644 __tests__/onchainMeltAmounts.test.ts create mode 100644 src/services/bitcoin/bitcoinUtils.ts diff --git a/__tests__/bitcoinUtils.test.ts b/__tests__/bitcoinUtils.test.ts new file mode 100644 index 00000000..ed8b91e1 --- /dev/null +++ b/__tests__/bitcoinUtils.test.ts @@ -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() + }) +}) diff --git a/__tests__/onchainMeltAmounts.test.ts b/__tests__/onchainMeltAmounts.test.ts new file mode 100644 index 00000000..65aa4b2d --- /dev/null +++ b/__tests__/onchainMeltAmounts.test.ts @@ -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) + }) +}) diff --git a/__tests__/transferOperationLifecycle.test.ts b/__tests__/transferOperationLifecycle.test.ts index 6f3da4f5..22d35596 100644 --- a/__tests__/transferOperationLifecycle.test.ts +++ b/__tests__/transferOperationLifecycle.test.ts @@ -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', ] diff --git a/src/i18n_messages/en.json b/src/i18n_messages/en.json index 52dedc78..bb6fbb03 100644 --- a/src/i18n_messages/en.json +++ b/src/i18n_messages/en.json @@ -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).", diff --git a/src/i18n_messages/es.json b/src/i18n_messages/es.json index eecbd5cd..dd8db427 100644 --- a/src/i18n_messages/es.json +++ b/src/i18n_messages/es.json @@ -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).", diff --git a/src/i18n_messages/pt.json b/src/i18n_messages/pt.json index ad1ca382..cc2f99a0 100644 --- a/src/i18n_messages/pt.json +++ b/src/i18n_messages/pt.json @@ -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).", diff --git a/src/i18n_messages/sk.json b/src/i18n_messages/sk.json index 9883cd4d..2e8e2372 100644 --- a/src/i18n_messages/sk.json +++ b/src/i18n_messages/sk.json @@ -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á)", diff --git a/src/models/TransactionsStore.ts b/src/models/TransactionsStore.ts index 462f7cc1..e0cd30e9 100644 --- a/src/models/TransactionsStore.ts +++ b/src/models/TransactionsStore.ts @@ -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 })) + }, })) diff --git a/src/models/WalletStore.ts b/src/models/WalletStore.ts index 6e7764eb..b0d8c31c 100644 --- a/src/models/WalletStore.ts +++ b/src/models/WalletStore.ts @@ -4,6 +4,7 @@ import { Wallet as CashuWallet, KeyChain as CashuKeyChain, MeltQuoteBolt11Response, + MeltQuoteOnchainResponse, setGlobalRequestOptions, type MintKeys, type MintKeyset, @@ -1296,16 +1297,221 @@ export const WalletStoreModel = types let message = 'The mint could not return the state of a melt quote.' if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions; throw new AppError( - Err.MINT_ERROR, - message, + Err.MINT_ERROR, + message, { message: e.message, - caller: 'checkLightningMeltQuote', - mintUrl, + caller: 'checkLightningMeltQuote', + mintUrl, } ) } }), + /** + * 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 = 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 = + 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, diff --git a/src/services/bitcoin/bitcoinUtils.ts b/src/services/bitcoin/bitcoinUtils.ts new file mode 100644 index 00000000..9133c2b9 --- /dev/null +++ b/src/services/bitcoin/bitcoinUtils.ts @@ -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 = { + bc: 'mainnet', + tb: 'testnet', + bcrt: 'regtest', +} + +/** Base58 version bytes. */ +const BASE58_VERSIONS: Record = { + 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, +} diff --git a/src/services/db/index.ts b/src/services/db/index.ts index c1648740..38bfdb89 100644 --- a/src/services/db/index.ts +++ b/src/services/db/index.ts @@ -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, diff --git a/src/services/db/transactionsRepo.ts b/src/services/db/transactionsRepo.ts index b5903062..6c8f97c8 100644 --- a/src/services/db/transactionsRepo.ts +++ b/src/services/db/transactionsRepo.ts @@ -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 { diff --git a/src/services/wallet/operations/inFlightOperations.ts b/src/services/wallet/operations/inFlightOperations.ts index 05851d01..4a42bd40 100644 --- a/src/services/wallet/operations/inFlightOperations.ts +++ b/src/services/wallet/operations/inFlightOperations.ts @@ -320,11 +320,19 @@ const handleInFlightByMintTask = async (mint: Mint): Promise = 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 } diff --git a/src/services/wallet/operations/meltOperations.ts b/src/services/wallet/operations/meltOperations.ts index e409a5e6..5c13ac5d 100644 --- a/src/services/wallet/operations/meltOperations.ts +++ b/src/services/wallet/operations/meltOperations.ts @@ -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 => + createQueueAwaitable({ + 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 => { + 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, } diff --git a/src/services/wallet/operations/onchainAmounts.ts b/src/services/wallet/operations/onchainAmounts.ts index 4ac27c40..a56a7f2e 100644 --- a/src/services/wallet/operations/onchainAmounts.ts +++ b/src/services/wallet/operations/onchainAmounts.ts @@ -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 diff --git a/src/services/wallet/operations/pendingOperations.ts b/src/services/wallet/operations/pendingOperations.ts index f9c85c54..6d576d19 100644 --- a/src/services/wallet/operations/pendingOperations.ts +++ b/src/services/wallet/operations/pendingOperations.ts @@ -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 => { const pendingTopups = transactionsStore.getPendingTopups() @@ -39,6 +46,7 @@ const handlePendingQueue = async (): Promise => { } await OnchainOperationService.handleOnchainQuoteQueue() + await MeltOperationService.handlePendingOnchainTransferQueue() } export const PendingOperationService = { diff --git a/src/services/wallet/operations/syncOperations.ts b/src/services/wallet/operations/syncOperations.ts index cb482154..db46a939 100644 --- a/src/services/wallet/operations/syncOperations.ts +++ b/src/services/wallet/operations/syncOperations.ts @@ -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 { switch (tx.type) { @@ -342,6 +369,9 @@ async function _dispatchFinalize(tx: Transaction): Promise { 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, diff --git a/src/services/wallet/operations/transferMethods.ts b/src/services/wallet/operations/transferMethods.ts index 8d950475..d3f141d4 100644 --- a/src/services/wallet/operations/transferMethods.ts +++ b/src/services/wallet/operations/transferMethods.ts @@ -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 = * * 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, + } + } + } } diff --git a/src/services/wallet/operations/transferOperationApi.ts b/src/services/wallet/operations/transferOperationApi.ts index 068968d3..068c66a7 100644 --- a/src/services/wallet/operations/transferOperationApi.ts +++ b/src/services/wallet/operations/transferOperationApi.ts @@ -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 = { + bolt11: {reserve: 'lightningFeeReserve', paid: 'lightningFeePaid'}, + onchain: {reserve: 'onchainFeeReserve', paid: 'onchainFeePaid'}, +} + // ───────────────────────────────────────────────────────────────────────────── // prepare() // ───────────────────────────────────────────────────────────────────────────── @@ -135,17 +182,29 @@ async function prepare(input: PrepareTransferInput): Promise 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 walletInstance.getFeesForProofs(selected).toNumber(), {caller: 'TransferOperationApi.prepare'}, @@ -233,7 +301,7 @@ async function prepare(input: PrepareTransferInput): Promise 0 && {preemptiveSwapFeePaid}), createdAt: new Date(), }) @@ -349,10 +418,11 @@ async function prepare(input: PrepareTransferInput): Promise => { + 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 { 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 { 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 { 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 { 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 { // 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 { + 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 { 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 { 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) } diff --git a/src/services/wallet/transferTask.ts b/src/services/wallet/transferTask.ts index d6caa498..e948304e 100644 --- a/src/services/wallet/transferTask.ts +++ b/src/services/wallet/transferTask.ts @@ -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 { + 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 + } +} diff --git a/src/services/walletService.ts b/src/services/walletService.ts index 6e19fd90..482c0c5d 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -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 + transferOnchainQueueAwaitable: ( + mintBalanceToTransferFrom: MintBalance, + amountToTransfer: number, + unit: MintUnit, + meltQuote: MeltQuoteOnchainResponse, + feeIndex: number, + memo: string, + quoteExpiry: Date, + address: string, + nwcEvent?: NostrEvent, + draftTransactionId?: number, + ) => Promise 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