mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 11:18:24 +00:00
MintInfo screen redesign
This commit is contained in:
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>) => ({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([])
|
||||
})
|
||||
})
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>(), LogLevel.ERROR)
|
||||
})
|
||||
@@ -75,6 +81,10 @@ export const UserSettingsStoreModel = types
|
||||
self.isReceiveOnlyFromContactsOn = isReceiveOnlyFromContactsOn
|
||||
return isReceiveOnlyFromContactsOn
|
||||
},
|
||||
setIsMintAuditOn: (isMintAuditOn: boolean) => {
|
||||
self.isMintAuditOn = isMintAuditOn
|
||||
return isMintAuditOn
|
||||
},
|
||||
setIsLoggerOn: (isLoggerOn: boolean) => {
|
||||
self.isLoggerOn = isLoggerOn
|
||||
return isLoggerOn
|
||||
|
||||
+766
-431
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* Turns a mint's raw NUT-06 `/v1/info` response into the things a user actually
|
||||
* wants to know: which payment rails it settles on (with their limits), and
|
||||
* which capabilities it supports — phrased as capabilities, not as NUT numbers.
|
||||
*
|
||||
* This is deliberately pure and UI-free so the (considerable) defensive parsing
|
||||
* can be reasoned about — and tested — apart from the screen that renders it.
|
||||
*
|
||||
* EVERY field of a NUT-06 response is optional and real mints publish malformed
|
||||
* values, so nothing here may assume a shape. The rule throughout: narrow, or
|
||||
* drop the item.
|
||||
*/
|
||||
import { GetInfoResponse, SwapMethod } from '@cashu/cashu-ts'
|
||||
import { IconTypes } from '../../components'
|
||||
import { TxKeyPath } from '../../i18n'
|
||||
import { MintUnit, MintUnits } from '../../services/wallet/currency'
|
||||
|
||||
/** `icon_url` is not in cashu-ts' type yet, but every mint publishes it. */
|
||||
export type MintInfo = GetInfoResponse & {icon_url?: string; tos_url?: string; urls?: string[]}
|
||||
|
||||
// === NUT support ===
|
||||
|
||||
/**
|
||||
* Is the NUT whose `nuts` entry is `value` supported?
|
||||
*
|
||||
* One funnel for the whole zoo of shapes mints publish, replacing what used to
|
||||
* be a per-NUT ladder of special cases:
|
||||
*
|
||||
* - `{supported: true}` — the common form
|
||||
* - `{supported: [...]}` — nutshell's NUT-17, a per-method array
|
||||
* - `[...]` — a bare array, also seen for NUT-17
|
||||
* (https://github.com/cashubtc/nutshell/issues/588)
|
||||
* - `{methods: [...], disabled}` — NUT-04 / NUT-05 / NUT-15 / NUT-29
|
||||
* - `{cached_endpoints: [...]}` — NUT-19
|
||||
*
|
||||
* `disabled` wins over everything, since a mint that advertises methods AND
|
||||
* disables them is telling us it is off.
|
||||
*/
|
||||
export function isNutSupported(value: unknown): boolean {
|
||||
if (value === null || typeof value === 'undefined') return false
|
||||
if (typeof value === 'boolean') return value
|
||||
if (typeof value !== 'object') return Boolean(value)
|
||||
if (Array.isArray(value)) return value.length > 0
|
||||
|
||||
const settings = value as Record<string, unknown>
|
||||
|
||||
if (settings.disabled === true) return false
|
||||
if (typeof settings.supported === 'boolean') return settings.supported
|
||||
if (Array.isArray(settings.supported)) return settings.supported.length > 0
|
||||
if (Array.isArray(settings.methods)) return settings.methods.length > 0
|
||||
if (Array.isArray(settings.cached_endpoints)) return settings.cached_endpoints.length > 0
|
||||
|
||||
// `disabled: false` with no other evidence still means "on"
|
||||
return settings.disabled === false
|
||||
}
|
||||
|
||||
/** The mint's `nuts` map, or an empty one when it published something else. */
|
||||
function getNuts(info?: MintInfo): Record<string, unknown> {
|
||||
const nuts = info?.nuts
|
||||
return nuts !== null && typeof nuts === 'object' && !Array.isArray(nuts)
|
||||
? (nuts as Record<string, unknown>)
|
||||
: {}
|
||||
}
|
||||
|
||||
/** Support flag for one NUT number, e.g. `supports(info, '11')`. */
|
||||
function supports(info: MintInfo | undefined, nut: string): boolean {
|
||||
return isNutSupported(getNuts(info)[nut])
|
||||
}
|
||||
|
||||
// === Capabilities ===
|
||||
|
||||
export type MintCapability = {
|
||||
key: string
|
||||
icon: IconTypes
|
||||
labelTx: TxKeyPath
|
||||
/** one line on what it means for the person holding the ecash */
|
||||
descriptionTx: TxKeyPath
|
||||
supported: boolean
|
||||
/**
|
||||
* A caveat rather than a feature — its presence is the notable thing, its
|
||||
* absence is the norm. Callers render these only when supported, so they
|
||||
* never appear in a "not supported" list where the polarity would invert.
|
||||
*/
|
||||
warning?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The capability catalogue, in display order.
|
||||
*
|
||||
* Only NUTs with a consequence a user can feel are listed — the rest stay in
|
||||
* the technical NUT breakdown. `nuts` is OR-ed: a capability is on when the
|
||||
* mint supports any of them (authentication is clear *or* blind).
|
||||
*/
|
||||
const CAPABILITY_SPECS: Array<{
|
||||
key: string
|
||||
nuts: string[]
|
||||
icon: IconTypes
|
||||
labelTx: TxKeyPath
|
||||
descriptionTx: TxKeyPath
|
||||
warning?: boolean
|
||||
}> = [
|
||||
{
|
||||
key: 'seedRestore',
|
||||
nuts: ['9'],
|
||||
icon: 'faSeedling',
|
||||
labelTx: 'mintInfo_capability_seedRestore',
|
||||
descriptionTx: 'mintInfo_capability_seedRestoreDesc',
|
||||
},
|
||||
{
|
||||
key: 'offlineVerification',
|
||||
nuts: ['12'],
|
||||
icon: 'faShieldHalved',
|
||||
labelTx: 'mintInfo_capability_offlineVerification',
|
||||
descriptionTx: 'mintInfo_capability_offlineVerificationDesc',
|
||||
},
|
||||
{
|
||||
key: 'lockedEcash',
|
||||
nuts: ['11'],
|
||||
icon: 'faLock',
|
||||
labelTx: 'mintInfo_capability_lockedEcash',
|
||||
descriptionTx: 'mintInfo_capability_lockedEcashDesc',
|
||||
},
|
||||
{
|
||||
key: 'timelocks',
|
||||
nuts: ['14'],
|
||||
icon: 'faClock',
|
||||
labelTx: 'mintInfo_capability_timelocks',
|
||||
descriptionTx: 'mintInfo_capability_timelocksDesc',
|
||||
},
|
||||
{
|
||||
key: 'feeReturn',
|
||||
nuts: ['8'],
|
||||
icon: 'faArrowRotateLeft',
|
||||
labelTx: 'mintInfo_capability_feeReturn',
|
||||
descriptionTx: 'mintInfo_capability_feeReturnDesc',
|
||||
},
|
||||
{
|
||||
key: 'stateCheck',
|
||||
nuts: ['7'],
|
||||
icon: 'faMagnifyingGlass',
|
||||
labelTx: 'mintInfo_capability_stateCheck',
|
||||
descriptionTx: 'mintInfo_capability_stateCheckDesc',
|
||||
},
|
||||
{
|
||||
key: 'liveUpdates',
|
||||
nuts: ['17'],
|
||||
icon: 'faWifi',
|
||||
labelTx: 'mintInfo_capability_liveUpdates',
|
||||
descriptionTx: 'mintInfo_capability_liveUpdatesDesc',
|
||||
},
|
||||
{
|
||||
key: 'safeRetries',
|
||||
nuts: ['19'],
|
||||
icon: 'faRecycle',
|
||||
labelTx: 'mintInfo_capability_safeRetries',
|
||||
descriptionTx: 'mintInfo_capability_safeRetriesDesc',
|
||||
},
|
||||
{
|
||||
key: 'quoteBinding',
|
||||
nuts: ['20'],
|
||||
icon: 'faKey',
|
||||
labelTx: 'mintInfo_capability_quoteBinding',
|
||||
descriptionTx: 'mintInfo_capability_quoteBindingDesc',
|
||||
},
|
||||
{
|
||||
key: 'batchedMint',
|
||||
nuts: ['29'],
|
||||
icon: 'faCoins',
|
||||
labelTx: 'mintInfo_capability_batchedMint',
|
||||
descriptionTx: 'mintInfo_capability_batchedMintDesc',
|
||||
},
|
||||
{
|
||||
key: 'multiPath',
|
||||
nuts: ['15'],
|
||||
icon: 'faArrowRightArrowLeft',
|
||||
labelTx: 'mintInfo_capability_multiPath',
|
||||
descriptionTx: 'mintInfo_capability_multiPathDesc',
|
||||
},
|
||||
{
|
||||
key: 'authRequired',
|
||||
nuts: ['21', '22'],
|
||||
icon: 'faUserShield',
|
||||
labelTx: 'mintInfo_capability_authRequired',
|
||||
descriptionTx: 'mintInfo_capability_authRequiredDesc',
|
||||
warning: true,
|
||||
},
|
||||
]
|
||||
|
||||
export function getMintCapabilities(info?: MintInfo): MintCapability[] {
|
||||
return CAPABILITY_SPECS.map(({nuts, ...spec}) => ({
|
||||
...spec,
|
||||
supported: nuts.some(nut => supports(info, nut)),
|
||||
}))
|
||||
}
|
||||
|
||||
// === Technical NUT breakdown ===
|
||||
|
||||
/** Titles from https://github.com/cashubtc/nuts. Unlisted numbers render bare. */
|
||||
const NUT_TITLES: Record<string, string> = {
|
||||
'0': 'Cryptography and models',
|
||||
'1': 'Mint public keys',
|
||||
'2': 'Keysets and fees',
|
||||
'3': 'Swapping tokens',
|
||||
'4': 'Minting tokens',
|
||||
'5': 'Melting tokens',
|
||||
'6': 'Mint information',
|
||||
'7': 'Token state check',
|
||||
'8': 'Overpaid Lightning fees',
|
||||
'9': 'Signature restore',
|
||||
'10': 'Spending conditions',
|
||||
'11': 'Pay-to-Pubkey (P2PK)',
|
||||
'12': 'DLEQ proofs',
|
||||
'13': 'Deterministic secrets',
|
||||
'14': 'Hashed timelock contracts',
|
||||
'15': 'Multi-path payments',
|
||||
'16': 'Animated QR codes',
|
||||
'17': 'WebSocket subscriptions',
|
||||
'18': 'Payment requests',
|
||||
'19': 'Cached responses',
|
||||
'20': 'Signature on mint quote',
|
||||
'21': 'Clear authentication',
|
||||
'22': 'Blind authentication',
|
||||
'23': 'Payment method: BOLT11',
|
||||
'24': 'HTTP 402 Payment Required',
|
||||
'25': 'Payment method: BOLT12',
|
||||
'26': 'Payment request bech32m encoding',
|
||||
'27': 'Nostr mint backup',
|
||||
'28': 'Pay-to-blinded-key (P2BK)',
|
||||
'29': 'Batched minting',
|
||||
'30': 'Payment method: onchain',
|
||||
}
|
||||
|
||||
export type NutSupport = {
|
||||
nut: string
|
||||
/** "NUT-04" — zero-padded the way the specs are written */
|
||||
code: string
|
||||
title?: string
|
||||
supported: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Every NUT the mint mentions, numerically sorted.
|
||||
*
|
||||
* Only what the mint actually published: absence from `nuts` is not the same as
|
||||
* "unsupported" for the mandatory NUTs (00–06), which no mint advertises
|
||||
* individually but every mint implements.
|
||||
*/
|
||||
export function getNutSupport(info?: MintInfo): NutSupport[] {
|
||||
return Object.entries(getNuts(info))
|
||||
.map(([nut, value]) => ({
|
||||
nut,
|
||||
code: `NUT-${nut.padStart(2, '0')}`,
|
||||
title: NUT_TITLES[nut],
|
||||
supported: isNutSupported(value),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const na = Number(a.nut)
|
||||
const nb = Number(b.nut)
|
||||
// non-numeric keys (never seen in the wild, but cheap to survive) sink
|
||||
if (isNaN(na) || isNaN(nb)) return a.nut.localeCompare(b.nut)
|
||||
return na - nb
|
||||
})
|
||||
}
|
||||
|
||||
// === Payment methods and limits ===
|
||||
|
||||
export type MethodLimit = {
|
||||
unit: string
|
||||
/** already-normalized wallet unit, absent when the mint uses one we do not know */
|
||||
mintUnit?: MintUnit
|
||||
min?: number
|
||||
max?: number
|
||||
}
|
||||
|
||||
export type PaymentMethodSummary = {
|
||||
method: string
|
||||
icon: IconTypes
|
||||
/** translated label for known rails; unknown methods fall back to `method` */
|
||||
labelTx?: TxKeyPath
|
||||
/** per-unit deposit limits; empty when the mint does not mint with this rail */
|
||||
mint: MethodLimit[]
|
||||
/** per-unit withdrawal limits; empty when the mint does not melt with this rail */
|
||||
melt: MethodLimit[]
|
||||
/** onchain (NUT-30) only: confirmations the mint waits for before crediting */
|
||||
confirmations?: number
|
||||
}
|
||||
|
||||
const METHOD_SPECS: Record<string, {icon: IconTypes; labelTx: TxKeyPath}> = {
|
||||
bolt11: {icon: 'faBolt', labelTx: 'mintInfo_method_bolt11'},
|
||||
bolt12: {icon: 'faBolt', labelTx: 'mintInfo_method_bolt12'},
|
||||
onchain: {icon: 'faCubes', labelTx: 'mintInfo_method_onchain'},
|
||||
}
|
||||
|
||||
/** Display order for the rails we recognise; anything else follows, as given. */
|
||||
const METHOD_ORDER = ['bolt11', 'bolt12', 'onchain']
|
||||
|
||||
/** The `methods` array of NUT-04 (mint) or NUT-05 (melt), or empty. */
|
||||
function getMethods(info: MintInfo | undefined, nut: '4' | '5'): SwapMethod[] {
|
||||
const entry = getNuts(info)[nut]
|
||||
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return []
|
||||
// A disabled NUT-04/05 means the direction is off, whatever it lists.
|
||||
if ((entry as Record<string, unknown>).disabled === true) return []
|
||||
const methods = (entry as {methods?: unknown}).methods
|
||||
return Array.isArray(methods) ? (methods.filter(Boolean) as SwapMethod[]) : []
|
||||
}
|
||||
|
||||
const asPositiveNumber = (value: unknown): number | undefined => {
|
||||
const n = Number(value)
|
||||
return typeof value !== 'undefined' && value !== null && !isNaN(n) && n >= 0 ? n : undefined
|
||||
}
|
||||
|
||||
function toLimit(method: SwapMethod): MethodLimit | undefined {
|
||||
const unit = typeof method.unit === 'string' ? method.unit : undefined
|
||||
if (!unit) return undefined
|
||||
|
||||
return {
|
||||
unit,
|
||||
mintUnit: (MintUnits as readonly string[]).includes(unit) ? (unit as MintUnit) : undefined,
|
||||
min: asPositiveNumber(method.min_amount),
|
||||
max: asPositiveNumber(method.max_amount),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment rails the mint settles on, each with its deposit and withdrawal
|
||||
* limits per unit.
|
||||
*
|
||||
* A rail is keyed by `method`, NOT by NUT number — onchain (NUT-30) has no
|
||||
* `nuts['30']` block of its own, it is simply an entry in the NUT-04 / NUT-05
|
||||
* method lists (the same reasoning as Mint.mintMethodSetting). A rail appears
|
||||
* here if EITHER direction offers it; the missing direction renders as
|
||||
* unsupported rather than silently dropping the rail.
|
||||
*/
|
||||
export function getPaymentMethods(info?: MintInfo): PaymentMethodSummary[] {
|
||||
const summaries = new Map<string, PaymentMethodSummary>()
|
||||
|
||||
const collect = (direction: 'mint' | 'melt', methods: SwapMethod[]) => {
|
||||
for (const method of methods) {
|
||||
const name = typeof method.method === 'string' ? method.method : undefined
|
||||
const limit = name ? toLimit(method) : undefined
|
||||
if (!name || !limit) continue
|
||||
|
||||
let summary = summaries.get(name)
|
||||
if (!summary) {
|
||||
summary = {
|
||||
method: name,
|
||||
icon: METHOD_SPECS[name]?.icon ?? 'faCoins',
|
||||
labelTx: METHOD_SPECS[name]?.labelTx,
|
||||
mint: [],
|
||||
melt: [],
|
||||
}
|
||||
summaries.set(name, summary)
|
||||
}
|
||||
|
||||
summary[direction].push(limit)
|
||||
|
||||
// NUT-30 publishes the confirmation depth under the mint method's
|
||||
// options; it is the same figure for the whole rail.
|
||||
const confirmations = asPositiveNumber(
|
||||
(method as {options?: {confirmations?: unknown}}).options?.confirmations,
|
||||
)
|
||||
if (typeof confirmations !== 'undefined') summary.confirmations = confirmations
|
||||
}
|
||||
}
|
||||
|
||||
collect('mint', getMethods(info, '4'))
|
||||
collect('melt', getMethods(info, '5'))
|
||||
|
||||
return [...summaries.values()].sort((a, b) => {
|
||||
const ia = METHOD_ORDER.indexOf(a.method)
|
||||
const ib = METHOD_ORDER.indexOf(b.method)
|
||||
return (ia === -1 ? METHOD_ORDER.length : ia) - (ib === -1 ? METHOD_ORDER.length : ib)
|
||||
})
|
||||
}
|
||||
|
||||
// === Contacts ===
|
||||
|
||||
export type MintContact = {method: string; info: string}
|
||||
|
||||
/** Narrows a possibly-malformed value to a non-empty trimmed string. */
|
||||
export function asNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Contacts, accepting both the current `{method, info}` objects and the legacy
|
||||
* `['email', 'a@b.c']` tuples that older mints still publish.
|
||||
*/
|
||||
export function getContacts(info?: MintInfo): MintContact[] {
|
||||
const raw = Array.isArray(info?.contact) ? info.contact : []
|
||||
const contacts: MintContact[] = []
|
||||
|
||||
for (const contact of raw) {
|
||||
if (!contact) continue
|
||||
|
||||
const [method, value] = Array.isArray(contact)
|
||||
? [asNonEmptyString(contact[0]), asNonEmptyString(contact[1])]
|
||||
: [
|
||||
asNonEmptyString((contact as MintContact).method),
|
||||
asNonEmptyString((contact as MintContact).info),
|
||||
]
|
||||
|
||||
if (method && value) contacts.push({method, info: value})
|
||||
}
|
||||
|
||||
return contacts
|
||||
}
|
||||
@@ -55,6 +55,9 @@ export const PrivacyScreen = observer(function PrivacyScreen({ route }: Props) {
|
||||
const [isLoggerOn, setIsLoggerOn] = useState<boolean>(
|
||||
userSettingsStore.isLoggerOn,
|
||||
)
|
||||
const [isMintAuditOn, setIsMintAuditOn] = useState<boolean>(
|
||||
userSettingsStore.isMintAuditOn,
|
||||
)
|
||||
const [isDerivedKeys, setIsDerivedKeys] = useState<boolean>(false)
|
||||
const [error, setError] = useState<AppError | undefined>()
|
||||
|
||||
@@ -86,6 +89,14 @@ export const PrivacyScreen = observer(function PrivacyScreen({ route }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMintAuditSwitch = () => {
|
||||
try {
|
||||
setIsMintAuditOn(userSettingsStore.setIsMintAuditOn(!isMintAuditOn))
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
const upgradeToNostrDerivedKeys = async () => {
|
||||
setIsLoading(true)
|
||||
setStatusMessage(translate('privacyScreen_upgradingKeys'))
|
||||
@@ -364,6 +375,33 @@ export const PrivacyScreen = observer(function PrivacyScreen({ route }: Props) {
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Card
|
||||
style={[$card, {marginTop: spacing.medium}]}
|
||||
ContentComponent={
|
||||
<>
|
||||
<ListItem
|
||||
tx="privacyScreen_mintAudit"
|
||||
subTx="privacyScreen_mintAuditDescription"
|
||||
leftIcon={'faHeartPulse'}
|
||||
leftIconColor={
|
||||
isMintAuditOn
|
||||
? colors.palette.iconViolet200
|
||||
: iconColor as string
|
||||
}
|
||||
leftIconInverse={true}
|
||||
RightComponent={
|
||||
<View style={$rightContainer}>
|
||||
<Switch
|
||||
onValueChange={toggleMintAuditSwitch}
|
||||
value={isMintAuditOn}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
style={$item}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Card
|
||||
style={[$card, {marginTop: spacing.medium}]}
|
||||
ContentComponent={
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* Read-only client for the public Cashu mint auditor (https://audit.8333.space).
|
||||
*
|
||||
* The auditor continuously swaps small amounts between the mints it knows about
|
||||
* and records whether each swap succeeded and how long it took. That gives an
|
||||
* INDEPENDENT availability signal — something the mint's own NUT-06 info can
|
||||
* never provide, because a mint that is broken still happily advertises itself.
|
||||
*
|
||||
* Everything here is best-effort and NON-FATAL: the auditor is a third party
|
||||
* that may be down, may not know a given mint, or may change its payload. Every
|
||||
* entry point resolves to `undefined` instead of throwing so the caller can just
|
||||
* skip the section. Nothing in the wallet depends on this data.
|
||||
*
|
||||
* PRIVACY: calling this sends the mint url to a third-party server. It is only
|
||||
* ever called from the mint detail screen, on explicit user navigation — never
|
||||
* in the background and never for every known mint.
|
||||
*/
|
||||
import { isValid, parseISO } from 'date-fns'
|
||||
import { log } from './logService'
|
||||
|
||||
/** REST API host. The matching human-facing site is AUDIT_SITE_URL. */
|
||||
const AUDIT_API_URL = 'https://api.audit.8333.space'
|
||||
const AUDIT_SITE_URL = 'https://audit.8333.space'
|
||||
|
||||
/** How many recent swap events to score. The API caps `limit` at 1000. */
|
||||
const SWAP_SAMPLE_SIZE = 100
|
||||
|
||||
/** Short by design: this decorates a screen that must render without it. */
|
||||
const REQUEST_TIMEOUT = 8000
|
||||
|
||||
/** Auditor verdict for a mint or a single swap event. */
|
||||
export type MintAuditState = 'OK' | 'WARN' | 'ERROR' | 'UNKNOWN'
|
||||
|
||||
/** The auditor's `MintRead` record, narrowed to the fields we render. */
|
||||
type MintAuditRecord = {
|
||||
id: number
|
||||
url: string
|
||||
name?: string
|
||||
state?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** The auditor's `SwapEventRead` record, narrowed to the fields we score. */
|
||||
type SwapEvent = {
|
||||
state?: string
|
||||
/** milliseconds the swap took end to end */
|
||||
time_taken?: number
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The thresholds cashu.me's auditor panel judges a mint by, kept at its values
|
||||
* so the same mint does not read as healthy in one wallet and suspect in the
|
||||
* other. See cashu.me's MintAuditWarningBox.
|
||||
*/
|
||||
export const AUDIT_THRESHOLDS = {
|
||||
/** window the "recent" figures are computed over */
|
||||
recentDays: 7,
|
||||
/** below this recent success rate a mint is called unreliable */
|
||||
successRatePercent: 85,
|
||||
/** no successful swap for this long reads as unreachable */
|
||||
inactiveDays: 2,
|
||||
/** slower than this on average is called out */
|
||||
slowMs: 5000,
|
||||
/** fewer recent successes than this means the auditor has too little data */
|
||||
requiredSuccessfulSwaps: 7,
|
||||
} as const
|
||||
|
||||
export type MintSwapStats = {
|
||||
/** 0–100, share of sampled swaps that completed */
|
||||
successRate: number
|
||||
okCount: number
|
||||
totalCount: number
|
||||
/** mean duration of the SUCCESSFUL swaps, in ms; absent if none were timed */
|
||||
averageMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One audited swap, reduced to what a timeline needs.
|
||||
*
|
||||
* Kept as parsed values rather than raw payload so the UI never touches the
|
||||
* auditor's naive-UTC strings or its `state` vocabulary.
|
||||
*/
|
||||
export type MintSwapEvent = {
|
||||
at: Date
|
||||
ok: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A judgement about the mint, as a code the UI translates.
|
||||
*
|
||||
* Deliberately NOT a rendered string: the service stays UI- and locale-free,
|
||||
* and the screen owns the wording. `params` carries the interpolations the
|
||||
* matching message needs.
|
||||
*/
|
||||
export type MintAuditWarningCode =
|
||||
| 'lastSwapFailed'
|
||||
| 'lastSwapFailedReliable'
|
||||
| 'lastSwapFailedUnreliable'
|
||||
| 'unknownQuality'
|
||||
| 'notEnoughData'
|
||||
| 'lowSuccessRate'
|
||||
| 'inactive'
|
||||
| 'noSuccessfulSwaps'
|
||||
| 'slow'
|
||||
|
||||
export type MintAuditWarning = {
|
||||
code: MintAuditWarningCode
|
||||
params?: Record<string, string | number>
|
||||
}
|
||||
|
||||
export type MintAuditSummary = {
|
||||
state: MintAuditState
|
||||
/**
|
||||
* Where to read more. The auditor's site is a single-page mint table with no
|
||||
* per-mint route, so this is its root — the same link cashu.me uses.
|
||||
*/
|
||||
auditUrl: string
|
||||
/** when the auditor last probed the mint */
|
||||
updatedAt?: Date
|
||||
/** absent when the swap history could not be loaded or is empty */
|
||||
swap?: MintSwapStats
|
||||
/** empty when the auditor found nothing worth flagging */
|
||||
warnings: MintAuditWarning[]
|
||||
/** the sampled swaps that carried a usable timestamp, OLDEST first */
|
||||
history: MintSwapEvent[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an auditor timestamp, which arrives as a NAIVE UTC datetime
|
||||
* (`2026-08-10T19:01:04`, no designator).
|
||||
*
|
||||
* The zone has to be supplied before parsing: ISO 8601 — and therefore
|
||||
* `parseISO` — reads a bare datetime as LOCAL time, which would shift "last
|
||||
* checked" by the device's utc offset. Far enough east and the auditor appears
|
||||
* to have checked in the future.
|
||||
*/
|
||||
const parseUtcDate = (value?: string): Date | undefined => {
|
||||
if (!value) return undefined
|
||||
const hasZone = /(?:Z|[+-]\d{2}:?\d{2})$/.test(value)
|
||||
const parsed = parseISO(hasZone ? value : `${value}Z`)
|
||||
return isValid(parsed) ? parsed : undefined
|
||||
}
|
||||
|
||||
const isAuditState = (value: unknown): value is MintAuditState =>
|
||||
value === 'OK' || value === 'WARN' || value === 'ERROR' || value === 'UNKNOWN'
|
||||
|
||||
/**
|
||||
* GET `path` and parse it as JSON, or resolve undefined.
|
||||
*
|
||||
* Aborts on timeout through the signal — a dangling fetch would otherwise keep
|
||||
* running after the screen is gone. A 404 (auditor does not track this mint) is
|
||||
* an expected outcome, not an error, so it is logged at trace like the rest.
|
||||
*/
|
||||
const fetchJson = async <T>(path: string): Promise<T | undefined> => {
|
||||
const url = `${AUDIT_API_URL}${path}`
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {Accept: 'application/json'},
|
||||
// React Native's own AbortSignal declaration diverges from lib.dom's
|
||||
// (`onabort` is non-nullable there), so the two never structurally
|
||||
// match. Runtime behaviour is fine — RN's fetch honours the signal.
|
||||
signal: controller.signal as any,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
log.trace('[mintAuditService.fetchJson]', 'Auditor returned a non-OK status', {
|
||||
status: response.status,
|
||||
url,
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
|
||||
return (await response.json()) as T
|
||||
} catch (e: any) {
|
||||
log.trace('[mintAuditService.fetchJson]', 'Auditor request failed', {
|
||||
message: e?.message,
|
||||
url,
|
||||
})
|
||||
return undefined
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/** Mean of the successful swaps only — a failed swap has no meaningful duration. */
|
||||
const averageDurationMs = (succeeded: SwapEvent[]): number | undefined => {
|
||||
const timed = succeeded.filter(e => typeof e.time_taken === 'number' && e.time_taken > 0)
|
||||
if (timed.length === 0) return undefined
|
||||
return timed.reduce((sum, e) => sum + (e.time_taken as number), 0) / timed.length
|
||||
}
|
||||
|
||||
const summarizeSwaps = (events: SwapEvent[]): MintSwapStats | undefined => {
|
||||
if (events.length === 0) return undefined
|
||||
|
||||
const succeeded = events.filter(e => e?.state === 'OK')
|
||||
|
||||
return {
|
||||
successRate: Math.round((succeeded.length / events.length) * 100),
|
||||
okCount: succeeded.length,
|
||||
totalCount: events.length,
|
||||
averageMs: averageDurationMs(succeeded),
|
||||
}
|
||||
}
|
||||
|
||||
const daysBetween = (from: Date, to: Date) =>
|
||||
(to.getTime() - from.getTime()) / (1000 * 60 * 60 * 24)
|
||||
|
||||
/**
|
||||
* Swaps that carried a usable timestamp, OLDEST first.
|
||||
*
|
||||
* Sorted here rather than trusting the API's newest-first ordering, which is not
|
||||
* contractual — and the timeline in the UI reads wrong if it is ever violated.
|
||||
* Events with an unparseable `created_at` are dropped: they can be neither
|
||||
* placed on the timeline nor aged against the recent window.
|
||||
*/
|
||||
const toHistory = (events: SwapEvent[]): Array<MintSwapEvent & {event: SwapEvent}> =>
|
||||
events
|
||||
.map(event => ({event, at: parseUtcDate(event.created_at), ok: event.state === 'OK'}))
|
||||
.filter((e): e is MintSwapEvent & {event: SwapEvent} => !!e.at)
|
||||
.sort((a, b) => a.at.getTime() - b.at.getTime())
|
||||
|
||||
/**
|
||||
* The auditor's judgements about a mint, mirroring cashu.me's warning panel so
|
||||
* the two wallets agree about the same mint.
|
||||
*
|
||||
* `state` is the verdict on the LAST swap alone, which is why a failure is
|
||||
* always qualified by the recent window: one failed swap against a mint with a
|
||||
* good week usually says more about the swap's counterparty than about this
|
||||
* mint. Reported as codes; see MintAuditWarningCode.
|
||||
*/
|
||||
const collectWarnings = (
|
||||
state: MintAuditState,
|
||||
events: SwapEvent[],
|
||||
dated: Array<MintSwapEvent & {event: SwapEvent}>,
|
||||
): MintAuditWarning[] => {
|
||||
const {recentDays, successRatePercent, inactiveDays, slowMs, requiredSuccessfulSwaps} =
|
||||
AUDIT_THRESHOLDS
|
||||
const warnings: MintAuditWarning[] = []
|
||||
const now = new Date()
|
||||
|
||||
const recent = dated.filter(({at}) => daysBetween(at, now) <= recentDays)
|
||||
const recentOk = recent.filter(({ok}) => ok)
|
||||
const recentRate = recent.length > 0 ? (recentOk.length / recent.length) * 100 : 0
|
||||
|
||||
if (state === 'WARN' || state === 'ERROR') {
|
||||
const params = {ok: recentOk.length, total: recent.length, days: recentDays}
|
||||
warnings.push(
|
||||
recent.length === 0 ? {code: 'lastSwapFailed'}
|
||||
: recentRate >= successRatePercent ? {code: 'lastSwapFailedReliable', params}
|
||||
: {code: 'lastSwapFailedUnreliable', params},
|
||||
)
|
||||
} else if (state === 'UNKNOWN') {
|
||||
warnings.push({code: 'unknownQuality'})
|
||||
} else if (state === 'OK') {
|
||||
if (recentOk.length < requiredSuccessfulSwaps) {
|
||||
warnings.push({code: 'notEnoughData', params: {count: recentOk.length, days: recentDays}})
|
||||
}
|
||||
if (recent.length > 0 && recentRate < successRatePercent) {
|
||||
warnings.push({
|
||||
code: 'lowSuccessRate',
|
||||
params: {percent: Math.round(recentRate), days: recentDays, threshold: successRatePercent},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Liveness and speed are judged over the WHOLE sample, not the recent
|
||||
// window — a mint that went quiet a month ago has no recent swaps to fail.
|
||||
const succeeded = dated.filter(({ok}) => ok)
|
||||
|
||||
if (succeeded.length === 0) {
|
||||
if (events.length > 0) warnings.push({code: 'noSuccessfulSwaps'})
|
||||
return warnings
|
||||
}
|
||||
|
||||
// `dated` is sorted oldest first, so the last success is the final entry.
|
||||
const idleDays = Math.floor(daysBetween(succeeded[succeeded.length - 1].at, now))
|
||||
|
||||
if (idleDays > inactiveDays) {
|
||||
warnings.push({code: 'inactive', params: {count: idleDays}})
|
||||
}
|
||||
|
||||
const averageMs = averageDurationMs(succeeded.map(({event}) => event))
|
||||
|
||||
if (typeof averageMs !== 'undefined' && averageMs > slowMs) {
|
||||
warnings.push({code: 'slow', params: {seconds: (averageMs / 1000).toFixed(1)}})
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
/**
|
||||
* Availability summary for `mintUrl`, or undefined when the auditor does not
|
||||
* know this mint / is unreachable / answered something unusable.
|
||||
*
|
||||
* The swap history is a second request and is treated as optional on its own:
|
||||
* losing it degrades the section to the mint's state alone rather than dropping
|
||||
* it entirely.
|
||||
*/
|
||||
export const getMintAuditSummary = async (
|
||||
mintUrl: string,
|
||||
): Promise<MintAuditSummary | undefined> => {
|
||||
if (!mintUrl) return undefined
|
||||
|
||||
const record = await fetchJson<MintAuditRecord>(
|
||||
`/mints/url?url=${encodeURIComponent(mintUrl)}`,
|
||||
)
|
||||
|
||||
// The auditor answers 200 with a body we cannot key off of if it ever
|
||||
// changes shape, so require the id before trusting anything else.
|
||||
if (!record || typeof record.id !== 'number') return undefined
|
||||
|
||||
const events = await fetchJson<SwapEvent[]>(
|
||||
`/swaps/mint/${record.id}?skip=0&limit=${SWAP_SAMPLE_SIZE}`,
|
||||
)
|
||||
|
||||
const state = isAuditState(record.state) ? record.state : 'UNKNOWN'
|
||||
const swapEvents = Array.isArray(events) ? events.filter(Boolean) : []
|
||||
const dated = toHistory(swapEvents)
|
||||
|
||||
const summary: MintAuditSummary = {
|
||||
state,
|
||||
auditUrl: AUDIT_SITE_URL,
|
||||
updatedAt: parseUtcDate(record.updated_at),
|
||||
swap: summarizeSwaps(swapEvents),
|
||||
// Requires the swap history: without it every liveness heuristic would
|
||||
// fire on missing data rather than on a real problem.
|
||||
warnings: Array.isArray(events) ? collectWarnings(state, swapEvents, dated) : [],
|
||||
history: dated.map(({at, ok}) => ({at, ok})),
|
||||
}
|
||||
|
||||
log.trace('[mintAuditService.getMintAuditSummary]', 'Loaded audit summary', {
|
||||
mintUrl,
|
||||
state: summary.state,
|
||||
warnings: summary.warnings.map(w => w.code),
|
||||
})
|
||||
|
||||
return summary
|
||||
}
|
||||
+29
-11
@@ -1,19 +1,37 @@
|
||||
import {lightFormat} from 'date-fns'
|
||||
// import { i18n } from "../i18n"
|
||||
// import en from "date-fns/locale/en-US"
|
||||
import {format, formatDistanceToNow, lightFormat, type Locale} from 'date-fns'
|
||||
// Deep imports, NOT the 'date-fns/locale' barrel: that barrel is every locale
|
||||
// date-fns ships (200+), all of which would land in the bundle to reach four.
|
||||
import {enUS} from 'date-fns/locale/en-US'
|
||||
import {sk} from 'date-fns/locale/sk'
|
||||
import {es} from 'date-fns/locale/es'
|
||||
import {pt} from 'date-fns/locale/pt'
|
||||
import {i18n} from '../i18n/i18n'
|
||||
|
||||
// TODO
|
||||
/** The locales the app is translated into; see src/i18n/i18n.ts. */
|
||||
const dateLocales: Record<string, Locale> = {en: enUS, sk, es, pt}
|
||||
|
||||
/* type Options = Parameters<typeof format>[2]
|
||||
/**
|
||||
* The date-fns locale matching the active app language.
|
||||
*
|
||||
* Without it every date-fns helper renders English, which is only invisible
|
||||
* while the surrounding text is English too — a translated sentence with an
|
||||
* interpolated duration comes out as "Posledná kontrola about 1 hour ago".
|
||||
*
|
||||
* i18n.locale is a bare language code here (set from react-native-localize),
|
||||
* but it is split anyway so an "sk-SK" style tag cannot silently fall back.
|
||||
*/
|
||||
export const getDateLocale = (): Locale =>
|
||||
dateLocales[(i18n.locale ?? 'en').split('-')[0]] ?? enUS
|
||||
|
||||
const getLocale = (): Locale => {
|
||||
const locale = i18n.locale.split("-")[0]
|
||||
// return locale === "ar" ? ar : locale === "ko" ? ko : en
|
||||
return locale
|
||||
/** "about 1 hour ago" / "približne pred hodinou" */
|
||||
export const formatRelativeToNow = function (date: number | Date) {
|
||||
return formatDistanceToNow(date, {addSuffix: true, locale: getDateLocale()})
|
||||
}
|
||||
|
||||
export const formatDate = (date: number | Date, dateFormat?: string, options?: Options) => {
|
||||
const locale = `${i18n.locale}` */
|
||||
/** "21 Jul 20:18", with the month name in the active language. */
|
||||
export const formatDayTime = function (date: number | Date) {
|
||||
return format(date, 'd MMM HH:mm', {locale: getDateLocale()})
|
||||
}
|
||||
|
||||
export const formatDate = function (date: number | Date) {
|
||||
return lightFormat(date, 'dd.MM.yyyy')
|
||||
|
||||
Reference in New Issue
Block a user