19 Commits
Author SHA1 Message Date
Michilis 987d7b2d84 Merge pull request 'Clarify the empty-WEB_ROOT hint to point at cashumints-web.' (#7) from dev into main
Reviewed-on: #7
2026-08-25 04:46:47 +00:00
michilisandCursor 06ba3d35e7 Clarify the empty-WEB_ROOT hint to point at cashumints-web.
A bare pnpm build only fills dist; production needs the publish unit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 06:46:23 +02:00
Michilis 4e22db5261 Merge pull request 'Serve the prerendered site from Node instead of nginx root.' (#6) from dev into main
Reviewed-on: #6
2026-08-25 04:28:30 +00:00
michilisandCursor 79a115be38 Serve the prerendered site from Node instead of nginx root.
Avoids www-data traversing the cashumints tree and keeps rebuilds from blanking a live root.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 06:27:27 +02:00
Michilis 8373853971 Merge pull request 'Dev' (#5) from dev into main
Reviewed-on: #5
2026-08-24 20:36:41 +00:00
michilisandCursor 301679d340 Wire locale catalogs, repair mint terminology, and confirm login with a toast.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 22:35:43 +02:00
michilisandCursor b95aab2bcd Improve write-review flow and require Node 22.18 for native TS stripping.
Gate login, reveal the form after a rating, and wire inline Rate this mint; drop --experimental-strip-types.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 21:52:11 +02:00
Michilis c1f4565bfe Merge pull request 'Dev' (#4) from dev into main
Reviewed-on: #4
2026-08-22 04:00:46 +00:00
michilisandCursor c74c7fc187 Ship LNURL mint pages, indexing UI, and reviews rewrite.
Add lnurl list/detail routes, OG fixtures, i18n strings, and the write/
index client flows so the site surfaces the new mint type end to end.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 03:44:43 +02:00
michilisandCursor 2a9444942b Index, probe, and announce LNURL mints in the API.
Wire discovery and probing for LNURL mints, add rate-limited POST /api/index
for user submissions, and optionally announce confirmed state to relays.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 03:44:35 +02:00
michilisandCursor c97b44018d Add shared LNURL types, indexing helpers, and warnings.
Introduce lnurl as a first-class mint type with probe/announcement fields
and shared helpers the API and web can both rely on.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 03:44:27 +02:00
michilisandCursor 36c01861f5 Document LNURL mint kind and live probe notes.
Capture the NIP kind contract and wire-level NOTES so indexing and probing
can follow observed LNURL mint behavior rather than outdated assumptions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 03:44:20 +02:00
Michilis f138066ee6 Merge pull request 'Add configurable Plausible analytics' (#3) from dev into main
Reviewed-on: #3
2026-08-21 04:11:35 +00:00
michilisandCursor be322cb0d8 Add configurable Plausible analytics
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 06:11:04 +02:00
Michilis 59f9af119a Merge pull request 'Expand i18n locales and improve reviews UI' (#2) from dev into main
Reviewed-on: #2
2026-08-21 03:20:32 +00:00
michilisandCursor 47e6537dde Expand i18n locales and improve reviews UI
Add many new language packs with RTL support, refresh brand assets, and harden review rendering with tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 05:06:58 +02:00
Michilis 87901c56d2 Merge pull request 'Dev' (#1) from dev into main
Reviewed-on: #1
2026-08-21 00:18:58 +00:00
michilisandCursor 1c5df18e81 Improve SEO and social card wiring
Add SearchAction and optional Product JSON-LD, per-page OG images/alts, and document the og build and cache headers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 02:17:35 +02:00
michilis 6f17b572b1 Expand ecash explorer capabilities
Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
2026-08-21 02:10:48 +02:00
160 changed files with 38879 additions and 2027 deletions
+61 -8
View File
@@ -38,16 +38,50 @@ PUBLIC_API_URL=
# Canonical origin for canonical links, OpenGraph tags and the sitemap.
SITE_URL=https://cashumints.space
# ─── API storage ─────────────────────────────────────────────────────────────
# Unset means api/data/, resolved from the source tree regardless of where you run
# from. Set these and they are taken as given: a relative path resolves against the
# working directory (api/ under every pnpm script), so prefer absolute paths. In
# production point both at a directory the service user owns.
# Plausible-compatible analytics script and the domain reported with each page view.
# Leave either value empty to disable analytics.
PLAUSIBLE_URL=https://analytics.azzamo.net/js/script.js
PLAUSIBLE_DOMAIN=cashumints.space
# SQLite file.
# Whether a rated mint's JSON-LD also carries the review-snippet-eligible Product
# type next to Service (web/src/lib/schema.ts explains the trade). On by default;
# set to 0 to ship the plain Service node on the next build, no code change needed.
SEO_PRODUCT_JSONLD=1
# ─── API storage ─────────────────────────────────────────────────────────────
# The API serves everything from its own database and nothing from a mint or a relay at
# request time, so a restart loses nothing: mint metadata, cached /v1/info payloads,
# reviews, probe history and the discovery cursor all live here. Icons live next to it
# as files. In production point both at a directory the service user owns.
# Which database. Unset means SQLite at DB_PATH below, which is what every existing
# deployment has and needs no setup at all. Set it to a postgres:// URL to use Postgres
# instead — put the API on one host and the database on another, run more than one API
# process, or fold the data into an existing backup and replication setup.
#
# Both backends create their tables on the first connection, so an empty database is
# the whole installation. To carry existing data across, in either direction:
#
# pnpm --filter ./api migrate --to postgres://user:pw@localhost:5432/cashumints
#
# then set DATABASE_URL to the same value and restart. Stop the API first: migrating
# from a database that is still being written to copies a moving target.
#
# DATABASE_URL=postgres://cashumints:secret@localhost:5432/cashumints
# DATABASE_URL=sqlite:/var/lib/cashumints/cashumints.db
# SQLite file. Ignored when DATABASE_URL is set. Unset means api/data/, resolved from
# the source tree regardless of where you run from. Set it and it is taken as given: a
# relative path resolves against the working directory (api/ under every pnpm script),
# so prefer an absolute path.
# DB_PATH=/var/lib/cashumints/cashumints.db
# Cached mint icons, served at /icons/*.
# Postgres connections held open. Ignored by SQLite, which has exactly one. The default
# of 10 is well above what one indexer plus the read endpoints need.
# DB_POOL_MAX=10
# Cached mint icons, served at /icons/*. Files, not rows — they are kept here whichever
# database is in use, and `migrate` does not touch them.
# ICON_DIR=/var/lib/cashumints/icons
# ─── Nostr relays ────────────────────────────────────────────────────────────
@@ -99,9 +133,22 @@ DISCOVERY_INTERVAL_MIN=60
# Mints probed in parallel.
PROBE_CONCURRENCY=8
# Per-mint request timeout, milliseconds.
# Per-mint request timeout, milliseconds. Cashu mints only: a federation is not
# fetched directly, see below.
PROBE_TIMEOUT_MS=5000
# ─── Fedimint ────────────────────────────────────────────────────────────────
# A federation has no HTTP status endpoint of its own — confirming one is up means
# being a Fedimint client — so its status is read from an outside checker instead,
# once per probe cycle for every federation at once. Every row whose status came
# from here records that fact and its page prints it, so nothing implies this site
# opened a socket itself.
#
# Empty disables the lookup entirely: every federation then carries the `announced`
# status, which is the honest one for "nothing checks this". It is never inferred
# to be online or offline. See the "Ecosystems" section of README.md.
FEDIMINT_OBSERVER_URL=https://observer.fedimint.org/api/federations
# ─── Ranking ─────────────────────────────────────────────────────────────────
# Bayesian prior mean C. The neutral 3 is intentional — read the "Ranking" section
@@ -109,6 +156,12 @@ PROBE_TIMEOUT_MS=5000
# which is the literal BACKEND.md behaviour and ranks noticeably worse.
SCORE_PRIOR_MEAN=3
# How many mints one address may submit to `POST /api/index` in an hour. Ten is several
# times what any honest use of the review dialog needs; raise it while exercising the
# whole flow end to end, which trips ten in about a minute. Behind a proxy, the limiter
# can only tell visitors apart if `X-Forwarded-For` is forwarded (see README, "API").
# INDEX_RATE_LIMIT=10
# ─── Tooling ─────────────────────────────────────────────────────────────────
# Dev server Boneyard captures against (`pnpm bones`). Defaults to WEB_PORT.
+5
View File
@@ -2,6 +2,11 @@ node_modules/
dist/
.astro/
api/data/
deploy/
*.log
.DS_Store
.env
# generated social images and their manifest (pnpm og); og-fixtures/ stays committed
web/public/og/
web/src/generated/
web/scripts/og/fonts/cache/
+350
View File
@@ -0,0 +1,350 @@
# NOTES-LNURL.md
What the LNURL mint endpoints **actually** return, recorded from the live reference
instance `https://lnurl.21mint.me` on 2026-08-21 and from a locally run
[dni/lnurl-mint](https://github.com/dni/lnurl-mint) (commit `a70cae1`) used to
reproduce the states the live instance does not currently exhibit.
Same rule as NOTES.md: where the software's README and the wire disagree, the wire
wins, and the disagreement is written down rather than quietly resolved.
---
## 1. The probe endpoint: `GET /.well-known/lnurlw/{username}`
**This is the LNURL equivalent of Cashu's `/v1/info`, and it is not the endpoint the
brief expected.** Findings, in the order they mattered:
### `/p` does not exist
The brief (and older readings of the project) describe `GET /p` as the LUD-06
payRequest carrying the `withdrawLink` mint advertisement. It is a **404** on the live
instance:
```
$ curl -i https://lnurl.21mint.me/p
HTTP/2 404
content-type: application/json
{"detail":"Not Found"}
```
The repository's own README says so explicitly — the LUD-16 well-known alias is
"this mint's **only** payRequest entry point (no separate bare `/p`)" — and
`router.py` registers no such route. `/p/cb` (the callback) exists; bare `/p` never
did. Note the body shape: `{"detail":"Not Found"}` with a real 404 status is FastAPI's
*unmatched route* handler. Every **registered** route answers errors as LNURL does
instead (see §5), so this shape is a reliable "this software does not have that
endpoint" signal.
### Both well-known aliases answer, and only one carries the limits
| Endpoint | Carries |
| --- | --- |
| `GET /.well-known/lnurlp/{username}` | LUD-06 payRequest: `minSendable`/`maxSendable`, `metadata`, `withdrawLink` |
| `GET /.well-known/lnurlw/{username}` | LUD-03-shaped withdrawRequest: `minWithdrawable`/`maxWithdrawable`, `defaultDescription`, `mintPubkey`, `payLink`, node identity |
`{username}` is the configured `USERNAME` (default `mint`) **or** the LUD-16 reserved
bare-domain `_`. Both resolve to the identical mint identity; `_` is used as the probe
path because it needs no prior knowledge of the operator's chosen username.
**The chosen primary probe endpoint is `/.well-known/lnurlw/_`**, because it is the one
carrying the withdraw limits, the description, and the mint's node identity — every
field the site renders. `/.well-known/lnurlp/_` is fetched as a **fallback only**, when
the withdraw side does not answer.
### Live response, `/.well-known/lnurlw/_`
```json
{
"tag": "withdrawRequest",
"callback": "https://lnurl.21mint.me/w",
"minWithdrawable": 5000,
"maxWithdrawable": 999899000,
"defaultDescription": "lnurlcash bearer note on lnurl.21mint.me",
"mintPubkey": "021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555",
"payLink": "https://lnurl.21mint.me/.well-known/lnurlp/mint",
"nodeAlias": "Azzamo",
"nodeUri": "021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555@145.239.92.138:9736",
"nodeColor": "#68f442",
"nodeCapacity": 30027500000,
"nodeNumChannels": 8,
"nodeNumPeers": 18
}
```
Headers: `server: nginx/1.22.1`, `content-type: application/json`. **No version header
of any kind**, on this or any other endpoint.
Notes on the fields:
- **All amounts are millisatoshi.** `minWithdrawable: 5000` is 5 sat;
`maxWithdrawable: 999899000` is 999,899 sat. `nodeCapacity` is msat too
(30,027,500 sat here). Divide by 1000 for display.
- `maxWithdrawable` is **fee-adjusted**: it is `MAX_SENDABLE_MSAT` minus the mint fee
at that amount (`router.max_mintable_msat`), not the raw setting. Likewise
`minWithdrawable` is `MIN_MINT_MSAT`. So these two numbers are the bounds a freshly
minted note's *value* can fall into — which is exactly what a reader wants — and not
the bounds of what they must *pay*. The payRequest's `minSendable`/`maxSendable` are
the pay-side numbers, and they differ (11,000 vs 10,000 msat locally, because of the
1000 msat base fee).
- `defaultDescription` is always `"lnurlcash bearer note on {host}"` — generated, never
operator-written. Useful as a description fallback and nothing more; it says what the
software is, not what this mint is.
- **`k1` is absent.** The model declares it `str | None = None`; its absence from the
wire is the first proof that `response_model_exclude_none = True` is in force
(`error_handler.py:26`). That matters for everything in §3.
### Live response, `/.well-known/lnurlp/_`
```json
{
"tag": "payRequest",
"callback": "https://lnurl.21mint.me/p/cb",
"minSendable": 6000,
"maxSendable": 1000000000,
"metadata": "[[\"text/plain\", \"Mint an lnurlcash bearer note on lnurl.21mint.me\"], [\"text/identifier\", \"_@lnurl.21mint.me\"], [\"text/plain\", \"Mint fees: 1000,100\"]]",
"withdrawLink": "https://lnurl.21mint.me/w"
}
```
- `metadata` is a **JSON-encoded string containing a JSON array** of `[mime, value]`
pairs — parse twice.
- `text/identifier` **echoes the username actually queried**. Querying `_` returns
`_@lnurl.21mint.me`; querying `mint` returns `mint@lnurl.21mint.me`. So the probe
cannot read the operator's real username off the `_` response. It reads it off
`payLink` on the withdraw side instead (`…/lnurlp/mint` → username `mint`), which is
built from `settings.username` unconditionally.
- The `Mint fees: <base_msat>,<ppm>` entry is present only when a fee is configured;
its absence means fee-free, per spec.
---
## 2. Every other endpoint, and what it is worth to a probe
| Endpoint | Live result | Verdict |
| --- | --- | --- |
| `GET /` | 200, 13,804 bytes of HTML, `<title>21 lnurl-mint</title>` | Fetched **only** for the onion address (§4) |
| `GET /openapi.json` | 200, `info.version` = `"0.1.0"`, `info.title` = `"lnurl-mint"` | **The only version source there is** (§6) |
| `GET /docs` | 200, Swagger UI | Ignored |
| `GET /w` (no `k1`) | 200 `{"status":"ERROR","reason":"Request validation error: Field required \`k1\`."}` | Ignored — never a mint advertisement |
| `GET /w?k1=deadbeef` | 200 `{"status":"ERROR","reason":"Unknown note."}` | Ignored — per-note, not per-mint |
| `GET /verify/{hash}` | 200 `{"status":"ERROR","reason":"Not found"}` | **Useless as a capability probe** (§5) |
| `GET /p` | 404 `{"detail":"Not Found"}` | Does not exist |
The site probes **`/.well-known/lnurlw/_`, then `/.well-known/lnurlp/_` on failure, and
`GET /openapi.json` + `GET /` opportunistically**. Nothing else is fetched, and nothing
that mutates state is ever called — `/p/cb` would make the mint issue a real invoice on
every probe cycle, which is not a thing a directory gets to do to a stranger's node.
---
## 3. The degraded state: no funding source (**empirically reproduced**)
The README: *"Without one, minting and melting are unavailable (rotate/split/merge of
existing notes still work)."* The question was whether that is visible over HTTP. It is.
`_mint_address_response` (`router.py:480`) only populates the node fields
`if funding_source.backend:`, and wraps the lookup in `try/except` that logs and leaves
them `None` on failure. With `response_model_exclude_none = True`, `None` fields are
**omitted from the wire entirely**.
Run locally with no `FUNDINGSOURCE_*` set at all:
```
$ curl http://127.0.0.1:8137/.well-known/lnurlw/_
{"tag":"withdrawRequest","callback":"https://lnurl.test/w","minWithdrawable":10000,
"maxWithdrawable":999999000,"defaultDescription":"lnurlcash bearer note on lnurl.test",
"payLink":"https://lnurl.test/.well-known/lnurlp/mint"}
```
`mintPubkey`, `nodeAlias`, `nodeUri`, `nodeColor`, `nodeCapacity`, `nodeNumChannels`
and `nodeNumPeers` are **all gone**. The response is otherwise a completely valid
withdrawRequest with real limits — the mint is up, serving, and answering. Its startup
log says the rest out loud:
```
WARNING:root:No funding source configured (FUNDINGSOURCE_BACKEND unset) -
minting, melting, and offline verification are all unavailable.
```
`/.well-known/lnurlp/_` **still answers normally** in this state (200, full payRequest),
so the pay side is *not* a degraded-state signal — only the withdraw side is.
### The detection rule, and the honest thing to say about it
> **`mintPubkey` absent from a valid mint-address response ⇒ no funding source is
> reachable ⇒ minting and melting are unavailable right now.**
One bit, and it necessarily conflates two causes:
1. no funding source was ever configured, and
2. one was configured but the node is unreachable at this moment (`except Exception`
above catches it and returns the same reduced response).
They are **not distinguishable over HTTP**, and the site does not try. It does not need
to: the user-facing consequence is identical in both cases — nothing moves in or out
over Lightning until it comes back — and that is exactly what the warning says. Calling
it "no funding source" specifically would be a guess; calling it "minting and melting
unavailable" is the observation.
The same bit also carries two other meanings, which is worth stating plainly because
three site features read it:
- it is the `signed-notes` capability (`signing.mint_pubkey` needs the same funding
source, and the README ties them: *"without a funding source, both fields are simply
omitted"*), and
- it is the canonical `d` identifier for the Nostr announcement (see
`docs/KIND-LNURL-MINT.md`).
That last one is why the `d` rule has to be sticky: a mint indexed while its node was
up must not silently change identity because its node had a bad minute. See the doc.
---
## 4. Tor / onion: HTML only, and only over clearnet
`ONION_URL` is **never** in any JSON response. It appears in exactly one place: the
one-pager's "Also via Tor" block (`frontend._tor_section`), and only when the request
did *not* arrive over the onion host itself (otherwise `public_base_url` already made
the onion the primary and the block would be a duplicate). A clearnet probe is
therefore the case where it does show.
Reproduced locally with `ONION_URL` set:
```html
<h2>Also via Tor</h2>
<div class="qr">…</div>
<button class="copy" data-copy="LNURL1…" title="Copy LNURL">LNURL1…</button>
<button class="copy" data-copy="mint@abcdefgh….onion" title="Copy lightning address">⚡ mint@…onion</button>
```
Cheaply parseable: scan the `GET /` body for a `*.onion` host. The live instance
advertises none, so `onion` is absent there.
**Caveat worth recording:** the fixture address in lnurl-mint's own tests
(`abcdefghijklmnop1234567890abcdefghijklmnop1234567890abcdefgh.onion`) contains
`0`, `1`, `8` and `9`, which are **not in base32's alphabet** — it is not a valid v3
address. A strict `[a-z2-7]{56}` matcher rejects it. The site's matcher is deliberately
looser (`[a-z0-9]{16,60}\.onion`) so that a real-but-unusual address is still shown;
nothing is fetched over Tor, so a wrong match costs a displayed string and nothing more.
---
## 5. LUD-21 verify is **not** probe-detectable (and why the site does not pretend)
`VERIFY_ENABLED=false` makes `/verify/{payment_hash}` raise `HTTPException(404, "Not
found")`. An *enabled* endpoint asked about a payment hash it has never seen raises
`HTTPException(404, "Not found")` too. Both then pass through
`LnurlErrorResponseHandler`, which converts any `HTTPException` into
`200 {"status": "ERROR", "reason": <detail>}`.
Measured, same binary, both settings:
```
VERIFY_ENABLED=true → 200 {"status":"ERROR","reason":"Not found"}
VERIFY_ENABLED=false → 200 {"status":"ERROR","reason":"Not found"}
```
**Byte-identical.** There is no status code, no header, and no reason string that
separates them.
The only place a `verify` URL is genuinely advertised is the response of `/p/cb` — the
pay callback — and calling that **creates a real Lightning invoice on the operator's
node**. Doing that every ten minutes, forever, to every mint in the index, is not
acceptable behaviour for a directory.
**Resolution:** `lud21` is an **announcement-only** capability. The probe never sets it
and never clears it. If an operator declares it in their `features` tag, the site shows
it; otherwise the row is simply absent. This is written into
`docs/KIND-LNURL-MINT.md` as a normative property of the vocabulary, not left as an
implementation quirk.
---
## 6. Version: `/openapi.json`, and only there
No `Server`, `X-Powered-By` or any other header identifies the software — nginx fronts
it and reports only itself. The one-pager's `<title>` is operator-configurable
(`settings.title`; the live instance sets `"21 lnurl-mint"`), so it identifies nothing
reliably.
FastAPI's generated `GET /openapi.json` carries both:
```json
{"info": {"title": "lnurl-mint",
"description": "Minimal lnurlcash (LUD-25, Lightning bearer assets) mint - LUD-03/LUD-06 only.",
"version": "0.1.0"}}
```
- `info.title` = `"lnurl-mint"` — a genuine software fingerprint, hardcoded in
`server.py`, not operator-settable.
- `info.version` = `__version__`, resolved from installed package metadata
(`lnurl_mint/__init__.py`), falling back to `LNURL_MINT_VERSION` or
`"0.0.0+unknown"`.
Two observed values: the live instance reports `0.1.0`; a source checkout with no
installed metadata reports `0.0.0+unknown`. The site stores `lnurl-mint/<version>`,
and treats a `0.0.0+unknown` as **no version** rather than displaying it — it is the
library's "I don't know" sentinel, not a release.
The live instance still serves `/docs` from a jsdelivr CDN, whereas HEAD (`a70cae1`,
*"report real version in openapi, vendor swagger ui for /docs"*) vendors it. So the
deployed build predates that commit, and its `0.1.0` is the pre-commit hardcoded value.
Recorded because it means **a version string from this endpoint is a weak signal**: it
was not always wired to the real package version.
---
## 7. Error convention, and what "online" must therefore mean
Every **registered** route returns HTTP **200** for its errors, with an LNURL body:
```json
{"status": "ERROR", "reason": "…"}
```
Only an **unregistered path** gives a real 404 (`{"detail":"Not Found"}`).
Consequence, and it is the single most important parsing rule here: **HTTP 200 does not
mean the mint is up.** A probe that stopped at the status code would call every
misconfigured host, every `Unknown user.`, and every wrong-path hit "online".
So the site's online test is on the parsed body, never the status:
- `tag == "withdrawRequest"` **and** numeric `minWithdrawable`/`maxWithdrawable` → online
(or degraded, per §3);
- `tag == "payRequest"` with numeric `minSendable`/`maxSendable` (fallback endpoint) → online;
- anything else that parses as JSON — including `{"status":"ERROR"}` — → **invalid**, a
distinct third outcome that is neither online nor offline. It renders the
"Endpoint responding but invalid" banner, because a host that answers with something
that is not a mint advertisement is a different problem from a host that does not
answer at all.
---
## 8. Summary of what the endpoints expose beyond the withdraw params
Everything below is real, observed, and stored in `ecosystem_json`:
| Field | Source | Used for |
| --- | --- | --- |
| `mintPubkey` | lnurlw | `d` identifier, `signed-notes` feature, degraded detection, sidebar row |
| `payLink` | lnurlw | Deriving the LUD-16 lightning address (`{username}@{host}`) |
| `nodeAlias` | lnurlw | Identity header fallback name |
| `nodeUri` | lnurlw | `pubkey@host:port` — the sidebar's node connect string |
| `nodeColor` | lnurlw | Not rendered (a node's own colour is not this site's palette) |
| `nodeCapacity` | lnurlw | msat, publicly announced channel capacity — sidebar |
| `nodeNumChannels` / `nodeNumPeers` | lnurlw | Sidebar |
| `defaultDescription` | lnurlw | Description fallback |
| `minSendable` / `maxSendable` | lnurlp | Pay-side limits, distinct from withdraw limits |
| `metadata` → `text/plain` | lnurlp | Description fallback, ranked above `defaultDescription` |
| `metadata` → `text/identifier` | lnurlp | Confirms the lightning address |
| `metadata` → `Mint fees:` | lnurlp | Mint fee disclosure (base msat, ppm) |
| `info.version` / `info.title` | openapi | Software cell |
| `*.onion` | `GET /` HTML | `onion` feature, sidebar row |
`nodeCapacity` is explicitly the **publicly announced** capacity only — lnurl-mint
sources it from the public graph (`lnd GetNodeInfo` / `cln listchannels`), never from a
private `listfunds`/`ListChannels` view. Displaying it discloses nothing the node's own
gossip does not already.
+32
View File
@@ -130,6 +130,38 @@ are matched by bare domain (too loose, a review of `mint.minibits.cash/Bitcoin`
`mint.minibits.cash/Anything`). The new backend fixes both directions: one normalizer, path is part
of identity, applied to mints and reviews alike (see `api/src/normalize.ts`).
### The slug is deterministic, not reversible
Found while building on-demand indexing (`POST /api/index`), which is the first thing that
has to turn a *page address* back into a *mint address* rather than the other way round.
`mint.example.com/Bitcoin` slugs to `mint.example.com-bitcoin`, and so would a mint whose
hostname really is `mint.example.com-bitcoin`. Looking a row up is unaffected — both
spellings are stored, and the slug column is what the lookup uses — but *indexing* from a
slug means opening a socket to whatever it decodes to, and guessing wrong there means
probing, and possibly listing, somebody else's server.
So `addressFromSlug` (shared/src/indexing.ts) only derives an address from the one
unambiguous shape: a plain hostname, optionally with the `-3338` port suffix. Anything
else returns null and the 404 page says it cannot work the address out from the link
alone, offering the dialog where a reader can paste the full URL including its path.
The `lnurl-` collision prefix is deliberately *not* stripped there either: a slug only
takes that prefix at insert time, so a link carrying one describes a row that already
exists and never reaches that code, while a hostname that merely begins `lnurl-` is a
real possibility.
Two URL shapes that fought the normalizer while testing this, both now covered by
`api/src/check-index.ts`:
- **The port suffix round trip.** `https://smilemoji.cash:3338` slugs to
`smilemoji.cash-3338`, which reads as a hostname with a trailing number until you know
the rule. The derivation has to put the colon back, and only for a 2 to 5 digit tail.
- **Case, scheme and trailing slash together.** `MINT.600.WTF`, `http://mint.600.wtf`,
`mint.600.wtf/` and `https://mint.600.wtf:443/` are one mint, and the endpoint has to
answer "already indexed" for all four rather than creating a second row. They collapse
in `normalizeMintUrl`, before the row is looked up and before the in-flight map is
keyed, which is what makes the dedup and the lookup agree.
## Old scoring
`src/hooks/usePopularMints.ts`:
+642 -137
View File
@@ -1,26 +1,27 @@
# cashumints.space
A Cashu mint explorer and review site. Lists every mint discoverable on the Nostr network,
shows each mint's live metadata from its own `/v1/info`, and surfaces community reviews
published as NIP-87 events.
An ecash explorer and review site. Lists every Cashu mint and every Fedimint federation
discoverable on the Nostr network, shows each one's metadata, and surfaces community
reviews published as NIP-87 events.
Made by [Azzamo](https://azzamo.net).
```
Nostr relays Cashu mints
(NIP-87 events) (/v1/info)
| |
v discovery (hourly) v probe (10 min)
+----------------------------------------------+
Nostr relays Cashu mints Fedimint federations
(NIP-87 events) (/v1/info) (no status endpoint;
38172 / 38173 / 38000 see "Ecosystems")
| | |
v discovery (hourly) v probe (10 min) v check (10 min)
+--------------------------------------------------------------+
| api/ Node + Hono + SQLite |
| last-known-good metadata, 4 REST endpoints |
+----------------------------------------------+
+--------------------------------------------------------------+
| |
build-time fetch runtime fetch (islands)
v v
+----------------------------------------------+
+--------------------------------------------------------------+
| web/ Astro, static, prerendered |
+----------------------------------------------+
+--------------------------------------------------------------+
|
browser <-> relays (review text, signing via NIP-07 or NIP-46)
```
@@ -38,9 +39,9 @@ this repository.
| Path | What it is |
| --------- | -------------------------------------------------------------- |
| `api/` | Indexer and REST API. Hono, better-sqlite3, nostr-tools. |
| `api/` | Indexer and REST API. Hono, SQLite or Postgres, nostr-tools. |
| `web/` | Astro static site with vanilla TypeScript islands. |
| `shared/` | Types, NIP-87 constants, URL normalization, scoring, NUT names. |
| `shared/` | Types, NIP-87 constants, URL normalization, scoring, NUT and module names. |
The site is in English, Spanish and Dutch. `web/src/i18n/` holds the message catalogs
and the locale table; `web/src/i18n/GLOSSARY.md` holds the term decisions a translator
@@ -51,7 +52,7 @@ rating encoding, and the bugs this rebuild fixes.
## Requirements
- Node 20 or newer (uses `--experimental-strip-types`, so no build step for the API)
- Node 22.18 or newer (native TypeScript type stripping, so no build step for the API)
- pnpm 9 or newer
## Setup
@@ -76,6 +77,68 @@ the relays, and prints a summary table. Takes about a minute against the live ne
pnpm seed
```
## Database
Everything the API serves is read from its own database, never from a mint or a relay at
request time: mint metadata, `/v1/info` payloads, reviews, probe history and the
discovery cursor. That is what lets a mint page render in full while the mint is offline,
and it is why a restart loses nothing — the process keeps no state of its own beyond a
60 second cache in front of `/api/stats`. Icons are files under `ICON_DIR` and survive
alongside it.
Two backends, chosen by `DATABASE_URL`:
| `DATABASE_URL` | Backend |
| ----------------------------------------- | -------------------------------------- |
| unset | SQLite at `DB_PATH`. The default. |
| `postgres://user:pw@host:5432/cashumints` | Postgres |
| `sqlite:/var/lib/cashumints/cashumints.db`| SQLite at that path |
SQLite is the right answer for a single API process, which is the shape this service has:
one indexer, one writer, reads served from the page cache. Reach for Postgres when you
need something SQLite cannot give you — the database on a different host from the API,
more than one API process, or your existing backup and replication setup.
Neither needs a setup step. Both create their tables on first connection, so pointing the
API at an empty Postgres database is the whole installation:
```bash
createdb cashumints
```
**Both backends run the same SQL.** One statement is written once and sent to either, so
there is no dialect-specific query path to drift. `pnpm --filter ./api test` runs the
review-dedupe statement against a real database, and `CHECK_DB_URL` runs it against
Postgres too. See the header of `api/src/db-schema.ts` for the rules that keep a
statement portable.
### Moving between them
`migrate` copies every table from one database to the other. `--from` defaults to
whatever the current configuration points at, so the usual direction needs only `--to`:
```bash
pnpm --filter ./api migrate --to postgres://user:pw@localhost:5432/cashumints
```
Then set `DATABASE_URL` to the same value and restart the API. It works in both
directions and between two databases of the same kind:
```bash
pnpm --filter ./api migrate --from postgres://localhost/cashumints --to ./data/cashumints.db
```
Rows are upserted on their primary key, so an interrupted run can just be repeated, and
the migrator reads the counts back from the target and fails if any table came up short.
`probes` is the exception — an append-only log with no unique key, so a target that
already has probe rows is refused unless you pass `--force`, which replaces them. Add
`--dry-run` to see the row counts without writing anything.
Migrating while the API is running will copy a moving target. Stop it first.
Icons are files, not rows: `migrate` does not touch `ICON_DIR`, so copy that directory
yourself if the new database lives on a different host.
## Development
Runs the API on `:8787` and the Astro dev server on `:4321`. Both ports come from
@@ -147,7 +210,7 @@ ships at `/fonts/OFL.txt`.
They sit in `src/assets/` rather than `public/` so the build fingerprints them into
`/_astro/`, which is what makes `Cache-Control: immutable` honest and puts them under a
cache rule the nginx config already has. Unhashed and uncached they are re-fetched on
cache rule `web/server.mjs` already has. Unhashed and uncached they are re-fetched on
every client-side navigation — the router re-inserts their preload links on each swap —
and the typefaces visibly reload from page to page.
@@ -189,6 +252,28 @@ cached metadata and reviews:
pnpm --filter ./api test:offline
```
The copy is made with the migrator, so the check runs against whichever backend holds the
real data and exercises the migration path every time. Point `CHECK_DB_URL` at a scratch
Postgres database to run the whole thing there — it is emptied first, so give it one of
its own:
```bash
CHECK_DB_URL=postgres://localhost/cashumints_test pnpm --filter ./api test:offline
```
`CHECK_DB_URL` does the same for `pnpm --filter ./api test`, which then runs the
review-dedupe SQL against both backends instead of just SQLite.
On-demand indexing. The address rules that keep `POST /api/index` from being an SSRF
hole, the redirect hops, the slug collapse that stops one mint becoming two rows, the
invite-code decoder and the rate limiter. Nothing here touches the network: the resolver
and the fetch are both injected, because a rule that can only be exercised against the
real internet stops being exercised the first time CI runs offline.
```bash
pnpm --filter ./api test:index
```
Type checking across the workspace:
```bash
@@ -207,17 +292,48 @@ OpenGraph and Twitter tags, a social card, a self-referencing canonical, a full
set and a JSON-LD graph. `sitemap.xml` lists every indexable one with its `xhtml:link`
alternates, and `robots.txt` sits beside it.
### Social cards
`pnpm build` starts with `pnpm og` (`web/scripts/og/build-og.mjs`), which renders one
1200x630 PNG per mint and federation into `web/public/og/` from the same API data the
pages use — satori lays the card out and turns the text into glyph paths (the six
static TTFs under `web/scripts/og/fonts/` are the same faces the site uses),
`@resvg/resvg-js` rasterises it. No browser involved; a full run of ~70 cards takes a
few seconds, and a rerun with unchanged data renders nothing: each card's inputs are
hashed into its filename (`mint.example.com.a1b2c3d4e5.png`) and
`web/src/generated/og-manifest.json` records what is already on disk. The hashed name
is also the cache-busting story — link-preview scrapers cache an `og:image` by URL, so
a mint whose rating moved or that went offline gets a new URL on the next scheduled
rebuild, while the stable-named `default.png` (brand plus network stats, used by every
non-mint page) is served with a one-hour cache instead — the one exception in
`cacheControl()` in `web/server.mjs`.
The images are one English render shared by every locale; titles, descriptions and alt
text translate per page. Relative times stay out of the PNGs on purpose — a "3d ago"
would go stale inside a static file — so the cards carry absolute month/year stamps,
and the one exception, the "Offline {n}d" chip, is derived from a day-granular clock so
it regenerates at most once a day.
`pnpm og:fixtures` renders the six edge cases in `web/scripts/og/fixtures.mjs`
(30-character name, no icon, zero reviews, offline, melt only, announced-only
federation) into `web/og-fixtures/` at a pinned timestamp; those snapshots are
committed, so eyeball them after any template change.
### What is indexed, and what is not
One rule, in `web/src/lib/seo.ts`, decides it: a mint page is left out of the index when
the site has **never once reached** that mint **and** nobody has reviewed it. Such a page
has no name, no description, no version and no reviews, because all of those come from a
mint that answered or a person who wrote something — so every one of them is the same
page as the next. It stays listed on `/mints`, stays linked, stays searchable on the site
and stays reviewable; it just carries `noindex, follow` and is absent from the sitemap,
until the mint answers once or someone reviews it.
One rule, in `web/src/lib/seo.ts`, decides it, and it covers both ecosystems: a page is
left out of the index when the site has **never once reached** the thing it is about
**and** nobody has reviewed it. Such a page has no name, no description, no version and
no reviews, because all of those come from a mint that answered, a federation a check
confirmed, or a person who wrote something — so every one of them is the same page as the
next. It stays listed on `/mints` or `/fedimints`, stays linked, stays searchable on the
site and stays reviewable; it just carries `noindex, follow` and is absent from the
sitemap, until something answers or someone reviews it.
The mint page and the sitemap import that one predicate rather than each testing for it,
That rule is why an announced-only federation is usually unindexed: nothing has confirmed
it, so until it collects a review its page says no more than the announcement did.
The two detail pages and the sitemap import that one predicate rather than each testing for it,
and `pnpm check:hreflang` verifies from the built output that they still agree — a page
saying `noindex` while the sitemap advertises it is a contradiction, and it fails the
build.
@@ -260,8 +376,31 @@ Both gate a deploy. See [Languages](#languages) for what they look for.
## Languages
English, Spanish and Dutch. English is the site as it was; the other two are the same
site, prerendered again.
Twenty-four, listed in `web/src/i18n/config.ts`. English is the site as it was; the other
twenty-three are the same site, prerendered again, 84 pages each.
English, Spanish and Dutch are hand-written. The other twenty-one began as bulk machine
translation, and they are in two states.
**German, Danish, Swedish, Indonesian, Vietnamese and Turkish** have had a full
terminology pass: every string that names a mint, and every label, title and meta
description on the home page, the mint list and a mint page. The machine had translated
the site's central noun into the local word for a coin factory (`Münzprägeanstalt`,
`mincovna`, `darphane`) or, in the Germanic languages, into the *sweet*: Danish and
Swedish shipped `Cashu-pastiller`, "Cashu lozenges", and Swedish offered to sort your
`minttabletter`. Indonesian had `permen`, Vietnamese `cây mint`, the mint plant. Those
six now say `mint`, and `sats`, `ecash` and `Lightning` survive untranslated in them as
the glossary requires.
**The other fifteen** still carry that damage in their body copy, in the same shapes:
Czech `mincovny`, Polish `mennice`, Romanian `monetării`, Greek `νομισματοκοπείο`. Their
chrome, titles and meta descriptions are repaired, so what a reader meets first is right,
but the prose underneath is not. Roughly 400 strings, concentrated in the four inflected
languages where a word-level fix needs case-correct edits inside running sentences, which
is a native speaker's job rather than a careful search and replace.
`web/src/i18n/GLOSSARY.md` is where a pass starts, and the house rules at the top of it
hold in every language, particularly the first row of the table.
### Routing
@@ -289,9 +428,9 @@ opened the English page is not overruled by a browser setting they configured ye
The language control in the header is how you change language, and it lands on the page
you were already reading.
One file per route emits all three: pages live under `web/src/pages/[...locale]/`, and
One file per route emits every locale: pages live under `web/src/pages/[...locale]/`, and
`localePaths()` in `web/src/i18n/paths.ts` is their `getStaticPaths`. There is one copy
of each page's markup, translated by `t()`, rather than three copies to keep in step.
of each page's markup, translated by `t()`, rather than twenty-four to keep in step.
### What is and is not translated
@@ -310,9 +449,10 @@ Never translated, because it is data rather than copy:
A mint's own `description` is the first sentence of its page's meta description, verbatim.
Only when a mint has published none does the site write that sentence itself.
### Adding a fourth language
### Adding a language
Four steps, and the build tells you if you miss one.
Five steps. Four of them the build tells you about; the fifth it cannot, which is why it
has a test of its own.
1. **`web/src/i18n/GLOSSARY.md`** — add the column and decide the terms first. This is
the step people skip, and it is the one that costs later: a reader who meets two words
@@ -338,6 +478,15 @@ Four steps, and the build tells you if you miss one.
TypeScript exists. `check-i18n` compares the two lists and fails if they disagree, so
forgetting this is a build error rather than a mystery.
5. **`web/src/i18n/index.ts`** — `import pt from './pt.json'` and add `pt: pt as Catalog`
to `CATALOGS`. This is the step to get right, because it is the only one nothing
downstream complains about: `catalogFor()` falls back to English by design, so a
locale listed in `LOCALES` with no catalog behind it does not fail the build. It
prerenders the whole English site under the new `lang`, the new URL prefix and a full
reciprocal hreflang set, and tells every crawler those pages are a different language.
Twenty-one locales shipped in exactly that state once. `pnpm test` now fails on it:
see `web/test/i18n-wiring.test.mjs`.
That is the whole change. The Astro i18n config, the routes, the switcher, the hreflang
sets, the `og:locale:alternate` list and the sitemap all read from `LOCALES` and pick the
new language up on the next build.
@@ -390,7 +539,7 @@ English. Islands read it through `useI18n()` in `web/src/i18n/client.ts`.
The alternative, importing `es.json` from an island, does not work here: island chunks
are shared across every locale, so anything imported into one is downloaded by all of
them. A dynamic `import()` keyed on locale would avoid that but costs a round trip on the
critical path of every island and leaves all three catalogs sitting in `dist/_astro/`.
critical path of every island and leaves every catalog sitting in `dist/_astro/`.
Inlining travels in HTML the page was sending anyway.
It is in the body rather than the head because the view transition router replaces the
@@ -421,23 +570,29 @@ so a systemd `Environment=` line or a one-off `PORT=9000 pnpm dev:api` still ove
| Variable | Default | Meaning |
| ------------------------ | --------------------------- | ---------------------------------------------------- |
| `PORT` | `8787` | HTTP port |
| `DB_PATH` | `api/data/cashumints.db` | SQLite file |
| `DATABASE_URL` | empty (SQLite at `DB_PATH`) | `postgres://…` or `sqlite:…`. See [Database](#database). |
| `DB_PATH` | `api/data/cashumints.db` | SQLite file. Ignored when `DATABASE_URL` is set. |
| `DB_POOL_MAX` | `10` | Postgres connections held open. Unused by SQLite. |
| `ICON_DIR` | `api/data/icons` | Cached mint icons, served at `/icons/*` |
| `RELAYS` | see `shared/src/nostr.ts` | Comma separated relay list |
| `PROBE_INTERVAL_MIN` | `10` | Minutes between probe cycles |
| `DISCOVERY_INTERVAL_MIN` | `60` | Minutes between discovery cycles |
| `PROBE_CONCURRENCY` | `8` | Mints probed in parallel |
| `PROBE_TIMEOUT_MS` | `5000` | Per-mint request timeout |
| `FEDIMINT_OBSERVER_URL` | `https://observer.fedimint.org/api/federations` | Where federation health is read from. Empty disables the lookup, and every federation stays `announced`. See [Ecosystems](#ecosystems). |
| `SCORE_PRIOR_MEAN` | `3` | Bayesian prior. See "Ranking" below before changing. |
| `INDEX_RATE_LIMIT` | `10` | `POST /api/index` submissions allowed per address per hour |
### Web (`web/`)
| Variable | Default | Meaning |
| ---------------- | -------------------------- | --------------------------------------------- |
| ------------------ | ------------------------------------------------- | --------------------------------------------- |
| `WEB_PORT` | `4321` | Dev server and preview port |
| `API_URL` | `http://127.0.0.1:8787` | API origin used at build time |
| `PUBLIC_API_URL` | empty (same origin) | API origin baked into markup and islands |
| `SITE_URL` | `https://cashumints.space` | Canonical origin for meta tags |
| `PLAUSIBLE_URL` | `https://analytics.azzamo.net/js/script.js` | Analytics script URL; empty disables analytics |
| `PLAUSIBLE_DOMAIN` | `cashumints.space` | Domain reported to analytics; empty disables analytics |
| `RELAYS` | see `shared/src/nostr.ts` | Relays for the build-time review fetch |
| `BONES_URL` | `http://localhost:$WEB_PORT` | Dev server `pnpm bones` captures against |
@@ -447,8 +602,8 @@ so a systemd `Environment=` line or a one-off `PORT=9000 pnpm dev:api` still ove
`PUBLIC_API_URL` deliberately does **not** fall back to `API_URL`: that is a build-machine
address, and a visitor's browser cannot reach `127.0.0.1`. Left empty, icon `src`
attributes and island fetches are same-origin paths (`/icons/...`, `/api/...`), which the
dev server proxies to `API_URL` and nginx forwards in production — see "Static site behind
nginx" below.
dev server proxies to `API_URL` and nginx forwards in production — see "nginx" under
Deployment below.
Set it only when the API answers on its own origin:
@@ -456,19 +611,155 @@ Set it only when the API answers on its own origin:
API_URL=http://127.0.0.1:8787 PUBLIC_API_URL=https://api.cashumints.space pnpm build
```
## Ecosystems
Two things are listed: **Cashu mints** at `/mints` and `/mint/{host}`, and **Fedimint
federations** at `/fedimints` and `/fedimint/{slug}`. They share one table, one review
pipeline, one review card and one write-review dialog; they differ in how they are
discovered, how they are checked, and what their page can honestly say.
| | Cashu | Fedimint |
| --- | --- | --- |
| `mints.type` | `cashu` | `fedimint` |
| Announcement | `kind:38172` | `kind:38173` |
| Review `k` tag | `38172` | `38173` |
| Identity (`d`) | the pubkey from `/v1/info` | the federation id |
| Address (`u`) | the mint URL | the invite code (`fed11…`) |
| Row key (`mints.url`) | the mint URL | `fedimint:<federation id>` |
| Routing slug | the hostname | `fed-` + the first 16 characters of the id |
| Check | `GET {url}/v1/info`, every 10 min | see below |
| Capability panel | supported NUTs, from `/v1/info` | modules, from the announcement |
| Statuses | online / degraded / offline / unknown | online / offline / **announced** |
### Checking a federation
A Cashu mint answers `GET /v1/info` over HTTPS, which is why probing one is fifteen
lines. A federation has no such endpoint: its guardians speak a JSON-RPC dialect over
websockets, their addresses are bech32m-encoded inside the invite code, and confirming
one is up means being a Fedimint client — decoding the code, opening sockets to a quorum
and agreeing a consensus session. Half of that would produce a status less trustworthy
than saying nothing.
So the federation check reads [fedimint.observer](https://observer.fedimint.org), which
already keeps those client connections open and publishes the result at
`/api/federations` as `{ id, name, invite, health }`. Two consequences, and the site
carries both rather than hiding them:
- **It is somebody else's check.** A federation whose status came from there stores
`status_source: "fedimint.observer"` and its page prints that beside the status, so
nothing implies this site opened a socket itself. Point `FEDIMINT_OBSERVER_URL`
somewhere else, or set it empty to disable the lookup entirely.
- **It does not cover everything.** A federation the observer does not track gets the
`announced` status: Nostr says it exists, nothing says it runs. It is never `online`,
never `offline`, has no `last_online`, no uptime figure and no sparkline. `announced`
sorts between the confirmed-up rows and the confirmed-down ones, because not knowing
is not the same as knowing otherwise.
When the observer itself is unreachable, nothing is written at all: a federation
confirmed up an hour ago is not demoted because a third party had a bad minute.
### What a federation page will not say
There is no Fedimint counterpart to the "melt only", "withdrawals disabled" or "mint
frozen" banners, and `shared/src/warnings.ts` cannot produce one. Those are read out of a
mint's own NUT-04 and NUT-05 switches; a federation's `modules` tag says which parts it
runs and nothing about whether any of them is accepting deposits, so the Modules panel
lists them and stops there. A federation gets two banners at most: a real check reported
its guardians down, or it has been announced for a month and nothing has ever confirmed
it.
### Adding a third ecosystem
The extension points, in the order you would touch them. Nothing in the review pipeline,
the card, the dialog or the feed needs an edit: they are already driven by the values
below rather than by a test for Cashu.
1. **A `type` value and its announcement kind.** One entry in `ANNOUNCEMENT_KINDS`
(`shared/src/nostr.ts`). That alone puts the kind on discovery's subscription list,
teaches the review resolver and the `/reviews` feed which `k` tag belongs to it, and
adds its pill to the ecosystem filter. `mints.type` is TEXT with no CHECK constraint,
so no migration is involved.
2. **Discovery: how to read its announcement.** A parser beside
`parseFedimintAnnouncement` and an `upsert…` beside `upsertFedimint`, called from
`runDiscovery`. Whatever is type-specific goes in `ecosystem_json`, which
`GET /api/mints/:host` spreads across the detail payload; the shared columns
(`name`, `description`, `icon_url`, `status`) are filled the same way for everyone.
3. **A probe strategy.** A branch in `probeAll` (`api/src/probe.ts`) and a checker beside
`fedimint-observer.ts`. If there is no reliable public check, use a pseudo-status like
`announced` and record why — do not infer a status from the announcement.
4. **Pages.** A list page and a detail page under `web/src/pages/[...locale]/`, a
`…Subject()` builder in `web/src/lib/review-subject.ts` (which is what makes the
reviews panel work unchanged), a route in `targetPath` (`web/src/lib/feed-resolve.ts`),
nav entries in `Topbar.astro` and `Footer.astro`, and sitemap entries in
`src/pages/sitemap.xml.ts`.
5. **Copy.** A namespace in `en.json` and in every other catalog, the namespace added to
`CLIENT_NAMESPACES` if an island renders any of it, and a row in
`web/src/i18n/GLOSSARY.md` for every term the ecosystem introduces. `pnpm check:i18n`
warns per locale until every catalog has the keys; until then they fall back to
English.
Not in scope, deliberately: **LNURL**. Nothing in the routes, event kinds, schema values
or copy refers to it, and it is planned as a later stage rather than half-built now.
## API
Four endpoints, CORS open, no auth.
Five endpoints, CORS open, no auth. Four read; the fifth writes.
| Endpoint | Notes |
| -------------------- | ------------------------------------------------------------------ |
| `GET /api/health` | Never cached. 503 when probes are stale or discovery failed. |
| `GET /api/stats` | Network counters, memoized 60s in process. |
| `GET /api/mints` | All mints, online first then score descending. Optional `?limit=`. |
| `GET /api/mints/:host` | One mint plus info, NUTs, distribution, uptime and probe history. |
| `GET /api/mints` | Everything listed, online first then score descending. `?limit=`, `?type=`. |
| `GET /api/mints/:host` | One listing plus its ecosystem's own fields, distribution, uptime and probe history. |
| `POST /api/index` | Index a mint nobody has announced yet. Rate limited. See below. |
`/icons/*` serves the cached mint icons.
### Indexing on demand
```bash
curl -X POST https://cashumints.space/api/index \
-H 'content-type: application/json' \
-d '{"type":"lnurl","input":"mint.600.wtf"}'
```
`type` is `cashu`, `fedimint` or `lnurl`; `input` is a URL, or an invite code for a
federation. The site itself calls this from two places: the 404 page, when somebody opens
`/mint/…`, `/fedimint/…` or `/lnurl-mint/…` for something this build has never heard of,
and the "Write a review" dialog on the three index pages.
What it does, in order, stopping at the first step that settles it:
1. **Already indexed** — `200` with the ordinary `GET /api/mints/:host` payload plus
`"existing": true`. Nothing is probed; a submission is not a reason to re-probe.
2. **Answers as what it claims** — one request, the standard `PROBE_TIMEOUT_MS`. The row
is written by the same functions the probe cycle uses, so it is indistinguishable from
one the loop created. `201`, same payload shape, plus `"indexed_from": "probe"`.
3. **Answers as something else** — `422` with `error: "wrong_type"` and `detected_type`,
which is what lets the dialog offer "this is a Cashu mint, review it there" as a
button. A federation's invite code is decoded rather than fetched: there is no
endpoint to probe.
4. **Nothing answers** — one bounded relay lookup (3s) for a NIP-87 announcement or any
review naming the address. Found: the row is written from the announcement with
`status = offline`, which is the mint that rugged last week and is exactly the one
somebody wants to review. Nothing anywhere: `404`, `error: "unverifiable"`.
Everything indexed this way is an ordinary row afterwards: the probe loop owns it from
the next cycle.
Because it fetches an address a stranger chose, it is hardened accordingly (`api/src/safe-fetch.ts`):
https only, DNS resolved and every returned address checked against the private, loopback,
link-local, CGNAT and multicast ranges *before* a socket opens, redirects followed by hand
and capped at two with every hop re-checked, a 256KB response cap, and a per-IP limit of
`INDEX_RATE_LIMIT` an hour with a clean `429`. Two simultaneous submissions of one address
share a single probe. `pnpm --filter ./api test:index` covers all of it without a network.
The limit needs to be able to tell two visitors apart, which is what `include proxy_params;`
on every proxied location in the nginx block below is for — it is what sets
`X-Forwarded-For`, and without it every submission arrives from the loopback peer and
shares one budget. The last entry of that header is the one used, because it is the one
the proxy vouched for; the header is ignored entirely when the peer is not loopback.
### Ranking
Bayesian weighted rating:
@@ -491,175 +782,368 @@ literal specified behaviour. `shared/src/score.ts` carries the arithmetic, and
## Deployment
### API as a systemd unit
Three units and an nginx block. Everything this project runs listens on loopback and
runs as the same unprivileged user; nginx terminates TLS and proxies to it, and opens no
file belonging to the project.
```
:443 nginx ──► 127.0.0.1:8789 cashumints-site.service the prerendered site
└─► 127.0.0.1:8788 cashumints.service /api/* and /icons/*
cashumints-web.service oneshot: build, then publish to
/var/lib/cashumints/web
```
nginx used to point a `root` at `web/dist` instead of proxying. That is the arrangement
to avoid, and the reason is worth stating because the failure mode is so quiet: nginx
runs as `www-data`, everything here runs as `cashumints`, and serving files across that
boundary requires every directory from `/` down to `dist` to be traversable by a user
with no other business in the tree. A home directory left at its default `0700` breaks
the whole site, and `try_files` reports a permission error as a plain miss — so the
symptom is a blanket 404, or an internal-redirect loop that ends in a 500, with the
cause named nowhere. Proxying removes the boundary: the process that serves the files
is the one that built them.
### Node
The API and the site server both run TypeScript and ESM directly, with no build step, so
**systemd's node must be 22.18 or newer** — that is the release where native type
stripping stopped needing a flag. This is not the same question as `node -v` in your
shell: a version manager puts its node on the interactive `PATH` only, while systemd
resolves the absolute path in `ExecStart`. Check the one that matters:
```bash
/usr/bin/node --version
```
On Node 20 the API exits immediately with `ERR_UNKNOWN_FILE_EXTENSION` for `.ts` and
restarts forever. Install Node system-wide rather than pointing `ExecStart` at a version
manager's path, which breaks at the next upgrade and is invisible to `ProtectHome`.
### The API
```ini
# /etc/systemd/system/cashumints-api.service
# /etc/systemd/system/cashumints.service
[Unit]
Description=cashumints.space indexer and API
After=network-online.target
Wants=network-online.target
After=network-online.target
# Stop after five failures in a minute rather than restarting forever: a process that
# cannot start will not start on the 4000th attempt either, and `failed` in
# `systemctl status` is a louder signal than a journal scrolling past. Both keys belong
# to [Unit] — under [Service] systemd only warns and ignores them.
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=simple
User=cashumints
WorkingDirectory=/srv/cashumints/api
ExecStart=/usr/bin/node --experimental-strip-types src/index.ts
Group=cashumints
WorkingDirectory=/home/cashumints/CashuMints.space/api
# StateDirectory creates /var/lib/cashumints with the service user's ownership.
StateDirectory=cashumints
Environment=NODE_ENV=production
Environment=PORT=8787
Environment=PORT=8788
Environment=DB_PATH=/var/lib/cashumints/cashumints.db
Environment=ICON_DIR=/var/lib/cashumints/icons
Restart=always
RestartSec=5
# For Postgres, replace DB_PATH with DATABASE_URL and add After=postgresql.service.
# Keep ICON_DIR either way: cached icons are files, not rows.
ExecStart=/usr/bin/node --env-file-if-exists=../.env src/index.ts
Restart=on-failure
RestartSec=5s
# The process finishes its in-flight probe batch and closes the database on SIGTERM.
KillSignal=SIGTERM
TimeoutStopSec=30
TimeoutStopSec=30s
UMask=0027
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectHome=read-only
ReadWritePaths=/var/lib/cashumints
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable --now cashumints-api
`Environment=` in the unit and `--env-file-if-exists=../.env` are not interchangeable.
Anything set in the unit wins, so a value that must not drift with an edit to `.env`
belongs in the unit; a secret belongs in `.env`, which is not world-readable.
### The site server
`web/server.mjs` serves the prerendered tree and nothing else — no template, no database
handle, no route table. It is a few hundred lines of `node:http` with no dependencies,
and `pnpm --filter ./web test` covers what it has to get right: a miss answers 404
rather than 200 with a 404-shaped body, a miss under a locale stays in that locale,
hashed assets are immutable and markup is not, and nothing outside the root is readable
however the path is spelled.
Two behaviours are worth knowing about because they are load-bearing:
- **It refuses to start on a root with no `index.html`.** The failure that prevents is a
process that starts cleanly, answers every request with 404 and looks healthy to
anything watching the port.
- **It does not serve `web/dist`.** `astro build` empties `dist` before it writes, so
serving it directly means a rebuild takes the site down for the length of the build.
It serves the published copy at `WEB_ROOT` instead.
```ini
# /etc/systemd/system/cashumints-site.service
[Unit]
Description=cashumints.space static site server
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=5
# Not Requires=cashumints.service: the pages are prerendered, so the site keeps serving
# a correct-as-of-last-build copy while the API is down. Only the islands go quiet.
[Service]
Type=simple
User=cashumints
Group=cashumints
WorkingDirectory=/home/cashumints/CashuMints.space/web
StateDirectory=cashumints
Environment=NODE_ENV=production
Environment=SITE_PORT=8789
Environment=SITE_HOST=127.0.0.1
Environment=WEB_ROOT=/var/lib/cashumints/web
ExecStart=/usr/bin/node server.mjs
Restart=on-failure
RestartSec=5s
KillSignal=SIGTERM
TimeoutStopSec=15s
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
# Read-only rather than absent: server.mjs itself lives under /home/cashumints.
ProtectHome=read-only
ReadWritePaths=/var/lib/cashumints
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictSUIDSGID=true
LockPersonality=true
[Install]
WantedBy=multi-user.target
```
### Static site behind nginx
### nginx
```nginx
server {
listen 443 ssl http2;
server_name cashumints.space;
root /srv/cashumints/web;
index index.html;
# Astro emits directory-style routes, so try the directory index before 404.
location / {
try_files $uri $uri/ $uri.html /404.html;
}
# A miss under a language prefix answers in that language. Without these two blocks
# every 404 is the English one, including the ones a Spanish reader reaches by
# following a Spanish link, and the language control on it would take them to a page
# they were not on.
location /es/ {
try_files $uri $uri/ $uri.html /es/404/index.html;
}
location /nl/ {
try_files $uri $uri/ $uri.html /nl/404/index.html;
}
# Fingerprinted assets are immutable. This covers the CSS, the island bundles and the
# webfonts, which are all emitted here with a content hash in the name. Serving the
# fonts without it is visible, not theoretical: the view transition router re-inserts
# the font preload links on every navigation, so an uncached font is re-fetched on
# each page change and the typefaces flicker as they reload.
location /_astro/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# The social card, the icons and the manifest. Not fingerprinted, since their names
# are referenced from outside the site, so a week with revalidation rather than a
# year of immutability.
location ~* ^/(og\.png|favicon\.(ico|svg)|apple-touch-icon\.png|icon-\d+\.png|site\.webmanifest)$ {
expires 7d;
add_header Cache-Control "public";
}
# Same-origin API and icons, matching the default empty PUBLIC_API_URL. Drop these two
# blocks only if you build with PUBLIC_API_URL pointing at a separate API host.
location /api/ {
proxy_pass http://127.0.0.1:8787;
}
location /icons/ {
proxy_pass http://127.0.0.1:8787;
expires 1d;
}
error_page 404 /404.html;
}
```
Add a `location` block per language when you add one. The 404 is also the client-side
fallback for a mint discovered since the last build, and it reads the locale off its own
URL, so `/es/mint/some-new-mint` resolves that mint and renders its summary in Spanish.
### API behind nginx, with a micro-cache
`/api/mints` and `/api/stats` change at most every few minutes but can be requested by
every visitor at once. A short micro-cache absorbs that without making the data stale.
`/api/health` is deliberately excluded: it is the endpoint you page on.
The whole public surface. Two upstreams, no `root`, no `try_files`, no `error_page`: a
miss is the site server's 404 page, in the right language and with a 404 status, and an
nginx error page here would replace it with a blank one and hide which upstream failed.
```nginx
proxy_cache_path /var/cache/nginx/cashumints levels=1:2 keys_zone=cashumints:10m
max_size=256m inactive=10m use_temp_path=off;
# Keep a few connections open to each upstream rather than reconnecting per request.
# Both processes hold idle sockets longer than nginx does, so nginx is always the side
# that closes — the reverse of that race is what produces sporadic 502s under load.
upstream cashumints_site { server 127.0.0.1:8789; keepalive 16; }
upstream cashumints_api { server 127.0.0.1:8788; keepalive 8; }
server {
listen 443 ssl http2;
server_name api.cashumints.space;
listen [::]:443 ssl http2;
server_name cashumints.space;
location /api/health {
proxy_pass http://127.0.0.1:8787;
proxy_cache off;
add_header Cache-Control "no-store" always;
ssl_certificate /etc/letsencrypt/live/cashumints.space/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cashumints.space/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Set here because this is the only part of the stack that knows a request arrived
# over TLS. Note that add_header replaces rather than merges: any location declaring
# its own add_header drops this one and has to restate it.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# The upstreams send no Content-Encoding, so compression is nginx's to do.
# gzip_proxied any is required — without it nginx will not compress a proxied response
# at all. The home page goes out at a fifth of its size.
gzip on;
gzip_proxied any;
gzip_vary on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types text/plain text/css text/javascript application/javascript application/json
application/manifest+json application/xml image/svg+xml;
# Nothing here accepts an upload; the API's largest body is an 8 KB JSON submission.
client_max_body_size 16k;
# Both upstreams are a process on this machine. A slow response is a bug, not a
# network condition, and failing fast beats holding a worker for a minute.
proxy_connect_timeout 2s;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
# HTTP/1.1 with an empty Connection header is what makes the keepalive pools work;
# the default 1.0 opens a new socket per request.
proxy_http_version 1.1;
proxy_set_header Connection "";
# proxy_params (Debian) sets Host, X-Real-IP, X-Forwarded-For and X-Forwarded-Proto.
# That include is load-bearing: the API's rate limiter reads the last hop of
# X-Forwarded-For to tell two visitors apart, and without it every request arrives from
# the loopback peer and shares one bucket. Do not also set X-Forwarded-For beside the
# include — declaring it twice is what produces nginx's proxy_headers_hash warning.
# Cache-Control comes from server.mjs: a year and immutable for anything with a
# content hash in its name, revalidate-every-time for markup. Nothing to restate here.
location / {
proxy_pass http://cashumints_site;
include proxy_params;
}
location ~ ^/api/(mints|stats) {
proxy_pass http://127.0.0.1:8787;
# Health must always reflect the live process. It is the endpoint you page on.
location = /api/health {
proxy_pass http://cashumints_api;
proxy_cache off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Cache-Control "no-store" always;
include proxy_params;
}
# /api/mints and /api/stats change every few minutes but can be asked for by every
# visitor at once. A micro-cache absorbs that without making the data stale.
location ~ ^/api/(mints|stats)(?:/|$|\?) {
proxy_pass http://cashumints_api;
proxy_cache cashumints;
proxy_cache_valid 200 30s;
proxy_cache_valid 404 10s;
# Serve the previous response while one request refreshes it, so a slow
# backend never becomes a slow page.
# Serve the previous response while one request refreshes it, so a slow backend
# never becomes a slow page.
proxy_cache_use_stale updating error timeout http_500 http_502 http_503;
proxy_cache_background_update on;
proxy_cache_lock on;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Cache-Status $upstream_cache_status always;
include proxy_params;
}
location /api/ {
proxy_pass http://cashumints_api;
include proxy_params;
}
location /icons/ {
proxy_pass http://127.0.0.1:8787;
proxy_pass http://cashumints_api;
proxy_cache cashumints;
proxy_cache_valid 200 1d;
include proxy_params;
}
}
server {
listen 80;
listen [::]:80;
server_name cashumints.space;
location /.well-known/acme-challenge/ { root /var/www/html; }
location / { return 301 https://cashumints.space$request_uri; }
}
```
`/api/` and `/icons/` are proxied same-origin because `PUBLIC_API_URL` is empty, which is
the default. Drop those blocks only if you build with it pointing at a separate API host.
nginx 1.25 and later want `http2 on;` on its own line and warn about the `listen … http2`
form above; Debian 12 ships 1.22, where the newer form is an unknown directive. The form
above is the one that works on both.
### Rebuilds
Mint pages are prerendered, so new mints and new review counts appear at the next build.
A nightly rebuild is enough; the site stays correct in between because the islands refresh
status and reviews at runtime, and an unbuilt mint still resolves through the client-side
fallback on the 404 page.
A nightly rebuild is enough; the site stays correct in between because the islands
refresh status and reviews at runtime, and an unbuilt mint still resolves through the
client-side fallback on the 404 page.
Publishing is a separate step from building, and the separation is the point: the copy
the site server reads is only touched once a build has succeeded, so a failed build
leaves the previous site up rather than replacing it with a half-written one.
```ini
# /etc/systemd/system/cashumints-build.service
# /etc/systemd/system/cashumints-web.service
[Unit]
Description=Rebuild the cashumints.space static site
# Every page's data comes from the API over loopback, so the API has to be up.
# Requires= rather than Wants=: a dead API should abort the build, not replace a good
# site with an empty one.
Requires=cashumints.service
After=cashumints.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=cashumints
WorkingDirectory=/srv/cashumints
Environment=API_URL=http://127.0.0.1:8787
Environment=PUBLIC_API_URL=https://api.cashumints.space
Group=cashumints
WorkingDirectory=/home/cashumints/CashuMints.space
StateDirectory=cashumints
Environment=NODE_ENV=production
# Where the build reaches the API. Must match PORT= in cashumints.service.
Environment=API_URL=http://127.0.0.1:8788
Environment=SITE_URL=https://cashumints.space
# Browser-facing origin. Empty means same origin: islands fetch /api/... and nginx
# forwards it. Declared even though it is empty, because systemd's environment wins over
# .env — so what a production build emits cannot drift with an edit to that file.
Environment=PUBLIC_API_URL=
# After= orders the start; it does not wait for the port to accept connections. At boot
# the API is still opening its database and probing, so block until it reports healthy
# rather than letting the first fetch die on ECONNREFUSED. /api/health answers 503 until
# it is genuinely ready, and curl -f treats that as a failure, so the loop keeps waiting.
ExecStartPre=/usr/bin/timeout 90 /bin/sh -c 'until curl -sf -o /dev/null http://127.0.0.1:8788/api/health; do sleep 1; done'
# Check `which pnpm` on the host: a corepack or pnpm-home install sits outside /usr/bin,
# and systemd's PATH does not include it.
ExecStart=/usr/bin/pnpm build
ExecStartPost=/usr/bin/rsync -a --delete web/dist/ /srv/cashumints/web/
# --delay-updates stages the changed files and renames them in at the end, so the window
# where the tree is a mix of two builds is a rename rather than a whole transfer, and
# --delete-after keeps removals from landing before their replacements. Unchanged files
# — every hashed asset and card, which is nearly all of it — are not touched at all.
ExecStartPost=/usr/bin/rsync -a --delete-after --delay-updates web/dist/ /var/lib/cashumints/web/
# ~500 prerendered pages plus a card per mint. Minutes, not seconds, on a small VPS, and
# TimeoutStartSec is what bounds a Type=oneshot.
TimeoutStartSec=1800
# A nightly rebuild should not starve the API it is reading from.
Nice=10
UMask=0022
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
# ProtectHome is deliberately absent, unlike in the other two units: this one writes
# inside /home/cashumints — web/dist, web/public/og, web/src/generated and the pnpm
# store are all under it.
ProtectSystem=full
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
```
There is deliberately no `[Install]` section: a rebuild should be scheduled, not fired on
every boot.
```ini
# /etc/systemd/system/cashumints-build.timer
# /etc/systemd/system/cashumints-web.timer
[Unit]
Description=Nightly cashumints.space rebuild
@@ -671,10 +1155,31 @@ Persistent=true
WantedBy=timers.target
```
### First deploy
Order matters once: the site server refuses to start against a root that has no
`index.html`, so the build has to publish before it comes up.
```bash
sudo systemctl enable --now cashumints-build.timer
/usr/bin/node --version # 22.18 or newer, or the API will not run
sudo systemctl enable --now cashumints # API first: the build reads from it
sudo systemctl start cashumints-web # build, then publish to /var/lib/cashumints/web
sudo systemctl enable --now cashumints-site # now it has something to serve
sudo systemctl enable --now cashumints-web.timer
sudo nginx -t && sudo systemctl reload nginx
```
```bash
curl -sI https://cashumints.space | head -1 # 200
curl -sI https://cashumints.space/nope | head -1 # 404, not 200
curl -s https://cashumints.space/api/health # status ok
```
A 502 on `/` means the site server is down or was never started; a 502 on `/api/` means
the API is. They fail independently, which is the other thing proxying buys: the
prerendered site keeps serving while the API is restarting.
## Licence
See `LICENSE`.
+12 -7
View File
@@ -4,24 +4,29 @@
"private": true,
"type": "module",
"scripts": {
"dev": "node --env-file-if-exists=../.env --experimental-strip-types --watch src/index.ts",
"start": "node --env-file-if-exists=../.env --experimental-strip-types src/index.ts",
"seed": "node --env-file-if-exists=../.env --experimental-strip-types src/seed.ts",
"dev": "node --env-file-if-exists=../.env --watch src/index.ts",
"start": "node --env-file-if-exists=../.env src/index.ts",
"seed": "node --env-file-if-exists=../.env src/seed.ts",
"migrate": "node --env-file-if-exists=../.env src/migrate.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --env-file-if-exists=../.env --experimental-strip-types src/check.ts",
"test:warnings": "node --env-file-if-exists=../.env --experimental-strip-types src/check-warnings.ts",
"test:offline": "node --env-file-if-exists=../.env --experimental-strip-types src/check-offline.ts"
"test": "node --env-file-if-exists=../.env src/check.ts",
"test:warnings": "node --env-file-if-exists=../.env src/check-warnings.ts",
"test:offline": "node --env-file-if-exists=../.env src/check-offline.ts",
"test:lnurl": "node --env-file-if-exists=../.env src/check-lnurl.ts",
"test:index": "node src/check-index.ts"
},
"dependencies": {
"@cashumints/shared": "workspace:*",
"@hono/node-server": "^1.13.7",
"better-sqlite3": "^11.7.0",
"hono": "^4.6.14",
"nostr-tools": "^2.10.4"
"nostr-tools": "^2.10.4",
"pg": "^8.13"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.10.2",
"@types/pg": "^8.23.1",
"typescript": "^5.6.3"
}
}
+253
View File
@@ -0,0 +1,253 @@
/**
* Publishing `kind:38174` announcements for LNURL mints this indexer has confirmed.
*
* **This writes to public relays.** Nostr has no delete that anyone is obliged to
* honour, so an event published here is on the network permanently, signed by this
* site's key and attributed to it. That is the whole reason for the gating below, and
* the reason it is two switches rather than one.
*
* Why publish at all: 38174 is a proposed kind (see `docs/KIND-LNURL-MINT.md`) and a
* proposed kind with no events is a document rather than a protocol. An indexer that
* has already probed a mint and confirmed what it serves is the one party in a position
* to put honest announcements on the network before any operator has heard of the kind.
*
* ## What is announced, and what deliberately is not
*
* Only what the probe actually **observed**. The vocabulary has eleven values; this
* publishes at most seven of them, and the four it never publishes are the point:
*
* - `rotate`, `split`, `merge` — these are only provable by calling `/w/cb`, which
* mutates or destroys a note. Not probeable, not published, even though the one
* implementation in existence supports all three unconditionally. Inferring them
* from a version string would be publishing a guess under this site's signature.
* - `lud21` — genuinely undetectable: verify-disabled and unknown-payment-hash return
* byte-identical responses. See NOTES-LNURL.md §5.
*
* An operator's own announcement can and should claim more; theirs is a statement about
* what they built, this is a statement about what was seen. When both exist the newer
* `created_at` wins, which is normal addressable-event behaviour and means an operator
* takes over their own listing simply by publishing one.
*/
import { finalizeEvent, getPublicKey, type EventTemplate } from 'nostr-tools/pure';
import { SimplePool } from 'nostr-tools/pool';
import * as nip19 from 'nostr-tools/nip19';
import {
KIND_LNURL_ANNOUNCEMENT, lnurlIdentifier, observedFeatures, type LnurlFields,
} from '@cashumints/shared';
import { announceConfig } from './config.ts';
import { getDb, getState, setState } from './db.ts';
import { log } from './log.ts';
import { parseEcosystem, type MintRow } from './mints.ts';
/**
* Republish interval.
*
* An addressable event is replaced, not duplicated, so republishing is cheap — but it
* is still traffic to somebody else's relay and a new `created_at` on every cycle would
* make this site's announcement permanently outrank an operator's own. Content changes
* are published immediately; an unchanged announcement is refreshed once a week, which
* keeps it from ageing out of relays that prune.
*/
const REPUBLISH_AFTER_S = 7 * 24 * 60 * 60;
/** State-table key holding what was last published for one mint, and when. */
const stateKey = (url: string): string => `announce:${url}`;
/**
* The secret key to sign with, as 32 bytes.
*
* Accepts an `nsec1…` or bare hex. Returns null — with a loud log line rather than a
* throw — for anything else: a misconfigured key must stop announcements and must not
* stop the indexer, which has a directory to serve either way.
*/
export function parseAnnounceKey(raw: string): Uint8Array | null {
const value = raw.trim();
if (!value) return null;
if (value.startsWith('nsec1')) {
try {
const decoded = nip19.decode(value);
if (decoded.type === 'nsec') return decoded.data;
} catch {
return null;
}
return null;
}
if (!/^[0-9a-f]{64}$/i.test(value)) return null;
return Uint8Array.from(Buffer.from(value, 'hex'));
}
/**
* The `features` this site is willing to sign for, from one mint's stored probe.
*
* Thin, and deliberately: the rule about what a probe may claim lives in
* `observedFeatures` in shared/, next to the vocabulary it draws from, so the list the
* page renders and the list this signs cannot drift apart. See that function for why
* `rotate`, `split`, `merge` and `lud21` are never among them.
*/
export function announcedFeatures(fields: LnurlFields): string[] {
return observedFeatures({
fundingAvailable: fields.funding_available,
maxWithdrawableMsat: fields.max_withdrawable_msat,
maxSendableMsat: fields.max_sendable_msat,
lightningAddress: fields.lightning_address,
mintPubkey: fields.mint_pubkey,
onionUrl: fields.onion_url,
});
}
/** The event this site would publish for one confirmed LNURL mint. */
export function announcementTemplate(
row: MintRow,
fields: LnurlFields,
createdAt: number,
): EventTemplate {
const tags: string[][] = [
['d', lnurlIdentifier(fields.base_url, fields.mint_pubkey)],
['u', fields.base_url],
];
const features = announcedFeatures(fields);
if (features.length > 0) tags.push(['features', features.join(',')]);
// Only when the mint said so. Absent reads as mainnet per the kind document, and
// asserting mainnet on a mint that never claimed a network would be this site
// inventing the one fact that decides whether the money is real.
if (fields.network) tags.push(['n', fields.network]);
/*
* `content` is left empty unless the mint published a name of its own.
*
* Empty content means "use the publisher's kind 0", per NIP-87 and the kind document
* — and the publisher here is this site, whose kind 0 is this site. That is the
* honest default for an announcement this site wrote: it is not the mint speaking.
* A name the mint itself served (its node alias) is worth passing on, and nothing else
* from the row is, because everything else came from a previous announcement.
*/
const content = row.name ? JSON.stringify({ name: row.name }) : '';
return { kind: KIND_LNURL_ANNOUNCEMENT, created_at: createdAt, tags, content };
}
/** Stable fingerprint of an announcement's meaning, for the change detector. */
function fingerprint(template: EventTemplate): string {
return JSON.stringify([template.tags, template.content]);
}
/**
* Which rows are eligible.
*
* Confirmed by probing, and only that: `status = 'online'` with a base URL and a
* withdraw ceiling means an advertisement genuinely parsed at some point. A mint that
* has never answered, is offline, or is only "responding but invalid" is never
* announced — this site does not put its signature on the existence of something it has
* not seen.
*
* A `degraded-funding` mint **is** announced: it is up and serving, and the reduced
* `features` list already tells the whole story.
*/
function eligible(row: MintRow, fields: LnurlFields | null): fields is LnurlFields {
if (row.type !== 'lnurl' || row.status !== 'online') return false;
if (!fields?.base_url) return false;
if (fields.invalid_reason) return false;
return fields.max_withdrawable_msat !== null;
}
export interface AnnounceResult {
eligible: number;
published: number;
skipped: number;
failed: number;
}
/**
* Publish an announcement for every confirmed LNURL mint that needs one.
*
* Returns counts rather than throwing: a relay refusing an event is not a reason for a
* probe cycle to fail. Does nothing at all, and says why once, when announcing is off —
* which is the default and is how every deployment that has not opted in behaves.
*/
export async function announceLnurlMints(
now = Math.floor(Date.now() / 1000),
): Promise<AnnounceResult> {
const empty: AnnounceResult = { eligible: 0, published: 0, skipped: 0, failed: 0 };
const settings = announceConfig();
if (!settings) return empty;
const secret = parseAnnounceKey(settings.secretKey);
if (!secret) {
log.error('announce disabled: ANNOUNCE_KEY is not an nsec or 64 hex characters');
return empty;
}
const db = await getDb();
const rows = await db.all<MintRow>(`SELECT * FROM mints WHERE type = 'lnurl'`);
const pool = new SimplePool();
const result: AnnounceResult = { ...empty };
try {
for (const row of rows) {
const fields = parseEcosystem<LnurlFields>(row);
if (!eligible(row, fields)) continue;
result.eligible++;
const template = announcementTemplate(row, fields, now);
const mark = fingerprint(template);
const previous = await getState(stateKey(row.url));
if (previous) {
try {
const { mark: lastMark, at } = JSON.parse(previous) as { mark: string; at: number };
if (lastMark === mark && now - at < REPUBLISH_AFTER_S) {
result.skipped++;
continue;
}
} catch {
// Unreadable marker: republish, which is the safe direction.
}
}
const event = finalizeEvent(template, secret);
/*
* `Promise.allSettled`, not `Promise.all`: one relay refusing the event (rate
* limits, a paid-relay policy, a write it does not accept) must not stop the
* others from taking it. One acceptance is a successful publish.
*/
const outcomes = await Promise.allSettled(pool.publish(settings.relays, event));
const accepted = outcomes.filter((o) => o.status === 'fulfilled').length;
if (accepted === 0) {
result.failed++;
log.warn('announcement rejected by every relay', {
url: row.url,
d: template.tags[0]?.[1],
relays: settings.relays.length,
});
continue;
}
await setState(stateKey(row.url), JSON.stringify({ mark, at: now }));
result.published++;
log.info('announced lnurl mint', {
url: row.url,
d: template.tags[0]?.[1],
features: template.tags.find((t) => t[0] === 'features')?.[1] ?? '',
relays: `${accepted}/${settings.relays.length}`,
event: event.id,
});
}
} finally {
try {
pool.close(settings.relays);
} catch {
// A relay that is already gone throws on close. Nothing to do about it.
}
}
if (result.eligible > 0) {
log.info('announce cycle', { ...result, pubkey: getPublicKey(secret) });
}
return result;
}
+474
View File
@@ -0,0 +1,474 @@
/**
* pnpm --filter ./api test:index
*
* The checks for on-demand indexing (`POST /api/index`). Same shape as `check.ts`: no
* framework, throws on the first failure, prints a count.
*
* Three things are being defended here, and they are in descending order of how bad it
* would be to get them wrong:
*
* 1. **SSRF.** This is the one endpoint that fetches an address a stranger chose, so
* every refusal it makes is asserted against a resolver and a fetch that this file
* controls. Nothing here touches the network: a rule that can only be exercised by
* pointing the test at a real host is a rule that stops being exercised the first
* time CI runs offline.
* 2. **Slug collapse.** Two spellings of one mint must never become two rows with half
* its reviews on each. That is the bug the normalizer was written for, and this
* endpoint is a new way to reintroduce it — a reader can now type the spelling
* discovery never saw.
* 3. **Invite decoding**, which is what makes a Fedimint submission possible at all.
*/
import assert from 'node:assert/strict';
import {
checkIndexInput,
federationIdFromInviteCode,
isNut06Info,
isPrivateIpAddress,
lnurlKey,
normalizeMintUrl,
typeForPath,
} from '@cashumints/shared';
import { checkDestination, safeFetchText, type FetchDeps } from './safe-fetch.ts';
import { RATE_LIMIT, resetRateLimits, takeToken } from './rate-limit.ts';
/** A real code off the relay pool, the same one `check.ts` parses an announcement from. */
const REAL_INVITE =
'fed11qvqzggnhwden5te0v9cxjtn9vd3jue3wvfkxjmnyva6kzunyd9skutnwv46z7qqqzc28wumn8ghj7' +
'end9e3hgunz9e5k7tmhwvhszqfq4m9xejq0l3fsh5k4fvyks8mwmwdyzhpk9e909l3atczpxuqxlgss2f35eg';
let checks = 0;
function check(name: string, fn: () => void): void {
try {
fn();
checks++;
} catch (err) {
console.error(`FAIL: ${name}`);
throw err;
}
}
async function checkAsync(name: string, fn: () => Promise<void>): Promise<void> {
try {
await fn();
checks++;
} catch (err) {
console.error(`FAIL: ${name}`);
throw err;
}
}
/* ---------- address rules ---------- */
check('every private, loopback and link-local range is refused', () => {
for (const address of [
'127.0.0.1', '127.1.2.3', '10.0.0.1', '10.255.255.255',
'172.16.0.1', '172.20.10.5', '172.31.255.255',
'192.168.0.1', '192.168.1.5',
'169.254.169.254', // the cloud metadata endpoint, the reason this exists
'0.0.0.0', '100.64.0.1', // this-network and carrier-grade NAT
'224.0.0.1', '255.255.255.255',
'::1', '::', 'fe80::1', 'fc00::1', 'fd12:3456::1', '::ffff:127.0.0.1', '::ffff:10.0.0.1',
]) {
assert.equal(isPrivateIpAddress(address), true, `${address} must be refused`);
}
});
check('ordinary public addresses are not', () => {
for (const address of ['1.1.1.1', '8.8.8.8', '157.245.26.63', '172.15.0.1', '172.32.0.1', '2606:4700::1111']) {
assert.equal(isPrivateIpAddress(address), false, `${address} must be allowed`);
}
});
await checkAsync('a URL whose hostname is a private literal never reaches DNS', async () => {
// If any of these consulted the resolver, this one would throw rather than answer.
const explode: FetchDeps = {
resolve: () => {
throw new Error('a literal address must not be resolved');
},
};
for (const url of [
'https://127.0.0.1/v1/info',
'https://10.0.0.1/v1/info',
'https://172.16.4.4/v1/info',
'https://192.168.1.5/v1/info',
'https://169.254.169.254/latest/meta-data/',
'https://[::1]/v1/info',
'https://localhost/v1/info',
'https://mint.local/v1/info',
'https://abcdefghij234567.onion/v1/info',
]) {
const verdict = await checkDestination(new URL(url), explode);
assert.equal(verdict?.kind, 'blocked', `${url} must be refused`);
}
});
await checkAsync('a public-looking name that resolves privately is refused', async () => {
const deps: FetchDeps = { resolve: async () => ['10.0.0.5'] };
const verdict = await checkDestination(new URL('https://internal.example.com'), deps);
assert.equal(verdict?.kind, 'blocked');
// One private answer among several is enough: the socket would pick one of them.
const mixed: FetchDeps = { resolve: async () => ['93.184.216.34', '127.0.0.1'] };
assert.equal((await checkDestination(new URL('https://mixed.example.com'), mixed))?.kind, 'blocked');
const public_: FetchDeps = { resolve: async () => ['93.184.216.34'] };
assert.equal(await checkDestination(new URL('https://mint.example.com'), public_), null);
});
await checkAsync('a name that does not resolve is unresolved, not blocked', async () => {
// The distinction the rugged-mint case turns on: a mint whose operator let the domain
// lapse must reach the Nostr lookup, not be rejected as an inadmissible address.
const gone: FetchDeps = { resolve: async () => null };
const verdict = await checkDestination(new URL('https://gone.example.com'), gone);
assert.equal(verdict?.kind, 'unresolved');
const outcome = await safeFetchText(
'https://gone.example.com/v1/info',
{ accept: 'application/json' },
{ ...gone, fetchImpl: (() => { throw new Error('must not connect'); }) as unknown as typeof fetch },
);
assert.equal(outcome.state, 'unreachable');
});
await checkAsync('http is refused outright: this endpoint is https only', async () => {
assert.equal((await checkDestination(new URL('http://mint.example.com')))?.kind, 'blocked');
assert.equal((await checkDestination(new URL('ftp://mint.example.com')))?.kind, 'blocked');
});
/* ---------- redirects ---------- */
/** A fetch that answers from a table, and records every URL it was asked for. */
function scriptedFetch(routes: Record<string, Response>): { fetch: typeof fetch; seen: string[] } {
const seen: string[] = [];
const impl = (async (input: unknown): Promise<Response> => {
const url = String(input);
seen.push(url);
const res = routes[url];
if (!res) throw new Error(`unexpected fetch of ${url}`);
return res;
}) as typeof fetch;
return { fetch: impl, seen };
}
await checkAsync('a redirect to a private address is refused before it is fetched', async () => {
const { fetch: impl, seen } = scriptedFetch({
'https://mint.example.com/v1/info': new Response(null, {
status: 302,
headers: { location: 'https://169.254.169.254/latest/meta-data/' },
}),
});
const outcome = await safeFetchText(
'https://mint.example.com/v1/info',
{ accept: 'application/json' },
{ fetchImpl: impl, resolve: async () => ['93.184.216.34'] },
);
assert.equal(outcome.state, 'blocked');
assert.match(outcome.state === 'blocked' ? outcome.reason : '', /redirected/);
// The crux: the metadata endpoint was never connected to, only reasoned about.
assert.deepEqual(seen, ['https://mint.example.com/v1/info']);
});
await checkAsync('a redirect to a name that resolves privately is refused too', async () => {
const { fetch: impl, seen } = scriptedFetch({
'https://mint.example.com/v1/info': new Response(null, {
status: 301,
headers: { location: 'https://internal.example.com/v1/info' },
}),
});
const outcome = await safeFetchText(
'https://mint.example.com/v1/info',
{ accept: 'application/json' },
{
fetchImpl: impl,
resolve: async (host) => (host === 'mint.example.com' ? ['93.184.216.34'] : ['10.1.2.3']),
},
);
assert.equal(outcome.state, 'blocked');
assert.equal(seen.length, 1, 'the private hop must never be fetched');
});
await checkAsync('two redirects are followed, a third is not', async () => {
const ok = { 'content-type': 'application/json' };
const routes: Record<string, Response> = {
'https://a.example.com/v1/info': new Response(null, {
status: 302,
headers: { location: 'https://b.example.com/v1/info' },
}),
'https://b.example.com/v1/info': new Response(null, {
status: 302,
headers: { location: 'https://c.example.com/v1/info' },
}),
'https://c.example.com/v1/info': new Response('{"name":"ok"}', { headers: ok }),
};
const deps = { resolve: async () => ['93.184.216.34'] };
const two = await safeFetchText(
'https://a.example.com/v1/info',
{ accept: 'application/json' },
{ ...deps, fetchImpl: scriptedFetch(routes).fetch },
);
assert.equal(two.state, 'ok', 'two hops are within the cap');
const deeper = {
...routes,
'https://c.example.com/v1/info': new Response(null, {
status: 302,
headers: { location: 'https://d.example.com/v1/info' },
}),
};
const three = scriptedFetch(deeper);
const over = await safeFetchText(
'https://a.example.com/v1/info',
{ accept: 'application/json' },
{ ...deps, fetchImpl: three.fetch },
);
assert.equal(over.state, 'unreachable');
assert.equal(three.seen.length, 3, 'the fourth address is never fetched');
});
await checkAsync('a body over the cap and a body of the wrong type are both refused', async () => {
const deps = { resolve: async () => ['93.184.216.34'] };
const big = scriptedFetch({
'https://mint.example.com/v1/info': new Response('x'.repeat(2048), {
headers: { 'content-type': 'application/json' },
}),
});
const capped = await safeFetchText(
'https://mint.example.com/v1/info',
{ accept: 'application/json', maxBytes: 512 },
{ ...deps, fetchImpl: big.fetch },
);
assert.equal(capped.state, 'unreachable');
const image = scriptedFetch({
'https://mint.example.com/v1/info': new Response('\x89PNG', {
headers: { 'content-type': 'image/png' },
}),
});
const wrongType = await safeFetchText(
'https://mint.example.com/v1/info',
{ accept: 'application/json' },
{ ...deps, fetchImpl: image.fetch },
);
assert.equal(wrongType.state, 'unreachable');
assert.match(wrongType.state === 'unreachable' ? wrongType.reason : '', /content type/);
});
/* ---------- slug collapse ---------- */
check('every spelling of one mint collapses to one row key', () => {
const spellings = [
'https://mint.600.wtf',
'mint.600.wtf',
'mint.600.wtf/',
'https://mint.600.wtf/',
'https://MINT.600.WTF',
'http://mint.600.wtf',
'https://mint.600.wtf:443/',
' https://mint.600.wtf/ ',
].map((raw) => normalizeMintUrl(raw));
const urls = new Set(spellings.map((s) => s?.url));
const hosts = new Set(spellings.map((s) => s?.host));
assert.equal(urls.size, 1, `one canonical URL, got ${[...urls].join(', ')}`);
assert.equal(hosts.size, 1, `one routing slug, got ${[...hosts].join(', ')}`);
assert.equal([...urls][0], 'https://mint.600.wtf');
assert.equal([...hosts][0], 'mint.600.wtf');
// And the LNURL row key derived from it is one value too, since that is what the
// endpoint actually looks the row up by.
assert.equal(new Set(spellings.map((s) => lnurlKey(s!.url))).size, 1);
});
check('a path is still part of a mint identity, and still collapses per path', () => {
const withPath = ['https://mint.example.com/Bitcoin', 'mint.example.com/Bitcoin/'].map((raw) =>
normalizeMintUrl(raw),
);
assert.equal(new Set(withPath.map((s) => s?.url)).size, 1);
assert.notEqual(withPath[0]?.url, normalizeMintUrl('https://mint.example.com')?.url);
});
/* ---------- what the browser checks before it asks ---------- */
check('the client-side pre-check accepts what the normalizer accepts', () => {
assert.deepEqual(checkIndexInput('lnurl', 'mint.600.wtf'), {
ok: true,
value: 'https://mint.600.wtf',
});
assert.deepEqual(checkIndexInput('cashu', ' https://21mint.me/ '), {
ok: true,
value: 'https://21mint.me',
});
assert.deepEqual(checkIndexInput('cashu', ''), { ok: false, reason: 'empty' });
assert.deepEqual(checkIndexInput('cashu', 'not a url at all'), { ok: false, reason: 'bad_url' });
// A private address fails the pre-check for the same reason the server refuses it.
assert.deepEqual(checkIndexInput('cashu', 'http://127.0.0.1:3338'), {
ok: false,
reason: 'bad_url',
});
});
check('an invite code pasted into a URL field is named as an invite code', () => {
const code = REAL_INVITE;
assert.deepEqual(checkIndexInput('cashu', code), { ok: false, reason: 'bad_invite' });
assert.deepEqual(checkIndexInput('lnurl', code), { ok: false, reason: 'bad_invite' });
assert.deepEqual(checkIndexInput('fedimint', code), { ok: true, value: code });
assert.deepEqual(checkIndexInput('fedimint', 'https://mint.example.com'), {
ok: false,
reason: 'bad_invite',
});
});
/* ---------- invite codes ---------- */
check('a real invite code yields the federation id its announcement carries', () => {
// The `d` tag of the announcement this code came in: decoding must agree with it, or
// a federation submitted by code would get a second row beside the announced one.
assert.equal(
federationIdFromInviteCode(REAL_INVITE),
'aeca6cc80ffc530bd2d54b09681f6edb9a415c362e4af2fe3d5e04137006fa21',
);
assert.equal(federationIdFromInviteCode(REAL_INVITE.toUpperCase()), federationIdFromInviteCode(REAL_INVITE));
});
check('anything that is not an invite code decodes to nothing', () => {
for (const junk of [
'',
'fed1',
'fed11',
'https://mint.example.com',
`${REAL_INVITE}x`, // checksum fails
REAL_INVITE.slice(0, -1), // truncated
REAL_INVITE.replace('fed11q', 'fed11p'), // one flipped character
'lnbc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq', // valid-ish bech32, wrong hrp
]) {
assert.equal(federationIdFromInviteCode(junk), null, `${junk.slice(0, 24)} must not decode`);
}
});
/* ---------- what counts as a mint ---------- */
check('a NUT-06 document is told apart from an arbitrary JSON endpoint', () => {
assert.ok(isNut06Info({ nuts: { '4': { methods: [] } } }));
assert.ok(isNut06Info({ pubkey: '03c21ef6'.padEnd(66, 'a'), name: 'x' }));
assert.ok(isNut06Info({ name: 'Some mint', version: 'cdk-mintd/0.17.5' }));
// The shapes a host that is not a mint actually answers with.
assert.ok(!isNut06Info({ status: 'ok' }));
assert.ok(!isNut06Info({ detail: 'Not Found' }));
assert.ok(!isNut06Info({ tag: 'withdrawRequest', minWithdrawable: 1 }));
assert.ok(!isNut06Info([]));
assert.ok(!isNut06Info('hello'));
assert.ok(!isNut06Info(null));
assert.ok(!isNut06Info({ nuts: {} }), 'an empty nuts object proves nothing');
});
/* ---------- rate limiting ---------- */
check('the eleventh submission in an hour is refused, and refusals do not extend it', () => {
resetRateLimits();
const now = 1_800_000_000_000;
for (let i = 0; i < RATE_LIMIT; i++) {
assert.equal(takeToken('1.2.3.4', now + i).ok, true, `submission ${i + 1} must be allowed`);
}
const refused = takeToken('1.2.3.4', now + RATE_LIMIT);
assert.equal(refused.ok, false);
assert.ok(refused.retryAfter > 0 && refused.retryAfter <= 3600);
// Pressing the button again must not push the window out.
const again = takeToken('1.2.3.4', now + RATE_LIMIT + 1000);
assert.ok(again.retryAfter <= refused.retryAfter, 'a refusal must not extend the wait');
// A different address has its own budget.
assert.equal(takeToken('5.6.7.8', now + RATE_LIMIT).ok, true);
// An hour later the window has slid past the first submission.
assert.equal(takeToken('1.2.3.4', now + 3_600_001).ok, true);
resetRateLimits();
});
/* ---------- the deep-link routes ---------- */
check('every deep link shape maps to the ecosystem that owns it', () => {
assert.deepEqual(typeForPath('/mint/mint.600.wtf'), { type: 'cashu', host: 'mint.600.wtf' });
assert.deepEqual(typeForPath('/lnurl-mint/mint.600.wtf'), { type: 'lnurl', host: 'mint.600.wtf' });
assert.deepEqual(typeForPath('/fedimint/fed-aeca6cc80ffc530b'), {
type: 'fedimint',
host: 'fed-aeca6cc80ffc530b',
});
// A trailing slash is the same page; anything else is not a mint page at all.
assert.deepEqual(typeForPath('/mint/x.example.com/'), { type: 'cashu', host: 'x.example.com' });
assert.equal(typeForPath('/mints'), null);
assert.equal(typeForPath('/'), null);
assert.equal(typeForPath('/mint/'), null);
});
/* ---------- one submission, one row ---------- */
/*
* The dedup and the write path, against a throwaway in-memory database and with no
* network involved at all: a Fedimint submission is decoded rather than fetched, so it
* exercises `indexSubmission` end to end — the in-flight map, the insert, and the
* payload read back — without touching a relay or a mint.
*
* The import is deferred until after `DATABASE_URL` is set, because the config resolves
* its target on first use and this must not open the real database.
*/
process.env['DATABASE_URL'] = ':memory:';
const { indexSubmission, inFlightCount } = await import('./index-mint.ts');
const { closeDb } = await import('./db.ts');
await checkAsync('two simultaneous submissions of one address share one probe', async () => {
assert.equal(inFlightCount(), 0);
const first = indexSubmission('fedimint', REAL_INVITE);
assert.equal(inFlightCount(), 1, 'the first submission claims the key immediately');
// Deliberately a different spelling of the same code: the key is the decoded
// federation id, so the two collapse before anything is written.
const second = indexSubmission('fedimint', REAL_INVITE.toUpperCase());
assert.equal(inFlightCount(), 1, 'the second submission joins the first, it does not start');
const [a, b] = await Promise.all([first, second]);
assert.equal(a, b, 'both callers get the same answer object');
assert.equal(a.status, 201);
assert.equal(inFlightCount(), 0, 'the entry is released when the work finishes');
const created = a.body as { host?: string; status?: string; invite_codes?: string[] };
assert.equal(created.host, 'fed-aeca6cc80ffc530b');
assert.equal(created.status, 'announced', 'nothing checks a federation, so nothing claims it is up');
assert.deepEqual(created.invite_codes, [REAL_INVITE.toLowerCase()]);
// And once it is a row, submitting it again is a lookup rather than a write.
const again = await indexSubmission('fedimint', REAL_INVITE);
assert.equal(again.status, 200);
assert.equal((again.body as { existing?: boolean }).existing, true);
});
await checkAsync('a submission of the wrong shape never reaches the work at all', async () => {
const junk = await indexSubmission('fedimint', 'fed11not-a-real-code');
assert.equal(junk.status, 422);
assert.equal((junk.body as { error?: string }).error, 'invalid_invite');
assert.equal(inFlightCount(), 0);
const wrongType = await indexSubmission('cashu-ish', 'https://mint.example.com');
assert.equal((wrongType.body as { error?: string }).error, 'bad_type');
// A private address is refused by the normalizer, before DNS and before the map.
const private_ = await indexSubmission('cashu', 'https://192.168.1.5');
assert.equal((private_.body as { error?: string }).error, 'blocked_host');
assert.equal(inFlightCount(), 0);
});
await closeDb();
console.log(`ok, ${checks} index checks passed`);
+935
View File
@@ -0,0 +1,935 @@
/**
* pnpm --filter ./api test:lnurl
*
* The LNURL ecosystem, checked against the two things it has to agree with: the
* responses recorded in `NOTES-LNURL.md`, and the rules written down in
* `docs/KIND-LNURL-MINT.md`. Those two documents are the specification; this file is
* what stops the code and the documents drifting apart in silence.
*
* Four groups:
*
* 1. **Parsers**, against verbatim captures of the live reference instance and of a
* locally run mint with no funding source. Not hand-written approximations — the
* bytes that were actually on the wire.
* 2. **The `d` rules**, including the sticky-identity case that the kind document
* spends a section on and that a naive implementation gets wrong.
* 3. **The features vocabulary**, including the two negative cases that matter:
* nothing invents `lud21`, and nothing invents `rotate`/`split`/`merge`.
* 4. **A full round trip** — announcement and review published to a real relay by this
* site's own publisher, read back by the indexer's own parsers, resolved to a row.
* The relay is `test-relay.ts`, in-process and on an ephemeral port, so this runs
* in CI with nothing installed and never touches a public relay.
*/
import assert from 'node:assert/strict';
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
import { SimplePool } from 'nostr-tools/pool';
import * as nip19 from 'nostr-tools/nip19';
import {
ANNOUNCEMENT_KINDS,
FEATURE_VOCABULARY,
KIND_LNURL_ANNOUNCEMENT,
KIND_REVIEW,
addressFromPayLink,
baseUrlFromKey,
ecosystemForKind,
featureStates,
getMintWarnings,
hasFeature,
hostIdentifier,
isMintPubkey,
lnurlIdentifier,
lnurlIdentifiers,
lnurlKey,
mintChip,
msatToSat,
normalizeLnurlNetwork,
normalizeNetwork,
onionFromHtml,
otherFeatures,
parseAdvertisement,
parseFeatures,
parseLnurlAnnouncement,
parsePayInfo,
parseRating,
parseSoftware,
reviewEcosystem,
type LnurlFields,
type NostrEventLike,
} from '@cashumints/shared';
import { announcedFeatures, announcementTemplate, parseAnnounceKey } from './announce.ts';
import { announceConfig } from './config.ts';
import type { MintRow } from './mints.ts';
import { startTestRelay } from './test-relay.ts';
let checks = 0;
function check(name: string, fn: () => void): void {
try {
fn();
checks++;
} catch (err) {
console.error(`FAIL: ${name}`);
throw err;
}
}
async function checkAsync(name: string, fn: () => Promise<void>): Promise<void> {
try {
await fn();
checks++;
} catch (err) {
console.error(`FAIL: ${name}`);
throw err;
}
}
/* ------------------------------------------------------------------ *
* Captures. Verbatim, from NOTES-LNURL.md.
* ------------------------------------------------------------------ */
/** `GET https://lnurl.21mint.me/.well-known/lnurlw/_`, 2026-08-21. */
const LIVE_WITHDRAW = {
tag: 'withdrawRequest',
callback: 'https://lnurl.21mint.me/w',
minWithdrawable: 5000,
maxWithdrawable: 999899000,
defaultDescription: 'lnurlcash bearer note on lnurl.21mint.me',
mintPubkey: '021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555',
payLink: 'https://lnurl.21mint.me/.well-known/lnurlp/mint',
nodeAlias: 'Azzamo',
nodeUri: '021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555@145.239.92.138:9736',
nodeColor: '#68f442',
nodeCapacity: 30027500000,
nodeNumChannels: 8,
nodeNumPeers: 18,
};
/** `GET https://lnurl.21mint.me/.well-known/lnurlp/_`, same run. */
const LIVE_PAY = {
tag: 'payRequest',
callback: 'https://lnurl.21mint.me/p/cb',
minSendable: 6000,
maxSendable: 1000000000,
metadata:
'[["text/plain", "Mint an lnurlcash bearer note on lnurl.21mint.me"], ' +
'["text/identifier", "_@lnurl.21mint.me"], ["text/plain", "Mint fees: 1000,100"]]',
withdrawLink: 'https://lnurl.21mint.me/w',
};
/**
* The same endpoint on a locally run mint with no `FUNDINGSOURCE_*` configured at all.
*
* Every node field is *absent* rather than null, because the software runs with
* `response_model_exclude_none`. This object is the degraded state, and it is the reason
* the probe can report one.
*/
const NO_FUNDING_WITHDRAW = {
tag: 'withdrawRequest',
callback: 'https://lnurl.test/w',
minWithdrawable: 10000,
maxWithdrawable: 999999000,
defaultDescription: 'lnurlcash bearer note on lnurl.test',
payLink: 'https://lnurl.test/.well-known/lnurlp/mint',
};
/* ------------------------------------------------------------------ *
* 1. Parsers
* ------------------------------------------------------------------ */
check('the live mint advertisement parses into every field the site renders', () => {
const ad = parseAdvertisement(LIVE_WITHDRAW);
assert.ok(ad, 'the live advertisement must parse');
assert.equal(ad.minWithdrawableMsat, 5000);
assert.equal(ad.maxWithdrawableMsat, 999899000);
assert.equal(ad.defaultDescription, 'lnurlcash bearer note on lnurl.21mint.me');
assert.equal(ad.mintPubkey, '021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555');
assert.equal(ad.nodeAlias, 'Azzamo');
assert.equal(ad.nodeCapacityMsat, 30027500000);
assert.equal(ad.nodeChannels, 8);
assert.equal(ad.nodePeers, 18);
assert.equal(ad.fundingAvailable, true);
});
check('millisatoshi limits become the sat figures the verdict strip shows', () => {
assert.equal(msatToSat(5000), 5);
assert.equal(msatToSat(999899000), 999899);
// Floored, not rounded: both numbers are bounds, and rounding a ceiling up would
// advertise a note larger than the mint will ever issue.
assert.equal(msatToSat(1999), 1);
assert.equal(msatToSat(999), 0);
assert.equal(msatToSat(null), null);
assert.equal(msatToSat(-1), null);
});
check('a missing mintPubkey is the degraded state, not a parse failure', () => {
const ad = parseAdvertisement(NO_FUNDING_WITHDRAW);
assert.ok(ad, 'a mint with no funding source still serves a valid advertisement');
assert.equal(ad.fundingAvailable, false);
assert.equal(ad.mintPubkey, null);
assert.equal(ad.nodeAlias, null);
assert.equal(ad.nodeUri, null);
// The limits are real and must still render. This is the whole point of the state.
assert.equal(msatToSat(ad.minWithdrawableMsat), 10);
assert.equal(msatToSat(ad.maxWithdrawableMsat), 999999);
});
check('an LNURL error body is not a mint advertisement, despite arriving as HTTP 200', () => {
// These endpoints answer their errors with 200. A probe keying on the status code
// would call every one of these "online".
assert.equal(parseAdvertisement({ status: 'ERROR', reason: 'Unknown user.' }), null);
assert.equal(parseAdvertisement({ status: 'ERROR', reason: 'Unknown note.' }), null);
assert.equal(parseAdvertisement({ detail: 'Not Found' }), null);
assert.equal(parseAdvertisement(LIVE_PAY), null, 'a payRequest is not a withdrawRequest');
assert.equal(parseAdvertisement(null), null);
assert.equal(parseAdvertisement('withdrawRequest'), null);
assert.equal(parseAdvertisement([]), null);
});
check('an advertisement with inverted or missing bounds is rejected', () => {
assert.equal(parseAdvertisement({ ...LIVE_WITHDRAW, minWithdrawable: 900, maxWithdrawable: 100 }), null);
assert.equal(parseAdvertisement({ ...LIVE_WITHDRAW, maxWithdrawable: undefined }), null);
assert.equal(parseAdvertisement({ ...LIVE_WITHDRAW, maxWithdrawable: 'lots' }), null);
});
check('a zero withdraw ceiling parses, because the warning needs to see it', () => {
// Rejecting this would turn "withdrawals disabled" into "offline", which is a
// different and much less useful thing to tell a reader.
const ad = parseAdvertisement({ ...LIVE_WITHDRAW, minWithdrawable: 0, maxWithdrawable: 0 });
assert.ok(ad);
assert.equal(ad.maxWithdrawableMsat, 0);
});
check('the payRequest metadata is parsed twice and yields fee, description and address', () => {
const pay = parsePayInfo(LIVE_PAY);
assert.ok(pay);
assert.equal(pay.minSendableMsat, 6000);
assert.equal(pay.maxSendableMsat, 1000000000);
assert.equal(pay.description, 'Mint an lnurlcash bearer note on lnurl.21mint.me');
assert.equal(pay.feeBaseMsat, 1000);
assert.equal(pay.feePpm, 100);
assert.equal(pay.withdrawLink, 'https://lnurl.21mint.me/w');
});
check('the "Mint fees:" entry never becomes the description', () => {
// It shares `text/plain` with the description, so a naive first-match reader shows
// "Mint fees: 1000,100" as what the mint is.
const pay = parsePayInfo({
...LIVE_PAY,
metadata: '[["text/plain", "Mint fees: 2000,50"], ["text/plain", "A real description"]]',
});
assert.equal(pay?.description, 'A real description');
assert.equal(pay?.feeBaseMsat, 2000);
assert.equal(pay?.feePpm, 50);
});
check('a fee-free mint reports no fee rather than zero', () => {
const pay = parsePayInfo({ ...LIVE_PAY, metadata: '[["text/plain", "Just a mint"]]' });
assert.equal(pay?.feeBaseMsat, null);
assert.equal(pay?.feePpm, null);
});
check('unparseable metadata costs the description, not the whole response', () => {
const pay = parsePayInfo({ ...LIVE_PAY, metadata: 'not json at all' });
assert.ok(pay, 'the limits above the metadata are still good');
assert.equal(pay.minSendableMsat, 6000);
assert.equal(pay.description, null);
});
check('the lightning address comes from payLink, never from the echoed identifier', () => {
// `text/identifier` echoes whichever username was queried, so probing `_` gets back
// `_@host` — LUD-16's bare-domain form, and not a name to show a reader.
assert.equal(parsePayInfo(LIVE_PAY)?.identifier, '_@lnurl.21mint.me');
assert.equal(addressFromPayLink(LIVE_WITHDRAW.payLink), 'mint@lnurl.21mint.me');
assert.equal(addressFromPayLink('https://x.example/.well-known/lnurlp/_'), null);
assert.equal(addressFromPayLink('https://x.example/somewhere/else'), null);
assert.equal(addressFromPayLink(null), null);
assert.equal(addressFromPayLink('not a url'), null);
});
check('an onion address is found in the one-pager and nowhere else', () => {
const html =
'<h2>Also via Tor</h2><button class="copy" ' +
'data-copy="mint@abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion" ' +
'title="Copy lightning address">&#9889;</button>';
assert.equal(
onionFromHtml(html),
'abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion',
);
assert.equal(onionFromHtml('<h1>a mint with no tor section</h1>'), null);
});
check('the version comes from openapi, and the unknown sentinel is not a version', () => {
assert.equal(
parseSoftware({ info: { title: 'lnurl-mint', version: '0.1.0' } }),
'lnurl-mint/0.1.0',
);
// What a source checkout with no installed package metadata reports. It is the
// library saying "I do not know", not a release, and must not reach a reader.
assert.equal(parseSoftware({ info: { title: 'lnurl-mint', version: '0.0.0+unknown' } }), null);
assert.equal(parseSoftware({ info: { version: '1.2.3' } }), null);
assert.equal(parseSoftware({}), null);
assert.equal(parseSoftware(null), null);
});
/* ------------------------------------------------------------------ *
* 2. Identity: the `d` rules
* ------------------------------------------------------------------ */
check('a mint pubkey is 66 hex characters beginning 02 or 03', () => {
assert.equal(isMintPubkey(LIVE_WITHDRAW.mintPubkey), true);
assert.equal(isMintPubkey('03' + 'a'.repeat(64)), true);
// A Cashu mint pubkey or a federation id is 64 characters, and must never be
// mistaken for one of these.
assert.equal(isMintPubkey('a'.repeat(64)), false);
assert.equal(isMintPubkey('04' + 'a'.repeat(64)), false);
assert.equal(isMintPubkey('021ab8'), false);
assert.equal(isMintPubkey(null), false);
});
check('the two `d` forms can never be confused for one another', () => {
const host = hostIdentifier('https://lnurl.21mint.me');
assert.equal(host, 'lnurl.21mint.me');
assert.equal(isMintPubkey(host), false, 'a host is never pubkey-shaped');
assert.ok(host.includes('.'), 'a host always contains a dot; a pubkey never does');
});
check('the `d` fallback drops the scheme and the trailing slash but keeps the path', () => {
assert.equal(hostIdentifier('https://mint.example.com/lnurl/'), 'mint.example.com/lnurl');
assert.equal(hostIdentifier('https://Mint.Example.com'), 'mint.example.com');
});
check('the identifier prefers the pubkey and falls back to the host', () => {
assert.equal(
lnurlIdentifier('https://lnurl.21mint.me', LIVE_WITHDRAW.mintPubkey),
LIVE_WITHDRAW.mintPubkey,
);
assert.equal(lnurlIdentifier('https://lnurl.21mint.me', null), 'lnurl.21mint.me');
// Junk in the pubkey position falls back rather than being published as a `d`.
assert.equal(lnurlIdentifier('https://lnurl.21mint.me', 'nonsense'), 'lnurl.21mint.me');
});
check('a mint that gained a pubkey is still reviewable under its old host identifier', () => {
// The case the kind document spends a section on. Reviews written before the mint had
// a funding source carry the host `d`; asking only for the current identifier would
// silently strand every one of them.
const both = lnurlIdentifiers('https://lnurl.21mint.me', LIVE_WITHDRAW.mintPubkey);
assert.deepEqual(both, [LIVE_WITHDRAW.mintPubkey, 'lnurl.21mint.me']);
const hostOnly = lnurlIdentifiers('https://lnurl.21mint.me', null);
assert.deepEqual(hostOnly, ['lnurl.21mint.me']);
});
check('the row key round-trips the base URL', () => {
const key = lnurlKey('https://lnurl.21mint.me');
assert.equal(key, 'lnurl:https://lnurl.21mint.me');
assert.equal(baseUrlFromKey(key), 'https://lnurl.21mint.me');
// Not an LNURL key, and must not be mistaken for one.
assert.equal(baseUrlFromKey('https://mint.example.com'), null);
assert.equal(baseUrlFromKey('fedimint:' + 'a'.repeat(64)), null);
});
/* ------------------------------------------------------------------ *
* 3. The features vocabulary
* ------------------------------------------------------------------ */
check('the features tag splits like a modules tag, and tolerates what publishers write', () => {
assert.deepEqual(parseFeatures('mint,melt,rotate'), ['mint', 'melt', 'rotate']);
assert.deepEqual(parseFeatures('mint, melt, rotate'), ['mint', 'melt', 'rotate']);
assert.deepEqual(parseFeatures('MINT,Melt'), ['mint', 'melt']);
assert.deepEqual(parseFeatures('mint,mint,melt'), ['mint', 'melt']);
assert.deepEqual(parseFeatures(''), []);
assert.deepEqual(parseFeatures(null), []);
// Junk tokens are dropped, not carried into a chip.
assert.deepEqual(parseFeatures('mint,<script>,melt'), ['mint', 'melt']);
});
check('an unrecognised feature is kept and shown, never dropped', () => {
// The kind document requires consumers to ignore tokens they do not know rather than
// reject the event, which is what lets the vocabulary grow without a new kind.
const features = parseFeatures('mint,melt,quantum-notes');
assert.deepEqual(features, ['mint', 'melt', 'quantum-notes']);
assert.deepEqual(otherFeatures(features), ['quantum-notes']);
});
check('the vocabulary is exactly what the kind document lists', () => {
assert.deepEqual([...FEATURE_VOCABULARY], [
'mint', 'melt', 'rotate', 'split', 'merge',
'lud06', 'lud03', 'lud16', 'lud21',
'signed-notes', 'onion',
]);
});
check('the notes row is satisfied by any one of rotate, split or merge', () => {
assert.equal(hasFeature(['rotate'], 'notes'), true);
assert.equal(hasFeature(['split'], 'notes'), true);
assert.equal(hasFeature(['merge'], 'notes'), true);
assert.equal(hasFeature(['mint', 'melt'], 'notes'), false);
});
check('a funding outage marks mint, melt and signed-notes unavailable, and nothing else', () => {
const features = ['mint', 'melt', 'rotate', 'split', 'merge', 'lud16', 'lud21', 'signed-notes'];
const down = featureStates(features, false);
assert.equal(down.mint, 'unavailable');
assert.equal(down.melt, 'unavailable');
assert.equal(down['signed-notes'], 'unavailable');
// Exactly what still works with no Lightning node, and the reason this state is
// rendered rather than collapsed into "offline".
assert.equal(down.notes, 'ok');
assert.equal(down.lud16, 'ok');
assert.equal(down.lud21, 'ok');
assert.equal(down.onion, 'none');
const up = featureStates(features, true);
assert.equal(up.mint, 'ok');
assert.equal(up['signed-notes'], 'ok');
// Nothing probed yet is not a reason to doubt an operator's claim.
const unknown = featureStates(features, null);
assert.equal(unknown.mint, 'ok');
});
check('a feature that was never claimed stays absent even when funding is down', () => {
const states = featureStates(['rotate'], false);
assert.equal(states.mint, 'none', 'absent, not "unavailable" — it was never claimed');
assert.equal(states.notes, 'ok');
});
check('the network tag agrees with the Fedimint side, bitcoin included', () => {
for (const value of ['bitcoin', 'mainnet', 'MAIN', 'signet', 'regtest', 'testnet4', '', null]) {
assert.equal(
normalizeLnurlNetwork(value),
normalizeNetwork(value),
`the two normalizers disagree about ${JSON.stringify(value)}`,
);
}
});
/* ------------------------------------------------------------------ *
* 4. The announcement
* ------------------------------------------------------------------ */
const ANNOUNCER = 'f'.repeat(64);
function announcement(tags: string[][], content = ''): NostrEventLike {
return {
id: 'e'.repeat(64),
pubkey: ANNOUNCER,
kind: KIND_LNURL_ANNOUNCEMENT,
created_at: 1_780_000_000,
content,
tags,
};
}
check('kind 38174 is wired into the one table every ecosystem is read from', () => {
assert.equal(ANNOUNCEMENT_KINDS.lnurl, 38174);
assert.equal(ecosystemForKind(38174), 'lnurl');
assert.equal(ecosystemForKind('38174'), 'lnurl');
// The neighbours are untouched.
assert.equal(ecosystemForKind(38172), 'cashu');
assert.equal(ecosystemForKind(38173), 'fedimint');
});
check('a full announcement parses into every field a row needs', () => {
const parsed = parseLnurlAnnouncement(
announcement(
[
['d', LIVE_WITHDRAW.mintPubkey],
['u', 'https://lnurl.21mint.me'],
['features', 'mint,melt,rotate,split,merge,lud06,lud03,lud16,lud21,signed-notes'],
['n', 'mainnet'],
],
JSON.stringify({ name: '21 Mint', about: 'Bearer notes.' }),
),
);
assert.ok(parsed);
assert.equal(parsed.identifier, LIVE_WITHDRAW.mintPubkey);
assert.equal(parsed.mintPubkey, LIVE_WITHDRAW.mintPubkey);
assert.equal(parsed.baseUrl, 'https://lnurl.21mint.me');
assert.equal(parsed.slug, 'lnurl.21mint.me');
assert.equal(parsed.network, 'mainnet');
assert.equal(parsed.name, '21 Mint');
assert.equal(parsed.about, 'Bearer notes.');
assert.equal(parsed.announcerPubkey, ANNOUNCER);
assert.equal(parsed.features.length, 10);
});
check('an announcement by host parses, and carries no pubkey', () => {
const parsed = parseLnurlAnnouncement(
announcement([['d', 'lnurl.example.com'], ['u', 'https://lnurl.example.com']]),
);
assert.ok(parsed);
assert.equal(parsed.identifier, 'lnurl.example.com');
assert.equal(parsed.mintPubkey, null, 'a host `d` is not a pubkey and must not read as one');
});
check('an announcement with no usable `u` is rejected', () => {
// `u` is REQUIRED for this kind, unlike 38172: a host-form `d` has no scheme and is
// not fetchable, so without `u` there is no address at all.
assert.equal(parseLnurlAnnouncement(announcement([['d', 'lnurl.example.com']])), null);
assert.equal(
parseLnurlAnnouncement(announcement([['d', 'lnurl.example.com'], ['u', 'not a url']])),
null,
);
// An onion cannot be the canonical `u`; the normalizer refuses it for every ecosystem.
assert.equal(
parseLnurlAnnouncement(
announcement([['d', 'lnurl.example.com'], ['u', 'http://abcdefghijklmnop.onion']]),
),
null,
);
});
check('an announcement with an unusable `d` is rejected', () => {
assert.equal(parseLnurlAnnouncement(announcement([['u', 'https://lnurl.example.com']])), null);
assert.equal(
parseLnurlAnnouncement(
announcement([['d', 'not an identifier'], ['u', 'https://lnurl.example.com']]),
),
null,
);
});
check('a `d` naming one mint and a `u` naming another is accepted, deliberately', () => {
// Not a validation failure: a mint's `d` is its funding node's pubkey, which has no
// relationship to its hostname at all. Cross-checking them would reject exactly the
// events the identity rule exists to allow.
const parsed = parseLnurlAnnouncement(
announcement([['d', LIVE_WITHDRAW.mintPubkey], ['u', 'https://somewhere.else.example']]),
);
assert.ok(parsed);
assert.equal(parsed.baseUrl, 'https://somewhere.else.example');
});
check('hostile announcement content degrades to no metadata', () => {
for (const content of ['not json', '[]', 'null', '{"name": 12345}', '""']) {
const parsed = parseLnurlAnnouncement(
announcement([['d', 'lnurl.example.com'], ['u', 'https://lnurl.example.com']], content),
);
assert.ok(parsed, `content ${content} must not reject the announcement`);
assert.equal(parsed.name, null);
}
// A `javascript:` picture never reaches an img src.
const parsed = parseLnurlAnnouncement(
announcement(
[['d', 'lnurl.example.com'], ['u', 'https://lnurl.example.com']],
JSON.stringify({ picture: 'javascript:alert(1)' }),
),
);
assert.equal(parsed?.picture, null);
});
/* ------------------------------------------------------------------ *
* 5. Reviews
* ------------------------------------------------------------------ */
function review(tags: string[][], content = '[5/5] Good mint.'): NostrEventLike {
return {
id: 'd'.repeat(64),
pubkey: 'c'.repeat(64),
kind: KIND_REVIEW,
created_at: 1_780_000_100,
content,
tags,
};
}
check('a k=38174 review is filed under lnurl and nothing else', () => {
assert.equal(reviewEcosystem(review([['k', '38174']])), 'lnurl');
assert.equal(reviewEcosystem(review([['k', '38172']])), 'cashu');
assert.equal(reviewEcosystem(review([['k', '38173']])), 'fedimint');
// A review with no `k` is Cashu, because every one of those predates everything else.
assert.equal(reviewEcosystem(review([])), 'cashu');
// A kind this build has no ecosystem for belongs to nobody.
assert.equal(reviewEcosystem(review([['k', '39999']])), null);
});
check('the rating convention is identical across all three ecosystems', () => {
// A reader comparing a Cashu mint to an LNURL one must be comparing the same scale,
// so this uses the site's existing parser with no LNURL-specific path at all.
assert.equal(parseRating(review([['k', '38174']], '[4/5] Fast melts')), 4);
assert.equal(parseRating(review([['k', '38174'], ['rating', '2']])), 2);
assert.equal(parseRating(review([['k', '38174'], ['rating', '0.8']])), 4);
assert.equal(parseRating(review([['k', '38174']], 'no rating here')), null);
});
/* ------------------------------------------------------------------ *
* 6. Warnings
* ------------------------------------------------------------------ */
const onlineMint = {
type: 'lnurl',
status: 'online' as const,
last_online: 1_786_999_910,
first_seen: 1_769_720_000,
};
const NOW = 1_787_000_000;
check('a healthy LNURL mint gets no banner', () => {
const warnings = getMintWarnings(
{ ...onlineMint, max_withdrawable_msat: 999899000, funding_available: true },
{ now: NOW },
);
assert.deepEqual(warnings, []);
});
check('a zero withdraw ceiling is critical and says withdrawals are disabled', () => {
const warnings = getMintWarnings(
{ ...onlineMint, max_withdrawable_msat: 0, funding_available: true },
{ now: NOW },
);
assert.equal(warnings[0]?.kind, 'lnurl-withdrawals-disabled');
assert.equal(warnings[0]?.severity, 'critical');
assert.match(warnings[0]!.lead, /Withdrawals disabled/);
assert.equal(mintChip(warnings)?.label, 'No withdrawals');
});
check('never having probed is not "withdrawals disabled"', () => {
// `=== 0`, not falsy. null means nothing has looked yet.
const warnings = getMintWarnings(
{ ...onlineMint, max_withdrawable_msat: null, funding_available: null },
{ now: NOW },
);
assert.deepEqual(warnings, []);
});
check('an unreachable funding source is a warning that says what still works', () => {
const warnings = getMintWarnings(
{ ...onlineMint, max_withdrawable_msat: 999899000, funding_available: false },
{ now: NOW },
);
assert.equal(warnings[0]?.kind, 'lnurl-no-funding');
assert.equal(warnings[0]?.severity, 'warning');
assert.match(warnings[0]!.body, /rotated, split, or merged/);
assert.match(warnings[0]!.body, /nothing moves in or out/);
assert.equal(mintChip(warnings)?.label, 'No mint / melt');
});
check('a host responding with junk says so, rather than saying offline', () => {
const warnings = getMintWarnings(
{
...onlineMint,
status: 'degraded',
max_withdrawable_msat: 999899000,
invalid_reason: 'mint replied: Unknown user.',
},
{ now: NOW },
);
assert.equal(warnings[0]?.kind, 'lnurl-invalid');
assert.match(warnings[0]!.lead, /Endpoint responding but invalid/);
assert.match(warnings[0]!.body, /withdrawals may not work/);
});
check('the offline tiers are exactly the Cashu ones', () => {
const day = 86400;
const tiers: Array<[number, string]> = [
[2 * day, 'offline'],
[10 * day, 'offline-long'],
[40 * day, 'gone'],
];
for (const [age, kind] of tiers) {
const warnings = getMintWarnings(
{ type: 'lnurl', status: 'offline', last_online: NOW - age, first_seen: NOW - 200 * day },
{ now: NOW },
);
assert.equal(warnings[0]?.kind, kind, `${age / day} days offline should be ${kind}`);
// The same input with `type: cashu` reaches the same tier, which is what makes
// "identical to Cashu" a checked claim rather than a comment.
const cashu = getMintWarnings(
{ type: 'cashu', status: 'offline', last_online: NOW - age, first_seen: NOW - 200 * day },
{ now: NOW },
);
assert.equal(cashu[0]?.kind, kind);
assert.equal(cashu[0]?.lead, warnings[0]?.lead, 'and says the same words');
}
});
check('a mint that never answered once lands in the top tier', () => {
const warnings = getMintWarnings(
{ type: 'lnurl', status: 'offline', last_online: null, first_seen: NOW - 40 * 86400 },
{ now: NOW },
);
assert.equal(warnings[0]?.kind, 'gone');
});
check('withdrawals disabled outranks an offline banner, and still says it is offline', () => {
const warnings = getMintWarnings(
{
type: 'lnurl',
status: 'offline',
last_online: NOW - 3 * 86400,
first_seen: NOW - 200 * 86400,
max_withdrawable_msat: 0,
},
{ now: NOW },
);
assert.equal(warnings[0]?.kind, 'lnurl-withdrawals-disabled');
assert.match(warnings[0]!.body, /also been offline/);
});
check('an offline banner rescues the funding fact into its last sentence', () => {
const warnings = getMintWarnings(
{
type: 'lnurl',
status: 'offline',
last_online: NOW - 40 * 86400,
first_seen: NOW - 200 * 86400,
funding_available: false,
},
{ now: NOW },
);
assert.equal(warnings[0]?.kind, 'gone');
assert.match(warnings[0]!.body, /Lightning node was also unreachable/);
});
check('no LNURL warning is ever derived from the features tag', () => {
// `features` is the operator's claim about what they built. A banner derived from a
// claim rather than an observation is the invention the Fedimint branch refuses to
// make, and this branch refuses it too.
const claimed = getMintWarnings(
{ ...onlineMint, features: [], max_withdrawable_msat: 5000, funding_available: true } as never,
{ now: NOW },
);
assert.deepEqual(claimed, []);
});
/* ------------------------------------------------------------------ *
* 7. The publisher
* ------------------------------------------------------------------ */
const probedRow: MintRow = {
url: 'lnurl:https://lnurl.21mint.me',
host: 'lnurl.21mint.me',
type: 'lnurl',
name: '21 Mint',
description: null,
icon_url: null,
icon_file: null,
pubkey: null,
info_json: null,
ecosystem_json: null,
nuts_json: null,
version: 'lnurl-mint/0.1.0',
status: 'online',
consecutive_fails: 0,
last_online: 1_786_999_910,
last_probe: 1_786_999_910,
first_seen: 1_769_720_000,
updated_at: 1_786_999_910,
};
const probedFields: LnurlFields = {
lnurl_id: LIVE_WITHDRAW.mintPubkey,
base_url: 'https://lnurl.21mint.me',
features: [],
network: 'mainnet',
announced_at: null,
announcer_pubkey: null,
mint_pubkey: LIVE_WITHDRAW.mintPubkey,
funding_available: true,
probe_endpoint: '/.well-known/lnurlw/_',
invalid_reason: null,
min_withdrawable_msat: 5000,
max_withdrawable_msat: 999899000,
min_sendable_msat: 6000,
max_sendable_msat: 1000000000,
fee_base_msat: 1000,
fee_ppm: 100,
lightning_address: 'mint@lnurl.21mint.me',
onion_url: null,
node_alias: 'Azzamo',
node_uri: LIVE_WITHDRAW.nodeUri,
node_capacity_msat: 30027500000,
node_channels: 8,
node_peers: 18,
observed_features: ['mint', 'melt', 'lud06', 'lud03', 'lud16', 'signed-notes'],
};
check('announcing is off unless all three switches are set', () => {
assert.equal(announceConfig({}), null);
assert.equal(announceConfig({ ANNOUNCE_LNURL: 'true' }), null, 'no key, no publish');
assert.equal(
announceConfig({ ANNOUNCE_LNURL: 'true', ANNOUNCE_KEY: 'nsec1x' }),
null,
'no relays, no publish — there is deliberately no default',
);
assert.equal(
announceConfig({ ANNOUNCE_LNURL: 'false', ANNOUNCE_KEY: 'k', ANNOUNCE_RELAYS: 'wss://r' }),
null,
);
const on = announceConfig({
ANNOUNCE_LNURL: 'true',
ANNOUNCE_KEY: 'nsec1x',
ANNOUNCE_RELAYS: 'wss://one, wss://two, http://not-a-relay',
});
assert.deepEqual(on, { secretKey: 'nsec1x', relays: ['wss://one', 'wss://two'] });
});
check('the signing key accepts an nsec or hex, and refuses anything else', () => {
const secret = generateSecretKey();
const hex = Buffer.from(secret).toString('hex');
assert.deepEqual(parseAnnounceKey(hex), secret);
assert.deepEqual(parseAnnounceKey(nip19.nsecEncode(secret)), secret);
assert.equal(parseAnnounceKey('npub1abc'), null);
assert.equal(parseAnnounceKey('nsec1notvalid'), null);
assert.equal(parseAnnounceKey('deadbeef'), null);
assert.equal(parseAnnounceKey(''), null);
});
check('this site announces only what it observed', () => {
const features = announcedFeatures(probedFields);
assert.deepEqual(features, ['mint', 'melt', 'lud06', 'lud03', 'lud16', 'signed-notes']);
// The four it must never publish, and the reasons, from the kind document:
assert.ok(!features.includes('lud21'), 'verify is undetectable over HTTP');
for (const operation of ['rotate', 'split', 'merge']) {
assert.ok(
!features.includes(operation),
`${operation} is only provable by calling /w/cb, which mutates a stranger's note`,
);
}
});
check('a mint with no reachable node is announced without the funded capabilities', () => {
const features = announcedFeatures({ ...probedFields, funding_available: false });
assert.deepEqual(features, ['lud06', 'lud03', 'lud16']);
assert.ok(!features.includes('signed-notes'), 'note signing needs the same node');
});
check('a zero withdraw ceiling is never announced as melt', () => {
const features = announcedFeatures({ ...probedFields, max_withdrawable_msat: 0 });
assert.ok(!features.includes('melt'));
assert.ok(features.includes('mint'), 'the pay side is unaffected');
});
check('the announcement template is shaped exactly as the kind document says', () => {
const template = announcementTemplate(probedRow, probedFields, 1_787_000_000);
assert.equal(template.kind, 38174);
assert.deepEqual(template.tags[0], ['d', LIVE_WITHDRAW.mintPubkey]);
assert.deepEqual(template.tags[1], ['u', 'https://lnurl.21mint.me']);
assert.deepEqual(template.tags[2], ['features', 'mint,melt,lud06,lud03,lud16,signed-notes']);
assert.deepEqual(template.tags[3], ['n', 'mainnet']);
assert.equal(template.content, JSON.stringify({ name: '21 Mint' }));
});
check('a mint that never named a network is not assigned one', () => {
// Absent reads as mainnet, so asserting it would be this site inventing the one fact
// that decides whether the money is real.
const template = announcementTemplate(probedRow, { ...probedFields, network: null }, 1);
assert.equal(template.tags.some((t) => t[0] === 'n'), false);
});
check('an announcement this site wrote defers to its own kind 0 when there is no name', () => {
const template = announcementTemplate({ ...probedRow, name: null }, probedFields, 1);
assert.equal(template.content, '');
});
/* ------------------------------------------------------------------ *
* 8. The round trip, against a real relay
* ------------------------------------------------------------------ */
await checkAsync('a 38174 announcement and a k=38174 review round-trip a relay', async () => {
const relay = await startTestRelay();
const pool = new SimplePool();
try {
const siteKey = generateSecretKey();
const sitePubkey = getPublicKey(siteKey);
const reviewerKey = generateSecretKey();
/* --- publish the announcement, using the publisher's own template builder --- */
const created = 1_787_000_000;
const announcementEvent = finalizeEvent(
announcementTemplate(probedRow, probedFields, created),
siteKey,
);
await Promise.allSettled(pool.publish([relay.url], announcementEvent));
/* --- publish a review of it, shaped as the kind document specifies --- */
const reviewEvent = finalizeEvent(
{
kind: KIND_REVIEW,
created_at: created + 60,
content: '[5/5] Rotations are instant and melts have never failed me.',
tags: [
['k', String(KIND_LNURL_ANNOUNCEMENT)],
['u', probedFields.base_url, 'lnurl'],
['d', probedFields.lnurl_id],
['a', `${KIND_LNURL_ANNOUNCEMENT}:${sitePubkey}:${probedFields.lnurl_id}`, relay.url],
],
},
reviewerKey,
);
await Promise.allSettled(pool.publish([relay.url], reviewEvent));
/* --- read both back the way the indexer does --- */
const announcements = await pool.querySync(
[relay.url],
{ kinds: [KIND_LNURL_ANNOUNCEMENT] },
{ maxWait: 4000 },
);
assert.equal(announcements.length, 1, 'the relay should hold exactly one announcement');
const parsed = parseLnurlAnnouncement(announcements[0]!);
assert.ok(parsed, 'the indexer must be able to parse what the site published');
assert.equal(parsed.identifier, LIVE_WITHDRAW.mintPubkey);
assert.equal(parsed.baseUrl, 'https://lnurl.21mint.me');
assert.equal(parsed.mintPubkey, LIVE_WITHDRAW.mintPubkey);
assert.equal(parsed.network, 'mainnet');
assert.equal(parsed.announcerPubkey, sitePubkey);
assert.deepEqual(parsed.features, ['mint', 'melt', 'lud06', 'lud03', 'lud16', 'signed-notes']);
assert.equal(parsed.name, '21 Mint');
/* --- the review, asked for exactly as the targeted pass asks --- */
const reviews = await pool.querySync(
[relay.url],
{
kinds: [KIND_REVIEW],
'#k': [String(KIND_LNURL_ANNOUNCEMENT)],
'#d': lnurlIdentifiers(parsed.baseUrl, parsed.mintPubkey),
},
{ maxWait: 4000 },
);
assert.equal(reviews.length, 1, 'the #d/#k filter the indexer uses must find it');
assert.equal(reviewEcosystem(reviews[0]!), 'lnurl');
assert.equal(parseRating(reviews[0]!), 5);
/* --- and by `u`, which is how a client that does not know this kind writes one --- */
const byUrl = await pool.querySync(
[relay.url],
{ kinds: [KIND_REVIEW], '#u': [probedFields.base_url] },
{ maxWait: 4000 },
);
assert.equal(byUrl.length, 1, 'the #u filter must find it too');
/* --- replacement: a second announcement for the same `d` replaces the first --- */
const updated = finalizeEvent(
announcementTemplate(
probedRow,
{ ...probedFields, funding_available: false },
created + 3600,
),
siteKey,
);
await Promise.allSettled(pool.publish([relay.url], updated));
const after = relay.byKind(KIND_LNURL_ANNOUNCEMENT);
assert.equal(after.length, 1, '38174 is addressable: the relay holds one, not two');
assert.equal(
after[0]?.tags.find((t) => t[0] === 'features')?.[1],
'lud06,lud03,lud16',
'and it is the newer one, with the funded capabilities dropped',
);
} finally {
pool.close([relay.url]);
await relay.close();
}
});
console.log(`ok, ${checks} LNURL checks passed`);
+44 -24
View File
@@ -8,44 +8,62 @@
* a test that actually kills a mint rather than a unit test of a helper.
*
* Runs against a throwaway copy of the seeded database, so the real one is untouched.
* The copy is made with the migrator, which means the check works whichever backend
* holds the real data — and, as a side effect, exercises the migrator on every run.
*
* Set CHECK_DB_URL to run the copy on Postgres instead of a temporary SQLite file:
*
* CHECK_DB_URL=postgres://localhost/cashumints_test pnpm --filter ./api test:offline
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { parseDbTarget, resolveDbConfig } from './config.ts';
import { TABLES } from './db-schema.ts';
import { openDb } from './db.ts';
import { migrate } from './migrate.ts';
const source = process.env['DB_PATH'] ?? path.resolve(import.meta.dirname, '..', 'data', 'cashumints.db');
if (!fs.existsSync(source)) {
console.error(`No seeded database at ${source}. Run "pnpm seed" first.`);
const source = resolveDbConfig();
if (source.dialect === 'sqlite' && !fs.existsSync(source.file)) {
console.error(`No seeded database at ${source.file}. Run "pnpm seed" first.`);
process.exit(1);
}
// Point the whole process at a copy before anything opens the real database.
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'cashumints-offline-'));
const copy = path.join(scratch, 'test.db');
fs.copyFileSync(source, copy);
process.env['DB_PATH'] = copy;
const target = parseDbTarget(process.env['CHECK_DB_URL'] ?? path.join(scratch, 'test.db'));
// A reused Postgres test database still holds the last run's rows, and a stale mint row
// would be picked as the victim. Start empty either way.
const wipe = await openDb(target);
for (const table of TABLES) await wipe.run(`DELETE FROM ${table}`);
await wipe.close();
await migrate({ from: source, to: target, force: true, dryRun: false });
// Point the whole process at the copy before anything else opens a database.
process.env['DATABASE_URL'] = target.dialect === 'postgres' ? target.url : `sqlite:${target.file}`;
delete process.env['DB_PATH'];
const { getDb, closeDb } = await import('./db.ts');
const { probeMint } = await import('./probe.ts');
const { getMintDetail } = await import('./queries.ts');
const { getMintDetail, listMints, resetStatsCache } = await import('./queries.ts');
const { mintByUrl } = await import('./mints.ts');
const db = getDb();
const db = await getDb();
assert.equal(db.label, target.label, 'the check must not be talking to the real database');
// Pick a mint that is online and has real cached metadata to lose.
const victim = db
.prepare(
`SELECT * FROM mints
const victim = await db.get<{ url: string; host: string; name: string }>(
`SELECT url, host, name FROM mints
WHERE status = 'online' AND info_json IS NOT NULL AND name IS NOT NULL
ORDER BY LENGTH(info_json) DESC LIMIT 1`,
)
.get() as { url: string; host: string; name: string } | undefined;
);
assert.ok(victim, 'seeded database has no online mint with cached metadata');
console.log(`victim: ${victim.name} (${victim.host})`);
const before = getMintDetail(victim.host);
const before = await getMintDetail(victim.host);
assert.ok(before, 'detail must exist before the mint dies');
assert.equal(before.status, 'online');
assert.ok(before.info, 'must have cached /v1/info before');
@@ -53,18 +71,20 @@ assert.ok(before.nuts.length > 0, 'must have parsed NUTs before');
// Rug it: same row, dead URL. This is the "point a mint row at a dead URL" test.
const DEAD = 'https://this-mint-is-gone.invalid';
db.prepare('UPDATE mints SET url = ? WHERE host = ?').run(DEAD, victim.host);
db.prepare('UPDATE reviews SET mint_url = ? WHERE mint_url = ?').run(DEAD, victim.url);
await db.run('UPDATE mints SET url = ? WHERE host = ?', DEAD, victim.host);
await db.run('UPDATE reviews SET mint_url = ? WHERE mint_url = ?', DEAD, victim.url);
// Three failures is the offline threshold.
for (let i = 0; i < 3; i++) {
const row = mintByUrl(DEAD);
const row = await mintByUrl(DEAD);
assert.ok(row, 'row must survive the URL change');
const result = await probeMint(row);
assert.equal(result.ok, false, 'a dead URL must not probe ok');
}
const after = getMintDetail(victim.host);
resetStatsCache();
const after = await getMintDetail(victim.host);
assert.ok(after, 'the mint page must still resolve when the mint is dead');
assert.equal(after.status, 'offline', 'three failures means offline');
@@ -86,17 +106,17 @@ assert.ok(after.last_online !== null, 'last_online must be set so the banner has
assert.ok(after.last_online <= Math.floor(Date.now() / 1000));
// And it must sink below every online mint without disappearing.
const { listMints } = await import('./queries.ts');
const list = listMints();
const list = await listMints();
const index = list.findIndex((m) => m.host === victim.host);
assert.ok(index >= 0, 'an offline mint must stay listed so people can find and review it');
const lastOnline = list.map((m) => m.status).lastIndexOf('online');
assert.ok(index > lastOnline, 'offline mints rank below every online mint');
closeDb();
await closeDb();
fs.rmSync(scratch, { recursive: true, force: true });
console.log(
`ok, offline mint still serves ${after.nuts.length} NUTs, ${after.review_count} reviews, ` +
`and full cached metadata (offline since ${new Date(after.last_online * 1000).toISOString()})`,
`ok on ${db.dialect}, offline mint still serves ${after.nuts.length} NUTs, ` +
`${after.review_count} reviews, and full cached metadata ` +
`(offline since ${new Date(after.last_online * 1000).toISOString()})`,
);
+248 -25
View File
@@ -6,8 +6,18 @@
* it throws on the first failure and prints a count on success.
*/
import assert from 'node:assert/strict';
import { bayesianScore, compareMints, NEUTRAL_PRIOR_MEAN, normalizeMintUrl, parseNuts, parseLimits, parseRating } from '@cashumints/shared';
import path from 'node:path';
import {
bayesianScore, cleanInviteCodes, compareMints, ecosystemForKind, fedimintKey, fedimintSlug,
federationIdFromKey, getMintWarnings, hasModule, NEUTRAL_PRIOR_MEAN, normalizeMintUrl,
normalizeNetwork, parseFedimintAnnouncement, parseLimits, parseModules, parseNuts, parseRating,
reviewEcosystem,
} from '@cashumints/shared';
import { parseDbTarget } from './config.ts';
import { openDb } from './db.ts';
import { toPgPlaceholders } from './db-postgres.ts';
import { statusForFails } from './probe.ts';
import { LATEST_REVIEWS } from './queries.ts';
let checks = 0;
function check(name: string, fn: () => void): void {
@@ -185,31 +195,244 @@ check('limits come from NUT-04 methods', () => {
assert.equal(parseLimits(undefined), null);
});
// The dedupe rule lives in SQL, so exercise it against a real in-memory database
// using the same statement the API uses.
check('one review per author per mint, newest wins', async () => {
const { default: Database } = await import('better-sqlite3');
const db = new Database(':memory:');
db.exec(`CREATE TABLE reviews (event_id TEXT PRIMARY KEY, mint_url TEXT, pubkey TEXT,
rating INTEGER, created_at INTEGER)`);
const insert = db.prepare('INSERT INTO reviews VALUES (?,?,?,?,?)');
insert.run('e1', 'https://m', 'alice', 1, 100);
insert.run('e2', 'https://m', 'alice', 5, 200); // alice changed her mind
insert.run('e3', 'https://m', 'bob', 3, 150);
/* ---------- fedimint ---------- */
const rows = db
.prepare(
`SELECT pubkey, rating FROM (
SELECT pubkey, rating, ROW_NUMBER() OVER (
PARTITION BY mint_url, pubkey ORDER BY created_at DESC, event_id) rn
FROM reviews) WHERE rn = 1 ORDER BY pubkey`,
)
.all() as { pubkey: string; rating: number }[];
/**
* A real kind 38173 from the relay pool, kept verbatim.
*
* Three things in it disagree with a plain reading of NIP-87 and are exactly why this
* fixture is a copy rather than something written to match the spec: `n` is `bitcoin`
* and not `mainnet`, `modules` are short names and not prose, and `content` carries
* `federation_name` rather than a kind-0 `name`.
*/
const REAL_ANNOUNCEMENT = {
id: 'f1',
pubkey: '141d2053cb29535ad45aa9e865cdec492524f0ec0066496b98b7099daab5d658',
kind: 38173,
created_at: 1_756_224_071,
content: '{"federation_name":"E-Cash Club","meta_external_url":"https://fm.ctrb.io/meta.json"}',
tags: [
['d', 'aeca6cc80ffc530bd2d54b09681f6edb9a415c362e4af2fe3d5e04137006fa21'],
[
'u',
'fed11qvqzggnhwden5te0v9cxjtn9vd3jue3wvfkxjmnyva6kzunyd9skutnwv46z7qqqzc28wumn8ghj7' +
'end9e3hgunz9e5k7tmhwvhszqfq4m9xejq0l3fsh5k4fvyks8mwmwdyzhpk9e909l3atczpxuqxlgss2f35eg',
],
['n', 'bitcoin'],
['modules', 'ln,mint,wallet,meta'],
],
};
assert.equal(rows.length, 2, 'alice must count once');
assert.equal(rows.find((r) => r.pubkey === 'alice')?.rating, 5, 'newest review wins');
db.close();
check('a real fedimint announcement parses into the fields the pages render', () => {
const a = parseFedimintAnnouncement(REAL_ANNOUNCEMENT);
assert.ok(a, 'the announcement every other check builds on must parse');
assert.equal(a.federationId, 'aeca6cc80ffc530bd2d54b09681f6edb9a415c362e4af2fe3d5e04137006fa21');
assert.equal(a.inviteCodes.length, 1);
assert.deepEqual(a.modules, ['ln', 'mint', 'wallet', 'meta']);
// `bitcoin` on the wire is `mainnet` here, so one federation cannot appear on two
// networks depending on which word its operator used.
assert.equal(a.network, 'mainnet');
assert.equal(a.name, 'E-Cash Club');
assert.equal(a.announcerPubkey, REAL_ANNOUNCEMENT.pubkey);
});
// The last check is async, so report after the microtask queue drains.
queueMicrotask(() => console.log(`ok, ${checks} checks passed`));
check('an invite code survives the validator that first rejected every real one', () => {
// `fed1` is the human-readable part and the `1` after it is bech32's separator, so
// every real code starts `fed11`. Anchoring on a bech32 data character after `fed1`
// rejected all fifteen announcements on the network.
const code = REAL_ANNOUNCEMENT.tags[1]?.[1] ?? '';
assert.deepEqual(cleanInviteCodes([code]), [code]);
assert.deepEqual(cleanInviteCodes([code, code.toUpperCase()]), [code], 'deduped case-insensitively');
assert.deepEqual(cleanInviteCodes(['https://mint.example.com', 'fed1', '']), []);
});
check('an announcement with no invite code is not a federation anyone can join', () => {
// This is a real event too: someone published a review as a 38173. It has a valid
// `d` and nothing to join, so there is no row to make from it.
assert.equal(
parseFedimintAnnouncement({
id: 'x', pubkey: 'p', kind: 38173, created_at: 1, content: '[5/5] test',
tags: [['d', '412d2a9338ebeee5957382eb06eac07fa5235087b5a7d5d0a6e18c635394e9ed'], ['n', 'mainnet'], ['k', '38173']],
}),
null,
);
});
check('the fedimint slug and its key round trip', () => {
const id = 'aeca6cc80ffc530bd2d54b09681f6edb9a415c362e4af2fe3d5e04137006fa21';
assert.equal(fedimintSlug(id), 'fed-aeca6cc80ffc530b');
assert.equal(fedimintSlug(id).length, 'fed-'.length + 16);
assert.equal(federationIdFromKey(fedimintKey(id)), id);
// A mint URL is not a federation key, and must never be read as one.
assert.equal(federationIdFromKey('https://mint.example.com'), null);
});
check('versioned modules still satisfy the named rows', () => {
// A federation running lnv2 and no ln can do Lightning. A row reading "not
// supported" beside an lnv2 chip would be false.
const modules = parseModules('lnv2,mintv2,walletv2,meta');
assert.ok(hasModule(modules, 'lightning'));
assert.ok(hasModule(modules, 'mint'));
assert.ok(hasModule(modules, 'wallet'));
assert.ok(!hasModule(parseModules('meta'), 'lightning'));
});
check('both spellings of mainnet arrive as one network', () => {
assert.equal(normalizeNetwork('bitcoin'), 'mainnet');
assert.equal(normalizeNetwork('mainnet'), 'mainnet');
assert.equal(normalizeNetwork('signet'), 'signet');
assert.equal(normalizeNetwork(''), null);
assert.equal(normalizeNetwork('<script>'), null);
});
check('the k tag decides which ecosystem a review belongs to', () => {
const review = (tags: string[][]) => ({ id: 'a', pubkey: 'b', kind: 38000, created_at: 1, content: '', tags });
assert.equal(reviewEcosystem(review([['k', '38172']])), 'cashu');
assert.equal(reviewEcosystem(review([['k', '38173']])), 'fedimint');
// No `k` at all is Cashu, and that is a fact about the past rather than a guess:
// every such event predates this site listing anything else. Most reviews on the
// network are this shape.
assert.equal(reviewEcosystem(review([['u', 'https://mint.example.com']])), 'cashu');
// A kind this build has no ecosystem for is nobody's: it is dropped, not filed.
assert.equal(reviewEcosystem(review([['k', '39999']])), null);
assert.equal(ecosystemForKind(38173), 'fedimint');
});
check('a federation never carries a capability warning it cannot know', () => {
const now = 1_800_000_000;
const base = { type: 'fedimint', status: 'announced', last_online: null, last_review_at: null };
// Announced this week: nothing has had the chance to check it, so nothing is said.
assert.deepEqual(getMintWarnings({ ...base, announced_at: now - 86400 }, { now }), []);
// Announced two months ago and still unconfirmed: the one soft banner.
const stale = getMintWarnings({ ...base, announced_at: now - 60 * 86400 }, { now });
assert.equal(stale[0]?.kind, 'never-confirmed');
assert.equal(stale[0]?.severity, 'warning');
assert.equal(stale.length, 1, 'a federation gets one banner at most');
// Reported down by a real check: critical, and dated from the last time it answered.
const down = getMintWarnings(
{ ...base, status: 'offline', last_online: now - 10 * 86400 },
{ now },
);
assert.equal(down[0]?.kind, 'fedimint-offline');
assert.equal(down[0]?.severity, 'critical');
// And none of the NUT-derived banners can ever appear on one, whatever is passed.
const kinds = new Set([...stale, ...down].map((w) => w.kind));
for (const forbidden of ['melt-only', 'melt-disabled', 'frozen', 'gone', 'offline-long']) {
assert.ok(!kinds.has(forbidden as never), `${forbidden} has no fedimint meaning`);
}
});
check('a cashu mint is unaffected by any of the above', () => {
const now = 1_800_000_000;
// The same input with no `type` and with `type: 'cashu'` must agree, because every
// existing caller passes neither.
const mint = {
status: 'offline',
last_online: now - 40 * 86400,
info: { nuts: { '4': { disabled: true }, '5': { methods: [{ method: 'bolt11' }] } } },
};
const untyped = getMintWarnings(mint, { now });
const typed = getMintWarnings({ ...mint, type: 'cashu' }, { now });
assert.deepEqual(untyped, typed);
assert.equal(untyped[0]?.kind, 'gone');
});
check('announced federations sort between confirmed-up and confirmed-down', () => {
// Not knowing is not the same as knowing otherwise: an announced federation must not
// sit with the rows a check confirmed, nor sink below the ones a check failed on.
const sorted = [
{ status: 'offline', score: 4.9, review_count: 99 },
{ status: 'announced', score: 2.7, review_count: 0 },
{ status: 'online', score: 1.2, review_count: 1 },
].sort(compareMints);
assert.deepEqual(sorted.map((m) => m.status), ['online', 'announced', 'offline']);
});
// Dialect portability. Every statement in the API is written once and run against both
// backends, so the translation and the two spellings that differ get their own checks.
check('placeholders translate to $n without touching quoted text', () => {
assert.equal(
toPgPlaceholders('SELECT ? FROM t WHERE a = ? AND b = ?'),
'SELECT $1 FROM t WHERE a = $2 AND b = $3',
);
// A literal question mark is data, not a placeholder.
assert.equal(toPgPlaceholders("SELECT '?' , ?"), "SELECT '?' , $1");
// Postgres doubles a quote to escape it; the closing quote just opens the next literal.
assert.equal(toPgPlaceholders("SELECT 'it''s ?' , ?"), "SELECT 'it''s ?' , $1");
assert.equal(toPgPlaceholders('SELECT "od?d", ?'), 'SELECT "od?d", $1');
});
check('connection targets are read the way people write them', () => {
assert.equal(parseDbTarget('postgres://u:p@h:5432/db').dialect, 'postgres');
assert.equal(parseDbTarget('postgresql://h/db').dialect, 'postgres');
assert.equal(parseDbTarget('/var/lib/cashumints/x.db').dialect, 'sqlite');
assert.equal(parseDbTarget('sqlite:/var/lib/x.db').file, '/var/lib/x.db');
assert.equal(parseDbTarget('sqlite:///var/lib/x.db').file, '/var/lib/x.db');
assert.equal(parseDbTarget('file:./data/x.db').file, path.resolve('./data/x.db'));
assert.equal(parseDbTarget(':memory:').file, ':memory:');
// A password must never reach a log line.
assert.ok(!parseDbTarget('postgres://u:hunter2@h/db').label.includes('hunter2'));
// An unsupported scheme must be an error, never a relative filename: a silent new
// empty SQLite file looks exactly like a successful boot with all the data gone.
assert.throws(() => parseDbTarget('mysql://h/db'), /Cannot tell what database/);
assert.throws(() => parseDbTarget('redis://h'), /Cannot tell what database/);
assert.throws(() => parseDbTarget('sqlite:'), /no path after the scheme/);
assert.throws(() => parseDbTarget(' '), /Empty database target/);
// A schemeless value is a path, including a bare relative filename.
assert.equal(parseDbTarget('cashumints.db').file, path.resolve('cashumints.db'));
});
/**
* The dedupe rule lives in SQL, so exercise the real statement — the one queries.ts
* ships — against a real database. Set CHECK_DB_URL to run it on Postgres too; that is
* what catches a statement that quietly went SQLite-only.
*/
async function checkDedupe(target: string): Promise<void> {
const db = await openDb(parseDbTarget(target));
try {
await db.run('DELETE FROM reviews');
await db.run(
`INSERT INTO reviews (event_id, mint_url, pubkey, rating, k, created_at)
VALUES (?,?,?,?,?,?), (?,?,?,?,?,?), (?,?,?,?,?,?), (?,?,?,?,?,?)`,
'e1', 'https://m', 'alice', 1, '38172', 100,
'e2', 'https://m', 'alice', 5, '38172', 200, // alice changed her mind
'e3', 'https://m', 'bob', 3, '38172', 150,
// Same author, a different subject: one npub reviewing a mint and a federation
// is two reviews, and the dedupe partitions on the subject as well as the author.
'e4', 'fedimint:aeca', 'alice', 4, '38173', 210,
);
const rows = await db.all<{ pubkey: string; rating: number }>(
`SELECT pubkey, rating FROM (${LATEST_REVIEWS}) AS latest WHERE mint_url = ? ORDER BY pubkey`,
'https://m',
);
assert.equal(rows.length, 2, `${db.dialect}: alice must count once`);
assert.equal(rows.find((r) => r.pubkey === 'alice')?.rating, 5, `${db.dialect}: newest wins`);
const federation = await db.all<{ pubkey: string; rating: number }>(
`SELECT pubkey, rating FROM (${LATEST_REVIEWS}) AS latest WHERE mint_url = ?`,
'fedimint:aeca',
);
assert.equal(federation.length, 1, `${db.dialect}: alice's federation review stands alone`);
assert.equal(federation[0]?.rating, 4, `${db.dialect}: and is not collapsed into her mint one`);
checks++;
} catch (err) {
console.error(`FAIL: one review per author per mint, newest wins (${db.dialect})`);
throw err;
} finally {
await db.close();
}
}
await checkDedupe(':memory:');
const pgUrl = process.env['CHECK_DB_URL'];
if (pgUrl) await checkDedupe(pgUrl);
else console.log('note: set CHECK_DB_URL to a postgres:// database to check it there too');
console.log(`ok, ${checks} checks passed`);
+130 -1
View File
@@ -1,6 +1,8 @@
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { DEFAULT_RELAYS } from '@cashumints/shared';
import type { Dialect } from './db-driver.ts';
import { redact } from './db-postgres.ts';
const here = path.dirname(fileURLToPath(import.meta.url));
const apiRoot = path.resolve(here, '..');
@@ -12,9 +14,101 @@ function int(name: string, fallback: number): number {
return Number.isFinite(n) && n > 0 ? n : fallback;
}
export interface DbConfig {
dialect: Dialect;
/** SQLite file. Empty when the dialect is postgres. */
file: string;
/** libpq connection string. Empty when the dialect is sqlite. */
url: string;
/** Postgres connections held open. Ignored by SQLite, which has one. */
poolMax: number;
/** Safe to log: a Postgres password is replaced with `***`. */
label: string;
}
export const DEFAULT_DB_FILE = path.join(apiRoot, 'data', 'cashumints.db');
/**
* Read one connection target.
*
* Accepts what a person is likely to type or paste:
*
* postgres://user:pw@host:5432/cashumints postgres
* postgresql://… postgres
* sqlite:/var/lib/cashumints/cashumints.db sqlite, absolute
* sqlite://./data/cashumints.db sqlite, relative to the working directory
* file:./data/cashumints.db sqlite
* /var/lib/cashumints/cashumints.db sqlite, a bare path
* :memory: sqlite, throwaway
*
* Throws on anything else rather than guessing, because the wrong guess here is a
* second empty database that looks like data loss.
*/
export function parseDbTarget(raw: string, poolMax = 10): DbConfig {
const value = raw.trim();
if (!value) throw new Error('Empty database target.');
const sqlite = (file: string): DbConfig => {
if (!file) throw new Error(`Database target has no path after the scheme: ${value}`);
const resolved = file === ':memory:' ? file : path.resolve(file);
return { dialect: 'sqlite', file: resolved, url: '', poolMax, label: `sqlite:${resolved}` };
};
// A scheme is checked before anything else, so an unsupported one is an error rather
// than a filename. Left to a "does it look like a path?" heuristic, `mysql://db/x`
// reads as a relative path and the API starts on a brand new empty SQLite file — data
// loss that announces itself as a successful boot.
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1]?.toLowerCase();
switch (scheme) {
case undefined:
return sqlite(value); // A bare path, absolute or relative.
case 'postgres':
case 'postgresql':
return { dialect: 'postgres', file: '', url: value, poolMax, label: redact(value) };
case 'sqlite':
case 'sqlite3':
case 'file':
return sqlite(value.replace(/^[a-z0-9+.-]+:(?:\/\/)?/i, ''));
default:
// `:memory:` has no scheme by this reading — the regex needs a letter first.
if (value === ':memory:') return sqlite(value);
throw new Error(
`Cannot tell what database "${value}" means. Use a postgres:// URL, ` +
'a sqlite: path, or a filesystem path.',
);
}
}
/**
* Where this process keeps its data.
*
* `DATABASE_URL` decides the backend. Without it the API stays on SQLite at `DB_PATH`,
* which is what every existing deployment already has, so adding Postgres support
* changed nothing for anyone who does not ask for it.
*/
export function resolveDbConfig(env: NodeJS.ProcessEnv = process.env): DbConfig {
const poolMax = int('DB_POOL_MAX', 10);
const url = env['DATABASE_URL']?.trim();
if (url) return parseDbTarget(url, poolMax);
const file = path.resolve(env['DB_PATH'] ?? DEFAULT_DB_FILE);
return { dialect: 'sqlite', file, url: '', poolMax, label: `sqlite:${file}` };
}
let dbConfig: DbConfig | null = null;
export const config = {
port: int('PORT', 8787),
dbPath: process.env['DB_PATH'] ?? path.join(apiRoot, 'data', 'cashumints.db'),
/**
* Resolved on first use rather than at import, so a process can still redirect itself
* — `test:offline` points at a throwaway copy by setting DATABASE_URL before anything
* opens a connection, and would otherwise rug the live database instead.
*/
get db(): DbConfig {
dbConfig ??= resolveDbConfig();
return dbConfig;
},
iconDir: process.env['ICON_DIR'] ?? path.join(apiRoot, 'data', 'icons'),
relays: (process.env['RELAYS']?.split(',').map((r) => r.trim()).filter(Boolean) ??
[...DEFAULT_RELAYS]) as string[],
@@ -25,4 +119,39 @@ export const config = {
userAgent: 'cashumints.space-indexer/1.0',
} as const;
/**
* Publishing `kind:38174` announcements, which is **off unless two switches are set**.
*
* One switch would be enough to make it work and is not enough to make it safe. This
* writes signed events to public relays, permanently and under this site's name, so
* turning it on has to be a thing somebody did on purpose rather than a thing that
* happened because a key was left in an env file from a test run:
*
* ANNOUNCE_LNURL=true the intent
* ANNOUNCE_KEY=nsec1… the identity it will be signed with
* ANNOUNCE_RELAYS=wss://… where, explicitly — there is deliberately no default
*
* The missing default on ANNOUNCE_RELAYS is the important one. Falling back to the
* site's read pool would mean the difference between a CI run against a local test relay
* and a permanent write to five public ones was a single unset variable.
*
* Returns null — never throws — when any of the three is missing, because a
* misconfigured publisher must not stop an indexer that still has a directory to serve.
* See the README section "Publishing LNURL mint announcements".
*/
export function announceConfig(
env: NodeJS.ProcessEnv = process.env,
): { secretKey: string; relays: string[] } | null {
if (env['ANNOUNCE_LNURL']?.trim().toLowerCase() !== 'true') return null;
const secretKey = env['ANNOUNCE_KEY']?.trim() ?? '';
const relays = (env['ANNOUNCE_RELAYS'] ?? '')
.split(',')
.map((relay) => relay.trim())
.filter((relay) => relay.startsWith('ws://') || relay.startsWith('wss://'));
if (!secretKey || relays.length === 0) return null;
return { secretKey, relays };
}
export const startedAt = Math.floor(Date.now() / 1000);
+44
View File
@@ -0,0 +1,44 @@
/**
* The one interface every backend implements.
*
* SQL is written once, in the portable subset both SQLite and Postgres speak, and
* bound with `?` placeholders. The Postgres driver rewrites those to `$1..$n`; the
* SQLite driver passes them straight through. See db-schema.ts for the rules that
* keep a statement portable.
*
* Everything is async because `pg` is async. better-sqlite3 is not, so its driver
* resolves already-settled promises — the cost is a microtask per query, which is
* nothing next to the HTTP and relay work around it.
*/
export type Dialect = 'sqlite' | 'postgres';
/** What a statement can be bound to. `undefined` is rejected by both drivers. */
export type Param = string | number | null;
export interface RunResult {
/** Rows inserted, updated or deleted. `ON CONFLICT DO NOTHING` that did nothing is 0. */
changes: number;
}
/** The read/write surface. A transaction hands back one of these bound to its connection. */
export interface Sql {
all<T>(sql: string, ...params: Param[]): Promise<T[]>;
get<T>(sql: string, ...params: Param[]): Promise<T | undefined>;
run(sql: string, ...params: Param[]): Promise<RunResult>;
}
export interface Db extends Sql {
readonly dialect: Dialect;
/** Connection description safe to log: a Postgres password is never in it. */
readonly label: string;
/** Run DDL. Each element is one statement, no placeholders. */
exec(statements: readonly string[]): Promise<void>;
/**
* Run `fn` inside a transaction. Statements issued through the handle `fn` receives
* are part of it and see its uncommitted writes; anything issued through the Db
* itself waits until the transaction settles. A throw rolls back and re-throws.
*/
transaction<T>(fn: (tx: Sql) => Promise<T>): Promise<T>;
close(): Promise<void>;
}
+147
View File
@@ -0,0 +1,147 @@
import pg from 'pg';
import type { Db, Param, RunResult, Sql } from './db-driver.ts';
const { Pool, types } = pg;
/**
* node-postgres hands back BIGINT and NUMERIC as strings, because either can hold a
* value JavaScript's number cannot. Nothing here can: the counters are row counts, the
* timestamps are unix seconds, and the averages are ratings between 1 and 5. Left
* as-is the strings would flow straight into the JSON the API serves, turning
* `review_count: 12` into `"12"` and quietly breaking every comparison on the way.
*/
types.setTypeParser(20, (v) => Number.parseInt(v, 10)); // int8
types.setTypeParser(1700, (v) => Number.parseFloat(v)); // numeric
/**
* Rewrite `?` placeholders to `$1..$n`.
*
* Quoted text is copied verbatim so a `?` inside a string literal is not renumbered.
* Postgres escapes a quote by doubling it, which needs no special case: the closing
* quote ends one literal and the next character opens another.
*/
export function toPgPlaceholders(sql: string): string {
let out = '';
let quote: string | null = null;
let n = 0;
for (const ch of sql) {
if (quote !== null) {
if (ch === quote) quote = null;
out += ch;
} else if (ch === "'" || ch === '"') {
quote = ch;
out += ch;
} else if (ch === '?') {
out += `$${++n}`;
} else {
out += ch;
}
}
return out;
}
const translated = new Map<string, string>();
function translate(sql: string): string {
let pgSql = translated.get(sql);
if (pgSql === undefined) {
pgSql = toPgPlaceholders(sql);
translated.set(sql, pgSql);
}
return pgSql;
}
/** Bind a Sql surface to anything that can run a query: the pool, or one client. */
function surface(run: (text: string, values: Param[]) => Promise<pg.QueryResult>): Sql {
return {
async all<T>(sql: string, ...params: Param[]): Promise<T[]> {
return (await run(translate(sql), params)).rows as T[];
},
async get<T>(sql: string, ...params: Param[]): Promise<T | undefined> {
return (await run(translate(sql), params)).rows[0] as T | undefined;
},
async run(sql: string, ...params: Param[]): Promise<RunResult> {
return { changes: (await run(translate(sql), params)).rowCount ?? 0 };
},
};
}
/** Strip the password so a connection string is safe to put in a log line. */
export function redact(url: string): string {
try {
const parsed = new URL(url);
if (parsed.password) parsed.password = '***';
return parsed.toString();
} catch {
return 'postgres';
}
}
export class PostgresDb implements Db {
readonly dialect = 'postgres' as const;
readonly label: string;
#pool: pg.Pool;
#sql: Sql;
constructor(connectionString: string, poolMax: number) {
this.#pool = new Pool({ connectionString, max: poolMax });
// A pool client can die between checkouts — a Postgres restart, a killed session, an
// idle timeout on a proxy. Without a listener node-postgres emits that on the pool as
// an unhandled 'error' and takes the process down with it.
this.#pool.on('error', () => undefined);
this.#sql = surface((text, values) => this.#pool.query(text, values));
this.label = redact(connectionString);
}
all<T>(sql: string, ...params: Param[]): Promise<T[]> {
return this.#sql.all<T>(sql, ...params);
}
get<T>(sql: string, ...params: Param[]): Promise<T | undefined> {
return this.#sql.get<T>(sql, ...params);
}
run(sql: string, ...params: Param[]): Promise<RunResult> {
return this.#sql.run(sql, ...params);
}
async exec(statements: readonly string[]): Promise<void> {
// One connection for the lot: concurrent CREATE ... IF NOT EXISTS on the same
// catalog rows deadlocks in Postgres, and this runs on every boot.
const client = await this.#pool.connect();
try {
for (const sql of statements) await client.query(sql);
} finally {
client.release();
}
}
async transaction<T>(fn: (tx: Sql) => Promise<T>): Promise<T> {
const client = await this.#pool.connect();
const tx = surface((text, values) => client.query(text, values));
try {
await client.query('BEGIN');
const result = await fn(tx);
await client.query('COMMIT');
return result;
} catch (err) {
try {
await client.query('ROLLBACK');
} catch {
// The connection is already unusable; release() below discards it.
}
throw err;
} finally {
client.release();
}
}
close(): Promise<void> {
return this.#pool.end();
}
}
+120
View File
@@ -0,0 +1,120 @@
/**
* One schema for both backends.
*
* The types below were picked because SQLite and Postgres agree on all of them:
* TEXT, INTEGER and SMALLINT are literal Postgres types and map to SQLite's INTEGER
* and TEXT affinities, and BIGINT is what unix seconds need in Postgres, where plain
* INTEGER runs out in 2038. `CREATE TABLE/INDEX IF NOT EXISTS` is spelled the same in
* both, so first boot on an empty Postgres database needs no separate setup step.
*
* Rules for keeping a statement portable, learned from the ones that were not:
* - Bind with `?`. The Postgres driver rewrites to `$1..$n`.
* - A subquery in FROM needs an alias. Postgres rejects it without one.
* - Count a condition with `COUNT(*) FILTER (WHERE ...)`, never `SUM(cond)`:
* Postgres has no implicit boolean-to-integer cast.
* - `INSERT OR IGNORE` is SQLite-only. `ON CONFLICT DO NOTHING` works in both and,
* with no conflict target, covers every unique constraint on the table.
*/
export const TABLES = ['mints', 'reviews', 'probes', 'state'] as const;
export type TableName = (typeof TABLES)[number];
export const SCHEMA: readonly string[] = [
`CREATE TABLE IF NOT EXISTS mints (
url TEXT PRIMARY KEY,
host TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'cashu',
name TEXT,
description TEXT,
icon_url TEXT,
icon_file TEXT,
pubkey TEXT,
info_json TEXT,
ecosystem_json TEXT,
nuts_json TEXT,
version TEXT,
status TEXT NOT NULL DEFAULT 'unknown',
consecutive_fails INTEGER NOT NULL DEFAULT 0,
last_online BIGINT,
last_probe BIGINT,
first_seen BIGINT NOT NULL,
updated_at BIGINT NOT NULL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_mints_host ON mints(host)`,
`CREATE INDEX IF NOT EXISTS idx_mints_pubkey ON mints(pubkey)`,
`CREATE TABLE IF NOT EXISTS reviews (
event_id TEXT PRIMARY KEY,
mint_url TEXT NOT NULL,
pubkey TEXT NOT NULL,
rating INTEGER,
k TEXT,
created_at BIGINT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_reviews_mint ON reviews(mint_url, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_reviews_author ON reviews(mint_url, pubkey, created_at)`,
`CREATE TABLE IF NOT EXISTS probes (
mint_url TEXT NOT NULL,
ts BIGINT NOT NULL,
ok SMALLINT NOT NULL,
latency_ms INTEGER
)`,
`CREATE INDEX IF NOT EXISTS idx_probes_mint ON probes(mint_url, ts)`,
`CREATE TABLE IF NOT EXISTS state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)`,
];
/** Column order used by the migrator, so source and target line up without guessing. */
export const COLUMNS: Record<TableName, readonly string[]> = {
mints: [
'url', 'host', 'type', 'name', 'description', 'icon_url', 'icon_file', 'pubkey',
'info_json', 'ecosystem_json', 'nuts_json', 'version', 'status', 'consecutive_fails',
'last_online', 'last_probe', 'first_seen', 'updated_at',
],
reviews: ['event_id', 'mint_url', 'pubkey', 'rating', 'k', 'created_at'],
probes: ['mint_url', 'ts', 'ok', 'latency_ms'],
state: ['key', 'value'],
};
/**
* Primary key per table, for the migrator's upsert. `probes` has none — it is an
* append-only log with no natural key — so the migrator replaces it wholesale.
*/
export const PRIMARY_KEY: Record<TableName, string | null> = {
mints: 'url',
reviews: 'event_id',
probes: null,
state: 'key',
};
/**
* Statements that bring an existing database up to the schema above.
*
* `CREATE TABLE IF NOT EXISTS` does nothing to a table that is already there, so a
* database seeded before federations were indexed never grows `mints.type` on its own.
* These run once per boot, after `SCHEMA`, and are the whole of this project's
* migration story.
*
* The `ADD COLUMN`s are spelled without `IF NOT EXISTS` on purpose: Postgres accepts it
* there and SQLite does not, so the portable form is to run the statement and swallow
* the "duplicate column" both backends raise. `applySchemaUpgrades` in db.ts does that.
*
* Order matters, and the index at the end is why this list exists rather than the index
* sitting with its table in `SCHEMA`: on an upgraded database the column is created
* here, so an index over it declared any earlier fails on a column that is not there
* yet — which is exactly how the first version of this failed to boot.
*
* Every statement must be re-runnable and must not need a value: a NOT NULL column
* needs a constant DEFAULT (which is what makes existing rows read as Cashu), and
* anything else is nullable.
*/
export const SCHEMA_UPGRADES: readonly string[] = [
`ALTER TABLE mints ADD COLUMN type TEXT NOT NULL DEFAULT 'cashu'`,
`ALTER TABLE mints ADD COLUMN ecosystem_json TEXT`,
`ALTER TABLE reviews ADD COLUMN k TEXT`,
`CREATE INDEX IF NOT EXISTS idx_mints_type ON mints(type, status)`,
];
+124
View File
@@ -0,0 +1,124 @@
import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import type { Db, Param, RunResult, Sql } from './db-driver.ts';
/**
* better-sqlite3 behind the async Db interface.
*
* Every statement runs synchronously; the promises are already settled by the time
* they are returned. Prepared statements are cached by SQL text, which the callers
* rely on: they pass constant strings and would otherwise re-prepare on every request.
*
* The one subtlety is transactions. better-sqlite3's own `db.transaction()` cannot
* wrap an async function, so this driver issues BEGIN/COMMIT itself — and that opens a
* window the synchronous version did not have, where an unrelated `await`ing caller
* could slip a statement between them and have it committed, or rolled back, with the
* transaction. `#openTx` closes the window: statements issued through the Db while a
* transaction is open queue behind it, and only the handle passed to the transaction
* body bypasses the queue. Transactions themselves are serialized for the same reason.
*/
export class SqliteDb implements Db {
readonly dialect = 'sqlite' as const;
readonly label: string;
#db: Database.Database;
#statements = new Map<string, Database.Statement>();
/** Resolves when the transaction in flight settles. Null when none is open. */
#openTx: Promise<unknown> | null = null;
constructor(file: string) {
if (file !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
this.#db = new Database(file);
this.#db.pragma('journal_mode = WAL');
this.#db.pragma('synchronous = NORMAL');
this.#db.pragma('busy_timeout = 5000');
this.label = `sqlite:${file}`;
}
#prepare(sql: string): Database.Statement {
let stmt = this.#statements.get(sql);
if (!stmt) {
stmt = this.#db.prepare(sql);
this.#statements.set(sql, stmt);
}
return stmt;
}
/** The synchronous core, shared by the Db surface and the transaction handle. */
#direct: Sql = {
all: <T>(sql: string, ...params: Param[]): Promise<T[]> =>
Promise.resolve(this.#prepare(sql).all(...params) as T[]),
get: <T>(sql: string, ...params: Param[]): Promise<T | undefined> =>
Promise.resolve(this.#prepare(sql).get(...params) as T | undefined),
run: (sql: string, ...params: Param[]): Promise<RunResult> =>
Promise.resolve({ changes: this.#prepare(sql).run(...params).changes }),
};
/** Wait out any open transaction, so a statement never lands inside someone else's. */
async #settled(): Promise<void> {
while (this.#openTx) await this.#openTx.catch(() => undefined);
}
async all<T>(sql: string, ...params: Param[]): Promise<T[]> {
await this.#settled();
return this.#direct.all<T>(sql, ...params);
}
async get<T>(sql: string, ...params: Param[]): Promise<T | undefined> {
await this.#settled();
return this.#direct.get<T>(sql, ...params);
}
async run(sql: string, ...params: Param[]): Promise<RunResult> {
await this.#settled();
return this.#direct.run(sql, ...params);
}
exec(statements: readonly string[]): Promise<void> {
for (const sql of statements) this.#db.exec(sql);
return Promise.resolve();
}
transaction<T>(fn: (tx: Sql) => Promise<T>): Promise<T> {
// Claim the slot synchronously, before the first await: two transactions started in
// the same tick would otherwise both find no transaction open and issue a nested
// BEGIN, which SQLite rejects outright.
const previous = this.#openTx;
const work = (async (): Promise<T> => {
if (previous) await previous.catch(() => undefined);
// IMMEDIATE takes the write lock up front rather than on the first write, so two
// writers fail fast against busy_timeout instead of deadlocking mid-transaction.
this.#db.exec('BEGIN IMMEDIATE');
try {
const result = await fn(this.#direct);
this.#db.exec('COMMIT');
return result;
} catch (err) {
try {
this.#db.exec('ROLLBACK');
} catch {
// Already rolled back by SQLite. The original error is the one that matters.
}
throw err;
}
})();
this.#openTx = work;
return work.finally(() => {
// Only the last transaction in the chain clears the slot; anything queued behind
// this one has already replaced it.
if (this.#openTx === work) this.#openTx = null;
});
}
close(): Promise<void> {
this.#statements.clear();
this.#db.close();
return Promise.resolve();
}
}
+99 -75
View File
@@ -1,101 +1,125 @@
import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { config } from './config.ts';
import { config, type DbConfig } from './config.ts';
import type { Db } from './db-driver.ts';
import { PostgresDb } from './db-postgres.ts';
import { SCHEMA, SCHEMA_UPGRADES } from './db-schema.ts';
import { SqliteDb } from './db-sqlite.ts';
const SCHEMA = `
CREATE TABLE IF NOT EXISTS mints (
url TEXT PRIMARY KEY,
host TEXT NOT NULL,
name TEXT,
description TEXT,
icon_url TEXT,
icon_file TEXT,
pubkey TEXT,
info_json TEXT,
nuts_json TEXT,
version TEXT,
status TEXT NOT NULL DEFAULT 'unknown',
consecutive_fails INTEGER NOT NULL DEFAULT 0,
last_online INTEGER,
last_probe INTEGER,
first_seen INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_mints_host ON mints(host);
CREATE INDEX IF NOT EXISTS idx_mints_pubkey ON mints(pubkey);
export type { Db, Dialect, Param, RunResult, Sql } from './db-driver.ts';
CREATE TABLE IF NOT EXISTS reviews (
event_id TEXT PRIMARY KEY,
mint_url TEXT NOT NULL,
pubkey TEXT NOT NULL,
rating INTEGER,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_reviews_mint ON reviews(mint_url, created_at);
CREATE INDEX IF NOT EXISTS idx_reviews_author ON reviews(mint_url, pubkey, created_at);
/**
* Open a connection and make sure the schema is there.
*
* Both backends create their tables on first use, so pointing the API at an empty
* Postgres database is the whole setup: no separate migration step to forget.
*/
export async function openDb(target: DbConfig = config.db): Promise<Db> {
const db: Db =
target.dialect === 'postgres'
? new PostgresDb(target.url, target.poolMax)
: new SqliteDb(target.file);
CREATE TABLE IF NOT EXISTS probes (
mint_url TEXT NOT NULL,
ts INTEGER NOT NULL,
ok INTEGER NOT NULL,
latency_ms INTEGER
);
CREATE INDEX IF NOT EXISTS idx_probes_mint ON probes(mint_url, ts);
try {
await db.exec(SCHEMA);
await applySchemaUpgrades(db);
} catch (err) {
await db.close().catch(() => undefined);
throw err;
}
CREATE TABLE IF NOT EXISTS state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`;
return db;
}
export type DB = Database.Database;
/**
* Bring an existing database up to the current schema.
*
* `CREATE TABLE IF NOT EXISTS` is a no-op on a table that already exists, so a
* deployment seeded before federations were indexed would otherwise boot against a
* `mints` table with no `type` column and fail on the first query. Running these here
* means an upgrade is a restart, with no separate step to forget.
*
* An already-present column is the expected outcome on every boot after the first, so
* that error is swallowed and anything else is re-thrown. Both backends are matched on
* the message rather than on a code: SQLite raises `duplicate column name: type` and
* Postgres `column "type" of relation "mints" already exists`, and neither exposes
* anything more structured through the drivers here.
*/
async function applySchemaUpgrades(db: Db): Promise<void> {
for (const statement of SCHEMA_UPGRADES) {
try {
await db.exec([statement]);
} catch (err) {
/*
* Postgres localizes its message text (lc_messages), so a German server says
* neither phrase below and every boot after the first would re-throw. Its error
* codes are locale-proof: 42701 duplicate_column, 42P07 duplicate_table. SQLite
* has no code for this, so its English message is still matched.
*/
const code = (err as { code?: unknown }).code;
const message = (err instanceof Error ? err.message : String(err)).toLowerCase();
const alreadyThere =
code === '42701' ||
code === '42P07' ||
message.includes('duplicate column') ||
message.includes('already exists');
if (!alreadyThere) throw err;
}
}
}
let db: DB | null = null;
let opening: Promise<Db> | null = null;
export function getDb(): DB {
if (db) return db;
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
/**
* The process-wide connection, opened on first use.
*
* Async because `pg` is: there is no synchronous way to reach a Postgres server. The
* promise is memoized, so concurrent callers during startup share one connection
* rather than racing to open several.
*/
export function getDb(): Promise<Db> {
if (!opening) {
fs.mkdirSync(config.iconDir, { recursive: true });
const handle = new Database(config.dbPath);
handle.pragma('journal_mode = WAL');
handle.pragma('synchronous = NORMAL');
handle.pragma('busy_timeout = 5000');
handle.exec(SCHEMA);
db = handle;
return handle;
opening = openDb().catch((err: unknown) => {
// A failed open must not be cached, or every later call replays the same error
// against a connection that was never established.
opening = null;
throw err;
});
}
return opening;
}
export function closeDb(): void {
db?.close();
db = null;
export async function closeDb(): Promise<void> {
const pending = opening;
opening = null;
if (pending) await pending.then((db) => db.close()).catch(() => undefined);
}
export function getState(key: string): string | null {
const row = getDb().prepare('SELECT value FROM state WHERE key = ?').get(key) as
| { value: string }
| undefined;
export async function getState(key: string): Promise<string | null> {
const db = await getDb();
const row = await db.get<{ value: string }>('SELECT value FROM state WHERE key = ?', key);
return row?.value ?? null;
}
export function setState(key: string, value: string): void {
getDb()
.prepare('INSERT INTO state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
.run(key, value);
export async function setState(key: string, value: string): Promise<void> {
const db = await getDb();
await db.run(
'INSERT INTO state (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value',
key,
value,
);
}
export function getStateNumber(key: string): number | null {
const raw = getState(key);
export async function getStateNumber(key: string): Promise<number | null> {
const raw = await getState(key);
if (raw === null) return null;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) ? n : null;
}
/** Delete probe rows older than 90 days. Called once a day. */
export function pruneProbes(): number {
export async function pruneProbes(): Promise<number> {
const cutoff = Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60;
return getDb().prepare('DELETE FROM probes WHERE ts < ?').run(cutoff).changes;
const db = await getDb();
return (await db.run('DELETE FROM probes WHERE ts < ?', cutoff)).changes;
}
+520 -57
View File
@@ -1,19 +1,30 @@
import { SimplePool } from 'nostr-tools/pool';
import type { Event as NostrEvent, Filter } from 'nostr-tools';
import {
ANNOUNCEMENT_KINDS,
KIND_FEDIMINT_ANNOUNCEMENT,
KIND_LNURL_ANNOUNCEMENT,
KIND_MINT_ANNOUNCEMENT,
KIND_REVIEW,
isCashuMintReview,
mintPubkeyRef,
mintUrlSpellings,
mintUrlsFromEvent,
normalizeMintUrl,
parseFedimintAnnouncement,
parseLnurlAnnouncement,
parseRating,
reviewEcosystem,
reviewTargetId,
reviewTargetKind,
type FedimintAnnouncement,
type LnurlAnnouncement,
type LnurlFields,
} from '@cashumints/shared';
import { config } from './config.ts';
import { getDb, getStateNumber, setState } from './db.ts';
import { getDb, setState, getStateNumber } from './db.ts';
import type { Sql } from './db-driver.ts';
import { log } from './log.ts';
import { insertMintIfNew, mintUrlByHost, mintUrlByPubkey } from './mints.ts';
import { insertMintIfNew, upsertFedimint, upsertLnurl } from './mints.ts';
const QUERY_LIMIT = 500;
const MAX_WAIT_MS = 12_000;
@@ -32,7 +43,16 @@ export interface DiscoveryResult {
let pool: SimplePool | null = null;
function getPool(): SimplePool {
/**
* The process's one relay pool.
*
* Exported because `relay-lookup.ts` asks the same relays a different question — "has
* anyone ever mentioned this address?", for a mint a reader just submitted that does
* not answer — and opening a second pool for it would mean a second set of sockets to
* the same five relays, with its own reconnect behaviour and its own lifetime to get
* wrong at shutdown.
*/
export function getPool(): SimplePool {
pool ??= new SimplePool();
return pool;
}
@@ -101,18 +121,61 @@ async function fetchKind(kind: number, since: number | null): Promise<NostrEvent
* count in the API disagrees with the list the browser renders on the same page.
*/
async function fetchReviewsForMint(
mintUrl: string,
mintPubkey: string | null,
target: ReviewTarget,
since: number | null,
): Promise<NostrEvent[]> {
const filters: Filter[] = [];
const base: Filter = { kinds: [KIND_REVIEW], limit: QUERY_LIMIT };
if (since !== null) base.since = since;
if (mintPubkey) {
filters.push({ ...base, '#d': [mintPubkey], '#k': [String(KIND_MINT_ANNOUNCEMENT)] });
if (target.type === 'fedimint') {
/*
* A federation is asked for by id, and only by id.
*
* The mint side asks by `#u` as well, and the obvious translation of that is `#u`
* with the invite codes. Two of the five relays in the pool answer such a filter
* with `ERROR: bad req: filter item too large` and nothing else — an invite code is
* 150+ characters — so the query costs two round trips and returns less than
* sending nothing would.
*
* It also buys nothing today: every kind 38000 with `k` = 38173 on the network
* carries the federation id in `d` and no `u` tag at all. A review that arrives
* with only an invite code is still matched, by the global sweep, through
* `byInvite` in the index above; it just is not asked for by name.
*/
if (!target.federationId) return [];
filters.push({
...base,
'#d': [target.federationId],
'#k': [String(KIND_FEDIMINT_ANNOUNCEMENT)],
});
} else if (target.type === 'lnurl') {
/*
* Asked for by every identifier it has ever had, not just its current one.
*
* An LNURL mint's `d` is its funding node's pubkey when it has one and its host
* otherwise, so a mint that gained a funding source has reviews filed under both
* spellings — the older ones under the host, the newer under the pubkey. One `#d`
* filter carrying both is one round trip and finds all of them; asking for only the
* current identifier would silently strand the earlier half. See the kind document.
*
* `#u` is asked as well, and unlike a federation's invite codes an LNURL mint's URLs
* are short enough that no relay rejects the filter.
*/
if (target.identifiers.length > 0) {
filters.push({
...base,
'#d': target.identifiers,
'#k': [String(KIND_LNURL_ANNOUNCEMENT)],
});
}
filters.push({ ...base, '#u': mintUrlSpellings(target.baseUrl ?? target.url) });
} else {
if (target.pubkey) {
filters.push({ ...base, '#d': [target.pubkey], '#k': [String(KIND_MINT_ANNOUNCEMENT)] });
}
filters.push({ ...base, '#u': mintUrlSpellings(target.url) });
}
filters.push({ ...base, '#u': mintUrlSpellings(mintUrl) });
const batches = await Promise.all(
filters.map((filter) =>
@@ -125,14 +188,206 @@ async function fetchReviewsForMint(
return batches.flat();
}
/** Every mint URL an event points at, normalized and inserted if new. */
function ingestMintUrls(event: NostrEvent, now: number, newMints: Set<string>): void {
for (const raw of mintUrlsFromEvent(event)) {
const created = insertMintIfNew(raw, now);
/**
* One thing the targeted second pass asks the relays about.
*
* A mint is asked for by URL and by the pubkey it publishes; a federation by its id and
* its invite codes. Nothing else about the row is needed, so the pass reads five
* columns rather than the whole table.
*/
interface ReviewTarget {
type: string;
/** Row key: the mint URL, `fedimint:<id>`, or `lnurl:<base url>`. */
url: string;
pubkey: string | null;
federationId: string | null;
/** LNURL: every `d` this mint could be reviewed under. Empty for the others. */
identifiers: string[];
/** LNURL: the fetchable https URL inside the row key. */
baseUrl: string | null;
}
/**
* Every federation the batch announces, inserted or refreshed.
*
* Unlike mints, a federation's row content comes entirely from these events — there is
* nothing to fetch afterwards — so this both creates rows and keeps existing ones
* current. Deduped by federation id inside the batch first, newest announcement per id
* winning, because the same federation is routinely announced by several npubs and by
* the same npub more than once, and writing each of those would be one UPDATE per event
* to land on the same final state.
*/
async function ingestFedimints(
events: Iterable<NostrEvent>,
now: number,
newRows: Set<string>,
): Promise<void> {
const newest = new Map<string, FedimintAnnouncement>();
for (const event of events) {
const announcement = parseFedimintAnnouncement(event);
if (!announcement) continue;
const existing = newest.get(announcement.federationId);
if (existing && existing.announcedAt >= announcement.announcedAt) continue;
newest.set(announcement.federationId, announcement);
}
for (const announcement of newest.values()) {
const created = await upsertFedimint(announcement, now);
if (created) newRows.add(created);
}
}
/**
* Every LNURL mint the batch announces, inserted or refreshed.
*
* Deduped **by normalized base URL**, not by the `d` tag, which is the one place this
* genuinely differs from `ingestFedimints`. A federation has exactly one identity for
* life; an LNURL mint's identifier is its funding node's pubkey when it has one and its
* host otherwise, so the same mint legitimately announces under two different `d` values
* across its life. Keying on `d` would split it into two pages with half its reviews on
* each. The `u` tag is present in every form of the event, so it is the key.
*
* Newest announcement per URL wins inside the batch, for the same reason the Fedimint
* side does it: the same mint is announced by more than one npub and more than once, and
* writing each of those would be one UPDATE per event to reach the same final state.
*/
async function ingestLnurl(
events: Iterable<NostrEvent>,
now: number,
newRows: Set<string>,
): Promise<void> {
const newest = new Map<string, LnurlAnnouncement>();
for (const event of events) {
const announcement = parseLnurlAnnouncement(event);
if (!announcement) continue;
const existing = newest.get(announcement.baseUrl);
if (existing && existing.announcedAt >= announcement.announcedAt) continue;
newest.set(announcement.baseUrl, announcement);
}
for (const announcement of newest.values()) {
const created = await upsertLnurl(announcement, now);
if (created) newRows.add(created);
}
}
/**
* Every mint URL the batch points at, normalized and inserted if new.
*
* Deduped across the whole batch before anything is written. A popular mint's URL
* appears in hundreds of events per cycle, and the old row-at-a-time version paid one
* INSERT round trip for each of them — invisible against a local SQLite file, minutes
* of latency against a Postgres server.
*/
async function ingestMintUrls(
events: Iterable<NostrEvent>,
now: number,
newMints: Set<string>,
): Promise<void> {
const seen = new Set<string>();
for (const event of events) {
// A review that says it is about a federation carries an invite code in `u`, not a
// mint URL, and feeding those to the mint normalizer is how `fed11…` filled the
// skipped-URL log. (A handful of kind 38172 announcements carry one too, which is
// their publisher's mistake and still logs once.)
if (reviewEcosystem(event) !== 'cashu') continue;
for (const raw of mintUrlsFromEvent(event)) seen.add(raw);
}
for (const raw of seen) {
const created = await insertMintIfNew(raw, now);
if (created) newMints.add(created);
}
}
/**
* The mints table as two lookup maps, read once per cycle.
*
* Review resolution asks "which mint is this?" once per event, and the answer only
* changes when a mint is inserted — which happens in ingestMintUrls, before any of
* this runs. So it is a snapshot, not a query per event.
*/
interface MintIndex {
byHost: Map<string, string>;
byPubkey: Map<string, string>;
/**
* Federation id to row key. A federation has no URL, so its `d` tag is the only thing
* a review can be matched on, and it is matched exactly.
*/
byFederation: Map<string, string>;
/** Invite code to row key, for a review that carries `u` but no usable `d`. */
byInvite: Map<string, string>;
/**
* Every LNURL identifier to its row key: the mint pubkey *and* the normalized host,
* both pointing at the same row.
*
* Kept apart from `byPubkey`, which is the Cashu index over `mints.pubkey`. An LNURL
* mint's key is its funding node's identity, not a Cashu mint pubkey, and mixing the
* two namespaces would let a review of one resolve to the other. Nothing writes
* `mints.pubkey` for an LNURL row, for exactly that reason.
*/
byLnurlId: Map<string, string>;
/** Normalized base URL to row key, for a review that carries `u` but no usable `d`. */
byLnurlUrl: Map<string, string>;
}
async function loadMintIndex(): Promise<MintIndex> {
const db = await getDb();
const rows = await db.all<{
url: string;
host: string;
type: string;
pubkey: string | null;
ecosystem_json: string | null;
}>('SELECT url, host, type, pubkey, ecosystem_json FROM mints');
const byHost = new Map<string, string>();
const byPubkey = new Map<string, string>();
const byFederation = new Map<string, string>();
const byInvite = new Map<string, string>();
const byLnurlId = new Map<string, string>();
const byLnurlUrl = new Map<string, string>();
for (const row of rows) {
byHost.set(row.host, row.url);
// First writer wins, matching the LIMIT 1 the per-event query used.
if (row.pubkey && !byPubkey.has(row.pubkey)) byPubkey.set(row.pubkey, row.url);
if (row.type === 'lnurl' && row.ecosystem_json) {
try {
const fields = JSON.parse(row.ecosystem_json) as Partial<LnurlFields>;
// Both identifiers, always: a mint announced by host and later by pubkey has
// reviews under each, and both belong to this one row.
for (const id of [fields.lnurl_id, fields.mint_pubkey]) {
if (id) byLnurlId.set(id.toLowerCase(), row.url);
}
if (fields.base_url) byLnurlUrl.set(fields.base_url, row.url);
} catch {
// Unparseable column: this row simply cannot be matched by identifier. It still
// has a page and still resolves by its key, which is honest.
}
continue;
}
if (row.type !== 'fedimint' || !row.ecosystem_json) continue;
try {
const fields = JSON.parse(row.ecosystem_json) as {
federation_id?: string;
invite_codes?: string[];
};
if (fields.federation_id) byFederation.set(fields.federation_id.toLowerCase(), row.url);
for (const code of fields.invite_codes ?? []) byInvite.set(code.toLowerCase(), row.url);
} catch {
// A row whose ecosystem column will not parse simply cannot be matched by id.
// It still has a page and still resolves by nothing else, which is honest.
}
}
return { byHost, byPubkey, byFederation, byInvite, byLnurlId, byLnurlUrl };
}
/**
* Resolve which mint a review is about.
*
@@ -141,23 +396,202 @@ function ingestMintUrls(event: NostrEvent, now: number, newMints: Set<string>):
* the `d` tag pubkey against the pubkey a mint publishes in its own /v1/info, which
* catches reviews whose `u` tag is missing or wrong.
*/
function resolveReviewTarget(event: NostrEvent): string | null {
function resolveReviewTarget(event: NostrEvent, index: MintIndex): string | null {
/*
* The `k` tag decides which ecosystem's resolver runs, and it is not merely a hint:
* a federation id and a Cashu mint pubkey are both 64 hex characters in a `d` tag, so
* without this a Fedimint review could in principle be filed against a mint. A review
* with no `k` at all is Cashu, because every one of those predates anything else.
*/
const ecosystem = reviewEcosystem(event);
if (ecosystem === 'fedimint') return resolveFedimintReview(event, index);
if (ecosystem === 'lnurl') return resolveLnurlReview(event, index);
// A `k` naming a kind this build has no ecosystem for: not ours to file.
if (ecosystem === null) return null;
for (const raw of mintUrlsFromEvent(event)) {
const normalized = normalizeMintUrl(raw);
if (!normalized) continue;
// The row was inserted moments ago by ingestMintUrls, so this almost always hits.
const canonical = mintUrlByHost(normalized.host);
const canonical = index.byHost.get(normalized.host);
if (canonical) return canonical;
}
const pubkey = mintPubkeyRef(event);
return pubkey ? mintUrlByPubkey(pubkey) : null;
return pubkey ? index.byPubkey.get(pubkey) ?? null : null;
}
/**
* Which federation a review is about.
*
* `d` first and almost always: every kind 38000 with `k` = 38173 seen on the network
* carries the federation id there and nothing else — no `u`, no `a`. The invite code
* fallback is for publishers that write `u` instead, and matches the code exactly
* rather than decoding it, because an invite code is opaque to this codebase.
*
* A review of a federation this site has never seen announced resolves to nothing and
* is dropped, exactly as a review of an unknown mint is. There is no page to put it on.
*/
function resolveFedimintReview(event: NostrEvent, index: MintIndex): string | null {
const d = reviewTargetId(event);
if (d && /^[0-9a-f]{64}$/i.test(d)) {
const found = index.byFederation.get(d.toLowerCase());
if (found) return found;
}
for (const raw of mintUrlsFromEvent(event)) {
const found = index.byInvite.get(raw.trim().toLowerCase());
if (found) return found;
}
return null;
}
/**
* Which LNURL mint a review is about.
*
* `d` first, matched against both identifiers a mint can have — its funding node's
* pubkey and its normalized host — because a mint that gained a funding source has
* reviews written under each and both name the same page.
*
* The `u` fallback is not the afterthought it is on the Fedimint side. An LNURL mint's
* `u` tag is a real, normalizable https URL, so a review that carries only an address
* resolves exactly as a Cashu review does, through the same normalizer. That matters
* for reviews written by clients that do not know this kind: `u` is the tag they are
* most likely to get right.
*
* A review of a mint this site has never seen resolves to nothing and is dropped, as it
* is for the other two ecosystems. There is no page to put it on.
*/
function resolveLnurlReview(event: NostrEvent, index: MintIndex): string | null {
const d = reviewTargetId(event);
if (d) {
const found = index.byLnurlId.get(d.trim().toLowerCase());
if (found) return found;
}
for (const raw of mintUrlsFromEvent(event)) {
const normalized = normalizeMintUrl(raw);
if (!normalized) continue;
const found = index.byLnurlUrl.get(normalized.url);
if (found) return found;
}
return null;
}
/** Postgres caps a statement at 65535 bounds parameters; this keeps every batch clear of it. */
const INSERT_BATCH = 500;
interface ReviewRow {
eventId: string;
mintUrl: string;
pubkey: string;
rating: number | null;
/** The `k` tag verbatim, or null when the event carried none. */
k: string | null;
createdAt: number;
}
/** Which of these event ids the reviews table already holds. */
async function knownEventIds(ids: string[]): Promise<Set<string>> {
const db = await getDb();
const known = new Set<string>();
for (let i = 0; i < ids.length; i += INSERT_BATCH) {
const chunk = ids.slice(i, i + INSERT_BATCH);
const rows = await db.all<{ event_id: string }>(
`SELECT event_id FROM reviews WHERE event_id IN (${chunk.map(() => '?').join(',')})`,
...chunk,
);
for (const row of rows) known.add(row.event_id);
}
return known;
}
/**
* Write a batch of reviews and report how many were genuinely new.
*
* Deduped by event id first, keeping the last spelling seen. That is not just a saving:
* Postgres refuses an ON CONFLICT DO UPDATE that would touch the same row twice in one
* statement, so a duplicate inside a single VALUES list is an error, not a no-op. The
* targeted second pass below queries by `#d` and by `#u` and routinely returns both.
*
* `newReviews` is a health signal — is discovery finding anything? — so it counts rows
* that did not exist, not rows written. `changes` would count every re-seen event and
* hide a stalled crawl behind a busy-looking number.
*/
async function ingestReviews(events: Iterable<NostrEvent>, index: MintIndex): Promise<number> {
const rows = new Map<string, ReviewRow>();
for (const e of events) {
/*
* The `k` tag is what marks a 38000 as being about a Cashu mint rather than a
* federation or something this site does not list. Events without it but with a
* resolvable mint URL are still accepted: the old publisher's own events are the
* reason, and every one of them is about a mint.
*
* A federation review has already proved itself by resolving: `resolveReviewTarget`
* only returns a federation row for an event whose `k` says 38173, so nothing here
* has to re-test that.
*/
const target = resolveReviewTarget(e, index);
if (!target) continue;
/*
* A review whose `k` names an ecosystem this build knows has already proved itself
* by resolving: `resolveReviewTarget` runs one resolver per ecosystem and returns a
* row only for an event whose `k` matches that resolver, so nothing here has to
* re-test it. Written against `ANNOUNCEMENT_KINDS` rather than against a list of
* kind numbers, so a fourth ecosystem needs no edit here either.
*
* The `u` clause is what keeps the legacy events: a great many kind 38000 on the
* network carry no `k` at all, every one of them is about a Cashu mint, and they are
* accepted on having a resolvable mint URL exactly as they always were.
*/
const kindKnown = reviewTargetKind(e) !== null && reviewEcosystem(e) !== null;
if (!kindKnown && mintUrlsFromEvent(e).length === 0) continue;
const k = reviewTargetKind(e);
rows.set(e.id, {
eventId: e.id,
mintUrl: target,
pubkey: e.pubkey,
rating: parseRating(e),
k: k === null ? null : String(k),
createdAt: e.created_at,
});
}
if (rows.size === 0) return 0;
const batch = [...rows.values()];
const known = await knownEventIds(batch.map((r) => r.eventId));
const db = await getDb();
await db.transaction(async (tx: Sql) => {
for (let i = 0; i < batch.length; i += INSERT_BATCH) {
const chunk = batch.slice(i, i + INSERT_BATCH);
await tx.run(
`INSERT INTO reviews (event_id, mint_url, pubkey, rating, k, created_at)
VALUES ${chunk.map(() => '(?, ?, ?, ?, ?, ?)').join(', ')}
ON CONFLICT (event_id) DO UPDATE SET
mint_url = excluded.mint_url,
rating = excluded.rating,
k = excluded.k`,
...chunk.flatMap((r) => [r.eventId, r.mintUrl, r.pubkey, r.rating, r.k, r.createdAt]),
);
}
});
return batch.filter((r) => !known.has(r.eventId)).length;
}
export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult> {
const started = Date.now();
const now = Math.floor(Date.now() / 1000);
const lastRun = backfill ? null : getStateNumber('discovery_since');
const lastRun = backfill ? null : await getStateNumber('discovery_since');
// Overlap the window by an hour so an event that arrived late is not missed.
const since = lastRun === null ? null : Math.max(0, lastRun - 3600);
@@ -167,9 +601,27 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
let ok = true;
try {
const announcements = await fetchKind(KIND_MINT_ANNOUNCEMENT, since);
events += announcements.length;
for (const e of announcements) ingestMintUrls(e, now, newMints);
/*
* One query per announcement kind, driven by ANNOUNCEMENT_KINDS rather than by a
* list written out here: adding an ecosystem is a line in shared/, not an edit in
* this loop. They run together because they are independent reads against the same
* relay pool, and running them in series doubled the cycle's wall clock for nothing.
*/
const announcementsByType = new Map<string, NostrEvent[]>();
await Promise.all(
Object.entries(ANNOUNCEMENT_KINDS).map(async ([type, kind]) => {
announcementsByType.set(type, await fetchKind(kind, since));
}),
);
const announcements = announcementsByType.get('cashu') ?? [];
const federations = announcementsByType.get('fedimint') ?? [];
const lnurlMints = announcementsByType.get('lnurl') ?? [];
events += announcements.length + federations.length + lnurlMints.length;
await ingestMintUrls(announcements, now, newMints);
await ingestFedimints(federations, now, newMints);
await ingestLnurl(lnurlMints, now, newMints);
// Announcements alone miss mints that only ever appear in a review's `u` tag,
// so reviews feed discovery too.
@@ -182,42 +634,53 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
for (const e of [...reviews, ...recent]) byId.set(e.id, e);
events += byId.size;
for (const e of byId.values()) ingestMintUrls(e, now, newMints);
await ingestMintUrls(byId.values(), now, newMints);
const insert = getDb().prepare(
`INSERT INTO reviews (event_id, mint_url, pubkey, rating, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(event_id) DO UPDATE SET
mint_url = excluded.mint_url,
rating = excluded.rating`,
);
// `changes` counts updates as well as inserts, so ask first. The log line is a
// health signal (is discovery finding anything?) and an inflated number hides a
// stalled crawl behind a busy-looking figure.
const known = getDb().prepare('SELECT 1 FROM reviews WHERE event_id = ?');
const ingestReviews = getDb().transaction((list: NostrEvent[]) => {
for (const e of list) {
// The `k` tag is what marks a 38000 as being about a Cashu mint rather than
// some other recommendable kind. Events without it but with a resolvable mint
// URL are still accepted: the old publisher's own events are the reason.
const target = resolveReviewTarget(e);
if (!target) continue;
if (!isCashuMintReview(e) && mintUrlsFromEvent(e).length === 0) continue;
if (!known.get(e.id)) newReviews++;
insert.run(e.id, target, e.pubkey, parseRating(e), e.created_at);
}
});
ingestReviews([...byId.values()]);
// Built after every mint this cycle found has been inserted, so review resolution
// below sees them. Nothing after this point inserts a mint.
const index = await loadMintIndex();
newReviews += await ingestReviews(byId.values(), index);
// Second pass: ask each known mint's reviews by name, which is the only way the
// counts converge (see fetchReviewsForMint). Runs after the global sweep so mints
// discovered in this cycle are included.
const targets = getDb()
.prepare('SELECT url, pubkey FROM mints')
.all() as { url: string; pubkey: string | null }[];
const db = await getDb();
const rows = await db.all<{
url: string;
type: string;
pubkey: string | null;
ecosystem_json: string | null;
}>('SELECT url, type, pubkey, ecosystem_json FROM mints');
const targets: ReviewTarget[] = rows.map((row) => {
let federationId: string | null = null;
let identifiers: string[] = [];
let baseUrl: string | null = null;
if (row.ecosystem_json) {
try {
if (row.type === 'fedimint') {
federationId =
(JSON.parse(row.ecosystem_json) as { federation_id?: string }).federation_id ?? null;
} else if (row.type === 'lnurl') {
const fields = JSON.parse(row.ecosystem_json) as Partial<LnurlFields>;
baseUrl = fields.base_url ?? null;
// Deduped, because a mint with no funding source has `lnurl_id` and
// `mint_pubkey` describing the same thing and a relay filter listing one
// value twice is one wasted slot.
identifiers = [...new Set(
[fields.lnurl_id, fields.mint_pubkey]
.filter((id): id is string => Boolean(id))
.map((id) => id.toLowerCase()),
)];
}
} catch {
// Nothing to ask by. The global sweep above already had its chance.
}
}
return { type: row.type, url: row.url, pubkey: row.pubkey, federationId, identifiers, baseUrl };
});
const CONCURRENCY = 6;
let cursor = 0;
@@ -226,21 +689,21 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
while (cursor < targets.length) {
const target = targets[cursor++];
if (!target) continue;
const found = await fetchReviewsForMint(target.url, target.pubkey, since);
const found = await fetchReviewsForMint(target, since);
if (found.length > 0) {
events += found.length;
ingestReviews(found);
newReviews += await ingestReviews(found, index);
}
}
}),
);
setState('discovery_since', String(now));
setState('last_discovery_at', String(now));
setState('last_discovery_ok', '1');
await setState('discovery_since', String(now));
await setState('last_discovery_at', String(now));
await setState('last_discovery_ok', '1');
} catch (err) {
ok = false;
setState('last_discovery_ok', '0');
await setState('last_discovery_ok', '0').catch(() => undefined);
log.error('discovery failed', { reason: err instanceof Error ? err.message : String(err) });
}
+108
View File
@@ -0,0 +1,108 @@
/**
* The only check this site can actually run against a Fedimint federation.
*
* A Cashu mint answers `GET /v1/info` over plain HTTPS, which is why probing one is
* fifteen lines. A federation has no such endpoint. Its guardians speak a JSON-RPC
* dialect over websockets, their addresses are bech32m-encoded inside the invite code,
* and confirming one is up means decoding that code, opening a socket to a quorum of
* guardians and agreeing a consensus session with them. That is a Fedimint client, not
* a probe, and writing half of one here would produce a status less trustworthy than
* saying nothing.
*
* What exists instead is fedimint.observer, which already keeps those client
* connections open and publishes the result:
*
* GET https://observer.fedimint.org/api/federations
* [{ "id": "<64 hex>", "name": "...", "invite": "fed11...", "health": "online" }, ...]
*
* So this is a real check, and it is somebody else's. Both halves of that matter and
* both are recorded: a federation whose status came from here carries
* `status_source: "fedimint.observer"` and its page prints that beside the status,
* rather than implying this site opened a socket. A federation the observer does not
* track gets `announced` — Nostr says it exists, nothing says it runs — and never
* `online`, never `offline`, and never a made-up uptime figure.
*
* When the observer itself is unreachable, nothing is written. A federation confirmed
* up an hour ago is not demoted because a third party had a bad minute; the row keeps
* its last real answer and `last_probe` is left alone so `/api/health` can still see
* that the cycle happened.
*/
import { log } from './log.ts';
/** Where the health data comes from, recorded on every row it decides. */
export const OBSERVER_SOURCE = 'fedimint.observer';
/** Empty disables the lookup entirely: every federation then stays `announced`. */
export const OBSERVER_URL =
process.env['FEDIMINT_OBSERVER_URL'] ?? 'https://observer.fedimint.org/api/federations';
/** One request for every federation, so it gets longer than a single mint probe does. */
const OBSERVER_TIMEOUT_MS = 12_000;
export interface ObserverFederation {
id: string;
name: string | null;
/** `online` or `offline` as published. Anything else is treated as unknown. */
health: 'online' | 'offline' | null;
}
/** Federation id (lowercase hex) to what the observer says about it. */
export type ObserverIndex = Map<string, ObserverFederation>;
function readHealth(value: unknown): 'online' | 'offline' | null {
if (value === 'online') return 'online';
if (value === 'offline') return 'offline';
// Anything else is a word this build has no meaning for, and guessing at it is
// exactly what "no fake statuses" rules out.
return null;
}
/**
* Ask the observer about every federation it tracks.
*
* Returns null when the lookup failed, which is deliberately distinguishable from an
* empty map: an empty map means the observer answered and tracks nothing, and the
* caller writes `announced` everywhere; null means it did not answer, and the caller
* writes nothing at all.
*/
export async function fetchObserverIndex(userAgent: string): Promise<ObserverIndex | null> {
if (!OBSERVER_URL.trim()) return new Map();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), OBSERVER_TIMEOUT_MS);
try {
const res = await fetch(OBSERVER_URL, {
signal: controller.signal,
headers: { Accept: 'application/json', 'User-Agent': userAgent },
redirect: 'follow',
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body: unknown = await res.json();
if (!Array.isArray(body)) throw new Error('not a JSON array');
const index: ObserverIndex = new Map();
for (const entry of body) {
if (!entry || typeof entry !== 'object') continue;
const row = entry as Record<string, unknown>;
const id = typeof row['id'] === 'string' ? row['id'].toLowerCase() : '';
if (!/^[0-9a-f]{64}$/.test(id)) continue;
index.set(id, {
id,
name: typeof row['name'] === 'string' ? row['name'] : null,
health: readHealth(row['health']),
});
}
return index;
} catch (err) {
log.warn('fedimint observer unreachable', {
url: OBSERVER_URL,
reason: err instanceof Error ? err.message : String(err),
});
return null;
} finally {
clearTimeout(timer);
}
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Read a response body up to `maxBytes`, or refuse it.
*
* `res.arrayBuffer()` and `res.json()` buffer however much the server sends before any
* size check can run, and a mint is an untrusted server: the abort timer bounds how
* *long* a read may take, this bounds how *large* it may get, and both are needed. The
* Content-Length header is checked first as a courtesy — a chunked or lying response
* still hits the streaming cap.
*
* Returns null when the body is over the limit or unreadable. The caller treats that
* exactly like a failed fetch.
*/
export async function readBodyBounded(res: Response, maxBytes: number): Promise<Buffer | null> {
const declared = Number(res.headers.get('content-length') ?? '');
if (Number.isFinite(declared) && declared > maxBytes) return null;
const reader = res.body?.getReader();
if (!reader) return null;
const chunks: Uint8Array[] = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > maxBytes) {
await reader.cancel().catch(() => undefined);
return null;
}
chunks.push(value);
}
} catch {
// Aborted by the caller's timer, or the connection died mid-body.
return null;
}
return Buffer.concat(chunks);
}
+57 -15
View File
@@ -1,6 +1,8 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { isFetchableUrl } from '@cashumints/shared';
import { config } from './config.ts';
import { readBodyBounded } from './http.ts';
import { log } from './log.ts';
import type { MintRow } from './mints.ts';
@@ -8,13 +10,54 @@ const EXT_BY_TYPE: Record<string, string> = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/webp': '.webp',
'image/svg+xml': '.svg',
'image/gif': '.gif',
'image/x-icon': '.ico',
'image/vnd.microsoft.icon': '.ico',
// No SVG on purpose: SVG is markup that can carry script, and these files are
// re-served from the site's own origin, so a cached one opened directly would run a
// mint operator's script there. The sandbox header in server.ts covers files cached
// before this rule existed.
};
const MAX_ICON_BYTES = 512 * 1024;
/** Redirect hops followed, each hop re-checked before it is fetched. */
const MAX_REDIRECTS = 3;
/**
* Fetch an icon with redirects validated hop by hop.
*
* `icon_url` is whatever the mint's /v1/info says it is, so every address on the way —
* the first one and each Location after it — has to pass `isFetchableUrl`, or a public
* URL that 302s to a metadata endpoint walks straight around a check done only once.
*/
async function fetchIconResponse(startUrl: string, signal: AbortSignal): Promise<Response | null> {
let target = startUrl;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
if (!isFetchableUrl(target)) return null;
const res = await fetch(target, {
signal,
redirect: 'manual',
headers: { 'User-Agent': config.userAgent },
});
if (res.status >= 300 && res.status < 400) {
const location = res.headers.get('location');
if (!location) return null;
try {
target = new URL(location, target).toString();
} catch {
return null;
}
continue;
}
return res.ok ? res : null;
}
return null;
}
/**
* Cache a mint's icon to disk so offline mints keep theirs.
@@ -30,27 +73,26 @@ export async function cacheIcon(row: MintRow, iconUrl: string | null): Promise<s
try {
const absolute = new URL(iconUrl, `${row.url}/`).toString();
if (!absolute.startsWith('https://') && !absolute.startsWith('http://')) return row.icon_file;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.probeTimeoutMs);
let res: Response;
let buf: Buffer | null = null;
let ext: string | undefined;
try {
res = await fetch(absolute, {
signal: controller.signal,
headers: { 'User-Agent': config.userAgent },
});
const res = await fetchIconResponse(absolute, controller.signal);
if (!res) return row.icon_file;
const type = (res.headers.get('content-type') ?? '').split(';')[0]?.trim() ?? '';
ext = EXT_BY_TYPE[type];
if (!ext) return row.icon_file;
// The timer stays armed through the body read: the abort is what stops a server
// that sends its headers quickly and then never finishes the body.
buf = await readBodyBounded(res, MAX_ICON_BYTES);
} finally {
clearTimeout(timer);
}
if (!res.ok) return row.icon_file;
const type = (res.headers.get('content-type') ?? '').split(';')[0]?.trim() ?? '';
const ext = EXT_BY_TYPE[type];
if (!ext) return row.icon_file;
const buf = Buffer.from(await res.arrayBuffer());
if (buf.byteLength === 0 || buf.byteLength > MAX_ICON_BYTES) return row.icon_file;
if (!buf || buf.byteLength === 0) return row.icon_file;
const filename = `${row.host}${ext}`;
await fs.writeFile(path.join(config.iconDir, filename), buf);
+536
View File
@@ -0,0 +1,536 @@
/**
* On-demand indexing: `POST /api/index`.
*
* The fifth endpoint, and the first one that writes. Everything else this API serves is
* a read of rows the probe and discovery loops put there on their own schedule; this is
* a reader saying "I have an address you do not know, look at it now" and getting a page
* back in the same request.
*
* It exists because of a specific failure the site had: opening `/lnurl-mint/mint.600.wtf`
* — a live, healthy mint — returned the 404 page, because the only way into the index
* was a Nostr announcement that nobody had published. A directory whose answer to "here
* is a mint you have not got" is a 404 is a directory that can only ever list what other
* people already listed.
*
* The order of the checks below is the whole design, and each step is only reached
* because the one above it did not settle the question:
*
* 1. **Already indexed?** Then there is nothing to do and nothing to fetch. Answering
* from the row is not an optimisation, it is the correct answer: a mint's status is
* the probe loop's business, and a submission is not a reason to re-probe it.
* 2. **Does it answer, as what it claims to be?** One probe, the standard timeout,
* through the guarded fetcher in `safe-fetch.ts`.
* 3. **Does it answer as something else?** A Cashu mint pasted into the LNURL box is
* the single most likely mistake, and "that address answered, but as a Cashu mint"
* with a button to go there is worth far more than "invalid".
* 4. **Did it ever exist?** Silence over HTTPS is not proof of absence. One bounded
* relay lookup separates a mint that rugged from an address nobody ever used —
* see `relay-lookup.ts`, which is where that argument is made at length.
*
* A row written here is an ordinary row the moment it exists. The probe loop owns it
* from the next cycle, discovery will fill in its reviews, and nothing downstream can
* tell how it arrived.
*/
import {
fedimintKey,
federationIdFromInviteCode,
isIndexType,
isNut06Info,
lnurlKey,
normalizeMintUrl,
parseAdvertisement,
parsePayInfo,
PAY_INFO_PATH,
WITHDRAW_INFO_PATH,
type IndexFailure,
type IndexSource,
type IndexSuccess,
type IndexType,
type MintInfo,
} from '@cashumints/shared';
import { getDb } from './db.ts';
import { log } from './log.ts';
import { probeLnurl } from './lnurl-probe.ts';
import {
insertFedimintFromInvite,
insertLnurlIfNew,
insertMintIfNew,
mintByHost,
mintByUrl,
upsertLnurl,
type MintRow,
} from './mints.ts';
import { applyLnurlResult, recordCashuOnline } from './probe.ts';
import { getMintDetail, resetStatsCache } from './queries.ts';
import { traceOnRelays } from './relay-lookup.ts';
import { checkDestination, guardedTextFetcher, MAX_PROBE_BYTES, safeFetchText } from './safe-fetch.ts';
import { cacheIcon } from './icons.ts';
export interface IndexOutcome {
status: 200 | 201 | 404 | 422 | 429;
body: IndexSuccess | IndexFailure;
}
function fail(
status: 404 | 422 | 429,
error: IndexFailure['error'],
message: string,
extra: Partial<IndexFailure> = {},
): IndexOutcome {
return { status, body: { error, message, ...extra } };
}
/**
* The response body for a row, in the shape `GET /api/mints/:host` returns.
*
* Read back through `getMintDetail` rather than assembled here, so the payload a
* freshly indexed mint arrives in is byte for byte the payload it will have on every
* request after this one. The 404 resolver renders the same page shell from both, and
* a second shape to keep in step would be a second shape to get wrong.
*/
async function payload(host: string, existing: boolean, source?: IndexSource): Promise<IndexOutcome> {
const detail = await getMintDetail(host);
if (!detail) {
// The row was written moments ago; a miss here means the write did not land.
return fail(422, 'invalid_response', 'The mint was indexed but could not be read back');
}
// The new row changes the counts three pages print in a sentence, and those are
// memoised for a minute. A reader who has just indexed a mint should see it counted.
if (!existing) resetStatsCache();
return {
status: existing ? 200 : 201,
body: { ...detail, existing, ...(source ? { indexed_from: source } : {}) },
};
}
/* ---------- what kind of bad input is this? ---------- */
/**
* Why `normalizeMintUrl` said no.
*
* It answers null for everything, which is right for a discovery loop reading tags and
* wrong for a person who just typed something: "that is not a URL" and "we will not
* fetch a private address" are different mistakes with different fixes.
*/
function rejectionFor(input: string): IndexFailure['error'] {
const raw = input.trim();
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
try {
const url = new URL(withScheme);
if (url.protocol !== 'https:' && url.protocol !== 'http:') return 'bad_input';
return url.hostname ? 'blocked_host' : 'bad_input';
} catch {
return 'bad_input';
}
}
/* ---------- cashu ---------- */
/** What one guarded `/v1/info` request concluded. */
type InfoProbe =
| { state: 'mint'; info: MintInfo; latencyMs: number }
| { state: 'answered'; detail: string }
| { state: 'blocked'; reason: string }
| { state: 'silent'; reason: string };
async function probeCashuInfo(url: string): Promise<InfoProbe> {
const started = Date.now();
const outcome = await safeFetchText(`${url}/v1/info`, {
accept: 'application/json',
maxBytes: MAX_PROBE_BYTES,
});
if (outcome.state === 'blocked') return { state: 'blocked', reason: outcome.reason };
if (outcome.state === 'unreachable') return { state: 'silent', reason: outcome.reason };
if (outcome.status < 200 || outcome.status >= 300) {
return { state: 'answered', detail: `HTTP ${outcome.status} from /v1/info` };
}
let body: unknown;
try {
body = JSON.parse(outcome.body) as unknown;
} catch {
return { state: 'answered', detail: 'the response was not JSON' };
}
if (!isNut06Info(body)) {
return { state: 'answered', detail: 'the response was not a NUT-06 mint info document' };
}
return { state: 'mint', info: body, latencyMs: Date.now() - started };
}
/* ---------- lnurl ---------- */
/**
* Is this host an LNURL mint, asked cheaply, for wrong-type detection only.
*
* The full `probeLnurl` fetches four endpoints and is what runs when LNURL is what was
* claimed. This is the other direction — a Cashu submission that did not answer as one
* — and only has to answer yes or no, so it reads the two advertisements and stops.
*/
async function looksLikeLnurl(url: string): Promise<boolean> {
const get = guardedTextFetcher();
const [withdraw, pay] = await Promise.all([
get(`${url}${WITHDRAW_INFO_PATH}`, 'application/json', MAX_PROBE_BYTES),
get(`${url}${PAY_INFO_PATH}`, 'application/json', MAX_PROBE_BYTES),
]);
const parsed = (raw: { body: string } | null, parse: (value: unknown) => unknown): boolean => {
if (!raw) return false;
try {
return parse(JSON.parse(raw.body) as unknown) !== null;
} catch {
return false;
}
};
return parsed(withdraw, parseAdvertisement) || parsed(pay, parsePayInfo);
}
/** The same question the other way round, for an LNURL submission that did not parse. */
async function looksLikeCashu(url: string): Promise<boolean> {
const probe = await probeCashuInfo(url);
return probe.state === 'mint';
}
/* ---------- writing a row that nothing answered for ---------- */
/**
* Record a mint that Nostr remembers and HTTPS does not: the rugged-mint case.
*
* `consecutive_fails` is set straight to the offline threshold rather than to the one
* failure that actually happened, and that is deliberate. The ladder in `statusForFails`
* only ever counts upwards, so a row stored as `offline` with one failure behind it
* would be *promoted* to `degraded` by its next failed probe — a mint appearing to
* recover by staying dark. Three failures is what "offline" means everywhere else in
* this database, so that is what an offline row carries.
*/
async function markOfflineFromAnnouncement(
row: MintRow,
meta: { name: string | null; about: string | null; picture: string | null },
now: number,
): Promise<void> {
const db = await getDb();
const iconFile = meta.picture ? await cacheIcon(row, meta.picture) : row.icon_file;
await db.run(
`UPDATE mints SET
name = COALESCE(name, ?),
description = COALESCE(description, ?),
icon_url = COALESCE(icon_url, ?),
icon_file = ?,
status = 'offline',
consecutive_fails = 3,
last_probe = ?,
updated_at = ?
WHERE url = ?`,
meta.name,
meta.about,
meta.picture,
iconFile,
now,
now,
row.url,
);
// One real failed probe, because one real probe really did fail. The uptime figure
// and the sparkline should show that this site looked and got nothing.
await db.run(
'INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 0, NULL)',
row.url,
now,
);
}
/**
* The last resort for a URL nothing answered at: ask the relays, and index what they
* remember. Returns the outcome to send back, whichever way it went.
*/
async function fromRelays(
type: 'cashu' | 'lnurl',
url: string,
now: number,
): Promise<IndexOutcome> {
const trace = await traceOnRelays(type, url);
if (!trace.found) {
return fail(
404,
'unverifiable',
'Nothing answered at that address, and no announcement or review of it exists on Nostr',
);
}
/*
* An LNURL announcement is written through the same path discovery uses, so the row
* carries its features, network and announcer exactly as it would have — but only when
* the announcement is about *this* address. A `#d` filter can also match an
* announcement whose `u` is a different URL under the same host identifier, and that
* one belongs to a different row.
*/
const useAnnouncement = type === 'lnurl' && trace.lnurl?.baseUrl === url;
const created = useAnnouncement
? await upsertLnurl(trace.lnurl!, now)
: type === 'lnurl'
? await insertLnurlIfNew(url, now)
: await insertMintIfNew(url, now);
const key = created ?? (type === 'lnurl' ? lnurlKey(url) : url);
const row = await mintByUrl(key);
if (!row) return fail(422, 'invalid_response', 'The mint could not be indexed');
/*
* Only a row this call brought into being is marked offline.
*
* The `existing` check upstream means there is almost always one, but an upsert can
* land on a row that was already there — and a row the probe loop has an opinion about
* is not one an unreachable submission gets to overwrite. Its status is the loop's to
* decide; this returns what is on file instead.
*/
if (created === null && row.status !== 'unknown') return payload(row.host, true);
await markOfflineFromAnnouncement(
row,
{ name: trace.name, about: trace.about, picture: trace.picture },
now,
);
log.info('indexed from nostr', {
url: key,
type,
reviews: trace.reviews,
announced: trace.announcedAt !== null,
});
return payload(row.host, false, 'announcement');
}
/* ---------- the three type paths ---------- */
async function indexFedimint(
code: string,
federationId: string,
now: number,
): Promise<IndexOutcome> {
const key = fedimintKey(federationId);
await insertFedimintFromInvite(code, federationId, now);
const row = await mintByUrl(key);
if (!row) return fail(422, 'invalid_invite', 'The federation could not be indexed');
log.info('indexed from invite code', { url: key });
return payload(row.host, false, 'invite');
}
async function indexCashu(url: string, host: string, now: number): Promise<IndexOutcome> {
const probe = await probeCashuInfo(url);
if (probe.state === 'blocked') {
return fail(422, 'blocked_host', `That address cannot be checked: ${probe.reason}`);
}
if (probe.state === 'answered') {
if (await looksLikeLnurl(url)) {
return fail(422, 'wrong_type', 'That address answered as an LNURL mint, not a Cashu mint', {
detected_type: 'lnurl',
});
}
return fail(422, 'invalid_response', `That address answered, but ${probe.detail}`);
}
if (probe.state === 'silent') return fromRelays('cashu', url, now);
// A live mint. Insert, then write exactly what a successful probe writes.
await insertMintIfNew(url, now);
const row = await mintByUrl(url);
if (!row) {
/*
* The URL is fine and the mint answered, so the only way here is a slug already
* held by a different URL — `host/a-b` and `host/a/b` both slug to `host-a-b`. That
* is a real limitation of the routing scheme rather than anything the reader did,
* and it is worth saying so plainly instead of claiming their mint is invalid.
*/
const holder = await mintByHost(host);
return fail(
422,
'invalid_response',
holder
? `That mint's page address is already taken by ${holder.url}`
: 'The mint could not be indexed',
);
}
await recordCashuOnline(row, probe.info, probe.latencyMs, now);
log.info('indexed on demand', { url, type: 'cashu', status: 'online' });
return payload(row.host, false, 'probe');
}
async function indexLnurl(url: string, now: number): Promise<IndexOutcome> {
// The base URL's own address is checked once here, so a blocked host is reported as
// blocked rather than as four endpoints that happened not to answer.
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return fail(422, 'bad_input', 'That is not a URL');
}
const verdict = await checkDestination(parsed);
if (verdict?.kind === 'blocked') {
return fail(422, 'blocked_host', `That address cannot be checked: ${verdict.reason}`);
}
// A host whose name no longer resolves is the rugged case, not a bad submission.
if (verdict) return fromRelays('lnurl', url, now);
let result: Awaited<ReturnType<typeof probeLnurl>>;
try {
result = await probeLnurl(url, guardedTextFetcher());
} catch {
// Neither endpoint answered at all. It may still be a mint that used to be one.
return fromRelays('lnurl', url, now);
}
if (result.outcome === 'invalid') {
if (await looksLikeCashu(url)) {
return fail(422, 'wrong_type', 'That address answered as a Cashu mint, not an LNURL mint', {
detected_type: 'cashu',
});
}
return fail(
422,
'invalid_response',
`That address answered, but not as an LNURL mint: ${result.invalidReason ?? 'unrecognised response'}`,
);
}
const key = lnurlKey(url);
await insertLnurlIfNew(url, now);
const row = await mintByUrl(key);
if (!row) return fail(422, 'invalid_response', 'The mint could not be indexed');
await applyLnurlResult(row, result, now);
log.info('indexed on demand', { url: key, type: 'lnurl', status: 'online' });
return payload(row.host, false, 'probe');
}
/* ---------- the entry point ---------- */
/**
* In-flight submissions, keyed by what they would create.
*
* Two readers pasting the same address at the same moment — which is exactly what
* happens when a link is shared — must produce one probe and one row, not a race
* between two inserts and two visits to a stranger's mint. The second caller awaits the
* first one's promise and gets its answer.
*
* Keyed on the *normalized* identifier rather than the raw input, so `mint.600.wtf` and
* `https://mint.600.wtf/` collapse into one entry for the same reason they collapse
* into one row.
*/
const inFlight = new Map<string, Promise<IndexOutcome>>();
/** How many submissions are being worked on right now. For the checks. */
export function inFlightCount(): number {
return inFlight.size;
}
/** How many probes may be in flight at once, across every submitter. */
const MAX_IN_FLIGHT = 8;
export async function indexSubmission(rawType: string, rawInput: unknown): Promise<IndexOutcome> {
if (!isIndexType(rawType)) {
return fail(422, 'bad_type', 'type must be one of cashu, fedimint or lnurl');
}
const type: IndexType = rawType;
if (typeof rawInput !== 'string' || rawInput.trim() === '') {
return fail(422, 'bad_input', 'input is required');
}
// Long enough for any real invite code (they run to ~400 characters), short enough
// that nothing absurd reaches a parser.
const input = rawInput.trim().slice(0, 2048);
let key: string;
let url = '';
let host = '';
if (type === 'fedimint') {
const federationId = federationIdFromInviteCode(input);
if (!federationId) return fail(422, 'invalid_invite', 'That is not a Fedimint invite code');
key = fedimintKey(federationId);
} else {
const normalized = normalizeMintUrl(input);
if (!normalized) {
const reason = rejectionFor(input);
return fail(
422,
reason,
reason === 'blocked_host'
? 'That address is not a public one this site will check'
: 'That is not a mint URL',
);
}
url = normalized.url;
host = normalized.host;
key = type === 'lnurl' ? lnurlKey(url) : url;
}
/*
* The dedup check comes before every await that follows, and that ordering is the
* whole guarantee. An earlier version looked the row up first and registered the
* in-flight entry afterwards, which left a window: two requests that arrived in the
* same tick both got past the lookup before either had registered, and both probed.
* Nothing may be awaited between computing the key and claiming it.
*/
const pending = inFlight.get(key);
if (pending) return pending;
if (inFlight.size >= MAX_IN_FLIGHT) {
/*
* A global ceiling on outbound probes, under the same reason code as the per-address
* limit because it is the same answer to the reader: not now, try shortly. The
* per-address budget already stops one browser tab from doing this; this stops many
* of them at once from turning this process into a load generator pointed at
* whatever host they picked.
*/
return fail(429, 'rate_limited', 'Too many checks in flight right now', { retry_after: 30 });
}
const now = Math.floor(Date.now() / 1000);
const work = (async (): Promise<IndexOutcome> => {
/*
* Is this identifier already a row? The same lookup for all three types, by key
* first and then by routing slug, because the slug is what the insert path dedupes
* on — `https://mint.example.com/Bitcoin` and its lowercase spelling are one mint
* under two URLs, and answering "not indexed" for the second would create the
* duplicate the normalizer exists to prevent. Nothing is probed on this path.
*/
const existing =
(await mintByUrl(key)) ?? (host ? await rowByHostOfType(host, type) : undefined);
if (existing) return payload(existing.host, true);
if (type === 'fedimint') {
return indexFedimint(input.toLowerCase(), key.slice('fedimint:'.length), now);
}
if (type === 'lnurl') return indexLnurl(url, now);
return indexCashu(url, host, now);
})().finally(() => inFlight.delete(key));
inFlight.set(key, work);
return work;
}
/**
* A row holding this routing slug, but only when it belongs to the ecosystem asked
* about.
*
* An LNURL mint and a Cashu mint can share a hostname, and the LNURL one then takes the
* `lnurl-` prefixed slug (see `insertLnurlIfNew`). Without the type test, submitting
* that LNURL mint's URL would find the *Cashu* row on the bare slug and answer "already
* indexed" with the wrong mint's page.
*/
async function rowByHostOfType(host: string, type: IndexType): Promise<MintRow | undefined> {
const row = await mintByHost(host);
return row && row.type === type ? row : undefined;
}
+54 -15
View File
@@ -1,4 +1,5 @@
import { serve } from '@hono/node-server';
import { announceLnurlMints } from './announce.ts';
import { config } from './config.ts';
import { closeDb, getDb, pruneProbes } from './db.ts';
import { closePool, runDiscovery } from './discovery.ts';
@@ -17,17 +18,44 @@ function track<T>(work: Promise<T>): Promise<T> {
return work;
}
/*
* One cycle of each kind at a time. A cycle that outlives its interval (a relay socket
* that never closes, a wedged fetch) must not have the next timer tick start a second
* copy beside it — that doubles the relay load and interleaves state writes for no
* new information. The late cycle finishes; the ticks it swallowed are simply skipped.
*/
let probeRunning = false;
let discoveryRunning = false;
async function probeCycle(): Promise<void> {
if (shuttingDown) return;
if (shuttingDown || probeRunning) return;
probeRunning = true;
try {
await track(probeAll());
/*
* Announcing runs after probing, in the same cycle, because it publishes only what
* the probe just confirmed — running it on its own timer would mean signing for a
* state that could be an interval old. Off by default; see `announceConfig`.
*
* Its own failures are caught here rather than allowed to mark the probe cycle
* failed: whether relays accepted an event says nothing about whether this site
* successfully checked its mints.
*/
await track(announceLnurlMints()).catch((err: unknown) => {
log.error('announce cycle failed', {
reason: err instanceof Error ? err.message : String(err),
});
});
} catch (err) {
log.error('probe cycle failed', { reason: err instanceof Error ? err.message : String(err) });
} finally {
probeRunning = false;
}
}
async function discoveryCycle(backfill: boolean): Promise<void> {
if (shuttingDown) return;
if (shuttingDown || discoveryRunning) return;
discoveryRunning = true;
try {
const result = await track(runDiscovery(backfill));
// New mints are probed straight away, not on the next cycle, so their page has
@@ -37,16 +65,20 @@ async function discoveryCycle(backfill: boolean): Promise<void> {
log.error('discovery cycle failed', {
reason: err instanceof Error ? err.message : String(err),
});
} finally {
discoveryRunning = false;
}
}
function main(): void {
getDb();
async function main(): Promise<void> {
// Fail loudly here rather than on the first request: a bad DATABASE_URL, an
// unreachable Postgres or an unwritable SQLite directory should stop the boot.
await getDb();
const server = serve({ fetch: createApp().fetch, port: config.port }, (info) => {
log.info('api listening', {
port: info.port,
db: config.dbPath,
db: config.db.label,
relays: config.relays.length,
});
});
@@ -57,8 +89,13 @@ function main(): void {
config.discoveryIntervalMin * 60 * 1000,
);
const pruneTimer = setInterval(() => {
const removed = pruneProbes();
void pruneProbes()
.then((removed) => {
if (removed > 0) log.info('pruned probes', { rows: removed });
})
.catch((err: unknown) => {
log.error('prune failed', { reason: err instanceof Error ? err.message : String(err) });
});
}, DAY_MS);
// Startup: probe what we already know, then a full discovery backfill so a fresh
@@ -74,17 +111,16 @@ function main(): void {
clearInterval(discoveryTimer);
clearInterval(pruneTimer);
const stop = (): void => {
// closeDb drains the Postgres pool, so it is awaited before the process leaves.
void closeDb().finally(() => process.exit(0));
};
void inFlight.finally(() => {
closePool();
server.close(() => {
closeDb();
process.exit(0);
});
server.close(stop);
// Do not hang forever on a relay socket that refuses to close.
setTimeout(() => {
closeDb();
process.exit(0);
}, 8000).unref();
setTimeout(stop, 8000).unref();
});
};
@@ -92,4 +128,7 @@ function main(): void {
process.on('SIGTERM', () => shutdown('SIGTERM'));
}
main();
void main().catch((err: unknown) => {
log.error('startup failed', { reason: err instanceof Error ? err.message : String(err) });
process.exit(1);
});
+274
View File
@@ -0,0 +1,274 @@
/**
* Checking an LNURL mint.
*
* Unlike a federation, which has no public endpoint and is checked by reading somebody
* else's index, an LNURL mint answers over HTTPS and this site checks it itself — the
* same relationship it has with a Cashu mint. What differs is that "answered" and
* "working" are two questions here rather than one, and the endpoint layout is not what
* the software's own README describes. `NOTES-LNURL.md` records what is actually on the
* wire; the three rules that shape this file are:
*
* 1. **There is no bare `/p`.** The mint advertisement — withdraw limits, description,
* mint pubkey, node identity — lives at `/.well-known/lnurlw/_`. The payRequest at
* `/.well-known/lnurlp/_` carries the pay-side limits and the fee, and is the
* fallback when the withdraw side does not answer.
* 2. **HTTP 200 proves nothing.** Every registered route returns 200 with an LNURL
* `{"status":"ERROR"}` body for its errors. Only the parsed body decides.
* 3. **Absence is the signal.** `None` fields are dropped from responses entirely, so
* a missing `mintPubkey` is how "the funding source is not reachable" reaches the
* wire. That is the degraded-but-online state.
*
* Nothing here calls anything that mutates. `/p/cb` would make the mint issue a real
* invoice on its operator's node, and `/w/cb` burns notes; neither is something a
* directory gets to do to a stranger on a ten minute timer. The cost of that restraint
* is that LUD-21 verify cannot be detected at all — see the kind document, which makes
* that normative rather than incidental.
*/
import {
PAY_INFO_PATH,
WITHDRAW_INFO_PATH,
addressFromPayLink,
onionFromHtml,
parseAdvertisement,
parsePayInfo,
parseSoftware,
type LnurlAdvertisement,
type LnurlPayInfo,
} from '@cashumints/shared';
import { config } from './config.ts';
import { readBodyBounded } from './http.ts';
/**
* Body caps, per endpoint.
*
* The JSON documents are a few hundred bytes; 64KB is room for a mint with an unusually
* chatty metadata blob and nothing more. The one-pager is real HTML with an inline QR
* SVG — 13.8KB on the live instance — so it gets its own, larger cap. Both bound an
* untrusted server, in bytes, on top of the abort timer that bounds it in seconds.
*/
const MAX_JSON_BYTES = 64 * 1024;
const MAX_HTML_BYTES = 512 * 1024;
/** What one probe of one LNURL mint concluded. */
export interface LnurlProbeResult {
/**
* `online` — a mint advertisement parsed, and the mint's Lightning node answered.
* `degraded-funding` — an advertisement parsed, but the node behind it did not, so
* minting and melting are unavailable while rotate/split/merge still work. Still
* an `ok` probe: the mint is up, and this is what warnings are for.
* `invalid` — the host answered with something that is not a mint advertisement.
* Neither up nor down, and counted as a failed probe with a reason attached.
*/
outcome: 'online' | 'degraded-funding' | 'invalid';
latencyMs: number;
/** Which path answered. Stored so a page can say what was actually checked. */
endpoint: string;
advertisement: LnurlAdvertisement | null;
pay: LnurlPayInfo | null;
lightningAddress: string | null;
onionUrl: string | null;
software: string | null;
/** Short, human-readable, and only set when `outcome` is `invalid`. */
invalidReason: string | null;
}
/**
* How this module reaches a mint.
*
* A parameter rather than a hard-wired `fetch`, because there are two callers with
* genuinely different threat models. The probe loop checks addresses that reached the
* database through discovery or the seed list, and uses the plain fetcher below.
* `POST /api/index` checks an address a stranger typed thirty seconds ago, and passes
* the guarded one from `safe-fetch.ts`, which resolves DNS and refuses private ranges
* before a socket opens. The parsing, the endpoint layout and every rule about what
* counts as a mint are identical either way, which is the point of the seam.
*/
export type TextFetcher = (
url: string,
accept: string,
maxBytes: number,
) => Promise<{ body: string; status: number } | null>;
/** A fetch that is bounded in time and in bytes, and never throws for a caller. */
const fetchBounded: TextFetcher = async (
url: string,
accept: string,
maxBytes: number,
): Promise<{ body: string; status: number } | null> => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.probeTimeoutMs);
try {
const res = await fetch(url, {
signal: controller.signal,
headers: { Accept: accept, 'User-Agent': config.userAgent },
redirect: 'follow',
});
const body = await readBodyBounded(res, maxBytes);
if (!body) return null;
return { body: body.toString('utf8'), status: res.status };
} catch {
// Timed out, DNS failure, TLS failure, connection reset. All the same to a caller:
// nothing was learned.
return null;
} finally {
clearTimeout(timer);
}
};
/**
* What one JSON endpoint did.
*
* Three outcomes, not two, and the middle one is why this is not simply
* `Promise<unknown | null>`: a host that answered with something that will not parse is
* *reachable*, and the site owes its readers a different sentence for that than for a
* host that is not there at all. Collapsing them would file every misconfigured proxy
* and every parked domain under "offline".
*/
type JsonProbe =
| { state: 'unreachable' }
| { state: 'unparseable'; status: number }
| { state: 'parsed'; value: unknown; status: number };
async function fetchJson(url: string, get: TextFetcher): Promise<JsonProbe> {
const res = await get(url, 'application/json', MAX_JSON_BYTES);
if (!res) return { state: 'unreachable' };
try {
return { state: 'parsed', value: JSON.parse(res.body) as unknown, status: res.status };
} catch {
return { state: 'unparseable', status: res.status };
}
}
/** The parsed body, or null for anything that did not parse. */
function jsonValue(probe: JsonProbe): unknown {
return probe.state === 'parsed' ? probe.value : null;
}
/**
* The reason string for a response that arrived but was not a mint advertisement.
*
* Kept short and specific, because it is what the "responding but invalid" banner shows
* underneath and because the three cases are genuinely different problems: a host that
* is not this software at all, a host that is but is refusing, and a host serving
* something that is not JSON.
*/
function invalidReasonFor(probe: JsonProbe): string {
if (probe.state === 'unreachable') return 'no response';
if (probe.state === 'unparseable') {
// Almost always an HTML error page from a proxy, or a parked domain. The status
// code is the only part of it worth repeating back.
return `HTTP ${probe.status}, and the body was not JSON`;
}
const body = probe.value;
if (body === null || body === undefined) return 'empty JSON response';
if (typeof body !== 'object' || Array.isArray(body)) return 'response was not a JSON object';
const record = body as Record<string, unknown>;
// The LNURL error convention: 200 with a status/reason pair. Common and informative.
if (record['status'] === 'ERROR') {
const reason = typeof record['reason'] === 'string' ? record['reason'].slice(0, 120) : '';
return reason ? `mint replied: ${reason}` : 'mint replied with an LNURL error';
}
if (typeof record['tag'] === 'string') return `unexpected tag "${String(record['tag']).slice(0, 40)}"`;
if (record['detail'] !== undefined) return 'endpoint not found on this host';
return 'response was not a mint advertisement';
}
/**
* Probe one LNURL mint.
*
* The withdraw side first, because it is the only endpoint carrying everything the site
* renders. The payRequest is fetched too, but for different reasons in the two cases:
* alongside a good advertisement it adds the fee, the pay-side limits and a
* human-written description; when the advertisement failed it is the fallback that can
* still prove the host is a live LNURL mint whose withdraw alias is simply configured
* under a username this probe cannot guess.
*
* The two opportunistic reads — the one-pager for a Tor address, `/openapi.json` for a
* version — never affect the outcome. A mint is not less online because its operator
* turned off the docs endpoint.
*/
export async function probeLnurl(
baseUrl: string,
get: TextFetcher = fetchBounded,
): Promise<LnurlProbeResult> {
const started = Date.now();
const [withdrawProbe, payProbe] = await Promise.all([
fetchJson(`${baseUrl}${WITHDRAW_INFO_PATH}`, get),
fetchJson(`${baseUrl}${PAY_INFO_PATH}`, get),
]);
const advertisement = parseAdvertisement(jsonValue(withdrawProbe));
const pay = parsePayInfo(jsonValue(payProbe));
const latencyMs = Date.now() - started;
const base = {
latencyMs,
advertisement,
pay,
lightningAddress: addressFromPayLink(advertisement?.payLink) ?? null,
};
if (!advertisement && !pay) {
/*
* Nothing parsed on either side. Distinguish "the host said something" from "the
* host said nothing at all": a timeout is an ordinary failed probe and feeds the
* consecutive-fails machinery as a plain offline, while a reply that is not a mint
* advertisement is the invalid state and gets to say why.
*/
const answered =
withdrawProbe.state !== 'unreachable' || payProbe.state !== 'unreachable';
if (!answered) throw new Error('no response from either LNURL endpoint');
/*
* Report on whichever endpoint actually said something, preferring the withdraw
* side. A host whose withdraw alias times out while its payRequest returns an HTML
* error page should say what the payRequest did, not "no response".
*/
const reported = withdrawProbe.state === 'unreachable' ? payProbe : withdrawProbe;
return {
...base,
outcome: 'invalid',
endpoint: withdrawProbe.state === 'unreachable' ? PAY_INFO_PATH : WITHDRAW_INFO_PATH,
onionUrl: null,
software: null,
invalidReason: invalidReasonFor(reported),
};
}
// Only worth two extra requests once the host has proved it is a mint.
const [html, openapi] = await Promise.all([
get(`${baseUrl}/`, 'text/html', MAX_HTML_BYTES),
fetchJson(`${baseUrl}/openapi.json`, get),
]);
const extras = {
onionUrl: html ? onionFromHtml(html.body) : null,
software: parseSoftware(jsonValue(openapi)),
invalidReason: null,
};
if (!advertisement) {
/*
* The payRequest answered and the withdraw alias did not.
*
* Still online — the host is demonstrably a live LNURL mint — but the site has no
* withdraw limits, no mint pubkey and no node identity for it, and `funding_available`
* stays unknown rather than being guessed at from the pay side. `/.well-known/lnurlp/_`
* is recorded as the endpoint so the page says what was actually checked.
*/
return { ...base, ...extras, outcome: 'online', endpoint: PAY_INFO_PATH };
}
return {
...base,
...extras,
outcome: advertisement.fundingAvailable ? 'online' : 'degraded-funding',
endpoint: WITHDRAW_INFO_PATH,
};
}
+201
View File
@@ -0,0 +1,201 @@
/**
* Copy the whole database from one backend to the other.
*
* pnpm --filter ./api migrate --to postgres://user:pw@localhost/cashumints
* pnpm --filter ./api migrate --from postgres://… --to ./data/cashumints.db
*
* `--from` defaults to whatever the running configuration points at (DATABASE_URL, or
* DB_PATH's SQLite file), so the common direction — the SQLite file you already have,
* into a fresh Postgres — needs only `--to`.
*
* Both sides accept either spelling, so this is also how you move one SQLite file to
* another, or one Postgres database to another.
*
* The target's schema is created if it is missing. Rows are upserted on the primary
* key, so an interrupted run can simply be repeated. `probes` is the exception: it is
* an append-only log with no unique key, so a target that already holds probe rows is
* left alone unless `--force`, which replaces them wholesale.
*
* Icons are files, not rows. `ICON_DIR` is shared by both backends and nothing here
* touches it.
*/
import { parseDbTarget, resolveDbConfig, type DbConfig } from './config.ts';
import type { Db, Param } from './db-driver.ts';
import { COLUMNS, PRIMARY_KEY, TABLES, type TableName } from './db-schema.ts';
import { openDb } from './db.ts';
import { log } from './log.ts';
/** Rows per statement. 16 columns x 500 stays far under Postgres' 65535 parameter cap. */
const BATCH = 500;
interface Options {
from: DbConfig;
to: DbConfig;
force: boolean;
dryRun: boolean;
}
function usage(): string {
return [
'Usage: migrate --to <target> [--from <source>] [--force] [--dry-run]',
'',
' <target>, <source> postgres://user:pw@host:5432/db',
' /var/lib/cashumints/cashumints.db',
' sqlite:./data/cashumints.db',
'',
' --from defaults to the configured database (DATABASE_URL, else DB_PATH)',
' --force replace the target probes table when it already has rows',
' --dry-run report what would be copied, write nothing',
].join('\n');
}
export function parseArgs(argv: string[]): Options {
let from: string | undefined;
let to: string | undefined;
let force = false;
let dryRun = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--from') from = argv[++i];
else if (arg === '--to') to = argv[++i];
else if (arg === '--force') force = true;
else if (arg === '--dry-run') dryRun = true;
else if (arg === '--help' || arg === '-h') throw new Error(usage());
else throw new Error(`Unknown argument "${arg}"\n\n${usage()}`);
}
if (!to) throw new Error(`--to is required.\n\n${usage()}`);
const source = from ? parseDbTarget(from) : resolveDbConfig();
const target = parseDbTarget(to);
if (source.label === target.label) {
throw new Error(`Source and target are the same database (${source.label}).`);
}
return { from: source, to: target, force, dryRun };
}
async function countRows(db: Db, table: TableName): Promise<number> {
const row = await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM ${table}`);
return row?.n ?? 0;
}
/** `INSERT … ON CONFLICT (pk) DO UPDATE`, or a plain insert for the keyless probes table. */
function insertStatement(table: TableName, rows: number): string {
const cols = COLUMNS[table];
const placeholders = Array.from({ length: rows }, () => `(${cols.map(() => '?').join(', ')})`);
const pk = PRIMARY_KEY[table];
const conflict =
pk === null
? ''
: ` ON CONFLICT (${pk}) DO UPDATE SET ${cols
.filter((c) => c !== pk)
.map((c) => `${c} = excluded.${c}`)
.join(', ')}`;
return `INSERT INTO ${table} (${cols.join(', ')}) VALUES ${placeholders.join(', ')}${conflict}`;
}
async function copyTable(source: Db, target: Db, table: TableName): Promise<number> {
const cols = COLUMNS[table];
const pk = PRIMARY_KEY[table];
// Read the whole table. The largest of them is probes, at roughly 13k rows per mint
// per 90-day retention window — tens of megabytes at the very top end, and paging it
// would need a stable sort key that probes does not have.
const rows = await source.all<Record<string, Param>>(`SELECT ${cols.join(', ')} FROM ${table}`);
if (rows.length === 0) return 0;
// Same primary key twice in one statement is an error in Postgres, not a silent
// overwrite. A source that is itself consistent cannot produce one, but a hand-edited
// file can, and the failure is confusing enough to be worth heading off.
const deduped =
pk === null ? rows : [...new Map(rows.map((r) => [String(r[pk]), r])).values()];
await target.transaction(async (tx) => {
for (let i = 0; i < deduped.length; i += BATCH) {
const chunk = deduped.slice(i, i + BATCH);
await tx.run(
insertStatement(table, chunk.length),
...chunk.flatMap((row) => cols.map((c) => row[c] ?? null)),
);
}
});
return deduped.length;
}
export async function migrate(options: Options): Promise<void> {
log.info('migrating', { from: options.from.label, to: options.to.label });
const source = await openDb(options.from);
let target: Db | null = null;
try {
// openDb creates the schema, so the target may be a database with nothing in it.
target = await openDb(options.to);
const existingProbes = await countRows(target, 'probes');
if (existingProbes > 0 && !options.force && !options.dryRun) {
throw new Error(
`Target already holds ${existingProbes} probe rows. probes has no primary key, so ` +
'they cannot be upserted. Re-run with --force to replace them, or drop the table first.',
);
}
if (options.dryRun) {
for (const table of TABLES) {
log.info('would copy', {
table,
rows: await countRows(source, table),
target_rows: await countRows(target, table),
});
}
log.info('dry run, nothing written');
return;
}
if (existingProbes > 0) {
await target.run('DELETE FROM probes');
log.info('cleared target probes', { rows: existingProbes });
}
for (const table of TABLES) {
const started = Date.now();
const copied = await copyTable(source, target, table);
log.info('copied', { table, rows: copied, ms: Date.now() - started });
}
// Read the counts back from the target rather than trusting the copy loop.
for (const table of TABLES) {
const [before, after] = await Promise.all([
countRows(source, table),
countRows(target, table),
]);
const verdict = after >= before ? 'ok' : 'SHORT';
log.info('verify', { table, source: before, target: after, verdict });
if (after < before) {
throw new Error(`${table}: target has ${after} rows, source has ${before}`);
}
}
log.info('migration complete', { to: options.to.label });
} finally {
await source.close().catch(() => undefined);
if (target) await target.close().catch(() => undefined);
}
}
// Only when run directly, so the functions above stay importable from a test.
if (process.argv[1] && import.meta.filename === process.argv[1]) {
try {
await migrate(parseArgs(process.argv.slice(2)));
process.exit(0);
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
}
+450 -27
View File
@@ -1,23 +1,36 @@
import { normalizeMintUrl } from '@cashumints/shared';
import {
LNURL_SLUG_PREFIX, fedimintKey, fedimintSlug, lnurlIdentifier, lnurlKey, normalizeMintUrl,
type FedimintAnnouncement, type FedimintFields, type LnurlAnnouncement, type LnurlFields,
} from '@cashumints/shared';
import { getDb } from './db.ts';
import { log } from './log.ts';
/**
* URLs already reported as unusable. A rejected value (a Fedimint `fed11…` invite, an
* onion address) recurs in dozens of events per cycle, and logging each occurrence
* buries the lines that matter.
* URLs already reported as unusable. A rejected value (an onion address, a bare label)
* recurs in dozens of events per cycle, and logging each occurrence buries the lines
* that matter.
*
* Fedimint invite codes used to be the loudest entry in here, arriving as `u` tags this
* function could make no sense of. They have their own path now and never reach it.
*/
const reportedSkips = new Set<string>();
export interface MintRow {
url: string;
host: string;
/** 'cashu', 'fedimint' or 'lnurl'. Rows written before the column existed default to 'cashu'. */
type: string;
name: string | null;
description: string | null;
icon_url: string | null;
icon_file: string | null;
pubkey: string | null;
info_json: string | null;
/**
* Type-specific data: `FedimintFields` for a federation, `LnurlFields` for an LNURL
* mint, null for a Cashu one. Read it back through `parseEcosystem`.
*/
ecosystem_json: string | null;
nuts_json: string | null;
version: string | null;
status: string;
@@ -44,7 +57,10 @@ export interface MintRow {
* If that ever stops holding, the upgrade is to probe both casings once and keep the
* one that answers.
*/
export function insertMintIfNew(rawUrl: string, now = Math.floor(Date.now() / 1000)): string | null {
export async function insertMintIfNew(
rawUrl: string,
now = Math.floor(Date.now() / 1000),
): Promise<string | null> {
const normalized = normalizeMintUrl(rawUrl);
if (!normalized) {
if (!reportedSkips.has(rawUrl)) {
@@ -54,41 +70,448 @@ export function insertMintIfNew(rawUrl: string, now = Math.floor(Date.now() / 10
return null;
}
const result = getDb()
.prepare(
// OR IGNORE covers both unique keys, url and host, in one clause.
`INSERT OR IGNORE INTO mints (url, host, status, first_seen, updated_at)
VALUES (?, ?, 'unknown', ?, ?)`,
)
.run(normalized.url, normalized.host, now, now);
const db = await getDb();
// No conflict target, so this covers both unique keys — url and host — in one clause.
// (`INSERT OR IGNORE` would too, but only SQLite knows that spelling.)
const result = await db.run(
`INSERT INTO mints (url, host, type, status, first_seen, updated_at)
VALUES (?, ?, 'cashu', 'unknown', ?, ?)
ON CONFLICT DO NOTHING`,
normalized.url,
normalized.host,
now,
now,
);
return result.changes > 0 ? normalized.url : null;
if (result.changes > 0) return normalized.url;
/*
* The no-op insert is almost always this exact URL already being tracked. The other
* possibility is a *different* URL whose slug collides (`host/a-b` vs `host/a/b`
* both slug to `host-a-b`): that mint is silently not tracked and any review of it
* files under the other one, which is worth a log line the first time it happens.
*/
const holder = await db.get<{ url: string }>(
'SELECT url FROM mints WHERE host = ?',
normalized.host,
);
if (holder && holder.url !== normalized.url && !reportedSkips.has(normalized.url)) {
reportedSkips.add(normalized.url);
log.warn('slug collision, mint not tracked', {
url: normalized.url,
host: normalized.host,
existing: holder.url,
});
}
return null;
}
/* ---------- fedimint ---------- */
/**
* Read a row's type-specific column back out.
*
* Generic, defaulting to `FedimintFields` so every existing call site reads exactly as
* it did. The caller already knows the row's `type` — it is what decided to call this
* at all — so the type argument is a statement of what was stored, not a guess.
*
* A column that will not parse degrades to null rather than throwing. Such a row still
* has a page and still resolves by its key; it simply has no ecosystem-specific fields
* on it, which is honest and is better than a probe cycle dying on one bad row.
*/
export function parseEcosystem<T = FedimintFields>(
row: Pick<MintRow, 'ecosystem_json'>,
): T | null {
if (!row.ecosystem_json) return null;
try {
return JSON.parse(row.ecosystem_json) as T;
} catch {
return null;
}
}
/**
* Insert or refresh a federation from a kind 38173 announcement.
*
* Deduped on the federation id, which is the only identity a federation has: the same
* federation is announced by several people (two different npubs currently announce
* "Bitcoin Principles" with the same `d`), and every one of those is the same thing to
* join. One row, keyed on the id, whoever published it.
*
* Unlike `insertMintIfNew` this does update an existing row, and it has to: an
* announcement is the *only* source of a federation's invite codes, modules and name,
* where a Cashu mint's row is refreshed by probing the mint itself. Older announcements
* are ignored (`announced_at` goes forwards only) so a replayed event from last year
* cannot overwrite this week's invite code.
*
* The status columns are never touched here. Whether a federation is up is a probe's
* answer, and an announcement is not evidence of anything being up.
*
* Returns the row's key when a row was created, null when one was merely updated.
*/
export async function upsertFedimint(
announcement: FedimintAnnouncement,
now = Math.floor(Date.now() / 1000),
): Promise<string | null> {
const db = await getDb();
const url = fedimintKey(announcement.federationId);
const host = fedimintSlug(announcement.federationId);
const fields: FedimintFields = {
federation_id: announcement.federationId,
invite_codes: announcement.inviteCodes,
modules: announcement.modules,
network: announcement.network,
announced_at: announcement.announcedAt,
announcer_pubkey: announcement.announcerPubkey,
// Set by the probe, not by an announcement. Carried over below when a row exists.
status_source: null,
};
const existing = await db.get<MintRow>('SELECT * FROM mints WHERE url = ?', url);
if (!existing) {
await db.run(
`INSERT INTO mints (url, host, type, name, description, icon_url, ecosystem_json,
status, first_seen, updated_at)
VALUES (?, ?, 'fedimint', ?, ?, ?, ?, 'announced', ?, ?)
ON CONFLICT DO NOTHING`,
url,
host,
announcement.name,
announcement.about,
announcement.picture,
JSON.stringify(fields),
now,
now,
);
return url;
}
const previous = parseEcosystem(existing);
if (previous && (previous.announced_at ?? 0) > announcement.announcedAt) return null;
await db.run(
`UPDATE mints SET
name = COALESCE(?, name),
description = COALESCE(?, description),
icon_url = COALESCE(?, icon_url),
ecosystem_json = ?,
updated_at = ?
WHERE url = ?`,
announcement.name,
announcement.about,
announcement.picture,
JSON.stringify({ ...fields, status_source: previous?.status_source ?? null }),
now,
url,
);
return null;
}
/**
* Insert a federation from an invite code alone, with no announcement behind it.
*
* For `POST /api/index`: a reader pastes the one thing they have, and there is nothing
* else to go on. The id comes out of the code itself (`federationIdFromInviteCode`), so
* the row is keyed exactly as an announced one would be and the two can never become
* two rows for one federation.
*
* `announced` rather than `unknown`, and the distinction is the same one the status
* carries everywhere else: `unknown` means nothing has checked yet, `announced` means
* there is nothing this site *can* check. A federation has no public endpoint, so an
* invite code is exactly as much as anyone will ever be able to confirm from here until
* the observer lookup covers it.
*
* Returns the row key when a row was created, null when one already existed.
*/
export async function insertFedimintFromInvite(
code: string,
federationId: string,
now = Math.floor(Date.now() / 1000),
): Promise<string | null> {
const db = await getDb();
const url = fedimintKey(federationId);
const fields: FedimintFields = {
federation_id: federationId,
invite_codes: [code.trim().toLowerCase()],
// Everything an announcement would have carried. Discovery fills these in when one
// turns up, and until then the page says only what the code itself proves.
modules: [],
network: null,
announced_at: null,
announcer_pubkey: null,
status_source: null,
};
const result = await db.run(
`INSERT INTO mints (url, host, type, ecosystem_json, status, first_seen, updated_at)
VALUES (?, ?, 'fedimint', ?, 'announced', ?, ?)
ON CONFLICT DO NOTHING`,
url,
fedimintSlug(federationId),
JSON.stringify(fields),
now,
now,
);
return result.changes > 0 ? url : null;
}
/** Every federation row, for the probe cycle and for review resolution. */
export async function fedimintRows(): Promise<MintRow[]> {
const db = await getDb();
return db.all<MintRow>(`SELECT * FROM mints WHERE type = 'fedimint'`);
}
/* ---------- lnurl ---------- */
/**
* The routing slug an LNURL mint gets, given what the table already holds.
*
* `mints.host` carries a UNIQUE index across every ecosystem, so a Cashu mint and an
* LNURL mint on one hostname would compete for a single slug and — with the existing
* insert path — the loser would silently not be tracked at all. That is the one outcome
* worth writing code to avoid: a mint that exists, is reviewable, and has no page.
*
* So the clean slug is used whenever it is free, which is very nearly always, and a
* colliding row takes `lnurl-` in front instead. Decided once at insert and then stored,
* never recomputed: a mint that took the prefixed slug keeps it even if the row it
* collided with later disappears, because its URL is already indexed and linked and a
* silently moving page is worse than a slightly long one.
*/
async function lnurlSlug(preferred: string, url: string): Promise<string | null> {
const db = await getDb();
const takenBy = async (slug: string): Promise<string | null> => {
const row = await db.get<{ url: string }>('SELECT url FROM mints WHERE host = ?', slug);
return row && row.url !== url ? row.url : null;
};
const clash = await takenBy(preferred);
if (!clash) return preferred;
const prefixed = LNURL_SLUG_PREFIX + preferred;
const secondClash = await takenBy(prefixed);
if (!secondClash) {
log.info('lnurl slug taken, using prefixed form', { url, slug: prefixed, existing: clash });
return prefixed;
}
log.warn('lnurl slug collision, mint not tracked', { url, slug: preferred, existing: clash });
return null;
}
/** The `ecosystem_json` an LNURL row starts life with, before anything has probed it. */
function blankLnurlFields(baseUrl: string, identifier: string): LnurlFields {
return {
lnurl_id: identifier,
base_url: baseUrl,
features: [],
network: null,
announced_at: null,
announcer_pubkey: null,
mint_pubkey: null,
funding_available: null,
probe_endpoint: null,
invalid_reason: null,
min_withdrawable_msat: null,
max_withdrawable_msat: null,
min_sendable_msat: null,
max_sendable_msat: null,
fee_base_msat: null,
fee_ppm: null,
lightning_address: null,
onion_url: null,
node_alias: null,
node_uri: null,
node_capacity_msat: null,
node_channels: null,
node_peers: null,
observed_features: [],
};
}
/**
* Insert an LNURL mint by URL alone, with no announcement behind it.
*
* For the seed list and for operators added by hand. `status` starts `unknown` and the
* very next probe cycle decides it, exactly as a seeded Cashu mint works — nothing here
* claims the mint is up, and `insertMintIfNew`'s rule that an existing row is never
* touched applies just as strictly.
*
* Returns the row key when a row was created, null otherwise.
*/
export async function insertLnurlIfNew(
rawUrl: string,
now = Math.floor(Date.now() / 1000),
): Promise<string | null> {
const normalized = normalizeMintUrl(rawUrl);
if (!normalized) {
if (!reportedSkips.has(rawUrl)) {
reportedSkips.add(rawUrl);
log.warn('skipped lnurl url', { url: rawUrl.slice(0, 80) });
}
return null;
}
const db = await getDb();
const url = lnurlKey(normalized.url);
const existing = await db.get<{ url: string }>('SELECT url FROM mints WHERE url = ?', url);
if (existing) return null;
const host = await lnurlSlug(normalized.host, url);
if (host === null) return null;
const result = await db.run(
`INSERT INTO mints (url, host, type, ecosystem_json, status, first_seen, updated_at)
VALUES (?, ?, 'lnurl', ?, 'unknown', ?, ?)
ON CONFLICT DO NOTHING`,
url,
host,
JSON.stringify(blankLnurlFields(normalized.url, lnurlIdentifier(normalized.url, null))),
now,
now,
);
return result.changes > 0 ? url : null;
}
/**
* Insert or refresh an LNURL mint from a kind 38174 announcement.
*
* **Deduped on the normalized base URL, not on the `d` tag**, and that is the whole
* reason this function is not a copy of `upsertFedimint`. An LNURL mint's identifier is
* its funding node's pubkey when it has one and its host otherwise, so the same mint
* legitimately appears under two different `d` values across its life — announced by
* host before a funding source was configured, by pubkey afterwards. Keying rows on `d`
* would split one mint into two pages with half its reviews on each. The `u` tag is the
* one value present in every form of the event, so it is the key.
*
* Unlike `insertMintIfNew` this does update an existing row, because an announcement is
* the only source of `features`, the network and the operator's own name for the mint.
* Older announcements are ignored (`announced_at` goes forwards only) so a replayed
* event from last year cannot overwrite this week's capability list.
*
* Two things are deliberately never written here:
*
* - **The status columns.** Whether a mint is up is a probe's answer. An announcement
* is not evidence of anything being up.
* - **Anything a probe learned.** `mint_pubkey`, the limits, the address, the node
* fields and `funding_available` are all carried over from the existing row
* untouched. In particular a `mint_pubkey` already observed is *never* cleared by an
* announcement that lacks one: identity is sticky, per the kind document.
*
* Returns the row's key when a row was created, null when one was merely updated.
*/
export async function upsertLnurl(
announcement: LnurlAnnouncement,
now = Math.floor(Date.now() / 1000),
): Promise<string | null> {
const db = await getDb();
const url = lnurlKey(announcement.baseUrl);
const existing = await db.get<MintRow>('SELECT * FROM mints WHERE url = ?', url);
const previous = existing ? parseEcosystem<LnurlFields>(existing) : null;
if (previous && (previous.announced_at ?? 0) > announcement.announcedAt) return null;
/*
* The stored pubkey wins over the announcement's.
*
* A probe reads `mintPubkey` off the mint itself; an announcement is a stranger's
* claim about it. Where both exist the observed one is the better evidence, and where
* only the announcement has one it is still worth keeping, so this prefers the probe
* and falls back rather than overwriting in either direction.
*/
const mintPubkey = previous?.mint_pubkey ?? announcement.mintPubkey;
const fields: LnurlFields = {
...(previous ?? blankLnurlFields(announcement.baseUrl, announcement.identifier)),
lnurl_id: lnurlIdentifier(announcement.baseUrl, mintPubkey),
base_url: announcement.baseUrl,
features: announcement.features,
network: announcement.network,
announced_at: announcement.announcedAt,
announcer_pubkey: announcement.announcerPubkey,
mint_pubkey: mintPubkey,
};
if (!existing) {
const host = await lnurlSlug(announcement.slug, url);
if (host === null) return null;
await db.run(
`INSERT INTO mints (url, host, type, name, description, icon_url, ecosystem_json,
status, first_seen, updated_at)
VALUES (?, ?, 'lnurl', ?, ?, ?, ?, 'unknown', ?, ?)
ON CONFLICT DO NOTHING`,
url,
host,
announcement.name,
announcement.about,
announcement.picture,
JSON.stringify(fields),
now,
now,
);
return url;
}
await db.run(
`UPDATE mints SET
name = COALESCE(?, name),
description = COALESCE(?, description),
icon_url = COALESCE(?, icon_url),
ecosystem_json = ?,
updated_at = ?
WHERE url = ?`,
announcement.name,
announcement.about,
announcement.picture,
JSON.stringify(fields),
now,
url,
);
return null;
}
/** Every LNURL row, for the probe cycle and for review resolution. */
export async function lnurlRows(): Promise<MintRow[]> {
const db = await getDb();
return db.all<MintRow>(`SELECT * FROM mints WHERE type = 'lnurl'`);
}
/** Canonical mint URL for a normalized slug, or null if no such mint is tracked. */
export function mintUrlByHost(host: string): string | null {
const row = getDb().prepare('SELECT url FROM mints WHERE host = ?').get(host) as
| { url: string }
| undefined;
export async function mintUrlByHost(host: string): Promise<string | null> {
const db = await getDb();
const row = await db.get<{ url: string }>('SELECT url FROM mints WHERE host = ?', host);
return row?.url ?? null;
}
export function allMintRows(): MintRow[] {
return getDb().prepare('SELECT * FROM mints').all() as MintRow[];
export async function allMintRows(): Promise<MintRow[]> {
const db = await getDb();
return db.all<MintRow>('SELECT * FROM mints');
}
export function mintByHost(host: string): MintRow | undefined {
return getDb().prepare('SELECT * FROM mints WHERE host = ?').get(host) as MintRow | undefined;
export async function mintByHost(host: string): Promise<MintRow | undefined> {
const db = await getDb();
return db.get<MintRow>('SELECT * FROM mints WHERE host = ?', host);
}
export function mintByUrl(url: string): MintRow | undefined {
return getDb().prepare('SELECT * FROM mints WHERE url = ?').get(url) as MintRow | undefined;
export async function mintByUrl(url: string): Promise<MintRow | undefined> {
const db = await getDb();
return db.get<MintRow>('SELECT * FROM mints WHERE url = ?', url);
}
/** Resolve a mint by the pubkey it publishes in /v1/info, for reviews with only a `d` tag. */
export function mintUrlByPubkey(pubkey: string): string | null {
const row = getDb().prepare('SELECT url FROM mints WHERE pubkey = ? LIMIT 1').get(pubkey) as
| { url: string }
| undefined;
export async function mintUrlByPubkey(pubkey: string): Promise<string | null> {
const db = await getDb();
const row = await db.get<{ url: string }>('SELECT url FROM mints WHERE pubkey = ? LIMIT 1', pubkey);
return row?.url ?? null;
}
+517 -48
View File
@@ -1,9 +1,15 @@
import { parseNuts, type MintInfo } from '@cashumints/shared';
import {
lnurlIdentifier, observedFeatures, parseNuts,
type FedimintFields, type LnurlFields, type MintInfo,
} from '@cashumints/shared';
import { config } from './config.ts';
import { getDb, setState } from './db.ts';
import { fetchObserverIndex, OBSERVER_SOURCE, type ObserverIndex } from './fedimint-observer.ts';
import { readBodyBounded } from './http.ts';
import { cacheIcon } from './icons.ts';
import { log } from './log.ts';
import { allMintRows, mintByUrl, type MintRow } from './mints.ts';
import { probeLnurl } from './lnurl-probe.ts';
import { allMintRows, mintByUrl, parseEcosystem, type MintRow } from './mints.ts';
export interface ProbeResult {
url: string;
@@ -19,45 +25,34 @@ export function statusForFails(fails: number): 'online' | 'degraded' | 'offline'
return fails < 3 ? 'degraded' : 'offline';
}
async function fetchInfo(url: string): Promise<{ info: MintInfo; latencyMs: number }> {
const started = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.probeTimeoutMs);
try {
const res = await fetch(`${url}/v1/info`, {
signal: controller.signal,
headers: { Accept: 'application/json', 'User-Agent': config.userAgent },
redirect: 'follow',
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const info = (await res.json()) as MintInfo;
if (!info || typeof info !== 'object') throw new Error('not a JSON object');
return { info, latencyMs: Date.now() - started };
} finally {
clearTimeout(timer);
}
}
/**
* The largest /v1/info accepted. Real payloads are a few KB; the whole document is
* stored in `info_json` and served back on every detail request, so an unbounded one
* is both a memory spike here and a bloated row forever after.
*/
const MAX_INFO_BYTES = 256 * 1024;
/**
* Probe one mint and write the result.
* Write everything a successful Cashu probe learned, and record the probe.
*
* On failure the cached metadata columns are deliberately left untouched. That cache is
* what lets an offline mint still render its full page, which is acceptance criterion #1.
* Split out of `probeMint` because the on-demand indexer has already made this exact
* request: a reader pastes a mint URL, `POST /api/index` fetches `/v1/info` once
* through the guarded fetcher to decide whether the address is a mint at all, and then
* needs the row to end up in precisely the state a probe cycle would have left it in.
* Re-probing to achieve that would hit a stranger's mint twice for one submission and
* would leave two ways for "what a good probe writes" to drift apart.
*/
export async function probeMint(row: MintRow): Promise<ProbeResult> {
const db = getDb();
const now = Math.floor(Date.now() / 1000);
try {
const { info, latencyMs } = await fetchInfo(row.url);
export async function recordCashuOnline(
row: MintRow,
info: MintInfo,
latencyMs: number,
now = Math.floor(Date.now() / 1000),
): Promise<void> {
const db = await getDb();
const nuts = parseNuts(info.nuts);
const iconFile = await cacheIcon(row, info.icon_url ?? null);
db.prepare(
await db.run(
`UPDATE mints SET
name = COALESCE(?, name),
description = COALESCE(?, description),
@@ -73,7 +68,6 @@ export async function probeMint(row: MintRow): Promise<ProbeResult> {
last_probe = ?,
updated_at = ?
WHERE url = ?`,
).run(
info.name ?? null,
info.description ?? null,
info.icon_url ?? null,
@@ -88,11 +82,52 @@ export async function probeMint(row: MintRow): Promise<ProbeResult> {
row.url,
);
db.prepare('INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 1, ?)').run(
await db.run(
'INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 1, ?)',
row.url,
now,
latencyMs,
);
}
async function fetchInfo(url: string): Promise<{ info: MintInfo; latencyMs: number }> {
const started = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.probeTimeoutMs);
try {
const res = await fetch(`${url}/v1/info`, {
signal: controller.signal,
headers: { Accept: 'application/json', 'User-Agent': config.userAgent },
redirect: 'follow',
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await readBodyBounded(res, MAX_INFO_BYTES);
if (!body) throw new Error(`info larger than ${MAX_INFO_BYTES} bytes or unreadable`);
const info = JSON.parse(body.toString('utf8')) as MintInfo;
if (!info || typeof info !== 'object') throw new Error('not a JSON object');
return { info, latencyMs: Date.now() - started };
} finally {
clearTimeout(timer);
}
}
/**
* Probe one mint and write the result.
*
* On failure the cached metadata columns are deliberately left untouched. That cache is
* what lets an offline mint still render its full page, which is acceptance criterion #1.
*/
export async function probeMint(row: MintRow): Promise<ProbeResult> {
const db = await getDb();
const now = Math.floor(Date.now() / 1000);
try {
const { info, latencyMs } = await fetchInfo(row.url);
await recordCashuOnline(row, info, latencyMs, now);
const changed = row.status !== 'online';
if (changed) log.info('mint state change', { url: row.url, from: row.status, to: 'online' });
@@ -102,12 +137,18 @@ export async function probeMint(row: MintRow): Promise<ProbeResult> {
const fails = row.consecutive_fails + 1;
const status = statusForFails(fails);
db.prepare(
await db.run(
`UPDATE mints SET consecutive_fails = ?, status = ?, last_probe = ?, updated_at = ?
WHERE url = ?`,
).run(fails, status, now, now, row.url);
fails,
status,
now,
now,
row.url,
);
db.prepare('INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 0, NULL)').run(
await db.run(
'INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 0, NULL)',
row.url,
now,
);
@@ -127,6 +168,403 @@ export async function probeMint(row: MintRow): Promise<ProbeResult> {
}
}
/* ---------- fedimint ---------- */
/**
* Write what a check said about one federation.
*
* Three outcomes, and the third is the one that matters most. `online` and `offline`
* are recorded exactly as a mint probe records them, probe row and all, so uptime and
* the sparkline work the same way. A federation the checker does not cover gets
* `announced`, which is a statement about this site's reach and not about the
* federation: no probe row is written for it, because nothing was probed, and
* `last_online` stays null so nothing downstream can render a "last seen" it invented.
*/
async function applyFederationHealth(
row: MintRow,
health: 'online' | 'offline' | null,
now: number,
): Promise<ProbeResult> {
const db = await getDb();
const previous = parseEcosystem(row);
const ecosystem = (source: string | null): string =>
JSON.stringify({ ...(previous ?? {}), status_source: source } as FedimintFields);
if (health === null) {
// Nothing checks this federation. Say so, and leave every column a real check
// would have written alone.
const status = 'announced';
const changed = row.status !== status;
await db.run(
`UPDATE mints SET status = ?, ecosystem_json = ?, last_probe = ?, updated_at = ?
WHERE url = ?`,
status,
ecosystem(null),
now,
now,
row.url,
);
if (changed) log.info('mint state change', { url: row.url, from: row.status, to: status });
return { url: row.url, ok: false, latencyMs: null, status, changed };
}
const ok = health === 'online';
const status = ok ? 'online' : 'offline';
await db.run(
`UPDATE mints SET
status = ?,
consecutive_fails = ?,
ecosystem_json = ?,
last_online = COALESCE(?, last_online),
last_probe = ?,
updated_at = ?
WHERE url = ?`,
status,
ok ? 0 : row.consecutive_fails + 1,
ecosystem(OBSERVER_SOURCE),
ok ? now : null,
now,
now,
row.url,
);
await db.run(
'INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, ?, NULL)',
row.url,
now,
ok ? 1 : 0,
);
const changed = row.status !== status;
if (changed) {
log.info('mint state change', {
url: row.url,
from: row.status,
to: status,
source: OBSERVER_SOURCE,
});
}
return { url: row.url, ok, latencyMs: null, status, changed };
}
/**
* One lookup for every federation, rather than one request each.
*
* The checker publishes its whole table in a single response, so asking per row would
* be the same answer fetched fifty times. Returns an empty list when the lookup itself
* failed: no answer is not a result, and writing one would be inventing it.
*/
async function checkFederations(rows: MintRow[], now: number): Promise<ProbeResult[]> {
if (rows.length === 0) return [];
const index: ObserverIndex | null = await fetchObserverIndex(config.userAgent);
if (!index) {
log.warn('fedimint check skipped', { federations: rows.length, reason: 'no observer data' });
return [];
}
const results: ProbeResult[] = [];
for (const row of rows) {
const federationId = parseEcosystem(row)?.federation_id ?? '';
results.push(await applyFederationHealth(row, index.get(federationId)?.health ?? null, now));
}
return results;
}
/* ---------- lnurl ---------- */
/**
* Probe one LNURL mint and write the result.
*
* Same shape as `probeMint` and the same guarantee: on failure the cached metadata
* columns are left untouched, which is what lets an offline LNURL mint still render its
* full page with a banner over it and a working review form. The rug-review guarantee
* covers all three ecosystems, and this is the half of it that lives in the indexer.
*
* Three outcomes rather than two, and the middle one is the point:
*
* - **online** — an advertisement parsed and the mint's Lightning node answered.
* - **degraded-funding** — an advertisement parsed and the node did not. The mint is
* up: it is serving, its limits are real, and rotate/split/merge still work. It is
* recorded as a *successful* probe with `funding_available: false`, which the
* warnings turn into "minting and melting unavailable". Storing it as `degraded`
* status instead would be wrong twice over — that value already means "one or two
* checks failed" here, and none did — and this is exactly the shape the Cashu side
* already uses for a mint that answers perfectly while refusing to move sats.
* - **invalid** — the host answered with something that is not a mint advertisement.
* Counted as a failed probe, because nothing about the mint was confirmed, but with
* `invalid_reason` recorded so the page can say "responding but invalid" instead of
* the flatly wrong "offline".
*/
async function probeLnurlMint(row: MintRow): Promise<ProbeResult> {
const now = Math.floor(Date.now() / 1000);
const previous = parseEcosystem<LnurlFields>(row);
const baseUrl = previous?.base_url ?? row.url.replace(/^lnurl:/, '');
try {
return await applyLnurlResult(row, await probeLnurl(baseUrl), now);
} catch (err) {
return await recordLnurlFailure(row, previous, err, now);
}
}
/**
* Write what one LNURL probe concluded, whichever of the three things it concluded.
*
* Split out of `probeLnurlMint` for the same reason `recordCashuOnline` was: the
* on-demand indexer runs `probeLnurl` itself, through the guarded fetcher, and the row
* it creates has to land in exactly the state a probe cycle would have left it in. One
* writer, two callers.
*/
export async function applyLnurlResult(
row: MintRow,
result: Awaited<ReturnType<typeof probeLnurl>>,
now = Math.floor(Date.now() / 1000),
): Promise<ProbeResult> {
const db = await getDb();
const previous = parseEcosystem<LnurlFields>(row);
const baseUrl = previous?.base_url ?? row.url.replace(/^lnurl:/, '');
if (result.outcome === 'invalid') {
const fails = row.consecutive_fails + 1;
const status = statusForFails(fails);
const fields: LnurlFields = {
...(previous ?? emptyLnurlFields(baseUrl)),
invalid_reason: result.invalidReason,
probe_endpoint: result.endpoint,
};
await db.run(
`UPDATE mints SET consecutive_fails = ?, status = ?, ecosystem_json = ?,
last_probe = ?, updated_at = ?
WHERE url = ?`,
fails,
status,
JSON.stringify(fields),
now,
now,
row.url,
);
await db.run(
'INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 0, NULL)',
row.url,
now,
);
const changed = row.status !== status;
if (changed) {
log.info('mint state change', {
url: row.url, from: row.status, to: status, fails, reason: result.invalidReason,
});
}
return { url: row.url, ok: false, latencyMs: null, status, changed };
}
const ad = result.advertisement;
/*
* `mint_pubkey` is sticky: `?? previous?.mint_pubkey` and never the other way round.
* A node that was unreachable for this one request drops the field from the
* response, and letting that clear a pubkey the site has already seen would change
* the mint's Nostr identity — its `d` tag — every time its node hiccuped. See
* "When a mint gains a pubkey after being announced by host" in the kind document.
*/
const mintPubkey = ad?.mintPubkey ?? previous?.mint_pubkey ?? null;
const fields: LnurlFields = {
...(previous ?? emptyLnurlFields(baseUrl)),
lnurl_id: lnurlIdentifier(baseUrl, mintPubkey),
base_url: baseUrl,
mint_pubkey: mintPubkey,
/*
* Left as whatever it was when the payRequest fallback answered and the withdraw
* side did not: that endpoint carries no node section at all, so its silence is
* not evidence either way. Only a parsed advertisement gets to set this.
*/
funding_available: ad ? ad.fundingAvailable : previous?.funding_available ?? null,
probe_endpoint: result.endpoint,
invalid_reason: null,
min_withdrawable_msat: ad?.minWithdrawableMsat ?? previous?.min_withdrawable_msat ?? null,
max_withdrawable_msat: ad?.maxWithdrawableMsat ?? previous?.max_withdrawable_msat ?? null,
min_sendable_msat: result.pay?.minSendableMsat ?? previous?.min_sendable_msat ?? null,
max_sendable_msat: result.pay?.maxSendableMsat ?? previous?.max_sendable_msat ?? null,
fee_base_msat: result.pay?.feeBaseMsat ?? null,
fee_ppm: result.pay?.feePpm ?? null,
lightning_address: result.lightningAddress ?? previous?.lightning_address ?? null,
onion_url: result.onionUrl ?? null,
node_alias: ad?.nodeAlias ?? null,
node_uri: ad?.nodeUri ?? null,
node_capacity_msat: ad?.nodeCapacityMsat ?? null,
node_channels: ad?.nodeChannels ?? null,
node_peers: ad?.nodePeers ?? null,
/*
* What this probe actually saw, kept beside — never merged into — whatever the
* operator announced. It is the only capability list a mint nobody has announced
* has, which today is every LNURL mint on the network, and it is what the page
* renders when `features` is empty. `displayFeatures` does the joining.
*/
observed_features: observedFeatures({
fundingAvailable: ad ? ad.fundingAvailable : null,
maxWithdrawableMsat: ad?.maxWithdrawableMsat ?? null,
maxSendableMsat: result.pay?.maxSendableMsat ?? null,
lightningAddress: result.lightningAddress,
mintPubkey,
onionUrl: result.onionUrl,
}),
};
const iconFile = await cacheIcon(row, row.icon_url);
/*
* `COALESCE(description, ?)`, the opposite way round from the Cashu probe.
*
* There, `/v1/info` is the mint's own word about itself and rightly overwrites a
* cached value. Here the candidate is a string the mint *software* generates —
* "Mint an lnurlcash bearer note on {host}" is a template, not a sentence anyone
* wrote — so it fills a gap and never displaces an operator's own announcement
* metadata.
*
* `name` is deliberately not written at all. The only candidate the endpoints offer
* is `nodeAlias`, and that is the *Lightning node's* name, not the mint's: the
* software's own one-pager prints it under a "Node" heading, separate from the
* mint's title. Calling a mint after its node would be a small invention, and the
* existing fallback — the hostname — is both true and what a reader typed to get
* here. The alias is kept in `ecosystem_json` and shown in the sidebar, where it is
* labelled as what it is.
*/
await db.run(
`UPDATE mints SET
description = COALESCE(description, ?),
icon_file = ?,
ecosystem_json = ?,
version = COALESCE(?, version),
status = 'online',
consecutive_fails = 0,
last_online = ?,
last_probe = ?,
updated_at = ?
WHERE url = ?`,
result.pay?.description ?? ad?.defaultDescription ?? null,
iconFile,
JSON.stringify(fields),
result.software,
now,
now,
now,
row.url,
);
await db.run(
'INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 1, ?)',
row.url,
now,
result.latencyMs,
);
const changed = row.status !== 'online' || previous?.funding_available !== fields.funding_available;
if (changed) {
log.info('mint state change', {
url: row.url,
from: row.status,
to: 'online',
funding: fields.funding_available === false ? 'unavailable' : 'ok',
});
}
return { url: row.url, ok: true, latencyMs: result.latencyMs, status: 'online', changed };
}
/**
* Write a failed LNURL probe: the host said nothing at all.
*
* Its own function only because two callers reach it — the probe cycle and the
* on-demand indexer, which treats a host that never answered as a candidate for the
* Nostr lookup rather than as a failure to report immediately.
*/
async function recordLnurlFailure(
row: MintRow,
previous: LnurlFields | null,
err: unknown,
now: number,
): Promise<ProbeResult> {
const db = await getDb();
const fails = row.consecutive_fails + 1;
const status = statusForFails(fails);
/*
* A host that said nothing at all is offline, not invalid. Clearing `invalid_reason`
* here matters: a mint that spent a day serving junk and then went dark should stop
* showing "responding but invalid" the moment it stops responding.
*/
if (previous?.invalid_reason) {
await db.run(
'UPDATE mints SET ecosystem_json = ? WHERE url = ?',
JSON.stringify({ ...previous, invalid_reason: null } satisfies LnurlFields),
row.url,
);
}
await db.run(
`UPDATE mints SET consecutive_fails = ?, status = ?, last_probe = ?, updated_at = ?
WHERE url = ?`,
fails,
status,
now,
now,
row.url,
);
await db.run(
'INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 0, NULL)',
row.url,
now,
);
const changed = row.status !== status;
if (changed) {
log.info('mint state change', {
url: row.url,
from: row.status,
to: status,
fails,
reason: err instanceof Error ? err.message : String(err),
});
}
return { url: row.url, ok: false, latencyMs: null, status, changed };
}
/** The `ecosystem_json` shape a row falls back to when its own will not parse. */
function emptyLnurlFields(baseUrl: string): LnurlFields {
return {
lnurl_id: lnurlIdentifier(baseUrl, null),
base_url: baseUrl,
features: [],
network: null,
announced_at: null,
announcer_pubkey: null,
mint_pubkey: null,
funding_available: null,
probe_endpoint: null,
invalid_reason: null,
min_withdrawable_msat: null,
max_withdrawable_msat: null,
min_sendable_msat: null,
max_sendable_msat: null,
fee_base_msat: null,
fee_ppm: null,
lightning_address: null,
onion_url: null,
node_alias: null,
node_uri: null,
node_capacity_msat: null,
node_channels: null,
node_peers: null,
observed_features: [],
};
}
/** Run `tasks` with at most `limit` in flight. */
async function pooled<T>(items: T[], limit: number, fn: (item: T) => Promise<unknown>): Promise<void> {
let cursor = 0;
@@ -139,31 +577,62 @@ async function pooled<T>(items: T[], limit: number, fn: (item: T) => Promise<unk
await Promise.all(workers);
}
export async function probeAll(rows: MintRow[] = allMintRows()): Promise<ProbeResult[]> {
if (rows.length === 0) return [];
export async function probeAll(rows?: MintRow[]): Promise<ProbeResult[]> {
const targets = rows ?? (await allMintRows());
if (targets.length === 0) return [];
const started = Date.now();
const now = Math.floor(Date.now() / 1000);
/*
* Three ecosystems, three checks.
*
* Two of them are an HTTP fetch per row and one is a single lookup covering every
* federation, so the federations are split out rather than each waiting behind a slot
* in the fetch pool. Cashu and LNURL mints share that pool — they are the same kind of
* work, a handful of small HTTPS requests each — and are told apart inside the worker
* by `type` rather than by two pools that would each idle waiting for the other.
*
* The default arm is Cashu, not LNURL: a row of some type this build has never heard
* of is far more likely to be an older ecosystem than a newer one, and `/v1/info` is
* what every row written before the column existed means.
*/
const federations = targets.filter((row) => row.type === 'fedimint');
const fetched = targets.filter((row) => row.type !== 'fedimint');
const results: ProbeResult[] = [];
await pooled(rows, config.probeConcurrency, async (row) => {
results.push(await probeMint(row));
});
const [, federationResults] = await Promise.all([
pooled(fetched, config.probeConcurrency, async (row) => {
results.push(row.type === 'lnurl' ? await probeLnurlMint(row) : await probeMint(row));
}),
checkFederations(federations, now),
]);
results.push(...federationResults);
const online = results.filter((r) => r.ok).length;
const changed = results.filter((r) => r.changed).length;
log.info('probe cycle', {
mints: results.length,
mints: fetched.filter((row) => row.type !== 'lnurl').length,
lnurl: fetched.filter((row) => row.type === 'lnurl').length,
federations: federations.length,
online,
down: results.length - online,
changed,
ms: Date.now() - started,
});
setState('last_probe_at', String(Math.floor(Date.now() / 1000)));
// Only a full sweep counts for /api/health. probeUrls passes a handful of newly
// discovered rows through here, and stamping the health marker for those would let
// a fresh discovery hide hours of failed probe cycles.
if (rows === undefined) {
await setState('last_probe_at', String(Math.floor(Date.now() / 1000)));
}
return results;
}
/** Probe a set of URLs immediately, used when discovery finds new mints. */
/** Probe a set of keys immediately, used when discovery finds new mints or federations. */
export async function probeUrls(urls: string[]): Promise<void> {
const rows = urls.map((u) => mintByUrl(u)).filter((r): r is MintRow => r !== undefined);
const found = await Promise.all(urls.map((u) => mintByUrl(u)));
const rows = found.filter((r): r is MintRow => r !== undefined);
if (rows.length > 0) await probeAll(rows);
}
+207 -76
View File
@@ -8,13 +8,15 @@ import {
type MintInfo,
type MintListItem,
type MintStatus,
type MintType,
type ProbeSample,
type RatingDistribution,
type LnurlFields,
type Stats,
} from '@cashumints/shared';
import { config, startedAt } from './config.ts';
import { getDb, getStateNumber, getState } from './db.ts';
import { mintByHost, type MintRow } from './mints.ts';
import { mintByHost, parseEcosystem, type MintRow } from './mints.ts';
/**
* One review per author per mint, newest wins.
@@ -23,15 +25,18 @@ import { mintByHost, type MintRow } from './mints.ts';
* replaces the earlier one. The old site applied the same rule in memory
* (aggregateReviews, keyed by pubkey); doing it in SQL keeps counts honest and stops
* one npub from moving a mint's average by re-posting.
*
* The `AS ranked` alias is not decoration: Postgres rejects an unaliased subquery in
* FROM, and SQLite does not care either way.
*/
const LATEST_REVIEWS = `
export const LATEST_REVIEWS = `
SELECT mint_url, pubkey, rating, created_at FROM (
SELECT mint_url, pubkey, rating, created_at,
ROW_NUMBER() OVER (
PARTITION BY mint_url, pubkey ORDER BY created_at DESC, event_id
) AS rn
FROM reviews
) WHERE rn = 1
) AS ranked WHERE rn = 1
`;
interface AggRow {
@@ -41,17 +46,26 @@ interface AggRow {
last_review_at: number | null;
}
function aggregates(): Map<string, AggRow> {
const rows = getDb()
.prepare(
/**
* Review counts and averages, for one mint or for all of them.
*
* A detail page passes its own URL. Computing the whole table to read one row off it
* is free on a local SQLite file and is not on a Postgres server across a socket.
*/
async function aggregates(mintUrl?: string): Promise<Map<string, AggRow>> {
const db = await getDb();
const where = mintUrl === undefined ? '' : 'WHERE mint_url = ?';
const params = mintUrl === undefined ? [] : [mintUrl];
const rows = await db.all<AggRow>(
`WITH latest AS (${LATEST_REVIEWS})
SELECT mint_url,
COUNT(*) AS review_count,
AVG(rating) AS rating_avg,
MAX(created_at) AS last_review_at
FROM latest GROUP BY mint_url`,
)
.all() as AggRow[];
FROM latest ${where} GROUP BY mint_url`,
...params,
);
return new Map(rows.map((r) => [r.mint_url, r]));
}
@@ -64,7 +78,7 @@ function aggregates(): Map<string, AggRow> {
* fail its own stated purpose. `SCORE_PRIOR_MEAN=global` restores literal BACKEND.md
* behaviour, or set any number to pin it.
*/
function priorMean(): number {
async function priorMean(): Promise<number> {
const override = process.env['SCORE_PRIOR_MEAN'];
if (override && override !== 'global') {
@@ -73,10 +87,11 @@ function priorMean(): number {
}
if (override === 'global') {
const row = getDb()
.prepare(`WITH latest AS (${LATEST_REVIEWS}) SELECT AVG(rating) AS avg FROM latest`)
.get() as { avg: number | null };
return row.avg ?? NEUTRAL_PRIOR_MEAN;
const db = await getDb();
const row = await db.get<{ avg: number | null }>(
`WITH latest AS (${LATEST_REVIEWS}) SELECT AVG(rating) AS avg FROM latest`,
);
return row?.avg ?? NEUTRAL_PRIOR_MEAN;
}
return NEUTRAL_PRIOR_MEAN;
@@ -103,6 +118,7 @@ function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: nu
host: row.host,
name: row.name,
icon: iconPath(row),
type: row.type as MintType,
status: row.status as MintStatus,
last_online: row.last_online,
review_count: base.review_count,
@@ -113,26 +129,38 @@ function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: nu
};
}
export function listMints(limit?: number): MintListItem[] {
/**
* The list, optionally narrowed to one ecosystem.
*
* `type` is filtered in SQL rather than after scoring, because the two list pages ask
* for one ecosystem each and there is no reason to score fifty federations to render
* /mints. Unfiltered still returns everything, so `/api/mints` on its own is the whole
* index with a `type` on every item.
*/
export async function listMints(limit?: number, type?: string): Promise<MintListItem[]> {
const now = Math.floor(Date.now() / 1000);
const agg = aggregates();
const mean = priorMean();
const db = await getDb();
const [agg, mean, rows] = await Promise.all([
aggregates(),
priorMean(),
type
? db.all<MintRow>('SELECT * FROM mints WHERE type = ?', type)
: db.all<MintRow>('SELECT * FROM mints'),
]);
const items = (getDb().prepare('SELECT * FROM mints').all() as MintRow[])
.map((row) => toListItem(row, agg.get(row.url), mean, now))
.sort(compareMints);
const items = rows.map((row) => toListItem(row, agg.get(row.url), mean, now)).sort(compareMints);
return limit && limit > 0 ? items.slice(0, limit) : items;
}
function distribution(mintUrl: string): RatingDistribution {
const rows = getDb()
.prepare(
async function distribution(mintUrl: string): Promise<RatingDistribution> {
const db = await getDb();
const rows = await db.all<{ rating: number; n: number }>(
`WITH latest AS (${LATEST_REVIEWS})
SELECT rating, COUNT(*) AS n FROM latest
WHERE mint_url = ? AND rating IS NOT NULL GROUP BY rating`,
)
.all(mintUrl) as { rating: number; n: number }[];
mintUrl,
);
const dist: RatingDistribution = { '1': 0, '2': 0, '3': 0, '4': 0, '5': 0 };
for (const r of rows) {
@@ -142,34 +170,39 @@ function distribution(mintUrl: string): RatingDistribution {
return dist;
}
function reviews90d(mintUrl: string): number {
async function reviews90d(mintUrl: string): Promise<number> {
const cutoff = Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60;
const row = getDb()
.prepare(
const db = await getDb();
const row = await db.get<{ n: number }>(
`WITH latest AS (${LATEST_REVIEWS})
SELECT COUNT(*) AS n FROM latest WHERE mint_url = ? AND created_at >= ?`,
)
.get(mintUrl, cutoff) as { n: number };
return row.n;
mintUrl,
cutoff,
);
return row?.n ?? 0;
}
function uptime30d(mintUrl: string): number | null {
async function uptime30d(mintUrl: string): Promise<number | null> {
const cutoff = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60;
const row = getDb()
.prepare('SELECT AVG(ok) AS up, COUNT(*) AS n FROM probes WHERE mint_url = ? AND ts >= ?')
.get(mintUrl, cutoff) as { up: number | null; n: number };
const db = await getDb();
const row = await db.get<{ up: number | null; n: number }>(
'SELECT AVG(ok) AS up, COUNT(*) AS n FROM probes WHERE mint_url = ? AND ts >= ?',
mintUrl,
cutoff,
);
if (!row.n || row.up === null) return null;
if (!row?.n || row.up === null) return null;
return Math.round(row.up * 1000) / 1000;
}
function recentProbes(mintUrl: string): ProbeSample[] {
async function recentProbes(mintUrl: string): Promise<ProbeSample[]> {
const cutoff = Math.floor(Date.now() / 1000) - 48 * 60 * 60;
return getDb()
.prepare(
const db = await getDb();
return db.all<ProbeSample>(
'SELECT ts, ok, latency_ms FROM probes WHERE mint_url = ? AND ts >= ? ORDER BY ts ASC',
)
.all(mintUrl, cutoff) as ProbeSample[];
mintUrl,
cutoff,
);
}
function parseInfo(json: string | null): MintInfo | null {
@@ -181,13 +214,23 @@ function parseInfo(json: string | null): MintInfo | null {
}
}
export function getMintDetail(host: string): MintDetail | null {
const row = mintByHost(host);
export async function getMintDetail(host: string): Promise<MintDetail | null> {
const row = await mintByHost(host);
if (!row) return null;
const now = Math.floor(Date.now() / 1000);
const agg = aggregates().get(row.url);
const item = toListItem(row, agg, priorMean(), now);
// One round trip's worth of latency instead of six, which is the difference between
// a local file and a Postgres server on another host.
const [agg, mean, ratingDistribution, reviews, uptime, probes] = await Promise.all([
aggregates(row.url),
priorMean(),
distribution(row.url),
reviews90d(row.url),
uptime30d(row.url),
recentProbes(row.url),
]);
const item = toListItem(row, agg.get(row.url), mean, now);
const info = parseInfo(row.info_json);
let nuts: string[] = [];
@@ -200,8 +243,20 @@ export function getMintDetail(host: string): MintDetail | null {
}
if (nuts.length === 0 && info) nuts = parseNuts(info.nuts);
/*
* Type-specific columns are spread across the payload rather than nested under a key.
*
* That is what keeps a Cashu detail byte for byte what it was: `ecosystem_json` is
* null for a mint, so nothing is added and no consumer sees a new empty object to
* handle. A federation gets `federation_id`, `invite_codes`, `modules`, `network` and
* the rest at the top level, where the page reads them beside `status` and `name`
* without unwrapping anything.
*/
const ecosystem = parseEcosystem(row);
return {
...item,
...(ecosystem ?? {}),
description: row.description,
pubkey: row.pubkey,
info,
@@ -209,58 +264,134 @@ export function getMintDetail(host: string): MintDetail | null {
first_seen: row.first_seen,
last_probe: row.last_probe,
updated_at: row.updated_at,
rating_distribution: distribution(row.url),
reviews_90d: reviews90d(row.url),
uptime_30d: uptime30d(row.url),
probes_recent: recentProbes(row.url),
rating_distribution: ratingDistribution,
reviews_90d: reviews,
uptime_30d: uptime,
probes_recent: probes,
};
}
let statsCache: { at: number; value: Stats } | null = null;
const STATS_TTL_S = 60;
export function getStats(): Stats {
export async function getStats(): Promise<Stats> {
const now = Math.floor(Date.now() / 1000);
if (statsCache && now - statsCache.at < STATS_TTL_S) return statsCache.value;
const db = getDb();
const counts = db
.prepare(
const db = await getDb();
/*
* The four `mints_*` fields are scoped to `type = 'cashu'`, which is what they always
* counted and what every consumer of them still means: the pulse ticker's "mints
* indexed", the /mints page description, the home page. Letting federations quietly
* inflate a number three pages print in a sentence would be a worse kind of breakage
* than a missing field, because nothing would fail — the sentences would just stop
* being true.
*
* COUNT(*) FILTER, not SUM(status = 'online'): Postgres has no implicit cast from
* boolean to integer, so the SQLite spelling is a type error there.
*/
const [counts, federations, lnurl, lnurlFunding, reviews, fedimintReviews, lnurlReviews] =
await Promise.all([
db.get<{ total: number; online: number; offline: number; degraded: number }>(
`SELECT
COUNT(*) AS total,
SUM(status = 'online') AS online,
SUM(status = 'offline') AS offline,
SUM(status = 'degraded') AS degraded
FROM mints`,
)
.get() as { total: number; online: number | null; offline: number | null; degraded: number | null };
COUNT(*) FILTER (WHERE status = 'online') AS online,
COUNT(*) FILTER (WHERE status = 'offline') AS offline,
COUNT(*) FILTER (WHERE status = 'degraded') AS degraded
FROM mints WHERE type = 'cashu'`,
),
db.get<{ total: number; online: number; offline: number; announced: number }>(
`SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'online') AS online,
COUNT(*) FILTER (WHERE status = 'offline') AS offline,
COUNT(*) FILTER (WHERE status = 'announced') AS announced
FROM mints WHERE type = 'fedimint'`,
),
db.get<{ total: number; online: number; offline: number }>(
`SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'online') AS online,
COUNT(*) FILTER (WHERE status = 'offline') AS offline
FROM mints WHERE type = 'lnurl'`,
),
/*
* The degraded-funding count is read in JavaScript rather than in SQL, because the
* flag lives inside `ecosystem_json` and neither a `LIKE '%"funding_available":false%'`
* (which depends on how the two drivers happen to serialise) nor a JSON operator
* (which SQLite and Postgres spell differently) is portable. There are a handful of
* these rows, the whole result is memoised for a minute, and a correct answer on
* both backends is worth one small scan.
*/
db.all<{ ecosystem_json: string | null }>(
`SELECT ecosystem_json FROM mints WHERE type = 'lnurl' AND status = 'online'`,
),
db.get<{ n: number; last: number | null }>(
`WITH latest AS (${LATEST_REVIEWS}) SELECT COUNT(*) AS n, MAX(created_at) AS last FROM latest`,
),
db.get<{ n: number }>(
`WITH latest AS (${LATEST_REVIEWS})
SELECT COUNT(*) AS n FROM latest
WHERE mint_url IN (SELECT url FROM mints WHERE type = 'fedimint')`,
),
db.get<{ n: number }>(
`WITH latest AS (${LATEST_REVIEWS})
SELECT COUNT(*) AS n FROM latest
WHERE mint_url IN (SELECT url FROM mints WHERE type = 'lnurl')`,
),
]);
const reviews = db
.prepare(`WITH latest AS (${LATEST_REVIEWS}) SELECT COUNT(*) AS n, MAX(created_at) AS last FROM latest`)
.get() as { n: number; last: number | null };
let lnurlDegradedFunding = 0;
for (const row of lnurlFunding) {
const fields = parseEcosystem<LnurlFields>(row);
if (fields?.funding_available === false) lnurlDegradedFunding++;
}
const cashuTotal = counts?.total ?? 0;
const value: Stats = {
mints_total: counts.total,
mints_online: counts.online ?? 0,
mints_offline: counts.offline ?? 0,
mints_degraded: counts.degraded ?? 0,
reviews_total: reviews.n,
last_review_at: reviews.last,
mints_total: cashuTotal,
mints_online: counts?.online ?? 0,
mints_offline: counts?.offline ?? 0,
mints_degraded: counts?.degraded ?? 0,
reviews_total: reviews?.n ?? 0,
last_review_at: reviews?.last ?? null,
updated_at: now,
cashu_total: cashuTotal,
fedimint_total: federations?.total ?? 0,
fedimint_online: federations?.online ?? 0,
fedimint_offline: federations?.offline ?? 0,
fedimint_announced: federations?.announced ?? 0,
fedimint_reviews: fedimintReviews?.n ?? 0,
lnurl_total: lnurl?.total ?? 0,
lnurl_online: lnurl?.online ?? 0,
lnurl_offline: lnurl?.offline ?? 0,
lnurl_degraded_funding: lnurlDegradedFunding,
lnurl_reviews: lnurlReviews?.n ?? 0,
};
statsCache = { at: now, value };
return value;
}
/** Health bypasses the stats cache: it is the endpoint you page on. */
export function getHealth(): Health {
const now = Math.floor(Date.now() / 1000);
const lastProbe = getStateNumber('last_probe_at');
const lastDiscovery = getStateNumber('last_discovery_at');
const discoveryOk = getState('last_discovery_ok') !== '0';
/** Drop the in-process stats cache. For tests that mutate the database underneath it. */
export function resetStatsCache(): void {
statsCache = null;
}
const tracked = (getDb().prepare('SELECT COUNT(*) AS n FROM mints').get() as { n: number }).n;
/** Health bypasses the stats cache: it is the endpoint you page on. */
export async function getHealth(): Promise<Health> {
const now = Math.floor(Date.now() / 1000);
const db = await getDb();
const [lastProbe, lastDiscovery, discoveryOkRaw, tracked] = await Promise.all([
getStateNumber('last_probe_at'),
getStateNumber('last_discovery_at'),
getState('last_discovery_ok'),
db.get<{ n: number }>('SELECT COUNT(*) AS n FROM mints'),
]);
const discoveryOk = discoveryOkRaw !== '0';
const staleAfter = config.probeIntervalMin * 60 * 3;
const probeStale = lastProbe === null || now - lastProbe > staleAfter;
@@ -269,7 +400,7 @@ export function getHealth(): Health {
uptime_s: now - startedAt,
last_probe_at: lastProbe,
last_discovery_at: lastDiscovery,
mints_tracked: tracked,
mints_tracked: tracked?.n ?? 0,
updated_at: now,
};
}
+92
View File
@@ -0,0 +1,92 @@
/**
* A per-address budget for the one endpoint that does work on a stranger's behalf.
*
* Every other route reads rows this process already has. `POST /api/index` resolves
* DNS, opens a socket to an address somebody chose, and may query five relays, so it is
* the one place where a loop in a browser tab costs this server real outbound work —
* and costs whoever is at the other end an unexpected visitor.
*
* In-process and in-memory, deliberately. The alternatives were considered and both are
* worse here: a table would put a write on the path of every submission for a counter
* that may be forgotten at any time, and doing it in nginx would put the limit in a
* file the application cannot see, cannot test, and does not ship — this repository's
* README documents an nginx block that a deployment is free to edit, so a limit that
* lives only there is a limit that silently varies per deployment. The cost of keeping
* it here is that a restart forgives everyone and a second process would double the
* allowance; at ten an hour, neither matters. BACKEND.md records the choice.
*/
/**
* Submissions one address may make per window.
*
* Ten by default, which is BACKEND.md's number and is several times what any honest use
* of the dialog needs. `INDEX_RATE_LIMIT` raises or lowers it, which exists for two
* real cases rather than as reflexive configurability: exercising the whole flow
* end to end trips a limit of ten in about a minute, and a deployment behind a proxy
* that does not forward the client address has every visitor sharing one bucket until
* it does. Both are better served by a number than by a code change.
*/
export const RATE_LIMIT = (() => {
const raw = Number.parseInt(process.env['INDEX_RATE_LIMIT'] ?? '', 10);
return Number.isFinite(raw) && raw > 0 ? raw : 10;
})();
/** The window those submissions are counted over. */
export const RATE_WINDOW_MS = 60 * 60 * 1000;
/** Addresses tracked at once. Past this the oldest bucket is dropped, not the newest. */
const MAX_TRACKED = 5000;
const hits = new Map<string, number[]>();
export interface RateVerdict {
ok: boolean;
/** Submissions left in this window after this one. */
remaining: number;
/** Seconds until the oldest counted submission falls out of the window. */
retryAfter: number;
}
/**
* Count one submission from `key`, and say whether it is allowed.
*
* A sliding window over timestamps rather than a fixed bucket, so ten submissions at
* 10:59 do not give a fresh ten at 11:00. A refused submission is *not* counted: being
* over the limit should not extend the wait every time the reader presses the button
* again, which is what turns a rate limit into a lockout.
*/
export function takeToken(key: string, now = Date.now()): RateVerdict {
const cutoff = now - RATE_WINDOW_MS;
const recent = (hits.get(key) ?? []).filter((at) => at > cutoff);
if (recent.length >= RATE_LIMIT) {
hits.set(key, recent);
const oldest = recent[0] ?? now;
return {
ok: false,
remaining: 0,
retryAfter: Math.max(1, Math.ceil((oldest + RATE_WINDOW_MS - now) / 1000)),
};
}
recent.push(now);
hits.set(key, recent);
if (hits.size > MAX_TRACKED) sweep(cutoff);
return { ok: true, remaining: RATE_LIMIT - recent.length, retryAfter: 0 };
}
/** Drop every bucket with nothing left in the window. Called only when the map grows. */
function sweep(cutoff: number): void {
for (const [key, times] of hits) {
const live = times.filter((at) => at > cutoff);
if (live.length === 0) hits.delete(key);
else hits.set(key, live);
}
}
/** Forget every counter. For tests, which must not inherit each other's budgets. */
export function resetRateLimits(): void {
hits.clear();
}
+173
View File
@@ -0,0 +1,173 @@
/**
* "Has anyone ever heard of this mint?"
*
* The last question `POST /api/index` asks before giving up, and the one that makes the
* rugged-mint case work. A reader pastes an address; nothing answers at it. That is two
* different situations wearing the same silence:
*
* - a typo, a dead domain, an address nobody has ever used — nothing to index; or
* - a mint that ran for two years, took people's money, and went dark last week.
*
* The second is exactly the mint somebody most wants to write a review of, and it is
* the one this site exists to keep a page for. HTTP cannot tell them apart, but Nostr
* can: a mint that was ever real has an announcement, or reviews, or both, and a typo
* has neither. So before answering "we cannot verify that", the endpoint spends one
* bounded query asking the relay pool.
*
* Bounded is the operative word. This runs inside a request a person is waiting on, so
* it gets one round of filters and `RELAY_LOOKUP_MS` to answer them; whatever has
* arrived by then is the answer. The discovery loop is where exhaustive paging lives —
* it runs on a timer, with nobody watching — and the row this creates is an ordinary
* row that the next discovery cycle will fill in properly.
*/
import type { Event as NostrEvent, Filter } from 'nostr-tools';
import {
KIND_LNURL_ANNOUNCEMENT,
KIND_MINT_ANNOUNCEMENT,
KIND_REVIEW,
hostIdentifier,
mintUrlSpellings,
parseAnnouncementMetadata,
parseLnurlAnnouncement,
reviewEcosystem,
type LnurlAnnouncement,
} from '@cashumints/shared';
import { config } from './config.ts';
import { getPool } from './discovery.ts';
import { log } from './log.ts';
/**
* How long the relays get, in total, for the whole lookup.
*
* Three seconds, which is the number BACKEND.md now records for this endpoint. It is
* chosen against the two facts that matter: the pool's own `querySync` resolves as soon
* as every relay has sent EOSE, which on this pool is usually well under a second, and
* the request this sits inside has already spent up to five seconds failing to reach
* the mint. Eight seconds of a reader watching a spinner is the outer bound of what
* this feature may cost, and three is what buys nearly all of the recall — the events
* being asked for are single, indexed, tag-filtered lookups, not a sweep.
*/
export const RELAY_LOOKUP_MS = 3000;
/** Bounded so a hostile or confused relay cannot answer with a hundred thousand rows. */
const LOOKUP_LIMIT = 60;
/** What the relays remember about an address that does not answer over HTTPS. */
export interface RelayTrace {
/** True when anything at all was found: an announcement, or a review, or both. */
found: boolean;
/** How many kind 38000 events name this address. Nothing is stored from them here. */
reviews: number;
/** Metadata from the newest announcement, when there was one. */
name: string | null;
picture: string | null;
about: string | null;
announcerPubkey: string | null;
announcedAt: number | null;
/** The parsed 38174, for an LNURL mint: the only source of its features and network. */
lnurl: LnurlAnnouncement | null;
}
const EMPTY: RelayTrace = {
found: false,
reviews: 0,
name: null,
picture: null,
about: null,
announcerPubkey: null,
announcedAt: null,
lnurl: null,
};
/**
* Run several filters against the pool at once, with one deadline over all of them.
*
* `querySync`'s own `maxWait` bounds each call, but a relay that accepts a connection
* and then never sends EOSE can outlive it; the race is what guarantees the caller gets
* an answer inside the budget whatever the sockets do.
*/
async function query(filters: Filter[]): Promise<NostrEvent[]> {
const started = Date.now();
const events = new Map<string, NostrEvent>();
const work = Promise.all(
filters.map((filter) =>
getPool()
.querySync(config.relays, filter, { maxWait: RELAY_LOOKUP_MS })
.then((batch) => {
for (const event of batch) events.set(event.id, event);
})
.catch(() => undefined),
),
);
await Promise.race([work, new Promise((resolve) => setTimeout(resolve, RELAY_LOOKUP_MS))]);
log.info('relay trace', { filters: filters.length, events: events.size, ms: Date.now() - started });
return [...events.values()];
}
/**
* Everything the relays know about one unreachable address.
*
* The filters are the same ones the discovery loop uses to resolve a review, asked in
* the other direction: there, "which mint is this review about?"; here, "are there any
* reviews, and any announcement, for this address?". Reusing `mintUrlSpellings` is what
* makes the two agree — a mint announced with a trailing slash is found by an address
* typed without one.
*/
export async function traceOnRelays(type: string, url: string): Promise<RelayTrace> {
const spellings = mintUrlSpellings(url);
const announcementKind = type === 'lnurl' ? KIND_LNURL_ANNOUNCEMENT : KIND_MINT_ANNOUNCEMENT;
const filters: Filter[] = [
{ kinds: [announcementKind], '#u': spellings, limit: LOOKUP_LIMIT },
{ kinds: [KIND_REVIEW], '#u': spellings, limit: LOOKUP_LIMIT },
];
/*
* An LNURL mint with no funding source is announced — and reviewed — under its bare
* host in `d` rather than under a pubkey, and a client that only writes `d` leaves no
* `u` to match on. One extra filter covers those; a Cashu mint's `d` is a pubkey
* nobody can derive from a URL, so it has no equivalent.
*/
if (type === 'lnurl') {
const identifier = hostIdentifier(url);
filters.push({ kinds: [announcementKind, KIND_REVIEW], '#d': [identifier], limit: LOOKUP_LIMIT });
}
const events = await query(filters);
if (events.length === 0) return EMPTY;
let reviews = 0;
let newest: NostrEvent | null = null;
for (const event of events) {
if (event.kind === KIND_REVIEW) {
// A review found by `#u` could be about any ecosystem; only count the ones whose
// `k` agrees, so a federation review carrying a stray URL is not evidence here.
if (reviewEcosystem(event) === type) reviews++;
continue;
}
if (event.kind !== announcementKind) continue;
if (!newest || event.created_at > newest.created_at) newest = event;
}
if (!newest) {
return reviews > 0 ? { ...EMPTY, found: true, reviews } : EMPTY;
}
const lnurl = type === 'lnurl' ? parseLnurlAnnouncement(newest) : null;
const meta = parseAnnouncementMetadata(newest.content);
return {
found: true,
reviews,
name: lnurl?.name ?? meta.name,
picture: lnurl?.picture ?? meta.picture,
about: lnurl?.about ?? meta.about,
announcerPubkey: newest.pubkey,
announcedAt: newest.created_at,
lnurl,
};
}
+288
View File
@@ -0,0 +1,288 @@
/**
* Fetching a URL a stranger typed.
*
* Every other fetch in this codebase goes to an address that reached the database
* through discovery or the seed list. `POST /api/index` is the first one that does not:
* a reader pastes something, and this server opens a socket to it. That inverts who the
* untrusted party is — it is no longer just the *response* that cannot be trusted, it
* is the *destination* — so this module exists to answer one question before any packet
* leaves: is that address somewhere this server has any business connecting to?
*
* Four rules, and each is here because leaving it out is a known exploit:
*
* 1. **https only.** Not http-upgraded-to-https, not any other scheme. `file:` reads
* the disk, `gopher:` used to be a way to make a server speak arbitrary protocols,
* and plaintext http to a stranger's address is a downgrade nobody asked for.
* 2. **Resolve first, judge the addresses, then connect.** A hostname check alone
* stops `http://127.0.0.1` and nothing else: `internal.example.com` is a perfectly
* ordinary public name that resolves to `10.0.0.5`, and only DNS can say so. Every
* address the name resolves to is checked, not the first: a name with one public
* and one private A record must be refused, not raced.
* 3. **Every redirect hop is a new destination.** A public URL that 302s to
* `http://169.254.169.254/latest/meta-data/` is the cloud-metadata attack in its
* classic form. Redirects are followed manually, capped at two, and each hop goes
* through the same check as the first.
* 4. **Bounded in bytes, in time, and in content type.** A stranger's server can be
* slow forever and large forever; `readBodyBounded` and an abort timer cap both,
* and a response that is not the media type asked for is not read at all.
*
* What this cannot close is the DNS rebinding window: the name is resolved here, and
* the socket resolves it again a moment later, and a hostile resolver can answer
* differently the second time. Closing it means dialing the vetted IP with the hostname
* pinned for TLS, and Node exposes no supported way to do that through `fetch` (undici's
* dispatchers are not a public API here). The exposure is a single GET whose body is
* parsed as JSON and discarded unless it is a valid mint advertisement, so the practical
* gain from a rebind is one unauthenticated GET — noted here rather than left implicit.
*/
import { lookup } from 'node:dns/promises';
import { isBlockedHostname, isPrivateIpAddress } from '@cashumints/shared';
import { config } from './config.ts';
import { readBodyBounded } from './http.ts';
/** BACKEND.md's cap for this endpoint: no probe response may exceed it. */
export const MAX_PROBE_BYTES = 256 * 1024;
/** How many redirects a probe follows before giving up. */
export const MAX_REDIRECTS = 2;
/**
* What one guarded fetch did.
*
* `blocked` and `unreachable` are kept apart because the endpoint answers them
* differently: a blocked address is a refusal this site made and can explain, while an
* unreachable one is a mint that may still be real and falls through to the Nostr
* lookup. Collapsing them would file "you may not point us at 10.0.0.1" under "we
* checked Nostr and found nothing", which is not what happened.
*/
export type FetchOutcome =
| { state: 'ok'; status: number; body: string; contentType: string | null; url: string }
| { state: 'blocked'; reason: string }
| { state: 'unreachable'; reason: string };
/**
* The two pieces of the outside world this module touches.
*
* Injectable so the SSRF rules can be tested without a network: `check-index.ts` hands
* in a resolver that answers `10.0.0.1` for an ordinary-looking name, and a fetch that
* returns a redirect to a private address, and asserts that neither is ever connected
* to. Those are the two attacks this file exists to stop, and a rule that is only
* exercised against the real internet is a rule that is not exercised.
*/
export interface FetchDeps {
resolve?: (hostname: string) => Promise<string[] | null>;
fetchImpl?: typeof fetch;
}
/** Every address a hostname resolves to, or null when it does not resolve at all. */
async function resolveAll(hostname: string): Promise<string[] | null> {
try {
const records = await lookup(hostname, { all: true, verbatim: true });
const addresses = records.map((record) => record.address).filter(Boolean);
return addresses.length > 0 ? addresses : null;
} catch {
return null;
}
}
/**
* Why an address was not connected to.
*
* Two kinds, and keeping them apart is load-bearing rather than tidy. `blocked` is a
* refusal this site made — the address is one it will not fetch, whoever asked — and
* the endpoint reports it as such. `unresolved` is the network saying nothing, and a
* name that no longer resolves is the *normal* state of a mint whose operator walked
* away: that submission has to fall through to the Nostr lookup, not be rejected as if
* the reader had typed something inadmissible.
*/
export interface DestinationVerdict {
kind: 'blocked' | 'unresolved';
reason: string;
}
/**
* May this server connect to this URL?
*
* Returns null when it may. Exported because the on-demand indexer runs it once up
* front — before it decides whether to probe at all — and because the redirect loop
* below runs it again on every hop.
*/
export async function checkDestination(
target: URL,
deps: FetchDeps = {},
): Promise<DestinationVerdict | null> {
const blocked = (reason: string): DestinationVerdict => ({ kind: 'blocked', reason });
if (target.protocol !== 'https:') return blocked('only https addresses are checked');
const hostname = target.hostname.toLowerCase().replace(/^\[|\]$/g, '');
if (!hostname) return blocked('no hostname');
// localhost, .onion, .local, a bare label, an IP literal in a private range: all
// refusable without asking a resolver anything.
if (isBlockedHostname(target.hostname)) return blocked('not a public address');
// An IP literal has already been judged by the line above; a name has to be resolved.
if (/^[\d.]+$/.test(hostname) || hostname.includes(':')) return null;
const addresses = await (deps.resolve ?? resolveAll)(hostname);
if (addresses === null) return { kind: 'unresolved', reason: 'the address does not resolve' };
// Every answer, not the first: a name with one public and one private record must be
// refused outright rather than depending on which one the socket happens to pick.
const private_ = addresses.find((address) => isPrivateIpAddress(address));
return private_ === undefined ? null : blocked(`resolves to a private address (${private_})`);
}
/**
* A response's media type, without its parameters. `application/json; charset=utf-8`
* and `application/json` are the same answer.
*/
function mediaType(res: Response): string | null {
const header = res.headers.get('content-type');
return header ? (header.split(';')[0] ?? '').trim().toLowerCase() : null;
}
/**
* Is this media type plausible for what was asked for?
*
* Deliberately lenient in one direction only. A mint serving JSON as `text/plain` is a
* misconfiguration this site should still read — several real ones do — so anything
* text-shaped or unlabelled passes. What it refuses is a response that is *positively*
* something else: an image, a video, an archive, an executable. Those are never a mint
* advertisement, and reading 256KB of one to find out costs bandwidth for nothing.
*/
function plausibleType(type: string | null, accept: string): boolean {
if (type === null) return true; // Unlabelled. The parser is the real check.
if (type.startsWith('text/') || type.includes('json') || type.includes('xml')) return true;
if (accept.includes('html') && type.includes('html')) return true;
return false;
}
export interface SafeFetchOptions {
accept: string;
maxBytes?: number;
timeoutMs?: number;
}
/**
* GET a stranger's URL, following at most two redirects and checking every hop.
*
* `redirect: 'manual'` rather than `follow`, which is the crux: with `follow`, undici
* resolves and connects to the redirect target itself and this code never sees the
* address. Doing the hops by hand is what makes rule 3 above enforceable at all.
*/
export async function safeFetchText(
rawUrl: string,
options: SafeFetchOptions,
deps: FetchDeps = {},
): Promise<FetchOutcome> {
const maxBytes = options.maxBytes ?? MAX_PROBE_BYTES;
const timeoutMs = options.timeoutMs ?? config.probeTimeoutMs;
let target: URL;
try {
target = new URL(rawUrl);
} catch {
return { state: 'blocked', reason: 'not a URL' };
}
/*
* One timer for the whole chain, not one per hop.
*
* Otherwise two redirects turn the endpoint's "standard 5s timeout" into fifteen
* seconds of a reader watching a spinner, and a hostile server can extend that for
* as long as the redirect cap allows.
*/
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const verdict = await checkDestination(target, deps);
if (verdict) {
// A name that does not resolve is not a refusal, it is a mint that is not there,
// and the caller has a whole extra step for that case.
if (verdict.kind === 'unresolved') {
return { state: 'unreachable', reason: verdict.reason };
}
return {
state: 'blocked',
reason:
hop === 0
? verdict.reason
: `redirected to an address we will not fetch: ${verdict.reason}`,
};
}
let res: Response;
try {
res = await (deps.fetchImpl ?? fetch)(target, {
signal: controller.signal,
redirect: 'manual',
headers: { Accept: options.accept, 'User-Agent': config.userAgent },
});
} catch (err) {
return {
state: 'unreachable',
reason: err instanceof Error ? err.message : 'connection failed',
};
}
if (res.status >= 300 && res.status < 400) {
const location = res.headers.get('location');
// Drain rather than leak the socket: a redirect body is never read.
await res.body?.cancel().catch(() => undefined);
if (!location) return { state: 'unreachable', reason: `HTTP ${res.status} with no target` };
if (hop === MAX_REDIRECTS) return { state: 'unreachable', reason: 'too many redirects' };
try {
target = new URL(location, target);
} catch {
return { state: 'unreachable', reason: 'redirect target is not a URL' };
}
continue;
}
const type = mediaType(res);
if (!plausibleType(type, options.accept)) {
await res.body?.cancel().catch(() => undefined);
return { state: 'unreachable', reason: `unexpected content type ${type ?? 'none'}` };
}
const body = await readBodyBounded(res, maxBytes);
if (!body) {
return { state: 'unreachable', reason: `body larger than ${maxBytes} bytes or unreadable` };
}
return {
state: 'ok',
status: res.status,
body: body.toString('utf8'),
contentType: type,
url: target.href,
};
}
// Unreachable in practice: the loop returns or continues, and the last iteration
// refuses to continue. Kept so the function has one type on every path.
return { state: 'unreachable', reason: 'too many redirects' };
} finally {
clearTimeout(timer);
}
}
/**
* The `TextFetcher` shape `probeLnurl` takes, backed by the guarded fetch above.
*
* The probe loop keeps its own plain fetcher: those rows are addresses this site chose
* to track and have already been through `normalizeMintUrl`. This one is for the path
* where the address arrived seconds ago from a stranger.
*/
export function guardedTextFetcher(deps: FetchDeps = {}) {
return async (
url: string,
accept: string,
maxBytes: number,
): Promise<{ body: string; status: number } | null> => {
const outcome = await safeFetchText(url, { accept, maxBytes }, deps);
return outcome.state === 'ok' ? { body: outcome.body, status: outcome.status } : null;
};
}
+62 -14
View File
@@ -10,7 +10,7 @@
import { closeDb, getDb } from './db.ts';
import { closePool, runDiscovery } from './discovery.ts';
import { log } from './log.ts';
import { insertMintIfNew } from './mints.ts';
import { insertLnurlIfNew, insertMintIfNew } from './mints.ts';
import { probeAll } from './probe.ts';
import { listMints } from './queries.ts';
@@ -54,16 +54,42 @@ const SEED_MINTS = [
'https://kashu.me',
];
/**
* LNURL mints, seeded by URL alone.
*
* Short for the obvious reason: `kind:38174` is a proposed kind (see
* `docs/KIND-LNURL-MINT.md`) and there are no announcements on the network yet, so
* unlike Cashu — where this list was read off real 38172 events — there is nothing to
* read it off. This is the reference instance the ecosystem was built against, and
* discovery takes over the moment operators start announcing.
*
* Seeded with no status: `insertLnurlIfNew` writes `unknown` and the first probe cycle
* decides, exactly as it does for a Cashu mint. Being on this list is not a claim that
* a mint is up, and is not an endorsement of it.
*/
const SEED_LNURL = [
'https://lnurl.21mint.me',
];
function pad(s: string, width: number): string {
return s.length > width ? `${s.slice(0, width - 1)}…` : s.padEnd(width);
}
async function main(): Promise<void> {
getDb();
const db = await getDb();
log.info('seeding', { db: db.label });
let added = 0;
for (const url of SEED_MINTS) if (insertMintIfNew(url)) added++;
log.info('seed list ingested', { listed: SEED_MINTS.length, added });
for (const url of SEED_MINTS) if (await insertMintIfNew(url)) added++;
let addedLnurl = 0;
for (const url of SEED_LNURL) if (await insertLnurlIfNew(url)) addedLnurl++;
log.info('seed list ingested', {
listed: SEED_MINTS.length + SEED_LNURL.length,
added: added + addedLnurl,
lnurl: addedLnurl,
});
await probeAll();
const discovery = await runDiscovery(true);
@@ -75,13 +101,14 @@ async function main(): Promise<void> {
await probeAll();
}
const mints = listMints();
const width = { host: 44, status: 9, score: 7, reviews: 8, rating: 7 };
const listed = await listMints();
const width = { host: 44, type: 9, status: 10, score: 7, reviews: 8, rating: 7 };
console.log('');
console.log(
[
pad('MINT', width.host),
pad('TYPE', width.type),
pad('STATUS', width.status),
pad('SCORE', width.score),
pad('REVIEWS', width.reviews),
@@ -89,12 +116,13 @@ async function main(): Promise<void> {
'VERSION',
].join(' '),
);
console.log('-'.repeat(110));
console.log('-'.repeat(120));
for (const m of mints) {
for (const m of listed) {
console.log(
[
pad(m.host, width.host),
pad(m.type, width.type),
pad(m.status, width.status),
pad(m.score.toFixed(3), width.score),
pad(String(m.review_count), width.reviews),
@@ -104,16 +132,36 @@ async function main(): Promise<void> {
);
}
const online = mints.filter((m) => m.status === 'online').length;
const offline = mints.filter((m) => m.status === 'offline').length;
const reviews = mints.reduce((sum, m) => sum + m.review_count, 0);
console.log('-'.repeat(110));
const mints = listed.filter((m) => m.type === 'cashu');
const federations = listed.filter((m) => m.type === 'fedimint');
const lnurlMints = listed.filter((m) => m.type === 'lnurl');
const count = (rows: typeof listed, status: string) =>
rows.filter((m) => m.status === status).length;
const reviews = listed.reduce((sum, m) => sum + m.review_count, 0);
console.log('-'.repeat(120));
console.log(
`${mints.length} mints (${online} online, ${offline} offline), ${reviews} reviews indexed`,
`${mints.length} mints (${count(mints, 'online')} online, ${count(mints, 'offline')} offline)`,
);
/*
* Federations are counted with `announced` called out rather than folded into the
* offline total. It is the honest word for "nothing checks this one", and rolling it
* into either of the other two would be the first place the site started claiming to
* know something it does not.
*/
console.log(
`${federations.length} federations (${count(federations, 'online')} confirmed up, ` +
`${count(federations, 'offline')} reported down, ` +
`${count(federations, 'announced')} announced only)`,
);
console.log(
`${lnurlMints.length} LNURL mints (${count(lnurlMints, 'online')} online, ` +
`${count(lnurlMints, 'offline')} offline)`,
);
console.log(`${reviews} reviews indexed`);
closePool();
closeDb();
await closeDb();
process.exit(0);
}
+99 -8
View File
@@ -1,9 +1,41 @@
import { Hono } from 'hono';
import type { Context } from 'hono';
import { cors } from 'hono/cors';
import { serveStatic } from '@hono/node-server/serve-static';
import path from 'node:path';
import { config } from './config.ts';
import { indexSubmission } from './index-mint.ts';
import { getHealth, getMintDetail, getStats, listMints } from './queries.ts';
import { RATE_LIMIT, takeToken } from './rate-limit.ts';
/** The largest `POST /api/index` body read. A JSON object with two short strings. */
const MAX_BODY_BYTES = 8 * 1024;
/**
* Who is submitting, for the rate limiter.
*
* The socket's peer address, unless that peer is the loopback interface — in which case
* this process is behind the reverse proxy the README documents, every request has the
* same peer, and `X-Forwarded-For` is the only thing that tells two visitors apart.
*
* The *last* entry of that header, not the first. nginx's `$proxy_add_x_forwarded_for`
* appends the peer it actually saw to whatever the client sent, so the first entry is a
* value a client can write for itself — a free way around the limit — and the last is
* the one the proxy vouched for. When the peer is not loopback the header is ignored
* entirely, because then there is no proxy to have vouched for anything.
*/
function clientKey(c: Context): string {
const socket = (c.env as { incoming?: { socket?: { remoteAddress?: string } } } | undefined)
?.incoming?.socket;
const peer = socket?.remoteAddress ?? '';
const loopback = peer === '' || peer === '::1' || peer === '127.0.0.1' || peer.startsWith('::ffff:127.');
if (!loopback) return peer;
const forwarded = c.req.header('x-forwarded-for') ?? '';
const hops = forwarded.split(',').map((hop) => hop.trim()).filter(Boolean);
return hops[hops.length - 1] ?? peer ?? 'unknown';
}
export function createApp(): Hono {
const app = new Hono();
@@ -12,33 +44,92 @@ export function createApp(): Hono {
app.use('/api/*', cors());
app.use('/icons/*', cors());
app.get('/api/health', (c) => {
const health = getHealth();
app.get('/api/health', async (c) => {
const health = await getHealth();
c.header('Cache-Control', 'no-store');
return c.json(health, health.status === 'ok' ? 200 : 503);
});
app.get('/api/stats', (c) => c.json(getStats()));
app.get('/api/stats', async (c) => c.json(await getStats()));
app.get('/api/mints', (c) => {
/*
* The whole index, every ecosystem, each item carrying its `type`.
*
* `?type=cashu` / `?type=fedimint` narrows it, which is what the two list pages ask
* for at build time. Unknown values are passed through rather than rejected: they
* return an empty list, which is the honest answer to "show me the ecosystem this
* build does not have".
*/
app.get('/api/mints', async (c) => {
const raw = c.req.query('limit');
const limit = raw ? Number.parseInt(raw, 10) : undefined;
return c.json(listMints(Number.isFinite(limit) ? limit : undefined));
const type = c.req.query('type')?.trim() || undefined;
return c.json(await listMints(Number.isFinite(limit) ? limit : undefined, type));
});
app.get('/api/mints/:host', (c) => {
const detail = getMintDetail(c.req.param('host'));
app.get('/api/mints/:host', async (c) => {
const detail = await getMintDetail(c.req.param('host'));
if (!detail) return c.json({ error: 'not_found', message: 'No mint with that host' }, 404);
return c.json(detail);
});
/*
* The one endpoint that writes: index a mint nobody has announced yet.
*
* It is a POST because it creates a row, and it is rate limited because it is the
* only route that makes this server fetch an address somebody else chose. Everything
* it actually does lives in `index-mint.ts`; what is here is the shape of the request
* and the two things that can only be decided at the edge — who is asking, and how
* much body to read from them.
*/
app.post('/api/index', async (c) => {
const verdict = takeToken(clientKey(c));
if (!verdict.ok) {
c.header('Retry-After', String(verdict.retryAfter));
return c.json(
{
error: 'rate_limited',
message: `At most ${RATE_LIMIT} submissions an hour from one address`,
retry_after: verdict.retryAfter,
},
429,
);
}
// An invite code runs to a few hundred characters; nothing legitimate is near this.
const declared = Number(c.req.header('content-length') ?? '0');
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
return c.json({ error: 'bad_input', message: 'Request body too large' }, 422);
}
let body: { type?: unknown; input?: unknown };
try {
body = (await c.req.json()) as { type?: unknown; input?: unknown };
} catch {
return c.json({ error: 'bad_input', message: 'Body must be JSON' }, 422);
}
const outcome = await indexSubmission(String(body?.type ?? ''), body?.input);
if (outcome.status === 429 && 'retry_after' in outcome.body) {
c.header('Retry-After', String(outcome.body.retry_after ?? 30));
}
return c.json(outcome.body, outcome.status);
});
// Cached mint icons, so an offline mint keeps its icon.
app.use(
'/icons/*',
serveStatic({
root: path.relative(process.cwd(), config.iconDir) || '.',
rewriteRequestPath: (p) => p.replace(/^\/icons/, ''),
onFound: (_p, c) => c.header('Cache-Control', 'public, max-age=86400'),
onFound: (_p, c) => {
c.header('Cache-Control', 'public, max-age=86400');
// These files came from mint operators. nosniff pins the served type, and the
// sandbox neutralizes anything script-capable (an SVG cached before icons.ts
// stopped accepting them) when the file is opened directly on this origin.
c.header('X-Content-Type-Options', 'nosniff');
c.header('Content-Security-Policy', 'sandbox');
},
}),
);
+311
View File
@@ -0,0 +1,311 @@
/**
* A Nostr relay, in one file, for tests.
*
* Enough of NIP-01 to accept an EVENT, answer a REQ and close a subscription, plus the
* addressable-replacement rule, which is not optional here: the whole point of the
* round-trip test is a `kind:38174`, and an addressable kind that a relay stores twice
* would let a broken publisher pass.
*
* Written rather than depended on, deliberately. The alternatives were a real relay
* binary (which CI would have to install and keep running) or a `ws` dependency added to
* the lockfile for test-only code. Node 22 ships a WebSocket *client* — which is what
* nostr-tools uses — but no server, so the handshake and framing below are the actual
* cost of not adding either, and RFC 6455 is small when the only frames that matter are
* short unfragmented text ones.
*
* **Not for production.** No authentication, no persistence, no NIP-42, no rate limits,
* no fragmentation support beyond a single continuation, and everything lives in a Map
* until the process exits. `api/src/check-lnurl.ts` is the only caller.
*/
import { createHash } from 'node:crypto';
import { createServer, type IncomingMessage, type Server } from 'node:http';
import type { Duplex } from 'node:stream';
/** RFC 6455's fixed handshake GUID. */
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
interface StoredEvent {
id: string;
pubkey: string;
kind: number;
created_at: number;
content: string;
tags: string[][];
sig?: string;
}
type Filter = Record<string, unknown>;
/* ---------- framing ---------- */
/** Encode one unfragmented text frame, server to client, never masked. */
function encodeText(text: string): Buffer {
const payload = Buffer.from(text, 'utf8');
const length = payload.length;
let header: Buffer;
if (length < 126) {
header = Buffer.from([0x81, length]);
} else if (length < 65536) {
header = Buffer.alloc(4);
header[0] = 0x81;
header[1] = 126;
header.writeUInt16BE(length, 2);
} else {
header = Buffer.alloc(10);
header[0] = 0x81;
header[1] = 127;
header.writeBigUInt64BE(BigInt(length), 2);
}
return Buffer.concat([header, payload]);
}
interface DecodedFrame {
opcode: number;
payload: Buffer;
/** Total bytes consumed, so the caller can advance its buffer. */
size: number;
}
/**
* Decode one frame from the front of `buffer`, or null when it is not all there yet.
*
* Client frames are always masked, per the spec, so the mask is applied unconditionally
* when the bit is set and ignored when it is not — a browser or Node client never omits
* it, and a test relay has no reason to reject one that did.
*/
function decodeFrame(buffer: Buffer): DecodedFrame | null {
if (buffer.length < 2) return null;
const first = buffer[0]!;
const second = buffer[1]!;
const opcode = first & 0x0f;
const masked = (second & 0x80) !== 0;
let length = second & 0x7f;
let offset = 2;
if (length === 126) {
if (buffer.length < offset + 2) return null;
length = buffer.readUInt16BE(offset);
offset += 2;
} else if (length === 127) {
if (buffer.length < offset + 8) return null;
const big = buffer.readBigUInt64BE(offset);
// A test relay has no business buffering a 4GB frame.
if (big > 8n * 1024n * 1024n) throw new Error('frame too large');
length = Number(big);
offset += 8;
}
let mask: Buffer | null = null;
if (masked) {
if (buffer.length < offset + 4) return null;
mask = buffer.subarray(offset, offset + 4);
offset += 4;
}
if (buffer.length < offset + length) return null;
const payload = Buffer.from(buffer.subarray(offset, offset + length));
if (mask) {
for (let i = 0; i < payload.length; i++) payload[i] = payload[i]! ^ mask[i % 4]!;
}
return { opcode, payload, size: offset + length };
}
/* ---------- filters ---------- */
function matchesFilter(event: StoredEvent, filter: Filter): boolean {
const ids = filter['ids'] as string[] | undefined;
if (ids && !ids.includes(event.id)) return false;
const authors = filter['authors'] as string[] | undefined;
if (authors && !authors.includes(event.pubkey)) return false;
const kinds = filter['kinds'] as number[] | undefined;
if (kinds && !kinds.includes(event.kind)) return false;
const since = filter['since'] as number | undefined;
if (typeof since === 'number' && event.created_at < since) return false;
const until = filter['until'] as number | undefined;
if (typeof until === 'number' && event.created_at > until) return false;
// `#e`, `#p`, `#d`, `#k`, `#u`: match any value of that single-letter tag.
for (const [key, wanted] of Object.entries(filter)) {
if (!key.startsWith('#') || key.length !== 2) continue;
const name = key.slice(1);
const values = wanted as string[];
const present = event.tags.filter((t) => t[0] === name).map((t) => t[1]);
if (!present.some((value) => value !== undefined && values.includes(value))) return false;
}
return true;
}
/**
* The storage key for an event.
*
* Addressable kinds (30000–39999) are keyed by `kind:pubkey:d`, so a second announcement
* from the same publisher for the same mint replaces the first instead of accumulating —
* which is exactly the behaviour `kind:38174` depends on and therefore exactly what a
* test of it must reproduce. Everything else is keyed by its own id.
*/
function storageKey(event: StoredEvent): string {
if (event.kind >= 30000 && event.kind < 40000) {
const d = event.tags.find((t) => t[0] === 'd')?.[1] ?? '';
return `${event.kind}:${event.pubkey}:${d}`;
}
if (event.kind === 0 || event.kind === 3 || (event.kind >= 10000 && event.kind < 20000)) {
return `${event.kind}:${event.pubkey}`;
}
return event.id;
}
/* ---------- the relay ---------- */
export interface TestRelay {
/** `ws://127.0.0.1:<port>`, ready to hand to a pool. */
url: string;
/** Every event currently stored, newest first. */
events(): StoredEvent[];
/** Events of one kind, newest first. */
byKind(kind: number): StoredEvent[];
close(): Promise<void>;
}
/**
* Start a relay on an ephemeral port.
*
* Port 0 rather than a fixed one so two tests, or two CI jobs on one machine, cannot
* collide — the caller reads the real port back off `url`.
*/
export async function startTestRelay(): Promise<TestRelay> {
const stored = new Map<string, StoredEvent>();
const sockets = new Set<Duplex>();
const server: Server = createServer((_req, res) => {
// Not a websocket upgrade. NIP-11 would go here on a real relay.
res.writeHead(426, { 'Content-Type': 'text/plain' });
res.end('websocket only');
});
server.on('upgrade', (req: IncomingMessage, socket: Duplex) => {
const key = req.headers['sec-websocket-key'];
if (typeof key !== 'string') {
socket.destroy();
return;
}
const accept = createHash('sha1').update(key + WS_GUID).digest('base64');
socket.write(
'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' +
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`,
);
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
socket.on('error', () => sockets.delete(socket));
const send = (message: unknown): void => {
if (!socket.destroyed) socket.write(encodeText(JSON.stringify(message)));
};
let buffer = Buffer.alloc(0);
socket.on('data', (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk]);
for (;;) {
let frame: DecodedFrame | null;
try {
frame = decodeFrame(buffer);
} catch {
socket.destroy();
return;
}
if (!frame) break;
buffer = buffer.subarray(frame.size);
if (frame.opcode === 0x8) {
socket.end();
return;
}
// Ping: answer with a pong carrying the same payload, per the spec.
if (frame.opcode === 0x9) {
const pong = encodeText('');
pong[0] = 0x8a;
socket.write(pong);
continue;
}
if (frame.opcode !== 0x1) continue;
let message: unknown;
try {
message = JSON.parse(frame.payload.toString('utf8'));
} catch {
continue;
}
if (!Array.isArray(message)) continue;
const [verb, ...rest] = message as [string, ...unknown[]];
if (verb === 'EVENT') {
const event = rest[0] as StoredEvent | undefined;
if (!event?.id) continue;
const existing = stored.get(storageKey(event));
// Older replacement for an addressable kind: keep what is there, and still
// answer OK, which is what a real relay does.
if (!existing || existing.created_at <= event.created_at) {
stored.set(storageKey(event), event);
}
send(['OK', event.id, true, '']);
continue;
}
if (verb === 'REQ') {
const subId = rest[0] as string;
const filters = rest.slice(1) as Filter[];
const all = [...stored.values()].sort((a, b) => b.created_at - a.created_at);
for (const filter of filters) {
const limit = typeof filter['limit'] === 'number' ? (filter['limit'] as number) : Infinity;
let sent = 0;
for (const event of all) {
if (sent >= limit) break;
if (!matchesFilter(event, filter)) continue;
send(['EVENT', subId, event]);
sent++;
}
}
send(['EOSE', subId]);
continue;
}
if (verb === 'CLOSE') {
send(['CLOSED', rest[0] as string, '']);
}
}
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('relay did not bind a port');
return {
url: `ws://127.0.0.1:${address.port}`,
events: () => [...stored.values()].sort((a, b) => b.created_at - a.created_at),
byKind: (kind) =>
[...stored.values()].filter((e) => e.kind === kind).sort((a, b) => b.created_at - a.created_at),
close: async () => {
for (const socket of sockets) socket.destroy();
sockets.clear();
await new Promise<void>((resolve) => server.close(() => resolve()));
},
};
}
+384
View File
@@ -0,0 +1,384 @@
# NIP-87 extension: `kind:38174`, LNURL mint announcements
`draft` `optional`
> **Status.** This is a proposed extension to [NIP-87][nip87], written to be submitted
> as a PR to [nostr-protocol/nips][nips]. It is not part of NIP-87 today. It is
> implemented and published by [cashumints.space](https://cashumints.space), which is
> also where the reference indexer for it lives. Nothing here changes 38172, 38173 or
> 38000; a client that implements only NIP-87 as written keeps working unchanged, and
> gets partial support for these events for free (see [Reviews](#reviews)).
[nip87]: https://github.com/nostr-protocol/nips/blob/master/87.md
[nips]: https://github.com/nostr-protocol/nips
---
## Rationale
NIP-87 gives ecash mints a way to be discovered, and gives users a way to recommend
them: an operator publishes an addressable announcement, a user publishes a
`kind:38000` naming the announcement's kind in a `k` tag, and a client queries by that
`k` to find recommendations for one ecosystem without seeing the others.
It covers exactly two ecosystems, one kind each: `38172` for Cashu, `38173` for
Fedimint. A third has since shipped — **lnurlcash** ([LUD-25][lud25]), Lightning bearer
notes served over plain [LUD-03][lud03] `withdrawRequest` and [LUD-06][lud06]
`payRequest`, implemented by [dni/lnurl-mint][lnurlmint] — and it has no kind. The
result today is that an lnurlcash mint either goes unannounced or is announced as
something it is not.
The pattern generalises cleanly, and this document does nothing but apply it:
- an LNURL mint has a **stable identifier** (its funding node's public key) → `d`
- it has an **address you connect to** (its https base URL) → `u`
- it runs on a **network** → `n`
- it has a **capability list** → `features`, the analogue of `nuts` and `modules`
The one thing genuinely new here is the capability vocabulary, because Cashu's NUT
numbers and Fedimint's module names have no LNURL equivalent to borrow. It is defined
in full below, and every value in it is grounded in a specific code path in
`lnurl-mint`, because a vocabulary that outruns any implementation is a wish list.
[lud25]: https://github.com/lnurl/luds/blob/luds/25.md
[lud03]: https://github.com/lnurl/luds/blob/luds/03.md
[lud06]: https://github.com/lnurl/luds/blob/luds/06.md
[lud16]: https://github.com/lnurl/luds/blob/luds/16.md
[lud21]: https://github.com/lnurl/luds/blob/luds/21.md
[lnurlmint]: https://github.com/dni/lnurl-mint
---
## Choosing the kind number
`38174` — the next free slot in the NIP-87 family.
Checked before claiming it, on 2026-08-21:
| Source | Result |
| --- | --- |
| [`nips/README.md`][nipsreadme] kind index | `38172` and `38173` listed; `38174` **absent**, and nothing else in `38100–38199` is assigned |
| GitHub search over `nostr-protocol/nips`, issues **and** PRs, for `38174` | **0 results** (open or closed) |
`38174` is in the *addressable* range (`30000 ≤ kind < 40000`), which is required: an
announcement must be replaceable per `(pubkey, kind, d)` so an operator can update
their capability list in place, exactly as 38172 and 38173 are.
**If `38174` is claimed before this is merged**, take the next free kind in the same
range, prefer keeping the family contiguous, and record the collision and the
replacement here — implementations read the kind from one constant
(`KIND_LNURL_ANNOUNCEMENT`) precisely so that this is a one-line change.
[nipsreadme]: https://github.com/nostr-protocol/nips/blob/master/README.md
---
## The announcement event
LNURL mints SHOULD publish `kind:38174` to announce their capabilities and how to reach
them.
```json
{
"kind": 38174,
"pubkey": "<publisher-pubkey>",
"content": "<optional-kind:0-style-metadata>",
"tags": [
["d", "021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555"],
["u", "https://lnurl.21mint.me"],
["features", "mint,melt,rotate,split,merge,lud06,lud03,lud16,lud21,signed-notes"],
["n", "mainnet"]
]
}
```
### `d` — the identifier
The `d` tag MUST be one of the following, in this order of preference:
1. **The mint's `mintPubkey`**, lowercase hex, when the mint advertises one. This is
the funding node's own identity key — a 33-byte compressed secp256k1 public key, 66
hex characters, beginning `02` or `03` — exposed on the mint-address endpoint
whenever a funding source is configured, and the same key the mint signs notes with
under LUD-25 offline verification.
2. **The normalized host**, when it does not: the lowercase hostname, plus `:port` if
non-default, plus the base path with no trailing slash, and **no scheme**. For
`https://mint.example.com/lnurl/` that is `mint.example.com/lnurl`.
The two forms are unambiguous by construction: form 1 is always exactly 66 hex
characters, form 2 always contains a `.` and never matches `^[0-9a-f]{66}$`.
#### When a mint gains a pubkey after being announced by host
This is not hypothetical. `mintPubkey` is present only while a funding source is
configured **and reachable**; a mint run without one — or announced during an outage —
legitimately has no pubkey to publish, and gains one later.
The rules, which exist so that identity never silently forks:
- **Prefer the pubkey once it exists.** A publisher that has been announcing by host
and learns a `mintPubkey` SHOULD begin publishing under the pubkey `d`.
- **The identifier is sticky.** Once a mint has been announced under a pubkey `d`, a
publisher MUST NOT revert to the host form because the node happened to be
unreachable at publish time. Absence of `mintPubkey` in one response is a statement
about the node's availability in that instant, not about the mint's identity.
- **The old announcement SHOULD be left in place**, not deleted. It is addressable, so
it stays queryable, and reviews already pointing at the host `d` keep resolving.
Publishers MAY additionally re-publish the host-`d` event with the same `u` tag so
both identifiers lead to the same live address.
- **Consumers MUST accept either.** An indexer resolving a review's `d` SHOULD try, in
order: exact match on a known `mintPubkey`; exact match on a known normalized host;
then fall back to the `u` tag. It MUST NOT treat the two identifiers for one mint as
two mints — deduplication is by **normalized base URL** (`u`), which is the one value
present in every form of the event.
### `u` — the address
The https base URL of the mint, with no trailing slash: `https://lnurl.21mint.me`.
`u` is REQUIRED in this kind, unlike in 38172 where it is a SHOULD. An LNURL mint's
`d` may be a bare host string with no scheme, which is not fetchable, so without `u`
there would be events carrying no usable address at all. Multiple `u` tags MAY appear;
the first is canonical. `.onion` addresses MUST NOT be the canonical `u` — they belong
in `features` as `onion` and are discovered from the mint itself.
Everything a client needs hangs off this base:
| Path | Role |
| --- | --- |
| `{u}/.well-known/lnurlw/_` | mint advertisement: withdraw limits, `mintPubkey`, node identity |
| `{u}/.well-known/lnurlp/_` | LUD-06 payRequest (also `{u}/.well-known/lnurlp/{username}`) |
| `{u}/w?k1=…` | LUD-03 withdrawRequest for one note |
| `{u}/w/cb` | melt / rotate / split / merge |
| `{u}/p/cb` | LUD-06 callback, mints a note |
| `{u}/verify/{payment_hash}` | LUD-21, when `lud21` is advertised |
`_` is [LUD-16][lud16]'s reserved bare-domain username and reaches the same identity as
the operator's configured username, so a consumer can probe without knowing it.
### `n` — the network
Same values and same meaning as NIP-87: `mainnet`, `testnet`, `signet` or `regtest`.
Absent reads as `mainnet`. Consumers SHOULD accept `bitcoin` as a synonym for
`mainnet`, because every Fedimint announcement in the wild writes it and publishers
copy each other.
### `features` — the capability list
One tag, one comma-separated list of lowercase tokens, the direct analogue of Cashu's
`nuts` and Fedimint's `modules`:
```json
["features", "mint,melt,rotate,split,merge,lud06,lud03,lud16,lud21,signed-notes"]
```
Consumers MUST tolerate whitespace after commas, MUST lowercase before comparing, and
MUST ignore tokens they do not recognise rather than rejecting the event — that is what
lets this vocabulary grow without a new kind.
#### The vocabulary
Every value below names something `lnurl-mint` actually implements. The "Grounded in"
column is the specific thing that makes the claim checkable.
**Note operations** — the four branches of the LUD-25 callback `GET /w/cb`, plus
minting. These are what the mint *does*.
| Value | Meaning | Grounded in |
| --- | --- | --- |
| `mint` | A new bearer note can be created by paying a Lightning invoice; the payment preimage becomes the note. | `router.get_pay_callback` (`/p/cb`). **Requires a funding source** — it issues the invoice. Also refused outright while `SUNSET_MINT` is on. |
| `melt` | A note can be redeemed back to a BOLT-11 payment. | `/w/cb` with a `pr` parameter. **Requires a funding source** — it pays the invoice. |
| `rotate` | A note's secret can be replaced by one the holder generates, without changing its value. | `/w/cb` with neither `pr` nor `amount`; the holder supplies `h = sha256(new secret)`. No funding source needed. |
| `split` | One note becomes two, of a chosen amount and the remainder. | `/w/cb` with `amount`; holder supplies `h` and `h2`. No funding source needed. Refused while `SUNSET_MINT` is on, since it grows outstanding liability. |
| `merge` | Several notes become one worth their sum. | `/w/cb` with multiple `k1`. No funding source needed. |
The `mint`/`melt` versus `rotate`/`split`/`merge` division is not cosmetic: it is
exactly the line a missing funding source falls along. Without one the service still
runs and the last three still work, while nothing moves in or out over Lightning. That
is a real, distinct state, and consumers should be able to render it — see
[Availability is not capability](#availability-is-not-capability).
**LNURL sub-specifications** — the wire formats spoken, so a wallet knows what to expect.
| Value | Meaning | Grounded in |
| --- | --- | --- |
| `lud06` | Serves a LUD-06 `payRequest` and its callback. | `router.get_lnaddress` returns `tag: "payRequest"`; `/p/cb` returns `pr`. |
| `lud03` | Serves a LUD-03 `withdrawRequest` per note, informational, never burning. | `router.get_withdraw` (`GET /w?k1=`). Advertised as `callback` on the mint-address response. |
| `lud16` | Payable at a lightning address, `{username}@{host}` and the bare-domain `_@{host}`. | `/.well-known/lnurlp/{username}`, with `text/identifier` in the payRequest metadata. |
| `lud21` | Serves `GET /verify/{payment_hash}` so a wallet with no node can poll settlement. | `router.verify_invoice`, gated on `VERIFY_ENABLED`. **Announcement-only** — see below. |
**Optional extras.**
| Value | Meaning | Grounded in |
| --- | --- | --- |
| `signed-notes` | The mint advertises a `mintPubkey` and returns recoverable signatures over each new note, so a holder can verify issuer and amount offline. | `signing.mint_pubkey` / `signing.sign_note`; `sig`/`sig2` on the `/w/cb` response. Requires a funding source (it signs via the node's `signmessage`). |
| `onion` | The mint also answers on a Tor hidden service. | `ONION_URL`; advertised in the one-pager's "Also via Tor" block, and used as the callback base when a request arrives over it. |
**Not in the vocabulary, deliberately:** anything about fees (they are already disclosed
in the payRequest `metadata` as `Mint fees: <base_msat>,<ppm>`, which is authoritative
and live), anything about the mint's balance or liability, and anything a consumer would
have to take on trust with no way to check.
#### Availability is not capability
`features` says what the mint **implements**. It does not say what is **working right
now**. A mint whose funding node is unreachable still implements `mint` and `melt`; it
just cannot perform them this minute.
Consumers that probe SHOULD present these as two different things. The concrete, checkable
signal, for `lnurl-mint`: if a mint-address response omits `mintPubkey` while still
returning valid `minWithdrawable`/`maxWithdrawable`, the funding source is not reachable,
and `mint`, `melt` and `signed-notes` are unavailable until it is — while `rotate`,
`split` and `merge` continue to work normally.
Consumers MUST NOT rewrite the announcement's `features` from a probe. The announcement
is the operator's statement of what they built; the probe is an observation about a
moment.
#### What a prober may and may not conclude
This matters enough to be normative, because two of these are counter-intuitive and one
is a trap.
| Value | Probe-detectable? | How |
| --- | --- | --- |
| `lud06`, `lud16` | **Yes** | `/.well-known/lnurlp/_` returns `tag: "payRequest"`; `payLink` on the withdraw side names the address. |
| `lud03` | **Yes** | The mint-address response's `callback` points at `/w`. |
| `signed-notes` | **Yes** | `mintPubkey` present on the mint-address response. |
| `onion` | **Yes** | A `*.onion` host in the one-pager at `GET /`. Never in any JSON. |
| `mint`, `melt` | **Partly** | Implementation cannot be probed without minting; *availability* is the `mintPubkey` bit above. |
| `rotate`, `split`, `merge` | **No** | Only `/w/cb` proves them, and calling it mutates or destroys a stranger's note. |
| `lud21` | **No — and this is the trap** | See below. |
**`lud21` cannot be probed.** `VERIFY_ENABLED=false` makes `/verify/{hash}` raise a 404
with detail `"Not found"`. An *enabled* endpoint asked about an unknown payment hash
raises a 404 with detail `"Not found"` too. Both are then converted by the LNURL error
handler into an identical `200 {"status":"ERROR","reason":"Not found"}`. The two states
are byte-identical on the wire — verified against a live instance and a local build of
both configurations. The only genuine advertisement of a `verify` URL is in the response
to `/p/cb`, and calling that **creates a real invoice on the operator's node**, which no
directory should be doing on a timer.
So `lud21` is announcement-only: a prober MUST NOT set it, and MUST NOT clear it either.
### `content`
Optional, and exactly NIP-87's rule: a stringified kind-0-style metadata object
(`name`, `picture`, `about`, …). **If `content` is empty, consumers should fall back to
the `kind:0` of the event's `pubkey`** for the mint's display information.
```json
{"name": "21 Mint", "picture": "https://lnurl.21mint.me/icon.png", "about": "lnurlcash bearer notes, mainnet."}
```
Consumers MUST treat `content` as untrusted text written by anyone: unparseable JSON,
wrong types and hostile strings degrade to "no metadata", never to an error and never to
unescaped output.
---
## Reviews
There is **no new review kind**. A review of an LNURL mint is a plain NIP-87
`kind:38000` whose `k` tag names `38174`:
```json
{
"kind": 38000,
"pubkey": "<reviewer-pubkey>",
"content": "[5/5] Rotations are instant and melts have never failed me.",
"tags": [
["k", "38174"],
["d", "021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555"],
["u", "https://lnurl.21mint.me", "lnurl"],
["a", "38174:<announcer-pubkey>:021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555", "wss://relay.cashumints.space"]
]
}
```
Reusing `38000` is the whole point of doing it this way. An existing NIP-87 client that
knows nothing about `38174` still sees a well-formed recommendation event from an author
it follows, still reads the rating and the text, and still knows it is *not* about Cashu
or Fedimint because the `k` tag says a kind it does not recognise. It half-understands
these reviews for free, and degrades to ignoring them rather than to misfiling them.
- **`k`** — REQUIRED, `"38174"`. This is what separates an LNURL review from a Cashu one,
and it is load-bearing: a Cashu mint's pubkey and an LNURL mint's `mintPubkey` are both
66-hex-character `d` values, so a resolver keying on `d` alone could file one as the
other.
- **`d`** — the announcement's identifier, per the `d` rules above. As NIP-87 says, this
can be computed from the mint's own pubkey even when no announcement event exists.
- **`u`** — OPTIONAL, the mint's https base URL. A third element MAY carry the free-form
marker `lnurl`, matching how NIP-87's own example marks `cashu` and `fedimint`.
- **`a`** — OPTIONAL, `38174:<announcer-pubkey>:<d>` plus a relay hint, pointing at the
announcement event itself.
### Rating
Ratings follow this site's existing convention (NOTES.md), unchanged across all three
ecosystems, because a reader comparing a Cashu mint to an LNURL one must be comparing
the same scale:
1. a `["rating", "<n>"]` tag with `n` in `1..5`, else
2. a `["rating", "<f>"]` tag with `f` in `0..1`, scaled to `1..5` (NIP-87 suggests this
form), else
3. an `[N/5]` prefix on `content`, which is how the great majority of real reviews on
the network encode it.
Unparseable means **no rating**, and the review is excluded from averages. It is never
defaulted to 5.
---
## Query patterns
Recommendations for LNURL mints from people a user follows:
```json
["REQ", "<id>", {"kinds": [38000], "authors": ["<user>", "<contacts…>"], "#k": ["38174"]}]
```
Every review of one specific mint:
```json
["REQ", "<id>", {"kinds": [38000], "#k": ["38174"], "#d": ["021ab8…3555"]}]
```
Announcements, directly:
```json
["REQ", "<id>", {"kinds": [38174]}]
["REQ", "<id>", {"kinds": [38174], "#d": ["021ab8…3555"]}]
```
All three ecosystems in one subscription, which is what an aggregator actually opens:
```json
["REQ", "<id>", {"kinds": [38172, 38173, 38174]}]
["REQ", "<id>", {"kinds": [38000], "#k": ["38172", "38173", "38174"]}]
```
As NIP-87 already warns for `38172`/`38173`: querying announcements directly, with no
web of trust between the reader and the publisher, will surface whatever anyone chose to
publish. Clients doing that SHOULD apply spam prevention or restrict to relays they
trust. Nothing about an announcement is evidence that the mint behind it is honest, or
even that it exists.
---
## Reference implementation
- **Constant and parse rules** — `shared/src/nostr.ts` (`KIND_LNURL_ANNOUNCEMENT`,
`ANNOUNCEMENT_KINDS`), `shared/src/lnurl.ts` (vocabulary, `d` rules, announcement
parser).
- **Discovery and review resolution** — `api/src/discovery.ts`.
- **Probing** — `api/src/lnurl-probe.ts`. What it found on a live instance, and every
ambiguity it had to resolve, is recorded in `NOTES-LNURL.md`.
- **Publishing 38174** — `api/src/announce.ts`. Off by default; see the README section
"Publishing LNURL mint announcements" before turning it on, because it writes to
public relays.
The implementation and this document are meant to be checked against each other:
`api/src/check-lnurl.ts` asserts the `d` rules, the vocabulary, the tag shapes and the
round trip described here, so a change to one that is not a change to the other fails
the test suite.
+2 -1
View File
@@ -4,7 +4,7 @@
"version": "2.0.0",
"type": "module",
"engines": {
"node": ">=20"
"node": ">=22.18"
},
"scripts": {
"dev": "pnpm --parallel --filter ./api --filter ./web dev",
@@ -13,6 +13,7 @@
"seed": "pnpm --filter ./api seed",
"bones": "pnpm --filter ./web bones",
"build": "pnpm --filter ./shared build && pnpm --filter ./web build",
"start": "pnpm --filter ./web start",
"check:links": "pnpm --filter ./web check:links",
"typecheck": "pnpm -r typecheck",
"check:i18n": "pnpm --filter ./web check:i18n",
+403
View File
@@ -25,6 +25,9 @@ importers:
nostr-tools:
specifier: ^2.10.4
version: 2.24.3(typescript@5.9.3)
pg:
specifier: ^8.13
version: 8.23.0
devDependencies:
'@types/better-sqlite3':
specifier: ^7.6.12
@@ -32,6 +35,9 @@ importers:
'@types/node':
specifier: ^22.10.2
version: 22.20.1
'@types/pg':
specifier: ^8.23.1
version: 8.23.1
typescript:
specifier: ^5.6.3
version: 5.9.3
@@ -60,9 +66,15 @@ importers:
'@astrojs/check':
specifier: ^0.9.4
version: 0.9.10(prettier@3.9.6)(typescript@5.9.3)
'@resvg/resvg-js':
specifier: ^2.6.2
version: 2.6.2
boneyard-js:
specifier: ^1.9.0
version: 1.9.0(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0))
satori:
specifier: ^0.33.0
version: 0.33.0
typescript:
specifier: ^5.6.3
version: 5.9.3
@@ -634,6 +646,82 @@ packages:
'@oslojs/encoding@1.1.0':
resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
'@resvg/resvg-js-android-arm-eabi@2.6.2':
resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==}
engines: {node: '>= 10'}
cpu: [arm]
os: [android]
'@resvg/resvg-js-android-arm64@2.6.2':
resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@resvg/resvg-js-darwin-arm64@2.6.2':
resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@resvg/resvg-js-darwin-x64@2.6.2':
resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@resvg/resvg-js-linux-arm-gnueabihf@2.6.2':
resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@resvg/resvg-js-linux-arm64-gnu@2.6.2':
resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@resvg/resvg-js-linux-arm64-musl@2.6.2':
resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@resvg/resvg-js-linux-x64-gnu@2.6.2':
resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@resvg/resvg-js-linux-x64-musl@2.6.2':
resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@resvg/resvg-js-win32-arm64-msvc@2.6.2':
resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@resvg/resvg-js-win32-ia32-msvc@2.6.2':
resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@resvg/resvg-js-win32-x64-msvc@2.6.2':
resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@resvg/resvg-js@2.6.2':
resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==}
engines: {node: '>= 10'}
'@rollup/pluginutils@5.4.0':
resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==}
engines: {node: '>=14.0.0'}
@@ -798,6 +886,11 @@ packages:
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
'@shuding/opentype.js@1.4.0-beta.0':
resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==}
engines: {node: '>= 8.0.0'}
hasBin: true
'@types/better-sqlite3@7.6.13':
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
@@ -822,6 +915,9 @@ packages:
'@types/node@22.20.1':
resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}
'@types/pg@8.23.1':
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@@ -919,6 +1015,10 @@ packages:
base-64@1.0.0:
resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==}
base64-js@0.0.8:
resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==}
engines: {node: '>= 0.4'}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -972,6 +1072,9 @@ packages:
resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==}
engines: {node: '>=16'}
camelize@1.0.1:
resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -1015,6 +1118,9 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
@@ -1035,9 +1141,26 @@ packages:
crossws@0.3.5:
resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==}
css-background-parser@0.1.0:
resolution: {integrity: sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==}
css-box-shadow@1.0.0-3:
resolution: {integrity: sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==}
css-color-keywords@1.0.0:
resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==}
engines: {node: '>=4'}
css-gradient-parser@0.0.17:
resolution: {integrity: sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==}
engines: {node: '>=16'}
css-select@5.2.2:
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
css-to-react-native@3.2.0:
resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==}
css-tree@2.2.1:
resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
@@ -1130,6 +1253,10 @@ packages:
emmet@2.4.11:
resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==}
emoji-regex-xs@2.0.1:
resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==}
engines: {node: '>=10.0.0'}
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -1164,6 +1291,9 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
@@ -1199,6 +1329,9 @@ packages:
picomatch:
optional: true
fflate@0.7.3:
resolution: {integrity: sha512-0Zz1jOzJWERhyhsimS54VTqOteCNwRtIlh8isdL0AXLo0g7xNTfTL7oWrkmCnPhZGocKIkWHBistBrrpoNH3aw==}
file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
@@ -1243,6 +1376,9 @@ packages:
h3@1.15.11:
resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==}
harfbuzzjs@0.10.0:
resolution: {integrity: sha512-SN8LVwCOzvTq3OPNd0+EAghgXugItz56wst567D1vs7LnnKGWprhi3EG58aVOBwhN8HEbQxL4I9NDWkcXUutRw==}
hast-util-from-html@2.0.3:
resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==}
@@ -1273,6 +1409,10 @@ packages:
hastscript@9.0.1:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
hex-rgb@4.3.0:
resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==}
engines: {node: '>=6'}
hono@4.13.3:
resolution: {integrity: sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==}
engines: {node: '>=16.9.0'}
@@ -1344,6 +1484,9 @@ packages:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
linebreak@1.1.0:
resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@@ -1582,6 +1725,12 @@ packages:
package-manager-detector@1.8.0:
resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==}
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
parse-css-color@0.2.1:
resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==}
parse-latin@7.0.0:
resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==}
@@ -1591,6 +1740,40 @@ packages:
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
pg-cloudflare@1.4.0:
resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
pg-connection-string@2.14.0:
resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==}
pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
pg-pool@3.14.0:
resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==}
peerDependencies:
pg: '>=8.0'
pg-protocol@1.16.0:
resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==}
pg-types@2.2.0:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
pg@8.23.0:
resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==}
engines: {node: '>= 16.0.0'}
peerDependencies:
pg-native: '>=3.0.1'
peerDependenciesMeta:
pg-native:
optional: true
pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
piccolore@0.1.3:
resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==}
@@ -1615,10 +1798,29 @@ packages:
engines: {node: '>=20'}
hasBin: true
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.5.26:
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
engines: {node: ^10 || ^12 || >=14}
postgres-array@2.0.0:
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
engines: {node: '>=4'}
postgres-bytea@1.0.1:
resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
engines: {node: '>=0.10.0'}
postgres-date@1.0.7:
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
engines: {node: '>=0.10.0'}
postgres-interval@1.2.0:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
@@ -1733,6 +1935,10 @@ packages:
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
satori@0.33.0:
resolution: {integrity: sha512-yKmqoWcwSV5VPSrLa5huaH90Uszptnl0gU4Nwfg/bneQZ8j8Dd68n0124mN/7ZSJiKWV9VOeCscbLsBm4LHJGQ==}
engines: {node: '>=16'}
sax@1.6.1:
resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==}
engines: {node: '>=11.0.0'}
@@ -1769,6 +1975,10 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -1781,6 +1991,9 @@ packages:
resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==}
engines: {node: '>=20'}
string.prototype.codepointat@0.2.1:
resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
@@ -1876,6 +2089,9 @@ packages:
resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==}
engines: {node: '>=22.19.0'}
unicode-trie@2.0.0:
resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==}
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@@ -2151,6 +2367,10 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
xxhash-wasm@1.1.0:
resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==}
@@ -2196,6 +2416,9 @@ packages:
resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==}
engines: {node: '>=18'}
yoga-layout@3.2.1:
resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
zod-to-json-schema@3.25.2:
resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
peerDependencies:
@@ -2621,6 +2844,57 @@ snapshots:
'@oslojs/encoding@1.1.0': {}
'@resvg/resvg-js-android-arm-eabi@2.6.2':
optional: true
'@resvg/resvg-js-android-arm64@2.6.2':
optional: true
'@resvg/resvg-js-darwin-arm64@2.6.2':
optional: true
'@resvg/resvg-js-darwin-x64@2.6.2':
optional: true
'@resvg/resvg-js-linux-arm-gnueabihf@2.6.2':
optional: true
'@resvg/resvg-js-linux-arm64-gnu@2.6.2':
optional: true
'@resvg/resvg-js-linux-arm64-musl@2.6.2':
optional: true
'@resvg/resvg-js-linux-x64-gnu@2.6.2':
optional: true
'@resvg/resvg-js-linux-x64-musl@2.6.2':
optional: true
'@resvg/resvg-js-win32-arm64-msvc@2.6.2':
optional: true
'@resvg/resvg-js-win32-ia32-msvc@2.6.2':
optional: true
'@resvg/resvg-js-win32-x64-msvc@2.6.2':
optional: true
'@resvg/resvg-js@2.6.2':
optionalDependencies:
'@resvg/resvg-js-android-arm-eabi': 2.6.2
'@resvg/resvg-js-android-arm64': 2.6.2
'@resvg/resvg-js-darwin-arm64': 2.6.2
'@resvg/resvg-js-darwin-x64': 2.6.2
'@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2
'@resvg/resvg-js-linux-arm64-gnu': 2.6.2
'@resvg/resvg-js-linux-arm64-musl': 2.6.2
'@resvg/resvg-js-linux-x64-gnu': 2.6.2
'@resvg/resvg-js-linux-x64-musl': 2.6.2
'@resvg/resvg-js-win32-arm64-msvc': 2.6.2
'@resvg/resvg-js-win32-ia32-msvc': 2.6.2
'@resvg/resvg-js-win32-x64-msvc': 2.6.2
'@rollup/pluginutils@5.4.0(rollup@4.62.4)':
dependencies:
'@types/estree': 1.0.9
@@ -2750,6 +3024,11 @@ snapshots:
'@shikijs/vscode-textmate@10.0.2': {}
'@shuding/opentype.js@1.4.0-beta.0':
dependencies:
fflate: 0.7.3
string.prototype.codepointat: 0.2.1
'@types/better-sqlite3@7.6.13':
dependencies:
'@types/node': 22.20.1
@@ -2778,6 +3057,12 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/pg@8.23.1':
dependencies:
'@types/node': 22.20.1
pg-protocol: 1.16.0
pg-types: 2.2.0
'@types/unist@3.0.3': {}
'@ungap/structured-clone@1.3.3': {}
@@ -2978,6 +3263,8 @@ snapshots:
base-64@1.0.0: {}
base64-js@0.0.8: {}
base64-js@1.5.1: {}
better-sqlite3@11.10.0:
@@ -3022,6 +3309,8 @@ snapshots:
camelcase@8.0.0: {}
camelize@1.0.1: {}
ccount@2.0.1: {}
chalk@5.6.2: {}
@@ -3054,6 +3343,8 @@ snapshots:
clsx@2.1.1: {}
color-name@1.1.4: {}
comma-separated-tokens@2.0.3: {}
commander@11.1.0: {}
@@ -3068,6 +3359,14 @@ snapshots:
dependencies:
uncrypto: 0.1.3
css-background-parser@0.1.0: {}
css-box-shadow@1.0.0-3: {}
css-color-keywords@1.0.0: {}
css-gradient-parser@0.0.17: {}
css-select@5.2.2:
dependencies:
boolbase: 1.0.0
@@ -3076,6 +3375,12 @@ snapshots:
domutils: 3.2.2
nth-check: 2.1.1
css-to-react-native@3.2.0:
dependencies:
camelize: 1.0.1
css-color-keywords: 1.0.0
postcss-value-parser: 4.2.0
css-tree@2.2.1:
dependencies:
mdn-data: 2.0.28
@@ -3155,6 +3460,8 @@ snapshots:
'@emmetio/abbreviation': 2.3.3
'@emmetio/css-abbreviation': 2.1.8
emoji-regex-xs@2.0.1: {}
emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {}
@@ -3229,6 +3536,8 @@ snapshots:
escalade@3.2.0: {}
escape-html@1.0.3: {}
escape-string-regexp@5.0.0: {}
estree-walker@2.0.2: {}
@@ -3251,6 +3560,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.5
fflate@0.7.3: {}
file-uri-to-path@1.0.0: {}
flattie@1.1.1: {}
@@ -3291,6 +3602,8 @@ snapshots:
ufo: 1.6.4
uncrypto: 0.1.3
harfbuzzjs@0.10.0: {}
hast-util-from-html@2.0.3:
dependencies:
'@types/hast': 3.0.5
@@ -3378,6 +3691,8 @@ snapshots:
property-information: 7.2.0
space-separated-tokens: 2.0.2
hex-rgb@4.3.0: {}
hono@4.13.3: {}
html-escaper@3.0.3: {}
@@ -3424,6 +3739,11 @@ snapshots:
kleur@4.1.5: {}
linebreak@1.1.0:
dependencies:
base64-js: 0.0.8
unicode-trie: 2.0.0
longest-streak@3.1.0: {}
lru-cache@11.5.2: {}
@@ -3838,6 +4158,13 @@ snapshots:
package-manager-detector@1.8.0: {}
pako@0.2.9: {}
parse-css-color@0.2.1:
dependencies:
color-name: 1.1.4
hex-rgb: 4.3.0
parse-latin@7.0.0:
dependencies:
'@types/nlcst': 2.0.3
@@ -3853,6 +4180,41 @@ snapshots:
path-browserify@1.0.1: {}
pg-cloudflare@1.4.0:
optional: true
pg-connection-string@2.14.0: {}
pg-int8@1.0.1: {}
pg-pool@3.14.0(pg@8.23.0):
dependencies:
pg: 8.23.0
pg-protocol@1.16.0: {}
pg-types@2.2.0:
dependencies:
pg-int8: 1.0.1
postgres-array: 2.0.0
postgres-bytea: 1.0.1
postgres-date: 1.0.7
postgres-interval: 1.2.0
pg@8.23.0:
dependencies:
pg-connection-string: 2.14.0
pg-pool: 3.14.0(pg@8.23.0)
pg-protocol: 1.16.0
pg-types: 2.2.0
pgpass: 1.0.5
optionalDependencies:
pg-cloudflare: 1.4.0
pgpass@1.0.5:
dependencies:
split2: 4.2.0
piccolore@0.1.3: {}
picocolors@1.1.1: {}
@@ -3869,12 +4231,24 @@ snapshots:
optionalDependencies:
fsevents: 2.3.2
postcss-value-parser@4.2.0: {}
postcss@8.5.26:
dependencies:
nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
postgres-array@2.0.0: {}
postgres-bytea@1.0.1: {}
postgres-date@1.0.7: {}
postgres-interval@1.2.0:
dependencies:
xtend: 4.0.2
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.1.2
@@ -4068,6 +4442,22 @@ snapshots:
safe-buffer@5.2.1: {}
satori@0.33.0:
dependencies:
'@shuding/opentype.js': 1.4.0-beta.0
css-background-parser: 0.1.0
css-box-shadow: 1.0.0-3
css-gradient-parser: 0.0.17
css-to-react-native: 3.2.0
emoji-regex-xs: 2.0.1
escape-html: 1.0.3
fflate: 0.7.3
harfbuzzjs: 0.10.0
linebreak: 1.1.0
parse-css-color: 0.2.1
postcss-value-parser: 4.2.0
yoga-layout: 3.2.1
sax@1.6.1: {}
semver@7.8.5: {}
@@ -4131,6 +4521,8 @@ snapshots:
space-separated-tokens@2.0.2: {}
split2@4.2.0: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@@ -4148,6 +4540,8 @@ snapshots:
get-east-asian-width: 1.6.0
strip-ansi: 7.2.0
string.prototype.codepointat@0.2.1: {}
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
@@ -4236,6 +4630,11 @@ snapshots:
undici@8.10.0: {}
unicode-trie@2.0.0:
dependencies:
pako: 0.2.9
tiny-inflate: 1.0.3
unified@11.0.5:
dependencies:
'@types/unist': 3.0.3
@@ -4461,6 +4860,8 @@ snapshots:
wrappy@1.0.2: {}
xtend@4.0.2: {}
xxhash-wasm@1.1.0: {}
y18n@5.0.8: {}
@@ -4505,6 +4906,8 @@ snapshots:
yoctocolors@2.2.0: {}
yoga-layout@3.2.1: {}
zod-to-json-schema@3.25.2(zod@3.25.76):
dependencies:
zod: 3.25.76
+500
View File
@@ -0,0 +1,500 @@
/**
* Fedimint federations: what a NIP-87 kind 38173 announcement actually contains, and
* the handful of values derived from it that the API, the pages and the islands all
* have to agree on.
*
* Everything here was written against real events pulled off the relay pool this site
* already reads, not against a reading of the spec, for the same reason NOTES.md gives
* for the Cashu side: what matters is what publishers actually put on relays. Three
* places where the two differ, all resolved in favour of the wire:
*
* - the `n` tag is `bitcoin`, not `mainnet`. `normalizeNetwork` maps it.
* - `modules` are protocol short names (`ln`, `mint`, `wallet`, `lnv2`, `meta`,
* `stability_pool`), not the prose words. `MODULE_ALIASES` maps them.
* - `content` carries `{"federation_name": "..."}` rather than a kind-0 `name`.
* `parseFedimintAnnouncement` accepts either.
*
* A federation has no URL and no HTTP info endpoint, so its identity is the federation
* id from the `d` tag and nothing else. `fedimintKey` turns that into the synthetic
* primary key its row uses, and `fedimintSlug` into the routing slug.
*/
import {
sanitizeDisplayText, sanitizePictureUrl, tagValue, tagValues, type NostrEventLike,
} from './nostr.js';
/** Routing slugs are prefixed so a federation can never collide with a mint host. */
export const FEDIMINT_SLUG_PREFIX = 'fed-';
/** How much of the federation id the slug carries. The full id is in the payload. */
export const FEDIMINT_SLUG_CHARS = 16;
/** The scheme on the synthetic `mints.url` a federation row is keyed by. */
export const FEDIMINT_KEY_SCHEME = 'fedimint:';
/** A federation id is a 32 byte hash, written as 64 hex characters. */
export function isFederationId(value: unknown): value is string {
return typeof value === 'string' && /^[0-9a-f]{64}$/i.test(value);
}
/** `fed-` plus the first 16 characters of the federation id. */
export function fedimintSlug(federationId: string): string {
return FEDIMINT_SLUG_PREFIX + federationId.toLowerCase().slice(0, FEDIMINT_SLUG_CHARS);
}
/**
* The primary key a federation row uses in place of a mint URL.
*
* `mints.url` is the table's primary key and a federation has no URL to put there. A
* `fedimint:` scheme keeps the column non-null and unique, is obviously not something
* to fetch, and is what a review row points at.
*/
export function fedimintKey(federationId: string): string {
return FEDIMINT_KEY_SCHEME + federationId.toLowerCase();
}
/** The federation id inside a `fedimint:` key, or null if that is not what this is. */
export function federationIdFromKey(key: string): string | null {
if (!key.startsWith(FEDIMINT_KEY_SCHEME)) return null;
const id = key.slice(FEDIMINT_KEY_SCHEME.length);
return isFederationId(id) ? id.toLowerCase() : null;
}
/* ---------- invite codes ---------- */
/**
* A fedimint invite code: bech32m holding the federation id and the guardian addresses
* a wallet needs to join. Long, opaque, and the only thing a reader actually copies off
* a federation page.
*
* Every real code starts `fed11`, which is two things and not a typo: `fed1` is the
* human-readable part, and the `1` after it is bech32's separator. A first attempt at
* this anchored on `fed1` followed by a bech32 data character and rejected every code
* on the network, because bech32's alphabet deliberately excludes `1`.
*
* Past the prefix it is loose on purpose. The code is handed to a wallet verbatim and
* nothing in this codebase decodes it, so the check only has to keep junk out of a copy
* button — and being stricter than the wallets that consume it would mean dropping a
* federation from the site over a character this code has no opinion about.
*/
export function isInviteCode(value: unknown): value is string {
return typeof value === 'string' && /^fed1[a-z0-9]{20,}$/i.test(value);
}
/** Every usable invite code in a list, lowercased, in order, without repeats. */
export function cleanInviteCodes(values: readonly string[]): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const raw of values) {
const code = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
if (!isInviteCode(code) || seen.has(code)) continue;
seen.add(code);
out.push(code);
}
return out;
}
/* ---------- modules ---------- */
/**
* The three modules a federation page gives a named row of its own, and every short
* name that satisfies each.
*
* Versioned modules are aliases rather than separate rows: a federation running `lnv2`
* and no `ln` can still do Lightning, and a row reading "Lightning — not supported"
* beside a `lnv2` chip would be false. The version still shows, as its own chip in the
* list underneath.
*/
export const MODULE_ALIASES: Record<string, readonly string[]> = {
lightning: ['ln', 'lnv2', 'lightning'],
mint: ['mint', 'mintv2'],
wallet: ['wallet', 'walletv2'],
};
/** Order of the named rows, highest interest first. Mirrors HIGHLIGHT_NUTS. */
export const HIGHLIGHT_MODULES = ['lightning', 'mint', 'wallet'] as const;
export type HighlightModule = (typeof HIGHLIGHT_MODULES)[number];
/**
* Plain-language names for the module short names seen in the wild, and the English
* source of truth for them. The catalogs carry a translation per key under
* `fedimint.module.`; a module neither knows renders as its own short name, which is
* still a true label.
*/
export const MODULE_NAMES_EN: Record<string, string> = {
lightning: 'Lightning',
mint: 'Ecash mint',
wallet: 'On-chain wallet',
meta: 'Metadata',
stability_pool: 'Stability pool',
multi_sig_stability_pool: 'Stability pool (multisig)',
'fedi-social': 'Social recovery',
unknown: 'Unknown module',
};
/** Which highlight row a module short name belongs to, or null for the chip list. */
export function highlightModuleFor(module: string): HighlightModule | null {
const name = module.toLowerCase();
for (const key of HIGHLIGHT_MODULES) {
if (MODULE_ALIASES[key]?.includes(name)) return key;
}
return null;
}
/** True when this federation runs anything satisfying one of the named rows. */
export function hasModule(modules: readonly string[], key: HighlightModule): boolean {
const aliases = MODULE_ALIASES[key] ?? [];
return modules.some((module) => aliases.includes(module.toLowerCase()));
}
/**
* Split a `modules` tag: `"ln,mint,wallet,lnv2,meta"`.
*
* Comma separated in every event seen, but whitespace is tolerated because a publisher
* writing `"ln, mint"` meant the same thing.
*/
export function parseModules(value: string | null | undefined): string[] {
if (!value) return [];
const out: string[] = [];
const seen = new Set<string>();
for (const part of value.split(/[,\s]+/)) {
const module = part.trim().toLowerCase();
if (!module || module.length > 40 || seen.has(module)) continue;
if (!/^[a-z0-9_-]+$/.test(module)) continue;
seen.add(module);
out.push(module);
}
return out;
}
/* ---------- network ---------- */
/** The networks this site has a name for. Anything else renders as published. */
export const KNOWN_NETWORKS = ['mainnet', 'testnet', 'testnet4', 'signet', 'regtest'] as const;
/**
* Normalize an `n` tag.
*
* NIP-87 describes the value as mainnet/testnet/signet/regtest; every real announcement
* on the network writes `bitcoin` for mainnet, which is what fedimint's own config
* calls it. Both are accepted and both come out as `mainnet`, so one federation cannot
* appear on two networks depending on which word its operator used.
*/
export function normalizeNetwork(value: string | null | undefined): string | null {
const raw = value?.trim().toLowerCase();
if (!raw) return null;
if (raw === 'bitcoin' || raw === 'main' || raw === 'mainnet') return 'mainnet';
if (!/^[a-z0-9]{1,20}$/.test(raw)) return null;
return raw;
}
/** True for anything that is not real bitcoin. Worth a badge of its own on a page. */
export function isTestNetwork(network: string | null): boolean {
return network !== null && network !== 'mainnet';
}
/* ---------- the announcement ---------- */
export interface FedimintAnnouncement {
/** The `d` tag: the federation id, lowercase hex. */
federationId: string;
/** Every `u` tag that is a usable invite code. At least one, or this is not valid. */
inviteCodes: string[];
/** The `modules` tag, split. */
modules: string[];
/** The `n` tag, normalized. */
network: string | null;
/** From `content`, which is kind-0-shaped metadata or fedimint's own. */
name: string | null;
picture: string | null;
about: string | null;
/** Who published it. Needed for the `a` tag a review points back with. */
announcerPubkey: string;
announcedAt: number;
}
/**
* Read a kind 38173 event, or return null if it is not one this site can use.
*
* An announcement with no valid `d` and no invite code is not something a reader can
* act on: there is nothing to join and nothing to key the row by. Everything else is
* optional and simply renders as absent.
*/
export function parseFedimintAnnouncement(event: NostrEventLike): FedimintAnnouncement | null {
const d = tagValue(event.tags, 'd');
if (!isFederationId(d)) return null;
const inviteCodes = cleanInviteCodes(tagValues(event.tags, 'u'));
if (inviteCodes.length === 0) return null;
const meta = parseAnnouncementMetadata(event.content);
return {
federationId: d.toLowerCase(),
inviteCodes,
modules: parseModules(tagValue(event.tags, 'modules')),
network: normalizeNetwork(tagValue(event.tags, 'n')),
name: meta.name,
picture: meta.picture,
about: meta.about,
announcerPubkey: event.pubkey,
announcedAt: event.created_at,
};
}
export interface AnnouncementMetadata {
name: string | null;
picture: string | null;
about: string | null;
}
/**
* The `content` of an announcement, which NIP-87 describes as kind-0-style metadata.
*
* Every fedimint announcement seen writes `{"federation_name": "..."}` instead, so both
* spellings are read. Unparseable content degrades to no metadata rather than throwing:
* this is arbitrary text written by anyone with a relay connection.
*/
export function parseAnnouncementMetadata(content: string): AnnouncementMetadata {
const empty: AnnouncementMetadata = { name: null, picture: null, about: null };
if (!content.trim()) return empty;
let meta: Record<string, unknown>;
try {
const parsed: unknown = JSON.parse(content);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return empty;
meta = parsed as Record<string, unknown>;
} catch {
return empty;
}
const first = (...keys: string[]): unknown => {
for (const key of keys) if (meta[key] !== undefined && meta[key] !== null) return meta[key];
return undefined;
};
return {
name: sanitizeDisplayText(first('name', 'federation_name', 'display_name'), 64) ?? null,
picture: sanitizePictureUrl(first('picture', 'federation_icon_url', 'icon_url', 'image')) ?? null,
about: sanitizeDisplayText(first('about', 'description', 'federation_description'), 400) ?? null,
};
}
/**
* The federation id a slug carries, which is the first 16 characters of it.
*
* The list payload has no `federation_id` — that lives on the detail — so a card built
* from the list reads its identifier back out of the routing slug. Shortened is all a
* card has room for anyway, and the full id is on the page it links to.
*/
export function federationIdFromSlug(slug: string): string {
return slug.startsWith(FEDIMINT_SLUG_PREFIX) ? slug.slice(FEDIMINT_SLUG_PREFIX.length) : slug;
}
/**
* The short form of an invite code, for a row that has to fit one.
*
* More head than tail: the prefix is what tells a reader it is an invite code at all,
* and the tail is what tells two of the same federation's codes apart.
*/
export function shortInviteCode(code: string, head = 14, tail = 8): string {
if (code.length <= head + tail + 1) return code;
return `${code.slice(0, head)}…${code.slice(-tail)}`;
}
/* ---------- decoding an invite code ---------- */
/**
* Decoding one, which until now nothing in this codebase did.
*
* `isInviteCode` above is a shape check, and a shape check is all the *display* side
* has ever needed: a code arrives inside an announcement that already carries the
* federation id in its `d` tag, and the code itself is handed to a wallet verbatim.
*
* On-demand indexing changes that. A reader pasting an invite code into the review
* dialog hands over the only thing they have, and there is no announcement beside it
* to read an id off — so the id has to come out of the code, or the federation cannot
* be keyed, deduped against what is already indexed, or given a page.
*
* Two layers, both small and both self-contained (a bech32 dependency for one function
* would be the tail wagging the dog):
*
* 1. **bech32m**, per BIP-350: the same alphabet and checksum as bech32 with a
* different constant. Fedimint uses bech32m, so a code that verifies under the
* *bech32* constant is rejected rather than accepted — it would mean the code was
* produced by something else.
* 2. **fedimint's consensus encoding** of `Vec<InviteCodePart>`: a BigSize count, then
* per part a BigSize tag, a BigSize length and that many bytes. Tag 1 is the
* federation id, 32 bytes. Everything else — guardian API URLs (tag 0), an API
* secret (tag 2), anything a later fedimint adds — is skipped by its length
* without being understood, which is what the tag/length framing is for.
*
* Written against real codes off the relay pool, not against a reading of the Rust, and
* `check-index.ts` asserts it still decodes them.
*/
/** bech32's alphabet, and its two checksum constants. `1` is deliberately not in it. */
const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
const BECH32M_CONST = 0x2bc830a3;
const GENERATOR = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
function bech32Polymod(values: readonly number[]): number {
let chk = 1;
for (const value of values) {
const top = chk >> 25;
chk = ((chk & 0x1ffffff) << 5) ^ value;
for (let i = 0; i < 5; i++) if ((top >> i) & 1) chk ^= GENERATOR[i]!;
}
return chk >>> 0;
}
function hrpExpand(hrp: string): number[] {
const out: number[] = [];
for (const char of hrp) out.push(char.charCodeAt(0) >> 5);
out.push(0);
for (const char of hrp) out.push(char.charCodeAt(0) & 31);
return out;
}
/**
* The payload bytes of a bech32m string, or null if it is not a valid one.
*
* Length capped well above any real invite code: the checksum is only meaningful over
* a string somebody could plausibly have produced, and an unbounded input here would
* be an unbounded loop below.
*/
function decodeBech32m(input: string): { hrp: string; bytes: Uint8Array } | null {
if (input.length < 8 || input.length > 4000) return null;
// Mixed case is invalid in bech32; one case throughout is not.
if (input !== input.toLowerCase() && input !== input.toUpperCase()) return null;
const value = input.toLowerCase();
const split = value.lastIndexOf('1');
if (split < 1 || split + 7 > value.length) return null;
const hrp = value.slice(0, split);
for (const char of hrp) {
const code = char.charCodeAt(0);
if (code < 33 || code > 126) return null;
}
const data: number[] = [];
for (const char of value.slice(split + 1)) {
const index = BECH32_CHARSET.indexOf(char);
if (index === -1) return null;
data.push(index);
}
if (bech32Polymod([...hrpExpand(hrp), ...data]) !== BECH32M_CONST) return null;
// Five-bit groups to eight, dropping the checksum and the final partial group.
const payload = data.slice(0, -6);
const bytes: number[] = [];
let acc = 0;
let bits = 0;
for (const group of payload) {
acc = (acc << 5) | group;
bits += 5;
while (bits >= 8) {
bits -= 8;
bytes.push((acc >> bits) & 0xff);
}
}
// Leftover bits must be zero padding, and there must be fewer than five of them.
if (bits >= 5 || ((acc << (8 - bits)) & 0xff) !== 0) return null;
return { hrp, bytes: Uint8Array.from(bytes) };
}
/** A cursor over the decoded bytes, reading fedimint's BigSize integers and blobs. */
class ByteReader {
private at = 0;
constructor(private readonly bytes: Uint8Array) {}
get done(): boolean {
return this.at >= this.bytes.length;
}
/**
* Lightning's BigSize, which is what fedimint encodes a `u64` as: one byte under
* 0xfd, otherwise a marker and 2, 4 or 8 big-endian bytes.
*
* Returns null rather than throwing when the buffer runs out, so a truncated code is
* a rejected code and not an exception a caller has to catch.
*/
bigSize(): number | null {
const first = this.byte();
if (first === null) return null;
if (first < 0xfd) return first;
const width = first === 0xfd ? 2 : first === 0xfe ? 4 : 8;
let value = 0;
for (let i = 0; i < width; i++) {
const next = this.byte();
if (next === null) return null;
// Above 2^53 nothing here is a real length or tag anyway, and the arithmetic
// stops being exact, so an absurd value is refused rather than rounded.
value = value * 256 + next;
if (value > Number.MAX_SAFE_INTEGER) return null;
}
return value;
}
bytesOf(length: number): Uint8Array | null {
if (length < 0 || this.at + length > this.bytes.length) return null;
const slice = this.bytes.subarray(this.at, this.at + length);
this.at += length;
return slice;
}
private byte(): number | null {
return this.at < this.bytes.length ? this.bytes[this.at++]! : null;
}
}
/** The tag fedimint gives the federation id inside an invite code. */
const INVITE_PART_FEDERATION_ID = 1;
/** A federation id is a 32 byte hash. A part of any other length is not one. */
const FEDERATION_ID_BYTES = 32;
/** Real codes carry two or three parts. This only has to stop a hostile count. */
const MAX_INVITE_PARTS = 64;
function toHex(bytes: Uint8Array): string {
let out = '';
for (const byte of bytes) out += byte.toString(16).padStart(2, '0');
return out;
}
/**
* The federation id inside an invite code, or null if the code is not one.
*
* Null covers every way a pasted string can fail — wrong prefix, a typo the checksum
* catches, valid bech32m that is not an invite code, an invite code with no federation
* id part — because a caller has exactly one thing to say about all of them ("that is
* not an invite code") and telling them apart would be telling a stranger which of
* their guesses was closest.
*/
export function federationIdFromInviteCode(code: string): string | null {
const raw = code?.trim();
if (!raw || !/^fed1[a-z0-9]+$/i.test(raw)) return null;
const decoded = decodeBech32m(raw);
// `fed1` is the human-readable part; the `1` after it is bech32's separator.
if (!decoded || decoded.hrp !== 'fed1') return null;
const reader = new ByteReader(decoded.bytes);
const parts = reader.bigSize();
if (parts === null || parts === 0 || parts > MAX_INVITE_PARTS) return null;
for (let i = 0; i < parts; i++) {
const tag = reader.bigSize();
const length = tag === null ? null : reader.bigSize();
const value = length === null ? null : reader.bytesOf(length);
if (value === null) return null;
if (tag === INVITE_PART_FEDERATION_ID && value.length === FEDERATION_ID_BYTES) {
return toHex(value);
}
}
return null;
}
+3
View File
@@ -4,3 +4,6 @@ export * from './normalize.js';
export * from './score.js';
export * from './nuts.js';
export * from './warnings.js';
export * from './fedimint.js';
export * from './lnurl.js';
export * from './indexing.js';
+208
View File
@@ -0,0 +1,208 @@
/**
* On-demand indexing: the contract `POST /api/index` speaks.
*
* Both ends of that request are in this repository — the API answers it, the 404
* resolver and the review-by-URL dialog send it — so the shapes live here rather than
* being written out twice and drifting. The reason codes in particular are the whole
* point of this file: the API decides *what happened*, the browser decides *what to
* say about it*, and a typo in a string literal must not be the thing that silently
* turns a precise sentence into a generic one.
*
* The endpoint itself is documented in BACKEND.md. What is here is only what both
* sides need to agree on: which types are indexable, what a valid submission looks
* like before any network call, and what comes back.
*/
import { federationIdFromInviteCode, isInviteCode } from './fedimint.js';
import { normalizeMintUrl } from './normalize.js';
import type { MintDetail, MintInfo } from './types.js';
/** The three ecosystems a reader can hand this site an identifier for. */
export const INDEXABLE_TYPES = ['cashu', 'fedimint', 'lnurl'] as const;
export type IndexType = (typeof INDEXABLE_TYPES)[number];
export function isIndexType(value: unknown): value is IndexType {
return typeof value === 'string' && (INDEXABLE_TYPES as readonly string[]).includes(value);
}
/**
* Why a submission did not become a row.
*
* Every one of these is a different sentence to a reader, which is why they are not
* collapsed into a generic failure:
*
* `bad_type` the `type` field was not one of the three
* `bad_input` the input is not a URL (or an invite code) at all
* `blocked_host` it resolves somewhere this server will not fetch from
* `wrong_type` the host answered, as a *different* ecosystem's mint
* `invalid_response` the host answered, as nothing this site recognises
* `invalid_invite` the invite code does not decode
* `unverifiable` nothing answered, and Nostr has never heard of it either
* `rate_limited` too many submissions from one address this hour
*/
export type IndexFailureReason =
| 'bad_type'
| 'bad_input'
| 'blocked_host'
| 'wrong_type'
| 'invalid_response'
| 'invalid_invite'
| 'unverifiable'
| 'rate_limited';
/** How a row that did not already exist came to be written. */
export type IndexSource = 'probe' | 'announcement' | 'invite';
export interface IndexFailure {
error: IndexFailureReason;
/** English, for a log or a `curl`. The browser renders its own translated copy. */
message: string;
/**
* The ecosystem this address *does* look like, when the answer said so.
*
* Only ever set beside `wrong_type`, and it is what lets the dialog offer "this looks
* like a Cashu mint, review it there instead" with a button rather than making the
* reader work out which page they wanted.
*/
detected_type?: IndexType;
/** Seconds until the next submission is accepted. Only beside `rate_limited`. */
retry_after?: number;
}
/** 200 or 201: the same payload `GET /api/mints/:host` returns, plus how it got there. */
export type IndexSuccess = MintDetail & {
/** True when the identifier was already indexed and nothing was probed. */
existing: boolean;
/** Absent on an `existing` hit: nothing was written, so nothing wrote it. */
indexed_from?: IndexSource;
};
export type IndexResponse = IndexSuccess | IndexFailure;
export function isIndexFailure(body: IndexResponse): body is IndexFailure {
return typeof (body as IndexFailure).error === 'string';
}
/**
* The client-side pre-check, run before any network call.
*
* Deliberately shallow: it answers "could this possibly be an address of this kind?"
* and nothing more. Whether the mint exists, answers, or is what it claims is the
* server's question, and asking the browser to guess would only produce a second
* opinion to disagree with. What it does catch is the common typo — an empty box, a
* sentence, a `fed1…` pasted into the Cashu field — before a request goes out.
*
* Returns the value to submit, which is the input with the scheme the normalizer would
* add, so the dialog can show the reader what it is about to check.
*/
export function checkIndexInput(
type: IndexType,
input: string,
): { ok: true; value: string } | { ok: false; reason: 'empty' | 'bad_url' | 'bad_invite' } {
const raw = input.trim();
if (!raw) return { ok: false, reason: 'empty' };
if (type === 'fedimint') {
return isInviteCode(raw) && federationIdFromInviteCode(raw) !== null
? { ok: true, value: raw.toLowerCase() }
: { ok: false, reason: 'bad_invite' };
}
// A pasted invite code in a URL field is a wrong-field mistake, not a malformed URL,
// and saying "that is an invite code" is more use than "that is not a URL".
if (isInviteCode(raw)) return { ok: false, reason: 'bad_invite' };
const normalized = normalizeMintUrl(raw);
return normalized ? { ok: true, value: normalized.url } : { ok: false, reason: 'bad_url' };
}
/**
* Is this body a NUT-06 mint info document?
*
* The probe needs a test that a Cashu mint passes and an arbitrary JSON endpoint fails,
* because `/v1/info` on a host that is not a mint is very often a 200 with *something*
* on it — an API index, a health check, a framework's error object. NUT-06 makes every
* field optional, so the test is "does it carry any of the things only a mint has":
* a `nuts` object, or a mint pubkey, or the name/version pair a mint's info always has.
*
* Kept here rather than in the prober because the wrong-type detection on the LNURL
* path runs the same test against the same document, and two spellings of "is this a
* Cashu mint" is exactly how a submission ends up filed under both ecosystems.
*/
export function isNut06Info(body: unknown): body is MintInfo {
if (!body || typeof body !== 'object' || Array.isArray(body)) return false;
const info = body as Record<string, unknown>;
const nuts = info['nuts'];
if (nuts && typeof nuts === 'object' && !Array.isArray(nuts) && Object.keys(nuts).length > 0) {
return true;
}
// A mint pubkey is 33 compressed bytes, exactly as an LNURL mint's is.
if (typeof info['pubkey'] === 'string' && /^0[23][0-9a-f]{64}$/i.test(info['pubkey'])) {
return true;
}
return typeof info['name'] === 'string' && typeof info['version'] === 'string';
}
/**
* The route a page for this listing lives at, given its type and routing slug.
*
* One table, because three pages, the 404 resolver, the review dialog and the sitemap
* all have to agree on it, and the failure mode of disagreeing is a link to a page that
* does not exist. Locale prefixing is the caller's job (`localePath`).
*/
export const MINT_ROUTES: Record<IndexType, string> = {
cashu: '/mint',
fedimint: '/fedimint',
lnurl: '/lnurl-mint',
};
/** `/mint/mint.example.com`, unprefixed. */
export function mintPath(type: string, host: string): string {
const base = MINT_ROUTES[type as IndexType] ?? MINT_ROUTES.cashu;
return `${base}/${host}`;
}
/**
* Which ecosystem a page path belongs to, or null when it is not a listing page.
*
* The 404 resolver's first question: the reader asked for *something*, and whether
* this build has any business indexing it on their behalf is decided entirely by the
* shape of the path they used.
*/
export function typeForPath(path: string): { type: IndexType; host: string } | null {
const match = /^\/(mint|fedimint|lnurl-mint)\/([^/]+)\/?$/.exec(path);
if (!match?.[1] || !match[2]) return null;
const type: IndexType =
match[1] === 'fedimint' ? 'fedimint' : match[1] === 'lnurl-mint' ? 'lnurl' : 'cashu';
return { type, host: decodeURIComponent(match[2]) };
}
/**
* The address a `/mint/{slug}` or `/lnurl-mint/{slug}` deep link implies, or null when
* the slug cannot be turned back into one.
*
* Routing slugs are deterministic but not reversible: `mint.example.com/Bitcoin` becomes
* `mint.example.com-bitcoin`, and so would a mint at `mint.example.com-bitcoin` if one
* existed. Looking a row up by slug is unaffected — that is what the column is for — but
* *indexing* from a slug means fetching an address, and guessing which of two readings a
* hyphen had would mean probing the wrong host and possibly indexing it.
*
* So only the unambiguous shape is derived: a plain hostname, optionally with the port
* suffix the slug spells `-3338`. Everything else returns null, and the caller says it
* cannot look this one up from the link alone and offers the dialog, where the reader
* can paste the address including its path.
*
* The `lnurl-` collision prefix is deliberately *not* stripped. A slug only takes that
* prefix at insert time, so a link carrying one describes a row that exists and never
* reaches this function; a slug that merely starts with those characters is far more
* likely to be a mint whose hostname begins `lnurl-`, and turning it into a different
* host would mean probing — and possibly indexing — somebody else's server.
*/
export function addressFromSlug(slug: string): string | null {
const value = slug.trim().toLowerCase();
const match = /^([a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,})(?:-(\d{2,5}))?$/.exec(value);
if (!match?.[1]) return null;
return `https://${match[1]}${match[2] ? `:${match[2]}` : ''}`;
}
+714
View File
@@ -0,0 +1,714 @@
/**
* LNURL mints: what a `kind:38174` announcement contains, what the mint's own endpoints
* return, and the handful of derived values the API, the pages and the islands all have
* to agree on.
*
* The kind is this site's own proposed NIP-87 extension, specified in
* `docs/KIND-LNURL-MINT.md`. That document and this file are meant to be read together:
* every rule below is stated there normatively, and `api/src/check-lnurl.ts` asserts the
* two have not drifted apart.
*
* The endpoint parsing was written against the live reference instance and against a
* locally run build of the mint software, not against a reading of the README — the same
* rule NOTES.md sets for the Cashu side. `NOTES-LNURL.md` records what was actually on
* the wire. Four places where the wire and the prose disagreed, all resolved for the wire:
*
* - There is no bare `/p`. The payRequest lives only at `/.well-known/lnurlp/{username}`,
* and `/p` is a hard 404. The mint advertisement — limits, description, pubkey, node
* identity — is on the *withdraw* side, `/.well-known/lnurlw/{username}`.
* - Every registered route answers its errors with **HTTP 200** and an LNURL
* `{"status":"ERROR"}` body. A status code proves nothing; only the parsed body does.
* - `None` fields are dropped from responses entirely, so "no funding source" shows up
* as `mintPubkey` and the node fields being *absent*, not null.
* - LUD-21 verify cannot be distinguished from an unknown payment hash. It is
* announcement-only; nothing here infers it.
*
* Every amount on the wire is millisatoshi. Nothing in this file rounds; `msatToSat`
* is where that decision is made once.
*/
import {
sanitizeDisplayText, sanitizePictureUrl, tagValue, tagValues, type NostrEventLike,
} from './nostr.js';
import { displayDomain, normalizeMintUrl } from './normalize.js';
/** The scheme on the synthetic `mints.url` an LNURL row is keyed by. */
export const LNURL_KEY_SCHEME = 'lnurl:';
/**
* Routing-slug prefix, used **only** on collision.
*
* `mints.host` is globally unique across every ecosystem, so an LNURL mint and a Cashu
* mint on the same hostname would fight over one slug and the loser would silently not
* be tracked. Almost always they do not collide, and the LNURL mint keeps the clean
* `lnurl.21mint.me` slug; when one does, the row takes `lnurl-` in front rather than
* vanishing. See `insertLnurlMint` for why that is decided at insert time and then never
* revisited.
*/
export const LNURL_SLUG_PREFIX = 'lnurl-';
/**
* The primary key an LNURL row uses.
*
* Namespaced rather than storing the bare URL, for the same reason `fedimint:` is: the
* column is the table's primary key across all three ecosystems, and one host serving
* both a Cashu mint and an LNURL mint must produce two rows, not a collision. The base
* URL is recoverable in full, so nothing is lost by the prefix.
*/
export function lnurlKey(baseUrl: string): string {
return LNURL_KEY_SCHEME + baseUrl;
}
/** The base URL inside an `lnurl:` key, or null if that is not what this is. */
export function baseUrlFromKey(key: string): string | null {
if (!key.startsWith(LNURL_KEY_SCHEME)) return null;
const url = key.slice(LNURL_KEY_SCHEME.length);
return url.startsWith('https://') ? url : null;
}
/* ---------- identity ---------- */
/**
* A mint pubkey: the funding node's identity key, 33 bytes compressed, 66 hex characters
* beginning `02` or `03`.
*
* Strict about the length and the prefix on purpose. This value becomes the `d` tag, and
* the whole reason the two `d` forms are unambiguous is that one of them is exactly this
* shape and the other never is.
*/
export function isMintPubkey(value: unknown): value is string {
return typeof value === 'string' && /^0[23][0-9a-f]{64}$/i.test(value);
}
/**
* The `d` fallback: a mint's normalized host, with no scheme and no trailing slash.
*
* `https://mint.example.com/lnurl/` becomes `mint.example.com/lnurl`. Always contains a
* dot and never matches `isMintPubkey`, which is what keeps the two forms apart.
*/
export function hostIdentifier(baseUrl: string): string {
return displayDomain(baseUrl).toLowerCase();
}
/**
* The canonical `d` for a mint, given whatever is known about it.
*
* Pubkey when there is one, host otherwise — and the pubkey is *sticky*: a caller
* passing a previously known pubkey keeps it even when the current probe found none,
* because a node being unreachable for one request is not a change of identity. See
* "When a mint gains a pubkey after being announced by host" in the kind document.
*/
export function lnurlIdentifier(baseUrl: string, mintPubkey: string | null | undefined): string {
return isMintPubkey(mintPubkey) ? mintPubkey.toLowerCase() : hostIdentifier(baseUrl);
}
/**
* Both identifiers a review of this mint could carry, for a `#d` relay filter and for
* resolution.
*
* A mint announced by host before it had a funding source, and by pubkey afterwards, has
* reviews pointing at both. Asking for only the current one strands the older half.
*/
export function lnurlIdentifiers(
baseUrl: string,
mintPubkey: string | null | undefined,
): string[] {
const host = hostIdentifier(baseUrl);
return isMintPubkey(mintPubkey) ? [mintPubkey.toLowerCase(), host] : [host];
}
/* ---------- features ---------- */
/**
* Note operations: the four branches of LUD-25's `/w/cb`, plus minting.
*
* Split into two groups because that is where a missing funding source cuts. `mint` and
* `melt` both need the node — one issues an invoice, the other pays one — while
* `rotate`, `split` and `merge` only rewrite this mint's own book and keep working with
* no node at all. That distinction is the whole reason the degraded state is worth
* rendering rather than collapsing into "offline".
*/
export const FUNDED_FEATURES = ['mint', 'melt'] as const;
export const NOTE_FEATURES = ['rotate', 'split', 'merge'] as const;
/** The LNURL sub-specifications a mint can speak. */
export const SPEC_FEATURES = ['lud06', 'lud03', 'lud16', 'lud21'] as const;
/** Optional extras, both of which are genuinely visible from outside. */
export const EXTRA_FEATURES = ['signed-notes', 'onion'] as const;
/**
* The whole vocabulary, and the only values this build has an opinion about.
*
* Anything else in a `features` tag is kept and shown as its own chip rather than
* dropped: the kind document requires consumers to ignore tokens they do not recognise,
* and rendering an unknown capability under its published name says exactly as much as
* it should.
*/
export const FEATURE_VOCABULARY: readonly string[] = [
...FUNDED_FEATURES, ...NOTE_FEATURES, ...SPEC_FEATURES, ...EXTRA_FEATURES,
];
/**
* The named rows on the mint page's Features panel, in display order.
*
* `notes` is a row and not a feature: the three note-rewriting operations are one thing
* to a reader ("can I reshape what I hold?") and always ship together, so one row
* satisfied by any of them beats three rows that are always identical. Every other row
* is one vocabulary value.
*/
export const HIGHLIGHT_FEATURES = [
'mint', 'melt', 'notes', 'lud16', 'lud21', 'signed-notes', 'onion',
] as const;
export type HighlightFeature = (typeof HIGHLIGHT_FEATURES)[number];
/** Which vocabulary values satisfy each named row. */
export const FEATURE_ALIASES: Record<string, readonly string[]> = {
mint: ['mint'],
melt: ['melt'],
notes: ['rotate', 'split', 'merge'],
lud16: ['lud16'],
lud21: ['lud21'],
'signed-notes': ['signed-notes'],
onion: ['onion'],
};
/**
* Rows whose row is only as good as the funding source behind it.
*
* A mint that implements minting, melting and note signing still cannot do any of them
* while its node is unreachable, and the panel says so rather than showing a tick that
* is not true today. `rotate`/`split`/`merge` are deliberately absent from this set:
* they are exactly what still works.
*/
export const FUNDING_DEPENDENT: readonly HighlightFeature[] = ['mint', 'melt', 'signed-notes'];
/**
* Plain-language names, and the English source of truth for them.
*
* The catalogs carry a translation per key under `lnurl.feature.`; anything neither
* knows renders as its own published token, which is still a true label.
*/
export const FEATURE_NAMES_EN: Record<string, string> = {
mint: 'Mint via Lightning (LUD-06)',
melt: 'Melt to Lightning',
notes: 'Rotate / split / merge notes',
lud16: 'Lightning address',
lud21: 'Payment verification (LUD-21)',
'signed-notes': 'Signed notes (offline verification)',
onion: 'Tor address',
rotate: 'Rotate a note',
split: 'Split a note',
merge: 'Merge notes',
lud06: 'LUD-06 payRequest',
lud03: 'LUD-03 withdrawRequest',
};
/**
* Split a `features` tag: `"mint,melt,rotate,lud06"`.
*
* Comma separated, but whitespace is tolerated because a publisher writing
* `"mint, melt"` meant the same thing — the same latitude `parseModules` gives a
* `modules` tag. Lowercased, deduped, order preserved, and bounded so a hostile tag
* cannot become a thousand chips on a page.
*/
export function parseFeatures(value: string | null | undefined): string[] {
if (!value) return [];
const out: string[] = [];
const seen = new Set<string>();
for (const part of value.split(/[,\s]+/)) {
const feature = part.trim().toLowerCase();
if (!feature || feature.length > 32 || seen.has(feature)) continue;
if (!/^[a-z0-9_-]+$/.test(feature)) continue;
seen.add(feature);
out.push(feature);
if (out.length >= 40) break;
}
return out;
}
/** Which named row a vocabulary value belongs to, or null for the chip list. */
export function highlightFeatureFor(feature: string): HighlightFeature | null {
const name = feature.toLowerCase();
for (const key of HIGHLIGHT_FEATURES) {
if (FEATURE_ALIASES[key]?.includes(name)) return key;
}
return null;
}
/** True when this mint claims anything satisfying one of the named rows. */
export function hasFeature(features: readonly string[], key: HighlightFeature): boolean {
const aliases = FEATURE_ALIASES[key] ?? [];
return features.some((feature) => aliases.includes(feature.toLowerCase()));
}
/** Features with no named row of their own, for the chip list under the seven. */
export function otherFeatures(features: readonly string[]): string[] {
return features.filter((feature) => highlightFeatureFor(feature) === null);
}
/**
* What one row of the Features panel says.
*
* Three states rather than two, which is the one place this panel is richer than the
* Fedimint Modules panel it is modelled on: a capability can be published, and still be
* unavailable this minute because the node behind it is unreachable. Collapsing that
* into "supported" would show a tick beside something that would fail if tried.
*/
export type FeatureState = 'ok' | 'unavailable' | 'none';
/**
* The state of every named row, given what was announced and what the probe saw.
*
* The announcement decides whether a capability exists at all; the probe can only take
* one away, and only the three that depend on a funding source. That asymmetry is
* normative in the kind document: a prober never rewrites an operator's `features`.
*
* `fundingAvailable` is null when nothing has probed yet, which reads as "no reason to
* doubt it" rather than as a failure.
*/
export function featureStates(
features: readonly string[],
fundingAvailable: boolean | null,
): Record<HighlightFeature, FeatureState> {
const out = {} as Record<HighlightFeature, FeatureState>;
for (const key of HIGHLIGHT_FEATURES) {
if (!hasFeature(features, key)) {
out[key] = 'none';
continue;
}
out[key] =
fundingAvailable === false && FUNDING_DEPENDENT.includes(key) ? 'unavailable' : 'ok';
}
return out;
}
/**
* The features a probe can honestly claim, from what it observed.
*
* Every entry has an observation behind it, and the four that are missing are the
* point. `rotate`, `split` and `merge` are only provable by calling `/w/cb`, which
* mutates or destroys a stranger's note; `lud21` is genuinely undetectable, because a
* disabled verify endpoint and an unknown payment hash return byte-identical responses
* (NOTES-LNURL.md §5). None of the four is inferred from a version string.
*
* Two callers, one definition, deliberately: the page renders this beside an operator's
* announced list, and the publisher signs it into a `kind:38174`. Those must not be
* able to disagree about what this site claims to have seen.
*
* The order is the vocabulary's own, so two runs over one mint produce identical
* output and the publisher's change detector does not fire on a reordering.
*/
export function observedFeatures(probe: {
fundingAvailable: boolean | null;
maxWithdrawableMsat: number | null;
maxSendableMsat: number | null;
lightningAddress: string | null;
mintPubkey: string | null;
onionUrl: string | null;
}): string[] {
const features: string[] = [];
/*
* `mint` and `melt` each need two things: the mint advertising the relevant side,
* and a funding source that can actually perform it. A mint whose node is unreachable
* implements both and can do neither, and this site only ever saw it in the state
* where it could not — so it does not say otherwise.
*/
const funded = probe.fundingAvailable === true;
if (funded && probe.maxSendableMsat !== null) features.push('mint');
if (funded && (probe.maxWithdrawableMsat ?? 0) > 0) features.push('melt');
if (probe.maxSendableMsat !== null) features.push('lud06');
// The advertisement's `callback` is `/w`, the LUD-03 withdrawRequest, and parsing the
// advertisement at all is what proves it was served.
if (probe.maxWithdrawableMsat !== null) features.push('lud03');
if (probe.lightningAddress) features.push('lud16');
if (probe.mintPubkey && funded) features.push('signed-notes');
if (probe.onionUrl) features.push('onion');
return features;
}
/**
* What the Features panel renders: what the operator announced, plus what was observed.
*
* A union, and it has to be one. The announcement is the operator's claim about what
* they built and is the richer list — only they can tell you that notes rotate. The
* probe is this site's own observation and is the *only* list for a mint nobody has
* announced yet, which today is every LNURL mint on the network.
*
* Announced values come first so an operator's own ordering survives. Note that this is
* a display concern and nothing else: `LnurlFields.features` still holds the
* announcement verbatim, and the publisher still signs only `observedFeatures`. The
* kind document's rule is that a prober never rewrites an operator's list, and nothing
* here does — it renders two lists side by side.
*/
export function displayFeatures(
announced: readonly string[] | null | undefined,
observed: readonly string[] | null | undefined,
): string[] {
return [...new Set([...(announced ?? []), ...(observed ?? [])])];
}
/* ---------- amounts ---------- */
/**
* Millisatoshi to satoshi, floored.
*
* Floored rather than rounded because both of the numbers this converts are *bounds*: a
* `maxWithdrawable` rounded up advertises a note larger than the mint will ever issue,
* and a `minWithdrawable` rounded down advertises one it will refuse. Flooring keeps the
* displayed range inside the real one at both ends, which is the safe direction to be
* wrong in. Sub-sat amounts floor to 0, which is true and is what the "withdrawals
* disabled" warning keys on.
*/
export function msatToSat(msat: number | null | undefined): number | null {
if (typeof msat !== 'number' || !Number.isFinite(msat) || msat < 0) return null;
return Math.floor(msat / 1000);
}
/* ---------- the mint's own endpoints ---------- */
/** The path the mint advertisement lives on. `_` is LUD-16's reserved bare-domain name. */
export const WITHDRAW_INFO_PATH = '/.well-known/lnurlw/_';
/** The LUD-06 payRequest, used as a fallback when the withdraw side does not answer. */
export const PAY_INFO_PATH = '/.well-known/lnurlp/_';
/**
* What a mint advertisement yields once parsed.
*
* Everything is optional except the two limits and the tag, because that is genuinely
* what varies: a mint with no funding source omits its whole node section, and the
* response is still valid and still worth rendering.
*/
export interface LnurlAdvertisement {
minWithdrawableMsat: number;
maxWithdrawableMsat: number;
defaultDescription: string | null;
mintPubkey: string | null;
payLink: string | null;
nodeAlias: string | null;
nodeUri: string | null;
nodeCapacityMsat: number | null;
nodeChannels: number | null;
nodePeers: number | null;
/**
* Whether the mint's funding source answered.
*
* Derived, not read: `mintPubkey` is populated only when a funding source is both
* configured and reachable, and is dropped from the response otherwise. One bit, and
* it is the only HTTP-visible signal there is — see NOTES-LNURL.md for why it is not
* possible to separate "never configured" from "unreachable right now", and why the
* site does not try.
*/
fundingAvailable: boolean;
}
function num(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function str(value: unknown, max = 400): string | null {
if (typeof value !== 'string') return null;
const text = value.trim();
if (!text || text.length > max) return null;
return text;
}
/**
* Parse `/.well-known/lnurlw/_`.
*
* Returns null for anything that is not a withdrawRequest carrying both limits — which
* includes the mint's own `{"status":"ERROR"}` bodies, since those arrive with HTTP 200
* and would otherwise read as a successful probe. The caller turns null into the
* "responding but invalid" state, which is neither online nor offline.
*/
export function parseAdvertisement(body: unknown): LnurlAdvertisement | null {
if (!body || typeof body !== 'object' || Array.isArray(body)) return null;
const raw = body as Record<string, unknown>;
if (raw['tag'] !== 'withdrawRequest') return null;
const min = num(raw['minWithdrawable']);
const max = num(raw['maxWithdrawable']);
// Both bounds are required, and inverted bounds are not a mint advertisement.
if (min === null || max === null || min < 0 || max < 0 || min > max) return null;
const mintPubkey = isMintPubkey(raw['mintPubkey']) ? String(raw['mintPubkey']).toLowerCase() : null;
return {
minWithdrawableMsat: min,
maxWithdrawableMsat: max,
defaultDescription: str(raw['defaultDescription']),
mintPubkey,
payLink: str(raw['payLink']),
nodeAlias: sanitizeDisplayText(raw['nodeAlias'], 64) ?? null,
nodeUri: str(raw['nodeUri'], 200),
nodeCapacityMsat: num(raw['nodeCapacity']),
nodeChannels: num(raw['nodeNumChannels']),
nodePeers: num(raw['nodeNumPeers']),
fundingAvailable: mintPubkey !== null,
};
}
/** What the LUD-06 payRequest yields. Fetched for the fee, the address and the limits. */
export interface LnurlPayInfo {
minSendableMsat: number;
maxSendableMsat: number;
/** The `text/plain` entry: the closest thing to an operator-written description. */
description: string | null;
/** The `text/identifier` entry, a LUD-16 lightning address. */
identifier: string | null;
/** `Mint fees: <base>,<ppm>`, absent when the mint is fee-free. */
feeBaseMsat: number | null;
feePpm: number | null;
/** The `withdrawLink` extension: lnurlcash's pointer back to the withdraw side. */
withdrawLink: string | null;
}
/**
* Parse `/.well-known/lnurlp/_`.
*
* `metadata` is a JSON *string* holding a JSON array of `[mime, value]` pairs, so it is
* parsed twice. A metadata blob that will not parse costs the description and the
* address and nothing else: the limits above it are still good.
*/
export function parsePayInfo(body: unknown): LnurlPayInfo | null {
if (!body || typeof body !== 'object' || Array.isArray(body)) return null;
const raw = body as Record<string, unknown>;
if (raw['tag'] !== 'payRequest') return null;
const min = num(raw['minSendable']);
const max = num(raw['maxSendable']);
if (min === null || max === null || min < 0 || max < 0 || min > max) return null;
let description: string | null = null;
let identifier: string | null = null;
let feeBaseMsat: number | null = null;
let feePpm: number | null = null;
try {
const entries: unknown = JSON.parse(typeof raw['metadata'] === 'string' ? raw['metadata'] : '[]');
if (Array.isArray(entries)) {
for (const entry of entries) {
if (!Array.isArray(entry) || typeof entry[0] !== 'string' || typeof entry[1] !== 'string') {
continue;
}
const [mime, value] = entry as [string, string];
// `Mint fees: <base_msat>,<ppm>` shares the `text/plain` mime with the
// description, so it is recognised by its prefix and taken out of the running
// for one — otherwise a fee-charging mint's description would be its fee line.
const fees = /^Mint fees:\s*(\d+)\s*,\s*(\d+)\s*$/i.exec(value);
if (mime === 'text/plain' && fees) {
feeBaseMsat = Number.parseInt(fees[1]!, 10);
feePpm = Number.parseInt(fees[2]!, 10);
continue;
}
if (mime === 'text/plain' && description === null) {
description = sanitizeDisplayText(value, 400) ?? null;
}
if (mime === 'text/identifier' && identifier === null) {
identifier = isLightningAddress(value) ? value.trim().toLowerCase() : null;
}
}
}
} catch {
// Unparseable metadata. The limits are still real, so this is not a failed probe.
}
return {
minSendableMsat: min,
maxSendableMsat: max,
description,
identifier,
feeBaseMsat,
feePpm,
withdrawLink: str(raw['withdrawLink']),
};
}
/** `name@domain`, and only that. Same shape rule the review cards apply to a NIP-05. */
export function isLightningAddress(value: unknown): value is string {
if (typeof value !== 'string') return false;
const text = value.trim();
return text.length <= 128 && /^[a-z0-9._+-]+@[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(text);
}
/**
* The mint's lightning address, derived from `payLink`.
*
* Not read off the payRequest's own `text/identifier`, which echoes back whatever
* username was queried — probing `_` gets `_@host`, which is LUD-16's bare-domain form
* and not something to render at a reader. `payLink` is built from the operator's
* configured username unconditionally, so it is the one place the real name appears.
*/
export function addressFromPayLink(payLink: string | null | undefined): string | null {
if (!payLink) return null;
let url: URL;
try {
url = new URL(payLink);
} catch {
return null;
}
const username = /\/\.well-known\/lnurlp\/([^/?#]+)$/.exec(url.pathname)?.[1];
if (!username || username === '_') return null;
const address = `${decodeURIComponent(username)}@${url.hostname.toLowerCase()}`;
return isLightningAddress(address) ? address : null;
}
/**
* A `*.onion` host in the one-pager, or null.
*
* Deliberately looser than base32's alphabet. A v3 address is 56 characters of `a-z2-7`,
* but the mint software's own test fixture is not valid base32, and a real address that
* does not fit the expected shape is still the operator's address. Nothing is ever
* fetched over Tor by this site, so the cost of a wrong match is one displayed string.
*/
export function onionFromHtml(html: string): string | null {
const match = /\b([a-z0-9]{16,60}\.onion)\b/i.exec(html);
return match?.[1]?.toLowerCase() ?? null;
}
/**
* The software version, from `GET /openapi.json`.
*
* `0.0.0+unknown` is the package's own "I could not find my metadata" sentinel, not a
* release, so it is treated as no version at all rather than printed at a reader. The
* title is checked too: an arbitrary FastAPI app on the same host would otherwise
* contribute its version to a mint's page.
*/
export function parseSoftware(body: unknown): string | null {
if (!body || typeof body !== 'object') return null;
const info = (body as Record<string, unknown>)['info'];
if (!info || typeof info !== 'object') return null;
const record = info as Record<string, unknown>;
const title = str(record['title'], 64);
const version = str(record['version'], 64);
if (!title || !version) return null;
if (!/^[\w.+-]+$/.test(version) || version.startsWith('0.0.0+unknown')) return null;
if (!/^[\w.@/ -]+$/.test(title)) return null;
return `${title}/${version}`;
}
/* ---------- the announcement ---------- */
export interface LnurlAnnouncement {
/** The `d` tag: a mint pubkey, or a normalized host. */
identifier: string;
/** The `d` tag when it was a pubkey, else null. Feeds the sticky identity rule. */
mintPubkey: string | null;
/** The canonical `u` tag, normalized. This is what rows are deduped by. */
baseUrl: string;
/** The routing slug the normalizer derived from that URL. */
slug: string;
/** The `features` tag, split. */
features: string[];
/** The `n` tag, normalized. */
network: string | null;
/** From `content`, which is kind-0-shaped metadata. */
name: string | null;
picture: string | null;
about: string | null;
announcerPubkey: string;
announcedAt: number;
}
/**
* Read a kind 38174 event, or return null if it is not one this site can use.
*
* `u` is required here where NIP-87 makes it a SHOULD for 38172, and the kind document
* says why: an LNURL mint's `d` may be a bare host with no scheme, which is not
* something to fetch, so an announcement with no usable `u` carries no address at all
* and there is nothing to probe, key a row by, or link to.
*
* The `d` tag is *not* required to match the `u` tag's host. A mint that has a pubkey
* announces under it, and checking the two against each other would reject exactly the
* events the identity rule exists to allow.
*/
export function parseLnurlAnnouncement(event: NostrEventLike): LnurlAnnouncement | null {
const d = tagValue(event.tags, 'd')?.trim().toLowerCase();
if (!d || d.length > 200) return null;
// A `d` that is neither a pubkey nor host-shaped is not an identifier this build can
// resolve a review against, so the announcement is not usable even if `u` is fine.
if (!isMintPubkey(d) && !/^[a-z0-9.-]+\.[a-z]{2,}(:\d+)?(\/[\w./~-]*)?$/.test(d)) return null;
let normalized: ReturnType<typeof normalizeMintUrl> = null;
for (const raw of tagValues(event.tags, 'u')) {
normalized = normalizeMintUrl(raw);
if (normalized) break;
}
if (!normalized) return null;
const meta = parseLnurlMetadata(event.content);
return {
identifier: d,
mintPubkey: isMintPubkey(d) ? d : null,
baseUrl: normalized.url,
slug: normalized.host,
features: parseFeatures(tagValue(event.tags, 'features')),
network: normalizeLnurlNetwork(tagValue(event.tags, 'n')),
name: meta.name,
picture: meta.picture,
about: meta.about,
announcerPubkey: event.pubkey,
announcedAt: event.created_at,
};
}
/**
* The `content` of a 38174, which the kind document defines as kind-0-style metadata.
*
* Same defensive posture as the Fedimint parser: this is arbitrary text written by
* anyone with a relay connection, so unparseable JSON, wrong types and junk fields all
* degrade to "no metadata" rather than throwing.
*/
export function parseLnurlMetadata(content: string): {
name: string | null;
picture: string | null;
about: string | null;
} {
const empty = { name: null, picture: null, about: null };
if (!content.trim()) return empty;
let meta: Record<string, unknown>;
try {
const parsed: unknown = JSON.parse(content);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return empty;
meta = parsed as Record<string, unknown>;
} catch {
return empty;
}
const first = (...keys: string[]): unknown => {
for (const key of keys) if (meta[key] !== undefined && meta[key] !== null) return meta[key];
return undefined;
};
return {
name: sanitizeDisplayText(first('name', 'display_name', 'mint_name'), 64) ?? null,
picture: sanitizePictureUrl(first('picture', 'icon_url', 'image')) ?? null,
about: sanitizeDisplayText(first('about', 'description'), 400) ?? null,
};
}
/**
* Normalize an `n` tag.
*
* The same mapping the Fedimint side applies, and for the same reason: NIP-87 names the
* value `mainnet`, publishers copy each other, and `bitcoin` is what they write. Kept
* separate from `normalizeNetwork` only so the two ecosystems' rules can diverge later
* without one quietly changing the other; today they agree, and `check-lnurl.ts` asserts
* that they still do.
*/
export function normalizeLnurlNetwork(value: string | null | undefined): string | null {
const raw = value?.trim().toLowerCase();
if (!raw) return null;
if (raw === 'bitcoin' || raw === 'main' || raw === 'mainnet') return 'mainnet';
if (!/^[a-z0-9]{1,20}$/.test(raw)) return null;
return raw;
}
+102 -1
View File
@@ -26,13 +26,114 @@ function isDisallowedHost(hostname: string): boolean {
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true;
if (hostname.endsWith('.onion')) return true;
if (hostname.endsWith('.local')) return true;
if (hostname === '::1' || hostname === '[::1]') return true;
if (PRIVATE_IPV4.test(hostname)) return true;
if (isPrivateIpv6(hostname)) return true;
// Anything that parses as an IP literal is judged by the resolved-address rule too,
// so the two checks cannot disagree about, say, 100.64.0.1 or 224.0.0.1.
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) && isPrivateIpAddress(hostname)) return true;
// A bare label with no dot cannot be a public host.
if (!hostname.includes('.') && !hostname.includes(':')) return true;
return false;
}
/**
* IPv6 forms that are loopback, link-local (fe80::/10), unique-local (fc00::/7) or an
* IPv4-mapped address whose IPv4 part is private. The URL parser brackets an IPv6
* hostname, so both spellings are accepted.
*/
function isPrivateIpv6(hostname: string): boolean {
const bare =
hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
if (!bare.includes(':')) return false;
if (bare === '::' || bare === '::1') return true;
if (/^f[cd]/i.test(bare)) return true;
if (/^fe[89ab]/i.test(bare)) return true;
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(bare);
if (mapped?.[1]) return PRIVATE_IPV4.test(mapped[1]) || mapped[1].startsWith('127.');
return false;
}
/**
* Is this literal IP address one the indexer must never connect to?
*
* Written against a *resolved* address rather than a hostname, which is the difference
* between this and `isDisallowedHost` above: `mint.example.com` looks like an ordinary
* public name and can resolve to `127.0.0.1`, and only the answer DNS gave can tell you
* so. `POST /api/index` fetches URLs a stranger typed, so it resolves first and checks
* every address here before a socket is opened.
*
* Broader than the hostname rule on purpose. Beyond loopback, link-local and the three
* RFC-1918 ranges it also refuses carrier-grade NAT (100.64/10), `0.0.0.0/8`, the
* benchmarking and documentation ranges, multicast and the broadcast address: none of
* them is a public mint, and each of them is somewhere on a network this server can see
* and a stranger should not be able to point it at.
*/
export function isPrivateIpAddress(value: string): boolean {
const ip = value.trim().toLowerCase().replace(/^\[|\]$/g, '');
if (!ip) return true;
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip);
if (v4) {
const [a, b] = [Number(v4[1]), Number(v4[2])];
if (a === undefined || b === undefined || a > 255 || b > 255) return true;
if (a === 0 || a === 10 || a === 127) return true; // this-network, RFC1918, loopback
if (a === 169 && b === 254) return true; // link-local
if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918
if (a === 192 && b === 168) return true; // RFC1918
if (a === 192 && b === 0) return true; // IETF protocol assignments / 192.0.2.0 docs
if (a === 198 && (b === 18 || b === 19)) return true; // benchmarking
if (a === 198 && b === 51) return true; // documentation
if (a === 203 && b === 0) return true; // documentation
if (a === 100 && b >= 64 && b <= 127) return true; // carrier-grade NAT
if (a >= 224) return true; // multicast, reserved, broadcast
return false;
}
if (!ip.includes(':')) return true; // Not an address this function understands.
// An IPv4-mapped or IPv4-compatible address is judged on its IPv4 half.
const mapped = /:((?:\d{1,3}\.){3}\d{1,3})$/.exec(ip);
if (mapped?.[1]) return isPrivateIpAddress(mapped[1]);
if (ip === '::' || ip === '::1') return true;
if (/^f[cd]/.test(ip)) return true; // unique local, fc00::/7
if (/^fe[89ab]/.test(ip)) return true; // link-local, fe80::/10
if (/^ff/.test(ip)) return true; // multicast
return false;
}
/**
* Is this hostname one the indexer must never fetch, before DNS is consulted at all?
*
* The cheap half of the check: `localhost`, `.onion`, `.local`, a bare label with no
* dot, and an IP literal that is already disqualified by `isPrivateIpAddress`. Exported
* so the on-demand indexer can refuse the obvious cases without paying for a lookup,
* and so a redirect hop can be judged by the same rule its origin was.
*/
export function isBlockedHostname(hostname: string): boolean {
return isDisallowedHost(hostname.toLowerCase());
}
/**
* May the indexer fetch this URL? http(s) only, and never a private or local host.
*
* For URLs a mint *publishes* rather than the URL it lives at — its `icon_url` above
* all. Those never pass through `normalizeMintUrl`, so without this check a mint's
* /v1/info could point the indexer at a cloud metadata endpoint or anything else on
* the API host's own network. Callers that follow redirects must re-check every hop.
*/
export function isFetchableUrl(value: URL | string): boolean {
let u: URL;
try {
u = typeof value === 'string' ? new URL(value) : value;
} catch {
return false;
}
if (u.protocol !== 'https:' && u.protocol !== 'http:') return false;
const hostname = u.hostname.toLowerCase();
return hostname !== '' && !isDisallowedHost(hostname);
}
/**
* Normalize a mint URL as found in a Nostr `u` tag or typed by a user.
* Returns null when the input is not a usable public mint URL.
+104 -7
View File
@@ -11,9 +11,58 @@ import type { Profile } from './types.js';
export const KIND_REVIEW = 38000;
/** NIP-87 Cashu mint announcement. */
export const KIND_MINT_ANNOUNCEMENT = 38172;
/** NIP-87 Fedimint federation announcement. */
export const KIND_FEDIMINT_ANNOUNCEMENT = 38173;
/**
* LNURL mint announcement.
*
* Not in NIP-87: this site's own proposed extension to it, specified in
* `docs/KIND-LNURL-MINT.md` and intended for a PR to nostr-protocol/nips. 38174 is the
* next free slot in the family — checked against the kind index in the nips README and
* against every issue and PR in that repository before it was claimed.
*/
export const KIND_LNURL_ANNOUNCEMENT = 38174;
/** Profile metadata. */
export const KIND_PROFILE = 0;
/**
* The one place an ecosystem is tied to its announcement kind.
*
* Everything downstream reads this rather than testing for 38172 or 38173 itself: the
* indexer's subscription list, the review resolver, the `k` tag a published review
* carries, and the filter on /reviews. Adding a third ecosystem is an entry here plus
* a probe strategy and its pages — see the "Adding an ecosystem" section of README.
*
* `mints.type` holds these keys verbatim, so they are a stored value: never rename one
* without a data migration.
*/
export const ANNOUNCEMENT_KINDS = {
cashu: KIND_MINT_ANNOUNCEMENT,
fedimint: KIND_FEDIMINT_ANNOUNCEMENT,
lnurl: KIND_LNURL_ANNOUNCEMENT,
} as const;
/**
* Ecosystems in display order. Cashu first: it is what this site was, and what most of
* its content still is.
*/
export const ECOSYSTEMS = Object.keys(ANNOUNCEMENT_KINDS) as Array<keyof typeof ANNOUNCEMENT_KINDS>;
/** The announcement kind for an ecosystem, or null for one this build does not know. */
export function announcementKind(type: string): number | null {
return (ANNOUNCEMENT_KINDS as Record<string, number>)[type] ?? null;
}
/** The ecosystem an announcement kind belongs to, or null. */
export function ecosystemForKind(kind: number | string | null): string | null {
const n = typeof kind === 'string' ? Number.parseInt(kind, 10) : kind;
if (n === null || !Number.isFinite(n)) return null;
for (const [type, value] of Object.entries(ANNOUNCEMENT_KINDS)) {
if (value === n) return type;
}
return null;
}
/**
* Union of the old site's read pool (utils/ndk.ts CASHU_RELAY_POOL) and its write pool
* (services/reviewPublisher.ts RELAY_URLS). The two lists differed, so reviews the old
@@ -180,6 +229,50 @@ export function isCashuMintReview(event: NostrEventLike): boolean {
return tagValue(event.tags, 'k') === String(KIND_MINT_ANNOUNCEMENT);
}
/** True when the event's `k` tag marks it as being about a Fedimint federation. */
export function isFedimintReview(event: NostrEventLike): boolean {
return tagValue(event.tags, 'k') === String(KIND_FEDIMINT_ANNOUNCEMENT);
}
/** True when the event's `k` tag marks it as being about an LNURL mint. */
export function isLnurlReview(event: NostrEventLike): boolean {
return tagValue(event.tags, 'k') === String(KIND_LNURL_ANNOUNCEMENT);
}
/**
* The kind a review says it is about, from its `k` tag, as a number.
*
* null when the tag is missing or unparseable, which is the shape most reviews already
* on the network have: the old publisher wrote `k` but plenty of other clients do not.
* A review with no `k` is treated as Cashu by every resolver here, because that is the
* only ecosystem that existed when those events were written.
*/
export function reviewTargetKind(event: NostrEventLike): number | null {
const raw = tagValue(event.tags, 'k');
if (!raw) return null;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) ? n : null;
}
/**
* Which ecosystem a review is about: its `k` tag, falling back to Cashu.
*
* The fallback is not a guess about the future, it is a fact about the past. Every
* kind 38000 written before Fedimint support existed is about a Cashu mint, and a great
* many of them carry no `k` at all (see NOTES.md). A review whose `k` names a kind this
* build does not know returns null and is skipped rather than filed under Cashu.
*/
export function reviewEcosystem(event: NostrEventLike): string | null {
const kind = reviewTargetKind(event);
if (kind === null) return 'cashu';
return ecosystemForKind(kind);
}
/** The `d` identifier a review points at, verbatim. Resolution is the caller's job. */
export function reviewTargetId(event: NostrEventLike): string | null {
return tagValue(event.tags, 'd');
}
/**
* Every spelling of a mint URL that may appear in a `u` tag.
*
@@ -210,8 +303,12 @@ export const MAX_PROFILE_NAME = 24;
* Written as a code point scan rather than a regex because the set is exactly the
* invisible ones: C0 and C1 controls, zero width joiners and the bidi overrides that
* let a name reorder the text around it.
*
* Exported because a Fedimint announcement's `content` carries a federation name
* written by the same kind of stranger, and `fedimint.ts` has no business owning a
* second copy of these rules.
*/
function sanitizeText(value: unknown, max: number): string | undefined {
export function sanitizeDisplayText(value: unknown, max: number): string | undefined {
if (typeof value !== 'string') return undefined;
let out = '';
@@ -238,7 +335,7 @@ function sanitizeText(value: unknown, max: number): string | undefined {
* been verified against a domain. Anything not shaped like an address is dropped.
*/
function sanitizeNip05(value: unknown, max: number): string | undefined {
const text = sanitizeText(value, max);
const text = sanitizeDisplayText(value, max);
if (!text || text.length > max) return undefined;
if (!/^[a-z0-9._+-]+@[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(text)) return undefined;
// "_@domain" is NIP-05's root form and reads better as the bare domain.
@@ -249,7 +346,7 @@ function sanitizeNip05(value: unknown, max: number): string | undefined {
* Only http(s) picture URLs are accepted. A `data:` or `javascript:` picture from a
* relay has no business being written into an `img src`.
*/
function sanitizePicture(value: unknown): string | undefined {
export function sanitizePictureUrl(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const url = value.trim();
if (!/^https?:\/\/[^\s"'<>]+$/i.test(url) || url.length > 400) return undefined;
@@ -278,11 +375,11 @@ export function parseProfileContent(pubkey: string, content: string): Profile {
const profile: Profile = {
pubkey,
name: sanitizeText(meta['name'], MAX_PROFILE_NAME),
name: sanitizeDisplayText(meta['name'], MAX_PROFILE_NAME),
display_name:
sanitizeText(meta['display_name'], MAX_PROFILE_NAME)
?? sanitizeText(meta['displayName'], MAX_PROFILE_NAME),
picture: sanitizePicture(meta['picture']),
sanitizeDisplayText(meta['display_name'], MAX_PROFILE_NAME)
?? sanitizeDisplayText(meta['displayName'], MAX_PROFILE_NAME),
picture: sanitizePictureUrl(meta['picture']),
nip05: sanitizeNip05(meta['nip05'], 64),
found: true,
};
+38 -8
View File
@@ -55,18 +55,48 @@ export function bayesianScore(m: ScoreInput, priorMean: number, now: number): nu
return Math.round(score * 1000) / 1000;
}
/**
* Three tiers, and the middle one exists for federations.
*
* `announced` is a Fedimint status: nothing has ever confirmed the thing is running, so
* it cannot sit with the confirmed-online rows — but it is not evidence of being down
* either, so it must not sink to the bottom with the rows a check actually failed on.
* Not knowing belongs between knowing and knowing otherwise.
*
* A Cashu mint is never `announced`, so this is exactly the two-tier order it always
* had for them.
*/
function healthRank(status: string): number {
if (status === 'offline') return 2;
if (status === 'announced') return 1;
return 0;
}
/**
* Default sort: every online mint before every offline one, then by score descending.
* Offline mints must stay findable (people need to reach them to review them), they
* just never appear above a live mint.
*/
export function compareMints<T extends { status: string; score: number; review_count: number }>(
a: T,
b: T,
): number {
const aOff = a.status === 'offline' ? 1 : 0;
const bOff = b.status === 'offline' ? 1 : 0;
if (aOff !== bOff) return aOff - bOff;
export function compareMints<
T extends { status: string; score: number; review_count: number; host?: string },
>(a: T, b: T): number {
const aRank = healthRank(a.status);
const bRank = healthRank(b.status);
if (aRank !== bRank) return aRank - bRank;
if (b.score !== a.score) return b.score - a.score;
return b.review_count - a.review_count;
if (b.review_count !== a.review_count) return b.review_count - a.review_count;
/*
* A final tiebreak on the slug, so two indistinguishable rows still have an order.
*
* Without it the winner is whatever the database happened to return first, which is a
* different answer on SQLite and on Postgres — the same data served in two orders, and
* a nightly rebuild that reshuffles rows for no reason. It went unnoticed while ties
* were rare (two unreviewed mints); federations made it the common case, since every
* one that nobody has reviewed scores exactly the prior.
*
* Optional in the type because the ranking checks compare bare score objects that have
* no slug, and there is nothing to tiebreak in a two-element fixture.
*/
return (a.host ?? '').localeCompare(b.host ?? '');
}
+188 -4
View File
@@ -1,6 +1,24 @@
/** Shapes returned by the API. The web app builds against these. */
export type MintStatus = 'online' | 'degraded' | 'offline' | 'unknown';
/**
* Statuses a listed thing can be in.
*
* `announced` is the Fedimint addition, and it is deliberately not a synonym for
* `unknown`. `unknown` means "we have not checked yet"; `announced` means "there is no
* check we can run" — the federation exists on Nostr, nothing has confirmed it since,
* and nothing here will claim otherwise. A Cashu mint never carries it.
*/
export type MintStatus = 'online' | 'degraded' | 'offline' | 'unknown' | 'announced';
/**
* Which ecosystem a listing belongs to.
*
* Deliberately open rather than a closed union: `mints.type` is a stored TEXT column
* with no CHECK constraint, so an older build reading a database written by a newer one
* has to be able to hold a value it does not have a page for. Compare against
* `ANNOUNCEMENT_KINDS` rather than switching exhaustively on this.
*/
export type MintType = 'cashu' | 'fedimint' | 'lnurl' | (string & {});
/** One item of `GET /api/mints`. */
export interface MintListItem {
@@ -8,6 +26,8 @@ export interface MintListItem {
host: string;
name: string | null;
icon: string | null;
/** 'cashu', 'fedimint' or 'lnurl'. Rows written before the column existed read as 'cashu'. */
type: MintType;
status: MintStatus;
last_online: number | null;
review_count: number;
@@ -25,8 +45,17 @@ export interface ProbeSample {
export type RatingDistribution = Record<'1' | '2' | '3' | '4' | '5', number>;
/** `GET /api/mints/:host`. */
export interface MintDetail extends MintListItem {
/**
* `GET /api/mints/:host`.
*
* The Fedimint and LNURL keys are optional and absent on a Cashu mint, which is what
* keeps the Cashu payload unchanged. Read them through `FedimintDetail` or
* `LnurlDetail` after checking `type`.
*/
export interface MintDetail
extends MintListItem,
Partial<FedimintFields>,
Partial<LnurlFields> {
description: string | null;
pubkey: string | null;
info: MintInfo | null;
@@ -41,7 +70,140 @@ export interface MintDetail extends MintListItem {
probes_recent: ProbeSample[];
}
/** `GET /api/stats`. */
/**
* What `ecosystem_json` holds for a Fedimint row, and what `GET /api/mints/:host`
* spreads across the detail payload for one.
*
* Flattened rather than nested so a Cashu payload is byte for byte what it always was:
* these keys are simply absent on one. A third ecosystem adds its own interface here
* and its own optional keys below; nothing existing has to move.
*/
export interface FedimintFields {
/** The `d` tag of the announcement: 64 hex characters. `host` is derived from it. */
federation_id: string;
/** Every `u` tag that was a usable invite code, in announcement order. */
invite_codes: string[];
/** The `modules` tag, split: `["ln", "mint", "wallet", "lnv2", "meta"]`. */
modules: string[];
/** The `n` tag, normalized — `bitcoin` and `mainnet` both arrive as `mainnet`. */
network: string | null;
/** `created_at` of the newest announcement seen for this federation. */
announced_at: number | null;
/** Who published that announcement. The `a` tag of a review points back at them. */
announcer_pubkey: string | null;
/**
* Who confirmed the status, when anything did: `"fedimint.observer"` today.
*
* null means nothing has, and then `status` is `announced` and never `online`. The
* page prints this next to the status, because "someone else says it is up" is a
* different claim from "we checked".
*/
status_source: string | null;
}
/** `GET /api/mints/:host` for a Fedimint federation: the detail plus its own fields. */
export type FedimintDetail = MintDetail & FedimintFields;
/**
* What `ecosystem_json` holds for an LNURL row.
*
* Two sources, never mixed: `features` is what the operator *announced* on Nostr, and
* everything under "probed" is what the mint's own endpoints said when they were last
* reached. The kind document makes that separation normative — a prober never rewrites
* an operator's capability list — and keeping the two in different fields is what makes
* it impossible to do by accident.
*
* Every millisatoshi field is stored exactly as the wire gave it. Conversion to sats
* happens once, at render, through `msatToSat`.
*/
export interface LnurlFields {
/** The `d` tag: the mint pubkey when it has one, else the normalized host. */
lnurl_id: string;
/** The https base URL. `host` is the routing slug derived from it. */
base_url: string;
/** The `features` tag, split. The operator's claim, never edited by a probe. */
features: string[];
/**
* What the last probe actually observed this mint serving.
*
* Kept apart from `features` above rather than merged into it, because the two are
* different kinds of statement — a claim and an observation — and the kind document
* makes it normative that a prober never rewrites the first. The page renders their
* union through `displayFeatures`; the publisher signs only this one.
*/
observed_features: string[];
/** The `n` tag, normalized — `bitcoin` and `mainnet` both arrive as `mainnet`. */
network: string | null;
/** `created_at` of the newest announcement seen. null for a seeded row. */
announced_at: number | null;
/** Who published that announcement. The `a` tag of a review points back at them. */
announcer_pubkey: string | null;
/* ---- probed: from the mint's own endpoints ---- */
/**
* The funding node's identity key, from the mint advertisement.
*
* Sticky once learned: a probe that finds none does not clear it, because a node
* being unreachable for one request is not a change of identity. Its *absence from
* the latest probe* is recorded separately, in `funding_available`.
*/
mint_pubkey: string | null;
/**
* Whether the last probe found a reachable funding source.
*
* null before anything has probed. false is the degraded-but-online state: the mint
* answers, its limits are real, and `rotate`/`split`/`merge` still work, but nothing
* moves in or out over Lightning. See NOTES-LNURL.md for why this one bit cannot
* distinguish "never configured" from "unreachable right now", and why that is fine.
*/
funding_available: boolean | null;
/** Which endpoint answered: the withdraw advertisement, or the payRequest fallback. */
probe_endpoint: string | null;
/**
* Set when the host answered but with something that is not a mint advertisement.
*
* A distinct outcome from both online and offline, and it has to be: these endpoints
* return HTTP 200 for their errors, so "responding" and "working" are different
* questions. Carries the short reason, for the banner.
*/
invalid_reason: string | null;
/** Withdraw bounds, millisatoshi: what a note's value can actually be. */
min_withdrawable_msat: number | null;
max_withdrawable_msat: number | null;
/** Pay bounds, millisatoshi: what a minter can actually send. Not the same numbers. */
min_sendable_msat: number | null;
max_sendable_msat: number | null;
/** `Mint fees: <base>,<ppm>` from the payRequest metadata. Absent means fee-free. */
fee_base_msat: number | null;
fee_ppm: number | null;
/** The LUD-16 address, derived from `payLink` rather than the echoed identifier. */
lightning_address: string | null;
/** The Tor address from the one-pager, when one is advertised. */
onion_url: string | null;
/** The funding node, as the mint chooses to describe it. All optional, all msat. */
node_alias: string | null;
node_uri: string | null;
node_capacity_msat: number | null;
node_channels: number | null;
node_peers: number | null;
}
/** `GET /api/mints/:host` for an LNURL mint: the detail plus its own fields. */
export type LnurlDetail = MintDetail & LnurlFields;
/**
* `GET /api/stats`.
*
* The four `mints_*` fields count Cashu mints and only Cashu mints, exactly as they did
* before federations were indexed: they are read by the pulse ticker, the /mints
* description and the home page, and a number that silently changed meaning would be
* worse than a new field. `cashu_total` is the same number under a name that says so,
* and every ecosystem added later gets its own `*_total` beside `fedimint_total`.
*/
export interface Stats {
mints_total: number;
mints_online: number;
@@ -50,6 +212,28 @@ export interface Stats {
reviews_total: number;
last_review_at: number | null;
updated_at: number;
/** Same value as `mints_total`, named for the ecosystem it counts. */
cashu_total: number;
fedimint_total: number;
/** Federations a real check confirmed were up. Never inferred from an announcement. */
fedimint_online: number;
fedimint_offline: number;
/** Federations announced on Nostr that no check has ever confirmed. */
fedimint_announced: number;
/** Reviews of federations (`k` = 38173), included in `reviews_total`. */
fedimint_reviews: number;
lnurl_total: number;
/** LNURL mints a probe reached. Online includes the degraded-funding ones. */
lnurl_online: number;
lnurl_offline: number;
/**
* Online mints whose funding source was unreachable at the last probe: up and
* serving, but nothing moves in or out over Lightning. A subset of `lnurl_online`,
* never added to it.
*/
lnurl_degraded_funding: number;
/** Reviews of LNURL mints (`k` = 38174), included in `reviews_total`. */
lnurl_reviews: number;
}
/** `GET /api/health`. */
+372 -9
View File
@@ -8,6 +8,12 @@
* `/v1/info` (NUT-04 and NUT-05), plus `status` and `last_online`. The cache is the
* point: an offline mint still has its last known configuration, and the copy says
* "had" rather than "has" so nobody reads a stale flag as a live one.
*
* Two branches, chosen by `type` and never mixed. Cashu gets the six states below,
* every one of them read out of a `/v1/info` this site fetched itself. Fedimint gets
* two, because a federation publishes no capability list to read: a real check said the
* guardians are down, or nothing has ever confirmed the federation at all. There is no
* Fedimint equivalent of "melt only" and this file does not invent one.
*/
import type { MintInfo, MintStatus } from './types.js';
import { nutEntry } from './nuts.js';
@@ -73,12 +79,23 @@ function isLightningMethod(method: unknown): boolean {
export type MintWarningSeverity = 'critical' | 'warning';
export type MintWarningKind =
// Cashu
| 'gone' // offline 30 days or more, or never reached at all
| 'melt-disabled' // sats can get in but not out
| 'frozen' // neither in nor out
| 'offline-long' // offline 7 to 30 days
| 'melt-only' // minting off, melting still works
| 'offline'; // offline under 7 days
| 'offline' // offline under 7 days
// Fedimint
| 'fedimint-offline' // a real check reported the guardians down
| 'never-confirmed' // announced on Nostr, and nothing has ever confirmed it
// LNURL. The offline tiers above are shared verbatim rather than twinned: `gone`,
// `offline-long` and `offline` are about reachability, which means exactly the same
// thing for an LNURL mint as for a Cashu one, and their copy already reads correctly
// for both. Only the states with no Cashu equivalent are new.
| 'lnurl-withdrawals-disabled' // maxWithdrawable is zero: nothing can be redeemed
| 'lnurl-invalid' // the host answers, but not with a mint advertisement
| 'lnurl-no-funding'; // up and serving, but its Lightning node is unreachable
export interface MintWarning {
kind: MintWarningKind;
@@ -108,8 +125,18 @@ export interface MintWarning {
export type WarningStrings = (key: string, vars?: Record<string, string | number>) => string;
export interface MintWarningInput {
/**
* Which ecosystem this is. Absent reads as `cashu`, so every existing caller keeps
* exactly the behaviour it had; a `fedimint` row takes the separate branch below,
* which shares the severity machinery and none of the NUT reasoning.
*/
type?: string;
status: MintStatus | string;
last_online: number | null;
/** Fedimint: `created_at` of the newest announcement, for "announced {date}". */
announced_at?: number | null;
/** Fedimint: used only to decide whether reviews are the page's one sign of life. */
last_review_at?: number | null;
/** Cached `/v1/info`, as `GET /api/mints/:host` returns it. */
info?: MintInfo | null;
/**
@@ -119,6 +146,19 @@ export interface MintWarningInput {
capabilities?: Pick<MintCapabilities, 'mintDisabled' | 'meltDisabled'> | null;
/** Only used to date "never answered a single check". */
first_seen?: number;
/* ---- LNURL ---- */
/**
* The advertised withdraw ceiling, millisatoshi. Zero is the disabling value, and it
* is checked as `=== 0` rather than as falsy: `null` means nothing has probed, which
* is not the same claim at all.
*/
max_withdrawable_msat?: number | null;
/** false when the last probe found the mint's Lightning node unreachable. */
funding_available?: boolean | null;
/** Set when the host answered with something that is not a mint advertisement. */
invalid_reason?: string | null;
}
export interface MintWarningOptions {
@@ -183,9 +223,43 @@ export const WARNING_COPY_EN: Record<string, string> = {
'offline.body': 'Details were last confirmed then. You can still write a review.',
'offline.meta': 'Offline since {date}',
'fedimintOffline.lead': 'Reported offline.',
'fedimintOffline.body.since':
'The last check that reached this federation was on {date} ({days} ago). Its guardians have not answered since. Treat funds held there as at risk, and do not join it with new funds.',
'fedimintOffline.body.never':
'No check of this federation has ever reached it, and the most recent one failed. Treat funds held there as at risk, and do not join it with new funds.',
'fedimintOffline.meta.since': 'Reported offline since {date}',
'fedimintOffline.meta.never': 'Reported offline, never once reached',
'neverConfirmed.lead': 'Announced, never confirmed.',
'neverConfirmed.body.reviewed':
'This federation was announced on Nostr on {date}, {days} ago, and nothing has confirmed since then that it is running. The reviews below are the only sign of life on this page; everything else came from the announcement.',
'neverConfirmed.body.quiet':
'This federation was announced on Nostr on {date}, {days} ago, nothing has confirmed since then that it is running, and nobody has reviewed it recently. Everything below came from the announcement.',
'neverConfirmed.meta': 'Announced {date}, never confirmed',
'lnurlWithdrawalsDisabled.lead': 'Withdrawals disabled.',
'lnurlWithdrawalsDisabled.body.online':
'This mint advertises a maximum withdrawal of zero, so no note it issues can be redeemed for anything. Do not mint here until that changes.',
'lnurlWithdrawalsDisabled.body.offline':
'This mint advertised a maximum withdrawal of zero when it was last reached, so no note it issued could be redeemed for anything. Do not mint here until that changes.',
'lnurlWithdrawalsDisabled.meta.online': 'Withdrawals currently disabled',
'lnurlWithdrawalsDisabled.meta.offline': 'Withdrawals were disabled when last seen',
'lnurlInvalid.lead': 'Endpoint responding but invalid.',
'lnurlInvalid.body':
'The host answers, but not with a mint advertisement this site can read, so withdrawals may not work. Anything below is the last state that did parse, and reviews still work.',
'lnurlInvalid.meta': 'Responding, but not with a valid mint advertisement',
'lnurlNoFunding.lead': 'Minting and melting unavailable.',
'lnurlNoFunding.body':
'Existing notes can still be rotated, split, or merged, but nothing moves in or out via Lightning right now. The mint itself is up and answering; the Lightning node behind it is not reachable from it.',
'lnurlNoFunding.meta': 'Minting and melting unavailable',
'also.meltDisabled': 'Before going offline it had also disabled withdrawals.',
'also.mintDisabled': 'Before going offline it had also disabled new minting.',
'also.offline': 'It has also been offline since {date} ({days}).',
'also.noFunding': 'Its Lightning node was also unreachable, so nothing could be minted or melted.',
};
/**
@@ -212,8 +286,35 @@ const englishStrings: WarningStrings = (key, vars) => {
/** Severity order, highest first. Index 0 of the returned list is the one to show. */
const ORDER: MintWarningKind[] = [
'gone', 'melt-disabled', 'frozen', 'offline-long', 'melt-only', 'offline',
'gone',
'melt-disabled', 'frozen', 'lnurl-withdrawals-disabled',
'offline-long',
'melt-only', 'lnurl-invalid', 'lnurl-no-funding',
'offline',
// Appended rather than interleaved. The Fedimint kinds never share a list with the
// Cashu ones (the two branches are exclusive), so their position relative to those
// is arbitrary — and appending leaves every existing rank exactly where it was.
'fedimint-offline', 'never-confirmed',
];
/*
* The LNURL kinds *are* interleaved, unlike the Fedimint ones, and they have to be:
* that branch reuses `gone`, `offline-long` and `offline`, so its warnings genuinely
* share a list with those ranks and appending would put a critical
* "withdrawals disabled" below a mild "offline since yesterday". The relative order of
* everything that existed before is unchanged, which is what `check-warnings.ts`
* asserts.
*/
/**
* How stale an unconfirmed announcement has to be before it is worth saying so.
*
* A federation announced last week that nothing has checked is not news: nothing has
* had the chance. A month of silence is.
*/
export const NEVER_CONFIRMED_AFTER_S = 30 * 24 * 60 * 60;
/** Reviews inside this window count as the page having a pulse. Matches `reviews_90d`. */
const RECENT_REVIEW_S = 90 * 24 * 60 * 60;
const defaultDate = (unix: number): string =>
new Date(unix * 1000).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
@@ -232,6 +333,89 @@ const defaultMonth = (unix: number): string =>
export function getMintWarnings(
mint: MintWarningInput,
options: MintWarningOptions = {},
): MintWarning[] {
if (mint.type === 'fedimint') return fedimintWarnings(mint, options);
if (mint.type === 'lnurl') return lnurlWarnings(mint, options);
return cashuWarnings(mint, options);
}
/**
* What can be said about a federation, which is much less than about a mint.
*
* A federation publishes no capability list, so there is no NUT-04/NUT-05 equivalent to
* read and nothing here invents one: the melt-only, withdrawals-disabled and frozen
* banners have no Fedimint counterpart and never will until federations publish
* something a check can actually read. That leaves two honest things to say.
*
* The first is that a check reported the guardians down — a real result, from
* `status_source`, never inferred from the absence of an announcement. The second is
* the soft one: this federation has been sitting on Nostr for a month or more and
* nothing has ever confirmed it is running, so everything on its page came from a
* stranger's announcement rather than from the federation itself.
*
* Note what is deliberately missing: a federation that no check reached is `announced`,
* not `offline`, and gets the soft banner rather than the "likely gone" one. Not
* knowing is not the same as knowing it is dead.
*/
function fedimintWarnings(
mint: MintWarningInput,
options: MintWarningOptions = {},
): MintWarning[] {
const now = options.now ?? Math.floor(Date.now() / 1000);
const date = options.formatDate ?? defaultDate;
const s = options.strings ?? englishStrings;
const days = (since: number): string =>
s('dayCount', { n: Math.max(0, Math.floor((now - since) / 86400)) });
if (mint.status === 'offline') {
const since = mint.last_online;
return [
{
kind: 'fedimint-offline',
severity: 'critical',
lead: s('fedimintOffline.lead'),
body:
since !== null
? s('fedimintOffline.body.since', { date: date(since), days: days(since) })
: s('fedimintOffline.body.never'),
meta:
since !== null
? s('fedimintOffline.meta.since', { date: date(since) })
: s('fedimintOffline.meta.never'),
},
];
}
// Anything a check has confirmed up needs no banner, and a fresh announcement no
// check has got to yet has not earned one either.
if (mint.status !== 'announced') return [];
const announced = mint.announced_at ?? mint.first_seen ?? null;
if (announced === null || now - announced < NEVER_CONFIRMED_AFTER_S) return [];
const reviewed =
mint.last_review_at !== null &&
mint.last_review_at !== undefined &&
now - mint.last_review_at <= RECENT_REVIEW_S;
return [
{
kind: 'never-confirmed',
severity: 'warning',
lead: s('neverConfirmed.lead'),
body: s(`neverConfirmed.body.${reviewed ? 'reviewed' : 'quiet'}`, {
date: date(announced),
days: days(announced),
}),
meta: s('neverConfirmed.meta', { date: date(announced) }),
},
];
}
function cashuWarnings(
mint: MintWarningInput,
options: MintWarningOptions = {},
): MintWarning[] {
const now = options.now ?? Math.floor(Date.now() / 1000);
const date = options.formatDate ?? defaultDate;
@@ -241,13 +425,7 @@ export function getMintWarnings(
const caps = mint.capabilities ?? readCapabilities(mint.info?.nuts);
const { mintDisabled, meltDisabled } = caps;
const offline = mint.status === 'offline';
const since = mint.last_online;
const days = offline && since !== null ? Math.max(0, Math.floor((now - since) / 86400)) : null;
// Offline with no last_online means it has never once answered, which is at least
// as bad as a month of silence, so it lands in the top tier rather than the bottom.
const tier = !offline ? 0 : days === null ? 3 : days >= 30 ? 3 : days >= 7 ? 2 : 1;
const { offline, since, days, tier } = offlineTier(mint, now);
/*
* Cached flags describe the last configuration seen, not the current one, and the
@@ -336,6 +514,183 @@ export function getMintWarnings(
return out;
}
/**
* How long a listing has been unreachable, in the four bands the banners key on.
*
* Shared by the Cashu and LNURL branches, which apply exactly the same thresholds —
* being unreachable means the same thing whether the endpoint that stopped answering
* was `/v1/info` or a mint advertisement, and two copies of "is 7 days long?" is two
* places for it to become 8 in one of them.
*
* Tier 3 covers both a month of silence and never having answered at all: offline with
* no `last_online` means it has never once answered, which is at least as bad as a
* month of it, so it lands in the top tier rather than the bottom.
*/
function offlineTier(
mint: Pick<MintWarningInput, 'status' | 'last_online'>,
now: number,
): { offline: boolean; since: number | null; days: number | null; tier: 0 | 1 | 2 | 3 } {
const offline = mint.status === 'offline';
const since = mint.last_online;
const days = offline && since !== null ? Math.max(0, Math.floor((now - since) / 86400)) : null;
const tier = !offline ? 0 : days === null ? 3 : days >= 30 ? 3 : days >= 7 ? 2 : 1;
return { offline, since, days, tier };
}
/**
* What can be said about an LNURL mint.
*
* Between the two extremes of the other ecosystems. A federation publishes no switches
* at all, so its page can only talk about reachability; a Cashu mint publishes NUT-04
* and NUT-05 flags, so its page can be specific about which direction is broken. An
* LNURL mint sits in between: two things about it are genuinely checkable over HTTP,
* and both get a banner.
*
* - `maxWithdrawable` of zero. A mint advertising that no note can be redeemed for
* anything is the closest analogue this ecosystem has to "withdrawals disabled",
* and it is read from the advertisement rather than inferred, so it ranks with the
* Cashu capability banners.
* - A funding source the mint cannot reach. Distinctive to lnurlcash and worth its
* own sentence, because it is *partial*: the mint is up, its notes still rotate,
* split and merge, and only the two operations that need a Lightning node are
* unavailable. Calling that "offline" would be wrong in both directions.
*
* The third, `lnurl-invalid`, is about this site's own reading rather than the mint's
* configuration: these endpoints answer their errors with HTTP 200, so a host that
* responds with something unparseable is a state that has to be named rather than
* silently counted as either up or down.
*
* Note what is deliberately missing: nothing here reads `features`. That tag is the
* operator's claim about what they built, and a banner derived from a claim rather than
* from an observation would be the same invention the Fedimint branch refuses to make.
*/
function lnurlWarnings(
mint: MintWarningInput,
options: MintWarningOptions = {},
): MintWarning[] {
const now = options.now ?? Math.floor(Date.now() / 1000);
const date = options.formatDate ?? defaultDate;
const month = options.formatMonth ?? defaultMonth;
const s = options.strings ?? englishStrings;
const { offline, since, days, tier } = offlineTier(mint, now);
const tense = offline ? 'offline' : 'online';
const dayCount = s('dayCount', { n: days ?? 0 });
// `=== 0`, never falsy: null means nothing has probed this mint yet, and "we have not
// looked" must not render as "withdrawals are disabled".
const withdrawalsDisabled = mint.max_withdrawable_msat === 0;
const invalid = Boolean(mint.invalid_reason);
const noFunding = mint.funding_available === false;
const out: MintWarning[] = [];
// Pushed in ORDER, so the first one added is the one that wins.
if (tier === 3) {
out.push({
kind: 'gone',
severity: 'critical',
lead: s('gone.lead'),
body:
since !== null
? s('gone.body.since', { date: date(since), days: dayCount })
: mint.first_seen
? s('gone.body.neverDated', { month: month(mint.first_seen) })
: s('gone.body.never'),
meta: since !== null ? s('gone.meta.since', { date: date(since) }) : s('gone.meta.never'),
});
}
if (withdrawalsDisabled) {
out.push({
kind: 'lnurl-withdrawals-disabled',
severity: 'critical',
lead: s('lnurlWithdrawalsDisabled.lead'),
body: s(`lnurlWithdrawalsDisabled.body.${tense}`),
meta: s(`lnurlWithdrawalsDisabled.meta.${tense}`),
});
}
if (tier === 2 && since !== null) {
out.push({
kind: 'offline-long',
severity: 'critical',
lead: s('offlineLong.lead', { days: dayCount, date: date(since) }),
body: s('offlineLong.body'),
meta: s('offlineLong.meta', { date: date(since) }),
});
}
if (invalid) {
out.push({
kind: 'lnurl-invalid',
severity: 'warning',
lead: s('lnurlInvalid.lead'),
body: s('lnurlInvalid.body'),
meta: s('lnurlInvalid.meta'),
});
}
if (noFunding) {
out.push({
kind: 'lnurl-no-funding',
severity: 'warning',
lead: s('lnurlNoFunding.lead'),
body: s('lnurlNoFunding.body'),
meta: s('lnurlNoFunding.meta'),
});
}
if (tier === 1 && since !== null) {
out.push({
kind: 'offline',
severity: 'warning',
lead: s('offline.lead', { date: date(since) }),
body: s('offline.body'),
meta: s('offline.meta', { date: date(since) }),
});
}
const primary = out[0];
if (primary) {
const extra = lnurlCollapsed(primary.kind, { noFunding, offline, since, dayCount, date, s });
if (extra) primary.body += ` ${extra}`;
}
return out;
}
/**
* The one sentence a losing LNURL condition earns inside the winner's text.
*
* Same contract as `collapsed`, and separate from it because the conditions being
* folded in are different ones: there is no NUT-04 or NUT-05 here, and the fact worth
* rescuing from an offline banner is that the mint's Lightning node was down too.
*/
function lnurlCollapsed(
kind: MintWarningKind,
ctx: {
noFunding: boolean;
offline: boolean;
since: number | null;
dayCount: string;
date: (unix: number) => string;
s: WarningStrings;
},
): string | null {
if (kind === 'gone' || kind === 'offline-long' || kind === 'offline') {
return ctx.noFunding ? ctx.s('also.noFunding') : null;
}
// A capability banner outranked an offline one: say the mint is also unreachable,
// otherwise the page reads as if it were up and merely misconfigured.
if (kind === 'lnurl-withdrawals-disabled' && ctx.offline && ctx.since !== null) {
return ctx.s('also.offline', { date: ctx.date(ctx.since), days: ctx.dayCount });
}
return null;
}
/**
* The one sentence a losing condition earns inside the winner's text. Banners never
* stack, but a mint that is both gone and had stopped paying out is a worse story
@@ -401,6 +756,8 @@ export const CHIP_COPY_EN: Record<string, string> = {
frozen: 'Frozen',
noWithdrawals: 'No withdrawals',
meltOnly: 'Melt only',
/** LNURL: up and serving, but nothing moves in or out over Lightning. */
noFunding: 'No mint / melt',
};
export function mintChip(warnings: MintWarning[], strings?: WarningStrings): MintChip | null {
@@ -409,7 +766,13 @@ export function mintChip(warnings: MintWarning[], strings?: WarningStrings): Min
if (kinds.has('frozen')) return { label: s('frozen'), severity: 'critical' };
// "Melt only" is the other direction, so it cannot double as the label here.
if (kinds.has('melt-disabled')) return { label: s('noWithdrawals'), severity: 'critical' };
// An LNURL mint advertising a zero withdraw ceiling is the same statement to a
// reader as a Cashu mint with melting off, so it earns the same two words.
if (kinds.has('lnurl-withdrawals-disabled')) {
return { label: s('noWithdrawals'), severity: 'critical' };
}
if (kinds.has('melt-only')) return { label: s('meltOnly'), severity: 'warning' };
if (kinds.has('lnurl-no-funding')) return { label: s('noFunding'), severity: 'warning' };
return null;
}
+8
View File
@@ -28,6 +28,12 @@ const apiUrl = process.env.API_URL ?? 'http://127.0.0.1:8787';
// https://api.cashumints.space.
const publicApiUrl = process.env.PUBLIC_API_URL ?? '';
// Plausible-compatible analytics endpoint and site identifier. Empty values disable the
// script, which is useful for local builds that should not report page views.
const plausibleUrl =
process.env.PLAUSIBLE_URL ?? 'https://analytics.azzamo.net/js/script.js';
const plausibleDomain = process.env.PLAUSIBLE_DOMAIN ?? 'cashumints.space';
// Dev server and preview port. Set WEB_PORT in .env; a --port flag still overrides it.
const port = Number.parseInt(process.env.WEB_PORT ?? '', 10) || 4321;
@@ -73,6 +79,8 @@ export default defineConfig({
vite: {
define: {
'import.meta.env.PUBLIC_API_URL': JSON.stringify(publicApiUrl),
'import.meta.env.PLAUSIBLE_URL': JSON.stringify(plausibleUrl),
'import.meta.env.PLAUSIBLE_DOMAIN': JSON.stringify(plausibleDomain),
},
server: { proxy },
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+7 -1
View File
@@ -5,12 +5,16 @@
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"og": "node scripts/og/build-og.mjs",
"og:fixtures": "node scripts/og/build-og.mjs --fixtures",
"build": "node scripts/og/build-og.mjs && astro build",
"start": "node server.mjs",
"preview": "astro preview",
"typecheck": "astro check",
"bones": "node --env-file-if-exists=../.env scripts/bones.mjs",
"check:links": "node scripts/check-links.mjs dist",
"check:i18n": "node scripts/check-i18n.mjs",
"test": "node --test test/*.test.mjs",
"check:hreflang": "node scripts/check-hreflang.mjs dist"
},
"dependencies": {
@@ -21,7 +25,9 @@
},
"devDependencies": {
"@astrojs/check": "^0.9.4",
"@resvg/resvg-js": "^2.6.2",
"boneyard-js": "^1.9.0",
"satori": "^0.33.0",
"typescript": "^5.6.3"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 888 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 910 B

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 559 B

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 10 KiB

+6
View File
@@ -238,6 +238,12 @@ const DYNAMIC_PREFIXES = [
'login.connect.', // CONNECT_COPY, keyed by method
'time.ago.', // formatRelative, keyed by unit
'time.short.', // formatShortDuration, keyed by unit
'fedimint.module.', // localModuleName(module)
'fedimint.network.', // localNetworkName(network)
'feed.ecosystem.', // the /reviews filter, one option per ECOSYSTEMS entry
'reviews.dialog.bodyPlaceholder.', // one prompt per ecosystem, keyed by subject.type
'reviews.dialog.lede.', // the dialog's invitation, one per ecosystem
'reviews.rate.', // the reviews panel's inline "Rate this mint" row
];
const isDynamic = (key) => DYNAMIC_PREFIXES.some((prefix) => key.startsWith(prefix));
+57 -42
View File
@@ -6,6 +6,7 @@
* files change roughly never.
*
* node scripts/make-assets.mjs
* node scripts/make-assets.mjs --no-card # icon set only, needs no browser
*
* Playwright and sharp are not dependencies of this workspace. Both arrive transitively
* under `astro` (which ships sharp for its image pipeline) and are resolved through it,
@@ -14,10 +15,12 @@
*
* The card is drawn in a real browser rather than assembled by hand because it uses the
* site's own fonts and tokens: a social card that does not look like the site is worth
* less than no card. The moai is the same rect geometry as `components/Moai.astro`.
* less than no card. The moai on the card is the same rect geometry as
* `components/Moai.astro`. The icon set is a different mark: the pixel cashu nut carried
* over from the old site (`src/assets/cashu-logo.png`), composed with sharp alone.
*/
import { createRequire } from 'node:module';
import { mkdir, writeFile } from 'node:fs/promises';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -100,14 +103,8 @@ p { margin-top: 28px; font-size: 27px; line-height: 1.45; color: #9A927E; max-wi
<div class="host">cashumints.space</div>
</body></html>`;
/* The icon master: the moai on the site's background, generous padding. */
const iconHtml = `<!doctype html><html><head><meta charset="utf-8"><style>
* { margin: 0; padding: 0; }
body {
width: 512px; height: 512px; background: #0E0D0B;
display: flex; align-items: center; justify-content: center;
}
</style></head><body>${moaiSvg(320)}</body></html>`;
/* The icon source: the old site's pixel cashu nut, 1000x1000 with transparent margins. */
const logoPath = path.join(root, 'src', 'assets', 'cashu-logo.png');
/**
* A 32x32 ICO wrapping a PNG payload.
@@ -138,8 +135,54 @@ function icoFromPng(png) {
await mkdir(publicDir, { recursive: true });
const browser = await chromium.launch();
try {
/*
* The icon master: the nut trimmed and centred on the brand ground, at the footprint the
* moai used to occupy (glyph ≈ 380px tall in the 512 tile). Content this size also stays
* inside the circle a maskable icon is cropped to.
*/
const glyph = await sharp(logoPath).trim().resize(384, 384, { fit: 'inside' }).png().toBuffer();
const master = await sharp({
create: { width: 512, height: 512, channels: 4, background: '#0E0D0B' },
})
.composite([{ input: glyph, gravity: 'centre' }])
.png()
.toBuffer();
for (const [name, size] of [
['icon-512.png', 512],
['icon-192.png', 192],
['favicon-32.png', 32],
]) {
await sharp(master).resize(size, size).png({ compressionLevel: 9 }).toFile(path.join(publicDir, name));
console.log(name);
}
// iOS ignores transparency and composites on white; the master's ground is already opaque.
await sharp(master)
.resize(180, 180)
.png({ compressionLevel: 9 })
.toFile(path.join(publicDir, 'apple-touch-icon.png'));
console.log('apple-touch-icon.png');
const png32 = await sharp(master).resize(32, 32).png({ compressionLevel: 9 }).toBuffer();
await writeFile(path.join(publicDir, 'favicon.ico'), icoFromPng(png32));
console.log('favicon.ico');
// The standalone SVG favicon: the nut itself, transparent like the old site served it.
// The PNG rides inside the SVG so one source file covers every size; `pixelated` keeps
// the pixel art crisp if anything scales it up.
const logoB64 = (await readFile(logoPath)).toString('base64');
await writeFile(
path.join(publicDir, 'favicon.svg'),
`<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1000" viewBox="0 0 1000 1000">` +
`<image width="1000" height="1000" style="image-rendering:pixelated" ` +
`href="data:image/png;base64,${logoB64}"/></svg>`,
);
console.log('favicon.svg');
if (!process.argv.includes('--no-card')) {
const browser = await chromium.launch();
try {
const page = await browser.newPage({
viewport: { width: 1200, height: 630 },
deviceScaleFactor: 1,
@@ -149,35 +192,7 @@ try {
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: path.join(publicDir, 'og.png') });
console.log('og.png');
await page.setViewportSize({ width: 512, height: 512 });
await page.setContent(iconHtml, { waitUntil: 'load' });
const master = await page.screenshot({ type: 'png' });
for (const [name, size] of [
['icon-512.png', 512],
['icon-192.png', 192],
['favicon-32.png', 32],
]) {
await sharp(master).resize(size, size).png({ compressionLevel: 9 }).toFile(path.join(publicDir, name));
console.log(name);
}
// iOS ignores transparency and composites on white, so flatten onto the brand ground.
await sharp(master)
.resize(180, 180)
.flatten({ background: '#0E0D0B' })
.png({ compressionLevel: 9 })
.toFile(path.join(publicDir, 'apple-touch-icon.png'));
console.log('apple-touch-icon.png');
const png32 = await sharp(master).resize(32, 32).png({ compressionLevel: 9 }).toBuffer();
await writeFile(path.join(publicDir, 'favicon.ico'), icoFromPng(png32));
console.log('favicon.ico');
// The standalone SVG favicon: the same mark, at any size, for browsers that take it.
await writeFile(path.join(publicDir, 'favicon.svg'), moaiSvg(11));
console.log('favicon.svg');
} finally {
} finally {
await browser.close();
}
}
+223
View File
@@ -0,0 +1,223 @@
/**
* Social image build step. `pnpm og`, and the first half of `pnpm build`.
*
* Renders one 1200x630 PNG per mint and federation into `public/og/`, plus the
* default card every non-mint page shares, from the same API the pages are built
* from. satori turns the card template into SVG (text becomes glyph paths, so the
* rasteriser needs no fonts), resvg turns the SVG into a PNG.
*
* Filenames carry a content hash — `mint.example.com.a1b2c3d4e5.png` — and the pages
* read the mapping from `src/generated/og-manifest.json`, which this script writes.
* The hash is over the card model (see model.mjs), so:
*
* - a rebuild with unchanged data renders nothing (the manifest says every hash is
* already on disk);
* - one mint's rating moving regenerates exactly that mint's image, at a new URL,
* which is what actually busts scraper caches — Telegram and friends cache an
* og:image URL for days and ignore Cache-Control on it;
* - the previous file is deleted, so `public/og/` never accumulates history.
*
* `--fixtures` renders the six edge-case cards from fixtures.mjs into `og-fixtures/`
* at a pinned timestamp instead: byte-stable snapshots for the repo, to eyeball after
* template changes.
*/
import '../load-env.mjs';
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Resvg } from '@resvg/resvg-js';
import { renderSvg } from './card.mjs';
import { cashuModel, defaultModel, fedimintModel, lnurlModel } from './model.mjs';
import { FIXTURES, NOW as FIXTURE_NOW } from './fixtures.mjs';
const here = path.dirname(fileURLToPath(import.meta.url));
const webRoot = path.resolve(here, '../..');
const outDir = path.join(webRoot, 'public/og');
const manifestFile = path.join(webRoot, 'src/generated/og-manifest.json');
const API_URL = process.env.API_URL ?? 'http://127.0.0.1:8787';
async function getJson(pathname) {
const res = await fetch(`${API_URL}${pathname}`, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`${pathname} responded ${res.status}`);
return res.json();
}
/** Ten hex characters of the model hash: the cache-busting part of the filename. */
const hashModel = (model) =>
createHash('sha256').update(JSON.stringify(model)).digest('hex').slice(0, 10);
/**
* The cached icon as a data URI resvg can decode, plus a digest for the model hash.
*
* Only PNG and JPEG go in — resvg decodes nothing else, and an image tag it cannot
* decode becomes a blank square, which is worse than the gradient tile. The one webp
* icon in the index today falls back to the tile; logged so it is a known trade, not
* a silent one.
*/
async function fetchIcon(icon, host) {
if (!icon) return null;
try {
const res = await fetch(`${API_URL}${icon}`);
if (!res.ok) throw new Error(`responded ${res.status}`);
const bytes = Buffer.from(await res.arrayBuffer());
const isPng = bytes.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47]));
const isJpeg = bytes[0] === 0xff && bytes[1] === 0xd8;
if (!isPng && !isJpeg) {
console.warn(`og: ${host}: icon is not PNG/JPEG, using the gradient tile`);
return null;
}
return {
data: `data:image/${isPng ? 'png' : 'jpeg'};base64,${bytes.toString('base64')}`,
hash: createHash('sha1').update(bytes).digest('hex').slice(0, 10),
};
} catch (error) {
console.warn(`og: ${host}: icon fetch failed (${error.message}), using the gradient tile`);
return null;
}
}
async function renderPng(model, iconData) {
const svg = await renderSvg(model, iconData);
const resvg = new Resvg(svg, { font: { loadSystemFonts: false } });
return resvg.render().asPng();
}
/* ---------- fixtures mode ---------- */
async function buildFixtures() {
const dir = path.join(webRoot, 'og-fixtures');
await mkdir(dir, { recursive: true });
for (const raw of FIXTURES) {
const model =
raw.type === 'fedimint'
? fedimintModel(raw, { now: FIXTURE_NOW })
: raw.type === 'lnurl'
? lnurlModel(raw, { now: FIXTURE_NOW })
: cashuModel(raw, { now: FIXTURE_NOW });
const png = await renderPng(model, null);
const file = path.join(dir, `${raw.host}.png`);
await writeFile(file, png);
console.log(`og: ${path.relative(webRoot, file)} (${(png.length / 1024).toFixed(0)}KB)`);
}
}
/* ---------- the real build ---------- */
async function build() {
const started = Date.now();
await mkdir(outDir, { recursive: true });
await mkdir(path.dirname(manifestFile), { recursive: true });
const previous = JSON.parse(await readFile(manifestFile, 'utf8').catch(() => '{}'));
const previousImages = previous.images ?? {};
const [cashu, fedimint, lnurl, stats] = await Promise.all([
getJson('/api/mints?type=cashu'),
getJson('/api/mints?type=fedimint'),
getJson('/api/mints?type=lnurl'),
getJson('/api/stats'),
]);
/*
* One clock for the whole run, so two rebuilds inside the same day produce the same
* day counts and therefore the same hashes. Day granularity is deliberate: an
* offline mint's "Offline 12d" chip regenerates once a day, not once a build.
*/
const now = Math.floor(Date.now() / 1000 / 86400) * 86400;
const onDisk = new Set(await readdir(outDir).catch(() => []));
const images = {};
let rendered = 0;
let skipped = 0;
const listings = [
...cashu.map((m) => ({ listing: m, kind: 'cashu' })),
...fedimint.map((m) => ({ listing: m, kind: 'fedimint' })),
...lnurl.map((m) => ({ listing: m, kind: 'lnurl' })),
];
// A handful at a time: satori renders in milliseconds, the icon fetches are local,
// and bounded concurrency keeps 60 base64 icons from piling up in memory at once.
const queue = [...listings];
const workers = Array.from({ length: 8 }, async () => {
for (;;) {
const next = queue.shift();
if (!next) return;
const { listing, kind } = next;
let detail;
try {
detail = await getJson(`/api/mints/${encodeURIComponent(listing.host)}`);
} catch (error) {
console.warn(`og: ${listing.host}: detail fetch failed (${error.message}), skipping`);
continue;
}
const icon = await fetchIcon(detail.icon, detail.host);
const model =
kind === 'fedimint'
? fedimintModel(detail, { iconHash: icon?.hash ?? null, now })
: kind === 'lnurl'
? lnurlModel(detail, { iconHash: icon?.hash ?? null, now })
: cashuModel(detail, { iconHash: icon?.hash ?? null, now });
const hash = hashModel(model);
const file = `${model.slug}.${hash}.png`;
images[model.slug] = { file, hash };
if (previousImages[model.slug]?.hash === hash && onDisk.has(file)) {
skipped++;
continue;
}
const png = await renderPng(model, icon?.data ?? null);
if (png.length > 300 * 1024) {
console.warn(`og: ${file} is ${(png.length / 1024).toFixed(0)}KB, over the 300KB budget`);
}
await writeFile(path.join(outDir, file), png);
rendered++;
}
});
await Promise.all(workers);
/*
* The default card. Stable filename on purpose — it is referenced by every non-mint
* page and by mints that failed above, and it changes with the site's own stats,
* which is what `Cache-Control: max-age=3600` is for (see README).
*/
const fallback = defaultModel(stats);
const fallbackHash = hashModel(fallback);
if (previous.default?.hash !== fallbackHash || !onDisk.has('default.png')) {
await writeFile(path.join(outDir, 'default.png'), await renderPng(fallback, null));
rendered++;
} else {
skipped++;
}
/* Sweep files no current listing produced, so removed mints do not linger. */
const keep = new Set([...Object.values(images).map((i) => i.file), 'default.png']);
for (const file of await readdir(outDir)) {
if (!keep.has(file)) await rm(path.join(outDir, file));
}
await writeFile(
manifestFile,
JSON.stringify({ default: { file: 'default.png', hash: fallbackHash }, images }, null, 2) + '\n',
);
const seconds = ((Date.now() - started) / 1000).toFixed(1);
console.log(
`og: ${rendered} rendered, ${skipped} unchanged, ${Object.keys(images).length} mints+federations, ${seconds}s`,
);
}
if (process.argv.includes('--fixtures')) {
await buildFixtures();
} else {
await build();
}
+712
View File
@@ -0,0 +1,712 @@
/**
* The satori templates: card model in, 1200x630 SVG out (build-og.mjs rasterises it).
*
* Design tokens are the site's own (src/styles/global.css): same background, same
* card surface, same amber/violet/mint/red, Space Grotesk over Inter over JetBrains
* Mono. The card should read as a cropped screenshot of the site, not a poster.
*
* Everything rendered here comes off the model object and nothing else — the model is
* the manifest hash input, so a value the template invented for itself would be a way
* for an image to change without its hash noticing.
*/
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import satori from 'satori';
const here = path.dirname(fileURLToPath(import.meta.url));
/* ---- palette (global.css :root) ---- */
const BG = '#0E0D0B';
const CARD = '#161512';
const LINE = '#2A2720';
const LINE_SOFT = '#211F19';
const TEXT = '#EDE8DC';
const MUTED = '#9A927E';
const FAINT = '#6B6555';
const AMBER = '#F0A93B';
const VIOLET = '#A78BFA';
const MINT = '#5FD68F';
const RED = '#E06450';
/** The LNURL ecosystem label. The only cool blue here — see DESIGN.md. */
const LNURL = '#4EA8DE';
const GROTESK = 'Space Grotesk';
const INTER = 'Inter';
const MONO = 'JetBrains Mono';
/**
* Shorthand: satori consumes plain React-shaped objects. `display: flex` is the
* default because satori requires it on any div with an element child.
*/
const h = (type, style = {}, ...children) => ({
type,
props: {
style: { display: 'flex', ...style },
children: children.flat().filter((c) => c !== null && c !== false),
},
});
/** The pixel moai from Moai.astro, as a data URI so satori can place it as an image. */
function moai(width) {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 11 13" shape-rendering="crispEdges">' +
'<rect x="2" y="0" width="7" height="1" fill="#C9BFA4"/>' +
'<rect x="1" y="1" width="9" height="2" fill="#C9BFA4"/>' +
'<rect x="1" y="3" width="2" height="7" fill="#A89B7C"/>' +
'<rect x="3" y="3" width="6" height="7" fill="#C9BFA4"/>' +
'<rect x="3" y="4" width="2" height="1" fill="#5C5340"/>' +
'<rect x="7" y="4" width="2" height="1" fill="#5C5340"/>' +
'<rect x="5" y="5" width="1" height="3" fill="#A89B7C"/>' +
'<rect x="2" y="10" width="8" height="2" fill="#A89B7C"/></svg>';
return {
type: 'img',
props: {
src: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`,
width,
height: Math.round((width / 11) * 13),
style: {},
},
};
}
const STAR_PATH =
'M12 2l2.9 6.22 6.82.63-5.14 4.52 1.51 6.68L12 16.55l-6.09 3.5 1.51-6.68L2.28 8.85l6.82-.63L12 2z';
/** Five stars, filled to Math.round(rating) — the same rounding starString() uses. */
function stars(rating, size, color) {
const filled = Math.round(rating);
return h(
'div',
{ display: 'flex', gap: `${Math.round(size * 0.18)}px` },
...[1, 2, 3, 4, 5].map((n) => ({
type: 'svg',
props: {
width: size,
height: size,
viewBox: '0 0 24 24',
style: { display: 'flex' },
children: [
{
type: 'path',
props: {
d: STAR_PATH,
fill: n <= filled ? color : 'none',
stroke: n <= filled ? 'none' : FAINT,
'stroke-width': 1.6,
},
},
],
},
})),
);
}
/** The status chip beside the name. Green only for a status a check actually verified. */
function statusChip(model) {
const style = (color, bg, border) => ({
display: 'flex',
alignItems: 'center',
gap: '9px',
padding: '8px 17px',
borderRadius: '999px',
border: `1.5px solid ${border}`,
backgroundColor: bg,
color,
fontFamily: INTER,
fontWeight: 600,
fontSize: '20px',
whiteSpace: 'nowrap',
});
const dot = (color) =>
h('div', { width: '9px', height: '9px', borderRadius: '999px', backgroundColor: color });
switch (model.status) {
case 'online':
return h('div', style(MINT, 'rgba(95,214,143,.08)', 'rgba(95,214,143,.35)'), dot(MINT), 'Online');
case 'offline': {
const label = model.offlineDays !== null ? `Offline ${model.offlineDays}d` : 'Offline';
return h('div', style(RED, 'rgba(224,100,80,.09)', 'rgba(224,100,80,.4)'), dot(RED), label);
}
case 'announced':
// The honest-status rule: an announcement is not a heartbeat, so never green.
return h('div', style(AMBER, 'rgba(240,169,59,.07)', 'rgba(240,169,59,.32)'), dot(AMBER), 'Announced');
case 'degraded':
return h('div', style(MUTED, 'rgba(154,146,126,.07)', 'rgba(154,146,126,.35)'), dot(MUTED), 'Unreachable');
default:
return h('div', style(MUTED, 'rgba(154,146,126,.07)', 'rgba(154,146,126,.35)'), dot(MUTED), 'Not checked yet');
}
}
/**
* The type badge that makes a non-Cashu card unmistakable at thumbnail size.
*
* Three things separate the three ecosystems at 300px wide, and the badge is only one
* of them: the word itself, the badge's colour, and the glow behind the whole card all
* change together. Cashu gets no badge at all, which is its own signal — it is the
* default and the majority of the index.
*/
function typeBadge(label, color, borderColor) {
return h(
'div',
{
display: 'flex',
padding: '9px 14px',
borderRadius: '9px',
border: `1.5px solid ${borderColor}`,
color,
fontFamily: MONO,
fontWeight: 500,
fontSize: '18px',
letterSpacing: '3px',
},
label,
);
}
/** The icon tile: cached icon when the indexer has one, the site's gradient tile when not. */
function iconTile(model, iconData) {
const size = 116;
const shared = {
width: `${size}px`,
height: `${size}px`,
borderRadius: '28px',
border: '1.5px solid rgba(255,255,255,.14)',
flexShrink: 0,
};
if (iconData) {
return {
type: 'img',
props: { src: iconData, width: size, height: size, style: { ...shared, objectFit: 'cover' } },
};
}
const [c1, c2] = model.gradient;
return h(
'div',
{
...shared,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundImage: `linear-gradient(135deg, ${c1}, ${c2})`,
color: BG,
fontFamily: GROTESK,
fontWeight: 700,
fontSize: '52px',
},
model.initials,
);
}
/**
* Fit the name on one line by stepping the size down; extreme names get two lines,
* and satori's lineClamp guarantees nothing ever leaves the box regardless.
*/
function nameSize(name, maxWidth) {
for (const size of [58, 50, 42]) {
if (name.length * size * 0.58 <= maxWidth) return { size, clamp: 1 };
}
return { size: 38, clamp: 2 };
}
/** Middle-out truncation for a domain that will not fit the identity row. */
function fitMono(value, maxChars) {
if (value.length <= maxChars) return value;
const head = Math.ceil((maxChars - 1) * 0.62);
const tail = maxChars - 1 - head;
return `${value.slice(0, head)}…${value.slice(-tail)}`;
}
function cellLabel(text, color = FAINT) {
return h(
'div',
{
fontFamily: INTER,
fontWeight: 600,
fontSize: '15px',
letterSpacing: '2px',
color,
marginBottom: '16px',
},
text,
);
}
/** Rating + stars + sentiment split, or the honest "no reviews yet". */
function ratingCell(model) {
const children = [cellLabel('COMMUNITY RATING')];
if (model.reviewCount === 0 || model.rating === null) {
children.push(
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '30px', color: MUTED, marginTop: '14px' }, 'No reviews yet'),
);
} else {
const starColor = model.rating < 3 ? RED : AMBER;
children.push(
h(
'div',
{ display: 'flex', alignItems: 'flex-end', gap: '18px' },
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '58px', lineHeight: 1, color: TEXT }, model.rating.toFixed(1)),
h('div', { display: 'flex', marginBottom: '7px' }, stars(model.rating, 27, starColor)),
),
);
if (model.sentiment) {
const { pos, neg } = model.sentiment;
const neutral = Math.max(0, 100 - pos - neg);
children.push(
h(
'div',
{ display: 'flex', width: '250px', height: '8px', borderRadius: '4px', marginTop: '22px', overflow: 'hidden' },
pos > 0 && h('div', { width: `${pos}%`, backgroundColor: MINT }),
neutral > 0 && h('div', { width: `${neutral}%`, backgroundColor: LINE }),
neg > 0 && h('div', { width: `${neg}%`, backgroundColor: RED }),
),
);
}
}
return h('div', { display: 'flex', flexDirection: 'column', flex: 1.25, padding: '30px 36px' }, ...children);
}
function reviewsCell(model) {
return h(
'div',
{ display: 'flex', flexDirection: 'column', flex: 0.9, padding: '30px 36px', borderLeft: `1px solid ${LINE_SOFT}` },
cellLabel('REVIEWS'),
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '58px', lineHeight: 1, color: TEXT }, String(model.reviewCount)),
// Absolute month/year on purpose: "3d ago" goes stale inside a static PNG.
model.lastReviewMonth !== null &&
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '19px', color: MUTED, marginTop: '14px' }, `Last review ${model.lastReviewMonth}`),
);
}
/** Third cell, Cashu: published limits in mono, or the warning that replaces them. */
function thirdCellCashu(model) {
const base = {
display: 'flex',
flexDirection: 'column',
flex: 1.1,
padding: '30px 36px',
borderLeft: `1px solid ${LINE_SOFT}`,
};
if (model.warning) {
const color = model.warning.severity === 'critical' ? RED : AMBER;
return h(
'div',
{ ...base, backgroundColor: model.warning.severity === 'critical' ? 'rgba(224,100,80,.06)' : 'rgba(240,169,59,.05)' },
cellLabel('WARNING', color),
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '38px', lineHeight: 1.05, color }, model.warning.label),
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '19px', color: MUTED, marginTop: '14px' }, model.warning.sub),
);
}
const lines = [];
if (model.limits) {
const { min, max, unit } = model.limits;
if (min !== null) lines.push(['min', `${compact(min)} ${unit}`]);
if (max !== null) lines.push(['max', `${compact(max)} ${unit}`]);
}
return h(
'div',
base,
cellLabel('LIMITS'),
lines.length === 0
? h('div', { fontFamily: MONO, fontWeight: 400, fontSize: '24px', color: MUTED, marginTop: '8px' }, 'Not published')
: h(
'div',
{ display: 'flex', flexDirection: 'column', gap: '10px', marginTop: '4px' },
...lines.map(([k, v]) =>
h(
'div',
{ display: 'flex', alignItems: 'baseline', gap: '14px' },
h('div', { fontFamily: MONO, fontWeight: 400, fontSize: '20px', color: FAINT }, k),
h('div', { fontFamily: MONO, fontWeight: 500, fontSize: '27px', color: TEXT }, v),
),
),
),
);
}
function compact(n) {
if (n >= 1_000_000) return trimNum(n / 1_000_000) + 'M';
if (n >= 1_000) return trimNum(n / 1_000) + 'k';
return String(n);
}
function trimNum(n) {
const s = n.toFixed(1);
return s.endsWith('.0') ? s.slice(0, -2) : s;
}
/** Third cell, Fedimint: modules and network — never limits, never a software version. */
function thirdCellFedimint(model) {
const base = {
display: 'flex',
flexDirection: 'column',
flex: 1.1,
padding: '30px 36px',
borderLeft: `1px solid ${LINE_SOFT}`,
};
if (model.warning) {
return h(
'div',
{ ...base, backgroundColor: 'rgba(224,100,80,.06)' },
cellLabel('WARNING', RED),
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '36px', lineHeight: 1.05, color: RED }, model.warning.label),
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '19px', color: MUTED, marginTop: '14px' }, model.warning.sub),
);
}
const list = model.modules.length > 0 ? fitMono(model.modules.join(', '), 40) : 'Not announced';
const mainnet = model.network === 'mainnet';
return h(
'div',
base,
cellLabel('MODULES'),
h('div', { fontFamily: MONO, fontWeight: 500, fontSize: '24px', lineHeight: 1.4, color: model.modules.length > 0 ? TEXT : MUTED, maxWidth: '300px' }, list),
model.network &&
h(
'div',
{ display: 'flex', marginTop: '16px' },
h(
'div',
{
display: 'flex',
padding: '6px 13px',
borderRadius: '8px',
border: `1.5px solid ${mainnet ? 'rgba(95,214,143,.4)' : 'rgba(240,169,59,.4)'}`,
color: mainnet ? MINT : AMBER,
fontFamily: MONO,
fontWeight: 500,
fontSize: '17px',
letterSpacing: '1px',
},
model.network,
),
),
);
}
/**
* Third cell, LNURL: the withdraw range — never NUT limits, never a module list.
*
* This is the number that answers an LNURL reader's actual question: how much can one
* of this mint's notes be worth? It is shown in sats, converted from millisatoshi in
* the model, and it is a *range* rather than a pair of independent bounds, which is
* why the two rows read "min"/"max" the way the Cashu limits cell does.
*
* The lightning address takes the space the Fedimint cell gives its network badge: it
* is the one string on the whole card someone might copy out of a screenshot.
*/
function thirdCellLnurl(model) {
const base = {
display: 'flex',
flexDirection: 'column',
flex: 1.1,
padding: '30px 36px',
borderLeft: `1px solid ${LINE_SOFT}`,
};
if (model.warning) {
const color = model.warning.severity === 'critical' ? RED : AMBER;
return h(
'div',
{ ...base, backgroundColor: model.warning.severity === 'critical' ? 'rgba(224,100,80,.06)' : 'rgba(240,169,59,.05)' },
cellLabel('WARNING', color),
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '34px', lineHeight: 1.05, color }, model.warning.label),
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '19px', color: MUTED, marginTop: '14px' }, model.warning.sub),
);
}
const lines = [];
if (model.withdraw) {
if (model.withdraw.min !== null) lines.push(['min', `${compact(model.withdraw.min)} sat`]);
if (model.withdraw.max !== null) lines.push(['max', `${compact(model.withdraw.max)} sat`]);
}
return h(
'div',
base,
cellLabel('WITHDRAW'),
lines.length === 0
? h('div', { fontFamily: MONO, fontWeight: 400, fontSize: '24px', color: MUTED, marginTop: '8px' }, 'Not published')
: h(
'div',
{ display: 'flex', flexDirection: 'column', gap: '10px', marginTop: '4px' },
...lines.map(([k, v]) =>
h(
'div',
{ display: 'flex', alignItems: 'baseline', gap: '14px' },
h('div', { fontFamily: MONO, fontWeight: 400, fontSize: '20px', color: FAINT }, k),
h('div', { fontFamily: MONO, fontWeight: 500, fontSize: '27px', color: TEXT }, v),
),
),
),
model.address &&
h(
'div',
{ fontFamily: MONO, fontWeight: 400, fontSize: '18px', color: FAINT, marginTop: '16px', maxWidth: '300px' },
fitMono(model.address, 30),
),
);
}
/** The background glow: amber healthy, red for trouble, violet Fedimint, blue LNURL. */
function glow(model) {
const danger =
model.status === 'offline' ||
(model.type !== 'fedimint' && model.warning && model.warning.severity === 'critical');
const color = danger
? 'rgba(224,100,80,.13)'
: model.type === 'fedimint'
? 'rgba(167,139,250,.11)'
: model.type === 'lnurl'
// Deliberately the strongest of the three. Amber and violet only have to
// separate a card from the site's own background; this one has to separate an
// LNURL card from a Fedimint card at 300px, where the badge text is a smudge
// and the tint is the first thing a reader actually resolves.
? 'rgba(78,168,222,.20)'
: 'rgba(240,169,59,.10)';
return h('div', {
position: 'absolute',
top: '-260px',
right: '-180px',
width: '760px',
height: '760px',
borderRadius: '999px',
backgroundImage: `radial-gradient(circle at center, ${color} 0%, rgba(14,13,11,0) 62%)`,
});
}
/** Brand row, shared by every variant. */
function brandRow(tagline) {
return h(
'div',
{ display: 'flex', alignItems: 'center', gap: '16px' },
moai(30),
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '29px', color: TEXT, letterSpacing: '-0.5px' }, 'Cashumints.space'),
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '23px', color: FAINT, marginTop: '2px' }, `· ${tagline}`),
);
}
/** One mint or federation card. `iconData` is a data: URI or null. */
export function mintCard(model, iconData) {
const isFed = model.type === 'fedimint';
const isLnurl = model.type === 'lnurl';
const monoLine = isFed ? model.shortId : fitMono(model.domain, 46);
// Room beside the icon: canvas minus padding, icon, gaps and the chips. A badge is
// ~180px for FEDIMINT and ~120px for the shorter LNURL.
const chipRoom = 240 + (isFed ? 180 : isLnurl ? 120 : 0);
const { size, clamp } = nameSize(model.name, 1200 - 128 - 148 - chipRoom);
return h(
'div',
{
width: '1200px',
height: '630px',
display: 'flex',
flexDirection: 'column',
backgroundColor: BG,
padding: '56px 64px',
position: 'relative',
fontFamily: INTER,
},
glow(model),
brandRow(isFed ? 'Fedimint reviews' : isLnurl ? 'LNURL mint reviews' : 'Cashu mint reviews'),
h(
'div',
{ display: 'flex', alignItems: 'center', gap: '32px', marginTop: '52px', flexGrow: 1 },
iconTile(model, iconData),
h(
'div',
{ display: 'flex', flexDirection: 'column', flexGrow: 1, minWidth: 0 },
h(
'div',
{ display: 'flex', alignItems: 'center', gap: '20px', flexWrap: 'wrap' },
h(
'div',
{
fontFamily: GROTESK,
fontWeight: 700,
fontSize: `${size}px`,
lineHeight: 1.08,
color: TEXT,
letterSpacing: '-1px',
maxWidth: '760px',
lineClamp: clamp,
},
model.name,
),
statusChip(model),
isFed && typeBadge('FEDIMINT', VIOLET, 'rgba(167,139,250,.5)'),
isLnurl && typeBadge('LNURL', LNURL, 'rgba(78,168,222,.5)'),
),
h('div', { fontFamily: MONO, fontWeight: 400, fontSize: '25px', color: MUTED, marginTop: '14px' }, monoLine),
// The Fedimint soft warning is a muted line, deliberately not a red cell.
isFed &&
model.announcedNote !== null &&
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '20px', color: 'rgba(240,169,59,.75)', marginTop: '10px' }, model.announcedNote),
),
),
h(
'div',
{
display: 'flex',
backgroundColor: CARD,
border: `1px solid ${LINE}`,
borderRadius: '20px',
minHeight: '196px',
},
ratingCell(model),
reviewsCell(model),
isFed ? thirdCellFedimint(model) : isLnurl ? thirdCellLnurl(model) : thirdCellCashu(model),
),
);
}
/** The default card: brand, tagline, and the network's numbers. Used site-wide. */
export function defaultCard(model) {
const stat = (n, label) =>
h(
'div',
{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '10px' },
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '54px', lineHeight: 1, color: TEXT }, String(n)),
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '21px', color: MUTED }, label),
);
return h(
'div',
{
width: '1200px',
height: '630px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: BG,
position: 'relative',
fontFamily: INTER,
},
h('div', {
position: 'absolute',
top: '-300px',
left: '220px',
width: '760px',
height: '760px',
borderRadius: '999px',
backgroundImage: 'radial-gradient(circle at center, rgba(240,169,59,.11) 0%, rgba(14,13,11,0) 62%)',
}),
moai(64),
h(
'div',
{ fontFamily: GROTESK, fontWeight: 700, fontSize: '66px', color: TEXT, letterSpacing: '-1.5px', marginTop: '30px' },
'Cashumints.space',
),
h(
'div',
{ fontFamily: INTER, fontWeight: 500, fontSize: '28px', color: MUTED, marginTop: '14px' },
'Cashu, Fedimint and LNURL mint reviews, signed on Nostr',
),
h(
'div',
// Four stats where there were three, so the gap tightens rather than the row
// running past the card's own margins.
{ display: 'flex', gap: '64px', marginTop: '64px' },
stat(model.mints, 'Cashu mints'),
stat(model.federations, 'federations'),
stat(model.lnurl, 'LNURL mints'),
stat(model.reviews, 'signed reviews'),
),
);
}
/* ---------- fonts ---------- */
let fontsPromise = null;
/**
* The six static faces satori embeds as glyph paths. Static TTF instances rather than
* the site's variable woff2 files, because satori reads neither woff2 nor variable
* axes; the weights are the ones the mockup uses. A missing file is a failed build:
* satori silently falling back to another face is exactly the "serif in the PNG"
* failure the acceptance criteria forbid.
*/
export function loadFonts() {
fontsPromise ??= Promise.all(
[
[GROTESK, 600, 'space-grotesk-600.ttf'],
[GROTESK, 700, 'space-grotesk-700.ttf'],
[INTER, 500, 'inter-500.ttf'],
[INTER, 600, 'inter-600.ttf'],
[MONO, 400, 'jetbrains-mono-400.ttf'],
[MONO, 500, 'jetbrains-mono-500.ttf'],
].map(async ([name, weight, file]) => ({
name,
weight,
style: 'normal',
data: await readFile(path.join(here, 'fonts', file)),
})),
);
return fontsPromise;
}
/* ---------- glyphs outside Latin ---------- */
const assetCache = new Map();
const cacheDir = path.join(here, 'fonts', 'cache');
/**
* Satori calls this for graphemes the six faces cannot draw (one indexed mint has a
* CJK name today). The Noto subset for exactly those characters is fetched once from
* Google Fonts and cached on disk, so repeat builds are offline and deterministic.
* If the fetch fails the glyphs are skipped — a name missing three characters beats
* a failed build, and the failure is logged by the caller.
*/
async function loadAdditionalAsset(code, segment) {
if (code === 'emoji') return []; // no emoji font is embedded; skip rather than tofu
const family = { 'ja-JP': 'Noto Sans JP', 'ko-KR': 'Noto Sans KR', 'zh-CN': 'Noto Sans SC', 'zh-TW': 'Noto Sans TC', 'th-TH': 'Noto Sans Thai' }[code] ?? 'Noto Sans SC';
const key = createHash('sha1').update(`${family}:${segment}`).digest('hex');
if (assetCache.has(key)) return assetCache.get(key);
const file = path.join(cacheDir, `${key}.ttf`);
let data = await readFile(file).catch(() => null);
if (!data) {
try {
const css = await fetch(
`https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}&text=${encodeURIComponent(segment)}`,
{ headers: { 'User-Agent': 'curl/7.68' } },
).then((r) => r.text());
// The subsetted file comes back as `url(https://fonts.gstatic.com/l/font?kit=…)
// format('truetype')` — a kit URL, not a .ttf path.
const url = /url\((https:[^)]+)\)\s*format\('truetype'\)/.exec(css)?.[1];
if (!url) throw new Error('no truetype url in css response');
data = Buffer.from(await fetch(url).then((r) => r.arrayBuffer()));
await mkdir(cacheDir, { recursive: true });
await writeFile(file, data);
} catch (error) {
console.warn(`og: no glyphs for "${segment}" (${error.message}); rendering without`);
assetCache.set(key, []);
return [];
}
}
const fonts = [{ name: family, data, weight: 400, style: 'normal' }];
assetCache.set(key, fonts);
return fonts;
}
/** Render one card model to SVG. */
export async function renderSvg(model, iconData) {
const element = model.type === 'default' ? defaultCard(model) : mintCard(model, iconData);
return satori(element, {
width: 1200,
height: 630,
fonts: await loadFonts(),
loadAdditionalAsset,
});
}
+175
View File
@@ -0,0 +1,175 @@
/**
* The edge cases the template must survive, as synthetic API payloads.
*
* Run through the same cashuModel/fedimintModel/lnurlModel derivation as real data — a fixture
* that bypassed the model would test a card no build can produce. `NOW` is pinned so
* the rendered PNGs under og-fixtures/ are byte-stable and can live in the repo as
* eyeball snapshots.
*/
/** 1 Aug 2026 00:00 UTC. Day counts and month stamps in fixtures derive from this. */
export const NOW = 1_785_542_400;
const dist = (five = 0, four = 0, three = 0, two = 0, one = 0) => ({
5: five, 4: four, 3: three, 2: two, 1: one,
});
const base = {
type: 'cashu',
status: 'online',
last_online: NOW - 300,
last_probe: NOW - 300,
first_seen: NOW - 400 * 86400,
review_count: 26,
rating_avg: 4.6,
rating_distribution: dist(18, 5, 2, 0, 1),
last_review_at: NOW - 12 * 86400,
info: {
nuts: {
4: { methods: [{ method: 'bolt11', unit: 'sat', min_amount: 100, max_amount: 500000 }], disabled: false },
5: { methods: [{ method: 'bolt11', unit: 'sat' }], disabled: false },
},
},
};
/**
* The shared shape of an LNURL row's detail payload, as `GET /api/mints/:host` returns
* it: the ecosystem fields flattened across the top level, millisatoshi on the wire.
*/
const lnurlBase = {
type: 'lnurl',
status: 'online',
last_online: NOW - 300,
last_probe: NOW - 300,
first_seen: NOW - 200 * 86400,
review_count: 9,
rating_avg: 4.4,
rating_distribution: dist(6, 2, 1),
last_review_at: NOW - 20 * 86400,
features: ['mint', 'melt', 'rotate', 'split', 'merge', 'lud06', 'lud03', 'lud16', 'signed-notes'],
network: 'mainnet',
mint_pubkey: '021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555',
funding_available: true,
invalid_reason: null,
min_withdrawable_msat: 5_000,
max_withdrawable_msat: 999_899_000,
lightning_address: 'mint@lnurl.example',
};
export const FIXTURES = [
{
// A name at the 30-character mark: must step down, never clip.
...base,
host: 'fixture-long-name',
url: 'https://mint.veryserious.example',
name: 'The Extremely Serious Mint Co.',
icon: null,
},
{
// No cached icon: the deterministic initial-on-gradient tile.
...base,
host: 'fixture-no-icon',
url: 'https://mint.plain.example',
name: 'Plain Mint',
icon: null,
},
{
// Zero reviews: "No reviews yet" in the rating cell, no stars, no sentiment bar.
...base,
host: 'fixture-zero-reviews',
url: 'https://mint.unreviewed.example',
name: 'Fresh Mint',
icon: null,
review_count: 0,
rating_avg: null,
rating_distribution: dist(),
last_review_at: null,
},
{
// Offline 12 days: red glow, "Offline 12d" chip, warning cell instead of limits.
...base,
host: 'fixture-offline',
url: 'https://mint.gone.example',
name: 'Vanished Mint',
icon: null,
status: 'offline',
last_online: NOW - 12 * 86400,
rating_avg: 2.4,
rating_distribution: dist(1, 1, 2, 4, 6),
review_count: 14,
},
{
// Melt only but ONLINE: green chip stays, warning cell appears anyway.
...base,
host: 'fixture-melt-only',
url: 'https://mint.windingdown.example',
name: 'Winding Down Mint',
icon: null,
info: {
nuts: {
4: { methods: [], disabled: true },
5: { methods: [{ method: 'bolt11', unit: 'sat' }], disabled: false },
},
},
},
{
// A federation announced 3 months ago that nothing ever confirmed.
type: 'fedimint',
host: 'fixture-fed-announced',
url: 'fed11qfixture',
name: 'Quiet Corner Federation',
icon: null,
status: 'announced',
last_online: null,
last_probe: null,
first_seen: NOW - 95 * 86400,
announced_at: NOW - 92 * 86400,
review_count: 3,
rating_avg: 4.3,
rating_distribution: dist(1, 2),
last_review_at: NOW - 40 * 86400,
federation_id: 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90',
modules: ['ln', 'mint', 'wallet', 'meta'],
network: 'mainnet',
status_source: null,
},
{
// The healthy LNURL card: blue glow, LNURL badge, a withdraw range in the third
// cell, and the lightning address under it.
...lnurlBase,
host: 'fixture-lnurl-online',
url: 'lnurl:https://lnurl.example',
base_url: 'https://lnurl.example',
name: null,
icon: null,
},
{
// Offline 20 days: red glow beats the blue one, warning cell replaces the range.
// The blue badge stays, because which ecosystem it is has not changed.
...lnurlBase,
host: 'fixture-lnurl-offline',
url: 'lnurl:https://lnurl.gone.example',
base_url: 'https://lnurl.gone.example',
name: 'Departed Notes',
icon: null,
status: 'offline',
last_online: NOW - 20 * 86400,
rating_avg: 2.1,
rating_distribution: dist(0, 1, 1, 3, 5),
review_count: 10,
},
{
// A withdraw ceiling of zero, while ONLINE: green status chip stays, and the
// critical warning cell appears anyway. The one card that must never print
// "max 0 sat" as though it were a limit.
...lnurlBase,
host: 'fixture-lnurl-no-withdrawals',
url: 'lnurl:https://lnurl.locked.example',
base_url: 'https://lnurl.locked.example',
name: 'Locked Notes Mint',
icon: null,
min_withdrawable_msat: 0,
max_withdrawable_msat: 0,
},
];
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+313
View File
@@ -0,0 +1,313 @@
/**
* The card model: everything one social image renders, and nothing else.
*
* This object is the hash input for the manifest, so it must be derived purely from
* API data plus the day the build runs on. Two rules follow from that:
*
* - No relative times finer than a day. "3 minutes ago" in a static PNG is a lie by
* the time anyone sees it; day counts and month/year stamps go stale slowly enough
* to survive until the next scheduled rebuild regenerates them.
* - Everything the template touches is in the model, and the template touches
* nothing but the model. A design change is a TEMPLATE_VERSION bump, which busts
* every hash at once; a data change busts exactly the mints it happened to.
*
* The warning logic is imported from shared/, not restated: the image must never
* disagree with the page it decorates about whether a mint is frozen or gone.
*/
import { getMintWarnings, msatToSat, parseLimits, readCapabilities } from '@cashumints/shared';
/**
* Bump when the template's layout or copy changes, to regenerate every image.
*
* 2: the LNURL variant. Its badge, glow and third cell are new, and the shared
* `warningCell` learned three LNURL warning kinds, so every card's hash input moved.
*/
export const TEMPLATE_VERSION = 2;
const DAY = 86_400;
/** English month/year ("Mar 2026") — the images are one English render for all locales. */
export function monthYear(unix) {
return new Date(unix * 1000).toLocaleDateString('en-GB', {
month: 'short',
year: 'numeric',
timeZone: 'UTC',
});
}
/** "3 Aug 2026", matching f.date() on the pages. */
export function shortDate(unix) {
return new Date(unix * 1000).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'UTC',
});
}
/* ---- mirrors of web/src/lib/format.ts, which is TypeScript this script cannot load.
Same algorithms, asserted by eyeball against the site's own fallback tiles. ---- */
export function displayDomain(url) {
return url.replace(/^https?:\/\//, '').replace(/\/+$/, '');
}
/** The site's deterministic gradient, resolved to hex pairs satori understands. */
export function iconGradient(seed) {
let hash = 0;
for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
const hue = hash % 360;
const hue2 = (hue + 40) % 360;
return [hsl(hue, 45, 55), hsl(hue2, 45, 45)];
}
function hsl(h, s, l) {
s /= 100;
l /= 100;
const k = (n) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
const hex = (v) => Math.round(v * 255).toString(16).padStart(2, '0');
return `#${hex(f(0))}${hex(f(8))}${hex(f(4))}`;
}
export function initials(name) {
const cleaned = name.replace(/^(https?:\/\/)?(mint\.|www\.)?/, '');
const first = cleaned.trim()[0] ?? '?';
if (/\d/.test(first)) return cleaned.slice(0, 2);
return first.toUpperCase();
}
export function truncateMiddle(value, head = 10, tail = 8) {
if (value.length <= head + tail + 1) return value;
return `${value.slice(0, head)}…${value.slice(-tail)}`;
}
/** 4–5 star share vs 1–2 star share, as the site's sentiment bar splits them. */
export function sentiment(dist) {
const positive = (dist['5'] ?? 0) + (dist['4'] ?? 0);
const negative = (dist['2'] ?? 0) + (dist['1'] ?? 0);
const total = positive + negative + (dist['3'] ?? 0);
if (total === 0) return null;
return {
pos: Math.round((positive / total) * 100),
neg: Math.round((negative / total) * 100),
};
}
/** "500k", "1M", "100" — sat amounts at stats-bar size. */
export function compactSats(n) {
if (n >= 1_000_000) return trim(n / 1_000_000) + 'M';
if (n >= 1_000) return trim(n / 1_000) + 'k';
return String(n);
}
function trim(n) {
const s = n.toFixed(1);
return s.endsWith('.0') ? s.slice(0, -2) : s;
}
/** The warning cell's two lines, from the same warning object the page banner uses. */
function warningCell(warning, mint, now) {
if (!warning) return null;
const since = mint.last_online;
const label = {
'gone': 'Likely gone',
'melt-disabled': 'No withdrawals',
'frozen': 'Frozen',
'melt-only': 'Melt only',
'offline-long': 'Offline',
'offline': 'Offline',
'fedimint-offline': 'Reported offline',
// LNURL. `lnurl-withdrawals-disabled` gets the same two words as the Cashu
// melt-disabled cell, because it is the same statement to a reader.
'lnurl-withdrawals-disabled': 'No withdrawals',
'lnurl-no-funding': 'No mint / melt',
'lnurl-invalid': 'Not responding properly',
}[warning.kind];
if (!label) return null;
let sub;
switch (warning.kind) {
case 'gone':
case 'offline-long':
case 'offline':
case 'fedimint-offline':
sub = since !== null ? `Since ${shortDate(since)}` : 'Never reached';
break;
case 'melt-only':
sub = 'Deposits disabled';
break;
case 'melt-disabled':
sub = 'Deposits still open';
break;
case 'frozen':
sub = 'Nothing moves in or out';
break;
case 'lnurl-withdrawals-disabled':
sub = 'Notes cannot be redeemed';
break;
case 'lnurl-no-funding':
// The precise half of the story, and the half that stops a reader writing the
// mint off: its notes still work, only Lightning is shut.
sub = 'Notes still rotate and split';
break;
case 'lnurl-invalid':
sub = 'Endpoint answered with junk';
break;
}
// The mint is also offline while a capability warning outranks that fact: the cell
// still has to say so, the way the page banner folds it into its last sentence.
if (
['melt-only', 'melt-disabled', 'frozen', 'lnurl-withdrawals-disabled'].includes(warning.kind) &&
mint.status === 'offline' &&
since !== null
) {
sub = `Offline since ${shortDate(since)}`;
}
return { kind: warning.kind, severity: warning.severity, label, sub };
}
/**
* One Cashu mint's card model, from its detail payload.
*
* `iconHash` arrives from the caller (a digest of the cached icon bytes), so a mint
* that changes its logo behind the same URL regenerates. `now` is passed in rather
* than read here so fixtures render the same PNG forever.
*/
export function cashuModel(mint, { iconHash = null, now = Math.floor(Date.now() / 1000) } = {}) {
const domain = displayDomain(mint.url);
const name = (mint.name ?? '').trim() || domain.split('/')[0] || domain;
const warning = getMintWarnings(mint, { now })[0] ?? null;
const offline = mint.status === 'offline';
const offlineDays =
offline && mint.last_online !== null
? Math.max(0, Math.floor((now - mint.last_online) / DAY))
: null;
const limits = parseLimits(mint.info?.nuts, 4);
const caps = readCapabilities(mint.info?.nuts);
return {
v: TEMPLATE_VERSION,
type: 'cashu',
slug: mint.host,
name,
domain,
status: mint.status,
offlineDays,
rating: mint.rating_avg,
reviewCount: mint.review_count,
sentiment: sentiment(mint.rating_distribution ?? {}),
lastReviewMonth: mint.last_review_at ? monthYear(mint.last_review_at) : null,
limits:
limits && (limits.min !== null || limits.max !== null) && !caps.mintDisabled
? { min: limits.min, max: limits.max, unit: limits.unit }
: null,
warning: warningCell(warning, mint, now),
iconHash,
gradient: iconHash ? null : iconGradient(domain),
initials: iconHash ? null : initials(name),
};
}
/** One federation's card model. Same skeleton, only honest fields. */
export function fedimintModel(fed, { iconHash = null, now = Math.floor(Date.now() / 1000) } = {}) {
const federationId = fed.federation_id ?? '';
const name = (fed.name ?? '').trim() || 'Unnamed federation';
const warning = getMintWarnings(fed, { now })[0] ?? null;
const announced = fed.status === 'announced';
const offlineDays =
fed.status === 'offline' && fed.last_online !== null
? Math.max(0, Math.floor((now - fed.last_online) / DAY))
: null;
return {
v: TEMPLATE_VERSION,
type: 'fedimint',
slug: fed.host,
name,
/** `fed11…` prefix plus the id tail: what the identity row shows where a domain sits. */
shortId: federationId ? `fed11…${federationId.slice(-6)}` : fed.host,
status: fed.status,
offlineDays,
rating: fed.rating_avg,
reviewCount: fed.review_count,
sentiment: sentiment(fed.rating_distribution ?? {}),
lastReviewMonth: fed.last_review_at ? monthYear(fed.last_review_at) : null,
modules: fed.modules ?? [],
network: fed.network,
/** The soft warning: never a red cell, just this muted line under the identity row. */
announcedNote:
announced && warning?.kind === 'never-confirmed' && fed.announced_at
? `Announced ${shortDate(fed.announced_at)}, never confirmed`
: null,
warning: fed.status === 'offline' ? warningCell(warning, fed, now) : null,
iconHash,
gradient: iconHash ? null : iconGradient(federationId || fed.host),
initials: iconHash ? null : initials(name),
};
}
/**
* One LNURL mint's card model.
*
* The same skeleton as the other two, with the third cell answering this ecosystem's
* own question: how much can one of this mint's bearer notes be worth? That is the
* withdraw range, in sats, converted here once from the millisatoshi the wire carries.
*
* `withdraw` is null when the ceiling is zero, and deliberately: a card printing
* "max 0 sat" reads as a number rather than as a refusal, and the warning cell that
* outranks it says the true thing instead.
*/
export function lnurlModel(mint, { iconHash = null, now = Math.floor(Date.now() / 1000) } = {}) {
const baseUrl = mint.base_url ?? mint.url.replace(/^lnurl:/, '');
const domain = displayDomain(baseUrl);
const name = (mint.name ?? '').trim() || domain.split('/')[0] || domain;
const warning = getMintWarnings(mint, { now })[0] ?? null;
const offline = mint.status === 'offline';
const offlineDays =
offline && mint.last_online !== null
? Math.max(0, Math.floor((now - mint.last_online) / DAY))
: null;
const min = msatToSat(mint.min_withdrawable_msat ?? null);
const max = msatToSat(mint.max_withdrawable_msat ?? null);
return {
v: TEMPLATE_VERSION,
type: 'lnurl',
slug: mint.host,
name,
domain,
status: mint.status,
offlineDays,
rating: mint.rating_avg,
reviewCount: mint.review_count,
sentiment: sentiment(mint.rating_distribution ?? {}),
lastReviewMonth: mint.last_review_at ? monthYear(mint.last_review_at) : null,
withdraw: max !== null && max > 0 ? { min, max } : null,
/** The LUD-16 address, which is the one string a reader might actually copy. */
address: mint.lightning_address ?? null,
warning: warningCell(warning, mint, now),
iconHash,
gradient: iconHash ? null : iconGradient(domain),
initials: iconHash ? null : initials(name),
};
}
/** The default card: brand, tagline, and the network's own numbers. */
export function defaultModel(stats) {
return {
v: TEMPLATE_VERSION,
type: 'default',
slug: 'default',
mints: stats.cashu_total,
federations: stats.fedimint_total,
lnurl: stats.lnurl_total,
reviews: stats.reviews_total,
};
}
+46
View File
@@ -0,0 +1,46 @@
/**
* A contact sheet of the three ecosystems' cards at thumbnail size.
*
* Not part of the build: run it by hand (`node scripts/og/thumbs.mjs`) to check the
* one thing a full-size render cannot show you — whether a reader scrolling a timeline
* can tell a Cashu card from a Fedimint one from an LNURL one before any text is
* legible. Writes og-fixtures/_thumbs.png.
*/
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Resvg } from '@resvg/resvg-js';
const here = path.dirname(fileURLToPath(import.meta.url));
const dir = path.resolve(here, '../../og-fixtures');
/** 300px wide: roughly how a link preview renders in a feed. */
const W = 300;
const H = Math.round((630 / 1200) * W);
const GAP = 16;
const LABEL = 22;
const cards = [
['Cashu', 'fixture-no-icon.png'],
['Fedimint', 'fixture-fed-announced.png'],
['LNURL', 'fixture-lnurl-online.png'],
];
const parts = await Promise.all(
cards.map(async ([label, file], i) => {
const data = await readFile(path.join(dir, file));
const x = i * (W + GAP);
return (
`<image x="${x}" y="${LABEL}" width="${W}" height="${H}" ` +
`href="data:image/png;base64,${data.toString('base64')}"/>` +
`<text x="${x}" y="15" font-family="monospace" font-size="13" fill="#EDE8DC">${label}</text>`
);
}),
);
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="${cards.length * (W + GAP) - GAP}" ` +
`height="${H + LABEL}"><rect width="100%" height="100%" fill="#0E0D0B"/>${parts.join('')}</svg>`;
await writeFile(path.join(dir, '_thumbs.png'), new Resvg(svg).render().asPng());
console.log(`og: og-fixtures/_thumbs.png (${cards.length} cards at ${W}px)`);
+386
View File
@@ -0,0 +1,386 @@
/**
* The production web server.
*
* The site is `output: 'static'`: `pnpm build` prerenders every page and this process
* only hands the result out over HTTP. nginx sits in front of it and proxies, rather
* than pointing a `root` at the built tree, so that nothing outside this file decides
* what is readable. That is not a stylistic preference — it removes two whole classes
* of failure the file-serving arrangement kept producing:
*
* traversal permissions nginx runs as www-data and the build runs as cashumints,
* so every directory from / down to dist had to be traversable
* by a user with no other business in it. One 0700 home
* directory anywhere in the chain took the site down, and
* `try_files` reports a permission error as a plain miss, so
* the symptom was a blanket 404 or an internal-redirect loop
* ending in 500 — never the actual cause.
*
* duplicated routing the locale 404 rule lived in nginx as a hand-maintained
* alternation of 23 codes. Adding a language meant editing a
* file that is not in this repository, and forgetting to was
* silent. Locale 404s are resolved by looking in the built
* tree now, so the list cannot drift.
*
* Reading files is all it does. There is no template, no database handle and no route
* table: `/api/*` and `/icons/*` belong to the API on its own port and nginx forwards
* them there directly.
*
* Env:
* SITE_PORT 8789 port to listen on
* SITE_HOST 127.0.0.1 interface to bind; loopback because nginx terminates TLS
* WEB_ROOT ./dist the tree to serve
*/
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
function int(raw, fallback) {
const n = Number.parseInt(raw ?? '', 10);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
/**
* The tree to serve, absolute.
*
* Defaults to the build output next to this file, which is what `pnpm start` and the
* tests use. In production it points at a copy outside the checkout: `pnpm build`
* empties dist before it writes, so serving dist directly means a rebuild takes the
* whole site down for the length of the build. See cashumints-web.service.
*/
export const ROOT = path.resolve(process.env.WEB_ROOT ?? path.join(here, 'dist'));
const PORT = int(process.env.SITE_PORT, 8789);
const HOST = process.env.SITE_HOST ?? '127.0.0.1';
/** One line per event, key=value after the message. Matches the API's log format. */
function log(level, msg, fields = {}) {
const parts = [new Date().toISOString(), level.toUpperCase(), msg];
for (const [k, v] of Object.entries(fields)) {
if (v !== undefined && v !== null) parts.push(`${k}=${v}`);
}
const line = parts.join(' ');
if (level === 'error') console.error(line);
else console.log(line);
}
const TYPES = new Map(Object.entries({
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.webmanifest': 'application/manifest+json; charset=utf-8',
'.xml': 'application/xml; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.ttf': 'font/ttf',
'.wasm': 'application/wasm',
}));
const IMMUTABLE = 'public, max-age=31536000, immutable';
const WEEK = 'public, max-age=604800';
const HOUR = 'public, max-age=3600';
/** Zero lifetime but cacheable: the client keeps the body and revalidates into a 304. */
const REVALIDATE = 'public, max-age=0, must-revalidate';
/**
* How long a response may be reused.
*
* Everything Vite and the card builder emit carries a content hash in its filename, so
* those are immutable for a year — a changed file is a changed URL. Markup is the
* opposite: the URLs are permanent and the bytes change on every rebuild, so it
* revalidates every time and the ETag below turns that into a 304 in the usual case.
*
* `/og/default.png` is the one unhashed card, served for pages that have no mint of
* their own, so it gets an hour rather than a year.
*/
export function cacheControl(urlPath, ext) {
if (ext === '.html') return REVALIDATE;
if (urlPath === '/og/default.png') return HOUR;
if (urlPath.startsWith('/_astro/') || urlPath.startsWith('/og/')) return IMMUTABLE;
if (urlPath === '/robots.txt' || urlPath === '/sitemap.xml') return HOUR;
return WEEK;
}
/**
* Turn a request path into a path inside ROOT, or null if it escapes or is malformed.
*
* Returns the *relative* path so the caller can join it against ROOT; every segment is
* checked rather than trusting `path.join` to have swallowed the `..`. Dotfiles are
* refused outright: a static build emits none, so a request for one is either a probe
* or a mistake, and neither should be answered with bytes.
*/
export function safePath(urlPath) {
if (urlPath.includes('\0')) return null;
const normalized = path.posix.normalize(urlPath);
if (!normalized.startsWith('/')) return null;
const segments = normalized.split('/').filter(Boolean);
for (const segment of segments) {
if (segment === '..' || segment.startsWith('.')) return null;
}
return segments;
}
async function statFile(file) {
try {
const stats = await fsp.stat(file);
return stats.isFile() ? stats : null;
} catch {
return null;
}
}
/**
* The candidates for one request path, in order.
*
* Astro emits directory-style routes — /mints is dist/mints/index.html — and
* `trailingSlash: 'ignore'` means /mints and /mints/ are both the page. Trying the
* literal path first keeps assets a single stat; the `.html` candidate covers the flat
* files at the root, /404.html among them.
*/
export function candidates(segments) {
const rel = segments.join('/');
if (rel === '') return ['index.html'];
return [rel, `${rel}/index.html`, `${rel}.html`];
}
/**
* The 404 body for a path, and it is not always the English one.
*
* A miss under /es keeps the visitor on the Spanish 404 rather than bouncing them into
* English, which is the same reason `redirectToDefaultLocale` is false in the Astro
* config. Which prefixes count is decided by what the build actually emitted: if
* <prefix>/404/index.html exists, the prefix is a locale. Nothing to keep in sync.
*/
async function notFoundBody(segments) {
const first = segments[0];
if (first) {
const localized = path.join(ROOT, first, '404', 'index.html');
const stats = await statFile(localized);
if (stats) return { file: localized, stats };
}
const fallback = path.join(ROOT, '404.html');
const stats = await statFile(fallback);
return stats ? { file: fallback, stats } : null;
}
/** nginx's ETag format: hex mtime and hex size, strong. Cheap, and changes on rebuild. */
function etagFor(stats) {
return `"${Math.floor(stats.mtimeMs / 1000).toString(16)}-${stats.size.toString(16)}"`;
}
/** RFC 9110: a list of entity tags, or `*`. Weak comparison is right for GET. */
function etagMatches(header, etag) {
if (!header) return false;
if (header.trim() === '*') return true;
const bare = etag.replace(/^W\//, '');
return header
.split(',')
.map((candidate) => candidate.trim().replace(/^W\//, ''))
.includes(bare);
}
function notModified(req, etag, lastModified) {
if (etagMatches(req.headers['if-none-match'], etag)) return true;
// Only consulted when the client sent no ETag, per RFC 9110 §13.1.3.
if (req.headers['if-none-match']) return false;
const since = Date.parse(req.headers['if-modified-since'] ?? '');
return Number.isFinite(since) && Math.floor(lastModified / 1000) * 1000 <= since;
}
/**
* Headers every response carries.
*
* No CSP here on purpose. The site loads an analytics script from another origin, the
* review islands open websockets to whatever relays are configured and the status
* islands fetch whatever mint URLs the index holds, so a policy tight enough to be
* worth having has to be derived from those lists rather than guessed at — and a wrong
* one fails as a silently broken island. HSTS belongs to nginx, which is what actually
* terminates TLS.
*/
function baseHeaders() {
return {
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'X-Frame-Options': 'DENY',
};
}
function send(res, status, headers, body) {
res.writeHead(status, { ...baseHeaders(), ...headers });
res.end(body);
}
/** Stream a file, or just its headers for HEAD. */
function sendFile(req, res, status, file, stats, urlPath) {
const ext = path.extname(file).toLowerCase();
const etag = etagFor(stats);
const headers = {
'Content-Type': TYPES.get(ext) ?? 'application/octet-stream',
'Content-Length': stats.size,
'Last-Modified': new Date(stats.mtimeMs).toUTCString(),
ETag: etag,
'Cache-Control': cacheControl(urlPath, ext),
...baseHeaders(),
};
// A 304 must not carry a body or a Content-Length describing one.
if (status === 200 && notModified(req, etag, stats.mtimeMs)) {
delete headers['Content-Length'];
delete headers['Content-Type'];
res.writeHead(304, headers);
res.end();
return;
}
res.writeHead(status, headers);
if (req.method === 'HEAD') {
res.end();
return;
}
const stream = fs.createReadStream(file);
stream.on('error', (err) => {
log('error', 'read failed', { file, err: err.message });
res.destroy();
});
// Kill the read when the client hangs up mid-transfer rather than draining the file.
res.on('close', () => stream.destroy());
stream.pipe(res);
}
async function handle(req, res) {
if (req.method !== 'GET' && req.method !== 'HEAD') {
send(res, 405, { Allow: 'GET, HEAD', 'Content-Type': 'text/plain; charset=utf-8' }, 'Method Not Allowed\n');
return;
}
let urlPath;
try {
urlPath = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
} catch {
send(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad Request\n');
return;
}
const segments = safePath(urlPath);
if (!segments) {
send(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad Request\n');
return;
}
for (const candidate of candidates(segments)) {
const file = path.join(ROOT, candidate);
// Belt and braces: safePath already refused `..`, this refuses anything that still
// resolved outside the tree, a symlink in the build output included.
if (file !== ROOT && !file.startsWith(ROOT + path.sep)) break;
const stats = await statFile(file);
if (stats) {
sendFile(req, res, 200, file, stats, urlPath);
return;
}
}
const miss = await notFoundBody(segments);
if (!miss) {
send(res, 404, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': REVALIDATE }, 'Not Found\n');
return;
}
// Served as a 404, not a 200 with a 404-shaped body: a soft 404 gets every typo'd
// URL indexed as a real page.
sendFile(req, res, 404, miss.file, miss.stats, urlPath);
}
export function createServer() {
const server = http.createServer((req, res) => {
handle(req, res).catch((err) => {
log('error', 'request failed', { path: req.url, err: err.message });
if (!res.headersSent) {
send(res, 500, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }, 'Internal Server Error\n');
} else {
res.destroy();
}
});
});
/*
* Longer than nginx's upstream keepalive, and headersTimeout longer still.
*
* If this end closes an idle connection at the same moment nginx reuses it, nginx has
* nothing to retry and reports 502. Outlasting the proxy makes the proxy always the
* one to close, which is the race-free direction.
*/
server.keepAliveTimeout = 65_000;
server.headersTimeout = 66_000;
// A malformed request line should not take the process with it.
server.on('clientError', (err, socket) => {
if (err.code === 'ECONNRESET' || !socket.writable) return;
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
});
return server;
}
/**
* Refuse to start on an empty or unreadable root.
*
* The failure this prevents is the one that is hardest to see: a process that starts
* cleanly, answers every request with a 404 and looks healthy to anything watching the
* port. Exiting non-zero puts the reason in `systemctl status` instead.
*/
async function checkRoot() {
const index = path.join(ROOT, 'index.html');
if (await statFile(index)) return;
log('error', 'web root has no index.html', {
root: ROOT,
hint: 'a manual `pnpm build` only writes web/dist; `systemctl start cashumints-web` builds and publishes to WEB_ROOT',
});
process.exit(1);
}
/** Only when run directly, so the tests can import the pieces above. */
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await checkRoot();
const server = createServer();
server.listen(PORT, HOST, () => {
log('info', 'site listening', { host: HOST, port: PORT, root: ROOT });
});
let shuttingDown = false;
for (const signal of ['SIGTERM', 'SIGINT']) {
process.on(signal, () => {
if (shuttingDown) return;
shuttingDown = true;
log('info', 'shutting down', { signal });
server.close(() => process.exit(0));
// Idle keep-alive connections would otherwise hold the close open for a minute.
server.closeIdleConnections();
setTimeout(() => {
server.closeAllConnections();
process.exit(0);
}, 10_000).unref();
});
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

+36 -15
View File
@@ -4,7 +4,7 @@
"name": "mint-page",
"viewportWidth": 324,
"width": 324,
"height": 327,
"height": 407,
"bones": [
[
0,
@@ -16,14 +16,14 @@
[
20.9877,
0,
59.2593,
54.4078,
43,
8
],
[
20.9877,
43,
59.2593,
54.4078,
22,
8
],
@@ -102,8 +102,15 @@
0,
265,
100,
63,
84,
8
],
[
0,
366,
42.284,
41,
10
]
]
},
@@ -111,7 +118,7 @@
"name": "mint-page",
"viewportWidth": 712,
"width": 712,
"height": 193,
"height": 252,
"bones": [
[
0,
@@ -123,19 +130,19 @@
[
9.5506,
0,
26.9663,
24.7586,
43,
8
],
[
9.5506,
43,
26.9663,
24.7586,
22,
8
],
[
38.764,
36.5564,
18,
10.8146,
29,
@@ -143,7 +150,7 @@
true
],
[
40.4494,
38.2417,
29,
0.9831,
7,
@@ -211,6 +218,13 @@
100,
42,
8
],
[
0,
211,
19.2416,
41,
10
]
]
},
@@ -218,7 +232,7 @@
"name": "mint-page",
"viewportWidth": 1184,
"width": 1184,
"height": 172,
"height": 231,
"bones": [
[
0,
@@ -230,19 +244,19 @@
[
5.7432,
0,
16.2162,
14.8886,
43,
8
],
[
5.7432,
43,
16.2162,
14.8886,
22,
8
],
[
23.3108,
21.9832,
18,
6.5034,
29,
@@ -250,7 +264,7 @@
true
],
[
24.3243,
22.9967,
29,
0.5912,
7,
@@ -318,9 +332,16 @@
100,
21,
8
],
[
0,
190,
11.5709,
41,
10
]
]
}
},
"_hash": "f3f7f7e86a10ecca631b74af17fb0335"
"_hash": "e76f6212eab4b1517002712803fae94e"
}
+235 -102
View File
@@ -4,132 +4,181 @@
"name": "reviews-panel",
"viewportWidth": 322,
"width": 322,
"height": 727,
"height": 876,
"bones": [
[
6.2112,
20,
18,
11.8012,
38,
"50%"
],
[
22.9814,
19,
17,
32.6087,
22,
8
],
[
22.9814,
41,
39,
70.8075,
16,
17,
5
],
[
22.9814,
70,
68,
20.429,
16,
8
],
[
46.5159,
68,
45.8948,
66,
0.9317,
20,
8
],
[
49.3109,
66,
11.8012,
19,
20,
8
],
[
22.9814,
98,
96,
70.8075,
116,
144,
8
],
[
22.9814,
246,
70.8075,
27,
8
],
[
6.2112,
255,
310,
11.8012,
38,
"50%"
],
[
22.9814,
254,
309,
23.6025,
22,
8
],
[
22.9814,
277,
331,
70.8075,
16,
17,
5
],
[
22.9814,
305,
360,
20.429,
16,
8
],
[
46.5159,
303,
45.8948,
358,
0.9317,
20,
8
],
[
49.3109,
358,
12.1118,
19,
20,
8
],
[
22.9814,
333,
388,
70.8075,
116,
120,
8
],
[
22.9814,
514,
70.8075,
27,
8
],
[
6.2112,
490,
578,
11.8012,
38,
"50%"
],
[
22.9814,
489,
577,
31.0559,
22,
8
],
[
22.9814,
512,
599,
70.8075,
16,
17,
5
],
[
22.9814,
540,
628,
20.429,
16,
8
],
[
46.5159,
538,
45.8948,
626,
0.9317,
20,
8
],
[
49.3109,
626,
12.7329,
19,
20,
8
],
[
22.9814,
568,
656,
70.8075,
139,
144,
8
],
[
22.9814,
806,
20.8075,
17,
8
],
[
22.9814,
831,
70.8075,
27,
8
]
]
@@ -138,132 +187,174 @@
"name": "reviews-panel",
"viewportWidth": 710,
"width": 710,
"height": 455,
"height": 533,
"bones": [
[
2.8169,
20,
18,
5.3521,
38,
"50%"
],
[
10.4225,
21,
17,
14.7887,
22,
8
],
[
10.4225,
43,
75.2421,
16,
39,
67.7773,
17,
5
],
[
87.9181,
22,
80.4533,
21,
9.265,
16,
8
],
[
91.831,
42,
5.3521,
90.5634,
19,
0.4225,
20,
8
],
[
91.831,
19,
5.3521,
20,
8
],
[
10.4225,
77,
68,
82.3944,
48,
8
],
[
10.4225,
124,
86.7606,
46,
27,
8
],
[
2.8169,
164,
188,
5.3521,
38,
"50%"
],
[
10.4225,
165,
187,
10.7042,
22,
8
],
[
10.4225,
187,
75.2421,
16,
209,
67.6364,
17,
5
],
[
87.9181,
166,
80.3125,
191,
9.265,
16,
8
],
[
90.4225,
189,
0.4225,
20,
8
],
[
91.6901,
186,
189,
5.493,
19,
20,
8
],
[
10.4225,
221,
238,
82.3944,
48,
8
],
[
10.4225,
294,
86.7606,
46,
27,
8
],
[
2.8169,
308,
358,
5.3521,
38,
"50%"
],
[
10.4225,
309,
357,
14.0845,
22,
8
],
[
10.4225,
331,
75.2421,
16,
379,
67.3548,
17,
5
],
[
87.9181,
310,
80.0308,
361,
9.265,
16,
8
],
[
90.1408,
359,
0.4225,
20,
8
],
[
91.4085,
330,
359,
5.7746,
19,
20,
8
],
[
10.4225,
365,
408,
82.3944,
72,
8
],
[
10.4225,
488,
86.7606,
70,
27,
8
]
]
@@ -272,136 +363,178 @@
"name": "reviews-panel",
"viewportWidth": 784,
"width": 784,
"height": 431,
"height": 533,
"bones": [
[
2.551,
20,
18,
4.8469,
38,
"50%"
],
[
9.4388,
21,
17,
13.3929,
22,
8
],
[
9.4388,
43,
77.5789,
16,
39,
70.8187,
17,
5
],
[
89.0585,
22,
82.2983,
21,
8.3905,
16,
8
],
[
92.602,
42,
4.8469,
91.4541,
19,
0.3827,
20,
8
],
[
92.602,
19,
4.8469,
20,
8
],
[
9.4388,
77,
68,
74.6173,
48,
8
],
[
9.4388,
124,
88.0102,
46,
27,
8
],
[
2.551,
164,
188,
4.8469,
38,
"50%"
],
[
9.4388,
165,
187,
9.6939,
22,
8
],
[
9.4388,
187,
77.5789,
16,
209,
70.6912,
17,
5
],
[
89.0585,
166,
82.1708,
191,
8.3905,
16,
8
],
[
91.3265,
189,
0.3827,
20,
8
],
[
92.4745,
186,
189,
4.9745,
19,
20,
8
],
[
9.4388,
221,
238,
74.6173,
48,
8
],
[
9.4388,
294,
88.0102,
46,
27,
8
],
[
2.551,
308,
358,
4.8469,
38,
"50%"
],
[
9.4388,
309,
357,
12.7551,
22,
8
],
[
9.4388,
331,
77.5789,
16,
379,
70.4361,
17,
5
],
[
89.0585,
310,
81.9157,
361,
8.3905,
16,
8
],
[
91.0714,
359,
0.3827,
20,
8
],
[
92.2194,
330,
359,
5.2296,
19,
20,
8
],
[
9.4388,
365,
408,
74.6173,
72,
8
],
[
9.4388,
488,
88.0102,
46,
27,
8
]
]
}
},
"_hash": "788093c50463a4ad673ece50c6db52da"
"_hash": "d9fb0ac370be8e706ff4dfea00b0081b"
}
+3 -1
View File
@@ -32,12 +32,14 @@ const t = useI18n(locale);
const { value, label, kind = t('mint.kind.identifier'), class: className } = Astro.props;
---
{/* The accessible name repeats the visible text: a voice-control user says what
they see, and a name that omits it cannot be spoken at. */}
<button
type="button"
class:list={['cid', className]}
data-copy-id={value}
title={value}
aria-label={t('common.copyFull', { kind })}
aria-label={`${label ?? value}: ${t('common.copyFull', { kind })}`}
>
<span class="cid-text">{label ?? value}</span>
</button>
+97 -38
View File
@@ -1,5 +1,23 @@
---
/**
* The site footer: one component, every page, three zones.
*
* Zone one is four columns — who this is, then the two link groups people actually
* navigate to, then the language control. Zone two is a thin bar with the copyright
* and the credit, and nothing else: the moment anything else moves into that row it
* stops being a signature and starts being a fourth navigation surface.
*
* What used to be here was four flat rows, the last of which was ~20 raw language
* links wrapped over two lines. That wall is now the same switcher the header carries
* (see LanguageSwitcher.astro, `placement="footer"`), so there is one language control
* on the site in two places rather than two controls that happen to agree.
*
* Everything below is static per locale and prerenders. No hex, no npub, no counts,
* nothing fetched: the footer is the one region of every page that must be identical
* on the home page and on a mint page that failed to load.
*/
import LanguageSwitcher from './LanguageSwitcher.astro';
import Moai from './Moai.astro';
import { localePath, useI18n } from '../i18n';
import { pageLocale } from '../i18n/paths';
@@ -9,22 +27,29 @@ const GITHUB = 'https://github.com/Azzamo-net/cashumints.space';
const { locale } = pageLocale(Astro);
const t = useI18n(locale);
/**
* Two rows: the pages people navigate to, then the ones they go looking for. The
* disclaimer sits in the second row here and again at the bottom of every mint page,
* which is where the decision it is about actually gets made.
/*
* Route slugs stay English in every language and `localePath` adds the prefix, exactly
* as the header does. See src/i18n/routing.ts for why that trade was made.
*/
const nav = [
{ path: '/mints', label: t('footer.allMints') },
{ path: '/reviews', label: t('footer.allReviews') },
{ path: '/wallets', label: t('footer.wallets') },
{ path: '/about', label: t('footer.about') },
const explore = [
{ href: localePath('/mints', locale), label: t('footer.allMints') },
{ href: localePath('/fedimints', locale), label: t('footer.allFedimints') },
{ href: localePath('/lnurl-mints', locale), label: t('footer.allLnurlMints') },
{ href: localePath('/reviews', locale), label: t('footer.allReviews') },
{ href: localePath('/wallets', locale), label: t('footer.wallets') },
];
const legal = [
{ path: '/disclaimer', label: t('footer.disclaimer') },
{ path: '/terms', label: t('footer.terms') },
{ path: '/privacy', label: t('footer.privacy') },
/*
* GitHub sits in this column rather than beside the credit line because it is a place
* to go, not a signature. It is the only external link in the columns, so it carries
* `rel="noopener"` and nothing else needs to.
*/
const site = [
{ href: localePath('/about', locale), label: t('footer.about'), external: false },
{ href: GITHUB, label: 'GitHub', external: true },
{ href: localePath('/terms', locale), label: t('footer.terms'), external: false },
{ href: localePath('/privacy', locale), label: t('footer.privacy'), external: false },
{ href: localePath('/disclaimer', locale), label: t('footer.disclaimer'), external: false },
];
/*
@@ -38,31 +63,65 @@ const madeBy = t('footer.madeBy', {
});
---
<footer>
<div class="foot-inner">
<footer class="site-footer" role="contentinfo">
<div class="foot-cols">
{/*
The brand column. The custodial warning lives here, under the description,
because it is the most honest sentence on the site and it was previously an
orphan floating at the bottom right of the last row. It is set in `--muted`
rather than `--faint`: quiet, but a step louder than the link columns, which is
the only typographic claim this footer makes about relative importance.
*/}
<div class="foot-brand">
<a class="foot-mark" href={localePath('/', locale)}>
<Moai size={20} />
<span>Cashumints.space</span>
</a>
<p class="foot-desc">{t('footer.tagline')}</p>
<p class="foot-warn">{t('footer.warning')}</p>
</div>
{/*
One nav landmark over both link columns, not one per column: a screen reader
listing landmarks should find "Footer", not "Footer" twice. The language control
carries its own nav, because it is a different kind of thing — it does not take
you to another page, it takes you to this page again.
*/}
<nav class="foot-nav" aria-label={t('footer.nav')}>
<div class="foot-col">
<h2 class="foot-head">{t('footer.col.explore')}</h2>
<ul>
{explore.map((link) => (
<li><a href={link.href}>{link.label}</a></li>
))}
</ul>
</div>
<div class="foot-col">
<h2 class="foot-head">{t('footer.col.site')}</h2>
<ul>
{site.map((link) => (
<li>
<a href={link.href} rel={link.external ? 'noopener' : undefined}>{link.label}</a>
</li>
))}
</ul>
</div>
</nav>
<div class="foot-col foot-lang">
<h2 class="foot-head">{t('lang.label')}</h2>
<LanguageSwitcher placement="footer" />
</div>
</div>
{/*
The bottom bar. Two items, and the discipline is that it stays two items: the
copyright and who made it. Everything else that was ever tempted into this row now
has a column above it.
*/}
<div class="foot-bar">
<span>{t('footer.copyright', { year: String(year) })}</span>
<div class="links">
<a href={GITHUB} rel="noopener">GitHub</a>
{nav.map((link) => <a href={localePath(link.path, locale)}>{link.label}</a>)}
</div>
<span class="foot-right" set:html={madeBy} />
</div>
<div class="foot-inner foot-legal">
<div class="links">
{legal.map((link) => <a href={localePath(link.path, locale)}>{link.label}</a>)}
</div>
<LanguageSwitcher variant="inline" />
<span class="foot-right foot-warn">{t('footer.warning')}</span>
<span class="foot-credit" set:html={madeBy} />
</div>
</footer>
<style>
/* The second row is quieter than the first and has no top border of its own: it
reads as a continuation of the footer, not a second footer. */
.foot-legal { padding-top: 0; border-top: none; font-size: 12.5px; }
.foot-warn { color: var(--faint); }
@media (max-width: 640px) {
.foot-legal { padding-top: 4px; }
}
</style>
+187
View File
@@ -0,0 +1,187 @@
---
/**
* The invite codes block on a federation page.
*
* An invite code is the whole point of the page: it is what a wallet needs to join, it
* is 150 to 300 characters long, and it is opaque. So it gets the two affordances that
* length demands and nothing else — one click to copy the whole thing, and a QR to
* point a phone at.
*
* Both are prerendered. The code is truncated in the middle by CSS-free string maths
* (`shortInviteCode`) rather than by an ellipsis rule, so the tail is visible: two codes
* for the same federation differ at the end, and a row that cuts there shows two
* identical rows. The QR is encoded at build time like the mint page's, so opening it
* costs no JavaScript beyond the toggle itself and works on a page whose island never
* loaded — the panel is a `<details>`, which opens on its own.
*
* Most federations publish exactly one code. Some publish several (different guardian
* address sets for the same federation), and every one of them is equally valid, so
* they are all listed rather than one being picked.
*/
import { shortInviteCode } from '@cashumints/shared';
import { qrSvg } from '../lib/qr';
import { useI18n } from '../i18n';
import { pageLocale } from '../i18n/paths';
interface Props {
codes: string[];
/** Named in the QR's caption, so a screenshot of it says which federation it joins. */
name: string;
}
const { codes, name } = Astro.props;
const { locale } = pageLocale(Astro);
const t = useI18n(locale);
/*
* Encoded once per code, at build time.
*
* An invite code is long enough that its QR runs to a high version with small modules,
* which is exactly why the frame below is given as much width as the panel has: a
* 300 character code at 180px is not readable by a phone camera.
*/
const rows = codes.map((code) => ({
code,
short: shortInviteCode(code),
qr: qrSvg(code),
}));
---
<div class="panel invite-panel" data-reveal>
<h2 class="side-title">
{t('fedimint.invite.title')}
{rows.length > 1 && <span class="invite-count">{t('fedimint.invite.count', { n: rows.length })}</span>}
</h2>
<p class="invite-note">{t('fedimint.invite.note')}</p>
{
rows.map((row, index) => (
<div class="invite">
<div class="invite-row">
{/*
The code is the copy control itself, not a label beside one: it is the only
thing on this page anybody copies, and hiding that behind a small icon
button would be making the primary action the least visible.
*/}
<button
type="button"
class="cid invite-code"
data-copy-id={row.code}
title={row.code}
aria-label={t('common.copyFull', { kind: t('fedimint.invite.kind') })}
>
<span class="cid-text mono">{row.short}</span>
</button>
<button
type="button"
class="invite-qr-btn"
data-invite-qr={index}
aria-expanded="false"
aria-controls={`invite-qr-${index}`}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<rect x="3" y="3" width="5" height="5" rx="1" /><rect x="16" y="3" width="5" height="5" rx="1" />
<rect x="3" y="16" width="5" height="5" rx="1" />
<path d="M21 16h-3a2 2 0 0 0-2 2v3M21 21v.01M12 7v3a2 2 0 0 1-2 2H7M12 3h.01M12 16v.01M16 12h1M21 12v.01M12 21v-1" />
</svg>
<span>{t('fedimint.invite.qr')}</span>
</button>
</div>
{/*
Hidden rather than absent, so the toggle has something to reveal without a
round trip and the code is in the markup for anything that reads it.
*/}
<div class="invite-qr" id={`invite-qr-${index}`} hidden>
<div class="qr-frame">
<Fragment set:html={row.qr} />
</div>
<p class="invite-qr-cap">{t('fedimint.invite.qrCaption', { name })}</p>
</div>
</div>
))
}
</div>
<style>
.invite-panel { padding: 20px; }
.side-title { display: flex; align-items: baseline; gap: 8px; }
.invite-count { font-family: var(--mono); font-size: 11px; color: var(--faint); letter-spacing: 0; text-transform: none; }
.invite-note { font-size: 12.5px; color: var(--muted); line-height: 1.55; margin-bottom: 12px; }
.invite + .invite { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--line-soft); }
.invite-row { display: flex; align-items: center; gap: 8px; }
/* The code fills the row and the QR button keeps its own width beside it. */
.invite-code {
flex: 1; min-width: 0; justify-content: flex-start;
background: var(--card2); border: 1px solid var(--line-soft); border-radius: 9px;
padding: 9px 11px; font-size: 12.5px; color: var(--text);
}
.invite-code:hover { color: var(--amber); border-color: #38342A; }
.invite-code .cid-text { overflow-wrap: anywhere; }
.invite-qr-btn {
display: inline-flex; align-items: center; gap: 7px; flex-shrink: 0;
font-family: var(--body); font-size: 12.5px; font-weight: 500; color: var(--muted);
background: none; border: 1px solid var(--line); border-radius: 9px;
padding: 9px 12px; cursor: pointer;
transition: color .15s, border-color .15s;
}
.invite-qr-btn:hover { color: var(--text); border-color: #38342A; }
.invite-qr-btn[aria-expanded='true'] { color: var(--amber); border-color: var(--amber-dim); }
.invite-qr { margin-top: 12px; display: flex; flex-direction: column; align-items: center; gap: 8px; }
/* `display: flex` above outranks the `hidden` attribute's own `display: none`, so
the code opened on page load until this said otherwise. */
.invite-qr[hidden] { display: none; }
/*
* The white plate is the quiet zone a camera needs, so it stays white in every theme.
* Wider than the mint page's 236px: an invite code encodes to a much denser code, and
* at that size the modules are too small for a phone to resolve.
*/
.invite-qr .qr-frame {
background: #FFFFFF; border-radius: 12px; padding: 12px;
width: 100%; max-width: 300px; aspect-ratio: 1; box-sizing: border-box;
}
.invite-qr .qr-frame :global(svg) { display: block; }
.invite-qr-cap { font-size: 11.5px; color: var(--faint); text-align: center; }
@media (max-width: 420px) {
/* Two controls on one row stop fitting; the code keeps the full width. */
.invite-row { flex-wrap: wrap; }
.invite-qr-btn { width: 100%; justify-content: center; }
}
</style>
<script>
import { wireCopyableIds } from '../lib/client';
import { onReady, prefersReducedMotion } from '../scripts/reveal';
onReady(() => {
// The codes are ordinary copyable identifiers; one delegated handler drives every
// one of them, the same one the npubs on a review card use.
wireCopyableIds();
for (const button of document.querySelectorAll<HTMLButtonElement>('[data-invite-qr]')) {
if (button.dataset['qrWired']) continue;
button.dataset['qrWired'] = '1';
button.addEventListener('click', () => {
const panel = document.getElementById(button.getAttribute('aria-controls') ?? '');
if (!panel) return;
const open = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', String(!open));
panel.hidden = open;
// The code arrives rather than appearing, which reads as a response to the
// press. Nothing moves under it: the panel is the last thing in its block.
if (!open && !prefersReducedMotion()) {
panel.classList.add('appearing');
panel.addEventListener('animationend', () => panel.classList.remove('appearing'), { once: true });
}
});
}
});
</script>
+167 -72
View File
@@ -1,12 +1,17 @@
---
/**
* The language switcher, in two shapes.
* The language switcher. One control, two placements.
*
* `menu` is the header control: a compact button showing the current language's code,
* opening a list of the three. `inline` is the footer's plain row of three links. Both
* are built from the same list and the same hrefs, because they are the same control
* and a reader who finds one and then the other should not have to work out whether
* they do the same thing.
* `bar` is the header's: a compact button showing the current language's code. `footer`
* is the Language column's: the same button, showing the language's full name, opening
* upward so the panel is never clipped off the bottom of the document. Same list, same
* hrefs, same markup, same script — the only differences are which label the trigger
* shows and which way the panel opens, because a reader who finds one and then the
* other should not have to work out whether they do the same thing.
*
* The footer used to carry a second, different control: every locale as a flat link,
* ~20 of them wrapped over two rows. That was not a switcher, it was a wall, and it
* grew by one row every time a language was added.
*
* Text, not flags, in both. A flag is a country: Spanish is not Spain, Dutch is not the
* Netherlands, and English belongs to nobody in particular. Each language is written in
@@ -15,22 +20,24 @@
* Every link points at this same page in that language, built from the unprefixed path,
* so this works identically on a mint page: /mint/kashu.me and /es/mint/kashu.me are
* the same mint. There is no "switch to Spanish and land on the home page" case, which
* is the usual failure of a switcher that only knows about the site root.
* is the usual failure of a switcher that only knows about the site root. They are real
* `<a href>` elements, so a crawler follows them and a reader with no JavaScript uses
* them.
*
* The header shape is a `<details>` rather than a scripted dropdown. It opens, closes
* and takes the keyboard with no JavaScript at all, which matters because the whole
* point of this control is being able to leave a page you cannot read. The script below
* only adds what `<details>` does not do by itself: closing on Escape and on a click
* elsewhere.
* The control is a `<details>` rather than a scripted dropdown. It opens, closes and
* takes the keyboard with no JavaScript at all, which matters because the whole point
* of this control is being able to leave a page you cannot read. The script below only
* adds what `<details>` does not do by itself: closing on Escape and on a click
* elsewhere, and filtering the list as you type.
*/
import { LOCALES, localePath, useI18n } from '../i18n';
import { LOCALES, localePath, localeInfo, useI18n } from '../i18n';
import { pageLocale } from '../i18n/paths';
interface Props {
/** `menu` is the header control; `inline` is the footer row. */
variant?: 'menu' | 'inline';
/** `bar` is the header control; `footer` is the one in the Language column. */
placement?: 'bar' | 'footer';
}
const { variant = 'inline' } = Astro.props;
const { placement = 'bar' } = Astro.props;
const { locale, path } = pageLocale(Astro);
const t = useI18n(locale);
@@ -43,40 +50,71 @@ const links = LOCALES.map((other) => ({
}));
/*
* Two letters in the header, because the full name of the current language is a word
* the reader already knows they are reading. The code is the locale's own, uppercased,
* so a fourth language needs nothing added here.
* The header shows two letters, because the full name of the current language is a word
* the reader already knows they are reading, and the header row is full. The footer
* column has the width for the name and no adjacent nav to crowd, and a column headed
* "Language" reads better with "Nederlands" under it than with "NL".
*/
const currentCode = locale.toUpperCase();
const trigger = placement === 'footer' ? localeInfo(locale).label : locale.toUpperCase();
/*
* The search field earns its place at 20-odd languages and is noise at three. It is
* the one part of this control that needs JavaScript, so it is also the one part that
* is allowed to be absent: every link is in the panel either way.
*/
const searchable = LOCALES.length > 8;
---
{
variant === 'menu' ? (
<details class="lang-menu" data-lang-menu>
<details class={`lang-menu lang-${placement}`} data-lang-menu>
<summary aria-haspopup="true">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3a15 15 0 0 1 4 9 15 15 0 0 1-4 9 15 15 0 0 1-4-9 15 15 0 0 1 4-9Z" />
</svg>
{/* The code alone does not say what it is, so the label is in the tree for a
screen reader and out of the way for everyone else. */}
{/* The trigger label does not say what it is on its own, so the word is in the
accessibility tree and out of the way for everyone else. */}
<span class="sr-only">{t('lang.label')}</span>
<span class="lang-code">{currentCode}</span>
<span class="lang-current">{trigger}</span>
<svg class="lang-caret" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path d="m6 9 6 6 6-6" />
</svg>
</summary>
<div class="lang-panel">
{searchable && (
<div class="lang-search">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
<input
type="search"
placeholder={`${t('lang.label')}…`}
aria-label={t('lang.switcher')}
autocomplete="off"
spellcheck="false"
data-lang-search
/>
</div>
)}
<nav class="lang-list" aria-label={t('lang.switcher')}>
{
/*
* The current language is still a link, and it goes where it says it goes.
* `aria-current` is what tells a screen reader it is where you already are; the
* weight and the tick are what tell everyone else.
*/
links.map((other) => (
<a
href={other.href}
hreflang={other.code}
lang={other.code}
aria-current={other.current ? 'true' : undefined}
data-lang-option
data-search={`${other.label} ${other.code}`}
>
<span>{other.label}</span>
<span class="lang-option-code">{other.code.toUpperCase()}</span>
{other.current && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path d="m5 13 4 4L19 7" />
@@ -86,33 +124,10 @@ const currentCode = locale.toUpperCase();
))
}
</nav>
</details>
) : (
<nav class="lang-switch" aria-label={t('lang.switcher')}>
{
/*
* The current language is still a link, and it goes where it says it goes.
* `aria-current` is what tells a screen reader it is where you already are, and
* the weight is what tells everyone else.
*/
links.map((other) => (
<a
href={other.href}
hreflang={other.code}
lang={other.code}
aria-current={other.current ? 'true' : undefined}
>
{other.label}
</a>
))
}
</nav>
)
}
</div>
</details>
<style>
/* ---------- the header control ---------- */
.lang-menu { position: relative; flex-shrink: 0; }
.lang-menu summary {
@@ -129,28 +144,62 @@ const currentCode = locale.toUpperCase();
.lang-menu summary:hover { color: var(--text); background: var(--card2); }
.lang-menu[open] summary { color: var(--text); background: var(--card2); }
.lang-code { font-family: var(--mono); font-size: 12.5px; letter-spacing: .04em; }
.lang-caret { color: var(--faint); transition: transform var(--dur-quick) var(--ease-out); }
.lang-menu[open] .lang-caret { transform: rotate(180deg); }
/* The same panel the account menu uses, so the two controls beside each other in the
header open into the same shape. */
.lang-list {
position: absolute; top: calc(100% + 8px); right: 0; z-index: 60;
min-width: 168px;
.lang-panel {
position: absolute; z-index: 60;
width: min(260px, calc(100vw - 24px));
background: var(--card); border: 1px solid var(--line); border-radius: 12px;
padding: 6px; display: flex; flex-direction: column; gap: 2px;
padding: 6px;
box-shadow: 0 18px 40px rgba(0, 0, 0, .45);
}
.lang-search {
display: flex; align-items: center; gap: 8px;
min-height: 40px; padding: 0 10px;
color: var(--faint); background: var(--card2);
border: 1px solid var(--line); border-radius: 8px;
}
.lang-search:focus-within {
color: var(--text);
border-color: var(--amber);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--amber) 18%, transparent);
}
.lang-search input {
width: 100%; min-width: 0; padding: 9px 0;
border: 0; outline: 0; background: transparent;
color: var(--text); font: inherit; font-size: 13.5px;
}
.lang-search input::placeholder { color: var(--faint); opacity: 1; }
.lang-search input::-webkit-search-cancel-button { cursor: pointer; }
.lang-list {
max-height: min(360px, calc(100dvh - 150px));
display: flex; flex-direction: column; gap: 2px;
overflow-y: auto; overscroll-behavior: contain;
scrollbar-width: thin; scrollbar-color: var(--line) transparent;
}
.lang-search + .lang-list { margin-top: 6px; padding-right: 2px; }
.lang-list a {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; border-radius: 8px;
font-size: 13.5px; color: var(--muted);
transition: color .15s, background .15s;
}
.lang-list a[hidden] { display: none; }
.lang-list a:hover { color: var(--text); background: var(--card2); }
.lang-list a[aria-current] { color: var(--text); font-weight: 600; }
.lang-list a svg { margin-left: auto; color: var(--amber); flex-shrink: 0; }
.lang-option-code {
margin-left: auto; color: var(--faint);
font-family: var(--mono); font-size: 10.5px; letter-spacing: .04em;
}
.lang-list a svg { color: var(--amber); flex-shrink: 0; }
/* ---------- the header placement ---------- */
.lang-bar .lang-current { font-family: var(--mono); font-size: 12.5px; letter-spacing: .04em; }
.lang-bar .lang-panel { top: calc(100% + 8px); right: 0; }
/*
* Narrow: the globe alone.
@@ -162,40 +211,63 @@ const currentCode = locale.toUpperCase();
* accessible name stays either way, because it was never the visible text.
*/
@media (max-width: 760px) {
.lang-menu summary { padding: 7px 8px; gap: 0; }
.lang-code,
.lang-caret { display: none; }
.lang-bar summary { padding: 7px 8px; gap: 0; }
.lang-bar .lang-current,
.lang-bar .lang-caret { display: none; }
}
@media (max-width: 420px) {
.lang-menu summary { padding: 6px 6px; }
.lang-bar summary { padding: 6px 6px; }
}
/* ---------- the footer row ---------- */
/* ---------- the footer placement ---------- */
.lang-switch { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.lang-switch a {
font-size: 12.5px; color: var(--faint);
transition: color var(--dur-quick);
}
.lang-switch a:hover { color: var(--text); }
.lang-switch a[aria-current] { color: var(--text); font-weight: 600; }
/*
* Aligned with the column's other text rather than inset by the trigger's padding,
* so the globe sits on the same vertical line as the "Language" heading above it and
* the links in the columns beside it.
*/
.lang-footer { display: inline-block; margin-inline-start: -10px; }
.lang-footer summary { font-size: 13.5px; }
.lang-footer .lang-current { font-weight: 500; }
/*
* Opens upward. The control is a few hundred pixels from the bottom of the document,
* so a panel hanging below it would be a scroll into empty space at best and clipped
* at worst. `inset-inline-start: 0` rather than `left`, because two of the shipped
* locales are right-to-left and the panel should hang from the trigger's leading
* edge in both directions.
*/
.lang-footer .lang-panel { bottom: calc(100% + 8px); inset-inline-start: 0; }
.lang-footer .lang-list { max-height: min(300px, calc(100dvh - 200px)); }
</style>
<script>
import { onReady } from '../scripts/reveal';
/**
* The two things `<details>` does not do on its own.
* The three things `<details>` does not do on its own.
*
* Everything else about this control (open, close, keyboard, focus) is the element's
* own behaviour and works with this script blocked, which is the point of using one.
*
* Attached to the document once rather than to the element, because the view
* transition router replaces the header on every navigation and a per-element
* listener would be re-bound, and leak, once per page visited.
* transition router replaces the header and the footer on every navigation and a
* per-element listener would be re-bound, and leak, once per page visited. One
* listener set serves both placements.
*/
let wired = false;
const normalise = (value: string): string =>
value.normalize('NFKD').replace(/\p{Diacritic}/gu, '').toLocaleLowerCase().trim();
const resetSearch = (menu: HTMLDetailsElement): void => {
const input = menu.querySelector<HTMLInputElement>('[data-lang-search]');
if (input) input.value = '';
for (const option of menu.querySelectorAll<HTMLElement>('[data-lang-option]')) {
option.hidden = false;
}
};
const closeAll = (except?: Element | null): void => {
for (const menu of document.querySelectorAll<HTMLDetailsElement>('[data-lang-menu][open]')) {
if (menu !== except) menu.open = false;
@@ -221,5 +293,28 @@ const currentCode = locale.toUpperCase();
// Back to the control that opened it, not to the top of the document.
open.querySelector('summary')?.focus();
});
document.addEventListener('input', (event) => {
const input = (event.target as Element | null)?.closest<HTMLInputElement>('[data-lang-search]');
if (!input) return;
const menu = input.closest<HTMLDetailsElement>('[data-lang-menu]');
if (!menu) return;
const query = normalise(input.value);
for (const option of menu.querySelectorAll<HTMLElement>('[data-lang-option]')) {
option.hidden = !normalise(option.dataset.search ?? '').includes(query);
}
});
// `toggle` does not bubble, so capture it. Starting in the search field makes a
// long language list keyboard-friendly; clearing on close keeps every reopen useful.
document.addEventListener('toggle', (event) => {
const menu = event.target as HTMLDetailsElement;
if (!menu.matches?.('[data-lang-menu]')) return;
if (menu.open) {
requestAnimationFrame(() => menu.querySelector<HTMLInputElement>('[data-lang-search]')?.focus());
} else {
resetSearch(menu);
}
}, true);
});
</script>
+8
View File
@@ -103,6 +103,14 @@ const t = useI18n(locale);
<div class="login-qr" data-connect-qr></div>
{/*
The same pairing link the QR carries, as something to tap. A reader whose
signer app is on the device they are reading on cannot scan their own screen;
this hands the link straight to the app instead. The href is set when the
pairing starts: nostrconnect:// for any signer, primalconnect:// for Primal.
*/}
<a class="btn login-open-app" data-connect-open hidden></a>
<div class="login-uri">
<span class="mono" data-connect-uri></span>
<button type="button" class="copy-btn" data-connect-copy data-copy="" aria-label={t('login.copyLink')} hidden>
+65 -15
View File
@@ -1,6 +1,10 @@
---
import { getMintWarnings, mintChip, type MintCapabilities, type MintListItem } from '@cashumints/shared';
import {
baseUrlFromKey, federationIdFromSlug, getMintWarnings, mintChip,
type LnurlDetail, type MintCapabilities, type MintListItem,
} from '@cashumints/shared';
import { iconUrl } from '../lib/api';
import { targetPath } from '../lib/feed-resolve';
import { displayDomain, iconGradient, initials, transitionName } from '../lib/format';
import { localePath, useI18n } from '../i18n';
import { formatters, starString } from '../i18n/format';
@@ -25,18 +29,53 @@ interface Props {
* from the detail it already has. Left off, the card simply has no chip.
*/
capabilities?: MintCapabilities | null;
/**
* The LNURL states a chip can be drawn from, for a caller that already has the
* detail. The Cashu counterpart is `capabilities` above, and this is separate rather
* than merged with it because the two ecosystems' chips are read from entirely
* different facts: NUT switches there, an advertised withdraw ceiling and a reachable
* Lightning node here. Left off, an LNURL card simply has no chip.
*/
lnurl?: Pick<LnurlDetail, 'max_withdrawable_msat' | 'funding_available'> | null;
}
const { mint, rank, sentiment, revealDelay, capabilities } = Astro.props;
const { mint, rank, sentiment, revealDelay, capabilities, lnurl } = Astro.props;
const { locale } = pageLocale(Astro);
const t = useI18n(locale);
const f = formatters(t);
const domain = displayDomain(mint.url);
const name = mint.name ?? domain.split('/')[0] ?? domain;
/*
* One card, all three ecosystems.
*
* A federation has no URL, so the second line cannot be a domain: `fedimint:aeca6c…`
* is a database key and means nothing to a reader. It shows the federation id instead,
* shortened, which is the thing that actually identifies it — and which is what the
* search box below matches on, so pasting an id finds its federation.
*
* An LNURL mint does have a URL, but its row key carries an `lnurl:` scheme in front of
* it, so the domain comes out of the key rather than off it directly. A reader should
* see `lnurl.21mint.me`, not `lnurl:https://lnurl.21mint.me`.
*
* Everything else on the card is identical, and deliberately: rating, review count,
* sentiment bar and status all mean the same thing for all three, and a reader moving
* between the three indexes should not have to re-learn a card.
*/
const fedimint = mint.type === 'fedimint';
const lnurlBase = baseUrlFromKey(mint.url);
const domain = fedimint
? federationIdFromSlug(mint.host)
: displayDomain(lnurlBase ?? mint.url);
const name = fedimint
? mint.name ?? t('card.unnamedFederation')
: mint.name ?? domain.split('/')[0] ?? domain;
const icon = iconUrl(mint.icon);
const offline = mint.status === 'offline';
/** Announced on Nostr and never confirmed by any check. Federations only. */
const announced = mint.status === 'announced';
// One route per ecosystem, from the same table the review feed's links use, so a card
// and a feed row can never disagree about where a listing lives.
const href = localePath(targetPath(mint), locale);
/*
Two words at most. Offline already has its own card styling, so only the states a
@@ -44,14 +83,18 @@ const offline = mint.status === 'offline';
cannot pay back out, or one that has stopped moving sats in either direction. The
mint page carries the explanation.
*/
const chip = capabilities
? mintChip(
getMintWarnings(
{ status: mint.status, last_online: mint.last_online, capabilities },
warningOptions(f),
),
chipStrings(t),
)
const chipSource = capabilities || lnurl
? {
type: mint.type,
status: mint.status,
last_online: mint.last_online,
capabilities: capabilities ?? null,
...(lnurl ?? {}),
}
: null;
const chip = chipSource
? mintChip(getMintWarnings(chipSource, warningOptions(f)), chipStrings(t))
: null;
const status = statusLabel(mint.status, t);
@@ -87,7 +130,7 @@ const bar =
*/}
<a
class:list={['mint-card', { 'is-offline': offline }]}
href={localePath(`/mint/${mint.host}`, locale)}
href={href}
data-reveal
data-reveal-delay={revealDelay}
data-mint-card
@@ -126,7 +169,7 @@ const bar =
}
<span class="mc-id">
<span class="mc-name" data-vt-name={transitionName('name', mint.host)}>{name}</span>
<span class="mc-domain">{domain}</span>
<span class:list={['mc-domain', { mono: fedimint }]}>{domain}</span>
</span>
{rank !== undefined && <span class:list={['mc-rank', { gold: rank === 1 }]}>#{rank}</span>}
</div>
@@ -164,7 +207,14 @@ const bar =
{chip && <span class:list={['mc-chip', chip.severity]}>{chip.label}</span>}
<span class="last">
{
offline
/*
"Announced" has no "last seen" to print, and printing one anyway is exactly
the fake status this site refuses to show. What it has instead is the fact
that nothing has checked it, said in as many words.
*/
announced
? t('card.notChecked')
: offline
? mint.last_online
? t('card.lastSeen', { when: f.relative(mint.last_online) })
: t('card.neverSeen')
+78 -7
View File
@@ -10,6 +10,13 @@
* The banner, the limits cell and the NUT rows are rebuilt from lib/mint-state.ts,
* the same code that prerendered them, so a live upgrade can never leave the three
* disagreeing with each other.
*
* All three ecosystems' pages mount this, unchanged. It fetches the same endpoint for
* every one of them and updates whichever regions the page actually has: a federation
* has no limits cell and no NUT rows, an LNURL mint has features rather than NUTs and
* withdraw bounds rather than mint limits, and a Cashu mint has neither of the other
* two panels — so each lookup simply misses. `getMintWarnings` branches on the `type` in
* the payload, so a fresh read can never put one ecosystem's banner on another's page.
*/
interface Props {
host: string;
@@ -20,14 +27,18 @@ const { host } = Astro.props;
<span data-mint-live={host} hidden></span>
<script>
import { getMintWarnings, readCapabilities, type MintWarning } from '@cashumints/shared';
import {
displayFeatures, getMintWarnings, readCapabilities, type MintWarning,
} from '@cashumints/shared';
import { apiBase } from '../lib/client';
import { bannerHtml, limitsHtml, nutRowsHtml } from '../lib/mint-state';
import { moduleRowsHtml } from '../lib/fedimint-ui';
import { featureRowsHtml, withdrawLimitsHtml } from '../lib/lnurl-ui';
import { useI18n } from '../i18n/client';
import { formatters, type Formatters } from '../i18n/format';
import { statusLabel, warningOptions } from '../i18n/mint';
import { onReady, prefersReducedMotion } from '../scripts/reveal';
import type { MintDetail } from '@cashumints/shared';
import type { LnurlDetail, MintDetail } from '@cashumints/shared';
function remove(el: Element): void {
el.classList.add('leaving');
@@ -77,8 +88,49 @@ const { host } = Astro.props;
else head.after(banner);
}
/** Keep the two places that repeat the banner's facts in step with it. */
function renderCapabilities(mint: MintDetail, f: Formatters): void {
/** Keep the places that repeat the banner's facts in step with it. */
function renderCapabilities(mint: MintDetail & Partial<LnurlDetail>, f: Formatters): void {
/*
* A federation's modules panel, rebuilt from a fresh announcement. Handled before
* the NUT work rather than after it because the two are mutually exclusive: the
* element only exists on a federation page, and `readCapabilities` on a payload
* with no `info` would be reading NUT switches that federations do not have.
*/
const modules = document.querySelector<HTMLElement>('[data-live-modules]');
if (modules) {
modules.innerHTML = moduleRowsHtml(mint.modules ?? [], f);
return;
}
/*
* An LNURL mint's features and withdraw bounds, from a fresh probe. Handled here
* for the same reason the modules panel is, and with the same early return: these
* elements only exist on an LNURL page, and falling through to `readCapabilities`
* on a payload with no `info` would be reading NUT switches this ecosystem has none
* of.
*
* The limits cell is rebuilt inside this branch rather than left to the shared code
* below, because the two cells hold different numbers — withdraw bounds here, NUT-04
* mint limits there — and the selector is the same.
*/
const featureRows = document.querySelector<HTMLElement>('[data-live-features]');
if (featureRows) {
featureRows.innerHTML = featureRowsHtml(
displayFeatures(mint.features, mint.observed_features),
mint.funding_available ?? null,
f,
);
const lnurlLimits = document.querySelector<HTMLElement>('[data-live-limits]');
if (lnurlLimits) {
lnurlLimits.innerHTML = withdrawLimitsHtml(
mint.min_withdrawable_msat,
mint.max_withdrawable_msat,
f,
);
}
return;
}
const caps = readCapabilities(mint.info?.nuts);
const limits = document.querySelector<HTMLElement>('[data-live-limits]');
@@ -112,7 +164,7 @@ const { host } = Astro.props;
const chip = document.querySelector<HTMLElement>('[data-live-status]');
const label = document.querySelector<HTMLElement>('[data-live-status-label]');
if (chip && label) {
chip.classList.remove('online', 'offline', 'degraded', 'unknown');
chip.classList.remove('online', 'offline', 'degraded', 'unknown', 'announced');
chip.classList.add(mint.status);
label.textContent = statusLabel(mint.status, t);
@@ -127,10 +179,29 @@ const { host } = Astro.props;
renderBanner(mint, f);
renderCapabilities(mint, f);
/*
* The "checked" line, on a mint page only.
*
* A federation's equivalent cell says who confirmed it as well as when, in two
* elements rather than one string, and is left to the page: rewriting it from
* here would mean this island owning a second wording it cannot see.
*/
const checked = document.querySelector<HTMLElement>('[data-live-checked]');
if (checked) {
if (checked && mint.type !== 'fedimint') {
/*
* The "up" wording differs by whether a version was discoverable, and only
* LNURL routinely has none: its version comes solely from `/openapi.json`,
* which an operator can turn off, so that cell falls back to "last confirmed"
* rather than printing "unknown" above a date. A Cashu mint has `version` set
* from `/v1/info` in every real case and takes the same branch it always did.
*/
const up =
mint.version
? t('mint.checked', { when: f.relative(mint.last_probe) })
: t('lnurl.confirmed.when', { when: f.relative(mint.last_probe) });
checked.textContent =
mint.status !== 'offline' ? t('mint.checked', { when: f.relative(mint.last_probe) })
mint.status !== 'offline' ? up
: mint.last_online ? t('mint.lastSeen', { when: f.relative(mint.last_online) })
: t('mint.neverReached');
}
+455
View File
@@ -0,0 +1,455 @@
---
import Base from '../layouts/Base.astro';
import Moai from './Moai.astro';
import Reviews from './Reviews.astro';
import ReviewByUrl from './ReviewByUrl.astro';
import { localePath, useI18n, type Locale } from '../i18n';
/**
* The 404, as a component rather than a page, because it is reached two ways.
*
* `pages/404.astro` emits the English one at `/404`, which is also the route Astro's
* dev server and a static host look for by name; `pages/[...locale]/404.astro` emits
* the prefixed ones. Neither can import the other's template, so the page itself
* lives here and both are three lines.
*
* Also the resolver for a mint this build has never heard of, which is most of what
* the file now does.
*
* It began as a fallback for a mint discovered *between builds*: a GET against the API
* for the host in the URL, and a summary rendered from whatever came back. That covered
* the gap between discovery and the next rebuild and nothing else — a mint nobody had
* announced on Nostr was not in the index either, so the lookup missed and the reader
* got a 404 for a mint that was live and answering. `/lnurl-mint/mint.600.wtf` was the
* case that made it obvious.
*
* So the lookup became a resolver, for all three route shapes:
*
* 1. the API is asked whether it knows the host (unchanged, and the common case);
* 2. on a miss, the address is derived from the path and `POST /api/index` checks it
* live — one probe, and a Nostr lookup if nothing answers;
* 3. whatever comes back renders the page shell *and* the reviews panel, so a mint
* indexed a second ago is readable and reviewable immediately;
* 4. and every way that can fail says what happened in plain words, with the
* review-by-URL dialog offered so a mistyped address can be corrected on the spot.
*
* A federation is the one shape that cannot be resolved from its own URL: `/fedimint/`
* carries a shortened federation id, and an invite code cannot be derived from one. That
* link goes straight to step 4's not-found state, with the dialog offered for the code.
*/
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const t = useI18n(locale);
---
<Base
title={t('notfound.title')}
description={t('notfound.description')}
current="mints"
offGraph
>
<div class="page">
<div class="notfound" data-notfound>
{/* 44px, not a hero graphic: DESIGN.md says the moai is never scaled large. */}
<span class="nf-moai"><Moai size={44} /></span>
<h1>{t('notfound.heading')}</h1>
<p class="lede">{t('notfound.lede')}</p>
<form class="search nf-search" action={localePath('/mints', locale)} method="get" role="search">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
</svg>
<label class="sr-only" for="nf-search">{t('home.search.label')}</label>
<input
id="nf-search"
name="q"
type="text"
autocomplete="off"
placeholder={t('home.search.placeholder')}
data-search-input
/>
<kbd aria-hidden="true">/</kbd>
</form>
<div class="nf-actions">
<a class="btn primary" href={localePath('/mints', locale)}>{t('notfound.browse')}</a>
<a class="btn" href={localePath('/', locale)}>{t('notfound.home')}</a>
</div>
</div>
{/*
Filled in by the resolver below when the URL looks like a mint page. Nothing
here is prerendered, so this is the one place on the site that gets a page
level skeleton. `data-boneyard` marks the capture target for `pnpm bones`.
*/}
<div class="mint-fallback" data-mint-fallback hidden>
{/*
One line, in words, above the skeleton: this page is doing something unusual —
checking a stranger's mint over the network — and a screenful of bones with no
explanation reads as a page that is merely slow. Outside the capture target on
purpose, so it is not measured into the skeleton it introduces.
*/}
<p class="fb-checking" data-nf-status hidden></p>
<div data-boneyard="mint-page" data-boneyard-config='{"excludeSelectors":[".sr-only"]}'><div data-mint-fallback-body></div></div>
{/*
The real reviews panel, with no subject until one is resolved.
It is the same island every mint page runs, so a mint resolved here gets the
same list, the same filters and the same write dialog rather than a lesser
version of them — which is the difference between "this mint is reachable" and
"this mint is reviewable", and the second is the point of the whole feature.
The island sits out its first run with no subject and starts when the resolver
dispatches `cashumints:subject`.
*/}
<div class="nf-reviews" data-nf-reviews hidden>
<Reviews subject={null} reviewCount={0} distribution={{ '1': 0, '2': 0, '3': 0, '4': 0, '5': 0 }} name="" />
</div>
</div>
</div>
{/*
The same dialog the index pages carry. Here it is the way out of every dead end:
a wrong ecosystem, an address with a typo in it, or a federation link whose invite
code only the reader has. Its trigger names which ecosystem to ask about.
*/}
<ReviewByUrl type="cashu" />
</Base>
<style>
.notfound { padding: 54px 0 20px; max-width: 620px; }
.nf-moai { display: block; margin-bottom: 18px; }
.notfound h1 { font-family: var(--display); font-size: 34px; font-weight: 700; letter-spacing: -.02em; }
.nf-actions { display: flex; gap: 12px; margin-top: 22px; flex-wrap: wrap; }
/* Same search control as the home page hero, so a miss lands somewhere useful. */
.nf-search {
display: flex; align-items: center; gap: 12px; margin-top: 24px;
background: var(--card); border: 1px solid var(--line);
border-radius: 14px; padding: 14px 16px;
transition: border-color .15s, box-shadow .15s;
}
.nf-search:focus-within { border-color: var(--amber-dim); box-shadow: 0 0 0 3px rgba(240, 169, 59, .08); }
.nf-search > svg { color: var(--faint); flex-shrink: 0; }
.nf-search input {
flex: 1; background: none; border: none; outline: none; min-width: 0;
color: var(--text); font-family: var(--body); font-size: 15.5px;
}
.nf-search input::placeholder { color: var(--faint); }
.nf-search kbd {
font-family: var(--mono); font-size: 11.5px; color: var(--faint);
border: 1px solid var(--line); border-radius: 6px; padding: 2px 7px; flex-shrink: 0;
}
/* The .fb-* rules live in global.css: this page builds that markup with
innerHTML, and Astro's scoped selectors do not reach injected elements. */
.mint-fallback { padding: 20px 0 40px; }
/* The one line above the skeleton while a mint is being checked. */
.fb-checking {
font-size: 13.5px; color: var(--muted); margin-bottom: 16px;
display: flex; align-items: center; gap: 9px;
}
/* `display: flex` beats the user agent's `[hidden] { display: none }`, so an author
rule has to take it back or the line (and its pulsing dot) never goes away. */
.fb-checking[hidden] { display: none; }
.fb-checking::before {
content: ''; width: 7px; height: 7px; border-radius: 50%;
background: var(--amber); flex-shrink: 0;
animation: soft-pulse 1.4s var(--ease-in-out) infinite;
}
</style>
<script>
import { apiBase, escapeHtml, iconGradient } from '../lib/client';
import { armBuildSnapshot, clearBones, isBuildMode, showBones, swapBones } from '../lib/skeleton';
import { useI18n } from '../i18n/client';
import { formatters, starString, type Formatters } from '../i18n/format';
import { statusLabel, warningOptions } from '../i18n/mint';
import { bannerHtml } from '../lib/mint-state';
import { splitLocale } from '../i18n/routing';
import { onReady } from '../scripts/reveal';
import { indexErrorMessage, mintDisplayName, requestIndex, subjectFor } from '../lib/index-client';
import mintPageBones from '../bones/mint-page.bones.json';
import {
addressFromSlug, getMintWarnings, typeForPath,
type IndexFailure, type IndexType, type MintDetail,
} from '@cashumints/shared';
/**
* The summary this page can build from the API alone. `pnpm bones` measures the
* output of this same function, so the bones are the shape of what actually
* arrives rather than of a fuller page that never does.
*/
function fallbackHtml(mint: MintDetail, f: Formatters): string {
const domain = mint.url.replace(/^lnurl:/, '').replace(/^https?:\/\//, '');
const name = mint.name ?? domain.split('/')[0] ?? domain;
/*
* The one warning the mint has earned, drawn by the same function the mint pages
* use. It matters most on exactly the mint this resolver exists for: one indexed
* from a Nostr announcement because nothing answered at its address is, by
* definition, a mint whose page opens with "likely gone" — and a status chip alone
* does not say that.
*/
const warning = bannerHtml(getMintWarnings(mint, warningOptions(f))[0] ?? null);
return `
${warning}
<div class="fb-head">
<span class="mc-icon" style="background:${iconGradient(domain)};width:52px;height:52px;border-radius:13px;font-size:21px">${escapeHtml(
(name[0] ?? '?').toUpperCase(),
)}</span>
<span>
<span class="fb-name">${escapeHtml(name)}</span>
<span class="fb-domain">${escapeHtml(domain)}</span>
</span>
<span class="status-chip ${escapeHtml(mint.status)}"><span class="status-dot"></span>${escapeHtml(
statusLabel(mint.status, f.t),
)}</span>
</div>
<div class="fb-stats">
<span class="fb-stat"><span class="k">${escapeHtml(
f.t('notfound.fb.rating'),
)}</span><span class="v">${
mint.rating_avg === null ? f.t('time.none') : f.decimal(mint.rating_avg)
} <span class="stars" style="font-size:14px">${starString(mint.rating_avg)}</span></span></span>
<span class="fb-stat"><span class="k">${escapeHtml(
f.t('notfound.fb.reviews'),
)}</span><span class="v">${f.number(mint.review_count)}</span></span>
<span class="fb-stat"><span class="k">${escapeHtml(
f.t('notfound.fb.software'),
)}</span><span class="v" style="font-family:var(--mono);font-size:16px">${escapeHtml(
mint.version ?? f.t('mint.software.unknown'),
)}</span></span>
<span class="fb-stat"><span class="k">${escapeHtml(
f.t('notfound.fb.lastOnline'),
)}</span><span class="v" style="font-size:16px">${escapeHtml(
f.relative(mint.last_online),
)}</span></span>
</div>
<p class="fb-note">${escapeHtml(f.t('notfound.fb.note'))}</p>
<div class="fb-actions">
<button type="button" class="btn primary" data-write-open>${escapeHtml(
f.t('reviews.byUrl.open'),
)}</button>
</div>`;
}
const setup = (): void => {
const f = formatters(useI18n());
const t = f.t;
/*
* The miss could be at /mint/x or at /es/mint/x, and both resolve to the same mint.
* The prefix comes off with the same helper the build routes with, so the two can
* never disagree about what counts as a locale segment.
*/
const { path } = splitLocale(window.location.pathname);
const target = typeForPath(path);
const box = document.querySelector<HTMLElement>('[data-mint-fallback]');
const body = document.querySelector<HTMLElement>('[data-mint-fallback-body]');
const status = document.querySelector<HTMLElement>('[data-nf-status]');
const notfound = document.querySelector<HTMLElement>('[data-notfound]');
const reviewsBox = document.querySelector<HTMLElement>('[data-nf-reviews]');
const panel = document.querySelector<HTMLElement>('[data-reviews-panel]');
if (!box || !body) return;
if (isBuildMode()) {
// `pnpm bones` is measuring this page. Show a representative mint so the capture
// has a real summary to measure instead of an empty box. Imported dynamically so
// the fixture never reaches a real visitor.
void armBuildSnapshot()
.then(() => import('../lib/skeleton-fixtures'))
.then(({ FIXTURE_MINT }) => {
if (notfound) notfound.hidden = true;
box.hidden = false;
body.innerHTML = fallbackHtml(FIXTURE_MINT, f);
});
return;
}
if (!target) return; // An ordinary 404. The page above is the whole answer.
/**
* Hand the resolved mint to the reviews panel and let it run.
*
* The panel is the same island a mint page uses and it reads its subject off its own
* root, so "starting" it is writing the subject there and telling it to look again.
* Nothing about the panel is special-cased for this page.
*/
function startReviews(mint: MintDetail): void {
const subject = subjectFor(mint);
if (!panel || !subject || !reviewsBox) return;
panel.dataset['subject'] = JSON.stringify(subject);
const list = panel.querySelector<HTMLElement>('[data-review-list]');
if (list) list.dataset['reviewCount'] = String(mint.review_count);
// The write dialog's heading names the mint, and until now there was no mint to
// name. Same sentence the prerendered pages carry.
const title = document.querySelector<HTMLElement>('#write-title');
if (title) title.textContent = t('reviews.dialog.title', { name: mintDisplayName(mint) });
/*
* And the prompt in the box, which is the one line in that dialog that cannot be
* ecosystem-neutral: "how did minting and melting work out" means nothing about a
* federation. The panel picks it at build time from its subject, and this page had
* none, so it defaulted to the Cashu wording. Same key, same fallback rule.
*/
const prompt = document.querySelector<HTMLTextAreaElement>('[data-write-dialog] textarea');
if (prompt) {
const key = `reviews.dialog.bodyPlaceholder.${mint.type}`;
prompt.placeholder = t.has(key) ? t(key) : t('reviews.dialog.bodyPlaceholder.cashu');
}
reviewsBox.hidden = false;
document.dispatchEvent(new CustomEvent('cashumints:subject'));
}
/**
* The tab title for a resolved mint.
*
* One key per ecosystem rather than the Cashu one for all three: a federation's tab
* reading "Cashu mint reviews" is a small, flatly wrong sentence in the one place a
* reader keeps a page open under.
*/
function titleFor(mint: MintDetail): string {
const name = mintDisplayName(mint);
if (mint.type === 'fedimint') return t('mint.titleFedimint', { name });
if (mint.type === 'lnurl') return t('mint.titleLnurl', { name });
return t('mint.title', { name });
}
/** Render the page shell for a mint the site now has. */
function renderMint(mint: MintDetail): void {
if (status) status.hidden = true;
swapBones(body!, (slot) => {
slot.innerHTML = fallbackHtml(mint, f);
});
document.title = titleFor(mint);
startReviews(mint);
}
/**
* The honest dead end.
*
* Not the plain 404: the reader asked about a specific address and this says what
* happened to it, in the words the failure deserves, with the two ways forward that
* actually exist — correct the address, or open it as the ecosystem it turned out to
* belong to.
*/
function renderFailure(message: string, options: { detected?: IndexType; prefill?: string } = {}): void {
if (status) status.hidden = true;
clearBones(body!);
box!.hidden = true;
if (reviewsBox) reviewsBox.hidden = true;
if (!notfound) return;
notfound.hidden = false;
const lede = notfound.querySelector<HTMLElement>('.lede');
if (lede) lede.textContent = message;
const heading = notfound.querySelector<HTMLElement>('h1');
if (heading) {
// A federation is not a mint, and this heading is the one line that names it.
heading.textContent =
target!.type === 'fedimint'
? t('notfound.resolve.failedFedimint')
: t('notfound.resolve.failed');
}
const actions = notfound.querySelector<HTMLElement>('.nf-actions');
if (!actions || actions.querySelector('[data-review-url-open]')) return;
/*
* The way out, as the first action. A reader who typed one character wrong is one
* dialog away from their mint's page; a reader whose address belongs to another
* ecosystem is one button away from the right one.
*/
const kind = options.detected ?? target!.type;
const label = options.detected
? t('notfound.resolve.switch', { kind: t(`feed.ecosystem.${options.detected}`) })
: t('notfound.resolve.retry');
const button = document.createElement('button');
button.type = 'button';
button.className = 'btn primary';
button.dataset['reviewUrlOpen'] = kind;
if (options.prefill) button.dataset['reviewUrlValue'] = options.prefill;
button.textContent = label;
actions.prepend(button);
// The dialog's own island bound its triggers when the page loaded, and this button
// did not exist then.
button.addEventListener('click', () => {
document.querySelector<HTMLElement>('[data-review-url-dialog]')?.dispatchEvent(
new CustomEvent('cashumints:open', { detail: { type: kind, value: options.prefill ?? '' } }),
);
});
}
// The "page not found" copy would be wrong if this mint turns out to exist, so hold
// it back while we find out rather than flashing it and taking it away again.
if (notfound) notfound.hidden = true;
box.hidden = false;
if (status) {
status.textContent = t('notfound.resolve.checking');
status.hidden = false;
}
// The same sentence goes to `showBones` as its screen reader status line, so both
// kinds of reader are told the same thing at the same moment.
showBones(body, mintPageBones, { label: t('notfound.resolve.checking') });
void (async () => {
// 1. Does the index already know it? Much the commonest case: a mint discovered
// since the last build, whose row exists and whose page does not.
try {
const res = await fetch(`${apiBase}/api/mints/${encodeURIComponent(target.host)}`);
if (res.ok) {
renderMint((await res.json()) as MintDetail);
return;
}
} catch {
// The API is unreachable. Indexing would fail for the same reason; fall through
// to the failure state, which says so.
}
/*
* 2. Two shapes cannot be turned back into an address.
*
* A federation's page address carries a shortened id, and an invite code cannot be
* derived from one. A slug with a path in it (`mint.example.com-bitcoin`) is
* ambiguous by construction — the hyphen may be a path separator or part of a
* hostname — and guessing would mean fetching, and possibly indexing, the wrong
* host. Both say so and offer the dialog, which is where an address the reader
* actually has can go.
*/
const address = target.type === 'fedimint' ? null : addressFromSlug(target.host);
if (!address) {
renderFailure(
target.type === 'fedimint' ? t('notfound.resolve.fedimint') : t('notfound.resolve.ambiguous'),
);
return;
}
// 3. Check it live and index it if it is real.
const result = await requestIndex(target.type, address);
if (result.ok) {
renderMint(result.mint);
return;
}
const failure: IndexFailure = result.failure;
renderFailure(indexErrorMessage(result, t), {
...(failure.detected_type ? { detected: failure.detected_type } : {}),
prefill: address,
});
})();
};
onReady(setup);
</script>
+492
View File
@@ -0,0 +1,492 @@
---
/**
* Island: review a mint by pasting its address.
*
* The index pages list what this site knows. Until now, reviewing something it did not
* know was impossible from here — the only way in was a mint page, and a mint with no
* page could not be reached. This is the way in: one dialog, two steps, on /mints,
* /fedimints and /lnurl-mints alike.
*
* 1. **Identify.** One field, labelled for this page's ecosystem, with a format check
* that runs before any request. Submitting it calls `POST /api/index`, which either
* finds the mint already indexed, checks it live and indexes it, tells us it is a
* *different* ecosystem's mint (with a one-click switch), or explains why not.
* 2. **Review.** The site's ordinary write form — same stars, same identity row, same
* publisher — pointed at whatever step 1 resolved.
*
* One `<dialog>` for both, because it is one intention: the reader came here to write a
* review and the address is the first question, not a separate errand. The steps
* cross-fade in place rather than the dialog closing and reopening.
*
* The trigger lives on the page rather than in here: it belongs in the index head row,
* beside the heading, and the dialog belongs at the end of the document. Anything with
* `data-review-url-open` opens this.
*/
import WriteForm from './WriteForm.astro';
import { useI18n, type Locale } from '../i18n';
import { pageLocale } from '../i18n/paths';
import type { IndexType } from '@cashumints/shared';
interface Props {
/** Which ecosystem this page lists, and therefore what the dialog opens asking for. */
type: IndexType;
}
const { type } = Astro.props;
const { locale } = pageLocale(Astro) as { locale: Locale };
const t = useI18n(locale);
/*
* The label, the invitation and the two review prompts all differ per ecosystem, and
* all of them fall back to the Cashu wording for a type whose own string has not been
* written — the same rule the reviews panel already applies to its placeholder.
*/
const wordsFor = (
kind: IndexType,
): { label: string; placeholder: string; lede: string; body: string } => {
const key = (base: string): string =>
t.has(`${base}.${kind}`) ? `${base}.${kind}` : `${base}.cashu`;
return {
label: t(key('reviews.byUrl.label')),
placeholder: t(key('reviews.byUrl.placeholder')),
lede: t(key('reviews.dialog.lede')),
body: t(key('reviews.dialog.bodyPlaceholder')),
};
};
/*
* All three ecosystems' wording travels with the dialog, not just this page's.
*
* An index page only ever asks about its own, but the 404 page carries one of these for
* whichever deep link missed — and a wrong-type answer can move a submission from one
* ecosystem to another mid-dialog, at which point "Mint URL" is the wrong label for the
* box the reader is looking at. Three short strings in an attribute is a smaller price
* than a dialog per ecosystem or a second request for a label.
*/
const words = {
cashu: wordsFor('cashu'),
fedimint: wordsFor('fedimint'),
lnurl: wordsFor('lnurl'),
};
const initial = words[type] ?? words.cashu;
---
<dialog
class="modal rbu-dialog"
data-review-url-dialog
data-rbu-type={type}
data-rbu-words={JSON.stringify(words)}
aria-labelledby="rbu-title"
>
{/*
Both steps live in the same box and only one is ever in the flow, so the dialog is
the height of whichever step is showing and the backdrop never jumps around a
half-rendered second step.
*/}
<div class="rbu-steps" data-rbu-steps>
<form class="modal-body rbu-step" data-rbu-identify novalidate>
<div class="modal-head">
<h2 id="rbu-title">{t('reviews.byUrl.title')}</h2>
<button type="button" class="copy-btn" data-rbu-close aria-label={t('common.close')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
<p class="modal-note">{t('reviews.byUrl.lede')}</p>
<label class="write-label" for="rbu-input" data-rbu-label>{initial.label}</label>
<input
id="rbu-input"
class="rbu-input"
data-rbu-input
type="text"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
placeholder={initial.placeholder}
aria-describedby="rbu-error"
/>
<p class="write-error" id="rbu-error" data-rbu-error hidden role="alert"></p>
{/* Filled by the island when the API says "that is the other ecosystem's mint". */}
<p class="rbu-switch" data-rbu-switch hidden></p>
<div class="write-actions">
<button type="button" class="btn" data-rbu-close>{t('common.cancel')}</button>
<button type="submit" class="btn primary" data-rbu-check>
<span class="write-spinner" aria-hidden="true"></span>
<span data-rbu-check-label>{t('reviews.byUrl.check')}</span>
</button>
</div>
</form>
<div class="rbu-step" data-rbu-review hidden>
<WriteForm locale={locale} lede={initial.lede} placeholder={initial.body} idPrefix="rbu">
<div class="modal-head" slot="head">
<h2 data-rbu-review-title>{t('reviews.byUrl.title')}</h2>
<button type="button" class="copy-btn" data-write-close aria-label={t('common.close')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
<button type="button" class="rbu-back" slot="head" data-rbu-back>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true">
<path d="m15 18-6-6 6-6" />
</svg>
{t('reviews.byUrl.back')}
</button>
{/*
The mint the reader is about to review, drawn from the payload the API just
returned. It is the confirmation that the right thing was found — a domain and
a status chip say more than a name alone — and it is why the second step does
not need to repeat the address in its heading.
*/}
<div class="rbu-preview" slot="head" data-rbu-preview></div>
</WriteForm>
</div>
</div>
</dialog>
<style>
/* The two steps share one box; only the one in the flow is measured. */
.rbu-steps { position: relative; }
/*
* `hidden` alone is not enough on the first step: it carries `.modal-body`, whose
* `display: flex` beats the user agent's `[hidden] { display: none }` and left both
* steps stacked in the dialog at once. An author rule has to take it back.
*/
.rbu-step[hidden] { display: none; }
.rbu-step { transition: opacity 140ms var(--ease-out), transform 140ms var(--ease-out); }
/* Leaving to the left, arriving from the right: the reader is moving forwards. */
.rbu-step.is-leaving { opacity: 0; transform: translateX(-10px); }
.rbu-step.is-entering { opacity: 0; transform: translateX(10px); }
/* Going back reverses it, so the motion always matches the direction of travel. */
.rbu-step.is-leaving-back { opacity: 0; transform: translateX(10px); }
.rbu-step.is-entering-back { opacity: 0; transform: translateX(-10px); }
@media (prefers-reduced-motion: reduce) {
.rbu-step { transition: none; }
.rbu-step.is-leaving, .rbu-step.is-entering,
.rbu-step.is-leaving-back, .rbu-step.is-entering-back { opacity: 1; transform: none; }
}
</style>
<script>
import { escapeHtml, iconGradient } from '../lib/client';
import { useI18n } from '../i18n/client';
import { statusLabel } from '../i18n/mint';
import { localePath, splitLocale } from '../i18n/routing';
import { onReady, prefersReducedMotion } from '../scripts/reveal';
import { closeModal, openModal } from '../lib/modal';
import { indexErrorMessage, mintDisplayName, displayAddress, requestIndex, subjectFor } from '../lib/index-client';
import { rememberFreshReview, wireWriteForm } from '../lib/write-review';
import { checkIndexInput, mintPath, type IndexSuccess, type IndexType } from '@cashumints/shared';
import type { ReviewSubject } from '../lib/review-subject';
const setup = (): void => {
const dialog = document.querySelector<HTMLDialogElement>('[data-review-url-dialog]');
if (!dialog) return;
const t = useI18n();
const identify = dialog.querySelector<HTMLFormElement>('[data-rbu-identify]')!;
const reviewStep = dialog.querySelector<HTMLElement>('[data-rbu-review]')!;
const input = dialog.querySelector<HTMLInputElement>('[data-rbu-input]')!;
const errorBox = dialog.querySelector<HTMLElement>('[data-rbu-error]')!;
const switchBox = dialog.querySelector<HTMLElement>('[data-rbu-switch]')!;
const checkButton = dialog.querySelector<HTMLButtonElement>('[data-rbu-check]')!;
const checkLabel = dialog.querySelector<HTMLElement>('[data-rbu-check-label]')!;
const preview = dialog.querySelector<HTMLElement>('[data-rbu-preview]')!;
const reviewTitle = dialog.querySelector<HTMLElement>('[data-rbu-review-title]')!;
const label = dialog.querySelector<HTMLElement>('[data-rbu-label]')!;
const ledeBox = dialog.querySelector<HTMLElement>('[data-write-lede]')!;
const bodyBox = dialog.querySelector<HTMLTextAreaElement>('textarea[name="content"]')!;
/**
* The wording for each ecosystem, carried in the markup.
*
* Read defensively: a payload that will not parse is a build bug, and a dialog that
* threw here would take the whole review flow down rather than showing a slightly
* generic label.
*/
const words = ((): Record<
string,
{ label: string; placeholder: string; lede: string; body: string }
> => {
try {
return JSON.parse(dialog.dataset['rbuWords'] ?? '{}') as Record<
string,
{ label: string; placeholder: string; lede: string; body: string }
>;
} catch {
return {};
}
})();
/**
* What is being asked for right now.
*
* Starts as the page's own ecosystem, and changes in two places: a trigger that
* names one (the 404 page, whose dialog serves whichever deep link missed), and the
* one-click switch after a wrong-type answer.
*/
let pageType = (dialog.dataset['rbuType'] ?? 'cashu') as IndexType;
function setType(next: IndexType): void {
pageType = next;
const wording = words[next];
if (!wording) return;
label.textContent = wording.label;
input.placeholder = wording.placeholder;
ledeBox.textContent = wording.lede;
bodyBox.placeholder = wording.body;
}
/** What step 1 resolved, and what step 2 publishes against. */
let resolved: { mint: IndexSuccess; subject: ReviewSubject } | null = null;
/** True while a submission is in flight, so nothing runs over it. */
let checking = false;
/* ---------- step 2: the ordinary write form ---------- */
const writer = wireWriteForm({
dialog,
subject: () => resolved?.subject ?? null,
onBackdrop: () => input.value.trim() === '' || window.confirm(t('reviews.byUrl.confirmClose')),
onPublished: (published) => {
const mint = resolved?.mint;
if (!mint) return;
/*
* The review is on the relays; the page that shows it is somewhere else. Hand it
* forward through sessionStorage and go there: the reviews panel picks it up on
* arrival and renders it at the top, exactly as it would have if the review had
* been written on that page. Without this, an author would land on their own
* mint's page and not see what they just wrote until the relays echoed it back.
*/
rememberFreshReview(published);
const { locale } = splitLocale(window.location.pathname);
window.location.href = localePath(mintPath(mint.type, mint.host), locale);
},
});
/* ---------- moving between the steps ---------- */
/**
* Cross-fade to the other step, and hand back the moment it is in the flow.
*
* `onShown` is where anything that focuses belongs: a hidden element cannot take
* focus, so setting up the incoming step before this callback runs would put the
* cursor on the document body and leave the reader pressing Tab to find the form.
* It runs before the pane is painted, so the step is never seen half-arranged.
*/
function showStep(next: 'identify' | 'review', onShown?: () => void): void {
const from = next === 'review' ? identify : reviewStep;
const to = next === 'review' ? reviewStep : identify;
const back = next === 'identify';
if (prefersReducedMotion()) {
from.hidden = true;
to.hidden = false;
onShown?.();
return;
}
from.classList.add(back ? 'is-leaving-back' : 'is-leaving');
window.setTimeout(() => {
from.hidden = true;
from.classList.remove('is-leaving', 'is-leaving-back');
to.hidden = false;
to.classList.add(back ? 'is-entering-back' : 'is-entering');
onShown?.();
// Two frames: one for the browser to lay the pane out hidden-to-shown, one for
// the transition to have a starting value to move from.
requestAnimationFrame(() =>
requestAnimationFrame(() => to.classList.remove('is-entering', 'is-entering-back')),
);
}, 140);
}
/* ---------- the preview card ---------- */
function drawPreview(mint: IndexSuccess): void {
const name = mintDisplayName(mint);
const address = displayAddress(mint);
const note = mint.existing ? t('reviews.byUrl.existing') : t('reviews.byUrl.indexed');
preview.innerHTML =
`<span class="rbu-icon" style="background:${iconGradient(address)}">${escapeHtml(
(name[0] ?? '?').toUpperCase(),
)}</span>` +
`<span class="rbu-who">` +
`<span class="rbu-name">${escapeHtml(name)}</span>` +
// A federation submitted as a bare invite code has no name but its own id, and
// printing that id twice says nothing the first line did not.
(address === name ? '' : `<span class="rbu-domain">${escapeHtml(address)}</span>`) +
`</span>` +
`<span class="rbu-chips">` +
`<span class="status-chip ${escapeHtml(mint.status)}"><span class="status-dot"></span>${escapeHtml(
statusLabel(mint.status, t),
)}</span>` +
`<span class="rbu-note">${escapeHtml(note)}</span></span>`;
reviewTitle.textContent = t('reviews.dialog.title', { name });
}
/* ---------- step 1: identify ---------- */
function showError(message: string): void {
errorBox.textContent = message;
errorBox.hidden = message === '';
}
function setChecking(busy: boolean): void {
checking = busy;
input.disabled = busy;
checkButton.disabled = busy;
checkButton.classList.toggle('is-busy', busy);
checkLabel.textContent = busy ? t('reviews.byUrl.checking') : t('reviews.byUrl.check');
}
/** Offer the other ecosystem, with a button that re-submits under it. */
function offerSwitch(detected: IndexType): void {
switchBox.hidden = false;
switchBox.innerHTML =
`${escapeHtml(t('reviews.byUrl.detected', { kind: t(`feed.ecosystem.${detected}`) }))} ` +
`<button type="button" class="rev-retry" data-rbu-switch-to="${escapeHtml(detected)}">${escapeHtml(
t('reviews.byUrl.switchTo', { kind: t(`feed.ecosystem.${detected}`) }),
)}</button>`;
}
async function submitAddress(type: IndexType, raw: string): Promise<void> {
if (checking) return;
showError('');
switchBox.hidden = true;
// The format check runs first and costs nothing: an empty box, a sentence, or an
// invite code in a URL field are all answerable without asking the server.
const precheck = checkIndexInput(type, raw);
if (!precheck.ok) {
showError(
precheck.reason === 'empty' ? t('reviews.byUrl.error.empty')
: precheck.reason === 'bad_invite' ? t('reviews.byUrl.error.badInvite')
: t('reviews.byUrl.error.badUrl'),
);
input.focus();
return;
}
setChecking(true);
const result = await requestIndex(type, precheck.value);
setChecking(false);
if (!result.ok) {
const detected = result.failure.detected_type;
showError(indexErrorMessage(result, t));
if (detected && detected !== type) offerSwitch(detected);
input.focus();
input.select();
return;
}
const subject = subjectFor(result.mint);
if (!subject) {
// Indexed, but this build has no review kind for its ecosystem. Publishing an
// event nothing could resolve would be worse than saying so.
showError(t('reviews.byUrl.error.notAMint'));
return;
}
resolved = { mint: result.mint, subject };
drawPreview(result.mint);
writer.reset();
showStep('review', () => writer.open());
}
identify.addEventListener('submit', (event) => {
event.preventDefault();
void submitAddress(pageType, input.value);
});
// The one-click switch: re-submit the same address under the ecosystem the API
// recognised, and carry straight on into the review.
switchBox.addEventListener('click', (event) => {
const button = (event.target as HTMLElement).closest<HTMLElement>('[data-rbu-switch-to]');
const detected = button?.dataset['rbuSwitchTo'] as IndexType | undefined;
if (!detected) return;
// The box is about to be asked about a different ecosystem, so it says so before
// the request goes out rather than after it comes back.
setType(detected);
void submitAddress(detected, input.value);
});
input.addEventListener('input', () => {
showError('');
switchBox.hidden = true;
});
dialog.querySelector('[data-rbu-back]')?.addEventListener('click', () => {
showStep('identify');
window.setTimeout(() => input.focus(), 160);
});
for (const button of dialog.querySelectorAll('[data-rbu-close], [data-write-close]')) {
button.addEventListener('click', () => closeModal(dialog));
}
/* ---------- opening ---------- */
/**
* Open the dialog on step 1, asking about `type`, with `prefill` in the box.
*
* Always back to the address, even if a previous visit got as far as the form: the
* reader pressed "write a review", not "carry on with the last one".
*/
function openWith(type: IndexType, prefill?: string): void {
setType(type in words ? type : ((dialog!.dataset['rbuType'] ?? 'cashu') as IndexType));
resolved = null;
identify.hidden = false;
reviewStep.hidden = true;
showError('');
switchBox.hidden = true;
setChecking(false);
if (prefill) input.value = prefill;
openModal(dialog!);
input.focus();
input.select();
}
for (const trigger of document.querySelectorAll<HTMLElement>('[data-review-url-open]')) {
trigger.addEventListener('click', () => {
/*
* A trigger may name the ecosystem it is asking about, and hand over the address
* the reader was already looking for. The index pages need neither — their
* dialog is already theirs and there is nothing to prefill — but the 404 page's
* trigger serves all three and knows exactly which address just failed.
*/
const asked = trigger.dataset['reviewUrlOpen'] as IndexType | undefined;
openWith(asked ?? ((dialog.dataset['rbuType'] ?? 'cashu') as IndexType), trigger.dataset['reviewUrlValue']);
});
}
/*
* The same thing, for a trigger that did not exist when this ran.
*
* The 404 page builds its button only once a resolution has failed, which is long
* after the loop above. Rather than have that page reach into this island's
* internals, it dispatches an event at the dialog and this answers it.
*/
dialog.addEventListener('cashumints:open', (event) => {
const detail = (event as CustomEvent<{ type?: string; value?: string }>).detail ?? {};
openWith((detail.type ?? 'cashu') as IndexType, detail.value);
});
};
onReady(setup);
</script>
File diff suppressed because it is too large Load Diff
+20 -2
View File
@@ -5,7 +5,7 @@ import { localePath, useI18n } from '../i18n';
import { pageLocale } from '../i18n/paths';
interface Props {
current?: 'mints' | 'reviews' | 'wallets' | 'about';
current?: 'mints' | 'fedimints' | 'lnurl-mints' | 'reviews' | 'wallets' | 'about';
/** Shows the compact search affordance next to the brand (mint pages, /mints). */
mintCount?: number;
}
@@ -18,8 +18,26 @@ const t = useI18n(locale);
* Route slugs stay English in every language, so the href is the English path run
* through `localePath`. See src/i18n/routing.ts for why that trade was made.
*/
/*
* "Cashu mints", not "Mints".
*
* Three ecosystems are listed now, so a bare "Mints" beside "Fedimints" and "LNURL
* mints" reads as though the others were something other than mints — or as though the
* first link covered all of them. The extra word costs ~45px in the widest nav row and
* buys a label that is unambiguous at a glance. It is the same wording on /mints' own
* heading, in the breadcrumb and in the footer, so nothing calls the same page two
* things.
*
* Six items is where this row stops fitting on one line. It still does at 1280px in
* every locale; below that the nav drops to a row of its own rather than collapsing
* behind a hamburger, which is the `@media (max-width: 1180px)` block in global.css and
* the comment there explains how that number was arrived at. A fourth ecosystem would
* need a real menu instead — this row is full.
*/
const links = [
{ path: '/mints', label: t('nav.mints'), key: 'mints' },
{ path: '/fedimints', label: t('nav.fedimints'), key: 'fedimints' },
{ path: '/lnurl-mints', label: t('nav.lnurlMints'), key: 'lnurl-mints' },
{ path: '/reviews', label: t('nav.reviews'), key: 'reviews' },
{ path: '/wallets', label: t('nav.wallets'), key: 'wallets' },
{ path: '/about', label: t('nav.about'), key: 'about' },
@@ -69,7 +87,7 @@ const links = [
of it to get out. The footer keeps its own copy, where someone deliberately looking
for site settings expects to find one.
*/}
<LanguageSwitcher variant="menu" />
<LanguageSwitcher placement="bar" />
{/*
The account control. Prerendered logged out, because a static build cannot know
+122
View File
@@ -0,0 +1,122 @@
---
/**
* The write-a-review form: a login gate, stars, body, identity row, actions.
*
* One markup, two dialogs. The reviews panel on a mint page opens it directly; the
* review-by-URL dialog on an index page opens it as its second step, once the reader's
* pasted address has been indexed. Its behaviour lives in `lib/write-review.ts`, and
* every hook below (`data-write-*`) is what that module binds to.
*
* Three states, only one of which is ever on screen:
*
* 1. **Gate.** No session and no NIP-07 extension: there is nothing to sign with, so
* the dialog asks for a login before it asks for anything else. Writing a review
* into a form that cannot publish it is the one outcome worth designing away.
* 2. **Stars.** The rating is the whole first step. It is the one thing every review
* must carry, it takes one tap, and it is what the rest of the form is *for*.
* 3. **The rest.** Body, identity and publish, revealed once a rating is chosen.
*
* Nothing here knows which ecosystem is being reviewed. The heading is a slot because
* the two dialogs have genuinely different heads — one names the mint, the other has a
* step back to the address field — and the lede and placeholder are props for the same
* reason: "how did minting and melting work out" means nothing about a federation.
*/
import { useI18n, type Locale } from '../i18n';
interface Props {
locale: Locale;
/** The one line under the heading, chosen per ecosystem by the caller. */
lede: string;
/** The body placeholder, chosen per ecosystem by the caller. */
placeholder: string;
/**
* Prefix for the two element ids in here.
*
* A mint page carries both dialogs at once — the panel's, and the one the header
* button opens — so the ids cannot be constants. `for`/`aria-describedby` are the
* whole reason they exist; nothing styles by id.
*/
idPrefix?: string;
}
const { locale, lede, placeholder, idPrefix = 'review' } = Astro.props;
const t = useI18n(locale);
const bodyId = `${idPrefix}-body`;
const countId = `${idPrefix}-count`;
---
<form method="dialog" class="modal-body write-form" data-write-form novalidate>
<slot name="head" />
<p class="modal-note" data-write-lede>{lede}</p>
{/*
The gate. Shown in place of everything below it when there is no signer at all, and
never on a page load: whether a reader has one is a browser fact the island checks
as the dialog opens. Its button is an ordinary `data-login-open`, so the same popup
the header opens is the one that opens here.
*/}
<div class="write-gate" data-write-gate hidden>
<p class="write-gate-title">{t('reviews.dialog.gate.title')}</p>
<p class="write-gate-lead">{t('reviews.dialog.gate.lead')}</p>
<div class="write-actions">
<button type="button" class="btn" data-write-close>{t('common.cancel')}</button>
<button type="button" class="btn primary" data-login-open>{t('login.open')}</button>
</div>
</div>
<div class="write-steps" data-write-steps>
<fieldset class="star-picker" data-star-picker>
<legend class="sr-only">{t('reviews.dialog.ratingLegend')}</legend>
{
[1, 2, 3, 4, 5].map((value) => (
<label class="star-option">
<input type="radio" name="rating" value={value} required />
<span aria-hidden="true">★</span>
<span class="sr-only">{t('reviews.dialog.starsSr', { n: value })}</span>
</label>
))
}
{/*
The only "Choose a rating" on screen. It used to be said here and again beside
the publish button, which is one prompt too many for one decision.
*/}
<span class="star-label" data-star-label aria-live="polite">
{t('reviews.dialog.chooseRating')}
</span>
</fieldset>
<div class="write-rest" data-write-rest hidden>
<div class="write-id" data-write-identity></div>
<label class="write-label" for={bodyId}>{t('reviews.dialog.bodyLabel')}</label>
<div class="write-box">
<textarea id={bodyId} name="content" rows="5" aria-describedby={countId} placeholder={placeholder}
></textarea>
<span class="write-count" id={countId} data-char-count>
{t('reviews.dialog.charCount', { n: 0, max: 2000 })}
</span>
</div>
<p class="write-status" data-write-status hidden role="status"></p>
<p class="write-error" data-write-error hidden role="alert"></p>
<div class="write-actions">
<span class="write-why" data-write-why></span>
<button type="button" class="btn" data-write-close>{t('common.cancel')}</button>
<button type="submit" class="btn primary" data-write-submit disabled>
<span class="write-spinner" aria-hidden="true"></span>
<span data-write-submit-label>{t('reviews.dialog.submit')}</span>
</button>
</div>
{/*
What pressing that button actually does, beside the button rather than in the
heading: it is a consequence, and a consequence belongs next to the thing that
causes it, not above a form nobody has filled in yet.
*/}
<p class="write-permanent">{t('reviews.dialog.permanent')}</p>
</div>
</div>
</form>
+49 -2
View File
@@ -4,8 +4,17 @@ The terms this site keeps consistent across every language. Decide a term once h
then use it everywhere: a reader who meets "reseña" on the home page and "opinión" on a
mint page has to work out whether they are the same thing.
Adding a fourth language starts here, not in the catalog. Fill in the column, then
translate `en.json` with it beside you.
Adding a language starts here, not in the catalog. Fill in the column, then translate
`en.json` with it beside you.
The table has columns for the three hand-written languages. The other twenty-one catalogs
were machine-translated in bulk, and only six of them (de, da, sv, id, vi, tr) have since
had the first row of this table applied throughout: in those, a mint is a `mint`. The
remaining fifteen keep the machine's word for it below the chrome, which is usually the
local term for a coin factory. A native pass on one of them means adding its column here
first, then working through `xx.json` against it. The house rules below hold in every
language, translated column or not, and the first row of the table is the one the machine
got wrong most often.
## House rules, in every language
@@ -52,6 +61,29 @@ translate `en.json` with it beside you.
| mint URL | la URL del mint | de mint-URL | |
| bunker link | el enlace bunker | de bunker-link | `bunker://` itself is never translated. |
| seed phrase | la frase semilla | de seed phrase | |
| Fedimint | Fedimint | Fedimint | The project's name. Never inflected, never translated. |
| federation | la federación | de federatie | The thing a Fedimint is. Not "el fedimint". |
| guardian | el guardián | de guardian | Who runs a federation. Dutch keeps the loanword, as with signer. |
| invite code | el código de invitación | de invite code | The `fed11…` string. Spanish translates it, Dutch does not: NL users say "invite code". |
| federation id | el id de la federación | het federatie-id | The 64 hex characters. Never "identificador". |
| module | el módulo | de module | `ln`, `mint`, `wallet`. The short name itself is never translated. |
| announced (a federation) | anunciada | aangekondigd | Its own status. NOT a synonym for unknown: see the note below. |
| confirmed (a federation) | confirmada | bevestigd | Something outside this site checked and it answered. |
| network (mainnet, signet) | la red | het netwerk | The network names themselves stay English. |
| LNURL | LNURL | LNURL | Always capitals, never inflected, never translated. |
| lnurlcash | lnurlcash | lnurlcash | Lowercase, always. The protocol's own spelling (LUD-25), like `ecash`. |
| LNURL mint | el mint LNURL | de LNURL mint | The thing, not "LNURL-acuñadora". Same noun as a Cashu mint, so it takes the same article and the same word. |
| bearer note | la nota al portador | het toonderbriefje | What an LNURL mint issues. Spanish uses the financial term; Dutch uses the everyday compound. Never "billete" / "biljet" alone, which loses the point that whoever holds it owns it. |
| to rotate (a note) | rotar | roteren | Replacing a note's secret without changing its value. Not "girar" / "draaien". |
| to split (a note) | dividir | splitsen | One note becomes two. |
| to merge (notes) | fusionar | samenvoegen | Several notes become one. |
| LUD-03, LUD-06, LUD-16, LUD-21, LUD-25 | unchanged | unchanged | Same rule as NUT-04: the number is the name. The plain-language label beside it IS translated. |
| withdraw (an LNURL mint) | la retirada | de opname | The LNURL side of melting. Deliberately the same word the Cashu warnings already use for "withdrawals disabled", so one reader meets one concept. |
| mint pubkey | la clave pública del mint | de publieke sleutel van de mint | The funding node's identity key. Its own thing, distinct from a Cashu mint's `/v1/info` pubkey. |
| lightning address | la dirección Lightning | het Lightning-adres | The `name@host` form (LUD-16). Never translate the `@`. |
| Tor / onion address | la dirección Tor | het Tor-adres | Tor keeps its capital T. "Onion" is never translated. |
| funding source | el nodo Lightning | de Lightning-node | Deliberately NOT a literal "fuente de financiación" / "financieringsbron", which reads as an investor. The copy names the thing a reader can picture: the mint's Lightning node. |
| Anon (a reviewer) | Anón | Anon | The label for a reviewer with no kind-0 profile. Site copy, so it IS translated: Spanish shortens "anónimo" to match the label's width, Dutch keeps "anon" because that is what NL Nostr users write. Decided 2026-08; used by the label and the "hide anon" filter alike. |
## Two decisions worth knowing
@@ -65,6 +97,21 @@ not need this: `minten` and `melten` are what NL and BE Nostr users actually wri
review, "portemonnee", "ondertekenaar") reads as stiff or, worse, as a different concept.
Spanish translates more, because Spanish tech writing does.
**"Unavailable" is not "not supported", on an LNURL mint.** The Features panel has three
states where the Cashu NUTs panel has three and the Fedimint Modules panel has two. A
feature the operator never announced is *not announced* (`lnurl.features.notListed`); one
they did announce but that cannot work right now, because the mint's Lightning node is
unreachable, is *unavailable* (`lnurl.features.unavailable`). Those are different facts —
"they never built it" against "it is built and broken today" — and a language that uses
one word for both loses the distinction the panel exists to draw.
**"Announced" is not "unknown", in any language.** A Cashu mint that has not been probed
yet is `status.unknown`: we have not looked. A federation that is `status.announced` is
one nobody *can* look at — there is no check to run, and there may never be. Translating
both with the same word throws away the only distinction the page is making, so pick two
words and keep them apart: `Sin comprobar aún` against `Solo anunciada`, `Nog niet
gecontroleerd` against `Alleen aangekondigd`.
## Safety copy
The warning banners (`mint.warnings.*`) and the disclaimer are the copy someone reads
+794
View File
@@ -0,0 +1,794 @@
{
"site.name": "Cashumints.space",
"common.close": "اقفل",
"common.cancel": "إلغاء",
"common.done": "تم",
"common.back": "العودة",
"common.retry": "جرّب تاني",
"common.connect": "اتصل",
"common.copied": "اتنسخ",
"common.copyFull": "انسخ {kind} بالكامل",
"common.previousPage": "الصفحة السابقة",
"common.nextPage": "الصفحة التالية",
"common.reviewPages": "صفحات الاستعراض",
"time.never": "أبداً",
"time.justNow": "دلوقتي",
"time.none": "-",
"time.unknownDate": "تاريخ مش معروف",
"time.ago.minute": "منذ {n}",
"time.ago.hour": "منذ {n}",
"time.ago.day": "قبل {n}",
"time.ago.week": "منذ {n}",
"time.ago.month": "منذ {n}",
"time.ago.year": "منذ {n}",
"time.short.minute": "{n}m",
"time.short.hour": "{n}h",
"time.short.day": "{n}d",
"time.short.week": "{n}w",
"time.short.month": "{n}mo",
"time.short.year": "{n}",
"status.online": "أونلاين",
"status.offline": "أوفلاين",
"status.degraded": "مينفعش الوصول إليها",
"status.unknown": "لم يتم فحصها بعد",
"nav.skip": "روح للمحتوى",
"nav.main": "الرئيسية",
"nav.mints": "دور سكّ Cashu",
"nav.reviews": "التقييمات",
"nav.wallets": "المحافظ",
"nav.about": "بشأن",
"nav.searchMints": "دوّر في {n} دار سكّ",
"lang.label": "اللغة",
"lang.switcher": "اختار لغة",
"footer.copyright": "© {year} Cashumints.space",
"footer.allMints": "كل دور سكّ Cashu",
"footer.allReviews": "كل التقييمات",
"footer.wallets": "المحافظ",
"footer.about": "بشأن",
"footer.disclaimer": "إخلاء المسؤولية",
"footer.terms": "الشروط",
"footer.privacy": "الخصوصية",
"footer.madeBy": "اتعمل بـ ❤️ بواسطة {azzamo} · بدعم Cashu وNIP-87",
"footer.warning": "دور السكّ بتحفظ فلوسك عندها. أي دار سكّ ممكن تختفي بفلوسك.",
"footer.tagline": "تقييمات المجتمع لدور سكّ ecash، موقّعة على Nostr.",
"footer.col.explore": "استكشف",
"footer.col.site": "الموقع",
"footer.nav": "تذييل الصفحة",
"home.title": "Cashumints.space · لاقي دار سكّ Cashu تقدر تثق فيها",
"home.description": "لاقي دار سكّ Cashu تقدر تثق فيها. عندنا {mints} دار سكّ مفهرسة و{reviews} مراجعة من المجتمع ومتوقعة على Nostr، مع حالة التشغيل المباشرة وNUTs المدعومة وبرنامج دار السكّ.",
"home.hero.title": "لاقي دار سكّ Cashu",
"home.hero.titleEm": "تقدر الثقة",
"home.hero.lede": "كل دار سكّ على شبكة Nostr مع استعراضات حقيقية للمجتمع لا بوّابات، بس وقّعَ الاستعراضاتَ إنت يُمْكِنُ أَنْ تَتأكّدَ نفسك.",
"home.search.label": "فتش الدار سكّ بالاسم أو النطاق أو URL",
"home.search.placeholder": "البحث بالاسم أو المجال أو المعاكرونة",
"home.filters.mostReviewed": "معظم الاستعراضات",
"home.filters.highestRated": "أعلى معدل",
"home.filters.recentlyActive": "نشط مؤخرا",
"home.filters.websockets": "خدمات الدعم على الشبكة العالمية",
"home.top.title": "الدار سكّ اليكاشف",
"home.top.sub": "مصنفة حسب الاستعراضات والتقييم",
"home.top.all": "كل دور السكّ وعددها {n} ←",
"home.latest.title": "آخر استعراضات",
"home.latest.sub": "مباشرة من شبكة Nostr",
"home.latest.prev": "الاستعراضات السابقة",
"home.latest.next": "المزيد من الاستعراضات",
"home.latest.all": "جميع الاستعراضات",
"home.latest.empty": "مينفعش قراءة أي استعراضات من عمليات النقل دلوقتي افتح أي صفحة نعن لقراءة وكتابة الاستعراضات مباشرة.",
"home.why.title": "لماذا تثق بدي الاستعراضات؟",
"home.why.signed.title": "توقيع، لم يقدم",
"home.why.signed.body": "كل استعراض هو {link} حدث وقع من قبل مفتاح كاتبه Nostr مفيش حاجة هنا يتم تحريره أو ترتيبه أو نقله من قبلنا",
"home.why.signed.link": "NIP-87",
"home.why.verify.title": "تحقق بنفسك",
"home.why.verify.body": "الاستعراضات تَعِيشُ على عملياتِ نوتستر العامةِ، لَيسَ قاعدةَ بياناتنا. نوجه أي زبون في نفس المسارات وسترى نفس البيانات",
"home.why.checked.title": "تم التحقق منها باستمرار",
"home.why.checked.body": "إحنا نفحص كل دار سكّ للوقت الاضافي ونسخة البرمجيات والحدود و ندعم نوتس لذا المواصفات التي تراها موجودة ولا تُبلغ عن نفسها",
"home.cta.title": "أتفكرين في الدار سكّ؟ إقرأه أولاً",
"home.cta.body": "الدار سكّ يمسك كشائك اقرأوا ما وجده الآخرون قبل ما تضعوا جلساتكم و تتركوا مراجعة لما استخدمتم واحدة بنفسكم",
"home.cta.explore": "استكشاف كل الدار سكّ",
"home.cta.learn": "تعلم بشأن (كاش)",
"pulse.mintsIndexed": "دار سكّ مفهرسة",
"pulse.reviewsOnNostr": "عمليات استعراض نوتستر",
"pulse.lastReview": "الاستعراض الأخير",
"pulse.mintsOnline": "الدار سكّ على الإنترنت",
"pulse.indexer": "الرقم القياسي",
"pulse.connected": "متصل",
"pulse.stale": "قديم",
"mints.title": "كل دور سكّ Cashu · Cashumints.space",
"mints.description": "كل دور سكّ Cashu وعددها {total} والمفهرسة من Nostr: ‏{online} أونلاين و{offline} أوفلاين، مع تقييمات المجتمع ووقت التشغيل وبرنامج دار السكّ. دوّر ورتّب القائمة كاملة.",
"mints.heading": "كل دور سكّ Cashu",
"mints.lede": "كل دار سكّ اكتشفت على شبكة Nostr بما في ذليك التي اختفت الدار سكّ الخارجي يبقى في القائمة حتى تقدر العثور عليها واستعراضها",
"mints.search.label": "دار سكّ البحث بالاسم أو المجال",
"mints.search.placeholder": "البحث بالاسم أو المجال...",
"mints.sort.label": "الدار سكّ",
"mints.sort.score": "أفضل عام",
"mints.sort.rating": "أعلى معدل",
"mints.sort.reviews": "معظم الاستعراضات",
"mints.sort.recent": "جرى استعراضها مؤخرا",
"mints.sort.seen": "شوهد مؤخرا على الإنترنت",
"mints.hideOffline": "إخفاء خارج الخط",
"mints.showing": "بنعرض {total} دار سكّ ({online} أونلاين، {offline} أوفلاين)",
"mints.showingFiltered": "بنعرض {shown} من {total} دار سكّ ({online} أونلاين، {offline} أوفلاين)",
"mints.noResults": "مفيش دور سكّ مطابقة. جرّب دومين أو الصق URL لدار سكّ.",
"mints.clearSearch": "أخلوا البحث",
"card.noRatings": "لا تقديرات بعد",
"card.reviews.one": "{n}",
"card.reviews.other": "{n}",
"card.lastSeen": "{when} شوهد آخر مرة",
"card.neverSeen": "لم أره على الإنترنت",
"card.reviewed": "اتراجعت {when}",
"card.noReviews": "لم تجر أي استعراضات بعد",
"mint.title": "مراجعات دار سكّ Cashu ‏{name}",
"mint.titleFedimint": "{name} · مراجعات اتحاد Fedimint",
"mint.titleLnurl": "{name} · مراجعات mint من نوع LNURL",
"mint.breadcrumb": "مسار الصفحة",
"mint.crumbMints": "النقدية الدار سكّ",
"mint.copyUrl": "نسخ دار سكّ URL",
"mint.qrButton": "QR لدار السكّ",
"mint.writeReview": "كتابة استعراض",
"mint.rankByScore": "# {rank} بالنتيجة",
"mint.tagLightning": "Lightning",
"mint.summary": "موجز مصغر",
"mint.rating.label": "التقييم المجتمعي",
"mint.rating.none": "لا تقديرات بعد",
"mint.rating.reviews.one": "{n}",
"mint.rating.reviews.other": "{n}",
"mint.rating.unrated.one": "{n} لم يترك أي تقدير",
"mint.rating.unrated.other": "{n} لم يترك أي تقدير",
"mint.activity.label": "النشاط",
"mint.activity.since": "منذ الاستعراض الأخير",
"mint.activity.noReviews": "لم تجر أي استعراضات بعد",
"mint.activity.recent.one": "{n} استعراض أجري في التسعين يوما الماضية",
"mint.activity.recent.other": "{n} استعراضات في التسعين يوما الماضية",
"mint.limits.label": "الحدود",
"mint.limits.notPublished": "غير منشورة",
"mint.limits.min": "الحد الأدنى",
"mint.limits.max": "الحد الأقصى",
"mint.limits.mintRow": "السكّ",
"mint.limits.meltRow": "الذوبان",
"mint.limits.disabled": "المعوقين",
"mint.limits.range": "{min} إلى {max} {unit}",
"mint.limits.upTo": "حتى {max} {unit}",
"mint.limits.from": "من {min} {unit}",
"mint.software.label": "برمجيات متنقلة",
"mint.software.unknown": "مجهول",
"mint.checked": "{when}",
"mint.lastSeen": "{when} شوهد آخر مرة",
"mint.neverReached": "لم تصل أبداً",
"mint.uptime.checks": "{ok} من آخر {total} شيكات يمكن الوصول إليها",
"mint.uptime.percent": "{percent} في المائة يمكن الوصول إليها على مدى 30 يوما",
"mint.uptime.description": "{ok} من آخر {total} شيك",
"mint.operator.title": "من المشغل",
"mint.operator.motd": "رسالة اليوم",
"mint.operator.beta": "ده الدار سكّ يصف نفسه بأنه تجريبي أو في بيتا اقرأ كلمات المشغل تحت قبل تخزين أي شيء مش هتقدر أن تخسره",
"mint.nuts.title": "وحدات غير حكومية مدعومة",
"mint.nuts.noneOffline": "مفيش قائمة بـ (ن.ت) مخبأة لده الدار سكّ، إنقطعت عن اعشانظار قبل تسجيل واحد.",
"mint.nuts.noneYet": "مفيش قائمة بـ (ن.ت) مجهزة لده الدار سكّ بعد",
"mint.nuts.supported": "بتدعم {n} من أصل {total} من NUTs المعروفة",
"mint.nuts.whatAre": "ما هي NUTs؟",
"mint.nutRow.disabled": "المعوقين",
"mint.nutRow.notSupported": "غير مدعوم",
"mint.details.title": "تفاصيل دقيقة",
"mint.details.pubkey": "المفتاح العام",
"mint.details.nostr": "Nostr",
"mint.details.email": "الإيميل",
"mint.details.serverTime": "وقت الخدمة",
"mint.details.firstSeen": "شوهدت أول مرة",
"mint.details.terms": "المصطلحات",
"mint.kind.identifier": "محدد الهوية",
"mint.kind.mintPubkey": "لحم الدار سكّ",
"mint.kind.nostrAddress": "عنوان Nostr",
"mint.kind.emailAddress": "عنوان البريد الإليكتروني",
"mint.kind.npub": "npub",
"mint.disclaimer": "ده الدار سكّ متحفظ، إنه يحمل ماليك، ويمكنه التوقف عن فعل ذليك في أي وقت. فالرات هي آراء مجتمعية ووسائل على الإنترنت يمكن الوصول إليها ومش جديرة بالثقة.",
"mint.disclaimerLink": "اقرأ المصل اليكامل",
"mint.qr.title": "إضافة {name} إلى محفظتك",
"mint.qr.note": "امسح ده بمحفظة (Cashu) لإضافة الدار سكّ، أو انسخ الـ (يور إل) و اربطه",
"mint.qr.warn": "إضافة الدار سكّ يعني ثقته بأمواليك",
"mint.qr.whatMeans": "ماذا يعني ذليك؟",
"mint.qr.wallets": "الجدران التي تدعم Cashu",
"mint.chip.frozen": "متجمّدة",
"mint.chip.noWithdrawals": "لا انسحابات",
"mint.chip.meltOnly": "ميلت بس",
"mint.warnings.dayCount.one": "{n} يوما",
"mint.warnings.dayCount.other": "{n} يوما",
"mint.warnings.gone.lead": "من المحتمل أنه ذهب",
"mint.warnings.gone.body.since": "دار السكّ دي مش متاحة من {date} ({days}). اعتبر الفلوس اللي فيها معرّضة للخطر، وماتبعتش فلوس جديدة. التفاصيل تحت بتعرض آخر حالة معروفة ليها.",
"mint.warnings.gone.body.neverDated": "ده الدار سكّ لم يجيب على شيك واحد منذ أن بدأنا مشاهدته في {month} معالجة الأموال المحتفظ بها هناك على أنها معرضة للخطر، ولا ترسل أموالا جديدة. أي شيء أسفل جاء من إعلان Nostr، ومش من الدار سكّ.",
"mint.warnings.gone.body.never": "ده الدار سكّ لم يجيب على شيك واحد منذ أن بدأنا مشاهدته معالجة الأموال المحتفظ بها هناك على أنها معرضة للخطر، ولا ترسل أموالا جديدة. أي شيء أسفل جاء من إعلان Nostr، ومش من الدار سكّ.",
"mint.warnings.gone.meta.since": "المبلغ النهائي منذ {date}",
"mint.warnings.gone.meta.never": "مينفعش الوصول إليها منذ أن تم اكتشافها",
"mint.warnings.meltDisabled.lead": "سحب معوق.",
"mint.warnings.meltDisabled.body.online": "ده الدار سكّ يذوب Lightning (نوت 05) ويُمكنُكَ أن تُجلس، ليكن لا يُمكنكَ سحبهم عبر Lightning. لا تُودع حتى يعود الإنصهار",
"mint.warnings.meltDisabled.body.offline": "ده الدار سكِّ كَانَ عِنْدَهُ الإنصهارُ Lightningِ (NUT-05) انطفأْ: إنت يُمْكِنُ أَنْ تَضِعَ في، لَكنَّك لا تَستطيعُ سحبهم عبر Lightningِ. لا تُودع حتى يعود الإنصهار",
"mint.warnings.meltDisabled.meta.online": "معوقات حاليا",
"mint.warnings.meltDisabled.meta.offline": "تم تعطيل السحب لما شوهد آخر مرة",
"mint.warnings.frozen.lead": "مجمدة",
"mint.warnings.frozen.body.online": "النسيج والذوبان معاقان مينفعش للجلسات أن تنتقل أو تخرج عبر Lightning",
"mint.warnings.frozen.body.offline": "تم تعطيل كل من التنين والذوبان. مينفعش للجلسات أن تنتقل أو تخرج عبر Lightning",
"mint.warnings.frozen.meta.online": "مفيش حاجة يتحرك أو يخرج",
"mint.warnings.frozen.meta.offline": "تجمد لما شوهد آخر مرة",
"mint.warnings.offlineLong.lead": "أوفلاين بقالها {days}، من {date}.",
"mint.warnings.offlineLong.body": "قد يختفي الدار سكّ كل شيء تحتها هو آخر حالة معروفة، والاستعراضات لا تزال تعمل.",
"mint.warnings.offlineLong.meta": "المبلغ النهائي منذ {date}",
"mint.warnings.meltOnly.lead": "ملطخة بس",
"mint.warnings.meltOnly.body.online": "النسيج الجديد معوق: مش هتقدر الإيداع، ليكن اليكشب الحالي يمكن سحبه. ده غالباً ما يعني أن الدار سكّ ينخفض لذا إن أمسكت به، فكّر في الإنصهار",
"mint.warnings.meltOnly.body.offline": "تم تعطيل النسيج الجديد (NUT-04): مش هتقدر الإيداع، بس اليكشب الحالي لسه يمكن سحبه. ده غالباً ما يعني أن الدار سكّ ينخفض لذا إن أمسكت به، فكّر في الإنصهار",
"mint.warnings.meltOnly.meta.online": "دلوقتي يذوب بس",
"mint.warnings.meltOnly.meta.offline": "(ميلت) بس لما شوهد آخر مرة",
"mint.warnings.offline.lead": "مسافر منذ {date}",
"mint.warnings.offline.body": "تم تأكيد آخر التفاصيل ما زال بإمكانك كتابة مراجعة",
"mint.warnings.offline.meta": "المبلغ النهائي منذ {date}",
"mint.warnings.also.meltDisabled": "قبل ما تنفجر كان لديها أيضاً سحبات معاقه",
"mint.warnings.also.mintDisabled": "قبل ما تنفجر، كان أيضاً قد عطّل نغمة جديدة.",
"mint.warnings.also.offline": "وكمان أوفلاين من {date} ({days}).",
"nut.0": "الإخطار والنماذج",
"nut.1": "مقايضة المفاتيح العامة",
"nut.2": "المقايض والرسوم",
"nut.3": "مبادلات",
"nut.4": "التعدين عن طريق Lightning",
"nut.5": "الميل عن طريق Lightning",
"nut.6": "معلومات دقيقة",
"nut.7": "تفتيش الدولة الطرف",
"nut.8": "رسوم Lightning الزائدة المدفوعة",
"nut.9": "التوقيعات التقييدية",
"nut.10": "شروط الإنفاق",
"nut.11": "الدفع للمفتاح العام",
"nut.12": "براهين DLEQ",
"nut.13": "أسرار محددة",
"nut.14": "العقود المتعلقة بالوقت المحدد",
"nut.15": "المدفوعات المتعددة الجنسيات",
"nut.16": "رموز كمية متحركة",
"nut.17": "اشتراكات الشبكة",
"nut.18": "طلبات الدفع",
"nut.19": "الردود المفصَّلة",
"nut.20": "اقتباسات موقعة من الدار سكّ",
"nut.21": "التوثيق",
"nut.22": "التوثيق الأعمى",
"nut.23": "طريقة الدفع BOLT11",
"nut.24": "المبلغ المطلوب",
"nut.25": "طريقة الدفع BOLT12",
"reviews.title": "الاستعراضات",
"reviews.anon": "مجهول",
"reviews.anonTitle": "لم يعثر على أي ملف عام لده المفتاح",
"reviews.nip05Title": "مُحدِّد هوية NIP-05 (لم يتحقق من ذليك في الموقع ده)",
"reviews.filter.group": "استعراضات الملفات",
"reviews.filter.all": "كل شيء",
"reviews.filter.five": "5★",
"reviews.filter.critical": "حرجة",
"reviews.filter.recent": "حديثة",
"reviews.filter.hideAnon": "اختبئ",
"reviews.anonHidden.other": "عرض {n} من {total} استعراضات: آنون مخبأ.",
"reviews.allAnon.one": "الاستعراض الوحيد هنا هو (آن)",
"reviews.allAnon.other": "جميع استعراضات الـ {n} هنا هي آن",
"reviews.allAnonShow.one": "أريه",
"reviews.allAnonShow.other": "أرهم",
"reviews.loading.one": "بنحمّل {n} مراجعة من مرحّلات Nostr",
"reviews.loading.other": "بنحمّل {n} مراجعات من مرحّلات Nostr",
"reviews.empty": "لا استعراضات بعد سيكون الأول ليكتابة واحدة.",
"reviews.emptyWritten": "لا استعراضات مكتوبة هنا بعد. سيكون الأول ليكتابة واحدة.",
"reviews.emptyFilter": "لا استعراضات تطابق ده المرشح",
"reviews.rate.cashu": "قيّم جهة الإصدار دي",
"reviews.rate.fedimint": "قيّم الاتحاد ده",
"reviews.rate.lnurl": "قيّم جهة الإصدار دي",
"reviews.noscript": "(الاستعراضات موقعة على (Nostr وتحمّل في مركبك لذا دي القائمة تحتاج (جافاسكريت فالتقديرات والعدادات الواردة أعلاه تأتي من الموقع نفسه.",
"reviews.relayError": "مينفعش أن تصل إلى النفقة. وقد تكون الاستعراضات غير كاملة.",
"reviews.srRating": "{rating} من 5",
"reviews.srNoRating": "مفيش تقييم",
"reviews.readMore": "اقرأ المزيد",
"reviews.readLess": "عرض أقل",
"reviews.actions.copyNpub": "نسخة رقم npub",
"reviews.actions.openNostr": "مشاهدة ده الحدث في Nostr",
"reviews.actions.copyLink": "وصلة نسخ بده الاستعراض",
"reviews.anonNote": "دي أول مراجعة من الـnpub ده، ومفيش نشاط تاني ليه",
"reviews.published": "نُشرت",
"reviews.publishedPartial": "نُشرت إلى {accepted} من {total} إعادة شحن.",
"reviews.propagating": "البروغات، قد يستغرق الأمر لحظة للظهور في مكان آخر.",
"reviews.summary.line.one": "{count} تصنيف دون تعليق: {breakdown}",
"reviews.summary.line.other": "{count} تصنيف دون تعليق: {breakdown}",
"reviews.summary.lineDay.one": "{count} تصنيف دون تعليق على {when}: {breakdown}",
"reviews.summary.lineDay.other": "{count} تصنيف دون تعليق على {when}: {breakdown}",
"reviews.summary.showOne": "أريه",
"reviews.summary.showMany": "أرهم",
"reviews.breakdown.5": "{n} 5 نجم",
"reviews.breakdown.4": "{n} نجم",
"reviews.breakdown.3": "{n} 3",
"reviews.breakdown.2": "{n} نجمان",
"reviews.breakdown.1": "{n} نجم واحد",
"reviews.breakdown.none": "{n} بدون تصنيف",
"reviews.dialog.title": "راجع {name}",
"reviews.dialog.lede.cashu": "احكيلنا تجربتك مع جهة الإصدار دي.",
"reviews.dialog.lede.fedimint": "احكيلنا تجربتك مع الاتحاد ده.",
"reviews.dialog.lede.lnurl": "احكيلنا تجربتك مع جهة الإصدار دي.",
"reviews.dialog.permanent": "بيتنشر بشكل دائم على موزّعات Nostr العامة.",
"reviews.dialog.gate.title": "سجّل الدخول عشان تكتب تقييم",
"reviews.dialog.gate.lead": "التقييمات بتتوقّع بمفتاح Nostr بتاعك. المفتاح بيفضل في المُوقِّع، والموقع ده عمره ما بيشوفه.",
"reviews.dialog.ratingLegend": "تقديرك",
"reviews.dialog.starsSr.one": "{n}",
"reviews.dialog.starsSr.other": "{n} نجوم",
"reviews.dialog.chooseRating": "اختيار تقدير",
"reviews.dialog.starLabel": "{n}: {phrase}",
"reviews.dialog.stars.1": "تجنب ذليك",
"reviews.dialog.stars.2": "مشاكل",
"reviews.dialog.stars.3": "عملت مع كهوف",
"reviews.dialog.stars.4": "جيد",
"reviews.dialog.stars.5": "عمل رائع",
"reviews.dialog.bodyLabel": "ماذا لازم يعرف الآخرون؟",
"reviews.dialog.charCount": "{n} / {max}",
"reviews.dialog.submit": "التوقيع والنشر",
"reviews.dialog.signing": "التوقيع...",
"reviews.dialog.publishing": "النشر...",
"reviews.dialog.publishingTo": "النشر للتأخيرات ({accepted}/{total})",
"reviews.dialog.waitingExtension": "في انتظار التمديد",
"reviews.dialog.waitingSigner": "في انتظار توقيعك",
"reviews.dialog.confirmClose": "إغلاق ده الاستعراض؟ مشروعك محتفظ به حتى تغادر الموقع",
"reviews.dialog.why.login": "تسجيل في النشر",
"reviews.dialog.why.words": "أكتب بضع كلمات",
"reviews.dialog.why.more.one": "{n} شخصية أخرى",
"reviews.dialog.why.more.other": "{n} المزيد من الشخصيات",
"reviews.dialog.why.over.one": "{n} شخصية على الحد الأقصى",
"reviews.dialog.why.over.other": "{n} شخصيات فوق الحد",
"reviews.dialog.identity.needSigner": "تحتاج لتوقيع Nostr للنشر مفتاحك يبقى فيه الموقع ده لا يراه",
"reviews.dialog.identity.loginToPublish": "تسجيل في النشر",
"reviews.dialog.identity.switch": "غيّر",
"reviews.dialog.identity.extension": "التوقيع على التمديد الخاص بك",
"reviews.dialog.identity.readingKey": "قراءة مفتاحك العام",
"reviews.dialog.identity.willAsk": "طلب منك أن توقع",
"reviews.dialog.error.noRelay": "ولم يقبل أي تأخير الاستعراض. تحقق من اتصاليك، ثم حاول تاني.",
"reviews.validate.rating": "اختار تقدير من 1 إلى 5 نجوم",
"reviews.validate.short": "أكتب ما لا يقل عن {n} شخصية ليكي يكون الاستعراض مفيداً.",
"reviews.validate.long": "وتقتصر عمليات الاستعراض على {n} شخصية.",
"feed.title": "أحدث تقييمات دور السكّ والاتحادات · Cashumints.space",
"feed.description": "كل مراجعة لمنّاتة أو اتحاد أو منّاتة LNURL فور نشرها: {reviews} مراجعة تغطي {mints} منّاتة Cashu و{federations} اتحاد Fedimint و{lnurl} منّاتة LNURL، موقّعة على Nostr ومقروءة هنا من الأحدث. صفِّ حسب المنظومة أو التقييم أو الاسم.",
"feed.heading": "آخر استعراضات",
"feed.lede": "كل استعراض ليكل اتحاد من اتحادات الدار سكّ والFedimint، أولها، يقرأ مباشرة من نوتستر. ويوقع كل واحد منهم من جانب مؤلفه ويعيش في المناورات، ومش في قاعدة بياناتنا.",
"feed.label": "الاستعراضات",
"feed.showingBuild.one": "عرض أحدث استعراض من آخر بناء",
"feed.showingBuild.other": "{n} عرض آخر استعراضات من المبنى الأخير",
"feed.noscript": "المراجعات متوقعة على Nostr وبتتحمّل في متصفحك، عشان كده التصفية والقائمة الكاملة محتاجين JavaScript. المراجعات الـ{n} اللي تحت اتقرت من المرحّلات وقت بناء الصفحة.",
"feed.emptyBuild": "مقدرناش نقرا مراجعات من المرحّلات وقت بناء الصفحة. القائمة هتتحمّل تاني من المرحّلات في متصفحك.",
"feed.foot": "فالاستعراضات هي آراء مجتمعية، ومش مطالبات متحقق منها. اقرأي الرقم {link} قبل ما تثقي بالدار سكّ بالمال",
"feed.footLink": "الملتمس",
"feed.showing": "عرض {shown} من {total}",
"feed.showingMatching": "عرض {shown} من {matching} استعراضات مطابقة ({total} في المجموع)",
"feed.emptyRelays": "لم تعد أي استعراضات من المؤخرات افتح أي صفحة نعن لقراءة وكتابة الاستعراضات هناك",
"feed.emptyFilter": "لا استعراضات تطابق ده المرشح أخلوا صندوق الدار سكّ أو حاولوا جميعاً",
"feed.relayError": "لم أستطع الوصول إلى التوابيت عرض الاستعراضات من المبنى الأخير.",
"feed.relayEmpty": "لم تعد أي استعراضات من المؤخرات عرض الاستعراضات من المبنى الأخير.",
"feed.reading": "اقرأ التوابيت",
"login.open": "تسجيل الدخول",
"login.title": "اركب مع (Nostr)",
"login.note": "مفتاحك يبقى في موقّعك الموقع ده لا يستطيع رؤيته",
"login.extension.name": "التمديد",
"login.extension.desc": "وقع مع (ألبي) أو (نوكس) أو (ن.ب) آخر",
"login.extension.none": "مفيش إضافة متصفح",
"login.extension.getOne": "الحصول على واحد:",
"login.remote.name": "الإشارة عن بعد",
"login.remote.desc": "مسح رمز مع تطبيق لافتة، أو رعاة الرابط",
"login.primal.name": "Primal",
"login.primal.desc": "الموافقة على تسجيل الدخول في التطبيق الرئيسي على هاتفك",
"login.paste.label": "أَو يَعْبرُ الرابطَ اللاصقةَ التي أعطاها ليك",
"login.copyLink": "عُلم الصلة",
"login.whatIsNostr": "ما هو (Nostr)؟",
"login.connect.remote.title": "اتصل بعلامة نائية",
"login.connect.remote.lead": "افحص ده مع تطبيقك للتوقيع او انسخ الرابط واقطعه هناك",
"login.connect.remote.hint": "بيشتغل مع أي موقّع NIP-46: ‏nsec.app أو Amber أو Nostrify أو bunker بتاعك.",
"login.connect.remote.open": "افتح في تطبيقك",
"login.connect.primal.title": "اتصل بـPrimal",
"login.connect.primal.lead": "افتحي الجائزة على هاتفكِ، ابحثي عن توقيع بعيد في أماكنها، ومسحي ده الرمز.",
"login.connect.primal.hint": "Primal بيمضي للتطبيقات التانية عبر NIP-46، يعني ده نفس الاتصال اللي بيستخدمه أي موقّع، ومفتاحك بيفضل جوه Primal. ‏{link}",
"login.connect.primal.open": "مفتوحة في القطاع الخاص",
"login.status.buildingLink": "بناء وصلة وصل",
"login.status.waitingExtension": "في انتظار التمديد",
"login.status.waitingSigner": "في انتظار توقيعك",
"login.status.connecting": "الاتصال بعلامتك",
"login.status.connected": "متصل",
"login.toast.loggedIn": "تم تسجيل الدخول",
"login.toast.fetchingProfile": "بنجيب بروفايلك على Nostr من المرحّلات…",
"login.toast.profile": "البروفايل: {name}",
"login.toast.noProfile": "مفيش بروفايل اتلاقى على المرحّلات",
"login.method.extension": "التمديد",
"login.method.remote": "عن بُعد",
"login.method.primal": "Primal",
"login.account": "حساب Nostr",
"login.copyNpub": "عُلم",
"login.logout": "أخرج",
"signer.status.waitingExtension": "في انتظار التمديد",
"signer.status.signed": "توقيع",
"signer.status.reconnecting": "إعادة الاتصال بعلامتك",
"signer.status.approveWindow": "موقّعكَ يَحتاجُك لإقرار ده في نافذتِه الخاصةِ",
"signer.status.waitingSigner": "في انتظار توقيعك",
"signer.status.stillWaiting": "ما زالَ يَنتظرُ موقّعَكَ للإجابة",
"signer.status.connected": "متصل",
"signer.error.noExtension": "لم يتم العثور على تمديد للتوقيع اثبتي (ألبي) أو (نوس2كس) ثم أعيدي تحميل الصفحة دي",
"signer.error.privateKey": "ده يبدو كمفتاح خاص ومش وصلة الموقع ده لا يأخذ مفتاح عُذر المخبأ رقم الرابط الذي أعطاه ليك موقّعك",
"signer.error.badLink": "دي مش وصلة وصل يفهمها الموقع ده استلموا المخبأ من موقّعكم",
"signer.error.noAnswer": "موقّعك لم يجيب قد يكون مغلقاً، أو قد يكون نقله منخفضاً افتحه وحاول ثانية",
"signer.error.timeout": "موقّعك لم يجيب في الوقت المناسب افتحه، تأكد أنه لسه متصلاً ثم حاول تاني",
"signer.error.cancelled": "تم إلغاء الإتصال",
"signer.error.declined": "رفض طلب التوقيع",
"notfound.title": "الصفحة مش موجودة · Cashumints.space",
"notfound.description": "الصفحة دي مش موجودة على Cashumints.space.",
"notfound.heading": "الصفحة دي غير موجودة",
"notfound.lede": "قد يكون قد تحرك، أو قد لا يتم فهرسة الدار سكّ بعد. ابحث عن دار سكّ بالاسم أو المجال، أو عجينة الدار سكّ.",
"notfound.browse": "فجر كل الدار سكّ",
"notfound.home": "عودي للمنزل",
"notfound.loading": "الموقع {host}",
"notfound.fb.rating": "التقييم",
"notfound.fb.reviews": "الاستعراضات",
"notfound.fb.software": "البرمجيات",
"notfound.fb.lastOnline": "آخر الإنترنت",
"notfound.fb.note": "فُهرس هذا الـ mint بعد آخر بناء للموقع، لذا هذا ملخّص من الواجهة الحية، ومراجعاته أدناه. تظهر الصفحة الكاملة عند البناء التالي.",
"notfound.resolve.checking": "أول زيارة: بنفحص الـ mint ده دلوقتي.",
"notfound.resolve.failed": "تعذّرت إضافة هذا الـ mint",
"notfound.resolve.failedFedimint": "تعذّرت إضافة الاتحاد ده",
"notfound.resolve.retry": "جرّب عنوان تاني",
"notfound.resolve.switch": "افتحه بوصفه mint من نوع {kind}",
"notfound.resolve.fedimint": "عنوان صفحة الاتحاد لا يحمل رمز الدعوة الخاص به، لذا لا يمكن العثور عليه من الرابط وحده. ألصق رمز الدعوة وسيُضاف.",
"notfound.resolve.ambiguous": "لا يمكن استنتاج هذا العنوان من الرابط وحده. ألصق رابط الـ mint كاملًا وسيُضاف.",
"wallets.title": "محافظ Cashu · Cashumints.space",
"wallets.description": "محفظات (Cashu) التي تعمل مع الدار سكّ المدرجة هنا عبر الهواتف المحمولة والشبكات والبروزر وخط القيادة",
"wallets.heading": "الجدار",
"wallets.lede": "تحتاج إلى محفظة لحجز أي من دي الدار سكّ معظم المحافظ تتحدث مع العديد من الدار سكّ في آن واحد، لذليك إنت لا ترتبط مع أول واحد تحاول.",
"wallets.note": "القائمة دي مش كاملة ومش توصية. مشروع Cashu عنده فهرس أوسع على {link}. المحفظة بتمسك المفاتيح ودار السكّ بتمسك الفلوس؛ اقرا مراجعات دار السكّ قبل ما تختار تحط sats فين.",
"wallets.platform.android": "أندرو",
"wallets.platform.ios": "iOS",
"wallets.platform.web": "الويب",
"wallets.platform.extension": "التمديد",
"wallets.platform.cli": "CLI",
"wallets.platform.python": "Python",
"wallets.item.minibits": "محفظة اليكش المتحركة مع نعنتها الخاصة، دعم عنوان Lightning و اتصالات Nostr.",
"wallets.item.nutstash": "محفظة بروزر التي تتعامل مع دار سكّ متعددة في آن واحد، وواردات وصادرات الخنازير الخام.",
"wallets.item.enuts": "وركّزت محفظة متنقلة على أرصدة متعددة وأرسلت كاش على Nostr.",
"wallets.item.cashume": "محفظة ويب مرجعية من مشروع Cashu. بتشتغل بالكامل في المتصفح وممكن تثبّتها كتطبيق PWA.",
"wallets.item.boardwalk": "محفظة الإنترنت مبنية حول عناوين Lightning وإرسال اليكاش عن طريق الرابط",
"wallets.item.coinos": "محفظة Lightning ونقطة البيع التي تدير أيضاً محفظتها الخاصة",
"wallets.item.oxchat": "رسول Nostr مع محفظة كاش مبنية في، لإرسال جلس داخل الدردشة.",
"wallets.item.nutshell": "التنفيذ المرجعي. يعمل كمحفظة على خط القيادة و كبرمجيات الدار سكّ",
"about.title": "عن (Cashumints.space) الفضاء",
"about.description": "ما هو Cashu، ما هو الدار سكّ في الواقع، ما هو NIP-87 هو، وكيف الموقع ده يفهرس الدار سكّ ويقرأ استعراضات من توابع Nostr.",
"about.heading": "بشأن",
"about.lede": "(Cashumints.space)، يُذكر الفضاء كلّ دار سكّ كشوتو) يمكننا إيجادها على شبكة (Nostr) ويُريك ما يقوله الناس عنهم إنها مصنوعة من (أزامو)",
"about.cashu.title": "ما Cashu",
"about.cashu.p1": "(Cashu) هو بروتوكول كاش لـ(بيتكوين) مشاكل الدار سكّ المُعمى التي تُدعم بالجلسات التي تُمسك بها، ودي اليكسور تتحرك بين الناس بدون تعلم الدار سكّ الذي أرسل ما إلى من. إن الخصوصية حقيقية وها هي النقطة: إن اليكسور مش حسابات، ومينفعش للدار سكّ أن يربط الحبر الذي وقعه إلى القرد الذي يعود.",
"about.cashu.p2": "تقدر قراءة البروتوكول ومواصفاته في {link} المواصفات نفسها تُدعى (نوتس)، حيث تأتي أرقام (نوت) في كل صفحة نعنت.",
"about.mints.title": "ما هو الدار سكّ، والمقايضة",
"about.mints.p1": "الدار سكّ هو السجن لما تَخْدعُ تَدْفعُ جلسَاتَ إلى الذي يَرْكضُ ذليك الخادمِ، وإنت تَئتمنُهم لتَشْريف الملوكِ لما تَذوبُهم. مفيش دليل احتيالي، ولا تجسس ولا نقض. مشغل الدار سكّ ممكن يختفي مع التوازن، والدار سكّ قد.",
"about.mints.p2": "وده هو بالضبط سبب وجود عمليات استعراض هنا. الموقع ده لا يخبرك أن الدار سكّ آمن الحالة على صفحة الدار سكّ تقول {code}، ومش \"مئتمنة\"، وده يعني بس أن الخادم أجاب لما سألنا آخر مرة. معالجة الأرصدة النقدية مثل النقدية في جيب معطف: غرامة للنفقة، خطأ للوفورات.",
"about.nip87.title": "ما هو NIP-87",
"about.nip87.p1": "NIP-87 هو معيار Nostr للإعلان عن دور سكّ ecash والتوصية بيها. فيه نوعين أحداث مهمين هنا:",
"about.nip87.li1": "‏{code} بيعلن عن دار سكّ. عنوان URL بتاعها موجود في وسم {tag}. كده الموقع بيكتشف دور سكّ ماشافهاش قبل كده.",
"about.nip87.li2": "‏{code} عبارة عن مراجعة فيها التقييم والنص، وبتشير لدار السكّ بوسم {utag} (عنوان URL) ووسم {dtag} (المفتاح العام بتاع دار السكّ).",
"about.nip87.p2": "كل مراجعة تم توقيعها من قبل مفتاح (Nostr) الخاص بمؤلفها إحنا لا نَدْعو مراجعةَ \"مُحقّقَ\" عشان لا أحد يَتأكّدُ منه: هو موقّعُ، الذي a مطالبة مختلفة وأكثر فائدة. يعني أنه تقدر التحقق من من كتبها و أنه لم يتم تغييرها وده لا يعني أن صاحب البلاغ صادق أو أبداً يستخدم الدار سكّ.",
"about.how.title": "كيف يعمل الموقع ده",
"about.how.p1": "هناك حلقتان وقاعدة واحدة القاعدة هي أن الدار سكّ يمدي بياناته الوصفية و (Nostr) يمليك الاستعراضات لذا الموقع ده لا يخزن كسلطة",
"about.how.li1": "{strong} يسأل كل دار سكّ معروفة عن {code} كل عشر دقائق ويسجل ما يقوله: الاسم، الوصف، النسخة، دعم NUTs، الحدود، تفاصيل الاتصال. ويسجل أيضا ما لو كان الدار سكّ قد أجاب على الإطلاق، وهو المكان الذي يأتي منه الوقت الإضافي.",
"about.how.li1Label": "حلقة المسبار",
"about.how.li2": "{strong} يربط بنفقات Nostr كل ساعة، ويقرأ الإعلانات والاستعراضات الدار سكّ، ويضيف أي دار سكّ لم يشاهدها من قبل.",
"about.how.li2Label": "حلقة الاكتشاف",
"about.how.p2": "الجزء المهم هو ما يحدث لما يتوقف الدار سكّ عن الإجابة بياناتها المخبأة لا تُخطّى أبداً من قبل مسبار فاشل، لذا فإن الدار سكّ الميت يبقي صفحته اليكاملة: مواصفاته وتاريخه واستعراضاته، وأظهرت النسخة السابقة من الموقع ده صفحة خاطئة بدلا من ذليك، مما يعني أن الدار سكّ هم أكثر الناس احتياجا لتحذير بعضهم البعض من دي التي مينفعش استعراضها. كان ذليك السبب الرئيسي لإعادة البناء",
"about.how.p3": "النص الاستعراضي لا يُخزن أبداً على جانبنا. لما تفتح صفحتك الدار سكّه الخاصه بك تتحدث الى المصاريف مباشرة وتقرأ الاستعراضات نفسها ولما تكتب واحدة يتم توقيعها من قبل التمديد الخاص بك Nostr وترسل مباشرة الى دي المصاريف إحنا نحتفظ بالعد حتى يمكن ترتيب الصفحات وفرزها، مفيش حاجة أكثر. مفيش حاجة هنا يمكن تحريره أو حذفه من قبلنا، بما في ذليك الاستعراضات التي لا نفضل أن نجريها.",
"about.relays.title": "نفقات نقرأها",
"about.relays.p1": "دي هي الملصقات التي تستخدمها",
"about.relays.p2": "نوجه أيّ زبون من (Nostr) في نفس المسارات وسترى نفس الأحداث ده هو الوعد: مفيش حاجة هنا صحيح بس عشاننا نقول ذليك.",
"about.nuts.title": "ما هي NUTs؟",
"about.nuts.p1": "NUTs هي مواصفات Cashu المرقمة، زي BIPs في Bitcoin. دار السكّ بتعلن هي مطبّقة أنهي مواصفات، وده بيوضح تقدر تعمل إيه: ‏NUT-04 وNUT-05 للسكّ والصرف عبر Lightning، وNUT-07 بيسمح للمحفظة تتأكد لو التوكن اتصرف، وNUT-17 بيدي تحديثات مباشرة عبر WebSockets، وNUT-20 بيسمح للمحفظة تثبت إن عرض السكّ اتصدر ليها فعلًا. القائمة كاملة على {link}.",
"about.numbers.title": "دلوقتي",
"about.numbers.p1": "عندنا {mints} دار سكّ مفهرسة، {online} منها بيرد، و{reviews} مراجعة محسوبة من المرحّلات اللي فوق.",
"about.source.title": "المصدر والائتمان",
"about.source.p1": "الموقع مفتوح المصدر على {github}، وبناه وبيشغله {azzamo}. لو فيه حاجة غلط في صفحة دار سكّ، صلّحها عند المصدر: بيانات دار السكّ جاية من {code} بتاعها، والمراجعات جاية من Nostr.",
"terms.title": "شروط الاستخدام · Cashumints.space",
"terms.description": "شروط استخدام Cashumints.space: الموقع ده إيه، وبيعمل إيه ومش بيعمل إيه، ومين المسؤول عن إيه. مفيش حسابات، مفيش ضمانات، ومفيش أي ارتباط بأي دار سكّ.",
"terms.heading": "مدة الاستخدام",
"terms.lede": "{date} شروط دنيئة للموقع الذي يفهرس البيانات العامة بس آخر تحديث",
"terms.what.title": "ما الموقع ده",
"terms.what.p1": "(Cashumints.space) الفضاء هو مؤشر للبيانات المتاحة للجمهور عن الدار سكّ نوعان من البيانات، من مكان آخر:",
"terms.what.li1": "\"البيانات الفوقية المصغرة، مُقروءة من نقطة نهاية كل دار سكّ \"{code} اسم، وصف، نسخة برمجية، مُدعمة بـ \"نوتز\" وتفاصيل الاتصال و ما لو كان الدار سكّ قد أجاب لما سألنا آخر مرة",
"terms.what.li2": "المراجعات بتتقري من مرحّلات Nostr العامة. دي أحداث NIP-87 نشرها أصحابها على مرحّلات أي حد يقدر يستعلم منها.",
"terms.what.p2": "نجمع دي البيانات، نطبيعها، نرتبها ونريها إحنا لا نخلقه",
"terms.noAccounts.title": "لا حسابات، لا أوامر",
"terms.noAccounts.p1": "مفيش تسجيل. الموقع متاح زي ما هو من غير أي ضمان: لا إن البيانات دقيقة أو حديثة، ولا إن الموقع هيفضل متاح. بيانات دار السكّ ممكن تكون قديمة بدقايق أو ساعات، ودار السكّ ممكن تتغير بعد آخر فحص، والمرحّل ممكن مايردش، وساعتها هتشوف بيانات أقل من الموجود.",
"terms.noAccounts.p2": "استخدمه على مسؤوليتك. إحنا مش مسؤولين عن أي خسارة بسبب استخدام الموقع أو الاعتماد على حاجة قريتها هنا. شوف {link} عشان مخاطر استخدام دار سكّ Cashu بالتحديد.",
"terms.noAccounts.link": "الملتمس",
"terms.noCustody.title": "إحنا لا نحمل ماليك",
"terms.noCustody.p1": "الموقع دهِ لا يَأْخذُ حضانةَ الأموالِ. مش لديه محفظة ولا يلمس (كاش) ومينفعشه تحريك أو تجميد أو استعادة أي شيء لو كان عندك مشكلة مع الدار سكّ، مشغّل الدار سكّ هو الطرف الوحيد الذي ممكن يساعدك، وإحنا مينفعش الاتصال بهم نيابة عنك.",
"terms.noCustody.p2": "إحنا لا ننتسب إلى أي دار سكّ مدرجة هنا، ولا نعنت يدفع لقائمة أو رتبتها أو تصنيفها. وتكتشف المعادن من إعلانات نوست العامة وتصنفها خوارزمية على بيانات الاستعراض العام.",
"terms.reviews.title": "الاستعراضات هي كلمات الآخرين",
"terms.reviews.p1": "أما الاستعراضات فهي محتواها الذي ينشره المستعملون على عمليات نقل نوتر العلنية التي يقوم بها مؤلفوهم. إحنا نعرضهم إحنا لا نتحكم بهم ولم نكتبها، ومينفعشنا تحريرها، ومينفعشنا حذفها من المناورات التي تحملها: فهي أحداث موقعة على شبكة لا نمليكها.",
"terms.reviews.p2": "{link} الاستعراض هو تجربة شخص واحد ومش حقيقة مؤكدة لو نشرت واحدة، إنت مسؤول عن ما يقول. لا تنشر أي شيء غير قانوني، وتدرك أن ما تنشره عام ودائم (انظر 91827361).",
"terms.reviews.link": "الخصوصية",
"terms.reviews.p3": "ويمكننا أن نتوقف عن عرض استعراض محدد في الموقع ده، وسنفعل ذليك من أجل المحتوى غير القانوني. ده يزيله من دي الصفحات بس ده الحدث يبقى على المؤخرات وأي عميل آخر من (Nostr) يستطيع قراءته",
"terms.links.title": "الصلات بالمواقع الأخرى",
"terms.links.p1": "ووصلات المحافظ واتصالات المشغلين وكل شيء آخر يشير إلى الموقع ده هو هناك عشانها جزء من البيانات الرابط مش مصادقة وإحنا لا نعرف ما هو في النهاية الأخرى",
"terms.changes.title": "التغييرات",
"terms.changes.p1": "وممكن تتغير دي المصطلحات. التاريخ في القمة يقول لما فعلوا الموقع هو مصدر مفتوح، لذليك كل تغيير في الصفحة دي هو في تاريخ الالتزام.",
"terms.contact.title": "الاتصال",
"terms.contact.p1": "ومن الأفضل تقديم القضايا وطلبات التصفية في {github}. ويدير الموقع {azzamo}.",
"privacy.title": "الخصوصية · Cashumints.space",
"privacy.description": "الخصوصية على Cashumints.space: مفيش حسابات، مفيش كوكيز تتبّع، مفيش تحليلات. الـ localStorage بيتستخدم لتخزين البروفايلات مؤقتًا ومفيش أي حاجة بتتبعت لأي مكان. التقييمات بتتوقّع في المتصفح بتاعك.",
"privacy.heading": "الخصوصية",
"privacy.lede": "{date} نجمع القليل كما يمكن للموقع آخر تحديث",
"privacy.noAccounts.title": "مفيش حسابات",
"privacy.noAccounts.p1": "مش هناك إشارة، لا سجل ولا ملف مفيش حاجة في الموقع ده يسأليك من إنت ومفيش سجل للمستعملين للتسرب عشانه مفيش مستعملين",
"privacy.noTracking.title": "لا تتبع كوكيز، لا محللين",
"privacy.noTracking.p1": "الموقع لا يجهز اليكعك للتعقب ومفيش نص تحليلي، ولا مدير للعلامات، ولا بيكسيل، ولا تسجيل للجلسات ولا شبكة إعلانية. مفيش حاجة في الصفحة يُبلغ عن سلوكك لنا أو لأي شخص آخر",
"privacy.logs.title": "سجلات السيرفر",
"privacy.logs.p1": "الخادم على الشبكة يحتفظ بسجلات الدخول القياسية وده ما يفعله أي خادم على الشبكة سجل خط تسجيل عنوانك، الوقت، الصفحة التي طلبتها، حالة الرد، ووكيل المستعمل والمرجع الذي أرسلته وتوجد سجلات ليكشف الأخطاء وإساءة استعمال الأماكن. ولا ينضمون إلى أي شيء آخر ولا يتم بيعهم أو تقاسمهم.",
"privacy.logs.operatorLabel": "ملاحظة:",
"privacy.logs.operator": "لو كنت تدير الموقع ده، تذكر الاحتفاظ الحقيقي هنا. ويستخدم الوزع في نظام README (Migginx) مع سجل دخوله غير الافتراضي وأي تناوب في سفن التوزيع الخاصة بك (بشأن شركة Debian وUbuntu التي تعمل باللوغاريت، يوميا، مع الاحتفاظ بـ 14 يوما). تأكد ما يفعله خادمك ويستبدل دي الفقرة بالرقم",
"privacy.storage.title": "ما الذي يخزن في جهازك",
"privacy.storage.p1": "الموقع يستخدم تخزيناً محلياً لشيئين بالضبط، كلاهما يبقى على جهازك ولا يرسل إلى أي مكان",
"privacy.storage.li1": "نسخة مؤقتة من بيانات ملفات Nostr (أحداث kind 0: الأسماء والصور وعناوين NIP-05) للمراجعين اللي شوفت مراجعاتهم. ده بيوفّر الاستعلام من المرحّلات في كل صفحة. البيانات بتنتهي، والذاكرة المؤقتة ليها حد وبتشيل الأقدم الأول.",
"privacy.storage.li2": "الأفضليات المتقاطعة لذليك الموقع يتصرف بنفس الطريقة المرة القادمة",
"privacy.storage.p2": "تصفية بيانات موقعك تزيل كلاهما مفيش حاجة ينكسر، مظهر الملف يركض تاني",
"privacy.thirdParties.title": "ما الذي تتحدث عنه",
"privacy.thirdParties.p1": "وضع صفحة تربط منظفك ببعض الأماكن بجانب الموقع ده وده هو كيف أن الموقع الذي لا تتبع جانب الخادم لسه يقرأ البيانات الحية:",
"privacy.thirdParties.li1Label": "النشرات العامة",
"privacy.thirdParties.li1": "‏{strong} عبر WebSocket، لقراية المراجعات وملفات أصحابها. المرحّل بيشوف IP بتاعك والطلب، زي أي خادم بتتصل بيه. المرحّلات موجودة في {link}.",
"privacy.thirdParties.li1Link": "حول الصفحة",
"privacy.thirdParties.li2Label": "صور الأفاتار",
"privacy.thirdParties.li2": "{strong}، محشوة من أي مكان نقاط المستعرض. دي تُرسل بسياسة عدم الإحالات لذا لا يتعلم المضيف أي صفحة كنت عليها",
"privacy.publishing.title": "نشر استعراض",
"privacy.publishing.p1": "كتابة المراجعة بتحصل في متصفحك، مش على خادمنا. الحدث بيمضيه امتداد NIP-07 عندك (Alby أو nos2x أو شبيه) وبيتذاع من متصفحك للمرحّلات مباشرة. مفتاحك الخاص عمره ما بيسيب الامتداد والموقع مش بيشوفه. إحنا مابنبنيش إدارة مفاتيح خاصة ومابنطلبش مفتاح أو عبارة استرداد.",
"privacy.permanent.title": "أي شيء تنشره لـ(Nostr) عام ودائم",
"privacy.permanent.p1": "الجزء ده يستاهل تقراه مرتين. المراجعة حدث متوقّع على مرحّلات عامة. أول ما تتنشر، خلاص بقت عامة:",
"privacy.permanent.li1": "أي حد يقدر يقراها، دلوقتي وبعدين، بأي عميل Nostr. مش ”عامة على Cashumints.space“، دي عامة على الشبكة.",
"privacy.permanent.li2": "إنه مرتبط بشكل دائم بمفتاحك العام (Nostr) إلى جانب كل ما نشره المفتاح",
"privacy.permanent.li3": "حذفها طلب ومش أمر تقدر أن تنشري حدثاً للحذف وبعض البطاقات ستفي به ليكن النفقة حرّة في تجاهله وأي شخص قد نسخ الحدث معالجة استعراض منشور بصفة دائمة.",
"privacy.permanent.li4": "مينفعشنا حذفها ليك يمكننا أن نتوقف عن عرضه هنا، ذليك لا يغير شيئاً على البطولات.",
"privacy.permanent.p2": "لو كنت تريد مراجعة الدار سكّ دون أن يكون مرتبطا بهويتك الرئيسية، استخدام مفتاح نوتستر منفصل. ملاحظة أن المفتاح الذي مفيش فيه أي نشاط آخر يظهر على ده النحو في الاستعراضات القاسية، عشان اليكتابة الرئيسية الجديدة لعلامة تجارية هي استعراض نجم واحد هي نمط من القراء يستحقون رؤيته.",
"privacy.contact.title": "المسائل",
"privacy.contact.p1": "الموقع هو المصدر المفتوح: لو كنت تريد التحقق من أي من ما سبق، قراءة الرمز في {github}. الأسئلة والتصويبات ممكن تدخل في مشكلة هناك",
"disclaimer.title": "(Cashumints.space) الفضاء",
"disclaimer.description": "ما لازم تعرفه قبل ما تستخدم الدار سكّ اليكشاوي: الدار سكّ محجوز، الدار سكّ ممكن يختفي بأمواليك،",
"disclaimer.heading": "إخلاء المسؤولية",
"disclaimer.lede": "{date} إقرأ ده قبل ما تضع المال في الدار سكّ آخر تحديث",
"disclaimer.callout": "(Cashu نينت) يحتجز ماليك من أجليك لو اختفت، خندقك قد اختفى، ومش هناك من يناشد. احتفظ بس بما تستطيع أن تخسره",
"disclaimer.custodial.title": "النزلاء يحتجزون",
"disclaimer.custodial.p1": "لما تَدْعنُ كاش، تَدْعمُ قليلاً إلى مشغلِ الدار سكِّ ويُصبحُ توكرزَ ظهراً. دي الشارات هي مطالبة على ذليك المشغل. هم لَمْ يَعْضوكَ، والمشغل هو الذي يُقرّرُ أَنْ يَحترمَهم.",
"disclaimer.custodial.p2": "وده يعني أن كل دار سكّ تحمل نفس المخاطر: إذ يمكن للمشغل أن يختفي، ويمكن إجباره على التوقف، أو أن يفقد الأموال من خلال حشرة أو قرصنة، أو يمكن ببساطة أن يطفئ الخادم. ومفيش تأمين للودائع، ولا تحميل، ولا خط دعم، ومفيش أي سبيل انتصاف قانوني يستحق الاعتماد عليه. ده صحيح من أعلى الدار سكّ في الموقع ده",
"disclaimer.custodial.p3": "(Cashu) مصمم للمبالغ الصغيرة تتحرك بسرعة إستخدمه بدي الطريقة، أمسك ما إنت على وشك أن تنفقه، ومش مدخراتك، وتنشر عبر الدار سكّ بدلاً من وقوف كل شيء في واحد.",
"disclaimer.reviews.title": "الاستعراضات هي آراء، غير متحقق منها",
"disclaimer.reviews.p1": "كُلّ مراجعة هنا كَتبتْ مِن قِبل شخص ما على Nostr ووقعَ بمفتاحِهم. \"التوقيع\" يعني أننا نستطيع أن نخبرك أي مفتاح كتبها ولا يعني الاستعراض أن صاحب البلاغ استخدم الدار سكّ، أو أن صاحب البلاغ مش المشغل، أو المنافس، أو شخص واحد بعشرة مفاتيح.",
"disclaimer.reviews.p2": "إحنا لا نتحقق من المستعرضين، وإحنا لا نحرر أو نلغي الاستعراضات التي نختلف معها. اقرأ القليل، انظر إلى ما يقولونه في الواقع، ويزن دار سكّ مع ثلاثة استعراضات مختلفة عن واحد مع ثلاثمائة.",
"disclaimer.status.title": "أونلاين معناها متاحة، مش موثوقة",
"disclaimer.status.p1": "نتفقد كل دار سكّ بشكل منتظم ونظهر ما لو كانت الإجابة. وده كل ما يعنيه الوضع الأخضر: فقد رد الخادم. لا تقول شيئاً عما لو كان المشغّل مُذيباً، صادقاً، أو ما زال ينوي أن يُكرّم مُخلّفاتك غداً.",
"disclaimer.status.p2": "الدار سكّ ممكن يكون على الإنترنت حتى اللحظة التي لا يكون. التأريخ في وقت متأخر، دعم NUTs ونسخة البرمجيات أقول ليكم شيئا عن كيفية تشغيل الدار سكّ، بس لا أحد منهم وعد عن أمواليك.",
"disclaimer.ranking.title": "كيف يعمل الترتيب وما لا يعنيه",
"disclaimer.ranking.p1": "ويُصدر أمر عن القُصَّر خوارزمية بشأن بيانات الاستعراض العام: التقييم، وعدد الاستعراضات، والنشاط الأخير، مع اقتراب الدار سكّ على الإنترنت من الدار سكّ الخارجي عن طريق التقصير. التفاصيل في رمز المصدر المفتوح",
"disclaimer.ranking.p2": "لا أحد يدفع ثمن التنسيب مش هناك مكان مشرف، ولا قائمة معززة، ولا ترتيب مع أي دار سكّ في الموقع ده. درجة عالية تعني أن الخوارزمية كانت تحب البيانات العامة ومفيش حاجة أكثر وهي مش توصية ومش تقييما للسلامة.",
"disclaimer.notAdvice.title": "دي مش نصيحة مالية",
"disclaimer.notAdvice.p1": "ومفيش في الموقع ده أي نصائح مالية أو استثمارية أو قانونية أو ضريبية. إحنا لا نعرف موقفك وإحنا لا ننصحك استخدام الدار سكّ، واختيار أي واحد، هو قرارك.",
"disclaimer.dyor.title": "قم ببحثك الخاص",
"disclaimer.dyor.li1": "ابحث عن المشغل الدار سكّ الذي يديره شخص له سمعة عامة ليخسره هو عرض مختلف من URL مجهول.",
"disclaimer.dyor.li2": "اقرأي شروط الدار سكّ و داء الاغراض الدار سكّ الذي يسمي نفسه اختبار أو بيتا يعني ذليك.",
"disclaimer.dyor.li3": "ابدأي بالصغر قليلًا، أنفقه، أذيبه، وأرى ما سيحدث قبل ما تثق به أكثر.",
"disclaimer.dyor.li4": "راقب الحدود فغالبا ما يكون الحد الأقصى المنخفض علامة على وجود مشغِّل حريص، ولا يكون ارتفاعه علامة على وجود عامل آمن.",
"disclaimer.dyor.li5": "وتذكروا أن (توكن) هو من يمليكه",
"disclaimer.site.title": "حول الموقع نفسه",
"disclaimer.site.p1": "البيانات هنا ممكن تكون غلط أو قديمة. دور السكّ بتتفحص في مواعيد محددة، والمرحّلات ساعات مابتردش، والصفحة اللي بتقراها اتبنت في وقت سابق. الموقع متاح زي ما هو من غير ضمان. شوف {link}.",
"disclaimer.site.link": "المصطلحات المستخدمة",
"disclaimer.updated": "2026 آب/أغسطس",
"login.getPrimal": "الحصول على Primal",
"reviews.dialog.identity.extensionGone": "تمديدك لم يجيب اركب مرة اخرى او استخدم لافتة نائية",
"seo.orgDescription": "دليل لدور سكّ Cashu، بحالتها المباشرة ومراجعات المجتمع المتوقعة على Nostr.",
"seo.ogImageAlt": "(Cashumints.space)، الفضاء، ابحث عن (Cashu نين) تقدر الوثوق به.",
"seo.mint.title.one": "مراجعات {name}: تقييم {rating} من {n} مراجعة | Cashumints.space",
"seo.mint.title.other": "مراجعات {name}: تقييم {rating} من {n} مراجعات | Cashumints.space",
"seo.mint.titleOffline.one": "{name}: أوفلاين بقالها {n} يوم | Cashumints.space",
"seo.mint.titleOffline.other": "{name}: أوفلاين بقالها {n} يوم | Cashumints.space",
"seo.mint.titleNoReviews": "{name}: معلومات ومراجعات دار سكّ Cashu | Cashumints.space",
"seo.fedimint.titleNoReviews": "{name}: معلومات ومراجعات اتحاد Fedimint | Cashumints.space",
"seo.desc.fallback": "مراجعات المجتمع لـ{name} ‏({domain}).",
"seo.desc.fallbackDomain": "مراجعات المجتمع لدار سكّ Cashu على {domain}.",
"seo.desc.rated.one": "تقييم {rating}/5 من {n} مراجعة Nostr متوقعة.",
"seo.desc.rated.other": "تقييم {rating}/5 من {n} مراجعات Nostr متوقعة.",
"seo.desc.noReviews": "لا استعراضات بعد",
"seo.desc.status": "{status}.",
"seo.desc.statusLimits": "{status}، الحدود {limits}.",
"seo.desc.fedModules": "{status}. الوحدات: {modules}.",
"seo.desc.fedModulesNetwork": "{status}. الوحدات: {modules} على {network}.",
"seo.mint.ogAlt.one": "{name}: {rating} نجمة من {n} تقييم على Cashumints.space",
"seo.mint.ogAlt.other": "{name}: {rating} نجمة من {n} تقييمات على Cashumints.space",
"seo.mint.ogAltNoReviews": "{name} على Cashumints.space",
"nav.home": "البيت",
"mint.more.title": "المزيد من الدار سكّ",
"mint.more.all": "انظر كل الدار سكّ",
"nav.fedimints": "اتحادات Fedimint",
"status.announced": "بس",
"card.unnamedFederation": "الاتحاد غير المسمى",
"card.notChecked": "لم يتم فحصها",
"fedimints.title": "كل اتحادات Fedimint · Cashumints.space",
"fedimints.description": "كل اتحادات Fedimint وعددها {total} والمعلنة على Nostr: ‏{online} اتأكد إنها شغالة بفحص خارجي، و{announced} اتعلن عنها من غير تأكيد. تقييمات المجتمع والوحدات وأكواد الدعوة لكل اتحاد.",
"fedimints.heading": "جميع الاتحادات",
"fedimints.lede": "كل اتحاد Fedimint أعلن على شبكة Nostr ومفيش في الاتحاد نقطة نهاية خاصة بالمركز العام، لذا تأكد أن بعضها يجري من خلال شيك خارجي، والبقية مدرجة بالضبط كما أعلن عنها.",
"fedimints.search.label": "الاتحادات البحثية بالاسم أو العاطلة",
"fedimints.search.placeholder": "البحث بالاسم أو الاتحاد",
"fedimints.sort.label": "الاتحادات الترفيهية",
"fedimints.hideOffline": "إخفاء خارج الخط",
"fedimints.showing": "بنعرض {total} اتحاد ({online} اتأكد إنه شغال، و{announced} مُعلن بس)",
"fedimints.showingFiltered": "بنعرض {shown} من {total} اتحاد ({online} اتأكد إنه شغال، و{announced} مُعلن بس)",
"fedimints.announcedNote.one": "واحدة من دي تم الإعلان عنها في (Nostr) ولم يتأكد منها شيء منذ تشغيلها وهي مدرجة على النحو المعلن عنه بدلا من أن تكون على شبكة الإنترنت أو خارجها، عشان أيا منها لن يكون صحيحا.",
"fedimints.announcedNote.other": "أُعلن عن {n} من هؤلاء في Nostr ولم يتأكد أي شيء منذ تشغيلهم. وهي مدرجة على النحو المعلن عنه لا على أنها على شبكة الإنترنت أو خارجها، عشان أيا منها لن يكون صحيحا.",
"fedimints.noResults": "مفيش تطابق بين الاتحادات جربي اسماً، أو عانقي الاتحاد",
"fedimints.emptyIndex": "لم يتم الإعلان عن أي اتحادات في إعادة الموقع ده وتملأ القائمة نفسها لما تصل الإعلانات.",
"fedimint.crumb": "اتحادات Fedimint",
"fedimint.breadcrumb": "مسار الصفحة",
"fedimint.unnamed": "الاتحاد غير المسمى",
"fedimint.idLabel": "الاتحاد",
"fedimint.kind.federationId": "الاتحاد",
"fedimint.moduleCount.one": "{n}",
"fedimint.moduleCount.other": "{n} وحدة",
"fedimint.copyInvite": "رمز الدعوة",
"fedimint.confirmed.label": "تم تأكيدها",
"fedimint.confirmed.never": "أبداً",
"fedimint.confirmed.by": "بحلول عام {source}",
"fedimint.confirmed.unattributed": "الشيك الأخير",
"fedimint.announcedOn": "المعلنة {when}",
"fedimint.announcedUnknown": "تاريخ الإعلان",
"fedimint.modules.title": "الوحدات",
"fedimint.modules.none": "إعلان ده الاتحاد لا يورد أي وحدات",
"fedimint.modules.note": "الوحدات هي الأجزاء التي يديرها الاتحاد الإعلان يقول أيهما موجود، ومفيش حاجة حول ما لو كان أي منهما يقبل الودائع.",
"fedimint.modules.notListed": "غير مدرجة",
"fedimint.invite.title": "رمز دعوة",
"fedimint.invite.count.one": "{n} الرمز",
"fedimint.invite.count.other": "{n}رمز",
"fedimint.invite.note": "استرح ده في محفظة Fedimint للإنضمام إنها تحمل عناوين الإتحاد و الأوصياء عليها ومن المفترض أن يتم تقاسمها",
"fedimint.invite.kind": "رمز دعوة",
"fedimint.invite.qr": "QR",
"fedimint.invite.qrCaption": "امسح عشان تنضم لـ{name}",
"fedimint.details.federationId": "الاتحاد",
"fedimint.details.network": "الشبكة",
"fedimint.details.announcedBy": "أعلن",
"fedimint.details.announced": "إعلانات",
"fedimint.more.title": "المزيد من الاتحادات",
"fedimint.more.all": "انظر جميع الاتحادات",
"fedimint.disclaimer": "الإنضمام إلى الاتحاد يعني الثقة بأوليائه بأمواليك فالرات هي آراء مجتمعية، وتؤكد أن الركض وسيلة يمكن الوصول إليها ومش جديرة بالثقة.",
"fedimint.module.lightning": "Lightning",
"fedimint.module.mint": "دار سكّ ecash",
"fedimint.module.wallet": "محفظة الشاين",
"fedimint.module.meta": "البيانات الوصفية",
"fedimint.module.stability_pool": "مجمع الاستقرار",
"fedimint.module.multi_sig_stability_pool": "مجمع الاستقرار (مولتيسج)",
"fedimint.module.fedi-social": "التعافي الاجتماعي",
"fedimint.network.mainnet": "Btcoin",
"fedimint.network.signet": "Signet",
"fedimint.network.testnet": "Testnet",
"fedimint.network.testnet4": "Testnet4",
"fedimint.network.regtest": "الحكم",
"feed.ecosystem.group": "النظام الإيكولوجي",
"feed.ecosystem.all": "كل شيء",
"feed.ecosystem.cashu": "النقدية",
"feed.ecosystem.fedimint": "Fedimint",
"feed.filterBySubject": "ملء بواسطة الدار سكّ أو الاتحاد",
"feed.anySubject": "أي دار سكّ أو اتحاد",
"feed.clearSubject": "نظف الاسم",
"home.topFedimints.title": "Fedimints",
"home.topFedimints.sub": "مصنفة حسب الاستعراضات والتقييم",
"home.topFedimints.all": "{n} جميع الاتحادات",
"reviews.validate.subject": "مش هناك ما يشير إلى ده الاستعراض.",
"mint.warnings.fedimintOffline.lead": "أُبلغ عنه خارج الإنترنت",
"mint.warnings.fedimintOffline.body.since": "آخر فحص وصل للاتحاد ده كان يوم {date} (من {days}). الحراس ماردوش من وقتها. اعتبر الفلوس اللي هناك معرّضة للخطر، وماتنضمش بفلوس جديدة.",
"mint.warnings.fedimintOffline.body.never": "مفيش شيك لده الاتحاد قد وصل إليه، وآخر واحد فشل. معالجة الأموال المحتفظ بها هناك على أنها معرضة للخطر، ولا تنضم إليها بأموال جديدة.",
"mint.warnings.fedimintOffline.meta.since": "المبلغ عنها منذ {date}",
"mint.warnings.fedimintOffline.meta.never": "مُبلغ عنه خارج الإنترنت، لم يتم الوصول إليه من قبل",
"mint.warnings.neverConfirmed.lead": "لم يتم تأكيده",
"mint.warnings.neverConfirmed.body.reviewed": "الاتحاد ده اتعلن عنه على Nostr يوم {date}، من {days}، ومفيش حاجة أكدت من وقتها إنه شغال. المراجعات تحت هي علامة النشاط الوحيدة في الصفحة؛ وكل حاجة تانية جاية من الإعلان.",
"mint.warnings.neverConfirmed.body.quiet": "الاتحاد ده اتعلن عنه على Nostr يوم {date}، من {days}، ومفيش حاجة أكدت من وقتها إنه شغال، ومحدش راجعه قريب. كل اللي تحت جاي من الإعلان.",
"mint.warnings.neverConfirmed.meta": "اتعلن عنه {date}، وماتأكدش أبدًا",
"footer.allFedimints": "جميع الاتحادات",
"reviews.dialog.bodyPlaceholder.cashu": "كيف كان القذف والذوبان ودعم العمل؟ كن محدداً",
"reviews.dialog.bodyPlaceholder.fedimint": "كيف تم الإنضمام، ودفعات Lightning ودعم العمل؟ كن محدداً",
"fedimint.details.title": "تفاصيل الاتحاد",
"nav.lnurlMints": "دور سكّ LNURL",
"footer.allLnurlMints": "كل دور سكّ LNURL",
"lnurlMints.title": "منّاتات LNURL: منّاتات سندات lnurlcash لحاملها، مع المراجعات | Cashumints.space",
"lnurlMints.description": "كل منّاتات LNURL البالغ عددها {total} والمفهرسة من شبكة Nostr: {online} متصلة، {offline} غير متصلة. تقييمات المجتمع وحدود السحب والميزات لكل واحدة.",
"lnurlMints.heading": "منّاتات LNURL",
"lnurlMints.lede": "كل منّاتة LNURL يعرفها هذا الموقع. منّاتة LNURL تُصدر سندات lnurlcash لحاملها: سندات Lightning تحتفظ بها كسرّ، ويمكن تدويرها أو تقسيمها أو دمجها أو صهرها مجددًا إلى دفعة Lightning. تُفحص كل واحدة عبر HTTPS كل بضع دقائق.",
"lnurlMints.search.label": "ابحث في منّاتات LNURL",
"lnurlMints.search.placeholder": "ابحث بالاسم أو العنوان…",
"lnurlMints.sort.label": "ترتيب منّاتات LNURL حسب",
"lnurlMints.hideOffline": "إخفاء غير المتصلة",
"lnurlMints.showing": "عرض {total} منّاتة LNURL ({online} متصلة، {offline} غير متصلة)",
"lnurlMints.showingFiltered": "عرض {shown} من {total} منّاتة LNURL ({online} متصلة، {offline} غير متصلة)",
"lnurlMints.noResults": "لا توجد منّاتة LNURL مطابقة.",
"lnurlMints.emptyIndex": "لم تُفهرس أي منّاتة LNURL بعد.",
"lnurl.crumb": "منّاتات LNURL",
"lnurl.breadcrumb": "مسار التنقل",
"lnurl.copyAddress": "نسخ العنوان",
"lnurl.more.title": "المزيد من منّاتات LNURL",
"lnurl.more.all": "كل منّاتات LNURL ←",
"lnurl.disclaimer": "منّاتة LNURL تحتفظ بالساتوشي الخاص بك بينما تحتفظ أنت بسنداتها، فاستخدامها يعني الوثوق بمشغّلها. التقييمات آراء من المجتمع، و«متصلة» تعني يمكن الوصول إليها، لا أنها جديرة بالثقة.",
"lnurl.features.title": "الميزات",
"lnurl.features.none": "لم تُعلن هذه المنّاتة عمّا تدعمه.",
"lnurl.features.note": "الميزات هي ما أعلنه مشغّل المنّاتة على Nostr. وحيثما أمكن لهذا الموقع التحقق من إحداها عبر HTTPS فإنه يفعل، ويُنبّه عندما يكون شيء مُعلن غير متاح حاليًا.",
"lnurl.features.notListed": "غير مُعلن",
"lnurl.features.unavailable": "غير متاح",
"lnurl.feature.mint": "السكّ عبر Lightning (LUD-06)",
"lnurl.feature.melt": "الصهر إلى Lightning",
"lnurl.feature.notes": "تدوير / تقسيم / دمج السندات",
"lnurl.feature.lud16": "عنوان Lightning",
"lnurl.feature.lud21": "التحقق من الدفع (LUD-21)",
"lnurl.feature.signed-notes": "سندات موقّعة (تحقق دون اتصال)",
"lnurl.feature.onion": "عنوان Tor",
"lnurl.feature.rotate": "تدوير سند",
"lnurl.feature.split": "تقسيم سند",
"lnurl.feature.merge": "دمج السندات",
"lnurl.feature.lud06": "LUD-06 payRequest",
"lnurl.feature.lud03": "LUD-03 withdrawRequest",
"lnurl.limits.label": "حدود السحب",
"lnurl.limits.notPublished": "غير منشور",
"lnurl.limits.withdrawRow": "السحب",
"lnurl.limits.disabled": "معطّل",
"lnurl.limits.sats.one": "{n} ساتوشي",
"lnurl.limits.sats.two": "{n} ساتوشي",
"lnurl.limits.sats.few": "{n} ساتوشي",
"lnurl.limits.sats.many": "{n} ساتوشي",
"lnurl.limits.sats.other": "{n} ساتوشي",
"lnurl.details.title": "تفاصيل المنّاتة",
"lnurl.details.address": "عنوان Lightning",
"lnurl.details.mintPubkey": "المفتاح العام للمنّاتة",
"lnurl.details.onion": "عنوان Tor",
"lnurl.details.fee": "رسوم السكّ",
"lnurl.details.node": "العقدة",
"lnurl.details.capacity": "السعة المُعلنة",
"lnurl.kind.lightningAddress": "عنوان Lightning",
"lnurl.kind.mintPubkey": "المفتاح العام للمنّاتة",
"lnurl.kind.onionAddress": "عنوان Tor",
"lnurl.confirmed.label": "آخر تأكيد",
"lnurl.confirmed.when": "تم التأكيد {when}",
"lnurl.fee.base.one": "{n} ساتوشي",
"lnurl.fee.base.two": "{n} ساتوشي",
"lnurl.fee.base.few": "{n} ساتوشي",
"lnurl.fee.base.many": "{n} ساتوشي",
"lnurl.fee.base.other": "{n} ساتوشي",
"lnurl.fee.percent": "{percent}٪",
"home.topLnurl.title": "أفضل منّاتات LNURL",
"home.topLnurl.sub": "مرتّبة حسب المراجعات والتقييم",
"home.topLnurl.all": "كل منّاتات LNURL البالغة {n} ←",
"feed.ecosystem.lnurl": "LNURL",
"reviews.dialog.bodyPlaceholder.lnurl": "كيف كان السكّ والصهر وتدوير السندات؟ كن محددًا.",
"reviews.byUrl.open": "اكتب مراجعة",
"reviews.byUrl.title": "اكتب مراجعة",
"reviews.byUrl.lede": "الصق العنوان. لو الموقع لسه ما يعرفوش، هيتفحص ويتضاف دلوقتي.",
"reviews.byUrl.label.cashu": "رابط الـ mint",
"reviews.byUrl.label.fedimint": "رمز دعوة الاتحاد",
"reviews.byUrl.label.lnurl": "رابط mint من نوع LNURL",
"reviews.byUrl.placeholder.cashu": "mint.example.com",
"reviews.byUrl.placeholder.fedimint": "fed11…",
"reviews.byUrl.placeholder.lnurl": "mint.example.com",
"reviews.byUrl.check": "افحص هذا العنوان",
"reviews.byUrl.checking": "بنفحص…",
"reviews.byUrl.back": "استخدم عنوان تاني",
"reviews.byUrl.existing": "مدرج بالفعل",
"reviews.byUrl.indexed": "اتضاف دلوقتي",
"reviews.byUrl.confirmClose": "نقفل؟ العنوان اللي كتبته مش هيتحفظ.",
"reviews.byUrl.detected": "استجاب هذا العنوان بوصفه mint من نوع {kind}.",
"reviews.byUrl.switchTo": "راجعه بوصفه mint من نوع {kind}",
"reviews.byUrl.error.empty": "اكتب عنوان الأول.",
"reviews.byUrl.error.badUrl": "ده مش شكله رابط mint.",
"reviews.byUrl.error.badInvite": "هذا ليس رمز دعوة Fedimint. الرموز تبدأ بـ fed11.",
"reviews.byUrl.error.badInput": "لا يستطيع هذا الموقع فحص ذلك العنوان.",
"reviews.byUrl.error.blocked": "هذا ليس عنوانًا عامًا، لذا لا يمكن فحصه.",
"reviews.byUrl.error.notAMint": "استجاب هذا العنوان، لكن ليس بوصفه mint من هذا النوع.",
"reviews.byUrl.error.wrongType": "استجاب هذا العنوان، لكن بوصفه mint من نوع آخر.",
"reviews.byUrl.error.unverifiable": "لم يستجب شيء على ذلك العنوان، ولا أثر له على Nostr حتى الآن.",
"reviews.byUrl.error.rateLimited.one": "ده فحص كتير من نفس الاتصال. جرّب تاني بعد حوالي {n} دقيقة.",
"reviews.byUrl.error.rateLimited.other": "ده فحص كتير من نفس الاتصال. جرّب تاني بعد حوالي {n} دقيقة.",
"reviews.byUrl.error.network": "تعذّر الوصول إلى الفهرس. تحقّق من اتصالك ثم حاول مجددًا.",
"seo.lnurl.titleNoReviews": "{name}: معلومات ومراجعات منّاتة LNURL | Cashumints.space",
"seo.desc.statusWithdraw": "{status}، السحب {min}–{max} ساتوشي.",
"mint.chip.noFunding": "لا سكّ ولا صهر",
"mint.warnings.lnurlWithdrawalsDisabled.lead": "السحب معطّل.",
"mint.warnings.lnurlWithdrawalsDisabled.body.online": "تُعلن هذه المنّاتة حدًّا أقصى للسحب يساوي صفرًا، فلا يمكن استبدال أي سند تُصدره بأي شيء. لا تسكّ هنا حتى يتغير ذلك.",
"mint.warnings.lnurlWithdrawalsDisabled.body.offline": "عند آخر اتصال بها كانت هذه المنّاتة تُعلن حدًّا أقصى للسحب يساوي صفرًا، فلم يكن ممكنًا استبدال أي سند تُصدره بأي شيء. لا تسكّ هنا حتى يتغير ذلك.",
"mint.warnings.lnurlWithdrawalsDisabled.meta.online": "السحب معطّل حاليًا",
"mint.warnings.lnurlWithdrawalsDisabled.meta.offline": "كان السحب معطّلًا عند آخر ظهور",
"mint.warnings.lnurlInvalid.lead": "نقطة النهاية تستجيب لكنها غير صالحة.",
"mint.warnings.lnurlInvalid.body": "المضيف يستجيب، لكن ليس بإعلان منّاتة يستطيع هذا الموقع قراءته، لذا قد لا يعمل السحب. كل ما في الأسفل هو آخر حالة أمكن قراءتها، والمراجعات لا تزال تعمل.",
"mint.warnings.lnurlInvalid.meta": "يستجيب، لكن ليس بإعلان منّاتة صالح",
"mint.warnings.lnurlNoFunding.lead": "السكّ والصهر غير متاحين.",
"mint.warnings.lnurlNoFunding.body": "لا تزال السندات الموجودة قابلة للتدوير أو التقسيم أو الدمج، لكن لا شيء يدخل أو يخرج عبر Lightning حاليًا. المنّاتة نفسها تعمل وتستجيب؛ لكن عقدة Lightning خلفها غير قابلة للوصول منها.",
"mint.warnings.lnurlNoFunding.meta": "السكّ والصهر غير متاحين",
"mint.warnings.also.noFunding": "كانت عقدة Lightning الخاصة بها غير قابلة للوصول أيضًا، فلم يكن ممكنًا سكّ أو صهر أي شيء."
}
+794
View File
@@ -0,0 +1,794 @@
{
"site.name": "Cashumints.space",
"common.close": "إغلاق",
"common.cancel": "إلغاء",
"common.done": "تم",
"common.back": "العودة",
"common.retry": "إعادة المحاولة",
"common.connect": "اتصل",
"common.copied": "تم نسخه",
"common.copyFull": "نسخ {kind} بالكامل",
"common.previousPage": "الصفحة السابقة",
"common.nextPage": "الصفحة التالية",
"common.reviewPages": "صفحات المراجعة",
"time.never": "أبدًا",
"time.justNow": "قبل قليل",
"time.none": "-",
"time.unknownDate": "تاريخ غير معروف",
"time.ago.minute": "منذ {n} د",
"time.ago.hour": "منذ {n} س",
"time.ago.day": "منذ {n} ي",
"time.ago.week": "منذ {n} أ",
"time.ago.month": "منذ {n} ش",
"time.ago.year": "منذ {n} سنة",
"time.short.minute": "{n} د",
"time.short.hour": "{n} س",
"time.short.day": "{n} ي",
"time.short.week": "{n} أ",
"time.short.month": "{n} ش",
"time.short.year": "{n} سنة",
"status.online": "عبر الإنترنت",
"status.offline": "غير متصل",
"status.degraded": "غير قابل للاتصال",
"status.unknown": "لم يتم التحقق منه بعد",
"nav.skip": "الانتقال إلى المحتوى",
"nav.main": "الرئيسية",
"nav.mints": "جهات إصدار Cashu",
"nav.reviews": "التقييمات",
"nav.wallets": "المحافظ",
"nav.about": "نبذة عن",
"nav.searchMints": "البحث في {n} جهة إصدار",
"lang.label": "اللغة",
"lang.switcher": "اختر لغة",
"footer.copyright": "© {year} Cashumints.space",
"footer.allMints": "كل جهات إصدار Cashu",
"footer.allReviews": "جميع التقييمات",
"footer.wallets": "المحافظ",
"footer.about": "نبذة عن",
"footer.disclaimer": "إخلاء المسؤولية",
"footer.terms": "الشروط",
"footer.privacy": "الخصوصية",
"footer.madeBy": "صُمم بحب ❤️ بواسطة {azzamo} · مدعوم بواسطة Cashu وNIP-87",
"footer.warning": "جهات الإصدار ائتمانية. يمكن لجهة إصدار أن تختفي بأموالك.",
"footer.tagline": "تقييمات المجتمع لجهات إصدار ecash، موقَّعة على Nostr.",
"footer.col.explore": "استكشاف",
"footer.col.site": "الموقع",
"footer.nav": "تذييل الصفحة",
"home.title": "Cashumints.space · ابحث عن جهة إصدار Cashu يمكنك الوثوق بها",
"home.description": "ابحث عن جهة إصدار Cashu يمكنك الوثوق بها. تمت فهرسة {mints} جهة إصدار مع {reviews} مراجعة مجتمعية موقعة على Nostr، إلى جانب حالة التشغيل المباشرة وNUTs المدعومة وبرمجيات جهة الإصدار.",
"home.hero.title": "ابحث عن جهة إصدار Cashu",
"home.hero.titleEm": "يمكنك الوثوق به",
"home.hero.lede": "كل جهة إصدار على شبكة Nostr، مع مراجعات حقيقية من المجتمع. لا حراس بوابة، بل مراجعات موقعة يمكنك التحقق منها بنفسك.",
"home.search.label": "ابحث عن جهات إصدار حسب الاسم أو النطاق أو عنوان URL",
"home.search.placeholder": "ابحث حسب الاسم أو النطاق، أو الصق رابطًا جهة إصدار…",
"home.filters.mostReviewed": "الأكثر تقييمًا",
"home.filters.highestRated": "الأعلى تقييمًا",
"home.filters.recentlyActive": "نشط مؤخرًا",
"home.filters.websockets": "يدعم WebSockets",
"home.top.title": "الأعلى جهات إصدار Cashu",
"home.top.sub": "مرتبة حسب التعليقات والتقييمات",
"home.top.all": "الكل {n} جهات إصدار →",
"home.latest.title": "أحدث التقييمات",
"home.latest.sub": "بث مباشر من شبكة Nostr",
"home.latest.prev": "التعليقات السابقة",
"home.latest.next": "المزيد من التقييمات",
"home.latest.all": "جميع التقييمات →",
"home.latest.empty": "لم يتمكن النظام من قراءة أي تعليقات من المرحلات في الوقت الحالي. افتح أي صفحة تحمل جهة إصدار لقراءة التعليقات وكتابتها مباشرةً.",
"home.why.title": "لماذا يمكن الوثوق بهذه التقييمات؟",
"home.why.signed.title": "تم التوقيع عليه، ولم يتم تقديمه",
"home.why.signed.body": "كل تقييم هو حدث يحمل {link} وموقع بمفتاح Nostr الخاص بمؤلفه. ولا نقوم نحن بتعديل أي شيء هنا أو تصنيفه أو حذفه.",
"home.why.signed.link": "NIP-87",
"home.why.verify.title": "تحقق من ذلك بنفسك",
"home.why.verify.body": "يتم تخزين التعليقات على خوادم الترحيل العامة Nostr، وليس في قاعدة بياناتنا. ما عليك سوى توجيه أي عميل إلى نفس خوادم الترحيل هذه وسترى نفس البيانات.",
"home.why.checked.title": "يتم فحصه باستمرار",
"home.why.checked.body": "نقوم بفحص كل جهة إصدار للتحقق من وقت التشغيل وإصدار البرنامج والحدود وNUTs المدعوم، لذا فإن المواصفات التي تراها حديثة وليست مستمدة من تقارير ذاتية.",
"home.cta.title": "هل تفكر في جهة إصدار؟ اقرأ هذا أولاً.",
"home.cta.body": "جهة إصدار يحتوي على ecash. اقرأ ما وجده الآخرون قبل أن تدخل sats، واترك تعليقًا بعد أن تستخدمه بنفسك.",
"home.cta.explore": "استكشف جميع التي تبدأ بـ جهات إصدار",
"home.cta.learn": "تعرف على Cashu",
"pulse.mintsIndexed": "جهات إصدار مُفهرس",
"pulse.reviewsOnNostr": "التعليقات على Nostr",
"pulse.lastReview": "آخر مراجعة",
"pulse.mintsOnline": "جهات إصدار متصل بالإنترنت",
"pulse.indexer": "المفهرس",
"pulse.connected": "متصل",
"pulse.stale": "قديم",
"mints.title": "الكل جهات إصدار Cashu · Cashumints.space",
"mints.description": "جميع {total} وجهات إصدار Cashu المُفهرسة من شبكة Nostr، {online} متصل بالإنترنت و{offline} غير متصل بالإنترنت، مع تقييمات المجتمع ووقت التشغيل وجهة إصدار البرامج. ابحث في القائمة الكاملة وقم بفرزها.",
"mints.heading": "كل جهات إصدار Cashu",
"mints.lede": "جميع العناوين جهة إصدار التي تم اكتشافها على شبكة Nostr، بما في ذلك تلك التي لم تعد موجودة. أما العناوين جهات إصدار غير المتصلة بالإنترنت، فستظل مدرجة في القائمة حتى تتمكن من العثور عليها ومراجعتها.",
"mints.search.label": "ابحث عن جهات إصدار حسب الاسم أو النطاق",
"mints.search.placeholder": "البحث حسب الاسم أو النطاق…",
"mints.sort.label": "جهات إصدار",
"mints.sort.score": "الأفضل بشكل عام",
"mints.sort.rating": "الأعلى تقييمًا",
"mints.sort.reviews": "الأكثر تقييمًا",
"mints.sort.recent": "تمت مراجعتها مؤخرًا",
"mints.sort.seen": "تمت مشاهدتها مؤخرًا على الإنترنت",
"mints.hideOffline": "إخفاء الحالة \"غير متصل\"",
"mints.showing": "يتم عرض {total} وجهات إصدار ({online} متصل، {offline} غير متصل)",
"mints.showingFiltered": "يتم عرض {shown} من أصل {total} جهات إصدار ({online} متصلون، {offline} غير متصلين)",
"mints.noResults": "لا توجد نتائج مطابقة لـ جهات إصدار. جرب إدخال اسم نطاق، أو الصق رابطًا يحتوي على جهة إصدار.",
"mints.clearSearch": "مسح البحث",
"card.noRatings": "لا توجد تقييمات حتى الآن",
"card.reviews.one": "{n} تقييم",
"card.reviews.other": "{n} تقييم",
"card.lastSeen": "شوهد آخر مرة {when}",
"card.neverSeen": "لم يُشاهد على الإنترنت من قبل",
"card.reviewed": "تمت مراجعة {when}",
"card.noReviews": "لا توجد تعليقات حتى الآن",
"mint.title": "{name} · جهة إصدار Cashu تقييمات",
"mint.titleFedimint": "{name} · مراجعات اتحاد Fedimint",
"mint.titleLnurl": "{name} · مراجعات mint من نوع LNURL",
"mint.breadcrumb": "مسار التنقل",
"mint.crumbMints": "جهات إصدار Cashu",
"mint.copyUrl": "نسخ رابط جهة إصدار",
"mint.qrButton": "جهة إصدار ريال قطري",
"mint.writeReview": "اكتب تقييمًا",
"mint.rankByScore": "#{rank} حسب النتيجة",
"mint.tagLightning": "Lightning",
"mint.summary": "جهة إصدار ملخص",
"mint.rating.label": "التصنيف المجتمعي",
"mint.rating.none": "لا توجد تقييمات حتى الآن",
"mint.rating.reviews.one": "{n} - تقييم",
"mint.rating.reviews.other": "{n} تقييم",
"mint.rating.unrated.one": "{n} لم يقدم أي تقييم",
"mint.rating.unrated.other": "{n} لم يقدم أي تقييم",
"mint.activity.label": "النشاط",
"mint.activity.since": "منذ المراجعة الأخيرة",
"mint.activity.noReviews": "لا توجد تعليقات حتى الآن",
"mint.activity.recent.one": "{n} تقييم خلال الـ 90 يوماً الماضية",
"mint.activity.recent.other": "{n} تقييم خلال الـ 90 يوماً الماضية",
"mint.limits.label": "الحدود",
"mint.limits.notPublished": "لم يُنشر",
"mint.limits.min": "دقيقة",
"mint.limits.max": "الحد الأقصى",
"mint.limits.mintRow": "جهة إصدار",
"mint.limits.meltRow": "يذوب",
"mint.limits.disabled": "معاق",
"mint.limits.range": "{min} إلى {max} {unit}",
"mint.limits.upTo": "حتى {max} {unit}",
"mint.limits.from": "من {min} إلى {unit}",
"mint.software.label": "برنامج جهة إصدار",
"mint.software.unknown": "غير معروف",
"mint.checked": "تم التحقق من {when}",
"mint.lastSeen": "شوهد آخر مرة {when}",
"mint.neverReached": "لم يتم الوصول إليها قط",
"mint.uptime.checks": "{ok} من أصل {total} شيك يمكن الوصول إليها",
"mint.uptime.percent": "{percent}% يمكن الوصول إليهم خلال 30 يومًا",
"mint.uptime.description": "نجحت {ok} عملية فحص من أصل {total} عملية فحص",
"mint.operator.title": "من المشغل",
"mint.operator.motd": "رسالة اليوم",
"mint.operator.beta": "يصف هذا جهة إصدار نفسه بأنه تجريبي أو في مرحلة بيتا. اقرأ ما جاء في تصريحات المشغل نفسه أدناه قبل تخزين أي شيء لا يمكنك تحمل فقدانه.",
"mint.nuts.title": "المدعوم: NUTs",
"mint.nuts.noneOffline": "لا توجد قائمة مخزنة مؤقتًا برقم NUT لهذا جهة إصدار، فقد انقطع اتصاله بالإنترنت قبل أن يتم تسجيل أي قائمة له.",
"mint.nuts.noneYet": "لم يتم تخزين القائمة NUT مؤقتًا لهذا جهة إصدار حتى الآن.",
"mint.nuts.supported": "{n} من أصل {total} معروف NUTs مدعوم",
"mint.nuts.whatAre": "ما هو NUTs؟",
"mint.nutRow.disabled": "معاق",
"mint.nutRow.notSupported": "غير مدعوم",
"mint.details.title": "جهة إصدار تفاصيل",
"mint.details.pubkey": "Pubkey",
"mint.details.nostr": "Nostr",
"mint.details.email": "البريد الإلكتروني",
"mint.details.serverTime": "توقيت الخادم",
"mint.details.firstSeen": "شوهد لأول مرة",
"mint.details.terms": "الشروط",
"mint.kind.identifier": "المعرّف",
"mint.kind.mintPubkey": "جهة إصدار pubkey",
"mint.kind.nostrAddress": "العنوان Nostr",
"mint.kind.emailAddress": "عنوان البريد الإلكتروني",
"mint.kind.npub": "npub",
"mint.disclaimer": "هذا حساب جهة إصدار هو حساب أمانة: فهو يحتفظ بأموالك، ويمكنه التوقف عن ذلك في أي وقت. التصنيفات هي آراء المجتمع، والوسائل المتاحة عبر الإنترنت يمكن الوصول إليها، لكنها غير موثوقة.",
"mint.disclaimerLink": "اقرأ إخلاء المسؤولية بالكامل",
"mint.qr.title": "أضف {name} إلى محفظتك",
"mint.qr.note": "امسح هذا الرمز ضوئيًّا باستخدام محفظة Cashu لإضافة جهة إصدار، أو انسخ عنوان URL والصقه هنا.",
"mint.qr.warn": "إضافة جهة إصدار يعني أن تثق به وتوكل إليه أموالك.",
"mint.qr.whatMeans": "ما يعنيه ذلك",
"mint.qr.wallets": "المحافظ التي تدعم Cashu",
"mint.chip.frozen": "فروزن",
"mint.chip.noWithdrawals": "لا توجد عمليات سحب",
"mint.chip.meltOnly": "للذوبان فقط",
"mint.warnings.dayCount.one": "{n} يوم",
"mint.warnings.dayCount.other": "{n} يومًا",
"mint.warnings.gone.lead": "من المرجح أنه اختفى.",
"mint.warnings.gone.body.since": "لم يعد بالإمكان الوصول إلى هذا جهة إصدار منذ {date} ({days}). يرجى اعتبار الأموال المودعة فيه معرضة للخطر، وعدم إرسال أي أموال جديدة إليه. توضح التفاصيل أدناه آخر حالة معروفة له.",
"mint.warnings.gone.body.neverDated": "لم يرد هذا الحساب جهة إصدار على أي طلب تحقق منذ أن بدأنا مراقبته في {month}. تعاملوا مع الأموال المودعة فيه على أنها معرضة للخطر، ولا ترسلوا أموالاً جديدة. كل ما يلي مأخوذ من الإعلان Nostr، وليس من الإعلان جهة إصدار.",
"mint.warnings.gone.body.never": "لم يرد جهة إصدار على أي استفسار منذ أن بدأنا مراقبته. اعتبروا الأموال المودعة هناك معرضة للخطر، ولا ترسلوا أي أموال جديدة. كل ما يلي مأخوذ من إعلان Nostr، وليس من جهة إصدار.",
"mint.warnings.gone.meta.since": "غير متصل منذ {date}",
"mint.warnings.gone.meta.never": "لم يتم الوصول إليه مطلقًا منذ اكتشافه",
"mint.warnings.meltDisabled.lead": "تم تعطيل عمليات السحب.",
"mint.warnings.meltDisabled.body.online": "هذا جهة إصدار يحتوي على Lightning والاسترداد (NUT-05) المعطلة: يمكنك إدخال sats، لكن لا يمكنك سحبها عبر Lightning. لا تقم بالإيداع حتى يعود الاسترداد.",
"mint.warnings.meltDisabled.body.offline": "تم تعطيل جهة إصدار من قبل Lightning والاسترداد (NUT-05): يمكنك إيداع المبلغ عبر sats، لكن لا يمكنك سحبها عبر Lightning. لا تقم بالإيداع حتى يعود الاسترداد.",
"mint.warnings.meltDisabled.meta.online": "عمليات السحب معطلة حاليًا",
"mint.warnings.meltDisabled.meta.offline": "تم تعطيل عمليات السحب عند آخر ظهور",
"mint.warnings.frozen.lead": "جهة إصدار متجمد.",
"mint.warnings.frozen.body.online": "كل من الإصدار والاسترداد معطلان. ولا يمكن لـ sats الدخول أو الخروج عبر Lightning.",
"mint.warnings.frozen.body.offline": "تم تعطيل كل من الإصدار والاسترداد. ولا يمكن لـ sats الدخول أو الخروج عبر Lightning.",
"mint.warnings.frozen.meta.online": "مُجمَّد حاليًا، ولا يدخل أو يخرج منه أي شيء",
"mint.warnings.frozen.meta.offline": "كان في حالة جمود عند رؤيته آخر مرة",
"mint.warnings.offlineLong.lead": "غير متصل منذ {days}، منذ {date}.",
"mint.warnings.offlineLong.body": "ربما يكون جهة إصدار قد اختفى. كل ما يلي يمثل آخر حالة معروفة له، ولا تزال التعليقات تعمل.",
"mint.warnings.offlineLong.meta": "غير متصل منذ {date}",
"mint.warnings.meltOnly.lead": "للتذويب فقط.",
"mint.warnings.meltOnly.body.online": "الإصدار الجديد (NUT-04) معطل: لا يمكنك الإيداع، لكن لا يزال بالإمكان السحب من الحالي ecash. وغالبًا ما يعني هذا أن جهة إصدار في طريقه إلى الانتهاء، لذا إذا كنت تمتلك ecash، ففكر في استبعاد الاسترداد.",
"mint.warnings.meltOnly.body.offline": "تم تعطيل الإصدار الجديد (NUT-04): لا يمكنك الإيداع، لكن لا يزال بالإمكان السحب من الحالي ecash. وغالبًا ما يعني هذا أن جهة إصدار في طريقه إلى الانتهاء، لذا إذا كنت تمتلك ecash، ففكر في استبعاد الاسترداد.",
"mint.warnings.meltOnly.meta.online": "حاليًا، يقتصر الأمر على الذوبان فقط",
"mint.warnings.meltOnly.meta.offline": "تذوب فقط عند آخر مرة شوهدت فيها",
"mint.warnings.offline.lead": "غير متصل منذ {date}.",
"mint.warnings.offline.body": "تم تأكيد التفاصيل في ذلك الوقت. لا يزال بإمكانك كتابة تقييم.",
"mint.warnings.offline.meta": "غير متصل منذ {date}",
"mint.warnings.also.meltDisabled": "وقبل أن تتوقف عن العمل، كانت قد أوقفت أيضًا عمليات السحب.",
"mint.warnings.also.mintDisabled": "وقبل أن ينقطع الاتصال بالإنترنت، كان قد عطل أيضًا الإصدار الجديد.",
"mint.warnings.also.offline": "كما أنها غير متصلة بالإنترنت منذ {date} ({days}).",
"nut.0": "الرموز والنماذج",
"nut.1": "جهة إصدار تبادل المفاتيح العامة",
"nut.2": "مجموعات المفاتيح والرسوم",
"nut.3": "مبادلة الرموز الرقمية",
"nut.4": "الإصدار عبر Lightning",
"nut.5": "الاسترداد عبر Lightning",
"nut.6": "جهة إصدار معلومات",
"nut.7": "التحقق من حالة الرمز الرمزي",
"nut.8": "رسوم زائدة قدرها Lightning",
"nut.9": "استعادة التوقيعات",
"nut.10": "شروط الإنفاق",
"nut.11": "الدفع إلى المفتاح العام",
"nut.12": "DLEQ نسخة تجريبية",
"nut.13": "الأسرار الحتمية",
"nut.14": "عقود القفل الزمني المُجزَّأة",
"nut.15": "المدفوعات متعددة المسارات",
"nut.16": "رموز QR المتحركة",
"nut.17": "WebSocket اشتراك",
"nut.18": "طلبات الدفع",
"nut.19": "الردود المخزنة مؤقتًا",
"nut.20": "تم التوقيع على جهة إصدار اقتباس",
"nut.21": "المصادقة الواضحة",
"nut.22": "المصادقة العمياء",
"nut.23": "BOLT11 طريقة الدفع",
"nut.24": "HTTP 402 مطلوب الدفع",
"nut.25": "BOLT12 طريقة الدفع",
"reviews.title": "التعليقات",
"reviews.anon": "مجهول",
"reviews.anonTitle": "لم يتم العثور على ملف تعريف عام برقم Nostr لهذا المفتاح",
"reviews.nip05Title": "المعرّف NIP-05 (لم يتم التحقق منه من قِبل هذا الموقع)",
"reviews.filter.group": "تصفية التقييمات",
"reviews.filter.all": "الكل",
"reviews.filter.five": "5★",
"reviews.filter.critical": "حرجة",
"reviews.filter.recent": "الأخبار الأخيرة",
"reviews.filter.hideAnon": "إخفاء الهوية",
"reviews.anonHidden.other": "يُعرض {n} من أصل {total} تقييمًا: مجهول، مخفي.",
"reviews.allAnon.one": "التعليق الوحيد هنا هو تعليق مجهول.",
"reviews.allAnon.other": "جميع التقييمات البالغ عددها {n} الموجودة هنا مجهولة المصدر.",
"reviews.allAnonShow.one": "اعرضه",
"reviews.allAnonShow.other": "أرهم",
"reviews.loading.one": "جاري تحميل التقييم {n} من Nostr مرحل",
"reviews.loading.other": "جاري تحميل {n} تقييم من Nostr مرحل",
"reviews.empty": "لا توجد تعليقات حتى الآن. كن أول من يكتب تعليقًا.",
"reviews.emptyWritten": "لا توجد تعليقات مكتوبة هنا حتى الآن. كن أول من يكتب تعليقًا.",
"reviews.emptyFilter": "لا توجد تقييمات تتطابق مع هذا الفلتر.",
"reviews.rate.cashu": "قيّم جهة الإصدار هذه",
"reviews.rate.fedimint": "قيّم هذا الاتحاد",
"reviews.rate.lnurl": "قيّم جهة الإصدار هذه",
"reviews.noscript": "يتم تسجيل التعليقات على Nostr ويتم تحميلها في متصفحك، لذا تحتاج هذه القائمة إلى JavaScript. أما التقييمات وأعداد التعليقات المذكورة أعلاه فهي مستمدة من الموقع نفسه.",
"reviews.relayError": "تعذر الوصول إلى جهاز الترحيل. قد تكون المراجعات غير مكتملة.",
"reviews.srRating": "{rating} من أصل 5",
"reviews.srNoRating": "لم يتم إعطاء تقييم",
"reviews.readMore": "اقرأ المزيد",
"reviews.readLess": "إظهار أقل",
"reviews.actions.copyNpub": "نسخة npub",
"reviews.actions.openNostr": "شاهد هذا الحدث على Nostr",
"reviews.actions.copyLink": "نسخ رابط هذه المراجعة",
"reviews.anonNote": "أول تقييم من هذا npub، لم يتم العثور على أي نشاط آخر",
"reviews.published": "تم النشر.",
"reviews.publishedPartial": "تم النشر إلى {accepted} من أصل {total} مرحل.",
"reviews.propagating": "أثناء النشر، قد يستغرق الأمر لحظة حتى يظهر في مكان آخر.",
"reviews.summary.line.one": "{count} تقييم بدون تعليق: {breakdown}",
"reviews.summary.line.other": "{count} تقييمًا بدون تعليق: {breakdown}",
"reviews.summary.lineDay.one": "تقييم {count} بدون تعليق على {when}: {breakdown}",
"reviews.summary.lineDay.other": "{count} تقييمًا بدون تعليق على {when}: {breakdown}",
"reviews.summary.showOne": "اعرضه",
"reviews.summary.showMany": "أرهم",
"reviews.breakdown.5": "{n} خمس نجوم",
"reviews.breakdown.4": "{n} أربع نجوم",
"reviews.breakdown.3": "{n} ثلاث نجوم",
"reviews.breakdown.2": "{n} نجمتان",
"reviews.breakdown.1": "{n} نجمة واحدة",
"reviews.breakdown.none": "{n} بدون تقييم",
"reviews.dialog.title": "التقييم {name}",
"reviews.dialog.lede.cashu": "شارك تجربتك مع جهة الإصدار هذه.",
"reviews.dialog.lede.fedimint": "شارك تجربتك مع هذا الاتحاد.",
"reviews.dialog.lede.lnurl": "شارك تجربتك مع جهة الإصدار هذه.",
"reviews.dialog.permanent": "يُنشر بشكل دائم على موزّعات Nostr العامة.",
"reviews.dialog.gate.title": "سجّل الدخول لكتابة تقييم",
"reviews.dialog.gate.lead": "تُوقَّع التقييمات بمفتاح Nostr الخاص بك. يبقى المفتاح في المُوقِّع، ولا يطّلع عليه هذا الموقع أبدًا.",
"reviews.dialog.ratingLegend": "تقييمك",
"reviews.dialog.starsSr.one": "{n} نجمة",
"reviews.dialog.starsSr.other": "{n} نجمة",
"reviews.dialog.chooseRating": "اختر تقييمًا",
"reviews.dialog.starLabel": "{n}: {phrase}",
"reviews.dialog.stars.1": "تجنبه",
"reviews.dialog.stars.2": "واجهت مشاكل",
"reviews.dialog.stars.3": "نجحت، مع بعض التحفظات",
"reviews.dialog.stars.4": "جيد",
"reviews.dialog.stars.5": "يعمل بشكل رائع",
"reviews.dialog.bodyLabel": "ما الذي ينبغي أن يعرفه الآخرون؟",
"reviews.dialog.charCount": "{n} / {max}",
"reviews.dialog.submit": "التوقيع والنشر",
"reviews.dialog.signing": "التوقيع…",
"reviews.dialog.publishing": "جاري النشر…",
"reviews.dialog.publishingTo": "النشر إلى الموزعات ({accepted}/{total})",
"reviews.dialog.waitingExtension": "في انتظار تمديدك",
"reviews.dialog.waitingSigner": "في انتظار الموقّع",
"reviews.dialog.confirmClose": "هل تريد إغلاق هذه المراجعة؟ سيتم الاحتفاظ بمسودتك حتى تغادر الموقع.",
"reviews.dialog.why.login": "تسجيل الدخول للنشر",
"reviews.dialog.why.words": "اكتب بضع كلمات",
"reviews.dialog.why.more.one": "{n} حرف إضافي",
"reviews.dialog.why.more.other": "{n} حرف إضافي",
"reviews.dialog.why.over.one": "{n} حرفًا فوق الحد الأقصى",
"reviews.dialog.why.over.other": "{n} حرفًا زائدة عن الحد المسموح به",
"reviews.dialog.identity.needSigner": "تحتاج إلى مُوقِّع برقم Nostr للنشر. سيبقى مفتاحك محفوظًا فيه، ولن يطلع عليه هذا الموقع أبدًا.",
"reviews.dialog.identity.loginToPublish": "تسجيل الدخول للنشر",
"reviews.dialog.identity.switch": "مفتاح",
"reviews.dialog.identity.extension": "تسجيل الدخول باستخدام ملحق المتصفح",
"reviews.dialog.identity.readingKey": "جاري قراءة مفتاحك العام…",
"reviews.dialog.identity.willAsk": "سيُطلب من الملحق الخاص بك التوقيع",
"reviews.dialog.error.noRelay": "لم يقبل أي وسيط طلب المراجعة. يرجى التحقق من اتصالك، ثم حاول مرة أخرى.",
"reviews.validate.rating": "اختر تقييمًا من 1 إلى 5 نجوم.",
"reviews.validate.short": "اكتب ما لا يقل عن {n} حرفًا حتى تكون المراجعة مفيدة.",
"reviews.validate.long": "يقتصر عدد أحرف التعليقات على {n} حرفًا.",
"feed.title": "أحدث التقييمات لـ جهة إصدار و«الفيدرالية» · Cashumints.space",
"feed.description": "كل مراجعة لمنّاتة أو اتحاد أو منّاتة LNURL فور نشرها: {reviews} مراجعة تغطي {mints} منّاتة Cashu و{federations} اتحاد Fedimint و{lnurl} منّاتة LNURL، موقّعة على Nostr ومقروءة هنا من الأحدث. صفِّ حسب المنظومة أو التقييم أو الاسم.",
"feed.heading": "أحدث التقييمات",
"feed.lede": "كل تقييم لكل اتحاد من الاتحادات جهة إصدار Cashu وFedimint، مع عرض الأحدث أولاً، يُقرأ مباشرةً من محطات الترحيل Nostr. كل تقييم موقَّع من قبل مؤلفه ويُحفظ على محطات الترحيل، وليس في قاعدة بياناتنا.",
"feed.label": "التعليقات",
"feed.showingBuild.one": "عرض أحدث تقييم من الإصدار الأخير",
"feed.showingBuild.other": "عرض آخر {n} تقييم من الإصدار الأخير",
"feed.noscript": "يتم توقيع التعليقات على Nostr ويتم تحميلها في متصفحك، لذا فإن عملية التصفية والقائمة الكاملة تتطلبان JavaScript. أما التعليقات الـ {n} الواردة أدناه فقد تمت قراءتها من المرحلات عند إنشاء هذه الصفحة.",
"feed.emptyBuild": "لم يتمكن الموقع من قراءة أي تقييمات من أجهزة الترحيل عند إنشاء هذه الصفحة. يتم تحديث القائمة من أجهزة الترحيل في متصفحك.",
"feed.foot": "التعليقات هي آراء المجتمع، وليست ادعاءات تم التحقق منها. اقرأ {link} قبل أن تثق في جهة إصدار وتدفع له المال.",
"feed.footLink": "إخلاء المسؤولية",
"feed.showing": "يُعرض {shown} من أصل {total} تقييمًا",
"feed.showingMatching": "يتم عرض {shown} من أصل {matching} تقييمًا مطابقًا (إجمالي {total})",
"feed.emptyRelays": "لم ترد أي تعليقات من الموزعات. افتح أي صفحة تحمل جهة إصدار لتقرأ التعليقات وتكتب تعليقاتك هناك.",
"feed.emptyFilter": "لا توجد تقييمات تتطابق مع هذا الفلتر. قم بإلغاء تحديد المربع جهة إصدار، أو جرب خيار «الكل».",
"feed.relayError": "تعذر الوصول إلى المرحلات. يتم عرض التقييمات من الإصدار الأخير.",
"feed.relayEmpty": "لم ترد أي مراجعات من الوسيطات. يتم عرض المراجعات من الإصدار الأخير.",
"feed.reading": "قراءة المرحلات…",
"login.open": "تسجيل الدخول",
"login.title": "تسجيل الدخول باستخدام Nostr",
"login.note": "يظل مفتاحك محفوظًا في جهاز التوقيع الخاص بك. ولا يمكن لهذا الموقع رؤيته أبدًا.",
"login.extension.name": "ملحق المتصفح",
"login.extension.desc": "قم بالتسجيل باستخدام Alby أو nos2x أو أي رقم آخر من سلسلة NIP-07",
"login.extension.none": "لم يتم العثور على امتداد",
"login.extension.getOne": "احصل على واحدة:",
"login.remote.name": "الموقّع عن بُعد",
"login.remote.desc": "امسح الرمز ضوئيًّا باستخدام تطبيق التوقيع، أو الصق رابط «بونكر»",
"login.primal.name": "Primal",
"login.primal.desc": "قم بالموافقة على تسجيل الدخول في تطبيق Primal على هاتفك",
"login.paste.label": "أو الصق رابط الاتصال الذي قدمه لك الموقّع",
"login.copyLink": "انسخ رابط الاتصال",
"login.whatIsNostr": "ما هو Nostr؟",
"login.connect.remote.title": "ربط مُوقِّع عن بُعد",
"login.connect.remote.lead": "امسح هذا ضوئيًا باستخدام تطبيق التوقيع الخاص بك، أو انسخ الرابط والصقه هناك.",
"login.connect.remote.hint": "يعمل مع أي موقّع يحمل NIP-46: nsec.app، Amber، Nostrify، أو المخبأ الخاص بك.",
"login.connect.remote.open": "افتح في تطبيق التوقيع الخاص بك",
"login.connect.primal.title": "تواصل مع Primal",
"login.connect.primal.lead": "افتح Primal على هاتفك، وابحث عن خيار «التسجيل عن بُعد» في الإعدادات، ثم امسح هذا الرمز ضوئيًّا.",
"login.connect.primal.hint": "Primal يُستخدم للتوقيع على تطبيقات أخرى عبر NIP-46، لذا فإن هذا هو نفس الاتصال الذي يستخدمه أي مُوقِّع، ويبقى مفتاحك في Primal. {link}",
"login.connect.primal.open": "افتتح في Primal",
"login.status.buildingLink": "إنشاء رابط اتصال",
"login.status.waitingExtension": "في انتظار تمديدك",
"login.status.waitingSigner": "في انتظار الموقّع",
"login.status.connecting": "الاتصال بالموقّع",
"login.status.connected": "متصل",
"login.toast.loggedIn": "تم تسجيل الدخول",
"login.toast.fetchingProfile": "جارٍ جلب ملفك الشخصي على Nostr من خوادم الترحيل…",
"login.toast.profile": "الملف الشخصي: {name}",
"login.toast.noProfile": "لم يُعثر على ملف شخصي على خوادم الترحيل",
"login.method.extension": "التمديد",
"login.method.remote": "عن بُعد",
"login.method.primal": "Primal",
"login.account": "حسابك Nostr",
"login.copyNpub": "انسخ الكامل npub",
"login.logout": "تسجيل الخروج",
"signer.status.waitingExtension": "في انتظار تمديدك",
"signer.status.signed": "موقّع",
"signer.status.reconnecting": "إعادة الاتصال بالموقّع",
"signer.status.approveWindow": "يحتاج الموقّع منك إلى الموافقة على هذا الأمر في نافذة منفصلة",
"signer.status.waitingSigner": "في انتظار الموقّع",
"signer.status.stillWaiting": "ما زلنا ننتظر رد الموقّع",
"signer.status.connected": "متصل",
"signer.error.noExtension": "لم يتم العثور على تمديد التوقيع. قم بتثبيت Alby أو nos2x، ثم أعد تحميل هذه الصفحة.",
"signer.error.privateKey": "يبدو أن هذا مفتاح خاص، وليس رابط اتصال. هذا الموقع لا يطلب أبدًا إدخال مفتاح. الصق الرابط bunker:// الذي أعطاك إياه المُوقِّع.",
"signer.error.badLink": "هذا الرابط غير مدعوم في هذا الموقع. قم بلصق الرابط bunker:// من الموقّع الخاص بك.",
"signer.error.noAnswer": "لم يرد المُوقِّع الخاص بك. ربما يكون الحساب مغلقًا، أو ربما يكون خادم الترحيل الخاص به معطلاً. افتحه وحاول مرة أخرى.",
"signer.error.timeout": "لم يرد الموقّع في الوقت المحدد. افتح الجهاز، وتأكد من أنه لا يزال متصلاً، ثم حاول مرة أخرى.",
"signer.error.cancelled": "تم إلغاء الاتصال.",
"signer.error.declined": "تم رفض طلب التوقيع",
"notfound.title": "الصفحة غير موجودة · Cashumints.space",
"notfound.description": "هذه الصفحة غير موجودة على Cashumints.space.",
"notfound.heading": "هذه الصفحة غير موجودة",
"notfound.lede": "ربما تم نقله، أو ربما لم يتم فهرسة جهة إصدار بعد. ابحث عن جهة إصدار بالاسم أو النطاق، أو الصق عنوان URL الخاص بجهة إصدار.",
"notfound.browse": "تصفح جميع جهات إصدار",
"notfound.home": "اذهب إلى المنزل",
"notfound.loading": "جاري التحميل {host}",
"notfound.fb.rating": "التقييم",
"notfound.fb.reviews": "التعليقات",
"notfound.fb.software": "البرامج",
"notfound.fb.lastOnline": "آخر مرة كان متصلاً بالإنترنت",
"notfound.fb.note": "فُهرس هذا الـ mint بعد آخر بناء للموقع، لذا هذا ملخّص من الواجهة الحية، ومراجعاته أدناه. تظهر الصفحة الكاملة عند البناء التالي.",
"notfound.resolve.checking": "أول زيارة: يجري فحص هذا الـ mint الآن.",
"notfound.resolve.failed": "تعذّرت إضافة هذا الـ mint",
"notfound.resolve.failedFedimint": "تعذّرت إضافة هذا الاتحاد",
"notfound.resolve.retry": "جرّب عنوانًا آخر",
"notfound.resolve.switch": "افتحه بوصفه mint من نوع {kind}",
"notfound.resolve.fedimint": "عنوان صفحة الاتحاد لا يحمل رمز الدعوة الخاص به، لذا لا يمكن العثور عليه من الرابط وحده. ألصق رمز الدعوة وسيُضاف.",
"notfound.resolve.ambiguous": "لا يمكن استنتاج هذا العنوان من الرابط وحده. ألصق رابط الـ mint كاملًا وسيُضاف.",
"wallets.title": "Cashu محفظة · Cashumints.space",
"wallets.description": "Cashu محفظة تعمل مع جهات إصدار المذكور هنا، عبر الهاتف المحمول والويب والمتصفح وخط الأوامر، مع الاستفادة من المزايا التي تتميز بها كل واحدة منها.",
"wallets.heading": "المحافظ",
"wallets.lede": "تحتاج إلى محفظة لتخزين ecash من أي من هذه جهات إصدار. تتواصل معظم المحافظ مع عدة أرقام من جهات إصدار في آن واحد، لذا فأنت لست ملزماً باستخدام أول رقم تجربه.",
"wallets.note": "هذه القائمة ليست شاملة ولا تمثل توصية. يحتفظ مشروع Cashu بفهرس أوسع نطاقًا على الرابط {link}. المحفظة تحتوي على المفاتيح، أما جهة إصدار فيحتوي على الأموال: اقرأ تقييمات جهة إصدار قبل أن تختار المكان الذي ستضع فيه sats.",
"wallets.platform.android": "Android",
"wallets.platform.ios": "iOS",
"wallets.platform.web": "الويب",
"wallets.platform.extension": "ملحق المتصفح",
"wallets.platform.cli": "CLI",
"wallets.platform.python": "Python",
"wallets.item.minibits": "محفظة الهاتف المحمول ecash مع دعم عناوينها الخاصة جهة إصدار وLightning وجهات الاتصال Nostr.",
"wallets.item.nutstash": "محفظة متصفح تدعم التعامل مع عدة جهات إصدار في آن واحد، وتقوم باستيراد وتصدير الرموز الأولية.",
"wallets.item.enuts": "محفظة جوال تركز على أرصدة العملات المتعددة وإرسال ecash إلى Nostr.",
"wallets.item.cashume": "محفظة ويب مرجعية من مشروع Cashu. تعمل بالكامل داخل المتصفح، ويمكن تثبيتها كملف PWA.",
"wallets.item.boardwalk": "محفظة إلكترونية مبنية على Lightning عنوان، وإرسال ecash عبر الرابط.",
"wallets.item.coinos": "Lightning: محفظة ونقطة بيع تدير أيضًا جهة إصدار Cashu.",
"wallets.item.oxchat": "تطبيق المراسلة Nostr المزود بمحفظة ecash مدمجة، لإرسال sats داخل المحادثات.",
"wallets.item.nutshell": "التنفيذ المرجعي. يعمل كمحفظة عبر سطر الأوامر وكبرنامج جهة إصدار.",
"about.title": "حوالي Cashumints.space",
"about.description": "ما هو Cashu، وما الذي يحتويه جهة إصدار فعليًّا، وما هو NIP-87، وكيف يقوم هذا الموقع بفهرسة جهات إصدار وقراءة التقييمات الواردة من محطات الترحيل Nostr.",
"about.heading": "نبذة عن",
"about.lede": "يضم Cashumints.space كل رقم من أرقام جهة إصدار Cashu التي يمكننا العثور عليها على الشبكة Nostr، ويعرض لك ما يقوله الناس عنها. وقد صممه Azzamo.",
"about.cashu.title": "ما هو Cashu؟",
"about.cashu.p1": "Cashu هو بروتوكول ecash الخاص بـ Bitcoin. يقوم جهة إصدار بإصدار رموز مميزة مجهولة المصدر مدعومة بـ sats التي يمتلكها، وتنتقل هذه الرموز بين الأشخاص دون أن يعرف جهة إصدار من أرسل ماذا إلى من. الخصوصية حقيقية وهذا هو بيت القصيد: الرموز ليست حسابات، ولا يمكن لـ جهة إصدار ربط الرمز الذي وقّعه بالرمز الذي يعود إليه.",
"about.cashu.p2": "يمكنك الاطلاع على البروتوكول ومواصفاته في {link}. وتُسمى المواصفات نفسها NUTs، ومن هنا تأتي NUT الموجودة في كل صفحة من صفحات جهة إصدار.",
"about.mints.title": "ما هو جهة إصدار، والمفاضلة",
"about.mints.p1": "جهة إصدار هو رقم حراسة. عندما تقوم بـ جهة إصدار ecash، فإنك تسلم sats إلى من يدير ذلك الخادم، وتثق في أنه سيحترم الرموز عند سحبها مرة أخرى. لا توجد ضمانات ضد الاحتيال، ولا حساب ضمان، ولا حق في الرجوع. يمكن لمشغل جهة إصدار أن يختفي بالرصيد، وقد حدث ذلك بالفعل مع جهات إصدار.",
"about.mints.p2": "وهذا بالضبط هو سبب وجود التقييمات هنا. فهذا الموقع لا يخبرك أبدًا أن جهة إصدار آمن. الحالة الموضحة على صفحة جهة إصدار تشير إلى {code}، وليس «موثوق»، وهذا يعني فقط أن الخادم استجاب عندما سألنا آخر مرة. تعامل مع أرصدة ecash كما لو كانت نقودًا في جيب معطفك: لا بأس بإنفاقها، لكنها غير مناسبة للادخار.",
"about.nip87.title": "ما هو NIP-87؟",
"about.nip87.p1": "NIP-87 هو الأسلوب المتبع في Nostr للإعلان عن ecash وجهات إصدار والتوصية بهما. وهناك نوعان من الأحداث مهمان هنا:",
"about.nip87.li1": "يُعلن {code} عن جهة إصدار. ويوجد عنوان URL الخاص بـ جهة إصدار داخل العلامة {tag}. وهذه هي الطريقة التي يكتشف بها الموقع جهات إصدار الذي لم يسبق له رؤيته من قبل.",
"about.nip87.li2": "{code} هو تعليق. ويحتوي على التقييم ونص التعليق، ويشير إلى جهة إصدار الذي يتعلق به باستخدام علامة {utag} (عنوان URL جهة إصدار) وعلامة {dtag} (المفتاح العام الخاص بـ جهة إصدار).",
"about.nip87.p2": "يتم توقيع كل تقييم بمفتاح Nostr الخاص بمؤلفه. نحن لا نصف أي تقييم أبدًا بـ«المُثبت»، لأن لا أحد قام بالتحقق منه: إنه موقَّع، وهو ادعاء مختلف وأكثر فائدة. وهذا يعني أنه يمكنك التحقق من هوية كاتبه ومن أنه لم يتم التلاعب به. ولا يعني ذلك أن المؤلف صادق أو أنه استخدم مفتاح جهة إصدار من قبل.",
"about.how.title": "كيف يعمل هذا الموقع",
"about.how.p1": "هناك حلقتان وقاعدة واحدة. القاعدة هي أن جهة إصدار يمتلك بياناته الوصفية، بينما يمتلك Nostr التقييمات؛ لذا لا يخزن هذا الموقع أيًّا منهما باعتباره مصدرًا موثوقًا.",
"about.how.li1": "يقوم {strong} بالاستعلام عن {code} من كل رقم معروف من جهة إصدار كل عشر دقائق، ويسجل ما يرد عليه: الاسم، والوصف، والإصدار، وأرقام NUTs المدعومة، والحدود، وتفاصيل الاتصال. كما يسجل أيضًا ما إذا كان جهة إصدار قد أجاب أم لا، ومن هنا تأتي معلومات وقت التشغيل.",
"about.how.li1Label": "حلقة الاستشعار",
"about.how.li2": "يتصل {strong} بNostr كل ساعة، ويقرأ إعلانات جهة إصدار ومراجعاته، ويضيف أي إعلانات من جهة إصدار لم يرها من قبل.",
"about.how.li2Label": "حلقة الاكتشاف",
"about.how.p2": "الجزء المهم هو ما يحدث عندما يتوقف جهة إصدار عن الرد. لا يتم أبدًا استبدال بياناته الوصفية المخزنة مؤقتًا بنتيجة فحص فاشلة، لذا فإن جهة إصدار المعطل يحتفظ بصفحته الكاملة: مواصفاته، وتاريخه، وتقييماته، مع لافتة توضح تاريخ توقفه عن العمل. أما الإصدار السابق من هذا الموقع، فقد كان يعرض صفحة خطأ بدلاً من ذلك، مما يعني أن التي كان الناس في أمس الحاجة إلى تحذير بعضهم البعض منها، وهي التي لا يمكن مراجعتها، هي التي لم يتم عرضها. وكان ذلك السبب الرئيسي وراء إعادة بناء الموقع.",
"about.how.p3": "لا يتم تخزين نص التعليقات أبدًا على خوادمنا. عندما تفتح صفحة جهة إصدار، يتواصل متصفحك مباشرةً مع محطات الترحيل ويقرأ التعليقات بنفسه، وعندما تكتب تعليقًا، يتم توقيعه بواسطة ملحقك الخاص Nostr وإرساله مباشرةً إلى تلك المحطات. نحن نحتفظ بعدد التعليقات فقط حتى يمكن ترتيب الصفحات وتصنيفها، ولا شيء أكثر من ذلك. لا يمكننا تعديل أو حذف أي شيء هنا، بما في ذلك التعليقات التي نفضل عدم وجودها.",
"about.relays.title": "المرحلات التي قرأنا عنها",
"about.relays.p1": "هذه هي خوادم الترحيل التي يستخدمها كل من أداة الفهرسة ومتصفحك:",
"about.relays.p2": "قم بتوجيه أي عميل يحمل Nostr إلى نفس نقاط الترحيل وسترى نفس الأحداث. هذا هو جوهر الوعد: لا يوجد هنا شيء يُعتبر صحيحًا لمجرد أننا نقول ذلك.",
"about.nuts.title": "ما هو NUTs؟",
"about.nuts.p1": "NUTs هي المواصفات المرقمة Cashu، مثل BIPs لـ Bitcoin. ويُعلن جهة إصدار عن المواصفات التي ينفذها، وهذا يوضح لك ما يمكنه فعله فعليًّا: NUT-04 وNUT-05 هما الإصدار والاسترداد على Lightning (بدونهما، لا يكون لـ جهة إصدار فائدة تذكر)، يسمح NUT-07 للمحفظة بالتحقق مما إذا كان الرمز المميز قد تم إنفاقه بالفعل، ويوفر NUT-17 للمحافظ تحديثات فورية عبر WebSockets بدلاً من الاستقصاء، ويسمح NUT-20 للمحفظة بإثبات أن عرض الأسعار جهة إصدار قد صدر لها بالفعل. توجد القائمة الكاملة في {link}.",
"about.numbers.title": "في الوقت الحالي",
"about.numbers.p1": "{mints} جهات إصدار تم فهرستها، {online} منها استجابت، {reviews} تقييمات تم احتسابها من المرحلات المذكورة أعلاه.",
"about.source.title": "المصدر والائتمان",
"about.source.p1": "الموقع مفتوح المصدر على العنوان {github}. تم إنشاؤه وتشغيله بواسطة {azzamo}. إذا كانت هناك مشكلة في إحدى صفحات جهة إصدار، فقم بإصلاحها من المصدر: تأتي البيانات الوصفية لـ جهة إصدار من {code} الخاص بـ جهة إصدار، وتأتي التعليقات من Nostr.",
"terms.title": "شروط الاستخدام · Cashumints.space",
"terms.description": "شروط استخدام Cashumints.space: ماهية الموقع، وما لا يقوم به، ومن المسؤول عن ماذا. لا توجد حسابات، ولا ضمانات، ولا علاقة بأي جهة إصدار.",
"terms.heading": "شروط الاستخدام",
"terms.lede": "شروط بسيطة لموقع يقتصر على فهرسة البيانات العامة. آخر تحديث: {date}.",
"terms.what.title": "ما هو هذا الموقع؟",
"terms.what.p1": "Cashumints.space هو فهرس للبيانات المتاحة للجمهور حول جهات إصدار Cashu. وهناك نوعان من البيانات، وكلاهما مستمدان من مصدر آخر:",
"terms.what.li1": "جهة إصدار من البيانات الوصفية، التي تمت قراءتها من نقطة النهاية {code} الخاصة بكل جهة إصدار: الاسم، والوصف، وإصدار البرنامج، وNUTs المدعوم، وتفاصيل الاتصال، وما إذا كان جهة إصدار قد رد عند آخر استفسار لنا.",
"terms.what.li2": "التعليقات، التي تمت قراءتها من Nostr منصة توزيع عامة. وهذه هي NIP-87 حدثًا نشرها مؤلفوها على منصات توزيع يمكن لأي شخص الاستعلام عنها.",
"terms.what.p2": "نقوم بجمع تلك البيانات وتوحيدها وترتيبها وعرضها. لكننا لا نقوم بإنشائها.",
"terms.noAccounts.title": "لا توجد حسابات، ولا توجد ضمانات",
"terms.noAccounts.p1": "لا يوجد ما يتعين عليك التسجيل فيه. يُقدم الموقع كما هو، دون أي ضمان من أي نوع: لا ضمان بدقة البيانات، ولا ضمان بأنها حديثة، ولا ضمان بأن الموقع سيكون متاحًا. قد تكون بيانات جهة إصدار قديمة بمقدار دقائق أو ساعات، وقد يتغير جهة إصدار بعد آخر مرة قمنا فيها بالتحقق منه، وقد يفشل أحد أجهزة الترحيل في الرد، وفي هذه الحالة سترى أقل مما هو موجود بالفعل.",
"terms.noAccounts.p2": "استخدمه على مسؤوليتك الخاصة. نحن لا نتحمل أي مسؤولية عن أي خسارة تنجم عن استخدام هذا الموقع أو الاعتماد على أي شيء تقرأه هنا. راجع {link} للاطلاع على المخاطر المحددة المرتبطة باستخدام جهة إصدار Cashu.",
"terms.noAccounts.link": "إخلاء المسؤولية",
"terms.noCustody.title": "نحن لا نحتفظ بأموالك",
"terms.noCustody.p1": "لا يتولى هذا الموقع أبدًا حفظ الأموال. فهو لا يمتلك محفظة، ولا يتعامل مع ecash، ولا يمكنه تحويل أو تجميد أو استرداد أي شيء. إذا واجهتك مشكلة تتعلق بجهة إصدار، فإن مشغل جهة إصدار هو الطرف الوحيد الذي يمكنه مساعدتك، ولا يمكننا الاتصال به نيابة عنك.",
"terms.noCustody.p2": "نحن لا نرتبط بأي من جهة إصدار المدرجة هنا، ولا يدفع أي رقم من جهة إصدار مقابل إدراجه أو ترتيبه أو تقييمه. يتم العثور على جهات إصدار من خلال الإعلانات العامة Nostr، ويتم ترتيبها بواسطة خوارزمية تستند إلى بيانات التقييمات العامة.",
"terms.reviews.title": "التعليقات هي كلمات الآخرين",
"terms.reviews.p1": "التعليقات هي محتوى ينشئه المستخدمون ويتم نشره على محطات الترحيل العامة Nostr بواسطة مؤلفيها. نحن نعرضها، لكننا لا نتحكم فيها. لم نكتبها، ولا يمكننا تعديلها، ولا يمكننا حذفها من محطات الترحيل التي تحتوي عليها: فهي أحداث موقعة على شبكة لا نملكها.",
"terms.reviews.p2": "التعليق هو تجربة شخصية، وليس حقيقة مؤكدة. إذا قمت بنشر تعليق، فأنت مسؤول عما ورد فيه. لا تنشر أي شيء مخالف للقانون، واعلم أن ما تنشره يكون متاحًا للجمهور ودائمًا (انظر {link}).",
"terms.reviews.link": "صفحة الخصوصية",
"terms.reviews.p3": "يمكننا إيقاف عرض تعليق معين على هذا الموقع، وسنقوم بذلك في حالة وجود محتوى غير قانوني. ويؤدي ذلك إلى إزالته من هذه الصفحات فقط. أما الحدث نفسه فيظل موجودًا على قنوات البث، ويمكن لأي عميل آخر يحمل Nostr قراءته.",
"terms.links.title": "روابط لمواقع أخرى",
"terms.links.p1": "يوجد جهة إصدار رابطًا إلكترونيًّا ورابطًا لمحفظة رقمية وبيانات اتصال بمشغلي شبكات الهاتف وكل ما يشير إلى مواقع خارج هذا الموقع، وذلك لأنها تشكل جزءًا من البيانات. ولا يُعد وجود الرابط بمثابة تأييد، كما أننا لا نتحقق من محتوى المواقع التي تؤدي إليها هذه الروابط.",
"terms.changes.title": "التغييرات",
"terms.changes.p1": "قد تتغير هذه الشروط. ويشير التاريخ المذكور في أعلى الصفحة إلى تاريخ آخر تعديل لها. والموقع مفتوح المصدر، لذا فإن كل تغيير يُجرى على هذه الصفحة يُسجل في سجل التعديلات.",
"terms.contact.title": "الاتصال",
"terms.contact.p1": "من الأفضل الإبلاغ عن المشكلات وطلبات إزالة المحتوى عبر {github}. ويتم إدارة الموقع بواسطة {azzamo}.",
"privacy.title": "الخصوصية · Cashumints.space",
"privacy.description": "الخصوصية في Cashumints.space: لا توجد حسابات، ولا ملفات تعريف ارتباط للتتبع، ولا أدوات تحليل. يُستخدم localStorage لتخزين ملف التعريف مؤقتًا ولا يتم إرسال أي شيء إلى أي مكان. يتم توقيع التعليقات في متصفحك الخاص.",
"privacy.heading": "الخصوصية",
"privacy.lede": "نحن نجمع أقل قدر ممكن من البيانات، بقدر ما يسمح به موقع الويب. آخر تحديث: {date}.",
"privacy.noAccounts.title": "لا توجد حسابات",
"privacy.noAccounts.p1": "لا يوجد تسجيل، ولا تسجيل دخول، ولا ملف شخصي. لا يطلب منك أي شيء في هذا الموقع أن تذكر هويتك، ولا يوجد سجل للمستخدمين يمكن أن يتسرب لأنه لا يوجد مستخدمون أصلاً.",
"privacy.noTracking.title": "لا ملفات تعريف ارتباط للتتبع، ولا تحليلات",
"privacy.noTracking.p1": "لا يقوم الموقع بوضع أي ملفات تعريف ارتباط لأغراض التتبع. ولا يوجد أي برنامج نصي للتحليلات، ولا مدير علامات، ولا بكسل، ولا تسجيل للجلسات، ولا شبكة إعلانية. ولا يوجد أي شيء في الصفحة يُبلغنا أو يُبلغ أي جهة أخرى بسلوكك.",
"privacy.logs.title": "سجلات الخادم",
"privacy.logs.p1": "يحتفظ خادم الويب بسجلات وصول قياسية، وهو ما يقوم به أي خادم ويب بشكل افتراضي. تسجل كل سطر في السجل عنوان IP الخاص بك، والوقت، والصفحة التي طلبتها، وحالة الاستجابة، ووكيل المستخدم والمصدر الذي أرسله متصفحك. وتُستخدم هذه السجلات لتصحيح الأخطاء وكشف حالات إساءة الاستخدام. ولا يتم ربطها بأي شيء آخر، كما لا يتم بيعها أو مشاركتها.",
"privacy.logs.operatorLabel": "ملاحظة للمشغل:",
"privacy.logs.operator": "إذا كنت تدير هذا الموقع، فاذكر مدة الاحتفاظ الفعلية هنا. يستخدم النشر في README الملف nginx مع سجل الوصول الافتراضي الخاص به وأي نظام تدوير يقدمه توزيعك (في Debian وUbuntu، وهو logrotate، يوميًا، مع الاحتفاظ بالسجلات لمدة 14 يومًا). تأكد من طريقة عمل الخادم الخاص بك واستبدل هذه الفقرة بالرقم.",
"privacy.storage.title": "ما الذي يتم تخزينه في متصفحك",
"privacy.storage.p1": "يستخدم الموقع localStorage لغرضين محددين فقط، وكلاهما يبقى على جهازك ولا يتم إرسالهما إلى أي مكان أبدًا:",
"privacy.storage.li1": "مخزن مؤقت يحتوي على Nostr من بيانات التعريف (أحداث من النوع 0: الأسماء، الصور الرمزية، NIP-05 العناوين) الخاصة بالمراجعين الذين اطلعت على تقييماتهم. وهذا يوفر إعادة الاستعلام عن المرحلات في كل صفحة. تنتهي صلاحية الإدخالات، ويتم تحديد سعة المخزن المؤقت وإزالة أقدم الإدخالات أولاً.",
"privacy.storage.li2": "تفضيلات الواجهة، حتى يعمل الموقع بنفس الطريقة في المرة القادمة.",
"privacy.storage.p2": "يؤدي مسح بيانات المواقع من متصفحك إلى إزالة كليهما. لن يحدث أي خلل، بل ستُجرى عمليات البحث عن الملفات الشخصية من جديد فحسب.",
"privacy.thirdParties.title": "ما الذي يتواصل معه متصفحك",
"privacy.thirdParties.p1": "يؤدي تحميل الصفحة إلى توصيل متصفحك بعدة مواقع أخرى بخلاف هذا الموقع. وهذه هي الطريقة التي يستقبل بها موقع لا يستخدم التتبع من جانب الخادم البيانات المباشرة:",
"privacy.thirdParties.li1Label": "Nostr مرحلات عامة",
"privacy.thirdParties.li1": "{strong}، عبر WebSocket، لقراءة التقييمات وملفات تعريف المُقيِّمين. يرى خادم الترحيل عنوان IP الخاص بك وما طلبته، كما يفعل أي خادم تتصل به. وترد قائمة بخوادم الترحيل على {link}.",
"privacy.thirdParties.li1Link": "صفحة «نبذة عنا»",
"privacy.thirdParties.li2Label": "صور الأفاتار الخاصة بالمراجعين",
"privacy.thirdParties.li2": "{strong}، يتم تحميلها من المكان الذي يشير إليه ملف تعريف المُراجع. ويتم إرسالها وفقًا لسياسة «عدم الإحالة»، بحيث لا يتمكن المضيف من معرفة الصفحة التي كنتَ تتصفحها.",
"privacy.publishing.title": "نشر مراجعة",
"privacy.publishing.p1": "تتم عملية كتابة التعليق في متصفحك، وليس على خادمنا. يتم توقيع هذه العملية بواسطة ملحقك NIP-07 (أو Alby، أو nos2x، أو ما شابه)، ويتم بثها من متصفحك مباشرةً إلى محطات الترحيل. لا يغادر مفتاحك الخاص الملحق أبدًا، ولا يراه هذا الموقع أبدًا. نحن لا ننشئ نظامًا مخصصًا لإدارة المفاتيح، ولا نطلب أبدًا مفتاحًا أو عبارة البذرة.",
"privacy.permanent.title": "أي شيء تنشره على Nostr يكون عامًا ودائمًا",
"privacy.permanent.p1": "هذا هو الجزء الذي يستحق القراءة مرتين. المراجعة هي حدث مُوقَّع عليه في شبكات الترحيل العامة. وبمجرد نشرها، تصبح متاحة للجميع:",
"privacy.permanent.li1": "يمكن لأي شخص قراءتها، الآن أو لاحقًا، باستخدام أي عميل Nostr. فهي ليست «عامة على Cashumints.space»، بل هي عامة على الشبكة.",
"privacy.permanent.li2": "وهو مرتبط بشكل دائم بمفتاحك العام Nostr، إلى جانب كل ما نشره هذا المفتاح على الإطلاق.",
"privacy.permanent.li3": "حذفها هو طلب وليس أمرًا. يمكنك نشر حدث حذف، وستلتزم بعض الوسيطات بتنفيذه، لكن للوسيطة الحرية في تجاهله، وسيظل الحدث محفوظًا لدى أي شخص قام بنسخه مسبقًا. تعامل مع المراجعة المنشورة على أنها دائمة.",
"privacy.permanent.li4": "لا يمكننا حذفه نيابة عنك. يمكننا إيقاف عرضه هنا؛ لكن ذلك لا يغير شيئًا في أجهزة الترحيل.",
"privacy.permanent.p2": "إذا كنت ترغب في كتابة تقييم لـ جهة إصدار دون ربطه بهويتك الرئيسية، فاستخدم مفتاحًا منفصلاً هو Nostr. لاحظ أن المفتاح الذي لا يوجد له أي نشاط آخر يظهر على هذا النحو في التقييمات القاسية، لأن قيام مفتاح جديد تمامًا بكتابة تقييم بنجمة واحدة يُعد نمطًا يستحق القراء رؤيته.",
"privacy.contact.title": "أسئلة",
"privacy.contact.p1": "الموقع مفتوح المصدر: إذا كنت ترغب في التحقق من أي مما سبق، فاقرأ الكود على {github}. يمكن إرسال الأسئلة والتصحيحات عبر إنشاء «قضية» هناك.",
"disclaimer.title": "إخلاء المسؤولية · Cashumints.space",
"disclaimer.description": "ما يجب معرفته قبل استخدام جهة إصدار جهات إصدار Cashu: هذه تابعة لجهات حفظ، أما جهة إصدار فقد يختفي مع أموالك، والتقييمات هي آراء، والمعلومات المتوفرة عبر الإنترنت يمكن الوصول إليها ولكنها غير موثوقة.",
"disclaimer.heading": "إخلاء المسؤولية",
"disclaimer.lede": "اقرأ هذا قبل أن تودع أموالاً في حساب جهة إصدار. آخر تحديث: {date}.",
"disclaimer.callout": "جهة إصدار Cashu يحتفظ بأموالك نيابة عنك. إذا اختفى، فسيضيع رقمك ecash، ولن يكون هناك من تلجأ إليه. احتفظ فقط بما يمكنك تحمل خسارته.",
"disclaimer.custodial.title": "جهات إصدار هي حسابات أمانة",
"disclaimer.custodial.p1": "عندما تقوم بـ جهة إصدار ecash، فإنك تسلم bitcoin إلى المشغل جهة إصدار وتحصل على الرموز في المقابل. هذه الرموز تمثل مطالبة تجاه ذلك المشغل. فهي ليست bitcoin في حوزتك، والمشغل هو الذي يقرر ما إذا كان سيقبلها أم لا.",
"disclaimer.custodial.p2": "وهذا يعني أن كل موقع يحمل جهة إصدار ينطوي على نفس المخاطر: فقد يختفي المشغل، أو يُجبر على التوقف، أو يفقد الأموال بسبب خلل تقني أو اختراق، أو قد يقوم ببساطة بإيقاف تشغيل الخادم. ولا يوجد تأمين على الودائع، ولا إمكانية لاسترداد الأموال، ولا خط دعم، ولا سبل انتصاف قانونية يمكن الاعتماد عليها. وينطبق هذا حتى على الموقع الذي يحمل جهة إصدار الأعلى تقييمًا على هذا الموقع.",
"disclaimer.custodial.p3": "تم تصميم حساب Cashu للمبالغ الصغيرة التي تتحرك بسرعة. استخدمه على النحو التالي: احتفظ فيه بالمبلغ الذي تنوي إنفاقه، وليس مدخراتك، ووزع المبلغ على حساب جهات إصدار بدلاً من إيداع كل المبلغ في حساب واحد.",
"disclaimer.reviews.title": "التعليقات هي آراء، وليست ادعاءات تم التحقق منها",
"disclaimer.reviews.p1": "كل تعليق هنا كتب بواسطة شخص يحمل Nostr وتم توقيعه بمفتاحه. يعني التوقيع أنه يمكننا تحديد المفتاح الذي كتب التعليق. ولا يعني ذلك أن التعليق صحيح، أو أن كاتبه استخدم جهة إصدار، أو أن كاتبه ليس المشغل، أو منافسًا، أو شخصًا واحدًا يمتلك عشرة مفاتيح.",
"disclaimer.reviews.p2": "نحن لا نتحقق من هوية المُقيِّمين، ولا نقوم بتعديل أو حذف التقييمات التي نختلف معها. اقرأ بعض التقييمات، وانظر إلى ما تقوله فعليًّا، وقيِّم التقييم الذي يحمل جهة إصدار مع ثلاث تقييمات بشكل مختلف عن التقييم الذي يحمل ثلاثمائة تقييم.",
"disclaimer.status.title": "\"عبر الإنترنت\" تعني \"يمكن الوصول إليه\"، لا \"موثوق به\"",
"disclaimer.status.p1": "نقوم بفحص كل جهة إصدار بانتظام ونوضح ما إذا كان قد استجاب أم لا. هذا هو كل ما تعنيه الحالة الخضراء: أن الخادم قد استجاب. وهي لا تشير بأي شكل إلى ما إذا كان المشغل ميسورًا ماليًا، أو أمينًا، أو لا يزال ينوي الوفاء برموزك الرقمية غدًا.",
"disclaimer.status.p2": "قد يظل جهة إصدار متصلاً بالإنترنت حتى اللحظة التي ينقطع فيها الاتصال. ويقدم لك سجل وقت التشغيل، وNUTs المدعوم، وإصدار البرنامج بعض المعلومات حول كيفية تشغيل جهة إصدار، لكن لا شيء من ذلك يمثل وعداً فيما يتعلق بأموالك.",
"disclaimer.ranking.title": "كيف يعمل نظام التصنيف، وما الذي لا يعنيه",
"disclaimer.ranking.p1": "يتم ترتيب جهات إصدار وفقًا لخوارزمية تستند إلى بيانات التقييمات العامة: التقييم، وعدد التقييمات، والنشاط الأخير، مع إعطاء الأولوية افتراضيًّا لـ جهات إصدار عبر الإنترنت على تلك التي تتم خارج الإنترنت. وترد التفاصيل في الكود مفتوح المصدر.",
"disclaimer.ranking.p2": "لا يدفع أحد مقابل تحديد ترتيب العروض. لا توجد أي خانات مدعومة، ولا قوائم مُروَّجة، ولا أي ترتيبات مع أي جهة تحمل جهة إصدار على هذا الموقع. ويعني الترتيب المتقدم أن الخوارزمية قد أعجبت بالبيانات العامة، ولا شيء أكثر من ذلك. وهذا لا يُعد توصيةً ولا تصنيفًا للأمان.",
"disclaimer.notAdvice.title": "هذه ليست نصيحة مالية",
"disclaimer.notAdvice.p1": "لا يُعد أي شيء وارد في هذا الموقع بمثابة مشورة مالية أو استثمارية أو قانونية أو ضريبية. نحن لا نعرف وضعك ولا نقدم لك أي مشورة. إن استخدام جهة إصدار واختيار المناسب هو قرارك أنت.",
"disclaimer.dyor.title": "قم بإجراء بحثك الخاص",
"disclaimer.dyor.li1": "ابحث عن صاحب الرقم. فجهة إصدار الذي يديره شخص لديه سمعة عامة قد يتضرر من ذلك يختلف تمامًا عن رابط ويب مجهول.",
"disclaimer.dyor.li2": "اقرأ الشروط الخاصة بـ جهة إصدار وMOTD. أما جهة إصدار الذي يصف نفسه بأنه «اختبار» أو «نسخة تجريبية»، فهو يعني ذلك فعلاً.",
"disclaimer.dyor.li3": "ابدأ بخطوات صغيرة. جهة إصدار، استخدم القليل منه، ثم استرجعه، وانظر ماذا سيحدث قبل أن تثق به بمبالغ أكبر.",
"disclaimer.dyor.li4": "انتبه إلى الحدود. غالبًا ما يكون الحد الأقصى المنخفض دلالة على حرص المشغل، في حين أن الحد الأقصى المرتفع لا يعني بالضرورة أنه آمن.",
"disclaimer.dyor.li5": "احرص على عمل نسخة احتياطية من توكناتك، وتذكر أن التوكن هو أصل لحامله: فمن يحمله يمكنه إنفاقه.",
"disclaimer.site.title": "حول الموقع نفسه",
"disclaimer.site.p1": "قد تكون البيانات الواردة هنا خاطئة أو قديمة. يتم فحص جهات إصدار وفقًا لجدول زمني محدد، وقد تفشل المرحلات أحيانًا في الرد، كما أن الصفحة التي تقرأها الآن تم إنشاؤها في وقت ما في الماضي. يُقدَّم الموقع «كما هو»، دون أي ضمان. انظر {link}.",
"disclaimer.site.link": "شروط الاستخدام",
"disclaimer.updated": "20 أغسطس 2026",
"login.getPrimal": "احصل على Primal",
"reviews.dialog.identity.extensionGone": "ملحق المتصفح الخاص بك لا يستجيب. يرجى تسجيل الدخول مرة أخرى، أو استخدام أداة التوقيع عن بُعد.",
"seo.orgDescription": "دليل يحتوي على جهات إصدار Cashu، مع حالة محدثة من كل جهة إصدار وتقييمات المجتمع الموقعة على Nostr.",
"seo.ogImageAlt": "Cashumints.space: ابحث عن جهة إصدار Cashu يمكنك الوثوق بهما.",
"seo.mint.title.one": "{name} تقييم: حصل على تقييم {rating} من أصل {n} تقييم | Cashumints.space",
"seo.mint.title.other": "{name} تقييم: حصل على تقييم {rating} من أصل {n} تقييم | Cashumints.space",
"seo.mint.titleOffline.one": "{name}: غير متصل منذ {n} يومًا | Cashumints.space",
"seo.mint.titleOffline.other": "{name}: غير متصل منذ {n} يومًا | Cashumints.space",
"seo.mint.titleNoReviews": "{name}: جهة إصدار Cashu معلومات وتقييمات | Cashumints.space",
"seo.fedimint.titleNoReviews": "{name}: Fedimint معلومات عن الاتحاد وتقييمات | Cashumints.space",
"seo.desc.fallback": "تقييمات المستخدمين لرقم {name} ({domain}).",
"seo.desc.fallbackDomain": "تقييمات المستخدمين لـ جهة إصدار Cashu و{domain}.",
"seo.desc.rated.one": "حصلت على تقييم {rating}/5 بناءً على تقييم {n}، الذي وقّعه Nostr.",
"seo.desc.rated.other": "حصل على تقييم {rating}/5 بناءً على {n} تقييمًا موقّعًا من Nostr.",
"seo.desc.noReviews": "لا توجد تعليقات حتى الآن.",
"seo.desc.status": "{status}.",
"seo.desc.statusLimits": "{status}، الحدود {limits}.",
"seo.desc.fedModules": "{status}. الوحدات: {modules}.",
"seo.desc.fedModulesNetwork": "{status}. الوحدات: {modules} و{network}.",
"seo.mint.ogAlt.one": "{name}: {rating} نجمة من تقييم {n} في Cashumints.space",
"seo.mint.ogAlt.other": "{name}: {rating} نجمة من {n} تقييمًا على Cashumints.space",
"seo.mint.ogAltNoReviews": "{name} على Cashumints.space",
"nav.home": "الصفحة الرئيسية",
"mint.more.title": "المزيد جهات إصدار",
"mint.more.all": "عرض الكل جهات إصدار",
"nav.fedimints": "Fedimints",
"status.announced": "تم الإعلان عنها فقط",
"card.unnamedFederation": "اتحاد لم يُذكر اسمه",
"card.notChecked": "لم يتم التحقق منه مطلقًا",
"fedimints.title": "جميع الاتحادات Fedimint · Cashumints.space",
"fedimints.description": "جميع الاتحادات البالغ عددها {total} وFedimint التي تم الإعلان عنها على الشبكة Nostr: {online} تم تأكيد تشغيلها من خلال فحص خارجي، و{announced} تم الإعلان عنها ولم يتم تأكيدها قط. تقييمات المجتمع والوحدات النمطية ورموز الدعوة لكل منها.",
"fedimints.heading": "جميع الاتحادات",
"fedimints.lede": "يتم الإعلان عن كل اتحاد Fedimint على شبكة Nostr. ولا يمتلك الاتحاد نقطة نهاية حالة عامة خاصة به، لذا يتم التأكد من تشغيل بعضها من خلال فحص خارجي، بينما يتم إدراج الباقي تمامًا كما تم الإعلان عنها.",
"fedimints.search.label": "البحث عن الاتحادات حسب الاسم أو المعرّف",
"fedimints.search.placeholder": "البحث حسب الاسم أو رقم التعريف الخاص بالاتحاد…",
"fedimints.sort.label": "فرز الاتحادات",
"fedimints.hideOffline": "إخفاء الحالة \"غير متصل\"",
"fedimints.showing": "يُعرض {total} اتحاد ({online} تم تأكيد انضمامها، {announced} تم الإعلان عنها فقط)",
"fedimints.showingFiltered": "يتم عرض {shown} من أصل {total} اتحادًا ({online} تم تأكيد انضمامها، و{announced} تم الإعلان عنها فقط)",
"fedimints.announcedNote.one": "تم الإعلان عن أحد هذه الخدمات على Nostr، ولم يتأكد منذ ذلك الحين ما إذا كانت تعمل أم لا. وقد تم إدراجها على أنها «تم الإعلان عنها» بدلاً من «متصلة» أو «غير متصلة»، لأن كلا الحالتين غير صحيحتين.",
"fedimints.announcedNote.other": "تم الإعلان عن {n} منها في Nostr، ولم يتأكد منذ ذلك الحين أنها قيد التشغيل. وقد أُدرجت في القائمة على أنها «مُعلنة» وليس «متصلة» أو «غير متصلة»، لأن كلا الحالتين غير صحيحتين.",
"fedimints.noResults": "لا توجد مباراة تابعة لأي اتحاد. جرب إدخال اسم، أو الصق معرّف الاتحاد.",
"fedimints.emptyIndex": "لم يتم الإعلان عن أي اتحادات في سباقات التتابع، حسبما يرد في هذا الموقع. وتُملأ القائمة تلقائيًا فور وصول الإعلانات.",
"fedimint.crumb": "Fedimints",
"fedimint.breadcrumb": "مسار التنقل",
"fedimint.unnamed": "اتحاد لم يُذكر اسمه",
"fedimint.idLabel": "الاتحاد",
"fedimint.kind.federationId": "معرّف الاتحاد",
"fedimint.moduleCount.one": "الوحدة {n}",
"fedimint.moduleCount.other": "{n} وحدة",
"fedimint.copyInvite": "نسخ رمز الدعوة",
"fedimint.confirmed.label": "تم التأكيد",
"fedimint.confirmed.never": "أبدًا",
"fedimint.confirmed.by": "بواسطة {source}",
"fedimint.confirmed.unattributed": "آخر فحص",
"fedimint.announcedOn": "أُعلن عن {when}",
"fedimint.announcedUnknown": "لم يتم تحديد موعد للإعلان",
"fedimint.modules.title": "الوحدات",
"fedimint.modules.none": "لا يتضمن إعلان هذا الاتحاد أي وحدات دراسية.",
"fedimint.modules.note": "الوحدات هي الأجزاء التي يديرها الاتحاد. ويشير الإعلان إلى الوحدات الموجودة، دون أن يذكر ما إذا كانت أي منها تقبل الإيداعات أم لا.",
"fedimint.modules.notListed": "غير مدرج",
"fedimint.invite.title": "رمز الدعوة",
"fedimint.invite.count.one": "الرمز {n}",
"fedimint.invite.count.other": "{n} رمزًا",
"fedimint.invite.note": "الصق هذا في محفظة Fedimint للانضمام. تحتوي هذه المحفظة على معرّف الاتحاد وعناوين أمنائه، وهي مخصصة للمشاركة.",
"fedimint.invite.kind": "رمز الدعوة",
"fedimint.invite.qr": "QR",
"fedimint.invite.qrCaption": "امسح الرمز ضوئيًا للانضمام إلى {name}",
"fedimint.details.federationId": "معرّف الاتحاد",
"fedimint.details.network": "الشبكة",
"fedimint.details.announcedBy": "أعلن عن ذلك",
"fedimint.details.announced": "تم الإعلان عن",
"fedimint.more.title": "المزيد من الاتحادات",
"fedimint.more.all": "عرض جميع الاتحادات",
"fedimint.disclaimer": "الانضمام إلى اتحاد ما يعني أن توكل أموالك إلى القائمين عليه. التصنيفات هي آراء المجتمع، و«التشغيل المؤكد» يعني أن الخدمة متاحة، لا أنها جديرة بالثقة.",
"fedimint.module.lightning": "Lightning",
"fedimint.module.mint": "Ecash جهة إصدار",
"fedimint.module.wallet": "محفظة على السلسلة",
"fedimint.module.meta": "البيانات الوصفية",
"fedimint.module.stability_pool": "صندوق الاستقرار",
"fedimint.module.multi_sig_stability_pool": "مجمع الاستقرار (متعدد التوقيعات)",
"fedimint.module.fedi-social": "التعافي الاجتماعي",
"fedimint.network.mainnet": "Bitcoin mainnet",
"fedimint.network.signet": "Signet",
"fedimint.network.testnet": "Testnet",
"fedimint.network.testnet4": "Testnet4",
"fedimint.network.regtest": "Regtest",
"feed.ecosystem.group": "النظام البيئي",
"feed.ecosystem.all": "الكل",
"feed.ecosystem.cashu": "Cashu",
"feed.ecosystem.fedimint": "Fedimint",
"feed.filterBySubject": "التصفية حسب جهة إصدار أو الاتحاد",
"feed.anySubject": "أي رقم من جهة إصدار أو الاتحاد",
"feed.clearSubject": "مسح فلتر الاسم",
"home.topFedimints.title": "أفضل عملات «فيديمينتس»",
"home.topFedimints.sub": "مرتبة حسب التعليقات والتقييمات",
"home.topFedimints.all": "جميع الاتحادات البالغ عددها {n} →",
"reviews.validate.subject": "لا يوجد ما يمكن توجيه هذه المراجعة إليه.",
"mint.warnings.fedimintOffline.lead": "تم الإبلاغ عنه في وضع عدم الاتصال.",
"mint.warnings.fedimintOffline.body.since": "آخر شيك وصل إلى هذا الاتحاد كان برقم {date} (قبل {days}). ولم يرد القائمون عليه منذ ذلك الحين. تعاملوا مع الأموال المودعة هناك على أنها معرضة للخطر، ولا تضيفوا إليها أموالاً جديدة.",
"mint.warnings.fedimintOffline.body.never": "لم تصل إلى هذا الاتحاد أي شيكات على الإطلاق، وقد فشل آخر شيك تم إرساله. تعاملوا مع الأموال المودعة هناك على أنها معرضة للخطر، ولا تودعوا فيها أموالاً جديدة.",
"mint.warnings.fedimintOffline.meta.since": "تم الإبلاغ عن انقطاع الاتصال منذ {date}",
"mint.warnings.fedimintOffline.meta.never": "تم الإبلاغ عنه في وضع عدم الاتصال، ولم يتم الوصول إليه مطلقًا",
"mint.warnings.neverConfirmed.lead": "تم الإعلان عنه، لكن لم يتم تأكيده قط.",
"mint.warnings.neverConfirmed.body.reviewed": "تم الإعلان عن هذا الاتحاد في Nostr منذ {date} و{days}، ولم يتأكد منذ ذلك الحين أنه لا يزال نشطًا. التعليقات الواردة أدناه هي العلامة الوحيدة على وجود نشاط في هذه الصفحة؛ أما كل ما عدا ذلك فقد ورد في الإعلان.",
"mint.warnings.neverConfirmed.body.quiet": "تم الإعلان عن هذا الاتحاد في Nostr منذ {date} و{days}، ولم يتم تأكيد أي شيء منذ ذلك الحين يفيد بأنه لا يزال نشطًا، ولم يقم أحد بمراجعته مؤخرًا. كل ما يلي مأخوذ من الإعلان.",
"mint.warnings.neverConfirmed.meta": "تم الإعلان عن {date}، ولم يتم تأكيده قط",
"footer.allFedimints": "جميع الاتحادات",
"reviews.dialog.bodyPlaceholder.cashu": "كيف تم حساب الإصدار والاسترداد و\"support\"؟ كن محددًا.",
"reviews.dialog.bodyPlaceholder.fedimint": "كيف سارت الأمور فيما يتعلق بالانضمام، وLightning عملية دفع، والدعم؟ يرجى التحديد.",
"fedimint.details.title": "تفاصيل الاتحاد",
"nav.lnurlMints": "جهات إصدار LNURL",
"footer.allLnurlMints": "كل جهات إصدار LNURL",
"lnurlMints.title": "منّاتات LNURL: منّاتات سندات lnurlcash لحاملها، مع المراجعات | Cashumints.space",
"lnurlMints.description": "كل منّاتات LNURL البالغ عددها {total} والمفهرسة من شبكة Nostr: {online} متصلة، {offline} غير متصلة. تقييمات المجتمع وحدود السحب والميزات لكل واحدة.",
"lnurlMints.heading": "منّاتات LNURL",
"lnurlMints.lede": "كل منّاتة LNURL يعرفها هذا الموقع. منّاتة LNURL تُصدر سندات lnurlcash لحاملها: سندات Lightning تحتفظ بها كسرّ، ويمكن تدويرها أو تقسيمها أو دمجها أو صهرها مجددًا إلى دفعة Lightning. تُفحص كل واحدة عبر HTTPS كل بضع دقائق.",
"lnurlMints.search.label": "ابحث في منّاتات LNURL",
"lnurlMints.search.placeholder": "ابحث بالاسم أو العنوان…",
"lnurlMints.sort.label": "ترتيب منّاتات LNURL حسب",
"lnurlMints.hideOffline": "إخفاء غير المتصلة",
"lnurlMints.showing": "عرض {total} منّاتة LNURL ({online} متصلة، {offline} غير متصلة)",
"lnurlMints.showingFiltered": "عرض {shown} من {total} منّاتة LNURL ({online} متصلة، {offline} غير متصلة)",
"lnurlMints.noResults": "لا توجد منّاتة LNURL مطابقة.",
"lnurlMints.emptyIndex": "لم تُفهرس أي منّاتة LNURL بعد.",
"lnurl.crumb": "منّاتات LNURL",
"lnurl.breadcrumb": "مسار التنقل",
"lnurl.copyAddress": "نسخ العنوان",
"lnurl.more.title": "المزيد من منّاتات LNURL",
"lnurl.more.all": "كل منّاتات LNURL ←",
"lnurl.disclaimer": "منّاتة LNURL تحتفظ بالساتوشي الخاص بك بينما تحتفظ أنت بسنداتها، فاستخدامها يعني الوثوق بمشغّلها. التقييمات آراء من المجتمع، و«متصلة» تعني يمكن الوصول إليها، لا أنها جديرة بالثقة.",
"lnurl.features.title": "الميزات",
"lnurl.features.none": "لم تُعلن هذه المنّاتة عمّا تدعمه.",
"lnurl.features.note": "الميزات هي ما أعلنه مشغّل المنّاتة على Nostr. وحيثما أمكن لهذا الموقع التحقق من إحداها عبر HTTPS فإنه يفعل، ويُنبّه عندما يكون شيء مُعلن غير متاح حاليًا.",
"lnurl.features.notListed": "غير مُعلن",
"lnurl.features.unavailable": "غير متاح",
"lnurl.feature.mint": "السكّ عبر Lightning (LUD-06)",
"lnurl.feature.melt": "الصهر إلى Lightning",
"lnurl.feature.notes": "تدوير / تقسيم / دمج السندات",
"lnurl.feature.lud16": "عنوان Lightning",
"lnurl.feature.lud21": "التحقق من الدفع (LUD-21)",
"lnurl.feature.signed-notes": "سندات موقّعة (تحقق دون اتصال)",
"lnurl.feature.onion": "عنوان Tor",
"lnurl.feature.rotate": "تدوير سند",
"lnurl.feature.split": "تقسيم سند",
"lnurl.feature.merge": "دمج السندات",
"lnurl.feature.lud06": "LUD-06 payRequest",
"lnurl.feature.lud03": "LUD-03 withdrawRequest",
"lnurl.limits.label": "حدود السحب",
"lnurl.limits.notPublished": "غير منشور",
"lnurl.limits.withdrawRow": "السحب",
"lnurl.limits.disabled": "معطّل",
"lnurl.limits.sats.one": "{n} ساتوشي",
"lnurl.limits.sats.two": "{n} ساتوشي",
"lnurl.limits.sats.few": "{n} ساتوشي",
"lnurl.limits.sats.many": "{n} ساتوشي",
"lnurl.limits.sats.other": "{n} ساتوشي",
"lnurl.details.title": "تفاصيل المنّاتة",
"lnurl.details.address": "عنوان Lightning",
"lnurl.details.mintPubkey": "المفتاح العام للمنّاتة",
"lnurl.details.onion": "عنوان Tor",
"lnurl.details.fee": "رسوم السكّ",
"lnurl.details.node": "العقدة",
"lnurl.details.capacity": "السعة المُعلنة",
"lnurl.kind.lightningAddress": "عنوان Lightning",
"lnurl.kind.mintPubkey": "المفتاح العام للمنّاتة",
"lnurl.kind.onionAddress": "عنوان Tor",
"lnurl.confirmed.label": "آخر تأكيد",
"lnurl.confirmed.when": "تم التأكيد {when}",
"lnurl.fee.base.one": "{n} ساتوشي",
"lnurl.fee.base.two": "{n} ساتوشي",
"lnurl.fee.base.few": "{n} ساتوشي",
"lnurl.fee.base.many": "{n} ساتوشي",
"lnurl.fee.base.other": "{n} ساتوشي",
"lnurl.fee.percent": "{percent}٪",
"home.topLnurl.title": "أفضل منّاتات LNURL",
"home.topLnurl.sub": "مرتّبة حسب المراجعات والتقييم",
"home.topLnurl.all": "كل منّاتات LNURL البالغة {n} ←",
"feed.ecosystem.lnurl": "LNURL",
"reviews.dialog.bodyPlaceholder.lnurl": "كيف كان السكّ والصهر وتدوير السندات؟ كن محددًا.",
"reviews.byUrl.open": "اكتب مراجعة",
"reviews.byUrl.title": "اكتب مراجعة",
"reviews.byUrl.lede": "ألصق العنوان. إن كان هذا الموقع لا يعرفه بعد، فسيفحصه ويضيفه الآن.",
"reviews.byUrl.label.cashu": "رابط الـ mint",
"reviews.byUrl.label.fedimint": "رمز دعوة الاتحاد",
"reviews.byUrl.label.lnurl": "رابط mint من نوع LNURL",
"reviews.byUrl.placeholder.cashu": "mint.example.com",
"reviews.byUrl.placeholder.fedimint": "fed11…",
"reviews.byUrl.placeholder.lnurl": "mint.example.com",
"reviews.byUrl.check": "افحص هذا العنوان",
"reviews.byUrl.checking": "جارٍ الفحص…",
"reviews.byUrl.back": "استخدم عنوانًا آخر",
"reviews.byUrl.existing": "مدرج بالفعل",
"reviews.byUrl.indexed": "أُضيف للتو",
"reviews.byUrl.confirmClose": "إغلاق؟ العنوان الذي كتبته لن يُحفظ.",
"reviews.byUrl.detected": "استجاب هذا العنوان بوصفه mint من نوع {kind}.",
"reviews.byUrl.switchTo": "راجعه بوصفه mint من نوع {kind}",
"reviews.byUrl.error.empty": "اكتب عنوانًا أولًا.",
"reviews.byUrl.error.badUrl": "هذا لا يبدو رابط mint.",
"reviews.byUrl.error.badInvite": "هذا ليس رمز دعوة Fedimint. الرموز تبدأ بـ fed11.",
"reviews.byUrl.error.badInput": "لا يستطيع هذا الموقع فحص ذلك العنوان.",
"reviews.byUrl.error.blocked": "هذا ليس عنوانًا عامًا، لذا لا يمكن فحصه.",
"reviews.byUrl.error.notAMint": "استجاب هذا العنوان، لكن ليس بوصفه mint من هذا النوع.",
"reviews.byUrl.error.wrongType": "استجاب هذا العنوان، لكن بوصفه mint من نوع آخر.",
"reviews.byUrl.error.unverifiable": "لم يستجب شيء على ذلك العنوان، ولا أثر له على Nostr حتى الآن.",
"reviews.byUrl.error.rateLimited.one": "عدد كبير من الفحوص من اتصال واحد. حاول مجددًا بعد نحو {n} دقيقة.",
"reviews.byUrl.error.rateLimited.other": "عدد كبير من الفحوص من اتصال واحد. حاول مجددًا بعد نحو {n} دقيقة.",
"reviews.byUrl.error.network": "تعذّر الوصول إلى الفهرس. تحقّق من اتصالك ثم حاول مجددًا.",
"seo.lnurl.titleNoReviews": "{name}: معلومات ومراجعات منّاتة LNURL | Cashumints.space",
"seo.desc.statusWithdraw": "{status}، السحب {min}–{max} ساتوشي.",
"mint.chip.noFunding": "لا سكّ ولا صهر",
"mint.warnings.lnurlWithdrawalsDisabled.lead": "السحب معطّل.",
"mint.warnings.lnurlWithdrawalsDisabled.body.online": "تُعلن هذه المنّاتة حدًّا أقصى للسحب يساوي صفرًا، فلا يمكن استبدال أي سند تُصدره بأي شيء. لا تسكّ هنا حتى يتغير ذلك.",
"mint.warnings.lnurlWithdrawalsDisabled.body.offline": "عند آخر اتصال بها كانت هذه المنّاتة تُعلن حدًّا أقصى للسحب يساوي صفرًا، فلم يكن ممكنًا استبدال أي سند تُصدره بأي شيء. لا تسكّ هنا حتى يتغير ذلك.",
"mint.warnings.lnurlWithdrawalsDisabled.meta.online": "السحب معطّل حاليًا",
"mint.warnings.lnurlWithdrawalsDisabled.meta.offline": "كان السحب معطّلًا عند آخر ظهور",
"mint.warnings.lnurlInvalid.lead": "نقطة النهاية تستجيب لكنها غير صالحة.",
"mint.warnings.lnurlInvalid.body": "المضيف يستجيب، لكن ليس بإعلان منّاتة يستطيع هذا الموقع قراءته، لذا قد لا يعمل السحب. كل ما في الأسفل هو آخر حالة أمكن قراءتها، والمراجعات لا تزال تعمل.",
"mint.warnings.lnurlInvalid.meta": "يستجيب، لكن ليس بإعلان منّاتة صالح",
"mint.warnings.lnurlNoFunding.lead": "السكّ والصهر غير متاحين.",
"mint.warnings.lnurlNoFunding.body": "لا تزال السندات الموجودة قابلة للتدوير أو التقسيم أو الدمج، لكن لا شيء يدخل أو يخرج عبر Lightning حاليًا. المنّاتة نفسها تعمل وتستجيب؛ لكن عقدة Lightning خلفها غير قابلة للوصول منها.",
"mint.warnings.lnurlNoFunding.meta": "السكّ والصهر غير متاحين",
"mint.warnings.also.noFunding": "كانت عقدة Lightning الخاصة بها غير قابلة للوصول أيضًا، فلم يكن ممكنًا سكّ أو صهر أي شيء."
}
+66
View File
@@ -23,6 +23,8 @@ export interface LocaleInfo {
intl: string;
/** `og:locale`. Underscored, region required by the OpenGraph spec. */
og: string;
/** Writing direction for the document. */
dir?: 'rtl';
}
/**
@@ -33,8 +35,63 @@ export const LOCALES: LocaleInfo[] = [
{ code: 'en', label: 'English', intl: 'en-GB', og: 'en_US' },
{ code: 'es', label: 'Español', intl: 'es-ES', og: 'es_ES' },
{ code: 'nl', label: 'Nederlands', intl: 'nl-NL', og: 'nl_NL' },
{ code: 'zh', label: '简体中文', intl: 'zh-CN', og: 'zh_CN' },
{ code: 'hi', label: 'हिन्दी', intl: 'hi-IN', og: 'hi_IN' },
{ code: 'ar', label: 'العربية الفصحى', intl: 'ar-SA', og: 'ar_SA', dir: 'rtl' },
{ code: 'fr', label: 'Français', intl: 'fr-FR', og: 'fr_FR' },
{ code: 'pt', label: 'Português', intl: 'pt-BR', og: 'pt_BR' },
{ code: 'it', label: 'Italiano', intl: 'it-IT', og: 'it_IT' },
{ code: 'tr', label: 'Türkçe', intl: 'tr-TR', og: 'tr_TR' },
{ code: 'pl', label: 'Polski', intl: 'pl-PL', og: 'pl_PL' },
{ code: 'uk', label: 'Українська', intl: 'uk-UA', og: 'uk_UA' },
{ code: 'ro', label: 'Română', intl: 'ro-RO', og: 'ro_RO' },
{ code: 'el', label: 'Ελληνικά', intl: 'el-GR', og: 'el_GR' },
{ code: 'cs', label: 'Čeština', intl: 'cs-CZ', og: 'cs_CZ' },
{ code: 'sv', label: 'Svenska', intl: 'sv-SE', og: 'sv_SE' },
{ code: 'da', label: 'Dansk', intl: 'da-DK', og: 'da_DK' },
{ code: 'id', label: 'Bahasa Indonesia', intl: 'id-ID', og: 'id_ID' },
{ code: 'ur', label: 'اردو', intl: 'ur-PK', og: 'ur_PK', dir: 'rtl' },
{ code: 'ru', label: 'Русский', intl: 'ru-RU', og: 'ru_RU' },
{ code: 'de', label: 'Deutsch', intl: 'de-DE', og: 'de_DE' },
{ code: 'ja', label: '日本語', intl: 'ja-JP', og: 'ja_JP' },
{ code: 'ar-eg', label: 'العربية المصرية', intl: 'ar-EG', og: 'ar_EG', dir: 'rtl' },
{ code: 'vi', label: 'Tiếng Việt', intl: 'vi-VN', og: 'vi_VN' },
];
/*
* All twenty-four, and what "shipping" one actually required.
*
* These locales spent a while listed here while `index.ts` imported only en, es and nl.
* That is the failure worth remembering, because it does not look like one: every other
* locale rendered the *English* catalog under its own `<html lang>`, its own URL prefix
* and a full reciprocal hreflang set. Not a half-translated page — twenty-one complete
* duplicates of the English site, each telling a crawler it was a different language.
* They were removed from this table rather than wired up, which fixed the SEO claim and
* left twenty-one finished catalogs on disk reaching nobody.
*
* They are back, and this time the catalog is imported and in `CATALOGS` alongside the
* entry here. Those two edits are one change; either alone is the bug above. The build
* cannot tell you when you make it — `catalogFor()` falls back to English by design —
* so the guard is `test/i18n-wiring.test.mjs`, which fails when a locale in this table
* has no catalog of its own behind it.
*
* Adding a language is therefore four things: the entry here, the code in `locales.mjs`,
* the import and `CATALOGS` line in `index.ts`, and `xx.json` itself.
*
* On translation quality: these catalogs were machine-translated in bulk and then
* repaired by hand across the strings that appear on every page — the brand name, the
* footer, the nav, the language switcher, the custody warning. The brand in particular
* was being translated as a phrase ("Καταιγίδες. Διάστημα", "ログイン スペース"), which is
* why `site.name` is now asserted identical to English in every catalog by the same
* test. Below the chrome, the body copy is still machine output: understandable, not
* idiomatic, and worth a native pass per language before anyone calls it finished.
*
* `ar` (Modern Standard) and `ar-eg` (Egyptian) are both here and both real catalogs
* rather than duplicates. Two RTL page sets do compete for a mostly-overlapping
* audience; that is a content decision to revisit with traffic in hand, not a reason to
* leave a finished catalog unreachable.
*/
export type Locale = string;
/** Just the codes, for the Astro config and for validating a URL segment. */
@@ -75,6 +132,15 @@ export const CLIENT_NAMESPACES = [
'mints',
'mint',
'nut',
// The federation index's own count line, and the module names the live island
// redraws when a fresh announcement disagrees with the build.
'fedimints',
'fedimint',
// The LNURL index's count line, and the feature rows and withdraw-limits cell the
// live island redraws when a fresh probe disagrees with the build.
'lnurlMints',
'lnurl',
'card',
'reviews',
'feed',
'login',
+834
View File
@@ -0,0 +1,834 @@
{
"site.name": "Cashumints.space",
"common.close": "Zavřít",
"common.cancel": "Zrušit",
"common.done": "Hotovo",
"common.back": "Zpět",
"common.retry": "Zkusit znovu",
"common.connect": "Připojit",
"common.copied": "Zkopírováno",
"common.copyFull": "Kopírovat plně{kind}",
"common.previousPage": "Předchozí stránka",
"common.nextPage": "Další strana",
"common.reviewPages": "Přezkum stránky",
"time.never": "nikdy",
"time.justNow": "právě teď",
"time.none": "-",
"time.unknownDate": "neznámé datum",
"time.ago.minute": "{n}m před",
"time.ago.hour": "{n}h před",
"time.ago.day": "{n}d před",
"time.ago.week": "{n}Před",
"time.ago.month": "{n}mo před",
"time.ago.year": "{n}Ano.",
"time.short.minute": "{n}m",
"time.short.hour": "{n}h",
"time.short.day": "{n}d",
"time.short.week": "{n}s",
"time.short.month": "{n}mo",
"time.short.year": "{n}y",
"status.online": "Online",
"status.offline": "Offline",
"status.degraded": "Nedosažitelné",
"status.unknown": "Ještě není zkontrolováno",
"nav.skip": "Přeskočit na obsah",
"nav.main": "Hlavní",
"nav.mints": "Cashu minty",
"nav.reviews": "Recenze",
"nav.wallets": "Peněženky",
"nav.about": "O",
"nav.searchMints": "Hledat mezi {n} minty",
"lang.label": "Jazyk",
"lang.switcher": "Vyberte si jazyk",
"footer.copyright": "© {year} Cashumints.space",
"footer.allMints": "Všechny Cashu minty",
"footer.allReviews": "Všechny recenze",
"footer.wallets": "Peněženky",
"footer.about": "O",
"footer.disclaimer": "Právní upozornění",
"footer.terms": "Podmínky",
"footer.privacy": "Ochrana soukromí",
"footer.madeBy": "Vytvořeno s ❤️ od {azzamo} · Poháněno Cashu a NIP-87",
"footer.warning": "Minty jsou custodiální. Mint může zmizet s vašimi penězi.",
"footer.tagline": "Recenze komunity na ecash minty, podepsané na Nostru.",
"footer.col.explore": "Prozkoumat",
"footer.col.site": "Web",
"footer.nav": "Zápatí",
"home.title": "Cashumints.space · Najdi Cashu mint, kterému můžeš věřit",
"home.description": "Najdi Cashu mint, kterému můžeš věřit. {mints} mintů v indexu, {reviews} komunitních recenzí podepsaných na Nostru, k tomu živá dostupnost, podporované NUTy a software mintu.",
"home.hero.title": "Najdi Cashu mint",
"home.hero.titleEm": "Můžeš věřit.",
"home.hero.lede": "Každá mincovna na síti Nostr, s opravdovými komunitními recenzemi. Žádné brány, jen podepsal recenze si můžete ověřit sami.",
"home.search.label": "Hledat mincovny podle jména, domain, nebo URL",
"home.search.placeholder": "Hledat podle jména, domain, nebo vložit mincovna URLD",
"home.filters.mostReviewed": "Nejpřezkoumávanější",
"home.filters.highestRated": "Nejvyšší hodnocení",
"home.filters.recentlyActive": "Nedávno aktivní",
"home.filters.websockets": "Podporuje webové Sockets",
"home.top.title": "Top Cashu mincovny",
"home.top.sub": "hodnocené podle hodnocení a hodnocení",
"home.top.all": "Všechny{n}mincovny →",
"home.latest.title": "Poslední recenze",
"home.latest.sub": "živě ze sítě Nostr",
"home.latest.prev": "Předchozí recenze",
"home.latest.next": "Další recenze",
"home.latest.all": "Všechny recenze →",
"home.latest.empty": "Žádné recenze by mohly být čteny z relé právě teď. Otevřete jakékoli mincovna stránky číst a psát recenze přímo.",
"home.why.title": "Proč věřit těmto recenzím?",
"home.why.signed.title": "Podepsáno, nepředloženo",
"home.why.signed.body": "Každá recenze je{link}událost podepsaná jejím autorizačním klíčem Nostr. Nic zde není editováno, Ranked, nebo odstraněn námi.",
"home.why.signed.link": "NIP- 87",
"home.why.verify.title": "Ověřte si to sám.",
"home.why.verify.body": "Recenze živě na veřejnosti Nostr relays, ne naše databáze. Point každý klient na stejné relé a uvidíte stejné datum.",
"home.why.checked.title": "Kontrolováno nepřetržitě",
"home.why.checked.body": "Prohledáváme každou mincovnu pro uptime, softwarovou verzi, limitsy a podporované NUTs, takže specifikace, které vidíte, jsou aktuální, ne etablované.",
"home.cta.title": "- Přemýšlíte o mincovně?",
"home.cta.body": "Mincovna drží vaše ecash. Přečtěte si, co ostatní lidé našli, než jste dali sats in, a zanechte recenzi, když jste použili jeden sám.",
"home.cta.explore": "Prozkoumejte všechny mincovny",
"home.cta.learn": "Přečtěte si o Cashu",
"pulse.mintsIndexed": "mincovny indexované",
"pulse.reviewsOnNostr": "Recenze na Nostr",
"pulse.lastReview": "poslední přezkum",
"pulse.mintsOnline": "mincovny online",
"pulse.indexer": "indexovač",
"pulse.connected": "připojeno",
"pulse.stale": "oschlý",
"mints.title": "Všechny Cashu mincovny · Cashumints.space",
"mints.description": "Všechny{total}Cashu mincovny indexovány z sítě Nostr,{online}online a{offline}Offline, s komunitou ratingstime, čas a mincovna software. hledání a třídit celý seznam.",
"mints.heading": "Všechny Cashu minty",
"mints.lede": "Každá mincovna objevil na síti Nostr, včetně těch, které jsou gone. offline mincovny zůstat uveden, takže můžete stále najít a zkontrolovat je.",
"mints.search.label": "Hledat mincovny podle názvu nebo domény",
"mints.search.placeholder": "Hledání podle jména nebo původu",
"mints.sort.label": "Třídit mincovny",
"mints.sort.score": "Celkem nejlepší",
"mints.sort.rating": "Nejvyšší hodnocení",
"mints.sort.reviews": "Nejpřezkoumávanější",
"mints.sort.recent": "Nedávno přezkoumáno",
"mints.sort.seen": "Nedávno viděn online",
"mints.hideOffline": "Skrýt offline",
"mints.showing": "Zobrazuji{total}mincovny ({online}online,{offline}offline)",
"mints.showingFiltered": "Zobrazuji{shown}z{total}mincovny ({online}online,{offline}offline)",
"mints.noResults": "Žádná mincovna neodpovídá. Zkuste doménu nebo vložte URL mincovny.",
"mints.clearSearch": "Vymazat vyhledávání",
"card.noRatings": "Zatím žádné hodnocení.",
"card.reviews.one": "{n}Přezkum",
"card.reviews.other": "{n}recenze",
"card.reviews.few": "{n}recenze",
"card.reviews.many": "{n}recenze",
"card.lastSeen": "naposledy viděn{when}",
"card.neverSeen": "Nikdy jsem neviděl online",
"card.reviewed": "přezkoumáno{when}",
"card.noReviews": "dosud žádné recenze",
"mint.title": "{name}· Cashu mincovna recenze",
"mint.titleFedimint": "{name} · Recenze federace Fedimint",
"mint.titleLnurl": "{name} · Recenze LNURL mintu",
"mint.breadcrumb": "Drobečková navigace",
"mint.crumbMints": "Cashu mincovny",
"mint.copyUrl": "Kopírovat URL mincovna",
"mint.qrButton": "mincovna QR",
"mint.writeReview": "Napište recenzi",
"mint.rankByScore": "#{rank}podle skóre",
"mint.tagLightning": "Blesk",
"mint.summary": "souhrn mincovna",
"mint.rating.label": "Hodnocení Společenství",
"mint.rating.none": "Zatím žádné hodnocení.",
"mint.rating.reviews.one": "{n}Přezkum",
"mint.rating.reviews.other": "{n}recenze",
"mint.rating.reviews.few": "{n}recenze",
"mint.rating.reviews.many": "{n}recenze",
"mint.rating.unrated.one": "{n}nehodnoceno",
"mint.rating.unrated.other": "{n}nehodnoceno",
"mint.rating.unrated.few": "{n}nehodnoceno",
"mint.rating.unrated.many": "{n}nehodnoceno",
"mint.activity.label": "Činnost",
"mint.activity.since": "od posledního přezkumu",
"mint.activity.noReviews": "dosud žádné recenze",
"mint.activity.recent.one": "{n}přezkum v posledních 90 dnech",
"mint.activity.recent.other": "{n}recenze v posledních 90 dnech",
"mint.activity.recent.few": "{n}recenze v posledních 90 dnech",
"mint.activity.recent.many": "{n}recenze v posledních 90 dnech",
"mint.limits.label": "Mezní hodnoty",
"mint.limits.notPublished": "nezveřejněno",
"mint.limits.min": "min",
"mint.limits.max": "max",
"mint.limits.mintRow": "ražba",
"mint.limits.meltRow": "výběr",
"mint.limits.disabled": "deaktivováno",
"mint.limits.range": "{min}do{max}{unit}",
"mint.limits.upTo": "až do{max}{unit}",
"mint.limits.from": "od{min}{unit}",
"mint.software.label": "mincovna software",
"mint.software.unknown": "neznámý",
"mint.checked": "zkontrolováno{when}",
"mint.lastSeen": "naposledy viděn{when}",
"mint.neverReached": "nedosažen",
"mint.uptime.checks": "{ok}Poslední{total}kontroly dosažitelné",
"mint.uptime.percent": "{percent}% dosažitelných za 30 dní",
"mint.uptime.description": "{ok}Poslední{total}úspěšně provedené kontroly",
"mint.operator.title": "Od provozovatele",
"mint.operator.motd": "Zpráva dne",
"mint.operator.beta": "Tento mincovna popisuje sám sebe jako experimentální nebo v beta. Přečtěte si operatortis vlastní slova níže před uložením něco, co nemůžete ztratit.",
"mint.nuts.title": "Podporovaná čísla NUTS",
"mint.nuts.noneOffline": "Žádný NUT seznam pro tuto mincovna, to šlo offline před tím, než jeden byl přijat.",
"mint.nuts.noneYet": "Na tuhle mincovnu ještě žádný NUT seznam neplatil.",
"mint.nuts.supported": "{n}z{total}podporované známé NUTS",
"mint.nuts.whatAre": "Co jsou NUTs?",
"mint.nutRow.disabled": "deaktivováno",
"mint.nutRow.notSupported": "nepodporované",
"mint.details.title": "mincovna podrobnosti",
"mint.details.pubkey": "Veřejný klíč",
"mint.details.nostr": "Nostr",
"mint.details.email": "E @-@ mail",
"mint.details.serverTime": "Čas serveru",
"mint.details.firstSeen": "Poprvé spatřeno",
"mint.details.terms": "Podmínky",
"mint.kind.identifier": "identifikátor",
"mint.kind.mintPubkey": "mincovna pubkey",
"mint.kind.nostrAddress": "Nostr adresa",
"mint.kind.emailAddress": "e @-@ mailová adresa",
"mint.kind.npub": "npub",
"mint.disclaimer": "Tato mincovna je uschovatelské diagramu: to drží vaše peníze, a to může přestat dělat tak kdykoliv. Ratings jsou názory komunity a on @-@ line znamená reachable. ne důvěryhodnost.",
"mint.disclaimerLink": "Přečtěte si úplné odřeknutí zodpovědnosti",
"mint.qr.title": "Přidat{name}do tvé peněženky.",
"mint.qr.note": "Proskenujte to pomocí Cashu peněženky přidat mincovna, nebo zkopírujte URL a vložte ji dovnitř.",
"mint.qr.warn": "Přidání mincovny znamená svěřit se s penězi.",
"mint.qr.whatMeans": "Co to znamená?",
"mint.qr.wallets": "Peněženky, které podporují Cashu",
"mint.chip.frozen": "Zmrazené",
"mint.chip.noWithdrawals": "Žádné výběry",
"mint.chip.meltOnly": "Pouze tavit",
"mint.warnings.dayCount.one": "{n}den",
"mint.warnings.dayCount.other": "{n}dny",
"mint.warnings.dayCount.few": "{n}dny",
"mint.warnings.dayCount.many": "{n}dny",
"mint.warnings.gone.lead": "Pravděpodobně ano.",
"mint.warnings.gone.body.since": "Tato mincovna je nedostupná od té doby, co{date}({days}Zacházení s finančními prostředky drženými tam jako v riziku, a neposílejte nové fondy. Podrobnosti níže ukazují jeho poslední známý stav.",
"mint.warnings.gone.body.neverDated": "Tato mincovna neodpověděla na jediný šek od doby, co jsme se na to začali dívat v{month}. Léčit fondy držené tam jako v riziku, a neposílejte nové fondy. Cokoliv níže pochází z Nostr oznámení, ne z mincovna.",
"mint.warnings.gone.body.never": "Tato mincovna neodpověděla na jediný šek od doby, co jsme začali sledovat. Zacházejte s finančními prostředky drženými tam jako v riziku, a neposílejte nové fondy. Cokoliv níže pochází z Nostr oznámení, ne z mincovna.",
"mint.warnings.gone.meta.since": "Offline od{date}",
"mint.warnings.gone.meta.never": "Nikdy nedostupný, protože byl objeven.",
"mint.warnings.meltDisabled.lead": "Odstoupení je nepoužitelné.",
"mint.warnings.meltDisabled.body.online": "Tento mincovna má Lightning tání CITNUT-05) vypnuto: můžete dát sats in, ale nemůžete je stáhnout přes Lightning. neukládat, dokud tání je zpět.",
"mint.warnings.meltDisabled.body.offline": "Tento mincovna měl Lightning tání CITNUT-05) vypnuto: můžete dát sats do, ale nemůžete je stáhnout přes Lightning. neukládat, dokud tání je zpět.",
"mint.warnings.meltDisabled.meta.online": "V současné době je stahování zakázáno",
"mint.warnings.meltDisabled.meta.offline": "Odebrání bylo zakázáno, když bylo naposledy pozorováno",
"mint.warnings.frozen.lead": "Mincovna Frozenová.",
"mint.warnings.frozen.body.online": "Jak mincovní, tak tavení jsou nepoužitelné. Žádné saturace se nemohou pohybovat dovnitř ani ven přes Lightning.",
"mint.warnings.frozen.body.offline": "Vypínání a tavení byly nepoužitelné. Žádné saturace se přes Lightninga ani dovnitř ani ven nemohou pohybovat.",
"mint.warnings.frozen.meta.online": "V současné době mražené, nic se pohybuje dovnitř nebo ven",
"mint.warnings.frozen.meta.offline": "Zmrazený, když byl naposledy spatřen",
"mint.warnings.offlineLong.lead": "Offline pro{days}, od{date}.",
"mint.warnings.offlineLong.body": "Mincovna může být pryč. Vše níže je jeho poslední známý stav, a recenze stále fungují.",
"mint.warnings.offlineLong.meta": "Offline od{date}",
"mint.warnings.meltOnly.lead": "Roztavit se jen tak.",
"mint.warnings.meltOnly.body.online": "Nová mincovna je diskvalifikována: nemůžete uložit, ale stávající ecash může být stále zapnutý. To často znamená, že mincovna se stahuje, takže pokud držíte jeho ecash, zvažte tavení ven.",
"mint.warnings.meltOnly.body.offline": "Nová mincovna byla zrušena: nemůžete uložit, ale stávající ecash může být stále zapnutý. To často znamená, že mincovna se stahuje, takže pokud máte jeho ecash, zvažte tání ven.",
"mint.warnings.meltOnly.meta.online": "V současné době se pouze taví",
"mint.warnings.meltOnly.meta.offline": "Rozpustit pouze, když naposledy viděn",
"mint.warnings.offline.lead": "Offline od{date}.",
"mint.warnings.offline.body": "Detaily byly naposledy potvrzeny. Stále můžete napsat recenzi.",
"mint.warnings.offline.meta": "Offline od{date}",
"mint.warnings.also.meltDisabled": "Před tím, než šel offline, byl také zakázán s odvodnění.",
"mint.warnings.also.mintDisabled": "Před tím, než šel offline to také vyřadila nové minting. color = &quot; # 8FFFF &quot;",
"mint.warnings.also.offline": "Je také offline od{date}({days}Izzie.",
"nut.0": "Poznamenávání a modely",
"nut.1": "mincovna výměna veřejných klíčů",
"nut.2": "Klíčové soubory a poplatky",
"nut.3": "Výměna žetonů",
"nut.4": "Vydávání přes Lightning",
"nut.5": "Roztavení pomocí blesku",
"nut.6": "informace o mincovně",
"nut.7": "Kontrola stavu Token",
"nut.8": "Přeplatky za blesky",
"nut.9": "Obnovit podpisy",
"nut.10": "Podmínky pro uložení",
"nut.11": "Platit na veřejný klíč",
"nut.12": "Důkazy DLEQ",
"nut.13": "Deterministická tajemství",
"nut.14": "Kontrakty na hashed timelock",
"nut.15": "Vícecestné platby",
"nut.16": "Animované kódy QR",
"nut.17": "Předplatné WebSocket",
"nut.18": "Žádosti o platbu",
"nut.19": "Chachované odpovědi",
"nut.20": "Podepsané mincovna kotace",
"nut.21": "Vyčistit autentizaci",
"nut.22": "Ověření slepého provedení",
"nut.23": "Platební metoda BOLT11",
"nut.24": "HTTP 402 nutná platba",
"nut.25": "Metoda platby BOLT12",
"reviews.title": "Přezkum",
"reviews.anon": "Anonym",
"reviews.anonTitle": "Žádný veřejný profil Nostr nenalezen pro tento klíč",
"reviews.nip05Title": "Identifikátor NIP- 05 není ověřen touto položkou )",
"reviews.filter.group": "Filtrační recenze",
"reviews.filter.all": "Všechny",
"reviews.filter.five": "5ņ@@",
"reviews.filter.critical": "Kritický",
"reviews.filter.recent": "Nedávné",
"reviews.filter.hideAnon": "Skrýt anon",
"reviews.anonHidden.other": "Zobrazuji{n}z{total}Přehled: anon hidden.",
"reviews.allAnon.one": "Jedinou recenzí je jeden.",
"reviews.allAnon.other": "Všechny{n}Recenze zde jsou anon.",
"reviews.allAnon.few": "Všechny{n}Recenze zde jsou anon.",
"reviews.allAnon.many": "Všechny{n}Recenze zde jsou anon.",
"reviews.allAnonShow.one": "Ukaž to.",
"reviews.allAnonShow.other": "Ukaž jim to.",
"reviews.allAnonShow.few": "Ukaž jim to.",
"reviews.allAnonShow.many": "Ukaž jim to.",
"reviews.loading.one": "Načítání{n}přezkum z relé Nostr",
"reviews.loading.other": "Načítání{n}recenze z Nostr relays",
"reviews.loading.few": "Načítání{n}recenze z Nostr relays",
"reviews.loading.many": "Načítání{n}recenze z Nostr relays",
"reviews.empty": "Ještě žádné recenze. Buďte první, kdo napsat jeden.",
"reviews.emptyWritten": "Už žádné písemné recenze. Buďte první, kdo napsat jeden.",
"reviews.emptyFilter": "Žádné recenze se neshodují s tímto filmem.",
"reviews.rate.cashu": "Ohodnoťte tento mint",
"reviews.rate.fedimint": "Ohodnoťte tuto federaci",
"reviews.rate.lnurl": "Ohodnoťte tento mint",
"reviews.noscript": "Recenze jsou podepsány na Nostr a načíst do browseru, takže tento seznam potřebuje JavaScript. Hodnocení a počty výše pocházejí z webu sám.",
"reviews.relayError": "Nelze dosáhnout relé. Recenze mohou být nedokončené.",
"reviews.srRating": "{rating}z 5",
"reviews.srNoRating": "Bez ratingu",
"reviews.readMore": "Přečtěte si více",
"reviews.readLess": "Zobrazit méně",
"reviews.actions.copyNpub": "Kopírovat npub",
"reviews.actions.openNostr": "Zobrazit tuto událost na Nostr",
"reviews.actions.copyLink": "Kopírovat odkaz na tuto recenzi",
"reviews.anonNote": "První přezkum z tohoto npub, žádná jiná činnost nenalezena",
"reviews.published": "Publikováno.",
"reviews.publishedPartial": "Zveřejněno{accepted}z{total}relé.",
"reviews.propagating": "Propagatování může chvíli trvat, než se objeví jinde.",
"reviews.summary.line.one": "{count}hodnocení bez komentáře:{breakdown}",
"reviews.summary.line.other": "{count}ratingy bez komentáře:{breakdown}",
"reviews.summary.line.few": "{count}ratingy bez komentáře:{breakdown}",
"reviews.summary.line.many": "{count}ratingy bez komentáře:{breakdown}",
"reviews.summary.lineDay.one": "{count}hodnocení bez komentáře k{when}:{breakdown}",
"reviews.summary.lineDay.other": "{count}hodnocení bez komentáře k{when}:{breakdown}",
"reviews.summary.lineDay.few": "{count}hodnocení bez komentáře k{when}:{breakdown}",
"reviews.summary.lineDay.many": "{count}hodnocení bez komentáře k{when}:{breakdown}",
"reviews.summary.showOne": "Ukaž to.",
"reviews.summary.showMany": "Ukaž jim to.",
"reviews.breakdown.5": "{n}5 hvězdiček",
"reviews.breakdown.4": "{n}4 hvězdy",
"reviews.breakdown.3": "{n}Tři hvězdy.",
"reviews.breakdown.2": "{n}Dvě hvězdy.",
"reviews.breakdown.1": "{n}jedna hvězda",
"reviews.breakdown.none": "{n}bez ratingu",
"reviews.dialog.title": "Přezkum{name}",
"reviews.dialog.lede.cashu": "Podělte se o svou zkušenost s tímto mintem.",
"reviews.dialog.lede.fedimint": "Podělte se o svou zkušenost s touto federací.",
"reviews.dialog.lede.lnurl": "Podělte se o svou zkušenost s tímto mintem.",
"reviews.dialog.permanent": "Zveřejněno natrvalo na veřejných Nostr relayích.",
"reviews.dialog.gate.title": "Přihlaste se, abyste mohli napsat recenzi",
"reviews.dialog.gate.lead": "Recenze se podepisují vaším klíčem Nostr. Zůstává ve vašem signeru, tato stránka ho nikdy nevidí.",
"reviews.dialog.ratingLegend": "Vaše hodnocení",
"reviews.dialog.starsSr.one": "{n}hvězda",
"reviews.dialog.starsSr.other": "{n}hvězdy",
"reviews.dialog.starsSr.few": "{n}hvězdy",
"reviews.dialog.starsSr.many": "{n}hvězdy",
"reviews.dialog.chooseRating": "Vyberte si rating",
"reviews.dialog.starLabel": "{n}:{phrase}",
"reviews.dialog.stars.1": "Vyhněte se tomu.",
"reviews.dialog.stars.2": "měl problémy",
"reviews.dialog.stars.3": "pracoval, s varováním",
"reviews.dialog.stars.4": "Dobře.",
"reviews.dialog.stars.5": "funguje skvěle.",
"reviews.dialog.bodyLabel": "Co by měli ostatní vědět?",
"reviews.dialog.charCount": "{n}/{max}",
"reviews.dialog.submit": "Podepsat a zveřejnit",
"reviews.dialog.signing": "Signalizační zařízení",
"reviews.dialog.publishing": "Vydavatelství",
"reviews.dialog.publishingTo": "Publikování relé ({accepted}/{total})",
"reviews.dialog.waitingExtension": "Čekám na prodloužení.",
"reviews.dialog.waitingSigner": "Čekám na váš podpis.",
"reviews.dialog.confirmClose": "Váš návrh zůstane, dokud neopustíte místo.",
"reviews.dialog.why.login": "Přihlásit se k publikování",
"reviews.dialog.why.words": "Napiš pár slov.",
"reviews.dialog.why.more.one": "{n}více charakteru",
"reviews.dialog.why.more.other": "{n}více znaků",
"reviews.dialog.why.more.few": "{n}více znaků",
"reviews.dialog.why.more.many": "{n}více znaků",
"reviews.dialog.why.over.one": "{n}charakter nad limit",
"reviews.dialog.why.over.other": "{n}znaky nad limitem",
"reviews.dialog.why.over.few": "{n}znaky nad limitem",
"reviews.dialog.why.over.many": "{n}znaky nad limitem",
"reviews.dialog.identity.needSigner": "Musíte Nostr signer publish. Váš klíč zůstane v ní, tato stránka nikdy nevidí.",
"reviews.dialog.identity.loginToPublish": "Přihlásit se k publikování",
"reviews.dialog.identity.switch": "Přepínač",
"reviews.dialog.identity.extension": "Podepisování s rozšířením prohlížeče",
"reviews.dialog.identity.readingKey": "Čtení vašich veřejných kláves",
"reviews.dialog.identity.willAsk": "Vaše prodloužení bude požádáno o podpis",
"reviews.dialog.error.noRelay": "Žádný relé nepřijal recenzi. Zkontrolujte spojení a zkuste to znovu.",
"reviews.validate.rating": "Vyberte si hodnocení od 1 do 5 hvězdiček.",
"reviews.validate.short": "Napište alespoň:{n}znaky, takže přezkum je užitečný.",
"reviews.validate.long": "Recenze jsou omezeny na{n}charakteristické znaky.",
"feed.title": "Nejnovější recenze mincovny a federace · Cashumints.space",
"feed.description": "Každá recenze mintu, federace a mintu LNURL hned po zveřejnění: {reviews} recenzí na {mints} mintů Cashu, {federations} federací Fedimint a {lnurl} mintů LNURL, podepsaných na Nostr a čitelných zde od nejnovějších. Filtrujte podle ekosystému, hodnocení nebo názvu.",
"feed.heading": "Poslední recenze",
"feed.lede": "Každá recenze každé Cashu mincovny a Fedimint federace, nejnovější první, číst živě z Nostr relays. Každý z nich je podepsán jeho autor a žije na relays, ne v naší databázi.",
"feed.label": "Přezkum",
"feed.showingBuild.one": "Zobrazuji poslední recenzi z poslední budovy",
"feed.showingBuild.other": "Ukazuji{n}poslední recenze z poslední stavět",
"feed.showingBuild.few": "Ukazuji{n}poslední recenze z poslední stavět",
"feed.showingBuild.many": "Ukazuji{n}poslední recenze z poslední stavět",
"feed.noscript": "Recenze jsou podepsány na Nostr a načíst do browseru, takže filtrování a celý seznam potřebují JavaScript.{n}recenze níže byly čteny z relé, když tato stránka byla postavena.",
"feed.emptyBuild": "Při sestavování této stránky se z relé v prohlížeči nedaly číst žádné recenze.",
"feed.foot": "Recenze jsou opce Společenství, neověřené žadatelů. Přečtěte si{link}Než budete věřit mincovně s penězi.",
"feed.footLink": "Odvolatelnost",
"feed.showing": "Zobrazuji{shown}z{total}recenze",
"feed.showingMatching": "Zobrazuji{shown}z{matching}hodnocení ({total}celkem )",
"feed.emptyRelays": "Žádné recenze se vrátil z relay. otevřete jakékoli mincovna stránky číst a psát recenze tam.",
"feed.emptyFilter": "Žádné recenze se shodují s tímto film. Vymazat mincovna boxer, nebo zkuste All.",
"feed.relayError": "Nelze dosáhnout relays. Zobrazuji recenze z posledního budová.d",
"feed.relayEmpty": "Žádné recenze se vrátil z relays. zobrazuji recenze z poslední budovy.",
"feed.reading": "Čtení relaysoth",
"login.open": "Přihlásit se",
"login.title": "Přihlaste se s Nostrem.",
"login.note": "Váš klíč zůstane ve vašem podpisu. Tato stránka nemůže nikdy vidět.",
"login.extension.name": "Prodloužení prohlížeče",
"login.extension.desc": "Podpis s Albyem, nós2x nebo jiným rozšířením NIP-07",
"login.extension.none": "Žádné prodloužení nenalezeno",
"login.extension.getOne": "Tak si jednu vem.",
"login.remote.name": "Vzdálený signer",
"login.remote.desc": "Proskenujte kód se signer app, nebo vložte odkaz na bunkr",
"login.primal.name": "Primal",
"login.primal.desc": "Povolit přihlášení v aplikaci Primal v telefonu",
"login.paste.label": "Nebo vložte odkaz na připojení, který vám dal váš znalec.",
"login.copyLink": "Kopírovat spojovací odkaz",
"login.whatIsNostr": "Co je Nostrem?",
"login.connect.remote.title": "Připojte dálkový signer",
"login.connect.remote.lead": "Naskenujte to svým podpisovým appem, nebo zkopírujte odkaz a vložte ho tam.",
"login.connect.remote.hint": "Pracuje s jakýmikoli NIP-46 signer: nsec.app, Ambere, Nosrify, váš vlastní bunker.",
"login.connect.remote.open": "Otevřít v aplikaci signer",
"login.connect.primal.title": "Připojení s Primal",
"login.connect.primal.lead": "Otevřít Primal na telefonu, najít dálkové podepisování v jeho usazeninách, a skenovat tento kód.",
"login.connect.primal.hint": "Primární značky pro jiné aplikace přes NIP-46, takže je to stejné spojení, jaké používá signer, a váš klíč zůstane v Primalu.{link}",
"login.connect.primal.open": "Otevřít v Primal",
"login.status.buildingLink": "Vybudování propojovacího spojení",
"login.status.waitingExtension": "Čekám na prodloužení.",
"login.status.waitingSigner": "Čekám na váš podpis.",
"login.status.connecting": "Připojení k vašemu signeru",
"login.status.connected": "Připojeno",
"login.toast.loggedIn": "Přihlášeno",
"login.toast.fetchingProfile": "Načítání vašeho profilu Nostr z relé…",
"login.toast.profile": "Profil: {name}",
"login.toast.noProfile": "Na relé nebyl nalezen žádný profil",
"login.method.extension": "Rozšíření",
"login.method.remote": "Vzdálené",
"login.method.primal": "Primal",
"login.account": "Váš účet Nostr",
"login.copyNpub": "Kopírovat celý npub",
"login.logout": "Odhlásit se",
"signer.status.waitingExtension": "Čekám na prodloužení.",
"signer.status.signed": "Podepsáno",
"signer.status.reconnecting": "Reunnecting to your signer",
"signer.status.approveWindow": "Váš znalec potřebuje, abyste to schválil ve svém vlastním okně.",
"signer.status.waitingSigner": "Čekám na váš podpis.",
"signer.status.stillWaiting": "Pořád čekám, až mi to podepíše.",
"signer.status.connected": "Připojeno",
"signer.error.noExtension": "Žádné rozšíření podpisu nenalezeno. Nainstalujte Alby nebo nos2x2x. a pak znovu načíst tuto stránku.",
"signer.error.privateKey": "To vypadá jako soukromý klíč, ne spojení link. Tato stránka nikdy bere klávesu. Vložte bunkersburg / odkaz, který vám signer dal.",
"signer.error.badLink": "To není odkaz na připojení této stránky rozumí. Vložte bunkersburg / odkaz z vašeho znaku.",
"signer.error.noAnswer": "Váš znalec neodpověděl, může být uzavřen, nebo může být jeho relé dole. Otevřete to a zkuste to znovu.",
"signer.error.timeout": "Váš znalec neodpověděl včas. Otevřete to, zkontrolujte, zda je to stále propojené, pak zkuste znovu.",
"signer.error.cancelled": "Zrušení spojení.",
"signer.error.declined": "Žádost o podpis byla zamítnuta.",
"notfound.title": "Stránka nenalezena · Cashumints.space",
"notfound.description": "Tato stránka neexistuje na Cashumints.space.",
"notfound.heading": "Tato stránka neexistuje",
"notfound.lede": "Může se pohybovat, nebo mincovna nemusí být indexován yet. hledat mincovna jménem nebo domain, nebo vložit mincovna URL.",
"notfound.browse": "Procházet všechny mincovny",
"notfound.home": "Jdi domů.",
"notfound.loading": "Načítání{host}",
"notfound.fb.rating": "Hodnocení",
"notfound.fb.reviews": "Přezkum",
"notfound.fb.software": "Software",
"notfound.fb.lastOnline": "Naposledy online",
"notfound.fb.note": "Tenhle mint byl zaindexován až po posledním sestavení webu, takže tohle je souhrn z živého API a pod ním jsou recenze. Celá stránka se objeví při dalším sestavení.",
"notfound.resolve.checking": "První návštěva: právě tenhle mint kontroluji.",
"notfound.resolve.failed": "Tenhle mint se nepodařilo přidat",
"notfound.resolve.failedFedimint": "Tuhle federaci se nepodařilo přidat",
"notfound.resolve.retry": "Zkusit jinou adresu",
"notfound.resolve.switch": "Otevřít jako mint {kind}",
"notfound.resolve.fedimint": "Adresa stránky federace neobsahuje její invite code, takže tuhle nelze najít jen podle odkazu. Vlož invite code a federace se přidá.",
"notfound.resolve.ambiguous": "Tuhle adresu nejde z odkazu odvodit. Vlož celou URL mintu a mint se přidá.",
"wallets.title": "Cashu peněženky · Cashumints.space",
"wallets.description": "Cashu peněženky, které pracují s mincovny uvedeny zde, přes mobilní, webový, prohlížeč a příkazové linky, s tím, co každý z nich je dobrý.",
"wallets.heading": "Peněženky",
"wallets.lede": "Většina peněženek mluví s několika mincovny najednou, takže nejste vázáni na první pokus.",
"wallets.note": "Tento seznam není vyčerpávající a není podporovatelem. Projekt Cashu udržuje širší index{link}. Peněženka drží keys, mincovna drží peníze: přečtěte si recenzi mincovna před tím, než si vybrat, kde vaše sats žije.",
"wallets.platform.android": "Android",
"wallets.platform.ios": "iOS",
"wallets.platform.web": "Web",
"wallets.platform.extension": "Prodloužení prohlížeče",
"wallets.platform.cli": "CLI",
"wallets.platform.python": "Python",
"wallets.item.minibits": "Mobilní ekashová peněženka s vlastní mincovna, podpora adresy Lightning a kontakty Nostr.",
"wallets.item.nutstash": "Prohlížecí peněženka, která zpracovává více mincovny najednou a importuje a exportuje syrové tokens.",
"wallets.item.enuts": "Mobilní peněženka se zaměřuje na multimintové bilance a odesílání ekash přes Nostr.Name",
"wallets.item.cashume": "Reference webová peněženka z projektu Cashu. Je plně v prohlížeči, instalovatelná jako PWA.",
"wallets.item.boardwalk": "Webové peněženky postavené kolem Blesk adresy a posílání ecash link.",
"wallets.item.coinos": "Blesk peněženka a místo prodeje, který také běží své vlastní Cashu mincovna.",
"wallets.item.oxchat": "Nostr posel s ekašovou peněženkou postavenou v, pro odesílání saturace uvnitř chats.",
"wallets.item.nutshell": "Reference funguje jako peněženka na příkazové řádce a jako mincovna software. name",
"about.title": "O Cashumints.space",
"about.description": "Co Cashu je, co Mincovna vlastně drží, co NIP-87 je, a jak tato stránka indexuje mincovny a čte recenze z Nostr relays. color",
"about.heading": "O",
"about.lede": "Cashumints.space uvádí všechny Cashu mincovna, které můžeme najít v síti Nostr a ukazuje vám, co o nich lidé říkají. Je vyroben Azzamo.",
"about.cashu.title": "Co je Cashu?",
"about.cashu.p1": "Cashu je ekash protokol pro Bitcoin. Mincovna problémy zaslepené tokeny podporované sats to holds. a tyto tokeny se pohybují mezi lidmi bez mincovna učení, který poslal, co na koho. Soukromí je skutečné a je to bod: tokeny nejsou účty, a mincovna nemůže spojit token podepsaný na token, který se vrací.",
"about.cashu.p2": "Protokol a jeho specifikace si můžete přečíst na adrese{link}. Specifikace se samy nazývají NUTs, což je místo, kde čísla NUT na každé mincovna stránce přijít from.",
"about.mints.title": "To je ale mincovna a ten obchod.",
"about.mints.p1": "Mincovna je opatrovnictví. Když Mincovna ecash jste podání sats tomu, kdo běží, že server, a vy věříte jim, aby ctít žetony, když je roztavíte zpět. Neexistuje žádný podvod korektury, žádné smlouvy a žádné opakování. Mincovna operátor může zmizet s balancí, a mincovny mají.",
"about.mints.p2": "To je přesně důvod, proč recenze existují zde. Tato stránka vám nikdy neřekne, mincovna je bezpečný. Stav na stránce mincovna říká{code}, ne důvěryhodné, a to jen znamená, že server odpověděl, když jsme se ptali naposledy. Zacházet s ekash zůstatky jako hotovost v kapse kabátu: pokuta za utrácení, špatné pro savings.",
"about.nip87.title": "Co je NIP-87?",
"about.nip87.p1": "NIP @-@ 87 je Nostr konvence pro oznámení a doporučení ecash mincovny. dva druhy událostí zde záleží:",
"about.nip87.li1": "{code}oznamuje mincovna. Mincovna URL je v{tag}Tag. Takhle se na místě objeví Mincovny, kterou nikdy neviděl.",
"about.nip87.li2": "{code}je recenzí. Nosí hodnocení a recenzi textury, a body na mincovna je asi s{utag}Značka mincovna URL ) a a{dtag}Značka Mincovnadols vlastní veřejné klíče.",
"about.nip87.p2": "Každý přezkum je podepsán svým autorizačním klíčem Nostr. Nikdy jsme volat recenzi ověřené, protože nikdo ověřil: to je signed, což je jiný a užitečnější žadatel. To znamená, že můžete zkontrolovat, kdo ji napsal a že to nebylo změněno. To neznamená, že autor je upřímný nebo někdy použil mincovna.",
"about.how.title": "Jak tato stránka funguje",
"about.how.p1": "Existují dvě smyčky a jedna pravidla. Pravidlem je, že mincovna vlastní metadata a Nostr vlastní recenzi, takže tato stránka ukládá ani jako autorita.",
"about.how.li1": "{strong}žádá všechny známé mincovna pro{code}každých deset minut a zaznamenává, co se říká: jméno, popis, verze, podporované NUTSs, limits, kontaktní údaje. Také zaznamenává, zda Mincovna odpověděl na všechny, což je místo, kde je čas přijde.",
"about.how.li1Label": "Sonda smyčka",
"about.how.li2": "{strong}se připojí k Nostr relays každou hodinu, čte oznámení a recenze mincovny, a přidává jakékoli mincovna, které dosud neviděl.",
"about.how.li2Label": "Objevovací smyčka",
"about.how.p2": "Důležitou částí je to, co se stane, když mincovna přestane odpovídat. Jeho cached metadata není nikdy přepsán neúspěšnou probee, takže mrtvý mincovna udržuje svou plnou podívanou: jeho spektrum, jeho historie a jeho recenzí, s banner říká, když šel tmavý. Předchozí verze této stránky ukázal chybové stránky insteadd, což znamenalo, že mincovny lidé nejvíce potřebují, aby se navzájem varoval o byly ty, které nemohly být přezkoumány. To byl hlavní důvod pro přestavení.",
"about.how.p3": "Recenze text není nikdy uložen na naší straně. Když otevřete stránku mincovna váš prohlížeč mluví s relé přímo a čte recenze sám o sobě, a když píšete jeden je podepsán vaše vlastní Nostr rozšíření a zaslán přímo do těchto relé. Držíme počet, takže stránky mohou být seřazeny a tříděny, nic víc. Nic zde nelze editovat nebo smazat námi, včetně recenzí bychom raději neměli.",
"about.relays.title": "Relé, které čteme",
"about.relays.p1": "Toto jsou relé indexeru i vašeho prohlížeče, které se používají:",
"about.relays.p2": "Ukazujte každému klientovi Nostr na stejné relé a uvidíte stejné události. To je celý slib: nic zde není pravda, protože říkáme, že ano.",
"about.nuts.title": "Co jsou NUTs?",
"about.nuts.p1": "NUT jsou číslovány Cashu specifikace. jako BIP pro Bitcoin. Mincovna inzeruje, které z nich implements. a to vám řekne, co to vlastně může dělat: NUT-04 a NUT-05 jsou Minting a tání nad Lightning bez nich mincovna není moc užitečné, NUT-07 nechá peněženka zkontrolovat, zda token byl již rozhazuje, NUT-17 dává peněženky živé aktualizace přes WebSockets místo pollingové, a NUT-20 umožňuje peněženka prokázat mincovna citace byl skutečně vydán na. Plný seznam je na{link}.",
"about.numbers.title": "Právě teď.",
"about.numbers.p1": "Indexujeme {mints} mincoven, z nichž {online} odpovídá, a z výše uvedených relé jsme započítali {reviews} recenzí.",
"about.source.title": "Zdroj a úvěry",
"about.source.p1": "Stránka je open source{github}. Je postavena a řízena{azzamo}. Pokud je na stránce mincovna něco špatně, opravte to na source: mincovna metadata pocházejí z Mincovnadolls vlastní{code}a recenze přicházejí z Nostrin.",
"terms.title": "Podmínky použití · Cashumints.space",
"terms.description": "Podmínky použití pro Cashumints.space: co je to za místo, co nedělá a kdo je zodpovědný za cokoli. žádné účty, žádné záruky, žádné spojení s žádnou mincovna.",
"terms.heading": "Podmínky použití",
"terms.lede": "Prosté termíny pro stránku, která pouze indexuje veřejné dat. Poslední aktualizované{date}.",
"terms.what.title": "Co je to za stránku?",
"terms.what.p1": "Cashumints.space je index veřejně dostupných dat o Cashu mincovny. dva druhy dat, a to jak odněkud jinde:",
"terms.what.li1": "mincovna metadata, číst z každého mincovnadois vlastní{code}Konečný bod: jméno, popis, softwarová verze, podporované NUTs, kontaktní údaje a zda Mincovna odpověděla, když jsme se naposledy ptali.",
"terms.what.li2": "Recenze, čtení z veřejných Nostr relays. Jedná se o NIP-87 události publikované jejich autoři relé kdokoliv může query.",
"terms.what.p2": "Shromáždíme data, znormalizujeme je, seřadíme a ukážeme.",
"terms.noAccounts.title": "Žádné účty, žádné záruky.",
"terms.noAccounts.p1": "Stránka není k zapsání. Stránka je poskytována, protože není záruka jakéhokoliv druhu: ne, že údaje jsou přesné, ne, že je aktuální, ne, že by místo bude k dispozici. Mincovna data mohou být minuty nebo hodiny staré, mincovna může změnit po poslední kontrole, a relé nemůže odpovědět, v takovém případě jste vidět méně než existují.",
"terms.noAccounts.p2": "Používejte jej na vlastní riziko. Nejsme odpovědní za žádnou ztrátu, která vyplývá z použití této stránky nebo spoléhá na cokoliv, co jste zde četli. Viz{link}pro zvláštní rizika použití Cashu mincovna. kgm",
"terms.noAccounts.link": "Odvolatelnost",
"terms.noCustody.title": "Nedržíme vaše peníze.",
"terms.noCustody.p1": "Tato stránka nikdy vezme opatrovnictví fondů. Nemá žádné peněženky, to se nedotkne ecash, a to nemůže přesunout, zmrazit nebo obnovit nic. Pokud máte problém s mincovna, operátor mincovna je jediná strana, která vám může pomoci, a nemůžeme se s nimi spojit na vaší behalfth.",
"terms.noCustody.p2": "Nejsme přidruženi k žádné mincovna zde uvedené, a žádná mincovna neplatí za jeho listing, jeho hodnost nebo jeho rating. mincovny jsou objeveny z veřejných Nostr oznámení a zařadil algoritmus nad veřejné hodnocení data.",
"terms.reviews.title": "Recenze jsou jiná lidová slova",
"terms.reviews.p1": "Recenze jsou uživatelsky @-@ generovaný obsah zveřejňovaný veřejnosti Nostr relé jejich autoritami. Zobrazujeme je, nekontrolujeme je. Nenapsali jsme je, nemůžeme je upravovat, a nemůžeme je smazat z relé, které je drží: jsou podepsané události na síti, kterou nemáme vlastní.",
"terms.reviews.p2": "Recenze je jedna osoba zkušenosti, ne ověřený fakt. Pokud publikujete jeden, jste zodpovědní za to, co říká. Nezveřejňovat nic nezákonného, a být si vědom toho, že to, co publikujete, je veřejné a trvalé vidět{link}Izzie.",
"terms.reviews.link": "stránka ochrany soukromí",
"terms.reviews.p3": "Můžeme přestat zobrazovat konkrétní recenzi na tomto místě, a budeme to dělat pro obsah, který je nezákonný. To odstraňuje z těchto stránek pouze. Událost zůstává na relé a každý jiný Nostr klient může stále číst.",
"terms.links.title": "Odkazy na jiné stránky",
"terms.links.p1": "mincovna URLs, peněženka links, operátorské kontakty a všechno ostatní, co ukazuje na těchto stránkách jsou tam, protože jsou součástí dat. Odkaz není doložka a my neověřujeme, co je na druhém konci.",
"terms.changes.title": "Změny",
"terms.changes.p1": "Tyto termíny se mohou změnit. Datum na vrcholu říká, kdy naposledy. Stránka je otevřená zdroj, takže každá změna této stránky je v historii odevzdání.",
"terms.contact.title": "Kontakt",
"terms.contact.p1": "Otázky a žádosti o převzetí jsou nejlépe vyplněny na{github}. Stránka je řízena{azzamo}.",
"privacy.title": "Soukromí · Cashumints.space",
"privacy.description": "Soukromí na Cashumints.space. žádné účty, žádné sledovací kuchyně, žádné analýzy. localStorage se používá pro ukládání profilů a nic není odesláno nikam. Recenze jsou podepsány ve vašem vlastním prohlížeči.",
"privacy.heading": "Ochrana soukromí",
"privacy.lede": "Sbíráme tak málo jako webové stránky can. poslední aktualizován{date}.",
"privacy.noAccounts.title": "Žádné účty",
"privacy.noAccounts.p1": "Není tam žádná známka nahoru, žádné přihlášení a žádný profil. Nic na této stránce se vás ptá, kdo jste, a neexistuje žádný záznam uživatele, který by unikl, protože nejsou žádní uživatelé.",
"privacy.noTracking.title": "Žádné sledovací kuchyně, žádná analýza.",
"privacy.noTracking.p1": "Stránky nenastavují žádné soubory cookie pro sledování. Neexistuje žádný analytický skript, žádný správce tagů, žádný pixel. žádný záznam relace a žádné reklamní sítě. Nic na stránce hlásí své chování nám nebo komukoliv jinému.",
"privacy.logs.title": "Záznamy serverů",
"privacy.logs.p1": "Web server udržuje standardní přístupové logs, což je to, co každý webový server dělá default. Log line zaznamenává vaše IP adresu. čas, stránku, kterou jste požádali o, stav odpovědi, a uživatelský agent a referenr váš prohlížeč sent. Záznamy existují pro ladění chyb a skvrn abus. nejsou spojeny s ničím jiným a nejsou prodávány nebo shared.",
"privacy.logs.operatorLabel": "Poznámka k obsluze:",
"privacy.logs.operator": "Pokud spustíte tuto stránku, uveďte zde svou skutečnou retenci. Zavedení v README používá nginx s jeho výchozí přístupový záznam a bez ohledu na rotaci distribučních lodí Debian a Ubuntu, které je logrotateDaily, uchování 14 dní. Potvrďte, co váš server dělá a nahraďte tento odstavec číslem.",
"privacy.storage.title": "Co je uloženo ve vašem prohlížeči",
"privacy.storage.p1": "Stránka používá localStorage přesně pro dvě věci, které zůstávají na vašem zařízení a nikdy nejsou odeslány nikam:",
"privacy.storage.li1": "Kache profilu Nostr metadata typu 0 events: names, avatars, NIP-05 addressesses ) pro recenzenty, jejichž recenze jste hledali. To ušetří re @-@ kladení relé na každé straně. Záznamy vymazává, a cache je capped a emicted nejstarší první.",
"privacy.storage.li2": "Rozhraní preferuje, takže místo se bude chovat stejně příště.",
"privacy.storage.p2": "Čištění vašich browsergets data stránky odstraňuje oba. Nic přestávky, profil vyhledávání prostě spustit znovu.",
"privacy.thirdParties.title": "S čím váš prohlížeč mluví",
"privacy.thirdParties.p1": "Nahrávání stránky spojuje váš prohlížeč na několik míst kromě tohoto místa. To je, jak stránky bez serverside sledování stále čte živé data:",
"privacy.thirdParties.li1Label": "Veřejná přenosová zařízení Nostr",
"privacy.thirdParties.li1": "{strong}, přes WebSocket, číst recenze a recenzenty profily. Relé vidí váš IP a to, co jste žádali, jako každý server, který připojíte k dělá. Relays jsou uvedeny na seznamu{link}.",
"privacy.thirdParties.li1Link": "o stránce",
"privacy.thirdParties.li2Label": "Revizor avatar obrázky",
"privacy.thirdParties.li2": "{strong}, načteno odkudkoliv, kde je profil reviewers body. Tyto jsou zaslány s ne- @-@ referrer politika, takže hostitel se nedozví, na které stránce jste byli na.",
"privacy.publishing.title": "Zveřejnění recenzí",
"privacy.publishing.p1": "Psaní recenzi se stane ve vašem browser. ne na našem serveru. Událost je podepsána vaše NIP- 07 rozšíření Albyou. nos2x nebo podoba. a vysílat z vašeho prohlížeče přímo do relé. Váš soukromý klíč nikdy opustí rozšíření a tato stránka nikdy nevidí. Nebudujeme vlastní správu klíčů a my nikdy požádat o klíč nebo semínka fráze.",
"privacy.permanent.title": "Všechno, co publikujete Nostrovi, je veřejné a trvalé.",
"privacy.permanent.p1": "Toto je část, která stojí za to číst twice. přezkum je podepsaná událost na veřejných relé. Jakmile je to venku, je to out:",
"privacy.permanent.li1": "Každý si to může přečíst, nyní i později, pomocí jakéhokoli klienta Nostr. Není to veřejné na Cashumints.spacethern, je to veřejné na síti.",
"privacy.permanent.li2": "Je trvale spojen s vaším veřejným klíčem Nostr, spolu se vším ostatním, co kdy ten klíč publikoval.",
"privacy.permanent.li3": "Vymazání je požadavek, ne velitel. Můžete publikovat smazání událost a některé relé bude ctít, ale relé je zdarma ignorovat a každý, kdo již kopíroval událost má. Zacházejte s publikovanou recenzi jako permanent.@@",
"privacy.permanent.li4": "Nemůžeme vám to smazat. Můžeme to přestat zobrazovat zde, to nic nemění na relé.",
"privacy.permanent.p2": "Pokud chcete přezkoumat mincovna, aniž by to bylo vázáno na vaši hlavní identity, použijte samostatný klíč Nostr. Všimněte si, že klíč bez jiné činnosti je zobrazen jako takový na tvrdých recenzích, protože zbrusu nový klíč psaní jedné hvězdy recenze je vzor čtenáři si zaslouží vidět.",
"privacy.contact.title": "Otázky",
"privacy.contact.p1": "Stránka je otevřená source: pokud chcete zkontrolovat některý z above.read kód na{github}Otázky a opravy mohou jít do problému tam.",
"disclaimer.title": "Discovery · Cashumints.space",
"disclaimer.description": "Co vědět, než budete používat Cashu mincovna: mincovny jsou custdialdiama, mincovna může zmizet s penězi, recenze jsou názory a on @-@ line znamená reachable. ne důvěřivě.",
"disclaimer.heading": "Zveřejnění",
"disclaimer.lede": "Přečtěte si to, než jste dát peníze v mincovna. naposledy aktualizováno{date}.",
"disclaimer.callout": "Cashu Mincovna drží vaše peníze pro vás. Pokud se rozpustí, vaše ecash je pryč, a není nikdo, kdo by apelovat na. Nechte si jen to, co si můžete dovolit ztratit.",
"disclaimer.custodial.title": "Mincovny jsou opatrovníci.",
"disclaimer.custodial.p1": "Když mincovna ecash, dáte Bitcoin operátorovi mincovna a získat žetony zpět. Tyto žetony jsou nárok na tento operator. Nejsou Bitcoin jste držet, a operátor je ten, kdo rozhodne, zda je poctít.",
"disclaimer.custodial.p2": "To znamená, že každá mincovna nese stejné riziko: operátor může mizet, může být nucen zastavit, může přijít o finanční prostředky přes chybu nebo hacknutí, nebo může jednoduše vypnout server. Neexistuje žádné pojištění vkladů, žádné zálohování, žádná podpůrná linka a žádné právní využití stojí za to počítat na. To je pravda z nejvyšší hodnocené mincovna na tomto místě.",
"disclaimer.custodial.p3": "Cashu je určen pro malé množství pohybující se rychle. Použijte to tak, že způsob: držet to, co se chystáte utrácet, ne vaše úspory, a šíří se po mincovny spíše než parkování všechno v jednom.",
"disclaimer.reviews.title": "Recenze jsou opinions, neověřená tvrzení",
"disclaimer.reviews.p1": "Každá recenze zde byl napsán někým na Nostr a podepsal s jejich klávese. Podepsán znamená, že vám můžeme říct, který klíč napsal. To neznamená, že přezkum je pravda, že autor použil mincovna, nebo že autor není operator, konkurent, nebo jedna osoba s deseti keysna.",
"disclaimer.reviews.p2": "Neověřujeme recenzí, a needitujeme ani neodstraňujeme recenze, se kterými nesouhlasíme. Přečtěte si pár, podívejte se na to, co ve skutečnosti říkají, a zvážit mincovna se třemi recenzemi odlišně od jednoho se třemi stovkami.",
"disclaimer.status.title": "Online znamená reakhable. ne důvěryhodný",
"disclaimer.status.p1": "Pravidelně kontrolujeme každou mincovnu a ukazujeme, zda odpovídá. To vše znamená zelený status: server odpovídá. To neříká nic o tom, zda operátor je solventní, čestný, nebo stále má v úmyslu uctít vaše žetony zítra.",
"disclaimer.status.p2": "Mincovna může být online až do okamžiku, kdy není. Uptime historie, podporované NUTs a softwarové verze vám něco říct o tom, jak mincovna je run, ale žádný z nich není slib o vašich peněz.",
"disclaimer.ranking.title": "Jak to pořadí funguje a co to neznamená",
"disclaimer.ranking.p1": "Mincovny jsou objednány algoritmem přes veřejné hodnocení data: ratinging, počet recenzí a nedávné aktivity, s on @-@ line mincovny před offline ty o default. Detaily jsou v kódu open source.",
"disclaimer.ranking.p2": "Nikdo neplatí za umístění. Neexistuje sponzorovaný slota, žádný podporovaný výpis a žádná dohoda s žádnou mincovna na tomto místě. Vysoká hodnost znamená, že algoritmus měl rád veřejná data, a nic víc. Není to doporučení a není to bezpečnost ratinging.",
"disclaimer.notAdvice.title": "To není finanční poradenství.",
"disclaimer.notAdvice.p1": "Nic na této stránce není finanční, investiční, právní nebo daňové poradenství. Nevíme, vaše situace a my vám neradíme. Použití mincovna, a výběr, který z nich, je vaše rozhodnutí.",
"disclaimer.dyor.title": "Proveďte si vlastní výzkum.",
"disclaimer.dyor.li1": "Mincovna vedená někým, kdo má veřejnou reputaci, je jiný návrh od anonymního URL.",
"disclaimer.dyor.li2": "Přečtěte si vlastní podmínky a jeho MOTD Mincovna, která se nazývá test nebo beta znamená.",
"disclaimer.dyor.li3": "Začněte trochu. Mincovna trochu, utraťte to, rozpusťte to, a uvidíte, co se stane, než budete věřit, že s více.",
"disclaimer.dyor.li4": "Sledujte omezení. Nízké maximum je často známkou pečlivého operatora a vysoké není známkou bezpečného.",
"disclaimer.dyor.li5": "A bojte se svých žetonů a vězte, že znamení jest to, co nese, neboť kdo drží je, ten může utratiti.",
"disclaimer.site.title": "O samotném místě",
"disclaimer.site.p1": "Data zde může být špatně nebo mimo datu. mincovny jsou zkoušeny na harmonogramu, relé někdy selhávají odpovědět, a stránka, kterou čtete, byla postavena v určitém bodě v minulosti. Stránka je poskytována, jak je, bez záruky. Viz{link}.",
"disclaimer.site.link": "Podmínky použití",
"disclaimer.updated": "20. srpna 2026",
"login.getPrimal": "Get Primal",
"reviews.dialog.identity.extensionGone": "Vaše rozšíření prohlížeče neodpovídá. Přihlaste se znovu, nebo použijte dálkový signál.",
"seo.orgDescription": "Adresář Cashu mincovnyy, s živým statusem z každé mincovny a komunitní recenze podepsané na Nostra.",
"seo.ogImageAlt": "Cashumints.space: najít Cashu mincovna můžete věřit.",
"seo.mint.title.one": "{name}Přehledy: jmenovité{rating}od{n}Recenze Cashumints.space",
"seo.mint.title.other": "{name}Přehledy: jmenovité{rating}od{n}recenze Cashumints.space",
"seo.mint.title.few": "{name}Přehledy: jmenovité{rating}od{n}recenze Cashumints.space",
"seo.mint.title.many": "{name}Přehledy: jmenovité{rating}od{n}recenze Cashumints.space",
"seo.mint.titleOffline.one": "{name}: offline pro{n}den Cashumints.space",
"seo.mint.titleOffline.other": "{name}: offline pro{n}Dny od Cashumints.space",
"seo.mint.titleOffline.few": "{name}: offline pro{n}Dny od Cashumints.space",
"seo.mint.titleOffline.many": "{name}: offline pro{n}Dny od Cashumints.space",
"seo.mint.titleNoReviews": "{name}: informace a recenze Cashu mintu | Cashumints.space",
"seo.fedimint.titleNoReviews": "{name}: informace a recenze federace Fedimint | Cashumints.space",
"seo.desc.fallback": "Hodnocení Společenství pro{name}({domain}Izzie.",
"seo.desc.fallbackDomain": "Recenze Společenství pro Cashu mincovna at{domain}.",
"seo.desc.rated.one": "Jmenovité{rating}5 od{n}Podepsal Nostr recenzi.",
"seo.desc.rated.other": "Jmenovité{rating}5 od{n}Podepsané recenze Nostra.",
"seo.desc.rated.few": "Jmenovité{rating}5 od{n}Podepsané recenze Nostra.",
"seo.desc.rated.many": "Jmenovité{rating}5 od{n}Podepsané recenze Nostra.",
"seo.desc.noReviews": "Ještě žádné recenze.",
"seo.desc.status": "{status}.",
"seo.desc.statusLimits": "{status}, omezení{limits}.",
"seo.desc.fedModules": "{status}. Moduly:{modules}.",
"seo.desc.fedModulesNetwork": "{status}. Moduly:{modules}zapnuto{network}.",
"seo.mint.ogAlt.one": "{name}:{rating}hvězdy od{n}přezkum Cashumints.space",
"seo.mint.ogAlt.other": "{name}:{rating}hvězdy od{n}recenze na Cashumints.space",
"seo.mint.ogAlt.few": "{name}:{rating}hvězdy od{n}recenze na Cashumints.space",
"seo.mint.ogAlt.many": "{name}:{rating}hvězdy od{n}recenze na Cashumints.space",
"seo.mint.ogAltNoReviews": "{name}na Cashumints.space",
"nav.home": "Domů",
"mint.more.title": "Více mincovny",
"mint.more.all": "Vidět všechny mincovny",
"nav.fedimints": "Fedimints",
"status.announced": "Pouze ohlášen",
"card.unnamedFederation": "Nepojmenovaná federace",
"card.notChecked": "nikdy nekontrolováno",
"fedimints.title": "Všechny Fedimint federace · Cashumints.space",
"fedimints.description": "Všechny{total}Fedimint federace oznámil na síti Nostr:{online}potvrzený běh vnější kontrolou,{announced}Oznámené a nikdy nepotvrzené, obecní daně, moduly a kódy pro každého.",
"fedimints.heading": "Všechny federace",
"fedimints.lede": "Každá Fedimint federace oznámila na síti Nostr. Federace nemá veřejný status koncový bod jeho vlastní, takže některé z nich jsou potvrzeny běh vnější kontroly a zbytek jsou uvedeny přesně tak, jak byly oznámeny.",
"fedimints.search.label": "Pátrací federace podle jména nebo totožnosti",
"fedimints.search.placeholder": "Hledání podle jména nebo federace idatis",
"fedimints.sort.label": "Třídit federace",
"fedimints.hideOffline": "Skrýt offline",
"fedimints.showing": "Zobrazuji{total}Federace ({online}potvrzen,{announced}Oznámeno pouze )",
"fedimints.showingFiltered": "Zobrazuji{shown}z{total}Federace ({online}potvrzen,{announced}Oznámeno pouze )",
"fedimints.announcedNote.one": "Jeden z nich byl oznámen na Nostr a nic nepotvrdilo, protože to běží. Je uveden jako ohlášen spíše než jako on @-@ line nebo offline, protože ani jeden by být pravda.",
"fedimints.announcedNote.other": "{n}z nich byly oznámeny na Nostr a nic nepotvrdilo, protože jsou na útěku. Jsou uvedeny jako ohlášené spíše než jako on @-@ line nebo offline, protože ani jeden by být pravda.",
"fedimints.announcedNote.few": "{n}z nich byly oznámeny na Nostr a nic nepotvrdilo, protože jsou na útěku. Jsou uvedeny jako ohlášené spíše než jako on @-@ line nebo offline, protože ani jeden by být pravda.",
"fedimints.announcedNote.many": "{n}z nich byly oznámeny na Nostr a nic nepotvrdilo, protože jsou na útěku. Jsou uvedeny jako ohlášené spíše než jako on @-@ line nebo offline, protože ani jeden by být pravda.",
"fedimints.noResults": "Žádné federace se neshodují. Zkuste jméno, nebo vložte federační ID.",
"fedimints.emptyIndex": "Žádné federace nebyly ohlášeny na relé těchto stránek čtení. seznam vyplní sám jako oznámení příchozí.",
"fedimint.crumb": "Fedimints",
"fedimint.breadcrumb": "Drobečková navigace",
"fedimint.unnamed": "Nepojmenovaná federace",
"fedimint.idLabel": "Federace",
"fedimint.kind.federationId": "Federation id",
"fedimint.moduleCount.one": "{n}modul",
"fedimint.moduleCount.other": "{n}moduly",
"fedimint.moduleCount.few": "{n}moduly",
"fedimint.moduleCount.many": "{n}moduly",
"fedimint.copyInvite": "Kopírovat kód pozvánky",
"fedimint.confirmed.label": "Potvrzeno",
"fedimint.confirmed.never": "Nikdy.",
"fedimint.confirmed.by": "podle{source}",
"fedimint.confirmed.unattributed": "poslední kontrola",
"fedimint.announcedOn": "ohlášen{when}",
"fedimint.announcedUnknown": "žádné datum oznámení",
"fedimint.modules.title": "Moduly",
"fedimint.modules.none": "Toto prohlášení federace neobsahuje žádné moduly.",
"fedimint.modules.note": "Moduly jsou části federační runy. V oznámení se píše, které jsou prezentovány, a nic o tom, zda některý z nich přijímá depozity.",
"fedimint.modules.notListed": "neuvedeno v seznamu",
"fedimint.invite.title": "Kód pozvánky",
"fedimint.invite.count.one": "{n}Kód",
"fedimint.invite.count.other": "{n}kódy",
"fedimint.invite.count.few": "{n}kódy",
"fedimint.invite.count.many": "{n}kódy",
"fedimint.invite.note": "Vložte to do Fedimint peněženky k připojení. To nese federační ID a jeho opatrovnictví adres, a to je určeno ke sdílení.",
"fedimint.invite.kind": "volací kód",
"fedimint.invite.qr": "QR",
"fedimint.invite.qrCaption": "Vyhledej, jak se připojit.{name}",
"fedimint.details.federationId": "Federace id",
"fedimint.details.network": "Síť",
"fedimint.details.announcedBy": "Oznámený",
"fedimint.details.announced": "Oznámeno",
"fedimint.more.title": "Další federace",
"fedimint.more.all": "Viz všechny federace",
"fedimint.disclaimer": "Spojení se s federací znamená věřit svým strážcům s vašimi penězi. Ratings jsou komunální názory, a potvrzený běh znamená reakbled, ne důvěryhodnost.",
"fedimint.module.lightning": "Blesk",
"fedimint.module.mint": "Ecash mincovna",
"fedimint.module.wallet": "Peněženka na řetěz",
"fedimint.module.meta": "Metadata",
"fedimint.module.stability_pool": "Stabilizační fond",
"fedimint.module.multi_sig_stability_pool": "Stabilizační pool multimedium sign )",
"fedimint.module.fedi-social": "Sociální zotavení",
"fedimint.network.mainnet": "Bitcoin mainnet",
"fedimint.network.signet": "Signet",
"fedimint.network.testnet": "Testnet",
"fedimint.network.testnet4": "Testnet4",
"fedimint.network.regtest": "Regtest",
"feed.ecosystem.group": "Ekosystém",
"feed.ecosystem.all": "Všechny",
"feed.ecosystem.cashu": "Cashu.",
"feed.ecosystem.fedimint": "Fedimint",
"feed.filterBySubject": "Filtr pomocí mincovny nebo federace",
"feed.anySubject": "Jakákoliv mincovna nebo federace",
"feed.clearSubject": "Vymazat jmenný filtr",
"home.topFedimints.title": "Top Fedilts",
"home.topFedimints.sub": "hodnocené podle hodnocení a hodnocení",
"home.topFedimints.all": "Všechny{n}Federace →",
"reviews.validate.subject": "Není na co poukázat.",
"mint.warnings.fedimintOffline.lead": "Hlášená mimo provoz.",
"mint.warnings.fedimintOffline.body.since": "Poslední kontrola, která dosáhla této federace byla na{date}({days}Od té doby její strážci neodpověděli. Zacházejte s finančními prostředky drženými v riziku, a nepřidejte se k nim s novými fondy.",
"mint.warnings.fedimintOffline.body.never": "Žádná kontrola této federace nikdy nedosáhla, a poslední selhal. Zacházet s finančními prostředky drženými tam jako v riziku, a nepřipojujte se k ní s novými fondy.",
"mint.warnings.fedimintOffline.meta.since": "Hlášené offline od{date}",
"mint.warnings.fedimintOffline.meta.never": "Oflinea hlášená, ani jednou nedosažena",
"mint.warnings.neverConfirmed.lead": "Oznámené, nikdy nepotvrzené.",
"mint.warnings.neverConfirmed.body.reviewed": "Tato federace byla oznámena na Nostr na{date},{days}A od té doby nic nepotvrdilo, že to běží. Recenze níže jsou jedinou známkou života na této soutěži, vše ostatní pochází z oznámení.",
"mint.warnings.neverConfirmed.body.quiet": "Tato federace byla oznámena na Nostr na{date},{days}Od té doby nic nepotvrdilo, že to běží, a nikdo to znovu neprověřil. Všechno níže pochází z oznámení.",
"mint.warnings.neverConfirmed.meta": "Oznámeno{date}, nikdy nepotvrzen",
"footer.allFedimints": "Všechny federace",
"reviews.dialog.bodyPlaceholder.cashu": "Jak se Mintingu, tání a podporu práce ven? Buďte specifičtí.",
"reviews.dialog.bodyPlaceholder.fedimint": "Jak se joining, Lightning platby a podpora práce ven? Buďte specifičtí.",
"fedimint.details.title": "Podrobnosti o federaci",
"nav.lnurlMints": "Minty LNURL",
"footer.allLnurlMints": "Všechny minty LNURL",
"lnurlMints.title": "Minty LNURL: minty směnek na doručitele lnurlcash s recenzemi | Cashumints.space",
"lnurlMints.description": "Všech {total} mintů LNURL indexovaných ze sítě Nostr: {online} online, {offline} offline. Hodnocení komunity, limity výběru a možnosti každého z nich.",
"lnurlMints.heading": "Minty LNURL",
"lnurlMints.lede": "Každý mint LNURL, o kterém tento web ví. Mint LNURL vydává směnky na doručitele lnurlcash: Lightning směnky, které držíte jako tajemství a které lze otočit, rozdělit, sloučit nebo roztavit zpět na Lightning platbu. Každý se každých pár minut kontroluje přes HTTPS.",
"lnurlMints.search.label": "Hledat minty LNURL",
"lnurlMints.search.placeholder": "Hledat podle jména nebo adresy…",
"lnurlMints.sort.label": "Řadit minty LNURL podle",
"lnurlMints.hideOffline": "Skrýt offline",
"lnurlMints.showing": "Zobrazeno {total} mintů LNURL ({online} online, {offline} offline)",
"lnurlMints.showingFiltered": "Zobrazeno {shown} z {total} mintů LNURL ({online} online, {offline} offline)",
"lnurlMints.noResults": "Žádný mint LNURL neodpovídá.",
"lnurlMints.emptyIndex": "Zatím nejsou indexovány žádné minty LNURL.",
"lnurl.crumb": "Minty LNURL",
"lnurl.breadcrumb": "Drobečková navigace",
"lnurl.copyAddress": "Kopírovat adresu",
"lnurl.more.title": "Další minty LNURL",
"lnurl.more.all": "Všechny minty LNURL →",
"lnurl.disclaimer": "Mint LNURL drží vaše saty, zatímco vy držíte jeho směnky, takže používat ho znamená důvěřovat jeho provozovateli. Hodnocení jsou názory komunity a online znamená dostupný, nikoli důvěryhodný.",
"lnurl.features.title": "Funkce",
"lnurl.features.none": "Tento mint neoznámil, co podporuje.",
"lnurl.features.note": "Funkce jsou to, co provozovatel mintu oznámil na Nostr. Kde to tento web může ověřit přes HTTPS, ověří to, a upozorní, když je něco oznámeného právě teď nedostupné.",
"lnurl.features.notListed": "neoznámeno",
"lnurl.features.unavailable": "nedostupné",
"lnurl.feature.mint": "Mintování přes Lightning (LUD-06)",
"lnurl.feature.melt": "Tavení do Lightning",
"lnurl.feature.notes": "Otočení / rozdělení / sloučení směnek",
"lnurl.feature.lud16": "Lightning adresa",
"lnurl.feature.lud21": "Ověření platby (LUD-21)",
"lnurl.feature.signed-notes": "Podepsané směnky (offline ověření)",
"lnurl.feature.onion": "Tor adresa",
"lnurl.feature.rotate": "Otočit směnku",
"lnurl.feature.split": "Rozdělit směnku",
"lnurl.feature.merge": "Sloučit směnky",
"lnurl.feature.lud06": "LUD-06 payRequest",
"lnurl.feature.lud03": "LUD-03 withdrawRequest",
"lnurl.limits.label": "Limity výběru",
"lnurl.limits.notPublished": "nezveřejněno",
"lnurl.limits.withdrawRow": "Výběr",
"lnurl.limits.disabled": "vypnuto",
"lnurl.limits.sats.one": "{n} sat",
"lnurl.limits.sats.few": "{n} saty",
"lnurl.limits.sats.many": "{n} satu",
"lnurl.limits.sats.other": "{n} satů",
"lnurl.details.title": "Podrobnosti mintu",
"lnurl.details.address": "Lightning adresa",
"lnurl.details.mintPubkey": "Veřejný klíč mintu",
"lnurl.details.onion": "Tor adresa",
"lnurl.details.fee": "Poplatek za mintování",
"lnurl.details.node": "Uzel",
"lnurl.details.capacity": "Oznámená kapacita",
"lnurl.kind.lightningAddress": "Lightning adresa",
"lnurl.kind.mintPubkey": "veřejný klíč mintu",
"lnurl.kind.onionAddress": "Tor adresa",
"lnurl.confirmed.label": "Naposledy potvrzeno",
"lnurl.confirmed.when": "potvrzeno {when}",
"lnurl.fee.base.one": "{n} sat",
"lnurl.fee.base.few": "{n} saty",
"lnurl.fee.base.many": "{n} satu",
"lnurl.fee.base.other": "{n} satů",
"lnurl.fee.percent": "{percent} %",
"home.topLnurl.title": "Nejlepší minty LNURL",
"home.topLnurl.sub": "seřazeno podle recenzí a hodnocení",
"home.topLnurl.all": "Všech {n} mintů LNURL →",
"feed.ecosystem.lnurl": "LNURL",
"reviews.dialog.bodyPlaceholder.lnurl": "Jak dopadlo mintování, tavení a otáčení směnek? Buďte konkrétní.",
"reviews.byUrl.open": "Napsat recenzi",
"reviews.byUrl.title": "Napsat recenzi",
"reviews.byUrl.lede": "Vlož adresu. Pokud ji tenhle web ještě nezná, teď ji zkontroluje a přidá.",
"reviews.byUrl.label.cashu": "URL mintu",
"reviews.byUrl.label.fedimint": "Invite code federace",
"reviews.byUrl.label.lnurl": "URL LNURL mintu",
"reviews.byUrl.placeholder.cashu": "mint.example.com",
"reviews.byUrl.placeholder.fedimint": "fed11…",
"reviews.byUrl.placeholder.lnurl": "mint.example.com",
"reviews.byUrl.check": "Zkontrolovat adresu",
"reviews.byUrl.checking": "Kontroluji…",
"reviews.byUrl.back": "Použít jinou adresu",
"reviews.byUrl.existing": "Už je v seznamu",
"reviews.byUrl.indexed": "Právě přidáno",
"reviews.byUrl.confirmClose": "Zavřít? Zadaná adresa se neuchová.",
"reviews.byUrl.detected": "Ta adresa odpověděla jako mint {kind}.",
"reviews.byUrl.switchTo": "Recenzovat jako mint {kind}",
"reviews.byUrl.error.empty": "Nejdřív zadej adresu.",
"reviews.byUrl.error.badUrl": "Tohle nevypadá jako URL mintu.",
"reviews.byUrl.error.badInvite": "Tohle není Fedimint invite code. Kódy začínají na fed11.",
"reviews.byUrl.error.badInput": "Tenhle web tuhle adresu zkontrolovat neumí.",
"reviews.byUrl.error.blocked": "Tohle není veřejná adresa, takže ji nelze zkontrolovat.",
"reviews.byUrl.error.notAMint": "Ta adresa odpověděla, ale ne jako mint tohoto druhu.",
"reviews.byUrl.error.wrongType": "Ta adresa odpověděla, ale jako jiný druh mintu.",
"reviews.byUrl.error.unverifiable": "Na té adrese nic neodpovědělo a na Nostru po ní zatím není ani stopa.",
"reviews.byUrl.error.rateLimited.one": "To je hodně kontrol z jednoho připojení. Zkus to znovu asi za {n} minutu.",
"reviews.byUrl.error.rateLimited.other": "To je hodně kontrol z jednoho připojení. Zkus to znovu asi za {n} minut.",
"reviews.byUrl.error.network": "Index není dostupný. Zkontroluj připojení a zkus to znovu.",
"seo.lnurl.titleNoReviews": "{name}: informace a recenze mintu LNURL | Cashumints.space",
"seo.desc.statusWithdraw": "{status}, výběr {min}–{max} sat.",
"mint.chip.noFunding": "Bez mintování / tavení",
"mint.warnings.lnurlWithdrawalsDisabled.lead": "Výběry vypnuty.",
"mint.warnings.lnurlWithdrawalsDisabled.body.online": "Tento mint oznamuje maximální výběr nula, takže žádnou jím vydanou směnku nelze za nic vyměnit. Nemintujte zde, dokud se to nezmění.",
"mint.warnings.lnurlWithdrawalsDisabled.body.offline": "Při posledním kontaktu tento mint oznamoval maximální výběr nula, takže žádnou jím vydanou směnku nešlo za nic vyměnit. Nemintujte zde, dokud se to nezmění.",
"mint.warnings.lnurlWithdrawalsDisabled.meta.online": "Výběry jsou nyní vypnuté",
"mint.warnings.lnurlWithdrawalsDisabled.meta.offline": "Při posledním kontaktu byly výběry vypnuté",
"mint.warnings.lnurlInvalid.lead": "Endpoint odpovídá, ale neplatně.",
"mint.warnings.lnurlInvalid.body": "Host odpovídá, ale ne oznámením mintu, které tento web umí přečíst, takže výběry nemusí fungovat. Níže je poslední stav, který se podařilo načíst, a recenze stále fungují.",
"mint.warnings.lnurlInvalid.meta": "Odpovídá, ale ne platným oznámením mintu",
"mint.warnings.lnurlNoFunding.lead": "Mintování a tavení nedostupné.",
"mint.warnings.lnurlNoFunding.body": "Stávající směnky lze stále otáčet, rozdělovat nebo slučovat, ale právě teď přes Lightning nic nevchází ani nevychází. Samotný mint běží a odpovídá; Lightning uzel za ním pro něj není dostupný.",
"mint.warnings.lnurlNoFunding.meta": "Mintování a tavení nedostupné",
"mint.warnings.also.noFunding": "Jeho Lightning uzel byl také nedostupný, takže nešlo nic zmintovat ani roztavit."
}
+788
View File
@@ -0,0 +1,788 @@
{
"site.name": "Cashumints.space",
"common.close": "Luk",
"common.cancel": "Annuller",
"common.done": "Færdig",
"common.back": "Tilbage",
"common.retry": "Prøv igen",
"common.connect": "Opret forbindelse",
"common.copied": "Kopieret",
"common.copyFull": "Kopier hele {kind}",
"common.previousPage": "Forrige side",
"common.nextPage": "Næste side",
"common.reviewPages": "Anmeldelsessider",
"time.never": "aldrig",
"time.justNow": "lige nu",
"time.none": "-",
"time.unknownDate": "en ukendt dato",
"time.ago.minute": "{n}m siden",
"time.ago.hour": "{n}h for et stykke tid siden",
"time.ago.day": "{n}d siden",
"time.ago.week": "{n}w siden",
"time.ago.month": "{n}mo for et stykke tid siden",
"time.ago.year": "{n}y siden",
"time.short.minute": "{n}m",
"time.short.hour": "{n}h",
"time.short.day": "{n}d",
"time.short.week": "{n}w",
"time.short.month": "{n}mo",
"time.short.year": "{n}y",
"status.online": "Online",
"status.offline": "Offline",
"status.degraded": "Uopnåelig",
"status.unknown": "Ikke kontrolleret endnu",
"nav.skip": "Gå direkte til indholdet",
"nav.main": "Hovedside",
"nav.mints": "Cashu-mints",
"nav.reviews": "Anmeldelser",
"nav.wallets": "Punge",
"nav.about": "Om",
"nav.searchMints": "Søg blandt {n} mints",
"lang.label": "Sprog",
"lang.switcher": "Vælg et sprog",
"footer.copyright": "© {year} Cashumints.space",
"footer.allMints": "Alle Cashu-mints",
"footer.allReviews": "Alle anmeldelser",
"footer.wallets": "Punge",
"footer.about": "Om",
"footer.disclaimer": "Ansvarsfraskrivelse",
"footer.terms": "Vilkår",
"footer.privacy": "Privatliv",
"footer.madeBy": "Lavet med ❤️ af {azzamo} · Drevet af Cashu og NIP-87",
"footer.warning": "Mints er depotbaserede. Et mint kan forsvinde med dine penge.",
"footer.tagline": "Anmeldelser fra fællesskabet af ecash-mints, signeret på Nostr.",
"footer.col.explore": "Udforsk",
"footer.col.site": "Websted",
"footer.nav": "Sidefod",
"home.title": "Cashumints.space · Find et Cashu-mint du kan stole på",
"home.description": "Find et Cashu-mint du kan stole på. {mints} mints i indekset med {reviews} anmeldelser fra fællesskabet, signeret på Nostr, plus live oppetid, understøttede NUTs og mint-software.",
"home.hero.title": "Find et Cashu-mint",
"home.hero.titleEm": "du kan stole på",
"home.hero.lede": "Hvert mint på Nostr-netværket, med rigtige anmeldelser fra fællesskabet. Ingen gatekeepere, bare signerede anmeldelser du selv kan verificere.",
"home.search.label": "Søg efter mints på navn, domæne eller URL",
"home.search.placeholder": "Søg efter navn, domæne, eller indsæt en Mint-URL…",
"home.filters.mostReviewed": "Mest anmeldte",
"home.filters.highestRated": "Bedst bedømte",
"home.filters.recentlyActive": "Senest aktive",
"home.filters.websockets": "Understøtter WebSockets",
"home.top.title": "De bedste Cashu-mints",
"home.top.sub": "rangeret efter anmeldelser og bedømmelser",
"home.top.all": "Alle {n} mints →",
"home.latest.title": "Seneste anmeldelser",
"home.latest.sub": "live fra Nostr-netværket",
"home.latest.prev": "Tidligere anmeldelser",
"home.latest.next": "Flere anmeldelser",
"home.latest.all": "Alle anmeldelser →",
"home.latest.empty": "Det var ikke muligt at læse anmeldelser fra relæerne lige nu. Åbn en hvilken som helst Mint-side for at læse og skrive anmeldelser direkte.",
"home.why.title": "Hvorfor kan man stole på disse anmeldelser?",
"home.why.signed.title": "Underskrevet, men ikke indsendt",
"home.why.signed.body": "Hver anmeldelse er en {link}-begivenhed, der er underskrevet med forfatterens Nostr-nøgle. Intet her redigeres, rangordnes eller fjernes af os.",
"home.why.signed.link": "NIP-87",
"home.why.verify.title": "Tjek det selv",
"home.why.verify.body": "Anmeldelserne findes på de offentlige Nostr-relæer, ikke i vores database. Ret et hvilket som helst cli-relæ mod de samme relæer, så vil du se de samme data.",
"home.why.checked.title": "Kontrolleres løbende",
"home.why.checked.body": "Vi prober hvert mint for oppetid, softwareversion, grænser og understøttede NUTs, så de specifikationer du ser er aktuelle, ikke selvrapporterede.",
"home.cta.title": "Overvejer du et mint? Læs op først.",
"home.cta.body": "Et mint holder din ecash. Læs, hvad andre har fundet, før du sætter sats ind, og skriv en anmeldelse, når du selv har brugt et.",
"home.cta.explore": "Se alle mints",
"home.cta.learn": "Læs mere om Cashu",
"pulse.mintsIndexed": "mints indekseret",
"pulse.reviewsOnNostr": "anmeldelser af Nostr",
"pulse.lastReview": "sidste gennemgang",
"pulse.mintsOnline": "mints online",
"pulse.indexer": "indekseringsprogram",
"pulse.connected": "forbundet",
"pulse.stale": "gammel",
"mints.title": "Alle Cashu-mints · Cashumints.space",
"mints.description": "Alle {total} Cashu-mints indekseret fra Nostr-netværket, {online} online og {offline} offline, med bedømmelser fra fællesskabet, oppetid og mint-software. Søg og sortér hele listen.",
"mints.heading": "Alle Cashu-mints",
"mints.lede": "Alle mints, der er fundet på Nostr-netværket, herunder dem, der ikke længere findes. Offline-mints forbliver på listen, så du stadig kan finde og gennemse dem.",
"mints.search.label": "Søg efter mints på navn eller domæne",
"mints.search.placeholder": "Søg efter navn eller domæne…",
"mints.sort.label": "Sortér mints",
"mints.sort.score": "Bedst samlet set",
"mints.sort.rating": "Bedst bedømte",
"mints.sort.reviews": "Mest anmeldte",
"mints.sort.recent": "Nyligt anmeldt",
"mints.sort.seen": "Senest set online",
"mints.hideOffline": "Skjul offline",
"mints.showing": "Viser {total} mints ({online} online, {offline} offline)",
"mints.showingFiltered": "Viser {shown} af {total} mints ({online} online, {offline} offline)",
"mints.noResults": "Der findes ingen matchende mints. Prøv med et domæne, eller indsæt en mint-URL.",
"mints.clearSearch": "Ryd søgningen",
"card.noRatings": "Der er endnu ingen bedømmelser",
"card.reviews.one": "Anmeldelse af {n}",
"card.reviews.other": "Anmeldelser af {n}",
"card.lastSeen": "sidst set {when}",
"card.neverSeen": "aldrig set på nettet",
"card.reviewed": "anmeldt {when}",
"card.noReviews": "Der er endnu ingen anmeldelser",
"mint.title": "{name} · anmeldelser af Cashu-mint",
"mint.titleFedimint": "{name} · anmeldelser af Fedimint-føderationen",
"mint.titleLnurl": "{name} · anmeldelser af LNURL-mint",
"mint.breadcrumb": "Navigationsspor",
"mint.crumbMints": "Cashu-mints",
"mint.copyUrl": "Kopiér mint-URL",
"mint.qrButton": "Mint QR",
"mint.writeReview": "Skriv en anmeldelse",
"mint.rankByScore": "#{rank} efter score",
"mint.tagLightning": "Lightning",
"mint.summary": "Mint-oversigt",
"mint.rating.label": "Fællesskabsvurdering",
"mint.rating.none": "Ingen bedømmelser endnu",
"mint.rating.reviews.one": "{n} anmeldelse",
"mint.rating.reviews.other": "{n} anmeldelser",
"mint.rating.unrated.one": "{n} har ikke givet nogen bedømmelse",
"mint.rating.unrated.other": "{n} har ikke givet nogen bedømmelse",
"mint.activity.label": "Aktivitet",
"mint.activity.since": "siden sidste anmeldelse",
"mint.activity.noReviews": "ingen anmeldelser endnu",
"mint.activity.recent.one": "{n} anmeldelse de seneste 90 dage",
"mint.activity.recent.other": "{n} anmeldelser de seneste 90 dage",
"mint.limits.label": "Grænser",
"mint.limits.notPublished": "ikke offentliggjort",
"mint.limits.min": "min",
"mint.limits.max": "max",
"mint.limits.mintRow": "mint",
"mint.limits.meltRow": "melt",
"mint.limits.disabled": "deaktiveret",
"mint.limits.range": "{min} til {max} {unit}",
"mint.limits.upTo": "op til {max} {unit}",
"mint.limits.from": "fra {min} {unit}",
"mint.software.label": "Mint-software",
"mint.software.unknown": "ukendt",
"mint.checked": "tjekket {when}",
"mint.lastSeen": "sidst set {when}",
"mint.neverReached": "aldrig nået",
"mint.uptime.checks": "{ok} af de seneste {total} tjek kunne nås",
"mint.uptime.percent": "{percent}% kunne nås over 30 dage",
"mint.uptime.description": "{ok} af de seneste {total} tjek lykkedes",
"mint.operator.title": "Fra operatøren",
"mint.operator.motd": "Dagens besked",
"mint.operator.beta": "Denne mint beskriver sig selv som eksperimentel eller i beta-fase. Læs udbyderens egne ord nedenfor, inden du opbevarer noget, du ikke har råd til at miste.",
"mint.nuts.title": "Understøttede NUTs",
"mint.nuts.noneOffline": "Ingen NUT-liste er cachet for dette mint, det gik offline, før en blev registreret.",
"mint.nuts.noneYet": "Ingen NUT-liste er cachet for dette mint endnu.",
"mint.nuts.supported": "{n} af {total} kendte NUTs understøttes",
"mint.nuts.whatAre": "Hvad er NUTs?",
"mint.nutRow.disabled": "deaktiveret",
"mint.nutRow.notSupported": "understøttes ikke",
"mint.details.title": "Mint-oplysninger",
"mint.details.pubkey": "Pubkey",
"mint.details.nostr": "Nostr",
"mint.details.email": "E-mail",
"mint.details.serverTime": "Servertid",
"mint.details.firstSeen": "Første gang set",
"mint.details.terms": "Vilkår",
"mint.kind.identifier": "identifikator",
"mint.kind.mintPubkey": "mint-pubkey",
"mint.kind.nostrAddress": "Nostr-adresse",
"mint.kind.emailAddress": "e-mail-adresse",
"mint.kind.npub": "npub",
"mint.disclaimer": "Dette mint er depotbaseret: det holder dine penge, og det kan holde op når som helst. Bedømmelser er meninger fra fællesskabet, og online betyder tilgængelig, ikke troværdig.",
"mint.disclaimerLink": "Læs den fulde ansvarsfraskrivelse",
"mint.qr.title": "Føj {name} til din tegnebog",
"mint.qr.note": "Scan denne med en Cashu-wallet for at tilføje mintet, eller kopiér URL'en og indsæt den.",
"mint.qr.warn": "At tilføje et mint betyder at betro det dine penge.",
"mint.qr.whatMeans": "Hvad det betyder",
"mint.qr.wallets": "Tegnebøger, der understøtter Cashu",
"mint.chip.frozen": "Frossen",
"mint.chip.noWithdrawals": "Ingen udbetalinger",
"mint.chip.meltOnly": "Kun smeltning",
"mint.warnings.dayCount.one": "{n}-dag",
"mint.warnings.dayCount.other": "{n} dage",
"mint.warnings.gone.lead": "Sandsynligvis væk.",
"mint.warnings.gone.body.since": "Dette mint har været utilgængeligt siden {date} ({days}). Betragt midler, der ligger der, som udsatte, og send ikke nye midler. Detaljerne nedenfor viser dets sidst kendte tilstand.",
"mint.warnings.gone.body.neverDated": "Dette mint har ikke svaret på et eneste tjek, siden vi begyndte at holde øje med det i {month}. Betragt midler, der ligger der, som udsatte, og send ikke nye midler. Alt nedenfor stammer fra Nostr-annonceringen, ikke fra mintet.",
"mint.warnings.gone.body.never": "Dette mint har ikke svaret på et eneste tjek, siden vi begyndte at holde øje med det. Betragt midler, der ligger der, som udsatte, og send ikke nye midler. Alt nedenfor stammer fra Nostr-annonceringen, ikke fra mintet.",
"mint.warnings.gone.meta.since": "Offline siden {date}",
"mint.warnings.gone.meta.never": "Har aldrig været tilgængelig, siden den blev opdaget",
"mint.warnings.meltDisabled.lead": "Udbetalinger er deaktiveret.",
"mint.warnings.meltDisabled.body.online": "Denne mint har slået smeltning af Lightning (NUT-05) fra: Du kan indsætte sats, men du kan ikke hæve dem via Lightning. Indsæt ikke penge, før smeltningen er genoptaget.",
"mint.warnings.meltDisabled.body.offline": "Denne mint havde smeltningen af Lightning (NUT-05) deaktiveret: Du kan indsætte sats, men du kan ikke hæve dem via Lightning. Indsæt ikke penge, før smeltningen er genoptaget.",
"mint.warnings.meltDisabled.meta.online": "Udbetalinger er i øjeblikket deaktiveret",
"mint.warnings.meltDisabled.meta.offline": "Udbetalinger var deaktiveret, da de sidst blev set",
"mint.warnings.frozen.lead": "Mint frosset.",
"mint.warnings.frozen.body.online": "Både minting og melting er slået fra. Ingen sats kan flytte ind eller ud via Lightning.",
"mint.warnings.frozen.body.offline": "Både minting og melting var slået fra. Ingen sats kan flytte ind eller ud via Lightning.",
"mint.warnings.frozen.meta.online": "Der er i øjeblikket indført en frysning; der foregår ingen ind- eller udførsel",
"mint.warnings.frozen.meta.offline": "Frosset, da den sidst blev set",
"mint.warnings.offlineLong.lead": "Ikke tilgængelig for {days}, fra og med {date}.",
"mint.warnings.offlineLong.body": "Mint-versionen er muligvis ikke længere tilgængelig. Alt nedenfor viser den seneste kendte version, og anmeldelserne fungerer stadig.",
"mint.warnings.offlineLong.meta": "Offline siden {date}",
"mint.warnings.meltOnly.lead": "Må kun smeltes.",
"mint.warnings.meltOnly.body.online": "Ny minting (NUT-04) er slået fra: du kan ikke indbetale, men eksisterende ecash kan stadig hæves. Det betyder ofte, at et mint er ved at lukke ned, så hvis du holder dets ecash, så overvej at melte ud.",
"mint.warnings.meltOnly.body.offline": "Ny minting (NUT-04) var slået fra: du kan ikke indbetale, men eksisterende ecash kan stadig hæves. Det betyder ofte, at et mint er ved at lukke ned, så hvis du holder dets ecash, så overvej at melte ud.",
"mint.warnings.meltOnly.meta.online": "Kun smeltning i øjeblikket",
"mint.warnings.meltOnly.meta.offline": "Smelt kun, når den sidst blev set",
"mint.warnings.offline.lead": "Har været offline siden {date}.",
"mint.warnings.offline.body": "Detaljerne blev bekræftet dengang. Du kan stadig skrive en anmeldelse.",
"mint.warnings.offline.meta": "Offline siden {date}",
"mint.warnings.also.meltDisabled": "Inden den gik offline, havde den også deaktiveret udbetalinger.",
"mint.warnings.also.mintDisabled": "Inden det gik offline, havde det også slået nyt minting fra.",
"mint.warnings.also.offline": "Den har desuden været offline siden {date} ({days}).",
"nut.0": "Notation og modeller",
"nut.1": "Udveksling af offentlige nøgler i Mint",
"nut.2": "Nøglesæt og gebyrer",
"nut.3": "Bytning af tokens",
"nut.4": "Minting via Lightning",
"nut.5": "Smeltning via Lightning",
"nut.6": "Mint-information",
"nut.7": "Kontrol af tokenets tilstand",
"nut.8": "For meget betalte Lightning-gebyrer",
"nut.9": "Gendan signaturer",
"nut.10": "Udgiftsbetingelser",
"nut.11": "Betaling til offentlig nøgle",
"nut.12": "DLEQ-prøvetryk",
"nut.13": "Deterministiske hemmeligheder",
"nut.14": "Hashed timelock-kontrakter",
"nut.15": "Betalinger via flere kanaler",
"nut.16": "Animerede QR-koder",
"nut.17": "WebSocket-abonnementer",
"nut.18": "Betalingsanmodninger",
"nut.19": "Cachelagrede svar",
"nut.20": "Signerede mint-quotes",
"nut.21": "Ryd godkendelse",
"nut.22": "Blind autentificering",
"nut.23": "BOLT11 betalingsmetode",
"nut.24": "HTTP 402 – betaling påkrævet",
"nut.25": "BOLT12 betalingsmetode",
"reviews.title": "Anmeldelser",
"reviews.anon": "Anonym",
"reviews.anonTitle": "Der blev ikke fundet nogen offentlig Nostr-profil for denne nøgle",
"reviews.nip05Title": "NIP-05-identifikator (ikke verificeret af dette websted)",
"reviews.filter.group": "Filtrer anmeldelser",
"reviews.filter.all": "Alle",
"reviews.filter.five": "5★",
"reviews.filter.critical": "Kritisk",
"reviews.filter.recent": "Seneste",
"reviews.filter.hideAnon": "Skjul anonymitet",
"reviews.anonHidden.other": "Viser anmeldelser af {n} ud af {total}: Anonym, skjult.",
"reviews.allAnon.one": "Den eneste anmeldelse her er fra en anonym bruger.",
"reviews.allAnon.other": "Alle anmeldelser af {n} her er anonyme.",
"reviews.allAnonShow.one": "Vis det",
"reviews.allAnonShow.other": "Vis dem det",
"reviews.loading.one": "Indlæser anmeldelse af {n} fra Nostr-relæer",
"reviews.loading.other": "Henter anmeldelser af {n} fra Nostr-relæer",
"reviews.empty": "Der er endnu ingen anmeldelser. Vær den første til at skrive en.",
"reviews.emptyWritten": "Der er endnu ingen skriftlige anmeldelser her. Vær den første til at skrive en.",
"reviews.emptyFilter": "Der er ingen anmeldelser, der matcher dette filter.",
"reviews.rate.cashu": "Bedøm denne mint",
"reviews.rate.fedimint": "Bedøm denne føderation",
"reviews.rate.lnurl": "Bedøm denne mint",
"reviews.noscript": "Anmeldelserne er underskrevet med Nostr og indlæses i din browser, så denne liste kræver JavaScript. Bedømmelserne og antallet ovenfor stammer fra selve hjemmesiden.",
"reviews.relayError": "Det var ikke muligt at oprette forbindelse til relæet. Anmeldelserne kan være ufuldstændige.",
"reviews.srRating": "{rating} ud af 5",
"reviews.srNoRating": "Ingen bedømmelse angivet",
"reviews.readMore": "Læs mere",
"reviews.readLess": "Vis mindre",
"reviews.actions.copyNpub": "Kopier npub",
"reviews.actions.openNostr": "Se denne begivenhed på Nostr",
"reviews.actions.copyLink": "Kopier linket til denne anmeldelse",
"reviews.anonNote": "Første anmeldelse af denne npub; der er ikke fundet andre aktiviteter",
"reviews.published": "Udgivet.",
"reviews.publishedPartial": "Udgivet til {accepted} blandt {total}-relæerne.",
"reviews.propagating": "Når indholdet opdateres, kan det tage et øjeblik, før det vises andre steder.",
"reviews.summary.line.one": "{count}-vurdering uden kommentar: {breakdown}",
"reviews.summary.line.other": "{count}-vurderinger uden kommentar: {breakdown}",
"reviews.summary.lineDay.one": "{count}-vurdering uden kommentar til {when}: {breakdown}",
"reviews.summary.lineDay.other": "{count}-vurderinger uden kommentar til {when}: {breakdown}",
"reviews.summary.showOne": "Vis det",
"reviews.summary.showMany": "Vis dem det",
"reviews.breakdown.5": "{n} fem stjerner",
"reviews.breakdown.4": "{n} fire stjerner",
"reviews.breakdown.3": "{n} tre stjerner",
"reviews.breakdown.2": "{n} to stjerner",
"reviews.breakdown.1": "{n} én stjerne",
"reviews.breakdown.none": "{n} uden bedømmelse",
"reviews.dialog.title": "Anmeldelse af {name}",
"reviews.dialog.lede.cashu": "Del din oplevelse med denne mint.",
"reviews.dialog.lede.fedimint": "Del din oplevelse med denne føderation.",
"reviews.dialog.lede.lnurl": "Del din oplevelse med denne mint.",
"reviews.dialog.permanent": "Offentliggøres permanent på offentlige Nostr-relays.",
"reviews.dialog.gate.title": "Log ind for at skrive en anmeldelse",
"reviews.dialog.gate.lead": "Anmeldelser signeres med din Nostr-nøgle. Den bliver i din signer, dette websted ser den aldrig.",
"reviews.dialog.ratingLegend": "Din bedømmelse",
"reviews.dialog.starsSr.one": "{n}-stjerne",
"reviews.dialog.starsSr.other": "{n}-stjerner",
"reviews.dialog.chooseRating": "Vælg en bedømmelse",
"reviews.dialog.starLabel": "{n}: {phrase}",
"reviews.dialog.stars.1": "undgå det",
"reviews.dialog.stars.2": "havde problemer",
"reviews.dialog.stars.3": "det fungerede, dog med visse forbehold",
"reviews.dialog.stars.4": "godt",
"reviews.dialog.stars.5": "fungerer rigtig godt",
"reviews.dialog.bodyLabel": "Hvad bør andre vide?",
"reviews.dialog.charCount": "{n} / {max}",
"reviews.dialog.submit": "Underskriv og offentliggør",
"reviews.dialog.signing": "Underskriver…",
"reviews.dialog.publishing": "Udgiver…",
"reviews.dialog.publishingTo": "Udgivelse til relæer ({accepted}/{total})",
"reviews.dialog.waitingExtension": "Vi afventer din forlængelse",
"reviews.dialog.waitingSigner": "Vi venter på din underskriver",
"reviews.dialog.confirmClose": "Vil du lukke denne anmeldelse? Dit udkast gemmes, indtil du forlader siden.",
"reviews.dialog.why.login": "Log ind for at offentliggøre",
"reviews.dialog.why.words": "Skriv et par ord",
"reviews.dialog.why.more.one": "{n} flere tegn",
"reviews.dialog.why.more.other": "{n} flere tegn",
"reviews.dialog.why.over.one": "{n} – antallet af tegn overskrider grænsen",
"reviews.dialog.why.over.other": "{n} – antallet af tegn overskrider grænsen",
"reviews.dialog.identity.needSigner": "Du skal bruge en Nostr-signaturenhed for at kunne offentliggøre. Din nøgle forbliver i enheden; denne hjemmeside får aldrig adgang til den.",
"reviews.dialog.identity.loginToPublish": "Log ind for at offentliggøre",
"reviews.dialog.identity.switch": "Omskifter",
"reviews.dialog.identity.extension": "Log ind med din browserudvidelse",
"reviews.dialog.identity.readingKey": "Læser din offentlige nøgle…",
"reviews.dialog.identity.willAsk": "Din udvidelse vil blive bedt om at underskrive",
"reviews.dialog.error.noRelay": "Ingen relæ har accepteret anmeldelsen. Kontroller din forbindelse, og prøv igen.",
"reviews.validate.rating": "Vælg en bedømmelse fra 1 til 5 stjerner.",
"reviews.validate.short": "Skriv mindst {n} tegn, så anmeldelsen bliver nyttig.",
"reviews.validate.long": "Anmeldelser må højst indeholde {n} tegn.",
"feed.title": "Seneste anmeldelser af Mint og Federation · Cashumints.space",
"feed.description": "Hver anmeldelse af mints, føderationer og LNURL-mints, så snart den offentliggøres: {reviews} anmeldelser af {mints} Cashu-mints, {federations} Fedimint-føderationer og {lnurl} LNURL-mints, signeret på Nostr og læsbare her med nyeste først. Filtrér på økosystem, bedømmelse eller navn.",
"feed.heading": "Seneste anmeldelser",
"feed.lede": "Alle anmeldelser af alle Cashu-mint- og Fedimint-federationer, med de nyeste først, læses direkte fra Nostr-relæerne. Hver enkelt er underskrevet af sin forfatter og findes på relæerne, ikke i vores database.",
"feed.label": "Anmeldelser",
"feed.showingBuild.one": "Her vises den seneste anmeldelse fra den seneste build",
"feed.showingBuild.other": "Her vises de seneste anmeldelser af {n} fra den seneste version",
"feed.noscript": "Anmeldelserne er gemt under Nostr og indlæses i din browser, så filtrering og den fulde liste kræver JavaScript. Anmeldelserne under {n} nedenfor blev hentet fra relæerne, da denne side blev oprettet.",
"feed.emptyBuild": "Der kunne ikke hentes nogen anmeldelser fra relæerne, da denne side blev oprettet. Listen opdateres fra relæerne i din browser.",
"feed.foot": "Anmeldelser er meninger fra fællesskabet, ikke verificerede påstande. Læs {link}, før du betror et mint dine penge.",
"feed.footLink": "ansvarsfraskrivelse",
"feed.showing": "Viser anmeldelser af {shown} og {total}",
"feed.showingMatching": "Viser {shown} af {matching} relevante anmeldelser (i alt {total})",
"feed.emptyRelays": "Der kom ingen anmeldelser tilbage fra relæerne. Åbn en vilkårlig Mint-side for at læse og skrive anmeldelser der.",
"feed.emptyFilter": "Ingen anmeldelser matcher dette filter. Ryd mint-feltet, eller prøv Alle.",
"feed.relayError": "Det var ikke muligt at oprette forbindelse til relæerne. Her vises anmeldelserne fra den seneste build.",
"feed.relayEmpty": "Der kom ingen anmeldelser tilbage fra relæerne. Her vises anmeldelserne fra den seneste build.",
"feed.reading": "Aflæsning af relæerne…",
"login.open": "Log ind",
"login.title": "Log ind med Nostr",
"login.note": "Din nøgle forbliver i din signeringsenhed. Dette websted kan aldrig se den.",
"login.extension.name": "Browserudvidelse",
"login.extension.desc": "Navngiv filen med filtypenavnet Alby, nos2x eller NIP-07",
"login.extension.none": "Der blev ikke fundet nogen udvidelse",
"login.extension.getOne": "Køb en:",
"login.remote.name": "Fjernunderskriver",
"login.remote.desc": "Scan en kode med en underskriftsapp, eller indsæt et bunker-link",
"login.primal.name": "Primal",
"login.primal.desc": "Godkend login i appen Primal på din telefon",
"login.paste.label": "Eller indsæt det link, som din underskriver har givet dig",
"login.copyLink": "Kopier forbindelseslinket",
"login.whatIsNostr": "Hvad er Nostr?",
"login.connect.remote.title": "Tilslut en ekstern underskriver",
"login.connect.remote.lead": "Scan dette med din Signer-app, eller kopier linket og indsæt det der.",
"login.connect.remote.hint": "Fungerer med alle NIP-46-signaturenheder: nsec.app, Amber, Nostrify, din egen bunker.",
"login.connect.remote.open": "Åbn i din Signer-app",
"login.connect.primal.title": "Følg Primal",
"login.connect.primal.lead": "Åbn Primal på din telefon, find »Fjernunderskrift« i indstillingerne, og scan denne kode.",
"login.connect.primal.hint": "Primal bruges til at underskrive for andre apps i stedet for NIP-46, så det er den samme forbindelse, som alle underskrivere bruger, og din nøgle forbliver i Primal. {link}",
"login.connect.primal.open": "Åbn i Primal",
"login.status.buildingLink": "Oprettelse af en forbindelse",
"login.status.waitingExtension": "Vi afventer din forlængelse",
"login.status.waitingSigner": "Vi venter på din underskriver",
"login.status.connecting": "Opret forbindelse til din underskriver",
"login.status.connected": "Forbundet",
"login.toast.loggedIn": "Logget ind",
"login.toast.fetchingProfile": "Henter din Nostr-profil fra relæerne…",
"login.toast.profile": "Profil: {name}",
"login.toast.noProfile": "Ingen profil fundet på relæerne",
"login.method.extension": "Udvidelse",
"login.method.remote": "Fjernbetjening",
"login.method.primal": "Primal",
"login.account": "Din Nostr-konto",
"login.copyNpub": "Kopier hele din npub",
"login.logout": "Log ud",
"signer.status.waitingExtension": "Vi afventer din forlængelse",
"signer.status.signed": "Underskrevet",
"signer.status.reconnecting": "Genopret forbindelse til din underskriver",
"signer.status.approveWindow": "Den, der skal underskrive, beder dig om at godkende dette i et separat vindue",
"signer.status.waitingSigner": "Vi venter på din underskriver",
"signer.status.stillWaiting": "Vi venter stadig på svar fra din underskriver",
"signer.status.connected": "Forbundet",
"signer.error.noExtension": "Der blev ikke fundet nogen signaturforlængelse. Installer Alby eller nos2x, og genindlæs derefter denne side.",
"signer.error.privateKey": "Det ligner en privat nøgle, ikke et link til oprettelse af forbindelse. Denne side beder aldrig om en nøgle. Indsæt det bunker://-link, som din underskriver har givet dig.",
"signer.error.badLink": "Det er ikke et link, som denne side genkender. Indsæt linket bunker:// fra din signaturtjeneste.",
"signer.error.noAnswer": "Din signaturudsteder svarede ikke. Den er muligvis lukket, eller dens relæ er måske nede. Åbn den og prøv igen.",
"signer.error.timeout": "Din underskriver svarede ikke i tide. Åbn den, kontroller, om den stadig er tilsluttet, og prøv derefter igen.",
"signer.error.cancelled": "Forbindelsen er afbrudt.",
"signer.error.declined": "Anmodningen om underskrift blev afvist",
"notfound.title": "Siden blev ikke fundet · Cashumints.space",
"notfound.description": "Den side findes ikke på Cashumints.space.",
"notfound.heading": "Denne side findes ikke",
"notfound.lede": "Den er måske flyttet, eller mintet er måske ikke indekseret endnu. Søg efter et mint på navn eller domæne, eller indsæt en mint-URL.",
"notfound.browse": "Se alle mints",
"notfound.home": "Gå hjem",
"notfound.loading": "Indlæser {host}",
"notfound.fb.rating": "Bedømmelse",
"notfound.fb.reviews": "Anmeldelser",
"notfound.fb.software": "Software",
"notfound.fb.lastOnline": "Sidst online",
"notfound.fb.note": "Denne mint blev indekseret efter sidste build af siden, så dette er et resumé fra det live API med anmeldelserne nedenfor. Hele siden kommer med ved næste build.",
"notfound.resolve.checking": "Første besøg: tjekker denne mint nu.",
"notfound.resolve.failed": "Denne mint kunne ikke tilføjes",
"notfound.resolve.failedFedimint": "Denne føderation kunne ikke tilføjes",
"notfound.resolve.retry": "Prøv en anden adresse",
"notfound.resolve.switch": "Åbn den som {kind}-mint",
"notfound.resolve.fedimint": "En føderations sideadresse indeholder ikke dens invite code, så denne kan ikke slås op ud fra linket alene. Indsæt invite code, så bliver den tilføjet.",
"notfound.resolve.ambiguous": "Denne adresse kan ikke udledes af linket alene. Indsæt mintens fulde URL, så bliver den tilføjet.",
"wallets.title": "Cashu-tegnebøger · Cashumints.space",
"wallets.description": "Cashu-tegnebøger, der fungerer sammen med de her anførte mints på mobil, web, i browseren og via kommandolinjen, alt efter hvad den enkelte er bedst til.",
"wallets.heading": "Punge",
"wallets.lede": "Du skal bruge en wallet for at holde ecash fra et af disse mints. De fleste wallets taler med flere mints ad gangen, så du er ikke bundet til det første, du prøver.",
"wallets.note": "Denne liste er ikke udtømmende og udgør ikke en anbefaling. Cashu-projektet fører et mere omfattende register på {link}. En wallet opbevarer nøgler, mens en mint opbevarer pengene: Læs anmeldelserne af mints, før du vælger, hvor din sats skal opbevares.",
"wallets.platform.android": "Android",
"wallets.platform.ios": "iOS",
"wallets.platform.web": "Web",
"wallets.platform.extension": "Browserudvidelse",
"wallets.platform.cli": "CLI",
"wallets.platform.python": "Python",
"wallets.item.minibits": "Mobil tegnebog ecash med egen mint, understøttelse af Lightning-adresser og Nostr-kontakter.",
"wallets.item.nutstash": "Browser-wallet, der håndterer flere mints ad gangen og importerer og eksporterer rå tokens.",
"wallets.item.enuts": "Mobil wallet med fokus på saldi hos flere mints og på at sende ecash over Nostr.",
"wallets.item.cashume": "Reference-webwallet fra Cashu-projektet. Kører udelukkende i browseren og kan installeres som en PWA.",
"wallets.item.boardwalk": "Web-tegnebog baseret på Lightning-adresser og afsendelse af ecash via et link.",
"wallets.item.coinos": "Lightning-wallet og salgssted, der også kører sit eget Cashu-mint.",
"wallets.item.oxchat": "Nostr-messenger med en indbygget ecash-tegnebog til at sende sats i chats.",
"wallets.item.nutshell": "Referenceimplementeringen. Kører som en wallet på kommandolinjen og som mint-software.",
"about.title": "Om Cashumints.space",
"about.description": "Hvad Cashu er, hvad en »mint« egentlig indeholder, hvad NIP-87 er, og hvordan dette websted indekserer »mints« og læser anmeldelser fra Nostr-relæer.",
"about.heading": "Om",
"about.lede": "Cashumints.space viser alle Cashu-mints, vi kan finde på Nostr-netværket, og viser, hvad folk siger om dem. Den er udviklet af Azzamo.",
"about.cashu.title": "Hvad er Cashu?",
"about.cashu.p1": "Cashu er en ecash-protokol til Bitcoin. Et mint udsteder blindede tokens dækket af de sats, det holder, og de tokens bevæger sig mellem folk, uden at mintet får at vide, hvem der sendte hvad til hvem. Privatlivet er ægte, og det er hele pointen: tokens er ikke konti, og mintet kan ikke koble den token, det signerede, til den token, der kommer tilbage.",
"about.cashu.p2": "Du kan læse protokollen og dens specifikationer på {link}. Selve specifikationerne hedder NUTs, og det er her, tallene NUT på hver enkelt mint-side stammer fra.",
"about.mints.title": "Hvad en mint er, og hvad der er fordele og ulemper ved den",
"about.mints.p1": "Et mint er depotbaseret. Når du minter ecash, giver du sats til den, der driver den server, og du stoler på, at de indfrier dine tokens, når du melter dem ud igen. Der er intet svindelbevis, ingen escrow og ingen klagevej. En mint-operatør kan forsvinde med saldoen, og mints har gjort det.",
"about.mints.p2": "Det er netop derfor, der findes anmeldelser her. Denne side siger aldrig, at en mint er sikker. Status på en mint-side angiver {code}, ikke »pålidelig«, og det betyder blot, at serveren svarede, da vi sidst spurgte. Betragt ecash-saldoer som kontanter i en frakkelomme: fine at bruge, men ikke egnet til opsparing.",
"about.nip87.title": "Hvad er NIP-87?",
"about.nip87.p1": "NIP-87 er Nostr-konventionen for at annoncere og anbefale ecash-mints. To event-typer betyder noget her:",
"about.nip87.li1": "{code} annoncerer en mint. URL’en til mintet findes i {tag}-tagget. På denne måde opdager webstedet mints, det aldrig har set før.",
"about.nip87.li2": "{code} er en anmeldelse. Den bærer bedømmelsen og anmeldelsesteksten og peger på det mint, den handler om, med et {utag}-tag (mint-URL'en) og et {dtag}-tag (mintets egen offentlige nøgle).",
"about.nip87.p2": "Hver anmeldelse er signeret med forfatterens Nostr-nøgle. Vi kalder aldrig en anmeldelse »verificeret«, for ingen har verificeret den: den er signeret, hvilket er en anden og mere nyttig påstand. Det betyder, at du kan se, hvem der skrev den, og at den ikke er ændret. Det betyder ikke, at forfatteren er ærlig eller nogensinde har brugt mintet.",
"about.how.title": "Sådan fungerer denne hjemmeside",
"about.how.p1": "Der er to sløjfer og én regel. Reglen er, at mintet ejer sine metadata, og Nostr ejer anmeldelserne, så dette websted gemmer ingen af delene som autoritet.",
"about.how.li1": "{strong} forespørger alle kendte mints om {code} hver tiende minut og registrerer svarene: navn, beskrivelse, version, understøttede NUTs, begrænsninger, kontaktoplysninger. Den registrerer også, om mienten overhovedet svarede, og det er herfra, at oppetiden stammer.",
"about.how.li1Label": "Sonderløkken",
"about.how.li2": "{strong} forbinder til Nostr-relæerne hver time, læser mint-annonceringer og anmeldelser og tilføjer ethvert mint, den ikke har set før.",
"about.how.li2Label": "Opdagelsessløjfen",
"about.how.p2": "Det vigtige er, hvad der sker, når en mint holder op med at svare. Dens cachelagrede metadata overskrives aldrig af en mislykket forespørgsel, så en nedlagt mint bevarer sin fulde side: dens specifikationer, historie og anmeldelser, med et banner, der angiver, hvornår den gik ned. Den tidligere version af dette websted viste i stedet en fejlside, hvilket betød, at netop de mints, som brugerne havde mest brug for at advare hinanden om, var dem, der ikke kunne anmeldes. Det var hovedårsagen til omlægningen.",
"about.how.p3": "Anmeldelsestekster gemmes aldrig hos os. Når du åbner en mint-side, kommunikerer din browser direkte med relæerne og henter selv anmeldelserne, og når du skriver en anmeldelse, underskrives den med din egen Nostr-udvidelse og sendes direkte til disse relæer. Vi holder styr på antallet, så siderne kan rangordnes og sorteres – ikke mere end det. Intet her kan redigeres eller slettes af os, heller ikke anmeldelser, som vi helst ville undvære.",
"about.relays.title": "Relæer, vi har læst om",
"about.relays.p1": "Dette er de relæer, som både indekseringsprogrammet og din browser bruger:",
"about.relays.p2": "Ret en hvilken som helst Nostr eller cli mod de samme relæer, og du vil se de samme hændelser. Det er hele løftet: Intet her er kun sandt, fordi vi siger det.",
"about.nuts.title": "Hvad er NUTs?",
"about.nuts.p1": "NUTs er de nummererede Cashu-specifikationer, ligesom BIP’er for Bitcoin. En mint angiver, hvilke den implementerer, og det fortæller dig, hvad den rent faktisk kan: NUT-04 og NUT-05 står for udstedelse og indløsning via Lightning (uden dem er en mint ikke til megen nytte), NUT-07 giver en tegnebog mulighed for at kontrollere, om et token allerede er blevet brugt, NUT-17 giver tegnebøger liveopdateringer via WebSockets i stedet for polling, og NUT-20 giver en tegnebog mulighed for at bevise, at et mint-tilbud virkelig blev udstedt til den. Den fulde liste findes under {link}.",
"about.numbers.title": "Lige nu",
"about.numbers.p1": "{mints}-mønstre indekseret, {online} af disse svarer, {reviews}-gennemgange talt fra relæerne ovenfor.",
"about.source.title": "Kilde og kreditering",
"about.source.p1": "Webstedet er open source på {github}. Det er bygget og drevet af {azzamo}. Hvis noget er galt på en mint-side, så ret det ved kilden: mint-metadata kommer fra mintets eget {code}, og anmeldelser kommer fra Nostr.",
"terms.title": "Brugsbetingelser · Cashumints.space",
"terms.description": "Brugsbetingelser for Cashumints.space: hvad webstedet er, hvad det ikke gør, og hvem der er ansvarlig for hvad. Ingen konti, ingen garantier, ingen tilknytning til noget mint.",
"terms.heading": "Brugsbetingelser",
"terms.lede": "Enkel beskrivelse af et websted, der udelukkende indekserer offentlige data. Senest opdateret {date}.",
"terms.what.title": "Hvad denne hjemmeside er",
"terms.what.p1": "Cashumints.space er et indeks over offentligt tilgængelige data om Cashu-mints. To slags data, begge fra et andet sted:",
"terms.what.li1": "Mint-metadata, læst fra hvert mints eget {code}-endpoint: navn, beskrivelse, softwareversion, understøttede NUTs, kontaktoplysninger og om mintet svarede, sidst vi spurgte.",
"terms.what.li2": "Anmeldelser, hentet fra de offentlige Nostr-relæer. Dette er NIP-87-begivenheder, som forfatterne har offentliggjort på relæer, som alle kan søge i.",
"terms.what.p2": "Vi indsamler disse data, normaliserer dem, rangordner dem og viser dem. Vi skaber dem ikke.",
"terms.noAccounts.title": "Ingen garantier, ingen ansvar",
"terms.noAccounts.p1": "Der er intet at tilmelde sig. Webstedet stilles til rådighed, som det er, uden nogen form for garanti: ikke for at data er korrekte, ikke for at de er aktuelle, ikke for at webstedet er tilgængeligt. Mint-data kan være minutter eller timer gamle, et mint kan ændre sig, efter vi sidst tjekkede, og et relæ kan undlade at svare, og så ser du mindre, end der findes.",
"terms.noAccounts.p2": "Brug det på eget ansvar. Vi er ikke ansvarlige for tab, der følger af at bruge dette websted eller af at stole på noget, du læser her. Se {link} for de specifikke risici ved at bruge et Cashu-mint.",
"terms.noAccounts.link": "ansvarsfraskrivelse",
"terms.noCustody.title": "Vi opbevarer ikke dine penge",
"terms.noCustody.p1": "Dette websted har aldrig midler i sin varetægt. Det har ingen tegnebog, det har ikke adgang til ecash, og det kan ikke flytte, spærre eller gendanne noget som helst. Hvis du har et problem med en mint, er mintoperatøren den eneste, der kan hjælpe dig, og vi kan ikke kontakte dem på dine vegne.",
"terms.noCustody.p2": "Vi er ikke tilknyttet noget mint på listen her, og intet mint betaler for sin opførelse, sin placering eller sin bedømmelse. Mints findes via offentlige Nostr-annonceringer og rangordnes af en algoritme over offentlige anmeldelsesdata.",
"terms.reviews.title": "Anmeldelser er andres ord",
"terms.reviews.p1": "Anmeldelser er brugergenereret indhold, der offentliggøres på de offentlige Nostr-relæer af deres forfattere. Vi viser dem, men vi har ikke kontrol over dem. Vi har ikke skrevet dem, vi kan ikke redigere dem, og vi kan ikke slette dem fra de relæer, hvor de er gemt: Det er signerede begivenheder på et netværk, som vi ikke ejer.",
"terms.reviews.p2": "En anmeldelse er en persons oplevelse, ikke et verificeret faktum. Hvis du offentliggør en anmeldelse, er du ansvarlig for indholdet. Offentliggør ikke noget ulovligt, og vær opmærksom på, at det, du offentliggør, er offentligt og forbliver der for altid (se {link}).",
"terms.reviews.link": "side om privatlivspolitik",
"terms.reviews.p3": "Vi kan vælge ikke længere at vise en bestemt anmeldelse på dette websted, og det vil vi gøre i tilfælde af ulovligt indhold. Det betyder dog kun, at anmeldelsen fjernes fra disse sider. Begivenheden forbliver på relæerne, og alle andre Nostr client kan stadig læse den.",
"terms.links.title": "Links til andre hjemmesider",
"terms.links.p1": "Mint-URL’er, links til tegnebøger, kontaktoplysninger på udbydere og alt andet, der fører væk fra dette websted, er medtaget, fordi de udgør en del af dataene. Et link er ikke en anbefaling, og vi kontrollerer ikke indholdet på den anden side.",
"terms.changes.title": "Ændringer",
"terms.changes.p1": "Disse vilkår kan ændres. Datoen øverst angiver, hvornår de senest blev ændret. Hjemmesiden er open source, så alle ændringer på denne side fremgår af commit-historikken.",
"terms.contact.title": "Kontakt",
"terms.contact.p1": "Problemer og anmodninger om fjernelse indsendes bedst via {github}. Hjemmesiden drives af {azzamo}.",
"privacy.title": "Privatliv · Cashumints.space",
"privacy.description": "Privatliv på Cashumints.space: ingen konti, ingen sporingscookies, ingen analyseværktøjer. localStorage bruges til cachelagring af profiler, og der sendes intet nogen steder hen. Anmeldelser underskrives i din egen browser.",
"privacy.heading": "Privatliv",
"privacy.lede": "Vi indsamler så få oplysninger som overhovedet muligt på en hjemmeside. Senest opdateret {date}.",
"privacy.noAccounts.title": "Ingen konti",
"privacy.noAccounts.p1": "Der er ingen tilmelding, ingen login og ingen profil. Intet på dette websted spørger dig, hvem du er, og der er ingen brugeroplysninger, der kan lækkes, fordi der ikke er nogen brugere.",
"privacy.noTracking.title": "Ingen sporingscookies, ingen analyse",
"privacy.noTracking.p1": "Hjemmesiden bruger ingen cookies til sporing. Der er ingen analyseskript, ingen tagmanager, ingen pixel, ingen sessionsoptagelse og intet annoncenetværk. Intet på siden videregiver oplysninger om din adfærd til os eller til andre.",
"privacy.logs.title": "Serverlogfiler",
"privacy.logs.p1": "Webserveren fører standardadgangslogfiler, hvilket er standard for alle webservere. En loglinje indeholder din IP-adresse, klokkeslættet, den side, du har anmodet om, svarstatus samt den brugeragent og den henvisningskilde, som din browser har sendt. Logfilerne bruges til at fejlfinde og opdage misbrug. De sammenkædes ikke med andre oplysninger og sælges eller deles ikke.",
"privacy.logs.operatorLabel": "Bemærkning fra operatøren:",
"privacy.logs.operator": "Hvis du driver dette websted, skal du angive den faktiske opbevaringsperiode her. Implementeringen i README bruger nginx med dens standardadgangslog og den rotationsindstilling, som din distribution leveres med (på Debian og Ubuntu er det logrotate, dagligt, hvor logfilerne opbevares i 14 dage). Bekræft, hvad din server gør, og erstat dette afsnit med tallet.",
"privacy.storage.title": "Hvad gemmes der i din browser?",
"privacy.storage.p1": "Webstedet bruger localStorage til præcis to formål, som begge forbliver på din enhed og aldrig sendes videre:",
"privacy.storage.li1": "En cache med Nostr-profilmetadata (begivenheder af type 0: navne, avatarer, NIP-05-adresser) for de anmeldere, hvis anmeldelser du har set. Dette sparer dig for at skulle sende forespørgsler til relæerne på hver eneste side. Posterne udløber, og cachen har en størrelsesbegrænsning, hvor de ældste poster fjernes først.",
"privacy.storage.li2": "Indstillinger for brugergrænsefladen, så hjemmesiden fungerer på samme måde næste gang.",
"privacy.storage.p2": "Hvis du sletter din browsers webstedsdata, fjernes begge dele. Der opstår ingen problemer; profilsøgningerne kører blot igen.",
"privacy.thirdParties.title": "Hvad din browser kommunikerer med",
"privacy.thirdParties.p1": "Når du indlæser en side, opretter din browser forbindelse til et par andre steder ud over dette websted. På den måde kan et websted uden sporing på serversiden stadig hente data i realtid:",
"privacy.thirdParties.li1Label": "Offentlige Nostr-relæer",
"privacy.thirdParties.li1": "{strong}, frem for WebSocket, for at læse anmeldelser og anmelderprofiler. Relæet kan se din IP-adresse og hvad du har anmodet om, ligesom enhver server, du opretter forbindelse til. Relæerne er angivet på {link}.",
"privacy.thirdParties.li1Link": "Om os-siden",
"privacy.thirdParties.li2Label": "Avatarbilleder af anmeldere",
"privacy.thirdParties.li2": "{strong}, der hentes fra den adresse, som anmelderens profil angiver. Disse sendes uden henvisningsadresse, så værten ikke kan se, hvilken side du befandt dig på.",
"privacy.publishing.title": "Offentliggørelse af en anmeldelse",
"privacy.publishing.p1": "Skrivningen af en anmeldelse foregår i din browser, ikke på vores server. Begivenheden signeres af din NIP-07-udvidelse (Alby, nos2x eller lignende) og sendes fra din browser direkte til relæerne. Din private nøgle forlader aldrig udvidelsen, og dette websted får aldrig adgang til den. Vi udvikler ikke brugerdefineret nøgleadministration, og vi beder aldrig om en nøgle eller en seed-sætning.",
"privacy.permanent.title": "Alt, hvad du offentliggør på Nostr, er offentligt og permanent",
"privacy.permanent.p1": "Dette er den del, der er værd at læse to gange. En anmeldelse er en signeret begivenhed på offentlige relæer. Når den først er ude, er den ude:",
"privacy.permanent.li1": "Alle kan læse det, både nu og senere, ved hjælp af enhver Nostr client. Det er ikke »offentligt på Cashumints.space«, men offentligt på netværket.",
"privacy.permanent.li2": "Den er permanent knyttet til din offentlige nøgle Nostr sammen med alt andet, som denne nøgle nogensinde har offentliggjort.",
"privacy.permanent.li3": "At slette den er en anmodning, ikke en ordre. Du kan offentliggøre en sletningsbegivenhed, og nogle relæer vil efterkomme den, men et relæ kan frit vælge at ignorere den, og alle, der allerede har kopieret begivenheden, har den stadig. Betragt en offentliggjort anmeldelse som permanent.",
"privacy.permanent.li4": "Vi kan ikke slette det for dig. Vi kan dog sørge for, at det ikke længere vises her; det ændrer dog ikke noget på relæerne.",
"privacy.permanent.p2": "Hvis du vil anmelde et mint, uden at det knyttes til din primære identitet, så brug en separat Nostr-nøgle. Bemærk, at en nøgle uden anden aktivitet vises som sådan ved hårde anmeldelser, fordi en helt ny nøgle, der skriver en anmeldelse på én stjerne, er et mønster, læsere fortjener at se.",
"privacy.contact.title": "Spørgsmål",
"privacy.contact.p1": "Webstedet er open source: Hvis du vil tjekke noget af det ovenstående, kan du læse koden på {github}. Spørgsmål og rettelser kan indsendes som en issue der.",
"disclaimer.title": "Ansvarsfraskrivelse · Cashumints.space",
"disclaimer.description": "Hvad du bør vide, før du bruger et Cashu-mint: mints er depotbaserede, et mint kan forsvinde med dine penge, anmeldelser er meninger, og online betyder tilgængelig, ikke troværdig.",
"disclaimer.heading": "Ansvarsfraskrivelse",
"disclaimer.lede": "Læs dette, før du lægger penge i et mint. Senest opdateret {date}.",
"disclaimer.callout": "Et Cashu-mint opbevarer dine penge for dig. Hvis det forsvinder, er din ecash væk, og der er ingen at klage til. Behold kun det, du har råd til at miste.",
"disclaimer.custodial.title": "Mints er forvaringsmyndigheder",
"disclaimer.custodial.p1": "Når du minter ecash, giver du bitcoin til mint-operatøren og får tokens tilbage. De tokens er et krav på den operatør. De er ikke bitcoin, du holder, og operatøren er den, der bestemmer, om de indfries.",
"disclaimer.custodial.p2": "Det betyder, at alle mints er forbundet med de samme risici: operatøren kan forsvinde, blive tvunget til at indstille driften, miste midlerne på grund af en fejl eller et hackerangreb, eller ganske enkelt slukke for serveren. Der er ingen indskudsforsikring, ingen tilbageførsel af betalinger, ingen supportlinje og ingen retsmidler, man kan regne med. Dette gælder også for den bedst vurderede mint på dette websted.",
"disclaimer.custodial.p3": "Cashu er lavet til små beløb, der bevæger sig hurtigt. Brug det sådan: hold det, du er ved at bruge, ikke din opsparing, og spred det over flere mints i stedet for at parkere alt i ét.",
"disclaimer.reviews.title": "Anmeldelser er meninger, ikke verificerede påstande",
"disclaimer.reviews.p1": "Hver anmeldelse her er skrevet af nogen på Nostr og signeret med deres nøgle. Signeret betyder, at vi kan fortælle dig, hvilken nøgle der skrev den. Det betyder ikke, at anmeldelsen er sand, at forfatteren har brugt mintet, eller at forfatteren ikke er operatøren, en konkurrent eller én person med ti nøgler.",
"disclaimer.reviews.p2": "Vi verificerer ikke anmeldere, og vi redigerer eller fjerner ikke anmeldelser, vi er uenige i. Læs et par stykker, se hvad de faktisk siger, og vægt et mint med tre anmeldelser anderledes end et med tre hundrede.",
"disclaimer.status.title": "»Online« betyder, at man kan få fat i det, ikke at det er pålideligt",
"disclaimer.status.p1": "Vi tjekker alle mints regelmæssigt og viser, om de har svaret. Det er alt, hvad en grøn status betyder: serveren har svaret. Det siger intet om, hvorvidt operatøren er solvent, ærlig eller stadig har til hensigt at indfri dine tokens i morgen.",
"disclaimer.status.p2": "Et mint kan være online lige indtil det øjeblik, det ikke er. Oppetidshistorik, understøttede NUTs og softwareversion siger noget om, hvordan et mint drives, men ingen af dem er et løfte om dine penge.",
"disclaimer.ranking.title": "Sådan fungerer ranglisten, og hvad den ikke betyder",
"disclaimer.ranking.p1": "Mints sorteres ved hjælp af en algoritme baseret på data fra offentlige anmeldelser: bedømmelse, antal anmeldelser og seneste aktivitet, hvor online-mints som standard prioriteres frem for offline-mints. De nærmere detaljer fremgår af open source-koden.",
"disclaimer.ranking.p2": "Ingen betaler for placering. Der er ingen sponsoreret plads, ingen fremhævet opførelse og ingen aftale med noget mint på dette websted. En høj placering betyder, at algoritmen kunne lide de offentlige data, og intet mere. Det er ikke en anbefaling, og det er ikke en sikkerhedsvurdering.",
"disclaimer.notAdvice.title": "Dette er ikke finansiel rådgivning",
"disclaimer.notAdvice.p1": "Intet på dette websted er finansiel, investerings-, juridisk eller skattemæssig rådgivning. Vi kender ikke din situation, og vi rådgiver dig ikke. At bruge et mint, og at vælge hvilket, er din beslutning.",
"disclaimer.dyor.title": "Undersøg selv",
"disclaimer.dyor.li1": "Slå operatøren op. Et mint drevet af nogen med et offentligt omdømme at miste er noget andet end en anonym URL.",
"disclaimer.dyor.li2": "Læs selve mintens vilkår og dens MOTD. En mint, der kalder sig selv en test- eller betaversion, mener det alvorligt.",
"disclaimer.dyor.li3": "Start i det små. Mint lidt, brug det, melt det ud igen, og se hvad der sker, før du betror det mere.",
"disclaimer.dyor.li4": "Vær opmærksom på grænserne. En lav maksimumsgrænse er ofte tegn på en forsigtig operatør, mens en høj grænse ikke nødvendigvis er tegn på en sikker operatør.",
"disclaimer.dyor.li5": "Sørg for at tage backup af dine tokens, og husk, at et token er et ihændehaverbevis: den, der er i besiddelse af det, kan bruge det.",
"disclaimer.site.title": "Om selve hjemmesiden",
"disclaimer.site.p1": "Oplysningerne her kan være forkerte eller forældede. Mints bliver afprøvet efter en fast tidsplan, relæer svarer ikke altid, og den side, du læser, blev oprettet på et tidspunkt i fortiden. Hjemmesiden stilles til rådighed »som den er«, uden nogen form for garanti. Se {link}.",
"disclaimer.site.link": "brugsbetingelser",
"disclaimer.updated": "20. august 2026",
"login.getPrimal": "Få Primal",
"reviews.dialog.identity.extensionGone": "Din browserudvidelse reagerer ikke. Log ind igen, eller brug en ekstern underskriver.",
"seo.orgDescription": "En oversigt over Cashu-mints, med live status fra hvert mint og anmeldelser fra fællesskabet signeret på Nostr.",
"seo.ogImageAlt": "Cashumints.space: find et Cashu-mint du kan stole på.",
"seo.mint.title.one": "{name} anmeldelser: {rating} fra {n} anmeldelse | Cashumints.space",
"seo.mint.title.other": "{name} anmeldelser: {rating} fra {n} anmeldelser | Cashumints.space",
"seo.mint.titleOffline.one": "{name}: offline i {n} dag | Cashumints.space",
"seo.mint.titleOffline.other": "{name}: offline i {n} dage | Cashumints.space",
"seo.mint.titleNoReviews": "{name}: info og anmeldelser om Cashu-mint | Cashumints.space",
"seo.fedimint.titleNoReviews": "{name}: Oplysninger og anmeldelser om Fedimint-føderationen | Cashumints.space",
"seo.desc.fallback": "Anmeldelser fra fællesskabet af {name} ({domain}).",
"seo.desc.fallbackDomain": "Anmeldelser fra fællesskabet af Cashu-mintet på {domain}.",
"seo.desc.rated.one": "{rating}/5 fra {n} signeret Nostr-anmeldelse.",
"seo.desc.rated.other": "{rating}/5 fra {n} signerede Nostr-anmeldelser.",
"seo.desc.noReviews": "Der er endnu ingen anmeldelser.",
"seo.desc.status": "{status}",
"seo.desc.statusLimits": "{status}, grænser {limits}.",
"seo.desc.fedModules": "{status}. Moduler: {modules}.",
"seo.desc.fedModulesNetwork": "{status}. Moduler: {modules} og {network}.",
"seo.mint.ogAlt.one": "{name}: {rating}-stjerner fra anmeldelsen af {n} på Cashumints.space",
"seo.mint.ogAlt.other": "{name}: {rating}-stjerner fra anmeldelser af {n} på Cashumints.space",
"seo.mint.ogAltNoReviews": "{name} på Cashumints.space",
"nav.home": "Hjem",
"mint.more.title": "Flere mints",
"mint.more.all": "Se alle mints",
"nav.fedimints": "Fedimints",
"status.announced": "Kun annonceret",
"card.unnamedFederation": "Ubenævnt forbund",
"card.notChecked": "aldrig kontrolleret",
"fedimints.title": "Alle Fedimint-forbund · Cashumints.space",
"fedimints.description": "Alle {total}- og Fedimint-foreninger, der er annonceret på Nostr-netværket: {online} er bekræftet som aktiv ved en ekstern kontrol, {announced} er annonceret, men aldrig bekræftet. Brugerbedømmelser, moduler og invitationskoder for hver enkelt.",
"fedimints.heading": "Alle forbund",
"fedimints.lede": "Hver Fedimint-føderation, der er annonceret på Nostr-netværket. En føderation har ikke sit eget offentlige status-endpoint, så det bekræftes ved en ekstern kontrol, at nogle af disse kører, mens resten er opført nøjagtigt som de blev annonceret.",
"fedimints.search.label": "Søg efter forbund efter navn eller id",
"fedimints.search.placeholder": "Søg efter navn eller forbunds-id…",
"fedimints.sort.label": "Sorter forbund",
"fedimints.hideOffline": "Skjul offline",
"fedimints.showing": "Viser {total}-forbund ({online} bekræftet, {announced} kun annonceret)",
"fedimints.showingFiltered": "Viser {shown} blandt {total}-forbundene ({online} er bekræftet, {announced} er kun annonceret)",
"fedimints.announcedNote.one": "En af disse blev annonceret på Nostr, og der er siden ikke kommet nogen bekræftelse på, at den er i drift. Den er angivet som »annonceret« i stedet for »online« eller »offline«, da ingen af delene ville være korrekt.",
"fedimints.announcedNote.other": "{n} af disse blev annonceret på Nostr, og der er siden ikke kommet nogen bekræftelse på, at de er i drift. De er angivet som »annonceret« i stedet for »online« eller »offline«, da ingen af delene ville være korrekt.",
"fedimints.noResults": "Der findes ingen kampe fra denne forbund. Prøv at indtaste et navn, eller indsæt et forbunds-id.",
"fedimints.emptyIndex": "Ifølge denne hjemmeside er der endnu ikke offentliggjort nogen holdopstillinger til stafetterne. Listen opdateres løbende, efterhånden som der kommer nye meddelelser.",
"fedimint.crumb": "Fedimints",
"fedimint.breadcrumb": "Navigationsspor",
"fedimint.unnamed": "Ubenævnt forbund",
"fedimint.idLabel": "Forbund",
"fedimint.kind.federationId": "forenings-id",
"fedimint.moduleCount.one": "{n}-modul",
"fedimint.moduleCount.other": "{n}-moduler",
"fedimint.copyInvite": "Kopier invitationskoden",
"fedimint.confirmed.label": "Bekræftet",
"fedimint.confirmed.never": "Aldrig",
"fedimint.confirmed.by": "af {source}",
"fedimint.confirmed.unattributed": "sidste kontrol",
"fedimint.announcedOn": "annonceret {when}",
"fedimint.announcedUnknown": "ingen dato for offentliggørelsen",
"fedimint.modules.title": "Moduler",
"fedimint.modules.none": "I denne forenings meddelelse er der ikke anført nogen moduler.",
"fedimint.modules.note": "Moduler er de dele, som en sammenslutning driver. I meddelelsen fremgår det, hvilke der findes, men der står intet om, hvorvidt nogen af dem modtager indskud.",
"fedimint.modules.notListed": "ikke opført",
"fedimint.invite.title": "Inviteringskode",
"fedimint.invite.count.one": "Kode {n}",
"fedimint.invite.count.other": "{n}-koder",
"fedimint.invite.note": "Indsæt dette i en Fedimint-tegnebog for at tilmelde dig. Den indeholder føderations-id’et og adresserne på dens værger, og den er beregnet til at blive delt.",
"fedimint.invite.kind": "inviteringskode",
"fedimint.invite.qr": "QR",
"fedimint.invite.qrCaption": "Scan for at tilmelde dig {name}",
"fedimint.details.federationId": "Forbunds-id",
"fedimint.details.network": "Netværk",
"fedimint.details.announcedBy": "Annonceret af",
"fedimint.details.announced": "Annonceret",
"fedimint.more.title": "Flere forbund",
"fedimint.more.all": "Se alle forbund",
"fedimint.disclaimer": "Når man melder sig ind i en sammenslutning, betyder det, at man overlader sine penge til dens forvaltere. Bedømmelserne er udtryk for fællesskabets holdninger, og at en kandidat er bekræftet som opstillet, betyder blot, at vedkommende kan kontaktes – ikke at vedkommende er pålidelig.",
"fedimint.module.lightning": "Lightning",
"fedimint.module.mint": "Ecash-mint",
"fedimint.module.wallet": "On-chain-tegnebog",
"fedimint.module.meta": "Metadata",
"fedimint.module.stability_pool": "Stabilitetspulje",
"fedimint.module.multi_sig_stability_pool": "Stabilitetspulje (multisig)",
"fedimint.module.fedi-social": "Social genopretning",
"fedimint.network.mainnet": "Bitcoin mainnet",
"fedimint.network.signet": "Signet",
"fedimint.network.testnet": "Testnet",
"fedimint.network.testnet4": "Testnet4",
"fedimint.network.regtest": "Regtest",
"feed.ecosystem.group": "Økosystem",
"feed.ecosystem.all": "Alle",
"feed.ecosystem.cashu": "Cashu",
"feed.ecosystem.fedimint": "Fedimint",
"feed.filterBySubject": "Filtrer efter »mint« eller »federation«",
"feed.anySubject": "Ethvert mint eller enhver føderation",
"feed.clearSubject": "Ryd navnefilteret",
"home.topFedimints.title": "De bedste Fedimint-modeller",
"home.topFedimints.sub": "rangeret efter anmeldelser og bedømmelser",
"home.topFedimints.all": "Alle {n}-forbund →",
"reviews.validate.subject": "Der er intet, som denne anmeldelse kan rette sig mod.",
"mint.warnings.fedimintOffline.lead": "Rapporteret som offline.",
"mint.warnings.fedimintOffline.body.since": "Den seneste indbetaling, der nåede frem til denne sammenslutning, var på {date} (for {days} siden). Dens forvaltere har ikke svaret siden. Betragt de midler, der er deponeret der, som værende i fare, og indsæt ikke nye midler til kontoen.",
"mint.warnings.fedimintOffline.body.never": "Der er aldrig blevet foretaget en revision af denne sammenslutning, og den seneste revision mislykkedes. Betragt de midler, der opbevares der, som værende i fare, og indsæt ikke nye midler i den.",
"mint.warnings.fedimintOffline.meta.since": "Har været rapporteret som offline siden {date}",
"mint.warnings.fedimintOffline.meta.never": "Rapporteret som offline, er aldrig blevet kontaktet",
"mint.warnings.neverConfirmed.lead": "Annonceret, men aldrig bekræftet.",
"mint.warnings.neverConfirmed.body.reviewed": "Denne sammenslutning blev annonceret på Nostr for {date} og {days} siden, og der er siden da ikke kommet nogen bekræftelse på, at den er i drift. Anmeldelserne nedenfor er det eneste tegn på liv på denne side; alt andet stammer fra annonceringen.",
"mint.warnings.neverConfirmed.body.quiet": "Denne sammenslutning blev annonceret på Nostr for {date} og {days} siden; der er siden da ikke kommet nogen bekræftelse på, at den er i drift, og ingen har anmeldt den for nylig. Alt nedenfor stammer fra annonceringen.",
"mint.warnings.neverConfirmed.meta": "{date} blev annonceret, men aldrig bekræftet",
"footer.allFedimints": "Alle forbund",
"reviews.dialog.bodyPlaceholder.cashu": "Hvordan gik minting, melting og support? Vær konkret.",
"reviews.dialog.bodyPlaceholder.fedimint": "Hvordan gik det med tilmeldingen, betalingerne via Lightning og supporten? Vær konkret.",
"fedimint.details.title": "Oplysninger om forbundet",
"nav.lnurlMints": "LNURL-mints",
"footer.allLnurlMints": "Alle LNURL-mints",
"lnurlMints.title": "LNURL-mints: mints til lnurlcash-ihændehaversedler, anmeldt | Cashumints.space",
"lnurlMints.description": "Alle {total} LNURL-mints indekseret fra Nostr-netværket: {online} online, {offline} offline. Bedømmelser fra fællesskabet, udbetalingsgrænser og funktioner for hver enkelt.",
"lnurlMints.heading": "LNURL-mints",
"lnurlMints.lede": "Hver LNURL-mint denne side kender til. En LNURL-mint udsteder lnurlcash-ihændehaversedler: Lightning-sedler, du opbevarer som en hemmelighed, og som kan roteres, deles, lægges sammen eller smeltes tilbage til en Lightning-betaling. Hver enkelt tjekkes via HTTPS hvert par minutter.",
"lnurlMints.search.label": "Søg i LNURL-mints",
"lnurlMints.search.placeholder": "Søg på navn eller adresse…",
"lnurlMints.sort.label": "Sortér LNURL-mints efter",
"lnurlMints.hideOffline": "Skjul offline",
"lnurlMints.showing": "Viser {total} LNURL-mints ({online} online, {offline} offline)",
"lnurlMints.showingFiltered": "Viser {shown} af {total} LNURL-mints ({online} online, {offline} offline)",
"lnurlMints.noResults": "Ingen LNURL-mint matcher det.",
"lnurlMints.emptyIndex": "Der er endnu ikke indekseret nogen LNURL-mints.",
"lnurl.crumb": "LNURL-mints",
"lnurl.breadcrumb": "Brødkrummesti",
"lnurl.copyAddress": "Kopiér adresse",
"lnurl.more.title": "Flere LNURL-mints",
"lnurl.more.all": "Alle LNURL-mints →",
"lnurl.disclaimer": "En LNURL-mint opbevarer dine sats, mens du holder dens sedler, så at bruge en betyder at stole på dens operatør. Bedømmelser er fællesskabets meninger, og online betyder tilgængelig, ikke troværdig.",
"lnurl.features.title": "Funktioner",
"lnurl.features.none": "Denne mint har ikke annonceret, hvad den understøtter.",
"lnurl.features.note": "Funktioner er det, mintens operatør har annonceret på Nostr. Hvor denne side kan tjekke en via HTTPS, gør den det, og siger til, når noget annonceret ikke er tilgængeligt lige nu.",
"lnurl.features.notListed": "ikke annonceret",
"lnurl.features.unavailable": "utilgængelig",
"lnurl.feature.mint": "Mint via Lightning (LUD-06)",
"lnurl.feature.melt": "Smelt til Lightning",
"lnurl.feature.notes": "Rotér / del / læg sedler sammen",
"lnurl.feature.lud16": "Lightning-adresse",
"lnurl.feature.lud21": "Betalingsverifikation (LUD-21)",
"lnurl.feature.signed-notes": "Signerede sedler (offlineverifikation)",
"lnurl.feature.onion": "Tor-adresse",
"lnurl.feature.rotate": "Rotér en seddel",
"lnurl.feature.split": "Del en seddel",
"lnurl.feature.merge": "Læg sedler sammen",
"lnurl.feature.lud06": "LUD-06 payRequest",
"lnurl.feature.lud03": "LUD-03 withdrawRequest",
"lnurl.limits.label": "Udbetalingsgrænser",
"lnurl.limits.notPublished": "ikke offentliggjort",
"lnurl.limits.withdrawRow": "Udbetaling",
"lnurl.limits.disabled": "slået fra",
"lnurl.limits.sats.one": "{n} sat",
"lnurl.limits.sats.other": "{n} sats",
"lnurl.details.title": "Oplysninger om minten",
"lnurl.details.address": "Lightning-adresse",
"lnurl.details.mintPubkey": "Mintens offentlige nøgle",
"lnurl.details.onion": "Tor-adresse",
"lnurl.details.fee": "Mint-gebyr",
"lnurl.details.node": "Node",
"lnurl.details.capacity": "Annonceret kapacitet",
"lnurl.kind.lightningAddress": "Lightning-adresse",
"lnurl.kind.mintPubkey": "mintens offentlige nøgle",
"lnurl.kind.onionAddress": "Tor-adresse",
"lnurl.confirmed.label": "Sidst bekræftet",
"lnurl.confirmed.when": "bekræftet {when}",
"lnurl.fee.base.one": "{n} sat",
"lnurl.fee.base.other": "{n} sats",
"lnurl.fee.percent": "{percent} %",
"home.topLnurl.title": "Bedste LNURL-mints",
"home.topLnurl.sub": "rangeret efter anmeldelser og bedømmelse",
"home.topLnurl.all": "Alle {n} LNURL-mints →",
"feed.ecosystem.lnurl": "LNURL",
"reviews.dialog.bodyPlaceholder.lnurl": "Hvordan gik mintning, smeltning og rotation af sedler? Vær konkret.",
"reviews.byUrl.open": "Skriv en anmeldelse",
"reviews.byUrl.title": "Skriv en anmeldelse",
"reviews.byUrl.lede": "Indsæt adressen. Kender siden den ikke endnu, bliver den tjekket og tilføjet nu.",
"reviews.byUrl.label.cashu": "Mint-URL",
"reviews.byUrl.label.fedimint": "Føderationens invite code",
"reviews.byUrl.label.lnurl": "URL til LNURL-mint",
"reviews.byUrl.placeholder.cashu": "mint.example.com",
"reviews.byUrl.placeholder.fedimint": "fed11…",
"reviews.byUrl.placeholder.lnurl": "mint.example.com",
"reviews.byUrl.check": "Tjek denne adresse",
"reviews.byUrl.checking": "Tjekker…",
"reviews.byUrl.back": "Brug en anden adresse",
"reviews.byUrl.existing": "Allerede på listen",
"reviews.byUrl.indexed": "Lige tilføjet",
"reviews.byUrl.confirmClose": "Luk? Adressen du skrev bliver ikke gemt.",
"reviews.byUrl.detected": "Den adresse svarede som en {kind}-mint.",
"reviews.byUrl.switchTo": "Anmeld den som {kind}-mint",
"reviews.byUrl.error.empty": "Skriv en adresse først.",
"reviews.byUrl.error.badUrl": "Det ligner ikke en mint-URL.",
"reviews.byUrl.error.badInvite": "Det er ikke en Fedimint invite code. Koder starter med fed11.",
"reviews.byUrl.error.badInput": "Siden kan ikke tjekke den adresse.",
"reviews.byUrl.error.blocked": "Det er ikke en offentlig adresse, så den kan ikke tjekkes.",
"reviews.byUrl.error.notAMint": "Adressen svarede, men ikke som en mint af den her slags.",
"reviews.byUrl.error.wrongType": "Adressen svarede, men som en anden slags mint.",
"reviews.byUrl.error.unverifiable": "Intet svarede på den adresse, og der er endnu ingen spor af den på Nostr.",
"reviews.byUrl.error.rateLimited.one": "Det er mange tjek fra én forbindelse. Prøv igen om cirka {n} minut.",
"reviews.byUrl.error.rateLimited.other": "Det er mange tjek fra én forbindelse. Prøv igen om cirka {n} minutter.",
"reviews.byUrl.error.network": "Indekset kunne ikke nås. Tjek din forbindelse, og prøv igen.",
"seo.lnurl.titleNoReviews": "{name}: info og anmeldelser af LNURL-minten | Cashumints.space",
"seo.desc.statusWithdraw": "{status}, udbetaling {min}–{max} sat.",
"mint.chip.noFunding": "Ingen mint / smelt",
"mint.warnings.lnurlWithdrawalsDisabled.lead": "Udbetalinger er slået fra.",
"mint.warnings.lnurlWithdrawalsDisabled.body.online": "Denne mint annoncerer en maksimal udbetaling på nul, så ingen seddel den udsteder kan indløses til noget. Lad være med at minte her, før det ændrer sig.",
"mint.warnings.lnurlWithdrawalsDisabled.body.offline": "Denne mint annoncerede en maksimal udbetaling på nul, sidst den blev nået, så ingen seddel den udstedte kunne indløses til noget. Lad være med at minte her, før det ændrer sig.",
"mint.warnings.lnurlWithdrawalsDisabled.meta.online": "Udbetalinger er slået fra lige nu",
"mint.warnings.lnurlWithdrawalsDisabled.meta.offline": "Udbetalinger var slået fra, sidst den blev set",
"mint.warnings.lnurlInvalid.lead": "Endepunktet svarer, men er ugyldigt.",
"mint.warnings.lnurlInvalid.body": "Værten svarer, men ikke med en mint-annoncering, denne side kan læse, så udbetalinger virker måske ikke. Alt nedenfor er den seneste tilstand, der kunne læses, og anmeldelser virker stadig.",
"mint.warnings.lnurlInvalid.meta": "Svarer, men ikke med en gyldig mint-annoncering",
"mint.warnings.lnurlNoFunding.lead": "Mintning og smeltning er utilgængelige.",
"mint.warnings.lnurlNoFunding.body": "Eksisterende sedler kan stadig roteres, deles eller lægges sammen, men lige nu bevæger intet sig ind eller ud via Lightning. Selve minten kører og svarer; Lightning-noden bag den kan ikke nås derfra.",
"mint.warnings.lnurlNoFunding.meta": "Mintning og smeltning utilgængelige",
"mint.warnings.also.noFunding": "Dens Lightning-node kunne heller ikke nås, så intet kunne mintes eller smeltes."
}
+788
View File
@@ -0,0 +1,788 @@
{
"site.name": "Cashumints.space",
"common.close": "Schließen",
"common.cancel": "Abbrechen",
"common.done": "Fertig",
"common.back": "Zurück",
"common.retry": "Erneut versuchen",
"common.connect": "Verbinden",
"common.copied": "Kopiert",
"common.copyFull": "Vollständigen Artikel kopieren: {kind}",
"common.previousPage": "Vorherige Seite",
"common.nextPage": "Nächste Seite",
"common.reviewPages": "Bewertungsseiten",
"time.never": "niemals",
"time.justNow": "gerade eben",
"time.none": "-",
"time.unknownDate": "ein unbekanntes Datum",
"time.ago.minute": "vor {n} Min.",
"time.ago.hour": "vor {n} Std.",
"time.ago.day": "vor {n} T.",
"time.ago.week": "vor {n} Wo.",
"time.ago.month": "vor {n} Mon.",
"time.ago.year": "vor {n} J.",
"time.short.minute": "{n}m",
"time.short.hour": "{n}h",
"time.short.day": "{n}d",
"time.short.week": "{n}w",
"time.short.month": "{n}mo",
"time.short.year": "{n}y",
"status.online": "Online",
"status.offline": "Offline",
"status.degraded": "Nicht erreichbar",
"status.unknown": "Noch nicht geprüft",
"nav.skip": "Zum Inhalt springen",
"nav.main": "Hauptseite",
"nav.mints": "Cashu-Mints",
"nav.reviews": "Rezensionen",
"nav.wallets": "Geldbörsen",
"nav.about": "Über",
"nav.searchMints": "Unter {n} Mints suchen",
"lang.label": "Sprache",
"lang.switcher": "Wählen Sie eine Sprache aus",
"footer.copyright": "© {year} Cashumints.space",
"footer.allMints": "Alle Cashu-Mints",
"footer.allReviews": "Alle Bewertungen",
"footer.wallets": "Geldbörsen",
"footer.about": "Über",
"footer.disclaimer": "Haftungsausschluss",
"footer.terms": "Nutzungsbedingungen",
"footer.privacy": "Datenschutz",
"footer.madeBy": "Mit ❤️ erstellt von {azzamo} · Unterstützt von Cashu und NIP-87",
"footer.warning": "Mints sind treuhänderisch. Ein Mint kann mit deinem Geld verschwinden.",
"footer.tagline": "Community-Bewertungen für ecash-Mints, signiert auf Nostr.",
"footer.col.explore": "Entdecken",
"footer.col.site": "Seite",
"footer.nav": "Fußzeile",
"home.title": "Cashumints.space · Finde ein Cashu-Mint, dem du vertrauen kannst",
"home.description": "Finde ein Cashu-Mint, dem du vertrauen kannst. {mints} Mints im Index, {reviews} auf Nostr signierte Community-Bewertungen, dazu Live-Verfügbarkeit, unterstützte NUTs und Mint-Software.",
"home.hero.title": "Finde ein Cashu-Mint",
"home.hero.titleEm": "auf die man sich verlassen kann",
"home.hero.lede": "Jede Mint im Nostr-Netzwerk, mit echten Bewertungen aus der Community. Keine Gatekeeper, nur signierte Bewertungen, die Sie selbst überprüfen können.",
"home.search.label": "Mints nach Name, Domain oder URL suchen",
"home.search.placeholder": "Suche nach Name, Domain oder füge eine Mint-URL ein…",
"home.filters.mostReviewed": "Am häufigsten bewertet",
"home.filters.highestRated": "Am besten bewertet",
"home.filters.recentlyActive": "Zuletzt aktiv",
"home.filters.websockets": "Unterstützt WebSockets",
"home.top.title": "Die besten Cashu-Mints",
"home.top.sub": "nach Bewertungen und Sternebewertung sortiert",
"home.top.all": "Alle {n} Mints →",
"home.latest.title": "Neueste Bewertungen",
"home.latest.sub": "Live aus dem Nostr-Netzwerk",
"home.latest.prev": "Frühere Rezensionen",
"home.latest.next": "Weitere Bewertungen",
"home.latest.all": "Alle Bewertungen →",
"home.latest.empty": "Derzeit konnten keine Bewertungen aus den Relays ausgelesen werden. Öffne eine beliebige Mint-Seite, um Bewertungen direkt zu lesen und zu schreiben.",
"home.why.title": "Warum sollte man diesen Bewertungen vertrauen?",
"home.why.signed.title": "Unterzeichnet, nicht eingereicht",
"home.why.signed.body": "Jede Rezension ist ein „{link}“-Ereignis, das mit dem Nostr-Schlüssel des Autors signiert ist. Nichts hier wird von uns bearbeitet, bewertet oder entfernt.",
"home.why.signed.link": "NIP-87",
"home.why.verify.title": "Überzeugen Sie sich selbst davon",
"home.why.verify.body": "Die Bewertungen werden auf öffentlichen Nostr-Relays gespeichert, nicht in unserer Datenbank. Wenn Sie einen beliebigen Client auf dieselben Relays verweisen, werden Ihnen dieselben Daten angezeigt.",
"home.why.checked.title": "Wird kontinuierlich überprüft",
"home.why.checked.body": "Wir überprüfen jede Mint-Version auf Verfügbarkeit, Softwareversion, Einschränkungen und unterstützte NUTs, sodass die angezeigten Spezifikationen aktuell sind und nicht auf Selbstangaben beruhen.",
"home.cta.title": "Du denkst über ein Mint nach? Lies erst nach.",
"home.cta.body": "Ein Mint verwahrt dein ecash. Lies, was andere herausgefunden haben, bevor du Sats einzahlst, und schreib eine Bewertung, wenn du selbst eines genutzt hast.",
"home.cta.explore": "Alle Mints entdecken",
"home.cta.learn": "Erfahren Sie mehr über Cashu",
"pulse.mintsIndexed": "Mints im Index",
"pulse.reviewsOnNostr": "Bewertungen zu Nostr",
"pulse.lastReview": "letzte Überprüfung",
"pulse.mintsOnline": "Mints online",
"pulse.indexer": "Indexierer",
"pulse.connected": "verbunden",
"pulse.stale": "abgestanden",
"mints.title": "Alle Cashu-Mints · Cashumints.space",
"mints.description": "Alle {total} Cashu-Mints aus dem Nostr-Netzwerk indexiert, {online} online und {offline} offline, mit Community-Bewertungen, Verfügbarkeit und Mint-Software. Durchsuche und sortiere die ganze Liste.",
"mints.heading": "Alle Cashu-Mints",
"mints.lede": "Jedes im Nostr-Netzwerk gefundene Mint, auch die, die es nicht mehr gibt. Offline-Mints bleiben gelistet, damit du sie weiterhin finden und bewerten kannst.",
"mints.search.label": "Such nach Mints nach Namen oder Domain",
"mints.search.placeholder": "Nach Name oder Domain suchen…",
"mints.sort.label": "Mints sortieren",
"mints.sort.score": "Gesamtbester",
"mints.sort.rating": "Am besten bewertet",
"mints.sort.reviews": "Am häufigsten bewertet",
"mints.sort.recent": "Kürzlich rezensiert",
"mints.sort.seen": "Kürzlich online angesehen",
"mints.hideOffline": "Als offline ausblenden",
"mints.showing": "{total} Mints angezeigt ({online} online, {offline} offline)",
"mints.showingFiltered": "Anzeige von „{shown}“ von „{total}“ Mints ({online} online, {offline} offline)",
"mints.noResults": "Es wurden keine Übereinstimmungen gefunden. Versuchen Sie es mit einer Domain oder fügen Sie eine Mint-URL ein.",
"mints.clearSearch": "Suche löschen",
"card.noRatings": "Noch keine Bewertungen",
"card.reviews.one": "{n} Bewertung",
"card.reviews.other": "{n} Bewertungen",
"card.lastSeen": "Zuletzt gesehen unter {when}",
"card.neverSeen": "noch nie online gesehen",
"card.reviewed": "Rezension zu „{when}“",
"card.noReviews": "noch keine Bewertungen",
"mint.title": "{name} · Bewertungen zum Cashu-Mint",
"mint.titleFedimint": "{name} · Bewertungen zur Fedimint-Föderation",
"mint.titleLnurl": "{name} · Bewertungen zum LNURL-Mint",
"mint.breadcrumb": "Navigationspfad",
"mint.crumbMints": "Cashu-Mints",
"mint.copyUrl": "Mint-URL kopieren",
"mint.qrButton": "Mint QR",
"mint.writeReview": "Eine Bewertung schreiben",
"mint.rankByScore": "#{rank} nach Punktestand",
"mint.tagLightning": "Lightning",
"mint.summary": "Mint-Zusammenfassung",
"mint.rating.label": "Community-Bewertung",
"mint.rating.none": "Noch keine Bewertungen",
"mint.rating.reviews.one": "{n} Bewertung",
"mint.rating.reviews.other": "{n} Bewertungen",
"mint.rating.unrated.one": "{n} hat keine Bewertung abgegeben",
"mint.rating.unrated.other": "{n} haben keine Bewertung abgegeben",
"mint.activity.label": "Aktivität",
"mint.activity.since": "seit der letzten Bewertung",
"mint.activity.noReviews": "noch keine Bewertungen",
"mint.activity.recent.one": "{n} Bewertung in den letzten 90 Tagen",
"mint.activity.recent.other": "{n} Bewertungen in den letzten 90 Tagen",
"mint.limits.label": "Limits",
"mint.limits.notPublished": "nicht veröffentlicht",
"mint.limits.min": "min",
"mint.limits.max": "max",
"mint.limits.mintRow": "minten",
"mint.limits.meltRow": "schmelzen",
"mint.limits.disabled": "deaktiviert",
"mint.limits.range": "{min} bis {max} {unit}",
"mint.limits.upTo": "bis zu {max} {unit}",
"mint.limits.from": "ab {min} {unit}",
"mint.software.label": "Mint-Software",
"mint.software.unknown": "unbekannt",
"mint.checked": "geprüft {when}",
"mint.lastSeen": "zuletzt gesehen {when}",
"mint.neverReached": "nie erreicht",
"mint.uptime.checks": "{ok} der letzten {total} Prüfungen erreichbar",
"mint.uptime.percent": "{percent}% erreichbar über 30 Tage",
"mint.uptime.description": "{ok} der letzten {total} Prüfungen erfolgreich",
"mint.operator.title": "Vom Betreiber",
"mint.operator.motd": "Nachricht des Tages",
"mint.operator.beta": "Diese Mint bezeichnet sich selbst als experimentell oder als Beta-Version. Lesen Sie die folgenden Aussagen des Betreibers, bevor Sie etwas dort hinterlegen, das Sie nicht verlieren dürfen.",
"mint.nuts.title": "Unterstützte NUTs",
"mint.nuts.noneOffline": "Für diese Mint wurde keine NUT-Liste zwischengespeichert; sie ging offline, bevor eine Liste erfasst werden konnte.",
"mint.nuts.noneYet": "Für dieses Mint wurde noch keine NUT-Liste zwischengespeichert.",
"mint.nuts.supported": "{n} von {total} bekannten NUTs unterstützt",
"mint.nuts.whatAre": "Was sind NUTs?",
"mint.nutRow.disabled": "deaktiviert",
"mint.nutRow.notSupported": "wird nicht unterstützt",
"mint.details.title": "Mint-Details",
"mint.details.pubkey": "Pubkey",
"mint.details.nostr": "Nostr",
"mint.details.email": "E-Mail",
"mint.details.serverTime": "Serverzeit",
"mint.details.firstSeen": "Erstmals gesehen",
"mint.details.terms": "Nutzungsbedingungen",
"mint.kind.identifier": "Bezeichner",
"mint.kind.mintPubkey": "Mint-Pubkey",
"mint.kind.nostrAddress": "Nostr-Adresse",
"mint.kind.emailAddress": "E-Mail-Adresse",
"mint.kind.npub": "npub",
"mint.disclaimer": "Diese Mint fungiert als Verwahrstelle: Sie verwahrt Ihr Geld und kann dies jederzeit einstellen. Bewertungen sind Meinungen der Community, und „online erreichbar“ bedeutet lediglich, dass die Plattform online verfügbar ist – nicht, dass sie vertrauenswürdig ist.",
"mint.disclaimerLink": "Lesen Sie den vollständigen Haftungsausschluss",
"mint.qr.title": "Füge „{name}“ zu deinem Wallet hinzu",
"mint.qr.note": "Scanne das mit einer Cashu-Wallet, um das Mint hinzuzufügen, oder kopiere die URL und füge sie ein.",
"mint.qr.warn": "Ein Mint hinzuzufügen heißt, ihm dein Geld anzuvertrauen.",
"mint.qr.whatMeans": "Was das bedeutet",
"mint.qr.wallets": "Wallets, die Cashu unterstützen",
"mint.chip.frozen": "Gefroren",
"mint.chip.noWithdrawals": "Keine Abhebungen",
"mint.chip.meltOnly": "Nur schmelzen",
"mint.warnings.dayCount.one": "{n}-Tag",
"mint.warnings.dayCount.other": "{n}-Tage",
"mint.warnings.gone.lead": "Wahrscheinlich weg.",
"mint.warnings.gone.body.since": "Diese Mint ist seit dem {date} ({days}) nicht mehr erreichbar. Betrachten Sie die dort hinterlegten Gelder als verloren und überweisen Sie keine neuen Gelder mehr dorthin. Die folgenden Angaben zeigen den letzten bekannten Status.",
"mint.warnings.gone.body.neverDated": "Dieses Mint hat seit Beginn unserer Beobachtung im {month} auf keine einzige Prüfung geantwortet. Betrachte dort verwahrtes Geld als gefährdet und zahle nichts Neues ein. Alles Folgende stammt aus der Nostr-Ankündigung, nicht vom Mint.",
"mint.warnings.gone.body.never": "Dieses Mint hat seit Beginn unserer Beobachtung auf keine einzige Prüfung geantwortet. Betrachte dort verwahrtes Geld als gefährdet und zahle nichts Neues ein. Alles Folgende stammt aus der Nostr-Ankündigung, nicht vom Mint.",
"mint.warnings.gone.meta.since": "Offline seit {date}",
"mint.warnings.gone.meta.never": "Seit seiner Entdeckung war es nie erreichbar",
"mint.warnings.meltDisabled.lead": "Auszahlungen sind deaktiviert.",
"mint.warnings.meltDisabled.body.online": "Bei dieser Mint ist die Funktion „Lightning Melting“ (NUT-05) deaktiviert: Sie können Sats einzahlen, aber nicht über Lightning abheben. Bitte nehmen Sie keine Einzahlungen vor, bis die Funktion wieder verfügbar ist.",
"mint.warnings.meltDisabled.body.offline": "Bei dieser Mint war die Funktion „Lightning Melting“ (NUT-05) deaktiviert: Sie können Sats einzahlen, aber nicht über Lightning abheben. Bitte nehmen Sie keine Einzahlungen vor, bis die Funktion wieder verfügbar ist.",
"mint.warnings.meltDisabled.meta.online": "Auszahlungen sind derzeit deaktiviert",
"mint.warnings.meltDisabled.meta.offline": "Auszahlungen waren zuletzt deaktiviert.",
"mint.warnings.frozen.lead": "Mint eingefroren.",
"mint.warnings.frozen.body.online": "Minten und Melten sind beide deaktiviert. Über Lightning können keine Sats rein oder raus.",
"mint.warnings.frozen.body.offline": "Minten und Melten waren beide deaktiviert. Über Lightning können keine Sats rein oder raus.",
"mint.warnings.frozen.meta.online": "Derzeit ist alles eingefroren, es kommt nichts herein und nichts geht hinaus",
"mint.warnings.frozen.meta.offline": "Zuletzt gesehen: eingefroren",
"mint.warnings.offlineLong.lead": "Für „{days}“ offline, da {date}.",
"mint.warnings.offlineLong.body": "Das Mint ist womöglich weg. Alles Folgende ist sein letzter bekannter Stand, und Bewertungen funktionieren weiterhin.",
"mint.warnings.offlineLong.meta": "Offline seit {date}",
"mint.warnings.meltOnly.lead": "Nur schmelzen.",
"mint.warnings.meltOnly.body.online": "Neues Minten (NUT-04) ist deaktiviert: Einzahlen geht nicht, vorhandenes ecash lässt sich aber weiterhin auszahlen. Das heißt oft, dass ein Mint den Betrieb einstellt. Wenn du sein ecash hältst, denk übers Melten nach.",
"mint.warnings.meltOnly.body.offline": "Neues Minten (NUT-04) war deaktiviert: Einzahlen geht nicht, vorhandenes ecash lässt sich aber weiterhin auszahlen. Das heißt oft, dass ein Mint den Betrieb einstellt. Wenn du sein ecash hältst, denk übers Melten nach.",
"mint.warnings.meltOnly.meta.online": "Derzeit nur schmelzen",
"mint.warnings.meltOnly.meta.offline": "Erst schmelzen, wenn zuletzt gesehen",
"mint.warnings.offline.lead": "Seit dem {date} offline.",
"mint.warnings.offline.body": "Die Details wurden damals zuletzt bestätigt. Sie können weiterhin eine Bewertung abgeben.",
"mint.warnings.offline.meta": "Offline seit {date}",
"mint.warnings.also.meltDisabled": "Bevor es offline ging, hatte es zudem Auszahlungen deaktiviert.",
"mint.warnings.also.mintDisabled": "Vor dem Offlinegehen hatte es auch neues Minten deaktiviert.",
"mint.warnings.also.offline": "Außerdem ist die Website „{date}“ ({days}) seit einiger Zeit nicht mehr erreichbar.",
"nut.0": "Notation und Modelle",
"nut.1": "Austausch öffentlicher Schlüssel bei Mint",
"nut.2": "Schlüsselsätze und Gebühren",
"nut.3": "Token tauschen",
"nut.4": "Minten über Lightning",
"nut.5": "Melten über Lightning",
"nut.6": "Mint-Informationen",
"nut.7": "Überprüfung des Token-Status",
"nut.8": "Zu viel gezahlte Lightning-Gebühren",
"nut.9": "Signaturen wiederherstellen",
"nut.10": "Ausgabebedingungen",
"nut.11": "An öffentlichen Schlüssel zahlen",
"nut.12": "DLEQ-Prüfberichte",
"nut.13": "Deterministische Geheimnisse",
"nut.14": "Verträge mit Hash-basierter Zeitverriegelung",
"nut.15": "Zahlungen über mehrere Kanäle",
"nut.16": "Animierte QR-Codes",
"nut.17": "WebSocket-Abonnements",
"nut.18": "Zahlungsaufforderungen",
"nut.19": "Zwischengespeicherte Antworten",
"nut.20": "Signierte Mint-Quotes",
"nut.21": "Authentifizierung löschen",
"nut.22": "Blinde Authentifizierung",
"nut.23": "Zahlungsmethode BOLT11",
"nut.24": "HTTP 402 – Zahlung erforderlich",
"nut.25": "Zahlungsmethode BOLT12",
"reviews.title": "Rezensionen",
"reviews.anon": "Anonym",
"reviews.anonTitle": "Für diesen Schlüssel wurde kein öffentliches Nostr-Profil gefunden.",
"reviews.nip05Title": "NIP-05-Kennung (von dieser Website nicht überprüft)",
"reviews.filter.group": "Bewertungen filtern",
"reviews.filter.all": "Alle",
"reviews.filter.five": "5★",
"reviews.filter.critical": "Kritisch",
"reviews.filter.recent": "Aktuell",
"reviews.filter.hideAnon": "Anonym ausblenden",
"reviews.anonHidden.other": "{n} von {total} Bewertungen werden angezeigt: anonym, verborgen.",
"reviews.allAnon.one": "Die einzige Bewertung hier stammt von einem anonymen Nutzer.",
"reviews.allAnon.other": "Alle Bewertungen zu „{n}“ hier sind anonym.",
"reviews.allAnonShow.one": "Zeige es",
"reviews.allAnonShow.other": "Zeig es ihnen",
"reviews.loading.one": "Lade... Testbericht zu „{n}“ von Nostr Relays",
"reviews.loading.other": "{n}-Bewertungen von Nostr-Relais werden geladen",
"reviews.empty": "Es gibt noch keine Bewertungen. Schreiben Sie die erste Bewertung.",
"reviews.emptyWritten": "Hier gibt es noch keine schriftlichen Bewertungen. Schreiben Sie die erste Bewertung.",
"reviews.emptyFilter": "Es gibt keine Bewertungen, die diesem Filter entsprechen.",
"reviews.rate.cashu": "Diese Mint bewerten",
"reviews.rate.fedimint": "Diese Föderation bewerten",
"reviews.rate.lnurl": "Diese Mint bewerten",
"reviews.noscript": "Die Bewertungen werden auf Nostr signiert und in Ihrem Browser geladen, daher benötigt diese Liste JavaScript. Die Bewertungen und Zahlen oben stammen von der Website selbst.",
"reviews.relayError": "Das Relais konnte nicht erreicht werden. Die Bewertungen sind möglicherweise unvollständig.",
"reviews.srRating": "{rating} von 5",
"reviews.srNoRating": "Keine Bewertung abgegeben",
"reviews.readMore": "Weiterlesen",
"reviews.readLess": "Weniger anzeigen",
"reviews.actions.copyNpub": "npub kopieren",
"reviews.actions.openNostr": "Diese Veranstaltung auf Nostr ansehen",
"reviews.actions.copyLink": "Link zu dieser Rezension kopieren",
"reviews.anonNote": "Erste Rezension von diesem Verlag, keine weiteren Aktivitäten gefunden",
"reviews.published": "Veröffentlicht.",
"reviews.publishedPartial": "Veröffentlicht auf {accepted} der {total}-Relais.",
"reviews.propagating": "Die Übertragung kann einen Moment dauern, bis sie an anderer Stelle angezeigt wird.",
"reviews.summary.line.one": "{count}-Bewertung ohne Kommentar: {breakdown}",
"reviews.summary.line.other": "{count}-Bewertungen ohne Kommentar: {breakdown}",
"reviews.summary.lineDay.one": "{count}-Bewertung ohne Kommentar auf {when}: {breakdown}",
"reviews.summary.lineDay.other": "{count}-Bewertungen ohne Kommentar auf {when}: {breakdown}",
"reviews.summary.showOne": "Zeige es",
"reviews.summary.showMany": "Zeig es ihnen",
"reviews.breakdown.5": "{n}: Fünf Sterne",
"reviews.breakdown.4": "{n}, Vier-Sterne-Hotel",
"reviews.breakdown.3": "{n}, drei Sterne",
"reviews.breakdown.2": "{n}, zwei Sterne",
"reviews.breakdown.1": "{n}, ein Stern",
"reviews.breakdown.none": "{n}, ohne Bewertung",
"reviews.dialog.title": "Rezension: „{name}“",
"reviews.dialog.lede.cashu": "Teilen Sie Ihre Erfahrung mit dieser Mint.",
"reviews.dialog.lede.fedimint": "Teilen Sie Ihre Erfahrung mit dieser Föderation.",
"reviews.dialog.lede.lnurl": "Teilen Sie Ihre Erfahrung mit dieser Mint.",
"reviews.dialog.permanent": "Wird dauerhaft auf öffentlichen Nostr-Relays veröffentlicht.",
"reviews.dialog.gate.title": "Melden Sie sich an, um eine Bewertung zu schreiben",
"reviews.dialog.gate.lead": "Bewertungen werden mit Ihrem Nostr-Schlüssel signiert. Er bleibt in Ihrem Signierer, diese Website sieht ihn nie.",
"reviews.dialog.ratingLegend": "Ihre Bewertung",
"reviews.dialog.starsSr.one": "{n}-Star",
"reviews.dialog.starsSr.other": "{n}-Stars",
"reviews.dialog.chooseRating": "Wählen Sie eine Bewertung aus",
"reviews.dialog.starLabel": "{n}: {phrase}",
"reviews.dialog.stars.1": "das vermeiden",
"reviews.dialog.stars.2": "hatte Probleme",
"reviews.dialog.stars.3": "hat funktioniert, allerdings mit Einschränkungen",
"reviews.dialog.stars.4": "gut",
"reviews.dialog.stars.5": "funktioniert super",
"reviews.dialog.bodyLabel": "Was sollten andere Menschen wissen?",
"reviews.dialog.charCount": "{n} / {max}",
"reviews.dialog.submit": "Unterzeichnen und veröffentlichen",
"reviews.dialog.signing": "Unterzeichnung…",
"reviews.dialog.publishing": "Veröffentlichung…",
"reviews.dialog.publishingTo": "Veröffentlichung auf Relays ({accepted}/{total})",
"reviews.dialog.waitingExtension": "Wir warten auf Ihre Verlängerung",
"reviews.dialog.waitingSigner": "Warten auf Ihren Unterzeichner",
"reviews.dialog.confirmClose": "Diese Bewertung schließen? Ihr Entwurf wird gespeichert, bis Sie die Website verlassen.",
"reviews.dialog.why.login": "Anmelden, um zu veröffentlichen",
"reviews.dialog.why.words": "Schreib ein paar Worte",
"reviews.dialog.why.more.one": "{n}t ein noch charakterstärkerer Charakter",
"reviews.dialog.why.more.other": "{n} weitere Zeichen",
"reviews.dialog.why.over.one": "{n}-Zeichen überschreiten den Grenzwert",
"reviews.dialog.why.over.other": "{n}-Zeichen überschreiten das Limit",
"reviews.dialog.identity.needSigner": "Zum Veröffentlichen benötigen Sie einen Nostr-Signierer. Ihr Schlüssel verbleibt darin; diese Website hat keinen Zugriff darauf.",
"reviews.dialog.identity.loginToPublish": "Anmelden, um zu veröffentlichen",
"reviews.dialog.identity.switch": "Umschalter",
"reviews.dialog.identity.extension": "Anmeldung über Ihre Browser-Erweiterung",
"reviews.dialog.identity.readingKey": "Deinen öffentlichen Schlüssel wird geladen…",
"reviews.dialog.identity.willAsk": "Ihre Erweiterung wird aufgefordert, eine Signatur zu erstellen",
"reviews.dialog.error.noRelay": "Kein Relais hat die Überprüfung akzeptiert. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
"reviews.validate.rating": "Wählen Sie eine Bewertung von 1 bis 5 Sternen aus.",
"reviews.validate.short": "Schreibe mindestens {n} Zeichen, damit die Bewertung hilfreich ist.",
"reviews.validate.long": "Rezensionen sind auf {n} Zeichen begrenzt.",
"feed.title": "Aktuelle Bewertungen zu Mints und Föderationen · Cashumints.space",
"feed.description": "Jede Bewertung von Mints, Föderationen und LNURL-Mints, sobald sie veröffentlicht wird: {reviews} Bewertungen zu {mints} Cashu-Mints, {federations} Fedimint-Föderationen und {lnurl} LNURL-Mints, auf Nostr signiert und hier neueste zuerst lesbar. Filtere nach Ökosystem, Note oder Name.",
"feed.heading": "Neueste Bewertungen",
"feed.lede": "Jede Bewertung zu jedem Cashu-Mint und jeder Fedimint-Föderation, neueste zuerst, live von den Nostr-Relays gelesen. Jede ist von ihrem Autor signiert und liegt auf den Relays, nicht in unserer Datenbank.",
"feed.label": "Rezensionen",
"feed.showingBuild.one": "Anzeige der aktuellsten Überprüfung aus dem letzten Build",
"feed.showingBuild.other": "Anzeige der neuesten Bewertungen für „{n}“ aus dem letzten Build",
"feed.noscript": "Die Bewertungen sind auf Nostr signiert und werden in Ihrem Browser geladen, daher sind für die Filterfunktion und die vollständige Liste JavaScript erforderlich. Die unten aufgeführten Bewertungen zu „{n}“ wurden beim Erstellen dieser Seite aus den Relays ausgelesen.",
"feed.emptyBuild": "Zum Zeitpunkt der Erstellung dieser Seite konnten keine Rückmeldungen von den Relais ausgelesen werden. Die Liste wird in Ihrem Browser automatisch mit den Daten der Relais aktualisiert.",
"feed.foot": "Bewertungen sind Meinungen der Community, keine geprüften Angaben. Lies den {link}, bevor du einem Mint dein Geld anvertraust.",
"feed.footLink": "Haftungsausschluss",
"feed.showing": "{shown} von {total} Bewertungen werden angezeigt",
"feed.showingMatching": "{shown} von {matching} passenden Bewertungen werden angezeigt (insgesamt {total})",
"feed.emptyRelays": "Von den Relays wurden keine Bewertungen zurückgemeldet. Öffne eine beliebige Mint-Seite, um dort Bewertungen zu lesen und zu schreiben.",
"feed.emptyFilter": "Keine Bewertungen passen zu diesem Filter. Leere das Mint-Feld oder probier Alle.",
"feed.relayError": "Die Relais konnten nicht erreicht werden. Es werden die Bewertungen aus dem letzten Build angezeigt.",
"feed.relayEmpty": "Von den Relais wurden keine Rückmeldungen erhalten. Es werden die Rückmeldungen aus dem letzten Build angezeigt.",
"feed.reading": "Die Relais ablesen…",
"login.open": "Anmelden",
"login.title": "Mit Nostr anmelden",
"login.note": "Ihr Schlüssel verbleibt in Ihrem Signaturprogramm. Diese Website hat niemals Zugriff darauf.",
"login.extension.name": "Browser-Erweiterung",
"login.extension.desc": "Vertragsunterzeichnung mit Alby, nos2x oder einer anderen NIP-07-Verlängerung",
"login.extension.none": "Es wurde keine Erweiterung gefunden",
"login.extension.getOne": "Hol dir eins:",
"login.remote.name": "Fernunterzeichner",
"login.remote.desc": "Scannen Sie einen Code mit einer Signatur-App oder fügen Sie einen Bunker-Link ein",
"login.primal.name": "Ursprünglich",
"login.primal.desc": "Bestätigen Sie die Anmeldung in der Primal-App auf Ihrem Smartphone",
"login.paste.label": "Oder fügen Sie den Verbindungslink ein, den Sie von Ihrem Unterzeichner erhalten haben",
"login.copyLink": "Kopiere den Verbindungslink",
"login.whatIsNostr": "Was ist Nostr?",
"login.connect.remote.title": "Einen Remote-Unterzeichner verbinden",
"login.connect.remote.lead": "Scannen Sie diesen Link mit Ihrer Signatur-App oder kopieren Sie ihn und fügen Sie ihn dort ein.",
"login.connect.remote.hint": "Funktioniert mit jedem NIP-46-Signaturprogramm: nsec.app, Amber, Nostrify, Ihrem eigenen Bunker.",
"login.connect.remote.open": "In Ihrer Signatur-App öffnen",
"login.connect.primal.title": "Mit Primal in Kontakt treten",
"login.connect.primal.lead": "Öffnen Sie „Primal“ auf Ihrem Smartphone, suchen Sie in den Einstellungen den Punkt „Fernanmeldung“ und scannen Sie diesen Code.",
"login.connect.primal.hint": "Primal signiert für andere Apps gemäß NIP-46, es handelt sich also um dieselbe Verbindung, die jeder Signierer nutzt, und Ihr Schlüssel verbleibt bei Primal. {link}",
"login.connect.primal.open": "In Primal öffnen",
"login.status.buildingLink": "Eine Verbindung herstellen",
"login.status.waitingExtension": "Wir warten auf Ihre Verlängerung",
"login.status.waitingSigner": "Warten auf Ihren Unterzeichner",
"login.status.connecting": "Verbindung zum Unterzeichner herstellen",
"login.status.connected": "Verbunden",
"login.toast.loggedIn": "Angemeldet",
"login.toast.fetchingProfile": "Dein Nostr-Profil wird von den Relays geladen…",
"login.toast.profile": "Profil: {name}",
"login.toast.noProfile": "Kein Profil auf den Relays gefunden",
"login.method.extension": "Erweiterung",
"login.method.remote": "Fernbedienung",
"login.method.primal": "Ursprünglich",
"login.account": "Ihr Nostr-Konto",
"login.copyNpub": "Kopiere deine vollständige npub-Nummer",
"login.logout": "Abmelden",
"signer.status.waitingExtension": "Wir warten auf Ihre Verlängerung",
"signer.status.signed": "Unterzeichnet",
"signer.status.reconnecting": "Erneut eine Verbindung zu Ihrem Unterzeichner herstellen",
"signer.status.approveWindow": "Der Unterzeichner benötigt Ihre Zustimmung in einem separaten Fenster.",
"signer.status.waitingSigner": "Warten auf Ihren Unterzeichner",
"signer.status.stillWaiting": "Ich warte immer noch auf die Antwort Ihres Unterzeichners",
"signer.status.connected": "Verbunden",
"signer.error.noExtension": "Es wurde keine Signaturerweiterung gefunden. Installieren Sie Alby oder nos2x und laden Sie diese Seite anschließend neu.",
"signer.error.privateKey": "Das sieht nach einem privaten Schlüssel aus, nicht nach einem Verbindungslink. Diese Website verlangt niemals einen Schlüssel. Fügen Sie den Link „bunker://“ ein, den Sie von Ihrem Signierer erhalten haben.",
"signer.error.badLink": "Das ist kein Link, den diese Website versteht. Fügen Sie den Link „bunker://“ aus Ihrem Signaturprogramm ein.",
"signer.error.noAnswer": "Ihr Signierer hat nicht geantwortet. Möglicherweise ist die Verbindung geschlossen oder der Relay-Server ist ausgefallen. Öffnen Sie die Verbindung und versuchen Sie es erneut.",
"signer.error.timeout": "Ihr Signaturgerät hat nicht rechtzeitig reagiert. Öffnen Sie es, überprüfen Sie, ob es noch angeschlossen ist, und versuchen Sie es dann erneut.",
"signer.error.cancelled": "Verbindung abgebrochen.",
"signer.error.declined": "Die Signaturanforderung wurde abgelehnt",
"notfound.title": "Seite nicht gefunden · Cashumints.space",
"notfound.description": "Diese Seite existiert auf Cashumints.space nicht.",
"notfound.heading": "Diese Seite existiert nicht",
"notfound.lede": "Möglicherweise wurde sie verschoben, oder die Mint ist noch nicht indexiert. Suchen Sie nach einer Mint anhand des Namens oder der Domain, oder fügen Sie die URL der Mint ein.",
"notfound.browse": "Alle Mints ansehen",
"notfound.home": "Geh nach Hause",
"notfound.loading": "{host} wird geladen",
"notfound.fb.rating": "Bewertung",
"notfound.fb.reviews": "Rezensionen",
"notfound.fb.software": "Software",
"notfound.fb.lastOnline": "Zuletzt online",
"notfound.fb.note": "Dieser Mint wurde nach dem letzten Build der Seite indexiert. Das hier ist eine Zusammenfassung aus der Live-API, mit den Reviews darunter. Die vollständige Seite erscheint beim nächsten Build.",
"notfound.resolve.checking": "Erster Besuch: dieser Mint wird gerade geprüft.",
"notfound.resolve.failed": "Dieser Mint konnte nicht aufgenommen werden",
"notfound.resolve.failedFedimint": "Diese Föderation konnte nicht aufgenommen werden",
"notfound.resolve.retry": "Andere Adresse versuchen",
"notfound.resolve.switch": "Als {kind} Mint öffnen",
"notfound.resolve.fedimint": "Die Seitenadresse einer Föderation enthält ihren Invite Code nicht, deshalb lässt sich diese nicht allein über den Link finden. Füg den Invite Code ein, dann wird sie aufgenommen.",
"notfound.resolve.ambiguous": "Diese Adresse lässt sich nicht allein aus dem Link ableiten. Füg die vollständige URL des Mint ein, dann wird er aufgenommen.",
"wallets.title": "Cashu-Wallets · Cashumints.space",
"wallets.description": "Cashu-Wallets, die mit den hier aufgeführten Mints kompatibel sind – auf Mobilgeräten, im Web, im Browser und über die Befehlszeile –, wobei jedes seine jeweiligen Stärken hat.",
"wallets.heading": "Geldbörsen",
"wallets.lede": "Du brauchst eine Wallet, um ecash von einem dieser Mints zu halten. Die meisten Wallets sprechen mit mehreren Mints gleichzeitig, du bist also nicht an das erste gebunden, das du ausprobierst.",
"wallets.note": "Diese Liste erhebt keinen Anspruch auf Vollständigkeit und stellt keine Empfehlung dar. Das Cashu-Projekt führt unter {link} ein umfassenderes Verzeichnis. Eine Wallet verwahrt die Schlüssel, eine Mint verwahrt das Geld: Lies dir die Bewertungen der Mints durch, bevor du dich entscheidest, wo deine Sats aufbewahrt werden sollen.",
"wallets.platform.android": "Android",
"wallets.platform.ios": "iOS",
"wallets.platform.web": "Web",
"wallets.platform.extension": "Browser-Erweiterung",
"wallets.platform.cli": "CLI",
"wallets.platform.python": "Python",
"wallets.item.minibits": "Mobile ecash-Wallet mit eigenem Mint, Unterstützung für Lightning-Adressen und Nostr-Kontakten.",
"wallets.item.nutstash": "Browser-Wallet, das mehrere Mints gleichzeitig verwaltet und Raw-Token importiert und exportiert.",
"wallets.item.enuts": "Mobile Wallet mit Fokus auf Guthaben bei mehreren Mints und dem Versenden von ecash über Nostr.",
"wallets.item.cashume": "Referenz-Web-Wallet aus dem Cashu-Projekt. Läuft vollständig im Browser und kann als PWA installiert werden.",
"wallets.item.boardwalk": "Web-Wallet rund um Lightning-Adressen und das Versenden von ecash per Link.",
"wallets.item.coinos": "Lightning-Wallet und Kassensystem, das zudem über eine eigene Cashu-Mint verfügt.",
"wallets.item.oxchat": "Nostr Messenger mit integrierter eCash-Geldbörse zum Versenden von Sats in Chats.",
"wallets.item.nutshell": "Die Referenzimplementierung. Läuft als Wallet über die Befehlszeile und als Mint-Software.",
"about.title": "Über Cashumints.space",
"about.description": "Was Cashu ist, was eine „Mint“ eigentlich darstellt, was NIP-87 ist und wie diese Website „Mints“ indexiert und Bewertungen von Nostr-Relays ausliest.",
"about.heading": "Über",
"about.lede": "Cashumints.space listet alle Cashu-Mints auf, die wir im Nostr-Netzwerk finden können, und zeigt Ihnen, was andere darüber sagen. Die Seite wurde von Azzamo erstellt.",
"about.cashu.title": "Was ist Cashu?",
"about.cashu.p1": "Cashu ist ein ecash-Protokoll für Bitcoin. Ein Mint gibt blinde Token aus, die durch die von ihm gehaltenen Sats gedeckt sind, und diese Token wandern zwischen Menschen, ohne dass das Mint erfährt, wer wem was geschickt hat. Die Privatsphäre ist echt und sie ist der Punkt: Token sind keine Konten, und das Mint kann den Token, den es signiert hat, nicht mit dem Token verknüpfen, der zurückkommt.",
"about.cashu.p2": "Das Protokoll und die dazugehörigen Spezifikationen können Sie unter {link} einsehen. Die Spezifikationen selbst werden als „NUTs“ bezeichnet – daher stammen auch die NUT-Nummern auf jeder Mint-Seite.",
"about.mints.title": "Was eine „Mint“ ist und welche Vor- und Nachteile sie mit sich bringt",
"about.mints.p1": "Ein Mint ist treuhänderisch. Wenn du ecash mintest, gibst du Sats an denjenigen, der diesen Server betreibt, und vertraust darauf, dass er die Token einlöst, wenn du sie wieder meltest. Es gibt keinen Betrugsbeweis, keine Treuhand und keinen Rechtsweg. Ein Mint-Betreiber kann mit dem Guthaben verschwinden, und Mints haben das getan.",
"about.mints.p2": "Genau deshalb gibt es hier Bewertungen. Diese Website sagt dir nie, dass ein Mint sicher ist. Der Status auf einer Mint-Seite sagt {code}, nicht „vertrauenswürdig“, und er heißt nur, dass der Server geantwortet hat, als wir zuletzt gefragt haben. Behandle ecash-Guthaben wie Bargeld in der Manteltasche: gut zum Ausgeben, falsch zum Sparen.",
"about.nip87.title": "Was ist NIP-87?",
"about.nip87.p1": "NIP-87 ist die Nostr-Konvention, um ecash-Mints anzukündigen und zu empfehlen. Zwei Event-Arten sind hier wichtig:",
"about.nip87.li1": "{code} kündigt eine neue „Mint“ an. Die URL der „Mint“ befindet sich im Tag „{tag}“. Auf diese Weise erkennt die Website neue „Mints“, die ihr bisher noch nicht bekannt waren.",
"about.nip87.li2": "„{code}“ ist eine Bewertung. Sie enthält die Bewertung und den Bewertungstext und verweist mit einem „{utag}“-Tag (der Mint-URL) und einem „{dtag}“-Tag (dem öffentlichen Schlüssel der Mint) auf die betreffende Mint.",
"about.nip87.p2": "Jede Bewertung ist mit dem Nostr-Schlüssel ihres Autors signiert. Wir nennen eine Bewertung nie „verifiziert“, denn niemand hat sie verifiziert: sie ist signiert, was eine andere und nützlichere Aussage ist. Das heißt, du kannst prüfen, wer sie geschrieben hat und dass sie nicht verändert wurde. Es heißt nicht, dass der Autor ehrlich ist oder das Mint je genutzt hat.",
"about.how.title": "So funktioniert diese Website",
"about.how.p1": "Es gibt zwei Schleifen und eine Regel. Die Regel lautet: dem Mint gehören seine Metadaten und Nostr gehören die Bewertungen, also speichert diese Website beides nicht als maßgebliche Quelle.",
"about.how.li1": "{strong} fragt alle bekannten Mint-Server alle zehn Minuten nach „{code}“ ab und protokolliert die Antwort: Name, Beschreibung, Version, unterstützte NUTs, Einschränkungen, Kontaktdaten. Außerdem wird erfasst, ob der Mint-Server überhaupt geantwortet hat – daher stammt auch der Begriff „Verfügbarkeit“.",
"about.how.li1Label": "Die Messschleife",
"about.how.li2": "{strong} stellt stündlich eine Verbindung zu den Nostr-Relays her, liest Ankündigungen und Bewertungen zu den Mints und fügt alle Mints hinzu, die es zuvor noch nicht gesehen hat.",
"about.how.li2Label": "Der Entdeckungskreislauf",
"about.how.p2": "Entscheidend ist, was passiert, wenn ein Mint nicht mehr antwortet. Seine zwischengespeicherten Metadaten werden von einer fehlgeschlagenen Abfrage nie überschrieben, also behält ein totes Mint seine vollständige Seite: seine Daten, seine Historie und seine Bewertungen, mit einem Banner, das sagt, wann es verstummt ist. Die vorherige Version dieser Website zeigte stattdessen eine Fehlerseite, was hieß, dass genau die Mints, vor denen sich Leute am dringendsten warnen mussten, nicht bewertet werden konnten. Das war der Hauptgrund für den Neubau.",
"about.how.p3": "Bewertungstexte werden bei uns niemals gespeichert. Wenn Sie eine Mint-Seite öffnen, kommuniziert Ihr Browser direkt mit den Relays und liest die Bewertungen selbst aus; wenn Sie eine Bewertung verfassen, wird diese von Ihrer eigenen Nostr-Erweiterung signiert und direkt an diese Relays gesendet. Wir führen lediglich eine Zählung, damit die Seiten bewertet und sortiert werden können – mehr nicht. Nichts davon kann von uns bearbeitet oder gelöscht werden, auch keine Bewertungen, die wir lieber nicht hätten.",
"about.relays.title": "Relais, die wir gelesen haben",
"about.relays.p1": "Dies sind die Relays, die sowohl der Indexer als auch Ihr Browser verwenden:",
"about.relays.p2": "Richten Sie einen beliebigen Nostr-Client auf dieselben Relays aus, und Sie werden dieselben Ereignisse sehen. Das ist das ganze Versprechen: Nichts hier gilt nur deshalb als wahr, weil wir es so sagen.",
"about.nuts.title": "Was sind NUTs?",
"about.nuts.p1": "NUTs sind die nummerierten Cashu-Spezifikationen, ähnlich wie die BIPs bei Bitcoin. Eine Mint gibt an, welche davon sie implementiert, und daran lässt sich erkennen, was sie tatsächlich leisten kann: NUT-04 und NUT-05 ermöglichen das Prägen und Einlösen über Lightning (ohne sie ist eine Mint kaum von Nutzen), NUT-07 ermöglicht es einer Wallet, zu überprüfen, ob ein Token bereits ausgegeben wurde, NUT-17 liefert Wallets Live-Updates über WebSockets anstelle von Polling, und NUT-20 ermöglicht es einer Wallet, nachzuweisen, dass ein Mint-Angebot tatsächlich an sie ausgestellt wurde. Die vollständige Liste finden Sie unter {link}.",
"about.numbers.title": "Im Moment",
"about.numbers.p1": "{mints}-Mints indiziert, {online} davon antworten, {reviews} Bewertungen, gezählt anhand der oben genannten Relays.",
"about.source.title": "Quelle und Bildnachweis",
"about.source.p1": "Die Website ist als Open-Source-Projekt unter {github} verfügbar. Sie wird von {azzamo} erstellt und betrieben. Sollte auf einer Mint-Seite ein Fehler vorliegen, korrigieren Sie diesen bitte direkt an der Quelle: Die Metadaten der Mints stammen von der jeweiligen Mint-eigenen Seite unter {code}, und die Bewertungen stammen von Nostr.",
"terms.title": "Nutzungsbedingungen · Cashumints.space",
"terms.description": "Nutzungsbedingungen für Cashumints.space: was die Website ist, was sie nicht tut und wer wofür verantwortlich ist. Keine Konten, keine Gewährleistung, keine Verbindung zu irgendeinem Mint.",
"terms.heading": "Nutzungsbedingungen",
"terms.lede": "Einfach ausgedrückt: Eine Website, die ausschließlich öffentliche Daten indexiert. Zuletzt aktualisiert am {date}.",
"terms.what.title": "Was diese Website ist",
"terms.what.p1": "Cashumints.space ist ein Verzeichnis öffentlich zugänglicher Daten zu Cashu-Mints. Es gibt zwei Arten von Daten, die beide aus anderen Quellen stammen:",
"terms.what.li1": "Metadaten der Mint, die vom jeweiligen Endpunkt „{code}“ der jeweiligen Mint abgerufen werden: Name, Beschreibung, Softwareversion, unterstützte NUTs, Kontaktdaten und die Angabe, ob die Mint bei unserer letzten Anfrage geantwortet hat.",
"terms.what.li2": "Bewertungen, die von öffentlichen Nostr-Relays abgerufen werden. Dabei handelt es sich um NIP-87-Ereignisse, die von ihren Autoren auf Relays veröffentlicht wurden, die jeder abfragen kann.",
"terms.what.p2": "Wir erfassen diese Daten, normalisieren sie, ordnen sie nach Relevanz und stellen sie dar. Wir erstellen sie nicht selbst.",
"terms.noAccounts.title": "Keine Angaben, keine Gewährleistungen",
"terms.noAccounts.p1": "Es ist keine Registrierung erforderlich. Die Website wird ohne Mängelgewähr und ohne jegliche Gewährleistung bereitgestellt: weder für die Richtigkeit der Daten, noch für deren Aktualität, noch für die Verfügbarkeit der Website. Die Daten zu den Mints können Minuten oder Stunden alt sein, ein Mint kann sich seit unserer letzten Überprüfung geändert haben, und ein Relay kann möglicherweise nicht antworten – in diesem Fall werden Ihnen weniger Daten angezeigt, als tatsächlich vorhanden sind.",
"terms.noAccounts.p2": "Die Nutzung erfolgt auf eigene Gefahr. Wir übernehmen keine Haftung für Verluste, die sich aus der Nutzung dieser Website oder dem Vertrauen auf hier veröffentlichte Informationen ergeben. Die spezifischen Risiken bei der Nutzung einer Cashu-Mint finden Sie unter {link}.",
"terms.noAccounts.link": "Haftungsausschluss",
"terms.noCustody.title": "Wir verwahren Ihr Geld nicht",
"terms.noCustody.p1": "Diese Website nimmt nie Gelder in Verwahrung. Sie hat keine Wallet, sie fasst kein ecash an, und sie kann nichts bewegen, einfrieren oder wiederherstellen. Wenn du ein Problem mit einem Mint hast, ist der Mint-Betreiber der Einzige, der dir helfen kann, und wir können ihn nicht für dich kontaktieren.",
"terms.noCustody.p2": "Wir stehen mit keinem hier gelisteten Mint in Verbindung, und kein Mint bezahlt für seinen Eintrag, seinen Rang oder seine Bewertung. Mints werden aus öffentlichen Nostr-Ankündigungen gefunden und von einem Algorithmus über öffentliche Bewertungsdaten eingeordnet.",
"terms.reviews.title": "Rezensionen sind die Worte anderer Menschen",
"terms.reviews.p1": "Bewertungen sind nutzergenerierte Inhalte, die von ihren Autoren auf öffentlichen Nostr-Relays veröffentlicht werden. Wir zeigen sie an, haben jedoch keinen Einfluss darauf. Wir haben sie nicht verfasst, können sie nicht bearbeiten und auch nicht von den Relays löschen, auf denen sie gespeichert sind: Es handelt sich um signierte Ereignisse in einem Netzwerk, das uns nicht gehört.",
"terms.reviews.p2": "Eine Bewertung gibt die Erfahrungen einer einzelnen Person wieder und stellt keine nachgewiesene Tatsache dar. Wenn Sie eine Bewertung veröffentlichen, sind Sie für deren Inhalt verantwortlich. Veröffentlichen Sie keine rechtswidrigen Inhalte und beachten Sie, dass Ihre Veröffentlichung öffentlich und dauerhaft ist (siehe „{link}“).",
"terms.reviews.link": "Datenschutzerklärung",
"terms.reviews.p3": "Wir können die Anzeige einer bestimmten Bewertung auf dieser Website unterbinden und werden dies bei rechtswidrigen Inhalten auch tun. Dadurch wird die Bewertung lediglich von diesen Seiten entfernt. Der Eintrag bleibt jedoch auf den Relays erhalten, sodass jeder andere Nostr-Client ihn weiterhin lesen kann.",
"terms.links.title": "Links zu anderen Websites",
"terms.links.p1": "Mint-URLs, Wallet-Links, Kontaktdaten von Betreibern und alles andere, was von dieser Website wegführt, sind dort aufgeführt, weil sie Teil der Daten sind. Ein Link stellt keine Empfehlung dar, und wir überprüfen nicht, was sich hinter dem Link verbirgt.",
"terms.changes.title": "Änderungen",
"terms.changes.p1": "Diese Bedingungen können sich ändern. Das Datum oben gibt an, wann sie zuletzt geändert wurden. Da es sich um eine Open-Source-Website handelt, ist jede Änderung an dieser Seite im Commit-Verlauf nachverfolgbar.",
"terms.contact.title": "Kontakt",
"terms.contact.p1": "Probleme und Anträge auf Entfernung von Inhalten reichen Sie am besten unter {github} ein. Die Website wird von {azzamo} betrieben.",
"privacy.title": "Datenschutz · Cashumints.space",
"privacy.description": "Datenschutz auf Cashumints.space: keine Konten, keine Tracking-Cookies, keine Analysen. „localStorage“ wird zum Zwischenspeichern von Profilen verwendet, und es werden keinerlei Daten an Dritte übermittelt. Bewertungen werden in Ihrem eigenen Browser signiert.",
"privacy.heading": "Datenschutz",
"privacy.lede": "Wir erheben so wenige Daten wie möglich. Zuletzt aktualisiert am {date}.",
"privacy.noAccounts.title": "Keine Konten",
"privacy.noAccounts.p1": "Es gibt keine Registrierung, keine Anmeldung und kein Profil. Auf dieser Website werden Sie nirgendwo nach Ihrer Identität gefragt, und es gibt keine Nutzerdaten, die offengelegt werden könnten, da es keine Nutzer gibt.",
"privacy.noTracking.title": "Keine Tracking-Cookies, keine Analysen",
"privacy.noTracking.p1": "Die Website setzt keine Cookies zum Tracking ein. Es gibt kein Analyseskript, keinen Tag-Manager, kein Pixel, keine Sitzungsaufzeichnung und kein Werbenetzwerk. Nichts auf dieser Seite übermittelt Ihr Verhalten an uns oder an Dritte.",
"privacy.logs.title": "Serverprotokolle",
"privacy.logs.p1": "Der Webserver führt standardmäßige Zugriffsprotokolle, wie es bei jedem Webserver standardmäßig der Fall ist. In einer Protokolleintragung werden Ihre IP-Adresse, die Uhrzeit, die von Ihnen angeforderte Seite, der Antwortstatus sowie der User-Agent und der Referrer, die Ihr Browser übermittelt hat, erfasst. Die Protokolle dienen dazu, Fehler zu beheben und Missbrauch aufzudecken. Sie werden nicht mit anderen Daten verknüpft und weder verkauft noch weitergegeben.",
"privacy.logs.operatorLabel": "Anmerkung des Bedieners:",
"privacy.logs.operator": "Wenn Sie diese Website betreiben, geben Sie hier die tatsächliche Aufbewahrungsdauer an. Die in der README-Datei beschriebene Konfiguration verwendet Nginx mit dem Standard-Zugriffsprotokoll und der von Ihrer Distribution vorgegebenen Rotation (unter Debian und Ubuntu ist dies „logrotate“, täglich, wobei die Protokolle 14 Tage lang aufbewahrt werden). Überprüfen Sie, wie Ihr Server konfiguriert ist, und ersetzen Sie diesen Absatz durch die entsprechende Zahl.",
"privacy.storage.title": "Was wird in Ihrem Browser gespeichert?",
"privacy.storage.p1": "Die Website nutzt localStorage für genau zwei Dinge, die beide auf Ihrem Gerät verbleiben und niemals an Dritte weitergeleitet werden:",
"privacy.storage.li1": "Ein Cache mit Nostr-Profil-Metadaten (Ereignisse vom Typ 0: Namen, Avatare, NIP-05-Adressen) für die Rezensenten, deren Rezensionen Sie sich angesehen haben. Dadurch wird vermieden, dass auf jeder Seite erneut Abfragen an die Relays gesendet werden müssen. Die Einträge verfallen, und der Cache ist begrenzt; die ältesten Einträge werden zuerst gelöscht.",
"privacy.storage.li2": "Einstellungen für die Benutzeroberfläche, damit sich die Website beim nächsten Mal genauso verhält.",
"privacy.storage.p2": "Wenn Sie die Website-Daten Ihres Browsers löschen, werden beide entfernt. Es entsteht kein Problem, die Profilabfragen werden einfach erneut ausgeführt.",
"privacy.thirdParties.title": "Mit wem kommuniziert Ihr Browser?",
"privacy.thirdParties.p1": "Beim Laden einer Seite stellt Ihr Browser neben dieser Website noch Verbindungen zu einigen anderen Orten her. Auf diese Weise kann eine Website ohne serverseitiges Tracking dennoch Live-Daten abrufen:",
"privacy.thirdParties.li1Label": "Öffentliche Nostr-Relais",
"privacy.thirdParties.li1": "{strong} über WebSocket, um Rezensionen und Profile von Rezensenten abzurufen. Das Relay erkennt Ihre IP-Adresse und Ihre Anfrage, wie es bei jedem Server der Fall ist, mit dem Sie eine Verbindung herstellen. Die Relays sind auf der Seite „{link}“ aufgelistet.",
"privacy.thirdParties.li1Link": "Über uns",
"privacy.thirdParties.li2Label": "Avatar-Bilder der Rezensenten",
"privacy.thirdParties.li2": "{strong}, geladen von der Adresse, auf die das Profil des Rezensenten verweist. Diese werden ohne Referrer gesendet, sodass der Host nicht erfahren kann, auf welcher Seite Sie sich zuvor befanden.",
"privacy.publishing.title": "Eine Rezension veröffentlichen",
"privacy.publishing.p1": "Das Verfassen einer Bewertung erfolgt in Ihrem Browser, nicht auf unserem Server. Der Vorgang wird von Ihrer NIP-07-Erweiterung (Alby, nos2x oder ähnlich) signiert und von Ihrem Browser direkt an die Relays gesendet. Ihr privater Schlüssel verlässt die Erweiterung zu keinem Zeitpunkt, und diese Website erhält niemals Einblick in ihn. Wir entwickeln keine eigene Schlüsselverwaltung und fragen niemals nach einem Schlüssel oder einer Seed-Phrase.",
"privacy.permanent.title": "Alles, was du auf Nostr veröffentlichst, ist öffentlich und dauerhaft",
"privacy.permanent.p1": "Das ist der Teil, den man sich zweimal durchlesen sollte. Eine Rezension ist ein signiertes Ereignis auf öffentlichen Relays. Ist sie erst einmal veröffentlicht, ist sie veröffentlicht:",
"privacy.permanent.li1": "Jeder kann es lesen, jetzt und später, mit jedem beliebigen Nostr-Client. Es ist nicht „öffentlich auf Cashumints.space“, sondern im Netzwerk öffentlich.",
"privacy.permanent.li2": "Es ist dauerhaft mit Ihrem öffentlichen Nostr-Schlüssel verknüpft, ebenso wie alles andere, was dieser Schlüssel jemals veröffentlicht hat.",
"privacy.permanent.li3": "Das Löschen ist eine Anfrage, kein Befehl. Sie können ein Löschereignis veröffentlichen, und einige Relays werden diesem nachkommen, aber es steht einem Relay frei, es zu ignorieren, und jeder, der das Ereignis bereits kopiert hat, verfügt weiterhin darüber. Behandeln Sie eine veröffentlichte Bewertung als dauerhaft.",
"privacy.permanent.li4": "Wir können es nicht für Sie löschen. Wir können jedoch verhindern, dass es hier angezeigt wird; das ändert jedoch nichts an den Relais.",
"privacy.permanent.p2": "Wenn du ein Mint bewerten willst, ohne dass das an deine Hauptidentität gebunden ist, nimm einen separaten Nostr-Schlüssel. Beachte, dass ein Schlüssel ohne weitere Aktivität bei harten Bewertungen als solcher gekennzeichnet wird, denn ein brandneuer Schlüssel, der eine Ein-Stern-Bewertung schreibt, ist ein Muster, das Leser sehen dürfen.",
"privacy.contact.title": "Fragen",
"privacy.contact.p1": "Die Website ist Open Source: Wenn Sie einen der oben genannten Punkte überprüfen möchten, können Sie den Quellcode unter {github} einsehen. Fragen und Korrekturen können dort als „Issue“ eingereicht werden.",
"disclaimer.title": "Haftungsausschluss · Cashumints.space",
"disclaimer.description": "Was Sie vor der Nutzung einer Cashu-Mint wissen sollten: Mints sind Verwahrdienste; eine Mint kann mit Ihrem Geld verschwinden; Bewertungen sind Meinungen, und „online“ bedeutet lediglich „erreichbar“, nicht „vertrauenswürdig“.",
"disclaimer.heading": "Haftungsausschluss",
"disclaimer.lede": "Lies das, bevor du Geld in ein Mint gibst. Zuletzt aktualisiert am {date}.",
"disclaimer.callout": "Ein Cashu-Mint verwahrt dein Geld für dich. Wenn es verschwindet, ist dein ecash weg, und es gibt niemanden, an den du dich wenden kannst. Halte dort nur, was du dir leisten kannst zu verlieren.",
"disclaimer.custodial.title": "Mints sind treuhänderisch",
"disclaimer.custodial.p1": "Wenn du ecash mintest, gibst du Bitcoin an den Mint-Betreiber und bekommst Token zurück. Diese Token sind ein Anspruch gegen diesen Betreiber. Sie sind kein Bitcoin, das du hältst, und der Betreiber entscheidet, ob er sie einlöst.",
"disclaimer.custodial.p2": "Das bedeutet, dass jede Mint dieselben Risiken birgt: Der Betreiber kann verschwinden, zur Einstellung des Betriebs gezwungen werden, die Gelder durch einen Fehler oder einen Hackerangriff verlieren oder den Server einfach abschalten. Es gibt keine Einlagensicherung, keine Rückbuchungen, keinen Kundendienst und keine Rechtsmittel, auf die man sich verlassen könnte. Das gilt sogar für die bestbewertete Mint auf dieser Website.",
"disclaimer.custodial.p3": "Cashu ist für schnelle Transaktionen mit kleinen Beträgen konzipiert. Nutzen Sie es entsprechend: Bewahren Sie darin nur das Geld auf, das Sie bald ausgeben möchten, nicht Ihre Ersparnisse, und verteilen Sie es auf verschiedene Mints, anstatt alles in einem zu parken.",
"disclaimer.reviews.title": "Bewertungen sind Meinungen, keine überprüften Behauptungen",
"disclaimer.reviews.p1": "Jede Bewertung hier wurde von jemandem auf Nostr geschrieben und mit dessen Schlüssel signiert. Signiert heißt, wir können dir sagen, welcher Schlüssel sie geschrieben hat. Es heißt nicht, dass die Bewertung wahr ist, dass der Autor das Mint genutzt hat, oder dass der Autor nicht der Betreiber, ein Konkurrent oder eine Person mit zehn Schlüsseln ist.",
"disclaimer.reviews.p2": "Wir verifizieren keine Verfasser, und wir bearbeiten oder löschen keine Bewertungen, mit denen wir nicht einverstanden sind. Lies ein paar, schau, was sie tatsächlich sagen, und gewichte ein Mint mit drei Bewertungen anders als eines mit dreihundert.",
"disclaimer.status.title": "„Online“ bedeutet „erreichbar“, nicht „vertrauenswürdig“",
"disclaimer.status.p1": "Wir überprüfen jede Mint regelmäßig und zeigen an, ob sie geantwortet hat. Das ist alles, was ein grüner Status bedeutet: Der Server hat geantwortet. Er sagt nichts darüber aus, ob der Betreiber zahlungsfähig oder ehrlich ist oder ob er auch morgen noch beabsichtigt, Ihre Token einzulösen.",
"disclaimer.status.p2": "Eine Mint kann so lange online sein, bis sie es nicht mehr ist. Die Verfügbarkeitsstatistik, die unterstützten NUTs und die Softwareversion geben zwar Aufschluss darüber, wie eine Mint betrieben wird, aber keines dieser Elemente ist eine Garantie für Ihr Geld.",
"disclaimer.ranking.title": "Wie das Ranking funktioniert und was es nicht bedeutet",
"disclaimer.ranking.p1": "Die Mints werden anhand eines Algorithmus auf der Grundlage öffentlicher Bewertungsdaten sortiert: Bewertung, Anzahl der Bewertungen und aktuelle Aktivität, wobei Online-Mints standardmäßig vor Offline-Mints stehen. Die Einzelheiten sind im Open-Source-Code zu finden.",
"disclaimer.ranking.p2": "Niemand bezahlt für die Platzierung. Es gibt keinen gesponserten Platz, keinen beworbenen Eintrag und keine Absprache mit irgendeinem Mint auf dieser Website. Ein hoher Rang heißt, dass dem Algorithmus die öffentlichen Daten gefallen haben, mehr nicht. Es ist keine Empfehlung und keine Sicherheitsbewertung.",
"disclaimer.notAdvice.title": "Dies ist keine Finanzberatung.",
"disclaimer.notAdvice.p1": "Nichts auf dieser Website ist Finanz-, Anlage-, Rechts- oder Steuerberatung. Wir kennen deine Situation nicht und wir beraten dich nicht. Ein Mint zu nutzen, und welches, ist deine Entscheidung.",
"disclaimer.dyor.title": "Recherchiere selbst",
"disclaimer.dyor.li1": "Informieren Sie sich über den Betreiber. Eine Mint, die von jemandem betrieben wird, der seinen guten Ruf zu verlieren hat, ist etwas ganz anderes als eine anonyme URL.",
"disclaimer.dyor.li2": "Lies dir die Nutzungsbedingungen der Mint selbst und deren „MOTD“ durch. Eine Mint, die sich selbst als „Test“ oder „Beta“ bezeichnet, meint das auch so.",
"disclaimer.dyor.li3": "Fang klein an. Minte ein wenig, gib es aus, melte es wieder heraus, und schau, was passiert, bevor du ihm mehr anvertraust.",
"disclaimer.dyor.li4": "Achten Sie auf die Grenzwerte. Ein niedriger Höchstwert ist oft ein Zeichen für einen umsichtigen Betreiber, während ein hoher Wert nicht unbedingt auf Sicherheit hindeutet.",
"disclaimer.dyor.li5": "Erstellen Sie regelmäßig Sicherungskopien Ihrer Token und denken Sie daran, dass ein Token ein Inhaberwert ist: Wer ihn besitzt, kann ihn ausgeben.",
"disclaimer.site.title": "Über die Website selbst",
"disclaimer.site.p1": "Die hier aufgeführten Daten können fehlerhaft oder veraltet sein. Die Mints werden nach einem festgelegten Zeitplan abgefragt, Relais reagieren manchmal nicht, und die Seite, die Sie gerade lesen, wurde zu einem bestimmten Zeitpunkt in der Vergangenheit erstellt. Die Website wird ohne Mängelgewähr bereitgestellt. Siehe die „{link}“.",
"disclaimer.site.link": "Nutzungsbedingungen",
"disclaimer.updated": "20. August 2026",
"login.getPrimal": "Get Primal",
"reviews.dialog.identity.extensionGone": "Ihre Browser-Erweiterung reagiert nicht. Melden Sie sich bitte erneut an oder verwenden Sie einen Remote-Signierer.",
"seo.orgDescription": "Ein Verzeichnis von Cashu-Mints, mit Live-Status jedes Mints und Community-Bewertungen, signiert auf Nostr.",
"seo.ogImageAlt": "Cashumints.space: finde ein Cashu-Mint, dem du vertrauen kannst.",
"seo.mint.title.one": "{name} Bewertungen: {rating} aus {n} Bewertung | Cashumints.space",
"seo.mint.title.other": "{name} Bewertungen: {rating} aus {n} Bewertungen | Cashumints.space",
"seo.mint.titleOffline.one": "{name}: seit {n} Tag offline | Cashumints.space",
"seo.mint.titleOffline.other": "{name}: seit {n} Tagen offline | Cashumints.space",
"seo.mint.titleNoReviews": "{name}: Infos und Bewertungen zum Cashu-Mint | Cashumints.space",
"seo.fedimint.titleNoReviews": "{name}: Informationen und Bewertungen zur Fedimint-Föderation | Cashumints.space",
"seo.desc.fallback": "Community-Bewertungen für {name} ({domain}).",
"seo.desc.fallbackDomain": "Community-Bewertungen für das Cashu-Mint unter {domain}.",
"seo.desc.rated.one": "{rating}/5 aus {n} signierter Nostr-Bewertung.",
"seo.desc.rated.other": "{rating}/5 aus {n} signierten Nostr-Bewertungen.",
"seo.desc.noReviews": "Noch keine Bewertungen.",
"seo.desc.status": "{status}.",
"seo.desc.statusLimits": "{status}, Limits {limits}.",
"seo.desc.fedModules": "{status}. Module: {modules}.",
"seo.desc.fedModulesNetwork": "{status}. Module: {modules} auf {network}.",
"seo.mint.ogAlt.one": "{name}: {rating} Sterne aus {n} Bewertung auf Cashumints.space",
"seo.mint.ogAlt.other": "{name}: {rating} Sterne aus {n} Bewertungen auf Cashumints.space",
"seo.mint.ogAltNoReviews": "{name} auf Cashumints.space",
"nav.home": "Startseite",
"mint.more.title": "Mehr Mints",
"mint.more.all": "Alle Mints ansehen",
"nav.fedimints": "Fedimints",
"status.announced": "Nur angekündigt",
"card.unnamedFederation": "Unbenannter Verband",
"card.notChecked": "nie überprüft",
"fedimints.title": "Alle Fedimint-Verbände · Cashumints.space",
"fedimints.description": "Alle im Nostr-Netzwerk angekündigten „{total}“-Fedimint-Verbünde: {online} – durch eine externe Überprüfung als funktionsfähig bestätigt; {announced} – angekündigt, aber nie bestätigt. Community-Bewertungen, Module und Einladungscodes für jeden einzelnen.",
"fedimints.heading": "Alle Verbände",
"fedimints.lede": "Jede Fedimint-Föderation wurde im Nostr-Netzwerk angekündigt. Da eine Föderation über keinen eigenen öffentlichen Status-Endpunkt verfügt, wird bei einigen durch eine externe Überprüfung bestätigt, dass sie aktiv sind, während die übrigen genau so aufgeführt sind, wie sie angekündigt wurden.",
"fedimints.search.label": "Suchverbünde nach Name oder ID suchen",
"fedimints.search.placeholder": "Nach Namen oder Verbands-ID suchen…",
"fedimints.sort.label": "Verbände sortieren",
"fedimints.hideOffline": "Offline ausblenden",
"fedimints.showing": "{total}-Verbände werden angezeigt ({online} bestätigt, {announced} nur angekündigt)",
"fedimints.showingFiltered": "Es werden {shown} von {total} Verbänden angezeigt ({online} sind bestätigt, {announced} wurden lediglich angekündigt)",
"fedimints.announcedNote.one": "Eines davon wurde auf Nostr angekündigt, und seitdem gibt es keine Bestätigung dafür, dass es läuft. Es wird als „angekündigt“ und nicht als „online“ oder „offline“ aufgeführt, da beides nicht zutreffen würde.",
"fedimints.announcedNote.other": "{n}e diese wurden auf Nostr angekündigt, und seitdem gibt es keine Bestätigung dafür, dass sie in Betrieb sind. Sie werden als „angekündigt“ und nicht als „online“ oder „offline“ aufgeführt, da beides nicht zutreffen würde.",
"fedimints.noResults": "Es wurde kein Verband gefunden. Geben Sie einen Namen ein oder fügen Sie eine Verbands-ID ein.",
"fedimints.emptyIndex": "Auf dieser Website ist zu lesen, dass noch keine Verbände für die Staffeln bekannt gegeben wurden. Die Liste wird automatisch ergänzt, sobald Ankündigungen eingehen.",
"fedimint.crumb": "Fedimints",
"fedimint.breadcrumb": "Navigationspfad",
"fedimint.unnamed": "Unbenannter Verband",
"fedimint.idLabel": "Verband",
"fedimint.kind.federationId": "Verbands-ID",
"fedimint.moduleCount.one": "{n}-Modul",
"fedimint.moduleCount.other": "{n}-Module",
"fedimint.copyInvite": "Einladungscode kopieren",
"fedimint.confirmed.label": "Bestätigt",
"fedimint.confirmed.never": "Niemals",
"fedimint.confirmed.by": "von {source}",
"fedimint.confirmed.unattributed": "letzte Überprüfung",
"fedimint.announcedOn": "{when} angekündigt",
"fedimint.announcedUnknown": "kein Ankündigungstermin",
"fedimint.modules.title": "Module",
"fedimint.modules.none": "In der Ankündigung dieses Verbandes werden keine Module aufgeführt.",
"fedimint.modules.note": "Module sind die Komponenten, die ein Verbund betreibt. In der Ankündigung wird angegeben, welche Module vorhanden sind, es wird jedoch nicht erwähnt, ob eines davon Einzahlungen entgegennimmt.",
"fedimint.modules.notListed": "nicht aufgeführt",
"fedimint.invite.title": "Einladungscode",
"fedimint.invite.count.one": "{n}-Code",
"fedimint.invite.count.other": "{n}-Codes",
"fedimint.invite.note": "Füge dies in eine Fedimint-Wallet ein, um beizutreten. Es enthält die Föderations-ID und die Adressen der Verwalter und ist dazu gedacht, weitergegeben zu werden.",
"fedimint.invite.kind": "Einladungscode",
"fedimint.invite.qr": "QR",
"fedimint.invite.qrCaption": "Scannen Sie den QR-Code, um der Gruppe „{name}“ beizutreten",
"fedimint.details.federationId": "Verbands-ID",
"fedimint.details.network": "Netzwerk",
"fedimint.details.announcedBy": "Bekannt gegeben von",
"fedimint.details.announced": "Angekündigt",
"fedimint.more.title": "Weitere Verbände",
"fedimint.more.all": "Alle Verbände anzeigen",
"fedimint.disclaimer": "Einem Verband beizutreten bedeutet, seinen Verantwortlichen das eigene Geld anzuvertrauen. Bewertungen spiegeln die Meinungen der Community wider, und „bestätigt“ bedeutet lediglich, dass der Anbieter erreichbar ist – nicht, dass er vertrauenswürdig ist.",
"fedimint.module.lightning": "Lightning",
"fedimint.module.mint": "ecash-Mint",
"fedimint.module.wallet": "On-Chain-Wallet",
"fedimint.module.meta": "Metadaten",
"fedimint.module.stability_pool": "Stabilitätspool",
"fedimint.module.multi_sig_stability_pool": "Stabilitätspool (Multisig)",
"fedimint.module.fedi-social": "Soziale Wiedereingliederung",
"fedimint.network.mainnet": "Bitcoin-Mainnet",
"fedimint.network.signet": "Siegel",
"fedimint.network.testnet": "Testnetz",
"fedimint.network.testnet4": "Testnetz 4",
"fedimint.network.regtest": "Regtest",
"feed.ecosystem.group": "Ökosystem",
"feed.ecosystem.all": "Alle",
"feed.ecosystem.cashu": "Cashu",
"feed.ecosystem.fedimint": "Fedimint",
"feed.filterBySubject": "Nach Mint oder Föderation filtern",
"feed.anySubject": "Jedes Mint oder jede Föderation",
"feed.clearSubject": "Namensfilter löschen",
"home.topFedimints.title": "Top-Fedimints",
"home.topFedimints.sub": "nach Bewertungen und Sternebewertung sortiert",
"home.topFedimints.all": "Alle Verbände unter {n} →",
"reviews.validate.subject": "Es gibt nichts, worauf sich diese Rezension beziehen könnte.",
"mint.warnings.fedimintOffline.lead": "Als offline gemeldet.",
"mint.warnings.fedimintOffline.body.since": "Der letzte Scheck, der bei diesem Verband eingegangen ist, stammte vom {date} (vor {days}). Die Verantwortlichen haben seitdem nicht mehr geantwortet. Betrachten Sie die dort hinterlegten Gelder als gefährdet und führen Sie keine neuen Gelder ein.",
"mint.warnings.fedimintOffline.body.never": "Es ist noch nie eine Überprüfung dieses Verbandes erfolgt, und die letzte ist gescheitert. Behandeln Sie die dort gehaltenen Gelder als gefährdet und führen Sie keine neuen Gelder diesem Verband zu.",
"mint.warnings.fedimintOffline.meta.since": "Seit {date} als offline gemeldet",
"mint.warnings.fedimintOffline.meta.never": "Als offline gemeldet, wurde nie erreicht",
"mint.warnings.neverConfirmed.lead": "Angekündigt, aber nie bestätigt.",
"mint.warnings.neverConfirmed.body.reviewed": "Dieser Zusammenschluss wurde vor einiger Zeit auf Nostr unter {date}, {days} angekündigt, und seitdem gibt es keine Bestätigungen dafür, dass er tatsächlich besteht. Die folgenden Rezensionen sind das einzige Lebenszeichen auf dieser Seite; alles andere stammt aus der Ankündigung.",
"mint.warnings.neverConfirmed.body.quiet": "Diese Föderation wurde vor einiger Zeit auf Nostr unter {date}, {days} angekündigt; seitdem gibt es keine Bestätigung dafür, dass sie noch besteht, und niemand hat sie in letzter Zeit bewertet. Alle folgenden Informationen stammen aus der Ankündigung.",
"mint.warnings.neverConfirmed.meta": "{date} angekündigt, nie bestätigt",
"footer.allFedimints": "Alle Verbände",
"reviews.dialog.bodyPlaceholder.cashu": "Wie liefen Minten, Melten und Support? Werde konkret.",
"reviews.dialog.bodyPlaceholder.fedimint": "Wie haben sich die Integration, Lightning-Zahlungen und der Support bewährt? Gehen Sie bitte konkret darauf ein.",
"fedimint.details.title": "Angaben zum Verband",
"nav.lnurlMints": "LNURL-Mints",
"footer.allLnurlMints": "Alle LNURL-Mints",
"lnurlMints.title": "LNURL-Mints: Mints für lnurlcash-Inhaberscheine, bewertet | Cashumints.space",
"lnurlMints.description": "Alle {total} aus dem Nostr-Netzwerk indexierten LNURL-Mints: {online} online, {offline} offline. Community-Bewertungen, Auszahlungsgrenzen und Funktionen für jeden.",
"lnurlMints.heading": "LNURL-Mints",
"lnurlMints.lede": "Jeder LNURL-Mint, den diese Seite kennt. Ein LNURL-Mint gibt lnurlcash-Inhaberscheine aus: Lightning-Scheine, die man als Geheimnis hält und die sich rotieren, teilen, zusammenführen oder zurück zu einer Lightning-Zahlung schmelzen lassen. Jeder wird alle paar Minuten über HTTPS geprüft.",
"lnurlMints.search.label": "LNURL-Mints durchsuchen",
"lnurlMints.search.placeholder": "Nach Name oder Adresse suchen…",
"lnurlMints.sort.label": "LNURL-Mints sortieren nach",
"lnurlMints.hideOffline": "Offline ausblenden",
"lnurlMints.showing": "{total} LNURL-Mints ({online} online, {offline} offline)",
"lnurlMints.showingFiltered": "{shown} von {total} LNURL-Mints ({online} online, {offline} offline)",
"lnurlMints.noResults": "Kein LNURL-Mint passt dazu.",
"lnurlMints.emptyIndex": "Es sind noch keine LNURL-Mints indexiert.",
"lnurl.crumb": "LNURL-Mints",
"lnurl.breadcrumb": "Brotkrumenpfad",
"lnurl.copyAddress": "Adresse kopieren",
"lnurl.more.title": "Weitere LNURL-Mints",
"lnurl.more.all": "Alle LNURL-Mints →",
"lnurl.disclaimer": "Ein LNURL-Mint verwahrt deine Sats, während du seine Scheine hältst – ihn zu nutzen heißt also, seinem Betreiber zu vertrauen. Bewertungen sind Meinungen der Community, und online heißt erreichbar, nicht vertrauenswürdig.",
"lnurl.features.title": "Funktionen",
"lnurl.features.none": "Dieser Mint hat nicht angekündigt, was er unterstützt.",
"lnurl.features.note": "Funktionen sind das, was der Betreiber des Mints auf Nostr angekündigt hat. Wo diese Seite eine davon über HTTPS prüfen kann, tut sie das, und sie sagt es, wenn etwas Angekündigtes gerade nicht verfügbar ist.",
"lnurl.features.notListed": "nicht angekündigt",
"lnurl.features.unavailable": "nicht verfügbar",
"lnurl.feature.mint": "Minten über Lightning (LUD-06)",
"lnurl.feature.melt": "Schmelzen zu Lightning",
"lnurl.feature.notes": "Scheine rotieren / teilen / zusammenführen",
"lnurl.feature.lud16": "Lightning-Adresse",
"lnurl.feature.lud21": "Zahlungsprüfung (LUD-21)",
"lnurl.feature.signed-notes": "Signierte Scheine (Offline-Prüfung)",
"lnurl.feature.onion": "Tor-Adresse",
"lnurl.feature.rotate": "Einen Schein rotieren",
"lnurl.feature.split": "Einen Schein teilen",
"lnurl.feature.merge": "Scheine zusammenführen",
"lnurl.feature.lud06": "LUD-06 payRequest",
"lnurl.feature.lud03": "LUD-03 withdrawRequest",
"lnurl.limits.label": "Auszahlungsgrenzen",
"lnurl.limits.notPublished": "nicht veröffentlicht",
"lnurl.limits.withdrawRow": "Auszahlung",
"lnurl.limits.disabled": "deaktiviert",
"lnurl.limits.sats.one": "{n} Sat",
"lnurl.limits.sats.other": "{n} Sats",
"lnurl.details.title": "Details zum Mint",
"lnurl.details.address": "Lightning-Adresse",
"lnurl.details.mintPubkey": "Öffentlicher Schlüssel des Mints",
"lnurl.details.onion": "Tor-Adresse",
"lnurl.details.fee": "Mint-Gebühr",
"lnurl.details.node": "Node",
"lnurl.details.capacity": "Angekündigte Kapazität",
"lnurl.kind.lightningAddress": "Lightning-Adresse",
"lnurl.kind.mintPubkey": "öffentlicher Schlüssel des Mints",
"lnurl.kind.onionAddress": "Tor-Adresse",
"lnurl.confirmed.label": "Zuletzt bestätigt",
"lnurl.confirmed.when": "bestätigt {when}",
"lnurl.fee.base.one": "{n} Sat",
"lnurl.fee.base.other": "{n} Sats",
"lnurl.fee.percent": "{percent} %",
"home.topLnurl.title": "Top LNURL-Mints",
"home.topLnurl.sub": "nach Bewertungen und Note sortiert",
"home.topLnurl.all": "Alle {n} LNURL-Mints →",
"feed.ecosystem.lnurl": "LNURL",
"reviews.dialog.bodyPlaceholder.lnurl": "Wie liefen Minten, Melten und das Rotieren von Scheinen? Werde konkret.",
"reviews.byUrl.open": "Review schreiben",
"reviews.byUrl.title": "Review schreiben",
"reviews.byUrl.lede": "Adresse einfügen. Kennt diese Seite sie noch nicht, wird sie jetzt geprüft und aufgenommen.",
"reviews.byUrl.label.cashu": "Mint-URL",
"reviews.byUrl.label.fedimint": "Invite Code der Föderation",
"reviews.byUrl.label.lnurl": "URL des LNURL Mint",
"reviews.byUrl.placeholder.cashu": "mint.example.com",
"reviews.byUrl.placeholder.fedimint": "fed11…",
"reviews.byUrl.placeholder.lnurl": "mint.example.com",
"reviews.byUrl.check": "Diese Adresse prüfen",
"reviews.byUrl.checking": "Wird geprüft…",
"reviews.byUrl.back": "Andere Adresse verwenden",
"reviews.byUrl.existing": "Schon gelistet",
"reviews.byUrl.indexed": "Gerade aufgenommen",
"reviews.byUrl.confirmClose": "Schließen? Die eingegebene Adresse wird nicht behalten.",
"reviews.byUrl.detected": "Diese Adresse hat als {kind} Mint geantwortet.",
"reviews.byUrl.switchTo": "Als {kind} Mint bewerten",
"reviews.byUrl.error.empty": "Gib zuerst eine Adresse ein.",
"reviews.byUrl.error.badUrl": "Das sieht nicht nach einer Mint-URL aus.",
"reviews.byUrl.error.badInvite": "Das ist kein Fedimint Invite Code. Codes beginnen mit fed11.",
"reviews.byUrl.error.badInput": "Diese Seite kann diese Adresse nicht prüfen.",
"reviews.byUrl.error.blocked": "Das ist keine öffentliche Adresse, sie kann also nicht geprüft werden.",
"reviews.byUrl.error.notAMint": "Diese Adresse hat geantwortet, aber nicht als Mint dieser Art.",
"reviews.byUrl.error.wrongType": "Diese Adresse hat geantwortet, aber als eine andere Art von Mint.",
"reviews.byUrl.error.unverifiable": "Unter dieser Adresse hat nichts geantwortet, und auf Nostr gibt es bisher keine Spur davon.",
"reviews.byUrl.error.rateLimited.one": "Das sind viele Prüfungen von einer Verbindung. Versuch es in etwa {n} Minute noch einmal.",
"reviews.byUrl.error.rateLimited.other": "Das sind viele Prüfungen von einer Verbindung. Versuch es in etwa {n} Minuten noch einmal.",
"reviews.byUrl.error.network": "Der Index war nicht erreichbar. Prüf deine Verbindung und versuch es noch einmal.",
"seo.lnurl.titleNoReviews": "{name}: Infos und Bewertungen zum LNURL-Mint | Cashumints.space",
"seo.desc.statusWithdraw": "{status}, Auszahlung {min}–{max} Sat.",
"mint.chip.noFunding": "Kein Minten / Melten",
"mint.warnings.lnurlWithdrawalsDisabled.lead": "Auszahlungen sind deaktiviert.",
"mint.warnings.lnurlWithdrawalsDisabled.body.online": "Dieser Mint gibt eine maximale Auszahlung von null an, also lässt sich kein von ihm ausgegebener Schein gegen irgendetwas einlösen. Präge hier nichts, solange das so bleibt.",
"mint.warnings.lnurlWithdrawalsDisabled.body.offline": "Dieser Mint gab beim letzten Kontakt eine maximale Auszahlung von null an, also ließ sich kein von ihm ausgegebener Schein gegen irgendetwas einlösen. Präge hier nichts, solange das so bleibt.",
"mint.warnings.lnurlWithdrawalsDisabled.meta.online": "Auszahlungen derzeit deaktiviert",
"mint.warnings.lnurlWithdrawalsDisabled.meta.offline": "Auszahlungen waren beim letzten Kontakt deaktiviert",
"mint.warnings.lnurlInvalid.lead": "Endpunkt antwortet, ist aber ungültig.",
"mint.warnings.lnurlInvalid.body": "Der Host antwortet, aber nicht mit einer Mint-Ankündigung, die diese Seite lesen kann – Auszahlungen funktionieren also möglicherweise nicht. Alles darunter ist der letzte lesbare Stand, und Bewertungen funktionieren weiterhin.",
"mint.warnings.lnurlInvalid.meta": "Antwortet, aber nicht mit einer gültigen Mint-Ankündigung",
"mint.warnings.lnurlNoFunding.lead": "Minten und Melten nicht verfügbar.",
"mint.warnings.lnurlNoFunding.body": "Vorhandene Scheine lassen sich weiterhin rotieren, teilen oder zusammenführen, aber über Lightning bewegt sich gerade nichts hinein oder hinaus. Der Mint selbst läuft und antwortet; die Lightning-Node dahinter ist von ihm aus nicht erreichbar.",
"mint.warnings.lnurlNoFunding.meta": "Minten und Melten nicht verfügbar",
"mint.warnings.also.noFunding": "Auch seine Lightning-Node war nicht erreichbar, es konnte also weder gemintet noch gemeltet werden."
}

Some files were not shown because too many files have changed in this diff Show More