Capture the NIP kind contract and wire-level NOTES so indexing and probing can follow observed LNURL mint behavior rather than outdated assumptions. Co-authored-by: Cursor <cursoragent@cursor.com>
12 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).
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:3338slugs tosmilemoji.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/andhttps://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 innormalizeMintUrl, 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:
.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/.