first commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-20 22:41:25 +02:00
co-authored by Cursor
commit aa1771ea20
136 changed files with 27069 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
/**
* Acceptance criterion #1, as a runnable check.
*
* pnpm --filter ./api test:offline
*
* A mint whose /v1/info is unreachable must still serve its full detail payload from
* cached metadata. This is the single most important fix over the old site, so it gets
* a test that actually kills a mint rather than a unit test of a helper.
*
* Runs against a throwaway copy of the seeded database, so the real one is untouched.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const source = process.env['DB_PATH'] ?? path.resolve(import.meta.dirname, '..', 'data', 'cashumints.db');
if (!fs.existsSync(source)) {
console.error(`No seeded database at ${source}. Run "pnpm seed" first.`);
process.exit(1);
}
// Point the whole process at a copy before anything opens the real database.
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'cashumints-offline-'));
const copy = path.join(scratch, 'test.db');
fs.copyFileSync(source, copy);
process.env['DB_PATH'] = copy;
const { getDb, closeDb } = await import('./db.ts');
const { probeMint } = await import('./probe.ts');
const { getMintDetail } = await import('./queries.ts');
const { mintByUrl } = await import('./mints.ts');
const db = getDb();
// Pick a mint that is online and has real cached metadata to lose.
const victim = db
.prepare(
`SELECT * FROM mints
WHERE status = 'online' AND info_json IS NOT NULL AND name IS NOT NULL
ORDER BY LENGTH(info_json) DESC LIMIT 1`,
)
.get() as { url: string; host: string; name: string } | undefined;
assert.ok(victim, 'seeded database has no online mint with cached metadata');
console.log(`victim: ${victim.name} (${victim.host})`);
const before = getMintDetail(victim.host);
assert.ok(before, 'detail must exist before the mint dies');
assert.equal(before.status, 'online');
assert.ok(before.info, 'must have cached /v1/info before');
assert.ok(before.nuts.length > 0, 'must have parsed NUTs before');
// Rug it: same row, dead URL. This is the "point a mint row at a dead URL" test.
const DEAD = 'https://this-mint-is-gone.invalid';
db.prepare('UPDATE mints SET url = ? WHERE host = ?').run(DEAD, victim.host);
db.prepare('UPDATE reviews SET mint_url = ? WHERE mint_url = ?').run(DEAD, victim.url);
// Three failures is the offline threshold.
for (let i = 0; i < 3; i++) {
const row = mintByUrl(DEAD);
assert.ok(row, 'row must survive the URL change');
const result = await probeMint(row);
assert.equal(result.ok, false, 'a dead URL must not probe ok');
}
const after = getMintDetail(victim.host);
assert.ok(after, 'the mint page must still resolve when the mint is dead');
assert.equal(after.status, 'offline', 'three failures means offline');
// The whole point: none of the cached metadata was touched by the failed probes.
assert.equal(after.name, before.name, 'name survives');
assert.equal(after.description, before.description, 'description survives');
assert.equal(after.version, before.version, 'version survives');
assert.equal(after.pubkey, before.pubkey, 'pubkey survives');
assert.deepEqual(after.nuts, before.nuts, 'supported NUTs survive');
assert.deepEqual(after.info, before.info, 'full cached /v1/info survives');
assert.equal(after.icon, before.icon, 'cached icon survives');
// Reviews must still be readable, so the mint stays reviewable.
assert.equal(after.review_count, before.review_count, 'reviews survive');
assert.deepEqual(after.rating_distribution, before.rating_distribution, 'distribution survives');
// The page needs a date for the "Offline since {date}" banner.
assert.ok(after.last_online !== null, 'last_online must be set so the banner has a date');
assert.ok(after.last_online <= Math.floor(Date.now() / 1000));
// And it must sink below every online mint without disappearing.
const { listMints } = await import('./queries.ts');
const list = listMints();
const index = list.findIndex((m) => m.host === victim.host);
assert.ok(index >= 0, 'an offline mint must stay listed so people can find and review it');
const lastOnline = list.map((m) => m.status).lastIndexOf('online');
assert.ok(index > lastOnline, 'offline mints rank below every online mint');
closeDb();
fs.rmSync(scratch, { recursive: true, force: true });
console.log(
`ok, offline mint still serves ${after.nuts.length} NUTs, ${after.review_count} reviews, ` +
`and full cached metadata (offline since ${new Date(after.last_online * 1000).toISOString()})`,
);
+289
View File
@@ -0,0 +1,289 @@
/**
* pnpm --filter ./api test:warnings
*
* getMintWarnings drives the banner on every mint page, the chip on every card and
* the live upgrade in between, so it gets its own fixture-driven check. Each fixture
* in shared/fixtures/warnings is a real /v1/info payload, or a real one with a single
* flag flipped, and the file says which.
*
* No framework: it throws on the first failure and prints a count on success.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import {
getMintWarnings, mintChip, primaryWarning, readCapabilities, warningRank,
type MintWarning, type MintWarningInput, type MintWarningKind,
} from '@cashumints/shared';
const DIR = path.resolve(import.meta.dirname, '..', '..', 'shared', 'fixtures', 'warnings');
/** The instant every fixture's dates were written against. */
const NOW = 1_787_000_000;
// Fixed formatters: the assertions are about the copy, not about the runner's locale.
const OPTIONS = {
now: NOW,
formatDate: (unix: number) => new Date(unix * 1000).toISOString().slice(0, 10),
formatMonth: (unix: number) => new Date(unix * 1000).toISOString().slice(0, 7),
};
interface Fixture extends MintWarningInput {
note: string;
}
function load(name: string): Fixture {
return JSON.parse(fs.readFileSync(path.join(DIR, `${name}.json`), 'utf8')) as Fixture;
}
function warnings(name: string): MintWarning[] {
return getMintWarnings(load(name), OPTIONS);
}
let checks = 0;
function check(name: string, fn: () => void): void {
try {
fn();
checks++;
} catch (err) {
console.error(`FAIL: ${name}`);
throw err;
}
}
/** Every fixture, and the banner its mint must show. null means no banner at all. */
const EXPECTED: [string, MintWarningKind | null][] = [
['healthy-cdk', null],
['healthy-nutshell', null],
['no-nut04-nut05', null],
['degraded', null],
['melt-only', 'melt-only'],
['melt-disabled', 'melt-disabled'],
['frozen', 'frozen'],
['no-lightning-methods', 'melt-only'],
['offline-recent', 'offline'],
['offline-long', 'offline-long'],
['offline-gone', 'gone'],
['offline-never', 'gone'],
['offline-gone-melt-disabled', 'gone'],
['offline-recent-melt-disabled', 'melt-disabled'],
['offline-long-melt-only', 'offline-long'],
];
check('every fixture resolves to the banner it is named for', () => {
for (const [name, kind] of EXPECTED) {
const primary = primaryWarning(load(name), OPTIONS);
assert.equal(primary?.kind ?? null, kind, `${name} should show ${kind ?? 'no banner'}`);
}
});
check('the fixture set covers every warning kind plus healthy', () => {
const covered = new Set(EXPECTED.map(([, kind]) => kind));
for (const kind of ['gone', 'melt-disabled', 'frozen', 'offline-long', 'melt-only', 'offline'] as const) {
assert.ok(covered.has(kind), `no fixture produces ${kind}`);
}
assert.ok(covered.has(null), 'no fixture produces a healthy mint');
});
check('a healthy mint produces nothing at all, not an empty-ish banner', () => {
assert.deepEqual(warnings('healthy-cdk'), []);
assert.deepEqual(warnings('healthy-nutshell'), []);
assert.deepEqual(warnings('no-nut04-nut05'), [], 'a NUT nobody published is not a NUT switched off');
assert.deepEqual(warnings('degraded'), [], 'degraded is not offline');
});
check('melt only: NUT-04 disabled, NUT-05 alive (mint.belgianbitcoinembassy.org)', () => {
const [w, ...rest] = warnings('melt-only');
assert.equal(rest.length, 0, 'one condition, one warning');
assert.equal(w?.severity, 'warning');
assert.ok(w?.lead.startsWith('Melt only.'));
assert.match(w!.body, /New minting \(NUT-04\) is disabled/);
assert.match(w!.body, /existing ecash can still be withdrawn/);
assert.equal(w?.meta, 'Currently melt only');
});
check('withdrawals disabled outranks everything but a mint being gone', () => {
const w = warnings('melt-disabled')[0];
assert.equal(w?.kind, 'melt-disabled');
assert.equal(w?.severity, 'critical', 'sats in with no way out is critical, not a nudge');
assert.ok(w?.lead.startsWith('Withdrawals disabled.'));
assert.match(w!.body, /you can put sats in, but you cannot withdraw them/);
assert.ok(warningRank('melt-disabled') < warningRank('melt-only'));
assert.ok(warningRank('gone') < warningRank('melt-disabled'));
});
check('both disabled collapses into one frozen banner (lekmint.cash)', () => {
const list = warnings('frozen');
assert.equal(list.length, 1, 'frozen replaces the melt-disabled and melt-only banners');
assert.equal(list[0]?.kind, 'frozen');
assert.equal(list[0]?.severity, 'critical');
assert.match(list[0]!.body, /Both minting and melting are disabled/);
assert.ok(!list.some((w) => w.kind === 'melt-disabled' || w.kind === 'melt-only'));
});
check('an empty methods list is off, whatever the flag says', () => {
// LekMint publishes disabled: true AND methods: []. Either one alone must be enough.
assert.deepEqual(readCapabilities({ '4': { methods: [] }, '5': { methods: [] } }), {
mintDisabled: true, meltDisabled: true, mintPublished: true, meltPublished: true,
});
const onchainOnly = warnings('no-lightning-methods')[0];
assert.equal(onchainOnly?.kind, 'melt-only', 'onchain-only minting cannot take a Lightning deposit');
});
check('capabilities read the shapes both mint softwares publish', () => {
// Nutshell and cdk-mintd both use plain numeric keys; the prefixed spellings are
// accepted because parseNuts has always accepted them.
const shapes: unknown[] = [
{ '4': { methods: [{ method: 'bolt11', unit: 'sat' }], disabled: true } },
{ nut4: { methods: [{ method: 'bolt11', unit: 'sat' }], disabled: true } },
{ 'NUT-04': { methods: [{ method: 'bolt11', unit: 'sat' }], disabled: true } },
];
for (const nuts of shapes) assert.equal(readCapabilities(nuts).mintDisabled, true);
// An absent flag means enabled: several Nutshell builds omit it.
assert.equal(readCapabilities({ '5': { methods: [{ method: 'bolt11', unit: 'sat' }] } }).meltDisabled, false);
// bolt12 is Lightning too, so a bolt12-only mint is not "disabled".
assert.equal(readCapabilities({ '4': { methods: [{ method: 'bolt12', unit: 'sat' }] } }).mintDisabled, false);
// Nothing published at all: no claim either way.
assert.deepEqual(readCapabilities(undefined), {
mintDisabled: false, meltDisabled: false, mintPublished: false, meltPublished: false,
});
});
check('offline tiers split at 7 and 30 days', () => {
const base = load('offline-long');
const at = (days: number) =>
primaryWarning({ ...base, last_online: NOW - days * 86_400 }, OPTIONS)?.kind;
assert.equal(at(0), 'offline');
assert.equal(at(6), 'offline');
assert.equal(at(7), 'offline-long', '7 days is the first day of tier 2');
assert.equal(at(29), 'offline-long');
assert.equal(at(30), 'gone', '30 days is the first day of tier 3');
assert.equal(at(400), 'gone');
});
check('an offline mint that has never answered is treated as gone, not as fresh', () => {
const w = warnings('offline-never')[0];
assert.equal(w?.kind, 'gone');
assert.equal(w?.severity, 'critical');
assert.match(w!.body, /has not answered a single check/);
assert.match(w!.body, /watching it in 2026-08/, 'dates the silence from first_seen');
assert.ok(!w!.body.includes('NaN') && !w!.body.includes('null'));
assert.equal(w?.meta, 'Never reachable since it was discovered');
});
check('day counts and dates are the real ones, and read as English', () => {
const gone = warnings('offline-gone')[0];
assert.match(gone!.body, /unreachable since 2026-06-14 \(64 days\)/);
const recent = warnings('offline-recent')[0];
assert.equal(recent?.severity, 'warning');
assert.equal(recent?.lead, 'Offline since 2026-08-14.');
const oneDay = primaryWarning({ ...load('offline-recent'), last_online: NOW - 86_400 }, OPTIONS);
assert.equal(oneDay?.lead, 'Offline since 2026-08-16.', 'singular day, no "1 days"');
assert.equal(
primaryWarning({ ...load('offline-long'), last_online: NOW - 7 * 86_400 }, OPTIONS)?.lead,
'Offline for 7 days, since 2026-08-10.',
);
});
check('offline 30+ days plus disabled withdrawals is one banner with one extra sentence', () => {
const list = warnings('offline-gone-melt-disabled');
const primary = list[0];
assert.equal(primary?.kind, 'gone', 'the mint being gone is the headline');
assert.ok(primary!.body.endsWith('Before going offline it had also disabled withdrawals.'));
assert.match(primary!.body, /Treat funds held there as at risk/);
// The melt fact is still in the list for the card, it just does not get a banner.
assert.ok(list.some((w) => w.kind === 'melt-disabled'), 'the card still needs to know');
assert.equal(list.filter((w) => w.kind === 'gone').length, 1);
});
check('offline 7 to 30 days plus disabled minting collapses the same way', () => {
const primary = warnings('offline-long-melt-only')[0];
assert.equal(primary?.kind, 'offline-long');
assert.ok(primary!.body.endsWith('Before going offline it had also disabled new minting.'));
});
check('a capability banner that outranks an offline one still says it is offline', () => {
const primary = warnings('offline-recent-melt-disabled')[0];
assert.equal(primary?.kind, 'melt-disabled');
assert.match(primary!.body, /This mint had Lightning melting/, 'cached flags are past tense');
assert.ok(primary!.body.endsWith('It has also been offline since 2026-08-14 (3 days).'));
});
check('cached flags never claim to be current on an offline mint', () => {
assert.match(warnings('offline-recent-melt-disabled')[0]!.body, /\bhad\b/);
assert.match(warnings('melt-disabled')[0]!.body, /\bhas\b/);
assert.match(warnings('melt-only')[0]!.body, /\bis disabled\b/);
const offlineMeltOnly = getMintWarnings(
{ ...load('melt-only'), status: 'offline', last_online: NOW - 86_400 },
OPTIONS,
);
assert.match(offlineMeltOnly[0]!.body, /\bwas disabled\b/);
assert.equal(offlineMeltOnly[0]?.meta, 'Melt only when last seen');
});
check('an offline banner does not repeat itself in its own last sentence', () => {
// The collapse sentence belongs to a capability banner that outranked an offline
// one. On an offline banner the fact is already in the lead.
for (const name of ['offline-recent', 'offline-long', 'offline-gone', 'offline-never']) {
const body = warnings(name)[0]!.body;
assert.ok(!body.includes('It has also been offline'), `${name} says it twice`);
}
});
check('warnings come back in severity order, most severe first', () => {
for (const [name] of EXPECTED) {
const ranks = warnings(name).map((w) => warningRank(w.kind));
assert.deepEqual(ranks, [...ranks].sort((a, b) => a - b), `${name} is out of order`);
}
});
check('cards get a chip for the capability states and nothing for offline alone', () => {
assert.deepEqual(mintChip(warnings('melt-only')), { label: 'Melt only', severity: 'warning' });
assert.deepEqual(mintChip(warnings('frozen')), { label: 'Frozen', severity: 'critical' });
assert.deepEqual(mintChip(warnings('melt-disabled')), { label: 'No withdrawals', severity: 'critical' });
assert.equal(mintChip(warnings('offline-gone')), null, 'offline cards already look offline');
assert.equal(mintChip(warnings('healthy-cdk')), null);
// The chip survives an offline banner outranking the capability warning.
assert.deepEqual(mintChip(warnings('offline-gone-melt-disabled')), {
label: 'No withdrawals', severity: 'critical',
});
});
check('a card can be warned from precomputed flags, with no info payload', () => {
const fromInfo = warnings('frozen');
const fromFlags = getMintWarnings(
{ status: 'online', last_online: NOW - 90, capabilities: { mintDisabled: true, meltDisabled: true } },
OPTIONS,
);
assert.deepEqual(fromFlags.map((w) => w.kind), fromInfo.map((w) => w.kind));
});
check('the copy obeys DESIGN.md: no em dashes, no all-caps, no emoji', () => {
for (const [name] of EXPECTED) {
for (const w of warnings(name)) {
const text = `${w.lead} ${w.body} ${w.meta}`;
assert.ok(!text.includes('—'), `${name}/${w.kind} contains an em dash`);
assert.ok(!/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(text), `${name}/${w.kind} contains an emoji`);
assert.ok(!/\b[A-Z]{4,}\b/.test(text.replace(/NUT-\d+/g, '')), `${name}/${w.kind} shouts`);
assert.ok(w.lead.length <= 60, `${name}/${w.kind} lead is not a short phrase`);
assert.ok(/[.!?]$/.test(w.lead.trim()) && /[.!?]$/.test(w.body.trim()));
assert.ok(w.meta.length > 0 && !w.meta.endsWith('.'), 'meta is a clause, not a sentence');
}
}
});
check('nothing is warned about without a reason: no now, no crash', () => {
// Called with no options at all, the way a page that forgot them would call it.
assert.deepEqual(getMintWarnings(load('healthy-cdk')), []);
assert.equal(getMintWarnings(load('frozen'))[0]?.kind, 'frozen');
assert.equal(getMintWarnings({ status: 'unknown', last_online: null }).length, 0);
});
console.log(`ok, ${checks} warning checks passed over ${fs.readdirSync(DIR).length} fixtures`);
+215
View File
@@ -0,0 +1,215 @@
/**
* pnpm --filter ./api test
*
* One runnable check over the logic that is easy to get quietly wrong: URL
* normalization, rating parsing, scoring, and the review-dedupe SQL. No framework:
* it throws on the first failure and prints a count on success.
*/
import assert from 'node:assert/strict';
import { bayesianScore, compareMints, NEUTRAL_PRIOR_MEAN, normalizeMintUrl, parseNuts, parseLimits, parseRating } from '@cashumints/shared';
import { statusForFails } from './probe.ts';
let checks = 0;
function check(name: string, fn: () => void): void {
try {
fn();
checks++;
} catch (err) {
console.error(`FAIL: ${name}`);
throw err;
}
}
check('normalize forces https and strips trailing slash', () => {
assert.equal(normalizeMintUrl('http://Mint.Example.com/')?.url, 'https://mint.example.com');
assert.equal(normalizeMintUrl('mint.example.com')?.url, 'https://mint.example.com');
assert.equal(normalizeMintUrl('https://mint.example.com//')?.url, 'https://mint.example.com');
});
check('normalize keeps the path as part of mint identity', () => {
const a = normalizeMintUrl('https://mint.minibits.cash/Bitcoin');
const b = normalizeMintUrl('https://mint.minibits.cash');
assert.equal(a?.url, 'https://mint.minibits.cash/Bitcoin');
assert.notEqual(a?.url, b?.url);
assert.notEqual(a?.host, b?.host);
assert.equal(a?.host, 'mint.minibits.cash-bitcoin');
assert.equal(b?.host, 'mint.minibits.cash');
});
check('normalize collapses the trailing-slash duplicate the old site created', () => {
const withSlash = normalizeMintUrl('https://21mint.me/');
const without = normalizeMintUrl('https://21mint.me');
assert.equal(withSlash?.url, without?.url);
assert.equal(withSlash?.host, without?.host);
});
check('normalize drops default ports but keeps explicit ones', () => {
assert.equal(normalizeMintUrl('https://x.example.com:443')?.url, 'https://x.example.com');
assert.equal(normalizeMintUrl('https://smilemoji.cash:3338')?.url, 'https://smilemoji.cash:3338');
assert.equal(normalizeMintUrl('https://smilemoji.cash:3338')?.host, 'smilemoji.cash-3338');
});
check('normalize rejects onion, localhost and private ranges', () => {
assert.equal(normalizeMintUrl('http://2tcc5ozz.onion'), null);
assert.equal(normalizeMintUrl('http://localhost:3338'), null);
assert.equal(normalizeMintUrl('https://192.168.1.5:3338'), null);
assert.equal(normalizeMintUrl('https://127.0.0.1'), null);
assert.equal(normalizeMintUrl('ftp://mint.example.com'), null);
assert.equal(normalizeMintUrl(''), null);
});
check('rating comes from the [N/5] content prefix when there is no rating tag', () => {
// This is the shape the old publisher actually shipped, and most reviews on the
// network have no rating tag at all.
const event = {
id: 'a',
pubkey: 'b',
kind: 38000,
created_at: 1,
content: '[4/5] Solid mint, fast melts',
tags: [['k', '38172'], ['u', 'https://mint.example.com', 'cashu']],
};
assert.equal(parseRating(event), 4);
});
check('rating tag wins over content', () => {
assert.equal(
parseRating({
id: 'a', pubkey: 'b', kind: 38000, created_at: 1,
content: '[1/5] ignore me',
tags: [['rating', '5']],
}),
5,
);
});
check('unparseable rating is null, never a silent 5', () => {
// The old parser defaulted to 5 here, which is why every mint showed 5/5.
assert.equal(
parseRating({ id: 'a', pubkey: 'b', kind: 38000, created_at: 1, content: 'nice', tags: [] }),
null,
);
});
check('fractional rating tags are mapped onto 1..5', () => {
assert.equal(
parseRating({ id: 'a', pubkey: 'b', kind: 38000, created_at: 1, content: '', tags: [['rating', '0.8']] }),
4,
);
});
check('many reviews at 4.6 outrank few at 5.0 (the point of the whole formula)', () => {
const now = 1_800_000_000;
const at = (v: number, R: number) =>
bayesianScore(
{ review_count: v, rating_avg: R, status: 'online', last_review_at: now - 3600 },
NEUTRAL_PRIOR_MEAN, now,
);
assert.ok(at(39, 4.6) > at(4, 5.0), `39@4.6=${at(39, 4.6)} must beat 4@5.0=${at(4, 5.0)}`);
assert.ok(at(39, 4.6) > at(1, 5.0), 'and must beat a lone five star review');
assert.ok(at(4, 5.0) > at(1, 5.0), 'among equal means, more reviews wins');
});
check('the observed global mean as prior breaks that guarantee', () => {
// Documents why NEUTRAL_PRIOR_MEAN is the default instead of the literal
// BACKEND.md "C = global mean". Cashu reviews are almost all five stars, so the
// observed mean sits near 4.7 and shrinking toward it cannot reorder two means
// that are both above it. If this check ever starts failing, the network's rating
// spread changed and the default prior is worth revisiting.
const now = 1_800_000_000;
const observed = 4.7;
const many = bayesianScore(
{ review_count: 39, rating_avg: 4.6, status: 'online', last_review_at: now - 3600 },
observed, now,
);
const one = bayesianScore(
{ review_count: 1, rating_avg: 5.0, status: 'online', last_review_at: now - 3600 },
observed, now,
);
assert.ok(one > many, 'with C=4.7 a single 5 star review outranks 39 considered ones');
});
check('offline mints are penalised and always sort below online ones', () => {
const now = 1_800_000_000;
const input = { review_count: 20, rating_avg: 5, status: 'offline', last_review_at: now - 100 };
const off = bayesianScore(input, 4.5, now);
const on = bayesianScore({ ...input, status: 'online' }, 4.5, now);
assert.ok(off < on);
// A perfect offline mint still ranks below a mediocre online one.
const sorted = [
{ status: 'offline', score: 4.9, review_count: 99 },
{ status: 'online', score: 1.2, review_count: 1 },
].sort(compareMints);
assert.equal(sorted[0]?.status, 'online');
});
check('stale mints lose 10 percent', () => {
const now = 1_800_000_000;
const base = { review_count: 10, rating_avg: 4.5, status: 'online' };
const fresh = bayesianScore({ ...base, last_review_at: now - 100 }, 4.5, now);
const stale = bayesianScore({ ...base, last_review_at: now - 200 * 86400 }, 4.5, now);
assert.ok(Math.abs(stale - fresh * 0.9) < 0.002, `${stale} vs ${fresh * 0.9}`);
// Never reviewed counts as stale too.
assert.ok(bayesianScore({ ...base, last_review_at: null }, 4.5, now) < fresh);
});
check('probe status ladder: 1-2 fails degraded, 3+ offline', () => {
assert.equal(statusForFails(0), 'online');
assert.equal(statusForFails(1), 'degraded');
assert.equal(statusForFails(2), 'degraded');
assert.equal(statusForFails(3), 'offline');
});
check('nuts parse handles all three encodings seen in the wild', () => {
const nuts = parseNuts({
'4': { methods: [{ method: 'bolt11', unit: 'sat' }], disabled: false },
'5': { methods: [{ method: 'bolt11', unit: 'sat' }] },
'7': { supported: true },
'9': { supported: false },
'15': { supported: [{ method: 'bolt11', unit: 'sat' }] },
'16': { supported: [] },
'17': true,
'19': { disabled: true, methods: [{ method: 'bolt11' }] },
});
assert.deepEqual(nuts, ['4', '5', '7', '15', '17']);
});
check('limits come from NUT-04 methods', () => {
const limits = parseLimits({
'4': { methods: [{ method: 'bolt11', unit: 'sat', min_amount: 1, max_amount: 1_000_000 }] },
});
assert.deepEqual(limits, { min: 1, max: 1_000_000, unit: 'sat' });
assert.equal(parseLimits({ '4': { methods: [{ method: 'bolt11' }] } }), null);
assert.equal(parseLimits(undefined), null);
});
// The dedupe rule lives in SQL, so exercise it against a real in-memory database
// using the same statement the API uses.
check('one review per author per mint, newest wins', async () => {
const { default: Database } = await import('better-sqlite3');
const db = new Database(':memory:');
db.exec(`CREATE TABLE reviews (event_id TEXT PRIMARY KEY, mint_url TEXT, pubkey TEXT,
rating INTEGER, created_at INTEGER)`);
const insert = db.prepare('INSERT INTO reviews VALUES (?,?,?,?,?)');
insert.run('e1', 'https://m', 'alice', 1, 100);
insert.run('e2', 'https://m', 'alice', 5, 200); // alice changed her mind
insert.run('e3', 'https://m', 'bob', 3, 150);
const rows = db
.prepare(
`SELECT pubkey, rating FROM (
SELECT pubkey, rating, ROW_NUMBER() OVER (
PARTITION BY mint_url, pubkey ORDER BY created_at DESC, event_id) rn
FROM reviews) WHERE rn = 1 ORDER BY pubkey`,
)
.all() as { pubkey: string; rating: number }[];
assert.equal(rows.length, 2, 'alice must count once');
assert.equal(rows.find((r) => r.pubkey === 'alice')?.rating, 5, 'newest review wins');
db.close();
});
// The last check is async, so report after the microtask queue drains.
queueMicrotask(() => console.log(`ok, ${checks} checks passed`));
+28
View File
@@ -0,0 +1,28 @@
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { DEFAULT_RELAYS } from '@cashumints/shared';
const here = path.dirname(fileURLToPath(import.meta.url));
const apiRoot = path.resolve(here, '..');
function int(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw) return fallback;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
export const config = {
port: int('PORT', 8787),
dbPath: process.env['DB_PATH'] ?? path.join(apiRoot, 'data', 'cashumints.db'),
iconDir: process.env['ICON_DIR'] ?? path.join(apiRoot, 'data', 'icons'),
relays: (process.env['RELAYS']?.split(',').map((r) => r.trim()).filter(Boolean) ??
[...DEFAULT_RELAYS]) as string[],
probeIntervalMin: int('PROBE_INTERVAL_MIN', 10),
discoveryIntervalMin: int('DISCOVERY_INTERVAL_MIN', 60),
probeConcurrency: int('PROBE_CONCURRENCY', 8),
probeTimeoutMs: int('PROBE_TIMEOUT_MS', 5000),
userAgent: 'cashumints.space-indexer/1.0',
} as const;
export const startedAt = Math.floor(Date.now() / 1000);
+101
View File
@@ -0,0 +1,101 @@
import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { config } from './config.ts';
const SCHEMA = `
CREATE TABLE IF NOT EXISTS mints (
url TEXT PRIMARY KEY,
host TEXT NOT NULL,
name TEXT,
description TEXT,
icon_url TEXT,
icon_file TEXT,
pubkey TEXT,
info_json TEXT,
nuts_json TEXT,
version TEXT,
status TEXT NOT NULL DEFAULT 'unknown',
consecutive_fails INTEGER NOT NULL DEFAULT 0,
last_online INTEGER,
last_probe INTEGER,
first_seen INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_mints_host ON mints(host);
CREATE INDEX IF NOT EXISTS idx_mints_pubkey ON mints(pubkey);
CREATE TABLE IF NOT EXISTS reviews (
event_id TEXT PRIMARY KEY,
mint_url TEXT NOT NULL,
pubkey TEXT NOT NULL,
rating INTEGER,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_reviews_mint ON reviews(mint_url, created_at);
CREATE INDEX IF NOT EXISTS idx_reviews_author ON reviews(mint_url, pubkey, created_at);
CREATE TABLE IF NOT EXISTS probes (
mint_url TEXT NOT NULL,
ts INTEGER NOT NULL,
ok INTEGER NOT NULL,
latency_ms INTEGER
);
CREATE INDEX IF NOT EXISTS idx_probes_mint ON probes(mint_url, ts);
CREATE TABLE IF NOT EXISTS state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`;
export type DB = Database.Database;
let db: DB | null = null;
export function getDb(): DB {
if (db) return db;
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
fs.mkdirSync(config.iconDir, { recursive: true });
const handle = new Database(config.dbPath);
handle.pragma('journal_mode = WAL');
handle.pragma('synchronous = NORMAL');
handle.pragma('busy_timeout = 5000');
handle.exec(SCHEMA);
db = handle;
return handle;
}
export function closeDb(): void {
db?.close();
db = null;
}
export function getState(key: string): string | null {
const row = getDb().prepare('SELECT value FROM state WHERE key = ?').get(key) as
| { value: string }
| undefined;
return row?.value ?? null;
}
export function setState(key: string, value: string): void {
getDb()
.prepare('INSERT INTO state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
.run(key, value);
}
export function getStateNumber(key: string): number | null {
const raw = getState(key);
if (raw === null) return null;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) ? n : null;
}
/** Delete probe rows older than 90 days. Called once a day. */
export function pruneProbes(): number {
const cutoff = Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60;
return getDb().prepare('DELETE FROM probes WHERE ts < ?').run(cutoff).changes;
}
+257
View File
@@ -0,0 +1,257 @@
import { SimplePool } from 'nostr-tools/pool';
import type { Event as NostrEvent, Filter } from 'nostr-tools';
import {
KIND_MINT_ANNOUNCEMENT,
KIND_REVIEW,
isCashuMintReview,
mintPubkeyRef,
mintUrlSpellings,
mintUrlsFromEvent,
normalizeMintUrl,
parseRating,
} from '@cashumints/shared';
import { config } from './config.ts';
import { getDb, getStateNumber, setState } from './db.ts';
import { log } from './log.ts';
import { insertMintIfNew, mintUrlByHost, mintUrlByPubkey } from './mints.ts';
const QUERY_LIMIT = 500;
const MAX_WAIT_MS = 12_000;
/** Backfill page cap. 20 pages x 500 is far more than the network holds today. */
const MAX_PAGES = 20;
/** The old site's second filter: a recent window alongside the unbounded one, because
* some relays answer an unbounded query with an arbitrary slice. See NOTES.md. */
const RECENT_WINDOW_S = 30 * 24 * 60 * 60;
export interface DiscoveryResult {
events: number;
newMints: string[];
newReviews: number;
ok: boolean;
}
let pool: SimplePool | null = null;
function getPool(): SimplePool {
pool ??= new SimplePool();
return pool;
}
export function closePool(): void {
try {
pool?.close(config.relays);
} catch {
// A relay that is already gone throws on close. Nothing to do about it.
}
pool = null;
}
/**
* Query one kind, paging backwards with `until` until a page yields nothing new.
* Relays cap `limit` independently, so paging is the only way a fresh database
* converges to the complete history.
*/
async function fetchKind(kind: number, since: number | null): Promise<NostrEvent[]> {
const seen = new Map<string, NostrEvent>();
let until: number | undefined;
for (let page = 0; page < MAX_PAGES; page++) {
const filter: Filter = { kinds: [kind], limit: QUERY_LIMIT };
if (since !== null) filter.since = since;
if (until !== undefined) filter.until = until;
let batch: NostrEvent[];
try {
batch = await getPool().querySync(config.relays, filter, { maxWait: MAX_WAIT_MS });
} catch (err) {
log.warn('relay query failed', {
kind,
page,
reason: err instanceof Error ? err.message : String(err),
});
break;
}
let added = 0;
let oldest = Number.POSITIVE_INFINITY;
for (const e of batch) {
oldest = Math.min(oldest, e.created_at);
if (!seen.has(e.id)) {
seen.set(e.id, e);
added++;
}
}
// Nothing new, or the relays returned less than a full page: history is exhausted.
if (added === 0 || batch.length < QUERY_LIMIT || !Number.isFinite(oldest)) break;
until = oldest - 1;
if (since !== null && until <= since) break;
}
return [...seen.values()];
}
/**
* Reviews for one specific mint, asked for by name.
*
* The global sweep above does NOT converge on its own: relays answer an unbounded
* `{kinds:[38000]}` with an arbitrary capped slice, but answer a filtered query for one
* mint in full. Measured against the live network, the global sweep found 43 reviews for
* mint.minibits.cash/Bitcoin while this targeted query finds 80. Without this pass the
* count in the API disagrees with the list the browser renders on the same page.
*/
async function fetchReviewsForMint(
mintUrl: string,
mintPubkey: string | null,
since: number | null,
): Promise<NostrEvent[]> {
const filters: Filter[] = [];
const base: Filter = { kinds: [KIND_REVIEW], limit: QUERY_LIMIT };
if (since !== null) base.since = since;
if (mintPubkey) {
filters.push({ ...base, '#d': [mintPubkey], '#k': [String(KIND_MINT_ANNOUNCEMENT)] });
}
filters.push({ ...base, '#u': mintUrlSpellings(mintUrl) });
const batches = await Promise.all(
filters.map((filter) =>
getPool()
.querySync(config.relays, filter, { maxWait: MAX_WAIT_MS })
.catch(() => [] as NostrEvent[]),
),
);
return batches.flat();
}
/** Every mint URL an event points at, normalized and inserted if new. */
function ingestMintUrls(event: NostrEvent, now: number, newMints: Set<string>): void {
for (const raw of mintUrlsFromEvent(event)) {
const created = insertMintIfNew(raw, now);
if (created) newMints.add(created);
}
}
/**
* Resolve which mint a review is about.
*
* `u` tag first, resolved through the slug so that every spelling of a mint URL (with
* or without a trailing slash, either path case) lands on the one canonical row. Then
* the `d` tag pubkey against the pubkey a mint publishes in its own /v1/info, which
* catches reviews whose `u` tag is missing or wrong.
*/
function resolveReviewTarget(event: NostrEvent): string | null {
for (const raw of mintUrlsFromEvent(event)) {
const normalized = normalizeMintUrl(raw);
if (!normalized) continue;
// The row was inserted moments ago by ingestMintUrls, so this almost always hits.
const canonical = mintUrlByHost(normalized.host);
if (canonical) return canonical;
}
const pubkey = mintPubkeyRef(event);
return pubkey ? mintUrlByPubkey(pubkey) : null;
}
export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult> {
const started = Date.now();
const now = Math.floor(Date.now() / 1000);
const lastRun = backfill ? null : getStateNumber('discovery_since');
// Overlap the window by an hour so an event that arrived late is not missed.
const since = lastRun === null ? null : Math.max(0, lastRun - 3600);
const newMints = new Set<string>();
let newReviews = 0;
let events = 0;
let ok = true;
try {
const announcements = await fetchKind(KIND_MINT_ANNOUNCEMENT, since);
events += announcements.length;
for (const e of announcements) ingestMintUrls(e, now, newMints);
// Announcements alone miss mints that only ever appear in a review's `u` tag,
// so reviews feed discovery too.
const reviews = await fetchKind(KIND_REVIEW, since);
// The recent window catches anything a relay dropped from the unbounded query.
const recent =
since === null ? await fetchKind(KIND_REVIEW, now - RECENT_WINDOW_S) : [];
const byId = new Map<string, NostrEvent>();
for (const e of [...reviews, ...recent]) byId.set(e.id, e);
events += byId.size;
for (const e of byId.values()) ingestMintUrls(e, now, newMints);
const insert = getDb().prepare(
`INSERT INTO reviews (event_id, mint_url, pubkey, rating, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(event_id) DO UPDATE SET
mint_url = excluded.mint_url,
rating = excluded.rating`,
);
// `changes` counts updates as well as inserts, so ask first. The log line is a
// health signal (is discovery finding anything?) and an inflated number hides a
// stalled crawl behind a busy-looking figure.
const known = getDb().prepare('SELECT 1 FROM reviews WHERE event_id = ?');
const ingestReviews = getDb().transaction((list: NostrEvent[]) => {
for (const e of list) {
// The `k` tag is what marks a 38000 as being about a Cashu mint rather than
// some other recommendable kind. Events without it but with a resolvable mint
// URL are still accepted: the old publisher's own events are the reason.
const target = resolveReviewTarget(e);
if (!target) continue;
if (!isCashuMintReview(e) && mintUrlsFromEvent(e).length === 0) continue;
if (!known.get(e.id)) newReviews++;
insert.run(e.id, target, e.pubkey, parseRating(e), e.created_at);
}
});
ingestReviews([...byId.values()]);
// Second pass: ask each known mint's reviews by name, which is the only way the
// counts converge (see fetchReviewsForMint). Runs after the global sweep so mints
// discovered in this cycle are included.
const targets = getDb()
.prepare('SELECT url, pubkey FROM mints')
.all() as { url: string; pubkey: string | null }[];
const CONCURRENCY = 6;
let cursor = 0;
await Promise.all(
Array.from({ length: Math.min(CONCURRENCY, targets.length) }, async () => {
while (cursor < targets.length) {
const target = targets[cursor++];
if (!target) continue;
const found = await fetchReviewsForMint(target.url, target.pubkey, since);
if (found.length > 0) {
events += found.length;
ingestReviews(found);
}
}
}),
);
setState('discovery_since', String(now));
setState('last_discovery_at', String(now));
setState('last_discovery_ok', '1');
} catch (err) {
ok = false;
setState('last_discovery_ok', '0');
log.error('discovery failed', { reason: err instanceof Error ? err.message : String(err) });
}
log.info('discovery cycle', {
mode: backfill ? 'backfill' : 'incremental',
events,
new_mints: newMints.size,
new_reviews: newReviews,
ok,
ms: Date.now() - started,
});
return { events, newMints: [...newMints], newReviews, ok };
}
+65
View File
@@ -0,0 +1,65 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { config } from './config.ts';
import { log } from './log.ts';
import type { MintRow } from './mints.ts';
const EXT_BY_TYPE: Record<string, string> = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/webp': '.webp',
'image/svg+xml': '.svg',
'image/gif': '.gif',
'image/x-icon': '.ico',
'image/vnd.microsoft.icon': '.ico',
};
const MAX_ICON_BYTES = 512 * 1024;
/**
* Cache a mint's icon to disk so offline mints keep theirs.
* Re-fetches only when the icon_url changed, per BACKEND.md.
* Returns the stored filename, or the existing one if nothing needed doing.
*/
export async function cacheIcon(row: MintRow, iconUrl: string | null): Promise<string | null> {
if (!iconUrl) return row.icon_file;
if (iconUrl === row.icon_url && row.icon_file) return row.icon_file;
// Some mints inline the icon as a data URI. Nothing to fetch, and nothing worth storing.
if (iconUrl.startsWith('data:')) return row.icon_file;
try {
const absolute = new URL(iconUrl, `${row.url}/`).toString();
if (!absolute.startsWith('https://') && !absolute.startsWith('http://')) return row.icon_file;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.probeTimeoutMs);
let res: Response;
try {
res = await fetch(absolute, {
signal: controller.signal,
headers: { 'User-Agent': config.userAgent },
});
} finally {
clearTimeout(timer);
}
if (!res.ok) return row.icon_file;
const type = (res.headers.get('content-type') ?? '').split(';')[0]?.trim() ?? '';
const ext = EXT_BY_TYPE[type];
if (!ext) return row.icon_file;
const buf = Buffer.from(await res.arrayBuffer());
if (buf.byteLength === 0 || buf.byteLength > MAX_ICON_BYTES) return row.icon_file;
const filename = `${row.host}${ext}`;
await fs.writeFile(path.join(config.iconDir, filename), buf);
return filename;
} catch (err) {
log.warn('icon fetch failed', {
url: row.url,
reason: err instanceof Error ? err.message : String(err),
});
return row.icon_file;
}
}
+95
View File
@@ -0,0 +1,95 @@
import { serve } from '@hono/node-server';
import { config } from './config.ts';
import { closeDb, getDb, pruneProbes } from './db.ts';
import { closePool, runDiscovery } from './discovery.ts';
import { log } from './log.ts';
import { probeAll, probeUrls } from './probe.ts';
import { createApp } from './server.ts';
const DAY_MS = 24 * 60 * 60 * 1000;
let shuttingDown = false;
/** Set while a probe or discovery cycle is in flight, so shutdown can wait for it. */
let inFlight: Promise<unknown> = Promise.resolve();
function track<T>(work: Promise<T>): Promise<T> {
inFlight = inFlight.then(() => work).catch(() => undefined);
return work;
}
async function probeCycle(): Promise<void> {
if (shuttingDown) return;
try {
await track(probeAll());
} catch (err) {
log.error('probe cycle failed', { reason: err instanceof Error ? err.message : String(err) });
}
}
async function discoveryCycle(backfill: boolean): Promise<void> {
if (shuttingDown) return;
try {
const result = await track(runDiscovery(backfill));
// New mints are probed straight away, not on the next cycle, so their page has
// real metadata as soon as it is reachable.
if (result.newMints.length > 0) await track(probeUrls(result.newMints));
} catch (err) {
log.error('discovery cycle failed', {
reason: err instanceof Error ? err.message : String(err),
});
}
}
function main(): void {
getDb();
const server = serve({ fetch: createApp().fetch, port: config.port }, (info) => {
log.info('api listening', {
port: info.port,
db: config.dbPath,
relays: config.relays.length,
});
});
const probeTimer = setInterval(() => void probeCycle(), config.probeIntervalMin * 60 * 1000);
const discoveryTimer = setInterval(
() => void discoveryCycle(false),
config.discoveryIntervalMin * 60 * 1000,
);
const pruneTimer = setInterval(() => {
const removed = pruneProbes();
if (removed > 0) log.info('pruned probes', { rows: removed });
}, DAY_MS);
// Startup: probe what we already know, then a full discovery backfill so a fresh
// database converges to complete review counts (BACKEND.md).
void probeCycle().then(() => discoveryCycle(true));
const shutdown = (signal: string) => {
if (shuttingDown) return;
shuttingDown = true;
log.info('shutting down', { signal });
clearInterval(probeTimer);
clearInterval(discoveryTimer);
clearInterval(pruneTimer);
void inFlight.finally(() => {
closePool();
server.close(() => {
closeDb();
process.exit(0);
});
// Do not hang forever on a relay socket that refuses to close.
setTimeout(() => {
closeDb();
process.exit(0);
}, 8000).unref();
});
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
}
main();
+21
View File
@@ -0,0 +1,21 @@
/** One line per event, key=value after the message, so logs grep cleanly. */
type Fields = Record<string, string | number | boolean | null | undefined>;
function emit(level: 'info' | 'warn' | 'error', msg: string, fields?: Fields): void {
const parts = [new Date().toISOString(), level.toUpperCase(), msg];
if (fields) {
for (const [k, v] of Object.entries(fields)) {
if (v !== undefined && v !== null) parts.push(`${k}=${v}`);
}
}
const line = parts.join(' ');
if (level === 'error') console.error(line);
else if (level === 'warn') console.warn(line);
else console.log(line);
}
export const log = {
info: (msg: string, fields?: Fields) => emit('info', msg, fields),
warn: (msg: string, fields?: Fields) => emit('warn', msg, fields),
error: (msg: string, fields?: Fields) => emit('error', msg, fields),
};
+94
View File
@@ -0,0 +1,94 @@
import { normalizeMintUrl } from '@cashumints/shared';
import { getDb } from './db.ts';
import { log } from './log.ts';
/**
* URLs already reported as unusable. A rejected value (a Fedimint `fed11…` invite, an
* onion address) recurs in dozens of events per cycle, and logging each occurrence
* buries the lines that matter.
*/
const reportedSkips = new Set<string>();
export interface MintRow {
url: string;
host: string;
name: string | null;
description: string | null;
icon_url: string | null;
icon_file: string | null;
pubkey: string | null;
info_json: string | null;
nuts_json: string | null;
version: string | null;
status: string;
consecutive_fails: number;
last_online: number | null;
last_probe: number | null;
first_seen: number;
updated_at: number;
}
/**
* Insert a mint if it is new. Never touches an existing row: cached metadata is the
* whole point of the table, and discovery must not overwrite what a probe learned.
* Returns the normalized URL when a row was created, null otherwise.
*
* Deduped on the slug, not the URL. Real data has `https://mint.minibits.cash/Bitcoin`
* and `https://mint.minibits.cash/bitcoin` announced as separate mints; they are one
* mint written two ways, and keying on the URL would split its reviews across two pages.
* The URL keeps its original path case, because a mint's path is case sensitive over
* HTTP and the lowercase spelling 404s when probed.
*
* ponytail: whichever casing is seen first wins and is the one probed. The seed list
* pins the working spelling, so in practice the correct one is always inserted first.
* If that ever stops holding, the upgrade is to probe both casings once and keep the
* one that answers.
*/
export function insertMintIfNew(rawUrl: string, now = Math.floor(Date.now() / 1000)): string | null {
const normalized = normalizeMintUrl(rawUrl);
if (!normalized) {
if (!reportedSkips.has(rawUrl)) {
reportedSkips.add(rawUrl);
log.warn('skipped mint url', { url: rawUrl.slice(0, 80) });
}
return null;
}
const result = getDb()
.prepare(
// OR IGNORE covers both unique keys, url and host, in one clause.
`INSERT OR IGNORE INTO mints (url, host, status, first_seen, updated_at)
VALUES (?, ?, 'unknown', ?, ?)`,
)
.run(normalized.url, normalized.host, now, now);
return result.changes > 0 ? normalized.url : null;
}
/** Canonical mint URL for a normalized slug, or null if no such mint is tracked. */
export function mintUrlByHost(host: string): string | null {
const row = getDb().prepare('SELECT url FROM mints WHERE host = ?').get(host) as
| { url: string }
| undefined;
return row?.url ?? null;
}
export function allMintRows(): MintRow[] {
return getDb().prepare('SELECT * FROM mints').all() as MintRow[];
}
export function mintByHost(host: string): MintRow | undefined {
return getDb().prepare('SELECT * FROM mints WHERE host = ?').get(host) as MintRow | undefined;
}
export function mintByUrl(url: string): MintRow | undefined {
return getDb().prepare('SELECT * FROM mints WHERE url = ?').get(url) as MintRow | undefined;
}
/** Resolve a mint by the pubkey it publishes in /v1/info, for reviews with only a `d` tag. */
export function mintUrlByPubkey(pubkey: string): string | null {
const row = getDb().prepare('SELECT url FROM mints WHERE pubkey = ? LIMIT 1').get(pubkey) as
| { url: string }
| undefined;
return row?.url ?? null;
}
+169
View File
@@ -0,0 +1,169 @@
import { parseNuts, type MintInfo } from '@cashumints/shared';
import { config } from './config.ts';
import { getDb, setState } from './db.ts';
import { cacheIcon } from './icons.ts';
import { log } from './log.ts';
import { allMintRows, mintByUrl, type MintRow } from './mints.ts';
export interface ProbeResult {
url: string;
ok: boolean;
latencyMs: number | null;
status: string;
changed: boolean;
}
/** 1 to 2 consecutive failures is degraded, 3 or more is offline. */
export function statusForFails(fails: number): 'online' | 'degraded' | 'offline' {
if (fails === 0) return 'online';
return fails < 3 ? 'degraded' : 'offline';
}
async function fetchInfo(url: string): Promise<{ info: MintInfo; latencyMs: number }> {
const started = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.probeTimeoutMs);
try {
const res = await fetch(`${url}/v1/info`, {
signal: controller.signal,
headers: { Accept: 'application/json', 'User-Agent': config.userAgent },
redirect: 'follow',
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const info = (await res.json()) as MintInfo;
if (!info || typeof info !== 'object') throw new Error('not a JSON object');
return { info, latencyMs: Date.now() - started };
} finally {
clearTimeout(timer);
}
}
/**
* Probe one mint and write the result.
*
* On failure the cached metadata columns are deliberately left untouched. That cache is
* what lets an offline mint still render its full page, which is acceptance criterion #1.
*/
export async function probeMint(row: MintRow): Promise<ProbeResult> {
const db = getDb();
const now = Math.floor(Date.now() / 1000);
try {
const { info, latencyMs } = await fetchInfo(row.url);
const nuts = parseNuts(info.nuts);
const iconFile = await cacheIcon(row, info.icon_url ?? null);
db.prepare(
`UPDATE mints SET
name = COALESCE(?, name),
description = COALESCE(?, description),
icon_url = ?,
icon_file = ?,
pubkey = COALESCE(?, pubkey),
info_json = ?,
nuts_json = ?,
version = COALESCE(?, version),
status = 'online',
consecutive_fails = 0,
last_online = ?,
last_probe = ?,
updated_at = ?
WHERE url = ?`,
).run(
info.name ?? null,
info.description ?? null,
info.icon_url ?? null,
iconFile,
info.pubkey ?? null,
JSON.stringify(info),
JSON.stringify(nuts),
info.version ?? null,
now,
now,
now,
row.url,
);
db.prepare('INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 1, ?)').run(
row.url,
now,
latencyMs,
);
const changed = row.status !== 'online';
if (changed) log.info('mint state change', { url: row.url, from: row.status, to: 'online' });
return { url: row.url, ok: true, latencyMs, status: 'online', changed };
} catch (err) {
const fails = row.consecutive_fails + 1;
const status = statusForFails(fails);
db.prepare(
`UPDATE mints SET consecutive_fails = ?, status = ?, last_probe = ?, updated_at = ?
WHERE url = ?`,
).run(fails, status, now, now, row.url);
db.prepare('INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 0, NULL)').run(
row.url,
now,
);
const changed = row.status !== status;
if (changed) {
log.info('mint state change', {
url: row.url,
from: row.status,
to: status,
fails,
reason: err instanceof Error ? err.message : String(err),
});
}
return { url: row.url, ok: false, latencyMs: null, status, changed };
}
}
/** Run `tasks` with at most `limit` in flight. */
async function pooled<T>(items: T[], limit: number, fn: (item: T) => Promise<unknown>): Promise<void> {
let cursor = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (cursor < items.length) {
const item = items[cursor++];
if (item !== undefined) await fn(item);
}
});
await Promise.all(workers);
}
export async function probeAll(rows: MintRow[] = allMintRows()): Promise<ProbeResult[]> {
if (rows.length === 0) return [];
const started = Date.now();
const results: ProbeResult[] = [];
await pooled(rows, config.probeConcurrency, async (row) => {
results.push(await probeMint(row));
});
const online = results.filter((r) => r.ok).length;
const changed = results.filter((r) => r.changed).length;
log.info('probe cycle', {
mints: results.length,
online,
down: results.length - online,
changed,
ms: Date.now() - started,
});
setState('last_probe_at', String(Math.floor(Date.now() / 1000)));
return results;
}
/** Probe a set of URLs immediately, used when discovery finds new mints. */
export async function probeUrls(urls: string[]): Promise<void> {
const rows = urls.map((u) => mintByUrl(u)).filter((r): r is MintRow => r !== undefined);
if (rows.length > 0) await probeAll(rows);
}
+275
View File
@@ -0,0 +1,275 @@
import {
bayesianScore,
compareMints,
NEUTRAL_PRIOR_MEAN,
parseNuts,
type Health,
type MintDetail,
type MintInfo,
type MintListItem,
type MintStatus,
type ProbeSample,
type RatingDistribution,
type Stats,
} from '@cashumints/shared';
import { config, startedAt } from './config.ts';
import { getDb, getStateNumber, getState } from './db.ts';
import { mintByHost, type MintRow } from './mints.ts';
/**
* One review per author per mint, newest wins.
*
* Kind 38000 is in the addressable range, so a later event from the same author
* replaces the earlier one. The old site applied the same rule in memory
* (aggregateReviews, keyed by pubkey); doing it in SQL keeps counts honest and stops
* one npub from moving a mint's average by re-posting.
*/
const LATEST_REVIEWS = `
SELECT mint_url, pubkey, rating, created_at FROM (
SELECT mint_url, pubkey, rating, created_at,
ROW_NUMBER() OVER (
PARTITION BY mint_url, pubkey ORDER BY created_at DESC, event_id
) AS rn
FROM reviews
) WHERE rn = 1
`;
interface AggRow {
mint_url: string;
review_count: number;
rating_avg: number | null;
last_review_at: number | null;
}
function aggregates(): Map<string, AggRow> {
const rows = getDb()
.prepare(
`WITH latest AS (${LATEST_REVIEWS})
SELECT mint_url,
COUNT(*) AS review_count,
AVG(rating) AS rating_avg,
MAX(created_at) AS last_review_at
FROM latest GROUP BY mint_url`,
)
.all() as AggRow[];
return new Map(rows.map((r) => [r.mint_url, r]));
}
/**
* C in the Bayesian formula.
*
* Defaults to a neutral 3, not the observed global mean: see the long comment in
* shared/src/score.ts for why the observed mean (about 4.7 here) makes the formula
* fail its own stated purpose. `SCORE_PRIOR_MEAN=global` restores literal BACKEND.md
* behaviour, or set any number to pin it.
*/
function priorMean(): number {
const override = process.env['SCORE_PRIOR_MEAN'];
if (override && override !== 'global') {
const n = Number.parseFloat(override);
if (Number.isFinite(n) && n >= 1 && n <= 5) return n;
}
if (override === 'global') {
const row = getDb()
.prepare(`WITH latest AS (${LATEST_REVIEWS}) SELECT AVG(rating) AS avg FROM latest`)
.get() as { avg: number | null };
return row.avg ?? NEUTRAL_PRIOR_MEAN;
}
return NEUTRAL_PRIOR_MEAN;
}
function iconPath(row: MintRow): string | null {
return row.icon_file ? `/icons/${row.icon_file}` : null;
}
function round1(n: number | null): number | null {
return n === null ? null : Math.round(n * 10) / 10;
}
function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: number): MintListItem {
const base = {
review_count: agg?.review_count ?? 0,
rating_avg: agg?.rating_avg ?? null,
status: row.status,
last_review_at: agg?.last_review_at ?? null,
};
return {
url: row.url,
host: row.host,
name: row.name,
icon: iconPath(row),
status: row.status as MintStatus,
last_online: row.last_online,
review_count: base.review_count,
rating_avg: round1(base.rating_avg),
score: bayesianScore(base, mean, now),
last_review_at: base.last_review_at,
version: row.version,
};
}
export function listMints(limit?: number): MintListItem[] {
const now = Math.floor(Date.now() / 1000);
const agg = aggregates();
const mean = priorMean();
const items = (getDb().prepare('SELECT * FROM mints').all() as MintRow[])
.map((row) => toListItem(row, agg.get(row.url), mean, now))
.sort(compareMints);
return limit && limit > 0 ? items.slice(0, limit) : items;
}
function distribution(mintUrl: string): RatingDistribution {
const rows = getDb()
.prepare(
`WITH latest AS (${LATEST_REVIEWS})
SELECT rating, COUNT(*) AS n FROM latest
WHERE mint_url = ? AND rating IS NOT NULL GROUP BY rating`,
)
.all(mintUrl) as { rating: number; n: number }[];
const dist: RatingDistribution = { '1': 0, '2': 0, '3': 0, '4': 0, '5': 0 };
for (const r of rows) {
const key = String(r.rating) as keyof RatingDistribution;
if (key in dist) dist[key] = r.n;
}
return dist;
}
function reviews90d(mintUrl: string): number {
const cutoff = Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60;
const row = getDb()
.prepare(
`WITH latest AS (${LATEST_REVIEWS})
SELECT COUNT(*) AS n FROM latest WHERE mint_url = ? AND created_at >= ?`,
)
.get(mintUrl, cutoff) as { n: number };
return row.n;
}
function uptime30d(mintUrl: string): number | null {
const cutoff = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60;
const row = getDb()
.prepare('SELECT AVG(ok) AS up, COUNT(*) AS n FROM probes WHERE mint_url = ? AND ts >= ?')
.get(mintUrl, cutoff) as { up: number | null; n: number };
if (!row.n || row.up === null) return null;
return Math.round(row.up * 1000) / 1000;
}
function recentProbes(mintUrl: string): ProbeSample[] {
const cutoff = Math.floor(Date.now() / 1000) - 48 * 60 * 60;
return getDb()
.prepare(
'SELECT ts, ok, latency_ms FROM probes WHERE mint_url = ? AND ts >= ? ORDER BY ts ASC',
)
.all(mintUrl, cutoff) as ProbeSample[];
}
function parseInfo(json: string | null): MintInfo | null {
if (!json) return null;
try {
return JSON.parse(json) as MintInfo;
} catch {
return null;
}
}
export function getMintDetail(host: string): MintDetail | null {
const row = mintByHost(host);
if (!row) return null;
const now = Math.floor(Date.now() / 1000);
const agg = aggregates().get(row.url);
const item = toListItem(row, agg, priorMean(), now);
const info = parseInfo(row.info_json);
let nuts: string[] = [];
if (row.nuts_json) {
try {
nuts = JSON.parse(row.nuts_json) as string[];
} catch {
nuts = [];
}
}
if (nuts.length === 0 && info) nuts = parseNuts(info.nuts);
return {
...item,
description: row.description,
pubkey: row.pubkey,
info,
nuts,
first_seen: row.first_seen,
last_probe: row.last_probe,
updated_at: row.updated_at,
rating_distribution: distribution(row.url),
reviews_90d: reviews90d(row.url),
uptime_30d: uptime30d(row.url),
probes_recent: recentProbes(row.url),
};
}
let statsCache: { at: number; value: Stats } | null = null;
const STATS_TTL_S = 60;
export function getStats(): Stats {
const now = Math.floor(Date.now() / 1000);
if (statsCache && now - statsCache.at < STATS_TTL_S) return statsCache.value;
const db = getDb();
const counts = db
.prepare(
`SELECT
COUNT(*) AS total,
SUM(status = 'online') AS online,
SUM(status = 'offline') AS offline,
SUM(status = 'degraded') AS degraded
FROM mints`,
)
.get() as { total: number; online: number | null; offline: number | null; degraded: number | null };
const reviews = db
.prepare(`WITH latest AS (${LATEST_REVIEWS}) SELECT COUNT(*) AS n, MAX(created_at) AS last FROM latest`)
.get() as { n: number; last: number | null };
const value: Stats = {
mints_total: counts.total,
mints_online: counts.online ?? 0,
mints_offline: counts.offline ?? 0,
mints_degraded: counts.degraded ?? 0,
reviews_total: reviews.n,
last_review_at: reviews.last,
updated_at: now,
};
statsCache = { at: now, value };
return value;
}
/** Health bypasses the stats cache: it is the endpoint you page on. */
export function getHealth(): Health {
const now = Math.floor(Date.now() / 1000);
const lastProbe = getStateNumber('last_probe_at');
const lastDiscovery = getStateNumber('last_discovery_at');
const discoveryOk = getState('last_discovery_ok') !== '0';
const tracked = (getDb().prepare('SELECT COUNT(*) AS n FROM mints').get() as { n: number }).n;
const staleAfter = config.probeIntervalMin * 60 * 3;
const probeStale = lastProbe === null || now - lastProbe > staleAfter;
return {
status: probeStale || !discoveryOk ? 'degraded' : 'ok',
uptime_s: now - startedAt,
last_probe_at: lastProbe,
last_discovery_at: lastDiscovery,
mints_tracked: tracked,
updated_at: now,
};
}
+120
View File
@@ -0,0 +1,120 @@
/**
* pnpm seed: ingest the initial mint list, run one probe cycle, run one discovery
* backfill, print a summary table.
*
* The list below is every mint URL carried in a kind 38172 announcement on the old
* site's relay pool at the time of the rebuild (see NOTES.md: the old project has no
* hardcoded list of its own, its directory is built live from Nostr). Seeding is only
* a warm start, discovery finds everything here on its own and keeps finding more.
*/
import { closeDb, getDb } from './db.ts';
import { closePool, runDiscovery } from './discovery.ts';
import { log } from './log.ts';
import { insertMintIfNew } from './mints.ts';
import { probeAll } from './probe.ts';
import { listMints } from './queries.ts';
const SEED_MINTS = [
'https://21mint.me',
'https://bitcointxoko.com/cashu/api/v1/dMk78c5aR7uhHzcqH3Bwqp',
'https://cashu.boats',
'https://cashu.mineracks.com',
'https://forge.flashapp.me',
'https://lnb.bolverker.com/cashu/api/v1/GZjPXyQXsgnZEgKZsLRQyy',
'https://lnwallet.app/cashu/api/v1/9tZLEVyknrtaGxeBjEecc4',
'https://bb.mountainlake.io/cashu/api/v1/WpczZwvNES7wMa8ForKGmh',
'https://mint.0xchat.com',
'https://mint.28waves.com',
'https://mint.belgianbitcoinembassy.org',
'https://mint.btcforplebs.com',
'https://mint.cashu.chat',
'https://mint.coinos.io',
'https://mint.endfiat.money',
'https://mint.fredix.xyz',
'https://mint.gitvid.net',
'https://mint.gwoq.com',
'https://mint.kashir.xyz',
'https://mint.lnfast.com',
'https://mint.lnserver.com',
'https://mint.lnvoltz.com',
'https://mint.lnw.cash',
'https://mint.lnwallet.app',
'https://mint.mikelcalvo.net',
'https://mint.minibits.cash/Bitcoin',
'https://mint.mineracks.com',
'https://mint.mountainlake.io',
'https://mint.nostrich.cc',
'https://mint.quixote.cash/cashu/api/v1/TX6nZECe8r6hjDxtdA6N5b',
'https://mint.sharegap.net',
'https://mint.westernbtc.com',
'https://mint2.nutmix.cash',
'https://obsessedjerky7.lnbits.com/cashu/api/v1/L4bNfiBtTyhEpZWdt6QTb4',
'https://smilemoji.cash:3338',
'https://stpaulinternet.net/cashu/api/v1/TTcmiEDSWmkefef3XqERKv',
'https://kashu.me',
];
function pad(s: string, width: number): string {
return s.length > width ? `${s.slice(0, width - 1)}…` : s.padEnd(width);
}
async function main(): Promise<void> {
getDb();
let added = 0;
for (const url of SEED_MINTS) if (insertMintIfNew(url)) added++;
log.info('seed list ingested', { listed: SEED_MINTS.length, added });
await probeAll();
const discovery = await runDiscovery(true);
// Discovery almost always turns up mints the seed list did not have. Probe those too,
// otherwise they sit at status 'unknown' with no metadata.
if (discovery.newMints.length > 0) {
log.info('probing newly discovered mints', { count: discovery.newMints.length });
await probeAll();
}
const mints = listMints();
const width = { host: 44, status: 9, score: 7, reviews: 8, rating: 7 };
console.log('');
console.log(
[
pad('MINT', width.host),
pad('STATUS', width.status),
pad('SCORE', width.score),
pad('REVIEWS', width.reviews),
pad('RATING', width.rating),
'VERSION',
].join(' '),
);
console.log('-'.repeat(110));
for (const m of mints) {
console.log(
[
pad(m.host, width.host),
pad(m.status, width.status),
pad(m.score.toFixed(3), width.score),
pad(String(m.review_count), width.reviews),
pad(m.rating_avg === null ? '-' : m.rating_avg.toFixed(1), width.rating),
m.version ?? '-',
].join(' '),
);
}
const online = mints.filter((m) => m.status === 'online').length;
const offline = mints.filter((m) => m.status === 'offline').length;
const reviews = mints.reduce((sum, m) => sum + m.review_count, 0);
console.log('-'.repeat(110));
console.log(
`${mints.length} mints (${online} online, ${offline} offline), ${reviews} reviews indexed`,
);
closePool();
closeDb();
process.exit(0);
}
void main();
+48
View File
@@ -0,0 +1,48 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { serveStatic } from '@hono/node-server/serve-static';
import path from 'node:path';
import { config } from './config.ts';
import { getHealth, getMintDetail, getStats, listMints } from './queries.ts';
export function createApp(): Hono {
const app = new Hono();
// Public read-only API. There is nothing to protect and every client is a browser.
app.use('/api/*', cors());
app.use('/icons/*', cors());
app.get('/api/health', (c) => {
const health = getHealth();
c.header('Cache-Control', 'no-store');
return c.json(health, health.status === 'ok' ? 200 : 503);
});
app.get('/api/stats', (c) => c.json(getStats()));
app.get('/api/mints', (c) => {
const raw = c.req.query('limit');
const limit = raw ? Number.parseInt(raw, 10) : undefined;
return c.json(listMints(Number.isFinite(limit) ? limit : undefined));
});
app.get('/api/mints/:host', (c) => {
const detail = getMintDetail(c.req.param('host'));
if (!detail) return c.json({ error: 'not_found', message: 'No mint with that host' }, 404);
return c.json(detail);
});
// Cached mint icons, so an offline mint keeps its icon.
app.use(
'/icons/*',
serveStatic({
root: path.relative(process.cwd(), config.iconDir) || '.',
rewriteRequestPath: (p) => p.replace(/^\/icons/, ''),
onFound: (_p, c) => c.header('Cache-Control', 'public, max-age=86400'),
}),
);
app.notFound((c) => c.json({ error: 'not_found' }, 404));
return app;
}