feat: NIP-87 conformance pass (P0 + P1 from audit/nip87-conformance-v1.md)#33
Merged
Conversation
…pubkey (P0.1, P0.2) P0.1 — Layer B now matches event.pubkey against EITHER `/v1/info.pubkey` OR any `/v1/info.contact[?method=nostr].info` entry (hex or npub form, normalized to lowercase hex via nostr-tools nip19.decode). NUT-06's contact field is the spec-blessed way for a mint to declare its operator nostr identity, and many real spec-conforming announcements sign with an app/operator key distinct from the mint's secp256k1 d-tag value. Either source counts as positive evidence; mismatch against ALL declared sources remains a real `false` verdict. Adds a new failure reason `no-signer-source` for the case where /v1/info returns ok but exposes no usable signer source (no pubkey AND no contact.nostr). The scheduler maps this to `null` (genuinely unverifiable) rather than `false` (mismatch), per the audit's soft-fail intent. Also adds a fixture exercising the spec-conforming `event.pubkey != d` case the prior corpus didn't cover (the audit's documented blind spot). P0.2 — `/v1/info.pubkey` is OPTIONAL per NUT-06. The prior hard-reject on missing pubkey was rejecting spec-conforming pubkey-less mints. Soft now: response passes through with `pubkey` undefined; Layer B falls through to contact.nostr per P0.1. Tests: 225 → 232 passing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
P0.3 — NIP-87 mandates a `k` tag on kind:38000 review events with value "38172" (Cashu) or "38173" (Fedimint). Previously the parser accepted k-less reviews and the cache layer silently defaulted to Cashu when k was missing, misrouting Fedimint reviews and weakening the bot-spam firewall. Now `parseReview` rejects events without a valid k at parse time (rejectedByParse++); the cache-layer Cashu-default fallback is removed, and `ReviewRow.k` is typed required. P1 — NIP-87 specifies an `a` tag of form `<kind>:<pubkey>:<d>` as the spec-blessed target pointer for reviews. We previously ignored it. `parseReview` now extracts and validates `a`: the format must be three colon-separated parts with `kind` matching the k tag, `pubkey` 64-char lowercase hex, and `d` matching the review's own d-tag. Reviews missing or malformed in `a` are rejected at parse. The parsed `a` lands on `ReviewRow.a` (typed required). Switched the integration test from `parseRecommendation` (the legacy nip87 layer) to `parseReview` (the strict reviews-layer parser) — the production path. Updated the fixture corpus + `reviews/corpus.test.ts` real-events list so every sample carries a well-formed `a` tag. Tests: 232 → 242 passing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two `[N/5]` regexes existed for the same event content: - `nip87/parse.ts`: `/(\d(?:\.\d+)?)\s*\/\s*5/` — un-anchored, floats OK - `reviews/parse.ts`: `/^\s*\[?\s*(\d+)\s*\/\s*5\b/` — anchored, integers Same input ([5/5] mid-content) produced different verdicts depending on which path the event went through. Per the audit + brief: consolidate on the anchored integer form (`reviews/parse.ts`), which is what the live production path uses. The anchored form encodes the ecosystem convention (reviewers lead with the rating); decimal `3.5/5` no longer parses, and no real-corpus event observed in audit data uses that shape. The canonical regexes (CONTENT_FIVE_REGEX, CONTENT_TEN_REGEX, CONTENT_EMOJI_REGEX) are now exported from `reviews/parse.ts` and imported by `nip87/parse.ts`. Updated the corresponding test to assert the new (correct) behavior. Tests: 242 → 243 passing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…fixup) Four new test cases covering gaps flagged by the test auditor: - scheduler: rejectedByParse wiring for missing k / missing a / malformed a (P0.3/P1) - scheduler: no-signer-source persists as null, not false (P0.2 + restart-replay re-enqueue) - integration: k=38173 reviews route through the Fedimint gate, materialize aggregates keyed by the federation d-tag (P0.3) - layerB: info.pubkey match wins over mismatched contact.nostr (P0.1 union semantics) Test count 243 → 247. No production code touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced Apr 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
NIP-87 conformance pass. After the 31-relay ecosystem sweep surfaced the real shape of the substrate, gudnuf's call was to tighten to spec and populate the ecosystem ourselves later. This PR addresses the P0 + P1 items from
/srv/forge/projects/bitcoinmints/audit/nip87-conformance-v1.md.Changes
P0.1 — Expand Layer B signer binding sources
Event signer is now compared against EITHER
/v1/info.pubkeyOR entries in/v1/info.contactwithmethod === "nostr". npub/hex normalization handled vianostr-toolsnip19.decode. Soft validation: real mismatch (against any usable source) setsverifiedBySignerBinding: false; genuinely-unverifiable cases (nopubkeyAND nocontact.nostr) setnullvia the newno-signer-sourcereason. No events are hidden — UI still renders verified/unverified/pending badges.Added a fixture exercising the spec-conforming
event.pubkey != dcase the prior corpus didn't cover (the audit's documented blind spot).P0.2 — Soft-handle missing
/v1/info.pubkeyNUT-06 says pubkey is optional. We no longer hard-reject; the
MintInfoResultpasses through withpubkeyundefined and Layer B falls through tocontact.nostrper P0.1. Empty-string pubkey is dropped defensively (don't compare against"").P0.3 — Enforce
ktag on reviewsReview events without a valid
ktag ("38172"or"38173") are rejected at parse (rejectedByParse++). The Cashu-default fallback incache/upsert.tsis removed;ReviewRow.kis typed required. Added a P0.3 regression test covering the previously-silent k-less default path.P1 — Parse
atag on reviewsSpec-blessed target pointer (
<kind>:<pubkey>:<d>) is now extracted, validated, and stored onReviewRow.a. Validation rules: 3 colon-separated parts,kindmust match thektag,pubkeymust be 64-char lowercase hex,dmust equal the review's own d-tag. Reviews missing or malformed inaare rejected at parse. Fixture corpus +reviews/corpus.test.tsreal-events list updated so every sample carries a well-formeda.P1 — Unify rating regex
Two
[N/5]regexes existed for the same event content (un-anchored float innip87/parse.ts, anchored integer inreviews/parse.ts), producing different verdicts depending on which path the event went through. Consolidated to the anchored integer-only form inreviews/parse.tsand re-exported asCONTENT_FIVE_REGEX/CONTENT_TEN_REGEX/CONTENT_EMOJI_REGEX.nip87/parse.tsnow imports the canonical regex.Test count
225 → 243 passing.
Judgment calls beyond the brief
atag" even though the audit text explicitly callsa"optional" per spec. Honored the brief; updated fixture corpus rather than relax the gate. This is a tightening above strict-spec-conformance — defensible because our directory's value-add is dedup correctness.normalizeSignerIdentityaccepts hex strings of any length (not just 64/66-char) for compare. Realinfo.pubkeyin the wild varies between SEC1-compressed and x-only; gating on canonical length here would silently break verification for legitimate variants. Bech32 npub still gets a strict decode.no-signer-sourcefor the case where /v1/info responds ok but exposes neitherpubkeynorcontact.nostr. Scheduler'sverdictForPersistencealready maps unknown failure reasons tonull(transient), so this drops in cleanly without a scheduler-side change.NOT in scope
parseRecommendation(legacy nip87 parse layer) is kept but has its rating extractor unified withparseReview. Full deprecation deferred.🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com