mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 11:18:24 +00:00
Stage 6a: onchain melt service layer (NUT-30)
One melt lifecycle, two rails. TransferOperationApi now handles both bolt11 (NUT-05) and onchain (NUT-30) melts; what differs between them is resolved once in resolveTransferMethod rather than branched on at each site that needs a fee or an expiry. There is deliberately one copy of the proof reservation, the preemptive swap, and the execute-error recovery matrix. cashu-ts' prepareMelt is already method-agnostic (it derives the NUT-08 blank count from inputs - quote.amount, not from fee_reserve), and fee_index rides along as extraPayload on completeMelt. So the prepare -> persist meltPreview -> complete split that melt-change recovery depends on survives intact. Substance, beyond the plumbing: - Onchain change can arrive at PENDING. The mint knows its miner fee the moment it builds the transaction, so it may return the unclaimed reserve with the spec-mandated PENDING response. bolt11's PENDING branch drops change on the floor (correctly - there is none yet); doing that here would strand signed proofs that nothing would ever look for again. execute() now commits change if present, and _finalizePaid subtracts what was already returned so banked change is not reported as fee. - Settlement is quote-driven, not proof-driven. The mint spending our inputs means it BROADCAST, not that the transaction confirmed. sync's _dispatchFinalize therefore routes TRANSFER_ONCHAIN to refresh() (which asks the mint and only completes on PAID) rather than finalize(), and sync now reports the status the dispatch actually reached instead of assuming COMPLETED - otherwise it would announce a Bitcoin payment as landed while it sat unconfirmed in the mempool. - Onchain transfers are never expired. The melt quote's expiry bounds executing the quote, not confirming the payment, which can outlive it by many blocks. - Mainnet only. The CDK fakewallet hands out regtest deposit addresses for topup quotes, so testers end up with one in their clipboard; pasting it back into Pay must not spend. Refused in the parser and again in prepare(), so a screen that forgets the check cannot move money. No websocket and no poller for onchain: settlement is bounded by block times, so the existing ~60s pending sweep is already finer-grained than what it waits for. Melts go through SyncQueue for the same counter-serialisation reason mints do. 87 tsc errors (unchanged baseline), 310/310 tests, i18n clean.
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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á)",
|
||||
|
||||
@@ -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 }))
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Wallet as CashuWallet,
|
||||
KeyChain as CashuKeyChain,
|
||||
MeltQuoteBolt11Response,
|
||||
MeltQuoteOnchainResponse,
|
||||
setGlobalRequestOptions,
|
||||
type MintKeys,
|
||||
type MintKeyset,
|
||||
@@ -1306,6 +1307,211 @@ export const WalletStoreModel = types
|
||||
)
|
||||
}
|
||||
}),
|
||||
/**
|
||||
* Ask the mint what it would charge to send `amount` to a Bitcoin address (NUT-30).
|
||||
*
|
||||
* Unlike bolt11 the amount is not carried by the payment request, so this cannot be
|
||||
* called until the user has entered one. The quote comes back with a list of
|
||||
* `fee_options` tiers rather than a single `fee_reserve`; they are fixed for the
|
||||
* quote's lifetime, and the caller picks one at execute time.
|
||||
*/
|
||||
createOnchainMeltQuote: flow(function* createOnchainMeltQuote(
|
||||
mintUrl: string,
|
||||
unit: MintUnit,
|
||||
address: string,
|
||||
amount: number,
|
||||
) {
|
||||
try {
|
||||
const cashuMint: CashuMint = yield self.getMint(mintUrl)
|
||||
const onchainQuote: MeltQuoteOnchainResponse = yield cashuMint.createMeltQuoteOnchain({
|
||||
unit,
|
||||
request: address,
|
||||
amount,
|
||||
})
|
||||
|
||||
log.info('[createOnchainMeltQuote]', {mintUrl, unit, amount}, {onchainQuote})
|
||||
|
||||
return onchainQuote
|
||||
|
||||
} catch (e: any) {
|
||||
let message = 'The mint could not return the onchain melt quote.'
|
||||
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
|
||||
throw new AppError(
|
||||
Err.MINT_ERROR,
|
||||
message,
|
||||
{
|
||||
message: e.message,
|
||||
caller: 'createOnchainMeltQuote',
|
||||
request: {mintUrl, unit, address, amount},
|
||||
}
|
||||
)
|
||||
}
|
||||
}),
|
||||
/**
|
||||
* Execute an onchain melt (NUT-30).
|
||||
*
|
||||
* Deliberately the same two-step shape as `payLightningMelt`, because the reason for
|
||||
* the split is the same: `prepareMelt` derives the NUT-08 change outputs from the
|
||||
* keyset counter, and if the app dies between submitting the melt and receiving the
|
||||
* response, the ONLY way to reconstruct that change is the meltPreview we wrote to
|
||||
* SQLite before submitting. cashu-ts' one-shot `meltProofsOnchain` hides the split and
|
||||
* would leave nothing to recover from.
|
||||
*
|
||||
* Two things differ from bolt11:
|
||||
* - `fee_index` rides along as `extraPayload` on the melt request. It is not part of
|
||||
* the quote and not part of prepare; the mint locks it into `selected_fee_index`
|
||||
* when it executes, and MUST NOT execute the same quote again with a different one.
|
||||
* - No `preferAsync`. NUT-30 mandates asynchrony ("The mint MUST return a PENDING
|
||||
* state after validating the melt request and then broadcast in the background"),
|
||||
* so there is no faster path to ask for.
|
||||
*/
|
||||
payOnchainMelt: flow(function* payOnchainMelt(
|
||||
mintUrl: string,
|
||||
unit: MintUnit,
|
||||
meltQuote: MeltQuoteOnchainResponse,
|
||||
proofsToMeltFrom: Proof[],
|
||||
feeIndex: number,
|
||||
transactionId: number,
|
||||
options?: {
|
||||
increaseCounterBy?: number,
|
||||
}
|
||||
) {
|
||||
const mintInstance = self.getMintModelInstance(mintUrl)
|
||||
|
||||
if(!mintInstance) {
|
||||
throw new AppError(Err.VALIDATION_ERROR, 'Missing mint instance', {mintUrl})
|
||||
}
|
||||
|
||||
const cashuWallet = yield self.getWallet(
|
||||
mintUrl,
|
||||
unit,
|
||||
{
|
||||
withSeed: true,
|
||||
}
|
||||
)
|
||||
|
||||
const currentCounter = mintInstance.getProofsCounterByKeysetId!(cashuWallet.keysetId)
|
||||
|
||||
// outputs error healing
|
||||
if(options && options.increaseCounterBy) {
|
||||
currentCounter.increaseProofsCounter(options.increaseCounterBy)
|
||||
}
|
||||
|
||||
yield cashuWallet.counters.advanceToAtLeast(cashuWallet.keysetId, currentCounter.counter)
|
||||
|
||||
log.trace('[WalletStore.payOnchainMelt] Preparing melt', {
|
||||
localCounter: currentCounter.counter,
|
||||
proofsCount: proofsToMeltFrom.length,
|
||||
feeIndex,
|
||||
})
|
||||
|
||||
let reservedCounters: OperationCounters | undefined
|
||||
|
||||
// Step 1: prepare (derives the deterministic change outputs)
|
||||
const meltPreview: MeltPreview<MeltQuoteOnchainResponse> = yield cashuWallet.prepareMelt(
|
||||
'onchain',
|
||||
meltQuote,
|
||||
CashuUtils.exportProofs(proofsToMeltFrom),
|
||||
{
|
||||
keysetId: cashuWallet.keysetId,
|
||||
onCountersReserved: (info: OperationCounters) => {
|
||||
reservedCounters = info
|
||||
log.debug('[payOnchainMelt] Counters reserved', info)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Synchronous SQLite write BEFORE the melt is submitted, so the change is
|
||||
// recoverable even if the app dies the moment after.
|
||||
Database.addMeltRecovery(
|
||||
transactionId,
|
||||
mintUrl,
|
||||
cashuWallet.keysetId,
|
||||
CashuUtils.serializeMeltPreview(meltPreview),
|
||||
)
|
||||
|
||||
if (reservedCounters) {
|
||||
currentCounter.setProofsCounter(reservedCounters.next)
|
||||
log.debug('[payOnchainMelt] Updated counter', {
|
||||
keysetId: reservedCounters.keysetId,
|
||||
start: reservedCounters.start,
|
||||
count: reservedCounters.count,
|
||||
next: reservedCounters.next
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 2: submit. fee_index is the onchain-specific part of the request body.
|
||||
const meltResponse: MeltProofsResponse<MeltQuoteOnchainResponse> =
|
||||
yield cashuWallet.completeMelt(meltPreview, undefined, {
|
||||
extraPayload: {fee_index: feeIndex},
|
||||
})
|
||||
|
||||
// The mint answers PENDING (spec-mandated) but MAY already have returned the
|
||||
// change, because it knows its actual fee the moment it builds the transaction.
|
||||
// Keep the preview only while there is still change left to reconstruct later.
|
||||
if (meltResponse.change.length > 0) {
|
||||
Database.removeMeltRecovery(transactionId)
|
||||
}
|
||||
|
||||
log.trace('[payOnchainMelt]', {meltResponse})
|
||||
return meltResponse
|
||||
|
||||
} catch (e: any) {
|
||||
if(!e.message.toLowerCase().includes('timeout') &&
|
||||
!e.message.toLowerCase().includes('network request failed')) {
|
||||
Database.removeMeltRecovery(transactionId)
|
||||
}
|
||||
|
||||
let message = 'Onchain payment failed.'
|
||||
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
|
||||
throw new AppError(
|
||||
Err.MINT_ERROR,
|
||||
message,
|
||||
{
|
||||
message: e.message,
|
||||
caller: 'payOnchainMelt',
|
||||
mintUrl,
|
||||
code: e.code || undefined,
|
||||
}
|
||||
)
|
||||
}
|
||||
}),
|
||||
/**
|
||||
* Current state of an onchain melt quote.
|
||||
*
|
||||
* This — not the state of the input proofs — is what says whether an onchain payment
|
||||
* has settled. The mint spending the inputs means it BROADCAST, not that the
|
||||
* transaction confirmed. Only `PAID` means confirmed.
|
||||
*/
|
||||
checkOnchainMeltQuote: flow(function* checkOnchainMeltQuote(
|
||||
mintUrl: string,
|
||||
quote: string,
|
||||
) {
|
||||
try {
|
||||
const cashuMint: CashuMint = yield self.getMint(mintUrl)
|
||||
const quoteResponse: MeltQuoteOnchainResponse = yield cashuMint.checkMeltQuoteOnchain(
|
||||
quote
|
||||
)
|
||||
|
||||
log.info('[checkOnchainMeltQuote]', {quoteResponse})
|
||||
|
||||
return quoteResponse
|
||||
|
||||
} catch (e: any) {
|
||||
let message = 'The mint could not return the state of an onchain melt quote.'
|
||||
if (isOnionMint(mintUrl)) message += TorVPNSetupInstructions;
|
||||
throw new AppError(
|
||||
Err.MINT_ERROR,
|
||||
message,
|
||||
{
|
||||
message: e.message,
|
||||
caller: 'checkOnchainMeltQuote',
|
||||
mintUrl,
|
||||
}
|
||||
)
|
||||
}
|
||||
}),
|
||||
restore: flow(function* restore(
|
||||
mintUrl: string,
|
||||
seed: Uint8Array,
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Bitcoin address and BIP21 URI parsing.
|
||||
*
|
||||
* Used on the way OUT (NUT-30 onchain melt): the user pastes or scans something and
|
||||
* the wallet has to decide what it is before it can route them anywhere. Onchain
|
||||
* payments are irreversible, so this errs towards refusing input it does not fully
|
||||
* understand — the checksums are verified locally rather than left for the mint to
|
||||
* catch, so a mistyped address fails on the screen the user is looking at instead of
|
||||
* one round-trip later.
|
||||
*
|
||||
* Checksums catch typos, not mistakes: a valid address for the wrong recipient looks
|
||||
* exactly like a valid address. Nothing here can help with that.
|
||||
*/
|
||||
import {bech32, bech32m, createBase58check} from '@scure/base'
|
||||
import {sha256} from '@noble/hashes/sha2.js'
|
||||
|
||||
const base58Check = createBase58check(sha256)
|
||||
|
||||
export type BitcoinNetwork = 'mainnet' | 'testnet' | 'regtest'
|
||||
|
||||
export type BitcoinAddressInfo = {
|
||||
address: string
|
||||
network: BitcoinNetwork
|
||||
/** Human-readable script type, for display and for logs. */
|
||||
kind: 'P2PKH' | 'P2SH' | 'P2WPKH' | 'P2WSH' | 'P2TR' | 'SEGWIT'
|
||||
}
|
||||
|
||||
/** Bech32 human-readable prefixes, per BIP-173 / BIP-350. */
|
||||
const SEGWIT_PREFIXES: Record<string, BitcoinNetwork> = {
|
||||
bc: 'mainnet',
|
||||
tb: 'testnet',
|
||||
bcrt: 'regtest',
|
||||
}
|
||||
|
||||
/** Base58 version bytes. */
|
||||
const BASE58_VERSIONS: Record<number, {network: BitcoinNetwork; kind: 'P2PKH' | 'P2SH'}> = {
|
||||
0x00: {network: 'mainnet', kind: 'P2PKH'}, // 1...
|
||||
0x05: {network: 'mainnet', kind: 'P2SH'}, // 3...
|
||||
0x6f: {network: 'testnet', kind: 'P2PKH'}, // m... / n...
|
||||
0xc4: {network: 'testnet', kind: 'P2SH'}, // 2...
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a segwit (bech32 / bech32m) address.
|
||||
*
|
||||
* The witness version decides the checksum constant: v0 MUST use bech32, v1+ (taproot
|
||||
* and anything after it) MUST use bech32m. They are different checksums over the same
|
||||
* alphabet, so accepting either for both versions would let a v0 address with a
|
||||
* corrupted checksum through as long as it happened to satisfy the other constant.
|
||||
* We decode with both and then insist the one that worked matches the version.
|
||||
*/
|
||||
const decodeSegwitAddress = (address: string): BitcoinAddressInfo | undefined => {
|
||||
const lower = address.toLowerCase()
|
||||
|
||||
// BIP-173: mixed case is invalid. Checked before lowercasing loses the evidence.
|
||||
if (address !== lower && address !== address.toUpperCase()) return undefined
|
||||
|
||||
const separator = lower.lastIndexOf('1')
|
||||
if (separator < 1) return undefined
|
||||
|
||||
const network = SEGWIT_PREFIXES[lower.slice(0, separator)]
|
||||
if (!network) return undefined
|
||||
|
||||
let words: number[]
|
||||
let usedBech32m = false
|
||||
|
||||
try {
|
||||
words = bech32.decode(lower as `${string}1${string}`, 90).words
|
||||
} catch {
|
||||
try {
|
||||
words = bech32m.decode(lower as `${string}1${string}`, 90).words
|
||||
usedBech32m = true
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const version = words[0]
|
||||
if (version === undefined || version > 16) return undefined
|
||||
if (version === 0 && usedBech32m) return undefined
|
||||
if (version > 0 && !usedBech32m) return undefined
|
||||
|
||||
let program: Uint8Array
|
||||
try {
|
||||
program = bech32.fromWords(words.slice(1))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (program.length < 2 || program.length > 40) return undefined
|
||||
// v0 is only ever defined for P2WPKH (20 bytes) and P2WSH (32).
|
||||
if (version === 0 && program.length !== 20 && program.length !== 32) return undefined
|
||||
|
||||
let kind: BitcoinAddressInfo['kind'] = 'SEGWIT'
|
||||
if (version === 0) kind = program.length === 20 ? 'P2WPKH' : 'P2WSH'
|
||||
else if (version === 1 && program.length === 32) kind = 'P2TR'
|
||||
|
||||
return {address: lower, network, kind}
|
||||
}
|
||||
|
||||
/** Decode a legacy base58check address (P2PKH / P2SH). */
|
||||
const decodeBase58Address = (address: string): BitcoinAddressInfo | undefined => {
|
||||
let decoded: Uint8Array
|
||||
try {
|
||||
decoded = base58Check.decode(address)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// version byte + 20-byte hash (the 4-byte checksum is consumed by the decoder)
|
||||
if (decoded.length !== 21) return undefined
|
||||
|
||||
const version = BASE58_VERSIONS[decoded[0]]
|
||||
if (!version) return undefined
|
||||
|
||||
return {address, network: version.network, kind: version.kind}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a bare Bitcoin address, verifying its checksum.
|
||||
*
|
||||
* Returns undefined rather than throwing, so it can be used as a predicate while
|
||||
* sniffing unknown input.
|
||||
*/
|
||||
export const decodeBitcoinAddress = (
|
||||
address: string,
|
||||
): BitcoinAddressInfo | undefined => {
|
||||
const trimmed = address.trim()
|
||||
if (trimmed.length === 0) return undefined
|
||||
|
||||
return decodeSegwitAddress(trimmed) ?? decodeBase58Address(trimmed)
|
||||
}
|
||||
|
||||
export const isBitcoinAddress = (address: string): boolean =>
|
||||
decodeBitcoinAddress(address) !== undefined
|
||||
|
||||
/**
|
||||
* Is this an address Minibits is allowed to pay?
|
||||
*
|
||||
* Mainnet only. The wallet holds mainnet-backed ecash and the mints melt to the real
|
||||
* chain, so a testnet or regtest address is never a payment — it is a mistake, and an
|
||||
* irreversible one if a mint broadcasts against it.
|
||||
*
|
||||
* This is not hypothetical. The CDK fakewallet backend hands out REGTEST deposit
|
||||
* addresses (`bcrt1q…`) for onchain topup quotes, so anyone testing this wallet ends
|
||||
* up with one in their clipboard. Pasting it back into Pay must fail loudly, not
|
||||
* quietly reach the mint.
|
||||
*
|
||||
* Kept separate from `decodeBitcoinAddress` on purpose: decoding tells you WHAT an
|
||||
* address is (and needs to recognise testnet in order to say so), while this decides
|
||||
* whether we are willing to send money to it. Collapsing the two would leave us
|
||||
* unable to tell "that is not an address" apart from "that is not OUR network", and
|
||||
* the second deserves its own error message.
|
||||
*/
|
||||
export const isPayableBitcoinAddress = (address: string): boolean =>
|
||||
decodeBitcoinAddress(address)?.network === 'mainnet'
|
||||
|
||||
export type Bip21Data = {
|
||||
address: string
|
||||
/** Amount in SATS, converted from the BIP21 `amount` (which is in BTC). */
|
||||
amountSat?: number
|
||||
label?: string
|
||||
message?: string
|
||||
/** A BOLT11 invoice carried alongside the address in a unified QR. */
|
||||
lightning?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a BIP21 `bitcoin:` URI.
|
||||
*
|
||||
* Only the address is required; everything else is a hint the wallet may use or
|
||||
* ignore. Returns undefined if the URI is not BIP21 or the address does not check
|
||||
* out — a `bitcoin:` URI with an address we cannot verify is not something to pass
|
||||
* along half-understood.
|
||||
*
|
||||
* The scheme is case-insensitive (BIP21 allows `BITCOIN:`, which is what QR encoders
|
||||
* emit to stay in the alphanumeric mode).
|
||||
*/
|
||||
export const parseBip21 = (uri: string): Bip21Data | undefined => {
|
||||
const trimmed = uri.trim()
|
||||
if (!/^bitcoin:/i.test(trimmed)) return undefined
|
||||
|
||||
const body = trimmed.slice('bitcoin:'.length)
|
||||
const [addressPart, queryPart] = body.split('?', 2)
|
||||
|
||||
// `bitcoin:?lightning=...` (no address) is a legal BOLT11-only unified URI, but
|
||||
// it is not something an onchain melt can use — the caller wants an address.
|
||||
const decoded = decodeBitcoinAddress(addressPart)
|
||||
if (!decoded) return undefined
|
||||
|
||||
// Take the DECODED address, not the raw text: QR encoders uppercase bech32 to stay
|
||||
// in alphanumeric mode, and this string is what we hand to the mint.
|
||||
const result: Bip21Data = {address: decoded.address}
|
||||
if (!queryPart) return result
|
||||
|
||||
const params = new URLSearchParams(queryPart)
|
||||
|
||||
const amount = params.get('amount')
|
||||
if (amount) {
|
||||
const btc = Number(amount)
|
||||
// BIP21 amounts are decimal BTC. Round rather than truncate: 0.0001 parses to
|
||||
// 9999.999999999999 sats in binary floating point, and a truncating conversion
|
||||
// would quietly under-request by one sat.
|
||||
if (Number.isFinite(btc) && btc > 0) result.amountSat = Math.round(btc * 100_000_000)
|
||||
}
|
||||
|
||||
const label = params.get('label')
|
||||
if (label) result.label = label
|
||||
|
||||
const message = params.get('message')
|
||||
if (message) result.message = message
|
||||
|
||||
const lightning = params.get('lightning')
|
||||
if (lightning) result.lightning = lightning.toLowerCase()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a Bitcoin address or BIP21 URI inside arbitrary pasted text.
|
||||
*
|
||||
* Mirrors `LightningUtils.findEncodedLightningInvoice` — clipboards carry surrounding
|
||||
* prose, and QR payloads sometimes carry a URI inside a larger string.
|
||||
*/
|
||||
export const findBitcoinAddress = (text: string): string | undefined => {
|
||||
const trimmed = text.trim()
|
||||
|
||||
const uriMatch = trimmed.match(/bitcoin:[^\s]+/i)
|
||||
if (uriMatch && parseBip21(uriMatch[0])) return uriMatch[0]
|
||||
|
||||
if (decodeBitcoinAddress(trimmed)) return trimmed
|
||||
|
||||
// Bare address embedded in text. The candidate pattern is deliberately loose —
|
||||
// `decodeBitcoinAddress` is the actual filter, so a false candidate costs a failed
|
||||
// checksum, not a false positive.
|
||||
const candidates = trimmed.match(/\b(bc1|tb1|bcrt1)[a-z0-9]{6,87}\b|\b[13mn2][a-km-zA-HJ-NP-Z1-9]{25,39}\b/gi)
|
||||
if (!candidates) return undefined
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (decodeBitcoinAddress(candidate)) return candidate
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const BitcoinUtils = {
|
||||
decodeBitcoinAddress,
|
||||
isBitcoinAddress,
|
||||
isPayableBitcoinAddress,
|
||||
parseBip21,
|
||||
findBitcoinAddress,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -219,6 +219,38 @@ export const getPendingTransfers = function () {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* PENDING onchain melts — payments the mint has taken but the chain has not confirmed.
|
||||
*
|
||||
* Separate from `getPendingTransfers` (which filters `type = 'TRANSFER'`) rather than
|
||||
* folded into it, because the two are watched for different reasons and on different
|
||||
* clocks: a bolt11 transfer is watched to catch a stuck payment and can be EXPIRED,
|
||||
* while an onchain transfer is waiting on blocks and must never be expired — the melt
|
||||
* quote's expiry bounds executing the quote, not confirming the payment.
|
||||
*/
|
||||
export const getPendingOnchainTransfers = function () {
|
||||
try {
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM transactions
|
||||
WHERE status = 'PENDING'
|
||||
AND type = 'TRANSFER_ONCHAIN'
|
||||
ORDER BY id DESC
|
||||
`
|
||||
|
||||
const db = getInstance()
|
||||
const {rows} = db.execute(query)
|
||||
|
||||
log.trace(`[getPendingOnchainTransfers], Returned ${rows?.length} rows`)
|
||||
|
||||
return normalizeTransactionRows(rows)
|
||||
|
||||
} catch (e: any) {
|
||||
throw dbError('Transactions could not be retrieved from the database', e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const getPendingTopupsCount = function () {
|
||||
let query: string = ''
|
||||
try {
|
||||
|
||||
@@ -320,11 +320,19 @@ const handleInFlightByMintTask = async (mint: Mint): Promise<WalletTaskResult> =
|
||||
break
|
||||
}
|
||||
|
||||
// TRANSFER (melt / lightning out retry)
|
||||
// COMMENTED OUT — solved by syncStateWithMintTask which recovers change
|
||||
// from pending-yet-paid transfers. Request params (meltPreview) is stored
|
||||
// in proofsCounter.meltCounterValues, not inFlightRequests.
|
||||
case TransactionType.TRANSFER: {
|
||||
// TRANSFER / TRANSFER_ONCHAIN (melt retry)
|
||||
// NO-OP — solved by syncStateWithMintTask which recovers change from
|
||||
// pending-yet-paid transfers. Request params (meltPreview) is stored in
|
||||
// proofsCounter.meltCounterValues, not inFlightRequests.
|
||||
//
|
||||
// Melts need no replay for the reason mints do. A lost mint RESPONSE
|
||||
// strands issued ecash (the mint counts it as issued, we never see it),
|
||||
// so TOPUP replays the request against the mint's NUT-19 cache. A lost
|
||||
// melt response strands nothing: the money is either gone (mint paid, and
|
||||
// sync recovers the change) or still ours (mint did not, and sync returns
|
||||
// the proofs). Replaying a melt would risk paying twice to fix nothing.
|
||||
case TransactionType.TRANSFER:
|
||||
case TransactionType.TRANSFER_ONCHAIN: {
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {isBefore} from 'date-fns'
|
||||
import {
|
||||
MeltQuoteBolt11Response,
|
||||
MeltQuoteOnchainResponse,
|
||||
MeltQuoteState,
|
||||
getEncodedToken,
|
||||
} from '@cashu/cashu-ts'
|
||||
@@ -14,13 +15,15 @@ import {
|
||||
Transaction,
|
||||
TransactionData,
|
||||
TransactionStatus,
|
||||
TransactionType,
|
||||
} from '../../../models/Transaction'
|
||||
import {MintBalance} from '../../../models/Mint'
|
||||
import {Proof} from '../../../models/Proof'
|
||||
import {CashuUtils} from '../../cashu/cashuUtils'
|
||||
import {NostrEvent} from '../../nostrService'
|
||||
import {MintUnit, formatCurrency, getCurrency} from '../currency'
|
||||
import {transferTask} from '../transferTask'
|
||||
import {transferOnchainTask, transferTask} from '../transferTask'
|
||||
import {SyncQueue} from '../../syncQueueService'
|
||||
import {WalletUtils} from '../utils'
|
||||
import {createQueueAwaitable} from '../queueHelper'
|
||||
import {TransactionTaskResult} from '../types'
|
||||
@@ -61,12 +64,60 @@ const transferQueueAwaitable = (
|
||||
})
|
||||
|
||||
/**
|
||||
* Recover change from a paid melt quote (lightning out)
|
||||
* Onchain melt, run through the SyncQueue.
|
||||
*
|
||||
* The queue is not a nicety. Melting derives its NUT-08 change outputs from the keyset
|
||||
* counter, exactly as minting derives its blinded secrets from it: two melts running
|
||||
* concurrently on one keyset both advance to the SAME counter and derive the SAME
|
||||
* blinded outputs. SyncQueue runs at concurrency 1, and going through it is what makes
|
||||
* that impossible. Every melting path must.
|
||||
*/
|
||||
const transferOnchainQueueAwaitable = (
|
||||
mintBalanceToTransferFrom: MintBalance,
|
||||
amountToTransfer: number,
|
||||
unit: MintUnit,
|
||||
meltQuote: MeltQuoteOnchainResponse,
|
||||
feeIndex: number,
|
||||
memo: string,
|
||||
quoteExpiry: Date,
|
||||
address: string,
|
||||
nwcEvent?: NostrEvent,
|
||||
draftTransactionId?: number,
|
||||
): Promise<TransactionTaskResult> =>
|
||||
createQueueAwaitable<TransactionTaskResult>({
|
||||
taskFunction: 'transferOnchainTask',
|
||||
timeoutMessage: 'transferOnchainQueue timed out',
|
||||
task: () =>
|
||||
transferOnchainTask(
|
||||
mintBalanceToTransferFrom,
|
||||
amountToTransfer,
|
||||
unit,
|
||||
meltQuote,
|
||||
feeIndex,
|
||||
memo,
|
||||
quoteExpiry,
|
||||
address,
|
||||
nwcEvent,
|
||||
draftTransactionId,
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* Recover change from a paid melt quote (lightning or onchain out).
|
||||
*
|
||||
* The recovery itself is rail-agnostic: it reconstructs the change from the
|
||||
* meltPreview we persisted before submitting, using the blind signatures the mint
|
||||
* reports on the resolved quote. Neither of those is bolt11-specific.
|
||||
*
|
||||
* Only the STRING form is: given just a quote id we have to ask the mint about it,
|
||||
* and there is no id to tell us which endpoint to ask. That form is reached from the
|
||||
* manual recovery screen, which is lightning-only. Callers with an onchain quote pass
|
||||
* the resolved object.
|
||||
*/
|
||||
const recoverMeltQuoteChange = async (
|
||||
params: {
|
||||
mintUrl: string
|
||||
meltQuote: string | MeltQuoteBolt11Response
|
||||
meltQuote: string | MeltQuoteBolt11Response | MeltQuoteOnchainResponse
|
||||
},
|
||||
): Promise<{recoveredAmount: number}> => {
|
||||
const {mintUrl, meltQuote} = params
|
||||
@@ -79,7 +130,7 @@ const recoverMeltQuoteChange = async (
|
||||
|
||||
log.trace('[recoverMeltQuoteChange] start', {mintUrl, meltQuote})
|
||||
|
||||
const meltQuoteResponse: MeltQuoteBolt11Response =
|
||||
const meltQuoteResponse: MeltQuoteBolt11Response | MeltQuoteOnchainResponse =
|
||||
typeof meltQuote === 'string'
|
||||
? await walletStore.checkLightningMeltQuote(mintUrl, meltQuote)
|
||||
: meltQuote
|
||||
@@ -396,11 +447,77 @@ const handlePendingMeltTask = async (params: {
|
||||
// MeltQuoteState.PENDING: no-op, ws/poller will call again
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-check every PENDING onchain transfer with its mint.
|
||||
*
|
||||
* The onchain equivalent of the bolt11 websocket + poller, and deliberately not either
|
||||
* of those. An onchain melt settles when the transaction is mined, so the wait is
|
||||
* measured in blocks: the existing ~60s pending-check cadence (app start, foreground,
|
||||
* WalletScreen focus) is already far finer-grained than the thing it waits for, and a
|
||||
* 15-second poller would only burn requests to learn nothing.
|
||||
*
|
||||
* Each check goes through SyncQueue for the counter-serialisation reason above —
|
||||
* `refresh` can reconstruct NUT-08 change, and change reconstruction reads the keyset
|
||||
* counter. Queueing per-transaction also means one unreachable mint cannot stall the
|
||||
* others.
|
||||
*/
|
||||
const handlePendingOnchainTransferQueue = async (): Promise<void> => {
|
||||
const pending = transactionsStore.getPendingOnchainTransfers()
|
||||
|
||||
if (pending.length === 0) {
|
||||
log.trace('[handlePendingOnchainTransferQueue] No pending onchain transfers')
|
||||
return
|
||||
}
|
||||
|
||||
log.trace('[handlePendingOnchainTransferQueue] start', {pending: pending.length})
|
||||
|
||||
for (const tx of pending) {
|
||||
enqueuePendingOnchainTransferCheck(tx.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a single onchain transfer re-check. THE ONLY WAY one may be started.
|
||||
*
|
||||
* Duplicate tasks for the same transaction are harmless: they run in sequence, and
|
||||
* `refresh` no-ops on anything that is no longer PENDING.
|
||||
*/
|
||||
const enqueuePendingOnchainTransferCheck = (transactionId: number) => {
|
||||
const taskId = `handlePendingOnchainTransferTask-${transactionId}-${Date.now()}`
|
||||
return SyncQueue.addTask(taskId, () => handlePendingOnchainTransferTask(transactionId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-check one onchain transfer. Errors are swallowed and logged: an offline mint, or
|
||||
* one transfer failing, must not abort the sweep — the next tick simply tries again.
|
||||
*/
|
||||
const handlePendingOnchainTransferTask = async (transactionId: number) => {
|
||||
try {
|
||||
const {TransferOperationApi} = await import('./transferOperationApi')
|
||||
return await TransferOperationApi.refresh(transactionId)
|
||||
} catch (e: any) {
|
||||
log.warn('[handlePendingOnchainTransferTask]', {transactionId, error: e.message})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expire lightning transfers whose invoices have passed. Used by handlePendingQueue.
|
||||
*
|
||||
* Lightning only, and that is load-bearing rather than incidental. An onchain melt quote
|
||||
* also carries an expiry, but it bounds EXECUTING the quote, not SETTLING the payment:
|
||||
* once the mint has broadcast, the transaction confirms on the chain's schedule and can
|
||||
* easily outlive the quote it came from. Expiring a transfer on that basis would mark a
|
||||
* real, in-flight, irreversible payment dead and hide it from the user.
|
||||
*
|
||||
* The caller passes only bolt11 transfers (`getPendingTransfers` filters on
|
||||
* `type = 'TRANSFER'`), so onchain never reaches here — but the guarantee is stated
|
||||
* here because this is where it would be violated.
|
||||
*/
|
||||
const expirePendingTransfers = (pendingTransfers: Transaction[]): void => {
|
||||
for (const tx of pendingTransfers) {
|
||||
if (tx.type !== TransactionType.TRANSFER) continue
|
||||
|
||||
if (tx.expiresAt && isBefore(tx.expiresAt, new Date())) {
|
||||
log.debug('[MeltOperationService] Expiring transfer', {paymentId: tx.paymentId})
|
||||
|
||||
@@ -428,7 +545,10 @@ const expirePendingTransfers = (pendingTransfers: Transaction[]): void => {
|
||||
|
||||
export const MeltOperationService = {
|
||||
transferQueueAwaitable,
|
||||
transferOnchainQueueAwaitable,
|
||||
recoverMeltQuoteChange,
|
||||
handlePendingMeltTask,
|
||||
handlePendingOnchainTransferQueue,
|
||||
enqueuePendingOnchainTransferCheck,
|
||||
expirePendingTransfers,
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Pure arithmetic for onchain (NUT-30) minting.
|
||||
* Pure arithmetic for onchain (NUT-30) minting and melting.
|
||||
*
|
||||
* Kept free of stores, database and cashu-ts on purpose: these two functions decide
|
||||
* how much money to mint, so they are worth being able to test in isolation. Both
|
||||
* Kept free of stores, database and cashu-ts on purpose: these functions decide how
|
||||
* much money to mint, how much to spend on miner fees, and whether an amount is even
|
||||
* worth sending, so they are worth being able to test in isolation. Most of them
|
||||
* exist to refuse a mint response we did not expect, rather than passing it through
|
||||
* into a mint request.
|
||||
* into a request.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -82,3 +83,114 @@ export const buildBip21Uri = (address: string, amountSat?: number): string => {
|
||||
|
||||
return `bitcoin:${address}?amount=${btc}`
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Melt (paying out onchain)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Minibits' own minimum for paying out onchain, in sats.
|
||||
*
|
||||
* Lower than the topup floor, and for a different reason. The topup floor is high
|
||||
* because the mint credits deposits PER UTXO and dust below its minimum is
|
||||
* unrecoverable — money can actually be lost. Nothing like that happens on the way
|
||||
* out: the mint either accepts the melt or refuses it.
|
||||
*
|
||||
* What this floor protects against is creating an output nobody can afford to spend.
|
||||
* 1000 sat clears every script type's dust limit (546 for P2PKH, 330 for P2WSH) with
|
||||
* enough margin that the recipient's output is still economically spendable. The
|
||||
* mint's own `min_amount` wins whenever it is higher — but as with topup, it is not
|
||||
* trusted to be sane on its own.
|
||||
*/
|
||||
export const MINIBITS_ONCHAIN_MELT_FLOOR_SAT = 1000
|
||||
|
||||
/**
|
||||
* The smallest amount worth paying out onchain: `max(our floor, the mint's minimum)`.
|
||||
*
|
||||
* Denominated in sats, so applied only to sat payouts; any other unit defers to the
|
||||
* mint. Mirrors `onchainTopupFloor`.
|
||||
*/
|
||||
export const onchainMeltFloor = (
|
||||
unit: string,
|
||||
mintMinAmount?: number | null,
|
||||
): number => {
|
||||
const mintFloor = mintMinAmount && mintMinAmount > 0 ? Number(mintMinAmount) : 0
|
||||
if (unit !== 'sat') return mintFloor
|
||||
return Math.max(MINIBITS_ONCHAIN_MELT_FLOOR_SAT, mintFloor)
|
||||
}
|
||||
|
||||
/**
|
||||
* A NUT-30 melt fee tier, normalized to plain numbers.
|
||||
*
|
||||
* cashu-ts hands us `fee_reserve` as an `Amount` object. Everything here works in
|
||||
* numbers so the selection logic stays testable without pulling cashu-ts (and the
|
||||
* store graph behind it) into the test.
|
||||
*/
|
||||
export type OnchainFeeOption = {
|
||||
feeIndex: number
|
||||
feeReserve: number
|
||||
estimatedBlocks: number
|
||||
}
|
||||
|
||||
/** Shape of a `fee_options` entry as it arrives from cashu-ts. */
|
||||
type RawFeeOption = {
|
||||
fee_index: number
|
||||
fee_reserve: number | {toNumber: () => number}
|
||||
estimated_blocks: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a quote's `fee_options` into plain numbers, sorted cheapest first.
|
||||
*
|
||||
* Sorting is not cosmetic — `selectDefaultFeeOption` picks by position, and the mint
|
||||
* is under no obligation to return the tiers in any particular order. `fee_index` is
|
||||
* the mint's identifier for a tier, NOT its rank, so it must never be used as one.
|
||||
*/
|
||||
export const normalizeFeeOptions = (options: RawFeeOption[]): OnchainFeeOption[] =>
|
||||
options
|
||||
.map(o => ({
|
||||
feeIndex: o.fee_index,
|
||||
feeReserve:
|
||||
typeof o.fee_reserve === 'number' ? o.fee_reserve : o.fee_reserve.toNumber(),
|
||||
estimatedBlocks: o.estimated_blocks,
|
||||
}))
|
||||
.sort((a, b) => a.feeReserve - b.feeReserve)
|
||||
|
||||
/**
|
||||
* Which fee tier to pre-select: the middle one, rounding to the cheaper side.
|
||||
*
|
||||
* The "normal" choice — fast enough not to strand the payment, cheap enough not to
|
||||
* quietly overspend. With an even number of tiers there is no true middle, so we
|
||||
* take the cheaper of the two: the user is always free to pay more, and a wallet
|
||||
* should never round a fee UP on the user's behalf without being asked.
|
||||
*
|
||||
* Expects the sorted output of `normalizeFeeOptions`. Returns undefined only when the
|
||||
* mint returned no tiers at all, which the spec forbids ("The mint MUST return at
|
||||
* least one fee_options item") — callers treat that as a broken quote rather than
|
||||
* inventing a fee.
|
||||
*/
|
||||
export const selectDefaultFeeOption = (
|
||||
options: OnchainFeeOption[],
|
||||
): OnchainFeeOption | undefined => {
|
||||
if (options.length === 0) return undefined
|
||||
return options[Math.floor((options.length - 1) / 2)]
|
||||
}
|
||||
|
||||
/** Look up a tier by the mint's `fee_index`. Undefined if the mint never offered it. */
|
||||
export const findFeeOption = (
|
||||
options: OnchainFeeOption[],
|
||||
feeIndex: number,
|
||||
): OnchainFeeOption | undefined => options.find(o => o.feeIndex === feeIndex)
|
||||
|
||||
/**
|
||||
* Total that must be covered by the inputs of an onchain melt.
|
||||
*
|
||||
* `amount + fee_reserve + input_fee`, per NUT-30. The mint may keep the whole
|
||||
* `fee_reserve` ("the mint is entitled to claim the full selected_fee_reserve as the
|
||||
* actual fee") — anything it does not spend comes back as NUT-08 change.
|
||||
*/
|
||||
export const onchainMeltTotal = (
|
||||
amount: number,
|
||||
feeReserve: number,
|
||||
inputFee: number = 0,
|
||||
): number => amount + feeReserve + inputFee
|
||||
|
||||
@@ -7,17 +7,24 @@ import {OnchainOperationService} from './onchainOperations'
|
||||
const {transactionsStore} = rootStoreInstance
|
||||
|
||||
/**
|
||||
* Process all pending topups and expired lightning transfers, and check for
|
||||
* onchain deposits.
|
||||
* Process all pending topups and expired lightning transfers, and check on both
|
||||
* directions of onchain money: deposits coming in, and melts going out.
|
||||
*
|
||||
* Topup polling is delegated to MintOperationService (mint quote lifecycle).
|
||||
* Transfer expiry is delegated to MeltOperationService (lightning out lifecycle).
|
||||
* Transfer expiry and the onchain melt sweep are delegated to MeltOperationService.
|
||||
* Onchain deposits are delegated to OnchainOperationService.
|
||||
*
|
||||
* Note the onchain sweep is driven by QUOTES, not by pending transactions: an
|
||||
* onchain address can be paid again after its transaction has COMPLETED, so
|
||||
* walking pending transactions (as the bolt11 path does) would miss precisely the
|
||||
* deposits that need catching.
|
||||
* The two onchain sweeps are driven differently, and the asymmetry is deliberate:
|
||||
*
|
||||
* - DEPOSITS are QUOTE-driven. An onchain address can be paid again after its
|
||||
* transaction has COMPLETED, so walking pending transactions (as the bolt11 path
|
||||
* does) would miss precisely the deposits that need catching.
|
||||
* - MELTS are TRANSACTION-driven. A melt quote is one-shot and terminal, so the
|
||||
* pending transaction IS the outstanding work, and there is nothing to find that a
|
||||
* transaction does not already point at.
|
||||
*
|
||||
* Neither uses a websocket or a poller: onchain settlement is bounded by block times,
|
||||
* so this ~60s cadence is already far finer-grained than what it waits for.
|
||||
*/
|
||||
const handlePendingQueue = async (): Promise<void> => {
|
||||
const pendingTopups = transactionsStore.getPendingTopups()
|
||||
@@ -39,6 +46,7 @@ const handlePendingQueue = async (): Promise<void> => {
|
||||
}
|
||||
|
||||
await OnchainOperationService.handleOnchainQuoteQueue()
|
||||
await MeltOperationService.handlePendingOnchainTransferQueue()
|
||||
}
|
||||
|
||||
export const PendingOperationService = {
|
||||
|
||||
@@ -169,12 +169,28 @@ const syncStateWithMintTask = async function (
|
||||
|
||||
try {
|
||||
await _dispatchFinalize(tx)
|
||||
|
||||
// Report the status the dispatch actually reached, not the one the
|
||||
// SPENT proofs implied. They are the same on every rail but one:
|
||||
// an onchain melt whose inputs are spent has only been BROADCAST,
|
||||
// and `refresh` deliberately leaves it PENDING until the mint
|
||||
// reports the transaction confirmed. Announcing COMPLETED here
|
||||
// would tell the user their Bitcoin payment had landed while it was
|
||||
// still sitting unconfirmed in the mempool.
|
||||
const settled = transactionsStore.findById(tId) ?? tx
|
||||
const reachedStatus = settled.status
|
||||
|
||||
if (reachedStatus === TransactionStatus.COMPLETED) {
|
||||
completedTxIds.push(tId)
|
||||
} else {
|
||||
pendingTxIds.push(tId)
|
||||
}
|
||||
|
||||
transactionStateUpdates.push({
|
||||
tId,
|
||||
amount: tx.amount,
|
||||
spentByMintAmount: spentAmount,
|
||||
updatedStatus: TransactionStatus.COMPLETED,
|
||||
updatedStatus: reachedStatus,
|
||||
})
|
||||
} catch (e: any) {
|
||||
log.error('[syncStateWithMintTask] finalize dispatch failed', {
|
||||
@@ -261,7 +277,10 @@ const syncStateWithMintTask = async function (
|
||||
const tx = transactionsStore.findById(tId)
|
||||
if (!tx) continue
|
||||
|
||||
if (tx.type !== TransactionType.TRANSFER) {
|
||||
if (
|
||||
tx.type !== TransactionType.TRANSFER &&
|
||||
tx.type !== TransactionType.TRANSFER_ONCHAIN
|
||||
) {
|
||||
log.warn(
|
||||
'[syncStateWithMintTask] Unexpected non-TRANSFER tx in branch 3',
|
||||
{tId, type: tx.type},
|
||||
@@ -326,13 +345,21 @@ const syncStateWithMintTask = async function (
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a sync-confirmed-SPENT transaction to the appropriate operation API's
|
||||
* `finalize`. Sync has already bulk-moved the proofs to SPENT, so each
|
||||
* finalize sees an empty PENDING set and just stamps the tx COMPLETED (and,
|
||||
* for TRANSFER, recovers melt change atomically with the status update).
|
||||
* Route a sync-confirmed-SPENT transaction to the appropriate operation API. Sync has
|
||||
* already bulk-moved the proofs to SPENT, so each finalize sees an empty PENDING set
|
||||
* and just stamps the tx COMPLETED (and, for TRANSFER, recovers melt change atomically
|
||||
* with the status update).
|
||||
*
|
||||
* Only SEND and TRANSFER are expected here — other types don't park proofs
|
||||
* in PENDING that sync could later observe as SPENT.
|
||||
* Only SEND, TRANSFER and TRANSFER_ONCHAIN are expected here — other types don't park
|
||||
* proofs in PENDING that sync could later observe as SPENT.
|
||||
*
|
||||
* TRANSFER_ONCHAIN goes to `refresh`, NOT `finalize`, and the distinction is the whole
|
||||
* point. On every other rail, the mint spending our inputs IS settlement. On onchain it
|
||||
* is not: the mint takes the inputs when it BROADCASTS, and the payment is not settled
|
||||
* until the transaction is mined — which may be many blocks later, or never, if the
|
||||
* transaction is dropped. Finalizing here would report an unconfirmed payment as
|
||||
* COMPLETED at exactly the moment it is least certain. `refresh` asks the mint for the
|
||||
* quote state and only completes on PAID, leaving the transaction PENDING otherwise.
|
||||
*/
|
||||
async function _dispatchFinalize(tx: Transaction): Promise<void> {
|
||||
switch (tx.type) {
|
||||
@@ -342,6 +369,9 @@ async function _dispatchFinalize(tx: Transaction): Promise<void> {
|
||||
case TransactionType.TRANSFER:
|
||||
await TransferOperationApi.finalize(tx.id)
|
||||
return
|
||||
case TransactionType.TRANSFER_ONCHAIN:
|
||||
await TransferOperationApi.refresh(tx.id)
|
||||
return
|
||||
default:
|
||||
log.warn('[syncStateWithMintTask] Unexpected tx type in finalize dispatch', {
|
||||
tId: tx.id,
|
||||
|
||||
@@ -7,15 +7,21 @@
|
||||
* without growing a parameter for each new rail.
|
||||
*
|
||||
* Today's methods:
|
||||
* - `bolt11`: lightning invoice melt (NUT-05, the only payment rail Minibits
|
||||
* currently supports).
|
||||
* - `bolt11`: lightning invoice melt (NUT-05).
|
||||
* - `onchain`: Bitcoin onchain melt (NUT-30).
|
||||
*
|
||||
* Future methods drop in by adding entries here — for example NUT-23 onchain
|
||||
* melt would add `onchain: { address, meltQuote: MeltQuoteBtcOnchainResponse }`
|
||||
* and the state machine in `TransferOperationApi` stays the same.
|
||||
* The two rails share ONE lifecycle. That is the point of this file: proof
|
||||
* reservation, the preemptive swap, and the execute-error recovery matrix (re-check
|
||||
* the quote, distinguish paid-despite-error from already-spent from pending-at-mint)
|
||||
* are the most safety-critical code in the wallet, and there is exactly one copy of
|
||||
* them. What actually differs between rails is small and local — where the fee
|
||||
* reserve comes from, what identifies the payment, what the destination is called —
|
||||
* and lives in `resolveTransferMethod` below.
|
||||
*/
|
||||
|
||||
import {MeltQuoteBolt11Response} from '@cashu/cashu-ts'
|
||||
import {MeltQuoteBolt11Response, MeltQuoteOnchainResponse} from '@cashu/cashu-ts'
|
||||
import {TransactionType} from '../../../models/Transaction'
|
||||
import {LightningUtils} from '../../lightning/lightningUtils'
|
||||
|
||||
export interface TransferMethodOptions {
|
||||
/**
|
||||
@@ -31,6 +37,26 @@ export interface TransferMethodOptions {
|
||||
meltQuote: MeltQuoteBolt11Response
|
||||
invoiceExpiry: Date
|
||||
}
|
||||
/**
|
||||
* NUT-30 onchain melt.
|
||||
* - `address`: the Bitcoin address the mint will pay. MAINNET only — see
|
||||
* `BitcoinUtils.isPayableBitcoinAddress`.
|
||||
* - `meltQuote`: the mint's melt quote. Unlike bolt11 it carries no single
|
||||
* `fee_reserve` but a list of `fee_options` tiers, fixed for the quote's life.
|
||||
* - `feeIndex`: the tier the user picked, by the mint's `fee_index` (which is an
|
||||
* identifier, NOT a rank). Locks on execute — once the mint sets
|
||||
* `selected_fee_index` it MUST NOT execute the quote with a different one.
|
||||
* - `quoteExpiry`: when the QUOTE stops being executable. Emphatically not when
|
||||
* the payment stops being settleable: a broadcast transaction can take many
|
||||
* blocks to confirm, long after this passes. Nothing may expire a transfer on
|
||||
* the strength of it once the melt has been submitted.
|
||||
*/
|
||||
onchain: {
|
||||
address: string
|
||||
meltQuote: MeltQuoteOnchainResponse
|
||||
feeIndex: number
|
||||
quoteExpiry: Date
|
||||
}
|
||||
}
|
||||
|
||||
export type TransferMethod = keyof TransferMethodOptions
|
||||
@@ -44,8 +70,90 @@ export type TransferMethodPayload<M extends TransferMethod = TransferMethod> =
|
||||
*
|
||||
* Example:
|
||||
* { method: 'bolt11', options: { encodedInvoice, meltQuote, invoiceExpiry } }
|
||||
* { method: 'onchain', options: { address, meltQuote, feeIndex, quoteExpiry } }
|
||||
*/
|
||||
export type TransferMethodInput = {
|
||||
method: 'bolt11'
|
||||
options: TransferMethodOptions['bolt11']
|
||||
[M in TransferMethod]: {method: M; options: TransferMethodOptions[M]}
|
||||
}[TransferMethod]
|
||||
|
||||
/**
|
||||
* Everything the shared transfer lifecycle needs to know about a rail, resolved from
|
||||
* the method payload in one place.
|
||||
*
|
||||
* The lifecycle reads these instead of branching on `method` at each site it needs a
|
||||
* fee or an expiry, so adding a rail means adding a case here rather than hunting for
|
||||
* every `if (method === 'bolt11')` in a 1200-line file.
|
||||
*/
|
||||
export interface ResolvedTransferMethod {
|
||||
method: TransferMethod
|
||||
/** Transaction type this rail records. */
|
||||
transactionType: TransactionType
|
||||
/** Quote id, as the mint knows it. */
|
||||
quoteId: string
|
||||
/**
|
||||
* The fee the mint may charge to settle the payment, on top of the amount.
|
||||
* bolt11: the quote's `fee_reserve`. onchain: the SELECTED tier's `fee_reserve`.
|
||||
* Either way the mint returns whatever it does not spend as NUT-08 change.
|
||||
*/
|
||||
feeReserve: number
|
||||
/** Where the money is going, as the user typed or scanned it. */
|
||||
paymentRequest: string
|
||||
/** Rail-native payment identifier: the payment hash for bolt11, none for onchain. */
|
||||
paymentId?: string
|
||||
/** When the quote stops being executable. */
|
||||
expiry: Date
|
||||
/**
|
||||
* Does an expired quote mean the transfer itself is dead?
|
||||
*
|
||||
* bolt11: yes — an expired invoice cannot be paid, so there is nothing to wait for.
|
||||
* onchain: NO. The expiry bounds executing the QUOTE, not confirming the PAYMENT.
|
||||
* Once the mint has broadcast, the transaction confirms on the chain's schedule
|
||||
* and may well outlive the quote. Expiring the transfer then would mark a real,
|
||||
* in-flight payment dead and hide it from the user.
|
||||
*/
|
||||
expiresPendingTransfer: boolean
|
||||
}
|
||||
|
||||
export const resolveTransferMethod = (
|
||||
input: TransferMethodInput,
|
||||
): ResolvedTransferMethod => {
|
||||
switch (input.method) {
|
||||
case 'bolt11': {
|
||||
const {meltQuote, encodedInvoice, invoiceExpiry} = input.options
|
||||
return {
|
||||
method: 'bolt11',
|
||||
transactionType: TransactionType.TRANSFER,
|
||||
quoteId: meltQuote.quote,
|
||||
feeReserve: meltQuote.fee_reserve.toNumber(),
|
||||
paymentRequest: encodedInvoice,
|
||||
paymentId: LightningUtils.getInvoiceData(
|
||||
LightningUtils.decodeInvoice(encodedInvoice),
|
||||
).payment_hash,
|
||||
expiry: invoiceExpiry,
|
||||
expiresPendingTransfer: true,
|
||||
}
|
||||
}
|
||||
case 'onchain': {
|
||||
const {meltQuote, address, feeIndex, quoteExpiry} = input.options
|
||||
const tier = meltQuote.fee_options.find(o => o.fee_index === feeIndex)
|
||||
|
||||
if (!tier) {
|
||||
// The mint MUST reject a fee_index it never offered, so failing here
|
||||
// saves a round-trip and a burned quote.
|
||||
throw new Error(
|
||||
`Fee index ${feeIndex} was not offered by the mint for quote ${meltQuote.quote}`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
method: 'onchain',
|
||||
transactionType: TransactionType.TRANSFER_ONCHAIN,
|
||||
quoteId: meltQuote.quote,
|
||||
feeReserve: tier.fee_reserve.toNumber(),
|
||||
paymentRequest: address,
|
||||
expiry: quoteExpiry,
|
||||
expiresPendingTransfer: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
/**
|
||||
* Transfer (lightning melt) operation lifecycle API.
|
||||
* Transfer (melt) operation lifecycle API — one lifecycle, two payment rails.
|
||||
*
|
||||
* Splits the historical monolithic `transferTask` into explicit lifecycle methods:
|
||||
* Handles BOLT11 lightning melt (NUT-05) and Bitcoin onchain melt (NUT-30). The rails
|
||||
* share every step below; what differs between them is resolved once, in
|
||||
* `resolveTransferMethod` (see transferMethods.ts), rather than branched on at each
|
||||
* site that needs a fee or an expiry. There is deliberately ONE copy of the proof
|
||||
* reservation, the preemptive swap, and `_handleExecuteError` — that error matrix
|
||||
* (paid-despite-error / already-spent / pending-at-mint / clean-unpaid) is the most
|
||||
* safety-critical code in the wallet and must not be forked per rail.
|
||||
*
|
||||
* The one place the rails genuinely diverge is settlement:
|
||||
*
|
||||
* bolt11 — usually settles synchronously (PAID on the melt response). When it does
|
||||
* not, a websocket + poller watch the quote.
|
||||
* onchain — NEVER settles synchronously. NUT-30 mandates that the mint answer
|
||||
* PENDING and broadcast in the background, so every onchain melt goes
|
||||
* through the PENDING path and is resolved later by the pending-queue
|
||||
* sweep. Confirmation is bounded by block times, so there is no websocket
|
||||
* and no poller: a ~60s sweep is already far finer-grained than the thing
|
||||
* it waits for. (Same reasoning as the onchain deposit watcher.)
|
||||
*
|
||||
* Lifecycle methods:
|
||||
*
|
||||
* prepare() → PreparedTransferData (DRAFT → PREPARED, melt reservation OPEN,
|
||||
* preemptive swap done if beneficial)
|
||||
@@ -30,6 +49,7 @@ import {
|
||||
normalizeProofAmounts,
|
||||
MeltProofsResponse,
|
||||
MeltQuoteBolt11Response,
|
||||
MeltQuoteOnchainResponse,
|
||||
MeltQuoteState,
|
||||
Mint as CashuMint,
|
||||
Wallet as CashuWallet,
|
||||
@@ -67,7 +87,16 @@ import {ProofReservation} from '../proofReservation'
|
||||
import {Database, ReservationRow} from '../../sqlite'
|
||||
import {poller} from '../../../utils/poller'
|
||||
import {Err} from '../../../utils/AppError'
|
||||
import {TransferMethodInput} from './transferMethods'
|
||||
import {
|
||||
ResolvedTransferMethod,
|
||||
TransferMethod,
|
||||
TransferMethodInput,
|
||||
resolveTransferMethod,
|
||||
} from './transferMethods'
|
||||
import {BitcoinUtils} from '../../bitcoin/bitcoinUtils'
|
||||
|
||||
/** Any melt quote, whichever rail produced it. */
|
||||
type AnyMeltQuote = MeltQuoteBolt11Response | MeltQuoteOnchainResponse
|
||||
|
||||
const {mintsStore, proofsStore, transactionsStore, walletStore} = rootStoreInstance
|
||||
|
||||
@@ -77,11 +106,11 @@ const {mintsStore, proofsStore, transactionsStore, walletStore} = rootStoreInsta
|
||||
|
||||
export interface PrepareTransferInput {
|
||||
mintBalance: MintBalance
|
||||
/** Amount the recipient receives (excludes lightning + mint fees). */
|
||||
/** Amount the recipient receives (excludes network + mint fees). */
|
||||
amount: number
|
||||
unit: MintUnit
|
||||
memo: string
|
||||
/** Transfer method discriminator (currently only `bolt11`). */
|
||||
/** Transfer method discriminator: `bolt11` or `onchain`. */
|
||||
method: TransferMethodInput
|
||||
/** NWC request that triggered this transfer (optional). */
|
||||
nwcEvent?: NostrEvent
|
||||
@@ -109,22 +138,40 @@ export interface PreparedTransferData {
|
||||
mintUrl: string
|
||||
unit: MintUnit
|
||||
amountToTransfer: number
|
||||
meltQuote: MeltQuoteBolt11Response
|
||||
invoiceExpiry: Date
|
||||
meltQuote: AnyMeltQuote
|
||||
path: TransferPath
|
||||
method: TransferMethodInput
|
||||
/** The rail's facts, resolved once (fee reserve, expiry, tx type, quote id). */
|
||||
resolved: ResolvedTransferMethod
|
||||
/** Proofs locked under the melt reservation (the operation's inputs). */
|
||||
proofsToMeltFrom: Proof[]
|
||||
proofsToMeltFromAmount: number
|
||||
/** Mint swap fee charged for melting these specific proofs. */
|
||||
meltFeeReserve: number
|
||||
/** Lightning fee reserve (mirror of meltQuote.fee_reserve). */
|
||||
lightningFeeReserve: number
|
||||
/**
|
||||
* The network fee the mint may charge to settle: the quote's `fee_reserve` for
|
||||
* bolt11, the SELECTED tier's `fee_reserve` for onchain. Whatever the mint does
|
||||
* not spend comes back as NUT-08 change.
|
||||
*/
|
||||
feeReserve: number
|
||||
/** Fee paid for the preemptive swap (0 if no swap ran). */
|
||||
preemptiveSwapFeePaid: number
|
||||
nwcEvent?: NostrEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Which audit-trail keys a rail writes its fees under.
|
||||
*
|
||||
* The numbers mean the same thing on both rails, but a transaction's data is read by
|
||||
* humans looking at a support ticket — calling a miner fee "lightningFeePaid" would be
|
||||
* actively misleading. bolt11 keeps its historical names so existing history renders
|
||||
* unchanged.
|
||||
*/
|
||||
const FEE_KEYS: Record<TransferMethod, {reserve: string; paid: string}> = {
|
||||
bolt11: {reserve: 'lightningFeeReserve', paid: 'lightningFeePaid'},
|
||||
onchain: {reserve: 'onchainFeeReserve', paid: 'onchainFeePaid'},
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// prepare()
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -135,17 +182,29 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
|
||||
if (amount <= 0) {
|
||||
throw new ValidationError('Amount to transfer must be above zero.')
|
||||
}
|
||||
if (method.method !== 'bolt11') {
|
||||
if (method.method !== 'bolt11' && method.method !== 'onchain') {
|
||||
throw new ValidationError(`Unsupported transfer method: ${(method as any).method}`)
|
||||
}
|
||||
|
||||
const {meltQuote, encodedInvoice, invoiceExpiry} = method.options
|
||||
const resolved = resolveTransferMethod(method)
|
||||
const meltQuote = method.options.meltQuote
|
||||
const mintUrl = mintBalance.mintUrl
|
||||
const mintInstance = mintsStore.findByUrl(mintUrl)
|
||||
if (!mintInstance) {
|
||||
throw new ValidationError('Could not find mint', {mintUrl})
|
||||
}
|
||||
|
||||
// Second line of defence on the destination network. The Pay screen already refuses
|
||||
// non-mainnet addresses, but this is the last point before real money moves and an
|
||||
// onchain payment cannot be taken back — so the check lives here too, where every
|
||||
// caller (screen, NWC, a future one) must pass through it.
|
||||
if (method.method === 'onchain' && !BitcoinUtils.isPayableBitcoinAddress(resolved.paymentRequest)) {
|
||||
throw new ValidationError(
|
||||
'Not a mainnet Bitcoin address. Minibits will not pay to testnet or regtest addresses.',
|
||||
{address: resolved.paymentRequest},
|
||||
)
|
||||
}
|
||||
|
||||
// ── Create or load the draft transaction ────────────────────────────
|
||||
let transaction: Transaction | undefined
|
||||
let transactionData: TransactionData[] = []
|
||||
@@ -168,9 +227,9 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
|
||||
createdAt: new Date(),
|
||||
})
|
||||
transaction = await transactionsStore.addTransaction({
|
||||
type: TransactionType.TRANSFER,
|
||||
type: resolved.transactionType,
|
||||
amount,
|
||||
fee: meltQuote.fee_reserve.toNumber(),
|
||||
fee: resolved.feeReserve,
|
||||
unit,
|
||||
data: JSON.stringify(transactionData),
|
||||
memo,
|
||||
@@ -183,23 +242,31 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
|
||||
}
|
||||
|
||||
const transactionId = transaction.id
|
||||
const paymentHash = LightningUtils.getInvoiceData(
|
||||
LightningUtils.decodeInvoice(encodedInvoice),
|
||||
).payment_hash
|
||||
transaction.update({paymentId: paymentHash, quote: meltQuote.quote})
|
||||
|
||||
transaction.update({
|
||||
quote: resolved.quoteId,
|
||||
// The destination, so the transaction detail can show it (and, for onchain,
|
||||
// so the user can check where their money actually went).
|
||||
paymentRequest: resolved.paymentRequest,
|
||||
// bolt11 has a payment hash; onchain has nothing equivalent until the mint
|
||||
// broadcasts, at which point it gets an `outpoint` instead.
|
||||
...(resolved.paymentId && {paymentId: resolved.paymentId}),
|
||||
})
|
||||
|
||||
// ── Validations ─────────────────────────────────────────────────────
|
||||
const lightningFeeReserve = meltQuote.fee_reserve.toNumber()
|
||||
if (amount + lightningFeeReserve > mintBalance.balances[unit]!) {
|
||||
const feeReserve = resolved.feeReserve
|
||||
if (amount + feeReserve > mintBalance.balances[unit]!) {
|
||||
throw new ValidationError(
|
||||
'Mint balance is insufficient to cover the amount to transfer with the expected Lightning fees.',
|
||||
'Mint balance is insufficient to cover the amount to transfer with the expected network fees.',
|
||||
{transactionId},
|
||||
)
|
||||
}
|
||||
if (isBefore(invoiceExpiry, new Date())) {
|
||||
if (isBefore(resolved.expiry, new Date())) {
|
||||
throw new ValidationError(
|
||||
'This invoice has already expired and can not be paid.',
|
||||
{invoiceExpiry, transactionId},
|
||||
resolved.method === 'bolt11'
|
||||
? 'This invoice has already expired and can not be paid.'
|
||||
: 'This payment quote has expired. Please request a new one.',
|
||||
{expiry: resolved.expiry, transactionId},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -209,9 +276,10 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
|
||||
|
||||
const walletInstance = (await walletStore.getWallet(mintUrl, unit, {withSeed: true})) as CashuWallet
|
||||
|
||||
// Select proofs covering amount + lightning fee_reserve + the mint's
|
||||
// per-proof input fee on the selected proofs. The helper iterates to a fixed
|
||||
// point so the inputs always cover their own input fee — without it, the fee
|
||||
// Select proofs covering amount + the network fee_reserve + the mint's per-proof
|
||||
// input fee on the selected proofs — `amount + fee_reserve + input_fee`, which is
|
||||
// what both NUT-05 and NUT-30 require the inputs to cover. The helper iterates to a
|
||||
// fixed point so the inputs always cover their own input fee — without it, the fee
|
||||
// computed on the first selection can be too low for the (larger) re-selected
|
||||
// set and the mint rejects with "not enough inputs provided for melt".
|
||||
let proofsToMeltFrom: Proof[]
|
||||
@@ -219,7 +287,7 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
|
||||
try {
|
||||
;({proofsToSend: proofsToMeltFrom, feeReserve: meltFeeReserve} =
|
||||
CashuUtils.selectProofsToSendWithFeeReserve(
|
||||
amount + lightningFeeReserve,
|
||||
amount + feeReserve,
|
||||
proofsFromMint,
|
||||
selected => walletInstance.getFeesForProofs(selected).toNumber(),
|
||||
{caller: 'TransferOperationApi.prepare'},
|
||||
@@ -233,7 +301,7 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
|
||||
})
|
||||
}
|
||||
|
||||
let amountWithFees = amount + lightningFeeReserve + meltFeeReserve
|
||||
let amountWithFees = amount + feeReserve + meltFeeReserve
|
||||
let proofsToMeltFromAmount = CashuUtils.getProofsAmount(proofsToMeltFrom)
|
||||
|
||||
// ── Preemptive swap path ────────────────────────────────────────────
|
||||
@@ -319,10 +387,11 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
|
||||
transactionData.push({
|
||||
status: TransactionStatus.PREPARED,
|
||||
proofsToMeltFromAmount,
|
||||
lightningFeeReserve,
|
||||
[FEE_KEYS[resolved.method].reserve]: feeReserve,
|
||||
meltFeeReserve,
|
||||
path,
|
||||
method: method.method,
|
||||
...(method.method === 'onchain' && {feeIndex: method.options.feeIndex}),
|
||||
...(preemptiveSwapFeePaid > 0 && {preemptiveSwapFeePaid}),
|
||||
createdAt: new Date(),
|
||||
})
|
||||
@@ -349,10 +418,11 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
|
||||
|
||||
log.debug('[TransferOperationApi.prepare]', 'Prepared', {
|
||||
transactionId,
|
||||
method: resolved.method,
|
||||
path,
|
||||
amount,
|
||||
meltFeeReserve,
|
||||
lightningFeeReserve,
|
||||
feeReserve,
|
||||
preemptiveSwapFeePaid,
|
||||
lockedCount: proofsToMeltFrom.length,
|
||||
})
|
||||
@@ -364,13 +434,13 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
|
||||
unit,
|
||||
amountToTransfer: amount,
|
||||
meltQuote,
|
||||
invoiceExpiry,
|
||||
path,
|
||||
method,
|
||||
resolved,
|
||||
proofsToMeltFrom,
|
||||
proofsToMeltFromAmount,
|
||||
meltFeeReserve,
|
||||
lightningFeeReserve,
|
||||
feeReserve,
|
||||
preemptiveSwapFeePaid,
|
||||
nwcEvent,
|
||||
}
|
||||
@@ -379,12 +449,16 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// execute()
|
||||
//
|
||||
// Marks tx EXECUTING, calls payLightningMelt, then commits atomically based on
|
||||
// the mint's quote state:
|
||||
// Marks tx EXECUTING, submits the melt, then commits atomically based on the
|
||||
// mint's quote state:
|
||||
// - PAID: inputs → SPENT, change → UNSPENT, tx → COMPLETED.
|
||||
// - PENDING: no proof changes, tx → PENDING; async ws/poller resolves later.
|
||||
// - PENDING: inputs stay PENDING, change committed IF the mint already returned
|
||||
// any, tx → PENDING; the watcher/monitor resolves it later.
|
||||
// - UNPAID: rollback reservation (proofs → UNSPENT) and throw.
|
||||
//
|
||||
// An onchain melt ALWAYS lands in PENDING — NUT-30 requires the mint to answer
|
||||
// PENDING and broadcast in the background. It is never PAID here.
|
||||
//
|
||||
// Errors are routed through `_handleExecuteError` which re-checks the quote
|
||||
// (the mint may have paid even though the client errored) and chooses the
|
||||
// right cleanup path.
|
||||
@@ -418,6 +492,8 @@ async function execute(
|
||||
unit,
|
||||
amountToTransfer,
|
||||
meltQuote,
|
||||
method,
|
||||
resolved,
|
||||
proofsToMeltFrom,
|
||||
proofsToMeltFromAmount,
|
||||
meltFeeReserve,
|
||||
@@ -425,12 +501,29 @@ async function execute(
|
||||
|
||||
tx.update({status: TransactionStatus.EXECUTING})
|
||||
|
||||
let meltResponse: MeltProofsResponse
|
||||
try {
|
||||
meltResponse = await walletStore.payLightningMelt(
|
||||
/**
|
||||
* Submit the melt on whichever rail this transfer is on.
|
||||
*
|
||||
* `increaseCounterBy` is the shared outputs-error healing path: the mint says our
|
||||
* blinded outputs were already signed, so we skip the counter forward and retry.
|
||||
*/
|
||||
const submitMelt = (increaseCounterBy?: number): Promise<MeltProofsResponse> => {
|
||||
if (method.method === 'onchain') {
|
||||
return walletStore.payOnchainMelt(
|
||||
mintUrl,
|
||||
unit,
|
||||
meltQuote,
|
||||
method.options.meltQuote,
|
||||
proofsToMeltFrom,
|
||||
method.options.feeIndex,
|
||||
tx.id,
|
||||
increaseCounterBy ? {increaseCounterBy} : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
return walletStore.payLightningMelt(
|
||||
mintUrl,
|
||||
unit,
|
||||
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}),
|
||||
},
|
||||
})
|
||||
|
||||
// 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.', {
|
||||
throw new MintError(
|
||||
resolved.method === 'onchain'
|
||||
? 'The onchain payment has not been made.'
|
||||
: 'Lightning payment has not been paid.',
|
||||
{
|
||||
meltResponseQuote: meltResponse.quote,
|
||||
transactionId: tx.id,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -669,7 +804,7 @@ async function finalize(transactionId: number): Promise<CompletedTransaction> {
|
||||
throw new ValidationError('Transfer has no quote id; cannot finalize.', {transactionId})
|
||||
}
|
||||
|
||||
const quote = await walletStore.checkLightningMeltQuote(tx.mint, tx.quote)
|
||||
const quote = await _checkQuote(tx, tx.quote)
|
||||
if (quote.state !== MeltQuoteState.PAID) {
|
||||
throw new MintError(
|
||||
`Cannot finalize transfer; mint reports quote state ${quote.state}.`,
|
||||
@@ -704,14 +839,17 @@ async function refresh(transactionId: number): Promise<Transaction> {
|
||||
return tx
|
||||
}
|
||||
|
||||
const quote = await walletStore.checkLightningMeltQuote(tx.mint, tx.quote)
|
||||
const isOnchain = _isOnchainTransfer(tx)
|
||||
const quote = await _checkQuote(tx, tx.quote)
|
||||
|
||||
if (quote.state === MeltQuoteState.PAID) {
|
||||
const completed = await _finalizePaid(tx, quote)
|
||||
EventEmitter.emit('ev_asyncMeltResult', {
|
||||
transactionId,
|
||||
status: TransactionStatus.COMPLETED,
|
||||
message: translate('transactionResult_lightningInvoicePaidFee', {
|
||||
message: isOnchain
|
||||
? translate('transactionResult_onchainPaymentConfirmed')
|
||||
: translate('transactionResult_lightningInvoicePaidFee', {
|
||||
fee: `${formatCurrency(tx.fee, getCurrency(tx.unit).code)} ${getCurrency(tx.unit).code}`,
|
||||
}),
|
||||
})
|
||||
@@ -719,10 +857,14 @@ async function refresh(transactionId: number): Promise<Transaction> {
|
||||
}
|
||||
|
||||
if (quote.state === MeltQuoteState.UNPAID) {
|
||||
// Lightning failed → proofs go back to spendable, tx is REVERTED.
|
||||
// The payment failed → proofs go back to spendable, tx is REVERTED.
|
||||
// (Original `handlePendingMeltTask` stamped ERROR here, but sync has
|
||||
// always used REVERTED for the same logical event — REVERTED is the
|
||||
// accurate terminal status, since the ecash IS recoverable.)
|
||||
//
|
||||
// For onchain this means the mint never broadcast, or dropped the transaction
|
||||
// before it was mined. A CONFIRMED payment can never come back here: once it is
|
||||
// in a block the mint reports PAID, and PAID is terminal.
|
||||
const pendingProofs = proofsStore
|
||||
.getByTransactionId(tx.id)
|
||||
.filter(p => p.state === 'PENDING')
|
||||
@@ -730,10 +872,14 @@ async function refresh(transactionId: number): Promise<Transaction> {
|
||||
proofsStore.revertToSpendable(pendingProofs)
|
||||
}
|
||||
|
||||
const failureMessage = isOnchain
|
||||
? translate('transactionResult_onchainPaymentFailed')
|
||||
: translate('transactionResult_lightningPaymentFailed')
|
||||
|
||||
const txData = _parseData(tx)
|
||||
txData.push({
|
||||
status: TransactionStatus.REVERTED,
|
||||
message: translate('transactionResult_lightningPaymentFailed'),
|
||||
message: failureMessage,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
tx.update({status: TransactionStatus.REVERTED, data: JSON.stringify(txData)})
|
||||
@@ -743,12 +889,26 @@ async function refresh(transactionId: number): Promise<Transaction> {
|
||||
EventEmitter.emit('ev_asyncMeltResult', {
|
||||
transactionId,
|
||||
status: TransactionStatus.REVERTED,
|
||||
message: translate('transactionResult_lightningPaymentFailed'),
|
||||
message: failureMessage,
|
||||
})
|
||||
return tx
|
||||
}
|
||||
|
||||
// PENDING: ws/poller will call back later.
|
||||
// ── Still PENDING ───────────────────────────────────────────────────
|
||||
// For onchain, the mint may only have broadcast between our last check and this
|
||||
// one — so the outpoint can appear now, while the state has not moved. Record it
|
||||
// as soon as it exists: it is the only way the user can follow their payment on a
|
||||
// block explorer, independently of the mint, and it is the thing they will ask for
|
||||
// if the mint goes quiet.
|
||||
const outpoint = _outpointOf(quote)
|
||||
if (outpoint && !tx.outpoint) {
|
||||
tx.update({outpoint})
|
||||
log.debug('[TransferOperationApi.refresh] Onchain payment broadcast', {
|
||||
transactionId,
|
||||
outpoint,
|
||||
})
|
||||
}
|
||||
|
||||
return tx
|
||||
}
|
||||
|
||||
@@ -756,6 +916,50 @@ async function refresh(transactionId: number): Promise<Transaction> {
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The proof-of-payment a rail produces, if any.
|
||||
*
|
||||
* bolt11 settles with a preimage. Onchain has no preimage — its evidence is the
|
||||
* `outpoint`, handled separately — so this is simply absent there, and the tx's
|
||||
* `proof` column stays empty rather than holding something invented.
|
||||
*/
|
||||
function _preimageOf(quote: object): string | undefined {
|
||||
const preimage = (quote as MeltQuoteBolt11Response).payment_preimage
|
||||
return preimage ?? undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* `txid:vout` of the onchain payment, once the mint has broadcast it.
|
||||
*
|
||||
* Null until then, and never present on bolt11. This is the only handle the user has
|
||||
* on an onchain payment: with it they can watch the transaction confirm on any block
|
||||
* explorer, independently of the mint.
|
||||
*/
|
||||
function _outpointOf(quote: object): string | undefined {
|
||||
const outpoint = (quote as MeltQuoteOnchainResponse).outpoint
|
||||
return outpoint ?? undefined
|
||||
}
|
||||
|
||||
/** Is this transaction an onchain melt? The tx type is the discriminator. */
|
||||
function _isOnchainTransfer(tx: Transaction): boolean {
|
||||
return tx.type === TransactionType.TRANSFER_ONCHAIN
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the mint for the current state of a transfer's quote, on the right rail.
|
||||
*
|
||||
* For onchain this — and ONLY this — is what says whether the payment settled. The
|
||||
* mint spending our inputs means it BROADCAST; it does not mean the transaction
|
||||
* confirmed. Reading settlement off proof state (as sync does for bolt11) would
|
||||
* complete an onchain transfer the moment it left the mint, which is exactly when it
|
||||
* is least certain.
|
||||
*/
|
||||
async function _checkQuote(tx: Transaction, quoteId: string): Promise<AnyMeltQuote> {
|
||||
return _isOnchainTransfer(tx)
|
||||
? await walletStore.checkOnchainMeltQuote(tx.mint, quoteId)
|
||||
: await walletStore.checkLightningMeltQuote(tx.mint, quoteId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralised error-recovery flow for `execute`. The mint may have paid the
|
||||
* invoice even though the client errored — so we re-check the quote and choose
|
||||
@@ -771,11 +975,11 @@ async function _handleExecuteError(
|
||||
},
|
||||
): Promise<never> {
|
||||
const {tx, transactionData, reservation, prepared} = ctx
|
||||
const {mintUrl, unit, meltQuote, proofsToMeltFrom, proofsToMeltFromAmount} = prepared
|
||||
const {mintUrl, unit, resolved, proofsToMeltFrom, proofsToMeltFromAmount} = prepared
|
||||
|
||||
let meltQuoteCheck: MeltQuoteBolt11Response
|
||||
let meltQuoteCheck: AnyMeltQuote
|
||||
try {
|
||||
meltQuoteCheck = await walletStore.checkLightningMeltQuote(mintUrl, meltQuote.quote)
|
||||
meltQuoteCheck = await _checkQuote(tx, resolved.quoteId)
|
||||
} catch (checkError: any) {
|
||||
// Quote check itself failed — leave the reservation as-is, the orphan
|
||||
// recovery sweep + sync will reconcile on the next startup.
|
||||
@@ -891,11 +1095,12 @@ async function _handleExecuteError(
|
||||
*/
|
||||
async function _finalizePaid(
|
||||
tx: Transaction,
|
||||
quote: MeltQuoteBolt11Response,
|
||||
quote: AnyMeltQuote,
|
||||
): Promise<CompletedTransaction> {
|
||||
const transactionId = tx.id
|
||||
const mintUrl = tx.mint
|
||||
const unit = tx.unit
|
||||
const method: TransferMethod = _isOnchainTransfer(tx) ? 'onchain' : 'bolt11'
|
||||
|
||||
// pendingProofs may be empty when called from sync after a bulk SPENT
|
||||
// marking — but we still need to unblind change and atomic-commit the tx
|
||||
@@ -915,9 +1120,14 @@ async function _finalizePaid(
|
||||
: (_readNumberFromData(tx, 'proofsToMeltFromAmount') ?? tx.amount)
|
||||
const amountToTransfer = tx.amount
|
||||
const meltFeeReserve = _readNumberFromData(tx, 'meltFeeReserve') ?? 0
|
||||
let totalFeePaid = proofsToMeltFromAmount - amountToTransfer
|
||||
let lightningFeePaid = totalFeePaid - meltFeeReserve
|
||||
const meltFeePaid = meltFeeReserve
|
||||
|
||||
// An onchain melt may have had its change returned ALREADY, on the PENDING melt
|
||||
// response (the mint knows its miner fee as soon as it builds the transaction).
|
||||
// That change is banked and `_unblindMeltChange` will correctly find nothing left
|
||||
// to reconstruct — but it is still not fee. Counting it here would report the
|
||||
// user's own returned money as money they spent. Read it before pushing this
|
||||
// status entry, which writes a `returnedAmount` of its own.
|
||||
const alreadyReturned = _readNumberFromData(tx, 'returnedAmount') ?? 0
|
||||
|
||||
// Unblind change BEFORE opening the reservation; same fallback behaviour as
|
||||
// the pre-reservation code — change recovery failure doesn't block finalize.
|
||||
@@ -929,26 +1139,32 @@ async function _finalizePaid(
|
||||
quoteChange: quote.change,
|
||||
})
|
||||
|
||||
let returnedAmount = 0
|
||||
let returnedNow = 0
|
||||
let outputToken: string | undefined
|
||||
if (unblinded.change.length > 0) {
|
||||
returnedAmount = CashuUtils.getProofsAmount(unblinded.change)
|
||||
returnedNow = CashuUtils.getProofsAmount(unblinded.change)
|
||||
outputToken = getEncodedToken({mint: mintUrl, proofs: unblinded.change, unit})
|
||||
totalFeePaid -= returnedAmount
|
||||
lightningFeePaid = totalFeePaid - meltFeeReserve
|
||||
}
|
||||
|
||||
const returnedAmount = alreadyReturned + returnedNow
|
||||
const totalFeePaid = proofsToMeltFromAmount - amountToTransfer - returnedAmount
|
||||
const networkFeePaid = totalFeePaid - meltFeeReserve
|
||||
const meltFeePaid = meltFeeReserve
|
||||
|
||||
const currentSpendable = proofsStore.getUnitBalance(unit)?.unitBalance ?? 0
|
||||
const balanceAfter = currentSpendable + returnedAmount
|
||||
const balanceAfter = currentSpendable + returnedNow
|
||||
|
||||
const preimage = _preimageOf(quote)
|
||||
const outpoint = _outpointOf(quote)
|
||||
|
||||
const txData = _parseData(tx)
|
||||
txData.push({
|
||||
status: TransactionStatus.COMPLETED,
|
||||
lightningFeePaid,
|
||||
[FEE_KEYS[method].paid]: networkFeePaid,
|
||||
meltFeePaid,
|
||||
returnedAmount,
|
||||
//@ts-ignore
|
||||
preimage: quote.payment_preimage,
|
||||
...(preimage && {preimage}),
|
||||
...(outpoint && {outpoint}),
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
@@ -986,14 +1202,16 @@ async function _finalizePaid(
|
||||
fee: totalFeePaid,
|
||||
balanceAfter,
|
||||
...(outputToken && {outputToken}),
|
||||
//@ts-ignore — payment_preimage is loosely typed in cashu-ts
|
||||
...(quote.payment_preimage && {proof: quote.payment_preimage}),
|
||||
...(preimage && {proof: preimage}),
|
||||
...(outpoint && {outpoint}),
|
||||
},
|
||||
})
|
||||
|
||||
log.debug('[TransferOperationApi._finalizePaid] Transaction completed', {
|
||||
transactionId,
|
||||
method,
|
||||
totalFeePaid,
|
||||
returnedAmount,
|
||||
})
|
||||
return _assertCompleted(tx, transactionId)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {MeltQuoteBolt11Response} from '@cashu/cashu-ts'
|
||||
import {MeltQuoteBolt11Response, MeltQuoteOnchainResponse} from '@cashu/cashu-ts'
|
||||
import {rootStoreInstance} from '../../models'
|
||||
import {TransactionTaskResult} from '../walletService'
|
||||
import {MintBalance} from '../../models/Mint'
|
||||
@@ -12,6 +12,7 @@ import { translate } from '../../i18n'
|
||||
const {transactionsStore} = rootStoreInstance
|
||||
|
||||
export const TRANSFER_TASK = 'transferTask'
|
||||
export const TRANSFER_ONCHAIN_TASK = 'transferOnchainTask'
|
||||
|
||||
/**
|
||||
* Backward-compatible transfer (lightning melt) task wrapper.
|
||||
@@ -134,3 +135,120 @@ export const transferTask = async function (
|
||||
} as TransactionTaskResult
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Onchain (NUT-30) melt task.
|
||||
*
|
||||
* Same two-step lifecycle as `transferTask` — `prepare()` then `execute()`, sharing
|
||||
* the one copy of the reservation, preemptive-swap and error-recovery machinery. Only
|
||||
* the result mapping differs, and it differs because the RAILS differ:
|
||||
*
|
||||
* `transferTask` treats PENDING as the exception (lightning usually settles in the
|
||||
* same round-trip). Here PENDING is the ONLY outcome. NUT-30 requires the mint to
|
||||
* answer PENDING and broadcast in the background, so a COMPLETED transaction coming
|
||||
* back from `execute()` would mean the mint did something the spec forbids — we still
|
||||
* handle it rather than assert on it, since being wrong about a payment that already
|
||||
* went through helps nobody.
|
||||
*
|
||||
* The transaction is resolved later by the pending-transfer sweep, which checks the
|
||||
* quote until the mint reports PAID (confirmed).
|
||||
*/
|
||||
export const transferOnchainTask = async function (
|
||||
mintBalanceToTransferFrom: MintBalance,
|
||||
amountToTransfer: number,
|
||||
unit: MintUnit,
|
||||
meltQuote: MeltQuoteOnchainResponse,
|
||||
feeIndex: number,
|
||||
memo: string,
|
||||
quoteExpiry: Date,
|
||||
address: string,
|
||||
nwcEvent?: NostrEvent,
|
||||
draftTransactionId?: number,
|
||||
): Promise<TransactionTaskResult> {
|
||||
const mintUrl = mintBalanceToTransferFrom.mintUrl
|
||||
|
||||
log.debug('[transferOnchainTask]', {mintUrl, amountToTransfer, feeIndex, address})
|
||||
|
||||
// Lazy import avoids a circular dep across the operations module graph.
|
||||
const {TransferOperationApi} = await import('./operations/transferOperationApi')
|
||||
|
||||
let transactionIdForRecovery: number | undefined
|
||||
|
||||
try {
|
||||
const prepared = await TransferOperationApi.prepare({
|
||||
mintBalance: mintBalanceToTransferFrom,
|
||||
amount: amountToTransfer,
|
||||
unit,
|
||||
memo,
|
||||
method: {
|
||||
method: 'onchain',
|
||||
options: {address, meltQuote, feeIndex, quoteExpiry},
|
||||
},
|
||||
nwcEvent,
|
||||
draftTransactionId,
|
||||
})
|
||||
transactionIdForRecovery = prepared.transactionId
|
||||
|
||||
const settled = await TransferOperationApi.execute(prepared)
|
||||
|
||||
if (settled.status === TransactionStatus.COMPLETED) {
|
||||
const totalFeePaid = settled.fee ?? 0
|
||||
const meltFeePaid = prepared.meltFeeReserve + prepared.preemptiveSwapFeePaid
|
||||
return {
|
||||
taskFunction: TRANSFER_ONCHAIN_TASK,
|
||||
mintUrl,
|
||||
transaction: settled,
|
||||
message: translate('transactionResult_onchainPaymentConfirmed'),
|
||||
meltFeePaid,
|
||||
totalFeePaid,
|
||||
meltQuote,
|
||||
nwcEvent,
|
||||
} as TransactionTaskResult
|
||||
}
|
||||
|
||||
// The normal path: broadcast, awaiting confirmations.
|
||||
return {
|
||||
taskFunction: TRANSFER_ONCHAIN_TASK,
|
||||
mintUrl,
|
||||
transaction: settled,
|
||||
message: translate('transactionResult_onchainPaymentBroadcast'),
|
||||
meltQuote,
|
||||
nwcEvent,
|
||||
} as TransactionTaskResult
|
||||
} catch (e: any) {
|
||||
const txAfterError = transactionIdForRecovery
|
||||
? transactionsStore.findById(transactionIdForRecovery)
|
||||
: undefined
|
||||
|
||||
// A PENDING transaction is in flight at the mint — never stamp it ERROR, that
|
||||
// would hide a real payment. execute()'s handler may also already have marked it
|
||||
// RECOVERED (paid despite a client error).
|
||||
if (txAfterError && txAfterError.status !== TransactionStatus.PENDING) {
|
||||
if (
|
||||
txAfterError.status !== TransactionStatus.RECOVERED &&
|
||||
txAfterError.status !== TransactionStatus.ERROR
|
||||
) {
|
||||
let transactionData: TransactionData[] = []
|
||||
try { transactionData = JSON.parse(txAfterError.data) } catch {}
|
||||
transactionData.push({
|
||||
status: TransactionStatus.ERROR,
|
||||
error: WalletUtils.formatError(e),
|
||||
createdAt: new Date(),
|
||||
})
|
||||
txAfterError.update({
|
||||
status: TransactionStatus.ERROR,
|
||||
data: JSON.stringify(transactionData),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
taskFunction: TRANSFER_ONCHAIN_TASK,
|
||||
mintUrl,
|
||||
transaction: txAfterError,
|
||||
message: e.message,
|
||||
error: WalletUtils.formatError(e),
|
||||
nwcEvent,
|
||||
} as TransactionTaskResult
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {Proof, ProofState} from '../models/Proof'
|
||||
import {
|
||||
Transaction,
|
||||
} from '../models/Transaction'
|
||||
import {MeltQuoteBolt11Response, TokenMetadata} from '@cashu/cashu-ts'
|
||||
import {MeltQuoteBolt11Response, MeltQuoteOnchainResponse, TokenMetadata} from '@cashu/cashu-ts'
|
||||
import {Mint, MintBalance} from '../models/Mint'
|
||||
import {NostrEvent} from './nostrService'
|
||||
import {Contact} from '../models/Contact'
|
||||
@@ -86,6 +86,18 @@ type WalletTaskService = {
|
||||
nwcEvent?: NostrEvent,
|
||||
draftTransactionId?: number,
|
||||
) => Promise<TransactionTaskResult>
|
||||
transferOnchainQueueAwaitable: (
|
||||
mintBalanceToTransferFrom: MintBalance,
|
||||
amountToTransfer: number,
|
||||
unit: MintUnit,
|
||||
meltQuote: MeltQuoteOnchainResponse,
|
||||
feeIndex: number,
|
||||
memo: string,
|
||||
quoteExpiry: Date,
|
||||
address: string,
|
||||
nwcEvent?: NostrEvent,
|
||||
draftTransactionId?: number,
|
||||
) => Promise<TransactionTaskResult>
|
||||
receiveQueueAwaitable: (
|
||||
mint: Mint,
|
||||
tokenMetadata: TokenMetadata,
|
||||
@@ -133,7 +145,7 @@ type WalletTaskService = {
|
||||
}) => Promise<{recoveredAmount: number}>
|
||||
recoverMeltQuoteChange: (params: {
|
||||
mintUrl: string
|
||||
meltQuote: string | MeltQuoteBolt11Response
|
||||
meltQuote: string | MeltQuoteBolt11Response | MeltQuoteOnchainResponse
|
||||
}) => Promise<{recoveredAmount: number}>
|
||||
handlePendingMeltTask: (params: {
|
||||
mintUrl: string
|
||||
@@ -191,6 +203,7 @@ export const WalletTask: WalletTaskService = {
|
||||
recoverMintQuote: MintOperationService.recoverMintQuote,
|
||||
// Melt (transfer)
|
||||
transferQueueAwaitable: MeltOperationService.transferQueueAwaitable,
|
||||
transferOnchainQueueAwaitable: MeltOperationService.transferOnchainQueueAwaitable,
|
||||
recoverMeltQuoteChange: MeltOperationService.recoverMeltQuoteChange,
|
||||
handlePendingMeltTask: MeltOperationService.handlePendingMeltTask,
|
||||
// Revert
|
||||
|
||||
Reference in New Issue
Block a user