diff --git a/__tests__/onchainMeltAmounts.test.ts b/__tests__/onchainMeltAmounts.test.ts index 65aa4b2d..f4dee6da 100644 --- a/__tests__/onchainMeltAmounts.test.ts +++ b/__tests__/onchainMeltAmounts.test.ts @@ -10,9 +10,11 @@ */ import { findFeeOption, + mockOnchainFeeTiers, normalizeFeeOptions, onchainMeltFloor, onchainMeltTotal, + payableFeeIndex, selectDefaultFeeOption, MINIBITS_ONCHAIN_MELT_FLOOR_SAT, } from '../src/services/wallet/operations/onchainAmounts' @@ -143,6 +145,81 @@ describe('onchainMeltFloor', () => { }) }) +describe('mockOnchainFeeTiers (debug-only picker mock)', () => { + const realTier = normalizeFeeOptions([ + {fee_index: 0, fee_reserve: amount(400), estimated_blocks: 6}, + ]) + + it('turns the fakewallet\'s single tier into three', () => { + const tiers = mockOnchainFeeTiers(realTier) + expect(tiers).toHaveLength(3) + expect(tiers.map(t => t.feeReserve)).toEqual([160, 400, 1000]) + }) + + /** + * The load-bearing property. NUT-30: "The mint MUST reject a melt request with a + * fee_index that was not returned in the quote." So the mint's own tier has to survive + * the mock intact — it is the only index that can actually be paid with. + */ + it('keeps the mint\'s real tier untouched, and marks only the invented ones', () => { + const tiers = mockOnchainFeeTiers(realTier) + const real = tiers.filter(t => !t.isMock) + + expect(real).toHaveLength(1) + expect(real[0]).toEqual({feeIndex: 0, feeReserve: 400, estimatedBlocks: 6, isMock: false}) + expect(tiers.filter(t => t.isMock)).toHaveLength(2) + }) + + it('gives invented tiers indices that cannot collide with the mint\'s', () => { + const tiers = mockOnchainFeeTiers(realTier) + const indices = tiers.map(t => t.feeIndex) + expect(new Set(indices).size).toBe(3) + for (const mock of tiers.filter(t => t.isMock)) { + expect(mock.feeIndex).not.toBe(0) + } + }) + + // Overwriting tiers a mint really sent with invented ones would be worse than useless. + it('is a no-op when the mint already offered more than one tier', () => { + const realTiers = normalizeFeeOptions([ + {fee_index: 0, fee_reserve: amount(400), estimated_blocks: 6}, + {fee_index: 1, fee_reserve: amount(900), estimated_blocks: 3}, + ]) + expect(mockOnchainFeeTiers(realTiers)).toBe(realTiers) + }) + + it('is a no-op on an empty list', () => { + expect(mockOnchainFeeTiers([])).toEqual([]) + }) +}) + +describe('payableFeeIndex', () => { + const tiers = mockOnchainFeeTiers( + normalizeFeeOptions([{fee_index: 3, fee_reserve: amount(400), estimated_blocks: 6}]), + ) + + it('is the identity for a real tier', () => { + const real = tiers.find(t => !t.isMock)! + expect(payableFeeIndex(tiers, real)).toBe(3) + }) + + /** + * Selecting a fabricated tier must still submit an index the mint offered, or the melt + * is rejected outright. The CHOICE is mocked; the payment is real, at the mint's real + * price. + */ + it('falls back to the mint\'s real index for a fabricated tier', () => { + for (const mock of tiers.filter(t => t.isMock)) { + expect(payableFeeIndex(tiers, mock)).toBe(3) + } + }) + + it('is undefined when there is no real tier to fall back to', () => { + const orphan = {feeIndex: 99, feeReserve: 1, estimatedBlocks: 1, isMock: true} + expect(payableFeeIndex([orphan], orphan)).toBeUndefined() + }) +}) + describe('onchainMeltTotal', () => { it('is amount + fee reserve + input fee, per NUT-30', () => { expect(onchainMeltTotal(10000, 400, 2)).toBe(10402) diff --git a/src/screens/OnchainTransferScreen.tsx b/src/screens/OnchainTransferScreen.tsx index 1170a222..5c800797 100644 --- a/src/screens/OnchainTransferScreen.tsx +++ b/src/screens/OnchainTransferScreen.tsx @@ -46,8 +46,10 @@ import useIsInternetReachable from '../utils/useIsInternetReachable' import {MintUnit, getCurrency} from '../services/wallet/currency' import { OnchainFeeOption, + mockOnchainFeeTiers, normalizeFeeOptions, onchainMeltFloor, + payableFeeIndex, selectDefaultFeeOption, } from '../services/wallet/operations/onchainAmounts' import {MintHeader} from './Mints/MintHeader' @@ -55,6 +57,19 @@ import {MintBalanceSelector} from './Mints/MintBalanceSelector' import {ResultModalInfo} from './Wallet/ResultModalInfo' import {TranItem} from './TranDetailScreen' +/** + * Fabricate extra fee tiers so the multi-tier picker can be exercised. DEBUG ONLY. + * + * The CDK fakewallet returns exactly ONE fee option, so without this the picker always + * collapses to its single-tier read-only row and the path that matters is never run. + * Flip to `true` in a debug build to see three. + * + * The mint's real tier keeps its real fee_index and is the one actually submitted + * whatever the user picks — a fabricated index would be rejected by the mint (NUT-30 + * requires it). So the CHOICE is simulated; the payment underneath is real. + */ +const MOCK_FEE_TIERS = false + type Props = StaticScreenProps<{ address: string /** BIP21 amount hint, in sats. The user may change it. */ @@ -356,7 +371,15 @@ export const OnchainTransferScreen = observer(function OnchainTransferScreen({ro amountUnit, ) - const options = normalizeFeeOptions(quote.fee_options ?? []) + let options = normalizeFeeOptions(quote.fee_options ?? []) + + if (MOCK_FEE_TIERS && __DEV__) { + options = mockOnchainFeeTiers(options) + log.warn('[requestQuote] MOCKED fee tiers — the mint offered', { + real: quote.fee_options?.length ?? 0, + }) + } + const defaultOption = selectDefaultFeeOption(options) if (!defaultOption) { @@ -414,6 +437,19 @@ export const OnchainTransferScreen = observer(function OnchainTransferScreen({ro return } + // Never send a fabricated fee_index: the mint MUST reject one it did not offer. + // For a real tier this is the identity; for a mocked one it falls back to the + // mint's own index, so the payment is made at the mint's real price. + const feeIndex = payableFeeIndex(feeOptions, selectedFee) + + if (feeIndex === undefined) { + throw new AppError( + Err.VALIDATION_ERROR, + translate('onchainTransferScreen_noFeeOptions'), + {quote: meltQuote.quote}, + ) + } + dispatch({type: 'TRANSFER_START'}) const result: TransactionTaskResult = await WalletTask.transferOnchainQueueAwaitable( @@ -421,7 +457,7 @@ export const OnchainTransferScreen = observer(function OnchainTransferScreen({ro meltQuote.amount.toNumber(), unitRef.current, meltQuote, - selectedFee.feeIndex, + feeIndex, memo, new Date(meltQuote.expiry * 1000), address, @@ -486,6 +522,20 @@ export const OnchainTransferScreen = observer(function OnchainTransferScreen({ro const currencyCode = getCurrency(unitRef.current).code const hasQuote = !!meltQuote && !!selectedFee + + /** + * A tier's "~N blocks · up to X sat" line. + * + * A fabricated tier says so, loudly and in plain text. It only exists in a debug build, + * but an unmarked invented fee reserve sitting next to real ones is exactly the kind of + * thing that gets believed later. + */ + const feeTierLabel = (option: OnchainFeeOption) => + translate('onchainTransferScreen_feeTierSubtext', { + blocks: option.estimatedBlocks, + amount: numbro(option.feeReserve).format({thousandSeparated: true}), + currency: currencyCode, + }) + (option.isMock ? ' ⚠️ MOCK — pays at the mint\'s real fee' : '') const isSettled = transactionStatus === TransactionStatus.COMPLETED || transactionStatus === TransactionStatus.PENDING @@ -603,13 +653,7 @@ export const OnchainTransferScreen = observer(function OnchainTransferScreen({ro ContentComponent={ options.find(o => o.feeIndex === feeIndex) +/** + * Invent extra fee tiers so the picker can be exercised. DEBUG BUILDS ONLY. + * + * The CDK fakewallet backend returns exactly ONE fee option, so against the only mint we + * can test with, the picker always collapses to its read-only single-tier form and the + * multi-tier path never runs. This fabricates a slower/cheaper and a faster/dearer tier + * around whatever the mint actually sent. + * + * **The mint's real tier is kept, untouched, with its real `fee_index`.** That matters: + * NUT-30 says the mint MUST reject a melt whose `fee_index` it never offered, so a + * fabricated index cannot be paid with. The caller submits the REAL index whichever tier + * the user picks (see `isMock`) — so what is mocked is the CHOICE, and the payment that + * follows is a real one at the mint's real price. A mocked tier's `feeReserve` is a + * fiction for display; nothing downstream may bill against it. + * + * A no-op when the mint already returned more than one tier — there is nothing to + * simulate, and overwriting real tiers with invented ones would be actively misleading. + */ +export const mockOnchainFeeTiers = ( + options: OnchainFeeOption[], +): OnchainFeeOption[] => { + if (options.length !== 1) return options + + const real = options[0] + + // Indices that cannot collide with the mint's own. They are never sent to it. + const cheaper: OnchainFeeOption = { + feeIndex: real.feeIndex + 1001, + feeReserve: Math.max(1, Math.round(real.feeReserve * 0.4)), + estimatedBlocks: Math.max(real.estimatedBlocks * 4, 12), + isMock: true, + } + const faster: OnchainFeeOption = { + feeIndex: real.feeIndex + 1002, + feeReserve: Math.round(real.feeReserve * 2.5), + estimatedBlocks: 1, + isMock: true, + } + + return normalizeFeeOptions( + [cheaper, real, faster].map(o => ({ + fee_index: o.feeIndex, + fee_reserve: o.feeReserve, + estimated_blocks: o.estimatedBlocks, + })), + ).map(o => ({ + ...o, + isMock: o.feeIndex !== real.feeIndex, + })) +} + +/** + * The `fee_index` that may actually be sent to the mint. + * + * Identity for a real tier. For a mocked one it resolves back to the mint's own tier — + * the only index the mint will accept — so a debug build can exercise the picker without + * the melt being rejected. Returns undefined if there is no real tier to fall back to, + * which cannot happen with a quote from a mint. + */ +export const payableFeeIndex = ( + options: OnchainFeeOption[], + selected: OnchainFeeOption, +): number | undefined => { + if (!selected.isMock) return selected.feeIndex + return options.find(o => !o.isMock)?.feeIndex +} + /** * Total that must be covered by the inputs of an onchain melt. *