Fix stale mint capabilities hiding the onchain topup option

Reported from device: with an 11k sat amount on a mint that advertises onchain
and NUT-20, the "Bitcoin address" button never appeared.

Two causes, both mine.

1. A missing `time` was being read as FRESH. The stamp only started being
   written when the capability model landed (d008d45), so every mint info
   cached before that has `time: undefined`. `now - undefined` is NaN, and
   every comparison against NaN is false — so `now - time > TTL` reported
   those records as fresh and never refetched them. A mint that had since
   gained onchain support kept looking like one that never had it, forever.

   isMintInfoStale() now treats a missing or non-finite timestamp as stale, and
   lives in its own module (no stores, no services) so the NaN behaviour is
   pinned by tests rather than rediscovered on a device.

2. Nothing on TopupScreen ever refreshed capabilities. The screen gates on the
   mint's cached NUT-06 info but otherwise never talks to the mint, so even
   with (1) fixed there was no trigger. Selecting a mint now kicks getMint(),
   which only hits the network when the info is genuinely stale; the screen is
   an observer, so the option appears by itself once fresher capabilities land.

Also logs the gate inputs (supportsOnchain / nut20 / mintMin / floor / amount),
since "the button is missing" is otherwise indistinguishable from a dozen
different causes.

260/260 tests pass; typecheck introduces no new errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
minibits-cash
2026-07-13 21:56:37 +02:00
parent 1a2c763dd6
commit be6dc69224
4 changed files with 126 additions and 14 deletions

View File

@@ -0,0 +1,50 @@
/**
* Mint info staleness (WalletStore.isMintInfoStale).
*
* The reason this is its own test: a MISSING `time` must count as STALE.
*
* The stamp was only added when the capability model landed, so every mint info
* cached before that has `time: undefined`. `now - undefined` is NaN, and every
* comparison against NaN is false — so the obvious `now - time > TTL` check reports
* those records as FRESH, forever. A mint that has since gained onchain support then
* keeps looking like a mint that never had it, and the wallet quietly refuses to
* offer a feature the mint supports.
*
* That is not hypothetical: it is exactly what happened on device.
*
* @jest-environment node
*/
import {isMintInfoStale, MINT_INFO_TTL_SECONDS} from '../src/models/helpers/mintInfoStale'
const NOW = 1_800_000_000
const infoAt = (time?: number) => ({time} as any)
describe('isMintInfoStale', () => {
it('treats absent info as stale', () => {
expect(isMintInfoStale(undefined, NOW)).toBe(true)
})
it('treats info with NO timestamp as stale (the legacy-cache trap)', () => {
// `now - undefined` is NaN; `NaN > TTL` is false. A naive check calls this
// fresh and never refetches.
expect(isMintInfoStale(infoAt(undefined), NOW)).toBe(true)
})
it('treats a non-finite timestamp as stale', () => {
expect(isMintInfoStale(infoAt(NaN), NOW)).toBe(true)
})
it('is fresh within the TTL', () => {
expect(isMintInfoStale(infoAt(NOW - 60), NOW)).toBe(false)
expect(isMintInfoStale(infoAt(NOW), NOW)).toBe(false)
})
it('is stale past the TTL', () => {
expect(isMintInfoStale(infoAt(NOW - MINT_INFO_TTL_SECONDS - 1), NOW)).toBe(true)
})
it('is fresh exactly at the TTL boundary', () => {
expect(isMintInfoStale(infoAt(NOW - MINT_INFO_TTL_SECONDS), NOW)).toBe(false)
})
})

View File

@@ -29,6 +29,7 @@ import { CashuProof, CashuUtils } from '../services/cashu/cashuUtils'
import { Proof } from './Proof'
import { InFlightRequest, Mint } from './Mint'
import { MINT_INFO_TTL_SECONDS, isMintInfoStale } from './helpers/mintInfoStale'
import { getRootStore } from './helpers/getRootStore'
import { Transaction } from './Transaction'
@@ -48,7 +49,7 @@ import { Transaction } from './Transaction'
* gains (or drops) a method visible reasonably soon, while costing at most one
* getInfo() per mint per hour.
*/
export const MINT_INFO_TTL_SECONDS = 3600
export {MINT_INFO_TTL_SECONDS, isMintInfoStale}
export type ExchangeRate = {
currency: CurrencyCode, // 1 EUR, USD, ...
@@ -259,10 +260,7 @@ export const WalletStoreModel = types
const mintInstance = self.getMintModelInstance(mintUrl)
if (!mintInstance) return
const {mintInfo} = mintInstance
const now = Math.floor(Date.now() / 1000)
if (mintInfo && now - mintInfo.time <= MINT_INFO_TTL_SECONDS) return
if (!isMintInfoStale(mintInstance.mintInfo)) return
const info: GetInfoResponse = yield cashuMint.getInfo()
@@ -322,9 +320,7 @@ export const WalletStoreModel = types
mintInstance.refreshKeysets!(keysets)
// fetch and cache mintInfo if not already cached or gone stale
const {mintInfo} = mintInstance
const now = Math.floor(Date.now() / 1000)
if(!mintInfo || now - mintInfo.time > MINT_INFO_TTL_SECONDS) {
if(isMintInfoStale(mintInstance.mintInfo)) {
const info: GetInfoResponse = yield newMint.getInfo()
mintInstance.setMintInfo!(info)
}

View File

@@ -0,0 +1,36 @@
import {type GetInfoResponse} from '@cashu/cashu-ts'
/**
* How long a cached mint NUT-06 info stays fresh, in seconds.
*
* mintInfo carries the mint's advertised payment methods and their min/max limits,
* which drive what the wallet offers the user. An hour picks up a mint that gains
* (or drops) a method reasonably soon, at a cost of at most one getInfo() per mint
* per hour.
*/
export const MINT_INFO_TTL_SECONDS = 3600
/**
* Is a cached mint info due a refresh?
*
* A MISSING `time` counts as STALE, and that is the entire reason this is a named
* function with tests rather than an inline comparison.
*
* The `time` stamp only started being written when the capability model landed, so
* every mint info cached before that has `time: undefined`. `now - undefined` is
* NaN, and every comparison against NaN is false — so the obvious
* `now - time > TTL` check reports those records as FRESH and never refetches them.
* A mint that has since gained onchain support then keeps looking like a mint that
* never had it, and the wallet quietly refuses to offer something the mint supports.
*
* Kept in its own module (no stores, no services) so it can be tested without
* dragging in the WalletStore import chain.
*/
export const isMintInfoStale = (
mintInfo: (GetInfoResponse & {time?: number}) | undefined,
nowSeconds: number = Math.floor(Date.now() / 1000),
): boolean => {
if (!mintInfo) return true
if (!Number.isFinite(mintInfo.time)) return true
return nowSeconds - (mintInfo.time as number) > MINT_INFO_TTL_SECONDS
}

View File

@@ -633,15 +633,45 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
? mintsStore.findByUrl(mintBalanceToTopup.mintUrl)
: undefined
/**
* Capability gating reads the mint's CACHED NUT-06 info, and this screen never
* otherwise talks to the mint — so without this, nothing here would ever refresh
* it, and a mint that has since gained onchain support would keep looking like
* one that never had it.
*
* getMint only hits the network when the cached info is actually stale, and the
* screen is an observer, so the onchain option appears by itself once fresher
* capabilities land.
*/
useEffect(() => {
if (!mintBalanceToTopup?.mintUrl) return
walletStore.getMint(mintBalanceToTopup.mintUrl).catch((e: any) => {
log.warn('[TopupScreen] Could not refresh mint capabilities', {error: e.message})
})
}, [mintBalanceToTopup?.mintUrl])
const isOnchainTopupAvailable = (() => {
if (!selectedMint?.supportsMint!('onchain', unitRef.current)) return false
if (!selectedMint) return false
const floor = onchainTopupFloor(
unitRef.current,
selectedMint.mintMethodSetting!('onchain', unitRef.current)?.min_amount as number | null,
)
const supportsOnchain = selectedMint.supportsMint!('onchain', unitRef.current)
const mintMin = selectedMint.mintMethodSetting!('onchain', unitRef.current)?.min_amount as
| number
| null
const floor = onchainTopupFloor(unitRef.current, mintMin)
const amount = amountToTopupInt()
return amountToTopupInt() >= floor
log.trace('[TopupScreen] onchain topup gate', {
mintUrl: selectedMint.mintUrl,
unit: unitRef.current,
supportsOnchain,
hasUnknownCapabilities: selectedMint.hasUnknownCapabilities,
supportsNut20: selectedMint.supportsNut20,
mintMin,
floor,
amount,
})
return supportsOnchain && amount >= floor
})()
/**