mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-08-12 01:17:43 +00:00
Onchain (NUT-30) means a mint can no longer be assumed to speak bolt11:
a mint may support onchain only, or gain/lose a method over time. The
wallet needs to read what each mint actually advertises and offer only
that.
Fixes the refresh first, because it was doubly broken and nothing built
on mintInfo could be trusted without it:
- `time` was never written. Every caller passed a raw GetInfoResponse
through a cast (`info as GetInfoResponse & {time: number}`), so
mintInfo.time was undefined, `now - undefined` was NaN, and
`NaN > ttl` is false — the TTL could never fire.
- The check also sat below getMint's early return for an already-cached
cashu-ts instance, so it was unreachable for any mint touched once
this session.
Together those meant mintInfo was fetched when a mint was added and then
never refreshed again. setMintInfo now stamps the time itself (callers
cannot forget), and the staleness check runs even for cached instances,
fire-and-forget so it never delays the operation that triggered it.
Capability views on the Mint model read nuts[4]/nuts[5], which is where
onchain advertises itself — it has no nuts[30] block, it is just another
method entry, so capability is always a (method, unit) question. Two
rules: onchain additionally requires NUT-20 (the mint MUST reject an
onchain quote with no pubkey, error 20009), and when info was never
cached bolt11 is assumed while anything newer must prove itself —
assuming bolt11 exactly preserves today's behaviour and cannot strand an
upgrading user with an empty menu.
Mints that cannot serve an operation are now listed but disabled in the
Topup/Transfer selectors, with the reason in place of the hostname;
hiding them would leave the user hunting for a mint that silently
vanished.
WalletScreen's Send/Receive sheets are relabelled ahead of onchain: the
verb moves to the sheet heading so the rows become plain nouns (Ecash /
Bitcoin) that read as a real either/or, and the descriptions give the
criteria users actually decide on — speed and cost — rather than protocol
names. faBolt would be wrong once onchain exists, so the rail row now
uses the circled faBitcoin mark, tinted to match the BtcIcon SVG already
used by CurrencySign. Neither row asks the user to pick a rail: paying
infers it from what they paste, and topping up asks in TopupScreen where
the mint and unit are known.
221/221 tests pass; typecheck introduces no new errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
208 lines
7.7 KiB
TypeScript
208 lines
7.7 KiB
TypeScript
/**
|
|
* Mint capability views (Mint.ts).
|
|
*
|
|
* The wallet decides which payment options to offer from the mint's cached NUT-06
|
|
* info. Getting this wrong is user-visible in both directions: offer a method the
|
|
* mint does not have and the operation fails at quote time; hide one it does have
|
|
* and the user simply cannot use their money.
|
|
*
|
|
* Two rules carry most of the weight:
|
|
*
|
|
* - `onchain` (NUT-30) additionally requires NUT-20, because the mint MUST refuse
|
|
* an onchain mint quote that carries no pubkey (error 20009).
|
|
* - When capabilities are UNKNOWN (info never cached), bolt11 is assumed and
|
|
* anything else is not — assuming bolt11 preserves the wallet's behaviour to
|
|
* date and cannot strand a user with an empty menu.
|
|
*
|
|
* @jest-environment node
|
|
*/
|
|
jest.mock('../src/services/logService', () => ({
|
|
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
|
|
LogLevel: {ERROR: 'ERROR', WARN: 'WARN', INFO: 'INFO', DEBUG: 'DEBUG', TRACE: 'TRACE'},
|
|
}))
|
|
jest.mock('../src/services', () => ({
|
|
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
|
|
Database: {},
|
|
}))
|
|
// Mint.ts pulls in ../theme and ../services/wallet/currency, and currency imports
|
|
// the components barrel -> react-native-reanimated (ESM, not transformed here).
|
|
// Neither is needed to exercise the capability views, which read only mintInfo.
|
|
jest.mock('../src/theme', () => ({
|
|
colors: {palette: {iconBlue200: '#4dabf7'}},
|
|
getRandomIconColor: () => '#4dabf7',
|
|
}))
|
|
jest.mock('../src/services/wallet/currency', () => ({
|
|
MintUnits: ['btc', 'sat', 'msat', 'usd', 'eur'],
|
|
}))
|
|
// utils.ts -> react-native-flash-message -> react-native-iphone-screen-helper (ESM).
|
|
jest.mock('../src/utils/utils', () => ({
|
|
generateId: () => 'testmint',
|
|
}))
|
|
// cashuUtils -> nostrService -> ESM deps. Not reached by the capability views.
|
|
jest.mock('../src/services/cashu/cashuUtils', () => ({
|
|
CashuUtils: {},
|
|
}))
|
|
|
|
import {MintModel} from '../src/models/Mint'
|
|
|
|
type MethodEntry = {
|
|
method: string
|
|
unit: string
|
|
min_amount?: number | null
|
|
max_amount?: number | null
|
|
options?: {confirmations?: number}
|
|
}
|
|
|
|
/** Build a mint whose cached info advertises the given methods. */
|
|
const mintWith = (opts: {
|
|
mintMethods?: MethodEntry[]
|
|
meltMethods?: MethodEntry[]
|
|
nut20?: boolean
|
|
}) =>
|
|
MintModel.create({
|
|
mintUrl: 'https://mint.test',
|
|
mintInfo: {
|
|
name: 'test',
|
|
pubkey: 'aa',
|
|
version: 'test/1',
|
|
contact: [],
|
|
nuts: {
|
|
'4': {methods: opts.mintMethods ?? [], disabled: false},
|
|
'5': {methods: opts.meltMethods ?? [], disabled: false},
|
|
...(opts.nut20 === undefined ? {} : {'20': {supported: opts.nut20}}),
|
|
},
|
|
time: Math.floor(Date.now() / 1000),
|
|
} as any,
|
|
})
|
|
|
|
/** A mint whose info was never cached (offline when added, or a very old entry). */
|
|
const mintWithUnknownInfo = () => MintModel.create({mintUrl: 'https://mint.test'})
|
|
|
|
// Shapes taken from the live CDK test mint.
|
|
const BOLT11_SAT: MethodEntry = {method: 'bolt11', unit: 'sat', min_amount: 1, max_amount: 500000}
|
|
const ONCHAIN_SAT: MethodEntry = {
|
|
method: 'onchain',
|
|
unit: 'sat',
|
|
min_amount: 10000,
|
|
max_amount: 500000,
|
|
options: {confirmations: 1},
|
|
}
|
|
|
|
describe('method settings lookup', () => {
|
|
it('returns the advertised setting for a (method, unit) pair', () => {
|
|
const mint = mintWith({mintMethods: [BOLT11_SAT, ONCHAIN_SAT]})
|
|
|
|
expect(mint.mintMethodSetting('onchain', 'sat')).toMatchObject({
|
|
method: 'onchain',
|
|
min_amount: 10000,
|
|
options: {confirmations: 1},
|
|
})
|
|
})
|
|
|
|
it('is undefined for a method the mint does not advertise', () => {
|
|
const mint = mintWith({mintMethods: [BOLT11_SAT]})
|
|
|
|
expect(mint.mintMethodSetting('onchain', 'sat')).toBeUndefined()
|
|
})
|
|
|
|
it('does not match a supported method in the wrong unit', () => {
|
|
const mint = mintWith({mintMethods: [ONCHAIN_SAT]})
|
|
|
|
expect(mint.mintMethodSetting('onchain', 'usd')).toBeUndefined()
|
|
})
|
|
|
|
it('keeps mint and melt lists separate', () => {
|
|
// a mint may take onchain deposits without paying onchain out
|
|
const mint = mintWith({
|
|
mintMethods: [BOLT11_SAT, ONCHAIN_SAT],
|
|
meltMethods: [BOLT11_SAT],
|
|
nut20: true,
|
|
})
|
|
|
|
expect(mint.supportsMint('onchain', 'sat')).toBe(true)
|
|
expect(mint.supportsMelt('onchain', 'sat')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('supportsMint / supportsMelt', () => {
|
|
it('supports bolt11 when advertised', () => {
|
|
const mint = mintWith({mintMethods: [BOLT11_SAT], meltMethods: [BOLT11_SAT]})
|
|
|
|
expect(mint.supportsMint('bolt11', 'sat')).toBe(true)
|
|
expect(mint.supportsMelt('bolt11', 'sat')).toBe(true)
|
|
})
|
|
|
|
it('does NOT support onchain mint without NUT-20, even when advertised', () => {
|
|
// the mint would reject the quote with 20009 (pubkey required), so an
|
|
// onchain method with no NUT-20 is unusable to us
|
|
const mint = mintWith({mintMethods: [BOLT11_SAT, ONCHAIN_SAT], nut20: false})
|
|
|
|
expect(mint.supportsMint('onchain', 'sat')).toBe(false)
|
|
expect(mint.supportsMint('bolt11', 'sat')).toBe(true)
|
|
})
|
|
|
|
it('supports onchain mint when advertised alongside NUT-20', () => {
|
|
const mint = mintWith({mintMethods: [BOLT11_SAT, ONCHAIN_SAT], nut20: true})
|
|
|
|
expect(mint.supportsMint('onchain', 'sat')).toBe(true)
|
|
})
|
|
|
|
it('allows onchain melt without NUT-20 (it gates mint quotes only)', () => {
|
|
const mint = mintWith({meltMethods: [ONCHAIN_SAT], nut20: false})
|
|
|
|
expect(mint.supportsMelt('onchain', 'sat')).toBe(true)
|
|
})
|
|
|
|
it('handles a pure-onchain mint (no bolt11 at all)', () => {
|
|
const mint = mintWith({
|
|
mintMethods: [ONCHAIN_SAT],
|
|
meltMethods: [ONCHAIN_SAT],
|
|
nut20: true,
|
|
})
|
|
|
|
expect(mint.supportsMint('bolt11', 'sat')).toBe(false)
|
|
expect(mint.supportsMelt('bolt11', 'sat')).toBe(false)
|
|
expect(mint.supportsMint('onchain', 'sat')).toBe(true)
|
|
expect(mint.supportsMelt('onchain', 'sat')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('unknown capabilities (info never cached)', () => {
|
|
it('assumes bolt11, so an upgrading user never loses their Lightning options', () => {
|
|
const mint = mintWithUnknownInfo()
|
|
|
|
expect(mint.hasUnknownCapabilities).toBe(true)
|
|
expect(mint.supportsMint('bolt11', 'sat')).toBe(true)
|
|
expect(mint.supportsMelt('bolt11', 'sat')).toBe(true)
|
|
})
|
|
|
|
it('hides onchain until the mint positively advertises it', () => {
|
|
const mint = mintWithUnknownInfo()
|
|
|
|
expect(mint.supportsMint('onchain', 'sat')).toBe(false)
|
|
expect(mint.supportsMelt('onchain', 'sat')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('setMintInfo stamps the fetch time', () => {
|
|
it('sets `time` so the staleness check can actually fire', () => {
|
|
// Callers pass a raw GetInfoResponse (which has no `time`). If the stamp
|
|
// were left to them, `now - undefined` is NaN, NaN > ttl is false, and the
|
|
// TTL would never fire — info would be cached forever.
|
|
const mint = mintWithUnknownInfo()
|
|
const before = Math.floor(Date.now() / 1000)
|
|
|
|
mint.setMintInfo({
|
|
name: 'test',
|
|
pubkey: 'aa',
|
|
version: 'test/1',
|
|
contact: [],
|
|
nuts: {'4': {methods: [], disabled: false}, '5': {methods: [], disabled: false}},
|
|
} as any)
|
|
|
|
expect(typeof mint.mintInfo!.time).toBe('number')
|
|
expect(mint.mintInfo!.time).toBeGreaterThanOrEqual(before)
|
|
expect(mint.hasUnknownCapabilities).toBe(false)
|
|
})
|
|
})
|