# 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`). ### 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`: ```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 ; // line 105 if (!mintInfo) return ; // 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 `}/>`. 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/`.