Skip to content

feat: NIP-87 conformance pass (P0 + P1 from audit/nip87-conformance-v1.md)#33

Merged
orveth merged 4 commits into
v2from
feat/nip87-conformance
Apr 18, 2026
Merged

feat: NIP-87 conformance pass (P0 + P1 from audit/nip87-conformance-v1.md)#33
orveth merged 4 commits into
v2from
feat/nip87-conformance

Conversation

@orveth
Copy link
Copy Markdown

@orveth orveth commented Apr 18, 2026

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.pubkey OR entries in /v1/info.contact with method === "nostr". npub/hex normalization handled via nostr-tools nip19.decode. Soft validation: real mismatch (against any usable source) sets verifiedBySignerBinding: false; genuinely-unverifiable cases (no pubkey AND no contact.nostr) set null via the new no-signer-source reason. No events are hidden — UI still renders verified/unverified/pending badges.

Added a fixture exercising the spec-conforming event.pubkey != d case the prior corpus didn't cover (the audit's documented blind spot).

P0.2 — Soft-handle missing /v1/info.pubkey

NUT-06 says pubkey is optional. We no longer hard-reject; the MintInfoResult passes through with pubkey undefined and Layer B falls through to contact.nostr per P0.1. Empty-string pubkey is dropped defensively (don't compare against "").

P0.3 — Enforce k tag on reviews

Review events without a valid k tag ("38172" or "38173") are rejected at parse (rejectedByParse++). The Cashu-default fallback in cache/upsert.ts is removed; ReviewRow.k is typed required. Added a P0.3 regression test covering the previously-silent k-less default path.

P1 — Parse a tag on reviews

Spec-blessed target pointer (<kind>:<pubkey>:<d>) is now extracted, validated, and stored on ReviewRow.a. Validation rules: 3 colon-separated parts, kind must match the k tag, pubkey must be 64-char lowercase hex, d must equal the review's own d-tag. Reviews missing or malformed in a are rejected at parse. Fixture corpus + reviews/corpus.test.ts real-events list updated so every sample carries a well-formed a.

P1 — Unify rating regex

Two [N/5] regexes existed for the same event content (un-anchored float in nip87/parse.ts, anchored integer in reviews/parse.ts), producing different verdicts depending on which path the event went through. Consolidated to the anchored integer-only form in reviews/parse.ts and re-exported as CONTENT_FIVE_REGEX / CONTENT_TEN_REGEX / CONTENT_EMOJI_REGEX. nip87/parse.ts now imports the canonical regex.

Test count

225 → 243 passing.

Judgment calls beyond the brief

  • The brief said "reject reviews missing a tag" even though the audit text explicitly calls a "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.
  • Layer B normalizeSignerIdentity accepts hex strings of any length (not just 64/66-char) for compare. Real info.pubkey in 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.
  • New Layer B failure reason no-signer-source for the case where /v1/info responds ok but exposes neither pubkey nor contact.nostr. Scheduler's verdictForPersistence already maps unknown failure reasons to null (transient), so this drops in cleanly without a scheduler-side change.

NOT in scope

  • Replaceable event handling audit (separate worker / PR)
  • Publish flow (out of v1)
  • P2 hygiene items in audit (backlog)
  • parseRecommendation (legacy nip87 parse layer) is kept but has its rating extractor unified with parseReview. Full deprecation deferred.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com

orveth and others added 3 commits April 17, 2026 17:09
…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>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 18, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
bitcoinmints Error Error Apr 18, 2026 1:06am

Request Review

…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>
@orveth orveth merged commit 2c65acb into v2 Apr 18, 2026
1 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant