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

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

Substance, beyond the plumbing:

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

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

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

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

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

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

155 lines
6.1 KiB
TypeScript

/**
* 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)
})
})