10 KiB
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:
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
utag. 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.tssubscribes 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):
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:
['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)
ratingtag, if present and 1..5.- Else
^\s*\[([1-5])\/5\]at the start of content. - Else the loose regex
rating[:\s]*([1-5])|([1-5])[\/]5|([1-5])\s*star(case insensitive). - 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:
// 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:
src/services/api.tsgetMintInfoByDomain/getMintPubkey:url.replace(/^https?:\/\//,'').replace(/\/+$/,'')thenhttps://${clean}/v1/info. Forces https, strips protocol and trailing slashes, keeps the path. This one is fine.src/hooks/useAllMints.tsandusePopularMints.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/Bitcoinandhttps://mint.minibits.cash/Othercollapse to the same display name, and the map is keyed on the raw un-normalizedutag value, sohttps://x.comandhttps://x.com/are two separate mints in the directory.src/utils/reviewHelpers.tsisReviewForThisMint: lowercases, strips protocol and trailing slashes, then compares domain only (split('/')[0]), with awww.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:
.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 >= 3floor, but repurposed. As a ranking rule it is too blunt (it hides new mints entirely). The prior weightm = 5in 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
sincewindow 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:
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.xmlhas 5 static routes and no mint pages.src/store/mintStore.tsis an empty runtime cache. So the seed list inapi/src/seed.tshad 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/walletsinHeader.tsx:57andMobileMenu.tsx:65. There is no wallet data to port./walletsis 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.tshexToNpubis fake. It returns`npub${hex.slice(0,8)}...`, which is not bech32 and not a real npub, it just looks like one. The mockup'snpub7b2bfe92…strings are this bug rendered. The new site encodes real npubs withnip19.npubEncodefrom nostr-tools.src/utils/nutInfo.tshas 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.tsxand a lot ofconsole.logdebug output shipped to production, plusloadDemoReviews()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/.