Compare commits
5
Commits
f138066ee6
...
c1f4565bfe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1f4565bfe | ||
|
|
c74c7fc187 | ||
|
|
2a9444942b | ||
|
|
c97b44018d | ||
|
|
36c01861f5 |
@@ -156,6 +156,12 @@ FEDIMINT_OBSERVER_URL=https://observer.fedimint.org/api/federations
|
||||
# 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.
|
||||
|
||||
+350
@@ -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.
|
||||
@@ -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`:
|
||||
|
||||
@@ -264,6 +264,16 @@ CHECK_DB_URL=postgres://localhost/cashumints_test pnpm --filter ./api test:offli
|
||||
`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
|
||||
@@ -537,6 +547,7 @@ so a systemd `Environment=` line or a one-off `PORT=9000 pnpm dev:api` still ove
|
||||
| `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/`)
|
||||
|
||||
@@ -657,7 +668,7 @@ or copy refers to it, and it is planned as a later stage rather than half-built
|
||||
|
||||
## API
|
||||
|
||||
Four endpoints, CORS open, no auth.
|
||||
Five endpoints, CORS open, no auth. Four read; the fifth writes.
|
||||
|
||||
| Endpoint | Notes |
|
||||
| -------------------- | ------------------------------------------------------------------ |
|
||||
@@ -665,9 +676,54 @@ Four endpoints, CORS open, no auth.
|
||||
| `GET /api/stats` | Network counters, memoized 60s in process. |
|
||||
| `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, so behind the nginx block below add
|
||||
`proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;` to `location /api/`. 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:
|
||||
@@ -792,6 +848,9 @@ server {
|
||||
# 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;
|
||||
# POST /api/index is rate limited per address, and without this every visitor
|
||||
# arrives as 127.0.0.1 and shares one budget.
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location /icons/ {
|
||||
|
||||
+3
-1
@@ -11,7 +11,9 @@
|
||||
"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:offline": "node --env-file-if-exists=../.env --experimental-strip-types src/check-offline.ts",
|
||||
"test:lnurl": "node --env-file-if-exists=../.env --experimental-strip-types src/check-lnurl.ts",
|
||||
"test:index": "node --experimental-strip-types src/check-index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cashumints/shared": "workspace:*",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`);
|
||||
@@ -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">⚡</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`);
|
||||
@@ -119,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);
|
||||
|
||||
+176
-10
@@ -3,25 +3,28 @@ 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, setState, getStateNumber } from './db.ts';
|
||||
import type { Sql } from './db-driver.ts';
|
||||
import { log } from './log.ts';
|
||||
import { insertMintIfNew, upsertFedimint } from './mints.ts';
|
||||
import { insertMintIfNew, upsertFedimint, upsertLnurl } from './mints.ts';
|
||||
|
||||
const QUERY_LIMIT = 500;
|
||||
const MAX_WAIT_MS = 12_000;
|
||||
@@ -40,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;
|
||||
}
|
||||
@@ -137,6 +149,27 @@ async function fetchReviewsForMint(
|
||||
'#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)] });
|
||||
@@ -164,10 +197,14 @@ async function fetchReviewsForMint(
|
||||
*/
|
||||
interface ReviewTarget {
|
||||
type: string;
|
||||
/** Row key: the mint URL, or `fedimint:<id>`. */
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,6 +238,41 @@ async function ingestFedimints(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -247,6 +319,18 @@ interface MintIndex {
|
||||
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> {
|
||||
@@ -263,12 +347,30 @@ async function loadMintIndex(): Promise<MintIndex> {
|
||||
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 {
|
||||
@@ -283,7 +385,7 @@ async function loadMintIndex(): Promise<MintIndex> {
|
||||
}
|
||||
}
|
||||
|
||||
return { byHost, byPubkey, byFederation, byInvite };
|
||||
return { byHost, byPubkey, byFederation, byInvite, byLnurlId, byLnurlUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -303,6 +405,7 @@ function resolveReviewTarget(event: NostrEvent, index: MintIndex): string | null
|
||||
*/
|
||||
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;
|
||||
|
||||
@@ -344,6 +447,39 @@ function resolveFedimintReview(event: NostrEvent, index: MintIndex): string | nu
|
||||
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;
|
||||
|
||||
@@ -402,8 +538,19 @@ async function ingestReviews(events: Iterable<NostrEvent>, index: MintIndex): Pr
|
||||
*/
|
||||
const target = resolveReviewTarget(e, index);
|
||||
if (!target) continue;
|
||||
const fedimint = reviewTargetKind(e) === KIND_FEDIMINT_ANNOUNCEMENT;
|
||||
if (!fedimint && !isCashuMintReview(e) && mintUrlsFromEvent(e).length === 0) 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);
|
||||
|
||||
@@ -469,10 +616,12 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
|
||||
|
||||
const announcements = announcementsByType.get('cashu') ?? [];
|
||||
const federations = announcementsByType.get('fedimint') ?? [];
|
||||
events += announcements.length + federations.length;
|
||||
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.
|
||||
@@ -505,15 +654,32 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
|
||||
|
||||
const targets: ReviewTarget[] = rows.map((row) => {
|
||||
let federationId: string | null = null;
|
||||
if (row.type === 'fedimint' && row.ecosystem_json) {
|
||||
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 };
|
||||
|
||||
return { type: row.type, url: row.url, pubkey: row.pubkey, federationId, identifiers, baseUrl };
|
||||
});
|
||||
|
||||
const CONCURRENCY = 6;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -31,6 +32,20 @@ async function probeCycle(): Promise<void> {
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+292
-7
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
fedimintKey, fedimintSlug, normalizeMintUrl,
|
||||
type FedimintAnnouncement, type FedimintFields,
|
||||
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';
|
||||
@@ -18,7 +18,7 @@ const reportedSkips = new Set<string>();
|
||||
export interface MintRow {
|
||||
url: string;
|
||||
host: string;
|
||||
/** 'cashu' or 'fedimint'. Rows written before the column existed default to 'cashu'. */
|
||||
/** 'cashu', 'fedimint' or 'lnurl'. Rows written before the column existed default to 'cashu'. */
|
||||
type: string;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
@@ -26,7 +26,10 @@ export interface MintRow {
|
||||
icon_file: string | null;
|
||||
pubkey: string | null;
|
||||
info_json: string | null;
|
||||
/** Type-specific data. `FedimintFields` for a federation, null for a Cashu mint. */
|
||||
/**
|
||||
* 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;
|
||||
@@ -106,11 +109,23 @@ export async function insertMintIfNew(
|
||||
|
||||
/* ---------- fedimint ---------- */
|
||||
|
||||
/** Read a federation row's type-specific columns back out. */
|
||||
export function parseEcosystem(row: Pick<MintRow, 'ecosystem_json'>): FedimintFields | null {
|
||||
/**
|
||||
* 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 FedimintFields;
|
||||
return JSON.parse(row.ecosystem_json) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -196,12 +211,282 @@ export async function upsertFedimint(
|
||||
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 async function mintUrlByHost(host: string): Promise<string | null> {
|
||||
const db = await getDb();
|
||||
|
||||
+367
-42
@@ -1,10 +1,14 @@
|
||||
import { parseNuts, type FedimintFields, 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 { probeLnurl } from './lnurl-probe.ts';
|
||||
import { allMintRows, mintByUrl, parseEcosystem, type MintRow } from './mints.ts';
|
||||
|
||||
export interface ProbeResult {
|
||||
@@ -28,45 +32,24 @@ export function statusForFails(fails: number): 'online' | 'degraded' | 'offline'
|
||||
*/
|
||||
const MAX_INFO_BYTES = 256 * 1024;
|
||||
|
||||
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.
|
||||
* 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> {
|
||||
export async function recordCashuOnline(
|
||||
row: MintRow,
|
||||
info: MintInfo,
|
||||
latencyMs: number,
|
||||
now = Math.floor(Date.now() / 1000),
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
try {
|
||||
const { info, latencyMs } = await fetchInfo(row.url);
|
||||
const nuts = parseNuts(info.nuts);
|
||||
|
||||
const iconFile = await cacheIcon(row, info.icon_url ?? null);
|
||||
|
||||
await db.run(
|
||||
@@ -105,6 +88,46 @@ export async function probeMint(row: MintRow): Promise<ProbeResult> {
|
||||
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' });
|
||||
@@ -251,6 +274,297 @@ async function checkFederations(rows: MintRow[], now: number): Promise<ProbeResu
|
||||
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;
|
||||
@@ -270,16 +584,26 @@ export async function probeAll(rows?: MintRow[]): Promise<ProbeResult[]> {
|
||||
const started = Date.now();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Two ecosystems, two entirely different checks: an HTTP fetch per mint, and one
|
||||
// lookup covering every federation. Split here rather than inside the worker so the
|
||||
// federations are not each waiting behind a slot in the mint pool.
|
||||
const mints = targets.filter((row) => row.type !== 'fedimint');
|
||||
/*
|
||||
* 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[] = [];
|
||||
const [, federationResults] = await Promise.all([
|
||||
pooled(mints, config.probeConcurrency, async (row) => {
|
||||
results.push(await probeMint(row));
|
||||
pooled(fetched, config.probeConcurrency, async (row) => {
|
||||
results.push(row.type === 'lnurl' ? await probeLnurlMint(row) : await probeMint(row));
|
||||
}),
|
||||
checkFederations(federations, now),
|
||||
]);
|
||||
@@ -288,7 +612,8 @@ export async function probeAll(rows?: MintRow[]): Promise<ProbeResult[]> {
|
||||
const online = results.filter((r) => r.ok).length;
|
||||
const changed = results.filter((r) => r.changed).length;
|
||||
log.info('probe cycle', {
|
||||
mints: mints.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,
|
||||
|
||||
+37
-1
@@ -11,6 +11,7 @@ import {
|
||||
type MintType,
|
||||
type ProbeSample,
|
||||
type RatingDistribution,
|
||||
type LnurlFields,
|
||||
type Stats,
|
||||
} from '@cashumints/shared';
|
||||
import { config, startedAt } from './config.ts';
|
||||
@@ -289,7 +290,8 @@ export async function getStats(): Promise<Stats> {
|
||||
* 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, reviews, fedimintReviews] = await Promise.all([
|
||||
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,
|
||||
@@ -306,6 +308,24 @@ export async function getStats(): Promise<Stats> {
|
||||
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`,
|
||||
),
|
||||
@@ -314,8 +334,19 @@ export async function getStats(): Promise<Stats> {
|
||||
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')`,
|
||||
),
|
||||
]);
|
||||
|
||||
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 = {
|
||||
@@ -332,6 +363,11 @@ export async function getStats(): Promise<Stats> {
|
||||
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 };
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
+32
-2
@@ -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,6 +54,23 @@ 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);
|
||||
}
|
||||
@@ -64,7 +81,15 @@ async function main(): Promise<void> {
|
||||
|
||||
let added = 0;
|
||||
for (const url of SEED_MINTS) if (await insertMintIfNew(url)) added++;
|
||||
log.info('seed list ingested', { listed: SEED_MINTS.length, 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);
|
||||
@@ -109,6 +134,7 @@ async function main(): Promise<void> {
|
||||
|
||||
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);
|
||||
@@ -128,6 +154,10 @@ async function main(): Promise<void> {
|
||||
`${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();
|
||||
|
||||
@@ -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();
|
||||
@@ -41,6 +73,49 @@ export function createApp(): Hono {
|
||||
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/*',
|
||||
|
||||
@@ -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()));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
@@ -300,3 +300,201 @@ 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;
|
||||
}
|
||||
|
||||
@@ -5,3 +5,5 @@ export * from './score.js';
|
||||
export * from './nuts.js';
|
||||
export * from './warnings.js';
|
||||
export * from './fedimint.js';
|
||||
export * from './lnurl.js';
|
||||
export * from './indexing.js';
|
||||
|
||||
@@ -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]}` : ''}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -28,6 +28,9 @@ function isDisallowedHost(hostname: string): boolean {
|
||||
if (hostname.endsWith('.local')) 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;
|
||||
@@ -50,6 +53,67 @@ function isPrivateIpv6(hostname: string): boolean {
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -13,6 +13,15 @@ export const KIND_REVIEW = 38000;
|
||||
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;
|
||||
|
||||
@@ -30,6 +39,7 @@ export const KIND_PROFILE = 0;
|
||||
export const ANNOUNCEMENT_KINDS = {
|
||||
cashu: KIND_MINT_ANNOUNCEMENT,
|
||||
fedimint: KIND_FEDIMINT_ANNOUNCEMENT,
|
||||
lnurl: KIND_LNURL_ANNOUNCEMENT,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -224,6 +234,11 @@ 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.
|
||||
*
|
||||
|
||||
+112
-5
@@ -18,7 +18,7 @@ export type MintStatus = 'online' | 'degraded' | 'offline' | 'unknown' | 'announ
|
||||
* 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' | (string & {});
|
||||
export type MintType = 'cashu' | 'fedimint' | 'lnurl' | (string & {});
|
||||
|
||||
/** One item of `GET /api/mints`. */
|
||||
export interface MintListItem {
|
||||
@@ -26,7 +26,7 @@ export interface MintListItem {
|
||||
host: string;
|
||||
name: string | null;
|
||||
icon: string | null;
|
||||
/** 'cashu' or 'fedimint'. Rows written before the column existed read as 'cashu'. */
|
||||
/** 'cashu', 'fedimint' or 'lnurl'. Rows written before the column existed read as 'cashu'. */
|
||||
type: MintType;
|
||||
status: MintStatus;
|
||||
last_online: number | null;
|
||||
@@ -48,10 +48,14 @@ export type RatingDistribution = Record<'1' | '2' | '3' | '4' | '5', number>;
|
||||
/**
|
||||
* `GET /api/mints/:host`.
|
||||
*
|
||||
* The Fedimint keys are optional and absent on a Cashu mint, which is what keeps the
|
||||
* Cashu payload unchanged. Read them through `FedimintDetail` after checking `type`.
|
||||
* 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> {
|
||||
export interface MintDetail
|
||||
extends MintListItem,
|
||||
Partial<FedimintFields>,
|
||||
Partial<LnurlFields> {
|
||||
description: string | null;
|
||||
pubkey: string | null;
|
||||
info: MintInfo | null;
|
||||
@@ -100,6 +104,97 @@ export interface FedimintFields {
|
||||
/** `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`.
|
||||
*
|
||||
@@ -127,6 +222,18 @@ export interface Stats {
|
||||
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`. */
|
||||
|
||||
+240
-9
@@ -88,7 +88,14 @@ export type MintWarningKind =
|
||||
| '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
|
||||
| '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;
|
||||
@@ -139,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 {
|
||||
@@ -218,9 +238,28 @@ export const WARNING_COPY_EN: Record<string, string> = {
|
||||
'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.',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -247,12 +286,24 @@ 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.
|
||||
@@ -284,6 +335,7 @@ export function getMintWarnings(
|
||||
options: MintWarningOptions = {},
|
||||
): MintWarning[] {
|
||||
if (mint.type === 'fedimint') return fedimintWarnings(mint, options);
|
||||
if (mint.type === 'lnurl') return lnurlWarnings(mint, options);
|
||||
return cashuWarnings(mint, options);
|
||||
}
|
||||
|
||||
@@ -373,13 +425,7 @@ function cashuWarnings(
|
||||
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
|
||||
@@ -468,6 +514,183 @@ function cashuWarnings(
|
||||
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
|
||||
@@ -533,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 {
|
||||
@@ -541,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;
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 56 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 |
@@ -28,7 +28,7 @@ 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 } from './model.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));
|
||||
@@ -96,6 +96,8 @@ async function buildFixtures() {
|
||||
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`);
|
||||
@@ -114,9 +116,10 @@ async function build() {
|
||||
const previous = JSON.parse(await readFile(manifestFile, 'utf8').catch(() => '{}'));
|
||||
const previousImages = previous.images ?? {};
|
||||
|
||||
const [cashu, fedimint, stats] = await Promise.all([
|
||||
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'),
|
||||
]);
|
||||
|
||||
@@ -135,6 +138,7 @@ async function build() {
|
||||
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,
|
||||
@@ -158,6 +162,8 @@ async function build() {
|
||||
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);
|
||||
|
||||
+100
-14
@@ -29,6 +29,8 @@ 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';
|
||||
@@ -137,22 +139,29 @@ function statusChip(model) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The type badge that makes a federation card unmistakable at thumbnail size. */
|
||||
function fedimintBadge() {
|
||||
/**
|
||||
* 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 rgba(167,139,250,.5)`,
|
||||
color: VIOLET,
|
||||
border: `1.5px solid ${borderColor}`,
|
||||
color,
|
||||
fontFamily: MONO,
|
||||
fontWeight: 500,
|
||||
fontSize: '18px',
|
||||
letterSpacing: '3px',
|
||||
},
|
||||
'FEDIMINT',
|
||||
label,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -380,14 +389,85 @@ function thirdCellFedimint(model) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The background glow: amber for a healthy card, red-tinted for trouble, violet for Fedimint. */
|
||||
/**
|
||||
* 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 === 'cashu' && model.warning && model.warning.severity === 'critical');
|
||||
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',
|
||||
@@ -414,10 +494,12 @@ function brandRow(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.
|
||||
const chipRoom = 240 + (isFed ? 180 : 0);
|
||||
// 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(
|
||||
@@ -433,7 +515,7 @@ export function mintCard(model, iconData) {
|
||||
fontFamily: INTER,
|
||||
},
|
||||
glow(model),
|
||||
brandRow(isFed ? 'Fedimint reviews' : 'Cashu mint reviews'),
|
||||
brandRow(isFed ? 'Fedimint reviews' : isLnurl ? 'LNURL mint reviews' : 'Cashu mint reviews'),
|
||||
h(
|
||||
'div',
|
||||
{ display: 'flex', alignItems: 'center', gap: '32px', marginTop: '52px', flexGrow: 1 },
|
||||
@@ -459,7 +541,8 @@ export function mintCard(model, iconData) {
|
||||
model.name,
|
||||
),
|
||||
statusChip(model),
|
||||
isFed && fedimintBadge(),
|
||||
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.
|
||||
@@ -479,7 +562,7 @@ export function mintCard(model, iconData) {
|
||||
},
|
||||
ratingCell(model),
|
||||
reviewsCell(model),
|
||||
isFed ? thirdCellFedimint(model) : thirdCellCashu(model),
|
||||
isFed ? thirdCellFedimint(model) : isLnurl ? thirdCellLnurl(model) : thirdCellCashu(model),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -525,13 +608,16 @@ export function defaultCard(model) {
|
||||
h(
|
||||
'div',
|
||||
{ fontFamily: INTER, fontWeight: 500, fontSize: '28px', color: MUTED, marginTop: '14px' },
|
||||
'Cashu mint and Fedimint reviews, signed on Nostr',
|
||||
'Cashu, Fedimint and LNURL mint reviews, signed on Nostr',
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ display: 'flex', gap: '86px', marginTop: '64px' },
|
||||
// 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'),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* The edge cases the template must survive, as synthetic API payloads.
|
||||
*
|
||||
* Run through the same cashuModel/fedimintModel derivation as real data — a fixture
|
||||
* 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.
|
||||
@@ -32,6 +32,31 @@ const base = {
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -109,4 +134,42 @@ export const FIXTURES = [
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -14,10 +14,15 @@
|
||||
* 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, parseLimits, readCapabilities } from '@cashumints/shared';
|
||||
import { getMintWarnings, msatToSat, parseLimits, readCapabilities } from '@cashumints/shared';
|
||||
|
||||
/** Bump when the template's layout or copy changes, to regenerate every image. */
|
||||
export const TEMPLATE_VERSION = 1;
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -115,6 +120,11 @@ function warningCell(warning, mint, now) {
|
||||
'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;
|
||||
|
||||
@@ -135,11 +145,26 @@ function warningCell(warning, mint, now) {
|
||||
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'].includes(warning.kind) && mint.status === 'offline' && since !== null) {
|
||||
if (
|
||||
['melt-only', 'melt-disabled', 'frozen', 'lnurl-withdrawals-disabled'].includes(warning.kind) &&
|
||||
mint.status === 'offline' &&
|
||||
since !== null
|
||||
) {
|
||||
sub = `Offline since ${shortDate(since)}`;
|
||||
}
|
||||
|
||||
@@ -227,6 +252,53 @@ export function fedimintModel(fed, { iconHash = null, now = Math.floor(Date.now(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
@@ -235,6 +307,7 @@ export function defaultModel(stats) {
|
||||
slug: 'default',
|
||||
mints: stats.cashu_total,
|
||||
federations: stats.fedimint_total,
|
||||
lnurl: stats.lnurl_total,
|
||||
reviews: stats.reviews_total,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)`);
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,23 +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: '/fedimints', label: t('footer.allFedimints') },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
/*
|
||||
@@ -39,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>
|
||||
|
||||
@@ -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 searchable list. `inline` is the footer's plain row of 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,31 +50,38 @@ 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" />
|
||||
@@ -82,8 +96,14 @@ const currentCode = locale.toUpperCase();
|
||||
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}
|
||||
@@ -105,33 +125,9 @@ const currentCode = locale.toUpperCase();
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
</details>
|
||||
|
||||
<style>
|
||||
/* ---------- the header control ---------- */
|
||||
|
||||
.lang-menu { position: relative; flex-shrink: 0; }
|
||||
|
||||
.lang-menu summary {
|
||||
@@ -148,14 +144,13 @@ 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-panel {
|
||||
position: absolute; top: calc(100% + 8px); right: 0; z-index: 60;
|
||||
position: absolute; z-index: 60;
|
||||
width: min(260px, calc(100vw - 24px));
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: 12px;
|
||||
padding: 6px;
|
||||
@@ -181,11 +176,11 @@ const currentCode = locale.toUpperCase();
|
||||
.lang-search input::-webkit-search-cancel-button { cursor: pointer; }
|
||||
.lang-list {
|
||||
max-height: min(360px, calc(100dvh - 150px));
|
||||
margin-top: 6px; padding-right: 2px;
|
||||
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;
|
||||
@@ -201,6 +196,11 @@ const currentCode = locale.toUpperCase();
|
||||
}
|
||||
.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.
|
||||
*
|
||||
@@ -211,37 +211,49 @@ 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;
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
---
|
||||
import {
|
||||
federationIdFromSlug, getMintWarnings, mintChip,
|
||||
type MintCapabilities, type MintListItem,
|
||||
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';
|
||||
@@ -28,28 +29,43 @@ 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);
|
||||
|
||||
/*
|
||||
* One card, both ecosystems.
|
||||
* 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 both, and a reader moving
|
||||
* between /mints and /fedimints should not have to re-learn a card.
|
||||
* 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 domain = fedimint ? federationIdFromSlug(mint.host) : displayDomain(mint.url);
|
||||
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;
|
||||
@@ -57,7 +73,9 @@ 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';
|
||||
const href = localePath(`${fedimint ? '/fedimint' : '/mint'}/${mint.host}`, locale);
|
||||
// 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
|
||||
@@ -65,14 +83,18 @@ const href = localePath(`${fedimint ? '/fedimint' : '/mint'}/${mint.host}`, loca
|
||||
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);
|
||||
|
||||
@@ -11,11 +11,12 @@
|
||||
* the same code that prerendered them, so a live upgrade can never leave the three
|
||||
* disagreeing with each other.
|
||||
*
|
||||
* Both a mint page and a federation page mount this, unchanged. It fetches the same
|
||||
* endpoint for both and updates whichever of the four regions the page actually has:
|
||||
* a federation has no limits cell and no NUT rows, and a mint has no modules panel, so
|
||||
* each lookup simply misses. `getMintWarnings` branches on the `type` in the payload,
|
||||
* so a fresh read can never put a Cashu banner on a federation.
|
||||
* 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;
|
||||
@@ -26,15 +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');
|
||||
@@ -85,7 +89,7 @@ const { host } = Astro.props;
|
||||
}
|
||||
|
||||
/** Keep the places that repeat the banner's facts in step with it. */
|
||||
function renderCapabilities(mint: MintDetail, f: Formatters): void {
|
||||
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
|
||||
@@ -98,6 +102,35 @@ const { host } = Astro.props;
|
||||
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]');
|
||||
@@ -155,8 +188,20 @@ const { host } = Astro.props;
|
||||
*/
|
||||
const checked = document.querySelector<HTMLElement>('[data-live-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');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,475 @@
|
||||
---
|
||||
/**
|
||||
* 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 placeholder and the review prompt all differ per ecosystem, and all
|
||||
* three 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; 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')),
|
||||
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} 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 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; body: string }> => {
|
||||
try {
|
||||
return JSON.parse(dialog.dataset['rbuWords'] ?? '{}') as Record<
|
||||
string,
|
||||
{ label: string; placeholder: 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;
|
||||
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 ---------- */
|
||||
|
||||
function showStep(next: 'identify' | 'review'): 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;
|
||||
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');
|
||||
// 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>
|
||||
+101
-473
@@ -13,6 +13,7 @@
|
||||
import type { RatingDistribution } from '@cashumints/shared';
|
||||
import type { ReviewSubject } from '../lib/review-subject';
|
||||
import { renderBones } from 'boneyard-js';
|
||||
import WriteForm from './WriteForm.astro';
|
||||
import reviewsBones from '../bones/reviews-panel.bones.json';
|
||||
import { BONE_COLOR } from '../lib/skeleton';
|
||||
import { useI18n } from '../i18n';
|
||||
@@ -20,8 +21,14 @@ import { formatters } from '../i18n/format';
|
||||
import { pageLocale } from '../i18n/paths';
|
||||
|
||||
interface Props {
|
||||
/** What is being reviewed: the tags a review carries and the filters that find them. */
|
||||
subject: ReviewSubject;
|
||||
/**
|
||||
* What is being reviewed: the tags a review carries and the filters that find them.
|
||||
*
|
||||
* Null on the 404 page alone, which renders this panel for a mint it has not resolved
|
||||
* yet: the resolver writes the subject onto the root and tells the island to start.
|
||||
* Every prerendered page passes a real one.
|
||||
*/
|
||||
subject: ReviewSubject | null;
|
||||
/** The counts the API already knows, prerendered so the panel is honest without JS. */
|
||||
reviewCount: number;
|
||||
distribution: RatingDistribution;
|
||||
@@ -43,7 +50,7 @@ const dist = distribution;
|
||||
* about anything. One key per ecosystem, falling back to the Cashu wording for a type
|
||||
* whose own has not been written yet.
|
||||
*/
|
||||
const placeholderKey = `reviews.dialog.bodyPlaceholder.${subject.type}`;
|
||||
const placeholderKey = `reviews.dialog.bodyPlaceholder.${subject?.type ?? 'cashu'}`;
|
||||
const bodyPlaceholder = t.has(placeholderKey)
|
||||
? t(placeholderKey)
|
||||
: t('reviews.dialog.bodyPlaceholder.cashu');
|
||||
@@ -98,7 +105,7 @@ function escapeText(value: string): string {
|
||||
aria-labelledby="reviews-title"
|
||||
data-reveal
|
||||
data-reviews-panel
|
||||
data-subject={JSON.stringify(subject)}
|
||||
data-subject={subject ? JSON.stringify(subject) : ''}
|
||||
>
|
||||
<div class="panel-head">
|
||||
<h2 class="panel-title" id="reviews-title">{t('reviews.title')}</h2>
|
||||
@@ -144,7 +151,15 @@ function escapeText(value: string): string {
|
||||
target for `pnpm bones`, which snapshots this element's single child.
|
||||
*/}
|
||||
<div data-review-list data-review-count={reviewCount}>
|
||||
<div data-boneyard="reviews-panel" data-boneyard-config='{"excludeSelectors":[".sr-only"]}'>
|
||||
{/*
|
||||
`data-boneyard` only when there is something to measure.
|
||||
|
||||
`pnpm bones` captures two pages, and the second is a /mint/ URL with no prerendered
|
||||
page — which now carries this panel too, with no subject and nothing in it. Marked
|
||||
as a capture target there, it would overwrite the reviews bones taken from the real
|
||||
mint page a moment earlier with the bones of an empty box.
|
||||
*/}
|
||||
<div data-boneyard={subject ? 'reviews-panel' : undefined} data-boneyard-config='{"excludeSelectors":[".sr-only"]}'>
|
||||
<div data-review-items>
|
||||
{
|
||||
showBoneBlocks ? (
|
||||
@@ -191,8 +206,8 @@ function escapeText(value: string): string {
|
||||
every page load.
|
||||
-->
|
||||
<dialog class="modal write-dialog" data-write-dialog aria-labelledby="write-title">
|
||||
<form method="dialog" class="modal-body write-form" data-write-form novalidate>
|
||||
<div class="modal-head">
|
||||
<WriteForm locale={locale} placeholder={bodyPlaceholder}>
|
||||
<div class="modal-head" slot="head">
|
||||
{/* The mint's name is data, dropped into a translated sentence. */}
|
||||
<h2 id="write-title">{t('reviews.dialog.title', { name })}</h2>
|
||||
<button type="button" class="copy-btn" data-write-close aria-label={t('common.close')}>
|
||||
@@ -201,52 +216,7 @@ function escapeText(value: string): string {
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="modal-note">{t('reviews.dialog.note')}</p>
|
||||
|
||||
<div class="write-id" data-write-identity></div>
|
||||
|
||||
<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>
|
||||
))
|
||||
}
|
||||
<span class="star-label" data-star-label aria-live="polite">
|
||||
{t('reviews.dialog.chooseRating')}
|
||||
</span>
|
||||
</fieldset>
|
||||
|
||||
<label class="write-label" for="review-body">{t('reviews.dialog.bodyLabel')}</label>
|
||||
<div class="write-box">
|
||||
<textarea
|
||||
id="review-body"
|
||||
name="content"
|
||||
rows="5"
|
||||
aria-describedby="review-count"
|
||||
placeholder={bodyPlaceholder}></textarea>
|
||||
<span class="write-count" id="review-count" 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>
|
||||
</form>
|
||||
</WriteForm>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
@@ -263,13 +233,11 @@ function escapeText(value: string): string {
|
||||
import { useI18n } from '../i18n/client';
|
||||
import { formatters } from '../i18n/format';
|
||||
import { enterStagger, onReady, prefersReducedMotion, scrollBehavior } from '../scripts/reveal';
|
||||
import { getExtension, loadProfiles, loadReviews, publishReview, safeNpub, type LoadedReview } from '../lib/reviews-client';
|
||||
import { loadProfiles, loadReviews, safeNpub, type LoadedReview } from '../lib/reviews-client';
|
||||
import type { ReviewSubject } from '../lib/review-subject';
|
||||
import { activeSigner, onSessionChange, onSignerStatus, readSession, signerErrorMessage } from '../lib/nostr-signer';
|
||||
// The identity row is the same identity the header shows, drawn by the same helpers.
|
||||
import { avatarMarkup, identityLines, methodChipHtml, openLogin, resolveProfile, toNpub } from '../lib/auth-ui';
|
||||
import { closeModal, openModal, wireModal } from '../lib/modal';
|
||||
import type { Profile } from '@cashumints/shared';
|
||||
// Stars, body, identity row, draft safety and publishing: shared with the
|
||||
// review-by-URL dialog the index pages open, so there is one signing flow on the site.
|
||||
import { takeFreshReview, wireWriteForm } from '../lib/write-review';
|
||||
// The card itself lives in one place: /reviews renders the identical markup from
|
||||
// the same functions.
|
||||
import {
|
||||
@@ -334,6 +302,12 @@ function escapeText(value: string): string {
|
||||
const expected = Number(listWrap.dataset['reviewCount'] ?? '0');
|
||||
|
||||
let all: LoadedReview[] = [];
|
||||
/**
|
||||
* A review published from the review-by-URL dialog on an index page, on its way
|
||||
* here. Read once, at setup, so a reload does not resurrect it; merged in below
|
||||
* when the relays answer. See `takeFreshReview`.
|
||||
*/
|
||||
const handedForward = takeFreshReview(subject.key);
|
||||
let filter: 'all' | '5' | 'critical' | 'recent' = 'all';
|
||||
let page = 1;
|
||||
/**
|
||||
@@ -619,13 +593,37 @@ function escapeText(value: string): string {
|
||||
// coming back for a mint the API counted reviews for means the relays did not
|
||||
// answer, not that the reviews are gone. Saying "no reviews yet" there would
|
||||
// be a lie the reader cannot check.
|
||||
if (loaded.length === 0 && expected > 0) {
|
||||
if (loaded.length === 0 && expected > 0 && !handedForward) {
|
||||
showRelayError();
|
||||
return;
|
||||
}
|
||||
|
||||
all = loaded;
|
||||
|
||||
/*
|
||||
* The author arrived here from the review-by-URL dialog moments ago. Their own
|
||||
* review is on the relays but may not be in this answer yet, so it goes to the
|
||||
* top as an optimistic card — the same card, with the same "propagating" note,
|
||||
* that they would have seen had they written it on this page.
|
||||
*/
|
||||
if (handedForward && !all.some((review) => review.id === handedForward.id)) {
|
||||
optimistic.set(handedForward.id, {
|
||||
accepted: handedForward.accepted,
|
||||
total: handedForward.total,
|
||||
});
|
||||
all.unshift({
|
||||
id: handedForward.id,
|
||||
pubkey: handedForward.pubkey,
|
||||
npub: safeNpub(handedForward.pubkey),
|
||||
created_at: handedForward.created_at,
|
||||
rating: handedForward.rating,
|
||||
content: handedForward.content,
|
||||
mint_url: subject.key,
|
||||
profile: null,
|
||||
});
|
||||
markNewest = true;
|
||||
}
|
||||
|
||||
/*
|
||||
* A `#review-{id}` deep link: land on the page that holds the card, then
|
||||
* scroll to it once it is drawn. A shared link wins over the reader's own
|
||||
@@ -731,393 +729,35 @@ function escapeText(value: string): string {
|
||||
|
||||
/* ---------- write a review ---------- */
|
||||
|
||||
/*
|
||||
* The form itself is `lib/write-review.ts`, which the review-by-URL dialog on the
|
||||
* index pages drives too. What stays here is the half that is about *this list*:
|
||||
* putting the fresh review at the top, resetting the filters so it is visible, and
|
||||
* pulsing the button that opened the dialog.
|
||||
*/
|
||||
const dialog = document.querySelector<HTMLDialogElement>('[data-write-dialog]');
|
||||
const form = dialog?.querySelector<HTMLFormElement>('[data-write-form]');
|
||||
const errorBox = dialog?.querySelector<HTMLElement>('[data-write-error]');
|
||||
const statusBox = dialog?.querySelector<HTMLElement>('[data-write-status]');
|
||||
const submit = dialog?.querySelector<HTMLButtonElement>('[data-write-submit]');
|
||||
const submitLabel = dialog?.querySelector<HTMLElement>('[data-write-submit-label]');
|
||||
const textarea = dialog?.querySelector<HTMLTextAreaElement>('textarea');
|
||||
const charCount = dialog?.querySelector<HTMLElement>('[data-char-count]');
|
||||
const starLabel = dialog?.querySelector<HTMLElement>('[data-star-label]');
|
||||
const identityRow = dialog?.querySelector<HTMLElement>('[data-write-identity]');
|
||||
const whyDisabled = dialog?.querySelector<HTMLElement>('[data-write-why]');
|
||||
|
||||
/** Reviews are capped at this length, and the counter turns red past it. */
|
||||
const MAX_CHARS = 2000;
|
||||
/** Below this a review says nothing anyone can use. */
|
||||
const MIN_CHARS = 10;
|
||||
|
||||
// One phrase per star, in the reader's words rather than the site's. Shown as
|
||||
// "2: had problems" beside the picker, and read out by the live region.
|
||||
// One phrase per star, from the catalog. Read through `t` rather than held in a
|
||||
// table here, so the phrases follow the page's language after a view transition.
|
||||
const starPhrase = (rating: number): string => t(`reviews.dialog.stars.${rating}`);
|
||||
|
||||
/** True once a publish is in flight, so nothing else runs over it. */
|
||||
let publishing = false;
|
||||
/** Who is going to sign, resolved when the dialog opens. */
|
||||
let canSign = false;
|
||||
|
||||
function showError(message: string): void {
|
||||
if (!errorBox) return;
|
||||
errorBox.textContent = message;
|
||||
errorBox.hidden = message === '';
|
||||
}
|
||||
|
||||
function showStatus(message: string): void {
|
||||
if (!statusBox) return;
|
||||
statusBox.textContent = message;
|
||||
statusBox.hidden = message === '';
|
||||
}
|
||||
|
||||
/* ---------- draft safety ---------- */
|
||||
|
||||
/**
|
||||
* A written review survives an accidental close.
|
||||
*
|
||||
* sessionStorage, keyed by mint: it lasts as long as the tab and no longer, which
|
||||
* is the life of the intention. Nothing here is secret, but nothing here belongs
|
||||
* on the machine a week later either.
|
||||
*/
|
||||
const DRAFT_KEY = `cashumints:draft:${subject.key}`;
|
||||
|
||||
function saveDraft(): void {
|
||||
if (!form) return;
|
||||
const data = new FormData(form);
|
||||
const rating = String(data.get('rating') ?? '');
|
||||
const content = String(data.get('content') ?? '');
|
||||
try {
|
||||
if (!rating && !content.trim()) sessionStorage.removeItem(DRAFT_KEY);
|
||||
else sessionStorage.setItem(DRAFT_KEY, JSON.stringify({ rating, content }));
|
||||
} catch {
|
||||
// Storage blocked. The draft lives in the open dialog and nowhere else.
|
||||
}
|
||||
}
|
||||
|
||||
function clearDraft(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(DRAFT_KEY);
|
||||
} catch {
|
||||
// Nothing stored to begin with.
|
||||
}
|
||||
}
|
||||
|
||||
function restoreDraft(): void {
|
||||
if (!form) return;
|
||||
let draft: { rating?: string; content?: string } | null = null;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(DRAFT_KEY);
|
||||
draft = raw ? (JSON.parse(raw) as { rating?: string; content?: string }) : null;
|
||||
} catch {
|
||||
draft = null;
|
||||
}
|
||||
if (!draft) return;
|
||||
|
||||
if (draft.rating) {
|
||||
const star = form.querySelector<HTMLInputElement>(`input[name="rating"][value="${draft.rating}"]`);
|
||||
if (star) star.checked = true;
|
||||
}
|
||||
if (draft.content && textarea) textarea.value = draft.content;
|
||||
syncStarLabel();
|
||||
syncCount();
|
||||
}
|
||||
|
||||
/* ---------- the form's own state ---------- */
|
||||
|
||||
function chosenRating(): number {
|
||||
return Number(new FormData(form ?? undefined).get('rating') ?? 0);
|
||||
}
|
||||
|
||||
function syncStarLabel(): void {
|
||||
if (!starLabel) return;
|
||||
const rating = chosenRating();
|
||||
starLabel.textContent = rating
|
||||
// The rating is a bare numeral here, so it is passed as a string: "3: good",
|
||||
// never "3,0: good".
|
||||
? t('reviews.dialog.starLabel', { n: String(rating), phrase: starPhrase(rating) })
|
||||
: t('reviews.dialog.chooseRating');
|
||||
}
|
||||
|
||||
function syncCount(): void {
|
||||
const length = textarea?.value.length ?? 0;
|
||||
if (charCount) {
|
||||
charCount.textContent = t('reviews.dialog.charCount', { n: length, max: MAX_CHARS });
|
||||
charCount.classList.toggle('over', length > MAX_CHARS);
|
||||
}
|
||||
syncSubmit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether "Sign and publish" is available, and if not, why.
|
||||
*
|
||||
* The reason is always on screen: a button that is off for an unstated reason is
|
||||
* a button people click twice and then leave.
|
||||
*/
|
||||
function syncSubmit(): void {
|
||||
if (!submit) return;
|
||||
const length = textarea?.value.trim().length ?? 0;
|
||||
const rating = chosenRating();
|
||||
|
||||
const over = (textarea?.value.length ?? 0) - MAX_CHARS;
|
||||
const reason = publishing
|
||||
? ''
|
||||
: !canSign ? t('reviews.dialog.why.login')
|
||||
: !rating ? t('reviews.dialog.why.rating')
|
||||
: length === 0 ? t('reviews.dialog.why.words')
|
||||
: length < MIN_CHARS ? t('reviews.dialog.why.more', { n: MIN_CHARS - length })
|
||||
: over > 0 ? t('reviews.dialog.why.over', { n: over })
|
||||
: '';
|
||||
|
||||
submit.disabled = publishing || reason !== '';
|
||||
if (whyDisabled) whyDisabled.textContent = publishing ? '' : reason;
|
||||
}
|
||||
|
||||
/** The publish button, mid-flight or back to rest. */
|
||||
function setBusy(busy: boolean, label = t('reviews.dialog.submit')): void {
|
||||
publishing = busy;
|
||||
submit?.classList.toggle('is-busy', busy);
|
||||
if (submitLabel) submitLabel.textContent = label;
|
||||
syncSubmit();
|
||||
}
|
||||
|
||||
/* ---------- who is signing ---------- */
|
||||
|
||||
/**
|
||||
* Draw the identity row, and decide whether publishing is possible at all.
|
||||
*
|
||||
* Three states, in the order they are likely:
|
||||
* - logged in: the session's signer, named, with a way to switch
|
||||
* - no session but a NIP-07 extension: quick-sign, exactly as before logins
|
||||
* existed, and no session is created by using it
|
||||
* - neither: the row becomes the way in, and the publish button says so
|
||||
*
|
||||
* The extension's pubkey is asked for here, on open, and never on page load: it
|
||||
* is a question some extensions put to the reader, and a page that asks it for
|
||||
* every visitor who might one day write a review is a page that nags.
|
||||
*/
|
||||
async function renderIdentity(): Promise<void> {
|
||||
if (!identityRow) return;
|
||||
|
||||
const session = readSession();
|
||||
if (session) {
|
||||
// A session that names an extension is only as good as the extension: it can
|
||||
// be uninstalled, or disabled for this site, between one visit and the next.
|
||||
canSign = session.method !== 'extension' || getExtension() !== null;
|
||||
syncSubmit();
|
||||
if (!canSign) {
|
||||
identityRow.innerHTML =
|
||||
`<span class="write-id-who"><span class="write-id-lead">${escapeHtml(
|
||||
t('reviews.dialog.identity.extensionGone'),
|
||||
)}</span></span>` +
|
||||
`<button type="button" class="btn" data-login-open>${escapeHtml(
|
||||
t('login.open'),
|
||||
)}</button>`;
|
||||
return;
|
||||
}
|
||||
const npub = await toNpub(session.pubkey);
|
||||
|
||||
const draw = (profile: Profile | null): void => {
|
||||
const { primary, secondary, named } = identityLines(profile, npub, t);
|
||||
// Whichever of the two lines is the npub is the machine-flavoured one, and
|
||||
// that is the one set in mono (DESIGN.md). The other is a name or a phrase.
|
||||
identityRow.innerHTML =
|
||||
avatarMarkup(session.pubkey, profile, npub, 34) +
|
||||
`<span class="write-id-who">` +
|
||||
`<span class="write-id-name${named ? '' : ' mono'}">${escapeHtml(primary)}</span>` +
|
||||
`<span class="write-id-sub${named ? ' mono' : ''}">${escapeHtml(secondary)}</span></span>` +
|
||||
methodChipHtml(session.method, t) +
|
||||
`<button type="button" class="write-id-switch" data-login-open>${escapeHtml(
|
||||
t('reviews.dialog.identity.switch'),
|
||||
)}</button>`;
|
||||
};
|
||||
|
||||
// Drawn from the npub first, then again if a name turns up: the row is the
|
||||
// same height either way, so nothing under it moves when the name lands.
|
||||
draw(null);
|
||||
const profile = await resolveProfile(session.pubkey);
|
||||
if (readSession()?.pubkey !== session.pubkey || !profile?.found) return;
|
||||
draw(profile);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getExtension()) {
|
||||
canSign = false;
|
||||
syncSubmit();
|
||||
identityRow.innerHTML =
|
||||
`<span class="write-id-who"><span class="write-id-lead">${escapeHtml(
|
||||
t('reviews.dialog.identity.needSigner'),
|
||||
)}</span></span>` +
|
||||
`<button type="button" class="btn" data-login-open>${escapeHtml(
|
||||
t('reviews.dialog.identity.loginToPublish'),
|
||||
)}</button>`;
|
||||
return;
|
||||
}
|
||||
|
||||
canSign = true;
|
||||
syncSubmit();
|
||||
identityRow.innerHTML =
|
||||
`<span class="write-id-who"><span class="write-id-name">${escapeHtml(
|
||||
t('reviews.dialog.identity.extension'),
|
||||
)}</span>` +
|
||||
`<span class="write-id-sub">${escapeHtml(
|
||||
t('reviews.dialog.identity.readingKey'),
|
||||
)}</span></span>` +
|
||||
methodChipHtml('extension', t);
|
||||
|
||||
let pubkey = '';
|
||||
try {
|
||||
pubkey = (await getExtension()?.getPublicKey()) ?? '';
|
||||
} catch {
|
||||
// Declined, or the extension is locked. Publishing still works: it will ask
|
||||
// again at signing time, which is the request that actually matters.
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/i.test(pubkey)) {
|
||||
const sub = identityRow.querySelector('.write-id-sub');
|
||||
if (sub) sub.textContent = t('reviews.dialog.identity.willAsk');
|
||||
return;
|
||||
}
|
||||
|
||||
const npub = await toNpub(pubkey);
|
||||
const profile = await resolveProfile(pubkey);
|
||||
const { primary, named } = identityLines(profile, npub, t);
|
||||
identityRow.innerHTML =
|
||||
avatarMarkup(pubkey, profile, npub, 34) +
|
||||
`<span class="write-id-who">` +
|
||||
`<span class="write-id-name${named ? '' : ' mono'}">${escapeHtml(primary)}</span>` +
|
||||
`<span class="write-id-sub">${escapeHtml(
|
||||
t('reviews.dialog.identity.extension'),
|
||||
)}</span></span>` +
|
||||
methodChipHtml('extension', t);
|
||||
}
|
||||
|
||||
/* ---------- opening and closing ---------- */
|
||||
|
||||
if (dialog) {
|
||||
wireModal(dialog, {
|
||||
// A written review is not thrown away on a stray click outside the box.
|
||||
onBackdrop: () =>
|
||||
(textarea?.value.trim().length ?? 0) === 0 ||
|
||||
window.confirm(t('reviews.dialog.confirmClose')),
|
||||
onClose: () => {
|
||||
saveDraft();
|
||||
showStatus('');
|
||||
setBusy(false);
|
||||
const writer = wireWriteForm({
|
||||
dialog,
|
||||
subject: () => subject,
|
||||
onProfile: (pubkey, profile) => {
|
||||
// The relays answer the publish before the kind-0 lookup answers, near enough
|
||||
// every time, so the card goes up as "Anon" and the author's name cross-fades
|
||||
// in a moment later, exactly as it does on every other card.
|
||||
for (const review of all) if (review.pubkey === pubkey) review.profile = profile;
|
||||
patchProfiles();
|
||||
},
|
||||
});
|
||||
|
||||
// The signer talks back while a signature is in flight: reconnecting, waiting,
|
||||
// approve-in-another-window. Mirrored into the dialog so the reader is never
|
||||
// watching a spinner with nothing behind it.
|
||||
onSignerStatus((update) => {
|
||||
if (dialog.open && publishing) showStatus(update.message);
|
||||
});
|
||||
|
||||
// Logging in from the identity row redraws it, without closing this dialog.
|
||||
onSessionChange(() => {
|
||||
if (dialog.open) void renderIdentity();
|
||||
});
|
||||
}
|
||||
|
||||
textarea?.addEventListener('input', () => {
|
||||
syncCount();
|
||||
saveDraft();
|
||||
});
|
||||
|
||||
form?.addEventListener('change', (event) => {
|
||||
if ((event.target as HTMLInputElement).name !== 'rating') return;
|
||||
syncStarLabel();
|
||||
syncSubmit();
|
||||
saveDraft();
|
||||
});
|
||||
|
||||
for (const button of document.querySelectorAll('[data-write-close]')) {
|
||||
button.addEventListener('click', () => closeModal(dialog!));
|
||||
}
|
||||
|
||||
for (const trigger of document.querySelectorAll('[data-write-open]')) {
|
||||
trigger.addEventListener('click', () => {
|
||||
if (!dialog) return;
|
||||
showError('');
|
||||
showStatus('');
|
||||
setBusy(false);
|
||||
restoreDraft();
|
||||
openModal(dialog);
|
||||
void renderIdentity();
|
||||
// Somewhere to land that is not the close button: the rating is the first
|
||||
// thing to decide, and a checked star keeps its own place in the group.
|
||||
const checked = form?.querySelector<HTMLInputElement>('input[name="rating"]:checked');
|
||||
(checked ?? form?.querySelector<HTMLInputElement>('input[name="rating"]'))?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- publishing ---------- */
|
||||
|
||||
form?.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (!form || !submit || publishing) return;
|
||||
|
||||
const rating = chosenRating();
|
||||
const content = textarea?.value ?? '';
|
||||
|
||||
const signer = activeSigner();
|
||||
if (!signer) {
|
||||
// No session and no extension. The row already says so; open the way in.
|
||||
openLogin(submit);
|
||||
return;
|
||||
}
|
||||
|
||||
showError('');
|
||||
setBusy(true, t('reviews.dialog.signing'));
|
||||
showStatus(
|
||||
signer.method === 'extension'
|
||||
? t('reviews.dialog.waitingExtension')
|
||||
: t('reviews.dialog.waitingSigner'),
|
||||
);
|
||||
|
||||
try {
|
||||
/*
|
||||
* The author's own kind 0, fetched while the relays are still taking the
|
||||
* review. Their pubkey is only knowable once the event is signed, so
|
||||
* `onSigned` starts this at the earliest moment there is: by the time the
|
||||
* card below is inserted the name is usually already in hand, and the author
|
||||
* does not watch their own review appear as "Anon" until the next reload.
|
||||
*
|
||||
* `refresh` skips the cache deliberately. Anyone who has reviewed before may
|
||||
* be sitting on an hour-old negative entry for their own key, and they are
|
||||
* exactly the reader who would notice their name missing.
|
||||
*/
|
||||
let profileLookup: Promise<Profile | null> | null = null;
|
||||
|
||||
const result = await publishReview({
|
||||
signer,
|
||||
subject,
|
||||
rating,
|
||||
content,
|
||||
onSigned: (signed) => {
|
||||
setBusy(true, t('reviews.dialog.publishing'));
|
||||
profileLookup = loadProfiles([signed.pubkey], { refresh: true })
|
||||
.then((profiles) => profiles.get(signed.pubkey) ?? null)
|
||||
.catch(() => {
|
||||
// Relays unreachable for the lookup. The card keeps "Anon" over its
|
||||
// npub, which is what every other unresolved card shows.
|
||||
return null;
|
||||
});
|
||||
},
|
||||
onProgress: (accepted, _settled, total) => {
|
||||
showStatus(t('reviews.dialog.publishingTo', { accepted, total }));
|
||||
},
|
||||
});
|
||||
|
||||
onPublished: (published) => {
|
||||
// Optimistic insert at the top. The relays will echo it back on next load.
|
||||
optimistic.set(result.event.id, { accepted: result.accepted, total: result.total });
|
||||
optimistic.set(published.id, { accepted: published.accepted, total: published.total });
|
||||
all.unshift({
|
||||
id: result.event.id,
|
||||
pubkey: result.event.pubkey,
|
||||
npub: safeNpub(result.event.pubkey),
|
||||
created_at: result.event.created_at,
|
||||
rating,
|
||||
content: content.trim(),
|
||||
id: published.id,
|
||||
pubkey: published.pubkey,
|
||||
npub: safeNpub(published.pubkey),
|
||||
created_at: published.created_at,
|
||||
rating: published.rating,
|
||||
content: published.content,
|
||||
mint_url: subject.key,
|
||||
profile: null,
|
||||
});
|
||||
@@ -1131,8 +771,8 @@ function escapeText(value: string): string {
|
||||
render();
|
||||
updateCounts();
|
||||
|
||||
// The dialog is about to close, so the confirmation belongs on the button
|
||||
// that opened it: one amber pulse, on success only.
|
||||
// The dialog is about to close, so the confirmation belongs on the button that
|
||||
// opened it: one amber pulse, on success only.
|
||||
const trigger = document.querySelector<HTMLElement>('[data-write-open]');
|
||||
if (trigger && !prefersReducedMotion()) {
|
||||
trigger.classList.remove('did-it');
|
||||
@@ -1140,42 +780,30 @@ function escapeText(value: string): string {
|
||||
trigger.classList.add('did-it');
|
||||
trigger.addEventListener('animationend', () => trigger.classList.remove('did-it'), { once: true });
|
||||
}
|
||||
|
||||
// The relays answer the publish before the lookup answers, near enough every
|
||||
// time, so the card goes up as "Anon" and the author's name cross-fades in a
|
||||
// moment later, exactly as it does on every other card.
|
||||
void (profileLookup as Promise<Profile | null> | null)?.then((profile) => {
|
||||
if (!profile?.found) return;
|
||||
for (const review of all) {
|
||||
if (review.pubkey === result.event.pubkey) review.profile = profile;
|
||||
}
|
||||
patchProfiles();
|
||||
},
|
||||
});
|
||||
|
||||
clearDraft();
|
||||
form.reset();
|
||||
syncStarLabel();
|
||||
syncCount();
|
||||
setBusy(false);
|
||||
showStatus('');
|
||||
closeModal(dialog!);
|
||||
} catch (err) {
|
||||
// Back to an editable dialog with everything still in it. A declined
|
||||
// signature is not an error the reader made, and a relay that would not take
|
||||
// it is worth another press of the same button.
|
||||
setBusy(false);
|
||||
showStatus('');
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
showError(
|
||||
message === 'no-relay'
|
||||
? t('reviews.dialog.error.noRelay')
|
||||
: signerErrorMessage(err, t),
|
||||
);
|
||||
for (const button of dialog.querySelectorAll('[data-write-close]')) {
|
||||
button.addEventListener('click', () => writer.close());
|
||||
}
|
||||
|
||||
for (const trigger of document.querySelectorAll('[data-write-open]')) {
|
||||
trigger.addEventListener('click', () => writer.open());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
onReady(setup);
|
||||
|
||||
/*
|
||||
* The 404 resolver renders this panel for a mint that had no subject when the page was
|
||||
* built, and dispatches this once it has one. Setup runs again and finds it.
|
||||
*
|
||||
* Safe to re-run: the first pass with no subject does nothing at all — every listener
|
||||
* and every fetch is inside the `if (root && parsedSubject)` guard — so nothing is
|
||||
* bound twice.
|
||||
*/
|
||||
document.addEventListener('cashumints:subject', setup);
|
||||
</script>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { localePath, useI18n } from '../i18n';
|
||||
import { pageLocale } from '../i18n/paths';
|
||||
|
||||
interface Props {
|
||||
current?: 'mints' | 'fedimints' | '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;
|
||||
}
|
||||
@@ -21,15 +21,23 @@ const t = useI18n(locale);
|
||||
/*
|
||||
* "Cashu mints", not "Mints".
|
||||
*
|
||||
* Two ecosystems are listed now, so a bare "Mints" beside "Fedimints" reads as though
|
||||
* federations were something other than mints — or as though the first link covered
|
||||
* both. 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.
|
||||
* 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' },
|
||||
@@ -79,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
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
/**
|
||||
* The write-a-review form: 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.
|
||||
*
|
||||
* 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 placeholder is a prop for the same reason
|
||||
* the panel already had one: "how did minting and melting work out" means nothing about
|
||||
* a federation.
|
||||
*/
|
||||
import { useI18n, type Locale } from '../i18n';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
/** 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, 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">{t('reviews.dialog.note')}</p>
|
||||
|
||||
<div class="write-id" data-write-identity></div>
|
||||
|
||||
<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>
|
||||
))
|
||||
}
|
||||
<span class="star-label" data-star-label aria-live="polite">
|
||||
{t('reviews.dialog.chooseRating')}
|
||||
</span>
|
||||
</fieldset>
|
||||
|
||||
<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>
|
||||
</form>
|
||||
@@ -61,6 +61,19 @@ translate `en.json` with it beside you.
|
||||
| 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
|
||||
@@ -75,6 +88,14 @@ 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
|
||||
|
||||
+124
-3
@@ -114,6 +114,8 @@
|
||||
"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",
|
||||
@@ -325,7 +327,7 @@
|
||||
"reviews.validate.short": "أكتب ما لا يقل عن {n} شخصية ليكي يكون الاستعراض مفيداً.",
|
||||
"reviews.validate.long": "وتقتصر عمليات الاستعراض على {n} شخصية.",
|
||||
"feed.title": "آخر استعراضات الدار سكّ والاتحادات الفضاء",
|
||||
"feed.description": "كل مراجعات دور السكّ والاتحادات أول ما تتنشر: {reviews} مراجعة على {mints} دار سكّ Cashu و{federations} اتحاد Fedimint، متوقعة على Nostr ومتاحة هنا من الأحدث للأقدم. صفّي حسب النظام أو التقييم أو الاسم.",
|
||||
"feed.description": "كل مراجعة لمنّاتة أو اتحاد أو منّاتة LNURL فور نشرها: {reviews} مراجعة تغطي {mints} منّاتة Cashu و{federations} اتحاد Fedimint و{lnurl} منّاتة LNURL، موقّعة على Nostr ومقروءة هنا من الأحدث. صفِّ حسب المنظومة أو التقييم أو الاسم.",
|
||||
"feed.heading": "آخر استعراضات",
|
||||
"feed.lede": "كل استعراض ليكل اتحاد من اتحادات الدار سكّ والFedimint، أولها، يقرأ مباشرة من نوتستر. ويوقع كل واحد منهم من جانب مؤلفه ويعيش في المناورات، ومش في قاعدة بياناتنا.",
|
||||
"feed.label": "الاستعراضات",
|
||||
@@ -400,7 +402,14 @@
|
||||
"notfound.fb.reviews": "الاستعراضات",
|
||||
"notfound.fb.software": "البرمجيات",
|
||||
"notfound.fb.lastOnline": "آخر الإنترنت",
|
||||
"notfound.fb.note": "ده الدار سكّ تم فهرسه بعد أن تم اخر بناء للموقع لذا ده موجز من الـ \"آي بي آي\" وتظهر الصفحة اليكاملة مع الاستعراضات في إعادة البناء التالية.",
|
||||
"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": "محفظات اليكاكاو الفضاء",
|
||||
"wallets.description": "محفظات (Cashu) التي تعمل مع الدار سكّ المدرجة هنا عبر الهواتف المحمولة والشبكات والبروزر وخط القيادة",
|
||||
"wallets.heading": "الجدار",
|
||||
@@ -654,5 +663,117 @@
|
||||
"footer.allFedimints": "جميع الاتحادات",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "كيف كان القذف والذوبان ودعم العمل؟ كن محدداً",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "كيف تم الإنضمام، ودفعات Lightning ودعم العمل؟ كن محدداً",
|
||||
"fedimint.details.title": "تفاصيل الاتحاد"
|
||||
"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 الخاصة بها غير قابلة للوصول أيضًا، فلم يكن ممكنًا سكّ أو صهر أي شيء."
|
||||
}
|
||||
|
||||
+124
-3
@@ -114,6 +114,8 @@
|
||||
"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": "نسخ رابط جهة إصدار",
|
||||
@@ -325,7 +327,7 @@
|
||||
"reviews.validate.short": "اكتب ما لا يقل عن {n} حرفًا حتى تكون المراجعة مفيدة.",
|
||||
"reviews.validate.long": "يقتصر عدد أحرف التعليقات على {n} حرفًا.",
|
||||
"feed.title": "أحدث التقييمات لـ جهة إصدار و«الفيدرالية» · Cashumints.space",
|
||||
"feed.description": "كل تقييم لـ جهة إصدار و«الفيدرالية» فور نشره: {reviews} تقييمًا عبر الاتحادات {mints} وجهات إصدار Cashu و{federations} وFedimint، تم التوقيع عليها في Nostr ويمكن قراءتها هنا مرتبة حسب الأحدث أولاً. قم بالتصفية حسب النظام البيئي أو التقييم أو الاسم.",
|
||||
"feed.description": "كل مراجعة لمنّاتة أو اتحاد أو منّاتة LNURL فور نشرها: {reviews} مراجعة تغطي {mints} منّاتة Cashu و{federations} اتحاد Fedimint و{lnurl} منّاتة LNURL، موقّعة على Nostr ومقروءة هنا من الأحدث. صفِّ حسب المنظومة أو التقييم أو الاسم.",
|
||||
"feed.heading": "أحدث التقييمات",
|
||||
"feed.lede": "كل تقييم لكل اتحاد من الاتحادات جهة إصدار Cashu وFedimint، مع عرض الأحدث أولاً، يُقرأ مباشرةً من محطات الترحيل Nostr. كل تقييم موقَّع من قبل مؤلفه ويُحفظ على محطات الترحيل، وليس في قاعدة بياناتنا.",
|
||||
"feed.label": "التعليقات",
|
||||
@@ -400,7 +402,14 @@
|
||||
"notfound.fb.reviews": "التعليقات",
|
||||
"notfound.fb.software": "البرامج",
|
||||
"notfound.fb.lastOnline": "آخر مرة كان متصلاً بالإنترنت",
|
||||
"notfound.fb.note": "تم فهرسة جهة إصدار بعد آخر عملية إعادة بناء للموقع، لذا فإن هذا ملخص مستمد من واجهة برمجة التطبيقات (API) الحية. وستظهر الصفحة الكاملة التي تحتوي على التقييمات عند إعادة البناء التالية.",
|
||||
"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": "المحافظ",
|
||||
@@ -654,5 +663,117 @@
|
||||
"footer.allFedimints": "جميع الاتحادات",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "كيف تم حساب الإصدار والاسترداد و\"support\"؟ كن محددًا.",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "كيف سارت الأمور فيما يتعلق بالانضمام، وLightning عملية دفع، والدعم؟ يرجى التحديد.",
|
||||
"fedimint.details.title": "تفاصيل الاتحاد"
|
||||
"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 الخاصة بها غير قابلة للوصول أيضًا، فلم يكن ممكنًا سكّ أو صهر أي شيء."
|
||||
}
|
||||
|
||||
+46
-21
@@ -35,29 +35,50 @@ 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' },
|
||||
];
|
||||
|
||||
/*
|
||||
* The twenty-one locales this table used to carry, and why they are not here.
|
||||
*
|
||||
* Their catalogs are on disk, complete to the key, and none of them ever reached a
|
||||
* reader. `index.ts` imports en, es and nl and nothing else, so every one of those
|
||||
* locales rendered the English catalog under its own `<html lang>`, its own URL prefix
|
||||
* and a full reciprocal hreflang set. That is not a half-translated page, which is bad;
|
||||
* it is twenty-one complete duplicates of the English site, each one telling a crawler
|
||||
* it is a different language. Confirmed in the build output before this was changed:
|
||||
* /ja/mint/mint.mountainlake.io declared `lang="ja"` and said "All Cashu mints".
|
||||
*
|
||||
* Removing them costs no reader anything, because there was never anything there to
|
||||
* lose. What it buys is that the hreflang set now describes three pages that really
|
||||
* are three languages.
|
||||
*
|
||||
* Reviving one is three steps and they must all happen together, or it goes back to
|
||||
* being an English page wearing a language tag:
|
||||
*
|
||||
* 1. add its entry here and its code to locales.mjs
|
||||
* 2. import its catalog in index.ts and add it to CATALOGS <- the step that was missed
|
||||
* 3. run `pnpm check:i18n`; it will name the four footer.* keys added after the
|
||||
* catalogs were frozen, and they need translating before the locale ships
|
||||
*
|
||||
* The catalogs were also machine-translated and several are not shippable as they
|
||||
* stand. The ones with damage in strings the footer itself renders, found while
|
||||
* auditing this list:
|
||||
*
|
||||
* el the brand becomes "Καταιγίδες. Διάστημα" — "Storms. Space"
|
||||
* ro the brand becomes "Casumints.space"
|
||||
* ur the copyright reads "نصب شدہ:" ("Installed:") and drops NIP-87
|
||||
* ja the copyright hallucinates "© 2018" and a rights-reserved clause
|
||||
* ar-eg the © becomes the digit 0; "wallets" becomes "الجدار" ("the wall")
|
||||
* it the © is doubled
|
||||
*
|
||||
* And on the two Arabic entries: `ar` (Modern Standard) is the one to bring back.
|
||||
* `ar-eg` (Egyptian) is a real dialect catalog rather than a duplicate, but it is the
|
||||
* more damaged of the two, and a second RTL page set competing with the first for a
|
||||
* mostly-overlapping audience is not worth its SEO cost. Decided explicitly rather
|
||||
* than by shipping both.
|
||||
*/
|
||||
|
||||
|
||||
export type Locale = string;
|
||||
|
||||
/** Just the codes, for the Astro config and for validating a URL segment. */
|
||||
@@ -102,6 +123,10 @@ export const CLIENT_NAMESPACES = [
|
||||
// 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',
|
||||
|
||||
+122
-3
@@ -116,6 +116,8 @@
|
||||
"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",
|
||||
@@ -351,7 +353,7 @@
|
||||
"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 mincovny a federace ihned po zveřejnění: {reviews} recenzí pro {mints} mincoven Cashu a {federations} federací Fedimint, podepsaných na Nostru a seřazených od nejnovější. Filtrujte podle ekosystému, hodnocení nebo názvu.",
|
||||
"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",
|
||||
@@ -428,7 +430,14 @@
|
||||
"notfound.fb.reviews": "Přezkum",
|
||||
"notfound.fb.software": "Software",
|
||||
"notfound.fb.lastOnline": "Naposledy online",
|
||||
"notfound.fb.note": "Tato mincovna byla indexována po stránce byl poslední built. takže to je shrnutí z živé API. Celá stránka s recenzemi se objeví na další rebuiltd.",
|
||||
"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",
|
||||
@@ -696,5 +705,115 @@
|
||||
"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"
|
||||
"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."
|
||||
}
|
||||
|
||||
+118
-3
@@ -114,6 +114,8 @@
|
||||
"card.reviewed": "anmeldt {when}",
|
||||
"card.noReviews": "Der er endnu ingen anmeldelser",
|
||||
"mint.title": "{name} · Cashu – anmeldelser af Mint",
|
||||
"mint.titleFedimint": "{name} · Anmeldelser af Fedimint-føderationen",
|
||||
"mint.titleLnurl": "{name} · Anmeldelser af LNURL-minten",
|
||||
"mint.breadcrumb": "Navigationsspor",
|
||||
"mint.crumbMints": "Cashu-pastiller",
|
||||
"mint.copyUrl": "Kopier Mint-URL’en",
|
||||
@@ -325,7 +327,7 @@
|
||||
"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": "Alle anmeldelser af mints og føderationer, så snart de offentliggøres: Anmeldelser af {reviews} på tværs af mints som {mints} og Cashu samt føderationer som {federations} og Fedimint, underskrevet på Nostr og tilgængelige her – de nyeste først. Filtrer efter økosystem, bedømmelse eller navn.",
|
||||
"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",
|
||||
@@ -400,7 +402,14 @@
|
||||
"notfound.fb.reviews": "Anmeldelser",
|
||||
"notfound.fb.software": "Software",
|
||||
"notfound.fb.lastOnline": "Sidst online",
|
||||
"notfound.fb.note": "Denne anmeldelse blev indekseret, efter at siden sidst blev opdateret, så dette er et resumé fra live-API’en. Den fulde side med anmeldelser vises ved den næste opdatering.",
|
||||
"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",
|
||||
@@ -654,5 +663,111 @@
|
||||
"footer.allFedimints": "Alle forbund",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "Hvordan gik det med prægningen, smeltningen og understøtningen? 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"
|
||||
"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."
|
||||
}
|
||||
|
||||
+124
-9
@@ -15,12 +15,12 @@
|
||||
"time.justNow": "gerade eben",
|
||||
"time.none": "-",
|
||||
"time.unknownDate": "ein unbekanntes Datum",
|
||||
"time.ago.minute": "{n}me zurück",
|
||||
"time.ago.hour": "{n}he zurück",
|
||||
"time.ago.day": "{n}de zurück",
|
||||
"time.ago.week": "{n}we zurück",
|
||||
"time.ago.month": "{n}moe zurück",
|
||||
"time.ago.year": "{n}ye zurück",
|
||||
"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",
|
||||
@@ -114,6 +114,8 @@
|
||||
"card.reviewed": "Rezension zu „{when}“",
|
||||
"card.noReviews": "noch keine Bewertungen",
|
||||
"mint.title": "{name} · Bewertungen zu Cashu Mint",
|
||||
"mint.titleFedimint": "{name} · Reviews der Fedimint-Föderation",
|
||||
"mint.titleLnurl": "{name} · Reviews des LNURL Mint",
|
||||
"mint.breadcrumb": "Navigationspfad",
|
||||
"mint.crumbMints": "Cashu-Münzen",
|
||||
"mint.copyUrl": "URL kopieren",
|
||||
@@ -325,7 +327,7 @@
|
||||
"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 Münzprägeanstalten und Verbänden · Cashumints.space",
|
||||
"feed.description": "Alle Bewertungen zu Mints und Föderationen, sobald sie veröffentlicht werden: {reviews}-Bewertungen zu {mints} Cashu-Mints und {federations} Fedimint-Föderationen, auf Nostr signiert und hier lesbar – die neuesten zuerst. Filtern Sie nach Ökosystem, Bewertung oder Name.",
|
||||
"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": "Alle Bewertungen zu allen Cashu-Münzen und der Fedimint-Föderation, sortiert nach den neuesten zuerst, werden live von den Nostr-Relays übertragen. Jede Bewertung ist vom jeweiligen Autor signiert und wird auf den Relays gespeichert, nicht in unserer Datenbank.",
|
||||
"feed.label": "Rezensionen",
|
||||
@@ -400,7 +402,14 @@
|
||||
"notfound.fb.reviews": "Rezensionen",
|
||||
"notfound.fb.software": "Software",
|
||||
"notfound.fb.lastOnline": "Zuletzt online",
|
||||
"notfound.fb.note": "Diese Seite wurde erst nach der letzten Aktualisierung der Website indexiert, daher handelt es sich hierbei um eine Zusammenfassung aus der Live-API. Die vollständige Seite mit den Bewertungen wird bei der nächsten Aktualisierung angezeigt.",
|
||||
"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",
|
||||
@@ -654,5 +663,111 @@
|
||||
"footer.allFedimints": "Alle Verbände",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "Wie liefen das Prägen, das Einschmelzen und die Unterstützung ab? Gehen Sie bitte ins Detail.",
|
||||
"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"
|
||||
"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": "Prägen ü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": "Prägegebü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 Prägen, Schmelzen 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 Prägen / Schmelzen",
|
||||
"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": "Prägen und Schmelzen 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": "Prägen und Schmelzen nicht verfügbar",
|
||||
"mint.warnings.also.noFunding": "Auch seine Lightning-Node war nicht erreichbar, es konnte also weder geprägt noch geschmolzen werden."
|
||||
}
|
||||
|
||||
+118
-3
@@ -114,6 +114,8 @@
|
||||
"card.reviewed": "επανεξετάζεται{when}",
|
||||
"card.noReviews": "δεν υπάρχουν ακόμη αξιολογήσεις",
|
||||
"mint.title": "{name}· Κασσού νόμισμα των κριτικών",
|
||||
"mint.titleFedimint": "{name} · Κριτικές της ομοσπονδίας Fedimint",
|
||||
"mint.titleLnurl": "{name} · Κριτικές του LNURL mint",
|
||||
"mint.breadcrumb": "Ψωμί",
|
||||
"mint.crumbMints": "Κασσού νόμισματοκοπία",
|
||||
"mint.copyUrl": "Αντιγραφή νομισματοκοπίου URL",
|
||||
@@ -325,7 +327,7 @@
|
||||
"reviews.validate.short": "Γράψε τουλάχιστον{n} χαρακτήρες έτσι ώστε η κριτική είναι χρήσιμη.",
|
||||
"reviews.validate.long": "Οι κριτικές περιορίζονται σε{n} χαρακτήρες.",
|
||||
"feed.title": "Τελευταίες κριτικές για το vomismatokopoo και την ομοσπονδία · Cashumints.space",
|
||||
"feed.description": "Κάθε κριτική νομισματοκοπείου και ομοσπονδίας τη στιγμή που δημοσιεύεται: {reviews} κριτικές για {mints} νομισματοκοπεία Cashu και {federations} ομοσπονδίες Fedimint, υπογεγραμμένες στο Nostr και διαθέσιμες εδώ από τη νεότερη. Φιλτράρετε ανά οικοσύστημα, αξιολόγηση ή όνομα.",
|
||||
"feed.description": "Κάθε κριτική για mint, ομοσπονδία και mint LNURL μόλις δημοσιεύεται: {reviews} κριτικές για {mints} mints Cashu, {federations} ομοσπονδίες Fedimint και {lnurl} mints LNURL, υπογεγραμμένες στο Nostr και αναγνώσιμες εδώ με τις νεότερες πρώτα. Φιλτράρετε ανά οικοσύστημα, βαθμολογία ή όνομα.",
|
||||
"feed.heading": "Τελευταίες κριτικές",
|
||||
"feed.lede": "Κάθε κριτική για κάθε Κασού νομοτόπο και Fedimint ομοσπονδία, νεότερος πρώτα, διαβάστε ζωντανά από τα ρελέ Nostr. Κάθε ένα είναι υπογεγραμμένο από τον συγγραφέα του και ζει στα ρελέ, όχι στη βάση δεδομένων μας.",
|
||||
"feed.label": "Επανεξέταση",
|
||||
@@ -400,7 +402,14 @@
|
||||
"notfound.fb.reviews": "Επανεξέταση",
|
||||
"notfound.fb.software": "Λογισμικό",
|
||||
"notfound.fb.lastOnline": "Τελευταία σύνδεση",
|
||||
"notfound.fb.note": "Αυτό το vomismatokopo ήταν ευρετηριασμένο μετά την τελευταία κατασκευή του site, έτσι αυτό είναι μια περίληψη από το live API. Η πλήρης σελίδα με σχόλια εμφανίζεται στην επόμενη ανακατασκευή.",
|
||||
"notfound.fb.note": "Αυτό το mint καταχωρήθηκε μετά την τελευταία κατασκευή του ιστότοπου, οπότε αυτή είναι μια σύνοψη από το ζωντανό API, με τις κριτικές από κάτω. Η πλήρης σελίδα εμφανίζεται στην επόμενη κατασκευή.",
|
||||
"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": "Αυτή η διεύθυνση δεν προκύπτει μόνο από τον σύνδεσμο. Επικόλλησε το πλήρες URL του mint και προστίθεται.",
|
||||
"wallets.title": "Πορτοφόλια Κασού · Κασουμέντος.space",
|
||||
"wallets.description": "Πορτοφόλια Cashu που λειτουργούν με τη νόμισμα που αναφέρεται εδώ, σε όλα τα κινητά, web, browser και τη γραμμή εντολών, με ό, τι ο καθένας είναι καλός σε.",
|
||||
"wallets.heading": "Πορτοφόλια",
|
||||
@@ -654,5 +663,111 @@
|
||||
"footer.allFedimints": "Όλες οι ομοσπονδίες",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "Πώς λειτούργησε η κοπή, το λιώσιμο και η υποστήριξη; Να είστε συγκεκριμένοι.",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "Πώς λειτούργησε η ένταξη, οι πληρωμές Lightning και η υποστήριξη; Να είστε συγκεκριμένοι.",
|
||||
"fedimint.details.title": "Στοιχεία Ομοσπονδίας"
|
||||
"fedimint.details.title": "Στοιχεία Ομοσπονδίας",
|
||||
"nav.lnurlMints": "Mints LNURL",
|
||||
"footer.allLnurlMints": "Όλα τα mints LNURL",
|
||||
"lnurlMints.title": "Mints LNURL: mints για κομιστικά γραμμάτια lnurlcash, με κριτικές | Cashumints.space",
|
||||
"lnurlMints.description": "Και τα {total} mints LNURL που έχουν ευρετηριαστεί από το δίκτυο Nostr: {online} σε σύνδεση, {offline} εκτός σύνδεσης. Βαθμολογίες της κοινότητας, όρια ανάληψης και δυνατότητες για το καθένα.",
|
||||
"lnurlMints.heading": "Mints LNURL",
|
||||
"lnurlMints.lede": "Κάθε mint LNURL που γνωρίζει αυτός ο ιστότοπος. Ένα mint LNURL εκδίδει κομιστικά γραμμάτια lnurlcash: γραμμάτια Lightning που κρατάτε ως μυστικό και που μπορούν να περιστραφούν, να διαιρεθούν, να συγχωνευθούν ή να λιώσουν πίσω σε μια πληρωμή Lightning. Το καθένα ελέγχεται μέσω HTTPS κάθε λίγα λεπτά.",
|
||||
"lnurlMints.search.label": "Αναζήτηση mints LNURL",
|
||||
"lnurlMints.search.placeholder": "Αναζήτηση με όνομα ή διεύθυνση…",
|
||||
"lnurlMints.sort.label": "Ταξινόμηση mints LNURL κατά",
|
||||
"lnurlMints.hideOffline": "Απόκρυψη εκτός σύνδεσης",
|
||||
"lnurlMints.showing": "Εμφανίζονται {total} mints LNURL ({online} σε σύνδεση, {offline} εκτός σύνδεσης)",
|
||||
"lnurlMints.showingFiltered": "Εμφανίζονται {shown} από {total} mints LNURL ({online} σε σύνδεση, {offline} εκτός σύνδεσης)",
|
||||
"lnurlMints.noResults": "Κανένα mint LNURL δεν ταιριάζει.",
|
||||
"lnurlMints.emptyIndex": "Δεν έχει ευρετηριαστεί ακόμη κανένα mint LNURL.",
|
||||
"lnurl.crumb": "Mints LNURL",
|
||||
"lnurl.breadcrumb": "Διαδρομή πλοήγησης",
|
||||
"lnurl.copyAddress": "Αντιγραφή διεύθυνσης",
|
||||
"lnurl.more.title": "Περισσότερα mints LNURL",
|
||||
"lnurl.more.all": "Όλα τα mints LNURL →",
|
||||
"lnurl.disclaimer": "Ένα mint LNURL κρατά τα sats σας ενώ εσείς κρατάτε τα γραμμάτιά του, οπότε η χρήση του σημαίνει εμπιστοσύνη στον διαχειριστή του. Οι βαθμολογίες είναι απόψεις της κοινότητας, και «σε σύνδεση» σημαίνει προσβάσιμο, όχι αξιόπιστο.",
|
||||
"lnurl.features.title": "Δυνατότητες",
|
||||
"lnurl.features.none": "Αυτό το mint δεν έχει ανακοινώσει τι υποστηρίζει.",
|
||||
"lnurl.features.note": "Οι δυνατότητες είναι όσα ανακοίνωσε ο διαχειριστής του mint στο 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} sat",
|
||||
"lnurl.limits.sats.other": "{n} sats",
|
||||
"lnurl.details.title": "Στοιχεία mint",
|
||||
"lnurl.details.address": "Διεύθυνση Lightning",
|
||||
"lnurl.details.mintPubkey": "Δημόσιο κλειδί του mint",
|
||||
"lnurl.details.onion": "Διεύθυνση Tor",
|
||||
"lnurl.details.fee": "Προμήθεια κοπής",
|
||||
"lnurl.details.node": "Κόμβος",
|
||||
"lnurl.details.capacity": "Ανακοινωμένη χωρητικότητα",
|
||||
"lnurl.kind.lightningAddress": "διεύθυνση Lightning",
|
||||
"lnurl.kind.mintPubkey": "δημόσιο κλειδί του mint",
|
||||
"lnurl.kind.onionAddress": "διεύθυνση Tor",
|
||||
"lnurl.confirmed.label": "Τελευταία επιβεβαίωση",
|
||||
"lnurl.confirmed.when": "επιβεβαιώθηκε {when}",
|
||||
"lnurl.fee.base.one": "{n} sat",
|
||||
"lnurl.fee.base.other": "{n} sats",
|
||||
"lnurl.fee.percent": "{percent}%",
|
||||
"home.topLnurl.title": "Κορυφαία mints LNURL",
|
||||
"home.topLnurl.sub": "κατάταξη βάσει κριτικών και βαθμολογίας",
|
||||
"home.topLnurl.all": "Και τα {n} mints LNURL →",
|
||||
"feed.ecosystem.lnurl": "LNURL",
|
||||
"reviews.dialog.bodyPlaceholder.lnurl": "Πώς πήγαν η κοπή, το λιώσιμο και η περιστροφή γραμματίων; Γίνετε συγκεκριμένοι.",
|
||||
"reviews.byUrl.open": "Γράψε μια κριτική",
|
||||
"reviews.byUrl.title": "Γράψε μια κριτική",
|
||||
"reviews.byUrl.lede": "Επικόλλησε τη διεύθυνση. Αν ο ιστότοπος δεν την ξέρει ακόμα, την ελέγχει και την προσθέτει τώρα.",
|
||||
"reviews.byUrl.label.cashu": "URL του mint",
|
||||
"reviews.byUrl.label.fedimint": "Κωδικός πρόσκλησης της ομοσπονδίας",
|
||||
"reviews.byUrl.label.lnurl": "URL του LNURL mint",
|
||||
"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": "Αυτό δεν μοιάζει με URL 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}: πληροφορίες και κριτικές για το mint LNURL | Cashumints.space",
|
||||
"seo.desc.statusWithdraw": "{status}, ανάληψη {min}–{max} sat.",
|
||||
"mint.chip.noFunding": "Χωρίς κοπή / λιώσιμο",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.lead": "Οι αναλήψεις είναι απενεργοποιημένες.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.body.online": "Αυτό το mint ανακοινώνει μέγιστη ανάληψη μηδέν, οπότε κανένα γραμμάτιο που εκδίδει δεν μπορεί να εξαργυρωθεί με τίποτα. Μην κόβετε εδώ μέχρι να αλλάξει αυτό.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.body.offline": "Την τελευταία φορά που έγινε προσβάσιμο, αυτό το mint ανακοίνωνε μέγιστη ανάληψη μηδέν, οπότε κανένα γραμμάτιο που εξέδιδε δεν μπορούσε να εξαργυρωθεί με τίποτα. Μην κόβετε εδώ μέχρι να αλλάξει αυτό.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.online": "Οι αναλήψεις είναι προς το παρόν απενεργοποιημένες",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.offline": "Οι αναλήψεις ήταν απενεργοποιημένες την τελευταία φορά",
|
||||
"mint.warnings.lnurlInvalid.lead": "Το endpoint απαντά αλλά είναι άκυρο.",
|
||||
"mint.warnings.lnurlInvalid.body": "Ο διακομιστής απαντά, αλλά όχι με ανακοίνωση mint που μπορεί να διαβάσει αυτός ο ιστότοπος, οπότε οι αναλήψεις ίσως δεν λειτουργούν. Όσα ακολουθούν είναι η τελευταία κατάσταση που διαβάστηκε, και οι κριτικές εξακολουθούν να λειτουργούν.",
|
||||
"mint.warnings.lnurlInvalid.meta": "Απαντά, αλλά όχι με έγκυρη ανακοίνωση mint",
|
||||
"mint.warnings.lnurlNoFunding.lead": "Η κοπή και το λιώσιμο δεν είναι διαθέσιμα.",
|
||||
"mint.warnings.lnurlNoFunding.body": "Τα υπάρχοντα γραμμάτια μπορούν ακόμη να περιστραφούν, να διαιρεθούν ή να συγχωνευθούν, αλλά αυτή τη στιγμή τίποτα δεν μπαίνει ή βγαίνει μέσω Lightning. Το ίδιο το mint λειτουργεί και απαντά· ο κόμβος Lightning πίσω του δεν είναι προσβάσιμος από αυτό.",
|
||||
"mint.warnings.lnurlNoFunding.meta": "Κοπή και λιώσιμο μη διαθέσιμα",
|
||||
"mint.warnings.also.noFunding": "Και ο κόμβος Lightning του ήταν απροσπέλαστος, οπότε τίποτα δεν μπορούσε να κοπεί ή να λιώσει."
|
||||
}
|
||||
|
||||
+122
-3
@@ -50,6 +50,10 @@
|
||||
"footer.privacy": "Privacy",
|
||||
"footer.madeBy": "Made with ❤️ by {azzamo} · Powered by Cashu and NIP-87",
|
||||
"footer.warning": "Mints are custodial. A mint can disappear with your money.",
|
||||
"footer.tagline": "Community reviews for ecash mints, signed on Nostr.",
|
||||
"footer.col.explore": "Explore",
|
||||
"footer.col.site": "Site",
|
||||
"footer.nav": "Footer",
|
||||
"home.title": "Cashumints.space · Find a Cashu mint you can trust",
|
||||
"home.description": "Find a Cashu mint you can trust. {mints} mints indexed with {reviews} community reviews signed on Nostr, plus live uptime, supported NUTs and mint software.",
|
||||
"home.hero.title": "Find a Cashu mint",
|
||||
@@ -114,6 +118,8 @@
|
||||
"card.reviewed": "reviewed {when}",
|
||||
"card.noReviews": "no reviews yet",
|
||||
"mint.title": "{name} · Cashu mint reviews",
|
||||
"mint.titleFedimint": "{name} · Fedimint federation reviews",
|
||||
"mint.titleLnurl": "{name} · LNURL mint reviews",
|
||||
"mint.breadcrumb": "Breadcrumb",
|
||||
"mint.crumbMints": "Cashu mints",
|
||||
"mint.copyUrl": "Copy mint URL",
|
||||
@@ -325,7 +331,7 @@
|
||||
"reviews.validate.short": "Write at least {n} characters so the review is useful.",
|
||||
"reviews.validate.long": "Reviews are limited to {n} characters.",
|
||||
"feed.title": "Latest mint and federation reviews · Cashumints.space",
|
||||
"feed.description": "Every mint and federation review as it is published: {reviews} reviews across {mints} Cashu mints and {federations} Fedimint federations, signed on Nostr and readable here newest first. Filter by ecosystem, by rating, or by name.",
|
||||
"feed.description": "Every mint, federation and LNURL mint review as it is published: {reviews} reviews across {mints} Cashu mints, {federations} Fedimint federations and {lnurl} LNURL mints, signed on Nostr and readable here newest first. Filter by ecosystem, by rating, or by name.",
|
||||
"feed.heading": "Latest reviews",
|
||||
"feed.lede": "Every review of every Cashu mint and Fedimint federation, newest first, read live from the Nostr relays. Each one is signed by its author and lives on the relays, not in our database.",
|
||||
"feed.label": "Reviews",
|
||||
@@ -400,7 +406,14 @@
|
||||
"notfound.fb.reviews": "Reviews",
|
||||
"notfound.fb.software": "Software",
|
||||
"notfound.fb.lastOnline": "Last online",
|
||||
"notfound.fb.note": "This mint was indexed after the site was last built, so this is a summary from the live API. The full page with reviews appears at the next rebuild.",
|
||||
"notfound.fb.note": "This mint was indexed after the site was last built, so this is a summary from the live API, with its reviews below. The full page appears at the next rebuild.",
|
||||
"notfound.resolve.checking": "First visit: checking this mint now.",
|
||||
"notfound.resolve.failed": "This mint could not be added",
|
||||
"notfound.resolve.failedFedimint": "This federation could not be added",
|
||||
"notfound.resolve.retry": "Try another address",
|
||||
"notfound.resolve.switch": "Open it on {kind}",
|
||||
"notfound.resolve.fedimint": "A federation page address does not carry its invite code, so this one cannot be looked up from the link alone. Paste the invite code and it is added.",
|
||||
"notfound.resolve.ambiguous": "This address cannot be worked out from the link alone. Paste the mint’s full URL and it is added.",
|
||||
"wallets.title": "Cashu wallets · Cashumints.space",
|
||||
"wallets.description": "Cashu wallets that work with the mints listed here, across mobile, web, browser and the command line, with what each one is good at.",
|
||||
"wallets.heading": "Wallets",
|
||||
@@ -654,5 +667,111 @@
|
||||
"footer.allFedimints": "All federations",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "How did minting, melting and support work out? Be specific.",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "How did joining, Lightning payments and support work out? Be specific.",
|
||||
"fedimint.details.title": "Federation details"
|
||||
"fedimint.details.title": "Federation details",
|
||||
"nav.lnurlMints": "LNURL mints",
|
||||
"footer.allLnurlMints": "All LNURL mints",
|
||||
"lnurlMints.title": "LNURL mints: lnurlcash bearer note mints, reviewed | Cashumints.space",
|
||||
"lnurlMints.description": "All {total} LNURL mints indexed from the Nostr network: {online} online, {offline} offline. Community ratings, withdraw limits and capabilities for each.",
|
||||
"lnurlMints.heading": "LNURL mints",
|
||||
"lnurlMints.lede": "Every LNURL mint this site knows about. An LNURL mint issues lnurlcash bearer notes: Lightning notes you hold as a secret, which can be rotated, split, merged or melted back to a Lightning payment. Each one is checked over HTTPS every few minutes.",
|
||||
"lnurlMints.search.label": "Search LNURL mints",
|
||||
"lnurlMints.search.placeholder": "Search by name or address…",
|
||||
"lnurlMints.sort.label": "Sort LNURL mints by",
|
||||
"lnurlMints.hideOffline": "Hide offline",
|
||||
"lnurlMints.showing": "Showing {total} LNURL mints ({online} online, {offline} offline)",
|
||||
"lnurlMints.showingFiltered": "Showing {shown} of {total} LNURL mints ({online} online, {offline} offline)",
|
||||
"lnurlMints.noResults": "No LNURL mint matches that.",
|
||||
"lnurlMints.emptyIndex": "No LNURL mints are indexed yet.",
|
||||
"lnurl.crumb": "LNURL mints",
|
||||
"lnurl.breadcrumb": "Breadcrumb",
|
||||
"lnurl.copyAddress": "Copy address",
|
||||
"lnurl.more.title": "More LNURL mints",
|
||||
"lnurl.more.all": "All LNURL mints →",
|
||||
"lnurl.disclaimer": "An LNURL mint holds your sats while you hold its notes, so using one means trusting its operator. Ratings are community opinions, and online means reachable, not trustworthy.",
|
||||
"lnurl.features.title": "Features",
|
||||
"lnurl.features.none": "This mint has not announced what it supports.",
|
||||
"lnurl.features.note": "Features are what the mint's operator announced on Nostr. Where this site can check one over HTTPS it does, and says so when something announced is unavailable right now.",
|
||||
"lnurl.features.notListed": "not announced",
|
||||
"lnurl.features.unavailable": "unavailable",
|
||||
"lnurl.feature.mint": "Mint via Lightning (LUD-06)",
|
||||
"lnurl.feature.melt": "Melt to Lightning",
|
||||
"lnurl.feature.notes": "Rotate / split / merge notes",
|
||||
"lnurl.feature.lud16": "Lightning address",
|
||||
"lnurl.feature.lud21": "Payment verification (LUD-21)",
|
||||
"lnurl.feature.signed-notes": "Signed notes (offline verification)",
|
||||
"lnurl.feature.onion": "Tor address",
|
||||
"lnurl.feature.rotate": "Rotate a note",
|
||||
"lnurl.feature.split": "Split a note",
|
||||
"lnurl.feature.merge": "Merge notes",
|
||||
"lnurl.feature.lud06": "LUD-06 payRequest",
|
||||
"lnurl.feature.lud03": "LUD-03 withdrawRequest",
|
||||
"lnurl.limits.label": "Withdraw limits",
|
||||
"lnurl.limits.notPublished": "not published",
|
||||
"lnurl.limits.withdrawRow": "Withdraw",
|
||||
"lnurl.limits.disabled": "disabled",
|
||||
"lnurl.limits.sats.one": "{n} sat",
|
||||
"lnurl.limits.sats.other": "{n} sats",
|
||||
"lnurl.details.title": "Details",
|
||||
"lnurl.details.address": "Lightning address",
|
||||
"lnurl.details.mintPubkey": "Mint pubkey",
|
||||
"lnurl.details.onion": "Tor address",
|
||||
"lnurl.details.fee": "Mint fee",
|
||||
"lnurl.details.node": "Node",
|
||||
"lnurl.details.capacity": "Announced capacity",
|
||||
"lnurl.kind.lightningAddress": "lightning address",
|
||||
"lnurl.kind.mintPubkey": "mint pubkey",
|
||||
"lnurl.kind.onionAddress": "Tor address",
|
||||
"lnurl.confirmed.label": "Last confirmed",
|
||||
"lnurl.confirmed.when": "confirmed {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": "ranked by reviews and rating",
|
||||
"home.topLnurl.all": "All {n} LNURL mints →",
|
||||
"feed.ecosystem.lnurl": "LNURL",
|
||||
"reviews.dialog.bodyPlaceholder.lnurl": "How did minting, melting and rotating notes work out? Be specific.",
|
||||
"reviews.byUrl.open": "Write a review",
|
||||
"reviews.byUrl.title": "Write a review",
|
||||
"reviews.byUrl.lede": "Paste the address. If this site does not know it yet, it is checked and added now.",
|
||||
"reviews.byUrl.label.cashu": "Mint URL",
|
||||
"reviews.byUrl.label.fedimint": "Federation invite code",
|
||||
"reviews.byUrl.label.lnurl": "LNURL mint URL",
|
||||
"reviews.byUrl.placeholder.cashu": "mint.example.com",
|
||||
"reviews.byUrl.placeholder.fedimint": "fed11…",
|
||||
"reviews.byUrl.placeholder.lnurl": "mint.example.com",
|
||||
"reviews.byUrl.check": "Check this address",
|
||||
"reviews.byUrl.checking": "Checking…",
|
||||
"reviews.byUrl.back": "Use a different address",
|
||||
"reviews.byUrl.existing": "Already listed",
|
||||
"reviews.byUrl.indexed": "Just added",
|
||||
"reviews.byUrl.confirmClose": "Close this? The address you typed is not kept.",
|
||||
"reviews.byUrl.detected": "That address answered as a mint on {kind}.",
|
||||
"reviews.byUrl.switchTo": "Review it on {kind}",
|
||||
"reviews.byUrl.error.empty": "Enter an address first.",
|
||||
"reviews.byUrl.error.badUrl": "That does not look like a mint URL.",
|
||||
"reviews.byUrl.error.badInvite": "That is not a Fedimint invite code. Codes start with fed11.",
|
||||
"reviews.byUrl.error.badInput": "This site cannot check that address.",
|
||||
"reviews.byUrl.error.blocked": "That is not a public address, so it cannot be checked.",
|
||||
"reviews.byUrl.error.notAMint": "That address answered, but not as a mint of this kind.",
|
||||
"reviews.byUrl.error.wrongType": "That address answered, but as a different kind of mint.",
|
||||
"reviews.byUrl.error.unverifiable": "Nothing responded at that address, and no trace of it exists on Nostr yet.",
|
||||
"reviews.byUrl.error.rateLimited.one": "That is a lot of checks from one connection. Try again in about {n} minute.",
|
||||
"reviews.byUrl.error.rateLimited.other": "That is a lot of checks from one connection. Try again in about {n} minutes.",
|
||||
"reviews.byUrl.error.network": "The index could not be reached. Check your connection, then try again.",
|
||||
"seo.lnurl.titleNoReviews": "{name}: LNURL mint info and reviews | Cashumints.space",
|
||||
"seo.desc.statusWithdraw": "{status}, withdraw {min}–{max} sat.",
|
||||
"mint.chip.noFunding": "No mint / melt",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.lead": "Withdrawals disabled.",
|
||||
"mint.warnings.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.",
|
||||
"mint.warnings.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.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.online": "Withdrawals currently disabled",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.offline": "Withdrawals were disabled when last seen",
|
||||
"mint.warnings.lnurlInvalid.lead": "Endpoint responding but invalid.",
|
||||
"mint.warnings.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.",
|
||||
"mint.warnings.lnurlInvalid.meta": "Responding, but not with a valid mint advertisement",
|
||||
"mint.warnings.lnurlNoFunding.lead": "Minting and melting unavailable.",
|
||||
"mint.warnings.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.",
|
||||
"mint.warnings.lnurlNoFunding.meta": "Minting and melting unavailable",
|
||||
"mint.warnings.also.noFunding": "Its Lightning node was also unreachable, so nothing could be minted or melted."
|
||||
}
|
||||
|
||||
+122
-3
@@ -54,6 +54,10 @@
|
||||
"footer.privacy": "Privacidad",
|
||||
"footer.madeBy": "Hecho con ❤️ por {azzamo} · Funciona con Cashu y NIP-87",
|
||||
"footer.warning": "Los mints son custodiales. Un mint puede desaparecer con tu dinero.",
|
||||
"footer.tagline": "Reseñas de la comunidad para mints de ecash, firmadas en Nostr.",
|
||||
"footer.col.explore": "Explorar",
|
||||
"footer.col.site": "Sitio",
|
||||
"footer.nav": "Pie de página",
|
||||
"home.title": "Cashumints.space · Encuentra un mint Cashu en el que confiar",
|
||||
"home.description": "Encuentra un mint Cashu en el que confiar. {mints} mints indexados con {reviews} reseñas de la comunidad firmadas en Nostr, además de disponibilidad en directo, NUTs compatibles y software del mint.",
|
||||
"home.hero.title": "Encuentra un mint Cashu",
|
||||
@@ -118,6 +122,8 @@
|
||||
"card.reviewed": "reseñado {when}",
|
||||
"card.noReviews": "sin reseñas aún",
|
||||
"mint.title": "{name} · Reseñas del mint Cashu",
|
||||
"mint.titleFedimint": "{name} · Reseñas de la federación Fedimint",
|
||||
"mint.titleLnurl": "{name} · Reseñas del mint LNURL",
|
||||
"mint.breadcrumb": "Ruta de navegación",
|
||||
"mint.crumbMints": "Mints Cashu",
|
||||
"mint.copyUrl": "Copiar la URL del mint",
|
||||
@@ -329,7 +335,7 @@
|
||||
"reviews.validate.short": "Escribe al menos {n} caracteres para que la reseña sirva de algo.",
|
||||
"reviews.validate.long": "Las reseñas están limitadas a {n} caracteres.",
|
||||
"feed.title": "Últimas reseñas de mints y federaciones · Cashumints.space",
|
||||
"feed.description": "Cada reseña de un mint o una federación según se publica: {reviews} reseñas de {mints} mints Cashu y {federations} federaciones Fedimint, firmadas en Nostr y legibles aquí de la más nueva a la más antigua. Filtra por ecosistema, por valoración o por nombre.",
|
||||
"feed.description": "Cada reseña de mint, federación y mint LNURL según se publica: {reviews} reseñas sobre {mints} mints Cashu, {federations} federaciones Fedimint y {lnurl} mints LNURL, firmadas en Nostr y legibles aquí de más nueva a más antigua. Filtra por ecosistema, por valoración o por nombre.",
|
||||
"feed.heading": "Últimas reseñas",
|
||||
"feed.lede": "Todas las reseñas de todos los mints Cashu y federaciones Fedimint, de la más nueva a la más antigua, leídas en directo de los relays de Nostr. Cada una está firmada por quien la escribió y vive en los relays, no en nuestra base de datos.",
|
||||
"feed.label": "Reseñas",
|
||||
@@ -404,7 +410,14 @@
|
||||
"notfound.fb.reviews": "Reseñas",
|
||||
"notfound.fb.software": "Software",
|
||||
"notfound.fb.lastOnline": "Última vez en línea",
|
||||
"notfound.fb.note": "Este mint se indexó después de la última compilación del sitio, así que esto es un resumen sacado de la API en directo. La página completa con reseñas aparecerá en la próxima compilación.",
|
||||
"notfound.fb.note": "Este mint se indexó después de la última compilación del sitio, así que este es un resumen de la API en vivo, con sus reseñas debajo. La página completa aparece en la siguiente compilación.",
|
||||
"notfound.resolve.checking": "Primera visita: comprobando este mint ahora.",
|
||||
"notfound.resolve.failed": "No se pudo añadir este mint",
|
||||
"notfound.resolve.failedFedimint": "No se pudo añadir esta federación",
|
||||
"notfound.resolve.retry": "Probar otra dirección",
|
||||
"notfound.resolve.switch": "Abrirlo como mint {kind}",
|
||||
"notfound.resolve.fedimint": "La dirección de la página de una federación no lleva su código de invitación, así que esta no se puede buscar solo con el enlace. Pega el código de invitación y se añade.",
|
||||
"notfound.resolve.ambiguous": "Esta dirección no se puede deducir solo con el enlace. Pega la URL completa del mint y se añade.",
|
||||
"wallets.title": "Monederos Cashu · Cashumints.space",
|
||||
"wallets.description": "Monederos Cashu que funcionan con los mints listados aquí, para móvil, web, navegador y línea de comandos, con lo que hace bien cada uno.",
|
||||
"wallets.heading": "Monederos",
|
||||
@@ -658,5 +671,111 @@
|
||||
"footer.allFedimints": "Todas las federaciones",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "¿Qué tal fueron la acuñación, el fundido y el soporte? Sé concreto.",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "¿Qué tal fueron unirte, los pagos Lightning y el soporte? Sé concreto.",
|
||||
"fedimint.details.title": "Datos de la federación"
|
||||
"fedimint.details.title": "Datos de la federación",
|
||||
"nav.lnurlMints": "Mints LNURL",
|
||||
"footer.allLnurlMints": "Todos los mints LNURL",
|
||||
"lnurlMints.title": "Mints LNURL: mints de notas al portador lnurlcash, con reseñas | Cashumints.space",
|
||||
"lnurlMints.description": "Los {total} mints LNURL indexados desde la red Nostr: {online} en línea, {offline} fuera de línea. Valoraciones de la comunidad, límites de retirada y capacidades de cada uno.",
|
||||
"lnurlMints.heading": "Mints LNURL",
|
||||
"lnurlMints.lede": "Todos los mints LNURL que este sitio conoce. Un mint LNURL emite notas al portador lnurlcash: notas Lightning que guardas como un secreto y que se pueden rotar, dividir, fusionar o fundir de vuelta a un pago Lightning. Cada uno se comprueba por HTTPS cada pocos minutos.",
|
||||
"lnurlMints.search.label": "Buscar mints LNURL",
|
||||
"lnurlMints.search.placeholder": "Buscar por nombre o dirección…",
|
||||
"lnurlMints.sort.label": "Ordenar mints LNURL por",
|
||||
"lnurlMints.hideOffline": "Ocultar fuera de línea",
|
||||
"lnurlMints.showing": "Mostrando {total} mints LNURL ({online} en línea, {offline} fuera de línea)",
|
||||
"lnurlMints.showingFiltered": "Mostrando {shown} de {total} mints LNURL ({online} en línea, {offline} fuera de línea)",
|
||||
"lnurlMints.noResults": "Ningún mint LNURL coincide con eso.",
|
||||
"lnurlMints.emptyIndex": "Todavía no hay mints LNURL indexados.",
|
||||
"lnurl.crumb": "Mints LNURL",
|
||||
"lnurl.breadcrumb": "Ruta de navegación",
|
||||
"lnurl.copyAddress": "Copiar dirección",
|
||||
"lnurl.more.title": "Más mints LNURL",
|
||||
"lnurl.more.all": "Todos los mints LNURL →",
|
||||
"lnurl.disclaimer": "Un mint LNURL guarda tus sats mientras tú guardas sus notas, así que usar uno significa confiar en su operador. Las valoraciones son opiniones de la comunidad, y en línea significa accesible, no confiable.",
|
||||
"lnurl.features.title": "Funciones",
|
||||
"lnurl.features.none": "Este mint no ha anunciado qué admite.",
|
||||
"lnurl.features.note": "Las funciones son lo que el operador del mint anunció en Nostr. Cuando este sitio puede comprobar alguna por HTTPS lo hace, y avisa cuando algo anunciado no está disponible ahora mismo.",
|
||||
"lnurl.features.notListed": "no anunciado",
|
||||
"lnurl.features.unavailable": "no disponible",
|
||||
"lnurl.feature.mint": "Acuñar por Lightning (LUD-06)",
|
||||
"lnurl.feature.melt": "Fundir a Lightning",
|
||||
"lnurl.feature.notes": "Rotar / dividir / fusionar notas",
|
||||
"lnurl.feature.lud16": "Dirección Lightning",
|
||||
"lnurl.feature.lud21": "Verificación de pago (LUD-21)",
|
||||
"lnurl.feature.signed-notes": "Notas firmadas (verificación sin conexión)",
|
||||
"lnurl.feature.onion": "Dirección Tor",
|
||||
"lnurl.feature.rotate": "Rotar una nota",
|
||||
"lnurl.feature.split": "Dividir una nota",
|
||||
"lnurl.feature.merge": "Fusionar notas",
|
||||
"lnurl.feature.lud06": "payRequest LUD-06",
|
||||
"lnurl.feature.lud03": "withdrawRequest LUD-03",
|
||||
"lnurl.limits.label": "Límites de retirada",
|
||||
"lnurl.limits.notPublished": "no publicado",
|
||||
"lnurl.limits.withdrawRow": "Retirada",
|
||||
"lnurl.limits.disabled": "desactivado",
|
||||
"lnurl.limits.sats.one": "{n} sat",
|
||||
"lnurl.limits.sats.other": "{n} sats",
|
||||
"lnurl.details.title": "Datos del mint",
|
||||
"lnurl.details.address": "Dirección Lightning",
|
||||
"lnurl.details.mintPubkey": "Clave pública del mint",
|
||||
"lnurl.details.onion": "Dirección Tor",
|
||||
"lnurl.details.fee": "Comisión de acuñación",
|
||||
"lnurl.details.node": "Nodo",
|
||||
"lnurl.details.capacity": "Capacidad anunciada",
|
||||
"lnurl.kind.lightningAddress": "dirección Lightning",
|
||||
"lnurl.kind.mintPubkey": "clave pública del mint",
|
||||
"lnurl.kind.onionAddress": "dirección Tor",
|
||||
"lnurl.confirmed.label": "Última confirmación",
|
||||
"lnurl.confirmed.when": "confirmado {when}",
|
||||
"lnurl.fee.base.one": "{n} sat",
|
||||
"lnurl.fee.base.other": "{n} sats",
|
||||
"lnurl.fee.percent": "{percent}%",
|
||||
"home.topLnurl.title": "Mejores mints LNURL",
|
||||
"home.topLnurl.sub": "ordenados por reseñas y valoración",
|
||||
"home.topLnurl.all": "Los {n} mints LNURL →",
|
||||
"feed.ecosystem.lnurl": "LNURL",
|
||||
"reviews.dialog.bodyPlaceholder.lnurl": "¿Qué tal fueron acuñar, fundir y rotar notas? Sé concreto.",
|
||||
"reviews.byUrl.open": "Escribir una reseña",
|
||||
"reviews.byUrl.title": "Escribir una reseña",
|
||||
"reviews.byUrl.lede": "Pega la dirección. Si este sitio aún no la conoce, la comprueba y la añade ahora.",
|
||||
"reviews.byUrl.label.cashu": "URL del mint",
|
||||
"reviews.byUrl.label.fedimint": "Código de invitación de la federación",
|
||||
"reviews.byUrl.label.lnurl": "URL del mint LNURL",
|
||||
"reviews.byUrl.placeholder.cashu": "mint.example.com",
|
||||
"reviews.byUrl.placeholder.fedimint": "fed11…",
|
||||
"reviews.byUrl.placeholder.lnurl": "mint.example.com",
|
||||
"reviews.byUrl.check": "Comprobar esta dirección",
|
||||
"reviews.byUrl.checking": "Comprobando…",
|
||||
"reviews.byUrl.back": "Usar otra dirección",
|
||||
"reviews.byUrl.existing": "Ya está en la lista",
|
||||
"reviews.byUrl.indexed": "Recién añadido",
|
||||
"reviews.byUrl.confirmClose": "¿Cerrar? La dirección que escribiste no se guarda.",
|
||||
"reviews.byUrl.detected": "Esa dirección respondió como un mint {kind}.",
|
||||
"reviews.byUrl.switchTo": "Reseñarlo como mint {kind}",
|
||||
"reviews.byUrl.error.empty": "Escribe una dirección primero.",
|
||||
"reviews.byUrl.error.badUrl": "Eso no parece la URL de un mint.",
|
||||
"reviews.byUrl.error.badInvite": "Eso no es un código de invitación de Fedimint. Los códigos empiezan por fed11.",
|
||||
"reviews.byUrl.error.badInput": "Este sitio no puede comprobar esa dirección.",
|
||||
"reviews.byUrl.error.blocked": "Esa no es una dirección pública, así que no se puede comprobar.",
|
||||
"reviews.byUrl.error.notAMint": "Esa dirección respondió, pero no como un mint de este tipo.",
|
||||
"reviews.byUrl.error.wrongType": "Esa dirección respondió, pero como un mint de otro tipo.",
|
||||
"reviews.byUrl.error.unverifiable": "Nada respondió en esa dirección, y todavía no hay ni rastro de ella en Nostr.",
|
||||
"reviews.byUrl.error.rateLimited.one": "Son muchas comprobaciones desde una misma conexión. Inténtalo otra vez en unos {n} minuto.",
|
||||
"reviews.byUrl.error.rateLimited.other": "Son muchas comprobaciones desde una misma conexión. Inténtalo otra vez en unos {n} minutos.",
|
||||
"reviews.byUrl.error.network": "No se pudo contactar con el índice. Comprueba tu conexión y vuelve a intentarlo.",
|
||||
"seo.lnurl.titleNoReviews": "{name}: información y reseñas del mint LNURL | Cashumints.space",
|
||||
"seo.desc.statusWithdraw": "{status}, retirada {min}–{max} sat.",
|
||||
"mint.chip.noFunding": "Sin acuñar / fundir",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.lead": "Retiradas desactivadas.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.body.online": "Este mint anuncia una retirada máxima de cero, así que ninguna nota que emita puede canjearse por nada. No acuñes aquí hasta que eso cambie.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.body.offline": "Este mint anunciaba una retirada máxima de cero la última vez que se pudo contactar, así que ninguna nota que emitiera podía canjearse por nada. No acuñes aquí hasta que eso cambie.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.online": "Retiradas desactivadas actualmente",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.offline": "Las retiradas estaban desactivadas la última vez que se vio",
|
||||
"mint.warnings.lnurlInvalid.lead": "El endpoint responde pero no es válido.",
|
||||
"mint.warnings.lnurlInvalid.body": "El servidor responde, pero no con un anuncio de mint que este sitio pueda leer, así que las retiradas podrían no funcionar. Lo de abajo es el último estado que sí se pudo leer, y las reseñas siguen funcionando.",
|
||||
"mint.warnings.lnurlInvalid.meta": "Responde, pero no con un anuncio de mint válido",
|
||||
"mint.warnings.lnurlNoFunding.lead": "Acuñación y fundido no disponibles.",
|
||||
"mint.warnings.lnurlNoFunding.body": "Las notas existentes todavía se pueden rotar, dividir o fusionar, pero ahora mismo nada entra ni sale por Lightning. El mint está activo y responde; el nodo Lightning que hay detrás no es accesible desde él.",
|
||||
"mint.warnings.lnurlNoFunding.meta": "Acuñación y fundido no disponibles",
|
||||
"mint.warnings.also.noFunding": "Su nodo Lightning también estaba inaccesible, así que no se podía acuñar ni fundir nada."
|
||||
}
|
||||
|
||||
+118
-3
@@ -114,6 +114,8 @@
|
||||
"card.reviewed": "a évalué {when}",
|
||||
"card.noReviews": "pas encore d'avis",
|
||||
"mint.title": "{name} · Cashu mint avis",
|
||||
"mint.titleFedimint": "{name} · Avis sur la fédération Fedimint",
|
||||
"mint.titleLnurl": "{name} · Avis sur le mint LNURL",
|
||||
"mint.breadcrumb": "Fil d'Ariane",
|
||||
"mint.crumbMints": "Cashu mints",
|
||||
"mint.copyUrl": "Copier l'URL mint",
|
||||
@@ -325,7 +327,7 @@
|
||||
"reviews.validate.short": "Écrivez au moins les caractères {n} pour que l'examen soit utile.",
|
||||
"reviews.validate.long": "Les avis sont limités aux personnages {n}.",
|
||||
"feed.title": "Derniers avis sur le mint et la fédération · Cashumints.space",
|
||||
"feed.description": "Chaque revue de mint et de fédération telle qu'elle est publiée : critiques de {reviews} dans les fédérations {mints} Cashu mints et {federations} Fedimint, signées sur Nostr et lisibles ici les plus récentes en premier. Filtrez par écosystème, par note ou par nom.",
|
||||
"feed.description": "Chaque avis sur un mint, une fédération et un mint LNURL dès sa publication : {reviews} avis portant sur {mints} mints Cashu, {federations} fédérations Fedimint et {lnurl} mints LNURL, signés sur Nostr et lisibles ici du plus récent au plus ancien. Filtrez par écosystème, par note ou par nom.",
|
||||
"feed.heading": "Derniers avis",
|
||||
"feed.lede": "Chaque revue de chaque fédération Cashu mint et Fedimint, la plus récente en premier, lue en direct depuis les relais Nostr. Chacun est signé par son auteur et vit sur les relais, pas dans notre base de données.",
|
||||
"feed.label": "Avis",
|
||||
@@ -400,7 +402,14 @@
|
||||
"notfound.fb.reviews": "Avis",
|
||||
"notfound.fb.software": "Logiciel",
|
||||
"notfound.fb.lastOnline": "Dernière connexion",
|
||||
"notfound.fb.note": "Ce mint a été indexé après la dernière création du site, il s'agit donc d'un résumé de l'API en direct. La page complète avec les critiques apparaîtra lors de la prochaine reconstruction.",
|
||||
"notfound.fb.note": "Ce mint a été indexé après la dernière construction du site : voici un résumé issu de l’API en direct, avec ses avis en dessous. La page complète apparaît à la prochaine construction.",
|
||||
"notfound.resolve.checking": "Première visite : ce mint est en cours de vérification.",
|
||||
"notfound.resolve.failed": "Ce mint n’a pas pu être ajouté",
|
||||
"notfound.resolve.failedFedimint": "Cette fédération n’a pas pu être ajoutée",
|
||||
"notfound.resolve.retry": "Essayer une autre adresse",
|
||||
"notfound.resolve.switch": "L’ouvrir comme mint {kind}",
|
||||
"notfound.resolve.fedimint": "L’adresse de la page d’une fédération ne contient pas son code d’invitation, donc celle-ci ne peut pas être retrouvée à partir du lien seul. Colle le code d’invitation et elle est ajoutée.",
|
||||
"notfound.resolve.ambiguous": "Cette adresse ne peut pas être déduite du lien seul. Colle l’URL complète du mint et il est ajouté.",
|
||||
"wallets.title": "Portefeuilles Cashu · Cashumints.space",
|
||||
"wallets.description": "Portefeuilles Cashu qui fonctionnent avec le mints répertorié ici, sur mobile, Web, navigateur et ligne de commande, avec ce pour quoi chacun est bon.",
|
||||
"wallets.heading": "Portefeuilles",
|
||||
@@ -654,5 +663,111 @@
|
||||
"footer.allFedimints": "Toutes les fédérations",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "Comment se sont déroulés les minting, melting et le support ? Soyez précis.",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "Comment s'est déroulée l'adhésion, les paiements Lightning et l'assistance ? Soyez précis.",
|
||||
"fedimint.details.title": "Détails de la fédération"
|
||||
"fedimint.details.title": "Détails de la fédération",
|
||||
"nav.lnurlMints": "Mints LNURL",
|
||||
"footer.allLnurlMints": "Tous les mints LNURL",
|
||||
"lnurlMints.title": "Mints LNURL : mints de billets au porteur lnurlcash, avec avis | Cashumints.space",
|
||||
"lnurlMints.description": "Les {total} mints LNURL indexés depuis le réseau Nostr : {online} en ligne, {offline} hors ligne. Notes de la communauté, limites de retrait et fonctionnalités de chacun.",
|
||||
"lnurlMints.heading": "Mints LNURL",
|
||||
"lnurlMints.lede": "Tous les mints LNURL que ce site connaît. Un mint LNURL émet des billets au porteur lnurlcash : des billets Lightning que vous conservez comme un secret et qui peuvent être tournés, divisés, fusionnés ou refondus en un paiement Lightning. Chacun est vérifié en HTTPS toutes les quelques minutes.",
|
||||
"lnurlMints.search.label": "Rechercher des mints LNURL",
|
||||
"lnurlMints.search.placeholder": "Rechercher par nom ou adresse…",
|
||||
"lnurlMints.sort.label": "Trier les mints LNURL par",
|
||||
"lnurlMints.hideOffline": "Masquer hors ligne",
|
||||
"lnurlMints.showing": "{total} mints LNURL affichés ({online} en ligne, {offline} hors ligne)",
|
||||
"lnurlMints.showingFiltered": "{shown} sur {total} mints LNURL affichés ({online} en ligne, {offline} hors ligne)",
|
||||
"lnurlMints.noResults": "Aucun mint LNURL ne correspond.",
|
||||
"lnurlMints.emptyIndex": "Aucun mint LNURL n'est encore indexé.",
|
||||
"lnurl.crumb": "Mints LNURL",
|
||||
"lnurl.breadcrumb": "Fil d'Ariane",
|
||||
"lnurl.copyAddress": "Copier l'adresse",
|
||||
"lnurl.more.title": "Plus de mints LNURL",
|
||||
"lnurl.more.all": "Tous les mints LNURL →",
|
||||
"lnurl.disclaimer": "Un mint LNURL détient vos sats pendant que vous détenez ses billets : l'utiliser revient donc à faire confiance à son opérateur. Les notes sont des avis de la communauté, et en ligne signifie joignable, pas digne de confiance.",
|
||||
"lnurl.features.title": "Fonctionnalités",
|
||||
"lnurl.features.none": "Ce mint n'a pas annoncé ce qu'il prend en charge.",
|
||||
"lnurl.features.note": "Les fonctionnalités sont ce que l'opérateur du mint a annoncé sur Nostr. Quand ce site peut en vérifier une en HTTPS, il le fait, et il le signale lorsqu'une fonctionnalité annoncée est indisponible en ce moment.",
|
||||
"lnurl.features.notListed": "non annoncé",
|
||||
"lnurl.features.unavailable": "indisponible",
|
||||
"lnurl.feature.mint": "Minting via Lightning (LUD-06)",
|
||||
"lnurl.feature.melt": "Melting vers Lightning",
|
||||
"lnurl.feature.notes": "Tourner / diviser / fusionner des billets",
|
||||
"lnurl.feature.lud16": "Adresse Lightning",
|
||||
"lnurl.feature.lud21": "Vérification de paiement (LUD-21)",
|
||||
"lnurl.feature.signed-notes": "Billets signés (vérification hors ligne)",
|
||||
"lnurl.feature.onion": "Adresse Tor",
|
||||
"lnurl.feature.rotate": "Tourner un billet",
|
||||
"lnurl.feature.split": "Diviser un billet",
|
||||
"lnurl.feature.merge": "Fusionner des billets",
|
||||
"lnurl.feature.lud06": "payRequest LUD-06",
|
||||
"lnurl.feature.lud03": "withdrawRequest LUD-03",
|
||||
"lnurl.limits.label": "Limites de retrait",
|
||||
"lnurl.limits.notPublished": "non publié",
|
||||
"lnurl.limits.withdrawRow": "Retrait",
|
||||
"lnurl.limits.disabled": "désactivé",
|
||||
"lnurl.limits.sats.one": "{n} sat",
|
||||
"lnurl.limits.sats.other": "{n} sats",
|
||||
"lnurl.details.title": "Détails du mint",
|
||||
"lnurl.details.address": "Adresse Lightning",
|
||||
"lnurl.details.mintPubkey": "Clé publique du mint",
|
||||
"lnurl.details.onion": "Adresse Tor",
|
||||
"lnurl.details.fee": "Frais de minting",
|
||||
"lnurl.details.node": "Nœud",
|
||||
"lnurl.details.capacity": "Capacité annoncée",
|
||||
"lnurl.kind.lightningAddress": "adresse Lightning",
|
||||
"lnurl.kind.mintPubkey": "clé publique du mint",
|
||||
"lnurl.kind.onionAddress": "adresse Tor",
|
||||
"lnurl.confirmed.label": "Dernière confirmation",
|
||||
"lnurl.confirmed.when": "confirmé {when}",
|
||||
"lnurl.fee.base.one": "{n} sat",
|
||||
"lnurl.fee.base.other": "{n} sats",
|
||||
"lnurl.fee.percent": "{percent} %",
|
||||
"home.topLnurl.title": "Meilleurs mints LNURL",
|
||||
"home.topLnurl.sub": "classés par avis et note",
|
||||
"home.topLnurl.all": "Les {n} mints LNURL →",
|
||||
"feed.ecosystem.lnurl": "LNURL",
|
||||
"reviews.dialog.bodyPlaceholder.lnurl": "Comment se sont passés le minting, le melting et la rotation des billets ? Soyez précis.",
|
||||
"reviews.byUrl.open": "Écrire un avis",
|
||||
"reviews.byUrl.title": "Écrire un avis",
|
||||
"reviews.byUrl.lede": "Colle l’adresse. Si ce site ne la connaît pas encore, elle est vérifiée et ajoutée maintenant.",
|
||||
"reviews.byUrl.label.cashu": "URL du mint",
|
||||
"reviews.byUrl.label.fedimint": "Code d’invitation de la fédération",
|
||||
"reviews.byUrl.label.lnurl": "URL du mint LNURL",
|
||||
"reviews.byUrl.placeholder.cashu": "mint.example.com",
|
||||
"reviews.byUrl.placeholder.fedimint": "fed11…",
|
||||
"reviews.byUrl.placeholder.lnurl": "mint.example.com",
|
||||
"reviews.byUrl.check": "Vérifier cette adresse",
|
||||
"reviews.byUrl.checking": "Vérification…",
|
||||
"reviews.byUrl.back": "Utiliser une autre adresse",
|
||||
"reviews.byUrl.existing": "Déjà répertorié",
|
||||
"reviews.byUrl.indexed": "Ajouté à l’instant",
|
||||
"reviews.byUrl.confirmClose": "Fermer ? L’adresse saisie n’est pas conservée.",
|
||||
"reviews.byUrl.detected": "Cette adresse a répondu comme un mint {kind}.",
|
||||
"reviews.byUrl.switchTo": "L’évaluer comme mint {kind}",
|
||||
"reviews.byUrl.error.empty": "Saisis d’abord une adresse.",
|
||||
"reviews.byUrl.error.badUrl": "Cela ne ressemble pas à une URL de mint.",
|
||||
"reviews.byUrl.error.badInvite": "Ce n’est pas un code d’invitation Fedimint. Les codes commencent par fed11.",
|
||||
"reviews.byUrl.error.badInput": "Ce site ne peut pas vérifier cette adresse.",
|
||||
"reviews.byUrl.error.blocked": "Ce n’est pas une adresse publique, elle ne peut donc pas être vérifiée.",
|
||||
"reviews.byUrl.error.notAMint": "Cette adresse a répondu, mais pas comme un mint de ce type.",
|
||||
"reviews.byUrl.error.wrongType": "Cette adresse a répondu, mais comme un autre type de mint.",
|
||||
"reviews.byUrl.error.unverifiable": "Rien n’a répondu à cette adresse, et il n’en existe encore aucune trace sur Nostr.",
|
||||
"reviews.byUrl.error.rateLimited.one": "Cela fait beaucoup de vérifications depuis une seule connexion. Réessaie dans environ {n} minute.",
|
||||
"reviews.byUrl.error.rateLimited.other": "Cela fait beaucoup de vérifications depuis une seule connexion. Réessaie dans environ {n} minutes.",
|
||||
"reviews.byUrl.error.network": "L’index est injoignable. Vérifie ta connexion, puis réessaie.",
|
||||
"seo.lnurl.titleNoReviews": "{name} : infos et avis sur le mint LNURL | Cashumints.space",
|
||||
"seo.desc.statusWithdraw": "{status}, retrait {min}–{max} sat.",
|
||||
"mint.chip.noFunding": "Ni minting ni melting",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.lead": "Retraits désactivés.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.body.online": "Ce mint annonce un retrait maximum de zéro : aucun billet qu'il émet ne peut donc être échangé contre quoi que ce soit. N'y faites pas de minting tant que cela n'a pas changé.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.body.offline": "Ce mint annonçait un retrait maximum de zéro lors du dernier contact : aucun billet qu'il émettait ne pouvait donc être échangé contre quoi que ce soit. N'y faites pas de minting tant que cela n'a pas changé.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.online": "Retraits actuellement désactivés",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.offline": "Les retraits étaient désactivés lors du dernier contact",
|
||||
"mint.warnings.lnurlInvalid.lead": "Le point de terminaison répond mais est invalide.",
|
||||
"mint.warnings.lnurlInvalid.body": "L'hôte répond, mais pas avec une annonce de mint que ce site sait lire : les retraits risquent donc de ne pas fonctionner. Tout ce qui suit correspond au dernier état lisible, et les avis fonctionnent toujours.",
|
||||
"mint.warnings.lnurlInvalid.meta": "Répond, mais pas avec une annonce de mint valide",
|
||||
"mint.warnings.lnurlNoFunding.lead": "Minting et melting indisponibles.",
|
||||
"mint.warnings.lnurlNoFunding.body": "Les billets existants peuvent toujours être tournés, divisés ou fusionnés, mais rien n'entre ni ne sort par Lightning pour le moment. Le mint lui-même est actif et répond ; le nœud Lightning derrière lui n'est pas joignable depuis celui-ci.",
|
||||
"mint.warnings.lnurlNoFunding.meta": "Minting et melting indisponibles",
|
||||
"mint.warnings.also.noFunding": "Son nœud Lightning était également injoignable, il était donc impossible de faire du minting ou du melting."
|
||||
}
|
||||
|
||||
+118
-3
@@ -114,6 +114,8 @@
|
||||
"card.reviewed": "समीक्षा की गई {when}",
|
||||
"card.noReviews": "अभी तक कोई समीक्षा नहीं",
|
||||
"mint.title": "{name} · Cashu मिंट समीक्षाएँ",
|
||||
"mint.titleFedimint": "{name} · Fedimint फ़ेडरेशन की समीक्षाएँ",
|
||||
"mint.titleLnurl": "{name} · LNURL mint की समीक्षाएँ",
|
||||
"mint.breadcrumb": "ब्रेडक्रम्ब",
|
||||
"mint.crumbMints": "Cashu मिंट्स",
|
||||
"mint.copyUrl": "URL मिंट की प्रतिलिपि बनाएँ",
|
||||
@@ -325,7 +327,7 @@
|
||||
"reviews.validate.short": "समीक्षा उपयोगी हो, इसके लिए कम से कम {n} अक्षर लिखें।",
|
||||
"reviews.validate.long": "समीक्षाएँ {n} वर्णों तक सीमित हैं।",
|
||||
"feed.title": "नवीनतम मिंट और फेडरेशन समीक्षाएँ · Cashumints.space",
|
||||
"feed.description": "प्रत्येक मिंट और संघ समीक्षा जैसे ही प्रकाशित होती है: {reviews} समीक्षाएँ {mints} Cashu मिंट्स और {federations} Fedimint संघों में, Nostr पर हस्ताक्षरित और यहाँ नवीनतम से पहले पढ़ने योग्य। इकोसिस्टम, रेटिंग, या नाम के अनुसार फ़िल्टर करें।",
|
||||
"feed.description": "हर मिंट, फ़ेडरेशन और LNURL मिंट समीक्षा प्रकाशित होते ही: {mints} Cashu मिंट, {federations} Fedimint फ़ेडरेशन और {lnurl} LNURL मिंट पर {reviews} समीक्षाएँ, Nostr पर हस्ताक्षरित और यहाँ नवीनतम पहले पढ़ने योग्य. पारिस्थितिकी, रेटिंग या नाम से छाँटें.",
|
||||
"feed.heading": "नवीनतम समीक्षाएँ",
|
||||
"feed.lede": "हर Cashu, मिंट और Fedimint संघ की हर समीक्षा, नवीनतम से शुरू होकर, Nostr रिले से सीधे पढ़ी जाती है। प्रत्येक समीक्षा अपने लेखक द्वारा हस्ताक्षरित होती है और हमारे डेटाबेस में नहीं, बल्कि रिले पर रहती है।",
|
||||
"feed.label": "समीक्षाएँ",
|
||||
@@ -400,7 +402,14 @@
|
||||
"notfound.fb.reviews": "समीक्षाएँ",
|
||||
"notfound.fb.software": "सॉफ़्टवेयर",
|
||||
"notfound.fb.lastOnline": "अंतिम ऑनलाइन",
|
||||
"notfound.fb.note": "यह मिंट साइट की पिछली पुनर्निर्माण के बाद अनुक्रमित किया गया था, इसलिए यह लाइव एपीआई से लिया गया सारांश है। समीक्षाओं सहित पूरा पृष्ठ अगली पुनर्निर्माण में दिखाई देगा।",
|
||||
"notfound.fb.note": "यह mint साइट के पिछले बिल्ड के बाद इंडेक्स हुआ था, इसलिए यह लाइव API से बना सारांश है और नीचे इसकी समीक्षाएँ हैं। पूरा पेज अगले बिल्ड पर दिखेगा।",
|
||||
"notfound.resolve.checking": "पहली बार: इस mint की जाँच अभी हो रही है।",
|
||||
"notfound.resolve.failed": "यह mint जोड़ा नहीं जा सका",
|
||||
"notfound.resolve.failedFedimint": "यह फ़ेडरेशन जोड़ा नहीं जा सका",
|
||||
"notfound.resolve.retry": "दूसरा पता आज़माएँ",
|
||||
"notfound.resolve.switch": "इसे {kind} mint के रूप में खोलें",
|
||||
"notfound.resolve.fedimint": "फ़ेडरेशन के पेज के पते में उसका invite code नहीं होता, इसलिए सिर्फ़ लिंक से इसे खोजा नहीं जा सकता। invite code चिपकाएँ, यह जुड़ जाएगा।",
|
||||
"notfound.resolve.ambiguous": "सिर्फ़ लिंक से यह पता निकाला नहीं जा सकता। mint का पूरा URL चिपकाएँ, यह जुड़ जाएगा।",
|
||||
"wallets.title": "Cashu वॉलेट्स · Cashumints.space",
|
||||
"wallets.description": "Cashu वॉलेट जो यहाँ सूचीबद्ध मिंट्स के साथ मोबाइल, वेब, ब्राउज़र और कमांड लाइन पर काम करते हैं, और प्रत्येक किस काम में अच्छा है।",
|
||||
"wallets.heading": "बटुए",
|
||||
@@ -654,5 +663,111 @@
|
||||
"footer.allFedimints": "सभी महासंघ",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "मिंटिंग, मेल्टिंग और सपोर्ट का हिसाब कैसे निकला? विशिष्ट रूप से बताएं।",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "जॉइनिंग, Lightning भुगतान और सपोर्ट का काम कैसे हुआ? विशिष्ट रूप से बताएं।",
|
||||
"fedimint.details.title": "संघ का विवरण"
|
||||
"fedimint.details.title": "संघ का विवरण",
|
||||
"nav.lnurlMints": "LNURL मिंट",
|
||||
"footer.allLnurlMints": "सभी LNURL मिंट",
|
||||
"lnurlMints.title": "LNURL मिंट: lnurlcash वाहक नोट मिंट, समीक्षा सहित | Cashumints.space",
|
||||
"lnurlMints.description": "Nostr नेटवर्क से अनुक्रमित सभी {total} LNURL मिंट: {online} ऑनलाइन, {offline} ऑफ़लाइन. हर एक के लिए समुदाय की रेटिंग, निकासी सीमाएँ और सुविधाएँ.",
|
||||
"lnurlMints.heading": "LNURL मिंट",
|
||||
"lnurlMints.lede": "इस साइट को ज्ञात हर LNURL मिंट. एक LNURL मिंट lnurlcash वाहक नोट जारी करता है: लाइटनिंग नोट जिन्हें आप एक रहस्य की तरह रखते हैं, और जिन्हें घुमाया, बाँटा, जोड़ा या वापस लाइटनिंग भुगतान में पिघलाया जा सकता है. हर एक की HTTPS पर कुछ मिनटों के अंतराल पर जाँच होती है.",
|
||||
"lnurlMints.search.label": "LNURL मिंट खोजें",
|
||||
"lnurlMints.search.placeholder": "नाम या पते से खोजें…",
|
||||
"lnurlMints.sort.label": "LNURL मिंट क्रमबद्ध करें",
|
||||
"lnurlMints.hideOffline": "ऑफ़लाइन छिपाएँ",
|
||||
"lnurlMints.showing": "{total} LNURL मिंट दिखाए जा रहे हैं ({online} ऑनलाइन, {offline} ऑफ़लाइन)",
|
||||
"lnurlMints.showingFiltered": "{total} में से {shown} 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 मिंट आपके sats रखता है जबकि आप उसके नोट रखते हैं, इसलिए उसका उपयोग करने का मतलब है उसके संचालक पर भरोसा करना. रेटिंग समुदाय की राय हैं, और ऑनलाइन का मतलब पहुँच योग्य है, भरोसेमंद नहीं.",
|
||||
"lnurl.features.title": "सुविधाएँ",
|
||||
"lnurl.features.none": "इस मिंट ने घोषित नहीं किया कि वह क्या समर्थन करता है.",
|
||||
"lnurl.features.note": "सुविधाएँ वही हैं जो मिंट के संचालक ने Nostr पर घोषित कीं. जहाँ यह साइट HTTPS पर किसी की जाँच कर सकती है, वहाँ करती है, और बताती है जब कोई घोषित चीज़ अभी उपलब्ध नहीं है.",
|
||||
"lnurl.features.notListed": "घोषित नहीं",
|
||||
"lnurl.features.unavailable": "अनुपलब्ध",
|
||||
"lnurl.feature.mint": "लाइटनिंग से मिंट (LUD-06)",
|
||||
"lnurl.feature.melt": "लाइटनिंग में पिघलाना",
|
||||
"lnurl.feature.notes": "नोट घुमाना / बाँटना / जोड़ना",
|
||||
"lnurl.feature.lud16": "लाइटनिंग पता",
|
||||
"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} sat",
|
||||
"lnurl.limits.sats.other": "{n} sats",
|
||||
"lnurl.details.title": "मिंट विवरण",
|
||||
"lnurl.details.address": "लाइटनिंग पता",
|
||||
"lnurl.details.mintPubkey": "मिंट की सार्वजनिक कुंजी",
|
||||
"lnurl.details.onion": "Tor पता",
|
||||
"lnurl.details.fee": "मिंट शुल्क",
|
||||
"lnurl.details.node": "नोड",
|
||||
"lnurl.details.capacity": "घोषित क्षमता",
|
||||
"lnurl.kind.lightningAddress": "लाइटनिंग पता",
|
||||
"lnurl.kind.mintPubkey": "मिंट की सार्वजनिक कुंजी",
|
||||
"lnurl.kind.onionAddress": "Tor पता",
|
||||
"lnurl.confirmed.label": "अंतिम पुष्टि",
|
||||
"lnurl.confirmed.when": "{when} पुष्ट",
|
||||
"lnurl.fee.base.one": "{n} sat",
|
||||
"lnurl.fee.base.other": "{n} sats",
|
||||
"lnurl.fee.percent": "{percent}%",
|
||||
"home.topLnurl.title": "शीर्ष LNURL मिंट",
|
||||
"home.topLnurl.sub": "समीक्षाओं और रेटिंग के अनुसार",
|
||||
"home.topLnurl.all": "सभी {n} LNURL मिंट →",
|
||||
"feed.ecosystem.lnurl": "LNURL",
|
||||
"reviews.dialog.bodyPlaceholder.lnurl": "मिंट करना, पिघलाना और नोट घुमाना कैसा रहा? विशिष्ट रहें.",
|
||||
"reviews.byUrl.open": "समीक्षा लिखें",
|
||||
"reviews.byUrl.title": "समीक्षा लिखें",
|
||||
"reviews.byUrl.lede": "पता चिपकाएँ। अगर यह साइट उसे अभी तक नहीं जानती, तो उसे अभी जाँचकर जोड़ लिया जाएगा।",
|
||||
"reviews.byUrl.label.cashu": "mint का URL",
|
||||
"reviews.byUrl.label.fedimint": "फ़ेडरेशन का invite code",
|
||||
"reviews.byUrl.label.lnurl": "LNURL mint का URL",
|
||||
"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": "उस पते ने {kind} mint की तरह जवाब दिया।",
|
||||
"reviews.byUrl.switchTo": "इसे {kind} mint के रूप में समीक्षा करें",
|
||||
"reviews.byUrl.error.empty": "पहले कोई पता लिखें।",
|
||||
"reviews.byUrl.error.badUrl": "यह mint के URL जैसा नहीं लगता।",
|
||||
"reviews.byUrl.error.badInvite": "यह Fedimint का invite code नहीं है। कोड 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} sat.",
|
||||
"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": "मौजूदा नोट अब भी घुमाए, बाँटे या जोड़े जा सकते हैं, पर अभी लाइटनिंग से कुछ भी अंदर या बाहर नहीं जा रहा. मिंट खुद चालू है और जवाब दे रहा है; उसके पीछे का लाइटनिंग नोड उससे पहुँच में नहीं है.",
|
||||
"mint.warnings.lnurlNoFunding.meta": "मिंट और मेल्ट अनुपलब्ध",
|
||||
"mint.warnings.also.noFunding": "इसका लाइटनिंग नोड भी पहुँच से बाहर था, इसलिए कुछ भी मिंट या मेल्ट नहीं किया जा सकता था."
|
||||
}
|
||||
|
||||
+118
-3
@@ -114,6 +114,8 @@
|
||||
"card.reviewed": "{when}",
|
||||
"card.noReviews": "belum ada ulasan",
|
||||
"mint.title": "{name}",
|
||||
"mint.titleFedimint": "{name} · Ulasan federasi Fedimint",
|
||||
"mint.titleLnurl": "{name} · Ulasan mint LNURL",
|
||||
"mint.breadcrumb": "Breadcrumb",
|
||||
"mint.crumbMints": "Mints Cashu",
|
||||
"mint.copyUrl": "Salin URL mint",
|
||||
@@ -325,7 +327,7 @@
|
||||
"reviews.validate.short": "Tulis setidaknya karakter {n} sehingga ulasan berguna.",
|
||||
"reviews.validate.long": "Ulasan terbatas pada karakter {n}.",
|
||||
"feed.title": "Ulasan mint dan federasi terbaru",
|
||||
"feed.description": "Setiap ulasan mint dan federasi saat diterbitkan: {reviews} ulasan di {mints} mint Cashu dan {federations} federasi Fedimint, ditandatangani di Nostr dan ditampilkan dari yang terbaru. Saring berdasarkan ekosistem, peringkat, atau nama.",
|
||||
"feed.description": "Setiap ulasan mint, federasi, dan mint LNURL begitu dipublikasikan: {reviews} ulasan atas {mints} mint Cashu, {federations} federasi Fedimint, dan {lnurl} mint LNURL, ditandatangani di Nostr dan dapat dibaca di sini mulai dari yang terbaru. Saring berdasarkan ekosistem, penilaian, atau nama.",
|
||||
"feed.heading": "Ulasan Terbaru",
|
||||
"feed.lede": "Setiap ulasan dari setiap Cushu mint dan Fedimint federation, terbaru pertama, membaca langsung dari relays Nostr. Masing-masing ditandatangani oleh penulis dan tinggal di relay, tidak dalam database kami.",
|
||||
"feed.label": "Ulasan",
|
||||
@@ -400,7 +402,14 @@
|
||||
"notfound.fb.reviews": "Ulasan",
|
||||
"notfound.fb.software": "Perangkat Lunak",
|
||||
"notfound.fb.lastOnline": "Online terakhir",
|
||||
"notfound.fb.note": "Mint ini diindeks setelah situs terakhir dibangun, jadi ini adalah ringkasan dari API hidup. Halaman penuh dengan ulasan muncul di pembangunan kembali berikutnya.",
|
||||
"notfound.fb.note": "Mint ini diindeks setelah situs terakhir dibangun, jadi ini ringkasan dari API langsung, dengan ulasannya di bawah. Halaman lengkapnya muncul pada pembangunan berikutnya.",
|
||||
"notfound.resolve.checking": "Kunjungan pertama: mint ini sedang diperiksa.",
|
||||
"notfound.resolve.failed": "Mint ini tidak bisa ditambahkan",
|
||||
"notfound.resolve.failedFedimint": "Federasi ini tidak bisa ditambahkan",
|
||||
"notfound.resolve.retry": "Coba alamat lain",
|
||||
"notfound.resolve.switch": "Buka sebagai mint {kind}",
|
||||
"notfound.resolve.fedimint": "Alamat halaman sebuah federasi tidak memuat kode undangannya, jadi yang ini tidak bisa dicari hanya dari tautannya. Tempel kode undangannya dan federasi itu ditambahkan.",
|
||||
"notfound.resolve.ambiguous": "Alamat ini tidak bisa disimpulkan hanya dari tautannya. Tempel URL lengkap mint itu dan mint itu ditambahkan.",
|
||||
"wallets.title": "Dompet Cashu",
|
||||
"wallets.description": "Dompet Cashu yang bekerja dengan permen yang ada di sini, di ponsel, web, browser dan baris perintah, dengan keahlian masing-masing.",
|
||||
"wallets.heading": "Dompet",
|
||||
@@ -654,5 +663,111 @@
|
||||
"footer.allFedimints": "Semua federasi",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "Bagaimana perpaduan, pencairan dan dukungan bekerja? Lebih spesifik.",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "Bagaimana bergabung, pembayaran Petir dan dukungan berhasil? Lebih spesifik.",
|
||||
"fedimint.details.title": "Rincian Federasi"
|
||||
"fedimint.details.title": "Rincian Federasi",
|
||||
"nav.lnurlMints": "Mint LNURL",
|
||||
"footer.allLnurlMints": "Semua mint LNURL",
|
||||
"lnurlMints.title": "Mint LNURL: mint nota atas unjuk lnurlcash, dengan ulasan | Cashumints.space",
|
||||
"lnurlMints.description": "Seluruh {total} mint LNURL yang diindeks dari jaringan Nostr: {online} daring, {offline} luring. Penilaian komunitas, batas penarikan, dan fitur untuk masing-masing.",
|
||||
"lnurlMints.heading": "Mint LNURL",
|
||||
"lnurlMints.lede": "Setiap mint LNURL yang diketahui situs ini. Mint LNURL menerbitkan nota atas unjuk lnurlcash: nota Lightning yang Anda simpan sebagai rahasia, dan dapat diputar, dipecah, digabung, atau dilebur kembali menjadi pembayaran Lightning. Masing-masing diperiksa lewat HTTPS setiap beberapa menit.",
|
||||
"lnurlMints.search.label": "Cari mint LNURL",
|
||||
"lnurlMints.search.placeholder": "Cari berdasarkan nama atau alamat…",
|
||||
"lnurlMints.sort.label": "Urutkan mint LNURL berdasarkan",
|
||||
"lnurlMints.hideOffline": "Sembunyikan yang luring",
|
||||
"lnurlMints.showing": "Menampilkan {total} mint LNURL ({online} daring, {offline} luring)",
|
||||
"lnurlMints.showingFiltered": "Menampilkan {shown} dari {total} mint LNURL ({online} daring, {offline} luring)",
|
||||
"lnurlMints.noResults": "Tidak ada mint LNURL yang cocok.",
|
||||
"lnurlMints.emptyIndex": "Belum ada mint LNURL yang diindeks.",
|
||||
"lnurl.crumb": "Mint LNURL",
|
||||
"lnurl.breadcrumb": "Remah roti",
|
||||
"lnurl.copyAddress": "Salin alamat",
|
||||
"lnurl.more.title": "Mint LNURL lainnya",
|
||||
"lnurl.more.all": "Semua mint LNURL →",
|
||||
"lnurl.disclaimer": "Mint LNURL menyimpan sats Anda sementara Anda memegang notanya, jadi memakainya berarti memercayai operatornya. Penilaian adalah pendapat komunitas, dan daring berarti dapat dijangkau, bukan tepercaya.",
|
||||
"lnurl.features.title": "Fitur",
|
||||
"lnurl.features.none": "Mint ini belum mengumumkan apa yang didukungnya.",
|
||||
"lnurl.features.note": "Fitur adalah apa yang diumumkan operator mint di Nostr. Bila situs ini dapat memeriksanya lewat HTTPS, ia melakukannya, dan memberi tahu bila sesuatu yang diumumkan sedang tidak tersedia.",
|
||||
"lnurl.features.notListed": "tidak diumumkan",
|
||||
"lnurl.features.unavailable": "tidak tersedia",
|
||||
"lnurl.feature.mint": "Mint lewat Lightning (LUD-06)",
|
||||
"lnurl.feature.melt": "Lebur ke Lightning",
|
||||
"lnurl.feature.notes": "Putar / pecah / gabung nota",
|
||||
"lnurl.feature.lud16": "Alamat Lightning",
|
||||
"lnurl.feature.lud21": "Verifikasi pembayaran (LUD-21)",
|
||||
"lnurl.feature.signed-notes": "Nota bertanda tangan (verifikasi luring)",
|
||||
"lnurl.feature.onion": "Alamat Tor",
|
||||
"lnurl.feature.rotate": "Putar sebuah nota",
|
||||
"lnurl.feature.split": "Pecah sebuah nota",
|
||||
"lnurl.feature.merge": "Gabungkan nota",
|
||||
"lnurl.feature.lud06": "LUD-06 payRequest",
|
||||
"lnurl.feature.lud03": "LUD-03 withdrawRequest",
|
||||
"lnurl.limits.label": "Batas penarikan",
|
||||
"lnurl.limits.notPublished": "tidak dipublikasikan",
|
||||
"lnurl.limits.withdrawRow": "Penarikan",
|
||||
"lnurl.limits.disabled": "dinonaktifkan",
|
||||
"lnurl.limits.sats.one": "{n} sat",
|
||||
"lnurl.limits.sats.other": "{n} sat",
|
||||
"lnurl.details.title": "Detail mint",
|
||||
"lnurl.details.address": "Alamat Lightning",
|
||||
"lnurl.details.mintPubkey": "Kunci publik mint",
|
||||
"lnurl.details.onion": "Alamat Tor",
|
||||
"lnurl.details.fee": "Biaya mint",
|
||||
"lnurl.details.node": "Node",
|
||||
"lnurl.details.capacity": "Kapasitas yang diumumkan",
|
||||
"lnurl.kind.lightningAddress": "alamat Lightning",
|
||||
"lnurl.kind.mintPubkey": "kunci publik mint",
|
||||
"lnurl.kind.onionAddress": "alamat Tor",
|
||||
"lnurl.confirmed.label": "Terakhir dikonfirmasi",
|
||||
"lnurl.confirmed.when": "dikonfirmasi {when}",
|
||||
"lnurl.fee.base.one": "{n} sat",
|
||||
"lnurl.fee.base.other": "{n} sat",
|
||||
"lnurl.fee.percent": "{percent}%",
|
||||
"home.topLnurl.title": "Mint LNURL teratas",
|
||||
"home.topLnurl.sub": "diperingkat berdasarkan ulasan dan penilaian",
|
||||
"home.topLnurl.all": "Semua {n} mint LNURL →",
|
||||
"feed.ecosystem.lnurl": "LNURL",
|
||||
"reviews.dialog.bodyPlaceholder.lnurl": "Bagaimana pengalaman mint, lebur, dan memutar nota? Jelaskan secara spesifik.",
|
||||
"reviews.byUrl.open": "Tulis ulasan",
|
||||
"reviews.byUrl.title": "Tulis ulasan",
|
||||
"reviews.byUrl.lede": "Tempel alamatnya. Kalau situs ini belum mengenalnya, alamat itu diperiksa dan ditambahkan sekarang.",
|
||||
"reviews.byUrl.label.cashu": "URL mint",
|
||||
"reviews.byUrl.label.fedimint": "Kode undangan federasi",
|
||||
"reviews.byUrl.label.lnurl": "URL mint LNURL",
|
||||
"reviews.byUrl.placeholder.cashu": "mint.example.com",
|
||||
"reviews.byUrl.placeholder.fedimint": "fed11…",
|
||||
"reviews.byUrl.placeholder.lnurl": "mint.example.com",
|
||||
"reviews.byUrl.check": "Periksa alamat ini",
|
||||
"reviews.byUrl.checking": "Memeriksa…",
|
||||
"reviews.byUrl.back": "Pakai alamat lain",
|
||||
"reviews.byUrl.existing": "Sudah terdaftar",
|
||||
"reviews.byUrl.indexed": "Baru ditambahkan",
|
||||
"reviews.byUrl.confirmClose": "Tutup? Alamat yang kamu ketik tidak disimpan.",
|
||||
"reviews.byUrl.detected": "Alamat itu menjawab sebagai mint {kind}.",
|
||||
"reviews.byUrl.switchTo": "Ulas sebagai mint {kind}",
|
||||
"reviews.byUrl.error.empty": "Isi alamatnya dulu.",
|
||||
"reviews.byUrl.error.badUrl": "Itu tidak terlihat seperti URL mint.",
|
||||
"reviews.byUrl.error.badInvite": "Itu bukan kode undangan Fedimint. Kode dimulai dengan fed11.",
|
||||
"reviews.byUrl.error.badInput": "Situs ini tidak bisa memeriksa alamat itu.",
|
||||
"reviews.byUrl.error.blocked": "Itu bukan alamat publik, jadi tidak bisa diperiksa.",
|
||||
"reviews.byUrl.error.notAMint": "Alamat itu menjawab, tetapi bukan sebagai mint jenis ini.",
|
||||
"reviews.byUrl.error.wrongType": "Alamat itu menjawab, tetapi sebagai mint jenis lain.",
|
||||
"reviews.byUrl.error.unverifiable": "Tidak ada yang menjawab di alamat itu, dan jejaknya belum ada di Nostr.",
|
||||
"reviews.byUrl.error.rateLimited.one": "Terlalu banyak pemeriksaan dari satu koneksi. Coba lagi sekitar {n} menit lagi.",
|
||||
"reviews.byUrl.error.rateLimited.other": "Terlalu banyak pemeriksaan dari satu koneksi. Coba lagi sekitar {n} menit lagi.",
|
||||
"reviews.byUrl.error.network": "Indeks tidak dapat dihubungi. Periksa koneksimu, lalu coba lagi.",
|
||||
"seo.lnurl.titleNoReviews": "{name}: info dan ulasan mint LNURL | Cashumints.space",
|
||||
"seo.desc.statusWithdraw": "{status}, penarikan {min}–{max} sat.",
|
||||
"mint.chip.noFunding": "Tanpa mint / lebur",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.lead": "Penarikan dinonaktifkan.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.body.online": "Mint ini mengumumkan penarikan maksimum nol, sehingga tidak ada nota yang diterbitkannya dapat ditukar dengan apa pun. Jangan melakukan mint di sini sampai hal itu berubah.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.body.offline": "Saat terakhir dapat dihubungi, mint ini mengumumkan penarikan maksimum nol, sehingga tidak ada nota yang diterbitkannya dapat ditukar dengan apa pun. Jangan melakukan mint di sini sampai hal itu berubah.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.online": "Penarikan sedang dinonaktifkan",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.offline": "Penarikan dinonaktifkan saat terakhir terlihat",
|
||||
"mint.warnings.lnurlInvalid.lead": "Endpoint merespons tetapi tidak valid.",
|
||||
"mint.warnings.lnurlInvalid.body": "Host merespons, tetapi bukan dengan pengumuman mint yang dapat dibaca situs ini, jadi penarikan mungkin tidak berfungsi. Semua di bawah ini adalah keadaan terakhir yang berhasil dibaca, dan ulasan tetap berfungsi.",
|
||||
"mint.warnings.lnurlInvalid.meta": "Merespons, tetapi bukan dengan pengumuman mint yang valid",
|
||||
"mint.warnings.lnurlNoFunding.lead": "Mint dan lebur tidak tersedia.",
|
||||
"mint.warnings.lnurlNoFunding.body": "Nota yang ada masih dapat diputar, dipecah, atau digabung, tetapi saat ini tidak ada yang masuk atau keluar lewat Lightning. Mint-nya sendiri hidup dan merespons; node Lightning di belakangnya tidak dapat dijangkau dari sana.",
|
||||
"mint.warnings.lnurlNoFunding.meta": "Mint dan lebur tidak tersedia",
|
||||
"mint.warnings.also.noFunding": "Node Lightning-nya juga tidak dapat dijangkau, sehingga tidak ada yang bisa di-mint atau dilebur."
|
||||
}
|
||||
|
||||
+118
-3
@@ -114,6 +114,8 @@
|
||||
"card.reviewed": "recensione {when}",
|
||||
"card.noReviews": "nessuna recensione",
|
||||
"mint.title": "{name} · Cashu mint recensioni",
|
||||
"mint.titleFedimint": "{name} · Recensioni della federazione Fedimint",
|
||||
"mint.titleLnurl": "{name} · Recensioni del mint LNURL",
|
||||
"mint.breadcrumb": "Percorso di navigazione",
|
||||
"mint.crumbMints": "Cashu mints",
|
||||
"mint.copyUrl": "Copia URL mint",
|
||||
@@ -325,7 +327,7 @@
|
||||
"reviews.validate.short": "Scrivi almeno {n} caratteri in modo che la recensione sia utile.",
|
||||
"reviews.validate.long": "Le recensioni sono limitate ai caratteri {n}.",
|
||||
"feed.title": "Ultimi mint e recensioni federazioni · Cashumints.space",
|
||||
"feed.description": "Ogni recensione mint e federazione come è pubblicato: {reviews} recensioni attraverso {mints} Cashu mints e {federations} Fedimint federazioni, firmate Nostr e leggibili qui prima. Filtra per ecosistema, per valutazione, o per nome.",
|
||||
"feed.description": "Ogni recensione di mint, federazione e mint LNURL appena viene pubblicata: {reviews} recensioni su {mints} mint Cashu, {federations} federazioni Fedimint e {lnurl} mint LNURL, firmate su Nostr e leggibili qui dalla più recente. Filtra per ecosistema, valutazione o nome.",
|
||||
"feed.heading": "Ultime recensioni",
|
||||
"feed.lede": "Ogni recensione di ogni Cashu mint e Fedimint federazione, prima nuova, leggere dal vivo dai relè Nostr. Ognuno è firmato dal suo autore e vive sui relè, non nel nostro database.",
|
||||
"feed.label": "Recensioni",
|
||||
@@ -400,7 +402,14 @@
|
||||
"notfound.fb.reviews": "Recensioni",
|
||||
"notfound.fb.software": "Software software",
|
||||
"notfound.fb.lastOnline": "Ultimo online",
|
||||
"notfound.fb.note": "Questo mint è stato indicizzato dopo l'ultima costruzione del sito, quindi questo è un riassunto dell'API dal vivo. La pagina completa con le recensioni appare alla prossima ricostruzione.",
|
||||
"notfound.fb.note": "Questo mint è stato indicizzato dopo l’ultima build del sito, quindi questo è un riepilogo dall’API dal vivo, con le recensioni sotto. La pagina completa compare alla prossima build.",
|
||||
"notfound.resolve.checking": "Prima visita: sto controllando questo mint adesso.",
|
||||
"notfound.resolve.failed": "Non è stato possibile aggiungere questo mint",
|
||||
"notfound.resolve.failedFedimint": "Non è stato possibile aggiungere questa federazione",
|
||||
"notfound.resolve.retry": "Prova un altro indirizzo",
|
||||
"notfound.resolve.switch": "Aprilo come mint {kind}",
|
||||
"notfound.resolve.fedimint": "L’indirizzo della pagina di una federazione non contiene il suo codice di invito, quindi non si può risalire a questa dal solo link. Incolla il codice di invito e verrà aggiunta.",
|
||||
"notfound.resolve.ambiguous": "Questo indirizzo non si può ricavare dal solo link. Incolla l’URL completo del mint e verrà aggiunto.",
|
||||
"wallets.title": "Cashu portafogli · Cashumints.space",
|
||||
"wallets.description": "Cashu portafogli che funzionano con il mints elencati qui, attraverso mobile, web, browser e la riga di comando, con quello che ognuno è buono a.",
|
||||
"wallets.heading": "Portafogli",
|
||||
@@ -654,5 +663,111 @@
|
||||
"footer.allFedimints": "Tutte le federazioni",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "Come ha funzionato minting, melting e supporto? Sii specifico.",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "Come si è unito, Lightning pagamenti e supporto funziona? Sii specifico.",
|
||||
"fedimint.details.title": "Dettagli della Federazione"
|
||||
"fedimint.details.title": "Dettagli della Federazione",
|
||||
"nav.lnurlMints": "Mint LNURL",
|
||||
"footer.allLnurlMints": "Tutti i mint LNURL",
|
||||
"lnurlMints.title": "Mint LNURL: mint di banconote al portatore lnurlcash, con recensioni | Cashumints.space",
|
||||
"lnurlMints.description": "Tutti i {total} mint LNURL indicizzati dalla rete Nostr: {online} online, {offline} offline. Valutazioni della comunità, limiti di prelievo e funzionalità di ciascuno.",
|
||||
"lnurlMints.heading": "Mint LNURL",
|
||||
"lnurlMints.lede": "Ogni mint LNURL che questo sito conosce. Un mint LNURL emette banconote al portatore lnurlcash: banconote Lightning che conservi come un segreto e che si possono ruotare, dividere, unire o rifondere in un pagamento Lightning. Ognuno viene controllato via HTTPS ogni pochi minuti.",
|
||||
"lnurlMints.search.label": "Cerca mint LNURL",
|
||||
"lnurlMints.search.placeholder": "Cerca per nome o indirizzo…",
|
||||
"lnurlMints.sort.label": "Ordina i mint LNURL per",
|
||||
"lnurlMints.hideOffline": "Nascondi offline",
|
||||
"lnurlMints.showing": "Mostrati {total} mint LNURL ({online} online, {offline} offline)",
|
||||
"lnurlMints.showingFiltered": "Mostrati {shown} di {total} mint LNURL ({online} online, {offline} offline)",
|
||||
"lnurlMints.noResults": "Nessun mint LNURL corrisponde.",
|
||||
"lnurlMints.emptyIndex": "Non ci sono ancora mint LNURL indicizzati.",
|
||||
"lnurl.crumb": "Mint LNURL",
|
||||
"lnurl.breadcrumb": "Percorso di navigazione",
|
||||
"lnurl.copyAddress": "Copia indirizzo",
|
||||
"lnurl.more.title": "Altri mint LNURL",
|
||||
"lnurl.more.all": "Tutti i mint LNURL →",
|
||||
"lnurl.disclaimer": "Un mint LNURL custodisce i tuoi sat mentre tu custodisci le sue banconote, quindi usarne uno significa fidarsi del suo operatore. Le valutazioni sono opinioni della comunità, e online significa raggiungibile, non affidabile.",
|
||||
"lnurl.features.title": "Funzionalità",
|
||||
"lnurl.features.none": "Questo mint non ha annunciato cosa supporta.",
|
||||
"lnurl.features.note": "Le funzionalità sono ciò che l'operatore del mint ha annunciato su Nostr. Dove questo sito può verificarne una via HTTPS lo fa, e segnala quando qualcosa di annunciato non è disponibile in questo momento.",
|
||||
"lnurl.features.notListed": "non annunciato",
|
||||
"lnurl.features.unavailable": "non disponibile",
|
||||
"lnurl.feature.mint": "Minting via Lightning (LUD-06)",
|
||||
"lnurl.feature.melt": "Melting verso Lightning",
|
||||
"lnurl.feature.notes": "Ruota / dividi / unisci banconote",
|
||||
"lnurl.feature.lud16": "Indirizzo Lightning",
|
||||
"lnurl.feature.lud21": "Verifica del pagamento (LUD-21)",
|
||||
"lnurl.feature.signed-notes": "Banconote firmate (verifica offline)",
|
||||
"lnurl.feature.onion": "Indirizzo Tor",
|
||||
"lnurl.feature.rotate": "Ruota una banconota",
|
||||
"lnurl.feature.split": "Dividi una banconota",
|
||||
"lnurl.feature.merge": "Unisci banconote",
|
||||
"lnurl.feature.lud06": "payRequest LUD-06",
|
||||
"lnurl.feature.lud03": "withdrawRequest LUD-03",
|
||||
"lnurl.limits.label": "Limiti di prelievo",
|
||||
"lnurl.limits.notPublished": "non pubblicato",
|
||||
"lnurl.limits.withdrawRow": "Prelievo",
|
||||
"lnurl.limits.disabled": "disattivato",
|
||||
"lnurl.limits.sats.one": "{n} sat",
|
||||
"lnurl.limits.sats.other": "{n} sat",
|
||||
"lnurl.details.title": "Dettagli del mint",
|
||||
"lnurl.details.address": "Indirizzo Lightning",
|
||||
"lnurl.details.mintPubkey": "Chiave pubblica del mint",
|
||||
"lnurl.details.onion": "Indirizzo Tor",
|
||||
"lnurl.details.fee": "Commissione di minting",
|
||||
"lnurl.details.node": "Nodo",
|
||||
"lnurl.details.capacity": "Capacità annunciata",
|
||||
"lnurl.kind.lightningAddress": "indirizzo Lightning",
|
||||
"lnurl.kind.mintPubkey": "chiave pubblica del mint",
|
||||
"lnurl.kind.onionAddress": "indirizzo Tor",
|
||||
"lnurl.confirmed.label": "Ultima conferma",
|
||||
"lnurl.confirmed.when": "confermato {when}",
|
||||
"lnurl.fee.base.one": "{n} sat",
|
||||
"lnurl.fee.base.other": "{n} sat",
|
||||
"lnurl.fee.percent": "{percent}%",
|
||||
"home.topLnurl.title": "Migliori mint LNURL",
|
||||
"home.topLnurl.sub": "ordinati per recensioni e valutazione",
|
||||
"home.topLnurl.all": "Tutti i {n} mint LNURL →",
|
||||
"feed.ecosystem.lnurl": "LNURL",
|
||||
"reviews.dialog.bodyPlaceholder.lnurl": "Come sono andati minting, melting e la rotazione delle banconote? Sii concreto.",
|
||||
"reviews.byUrl.open": "Scrivi una recensione",
|
||||
"reviews.byUrl.title": "Scrivi una recensione",
|
||||
"reviews.byUrl.lede": "Incolla l’indirizzo. Se questo sito non lo conosce ancora, viene controllato e aggiunto ora.",
|
||||
"reviews.byUrl.label.cashu": "URL del mint",
|
||||
"reviews.byUrl.label.fedimint": "Codice di invito della federazione",
|
||||
"reviews.byUrl.label.lnurl": "URL del mint LNURL",
|
||||
"reviews.byUrl.placeholder.cashu": "mint.example.com",
|
||||
"reviews.byUrl.placeholder.fedimint": "fed11…",
|
||||
"reviews.byUrl.placeholder.lnurl": "mint.example.com",
|
||||
"reviews.byUrl.check": "Controlla questo indirizzo",
|
||||
"reviews.byUrl.checking": "Controllo in corso…",
|
||||
"reviews.byUrl.back": "Usa un altro indirizzo",
|
||||
"reviews.byUrl.existing": "Già elencato",
|
||||
"reviews.byUrl.indexed": "Appena aggiunto",
|
||||
"reviews.byUrl.confirmClose": "Chiudere? L’indirizzo digitato non viene conservato.",
|
||||
"reviews.byUrl.detected": "Quell’indirizzo ha risposto come un mint {kind}.",
|
||||
"reviews.byUrl.switchTo": "Recensiscilo come mint {kind}",
|
||||
"reviews.byUrl.error.empty": "Inserisci prima un indirizzo.",
|
||||
"reviews.byUrl.error.badUrl": "Non sembra l’URL di un mint.",
|
||||
"reviews.byUrl.error.badInvite": "Non è un codice di invito Fedimint. I codici iniziano con fed11.",
|
||||
"reviews.byUrl.error.badInput": "Questo sito non può controllare quell’indirizzo.",
|
||||
"reviews.byUrl.error.blocked": "Non è un indirizzo pubblico, quindi non può essere controllato.",
|
||||
"reviews.byUrl.error.notAMint": "Quell’indirizzo ha risposto, ma non come un mint di questo tipo.",
|
||||
"reviews.byUrl.error.wrongType": "Quell’indirizzo ha risposto, ma come un mint di un altro tipo.",
|
||||
"reviews.byUrl.error.unverifiable": "A quell’indirizzo non ha risposto nulla, e su Nostr non c’è ancora traccia.",
|
||||
"reviews.byUrl.error.rateLimited.one": "Sono molti controlli da una sola connessione. Riprova tra circa {n} minuto.",
|
||||
"reviews.byUrl.error.rateLimited.other": "Sono molti controlli da una sola connessione. Riprova tra circa {n} minuti.",
|
||||
"reviews.byUrl.error.network": "Non è stato possibile raggiungere l’indice. Controlla la connessione e riprova.",
|
||||
"seo.lnurl.titleNoReviews": "{name}: informazioni e recensioni del mint LNURL | Cashumints.space",
|
||||
"seo.desc.statusWithdraw": "{status}, prelievo {min}–{max} sat.",
|
||||
"mint.chip.noFunding": "Né mint né melt",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.lead": "Prelievi disattivati.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.body.online": "Questo mint dichiara un prelievo massimo pari a zero, quindi nessuna banconota che emette può essere riscossa per qualcosa. Non fare minting qui finché non cambia.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.body.offline": "Questo mint dichiarava un prelievo massimo pari a zero l'ultima volta che è stato raggiunto, quindi nessuna banconota che emetteva poteva essere riscossa per qualcosa. Non fare minting qui finché non cambia.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.online": "Prelievi attualmente disattivati",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.offline": "I prelievi erano disattivati all'ultimo contatto",
|
||||
"mint.warnings.lnurlInvalid.lead": "L'endpoint risponde ma non è valido.",
|
||||
"mint.warnings.lnurlInvalid.body": "L'host risponde, ma non con un annuncio di mint che questo sito sappia leggere, quindi i prelievi potrebbero non funzionare. Quello che segue è l'ultimo stato leggibile, e le recensioni funzionano ancora.",
|
||||
"mint.warnings.lnurlInvalid.meta": "Risponde, ma non con un annuncio di mint valido",
|
||||
"mint.warnings.lnurlNoFunding.lead": "Minting e melting non disponibili.",
|
||||
"mint.warnings.lnurlNoFunding.body": "Le banconote esistenti si possono ancora ruotare, dividere o unire, ma al momento nulla entra o esce via Lightning. Il mint è attivo e risponde; il nodo Lightning che sta dietro non è raggiungibile da lì.",
|
||||
"mint.warnings.lnurlNoFunding.meta": "Minting e melting non disponibili",
|
||||
"mint.warnings.also.noFunding": "Anche il suo nodo Lightning era irraggiungibile, quindi non si poteva fare né minting né melting."
|
||||
}
|
||||
|
||||
+118
-3
@@ -114,6 +114,8 @@
|
||||
"card.reviewed": "レビュー {when}",
|
||||
"card.noReviews": "レビューはまだありません",
|
||||
"mint.title": "{name} · Cashuミントレビュー",
|
||||
"mint.titleFedimint": "{name} · Fedimint フェデレーションのレビュー",
|
||||
"mint.titleLnurl": "{name} · LNURL mint のレビュー",
|
||||
"mint.breadcrumb": "パン粉",
|
||||
"mint.crumbMints": "キャッシュミント",
|
||||
"mint.copyUrl": "ミントURLをコピーする",
|
||||
@@ -325,7 +327,7 @@
|
||||
"reviews.validate.short": "少なくとも{n}文字を書くと、レビューが便利です。",
|
||||
"reviews.validate.long": "レビューは{n}文字に制限されています。",
|
||||
"feed.title": "最新のミントとフェデレーションレビュー・カウンティン。 スペース",
|
||||
"feed.description": "すべてのミントとフェデレーションのレビューが公開されています: {reviews} レビューを渡る {mints} Cashu mints と {federations} Fedimint のフェデレーション, ノストに署名し、ここ最新で読みやすく. 生態系、評価、または名前でフィルタリングします。",
|
||||
"feed.description": "ミント、フェデレーション、LNURL ミントのレビューを公開され次第すべて掲載:{mints} 件の Cashu ミント、{federations} 件の Fedimint フェデレーション、{lnurl} 件の LNURL ミントにわたる {reviews} 件のレビューを、Nostr 上で署名された新しい順に読めます。エコシステム、評価、名前で絞り込めます。",
|
||||
"feed.heading": "最新のレビュー",
|
||||
"feed.lede": "すべての Cashu mint と Fedimint のフェデレーションのすべてのレビューは、Nostr リレーからライブを最初に読みます。 それぞれがその著者によって署名され、私たちのデータベースではなく、リレーに住んでいます。",
|
||||
"feed.label": "レビュー",
|
||||
@@ -400,7 +402,14 @@
|
||||
"notfound.fb.reviews": "レビュー",
|
||||
"notfound.fb.software": "ソフトウェア",
|
||||
"notfound.fb.lastOnline": "最新オンライン",
|
||||
"notfound.fb.note": "サイトのビルド後、この mint がインデックス化されました。ライブ API のサマリーです。 次の再ビルドでレビュー付きのフルページが表示されます。",
|
||||
"notfound.fb.note": "この mint はサイトの最終ビルド後にインデックスされたため、これはライブ API からの要約で、下にレビューがあります。完全なページは次回のビルドで表示されます。",
|
||||
"notfound.resolve.checking": "初めての訪問です。この mint を今確認しています。",
|
||||
"notfound.resolve.failed": "この mint は追加できませんでした",
|
||||
"notfound.resolve.failedFedimint": "このフェデレーションは追加できませんでした",
|
||||
"notfound.resolve.retry": "別のアドレスを試す",
|
||||
"notfound.resolve.switch": "{kind} の mint として開く",
|
||||
"notfound.resolve.fedimint": "フェデレーションのページアドレスには invite code が含まれないため、リンクだけでは見つけられません。invite code を貼り付ければ追加されます。",
|
||||
"notfound.resolve.ambiguous": "このアドレスはリンクだけからは特定できません。mint の完全な URL を貼り付ければ追加されます。",
|
||||
"wallets.title": "Cashuウォレット・Cashumints スペース",
|
||||
"wallets.description": "モバイル、ウェブ、ブラウザ、コマンドラインで、ここにリストされているミントで動作するキャッシュウォレットは、それぞれが得意です。",
|
||||
"wallets.heading": "ウォレット",
|
||||
@@ -654,5 +663,111 @@
|
||||
"footer.allFedimints": "すべてのフェデレーション",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "鉱山、溶融、サポートはどのように機能しましたか? 具体的に。",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "参加方法、支払いのLightning、サポートはどのように機能しますか? 具体的に。",
|
||||
"fedimint.details.title": "フェデレーションの詳細"
|
||||
"fedimint.details.title": "フェデレーションの詳細",
|
||||
"nav.lnurlMints": "LNURL ミント",
|
||||
"footer.allLnurlMints": "すべての LNURL ミント",
|
||||
"lnurlMints.title": "LNURL ミント:lnurlcash 無記名ノートのミント評価 | Cashumints.space",
|
||||
"lnurlMints.description": "Nostr ネットワークから索引した {total} 件の LNURL ミント:オンライン {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": "{total} 件中 {shown} 件の 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 ミントはあなたの sats を預かり、あなたはそのノートを持ちます。つまり利用は運営者を信頼するということです。評価はコミュニティの意見であり、オンラインは到達可能という意味で、信頼できるという意味ではありません。",
|
||||
"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} sat",
|
||||
"lnurl.limits.sats.other": "{n} sats",
|
||||
"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} sat",
|
||||
"lnurl.fee.base.other": "{n} sats",
|
||||
"lnurl.fee.percent": "{percent}%",
|
||||
"home.topLnurl.title": "人気の LNURL ミント",
|
||||
"home.topLnurl.sub": "レビュー数と評価で順位付け",
|
||||
"home.topLnurl.all": "全 {n} 件の LNURL ミント →",
|
||||
"feed.ecosystem.lnurl": "LNURL",
|
||||
"reviews.dialog.bodyPlaceholder.lnurl": "ミント、メルト、ノートのローテーションはどうでしたか?具体的にどうぞ。",
|
||||
"reviews.byUrl.open": "レビューを書く",
|
||||
"reviews.byUrl.title": "レビューを書く",
|
||||
"reviews.byUrl.lede": "アドレスを貼り付けてください。このサイトがまだ知らないアドレスなら、今すぐ確認して追加します。",
|
||||
"reviews.byUrl.label.cashu": "mint の URL",
|
||||
"reviews.byUrl.label.fedimint": "フェデレーションの invite code",
|
||||
"reviews.byUrl.label.lnurl": "LNURL mint の URL",
|
||||
"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": "このアドレスは {kind} の mint として応答しました。",
|
||||
"reviews.byUrl.switchTo": "{kind} の mint としてレビューする",
|
||||
"reviews.byUrl.error.empty": "まずアドレスを入力してください。",
|
||||
"reviews.byUrl.error.badUrl": "mint の URL には見えません。",
|
||||
"reviews.byUrl.error.badInvite": "これは Fedimint の invite code ではありません。コードは 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} sat。",
|
||||
"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 ノードにも到達できず、ミントもメルトもできませんでした。"
|
||||
}
|
||||
|
||||
@@ -11,29 +11,4 @@
|
||||
*/
|
||||
export const DEFAULT_LOCALE = 'en';
|
||||
|
||||
export const LOCALE_CODES = [
|
||||
'en',
|
||||
'es',
|
||||
'nl',
|
||||
'zh',
|
||||
'hi',
|
||||
'ar',
|
||||
'fr',
|
||||
'pt',
|
||||
'it',
|
||||
'tr',
|
||||
'pl',
|
||||
'uk',
|
||||
'ro',
|
||||
'el',
|
||||
'cs',
|
||||
'sv',
|
||||
'da',
|
||||
'id',
|
||||
'ur',
|
||||
'ru',
|
||||
'de',
|
||||
'ja',
|
||||
'ar-eg',
|
||||
'vi',
|
||||
];
|
||||
export const LOCALE_CODES = ['en', 'es', 'nl'];
|
||||
|
||||
+122
-3
@@ -50,6 +50,10 @@
|
||||
"footer.privacy": "Privacy",
|
||||
"footer.madeBy": "Gemaakt met ❤️ door {azzamo} · Draait op Cashu en NIP-87",
|
||||
"footer.warning": "Mints zijn custodiaal. Een mint kan er met jouw geld vandoor gaan.",
|
||||
"footer.tagline": "Reviews uit de community voor ecash mints, ondertekend op Nostr.",
|
||||
"footer.col.explore": "Verkennen",
|
||||
"footer.col.site": "Site",
|
||||
"footer.nav": "Voettekst",
|
||||
"home.title": "Cashumints.space · Vind een Cashu mint die je kunt vertrouwen",
|
||||
"home.description": "Vind een Cashu mint die je kunt vertrouwen. {mints} mints geïndexeerd met {reviews} reviews uit de community, ondertekend op Nostr, plus live uptime, ondersteunde NUTs en mint-software.",
|
||||
"home.hero.title": "Vind een Cashu mint",
|
||||
@@ -114,6 +118,8 @@
|
||||
"card.reviewed": "laatste review {when}",
|
||||
"card.noReviews": "nog geen reviews",
|
||||
"mint.title": "{name} · Reviews van deze Cashu mint",
|
||||
"mint.titleFedimint": "{name} · Reviews van de Fedimint-federatie",
|
||||
"mint.titleLnurl": "{name} · Reviews van de LNURL mint",
|
||||
"mint.breadcrumb": "Kruimelpad",
|
||||
"mint.crumbMints": "Cashu mints",
|
||||
"mint.copyUrl": "Mint-URL kopiëren",
|
||||
@@ -325,7 +331,7 @@
|
||||
"reviews.validate.short": "Schrijf minstens {n} tekens, anders heeft niemand iets aan de review.",
|
||||
"reviews.validate.long": "Reviews mogen maximaal {n} tekens lang zijn.",
|
||||
"feed.title": "Laatste reviews van mints en federaties · Cashumints.space",
|
||||
"feed.description": "Elke review van een mint of federatie zodra hij verschijnt: {reviews} reviews over {mints} Cashu mints en {federations} Fedimint-federaties, ondertekend op Nostr en hier te lezen met de nieuwste bovenaan. Filter op ecosysteem, op beoordeling of op naam.",
|
||||
"feed.description": "Elke beoordeling van een mint, federatie en LNURL mint zodra die verschijnt: {reviews} beoordelingen over {mints} Cashu mints, {federations} Fedimint-federaties en {lnurl} LNURL mints, ondertekend op Nostr en hier te lezen met de nieuwste bovenaan. Filter op ecosysteem, op cijfer of op naam.",
|
||||
"feed.heading": "Laatste reviews",
|
||||
"feed.lede": "Elke review van elke Cashu mint en elke Fedimint-federatie, nieuwste bovenaan, live gelezen van de Nostr-relays. Elke review is ondertekend door wie hem schreef en staat op de relays, niet in onze database.",
|
||||
"feed.label": "Reviews",
|
||||
@@ -400,7 +406,14 @@
|
||||
"notfound.fb.reviews": "Reviews",
|
||||
"notfound.fb.software": "Software",
|
||||
"notfound.fb.lastOnline": "Laatst online",
|
||||
"notfound.fb.note": "Deze mint is geïndexeerd nadat de site voor het laatst gebouwd is, dus dit is een samenvatting uit de live API. De volledige pagina met reviews verschijnt bij de volgende build.",
|
||||
"notfound.fb.note": "Deze mint is geïndexeerd nadat de site voor het laatst gebouwd werd, dus dit is een samenvatting uit de live API, met de reviews eronder. De volledige pagina verschijnt bij de volgende build.",
|
||||
"notfound.resolve.checking": "Eerste bezoek: deze mint wordt nu gecontroleerd.",
|
||||
"notfound.resolve.failed": "Deze mint kon niet worden toegevoegd",
|
||||
"notfound.resolve.failedFedimint": "Deze federatie kon niet worden toegevoegd",
|
||||
"notfound.resolve.retry": "Een ander adres proberen",
|
||||
"notfound.resolve.switch": "Openen als {kind} mint",
|
||||
"notfound.resolve.fedimint": "Het paginadres van een federatie bevat de invite code niet, dus deze is niet op te zoeken met alleen de link. Plak de invite code en hij wordt toegevoegd.",
|
||||
"notfound.resolve.ambiguous": "Dit adres is niet af te leiden uit alleen de link. Plak de volledige URL van de mint en hij wordt toegevoegd.",
|
||||
"wallets.title": "Cashu wallets · Cashumints.space",
|
||||
"wallets.description": "Cashu wallets die werken met de mints die hier staan, voor mobiel, web, browser en de opdrachtregel, met waar elke wallet goed in is.",
|
||||
"wallets.heading": "Wallets",
|
||||
@@ -654,5 +667,111 @@
|
||||
"footer.allFedimints": "Alle federaties",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "Hoe gingen minten, melten en de support? Wees concreet.",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "Hoe gingen meedoen, Lightning-betalingen en de support? Wees concreet.",
|
||||
"fedimint.details.title": "Gegevens van de federatie"
|
||||
"fedimint.details.title": "Gegevens van de federatie",
|
||||
"nav.lnurlMints": "LNURL mints",
|
||||
"footer.allLnurlMints": "Alle LNURL mints",
|
||||
"lnurlMints.title": "LNURL mints: mints voor lnurlcash toonderbriefjes, beoordeeld | Cashumints.space",
|
||||
"lnurlMints.description": "Alle {total} LNURL mints die vanaf het Nostr-netwerk zijn geïndexeerd: {online} online, {offline} offline. Beoordelingen van de gemeenschap, opnamelimieten en mogelijkheden per mint.",
|
||||
"lnurlMints.heading": "LNURL mints",
|
||||
"lnurlMints.lede": "Elke LNURL mint die deze site kent. Een LNURL mint geeft lnurlcash toonderbriefjes uit: Lightning-briefjes die je als geheim bewaart en die je kunt roteren, splitsen, samenvoegen of terug melten naar een Lightning-betaling. Elke mint wordt om de paar minuten via HTTPS gecontroleerd.",
|
||||
"lnurlMints.search.label": "Zoek LNURL mints",
|
||||
"lnurlMints.search.placeholder": "Zoek op naam of adres…",
|
||||
"lnurlMints.sort.label": "Sorteer LNURL mints op",
|
||||
"lnurlMints.hideOffline": "Verberg offline",
|
||||
"lnurlMints.showing": "{total} LNURL mints getoond ({online} online, {offline} offline)",
|
||||
"lnurlMints.showingFiltered": "{shown} van {total} LNURL mints getoond ({online} online, {offline} offline)",
|
||||
"lnurlMints.noResults": "Geen LNURL mint komt daarmee overeen.",
|
||||
"lnurlMints.emptyIndex": "Er zijn nog geen LNURL mints geïndexeerd.",
|
||||
"lnurl.crumb": "LNURL mints",
|
||||
"lnurl.breadcrumb": "Kruimelpad",
|
||||
"lnurl.copyAddress": "Adres kopiëren",
|
||||
"lnurl.more.title": "Meer LNURL mints",
|
||||
"lnurl.more.all": "Alle LNURL mints →",
|
||||
"lnurl.disclaimer": "Een LNURL mint houdt jouw sats vast terwijl jij zijn briefjes vasthoudt, dus er een gebruiken betekent de beheerder vertrouwen. Beoordelingen zijn meningen van de gemeenschap, en online betekent bereikbaar, niet betrouwbaar.",
|
||||
"lnurl.features.title": "Functies",
|
||||
"lnurl.features.none": "Deze mint heeft niet aangekondigd wat hij ondersteunt.",
|
||||
"lnurl.features.note": "Functies zijn wat de beheerder van de mint op Nostr heeft aangekondigd. Waar deze site er een via HTTPS kan controleren doet hij dat, en hij zegt het als iets aangekondigds nu niet beschikbaar is.",
|
||||
"lnurl.features.notListed": "niet aangekondigd",
|
||||
"lnurl.features.unavailable": "niet beschikbaar",
|
||||
"lnurl.feature.mint": "Minten via Lightning (LUD-06)",
|
||||
"lnurl.feature.melt": "Melten naar Lightning",
|
||||
"lnurl.feature.notes": "Briefjes roteren / splitsen / samenvoegen",
|
||||
"lnurl.feature.lud16": "Lightning-adres",
|
||||
"lnurl.feature.lud21": "Betalingsverificatie (LUD-21)",
|
||||
"lnurl.feature.signed-notes": "Ondertekende briefjes (offline verificatie)",
|
||||
"lnurl.feature.onion": "Tor-adres",
|
||||
"lnurl.feature.rotate": "Een briefje roteren",
|
||||
"lnurl.feature.split": "Een briefje splitsen",
|
||||
"lnurl.feature.merge": "Briefjes samenvoegen",
|
||||
"lnurl.feature.lud06": "LUD-06 payRequest",
|
||||
"lnurl.feature.lud03": "LUD-03 withdrawRequest",
|
||||
"lnurl.limits.label": "Opnamelimieten",
|
||||
"lnurl.limits.notPublished": "niet gepubliceerd",
|
||||
"lnurl.limits.withdrawRow": "Opnemen",
|
||||
"lnurl.limits.disabled": "uitgeschakeld",
|
||||
"lnurl.limits.sats.one": "{n} sat",
|
||||
"lnurl.limits.sats.other": "{n} sats",
|
||||
"lnurl.details.title": "Gegevens van de mint",
|
||||
"lnurl.details.address": "Lightning-adres",
|
||||
"lnurl.details.mintPubkey": "Publieke sleutel van de mint",
|
||||
"lnurl.details.onion": "Tor-adres",
|
||||
"lnurl.details.fee": "Mintkosten",
|
||||
"lnurl.details.node": "Node",
|
||||
"lnurl.details.capacity": "Aangekondigde capaciteit",
|
||||
"lnurl.kind.lightningAddress": "Lightning-adres",
|
||||
"lnurl.kind.mintPubkey": "publieke sleutel van de mint",
|
||||
"lnurl.kind.onionAddress": "Tor-adres",
|
||||
"lnurl.confirmed.label": "Laatst bevestigd",
|
||||
"lnurl.confirmed.when": "bevestigd {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": "gerangschikt op beoordelingen en cijfer",
|
||||
"home.topLnurl.all": "Alle {n} LNURL mints →",
|
||||
"feed.ecosystem.lnurl": "LNURL",
|
||||
"reviews.dialog.bodyPlaceholder.lnurl": "Hoe gingen minten, melten en het roteren van briefjes? Wees concreet.",
|
||||
"reviews.byUrl.open": "Review schrijven",
|
||||
"reviews.byUrl.title": "Review schrijven",
|
||||
"reviews.byUrl.lede": "Plak het adres. Kent deze site het nog niet, dan wordt het nu gecontroleerd en toegevoegd.",
|
||||
"reviews.byUrl.label.cashu": "Mint-URL",
|
||||
"reviews.byUrl.label.fedimint": "Invite code van de federatie",
|
||||
"reviews.byUrl.label.lnurl": "URL van de LNURL mint",
|
||||
"reviews.byUrl.placeholder.cashu": "mint.example.com",
|
||||
"reviews.byUrl.placeholder.fedimint": "fed11…",
|
||||
"reviews.byUrl.placeholder.lnurl": "mint.example.com",
|
||||
"reviews.byUrl.check": "Dit adres controleren",
|
||||
"reviews.byUrl.checking": "Controleren…",
|
||||
"reviews.byUrl.back": "Ander adres gebruiken",
|
||||
"reviews.byUrl.existing": "Staat er al bij",
|
||||
"reviews.byUrl.indexed": "Net toegevoegd",
|
||||
"reviews.byUrl.confirmClose": "Sluiten? Het adres dat je typte wordt niet bewaard.",
|
||||
"reviews.byUrl.detected": "Dat adres antwoordde als een {kind} mint.",
|
||||
"reviews.byUrl.switchTo": "Als {kind} mint reviewen",
|
||||
"reviews.byUrl.error.empty": "Vul eerst een adres in.",
|
||||
"reviews.byUrl.error.badUrl": "Dat lijkt geen mint-URL.",
|
||||
"reviews.byUrl.error.badInvite": "Dat is geen Fedimint invite code. Codes beginnen met fed11.",
|
||||
"reviews.byUrl.error.badInput": "Deze site kan dat adres niet controleren.",
|
||||
"reviews.byUrl.error.blocked": "Dat is geen openbaar adres, dus het kan niet gecontroleerd worden.",
|
||||
"reviews.byUrl.error.notAMint": "Dat adres antwoordde, maar niet als een mint van dit soort.",
|
||||
"reviews.byUrl.error.wrongType": "Dat adres antwoordde, maar als een ander soort mint.",
|
||||
"reviews.byUrl.error.unverifiable": "Op dat adres reageerde niets, en op Nostr is er nog geen spoor van te vinden.",
|
||||
"reviews.byUrl.error.rateLimited.one": "Dat zijn veel controles vanaf één verbinding. Probeer het over ongeveer {n} minuut opnieuw.",
|
||||
"reviews.byUrl.error.rateLimited.other": "Dat zijn veel controles vanaf één verbinding. Probeer het over ongeveer {n} minuten opnieuw.",
|
||||
"reviews.byUrl.error.network": "De index was niet bereikbaar. Controleer je verbinding en probeer het opnieuw.",
|
||||
"seo.lnurl.titleNoReviews": "{name}: info en beoordelingen van de LNURL mint | Cashumints.space",
|
||||
"seo.desc.statusWithdraw": "{status}, opname {min}–{max} sat.",
|
||||
"mint.chip.noFunding": "Geen mint / melt",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.lead": "Opnemen staat uit.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.body.online": "Deze mint adverteert een maximale opname van nul, dus geen enkel briefje dat hij uitgeeft kan voor iets worden ingewisseld. Mint hier niet totdat dat verandert.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.body.offline": "Deze mint adverteerde een maximale opname van nul toen hij voor het laatst bereikt werd, dus geen enkel briefje dat hij uitgaf kon voor iets worden ingewisseld. Mint hier niet totdat dat verandert.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.online": "Opnemen staat momenteel uit",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.offline": "Opnemen stond uit toen hij voor het laatst gezien werd",
|
||||
"mint.warnings.lnurlInvalid.lead": "Endpoint antwoordt maar is ongeldig.",
|
||||
"mint.warnings.lnurlInvalid.body": "De host antwoordt, maar niet met een mintaankondiging die deze site kan lezen, dus opnemen werkt mogelijk niet. Alles hieronder is de laatste staat die wel te lezen was, en beoordelingen werken nog steeds.",
|
||||
"mint.warnings.lnurlInvalid.meta": "Antwoordt, maar niet met een geldige mintaankondiging",
|
||||
"mint.warnings.lnurlNoFunding.lead": "Minten en melten niet beschikbaar.",
|
||||
"mint.warnings.lnurlNoFunding.body": "Bestaande briefjes kunnen nog steeds geroteerd, gesplitst of samengevoegd worden, maar er gaat nu niets in of uit via Lightning. De mint zelf is bereikbaar en antwoordt; de Lightning-node erachter is vanaf daar niet bereikbaar.",
|
||||
"mint.warnings.lnurlNoFunding.meta": "Minten en melten niet beschikbaar",
|
||||
"mint.warnings.also.noFunding": "De Lightning-node was ook onbereikbaar, dus er kon niets gemint of gemelt worden."
|
||||
}
|
||||
|
||||
+122
-3
@@ -116,6 +116,8 @@
|
||||
"card.reviewed": "Recenzja {when}",
|
||||
"card.noReviews": "Brak jeszcze recenzji",
|
||||
"mint.title": "{name} · Recenzje Cashu Mint",
|
||||
"mint.titleFedimint": "{name} · Recenzje federacji Fedimint",
|
||||
"mint.titleLnurl": "{name} · Recenzje mintu LNURL",
|
||||
"mint.breadcrumb": "Okruszek chleba",
|
||||
"mint.crumbMints": "Mennice cashu",
|
||||
"mint.copyUrl": "Skopiuj URL Mint",
|
||||
@@ -351,7 +353,7 @@
|
||||
"reviews.validate.short": "Napisz przynajmniej {n} postaci, żeby recenzja była przydatna.",
|
||||
"reviews.validate.long": "Recenzje ograniczają się do {n} postaci.",
|
||||
"feed.title": "Najnowsze recenzje mennic i federacji · Cashumints.space",
|
||||
"feed.description": "Każda recenzja mennicy i federacji w miarę publikacji: {reviews} recenzje {mints} w mennicach Cashu i {federations} federacjach Fedimint, podpisane na Nostr i dostępne tutaj najnowsze. Filtruj według ekosystemu, oceny lub nazwy.",
|
||||
"feed.description": "Każda opinia o mincie, federacji i mincie LNURL zaraz po opublikowaniu: {reviews} opinii o {mints} mintach Cashu, {federations} federacjach Fedimint i {lnurl} mintach LNURL, podpisanych w Nostr i czytelnych tutaj od najnowszych. Filtruj po ekosystemie, ocenie lub nazwie.",
|
||||
"feed.heading": "Najnowsze recenzje",
|
||||
"feed.lede": "Każda recenzja każdej mennicy Cashu i federacji Fedimint, najnowsza, czytana na żywo z przekaźników Nostr. Każdy z nich jest podpisany przez autora i znajduje się na przekaźnikach, a nie w naszej bazie danych.",
|
||||
"feed.label": "Recenzje",
|
||||
@@ -428,7 +430,14 @@
|
||||
"notfound.fb.reviews": "Recenzje",
|
||||
"notfound.fb.software": "Oprogramowanie",
|
||||
"notfound.fb.lastOnline": "Ostatni czas online",
|
||||
"notfound.fb.note": "Ta mennica została zindeksowana po ostatnim uruchomieniu strony, więc to jest podsumowanie z aktywnego API. Pełna strona z recenzjami pojawia się przy kolejnej odbudowie.",
|
||||
"notfound.fb.note": "Ten mint został zaindeksowany po ostatnim zbudowaniu strony, więc to podsumowanie z działającego API, a poniżej są recenzje. Pełna strona pojawi się przy następnym budowaniu.",
|
||||
"notfound.resolve.checking": "Pierwsza wizyta: sprawdzam ten mint teraz.",
|
||||
"notfound.resolve.failed": "Nie udało się dodać tego mintu",
|
||||
"notfound.resolve.failedFedimint": "Nie udało się dodać tej federacji",
|
||||
"notfound.resolve.retry": "Spróbuj innego adresu",
|
||||
"notfound.resolve.switch": "Otwórz jako mint {kind}",
|
||||
"notfound.resolve.fedimint": "Adres strony federacji nie zawiera jej kodu zaproszenia, więc nie da się jej odnaleźć na podstawie samego linku. Wklej kod zaproszenia, a zostanie dodana.",
|
||||
"notfound.resolve.ambiguous": "Tego adresu nie da się odtworzyć z samego linku. Wklej pełny URL mintu, a zostanie dodany.",
|
||||
"wallets.title": "Portfele Cashu · Cashumints.space",
|
||||
"wallets.description": "Portfele cashu, które współpracują z wymienionymi tutaj monetami – na urządzeniach mobilnych, w internecie, przeglądarce i wierszu poleceń – z tym, do czego każdy z nich jest dobry.",
|
||||
"wallets.heading": "Portfele",
|
||||
@@ -696,5 +705,115 @@
|
||||
"footer.allFedimints": "Wszystkie federacje",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "Jak wyglądało bicie, topienie i wspieranie? Bądź konkretny.",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "Jak wyglądało dołączenie, płatności i wsparcie Lightning? Bądź konkretny.",
|
||||
"fedimint.details.title": "Szczegóły federacji"
|
||||
"fedimint.details.title": "Szczegóły federacji",
|
||||
"nav.lnurlMints": "Minty LNURL",
|
||||
"footer.allLnurlMints": "Wszystkie minty LNURL",
|
||||
"lnurlMints.title": "Minty LNURL: minty not na okaziciela lnurlcash z opiniami | Cashumints.space",
|
||||
"lnurlMints.description": "Wszystkie {total} mintów LNURL zindeksowanych z sieci Nostr: {online} online, {offline} offline. Oceny społeczności, limity wypłat i możliwości każdego z nich.",
|
||||
"lnurlMints.heading": "Minty LNURL",
|
||||
"lnurlMints.lede": "Każdy mint LNURL, o którym wie ta strona. Mint LNURL emituje noty na okaziciela lnurlcash: noty Lightning, które trzymasz jako sekret i które można obrócić, podzielić, połączyć albo przetopić z powrotem na płatność Lightning. Każdy jest sprawdzany przez HTTPS co kilka minut.",
|
||||
"lnurlMints.search.label": "Szukaj mintów LNURL",
|
||||
"lnurlMints.search.placeholder": "Szukaj po nazwie lub adresie…",
|
||||
"lnurlMints.sort.label": "Sortuj minty LNURL według",
|
||||
"lnurlMints.hideOffline": "Ukryj offline",
|
||||
"lnurlMints.showing": "Pokazano {total} mintów LNURL ({online} online, {offline} offline)",
|
||||
"lnurlMints.showingFiltered": "Pokazano {shown} z {total} mintów LNURL ({online} online, {offline} offline)",
|
||||
"lnurlMints.noResults": "Żaden mint LNURL nie pasuje.",
|
||||
"lnurlMints.emptyIndex": "Nie zindeksowano jeszcze żadnych mintów LNURL.",
|
||||
"lnurl.crumb": "Minty LNURL",
|
||||
"lnurl.breadcrumb": "Okruszki nawigacyjne",
|
||||
"lnurl.copyAddress": "Kopiuj adres",
|
||||
"lnurl.more.title": "Więcej mintów LNURL",
|
||||
"lnurl.more.all": "Wszystkie minty LNURL →",
|
||||
"lnurl.disclaimer": "Mint LNURL przechowuje twoje saty, a ty trzymasz jego noty, więc korzystanie z niego oznacza zaufanie operatorowi. Oceny to opinie społeczności, a online znaczy osiągalny, nie godny zaufania.",
|
||||
"lnurl.features.title": "Funkcje",
|
||||
"lnurl.features.none": "Ten mint nie ogłosił, co obsługuje.",
|
||||
"lnurl.features.note": "Funkcje to to, co operator minta ogłosił w Nostr. Tam, gdzie ta strona może sprawdzić coś przez HTTPS, robi to i informuje, gdy coś ogłoszonego jest w tej chwili niedostępne.",
|
||||
"lnurl.features.notListed": "nieogłoszone",
|
||||
"lnurl.features.unavailable": "niedostępne",
|
||||
"lnurl.feature.mint": "Mintowanie przez Lightning (LUD-06)",
|
||||
"lnurl.feature.melt": "Przetapianie na Lightning",
|
||||
"lnurl.feature.notes": "Obracanie / dzielenie / łączenie not",
|
||||
"lnurl.feature.lud16": "Adres Lightning",
|
||||
"lnurl.feature.lud21": "Weryfikacja płatności (LUD-21)",
|
||||
"lnurl.feature.signed-notes": "Podpisane noty (weryfikacja offline)",
|
||||
"lnurl.feature.onion": "Adres Tor",
|
||||
"lnurl.feature.rotate": "Obróć notę",
|
||||
"lnurl.feature.split": "Podziel notę",
|
||||
"lnurl.feature.merge": "Połącz noty",
|
||||
"lnurl.feature.lud06": "LUD-06 payRequest",
|
||||
"lnurl.feature.lud03": "LUD-03 withdrawRequest",
|
||||
"lnurl.limits.label": "Limity wypłat",
|
||||
"lnurl.limits.notPublished": "nieopublikowane",
|
||||
"lnurl.limits.withdrawRow": "Wypłata",
|
||||
"lnurl.limits.disabled": "wyłączone",
|
||||
"lnurl.limits.sats.one": "{n} sat",
|
||||
"lnurl.limits.sats.few": "{n} saty",
|
||||
"lnurl.limits.sats.many": "{n} satów",
|
||||
"lnurl.limits.sats.other": "{n} sata",
|
||||
"lnurl.details.title": "Szczegóły minta",
|
||||
"lnurl.details.address": "Adres Lightning",
|
||||
"lnurl.details.mintPubkey": "Klucz publiczny minta",
|
||||
"lnurl.details.onion": "Adres Tor",
|
||||
"lnurl.details.fee": "Opłata za mintowanie",
|
||||
"lnurl.details.node": "Węzeł",
|
||||
"lnurl.details.capacity": "Ogłoszona pojemność",
|
||||
"lnurl.kind.lightningAddress": "adres Lightning",
|
||||
"lnurl.kind.mintPubkey": "klucz publiczny minta",
|
||||
"lnurl.kind.onionAddress": "adres Tor",
|
||||
"lnurl.confirmed.label": "Ostatnie potwierdzenie",
|
||||
"lnurl.confirmed.when": "potwierdzony {when}",
|
||||
"lnurl.fee.base.one": "{n} sat",
|
||||
"lnurl.fee.base.few": "{n} saty",
|
||||
"lnurl.fee.base.many": "{n} satów",
|
||||
"lnurl.fee.base.other": "{n} sata",
|
||||
"lnurl.fee.percent": "{percent}%",
|
||||
"home.topLnurl.title": "Najlepsze minty LNURL",
|
||||
"home.topLnurl.sub": "według opinii i oceny",
|
||||
"home.topLnurl.all": "Wszystkie {n} mintów LNURL →",
|
||||
"feed.ecosystem.lnurl": "LNURL",
|
||||
"reviews.dialog.bodyPlaceholder.lnurl": "Jak poszło mintowanie, przetapianie i obracanie not? Napisz konkretnie.",
|
||||
"reviews.byUrl.open": "Napisz recenzję",
|
||||
"reviews.byUrl.title": "Napisz recenzję",
|
||||
"reviews.byUrl.lede": "Wklej adres. Jeśli ta strona jeszcze go nie zna, sprawdzi go i doda teraz.",
|
||||
"reviews.byUrl.label.cashu": "URL mintu",
|
||||
"reviews.byUrl.label.fedimint": "Kod zaproszenia federacji",
|
||||
"reviews.byUrl.label.lnurl": "URL mintu LNURL",
|
||||
"reviews.byUrl.placeholder.cashu": "mint.example.com",
|
||||
"reviews.byUrl.placeholder.fedimint": "fed11…",
|
||||
"reviews.byUrl.placeholder.lnurl": "mint.example.com",
|
||||
"reviews.byUrl.check": "Sprawdź ten adres",
|
||||
"reviews.byUrl.checking": "Sprawdzanie…",
|
||||
"reviews.byUrl.back": "Użyj innego adresu",
|
||||
"reviews.byUrl.existing": "Już na liście",
|
||||
"reviews.byUrl.indexed": "Właśnie dodany",
|
||||
"reviews.byUrl.confirmClose": "Zamknąć? Wpisany adres nie zostanie zachowany.",
|
||||
"reviews.byUrl.detected": "Ten adres odpowiedział jako mint {kind}.",
|
||||
"reviews.byUrl.switchTo": "Oceń go jako mint {kind}",
|
||||
"reviews.byUrl.error.empty": "Najpierw wpisz adres.",
|
||||
"reviews.byUrl.error.badUrl": "To nie wygląda na URL mintu.",
|
||||
"reviews.byUrl.error.badInvite": "To nie jest kod zaproszenia Fedimint. Kody zaczynają się od fed11.",
|
||||
"reviews.byUrl.error.badInput": "Ta strona nie może sprawdzić tego adresu.",
|
||||
"reviews.byUrl.error.blocked": "To nie jest adres publiczny, więc nie da się go sprawdzić.",
|
||||
"reviews.byUrl.error.notAMint": "Ten adres odpowiedział, ale nie jako mint tego rodzaju.",
|
||||
"reviews.byUrl.error.wrongType": "Ten adres odpowiedział, ale jako mint innego rodzaju.",
|
||||
"reviews.byUrl.error.unverifiable": "Pod tym adresem nic nie odpowiedziało, a na Nostr nie ma po nim jeszcze śladu.",
|
||||
"reviews.byUrl.error.rateLimited.one": "To dużo sprawdzeń z jednego połączenia. Spróbuj ponownie za około {n} minutę.",
|
||||
"reviews.byUrl.error.rateLimited.other": "To dużo sprawdzeń z jednego połączenia. Spróbuj ponownie za około {n} minut.",
|
||||
"reviews.byUrl.error.network": "Nie udało się połączyć z indeksem. Sprawdź połączenie i spróbuj ponownie.",
|
||||
"seo.lnurl.titleNoReviews": "{name}: informacje i opinie o mincie LNURL | Cashumints.space",
|
||||
"seo.desc.statusWithdraw": "{status}, wypłata {min}–{max} sat.",
|
||||
"mint.chip.noFunding": "Brak mintowania / przetapiania",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.lead": "Wypłaty wyłączone.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.body.online": "Ten mint ogłasza maksymalną wypłatę równą zeru, więc żadnej wyemitowanej przez niego noty nie da się na nic wymienić. Nie mintuj tutaj, dopóki to się nie zmieni.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.body.offline": "Przy ostatnim kontakcie ten mint ogłaszał maksymalną wypłatę równą zeru, więc żadnej wyemitowanej przez niego noty nie dało się na nic wymienić. Nie mintuj tutaj, dopóki to się nie zmieni.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.online": "Wypłaty obecnie wyłączone",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.offline": "Przy ostatnim kontakcie wypłaty były wyłączone",
|
||||
"mint.warnings.lnurlInvalid.lead": "Endpoint odpowiada, ale nieprawidłowo.",
|
||||
"mint.warnings.lnurlInvalid.body": "Host odpowiada, ale nie ogłoszeniem minta, które ta strona potrafi odczytać, więc wypłaty mogą nie działać. Poniżej jest ostatni stan, który udało się odczytać, a opinie nadal działają.",
|
||||
"mint.warnings.lnurlInvalid.meta": "Odpowiada, ale nie prawidłowym ogłoszeniem minta",
|
||||
"mint.warnings.lnurlNoFunding.lead": "Mintowanie i przetapianie niedostępne.",
|
||||
"mint.warnings.lnurlNoFunding.body": "Istniejące noty nadal można obracać, dzielić lub łączyć, ale w tej chwili nic nie wchodzi ani nie wychodzi przez Lightning. Sam mint działa i odpowiada; węzeł Lightning za nim jest z niego nieosiągalny.",
|
||||
"mint.warnings.lnurlNoFunding.meta": "Mintowanie i przetapianie niedostępne",
|
||||
"mint.warnings.also.noFunding": "Jego węzeł Lightning też był nieosiągalny, więc nie dało się nic zmintować ani przetopić."
|
||||
}
|
||||
|
||||
+118
-3
@@ -114,6 +114,8 @@
|
||||
"card.reviewed": "revisado {when}",
|
||||
"card.noReviews": "ainda não há comentários",
|
||||
"mint.title": "{name} · Cashu mint comentários",
|
||||
"mint.titleFedimint": "{name} · Avaliações da federação Fedimint",
|
||||
"mint.titleLnurl": "{name} · Avaliações do mint LNURL",
|
||||
"mint.breadcrumb": "Pão ralado",
|
||||
"mint.crumbMints": "Cashu mints",
|
||||
"mint.copyUrl": "Copiar URL mint",
|
||||
@@ -325,7 +327,7 @@
|
||||
"reviews.validate.short": "Escreva pelo menos caracteres {n} para que a revisão seja útil.",
|
||||
"reviews.validate.long": "As resenhas são limitadas a caracteres {n}.",
|
||||
"feed.title": "Últimas análises de mint e da federação · Cashumints.space",
|
||||
"feed.description": "Cada análise de mint e de federação conforme publicada: análises de {reviews} nas federações {mints} Cashu mints e {federations} Fedimint, assinadas em Nostr e legíveis aqui, as mais recentes primeiro. Filtre por ecossistema, por classificação ou por nome.",
|
||||
"feed.description": "Cada avaliação de mint, federação e mint LNURL assim que é publicada: {reviews} avaliações sobre {mints} mints Cashu, {federations} federações Fedimint e {lnurl} mints LNURL, assinadas no Nostr e legíveis aqui da mais recente para a mais antiga. Filtre por ecossistema, por nota ou por nome.",
|
||||
"feed.heading": "Últimas avaliações",
|
||||
"feed.lede": "Cada revisão de cada federação Cashu mint e Fedimint, as mais recentes primeiro, lida ao vivo nos relés Nostr. Cada um é assinado por seu autor e reside nos relés, não em nosso banco de dados.",
|
||||
"feed.label": "Avaliações",
|
||||
@@ -400,7 +402,14 @@
|
||||
"notfound.fb.reviews": "Avaliações",
|
||||
"notfound.fb.software": "Programas",
|
||||
"notfound.fb.lastOnline": "Última vez on-line",
|
||||
"notfound.fb.note": "Este mint foi indexado após a última construção do site, portanto, este é um resumo da API ativa. A página completa com comentários aparece na próxima reconstrução.",
|
||||
"notfound.fb.note": "Este mint foi indexado depois da última construção do site, por isso este é um resumo da API em direto, com as avaliações abaixo. A página completa aparece na próxima construção.",
|
||||
"notfound.resolve.checking": "Primeira visita: a verificar este mint agora.",
|
||||
"notfound.resolve.failed": "Não foi possível adicionar este mint",
|
||||
"notfound.resolve.failedFedimint": "Não foi possível adicionar esta federação",
|
||||
"notfound.resolve.retry": "Tentar outro endereço",
|
||||
"notfound.resolve.switch": "Abri-lo como mint {kind}",
|
||||
"notfound.resolve.fedimint": "O endereço da página de uma federação não contém o seu código de convite, por isso esta não pode ser encontrada só com o link. Cola o código de convite e ela é adicionada.",
|
||||
"notfound.resolve.ambiguous": "Este endereço não pode ser deduzido só a partir do link. Cola o URL completo do mint e ele é adicionado.",
|
||||
"wallets.title": "Carteiras Cashu · Cashumints.space",
|
||||
"wallets.description": "Carteiras Cashu que funcionam com o mints listado aqui, em dispositivos móveis, web, navegador e linha de comando, com o que cada uma é boa.",
|
||||
"wallets.heading": "Carteiras",
|
||||
@@ -654,5 +663,111 @@
|
||||
"footer.allFedimints": "Todas as federações",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "Como funcionou minting, melting e suporte? Seja específico.",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "Como funcionou a adesão, os pagamentos e o suporte do Lightning? Seja específico.",
|
||||
"fedimint.details.title": "Detalhes da federação"
|
||||
"fedimint.details.title": "Detalhes da federação",
|
||||
"nav.lnurlMints": "Mints LNURL",
|
||||
"footer.allLnurlMints": "Todos os mints LNURL",
|
||||
"lnurlMints.title": "Mints LNURL: mints de notas ao portador lnurlcash, avaliados | Cashumints.space",
|
||||
"lnurlMints.description": "Todos os {total} mints LNURL indexados da rede Nostr: {online} on-line, {offline} off-line. Avaliações da comunidade, limites de saque e recursos de cada um.",
|
||||
"lnurlMints.heading": "Mints LNURL",
|
||||
"lnurlMints.lede": "Todos os mints LNURL que este site conhece. Um mint LNURL emite notas ao portador lnurlcash: notas Lightning que você guarda como um segredo e que podem ser rotacionadas, divididas, mescladas ou derretidas de volta em um pagamento Lightning. Cada um é verificado por HTTPS a cada poucos minutos.",
|
||||
"lnurlMints.search.label": "Pesquisar mints LNURL",
|
||||
"lnurlMints.search.placeholder": "Pesquisar por nome ou endereço…",
|
||||
"lnurlMints.sort.label": "Ordenar mints LNURL por",
|
||||
"lnurlMints.hideOffline": "Ocultar off-line",
|
||||
"lnurlMints.showing": "Mostrando {total} mints LNURL ({online} on-line, {offline} off-line)",
|
||||
"lnurlMints.showingFiltered": "Mostrando {shown} de {total} mints LNURL ({online} on-line, {offline} off-line)",
|
||||
"lnurlMints.noResults": "Nenhum mint LNURL corresponde a isso.",
|
||||
"lnurlMints.emptyIndex": "Ainda não há mints LNURL indexados.",
|
||||
"lnurl.crumb": "Mints LNURL",
|
||||
"lnurl.breadcrumb": "Trilha de navegação",
|
||||
"lnurl.copyAddress": "Copiar endereço",
|
||||
"lnurl.more.title": "Mais mints LNURL",
|
||||
"lnurl.more.all": "Todos os mints LNURL →",
|
||||
"lnurl.disclaimer": "Um mint LNURL guarda seus sats enquanto você guarda as notas dele, então usá-lo significa confiar em seu operador. As avaliações são opiniões da comunidade, e on-line significa acessível, não confiável.",
|
||||
"lnurl.features.title": "Recursos",
|
||||
"lnurl.features.none": "Este mint não anunciou o que oferece.",
|
||||
"lnurl.features.note": "Os recursos são o que o operador do mint anunciou no Nostr. Quando este site consegue verificar algum por HTTPS, ele o faz, e avisa quando algo anunciado não está disponível no momento.",
|
||||
"lnurl.features.notListed": "não anunciado",
|
||||
"lnurl.features.unavailable": "indisponível",
|
||||
"lnurl.feature.mint": "Minting via Lightning (LUD-06)",
|
||||
"lnurl.feature.melt": "Melting para Lightning",
|
||||
"lnurl.feature.notes": "Rotacionar / dividir / mesclar notas",
|
||||
"lnurl.feature.lud16": "Endereço Lightning",
|
||||
"lnurl.feature.lud21": "Verificação de pagamento (LUD-21)",
|
||||
"lnurl.feature.signed-notes": "Notas assinadas (verificação off-line)",
|
||||
"lnurl.feature.onion": "Endereço Tor",
|
||||
"lnurl.feature.rotate": "Rotacionar uma nota",
|
||||
"lnurl.feature.split": "Dividir uma nota",
|
||||
"lnurl.feature.merge": "Mesclar notas",
|
||||
"lnurl.feature.lud06": "payRequest LUD-06",
|
||||
"lnurl.feature.lud03": "withdrawRequest LUD-03",
|
||||
"lnurl.limits.label": "Limites de saque",
|
||||
"lnurl.limits.notPublished": "não publicado",
|
||||
"lnurl.limits.withdrawRow": "Saque",
|
||||
"lnurl.limits.disabled": "desativado",
|
||||
"lnurl.limits.sats.one": "{n} sat",
|
||||
"lnurl.limits.sats.other": "{n} sats",
|
||||
"lnurl.details.title": "Detalhes do mint",
|
||||
"lnurl.details.address": "Endereço Lightning",
|
||||
"lnurl.details.mintPubkey": "Chave pública do mint",
|
||||
"lnurl.details.onion": "Endereço Tor",
|
||||
"lnurl.details.fee": "Taxa de minting",
|
||||
"lnurl.details.node": "Nó",
|
||||
"lnurl.details.capacity": "Capacidade anunciada",
|
||||
"lnurl.kind.lightningAddress": "endereço Lightning",
|
||||
"lnurl.kind.mintPubkey": "chave pública do mint",
|
||||
"lnurl.kind.onionAddress": "endereço Tor",
|
||||
"lnurl.confirmed.label": "Última confirmação",
|
||||
"lnurl.confirmed.when": "confirmado {when}",
|
||||
"lnurl.fee.base.one": "{n} sat",
|
||||
"lnurl.fee.base.other": "{n} sats",
|
||||
"lnurl.fee.percent": "{percent}%",
|
||||
"home.topLnurl.title": "Melhores mints LNURL",
|
||||
"home.topLnurl.sub": "classificados por avaliações e nota",
|
||||
"home.topLnurl.all": "Todos os {n} mints LNURL →",
|
||||
"feed.ecosystem.lnurl": "LNURL",
|
||||
"reviews.dialog.bodyPlaceholder.lnurl": "Como foram o minting, o melting e a rotação de notas? Seja específico.",
|
||||
"reviews.byUrl.open": "Escrever uma avaliação",
|
||||
"reviews.byUrl.title": "Escrever uma avaliação",
|
||||
"reviews.byUrl.lede": "Cola o endereço. Se este site ainda não o conhece, ele é verificado e adicionado agora.",
|
||||
"reviews.byUrl.label.cashu": "URL do mint",
|
||||
"reviews.byUrl.label.fedimint": "Código de convite da federação",
|
||||
"reviews.byUrl.label.lnurl": "URL do mint LNURL",
|
||||
"reviews.byUrl.placeholder.cashu": "mint.example.com",
|
||||
"reviews.byUrl.placeholder.fedimint": "fed11…",
|
||||
"reviews.byUrl.placeholder.lnurl": "mint.example.com",
|
||||
"reviews.byUrl.check": "Verificar este endereço",
|
||||
"reviews.byUrl.checking": "A verificar…",
|
||||
"reviews.byUrl.back": "Usar outro endereço",
|
||||
"reviews.byUrl.existing": "Já está listado",
|
||||
"reviews.byUrl.indexed": "Acabado de adicionar",
|
||||
"reviews.byUrl.confirmClose": "Fechar? O endereço que escreveste não é guardado.",
|
||||
"reviews.byUrl.detected": "Esse endereço respondeu como um mint {kind}.",
|
||||
"reviews.byUrl.switchTo": "Avaliá-lo como mint {kind}",
|
||||
"reviews.byUrl.error.empty": "Escreve primeiro um endereço.",
|
||||
"reviews.byUrl.error.badUrl": "Isso não parece o URL de um mint.",
|
||||
"reviews.byUrl.error.badInvite": "Isso não é um código de convite Fedimint. Os códigos começam por fed11.",
|
||||
"reviews.byUrl.error.badInput": "Este site não consegue verificar esse endereço.",
|
||||
"reviews.byUrl.error.blocked": "Esse não é um endereço público, por isso não pode ser verificado.",
|
||||
"reviews.byUrl.error.notAMint": "Esse endereço respondeu, mas não como um mint deste tipo.",
|
||||
"reviews.byUrl.error.wrongType": "Esse endereço respondeu, mas como um mint de outro tipo.",
|
||||
"reviews.byUrl.error.unverifiable": "Nada respondeu nesse endereço, e ainda não há qualquer rasto dele no Nostr.",
|
||||
"reviews.byUrl.error.rateLimited.one": "São muitas verificações a partir de uma só ligação. Tenta de novo daqui a cerca de {n} minuto.",
|
||||
"reviews.byUrl.error.rateLimited.other": "São muitas verificações a partir de uma só ligação. Tenta de novo daqui a cerca de {n} minutos.",
|
||||
"reviews.byUrl.error.network": "Não foi possível contactar o índice. Verifica a tua ligação e tenta de novo.",
|
||||
"seo.lnurl.titleNoReviews": "{name}: informações e avaliações do mint LNURL | Cashumints.space",
|
||||
"seo.desc.statusWithdraw": "{status}, saque de {min}–{max} sat.",
|
||||
"mint.chip.noFunding": "Sem mint / melt",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.lead": "Saques desativados.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.body.online": "Este mint anuncia um saque máximo de zero, então nenhuma nota que ele emite pode ser resgatada por nada. Não faça minting aqui até que isso mude.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.body.offline": "Este mint anunciava um saque máximo de zero no último contato, então nenhuma nota que ele emitia podia ser resgatada por nada. Não faça minting aqui até que isso mude.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.online": "Saques desativados no momento",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.offline": "Os saques estavam desativados no último contato",
|
||||
"mint.warnings.lnurlInvalid.lead": "O endpoint responde, mas é inválido.",
|
||||
"mint.warnings.lnurlInvalid.body": "O host responde, mas não com um anúncio de mint que este site consiga ler, então os saques podem não funcionar. O que está abaixo é o último estado legível, e as avaliações continuam funcionando.",
|
||||
"mint.warnings.lnurlInvalid.meta": "Responde, mas não com um anúncio de mint válido",
|
||||
"mint.warnings.lnurlNoFunding.lead": "Minting e melting indisponíveis.",
|
||||
"mint.warnings.lnurlNoFunding.body": "As notas existentes ainda podem ser rotacionadas, divididas ou mescladas, mas nada entra nem sai via Lightning no momento. O mint em si está no ar e respondendo; o nó Lightning por trás dele não está acessível a partir dali.",
|
||||
"mint.warnings.lnurlNoFunding.meta": "Minting e melting indisponíveis",
|
||||
"mint.warnings.also.noFunding": "O nó Lightning também estava inacessível, então nada podia ser mintado ou derretido."
|
||||
}
|
||||
|
||||
+120
-3
@@ -115,6 +115,8 @@
|
||||
"card.reviewed": "revăzut{when}",
|
||||
"card.noReviews": "nu există comentarii încă",
|
||||
"mint.title": "{name}· Cashu monetărie recenzii",
|
||||
"mint.titleFedimint": "{name} · Recenzii ale federației Fedimint",
|
||||
"mint.titleLnurl": "{name} · Recenzii ale mintului LNURL",
|
||||
"mint.breadcrumb": "Cremă de pâine",
|
||||
"mint.crumbMints": "Caju muntetării",
|
||||
"mint.copyUrl": "Copiază URL- ul monetărie",
|
||||
@@ -338,7 +340,7 @@
|
||||
"reviews.validate.short": "Scrie cel puţin{n} caractere, astfel încât revizuirea este utilă.",
|
||||
"reviews.validate.long": "Revizuirile sunt limitate la{n}Personaje.",
|
||||
"feed.title": "Ultimele reviste monetărie și federație · Casumints.space",
|
||||
"feed.description": "Fiecare recenzie a unei monetării sau federații, pe măsură ce este publicată: {reviews} recenzii pentru {mints} monetării Cashu și {federations} federații Fedimint, semnate pe Nostr și afișate aici de la cea mai nouă. Filtrează după ecosistem, evaluare sau nume.",
|
||||
"feed.description": "Fiecare recenzie de mint, federație și mint LNURL pe măsură ce este publicată: {reviews} recenzii pentru {mints} mint-uri Cashu, {federations} federații Fedimint și {lnurl} mint-uri LNURL, semnate pe Nostr și de citit aici de la cele mai noi. Filtrează după ecosistem, evaluare sau nume.",
|
||||
"feed.heading": "Ultimele recenzii",
|
||||
"feed.lede": "Fiecare recenzie a fiecărei federații Cashu monetărie și Fedimint, cea mai nouă primă, citește în direct de la releele Nostr. Fiecare este semnată de autorul său și trăiește pe relee, nu în baza noastră de date.",
|
||||
"feed.label": "Revizuiri",
|
||||
@@ -414,7 +416,14 @@
|
||||
"notfound.fb.reviews": "Revizuiri",
|
||||
"notfound.fb.software": "Programe",
|
||||
"notfound.fb.lastOnline": "Ultima dată online",
|
||||
"notfound.fb.note": "Această monetărie a fost indexată după ce site-ul a fost construit ultima dată, astfel încât acesta este un rezumat al API live. Pagina completă cu recenzii apare la următoarea reconstrucție.",
|
||||
"notfound.fb.note": "Acest mint a fost indexat după ultima construire a site-ului, deci acesta e un rezumat din API-ul live, cu recenziile dedesubt. Pagina completă apare la următoarea construire.",
|
||||
"notfound.resolve.checking": "Prima vizită: verific acest mint acum.",
|
||||
"notfound.resolve.failed": "Acest mint nu a putut fi adăugat",
|
||||
"notfound.resolve.failedFedimint": "Această federație nu a putut fi adăugată",
|
||||
"notfound.resolve.retry": "Încearcă altă adresă",
|
||||
"notfound.resolve.switch": "Deschide-l ca mint {kind}",
|
||||
"notfound.resolve.fedimint": "Adresa paginii unei federații nu conține codul ei de invitație, așa că aceasta nu poate fi găsită doar din link. Lipește codul de invitație și va fi adăugată.",
|
||||
"notfound.resolve.ambiguous": "Această adresă nu poate fi dedusă doar din link. Lipește URL-ul complet al mintului și va fi adăugat.",
|
||||
"wallets.title": "Portofele caju · Casumints.space",
|
||||
"wallets.description": "Portofelele Cashu care lucrează cu monetării enumerate aici, prin telefon mobil, web, browser și linia de comandă, cu ceea ce fiecare este bun la.",
|
||||
"wallets.heading": "Portofele",
|
||||
@@ -675,5 +684,113 @@
|
||||
"footer.allFedimints": "Toate federațiile",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "Cum a funcţionat menţinerea, topirea şi susţinerea?",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "Cum s-a realizat aderarea, plăţile pentru fulger şi sprijinul acordat?",
|
||||
"fedimint.details.title": "Detaliile Federaţiei"
|
||||
"fedimint.details.title": "Detaliile Federaţiei",
|
||||
"nav.lnurlMints": "Mint-uri LNURL",
|
||||
"footer.allLnurlMints": "Toate mint-urile LNURL",
|
||||
"lnurlMints.title": "Mint-uri LNURL: mint-uri de bilete la purtător lnurlcash, cu recenzii | Cashumints.space",
|
||||
"lnurlMints.description": "Toate cele {total} mint-uri LNURL indexate din rețeaua Nostr: {online} online, {offline} offline. Evaluări ale comunității, limite de retragere și funcții pentru fiecare.",
|
||||
"lnurlMints.heading": "Mint-uri LNURL",
|
||||
"lnurlMints.lede": "Fiecare mint LNURL pe care îl cunoaște acest site. Un mint LNURL emite bilete la purtător lnurlcash: bilete Lightning pe care le păstrezi ca pe un secret și care pot fi rotite, împărțite, unite sau topite înapoi într-o plată Lightning. Fiecare este verificat prin HTTPS la câteva minute.",
|
||||
"lnurlMints.search.label": "Caută mint-uri LNURL",
|
||||
"lnurlMints.search.placeholder": "Caută după nume sau adresă…",
|
||||
"lnurlMints.sort.label": "Sortează mint-urile LNURL după",
|
||||
"lnurlMints.hideOffline": "Ascunde offline",
|
||||
"lnurlMints.showing": "Se afișează {total} mint-uri LNURL ({online} online, {offline} offline)",
|
||||
"lnurlMints.showingFiltered": "Se afișează {shown} din {total} mint-uri LNURL ({online} online, {offline} offline)",
|
||||
"lnurlMints.noResults": "Niciun mint LNURL nu corespunde.",
|
||||
"lnurlMints.emptyIndex": "Niciun mint LNURL nu este indexat încă.",
|
||||
"lnurl.crumb": "Mint-uri LNURL",
|
||||
"lnurl.breadcrumb": "Firimituri de navigare",
|
||||
"lnurl.copyAddress": "Copiază adresa",
|
||||
"lnurl.more.title": "Mai multe mint-uri LNURL",
|
||||
"lnurl.more.all": "Toate mint-urile LNURL →",
|
||||
"lnurl.disclaimer": "Un mint LNURL îți păstrează sat-ii cât timp tu îi păstrezi biletele, deci folosirea lui înseamnă să ai încredere în operatorul său. Evaluările sunt păreri ale comunității, iar online înseamnă accesibil, nu demn de încredere.",
|
||||
"lnurl.features.title": "Funcții",
|
||||
"lnurl.features.none": "Acest mint nu a anunțat ce acceptă.",
|
||||
"lnurl.features.note": "Funcțiile sunt ceea ce operatorul mint-ului a anunțat pe Nostr. Acolo unde acest site poate verifica una prin HTTPS, o face, și semnalează când ceva anunțat nu este disponibil chiar acum.",
|
||||
"lnurl.features.notListed": "neanunțat",
|
||||
"lnurl.features.unavailable": "indisponibil",
|
||||
"lnurl.feature.mint": "Mint prin Lightning (LUD-06)",
|
||||
"lnurl.feature.melt": "Topire către Lightning",
|
||||
"lnurl.feature.notes": "Rotire / împărțire / unire bilete",
|
||||
"lnurl.feature.lud16": "Adresă Lightning",
|
||||
"lnurl.feature.lud21": "Verificarea plății (LUD-21)",
|
||||
"lnurl.feature.signed-notes": "Bilete semnate (verificare offline)",
|
||||
"lnurl.feature.onion": "Adresă Tor",
|
||||
"lnurl.feature.rotate": "Rotește un bilet",
|
||||
"lnurl.feature.split": "Împarte un bilet",
|
||||
"lnurl.feature.merge": "Unește bilete",
|
||||
"lnurl.feature.lud06": "LUD-06 payRequest",
|
||||
"lnurl.feature.lud03": "LUD-03 withdrawRequest",
|
||||
"lnurl.limits.label": "Limite de retragere",
|
||||
"lnurl.limits.notPublished": "nepublicat",
|
||||
"lnurl.limits.withdrawRow": "Retragere",
|
||||
"lnurl.limits.disabled": "dezactivat",
|
||||
"lnurl.limits.sats.one": "{n} sat",
|
||||
"lnurl.limits.sats.few": "{n} sați",
|
||||
"lnurl.limits.sats.other": "{n} de sați",
|
||||
"lnurl.details.title": "Detalii mint",
|
||||
"lnurl.details.address": "Adresă Lightning",
|
||||
"lnurl.details.mintPubkey": "Cheia publică a mint-ului",
|
||||
"lnurl.details.onion": "Adresă Tor",
|
||||
"lnurl.details.fee": "Comision de mint",
|
||||
"lnurl.details.node": "Nod",
|
||||
"lnurl.details.capacity": "Capacitate anunțată",
|
||||
"lnurl.kind.lightningAddress": "adresă Lightning",
|
||||
"lnurl.kind.mintPubkey": "cheia publică a mint-ului",
|
||||
"lnurl.kind.onionAddress": "adresă Tor",
|
||||
"lnurl.confirmed.label": "Ultima confirmare",
|
||||
"lnurl.confirmed.when": "confirmat {when}",
|
||||
"lnurl.fee.base.one": "{n} sat",
|
||||
"lnurl.fee.base.few": "{n} sați",
|
||||
"lnurl.fee.base.other": "{n} de sați",
|
||||
"lnurl.fee.percent": "{percent}%",
|
||||
"home.topLnurl.title": "Top mint-uri LNURL",
|
||||
"home.topLnurl.sub": "clasate după recenzii și evaluare",
|
||||
"home.topLnurl.all": "Toate cele {n} mint-uri LNURL →",
|
||||
"feed.ecosystem.lnurl": "LNURL",
|
||||
"reviews.dialog.bodyPlaceholder.lnurl": "Cum au mers mint-ul, topirea și rotirea biletelor? Fii concret.",
|
||||
"reviews.byUrl.open": "Scrie o recenzie",
|
||||
"reviews.byUrl.title": "Scrie o recenzie",
|
||||
"reviews.byUrl.lede": "Lipește adresa. Dacă site-ul nu o cunoaște încă, o verifică și o adaugă acum.",
|
||||
"reviews.byUrl.label.cashu": "URL-ul mintului",
|
||||
"reviews.byUrl.label.fedimint": "Codul de invitație al federației",
|
||||
"reviews.byUrl.label.lnurl": "URL-ul mintului LNURL",
|
||||
"reviews.byUrl.placeholder.cashu": "mint.example.com",
|
||||
"reviews.byUrl.placeholder.fedimint": "fed11…",
|
||||
"reviews.byUrl.placeholder.lnurl": "mint.example.com",
|
||||
"reviews.byUrl.check": "Verifică această adresă",
|
||||
"reviews.byUrl.checking": "Se verifică…",
|
||||
"reviews.byUrl.back": "Folosește altă adresă",
|
||||
"reviews.byUrl.existing": "Deja în listă",
|
||||
"reviews.byUrl.indexed": "Tocmai adăugat",
|
||||
"reviews.byUrl.confirmClose": "Închizi? Adresa scrisă nu se păstrează.",
|
||||
"reviews.byUrl.detected": "Acea adresă a răspuns ca un mint {kind}.",
|
||||
"reviews.byUrl.switchTo": "Recenzează-l ca mint {kind}",
|
||||
"reviews.byUrl.error.empty": "Scrie mai întâi o adresă.",
|
||||
"reviews.byUrl.error.badUrl": "Asta nu pare un URL de mint.",
|
||||
"reviews.byUrl.error.badInvite": "Acesta nu e un cod de invitație Fedimint. Codurile încep cu fed11.",
|
||||
"reviews.byUrl.error.badInput": "Site-ul nu poate verifica acea adresă.",
|
||||
"reviews.byUrl.error.blocked": "Nu e o adresă publică, deci nu poate fi verificată.",
|
||||
"reviews.byUrl.error.notAMint": "Acea adresă a răspuns, dar nu ca un mint de acest fel.",
|
||||
"reviews.byUrl.error.wrongType": "Acea adresă a răspuns, dar ca un alt fel de mint.",
|
||||
"reviews.byUrl.error.unverifiable": "Nimic nu a răspuns la acea adresă și nu există încă nicio urmă a ei pe Nostr.",
|
||||
"reviews.byUrl.error.rateLimited.one": "Sunt multe verificări de pe o singură conexiune. Încearcă din nou în aproximativ {n} minut.",
|
||||
"reviews.byUrl.error.rateLimited.other": "Sunt multe verificări de pe o singură conexiune. Încearcă din nou în aproximativ {n} de minute.",
|
||||
"reviews.byUrl.error.network": "Indexul nu a putut fi contactat. Verifică-ți conexiunea și încearcă din nou.",
|
||||
"seo.lnurl.titleNoReviews": "{name}: informații și recenzii despre mint-ul LNURL | Cashumints.space",
|
||||
"seo.desc.statusWithdraw": "{status}, retragere {min}–{max} sat.",
|
||||
"mint.chip.noFunding": "Fără mint / topire",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.lead": "Retrageri dezactivate.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.body.online": "Acest mint anunță o retragere maximă de zero, deci niciun bilet emis de el nu poate fi schimbat pe nimic. Nu face mint aici până nu se schimbă asta.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.body.offline": "La ultimul contact, acest mint anunța o retragere maximă de zero, deci niciun bilet emis de el nu putea fi schimbat pe nimic. Nu face mint aici până nu se schimbă asta.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.online": "Retrageri dezactivate în prezent",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.offline": "Retragerile erau dezactivate la ultimul contact",
|
||||
"mint.warnings.lnurlInvalid.lead": "Endpoint-ul răspunde, dar este invalid.",
|
||||
"mint.warnings.lnurlInvalid.body": "Gazda răspunde, dar nu cu un anunț de mint pe care acest site îl poate citi, așa că retragerile s-ar putea să nu funcționeze. Mai jos este ultima stare care a putut fi citită, iar recenziile funcționează în continuare.",
|
||||
"mint.warnings.lnurlInvalid.meta": "Răspunde, dar nu cu un anunț de mint valid",
|
||||
"mint.warnings.lnurlNoFunding.lead": "Mint și topire indisponibile.",
|
||||
"mint.warnings.lnurlNoFunding.body": "Biletele existente pot fi în continuare rotite, împărțite sau unite, dar chiar acum nimic nu intră și nu iese prin Lightning. Mint-ul în sine funcționează și răspunde; nodul Lightning din spatele lui nu îi este accesibil.",
|
||||
"mint.warnings.lnurlNoFunding.meta": "Mint și topire indisponibile",
|
||||
"mint.warnings.also.noFunding": "Și nodul său Lightning era inaccesibil, deci nu se putea face mint sau topire."
|
||||
}
|
||||
|
||||
+128
-9
@@ -15,12 +15,12 @@
|
||||
"time.justNow": "только что",
|
||||
"time.none": "-",
|
||||
"time.unknownDate": "неизвестная дата",
|
||||
"time.ago.minute": "{n}m назад",
|
||||
"time.ago.hour": "{n}h назад",
|
||||
"time.ago.day": "{n}d назад",
|
||||
"time.ago.week": "{n}w назад",
|
||||
"time.ago.month": "{n}mo назад",
|
||||
"time.ago.year": "{n}y назад",
|
||||
"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",
|
||||
@@ -116,6 +116,8 @@
|
||||
"card.reviewed": "отзыв оставлен {when}",
|
||||
"card.noReviews": "пока нет отзывов",
|
||||
"mint.title": "{name} · Отзывы о минте Cashu",
|
||||
"mint.titleFedimint": "{name} · Отзывы о федерации Fedimint",
|
||||
"mint.titleLnurl": "{name} · Отзывы о LNURL-минте",
|
||||
"mint.breadcrumb": "Навигационная цепочка",
|
||||
"mint.crumbMints": "Минты Cashu",
|
||||
"mint.copyUrl": "Скопировать URL минта",
|
||||
@@ -351,7 +353,7 @@
|
||||
"reviews.validate.short": "Напишите не менее {n} символов, чтобы отзыв был полезен.",
|
||||
"reviews.validate.long": "Длина отзыва ограничена {n} символами.",
|
||||
"feed.title": "Последние отзывы о минтах и федерациях · Cashumints.space",
|
||||
"feed.description": "Все отзывы о минтах и федерациях по мере публикации: {reviews} отзывов о {mints} минтах Cashu и {federations} федерациях Fedimint. Они подписаны в Nostr и показаны от новых к старым. Фильтруйте по экосистеме, рейтингу или названию.",
|
||||
"feed.description": "Каждый отзыв о минте, федерации и LNURL-минте сразу после публикации: {reviews} отзывов о {mints} минтах Cashu, {federations} федерациях Fedimint и {lnurl} LNURL-минтах, подписанных в Nostr и доступных здесь начиная с самых новых. Фильтруйте по экосистеме, оценке или названию.",
|
||||
"feed.heading": "Последние отзывы",
|
||||
"feed.lede": "Все отзывы о минтах Cashu и федерациях Fedimint, от новых к старым, напрямую с Nostr relays. Каждый отзыв подписан автором и хранится на relays, а не в нашей базе данных.",
|
||||
"feed.label": "Отзывы",
|
||||
@@ -428,7 +430,14 @@
|
||||
"notfound.fb.reviews": "Отзывы",
|
||||
"notfound.fb.software": "Программное обеспечение",
|
||||
"notfound.fb.lastOnline": "Последний раз онлайн",
|
||||
"notfound.fb.note": "Эта запись была проиндексирована после последнего обновления сайта, поэтому это сводка данных из активного API. Полная страница с отзывами появится после следующего обновления сайта.",
|
||||
"notfound.fb.note": "Этот минт был проиндексирован после последней сборки сайта, поэтому здесь сводка из живого API, а ниже его отзывы. Полная страница появится при следующей сборке.",
|
||||
"notfound.resolve.checking": "Первое посещение: проверяем этот минт.",
|
||||
"notfound.resolve.failed": "Не удалось добавить этот минт",
|
||||
"notfound.resolve.failedFedimint": "Не удалось добавить эту федерацию",
|
||||
"notfound.resolve.retry": "Попробовать другой адрес",
|
||||
"notfound.resolve.switch": "Открыть как минт {kind}",
|
||||
"notfound.resolve.fedimint": "Адрес страницы федерации не содержит её код приглашения, поэтому найти её по одной ссылке нельзя. Вставь код приглашения, и она будет добавлена.",
|
||||
"notfound.resolve.ambiguous": "Этот адрес нельзя восстановить по одной ссылке. Вставь полный URL минта, и он будет добавлен.",
|
||||
"wallets.title": "Кошельки Cashu · Cashumints.space",
|
||||
"wallets.description": "Кошельки Cashu для перечисленных здесь минтов: мобильные, веб-кошельки, расширения и CLI, с описанием сильных сторон каждого.",
|
||||
"wallets.heading": "Кошельки",
|
||||
@@ -696,5 +705,115 @@
|
||||
"footer.allFedimints": "Все федерации",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "Как работали mint, melt и поддержка? Опишите конкретно.",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "Как прошло подключение к системе, работали ли платежи Lightning и поддержка? Расскажите подробнее.",
|
||||
"fedimint.details.title": "Сведения о федерации"
|
||||
"fedimint.details.title": "Сведения о федерации",
|
||||
"nav.lnurlMints": "LNURL-минты",
|
||||
"footer.allLnurlMints": "Все LNURL-минты",
|
||||
"lnurlMints.title": "LNURL-минты: минты предъявительских нот lnurlcash с отзывами | Cashumints.space",
|
||||
"lnurlMints.description": "Все {total} LNURL-минтов, проиндексированных из сети 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.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.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": "Все {n} LNURL-минтов →",
|
||||
"feed.ecosystem.lnurl": "LNURL",
|
||||
"reviews.dialog.bodyPlaceholder.lnurl": "Как прошли чеканка, плавка и проворот нот? Пишите конкретно.",
|
||||
"reviews.byUrl.open": "Написать отзыв",
|
||||
"reviews.byUrl.title": "Написать отзыв",
|
||||
"reviews.byUrl.lede": "Вставь адрес. Если сайт его ещё не знает, он проверит его и добавит прямо сейчас.",
|
||||
"reviews.byUrl.label.cashu": "URL минта",
|
||||
"reviews.byUrl.label.fedimint": "Код приглашения федерации",
|
||||
"reviews.byUrl.label.lnurl": "URL 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": "Этот адрес ответил как минт {kind}.",
|
||||
"reviews.byUrl.switchTo": "Оценить как минт {kind}",
|
||||
"reviews.byUrl.error.empty": "Сначала введи адрес.",
|
||||
"reviews.byUrl.error.badUrl": "Это не похоже на URL минта.",
|
||||
"reviews.byUrl.error.badInvite": "Это не код приглашения Fedimint. Коды начинаются с fed11.",
|
||||
"reviews.byUrl.error.badInput": "Сайт не может проверить этот адрес.",
|
||||
"reviews.byUrl.error.blocked": "Это не публичный адрес, поэтому проверить его нельзя.",
|
||||
"reviews.byUrl.error.notAMint": "Адрес ответил, но не как минт этого типа.",
|
||||
"reviews.byUrl.error.wrongType": "Адрес ответил, но как минт другого типа.",
|
||||
"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 тоже был недоступен, поэтому ничего нельзя было отчеканить или расплавить."
|
||||
}
|
||||
|
||||
+118
-3
@@ -114,6 +114,8 @@
|
||||
"card.reviewed": "recenserat {when}",
|
||||
"card.noReviews": "inga recensioner ännu",
|
||||
"mint.title": "{name} · Cashu – recensioner i nyskick",
|
||||
"mint.titleFedimint": "{name} · Recensioner av Fedimint-federationen",
|
||||
"mint.titleLnurl": "{name} · Recensioner av LNURL-minten",
|
||||
"mint.breadcrumb": "Navigationsspår",
|
||||
"mint.crumbMints": "Cashu-pastiller",
|
||||
"mint.copyUrl": "Kopiera Mint-URL:en",
|
||||
@@ -325,7 +327,7 @@
|
||||
"reviews.validate.short": "Skriv minst {n} tecken så att recensionen blir till nytta.",
|
||||
"reviews.validate.long": "Recensioner får högst {n} tecken.",
|
||||
"feed.title": "Senaste recensioner av mynt och federationer · Cashumints.space",
|
||||
"feed.description": "Alla recensioner av mynt och federationer i takt med att de publiceras: Recensioner av {reviews} från mynten {mints} och Cashu samt federationerna {federations} och Fedimint, signerade på Nostr och tillgängliga här, med de senaste först. Filtrera efter ekosystem, betyg eller namn.",
|
||||
"feed.description": "Varje recension av mints, federationer och LNURL-mints så fort den publiceras: {reviews} recensioner om {mints} Cashu-mints, {federations} Fedimint-federationer och {lnurl} LNURL-mints, signerade på Nostr och läsbara här med nyast först. Filtrera på ekosystem, betyg eller namn.",
|
||||
"feed.heading": "Senaste recensionerna",
|
||||
"feed.lede": "Alla recensioner av alla Cashu-mint- och Fedimint-federationer, med de senaste först, läses direkt från Nostr-reläerna. Varje recension är signerad av sin författare och finns lagrad på reläerna, inte i vår databas.",
|
||||
"feed.label": "Recensioner",
|
||||
@@ -400,7 +402,14 @@
|
||||
"notfound.fb.reviews": "Recensioner",
|
||||
"notfound.fb.software": "Programvara",
|
||||
"notfound.fb.lastOnline": "Senast online",
|
||||
"notfound.fb.note": "Denna recension indexerades efter det att webbplatsen senast uppdaterades, så detta är en sammanfattning från live-API:et. Hela sidan med recensioner visas vid nästa uppdatering.",
|
||||
"notfound.fb.note": "Den här minten indexerades efter att sajten senast byggdes, så det här är en sammanfattning från det live-API:t, med recensionerna nedanför. Hela sidan dyker upp vid nästa bygge.",
|
||||
"notfound.resolve.checking": "Första besöket: kontrollerar minten nu.",
|
||||
"notfound.resolve.failed": "Den här minten kunde inte läggas till",
|
||||
"notfound.resolve.failedFedimint": "Den här federationen kunde inte läggas till",
|
||||
"notfound.resolve.retry": "Prova en annan adress",
|
||||
"notfound.resolve.switch": "Öppna den som {kind}-mint",
|
||||
"notfound.resolve.fedimint": "En federations sidadress innehåller inte dess invite code, så den här går inte att slå upp enbart via länken. Klistra in invite code så läggs den till.",
|
||||
"notfound.resolve.ambiguous": "Adressen går inte att härleda enbart ur länken. Klistra in mintens fullständiga URL så läggs den till.",
|
||||
"wallets.title": "Cashu-plånböcker · Cashumints.space",
|
||||
"wallets.description": "Cashu-plånböcker som fungerar med de mynt som anges här, både på mobil, webben, i webbläsaren och via kommandoraden, och som utnyttjar respektive plattforms styrkor.",
|
||||
"wallets.heading": "Plånböcker",
|
||||
@@ -654,5 +663,111 @@
|
||||
"footer.allFedimints": "Alla förbund",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "Hur gick det med präglingen, smältningen och stödet? Var så detaljerad som möjligt.",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "Hur gick det med registreringen, betalningarna med Lightning och supporten? Var så detaljerad som möjligt.",
|
||||
"fedimint.details.title": "Uppgifter om förbundet"
|
||||
"fedimint.details.title": "Uppgifter om förbundet",
|
||||
"nav.lnurlMints": "LNURL-mints",
|
||||
"footer.allLnurlMints": "Alla LNURL-mints",
|
||||
"lnurlMints.title": "LNURL-mints: mints för lnurlcash-innehavarsedlar, recenserade | Cashumints.space",
|
||||
"lnurlMints.description": "Alla {total} LNURL-mints som indexerats från Nostr-nätverket: {online} online, {offline} offline. Betyg från gemenskapen, uttagsgränser och funktioner för var och en.",
|
||||
"lnurlMints.heading": "LNURL-mints",
|
||||
"lnurlMints.lede": "Varje LNURL-mint den här sajten känner till. En LNURL-mint ger ut lnurlcash-innehavarsedlar: Lightning-sedlar som du håller som en hemlighet och som kan roteras, delas, slås ihop eller smältas tillbaka till en Lightning-betalning. Var och en kontrolleras via HTTPS med några minuters mellanrum.",
|
||||
"lnurlMints.search.label": "Sök LNURL-mints",
|
||||
"lnurlMints.search.placeholder": "Sök på namn eller adress…",
|
||||
"lnurlMints.sort.label": "Sortera LNURL-mints efter",
|
||||
"lnurlMints.hideOffline": "Dölj offline",
|
||||
"lnurlMints.showing": "Visar {total} LNURL-mints ({online} online, {offline} offline)",
|
||||
"lnurlMints.showingFiltered": "Visar {shown} av {total} LNURL-mints ({online} online, {offline} offline)",
|
||||
"lnurlMints.noResults": "Ingen LNURL-mint matchar det.",
|
||||
"lnurlMints.emptyIndex": "Inga LNURL-mints är indexerade ännu.",
|
||||
"lnurl.crumb": "LNURL-mints",
|
||||
"lnurl.breadcrumb": "Brödsmulor",
|
||||
"lnurl.copyAddress": "Kopiera adress",
|
||||
"lnurl.more.title": "Fler LNURL-mints",
|
||||
"lnurl.more.all": "Alla LNURL-mints →",
|
||||
"lnurl.disclaimer": "En LNURL-mint förvarar dina sats medan du håller dess sedlar, så att använda en betyder att lita på dess operatör. Betyg är åsikter från gemenskapen, och online betyder nåbar, inte pålitlig.",
|
||||
"lnurl.features.title": "Funktioner",
|
||||
"lnurl.features.none": "Den här minten har inte annonserat vad den stöder.",
|
||||
"lnurl.features.note": "Funktioner är vad mintens operatör annonserat på Nostr. Där sajten kan kontrollera en via HTTPS gör den det, och säger till när något annonserat inte är tillgängligt just nu.",
|
||||
"lnurl.features.notListed": "inte annonserat",
|
||||
"lnurl.features.unavailable": "otillgängligt",
|
||||
"lnurl.feature.mint": "Minta via Lightning (LUD-06)",
|
||||
"lnurl.feature.melt": "Smälta till Lightning",
|
||||
"lnurl.feature.notes": "Rotera / dela / slå ihop sedlar",
|
||||
"lnurl.feature.lud16": "Lightning-adress",
|
||||
"lnurl.feature.lud21": "Betalningsverifiering (LUD-21)",
|
||||
"lnurl.feature.signed-notes": "Signerade sedlar (offlineverifiering)",
|
||||
"lnurl.feature.onion": "Tor-adress",
|
||||
"lnurl.feature.rotate": "Rotera en sedel",
|
||||
"lnurl.feature.split": "Dela en sedel",
|
||||
"lnurl.feature.merge": "Slå ihop sedlar",
|
||||
"lnurl.feature.lud06": "LUD-06 payRequest",
|
||||
"lnurl.feature.lud03": "LUD-03 withdrawRequest",
|
||||
"lnurl.limits.label": "Uttagsgränser",
|
||||
"lnurl.limits.notPublished": "inte publicerat",
|
||||
"lnurl.limits.withdrawRow": "Uttag",
|
||||
"lnurl.limits.disabled": "avstängt",
|
||||
"lnurl.limits.sats.one": "{n} sat",
|
||||
"lnurl.limits.sats.other": "{n} sats",
|
||||
"lnurl.details.title": "Uppgifter om minten",
|
||||
"lnurl.details.address": "Lightning-adress",
|
||||
"lnurl.details.mintPubkey": "Mintens publika nyckel",
|
||||
"lnurl.details.onion": "Tor-adress",
|
||||
"lnurl.details.fee": "Mintavgift",
|
||||
"lnurl.details.node": "Nod",
|
||||
"lnurl.details.capacity": "Annonserad kapacitet",
|
||||
"lnurl.kind.lightningAddress": "Lightning-adress",
|
||||
"lnurl.kind.mintPubkey": "mintens publika nyckel",
|
||||
"lnurl.kind.onionAddress": "Tor-adress",
|
||||
"lnurl.confirmed.label": "Senast bekräftad",
|
||||
"lnurl.confirmed.when": "bekräftad {when}",
|
||||
"lnurl.fee.base.one": "{n} sat",
|
||||
"lnurl.fee.base.other": "{n} sats",
|
||||
"lnurl.fee.percent": "{percent} %",
|
||||
"home.topLnurl.title": "Toppade LNURL-mints",
|
||||
"home.topLnurl.sub": "rankade efter recensioner och betyg",
|
||||
"home.topLnurl.all": "Alla {n} LNURL-mints →",
|
||||
"feed.ecosystem.lnurl": "LNURL",
|
||||
"reviews.dialog.bodyPlaceholder.lnurl": "Hur gick mintning, smältning och rotering av sedlar? Var konkret.",
|
||||
"reviews.byUrl.open": "Skriv en recension",
|
||||
"reviews.byUrl.title": "Skriv en recension",
|
||||
"reviews.byUrl.lede": "Klistra in adressen. Om sajten inte känner till den än kontrolleras och läggs den till nu.",
|
||||
"reviews.byUrl.label.cashu": "Mint-URL",
|
||||
"reviews.byUrl.label.fedimint": "Federationens invite code",
|
||||
"reviews.byUrl.label.lnurl": "URL till LNURL-mint",
|
||||
"reviews.byUrl.placeholder.cashu": "mint.example.com",
|
||||
"reviews.byUrl.placeholder.fedimint": "fed11…",
|
||||
"reviews.byUrl.placeholder.lnurl": "mint.example.com",
|
||||
"reviews.byUrl.check": "Kontrollera adressen",
|
||||
"reviews.byUrl.checking": "Kontrollerar…",
|
||||
"reviews.byUrl.back": "Använd en annan adress",
|
||||
"reviews.byUrl.existing": "Finns redan med",
|
||||
"reviews.byUrl.indexed": "Nyss tillagd",
|
||||
"reviews.byUrl.confirmClose": "Stänga? Adressen du skrev sparas inte.",
|
||||
"reviews.byUrl.detected": "Adressen svarade som en {kind}-mint.",
|
||||
"reviews.byUrl.switchTo": "Recensera den som {kind}-mint",
|
||||
"reviews.byUrl.error.empty": "Skriv en adress först.",
|
||||
"reviews.byUrl.error.badUrl": "Det ser inte ut som en mint-URL.",
|
||||
"reviews.byUrl.error.badInvite": "Det är ingen Fedimint invite code. Koder börjar med fed11.",
|
||||
"reviews.byUrl.error.badInput": "Sajten kan inte kontrollera den adressen.",
|
||||
"reviews.byUrl.error.blocked": "Det är ingen offentlig adress, så den går inte att kontrollera.",
|
||||
"reviews.byUrl.error.notAMint": "Adressen svarade, men inte som en mint av det här slaget.",
|
||||
"reviews.byUrl.error.wrongType": "Adressen svarade, men som ett annat slags mint.",
|
||||
"reviews.byUrl.error.unverifiable": "Inget svarade på den adressen, och det finns ännu inga spår av den på Nostr.",
|
||||
"reviews.byUrl.error.rateLimited.one": "Det är många kontroller från en och samma anslutning. Försök igen om ungefär {n} minut.",
|
||||
"reviews.byUrl.error.rateLimited.other": "Det är många kontroller från en och samma anslutning. Försök igen om ungefär {n} minuter.",
|
||||
"reviews.byUrl.error.network": "Indexet gick inte att nå. Kontrollera din uppkoppling och försök igen.",
|
||||
"seo.lnurl.titleNoReviews": "{name}: info och recensioner om LNURL-minten | Cashumints.space",
|
||||
"seo.desc.statusWithdraw": "{status}, uttag {min}–{max} sat.",
|
||||
"mint.chip.noFunding": "Ingen mint / smält",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.lead": "Uttag är avstängda.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.body.online": "Den här minten annonserar ett maximalt uttag på noll, så ingen sedel den ger ut kan lösas in mot någonting. Minta inte här förrän det ändras.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.body.offline": "Den här minten annonserade ett maximalt uttag på noll när den senast nåddes, så ingen sedel den gav ut kunde lösas in mot någonting. Minta inte här förrän det ändras.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.online": "Uttag är avstängda just nu",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.offline": "Uttag var avstängda när den senast sågs",
|
||||
"mint.warnings.lnurlInvalid.lead": "Endpunkten svarar men är ogiltig.",
|
||||
"mint.warnings.lnurlInvalid.body": "Värden svarar, men inte med en mint-annonsering som den här sajten kan läsa, så uttag kanske inte fungerar. Allt nedan är det senaste tillstånd som gick att läsa, och recensioner fungerar fortfarande.",
|
||||
"mint.warnings.lnurlInvalid.meta": "Svarar, men inte med en giltig mint-annonsering",
|
||||
"mint.warnings.lnurlNoFunding.lead": "Mintning och smältning är otillgängliga.",
|
||||
"mint.warnings.lnurlNoFunding.body": "Befintliga sedlar kan fortfarande roteras, delas eller slås ihop, men just nu rör sig ingenting in eller ut via Lightning. Själva minten är uppe och svarar; Lightning-noden bakom den är inte nåbar därifrån.",
|
||||
"mint.warnings.lnurlNoFunding.meta": "Mintning och smältning otillgängliga",
|
||||
"mint.warnings.also.noFunding": "Dess Lightning-nod var också onåbar, så ingenting kunde mintas eller smältas."
|
||||
}
|
||||
|
||||
+118
-3
@@ -114,6 +114,8 @@
|
||||
"card.reviewed": "İnceleme {when}",
|
||||
"card.noReviews": "Henüz inceleme yok",
|
||||
"mint.title": "{name} · Cashu mint incelemeleri",
|
||||
"mint.titleFedimint": "{name} · Fedimint federasyonu değerlendirmeleri",
|
||||
"mint.titleLnurl": "{name} · LNURL mint değerlendirmeleri",
|
||||
"mint.breadcrumb": "Ekmek kırıntısı",
|
||||
"mint.crumbMints": "Cashu darphaneleri",
|
||||
"mint.copyUrl": "Kopyala URL",
|
||||
@@ -325,7 +327,7 @@
|
||||
"reviews.validate.short": "En az {n} karakter yaz ki inceleme faydalı olsun.",
|
||||
"reviews.validate.long": "İncelemeler {n} karakterle sınırlıdır.",
|
||||
"feed.title": "En son darphaneler ve federasyon incelemeleri · Cashumints.space",
|
||||
"feed.description": "Her mint ve federasyon incelemesi yayımlandığı halde: {mints} Cashu darphaneleri ve {federations} Fedimint federasyonlarında {reviews} incelemeler, Nostr'da imzalanmış ve burada en yeni en yeni olarak okunabilir. Ekosisteme, dereceye veya isme göre filtreleyin.",
|
||||
"feed.description": "Her mint, federasyon ve LNURL mint değerlendirmesi yayımlandığı anda: {mints} Cashu mint'i, {federations} Fedimint federasyonu ve {lnurl} LNURL mint'i hakkında {reviews} değerlendirme, Nostr üzerinde imzalanmış ve burada en yeniden başlayarak okunabilir. Ekosisteme, puana veya isme göre filtreleyin.",
|
||||
"feed.heading": "Son incelemeler",
|
||||
"feed.lede": "Her Cashu darphanesi ve Fedimint federasyonunun her incelemesi, en yenisi ilk olarak, Nostr rölelerinden canlı olarak okundu. Her biri yazarı tarafından imzalanmıştır ve rölelerde yaşıyor, veritabanımızda değil.",
|
||||
"feed.label": "İncelemeler",
|
||||
@@ -400,7 +402,14 @@
|
||||
"notfound.fb.reviews": "İncelemeler",
|
||||
"notfound.fb.software": "Yazılım",
|
||||
"notfound.fb.lastOnline": "Son çevrimiçi",
|
||||
"notfound.fb.note": "Bu mint, site son inşa edildikten sonra indekslendi, bu yüzden canlı API'den bir özet. Yorumların olduğu tam sayfa bir sonraki yeniden yapımda görünür.",
|
||||
"notfound.fb.note": "Bu mint, site en son derlendikten sonra dizine eklendi; bu yüzden burada canlı API’den bir özet ve altında değerlendirmeleri var. Sayfanın tamamı bir sonraki derlemede görünür.",
|
||||
"notfound.resolve.checking": "İlk ziyaret: bu mint şimdi kontrol ediliyor.",
|
||||
"notfound.resolve.failed": "Bu mint eklenemedi",
|
||||
"notfound.resolve.failedFedimint": "Bu federasyon eklenemedi",
|
||||
"notfound.resolve.retry": "Başka bir adres dene",
|
||||
"notfound.resolve.switch": "{kind} mint olarak aç",
|
||||
"notfound.resolve.fedimint": "Bir federasyonun sayfa adresi davet kodunu taşımaz, bu yüzden bu federasyon yalnızca bağlantıyla bulunamaz. Davet kodunu yapıştır, federasyon eklenir.",
|
||||
"notfound.resolve.ambiguous": "Bu adres yalnızca bağlantıdan çıkarılamıyor. Mintin tam URL’sini yapıştır, mint eklenir.",
|
||||
"wallets.title": "Cashu cüzdanları · Cashumints.space",
|
||||
"wallets.description": "Burada listelenen mintlerle mobil, web, tarayıcı ve komut satırı üzerinden çalışan Cashu cüzdanları, her birinin neye iyi olduğunu gösteriyor.",
|
||||
"wallets.heading": "Cüzdanlar",
|
||||
@@ -654,5 +663,111 @@
|
||||
"footer.allFedimints": "Tüm federasyonlar",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "Basıcılık, eritme ve destek nasıl sonuçlandı? Spesifik olun.",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "Katılım, Lightning ödemeleri ve destek nasıl sonuçlandı? Spesifik olun.",
|
||||
"fedimint.details.title": "Federasyon detayları"
|
||||
"fedimint.details.title": "Federasyon detayları",
|
||||
"nav.lnurlMints": "LNURL mint'leri",
|
||||
"footer.allLnurlMints": "Tüm LNURL mint'leri",
|
||||
"lnurlMints.title": "LNURL mint'leri: lnurlcash hamiline senet mint'leri, değerlendirmelerle | Cashumints.space",
|
||||
"lnurlMints.description": "Nostr ağından dizinlenen {total} LNURL mint'inin tamamı: {online} çevrimiçi, {offline} çevrimdışı. Her biri için topluluk puanları, çekim limitleri ve özellikler.",
|
||||
"lnurlMints.heading": "LNURL mint'leri",
|
||||
"lnurlMints.lede": "Bu sitenin bildiği her LNURL mint'i. Bir LNURL mint'i lnurlcash hamiline senetleri çıkarır: bir sır olarak sakladığınız, döndürülebilen, bölünebilen, birleştirilebilen veya bir Lightning ödemesine geri eritilebilen Lightning senetleri. Her biri birkaç dakikada bir HTTPS üzerinden kontrol edilir.",
|
||||
"lnurlMints.search.label": "LNURL mint'lerinde ara",
|
||||
"lnurlMints.search.placeholder": "İsme veya adrese göre ara…",
|
||||
"lnurlMints.sort.label": "LNURL mint'lerini şuna göre sırala",
|
||||
"lnurlMints.hideOffline": "Çevrimdışıları gizle",
|
||||
"lnurlMints.showing": "{total} LNURL mint'i gösteriliyor ({online} çevrimiçi, {offline} çevrimdışı)",
|
||||
"lnurlMints.showingFiltered": "{total} LNURL mint'inden {shown} tanesi gösteriliyor ({online} çevrimiçi, {offline} çevrimdışı)",
|
||||
"lnurlMints.noResults": "Eşleşen LNURL mint'i yok.",
|
||||
"lnurlMints.emptyIndex": "Henüz dizinlenmiş LNURL mint'i yok.",
|
||||
"lnurl.crumb": "LNURL mint'leri",
|
||||
"lnurl.breadcrumb": "Gezinti yolu",
|
||||
"lnurl.copyAddress": "Adresi kopyala",
|
||||
"lnurl.more.title": "Daha fazla LNURL mint'i",
|
||||
"lnurl.more.all": "Tüm LNURL mint'leri →",
|
||||
"lnurl.disclaimer": "Bir LNURL mint'i siz onun senetlerini tutarken sizin sat'larınızı tutar; yani birini kullanmak işletmecisine güvenmek demektir. Puanlar topluluk görüşüdür ve çevrimiçi, erişilebilir demektir, güvenilir değil.",
|
||||
"lnurl.features.title": "Özellikler",
|
||||
"lnurl.features.none": "Bu mint neyi desteklediğini duyurmadı.",
|
||||
"lnurl.features.note": "Özellikler, mint işletmecisinin Nostr'da duyurduklarıdır. Bu site birini HTTPS üzerinden doğrulayabildiğinde doğrular ve duyurulan bir şey şu anda kullanılamıyorsa bunu belirtir.",
|
||||
"lnurl.features.notListed": "duyurulmadı",
|
||||
"lnurl.features.unavailable": "kullanılamıyor",
|
||||
"lnurl.feature.mint": "Lightning ile mint (LUD-06)",
|
||||
"lnurl.feature.melt": "Lightning'e erit",
|
||||
"lnurl.feature.notes": "Senetleri döndür / böl / birleştir",
|
||||
"lnurl.feature.lud16": "Lightning adresi",
|
||||
"lnurl.feature.lud21": "Ödeme doğrulaması (LUD-21)",
|
||||
"lnurl.feature.signed-notes": "İmzalı senetler (çevrimdışı doğrulama)",
|
||||
"lnurl.feature.onion": "Tor adresi",
|
||||
"lnurl.feature.rotate": "Bir seneti döndür",
|
||||
"lnurl.feature.split": "Bir seneti böl",
|
||||
"lnurl.feature.merge": "Senetleri birleştir",
|
||||
"lnurl.feature.lud06": "LUD-06 payRequest",
|
||||
"lnurl.feature.lud03": "LUD-03 withdrawRequest",
|
||||
"lnurl.limits.label": "Çekim limitleri",
|
||||
"lnurl.limits.notPublished": "yayımlanmadı",
|
||||
"lnurl.limits.withdrawRow": "Çekim",
|
||||
"lnurl.limits.disabled": "devre dışı",
|
||||
"lnurl.limits.sats.one": "{n} sat",
|
||||
"lnurl.limits.sats.other": "{n} sat",
|
||||
"lnurl.details.title": "Mint ayrıntıları",
|
||||
"lnurl.details.address": "Lightning adresi",
|
||||
"lnurl.details.mintPubkey": "Mint açık anahtarı",
|
||||
"lnurl.details.onion": "Tor adresi",
|
||||
"lnurl.details.fee": "Mint ücreti",
|
||||
"lnurl.details.node": "Düğüm",
|
||||
"lnurl.details.capacity": "Duyurulan kapasite",
|
||||
"lnurl.kind.lightningAddress": "Lightning adresi",
|
||||
"lnurl.kind.mintPubkey": "mint açık anahtarı",
|
||||
"lnurl.kind.onionAddress": "Tor adresi",
|
||||
"lnurl.confirmed.label": "Son doğrulama",
|
||||
"lnurl.confirmed.when": "{when} doğrulandı",
|
||||
"lnurl.fee.base.one": "{n} sat",
|
||||
"lnurl.fee.base.other": "{n} sat",
|
||||
"lnurl.fee.percent": "%{percent}",
|
||||
"home.topLnurl.title": "En iyi LNURL mint'leri",
|
||||
"home.topLnurl.sub": "değerlendirme ve puana göre sıralandı",
|
||||
"home.topLnurl.all": "{n} LNURL mint'inin tümü →",
|
||||
"feed.ecosystem.lnurl": "LNURL",
|
||||
"reviews.dialog.bodyPlaceholder.lnurl": "Mint etme, eritme ve senet döndürme nasıl gitti? Somut olun.",
|
||||
"reviews.byUrl.open": "Bir değerlendirme yaz",
|
||||
"reviews.byUrl.title": "Bir değerlendirme yaz",
|
||||
"reviews.byUrl.lede": "Adresi yapıştır. Bu site adresi henüz bilmiyorsa şimdi kontrol eder ve ekler.",
|
||||
"reviews.byUrl.label.cashu": "Mint URL’si",
|
||||
"reviews.byUrl.label.fedimint": "Federasyonun davet kodu",
|
||||
"reviews.byUrl.label.lnurl": "LNURL mint URL’si",
|
||||
"reviews.byUrl.placeholder.cashu": "mint.example.com",
|
||||
"reviews.byUrl.placeholder.fedimint": "fed11…",
|
||||
"reviews.byUrl.placeholder.lnurl": "mint.example.com",
|
||||
"reviews.byUrl.check": "Bu adresi kontrol et",
|
||||
"reviews.byUrl.checking": "Kontrol ediliyor…",
|
||||
"reviews.byUrl.back": "Başka bir adres kullan",
|
||||
"reviews.byUrl.existing": "Zaten listede",
|
||||
"reviews.byUrl.indexed": "Yeni eklendi",
|
||||
"reviews.byUrl.confirmClose": "Kapatılsın mı? Yazdığın adres saklanmaz.",
|
||||
"reviews.byUrl.detected": "Bu adres bir {kind} mint olarak yanıt verdi.",
|
||||
"reviews.byUrl.switchTo": "{kind} mint olarak değerlendir",
|
||||
"reviews.byUrl.error.empty": "Önce bir adres gir.",
|
||||
"reviews.byUrl.error.badUrl": "Bu bir mint URL’sine benzemiyor.",
|
||||
"reviews.byUrl.error.badInvite": "Bu bir Fedimint davet kodu değil. Kodlar fed11 ile başlar.",
|
||||
"reviews.byUrl.error.badInput": "Bu site o adresi kontrol edemiyor.",
|
||||
"reviews.byUrl.error.blocked": "Bu genel bir adres değil, bu yüzden kontrol edilemiyor.",
|
||||
"reviews.byUrl.error.notAMint": "Bu adres yanıt verdi, ama bu türden bir mint olarak değil.",
|
||||
"reviews.byUrl.error.wrongType": "Bu adres yanıt verdi, ama başka türden bir mint olarak.",
|
||||
"reviews.byUrl.error.unverifiable": "Bu adreste hiçbir şey yanıt vermedi ve Nostr’da da henüz izine rastlanmadı.",
|
||||
"reviews.byUrl.error.rateLimited.one": "Tek bağlantıdan çok fazla kontrol yapıldı. Yaklaşık {n} dakika sonra tekrar dene.",
|
||||
"reviews.byUrl.error.rateLimited.other": "Tek bağlantıdan çok fazla kontrol yapıldı. Yaklaşık {n} dakika sonra tekrar dene.",
|
||||
"reviews.byUrl.error.network": "Dizine ulaşılamadı. Bağlantını kontrol edip tekrar dene.",
|
||||
"seo.lnurl.titleNoReviews": "{name}: LNURL mint bilgileri ve değerlendirmeleri | Cashumints.space",
|
||||
"seo.desc.statusWithdraw": "{status}, çekim {min}–{max} sat.",
|
||||
"mint.chip.noFunding": "Mint / erit yok",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.lead": "Çekimler devre dışı.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.body.online": "Bu mint azami çekimi sıfır olarak duyuruyor, yani çıkardığı hiçbir senet hiçbir şeye çevrilemez. Bu değişene kadar burada mint etmeyin.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.body.offline": "Bu mint'e en son ulaşıldığında azami çekimi sıfır olarak duyuruyordu, yani çıkardığı hiçbir senet hiçbir şeye çevrilemiyordu. Bu değişene kadar burada mint etmeyin.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.online": "Çekimler şu anda devre dışı",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.offline": "En son görüldüğünde çekimler devre dışıydı",
|
||||
"mint.warnings.lnurlInvalid.lead": "Uç nokta yanıt veriyor ama geçersiz.",
|
||||
"mint.warnings.lnurlInvalid.body": "Sunucu yanıt veriyor, ancak bu sitenin okuyabileceği bir mint duyurusuyla değil; bu yüzden çekimler çalışmayabilir. Aşağıdakiler okunabilen son durumdur ve değerlendirmeler hâlâ çalışıyor.",
|
||||
"mint.warnings.lnurlInvalid.meta": "Yanıt veriyor, ama geçerli bir mint duyurusuyla değil",
|
||||
"mint.warnings.lnurlNoFunding.lead": "Mint etme ve eritme kullanılamıyor.",
|
||||
"mint.warnings.lnurlNoFunding.body": "Mevcut senetler hâlâ döndürülebilir, bölünebilir veya birleştirilebilir, ancak şu anda Lightning üzerinden hiçbir şey girip çıkmıyor. Mint'in kendisi ayakta ve yanıt veriyor; arkasındaki Lightning düğümüne oradan erişilemiyor.",
|
||||
"mint.warnings.lnurlNoFunding.meta": "Mint etme ve eritme kullanılamıyor",
|
||||
"mint.warnings.also.noFunding": "Lightning düğümüne de erişilemiyordu, bu yüzden hiçbir şey mint edilemez veya eritilemezdi."
|
||||
}
|
||||
|
||||
+122
-3
@@ -116,6 +116,8 @@
|
||||
"card.reviewed": "відгук {when}",
|
||||
"card.noReviews": "Поки що немає відгуків",
|
||||
"mint.title": "{name} · Відгуки про мінт Cashu",
|
||||
"mint.titleFedimint": "{name} · Відгуки про федерацію Fedimint",
|
||||
"mint.titleLnurl": "{name} · Відгуки про LNURL-мінт",
|
||||
"mint.breadcrumb": "Навігаційний ланцюжок",
|
||||
"mint.crumbMints": "Мінти Cashu",
|
||||
"mint.copyUrl": "Скопіювати URL мінту",
|
||||
@@ -351,7 +353,7 @@
|
||||
"reviews.validate.short": "Напишіть щонайменше {n} символів, щоб відгук був корисним.",
|
||||
"reviews.validate.long": "Відгук може містити не більше {n} символів.",
|
||||
"feed.title": "Останні відгуки про мінти та федерації · Cashumints.space",
|
||||
"feed.description": "Усі відгуки про мінти й федерації одразу після публікації: {reviews} відгуків про {mints} мінтів Cashu та {federations} федерацій Fedimint, підписаних у Nostr. Нові відгуки показано першими. Фільтруйте за екосистемою, оцінкою або назвою.",
|
||||
"feed.description": "Кожен відгук про мінт, федерацію та LNURL-мінт одразу після публікації: {reviews} відгуків про {mints} мінтів Cashu, {federations} федерацій Fedimint і {lnurl} LNURL-мінтів, підписаних у Nostr і доступних тут від найновіших. Фільтруйте за екосистемою, оцінкою або назвою.",
|
||||
"feed.heading": "Останні огляди",
|
||||
"feed.lede": "Усі відгуки про кожен мінт Cashu і кожну федерацію Fedimint, від найновіших, читаються безпосередньо з Nostr relays. Кожен відгук підписаний автором і зберігається на relays, а не в нашій базі даних.",
|
||||
"feed.label": "Відгуки",
|
||||
@@ -428,7 +430,14 @@
|
||||
"notfound.fb.reviews": "Відгуки",
|
||||
"notfound.fb.software": "Програмне забезпечення",
|
||||
"notfound.fb.lastOnline": "Востаннє онлайн",
|
||||
"notfound.fb.note": "Цей мінт проіндексовано після останньої збірки сайту, тому тут показано зведення з актуального API. Повна сторінка з відгуками з’явиться після наступної збірки.",
|
||||
"notfound.fb.note": "Цей мінт проіндексовано після останньої збірки сайту, тож тут зведення з живого API, а нижче його відгуки. Повна сторінка з’явиться під час наступної збірки.",
|
||||
"notfound.resolve.checking": "Перший візит: перевіряємо цей мінт.",
|
||||
"notfound.resolve.failed": "Не вдалося додати цей мінт",
|
||||
"notfound.resolve.failedFedimint": "Не вдалося додати цю федерацію",
|
||||
"notfound.resolve.retry": "Спробувати іншу адресу",
|
||||
"notfound.resolve.switch": "Відкрити як мінт {kind}",
|
||||
"notfound.resolve.fedimint": "Адреса сторінки федерації не містить її коду запрошення, тож знайти її лише за посиланням не вийде. Встав код запрошення, і її буде додано.",
|
||||
"notfound.resolve.ambiguous": "Цю адресу не відновити лише за посиланням. Встав повний URL мінта, і його буде додано.",
|
||||
"wallets.title": "Гаманці Cashu · Cashumints.space",
|
||||
"wallets.description": "Гаманці Cashu, що працюють із переліченими тут мінтами на мобільних пристроях, у вебі, браузері та командному рядку, із зазначенням переваг кожного.",
|
||||
"wallets.heading": "Гаманці",
|
||||
@@ -696,5 +705,115 @@
|
||||
"footer.allFedimints": "Усі федерації",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "Як працювали внесення через mint, виведення через melt і підтримка? Пишіть конкретно.",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "Як працювали приєднання, платежі Lightning і підтримка? Пишіть конкретно.",
|
||||
"fedimint.details.title": "Деталі федерації"
|
||||
"fedimint.details.title": "Деталі федерації",
|
||||
"nav.lnurlMints": "LNURL-мінти",
|
||||
"footer.allLnurlMints": "Усі LNURL-мінти",
|
||||
"lnurlMints.title": "LNURL-мінти: мінти пред'явницьких нот lnurlcash з відгуками | Cashumints.space",
|
||||
"lnurlMints.description": "Усі {total} LNURL-мінтів, проіндексованих із мережі 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.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.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": "Усі {n} LNURL-мінтів →",
|
||||
"feed.ecosystem.lnurl": "LNURL",
|
||||
"reviews.dialog.bodyPlaceholder.lnurl": "Як пройшли карбування, плавлення та прокрутка нот? Пишіть конкретно.",
|
||||
"reviews.byUrl.open": "Написати відгук",
|
||||
"reviews.byUrl.title": "Написати відгук",
|
||||
"reviews.byUrl.lede": "Встав адресу. Якщо сайт її ще не знає, він перевірить її й додасть зараз.",
|
||||
"reviews.byUrl.label.cashu": "URL мінта",
|
||||
"reviews.byUrl.label.fedimint": "Код запрошення федерації",
|
||||
"reviews.byUrl.label.lnurl": "URL 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": "Ця адреса відповіла як мінт {kind}.",
|
||||
"reviews.byUrl.switchTo": "Оцінити як мінт {kind}",
|
||||
"reviews.byUrl.error.empty": "Спочатку введи адресу.",
|
||||
"reviews.byUrl.error.badUrl": "Це не схоже на URL мінта.",
|
||||
"reviews.byUrl.error.badInvite": "Це не код запрошення Fedimint. Коди починаються з fed11.",
|
||||
"reviews.byUrl.error.badInput": "Сайт не може перевірити цю адресу.",
|
||||
"reviews.byUrl.error.blocked": "Це не публічна адреса, тож перевірити її не можна.",
|
||||
"reviews.byUrl.error.notAMint": "Адреса відповіла, але не як мінт цього типу.",
|
||||
"reviews.byUrl.error.wrongType": "Адреса відповіла, але як мінт іншого типу.",
|
||||
"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 теж був недоступний, тож нічого не можна було відкарбувати чи розплавити."
|
||||
}
|
||||
|
||||
+145
-30
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"site.name": "Cashumints. space",
|
||||
"common.close": "بندکرو",
|
||||
"common.cancel": "منسوخ",
|
||||
"common.done": "بند",
|
||||
"common.back": "ما وٴ س",
|
||||
"site.name": "Cashumints.space",
|
||||
"common.close": "بند کریں",
|
||||
"common.cancel": "منسوخ کریں",
|
||||
"common.done": "ہو گیا",
|
||||
"common.back": "واپس",
|
||||
"common.retry": "دوبارہ کوشش",
|
||||
"common.connect": "نظر انداز کریں",
|
||||
"common.copied": "کُتبخانے",
|
||||
"common.copyFull": "مکمل کاپی کریں{kind}",
|
||||
"common.connect": "منسلک کریں",
|
||||
"common.copied": "کاپی ہو گیا",
|
||||
"common.copyFull": "مکمل {kind} کاپی کریں",
|
||||
"common.previousPage": "پہلے صفحہ",
|
||||
"common.nextPage": "اگلا صفحہ",
|
||||
"common.reviewPages": "کے صفحات دیکھیں",
|
||||
@@ -15,27 +15,27 @@
|
||||
"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}Y پہلے",
|
||||
"time.short.minute": "{n}م",
|
||||
"time.short.hour": "{n}ح",
|
||||
"time.short.day": "{n}ڈ",
|
||||
"time.short.week": "{n}وے",
|
||||
"time.short.month": "{n}ما وٴ س",
|
||||
"time.short.year": "{n}Y",
|
||||
"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.skip": "مواد پر جائیں",
|
||||
"nav.main": "مین",
|
||||
"nav.mints": "Cashu منتر",
|
||||
"nav.reviews": "اعادہ",
|
||||
"nav.wallets": "دیواری",
|
||||
"nav.reviews": "جائزے",
|
||||
"nav.wallets": "والیٹس",
|
||||
"nav.about": "کے بارے میں",
|
||||
"nav.searchMints": "تلاش{n}منٹس",
|
||||
"lang.label": "زبان",
|
||||
@@ -43,11 +43,11 @@
|
||||
"footer.copyright": "نصب شدہ:{year}Cashumints. space",
|
||||
"footer.allMints": "تمام کیوشو منتر",
|
||||
"footer.allReviews": "تمام جائزے",
|
||||
"footer.wallets": "دیواری",
|
||||
"footer.wallets": "والیٹس",
|
||||
"footer.about": "کے بارے میں",
|
||||
"footer.disclaimer": "غیر متصل",
|
||||
"footer.terms": "مدت",
|
||||
"footer.privacy": "پُرتشدد",
|
||||
"footer.disclaimer": "دستبرداری",
|
||||
"footer.terms": "شرائط",
|
||||
"footer.privacy": "رازداری",
|
||||
"footer.madeBy": " ⁇ کے ساتھ بنایا گیا ہے۔{azzamo}· Cashu اور این آئی پی-87 کی طرف سے طاقت ور",
|
||||
"footer.warning": "آپ کے پیسوں سے منٹ ختم ہو سکتے ہیں ۔",
|
||||
"home.title": "Cashumints. space · Cashu منٹ پر آپ اعتماد کر سکتے ہیں۔",
|
||||
@@ -114,6 +114,8 @@
|
||||
"card.reviewed": "طےشدہ{when}",
|
||||
"card.noReviews": "ابھی تک کوئی تجزیے نہیں",
|
||||
"mint.title": "{name}· Cashu منٹ کے جائزے",
|
||||
"mint.titleFedimint": "{name} · Fedimint فیڈریشن کے ریویو",
|
||||
"mint.titleLnurl": "{name} · LNURL mint کے ریویو",
|
||||
"mint.breadcrumb": "بِلکومب",
|
||||
"mint.crumbMints": "Cashu منتر",
|
||||
"mint.copyUrl": "منٹ کاپی کریں",
|
||||
@@ -325,7 +327,7 @@
|
||||
"reviews.validate.short": "کم سے کم لکھیں{n}اس طرح کے حروف مفید ہیں۔",
|
||||
"reviews.validate.long": "تجزیے محدود ہیں۔{n}حروف",
|
||||
"feed.title": "Last Mint and Federation تجزیے · Cashumints. space -",
|
||||
"feed.description": "ہر منٹ اور فیڈریشن جائزہ جتنا شائع ہوتا ہے:{reviews}پورے جائزے{mints}Cashu منٹس اور{federations}نوسٹر پر دستخط کیے گئے، اور یہاں سب سے پہلے نئے سرے سے پڑھے۔",
|
||||
"feed.description": "ہر منٹ، فیڈریشن اور LNURL منٹ کا جائزہ شائع ہوتے ہی: {mints} Cashu منٹس، {federations} Fedimint فیڈریشنز اور {lnurl} LNURL منٹس پر {reviews} جائزے، Nostr پر دستخط شدہ اور یہاں نئے سے پرانے پڑھنے کے لیے۔ ماحولیاتی نظام، درجہ بندی یا نام سے چھانٹیں۔",
|
||||
"feed.heading": "پچھلے جائزے",
|
||||
"feed.lede": "ہر Cashu منٹ اور فیدمینٹ فیڈریشن کا ہر جائزہ، نیا سب سے پہلے، نوسٹر ریلویز سے پڑھے۔ ہر شخص اس کے مصنف کی طرف سے دستخط کیا جاتا ہے اور ہماری ڈیٹا بیس میں نہیں۔",
|
||||
"feed.label": "اعادہ",
|
||||
@@ -400,7 +402,14 @@
|
||||
"notfound.fb.reviews": "اعادہ",
|
||||
"notfound.fb.software": "سافٹ وئیر",
|
||||
"notfound.fb.lastOnline": "آخری آن لائن",
|
||||
"notfound.fb.note": "اس منٹ کو سائٹ کی آخری تعمیر کے بعد انڈیکس کیا گیا تھا، چنانچہ یہ صفحہ زندہ اے پی آئی سے مکمل صفحہ ہے جس میں تجزیے اگلے ازسرنو ملتے ہیں۔",
|
||||
"notfound.fb.note": "یہ mint سائٹ کے آخری بلڈ کے بعد انڈیکس ہوا، اس لیے یہ لائیو API سے ایک خلاصہ ہے اور نیچے اس کے ریویو ہیں۔ مکمل صفحہ اگلے بلڈ پر آئے گا۔",
|
||||
"notfound.resolve.checking": "پہلی بار: اس mint کو ابھی جانچا جا رہا ہے۔",
|
||||
"notfound.resolve.failed": "یہ mint شامل نہیں کیا جا سکا",
|
||||
"notfound.resolve.failedFedimint": "یہ فیڈریشن شامل نہیں کی جا سکی",
|
||||
"notfound.resolve.retry": "کوئی اور پتہ آزمائیں",
|
||||
"notfound.resolve.switch": "اسے {kind} mint کے طور پر کھولیں",
|
||||
"notfound.resolve.fedimint": "فیڈریشن کے صفحے کے پتے میں اس کا invite code نہیں ہوتا، اس لیے صرف لنک سے اسے تلاش نہیں کیا جا سکتا۔ invite code چسپاں کریں، یہ شامل ہو جائے گی۔",
|
||||
"notfound.resolve.ambiguous": "صرف لنک سے یہ پتہ اخذ نہیں کیا جا سکتا۔ mint کا مکمل URL چسپاں کریں، یہ شامل ہو جائے گا۔",
|
||||
"wallets.title": "Cashu Citutes · Cashumints. space",
|
||||
"wallets.description": "کیش چندر گپت جو یہاں کے منٹوں کے ساتھ کام کرتے ہیں، موبائل، ویب، براؤزر اور کمانڈ لائن کے ساتھ،",
|
||||
"wallets.heading": "دیواری",
|
||||
@@ -654,5 +663,111 @@
|
||||
"footer.allFedimints": "تمام رعایتیں",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "اِس سلسلے میں ایک مثال پر غور کریں ۔",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "اِس سلسلے میں ایک مثال پر غور کریں ۔",
|
||||
"fedimint.details.title": "فیڈریشن تفصیلات"
|
||||
"fedimint.details.title": "فیڈریشن تفصیلات",
|
||||
"nav.lnurlMints": "LNURL منٹس",
|
||||
"footer.allLnurlMints": "تمام LNURL منٹس",
|
||||
"lnurlMints.title": "LNURL منٹس: lnurlcash حاملِ نوٹ منٹس، جائزوں کے ساتھ | Cashumints.space",
|
||||
"lnurlMints.description": "Nostr نیٹ ورک سے فہرست بند کیے گئے تمام {total} LNURL منٹس: {online} آن لائن، {offline} آف لائن۔ ہر ایک کے لیے کمیونٹی کی درجہ بندی، رقم نکالنے کی حدود اور خصوصیات۔",
|
||||
"lnurlMints.heading": "LNURL منٹس",
|
||||
"lnurlMints.lede": "ہر وہ LNURL منٹ جو یہ سائٹ جانتی ہے۔ ایک LNURL منٹ lnurlcash حاملِ نوٹ جاری کرتا ہے: لائٹننگ نوٹ جنہیں آپ ایک راز کی طرح رکھتے ہیں اور جنہیں گھمایا، تقسیم کیا، ملایا یا واپس لائٹننگ ادائیگی میں پگھلایا جا سکتا ہے۔ ہر ایک کی HTTPS پر چند منٹ بعد جانچ ہوتی ہے۔",
|
||||
"lnurlMints.search.label": "LNURL منٹس تلاش کریں",
|
||||
"lnurlMints.search.placeholder": "نام یا پتے سے تلاش کریں…",
|
||||
"lnurlMints.sort.label": "LNURL منٹس ترتیب دیں بلحاظ",
|
||||
"lnurlMints.hideOffline": "آف لائن چھپائیں",
|
||||
"lnurlMints.showing": "{total} LNURL منٹس دکھائے جا رہے ہیں ({online} آن لائن، {offline} آف لائن)",
|
||||
"lnurlMints.showingFiltered": "{total} میں سے {shown} 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 منٹ آپ کے sats رکھتا ہے جبکہ آپ اس کے نوٹ رکھتے ہیں، تو اسے استعمال کرنے کا مطلب ہے اس کے چلانے والے پر بھروسہ کرنا۔ درجہ بندیاں کمیونٹی کی رائے ہیں، اور آن لائن کا مطلب قابلِ رسائی ہے، قابلِ بھروسہ نہیں۔",
|
||||
"lnurl.features.title": "خصوصیات",
|
||||
"lnurl.features.none": "اس منٹ نے اعلان نہیں کیا کہ وہ کیا سہولت دیتا ہے۔",
|
||||
"lnurl.features.note": "خصوصیات وہ ہیں جو منٹ کے چلانے والے نے Nostr پر اعلان کیں۔ جہاں یہ سائٹ HTTPS پر کسی کی تصدیق کر سکتی ہے وہاں کرتی ہے، اور بتاتی ہے جب کوئی اعلان کردہ چیز ابھی دستیاب نہ ہو۔",
|
||||
"lnurl.features.notListed": "اعلان نہیں کیا گیا",
|
||||
"lnurl.features.unavailable": "دستیاب نہیں",
|
||||
"lnurl.feature.mint": "لائٹننگ کے ذریعے منٹ (LUD-06)",
|
||||
"lnurl.feature.melt": "لائٹننگ میں پگھلانا",
|
||||
"lnurl.feature.notes": "نوٹ گھمانا / تقسیم / ملانا",
|
||||
"lnurl.feature.lud16": "لائٹننگ پتہ",
|
||||
"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} sat",
|
||||
"lnurl.limits.sats.other": "{n} sats",
|
||||
"lnurl.details.title": "منٹ کی تفصیلات",
|
||||
"lnurl.details.address": "لائٹننگ پتہ",
|
||||
"lnurl.details.mintPubkey": "منٹ کی عوامی کلید",
|
||||
"lnurl.details.onion": "Tor پتہ",
|
||||
"lnurl.details.fee": "منٹ فیس",
|
||||
"lnurl.details.node": "نوڈ",
|
||||
"lnurl.details.capacity": "اعلان کردہ گنجائش",
|
||||
"lnurl.kind.lightningAddress": "لائٹننگ پتہ",
|
||||
"lnurl.kind.mintPubkey": "منٹ کی عوامی کلید",
|
||||
"lnurl.kind.onionAddress": "Tor پتہ",
|
||||
"lnurl.confirmed.label": "آخری تصدیق",
|
||||
"lnurl.confirmed.when": "{when} تصدیق ہوئی",
|
||||
"lnurl.fee.base.one": "{n} sat",
|
||||
"lnurl.fee.base.other": "{n} sats",
|
||||
"lnurl.fee.percent": "{percent}%",
|
||||
"home.topLnurl.title": "بہترین LNURL منٹس",
|
||||
"home.topLnurl.sub": "جائزوں اور درجہ بندی کے مطابق",
|
||||
"home.topLnurl.all": "تمام {n} LNURL منٹس ←",
|
||||
"feed.ecosystem.lnurl": "LNURL",
|
||||
"reviews.dialog.bodyPlaceholder.lnurl": "منٹ کرنا، پگھلانا اور نوٹ گھمانا کیسا رہا؟ واضح لکھیں۔",
|
||||
"reviews.byUrl.open": "ریویو لکھیں",
|
||||
"reviews.byUrl.title": "ریویو لکھیں",
|
||||
"reviews.byUrl.lede": "پتہ چسپاں کریں۔ اگر یہ سائٹ اسے ابھی نہیں جانتی، تو اسے ابھی جانچ کر شامل کر لیا جائے گا۔",
|
||||
"reviews.byUrl.label.cashu": "mint کا URL",
|
||||
"reviews.byUrl.label.fedimint": "فیڈریشن کا invite code",
|
||||
"reviews.byUrl.label.lnurl": "LNURL mint کا URL",
|
||||
"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": "اس پتے نے {kind} mint کے طور پر جواب دیا۔",
|
||||
"reviews.byUrl.switchTo": "اسے {kind} mint کے طور پر ریویو کریں",
|
||||
"reviews.byUrl.error.empty": "پہلے کوئی پتہ لکھیں۔",
|
||||
"reviews.byUrl.error.badUrl": "یہ mint کے URL جیسا نہیں لگتا۔",
|
||||
"reviews.byUrl.error.badInvite": "یہ Fedimint کا invite code نہیں ہے۔ کوڈ 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} sat۔",
|
||||
"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": "موجودہ نوٹ اب بھی گھمائے، تقسیم یا ملائے جا سکتے ہیں، مگر ابھی لائٹننگ کے ذریعے کچھ اندر یا باہر نہیں جا رہا۔ منٹ خود چل رہا ہے اور جواب دے رہا ہے؛ اس کے پیچھے کا لائٹننگ نوڈ اس سے قابلِ رسائی نہیں۔",
|
||||
"mint.warnings.lnurlNoFunding.meta": "منٹ اور میلٹ دستیاب نہیں",
|
||||
"mint.warnings.also.noFunding": "اس کا لائٹننگ نوڈ بھی ناقابلِ رسائی تھا، اس لیے کچھ بھی منٹ یا میلٹ نہیں کیا جا سکتا تھا۔"
|
||||
}
|
||||
|
||||
+118
-3
@@ -114,6 +114,8 @@
|
||||
"card.reviewed": "Xem lại {when}",
|
||||
"card.noReviews": "chưa xem lại",
|
||||
"mint.title": "{name} · Cashu bình luận mint",
|
||||
"mint.titleFedimint": "{name} · Đánh giá liên minh Fedimint",
|
||||
"mint.titleLnurl": "{name} · Đánh giá mint LNURL",
|
||||
"mint.breadcrumb": "Mảnh bánh mì",
|
||||
"mint.crumbMints": "mint",
|
||||
"mint.copyUrl": "Chép URL mint",
|
||||
@@ -325,7 +327,7 @@
|
||||
"reviews.validate.short": "Viết ít nhất {n} chữ cái nên bài phê bình rất hữu ích.",
|
||||
"reviews.validate.long": "Các bài ôn lại chỉ có {n} chữ cái.",
|
||||
"feed.title": "Canh mint và thức ăn nội thất mới nhất. không gian",
|
||||
"feed.description": "Mỗi bản đánh giá về mint và dinh dưỡng như được xuất bản: {reviews} bài phê bình trên {mints} Kẹo mint và {federations} Fediminations, ký tên trên Nostr và có thể đọc ở đây trước. Lọc theo hệ sinh thái, đánh giá, hay tên.",
|
||||
"feed.description": "Mọi đánh giá về mint, liên minh và mint LNURL ngay khi được đăng: {reviews} đánh giá trên {mints} mint Cashu, {federations} liên minh Fedimint và {lnurl} mint LNURL, được ký trên Nostr và đọc ở đây theo thứ tự mới nhất trước. Lọc theo hệ sinh thái, điểm số hoặc tên.",
|
||||
"feed.heading": "phê bình mới nhất",
|
||||
"feed.lede": "Tất cả các bản đánh giá của tất cả các công ty truyền thông Cashu và Fedimint, đều mới nhất, đọc trực tiếp từ các thông tin liên lạc của Nostr. Mỗi cái được ký bởi tác giả của nó và sống trên các rơ-le, không phải trong cơ sở dữ liệu.",
|
||||
"feed.label": "Xem lại",
|
||||
@@ -400,7 +402,14 @@
|
||||
"notfound.fb.reviews": "Xem lại",
|
||||
"notfound.fb.software": "Phần mềm",
|
||||
"notfound.fb.lastOnline": "trực tuyến cuối cùng",
|
||||
"notfound.fb.note": "Thanh mint này được lập danh mục sau khi trang web được xây dựng lần cuối, nên đây là bản tóm tắt trực tiếp từ ADI. Trang đầy đủ có đánh giá xuất hiện ở lần xây dựng tiếp theo.",
|
||||
"notfound.fb.note": "Mint này được lập chỉ mục sau lần dựng trang gần nhất, nên đây là bản tóm tắt từ API trực tiếp, kèm các đánh giá bên dưới. Trang đầy đủ sẽ xuất hiện ở lần dựng tiếp theo.",
|
||||
"notfound.resolve.checking": "Lần đầu ghé thăm: đang kiểm tra mint này.",
|
||||
"notfound.resolve.failed": "Không thể thêm mint này",
|
||||
"notfound.resolve.failedFedimint": "Không thể thêm liên minh này",
|
||||
"notfound.resolve.retry": "Thử địa chỉ khác",
|
||||
"notfound.resolve.switch": "Mở nó như mint {kind}",
|
||||
"notfound.resolve.fedimint": "Địa chỉ trang của một liên minh không chứa mã mời, nên không thể tra cứu liên minh này chỉ từ đường dẫn. Hãy dán mã mời vào và nó sẽ được thêm.",
|
||||
"notfound.resolve.ambiguous": "Không thể suy ra địa chỉ này chỉ từ đường dẫn. Hãy dán URL đầy đủ của mint và nó sẽ được thêm.",
|
||||
"wallets.title": "Tiền trong ví. không gian",
|
||||
"wallets.description": "Ví tiền làm việc với mint được liệt kê ở đây, qua điện thoại di động, web, trình duyệt và dòng lệnh, với những gì mỗi cái đều tốt.",
|
||||
"wallets.heading": "Comment",
|
||||
@@ -654,5 +663,111 @@
|
||||
"footer.allFedimints": "Mọi liên kết",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "Việc đúc, nấu chảy và hỗ trợ diễn ra thế nào? Cụ thể hơn.",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "Làm thế nào để tham gia, thanh toán Lightning nhoáng và hỗ trợ? Cụ thể hơn.",
|
||||
"fedimint.details.title": "Chi tiết Liên bang"
|
||||
"fedimint.details.title": "Chi tiết Liên bang",
|
||||
"nav.lnurlMints": "Mint LNURL",
|
||||
"footer.allLnurlMints": "Tất cả mint LNURL",
|
||||
"lnurlMints.title": "Mint LNURL: mint phát hành giấy nhận nợ vô danh lnurlcash, có đánh giá | Cashumints.space",
|
||||
"lnurlMints.description": "Toàn bộ {total} mint LNURL được lập chỉ mục từ mạng Nostr: {online} trực tuyến, {offline} ngoại tuyến. Điểm đánh giá của cộng đồng, hạn mức rút và tính năng của từng mint.",
|
||||
"lnurlMints.heading": "Mint LNURL",
|
||||
"lnurlMints.lede": "Mọi mint LNURL mà trang này biết. Một mint LNURL phát hành giấy nhận nợ vô danh lnurlcash: những tờ Lightning bạn giữ như một bí mật, có thể xoay vòng, tách, gộp hoặc nung chảy trở lại thành một khoản thanh toán Lightning. Mỗi mint được kiểm tra qua HTTPS vài phút một lần.",
|
||||
"lnurlMints.search.label": "Tìm mint LNURL",
|
||||
"lnurlMints.search.placeholder": "Tìm theo tên hoặc địa chỉ…",
|
||||
"lnurlMints.sort.label": "Sắp xếp mint LNURL theo",
|
||||
"lnurlMints.hideOffline": "Ẩn mint ngoại tuyến",
|
||||
"lnurlMints.showing": "Đang hiển thị {total} mint LNURL ({online} trực tuyến, {offline} ngoại tuyến)",
|
||||
"lnurlMints.showingFiltered": "Đang hiển thị {shown} trong {total} mint LNURL ({online} trực tuyến, {offline} ngoại tuyến)",
|
||||
"lnurlMints.noResults": "Không có mint LNURL nào khớp.",
|
||||
"lnurlMints.emptyIndex": "Chưa có mint LNURL nào được lập chỉ mục.",
|
||||
"lnurl.crumb": "Mint LNURL",
|
||||
"lnurl.breadcrumb": "Đường dẫn",
|
||||
"lnurl.copyAddress": "Sao chép địa chỉ",
|
||||
"lnurl.more.title": "Thêm mint LNURL",
|
||||
"lnurl.more.all": "Tất cả mint LNURL →",
|
||||
"lnurl.disclaimer": "Mint LNURL giữ sats của bạn trong khi bạn giữ giấy nhận nợ của nó, nên dùng một mint nghĩa là tin tưởng người vận hành. Điểm đánh giá là ý kiến của cộng đồng, và trực tuyến nghĩa là truy cập được, không phải đáng tin cậy.",
|
||||
"lnurl.features.title": "Tính năng",
|
||||
"lnurl.features.none": "Mint này chưa công bố nó hỗ trợ những gì.",
|
||||
"lnurl.features.note": "Tính năng là những gì người vận hành mint đã công bố trên Nostr. Chỗ nào trang này kiểm tra được qua HTTPS thì sẽ kiểm tra, và sẽ nói rõ khi một tính năng đã công bố hiện không dùng được.",
|
||||
"lnurl.features.notListed": "chưa công bố",
|
||||
"lnurl.features.unavailable": "không dùng được",
|
||||
"lnurl.feature.mint": "Mint qua Lightning (LUD-06)",
|
||||
"lnurl.feature.melt": "Nung chảy sang Lightning",
|
||||
"lnurl.feature.notes": "Xoay vòng / tách / gộp giấy nhận nợ",
|
||||
"lnurl.feature.lud16": "Địa chỉ Lightning",
|
||||
"lnurl.feature.lud21": "Xác minh thanh toán (LUD-21)",
|
||||
"lnurl.feature.signed-notes": "Giấy nhận nợ có chữ ký (xác minh ngoại tuyến)",
|
||||
"lnurl.feature.onion": "Địa chỉ Tor",
|
||||
"lnurl.feature.rotate": "Xoay vòng một tờ",
|
||||
"lnurl.feature.split": "Tách một tờ",
|
||||
"lnurl.feature.merge": "Gộp các tờ",
|
||||
"lnurl.feature.lud06": "LUD-06 payRequest",
|
||||
"lnurl.feature.lud03": "LUD-03 withdrawRequest",
|
||||
"lnurl.limits.label": "Hạn mức rút",
|
||||
"lnurl.limits.notPublished": "chưa công bố",
|
||||
"lnurl.limits.withdrawRow": "Rút",
|
||||
"lnurl.limits.disabled": "đã tắt",
|
||||
"lnurl.limits.sats.one": "{n} sat",
|
||||
"lnurl.limits.sats.other": "{n} sat",
|
||||
"lnurl.details.title": "Chi tiết mint",
|
||||
"lnurl.details.address": "Địa chỉ Lightning",
|
||||
"lnurl.details.mintPubkey": "Khóa công khai của mint",
|
||||
"lnurl.details.onion": "Địa chỉ Tor",
|
||||
"lnurl.details.fee": "Phí mint",
|
||||
"lnurl.details.node": "Nút",
|
||||
"lnurl.details.capacity": "Dung lượng đã công bố",
|
||||
"lnurl.kind.lightningAddress": "địa chỉ Lightning",
|
||||
"lnurl.kind.mintPubkey": "khóa công khai của mint",
|
||||
"lnurl.kind.onionAddress": "địa chỉ Tor",
|
||||
"lnurl.confirmed.label": "Xác nhận lần cuối",
|
||||
"lnurl.confirmed.when": "xác nhận {when}",
|
||||
"lnurl.fee.base.one": "{n} sat",
|
||||
"lnurl.fee.base.other": "{n} sat",
|
||||
"lnurl.fee.percent": "{percent}%",
|
||||
"home.topLnurl.title": "Mint LNURL hàng đầu",
|
||||
"home.topLnurl.sub": "xếp hạng theo đánh giá và điểm số",
|
||||
"home.topLnurl.all": "Tất cả {n} mint LNURL →",
|
||||
"feed.ecosystem.lnurl": "LNURL",
|
||||
"reviews.dialog.bodyPlaceholder.lnurl": "Việc mint, nung chảy và xoay vòng giấy nhận nợ diễn ra thế nào? Hãy nói cụ thể.",
|
||||
"reviews.byUrl.open": "Viết đánh giá",
|
||||
"reviews.byUrl.title": "Viết đánh giá",
|
||||
"reviews.byUrl.lede": "Dán địa chỉ vào đây. Nếu trang này chưa biết địa chỉ đó, nó sẽ được kiểm tra và thêm vào ngay.",
|
||||
"reviews.byUrl.label.cashu": "URL của mint",
|
||||
"reviews.byUrl.label.fedimint": "Mã mời của liên minh",
|
||||
"reviews.byUrl.label.lnurl": "URL của mint LNURL",
|
||||
"reviews.byUrl.placeholder.cashu": "mint.example.com",
|
||||
"reviews.byUrl.placeholder.fedimint": "fed11…",
|
||||
"reviews.byUrl.placeholder.lnurl": "mint.example.com",
|
||||
"reviews.byUrl.check": "Kiểm tra địa chỉ này",
|
||||
"reviews.byUrl.checking": "Đang kiểm tra…",
|
||||
"reviews.byUrl.back": "Dùng địa chỉ khác",
|
||||
"reviews.byUrl.existing": "Đã có trong danh sách",
|
||||
"reviews.byUrl.indexed": "Vừa được thêm",
|
||||
"reviews.byUrl.confirmClose": "Đóng lại? Địa chỉ bạn vừa gõ sẽ không được giữ.",
|
||||
"reviews.byUrl.detected": "Địa chỉ đó trả lời như một mint {kind}.",
|
||||
"reviews.byUrl.switchTo": "Đánh giá nó như mint {kind}",
|
||||
"reviews.byUrl.error.empty": "Hãy nhập địa chỉ trước.",
|
||||
"reviews.byUrl.error.badUrl": "Cái đó trông không giống URL của một mint.",
|
||||
"reviews.byUrl.error.badInvite": "Đó không phải mã mời Fedimint. Mã bắt đầu bằng fed11.",
|
||||
"reviews.byUrl.error.badInput": "Trang này không thể kiểm tra địa chỉ đó.",
|
||||
"reviews.byUrl.error.blocked": "Đó không phải địa chỉ công khai, nên không thể kiểm tra.",
|
||||
"reviews.byUrl.error.notAMint": "Địa chỉ đó có trả lời, nhưng không phải với tư cách một mint loại này.",
|
||||
"reviews.byUrl.error.wrongType": "Địa chỉ đó có trả lời, nhưng là một loại mint khác.",
|
||||
"reviews.byUrl.error.unverifiable": "Không có gì trả lời ở địa chỉ đó, và trên Nostr cũng chưa có dấu vết nào.",
|
||||
"reviews.byUrl.error.rateLimited.one": "Có quá nhiều lượt kiểm tra từ một kết nối. Thử lại sau khoảng {n} phút.",
|
||||
"reviews.byUrl.error.rateLimited.other": "Có quá nhiều lượt kiểm tra từ một kết nối. Thử lại sau khoảng {n} phút.",
|
||||
"reviews.byUrl.error.network": "Không liên hệ được với chỉ mục. Kiểm tra kết nối rồi thử lại.",
|
||||
"seo.lnurl.titleNoReviews": "{name}: thông tin và đánh giá mint LNURL | Cashumints.space",
|
||||
"seo.desc.statusWithdraw": "{status}, rút {min}–{max} sat.",
|
||||
"mint.chip.noFunding": "Không mint / nung chảy",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.lead": "Đã tắt rút tiền.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.body.online": "Mint này công bố mức rút tối đa bằng không, nên không tờ nào nó phát hành có thể đổi lấy bất cứ thứ gì. Đừng mint ở đây cho tới khi điều đó thay đổi.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.body.offline": "Lần cuối liên hệ được, mint này công bố mức rút tối đa bằng không, nên không tờ nào nó phát hành có thể đổi lấy bất cứ thứ gì. Đừng mint ở đây cho tới khi điều đó thay đổi.",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.online": "Hiện đã tắt rút tiền",
|
||||
"mint.warnings.lnurlWithdrawalsDisabled.meta.offline": "Rút tiền đã tắt ở lần nhìn thấy cuối cùng",
|
||||
"mint.warnings.lnurlInvalid.lead": "Endpoint có phản hồi nhưng không hợp lệ.",
|
||||
"mint.warnings.lnurlInvalid.body": "Máy chủ có phản hồi, nhưng không phải bằng một công bố mint mà trang này đọc được, nên việc rút có thể không hoạt động. Mọi thứ bên dưới là trạng thái cuối cùng đọc được, và phần đánh giá vẫn hoạt động.",
|
||||
"mint.warnings.lnurlInvalid.meta": "Có phản hồi, nhưng không phải công bố mint hợp lệ",
|
||||
"mint.warnings.lnurlNoFunding.lead": "Không thể mint và nung chảy.",
|
||||
"mint.warnings.lnurlNoFunding.body": "Các tờ hiện có vẫn xoay vòng, tách hoặc gộp được, nhưng hiện tại không có gì ra vào qua Lightning. Bản thân mint vẫn chạy và phản hồi; nút Lightning phía sau nó thì không truy cập được từ đó.",
|
||||
"mint.warnings.lnurlNoFunding.meta": "Không thể mint và nung chảy",
|
||||
"mint.warnings.also.noFunding": "Nút Lightning của nó cũng không truy cập được, nên không thể mint hay nung chảy gì."
|
||||
}
|
||||
|
||||
+118
-3
@@ -114,6 +114,8 @@
|
||||
"card.reviewed": "已审核 {when}",
|
||||
"card.noReviews": "尚无评论",
|
||||
"mint.title": "{name} · Cashu 铸币服务 评测",
|
||||
"mint.titleFedimint": "{name} · Fedimint 联邦评价",
|
||||
"mint.titleLnurl": "{name} · LNURL mint 评价",
|
||||
"mint.breadcrumb": "面包屑导航",
|
||||
"mint.crumbMints": "Cashu 铸币服务",
|
||||
"mint.copyUrl": "复制 铸币服务 的网址",
|
||||
@@ -325,7 +327,7 @@
|
||||
"reviews.validate.short": "请至少输入 {n} 个字符,以便该评论对他人有所帮助。",
|
||||
"reviews.validate.long": "评论长度限制为 {n} 个字符。",
|
||||
"feed.title": "最新的 铸币服务 和联盟评测 · Cashumints.space",
|
||||
"feed.description": "每篇关于 铸币服务 及联盟的评测一经发布即收录: 涵盖 {mints}、Cashu、铸币服务、{federations} 及 Fedimint 联盟的 {reviews} 评测,已在 Nostr 上签署,可在此按最新顺序阅读。可按生态系统、评分或名称进行筛选。",
|
||||
"feed.description": "每一条铸造厂、联邦和 LNURL 铸造厂的评价一经发布即在此呈现:涵盖 {mints} 个 Cashu 铸造厂、{federations} 个 Fedimint 联邦和 {lnurl} 个 LNURL 铸造厂的 {reviews} 条评价,在 Nostr 上签名,按最新在前排列。可按生态、评分或名称筛选。",
|
||||
"feed.heading": "最新评论",
|
||||
"feed.lede": "所有关于 Cashu、铸币服务 和 Fedimint 联盟的评论(按最新顺序排列),均实时读取自 Nostr 中继服务器。每条评论均由其作者签名,并存储在中继服务器上,而非我们的数据库中。",
|
||||
"feed.label": "评论",
|
||||
@@ -400,7 +402,14 @@
|
||||
"notfound.fb.reviews": "评论",
|
||||
"notfound.fb.software": "软件",
|
||||
"notfound.fb.lastOnline": "最后一次上线",
|
||||
"notfound.fb.note": "该 铸币服务 是在网站上次重建后被收录的,因此这是来自实时 API 的摘要。包含评论的完整页面将在下次重建时显示。",
|
||||
"notfound.fb.note": "这个 mint 是在本站最近一次构建之后收录的,所以这里是来自实时 API 的摘要,下面是它的评价。完整页面会在下次构建时出现。",
|
||||
"notfound.resolve.checking": "第一次访问:正在检查这个 mint。",
|
||||
"notfound.resolve.failed": "无法收录这个 mint",
|
||||
"notfound.resolve.failedFedimint": "无法收录这个联邦",
|
||||
"notfound.resolve.retry": "试另一个地址",
|
||||
"notfound.resolve.switch": "作为 {kind} mint 打开",
|
||||
"notfound.resolve.fedimint": "联邦的页面地址不包含 invite code,所以仅凭链接找不到它。粘贴 invite code,它就会被收录。",
|
||||
"notfound.resolve.ambiguous": "仅凭链接推不出这个地址。粘贴 mint 的完整 URL,它就会被收录。",
|
||||
"wallets.title": "Cashu 钱包 · Cashumints.space",
|
||||
"wallets.description": "Cashu 钱包可与此处列出的 铸币服务 配合使用,支持移动端、网页端、浏览器和命令行等多种平台,各平台均发挥其独特优势。",
|
||||
"wallets.heading": "钱包",
|
||||
@@ -654,5 +663,111 @@
|
||||
"footer.allFedimints": "所有联合会",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "铸造电子现金、赎回电子现金 以及相关支持服务的使用情况如何?请具体说明。",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "加入Lightning后,支付和支持方面的情况如何?请具体说明。",
|
||||
"fedimint.details.title": "联合会详情"
|
||||
"fedimint.details.title": "联合会详情",
|
||||
"nav.lnurlMints": "LNURL 铸造厂",
|
||||
"footer.allLnurlMints": "所有 LNURL 铸造厂",
|
||||
"lnurlMints.title": "LNURL 铸造厂:lnurlcash 不记名票据铸造厂评价 | Cashumints.space",
|
||||
"lnurlMints.description": "从 Nostr 网络索引到的全部 {total} 个 LNURL 铸造厂:{online} 个在线,{offline} 个离线。每个都附有社区评分、提现额度和功能。",
|
||||
"lnurlMints.heading": "LNURL 铸造厂",
|
||||
"lnurlMints.lede": "本站已知的每一个 LNURL 铸造厂。LNURL 铸造厂发行 lnurlcash 不记名票据:以密钥形式持有的闪电票据,可以轮换、拆分、合并,或熔回成一笔闪电付款。每个铸造厂每隔几分钟通过 HTTPS 检查一次。",
|
||||
"lnurlMints.search.label": "搜索 LNURL 铸造厂",
|
||||
"lnurlMints.search.placeholder": "按名称或地址搜索…",
|
||||
"lnurlMints.sort.label": "LNURL 铸造厂排序方式",
|
||||
"lnurlMints.hideOffline": "隐藏离线",
|
||||
"lnurlMints.showing": "显示 {total} 个 LNURL 铸造厂({online} 个在线,{offline} 个离线)",
|
||||
"lnurlMints.showingFiltered": "显示 {total} 个中的 {shown} 个 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": "通过闪电铸造(LUD-06)",
|
||||
"lnurl.feature.melt": "熔回闪电",
|
||||
"lnurl.feature.notes": "轮换 / 拆分 / 合并票据",
|
||||
"lnurl.feature.lud16": "闪电地址",
|
||||
"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.other": "{n} 聪",
|
||||
"lnurl.details.title": "铸造厂详情",
|
||||
"lnurl.details.address": "闪电地址",
|
||||
"lnurl.details.mintPubkey": "铸造厂公钥",
|
||||
"lnurl.details.onion": "Tor 地址",
|
||||
"lnurl.details.fee": "铸造手续费",
|
||||
"lnurl.details.node": "节点",
|
||||
"lnurl.details.capacity": "已公告容量",
|
||||
"lnurl.kind.lightningAddress": "闪电地址",
|
||||
"lnurl.kind.mintPubkey": "铸造厂公钥",
|
||||
"lnurl.kind.onionAddress": "Tor 地址",
|
||||
"lnurl.confirmed.label": "最后确认",
|
||||
"lnurl.confirmed.when": "{when}确认",
|
||||
"lnurl.fee.base.one": "{n} 聪",
|
||||
"lnurl.fee.base.other": "{n} 聪",
|
||||
"lnurl.fee.percent": "{percent}%",
|
||||
"home.topLnurl.title": "热门 LNURL 铸造厂",
|
||||
"home.topLnurl.sub": "按评价数和评分排名",
|
||||
"home.topLnurl.all": "全部 {n} 个 LNURL 铸造厂 →",
|
||||
"feed.ecosystem.lnurl": "LNURL",
|
||||
"reviews.dialog.bodyPlaceholder.lnurl": "铸造、熔回和轮换票据的体验如何?请具体说明。",
|
||||
"reviews.byUrl.open": "写一条评价",
|
||||
"reviews.byUrl.title": "写一条评价",
|
||||
"reviews.byUrl.lede": "粘贴地址。如果本站还不认识它,现在就会检查并收录。",
|
||||
"reviews.byUrl.label.cashu": "mint 的 URL",
|
||||
"reviews.byUrl.label.fedimint": "联邦的 invite code",
|
||||
"reviews.byUrl.label.lnurl": "LNURL mint 的 URL",
|
||||
"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": "该地址以 {kind} mint 的身份作出了回应。",
|
||||
"reviews.byUrl.switchTo": "作为 {kind} mint 来评价",
|
||||
"reviews.byUrl.error.empty": "请先输入地址。",
|
||||
"reviews.byUrl.error.badUrl": "这看起来不像 mint 的 URL。",
|
||||
"reviews.byUrl.error.badInvite": "这不是 Fedimint 的 invite code。代码以 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": "已有票据仍可轮换、拆分或合并,但目前没有任何资金能通过闪电进出。铸造厂本身正常运行并有响应;它背后的闪电节点从它那里无法访问。",
|
||||
"mint.warnings.lnurlNoFunding.meta": "无法铸造和熔回",
|
||||
"mint.warnings.also.noFunding": "它的闪电节点当时同样无法访问,因此既不能铸造也不能熔回。"
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import '../styles/global.css';
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
current?: 'mints' | 'fedimints' | 'reviews' | 'wallets' | 'about';
|
||||
current?: 'mints' | 'fedimints' | 'lnurl-mints' | 'reviews' | 'wallets' | 'about';
|
||||
mintCount?: number;
|
||||
ogType?: string;
|
||||
/**
|
||||
|
||||
+32
-4
@@ -1,4 +1,7 @@
|
||||
import type { FedimintDetail, Health, MintDetail, MintListItem, Stats } from '@cashumints/shared';
|
||||
import { ECOSYSTEMS } from '@cashumints/shared';
|
||||
import type {
|
||||
FedimintDetail, Health, LnurlDetail, MintDetail, MintListItem, Stats,
|
||||
} from '@cashumints/shared';
|
||||
|
||||
/**
|
||||
* Build-time API base. Islands use PUBLIC_API_BASE (see api-client.ts) so the browser
|
||||
@@ -18,9 +21,9 @@ async function get<T>(path: string): Promise<T> {
|
||||
*
|
||||
* `fetchMints` asks for `type=cashu` rather than for everything, which is what keeps
|
||||
* /mints, the mint pages, the home page grid and the sitemap's mint entries meaning
|
||||
* exactly what they meant before federations were indexed. Federations are their own
|
||||
* call, their own pages and their own count; nothing has to filter a mixed list and
|
||||
* nothing silently grew.
|
||||
* exactly what they meant before federations were indexed. Each ecosystem is its own
|
||||
* call, its own pages and its own count; nothing has to filter a mixed list and nothing
|
||||
* silently grew when a second or a third was added.
|
||||
*/
|
||||
export const fetchMints = (): Promise<MintListItem[]> => get<MintListItem[]>('/api/mints?type=cashu');
|
||||
export const fetchFedimints = (): Promise<MintListItem[]> =>
|
||||
@@ -30,8 +33,33 @@ export const fetchMint = (host: string): Promise<MintDetail> =>
|
||||
/** The same endpoint, typed for a federation's flattened fields. */
|
||||
export const fetchFedimint = (host: string): Promise<FedimintDetail> =>
|
||||
get<FedimintDetail>(`/api/mints/${encodeURIComponent(host)}`);
|
||||
export const fetchLnurlMints = (): Promise<MintListItem[]> =>
|
||||
get<MintListItem[]>('/api/mints?type=lnurl');
|
||||
/** The same endpoint again, typed for an LNURL mint's flattened fields. */
|
||||
export const fetchLnurlMint = (host: string): Promise<LnurlDetail> =>
|
||||
get<LnurlDetail>(`/api/mints/${encodeURIComponent(host)}`);
|
||||
export const fetchStats = (): Promise<Stats> => get<Stats>('/api/stats');
|
||||
|
||||
/**
|
||||
* Every ecosystem's listing, in `ECOSYSTEMS` order, fetched together.
|
||||
*
|
||||
* For the pages that are about *all* of them rather than one — /reviews and the home
|
||||
* page's review strip — where naming each ecosystem's fetch by hand is exactly the kind
|
||||
* of list that gets one entry short. The filter pills on /reviews are already built from
|
||||
* `ECOSYSTEMS`, so before this existed a new ecosystem got a pill that could never match
|
||||
* anything: the pill came from that list and the data did not. This closes that gap, and
|
||||
* a fourth ecosystem now needs no edit on either side.
|
||||
*
|
||||
* One request per ecosystem rather than a single unfiltered `/api/mints`, deliberately:
|
||||
* the per-type responses are what every other page already asks for, so they are warm in
|
||||
* the API's own path, and a caller gets them back already grouped.
|
||||
*/
|
||||
export async function fetchAllListings(): Promise<MintListItem[][]> {
|
||||
return Promise.all(
|
||||
ECOSYSTEMS.map((type) => get<MintListItem[]>(`/api/mints?type=${encodeURIComponent(type)}`)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Health is allowed to be down at build time: the ticker just renders without it. */
|
||||
export async function fetchHealth(): Promise<Health | null> {
|
||||
try {
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
* there is nothing to translate in a lookup anyway.
|
||||
*/
|
||||
import {
|
||||
federationIdFromKey, federationIdFromSlug, mintUrlsFromEvent, normalizeMintUrl,
|
||||
reviewEcosystem, reviewTargetId, type MintListItem, type NostrEventLike,
|
||||
baseUrlFromKey, federationIdFromKey, federationIdFromSlug, hostIdentifier, mintUrlsFromEvent,
|
||||
normalizeMintUrl, reviewEcosystem, reviewTargetId,
|
||||
type MintListItem, type NostrEventLike,
|
||||
} from '@cashumints/shared';
|
||||
import { displayDomain } from './format';
|
||||
|
||||
@@ -30,13 +31,14 @@ export interface FeedTarget {
|
||||
name: string;
|
||||
/** The label a card prints: a domain, or a shortened federation id. */
|
||||
domain: string;
|
||||
/** `cashu` or `fedimint`. Picks both the resolver and the link's route. */
|
||||
/** `cashu`, `fedimint` or `lnurl`. Picks both the resolver and the link's route. */
|
||||
type: string;
|
||||
/**
|
||||
* The `d` identifier reviews of this thing carry, when that is how it is found.
|
||||
*
|
||||
* A federation id. null for a mint, which is resolved through its `u` tag instead —
|
||||
* that is how every legacy review points at one, and changing it would strand them.
|
||||
* A federation id, or an LNURL mint's normalized host. null for a Cashu mint, which is
|
||||
* resolved through its `u` tag instead — that is how every legacy review points at
|
||||
* one, and changing it would strand them.
|
||||
*/
|
||||
ref: string | null;
|
||||
}
|
||||
@@ -74,6 +76,29 @@ export function resolveFeedTarget(event: NostrEventLike, index: FeedIndex): Feed
|
||||
return d ? index.byRef.get(d.toLowerCase()) ?? null : null;
|
||||
}
|
||||
|
||||
/*
|
||||
* LNURL resolves by `d` and then by `u`, because both are genuinely used.
|
||||
*
|
||||
* The `d` a review carries is either the mint's funding-node pubkey or its host, and
|
||||
* the feed index only knows the host — the list payload carries no pubkey. So a
|
||||
* pubkey-`d` review falls through to `u`, which an LNURL review can actually be
|
||||
* resolved by: unlike a federation's opaque invite code, it is a real URL that the
|
||||
* same normalizer the Cashu branch uses can turn into a row key.
|
||||
*/
|
||||
if (ecosystem === 'lnurl') {
|
||||
const d = reviewTargetId(event);
|
||||
const byRef = d ? index.byRef.get(d.trim().toLowerCase()) : undefined;
|
||||
if (byRef) return byRef;
|
||||
|
||||
for (const raw of mintUrlsFromEvent(event)) {
|
||||
const normalized = normalizeMintUrl(raw);
|
||||
if (!normalized) continue;
|
||||
const found = index.byUrl.get(`lnurl:${normalized.url}`);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ecosystem !== 'cashu') return null;
|
||||
|
||||
const raw = mintUrlsFromEvent(event)[0];
|
||||
@@ -83,9 +108,21 @@ export function resolveFeedTarget(event: NostrEventLike, index: FeedIndex): Feed
|
||||
return index.byUrl.get(normalized.url) ?? null;
|
||||
}
|
||||
|
||||
/** The page a card links to. One route per ecosystem, decided in one place. */
|
||||
/**
|
||||
* The page a card links to. One route per ecosystem, decided in one place.
|
||||
*
|
||||
* The default is `/mint/`, not the newest ecosystem: a row of some type this build has
|
||||
* never heard of is far more likely to be an older one, and every row written before
|
||||
* `mints.type` existed means Cashu.
|
||||
*/
|
||||
const ROUTES: Record<string, string> = {
|
||||
fedimint: '/fedimint',
|
||||
lnurl: '/lnurl-mint',
|
||||
cashu: '/mint',
|
||||
};
|
||||
|
||||
export function targetPath(target: Pick<FeedTarget, 'type' | 'host'>): string {
|
||||
return `/${target.type === 'fedimint' ? 'fedimint' : 'mint'}/${target.host}`;
|
||||
return `${ROUTES[target.type] ?? '/mint'}/${target.host}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,14 +140,27 @@ export function targetPath(target: Pick<FeedTarget, 'type' | 'host'>): string {
|
||||
export function feedTargets(...lists: Array<readonly MintListItem[]>): FeedTarget[] {
|
||||
return lists.flat().map((item) => {
|
||||
const federationId = federationIdFromKey(item.url);
|
||||
const domain = federationId ? federationIdFromSlug(item.host) : displayDomain(item.url);
|
||||
const lnurlBase = baseUrlFromKey(item.url);
|
||||
|
||||
/*
|
||||
* An LNURL row's key is `lnurl:https://host`, so the label has to come out of the
|
||||
* key rather than off `item.url` directly — `displayDomain` on the raw key would
|
||||
* print the scheme prefix at a reader. Its `ref` is the host identifier, which is
|
||||
* the `d` a review carries whenever the mint has no funding-node pubkey; reviews
|
||||
* that carry the pubkey instead resolve through `u`, which is why this does not
|
||||
* need one.
|
||||
*/
|
||||
const domain = federationId
|
||||
? federationIdFromSlug(item.host)
|
||||
: displayDomain(lnurlBase ?? item.url);
|
||||
|
||||
return {
|
||||
url: item.url,
|
||||
host: item.host,
|
||||
name: item.name ?? domain.split('/')[0] ?? domain,
|
||||
domain,
|
||||
type: item.type,
|
||||
ref: federationId,
|
||||
ref: federationId ?? (lnurlBase ? hostIdentifier(lnurlBase) : null),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* The browser half of on-demand indexing.
|
||||
*
|
||||
* One request — `POST /api/index` — with the two callers that make it: the 404
|
||||
* resolver, which asks on the reader's behalf when they open a deep link this build has
|
||||
* never heard of, and the review-by-URL dialog, where they ask deliberately.
|
||||
*
|
||||
* Both need the same three things and neither should reimplement them: the request, a
|
||||
* plain sentence for every way it can fail, and the `ReviewSubject` the review dialog
|
||||
* publishes against. The failure vocabulary itself is `@cashumints/shared`, so the API
|
||||
* and this file cannot drift about what `wrong_type` means.
|
||||
*/
|
||||
import {
|
||||
fedimintSubject,
|
||||
lnurlSubject,
|
||||
mintSubject,
|
||||
type ReviewSubject,
|
||||
} from './review-subject';
|
||||
import { apiBase } from './client';
|
||||
import type { Translator } from '../i18n/translate';
|
||||
import {
|
||||
isIndexFailure,
|
||||
type IndexFailure,
|
||||
type IndexSuccess,
|
||||
type IndexType,
|
||||
type FedimintDetail,
|
||||
type LnurlDetail,
|
||||
type MintDetail,
|
||||
} from '@cashumints/shared';
|
||||
|
||||
/**
|
||||
* What came back.
|
||||
*
|
||||
* The HTTP status is kept beside the body rather than collapsed into a boolean, because
|
||||
* 200 and 201 mean visibly different things to a reader — "already listed" against "we
|
||||
* just checked it for you" — and the dialog says so.
|
||||
*/
|
||||
export type IndexResult =
|
||||
| { ok: true; status: number; mint: IndexSuccess }
|
||||
| { ok: false; status: number; failure: IndexFailure };
|
||||
|
||||
/** A network failure, shaped like every other failure so callers have one path. */
|
||||
function offline(): IndexResult {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
failure: { error: 'invalid_response', message: 'the API could not be reached' },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the API to index an address, and wait for the answer.
|
||||
*
|
||||
* There is deliberately no client-side timeout. The endpoint bounds itself — one probe
|
||||
* at the standard five seconds, then at most three more for the relay lookup — and a
|
||||
* shorter timer here would abandon requests that are about to succeed while leaving the
|
||||
* server doing the work anyway. The caller shows a "checking" state and means it.
|
||||
*/
|
||||
export async function requestIndex(type: IndexType, input: string): Promise<IndexResult> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${apiBase}/api/index`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify({ type, input }),
|
||||
});
|
||||
} catch {
|
||||
return offline();
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = (await res.json()) as unknown;
|
||||
} catch {
|
||||
return offline();
|
||||
}
|
||||
|
||||
if (!body || typeof body !== 'object') return offline();
|
||||
|
||||
if (res.ok && !isIndexFailure(body as IndexSuccess | IndexFailure)) {
|
||||
return { ok: true, status: res.status, mint: body as IndexSuccess };
|
||||
}
|
||||
return { ok: false, status: res.status, failure: body as IndexFailure };
|
||||
}
|
||||
|
||||
/**
|
||||
* The sentence a reader gets for a failure.
|
||||
*
|
||||
* Plain words, and specific: "nothing responded at this address and no trace of it
|
||||
* exists on Nostr yet" is a different situation from "that address answered, but not as
|
||||
* an LNURL mint", and a reader can act on the difference — the first means checking the
|
||||
* spelling, the second means picking a different page. A generic "could not be added"
|
||||
* would hide both.
|
||||
*
|
||||
* `wrong_type` is handled by the caller rather than here, because the useful response to
|
||||
* it is a button, not a sentence.
|
||||
*/
|
||||
export function indexErrorMessage(result: { status: number; failure: IndexFailure }, t: Translator): string {
|
||||
const { failure, status } = result;
|
||||
|
||||
switch (failure.error) {
|
||||
case 'bad_input':
|
||||
return t('reviews.byUrl.error.badInput');
|
||||
case 'bad_type':
|
||||
return t('reviews.byUrl.error.badInput');
|
||||
case 'blocked_host':
|
||||
return t('reviews.byUrl.error.blocked');
|
||||
case 'invalid_invite':
|
||||
return t('reviews.byUrl.error.badInvite');
|
||||
case 'invalid_response':
|
||||
return status === 0
|
||||
? t('reviews.byUrl.error.network')
|
||||
: t('reviews.byUrl.error.notAMint');
|
||||
case 'unverifiable':
|
||||
return t('reviews.byUrl.error.unverifiable');
|
||||
case 'rate_limited':
|
||||
return t('reviews.byUrl.error.rateLimited', { n: retryMinutes(failure) });
|
||||
case 'wrong_type':
|
||||
return t('reviews.byUrl.error.wrongType');
|
||||
default:
|
||||
return t('reviews.byUrl.error.network');
|
||||
}
|
||||
}
|
||||
|
||||
/** A `retry_after` in whole minutes, at least one, for the "try again in…" sentence. */
|
||||
function retryMinutes(failure: IndexFailure): number {
|
||||
return Math.max(1, Math.ceil((failure.retry_after ?? 3600) / 60));
|
||||
}
|
||||
|
||||
/**
|
||||
* The review subject for an indexed mint, whichever ecosystem it belongs to.
|
||||
*
|
||||
* The same three builders the three page templates use, chosen by the payload's own
|
||||
* `type` rather than by what the caller asked for — a submission can come back as a
|
||||
* different ecosystem than it went out as (that is what `detected_type` is for), and
|
||||
* the row that exists is the one the review has to point at.
|
||||
*/
|
||||
export function subjectFor(mint: MintDetail): ReviewSubject | null {
|
||||
if (mint.type === 'fedimint') return fedimintSubject(mint as FedimintDetail);
|
||||
if (mint.type === 'lnurl') return lnurlSubject(mint as LnurlDetail);
|
||||
if (mint.type === 'cashu') return mintSubject(mint);
|
||||
// A type this build has no review kind for. Nothing here can point a review at it.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name to show for a mint, falling back the way every card does.
|
||||
*
|
||||
* A federation usually has one from its announcement; one submitted as a bare invite
|
||||
* code has nothing but its id, and its shortened id is then both its name and its
|
||||
* address. Callers that render both lines drop the second when they match, rather than
|
||||
* printing the same string twice.
|
||||
*/
|
||||
export function mintDisplayName(mint: MintDetail): string {
|
||||
const domain = displayAddress(mint);
|
||||
return mint.name ?? domain.split('/')[0] ?? domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* The address to print under that name.
|
||||
*
|
||||
* A federation has no URL — its key is `fedimint:<id>` — so it shows its id instead,
|
||||
* shortened the way the federation pages shorten it. Everything else shows the address
|
||||
* a reader would type.
|
||||
*/
|
||||
export function displayAddress(mint: MintDetail): string {
|
||||
if (mint.type === 'fedimint') {
|
||||
const id = mint.federation_id ?? mint.host;
|
||||
return id.length > 24 ? `${id.slice(0, 16)}…${id.slice(-6)}` : id;
|
||||
}
|
||||
return mint.url.replace(/^lnurl:/, '').replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* The LNURL mint page's Features panel and withdraw-limits cell, as HTML strings.
|
||||
*
|
||||
* The Cashu counterparts are `nutRowsHtml` and `limitsHtml` in mint-state.ts, and the
|
||||
* Fedimint one is `moduleRowsHtml` in fedimint-ui.ts. This is deliberately their twin:
|
||||
* same three-column row, same check, same grey "not published", same classes, so a
|
||||
* reader who has learned to scan one panel can scan the others without relearning them
|
||||
* and there is no second stylesheet.
|
||||
*
|
||||
* Built as strings rather than as markup in the page for the same reason those files
|
||||
* give: two things render this panel — the page at build time from the last probe, and
|
||||
* the live island from a fresh read of the API — and two implementations would drift.
|
||||
*
|
||||
* Where it differs from both, and why:
|
||||
*
|
||||
* - **Three states, not two.** The Fedimint panel has no disabled state because a
|
||||
* `modules` tag says nothing about whether a module is switched on. Here there
|
||||
* genuinely is a third state: a mint can publish `mint` and `melt`, and still be
|
||||
* unable to do either right now because the Lightning node behind it is unreachable.
|
||||
* That is checkable over HTTP, so the row says "unavailable" rather than showing a
|
||||
* tick beside something that would fail if tried.
|
||||
* - **`features` is never rewritten.** The announcement is the operator's claim about
|
||||
* what they built; the probe can only mark a claimed capability unavailable, never
|
||||
* add or remove one. That asymmetry is normative in `docs/KIND-LNURL-MINT.md`, and
|
||||
* `featureStates` in shared/ is where it is enforced.
|
||||
*/
|
||||
import {
|
||||
FEATURE_NAMES_EN, HIGHLIGHT_FEATURES, featureStates, msatToSat,
|
||||
type FeatureState, type HighlightFeature,
|
||||
} from '@cashumints/shared';
|
||||
import type { Formatters } from '../i18n/format';
|
||||
|
||||
const CHECK_ICON =
|
||||
'<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"/></svg>';
|
||||
|
||||
const PAUSE_ICON =
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
||||
'stroke-width="2.5" aria-hidden="true"><path d="M10 5v14M16 5v14"/></svg>';
|
||||
|
||||
function escape(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* A feature's plain-language name in this locale.
|
||||
*
|
||||
* Same rule as `localNutName` and `localModuleName`: the catalog first, `shared`'s
|
||||
* English table second, and the published token last. A mint advertising something this
|
||||
* build has never heard of renders as `quantum-notes` rather than as a guess, which is
|
||||
* still a true label.
|
||||
*/
|
||||
export function localFeatureName(feature: string, f: Formatters): string {
|
||||
const key = `lnurl.feature.${feature}`;
|
||||
if (f.t.has(key)) return f.t(key);
|
||||
return FEATURE_NAMES_EN[feature] ?? feature;
|
||||
}
|
||||
|
||||
/**
|
||||
* The short mono label in the first column.
|
||||
*
|
||||
* The LUD numbers are spec names and are the same in every language, so they are shown
|
||||
* literally. The note operations have no such name, and the column shows the vocabulary
|
||||
* token itself — which is exactly what a publisher would put in a `features` tag, and so
|
||||
* is the string worth being able to recognise.
|
||||
*/
|
||||
const ROW_TOKENS: Record<HighlightFeature, string> = {
|
||||
mint: 'mint',
|
||||
melt: 'melt',
|
||||
notes: 'notes',
|
||||
lud16: 'LUD-16',
|
||||
lud21: 'LUD-21',
|
||||
'signed-notes': 'signed',
|
||||
onion: 'onion',
|
||||
};
|
||||
|
||||
/**
|
||||
* The seven named rows.
|
||||
*
|
||||
* `fundingAvailable` is null when nothing has probed the mint yet, which reads as "no
|
||||
* reason to doubt the announcement" rather than as a failure — a mint discovered
|
||||
* minutes ago should not have its capabilities greyed out for not having been checked.
|
||||
*/
|
||||
export function featureRowsHtml(
|
||||
features: readonly string[],
|
||||
fundingAvailable: boolean | null,
|
||||
f: Formatters,
|
||||
): string {
|
||||
const states = featureStates(features, fundingAvailable);
|
||||
|
||||
return HIGHLIGHT_FEATURES.map((key: HighlightFeature) => {
|
||||
const state: FeatureState = states[key];
|
||||
|
||||
const badge =
|
||||
state === 'ok'
|
||||
? `<span class="nut-state ok">${CHECK_ICON}</span>`
|
||||
: state === 'unavailable'
|
||||
? `<span class="nut-state disabled">${PAUSE_ICON}${escape(f.t('lnurl.features.unavailable'))}</span>`
|
||||
: `<span class="nut-state none">${escape(f.t('lnurl.features.notListed'))}</span>`;
|
||||
|
||||
const classes =
|
||||
state === 'ok' ? 'nut-row' : state === 'unavailable' ? 'nut-row disabled' : 'nut-row off';
|
||||
|
||||
return (
|
||||
`<div class="${classes}" data-feature-row="${escape(key)}">` +
|
||||
`<span class="nut-num">${escape(ROW_TOKENS[key])}</span>` +
|
||||
`<span class="nut-name">${escape(localFeatureName(key, f))}</span>` +
|
||||
`${badge}</div>`
|
||||
);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* The inside of the verdict strip's withdraw-limits cell.
|
||||
*
|
||||
* The Cashu cell shows NUT-04's mint limits and splits in two when a direction is
|
||||
* switched off. This one shows the withdraw bounds, which are the numbers that actually
|
||||
* answer a reader's question here: how much can one of this mint's notes be worth?
|
||||
*
|
||||
* A ceiling of zero is not rendered as "0 sat". It is the disabling value — no note this
|
||||
* mint issues can be redeemed for anything — and it gets the struck-through treatment
|
||||
* the Cashu cell gives a disabled direction, with the banner above doing the explaining.
|
||||
*/
|
||||
export function withdrawLimitsHtml(
|
||||
minMsat: number | null | undefined,
|
||||
maxMsat: number | null | undefined,
|
||||
f: Formatters,
|
||||
): string {
|
||||
const min = msatToSat(minMsat ?? null);
|
||||
const max = msatToSat(maxMsat ?? null);
|
||||
|
||||
if (min === null && max === null) {
|
||||
return `<div class="v-mono"><span>${escape(f.t('lnurl.limits.notPublished'))}</span></div>`;
|
||||
}
|
||||
|
||||
if (max === 0) {
|
||||
return (
|
||||
'<div class="v-mono lim-split">' +
|
||||
'<div class="lim-row off"><span class="lim-k">' +
|
||||
`${escape(f.t('lnurl.limits.withdrawRow'))}</span>` +
|
||||
'<span class="lim-body">' +
|
||||
`<b class="lim-v">${escape(f.t('lnurl.limits.sats', { n: 0 }))}</b>` +
|
||||
`<span class="lim-off">${escape(f.t('lnurl.limits.disabled'))}</span>` +
|
||||
'</span></div></div>'
|
||||
);
|
||||
}
|
||||
|
||||
const lines = [
|
||||
min !== null
|
||||
? `<span>${escape(f.t('mint.limits.min'))}</span> <b>${escape(f.t('lnurl.limits.sats', { n: min }))}</b>`
|
||||
: '',
|
||||
max !== null
|
||||
? `<span>${escape(f.t('mint.limits.max'))}</span> <b>${escape(f.t('lnurl.limits.sats', { n: max }))}</b>`
|
||||
: '',
|
||||
].filter(Boolean);
|
||||
|
||||
return `<div class="v-mono">${lines.join('<br />')}</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mint fee, as one line, or null when there is nothing to say.
|
||||
*
|
||||
* A mint that publishes no `Mint fees:` entry is fee-free by spec, and a row reading
|
||||
* "Fee: none" on every one of them would be furniture. Both parts are shown when both
|
||||
* are set, because a flat fee and a percentage answer different questions.
|
||||
*/
|
||||
export function feeText(
|
||||
baseMsat: number | null | undefined,
|
||||
ppm: number | null | undefined,
|
||||
f: Formatters,
|
||||
): string | null {
|
||||
const base = baseMsat ?? 0;
|
||||
const percentPpm = ppm ?? 0;
|
||||
if (base === 0 && percentPpm === 0) return null;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (base > 0) parts.push(f.t('lnurl.fee.base', { n: msatToSat(base) ?? 0 }));
|
||||
if (percentPpm > 0) {
|
||||
// ppm to percent, at most three decimals: 100 ppm is 0.01%, and a mint charging
|
||||
// 25_000 ppm is charging 2.5%. Trailing zeros are trimmed so the common cases read
|
||||
// as "0.01%" rather than "0.010%".
|
||||
const percent = (percentPpm / 10_000).toFixed(3).replace(/\.?0+$/, '');
|
||||
parts.push(f.t('lnurl.fee.percent', { percent }));
|
||||
}
|
||||
return parts.join(' + ');
|
||||
}
|
||||
@@ -16,14 +16,15 @@
|
||||
* has to stay plain data — no functions, no class instances.
|
||||
*/
|
||||
import {
|
||||
KIND_FEDIMINT_ANNOUNCEMENT, KIND_MINT_ANNOUNCEMENT, PRIMARY_RELAY, mintUrlSpellings,
|
||||
type FedimintDetail, type MintDetail,
|
||||
KIND_FEDIMINT_ANNOUNCEMENT, KIND_LNURL_ANNOUNCEMENT, KIND_MINT_ANNOUNCEMENT, PRIMARY_RELAY,
|
||||
lnurlIdentifiers, mintUrlSpellings,
|
||||
type FedimintDetail, type LnurlDetail, type MintDetail,
|
||||
} from '@cashumints/shared';
|
||||
|
||||
export interface ReviewSubject {
|
||||
/** `cashu` or `fedimint`. Only used for labelling; no logic branches on it. */
|
||||
/** `cashu`, `fedimint` or `lnurl`. Only used for labelling; no logic branches on it. */
|
||||
type: string;
|
||||
/** The announcement kind, written to the review's `k` tag: 38172 or 38173. */
|
||||
/** The announcement kind, written to the review's `k` tag: 38172, 38173 or 38174. */
|
||||
kind: number;
|
||||
/**
|
||||
* The `d` identifier of the announcement being reviewed, and the value the `#d` relay
|
||||
@@ -67,6 +68,14 @@ export interface ReviewSubject {
|
||||
* nothing else, so a federation asks by `d` only and this is 0 for one.
|
||||
*/
|
||||
filterByU: boolean;
|
||||
/**
|
||||
* Every `d` a review of this thing could carry, for the relay filter.
|
||||
*
|
||||
* Only LNURL sets this, and only because its identifier can legitimately change once
|
||||
* — from a host to a pubkey — leaving reviews under both. Absent everywhere else,
|
||||
* where `d` is the one identifier there has ever been.
|
||||
*/
|
||||
alternateIds?: string[];
|
||||
}
|
||||
|
||||
/** A Cashu mint, reviewed exactly as it has always been. */
|
||||
@@ -99,6 +108,39 @@ export function fedimintSubject(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* An LNURL mint, identified per `docs/KIND-LNURL-MINT.md`.
|
||||
*
|
||||
* `d` is the funding node's pubkey when the mint has one and its normalized host
|
||||
* otherwise, and `alternateIds` carries the other spelling so the reviews panel asks
|
||||
* the relays for both. That is not defensive coding: a mint announced by host before it
|
||||
* had a funding source, and by pubkey afterwards, genuinely has reviews under each, and
|
||||
* a panel asking only for the current identifier would show half of them.
|
||||
*
|
||||
* `filterByU` is true, unlike the Fedimint side. An LNURL mint's address is a short URL
|
||||
* rather than a 150-character invite code, so no relay rejects the filter for being too
|
||||
* large, and `u` is the tag a client that has never heard of 38174 is most likely to get
|
||||
* right.
|
||||
*/
|
||||
export function lnurlSubject(
|
||||
mint: Pick<LnurlDetail, 'url' | 'base_url' | 'lnurl_id' | 'mint_pubkey' | 'announcer_pubkey'>,
|
||||
): ReviewSubject {
|
||||
const baseUrl = mint.base_url ?? mint.url.replace(/^lnurl:/, '');
|
||||
const ids = lnurlIdentifiers(baseUrl, mint.mint_pubkey);
|
||||
|
||||
return {
|
||||
type: 'lnurl',
|
||||
kind: KIND_LNURL_ANNOUNCEMENT,
|
||||
d: mint.lnurl_id ?? ids[0] ?? null,
|
||||
u: mintUrlSpellings(baseUrl),
|
||||
marker: 'lnurl',
|
||||
announcer: mint.announcer_pubkey ?? null,
|
||||
key: mint.url,
|
||||
filterByU: true,
|
||||
alternateIds: ids,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The tags a published review carries, per NIP-87.
|
||||
*
|
||||
@@ -115,6 +157,10 @@ export function fedimintSubject(
|
||||
* the ones written after. NOTES.md has the history.
|
||||
* - Fedimint writes `38173:<announcer pubkey>:<federation id>`, which is the correct
|
||||
* form, because there is no legacy to preserve and the announcer is known.
|
||||
* - LNURL writes `38174:<announcer pubkey>:<d>`, the correct form for the same reason
|
||||
* — the kind is new, so there is nothing to stay compatible with. When nothing has
|
||||
* announced the mint yet there is no announcer, and the pointer falls back to the
|
||||
* identifier in the author position exactly as Cashu does.
|
||||
*
|
||||
* A subject with neither `d` nor any `u` value cannot be pointed at, and the caller
|
||||
* refuses to publish rather than sign an event nothing can resolve.
|
||||
|
||||
@@ -104,13 +104,21 @@ export function lastPublish(): number {
|
||||
*
|
||||
* Two filters, OR'd, matching the old site's read path:
|
||||
* - `#d` = the announcement's identifier, with `#k` = its kind (proper NIP-87). For a
|
||||
* mint that is the pubkey from /v1/info; for a federation, the federation id.
|
||||
* mint that is the pubkey from /v1/info; for a federation, the federation id; for an
|
||||
* LNURL mint, its funding node's pubkey or its host.
|
||||
* - `#u` = the address, in every spelling the old site published
|
||||
*
|
||||
* The URL filter is what finds legacy mint reviews, and dropping it would lose most of
|
||||
* them. A federation sets `filterByU: false` and is asked by `#d` alone — its `u` value
|
||||
* is a 150 character invite code, and relays reject a filter item that long outright,
|
||||
* so sending it costs round trips and returns nothing. See `review-subject.ts`.
|
||||
*
|
||||
* An LNURL mint sends more than one `#d` value, and it is the only subject that does:
|
||||
* its identifier legitimately changes once in its life, from its host to its funding
|
||||
* node's pubkey, and reviews written either side of that carry different `d` tags for
|
||||
* the same mint. Both are asked for in one filter, and both are accepted by the
|
||||
* re-check below, so a mint that gained a funding source does not appear to lose half
|
||||
* its reviews.
|
||||
*/
|
||||
/** Longest body a card will render. Anything real fits; a stunt event is cut. */
|
||||
const MAX_RENDERED_CHARS = 4000;
|
||||
@@ -121,10 +129,13 @@ export async function loadReviews(subject: ReviewSubject): Promise<LoadedReview[
|
||||
|
||||
const filters: Record<string, unknown>[] = [];
|
||||
|
||||
if (subject.d) {
|
||||
const identifiers = [...new Set([...(subject.alternateIds ?? []), subject.d]
|
||||
.filter((id): id is string => Boolean(id)))];
|
||||
|
||||
if (identifiers.length > 0) {
|
||||
filters.push({
|
||||
kinds: [KIND_REVIEW],
|
||||
'#d': [subject.d],
|
||||
'#d': identifiers,
|
||||
'#k': [String(subject.kind)],
|
||||
limit: 500,
|
||||
});
|
||||
@@ -151,13 +162,11 @@ export async function loadReviews(subject: ReviewSubject): Promise<LoadedReview[
|
||||
* event is re-checked against the subject's own tags, mirroring the two filters.
|
||||
*/
|
||||
const uSpellings = new Set(subject.u);
|
||||
const knownIds = new Set(identifiers);
|
||||
const aboutSubject = (event: NostrEvent): boolean => {
|
||||
if (event.kind !== KIND_REVIEW) return false;
|
||||
if (
|
||||
subject.d &&
|
||||
tagValue(event.tags, 'd') === subject.d &&
|
||||
tagValue(event.tags, 'k') === String(subject.kind)
|
||||
) {
|
||||
const d = tagValue(event.tags, 'd');
|
||||
if (d && knownIds.has(d) && tagValue(event.tags, 'k') === String(subject.kind)) {
|
||||
return true;
|
||||
}
|
||||
return subject.filterByU && tagValues(event.tags, 'u').some((u) => uSpellings.has(u));
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* The write-a-review form, as one behaviour two dialogs share.
|
||||
*
|
||||
* It used to live inside the reviews panel's island, which was right while a review
|
||||
* could only be written from a mint's own page: the panel had the subject, the dialog
|
||||
* was part of the panel, and there was nothing to share it with.
|
||||
*
|
||||
* Review-by-URL changed that. The three index pages now open a dialog whose *first*
|
||||
* step works out what is being reviewed — a reader pastes an address, the site indexes
|
||||
* it — and whose second step is this form, identically. Two copies of a signing flow is
|
||||
* the last thing this codebase should have, so the form moved here and both callers
|
||||
* drive it through `wireWriteForm`.
|
||||
*
|
||||
* What is deliberately *not* here: anything about the list of reviews. Rendering,
|
||||
* filtering, counts and optimistic insertion belong to whoever is showing reviews, and
|
||||
* the index pages are not. This module signs and publishes, and reports what happened.
|
||||
*
|
||||
* The one behavioural difference between the two callers is where the subject comes
|
||||
* from — a prerendered data attribute on a mint page, a resolved API response in the
|
||||
* dialog — so it is a function rather than a value, read at submit time.
|
||||
*/
|
||||
import { useI18n } from '../i18n/client';
|
||||
import { escapeHtml } from './client';
|
||||
import { closeModal, openModal, wireModal } from './modal';
|
||||
import { activeSigner, onSessionChange, onSignerStatus, readSession, signerErrorMessage } from './nostr-signer';
|
||||
import { avatarMarkup, identityLines, methodChipHtml, openLogin, resolveProfile, toNpub } from './auth-ui';
|
||||
import { getExtension, loadProfiles, publishReview } from './reviews-client';
|
||||
import type { ReviewSubject } from './review-subject';
|
||||
import type { Profile } from '@cashumints/shared';
|
||||
|
||||
/** Reviews are capped at this length, and the counter turns red past it. */
|
||||
export const MAX_CHARS = 2000;
|
||||
/** Below this a review says nothing anyone can use. */
|
||||
export const MIN_CHARS = 10;
|
||||
|
||||
/** What a completed publish hands back to whoever opened the dialog. */
|
||||
export interface PublishedReview {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
created_at: number;
|
||||
rating: number;
|
||||
content: string;
|
||||
/** How many relays took it, of how many tried. Shown on the card. */
|
||||
accepted: number;
|
||||
total: number;
|
||||
/** The subject it was published against, which the caller may have just resolved. */
|
||||
subject: ReviewSubject;
|
||||
}
|
||||
|
||||
export interface WriteFormOptions {
|
||||
/** The `<dialog>` the form lives in. This module wires its modal behaviour. */
|
||||
dialog: HTMLDialogElement;
|
||||
/** What is being reviewed, read at open and at submit. Null means "not yet known". */
|
||||
subject: () => ReviewSubject | null;
|
||||
/** Called once the relays have taken the event. */
|
||||
onPublished?: (review: PublishedReview) => void;
|
||||
/** The author's own kind 0, once it lands. Only used to un-Anon their fresh card. */
|
||||
onProfile?: (pubkey: string, profile: Profile) => void;
|
||||
/**
|
||||
* Asked before a backdrop click closes the dialog, in addition to this module's own
|
||||
* "is there a half-written review in the box?" check. The review-by-URL dialog uses
|
||||
* it to protect its first step as well.
|
||||
*/
|
||||
onBackdrop?: () => boolean;
|
||||
}
|
||||
|
||||
export interface WriteFormApi {
|
||||
/** Restore any draft, draw the identity row, and focus the rating. */
|
||||
open(): void;
|
||||
close(): void;
|
||||
/** Clear the form and its stored draft. After publishing, or a subject change. */
|
||||
reset(): void;
|
||||
/** Re-read who is signing. Called when the subject changes under an open dialog. */
|
||||
refreshIdentity(): void;
|
||||
/** Show an error line inside the dialog, in the same place a publish failure uses. */
|
||||
showError(message: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire one dialog's write form. Returns the handful of things a caller drives it with.
|
||||
*
|
||||
* Every element is looked up under the dialog, so two of these can coexist on one page
|
||||
* without reaching into each other — which is exactly what happens on a mint page that
|
||||
* also carries the review-by-URL dialog.
|
||||
*/
|
||||
export function wireWriteForm(options: WriteFormOptions): WriteFormApi {
|
||||
const { dialog } = options;
|
||||
const t = useI18n();
|
||||
|
||||
const form = dialog.querySelector<HTMLFormElement>('[data-write-form]');
|
||||
const errorBox = dialog.querySelector<HTMLElement>('[data-write-error]');
|
||||
const statusBox = dialog.querySelector<HTMLElement>('[data-write-status]');
|
||||
const submit = dialog.querySelector<HTMLButtonElement>('[data-write-submit]');
|
||||
const submitLabel = dialog.querySelector<HTMLElement>('[data-write-submit-label]');
|
||||
const textarea = dialog.querySelector<HTMLTextAreaElement>('textarea[name="content"]');
|
||||
const charCount = dialog.querySelector<HTMLElement>('[data-char-count]');
|
||||
const starLabel = dialog.querySelector<HTMLElement>('[data-star-label]');
|
||||
const identityRow = dialog.querySelector<HTMLElement>('[data-write-identity]');
|
||||
const whyDisabled = dialog.querySelector<HTMLElement>('[data-write-why]');
|
||||
|
||||
// One phrase per star, from the catalog, read through `t` rather than held in a table
|
||||
// here so the phrases follow the page's language after a view transition.
|
||||
const starPhrase = (rating: number): string => t(`reviews.dialog.stars.${rating}`);
|
||||
|
||||
/** True once a publish is in flight, so nothing else runs over it. */
|
||||
let publishing = false;
|
||||
/** Who is going to sign, resolved when the dialog opens. */
|
||||
let canSign = false;
|
||||
|
||||
function showError(message: string): void {
|
||||
if (!errorBox) return;
|
||||
errorBox.textContent = message;
|
||||
errorBox.hidden = message === '';
|
||||
}
|
||||
|
||||
function showStatus(message: string): void {
|
||||
if (!statusBox) return;
|
||||
statusBox.textContent = message;
|
||||
statusBox.hidden = message === '';
|
||||
}
|
||||
|
||||
/* ---------- draft safety ---------- */
|
||||
|
||||
/**
|
||||
* A written review survives an accidental close.
|
||||
*
|
||||
* sessionStorage, keyed by subject: it lasts as long as the tab and no longer, which
|
||||
* is the life of the intention. Nothing here is secret, but nothing here belongs on
|
||||
* the machine a week later either.
|
||||
*/
|
||||
const draftKey = (): string | null => {
|
||||
const key = options.subject()?.key;
|
||||
return key ? `cashumints:draft:${key}` : null;
|
||||
};
|
||||
|
||||
function saveDraft(): void {
|
||||
const key = draftKey();
|
||||
if (!form || !key) return;
|
||||
const data = new FormData(form);
|
||||
const rating = String(data.get('rating') ?? '');
|
||||
const content = String(data.get('content') ?? '');
|
||||
try {
|
||||
if (!rating && !content.trim()) sessionStorage.removeItem(key);
|
||||
else sessionStorage.setItem(key, JSON.stringify({ rating, content }));
|
||||
} catch {
|
||||
// Storage blocked. The draft lives in the open dialog and nowhere else.
|
||||
}
|
||||
}
|
||||
|
||||
function clearDraft(): void {
|
||||
const key = draftKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
sessionStorage.removeItem(key);
|
||||
} catch {
|
||||
// Nothing stored to begin with.
|
||||
}
|
||||
}
|
||||
|
||||
function restoreDraft(): void {
|
||||
const key = draftKey();
|
||||
if (!form || !key) return;
|
||||
let draft: { rating?: string; content?: string } | null = null;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
draft = raw ? (JSON.parse(raw) as { rating?: string; content?: string }) : null;
|
||||
} catch {
|
||||
draft = null;
|
||||
}
|
||||
if (!draft) return;
|
||||
|
||||
if (draft.rating) {
|
||||
const star = form.querySelector<HTMLInputElement>(`input[name="rating"][value="${draft.rating}"]`);
|
||||
if (star) star.checked = true;
|
||||
}
|
||||
if (draft.content && textarea) textarea.value = draft.content;
|
||||
syncStarLabel();
|
||||
syncCount();
|
||||
}
|
||||
|
||||
/* ---------- the form's own state ---------- */
|
||||
|
||||
function chosenRating(): number {
|
||||
return Number(new FormData(form ?? undefined).get('rating') ?? 0);
|
||||
}
|
||||
|
||||
function syncStarLabel(): void {
|
||||
if (!starLabel) return;
|
||||
const rating = chosenRating();
|
||||
starLabel.textContent = rating
|
||||
// The rating is a bare numeral here, so it is passed as a string: "3: good",
|
||||
// never "3,0: good".
|
||||
? t('reviews.dialog.starLabel', { n: String(rating), phrase: starPhrase(rating) })
|
||||
: t('reviews.dialog.chooseRating');
|
||||
}
|
||||
|
||||
function syncCount(): void {
|
||||
const length = textarea?.value.length ?? 0;
|
||||
if (charCount) {
|
||||
charCount.textContent = t('reviews.dialog.charCount', { n: length, max: MAX_CHARS });
|
||||
charCount.classList.toggle('over', length > MAX_CHARS);
|
||||
}
|
||||
syncSubmit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether "Sign and publish" is available, and if not, why.
|
||||
*
|
||||
* The reason is always on screen: a button that is off for an unstated reason is a
|
||||
* button people click twice and then leave.
|
||||
*/
|
||||
function syncSubmit(): void {
|
||||
if (!submit) return;
|
||||
const length = textarea?.value.trim().length ?? 0;
|
||||
const rating = chosenRating();
|
||||
|
||||
const over = (textarea?.value.length ?? 0) - MAX_CHARS;
|
||||
const reason = publishing
|
||||
? ''
|
||||
: !canSign ? t('reviews.dialog.why.login')
|
||||
: !rating ? t('reviews.dialog.why.rating')
|
||||
: length === 0 ? t('reviews.dialog.why.words')
|
||||
: length < MIN_CHARS ? t('reviews.dialog.why.more', { n: MIN_CHARS - length })
|
||||
: over > 0 ? t('reviews.dialog.why.over', { n: over })
|
||||
: '';
|
||||
|
||||
submit.disabled = publishing || reason !== '';
|
||||
if (whyDisabled) whyDisabled.textContent = publishing ? '' : reason;
|
||||
}
|
||||
|
||||
/** The publish button, mid-flight or back to rest. */
|
||||
function setBusy(busy: boolean, label = t('reviews.dialog.submit')): void {
|
||||
publishing = busy;
|
||||
submit?.classList.toggle('is-busy', busy);
|
||||
if (submitLabel) submitLabel.textContent = label;
|
||||
syncSubmit();
|
||||
}
|
||||
|
||||
/* ---------- who is signing ---------- */
|
||||
|
||||
/**
|
||||
* Draw the identity row, and decide whether publishing is possible at all.
|
||||
*
|
||||
* Three states, in the order they are likely:
|
||||
* - logged in: the session's signer, named, with a way to switch
|
||||
* - no session but a NIP-07 extension: quick-sign, exactly as before logins
|
||||
* existed, and no session is created by using it
|
||||
* - neither: the row becomes the way in, and the publish button says so
|
||||
*
|
||||
* The extension's pubkey is asked for here, on open, and never on page load: it is a
|
||||
* question some extensions put to the reader, and a page that asks it for every
|
||||
* visitor who might one day write a review is a page that nags.
|
||||
*/
|
||||
async function renderIdentity(): Promise<void> {
|
||||
if (!identityRow) return;
|
||||
|
||||
const session = readSession();
|
||||
if (session) {
|
||||
// A session that names an extension is only as good as the extension: it can be
|
||||
// uninstalled, or disabled for this site, between one visit and the next.
|
||||
canSign = session.method !== 'extension' || getExtension() !== null;
|
||||
syncSubmit();
|
||||
if (!canSign) {
|
||||
identityRow.innerHTML =
|
||||
`<span class="write-id-who"><span class="write-id-lead">${escapeHtml(
|
||||
t('reviews.dialog.identity.extensionGone'),
|
||||
)}</span></span>` +
|
||||
`<button type="button" class="btn" data-login-open>${escapeHtml(t('login.open'))}</button>`;
|
||||
return;
|
||||
}
|
||||
const npub = await toNpub(session.pubkey);
|
||||
|
||||
const draw = (profile: Profile | null): void => {
|
||||
const { primary, secondary, named } = identityLines(profile, npub, t);
|
||||
// Whichever of the two lines is the npub is the machine-flavoured one, and that
|
||||
// is the one set in mono (DESIGN.md). The other is a name or a phrase.
|
||||
identityRow.innerHTML =
|
||||
avatarMarkup(session.pubkey, profile, npub, 34) +
|
||||
`<span class="write-id-who">` +
|
||||
`<span class="write-id-name${named ? '' : ' mono'}">${escapeHtml(primary)}</span>` +
|
||||
`<span class="write-id-sub${named ? ' mono' : ''}">${escapeHtml(secondary)}</span></span>` +
|
||||
methodChipHtml(session.method, t) +
|
||||
`<button type="button" class="write-id-switch" data-login-open>${escapeHtml(
|
||||
t('reviews.dialog.identity.switch'),
|
||||
)}</button>`;
|
||||
};
|
||||
|
||||
// Drawn from the npub first, then again if a name turns up: the row is the same
|
||||
// height either way, so nothing under it moves when the name lands.
|
||||
draw(null);
|
||||
const profile = await resolveProfile(session.pubkey);
|
||||
if (readSession()?.pubkey !== session.pubkey || !profile?.found) return;
|
||||
draw(profile);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getExtension()) {
|
||||
canSign = false;
|
||||
syncSubmit();
|
||||
identityRow.innerHTML =
|
||||
`<span class="write-id-who"><span class="write-id-lead">${escapeHtml(
|
||||
t('reviews.dialog.identity.needSigner'),
|
||||
)}</span></span>` +
|
||||
`<button type="button" class="btn" data-login-open>${escapeHtml(
|
||||
t('reviews.dialog.identity.loginToPublish'),
|
||||
)}</button>`;
|
||||
return;
|
||||
}
|
||||
|
||||
canSign = true;
|
||||
syncSubmit();
|
||||
identityRow.innerHTML =
|
||||
`<span class="write-id-who"><span class="write-id-name">${escapeHtml(
|
||||
t('reviews.dialog.identity.extension'),
|
||||
)}</span>` +
|
||||
`<span class="write-id-sub">${escapeHtml(t('reviews.dialog.identity.readingKey'))}</span></span>` +
|
||||
methodChipHtml('extension', t);
|
||||
|
||||
let pubkey = '';
|
||||
try {
|
||||
pubkey = (await getExtension()?.getPublicKey()) ?? '';
|
||||
} catch {
|
||||
// Declined, or the extension is locked. Publishing still works: it will ask again
|
||||
// at signing time, which is the request that actually matters.
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/i.test(pubkey)) {
|
||||
const sub = identityRow.querySelector('.write-id-sub');
|
||||
if (sub) sub.textContent = t('reviews.dialog.identity.willAsk');
|
||||
return;
|
||||
}
|
||||
|
||||
const npub = await toNpub(pubkey);
|
||||
const profile = await resolveProfile(pubkey);
|
||||
const { primary, named } = identityLines(profile, npub, t);
|
||||
identityRow.innerHTML =
|
||||
avatarMarkup(pubkey, profile, npub, 34) +
|
||||
`<span class="write-id-who">` +
|
||||
`<span class="write-id-name${named ? '' : ' mono'}">${escapeHtml(primary)}</span>` +
|
||||
`<span class="write-id-sub">${escapeHtml(t('reviews.dialog.identity.extension'))}</span></span>` +
|
||||
methodChipHtml('extension', t);
|
||||
}
|
||||
|
||||
/* ---------- opening and closing ---------- */
|
||||
|
||||
wireModal(dialog, {
|
||||
// A written review is not thrown away on a stray click outside the box.
|
||||
onBackdrop: () => {
|
||||
if ((textarea?.value.trim().length ?? 0) > 0 && !window.confirm(t('reviews.dialog.confirmClose'))) {
|
||||
return false;
|
||||
}
|
||||
return options.onBackdrop ? options.onBackdrop() : true;
|
||||
},
|
||||
onClose: () => {
|
||||
saveDraft();
|
||||
showStatus('');
|
||||
setBusy(false);
|
||||
},
|
||||
});
|
||||
|
||||
// The signer talks back while a signature is in flight: reconnecting, waiting,
|
||||
// approve-in-another-window. Mirrored into the dialog so the reader is never watching
|
||||
// a spinner with nothing behind it.
|
||||
onSignerStatus((update) => {
|
||||
if (dialog.open && publishing) showStatus(update.message);
|
||||
});
|
||||
|
||||
// Logging in from the identity row redraws it, without closing this dialog.
|
||||
onSessionChange(() => {
|
||||
if (dialog.open) void renderIdentity();
|
||||
});
|
||||
|
||||
textarea?.addEventListener('input', () => {
|
||||
syncCount();
|
||||
saveDraft();
|
||||
});
|
||||
|
||||
form?.addEventListener('change', (event) => {
|
||||
if ((event.target as HTMLInputElement).name !== 'rating') return;
|
||||
syncStarLabel();
|
||||
syncSubmit();
|
||||
saveDraft();
|
||||
});
|
||||
|
||||
/* ---------- publishing ---------- */
|
||||
|
||||
form?.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (!form || !submit || publishing) return;
|
||||
|
||||
const subject = options.subject();
|
||||
if (!subject) return;
|
||||
|
||||
const rating = chosenRating();
|
||||
const content = textarea?.value ?? '';
|
||||
|
||||
const signer = activeSigner();
|
||||
if (!signer) {
|
||||
// No session and no extension. The row already says so; open the way in.
|
||||
openLogin(submit);
|
||||
return;
|
||||
}
|
||||
|
||||
showError('');
|
||||
setBusy(true, t('reviews.dialog.signing'));
|
||||
showStatus(
|
||||
signer.method === 'extension'
|
||||
? t('reviews.dialog.waitingExtension')
|
||||
: t('reviews.dialog.waitingSigner'),
|
||||
);
|
||||
|
||||
try {
|
||||
/*
|
||||
* The author's own kind 0, fetched while the relays are still taking the review.
|
||||
* Their pubkey is only knowable once the event is signed, so `onSigned` starts
|
||||
* this at the earliest moment there is: by the time the card is inserted the name
|
||||
* is usually already in hand, and the author does not watch their own review
|
||||
* appear as "Anon" until the next reload.
|
||||
*
|
||||
* `refresh` skips the cache deliberately. Anyone who has reviewed before may be
|
||||
* sitting on an hour-old negative entry for their own key, and they are exactly
|
||||
* the reader who would notice their name missing.
|
||||
*/
|
||||
let profileLookup: Promise<Profile | null> | null = null;
|
||||
|
||||
const result = await publishReview({
|
||||
signer,
|
||||
subject,
|
||||
rating,
|
||||
content,
|
||||
onSigned: (signed) => {
|
||||
setBusy(true, t('reviews.dialog.publishing'));
|
||||
profileLookup = loadProfiles([signed.pubkey], { refresh: true })
|
||||
.then((profiles) => profiles.get(signed.pubkey) ?? null)
|
||||
.catch(() => {
|
||||
// Relays unreachable for the lookup. The card keeps "Anon" over its npub,
|
||||
// which is what every other unresolved card shows.
|
||||
return null;
|
||||
});
|
||||
},
|
||||
onProgress: (accepted, _settled, total) => {
|
||||
showStatus(t('reviews.dialog.publishingTo', { accepted, total }));
|
||||
},
|
||||
});
|
||||
|
||||
void (profileLookup as Promise<Profile | null> | null)?.then((profile) => {
|
||||
if (profile?.found) options.onProfile?.(result.event.pubkey, profile);
|
||||
});
|
||||
|
||||
clearDraft();
|
||||
|
||||
options.onPublished?.({
|
||||
id: result.event.id,
|
||||
pubkey: result.event.pubkey,
|
||||
created_at: result.event.created_at,
|
||||
rating,
|
||||
content: content.trim(),
|
||||
accepted: result.accepted,
|
||||
total: result.total,
|
||||
subject,
|
||||
});
|
||||
|
||||
form.reset();
|
||||
syncStarLabel();
|
||||
syncCount();
|
||||
setBusy(false);
|
||||
showStatus('');
|
||||
closeModal(dialog);
|
||||
} catch (err) {
|
||||
// Back to an editable dialog with everything still in it. A declined signature is
|
||||
// not an error the reader made, and a relay that would not take it is worth
|
||||
// another press of the same button.
|
||||
setBusy(false);
|
||||
showStatus('');
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
showError(message === 'no-relay' ? t('reviews.dialog.error.noRelay') : signerErrorMessage(err, t));
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
open(): void {
|
||||
showError('');
|
||||
showStatus('');
|
||||
setBusy(false);
|
||||
restoreDraft();
|
||||
openModal(dialog);
|
||||
void renderIdentity();
|
||||
// Somewhere to land that is not the close button: the rating is the first thing
|
||||
// to decide, and a checked star keeps its own place in the group.
|
||||
const checked = form?.querySelector<HTMLInputElement>('input[name="rating"]:checked');
|
||||
(checked ?? form?.querySelector<HTMLInputElement>('input[name="rating"]'))?.focus();
|
||||
},
|
||||
close(): void {
|
||||
closeModal(dialog);
|
||||
},
|
||||
reset(): void {
|
||||
form?.reset();
|
||||
syncStarLabel();
|
||||
syncCount();
|
||||
showError('');
|
||||
showStatus('');
|
||||
},
|
||||
refreshIdentity(): void {
|
||||
void renderIdentity();
|
||||
},
|
||||
showError,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- handing a fresh review to the page that shows it ---------- */
|
||||
|
||||
/**
|
||||
* Where a review published from the review-by-URL dialog waits for its own page.
|
||||
*
|
||||
* The dialog lives on an index page and the review belongs on a mint's page, so
|
||||
* publishing is immediately followed by a navigation. Relays take a moment to echo an
|
||||
* event back, and the author landing on a page that does not yet show what they wrote
|
||||
* reads as a failure — so the review travels with them, in sessionStorage, and the
|
||||
* reviews panel puts it at the top on arrival exactly as it would have if the review
|
||||
* had been written there.
|
||||
*
|
||||
* sessionStorage rather than a query parameter: the review body would not fit in a URL,
|
||||
* and a review is not something to put in a link somebody might share. It is read once
|
||||
* and removed, and it expires anyway — a stale entry from an hour ago must never be
|
||||
* inserted into a list that has since loaded the real thing.
|
||||
*/
|
||||
const FRESH_PREFIX = 'cashumints:fresh:';
|
||||
/** How long a handed-forward review stays valid. Long enough for one navigation. */
|
||||
const FRESH_TTL_MS = 2 * 60 * 1000;
|
||||
|
||||
export function rememberFreshReview(published: PublishedReview): void {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
FRESH_PREFIX + published.subject.key,
|
||||
JSON.stringify({ at: Date.now(), review: published }),
|
||||
);
|
||||
} catch {
|
||||
// Storage blocked. The review is on the relays either way; it simply shows up on
|
||||
// the next load instead of this one.
|
||||
}
|
||||
}
|
||||
|
||||
/** The review waiting for this subject, if any. Removed as it is read. */
|
||||
export function takeFreshReview(key: string): PublishedReview | null {
|
||||
const storageKey = FRESH_PREFIX + key;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(storageKey);
|
||||
if (!raw) return null;
|
||||
sessionStorage.removeItem(storageKey);
|
||||
const parsed = JSON.parse(raw) as { at?: number; review?: PublishedReview };
|
||||
if (!parsed.review || typeof parsed.at !== 'number') return null;
|
||||
return Date.now() - parsed.at < FRESH_TTL_MS ? parsed.review : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import NotFound from '../components/NotFound.astro';
|
||||
import { splitLocale } from '../i18n';
|
||||
|
||||
/**
|
||||
* The English 404, at `/404`, and the only one a server finds without being told.
|
||||
*
|
||||
* It sits outside `[...locale]/` on purpose. Astro recognises a custom 404 by route
|
||||
* name — the route has to be exactly `/404` — and `[...locale]/404` is not that, so
|
||||
* with only the prefixed file the dev server fell back to Astro's own default page
|
||||
* for every miss, including `/mint/{host}` misses that `pnpm bones` measures. A
|
||||
* static host has the same blind spot: nginx's `error_page` names `/404.html`.
|
||||
*
|
||||
* The locale comes off the URL rather than being fixed to English because the dev
|
||||
* server renders this route with the pathname that missed. Prerendered, that pathname
|
||||
* is `/404` and this is English; in dev, a miss under `/es/` renders in Spanish, which
|
||||
* is what nginx's per-prefix `try_files` does in production.
|
||||
*/
|
||||
const { locale } = splitLocale(Astro.url.pathname);
|
||||
---
|
||||
|
||||
<NotFound locale={locale} />
|
||||
@@ -1,221 +1,29 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import Moai from '../../components/Moai.astro';
|
||||
import { localePath, useI18n, type Locale } from '../../i18n';
|
||||
import { localePaths } from '../../i18n/paths';
|
||||
import NotFound from '../../components/NotFound.astro';
|
||||
import { DEFAULT_LOCALE, LOCALES, type Locale } from '../../i18n';
|
||||
|
||||
/**
|
||||
* Also the fallback for a mint discovered between builds.
|
||||
* The prefixed 404s: `/es/404`, `/nl/404`, one per language.
|
||||
*
|
||||
* A new mint's page does not exist until the next rebuild, so on a /mint/{host} miss
|
||||
* this asks the API for that host and renders a page shell from it. Not as complete
|
||||
* as the prerendered page, but the mint is reachable and reviewable immediately
|
||||
* instead of invisible for a day (FRONTEND.md).
|
||||
* English is deliberately absent here — `pages/404.astro` emits it at `/404`, where
|
||||
* Astro and nginx both expect to find it, and two routes emitting the same path is a
|
||||
* collision. Everything the page does lives in the component both files render.
|
||||
*
|
||||
* Emitted per locale: `/404`, `/es/404`, `/nl/404`. A static host serves the one at the
|
||||
* root for every miss unless it is told otherwise, so the README's nginx block adds an
|
||||
* `error_page` per prefix. That is what keeps a miss under /es/ from answering in
|
||||
* English.
|
||||
* A static host serves the root 404 for every miss unless it is told otherwise, so the
|
||||
* README's nginx block adds a `try_files` fallback per prefix. That is what keeps a
|
||||
* miss under /es/ answering in Spanish.
|
||||
*/
|
||||
export const getStaticPaths = localePaths;
|
||||
export function getStaticPaths() {
|
||||
return LOCALES.filter(({ code }) => code !== DEFAULT_LOCALE).map(({ code }) => ({
|
||||
params: { locale: code },
|
||||
props: { locale: code as Locale },
|
||||
}));
|
||||
}
|
||||
|
||||
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 fallback 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>
|
||||
<div data-boneyard="mint-page" data-boneyard-config='{"excludeSelectors":[".sr-only"]}'><div data-mint-fallback-body></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</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; }
|
||||
</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 } from '../../i18n/mint';
|
||||
import { splitLocale } from '../../i18n/routing';
|
||||
import { onReady } from '../../scripts/reveal';
|
||||
import mintPageBones from '../../bones/mint-page.bones.json';
|
||||
import 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(/^https?:\/\//, '');
|
||||
const name = mint.name ?? domain.split('/')[0] ?? domain;
|
||||
|
||||
return `
|
||||
<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(mint.url)}</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>`;
|
||||
}
|
||||
|
||||
const setup = (): void => {
|
||||
const f = formatters(useI18n());
|
||||
|
||||
/*
|
||||
* 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 match = /^\/mint\/([^/]+)\/?$/.exec(path);
|
||||
const box = document.querySelector<HTMLElement>('[data-mint-fallback]');
|
||||
const body = document.querySelector<HTMLElement>('[data-mint-fallback-body]');
|
||||
const notfound = document.querySelector<HTMLElement>('[data-notfound]');
|
||||
|
||||
if (body && box && 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);
|
||||
});
|
||||
} else if (match?.[1] && box && body) {
|
||||
const host = decodeURIComponent(match[1]);
|
||||
|
||||
// The "page not found" copy would be wrong if the API knows this mint, so hold
|
||||
// it back while we ask instead of flashing it and taking it away again.
|
||||
if (notfound) notfound.hidden = true;
|
||||
box.hidden = false;
|
||||
showBones(body, mintPageBones, { label: f.t('notfound.loading', { host }) });
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/mints/${encodeURIComponent(host)}`);
|
||||
if (!res.ok) throw new Error(`api responded ${res.status}`);
|
||||
const mint = (await res.json()) as MintDetail;
|
||||
|
||||
swapBones(body, (target) => {
|
||||
target.innerHTML = fallbackHtml(mint, f);
|
||||
});
|
||||
|
||||
const domain = mint.url.replace(/^https?:\/\//, '');
|
||||
const name = mint.name ?? domain.split('/')[0] ?? domain;
|
||||
document.title = f.t('mint.title', { name });
|
||||
} catch {
|
||||
// No such mint, or the API is unreachable. The plain 404 is the honest
|
||||
// answer, so put it back rather than leaving bones on screen.
|
||||
clearBones(body);
|
||||
box.hidden = true;
|
||||
if (notfound) notfound.hidden = false;
|
||||
}
|
||||
})();
|
||||
}
|
||||
};
|
||||
|
||||
onReady(setup);
|
||||
</script>
|
||||
<NotFound locale={locale} />
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import MintCard from '../../components/MintCard.astro';
|
||||
import ReviewByUrl from '../../components/ReviewByUrl.astro';
|
||||
import { fetchFedimints } from '../../lib/api';
|
||||
import { localePath, useI18n, type Locale } from '../../i18n';
|
||||
import { itemListNode } from '../../lib/schema';
|
||||
@@ -66,9 +67,19 @@ const schema = [
|
||||
<Base title={t('fedimints.title')} description={description} current="fedimints" schema={schema}>
|
||||
<div class="page">
|
||||
<div class="index-head">
|
||||
<div class="index-head-text">
|
||||
<h1>{t('fedimints.heading')}</h1>
|
||||
<p class="lede">{t('fedimints.lede')}</p>
|
||||
</div>
|
||||
{/*
|
||||
The way in for something this site has never seen. Every other route to writing
|
||||
a review starts from a page that already exists, which is exactly the case a
|
||||
reader with a brand new mint does not have.
|
||||
*/}
|
||||
<button class="btn primary index-head-action" type="button" data-review-url-open="fedimint">
|
||||
{t('reviews.byUrl.open')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="controls" data-mint-controls>
|
||||
<form class="search" role="search" onsubmit="return false">
|
||||
@@ -142,10 +153,17 @@ const schema = [
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Two steps in one dialog: identify the mint, then the ordinary review form. */}
|
||||
<ReviewByUrl type="fedimint" />
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.index-head { margin-bottom: 24px; }
|
||||
/* Heading and lede on the left, the one primary action on the right, wrapping to
|
||||
its own line before the lede has to give up any width. */
|
||||
.index-head { margin-bottom: 24px; display: flex; align-items: flex-start; gap: 16px; flex-wrap: wrap; }
|
||||
.index-head-text { flex: 1 1 320px; }
|
||||
.index-head-action { flex-shrink: 0; }
|
||||
.index-head h1 { font-family: var(--display); font-size: 32px; font-weight: 700; letter-spacing: -.02em; line-height: 1.1; }
|
||||
.index-head .lede { max-width: 620px; font-size: 15.5px; margin-top: 10px; }
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ import Base from '../../layouts/Base.astro';
|
||||
import ColorBends from '../../components/ColorBends.astro';
|
||||
import MintCard from '../../components/MintCard.astro';
|
||||
import PulseTicker from '../../components/PulseTicker.astro';
|
||||
import { fetchFedimint, fetchFedimints, fetchHealth, fetchMint, fetchMints, fetchStats } from '../../lib/api';
|
||||
import {
|
||||
fetchFedimint, fetchFedimints, fetchHealth, fetchLnurlMint, fetchLnurlMints, fetchMint,
|
||||
fetchMints, fetchStats,
|
||||
} from '../../lib/api';
|
||||
import { fetchLatestReviews } from '../../lib/nostr-build';
|
||||
import { feedTargets, targetPath } from '../../lib/feed-resolve';
|
||||
import { displayDomain, iconGradient, sentiment, shortNpub } from '../../lib/format';
|
||||
@@ -23,23 +26,27 @@ const { locale } = Astro.props;
|
||||
const t = useI18n(locale);
|
||||
const f = formatters(t);
|
||||
|
||||
const [mints, federations, stats, health] = await Promise.all([
|
||||
const [mints, federations, lnurlMints, stats, health] = await Promise.all([
|
||||
fetchMints(),
|
||||
fetchFedimints(),
|
||||
fetchLnurlMints(),
|
||||
fetchStats(),
|
||||
fetchHealth(),
|
||||
]);
|
||||
|
||||
const top = mints.slice(0, 6);
|
||||
const topFederations = federations.slice(0, 6);
|
||||
const topLnurl = lnurlMints.slice(0, 6);
|
||||
|
||||
/*
|
||||
* The reviews strip resolves against both ecosystems, because its heading is "latest
|
||||
* The reviews strip resolves against every ecosystem, because its heading is "latest
|
||||
* reviews" and not "latest mint reviews". Federations rarely reach it in practice — the
|
||||
* junk filter wants a written body and nearly every 38173 review is a bare `[5/5]` —
|
||||
* but when one does, it is genuinely among the latest and it links to its own page.
|
||||
* but when one does, it is genuinely among the latest and it links to its own page. The
|
||||
* same is true of an LNURL mint, which today has the further handicap that 38174 is a
|
||||
* proposed kind with barely any events on the network at all.
|
||||
*/
|
||||
const reviewTargets = feedTargets(mints, federations);
|
||||
const reviewTargets = feedTargets(mints, federations, lnurlMints);
|
||||
|
||||
/*
|
||||
* The mint details and the relay read have nothing to say to each other — the reviews
|
||||
@@ -50,10 +57,11 @@ const reviewTargets = feedTargets(mints, federations);
|
||||
* few enough that they are all real, recent, written reviews. Anything the relays
|
||||
* cannot fill is simply fewer cards, and the carousel's controls disappear with them.
|
||||
*/
|
||||
const [details, fediDetails, latest] = await Promise.all([
|
||||
const [details, fediDetails, lnurlDetails, latest] = await Promise.all([
|
||||
// Distributions give the card sentiment bars real data instead of a rating proxy.
|
||||
Promise.all(top.map((m) => fetchMint(m.host).catch(() => null))),
|
||||
Promise.all(topFederations.map((federation) => fetchFedimint(federation.host).catch(() => null))),
|
||||
Promise.all(topLnurl.map((mint) => fetchLnurlMint(mint.host).catch(() => null))),
|
||||
fetchLatestReviews(reviewTargets, 10),
|
||||
]);
|
||||
|
||||
@@ -63,6 +71,20 @@ const capabilities = details.map((d) => (d ? readCapabilities(d.info?.nuts) : nu
|
||||
// No capability read for federations: they publish no NUT switches to draw a chip from.
|
||||
const fediSentiments = fediDetails.map((d) => (d ? sentiment(d.rating_distribution) : undefined));
|
||||
|
||||
const lnurlSentiments = lnurlDetails.map((d) => (d ? sentiment(d.rating_distribution) : undefined));
|
||||
/*
|
||||
* An LNURL card's chip comes from two probed facts rather than from NUT switches: an
|
||||
* advertised withdraw ceiling of zero, and a Lightning node the mint cannot reach.
|
||||
*/
|
||||
const lnurlChips = lnurlDetails.map((d) =>
|
||||
d
|
||||
? {
|
||||
max_withdrawable_msat: d.max_withdrawable_msat ?? null,
|
||||
funding_available: d.funding_available ?? null,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const description = t('home.description', {
|
||||
mints: stats.mints_total,
|
||||
reviews: stats.reviews_total,
|
||||
@@ -191,6 +213,37 @@ const signedBody = t('home.why.signed.body', {
|
||||
)
|
||||
}
|
||||
|
||||
{/*
|
||||
The LNURL grid: the third ecosystem strip, in the same order the nav lists them.
|
||||
Same ranked cards as /lnurl-mints, same real sentiment bars, and the same chip rule
|
||||
— so a reader moving down the page meets one card three times rather than three
|
||||
cards once each.
|
||||
*/}
|
||||
{
|
||||
topLnurl.length > 0 && (
|
||||
<section class="section" aria-labelledby="top-lnurl">
|
||||
<div class="sec-head" data-reveal>
|
||||
<h2 class="sec-title" id="top-lnurl">{t('home.topLnurl.title')}</h2>
|
||||
<span class="sec-sub">{t('home.topLnurl.sub')}</span>
|
||||
<a class="sec-link" href={localePath('/lnurl-mints', locale)}>
|
||||
{t('home.topLnurl.all', { n: stats.lnurl_total })}
|
||||
</a>
|
||||
</div>
|
||||
<div class="mint-grid">
|
||||
{topLnurl.map((mint, i) => (
|
||||
<MintCard
|
||||
mint={mint}
|
||||
rank={i + 1}
|
||||
sentiment={lnurlSentiments[i]}
|
||||
lnurl={lnurlChips[i]}
|
||||
revealDelay={i * 50}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<section class="section" aria-labelledby="latest-reviews-title" id="latest-reviews">
|
||||
<div class="sec-head" data-reveal>
|
||||
<h2 class="sec-title" id="latest-reviews-title">{t('home.latest.title')}</h2>
|
||||
|
||||
@@ -0,0 +1,695 @@
|
||||
---
|
||||
/**
|
||||
* One LNURL mint. The mint page's layout, with the panels an lnurlcash mint can
|
||||
* honestly fill.
|
||||
*
|
||||
* What is the same: the identity header, the warning banner slot, the four-cell verdict
|
||||
* strip, the reviews panel (identical component, identical cards, identical write
|
||||
* dialog), the related ring and the disclaimer.
|
||||
*
|
||||
* What is different, and why:
|
||||
*
|
||||
* - The NUTs panel becomes a Features panel. Same rows, but three states rather than
|
||||
* two: a mint can publish `mint` and `melt` and still be unable to perform either
|
||||
* right now, because the Lightning node behind it is unreachable. That is checkable
|
||||
* over HTTP — see NOTES-LNURL.md — so it is shown rather than papered over with a
|
||||
* tick.
|
||||
* - The limits cell shows *withdraw* bounds, not NUT-04 mint limits. They answer the
|
||||
* question a reader of this page actually has: how much can one of this mint's
|
||||
* bearer notes be worth?
|
||||
* - The sidebar gains the three things an LNURL mint publishes that no other ecosystem
|
||||
* does: a lightning address, a mint pubkey, and a Tor address.
|
||||
*
|
||||
* Everything the reviews panel needs comes from `lnurlSubject`, so nothing in the panel,
|
||||
* the card, the publisher or the dialog is aware this page exists.
|
||||
*/
|
||||
import Base from '../../../layouts/Base.astro';
|
||||
import Reviews from '../../../components/Reviews.astro';
|
||||
import MintLive from '../../../components/MintLive.astro';
|
||||
import Sparkline from '../../../components/Sparkline.astro';
|
||||
import CopyableId from '../../../components/CopyableId.astro';
|
||||
import MintCard from '../../../components/MintCard.astro';
|
||||
import {
|
||||
displayFeatures, getMintWarnings, isTestNetwork, msatToSat, otherFeatures,
|
||||
type LnurlDetail, type MintListItem,
|
||||
} from '@cashumints/shared';
|
||||
import { fetchLnurlMint, fetchLnurlMints, iconUrl } from '../../../lib/api';
|
||||
import { ogImageFor } from '../../../lib/og';
|
||||
import { lnurlSubject } from '../../../lib/review-subject';
|
||||
import { breadcrumbNode, mintServiceNode } from '../../../lib/schema';
|
||||
import { clampWords, cleanMintDescription, isIndexableMint, nameCollisions, seoName } from '../../../lib/seo';
|
||||
import { PRODUCT_JSONLD } from '../../../lib/site';
|
||||
import { bannerHtml } from '../../../lib/mint-state';
|
||||
import { feeText, featureRowsHtml, localFeatureName, withdrawLimitsHtml } from '../../../lib/lnurl-ui';
|
||||
import { localNetworkName } from '../../../lib/fedimint-ui';
|
||||
import { displayDomain, iconGradient, initials, transitionName, truncateMiddle } from '../../../lib/format';
|
||||
import { localePath, useI18n } from '../../../i18n';
|
||||
import { formatters, starString } from '../../../i18n/format';
|
||||
import { statusLabel, warningOptions } from '../../../i18n/mint';
|
||||
import { localePathsFor } from '../../../i18n/paths';
|
||||
|
||||
/** Every LNURL mint, in every locale. One API call per mint, shared across the locales. */
|
||||
export async function getStaticPaths() {
|
||||
const mints = await fetchLnurlMints();
|
||||
const details = await Promise.all(
|
||||
mints.map(async (mint, index) => {
|
||||
try {
|
||||
return { detail: await fetchLnurlMint(mint.host), rank: index + 1 };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const collisions = nameCollisions(mints);
|
||||
|
||||
/*
|
||||
* The same related ring the other two ecosystems use: position i links to i+1, i+2
|
||||
* and i+3, wrapping. Deterministic, never itself, and drawn only from mints whose own
|
||||
* pages are indexable — a page nobody should index needs no crawl paths out of it.
|
||||
*/
|
||||
const ring = mints.filter(isIndexableMint);
|
||||
const ringIndex = new Map(ring.map((mint, index) => [mint.host, index]));
|
||||
|
||||
const related = (host: string): MintListItem[] => {
|
||||
const at = ringIndex.get(host);
|
||||
if (at === undefined || ring.length < 2) return [];
|
||||
return [1, 2, 3]
|
||||
.filter((step) => step < ring.length)
|
||||
.map((step) => ring[(at + step) % ring.length]!);
|
||||
};
|
||||
|
||||
return localePathsFor(
|
||||
details
|
||||
.filter((d): d is { detail: LnurlDetail; rank: number } => d !== null)
|
||||
.map(({ detail, rank }) => ({
|
||||
params: { host: detail.host },
|
||||
props: {
|
||||
mint: detail,
|
||||
rank,
|
||||
related: related(detail.host),
|
||||
seoName: seoName(detail.name, detail.host, collisions),
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const { mint, rank, related, seoName: titleName, locale } = Astro.props;
|
||||
|
||||
const t = useI18n(locale);
|
||||
const f = formatters(t);
|
||||
|
||||
/*
|
||||
* The row key carries an `lnurl:` scheme in front of the URL, so everything a reader
|
||||
* sees comes from `base_url` instead. A page printing `lnurl:https://…` at somebody
|
||||
* would be showing them a database key.
|
||||
*/
|
||||
const baseUrl = mint.base_url ?? mint.url.replace(/^lnurl:/, '');
|
||||
const domain = displayDomain(baseUrl);
|
||||
const name = mint.name ?? domain.split('/')[0] ?? domain;
|
||||
const icon = iconUrl(mint.icon);
|
||||
const status = statusLabel(mint.status, t);
|
||||
const offline = mint.status === 'offline';
|
||||
|
||||
/*
|
||||
* What the operator announced, plus what the probe actually saw.
|
||||
*
|
||||
* A union, because the two are different kinds of evidence and both are worth showing.
|
||||
* The announcement is richer — only an operator can tell you their notes rotate — but
|
||||
* `kind:38174` is a proposed kind, so today most LNURL mints have no announcement at
|
||||
* all and the probe is the only list there is. The panel's own note explains where the
|
||||
* rows came from; `LnurlFields.features` still holds the announcement verbatim.
|
||||
*/
|
||||
const features = displayFeatures(mint.features, mint.observed_features);
|
||||
const extraFeatures = otherFeatures(features);
|
||||
const network = mint.network ?? null;
|
||||
const fee = feeText(mint.fee_base_msat, mint.fee_ppm, f);
|
||||
|
||||
/*
|
||||
* The software string, from `GET /openapi.json` — the only place an LNURL mint's version
|
||||
* is discoverable at all. No header carries it, and the one-pager's title is operator
|
||||
* configurable, so it identifies nothing. See NOTES-LNURL.md §6.
|
||||
*/
|
||||
const software = mint.version;
|
||||
const softwareName = software?.split('/')[0] ?? null;
|
||||
|
||||
/* verdict: rating. Identical to the other two pages', because a rating means the same. */
|
||||
const dist = mint.rating_distribution;
|
||||
const distMax = Math.max(1, ...Object.values(dist));
|
||||
const ratedCount = Object.values(dist).reduce((a, b) => a + b, 0);
|
||||
const unrated = mint.review_count - ratedCount;
|
||||
|
||||
/*
|
||||
* The banner.
|
||||
*
|
||||
* `getMintWarnings` takes the LNURL branch on `type`, so this page can receive the three
|
||||
* offline tiers — identical thresholds and identical words to a Cashu mint's — plus the
|
||||
* two states only this ecosystem has: a zero withdraw ceiling, and a Lightning node the
|
||||
* mint cannot reach. Nothing here is derived from the `features` tag, which is a claim
|
||||
* rather than an observation.
|
||||
*/
|
||||
const warning = getMintWarnings(mint, warningOptions(f))[0] ?? null;
|
||||
|
||||
/* SEO. Same templates as a mint page, with the Cashu limits clause swapped for withdraw. */
|
||||
const hasRating = mint.review_count > 0 && mint.rating_avg !== null;
|
||||
const offlineDays =
|
||||
offline && mint.last_online !== null
|
||||
? Math.max(0, Math.floor(Date.now() / 1000 / 86400 - mint.last_online / 86400))
|
||||
: null;
|
||||
|
||||
const title =
|
||||
offlineDays !== null
|
||||
? t('seo.mint.titleOffline', { name: titleName, n: offlineDays })
|
||||
: hasRating
|
||||
? t('seo.mint.title', {
|
||||
name: titleName,
|
||||
rating: f.decimal(mint.rating_avg!),
|
||||
n: mint.review_count,
|
||||
})
|
||||
: t('seo.lnurl.titleNoReviews', { name: titleName });
|
||||
|
||||
const lead =
|
||||
cleanMintDescription(mint.description) ?? t('seo.desc.fallbackDomain', { domain });
|
||||
|
||||
const ratingPart = hasRating
|
||||
? t('seo.desc.rated', { rating: f.decimal(mint.rating_avg!), n: mint.review_count })
|
||||
: t('seo.desc.noReviews');
|
||||
|
||||
/*
|
||||
* The third sentence. A warning's own meta line replaces the plain status, because the
|
||||
* state is the one thing a search result must not soften.
|
||||
*
|
||||
* Otherwise it is the withdraw range, which is this ecosystem's answer to the Cashu
|
||||
* description's mint limits: the same shape of sentence about the number that actually
|
||||
* matters here.
|
||||
*/
|
||||
const minSat = msatToSat(mint.min_withdrawable_msat ?? null);
|
||||
const maxSat = msatToSat(mint.max_withdrawable_msat ?? null);
|
||||
const statusPart = warning
|
||||
? `${warning.meta}.`
|
||||
: minSat !== null && maxSat !== null
|
||||
? t('seo.desc.statusWithdraw', { status, min: f.number(minSat), max: f.number(maxSat) })
|
||||
: t('seo.desc.status', { status });
|
||||
|
||||
const description = clampWords(`${lead} ${ratingPart} ${statusPart}`, 158);
|
||||
|
||||
/* The card: one English render shared by all locales, hashed filename per state. */
|
||||
const ogImage = ogImageFor(mint.host);
|
||||
const imageAlt = hasRating
|
||||
? t('seo.mint.ogAlt', { name: titleName, rating: f.decimal(mint.rating_avg!), n: mint.review_count })
|
||||
: t('seo.mint.ogAltNoReviews', { name: titleName });
|
||||
|
||||
const indexable = isIndexableMint(mint);
|
||||
|
||||
const crumbs = [
|
||||
{ name: t('nav.home'), url: new URL(localePath('/', locale), Astro.site).href },
|
||||
{ name: t('lnurl.crumb'), url: new URL(localePath('/lnurl-mints', locale), Astro.site).href },
|
||||
{ name: domain },
|
||||
];
|
||||
|
||||
const canonical = new URL(localePath(`/lnurl-mint/${mint.host}`, locale), Astro.site).href;
|
||||
|
||||
const schema = [
|
||||
breadcrumbNode(canonical, crumbs),
|
||||
mintServiceNode({
|
||||
canonical,
|
||||
name: titleName,
|
||||
description,
|
||||
product: PRODUCT_JSONLD,
|
||||
mintUrl: baseUrl,
|
||||
icon: icon ? new URL(icon, Astro.site).href : null,
|
||||
rating:
|
||||
ratedCount > 0 && mint.rating_avg !== null
|
||||
? {
|
||||
value: Number(mint.rating_avg.toFixed(1)),
|
||||
ratingCount: ratedCount,
|
||||
reviewCount: mint.review_count,
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
];
|
||||
---
|
||||
|
||||
<Base
|
||||
title={title}
|
||||
description={description}
|
||||
current="lnurl-mints"
|
||||
noindex={!indexable}
|
||||
schema={schema}
|
||||
image={ogImage ?? undefined}
|
||||
imageAlt={imageAlt}
|
||||
>
|
||||
<div class="page">
|
||||
<nav class="crumbs" aria-label={t('lnurl.breadcrumb')}>
|
||||
<a href={localePath('/', locale)}>{t('nav.home')}</a>
|
||||
<span class="sep" aria-hidden="true">/</span>
|
||||
<a href={localePath('/lnurl-mints', locale)}>{t('lnurl.crumb')}</a>
|
||||
<span class="sep" aria-hidden="true">/</span>
|
||||
<span class="here mono">{domain}</span>
|
||||
</nav>
|
||||
|
||||
<div class="mint-head">
|
||||
{
|
||||
icon ? (
|
||||
<img
|
||||
class="mint-icon"
|
||||
src={icon}
|
||||
alt=""
|
||||
width="72"
|
||||
height="72"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchpriority="high"
|
||||
style={`view-transition-name:${transitionName('icon', mint.host)}`}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
class="mint-icon"
|
||||
style={`background:${iconGradient(domain)};view-transition-name:${transitionName('icon', mint.host)}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initials(name)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="mint-id">
|
||||
<div class="mint-name-row">
|
||||
<h1 style={`view-transition-name:${transitionName('name', mint.host)}`}>{name}</h1>
|
||||
<span class:list={['status-chip', mint.status]} data-live-status>
|
||||
<span class="status-dot"></span><span data-live-status-label>{status}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mint-domain">
|
||||
<span class="mono">{baseUrl}</span>
|
||||
<button class="copy-btn" data-copy={baseUrl} aria-label={t('mint.copyUrl')} type="button">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<rect x="9" y="9" width="12" height="12" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mint-tags">
|
||||
<span class="tag">{t('mint.rankByScore', { rank: String(rank) })}</span>
|
||||
{
|
||||
/*
|
||||
Not mainnet is called out rather than stated flatly, exactly as it is on a
|
||||
federation page: a signet mint's notes are worth nothing, and someone
|
||||
scanning for the real one needs that to be loud.
|
||||
*/
|
||||
network && (
|
||||
<span class:list={['tag', { testnet: isTestNetwork(network) }]}>
|
||||
{localNetworkName(network, f)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{softwareName && <span class="tag mono">{softwareName}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="head-actions">
|
||||
{
|
||||
mint.lightning_address && (
|
||||
<button class="btn" data-copy={mint.lightning_address} type="button">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M13 2 4 14h7l-1 8 9-12h-7l1-8Z" />
|
||||
</svg>
|
||||
{t('lnurl.copyAddress')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
<button class="btn" data-copy={baseUrl} type="button">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<rect x="9" y="9" width="12" height="12" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" />
|
||||
</svg>
|
||||
{t('mint.copyUrl')}
|
||||
</button>
|
||||
<button class="btn primary" data-write-open type="button">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</svg>
|
||||
{t('mint.writeReview')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Fragment set:html={bannerHtml(warning)} />
|
||||
|
||||
<section class="verdict" aria-labelledby="lnurl-summary">
|
||||
<h2 class="sr-only" id="lnurl-summary">{t('mint.summary')}</h2>
|
||||
|
||||
<div class="v-cell rating-cell" data-reveal data-reveal-delay="0">
|
||||
<div class="v-label">{t('mint.rating.label')}</div>
|
||||
<div class="rating-body">
|
||||
<div class="rating-figure">
|
||||
{
|
||||
mint.rating_avg === null ? (
|
||||
<>
|
||||
<div class="v-big">{t('time.none')}</div>
|
||||
<div class="v-sub">{t('mint.rating.none')}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div class="v-big" data-count-up={mint.rating_avg.toFixed(1)} data-count-decimals="1">
|
||||
{f.decimal(mint.rating_avg)}
|
||||
</div>
|
||||
<div class="stars" aria-hidden="true">{starString(mint.rating_avg)}</div>
|
||||
<div class="v-sub">
|
||||
{t('mint.rating.reviews', { n: mint.review_count })}
|
||||
{unrated > 0 && <><br />{t('mint.rating.unrated', { n: unrated })}</>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div class="dist" aria-hidden="true">
|
||||
{
|
||||
([5, 4, 3, 2, 1] as const).map((n) => {
|
||||
const count = dist[String(n) as '1' | '2' | '3' | '4' | '5'];
|
||||
return (
|
||||
<div class="dist-row">
|
||||
<span class="n">{n}</span>
|
||||
<span class="dist-bar">
|
||||
<span
|
||||
class:list={['dist-fill', { low: n === 1 }]}
|
||||
style={`width:${Math.round((count / distMax) * 100)}%`}
|
||||
/>
|
||||
</span>
|
||||
<span class="c">{f.number(count)}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="v-cell" data-reveal data-reveal-delay="60">
|
||||
<div class="v-label">{t('mint.activity.label')}</div>
|
||||
<div class="v-big">{f.shortDuration(mint.last_review_at)}</div>
|
||||
<div class="v-sub">
|
||||
{mint.last_review_at ? t('mint.activity.since') : t('mint.activity.noReviews')}
|
||||
{mint.reviews_90d > 0 && (
|
||||
<><br />{t('mint.activity.recent', { n: mint.reviews_90d })}</>
|
||||
)}
|
||||
</div>
|
||||
<Sparkline probes={mint.probes_recent} uptime={mint.uptime_30d} />
|
||||
</div>
|
||||
|
||||
<div class="v-cell" data-reveal data-reveal-delay="120">
|
||||
<div class="v-label">{t('lnurl.limits.label')}</div>
|
||||
{/*
|
||||
Withdraw bounds, not mint limits: this is what one of this mint's notes can be
|
||||
worth. A ceiling of zero is struck out rather than printed as "0 sat", because
|
||||
it is the disabling value and the banner above is already explaining it.
|
||||
*/}
|
||||
<div data-live-limits set:html={withdrawLimitsHtml(mint.min_withdrawable_msat, mint.max_withdrawable_msat, f)} />
|
||||
</div>
|
||||
|
||||
<div class="v-cell" data-reveal data-reveal-delay="180">
|
||||
<div class="v-label">{software ? t('mint.software.label') : t('lnurl.confirmed.label')}</div>
|
||||
<div class="v-mono">
|
||||
{/*
|
||||
A version when one was discoverable, and the last confirmation when not.
|
||||
An LNURL mint's version comes only from `/openapi.json`, which an operator
|
||||
can turn off, so "unknown" here is common enough that a cell reading it twice
|
||||
would be furniture — the fallback says something true instead.
|
||||
*/}
|
||||
{software && <><b>{software.replace('/', ' ')}</b><br /></>}
|
||||
<span data-live-checked>
|
||||
{
|
||||
offline
|
||||
? mint.last_online
|
||||
? t('mint.lastSeen', { when: f.relative(mint.last_online) })
|
||||
: t('mint.neverReached')
|
||||
: software
|
||||
? t('mint.checked', { when: f.relative(mint.last_probe) })
|
||||
: t('lnurl.confirmed.when', { when: f.relative(mint.last_probe) })
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="cols">
|
||||
<Reviews
|
||||
subject={lnurlSubject(mint)}
|
||||
reviewCount={mint.review_count}
|
||||
distribution={mint.rating_distribution}
|
||||
name={name}
|
||||
/>
|
||||
|
||||
<aside class="side">
|
||||
<div class="panel" data-reveal>
|
||||
<h2 class="side-title">{t('lnurl.features.title')}</h2>
|
||||
{
|
||||
features.length === 0 ? (
|
||||
<p class="side-empty">{t('lnurl.features.none')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div class="nut-hi" data-live-features set:html={featureRowsHtml(features, mint.funding_available ?? null, f)} />
|
||||
{extraFeatures.length > 0 && (
|
||||
<div class="nut-rest">
|
||||
{extraFeatures.map((feature) => (
|
||||
<span class="nut-chip" title={localFeatureName(feature, f)}>{feature}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p class="nut-more">{t('lnurl.features.note')}</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="panel" data-reveal>
|
||||
<h2 class="side-title">{t('lnurl.details.title')}</h2>
|
||||
<div class="kv">
|
||||
{
|
||||
mint.lightning_address && (
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('lnurl.details.address')}</span>
|
||||
<CopyableId
|
||||
class="kv-v"
|
||||
value={mint.lightning_address}
|
||||
label={mint.lightning_address}
|
||||
kind={t('lnurl.kind.lightningAddress')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
mint.mint_pubkey && (
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('lnurl.details.mintPubkey')}</span>
|
||||
<CopyableId
|
||||
class="kv-v"
|
||||
value={mint.mint_pubkey}
|
||||
label={truncateMiddle(mint.mint_pubkey, 10, 6)}
|
||||
kind={t('lnurl.kind.mintPubkey')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
mint.onion_url && (
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('lnurl.details.onion')}</span>
|
||||
<CopyableId
|
||||
class="kv-v"
|
||||
value={mint.onion_url}
|
||||
label={truncateMiddle(mint.onion_url, 14, 12)}
|
||||
kind={t('lnurl.kind.onionAddress')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
fee && (
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('lnurl.details.fee')}</span>
|
||||
<span class="kv-v">{fee}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
network && (
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('fedimint.details.network')}</span>
|
||||
<span class="kv-v">{localNetworkName(network, f)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
/*
|
||||
The funding node, as the mint chooses to describe it. `nodeCapacity` is
|
||||
explicitly the *publicly announced* channel capacity — lnurl-mint reads
|
||||
it from the public graph, never from a private `listfunds` view — so
|
||||
printing it discloses nothing the node's own gossip does not.
|
||||
*/
|
||||
mint.node_alias && (
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('lnurl.details.node')}</span>
|
||||
<span class="kv-v">{mint.node_alias}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
mint.node_capacity_msat !== null && mint.node_capacity_msat !== undefined && (
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('lnurl.details.capacity')}</span>
|
||||
<span class="kv-v">
|
||||
{t('lnurl.limits.sats', { n: msatToSat(mint.node_capacity_msat) ?? 0 })}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
mint.announcer_pubkey && (
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('fedimint.details.announcedBy')}</span>
|
||||
<CopyableId
|
||||
class="kv-v"
|
||||
value={mint.announcer_pubkey}
|
||||
label={truncateMiddle(mint.announcer_pubkey, 10, 6)}
|
||||
kind={t('mint.kind.mintPubkey')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('mint.details.firstSeen')}</span>
|
||||
<span class="kv-v">{f.monthYear(mint.first_seen)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{
|
||||
related.length > 0 && (
|
||||
<section class="more-mints" aria-labelledby="more-lnurl">
|
||||
<div class="sec-head" data-reveal>
|
||||
<h2 class="sec-title" id="more-lnurl">{t('lnurl.more.title')}</h2>
|
||||
<a class="sec-link" href={localePath('/lnurl-mints', locale)}>{t('lnurl.more.all')}</a>
|
||||
</div>
|
||||
<div class="mint-grid">
|
||||
{related.map((item) => <MintCard mint={item} />)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<p class="mint-disclaimer">
|
||||
{t('lnurl.disclaimer')}
|
||||
<a href={localePath('/disclaimer', locale)}>{t('mint.disclaimerLink')}</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MintLive host={mint.host} />
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.more-mints { margin-top: 52px; }
|
||||
|
||||
.mint-disclaimer {
|
||||
margin-top: 30px; padding-top: 18px; border-top: 1px solid var(--line-soft);
|
||||
font-size: 13px; color: var(--faint); max-width: 720px;
|
||||
}
|
||||
.mint-disclaimer a { color: var(--amber); border-bottom: 1px dotted rgba(240, 169, 59, .4); }
|
||||
|
||||
.mint-head { display: flex; align-items: flex-start; gap: 20px; flex-wrap: wrap; margin-bottom: 22px; }
|
||||
.mint-icon {
|
||||
width: 72px; height: 72px; border-radius: 18px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-family: var(--display); font-weight: 700; font-size: 32px; color: #0E0D0B;
|
||||
border: 1px solid rgba(255, 255, 255, .14); object-fit: cover; overflow: hidden;
|
||||
}
|
||||
.mint-id { min-width: 240px; flex: 1; }
|
||||
.mint-name-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
h1 { font-family: var(--display); font-size: 32px; font-weight: 700; letter-spacing: -.02em; line-height: 1.1; overflow-wrap: anywhere; }
|
||||
.mint-domain {
|
||||
display: flex; align-items: center; gap: 8px; margin-top: 8px;
|
||||
font-size: 14px; color: var(--muted);
|
||||
}
|
||||
.mint-domain .mono { font-family: var(--mono); font-size: 13.5px; overflow-wrap: anywhere; }
|
||||
.copy-btn {
|
||||
background: none; border: none; padding: 3px; cursor: pointer;
|
||||
color: var(--faint); display: inline-flex; border-radius: 5px;
|
||||
transition: color .15s;
|
||||
}
|
||||
.copy-btn:hover { color: var(--amber); }
|
||||
|
||||
.mint-tags { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.tag { font-size: 12px; font-weight: 500; color: var(--muted); border: 1px solid var(--line); border-radius: 7px; padding: 3px 9px; }
|
||||
.tag.mono { font-family: var(--mono); font-size: 11.5px; }
|
||||
/* Not mainnet: the notes are play money and the badge has to read that way. */
|
||||
.tag.testnet { color: var(--amber); border-color: var(--amber-dim); background: rgba(240, 169, 59, .07); font-weight: 600; }
|
||||
|
||||
.head-actions { margin-left: auto; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
|
||||
.verdict {
|
||||
display: grid; grid-template-columns: 1.35fr 1fr 1fr 1fr;
|
||||
border: 1px solid var(--line); border-radius: var(--radius);
|
||||
background: var(--card); overflow: hidden; margin-bottom: 28px;
|
||||
}
|
||||
.v-cell { padding: 20px; border-right: 1px solid var(--line-soft); position: relative; }
|
||||
.v-cell:last-child { border-right: none; }
|
||||
.v-label {
|
||||
font-size: 11px; font-weight: 600; letter-spacing: .09em; text-transform: uppercase;
|
||||
color: var(--faint); margin-bottom: 10px; display: flex; align-items: center; gap: 6px;
|
||||
height: 16px;
|
||||
}
|
||||
.v-big { font-family: var(--display); font-size: 34px; font-weight: 700; letter-spacing: -.02em; line-height: 1; }
|
||||
.v-sub { font-size: 13px; color: var(--muted); margin-top: 6px; }
|
||||
.rating-body { display: flex; gap: 24px; align-items: flex-start; flex-wrap: wrap; }
|
||||
.rating-figure { flex: 0 0 auto; }
|
||||
.rating-body .dist { flex: 1 1 150px; }
|
||||
.rating-cell .stars { font-size: 15px; margin-top: 7px; }
|
||||
|
||||
.cols { display: grid; grid-template-columns: 1fr 372px; gap: 26px; align-items: start; }
|
||||
.side { display: flex; flex-direction: column; gap: 20px; }
|
||||
.side .panel { padding: 20px; }
|
||||
.side-empty { font-size: 13.5px; color: var(--muted); }
|
||||
|
||||
.nut-rest { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.nut-chip { font-family: var(--mono); font-size: 10.5px; line-height: 1.5; color: var(--muted); border: 1px solid var(--line); border-radius: 5px; padding: 2px 6px; }
|
||||
.nut-more { font-size: 12px; color: var(--faint); margin-top: 10px; line-height: 1.55; }
|
||||
|
||||
.kv-row :global(.cid.kv-v) { flex: 1; font-size: 12.5px; color: var(--text); }
|
||||
.kv-row :global(.cid.kv-v:hover) { color: var(--amber); }
|
||||
|
||||
@media (max-width: 1020px) {
|
||||
.cols { grid-template-columns: 1fr; }
|
||||
.verdict { grid-template-columns: 1fr 1fr; }
|
||||
.v-cell:nth-child(2) { border-right: none; }
|
||||
.v-cell:nth-child(1), .v-cell:nth-child(2) { border-bottom: 1px solid var(--line-soft); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.verdict { grid-template-columns: 1fr; }
|
||||
.v-cell { border-right: none; border-bottom: 1px solid var(--line-soft); }
|
||||
.v-cell:last-child { border-bottom: none; }
|
||||
.head-actions { margin-left: 0; width: 100%; }
|
||||
.head-actions .btn { flex: 1 1 44%; justify-content: center; }
|
||||
.page { padding: 20px var(--page-pad) 60px; }
|
||||
h1 { font-size: 26px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { wireCopyableIds, wireCopyButtons } from '../../../lib/client';
|
||||
import { countUp, onReady, whenRevealed } from '../../../scripts/reveal';
|
||||
|
||||
onReady(() => {
|
||||
wireCopyButtons();
|
||||
wireCopyableIds();
|
||||
|
||||
const big = document.querySelector<HTMLElement>('[data-count-up]');
|
||||
const cell = big?.closest<HTMLElement>('[data-reveal]');
|
||||
if (big && cell) {
|
||||
const target = Number(big.dataset['countUp']);
|
||||
const decimals = Number(big.dataset['countDecimals'] ?? '0');
|
||||
whenRevealed(cell, () => countUp(big, target, { decimals }));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,442 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import MintCard from '../../components/MintCard.astro';
|
||||
import ReviewByUrl from '../../components/ReviewByUrl.astro';
|
||||
import type { LnurlDetail } from '@cashumints/shared';
|
||||
import { fetchLnurlMint, fetchLnurlMints } from '../../lib/api';
|
||||
import { localePath, useI18n, type Locale } from '../../i18n';
|
||||
import { itemListNode } from '../../lib/schema';
|
||||
import { isIndexableMint } from '../../lib/seo';
|
||||
import { localePaths } from '../../i18n/paths';
|
||||
|
||||
/**
|
||||
* The LNURL mint index. /mints' twin, and built from the same parts for the same reason
|
||||
* /fedimints is: same container, same card, same search, same result line, same
|
||||
* leave-and-return animation.
|
||||
*
|
||||
* Unlike /fedimints it keeps the full sort set including "recently seen online". That
|
||||
* page drops it because a third of its rows have no `last_online` at all — nothing
|
||||
* checks those federations, so ordering on the column is a shuffle rather than a sort.
|
||||
* An LNURL mint is genuinely probed over HTTPS, exactly as a Cashu mint is, so every row
|
||||
* here has a real answer and the sort means what it says.
|
||||
*/
|
||||
|
||||
/** One page per locale: `/lnurl-mints`, `/es/lnurl-mints`, `/nl/lnurl-mints`. */
|
||||
export const getStaticPaths = localePaths;
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
|
||||
// `t` formats the numbers inside its own strings, so no separate formatter is needed.
|
||||
const t = useI18n(locale);
|
||||
|
||||
const mints = await fetchLnurlMints();
|
||||
|
||||
/*
|
||||
The list payload carries no LNURL fields, so the "no withdrawals" and "no mint / melt"
|
||||
chips need each mint's detail. One fetch per mint, at build time, against the API on
|
||||
the same machine — exactly what /mints does for its NUT switches. A mint whose detail
|
||||
cannot be read simply gets no chip.
|
||||
*/
|
||||
const details = await Promise.all(
|
||||
mints.map((mint) => fetchLnurlMint(mint.host).catch(() => null)),
|
||||
);
|
||||
const chipSources = details.map((detail: LnurlDetail | null) =>
|
||||
detail
|
||||
? {
|
||||
max_withdrawable_msat: detail.max_withdrawable_msat ?? null,
|
||||
funding_available: detail.funding_available ?? null,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const online = mints.filter((m) => m.status === 'online').length;
|
||||
const offline = mints.filter((m) => m.status === 'offline').length;
|
||||
|
||||
const description = t('lnurlMints.description', { total: mints.length, online, offline });
|
||||
|
||||
/*
|
||||
* The index, as a list a crawler can read in order.
|
||||
*
|
||||
* Only the mints whose own pages are indexable: listing a page here while telling a
|
||||
* crawler not to index it is a contradiction. The order is the server-rendered one,
|
||||
* which is the score order; the sort control rewrites the DOM but not this markup,
|
||||
* so `ItemListOrderDescending` describes what shipped.
|
||||
*/
|
||||
const canonical = new URL(localePath('/lnurl-mints', locale), Astro.site).href;
|
||||
const schema = [
|
||||
itemListNode({
|
||||
canonical,
|
||||
fragment: 'lnurl-mints',
|
||||
name: t('lnurlMints.heading'),
|
||||
items: mints.filter(isIndexableMint).map((mint) => ({
|
||||
name: mint.name ?? mint.host,
|
||||
url: new URL(localePath(`/lnurl-mint/${mint.host}`, locale), Astro.site).href,
|
||||
})),
|
||||
}),
|
||||
];
|
||||
---
|
||||
|
||||
<Base
|
||||
title={t('lnurlMints.title')}
|
||||
description={description}
|
||||
current="lnurl-mints"
|
||||
schema={schema}
|
||||
>
|
||||
{/*
|
||||
The same .page container the home page and the mint pages use, so the heading,
|
||||
the controls, the count line and the grid all share one left edge and one width.
|
||||
*/}
|
||||
<div class="page">
|
||||
<div class="index-head">
|
||||
<div class="index-head-text">
|
||||
<h1>{t('lnurlMints.heading')}</h1>
|
||||
<p class="lede">{t('lnurlMints.lede')}</p>
|
||||
</div>
|
||||
{/*
|
||||
The way in for something this site has never seen. Every other route to writing
|
||||
a review starts from a page that already exists, which is exactly the case a
|
||||
reader with a brand new mint does not have.
|
||||
*/}
|
||||
<button class="btn primary index-head-action" type="button" data-review-url-open="lnurl">
|
||||
{t('reviews.byUrl.open')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="controls" data-mint-controls>
|
||||
<form class="search" role="search" onsubmit="return false">
|
||||
<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="mint-search">{t('lnurlMints.search.label')}</label>
|
||||
<input
|
||||
id="mint-search"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder={t('lnurlMints.search.placeholder')}
|
||||
data-search-input
|
||||
/>
|
||||
<kbd aria-hidden="true">/</kbd>
|
||||
</form>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="sr-only" for="mint-sort">{t('lnurlMints.sort.label')}</label>
|
||||
<div class="select-wrap">
|
||||
<select id="mint-sort" data-sort>
|
||||
<option value="score">{t('mints.sort.score')}</option>
|
||||
<option value="rating">{t('mints.sort.rating')}</option>
|
||||
<option value="reviews">{t('mints.sort.reviews')}</option>
|
||||
<option value="recent">{t('mints.sort.recent')}</option>
|
||||
<option value="seen">{t('mints.sort.seen')}</option>
|
||||
</select>
|
||||
<svg class="chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true">
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
A pressed pill rather than a native checkbox: it is the same control the
|
||||
review filters use, and it is a real button, so it keeps the focus ring.
|
||||
*/}
|
||||
<button class="toggle-chip" type="button" data-hide-offline aria-pressed="false">
|
||||
<span class="toggle-dot" aria-hidden="true"></span>
|
||||
{t('lnurlMints.hideOffline')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="result-count" data-result-count aria-live="polite">
|
||||
{t('lnurlMints.showing', { total: mints.length, online, offline })}
|
||||
</p>
|
||||
|
||||
<div class="mint-grid" data-mint-grid>
|
||||
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} lnurl={chipSources[i]} />)}
|
||||
</div>
|
||||
|
||||
<p class="no-results" data-no-results hidden>
|
||||
{t('lnurlMints.noResults')}
|
||||
<button class="link-btn" type="button" data-clear-search>{t('mints.clearSearch')}</button>
|
||||
</p>
|
||||
|
||||
{mints.length === 0 && <p class="no-results">{t('lnurlMints.emptyIndex')}</p>}
|
||||
</div>
|
||||
|
||||
{/* Two steps in one dialog: identify the mint, then the ordinary review form. */}
|
||||
<ReviewByUrl type="lnurl" />
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
/* Heading and lede on the left, the one primary action on the right, wrapping to
|
||||
its own line before the lede has to give up any width. */
|
||||
.index-head { margin-bottom: 24px; display: flex; align-items: flex-start; gap: 16px; flex-wrap: wrap; }
|
||||
.index-head-text { flex: 1 1 320px; }
|
||||
.index-head-action { flex-shrink: 0; }
|
||||
.index-head h1 { font-family: var(--display); font-size: 32px; font-weight: 700; letter-spacing: -.02em; line-height: 1.1; }
|
||||
.index-head .lede { max-width: 620px; font-size: 15.5px; margin-top: 10px; }
|
||||
|
||||
.controls { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
.search {
|
||||
display: flex; align-items: center; gap: 12px; flex: 1 1 320px; min-width: 240px;
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 12px; padding: 10px 16px;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.search:focus-within { border-color: var(--amber-dim); box-shadow: 0 0 0 3px rgba(240, 169, 59, .08); }
|
||||
.search svg { color: var(--faint); flex-shrink: 0; }
|
||||
.search input {
|
||||
flex: 1; background: none; border: none; outline: none; min-width: 0;
|
||||
color: var(--text); font-family: var(--body); font-size: 15px;
|
||||
}
|
||||
.search input::placeholder { color: var(--faint); }
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Search grows; sort and toggle sit together at the right and wrap as a pair. */
|
||||
.control-group { display: flex; gap: 10px; align-items: center; margin-left: auto; flex-wrap: wrap; }
|
||||
|
||||
.select-wrap { position: relative; display: flex; align-items: center; }
|
||||
.select-wrap select {
|
||||
appearance: none; -webkit-appearance: none;
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: 9px;
|
||||
color: var(--text); font-family: var(--body); font-size: 14px;
|
||||
padding: 10px 34px 10px 14px; cursor: pointer;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.select-wrap select:hover { border-color: #38342A; }
|
||||
.select-wrap .chevron { position: absolute; right: 12px; color: var(--faint); pointer-events: none; }
|
||||
.select-wrap option { background: var(--card); color: var(--text); }
|
||||
|
||||
.toggle-chip {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
font-family: var(--body); font-size: 13.5px; font-weight: 500;
|
||||
color: var(--muted); cursor: pointer;
|
||||
border: 1px solid var(--line); background: none; border-radius: 999px;
|
||||
padding: 9px 15px;
|
||||
transition: color .15s, border-color .15s, background .15s;
|
||||
}
|
||||
.toggle-chip:hover { color: var(--text); border-color: #38342A; }
|
||||
.toggle-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%; background: var(--faint); flex-shrink: 0;
|
||||
transition: background .15s;
|
||||
}
|
||||
.toggle-chip[aria-pressed='true'] {
|
||||
color: #141109; background: var(--amber); border-color: var(--amber); font-weight: 600;
|
||||
}
|
||||
.toggle-chip[aria-pressed='true'] .toggle-dot { background: #141109; }
|
||||
|
||||
.result-count { font-size: 13px; color: var(--faint); margin-bottom: 16px; font-family: var(--mono); }
|
||||
|
||||
/*
|
||||
* A card leaving the results fades and settles back a touch, 120ms, then it is
|
||||
* hidden and the grid reflows around it. Positions are not animated: the browser's
|
||||
* own reflow is instant and honest, and 58 cards sliding to new places would be a
|
||||
* light show rather than a search result.
|
||||
*/
|
||||
.mint-grid :global(.mint-card.leaving) {
|
||||
transform: scale(.98);
|
||||
transition: opacity 120ms var(--ease-out), transform 120ms var(--ease-out);
|
||||
}
|
||||
|
||||
.no-results { color: var(--muted); font-size: 14.5px; padding: 34px 0; }
|
||||
.link-btn {
|
||||
background: none; border: none; padding: 0; cursor: pointer;
|
||||
font-family: var(--body); font-size: inherit; color: var(--amber);
|
||||
border-bottom: 1px solid var(--amber-dim);
|
||||
}
|
||||
.link-btn:hover { border-bottom-color: var(--amber); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
/* Sort and toggle take their own row, still sharing the grid's left edge. */
|
||||
.control-group { margin-left: 0; width: 100%; }
|
||||
.select-wrap, .select-wrap select { flex: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { wireCopyableIds } from '../../lib/client';
|
||||
import { useI18n } from '../../i18n/client';
|
||||
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
|
||||
|
||||
/** How long a card takes to fade out of the results before it is hidden. */
|
||||
const LEAVE_MS = 120;
|
||||
|
||||
const setup = (): void => {
|
||||
wireCopyableIds();
|
||||
|
||||
// Re-read on every arrival, so the count line under the filters follows the page.
|
||||
// `t` formats its own numbers, so the grouping separator follows the locale too.
|
||||
const t = useI18n();
|
||||
|
||||
const grid = document.querySelector<HTMLElement>('[data-mint-grid]');
|
||||
const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]');
|
||||
const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]');
|
||||
const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]');
|
||||
const countEl = document.querySelector<HTMLElement>('[data-result-count]');
|
||||
const noResults = document.querySelector<HTMLElement>('[data-no-results]');
|
||||
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
|
||||
|
||||
if (grid && searchInput && sortSelect && hideOffline) {
|
||||
const cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
|
||||
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
|
||||
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
|
||||
|
||||
const SORTS: Record<string, (a: HTMLElement, b: HTMLElement) => number> = {
|
||||
// Default keeps the API's rule: online first, then score. Every other sort
|
||||
// ranks offline mints on their merits, so a dead mint with 40 good reviews
|
||||
// still shows up under "Most reviewed".
|
||||
score: (a, b) => offlineRank(a) - offlineRank(b) || num(b, 'score') - num(a, 'score'),
|
||||
rating: (a, b) => num(b, 'rating') - num(a, 'rating') || num(b, 'reviews') - num(a, 'reviews'),
|
||||
reviews: (a, b) => num(b, 'reviews') - num(a, 'reviews'),
|
||||
recent: (a, b) => num(b, 'lastReview') - num(a, 'lastReview'),
|
||||
seen: (a, b) => num(b, 'lastOnline') - num(a, 'lastOnline'),
|
||||
};
|
||||
|
||||
/**
|
||||
* One pending fade per card, at most.
|
||||
*
|
||||
* Typing into the search box calls `apply` on every keystroke, so a card can be
|
||||
* told to leave and then to come back before its fade has finished. Cancelling
|
||||
* the timer is what keeps those from stacking: the card either goes out once, or
|
||||
* never starts.
|
||||
*/
|
||||
const leaving = new Map<HTMLElement, number>();
|
||||
|
||||
function show(card: HTMLElement, visible: boolean): void {
|
||||
const pending = leaving.get(card);
|
||||
if (pending !== undefined) {
|
||||
window.clearTimeout(pending);
|
||||
leaving.delete(card);
|
||||
}
|
||||
|
||||
if (visible) {
|
||||
const wasHidden = card.hidden;
|
||||
card.classList.remove('leaving');
|
||||
card.hidden = false;
|
||||
// Back in the results after being filtered out: a plain fade, no rise. It
|
||||
// was here before, it is not new.
|
||||
if (wasHidden && !prefersReducedMotion()) {
|
||||
card.classList.add('appearing');
|
||||
card.addEventListener('animationend', () => card.classList.remove('appearing'), { once: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (card.hidden) return;
|
||||
if (prefersReducedMotion()) {
|
||||
card.hidden = true;
|
||||
return;
|
||||
}
|
||||
card.classList.add('leaving');
|
||||
leaving.set(
|
||||
card,
|
||||
window.setTimeout(() => {
|
||||
leaving.delete(card);
|
||||
card.hidden = true;
|
||||
card.classList.remove('leaving');
|
||||
}, LEAVE_MS),
|
||||
);
|
||||
}
|
||||
|
||||
function apply(): void {
|
||||
const query = searchInput!.value.trim().toLowerCase();
|
||||
// A pasted mint URL should find the mint, so match on the bare domain too.
|
||||
const needle = query.replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||||
const hide = hideOffline!.getAttribute('aria-pressed') === 'true';
|
||||
|
||||
let visible = 0;
|
||||
let onlineCount = 0;
|
||||
let offlineCount = 0;
|
||||
|
||||
for (const card of cards) {
|
||||
const matches =
|
||||
needle === '' ||
|
||||
(card.dataset['name'] ?? '').includes(needle) ||
|
||||
(card.dataset['domain'] ?? '').includes(needle);
|
||||
const isOffline = card.dataset['status'] === 'offline';
|
||||
const keep = matches && !(hide && isOffline);
|
||||
|
||||
show(card, keep);
|
||||
if (keep) {
|
||||
visible++;
|
||||
if (isOffline) offlineCount++;
|
||||
else if (card.dataset['status'] === 'online') onlineCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...cards].sort(SORTS[sortSelect!.value] ?? SORTS['score']!);
|
||||
for (const card of sorted) grid!.append(card);
|
||||
|
||||
// A rank chip is a claim about position in the default order. It means nothing
|
||||
// in a rating sort and nothing in a filtered list, so it is hidden in both.
|
||||
const ranked = sortSelect!.value === 'score' && needle === '' && !hide;
|
||||
let rank = 0;
|
||||
for (const card of sorted) {
|
||||
const chip = card.querySelector<HTMLElement>('.mc-rank');
|
||||
if (!chip) continue;
|
||||
if (!ranked) {
|
||||
chip.hidden = true;
|
||||
continue;
|
||||
}
|
||||
rank++;
|
||||
chip.hidden = false;
|
||||
chip.textContent = `#${rank}`;
|
||||
chip.classList.toggle('gold', rank === 1);
|
||||
}
|
||||
|
||||
if (countEl) {
|
||||
// The count is the answer to what the reader just typed, so it cross-fades
|
||||
// rather than flicking from one number to another.
|
||||
swapText(
|
||||
countEl,
|
||||
visible === cards.length
|
||||
? t('lnurlMints.showing', { total: visible, online: onlineCount, offline: offlineCount })
|
||||
: t('lnurlMints.showingFiltered', {
|
||||
shown: visible,
|
||||
total: cards.length,
|
||||
online: onlineCount,
|
||||
offline: offlineCount,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (noResults) noResults.hidden = visible > 0;
|
||||
}
|
||||
|
||||
searchInput.addEventListener('input', apply);
|
||||
sortSelect.addEventListener('change', () => {
|
||||
apply();
|
||||
// A new order is a new list: it arrives the way the grid arrived, once, and
|
||||
// the stagger is capped so the last card is never more than 400ms behind.
|
||||
enterStagger(cards.filter((card) => !card.hidden));
|
||||
});
|
||||
hideOffline.addEventListener('click', () => {
|
||||
const on = hideOffline.getAttribute('aria-pressed') === 'true';
|
||||
hideOffline.setAttribute('aria-pressed', String(!on));
|
||||
apply();
|
||||
});
|
||||
clearSearch?.addEventListener('click', () => {
|
||||
searchInput.value = '';
|
||||
searchInput.focus();
|
||||
apply();
|
||||
});
|
||||
|
||||
// Deep links from the home page quick filters: /mints?q=…&sort=…
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const q = params.get('q');
|
||||
const sort = params.get('sort');
|
||||
if (q) searchInput.value = q;
|
||||
if (sort && sort in SORTS) sortSelect.value = sort;
|
||||
if (q || sort) apply();
|
||||
|
||||
onLeave(() => {
|
||||
for (const timer of leaving.values()) window.clearTimeout(timer);
|
||||
leaving.clear();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onReady(setup);
|
||||
</script>
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import MintCard from '../../components/MintCard.astro';
|
||||
import ReviewByUrl from '../../components/ReviewByUrl.astro';
|
||||
import { readCapabilities } from '@cashumints/shared';
|
||||
import { fetchMint, fetchMints } from '../../lib/api';
|
||||
import { localePath, useI18n, type Locale } from '../../i18n';
|
||||
@@ -66,9 +67,19 @@ const schema = [
|
||||
*/}
|
||||
<div class="page">
|
||||
<div class="index-head">
|
||||
<div class="index-head-text">
|
||||
<h1>{t('mints.heading')}</h1>
|
||||
<p class="lede">{t('mints.lede')}</p>
|
||||
</div>
|
||||
{/*
|
||||
The way in for something this site has never seen. Every other route to writing
|
||||
a review starts from a page that already exists, which is exactly the case a
|
||||
reader with a brand new mint does not have.
|
||||
*/}
|
||||
<button class="btn primary index-head-action" type="button" data-review-url-open="cashu">
|
||||
{t('reviews.byUrl.open')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="controls" data-mint-controls>
|
||||
<form class="search" role="search" onsubmit="return false">
|
||||
@@ -125,10 +136,17 @@ const schema = [
|
||||
<button class="link-btn" type="button" data-clear-search>{t('mints.clearSearch')}</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Two steps in one dialog: identify the mint, then the ordinary review form. */}
|
||||
<ReviewByUrl type="cashu" />
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.index-head { margin-bottom: 24px; }
|
||||
/* Heading and lede on the left, the one primary action on the right, wrapping to
|
||||
its own line before the lede has to give up any width. */
|
||||
.index-head { margin-bottom: 24px; display: flex; align-items: flex-start; gap: 16px; flex-wrap: wrap; }
|
||||
.index-head-text { flex: 1 1 320px; }
|
||||
.index-head-action { flex-shrink: 0; }
|
||||
.index-head h1 { font-family: var(--display); font-size: 32px; font-weight: 700; letter-spacing: -.02em; line-height: 1.1; }
|
||||
.index-head .lede { max-width: 620px; font-size: 15.5px; margin-top: 10px; }
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* review looks and behaves identically wherever you meet it.
|
||||
*/
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import { fetchFedimints, fetchMints, fetchStats } from '../../lib/api';
|
||||
import { fetchAllListings, fetchStats } from '../../lib/api';
|
||||
import { fetchReviewFeed } from '../../lib/nostr-build';
|
||||
import { feedTargets, targetPath } from '../../lib/feed-resolve';
|
||||
import { reviewHtml } from '../../lib/review-cards';
|
||||
@@ -33,20 +33,22 @@ const f = formatters(t);
|
||||
/** How many reviews the build renders. Enough to fill the page above and below the fold. */
|
||||
const PRERENDER_COUNT = 30;
|
||||
|
||||
const [mints, federations, stats] = await Promise.all([
|
||||
fetchMints(),
|
||||
fetchFedimints(),
|
||||
fetchStats(),
|
||||
]);
|
||||
const [listings, stats] = await Promise.all([fetchAllListings(), fetchStats()]);
|
||||
|
||||
/**
|
||||
* Everything the feed can resolve a review to, both ecosystems in one list.
|
||||
* Everything the feed can resolve a review to, every ecosystem in one list.
|
||||
*
|
||||
* Handed to the island as JSON so the browser can resolve a review back to a listing
|
||||
* without asking the API again. Name and domain are what the card's link shows; `ref`
|
||||
* is the federation id a 38173 review points at, read straight out of the row key.
|
||||
* is the identifier a review's `d` tag points at — a federation id, or an LNURL mint's
|
||||
* host — read straight out of the row key.
|
||||
*
|
||||
* Fetched through `fetchAllListings`, which is driven by `ECOSYSTEMS`, so this list and
|
||||
* the filter pills below can never disagree about which ecosystems exist. They did once:
|
||||
* the pills were built from that list while the data was two fetches written out by
|
||||
* hand, which meant a new ecosystem got a pill that matched nothing.
|
||||
*/
|
||||
const feedMints = feedTargets(mints, federations);
|
||||
const feedMints = feedTargets(...listings);
|
||||
const feed = await fetchReviewFeed(feedMints, PRERENDER_COUNT);
|
||||
|
||||
const mintByHost = new Map(feedMints.map((mint) => [mint.host, mint]));
|
||||
@@ -82,6 +84,7 @@ const description = t('feed.description', {
|
||||
reviews: stats.reviews_total,
|
||||
mints: stats.cashu_total,
|
||||
federations: stats.fedimint_total,
|
||||
lnurl: stats.lnurl_total,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* /404 is deliberately absent, in every locale.
|
||||
*/
|
||||
import type { APIRoute } from 'astro';
|
||||
import { fetchFedimints, fetchMints } from '../lib/api';
|
||||
import { fetchFedimints, fetchLnurlMints, fetchMints } from '../lib/api';
|
||||
import { alternates } from '../i18n/routing';
|
||||
import { isIndexableMint } from '../lib/seo';
|
||||
import { DEFAULT_LOCALE, LOCALE_CODES } from '../i18n/config';
|
||||
@@ -42,6 +42,12 @@ const STATIC_PAGES: Array<Omit<Entry, 'lastmod'>> = [
|
||||
* changes most and matters most keeps the higher number.
|
||||
*/
|
||||
{ path: '/fedimints', changefreq: 'daily', priority: 0.8 },
|
||||
/*
|
||||
* Level with /fedimints, not below it. The two are peers: both are a second-tier
|
||||
* ecosystem index next to Cashu's, and neither has a claim on being read first. The
|
||||
* ordering in this list is display order and says nothing to a crawler.
|
||||
*/
|
||||
{ path: '/lnurl-mints', changefreq: 'daily', priority: 0.8 },
|
||||
{ path: '/reviews', changefreq: 'daily', priority: 0.7 },
|
||||
{ path: '/wallets', changefreq: 'monthly', priority: 0.5 },
|
||||
{ path: '/about', changefreq: 'monthly', priority: 0.5 },
|
||||
@@ -62,6 +68,9 @@ const MINT_PRIORITY = 0.8;
|
||||
/** One notch below a mint page, for the same reason /fedimints sits below /mints. */
|
||||
const FEDIMINT_PRIORITY = 0.7;
|
||||
|
||||
/** Level with a federation page: the same second-tier ecosystem, the same weight. */
|
||||
const LNURL_PRIORITY = 0.7;
|
||||
|
||||
/**
|
||||
* Non-default locales sit slightly below their English counterpart, which is the same
|
||||
* thing `x-default` says in the alternate set: when a crawler has to pick one, pick
|
||||
@@ -83,7 +92,11 @@ export const GET: APIRoute = async ({ site }) => {
|
||||
const origin = site ?? new URL('https://cashumints.space');
|
||||
const builtAt = Math.floor(Date.now() / 1000);
|
||||
|
||||
const [mints, federations] = await Promise.all([fetchMints(), fetchFedimints()]);
|
||||
const [mints, federations, lnurlMints] = await Promise.all([
|
||||
fetchMints(),
|
||||
fetchFedimints(),
|
||||
fetchLnurlMints(),
|
||||
]);
|
||||
|
||||
const entries: Entry[] = [
|
||||
...STATIC_PAGES.map((page) => ({ ...page, lastmod: builtAt })),
|
||||
@@ -121,6 +134,18 @@ export const GET: APIRoute = async ({ site }) => {
|
||||
changefreq: 'weekly' as const,
|
||||
priority: FEDIMINT_PRIORITY,
|
||||
})),
|
||||
/*
|
||||
* LNURL mints, on the same rule again. Their `lastmod` behaves like a Cashu mint's
|
||||
* rather than a federation's: they are genuinely probed, so `last_online` is set on
|
||||
* every one that has ever answered, and the build-time fallback is only reached by a
|
||||
* mint that never has — which `isIndexableMint` has already filtered out.
|
||||
*/
|
||||
...lnurlMints.filter(isIndexableMint).map((mint) => ({
|
||||
path: `/lnurl-mint/${mint.host}`,
|
||||
lastmod: Math.max(mint.last_review_at ?? 0, mint.last_online ?? 0) || builtAt,
|
||||
changefreq: 'weekly' as const,
|
||||
priority: LNURL_PRIORITY,
|
||||
})),
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+155
-20
@@ -194,6 +194,20 @@
|
||||
--violet: #A78BFA;
|
||||
--mint: #5FD68F;
|
||||
--red: #E06450;
|
||||
/*
|
||||
* The LNURL ecosystem accent, and the only cool blue on this site.
|
||||
*
|
||||
* The palette is otherwise warm, which is exactly what makes this readable as "a
|
||||
* different ecosystem" at thumbnail size on a social card. Every other candidate was
|
||||
* already taken by a meaning the colour discipline reserves: amber is the brand,
|
||||
* mint is online/success, red is offline/critical, and violet is Nostr-protocol
|
||||
* (which the Fedimint badge borrows). A blue is the only hue left that a reader
|
||||
* cannot mistake for a status.
|
||||
*
|
||||
* Used for the LNURL card's badge and glow, and nowhere else. It is an ecosystem
|
||||
* label, not a state, and must never decorate one.
|
||||
*/
|
||||
--lnurl: #4EA8DE;
|
||||
--radius: 14px;
|
||||
|
||||
--body: 'Inter', 'Inter Fallback', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
@@ -523,18 +537,85 @@ main { flex: 1; }
|
||||
.kv-row .copy-btn { margin-left: auto; }
|
||||
|
||||
/* ---------- footer ---------- */
|
||||
footer { border-top: 1px solid var(--line-soft); margin-top: 70px; }
|
||||
.foot-inner {
|
||||
max-width: var(--page-max); margin: 0 auto; padding: 28px;
|
||||
display: flex; align-items: center; gap: 20px; flex-wrap: wrap;
|
||||
font-size: 13px; color: var(--faint);
|
||||
|
||||
/*
|
||||
* Three zones: the columns, then a thin bar. `main { flex: 1 }` and the column layout
|
||||
* on `body` are what put this at the bottom of the viewport on a short page (the LNURL
|
||||
* index with one mint) rather than halfway up it, so this needs no min-height of its
|
||||
* own. `margin-top: 70px` is the gap after a long page; on a short one the flex growth
|
||||
* of `main` swallows it.
|
||||
*/
|
||||
.site-footer { border-top: 1px solid var(--line-soft); margin-top: 70px; }
|
||||
|
||||
.foot-cols {
|
||||
max-width: var(--page-max); margin: 0 auto;
|
||||
padding: 44px var(--page-pad) 36px;
|
||||
display: grid;
|
||||
/*
|
||||
* Four visible columns from three grid tracks: the middle one is the nav landmark,
|
||||
* which holds Explore and Site. One landmark over both, rather than a track each,
|
||||
* is what keeps the landmark list honest — see Footer.astro.
|
||||
*/
|
||||
grid-template-columns: minmax(0, 2.1fr) minmax(0, 2fr) minmax(0, 1.2fr);
|
||||
gap: 40px 44px;
|
||||
}
|
||||
.foot-nav { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 40px 32px; }
|
||||
|
||||
/* The brand column. Narrower than its track so the sentences break at a readable
|
||||
measure instead of running the full width of a 1240px grid cell. */
|
||||
.foot-brand { max-width: 34ch; }
|
||||
.foot-mark {
|
||||
display: inline-flex; align-items: center; gap: 10px;
|
||||
font-family: var(--display); font-weight: 700; font-size: 15.5px; letter-spacing: -.01em;
|
||||
}
|
||||
.foot-desc { color: var(--muted); font-size: 13.5px; line-height: 1.55; margin-top: 12px; }
|
||||
/*
|
||||
* The custodial warning. `--muted`, not `--faint`: it is the one sentence in this
|
||||
* footer that a reader is worse off for having missed, and it used to be an orphan in
|
||||
* the bottom right corner of the last row.
|
||||
*/
|
||||
.foot-warn { color: var(--muted); font-size: 13px; line-height: 1.5; margin-top: 10px; }
|
||||
|
||||
/* The small-caps label, the same one `.side-title` and `.fb-stat .k` use. An h2 in the
|
||||
markup, so the margin is stated in full and the UA's own h2 margins cannot show. */
|
||||
.foot-head {
|
||||
font-size: 11px; font-weight: 600; letter-spacing: .09em; text-transform: uppercase;
|
||||
color: var(--faint); margin: 0 0 14px;
|
||||
}
|
||||
.foot-col ul { list-style: none; display: flex; flex-direction: column; gap: 8px; }
|
||||
.foot-col a {
|
||||
font-size: 13.5px; color: var(--muted);
|
||||
transition: color var(--dur-quick);
|
||||
}
|
||||
.foot-col a:hover { color: var(--text); }
|
||||
|
||||
.foot-bar {
|
||||
max-width: var(--page-max); margin: 0 auto;
|
||||
padding: 16px var(--page-pad);
|
||||
border-top: 1px solid var(--line-soft);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 8px 24px; flex-wrap: wrap;
|
||||
font-size: 12.5px; color: var(--faint);
|
||||
}
|
||||
.foot-inner .links { display: flex; gap: 18px; flex-wrap: wrap; }
|
||||
.foot-inner a:hover { color: var(--muted); }
|
||||
.foot-right { margin-left: auto; }
|
||||
/* Dotted underline like every other in-sentence link: colour alone is not enough
|
||||
to mark a link inside running text (WCAG 1.4.1). */
|
||||
.foot-right a { color: var(--amber); border-bottom: 1px dotted rgba(240, 169, 59, .4); }
|
||||
.foot-credit a { color: var(--amber); border-bottom: 1px dotted rgba(240, 169, 59, .4); }
|
||||
|
||||
/*
|
||||
* The stack.
|
||||
*
|
||||
* At 900px the three tracks become one, which puts the columns in source order —
|
||||
* Brand, then the nav's two columns side by side, then Language. Explore and Site stay
|
||||
* paired down to 460px because two short link lists next to each other read as one
|
||||
* block; below that they are two lists that would each be one word wide.
|
||||
*/
|
||||
@media (max-width: 900px) {
|
||||
.foot-cols { grid-template-columns: minmax(0, 1fr); gap: 34px; }
|
||||
.foot-brand { max-width: 46ch; }
|
||||
}
|
||||
@media (max-width: 460px) {
|
||||
.foot-nav { grid-template-columns: minmax(0, 1fr); gap: 30px; }
|
||||
}
|
||||
|
||||
/* ---------- prose (about, wallets) ---------- */
|
||||
.prose { max-width: 720px; }
|
||||
@@ -559,24 +640,29 @@ footer { border-top: 1px solid var(--line-soft); margin-top: 70px; }
|
||||
/*
|
||||
* Below this the header stops being one row.
|
||||
*
|
||||
* Five nav items, the brand, the language control and the account control do not fit
|
||||
* Six nav items, the brand, the language control and the account control do not fit
|
||||
* across anything narrower than a laptop, and a header that does not fit is a page that
|
||||
* scrolls sideways: the body inherits the overflow and every section under it drifts. So
|
||||
* the nav takes a row of its own and the two controls stay up top with the brand, which
|
||||
* is also the order they are wanted in: what page am I on, then who am I and what
|
||||
* language is this.
|
||||
*
|
||||
* 1040px, not the 620px this used to be. The old figure was already two pixels short of
|
||||
* fitting four English items at 768, and five need 400px against the 303px the first row
|
||||
* has left there — no amount of tightening the padding closes a gap that size.
|
||||
* The number is set by the widest locale, never by English. Picking the English figure
|
||||
* is how this shipped broken once already: every locale renders the same stylesheet, so
|
||||
* the breakpoint has to clear the widest of them, with room for the next language to be
|
||||
* longer still.
|
||||
*
|
||||
* The number is set by Spanish, which is the longest of the three ("Mints Cashu /
|
||||
* Fedimints / Reseñas / Monederos / Acerca de") and needs 1008px to fit on one row
|
||||
* against English's 906. Picking the English figure is how this shipped broken once
|
||||
* already: every locale renders the same stylesheet, so the breakpoint has to clear the
|
||||
* widest of them, with room for the next language to be longer still.
|
||||
* History, because the direction of travel matters here: 620px fitted four items,
|
||||
* 1040px fitted five (set by Spanish at 1008px against English's 906), and adding the
|
||||
* LNURL index made six. Measured across all shipped locales at the time it was added,
|
||||
* the widest first row needs 1123px, so this sits at 1180 — the same kind of headroom
|
||||
* the 1040 figure carried over its own 1008.
|
||||
*
|
||||
* At 1280px, the widest desktop this site targets, all six fit on one row with room to
|
||||
* spare in every locale. A seventh would not, and would need a real menu rather than
|
||||
* another bump: this row is full.
|
||||
*/
|
||||
@media (max-width: 1040px) {
|
||||
@media (max-width: 1180px) {
|
||||
.topbar-inner { flex-wrap: wrap; row-gap: 10px; }
|
||||
/* Pushes the pair to the right of the first row, where they were before it wrapped. */
|
||||
.topbar-inner > .lang-menu { margin-left: auto; }
|
||||
@@ -601,7 +687,10 @@ footer { border-top: 1px solid var(--line-soft); margin-top: 70px; }
|
||||
@media (max-width: 640px) {
|
||||
:root { --page-pad: 18px; }
|
||||
.mint-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.foot-right { margin-left: 0; }
|
||||
/* Two centred lines rather than one row pushed to both edges: at this width the
|
||||
copyright and the credit would otherwise sit in opposite corners with a gulf
|
||||
between them. */
|
||||
.foot-bar { justify-content: center; text-align: center; gap: 6px; }
|
||||
}
|
||||
@media (max-width: 420px) {
|
||||
.brand { gap: 0; }
|
||||
@@ -884,6 +973,52 @@ footer { border-top: 1px solid var(--line-soft); margin-top: 70px; }
|
||||
.fb-stats { display: flex; gap: 26px; flex-wrap: wrap; margin-top: 8px; }
|
||||
.fb-stat .k { font-size: 11px; letter-spacing: .09em; text-transform: uppercase; color: var(--faint); display: block; }
|
||||
.fb-stat .v { font-family: var(--display); font-size: 24px; font-weight: 700; }
|
||||
/* The one action a resolved-on-arrival mint offers: review it, right now. */
|
||||
.fb-actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 18px; }
|
||||
/* The reviews panel the resolver starts once it has a subject to give it. */
|
||||
.nf-reviews { margin-top: 26px; }
|
||||
|
||||
/* ---------- review by URL (the two-step dialog) ---------- */
|
||||
/*
|
||||
* Step one asks for an address. It is the same input the search boxes use, so it reads
|
||||
* as one control family across the site rather than as a form that turned up in a modal.
|
||||
*/
|
||||
.rbu-input {
|
||||
background: var(--bg); border: 1px solid var(--line); border-radius: 10px;
|
||||
color: var(--text); font-family: var(--mono); font-size: 14px;
|
||||
padding: 12px 12px; width: 100%; display: block;
|
||||
}
|
||||
.rbu-input:focus { outline: 2px solid var(--amber); outline-offset: 1px; }
|
||||
.rbu-input:disabled { color: var(--muted); background: var(--card2); }
|
||||
.rbu-input::placeholder { color: var(--faint); font-family: var(--mono); }
|
||||
|
||||
/* "That address answered as a Cashu mint" plus the button that acts on it. */
|
||||
.rbu-switch { font-size: 13px; color: var(--muted); line-height: 1.6; }
|
||||
|
||||
/* Step two's way back, above the mint it is about to review. */
|
||||
.rbu-back {
|
||||
align-self: flex-start; display: inline-flex; align-items: center; gap: 5px;
|
||||
border: none; background: none; cursor: pointer; padding: 2px 0;
|
||||
font-family: var(--body); font-size: 12.5px; color: var(--muted);
|
||||
}
|
||||
.rbu-back:hover { color: var(--amber); }
|
||||
|
||||
/* The mint that was found: enough to recognise it by, and no more. */
|
||||
.rbu-preview {
|
||||
display: flex; align-items: center; gap: 11px; flex-wrap: wrap;
|
||||
background: var(--card2); border: 1px solid var(--line-soft);
|
||||
border-radius: 11px; padding: 10px 12px;
|
||||
}
|
||||
.rbu-icon {
|
||||
width: 36px; height: 36px; border-radius: 9px; flex-shrink: 0;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-family: var(--display); font-size: 16px; font-weight: 700; color: #141109;
|
||||
}
|
||||
.rbu-who { display: flex; flex-direction: column; gap: 1px; min-width: 120px; flex: 1; }
|
||||
.rbu-name { font-size: 14px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.rbu-domain { font-family: var(--mono); font-size: 11.5px; color: var(--faint); overflow-wrap: anywhere; }
|
||||
.rbu-chips { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.rbu-note { font-size: 11.5px; color: var(--faint); }
|
||||
|
||||
/* ---------- skeletons ---------- */
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user