mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-07-22 07:48:28 +00:00
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:
50
__tests__/mintInfoStale.test.ts
Normal file
50
__tests__/mintInfoStale.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
36
src/models/helpers/mintInfoStale.ts
Normal file
36
src/models/helpers/mintInfoStale.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
})()
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user