Files
CashuMints.space/NOTES.md
T
2026-08-20 22:41:25 +02:00

216 lines
10 KiB
Markdown

# NOTES.md: what the old codebase actually does
Findings from reading `cashu-mint-page/` (the previous cashumints.space frontend, React + Vite + NDK).
Everything here is copied from the source, not from memory. File references are to the old repo.
## Nostr: event kinds
From `src/utils/ndk.ts`:
```ts
export const MINT_RECOMMENDATION_KIND = 38000; // NIP-87: mint recommendation/review
export const CASHU_MINT_KIND = 38172; // NIP-87: Cashu mint announcement
```
- **38172**: mint announcement. Mint URL lives in the `u` tag. This is the only source of mint
discovery in the old site: there is no hardcoded mint list anywhere in the repo
(`src/hooks/useAllMints.ts` subscribes to `{kinds:[38172], limit:1000}` and builds the whole
directory from what comes back).
- **38000**: review / recommendation event.
## Nostr: relays
From `src/utils/ndk.ts`, `CASHU_RELAY_POOL` (this is the pool used for reads):
```
wss://relay.damus.io
wss://nos.lol
wss://relay.azzamo.net
wss://relay.cashumints.space
```
`PROFILE_RELAYS` is the same list (with `nos.lol` and `relay.azzamo.net` duplicated, a bug that
does nothing).
From `src/services/reviewPublisher.ts`, `RELAY_URLS` (the publish pool, a **different** list):
```
wss://relay.cashumints.space
wss://relay.damus.io
wss://relay.snort.social
wss://relay.primal.net
```
Note the asymmetry: the old site publishes to `snort.social` and `primal.net` but never reads from
them, so some of its own published reviews were invisible to it. The new backend reads the union of
both lists (6 distinct relays) so nothing published by the old site is lost.
Also note `RELAY_URLS[0]` (`wss://relay.cashumints.space`) is used as the relay hint in the `a` tag.
## Nostr: review event wire format
Two code paths build events, and they disagree. What actually ships is `publishReview()`:
`src/services/reviewPublisher.ts` `publishReview()` (the one called by the live flow):
```ts
event.kind = 38000;
event.content = `[${rating}/5] ${content}`;
event.tags = [
['k', '38172'], // kind being recommended
['u', mintInfo.url, 'cashu'], // mint URL, with 'cashu' as third element
['d', mintInfo.pubkey] // mint's own pubkey from /v1/info (NIP-33 d tag)
];
```
`createReviewEvent()` in the same file builds a richer event that is **never published** (the flow
calls `createReviewEvent` and then throws the result away, rebuilding the event inside
`publishReview`). The dead version adds:
```ts
['a', `38172:${mint.pubkey}:${mint.pubkey}`, 'wss://relay.cashumints.space'],
['rating', String(rating)]
```
**Consequence for reading:** most reviews in the wild have NO `rating` tag. The rating is encoded in
the content as a `[N/5]` prefix. Any reader that only looks at the `rating` tag will see nothing.
### Rating parse order (from `src/utils/reviewHelpers.ts` `parseNIP87Review`)
1. `rating` tag, if present and 1..5.
2. Else `^\s*\[([1-5])\/5\]` at the start of content.
3. Else the loose regex `rating[:\s]*([1-5])|([1-5])[\/]5|([1-5])\s*star` (case insensitive).
4. Else **default to 5**.
Step 4 is the reason every mint on the old site shows 5/5. Anything unparseable silently becomes a
five star review. The new indexer stores `rating = NULL` instead and excludes NULLs from averages
(the schema in BACKEND.md already allows this).
### Review filters used for reading (`src/hooks/useReviews.ts`)
Two filters, OR'd:
```ts
// NIP-87 proper, only when the mint's /v1/info pubkey is known
{ kinds:[38000], '#d':[mintPubkey], '#k':['38172'], limit }
// legacy / URL based, always included
{ kinds:[38000], '#u':[ mintUrl, noTrailingSlash, withTrailingSlash,
noProtocol, httpsForm, httpForm ], limit }
```
So a review is matched to a mint by `d`=mint pubkey **or** `u`=mint URL in any of 6 spellings.
The new indexer resolves both the same way, but normalizes the `u` value instead of enumerating
spellings.
### Dedupe
`aggregateReviews()` keys by **pubkey**, keeping the newest event per author (treating 38000 as a
NIP-33 replaceable event, which it is: 30000-39999 is the addressable range). One review per npub
per mint. The new backend keeps every event row (keyed by event id, per BACKEND.md schema) but
applies the same newest-per-(pubkey, mint) rule when computing counts and averages.
## Mint URL normalization in the old code
There is no single normalizer. Three different ad-hoc ones:
1. `src/services/api.ts` `getMintInfoByDomain` / `getMintPubkey`:
`url.replace(/^https?:\/\//,'').replace(/\/+$/,'')` then `https://${clean}/v1/info`.
Forces https, strips protocol and trailing slashes, **keeps the path**. This one is fine.
2. `src/hooks/useAllMints.ts` and `usePopularMints.ts`: `new URL(mintUrl).hostname`, then
`.replace(/^mint\./,'').replace(/^www\./,'')` for the display name. The **hostname only**, so the
path is dropped. `https://mint.minibits.cash/Bitcoin` and `https://mint.minibits.cash/Other`
collapse to the same display name, and the map is keyed on the raw un-normalized `u` tag value,
so `https://x.com` and `https://x.com/` are two separate mints in the directory.
3. `src/utils/reviewHelpers.ts` `isReviewForThisMint`: lowercases, strips protocol and trailing
slashes, then compares **domain only** (`split('/')[0]`), with a `www.` variant fallback.
Explicit comment says subdomain and content matching were removed as too false-positive prone.
Net effect: mints are deduped by raw tag string (too strict, splits on trailing slash) while reviews
are matched by bare domain (too loose, a review of `mint.minibits.cash/Bitcoin` counts for
`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`).
## Old scoring
`src/hooks/usePopularMints.ts`:
```ts
.filter(m => m.reviewCount >= 3)
.sort((a,b) => b.averageRating !== a.averageRating
? b.averageRating - a.averageRating
: b.reviewCount - a.reviewCount)
.slice(0, limit)
```
Plain mean rating, descending, review count only as a tiebreak, with a hard `>= 3 reviews` floor.
Combined with the "default to 5" parse fallback, this guarantees the leaderboard is a wall of 5.0s
ordered arbitrarily. Replaced with the Bayesian weighted score from BACKEND.md.
**Worth keeping from the old scoring** (carried into the new implementation):
- **Dedupe by pubkey before averaging.** One npub, one vote per mint. Without this a single author
can move a mint's average by replaying reviews. The Bayesian formula does not protect against
this on its own, so the new backend keeps it.
- **The `reviewCount >= 3` floor**, but repurposed. As a ranking rule it is too blunt (it hides
new mints entirely). The prior weight `m = 5` in the Bayesian formula does the same job
continuously, so the floor is dropped from ranking. It survives as a display rule only: the home
page "top mints" grid shows the top 6 by score, and a mint with 1 review simply cannot out-score
an established one, so no explicit floor is needed.
- **The 30 day `since` window as a second filter alongside the unbounded one.** The old code fires
`{kinds:[38000], limit:100}` and `{kinds:[38000], limit:100, since: now-30d}` together, which
works around relays that return an arbitrary slice for an unbounded query. The discovery loop does
the same thing (broad backfill plus a recent window) for the same reason.
Not kept: the `filter(m => m.reviewCount >= 3)` on the directory itself, the mean-only sort, and the
default-to-5 rating.
## The rug bug, precisely
`src/pages/MintPage.tsx`:
```ts
if (error) return <ErrorDisplay message={error} retryFn={fetchMintInfo} />; // line 105
if (!mintInfo) return <ErrorDisplay message="No mint information available" .../>; // line 106
```
`fetchMintInfo` calls `getMintInfoByDomain`, which is a direct browser `axios.get` to
`https://{host}/v1/info` with a 10s timeout. If the mint is down, blocks CORS, or serves mixed
content, `error` is set and the component returns before rendering the header, the reviews panel, or
the review form. So a rugged mint, the one people most need to warn each other about, has no
reachable review page. There is no cache: every page view re-fetches from the mint itself.
This is why the new architecture puts a server-side cache in front (`mints.info_json`, never
overwritten on a failed probe) and renders the mint page from that cache, not from a live fetch.
## Other things found
- **No hardcoded mint list.** `public/sitemap.xml` has 5 static routes and no mint pages.
`src/store/mintStore.ts` is an empty runtime cache. So the seed list in `api/src/seed.ts` had to
be assembled from the mints the old site actually surfaces via Nostr, listed there with a comment.
- **No wallet directory.** FRONTEND.md says to port wallet content from the old project, but the
only thing there is an external link to `https://docs.cashu.space/wallets` in `Header.tsx:57` and
`MobileMenu.tsx:65`. There is no wallet data to port. `/wallets` is built from a curated list
instead, flagged in the summary.
- **Routing was `/{host+path}`**, e.g. `/mint.minibits.cash/Bitcoin`, via a catch-all
`<Route path="/*" element={<MintPage/>}/>`. The new site uses `/mint/{slug}` with the slug rules
from BACKEND.md, and redirects are not attempted (old URLs were never indexed: no SEO, see
PROJECT.md).
- **`src/utils/nostr.ts` `hexToNpub` is fake.** It returns `` `npub${hex.slice(0,8)}...` ``, which is
not bech32 and not a real npub, it just looks like one. The mockup's `npub7b2bfe92…` strings are
this bug rendered. The new site encodes real npubs with `nip19.npubEncode` from nostr-tools.
- **`src/utils/nutInfo.ts`** has human readable NUT names and descriptions, but several are wrong
(NUT-04 is titled "Token Spending", NUT-05 "Token Melt", NUT-07 "Swap", NUT-08 "Melt"). The
correct names are used instead, matching the mint page mockup's five highlighted rows.
- Old site had `RelayPoolTest.tsx` and a lot of `console.log` debug output shipped to production,
plus `loadDemoReviews()` returning fake Alice/Bob/Charlie reviews. None of that is carried over.
## Library choice
The old project depends on **both** `@nostr-dev-kit/ndk` and `nostr-tools`. NDK is used for all
relay work; `nostr-tools` is a transitive dependency it never directly imports. NDK's browser bias
(it assumes a persistent process and has no clean shutdown, see the `cleanupSharedNDK` comment
"NDK doesn't have a disconnect method") makes it a poor fit for a Node indexer that must exit
cleanly. The new code uses **nostr-tools** on both sides: `SimplePool` in the indexer, and the same
library in the browser island, so there is one Nostr code path and one set of parse rules shared
through `shared/`.