mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
Add mint capability model (Stage 2 of onchain)
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c7fd763eca
commit
d008d452ac
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -18,6 +18,10 @@ import { IconDefinition, Transform } from "@fortawesome/fontawesome-svg-core"
|
|||||||
// metro bundler tree-shaking issue: https://github.com/facebook/metro/issues/132
|
// metro bundler tree-shaking issue: https://github.com/facebook/metro/issues/132
|
||||||
// relevant font-awesome docs: https://docs.fontawesome.com/apis/javascript/tree-shaking
|
// 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 { faTwitter } from "@fortawesome/free-brands-svg-icons/faTwitter"
|
||||||
import { faTelegramPlane } from "@fortawesome/free-brands-svg-icons/faTelegramPlane"
|
import { faTelegramPlane } from "@fortawesome/free-brands-svg-icons/faTelegramPlane"
|
||||||
import { faDiscord } from "@fortawesome/free-brands-svg-icons/faDiscord"
|
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?
|
// TODO remove need for manual iconregistry?
|
||||||
// would be best to just import all of them, i guess, or figure out something smart
|
// 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 {
|
interface IconProps extends TouchableOpacityProps {
|
||||||
|
|||||||
@@ -721,17 +721,15 @@
|
|||||||
"walletScreen_mintExists": "This mint already exists",
|
"walletScreen_mintExists": "This mint already exists",
|
||||||
"walletScreen_pay": "Pay",
|
"walletScreen_pay": "Pay",
|
||||||
"walletScreen_paymentSentSuccess": "Payment request has been successfully sent.",
|
"walletScreen_paymentSentSuccess": "Payment request has been successfully sent.",
|
||||||
"walletScreen_payWithLightning": "Pay with Lightning",
|
"walletScreen_optionBitcoin": "Bitcoin",
|
||||||
"walletScreen_payWithLightningDesc": "Pay invoice or to a Lightning address",
|
"walletScreen_optionBitcoinSendDesc": "Lightning invoice or onchain address. Network fees apply.",
|
||||||
"walletScreen_receiveEcash": "Receive Ecash",
|
"walletScreen_optionEcashReceiveDesc": "Paste or scan an ecash token",
|
||||||
"walletScreen_receiveEcashDesc": "Paste or scan ecash token",
|
"walletScreen_optionEcash": "Ecash",
|
||||||
"walletScreen_sendEcash": "Send ecash",
|
"walletScreen_optionEcashSendDesc": "Instant and free, wallet to wallet",
|
||||||
"walletScreen_sendEcashDesc": "Share ecash or send it to one of your contacts",
|
|
||||||
"walletScreen_spentToday": "Spent today",
|
"walletScreen_spentToday": "Spent today",
|
||||||
"walletScreen_startByFunding": "Start by funding your wallet",
|
"walletScreen_startByFunding": "Start by funding your wallet",
|
||||||
"walletScreen_topup": "Topup",
|
"walletScreen_topup": "Topup",
|
||||||
"walletScreen_topupWithLightning": "Topup with Lightning",
|
"walletScreen_optionBitcoinReceiveDesc": "Top up over Lightning or onchain",
|
||||||
"walletScreen_topupWithLightningDesc": "Create Lightning invoice to topup your balance",
|
|
||||||
"warning": "Warning",
|
"warning": "Warning",
|
||||||
"welcomeScreen_creatingKeys": "Creating wallet keys...",
|
"welcomeScreen_creatingKeys": "Creating wallet keys...",
|
||||||
"welcomeScreen_creatingProfile": "Creating wallet profile...",
|
"welcomeScreen_creatingProfile": "Creating wallet profile...",
|
||||||
@@ -748,5 +746,7 @@
|
|||||||
"welcomeScreen_terms_agreePrefix": "I have read and agree to the",
|
"welcomeScreen_terms_agreePrefix": "I have read and agree to the",
|
||||||
"welcomeScreen_terms_agreeTerms": "Terms",
|
"welcomeScreen_terms_agreeTerms": "Terms",
|
||||||
"welcomeScreen_terms_agreeConjunction": "and",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -720,17 +720,15 @@
|
|||||||
"walletScreen_mintExists": "Esta menta ya existe",
|
"walletScreen_mintExists": "Esta menta ya existe",
|
||||||
"walletScreen_pay": "Pagar",
|
"walletScreen_pay": "Pagar",
|
||||||
"walletScreen_paymentSentSuccess": "La solicitud de pago ha sido enviada exitosamente.",
|
"walletScreen_paymentSentSuccess": "La solicitud de pago ha sido enviada exitosamente.",
|
||||||
"walletScreen_payWithLightning": "Pagar con Lightning",
|
"walletScreen_optionBitcoin": "Bitcoin",
|
||||||
"walletScreen_payWithLightningDesc": "Pagar factura o a una dirección Lightning",
|
"walletScreen_optionBitcoinSendDesc": "Factura Lightning o dirección onchain. Se aplican comisiones de red.",
|
||||||
"walletScreen_receiveEcash": "Recibir Ecash",
|
"walletScreen_optionEcashReceiveDesc": "Pega o escanea un token ecash",
|
||||||
"walletScreen_receiveEcashDesc": "Pegar o escanear el token de ecash",
|
"walletScreen_optionEcash": "Ecash",
|
||||||
"walletScreen_sendEcash": "Enviar dinero electrónico",
|
"walletScreen_optionEcashSendDesc": "Instantáneo y gratuito, de billetera a billetera",
|
||||||
"walletScreen_sendEcashDesc": "Comparte ecash o envíaselo a uno de tus contactos",
|
|
||||||
"walletScreen_spentToday": "Pasé hoy",
|
"walletScreen_spentToday": "Pasé hoy",
|
||||||
"walletScreen_startByFunding": "Comience por financiar su billetera",
|
"walletScreen_startByFunding": "Comience por financiar su billetera",
|
||||||
"walletScreen_topup": "Recarga",
|
"walletScreen_topup": "Recarga",
|
||||||
"walletScreen_topupWithLightning": "Recarga con Lightning",
|
"walletScreen_optionBitcoinReceiveDesc": "Recarga mediante Lightning u onchain",
|
||||||
"walletScreen_topupWithLightningDesc": "Crea una factura Lightning para recargar tu saldo",
|
|
||||||
"warning": "Advertencia",
|
"warning": "Advertencia",
|
||||||
"welcomeScreen_creatingKeys": "Creando claves de billetera...",
|
"welcomeScreen_creatingKeys": "Creando claves de billetera...",
|
||||||
"welcomeScreen_creatingProfile": "Creando perfil de billetera...",
|
"welcomeScreen_creatingProfile": "Creando perfil de billetera...",
|
||||||
@@ -747,5 +745,7 @@
|
|||||||
"welcomeScreen_terms_agreePrefix": "He leído y acepto los",
|
"welcomeScreen_terms_agreePrefix": "He leído y acepto los",
|
||||||
"welcomeScreen_terms_agreeTerms": "Términos",
|
"welcomeScreen_terms_agreeTerms": "Términos",
|
||||||
"welcomeScreen_terms_agreeConjunction": "y la",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -721,17 +721,15 @@
|
|||||||
"walletScreen_mintExists": "Este mint já existe",
|
"walletScreen_mintExists": "Este mint já existe",
|
||||||
"walletScreen_pay": "Pagar",
|
"walletScreen_pay": "Pagar",
|
||||||
"walletScreen_paymentSentSuccess": "Solicitação de pagamento enviada com sucesso.",
|
"walletScreen_paymentSentSuccess": "Solicitação de pagamento enviada com sucesso.",
|
||||||
"walletScreen_payWithLightning": "Pagar com Lightning",
|
"walletScreen_optionBitcoin": "Bitcoin",
|
||||||
"walletScreen_payWithLightningDesc": "Pagar invoice ou para endereço Lightning",
|
"walletScreen_optionBitcoinSendDesc": "Fatura Lightning ou endereço onchain. Aplicam-se taxas de rede.",
|
||||||
"walletScreen_receiveEcash": "Receber Ecash",
|
"walletScreen_optionEcashReceiveDesc": "Cole ou digitalize um token ecash",
|
||||||
"walletScreen_receiveEcashDesc": "Colar ou escanear token ecash",
|
"walletScreen_optionEcash": "Ecash",
|
||||||
"walletScreen_sendEcash": "Enviar ecash",
|
"walletScreen_optionEcashSendDesc": "Instantâneo e gratuito, de carteira para carteira",
|
||||||
"walletScreen_sendEcashDesc": "Compartilhar ecash ou enviar para contatos",
|
|
||||||
"walletScreen_spentToday": "Gasto hoje",
|
"walletScreen_spentToday": "Gasto hoje",
|
||||||
"walletScreen_startByFunding": "Comece financiando sua carteira",
|
"walletScreen_startByFunding": "Comece financiando sua carteira",
|
||||||
"walletScreen_topup": "Recarga",
|
"walletScreen_topup": "Recarga",
|
||||||
"walletScreen_topupWithLightning": "Recarregar com Lightning",
|
"walletScreen_optionBitcoinReceiveDesc": "Carregue via Lightning ou onchain",
|
||||||
"walletScreen_topupWithLightningDesc": "Criar invoice Lightning para recarregar saldo",
|
|
||||||
"warning": "Aviso",
|
"warning": "Aviso",
|
||||||
"welcomeScreen_creatingKeys": "Criando chaves da carteira...",
|
"welcomeScreen_creatingKeys": "Criando chaves da carteira...",
|
||||||
"welcomeScreen_creatingProfile": "Criando perfil da carteira...",
|
"welcomeScreen_creatingProfile": "Criando perfil da carteira...",
|
||||||
@@ -748,5 +746,7 @@
|
|||||||
"welcomeScreen_terms_agreePrefix": "Li e concordo com os",
|
"welcomeScreen_terms_agreePrefix": "Li e concordo com os",
|
||||||
"welcomeScreen_terms_agreeTerms": "Termos",
|
"welcomeScreen_terms_agreeTerms": "Termos",
|
||||||
"welcomeScreen_terms_agreeConjunction": "e a",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -721,17 +721,15 @@
|
|||||||
"walletScreen_mintExists": "Tento mint už existuje",
|
"walletScreen_mintExists": "Tento mint už existuje",
|
||||||
"walletScreen_pay": "Zaplať",
|
"walletScreen_pay": "Zaplať",
|
||||||
"walletScreen_paymentSentSuccess": "Požiadavka na platbu bola odoslaná.",
|
"walletScreen_paymentSentSuccess": "Požiadavka na platbu bola odoslaná.",
|
||||||
"walletScreen_payWithLightning": "Zaplať cez Lightning",
|
"walletScreen_optionBitcoin": "Bitcoin",
|
||||||
"walletScreen_payWithLightningDesc": "Zaplať invoice alebo na Lightning adresu",
|
"walletScreen_optionBitcoinSendDesc": "Lightning faktúra alebo onchain adresa. Účtujú sa sieťové poplatky.",
|
||||||
"walletScreen_receiveEcash": "Prijmi ecash",
|
"walletScreen_optionEcashReceiveDesc": "Vložte alebo naskenujte ecash token",
|
||||||
"walletScreen_receiveEcashDesc": "Vlož alebo skenuj ecash token",
|
"walletScreen_optionEcash": "Ecash",
|
||||||
"walletScreen_sendEcash": "Pošli ecash",
|
"walletScreen_optionEcashSendDesc": "Okamžite a zadarmo, z peňaženky do peňaženky",
|
||||||
"walletScreen_sendEcashDesc": "Zdieľaj ecash alebo pošli ecash jednému zo svojich kontaktov",
|
|
||||||
"walletScreen_spentToday": "Dnes minuté",
|
"walletScreen_spentToday": "Dnes minuté",
|
||||||
"walletScreen_startByFunding": "Začni doplnením peňaženky",
|
"walletScreen_startByFunding": "Začni doplnením peňaženky",
|
||||||
"walletScreen_topup": "Pridaj",
|
"walletScreen_topup": "Pridaj",
|
||||||
"walletScreen_topupWithLightning": "Pridaj cez Lightning",
|
"walletScreen_optionBitcoinReceiveDesc": "Dobitie cez Lightning alebo onchain",
|
||||||
"walletScreen_topupWithLightningDesc": "Vytvor Lightning invoice na pridanie ecash",
|
|
||||||
"warning": "Upozornenie",
|
"warning": "Upozornenie",
|
||||||
"welcomeScreen_creatingKeys": "Vytváram kľúče peňaženky...",
|
"welcomeScreen_creatingKeys": "Vytváram kľúče peňaženky...",
|
||||||
"welcomeScreen_creatingProfile": "Vytváram profil 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_agreePrefix": "Prečítal som si a súhlasím s",
|
||||||
"welcomeScreen_terms_agreeTerms": "Podmienkami",
|
"welcomeScreen_terms_agreeTerms": "Podmienkami",
|
||||||
"welcomeScreen_terms_agreeConjunction": "a",
|
"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é"
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-3
@@ -4,6 +4,8 @@ import {
|
|||||||
type GetInfoResponse,
|
type GetInfoResponse,
|
||||||
type MintKeys as CashuMintKeys,
|
type MintKeys as CashuMintKeys,
|
||||||
type MintKeyset as CashuMintKeyset,
|
type MintKeyset as CashuMintKeyset,
|
||||||
|
type MintMethod,
|
||||||
|
type SwapMethod,
|
||||||
Mint as CashuMint,
|
Mint as CashuMint,
|
||||||
} from '@cashu/cashu-ts'
|
} from '@cashu/cashu-ts'
|
||||||
import {colors, getRandomIconColor} from '../theme'
|
import {colors, getRandomIconColor} from '../theme'
|
||||||
@@ -16,6 +18,16 @@ import { generateId } from '../utils/utils'
|
|||||||
import { Proof } from './Proof'
|
import { Proof } from './Proof'
|
||||||
import { CashuProof, CashuUtils } from '../services/cashu/cashuUtils'
|
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 = {
|
export type MintBalance = {
|
||||||
mintUrl: string
|
mintUrl: string
|
||||||
balances: {
|
balances: {
|
||||||
@@ -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')
|
.actions(withSetPropAction) // TODO? start to use across app to avoid pure setter methods, e.g. mint.setProp('color', '#ccc')
|
||||||
.views(self => ({
|
.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 => ({
|
.actions(self => ({
|
||||||
addKeyset(keyset: CashuMintKeyset) {
|
addKeyset(keyset: CashuMintKeyset) {
|
||||||
@@ -414,8 +487,18 @@ export const MintModel = types
|
|||||||
self.initKeys(key)
|
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})
|
log.trace('[setMintInfo]', {mintUrl: self.mintUrl})
|
||||||
},
|
},
|
||||||
getProofsCounterByKeysetId(keysetId: string) {
|
getProofsCounterByKeysetId(keysetId: string) {
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
Mint as CashuMint,
|
Mint as CashuMint,
|
||||||
Wallet as CashuWallet,
|
Wallet as CashuWallet,
|
||||||
@@ -39,6 +39,16 @@ import { Transaction } from './Transaction'
|
|||||||
so that new cashu-ts instances are re-used over app lifecycle.
|
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 = {
|
export type ExchangeRate = {
|
||||||
currency: CurrencyCode, // 1 EUR, USD, ...
|
currency: CurrencyCode, // 1 EUR, USD, ...
|
||||||
rate: number // in satoshis
|
rate: number // in satoshis
|
||||||
@@ -231,12 +241,53 @@ export const WalletStoreModel = types
|
|||||||
const keys: WalletKeys = yield self.getCachedWalletKeys()
|
const keys: WalletKeys = yield self.getCachedWalletKeys()
|
||||||
return keys.SEED.seedHash
|
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) {
|
getMint: flow(function* getMint(mintUrl: string) {
|
||||||
const mint = self.mints.find(m => m.mintUrl === mintUrl)
|
const mint = self.mints.find(m => m.mintUrl === mintUrl)
|
||||||
|
|
||||||
log.trace('[WalletStore.getMint]', {cachedMint: !!mint})
|
log.trace('[WalletStore.getMint]', {cachedMint: !!mint})
|
||||||
|
|
||||||
if (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
|
return mint as CashuMint
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,12 +320,12 @@ export const WalletStoreModel = types
|
|||||||
// sync wallet state with fresh keysets, active statuses and keys
|
// sync wallet state with fresh keysets, active statuses and keys
|
||||||
mintInstance.refreshKeysets!(keysets)
|
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 {mintInfo} = mintInstance
|
||||||
const now = Math.floor(Date.now() / 1000)
|
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()
|
const info: GetInfoResponse = yield newMint.getInfo()
|
||||||
mintInstance.setMintInfo!(info as GetInfoResponse & {time: number})
|
mintInstance.setMintInfo!(info)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { FlatList, Keyboard, LayoutAnimation, Platform, View, ViewStyle } from "
|
|||||||
import { observer } from "mobx-react-lite"
|
import { observer } from "mobx-react-lite"
|
||||||
import { Button, Card, Icon, IconTypes} from "../../components"
|
import { Button, Card, Icon, IconTypes} from "../../components"
|
||||||
import { spacing, useThemeColor } from "../../theme"
|
import { spacing, useThemeColor } from "../../theme"
|
||||||
import { MintBalance } from "../../models/Mint"
|
import { Mint, MintBalance } from "../../models/Mint"
|
||||||
import { MintUnit } from "../../services/wallet/currency"
|
import { MintUnit } from "../../services/wallet/currency"
|
||||||
|
|
||||||
import { useStores } from "../../models"
|
import { useStores } from "../../models"
|
||||||
@@ -21,6 +21,16 @@ export const MintBalanceSelector = observer(function (props: {
|
|||||||
secondaryConfirmIcon?: IconTypes
|
secondaryConfirmIcon?: IconTypes
|
||||||
cancelIcon?: IconTypes
|
cancelIcon?: IconTypes
|
||||||
collapsible?: boolean
|
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
|
onMintBalanceSelect: any
|
||||||
onSecondaryMintBalanceSelect?: any
|
onSecondaryMintBalanceSelect?: any
|
||||||
onCancel?: any
|
onCancel?: any
|
||||||
@@ -90,15 +100,22 @@ export const MintBalanceSelector = observer(function (props: {
|
|||||||
<FlatList<MintBalance>
|
<FlatList<MintBalance>
|
||||||
data={props.mintBalances}
|
data={props.mintBalances}
|
||||||
renderItem={({ item, index }) => {
|
renderItem={({ item, index }) => {
|
||||||
|
const mint = mintsStore.findByUrl(item.mintUrl)!
|
||||||
|
const isDisabled = props.requiredCapability
|
||||||
|
? !props.requiredCapability(mint)
|
||||||
|
: false
|
||||||
|
|
||||||
return(
|
return(
|
||||||
<MintListItem
|
<MintListItem
|
||||||
key={item.mintUrl}
|
key={item.mintUrl}
|
||||||
mint={mintsStore.findByUrl(item.mintUrl)!}
|
mint={mint}
|
||||||
mintBalance={item}
|
mintBalance={item}
|
||||||
selectedUnit={props.unit}
|
selectedUnit={props.unit}
|
||||||
onMintSelect={() => onMintSelect(item)}
|
onMintSelect={() => onMintSelect(item)}
|
||||||
isSelectable={true}
|
isSelectable={true}
|
||||||
isSelected={!!props.selectedMintBalance ? props.selectedMintBalance.mintUrl === item.mintUrl : false}
|
isSelected={!!props.selectedMintBalance ? props.selectedMintBalance.mintUrl === item.mintUrl : false}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
disabledReason={props.unsupportedReason}
|
||||||
separator={index === 0 || allVisible === false ? undefined : 'top'}
|
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'} : {}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -18,6 +18,17 @@ export const MintListItem = observer(function(props: {
|
|||||||
isSelected?: boolean,
|
isSelected?: boolean,
|
||||||
isSelectable: boolean,
|
isSelectable: boolean,
|
||||||
isBlocked?: 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,
|
isUnitVisible?: boolean,
|
||||||
separator?: 'bottom' | 'top' | 'both'
|
separator?: 'bottom' | 'top' | 'both'
|
||||||
style?: ViewStyle
|
style?: ViewStyle
|
||||||
@@ -36,7 +47,7 @@ export const MintListItem = observer(function(props: {
|
|||||||
margin: spacing.extraSmall
|
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)
|
// log.trace('[MintListItem]', props)
|
||||||
|
|
||||||
@@ -44,12 +55,14 @@ export const MintListItem = observer(function(props: {
|
|||||||
<ListItem
|
<ListItem
|
||||||
key={mint.mintUrl}
|
key={mint.mintUrl}
|
||||||
text={mint.shortname}
|
text={mint.shortname}
|
||||||
subText={mint.hostname}
|
// when disabled, the reason replaces the hostname: it is the one thing
|
||||||
|
// the user needs to read on this row
|
||||||
|
subText={isDisabled && disabledReason ? disabledReason : mint.hostname}
|
||||||
leftIcon={isSelectable ? isSelected ? 'faCheckCircle' : 'faCircle' : undefined}
|
leftIcon={isSelectable ? isSelected ? 'faCheckCircle' : 'faCircle' : undefined}
|
||||||
leftIconColor={isSelected ? iconSelectedColor as string : iconColor as string}
|
leftIconColor={isSelected ? iconSelectedColor as string : iconColor as string}
|
||||||
rightIcon={isBlocked ? 'faShieldHalved' : mint.status === MintStatus.OFFLINE ? 'faTriangleExclamation' : undefined}
|
rightIcon={isBlocked ? 'faShieldHalved' : mint.status === MintStatus.OFFLINE ? 'faTriangleExclamation' : undefined}
|
||||||
rightIconColor={isBlocked ? iconBlockedColor : iconColor as string}
|
rightIconColor={isBlocked ? iconBlockedColor : iconColor as string}
|
||||||
onPress={onMintSelect ? () => onMintSelect(mint, mintBalance) : undefined}
|
onPress={isDisabled ? undefined : onMintSelect ? () => onMintSelect(mint, mintBalance) : undefined}
|
||||||
RightComponent={mintBalance && selectedUnit &&
|
RightComponent={mintBalance && selectedUnit &&
|
||||||
<CurrencyAmount
|
<CurrencyAmount
|
||||||
amount={mintBalance?.balances[selectedUnit] || 0}
|
amount={mintBalance?.balances[selectedUnit] || 0}
|
||||||
@@ -61,7 +74,12 @@ export const MintListItem = observer(function(props: {
|
|||||||
containerStyle={{alignSelf: 'stretch'}}
|
containerStyle={{alignSelf: 'stretch'}}
|
||||||
bottomSeparator={separator === 'bottom' || separator === 'both'}
|
bottomSeparator={separator === 'bottom' || separator === 'both'}
|
||||||
topSeparator={separator === 'top' || 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -917,6 +917,11 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
|
|||||||
unit={unitRef.current}
|
unit={unitRef.current}
|
||||||
title={translate("topup_mint")}
|
title={translate("topup_mint")}
|
||||||
confirmTitle={translate("commonConfirmCreateInvoice")}
|
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}
|
onMintBalanceSelect={onMintBalanceSelect}
|
||||||
onCancel={onMintBalanceCancel}
|
onCancel={onMintBalanceCancel}
|
||||||
onMintBalanceConfirm={onMintBalanceConfirm}
|
onMintBalanceConfirm={onMintBalanceConfirm}
|
||||||
|
|||||||
@@ -1048,6 +1048,11 @@ export const TransferScreen = observer(function TransferScreen({ route }: Props)
|
|||||||
unit={unitRef.current}
|
unit={unitRef.current}
|
||||||
title={translate('payCommon_payFrom')}
|
title={translate('payCommon_payFrom')}
|
||||||
confirmTitle={translate('payCommon_payNow')}
|
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}
|
onMintBalanceSelect={onMintBalanceSelect}
|
||||||
onCancel={gotoWallet}
|
onCancel={gotoWallet}
|
||||||
onMintBalanceConfirm={transfer}
|
onMintBalanceConfirm={transfer}
|
||||||
|
|||||||
@@ -922,22 +922,36 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
|
|||||||
onBackButtonPress={toggleMintsModal}
|
onBackButtonPress={toggleMintsModal}
|
||||||
onBackdropPress={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.
|
||||||
|
*/}
|
||||||
<BottomModal
|
<BottomModal
|
||||||
isVisible={isSendModalVisible ? true : false}
|
isVisible={isSendModalVisible ? true : false}
|
||||||
//style={{alignItems: 'stretch'}}
|
//style={{alignItems: 'stretch'}}
|
||||||
|
headingTx="payCommon_send"
|
||||||
ContentComponent={
|
ContentComponent={
|
||||||
<>
|
<>
|
||||||
<ListItem
|
<ListItem
|
||||||
leftIcon='faMoneyBill1'
|
leftIcon='faMoneyBill1'
|
||||||
tx="walletScreen_sendEcash"
|
tx="walletScreen_optionEcash"
|
||||||
subTx="walletScreen_sendEcashDesc"
|
subTx="walletScreen_optionEcashSendDesc"
|
||||||
onPress={gotoSend}
|
onPress={gotoSend}
|
||||||
bottomSeparator={true}
|
bottomSeparator={true}
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
leftIcon='faBolt'
|
leftIcon='faBitcoin'
|
||||||
tx="walletScreen_payWithLightning"
|
leftIconColor={colors.palette.orange400}
|
||||||
subTx="walletScreen_payWithLightningDesc"
|
tx="walletScreen_optionBitcoin"
|
||||||
|
subTx="walletScreen_optionBitcoinSendDesc"
|
||||||
onPress={() => gotoLightningPay()}
|
onPress={() => gotoLightningPay()}
|
||||||
//bottomSeparator={true}
|
//bottomSeparator={true}
|
||||||
/>
|
/>
|
||||||
@@ -955,19 +969,21 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
|
|||||||
<BottomModal
|
<BottomModal
|
||||||
isVisible={isReceiveModalVisible ? true : false}
|
isVisible={isReceiveModalVisible ? true : false}
|
||||||
// style={{alignItems: 'stretch'}}
|
// style={{alignItems: 'stretch'}}
|
||||||
|
headingTx="payCommon_receive"
|
||||||
ContentComponent={
|
ContentComponent={
|
||||||
<>
|
<>
|
||||||
<ListItem
|
<ListItem
|
||||||
leftIcon='faMoneyBill1'
|
leftIcon='faMoneyBill1'
|
||||||
tx="walletScreen_receiveEcash"
|
tx="walletScreen_optionEcash"
|
||||||
subTx="walletScreen_receiveEcashDesc"
|
subTx="walletScreen_optionEcashReceiveDesc"
|
||||||
onPress={gotoTokenReceive}
|
onPress={gotoTokenReceive}
|
||||||
bottomSeparator={true}
|
bottomSeparator={true}
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
leftIcon='faBolt'
|
leftIcon='faBitcoin'
|
||||||
tx='walletScreen_topupWithLightning'
|
leftIconColor={colors.palette.orange400}
|
||||||
subTx="walletScreen_topupWithLightningDesc"
|
tx='walletScreen_optionBitcoin'
|
||||||
|
subTx="walletScreen_optionBitcoinReceiveDesc"
|
||||||
onPress={() => gotoTopup()}
|
onPress={() => gotoTopup()}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user