mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 11:18:24 +00:00
One melt lifecycle, two rails. TransferOperationApi now handles both bolt11 (NUT-05) and onchain (NUT-30) melts; what differs between them is resolved once in resolveTransferMethod rather than branched on at each site that needs a fee or an expiry. There is deliberately one copy of the proof reservation, the preemptive swap, and the execute-error recovery matrix. cashu-ts' prepareMelt is already method-agnostic (it derives the NUT-08 blank count from inputs - quote.amount, not from fee_reserve), and fee_index rides along as extraPayload on completeMelt. So the prepare -> persist meltPreview -> complete split that melt-change recovery depends on survives intact. Substance, beyond the plumbing: - Onchain change can arrive at PENDING. The mint knows its miner fee the moment it builds the transaction, so it may return the unclaimed reserve with the spec-mandated PENDING response. bolt11's PENDING branch drops change on the floor (correctly - there is none yet); doing that here would strand signed proofs that nothing would ever look for again. execute() now commits change if present, and _finalizePaid subtracts what was already returned so banked change is not reported as fee. - Settlement is quote-driven, not proof-driven. The mint spending our inputs means it BROADCAST, not that the transaction confirmed. sync's _dispatchFinalize therefore routes TRANSFER_ONCHAIN to refresh() (which asks the mint and only completes on PAID) rather than finalize(), and sync now reports the status the dispatch actually reached instead of assuming COMPLETED - otherwise it would announce a Bitcoin payment as landed while it sat unconfirmed in the mempool. - Onchain transfers are never expired. The melt quote's expiry bounds executing the quote, not confirming the payment, which can outlive it by many blocks. - Mainnet only. The CDK fakewallet hands out regtest deposit addresses for topup quotes, so testers end up with one in their clipboard; pasting it back into Pay must not spend. Refused in the parser and again in prepare(), so a screen that forgets the check cannot move money. No websocket and no poller for onchain: settlement is bounded by block times, so the existing ~60s pending sweep is already finer-grained than what it waits for. Melts go through SyncQueue for the same counter-serialisation reason mints do. 87 tsc errors (unchanged baseline), 310/310 tests, i18n clean.
155 lines
6.1 KiB
TypeScript
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)
|
|
})
|
|
})
|