From c402fc5ef89a08f50a8cb43e679768c3b021ec27 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Apr 2026 14:50:24 -0700 Subject: [PATCH 01/13] feat(app): Vite+React X-ray shell renders scheduler stats + ranked mints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First PR with a viewable browser surface for v2. NOT a designed UI — this is a raw data dump so PR #5's aggregates and the PR #2-#4 ingest pipeline can be eyeballed end-to-end before PR #7 layers actual UX. Package layout under `packages/app/`: - Vite + React 19 + TypeScript, Tailwind v3 LTS for monospace + padding, `dexie-react-hooks` for `useLiveQuery`, `@bitcoinmints/core` as a workspace dep. shadcn scaffolded (components.json + `cn` in `src/lib/utils.ts`) but no components used. Zustand installed, no slice yet — per the PR #6 spec both are installed-not-used so PR #7+ can drop them in without churn. - Single route: `` owns the scheduler lifecycle; `` renders the ranked aggregates; `` dumps every field the spec locks in (pubkey, d, kind, u, createdAt, verifiedBySignerBinding, reviewCount, ratedCount, avgRating, bayesianScore, updatedAt, mintInfo). Rows separated by `
`; empty state is a literal `no mints yet` line under the header. Scheduler boot in `App.tsx`: db + pool + fetcher + scheduler created at module load (once), `scheduler.start()` / `stop()` driven by a `useEffect`. Stats refreshed via a 500ms poll of `getStats()` so the counters advance even during quiet Dexie spells — `useLiveQuery` alone wouldn't re-render on `eventsReceived++` without a write. StrictMode is intentionally omitted at `createRoot` render time: the scheduler's `startReady`-sequenced stop drains the in-progress start, and a second (StrictMode-dev) start() would return the already-resolved promise without re-subscribing. Documented inline; PR #7+ can revisit. Core export bump: `export * from "./scheduler"` added to `packages/core/src/index.ts` so `createScheduler` / `Scheduler` / `SchedulerStats` are reachable from the app without subpath imports. No runtime behavior changes in core. Biome config: CSS override disabling `suspicious/noUnknownAtRules` for `@tailwind` directives. Scoped to `*.css` via an `overrides` entry so JS/TS lint is unchanged. Root scripts: add `dev` and `build` that dispatch to the app filter, so `bun dev` from the repo root serves `packages/app` per the PR #6 brief. Tests: 224 passing, unchanged — core untouched beyond the index.ts export, and the app has no test files in this PR. PR #7 will add app test patterns once there's UI worth snapshotting. Deviations from spec (all intentional, pre-cleared): - shadcn "installed but not used": deps (`class-variance-authority`, `clsx`, `tailwind-merge`) + `components.json` + `src/lib/utils.ts` with `cn` helper. No shadcn components added. - Zustand installed, no slice. Added as a `dependencies` entry, not imported anywhere. - mintInfo lookup is by `d` (the primary key in the v3 schema), not "by URL" as the brief's parenthetical suggested — the `mintInfo` table is `d`-keyed with `url` as a stored field, so a URL-first lookup would need a secondary index that doesn't exist yet. The behavior matches the brief's intent (one row per mint, rendered as pretty-printed JSON or `(none)`). No Vercel config (deferred to PR #8), no tests (deferred to PR #7), no dark/light toggle, no error boundaries, no navigation, no badges, no loading spinners — per the PR #6 explicit-non-goals list. Verified in a Playwright-driven Chromium session: - empty state renders: `scheduler stats` block with zero-valued JSON, `
`, `mints` header, `no mints yet`; - live state after ~15s of subscription: 2711 events received, 2546 accepted, 60 Layer A rejections (bot-spam), 81 parse rejections, 1 Layer B failure, 50 mints rendered sorted by bayesianScore DESC, every spec-locked field present per row. Reviewable by: `bun dev` from repo root → http://localhost:5173/. Co-Authored-By: Claude Opus 4.7 --- biome.json | 14 +- bun.lock | 199 ++++++++++++++++++++++- package.json | 4 +- packages/app/components.json | 21 +++ packages/app/index.html | 12 ++ packages/app/package.json | 22 +++ packages/app/postcss.config.js | 6 + packages/app/src/App.tsx | 72 ++++++++ packages/app/src/components/MintList.tsx | 89 ++++++++++ packages/app/src/components/MintRow.tsx | 68 ++++++++ packages/app/src/index.css | 3 + packages/app/src/index.ts | 1 - packages/app/src/lib/utils.ts | 11 ++ packages/app/src/main.tsx | 20 +++ packages/app/tailwind.config.js | 8 + packages/app/tsconfig.json | 8 + packages/app/vite.config.ts | 22 +++ packages/core/src/index.ts | 1 + 18 files changed, 577 insertions(+), 4 deletions(-) create mode 100644 packages/app/components.json create mode 100644 packages/app/index.html create mode 100644 packages/app/postcss.config.js create mode 100644 packages/app/src/App.tsx create mode 100644 packages/app/src/components/MintList.tsx create mode 100644 packages/app/src/components/MintRow.tsx create mode 100644 packages/app/src/index.css delete mode 100644 packages/app/src/index.ts create mode 100644 packages/app/src/lib/utils.ts create mode 100644 packages/app/src/main.tsx create mode 100644 packages/app/tailwind.config.js create mode 100644 packages/app/vite.config.ts diff --git a/biome.json b/biome.json index 2f50cf5..5024050 100644 --- a/biome.json +++ b/biome.json @@ -19,5 +19,17 @@ "formatter": { "quoteStyle": "double" } - } + }, + "overrides": [ + { + "includes": ["**/*.css"], + "linter": { + "rules": { + "suspicious": { + "noUnknownAtRules": "off" + } + } + } + } + ] } diff --git a/bun.lock b/bun.lock index 50e4e03..e79ea8c 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,25 @@ "packages/app": { "name": "@bitcoinmints/app", "version": "0.0.0", + "dependencies": { + "@bitcoinmints/core": "workspace:*", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dexie-react-hooks": "^4.4.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "tailwind-merge": "^3.5.0", + "zustand": "^5.0.12", + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^6.0.1", + "autoprefixer": "^10.5.0", + "postcss": "^8.5.10", + "tailwindcss": "^3.4.19", + "vite": "^8.0.8", + }, }, "packages/core": { "name": "@bitcoinmints/core", @@ -28,6 +47,8 @@ }, }, "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@biomejs/biome": ["@biomejs/biome@2.3.12", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.12", "@biomejs/cli-darwin-x64": "2.3.12", "@biomejs/cli-linux-arm64": "2.3.12", "@biomejs/cli-linux-arm64-musl": "2.3.12", "@biomejs/cli-linux-x64": "2.3.12", "@biomejs/cli-linux-x64-musl": "2.3.12", "@biomejs/cli-win32-arm64": "2.3.12", "@biomejs/cli-win32-x64": "2.3.12" }, "bin": { "biome": "bin/biome" } }, "sha512-AR7h4aSlAvXj7TAajW/V12BOw2EiS0AqZWV5dGozf4nlLoUF/ifvD0+YgKSskT0ylA6dY1A8AwgP8kZ6yaCQnA=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cO6fn+KiMBemva6EARDLQBxeyvLzgidaFRJi8G7OeRqz54kWK0E+uSjgFaiHlc3DZYoa0+1UFE8mDxozpc9ieg=="], @@ -56,8 +77,14 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@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=="], @@ -66,6 +93,12 @@ "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@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=="], @@ -98,7 +131,7 @@ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="], "@scure/base": ["@scure/base@2.0.0", "", {}, "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w=="], @@ -118,6 +151,12 @@ "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], + "@vitest/expect": ["@vitest/expect@4.1.4", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.4", "@vitest/utils": "4.1.4", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww=="], "@vitest/mocker": ["@vitest/mocker@4.1.4", "", { "dependencies": { "@vitest/spy": "4.1.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg=="], @@ -132,28 +171,98 @@ "@vitest/utils": ["@vitest/utils@4.1.4", "", { "dependencies": { "@vitest/pretty-format": "4.1.4", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw=="], + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "autoprefixer": ["autoprefixer@10.5.0", "", { "dependencies": { "browserslist": "^4.28.2", "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g=="], + + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + + "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001788", "", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "dexie": ["dexie@4.4.2", "", {}, "sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw=="], + "dexie-react-hooks": ["dexie-react-hooks@4.4.0", "", { "peerDependencies": { "dexie": ">=4.2.0-alpha.1 <5.0.0", "react": ">=16" } }, "sha512-ObLXBS5+4BJU8vtSvBx6b9fY6zZYgniAtwxzjCHsUQadgbqYN6935X2/1TWw4Rf2N1aZV1io5/ziox4vKuxABA=="], + + "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], + + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.340", "", {}, "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "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=="], + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -178,26 +287,80 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "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=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], + + "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + "postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], + "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], + + "postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="], + + "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], + + "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="], + + "postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], + + "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], + + "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], + + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rolldown": ["rolldown@1.0.0-rc.15", "", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -206,6 +369,18 @@ "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], + + "tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], + + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], + + "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], @@ -214,16 +389,38 @@ "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="], "vitest": ["vitest@4.1.4", "", { "dependencies": { "@vitest/expect": "4.1.4", "@vitest/mocker": "4.1.4", "@vitest/pretty-format": "4.1.4", "@vitest/runner": "4.1.4", "@vitest/snapshot": "4.1.4", "@vitest/spy": "4.1.4", "@vitest/utils": "4.1.4", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.4", "@vitest/browser-preview": "4.1.4", "@vitest/browser-webdriverio": "4.1.4", "@vitest/coverage-istanbul": "4.1.4", "@vitest/coverage-v8": "4.1.4", "@vitest/ui": "4.1.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="], + + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="], } } diff --git a/package.json b/package.json index 36078ff..2d220cd 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "check": "biome check .", "format": "biome format --write .", "typecheck": "bun --filter='*' run typecheck", - "test": "bun --filter='*' run test" + "test": "bun --filter='*' run test", + "dev": "bun --filter='@bitcoinmints/app' run dev", + "build": "bun --filter='@bitcoinmints/app' run build" }, "devDependencies": { "@biomejs/biome": "2.3.12", diff --git a/packages/app/components.json b/packages/app/components.json new file mode 100644 index 0000000..6296a74 --- /dev/null +++ b/packages/app/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/packages/app/index.html b/packages/app/index.html new file mode 100644 index 0000000..f90549b --- /dev/null +++ b/packages/app/index.html @@ -0,0 +1,12 @@ + + + + + + bitcoinmints — data dump (PR #6) + + +
+ + + diff --git a/packages/app/package.json b/packages/app/package.json index 48d3015..bd35930 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -4,7 +4,29 @@ "private": true, "type": "module", "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@bitcoinmints/core": "workspace:*", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dexie-react-hooks": "^4.4.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "tailwind-merge": "^3.5.0", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^6.0.1", + "autoprefixer": "^10.5.0", + "postcss": "^8.5.10", + "tailwindcss": "^3.4.19", + "vite": "^8.0.8" } } diff --git a/packages/app/postcss.config.js b/packages/app/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/packages/app/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx new file mode 100644 index 0000000..3aebd06 --- /dev/null +++ b/packages/app/src/App.tsx @@ -0,0 +1,72 @@ +import { + BitcoinmintsDB, + createMintInfoFetcher, + createPool, + createScheduler, + type Scheduler, + type SchedulerStats, + SEED_RELAYS, +} from "@bitcoinmints/core"; +import { type JSX, useEffect, useState } from "react"; +import { MintList } from "./components/MintList"; + +/** + * The one and only route. No router: a single `/` view dumping everything + * Dexie has. + * + * Boot discipline: + * - db + pool + fetcher + scheduler are created exactly once at module + * load (outside the component) so React 19 StrictMode's double-invoke + * of effects in dev can't produce two schedulers fighting over the + * same Dexie. The effect body then does `scheduler.start()` on every + * mount and `scheduler.stop()` on every cleanup — the scheduler is + * idempotent across those calls (start resets `stopped`, stop drains + * in-flight Layer B work), so StrictMode's double-invoke produces + * start → stop → start exactly as intended rather than leaving us + * stuck after the first cleanup. + * + * Stats refresh: + * - getStats() returns a plain snapshot; we poll it on a 500ms ticker so + * the `
` at the top advances even when Dexie writes are quiet (the
+ *     Dexie-triggered `useLiveQuery` in MintList would otherwise be the
+ *     only re-render driver, and it only fires on row changes — not when
+ *     `eventsReceived` increments without a write).
+ */
+const db = new BitcoinmintsDB();
+const pool = createPool({ relays: [...SEED_RELAYS] });
+const fetcher = createMintInfoFetcher({ concurrency: 4 });
+const scheduler: Scheduler = createScheduler({
+  db,
+  pool,
+  fetcher,
+  relays: SEED_RELAYS,
+});
+
+const STATS_POLL_MS = 500;
+
+export function App(): JSX.Element {
+  const [stats, setStats] = useState(scheduler.getStats());
+
+  useEffect(() => {
+    void scheduler.start();
+    const handle = window.setInterval(() => {
+      setStats(scheduler.getStats());
+    }, STATS_POLL_MS);
+    return () => {
+      window.clearInterval(handle);
+      // Fire-and-forget stop(): app unmount means page navigation away or
+      // dev-HMR, either way we want the subscription closed. We don't await
+      // because React's effect-cleanup contract is synchronous.
+      void scheduler.stop();
+    };
+  }, []);
+
+  return (
+    
+
scheduler stats
+
{JSON.stringify(stats, null, 2)}
+
+ +
+ ); +} diff --git a/packages/app/src/components/MintList.tsx b/packages/app/src/components/MintList.tsx new file mode 100644 index 0000000..8c3e892 --- /dev/null +++ b/packages/app/src/components/MintList.tsx @@ -0,0 +1,89 @@ +import { + type AnnouncementRow, + type BitcoinmintsDB, + type MintAggregateRow, + type MintInfoRow, + rankMints, +} from "@bitcoinmints/core"; +import { useLiveQuery } from "dexie-react-hooks"; +import type { JSX } from "react"; +import { MintRow } from "./MintRow"; + +/** + * The whole list surface for PR #6 — spec is raw field dump per mint, + * sorted by `bayesianScore` DESC via `rankMints(db, 50)`. We pair each + * aggregate with its announcement row via a `useLiveQuery` per row (see + * MintRow); the join stays naive per the brief ("keep the join naive — a + * per-row useLiveQuery for mintInfo is fine for v1"). + */ +type Props = { + db: BitcoinmintsDB; +}; + +export function MintList({ db }: Props): JSX.Element { + // Order: rankMints() returns aggregates sorted by bayesianScore DESC. + // A mint with no reviews yet has no aggregate row, so this list can + // lag behind `announcements` — that's intentional. PR #7 will decide + // whether to render un-reviewed announcements as a tail section; for + // the X-ray we follow the ranked-aggregate-as-truth posture. + const aggregates = useLiveQuery( + () => rankMints(db, 50), + [db], + [], + ); + + // Empty state per spec: stats block still renders (that's in App.tsx), + // the `mints` header always renders, and if there's nothing to show the + // single line `no mints yet` sits below it. + if (aggregates.length === 0) { + return ( + <> +
mints
+
no mints yet
+ + ); + } + + return ( + <> +
mints
+ {aggregates.map((agg, i) => ( + + ))} + + ); +} + +/** + * Thin wrapper that joins aggregate → announcement → mintInfo via the + * live-query hook. Announcement is queried by `d` (first match wins; + * NIP-01 replaceable semantics mean there's only one current row per + * [pubkey, kind, d], but a single d-tag CAN appear for multiple pubkeys + * in-the-wild — PR #7 will surface that ambiguity properly, for now we + * render whichever comes out of the index). + */ +function MintRowWithLookup({ + db, + aggregate, + isLast, +}: { + db: BitcoinmintsDB; + aggregate: MintAggregateRow; + isLast: boolean; +}): JSX.Element { + const announcement = useLiveQuery( + () => db.announcements.where("d").equals(aggregate.d).first(), + [db, aggregate.d], + ); + const info = useLiveQuery( + () => db.mintInfo.get(aggregate.d), + [db, aggregate.d], + ); + + return ; +} diff --git a/packages/app/src/components/MintRow.tsx b/packages/app/src/components/MintRow.tsx new file mode 100644 index 0000000..37ef61f --- /dev/null +++ b/packages/app/src/components/MintRow.tsx @@ -0,0 +1,68 @@ +import type { AnnouncementRow, MintAggregateRow, MintInfoRow } from "@bitcoinmints/core"; +import type { JSX } from "react"; + +/** + * Raw per-mint dump — one field per line, monospace, no formatting beyond + * labels + JSON.stringify. Spec (PR #6 brief) locks every field: + * + * pubkey: ... + * d: ... + * kind: ... + * u: [...] // full array JSON, no truncation + * createdAt: + * verifiedBySignerBinding: + * reviewCount: ... // 0 if no aggregate row + * ratedCount: ... + * avgRating: ... + * bayesianScore: ... + * aggregate.updatedAt: ... + * mintInfo: // or `mintInfo: (none)` if no row + * + * Rows separated by
. + */ +type Props = { + aggregate: MintAggregateRow; + announcement: AnnouncementRow | undefined; + info: MintInfoRow | undefined; + /** + * Final row in the list gets no trailing
. Keeping the decision with + * the row itself so the parent stays a dumb `.map()`. + */ + isLast: boolean; +}; + +export function MintRow({ aggregate, announcement, info, isLast }: Props): JSX.Element { + // Announcement fields come from the joined row. If the announcement is + // still undefined the aggregate exists without an announcement — possible + // when the review lands first and the mint announcement hasn't arrived + // yet. We render the aggregate fields anyway so the X-ray shows the + // dangling state rather than hiding it. + const pubkey = announcement?.pubkey ?? "(no announcement)"; + const d = aggregate.d; + const kind = announcement?.kind ?? "(no announcement)"; + const u = announcement?.u ?? []; + const createdAt = announcement?.createdAt ?? "(no announcement)"; + const verifiedBySignerBinding = announcement + ? String(announcement.verifiedBySignerBinding) + : "(no announcement)"; + + const mintInfoLine = info ? `mintInfo: ${JSON.stringify(info, null, 2)}` : "mintInfo: (none)"; + + return ( + <> +
pubkey: {pubkey}
+
d: {d}
+
kind: {String(kind)}
+
u: {JSON.stringify(u)}
+
createdAt: {String(createdAt)}
+
verifiedBySignerBinding: {verifiedBySignerBinding}
+
reviewCount: {aggregate.reviewCount}
+
ratedCount: {aggregate.ratedCount}
+
avgRating: {String(aggregate.avgRating)}
+
bayesianScore: {aggregate.bayesianScore}
+
aggregate.updatedAt: {aggregate.updatedAt}
+
{mintInfoLine}
+ {!isLast &&
} + + ); +} diff --git a/packages/app/src/index.css b/packages/app/src/index.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/packages/app/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts deleted file mode 100644 index cb0ff5c..0000000 --- a/packages/app/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/packages/app/src/lib/utils.ts b/packages/app/src/lib/utils.ts new file mode 100644 index 0000000..f1f0551 --- /dev/null +++ b/packages/app/src/lib/utils.ts @@ -0,0 +1,11 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +/** + * shadcn-style class-name merge helper. Not used in PR #6 (data-dump + * X-ray has no designed UI), but committed as part of the shadcn init so + * PR #7+ can drop in `shadcn add ` without reshaping imports. + */ +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} diff --git a/packages/app/src/main.tsx b/packages/app/src/main.tsx new file mode 100644 index 0000000..94013d9 --- /dev/null +++ b/packages/app/src/main.tsx @@ -0,0 +1,20 @@ +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./index.css"; + +const rootEl = document.getElementById("root"); +if (!rootEl) { + throw new Error("root element not found"); +} + +// StrictMode is intentionally omitted. The scheduler's idempotency is +// sequenced around `startReady` so stop() must await the in-progress +// start() before closing the handle; StrictMode's double-invoke of +// effects ends up no-opping the second start (the stopped flag latches +// true after cleanup 1 completes inside start's microtask-chained body) +// which leaves the app with no active relay subscription in dev. Not a +// correctness bug in the scheduler — it's doing what its contract says — +// but the useEffect dance for sync start + async stop under double-invoke +// is not worth the churn for a data-dump X-ray. Production mounts once. +// PR #7+ can revisit this if a more robust teardown pattern is needed. +createRoot(rootEl).render(); diff --git a/packages/app/tailwind.config.js b/packages/app/tailwind.config.js new file mode 100644 index 0000000..93aa364 --- /dev/null +++ b/packages/app/tailwind.config.js @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json index bf5a36d..291db67 100644 --- a/packages/app/tsconfig.json +++ b/packages/app/tsconfig.json @@ -1,4 +1,12 @@ { "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "paths": { + "@/*": ["./src/*"] + } + }, "include": ["src/**/*"] } diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts new file mode 100644 index 0000000..8696102 --- /dev/null +++ b/packages/app/vite.config.ts @@ -0,0 +1,22 @@ +import path from "node:path"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +/** + * Vite config for the bitcoinmints X-ray app. + * + * This PR (#6) is the data-dump verifier — we want Vite + React + the + * workspace `@bitcoinmints/core` on the module graph and nothing more. + * PR #7+ will layer designed UI on top. + * + * The `@/*` alias matches shadcn's components.json so future scaffolding + * (PR #7+) drops in without reshaping imports. + */ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + }, + }, +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 302e547..faba74b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,5 +3,6 @@ export * from "./cashu"; export * from "./nip87"; export * from "./nostr"; export * from "./reviews"; +export * from "./scheduler"; export const VERSION = "0.0.0"; From 14b2755f5d4d11335bfe207606e1ddcb3e6e7e4a Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Apr 2026 15:04:35 -0700 Subject: [PATCH 02/13] fix(app): unified join query eliminates post-reload placeholder flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the per-row `useLiveQuery` in `MintRowWithLookup` into a single `useLiveQuery` at the `` level that pre-joins aggregate → announcement → mintInfo and hands `` fully-resolved props. Previously the aggregate query resolved first and each per-row join resolved a microtask later, causing a ~1s "(no announcement)" flash after a page reload. One query, one render, no flash. Co-Authored-By: Claude Opus 4.7 --- packages/app/src/components/MintList.tsx | 81 ++++++++++++------------ 1 file changed, 39 insertions(+), 42 deletions(-) diff --git a/packages/app/src/components/MintList.tsx b/packages/app/src/components/MintList.tsx index 8c3e892..0356900 100644 --- a/packages/app/src/components/MintList.tsx +++ b/packages/app/src/components/MintList.tsx @@ -11,23 +11,48 @@ import { MintRow } from "./MintRow"; /** * The whole list surface for PR #6 — spec is raw field dump per mint, - * sorted by `bayesianScore` DESC via `rankMints(db, 50)`. We pair each - * aggregate with its announcement row via a `useLiveQuery` per row (see - * MintRow); the join stays naive per the brief ("keep the join naive — a - * per-row useLiveQuery for mintInfo is fine for v1"). + * sorted by `bayesianScore` DESC via `rankMints(db, 50)`. + * + * Join strategy: one unified `useLiveQuery` at this level pre-joins + * aggregate → announcement → mintInfo and hands `` fully-resolved + * props. Previously we had a per-row `useLiveQuery` which resolved a + * microtask after the aggregate query, causing a ~1s "(no announcement)" + * placeholder flash on reload. Single query kills the flash. */ type Props = { db: BitcoinmintsDB; }; +type JoinedRow = { + aggregate: MintAggregateRow; + announcement: AnnouncementRow | undefined; + info: MintInfoRow | undefined; +}; + export function MintList({ db }: Props): JSX.Element { // Order: rankMints() returns aggregates sorted by bayesianScore DESC. // A mint with no reviews yet has no aggregate row, so this list can // lag behind `announcements` — that's intentional. PR #7 will decide // whether to render un-reviewed announcements as a tail section; for // the X-ray we follow the ranked-aggregate-as-truth posture. - const aggregates = useLiveQuery( - () => rankMints(db, 50), + const rows = useLiveQuery( + async () => { + const aggregates = await rankMints(db, 50); + const ds = aggregates.map((a) => a.d); + const announcements = await db.announcements.where("d").anyOf(ds).toArray(); + const infos = await db.mintInfo.bulkGet(ds); + // A single `d` CAN map to multiple announcements (different pubkeys). + // Map.set keeps whichever appears LAST in `toArray()`; that matches + // the previous per-row `.first()` behavior only by luck-of-insert-order. + // PR #7 will resolve the ambiguity properly. + const annByD = new Map(announcements.map((a) => [a.d, a])); + // bulkGet returns an array in the same order as the keys; index align. + return aggregates.map((agg, i) => ({ + aggregate: agg, + announcement: annByD.get(agg.d), + info: infos[i], + })); + }, [db], [], ); @@ -35,7 +60,7 @@ export function MintList({ db }: Props): JSX.Element { // Empty state per spec: stats block still renders (that's in App.tsx), // the `mints` header always renders, and if there's nothing to show the // single line `no mints yet` sits below it. - if (aggregates.length === 0) { + if (rows.length === 0) { return ( <>
mints
@@ -47,43 +72,15 @@ export function MintList({ db }: Props): JSX.Element { return ( <>
mints
- {aggregates.map((agg, i) => ( - ( + ))} ); } - -/** - * Thin wrapper that joins aggregate → announcement → mintInfo via the - * live-query hook. Announcement is queried by `d` (first match wins; - * NIP-01 replaceable semantics mean there's only one current row per - * [pubkey, kind, d], but a single d-tag CAN appear for multiple pubkeys - * in-the-wild — PR #7 will surface that ambiguity properly, for now we - * render whichever comes out of the index). - */ -function MintRowWithLookup({ - db, - aggregate, - isLast, -}: { - db: BitcoinmintsDB; - aggregate: MintAggregateRow; - isLast: boolean; -}): JSX.Element { - const announcement = useLiveQuery( - () => db.announcements.where("d").equals(aggregate.d).first(), - [db, aggregate.d], - ); - const info = useLiveQuery( - () => db.mintInfo.get(aggregate.d), - [db, aggregate.d], - ); - - return ; -} From 60b57abc5e1124a2c32ffe8ed0e91e81e911ea34 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Apr 2026 15:05:31 -0700 Subject: [PATCH 03/13] fix(app): render-filter Cashu mints only (kind 38172) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec v1 is Cashu-only, but `rankMints` ranks all aggregates (including k=38173 Fedimint rows) and Fedimint mints dominate the top because they have more reviews. Filter out non-Cashu announcements at render time so the X-ray matches spec. Filter lives in ``, not `rankMints` — this is a UI-only concern, don't mutate core. Orphan aggregates (no announcement) are also dropped defensively. Co-Authored-By: Claude Opus 4.7 --- packages/app/src/components/MintList.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/MintList.tsx b/packages/app/src/components/MintList.tsx index 0356900..7d778c5 100644 --- a/packages/app/src/components/MintList.tsx +++ b/packages/app/src/components/MintList.tsx @@ -35,6 +35,12 @@ export function MintList({ db }: Props): JSX.Element { // lag behind `announcements` — that's intentional. PR #7 will decide // whether to render un-reviewed announcements as a tail section; for // the X-ray we follow the ranked-aggregate-as-truth posture. + // + // Render-filter note: we drop non-Cashu announcements (kind !== 38172) + // below so the X-ray matches spec v1 (Cashu-only). The parse layer still + // stores k=38173 (Fedimint) rows and `rankMints` still ranks them — PR + // #7+ may surface those elsewhere. Keep this filter in the UI; do NOT + // push it into `rankMints` (don't mutate core for a UI-only concern). const rows = useLiveQuery( async () => { const aggregates = await rankMints(db, 50); @@ -57,10 +63,15 @@ export function MintList({ db }: Props): JSX.Element { [], ); + // Render-only Cashu filter (see note above). Orphan aggregates (no + // announcement — shouldn't happen in practice) are also dropped + // defensively so we never flash a "(no announcement)" row. + const visible = rows.filter((r) => r.announcement !== undefined && r.announcement.kind === 38172); + // Empty state per spec: stats block still renders (that's in App.tsx), // the `mints` header always renders, and if there's nothing to show the // single line `no mints yet` sits below it. - if (rows.length === 0) { + if (visible.length === 0) { return ( <>
mints
@@ -72,13 +83,13 @@ export function MintList({ db }: Props): JSX.Element { return ( <>
mints
- {rows.map((row, i) => ( + {visible.map((row, i) => ( ))} From 195109b1032ce2645e85d8c5605d77f2ef017888 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Apr 2026 15:40:20 -0700 Subject: [PATCH 04/13] feat(core/nostr): expand seed relay set per audit v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop relay.8333.space (audit: defunct / handshake timeout) and add three audited secondaries: nostr.mom (8 × k38172), relay.nostr.wirednet.jp (4), relay.nostrplebs.com (2). Net +2 relays, now 7 total. Keeps the top-3 ecosystem-consensus firehose (nos.lol / damus / primal) and the cashu-branded holdover (relay.cashumints.space) untouched. Rationale: alchemist demo against the prior 5-relay seed surfaced 33 Fedimint announcements but only 1 Cashu on the wire. Widening to the power-law knee documented in audit/relay-strategy-v1.md §3 gives the Cashu render a real catch. Updates doc-comment above SEED_RELAYS to cite the audit and the pool.test.ts assertion to match. --- packages/core/src/nostr/pool.test.ts | 14 ++++++++++---- packages/core/src/nostr/pool.ts | 19 ++++++++++++++----- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/core/src/nostr/pool.test.ts b/packages/core/src/nostr/pool.test.ts index 5f50ada..9e758af 100644 --- a/packages/core/src/nostr/pool.test.ts +++ b/packages/core/src/nostr/pool.test.ts @@ -31,16 +31,22 @@ vi.mock("nostr-tools/pool", () => { import { createPool, SEED_RELAYS } from "./pool"; describe("SEED_RELAYS", () => { - it("exports exactly the five-relay default seed pool from the spec", () => { + it("exports exactly the seven-relay default seed pool from the spec", () => { // Top 3 are the ecosystem-consensus NIP-87 implementor defaults // (damus 6/6, nos.lol 5/6, primal 4/6 across 6 surveyed hardcoders). - // Last 2 are cashu-branded relays — thin on event count but part of the - // cashu community's curated NIP-87 surface. + // Next 3 are audited secondary relays with real k38172 event counts + // (nostr.mom 8, relay.nostr.wirednet.jp 4, relay.nostrplebs.com 2) + // — added to widen the Cashu catch past the power-law knee. Last is + // relay.cashumints.space (cashu-branded, thin on events but part of + // the community's curated NIP-87 surface). relay.8333.space was + // dropped per audit: defunct / handshake timeout. expect(SEED_RELAYS).toEqual([ "wss://nos.lol", "wss://relay.damus.io", "wss://relay.primal.net", - "wss://relay.8333.space", + "wss://nostr.mom", + "wss://relay.nostr.wirednet.jp", + "wss://relay.nostrplebs.com", "wss://relay.cashumints.space", ]); }); diff --git a/packages/core/src/nostr/pool.ts b/packages/core/src/nostr/pool.ts index 28ca6df..6304fb6 100644 --- a/packages/core/src/nostr/pool.ts +++ b/packages/core/src/nostr/pool.ts @@ -10,16 +10,25 @@ import { SimplePool } from "nostr-tools/pool"; * nos.lol + damus alone carry 98.4% of all historical NIP-87 events per * /srv/forge/projects/bitcoinmints/audit/relay-strategy-v1.md. * - * relay.8333.space + relay.cashumints.space are cashu-branded relays included - * for ecosystem citizenship — thin on event count but part of the cashu - * community's curated NIP-87 surface (8333 is cashu.me's extra default; - * cashumints.space appears in 2/6 implementor defaults). + * Extended with three audited secondary relays (nostr.mom 8 × k38172, + * relay.nostr.wirednet.jp 4, relay.nostrplebs.com 2) to widen the Cashu + * catch. The alchemist demo against the prior 5-relay seed found only 1 + * Cashu announcement on the wire — adding these pushes us past the + * power-law knee documented in the audit (§3 cumulative table). + * + * relay.cashumints.space is the sole cashu-branded holdover — thin on event + * count (only 4 historical events per audit) but part of the cashu + * community's curated NIP-87 surface. relay.8333.space was dropped: the + * audit reports a handshake timeout and classifies it as defunct despite + * matching the audit.8333 domain. */ export const SEED_RELAYS: readonly string[] = [ "wss://nos.lol", "wss://relay.damus.io", "wss://relay.primal.net", - "wss://relay.8333.space", + "wss://nostr.mom", + "wss://relay.nostr.wirednet.jp", + "wss://relay.nostrplebs.com", "wss://relay.cashumints.space", ]; From 16498f8cdb65bf98fc02c527d177ee53eba30c50 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Apr 2026 15:43:07 -0700 Subject: [PATCH 05/13] feat(core/scheduler): opt-in debug logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `debug?: boolean` option to SchedulerConfig (default false — zero perf cost when off). When enabled, the scheduler logs through console.log/console.warn with the stable `[scheduler]` prefix: - on start(): the filters array being sent to relays + the relay list - per event (after the switch branch resolves): kind, id[0:8], delivering relay, and the resolved path (accepted / rejected-layerA / rejected-parse / dropped / replaced) - per Layer B resolution: kind/id/url + verdict (verified / failed: / transient:) The pool callback already passes `(event, relay)` to its onEvent subscriber but the scheduler dropped the second arg. Thread it through onEvent and the nested log calls — surgical change, no rename/refactor of the handler body. Also export `getSubscribedKinds()` so UI consumers can render "filters in use" without duplicating the SUBSCRIBED_KINDS literal. Wire `debug` into App.tsx from `?debug` URL search param (presence wins; no env rebuild needed to flip at demo time). Test: add a single negative-case assertion — default config (debug unset) produces zero console.log calls across a full kind-0/38000/38172 pipeline run. Don't assert debug=true output verbatim — that log format is a demo/X-ray aid and brittle to style tweaks. Test count: 224 → 225. --- packages/app/src/App.tsx | 11 +++ packages/core/src/scheduler/index.test.ts | 51 +++++++++++ packages/core/src/scheduler/index.ts | 103 ++++++++++++++++++++-- 3 files changed, 159 insertions(+), 6 deletions(-) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 3aebd06..7c0283c 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -35,11 +35,22 @@ import { MintList } from "./components/MintList"; const db = new BitcoinmintsDB(); const pool = createPool({ relays: [...SEED_RELAYS] }); const fetcher = createMintInfoFetcher({ concurrency: 4 }); +/** + * Debug logging is a demo/X-ray aid, toggled via `?debug` on the URL (any + * presence wins; no value parsing). When enabled, the scheduler logs its + * filters/relays on start(), a per-event path line, and a per-Layer-B + * verdict line — all through `console.log`/`console.warn` with the + * `[scheduler]` prefix. Deliberately URL-toggled (not env-baked) so an + * alchemist can flip it on during a live demo without rebuilding. + */ +const DEBUG_SCHEDULER = + typeof window !== "undefined" && new URLSearchParams(window.location.search).has("debug"); const scheduler: Scheduler = createScheduler({ db, pool, fetcher, relays: SEED_RELAYS, + debug: DEBUG_SCHEDULER, }); const STATS_POLL_MS = 500; diff --git a/packages/core/src/scheduler/index.test.ts b/packages/core/src/scheduler/index.test.ts index 518f335..31ce019 100644 --- a/packages/core/src/scheduler/index.test.ts +++ b/packages/core/src/scheduler/index.test.ts @@ -788,3 +788,54 @@ describe("scheduler — backoff cap", () => { await sched.stop(); }); }); + +describe("scheduler — debug logging opt-in", () => { + // We assert the negative-case contract: debug unset (and debug=false, + // exercised via the default) produces ZERO console.log calls from the + // scheduler. We don't assert the `debug=true` output verbatim — that + // log format is a demo/X-ray aid and asserting exact strings would + // brittle the tests against formatting tweaks. + it("default (debug unset) produces no scheduler console.log across a full pipeline run", async () => { + const db = await freshDB(); + const { pool, pushEvent } = makeFakePool(); + const pubkey = "02".padEnd(66, "7"); + const { fetcher } = makeFetcher({ "https://mint.example.com": pubkey }); + const sched = createScheduler({ db, pool, fetcher, relays: ["wss://test"] }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + await sched.start(); + // Exercise each kind's path so any path-level log would fire. + await pushEvent(makeAnnouncement({ pubkey, d: pubkey, u: ["https://mint.example.com"] })); + await pushEvent({ + id: "review-debug", + kind: 38000, + pubkey: "reviewer".padEnd(64, "0"), + created_at: 1_700_000_000, + tags: [ + ["k", "38172"], + ["d", pubkey], + ["rating", "5", "5"], + ], + content: "[5/5] fine", + sig: "fake", + }); + await pushEvent({ + id: "profile-debug", + kind: 0, + pubkey: "profile".padEnd(64, "0"), + created_at: 1_700_000_001, + tags: [], + content: "{}", + sig: "fake", + }); + await settle(); + await sched.stop(); + + // Zero calls: confirms debug-off is a pure no-op for logging cost. + expect(logSpy).not.toHaveBeenCalled(); + } finally { + logSpy.mockRestore(); + } + }); +}); diff --git a/packages/core/src/scheduler/index.ts b/packages/core/src/scheduler/index.ts index 2f8bb62..1e8fc36 100644 --- a/packages/core/src/scheduler/index.ts +++ b/packages/core/src/scheduler/index.ts @@ -134,11 +134,39 @@ export type SchedulerConfig = { relays: readonly string[]; /** Optional clock injector for deterministic backoff tests. Defaults to Date.now. */ now?: () => number; + /** + * Opt-in per-event debug logging. Default `false` — zero perf cost when + * off (no allocations, no logs). + * + * When `true`, logs through `console.log` / `console.warn` with the + * stable `[scheduler]` prefix: + * - on start(): the filters array being sent to relays + the configured + * relay list + * - per event (after the switch branch resolves): kind, id prefix, + * delivering relay, and the resolved path (accepted / rejected-* + * / dropped / replaced) + * - per Layer B resolution: kind/id/url + verdict (verified / + * failed: / transient) + * + * Keep the surface console-only — no structured logger is wired through + * the package. Intended as a demo/X-ray aid, not production telemetry. + */ + debug?: boolean; }; /** NIP-87 + supporting kinds. See data-model-v1.md §1 for the full list. */ const SUBSCRIBED_KINDS = [38172, 38173, 38000, 0, 10002] as const; +/** + * Expose the subscribed kinds tuple for UI consumers that want to render + * "filters in use" without duplicating the literal. Frozen through + * `as const` in the declaration above, so callers cannot mutate the + * underlying array. + */ +export function getSubscribedKinds(): readonly number[] { + return SUBSCRIBED_KINDS; +} + /** Initial backoff window for a failed mint URL: attempts=0 → 30s. */ const BASE_BACKOFF_MS = 30_000; /** Cap at 1 hour. */ @@ -269,6 +297,7 @@ function toRelayListRow(event: NostrEvent): RelayListRow | null { export function createScheduler(config: SchedulerConfig): Scheduler { const { db, pool, fetcher } = config; const now = config.now ?? Date.now; + const debug = config.debug ?? false; const stats: SchedulerStats = { eventsReceived: 0, @@ -552,6 +581,23 @@ export function createScheduler(config: SchedulerConfig): Scheduler { } else { stats.layerBFailed += 1; } + + if (debug) { + // Verdict mirrors `verdictForPersistence` shape: verified=true → + // verified; pubkey-mismatch → failed:; anything else → + // transient (the row stays null and will be retried). + let verdict: string; + if (result.verified) { + verdict = "verified"; + } else if (typeof result.reason === "string" && result.reason.startsWith("pubkey-mismatch")) { + verdict = `failed:${result.reason}`; + } else { + verdict = `transient:${result.reason ?? "unknown"}`; + } + console.log( + `[scheduler] layerB kind=38172 id=${row.eventId.slice(0, 8)} url=${persistedUrl} verdict=${verdict}`, + ); + } } /** @@ -590,8 +636,20 @@ export function createScheduler(config: SchedulerConfig): Scheduler { * gap). Log surface matches `reenqueueUnverified`'s existing pattern: * a `[scheduler]`-prefixed console call, no structured logger is wired * through the package yet. + * + * `relay` is the wss:// URL that delivered the event, threaded through + * from the pool's `onEvent(event, relay)` callback purely so the + * opt-in debug log line can include it. It is NOT otherwise used by + * the scheduler (single global watermark, no per-relay bookkeeping). */ - async function onEvent(event: NostrEvent): Promise { + // path labels for the debug per-event line. Kept narrow so we can't + // typo a path name and have it silently fall through. + type EventPath = "accepted" | "rejected-layerA" | "rejected-parse" | "dropped" | "replaced"; + const logPath = (kind: number, eventId: string, relay: string, path: EventPath): void => { + if (!debug) return; + console.log(`[scheduler] kind=${kind} id=${eventId.slice(0, 8)} relay=${relay} path=${path}`); + }; + async function onEvent(event: NostrEvent, relay: string): Promise { if (stopped) return; stats.eventsReceived += 1; @@ -600,11 +658,16 @@ export function createScheduler(config: SchedulerConfig): Scheduler { case 38173: { try { const parsed = parseMintAnnouncement(event); - if (!parsed) return; + if (!parsed) { + logPath(event.kind, event.id, relay, "dropped"); + return; + } const row = toAnnouncementRow(parsed); const result = await upsertAnnouncement(db, row); if (result === "rejected-invalid") { stats.rejectedByLayerA += 1; + logPath(event.kind, event.id, relay, "rejected-layerA"); + return; } if (result === "inserted" || result === "replaced") { stats.accepted += 1; @@ -615,7 +678,12 @@ export function createScheduler(config: SchedulerConfig): Scheduler { if (event.kind === 38172) { enqueueLayerB(row); } + logPath(event.kind, event.id, relay, result === "replaced" ? "replaced" : "accepted"); + return; } + // rejected-stale or any other terminal upsert result: count as + // a drop so the trace doesn't go silent on duplicates. + logPath(event.kind, event.id, relay, "dropped"); } catch (err) { stats.handlerErrors += 1; console.error("[scheduler] handler error", { @@ -638,17 +706,22 @@ export function createScheduler(config: SchedulerConfig): Scheduler { // — neither should reach here in a healthy pipeline but both // are silent drops worth counting (silent-failure gap). stats.rejectedByParse += 1; + logPath(event.kind, event.id, relay, "rejected-parse"); return; } const result = await upsertReviewWithAggregate(db, row, now); if (result === "inserted" || result === "replaced") { stats.accepted += 1; updateWatermark(event.kind, event.created_at); + logPath(event.kind, event.id, relay, result === "replaced" ? "replaced" : "accepted"); } else if (result === "rejected-invalid") { // Layer A gate on reviews: pointing at a bot-spam d-tag. Count // under the same stats bucket as the announcement Layer A // rejection — it's the same firewall. stats.rejectedByLayerA += 1; + logPath(event.kind, event.id, relay, "rejected-layerA"); + } else { + logPath(event.kind, event.id, relay, "dropped"); } } catch (err) { stats.handlerErrors += 1; @@ -663,11 +736,17 @@ export function createScheduler(config: SchedulerConfig): Scheduler { case 0: { try { const row = toProfileRow(event); - if (!row) return; + if (!row) { + logPath(event.kind, event.id, relay, "dropped"); + return; + } const result = await upsertProfile(db, row); if (result === "inserted" || result === "replaced") { stats.accepted += 1; updateWatermark(event.kind, event.created_at); + logPath(event.kind, event.id, relay, result === "replaced" ? "replaced" : "accepted"); + } else { + logPath(event.kind, event.id, relay, "dropped"); } } catch (err) { stats.handlerErrors += 1; @@ -682,11 +761,17 @@ export function createScheduler(config: SchedulerConfig): Scheduler { case 10002: { try { const row = toRelayListRow(event); - if (!row) return; + if (!row) { + logPath(event.kind, event.id, relay, "dropped"); + return; + } const result = await upsertRelayList(db, row); if (result === "inserted" || result === "replaced") { stats.accepted += 1; updateWatermark(event.kind, event.created_at); + logPath(event.kind, event.id, relay, result === "replaced" ? "replaced" : "accepted"); + } else { + logPath(event.kind, event.id, relay, "dropped"); } } catch (err) { stats.handlerErrors += 1; @@ -699,6 +784,7 @@ export function createScheduler(config: SchedulerConfig): Scheduler { return; } default: + logPath(event.kind, event.id, relay, "dropped"); return; // unknown kind — ignore } } @@ -745,15 +831,20 @@ export function createScheduler(config: SchedulerConfig): Scheduler { // duplicates CAS-fail at the cache layer. return since !== undefined ? { kinds: [kind], since } : { kinds: [kind] }; }); + if (debug) { + console.log( + `[scheduler] start — filters=${JSON.stringify(filters)} relays=${JSON.stringify(config.relays)}`, + ); + } handle = pool.subscribe({ filters, - onEvent: (event) => { + onEvent: (event, relay) => { // onEvent returns a promise; we don't await here because the // pool callback contract is sync. Each kind's case body wraps // its own try/catch that counts into stats.handlerErrors, so // a thrown Dexie transaction can't escape as an unhandled // rejection or silently freeze the stats. - void onEvent(event); + void onEvent(event, relay); }, closeOnEose: false, }); From e20fc590fdef9beda5a778681f0326308e4d2f02 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Apr 2026 15:44:15 -0700 Subject: [PATCH 06/13] feat(app): surface relay filters + validation paths in X-ray MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Below the existing scheduler stats `
` block and above the `
`, add three new blocks so the X-ray is self-documenting during demos: (A) filters in use — one line per subscribed kind, rendered from `getSubscribedKinds()` so the UI can never drift from scheduler/SUBSCRIBED_KINDS. Kinds 0 and 10002 carry the explicit "firehose — no authors" annotation flagged in PR #30 review — we want it visible, not footnoted. (B) relays — the SEED_RELAYS list, one per line. (C) validation paths — fixed-width reference table laying out kind → parser → Layer A gate → Layer B → counter path for every subscribed kind. Inline `
` string, no React table styling:
      stays terse and font-mono alongside the rest of the X-ray.

Also annotate the stats `
` with a one-liner that calls out
`layerBPending` as the only transient counter (enqueue↑ / complete↓).
The alchemist noticed it "going up then down" during the prior demo
and asked about it — inline note pre-empts the next ask.

No layout overhaul, no new deps, no Tailwind components.
---
 packages/app/src/App.tsx | 79 ++++++++++++++++++++++++++++++++++++++++
 1 file changed, 79 insertions(+)

diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx
index 7c0283c..ad4e99f 100644
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -3,6 +3,7 @@ import {
   createMintInfoFetcher,
   createPool,
   createScheduler,
+  getSubscribedKinds,
   type Scheduler,
   type SchedulerStats,
   SEED_RELAYS,
@@ -10,6 +11,21 @@ import {
 import { type JSX, useEffect, useState } from "react";
 import { MintList } from "./components/MintList";
 
+/**
+ * Human labels for each subscribed kind, aligned with the validation-paths
+ * table below. The "firehose — no authors" annotation on kinds 0 and 10002
+ * matches PR #30's review callout: we subscribe without an `authors`
+ * restriction, so those filters are fundamentally unbounded. Visible in the
+ * X-ray so the demo doesn't need a footnote.
+ */
+const KIND_LABELS: Record = {
+  38172: "cashu announcements",
+  38173: "fedimint announcements",
+  38000: "reviews",
+  0: "profiles (firehose — no authors)",
+  10002: "relay lists (firehose — no authors)",
+};
+
 /**
  * The one and only route. No router: a single `/` view dumping everything
  * Dexie has.
@@ -72,12 +88,75 @@ export function App(): JSX.Element {
     };
   }, []);
 
+  // Static derivations for the X-ray — computed once per render, but the
+  // inputs are module-constants so React's reconciler is effectively a
+  // no-op on these blocks.
+  const kinds = getSubscribedKinds();
+  // The widest kind label is 5 chars (e.g. "38172"). Pad to align.
+  const filtersBlock = kinds
+    .map((k) => {
+      const label = KIND_LABELS[k] ?? "";
+      const padded = `{ kinds: [${String(k).padEnd(5, " ")}] }`;
+      return `  ${padded}     ${label}`;
+    })
+    .join("\n");
+  const relaysBlock = SEED_RELAYS.map((r) => `  ${r}`).join("\n");
+
   return (
     
scheduler stats
{JSON.stringify(stats, null, 2)}
+ {/* + Counter note: all scheduler stats are monotonically increasing + EXCEPT `layerBPending`, which is transient — it goes up on + enqueue and back down when Layer B completes. The alchemist + observed it "going up then down" during the prior demo; this + comment exists so the next viewer doesn't flag it as a bug. + */} +
counters: monotonic, except layerBPending (transient: enqueue↑ / complete↓)
+ +
+
filters in use
+
{filtersBlock}
+
relays
+
{relaysBlock}
+ +
+
validation paths
+
{VALIDATION_PATHS_TABLE}
+
); } + +/** + * Reference doc rendered in the X-ray — the kind → parser → gate → counter + * path for every subscribed kind. Kept as a plain string (not a React + * table) so it stays font-mono and terse alongside the other `
`
+ * blocks. Source of truth: scheduler/index.ts:onEvent switch.
+ *
+ * Hand-aligned fixed-width columns — edit with care. If a column grows
+ * past its width, widen the whole column rather than wrapping mid-row.
+ */
+const VALIDATION_PATHS_TABLE = `
+kind           parser                       Layer A gate                         Layer B                               counters
+─────          ──────                       ────────────                         ───────                               ────────
+38172 cashu    parseMintAnnouncement        upsertAnnouncement d-tag + spam      verifySignerBinding /v1/info ×        parse-null → drop
+               (needs d + ≥1 u)             check                                pubkey match                          rejected → rejectedByLayerA
+                                                                                                                       accepted → accepted + layerBPending
+
+38173 fedimint parseMintAnnouncement        upsertAnnouncement d-tag + spam      none                                  same as 38172 minus Layer B
+               (needs d + ≥1 u)             check
+
+38000 review   parseReview                  upsertReviewWithAggregate d-tag      none                                  parse-null → rejectedByParse
+                                            check                                                                      rejected → rejectedByLayerA
+                                                                                                                       accepted → accepted
+
+0 profile      toProfileRow                 upsertProfile                        none                                  null → drop
+                                                                                                                       accepted → accepted
+
+10002 relay    toRelayListRow               upsertRelayList                      none                                  same as kind 0
+list
+`;

From 07a5b1f114d608681db276ef637c847a00521d67 Mon Sep 17 00:00:00 2001
From: orveth 
Date: Fri, 17 Apr 2026 15:47:03 -0700
Subject: [PATCH 07/13] feat(app): extract readable fields, collapse raw JSON
 in MintRow

Reshape the X-ray MintRow into a two-tier disclosure: human-readable
NUT-06 fields surface at first glance (name, description, urls, rating,
score, verified, version, motd, contact), with the raw mintInfo /
announcement / aggregate JSON tucked into native 
collapsibles. Technical identifiers (d, pubkey, kind) stay visible but de-emphasized. Contact rendering defends against both the NUT-06 object-of-objects shape and the older tuple-of-tuples shape seen in the wild. Co-Authored-By: Claude Opus 4.7 --- packages/app/src/components/MintRow.tsx | 155 +++++++++++++++++------- 1 file changed, 112 insertions(+), 43 deletions(-) diff --git a/packages/app/src/components/MintRow.tsx b/packages/app/src/components/MintRow.tsx index 37ef61f..06ba539 100644 --- a/packages/app/src/components/MintRow.tsx +++ b/packages/app/src/components/MintRow.tsx @@ -2,66 +2,135 @@ import type { AnnouncementRow, MintAggregateRow, MintInfoRow } from "@bitcoinmin import type { JSX } from "react"; /** - * Raw per-mint dump — one field per line, monospace, no formatting beyond - * labels + JSON.stringify. Spec (PR #6 brief) locks every field: + * Two-tier X-ray render of a single mint. * - * pubkey: ... - * d: ... - * kind: ... - * u: [...] // full array JSON, no truncation - * createdAt: - * verifiedBySignerBinding: - * reviewCount: ... // 0 if no aggregate row - * ratedCount: ... - * avgRating: ... - * bayesianScore: ... - * aggregate.updatedAt: ... - * mintInfo: // or `mintInfo: (none)` if no row + * Tier 1 — human-readable top matter pulled out of `info.infoJson` (NUT-06): + * name, description, version, motd, contact, urls, rating/score/verified. * - * Rows separated by
. + * Tier 0 — de-emphasized technical identifiers (d, pubkey, kind) pinned below + * the top matter for X-ray debugging; deliberately small + faded, not + * user-facing content. + * + * Tier 2 — collapsed
blocks for the raw mintInfo, announcement, and + * aggregate JSON, so the full shape (including rawTags, sig, etc.) stays + * inspectable without drowning the first glance. + * + * Browser-native
disclosure — no dep, no state, no animation. */ type Props = { aggregate: MintAggregateRow; announcement: AnnouncementRow | undefined; info: MintInfoRow | undefined; - /** - * Final row in the list gets no trailing
. Keeping the decision with - * the row itself so the parent stays a dumb `.map()`. - */ + /** Final row in the list gets no trailing
. */ isLast: boolean; }; +/** + * Render the NUT-06 `contact` field. The spec says it's an array of + * `{ method, info }` objects, but mints in the wild have been seen emitting + * the older array-of-tuples shape (`[[method, info], ...]`). Handle both + * defensively — anything that doesn't match either shape is skipped rather + * than crashing the row render. + */ +function renderContact(contact: unknown): string[] { + if (!Array.isArray(contact)) return []; + const lines: string[] = []; + for (const entry of contact) { + if (Array.isArray(entry) && entry.length >= 2) { + // Legacy tuple shape: ["email", "foo@bar"]. + const [method, value] = entry; + if (typeof method === "string" && typeof value === "string") { + lines.push(`contact: ${method} ${value}`); + } + continue; + } + if (entry && typeof entry === "object") { + // NUT-06 object shape: { method, info }. + const obj = entry as Record; + const method = obj.method; + const value = obj.info; + if (typeof method === "string" && typeof value === "string") { + lines.push(`contact: ${method} ${value}`); + } + } + } + return lines; +} + export function MintRow({ aggregate, announcement, info, isLast }: Props): JSX.Element { - // Announcement fields come from the joined row. If the announcement is - // still undefined the aggregate exists without an announcement — possible - // when the review lands first and the mint announcement hasn't arrived - // yet. We render the aggregate fields anyway so the X-ray shows the - // dangling state rather than hiding it. + const body = info?.infoJson as Record | undefined; + + const name = typeof body?.name === "string" && body.name.length > 0 ? body.name : undefined; + const description = typeof body?.description === "string" ? body.description : undefined; + const version = typeof body?.version === "string" ? body.version : undefined; + const motd = typeof body?.motd === "string" ? body.motd : undefined; + const contactLines = renderContact(body?.contact); + + const urls = announcement?.u ?? []; + + const ratedCount = aggregate.ratedCount; + const reviewCount = aggregate.reviewCount; + const avg = aggregate.avgRating; + const ratingLine = + avg === null + ? `rating: — (${reviewCount} total, 0 rated)` + : `rating: ${avg}/5 (${ratedCount} ratings, ${reviewCount} total)`; + + const verifiedLabel = announcement + ? announcement.verifiedBySignerBinding === true + ? "verified" + : announcement.verifiedBySignerBinding === false + ? "unverified" + : "pending" + : "(no announcement)"; + + // Tier 0 identifiers — present even if announcement is missing so the + // dangling-aggregate state is still visible. const pubkey = announcement?.pubkey ?? "(no announcement)"; const d = aggregate.d; const kind = announcement?.kind ?? "(no announcement)"; - const u = announcement?.u ?? []; - const createdAt = announcement?.createdAt ?? "(no announcement)"; - const verifiedBySignerBinding = announcement - ? String(announcement.verifiedBySignerBinding) - : "(no announcement)"; - - const mintInfoLine = info ? `mintInfo: ${JSON.stringify(info, null, 2)}` : "mintInfo: (none)"; return ( <> -
pubkey: {pubkey}
-
d: {d}
-
kind: {String(kind)}
-
u: {JSON.stringify(u)}
-
createdAt: {String(createdAt)}
-
verifiedBySignerBinding: {verifiedBySignerBinding}
-
reviewCount: {aggregate.reviewCount}
-
ratedCount: {aggregate.ratedCount}
-
avgRating: {String(aggregate.avgRating)}
-
bayesianScore: {aggregate.bayesianScore}
-
aggregate.updatedAt: {aggregate.updatedAt}
-
{mintInfoLine}
+
name: {name ?? "(no name)"}
+ {description &&
description: {description}
} + {urls.map((url) => ( +
url: {url}
+ ))} +
{ratingLine}
+
score: {aggregate.bayesianScore}
+
verified: {verifiedLabel}
+ {version &&
version: {version}
} + {motd &&
motd: {motd}
} + {contactLines.map((line) => ( +
{line}
+ ))} + +
+
d: {d}
+
pubkey: {pubkey}
+
kind: {String(kind)}
+
+ + {info ? ( +
+ raw mintInfo +
{JSON.stringify(info, null, 2)}
+
+ ) : ( +
mintInfo: (none)
+ )} + {announcement && ( +
+ raw announcement +
{JSON.stringify(announcement, null, 2)}
+
+ )} +
+ raw aggregate +
{JSON.stringify(aggregate, null, 2)}
+
+ {!isLast &&
} ); From bfdae802f69797fe3e793ef6b90fc94ffd2b8d09 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Apr 2026 16:16:36 -0700 Subject: [PATCH 08/13] fix(core/nip87/dtag): relax Cashu d-tag regex to accept curator-style tags Browser audit of 500 on-wire {kinds:[38172]} events from nos.lol showed 99.8% use 16-char random d-tags pointing at legitimate operational mints (mint.coinos.io, stablenut.umint.cash, cashu.boats, mint.lnvoltz.com, etc). The earlier strict regex (64-char x-only OR 66-char compressed secp256k1) rejected the entire real ecosystem bar one zero-review mint. Decision (gudnuf, 2026-04-17): the URL is the mint's identity, not the d-tag. Accept any non-empty printable-ASCII d-tag up to 256 chars; Layer A now only rejects empty / oversized / non-printable garbage. URL + Layer B signer binding are the real verification gates. - D_TAG_REGEX: /^[\x20-\x7E]{1,256}$/ - FEDIMINT_D_TAG_REGEX unchanged (still 64-char hex) - Updated callsite comments in upsert.ts + scheduler/index.ts to reflect the new semantics and drop stale "bot spam" framing - Retitled review-parse/types docstrings Co-Authored-By: Claude Opus 4.7 --- packages/core/src/cache/upsert.ts | 39 +++++++++--------- packages/core/src/nip87/dtag.ts | 54 ++++++++++++------------- packages/core/src/nip87/parse.test.ts | 8 ++-- packages/core/src/nip87/types.ts | 10 +++-- packages/core/src/reviews/parse.test.ts | 6 +-- packages/core/src/reviews/parse.ts | 2 +- packages/core/src/scheduler/index.ts | 9 +++-- 7 files changed, 64 insertions(+), 64 deletions(-) diff --git a/packages/core/src/cache/upsert.ts b/packages/core/src/cache/upsert.ts index ff50a2e..209fe4e 100644 --- a/packages/core/src/cache/upsert.ts +++ b/packages/core/src/cache/upsert.ts @@ -13,12 +13,13 @@ * 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) uses a sibling shape gate (isValidFedimintDTag) — every real - * federation ID in the audit corpus is 64-char lowercase hex, so short / - * junk d-tags with `["k","38173"]` are still filtered at the same choke - * point as Cashu bot spam. + * isValidCashuDTag(d). Post-2026-04-17 the Cashu gate only rejects empty / + * oversized / non-printable-ASCII d-tags — the earlier strict pubkey regex + * rejected 99.8% of real on-wire events (see dtag.ts for the relaxation + * note). URL + Layer B signer binding are the real verification gates. + * kind:38173 (Fedimint) uses a sibling shape gate (isValidFedimintDTag) — + * every real federation ID in the audit corpus is 64-char lowercase hex, + * and that gate is unchanged by the Cashu relaxation. * * mintInfo and mintAggregate aren't event-based, so their CAS predicate * is a monotonically-increasing timestamp: `fetchedAt` for mintInfo, @@ -64,10 +65,10 @@ export async function upsertAnnouncement( row: AnnouncementRow, ): Promise { // Layer A gate — reject invalid d-tag shapes before touching the DB. - // Cashu (38172) requires a 64- or 66-char secp256k1 pubkey shape; - // Fedimint (38173) requires a 64-char lowercase hex federation-id shape. - // A short/junk d-tag with `k=38173` slapped on is still bot spam and - // must be caught by the same firewall — don't free-pass by kind alone. + // Cashu (38172) accepts any non-empty printable-ASCII d-tag up to 256 + // chars (post-2026-04-17 relaxation — see dtag.ts). Fedimint (38173) + // requires a 64-char lowercase hex federation-id shape — unchanged by + // the Cashu relaxation; junk d-tags with k=38173 still get filtered. if (row.kind === 38173) { if (!isValidFedimintDTag(row.d)) return "rejected-invalid"; } else if (!isValidCashuDTag(row.d)) { @@ -100,16 +101,14 @@ export async function upsertAnnouncement( * * The review's `d` points at a mint. When `k === 38172` (or `k` is absent, * which is how most in-the-wild Cashu reviews shape), we apply the same - * Layer A d-regex gate that `upsertAnnouncement` uses — if the referenced - * mint pubkey isn't 64/66-char hex, the review is bot-spam pointing at - * bot-spam, returned as `rejected-invalid`. This is the firewall that - * keeps the 959 zero-d-tag bot spam events (per relay-strategy §4) from - * filtering up into the ranking aggregate. + * Layer A gate that `upsertAnnouncement` uses — post-relaxation, that + * means rejecting only empty / oversized / non-printable d-tags. URL + + * Layer B are the real verification gates on the mint side; here we just + * ensure the d pointer itself is a well-formed string. * * `k === 38173` (Fedimint) switches to the sibling `isValidFedimintDTag` * shape gate — every real federation ID in the audit corpus is lowercase - * 64-char hex, so a short / junk d-tag with `k=38173` attached is still - * bot spam and must be caught by the same firewall. + * 64-char hex. This gate is unchanged by the Cashu relaxation. * * Note: this low-level upsert is the mechanical write. It does NOT * materialize the `mintAggregate` row — the `reviews/` wrapper composes @@ -122,9 +121,9 @@ export async function upsertReview(db: BitcoinmintsDB, row: ReviewRow): Promise< // Layer A gate — reject invalid d-tag shapes before touching the DB. // Reviews point at a target mint via `d`; the pointer-kind `k` selects // which shape gate applies. No `k` tag → treat as Cashu (the default - // for in-the-wild events per rating-tag-research §3). Fedimint rows - // still get a sibling shape check (64-char hex federation id) so junk - // d-tags with `k=38173` slapped on don't free-pass the firewall. + // for in-the-wild events per rating-tag-research §3). The Cashu gate is + // relaxed (any non-empty printable ASCII); the Fedimint gate remains + // 64-char hex. if (row.k === 38173) { if (!isValidFedimintDTag(row.d)) return "rejected-invalid"; } else if (!isValidCashuDTag(row.d)) { diff --git a/packages/core/src/nip87/dtag.ts b/packages/core/src/nip87/dtag.ts index 9b84b32..9bdc7ff 100644 --- a/packages/core/src/nip87/dtag.ts +++ b/packages/core/src/nip87/dtag.ts @@ -1,41 +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. + * Empirical finding (PR #32 browser demo, 2026-04-17): of 500 on-wire + * `{kinds:[38172]}` events from nos.lol, 499 use 16-char random d-tags + * and point at legitimate operational mints — mint.coinos.io, + * stablenut.umint.cash, cashu.boats, mint.lnvoltz.com, etc. The earlier + * "bot spam with fabricated d-tags" reading was wrong: one non-spec + * curator publishes real mint URLs under random d-tags. A strict regex + * (64-char x-only or 66-char compressed secp256k1) rejects 99.8% of the + * real ecosystem — only sharegap.net passes, and it has zero reviews. * - * 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 + * Decision (gudnuf, 2026-04-17): relax d-tag shape filtering. The URL + * is the mint's identity, not the d-tag. Trust Layer B (NUT-06 signer + * binding via /v1/info) + URL as the real verification gate; use Layer A + * only to reject unambiguous garbage (empty, oversized, non-printable). * - * 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. + * Later analysis (deferred) will re-examine the on-wire corpus and may + * tighten this back once we understand the shape distribution. */ -// 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})$/; +// Accept any non-empty printable-ASCII d-tag up to 256 chars. Rejects only +// empty, oversized (>256), or non-printable/high-byte garbage. Chosen over +// [A-Za-z0-9_\-] because real curator d-tags in the wild mix unexpected +// characters and we'd rather gate on URL + Layer B than relitigate shape. +export const D_TAG_REGEX = /^[\x20-\x7E]{1,256}$/; /** * Fedimint federation-id d-tag shape. Every real Fedimint federation ID * observed in the audit corpus (see `audit/fedimint-observer.md` and * `packages/core/src/reviews/corpus.test.ts`) is lowercase 64-char hex — * the blake3 hash of the federation's consensus public key, serialized as - * 32 bytes of hex. A short/junk d-tag with `k=38173` slapped on is bot - * spam, not a federation, and must be rejected by the same Layer A - * firewall that catches 16-char Cashu bot spam. + * 32 bytes of hex. This gate is unchanged by the 2026-04-17 Cashu + * relaxation; a short/junk d-tag with `k=38173` slapped on is still not + * a federation and gets rejected here. * * Keeping this sibling to `D_TAG_REGEX` so both Layer A shape gates live * in one file — a reviewer touching one will see the other immediately. @@ -43,8 +39,10 @@ export const D_TAG_REGEX = /^([0-9a-f]{64}|0[23][0-9a-f]{64})$/; export const FEDIMINT_D_TAG_REGEX = /^[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. + * True iff `d` is a non-empty printable-ASCII string up to 256 chars. + * Per the relaxation recorded on D_TAG_REGEX above: shape is no longer + * used to filter curator-style d-tags; URL + Layer B signer binding + * are the real verification gates. */ export function isValidCashuDTag(d: string): boolean { return D_TAG_REGEX.test(d); diff --git a/packages/core/src/nip87/parse.test.ts b/packages/core/src/nip87/parse.test.ts index a7dac25..c38e804 100644 --- a/packages/core/src/nip87/parse.test.ts +++ b/packages/core/src/nip87/parse.test.ts @@ -4,7 +4,7 @@ import fixtures from "./__fixtures__/nip87-sample.json" with { type: "json" }; import { parseMintAnnouncement, parseRecommendation } from "./parse"; type Fixture = { - cashu38172BotSpam: NostrEvent[]; + cashu38172Curator: NostrEvent[]; cashu38172Legacy: NostrEvent[]; cashu38172SpecConforming: NostrEvent[]; fedimint38173: NostrEvent[]; @@ -13,9 +13,9 @@ type Fixture = { 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]"); + it("parses a real kind:38172 curator event into the expected shape", () => { + const event = f.cashu38172Curator[0]; + if (!event) throw new Error("fixture missing cashu38172Curator[0]"); const parsed = parseMintAnnouncement(event); expect(parsed).not.toBeNull(); diff --git a/packages/core/src/nip87/types.ts b/packages/core/src/nip87/types.ts index 4e0a973..1777194 100644 --- a/packages/core/src/nip87/types.ts +++ b/packages/core/src/nip87/types.ts @@ -63,10 +63,12 @@ export type MintRecommendation = { 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. + * Target mint identifier — matches an announcement's d-tag. In the wild + * this is typically a 16-char curator-style tag (most common on nos.lol) + * or a 64-char secp256k1 pubkey; for Fedimint, a 64-char federation id. + * The parser is lenient and preserves whatever is there — shape gating + * happens at the cache layer (Layer A) with a permissive printable-ASCII + * rule; URL + Layer B are the real verification gates. */ d: string; /** Parsed 0..5 rating (inclusive). See parse.ts for format precedence. */ diff --git a/packages/core/src/reviews/parse.test.ts b/packages/core/src/reviews/parse.test.ts index f287aaf..d53721a 100644 --- a/packages/core/src/reviews/parse.test.ts +++ b/packages/core/src/reviews/parse.test.ts @@ -9,7 +9,7 @@ import { parseReview } from "./parse"; /** Realistic 64-char x-only Cashu d-tag. */ const D_VALID = "5fe928ae0970844f3c5253d2e85a88788486edcbd96c070334a4a2d0d0154a77"; -/** 16-char legacy / bot-spam d-tag. */ +/** 16-char curator-style d-tag (common on nos.lol, see dtag.ts relaxation note). */ const D_LEGACY_16 = "psvef0yh2zk24tt7"; function makeEvent(over: Partial & { tags?: string[][] } = {}): NostrEvent { @@ -410,8 +410,8 @@ describe("parseReview — u tag collection (display helper)", () => { }); describe("parseReview — parser is lenient on Layer A", () => { - it("16-char legacy d-tag still parses (gate is at upsert, not parse)", () => { - // The parser preserves whatever is there — bot-spam filtering is the + it("16-char curator-style d-tag still parses (gate is at upsert, not parse)", () => { + // The parser preserves whatever is there — shape filtering is the // cache layer's job. This keeps parser usable by raw-event log views. const row = parseReview( makeEvent({ diff --git a/packages/core/src/reviews/parse.ts b/packages/core/src/reviews/parse.ts index 919ebf9..ccf7313 100644 --- a/packages/core/src/reviews/parse.ts +++ b/packages/core/src/reviews/parse.ts @@ -29,7 +29,7 @@ * * Parse does NOT reject on Layer A d-shape — the upsert gate handles that * so the parser stays pure and callable from tests, pagination dedup, etc. - * Callers who want the bot-spam firewall use `upsertReviewWithAggregate`. + * Callers who want the Layer A shape firewall use `upsertReviewWithAggregate`. */ import type { Event as NostrEvent } from "nostr-tools/core"; import type { ReviewRow } from "../cache"; diff --git a/packages/core/src/scheduler/index.ts b/packages/core/src/scheduler/index.ts index 1e8fc36..37b3b96 100644 --- a/packages/core/src/scheduler/index.ts +++ b/packages/core/src/scheduler/index.ts @@ -92,7 +92,7 @@ export type SchedulerStats = { * `null` — either the `d` tag was missing/empty or the event was * unexpectedly not kind:38000. Counted separately from `rejectedByLayerA` * because it's a parser-level reject (malformed event) rather than a - * shape-gate reject (valid event pointing at bot-spam). + * shape-gate reject (valid event with a d-tag that fails the shape gate). */ rejectedByParse: number; layerBPending: number; @@ -715,9 +715,10 @@ export function createScheduler(config: SchedulerConfig): Scheduler { updateWatermark(event.kind, event.created_at); logPath(event.kind, event.id, relay, result === "replaced" ? "replaced" : "accepted"); } else if (result === "rejected-invalid") { - // Layer A gate on reviews: pointing at a bot-spam d-tag. Count - // under the same stats bucket as the announcement Layer A - // rejection — it's the same firewall. + // Layer A gate on reviews: the `d` pointer failed the shape + // gate (empty, oversized, or non-printable post-relaxation). + // Counted under the same stats bucket as the announcement + // Layer A rejection — it's the same firewall. stats.rejectedByLayerA += 1; logPath(event.kind, event.id, relay, "rejected-layerA"); } else { From 60fccd276b1b8d3e886fd5c39c31a6dc455f9eb3 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Apr 2026 16:16:46 -0700 Subject: [PATCH 09/13] test(core/nip87): rebaseline fixtures + tests for d-tag relaxation - Fixture: rename cashu38172BotSpam -> cashu38172Curator, update _meta notes to reflect the 2026-04-17 browser audit (16-char shape is a non-spec curator convention pointing at real mints, not bot spam). - dtag.test.ts: rewrite around the relaxed regex. Accepts curator-style 16-char tags, uppercase hex, short single-char tags, tags up to 256 chars. Rejects only empty, oversized, non-printable/high-byte bytes. - corpus.test.ts: all 8 Cashu announcements now accept at Layer A (5 curator + 1 legacy + 2 spec-conforming). - cache/upsert.test.ts: flip the bot-spam rejection assertion to an inserted assertion; add new rejection coverage for empty / oversized / non-printable d-tags so the gate coverage isn't lost. - reviews/upsert.test.ts: curator d-tag review now inserts; rename D_BOT -> D_CURATOR, add D_EMPTY coverage for the still-live gate. Co-Authored-By: Claude Opus 4.7 --- packages/core/src/cache/upsert.test.ts | 39 ++++- .../src/nip87/__fixtures__/nip87-sample.json | 10 +- packages/core/src/nip87/corpus.test.ts | 45 ++--- packages/core/src/nip87/dtag.test.ts | 158 ++++++------------ packages/core/src/reviews/upsert.test.ts | 21 ++- 5 files changed, 134 insertions(+), 139 deletions(-) diff --git a/packages/core/src/cache/upsert.test.ts b/packages/core/src/cache/upsert.test.ts index ecb0115..aa9fe7e 100644 --- a/packages/core/src/cache/upsert.test.ts +++ b/packages/core/src/cache/upsert.test.ts @@ -183,16 +183,47 @@ describe("upsertAnnouncement", () => { expect(fetched?.eventId).toBe(EID_HIGH); }); - it("rejects as invalid when kind:38172 has a 16-char bot-spam d-tag", async () => { + it("accepts kind:38172 with a 16-char curator-style d-tag (post-relaxation)", async () => { + // Pre-2026-04-17 this was a 'rejected-invalid' assertion. The browser + // audit showed 99.8% of on-wire kind:38172 events use this exact + // shape pointing at legitimate mints — the URL is the identity, not + // the d-tag. Layer A now only rejects empty / oversized / non-printable. const db = await freshDB(); - const bot = makeAnnouncement({ d: "abc123def4567890" }); + const curator = makeAnnouncement({ d: "abc123def4567890" }); - const result = await upsertAnnouncement(db, bot); + const result = await upsertAnnouncement(db, curator); + expect(result).toBe("inserted"); + expect(await db.announcements.count()).toBe(1); + }); + + it("still rejects kind:38172 with an empty d-tag (only unambiguous garbage)", async () => { + const db = await freshDB(); + const empty = makeAnnouncement({ d: "" }); + + const result = await upsertAnnouncement(db, empty); + expect(result).toBe("rejected-invalid"); + expect(await db.announcements.count()).toBe(0); + }); + + it("still rejects kind:38172 with an oversized (>256 char) d-tag", async () => { + const db = await freshDB(); + const oversized = makeAnnouncement({ d: "a".repeat(257) }); + + const result = await upsertAnnouncement(db, oversized); + expect(result).toBe("rejected-invalid"); + expect(await db.announcements.count()).toBe(0); + }); + + it("still rejects kind:38172 with a non-printable d-tag (control char)", async () => { + const db = await freshDB(); + const nonPrintable = makeAnnouncement({ d: "has\nnewline" }); + + const result = await upsertAnnouncement(db, nonPrintable); 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 () => { + it("inserts kind:38172 with a valid 64-char x-only d-tag", async () => { const db = await freshDB(); const row = makeAnnouncement({ d: D_XONLY }); diff --git a/packages/core/src/nip87/__fixtures__/nip87-sample.json b/packages/core/src/nip87/__fixtures__/nip87-sample.json index fbad80d..01ad4c8 100644 --- a/packages/core/src/nip87/__fixtures__/nip87-sample.json +++ b/packages/core/src/nip87/__fixtures__/nip87-sample.json @@ -5,15 +5,15 @@ "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 5 cashu38172Curator events share pubkey 972f233a... and use random 16-char d-tags. Originally filed as 'bot spam' based on the 2025-02-13 burst documented in audit/relay-strategy-v1.md §4; the 2026-04-17 browser audit of 500 on-wire events showed 99.8% of kind:38172 events on nos.lol use this exact shape pointing at legitimate operational mints (mint.coinos.io, stablenut.umint.cash, cashu.boats, mint.lnvoltz.com, etc). The shape is a non-spec curator convention, not bot spam; the URL is the mint's identity, not the d-tag.", + "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 NOT the mainstream shape — the 16-char curator form dominates the wild.", + "Layer A regex relaxed (PR #32 follow-up, 2026-04-17) to accept any non-empty printable-ASCII d-tag up to 256 chars. Accepted: all cashu38172Curator + cashu38172Legacy + cashu38172SpecConforming. URL + Layer B are the real verification gates.", "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.", + "Fedimint events are real and filtered to ones that include at least one u tag (required by the parser). Layer A for kind:38173 still enforces the 64-char hex federation-id shape via FEDIMINT_D_TAG_REGEX.", "Recommendations span three rating formats plus a no-rating case." ] }, - "cashu38172BotSpam": [ + "cashu38172Curator": [ { "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, diff --git a/packages/core/src/nip87/corpus.test.ts b/packages/core/src/nip87/corpus.test.ts index 5070e1a..a2d1d77 100644 --- a/packages/core/src/nip87/corpus.test.ts +++ b/packages/core/src/nip87/corpus.test.ts @@ -6,7 +6,7 @@ import { parseMintAnnouncement, parseRecommendation } from "./parse"; type Fixture = { _meta: Record; - cashu38172BotSpam: NostrEvent[]; + cashu38172Curator: NostrEvent[]; cashu38172Legacy: NostrEvent[]; cashu38172SpecConforming: NostrEvent[]; fedimint38173: NostrEvent[]; @@ -21,14 +21,14 @@ const f = fixtures as unknown as Fixture; */ describe("NIP-87 corpus", () => { it("has the expected event counts per bucket", () => { - expect(f.cashu38172BotSpam.length).toBe(5); + expect(f.cashu38172Curator.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.cashu38172Curator.length + f.cashu38172Legacy.length + f.cashu38172SpecConforming.length + f.fedimint38173.length + @@ -36,9 +36,9 @@ describe("NIP-87 corpus", () => { expect(total).toBe(16); }); - it("Layer A accepts spec-conforming AND x-only Cashu announcements, rejects bot spam", () => { + it("Layer A accepts all Cashu announcements post-relaxation (curator + legacy + spec-conforming)", () => { const all38172: NostrEvent[] = [ - ...f.cashu38172BotSpam, + ...f.cashu38172Curator, ...f.cashu38172Legacy, ...f.cashu38172SpecConforming, ]; @@ -53,27 +53,32 @@ describe("NIP-87 corpus", () => { 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); + // All 8 accepted post-relaxation: 5 curator (16-char) + 1 legacy (64-char) + // + 2 spec-conforming (66-char). Rejection is reserved for empty / oversized + // / non-printable garbage, none of which appear in the corpus. + expect(accepted.length).toBe(8); + expect(rejected.length).toBe(0); }); - it("Layer A rejects all 5 bot-spam events (16-char d-tags)", () => { - for (const e of f.cashu38172BotSpam) { + it("Layer A accepts all 5 curator events (16-char d-tags are legitimate)", () => { + for (const e of f.cashu38172Curator) { const parsed = parseMintAnnouncement(e); expect(parsed).not.toBeNull(); - expect(parsed && isValidCashuDTag(parsed.d)).toBe(false); + expect(parsed && isValidCashuDTag(parsed.d)).toBe(true); + // Sanity: the curator shape really is 16 chars. + expect(parsed?.d.length).toBe(16); } }); - 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("all curator events in the fixture belong to the 972f233a... publisher", () => { + const CURATOR_PUBKEY = "972f233aa467bc9804032c0bce0a117daead5473c56c91e811a216bdd08c08cf"; + const curatorPubkeyCount = f.cashu38172Curator.filter( + (e) => e.pubkey === CURATOR_PUBKEY, + ).length; + expect(curatorPubkeyCount).toBe(5); }); - it("Layer A accepts the 64-char x-only Nostrodomo announcement (de-facto mainstream shape)", () => { + it("Layer A accepts the 64-char x-only Nostrodomo announcement", () => { for (const e of f.cashu38172Legacy) { const parsed = parseMintAnnouncement(e); expect(parsed).not.toBeNull(); @@ -83,7 +88,7 @@ describe("NIP-87 corpus", () => { } }); - it("Layer A does NOT apply to Fedimint — all 3 parse, at least one has modules", () => { + it("Fedimint still uses its own (stricter) shape gate — unchanged by the Cashu relaxation", () => { const parsedFedi = f.fedimint38173 .map((e) => parseMintAnnouncement(e)) .filter((a): a is NonNullable => a !== null); @@ -97,8 +102,8 @@ describe("NIP-87 corpus", () => { 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. + // Intentionally don't call isValidCashuDTag on Fedimint events — the + // cashu regex isn't semantically applicable. } // At least one of the curated fixtures should have modules populated. diff --git a/packages/core/src/nip87/dtag.test.ts b/packages/core/src/nip87/dtag.test.ts index f8f4c4f..26557c5 100644 --- a/packages/core/src/nip87/dtag.test.ts +++ b/packages/core/src/nip87/dtag.test.ts @@ -1,139 +1,87 @@ 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", () => { +describe("isValidCashuDTag (post-relaxation)", () => { + // Per PR #32 follow-up: the Layer A d-tag shape gate was relaxed because + // 99.8% of real on-wire kind:38172 events use 16-char random d-tags + // pointing at legitimate mint URLs. The regex now accepts any non-empty + // printable-ASCII string up to 256 chars; URL + Layer B signer binding + // are the real verification gates. + + describe("accepts curator-style d-tags (real ecosystem shape)", () => { + it("accepts a 16-char random d-tag from the real curator burst", () => { + // These were incorrectly labeled "bot spam" before the 2026-04-17 + // browser audit. Real mints (mint.azzamo.net, mint.lnw.cash, etc.) + // publish under random 16-char d-tags. + expect(isValidCashuDTag("ewakfwchz6tmlmvy")).toBe(true); + expect(isValidCashuDTag("rp8l2ez6vw3t4u2j")).toBe(true); + expect(isValidCashuDTag("psvef0yh2zk24tt7")).toBe(true); + expect(isValidCashuDTag("abc123def4567890")).toBe(true); + }); + + it("accepts a 66-char compressed secp256k1 d-tag (spec-conforming)", () => { 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. + it("accepts a 64-char x-only secp256k1 d-tag (de-facto form)", () => { + // Nostrodomo Mint — real in-the-wild x-only pubkey d-tag. expect( - isValidCashuDTag("03c5f16604678b8b118a454db12885e586f0fc146788d54182b3ca7943a327278e"), + isValidCashuDTag("5fe928ae0970844f3c5253d2e85a88788486edcbd96c070334a4a2d0d0154a77"), ).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)", () => { + it("accepts uppercase hex — case-insensitive in post-relaxation", () => { + expect(isValidCashuDTag("A".repeat(64))).toBe(true); expect( - isValidCashuDTag("5fe928ae0970844f3c5253d2e85a88788486edcbd96c070334a4a2d0d0154a77"), + 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 short single-char d-tags (min boundary)", () => { + expect(isValidCashuDTag("a")).toBe(true); + expect(isValidCashuDTag("1")).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 d-tags at the 256-char maximum", () => { + expect(isValidCashuDTag("a".repeat(256))).toBe(true); }); - it("accepts the full hex alphabet in a 64-char d-tag", () => { - expect(isValidCashuDTag("0123456789abcdef".repeat(4))).toBe(true); + it("accepts d-tags with mixed alphanumerics + common ASCII punctuation", () => { + expect(isValidCashuDTag("mint-foo_bar.baz")).toBe(true); + expect(isValidCashuDTag("some+curator/path?q=1")).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); - }); - + describe("rejects only unambiguous garbage", () => { 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 too-long d-tag (>256 chars)", () => { + expect(isValidCashuDTag("a".repeat(257))).toBe(false); + expect(isValidCashuDTag("x".repeat(1024))).toBe(false); + }); + + it("rejects d-tags containing non-printable ASCII (control chars)", () => { + expect(isValidCashuDTag("hello\nworld")).toBe(false); + expect(isValidCashuDTag("\tindented")).toBe(false); + expect(isValidCashuDTag("null\0byte")).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("rejects d-tags containing high-byte / non-ASCII characters", () => { + // U+00A0 NO-BREAK SPACE (0xA0) is outside the printable-ASCII range. + expect(isValidCashuDTag("café-mint")).toBe(false); + expect(isValidCashuDTag("héllo")).toBe(false); + // Emoji is multi-byte non-ASCII. + expect(isValidCashuDTag("mint🚀")).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); + expect(D_TAG_REGEX.test("a")).toBe(true); + expect(D_TAG_REGEX.test("ewakfwchz6tmlmvy")).toBe(true); + expect(D_TAG_REGEX.test("")).toBe(false); + expect(D_TAG_REGEX.test("a".repeat(257))).toBe(false); }); }); diff --git a/packages/core/src/reviews/upsert.test.ts b/packages/core/src/reviews/upsert.test.ts index 45dfeeb..c07739c 100644 --- a/packages/core/src/reviews/upsert.test.ts +++ b/packages/core/src/reviews/upsert.test.ts @@ -36,7 +36,8 @@ async function freshDB(): Promise { } const D_VALID = "5fe928ae0970844f3c5253d2e85a88788486edcbd96c070334a4a2d0d0154a77"; -const D_BOT = "psvef0yh2zk24tt7"; // 16-char legacy/bot-spam shape. +const D_CURATOR = "psvef0yh2zk24tt7"; // 16-char curator shape — now valid post-relaxation. +const D_EMPTY = ""; // Empty d-tag — still rejected as unambiguous garbage. const EID_LOW = `${"0".repeat(60)}aaaa`; const EID_HIGH = `${"0".repeat(60)}ffff`; @@ -228,17 +229,27 @@ describe("upsertReviewWithAggregate — concurrent CAS + aggregate race", () => }); describe("upsertReviewWithAggregate — Layer A gate", () => { - it("16-char bot-spam d-tag → rejected-invalid, no review row, no aggregate row", async () => { + it("16-char curator-style d-tag → inserted post-relaxation (was rejected pre-2026-04-17)", async () => { + // Per the Layer A relaxation: curator-style d-tags are legitimate + // pointers at real mints. URL + Layer B are the real gates. const db = await freshDB(); - const result = await upsertReviewWithAggregate(db, makeReview({ d: D_BOT })); + const result = await upsertReviewWithAggregate(db, makeReview({ d: D_CURATOR })); + expect(result).toBe("inserted"); + expect(await db.reviews.count()).toBe(1); + expect(await db.mintAggregate.count()).toBe(1); + }); + + it("empty d-tag → still rejected-invalid (unambiguous garbage)", async () => { + const db = await freshDB(); + const result = await upsertReviewWithAggregate(db, makeReview({ d: D_EMPTY })); expect(result).toBe("rejected-invalid"); expect(await db.reviews.count()).toBe(0); expect(await db.mintAggregate.count()).toBe(0); }); - it("Fedimint k=38173 review with non-regex d bypasses the gate", async () => { + it("Fedimint k=38173 review with a valid 64-char federation id passes the sibling gate", async () => { const db = await freshDB(); - // A federation ID isn't constrained by the Cashu-mint-pubkey regex. + // The Fedimint gate is unchanged by the Cashu relaxation — still 64-char hex. const fediRow = makeReview({ d: "718e421be177486639330d198e870b7345ebd07b2866b5fd3797d73e4bc4c9af", k: 38173, From abf28316846d6e117880be070f5c88ecf11b7608 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Apr 2026 16:16:58 -0700 Subject: [PATCH 10/13] test(core/integration,scheduler): update pipeline counts for d-tag relaxation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the curator-style 16-char d-tags now accepted at Layer A, the corpus replay lands 11 announcements (5 curator + 1 legacy + 2 spec + 3 fedi) instead of 6, and 5 reviews instead of 3. Nothing gets rejected by the Layer A shape gate in the curated corpus. - integration.test.ts: parse->cache replay now expects 11 inserted + 0 rejected-invalid announcements, 5 inserted + 0 rejected-invalid reviews. Scheduler full-pipeline test: rejectedByLayerA=0, accepted=16, layerBVerified=2 (alpha/beta), layerBFailed=5 (4 curator URL 404s + 1 legacy pubkey-mismatch — second azzamo event short-circuits on per-URL backoff), mintInfo=7. Idempotency test: round-2 may grow mintInfo by 1 as reenqueueUnverified bulk-enqueues cleared backoff. - scheduler/index.test.ts: rename "bot-spam" test to use a non-printable d (newline embedded), add a positive test pinning that 16-char curator d-tags accept and bump `accepted`. - "Layer A enforced at cache, not parser" block: flip the 5-bot-spam assertion to 5-curator-accepted; add fresh "rejects unambiguous garbage" coverage (empty / oversized / non-printable) so the gate isn't untested at the integration layer. Co-Authored-By: Claude Opus 4.7 --- packages/core/src/integration.test.ts | 220 ++++++++++++++-------- packages/core/src/scheduler/index.test.ts | 40 +++- 2 files changed, 183 insertions(+), 77 deletions(-) diff --git a/packages/core/src/integration.test.ts b/packages/core/src/integration.test.ts index 69ad30b..310cd8a 100644 --- a/packages/core/src/integration.test.ts +++ b/packages/core/src/integration.test.ts @@ -27,7 +27,7 @@ import { createScheduler } from "./scheduler"; type Fixture = { _meta: Record; - cashu38172BotSpam: NostrEvent[]; + cashu38172Curator: NostrEvent[]; cashu38172Legacy: NostrEvent[]; cashu38172SpecConforming: NostrEvent[]; fedimint38173: NostrEvent[]; @@ -104,7 +104,7 @@ function toReviewRow(parsed: NonNullable> */ async function replayCorpus(db: BitcoinmintsDB) { const all38172: NostrEvent[] = [ - ...f.cashu38172BotSpam, + ...f.cashu38172Curator, ...f.cashu38172Legacy, ...f.cashu38172SpecConforming, ]; @@ -140,7 +140,7 @@ describe("integration: corpus replay → parse → cache", () => { // Sanity: every event in the corpus has a result entry. expect(announcementResults.length).toBe( - f.cashu38172BotSpam.length + + f.cashu38172Curator.length + f.cashu38172Legacy.length + f.cashu38172SpecConforming.length + f.fedimint38173.length, @@ -152,41 +152,38 @@ describe("integration: corpus replay → parse → cache", () => { 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; + // Per the fixture's _meta.notes (post-relaxation): + // "Accepted: all cashu38172Curator + cashu38172Legacy + cashu38172SpecConforming." + // Plus all 3 Fedimint events pass their sibling 64-char hex gate. + // Accepted = 5 (Curator 16-char) + 1 (Legacy x-only) + 2 (SpecConforming compressed) + 3 (Fedimint) = 11 + // Rejected by Layer A = 0 (all corpus d-tags are well-formed printable ASCII). + const acceptedCashuLayerA = + f.cashu38172Curator.length + 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); + const expectedRejected = 0; + expect(expectedAccepted).toBe(11); + expect(expectedRejected).toBe(0); // 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). + // Result-stream assertions: every event lands as 'inserted' post-relaxation + // (each has unique [pubkey,kind,d]); nothing rejected by Layer A. 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, but Layer A applies to the - // reviews' `d` tag too (PR #5) — 2 of the 5 point at 16-char legacy - // d-tags that pre-date the Cashu-mint-pubkey d-tag convention and - // would be indistinguishable from the bot-spam shape the gate is - // designed to reject. Those are `rejected-invalid`. The remaining 3 - // reference real 64-char Cashu mint pubkeys and insert cleanly. + // Reviews: all 5 recommendations parse and all 5 insert post-relaxation. + // 2 point at 16-char curator d-tags (previously rejected as bot-spam); + // 3 point at 64-char pubkeys/federation-ids. URL + Layer B are the real + // verification gates now. const reviewsInserted = reviewResults.filter((r) => r.result === "inserted"); const reviewsRejectedInvalid = reviewResults.filter((r) => r.result === "rejected-invalid"); - expect(reviewsInserted.length).toBe(3); - expect(reviewsRejectedInvalid.length).toBe(2); - expect(await db.reviews.count()).toBe(3); + expect(reviewsInserted.length).toBe(5); + expect(reviewsRejectedInvalid.length).toBe(0); + expect(await db.reviews.count()).toBe(5); }); it("the legacy Nostrodomo (64-char x-only) lands as inserted, not rejected-invalid", async () => { @@ -275,29 +272,69 @@ describe("integration: CAS convergence under simulated multi-relay race", () => }); 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. + it("curator events parse successfully and are accepted post-relaxation", async () => { + // Pin the design choice: parser is lenient, the cache is the gate. Post- + // 2026-04-17 relaxation, curator-style 16-char d-tags are accepted — the + // URL + Layer B are the real verification gates. This test used to + // assert rejection; the browser audit showed the 16-char shape points + // at real mints. We keep the test as a spot-check that the cache gate + // still runs (just with a different verdict for this shape). const db = await freshDB(); let parsedCount = 0; - let rejectedAtCacheCount = 0; - for (const e of f.cashu38172BotSpam) { + let acceptedCount = 0; + for (const e of f.cashu38172Curator) { 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); + // Curator d-tags are 16-char random printable ASCII — regex matches. + expect(isValidCashuDTag(parsed.d)).toBe(true); const result = await upsertAnnouncement(db, toAnnouncementRow(parsed)); - // The cache is where Layer A bites. - expect(result).toBe("rejected-invalid"); - rejectedAtCacheCount++; + expect(result).toBe("inserted"); + acceptedCount++; } - expect(parsedCount).toBe(f.cashu38172BotSpam.length); - expect(rejectedAtCacheCount).toBe(f.cashu38172BotSpam.length); - // Nothing landed despite all 5 parsing successfully — design contract held. + expect(parsedCount).toBe(f.cashu38172Curator.length); + expect(acceptedCount).toBe(f.cashu38172Curator.length); + expect(await db.announcements.count()).toBe(f.cashu38172Curator.length); + }); + + it("the cache gate still rejects unambiguous garbage (empty / oversized / non-printable)", async () => { + // The Layer A gate is still live for the narrow cases that survive the + // relaxation. Constructing synthetic events rather than mining fixtures + // because the corpus intentionally doesn't carry junk d-tags. + const db = await freshDB(); + const pubkey = "0".repeat(64); + + // Empty d — rejected. + const emptyDEvent = { ...(f.cashu38172Curator[0] as NostrEvent) }; + const emptyTags = (emptyDEvent.tags ?? []).map((t) => (t[0] === "d" ? ["d", ""] : t)); + const emptyEvent: NostrEvent = { ...emptyDEvent, pubkey, tags: emptyTags }; + const emptyParsed = parseMintAnnouncement(emptyEvent); + // parseMintAnnouncement currently returns null for missing/empty d — + // so the gate doesn't even get to run. That's fine; assert via a row + // we construct directly. + expect(emptyParsed).toBeNull(); + const emptyRow = { + pubkey, + kind: 38172 as const, + d: "", + eventId: "e".repeat(64), + createdAt: 1_700_000_000, + u: ["https://mint.example"], + content: "", + rawTags: [] as string[][], + verifiedBySignerBinding: null, + }; + expect(await upsertAnnouncement(db, emptyRow)).toBe("rejected-invalid"); + + // Oversized d — rejected. + const oversizedRow = { ...emptyRow, d: "a".repeat(257), eventId: "f".repeat(64) }; + expect(await upsertAnnouncement(db, oversizedRow)).toBe("rejected-invalid"); + + // Non-printable d — rejected. + const nonPrintableRow = { ...emptyRow, d: "has\nnewline", eventId: "9".repeat(64) }; + expect(await upsertAnnouncement(db, nonPrintableRow)).toBe("rejected-invalid"); + expect(await db.announcements.count()).toBe(0); }); @@ -420,7 +457,7 @@ function makeCorpusFetcher(): MintInfoFetcher { */ async function pushCashuCorpus(pushEvent: (e: NostrEvent) => Promise): Promise { const allCashu: NostrEvent[] = [ - ...f.cashu38172BotSpam, + ...f.cashu38172Curator, ...f.cashu38172Legacy, ...f.cashu38172SpecConforming, ]; @@ -440,30 +477,34 @@ describe("integration: scheduler full pipeline", () => { await pushCashuCorpus(pushEvent); await drainLayerB(sched); - // Stats: same accept/reject as the parse → cache integration above - // (5 bot-spam rejected at Layer A; 1 legacy + 2 spec-conforming + 3 - // fedimint accepted = 6 announcements; 3 reviews accepted + 2 reviews - // rejected for 16-char legacy d-tags per PR #5's Layer A review gate). + // Stats post-relaxation: every corpus event has a well-formed d-tag, + // so Layer A rejects nothing. 11 announcements + 5 reviews = 16 + // accepted. Layer B still distinguishes verified from failed. const stats = sched.getStats(); - // 11 announcements (5 spam + 1 legacy + 2 spec + 3 fedi) + 5 reviews = 16. + // 11 announcements (5 curator + 1 legacy + 2 spec + 3 fedi) + 5 reviews = 16. expect(stats.eventsReceived).toBe(16); - // 5 announcement bot-spam rejections + 2 review 16-char d-tag rejections. - expect(stats.rejectedByLayerA).toBe(7); - // Accepted = 6 announcements + 3 reviews = 9. - expect(stats.accepted).toBe(9); - - // Layer B: spec-conforming Alpha + Beta verify. Legacy Nostrodomo - // returns ok but with the wrong pubkey → counts as failed. Fedimint - // is non-cashu and doesn't enqueue Layer B at all. + // No Layer A rejections — all corpus d-tags are well-formed printable ASCII. + expect(stats.rejectedByLayerA).toBe(0); + // Accepted = 11 announcements + 5 reviews = 16. + expect(stats.accepted).toBe(16); + + // Layer B: spec-conforming Alpha + Beta verify against the fetcher + // mapping. Legacy Nostrodomo (sharegap) returns pubkey-mismatch. + // Curator URLs (azzamo×2 / lnw / 21mint / cashu.boats) aren't in the + // fetcher mapping → 404 → all-fetches-failed (transient). Only 4 + // distinct curator URLs exist in the fixture (azzamo appears twice); + // the second azzamo enqueue hits the per-URL backoff cooldown and + // short-circuits without running Layer B again, so only 4 curator + // attempts actually register. Fedimint is non-cashu and doesn't + // enqueue Layer B at all. expect(stats.layerBVerified).toBe(2); - expect(stats.layerBFailed).toBe(1); + expect(stats.layerBFailed).toBe(5); expect(stats.layerBPending).toBe(0); - // Cache state matches the parse → cache test exactly: 6 announcements, - // 3 reviews (2 more reviews rejected by PR #5's Layer A on reviews' - // d-tags). Bot-spam rejected at Layer A, never lands. - expect(await db.announcements.count()).toBe(6); - expect(await db.reviews.count()).toBe(3); + // Cache state: all 11 announcements land (5 curator + 1 legacy + 2 spec + // + 3 fedi). All 5 reviews insert post-relaxation. + expect(await db.announcements.count()).toBe(11); + expect(await db.reviews.count()).toBe(5); // Spot-check verifiedBySignerBinding wired through correctly. const alphaPubkey = "02aa00000000000000000000000000000000000000000000000000000000000001"; @@ -485,8 +526,10 @@ describe("integration: scheduler full pipeline", () => { expect(fedimintRow).toBeDefined(); expect(fedimintRow?.verifiedBySignerBinding).toBeNull(); - // mintInfo rows: 2 ok (Alpha, Beta) + 1 !ok (Legacy mismatch). - expect(await db.mintInfo.count()).toBe(3); + // mintInfo rows: 2 ok (Alpha, Beta) + 1 !ok (Legacy pubkey-mismatch) + // + 4 !ok (curator events — 4 distinct URLs, second azzamo skipped via + // backoff cooldown, so no row written for curator[2]). + expect(await db.mintInfo.count()).toBe(7); const alphaInfo = await db.mintInfo.get(alphaPubkey); expect(alphaInfo?.ok).toBe(true); expect(alphaInfo?.url).toBe("https://mint.alpha.test"); @@ -531,16 +574,24 @@ describe("integration: scheduler full pipeline", () => { mintInfo: await db.mintInfo.count(), fetches: calls1.length, }; - expect(round1Counts.announcements).toBe(6); - // 3 reviews (2 more gated out by PR #5's Layer A on review d-tags). - expect(round1Counts.reviews).toBe(3); - expect(round1Counts.mintInfo).toBe(3); + // 11 announcements (5 curator + 1 legacy + 2 spec + 3 fedi). + expect(round1Counts.announcements).toBe(11); + // 5 reviews post-relaxation (curator d-tags accepted). + expect(round1Counts.reviews).toBe(5); + // 2 verified ok + 1 legacy pubkey-mismatch + 4 curator 404 rows + // (second azzamo skipped via backoff cooldown, no row written). + expect(round1Counts.mintInfo).toBe(7); // Round 2 — fresh scheduler against same DB. createScheduler reads // the watermarks from the cache; the corpus replay uses the same // events (same createdAt), so every announcement upsert lands as - // 'rejected-stale' (next.createdAt is NOT > prev.createdAt) which - // means Layer B is not re-enqueued, so calls2 stays at 0. + // 'rejected-stale' (next.createdAt is NOT > prev.createdAt). On + // startup, reenqueueUnverified finds rows with + // verifiedBySignerBinding === null (the 5 curator events whose + // Layer B hit 404 = all-fetches-failed = transient) and re-enqueues + // them — so calls2 will have the 5 curator URLs re-fetched. The + // alpha/beta (verified=true) and legacy (verified=false, pubkey- + // mismatch) rows are NOT re-enqueued. const { pool: pool2, pushEvent: push2 } = makeFakePool(); const calls2: string[] = []; const fetcher2: MintInfoFetcher = (url) => { @@ -558,14 +609,35 @@ describe("integration: scheduler full pipeline", () => { await drainLayerB(sched2); await sched2.stop(); - // Same row counts — no duplicates introduced by the replay. + // Same announcement + review counts — no duplicates introduced by replay. expect(await db.announcements.count()).toBe(round1Counts.announcements); expect(await db.reviews.count()).toBe(round1Counts.reviews); - expect(await db.mintInfo.count()).toBe(round1Counts.mintInfo); - - // No second-round Layer B fetches: each 'rejected-stale' upsert short- - // circuits the enqueue path. - expect(calls2.length).toBe(0); + // MintInfo may grow by one on round 2: round 1 streams events sequentially + // so the per-URL backoff short-circuits the second `mint.azzamo.net` + // attempt; round 2's reenqueueUnverified bulk-enqueues all 5 curator + // rows before any completes, so both azzamo attempts race through and + // the second row lands too. Both outcomes are correct — pin a permissive + // bound rather than the exact count. + expect(await db.mintInfo.count()).toBeGreaterThanOrEqual(round1Counts.mintInfo); + expect(await db.mintInfo.count()).toBeLessThanOrEqual(round1Counts.mintInfo + 1); + + // Round-2 Layer B re-fetches come only from the transient-null curator + // rows being re-enqueued at startup — alpha/beta (verified) and legacy + // (pubkey-mismatch = real failure) aren't re-enqueued. + const curatorUrls = new Set([ + "https://mint.azzamo.net", + "https://mint.lnw.cash", + "https://21mint.me", + "https://cashu.boats", + ]); + const curatorCalls = calls2.filter((u) => curatorUrls.has(u)); + // 5 curator announcements but only 4 distinct URLs (azzamo appears + // twice). Each re-enqueued row fetches its `u[]` once → 5 calls. + expect(curatorCalls.length).toBeGreaterThanOrEqual(4); + // No alpha/beta/sharegap re-fetches — verdicts were terminal. + expect(calls2.some((u) => u.includes("mint.alpha.test"))).toBe(false); + expect(calls2.some((u) => u.includes("mint.beta.test"))).toBe(false); + expect(calls2.some((u) => u.includes("sharegap"))).toBe(false); // Verification status preserved across restart (PR #29 fix on the // cache + scheduler not clobbering on replace). diff --git a/packages/core/src/scheduler/index.test.ts b/packages/core/src/scheduler/index.test.ts index 31ce019..a1309f8 100644 --- a/packages/core/src/scheduler/index.test.ts +++ b/packages/core/src/scheduler/index.test.ts @@ -186,7 +186,11 @@ describe("scheduler — pipeline (single event)", () => { expect(mintInfo?.lastError).toContain("pubkey-mismatch"); }); - it("rejects bot-spam d-tag at Layer A (rejectedByLayerA stat increments)", async () => { + it("rejects unambiguous-garbage d-tag at Layer A (rejectedByLayerA stat increments)", async () => { + // Post-2026-04-17 relaxation: 16-char printable ASCII d-tags are now + // accepted (99.8% of real on-wire kind:38172 events use that shape). + // Layer A now only rejects empty / oversized / non-printable d-tags. + // Here we use a non-printable (embedded newline) d to exercise the gate. const db = await freshDB(); const { pool, pushEvent } = makeFakePool(); const { fetcher, calls } = makeFetcher({}); @@ -194,12 +198,12 @@ describe("scheduler — pipeline (single event)", () => { await sched.start(); await pushEvent({ - id: "spam-1", + id: "garbage-1", kind: 38172, pubkey: "972f233a".padEnd(64, "0"), created_at: 1_700_000_000, tags: [ - ["d", "shortspamtag123"], // 15-char garbage — fails Layer A + ["d", "has\nnewline"], // non-printable ASCII — fails Layer A ["u", "https://mint.example.com"], ], content: "", @@ -215,6 +219,36 @@ describe("scheduler — pipeline (single event)", () => { expect(calls.length).toBe(0); }); + it("accepts curator-style 16-char d-tag at Layer A (post-relaxation)", async () => { + // Regression pin for the 2026-04-17 relaxation: 16-char random d-tags + // were wrongly filed as bot spam before the browser audit showed them + // pointing at real mints. Same shape, now accepted. + const db = await freshDB(); + const { pool, pushEvent } = makeFakePool(); + const { fetcher } = makeFetcher({}); + const sched = createScheduler({ db, pool, fetcher, relays: ["wss://test"] }); + await sched.start(); + + await pushEvent({ + id: "curator-1", + kind: 38172, + pubkey: "972f233a".padEnd(64, "0"), + created_at: 1_700_000_000, + tags: [ + ["d", "abc123def4567890"], // 16-char curator shape — accepted + ["u", "https://mint.example.com"], + ], + content: "", + sig: "fake", + }); + await settle(); + await sched.stop(); + + expect(await db.announcements.count()).toBe(1); + expect(sched.getStats().rejectedByLayerA).toBe(0); + expect(sched.getStats().accepted).toBe(1); + }); + it("Fedimint (kind:38173) is accepted but Layer B is not enqueued", async () => { const db = await freshDB(); const { pool, pushEvent } = makeFakePool(); From 62d147c78cfcf726a25df1815a8703fe412987fe Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Apr 2026 16:48:06 -0700 Subject: [PATCH 11/13] Revert "test(core/integration,scheduler): update pipeline counts for d-tag relaxation" This reverts commit abf28316846d6e117880be070f5c88ecf11b7608. --- packages/core/src/integration.test.ts | 220 ++++++++-------------- packages/core/src/scheduler/index.test.ts | 40 +--- 2 files changed, 77 insertions(+), 183 deletions(-) diff --git a/packages/core/src/integration.test.ts b/packages/core/src/integration.test.ts index 310cd8a..69ad30b 100644 --- a/packages/core/src/integration.test.ts +++ b/packages/core/src/integration.test.ts @@ -27,7 +27,7 @@ import { createScheduler } from "./scheduler"; type Fixture = { _meta: Record; - cashu38172Curator: NostrEvent[]; + cashu38172BotSpam: NostrEvent[]; cashu38172Legacy: NostrEvent[]; cashu38172SpecConforming: NostrEvent[]; fedimint38173: NostrEvent[]; @@ -104,7 +104,7 @@ function toReviewRow(parsed: NonNullable> */ async function replayCorpus(db: BitcoinmintsDB) { const all38172: NostrEvent[] = [ - ...f.cashu38172Curator, + ...f.cashu38172BotSpam, ...f.cashu38172Legacy, ...f.cashu38172SpecConforming, ]; @@ -140,7 +140,7 @@ describe("integration: corpus replay → parse → cache", () => { // Sanity: every event in the corpus has a result entry. expect(announcementResults.length).toBe( - f.cashu38172Curator.length + + f.cashu38172BotSpam.length + f.cashu38172Legacy.length + f.cashu38172SpecConforming.length + f.fedimint38173.length, @@ -152,38 +152,41 @@ describe("integration: corpus replay → parse → cache", () => { 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 (post-relaxation): - // "Accepted: all cashu38172Curator + cashu38172Legacy + cashu38172SpecConforming." - // Plus all 3 Fedimint events pass their sibling 64-char hex gate. - // Accepted = 5 (Curator 16-char) + 1 (Legacy x-only) + 2 (SpecConforming compressed) + 3 (Fedimint) = 11 - // Rejected by Layer A = 0 (all corpus d-tags are well-formed printable ASCII). - const acceptedCashuLayerA = - f.cashu38172Curator.length + f.cashu38172Legacy.length + f.cashu38172SpecConforming.length; + // 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 = 0; - expect(expectedAccepted).toBe(11); - expect(expectedRejected).toBe(0); + 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 event lands as 'inserted' post-relaxation - // (each has unique [pubkey,kind,d]); nothing rejected by Layer A. + // 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 all 5 insert post-relaxation. - // 2 point at 16-char curator d-tags (previously rejected as bot-spam); - // 3 point at 64-char pubkeys/federation-ids. URL + Layer B are the real - // verification gates now. + // Reviews: all 5 recommendations parse, but Layer A applies to the + // reviews' `d` tag too (PR #5) — 2 of the 5 point at 16-char legacy + // d-tags that pre-date the Cashu-mint-pubkey d-tag convention and + // would be indistinguishable from the bot-spam shape the gate is + // designed to reject. Those are `rejected-invalid`. The remaining 3 + // reference real 64-char Cashu mint pubkeys and insert cleanly. const reviewsInserted = reviewResults.filter((r) => r.result === "inserted"); const reviewsRejectedInvalid = reviewResults.filter((r) => r.result === "rejected-invalid"); - expect(reviewsInserted.length).toBe(5); - expect(reviewsRejectedInvalid.length).toBe(0); - expect(await db.reviews.count()).toBe(5); + expect(reviewsInserted.length).toBe(3); + expect(reviewsRejectedInvalid.length).toBe(2); + expect(await db.reviews.count()).toBe(3); }); it("the legacy Nostrodomo (64-char x-only) lands as inserted, not rejected-invalid", async () => { @@ -272,69 +275,29 @@ describe("integration: CAS convergence under simulated multi-relay race", () => }); describe("integration: Layer A enforced at cache, not parser", () => { - it("curator events parse successfully and are accepted post-relaxation", async () => { - // Pin the design choice: parser is lenient, the cache is the gate. Post- - // 2026-04-17 relaxation, curator-style 16-char d-tags are accepted — the - // URL + Layer B are the real verification gates. This test used to - // assert rejection; the browser audit showed the 16-char shape points - // at real mints. We keep the test as a spot-check that the cache gate - // still runs (just with a different verdict for this shape). + 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 acceptedCount = 0; - for (const e of f.cashu38172Curator) { + 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++; - // Curator d-tags are 16-char random printable ASCII — regex matches. - expect(isValidCashuDTag(parsed.d)).toBe(true); + // 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)); - expect(result).toBe("inserted"); - acceptedCount++; + // The cache is where Layer A bites. + expect(result).toBe("rejected-invalid"); + rejectedAtCacheCount++; } - expect(parsedCount).toBe(f.cashu38172Curator.length); - expect(acceptedCount).toBe(f.cashu38172Curator.length); - expect(await db.announcements.count()).toBe(f.cashu38172Curator.length); - }); - - it("the cache gate still rejects unambiguous garbage (empty / oversized / non-printable)", async () => { - // The Layer A gate is still live for the narrow cases that survive the - // relaxation. Constructing synthetic events rather than mining fixtures - // because the corpus intentionally doesn't carry junk d-tags. - const db = await freshDB(); - const pubkey = "0".repeat(64); - - // Empty d — rejected. - const emptyDEvent = { ...(f.cashu38172Curator[0] as NostrEvent) }; - const emptyTags = (emptyDEvent.tags ?? []).map((t) => (t[0] === "d" ? ["d", ""] : t)); - const emptyEvent: NostrEvent = { ...emptyDEvent, pubkey, tags: emptyTags }; - const emptyParsed = parseMintAnnouncement(emptyEvent); - // parseMintAnnouncement currently returns null for missing/empty d — - // so the gate doesn't even get to run. That's fine; assert via a row - // we construct directly. - expect(emptyParsed).toBeNull(); - const emptyRow = { - pubkey, - kind: 38172 as const, - d: "", - eventId: "e".repeat(64), - createdAt: 1_700_000_000, - u: ["https://mint.example"], - content: "", - rawTags: [] as string[][], - verifiedBySignerBinding: null, - }; - expect(await upsertAnnouncement(db, emptyRow)).toBe("rejected-invalid"); - - // Oversized d — rejected. - const oversizedRow = { ...emptyRow, d: "a".repeat(257), eventId: "f".repeat(64) }; - expect(await upsertAnnouncement(db, oversizedRow)).toBe("rejected-invalid"); - - // Non-printable d — rejected. - const nonPrintableRow = { ...emptyRow, d: "has\nnewline", eventId: "9".repeat(64) }; - expect(await upsertAnnouncement(db, nonPrintableRow)).toBe("rejected-invalid"); - + 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); }); @@ -457,7 +420,7 @@ function makeCorpusFetcher(): MintInfoFetcher { */ async function pushCashuCorpus(pushEvent: (e: NostrEvent) => Promise): Promise { const allCashu: NostrEvent[] = [ - ...f.cashu38172Curator, + ...f.cashu38172BotSpam, ...f.cashu38172Legacy, ...f.cashu38172SpecConforming, ]; @@ -477,34 +440,30 @@ describe("integration: scheduler full pipeline", () => { await pushCashuCorpus(pushEvent); await drainLayerB(sched); - // Stats post-relaxation: every corpus event has a well-formed d-tag, - // so Layer A rejects nothing. 11 announcements + 5 reviews = 16 - // accepted. Layer B still distinguishes verified from failed. + // Stats: same accept/reject as the parse → cache integration above + // (5 bot-spam rejected at Layer A; 1 legacy + 2 spec-conforming + 3 + // fedimint accepted = 6 announcements; 3 reviews accepted + 2 reviews + // rejected for 16-char legacy d-tags per PR #5's Layer A review gate). const stats = sched.getStats(); - // 11 announcements (5 curator + 1 legacy + 2 spec + 3 fedi) + 5 reviews = 16. + // 11 announcements (5 spam + 1 legacy + 2 spec + 3 fedi) + 5 reviews = 16. expect(stats.eventsReceived).toBe(16); - // No Layer A rejections — all corpus d-tags are well-formed printable ASCII. - expect(stats.rejectedByLayerA).toBe(0); - // Accepted = 11 announcements + 5 reviews = 16. - expect(stats.accepted).toBe(16); - - // Layer B: spec-conforming Alpha + Beta verify against the fetcher - // mapping. Legacy Nostrodomo (sharegap) returns pubkey-mismatch. - // Curator URLs (azzamo×2 / lnw / 21mint / cashu.boats) aren't in the - // fetcher mapping → 404 → all-fetches-failed (transient). Only 4 - // distinct curator URLs exist in the fixture (azzamo appears twice); - // the second azzamo enqueue hits the per-URL backoff cooldown and - // short-circuits without running Layer B again, so only 4 curator - // attempts actually register. Fedimint is non-cashu and doesn't - // enqueue Layer B at all. + // 5 announcement bot-spam rejections + 2 review 16-char d-tag rejections. + expect(stats.rejectedByLayerA).toBe(7); + // Accepted = 6 announcements + 3 reviews = 9. + expect(stats.accepted).toBe(9); + + // Layer B: spec-conforming Alpha + Beta verify. Legacy Nostrodomo + // returns ok but with the wrong pubkey → counts as failed. Fedimint + // is non-cashu and doesn't enqueue Layer B at all. expect(stats.layerBVerified).toBe(2); - expect(stats.layerBFailed).toBe(5); + expect(stats.layerBFailed).toBe(1); expect(stats.layerBPending).toBe(0); - // Cache state: all 11 announcements land (5 curator + 1 legacy + 2 spec - // + 3 fedi). All 5 reviews insert post-relaxation. - expect(await db.announcements.count()).toBe(11); - expect(await db.reviews.count()).toBe(5); + // Cache state matches the parse → cache test exactly: 6 announcements, + // 3 reviews (2 more reviews rejected by PR #5's Layer A on reviews' + // d-tags). Bot-spam rejected at Layer A, never lands. + expect(await db.announcements.count()).toBe(6); + expect(await db.reviews.count()).toBe(3); // Spot-check verifiedBySignerBinding wired through correctly. const alphaPubkey = "02aa00000000000000000000000000000000000000000000000000000000000001"; @@ -526,10 +485,8 @@ describe("integration: scheduler full pipeline", () => { expect(fedimintRow).toBeDefined(); expect(fedimintRow?.verifiedBySignerBinding).toBeNull(); - // mintInfo rows: 2 ok (Alpha, Beta) + 1 !ok (Legacy pubkey-mismatch) - // + 4 !ok (curator events — 4 distinct URLs, second azzamo skipped via - // backoff cooldown, so no row written for curator[2]). - expect(await db.mintInfo.count()).toBe(7); + // mintInfo rows: 2 ok (Alpha, Beta) + 1 !ok (Legacy mismatch). + expect(await db.mintInfo.count()).toBe(3); const alphaInfo = await db.mintInfo.get(alphaPubkey); expect(alphaInfo?.ok).toBe(true); expect(alphaInfo?.url).toBe("https://mint.alpha.test"); @@ -574,24 +531,16 @@ describe("integration: scheduler full pipeline", () => { mintInfo: await db.mintInfo.count(), fetches: calls1.length, }; - // 11 announcements (5 curator + 1 legacy + 2 spec + 3 fedi). - expect(round1Counts.announcements).toBe(11); - // 5 reviews post-relaxation (curator d-tags accepted). - expect(round1Counts.reviews).toBe(5); - // 2 verified ok + 1 legacy pubkey-mismatch + 4 curator 404 rows - // (second azzamo skipped via backoff cooldown, no row written). - expect(round1Counts.mintInfo).toBe(7); + expect(round1Counts.announcements).toBe(6); + // 3 reviews (2 more gated out by PR #5's Layer A on review d-tags). + expect(round1Counts.reviews).toBe(3); + expect(round1Counts.mintInfo).toBe(3); // Round 2 — fresh scheduler against same DB. createScheduler reads // the watermarks from the cache; the corpus replay uses the same // events (same createdAt), so every announcement upsert lands as - // 'rejected-stale' (next.createdAt is NOT > prev.createdAt). On - // startup, reenqueueUnverified finds rows with - // verifiedBySignerBinding === null (the 5 curator events whose - // Layer B hit 404 = all-fetches-failed = transient) and re-enqueues - // them — so calls2 will have the 5 curator URLs re-fetched. The - // alpha/beta (verified=true) and legacy (verified=false, pubkey- - // mismatch) rows are NOT re-enqueued. + // 'rejected-stale' (next.createdAt is NOT > prev.createdAt) which + // means Layer B is not re-enqueued, so calls2 stays at 0. const { pool: pool2, pushEvent: push2 } = makeFakePool(); const calls2: string[] = []; const fetcher2: MintInfoFetcher = (url) => { @@ -609,35 +558,14 @@ describe("integration: scheduler full pipeline", () => { await drainLayerB(sched2); await sched2.stop(); - // Same announcement + review counts — no duplicates introduced by replay. + // Same row counts — no duplicates introduced by the replay. expect(await db.announcements.count()).toBe(round1Counts.announcements); expect(await db.reviews.count()).toBe(round1Counts.reviews); - // MintInfo may grow by one on round 2: round 1 streams events sequentially - // so the per-URL backoff short-circuits the second `mint.azzamo.net` - // attempt; round 2's reenqueueUnverified bulk-enqueues all 5 curator - // rows before any completes, so both azzamo attempts race through and - // the second row lands too. Both outcomes are correct — pin a permissive - // bound rather than the exact count. - expect(await db.mintInfo.count()).toBeGreaterThanOrEqual(round1Counts.mintInfo); - expect(await db.mintInfo.count()).toBeLessThanOrEqual(round1Counts.mintInfo + 1); - - // Round-2 Layer B re-fetches come only from the transient-null curator - // rows being re-enqueued at startup — alpha/beta (verified) and legacy - // (pubkey-mismatch = real failure) aren't re-enqueued. - const curatorUrls = new Set([ - "https://mint.azzamo.net", - "https://mint.lnw.cash", - "https://21mint.me", - "https://cashu.boats", - ]); - const curatorCalls = calls2.filter((u) => curatorUrls.has(u)); - // 5 curator announcements but only 4 distinct URLs (azzamo appears - // twice). Each re-enqueued row fetches its `u[]` once → 5 calls. - expect(curatorCalls.length).toBeGreaterThanOrEqual(4); - // No alpha/beta/sharegap re-fetches — verdicts were terminal. - expect(calls2.some((u) => u.includes("mint.alpha.test"))).toBe(false); - expect(calls2.some((u) => u.includes("mint.beta.test"))).toBe(false); - expect(calls2.some((u) => u.includes("sharegap"))).toBe(false); + expect(await db.mintInfo.count()).toBe(round1Counts.mintInfo); + + // No second-round Layer B fetches: each 'rejected-stale' upsert short- + // circuits the enqueue path. + expect(calls2.length).toBe(0); // Verification status preserved across restart (PR #29 fix on the // cache + scheduler not clobbering on replace). diff --git a/packages/core/src/scheduler/index.test.ts b/packages/core/src/scheduler/index.test.ts index a1309f8..31ce019 100644 --- a/packages/core/src/scheduler/index.test.ts +++ b/packages/core/src/scheduler/index.test.ts @@ -186,11 +186,7 @@ describe("scheduler — pipeline (single event)", () => { expect(mintInfo?.lastError).toContain("pubkey-mismatch"); }); - it("rejects unambiguous-garbage d-tag at Layer A (rejectedByLayerA stat increments)", async () => { - // Post-2026-04-17 relaxation: 16-char printable ASCII d-tags are now - // accepted (99.8% of real on-wire kind:38172 events use that shape). - // Layer A now only rejects empty / oversized / non-printable d-tags. - // Here we use a non-printable (embedded newline) d to exercise the gate. + it("rejects bot-spam d-tag at Layer A (rejectedByLayerA stat increments)", async () => { const db = await freshDB(); const { pool, pushEvent } = makeFakePool(); const { fetcher, calls } = makeFetcher({}); @@ -198,12 +194,12 @@ describe("scheduler — pipeline (single event)", () => { await sched.start(); await pushEvent({ - id: "garbage-1", + id: "spam-1", kind: 38172, pubkey: "972f233a".padEnd(64, "0"), created_at: 1_700_000_000, tags: [ - ["d", "has\nnewline"], // non-printable ASCII — fails Layer A + ["d", "shortspamtag123"], // 15-char garbage — fails Layer A ["u", "https://mint.example.com"], ], content: "", @@ -219,36 +215,6 @@ describe("scheduler — pipeline (single event)", () => { expect(calls.length).toBe(0); }); - it("accepts curator-style 16-char d-tag at Layer A (post-relaxation)", async () => { - // Regression pin for the 2026-04-17 relaxation: 16-char random d-tags - // were wrongly filed as bot spam before the browser audit showed them - // pointing at real mints. Same shape, now accepted. - const db = await freshDB(); - const { pool, pushEvent } = makeFakePool(); - const { fetcher } = makeFetcher({}); - const sched = createScheduler({ db, pool, fetcher, relays: ["wss://test"] }); - await sched.start(); - - await pushEvent({ - id: "curator-1", - kind: 38172, - pubkey: "972f233a".padEnd(64, "0"), - created_at: 1_700_000_000, - tags: [ - ["d", "abc123def4567890"], // 16-char curator shape — accepted - ["u", "https://mint.example.com"], - ], - content: "", - sig: "fake", - }); - await settle(); - await sched.stop(); - - expect(await db.announcements.count()).toBe(1); - expect(sched.getStats().rejectedByLayerA).toBe(0); - expect(sched.getStats().accepted).toBe(1); - }); - it("Fedimint (kind:38173) is accepted but Layer B is not enqueued", async () => { const db = await freshDB(); const { pool, pushEvent } = makeFakePool(); From d722f1ff784e11365dc187ce38040f62a366c1eb Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Apr 2026 16:48:06 -0700 Subject: [PATCH 12/13] Revert "test(core/nip87): rebaseline fixtures + tests for d-tag relaxation" This reverts commit 60fccd276b1b8d3e886fd5c39c31a6dc455f9eb3. --- packages/core/src/cache/upsert.test.ts | 39 +---- .../src/nip87/__fixtures__/nip87-sample.json | 10 +- packages/core/src/nip87/corpus.test.ts | 45 +++-- packages/core/src/nip87/dtag.test.ts | 158 ++++++++++++------ packages/core/src/reviews/upsert.test.ts | 21 +-- 5 files changed, 139 insertions(+), 134 deletions(-) diff --git a/packages/core/src/cache/upsert.test.ts b/packages/core/src/cache/upsert.test.ts index aa9fe7e..ecb0115 100644 --- a/packages/core/src/cache/upsert.test.ts +++ b/packages/core/src/cache/upsert.test.ts @@ -183,47 +183,16 @@ describe("upsertAnnouncement", () => { expect(fetched?.eventId).toBe(EID_HIGH); }); - it("accepts kind:38172 with a 16-char curator-style d-tag (post-relaxation)", async () => { - // Pre-2026-04-17 this was a 'rejected-invalid' assertion. The browser - // audit showed 99.8% of on-wire kind:38172 events use this exact - // shape pointing at legitimate mints — the URL is the identity, not - // the d-tag. Layer A now only rejects empty / oversized / non-printable. + it("rejects as invalid when kind:38172 has a 16-char bot-spam d-tag", async () => { const db = await freshDB(); - const curator = makeAnnouncement({ d: "abc123def4567890" }); + const bot = makeAnnouncement({ d: "abc123def4567890" }); - const result = await upsertAnnouncement(db, curator); - expect(result).toBe("inserted"); - expect(await db.announcements.count()).toBe(1); - }); - - it("still rejects kind:38172 with an empty d-tag (only unambiguous garbage)", async () => { - const db = await freshDB(); - const empty = makeAnnouncement({ d: "" }); - - const result = await upsertAnnouncement(db, empty); - expect(result).toBe("rejected-invalid"); - expect(await db.announcements.count()).toBe(0); - }); - - it("still rejects kind:38172 with an oversized (>256 char) d-tag", async () => { - const db = await freshDB(); - const oversized = makeAnnouncement({ d: "a".repeat(257) }); - - const result = await upsertAnnouncement(db, oversized); - expect(result).toBe("rejected-invalid"); - expect(await db.announcements.count()).toBe(0); - }); - - it("still rejects kind:38172 with a non-printable d-tag (control char)", async () => { - const db = await freshDB(); - const nonPrintable = makeAnnouncement({ d: "has\nnewline" }); - - const result = await upsertAnnouncement(db, nonPrintable); + 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", async () => { + 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 }); diff --git a/packages/core/src/nip87/__fixtures__/nip87-sample.json b/packages/core/src/nip87/__fixtures__/nip87-sample.json index 01ad4c8..fbad80d 100644 --- a/packages/core/src/nip87/__fixtures__/nip87-sample.json +++ b/packages/core/src/nip87/__fixtures__/nip87-sample.json @@ -5,15 +5,15 @@ "snapshot": "2026-04-16", "notes": [ "Events are real, collected in the 2026-04-16 relay survey.", - "The 5 cashu38172Curator events share pubkey 972f233a... and use random 16-char d-tags. Originally filed as 'bot spam' based on the 2025-02-13 burst documented in audit/relay-strategy-v1.md §4; the 2026-04-17 browser audit of 500 on-wire events showed 99.8% of kind:38172 events on nos.lol use this exact shape pointing at legitimate operational mints (mint.coinos.io, stablenut.umint.cash, cashu.boats, mint.lnvoltz.com, etc). The shape is a non-spec curator convention, not bot spam; the URL is the mint's identity, not the d-tag.", - "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 NOT the mainstream shape — the 16-char curator form dominates the wild.", - "Layer A regex relaxed (PR #32 follow-up, 2026-04-17) to accept any non-empty printable-ASCII d-tag up to 256 chars. Accepted: all cashu38172Curator + cashu38172Legacy + cashu38172SpecConforming. URL + Layer B are the real verification gates.", + "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 for kind:38173 still enforces the 64-char hex federation-id shape via FEDIMINT_D_TAG_REGEX.", + "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." ] }, - "cashu38172Curator": [ + "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, diff --git a/packages/core/src/nip87/corpus.test.ts b/packages/core/src/nip87/corpus.test.ts index a2d1d77..5070e1a 100644 --- a/packages/core/src/nip87/corpus.test.ts +++ b/packages/core/src/nip87/corpus.test.ts @@ -6,7 +6,7 @@ import { parseMintAnnouncement, parseRecommendation } from "./parse"; type Fixture = { _meta: Record; - cashu38172Curator: NostrEvent[]; + cashu38172BotSpam: NostrEvent[]; cashu38172Legacy: NostrEvent[]; cashu38172SpecConforming: NostrEvent[]; fedimint38173: NostrEvent[]; @@ -21,14 +21,14 @@ const f = fixtures as unknown as Fixture; */ describe("NIP-87 corpus", () => { it("has the expected event counts per bucket", () => { - expect(f.cashu38172Curator.length).toBe(5); + 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.cashu38172Curator.length + + f.cashu38172BotSpam.length + f.cashu38172Legacy.length + f.cashu38172SpecConforming.length + f.fedimint38173.length + @@ -36,9 +36,9 @@ describe("NIP-87 corpus", () => { expect(total).toBe(16); }); - it("Layer A accepts all Cashu announcements post-relaxation (curator + legacy + spec-conforming)", () => { + it("Layer A accepts spec-conforming AND x-only Cashu announcements, rejects bot spam", () => { const all38172: NostrEvent[] = [ - ...f.cashu38172Curator, + ...f.cashu38172BotSpam, ...f.cashu38172Legacy, ...f.cashu38172SpecConforming, ]; @@ -53,32 +53,27 @@ describe("NIP-87 corpus", () => { const accepted = parsed.filter((a) => isValidCashuDTag(a.d)); const rejected = parsed.filter((a) => !isValidCashuDTag(a.d)); - // All 8 accepted post-relaxation: 5 curator (16-char) + 1 legacy (64-char) - // + 2 spec-conforming (66-char). Rejection is reserved for empty / oversized - // / non-printable garbage, none of which appear in the corpus. - expect(accepted.length).toBe(8); - expect(rejected.length).toBe(0); + // 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 accepts all 5 curator events (16-char d-tags are legitimate)", () => { - for (const e of f.cashu38172Curator) { + 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(true); - // Sanity: the curator shape really is 16 chars. - expect(parsed?.d.length).toBe(16); + expect(parsed && isValidCashuDTag(parsed.d)).toBe(false); } }); - it("all curator events in the fixture belong to the 972f233a... publisher", () => { - const CURATOR_PUBKEY = "972f233aa467bc9804032c0bce0a117daead5473c56c91e811a216bdd08c08cf"; - const curatorPubkeyCount = f.cashu38172Curator.filter( - (e) => e.pubkey === CURATOR_PUBKEY, - ).length; - expect(curatorPubkeyCount).toBe(5); + 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", () => { + 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(); @@ -88,7 +83,7 @@ describe("NIP-87 corpus", () => { } }); - it("Fedimint still uses its own (stricter) shape gate — unchanged by the Cashu relaxation", () => { + 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); @@ -102,8 +97,8 @@ describe("NIP-87 corpus", () => { expect(Array.isArray(parsed.modules)).toBe(true); expect(parsed.modules.length).toBeGreaterThan(0); } - // Intentionally don't call isValidCashuDTag on Fedimint events — the - // cashu regex isn't semantically applicable. + // 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. diff --git a/packages/core/src/nip87/dtag.test.ts b/packages/core/src/nip87/dtag.test.ts index 26557c5..f8f4c4f 100644 --- a/packages/core/src/nip87/dtag.test.ts +++ b/packages/core/src/nip87/dtag.test.ts @@ -1,87 +1,139 @@ import { describe, expect, it } from "vitest"; import { D_TAG_REGEX, isValidCashuDTag } from "./dtag"; -describe("isValidCashuDTag (post-relaxation)", () => { - // Per PR #32 follow-up: the Layer A d-tag shape gate was relaxed because - // 99.8% of real on-wire kind:38172 events use 16-char random d-tags - // pointing at legitimate mint URLs. The regex now accepts any non-empty - // printable-ASCII string up to 256 chars; URL + Layer B signer binding - // are the real verification gates. - - describe("accepts curator-style d-tags (real ecosystem shape)", () => { - it("accepts a 16-char random d-tag from the real curator burst", () => { - // These were incorrectly labeled "bot spam" before the 2026-04-17 - // browser audit. Real mints (mint.azzamo.net, mint.lnw.cash, etc.) - // publish under random 16-char d-tags. - expect(isValidCashuDTag("ewakfwchz6tmlmvy")).toBe(true); - expect(isValidCashuDTag("rp8l2ez6vw3t4u2j")).toBe(true); - expect(isValidCashuDTag("psvef0yh2zk24tt7")).toBe(true); - expect(isValidCashuDTag("abc123def4567890")).toBe(true); - }); - - it("accepts a 66-char compressed secp256k1 d-tag (spec-conforming)", () => { +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 64-char x-only secp256k1 d-tag (de-facto form)", () => { - // Nostrodomo Mint — real in-the-wild x-only pubkey d-tag. + it("accepts a realistic-looking 02-prefixed pubkey", () => { + // From a real kind:38000 recommendation's d-tag, pointing to lemonfizz mint. expect( - isValidCashuDTag("5fe928ae0970844f3c5253d2e85a88788486edcbd96c070334a4a2d0d0154a77"), + isValidCashuDTag("03c5f16604678b8b118a454db12885e586f0fc146788d54182b3ca7943a327278e"), ).toBe(true); }); - it("accepts uppercase hex — case-insensitive in post-relaxation", () => { - expect(isValidCashuDTag("A".repeat(64))).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"), + isValidCashuDTag("5fe928ae0970844f3c5253d2e85a88788486edcbd96c070334a4a2d0d0154a77"), ).toBe(true); }); - it("accepts short single-char d-tags (min boundary)", () => { - expect(isValidCashuDTag("a")).toBe(true); - expect(isValidCashuDTag("1")).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 d-tags at the 256-char maximum", () => { - expect(isValidCashuDTag("a".repeat(256))).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 d-tags with mixed alphanumerics + common ASCII punctuation", () => { - expect(isValidCashuDTag("mint-foo_bar.baz")).toBe(true); - expect(isValidCashuDTag("some+curator/path?q=1")).toBe(true); + it("accepts the full hex alphabet in a 64-char d-tag", () => { + expect(isValidCashuDTag("0123456789abcdef".repeat(4))).toBe(true); }); }); - describe("rejects only unambiguous garbage", () => { - it("rejects empty string", () => { - expect(isValidCashuDTag("")).toBe(false); + 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-long d-tag (>256 chars)", () => { - expect(isValidCashuDTag("a".repeat(257))).toBe(false); - expect(isValidCashuDTag("x".repeat(1024))).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 d-tags containing non-printable ASCII (control chars)", () => { - expect(isValidCashuDTag("hello\nworld")).toBe(false); - expect(isValidCashuDTag("\tindented")).toBe(false); - expect(isValidCashuDTag("null\0byte")).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 d-tags containing high-byte / non-ASCII characters", () => { - // U+00A0 NO-BREAK SPACE (0xA0) is outside the printable-ASCII range. - expect(isValidCashuDTag("café-mint")).toBe(false); - expect(isValidCashuDTag("héllo")).toBe(false); - // Emoji is multi-byte non-ASCII. - expect(isValidCashuDTag("mint🚀")).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", () => { - expect(D_TAG_REGEX.test("a")).toBe(true); - expect(D_TAG_REGEX.test("ewakfwchz6tmlmvy")).toBe(true); - expect(D_TAG_REGEX.test("")).toBe(false); - expect(D_TAG_REGEX.test("a".repeat(257))).toBe(false); + // 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/reviews/upsert.test.ts b/packages/core/src/reviews/upsert.test.ts index c07739c..45dfeeb 100644 --- a/packages/core/src/reviews/upsert.test.ts +++ b/packages/core/src/reviews/upsert.test.ts @@ -36,8 +36,7 @@ async function freshDB(): Promise { } const D_VALID = "5fe928ae0970844f3c5253d2e85a88788486edcbd96c070334a4a2d0d0154a77"; -const D_CURATOR = "psvef0yh2zk24tt7"; // 16-char curator shape — now valid post-relaxation. -const D_EMPTY = ""; // Empty d-tag — still rejected as unambiguous garbage. +const D_BOT = "psvef0yh2zk24tt7"; // 16-char legacy/bot-spam shape. const EID_LOW = `${"0".repeat(60)}aaaa`; const EID_HIGH = `${"0".repeat(60)}ffff`; @@ -229,27 +228,17 @@ describe("upsertReviewWithAggregate — concurrent CAS + aggregate race", () => }); describe("upsertReviewWithAggregate — Layer A gate", () => { - it("16-char curator-style d-tag → inserted post-relaxation (was rejected pre-2026-04-17)", async () => { - // Per the Layer A relaxation: curator-style d-tags are legitimate - // pointers at real mints. URL + Layer B are the real gates. + it("16-char bot-spam d-tag → rejected-invalid, no review row, no aggregate row", async () => { const db = await freshDB(); - const result = await upsertReviewWithAggregate(db, makeReview({ d: D_CURATOR })); - expect(result).toBe("inserted"); - expect(await db.reviews.count()).toBe(1); - expect(await db.mintAggregate.count()).toBe(1); - }); - - it("empty d-tag → still rejected-invalid (unambiguous garbage)", async () => { - const db = await freshDB(); - const result = await upsertReviewWithAggregate(db, makeReview({ d: D_EMPTY })); + const result = await upsertReviewWithAggregate(db, makeReview({ d: D_BOT })); expect(result).toBe("rejected-invalid"); expect(await db.reviews.count()).toBe(0); expect(await db.mintAggregate.count()).toBe(0); }); - it("Fedimint k=38173 review with a valid 64-char federation id passes the sibling gate", async () => { + it("Fedimint k=38173 review with non-regex d bypasses the gate", async () => { const db = await freshDB(); - // The Fedimint gate is unchanged by the Cashu relaxation — still 64-char hex. + // A federation ID isn't constrained by the Cashu-mint-pubkey regex. const fediRow = makeReview({ d: "718e421be177486639330d198e870b7345ebd07b2866b5fd3797d73e4bc4c9af", k: 38173, From 9e3f93b995bb4064b3eed08fe2fc67ef94471879 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 17 Apr 2026 16:48:06 -0700 Subject: [PATCH 13/13] Revert "fix(core/nip87/dtag): relax Cashu d-tag regex to accept curator-style tags" This reverts commit bfdae802f69797fe3e793ef6b90fc94ffd2b8d09. --- packages/core/src/cache/upsert.ts | 39 +++++++++--------- packages/core/src/nip87/dtag.ts | 54 +++++++++++++------------ packages/core/src/nip87/parse.test.ts | 8 ++-- packages/core/src/nip87/types.ts | 10 ++--- packages/core/src/reviews/parse.test.ts | 6 +-- packages/core/src/reviews/parse.ts | 2 +- packages/core/src/scheduler/index.ts | 9 ++--- 7 files changed, 64 insertions(+), 64 deletions(-) diff --git a/packages/core/src/cache/upsert.ts b/packages/core/src/cache/upsert.ts index 209fe4e..ff50a2e 100644 --- a/packages/core/src/cache/upsert.ts +++ b/packages/core/src/cache/upsert.ts @@ -13,13 +13,12 @@ * standard replaceable-event tiebreak clients converge on. * * Layer A gate for kind:38172: before writing an announcement we check - * isValidCashuDTag(d). Post-2026-04-17 the Cashu gate only rejects empty / - * oversized / non-printable-ASCII d-tags — the earlier strict pubkey regex - * rejected 99.8% of real on-wire events (see dtag.ts for the relaxation - * note). URL + Layer B signer binding are the real verification gates. - * kind:38173 (Fedimint) uses a sibling shape gate (isValidFedimintDTag) — - * every real federation ID in the audit corpus is 64-char lowercase hex, - * and that gate is unchanged by the Cashu relaxation. + * isValidCashuDTag(d). Invalid shapes (bot spam, non-hex garbage) are + * returned as "rejected-invalid" and never hit the DB. kind:38173 + * (Fedimint) uses a sibling shape gate (isValidFedimintDTag) — every real + * federation ID in the audit corpus is 64-char lowercase hex, so short / + * junk d-tags with `["k","38173"]` are still filtered at the same choke + * point as Cashu bot spam. * * mintInfo and mintAggregate aren't event-based, so their CAS predicate * is a monotonically-increasing timestamp: `fetchedAt` for mintInfo, @@ -65,10 +64,10 @@ export async function upsertAnnouncement( row: AnnouncementRow, ): Promise { // Layer A gate — reject invalid d-tag shapes before touching the DB. - // Cashu (38172) accepts any non-empty printable-ASCII d-tag up to 256 - // chars (post-2026-04-17 relaxation — see dtag.ts). Fedimint (38173) - // requires a 64-char lowercase hex federation-id shape — unchanged by - // the Cashu relaxation; junk d-tags with k=38173 still get filtered. + // Cashu (38172) requires a 64- or 66-char secp256k1 pubkey shape; + // Fedimint (38173) requires a 64-char lowercase hex federation-id shape. + // A short/junk d-tag with `k=38173` slapped on is still bot spam and + // must be caught by the same firewall — don't free-pass by kind alone. if (row.kind === 38173) { if (!isValidFedimintDTag(row.d)) return "rejected-invalid"; } else if (!isValidCashuDTag(row.d)) { @@ -101,14 +100,16 @@ export async function upsertAnnouncement( * * The review's `d` points at a mint. When `k === 38172` (or `k` is absent, * which is how most in-the-wild Cashu reviews shape), we apply the same - * Layer A gate that `upsertAnnouncement` uses — post-relaxation, that - * means rejecting only empty / oversized / non-printable d-tags. URL + - * Layer B are the real verification gates on the mint side; here we just - * ensure the d pointer itself is a well-formed string. + * Layer A d-regex gate that `upsertAnnouncement` uses — if the referenced + * mint pubkey isn't 64/66-char hex, the review is bot-spam pointing at + * bot-spam, returned as `rejected-invalid`. This is the firewall that + * keeps the 959 zero-d-tag bot spam events (per relay-strategy §4) from + * filtering up into the ranking aggregate. * * `k === 38173` (Fedimint) switches to the sibling `isValidFedimintDTag` * shape gate — every real federation ID in the audit corpus is lowercase - * 64-char hex. This gate is unchanged by the Cashu relaxation. + * 64-char hex, so a short / junk d-tag with `k=38173` attached is still + * bot spam and must be caught by the same firewall. * * Note: this low-level upsert is the mechanical write. It does NOT * materialize the `mintAggregate` row — the `reviews/` wrapper composes @@ -121,9 +122,9 @@ export async function upsertReview(db: BitcoinmintsDB, row: ReviewRow): Promise< // Layer A gate — reject invalid d-tag shapes before touching the DB. // Reviews point at a target mint via `d`; the pointer-kind `k` selects // which shape gate applies. No `k` tag → treat as Cashu (the default - // for in-the-wild events per rating-tag-research §3). The Cashu gate is - // relaxed (any non-empty printable ASCII); the Fedimint gate remains - // 64-char hex. + // for in-the-wild events per rating-tag-research §3). Fedimint rows + // still get a sibling shape check (64-char hex federation id) so junk + // d-tags with `k=38173` slapped on don't free-pass the firewall. if (row.k === 38173) { if (!isValidFedimintDTag(row.d)) return "rejected-invalid"; } else if (!isValidCashuDTag(row.d)) { diff --git a/packages/core/src/nip87/dtag.ts b/packages/core/src/nip87/dtag.ts index 9bdc7ff..9b84b32 100644 --- a/packages/core/src/nip87/dtag.ts +++ b/packages/core/src/nip87/dtag.ts @@ -1,37 +1,41 @@ /** * Layer A d-tag shape validator for NIP-87 Cashu mint announcements. * - * Empirical finding (PR #32 browser demo, 2026-04-17): of 500 on-wire - * `{kinds:[38172]}` events from nos.lol, 499 use 16-char random d-tags - * and point at legitimate operational mints — mint.coinos.io, - * stablenut.umint.cash, cashu.boats, mint.lnvoltz.com, etc. The earlier - * "bot spam with fabricated d-tags" reading was wrong: one non-spec - * curator publishes real mint URLs under random d-tags. A strict regex - * (64-char x-only or 66-char compressed secp256k1) rejects 99.8% of the - * real ecosystem — only sharegap.net passes, and it has zero reviews. + * 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. * - * Decision (gudnuf, 2026-04-17): relax d-tag shape filtering. The URL - * is the mint's identity, not the d-tag. Trust Layer B (NUT-06 signer - * binding via /v1/info) + URL as the real verification gate; use Layer A - * only to reject unambiguous garbage (empty, oversized, non-printable). + * 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 * - * Later analysis (deferred) will re-examine the on-wire corpus and may - * tighten this back once we understand the shape distribution. + * 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. */ -// Accept any non-empty printable-ASCII d-tag up to 256 chars. Rejects only -// empty, oversized (>256), or non-printable/high-byte garbage. Chosen over -// [A-Za-z0-9_\-] because real curator d-tags in the wild mix unexpected -// characters and we'd rather gate on URL + Layer B than relitigate shape. -export const D_TAG_REGEX = /^[\x20-\x7E]{1,256}$/; +// 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})$/; /** * Fedimint federation-id d-tag shape. Every real Fedimint federation ID * observed in the audit corpus (see `audit/fedimint-observer.md` and * `packages/core/src/reviews/corpus.test.ts`) is lowercase 64-char hex — * the blake3 hash of the federation's consensus public key, serialized as - * 32 bytes of hex. This gate is unchanged by the 2026-04-17 Cashu - * relaxation; a short/junk d-tag with `k=38173` slapped on is still not - * a federation and gets rejected here. + * 32 bytes of hex. A short/junk d-tag with `k=38173` slapped on is bot + * spam, not a federation, and must be rejected by the same Layer A + * firewall that catches 16-char Cashu bot spam. * * Keeping this sibling to `D_TAG_REGEX` so both Layer A shape gates live * in one file — a reviewer touching one will see the other immediately. @@ -39,10 +43,8 @@ export const D_TAG_REGEX = /^[\x20-\x7E]{1,256}$/; export const FEDIMINT_D_TAG_REGEX = /^[0-9a-f]{64}$/; /** - * True iff `d` is a non-empty printable-ASCII string up to 256 chars. - * Per the relaxation recorded on D_TAG_REGEX above: shape is no longer - * used to filter curator-style d-tags; URL + Layer B signer binding - * are the real verification gates. + * 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/parse.test.ts b/packages/core/src/nip87/parse.test.ts index c38e804..a7dac25 100644 --- a/packages/core/src/nip87/parse.test.ts +++ b/packages/core/src/nip87/parse.test.ts @@ -4,7 +4,7 @@ import fixtures from "./__fixtures__/nip87-sample.json" with { type: "json" }; import { parseMintAnnouncement, parseRecommendation } from "./parse"; type Fixture = { - cashu38172Curator: NostrEvent[]; + cashu38172BotSpam: NostrEvent[]; cashu38172Legacy: NostrEvent[]; cashu38172SpecConforming: NostrEvent[]; fedimint38173: NostrEvent[]; @@ -13,9 +13,9 @@ type Fixture = { const f = fixtures as unknown as Fixture; describe("parseMintAnnouncement", () => { - it("parses a real kind:38172 curator event into the expected shape", () => { - const event = f.cashu38172Curator[0]; - if (!event) throw new Error("fixture missing cashu38172Curator[0]"); + 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(); diff --git a/packages/core/src/nip87/types.ts b/packages/core/src/nip87/types.ts index 1777194..4e0a973 100644 --- a/packages/core/src/nip87/types.ts +++ b/packages/core/src/nip87/types.ts @@ -63,12 +63,10 @@ export type MintRecommendation = { pubkey: string; createdAt: number; /** - * Target mint identifier — matches an announcement's d-tag. In the wild - * this is typically a 16-char curator-style tag (most common on nos.lol) - * or a 64-char secp256k1 pubkey; for Fedimint, a 64-char federation id. - * The parser is lenient and preserves whatever is there — shape gating - * happens at the cache layer (Layer A) with a permissive printable-ASCII - * rule; URL + Layer B are the real verification gates. + * 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. */ diff --git a/packages/core/src/reviews/parse.test.ts b/packages/core/src/reviews/parse.test.ts index d53721a..f287aaf 100644 --- a/packages/core/src/reviews/parse.test.ts +++ b/packages/core/src/reviews/parse.test.ts @@ -9,7 +9,7 @@ import { parseReview } from "./parse"; /** Realistic 64-char x-only Cashu d-tag. */ const D_VALID = "5fe928ae0970844f3c5253d2e85a88788486edcbd96c070334a4a2d0d0154a77"; -/** 16-char curator-style d-tag (common on nos.lol, see dtag.ts relaxation note). */ +/** 16-char legacy / bot-spam d-tag. */ const D_LEGACY_16 = "psvef0yh2zk24tt7"; function makeEvent(over: Partial & { tags?: string[][] } = {}): NostrEvent { @@ -410,8 +410,8 @@ describe("parseReview — u tag collection (display helper)", () => { }); describe("parseReview — parser is lenient on Layer A", () => { - it("16-char curator-style d-tag still parses (gate is at upsert, not parse)", () => { - // The parser preserves whatever is there — shape filtering is the + it("16-char legacy d-tag still parses (gate is at upsert, not parse)", () => { + // The parser preserves whatever is there — bot-spam filtering is the // cache layer's job. This keeps parser usable by raw-event log views. const row = parseReview( makeEvent({ diff --git a/packages/core/src/reviews/parse.ts b/packages/core/src/reviews/parse.ts index ccf7313..919ebf9 100644 --- a/packages/core/src/reviews/parse.ts +++ b/packages/core/src/reviews/parse.ts @@ -29,7 +29,7 @@ * * Parse does NOT reject on Layer A d-shape — the upsert gate handles that * so the parser stays pure and callable from tests, pagination dedup, etc. - * Callers who want the Layer A shape firewall use `upsertReviewWithAggregate`. + * Callers who want the bot-spam firewall use `upsertReviewWithAggregate`. */ import type { Event as NostrEvent } from "nostr-tools/core"; import type { ReviewRow } from "../cache"; diff --git a/packages/core/src/scheduler/index.ts b/packages/core/src/scheduler/index.ts index 37b3b96..1e8fc36 100644 --- a/packages/core/src/scheduler/index.ts +++ b/packages/core/src/scheduler/index.ts @@ -92,7 +92,7 @@ export type SchedulerStats = { * `null` — either the `d` tag was missing/empty or the event was * unexpectedly not kind:38000. Counted separately from `rejectedByLayerA` * because it's a parser-level reject (malformed event) rather than a - * shape-gate reject (valid event with a d-tag that fails the shape gate). + * shape-gate reject (valid event pointing at bot-spam). */ rejectedByParse: number; layerBPending: number; @@ -715,10 +715,9 @@ export function createScheduler(config: SchedulerConfig): Scheduler { updateWatermark(event.kind, event.created_at); logPath(event.kind, event.id, relay, result === "replaced" ? "replaced" : "accepted"); } else if (result === "rejected-invalid") { - // Layer A gate on reviews: the `d` pointer failed the shape - // gate (empty, oversized, or non-printable post-relaxation). - // Counted under the same stats bucket as the announcement - // Layer A rejection — it's the same firewall. + // Layer A gate on reviews: pointing at a bot-spam d-tag. Count + // under the same stats bucket as the announcement Layer A + // rejection — it's the same firewall. stats.rejectedByLayerA += 1; logPath(event.kind, event.id, relay, "rejected-layerA"); } else {