diff --git a/__tests__/mintAuditService.test.ts b/__tests__/mintAuditService.test.ts new file mode 100644 index 00000000..9fd759c1 --- /dev/null +++ b/__tests__/mintAuditService.test.ts @@ -0,0 +1,287 @@ +/** + * Mint auditor client (services/mintAuditService.ts). + * + * The auditor is a third party the wallet must not depend on: it can be down, + * can answer 404 for a mint it does not track, and can change its payload. The + * contract this file pins is that NONE of that ever reaches the user as an + * error — the availability section simply does not render. + * + * The other half is the warning heuristics, which mirror cashu.me's auditor + * panel (MintAuditWarningBox) so the same mint does not read as healthy in one + * wallet and suspect in the other. Getting a threshold wrong here is quiet and + * consequential: it either cries wolf about a working mint or stays silent + * about one that has been failing for a week. + * + * @jest-environment node + */ +jest.mock('../src/services/logService', () => ({ + log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()}, +})) + +import {AUDIT_THRESHOLDS, getMintAuditSummary} from '../src/services/mintAuditService' + +const MINT_URL = 'https://mint.test/Bitcoin' + +const RECORD = { + id: 2, + url: MINT_URL, + name: 'Test mint', + state: 'OK', + n_errors: 87, + n_mints: 1961, + n_melts: 2951, + updated_at: '2026-08-10T19:01:04', +} + +/** Naive-UTC timestamp `daysAgo` days back, in the auditor's wire format. */ +const at = (daysAgo: number) => + new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000).toISOString().replace(/\.\d+Z$/, '') + +const swap = (opts: {daysAgo: number; state?: string; ms?: number}) => ({ + state: opts.state ?? 'OK', + time_taken: opts.ms ?? 2000, + created_at: at(opts.daysAgo), +}) + +/** A healthy history: enough recent successes, all fast. */ +const healthySwaps = () => Array.from({length: 10}, (_, i) => swap({daysAgo: i * 0.5})) + +/** Route the two endpoints the service calls; `swaps: null` fails that request. */ +const mockApi = (opts: {record?: unknown; recordStatus?: number; swaps?: unknown | null}) => { + const fetchMock = jest.fn(async (url: string) => { + if (url.includes('/mints/url')) { + const status = opts.recordStatus ?? 200 + return {ok: status >= 200 && status < 300, status, json: async () => opts.record} + } + if (opts.swaps === null) throw new Error('network down') + return {ok: true, status: 200, json: async () => opts.swaps ?? []} + }) + ;(global as any).fetch = fetchMock + return fetchMock +} + +afterEach(() => { + delete (global as any).fetch +}) + +describe('failure is always graceful', () => { + it('resolves undefined when the auditor does not track the mint (404)', async () => { + mockApi({recordStatus: 404, record: {detail: 'Not found'}}) + await expect(getMintAuditSummary(MINT_URL)).resolves.toBeUndefined() + }) + + it('resolves undefined when the request throws', async () => { + ;(global as any).fetch = jest.fn(async () => { + throw new Error('network down') + }) + await expect(getMintAuditSummary(MINT_URL)).resolves.toBeUndefined() + }) + + it('resolves undefined when the payload is not the record we expect', async () => { + // No `id` means we cannot trust anything else in the body either. + mockApi({record: {url: MINT_URL, state: 'OK'}}) + await expect(getMintAuditSummary(MINT_URL)).resolves.toBeUndefined() + }) + + it('keeps the summary when only the swap history fails', async () => { + // Degrading to the record alone beats dropping the whole section. + mockApi({record: RECORD, swaps: null}) + const summary = await getMintAuditSummary(MINT_URL) + expect(summary?.state).toBe('OK') + expect(summary?.swap).toBeUndefined() + expect(summary?.warnings).toEqual([]) + expect(summary?.history).toEqual([]) + }) + + it('resolves undefined for an empty mint url without calling out', async () => { + const fetchMock = mockApi({record: RECORD}) + await expect(getMintAuditSummary('')).resolves.toBeUndefined() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('url-encodes the mint url it looks up', async () => { + const fetchMock = mockApi({record: RECORD}) + await getMintAuditSummary(MINT_URL) + expect(fetchMock.mock.calls[0][0]).toContain(encodeURIComponent(MINT_URL)) + }) + + it('falls back to UNKNOWN for a state it does not recognise', async () => { + mockApi({record: {...RECORD, state: 'WEIRD'}}) + expect((await getMintAuditSummary(MINT_URL))?.state).toBe('UNKNOWN') + }) +}) + +describe('swap statistics', () => { + it('scores the success rate over the whole sample', async () => { + mockApi({ + record: RECORD, + swaps: [swap({daysAgo: 0}), swap({daysAgo: 1}), swap({daysAgo: 2, state: 'ERROR'})], + }) + const stats = (await getMintAuditSummary(MINT_URL))?.swap + expect(stats).toMatchObject({successRate: 67, okCount: 2, totalCount: 3}) + }) + + it('averages only the SUCCESSFUL swaps — a failure has no meaningful duration', async () => { + mockApi({ + record: RECORD, + swaps: [ + swap({daysAgo: 0, ms: 1000}), + swap({daysAgo: 1, ms: 3000}), + swap({daysAgo: 2, state: 'ERROR', ms: 60000}), + ], + }) + expect((await getMintAuditSummary(MINT_URL))?.swap?.averageMs).toBe(2000) + }) + + it('leaves the average absent when no swap was timed', async () => { + mockApi({record: RECORD, swaps: [{state: 'OK', created_at: at(0)}]}) + expect((await getMintAuditSummary(MINT_URL))?.swap?.averageMs).toBeUndefined() + }) +}) + +describe('swap history (drives the timeline)', () => { + it('returns the sample sorted OLDEST first, whatever order the API used', async () => { + // The chart reads left-to-right off this ordering, and the API's + // newest-first ordering is not contractual. + mockApi({ + record: RECORD, + swaps: [swap({daysAgo: 1}), swap({daysAgo: 5}), swap({daysAgo: 3})], + }) + const history = (await getMintAuditSummary(MINT_URL))!.history + expect(history).toHaveLength(3) + expect(history.map(h => h.at.getTime())).toEqual( + [...history.map(h => h.at.getTime())].sort((a, b) => a - b), + ) + }) + + it('carries the outcome as a boolean, not the auditor\'s state vocabulary', async () => { + mockApi({record: RECORD, swaps: [swap({daysAgo: 1}), swap({daysAgo: 2, state: 'ERROR'})]}) + expect((await getMintAuditSummary(MINT_URL))!.history.map(h => h.ok)).toEqual([false, true]) + }) + + it('drops events the timeline could not place', async () => { + mockApi({record: RECORD, swaps: [{state: 'OK'}, swap({daysAgo: 1})]}) + expect((await getMintAuditSummary(MINT_URL))!.history).toHaveLength(1) + }) +}) + +describe('auditor timestamps are naive UTC', () => { + it('reads `2026-08-10T19:01:04` as UTC, not as local time', async () => { + // Parsed as local time this shifts by the device offset, and far enough + // east the auditor appears to have checked in the future. + mockApi({record: RECORD}) + expect((await getMintAuditSummary(MINT_URL))?.updatedAt?.toISOString()) + .toBe('2026-08-10T19:01:04.000Z') + }) + + it('respects an explicit zone when the auditor sends one', async () => { + mockApi({record: {...RECORD, updated_at: '2026-08-10T19:01:04+02:00'}}) + expect((await getMintAuditSummary(MINT_URL))?.updatedAt?.toISOString()) + .toBe('2026-08-10T17:01:04.000Z') + }) + + it('drops an unparseable timestamp rather than rendering an invalid date', async () => { + mockApi({record: {...RECORD, updated_at: 'not a date'}}) + expect((await getMintAuditSummary(MINT_URL))?.updatedAt).toBeUndefined() + }) +}) + +describe('warnings', () => { + const codesFor = async (state: string, swaps: unknown[]) => { + mockApi({record: {...RECORD, state}, swaps}) + const summary = await getMintAuditSummary(MINT_URL) + return summary!.warnings.map(w => w.code) + } + + it('stays silent about a healthy mint', async () => { + expect(await codesFor('OK', healthySwaps())).toEqual([]) + }) + + it('qualifies a failed last swap by the recent record', async () => { + // A single failure against a good week usually says more about the + // swap's counterparty than about this mint. + expect(await codesFor('ERROR', healthySwaps())).toEqual(['lastSwapFailedReliable']) + }) + + it('calls a failing mint unreliable when the recent record is bad too', async () => { + const swaps = [ + ...Array.from({length: 6}, (_, i) => swap({daysAgo: i * 0.5, state: 'ERROR'})), + ...Array.from({length: 4}, (_, i) => swap({daysAgo: 3 + i * 0.5})), + ] + expect(await codesFor('ERROR', swaps)).toContain('lastSwapFailedUnreliable') + }) + + it('reports a failure without context when there is no recent history', async () => { + expect(await codesFor('ERROR', [swap({daysAgo: 60})])).toContain('lastSwapFailed') + }) + + it('says so when the auditor could not form a verdict', async () => { + expect(await codesFor('UNKNOWN', healthySwaps())).toEqual(['unknownQuality']) + }) + + it('flags thin recent data even when the mint is OK', async () => { + const codes = await codesFor('OK', [swap({daysAgo: 0}), swap({daysAgo: 1})]) + expect(codes).toContain('notEnoughData') + }) + + it('flags a recent success rate under the threshold', async () => { + const swaps = [ + ...Array.from({length: 8}, (_, i) => swap({daysAgo: i * 0.2})), + ...Array.from({length: 4}, (_, i) => swap({daysAgo: 2 + i * 0.2, state: 'ERROR'})), + ] + mockApi({record: RECORD, swaps}) + const warning = (await getMintAuditSummary(MINT_URL))!.warnings.find( + w => w.code === 'lowSuccessRate', + ) + expect(warning?.params).toEqual({ + percent: 67, + days: AUDIT_THRESHOLDS.recentDays, + threshold: AUDIT_THRESHOLDS.successRatePercent, + }) + }) + + it('judges liveness over the whole sample, not the recent window', async () => { + // A mint that went quiet a month ago has NO recent swaps to fail, so a + // recent-window-only check would report nothing at all. + const codes = await codesFor('OK', [swap({daysAgo: 30}), swap({daysAgo: 31})]) + expect(codes).toContain('inactive') + }) + + it('does not call a mint inactive within the threshold', async () => { + expect(await codesFor('OK', healthySwaps())).not.toContain('inactive') + }) + + it('finds the last success by date, not by array position', async () => { + // The API returns newest first, but that ordering is not contractual. + mockApi({ + record: RECORD, + swaps: [swap({daysAgo: 40}), swap({daysAgo: 0}), swap({daysAgo: 20})], + }) + const codes = (await getMintAuditSummary(MINT_URL))!.warnings.map(w => w.code) + expect(codes).not.toContain('inactive') + }) + + it('flags a slow mint', async () => { + const swaps = Array.from({length: 10}, (_, i) => + swap({daysAgo: i * 0.5, ms: AUDIT_THRESHOLDS.slowMs + 1000}), + ) + mockApi({record: RECORD, swaps}) + const warning = (await getMintAuditSummary(MINT_URL))!.warnings.find(w => w.code === 'slow') + expect(warning?.params).toEqual({seconds: '6.0'}) + }) + + it('reports a mint with swaps but no successes as unreachable', async () => { + const codes = await codesFor('OK', [ + swap({daysAgo: 0, state: 'ERROR'}), + swap({daysAgo: 1, state: 'ERROR'}), + ]) + expect(codes).toContain('noSuccessfulSwaps') + // ...and not ALSO as inactive, which needs a last success to measure from + expect(codes).not.toContain('inactive') + }) + + it('ignores swap events with no usable timestamp', async () => { + const codes = await codesFor('OK', [{state: 'OK', time_taken: 100}, ...healthySwaps()]) + expect(codes).toEqual([]) + }) +}) diff --git a/__tests__/mintInfoSummary.test.ts b/__tests__/mintInfoSummary.test.ts new file mode 100644 index 00000000..5d5bbfa3 --- /dev/null +++ b/__tests__/mintInfoSummary.test.ts @@ -0,0 +1,307 @@ +/** + * Mint info presentation (screens/Mints/mintInfoSummary.ts). + * + * Every field of a NUT-06 `/v1/info` response is optional and mints publish + * genuinely inconsistent shapes for the same NUT — nutshell answers NUT-17 as a + * bare array, others as `{supported: [...]}`, others as `{supported: true}`. + * The mint detail screen used to carry a per-NUT ladder of special cases for + * this; the whole point of this module is to funnel it into one place, so the + * funnel is what these tests pin down. + * + * The failure that matters is silent: a mint that DOES support something + * rendering as if it does not (or vice versa), which sends a user to a mint + * under a false idea of what it can do. + * + * @jest-environment node + */ +// currency.ts imports the component modules for its currency icons, which pull +// in react-native-reanimated (ESM, not transformed here). Only MintUnits is +// reached from this module's unit-narrowing. +jest.mock('../src/services/wallet/currency', () => ({ + MintUnits: ['btc', 'sat', 'msat', 'usd', 'eur'], +})) +// The components barrel is imported for the IconTypes TYPE only, which is +// erased at compile time — but the import statement still executes. +jest.mock('../src/components', () => ({})) +jest.mock('../src/i18n', () => ({})) + +import { + asNonEmptyString, + getContacts, + getMintCapabilities, + getNutSupport, + getPaymentMethods, + isNutSupported, + MintInfo, +} from '../src/screens/Mints/mintInfoSummary' + +const infoWith = (nuts: Record) => ({nuts} as unknown as MintInfo) + +const capability = (info: MintInfo | undefined, key: string) => + getMintCapabilities(info).find(c => c.key === key) + +const method = (info: MintInfo | undefined, name: string) => + getPaymentMethods(info).find(m => m.method === name) + +describe('isNutSupported', () => { + it('reads the common {supported: boolean} form', () => { + expect(isNutSupported({supported: true})).toBe(true) + expect(isNutSupported({supported: false})).toBe(false) + }) + + it('reads a non-empty {supported: [...]} array (nutshell NUT-17)', () => { + expect(isNutSupported({supported: [{method: 'bolt11', unit: 'sat'}]})).toBe(true) + expect(isNutSupported({supported: []})).toBe(false) + }) + + it('reads a BARE array, which nutshell also publishes for NUT-17', () => { + // https://github.com/cashubtc/nutshell/issues/588 + expect(isNutSupported([{method: 'bolt11', unit: 'sat'}])).toBe(true) + expect(isNutSupported([])).toBe(false) + }) + + it('treats an advertised method list as support', () => { + expect(isNutSupported({methods: [{method: 'bolt11', unit: 'sat'}]})).toBe(true) + expect(isNutSupported({methods: []})).toBe(false) + }) + + it('lets `disabled` override an advertised method list', () => { + expect(isNutSupported({methods: [{method: 'bolt11'}], disabled: true})).toBe(false) + expect(isNutSupported({methods: [{method: 'bolt11'}], disabled: false})).toBe(true) + }) + + it('reads NUT-19 cached endpoints', () => { + expect(isNutSupported({cached_endpoints: [{method: 'POST', path: '/v1/swap'}], ttl: 604800})).toBe(true) + expect(isNutSupported({cached_endpoints: []})).toBe(false) + }) + + it('survives the malformed values mints actually publish', () => { + expect(isNutSupported(null)).toBe(false) + expect(isNutSupported(undefined)).toBe(false) + expect(isNutSupported({})).toBe(false) + expect(isNutSupported(true)).toBe(true) + expect(isNutSupported(false)).toBe(false) + expect(isNutSupported('yes')).toBe(true) + expect(isNutSupported(0)).toBe(false) + }) +}) + +describe('capabilities', () => { + it('is on when ANY of the capability\'s NUTs is supported', () => { + // authentication: clear (21) OR blind (22) + expect(capability(infoWith({'22': {supported: true}}), 'authRequired')?.supported).toBe(true) + expect(capability(infoWith({'21': {supported: true}}), 'authRequired')?.supported).toBe(true) + expect(capability(infoWith({}), 'authRequired')?.supported).toBe(false) + }) + + it('flags authentication as a caveat, not a feature', () => { + // The screen renders `warning` capabilities only when PRESENT — a mint + // that needs no account must not be listed as missing a feature. + expect(capability(infoWith({}), 'authRequired')?.warning).toBe(true) + expect(capability(infoWith({}), 'seedRestore')?.warning).toBeUndefined() + }) + + it('reports every capability as unsupported when the mint published no nuts', () => { + const capabilities = getMintCapabilities(undefined) + expect(capabilities.length).toBeGreaterThan(0) + expect(capabilities.every(c => !c.supported)).toBe(true) + }) + + it('is not fooled by a nuts value that is not an object', () => { + expect(getMintCapabilities({nuts: 'broken'} as unknown as MintInfo).every(c => !c.supported)).toBe(true) + }) +}) + +describe('technical NUT breakdown', () => { + it('sorts numerically, not lexically', () => { + const nuts = getNutSupport(infoWith({'20': {supported: true}, '4': {}, '17': {}, '7': {}})) + expect(nuts.map(n => n.nut)).toEqual(['4', '7', '17', '20']) + }) + + it('zero-pads the code the way the specs are written', () => { + expect(getNutSupport(infoWith({'7': {supported: true}}))[0].code).toBe('NUT-07') + }) + + it('lists only what the mint actually advertised', () => { + // Absence is not "unsupported": no mint advertises the mandatory NUTs. + expect(getNutSupport(infoWith({'7': {supported: true}})).map(n => n.nut)).toEqual(['7']) + }) + + it('renders an unknown NUT number without a title rather than dropping it', () => { + const [nut] = getNutSupport(infoWith({'99': {supported: true}})) + expect(nut.code).toBe('NUT-99') + expect(nut.title).toBeUndefined() + expect(nut.supported).toBe(true) + }) +}) + +describe('payment methods', () => { + const LIGHTNING_AND_ONCHAIN = infoWith({ + '4': { + methods: [ + {method: 'bolt11', unit: 'sat', min_amount: 0, max_amount: 1000000}, + {method: 'onchain', unit: 'sat', min_amount: 10000, options: {confirmations: 3}}, + ], + disabled: false, + }, + '5': { + methods: [{method: 'bolt11', unit: 'sat', min_amount: 1, max_amount: 500000}], + disabled: false, + }, + }) + + it('groups a rail\'s deposit and withdrawal limits together', () => { + const bolt11 = method(LIGHTNING_AND_ONCHAIN, 'bolt11') + expect(bolt11?.mint).toEqual([{unit: 'sat', mintUnit: 'sat', min: 0, max: 1000000}]) + expect(bolt11?.melt).toEqual([{unit: 'sat', mintUnit: 'sat', min: 1, max: 500000}]) + }) + + it('keeps a deposit-only rail, with an empty withdrawal side', () => { + // Dropping it would hide a usable deposit route; the screen renders the + // missing direction as "Not supported". + const onchain = method(LIGHTNING_AND_ONCHAIN, 'onchain') + expect(onchain?.mint).toHaveLength(1) + expect(onchain?.melt).toEqual([]) + }) + + it('carries the onchain confirmation depth', () => { + expect(method(LIGHTNING_AND_ONCHAIN, 'onchain')?.confirmations).toBe(3) + }) + + it('orders Lightning before on-chain regardless of the mint\'s order', () => { + expect(getPaymentMethods(LIGHTNING_AND_ONCHAIN).map(m => m.method)).toEqual(['bolt11', 'onchain']) + }) + + it('keeps every unit a rail settles in', () => { + const multi = infoWith({ + '4': {methods: [ + {method: 'bolt11', unit: 'sat', max_amount: 1000}, + {method: 'bolt11', unit: 'usd', max_amount: 50}, + ]}, + }) + expect(method(multi, 'bolt11')?.mint.map(l => l.unit)).toEqual(['sat', 'usd']) + }) + + it('marks a unit the wallet has no currency data for', () => { + const exotic = infoWith({'4': {methods: [{method: 'bolt11', unit: 'gbp', max_amount: 10}]}}) + expect(method(exotic, 'bolt11')?.mint[0]).toEqual({unit: 'gbp', mintUnit: undefined, min: undefined, max: 10}) + }) + + it('drops a whole direction when the mint disables that NUT', () => { + const disabled = infoWith({ + '4': {methods: [{method: 'bolt11', unit: 'sat'}], disabled: true}, + '5': {methods: [{method: 'bolt11', unit: 'sat'}], disabled: false}, + }) + expect(method(disabled, 'bolt11')?.mint).toEqual([]) + expect(method(disabled, 'bolt11')?.melt).toHaveLength(1) + }) + + it('distinguishes an absent limit from a zero one', () => { + // `min_amount: 0` is a real bound and must not read as "no minimum". + const info = infoWith({'4': {methods: [{method: 'bolt11', unit: 'sat', min_amount: 0}]}}) + const [limit] = method(info, 'bolt11')!.mint + expect(limit.min).toBe(0) + expect(limit.max).toBeUndefined() + }) + + it('ignores method entries too malformed to render', () => { + const junk = infoWith({ + '4': {methods: [null, {unit: 'sat'}, {method: 'bolt11'}, {method: 'bolt11', unit: 'sat'}]}, + }) + expect(getPaymentMethods(junk)).toHaveLength(1) + expect(method(junk, 'bolt11')?.mint).toEqual([ + {unit: 'sat', mintUnit: 'sat', min: undefined, max: undefined}, + ]) + }) + + it('yields nothing when the mint published no methods', () => { + expect(getPaymentMethods(undefined)).toEqual([]) + expect(getPaymentMethods(infoWith({}))).toEqual([]) + }) +}) + +describe('contacts', () => { + it('reads the current {method, info} objects', () => { + const info = {contact: [{method: 'email', info: 'a@b.c'}]} as unknown as MintInfo + expect(getContacts(info)).toEqual([{method: 'email', info: 'a@b.c'}]) + }) + + it('still reads the legacy tuple form older mints publish', () => { + const info = {contact: [['email', 'a@b.c']]} as unknown as MintInfo + expect(getContacts(info)).toEqual([{method: 'email', info: 'a@b.c'}]) + }) + + it('drops half-filled and malformed entries', () => { + const info = { + contact: [null, {method: 'email'}, {info: 'a@b.c'}, ['nostr', ' '], {method: 'x', info: 'y'}], + } as unknown as MintInfo + expect(getContacts(info)).toEqual([{method: 'x', info: 'y'}]) + }) + + it('is empty when contact is missing or not an array', () => { + expect(getContacts(undefined)).toEqual([]) + expect(getContacts({contact: 'nope'} as unknown as MintInfo)).toEqual([]) + }) +}) + +describe('asNonEmptyString', () => { + it('rejects whitespace-only and non-string values', () => { + expect(asNonEmptyString(' x ')).toBe('x') + expect(asNonEmptyString(' ')).toBeUndefined() + expect(asNonEmptyString(undefined)).toBeUndefined() + expect(asNonEmptyString(42)).toBeUndefined() + }) +}) + +describe('the live Minibits mint response', () => { + // Verbatim `nuts` from https://mint.minibits.cash/Bitcoin — the shape this + // module was written against, including nutshell's NUT-17 quirk. + const MINIBITS = infoWith({ + '4': {methods: [{method: 'bolt11', unit: 'sat', min_amount: 0, max_amount: 1000000, options: {description: true}}], disabled: false}, + '5': {methods: [{method: 'bolt11', unit: 'sat', min_amount: 0, max_amount: 1000000}], disabled: false}, + '7': {supported: true}, + '8': {supported: true}, + '9': {supported: true}, + '10': {supported: true}, + '11': {supported: true}, + '12': {supported: true}, + '14': {supported: true}, + '20': {supported: true}, + '15': {methods: [{method: 'bolt11', unit: 'sat'}]}, + '17': {supported: [{method: 'bolt11', unit: 'sat', commands: ['bolt11_melt_quote']}]}, + '19': {cached_endpoints: [{method: 'POST', path: '/v1/swap'}], ttl: 604800}, + '29': {supported: true, max_batch_size: 1000, methods: ['bolt11']}, + }) + + it('resolves the capabilities a user would expect to see', () => { + const supported = getMintCapabilities(MINIBITS).filter(c => c.supported).map(c => c.key) + expect(supported.sort()).toEqual([ + 'batchedMint', + 'feeReturn', + 'liveUpdates', + 'lockedEcash', + 'multiPath', + 'offlineVerification', + 'quoteBinding', + 'safeRetries', + 'seedRestore', + 'stateCheck', + 'timelocks', + ]) + expect(supported).not.toContain('authRequired') + }) + + it('resolves one Lightning rail with symmetric limits', () => { + expect(getPaymentMethods(MINIBITS)).toEqual([ + expect.objectContaining({ + method: 'bolt11', + mint: [{unit: 'sat', mintUnit: 'sat', min: 0, max: 1000000}], + melt: [{unit: 'sat', mintUnit: 'sat', min: 0, max: 1000000}], + }), + ]) + }) + + it('marks every advertised NUT as supported', () => { + expect(getNutSupport(MINIBITS).filter(n => !n.supported)).toEqual([]) + }) +}) diff --git a/src/i18n_messages/en.json b/src/i18n_messages/en.json index d188637f..ca7530e0 100644 --- a/src/i18n_messages/en.json +++ b/src/i18n_messages/en.json @@ -181,6 +181,74 @@ "invalidRelayUrl": "Invalid relay URL.", "lightningAddress": "Lightning address", "lightningInvoiceSharedWaiting": "Lightning invoice has been shared, waiting to be paid by receiver.", + "mintInfo_audit_disclaimer": "Audit results describe reliability over time. They are informational only and do not guarantee a mint's safety, solvency or trustworthiness.", + "mintInfo_audit_forSuccessfulSwaps": "For successful swaps", + "mintInfo_audit_heading": "Availability", + "mintInfo_audit_lastCheckedParam": "Last checked %{ago}", + "mintInfo_audit_now": "Now", + "mintInfo_audit_source": "Independently audited by audit.8333.space", + "mintInfo_audit_swapSuccess": "Success rate", + "mintInfo_audit_swapSuccessValue": "%{ok} of %{total} swaps", + "mintInfo_audit_swapTime": "Average time", + "mintInfo_audit_warn_inactive": { + "one": "No successful swap for %{count} day. The mint may be unreachable.", + "other": "No successful swap for %{count} days. The mint may be unreachable." + }, + "mintInfo_audit_warn_lastSwapFailed": "The auditor's last swap with this mint failed.", + "mintInfo_audit_warn_lastSwapFailedReliable": "The auditor's last swap failed, but %{ok} of %{total} swaps in the last %{days} days succeeded. The failure may have been on the other mint's side.", + "mintInfo_audit_warn_lastSwapFailedUnreliable": "The auditor's last swap failed, and only %{ok} of %{total} swaps in the last %{days} days succeeded. This mint may be unreliable.", + "mintInfo_audit_warn_lowSuccessRate": "In the last %{days} days this mint succeeded %{percent}% of the time, below the recommended %{threshold}%.", + "mintInfo_audit_warn_noSuccessfulSwaps": "No successful swaps recorded. The mint may be unreachable.", + "mintInfo_audit_warn_notEnoughData": { + "one": "Little recent data: only %{count} successful swap in the last %{days} days.", + "other": "Little recent data: only %{count} successful swaps in the last %{days} days." + }, + "mintInfo_audit_warn_slow": "This mint is slow: swaps took %{seconds} seconds on average.", + "mintInfo_audit_warn_unknownQuality": "The auditor has not been able to judge this mint yet.", + "mintInfo_audit_warningsHeading": "Auditor warnings", + "mintInfo_capabilitiesHeading": "Capabilities", + "mintInfo_capabilitiesUnknown": "This mint does not advertise any optional capabilities.", + "mintInfo_capability_authRequired": "Requires an account", + "mintInfo_capability_authRequiredDesc": "This mint only serves users who sign in. Minibits does not support authenticated mints yet.", + "mintInfo_capability_batchedMint": "Batched deposits", + "mintInfo_capability_batchedMintDesc": "Several deposits can be claimed in a single request, which is faster and cheaper.", + "mintInfo_capability_feeReturn": "Lightning fee refunds", + "mintInfo_capability_feeReturnDesc": "Routing fees reserved for a Lightning withdrawal but not spent are returned to you.", + "mintInfo_capability_liveUpdates": "Instant payment updates", + "mintInfo_capability_liveUpdatesDesc": "Payments are confirmed the moment they settle, instead of by repeated checking.", + "mintInfo_capability_lockedEcash": "Locked ecash", + "mintInfo_capability_lockedEcashDesc": "Ecash can be locked to a public key so that only its intended receiver can spend it.", + "mintInfo_capability_multiPath": "Multi-path payments", + "mintInfo_capability_multiPathDesc": "A large Lightning payment can be split across balances held at several mints.", + "mintInfo_capability_offlineVerification": "Offline verification", + "mintInfo_capability_offlineVerificationDesc": "Ecash carries a proof that lets your wallet check it is genuine without asking the mint.", + "mintInfo_capability_quoteBinding": "Deposits bound to your wallet", + "mintInfo_capability_quoteBindingDesc": "A deposit can only be claimed by the wallet that requested it.", + "mintInfo_capability_safeRetries": "Safe retries", + "mintInfo_capability_safeRetriesDesc": "An interrupted payment can be retried without the risk of paying twice.", + "mintInfo_capability_seedRestore": "Recoverable from seed phrase", + "mintInfo_capability_seedRestoreDesc": "Ecash held at this mint can be recovered with your seed phrase if you lose your device.", + "mintInfo_capability_stateCheck": "Spent ecash detection", + "mintInfo_capability_stateCheckDesc": "Your wallet can ask whether a piece of ecash has already been spent.", + "mintInfo_capability_timelocks": "Timelocked payments", + "mintInfo_capability_timelocksDesc": "Locked ecash can be given a deadline, after which it returns to the sender.", + "mintInfo_confirmationsParam": { + "one": "%{count} confirmation", + "other": "%{count} confirmations" + }, + "mintInfo_detail_pubkey": "Public key", + "mintInfo_detail_tos": "Terms of service", + "mintInfo_detail_urls": "Other URLs", + "mintInfo_detail_version": "Version", + "mintInfo_limitMax": "up to %{amount}", + "mintInfo_limitMin": "from %{amount}", + "mintInfo_limitNone": "No limit", + "mintInfo_method_bolt11": "Lightning", + "mintInfo_method_bolt12": "Lightning offer (BOLT12)", + "mintInfo_method_onchain": "On-chain Bitcoin", + "mintInfo_notSupported": "Not supported", + "mintInfo_paymentMethodsHeading": "Payment methods", + "mintInfo_technicalDetails": "Technical details", "payScreen_onPasteEmptyClipboard": "First copy a Lightning invoice, address, pay code or a Bitcoin address to pay to. Then paste.", "payScreen_payHeading": "Pay", "loadingPublicInfo": "Loading public info", @@ -191,18 +259,17 @@ "loglevelInfoDesc": "Log anonymous usage, without private data.", "manageMints": "Manage mints", "mintInfo_contactsHeading": "Contacts", - "mintInfo_depositMint": "Deposit (Mint)", + "mintInfo_depositMint": "Deposit", "mintInfo_descriptionHeading": "Description", "mintInfo_emptyValueParam": "No %{param} provided.", - "mintInfo_keyValueInfoCardHeading": "More Info", + "mintInfo_keyValueInfoCardHeading": "Details", "mintInfo_loadFailed": "Mint information could not be loaded. The mint is currently unreachable.", "mintInfo_loadingNamePlaceholder": "Loading name...", "mintInfo_loadingUnitsPlaceholder": "Loading units...", - "mintInfo_mintMeltHeading": "Limits", "mintInfo_mintUrlHeading": "Mint URL", "mintInfo_motd": "Important notice", "mintInfo_nutsHeading": "Supported NUTs", - "mintInfo_withdrawMelt": "Withdraw (Melt)", + "mintInfo_withdrawMelt": "Withdraw", "mintInfoHeading": "Mint info", "mintSettingsUpdated": "Updated mint settings", "mintsScreen_addMint": "Add mint", @@ -341,6 +408,8 @@ "placeholderRelay": "wss://...", "privacyScreen_logger": "Enable logging", "privacyScreen_loggerDescription": "Log anonymous error information in order to identify and fix wallet bugs during beta testing. Switch off for best privacy.", + "privacyScreen_mintAudit": "Mint availability checks", + "privacyScreen_mintAuditDescription": "Show how reliably a mint has been working, using data from the public auditor at audit.8333.space. Opening a mint's detail page shares that mint's URL with the auditor.", "privacyScreen_title": "Privacy", "privacyScreen_torDaemon": "Use Tor network", "privacyScreen_torDaemonDescription": "Allow connections with mints through Tor .onion addresses.", diff --git a/src/i18n_messages/es.json b/src/i18n_messages/es.json index b207f45e..7d346ea3 100644 --- a/src/i18n_messages/es.json +++ b/src/i18n_messages/es.json @@ -137,6 +137,7 @@ "deleteContact": "Eliminar contacto", "deleteContactDesc": "Elimina este contacto de tu billetera.", "derivingSeedStatus": "Derivar la semilla, esto lleva un tiempo...", + "developerScreen_clearJwtTokensDescription": "Elimina los datos de autenticación con el servidor de direcciones Lightning.", "developerScreen_devOptions": "Opc. de dev", "developerScreen_forceDeletePending": "Forzar eliminación pendiente", "developerScreen_forceDeletePendingDescription": "Elimina el ecash del estado pendiente y elimina todas las transacciones pendientes.", @@ -180,6 +181,87 @@ "invalidRelayUrl": "URL de retransmisión no válida.", "lightningAddress": "Dirección de Lightning", "lightningInvoiceSharedWaiting": "Se ha compartido una factura Lightning y está a la espera de ser pagada por el receptor.", + "mintInfo_audit_disclaimer": "Los resultados de la auditoría describen la fiabilidad a lo largo del tiempo. Son solo informativos y no garantizan la seguridad, la solvencia ni la confiabilidad de un mint.", + "mintInfo_audit_forSuccessfulSwaps": "De los swaps con éxito", + "mintInfo_audit_heading": "Disponibilidad", + "mintInfo_audit_lastCheckedParam": "Última comprobación %{ago}", + "mintInfo_audit_now": "Ahora", + "mintInfo_audit_source": "Auditado de forma independiente por audit.8333.space", + "mintInfo_audit_swapSuccess": "Tasa de éxito", + "mintInfo_audit_swapSuccessValue": "%{ok} de %{total} swaps", + "mintInfo_audit_swapTime": "Tiempo medio", + "mintInfo_audit_warn_inactive": { + "one": "Sin ningún swap con éxito desde hace %{count} día. Puede que el mint no esté accesible.", + "other": "Sin ningún swap con éxito desde hace %{count} días. Puede que el mint no esté accesible." + }, + "mintInfo_audit_warn_lastSwapFailed": "El último swap del auditor con este mint falló.", + "mintInfo_audit_warn_lastSwapFailedReliable": "El último swap del auditor falló, pero %{ok} de %{total} swaps de los últimos %{days} días tuvieron éxito. El fallo pudo estar en el otro mint.", + "mintInfo_audit_warn_lastSwapFailedUnreliable": "El último swap del auditor falló y solo %{ok} de %{total} swaps de los últimos %{days} días tuvieron éxito. Este mint puede no ser fiable.", + "mintInfo_audit_warn_lowSuccessRate": "En los últimos %{days} días este mint tuvo éxito el %{percent}% de las veces, por debajo del %{threshold}% recomendado.", + "mintInfo_audit_warn_noSuccessfulSwaps": "No se registró ningún swap con éxito. Puede que el mint no esté accesible.", + "mintInfo_audit_warn_notEnoughData": { + "one": "Pocos datos recientes: solo %{count} swap con éxito en los últimos %{days} días.", + "other": "Pocos datos recientes: solo %{count} swaps con éxito en los últimos %{days} días." + }, + "mintInfo_audit_warn_slow": "Este mint es lento: los swaps tardaron %{seconds} segundos de media.", + "mintInfo_audit_warn_unknownQuality": "El auditor todavía no ha podido evaluar este mint.", + "mintInfo_audit_warningsHeading": "Avisos del auditor", + "mintInfo_capabilitiesHeading": "Capacidades", + "mintInfo_capabilitiesUnknown": "Este mint no anuncia ninguna capacidad opcional.", + "mintInfo_capability_authRequired": "Requiere una cuenta", + "mintInfo_capability_authRequiredDesc": "Este mint solo atiende a usuarios que inician sesión. Minibits aún no admite mints con autenticación.", + "mintInfo_capability_batchedMint": "Depósitos por lotes", + "mintInfo_capability_batchedMintDesc": "Varios depósitos se pueden reclamar en una sola petición, lo que es más rápido y barato.", + "mintInfo_capability_feeReturn": "Devolución de tarifas Lightning", + "mintInfo_capability_feeReturnDesc": "Las tarifas reservadas para un retiro Lightning que no se usan se te devuelven.", + "mintInfo_capability_liveUpdates": "Avisos de pago instantáneos", + "mintInfo_capability_liveUpdatesDesc": "Los pagos se confirman en cuanto se liquidan, en vez de comprobarlos repetidamente.", + "mintInfo_capability_lockedEcash": "Ecash bloqueado", + "mintInfo_capability_lockedEcashDesc": "El ecash se puede bloquear con una clave pública para que solo lo gaste el destinatario previsto.", + "mintInfo_capability_multiPath": "Pagos multiruta", + "mintInfo_capability_multiPathDesc": "Un pago Lightning grande se puede repartir entre saldos de varios mints.", + "mintInfo_capability_offlineVerification": "Verificación sin conexión", + "mintInfo_capability_offlineVerificationDesc": "El ecash lleva una prueba con la que tu billetera comprueba que es auténtico sin preguntar al mint.", + "mintInfo_capability_quoteBinding": "Depósitos ligados a tu billetera", + "mintInfo_capability_quoteBindingDesc": "Un depósito solo lo puede reclamar la billetera que lo solicitó.", + "mintInfo_capability_safeRetries": "Reintentos seguros", + "mintInfo_capability_safeRetriesDesc": "Un pago interrumpido se puede reintentar sin riesgo de pagar dos veces.", + "mintInfo_capability_seedRestore": "Recuperable con la frase semilla", + "mintInfo_capability_seedRestoreDesc": "El ecash guardado en este mint se puede recuperar con tu frase semilla si pierdes el dispositivo.", + "mintInfo_capability_stateCheck": "Detección de ecash gastado", + "mintInfo_capability_stateCheckDesc": "Tu billetera puede consultar si un ecash ya fue gastado.", + "mintInfo_capability_timelocks": "Pagos con límite de tiempo", + "mintInfo_capability_timelocksDesc": "El ecash bloqueado puede tener un plazo tras el cual vuelve al remitente.", + "mintInfo_confirmationsParam": { + "one": "%{count} confirmación", + "other": "%{count} confirmaciones" + }, + "mintInfo_detail_pubkey": "Clave pública", + "mintInfo_detail_tos": "Términos del servicio", + "mintInfo_detail_urls": "Otras URL", + "mintInfo_detail_version": "Versión", + "mintInfo_limitMax": "hasta %{amount}", + "mintInfo_limitMin": "desde %{amount}", + "mintInfo_limitNone": "Sin límite", + "mintInfo_method_bolt11": "Lightning", + "mintInfo_method_bolt12": "Oferta Lightning (BOLT12)", + "mintInfo_method_onchain": "Bitcoin on-chain", + "mintInfo_notSupported": "No compatible", + "mintInfo_paymentMethodsHeading": "Métodos de pago", + "mintInfo_technicalDetails": "Detalles técnicos", + "orphanedSeed_copied": "Frase semilla copiada al portapapeles.", + "orphanedSeed_copyFirst": "Copia primero tu frase semilla. La recuperación te pide escribirla, e introducir otra distinta sobrescribiría las claves mostradas arriba, que son la única vía de vuelta a los fondos de esa billetera.", + "orphanedSeed_explainDescription": "Este dispositivo todavía guarda las claves de una billetera Minibits anterior, pero ninguno de sus datos. Desinstalar la app borra su ecash, su historial y sus mints, mientras que iOS conserva las claves. La frase semilla de abajo es la única forma de llegar a los fondos que esa billetera aún tenga.", + "orphanedSeed_explainTitle": "Los datos de tu billetera ya no están, tus claves sí", + "orphanedSeed_recoverCopyFirst": "Copia primero la frase semilla de arriba: la recuperación te pedirá que la introduzcas.", + "orphanedSeed_recoverDescription": "Restaura el ecash, los mints y tu dirección minibits.cash a partir de esta frase semilla. Elige esto si la billetera anterior todavía puede tener fondos.", + "orphanedSeed_recoverTitle": "Recuperar esta billetera", + "orphanedSeed_resetConfirmButton": "Descartar y empezar de cero", + "orphanedSeed_resetConfirmDescription": "Sin la frase semilla de arriba, cualquier ecash que tenga la billetera anterior queda fuera de tu alcance. También recibirás una dirección minibits.cash nueva, y tus contactos ya no te encontrarán en la antigua.", + "orphanedSeed_resetConfirmTitle": "¿Descartar estas claves?", + "orphanedSeed_resetDescription": "Descarta las claves de arriba y crea una billetera nueva. Guarda antes la frase semilla por si necesitas esos fondos más adelante.", + "orphanedSeed_resetTitle": "Empezar una billetera nueva", + "orphanedSeed_title": "Se encontraron claves de billetera", "payScreen_onPasteEmptyClipboard": "Primero copia una factura Lightning, dirección, código de pago o una dirección Bitcoin a la que pagar. Luego pega.", "payScreen_payHeading": "Pagar", "loadingPublicInfo": "Cargando información pública", @@ -197,7 +279,6 @@ "mintInfo_loadFailed": "No se pudo cargar la información del mint. El mint no está disponible en este momento.", "mintInfo_loadingNamePlaceholder": "Cargando nombre...", "mintInfo_loadingUnitsPlaceholder": "Cargando unidades...", - "mintInfo_mintMeltHeading": "Límites", "mintInfo_mintUrlHeading": "URL de mint", "mintInfo_motd": "Aviso importante", "mintInfo_nutsHeading": "NUT compatibles", @@ -340,6 +421,8 @@ "placeholderRelay": "wss://...", "privacyScreen_logger": "Habilitar el registro", "privacyScreen_loggerDescription": "Registra información de errores anónima para identificar y corregir errores de la billetera durante las pruebas beta. Desactívelo para mayor privacidad.", + "privacyScreen_mintAudit": "Comprobaciones de disponibilidad", + "privacyScreen_mintAuditDescription": "Muestra con qué fiabilidad ha funcionado un mint, con datos del auditor público audit.8333.space. Al abrir la página de detalle de un mint se comparte su URL con el auditor.", "privacyScreen_title": "Privacidad", "privacyScreen_torDaemon": "Utilice la red Tor", "privacyScreen_torDaemonDescription": "Permitir conexiones con mints a través de direcciones .onion de Tor.", diff --git a/src/i18n_messages/pt.json b/src/i18n_messages/pt.json index 4e6d6846..136d138b 100644 --- a/src/i18n_messages/pt.json +++ b/src/i18n_messages/pt.json @@ -181,6 +181,87 @@ "invalidRelayUrl": "URL de relay inválido.", "lightningAddress": "Endereço Lightning", "lightningInvoiceSharedWaiting": "A invoice Lightning foi compartilhada, aguardando pagamento.", + "mintInfo_audit_disclaimer": "Os resultados da auditoria descrevem a confiabilidade ao longo do tempo. São apenas informativos e não garantem a segurança, a solvência nem a idoneidade de um mint.", + "mintInfo_audit_forSuccessfulSwaps": "Dos swaps bem-sucedidos", + "mintInfo_audit_heading": "Disponibilidade", + "mintInfo_audit_lastCheckedParam": "Última verificação %{ago}", + "mintInfo_audit_now": "Agora", + "mintInfo_audit_source": "Auditado de forma independente por audit.8333.space", + "mintInfo_audit_swapSuccess": "Taxa de sucesso", + "mintInfo_audit_swapSuccessValue": "%{ok} de %{total} swaps", + "mintInfo_audit_swapTime": "Tempo médio", + "mintInfo_audit_warn_inactive": { + "one": "Nenhum swap bem-sucedido há %{count} dia. O mint pode estar inacessível.", + "other": "Nenhum swap bem-sucedido há %{count} dias. O mint pode estar inacessível." + }, + "mintInfo_audit_warn_lastSwapFailed": "O último swap do auditor com este mint falhou.", + "mintInfo_audit_warn_lastSwapFailedReliable": "O último swap do auditor falhou, mas %{ok} de %{total} swaps nos últimos %{days} dias tiveram sucesso. A falha pode ter sido do outro mint.", + "mintInfo_audit_warn_lastSwapFailedUnreliable": "O último swap do auditor falhou e apenas %{ok} de %{total} swaps nos últimos %{days} dias tiveram sucesso. Este mint pode não ser confiável.", + "mintInfo_audit_warn_lowSuccessRate": "Nos últimos %{days} dias este mint teve sucesso em %{percent}% das vezes, abaixo dos %{threshold}% recomendados.", + "mintInfo_audit_warn_noSuccessfulSwaps": "Nenhum swap bem-sucedido registrado. O mint pode estar inacessível.", + "mintInfo_audit_warn_notEnoughData": { + "one": "Poucos dados recentes: apenas %{count} swap bem-sucedido nos últimos %{days} dias.", + "other": "Poucos dados recentes: apenas %{count} swaps bem-sucedidos nos últimos %{days} dias." + }, + "mintInfo_audit_warn_slow": "Este mint é lento: os swaps levaram %{seconds} segundos em média.", + "mintInfo_audit_warn_unknownQuality": "O auditor ainda não conseguiu avaliar este mint.", + "mintInfo_audit_warningsHeading": "Avisos do auditor", + "mintInfo_capabilitiesHeading": "Recursos", + "mintInfo_capabilitiesUnknown": "Este mint não anuncia nenhum recurso opcional.", + "mintInfo_capability_authRequired": "Requer uma conta", + "mintInfo_capability_authRequiredDesc": "Este mint só atende usuários autenticados. O Minibits ainda não suporta mints com autenticação.", + "mintInfo_capability_batchedMint": "Depósitos em lote", + "mintInfo_capability_batchedMintDesc": "Vários depósitos podem ser resgatados numa só requisição, o que é mais rápido e barato.", + "mintInfo_capability_feeReturn": "Devolução de taxas Lightning", + "mintInfo_capability_feeReturnDesc": "As taxas reservadas para um saque Lightning que não forem usadas são devolvidas a você.", + "mintInfo_capability_liveUpdates": "Atualizações instantâneas de pagamento", + "mintInfo_capability_liveUpdatesDesc": "Os pagamentos são confirmados assim que liquidam, em vez de verificação repetida.", + "mintInfo_capability_lockedEcash": "Ecash bloqueado", + "mintInfo_capability_lockedEcashDesc": "O ecash pode ser bloqueado para uma chave pública, de modo que só o destinatário previsto o gaste.", + "mintInfo_capability_multiPath": "Pagamentos multirrota", + "mintInfo_capability_multiPathDesc": "Um pagamento Lightning grande pode ser dividido entre saldos em vários mints.", + "mintInfo_capability_offlineVerification": "Verificação offline", + "mintInfo_capability_offlineVerificationDesc": "O ecash carrega uma prova que permite à carteira verificar se é genuíno sem consultar o mint.", + "mintInfo_capability_quoteBinding": "Depósitos vinculados à carteira", + "mintInfo_capability_quoteBindingDesc": "Um depósito só pode ser resgatado pela carteira que o solicitou.", + "mintInfo_capability_safeRetries": "Novas tentativas seguras", + "mintInfo_capability_safeRetriesDesc": "Um pagamento interrompido pode ser repetido sem risco de pagar duas vezes.", + "mintInfo_capability_seedRestore": "Recuperável pela frase seed", + "mintInfo_capability_seedRestoreDesc": "O ecash guardado neste mint pode ser recuperado com a sua frase seed se perder o dispositivo.", + "mintInfo_capability_stateCheck": "Detecção de ecash gasto", + "mintInfo_capability_stateCheckDesc": "A sua carteira pode consultar se um ecash já foi gasto.", + "mintInfo_capability_timelocks": "Pagamentos com prazo", + "mintInfo_capability_timelocksDesc": "O ecash bloqueado pode ter um prazo, após o qual volta para o remetente.", + "mintInfo_confirmationsParam": { + "one": "%{count} confirmação", + "other": "%{count} confirmações" + }, + "mintInfo_detail_pubkey": "Chave pública", + "mintInfo_detail_tos": "Termos de serviço", + "mintInfo_detail_urls": "Outras URLs", + "mintInfo_detail_version": "Versão", + "mintInfo_limitMax": "até %{amount}", + "mintInfo_limitMin": "a partir de %{amount}", + "mintInfo_limitNone": "Sem limite", + "mintInfo_method_bolt11": "Lightning", + "mintInfo_method_bolt12": "Oferta Lightning (BOLT12)", + "mintInfo_method_onchain": "Bitcoin on-chain", + "mintInfo_notSupported": "Não suportado", + "mintInfo_paymentMethodsHeading": "Métodos de pagamento", + "mintInfo_technicalDetails": "Detalhes técnicos", + "orphanedSeed_copied": "Frase seed copiada para a área de transferência.", + "orphanedSeed_copyFirst": "Copie primeiro a sua frase seed. A recuperação pede que você a digite, e informar outra sobrescreveria as chaves mostradas acima — o único caminho de volta aos fundos daquela carteira.", + "orphanedSeed_explainDescription": "Este dispositivo ainda guarda as chaves de uma carteira Minibits anterior, mas nenhum dos seus dados. Remover o app apaga o ecash, o histórico e os mints, enquanto o iOS mantém as chaves. A frase seed abaixo é a única forma de alcançar os fundos que essa carteira ainda tenha.", + "orphanedSeed_explainTitle": "Os dados da sua carteira sumiram, as chaves não", + "orphanedSeed_recoverCopyFirst": "Copie primeiro a frase seed acima — a recuperação vai pedir que você a digite.", + "orphanedSeed_recoverDescription": "Restaure o ecash, os mints e o seu endereço minibits.cash a partir desta frase seed. Escolha isto se a carteira anterior ainda puder ter fundos.", + "orphanedSeed_recoverTitle": "Recuperar esta carteira", + "orphanedSeed_resetConfirmButton": "Descartar e começar do zero", + "orphanedSeed_resetConfirmDescription": "Sem a frase seed acima, qualquer ecash que a carteira anterior tenha fica inacessível. Você também receberá um novo endereço minibits.cash, e os contatos não o alcançarão mais no antigo.", + "orphanedSeed_resetConfirmTitle": "Descartar estas chaves?", + "orphanedSeed_resetDescription": "Descarte as chaves acima e crie uma carteira nova. Salve a frase seed antes, caso precise desses fundos mais tarde.", + "orphanedSeed_resetTitle": "Começar uma carteira nova", + "orphanedSeed_title": "Chaves de carteira encontradas", "payScreen_onPasteEmptyClipboard": "Primeiro copie uma fatura Lightning, endereço, código de pagamento ou um endereço Bitcoin para pagar. Depois cole.", "payScreen_payHeading": "Pagar", "loadingPublicInfo": "Carregando informações públicas", @@ -198,7 +279,6 @@ "mintInfo_loadFailed": "Não foi possível carregar as informações do mint. O mint está indisponível no momento.", "mintInfo_loadingNamePlaceholder": "Carregando nome...", "mintInfo_loadingUnitsPlaceholder": "Carregando unidades...", - "mintInfo_mintMeltHeading": "Limites", "mintInfo_mintUrlHeading": "URL do Mint", "mintInfo_motd": "Aviso importante", "mintInfo_nutsHeading": "NUTs suportados", @@ -341,6 +421,8 @@ "placeholderRelay": "wss://...", "privacyScreen_logger": "Habilitar registro", "privacyScreen_loggerDescription": "Registre informações anônimas de erros para identificar/corrigir bugs durante testes beta. Desative para melhor privacidade.", + "privacyScreen_mintAudit": "Verificações de disponibilidade", + "privacyScreen_mintAuditDescription": "Mostra com que confiabilidade um mint tem funcionado, usando dados do auditor público audit.8333.space. Abrir a página de detalhes de um mint compartilha a URL desse mint com o auditor.", "privacyScreen_title": "Privacidade", "privacyScreen_torDaemon": "Usar rede Tor", "privacyScreen_torDaemonDescription": "Permitir conexões com mints via endereços onion Tor.", diff --git a/src/i18n_messages/sk.json b/src/i18n_messages/sk.json index 5bf039b0..4fdc625d 100644 --- a/src/i18n_messages/sk.json +++ b/src/i18n_messages/sk.json @@ -181,6 +181,87 @@ "invalidRelayUrl": "Neplatná relay URL adresa.", "lightningAddress": "Lightning adresa", "lightningInvoiceSharedWaiting": "Lightning invoice bola vyzdieľaná, čaká na zaplatenie príjemcom.", + "mintInfo_audit_disclaimer": "Výsledky auditu popisujú spoľahlivosť v čase. Sú len informatívne a nezaručujú bezpečnosť, solventnosť ani dôveryhodnosť mintu.", + "mintInfo_audit_forSuccessfulSwaps": "Za úspešné swapy", + "mintInfo_audit_heading": "Dostupnosť", + "mintInfo_audit_lastCheckedParam": "Posledná kontrola %{ago}", + "mintInfo_audit_now": "Teraz", + "mintInfo_audit_source": "Nezávisle auditované službou audit.8333.space", + "mintInfo_audit_swapSuccess": "Úspešnosť", + "mintInfo_audit_swapSuccessValue": "%{ok} zo %{total} swapov", + "mintInfo_audit_swapTime": "Priemerný čas", + "mintInfo_audit_warn_inactive": { + "one": "Posledný úspešný swap bol pred %{count} dňom. Mint môže byť nedostupný.", + "other": "Posledný úspešný swap bol pred %{count} dňami. Mint môže byť nedostupný." + }, + "mintInfo_audit_warn_lastSwapFailed": "Posledný swap audítora s týmto mintom zlyhal.", + "mintInfo_audit_warn_lastSwapFailedReliable": "Posledný swap audítora zlyhal, ale za posledných %{days} dní uspelo %{ok} z %{total} swapov. Chyba mohla byť na strane druhého mintu.", + "mintInfo_audit_warn_lastSwapFailedUnreliable": "Posledný swap audítora zlyhal a za posledných %{days} dní uspelo len %{ok} z %{total} swapov. Tento mint môže byť nespoľahlivý.", + "mintInfo_audit_warn_lowSuccessRate": "Za posledných %{days} dní bol tento mint úspešný na %{percent} %, čo je pod odporúčanými %{threshold} %.", + "mintInfo_audit_warn_noSuccessfulSwaps": "Nezaznamenal sa žiadny úspešný swap. Mint môže byť nedostupný.", + "mintInfo_audit_warn_notEnoughData": { + "one": "Málo aktuálnych údajov: za posledných %{days} dní len %{count} úspešný swap.", + "other": "Málo aktuálnych údajov: za posledných %{days} dní len %{count} úspešné swapy." + }, + "mintInfo_audit_warn_slow": "Tento mint je pomalý: swapy trvali v priemere %{seconds} s.", + "mintInfo_audit_warn_unknownQuality": "Audítor zatiaľ nedokázal tento mint vyhodnotiť.", + "mintInfo_audit_warningsHeading": "Upozornenia audítora", + "mintInfo_capabilitiesHeading": "Funkcie", + "mintInfo_capabilitiesUnknown": "Tento mint neuvádza žiadne voliteľné funkcie.", + "mintInfo_capability_authRequired": "Vyžaduje účet", + "mintInfo_capability_authRequiredDesc": "Tento mint obsluhuje len prihlásených používateľov. Minibits zatiaľ takéto minty nepodporuje.", + "mintInfo_capability_batchedMint": "Hromadné vklady", + "mintInfo_capability_batchedMintDesc": "Viac vkladov sa dá vyzdvihnúť naraz, čo je rýchlejšie a lacnejšie.", + "mintInfo_capability_feeReturn": "Vrátenie Lightning poplatkov", + "mintInfo_capability_feeReturnDesc": "Poplatky rezervované na Lightning výber, ktoré sa neminuli, sa ti vrátia.", + "mintInfo_capability_liveUpdates": "Okamžité info o platbách", + "mintInfo_capability_liveUpdatesDesc": "Platby sa potvrdia hneď po vyrovnaní, namiesto opakovaného zisťovania.", + "mintInfo_capability_lockedEcash": "Zamknutý ecash", + "mintInfo_capability_lockedEcashDesc": "Ecash sa dá zamknúť na verejný kľúč, takže ho minie len určený príjemca.", + "mintInfo_capability_multiPath": "Viaccestné platby", + "mintInfo_capability_multiPathDesc": "Veľkú Lightning platbu možno rozdeliť medzi zostatky u viacerých mintov.", + "mintInfo_capability_offlineVerification": "Overenie offline", + "mintInfo_capability_offlineVerificationDesc": "Ecash nesie dôkaz, vďaka ktorému peňaženka overí jeho pravosť bez toho, aby sa pýtala mintu.", + "mintInfo_capability_quoteBinding": "Vklady viazané na peňaženku", + "mintInfo_capability_quoteBindingDesc": "Vklad si môže vyzdvihnúť len tá peňaženka, ktorá oň požiadala.", + "mintInfo_capability_safeRetries": "Bezpečné opakovanie", + "mintInfo_capability_safeRetriesDesc": "Prerušenú platbu možno zopakovať bez rizika, že zaplatíš dvakrát.", + "mintInfo_capability_seedRestore": "Obnova z mnemonickej vety", + "mintInfo_capability_seedRestoreDesc": "Ecash z tohto mintu obnovíš pomocou svojej mnemonickej vety, ak stratíš mobil.", + "mintInfo_capability_stateCheck": "Detekcia minutého ecashu", + "mintInfo_capability_stateCheckDesc": "Peňaženka sa vie spýtať, či už bol daný ecash minutý.", + "mintInfo_capability_timelocks": "Časovo obmedzené platby", + "mintInfo_capability_timelocksDesc": "Zamknutý ecash môže mať lehotu, po ktorej sa vráti odosielateľovi.", + "mintInfo_confirmationsParam": { + "one": "%{count} potvrdenie", + "other": "%{count} potvrdenia" + }, + "mintInfo_detail_pubkey": "Verejný kľúč", + "mintInfo_detail_tos": "Podmienky používania", + "mintInfo_detail_urls": "Ďalšie URL adresy", + "mintInfo_detail_version": "Verzia", + "mintInfo_limitMax": "do %{amount}", + "mintInfo_limitMin": "od %{amount}", + "mintInfo_limitNone": "Bez limitu", + "mintInfo_method_bolt11": "Lightning", + "mintInfo_method_bolt12": "Lightning ponuka (BOLT12)", + "mintInfo_method_onchain": "Bitcoin on-chain", + "mintInfo_notSupported": "Nepodporované", + "mintInfo_paymentMethodsHeading": "Spôsoby platby", + "mintInfo_technicalDetails": "Technické detaily", + "orphanedSeed_copied": "Mnemonická veta bola skopírovaná do schránky.", + "orphanedSeed_copyFirst": "Najprv si skopíruj mnemonickú vetu. Obnova ťa vyzve, aby si ju zadal, a zadanie inej by prepísalo kľúče zobrazené vyššie — jedinú cestu k prostriedkom tej peňaženky.", + "orphanedSeed_explainDescription": "Na tomto zariadení sú stále kľúče od predchádzajúcej Minibits peňaženky, ale žiadne jej dáta. Odinštalovanie aplikácie zmaže jej ecash, históriu aj minty, no iOS si kľúče ponechá. Mnemonická veta nižšie je jediná cesta k prostriedkom, ktoré tá peňaženka ešte drží.", + "orphanedSeed_explainTitle": "Dáta peňaženky sú preč, kľúče nie", + "orphanedSeed_recoverCopyFirst": "Najprv si skopíruj mnemonickú vetu vyššie — obnova ťa vyzve na jej zadanie.", + "orphanedSeed_recoverDescription": "Obnov ecash, minty a svoju minibits.cash adresu z tejto mnemonickej vety. Zvoľ, ak predchádzajúca peňaženka môže ešte držať prostriedky.", + "orphanedSeed_recoverTitle": "Obnov túto peňaženku", + "orphanedSeed_resetConfirmButton": "Zahodiť a začať odznova", + "orphanedSeed_resetConfirmDescription": "Bez mnemonickej vety vyššie sa k ecashu, ktorý predchádzajúca peňaženka drží, už nedostaneš. Dostaneš tiež novú minibits.cash adresu a kontakty ťa na starej už nezastihnú.", + "orphanedSeed_resetConfirmTitle": "Zahodiť tieto kľúče?", + "orphanedSeed_resetDescription": "Zahoď kľúče vyššie a vytvor novú peňaženku. Ak by si tie prostriedky ešte potreboval, najprv si ulož mnemonickú vetu.", + "orphanedSeed_resetTitle": "Založ novú peňaženku", + "orphanedSeed_title": "Našli sa kľúče peňaženky", "payScreen_onPasteEmptyClipboard": "Najprv skopírujte Lightning faktúru, adresu, platobný kód alebo Bitcoin adresu, ktorej chcete zaplatiť. Potom vložte.", "payScreen_payHeading": "Zaplatiť", "loadingPublicInfo": "Načítavam verejné informácie", @@ -198,7 +279,6 @@ "mintInfo_loadFailed": "Informácie o minte sa nepodarilo načítať. Mint je momentálne nedostupný.", "mintInfo_loadingNamePlaceholder": "Načítavam...", "mintInfo_loadingUnitsPlaceholder": "Načítavam...", - "mintInfo_mintMeltHeading": "Limity", "mintInfo_mintUrlHeading": "URL mintu", "mintInfo_motd": "Dôležitá správa", "mintInfo_nutsHeading": "Podporované NUTs", @@ -341,6 +421,8 @@ "placeholderRelay": "wss://...", "privacyScreen_logger": "Povoľ logovanie", "privacyScreen_loggerDescription": "Loguj anonymné informácie o chybách, aby ich bolo možné opraviť počas beta testovania. Vypni pre najvyššiu ochranu súkromia.", + "privacyScreen_mintAudit": "Kontrola dostupnosti mintov", + "privacyScreen_mintAuditDescription": "Zobraz, ako spoľahlivo mint fungoval, podľa údajov verejného audítora audit.8333.space. Otvorením detailu mintu sa jeho URL adresa odošle audítorovi.", "privacyScreen_title": "Súkromie", "privacyScreen_torDaemon": "Použi Tor sieť", "privacyScreen_torDaemonDescription": "Umožni spojenie s mintmi na Tor .onion adresách.", diff --git a/src/models/UserSettingsStore.ts b/src/models/UserSettingsStore.ts index 05e96c7f..a4fbc8f2 100644 --- a/src/models/UserSettingsStore.ts +++ b/src/models/UserSettingsStore.ts @@ -14,6 +14,7 @@ export type UserSettings = { isLocalBackupOn: boolean isBatchClaimOn: boolean isReceiveOnlyFromContactsOn: boolean // allow receiving over Nostr from unknown contacts + isMintAuditOn: boolean // share a mint's url with the public auditor to show its availability isLoggerOn: boolean logLevel: LogLevel } @@ -29,6 +30,11 @@ export const UserSettingsStoreModel = types isPOSAuthOn: types.optional(types.boolean, false), isBatchClaimOn: types.optional(types.boolean, true), isReceiveOnlyFromContactsOn: types.optional(types.boolean, false), + // Opt-out, not opt-in: the mint detail screen is the only caller, so the + // url leaves the device only when the user opens that one screen — and a + // silently empty availability section would read as "not audited". + // Optional with a default, so a pre-existing snapshot needs no migration. + isMintAuditOn: types.optional(types.boolean, true), isLoggerOn: types.optional(types.boolean, true), logLevel: types.optional(types.frozen(), LogLevel.ERROR) }) @@ -75,7 +81,11 @@ export const UserSettingsStoreModel = types self.isReceiveOnlyFromContactsOn = isReceiveOnlyFromContactsOn return isReceiveOnlyFromContactsOn }, - setIsLoggerOn: (isLoggerOn: boolean) => { + setIsMintAuditOn: (isMintAuditOn: boolean) => { + self.isMintAuditOn = isMintAuditOn + return isMintAuditOn + }, + setIsLoggerOn: (isLoggerOn: boolean) => { self.isLoggerOn = isLoggerOn return isLoggerOn }, diff --git a/src/screens/MintInfoScreen.tsx b/src/screens/MintInfoScreen.tsx index 3b149ac9..88484df7 100644 --- a/src/screens/MintInfoScreen.tsx +++ b/src/screens/MintInfoScreen.tsx @@ -1,10 +1,10 @@ import React, { useEffect, useMemo, useState } from 'react' -import { GetInfoResponse as CashuGetInfoResponse, SwapMethod } from '@cashu/cashu-ts' -import { CashuUtils } from '../services/cashu/cashuUtils' +import { verticalScale } from '@gocodingnow/rn-size-matters' import Clipboard from '@react-native-clipboard/clipboard' +import numbro from 'numbro' import { observer } from 'mobx-react-lite' import { getSnapshot } from 'mobx-state-tree' -import { DimensionValue, Image, LayoutAnimation, Platform, TextStyle, UIManager, View, ViewStyle } from 'react-native' +import { LayoutAnimation, Linking, TextStyle, View, ViewStyle } from 'react-native' import Animated, { Extrapolation, interpolate, @@ -13,11 +13,13 @@ import Animated, { useSharedValue, } from 'react-native-reanimated' import JSONTree from 'react-native-json-tree' +import { SvgXml } from 'react-native-svg' +import FastImage from 'react-native-fast-image' +import { StaticScreenProps, useNavigation } from '@react-navigation/native' import { $sizeStyles, Button, Card, - Header, Icon, IconTypes, InfoModal, @@ -28,70 +30,51 @@ import { Text, } from '../components' import { CollapsibleText } from '../components/CollapsibleText' -import { translate } from '../i18n' +import { QRShareModal } from '../components/QRShareModal' +import { translate, TxKeyPath } from '../i18n' import { useStores } from '../models' import { Mint, MintStatus } from '../models/Mint' import { log } from '../services' -import { colors, spacing, typography, useThemeColor } from '../theme' +import { + getMintAuditSummary, + MintAuditSummary, + MintAuditWarning, + MintAuditWarningCode, + MintSwapEvent, +} from '../services/mintAuditService' +import { formatCurrency, getCurrency } from '../services/wallet/currency' +import { colors, spacing, useThemeColor } from '../theme' import useColorScheme from '../theme/useThemeColor' +import { formatDayTime, formatRelativeToNow } from '../utils/dateUtils' import { useHeader } from '../utils/useHeader' import { CurrencySign } from './Wallet/CurrencySign' -import { SvgXml } from 'react-native-svg' -import { CurrencyCode, formatCurrency } from '../services/wallet/currency' -import { QRShareModal } from '../components/QRShareModal' -import { StaticScreenProps, useNavigation } from '@react-navigation/native' -import FastImage from 'react-native-fast-image' - -interface DetailedNutInfo { - methods: Array; - disabled: boolean; -} - -interface MethodLimit { - min?: number, - max?: number, -} - -type GetInfoResponse = CashuGetInfoResponse & {icon_url: string} - -const iconMap: Partial> = { - 'name': 'faTag', - 'pubkey': 'faKey', - 'version': 'faInfoCircle', - 'contact': 'faAddressCard' -} -const contactIconMap: Record = { - 'email': 'faEnvelope', - 'twitter': 'faTwitter', - 'telegram': 'faTelegramPlane', - 'discord': 'faDiscord', - 'github': 'faGithub', - 'reddit': 'faReddit', - 'nostr': 'faCircleNodes' -} +import { + asNonEmptyString, + getContacts, + getMintCapabilities, + getNutSupport, + getPaymentMethods, + MethodLimit, + MintCapability, + MintContact, + MintInfo, + NutSupport, + PaymentMethodSummary, +} from './Mints/mintInfoSummary' type Props = StaticScreenProps<{ - mintUrl : string + mintUrl: string }> -/** Every NUT-06 field is optional and mints may publish malformed values, so - * narrow to a usable string or give up. */ -function asNonEmptyString(value: unknown): string | undefined { - return typeof value === 'string' && value.trim() !== '' ? value : undefined -} - -/** Renders any info value as a copyable string, without assuming its type. */ -function stringifyValue(value: unknown): string { - if (value === null || typeof value === 'undefined') return '' - if (typeof value === 'string') return value - if (typeof value === 'object') { - try { - return JSON.stringify(value) ?? '' - } catch (e) { - return '' - } - } - return String(value) +const contactIconMap: Record = { + email: 'faEnvelope', + twitter: 'faTwitter', + telegram: 'faTelegramPlane', + discord: 'faDiscord', + github: 'faGithub', + reddit: 'faReddit', + nostr: 'faCircleNodes', + website: 'faGlobe', } /** header height, and the scroll distance over which the header title hands off @@ -102,55 +85,52 @@ export const MintInfoScreen = observer(function MintInfoScreen({ route }: Props) const navigation = useNavigation() const scrollY = useSharedValue(0) - const { mintsStore, walletStore } = useStores() - //const { walletStore } = nonPersistedStores + const { mintsStore, walletStore, userSettingsStore } = useStores() const [isLoading, setIsLoading] = useState(false) - const [mintInfo, setMintInfo] = useState() + const [mintInfo, setMintInfo] = useState() const [mint, setMint] = useState() - const [isLocalInfoVisible, setIsLocalInfoVisible] = useState(false) + const [audit, setAudit] = useState() + const [isLocalInfoVisible, setIsLocalInfoVisible] = useState(false) const [isShareModalVisible, setIsShareModalVisible] = useState(false) const [isInfoLoadFailed, setIsInfoLoadFailed] = useState(false) - const [info, setInfo] = useState('') + const mintUrl = route.params?.mintUrl + useEffect(() => { const getInfo = async () => { - const mintUrl = route.params?.mintUrl - if (!mintUrl) { - log.error('[MintInfoScreen] Missing mintUrl route param') + log.error('[MintInfoScreen]', 'Missing mintUrl route param') setIsInfoLoadFailed(true) return } - log.trace('[MintInfoScreen] useEffect', { mintUrl }) - const mint = mintsStore.findByUrl(mintUrl) if (!mint) { - log.error('[MintInfoScreen] Could not find mint', { mintUrl }) + log.error('[MintInfoScreen]', 'Could not find mint', { mintUrl }) setIsInfoLoadFailed(true) return } setMint(mint) // show whatever we cached on device while the fresh info loads - const cachedInfo = mint.mintInfo as GetInfoResponse | undefined + const cachedInfo = mint.mintInfo as MintInfo | undefined setMintInfo(cachedInfo) setIsLoading(!cachedInfo) try { - const info: GetInfoResponse & {time: number} = await walletStore.getMintInfo(mint.mintUrl) + const freshInfo: MintInfo = await walletStore.getMintInfo(mint.mintUrl) mint.setStatus(MintStatus.ONLINE) - mint.setMintInfo(info) - if (info.name && info.name !== mint.shortname) { + mint.setMintInfo(freshInfo) + if (freshInfo.name && freshInfo.name !== mint.shortname) { await mint.setShortname() } - setMintInfo(info as GetInfoResponse) + setMintInfo(freshInfo) setIsInfoLoadFailed(false) } catch (e: any) { - log.warn('[MintInfoScreen] Could not load mint info', { mintUrl, message: e.message }) + log.warn('[MintInfoScreen]', 'Could not load mint info', { mintUrl, message: e.message }) mint.setStatus(MintStatus.OFFLINE) setIsInfoLoadFailed(true) } finally { @@ -158,7 +138,31 @@ export const MintInfoScreen = observer(function MintInfoScreen({ route }: Props) } } getInfo() - }, []) + }, [mintUrl]) + + // Independent of the mint's own info: the whole point of the auditor is that + // it answers even when the mint does not. Resolves undefined on any failure, + // which simply hides the section. + // + // Gated on the privacy setting, since this is the ONE request on this screen + // that tells a third party which mint the user is looking at. Reading it here + // rather than inside the service keeps the network call and the consent in the + // same place, and the effect re-runs if the switch is flipped mid-visit. + const isMintAuditOn = userSettingsStore.isMintAuditOn + + useEffect(() => { + if (!mintUrl || !isMintAuditOn) { + setAudit(undefined) + return + } + let isCurrent = true + + getMintAuditSummary(mintUrl).then(summary => { + if (isCurrent) setAudit(summary) + }) + + return () => { isCurrent = false } + }, [mintUrl, isMintAuditOn]) const toggleLocalInfo = () => { LayoutAnimation.easeInEaseOut() @@ -167,20 +171,19 @@ export const MintInfoScreen = observer(function MintInfoScreen({ route }: Props) const toggleShareModal = () => setIsShareModalVisible(previousState => !previousState) - const gotoShare = function () { - toggleShareModal() - } + const copyToClipboard = (value: string) => { + Clipboard.setString(value) + setInfo(translate('commonCopySuccessParam', { param: value })) + } - /** memoized mint limit info */ - const mintLimitInfo = useMemo(() => { - if (typeof mintInfo === 'undefined') return; - return getMintLimits(mintInfo) - }, [mintInfo]) + const capabilities = useMemo(() => getMintCapabilities(mintInfo), [mintInfo]) + const nutSupport = useMemo(() => getNutSupport(mintInfo), [mintInfo]) + const paymentMethods = useMemo(() => getPaymentMethods(mintInfo), [mintInfo]) + const contacts = useMemo(() => getContacts(mintInfo), [mintInfo]) const colorScheme = useColorScheme() const textDim = useThemeColor('textDim') const headerBg = useThemeColor('header') - const iconColor = useThemeColor('textDim') const headerTitle = useThemeColor('headerTitle') // every field of the NUT-06 info response is optional, so nothing may be trusted to exist @@ -223,529 +226,874 @@ export const MintInfoScreen = observer(function MintInfoScreen({ route }: Props) onScroll={scrollHandler} scrollEventThrottle={16} > - - - {iconUrl ? ( + + + {iconUrl ? ( - ):( - - - - )} - - {mint?.units && ( - - {mint.units.map(unit => ( - - ))} - - )} - - - - {isLoading ? ( - - ) : ( - <> - {isInfoLoadFailed && } - {motd && } - {mintInfo && } - -