Files
michilisandCursor 36c01861f5 Document LNURL mint kind and live probe notes.
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>
2026-08-22 03:44:20 +02:00

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 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):

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)

  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:

// 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:

.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:

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/.