Debug-only mock for multi-tier onchain fee options

The CDK fakewallet returns exactly ONE fee_option, so against the only mint we
can test against the picker always collapses to its single-tier read-only row and
the multi-tier path never runs. mockOnchainFeeTiers fabricates a slower/cheaper
and a faster/dearer tier around whatever the mint sent. Flip MOCK_FEE_TIERS in
OnchainTransferScreen to see three.

What is mocked is the CHOICE, not the payment. NUT-30 requires the mint to reject
a melt whose fee_index it never offered, so a fabricated index cannot be paid
with — the mint's real tier survives the mock untouched, and payableFeeIndex
resolves any mocked selection back to it on submit. The melt therefore goes
through at the mint's real price whichever tier is picked; only the displayed
estimate was invented. Mocked rows say so in plain text, because an unmarked
invented fee reserve sitting next to real ones is exactly the sort of thing that
gets believed later.

No-op when the mint already offered more than one tier: overwriting real tiers
with invented ones would be worse than useless.

87 tsc (unchanged baseline), 328/328.
This commit is contained in:
minibits-cash
2026-07-14 23:13:22 +02:00
parent 493a5cb0c3
commit 4a3a7efbb4
3 changed files with 210 additions and 15 deletions

View File

@@ -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)

View File

@@ -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={
<ListItem
tx="onchainTransferScreen_networkFee"
subText={translate('onchainTransferScreen_feeTierSubtext', {
blocks: selectedFee!.estimatedBlocks,
amount: numbro(selectedFee!.feeReserve).format({
thousandSeparated: true,
}),
currency: currencyCode,
})}
subText={feeTierLabel(selectedFee!)}
LeftComponent={
<Icon
containerStyle={$iconContainer}
@@ -704,12 +748,14 @@ export const OnchainTransferScreen = observer(function OnchainTransferScreen({ro
text={translate('onchainTransferScreen_feeTierBlocks', {
blocks: option.estimatedBlocks,
})}
subText={translate('onchainTransferScreen_feeTierReserve', {
amount: numbro(option.feeReserve).format({
thousandSeparated: true,
}),
currency: currencyCode,
})}
subText={
translate('onchainTransferScreen_feeTierReserve', {
amount: numbro(option.feeReserve).format({
thousandSeparated: true,
}),
currency: currencyCode,
}) + (option.isMock ? ' ⚠️ MOCK' : '')
}
leftIcon={
option.feeIndex === selectedFee?.feeIndex
? 'faCheckCircle'

View File

@@ -130,6 +130,11 @@ export type OnchainFeeOption = {
feeIndex: number
feeReserve: number
estimatedBlocks: number
/**
* True for a tier this wallet invented in a debug build (see `mockOnchainFeeTiers`).
* Never set on anything a mint sent.
*/
isMock?: boolean
}
/** Shape of a `fee_options` entry as it arrives from cashu-ts. */
@@ -182,6 +187,73 @@ export const findFeeOption = (
feeIndex: number,
): OnchainFeeOption | undefined => 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.
*