diff --git a/bun.lock b/bun.lock index eac04d3..2cecaae 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,9 @@ "packages/core": { "name": "@bitcoinmints/core", "version": "0.0.0", + "dependencies": { + "nostr-tools": "^2.23.3", + }, }, }, "packages": { @@ -53,6 +56,12 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="], + + "@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="], + + "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + "@oxc-project/types": ["@oxc-project/types@0.124.0", "", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="], @@ -87,6 +96,12 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="], + "@scure/base": ["@scure/base@2.0.0", "", {}, "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w=="], + + "@scure/bip32": ["@scure/bip32@2.0.1", "", { "dependencies": { "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA=="], + + "@scure/bip39": ["@scure/bip39@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], @@ -159,6 +174,10 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nostr-tools": ["nostr-tools@2.23.3", "", { "dependencies": { "@noble/ciphers": "2.1.1", "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0", "@scure/bip32": "2.0.1", "@scure/bip39": "2.0.1", "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA=="], + + "nostr-wasm": ["nostr-wasm@0.1.0", "", {}, "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], diff --git a/packages/core/package.json b/packages/core/package.json index 833d4b8..505af48 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -8,5 +8,8 @@ "scripts": { "test": "vitest run", "typecheck": "tsc --noEmit" + }, + "dependencies": { + "nostr-tools": "^2.23.3" } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2e47a88..53db0f6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1 +1,4 @@ +export * from "./nip87"; +export * from "./nostr"; + export const VERSION = "0.0.0"; diff --git a/packages/core/src/nip87/__fixtures__/nip87-sample.json b/packages/core/src/nip87/__fixtures__/nip87-sample.json new file mode 100644 index 0000000..fbad80d --- /dev/null +++ b/packages/core/src/nip87/__fixtures__/nip87-sample.json @@ -0,0 +1,260 @@ +{ + "_meta": { + "description": "Curated NIP-87 corpus for @bitcoinmints/core parse tests.", + "source": "/srv/forge/projects/bitcoinmints/audit/relay-data/full-nip87.json + full-38000.json", + "snapshot": "2026-04-16", + "notes": [ + "Events are real, collected in the 2026-04-16 relay survey.", + "The 5 cashu38172BotSpam events share pubkey 972f233a... and use random 16-char d-tags — part of the 959-event 2025-02-13 burst documented in audit/relay-strategy-v1.md §4.", + "The 1 cashu38172Legacy event (Nostrodomo Mint) uses a 64-char x-only secp256k1 pubkey d-tag. The 'Legacy' bucket name is preserved for continuity, but empirically this IS the de-facto mainstream shape every real Cashu mint in the wild publishes — NOT a legacy minority form.", + "Layer A regex relaxed to accept 64-char x-only (empirical fix — no real mints use 66-char compressed in the wild). 16-char bot-spam d-tags still rejected. Accepted: all cashu38172Legacy + cashu38172SpecConforming. Rejected: cashu38172BotSpam only.", + "The 2 cashu38172SpecConforming events are SYNTHETIC: real mint URLs + contentMetadata, but the d-tag is rewritten to a valid 66-char compressed secp256k1 pubkey (derived by prefixing real mint pubkeys with 02/03). Their sig field is intentionally invalid (we do not re-sign) and the id does not match tag contents — parse layer does not verify signatures or event ids.", + "Fedimint events are real and filtered to ones that include at least one u tag (required by the parser). Layer A does not apply to kind:38173 — federation-id shape is TODO-v1.1.", + "Recommendations span three rating formats plus a no-rating case." + ] + }, + "cashu38172BotSpam": [ + { + "content": "{\"url\":\"https://mint.azzamo.net\",\"name\":\"Azzamo Cashu Mint\",\"description\":\"Unlock a new dimension of digital transactions with Azzamo cash Mint.\",\"version\":\"Nutshell/0.16.4\",\"nuts\":[\"NUT-07\",\"NUT-08\",\"NUT-09\"],\"motd\":\"Disclaimer: Azzamo Mint is in beta and experimental. Use small amounts only. Key Mantra: Not your keys = Not your coins.\",\"contact\":[[\"email\",\"support@azzamo.net\"],[\"twitter\",\"@me\"],[\"nostr\",\"npub...\"]]}", + "created_at": 1739410455, + "id": "005484e8d3beef38feb851b4005840d4532ce73f20f0761c8c85e06e3e0086c3", + "kind": 38172, + "pubkey": "972f233aa467bc9804032c0bce0a117daead5473c56c91e811a216bdd08c08cf", + "sig": "eb4c61474804acbac09a78be99ce7a80b225d60f2f2824ac37b2b67b1db98427a0baa5253f2500c2622ee7c988f545670b102d2ed90a46dbdf6bb5a69cb65a8f", + "tags": [ + ["u", "https://mint.azzamo.net"], + ["nuts", "0,1,2"], + ["updated_at", "1739410455"], + ["d", "6hca1u9u9a39iiii"] + ] + }, + { + "content": "{\"url\":\"https://mint.lnw.cash\",\"name\":\"lnwCash Mint\",\"description\":\"\\\"The ecash nutshell mint for freedom.\\\"\",\"version\":\"Nutshell/0.16.0\",\"nuts\":[\"NUT-07\",\"NUT-08\",\"NUT-09\"],\"contact\":[]}", + "created_at": 1739410543, + "id": "009b1ed07e26e97631ee845c23a36e5ea0e7c94e721abce43caaf54faa227782", + "kind": 38172, + "pubkey": "972f233aa467bc9804032c0bce0a117daead5473c56c91e811a216bdd08c08cf", + "sig": "f8c78242bc2bbfece8a281470e0c91421ca3da9e0dcd95ee0baa704d70cc4f13aef00fdf5dd14ee813bbe0da1767a464d5a4289f949d729be5cab14aea0c7e7a", + "tags": [ + ["u", "https://mint.lnw.cash"], + ["nuts", "0,1,2"], + ["updated_at", "1739410543"], + ["d", "gm0cw0i8n92w8ya2"] + ] + }, + { + "content": "{\"url\":\"https://mint.azzamo.net\",\"name\":\"Azzamo Cashu Mint\",\"description\":\"Unlock a new dimension of digital transactions with Azzamo cash Mint.\",\"version\":\"Nutshell/0.16.4\",\"nuts\":[\"NUT-07\",\"NUT-08\",\"NUT-09\"],\"motd\":\"Disclaimer: Azzamo Mint is in beta and experimental. Use small amounts only. Key Mantra: Not your keys = Not your coins.\",\"contact\":[[\"email\",\"support@azzamo.net\"],[\"twitter\",\"@me\"],[\"nostr\",\"npub...\"]]}", + "created_at": 1739410566, + "id": "00e5d6b029760d9368ae312546c03040f39ceecba8b2def646251b4b9f1b6bbb", + "kind": 38172, + "pubkey": "972f233aa467bc9804032c0bce0a117daead5473c56c91e811a216bdd08c08cf", + "sig": "638282664698a4394d7c66b92007b626ecdffd6d2038246b1f01269e82654531838f99ecb493f5bb99f5e20d39ac90a3c7adf6f37ea73c18d2af5161978d7b27", + "tags": [ + ["u", "https://mint.azzamo.net"], + ["nuts", "0,1,2"], + ["updated_at", "1739410566"], + ["d", "9byv4xd5fx8xtjjm"] + ] + }, + { + "content": "{\"url\":\"https://21mint.me\",\"name\":\"21Mint\",\"description\":\"Secure and privacy-oriented Cashu mint. All logs are automatically deleted every 24 hours.\",\"version\":\"Nutshell/0.16.4\",\"nuts\":[\"NUT-07\",\"NUT-08\",\"NUT-09\"],\"motd\":\"Welcome to 21Mint! We are currently in beta testing. Please report any issues or feedback. All logs are automatically deleted every 24 hours.\",\"contact\":[[\"nostr\",\"npub13suzac0smac4fr9zqvrrcr003kj2snr2m5a58j4gg6w3udejennsuc6ts3\"]]}", + "created_at": 1739410554, + "id": "0121b05ae161734d4a617b1e69eadd179b901cb2196fe50cab46e684edd4f566", + "kind": 38172, + "pubkey": "972f233aa467bc9804032c0bce0a117daead5473c56c91e811a216bdd08c08cf", + "sig": "98650eade787a3b86751db2972ae11cd92ca164670eb61ff59ac56275e56bb799d3bccd9f11ca18837380d329dccf0f890da2645292467a0516c41e9c5a3b4bc", + "tags": [ + ["u", "https://21mint.me"], + ["nuts", "0,1,2"], + ["updated_at", "1739410554"], + ["d", "wza56s85l1pwzaz0"] + ] + }, + { + "content": "{\"url\":\"https://cashu.boats\",\"name\":\"Kinda Reckless Mint\",\"description\":\"Reckless mint for the brave\",\"version\":\"Nutshell/0.16.3\",\"nuts\":[\"NUT-07\",\"NUT-08\",\"NUT-09\"],\"motd\":\"Go forth and spend with reckless abandon!\",\"contact\":[[\"email\",\"deodorize_average752@aleeas.com\"],[\"nostr\",\"npub18ehswvhxvl7992y2999lyq8lnnkyppn6ald5hfdmuvtcjmlu3v6sh9vrmc\"]]}", + "created_at": 1739410695, + "id": "0125348088164471a4b6b04a0e75175b7357f29c4f61eb995173d8b675c2e741", + "kind": 38172, + "pubkey": "972f233aa467bc9804032c0bce0a117daead5473c56c91e811a216bdd08c08cf", + "sig": "407aacb1362fb32806a0788994c9b568bc55aedaad54e74fb20661ab9479cada282a5e4266c3d73f1f6c8ff97f21b47eb1bd7802490ec64a816bbc3655d1435f", + "tags": [ + ["u", "https://cashu.boats"], + ["nuts", "0,1,2"], + ["updated_at", "1739410695"], + ["d", "tsimxkld5mxmd9d5"] + ] + } + ], + "cashu38172Legacy": [ + { + "content": "{\"name\":\"Nostrodomo Mint\",\"description\":\"Cashu mint for AI agents — Loom payment layer for Cascadia fleet\",\"contact\":[]}", + "created_at": 1774120532, + "id": "2451d5d6b79257eab6bb9f4e2b15dd9240ab6b82ce2b9b45ad38f17b235e08a4", + "kind": 38172, + "pubkey": "5fe928ae0970844f3c5253d2e85a88788486edcbd96c070334a4a2d0d0154a77", + "sig": "89383f7e7fba2105c465e1c0d3f6f00562e33f948c34976de53d75595d82afe1bdb4141b3de8076f4eccde1787b58e2f5552fb5ea4ce82fb9cebc84e2a529b94", + "tags": [ + ["d", "5fe928ae0970844f3c5253d2e85a88788486edcbd96c070334a4a2d0d0154a77"], + ["u", "https://mint.sharegap.net"], + ["nuts", "1,2,3,4,5,6,7,9,10,11,12,14,20"], + ["n", "mainnet"], + ["t", "loom"], + ["t", "cascadia"] + ] + } + ], + "cashu38172SpecConforming": [ + { + "content": "{\"name\":\"Mint Alpha (synthetic)\",\"about\":\"Representative spec-conformant Cashu announcement\",\"picture\":\"https://example.test/alpha.png\"}", + "created_at": 1770000000, + "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "kind": 38172, + "pubkey": "02aa00000000000000000000000000000000000000000000000000000000000001", + "sig": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "tags": [ + ["d", "02aa00000000000000000000000000000000000000000000000000000000000001"], + ["u", "https://mint.alpha.test"], + ["nuts", "1,2,3,4,5,6,7,9,10,11,12,14,20"], + ["n", "mainnet"] + ] + }, + { + "content": "", + "created_at": 1770000001, + "id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kind": 38172, + "pubkey": "03bb00000000000000000000000000000000000000000000000000000000000002", + "sig": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "tags": [ + ["d", "03bb00000000000000000000000000000000000000000000000000000000000002"], + ["u", "https://mint.beta.test"], + ["u", "https://mint.beta.test/v1"], + ["nuts", "7,8,9,10,11,12,14"] + ] + } + ], + "fedimint38173": [ + { + "content": "{\"federation_name\":\"Mutinynet-iroh\"}", + "created_at": 1775199515, + "id": "025aba4244de9845be458f414baf1e7db32ea9e767a9dfe116f16d628855b7e0", + "kind": 38173, + "pubkey": "bc5122e4d89e495ceb2a63134b5f208c7d17a1505d041d6a4eca7fc59eb71d50", + "sig": "91e271732d827651980dc6e25924c1d9c6896b8d864fcf4cca318f064f3ae2ab9eeb2782244bdb1dba93f11fb790a01f85dd24238c3d9e2dab2c228e98f37b95", + "tags": [ + ["d", "103b0a005077185b64583e1cdcd2e3023f00a16e28ae5fcc79fffc156a36ef61"], + [ + "u", + "fed11qvqyj3mfwfhksw309umrjen9vscnwefhx4jkve3j893rsvmpv56rzvpjxcexxvtp8p3rjvnrxsmr2vmxxv6n2wtzxv6rwv3cv9nxvefexumrscekx3jryvpeqqqyj3mfwfhksw309u6ngde4xymngvesxsurjetrxanxgefjv93kgcfkx33rxdtyx3snywp5xu6xvv3cx43nqc3svg6kxdm9x9jrxep3v9jnqdp5xcenjefjqyqjqypmpgq9qacctdj9s0sumnfwxq3lqzsku29wtlx8nlluz44rdmmp32qwkf" + ], + ["n", "signet"], + ["modules", "ln,mint,wallet,lnv2,meta"] + ] + }, + { + "content": "{\"federation_name\":\"Orange Club Africa\"}", + "created_at": 1759923172, + "id": "117eedf75b1d17b716d36c72388c97ab9095764f553ee28d9bd306ec5359e899", + "kind": 38173, + "pubkey": "de2b138f51b9d52752ec4f44fc36153fe5ae5b7dc92c39cdbd6d4a9fb8f4334e", + "sig": "3cc86c13bf5147475ca6c5e8d1ea1bf0932d54b03be8531ab126e13c18d0394406a8354c515ff2d45bfa17f1ff926b511aa795b5d860570d352588a8535473ce", + "tags": [ + ["d", "718e421be177486639330d198e870b7345ebd07b2866b5fd3797d73e4bc4c9af"], + [ + "u", + "fed11qvqyj3mfwfhksw309ucnywf38yurwwf4x5unswf58pjxydpnx9jnxdpjvgurxdfjv4jkgd3hxguxvdekvsmrwd3nvc6r2ce3v93xyve4xsunwetxv56x2vfjqqqyj3mfwfhksw309ajkxc3jxejkgd3jxcerqdf5xs6r2c3svgunxvf4xuckxenrvyunxcn9vsmxvdr9xvensv3kvvmrgveexymkzvp5vcmnjer9xcunxdmpqyqjquvwggd7za6gvcunxrge36rsku69a0g8k2rxkh7n097h8e9ufjd09yck6f" + ], + ["n", "bitcoin"], + ["modules", "ln,mint,wallet,lnv2,meta,multi_sig_stability_pool"] + ] + }, + { + "content": "", + "created_at": 1716511146, + "id": "1819482150b26768b6db2d766190e9012801c8e8f606a8dc79f67b11a3767a70", + "kind": 38173, + "pubkey": "36307f944a8ddfde2fcf09aa5ea472c51d0a9173108d1ffd5df7654ca9e7d1af", + "sig": "283f146a212725ca076579b69ee2b187b90013ed517e587de00a595040520c031d15ee3243264dc6e6a7ec90461accdf30a6e23f55bcc438e183aa0769370b83", + "tags": [ + [ + "u", + "fed11qgqzutrhwden5te0vejkg6tdd9h8gepwvejkg6tdd9h8gtn0v35kuen9v3jhyct5d9hkutnc09az7qqpyp938g2xae96wv4jhzg55u4q5tjcw037jsk6948walv95hlyrunm5tyfcdy", + "fedimint" + ], + ["d", "4b13a146ee4ba732b2b8914a72a0a2e5873e3e942da2d4eeefd85a5fe41f27ba"] + ] + } + ], + "recommendations38000": [ + { + "content": "[5/5]", + "created_at": 1758213273, + "id": "001aa17b4673880e626b110fa284011dd2003609c161977fb84f43087c4802ce", + "kind": 38000, + "pubkey": "bf5ae4651d81d5a211fac31823458160853a920b2ed03881a578b908280fe690", + "sig": "427bcb7b43f04c30553e71fbf4b82a333c34dfdaa417d2e55607a9d2cc3871f804eb9feb0340e0218a52ce01b0cf73bfc42b7e2078716933e1d955c9294cf15f", + "tags": [ + ["d", "b21068c84f5b12ca4fdf93f3e443d3bd7c27e8642d0d52ea2e4dce6fdbbee9df"], + ["k", "38173"], + ["rating", "5"] + ] + }, + { + "content": "[3/5]", + "created_at": 1756753511, + "id": "0028826fe2134330b7d5a46fbd17a43b1368ec0f563f5636b585b1ad2ebf5136", + "kind": 38000, + "pubkey": "fe7e6de7f758dbdb06cac665a144eeb6898680298b4978ef1711b3e07238fe93", + "sig": "533f22c79b647b163d1115e43b8c8a772135beb465dad56dc04debddba4afd4324b53afa4f3d9e83458538390d1d328ae451c55f3c788e8ca4051ae2cf5b88c8", + "tags": [ + ["d", "b21068c84f5b12ca4fdf93f3e443d3bd7c27e8642d0d52ea2e4dce6fdbbee9df"], + ["k", "38173"], + ["rating", "3"] + ] + }, + { + "content": "[5/5] I'm SUPERMAX and this 'The Super Mint' is for educational and testing purposes. If you have questions or need help connecting, please contact me either on Nostr or email. Here's a quick site I made for information about The Super Mint here, https://tinyurl.com/thesupermint . Thanks all!", + "created_at": 1710867353, + "id": "00183f8eac08d8b34a75947a8e707600715b3d48aa8be35786891970fb2d7c7f", + "kind": 38000, + "pubkey": "ae1008d23930b776c18092f6eab41e4b09fcf3f03f3641b1b4e6ee3aa166d760", + "sig": "78eeace559cb384954f5f1abd34a142f2ffd97c3a0539b817552737b8a6569cfe8ad2a8310ee4e464037f728eb45f599eadab97fc0909bdfbefef038ef311f1b", + "tags": [ + ["k", "38172"], + ["u", "https://obsessedjerky7.lnbits.com/cashu/api/v1/L4bNfiBtTyhEpZWdt6QTb4", "cashu"], + ["d", "psvef0yh2zk24tt7"] + ] + }, + { + "content": "[5/5] Es un placer formar parte de esta bella comunidad, espero que este proyecto crezca y siga igual de positivo", + "created_at": 1746224475, + "id": "00230427076a58c4a2aa91f0236644b5125fcf76dfd165a5149424f9f3c7ee46", + "kind": 38000, + "pubkey": "2871a10447d80a1c7ca542488d9e09c10ed843641f6867b69fd92fb84bbe84dc", + "sig": "dcce911190c0b45a3bbc80f06a57dde11e9f7c189a8f9e2a47439d33b631fed5b8b7a06d00a775b4e2c321be2373a20b5cd749f4ad6c969919d9a3d1cb43c349", + "tags": [ + ["k", "38172"], + ["u", "https://mint.cubabitcoin.org", "cashu"], + ["a", "38172:cashu-mint-pubkey:nda1zjgriivc1xvv", "wss://bitcoiner.social", "cashu"], + ["d", "nda1zjgriivc1xvv"] + ] + }, + { + "content": "", + "created_at": 1718722206, + "id": "0015d072642960c96a589b06d341160e32d2c58cf5662d1a7742d765183e1d52", + "kind": 38000, + "pubkey": "7624c028f873a52abf025ea89ff8cd7599dcddd98c1199eebe3a7d76905bb138", + "sig": "1dd858e49587d0d4d24708dadcf99737858d5dd2e70ccaed632b25bbb690e24258b99818f45c50ed00e6043b1606e3e2edf3405e24a88d46481a1093d9150b53", + "tags": [ + ["d", "c944b2fd1e7fe04ca87f9a57d7894cb69116cec6264cb52faa71228f4ec54cd6"], + ["k", "38173"], + [ + "u", + "fed11qgqzz8mhwden5te0vejkg6tdd9h8gepwvchxjmm5w4hxgunp9e3k7mf0qyqjpj2ykt73ullqfj58lxjh67y5ed53zm8vvfjvk5h65ufz3a8v2nxky9wuce" + ], + ["n", "mainnet"] + ] + } + ] +} diff --git a/packages/core/src/nip87/corpus.test.ts b/packages/core/src/nip87/corpus.test.ts new file mode 100644 index 0000000..5070e1a --- /dev/null +++ b/packages/core/src/nip87/corpus.test.ts @@ -0,0 +1,149 @@ +import type { Event as NostrEvent } from "nostr-tools/core"; +import { describe, expect, it } from "vitest"; +import fixtures from "./__fixtures__/nip87-sample.json" with { type: "json" }; +import { isValidCashuDTag } from "./dtag"; +import { parseMintAnnouncement, parseRecommendation } from "./parse"; + +type Fixture = { + _meta: Record; + cashu38172BotSpam: NostrEvent[]; + cashu38172Legacy: NostrEvent[]; + cashu38172SpecConforming: NostrEvent[]; + fedimint38173: NostrEvent[]; + recommendations38000: NostrEvent[]; +}; +const f = fixtures as unknown as Fixture; + +/** + * Empirical-findings assertions for the curated corpus at + * __fixtures__/nip87-sample.json. See that file's `_meta.notes` for the + * provenance and composition. + */ +describe("NIP-87 corpus", () => { + it("has the expected event counts per bucket", () => { + expect(f.cashu38172BotSpam.length).toBe(5); + expect(f.cashu38172Legacy.length).toBe(1); + expect(f.cashu38172SpecConforming.length).toBe(2); + expect(f.fedimint38173.length).toBe(3); + expect(f.recommendations38000.length).toBe(5); + + const total = + f.cashu38172BotSpam.length + + f.cashu38172Legacy.length + + f.cashu38172SpecConforming.length + + f.fedimint38173.length + + f.recommendations38000.length; + expect(total).toBe(16); + }); + + it("Layer A accepts spec-conforming AND x-only Cashu announcements, rejects bot spam", () => { + const all38172: NostrEvent[] = [ + ...f.cashu38172BotSpam, + ...f.cashu38172Legacy, + ...f.cashu38172SpecConforming, + ]; + expect(all38172.length).toBe(8); + + const parsed = all38172 + .map((e) => parseMintAnnouncement(e)) + .filter((a): a is NonNullable => a !== null); + // All 8 parse successfully (parse does NOT gate on Layer A). + expect(parsed.length).toBe(8); + + const accepted = parsed.filter((a) => isValidCashuDTag(a.d)); + const rejected = parsed.filter((a) => !isValidCashuDTag(a.d)); + + // 2 SpecConforming (66-char compressed) + 1 Legacy (64-char x-only) = 3 accepted. + expect(accepted.length).toBe(3); + // 5 bot-spam (16-char random) = 5 rejected. + expect(rejected.length).toBe(5); + }); + + it("Layer A rejects all 5 bot-spam events (16-char d-tags)", () => { + for (const e of f.cashu38172BotSpam) { + const parsed = parseMintAnnouncement(e); + expect(parsed).not.toBeNull(); + expect(parsed && isValidCashuDTag(parsed.d)).toBe(false); + } + }); + + it("all bot-spam events in the fixture belong to the 972f233a... publisher", () => { + const BOT_PUBKEY = "972f233aa467bc9804032c0bce0a117daead5473c56c91e811a216bdd08c08cf"; + const botPubkeyCount = f.cashu38172BotSpam.filter((e) => e.pubkey === BOT_PUBKEY).length; + expect(botPubkeyCount).toBe(5); + }); + + it("Layer A accepts the 64-char x-only Nostrodomo announcement (de-facto mainstream shape)", () => { + for (const e of f.cashu38172Legacy) { + const parsed = parseMintAnnouncement(e); + expect(parsed).not.toBeNull(); + expect(parsed && isValidCashuDTag(parsed.d)).toBe(true); + // Sanity: it really is 64 chars, not 66. + expect(parsed?.d.length).toBe(64); + } + }); + + it("Layer A does NOT apply to Fedimint — all 3 parse, at least one has modules", () => { + const parsedFedi = f.fedimint38173 + .map((e) => parseMintAnnouncement(e)) + .filter((a): a is NonNullable => a !== null); + expect(parsedFedi.length).toBe(f.fedimint38173.length); + + for (const parsed of parsedFedi) { + expect(parsed.kind).toBe(38173); + expect(parsed.nuts).toBeUndefined(); + // `modules` is optional — some Fedimint announcements omit it. + if (parsed.modules !== undefined) { + expect(Array.isArray(parsed.modules)).toBe(true); + expect(parsed.modules.length).toBeGreaterThan(0); + } + // TODO-v1.1: Fedimint d-tag is a federation id — no Layer A equivalent + // yet. We deliberately do NOT call isValidCashuDTag on Fedimint events. + } + + // At least one of the curated fixtures should have modules populated. + const withModules = parsedFedi.filter((a) => a.modules !== undefined); + expect(withModules.length).toBeGreaterThanOrEqual(1); + }); + + it("every fixture recommendation parses", () => { + for (const e of f.recommendations38000) { + const parsed = parseRecommendation(e); + expect(parsed).not.toBeNull(); + if (!parsed) continue; + expect(parsed.kind).toBe(38000); + } + }); + + it("the fixture covers multiple rating-format paths", () => { + const parsed = f.recommendations38000 + .map((e) => parseRecommendation(e)) + .filter((r): r is NonNullable => r !== null); + + // At least one with rating, at least one without. + const withRating = parsed.filter((r) => r.rating !== undefined); + const withoutRating = parsed.filter((r) => r.rating === undefined); + expect(withRating.length).toBeGreaterThanOrEqual(1); + expect(withoutRating.length).toBeGreaterThanOrEqual(1); + + // All ratings in range [0,5]. + for (const r of withRating) { + expect(r.rating).toBeGreaterThanOrEqual(0); + expect(r.rating).toBeLessThanOrEqual(5); + } + }); + + it("the fixture includes at least one rec that uses the 2-arg ['rating','N'] tag format", () => { + const tagged = f.recommendations38000.filter((e) => + e.tags.some((t) => t[0] === "rating" && typeof t[1] === "string" && t[2] === undefined), + ); + expect(tagged.length).toBeGreaterThanOrEqual(1); + }); + + it("the fixture includes at least one rec that relies on the [N/5] content regex", () => { + const contentRating = f.recommendations38000.filter( + (e) => e.tags.every((t) => t[0] !== "rating") && /(\d(?:\.\d+)?)\s*\/\s*5/.test(e.content), + ); + expect(contentRating.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/packages/core/src/nip87/dtag.test.ts b/packages/core/src/nip87/dtag.test.ts new file mode 100644 index 0000000..f8f4c4f --- /dev/null +++ b/packages/core/src/nip87/dtag.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { D_TAG_REGEX, isValidCashuDTag } from "./dtag"; + +describe("isValidCashuDTag", () => { + describe("valid — 66-char compressed secp256k1 pubkeys", () => { + it("accepts a 02-prefixed 66-char lowercase hex d-tag", () => { + expect(isValidCashuDTag(`02${"0".repeat(64)}`)).toBe(true); + }); + + it("accepts a 03-prefixed 66-char lowercase hex d-tag", () => { + expect(isValidCashuDTag(`03${"a".repeat(64)}`)).toBe(true); + }); + + it("accepts a realistic-looking 02-prefixed pubkey", () => { + // From a real kind:38000 recommendation's d-tag, pointing to lemonfizz mint. + expect( + isValidCashuDTag("03c5f16604678b8b118a454db12885e586f0fc146788d54182b3ca7943a327278e"), + ).toBe(true); + }); + + it("accepts the full hex alphabet in a 66-char d-tag", () => { + expect(isValidCashuDTag(`02${"0123456789abcdef".repeat(4)}`)).toBe(true); + }); + }); + + describe("valid — 64-char x-only secp256k1 pubkeys (de-facto form)", () => { + it("accepts a real 64-char x-only d-tag (Nostrodomo Mint)", () => { + expect( + isValidCashuDTag("5fe928ae0970844f3c5253d2e85a88788486edcbd96c070334a4a2d0d0154a77"), + ).toBe(true); + }); + + it("accepts a 64-char d-tag starting with 00", () => { + // 64 chars, starts with 00 — would fail the 66-char branch but passes the 64-char branch. + expect(isValidCashuDTag(`00${"0".repeat(62)}`)).toBe(true); + }); + + it("accepts a 64-char d-tag starting with ff", () => { + // 64 chars, starts with ff — would fail the 66-char branch but passes the 64-char branch. + expect(isValidCashuDTag(`ff${"0".repeat(62)}`)).toBe(true); + }); + + it("accepts the full hex alphabet in a 64-char d-tag", () => { + expect(isValidCashuDTag("0123456789abcdef".repeat(4))).toBe(true); + }); + }); + + describe("invalid — shape mismatches", () => { + it("rejects 16-char bot-spam d-tags", () => { + // Real examples from the 972f233a... bot burst. + expect(isValidCashuDTag("ewakfwchz6tmlmvy")).toBe(false); + expect(isValidCashuDTag("rp8l2ez6vw3t4u2j")).toBe(false); + expect(isValidCashuDTag("psvef0yh2zk24tt7")).toBe(false); + expect(isValidCashuDTag("abc123def4567890")).toBe(false); + }); + + it("rejects 66-char d-tag with wrong prefix (uncompressed 04, or other)", () => { + // 04 prefix = uncompressed — wrong kind for Cashu's compressed-secp256k1 slot. + expect(isValidCashuDTag(`04${"0".repeat(64)}`)).toBe(false); + expect(isValidCashuDTag(`01${"0".repeat(64)}`)).toBe(false); + expect(isValidCashuDTag(`05${"0".repeat(64)}`)).toBe(false); + expect(isValidCashuDTag(`ff${"0".repeat(64)}`)).toBe(false); + expect(isValidCashuDTag(`aa${"0".repeat(64)}`)).toBe(false); + }); + + it("rejects 65-char d-tag (between the two valid lengths)", () => { + expect(isValidCashuDTag(`02${"0".repeat(63)}`)).toBe(false); + expect(isValidCashuDTag("0".repeat(65))).toBe(false); + }); + + it("rejects 67-char d-tag (one past 66)", () => { + expect(isValidCashuDTag(`02${"0".repeat(65)}`)).toBe(false); + expect(isValidCashuDTag("0".repeat(67))).toBe(false); + }); + + it("rejects too-short d-tag", () => { + expect(isValidCashuDTag(`02${"0".repeat(10)}`)).toBe(false); + expect(isValidCashuDTag("02")).toBe(false); + expect(isValidCashuDTag("0".repeat(63))).toBe(false); + }); + + it("rejects too-long d-tag", () => { + expect(isValidCashuDTag("0".repeat(128))).toBe(false); + expect(isValidCashuDTag(`02${"0".repeat(128)}`)).toBe(false); + }); + + it("rejects non-hex characters (64-char length)", () => { + expect(isValidCashuDTag("z".repeat(64))).toBe(false); + expect(isValidCashuDTag("g".repeat(64))).toBe(false); + expect(isValidCashuDTag(`${"0".repeat(63)}z`)).toBe(false); + }); + + it("rejects non-hex characters (66-char length)", () => { + expect(isValidCashuDTag(`02${"z".repeat(64)}`)).toBe(false); + expect(isValidCashuDTag(`02${"g".repeat(64)}`)).toBe(false); + expect(isValidCashuDTag(`02!@#$%^&*()${"0".repeat(55)}`)).toBe(false); + }); + + it("rejects uppercase hex (64-char) — regex is case-sensitive", () => { + expect(isValidCashuDTag("A".repeat(64))).toBe(false); + expect( + isValidCashuDTag("5FE928AE0970844F3C5253D2E85A88788486EDCBD96C070334A4A2D0D0154A77"), + ).toBe(false); + }); + + it("rejects uppercase hex (66-char) — regex is case-sensitive", () => { + expect(isValidCashuDTag(`02${"A".repeat(64)}`)).toBe(false); + expect( + isValidCashuDTag("02C5F16604678B8B118A454DB12885E586F0FC146788D54182B3CA7943A327278"), + ).toBe(false); + }); + + it("rejects empty string", () => { + expect(isValidCashuDTag("")).toBe(false); + }); + + it("rejects whitespace-only or whitespace-padded", () => { + expect(isValidCashuDTag(" ")).toBe(false); + expect(isValidCashuDTag(` 02${"0".repeat(64)}`)).toBe(false); + expect(isValidCashuDTag(`02${"0".repeat(64)} `)).toBe(false); + expect(isValidCashuDTag(` ${"0".repeat(64)}`)).toBe(false); + expect(isValidCashuDTag(`${"0".repeat(64)} `)).toBe(false); + }); + + it("rejects the strings 'null' and 'undefined' (sanity: if coerced from non-string)", () => { + expect(isValidCashuDTag("null")).toBe(false); + expect(isValidCashuDTag("undefined")).toBe(false); + }); + }); + + it("D_TAG_REGEX export is the live regex used by the validator", () => { + // 66-char branch live + expect(D_TAG_REGEX.test(`02${"0".repeat(64)}`)).toBe(true); + // 64-char branch live + expect(D_TAG_REGEX.test("0".repeat(64))).toBe(true); + // Nonsense rejected + expect(D_TAG_REGEX.test("not-a-pubkey")).toBe(false); + }); +}); diff --git a/packages/core/src/nip87/dtag.ts b/packages/core/src/nip87/dtag.ts new file mode 100644 index 0000000..729d735 --- /dev/null +++ b/packages/core/src/nip87/dtag.ts @@ -0,0 +1,37 @@ +/** + * Layer A d-tag shape validator for NIP-87 Cashu mint announcements. + * + * Empirical finding: zero real kind:38172 events in the wild conform to + * the strict 66-char compressed secp256k1 form per NUT-00 spec. Every + * real Cashu mint (e.g. Nostrodomo 5fe928ae...) publishes a 64-char + * x-only pubkey. A strict 66-char-only regex would reject 100% of real + * mints AND bot spam, defeating Layer A's purpose. + * + * The accepted shape is therefore the union of: + * - 64-char x-only secp256k1 (de-facto form real Cashu mints publish) + * - 66-char with `02`/`03` prefix = compressed secp256k1 per NUT-00 spec + * + * Layer B (NUT-06 signer binding via /v1/info) is a follow-up check that + * lives in PR #4 and confirms the pubkey corresponds to an actual Cashu + * mint. Layer A alone is cheap and still rejects: + * - Bot spam with random 16-char d-tags (959 events from 2025-02-13 per + * /srv/forge/projects/bitcoinmints/audit/relay-strategy-v1.md §4) + * - Any non-hex garbage + * - Wrong-length hex + * + * Fedimint (kind:38173) d-tags are federation IDs (different shape) — + * this validator applies to Cashu only (kind:38172). See TODO-v1.1 in + * parse.ts for Fedimint federation-id validation. + */ +// 64-char = x-only secp256k1 (de-facto form real Cashu mints publish) +// 66-char with 02/03 prefix = compressed secp256k1 per NUT-00 spec +// Bot spam (16-char random d-tags) rejected by both branches. +export const D_TAG_REGEX = /^([0-9a-f]{64}|0[23][0-9a-f]{64})$/; + +/** + * True iff `d` is either a 64-char x-only secp256k1 pubkey or a 66-char + * compressed secp256k1 pubkey, both lowercase hex. + */ +export function isValidCashuDTag(d: string): boolean { + return D_TAG_REGEX.test(d); +} diff --git a/packages/core/src/nip87/index.ts b/packages/core/src/nip87/index.ts new file mode 100644 index 0000000..eb58113 --- /dev/null +++ b/packages/core/src/nip87/index.ts @@ -0,0 +1,7 @@ +export { D_TAG_REGEX, isValidCashuDTag } from "./dtag"; +export { parseMintAnnouncement, parseRecommendation } from "./parse"; +export type { + MintAnnouncement, + MintAnnouncementNetwork, + MintRecommendation, +} from "./types"; diff --git a/packages/core/src/nip87/parse.test.ts b/packages/core/src/nip87/parse.test.ts new file mode 100644 index 0000000..a7dac25 --- /dev/null +++ b/packages/core/src/nip87/parse.test.ts @@ -0,0 +1,332 @@ +import type { Event as NostrEvent } from "nostr-tools/core"; +import { describe, expect, it } from "vitest"; +import fixtures from "./__fixtures__/nip87-sample.json" with { type: "json" }; +import { parseMintAnnouncement, parseRecommendation } from "./parse"; + +type Fixture = { + cashu38172BotSpam: NostrEvent[]; + cashu38172Legacy: NostrEvent[]; + cashu38172SpecConforming: NostrEvent[]; + fedimint38173: NostrEvent[]; + recommendations38000: NostrEvent[]; +}; +const f = fixtures as unknown as Fixture; + +describe("parseMintAnnouncement", () => { + it("parses a real kind:38172 bot-spam event into the expected shape", () => { + const event = f.cashu38172BotSpam[0]; + if (!event) throw new Error("fixture missing cashu38172BotSpam[0]"); + + const parsed = parseMintAnnouncement(event); + expect(parsed).not.toBeNull(); + if (!parsed) return; + + expect(parsed.eventId).toBe(event.id); + expect(parsed.kind).toBe(38172); + expect(parsed.pubkey).toBe(event.pubkey); + expect(parsed.createdAt).toBe(event.created_at); + expect(parsed.d).toBeTypeOf("string"); + expect(parsed.u.length).toBeGreaterThan(0); + expect(parsed.raw).toBe(event); + }); + + it("parses a spec-conforming 38172 with nuts tag into a number array", () => { + const event = f.cashu38172SpecConforming[0]; + if (!event) throw new Error("fixture missing cashu38172SpecConforming[0]"); + + const parsed = parseMintAnnouncement(event); + expect(parsed).not.toBeNull(); + if (!parsed) return; + + expect(parsed.kind).toBe(38172); + expect(parsed.nuts).toEqual([1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 14, 20]); + expect(parsed.modules).toBeUndefined(); + expect(parsed.n).toBe("mainnet"); + expect(parsed.u).toEqual(["https://mint.alpha.test"]); + expect(parsed.contentMetadata?.name).toBe("Mint Alpha (synthetic)"); + expect(parsed.contentMetadata?.picture).toBe("https://example.test/alpha.png"); + }); + + it("parses a 38172 with multiple u tags into u: string[]", () => { + const event = f.cashu38172SpecConforming[1]; + if (!event) throw new Error("fixture missing cashu38172SpecConforming[1]"); + + const parsed = parseMintAnnouncement(event); + expect(parsed).not.toBeNull(); + if (!parsed) return; + + expect(parsed.u).toEqual(["https://mint.beta.test", "https://mint.beta.test/v1"]); + // No `n` tag in this fixture. + expect(parsed.n).toBeUndefined(); + // Empty content -> no metadata. + expect(parsed.contentMetadata).toBeUndefined(); + }); + + it("parses a real kind:38173 Fedimint event with modules CSV", () => { + const event = f.fedimint38173[0]; + if (!event) throw new Error("fixture missing fedimint38173[0]"); + + const parsed = parseMintAnnouncement(event); + expect(parsed).not.toBeNull(); + if (!parsed) return; + + expect(parsed.kind).toBe(38173); + expect(parsed.nuts).toBeUndefined(); + expect(parsed.modules).toBeDefined(); + expect(Array.isArray(parsed.modules)).toBe(true); + // First Fedimint fixture's modules tag is "ln,mint,wallet,lnv2,meta,multi_sig_stability_pool". + expect(parsed.modules?.length).toBeGreaterThanOrEqual(2); + expect(parsed.u.length).toBeGreaterThan(0); + }); + + it("returns null when the `d` tag is missing", () => { + const bogus: NostrEvent = { + id: "noid", + pubkey: "nopubkey", + created_at: 0, + kind: 38172, + tags: [["u", "https://nope.test"]], + content: "", + sig: "nosig", + }; + expect(parseMintAnnouncement(bogus)).toBeNull(); + }); + + it("returns null when there are no `u` tags", () => { + const bogus: NostrEvent = { + id: "noid", + pubkey: "nopubkey", + created_at: 0, + kind: 38172, + tags: [["d", `02${"0".repeat(64)}`]], + content: "", + sig: "nosig", + }; + expect(parseMintAnnouncement(bogus)).toBeNull(); + }); + + it("returns null for unexpected kinds", () => { + const bogus: NostrEvent = { + id: "noid", + pubkey: "nopubkey", + created_at: 0, + kind: 1, + tags: [ + ["d", "x"], + ["u", "https://nope.test"], + ], + content: "", + sig: "nosig", + }; + expect(parseMintAnnouncement(bogus)).toBeNull(); + }); + + it("tolerates non-JSON content (contentMetadata undefined, no throw)", () => { + const event: NostrEvent = { + id: "ok", + pubkey: "0".repeat(64), + created_at: 1234, + kind: 38172, + tags: [ + ["d", `02${"0".repeat(64)}`], + ["u", "https://mint.example"], + ], + content: "[5/5] hello not JSON", + sig: "sig", + }; + const parsed = parseMintAnnouncement(event); + expect(parsed).not.toBeNull(); + expect(parsed?.contentMetadata).toBeUndefined(); + }); + + it("ignores unknown network values in `n` tag", () => { + const event: NostrEvent = { + id: "ok", + pubkey: "0".repeat(64), + created_at: 1234, + kind: 38172, + tags: [ + ["d", `02${"0".repeat(64)}`], + ["u", "https://mint.example"], + ["n", "bitcoin-but-weird"], + ], + content: "", + sig: "sig", + }; + const parsed = parseMintAnnouncement(event); + expect(parsed).not.toBeNull(); + expect(parsed?.n).toBeUndefined(); + }); +}); + +describe("parseRecommendation", () => { + it("parses a real kind:38000 event with 2-arg rating tag", () => { + // First fixture recommendation has ["rating","5"] + content "[5/5]". + const event = f.recommendations38000[0]; + if (!event) throw new Error("fixture missing recommendations38000[0]"); + + const parsed = parseRecommendation(event); + expect(parsed).not.toBeNull(); + if (!parsed) return; + + expect(parsed.kind).toBe(38000); + expect(parsed.eventId).toBe(event.id); + expect(parsed.rating).toBe(5); + // k tag references either 38172 or 38173 in all fixture recommendations. + expect([38172, 38173]).toContain(parsed.k); + expect(parsed.content).toBeTypeOf("string"); + expect(parsed.d).toBeTypeOf("string"); + }); + + it("parses 3-arg rating tag ['rating','N','5'] as canonical format", () => { + const event: NostrEvent = { + id: "canon", + pubkey: "0".repeat(64), + created_at: 1234, + kind: 38000, + tags: [ + ["d", `02${"0".repeat(64)}`], + ["k", "38172"], + ["rating", "4", "5"], + ], + content: "", + sig: "sig", + }; + const parsed = parseRecommendation(event); + expect(parsed?.rating).toBe(4); + }); + + it("prefers 3-arg rating tag over 2-arg when both present", () => { + const event: NostrEvent = { + id: "canon", + pubkey: "0".repeat(64), + created_at: 1234, + kind: 38000, + tags: [ + ["d", `02${"0".repeat(64)}`], + ["rating", "1"], + ["rating", "4", "5"], + ], + content: "", + sig: "sig", + }; + const parsed = parseRecommendation(event); + expect(parsed?.rating).toBe(4); + }); + + it("prefers tag rating over content regex rating", () => { + const event: NostrEvent = { + id: "canon", + pubkey: "0".repeat(64), + created_at: 1234, + kind: 38000, + tags: [ + ["d", `02${"0".repeat(64)}`], + ["rating", "3"], + ], + content: "[5/5] great", + sig: "sig", + }; + const parsed = parseRecommendation(event); + expect(parsed?.rating).toBe(3); + }); + + it("falls back to content regex when no rating tag present (real corpus case)", () => { + // Third fixture rec has only `k`, `u`, `d` tags + content "[5/5] I'm SUPERMAX…". + const event = f.recommendations38000[2]; + if (!event) throw new Error("fixture missing recommendations38000[2]"); + const parsed = parseRecommendation(event); + expect(parsed?.rating).toBe(5); + }); + + it("returns rating undefined when neither tag nor content regex match", () => { + // Fifth fixture rec is a real empty-content Fedimint rec with no rating. + const event = f.recommendations38000[4]; + if (!event) throw new Error("fixture missing recommendations38000[4]"); + const parsed = parseRecommendation(event); + expect(parsed).not.toBeNull(); + expect(parsed?.rating).toBeUndefined(); + }); + + it("accepts fractional ratings via content regex (3.5/5)", () => { + const event: NostrEvent = { + id: "frac", + pubkey: "0".repeat(64), + created_at: 1234, + kind: 38000, + tags: [["d", `02${"0".repeat(64)}`]], + content: "[3.5/5] mostly fine", + sig: "sig", + }; + const parsed = parseRecommendation(event); + expect(parsed?.rating).toBe(3.5); + }); + + it("rejects out-of-range ratings from content regex", () => { + const event: NostrEvent = { + id: "oob", + pubkey: "0".repeat(64), + created_at: 1234, + kind: 38000, + tags: [["d", `02${"0".repeat(64)}`]], + content: "[7/5] wild overshoot", + sig: "sig", + }; + const parsed = parseRecommendation(event); + // Regex only matches 1 digit before the slash; 7 is valid syntactically + // but fails the 0..5 range check. + expect(parsed?.rating).toBeUndefined(); + }); + + it("returns null when `d` tag missing", () => { + const event: NostrEvent = { + id: "nod", + pubkey: "0".repeat(64), + created_at: 1234, + kind: 38000, + tags: [["k", "38172"]], + content: "", + sig: "sig", + }; + expect(parseRecommendation(event)).toBeNull(); + }); + + it("returns null for wrong kinds", () => { + const event: NostrEvent = { + id: "nod", + pubkey: "0".repeat(64), + created_at: 1234, + kind: 1, + tags: [["d", "x"]], + content: "", + sig: "sig", + }; + expect(parseRecommendation(event)).toBeNull(); + }); + + it("preserves `k` when numeric, omits when missing", () => { + const withK: NostrEvent = { + id: "a", + pubkey: "0".repeat(64), + created_at: 1, + kind: 38000, + tags: [ + ["d", `02${"0".repeat(64)}`], + ["k", "38172"], + ], + content: "", + sig: "sig", + }; + const noK: NostrEvent = { + id: "b", + pubkey: "0".repeat(64), + created_at: 1, + kind: 38000, + tags: [["d", `02${"0".repeat(64)}`]], + content: "", + sig: "sig", + }; + expect(parseRecommendation(withK)?.k).toBe(38172); + expect(parseRecommendation(noK)?.k).toBeUndefined(); + }); +}); diff --git a/packages/core/src/nip87/parse.ts b/packages/core/src/nip87/parse.ts new file mode 100644 index 0000000..865ef65 --- /dev/null +++ b/packages/core/src/nip87/parse.ts @@ -0,0 +1,179 @@ +import type { Event as NostrEvent } from "nostr-tools/core"; +import type { MintAnnouncement, MintAnnouncementNetwork, MintRecommendation } from "./types"; + +const KNOWN_NETWORKS = new Set([ + "mainnet", + "testnet", + "signet", + "regtest", +]); + +/** Matches `[N/5]` or `[N.M/5]` anywhere in the content, with optional whitespace. */ +const CONTENT_RATING_REGEX = /(\d(?:\.\d+)?)\s*\/\s*5/; + +function firstTagValue(tags: string[][], name: string): string | undefined { + for (const t of tags) { + if (t[0] === name && typeof t[1] === "string") return t[1]; + } + return undefined; +} + +function allTagValues(tags: string[][], name: string): string[] { + const out: string[] = []; + for (const t of tags) { + if (t[0] === name && typeof t[1] === "string") out.push(t[1]); + } + return out; +} + +function parseNumberList(csv: string | undefined): number[] | undefined { + if (!csv) return undefined; + const parts = csv + .split(",") + .map((p) => p.trim()) + .filter((p) => p.length > 0); + const nums: number[] = []; + for (const p of parts) { + const n = Number.parseInt(p, 10); + if (Number.isFinite(n)) nums.push(n); + } + return nums.length > 0 ? nums : undefined; +} + +function parseStringList(csv: string | undefined): string[] | undefined { + if (!csv) return undefined; + const parts = csv + .split(",") + .map((p) => p.trim()) + .filter((p) => p.length > 0); + return parts.length > 0 ? parts : undefined; +} + +function parseNetwork(n: string | undefined): MintAnnouncementNetwork | undefined { + if (!n) return undefined; + return KNOWN_NETWORKS.has(n as MintAnnouncementNetwork) + ? (n as MintAnnouncementNetwork) + : undefined; +} + +function parseContentMetadata(content: string): MintAnnouncement["contentMetadata"] { + if (!content?.trim().startsWith("{")) return undefined; + try { + const parsed = JSON.parse(content) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as MintAnnouncement["contentMetadata"]; + } + } catch { + // Non-JSON content is common (e.g. "[5/5] decent") — tolerate silently. + } + return undefined; +} + +/** + * Parse a kind:38172 (Cashu) or kind:38173 (Fedimint) event into a + * MintAnnouncement. Returns null when required tags (d, at least one u) + * are missing. + * + * Does NOT validate the d-tag shape; pair with isValidCashuDTag(a.d) when + * filtering the Cashu subset. Fedimint d-tag shape validation is a + * TODO-v1.1 (federation ids have different structure). + */ +export function parseMintAnnouncement(event: NostrEvent): MintAnnouncement | null { + if (event.kind !== 38172 && event.kind !== 38173) return null; + + const d = firstTagValue(event.tags, "d"); + if (!d) return null; + + const u = allTagValues(event.tags, "u"); + if (u.length === 0) return null; + + const n = parseNetwork(firstTagValue(event.tags, "n")); + const contentMetadata = parseContentMetadata(event.content); + + const base: MintAnnouncement = { + eventId: event.id, + kind: event.kind, + pubkey: event.pubkey, + createdAt: event.created_at, + d, + u, + raw: event, + }; + + if (n !== undefined) base.n = n; + if (contentMetadata !== undefined) base.contentMetadata = contentMetadata; + + if (event.kind === 38172) { + const nuts = parseNumberList(firstTagValue(event.tags, "nuts")); + if (nuts !== undefined) base.nuts = nuts; + } else { + // kind:38173 Fedimint + const modules = parseStringList(firstTagValue(event.tags, "modules")); + if (modules !== undefined) base.modules = modules; + } + + return base; +} + +function parseRatingFromTags(tags: string[][]): number | undefined { + for (const t of tags) { + if (t[0] !== "rating") continue; + // Canonical v1 shape: ["rating","","5"] (N in 0..5, max as 3rd arg). + if (typeof t[1] === "string" && t[2] === "5") { + const n = Number.parseFloat(t[1]); + if (Number.isFinite(n) && n >= 0 && n <= 5) return n; + } + } + // Legacy recall-trainer emitter: ["rating",""] (no max). + for (const t of tags) { + if (t[0] !== "rating") continue; + if (typeof t[1] === "string" && t[2] === undefined) { + const n = Number.parseFloat(t[1]); + if (Number.isFinite(n) && n >= 0 && n <= 5) return n; + } + } + return undefined; +} + +function parseRatingFromContent(content: string): number | undefined { + const match = content.match(CONTENT_RATING_REGEX); + if (!match?.[1]) return undefined; + const n = Number.parseFloat(match[1]); + if (Number.isFinite(n) && n >= 0 && n <= 5) return n; + return undefined; +} + +/** + * Parse a kind:38000 event into a MintRecommendation. Returns null when + * required tag (d) is missing. Rating parsing order per spec: + * + * 1. ["rating","","5"] structured tag (canonical) + * 2. ["rating",""] legacy + * 3. `[N/5]` or `N/5` regex on content + * 4. undefined + */ +export function parseRecommendation(event: NostrEvent): MintRecommendation | null { + if (event.kind !== 38000) return null; + + const d = firstTagValue(event.tags, "d"); + if (d === undefined) return null; + + const kStr = firstTagValue(event.tags, "k"); + const k = kStr ? Number.parseInt(kStr, 10) : Number.NaN; + + const rating = parseRatingFromTags(event.tags) ?? parseRatingFromContent(event.content); + + const rec: MintRecommendation = { + eventId: event.id, + kind: 38000, + pubkey: event.pubkey, + createdAt: event.created_at, + d, + content: event.content ?? "", + raw: event, + }; + if (Number.isFinite(k)) rec.k = k; + if (rating !== undefined) rec.rating = rating; + + return rec; +} diff --git a/packages/core/src/nip87/types.ts b/packages/core/src/nip87/types.ts new file mode 100644 index 0000000..4e0a973 --- /dev/null +++ b/packages/core/src/nip87/types.ts @@ -0,0 +1,79 @@ +import type { Event as NostrEvent } from "nostr-tools/core"; + +export type MintAnnouncementNetwork = "mainnet" | "testnet" | "signet" | "regtest"; + +/** + * Parsed NIP-87 mint announcement (kind:38172 Cashu or kind:38173 Fedimint). + * + * This is a parsing result only — no validation of pubkey-vs-signer, no + * /v1/info enrichment, no ranking. Pure event-to-shape transformation. + * Layer A d-tag shape validation lives in dtag.ts; call + * isValidCashuDTag(a.d) separately when filtering the Cashu subset. + */ +export type MintAnnouncement = { + eventId: string; + kind: 38172 | 38173; + /** Signer of the event — NOT necessarily the mint operator. */ + pubkey: string; + createdAt: number; + /** + * Parameterized-replaceable d-tag. + * - Cashu (kind:38172): mint's compressed secp256k1 pubkey per spec + * (see dtag.ts for Layer A validation). + * - Fedimint (kind:38173): federation id (TODO-v1.1: shape validator + * for federation ids). + */ + d: string; + /** + * Canonical mint URL(s) for Cashu, or invite codes for Fedimint. + * Collected from all `u` tags in the event. + */ + u: string[]; + /** Cashu only: parsed from comma-joined `nuts` tag. Not present for Fedimint. */ + nuts?: number[]; + /** Fedimint only: parsed from comma-joined `modules` tag. Not present for Cashu. */ + modules?: string[]; + /** Network from optional `n` tag, when it's one of the known Bitcoin networks. */ + n?: MintAnnouncementNetwork; + /** + * kind-0-style JSON metadata embedded in the event content. May be + * absent (empty content), malformed (non-JSON content), or present. We + * tolerate all three and surface the parse result (or undefined). + */ + contentMetadata?: { + name?: string; + about?: string; + picture?: string; + nuts?: number[]; + [key: string]: unknown; + }; + /** Original event — preserved for rehydration and downstream signatures. */ + raw: NostrEvent; +}; + +/** + * Parsed NIP-87 mint recommendation / review (kind:38000). + * + * Uniquely keyed by (pubkey, d) per NIP-87 parameterized-replaceable semantics. + */ +export type MintRecommendation = { + eventId: string; + kind: 38000; + /** Reviewer (event signer). */ + pubkey: string; + createdAt: number; + /** + * Target mint identifier — matches an announcement's d-tag. For Cashu + * this should be a 66-char compressed pubkey; for Fedimint, a + * federation id. Legacy events and bot spam use other shapes — the + * parser is lenient and preserves whatever is there. + */ + d: string; + /** Parsed 0..5 rating (inclusive). See parse.ts for format precedence. */ + rating?: number; + /** Freeform review text — may include a `[N/5]` prefix, may be empty. */ + content: string; + /** Referenced announcement kind from optional `k` tag (38172 or 38173). */ + k?: number; + raw: NostrEvent; +}; diff --git a/packages/core/src/nostr/index.ts b/packages/core/src/nostr/index.ts new file mode 100644 index 0000000..c6fbd8d --- /dev/null +++ b/packages/core/src/nostr/index.ts @@ -0,0 +1,8 @@ +export { + createPool, + type Pool, + type PoolConfig, + type PoolHandle, + SEED_RELAYS, + type SubscribeOptions, +} from "./pool"; diff --git a/packages/core/src/nostr/pool.test.ts b/packages/core/src/nostr/pool.test.ts new file mode 100644 index 0000000..5f50ada --- /dev/null +++ b/packages/core/src/nostr/pool.test.ts @@ -0,0 +1,380 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock nostr-tools SimplePool at module load. Each pool.subscribeMany +// returns a closer we can spy on; pool.close() collects calls for inspection. +const subscribeManyMock = vi.fn(); +const closeMock = vi.fn(); +const seenOnMock = new Map>(); +const constructedPools: Array<{ trackRelays: boolean }> = []; + +vi.mock("nostr-tools/pool", () => { + return { + SimplePool: class { + // Mirror nostr-tools 2.23.3 default: trackRelays starts false and + // must be flipped on by the caller for seenOn to populate. + trackRelays = false; + seenOn = seenOnMock; + constructor() { + constructedPools.push(this); + } + subscribeMany(...args: unknown[]) { + return subscribeManyMock(...args); + } + close(...args: unknown[]) { + return closeMock(...args); + } + }, + }; +}); + +// Import after the mock is registered. +import { createPool, SEED_RELAYS } from "./pool"; + +describe("SEED_RELAYS", () => { + it("exports exactly the five-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. + expect(SEED_RELAYS).toEqual([ + "wss://nos.lol", + "wss://relay.damus.io", + "wss://relay.primal.net", + "wss://relay.8333.space", + "wss://relay.cashumints.space", + ]); + }); +}); + +describe("createPool", () => { + beforeEach(() => { + subscribeManyMock.mockReset(); + closeMock.mockReset(); + seenOnMock.clear(); + constructedPools.length = 0; + }); + + it("flips trackRelays=true on the underlying SimplePool so seenOn populates", () => { + // nostr-tools 2.23.3 defaults trackRelays to false, which silently + // disables seenOn. Without this flip every event would fall back to + // relays[0] and be misattributed. Regression guard for the bug found + // in PR #28 review. + createPool({ relays: [...SEED_RELAYS] }); + expect(constructedPools).toHaveLength(1); + expect(constructedPools[0]?.trackRelays).toBe(true); + }); + + it("returns a pool with subscribe() and close()", () => { + const pool = createPool({ relays: [...SEED_RELAYS] }); + expect(typeof pool.subscribe).toBe("function"); + expect(typeof pool.close).toBe("function"); + }); + + it("subscribe() returns a handle with close(); does not hit live relays", () => { + const innerCloser = { close: vi.fn() }; + subscribeManyMock.mockReturnValue(innerCloser); + + const pool = createPool({ relays: [...SEED_RELAYS] }); + const handle = pool.subscribe({ + filters: [{ kinds: [38172] }], + onEvent: () => {}, + }); + + expect(typeof handle.close).toBe("function"); + expect(subscribeManyMock).toHaveBeenCalledTimes(1); + expect(subscribeManyMock.mock.calls[0]?.[0]).toEqual([...SEED_RELAYS]); + expect(subscribeManyMock.mock.calls[0]?.[1]).toEqual({ kinds: [38172] }); + + handle.close(); + expect(innerCloser.close).toHaveBeenCalledTimes(1); + + // Double-close is safe. + handle.close(); + expect(innerCloser.close).toHaveBeenCalledTimes(1); + }); + + it("dispatches one subscription per filter entry", () => { + subscribeManyMock.mockReturnValue({ close: vi.fn() }); + + const pool = createPool({ relays: ["wss://example.test"] }); + pool.subscribe({ + filters: [{ kinds: [38172] }, { kinds: [38173] }, { kinds: [38000] }], + onEvent: () => {}, + }); + + expect(subscribeManyMock).toHaveBeenCalledTimes(3); + }); + + it("forwards events from subscribeMany's onevent to user callback with a relay url", () => { + let capturedOnevent: ((e: unknown) => void) | undefined; + subscribeManyMock.mockImplementation( + (_relays: string[], _filter: unknown, params: { onevent: (e: unknown) => void }) => { + capturedOnevent = params.onevent; + return { close: () => {} }; + }, + ); + + const received: Array<{ eventId: string; relay: string }> = []; + const pool = createPool({ relays: ["wss://a.test", "wss://b.test"] }); + pool.subscribe({ + filters: [{ kinds: [38000] }], + onEvent: (event, relay) => received.push({ eventId: event.id, relay }), + }); + + // Simulate an event delivery. seenOn maps event id -> set of relay-like objects. + const evt = { + id: "abc", + pubkey: "pk", + created_at: 1, + kind: 38000, + tags: [], + content: "", + sig: "", + }; + seenOnMock.set("abc", new Set([{ url: "wss://a.test" }])); + capturedOnevent?.(evt); + + expect(received).toHaveLength(1); + expect(received[0]?.eventId).toBe("abc"); + expect(received[0]?.relay).toBe("wss://a.test"); + }); + + it("drops events delivered after handle.close() (post-close gating)", () => { + // The inner closer awaits allOpened internally before tearing down + // the websocket subscription, so events can race past handle.close(). + // Wrapper must gate at the boundary so callers see clean shutdown. + let capturedOnevent: ((e: unknown) => void) | undefined; + subscribeManyMock.mockImplementation( + (_relays: string[], _filter: unknown, params: { onevent: (e: unknown) => void }) => { + capturedOnevent = params.onevent; + return { close: () => {} }; + }, + ); + + const received: string[] = []; + const pool = createPool({ relays: ["wss://a.test"] }); + const handle = pool.subscribe({ + filters: [{ kinds: [38000] }], + onEvent: (event) => received.push(event.id), + }); + + const evt = (id: string) => ({ + id, + pubkey: "pk", + created_at: 1, + kind: 38000, + tags: [], + content: "", + sig: "", + }); + + seenOnMock.set("before", new Set([{ url: "wss://a.test" }])); + capturedOnevent?.(evt("before")); + expect(received).toEqual(["before"]); + + handle.close(); + + // Late delivery from the still-tearing-down subscription. Should be + // silently dropped. + seenOnMock.set("after", new Set([{ url: "wss://a.test" }])); + capturedOnevent?.(evt("after")); + expect(received).toEqual(["before"]); + }); + + it("fires onEose once with the '*' placeholder (subscribeMany aggregates EOSE)", () => { + // subscribeMany emits a single oneose after all relays EOSE without + // surfacing which relay EOSE'd. Our wrapper documents this by passing + // "*" — callers must not assume one-call-per-relay semantics. + let capturedOneose: (() => void) | undefined; + subscribeManyMock.mockImplementation( + (_relays: string[], _filter: unknown, params: { oneose?: () => void }) => { + capturedOneose = params.oneose; + return { close: () => {} }; + }, + ); + + const eoseRelays: string[] = []; + const pool = createPool({ relays: ["wss://a.test", "wss://b.test"] }); + pool.subscribe({ + filters: [{ kinds: [38000] }], + onEvent: () => {}, + onEose: (relay) => eoseRelays.push(relay), + }); + + capturedOneose?.(); + expect(eoseRelays).toEqual(["*"]); + + // A second oneose tick (e.g. duplicate fire) would still report "*" + // — this is contract, not bug. + capturedOneose?.(); + expect(eoseRelays).toEqual(["*", "*"]); + }); + + it("close() forwards the configured relay list to SimplePool.close", () => { + subscribeManyMock.mockReturnValue({ close: vi.fn() }); + + const relays = ["wss://one.test", "wss://two.test"]; + const pool = createPool({ relays }); + pool.close(); + + expect(closeMock).toHaveBeenCalledTimes(1); + expect(closeMock.mock.calls[0]?.[0]).toEqual(relays); + }); + + it("does not mutate the caller's relay array", () => { + subscribeManyMock.mockReturnValue({ close: vi.fn() }); + + const relays = ["wss://one.test"]; + const pool = createPool({ relays }); + relays.push("wss://mutated.test"); + pool.subscribe({ filters: [{ kinds: [1] }], onEvent: () => {} }); + + // First call should have used the original single-entry list. + expect(subscribeManyMock.mock.calls[0]?.[0]).toEqual(["wss://one.test"]); + }); + + it("subscribe() with an empty filter array is a no-op (no subscribeMany, close is safe)", () => { + subscribeManyMock.mockReturnValue({ close: vi.fn() }); + + const pool = createPool({ relays: [...SEED_RELAYS] }); + const handle = pool.subscribe({ filters: [], onEvent: () => {} }); + + expect(subscribeManyMock).not.toHaveBeenCalled(); + + // close() must not throw and must remain idempotent even with no + // underlying subscriptions. + expect(() => handle.close()).not.toThrow(); + expect(() => handle.close()).not.toThrow(); + }); + + it("closeOnEose:true without onEose: auto-closes after EOSE; subsequent events are dropped", () => { + let capturedOnevent: ((e: unknown) => void) | undefined; + let capturedOneose: (() => void) | undefined; + const innerClose = vi.fn(); + subscribeManyMock.mockImplementation( + ( + _relays: string[], + _filter: unknown, + params: { onevent: (e: unknown) => void; oneose?: () => void }, + ) => { + capturedOnevent = params.onevent; + capturedOneose = params.oneose; + return { close: innerClose }; + }, + ); + + const received: string[] = []; + const pool = createPool({ relays: ["wss://a.test"] }); + pool.subscribe({ + filters: [{ kinds: [38000] }], + onEvent: (event) => received.push(event.id), + closeOnEose: true, + // Intentionally no onEose — exercises the closeOnEose-only branch + // (different oneose closure than the one with onEose set). + }); + + // oneose handler must be wired even without onEose so closeOnEose + // can do its job. + expect(capturedOneose).toBeDefined(); + + // Pre-EOSE event flows through. + const evt = (id: string) => ({ + id, + pubkey: "pk", + created_at: 1, + kind: 38000, + tags: [], + content: "", + sig: "", + }); + seenOnMock.set("pre", new Set([{ url: "wss://a.test" }])); + capturedOnevent?.(evt("pre")); + expect(received).toEqual(["pre"]); + + // EOSE fires -> handle should auto-close. + capturedOneose?.(); + expect(innerClose).toHaveBeenCalledTimes(1); + + // Late event must be dropped (post-close gating). + seenOnMock.set("post", new Set([{ url: "wss://a.test" }])); + capturedOnevent?.(evt("post")); + expect(received).toEqual(["pre"]); + }); + + it("multiple concurrent subscribes are isolated: each handle gets its own events; closing one leaves the other live", () => { + // Each subscribeMany call gets its own onevent/closer pair. Capture + // them so we can fire events into one subscription at a time and + // verify isolation. + type Capture = { + onevent: (e: unknown) => void; + close: ReturnType; + filter: unknown; + }; + const captures: Capture[] = []; + subscribeManyMock.mockImplementation( + (_relays: string[], filter: unknown, params: { onevent: (e: unknown) => void }) => { + const close = vi.fn(); + captures.push({ onevent: params.onevent, close, filter }); + return { close }; + }, + ); + + const pool = createPool({ relays: ["wss://a.test"] }); + const receivedA: string[] = []; + const receivedB: string[] = []; + const handleA = pool.subscribe({ + filters: [{ kinds: [38172] }], + onEvent: (event) => receivedA.push(event.id), + }); + const handleB = pool.subscribe({ + filters: [{ kinds: [38000] }], + onEvent: (event) => receivedB.push(event.id), + }); + + expect(captures).toHaveLength(2); + // Ordered by subscribe() call order. + expect(captures[0]?.filter).toEqual({ kinds: [38172] }); + expect(captures[1]?.filter).toEqual({ kinds: [38000] }); + + // Fire an event into A only. + const evt = (id: string, kind: number) => ({ + id, + pubkey: "pk", + created_at: 1, + kind, + tags: [], + content: "", + sig: "", + }); + seenOnMock.set("a1", new Set([{ url: "wss://a.test" }])); + captures[0]?.onevent(evt("a1", 38172)); + expect(receivedA).toEqual(["a1"]); + expect(receivedB).toEqual([]); + + // Fire an event into B only. + seenOnMock.set("b1", new Set([{ url: "wss://a.test" }])); + captures[1]?.onevent(evt("b1", 38000)); + expect(receivedA).toEqual(["a1"]); + expect(receivedB).toEqual(["b1"]); + + // Close A. B's underlying closer must not fire and B must keep + // delivering. + handleA.close(); + expect(captures[0]?.close).toHaveBeenCalledTimes(1); + expect(captures[1]?.close).not.toHaveBeenCalled(); + + seenOnMock.set("b2", new Set([{ url: "wss://a.test" }])); + captures[1]?.onevent(evt("b2", 38000)); + expect(receivedB).toEqual(["b1", "b2"]); + + // Late event into A is dropped (post-close gating from Fix 3 + // applies per-handle). + seenOnMock.set("a2", new Set([{ url: "wss://a.test" }])); + captures[0]?.onevent(evt("a2", 38172)); + expect(receivedA).toEqual(["a1"]); + + // Closing B now tears down only B's closer. + handleB.close(); + expect(captures[1]?.close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/nostr/pool.ts b/packages/core/src/nostr/pool.ts new file mode 100644 index 0000000..28ca6df --- /dev/null +++ b/packages/core/src/nostr/pool.ts @@ -0,0 +1,131 @@ +import type { Event as NostrEvent } from "nostr-tools/core"; +import type { Filter } from "nostr-tools/filter"; +import { SimplePool } from "nostr-tools/pool"; + +/** + * Seed relay pool. The first three (nos.lol, relay.damus.io, relay.primal.net) + * are the ecosystem-consensus top 3 from an implementor-default survey across + * cashu.me, bitpoints.me, cashumints.space site, cashu-mint-page, and the + * current bitcoinmints main. Damus 6/6, nos.lol 5/6, primal 4/6. Empirically, + * 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). + */ +export const SEED_RELAYS: readonly string[] = [ + "wss://nos.lol", + "wss://relay.damus.io", + "wss://relay.primal.net", + "wss://relay.8333.space", + "wss://relay.cashumints.space", +]; + +export type PoolConfig = { + relays: string[]; +}; + +export type SubscribeOptions = { + /** One or more filters. Each filter is dispatched as its own subscription. */ + filters: Filter[]; + /** Called for each matching event; `relay` is the wss:// URL that delivered it. */ + onEvent: (event: NostrEvent, relay: string) => void; + /** + * Called once after all relays signal EOSE (or eoseTimeout fires). The + * `relay` arg is the placeholder `"*"` because subscribeMany aggregates + * EOSE across relays — the underlying API does not surface which relay + * EOSE'd. Per-relay EOSE will require switching to per-relay subscribes + * (deferred to PR #4). + */ + onEose?: (relay: string) => void; + /** If true, close the subscription after all relays signal EOSE. Default: false (live). */ + closeOnEose?: boolean; +}; + +export type PoolHandle = { + /** Close all subscriptions created by this handle. Safe to call repeatedly. */ + close: () => void; +}; + +export type Pool = { + /** Open a subscription across the configured relays for the given filters. */ + subscribe: (opts: SubscribeOptions) => PoolHandle; + /** Close all relay connections managed by this pool. */ + close: () => void; +}; + +/** + * Thin wrapper around nostr-tools SimplePool. Intentionally omits signer, + * publish, and NIP-65 outbox logic — those are out of scope for the + * read-only directory. See PR #2 scope notes for the reasoning. + */ +export function createPool(config: PoolConfig): Pool { + const pool = new SimplePool(); + // nostr-tools 2.23.3 ships with trackRelays defaulting to false, which + // means pool.seenOn never gets populated and our event-routing falls back + // to relays[0] for every event. Flip it on so seenOn actually reflects + // which relay delivered each event. + pool.trackRelays = true; + const relays = [...config.relays]; + + return { + subscribe(opts: SubscribeOptions): PoolHandle { + // Hoisted before subscribeMany so the onevent/oneose closures below + // can read it. handle.close() may resolve before the underlying + // closer actually tears down the websocket subscription (the inner + // closer awaits allOpened internally), so a late-arriving event + // must be dropped at the wrapper boundary to honor the close + // contract. + let closed = false; + const closers = opts.filters.map((filter) => + pool.subscribeMany(relays, filter, { + onevent: (event: NostrEvent) => { + if (closed) return; + // nostr-tools doesn't expose the delivering relay on the event + // directly in subscribeMany's onevent; use seenOn to look up + // which relay(s) reported this event id. + const seen = pool.seenOn.get(event.id); + const firstRelay = seen?.values().next().value?.url; + opts.onEvent(event, firstRelay ?? relays[0] ?? ""); + }, + oneose: opts.onEose + ? () => { + if (closed) return; + // subscribeMany signals oneose once total (after all relays + // EOSE, or eoseTimeout fires) without surfacing which relay + // EOSE'd. Emit "*" as a placeholder so callers can still + // observe the boundary between stored and live events. + // TODO(PR #4): if per-relay EOSE is needed (e.g. for + // single-slow-relay timeout handling), switch to + // per-relay subscribes instead of subscribeMany. + opts.onEose?.("*"); + if (opts.closeOnEose) { + closed = true; + for (const c of closers) c.close(); + } + } + : opts.closeOnEose + ? () => { + if (closed) return; + closed = true; + for (const c of closers) c.close(); + } + : undefined, + }), + ); + + return { + close() { + if (closed) return; + closed = true; + for (const c of closers) c.close(); + }, + }; + }, + close() { + pool.close(relays); + }, + }; +}