diff --git a/__tests__/mintCapabilities.test.ts b/__tests__/mintCapabilities.test.ts new file mode 100644 index 00000000..77a4a246 --- /dev/null +++ b/__tests__/mintCapabilities.test.ts @@ -0,0 +1,207 @@ +/** + * 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) + }) +}) diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index a8aa905b..75d48ad9 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -18,6 +18,10 @@ import { IconDefinition, Transform } from "@fortawesome/fontawesome-svg-core" // metro bundler tree-shaking issue: https://github.com/facebook/metro/issues/132 // relevant font-awesome docs: https://docs.fontawesome.com/apis/javascript/tree-shaking +// Circled Bitcoin mark, matching the silhouette of the BtcIcon SVG used by +// CurrencySign / CurrencyAmount. Tint it with colors.palette.orange400 to stay +// consistent with that icon's brand orange. +import { faBitcoin } from "@fortawesome/free-brands-svg-icons/faBitcoin" import { faTwitter } from "@fortawesome/free-brands-svg-icons/faTwitter" import { faTelegramPlane } from "@fortawesome/free-brands-svg-icons/faTelegramPlane" import { faDiscord } from "@fortawesome/free-brands-svg-icons/faDiscord" @@ -110,7 +114,7 @@ export type IconTypes = keyof typeof iconRegistry // TODO remove need for manual iconregistry? // would be best to just import all of them, i guess, or figure out something smart -export const iconRegistry = { faWifi, faNfcSymbol, faAddressCard, faAddressBook, faWallet, faQrcode, faClipboard, faSliders, faCoins, faEllipsisVertical, faEllipsis, faArrowUp, faArrowDown, faArrowLeft, faXmark, faInfoCircle, faBug, faCheckCircle, faCheck, faArrowTurnUp, faArrowTurnDown, faPencil, faTags, faShareFromSquare, faRotate, faCode, faBan, faCircle, faPaperPlane, faBolt, faArrowUpFromBracket, faArrowRightToBracket, faPlus, faShieldHalved, faCloudArrowUp, faPaintbrush, faCopy, faBurst, faUserShield, faLock, faLockOpen, faTriangleExclamation, faDownload, faUpload, faRecycle, faListUl, faExpand, faFingerprint, faWandMagicSparkles, faCircleUser, faComment, faKey, faCircleNodes, faBullseye, faEyeSlash, faUpRightFromSquare, faShareNodes, faPaste, faKeyboard, faMoneyBill1, faGears, faTag, faBank, faChevronDown, faChevronUp, faCircleExclamation, faCircleQuestion, faEnvelope, faTwitter, faTelegramPlane, faDiscord, faGithub, faReddit, faCircleArrowUp, faCircleArrowDown, faGlobe, faCubes, faClock, faArrowRotateLeft, faHeartPulse, faSeedling, faChevronLeft, faArrowRightArrowLeft, faMagnifyingGlass } +export const iconRegistry = { faWifi, faNfcSymbol, faAddressCard, faAddressBook, faWallet, faQrcode, faClipboard, faSliders, faCoins, faEllipsisVertical, faEllipsis, faArrowUp, faArrowDown, faArrowLeft, faXmark, faInfoCircle, faBug, faCheckCircle, faCheck, faArrowTurnUp, faArrowTurnDown, faPencil, faTags, faShareFromSquare, faRotate, faCode, faBan, faCircle, faPaperPlane, faBolt, faArrowUpFromBracket, faArrowRightToBracket, faPlus, faShieldHalved, faCloudArrowUp, faPaintbrush, faCopy, faBurst, faUserShield, faLock, faLockOpen, faTriangleExclamation, faDownload, faUpload, faRecycle, faListUl, faExpand, faFingerprint, faWandMagicSparkles, faCircleUser, faComment, faKey, faCircleNodes, faBullseye, faEyeSlash, faUpRightFromSquare, faShareNodes, faPaste, faKeyboard, faMoneyBill1, faGears, faTag, faBank, faChevronDown, faChevronUp, faCircleExclamation, faCircleQuestion, faEnvelope, faTwitter, faTelegramPlane, faDiscord, faGithub, faReddit, faCircleArrowUp, faCircleArrowDown, faGlobe, faCubes, faClock, faArrowRotateLeft, faHeartPulse, faSeedling, faChevronLeft, faArrowRightArrowLeft, faMagnifyingGlass, faBitcoin } interface IconProps extends TouchableOpacityProps { diff --git a/src/i18n_messages/en.json b/src/i18n_messages/en.json index 214872a7..f6220c46 100644 --- a/src/i18n_messages/en.json +++ b/src/i18n_messages/en.json @@ -721,17 +721,15 @@ "walletScreen_mintExists": "This mint already exists", "walletScreen_pay": "Pay", "walletScreen_paymentSentSuccess": "Payment request has been successfully sent.", - "walletScreen_payWithLightning": "Pay with Lightning", - "walletScreen_payWithLightningDesc": "Pay invoice or to a Lightning address", - "walletScreen_receiveEcash": "Receive Ecash", - "walletScreen_receiveEcashDesc": "Paste or scan ecash token", - "walletScreen_sendEcash": "Send ecash", - "walletScreen_sendEcashDesc": "Share ecash or send it to one of your contacts", + "walletScreen_optionBitcoin": "Bitcoin", + "walletScreen_optionBitcoinSendDesc": "Lightning invoice or onchain address. Network fees apply.", + "walletScreen_optionEcashReceiveDesc": "Paste or scan an ecash token", + "walletScreen_optionEcash": "Ecash", + "walletScreen_optionEcashSendDesc": "Instant and free, wallet to wallet", "walletScreen_spentToday": "Spent today", "walletScreen_startByFunding": "Start by funding your wallet", "walletScreen_topup": "Topup", - "walletScreen_topupWithLightning": "Topup with Lightning", - "walletScreen_topupWithLightningDesc": "Create Lightning invoice to topup your balance", + "walletScreen_optionBitcoinReceiveDesc": "Top up over Lightning or onchain", "warning": "Warning", "welcomeScreen_creatingKeys": "Creating wallet keys...", "welcomeScreen_creatingProfile": "Creating wallet profile...", @@ -748,5 +746,7 @@ "welcomeScreen_terms_agreePrefix": "I have read and agree to the", "welcomeScreen_terms_agreeTerms": "Terms", "welcomeScreen_terms_agreeConjunction": "and", - "welcomeScreen_terms_agreePrivacy": "Privacy Policy" + "welcomeScreen_terms_agreePrivacy": "Privacy Policy", + "mintSelector_noTopupSupport": "Top up not supported for this currency", + "mintSelector_noPayoutSupport": "Payouts not supported for this currency" } diff --git a/src/i18n_messages/es.json b/src/i18n_messages/es.json index f76f1478..def115af 100644 --- a/src/i18n_messages/es.json +++ b/src/i18n_messages/es.json @@ -143,9 +143,9 @@ "developerScreen_forceMovePending": "Movimiento forzado pendiente", "developerScreen_forceMovePendingDescription": "Mueva el efectivo pendiente nuevamente al saldo gastable.", "developerScreen_go": "¡Vamos!", - "developerScreen_info": "Acerca de la billetera Minibits", + "developerScreen_info": "Acerca de la billetera Minibits", "developerScreen_logLevel": "Nivel de registro", - "developerScreen_clearJwtTokens": "Borrar tokens JWT", + "developerScreen_clearJwtTokens": "Borrar tokens JWT", "developerScreen_jwtTokensCleared": "Tokens JWT borrados", "developerScreen_reset": "Restablecimiento de fábrica", "developerScreen_resetDescription": "Esto eliminará todos los datos locales del almacenamiento y de la base de datos local. Úselo solo durante el desarrollo o las pruebas.", @@ -720,17 +720,15 @@ "walletScreen_mintExists": "Esta menta ya existe", "walletScreen_pay": "Pagar", "walletScreen_paymentSentSuccess": "La solicitud de pago ha sido enviada exitosamente.", - "walletScreen_payWithLightning": "Pagar con Lightning", - "walletScreen_payWithLightningDesc": "Pagar factura o a una dirección Lightning", - "walletScreen_receiveEcash": "Recibir Ecash", - "walletScreen_receiveEcashDesc": "Pegar o escanear el token de ecash", - "walletScreen_sendEcash": "Enviar dinero electrónico", - "walletScreen_sendEcashDesc": "Comparte ecash o envíaselo a uno de tus contactos", + "walletScreen_optionBitcoin": "Bitcoin", + "walletScreen_optionBitcoinSendDesc": "Factura Lightning o dirección onchain. Se aplican comisiones de red.", + "walletScreen_optionEcashReceiveDesc": "Pega o escanea un token ecash", + "walletScreen_optionEcash": "Ecash", + "walletScreen_optionEcashSendDesc": "Instantáneo y gratuito, de billetera a billetera", "walletScreen_spentToday": "Pasé hoy", "walletScreen_startByFunding": "Comience por financiar su billetera", "walletScreen_topup": "Recarga", - "walletScreen_topupWithLightning": "Recarga con Lightning", - "walletScreen_topupWithLightningDesc": "Crea una factura Lightning para recargar tu saldo", + "walletScreen_optionBitcoinReceiveDesc": "Recarga mediante Lightning u onchain", "warning": "Advertencia", "welcomeScreen_creatingKeys": "Creando claves de billetera...", "welcomeScreen_creatingProfile": "Creando perfil de billetera...", @@ -747,5 +745,7 @@ "welcomeScreen_terms_agreePrefix": "He leído y acepto los", "welcomeScreen_terms_agreeTerms": "Términos", "welcomeScreen_terms_agreeConjunction": "y la", - "welcomeScreen_terms_agreePrivacy": "Política de Privacidad" + "welcomeScreen_terms_agreePrivacy": "Política de Privacidad", + "mintSelector_noTopupSupport": "Recargas no soportadas para esta moneda", + "mintSelector_noPayoutSupport": "Pagos no soportados para esta moneda" } diff --git a/src/i18n_messages/pt.json b/src/i18n_messages/pt.json index f807db29..5280fc7a 100644 --- a/src/i18n_messages/pt.json +++ b/src/i18n_messages/pt.json @@ -721,17 +721,15 @@ "walletScreen_mintExists": "Este mint já existe", "walletScreen_pay": "Pagar", "walletScreen_paymentSentSuccess": "Solicitação de pagamento enviada com sucesso.", - "walletScreen_payWithLightning": "Pagar com Lightning", - "walletScreen_payWithLightningDesc": "Pagar invoice ou para endereço Lightning", - "walletScreen_receiveEcash": "Receber Ecash", - "walletScreen_receiveEcashDesc": "Colar ou escanear token ecash", - "walletScreen_sendEcash": "Enviar ecash", - "walletScreen_sendEcashDesc": "Compartilhar ecash ou enviar para contatos", + "walletScreen_optionBitcoin": "Bitcoin", + "walletScreen_optionBitcoinSendDesc": "Fatura Lightning ou endereço onchain. Aplicam-se taxas de rede.", + "walletScreen_optionEcashReceiveDesc": "Cole ou digitalize um token ecash", + "walletScreen_optionEcash": "Ecash", + "walletScreen_optionEcashSendDesc": "Instantâneo e gratuito, de carteira para carteira", "walletScreen_spentToday": "Gasto hoje", "walletScreen_startByFunding": "Comece financiando sua carteira", "walletScreen_topup": "Recarga", - "walletScreen_topupWithLightning": "Recarregar com Lightning", - "walletScreen_topupWithLightningDesc": "Criar invoice Lightning para recarregar saldo", + "walletScreen_optionBitcoinReceiveDesc": "Carregue via Lightning ou onchain", "warning": "Aviso", "welcomeScreen_creatingKeys": "Criando chaves da carteira...", "welcomeScreen_creatingProfile": "Criando perfil da carteira...", @@ -748,5 +746,7 @@ "welcomeScreen_terms_agreePrefix": "Li e concordo com os", "welcomeScreen_terms_agreeTerms": "Termos", "welcomeScreen_terms_agreeConjunction": "e a", - "welcomeScreen_terms_agreePrivacy": "Política de Privacidade" + "welcomeScreen_terms_agreePrivacy": "Política de Privacidade", + "mintSelector_noTopupSupport": "Carregamentos não suportados para esta moeda", + "mintSelector_noPayoutSupport": "Pagamentos não suportados para esta moeda" } diff --git a/src/i18n_messages/sk.json b/src/i18n_messages/sk.json index 626c70a8..4e020e0f 100644 --- a/src/i18n_messages/sk.json +++ b/src/i18n_messages/sk.json @@ -721,17 +721,15 @@ "walletScreen_mintExists": "Tento mint už existuje", "walletScreen_pay": "Zaplať", "walletScreen_paymentSentSuccess": "Požiadavka na platbu bola odoslaná.", - "walletScreen_payWithLightning": "Zaplať cez Lightning", - "walletScreen_payWithLightningDesc": "Zaplať invoice alebo na Lightning adresu", - "walletScreen_receiveEcash": "Prijmi ecash", - "walletScreen_receiveEcashDesc": "Vlož alebo skenuj ecash token", - "walletScreen_sendEcash": "Pošli ecash", - "walletScreen_sendEcashDesc": "Zdieľaj ecash alebo pošli ecash jednému zo svojich kontaktov", + "walletScreen_optionBitcoin": "Bitcoin", + "walletScreen_optionBitcoinSendDesc": "Lightning faktúra alebo onchain adresa. Účtujú sa sieťové poplatky.", + "walletScreen_optionEcashReceiveDesc": "Vložte alebo naskenujte ecash token", + "walletScreen_optionEcash": "Ecash", + "walletScreen_optionEcashSendDesc": "Okamžite a zadarmo, z peňaženky do peňaženky", "walletScreen_spentToday": "Dnes minuté", "walletScreen_startByFunding": "Začni doplnením peňaženky", "walletScreen_topup": "Pridaj", - "walletScreen_topupWithLightning": "Pridaj cez Lightning", - "walletScreen_topupWithLightningDesc": "Vytvor Lightning invoice na pridanie ecash", + "walletScreen_optionBitcoinReceiveDesc": "Dobitie cez Lightning alebo onchain", "warning": "Upozornenie", "welcomeScreen_creatingKeys": "Vytváram kľúče peňaženky...", "welcomeScreen_creatingProfile": "Vytváram profil peňaženky...", @@ -748,5 +746,7 @@ "welcomeScreen_terms_agreePrefix": "Prečítal som si a súhlasím s", "welcomeScreen_terms_agreeTerms": "Podmienkami", "welcomeScreen_terms_agreeConjunction": "a", - "welcomeScreen_terms_agreePrivacy": "Zásadami ochrany osobných údajov" + "welcomeScreen_terms_agreePrivacy": "Zásadami ochrany osobných údajov", + "mintSelector_noTopupSupport": "Dobitie nie je pre túto menu podporované", + "mintSelector_noPayoutSupport": "Výplaty nie sú pre túto menu podporované" } diff --git a/src/models/Mint.ts b/src/models/Mint.ts index f6deb190..102188e6 100644 --- a/src/models/Mint.ts +++ b/src/models/Mint.ts @@ -4,6 +4,8 @@ import { type GetInfoResponse, type MintKeys as CashuMintKeys, type MintKeyset as CashuMintKeyset, + type MintMethod, + type SwapMethod, Mint as CashuMint, } from '@cashu/cashu-ts' import {colors, getRandomIconColor} from '../theme' @@ -16,12 +18,22 @@ import { generateId } from '../utils/utils' import { Proof } from './Proof' import { CashuProof, CashuUtils } from '../services/cashu/cashuUtils' +/** + * A mint payment method — the rail the mint settles on. + * + * Aliased from cashu-ts so the set cannot drift from the library's. Note the + * wallet only implements bolt11 today; `onchain` arrives with NUT-30 and + * `bolt12` is advertised by some mints but not yet supported here. The capability + * views below can answer for any of them. + */ +export type PaymentMethod = MintMethod + export type MintBalance = { mintUrl: string balances: { [key in MintUnit]?: number - } -} + } +} export type UnitBalance = { unitBalance: number @@ -178,7 +190,68 @@ export const MintModel = types }) .actions(withSetPropAction) // TODO? start to use across app to avoid pure setter methods, e.g. mint.setProp('color', '#ccc') .views(self => ({ - + /** + * The mint's advertised setting for a (method, unit) pair, or undefined when + * it does not offer that combination. + * + * NUT-04 (mint) and NUT-05 (melt) each advertise a flat list of method + * settings. A payment method is just an entry in that list — `onchain` + * (NUT-30) has no `nuts['30']` block of its own, it simply appears as + * `{method: 'onchain', unit: 'sat', ...}`. So capability is always a + * question about a (method, unit) pair, never about a NUT number. + * + * The setting also carries `min_amount` / `max_amount` (and, for onchain, + * `options.confirmations`), which callers need for limit checks. + */ + mintMethodSetting(method: PaymentMethod, unit: MintUnit): SwapMethod | undefined { + return self.mintInfo?.nuts?.['4']?.methods?.find( + m => m.method === method && m.unit === unit, + ) + }, + meltMethodSetting(method: PaymentMethod, unit: MintUnit): SwapMethod | undefined { + return self.mintInfo?.nuts?.['5']?.methods?.find( + m => m.method === method && m.unit === unit, + ) + }, + /** Does the mint advertise NUT-20 (signed mint quotes)? */ + get supportsNut20(): boolean { + return self.mintInfo?.nuts?.['20']?.supported === true + }, + /** True while we have never managed to cache this mint's info. */ + get hasUnknownCapabilities(): boolean { + return !self.mintInfo + }, + })) + .views(self => ({ + /** + * Can this mint MINT (topup) with `method` in `unit`? + * + * Two rules layered on the advertised methods: + * + * - `onchain` additionally requires NUT-20. NUT-30 depends on it and the + * mint MUST refuse an onchain quote with no pubkey (error 20009), so an + * onchain method without NUT-20 is unusable to us. + * + * - When capabilities are UNKNOWN (info never cached — mint offline when + * added, or a very old wallet entry), bolt11 is assumed supported and + * everything else is not. bolt11 has been the only method the wallet + * ever used, so assuming it exactly preserves today's behaviour and + * cannot strand a user with an empty menu; anything newer has to prove + * itself. The check is otherwise symmetric across methods. + */ + supportsMint(method: PaymentMethod, unit: MintUnit): boolean { + if (self.hasUnknownCapabilities) return method === 'bolt11' + if (!self.mintMethodSetting(method, unit)) return false + if (method === 'onchain') return self.supportsNut20 + return true + }, + /** Can this mint MELT (pay out) with `method` in `unit`? See supportsMint. */ + supportsMelt(method: PaymentMethod, unit: MintUnit): boolean { + if (self.hasUnknownCapabilities) return method === 'bolt11' + if (!self.meltMethodSetting(method, unit)) return false + // NUT-20 gates mint quotes only; melt needs no quote signature. + return true + }, })) .actions(self => ({ addKeyset(keyset: CashuMintKeyset) { @@ -414,8 +487,18 @@ export const MintModel = types self.initKeys(key) } }, - setMintInfo(info: GetInfoResponse & {time: number}) { - self.mintInfo = info + /** + * Cache the mint's NUT-06 info, stamping the fetch time. + * + * The stamp is applied HERE rather than at call sites: `time` drives the + * staleness check in WalletStore.getMint, and every caller used to pass a + * raw GetInfoResponse through a cast, leaving `time` undefined. `now - + * undefined` is NaN, NaN > ttl is false, so the TTL never fired and info + * was cached forever — which is how mints kept advertising capabilities + * they no longer had. + */ + setMintInfo(info: GetInfoResponse) { + self.mintInfo = {...info, time: Math.floor(Date.now() / 1000)} log.trace('[setMintInfo]', {mintUrl: self.mintUrl}) }, getProofsCounterByKeysetId(keysetId: string) { diff --git a/src/models/WalletStore.ts b/src/models/WalletStore.ts index 89bf3887..34dbb5eb 100644 --- a/src/models/WalletStore.ts +++ b/src/models/WalletStore.ts @@ -1,4 +1,4 @@ -import {Instance, SnapshotOut, types, flow, getRoot, getSnapshot} from 'mobx-state-tree' +import {Instance, SnapshotOut, types, flow, getRoot, getSnapshot, isAlive} from 'mobx-state-tree' import { Mint as CashuMint, Wallet as CashuWallet, @@ -39,6 +39,16 @@ import { Transaction } from './Transaction' so that new cashu-ts instances are re-used over app lifecycle. */ +/** + * 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 keeps a mint that + * 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 type ExchangeRate = { currency: CurrencyCode, // 1 EUR, USD, ... rate: number // in satoshis @@ -231,12 +241,53 @@ export const WalletStoreModel = types const keys: WalletKeys = yield self.getCachedWalletKeys() return keys.SEED.seedHash }), + /** + * Re-fetch a mint's NUT-06 info if the cached copy is older than the TTL. + * + * Capabilities (which payment methods a mint supports, and their min/max + * limits) are read off `mintInfo`, so a copy that never refreshes means the + * wallet keeps offering — or hiding — the wrong options. Errors are logged + * and swallowed: a capability refresh must never break the operation that + * happened to trigger it. + */ + refreshMintInfoIfStale: flow(function* refreshMintInfoIfStale( + mintUrl: string, + cashuMint: CashuMint, + ) { + try { + 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 + + const info: GetInfoResponse = yield cashuMint.getInfo() + + // the mint may have been removed while the call was in flight + if (!isAlive(mintInstance)) return + + mintInstance.setMintInfo!(info) + log.trace('[WalletStore.refreshMintInfoIfStale] refreshed', {mintUrl}) + } catch (e: any) { + log.warn('[WalletStore.refreshMintInfoIfStale]', {mintUrl, error: e.message}) + } + }), + })) + .actions(self => ({ getMint: flow(function* getMint(mintUrl: string) { const mint = self.mints.find(m => m.mintUrl === mintUrl) log.trace('[WalletStore.getMint]', {cachedMint: !!mint}) if (mint) { + // Refresh capabilities on a TTL even when the cashu-ts instance is already + // cached. This check used to live below this early return, so a mint touched + // once in a session never refreshed again for the rest of it. Fire-and-forget + // so the caller's operation is not delayed by a getInfo() round trip; + // observers re-render when fresher info lands. + void self.refreshMintInfoIfStale(mintUrl, mint as CashuMint) return mint as CashuMint } @@ -269,12 +320,12 @@ export const WalletStoreModel = types // sync wallet state with fresh keysets, active statuses and keys mintInstance.refreshKeysets!(keysets) - // fetch and cache mintInfo if not already cached + // 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 > 3600) { + if(!mintInfo || now - mintInfo.time > MINT_INFO_TTL_SECONDS) { const info: GetInfoResponse = yield newMint.getInfo() - mintInstance.setMintInfo!(info as GetInfoResponse & {time: number}) + mintInstance.setMintInfo!(info) } } @@ -282,7 +333,7 @@ export const WalletStoreModel = types self.mints.push(newMint) return newMint - }) + }) })) .actions(self => ({ getWallet: flow(function* getWallet( diff --git a/src/screens/Mints/MintBalanceSelector.tsx b/src/screens/Mints/MintBalanceSelector.tsx index 0439cfa6..e21b5ec4 100644 --- a/src/screens/Mints/MintBalanceSelector.tsx +++ b/src/screens/Mints/MintBalanceSelector.tsx @@ -3,7 +3,7 @@ import { FlatList, Keyboard, LayoutAnimation, Platform, View, ViewStyle } from " import { observer } from "mobx-react-lite" import { Button, Card, Icon, IconTypes} from "../../components" import { spacing, useThemeColor } from "../../theme" -import { MintBalance } from "../../models/Mint" +import { Mint, MintBalance } from "../../models/Mint" import { MintUnit } from "../../services/wallet/currency" import { useStores } from "../../models" @@ -21,12 +21,22 @@ export const MintBalanceSelector = observer(function (props: { secondaryConfirmIcon?: IconTypes cancelIcon?: IconTypes collapsible?: boolean + /** + * Which capability a mint needs to be usable here. Mints that lack it are + * listed but disabled, with `unsupportedReason` shown in place of the hostname + * — a mint silently missing from the list is more confusing than one that says + * why it cannot be used. + * + * Omit to disable no mint (the caller does not care about capability). + */ + requiredCapability?: (mint: Mint) => boolean + unsupportedReason?: string onMintBalanceSelect: any onSecondaryMintBalanceSelect?: any onCancel?: any onMintBalanceConfirm?: any }) { - + const collapsible = props.collapsible === false ? false : true // default true const {mintsStore} = useStores() const [isKeyboardVisible, setIsKeyboardVisible] = useState(false) @@ -89,18 +99,25 @@ export const MintBalanceSelector = observer(function (props: { <> data={props.mintBalances} - renderItem={({ item, index }) => { + renderItem={({ item, index }) => { + const mint = mintsStore.findByUrl(item.mintUrl)! + const isDisabled = props.requiredCapability + ? !props.requiredCapability(mint) + : false + return( onMintSelect(item)} isSelectable={true} isSelected={!!props.selectedMintBalance ? props.selectedMintBalance.mintUrl === item.mintUrl : false} + isDisabled={isDisabled} + disabledReason={props.unsupportedReason} separator={index === 0 || allVisible === false ? undefined : 'top'} - style={(!allVisible && collapsible && props.selectedMintBalance && props.selectedMintBalance.mintUrl !== item.mintUrl) ? {display: 'none'} : {}} + style={(!allVisible && collapsible && props.selectedMintBalance && props.selectedMintBalance.mintUrl !== item.mintUrl) ? {display: 'none'} : {}} /> ) }} diff --git a/src/screens/Mints/MintListItem.tsx b/src/screens/Mints/MintListItem.tsx index 680eacc0..6a372d9a 100644 --- a/src/screens/Mints/MintListItem.tsx +++ b/src/screens/Mints/MintListItem.tsx @@ -10,19 +10,30 @@ import { MintUnit } from "../../services/wallet/currency" import { log } from "../../services" import { CurrencyAmount } from "../Wallet/CurrencyAmount" -export const MintListItem = observer(function(props: { +export const MintListItem = observer(function(props: { mint: Mint, mintBalance?: MintBalance, - selectedUnit?: MintUnit, + selectedUnit?: MintUnit, onMintSelect?: any, isSelected?: boolean, isSelectable: boolean, isBlocked?: boolean, + /** + * The mint cannot serve the operation being offered (e.g. it does not support + * this payment method for this unit). The row stays visible but is inert and + * dimmed, and `disabledReason` says why — hiding it instead would leave the + * user wondering where their mint went. + * + * Distinct from `isBlocked`, which flags a user-blocked mint and only changes + * the right icon. + */ + isDisabled?: boolean, + disabledReason?: string, isUnitVisible?: boolean, separator?: 'bottom' | 'top' | 'both' style?: ViewStyle - }) { - + }) { + const iconSelectedColor = useThemeColor('mainButtonIcon') const iconColor = useThemeColor('textDim') const iconBlockedColor = colors.palette.angry500 @@ -36,32 +47,39 @@ export const MintListItem = observer(function(props: { margin: spacing.extraSmall } - const {mint, mintBalance, selectedUnit, onMintSelect, isSelected, isSelectable, isBlocked, isUnitVisible, separator, style} = props + const {mint, mintBalance, selectedUnit, onMintSelect, isSelected, isSelectable, isBlocked, isDisabled, disabledReason, isUnitVisible, separator, style} = props // log.trace('[MintListItem]', props) - + return ( onMintSelect(mint, mintBalance) : undefined} - RightComponent={mintBalance && selectedUnit && - onMintSelect(mint, mintBalance) : undefined} + RightComponent={mintBalance && selectedUnit && + + size='medium' + /> } - BottomComponent={isUnitVisible && mint.units ? (<>{mint.units.map(unit => )}) : undefined} + BottomComponent={isUnitVisible && mint.units ? (<>{mint.units.map(unit => )}) : undefined} containerStyle={{alignSelf: 'stretch'}} bottomSeparator={separator === 'bottom' || separator === 'both'} topSeparator={separator === 'top' || separator === 'both'} - style={[{paddingHorizontal: spacing.tiny}, style]} + style={[{paddingHorizontal: spacing.tiny}, isDisabled ? $disabled : null, style]} /> )}) +/** Dim an unusable mint without hiding it. */ +const $disabled: ViewStyle = { + opacity: 0.45, +} + diff --git a/src/screens/TopupScreen.tsx b/src/screens/TopupScreen.tsx index 834ebdf0..6410063d 100644 --- a/src/screens/TopupScreen.tsx +++ b/src/screens/TopupScreen.tsx @@ -917,6 +917,11 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) { unit={unitRef.current} title={translate("topup_mint")} confirmTitle={translate("commonConfirmCreateInvoice")} + // A mint that cannot mint in this unit by any method it advertises + // cannot top up at all — list it, but inert. Once onchain lands, a + // mint supporting either rail qualifies. + requiredCapability={mint => mint.supportsMint!('bolt11', unitRef.current)} + unsupportedReason={translate('mintSelector_noTopupSupport')} onMintBalanceSelect={onMintBalanceSelect} onCancel={onMintBalanceCancel} onMintBalanceConfirm={onMintBalanceConfirm} diff --git a/src/screens/TransferScreen.tsx b/src/screens/TransferScreen.tsx index b53dfbf6..50c3cafd 100644 --- a/src/screens/TransferScreen.tsx +++ b/src/screens/TransferScreen.tsx @@ -1048,6 +1048,11 @@ export const TransferScreen = observer(function TransferScreen({ route }: Props) unit={unitRef.current} title={translate('payCommon_payFrom')} confirmTitle={translate('payCommon_payNow')} + // This screen pays a bolt11 invoice, so a mint that cannot melt + // bolt11 in this unit cannot be paid from. Onchain melt (NUT-30) + // will gate on its own method when it lands. + requiredCapability={mint => mint.supportsMelt!('bolt11', unitRef.current)} + unsupportedReason={translate('mintSelector_noPayoutSupport')} onMintBalanceSelect={onMintBalanceSelect} onCancel={gotoWallet} onMintBalanceConfirm={transfer} diff --git a/src/screens/WalletScreen.tsx b/src/screens/WalletScreen.tsx index d8b31643..3184ec76 100644 --- a/src/screens/WalletScreen.tsx +++ b/src/screens/WalletScreen.tsx @@ -922,59 +922,75 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) { onBackButtonPress={toggleMintsModal} onBackdropPress={toggleMintsModal} /> + {/* + The verb lives in the sheet heading, so the rows are plain nouns: the two + options then read as a genuine either/or (which layer does the money move + on?) rather than repeating "Send"/"Receive" twice. Descriptions carry the + criteria a user actually decides on — speed and cost — instead of protocol + vocabulary. + + Neither row commits to a rail. Paying resolves the rail from whatever the + user pastes (invoice / LN address / onchain address); topping up asks in + TopupScreen, where the mint and unit are known and unsupported methods can + be hidden. So the user is never made to pick a rail uninformed. + */} - - gotoLightningPay()} //bottomSeparator={true} /> - {/* gotoNfcPay()} />*/} - + } onBackButtonPress={toggleSendModal} onBackdropPress={toggleSendModal} - /> + /> - - gotoTopup()} /> - + } onBackButtonPress={toggleReceiveModal} onBackdropPress={toggleReceiveModal} - /> + /> )