diff --git a/bun.lock b/bun.lock index eac04d3..50e4e03 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,13 @@ "packages/core": { "name": "@bitcoinmints/core", "version": "0.0.0", + "dependencies": { + "dexie": "^4.2.0", + "nostr-tools": "^2.23.3", + }, + "devDependencies": { + "fake-indexeddb": "^6.2.2", + }, }, }, "packages": { @@ -53,6 +60,12 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="], + + "@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="], + + "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + "@oxc-project/types": ["@oxc-project/types@0.124.0", "", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="], @@ -87,6 +100,12 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="], + "@scure/base": ["@scure/base@2.0.0", "", {}, "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w=="], + + "@scure/bip32": ["@scure/bip32@2.0.1", "", { "dependencies": { "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA=="], + + "@scure/bip39": ["@scure/bip39@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], @@ -121,12 +140,16 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "dexie": ["dexie@4.4.2", "", {}, "sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw=="], + "es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "fake-indexeddb": ["fake-indexeddb@6.2.5", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -159,6 +182,10 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nostr-tools": ["nostr-tools@2.23.3", "", { "dependencies": { "@noble/ciphers": "2.1.1", "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0", "@scure/bip32": "2.0.1", "@scure/bip39": "2.0.1", "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA=="], + + "nostr-wasm": ["nostr-wasm@0.1.0", "", {}, "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], diff --git a/packages/core/package.json b/packages/core/package.json index 833d4b8..21e8adf 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -8,5 +8,12 @@ "scripts": { "test": "vitest run", "typecheck": "tsc --noEmit" + }, + "dependencies": { + "dexie": "^4.2.0", + "nostr-tools": "^2.23.3" + }, + "devDependencies": { + "fake-indexeddb": "^6.2.2" } } diff --git a/packages/core/src/cache/index.ts b/packages/core/src/cache/index.ts new file mode 100644 index 0000000..cc1d1ea --- /dev/null +++ b/packages/core/src/cache/index.ts @@ -0,0 +1,18 @@ +export type { + AnnouncementRow, + MintAggregateRow, + MintInfoRow, + ProfileRow, + RelayListRow, + ReviewRow, +} from "./schema"; +export { BitcoinmintsDB } from "./schema"; +export type { UpsertResult } from "./upsert"; +export { + upsertAnnouncement, + upsertMintAggregate, + upsertMintInfo, + upsertProfile, + upsertRelayList, + upsertReview, +} from "./upsert"; diff --git a/packages/core/src/cache/schema.test.ts b/packages/core/src/cache/schema.test.ts new file mode 100644 index 0000000..b583115 --- /dev/null +++ b/packages/core/src/cache/schema.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { BitcoinmintsDB } from "./schema"; + +/** + * Each test creates a fresh DB name — fake-indexeddb holds state on + * globalThis and name collisions would leak data between tests. + */ +const freshName = () => `test-schema-${Math.random().toString(36).slice(2)}`; + +const toDispose: BitcoinmintsDB[] = []; + +afterEach(async () => { + while (toDispose.length > 0) { + const db = toDispose.pop(); + if (!db) continue; + db.close(); + await BitcoinmintsDB.delete(db.name); + } +}); + +describe("BitcoinmintsDB schema", () => { + it("opens at version 1 with all 6 tables present", async () => { + const db = new BitcoinmintsDB(freshName()); + toDispose.push(db); + await db.open(); + + expect(db.verno).toBe(1); + const names = db.tables.map((t) => t.name).sort(); + expect(names).toEqual( + ["announcements", "mintAggregate", "mintInfo", "profiles", "relayLists", "reviews"].sort(), + ); + }); + + it("declares the expected primary keys per table", async () => { + const db = new BitcoinmintsDB(freshName()); + toDispose.push(db); + await db.open(); + + const pkKey = (name: string) => db.table(name).schema.primKey.keyPath; + + // Compound PK on replaceable event tables. + expect(pkKey("announcements")).toEqual(["pubkey", "kind", "d"]); + expect(pkKey("reviews")).toEqual(["pubkey", "kind", "d"]); + + // Scalar PK on the rest. + expect(pkKey("profiles")).toBe("pubkey"); + expect(pkKey("relayLists")).toBe("pubkey"); + expect(pkKey("mintInfo")).toBe("d"); + expect(pkKey("mintAggregate")).toBe("d"); + }); + + it("declares the secondary indexes that readers rely on", async () => { + const db = new BitcoinmintsDB(freshName()); + toDispose.push(db); + await db.open(); + + const indexNames = (name: string) => + db + .table(name) + .schema.indexes.map((ix) => ix.name) + .sort(); + + // announcements secondary indexes: eventId, kind, d, createdAt + expect(indexNames("announcements")).toEqual(["createdAt", "d", "eventId", "kind"]); + // reviews secondary indexes: eventId, d, createdAt, k + expect(indexNames("reviews")).toEqual(["createdAt", "d", "eventId", "k"]); + // mintInfo secondary: fetchedAt, ok + expect(indexNames("mintInfo")).toEqual(["fetchedAt", "ok"]); + // mintAggregate secondary: bayesianRank, updatedAt + expect(indexNames("mintAggregate")).toEqual(["bayesianRank", "updatedAt"]); + }); + + it("starts empty", async () => { + const db = new BitcoinmintsDB(freshName()); + toDispose.push(db); + await db.open(); + + expect(await db.announcements.count()).toBe(0); + expect(await db.reviews.count()).toBe(0); + expect(await db.profiles.count()).toBe(0); + expect(await db.relayLists.count()).toBe(0); + expect(await db.mintInfo.count()).toBe(0); + expect(await db.mintAggregate.count()).toBe(0); + }); +}); diff --git a/packages/core/src/cache/schema.ts b/packages/core/src/cache/schema.ts new file mode 100644 index 0000000..da65eb3 --- /dev/null +++ b/packages/core/src/cache/schema.ts @@ -0,0 +1,146 @@ +/** + * Dexie (IndexedDB) schema for the bitcoinmints cache. + * + * Tables map 1:1 to the parsing and enrichment layers: + * - announcements — parsed kind:38172 / kind:38173 events + * - reviews — parsed kind:38000 events + * - profiles — kind:0 JSON-content metadata + * - relayLists — kind:10002 NIP-65 relay lists + * - mintInfo — /v1/info responses (PR #4 populates this) + * - mintAggregate — ranking aggregates (PR #5 populates this) + * + * Replaceable-event uniqueness is encoded via Dexie compound primary keys + * `[pubkey+kind+d]` on announcements and reviews so the DB enforces the + * NIP-01 parameterized-replaceable invariant: one (signer, kind, d-tag) + * triple == one row. + * + * The row shapes here are derived from but DISTINCT from the parse-layer + * types (./nip87/types.ts). The cache has to hold forms the parser + * doesn't, e.g. un-parsed raw kind:0 content, /v1/info fetch metadata, + * and liveness state. Keeping these separate keeps the parse layer pure + * and lets the cache evolve independently. + */ +import Dexie, { type Table } from "dexie"; + +/** Parsed NIP-87 mint announcement row. */ +export type AnnouncementRow = { + // Composite primary key: [pubkey+kind+d] uniqueness for replaceable semantics. + pubkey: string; + kind: 38172 | 38173; + /** Mint hex pubkey (Cashu, 64- or 66-char) or federation id (Fedimint). */ + d: string; + eventId: string; + createdAt: number; + /** Canonical mint URL(s) for Cashu, or invite codes for Fedimint. */ + u: string[]; + /** Cashu only — parsed from comma-joined `nuts` tag. */ + nuts?: number[]; + /** Fedimint only — parsed from comma-joined `modules` tag. */ + modules?: string[]; + /** Optional Bitcoin network tag (`n`). */ + n?: string; + /** Original event content. */ + content: string; + /** Original event tags (preserved verbatim for downstream rehydration). */ + rawTags: string[][]; + /** + * Signer-binding verification (Layer B). Populated by PR #4. `null` + * while unverified; `true` or `false` once /v1/info has been + * reconciled against the signer pubkey. + */ + verifiedBySignerBinding: boolean | null; +}; + +/** Parsed NIP-87 mint recommendation / review row. */ +export type ReviewRow = { + pubkey: string; + kind: 38000; + /** Target mint identifier (matches an announcement's d-tag). */ + d: string; + eventId: string; + createdAt: number; + /** Pointer-kind tag: 38172 (Cashu) or 38173 (Fedimint). */ + k?: number; + /** Parsed 0..5 rating. */ + rating?: number; + /** Freeform review text. */ + content: string; + rawTags: string[][]; +}; + +/** NIP-01 kind:0 profile metadata row. */ +export type ProfileRow = { + pubkey: string; + eventId: string; + createdAt: number; + name?: string; + displayName?: string; + picture?: string; + about?: string; + nip05?: string; + /** Original JSON string — preserved for re-parsing if the shape evolves. */ + rawContent: string; +}; + +/** NIP-65 kind:10002 relay-list row. */ +export type RelayListRow = { + pubkey: string; + eventId: string; + createdAt: number; + relays: Array<{ url: string; read: boolean; write: boolean }>; +}; + +/** + * /v1/info response row (populated in PR #4 — empty in PR #3). + * Keyed by the announcement `d` the info corresponds to. + */ +export type MintInfoRow = { + /** Matches the announcement d-tag. */ + d: string; + /** Canonical /v1/info URL chosen for this mint. */ + url: string; + /** Epoch-ms timestamp of the fetch (used for CAS). */ + fetchedAt: number; + /** Raw /v1/info response. */ + infoJson: Record; + /** HTTP ETag, if the server returns one. */ + etag?: string; + /** Whether the last fetch succeeded. */ + ok: boolean; + /** Last error message, if ok === false. */ + lastError?: string; +}; + +/** Aggregated per-mint ranking row (populated in PR #5 — empty in PR #3). */ +export type MintAggregateRow = { + d: string; + reviewCount: number; + averageRating?: number; + bayesianRank?: number; + /** Epoch-ms timestamp of the aggregate recompute (used for CAS). */ + updatedAt: number; +}; + +/** Dexie handle for the bitcoinmints IndexedDB database. */ +export class BitcoinmintsDB extends Dexie { + announcements!: Table; + reviews!: Table; + profiles!: Table; + relayLists!: Table; + mintInfo!: Table; + mintAggregate!: Table; + + constructor(name = "bitcoinmints") { + super(name); + // Dexie compound primary-key syntax: "[a+b+c]" — first entry is the PK, + // remaining entries are secondary indexes for efficient range queries. + this.version(1).stores({ + announcements: "[pubkey+kind+d], eventId, kind, d, createdAt", + reviews: "[pubkey+kind+d], eventId, d, createdAt, k", + profiles: "pubkey, createdAt", + relayLists: "pubkey, createdAt", + mintInfo: "d, fetchedAt, ok", + mintAggregate: "d, bayesianRank, updatedAt", + }); + } +} diff --git a/packages/core/src/cache/upsert.test.ts b/packages/core/src/cache/upsert.test.ts new file mode 100644 index 0000000..07be5fd --- /dev/null +++ b/packages/core/src/cache/upsert.test.ts @@ -0,0 +1,605 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { + AnnouncementRow, + MintAggregateRow, + MintInfoRow, + ProfileRow, + RelayListRow, + ReviewRow, +} from "./schema"; +import { BitcoinmintsDB } from "./schema"; +import { + upsertAnnouncement, + upsertMintAggregate, + upsertMintInfo, + upsertProfile, + upsertRelayList, + upsertReview, +} from "./upsert"; + +const freshName = () => `test-upsert-${Math.random().toString(36).slice(2)}`; + +const toDispose: BitcoinmintsDB[] = []; + +afterEach(async () => { + while (toDispose.length > 0) { + const db = toDispose.pop(); + if (!db) continue; + db.close(); + await BitcoinmintsDB.delete(db.name); + } +}); + +async function freshDB(): Promise { + const db = new BitcoinmintsDB(freshName()); + toDispose.push(db); + await db.open(); + return db; +} + +/** Realistic 64-char x-only Cashu d-tag (de-facto form — Nostrodomo). */ +const D_XONLY = "5fe928ae0970844f3c5253d2e85a88788486edcbd96c070334a4a2d0d0154a77"; +/** Realistic 66-char compressed Cashu d-tag. */ +const D_COMPRESSED = `02${"a".repeat(64)}`; +/** Event id space — 64-char hex strings; lexicographic compare on lowercase hex. */ +const EID_LOW = `${"0".repeat(60)}aaaa`; +const EID_HIGH = `${"0".repeat(60)}ffff`; + +function makeAnnouncement(over: Partial = {}): AnnouncementRow { + return { + pubkey: `pk-${"0".repeat(60)}1111`, + kind: 38172, + d: D_XONLY, + eventId: EID_LOW, + createdAt: 1_700_000_000, + u: ["https://mint.example"], + nuts: [1, 2, 3], + content: "", + rawTags: [], + verifiedBySignerBinding: null, + ...over, + }; +} + +function makeReview(over: Partial = {}): ReviewRow { + return { + pubkey: `pk-${"0".repeat(60)}2222`, + kind: 38000, + d: D_XONLY, + eventId: EID_LOW, + createdAt: 1_700_000_000, + k: 38172, + rating: 5, + content: "[5/5] good mint", + rawTags: [], + ...over, + }; +} + +function makeProfile(over: Partial = {}): ProfileRow { + return { + pubkey: `pk-${"0".repeat(60)}3333`, + eventId: EID_LOW, + createdAt: 1_700_000_000, + name: "alice", + rawContent: '{"name":"alice"}', + ...over, + }; +} + +function makeRelayList(over: Partial = {}): RelayListRow { + return { + pubkey: `pk-${"0".repeat(60)}4444`, + eventId: EID_LOW, + createdAt: 1_700_000_000, + relays: [{ url: "wss://relay.example", read: true, write: true }], + ...over, + }; +} + +function makeMintInfo(over: Partial = {}): MintInfoRow { + return { + d: D_XONLY, + url: "https://mint.example/v1/info", + fetchedAt: 1_700_000_000, + infoJson: { name: "Example Mint" }, + ok: true, + ...over, + }; +} + +function makeMintAggregate(over: Partial = {}): MintAggregateRow { + return { + d: D_XONLY, + reviewCount: 5, + averageRating: 4.2, + bayesianRank: 3.8, + updatedAt: 1_700_000_000, + ...over, + }; +} + +describe("upsertAnnouncement", () => { + it("inserts a brand-new row", async () => { + const db = await freshDB(); + const row = makeAnnouncement(); + + const result = await upsertAnnouncement(db, row); + expect(result).toBe("inserted"); + + const fetched = await db.announcements.get([row.pubkey, row.kind, row.d]); + expect(fetched).toEqual(row); + expect(await db.announcements.count()).toBe(1); + }); + + it("replaces on newer createdAt", async () => { + const db = await freshDB(); + const older = makeAnnouncement({ createdAt: 1000, content: "older" }); + const newer = makeAnnouncement({ createdAt: 2000, content: "newer" }); + + expect(await upsertAnnouncement(db, older)).toBe("inserted"); + expect(await upsertAnnouncement(db, newer)).toBe("replaced"); + + const fetched = await db.announcements.get([newer.pubkey, newer.kind, newer.d]); + expect(fetched?.content).toBe("newer"); + expect(await db.announcements.count()).toBe(1); + }); + + it("rejects as stale on older createdAt", async () => { + const db = await freshDB(); + const newer = makeAnnouncement({ createdAt: 2000, content: "newer" }); + const older = makeAnnouncement({ createdAt: 1000, content: "older" }); + + expect(await upsertAnnouncement(db, newer)).toBe("inserted"); + expect(await upsertAnnouncement(db, older)).toBe("rejected-stale"); + + const fetched = await db.announcements.get([newer.pubkey, newer.kind, newer.d]); + expect(fetched?.content).toBe("newer"); + }); + + it("replaces when createdAt ties and eventId is higher lexicographically", async () => { + const db = await freshDB(); + const loEid = makeAnnouncement({ eventId: EID_LOW, content: "lo" }); + const hiEid = makeAnnouncement({ eventId: EID_HIGH, content: "hi" }); + + expect(await upsertAnnouncement(db, loEid)).toBe("inserted"); + expect(await upsertAnnouncement(db, hiEid)).toBe("replaced"); + + const fetched = await db.announcements.get([loEid.pubkey, loEid.kind, loEid.d]); + expect(fetched?.eventId).toBe(EID_HIGH); + expect(fetched?.content).toBe("hi"); + }); + + it("rejects as stale when createdAt ties and eventId is lower lexicographically", async () => { + const db = await freshDB(); + const hiEid = makeAnnouncement({ eventId: EID_HIGH, content: "hi" }); + const loEid = makeAnnouncement({ eventId: EID_LOW, content: "lo" }); + + expect(await upsertAnnouncement(db, hiEid)).toBe("inserted"); + expect(await upsertAnnouncement(db, loEid)).toBe("rejected-stale"); + + const fetched = await db.announcements.get([hiEid.pubkey, hiEid.kind, hiEid.d]); + expect(fetched?.eventId).toBe(EID_HIGH); + }); + + it("rejects as invalid when kind:38172 has a 16-char bot-spam d-tag", async () => { + const db = await freshDB(); + const bot = makeAnnouncement({ d: "abc123def4567890" }); + + const result = await upsertAnnouncement(db, bot); + expect(result).toBe("rejected-invalid"); + expect(await db.announcements.count()).toBe(0); + }); + + it("inserts kind:38172 with a valid 64-char x-only d-tag (Path 1 relaxation)", async () => { + const db = await freshDB(); + const row = makeAnnouncement({ d: D_XONLY }); + + expect(await upsertAnnouncement(db, row)).toBe("inserted"); + const fetched = await db.announcements.get([row.pubkey, row.kind, row.d]); + expect(fetched?.d).toBe(D_XONLY); + expect(fetched?.d.length).toBe(64); + }); + + it("inserts kind:38172 with a valid 66-char compressed d-tag", async () => { + const db = await freshDB(); + const row = makeAnnouncement({ d: D_COMPRESSED }); + + expect(await upsertAnnouncement(db, row)).toBe("inserted"); + const fetched = await db.announcements.get([row.pubkey, row.kind, row.d]); + expect(fetched?.d.length).toBe(66); + expect(fetched?.d).toBe(D_COMPRESSED); + }); + + it("inserts kind:38173 (Fedimint) bypassing Layer A — federation IDs are shaped differently", async () => { + const db = await freshDB(); + // Fedimint federation id — 64 hex but semantically not a Cashu pubkey. + const fediId = "718e421be177486639330d198e870b7345ebd07b2866b5fd3797d73e4bc4c9af"; + const row = makeAnnouncement({ + kind: 38173, + d: fediId, + nuts: undefined, + modules: ["ln", "mint", "wallet"], + }); + + expect(await upsertAnnouncement(db, row)).toBe("inserted"); + const fetched = await db.announcements.get([row.pubkey, 38173, fediId]); + expect(fetched?.kind).toBe(38173); + expect(fetched?.modules).toEqual(["ln", "mint", "wallet"]); + }); + + it("keeps separate rows for different pubkeys announcing the same (kind, d)", async () => { + const db = await freshDB(); + const aliceRow = makeAnnouncement({ pubkey: "alice", content: "alice's view" }); + const bobRow = makeAnnouncement({ pubkey: "bob", content: "bob's view" }); + + expect(await upsertAnnouncement(db, aliceRow)).toBe("inserted"); + expect(await upsertAnnouncement(db, bobRow)).toBe("inserted"); + + expect(await db.announcements.count()).toBe(2); + const alice = await db.announcements.get(["alice", aliceRow.kind, aliceRow.d]); + const bob = await db.announcements.get(["bob", bobRow.kind, bobRow.d]); + expect(alice?.content).toBe("alice's view"); + expect(bob?.content).toBe("bob's view"); + }); + + it("keeps separate rows for same pubkey + kind but different d-tags", async () => { + const db = await freshDB(); + const rowA = makeAnnouncement({ d: D_XONLY }); + const rowB = makeAnnouncement({ d: D_COMPRESSED }); + + expect(await upsertAnnouncement(db, rowA)).toBe("inserted"); + expect(await upsertAnnouncement(db, rowB)).toBe("inserted"); + + expect(await db.announcements.count()).toBe(2); + }); + + it("replaces when same pubkey, same kind, SAME d and the newcomer wins", async () => { + const db = await freshDB(); + const original = makeAnnouncement({ d: D_XONLY, createdAt: 1000, content: "original" }); + const update = makeAnnouncement({ d: D_XONLY, createdAt: 2000, content: "update" }); + + expect(await upsertAnnouncement(db, original)).toBe("inserted"); + expect(await upsertAnnouncement(db, update)).toBe("replaced"); + + expect(await db.announcements.count()).toBe(1); + const fetched = await db.announcements.get([original.pubkey, original.kind, D_XONLY]); + expect(fetched?.content).toBe("update"); + }); + + it("ingesting the same eventId 3x: 1 row, 1 inserted + 2 rejected-stale (no-op tiebreak)", async () => { + const db = await freshDB(); + const row = makeAnnouncement({ d: D_XONLY, eventId: EID_LOW, createdAt: 1000 }); + + const r1 = await upsertAnnouncement(db, row); + const r2 = await upsertAnnouncement(db, row); + const r3 = await upsertAnnouncement(db, row); + + // First wins, subsequent dupes lose tiebreak (next.eventId > prev.eventId is false on equal). + expect(r1).toBe("inserted"); + expect(r2).toBe("rejected-stale"); + expect(r3).toBe("rejected-stale"); + expect(await db.announcements.count()).toBe(1); + const fetched = await db.announcements.get([row.pubkey, row.kind, D_XONLY]); + expect(fetched?.eventId).toBe(EID_LOW); + }); + + it("concurrent upserts of the same [pubkey,kind,d] always converge to the higher createdAt — shuffled order, 5 trials", async () => { + // Two distinct events for the same parameterized-replaceable key, with + // different createdAt. The transaction guarantees that whichever lands + // second still sees the first's row and applies CAS correctly — there's + // no "interleaved garbage state" where the older row wins by virtue of + // arriving last. + const lower = makeAnnouncement({ + d: D_XONLY, + eventId: EID_LOW, + createdAt: 1000, + content: "lower", + }); + const higher = makeAnnouncement({ + d: D_XONLY, + eventId: EID_HIGH, + createdAt: 2000, + content: "higher", + }); + + for (let trial = 0; trial < 5; trial++) { + const db = await freshDB(); + const ops = + trial % 2 === 0 + ? [upsertAnnouncement(db, lower), upsertAnnouncement(db, higher)] + : [upsertAnnouncement(db, higher), upsertAnnouncement(db, lower)]; + const results = await Promise.all(ops); + + // Convergence: exactly one row, the higher-createdAt event always wins. + expect(await db.announcements.count()).toBe(1); + const fetched = await db.announcements.get([higher.pubkey, higher.kind, D_XONLY]); + expect(fetched?.content).toBe("higher"); + expect(fetched?.createdAt).toBe(2000); + expect(fetched?.eventId).toBe(EID_HIGH); + + // Result composition: one inserted, one replaced/rejected depending on + // which landed first inside the transaction queue. Either way, no + // "rejected-invalid" and no double-insert. + expect(results).toContain("inserted"); + const second = results.find((r) => r !== "inserted"); + expect(second === "replaced" || second === "rejected-stale").toBe(true); + } + }); + + it("preserves verifiedBySignerBinding across a CAS replace (Layer B isn't clobbered by a newer parser-emitted row)", async () => { + const db = await freshDB(); + const original = makeAnnouncement({ d: D_XONLY, createdAt: 1000, content: "original" }); + expect(await upsertAnnouncement(db, original)).toBe("inserted"); + + // Simulate PR #4's Layer B verifier flipping the bit out-of-band (direct + // db write — not via upsert). + await db.announcements.update([original.pubkey, original.kind, D_XONLY], { + verifiedBySignerBinding: true, + }); + const afterVerify = await db.announcements.get([original.pubkey, original.kind, D_XONLY]); + expect(afterVerify?.verifiedBySignerBinding).toBe(true); + + // Newer event arrives — parser doesn't know about Layer B, so it carries `null`. + const update = makeAnnouncement({ + d: D_XONLY, + createdAt: 2000, + content: "update", + verifiedBySignerBinding: null, + }); + expect(await upsertAnnouncement(db, update)).toBe("replaced"); + + const fetched = await db.announcements.get([original.pubkey, original.kind, D_XONLY]); + // Newer fields land... + expect(fetched?.content).toBe("update"); + expect(fetched?.createdAt).toBe(2000); + // ...but Layer B verification is preserved. + expect(fetched?.verifiedBySignerBinding).toBe(true); + }); +}); + +describe("upsertReview", () => { + it("inserts a brand-new review", async () => { + const db = await freshDB(); + const row = makeReview(); + + expect(await upsertReview(db, row)).toBe("inserted"); + expect(await db.reviews.count()).toBe(1); + }); + + it("replaces on newer createdAt", async () => { + const db = await freshDB(); + const older = makeReview({ createdAt: 1000, rating: 3 }); + const newer = makeReview({ createdAt: 2000, rating: 5 }); + + expect(await upsertReview(db, older)).toBe("inserted"); + expect(await upsertReview(db, newer)).toBe("replaced"); + + const fetched = await db.reviews.get([newer.pubkey, newer.kind, newer.d]); + expect(fetched?.rating).toBe(5); + }); + + it("rejects on older createdAt", async () => { + const db = await freshDB(); + const newer = makeReview({ createdAt: 2000, rating: 5 }); + const older = makeReview({ createdAt: 1000, rating: 3 }); + + expect(await upsertReview(db, newer)).toBe("inserted"); + expect(await upsertReview(db, older)).toBe("rejected-stale"); + + const fetched = await db.reviews.get([newer.pubkey, newer.kind, newer.d]); + expect(fetched?.rating).toBe(5); + }); + + it("tiebreak: higher eventId replaces on createdAt tie", async () => { + const db = await freshDB(); + const loEid = makeReview({ eventId: EID_LOW, rating: 1 }); + const hiEid = makeReview({ eventId: EID_HIGH, rating: 5 }); + + expect(await upsertReview(db, loEid)).toBe("inserted"); + expect(await upsertReview(db, hiEid)).toBe("replaced"); + + const fetched = await db.reviews.get([loEid.pubkey, loEid.kind, loEid.d]); + expect(fetched?.rating).toBe(5); + }); + + it("tiebreak: lower eventId is rejected as stale on createdAt tie", async () => { + const db = await freshDB(); + const hiEid = makeReview({ eventId: EID_HIGH, rating: 5 }); + const loEid = makeReview({ eventId: EID_LOW, rating: 1 }); + + expect(await upsertReview(db, hiEid)).toBe("inserted"); + expect(await upsertReview(db, loEid)).toBe("rejected-stale"); + + const fetched = await db.reviews.get([hiEid.pubkey, hiEid.kind, hiEid.d]); + expect(fetched?.rating).toBe(5); + }); + + it("keeps separate rows when the same reviewer reviews different mints", async () => { + const db = await freshDB(); + const rowA = makeReview({ d: D_XONLY, content: "mint A review" }); + const rowB = makeReview({ d: D_COMPRESSED, content: "mint B review" }); + + expect(await upsertReview(db, rowA)).toBe("inserted"); + expect(await upsertReview(db, rowB)).toBe("inserted"); + + expect(await db.reviews.count()).toBe(2); + }); + + it("keeps separate rows when different reviewers review the same mint", async () => { + const db = await freshDB(); + const alice = makeReview({ pubkey: "alice", content: "alice says" }); + const bob = makeReview({ pubkey: "bob", content: "bob says" }); + + expect(await upsertReview(db, alice)).toBe("inserted"); + expect(await upsertReview(db, bob)).toBe("inserted"); + + expect(await db.reviews.count()).toBe(2); + }); +}); + +describe("upsertProfile", () => { + it("inserts, replaces on newer, rejects older", async () => { + const db = await freshDB(); + const row1 = makeProfile({ createdAt: 1000, name: "alice-v1" }); + const row2 = makeProfile({ createdAt: 2000, name: "alice-v2" }); + const row3 = makeProfile({ createdAt: 500, name: "alice-ancient" }); + + expect(await upsertProfile(db, row1)).toBe("inserted"); + expect(await upsertProfile(db, row2)).toBe("replaced"); + expect(await upsertProfile(db, row3)).toBe("rejected-stale"); + + const fetched = await db.profiles.get(row1.pubkey); + expect(fetched?.name).toBe("alice-v2"); + }); + + it("tiebreak on eventId when createdAt ties", async () => { + const db = await freshDB(); + const lo = makeProfile({ eventId: EID_LOW, name: "lo" }); + const hi = makeProfile({ eventId: EID_HIGH, name: "hi" }); + + expect(await upsertProfile(db, lo)).toBe("inserted"); + expect(await upsertProfile(db, hi)).toBe("replaced"); + + const backToLo = makeProfile({ eventId: EID_LOW, name: "back-to-lo" }); + expect(await upsertProfile(db, backToLo)).toBe("rejected-stale"); + + const fetched = await db.profiles.get(lo.pubkey); + expect(fetched?.name).toBe("hi"); + }); + + it("keeps one row per pubkey; different pubkeys are independent", async () => { + const db = await freshDB(); + const alice = makeProfile({ pubkey: "alice" }); + const bob = makeProfile({ pubkey: "bob" }); + + expect(await upsertProfile(db, alice)).toBe("inserted"); + expect(await upsertProfile(db, bob)).toBe("inserted"); + expect(await db.profiles.count()).toBe(2); + }); +}); + +describe("upsertRelayList", () => { + it("inserts, replaces on newer, rejects older", async () => { + const db = await freshDB(); + const row1 = makeRelayList({ createdAt: 1000 }); + const row2 = makeRelayList({ + createdAt: 2000, + relays: [{ url: "wss://r2.example", read: true, write: false }], + }); + const row3 = makeRelayList({ createdAt: 500 }); + + expect(await upsertRelayList(db, row1)).toBe("inserted"); + expect(await upsertRelayList(db, row2)).toBe("replaced"); + expect(await upsertRelayList(db, row3)).toBe("rejected-stale"); + + const fetched = await db.relayLists.get(row1.pubkey); + expect(fetched?.relays[0]?.url).toBe("wss://r2.example"); + expect(fetched?.relays[0]?.write).toBe(false); + }); + + it("tiebreak on eventId when createdAt ties", async () => { + const db = await freshDB(); + const lo = makeRelayList({ eventId: EID_LOW }); + const hi = makeRelayList({ + eventId: EID_HIGH, + relays: [{ url: "wss://hi.example", read: true, write: true }], + }); + + expect(await upsertRelayList(db, lo)).toBe("inserted"); + expect(await upsertRelayList(db, hi)).toBe("replaced"); + + const fetched = await db.relayLists.get(lo.pubkey); + expect(fetched?.eventId).toBe(EID_HIGH); + expect(fetched?.relays[0]?.url).toBe("wss://hi.example"); + }); +}); + +describe("upsertMintInfo", () => { + it("inserts a brand-new mint-info row", async () => { + const db = await freshDB(); + const row = makeMintInfo(); + expect(await upsertMintInfo(db, row)).toBe("inserted"); + expect(await db.mintInfo.count()).toBe(1); + }); + + it("replaces on newer fetchedAt", async () => { + const db = await freshDB(); + const older = makeMintInfo({ fetchedAt: 1000, infoJson: { v: 1 } }); + const newer = makeMintInfo({ fetchedAt: 2000, infoJson: { v: 2 } }); + + expect(await upsertMintInfo(db, older)).toBe("inserted"); + expect(await upsertMintInfo(db, newer)).toBe("replaced"); + + const fetched = await db.mintInfo.get(older.d); + expect(fetched?.infoJson).toEqual({ v: 2 }); + expect(fetched?.fetchedAt).toBe(2000); + }); + + it("rejects on older fetchedAt", async () => { + const db = await freshDB(); + const newer = makeMintInfo({ fetchedAt: 2000, infoJson: { v: 2 } }); + const older = makeMintInfo({ fetchedAt: 1000, infoJson: { v: 1 } }); + + expect(await upsertMintInfo(db, newer)).toBe("inserted"); + expect(await upsertMintInfo(db, older)).toBe("rejected-stale"); + + const fetched = await db.mintInfo.get(newer.d); + expect(fetched?.infoJson).toEqual({ v: 2 }); + }); + + it("treats equal fetchedAt as stale (no churn)", async () => { + const db = await freshDB(); + const a = makeMintInfo({ fetchedAt: 1000, infoJson: { v: "a" } }); + const b = makeMintInfo({ fetchedAt: 1000, infoJson: { v: "b" } }); + + expect(await upsertMintInfo(db, a)).toBe("inserted"); + expect(await upsertMintInfo(db, b)).toBe("rejected-stale"); + + const fetched = await db.mintInfo.get(a.d); + expect(fetched?.infoJson).toEqual({ v: "a" }); + }); + + it("ok=false overwrites a prior ok=true on a newer fetch — `ok` is NOT part of the CAS predicate", async () => { + // Design choice: mintInfo CAS is monotonic on fetchedAt only. The + // freshness signal wins regardless of the success bit so the cache + // accurately reflects the latest /v1/info attempt — including outages. + const db = await freshDB(); + const ok = makeMintInfo({ + fetchedAt: 1000, + ok: true, + infoJson: { name: "live mint" }, + }); + const failed = makeMintInfo({ + fetchedAt: 2000, + ok: false, + infoJson: {}, + lastError: "ECONNREFUSED", + }); + + expect(await upsertMintInfo(db, ok)).toBe("inserted"); + expect(await upsertMintInfo(db, failed)).toBe("replaced"); + + const fetched = await db.mintInfo.get(ok.d); + expect(fetched?.ok).toBe(false); + expect(fetched?.lastError).toBe("ECONNREFUSED"); + expect(fetched?.fetchedAt).toBe(2000); + }); +}); + +describe("upsertMintAggregate", () => { + it("inserts, replaces on newer updatedAt, rejects older", async () => { + const db = await freshDB(); + const older = makeMintAggregate({ updatedAt: 1000, bayesianRank: 1.0 }); + const newer = makeMintAggregate({ updatedAt: 2000, bayesianRank: 4.5 }); + const ancient = makeMintAggregate({ updatedAt: 500 }); + + expect(await upsertMintAggregate(db, older)).toBe("inserted"); + expect(await upsertMintAggregate(db, newer)).toBe("replaced"); + expect(await upsertMintAggregate(db, ancient)).toBe("rejected-stale"); + + const fetched = await db.mintAggregate.get(older.d); + expect(fetched?.bayesianRank).toBe(4.5); + expect(fetched?.updatedAt).toBe(2000); + }); +}); diff --git a/packages/core/src/cache/upsert.ts b/packages/core/src/cache/upsert.ts new file mode 100644 index 0000000..b6c4025 --- /dev/null +++ b/packages/core/src/cache/upsert.ts @@ -0,0 +1,175 @@ +/** + * Compare-and-swap (CAS) upsert helpers for the bitcoinmints cache. + * + * Every upsert runs inside a Dexie read-write transaction so the + * read-compare-write cycle is atomic. Without that, concurrent ingest + * from multiple relays can race and leave older events overwriting newer + * ones. + * + * Replaceable-event ordering rules (per NIP-01 §7.3 / NIP-33): + * 1. Higher `createdAt` wins. + * 2. Tiebreak — when `createdAt` is equal, the event with the higher + * `eventId` (string compare on lowercase hex) wins. This is the + * standard replaceable-event tiebreak clients converge on. + * + * Layer A gate for kind:38172: before writing an announcement we check + * isValidCashuDTag(d). Invalid shapes (bot spam, non-hex garbage) are + * returned as "rejected-invalid" and never hit the DB. kind:38173 + * (Fedimint) bypasses Layer A — federation IDs have a different shape + * and their validator is a TODO-v1.1 concern. + * + * mintInfo and mintAggregate aren't event-based, so their CAS predicate + * is a monotonically-increasing timestamp: `fetchedAt` for mintInfo, + * `updatedAt` for mintAggregate. + */ +import { isValidCashuDTag } from "../nip87/dtag"; +import type { + AnnouncementRow, + BitcoinmintsDB, + MintAggregateRow, + MintInfoRow, + ProfileRow, + RelayListRow, + ReviewRow, +} from "./schema"; + +/** + * Outcome of an upsert attempt. + * - "inserted" — no prior row; the new row was written. + * - "replaced" — a prior row existed and was overwritten. + * - "rejected-stale" — a prior row existed and was kept (newer or tiebreak-winning). + * - "rejected-invalid" — the row failed a pre-write validator (e.g. Layer A d-tag shape). + */ +export type UpsertResult = "inserted" | "replaced" | "rejected-stale" | "rejected-invalid"; + +/** + * Decide whether `next` supersedes `prev` by NIP-01 replaceable-event rules. + * Returns true iff `next` should overwrite `prev`. + */ +function nextWins( + prev: { createdAt: number; eventId: string }, + next: { createdAt: number; eventId: string }, +): boolean { + if (next.createdAt > prev.createdAt) return true; + if (next.createdAt < prev.createdAt) return false; + // Tiebreak: lexicographically higher eventId wins. + return next.eventId > prev.eventId; +} + +/** Upsert a kind:38172 or kind:38173 announcement with Layer A gating on 38172. */ +export async function upsertAnnouncement( + db: BitcoinmintsDB, + row: AnnouncementRow, +): Promise { + // Layer A gate — reject invalid Cashu d-tag shapes before touching the DB. + // Fedimint (38173) bypasses: federation-id shape is TODO-v1.1. + if (row.kind === 38172 && !isValidCashuDTag(row.d)) { + return "rejected-invalid"; + } + + return db.transaction("rw", db.announcements, async () => { + const prev = await db.announcements.get([row.pubkey, row.kind, row.d]); + if (!prev) { + await db.announcements.put(row); + return "inserted"; + } + if (nextWins(prev, row)) { + // Preserve Layer B verification across CAS replace. The parser doesn't + // know about /v1/info reconciliation, so an incoming row always carries + // verifiedBySignerBinding: null. Without this merge, a newer event would + // clobber a prior `true`/`false` set by PR #4's verifier. + await db.announcements.put({ + ...row, + verifiedBySignerBinding: prev.verifiedBySignerBinding ?? row.verifiedBySignerBinding, + }); + return "replaced"; + } + return "rejected-stale"; + }); +} + +/** Upsert a kind:38000 review. No Layer A gate — the `d` here points at a mint but isn't itself a Cashu pubkey owned by the reviewer. */ +export async function upsertReview(db: BitcoinmintsDB, row: ReviewRow): Promise { + return db.transaction("rw", db.reviews, async () => { + const prev = await db.reviews.get([row.pubkey, row.kind, row.d]); + if (!prev) { + await db.reviews.put(row); + return "inserted"; + } + if (nextWins(prev, row)) { + await db.reviews.put(row); + return "replaced"; + } + return "rejected-stale"; + }); +} + +/** Upsert a kind:0 profile. Keyed by pubkey (NIP-01 replaceable). */ +export async function upsertProfile(db: BitcoinmintsDB, row: ProfileRow): Promise { + return db.transaction("rw", db.profiles, async () => { + const prev = await db.profiles.get(row.pubkey); + if (!prev) { + await db.profiles.put(row); + return "inserted"; + } + if (nextWins(prev, row)) { + await db.profiles.put(row); + return "replaced"; + } + return "rejected-stale"; + }); +} + +/** Upsert a kind:10002 relay list. Keyed by pubkey (NIP-01 replaceable). */ +export async function upsertRelayList( + db: BitcoinmintsDB, + row: RelayListRow, +): Promise { + return db.transaction("rw", db.relayLists, async () => { + const prev = await db.relayLists.get(row.pubkey); + if (!prev) { + await db.relayLists.put(row); + return "inserted"; + } + if (nextWins(prev, row)) { + await db.relayLists.put(row); + return "replaced"; + } + return "rejected-stale"; + }); +} + +/** Upsert a /v1/info row. CAS predicate: higher `fetchedAt` wins. */ +export async function upsertMintInfo(db: BitcoinmintsDB, row: MintInfoRow): Promise { + return db.transaction("rw", db.mintInfo, async () => { + const prev = await db.mintInfo.get(row.d); + if (!prev) { + await db.mintInfo.put(row); + return "inserted"; + } + if (row.fetchedAt > prev.fetchedAt) { + await db.mintInfo.put(row); + return "replaced"; + } + return "rejected-stale"; + }); +} + +/** Upsert an aggregate row. CAS predicate: higher `updatedAt` wins. */ +export async function upsertMintAggregate( + db: BitcoinmintsDB, + row: MintAggregateRow, +): Promise { + return db.transaction("rw", db.mintAggregate, async () => { + const prev = await db.mintAggregate.get(row.d); + if (!prev) { + await db.mintAggregate.put(row); + return "inserted"; + } + if (row.updatedAt > prev.updatedAt) { + await db.mintAggregate.put(row); + return "replaced"; + } + return "rejected-stale"; + }); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2e47a88..294af0f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1 +1,5 @@ +export * from "./cache"; +export * from "./nip87"; +export * from "./nostr"; + export const VERSION = "0.0.0"; diff --git a/packages/core/src/integration.test.ts b/packages/core/src/integration.test.ts new file mode 100644 index 0000000..653a3ec --- /dev/null +++ b/packages/core/src/integration.test.ts @@ -0,0 +1,303 @@ +/** + * End-to-end integration tests for the parse → cache pipeline. + * + * These pin the cross-cutting contracts the unit tests can't: that the + * curated NIP-87 corpus actually flows through parseMintAnnouncement / + * parseRecommendation into upsertAnnouncement / upsertReview the way the + * design says it should. + * + * fake-indexeddb is loaded in vitest.setup.ts. + */ +import type { Event as NostrEvent } from "nostr-tools/core"; +import { afterEach, describe, expect, it } from "vitest"; +import { + type AnnouncementRow, + BitcoinmintsDB, + type ReviewRow, + upsertAnnouncement, + upsertReview, +} from "./cache"; +import fixtures from "./nip87/__fixtures__/nip87-sample.json" with { type: "json" }; +import { isValidCashuDTag } from "./nip87/dtag"; +import { parseMintAnnouncement, parseRecommendation } from "./nip87/parse"; + +type Fixture = { + _meta: Record; + cashu38172BotSpam: NostrEvent[]; + cashu38172Legacy: NostrEvent[]; + cashu38172SpecConforming: NostrEvent[]; + fedimint38173: NostrEvent[]; + recommendations38000: NostrEvent[]; +}; +const f = fixtures as unknown as Fixture; + +const freshName = () => `test-integration-${Math.random().toString(36).slice(2)}`; +const toDispose: BitcoinmintsDB[] = []; + +afterEach(async () => { + while (toDispose.length > 0) { + const db = toDispose.pop(); + if (!db) continue; + db.close(); + await BitcoinmintsDB.delete(db.name); + } +}); + +async function freshDB(): Promise { + const db = new BitcoinmintsDB(freshName()); + toDispose.push(db); + await db.open(); + return db; +} + +/** + * Convert a parsed MintAnnouncement to the cache row shape. The parser and + * cache types are deliberately separate (see schema.ts comment), so this + * adapter is what real ingest code will use. + */ +function toAnnouncementRow( + parsed: NonNullable>, +): AnnouncementRow { + return { + pubkey: parsed.pubkey, + kind: parsed.kind, + d: parsed.d, + eventId: parsed.eventId, + createdAt: parsed.createdAt, + u: parsed.u, + nuts: parsed.nuts, + modules: parsed.modules, + n: parsed.n, + content: parsed.raw.content, + rawTags: parsed.raw.tags, + verifiedBySignerBinding: null, + }; +} + +function toReviewRow(parsed: NonNullable>): ReviewRow { + return { + pubkey: parsed.pubkey, + kind: 38000, + d: parsed.d, + eventId: parsed.eventId, + createdAt: parsed.createdAt, + k: parsed.k, + rating: parsed.rating, + content: parsed.content, + rawTags: parsed.raw.tags, + }; +} + +/** + * Replay every event in the corpus through the parse → upsert pipeline and + * collect the per-event outcome for assertions. + */ +async function replayCorpus(db: BitcoinmintsDB) { + const all38172: NostrEvent[] = [ + ...f.cashu38172BotSpam, + ...f.cashu38172Legacy, + ...f.cashu38172SpecConforming, + ]; + const announcementResults: { event: NostrEvent; result: string | "parse-failed" }[] = []; + for (const e of [...all38172, ...f.fedimint38173]) { + const parsed = parseMintAnnouncement(e); + if (!parsed) { + announcementResults.push({ event: e, result: "parse-failed" }); + continue; + } + const result = await upsertAnnouncement(db, toAnnouncementRow(parsed)); + announcementResults.push({ event: e, result }); + } + + const reviewResults: { event: NostrEvent; result: string | "parse-failed" }[] = []; + for (const e of f.recommendations38000) { + const parsed = parseRecommendation(e); + if (!parsed) { + reviewResults.push({ event: e, result: "parse-failed" }); + continue; + } + const result = await upsertReview(db, toReviewRow(parsed)); + reviewResults.push({ event: e, result }); + } + + return { announcementResults, reviewResults }; +} + +describe("integration: corpus replay → parse → cache", () => { + it("replays all 16 corpus events and converges to the expected cache state", async () => { + const db = await freshDB(); + const { announcementResults, reviewResults } = await replayCorpus(db); + + // Sanity: every event in the corpus has a result entry. + expect(announcementResults.length).toBe( + f.cashu38172BotSpam.length + + f.cashu38172Legacy.length + + f.cashu38172SpecConforming.length + + f.fedimint38173.length, + ); + expect(reviewResults.length).toBe(f.recommendations38000.length); + + // No parse failures — the curated corpus is well-formed (every event + // has d + at least one u tag). + for (const r of announcementResults) expect(r.result).not.toBe("parse-failed"); + for (const r of reviewResults) expect(r.result).not.toBe("parse-failed"); + + // Per the fixture's _meta.notes: + // "Accepted: all cashu38172Legacy + cashu38172SpecConforming. + // Rejected: cashu38172BotSpam only." + // Plus all 3 Fedimint events bypass Layer A. + // Accepted = 1 (Legacy x-only) + 2 (SpecConforming compressed) + 3 (Fedimint) = 6 + // Rejected by Layer A = 5 (bot-spam only). + const acceptedCashuLayerA = f.cashu38172Legacy.length + f.cashu38172SpecConforming.length; + const acceptedFedimint = f.fedimint38173.length; + const expectedAccepted = acceptedCashuLayerA + acceptedFedimint; + const expectedRejected = f.cashu38172BotSpam.length; + expect(expectedAccepted).toBe(6); + expect(expectedRejected).toBe(5); + + // Cache state assertions. + expect(await db.announcements.count()).toBe(expectedAccepted); + + // Result-stream assertions: every accepted event lands as 'inserted' + // (each has unique [pubkey,kind,d]); every bot-spam lands as + // 'rejected-invalid' (Layer A gate). + const inserted = announcementResults.filter((r) => r.result === "inserted"); + const rejectedInvalid = announcementResults.filter((r) => r.result === "rejected-invalid"); + expect(inserted.length).toBe(expectedAccepted); + expect(rejectedInvalid.length).toBe(expectedRejected); + + // Reviews: all 5 recommendations parse and insert (each has unique + // [pubkey,38000,d]). + expect(await db.reviews.count()).toBe(f.recommendations38000.length); + expect(await db.reviews.count()).toBe(5); + const reviewsInserted = reviewResults.filter((r) => r.result === "inserted"); + expect(reviewsInserted.length).toBe(5); + }); + + it("the legacy Nostrodomo (64-char x-only) lands as inserted, not rejected-invalid", async () => { + // Spot-check Path 1 of the relaxed Layer A regex actually fires E2E. + const db = await freshDB(); + const legacy = f.cashu38172Legacy[0]; + expect(legacy).toBeDefined(); + if (!legacy) return; + const parsed = parseMintAnnouncement(legacy); + expect(parsed).not.toBeNull(); + if (!parsed) return; + expect(parsed.d.length).toBe(64); + expect(isValidCashuDTag(parsed.d)).toBe(true); + const result = await upsertAnnouncement(db, toAnnouncementRow(parsed)); + expect(result).toBe("inserted"); + }); +}); + +describe("integration: CAS convergence under simulated multi-relay race", () => { + it("multi-relay echo of the same event id: 1 row, 1 inserted + 2 rejected-stale, deterministic", async () => { + // Three relays publish the same canonical event. Same id, same key, + // same createdAt — the equal-eventId loses the tiebreak (next > prev is + // false), so re-broadcasts always end as 'rejected-stale'. No churn. + const legacy = f.cashu38172Legacy[0]; + expect(legacy).toBeDefined(); + if (!legacy) return; + const parsed = parseMintAnnouncement(legacy); + expect(parsed).not.toBeNull(); + if (!parsed) return; + const row = toAnnouncementRow(parsed); + + const db = await freshDB(); + const results = await Promise.all([ + upsertAnnouncement(db, row), + upsertAnnouncement(db, row), + upsertAnnouncement(db, row), + ]); + + expect(await db.announcements.count()).toBe(1); + const inserted = results.filter((r) => r === "inserted"); + const stale = results.filter((r) => r === "rejected-stale"); + expect(inserted.length).toBe(1); + expect(stale.length).toBe(2); + }); + + it("tiebreak under race: 3 events with same [pubkey,kind,d,createdAt] but different eventIds — highest eventId always wins, 10 shuffled trials", async () => { + // Real-world: same logical replaceable event published by the same + // signer at the same second but with different ids (e.g. retried after + // a sig collision, or re-emitted by a buggy client). The lex-highest + // eventId must win deterministically every time, regardless of arrival + // order. + const legacy = f.cashu38172Legacy[0]; + expect(legacy).toBeDefined(); + if (!legacy) return; + const parsed = parseMintAnnouncement(legacy); + expect(parsed).not.toBeNull(); + if (!parsed) return; + const baseRow = toAnnouncementRow(parsed); + const eidLow = `${"0".repeat(60)}1111`; + const eidMid = `${"0".repeat(60)}5555`; + const eidHigh = `${"0".repeat(60)}ffff`; + + for (let trial = 0; trial < 10; trial++) { + const db = await freshDB(); + const variants: AnnouncementRow[] = [ + { ...baseRow, eventId: eidLow, content: "lo" }, + { ...baseRow, eventId: eidMid, content: "mid" }, + { ...baseRow, eventId: eidHigh, content: "hi" }, + ]; + // Fisher-Yates shuffle — different arrival order each trial. + for (let i = variants.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const tmp = variants[i] as AnnouncementRow; + const swap = variants[j] as AnnouncementRow; + variants[i] = swap; + variants[j] = tmp; + } + await Promise.all(variants.map((v) => upsertAnnouncement(db, v))); + + expect(await db.announcements.count()).toBe(1); + const fetched = await db.announcements.get([baseRow.pubkey, baseRow.kind, baseRow.d]); + expect(fetched?.eventId).toBe(eidHigh); + expect(fetched?.content).toBe("hi"); + } + }); +}); + +describe("integration: Layer A enforced at cache, not parser", () => { + it("bot-spam events parse successfully but are rejected by upsertAnnouncement", async () => { + // Pin the design choice: parser is lenient, the cache is the gate. This + // matters because downstream code (e.g. raw-event log, debugger views) + // can still see what came over the wire even if it never lands. + const db = await freshDB(); + let parsedCount = 0; + let rejectedAtCacheCount = 0; + for (const e of f.cashu38172BotSpam) { + const parsed = parseMintAnnouncement(e); + // Parser does NOT gate on Layer A — every bot-spam event parses fine. + expect(parsed).not.toBeNull(); + if (!parsed) continue; + parsedCount++; + // Bot-spam d-tags are 16-char random — regex doesn't match. + expect(isValidCashuDTag(parsed.d)).toBe(false); + const result = await upsertAnnouncement(db, toAnnouncementRow(parsed)); + // The cache is where Layer A bites. + expect(result).toBe("rejected-invalid"); + rejectedAtCacheCount++; + } + expect(parsedCount).toBe(f.cashu38172BotSpam.length); + expect(rejectedAtCacheCount).toBe(f.cashu38172BotSpam.length); + // Nothing landed despite all 5 parsing successfully — design contract held. + expect(await db.announcements.count()).toBe(0); + }); + + it("the same Layer A check lets valid events through when the parser hands them off", async () => { + // Mirror of the above for the positive side — valid parses that land. + const db = await freshDB(); + const allValid: NostrEvent[] = [...f.cashu38172Legacy, ...f.cashu38172SpecConforming]; + for (const e of allValid) { + const parsed = parseMintAnnouncement(e); + expect(parsed).not.toBeNull(); + if (!parsed) continue; + expect(isValidCashuDTag(parsed.d)).toBe(true); + const result = await upsertAnnouncement(db, toAnnouncementRow(parsed)); + expect(result).toBe("inserted"); + } + expect(await db.announcements.count()).toBe(allValid.length); + }); +}); diff --git a/packages/core/src/nip87/__fixtures__/nip87-sample.json b/packages/core/src/nip87/__fixtures__/nip87-sample.json new file mode 100644 index 0000000..fbad80d --- /dev/null +++ b/packages/core/src/nip87/__fixtures__/nip87-sample.json @@ -0,0 +1,260 @@ +{ + "_meta": { + "description": "Curated NIP-87 corpus for @bitcoinmints/core parse tests.", + "source": "/srv/forge/projects/bitcoinmints/audit/relay-data/full-nip87.json + full-38000.json", + "snapshot": "2026-04-16", + "notes": [ + "Events are real, collected in the 2026-04-16 relay survey.", + "The 5 cashu38172BotSpam events share pubkey 972f233a... and use random 16-char d-tags — part of the 959-event 2025-02-13 burst documented in audit/relay-strategy-v1.md §4.", + "The 1 cashu38172Legacy event (Nostrodomo Mint) uses a 64-char x-only secp256k1 pubkey d-tag. The 'Legacy' bucket name is preserved for continuity, but empirically this IS the de-facto mainstream shape every real Cashu mint in the wild publishes — NOT a legacy minority form.", + "Layer A regex relaxed to accept 64-char x-only (empirical fix — no real mints use 66-char compressed in the wild). 16-char bot-spam d-tags still rejected. Accepted: all cashu38172Legacy + cashu38172SpecConforming. Rejected: cashu38172BotSpam only.", + "The 2 cashu38172SpecConforming events are SYNTHETIC: real mint URLs + contentMetadata, but the d-tag is rewritten to a valid 66-char compressed secp256k1 pubkey (derived by prefixing real mint pubkeys with 02/03). Their sig field is intentionally invalid (we do not re-sign) and the id does not match tag contents — parse layer does not verify signatures or event ids.", + "Fedimint events are real and filtered to ones that include at least one u tag (required by the parser). Layer A does not apply to kind:38173 — federation-id shape is TODO-v1.1.", + "Recommendations span three rating formats plus a no-rating case." + ] + }, + "cashu38172BotSpam": [ + { + "content": "{\"url\":\"https://mint.azzamo.net\",\"name\":\"Azzamo Cashu Mint\",\"description\":\"Unlock a new dimension of digital transactions with Azzamo cash Mint.\",\"version\":\"Nutshell/0.16.4\",\"nuts\":[\"NUT-07\",\"NUT-08\",\"NUT-09\"],\"motd\":\"Disclaimer: Azzamo Mint is in beta and experimental. Use small amounts only. Key Mantra: Not your keys = Not your coins.\",\"contact\":[[\"email\",\"support@azzamo.net\"],[\"twitter\",\"@me\"],[\"nostr\",\"npub...\"]]}", + "created_at": 1739410455, + "id": "005484e8d3beef38feb851b4005840d4532ce73f20f0761c8c85e06e3e0086c3", + "kind": 38172, + "pubkey": "972f233aa467bc9804032c0bce0a117daead5473c56c91e811a216bdd08c08cf", + "sig": "eb4c61474804acbac09a78be99ce7a80b225d60f2f2824ac37b2b67b1db98427a0baa5253f2500c2622ee7c988f545670b102d2ed90a46dbdf6bb5a69cb65a8f", + "tags": [ + ["u", "https://mint.azzamo.net"], + ["nuts", "0,1,2"], + ["updated_at", "1739410455"], + ["d", "6hca1u9u9a39iiii"] + ] + }, + { + "content": "{\"url\":\"https://mint.lnw.cash\",\"name\":\"lnwCash Mint\",\"description\":\"\\\"The ecash nutshell mint for freedom.\\\"\",\"version\":\"Nutshell/0.16.0\",\"nuts\":[\"NUT-07\",\"NUT-08\",\"NUT-09\"],\"contact\":[]}", + "created_at": 1739410543, + "id": "009b1ed07e26e97631ee845c23a36e5ea0e7c94e721abce43caaf54faa227782", + "kind": 38172, + "pubkey": "972f233aa467bc9804032c0bce0a117daead5473c56c91e811a216bdd08c08cf", + "sig": "f8c78242bc2bbfece8a281470e0c91421ca3da9e0dcd95ee0baa704d70cc4f13aef00fdf5dd14ee813bbe0da1767a464d5a4289f949d729be5cab14aea0c7e7a", + "tags": [ + ["u", "https://mint.lnw.cash"], + ["nuts", "0,1,2"], + ["updated_at", "1739410543"], + ["d", "gm0cw0i8n92w8ya2"] + ] + }, + { + "content": "{\"url\":\"https://mint.azzamo.net\",\"name\":\"Azzamo Cashu Mint\",\"description\":\"Unlock a new dimension of digital transactions with Azzamo cash Mint.\",\"version\":\"Nutshell/0.16.4\",\"nuts\":[\"NUT-07\",\"NUT-08\",\"NUT-09\"],\"motd\":\"Disclaimer: Azzamo Mint is in beta and experimental. Use small amounts only. Key Mantra: Not your keys = Not your coins.\",\"contact\":[[\"email\",\"support@azzamo.net\"],[\"twitter\",\"@me\"],[\"nostr\",\"npub...\"]]}", + "created_at": 1739410566, + "id": "00e5d6b029760d9368ae312546c03040f39ceecba8b2def646251b4b9f1b6bbb", + "kind": 38172, + "pubkey": "972f233aa467bc9804032c0bce0a117daead5473c56c91e811a216bdd08c08cf", + "sig": "638282664698a4394d7c66b92007b626ecdffd6d2038246b1f01269e82654531838f99ecb493f5bb99f5e20d39ac90a3c7adf6f37ea73c18d2af5161978d7b27", + "tags": [ + ["u", "https://mint.azzamo.net"], + ["nuts", "0,1,2"], + ["updated_at", "1739410566"], + ["d", "9byv4xd5fx8xtjjm"] + ] + }, + { + "content": "{\"url\":\"https://21mint.me\",\"name\":\"21Mint\",\"description\":\"Secure and privacy-oriented Cashu mint. All logs are automatically deleted every 24 hours.\",\"version\":\"Nutshell/0.16.4\",\"nuts\":[\"NUT-07\",\"NUT-08\",\"NUT-09\"],\"motd\":\"Welcome to 21Mint! We are currently in beta testing. Please report any issues or feedback. All logs are automatically deleted every 24 hours.\",\"contact\":[[\"nostr\",\"npub13suzac0smac4fr9zqvrrcr003kj2snr2m5a58j4gg6w3udejennsuc6ts3\"]]}", + "created_at": 1739410554, + "id": "0121b05ae161734d4a617b1e69eadd179b901cb2196fe50cab46e684edd4f566", + "kind": 38172, + "pubkey": "972f233aa467bc9804032c0bce0a117daead5473c56c91e811a216bdd08c08cf", + "sig": "98650eade787a3b86751db2972ae11cd92ca164670eb61ff59ac56275e56bb799d3bccd9f11ca18837380d329dccf0f890da2645292467a0516c41e9c5a3b4bc", + "tags": [ + ["u", "https://21mint.me"], + ["nuts", "0,1,2"], + ["updated_at", "1739410554"], + ["d", "wza56s85l1pwzaz0"] + ] + }, + { + "content": "{\"url\":\"https://cashu.boats\",\"name\":\"Kinda Reckless Mint\",\"description\":\"Reckless mint for the brave\",\"version\":\"Nutshell/0.16.3\",\"nuts\":[\"NUT-07\",\"NUT-08\",\"NUT-09\"],\"motd\":\"Go forth and spend with reckless abandon!\",\"contact\":[[\"email\",\"deodorize_average752@aleeas.com\"],[\"nostr\",\"npub18ehswvhxvl7992y2999lyq8lnnkyppn6ald5hfdmuvtcjmlu3v6sh9vrmc\"]]}", + "created_at": 1739410695, + "id": "0125348088164471a4b6b04a0e75175b7357f29c4f61eb995173d8b675c2e741", + "kind": 38172, + "pubkey": "972f233aa467bc9804032c0bce0a117daead5473c56c91e811a216bdd08c08cf", + "sig": "407aacb1362fb32806a0788994c9b568bc55aedaad54e74fb20661ab9479cada282a5e4266c3d73f1f6c8ff97f21b47eb1bd7802490ec64a816bbc3655d1435f", + "tags": [ + ["u", "https://cashu.boats"], + ["nuts", "0,1,2"], + ["updated_at", "1739410695"], + ["d", "tsimxkld5mxmd9d5"] + ] + } + ], + "cashu38172Legacy": [ + { + "content": "{\"name\":\"Nostrodomo Mint\",\"description\":\"Cashu mint for AI agents — Loom payment layer for Cascadia fleet\",\"contact\":[]}", + "created_at": 1774120532, + "id": "2451d5d6b79257eab6bb9f4e2b15dd9240ab6b82ce2b9b45ad38f17b235e08a4", + "kind": 38172, + "pubkey": "5fe928ae0970844f3c5253d2e85a88788486edcbd96c070334a4a2d0d0154a77", + "sig": "89383f7e7fba2105c465e1c0d3f6f00562e33f948c34976de53d75595d82afe1bdb4141b3de8076f4eccde1787b58e2f5552fb5ea4ce82fb9cebc84e2a529b94", + "tags": [ + ["d", "5fe928ae0970844f3c5253d2e85a88788486edcbd96c070334a4a2d0d0154a77"], + ["u", "https://mint.sharegap.net"], + ["nuts", "1,2,3,4,5,6,7,9,10,11,12,14,20"], + ["n", "mainnet"], + ["t", "loom"], + ["t", "cascadia"] + ] + } + ], + "cashu38172SpecConforming": [ + { + "content": "{\"name\":\"Mint Alpha (synthetic)\",\"about\":\"Representative spec-conformant Cashu announcement\",\"picture\":\"https://example.test/alpha.png\"}", + "created_at": 1770000000, + "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "kind": 38172, + "pubkey": "02aa00000000000000000000000000000000000000000000000000000000000001", + "sig": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "tags": [ + ["d", "02aa00000000000000000000000000000000000000000000000000000000000001"], + ["u", "https://mint.alpha.test"], + ["nuts", "1,2,3,4,5,6,7,9,10,11,12,14,20"], + ["n", "mainnet"] + ] + }, + { + "content": "", + "created_at": 1770000001, + "id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kind": 38172, + "pubkey": "03bb00000000000000000000000000000000000000000000000000000000000002", + "sig": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "tags": [ + ["d", "03bb00000000000000000000000000000000000000000000000000000000000002"], + ["u", "https://mint.beta.test"], + ["u", "https://mint.beta.test/v1"], + ["nuts", "7,8,9,10,11,12,14"] + ] + } + ], + "fedimint38173": [ + { + "content": "{\"federation_name\":\"Mutinynet-iroh\"}", + "created_at": 1775199515, + "id": "025aba4244de9845be458f414baf1e7db32ea9e767a9dfe116f16d628855b7e0", + "kind": 38173, + "pubkey": "bc5122e4d89e495ceb2a63134b5f208c7d17a1505d041d6a4eca7fc59eb71d50", + "sig": "91e271732d827651980dc6e25924c1d9c6896b8d864fcf4cca318f064f3ae2ab9eeb2782244bdb1dba93f11fb790a01f85dd24238c3d9e2dab2c228e98f37b95", + "tags": [ + ["d", "103b0a005077185b64583e1cdcd2e3023f00a16e28ae5fcc79fffc156a36ef61"], + [ + "u", + "fed11qvqyj3mfwfhksw309umrjen9vscnwefhx4jkve3j893rsvmpv56rzvpjxcexxvtp8p3rjvnrxsmr2vmxxv6n2wtzxv6rwv3cv9nxvefexumrscekx3jryvpeqqqyj3mfwfhksw309u6ngde4xymngvesxsurjetrxanxgefjv93kgcfkx33rxdtyx3snywp5xu6xvv3cx43nqc3svg6kxdm9x9jrxep3v9jnqdp5xcenjefjqyqjqypmpgq9qacctdj9s0sumnfwxq3lqzsku29wtlx8nlluz44rdmmp32qwkf" + ], + ["n", "signet"], + ["modules", "ln,mint,wallet,lnv2,meta"] + ] + }, + { + "content": "{\"federation_name\":\"Orange Club Africa\"}", + "created_at": 1759923172, + "id": "117eedf75b1d17b716d36c72388c97ab9095764f553ee28d9bd306ec5359e899", + "kind": 38173, + "pubkey": "de2b138f51b9d52752ec4f44fc36153fe5ae5b7dc92c39cdbd6d4a9fb8f4334e", + "sig": "3cc86c13bf5147475ca6c5e8d1ea1bf0932d54b03be8531ab126e13c18d0394406a8354c515ff2d45bfa17f1ff926b511aa795b5d860570d352588a8535473ce", + "tags": [ + ["d", "718e421be177486639330d198e870b7345ebd07b2866b5fd3797d73e4bc4c9af"], + [ + "u", + "fed11qvqyj3mfwfhksw309ucnywf38yurwwf4x5unswf58pjxydpnx9jnxdpjvgurxdfjv4jkgd3hxguxvdekvsmrwd3nvc6r2ce3v93xyve4xsunwetxv56x2vfjqqqyj3mfwfhksw309ajkxc3jxejkgd3jxcerqdf5xs6r2c3svgunxvf4xuckxenrvyunxcn9vsmxvdr9xvensv3kvvmrgveexymkzvp5vcmnjer9xcunxdmpqyqjquvwggd7za6gvcunxrge36rsku69a0g8k2rxkh7n097h8e9ufjd09yck6f" + ], + ["n", "bitcoin"], + ["modules", "ln,mint,wallet,lnv2,meta,multi_sig_stability_pool"] + ] + }, + { + "content": "", + "created_at": 1716511146, + "id": "1819482150b26768b6db2d766190e9012801c8e8f606a8dc79f67b11a3767a70", + "kind": 38173, + "pubkey": "36307f944a8ddfde2fcf09aa5ea472c51d0a9173108d1ffd5df7654ca9e7d1af", + "sig": "283f146a212725ca076579b69ee2b187b90013ed517e587de00a595040520c031d15ee3243264dc6e6a7ec90461accdf30a6e23f55bcc438e183aa0769370b83", + "tags": [ + [ + "u", + "fed11qgqzutrhwden5te0vejkg6tdd9h8gepwvejkg6tdd9h8gtn0v35kuen9v3jhyct5d9hkutnc09az7qqpyp938g2xae96wv4jhzg55u4q5tjcw037jsk6948walv95hlyrunm5tyfcdy", + "fedimint" + ], + ["d", "4b13a146ee4ba732b2b8914a72a0a2e5873e3e942da2d4eeefd85a5fe41f27ba"] + ] + } + ], + "recommendations38000": [ + { + "content": "[5/5]", + "created_at": 1758213273, + "id": "001aa17b4673880e626b110fa284011dd2003609c161977fb84f43087c4802ce", + "kind": 38000, + "pubkey": "bf5ae4651d81d5a211fac31823458160853a920b2ed03881a578b908280fe690", + "sig": "427bcb7b43f04c30553e71fbf4b82a333c34dfdaa417d2e55607a9d2cc3871f804eb9feb0340e0218a52ce01b0cf73bfc42b7e2078716933e1d955c9294cf15f", + "tags": [ + ["d", "b21068c84f5b12ca4fdf93f3e443d3bd7c27e8642d0d52ea2e4dce6fdbbee9df"], + ["k", "38173"], + ["rating", "5"] + ] + }, + { + "content": "[3/5]", + "created_at": 1756753511, + "id": "0028826fe2134330b7d5a46fbd17a43b1368ec0f563f5636b585b1ad2ebf5136", + "kind": 38000, + "pubkey": "fe7e6de7f758dbdb06cac665a144eeb6898680298b4978ef1711b3e07238fe93", + "sig": "533f22c79b647b163d1115e43b8c8a772135beb465dad56dc04debddba4afd4324b53afa4f3d9e83458538390d1d328ae451c55f3c788e8ca4051ae2cf5b88c8", + "tags": [ + ["d", "b21068c84f5b12ca4fdf93f3e443d3bd7c27e8642d0d52ea2e4dce6fdbbee9df"], + ["k", "38173"], + ["rating", "3"] + ] + }, + { + "content": "[5/5] I'm SUPERMAX and this 'The Super Mint' is for educational and testing purposes. If you have questions or need help connecting, please contact me either on Nostr or email. Here's a quick site I made for information about The Super Mint here, https://tinyurl.com/thesupermint . Thanks all!", + "created_at": 1710867353, + "id": "00183f8eac08d8b34a75947a8e707600715b3d48aa8be35786891970fb2d7c7f", + "kind": 38000, + "pubkey": "ae1008d23930b776c18092f6eab41e4b09fcf3f03f3641b1b4e6ee3aa166d760", + "sig": "78eeace559cb384954f5f1abd34a142f2ffd97c3a0539b817552737b8a6569cfe8ad2a8310ee4e464037f728eb45f599eadab97fc0909bdfbefef038ef311f1b", + "tags": [ + ["k", "38172"], + ["u", "https://obsessedjerky7.lnbits.com/cashu/api/v1/L4bNfiBtTyhEpZWdt6QTb4", "cashu"], + ["d", "psvef0yh2zk24tt7"] + ] + }, + { + "content": "[5/5] Es un placer formar parte de esta bella comunidad, espero que este proyecto crezca y siga igual de positivo", + "created_at": 1746224475, + "id": "00230427076a58c4a2aa91f0236644b5125fcf76dfd165a5149424f9f3c7ee46", + "kind": 38000, + "pubkey": "2871a10447d80a1c7ca542488d9e09c10ed843641f6867b69fd92fb84bbe84dc", + "sig": "dcce911190c0b45a3bbc80f06a57dde11e9f7c189a8f9e2a47439d33b631fed5b8b7a06d00a775b4e2c321be2373a20b5cd749f4ad6c969919d9a3d1cb43c349", + "tags": [ + ["k", "38172"], + ["u", "https://mint.cubabitcoin.org", "cashu"], + ["a", "38172:cashu-mint-pubkey:nda1zjgriivc1xvv", "wss://bitcoiner.social", "cashu"], + ["d", "nda1zjgriivc1xvv"] + ] + }, + { + "content": "", + "created_at": 1718722206, + "id": "0015d072642960c96a589b06d341160e32d2c58cf5662d1a7742d765183e1d52", + "kind": 38000, + "pubkey": "7624c028f873a52abf025ea89ff8cd7599dcddd98c1199eebe3a7d76905bb138", + "sig": "1dd858e49587d0d4d24708dadcf99737858d5dd2e70ccaed632b25bbb690e24258b99818f45c50ed00e6043b1606e3e2edf3405e24a88d46481a1093d9150b53", + "tags": [ + ["d", "c944b2fd1e7fe04ca87f9a57d7894cb69116cec6264cb52faa71228f4ec54cd6"], + ["k", "38173"], + [ + "u", + "fed11qgqzz8mhwden5te0vejkg6tdd9h8gepwvchxjmm5w4hxgunp9e3k7mf0qyqjpj2ykt73ullqfj58lxjh67y5ed53zm8vvfjvk5h65ufz3a8v2nxky9wuce" + ], + ["n", "mainnet"] + ] + } + ] +} diff --git a/packages/core/src/nip87/corpus.test.ts b/packages/core/src/nip87/corpus.test.ts new file mode 100644 index 0000000..5070e1a --- /dev/null +++ b/packages/core/src/nip87/corpus.test.ts @@ -0,0 +1,149 @@ +import type { Event as NostrEvent } from "nostr-tools/core"; +import { describe, expect, it } from "vitest"; +import fixtures from "./__fixtures__/nip87-sample.json" with { type: "json" }; +import { isValidCashuDTag } from "./dtag"; +import { parseMintAnnouncement, parseRecommendation } from "./parse"; + +type Fixture = { + _meta: Record; + cashu38172BotSpam: NostrEvent[]; + cashu38172Legacy: NostrEvent[]; + cashu38172SpecConforming: NostrEvent[]; + fedimint38173: NostrEvent[]; + recommendations38000: NostrEvent[]; +}; +const f = fixtures as unknown as Fixture; + +/** + * Empirical-findings assertions for the curated corpus at + * __fixtures__/nip87-sample.json. See that file's `_meta.notes` for the + * provenance and composition. + */ +describe("NIP-87 corpus", () => { + it("has the expected event counts per bucket", () => { + expect(f.cashu38172BotSpam.length).toBe(5); + expect(f.cashu38172Legacy.length).toBe(1); + expect(f.cashu38172SpecConforming.length).toBe(2); + expect(f.fedimint38173.length).toBe(3); + expect(f.recommendations38000.length).toBe(5); + + const total = + f.cashu38172BotSpam.length + + f.cashu38172Legacy.length + + f.cashu38172SpecConforming.length + + f.fedimint38173.length + + f.recommendations38000.length; + expect(total).toBe(16); + }); + + it("Layer A accepts spec-conforming AND x-only Cashu announcements, rejects bot spam", () => { + const all38172: NostrEvent[] = [ + ...f.cashu38172BotSpam, + ...f.cashu38172Legacy, + ...f.cashu38172SpecConforming, + ]; + expect(all38172.length).toBe(8); + + const parsed = all38172 + .map((e) => parseMintAnnouncement(e)) + .filter((a): a is NonNullable => a !== null); + // All 8 parse successfully (parse does NOT gate on Layer A). + expect(parsed.length).toBe(8); + + const accepted = parsed.filter((a) => isValidCashuDTag(a.d)); + const rejected = parsed.filter((a) => !isValidCashuDTag(a.d)); + + // 2 SpecConforming (66-char compressed) + 1 Legacy (64-char x-only) = 3 accepted. + expect(accepted.length).toBe(3); + // 5 bot-spam (16-char random) = 5 rejected. + expect(rejected.length).toBe(5); + }); + + it("Layer A rejects all 5 bot-spam events (16-char d-tags)", () => { + for (const e of f.cashu38172BotSpam) { + const parsed = parseMintAnnouncement(e); + expect(parsed).not.toBeNull(); + expect(parsed && isValidCashuDTag(parsed.d)).toBe(false); + } + }); + + it("all bot-spam events in the fixture belong to the 972f233a... publisher", () => { + const BOT_PUBKEY = "972f233aa467bc9804032c0bce0a117daead5473c56c91e811a216bdd08c08cf"; + const botPubkeyCount = f.cashu38172BotSpam.filter((e) => e.pubkey === BOT_PUBKEY).length; + expect(botPubkeyCount).toBe(5); + }); + + it("Layer A accepts the 64-char x-only Nostrodomo announcement (de-facto mainstream shape)", () => { + for (const e of f.cashu38172Legacy) { + const parsed = parseMintAnnouncement(e); + expect(parsed).not.toBeNull(); + expect(parsed && isValidCashuDTag(parsed.d)).toBe(true); + // Sanity: it really is 64 chars, not 66. + expect(parsed?.d.length).toBe(64); + } + }); + + it("Layer A does NOT apply to Fedimint — all 3 parse, at least one has modules", () => { + const parsedFedi = f.fedimint38173 + .map((e) => parseMintAnnouncement(e)) + .filter((a): a is NonNullable => a !== null); + expect(parsedFedi.length).toBe(f.fedimint38173.length); + + for (const parsed of parsedFedi) { + expect(parsed.kind).toBe(38173); + expect(parsed.nuts).toBeUndefined(); + // `modules` is optional — some Fedimint announcements omit it. + if (parsed.modules !== undefined) { + expect(Array.isArray(parsed.modules)).toBe(true); + expect(parsed.modules.length).toBeGreaterThan(0); + } + // TODO-v1.1: Fedimint d-tag is a federation id — no Layer A equivalent + // yet. We deliberately do NOT call isValidCashuDTag on Fedimint events. + } + + // At least one of the curated fixtures should have modules populated. + const withModules = parsedFedi.filter((a) => a.modules !== undefined); + expect(withModules.length).toBeGreaterThanOrEqual(1); + }); + + it("every fixture recommendation parses", () => { + for (const e of f.recommendations38000) { + const parsed = parseRecommendation(e); + expect(parsed).not.toBeNull(); + if (!parsed) continue; + expect(parsed.kind).toBe(38000); + } + }); + + it("the fixture covers multiple rating-format paths", () => { + const parsed = f.recommendations38000 + .map((e) => parseRecommendation(e)) + .filter((r): r is NonNullable => r !== null); + + // At least one with rating, at least one without. + const withRating = parsed.filter((r) => r.rating !== undefined); + const withoutRating = parsed.filter((r) => r.rating === undefined); + expect(withRating.length).toBeGreaterThanOrEqual(1); + expect(withoutRating.length).toBeGreaterThanOrEqual(1); + + // All ratings in range [0,5]. + for (const r of withRating) { + expect(r.rating).toBeGreaterThanOrEqual(0); + expect(r.rating).toBeLessThanOrEqual(5); + } + }); + + it("the fixture includes at least one rec that uses the 2-arg ['rating','N'] tag format", () => { + const tagged = f.recommendations38000.filter((e) => + e.tags.some((t) => t[0] === "rating" && typeof t[1] === "string" && t[2] === undefined), + ); + expect(tagged.length).toBeGreaterThanOrEqual(1); + }); + + it("the fixture includes at least one rec that relies on the [N/5] content regex", () => { + const contentRating = f.recommendations38000.filter( + (e) => e.tags.every((t) => t[0] !== "rating") && /(\d(?:\.\d+)?)\s*\/\s*5/.test(e.content), + ); + expect(contentRating.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/packages/core/src/nip87/dtag.test.ts b/packages/core/src/nip87/dtag.test.ts new file mode 100644 index 0000000..f8f4c4f --- /dev/null +++ b/packages/core/src/nip87/dtag.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { D_TAG_REGEX, isValidCashuDTag } from "./dtag"; + +describe("isValidCashuDTag", () => { + describe("valid — 66-char compressed secp256k1 pubkeys", () => { + it("accepts a 02-prefixed 66-char lowercase hex d-tag", () => { + expect(isValidCashuDTag(`02${"0".repeat(64)}`)).toBe(true); + }); + + it("accepts a 03-prefixed 66-char lowercase hex d-tag", () => { + expect(isValidCashuDTag(`03${"a".repeat(64)}`)).toBe(true); + }); + + it("accepts a realistic-looking 02-prefixed pubkey", () => { + // From a real kind:38000 recommendation's d-tag, pointing to lemonfizz mint. + expect( + isValidCashuDTag("03c5f16604678b8b118a454db12885e586f0fc146788d54182b3ca7943a327278e"), + ).toBe(true); + }); + + it("accepts the full hex alphabet in a 66-char d-tag", () => { + expect(isValidCashuDTag(`02${"0123456789abcdef".repeat(4)}`)).toBe(true); + }); + }); + + describe("valid — 64-char x-only secp256k1 pubkeys (de-facto form)", () => { + it("accepts a real 64-char x-only d-tag (Nostrodomo Mint)", () => { + expect( + isValidCashuDTag("5fe928ae0970844f3c5253d2e85a88788486edcbd96c070334a4a2d0d0154a77"), + ).toBe(true); + }); + + it("accepts a 64-char d-tag starting with 00", () => { + // 64 chars, starts with 00 — would fail the 66-char branch but passes the 64-char branch. + expect(isValidCashuDTag(`00${"0".repeat(62)}`)).toBe(true); + }); + + it("accepts a 64-char d-tag starting with ff", () => { + // 64 chars, starts with ff — would fail the 66-char branch but passes the 64-char branch. + expect(isValidCashuDTag(`ff${"0".repeat(62)}`)).toBe(true); + }); + + it("accepts the full hex alphabet in a 64-char d-tag", () => { + expect(isValidCashuDTag("0123456789abcdef".repeat(4))).toBe(true); + }); + }); + + describe("invalid — shape mismatches", () => { + it("rejects 16-char bot-spam d-tags", () => { + // Real examples from the 972f233a... bot burst. + expect(isValidCashuDTag("ewakfwchz6tmlmvy")).toBe(false); + expect(isValidCashuDTag("rp8l2ez6vw3t4u2j")).toBe(false); + expect(isValidCashuDTag("psvef0yh2zk24tt7")).toBe(false); + expect(isValidCashuDTag("abc123def4567890")).toBe(false); + }); + + it("rejects 66-char d-tag with wrong prefix (uncompressed 04, or other)", () => { + // 04 prefix = uncompressed — wrong kind for Cashu's compressed-secp256k1 slot. + expect(isValidCashuDTag(`04${"0".repeat(64)}`)).toBe(false); + expect(isValidCashuDTag(`01${"0".repeat(64)}`)).toBe(false); + expect(isValidCashuDTag(`05${"0".repeat(64)}`)).toBe(false); + expect(isValidCashuDTag(`ff${"0".repeat(64)}`)).toBe(false); + expect(isValidCashuDTag(`aa${"0".repeat(64)}`)).toBe(false); + }); + + it("rejects 65-char d-tag (between the two valid lengths)", () => { + expect(isValidCashuDTag(`02${"0".repeat(63)}`)).toBe(false); + expect(isValidCashuDTag("0".repeat(65))).toBe(false); + }); + + it("rejects 67-char d-tag (one past 66)", () => { + expect(isValidCashuDTag(`02${"0".repeat(65)}`)).toBe(false); + expect(isValidCashuDTag("0".repeat(67))).toBe(false); + }); + + it("rejects too-short d-tag", () => { + expect(isValidCashuDTag(`02${"0".repeat(10)}`)).toBe(false); + expect(isValidCashuDTag("02")).toBe(false); + expect(isValidCashuDTag("0".repeat(63))).toBe(false); + }); + + it("rejects too-long d-tag", () => { + expect(isValidCashuDTag("0".repeat(128))).toBe(false); + expect(isValidCashuDTag(`02${"0".repeat(128)}`)).toBe(false); + }); + + it("rejects non-hex characters (64-char length)", () => { + expect(isValidCashuDTag("z".repeat(64))).toBe(false); + expect(isValidCashuDTag("g".repeat(64))).toBe(false); + expect(isValidCashuDTag(`${"0".repeat(63)}z`)).toBe(false); + }); + + it("rejects non-hex characters (66-char length)", () => { + expect(isValidCashuDTag(`02${"z".repeat(64)}`)).toBe(false); + expect(isValidCashuDTag(`02${"g".repeat(64)}`)).toBe(false); + expect(isValidCashuDTag(`02!@#$%^&*()${"0".repeat(55)}`)).toBe(false); + }); + + it("rejects uppercase hex (64-char) — regex is case-sensitive", () => { + expect(isValidCashuDTag("A".repeat(64))).toBe(false); + expect( + isValidCashuDTag("5FE928AE0970844F3C5253D2E85A88788486EDCBD96C070334A4A2D0D0154A77"), + ).toBe(false); + }); + + it("rejects uppercase hex (66-char) — regex is case-sensitive", () => { + expect(isValidCashuDTag(`02${"A".repeat(64)}`)).toBe(false); + expect( + isValidCashuDTag("02C5F16604678B8B118A454DB12885E586F0FC146788D54182B3CA7943A327278"), + ).toBe(false); + }); + + it("rejects empty string", () => { + expect(isValidCashuDTag("")).toBe(false); + }); + + it("rejects whitespace-only or whitespace-padded", () => { + expect(isValidCashuDTag(" ")).toBe(false); + expect(isValidCashuDTag(` 02${"0".repeat(64)}`)).toBe(false); + expect(isValidCashuDTag(`02${"0".repeat(64)} `)).toBe(false); + expect(isValidCashuDTag(` ${"0".repeat(64)}`)).toBe(false); + expect(isValidCashuDTag(`${"0".repeat(64)} `)).toBe(false); + }); + + it("rejects the strings 'null' and 'undefined' (sanity: if coerced from non-string)", () => { + expect(isValidCashuDTag("null")).toBe(false); + expect(isValidCashuDTag("undefined")).toBe(false); + }); + }); + + it("D_TAG_REGEX export is the live regex used by the validator", () => { + // 66-char branch live + expect(D_TAG_REGEX.test(`02${"0".repeat(64)}`)).toBe(true); + // 64-char branch live + expect(D_TAG_REGEX.test("0".repeat(64))).toBe(true); + // Nonsense rejected + expect(D_TAG_REGEX.test("not-a-pubkey")).toBe(false); + }); +}); diff --git a/packages/core/src/nip87/dtag.ts b/packages/core/src/nip87/dtag.ts new file mode 100644 index 0000000..729d735 --- /dev/null +++ b/packages/core/src/nip87/dtag.ts @@ -0,0 +1,37 @@ +/** + * Layer A d-tag shape validator for NIP-87 Cashu mint announcements. + * + * Empirical finding: zero real kind:38172 events in the wild conform to + * the strict 66-char compressed secp256k1 form per NUT-00 spec. Every + * real Cashu mint (e.g. Nostrodomo 5fe928ae...) publishes a 64-char + * x-only pubkey. A strict 66-char-only regex would reject 100% of real + * mints AND bot spam, defeating Layer A's purpose. + * + * The accepted shape is therefore the union of: + * - 64-char x-only secp256k1 (de-facto form real Cashu mints publish) + * - 66-char with `02`/`03` prefix = compressed secp256k1 per NUT-00 spec + * + * Layer B (NUT-06 signer binding via /v1/info) is a follow-up check that + * lives in PR #4 and confirms the pubkey corresponds to an actual Cashu + * mint. Layer A alone is cheap and still rejects: + * - Bot spam with random 16-char d-tags (959 events from 2025-02-13 per + * /srv/forge/projects/bitcoinmints/audit/relay-strategy-v1.md §4) + * - Any non-hex garbage + * - Wrong-length hex + * + * Fedimint (kind:38173) d-tags are federation IDs (different shape) — + * this validator applies to Cashu only (kind:38172). See TODO-v1.1 in + * parse.ts for Fedimint federation-id validation. + */ +// 64-char = x-only secp256k1 (de-facto form real Cashu mints publish) +// 66-char with 02/03 prefix = compressed secp256k1 per NUT-00 spec +// Bot spam (16-char random d-tags) rejected by both branches. +export const D_TAG_REGEX = /^([0-9a-f]{64}|0[23][0-9a-f]{64})$/; + +/** + * True iff `d` is either a 64-char x-only secp256k1 pubkey or a 66-char + * compressed secp256k1 pubkey, both lowercase hex. + */ +export function isValidCashuDTag(d: string): boolean { + return D_TAG_REGEX.test(d); +} diff --git a/packages/core/src/nip87/index.ts b/packages/core/src/nip87/index.ts new file mode 100644 index 0000000..eb58113 --- /dev/null +++ b/packages/core/src/nip87/index.ts @@ -0,0 +1,7 @@ +export { D_TAG_REGEX, isValidCashuDTag } from "./dtag"; +export { parseMintAnnouncement, parseRecommendation } from "./parse"; +export type { + MintAnnouncement, + MintAnnouncementNetwork, + MintRecommendation, +} from "./types"; diff --git a/packages/core/src/nip87/parse.test.ts b/packages/core/src/nip87/parse.test.ts new file mode 100644 index 0000000..a7dac25 --- /dev/null +++ b/packages/core/src/nip87/parse.test.ts @@ -0,0 +1,332 @@ +import type { Event as NostrEvent } from "nostr-tools/core"; +import { describe, expect, it } from "vitest"; +import fixtures from "./__fixtures__/nip87-sample.json" with { type: "json" }; +import { parseMintAnnouncement, parseRecommendation } from "./parse"; + +type Fixture = { + cashu38172BotSpam: NostrEvent[]; + cashu38172Legacy: NostrEvent[]; + cashu38172SpecConforming: NostrEvent[]; + fedimint38173: NostrEvent[]; + recommendations38000: NostrEvent[]; +}; +const f = fixtures as unknown as Fixture; + +describe("parseMintAnnouncement", () => { + it("parses a real kind:38172 bot-spam event into the expected shape", () => { + const event = f.cashu38172BotSpam[0]; + if (!event) throw new Error("fixture missing cashu38172BotSpam[0]"); + + const parsed = parseMintAnnouncement(event); + expect(parsed).not.toBeNull(); + if (!parsed) return; + + expect(parsed.eventId).toBe(event.id); + expect(parsed.kind).toBe(38172); + expect(parsed.pubkey).toBe(event.pubkey); + expect(parsed.createdAt).toBe(event.created_at); + expect(parsed.d).toBeTypeOf("string"); + expect(parsed.u.length).toBeGreaterThan(0); + expect(parsed.raw).toBe(event); + }); + + it("parses a spec-conforming 38172 with nuts tag into a number array", () => { + const event = f.cashu38172SpecConforming[0]; + if (!event) throw new Error("fixture missing cashu38172SpecConforming[0]"); + + const parsed = parseMintAnnouncement(event); + expect(parsed).not.toBeNull(); + if (!parsed) return; + + expect(parsed.kind).toBe(38172); + expect(parsed.nuts).toEqual([1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 14, 20]); + expect(parsed.modules).toBeUndefined(); + expect(parsed.n).toBe("mainnet"); + expect(parsed.u).toEqual(["https://mint.alpha.test"]); + expect(parsed.contentMetadata?.name).toBe("Mint Alpha (synthetic)"); + expect(parsed.contentMetadata?.picture).toBe("https://example.test/alpha.png"); + }); + + it("parses a 38172 with multiple u tags into u: string[]", () => { + const event = f.cashu38172SpecConforming[1]; + if (!event) throw new Error("fixture missing cashu38172SpecConforming[1]"); + + const parsed = parseMintAnnouncement(event); + expect(parsed).not.toBeNull(); + if (!parsed) return; + + expect(parsed.u).toEqual(["https://mint.beta.test", "https://mint.beta.test/v1"]); + // No `n` tag in this fixture. + expect(parsed.n).toBeUndefined(); + // Empty content -> no metadata. + expect(parsed.contentMetadata).toBeUndefined(); + }); + + it("parses a real kind:38173 Fedimint event with modules CSV", () => { + const event = f.fedimint38173[0]; + if (!event) throw new Error("fixture missing fedimint38173[0]"); + + const parsed = parseMintAnnouncement(event); + expect(parsed).not.toBeNull(); + if (!parsed) return; + + expect(parsed.kind).toBe(38173); + expect(parsed.nuts).toBeUndefined(); + expect(parsed.modules).toBeDefined(); + expect(Array.isArray(parsed.modules)).toBe(true); + // First Fedimint fixture's modules tag is "ln,mint,wallet,lnv2,meta,multi_sig_stability_pool". + expect(parsed.modules?.length).toBeGreaterThanOrEqual(2); + expect(parsed.u.length).toBeGreaterThan(0); + }); + + it("returns null when the `d` tag is missing", () => { + const bogus: NostrEvent = { + id: "noid", + pubkey: "nopubkey", + created_at: 0, + kind: 38172, + tags: [["u", "https://nope.test"]], + content: "", + sig: "nosig", + }; + expect(parseMintAnnouncement(bogus)).toBeNull(); + }); + + it("returns null when there are no `u` tags", () => { + const bogus: NostrEvent = { + id: "noid", + pubkey: "nopubkey", + created_at: 0, + kind: 38172, + tags: [["d", `02${"0".repeat(64)}`]], + content: "", + sig: "nosig", + }; + expect(parseMintAnnouncement(bogus)).toBeNull(); + }); + + it("returns null for unexpected kinds", () => { + const bogus: NostrEvent = { + id: "noid", + pubkey: "nopubkey", + created_at: 0, + kind: 1, + tags: [ + ["d", "x"], + ["u", "https://nope.test"], + ], + content: "", + sig: "nosig", + }; + expect(parseMintAnnouncement(bogus)).toBeNull(); + }); + + it("tolerates non-JSON content (contentMetadata undefined, no throw)", () => { + const event: NostrEvent = { + id: "ok", + pubkey: "0".repeat(64), + created_at: 1234, + kind: 38172, + tags: [ + ["d", `02${"0".repeat(64)}`], + ["u", "https://mint.example"], + ], + content: "[5/5] hello not JSON", + sig: "sig", + }; + const parsed = parseMintAnnouncement(event); + expect(parsed).not.toBeNull(); + expect(parsed?.contentMetadata).toBeUndefined(); + }); + + it("ignores unknown network values in `n` tag", () => { + const event: NostrEvent = { + id: "ok", + pubkey: "0".repeat(64), + created_at: 1234, + kind: 38172, + tags: [ + ["d", `02${"0".repeat(64)}`], + ["u", "https://mint.example"], + ["n", "bitcoin-but-weird"], + ], + content: "", + sig: "sig", + }; + const parsed = parseMintAnnouncement(event); + expect(parsed).not.toBeNull(); + expect(parsed?.n).toBeUndefined(); + }); +}); + +describe("parseRecommendation", () => { + it("parses a real kind:38000 event with 2-arg rating tag", () => { + // First fixture recommendation has ["rating","5"] + content "[5/5]". + const event = f.recommendations38000[0]; + if (!event) throw new Error("fixture missing recommendations38000[0]"); + + const parsed = parseRecommendation(event); + expect(parsed).not.toBeNull(); + if (!parsed) return; + + expect(parsed.kind).toBe(38000); + expect(parsed.eventId).toBe(event.id); + expect(parsed.rating).toBe(5); + // k tag references either 38172 or 38173 in all fixture recommendations. + expect([38172, 38173]).toContain(parsed.k); + expect(parsed.content).toBeTypeOf("string"); + expect(parsed.d).toBeTypeOf("string"); + }); + + it("parses 3-arg rating tag ['rating','N','5'] as canonical format", () => { + const event: NostrEvent = { + id: "canon", + pubkey: "0".repeat(64), + created_at: 1234, + kind: 38000, + tags: [ + ["d", `02${"0".repeat(64)}`], + ["k", "38172"], + ["rating", "4", "5"], + ], + content: "", + sig: "sig", + }; + const parsed = parseRecommendation(event); + expect(parsed?.rating).toBe(4); + }); + + it("prefers 3-arg rating tag over 2-arg when both present", () => { + const event: NostrEvent = { + id: "canon", + pubkey: "0".repeat(64), + created_at: 1234, + kind: 38000, + tags: [ + ["d", `02${"0".repeat(64)}`], + ["rating", "1"], + ["rating", "4", "5"], + ], + content: "", + sig: "sig", + }; + const parsed = parseRecommendation(event); + expect(parsed?.rating).toBe(4); + }); + + it("prefers tag rating over content regex rating", () => { + const event: NostrEvent = { + id: "canon", + pubkey: "0".repeat(64), + created_at: 1234, + kind: 38000, + tags: [ + ["d", `02${"0".repeat(64)}`], + ["rating", "3"], + ], + content: "[5/5] great", + sig: "sig", + }; + const parsed = parseRecommendation(event); + expect(parsed?.rating).toBe(3); + }); + + it("falls back to content regex when no rating tag present (real corpus case)", () => { + // Third fixture rec has only `k`, `u`, `d` tags + content "[5/5] I'm SUPERMAX…". + const event = f.recommendations38000[2]; + if (!event) throw new Error("fixture missing recommendations38000[2]"); + const parsed = parseRecommendation(event); + expect(parsed?.rating).toBe(5); + }); + + it("returns rating undefined when neither tag nor content regex match", () => { + // Fifth fixture rec is a real empty-content Fedimint rec with no rating. + const event = f.recommendations38000[4]; + if (!event) throw new Error("fixture missing recommendations38000[4]"); + const parsed = parseRecommendation(event); + expect(parsed).not.toBeNull(); + expect(parsed?.rating).toBeUndefined(); + }); + + it("accepts fractional ratings via content regex (3.5/5)", () => { + const event: NostrEvent = { + id: "frac", + pubkey: "0".repeat(64), + created_at: 1234, + kind: 38000, + tags: [["d", `02${"0".repeat(64)}`]], + content: "[3.5/5] mostly fine", + sig: "sig", + }; + const parsed = parseRecommendation(event); + expect(parsed?.rating).toBe(3.5); + }); + + it("rejects out-of-range ratings from content regex", () => { + const event: NostrEvent = { + id: "oob", + pubkey: "0".repeat(64), + created_at: 1234, + kind: 38000, + tags: [["d", `02${"0".repeat(64)}`]], + content: "[7/5] wild overshoot", + sig: "sig", + }; + const parsed = parseRecommendation(event); + // Regex only matches 1 digit before the slash; 7 is valid syntactically + // but fails the 0..5 range check. + expect(parsed?.rating).toBeUndefined(); + }); + + it("returns null when `d` tag missing", () => { + const event: NostrEvent = { + id: "nod", + pubkey: "0".repeat(64), + created_at: 1234, + kind: 38000, + tags: [["k", "38172"]], + content: "", + sig: "sig", + }; + expect(parseRecommendation(event)).toBeNull(); + }); + + it("returns null for wrong kinds", () => { + const event: NostrEvent = { + id: "nod", + pubkey: "0".repeat(64), + created_at: 1234, + kind: 1, + tags: [["d", "x"]], + content: "", + sig: "sig", + }; + expect(parseRecommendation(event)).toBeNull(); + }); + + it("preserves `k` when numeric, omits when missing", () => { + const withK: NostrEvent = { + id: "a", + pubkey: "0".repeat(64), + created_at: 1, + kind: 38000, + tags: [ + ["d", `02${"0".repeat(64)}`], + ["k", "38172"], + ], + content: "", + sig: "sig", + }; + const noK: NostrEvent = { + id: "b", + pubkey: "0".repeat(64), + created_at: 1, + kind: 38000, + tags: [["d", `02${"0".repeat(64)}`]], + content: "", + sig: "sig", + }; + expect(parseRecommendation(withK)?.k).toBe(38172); + expect(parseRecommendation(noK)?.k).toBeUndefined(); + }); +}); diff --git a/packages/core/src/nip87/parse.ts b/packages/core/src/nip87/parse.ts new file mode 100644 index 0000000..865ef65 --- /dev/null +++ b/packages/core/src/nip87/parse.ts @@ -0,0 +1,179 @@ +import type { Event as NostrEvent } from "nostr-tools/core"; +import type { MintAnnouncement, MintAnnouncementNetwork, MintRecommendation } from "./types"; + +const KNOWN_NETWORKS = new Set([ + "mainnet", + "testnet", + "signet", + "regtest", +]); + +/** Matches `[N/5]` or `[N.M/5]` anywhere in the content, with optional whitespace. */ +const CONTENT_RATING_REGEX = /(\d(?:\.\d+)?)\s*\/\s*5/; + +function firstTagValue(tags: string[][], name: string): string | undefined { + for (const t of tags) { + if (t[0] === name && typeof t[1] === "string") return t[1]; + } + return undefined; +} + +function allTagValues(tags: string[][], name: string): string[] { + const out: string[] = []; + for (const t of tags) { + if (t[0] === name && typeof t[1] === "string") out.push(t[1]); + } + return out; +} + +function parseNumberList(csv: string | undefined): number[] | undefined { + if (!csv) return undefined; + const parts = csv + .split(",") + .map((p) => p.trim()) + .filter((p) => p.length > 0); + const nums: number[] = []; + for (const p of parts) { + const n = Number.parseInt(p, 10); + if (Number.isFinite(n)) nums.push(n); + } + return nums.length > 0 ? nums : undefined; +} + +function parseStringList(csv: string | undefined): string[] | undefined { + if (!csv) return undefined; + const parts = csv + .split(",") + .map((p) => p.trim()) + .filter((p) => p.length > 0); + return parts.length > 0 ? parts : undefined; +} + +function parseNetwork(n: string | undefined): MintAnnouncementNetwork | undefined { + if (!n) return undefined; + return KNOWN_NETWORKS.has(n as MintAnnouncementNetwork) + ? (n as MintAnnouncementNetwork) + : undefined; +} + +function parseContentMetadata(content: string): MintAnnouncement["contentMetadata"] { + if (!content?.trim().startsWith("{")) return undefined; + try { + const parsed = JSON.parse(content) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as MintAnnouncement["contentMetadata"]; + } + } catch { + // Non-JSON content is common (e.g. "[5/5] decent") — tolerate silently. + } + return undefined; +} + +/** + * Parse a kind:38172 (Cashu) or kind:38173 (Fedimint) event into a + * MintAnnouncement. Returns null when required tags (d, at least one u) + * are missing. + * + * Does NOT validate the d-tag shape; pair with isValidCashuDTag(a.d) when + * filtering the Cashu subset. Fedimint d-tag shape validation is a + * TODO-v1.1 (federation ids have different structure). + */ +export function parseMintAnnouncement(event: NostrEvent): MintAnnouncement | null { + if (event.kind !== 38172 && event.kind !== 38173) return null; + + const d = firstTagValue(event.tags, "d"); + if (!d) return null; + + const u = allTagValues(event.tags, "u"); + if (u.length === 0) return null; + + const n = parseNetwork(firstTagValue(event.tags, "n")); + const contentMetadata = parseContentMetadata(event.content); + + const base: MintAnnouncement = { + eventId: event.id, + kind: event.kind, + pubkey: event.pubkey, + createdAt: event.created_at, + d, + u, + raw: event, + }; + + if (n !== undefined) base.n = n; + if (contentMetadata !== undefined) base.contentMetadata = contentMetadata; + + if (event.kind === 38172) { + const nuts = parseNumberList(firstTagValue(event.tags, "nuts")); + if (nuts !== undefined) base.nuts = nuts; + } else { + // kind:38173 Fedimint + const modules = parseStringList(firstTagValue(event.tags, "modules")); + if (modules !== undefined) base.modules = modules; + } + + return base; +} + +function parseRatingFromTags(tags: string[][]): number | undefined { + for (const t of tags) { + if (t[0] !== "rating") continue; + // Canonical v1 shape: ["rating","","5"] (N in 0..5, max as 3rd arg). + if (typeof t[1] === "string" && t[2] === "5") { + const n = Number.parseFloat(t[1]); + if (Number.isFinite(n) && n >= 0 && n <= 5) return n; + } + } + // Legacy recall-trainer emitter: ["rating",""] (no max). + for (const t of tags) { + if (t[0] !== "rating") continue; + if (typeof t[1] === "string" && t[2] === undefined) { + const n = Number.parseFloat(t[1]); + if (Number.isFinite(n) && n >= 0 && n <= 5) return n; + } + } + return undefined; +} + +function parseRatingFromContent(content: string): number | undefined { + const match = content.match(CONTENT_RATING_REGEX); + if (!match?.[1]) return undefined; + const n = Number.parseFloat(match[1]); + if (Number.isFinite(n) && n >= 0 && n <= 5) return n; + return undefined; +} + +/** + * Parse a kind:38000 event into a MintRecommendation. Returns null when + * required tag (d) is missing. Rating parsing order per spec: + * + * 1. ["rating","","5"] structured tag (canonical) + * 2. ["rating",""] legacy + * 3. `[N/5]` or `N/5` regex on content + * 4. undefined + */ +export function parseRecommendation(event: NostrEvent): MintRecommendation | null { + if (event.kind !== 38000) return null; + + const d = firstTagValue(event.tags, "d"); + if (d === undefined) return null; + + const kStr = firstTagValue(event.tags, "k"); + const k = kStr ? Number.parseInt(kStr, 10) : Number.NaN; + + const rating = parseRatingFromTags(event.tags) ?? parseRatingFromContent(event.content); + + const rec: MintRecommendation = { + eventId: event.id, + kind: 38000, + pubkey: event.pubkey, + createdAt: event.created_at, + d, + content: event.content ?? "", + raw: event, + }; + if (Number.isFinite(k)) rec.k = k; + if (rating !== undefined) rec.rating = rating; + + return rec; +} diff --git a/packages/core/src/nip87/types.ts b/packages/core/src/nip87/types.ts new file mode 100644 index 0000000..4e0a973 --- /dev/null +++ b/packages/core/src/nip87/types.ts @@ -0,0 +1,79 @@ +import type { Event as NostrEvent } from "nostr-tools/core"; + +export type MintAnnouncementNetwork = "mainnet" | "testnet" | "signet" | "regtest"; + +/** + * Parsed NIP-87 mint announcement (kind:38172 Cashu or kind:38173 Fedimint). + * + * This is a parsing result only — no validation of pubkey-vs-signer, no + * /v1/info enrichment, no ranking. Pure event-to-shape transformation. + * Layer A d-tag shape validation lives in dtag.ts; call + * isValidCashuDTag(a.d) separately when filtering the Cashu subset. + */ +export type MintAnnouncement = { + eventId: string; + kind: 38172 | 38173; + /** Signer of the event — NOT necessarily the mint operator. */ + pubkey: string; + createdAt: number; + /** + * Parameterized-replaceable d-tag. + * - Cashu (kind:38172): mint's compressed secp256k1 pubkey per spec + * (see dtag.ts for Layer A validation). + * - Fedimint (kind:38173): federation id (TODO-v1.1: shape validator + * for federation ids). + */ + d: string; + /** + * Canonical mint URL(s) for Cashu, or invite codes for Fedimint. + * Collected from all `u` tags in the event. + */ + u: string[]; + /** Cashu only: parsed from comma-joined `nuts` tag. Not present for Fedimint. */ + nuts?: number[]; + /** Fedimint only: parsed from comma-joined `modules` tag. Not present for Cashu. */ + modules?: string[]; + /** Network from optional `n` tag, when it's one of the known Bitcoin networks. */ + n?: MintAnnouncementNetwork; + /** + * kind-0-style JSON metadata embedded in the event content. May be + * absent (empty content), malformed (non-JSON content), or present. We + * tolerate all three and surface the parse result (or undefined). + */ + contentMetadata?: { + name?: string; + about?: string; + picture?: string; + nuts?: number[]; + [key: string]: unknown; + }; + /** Original event — preserved for rehydration and downstream signatures. */ + raw: NostrEvent; +}; + +/** + * Parsed NIP-87 mint recommendation / review (kind:38000). + * + * Uniquely keyed by (pubkey, d) per NIP-87 parameterized-replaceable semantics. + */ +export type MintRecommendation = { + eventId: string; + kind: 38000; + /** Reviewer (event signer). */ + pubkey: string; + createdAt: number; + /** + * Target mint identifier — matches an announcement's d-tag. For Cashu + * this should be a 66-char compressed pubkey; for Fedimint, a + * federation id. Legacy events and bot spam use other shapes — the + * parser is lenient and preserves whatever is there. + */ + d: string; + /** Parsed 0..5 rating (inclusive). See parse.ts for format precedence. */ + rating?: number; + /** Freeform review text — may include a `[N/5]` prefix, may be empty. */ + content: string; + /** Referenced announcement kind from optional `k` tag (38172 or 38173). */ + k?: number; + raw: NostrEvent; +}; diff --git a/packages/core/src/nostr/index.ts b/packages/core/src/nostr/index.ts new file mode 100644 index 0000000..c6fbd8d --- /dev/null +++ b/packages/core/src/nostr/index.ts @@ -0,0 +1,8 @@ +export { + createPool, + type Pool, + type PoolConfig, + type PoolHandle, + SEED_RELAYS, + type SubscribeOptions, +} from "./pool"; diff --git a/packages/core/src/nostr/pool.test.ts b/packages/core/src/nostr/pool.test.ts new file mode 100644 index 0000000..4a2a5cc --- /dev/null +++ b/packages/core/src/nostr/pool.test.ts @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock nostr-tools SimplePool at module load. Each pool.subscribeMany +// returns a closer we can spy on; pool.close() collects calls for inspection. +const subscribeManyMock = vi.fn(); +const closeMock = vi.fn(); +const seenOnMock = new Map>(); + +vi.mock("nostr-tools/pool", () => { + return { + SimplePool: class { + subscribeMany(...args: unknown[]) { + return subscribeManyMock(...args); + } + close(...args: unknown[]) { + return closeMock(...args); + } + seenOn = seenOnMock; + }, + }; +}); + +// Import after the mock is registered. +import { createPool, SEED_RELAYS } from "./pool"; + +describe("SEED_RELAYS", () => { + it("exports exactly the three-relay default seed pool from the spec", () => { + expect(SEED_RELAYS).toEqual([ + "wss://nos.lol", + "wss://relay.damus.io", + "wss://relay.primal.net", + ]); + }); +}); + +describe("createPool", () => { + beforeEach(() => { + subscribeManyMock.mockReset(); + closeMock.mockReset(); + seenOnMock.clear(); + }); + + it("returns a pool with subscribe() and close()", () => { + const pool = createPool({ relays: [...SEED_RELAYS] }); + expect(typeof pool.subscribe).toBe("function"); + expect(typeof pool.close).toBe("function"); + }); + + it("subscribe() returns a handle with close(); does not hit live relays", () => { + const innerCloser = { close: vi.fn() }; + subscribeManyMock.mockReturnValue(innerCloser); + + const pool = createPool({ relays: [...SEED_RELAYS] }); + const handle = pool.subscribe({ + filters: [{ kinds: [38172] }], + onEvent: () => {}, + }); + + expect(typeof handle.close).toBe("function"); + expect(subscribeManyMock).toHaveBeenCalledTimes(1); + expect(subscribeManyMock.mock.calls[0]?.[0]).toEqual([...SEED_RELAYS]); + expect(subscribeManyMock.mock.calls[0]?.[1]).toEqual({ kinds: [38172] }); + + handle.close(); + expect(innerCloser.close).toHaveBeenCalledTimes(1); + + // Double-close is safe. + handle.close(); + expect(innerCloser.close).toHaveBeenCalledTimes(1); + }); + + it("dispatches one subscription per filter entry", () => { + subscribeManyMock.mockReturnValue({ close: vi.fn() }); + + const pool = createPool({ relays: ["wss://example.test"] }); + pool.subscribe({ + filters: [{ kinds: [38172] }, { kinds: [38173] }, { kinds: [38000] }], + onEvent: () => {}, + }); + + expect(subscribeManyMock).toHaveBeenCalledTimes(3); + }); + + it("forwards events from subscribeMany's onevent to user callback with a relay url", () => { + let capturedOnevent: ((e: unknown) => void) | undefined; + subscribeManyMock.mockImplementation( + (_relays: string[], _filter: unknown, params: { onevent: (e: unknown) => void }) => { + capturedOnevent = params.onevent; + return { close: () => {} }; + }, + ); + + const received: Array<{ eventId: string; relay: string }> = []; + const pool = createPool({ relays: ["wss://a.test", "wss://b.test"] }); + pool.subscribe({ + filters: [{ kinds: [38000] }], + onEvent: (event, relay) => received.push({ eventId: event.id, relay }), + }); + + // Simulate an event delivery. seenOn maps event id -> set of relay-like objects. + const evt = { + id: "abc", + pubkey: "pk", + created_at: 1, + kind: 38000, + tags: [], + content: "", + sig: "", + }; + seenOnMock.set("abc", new Set([{ url: "wss://a.test" }])); + capturedOnevent?.(evt); + + expect(received).toHaveLength(1); + expect(received[0]?.eventId).toBe("abc"); + expect(received[0]?.relay).toBe("wss://a.test"); + }); + + it("close() forwards the configured relay list to SimplePool.close", () => { + subscribeManyMock.mockReturnValue({ close: vi.fn() }); + + const relays = ["wss://one.test", "wss://two.test"]; + const pool = createPool({ relays }); + pool.close(); + + expect(closeMock).toHaveBeenCalledTimes(1); + expect(closeMock.mock.calls[0]?.[0]).toEqual(relays); + }); + + it("does not mutate the caller's relay array", () => { + subscribeManyMock.mockReturnValue({ close: vi.fn() }); + + const relays = ["wss://one.test"]; + const pool = createPool({ relays }); + relays.push("wss://mutated.test"); + pool.subscribe({ filters: [{ kinds: [1] }], onEvent: () => {} }); + + // First call should have used the original single-entry list. + expect(subscribeManyMock.mock.calls[0]?.[0]).toEqual(["wss://one.test"]); + }); +}); diff --git a/packages/core/src/nostr/pool.ts b/packages/core/src/nostr/pool.ts new file mode 100644 index 0000000..b276ad1 --- /dev/null +++ b/packages/core/src/nostr/pool.ts @@ -0,0 +1,98 @@ +import type { Event as NostrEvent } from "nostr-tools/core"; +import type { Filter } from "nostr-tools/filter"; +import { SimplePool } from "nostr-tools/pool"; + +/** + * Seed relay pool. Two of these (nos.lol + relay.damus.io) cover 98.4% of + * all historical NIP-87 events per the empirical relay survey at + * /srv/forge/projects/bitcoinmints/audit/relay-strategy-v1.md. + * + * relay.primal.net is included for: + * - authorless kind-10002 lookups + * - real-time live events (carries live traffic even when NIP-87 backlog is thin) + */ +export const SEED_RELAYS: readonly string[] = [ + "wss://nos.lol", + "wss://relay.damus.io", + "wss://relay.primal.net", +]; + +export type PoolConfig = { + relays: string[]; +}; + +export type SubscribeOptions = { + /** One or more filters. Each filter is dispatched as its own subscription. */ + filters: Filter[]; + /** Called for each matching event; `relay` is the wss:// URL that delivered it. */ + onEvent: (event: NostrEvent, relay: string) => void; + /** Called once per relay when end-of-stored-events is received. */ + onEose?: (relay: string) => void; + /** If true, close the subscription after all relays signal EOSE. Default: false (live). */ + closeOnEose?: boolean; +}; + +export type PoolHandle = { + /** Close all subscriptions created by this handle. Safe to call repeatedly. */ + close: () => void; +}; + +export type Pool = { + /** Open a subscription across the configured relays for the given filters. */ + subscribe: (opts: SubscribeOptions) => PoolHandle; + /** Close all relay connections managed by this pool. */ + close: () => void; +}; + +/** + * Thin wrapper around nostr-tools SimplePool. Intentionally omits signer, + * publish, and NIP-65 outbox logic — those are out of scope for the + * read-only directory. See PR #2 scope notes for the reasoning. + */ +export function createPool(config: PoolConfig): Pool { + const pool = new SimplePool(); + const relays = [...config.relays]; + + return { + subscribe(opts: SubscribeOptions): PoolHandle { + const closers = opts.filters.map((filter) => + pool.subscribeMany(relays, filter, { + onevent: (event: NostrEvent) => { + // nostr-tools doesn't expose the delivering relay on the event + // directly in subscribeMany's onevent; use seenOn to look up + // which relay(s) reported this event id. + const seen = pool.seenOn.get(event.id); + const firstRelay = seen?.values().next().value?.url; + opts.onEvent(event, firstRelay ?? relays[0] ?? ""); + }, + oneose: opts.onEose + ? () => { + // subscribeMany signals oneose once after all relays EOSE + // (the relay URL is not provided — we emit a placeholder). + opts.onEose?.("*"); + if (opts.closeOnEose) { + for (const c of closers) c.close(); + } + } + : opts.closeOnEose + ? () => { + for (const c of closers) c.close(); + } + : undefined, + }), + ); + + let closed = false; + return { + close() { + if (closed) return; + closed = true; + for (const c of closers) c.close(); + }, + }; + }, + close() { + pool.close(relays); + }, + }; +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 39159d4..755bc86 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -3,5 +3,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: false, + setupFiles: ["./vitest.setup.ts"], }, }); diff --git a/packages/core/vitest.setup.ts b/packages/core/vitest.setup.ts new file mode 100644 index 0000000..c3fef20 --- /dev/null +++ b/packages/core/vitest.setup.ts @@ -0,0 +1,4 @@ +// Polyfill IndexedDB for Dexie cache tests. Required because vitest runs in +// Node, which has no native IndexedDB. fake-indexeddb/auto assigns the +// in-memory implementations to globalThis.indexedDB / IDBKeyRange / etc. +import "fake-indexeddb/auto";