Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions packages/core/src/cache/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,18 @@ export type ReviewRow = {
eventId: string;
createdAt: number;
/**
* Pointer-kind tag: 38172 (Cashu) or 38173 (Fedimint). Optional because
* in-the-wild events sometimes omit the `k` tag entirely; keep lenient.
* Pointer-kind tag: 38172 (Cashu) or 38173 (Fedimint). Required per
* NIP-87 (P0.3 — `parseReview` rejects events without it). Typed
* required at the cache layer so consumers don't have to handle the
* undefined case any more.
*/
k?: 38172 | 38173;
k: 38172 | 38173;
/**
* Spec-blessed target pointer: `<kind>:<pubkey>:<d>` per NIP-87. The
* authoritative dedup key when a single mint is referenced from multiple
* announcements (P1). Required on parse — see `parseReview`.
*/
a: string;
/**
* Mint URL(s) from optional `u` tags on the recommendation — display-only
* helper, does NOT participate in replaceable-event keying.
Expand Down
13 changes: 10 additions & 3 deletions packages/core/src/cache/upsert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,20 @@ function makeAnnouncement(over: Partial<AnnouncementRow> = {}): AnnouncementRow
}

function makeReview(over: Partial<ReviewRow> = {}): ReviewRow {
// Default to a 64-char hex reviewer pubkey so the synthetic `a` tag below
// satisfies the parse-layer's hex-pubkey check (mirrors what real
// upstream-of-cache flows produce).
const reviewer = `${"f".repeat(60)}2222`;
const d = over.d ?? D_XONLY;
const k = (over.k ?? 38172) as 38172 | 38173;
return {
pubkey: `pk-${"0".repeat(60)}2222`,
pubkey: reviewer,
kind: 38000,
d: D_XONLY,
d,
eventId: EID_LOW,
createdAt: 1_700_000_000,
k: 38172,
k,
a: `${k}:${reviewer}:${d}`,
rating: 5,
content: "[5/5] good mint",
rawTags: [],
Expand Down
25 changes: 8 additions & 17 deletions packages/core/src/cache/upsert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,18 +98,12 @@ export async function upsertAnnouncement(
/**
* Upsert a kind:38000 review with Layer A gating on the target `d` tag.
*
* The review's `d` points at a mint. When `k === 38172` (or `k` is absent,
* which is how most in-the-wild Cashu reviews shape), we apply the same
* Layer A d-regex gate that `upsertAnnouncement` uses — if the referenced
* mint pubkey isn't 64/66-char hex, the review is bot-spam pointing at
* bot-spam, returned as `rejected-invalid`. This is the firewall that
* keeps the 959 zero-d-tag bot spam events (per relay-strategy §4) from
* filtering up into the ranking aggregate.
*
* `k === 38173` (Fedimint) switches to the sibling `isValidFedimintDTag`
* shape gate — every real federation ID in the audit corpus is lowercase
* 64-char hex, so a short / junk d-tag with `k=38173` attached is still
* bot spam and must be caught by the same firewall.
* The review's `d` points at a mint. The pointer-kind `k` selects which
* shape gate applies: `k === 38173` uses `isValidFedimintDTag`, else
* `isValidCashuDTag`. P0.3: `parseReview` rejects events without a valid
* `k` tag at parse, so by the time a row reaches this function, `k` is
* always set (38172 or 38173). The previous fallback ("no k → default to
* Cashu") silently misrouted Fedimint reviews and let bot spam through.
*
* Note: this low-level upsert is the mechanical write. It does NOT
* materialize the `mintAggregate` row — the `reviews/` wrapper composes
Expand All @@ -120,11 +114,8 @@ export async function upsertAnnouncement(
*/
export async function upsertReview(db: BitcoinmintsDB, row: ReviewRow): Promise<UpsertResult> {
// Layer A gate — reject invalid d-tag shapes before touching the DB.
// Reviews point at a target mint via `d`; the pointer-kind `k` selects
// which shape gate applies. No `k` tag → treat as Cashu (the default
// for in-the-wild events per rating-tag-research §3). Fedimint rows
// still get a sibling shape check (64-char hex federation id) so junk
// d-tags with `k=38173` slapped on don't free-pass the firewall.
// P0.3: `k` is required at parse time, so we route purely on its value;
// no implicit Cashu default for missing k.
if (row.k === 38173) {
if (!isValidFedimintDTag(row.d)) return "rejected-invalid";
} else if (!isValidCashuDTag(row.d)) {
Expand Down
25 changes: 16 additions & 9 deletions packages/core/src/cashu/info.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,20 +142,27 @@ describe("fetchMintInfo — failure modes", () => {
expect(r.error).toBe("invalid JSON");
});

it("returns 'missing pubkey field' when JSON parses but lacks pubkey", async () => {
it("P0.2: passes through with pubkey absent when JSON lacks pubkey (NUT-06 says optional)", async () => {
// NUT-06 §"info": pubkey is optional — Layer B falls through to
// `contact.[method=nostr]` for signer binding (P0.1). Don't hard-reject.
fetchSpy.mockResolvedValueOnce(jsonResponse({ name: "no key here" }));
const r = await fetchMintInfo("https://mint.example.com");
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.error).toBe("missing pubkey field");
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.info.pubkey).toBeUndefined();
expect(r.info.name).toBe("no key here");
});

it("returns 'missing pubkey field' when pubkey is empty string", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ pubkey: "" }));
it("P0.2: drops malformed pubkey (empty string) but keeps the response ok", async () => {
// Defensive: empty string isn't a valid pubkey — drop it so downstream
// signer-binding doesn't compare against `""` and accidentally match a
// signer with a similarly empty value.
fetchSpy.mockResolvedValueOnce(jsonResponse({ pubkey: "", name: "some mint" }));
const r = await fetchMintInfo("https://mint.example.com");
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.error).toBe("missing pubkey field");
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.info.pubkey).toBeUndefined();
expect(r.info.name).toBe("some mint");
});
});

Expand Down
32 changes: 24 additions & 8 deletions packages/core/src/cashu/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,24 @@
/**
* NUT-06 `/v1/info` response shape — the subset we care about.
*
* Cashu mints in the wild don't all set every field. We type optional
* everything except `pubkey` (NUT-06 mandatory + this is what Layer B
* compares against the announcement signer). `nuts` is a bag of
* NUT-name -> capability shape; we under-spec it here and pass through
* whatever shape the mint emits — see data-model-v1.md §7.
* Cashu mints in the wild don't all set every field. We type EVERYTHING
* optional including `pubkey`: NUT-06 explicitly says "(optional) pubkey
* is the hex pubkey of the mint", and the audit (P0.2) found the prior
* hard-reject on missing pubkey rejected spec-conforming pubkey-less
* mints. Layer B handles the absent-pubkey case via NUT-06 `contact`
* with `method=nostr` (P0.1), or surfaces `null` when neither source
* is available. `nuts` is a bag of NUT-name -> capability shape; we
* under-spec it here and pass through whatever shape the mint emits —
* see data-model-v1.md §7.
*/
export type MintInfoV1 = {
/** Mint's compressed/x-only secp256k1 pubkey. NUT-06 mandatory. */
pubkey: string;
/**
* Mint's compressed/x-only secp256k1 pubkey. NUT-06 says optional —
* many real mints omit it and rely on `contact.[method=nostr]` for
* signer binding instead. Layer B (cashu/layerB.ts) handles both
* sources.
*/
pubkey?: string;
name?: string;
version?: string;
description?: string;
Expand Down Expand Up @@ -179,8 +188,15 @@ export async function fetchMintInfo(
}

const obj = body as Record<string, unknown>;
// P0.2: pubkey is OPTIONAL per NUT-06. Don't reject on absence; Layer B
// falls through to `contact.[method=nostr]` for signer binding when
// `pubkey` is missing (see cashu/layerB.ts). When pubkey IS present but
// not a non-empty string, drop it from the result so downstream code
// doesn't compare against a malformed value (defensive: an emitter that
// sends `pubkey: null` shouldn't be treated as "matches null").
if (typeof obj.pubkey !== "string" || obj.pubkey.length === 0) {
return { ok: false, error: "missing pubkey field", status: response.status };
const { pubkey: _omitted, ...rest } = obj;
return { ok: true, info: rest as MintInfoV1 };
}

return { ok: true, info: obj as MintInfoV1 };
Expand Down
Loading
Loading