/** * The LNURL mint page's Features panel and withdraw-limits cell, as HTML strings. * * The Cashu counterparts are `nutRowsHtml` and `limitsHtml` in mint-state.ts, and the * Fedimint one is `moduleRowsHtml` in fedimint-ui.ts. This is deliberately their twin: * same three-column row, same check, same grey "not published", same classes, so a * reader who has learned to scan one panel can scan the others without relearning them * and there is no second stylesheet. * * Built as strings rather than as markup in the page for the same reason those files * give: two things render this panel — the page at build time from the last probe, and * the live island from a fresh read of the API — and two implementations would drift. * * Where it differs from both, and why: * * - **Three states, not two.** The Fedimint panel has no disabled state because a * `modules` tag says nothing about whether a module is switched on. Here there * genuinely is a third state: a mint can publish `mint` and `melt`, and still be * unable to do either right now because the Lightning node behind it is unreachable. * That is checkable over HTTP, so the row says "unavailable" rather than showing a * tick beside something that would fail if tried. * - **`features` is never rewritten.** The announcement is the operator's claim about * what they built; the probe can only mark a claimed capability unavailable, never * add or remove one. That asymmetry is normative in `docs/KIND-LNURL-MINT.md`, and * `featureStates` in shared/ is where it is enforced. */ import { FEATURE_NAMES_EN, HIGHLIGHT_FEATURES, featureStates, msatToSat, type FeatureState, type HighlightFeature, } from '@cashumints/shared'; import type { Formatters } from '../i18n/format'; const CHECK_ICON = ''; const PAUSE_ICON = ''; function escape(value: string): string { return value .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } /** * A feature's plain-language name in this locale. * * Same rule as `localNutName` and `localModuleName`: the catalog first, `shared`'s * English table second, and the published token last. A mint advertising something this * build has never heard of renders as `quantum-notes` rather than as a guess, which is * still a true label. */ export function localFeatureName(feature: string, f: Formatters): string { const key = `lnurl.feature.${feature}`; if (f.t.has(key)) return f.t(key); return FEATURE_NAMES_EN[feature] ?? feature; } /** * The short mono label in the first column. * * The LUD numbers are spec names and are the same in every language, so they are shown * literally. The note operations have no such name, and the column shows the vocabulary * token itself — which is exactly what a publisher would put in a `features` tag, and so * is the string worth being able to recognise. */ const ROW_TOKENS: Record = { mint: 'mint', melt: 'melt', notes: 'notes', lud16: 'LUD-16', lud21: 'LUD-21', 'signed-notes': 'signed', onion: 'onion', }; /** * The seven named rows. * * `fundingAvailable` is null when nothing has probed the mint yet, which reads as "no * reason to doubt the announcement" rather than as a failure — a mint discovered * minutes ago should not have its capabilities greyed out for not having been checked. */ export function featureRowsHtml( features: readonly string[], fundingAvailable: boolean | null, f: Formatters, ): string { const states = featureStates(features, fundingAvailable); return HIGHLIGHT_FEATURES.map((key: HighlightFeature) => { const state: FeatureState = states[key]; const badge = state === 'ok' ? `${CHECK_ICON}` : state === 'unavailable' ? `${PAUSE_ICON}${escape(f.t('lnurl.features.unavailable'))}` : `${escape(f.t('lnurl.features.notListed'))}`; const classes = state === 'ok' ? 'nut-row' : state === 'unavailable' ? 'nut-row disabled' : 'nut-row off'; return ( `
` + `${escape(ROW_TOKENS[key])}` + `${escape(localFeatureName(key, f))}` + `${badge}
` ); }).join(''); } /** * The inside of the verdict strip's withdraw-limits cell. * * The Cashu cell shows NUT-04's mint limits and splits in two when a direction is * switched off. This one shows the withdraw bounds, which are the numbers that actually * answer a reader's question here: how much can one of this mint's notes be worth? * * A ceiling of zero is not rendered as "0 sat". It is the disabling value — no note this * mint issues can be redeemed for anything — and it gets the struck-through treatment * the Cashu cell gives a disabled direction, with the banner above doing the explaining. */ export function withdrawLimitsHtml( minMsat: number | null | undefined, maxMsat: number | null | undefined, f: Formatters, ): string { const min = msatToSat(minMsat ?? null); const max = msatToSat(maxMsat ?? null); if (min === null && max === null) { return `
${escape(f.t('lnurl.limits.notPublished'))}
`; } if (max === 0) { return ( '
' + '
' + `${escape(f.t('lnurl.limits.withdrawRow'))}` + '' + `${escape(f.t('lnurl.limits.sats', { n: 0 }))}` + `${escape(f.t('lnurl.limits.disabled'))}` + '
' ); } const lines = [ min !== null ? `${escape(f.t('mint.limits.min'))} ${escape(f.t('lnurl.limits.sats', { n: min }))}` : '', max !== null ? `${escape(f.t('mint.limits.max'))} ${escape(f.t('lnurl.limits.sats', { n: max }))}` : '', ].filter(Boolean); return `
${lines.join('
')}
`; } /** * The mint fee, as one line, or null when there is nothing to say. * * A mint that publishes no `Mint fees:` entry is fee-free by spec, and a row reading * "Fee: none" on every one of them would be furniture. Both parts are shown when both * are set, because a flat fee and a percentage answer different questions. */ export function feeText( baseMsat: number | null | undefined, ppm: number | null | undefined, f: Formatters, ): string | null { const base = baseMsat ?? 0; const percentPpm = ppm ?? 0; if (base === 0 && percentPpm === 0) return null; const parts: string[] = []; if (base > 0) parts.push(f.t('lnurl.fee.base', { n: msatToSat(base) ?? 0 })); if (percentPpm > 0) { // ppm to percent, at most three decimals: 100 ppm is 0.01%, and a mint charging // 25_000 ppm is charging 2.5%. Trailing zeros are trimmed so the common cases read // as "0.01%" rather than "0.010%". const percent = (percentPpm / 10_000).toFixed(3).replace(/\.?0+$/, ''); parts.push(f.t('lnurl.fee.percent', { percent })); } return parts.join(' + '); }