lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit cb9e46dd62eb275ad53c4c43acf974dce317a0c9
parent 1854f6167578b526f394ac78dae799a7c2383ad0
Author: triesap <tyson@radroots.org>
Date:   Sun, 19 Jul 2026 10:07:45 +0000

event: seal NIP-10 Reply boundaries

- add opaque direct and nested Reply models with deterministic marked authoring
- admit signature-verified marked and positional replies with ordered diagnostics
- reserve kind 1 publication for typed post and Reply builders
- execute signed conformance vectors and align registry and release contracts

Diffstat:
MCHANGELOG.md | 35+++++++++++++++++++++++++----------
Mcontracts/conformance/vectors/knowledge/manifest_and_decode.v1.json | 4++--
Mcontracts/conformance/vectors/post/verified_profiles.v1.json | 742+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/event_boundary_matrix.md | 26++++++++++++++++++++++++--
Mcontracts/events/social-events.md | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mcontracts/knowledge/knowledge_event_contract_manifest.v2.json | 2+-
Mcontracts/knowledge/knowledge_event_contract_manifest.v2.sha256 | 2+-
Mcontracts/operations.toml | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/releases/1.0.0-alpha.1.toml | 15+++++++++++++++
Mcrates/authority/src/authorization.rs | 6+++---
Mcrates/authority/src/error.rs | 19++++++++++++-------
Mcrates/event/README | 8++++++++
Mcrates/event/src/contract.rs | 35+++++++++++++++++++++++++++++++++--
Mcrates/event/src/draft.rs | 13++++++++-----
Mcrates/event/src/lib.rs | 1+
Acrates/event/src/reply.rs | 481+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/README | 8++++++++
Mcrates/event_codec/src/lib.rs | 1+
Mcrates/event_codec/src/post/admission.rs | 4++--
Acrates/event_codec/src/reply/admission.rs | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_codec/src/reply/authored.rs | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_codec/src/reply/inbound.rs | 1134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_codec/src/reply/mod.rs | 3+++
Mcrates/event_codec/tests/fixtures/post_verified_profiles.v1.json | 742+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/tests/verified_post_conformance.rs | 268++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/net/src/nostr_client/events/post.rs | 46+++++++++++++++-------------------------------
Mcrates/nostr/Cargo.toml | 4++++
Mcrates/nostr/README | 32+++++++++++++++++++++-----------
Mcrates/nostr/src/client.rs | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mcrates/nostr/src/events/mod.rs | 2++
Mcrates/nostr/src/events/post.rs | 31++-----------------------------
Acrates/nostr/src/events/reply.rs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/nostr/src/lib.rs | 10++++++++--
Mcrates/nostr/src/types.rs | 31+++++++++++++++----------------
Mcrates/nostr/tests/coverage.rs | 103+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Acrates/nostr/tests/nip10_reply_profile.rs | 225+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/contract.rs | 242+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
37 files changed, 4595 insertions(+), 214 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -47,13 +47,19 @@ publish policy both pass for the same source revision. separates every `e`-tagged event as a thread-excluded candidate without a Reply claim, and deterministically admits Ask, PhotoUpdate, or Update roots while preserving malformed media diagnostics. +- NIP-10 Reply authoring now requires an opaque direct or nested type and emits + exact marked `root`/`reply` event references plus required participant + references. Verified inbound projection accepts preferred marked and + deprecated positional threading, preserves valid supplemental references as + citations, and retains malformed advisory metadata as ordered diagnostics + while keeping every admitted Reply out of root-card classification. - Typed root posts now enter signing and client publication through an opaque builder with no raw tag/content mutation. Generic builder direct signing and - client publication reject kind `0` plus unmarked kind `1` before signer - access; externally supplied unsigned events, NIP-46 signing, and signed-event - relay remain explicit low-level Nostr interoperability with no typed product + client publication reject kind `0` plus every kind `1` before signer access; + externally supplied unsigned events, NIP-46 signing, and signed-event relay + remain explicit low-level Nostr interoperability with no typed product authoring claim. -- Frozen drafts now carry event-contract registry version `4`, revalidate all +- Frozen drafts now carry event-contract registry version `5`, revalidate all persisted fields and the recomputed event id during deserialization and signing, and enforce explicit `GenericDraft`, `TypedOnly`, and `ReadOnly` authoring policies. @@ -115,6 +121,11 @@ publish policy both pass for the same source revision. metadata retention, and stable rejection codes. Operation-owned vectors also execute every typed post authoring, projection, and admission function and compare complete deterministic outputs. +- Raw signed NIP-10 vectors execute marked direct and nested Replies, marked + supplemental citations, deprecated positional empty-marker author hints, + malformed middle-citation tolerance, participant and relay validation, + classifier precedence, signature-gated admission, and stable invalid-case + codes through the public owning APIs. ### Removed @@ -141,9 +152,13 @@ publish policy both pass for the same source revision. protocol's unsigned event explicitly. - Permissive `RadrootsPost` tag authoring, the free-form Nostr post builder, and the generic net custom publisher were removed. Product-root publication now - requires one of the strict authored Update, PhotoUpdate, or Ask states; the - generic protocol builder remains only behind a root-kind-`1` publication - guard and for non-product interoperability. + requires one of the strict authored Update, PhotoUpdate, or Ask states. + Generic kind-1 authoring is no longer available through the generic protocol + builder; non-product interoperability remains available only for + non-reserved kinds. +- The permissive post-reply builder and raw-string net reply publisher were + removed. Reply signing and client publication now require the typed NIP-10 + Reply boundary. ### Compatibility @@ -158,9 +173,9 @@ publish policy both pass for the same source revision. compatibility version instead of package SemVer. Existing backups stamped by `0.1.0-alpha.2` remain restorable because this release does not change their stored schema. -- Persisted frozen drafts with event-contract registry versions `1`, `2`, or - `3` are rejected and must be reconstructed against registry version `4`; strict - Profile plus typed and read-only post contracts can no longer be +- Persisted frozen drafts with event-contract registry versions `1`, `2`, `3`, + or `4` are rejected and must be reconstructed against registry version `5`; + strict Profile plus typed and read-only post contracts can no longer be reconstructed as generic drafts. - This breaking capsule revision must not be pinned or published through downstream product clients until `radroots_app_rt` is migrated, generated FFI diff --git a/contracts/conformance/vectors/knowledge/manifest_and_decode.v1.json b/contracts/conformance/vectors/knowledge/manifest_and_decode.v1.json @@ -6,11 +6,11 @@ "id": "knowledge_manifest_fields_valid_001", "kind": "knowledge.contract_manifest_json.valid", "input": { - "registry": "radroots_event_contract_registry_v4" + "registry": "radroots_event_contract_registry_v5" }, "expected": { "schema_version": 2, - "registry_version": 4, + "registry_version": 5, "required_fields": [ "schema_version", "registry_version", diff --git a/contracts/conformance/vectors/post/verified_profiles.v1.json b/contracts/conformance/vectors/post/verified_profiles.v1.json @@ -137,6 +137,748 @@ } }, { + "id": "authored_nip10_direct_wire", + "kind": "social.reply.build_authored_draft.valid", + "input": { + "content": "Direct reply", + "root": { + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": "wss://root.relay.example" + } + }, + "expected": { + "kind": 1, + "content": "Direct reply", + "tags": [ + ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://root.relay.example", "root"], + ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"] + ] + } + }, + { + "id": "authored_nip10_nested_wire", + "kind": "social.reply.build_authored_draft.valid", + "input": { + "content": "Nested reply", + "root": { + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + }, + "parent": { + "event_id": "2222222222222222222222222222222222222222222222222222222222222222", + "author": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "relay": "wss://parent.relay.example" + } + }, + "expected": { + "kind": 1, + "content": "Nested reply", + "tags": [ + ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root"], + ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://parent.relay.example", "reply"], + ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"] + ] + } + }, + { + "id": "authored_nip10_ambiguous_parent", + "kind": "social.reply.build_authored_draft.invalid", + "input": { + "content": "Ambiguous", + "root": { + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + }, + "parent": { + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "author": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "relay": null + } + }, + "expected": { + "error": "reply_reference_ambiguous" + } + }, + { + "id": "authored_nip10_invalid_event_id", + "kind": "social.reply.build_authored_draft.invalid", + "input": { + "content": "Invalid", + "root": { + "event_id": "not-an-event-id", + "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + } + }, + "expected": { + "error": "reply_event_id_invalid" + } + }, + { + "id": "project_signed_nip10_marked_direct", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"2340fc3fec291f4d4429bf13bf23cc3b7ec8589aa5e6426a5fc5a25bfcf1a896\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000001,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Direct reply\",\"sig\":\"16afb66cf2e30450a1055d697044b0f27835352553f32f7ac8d18387cc27861dfde3bb820a8882c00f933c13d4c967df5d5db986cc228a80dd3d291841bf08cd\"}" + }, + "expected": { + "style": "marked", + "contract_id": "radroots.social.reply.v1", + "direct": true, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": null, + "author_hint": null + }, + "parent": null, + "citations": [], + "participants": [ + { + "tag_index": 1, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + } + ], + "diagnostics": [] + } + }, + { + "id": "project_signed_nip10_marked_with_citation", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"b6bd8c4e2c8e3098a0b6fdbefbf7a25285887519e2ea58738af4221ddb5fd39e\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000016,\"kind\":1,\"tags\":[[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"wss://relay.example\",\"\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Marked reply with citation\",\"sig\":\"9f6516def04521e4e9c9b27f1225e43c03919914c8ced58dfb0afd5ee4fb950bcbbe42ba505f00f75be399ac4a61f7a7eb612b58051bc78ae245b7b122f760e4\"}" + }, + "expected": { + "style": "marked", + "contract_id": "radroots.social.reply.v1", + "direct": true, + "root": { + "tag_index": 1, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": null, + "author_hint": null + }, + "parent": null, + "citations": [ + { + "tag_index": 0, + "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://relay.example", "", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "event_id": "3333333333333333333333333333333333333333333333333333333333333333", + "relay": "wss://relay.example", + "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + ], + "participants": [ + { + "tag_index": 2, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + } + ], + "diagnostics": [] + } + }, + { + "id": "project_signed_nip10_marked_with_malformed_citation", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"c346aaff3f00a5c8fac4d8dcc15f286f56acbc4608c2e67e799266643f2544fa\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000017,\"kind\":1,\"tags\":[[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"\",\"mention\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Marked reply with malformed citation\",\"sig\":\"25d8248ba0220435036611dfbf14c4d52f63a2f29e568f9fd79435faa126d7f862068a1db66dac9a2179911e084eb12a03948a51f0ad7a50ab4fc6a83491037b\"}" + }, + "expected": { + "style": "marked", + "contract_id": "radroots.social.reply.v1", + "direct": true, + "root": { + "tag_index": 1, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": null, + "author_hint": null + }, + "parent": null, + "citations": [], + "participants": [ + { + "tag_index": 2, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + } + ], + "diagnostics": [ + { + "code": "reply_citation_marker_ignored", + "tag_index": 0, + "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "", "mention"] + } + ] + } + }, + { + "id": "project_signed_nip10_marked_nested_reordered", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"8179bf853f4b24f4ba7af58f162c0dabf08e74c2fe27df4e5573e48d86a8c1c5\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000002,\"kind\":1,\"tags\":[[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"wss://parent.relay.example\",\"reply\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://root.relay.example\",\"root\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Nested reply\",\"sig\":\"53b88d556d28f98b43ee36eb8b6453d1fa36da7058891100608ab8e0842dc3d1c49de29323f08679ecbee9e49cf3a09c59139c9e7285826dc7db479dd6219fd3\"}" + }, + "expected": { + "style": "marked", + "contract_id": "radroots.social.reply.v1", + "direct": false, + "root": { + "tag_index": 1, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://root.relay.example", "root", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": "wss://root.relay.example", + "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "parent": { + "tag_index": 0, + "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://parent.relay.example", "reply", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], + "event_id": "2222222222222222222222222222222222222222222222222222222222222222", + "relay": "wss://parent.relay.example", + "author_hint": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "citations": [], + "participants": [ + { + "tag_index": 2, + "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "relay": null + }, + { + "tag_index": 3, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + } + ], + "diagnostics": [] + } + }, + { + "id": "project_signed_nip10_positional_direct", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"802da76963580d5932a400a2e56e73caccb80c78d43845c62f5fd95feee600e8\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000003,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Legacy direct reply\",\"sig\":\"9a1a958170b2faf3c467bf174e776f7f61bf886644657cc12d92d6a9782db89f273508e1d940412645bbb73a6ce7948b7d20daa77647aa5fd8f9e164159826e7\"}" + }, + "expected": { + "style": "legacy_positional", + "contract_id": "radroots.social.reply.v1", + "direct": true, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": null, + "author_hint": null + }, + "parent": null, + "citations": [], + "participants": [ + { + "tag_index": 1, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + } + ], + "diagnostics": [] + } + }, + { + "id": "project_signed_nip10_positional_many", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"71af1b702a370218eeb1d6d4fd4734c8cd5fbc64c02ef90196365bda0755490b\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000004,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://root.relay.example\"],[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"wss://parent.relay.example\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"]],\"content\":\"Legacy nested reply\",\"sig\":\"49d2bf6c65edab401f7d981bf7bd87b7b1ece61d347f75270f38cf67597791f4058c6bd58444a52f831b0cd15c1f91e18958b3ca877a196ababd7055247d65aa\"}" + }, + "expected": { + "style": "legacy_positional", + "contract_id": "radroots.social.reply.v1", + "direct": false, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://root.relay.example"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": "wss://root.relay.example", + "author_hint": null + }, + "parent": { + "tag_index": 2, + "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://parent.relay.example"], + "event_id": "2222222222222222222222222222222222222222222222222222222222222222", + "relay": "wss://parent.relay.example", + "author_hint": null + }, + "citations": [ + { + "tag_index": 1, + "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333"], + "event_id": "3333333333333333333333333333333333333333333333333333333333333333", + "relay": null, + "author_hint": null + } + ], + "participants": [ + { + "tag_index": 3, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + }, + { + "tag_index": 4, + "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "relay": null + } + ], + "diagnostics": [] + } + }, + { + "id": "project_signed_nip10_positional_direct_with_author_hint", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"ff050a31d73546d609080281ad2c1e98f878aba826108e7ee0462cf6b397a941\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000018,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://root.relay.example\",\"\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Legacy direct with author hint\",\"sig\":\"e71bcb61d49862cd7fa1c776e4fe51aa9e4198234ee93efeacc9afc3d7038c00dee246f1df19eb0fd22aee0a70436708faec39e92bee16e86601f1c91fee1117\"}" + }, + "expected": { + "style": "legacy_positional", + "contract_id": "radroots.social.reply.v1", + "direct": true, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://root.relay.example", "", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": "wss://root.relay.example", + "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "parent": null, + "citations": [], + "participants": [ + { + "tag_index": 1, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + } + ], + "diagnostics": [] + } + }, + { + "id": "project_signed_nip10_positional_many_with_author_hints", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"2473084e4272dc675f34e18b9b2aaa3a164e3132bf19056c734759c922acafa7\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000019,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://root.relay.example\",\"\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"wss://citation.relay.example\",\"\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"wss://parent.relay.example\",\"\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"p\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"]],\"content\":\"Legacy nested with author hints\",\"sig\":\"e94c18ae8d2c8785a32f42dfff57712508018e2820d24497fbafcfcecc50890d19a4c15d48b2729c4f9dbe73e4c985e055097174b4505ed3a68858d4d56f7809\"}" + }, + "expected": { + "style": "legacy_positional", + "contract_id": "radroots.social.reply.v1", + "direct": false, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://root.relay.example", "", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": "wss://root.relay.example", + "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "parent": { + "tag_index": 2, + "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://parent.relay.example", "", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], + "event_id": "2222222222222222222222222222222222222222222222222222222222222222", + "relay": "wss://parent.relay.example", + "author_hint": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "citations": [ + { + "tag_index": 1, + "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://citation.relay.example", "", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"], + "event_id": "3333333333333333333333333333333333333333333333333333333333333333", + "relay": "wss://citation.relay.example", + "author_hint": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + } + ], + "participants": [ + { + "tag_index": 3, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + }, + { + "tag_index": 4, + "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "relay": null + }, + { + "tag_index": 5, + "raw_tag": ["p", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"], + "pubkey": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "relay": null + } + ], + "diagnostics": [] + } + }, + { + "id": "project_signed_nip10_positional_malformed_middle_citation", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"ce7684655dafc04bcb8048f4ba95da4b4780316ce90143c332ca4ed40af60ac7\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000020,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\"],[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"\",\"mention\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"]],\"content\":\"Legacy nested with malformed citation\",\"sig\":\"a278e44d6c44fe196c3557d01a99ec3cf3eaa31e16b66370743183db671eac6da75f11151b9da194582b03f529c4e9f871cf743d58dea430d0d12c3ed90c4997\"}" + }, + "expected": { + "style": "legacy_positional", + "contract_id": "radroots.social.reply.v1", + "direct": false, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": null, + "author_hint": null + }, + "parent": { + "tag_index": 2, + "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222"], + "event_id": "2222222222222222222222222222222222222222222222222222222222222222", + "relay": null, + "author_hint": null + }, + "citations": [], + "participants": [ + { + "tag_index": 3, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + }, + { + "tag_index": 4, + "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "relay": null + } + ], + "diagnostics": [ + { + "code": "reply_citation_marker_ignored", + "tag_index": 1, + "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "", "mention"] + } + ] + } + }, + { + "id": "project_signed_nip10_precedes_ask_and_media", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"7911ed2e9e2e08173a357f5f83fcdc415ca0c87e95682bad28cefe3a616f5be3\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000005,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"t\",\"radroots-ask\"],[\"imeta\",\"url https://media.example/image.webp\",\"x malformed\"]],\"content\":\"Reply with compatibility metadata\",\"sig\":\"9bd6c5f40c8587893b3460a53931c1253b2764c44922b25ffb64fe3e96e7fe2b8d139d5fca5ffd7b6f5a1cf19598c2a34e7ab48f5d15b18e695a5e39c81fc9f4\"}" + }, + "expected": { + "style": "marked", + "contract_id": "radroots.social.reply.v1", + "direct": true, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": null, + "author_hint": null + }, + "parent": null, + "citations": [], + "participants": [ + { + "tag_index": 1, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + } + ], + "diagnostics": [] + } + }, + { + "id": "project_signed_nip10_invalid_relay", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"15e0f001e760eb1ac3fd15b8a85e43a5c07603d4ceccdc6ccb93e6cc1876a626\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000006,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"https://relay.example\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Invalid relay\",\"sig\":\"18065b4433c33b29a06ecec64f0f863482479aec2832fcde3c95d2814b5ed91310fdd44df507f346e0231206be239ed4ac85353fc60a838435ae10feae2cc7c6\"}" + }, + "expected": { + "style": "marked", + "contract_id": "radroots.social.reply.v1", + "direct": true, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "https://relay.example", "root"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": null, + "author_hint": null + }, + "parent": null, + "citations": [], + "participants": [ + { + "tag_index": 1, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + } + ], + "diagnostics": [ + { + "code": "reply_reference_relay_ignored", + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "https://relay.example", "root"] + } + ] + } + }, + { + "id": "project_signed_nip10_ambiguous_same_reference", + "kind": "social.reply.project_verified_event.invalid", + "input": { + "event_json": "{\"id\":\"86f19437493f84ecd999d1cd5890ff53fa157b99309f65c056de48e82a8c75bf\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000007,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"reply\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Ambiguous\",\"sig\":\"730b282fadf78838f6dad8830711fff6cff5b6da0d3c42db49faf1ef547cf3908ec0c901e1ac7069f8c346e9ec73cea879830a8687e18d87e476a1e0f82a0ca7\"}" + }, + "expected": { + "error": "reply_reference_ambiguous" + } + }, + { + "id": "project_signed_nip10_missing_author", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"2747dc566bfa7ecbc9744bf675d0bdaff435a16a4824a60a4ac5a6eb81b38bf7\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000008,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"]],\"content\":\"Missing author\",\"sig\":\"ceb6c490fe33ccf32c70fef87164212eb4b0d11f98e314b8a87fd9f5e6a9a08a175ab068fa307ac9b16a2e39c120e3c6185e42b8bb3a67e6843c1f676c62b97e\"}" + }, + "expected": { + "style": "marked", + "contract_id": "radroots.social.reply.v1", + "direct": true, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": null, + "author_hint": null + }, + "parent": null, + "citations": [], + "participants": [], + "diagnostics": [ + { + "code": "reply_author_missing_ignored", + "tag_index": null, + "raw_tag": null + } + ] + } + }, + { + "id": "project_signed_nip10_invalid_event_id", + "kind": "social.reply.project_verified_event.invalid", + "input": { + "event_json": "{\"id\":\"2c071cfb2696c8cb2e676564290007bcbc995d08a5404462f747d6ceb73194fe\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000009,\"kind\":1,\"tags\":[[\"e\",\"not-an-event-id\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Invalid event id\",\"sig\":\"712bed9b975b2742dbb4b241e70893c67fbd4debab110c7d44bf365500405cc9c5c39efa1f9a3260a3a57f10cd0b981d84c327ff4ae0f1afa21c6e9fc8f8b851\"}" + }, + "expected": { + "error": "reply_event_id_invalid" + } + }, + { + "id": "project_signed_nip10_author_hint_mismatch", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"a54d89f21c0a80df592eab60b07b0800ea5bd212fbe17d0f9deb852c109775a9\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000010,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"]],\"content\":\"Author mismatch\",\"sig\":\"3a9b2af22a1b63a1bf6a4cdf0f83497a199ff6f5edc5b1ea9fd0a70c4dd12034431e3992be9ee80827cd0f396e70d0a9f0c316408a269c80949cb5a0cf59c5f7\"}" + }, + "expected": { + "style": "marked", + "contract_id": "radroots.social.reply.v1", + "direct": true, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": null, + "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "parent": null, + "citations": [], + "participants": [ + { + "tag_index": 1, + "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "relay": null + } + ], + "diagnostics": [ + { + "code": "reply_author_mismatch_ignored", + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"] + } + ] + } + }, + { + "id": "project_signed_nip10_blank_content", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"bb57163b91910e28747d2ea50c89a84fc4d72ce0509d87e97492518f71081d39\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000013,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"\",\"sig\":\"b1833129fc7568362e5c308a3d4b44b09c5d7b7c474b1335cb9a332e9167689678c009547f312b5bc79442bd018ed8ed2af6c64eafd3112bb96e154d6b7b4b48\"}" + }, + "expected": { + "style": "marked", + "contract_id": "radroots.social.reply.v1", + "direct": true, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": null, + "author_hint": null + }, + "parent": null, + "citations": [], + "participants": [ + { + "tag_index": 1, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + } + ], + "diagnostics": [] + } + }, + { + "id": "project_signed_nip10_invalid_author_hint_tolerated", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"64a50435d0e23c12ae6e52b0eddcd0e9a8efb07f0c59e2b41f839a30b9593d56\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000014,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\",\"not-a-pubkey\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Malformed optional author\",\"sig\":\"3a554dd47779861e5c020bdcad2103b4831eaf7930b8a801bb99d7c0bcd898ef1a4a79aa8a3856533ab1b88bed0b2a8c44afafa6118daf8b172d502e22eeb95f\"}" + }, + "expected": { + "style": "marked", + "contract_id": "radroots.social.reply.v1", + "direct": true, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root", "not-a-pubkey"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": null, + "author_hint": null + }, + "parent": null, + "citations": [], + "participants": [ + { + "tag_index": 1, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + } + ], + "diagnostics": [ + { + "code": "reply_reference_author_ignored", + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root", "not-a-pubkey"] + } + ] + } + }, + { + "id": "project_signed_nip10_invalid_participant_tolerated", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"2d76c510e6002361b33eb0a0aa01ebb612629a3c33190472f981460c85628683\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000015,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"not-a-pubkey\"]],\"content\":\"Malformed optional participant\",\"sig\":\"b84460d29b4cda084dc338c5e0596415dd7d48e2c494e54de3a6d59076fdbb418e595665f0016d0a3a93125ecaae848c962fe1bdf6efd4a635e38659424e9a75\"}" + }, + "expected": { + "style": "marked", + "contract_id": "radroots.social.reply.v1", + "direct": true, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": null, + "author_hint": null + }, + "parent": null, + "citations": [], + "participants": [], + "diagnostics": [ + { + "code": "reply_author_invalid_ignored", + "tag_index": 1, + "raw_tag": ["p", "not-a-pubkey"] + } + ] + } + }, + { + "id": "project_signed_nip10_lone_reply_marker", + "kind": "social.reply.project_verified_event.invalid", + "input": { + "event_json": "{\"id\":\"eb5f2eea6aa74ffcbd758cb5fb72749babcad7343717cb30caac19200154f03a\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000011,\"kind\":1,\"tags\":[[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"\",\"reply\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"]],\"content\":\"Lone reply\",\"sig\":\"b4f2d2a564f248fc7012e427a1fea9267de728342485c3f8a4f19066cd15e4295b14a51f7aab898d38e61a5c86d134a80863e6ae1394081b4d9e368dee65e94b\"}" + }, + "expected": { + "error": "reply_marker_count" + } + }, + { + "id": "project_signed_nip10_unknown_marker", + "kind": "social.reply.project_verified_event.invalid", + "input": { + "event_json": "{\"id\":\"266e800a1a46fefad41fd745d3750eac53e5527339be4aa0b28f0fb8ebe34bea\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000012,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"mention\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Unknown marker\",\"sig\":\"fd06119aad71aef0851c900fed068540ca6dde40ccfb8fc70c74cf8d10f0fdd13aac49863753fa8f1fd679c1890ba410259a291780682cc3f8fcf5b89e381afe\"}" + }, + "expected": { + "error": "reply_marker_missing" + } + }, + { + "id": "admit_signed_nip10_marked_direct", + "kind": "social.reply.verify_and_admit_event.valid", + "input": { + "event_json": "{\"id\":\"2340fc3fec291f4d4429bf13bf23cc3b7ec8589aa5e6426a5fc5a25bfcf1a896\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000001,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Direct reply\",\"sig\":\"16afb66cf2e30450a1055d697044b0f27835352553f32f7ac8d18387cc27861dfde3bb820a8882c00f933c13d4c967df5d5db986cc228a80dd3d291841bf08cd\"}" + }, + "expected": { + "contract_id": "radroots.social.reply.v1", + "style": "marked", + "direct": true + } + }, + { + "id": "admit_signed_nip10_positional_direct", + "kind": "social.reply.verify_and_admit_event.valid", + "input": { + "event_json": "{\"id\":\"802da76963580d5932a400a2e56e73caccb80c78d43845c62f5fd95feee600e8\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000003,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Legacy direct reply\",\"sig\":\"9a1a958170b2faf3c467bf174e776f7f61bf886644657cc12d92d6a9782db89f273508e1d940412645bbb73a6ce7948b7d20daa77647aa5fd8f9e164159826e7\"}" + }, + "expected": { + "contract_id": "radroots.social.reply.v1", + "style": "legacy_positional", + "direct": true + } + }, + { + "id": "admit_signed_nip10_invalid_signature", + "kind": "social.reply.verify_and_admit_event.invalid", + "input": { + "event_json": "{\"id\":\"2340fc3fec291f4d4429bf13bf23cc3b7ec8589aa5e6426a5fc5a25bfcf1a896\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000001,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Direct reply\",\"sig\":\"06afb66cf2e30450a1055d697044b0f27835352553f32f7ac8d18387cc27861dfde3bb820a8882c00f933c13d4c967df5d5db986cc228a80dd3d291841bf08cd\"}" + }, + "expected": { + "error": "signature_invalid" + } + }, + { "expected": { "ask_marker": null, "classification": "update", diff --git a/contracts/event_boundary_matrix.md b/contracts/event_boundary_matrix.md @@ -93,7 +93,7 @@ only. Successful BUD-02 upload completion and any raster, retrievability, or availability checks remain runtime responsibilities before signing or publication. -## Kind-1 post boundary rule +## Kind-1 post and Reply boundary rule Ordinary kind-1 events remain interoperable at the generic `radroots.social.post.v1` read boundary. Product projection first requires a @@ -104,10 +104,31 @@ thread-excluded candidates in distinct public variants. Exact subtype registry contracts are admission-only and cannot be selected by unsigned kind/tag matching. New root publication uses the strict authored types and a sealed Nostr builder with no raw tag/content mutation; generic builder publication -rejects unmarked kind `1` before signing. The legacy mutable `RadrootsPost` +rejects every kind `1` event before signing. The legacy mutable `RadrootsPost` decoder is compatibility-only and has no authored encoder or tag-builder implementation. +NIP-10 Reply is a separate typed kind-1 boundary. Strict direct authoring emits +one marked `root` event reference and its referenced-author `p` tag. Strict +nested authoring adds one distinct marked `reply` parent and its author, +deduplicating equal authors. Inbound Reply projection requires NIP-01 +verification but tolerates both preferred marked and deprecated positional +NIP-10 references. Valid supplemental unmarked `e` references remain citations; +malformed supplemental references are ignored with ordered diagnostics. +Empty marker slots remain absent for positional root, parent, and citation +references even when a fifth-element author hint is present; malformed +intermediate citations do not erase valid positional anchors. +Advisory `p`, relay, and referenced-author metadata is projected best-effort +rather than promoted to a validity gate. Positional replies remain thread +enrichment, and a Reply carrying Ask or media metadata cannot be promoted as a +root card. + +Reply admission proves the Reply envelope's id, signature, and bounded NIP-10 +structure. It does not prove that a referenced event exists, has kind `1`, has +a valid signature, was authored by the declared referenced author, or is +available from a relay. Signed-event relay remains generic transport rather +than a typed Reply-authoring boundary. + ## Coverage matrix | Domain | Kind | Radroots Type | RPC Methods | Notes | @@ -115,6 +136,7 @@ implementation. | profile | 0 | RadrootsAuthoredProfile / RadrootsInboundProfileMetadata | events.profile.publish, events.profile.list, events.profile.get | publish must use `profile.build_authored_draft`; inbound projection must use `profile.parse_inbound_metadata`; authored output is deterministic JSON with no marker tag | | follow | 3 | RadrootsFollow | events.follow.publish, events.follow.list, events.follow.get | replaceable event | | post | 1 | RadrootsAuthoredUpdate / RadrootsAuthoredPhotoUpdate / RadrootsAuthoredAsk / RadrootsInboundPostProjection | events.post.publish, events.post.list, events.post.get | ordinary kind-1 reads remain generic; exact root-card subtypes require verified admission; any `e` tag produces a thread-excluded candidate without a Reply claim | +| reply | 1 | RadrootsAuthoredNip10Reply / RadrootsInboundNip10ReplyProjection / RadrootsAdmittedNip10ReplyEvent / RadrootsNostrNip10ReplyEventBuilder | social.reply.build_authored_draft, social.reply.project_verified_event, social.reply.verify_and_admit_event | strict marked direct/nested NIP-10 authoring; verified marked or positional inbound admission with advisory-metadata diagnostics; never a root card; target existence, kind, author, and relay availability are not proven | | comment | 1111 | RadrootsComment | events.comment.publish, events.comment.list, events.comment.get | requires root and parent tags | | reaction | 7 | RadrootsReaction | events.reaction.publish, events.reaction.list, events.reaction.get | requires event, pubkey, or address tags | | repost | 6 | RadrootsRepost | events.repost.publish, events.repost.list, events.repost.get | NIP-18 kind-1 repost surface | diff --git a/contracts/events/social-events.md b/contracts/events/social-events.md @@ -20,14 +20,17 @@ Calendar behavior is based on [NIP-52](https://github.com/nostr-protocol/nips/blob/bdfa7e62ef87fcfcb992b1a27aee49d36b0b4f91/52.md) and its collection metadata uses [NIP-51](https://github.com/nostr-protocol/nips/blob/bdfa7e62ef87fcfcb992b1a27aee49d36b0b4f91/51.md), -both at NIPs commit `bdfa7e62ef87fcfcb992b1a27aee49d36b0b4f91`. Calendar media uses the public +while text-note Reply threading follows +[NIP-10](https://github.com/nostr-protocol/nips/blob/bdfa7e62ef87fcfcb992b1a27aee49d36b0b4f91/10.md), +all at NIPs commit `bdfa7e62ef87fcfcb992b1a27aee49d36b0b4f91`. Calendar media uses the public Blossom primitives governed by the protocol pin in [`blossom-media.md`](blossom-media.md). The upstream NIP-52 rules and the stricter Radroots authoring and admission profile are separate contract layers. ## Implementation Inventory -The repository implements strict authored and verified-projected kind `1` post profiles, kind `1111` +The repository implements strict authored and verified-projected kind `1` root-post profiles, a +separate strict-authored and tolerant-inbound kind `1` NIP-10 Reply profile, kind `1111` `RadrootsComment`, kind `7` `RadrootsReaction`, generic `RadrootsList` entries, operational listing records through `RadrootsOperationalListing`, the raw kind-`30402` profile partition and validated FoodAvailability authored, verified-admission, and revision contract, articles, generic public file @@ -39,6 +42,7 @@ The closeout contract requires: - complete model and codec coverage for the approved public social event families - kind and tag constants for the approved NIP surface - ordinary kind-1 compatibility reads plus strict Update, PhotoUpdate, and Ask authoring +- strict marked direct and nested NIP-10 Reply authoring plus tolerant positional inbound admission - strict NIP-22 `RadrootsComment` behavior without legacy `e_root` or `e_prev` fallback tags - strict NIP-25 `RadrootsReaction` behavior where empty content is a valid like - explicit optional `published_at` support for NIP-99 classified-listing parity @@ -52,6 +56,9 @@ The MVP public social substrate includes: - strict `RadrootsAuthoredUpdate`, `RadrootsAuthoredPhotoUpdate`, and `RadrootsAuthoredAsk` publication types plus verified tolerant projection for ordinary NIP-01 kind `1` events +- strict `RadrootsAuthoredNip10Reply` direct and nested publication plus + `RadrootsInboundNip10ReplyProjection` for marked and deprecated positional + inbound NIP-10 replies - `RadrootsArticle` for NIP-23 kind `30023` long-form content - generic public `RadrootsFileMetadata` for NIP-94 kind `1063` - strict authored `RadrootsAuthoredCalendarDateEvent`, tolerant @@ -141,21 +148,68 @@ Update, PhotoUpdate, and Ask contracts use `AdmissionOnly` and are returned only by the verified projection/admission boundary. The event-contract registry assigns an explicit authoring policy to every -contract. Strict Profile, Update, PhotoUpdate, and Ask contracts are +contract. Strict Profile, Update, PhotoUpdate, Ask, and NIP-10 Reply contracts are `TypedOnly`; `radroots.social.post.v1` is `ReadOnly`; ordinary generic-draft contracts remain `GenericDraft`. `RadrootsEventDraft::new` therefore rejects -the strict Profile contract and all four governed kind-1 post contracts with -`contract_not_draft_authorable`. Serialized drafts record registry version `4` +the strict Profile contract and every governed kind-1 contract with +`contract_not_draft_authorable`. Serialized drafts record registry version `5` and are accepted only after deserialization revalidates the registry version, contract, kind, shape, policy, recomputed event id, and known fields. The -frozen-draft signing boundary repeats that validation, so stale version-`1`, version-`2`, and -version-`3` drafts must be rebuilt. Typed root posts enter Nostr signing and client -publication only through an opaque post builder that exposes timestamp -selection and signing, but no raw tag/content mutation or public conversion to -the upstream builder. The opaque generic builder rejects kind `0` and unmarked -kind `1` at both direct signing and client publication before a signer is -consulted; `e`-tagged kind-1 builders remain a thread-compatibility surface and -cannot enter root-card admission. +frozen-draft signing boundary repeats that validation, so stale version-`1` through version-`4` +drafts must be rebuilt. Typed root posts and Replies enter Nostr signing and client +publication only through opaque profile-specific builders that expose +timestamp selection and signing, but no raw tag/content mutation or public +conversion to the upstream builder. The opaque generic builder rejects kind `0` and every +kind `1` event at both direct signing and client publication before a signer is +consulted. + +### NIP-10 Reply Trust Layers + +`RadrootsAuthoredNip10Reply` is an opaque, bounded authoring state. Direct +Replies contain one root reference; nested Replies contain one root and one +distinct parent reference. Each reference carries a validated 64-character +lowercase event id, a referenced-author pubkey, and an optional `ws` or `wss` +relay hint. Content is non-whitespace and shares the root-post content, +tag-element, total-tag-byte, and compact signed-wire budgets. + +Strict authoring is deterministic. A direct Reply emits +`["e",<root-id>,<relay-or-empty>,"root"]` followed by the root author's +two-element `p` tag. A nested Reply emits the root `e` tag, then +`["e",<parent-id>,<relay-or-empty>,"reply"]`, then the root and parent author +`p` tags in that order; equal authors are emitted once. No other authored tag +shape is accepted. Signing and client publication are exposed only through the +sealed `RadrootsNostrNip10ReplyEventBuilder`. + +Inbound projection requires a signature-and-id verified envelope. It accepts +preferred marked `e` references, including the optional NIP-10 author hint, and +supplemental unmarked `e` citations in the same event. An empty marker slot is +treated as absent so a citation may retain its optional fifth-element author +hint. Malformed supplemental references are ignored with ordered diagnostics +without erasing an otherwise unambiguous marked Reply. The projection also +accepts deprecated positional references where the first `e` tag is the root, +the last is the parent, and intermediates are citations. Empty marker slots +remain absent in this mode, including when a fifth-element author hint exists. +Malformed intermediate citations become diagnostics; malformed root or parent +anchors remain hard failures. Because NIP-10 makes +participant propagation, relay hints, and referenced-author hints advisory, +blank content or absent `p` tags do not erase an otherwise unambiguous inbound +Reply. Malformed optional relay, author-hint, citation, and participant metadata is +retained in the verified envelope and exposed as ordered typed diagnostics; +valid values are projected best-effort. This tolerant read-side behavior never +weakens strict authored output. + +The registry's Reply `e` and `p` tag contracts describe normalized qualifying +semantic references. They do not claim that every malformed optional raw tag +retained by the verified envelope is itself a valid identifier-bearing tag. + +Any `e` tag excludes a kind-1 event from root-card admission before Ask or +media classification. A thread-excluded candidate can be promoted only by the +separate NIP-10 Reply admission boundary; a Reply carrying Ask or media +metadata therefore remains a Reply and never becomes a root card. Admission +proves only the Reply envelope's NIP-01 id and signature plus its NIP-10 +structure. It does not retrieve a referenced event or prove its existence, +kind, signature, author, relay availability, or relationship to the declared +author hint. The signer backend's externally supplied unsigned-event operation and the standard NIP-46 `sign_event` method are explicit low-level interoperability @@ -164,13 +218,14 @@ they do not confer a Radroots typed product-authoring claim and are not product authoring entry points. Relaying an already signed event is likewise a generic transport operation with no Radroots authoring claim. -Each post operation owns exact valid and invalid case kinds in +Each post and Reply operation owns exact valid and invalid case kinds in `contracts/operations.toml`. The canonical and packaged conformance suites execute every public Update, PhotoUpdate, and Ask authoring function, verified -projection, and admission function; they compare complete deterministic wire -parts or projections and enforce stable negative error codes. Xtask validation -pins the complete post-operation namespace, ownership metadata, public types, -and exact case-id-to-kind corpus; it rejects missing, duplicate, unclaimed, +projection, admission function, and NIP-10 Reply authoring, projection, and +admission boundary; they compare complete deterministic wire parts or +projections and enforce stable negative error codes. Xtask validation pins the +complete operation namespaces, ownership metadata, public types, and exact +case-id-to-kind corpus; it rejects missing, duplicate, unclaimed, mis-prefixed, or substituted cases. `RadrootsComment` uses strict NIP-22 semantics. The target and scope model must support event-id, diff --git a/contracts/knowledge/knowledge_event_contract_manifest.v2.json b/contracts/knowledge/knowledge_event_contract_manifest.v2.json @@ -1,6 +1,6 @@ { "schema_version": 2, - "registry_version": 4, + "registry_version": 5, "radroots_event_version": "1.0.0-alpha.1", "radroots_event_codec_version": "1.0.0-alpha.1", "contract_count": 11, diff --git a/contracts/knowledge/knowledge_event_contract_manifest.v2.sha256 b/contracts/knowledge/knowledge_event_contract_manifest.v2.sha256 @@ -1 +1 @@ -ce21d5e585215956386e0243d7ddebe568293b9635c68c2b42c07bd8984a7c4b +aae1edebb4aba57dc5690d825d35f46fd770d212a21d5886b77069809f568f64 diff --git a/contracts/operations.toml b/contracts/operations.toml @@ -104,6 +104,9 @@ public = [ "RadrootsAuthoredUpdate", "RadrootsAuthoredPhotoUpdate", "RadrootsAuthoredAsk", + "RadrootsNip10ReplyError", + "RadrootsNip10ReplyReference", + "RadrootsAuthoredNip10Reply", "RadrootsPostDiagnostic", "RadrootsPostClassification", "RadrootsInboundPostImeta", @@ -113,6 +116,14 @@ public = [ "RadrootsThreadExcludedPostCandidate", "RadrootsPostAdmissionOutcome", "RadrootsPostAdmissionError", + "RadrootsNip10ReplyStyle", + "RadrootsNip10ReplyDiagnostic", + "RadrootsInboundNip10EventReference", + "RadrootsInboundNip10Participant", + "RadrootsInboundNip10ReplyProjection", + "RadrootsNip10ReplyProjectionError", + "RadrootsAdmittedNip10ReplyEvent", + "RadrootsNip10ReplyAdmissionError", "RadrootsComment", "RadrootsReaction", "RadrootsArticle", @@ -828,6 +839,97 @@ case_kinds = [ "social.ask.build_authored_draft.invalid", ] +[operations.social_reply_build_authored_draft] +domain = "social" +id = "social.reply.build_authored_draft" +stability = "beta" +inputs = ["RadrootsAuthoredNip10Reply"] +outputs = ["RadrootsNip01EventWireParts"] +error_class = "encode_error" +deterministic = true +signing = "none" +transport = "none" + +[operations.social_reply_build_authored_draft.implementation] +rust_modules = [ + "crates/event/src/reply.rs", + "crates/event_codec/src/reply/authored.rs", +] +rust_types = [ + "radroots_event::reply::RadrootsAuthoredNip10Reply", + "radroots_event::reply::RadrootsNip10ReplyError", + "radroots_event::reply::RadrootsNip10ReplyReference", +] + +[operations.social_reply_build_authored_draft.conformance] +vector = "contracts/conformance/vectors/post/verified_profiles.v1.json" +case_kinds = [ + "social.reply.build_authored_draft.valid", + "social.reply.build_authored_draft.invalid", +] + +[operations.social_reply_project_verified_event] +domain = "social" +id = "social.reply.project_verified_event" +stability = "beta" +inputs = ["RadrootsSignatureVerifiedEvent"] +outputs = ["RadrootsInboundNip10ReplyProjection"] +error_class = "parse_error" +deterministic = true +signing = "none" +transport = "none" + +[operations.social_reply_project_verified_event.implementation] +rust_modules = ["crates/event_codec/src/reply/inbound.rs"] +rust_types = [ + "radroots_event_codec::reply::inbound::RadrootsInboundNip10EventReference", + "radroots_event_codec::reply::inbound::RadrootsInboundNip10Participant", + "radroots_event_codec::reply::inbound::RadrootsInboundNip10ReplyProjection", + "radroots_event_codec::reply::inbound::RadrootsNip10ReplyDiagnostic", + "radroots_event_codec::reply::inbound::RadrootsNip10ReplyProjectionError", + "radroots_event_codec::reply::inbound::RadrootsNip10ReplyStyle", + "radroots_event_codec::verification::RadrootsSignatureVerifiedEvent", +] + +[operations.social_reply_project_verified_event.conformance] +vector = "contracts/conformance/vectors/post/verified_profiles.v1.json" +case_kinds = [ + "social.reply.project_verified_event.valid", + "social.reply.project_verified_event.invalid", +] + +[operations.social_reply_verify_and_admit_event] +domain = "social" +id = "social.reply.verify_and_admit_event" +stability = "beta" +inputs = ["RadrootsEventEnvelope"] +outputs = ["RadrootsAdmittedNip10ReplyEvent"] +error_class = "admission_error" +deterministic = true +signing = "nip01" +transport = "none" + +[operations.social_reply_verify_and_admit_event.implementation] +rust_modules = [ + "crates/event_codec/src/reply/admission.rs", + "crates/event_codec/src/reply/inbound.rs", + "crates/event_codec/src/verification.rs", +] +rust_types = [ + "radroots_event::RadrootsEventEnvelope", + "radroots_event_codec::reply::admission::RadrootsAdmittedNip10ReplyEvent", + "radroots_event_codec::reply::admission::RadrootsNip10ReplyAdmissionError", + "radroots_event_codec::reply::inbound::RadrootsInboundNip10ReplyProjection", + "radroots_event_codec::verification::RadrootsSignatureVerifiedEvent", +] + +[operations.social_reply_verify_and_admit_event.conformance] +vector = "contracts/conformance/vectors/post/verified_profiles.v1.json" +case_kinds = [ + "social.reply.verify_and_admit_event.valid", + "social.reply.verify_and_admit_event.invalid", +] + [operations.social_post_project_verified_event] domain = "social" id = "social.post.project_verified_event" diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml @@ -182,3 +182,18 @@ semver_impacts = [ "change_exported_algorithm_behavior", ] summary = "Reserve focused FoodAvailability signing and publication behind a sealed typed Nostr builder, require verified Operational Listing validation input, route replica kind-30402 heads before profile projection, and reject head-only kind-30402 ingestion." + +[[changes]] +id = "strict-nip10-reply-contract" +classification = "breaking" +semver_impacts = [ + "add_exported_type", + "add_exported_function", + "add_enum_variant", + "add_conformance_vector", + "remove_exported_function", + "change_exported_function_signature", + "change_exported_constant_value", + "change_exported_algorithm_behavior", +] +summary = "Replace permissive unmarked reply authoring with strict marked direct and nested NIP-10 Reply types, signature-gated tolerant inbound projection with supplemental citations and advisory diagnostics, typed publication, and a registry-version-5 admission-only event contract." diff --git a/crates/authority/src/authorization.rs b/crates/authority/src/authorization.rs @@ -449,8 +449,8 @@ mod tests { let actor = seller_actor(pubkey.as_str()); let signer = CountingSigner::new(pubkey.as_str()); - assert_eq!(RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, 4); - for actual in [1, 2, 3] { + assert_eq!(RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, 5); + for actual in [1, 2, 3, 4] { let error = sign_authorized_draft_with_validator(&actor, &signer, &draft, |_| { Err(RadrootsDraftError::ContractRegistryVersionMismatch { expected: RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, @@ -463,7 +463,7 @@ mod tests { error, RadrootsAuthorityError::DraftValidation( RadrootsDraftError::ContractRegistryVersionMismatch { - expected: 4, + expected: 5, actual, } ) diff --git a/crates/authority/src/error.rs b/crates/authority/src/error.rs @@ -411,25 +411,30 @@ mod tests { let cases = [ ( 1, - "draft validation failed: event contract registry version mismatch: expected 4, got 1", - "event contract registry version mismatch: expected 4, got 1", + "draft validation failed: event contract registry version mismatch: expected 5, got 1", + "event contract registry version mismatch: expected 5, got 1", ), ( 2, - "draft validation failed: event contract registry version mismatch: expected 4, got 2", - "event contract registry version mismatch: expected 4, got 2", + "draft validation failed: event contract registry version mismatch: expected 5, got 2", + "event contract registry version mismatch: expected 5, got 2", ), ( 3, - "draft validation failed: event contract registry version mismatch: expected 4, got 3", - "event contract registry version mismatch: expected 4, got 3", + "draft validation failed: event contract registry version mismatch: expected 5, got 3", + "event contract registry version mismatch: expected 5, got 3", + ), + ( + 4, + "draft validation failed: event contract registry version mismatch: expected 5, got 4", + "event contract registry version mismatch: expected 5, got 4", ), ]; for (actual, expected_authority, expected_source) in cases { let authority_error = RadrootsAuthorityError::DraftValidation( radroots_event::draft::RadrootsDraftError::ContractRegistryVersionMismatch { - expected: 4, + expected: 5, actual, }, ); diff --git a/crates/event/README b/crates/event/README @@ -30,6 +30,14 @@ image-typed byte-verified Blossom descriptor. That descriptor state is not an upload receipt; BUD-02 completion remains a runtime prerequisite before signing. +The Reply module exposes opaque, bounded `RadrootsNip10ReplyReference` and +`RadrootsAuthoredNip10Reply` types for strict direct and nested kind-1 +authoring. References carry a validated event id, referenced author, and +optional relay hint; construction emits either one marked root or distinct +marked root and parent references. These values prove syntax and authored +shape, not target existence, target kind, signature, author, or relay +availability. + Kind `30402` has a raw, allocation-free marker partition before profile-specific tag-shape validation. Presence of `radroots:price_unit` or `radroots:quantity` selects the focused FoodAvailability marker family; presence of diff --git a/crates/event/src/contract.rs b/crates/event/src/contract.rs @@ -22,7 +22,7 @@ use crate::{ }; use radroots_blossom::RadrootsBlossomBlobUrl; -pub const RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION: u32 = 4; +pub const RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION: u32 = 5; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RadrootsEventClass { @@ -541,6 +541,20 @@ const TAG_E_MANY: RadrootsTagContract = tag( RadrootsTagValueType::EventId, true, ); +const TAG_NIP10_E_REQUIRED: RadrootsTagContract = tag( + "e", + RadrootsTagCardinality::RequiredMany, + RadrootsTagSemantic::EventPointer, + RadrootsTagValueType::EventId, + true, +); +const TAG_NIP10_P_OPTIONAL: RadrootsTagContract = tag( + "p", + RadrootsTagCardinality::OptionalMany, + RadrootsTagSemantic::Participant, + RadrootsTagValueType::PublicKey, + true, +); const TAG_KIND: RadrootsTagContract = tag( "k", RadrootsTagCardinality::OptionalOne, @@ -1179,6 +1193,7 @@ const EVIDENCE_BOUNTY_TAGS: &[RadrootsTagContract] = &[ const SOCIAL_REDUCERS: &[RadrootsReducer] = &[RadrootsReducer::SocialProjection]; const PHOTO_UPDATE_TAGS: &[RadrootsTagContract] = &[TAG_IMETA_REQUIRED_MANY]; const ASK_TAGS: &[RadrootsTagContract] = &[TAG_ASK_MARKER, TAG_IMETA_OPTIONAL_MANY]; +const NIP10_REPLY_TAGS: &[RadrootsTagContract] = &[TAG_NIP10_E_REQUIRED, TAG_NIP10_P_OPTIONAL]; const PROFILE_REDUCERS: &[RadrootsReducer] = &[RadrootsReducer::ProfileProjection]; const FARM_OPS_REDUCERS: &[RadrootsReducer] = &[RadrootsReducer::FarmOpsProjection]; const GROUP_REDUCERS: &[RadrootsReducer] = &[RadrootsReducer::GroupProjection]; @@ -1379,7 +1394,8 @@ static ALL_KIND_CONTRACTS: &[RadrootsKindContract] = &[ "radroots.social.post.v1", "radroots.social.update.v1", "radroots.social.photo_update.v1", - "radroots.social.ask.v1" + "radroots.social.ask.v1", + "radroots.social.reply.v1" ] ), kind_contract!( @@ -2185,6 +2201,20 @@ static ALL_EVENT_CONTRACTS: &[RadrootsEventContract] = &[ ASK_TAGS, SOCIAL_REDUCERS ), + event_contract_with_authoring_policy!( + "radroots.social.reply.v1", + KIND_POST, + "NIP-10 Reply", + "RadrootsAuthoredNip10Reply / RadrootsInboundNip10ReplyProjection", + RadrootsEventClass::Regular, + RadrootsEventPrivacy::Public, + RadrootsActorRole::Any, + RadrootsContentSchema::PlainText, + RadrootsEventAuthoringPolicy::TypedOnly, + RadrootsEventDiscriminator::AdmissionOnly, + NIP10_REPLY_TAGS, + SOCIAL_REDUCERS + ), event_contract!( "radroots.social.follow_list.v1", KIND_FOLLOW, @@ -5613,6 +5643,7 @@ mod tests { "radroots.social.update.v1", "radroots.social.photo_update.v1", "radroots.social.ask.v1", + "radroots.social.reply.v1", ] { let contract = event_contract(id).expect(id); assert_eq!( diff --git a/crates/event/src/draft.rs b/crates/event/src/draft.rs @@ -1075,6 +1075,7 @@ mod tests { ("radroots.social.update.v1", KIND_POST), ("radroots.social.photo_update.v1", KIND_POST), ("radroots.social.ask.v1", KIND_POST), + ("radroots.social.reply.v1", KIND_POST), ] { let error = RadrootsEventDraft::new(contract_id, kind, 1, Vec::new(), "hello", hex_64('a')) @@ -1096,11 +1097,13 @@ mod tests { serde_json::from_value(value.clone()).expect("validated roundtrip"); assert_eq!(decoded, draft); - let mut tampered = value.clone(); - tampered["contract_registry_version"] = serde_json::json!(1); - let error = serde_json::from_value::<RadrootsEventDraft>(tampered) - .expect_err("stale registry generation must fail"); - assert!(error.to_string().contains("registry version mismatch")); + for stale_version in 1..RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION { + let mut tampered = value.clone(); + tampered["contract_registry_version"] = serde_json::json!(stale_version); + let error = serde_json::from_value::<RadrootsEventDraft>(tampered) + .expect_err("stale registry generation must fail"); + assert!(error.to_string().contains("registry version mismatch")); + } let mut tampered = value.clone(); tampered["expected_event_id"] = serde_json::Value::String(hex_64('f')); diff --git a/crates/event/src/lib.rs b/crates/event/src/lib.rs @@ -58,6 +58,7 @@ pub mod profile; pub mod reaction; pub mod relay_auth; pub mod relay_document; +pub mod reply; pub mod report; pub mod repost; pub mod resource_area; diff --git a/crates/event/src/reply.rs b/crates/event/src/reply.rs @@ -0,0 +1,481 @@ +#[cfg(not(feature = "std"))] +use alloc::string::String; +use core::fmt; + +use crate::{ + ids::{RadrootsEventId, RadrootsIdParseError, RadrootsPublicKey, RadrootsRelayUrl}, + post::{ + RADROOTS_POST_CONTENT_MAX_BYTES, RADROOTS_POST_EVENT_WIRE_MAX_BYTES, + RADROOTS_POST_TAG_ELEMENT_MAX_BYTES, RADROOTS_POST_TAG_TOTAL_MAX_BYTES, + }, +}; + +const RADROOTS_NIP10_REPLY_SIGNED_EVENT_FIXED_MAX_BYTES: usize = "{\"id\":\"".len() + + 64 + + "\",\"pubkey\":\"".len() + + 64 + + "\",\"created_at\":".len() + + 20 + + ",\"kind\":1,\"tags\":".len() + + ",\"content\":".len() + + ",\"sig\":\"".len() + + 128 + + "\"}".len(); + +#[non_exhaustive] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsNip10ReplyError { + ContentMissing, + ContentTooLarge { max: usize, actual: usize }, + EventIdInvalid(RadrootsIdParseError), + AuthorInvalid(RadrootsIdParseError), + RelayInvalid(RadrootsIdParseError), + NestedParentMatchesRoot, + TagElementTooLarge { max: usize, actual: usize }, + TagBytesExceeded { max: usize, actual: usize }, + EventWireTooLarge { max: usize, actual: usize }, +} + +impl RadrootsNip10ReplyError { + pub const fn code(&self) -> &'static str { + match self { + Self::ContentMissing => "reply_content_missing", + Self::ContentTooLarge { .. } => "reply_content_too_large", + Self::EventIdInvalid(_) => "reply_event_id_invalid", + Self::AuthorInvalid(_) => "reply_author_invalid", + Self::RelayInvalid(_) => "reply_relay_invalid", + Self::NestedParentMatchesRoot => "reply_reference_ambiguous", + Self::TagElementTooLarge { .. } => "reply_tag_element_too_large", + Self::TagBytesExceeded { .. } => "reply_tag_bytes_exceeded", + Self::EventWireTooLarge { .. } => "reply_event_wire_too_large", + } + } +} + +impl fmt::Display for RadrootsNip10ReplyError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ContentMissing => { + formatter.write_str("authored NIP-10 reply content must be non-whitespace") + } + Self::ContentTooLarge { max, actual } => { + write!( + formatter, + "authored NIP-10 reply content is {actual} bytes; max is {max}" + ) + } + Self::EventIdInvalid(error) => { + write!(formatter, "NIP-10 reply event id is invalid: {error}") + } + Self::AuthorInvalid(error) => { + write!(formatter, "NIP-10 reply author is invalid: {error}") + } + Self::RelayInvalid(error) => { + write!(formatter, "NIP-10 reply relay hint is invalid: {error}") + } + Self::NestedParentMatchesRoot => { + formatter.write_str("nested NIP-10 reply parent must differ from the thread root") + } + Self::TagElementTooLarge { max, actual } => { + write!( + formatter, + "authored NIP-10 reply tag element is {actual} bytes; max is {max}" + ) + } + Self::TagBytesExceeded { max, actual } => { + write!( + formatter, + "authored NIP-10 reply tag bytes are {actual}; max is {max}" + ) + } + Self::EventWireTooLarge { max, actual } => write!( + formatter, + "authored NIP-10 reply canonical signed event is at most {actual} bytes; max is {max}" + ), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsNip10ReplyError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::EventIdInvalid(error) + | Self::AuthorInvalid(error) + | Self::RelayInvalid(error) => Some(error), + _ => None, + } + } +} + +/// One syntactically validated reference used by strict NIP-10 authoring. +/// +/// The caller asserts that the target is a kind-1 event. This value does not +/// retrieve the target or prove its existence, kind, signature, or author. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsNip10ReplyReference { + event_id: RadrootsEventId, + author: RadrootsPublicKey, + relay: Option<RadrootsRelayUrl>, +} + +impl RadrootsNip10ReplyReference { + pub fn new( + event_id: RadrootsEventId, + author: RadrootsPublicKey, + relay: Option<RadrootsRelayUrl>, + ) -> Result<Self, RadrootsNip10ReplyError> { + if let Some(relay) = &relay { + validate_tag_element(relay.as_str())?; + } + Ok(Self { + event_id, + author, + relay, + }) + } + + pub fn parse( + event_id: impl AsRef<str>, + author: impl AsRef<str>, + relay: Option<&str>, + ) -> Result<Self, RadrootsNip10ReplyError> { + let event_id = + RadrootsEventId::parse(event_id).map_err(RadrootsNip10ReplyError::EventIdInvalid)?; + let author = + RadrootsPublicKey::parse(author).map_err(RadrootsNip10ReplyError::AuthorInvalid)?; + let relay = match relay { + None | Some("") => None, + Some(relay) => Some( + RadrootsRelayUrl::parse(relay).map_err(RadrootsNip10ReplyError::RelayInvalid)?, + ), + }; + Self::new(event_id, author, relay) + } + + pub const fn event_id(&self) -> &RadrootsEventId { + &self.event_id + } + + pub const fn author(&self) -> &RadrootsPublicKey { + &self.author + } + + pub const fn relay(&self) -> Option<&RadrootsRelayUrl> { + self.relay.as_ref() + } + + pub fn relay_or_empty(&self) -> &str { + self.relay.as_ref().map_or("", RadrootsRelayUrl::as_str) + } +} + +/// Strict authored marked NIP-10 reply. +/// +/// Direct replies contain one `root` reference. Nested replies contain one +/// `root` and one distinct `reply` reference. The type is intentionally opaque +/// and has no Serde construction path. +/// +/// ```compile_fail +/// let _: radroots_event::reply::RadrootsAuthoredNip10Reply = +/// serde_json::from_str(r#"{"content":"reply"}"#).unwrap(); +/// ``` +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsAuthoredNip10Reply { + content: String, + root: RadrootsNip10ReplyReference, + parent: Option<RadrootsNip10ReplyReference>, +} + +impl RadrootsAuthoredNip10Reply { + pub fn direct( + content: impl Into<String>, + root: RadrootsNip10ReplyReference, + ) -> Result<Self, RadrootsNip10ReplyError> { + Self::new(content.into(), root, None) + } + + pub fn nested( + content: impl Into<String>, + root: RadrootsNip10ReplyReference, + parent: RadrootsNip10ReplyReference, + ) -> Result<Self, RadrootsNip10ReplyError> { + if root.event_id == parent.event_id { + return Err(RadrootsNip10ReplyError::NestedParentMatchesRoot); + } + Self::new(content.into(), root, Some(parent)) + } + + fn new( + content: String, + root: RadrootsNip10ReplyReference, + parent: Option<RadrootsNip10ReplyReference>, + ) -> Result<Self, RadrootsNip10ReplyError> { + validate_content(&content)?; + validate_authored_reply_wire_size(&content, &root, parent.as_ref())?; + Ok(Self { + content, + root, + parent, + }) + } + + pub fn content(&self) -> &str { + &self.content + } + + pub const fn root(&self) -> &RadrootsNip10ReplyReference { + &self.root + } + + pub const fn parent(&self) -> Option<&RadrootsNip10ReplyReference> { + self.parent.as_ref() + } + + pub const fn is_direct(&self) -> bool { + self.parent.is_none() + } +} + +fn validate_content(content: &str) -> Result<(), RadrootsNip10ReplyError> { + if content.trim().is_empty() { + return Err(RadrootsNip10ReplyError::ContentMissing); + } + if content.len() > RADROOTS_POST_CONTENT_MAX_BYTES { + return Err(RadrootsNip10ReplyError::ContentTooLarge { + max: RADROOTS_POST_CONTENT_MAX_BYTES, + actual: content.len(), + }); + } + Ok(()) +} + +fn validate_tag_element(element: &str) -> Result<(), RadrootsNip10ReplyError> { + if element.len() > RADROOTS_POST_TAG_ELEMENT_MAX_BYTES { + return Err(RadrootsNip10ReplyError::TagElementTooLarge { + max: RADROOTS_POST_TAG_ELEMENT_MAX_BYTES, + actual: element.len(), + }); + } + Ok(()) +} + +fn validate_authored_reply_wire_size( + content: &str, + root: &RadrootsNip10ReplyReference, + parent: Option<&RadrootsNip10ReplyReference>, +) -> Result<(), RadrootsNip10ReplyError> { + let mut tag_bytes = 0usize; + let mut tags_json_bytes = 2usize; + let mut tag_count = 0usize; + + add_tag( + &mut tag_bytes, + &mut tags_json_bytes, + &mut tag_count, + &["e", root.event_id.as_str(), root.relay_or_empty(), "root"], + ); + if let Some(parent) = parent { + add_tag( + &mut tag_bytes, + &mut tags_json_bytes, + &mut tag_count, + &[ + "e", + parent.event_id.as_str(), + parent.relay_or_empty(), + "reply", + ], + ); + } + add_tag( + &mut tag_bytes, + &mut tags_json_bytes, + &mut tag_count, + &["p", root.author.as_str()], + ); + if let Some(parent) = parent.filter(|parent| parent.author != root.author) { + add_tag( + &mut tag_bytes, + &mut tags_json_bytes, + &mut tag_count, + &["p", parent.author.as_str()], + ); + } + + if tag_bytes > RADROOTS_POST_TAG_TOTAL_MAX_BYTES { + return Err(RadrootsNip10ReplyError::TagBytesExceeded { + max: RADROOTS_POST_TAG_TOTAL_MAX_BYTES, + actual: tag_bytes, + }); + } + let actual = RADROOTS_NIP10_REPLY_SIGNED_EVENT_FIXED_MAX_BYTES + .saturating_add(tags_json_bytes) + .saturating_add(canonical_json_string_bytes(content)); + if actual > RADROOTS_POST_EVENT_WIRE_MAX_BYTES { + return Err(RadrootsNip10ReplyError::EventWireTooLarge { + max: RADROOTS_POST_EVENT_WIRE_MAX_BYTES, + actual, + }); + } + Ok(()) +} + +fn add_tag( + tag_bytes: &mut usize, + tags_json_bytes: &mut usize, + tag_count: &mut usize, + elements: &[&str], +) { + if *tag_count > 0 { + *tags_json_bytes = tags_json_bytes.saturating_add(1); + } + *tags_json_bytes = tags_json_bytes.saturating_add(2); + for (index, element) in elements.iter().enumerate() { + if index > 0 { + *tags_json_bytes = tags_json_bytes.saturating_add(1); + } + *tags_json_bytes = tags_json_bytes.saturating_add(canonical_json_string_bytes(element)); + *tag_bytes = tag_bytes.saturating_add(element.len()); + } + *tag_count = tag_count.saturating_add(1); +} + +fn canonical_json_string_bytes(value: &str) -> usize { + value.chars().fold(2usize, |total, character| { + total.saturating_add(match character { + '"' | '\\' | '\u{0008}' | '\t' | '\n' | '\u{000c}' | '\r' => 2, + '\u{0000}'..='\u{001f}' => 6, + _ => character.len_utf8(), + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn reference(event: char, author: char) -> RadrootsNip10ReplyReference { + RadrootsNip10ReplyReference::parse( + event.to_string().repeat(64), + author.to_string().repeat(64), + Some("wss://relay.example"), + ) + .expect("reference") + } + + #[test] + fn builds_direct_and_nested_replies_with_distinct_coordinates() { + let direct = + RadrootsAuthoredNip10Reply::direct("Direct", reference('a', 'b')).expect("direct"); + assert!(direct.is_direct()); + assert!(direct.parent().is_none()); + + let nested = + RadrootsAuthoredNip10Reply::nested("Nested", reference('a', 'b'), reference('c', 'd')) + .expect("nested"); + assert!(!nested.is_direct()); + assert_eq!( + nested.parent().expect("parent").event_id().as_str(), + "c".repeat(64) + ); + } + + #[test] + fn rejects_blank_content_and_ambiguous_nested_parent() { + assert_eq!( + RadrootsAuthoredNip10Reply::direct("\t", reference('a', 'b')).unwrap_err(), + RadrootsNip10ReplyError::ContentMissing + ); + + let root = reference('a', 'b'); + let parent = reference('a', 'c'); + assert_eq!( + RadrootsAuthoredNip10Reply::nested("Nested", root, parent).unwrap_err(), + RadrootsNip10ReplyError::NestedParentMatchesRoot + ); + } + + #[test] + fn parses_and_canonicalizes_reference_identifiers() { + let reference = RadrootsNip10ReplyReference::parse( + "A".repeat(64), + "B".repeat(64), + Some("wss://relay.example"), + ) + .expect("reference"); + assert_eq!(reference.event_id().as_str(), "a".repeat(64)); + assert_eq!(reference.author().as_str(), "b".repeat(64)); + assert_eq!( + reference.relay().expect("relay").as_str(), + "wss://relay.example" + ); + + let error = + RadrootsNip10ReplyReference::parse("not-an-id", "b".repeat(64), None).unwrap_err(); + assert_eq!(error.code(), "reply_event_id_invalid"); + } + + #[test] + fn enforces_content_and_relay_element_boundaries() { + let exact_content = "x".repeat(RADROOTS_POST_CONTENT_MAX_BYTES); + RadrootsAuthoredNip10Reply::direct(exact_content, reference('a', 'b')) + .expect("exact decoded content limit"); + assert!(matches!( + RadrootsAuthoredNip10Reply::direct( + "x".repeat(RADROOTS_POST_CONTENT_MAX_BYTES + 1), + reference('a', 'b'), + ), + Err(RadrootsNip10ReplyError::ContentTooLarge { + max: RADROOTS_POST_CONTENT_MAX_BYTES, + actual, + }) if actual == RADROOTS_POST_CONTENT_MAX_BYTES + 1 + )); + + let prefix = "wss://relay.example/"; + let exact_relay = format!( + "{prefix}{}", + "a".repeat(RADROOTS_POST_TAG_ELEMENT_MAX_BYTES - prefix.len()) + ); + RadrootsNip10ReplyReference::parse("a".repeat(64), "b".repeat(64), Some(&exact_relay)) + .expect("exact tag-element limit"); + let overflow_relay = format!("{exact_relay}a"); + assert!(matches!( + RadrootsNip10ReplyReference::parse( + "a".repeat(64), + "b".repeat(64), + Some(&overflow_relay), + ), + Err(RadrootsNip10ReplyError::TagElementTooLarge { + max: RADROOTS_POST_TAG_ELEMENT_MAX_BYTES, + actual, + }) if actual == RADROOTS_POST_TAG_ELEMENT_MAX_BYTES + 1 + )); + } + + #[test] + fn escaped_content_cannot_cross_compact_signed_wire_limit() { + let mut lower = 1usize; + let mut upper = RADROOTS_POST_CONTENT_MAX_BYTES; + while lower < upper { + let candidate = lower + (upper - lower).div_ceil(2); + if RadrootsAuthoredNip10Reply::direct("\u{0001}".repeat(candidate), reference('a', 'b')) + .is_ok() + { + lower = candidate; + } else { + upper = candidate - 1; + } + } + + RadrootsAuthoredNip10Reply::direct("\u{0001}".repeat(lower), reference('a', 'b')) + .expect("largest escaped content fitting the wire budget"); + assert!(matches!( + RadrootsAuthoredNip10Reply::direct("\u{0001}".repeat(lower + 1), reference('a', 'b'),), + Err(RadrootsNip10ReplyError::EventWireTooLarge { + max: RADROOTS_POST_EVENT_WIRE_MAX_BYTES, + .. + }) + )); + } +} diff --git a/crates/event_codec/README b/crates/event_codec/README @@ -41,6 +41,14 @@ remain ordered; malformed media becomes diagnostic Update unless a valid Ask marker takes precedence. Structural inbound URLs remain unverified and no network retrieval occurs. +The Reply codec deterministically emits strict marked direct and nested NIP-10 +wire parts from `RadrootsAuthoredNip10Reply`. Its inbound boundary projects +only signature-and-id verified kind-1 envelopes, accepts marked and deprecated +positional thread anchors, retains valid supplemental citations, and reports +malformed advisory relay, author, participant, and citation metadata through +ordered diagnostics. Root and parent ambiguity or malformed hard anchors +remain projection failures. + The FoodAvailability codec owns four boundaries for the focused `radroots.food.availability.v1` kind-`30402` profile. Strict details plus `created_at` produce bounded unsigned wire parts whose tag order is exactly diff --git a/crates/event_codec/src/lib.rs b/crates/event_codec/src/lib.rs @@ -15,6 +15,7 @@ pub mod knowledge; pub mod manifest; pub mod parsed; pub mod profile; +pub mod reply; pub mod report; pub mod repost; mod social_helpers; diff --git a/crates/event_codec/src/post/admission.rs b/crates/event_codec/src/post/admission.rs @@ -49,8 +49,8 @@ impl RadrootsAdmittedRootPostEvent { /// A verified kind-1 event excluded from root-card admission by an `e` tag. /// -/// This candidate carries no Reply claim. Slice 06 owns strict NIP-10 parsing -/// and any later promotion into a semantic thread model. +/// This candidate carries no Reply claim. Promotion into a semantic thread +/// model is available only through the dedicated NIP-10 admission boundary. #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsThreadExcludedPostCandidate { verified_event: RadrootsSignatureVerifiedEvent, diff --git a/crates/event_codec/src/reply/admission.rs b/crates/event_codec/src/reply/admission.rs @@ -0,0 +1,120 @@ +use core::fmt; + +use radroots_event::{RadrootsEventEnvelope, contract::RadrootsEventContract}; + +use crate::{ + post::admission::RadrootsThreadExcludedPostCandidate, + reply::inbound::{ + RadrootsInboundNip10ReplyProjection, RadrootsNip10ReplyProjectionError, + project_verified_nip10_reply_event, + }, + verification::{ + RadrootsNip01VerificationError, RadrootsSignatureVerifiedEvent, verify_nip01_event, + }, +}; + +/// A signature-and-id verified kind-1 event admitted as a NIP-10 Reply. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsAdmittedNip10ReplyEvent { + verified_event: RadrootsSignatureVerifiedEvent, + projection: RadrootsInboundNip10ReplyProjection, +} + +impl RadrootsAdmittedNip10ReplyEvent { + pub fn verified_event(&self) -> &RadrootsSignatureVerifiedEvent { + &self.verified_event + } + + pub fn event(&self) -> &RadrootsEventEnvelope { + self.verified_event.event() + } + + pub fn projection(&self) -> &RadrootsInboundNip10ReplyProjection { + &self.projection + } + + pub fn contract(&self) -> &'static RadrootsEventContract { + radroots_event::contract::event_contract(self.projection.contract_id()) + .expect("NIP-10 Reply contract is registry-owned") + } + + pub fn into_parts( + self, + ) -> ( + RadrootsSignatureVerifiedEvent, + RadrootsInboundNip10ReplyProjection, + ) { + (self.verified_event, self.projection) + } +} + +#[non_exhaustive] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsNip10ReplyAdmissionError { + Nip01Verification(RadrootsNip01VerificationError), + Projection(RadrootsNip10ReplyProjectionError), +} + +impl RadrootsNip10ReplyAdmissionError { + pub const fn code(&self) -> &'static str { + match self { + Self::Nip01Verification(error) => error.code(), + Self::Projection(error) => error.code(), + } + } +} + +impl fmt::Display for RadrootsNip10ReplyAdmissionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Nip01Verification(error) => write!(formatter, "{error}"), + Self::Projection(error) => write!(formatter, "{error}"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsNip10ReplyAdmissionError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Nip01Verification(error) => Some(error), + Self::Projection(error) => Some(error), + } + } +} + +impl From<RadrootsNip01VerificationError> for RadrootsNip10ReplyAdmissionError { + fn from(value: RadrootsNip01VerificationError) -> Self { + Self::Nip01Verification(value) + } +} + +impl From<RadrootsNip10ReplyProjectionError> for RadrootsNip10ReplyAdmissionError { + fn from(value: RadrootsNip10ReplyProjectionError) -> Self { + Self::Projection(value) + } +} + +pub fn admit_verified_nip10_reply_event( + verified_event: RadrootsSignatureVerifiedEvent, +) -> Result<RadrootsAdmittedNip10ReplyEvent, RadrootsNip10ReplyAdmissionError> { + let projection = project_verified_nip10_reply_event(&verified_event)?; + Ok(RadrootsAdmittedNip10ReplyEvent { + verified_event, + projection, + }) +} + +/// Promotes a verified candidate already excluded from root-card admission. +pub fn admit_thread_excluded_post_candidate( + candidate: RadrootsThreadExcludedPostCandidate, +) -> Result<RadrootsAdmittedNip10ReplyEvent, RadrootsNip10ReplyAdmissionError> { + let (verified_event, _) = candidate.into_parts(); + admit_verified_nip10_reply_event(verified_event) +} + +pub fn verify_and_admit_nip10_reply_event( + event: RadrootsEventEnvelope, +) -> Result<RadrootsAdmittedNip10ReplyEvent, RadrootsNip10ReplyAdmissionError> { + admit_verified_nip10_reply_event(verify_nip01_event(event)?) +} diff --git a/crates/event_codec/src/reply/authored.rs b/crates/event_codec/src/reply/authored.rs @@ -0,0 +1,84 @@ +#[cfg(not(feature = "std"))] +use alloc::{ + string::{String, ToString}, + vec, + vec::Vec, +}; + +use radroots_event::{ + kinds::KIND_POST, + reply::{RadrootsAuthoredNip10Reply, RadrootsNip10ReplyReference}, + wire::RadrootsNip01EventWireParts, +}; + +/// Builds deterministic unsigned kind-1 wire parts for a strict marked NIP-10 +/// reply. +pub fn authored_nip10_reply_to_wire_parts( + reply: &RadrootsAuthoredNip10Reply, +) -> RadrootsNip01EventWireParts { + let mut tags = Vec::with_capacity(if reply.parent().is_some() { 4 } else { 2 }); + tags.push(event_tag(reply.root(), "root")); + if let Some(parent) = reply.parent() { + tags.push(event_tag(parent, "reply")); + } + tags.push(public_key_tag(reply.root())); + if let Some(parent) = reply + .parent() + .filter(|parent| parent.author() != reply.root().author()) + { + tags.push(public_key_tag(parent)); + } + RadrootsNip01EventWireParts { + kind: KIND_POST, + content: reply.content().to_string(), + tags, + } +} + +fn event_tag(reference: &RadrootsNip10ReplyReference, marker: &str) -> Vec<String> { + vec![ + "e".to_string(), + reference.event_id().as_str().to_string(), + reference.relay_or_empty().to_string(), + marker.to_string(), + ] +} + +fn public_key_tag(reference: &RadrootsNip10ReplyReference) -> Vec<String> { + vec!["p".to_string(), reference.author().as_str().to_string()] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nested_reply_deduplicates_equal_reference_authors() { + let author = "b".repeat(64); + let root = RadrootsNip10ReplyReference::parse("a".repeat(64), &author, None) + .expect("root reference"); + let parent = RadrootsNip10ReplyReference::parse("c".repeat(64), &author, None) + .expect("parent reference"); + let reply = + RadrootsAuthoredNip10Reply::nested("Reply", root, parent).expect("nested reply"); + + assert_eq!( + authored_nip10_reply_to_wire_parts(&reply).tags, + vec![ + vec![ + "e".to_string(), + "a".repeat(64), + String::new(), + "root".to_string(), + ], + vec![ + "e".to_string(), + "c".repeat(64), + String::new(), + "reply".to_string(), + ], + vec!["p".to_string(), author], + ] + ); + } +} diff --git a/crates/event_codec/src/reply/inbound.rs b/crates/event_codec/src/reply/inbound.rs @@ -0,0 +1,1134 @@ +#[cfg(not(feature = "std"))] +use alloc::{collections::BTreeSet, string::String, vec::Vec}; +use core::fmt; +#[cfg(feature = "std")] +use std::collections::BTreeSet; + +use radroots_event::{ + ids::{RadrootsEventId, RadrootsIdParseError, RadrootsPublicKey, RadrootsRelayUrl}, + kinds::KIND_POST, + post::{ + RADROOTS_POST_CONTENT_MAX_BYTES, RADROOTS_POST_EVENT_WIRE_MAX_BYTES, + RADROOTS_POST_TAG_ELEMENT_MAX_BYTES, RADROOTS_POST_TAG_TOTAL_MAX_BYTES, + }, +}; + +use crate::verification::RadrootsSignatureVerifiedEvent; + +const RADROOTS_NIP10_REPLY_SIGNED_EVENT_FIXED_BYTES: usize = "{\"id\":\"".len() + + 64 + + "\",\"pubkey\":\"".len() + + 64 + + "\",\"created_at\":".len() + + ",\"kind\":1,\"tags\":".len() + + ",\"content\":".len() + + ",\"sig\":\"".len() + + 128 + + "\"}".len(); + +#[non_exhaustive] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsNip10ReplyStyle { + Marked, + LegacyPositional, +} + +/// One ignored interoperability issue encountered while projecting a verified +/// inbound NIP-10 Reply. +/// +/// Diagnostics are ordered deterministically: event-reference diagnostics in +/// `e`-tag order, participant diagnostics in `p`-tag order, then reference to +/// participant relationship diagnostics in root/parent/citation order. +#[non_exhaustive] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsNip10ReplyDiagnostic { + ReplyAuthorMissing, + ReferenceRelayIgnored { + tag_index: usize, + raw_tag: Vec<String>, + error: RadrootsIdParseError, + }, + ReferenceAuthorIgnored { + tag_index: usize, + raw_tag: Vec<String>, + error: RadrootsIdParseError, + }, + CitationShapeIgnored { + tag_index: usize, + raw_tag: Vec<String>, + }, + CitationEventIdIgnored { + tag_index: usize, + raw_tag: Vec<String>, + error: RadrootsIdParseError, + }, + CitationMarkerIgnored { + tag_index: usize, + raw_tag: Vec<String>, + }, + ReplyAuthorShapeIgnored { + tag_index: usize, + raw_tag: Vec<String>, + }, + ReplyAuthorIgnored { + tag_index: usize, + raw_tag: Vec<String>, + error: RadrootsIdParseError, + }, + ReplyAuthorRelayIgnored { + tag_index: usize, + raw_tag: Vec<String>, + error: RadrootsIdParseError, + }, + ReplyAuthorDuplicateIgnored { + tag_index: usize, + raw_tag: Vec<String>, + }, + ReplyAuthorMismatchIgnored { + tag_index: usize, + raw_tag: Vec<String>, + }, +} + +impl RadrootsNip10ReplyDiagnostic { + pub const fn code(&self) -> &'static str { + match self { + Self::ReplyAuthorMissing => "reply_author_missing_ignored", + Self::ReferenceRelayIgnored { .. } => "reply_reference_relay_ignored", + Self::ReferenceAuthorIgnored { .. } => "reply_reference_author_ignored", + Self::CitationShapeIgnored { .. } => "reply_citation_shape_ignored", + Self::CitationEventIdIgnored { .. } => "reply_citation_event_id_ignored", + Self::CitationMarkerIgnored { .. } => "reply_citation_marker_ignored", + Self::ReplyAuthorShapeIgnored { .. } => "reply_author_shape_ignored", + Self::ReplyAuthorIgnored { .. } => "reply_author_invalid_ignored", + Self::ReplyAuthorRelayIgnored { .. } => "reply_author_relay_ignored", + Self::ReplyAuthorDuplicateIgnored { .. } => "reply_author_duplicate_ignored", + Self::ReplyAuthorMismatchIgnored { .. } => "reply_author_mismatch_ignored", + } + } + + pub const fn tag_index(&self) -> Option<usize> { + match self { + Self::ReplyAuthorMissing => None, + Self::ReferenceRelayIgnored { tag_index, .. } + | Self::ReferenceAuthorIgnored { tag_index, .. } + | Self::CitationShapeIgnored { tag_index, .. } + | Self::CitationEventIdIgnored { tag_index, .. } + | Self::CitationMarkerIgnored { tag_index, .. } + | Self::ReplyAuthorShapeIgnored { tag_index, .. } + | Self::ReplyAuthorIgnored { tag_index, .. } + | Self::ReplyAuthorRelayIgnored { tag_index, .. } + | Self::ReplyAuthorDuplicateIgnored { tag_index, .. } + | Self::ReplyAuthorMismatchIgnored { tag_index, .. } => Some(*tag_index), + } + } + + pub fn raw_tag(&self) -> Option<&[String]> { + match self { + Self::ReplyAuthorMissing => None, + Self::ReferenceRelayIgnored { raw_tag, .. } + | Self::ReferenceAuthorIgnored { raw_tag, .. } + | Self::CitationShapeIgnored { raw_tag, .. } + | Self::CitationEventIdIgnored { raw_tag, .. } + | Self::CitationMarkerIgnored { raw_tag, .. } + | Self::ReplyAuthorShapeIgnored { raw_tag, .. } + | Self::ReplyAuthorIgnored { raw_tag, .. } + | Self::ReplyAuthorRelayIgnored { raw_tag, .. } + | Self::ReplyAuthorDuplicateIgnored { raw_tag, .. } + | Self::ReplyAuthorMismatchIgnored { raw_tag, .. } => Some(raw_tag), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsInboundNip10EventReference { + tag_index: usize, + raw_tag: Vec<String>, + event_id: RadrootsEventId, + relay: Option<RadrootsRelayUrl>, + author_hint: Option<RadrootsPublicKey>, +} + +impl RadrootsInboundNip10EventReference { + pub const fn tag_index(&self) -> usize { + self.tag_index + } + + pub fn raw_tag(&self) -> &[String] { + &self.raw_tag + } + + pub const fn event_id(&self) -> &RadrootsEventId { + &self.event_id + } + + pub const fn relay(&self) -> Option<&RadrootsRelayUrl> { + self.relay.as_ref() + } + + pub const fn author_hint(&self) -> Option<&RadrootsPublicKey> { + self.author_hint.as_ref() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsInboundNip10Participant { + tag_index: usize, + raw_tag: Vec<String>, + pubkey: RadrootsPublicKey, + relay: Option<RadrootsRelayUrl>, +} + +impl RadrootsInboundNip10Participant { + pub const fn tag_index(&self) -> usize { + self.tag_index + } + + pub fn raw_tag(&self) -> &[String] { + &self.raw_tag + } + + pub const fn pubkey(&self) -> &RadrootsPublicKey { + &self.pubkey + } + + pub const fn relay(&self) -> Option<&RadrootsRelayUrl> { + self.relay.as_ref() + } +} + +/// Normalized projection of one structurally valid NIP-10 reply. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsInboundNip10ReplyProjection { + style: RadrootsNip10ReplyStyle, + root: RadrootsInboundNip10EventReference, + parent: Option<RadrootsInboundNip10EventReference>, + citations: Vec<RadrootsInboundNip10EventReference>, + participants: Vec<RadrootsInboundNip10Participant>, + diagnostics: Vec<RadrootsNip10ReplyDiagnostic>, +} + +impl RadrootsInboundNip10ReplyProjection { + pub const fn style(&self) -> RadrootsNip10ReplyStyle { + self.style + } + + pub const fn root(&self) -> &RadrootsInboundNip10EventReference { + &self.root + } + + pub fn parent(&self) -> &RadrootsInboundNip10EventReference { + self.parent.as_ref().unwrap_or(&self.root) + } + + pub const fn reply_reference(&self) -> Option<&RadrootsInboundNip10EventReference> { + self.parent.as_ref() + } + + pub const fn is_direct(&self) -> bool { + self.parent.is_none() + } + + pub fn citations(&self) -> &[RadrootsInboundNip10EventReference] { + &self.citations + } + + pub fn participants(&self) -> &[RadrootsInboundNip10Participant] { + &self.participants + } + + pub fn diagnostics(&self) -> &[RadrootsNip10ReplyDiagnostic] { + &self.diagnostics + } + + pub const fn contract_id(&self) -> &'static str { + "radroots.social.reply.v1" + } +} + +#[non_exhaustive] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsNip10ReplyProjectionError { + InvalidKind { + expected: u32, + actual: u32, + }, + ContentTooLarge { + max: usize, + actual: usize, + }, + ReplyMarkerMissing, + ReplyMarkerCount, + EventReferenceShape { + tag_index: usize, + }, + EventIdInvalid { + tag_index: usize, + error: RadrootsIdParseError, + }, + ReplyReferenceAmbiguous, + TagElementTooLarge { + max: usize, + actual: usize, + tag_index: usize, + element_index: usize, + }, + TagBytesExceeded { + max: usize, + actual: usize, + }, + EventWireTooLarge { + max: usize, + actual: usize, + }, +} + +impl RadrootsNip10ReplyProjectionError { + pub const fn code(&self) -> &'static str { + match self { + Self::InvalidKind { .. } => "invalid_kind", + Self::ContentTooLarge { .. } => "reply_content_too_large", + Self::ReplyMarkerMissing => "reply_marker_missing", + Self::ReplyMarkerCount => "reply_marker_count", + Self::EventReferenceShape { .. } => "reply_reference_shape", + Self::EventIdInvalid { .. } => "reply_event_id_invalid", + Self::ReplyReferenceAmbiguous => "reply_reference_ambiguous", + Self::TagElementTooLarge { .. } => "reply_tag_element_too_large", + Self::TagBytesExceeded { .. } => "reply_tag_bytes_exceeded", + Self::EventWireTooLarge { .. } => "reply_event_wire_too_large", + } + } +} + +impl fmt::Display for RadrootsNip10ReplyProjectionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidKind { expected, actual } => { + write!( + formatter, + "NIP-10 reply kind must be {expected}, got {actual}" + ) + } + Self::ContentTooLarge { max, actual } => { + write!( + formatter, + "NIP-10 reply content is {actual} bytes; max is {max}" + ) + } + Self::ReplyMarkerMissing => formatter.write_str( + "NIP-10 reply references require a marked root or valid positional anchors", + ), + Self::ReplyMarkerCount => formatter + .write_str("NIP-10 reply must contain one root and at most one reply marker"), + Self::EventReferenceShape { tag_index } => { + write!(formatter, "NIP-10 e tag {tag_index} has an invalid shape") + } + Self::EventIdInvalid { tag_index, error } => { + write!( + formatter, + "NIP-10 e tag {tag_index} has an invalid event id: {error}" + ) + } + Self::ReplyReferenceAmbiguous => { + formatter.write_str("NIP-10 root and reply references must differ") + } + Self::TagElementTooLarge { + max, + actual, + tag_index, + element_index, + } => write!( + formatter, + "NIP-10 tag {tag_index} element {element_index} is {actual} bytes; max is {max}" + ), + Self::TagBytesExceeded { max, actual } => { + write!( + formatter, + "NIP-10 reply tag bytes are {actual}; max is {max}" + ) + } + Self::EventWireTooLarge { max, actual } => write!( + formatter, + "NIP-10 reply compact signed event is {actual} bytes; max is {max}" + ), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsNip10ReplyProjectionError {} + +/// Projects a signature-and-id verified kind-1 event as a NIP-10 reply. +/// +/// Preferred marked references and deprecated positional references are both +/// accepted. This does not prove that referenced events exist or are kind 1. +pub fn project_verified_nip10_reply_event( + verified_event: &RadrootsSignatureVerifiedEvent, +) -> Result<RadrootsInboundNip10ReplyProjection, RadrootsNip10ReplyProjectionError> { + let event = verified_event.event(); + project_nip10_reply_parts( + event.kind_u32(), + &event.tags_as_vec(), + event.content(), + decimal_digits(event.created_at_u64()), + ) +} + +fn project_nip10_reply_parts( + kind: u32, + tags: &[Vec<String>], + content: &str, + created_at_digits: usize, +) -> Result<RadrootsInboundNip10ReplyProjection, RadrootsNip10ReplyProjectionError> { + if kind != KIND_POST { + return Err(RadrootsNip10ReplyProjectionError::InvalidKind { + expected: KIND_POST, + actual: kind, + }); + } + if content.len() > RADROOTS_POST_CONTENT_MAX_BYTES { + return Err(RadrootsNip10ReplyProjectionError::ContentTooLarge { + max: RADROOTS_POST_CONTENT_MAX_BYTES, + actual: content.len(), + }); + } + validate_tag_and_wire_budgets(tags, content, created_at_digits)?; + + let e_tags = tags + .iter() + .enumerate() + .filter(|(_, tag)| tag.first().is_some_and(|name| name == "e")) + .collect::<Vec<_>>(); + if e_tags.is_empty() { + return Err(RadrootsNip10ReplyProjectionError::ReplyMarkerMissing); + } + let has_marked_reference = e_tags + .iter() + .any(|(_, tag)| matches!(tag.get(3).map(String::as_str), Some("root" | "reply"))); + let mut diagnostics = Vec::new(); + let (style, root, parent, citations) = if has_marked_reference { + project_marked_references(&e_tags, &mut diagnostics)? + } else { + project_positional_references(&e_tags, &mut diagnostics)? + }; + let participants = project_participants(tags, &mut diagnostics); + for reference in core::iter::once(&root) + .chain(parent.iter()) + .chain(citations.iter()) + { + if let Some(author) = reference.author_hint() + && !participants + .iter() + .any(|participant| participant.pubkey() == author) + { + diagnostics.push(RadrootsNip10ReplyDiagnostic::ReplyAuthorMismatchIgnored { + tag_index: reference.tag_index, + raw_tag: reference.raw_tag.clone(), + }); + } + } + + Ok(RadrootsInboundNip10ReplyProjection { + style, + root, + parent, + citations, + participants, + diagnostics, + }) +} + +type IndexedTag<'a> = (usize, &'a Vec<String>); + +fn project_marked_references( + e_tags: &[IndexedTag<'_>], + diagnostics: &mut Vec<RadrootsNip10ReplyDiagnostic>, +) -> Result< + ( + RadrootsNip10ReplyStyle, + RadrootsInboundNip10EventReference, + Option<RadrootsInboundNip10EventReference>, + Vec<RadrootsInboundNip10EventReference>, + ), + RadrootsNip10ReplyProjectionError, +> { + let mut root = None; + let mut parent = None; + let mut citations = Vec::new(); + for (tag_index, tag) in e_tags { + let marker = match tag.get(3).map(String::as_str) { + Some(marker @ ("root" | "reply")) => marker, + _ => { + if let Some(citation) = project_supplemental_reference(*tag_index, tag, diagnostics) + { + citations.push(citation); + } + continue; + } + }; + if !matches!(tag.len(), 4 | 5) { + return Err(RadrootsNip10ReplyProjectionError::EventReferenceShape { + tag_index: *tag_index, + }); + } + let reference = parse_event_reference(*tag_index, tag, diagnostics)?; + match marker { + "root" if root.is_none() => root = Some(reference), + "reply" if parent.is_none() => parent = Some(reference), + _ => return Err(RadrootsNip10ReplyProjectionError::ReplyMarkerCount), + } + } + let Some(root) = root else { + return Err(RadrootsNip10ReplyProjectionError::ReplyMarkerCount); + }; + if parent + .as_ref() + .is_some_and(|parent| parent.event_id == root.event_id) + { + return Err(RadrootsNip10ReplyProjectionError::ReplyReferenceAmbiguous); + } + Ok((RadrootsNip10ReplyStyle::Marked, root, parent, citations)) +} + +fn project_positional_references( + e_tags: &[IndexedTag<'_>], + diagnostics: &mut Vec<RadrootsNip10ReplyDiagnostic>, +) -> Result< + ( + RadrootsNip10ReplyStyle, + RadrootsInboundNip10EventReference, + Option<RadrootsInboundNip10EventReference>, + Vec<RadrootsInboundNip10EventReference>, + ), + RadrootsNip10ReplyProjectionError, +> { + let (root_tag_index, root_tag) = e_tags[0]; + if !is_positional_reference_shape(root_tag) { + return Err(RadrootsNip10ReplyProjectionError::ReplyMarkerMissing); + } + let root = parse_event_reference(root_tag_index, root_tag, diagnostics)?; + let mut citations = Vec::new(); + for (tag_index, tag) in e_tags.iter().skip(1).take(e_tags.len().saturating_sub(2)) { + if let Some(citation) = project_supplemental_reference(*tag_index, tag, diagnostics) { + citations.push(citation); + } + } + let parent = if e_tags.len() > 1 { + let (parent_tag_index, parent_tag) = e_tags[e_tags.len() - 1]; + if !is_positional_reference_shape(parent_tag) { + return Err(RadrootsNip10ReplyProjectionError::ReplyMarkerMissing); + } + Some(parse_event_reference( + parent_tag_index, + parent_tag, + diagnostics, + )?) + } else { + None + }; + if parent + .as_ref() + .is_some_and(|parent| parent.event_id == root.event_id) + { + return Err(RadrootsNip10ReplyProjectionError::ReplyReferenceAmbiguous); + } + Ok(( + RadrootsNip10ReplyStyle::LegacyPositional, + root, + parent, + citations, + )) +} + +fn parse_event_reference( + tag_index: usize, + tag: &[String], + diagnostics: &mut Vec<RadrootsNip10ReplyDiagnostic>, +) -> Result<RadrootsInboundNip10EventReference, RadrootsNip10ReplyProjectionError> { + let event_id = tag + .get(1) + .ok_or(RadrootsNip10ReplyProjectionError::EventReferenceShape { tag_index }) + .and_then(|value| { + RadrootsEventId::parse(value).map_err(|error| { + RadrootsNip10ReplyProjectionError::EventIdInvalid { tag_index, error } + }) + })?; + let relay = match parse_relay_hint(tag.get(2).map(String::as_str)) { + Ok(relay) => relay, + Err(error) => { + diagnostics.push(RadrootsNip10ReplyDiagnostic::ReferenceRelayIgnored { + tag_index, + raw_tag: tag.to_vec(), + error, + }); + None + } + }; + let author_hint = if tag.len() == 5 { + match RadrootsPublicKey::parse(&tag[4]) { + Ok(author) => Some(author), + Err(error) => { + diagnostics.push(RadrootsNip10ReplyDiagnostic::ReferenceAuthorIgnored { + tag_index, + raw_tag: tag.to_vec(), + error, + }); + None + } + } + } else { + None + }; + Ok(RadrootsInboundNip10EventReference { + tag_index, + raw_tag: tag.to_vec(), + event_id, + relay, + author_hint, + }) +} + +fn project_supplemental_reference( + tag_index: usize, + tag: &[String], + diagnostics: &mut Vec<RadrootsNip10ReplyDiagnostic>, +) -> Option<RadrootsInboundNip10EventReference> { + match tag.get(3).map(String::as_str) { + Some("") if matches!(tag.len(), 4 | 5) => {} + Some("") => { + diagnostics.push(RadrootsNip10ReplyDiagnostic::CitationShapeIgnored { + tag_index, + raw_tag: tag.to_vec(), + }); + return None; + } + Some(_) => { + diagnostics.push(RadrootsNip10ReplyDiagnostic::CitationMarkerIgnored { + tag_index, + raw_tag: tag.to_vec(), + }); + return None; + } + None if matches!(tag.len(), 2 | 3) => {} + None => { + diagnostics.push(RadrootsNip10ReplyDiagnostic::CitationShapeIgnored { + tag_index, + raw_tag: tag.to_vec(), + }); + return None; + } + } + let event_id = match RadrootsEventId::parse(&tag[1]) { + Ok(event_id) => event_id, + Err(error) => { + diagnostics.push(RadrootsNip10ReplyDiagnostic::CitationEventIdIgnored { + tag_index, + raw_tag: tag.to_vec(), + error, + }); + return None; + } + }; + let relay = match parse_relay_hint(tag.get(2).map(String::as_str)) { + Ok(relay) => relay, + Err(error) => { + diagnostics.push(RadrootsNip10ReplyDiagnostic::ReferenceRelayIgnored { + tag_index, + raw_tag: tag.to_vec(), + error, + }); + None + } + }; + let author_hint = if tag.len() == 5 { + match RadrootsPublicKey::parse(&tag[4]) { + Ok(author) => Some(author), + Err(error) => { + diagnostics.push(RadrootsNip10ReplyDiagnostic::ReferenceAuthorIgnored { + tag_index, + raw_tag: tag.to_vec(), + error, + }); + None + } + } + } else { + None + }; + Some(RadrootsInboundNip10EventReference { + tag_index, + raw_tag: tag.to_vec(), + event_id, + relay, + author_hint, + }) +} + +fn is_positional_reference_shape(tag: &[String]) -> bool { + matches!(tag.len(), 2 | 3) + || matches!(tag.len(), 4 | 5) && tag.get(3).is_some_and(String::is_empty) +} + +fn project_participants( + tags: &[Vec<String>], + diagnostics: &mut Vec<RadrootsNip10ReplyDiagnostic>, +) -> Vec<RadrootsInboundNip10Participant> { + let p_tags = tags + .iter() + .enumerate() + .filter(|(_, tag)| tag.first().is_some_and(|name| name == "p")) + .collect::<Vec<_>>(); + if p_tags.is_empty() { + diagnostics.push(RadrootsNip10ReplyDiagnostic::ReplyAuthorMissing); + return Vec::new(); + } + + let mut seen = BTreeSet::new(); + let mut participants = Vec::with_capacity(p_tags.len()); + for (tag_index, tag) in p_tags { + if !(2..=4).contains(&tag.len()) { + diagnostics.push(RadrootsNip10ReplyDiagnostic::ReplyAuthorShapeIgnored { + tag_index, + raw_tag: tag.clone(), + }); + if tag.len() < 2 { + continue; + } + } + let pubkey = match RadrootsPublicKey::parse(&tag[1]) { + Ok(pubkey) => pubkey, + Err(error) => { + diagnostics.push(RadrootsNip10ReplyDiagnostic::ReplyAuthorIgnored { + tag_index, + raw_tag: tag.clone(), + error, + }); + continue; + } + }; + let relay = match parse_relay_hint(tag.get(2).map(String::as_str)) { + Ok(relay) => relay, + Err(error) => { + diagnostics.push(RadrootsNip10ReplyDiagnostic::ReplyAuthorRelayIgnored { + tag_index, + raw_tag: tag.clone(), + error, + }); + None + } + }; + if !seen.insert(pubkey.clone()) { + diagnostics.push(RadrootsNip10ReplyDiagnostic::ReplyAuthorDuplicateIgnored { + tag_index, + raw_tag: tag.clone(), + }); + continue; + } + participants.push(RadrootsInboundNip10Participant { + tag_index, + raw_tag: tag.clone(), + pubkey, + relay, + }); + } + participants +} + +fn parse_relay_hint(value: Option<&str>) -> Result<Option<RadrootsRelayUrl>, RadrootsIdParseError> { + match value { + None | Some("") => Ok(None), + Some(value) => RadrootsRelayUrl::parse(value).map(Some), + } +} + +fn validate_tag_and_wire_budgets( + tags: &[Vec<String>], + content: &str, + created_at_digits: usize, +) -> Result<(), RadrootsNip10ReplyProjectionError> { + let mut tag_bytes = 0usize; + let mut tags_json_bytes = 2usize; + for (tag_index, tag) in tags.iter().enumerate() { + if tag_index > 0 { + tags_json_bytes = tags_json_bytes.saturating_add(1); + } + tags_json_bytes = tags_json_bytes.saturating_add(2); + for (element_index, element) in tag.iter().enumerate() { + if element.len() > RADROOTS_POST_TAG_ELEMENT_MAX_BYTES { + return Err(RadrootsNip10ReplyProjectionError::TagElementTooLarge { + max: RADROOTS_POST_TAG_ELEMENT_MAX_BYTES, + actual: element.len(), + tag_index, + element_index, + }); + } + if element_index > 0 { + tags_json_bytes = tags_json_bytes.saturating_add(1); + } + tags_json_bytes = tags_json_bytes.saturating_add(canonical_json_string_bytes(element)); + tag_bytes = tag_bytes.saturating_add(element.len()); + } + } + if tag_bytes > RADROOTS_POST_TAG_TOTAL_MAX_BYTES { + return Err(RadrootsNip10ReplyProjectionError::TagBytesExceeded { + max: RADROOTS_POST_TAG_TOTAL_MAX_BYTES, + actual: tag_bytes, + }); + } + let actual = RADROOTS_NIP10_REPLY_SIGNED_EVENT_FIXED_BYTES + .saturating_add(created_at_digits) + .saturating_add(tags_json_bytes) + .saturating_add(canonical_json_string_bytes(content)); + if actual > RADROOTS_POST_EVENT_WIRE_MAX_BYTES { + return Err(RadrootsNip10ReplyProjectionError::EventWireTooLarge { + max: RADROOTS_POST_EVENT_WIRE_MAX_BYTES, + actual, + }); + } + Ok(()) +} + +fn decimal_digits(mut value: u64) -> usize { + let mut digits = 1usize; + while value >= 10 { + value /= 10; + digits += 1; + } + digits +} + +fn canonical_json_string_bytes(value: &str) -> usize { + value.chars().fold(2usize, |total, character| { + total.saturating_add(match character { + '"' | '\\' | '\u{0008}' | '\t' | '\n' | '\u{000c}' | '\r' => 2, + '\u{0000}'..='\u{001f}' => 6, + _ => character.len_utf8(), + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tolerant_inbound_accepts_blank_content_and_absent_participants() { + let projection = project_nip10_reply_parts( + KIND_POST, + &[vec![ + "e".to_string(), + "a".repeat(64), + String::new(), + "root".to_string(), + ]], + "", + 10, + ) + .expect("optional participants and blank content do not erase a Reply"); + + assert!(projection.is_direct()); + assert!(projection.participants().is_empty()); + assert_eq!( + projection + .diagnostics() + .iter() + .map(RadrootsNip10ReplyDiagnostic::code) + .collect::<Vec<_>>(), + vec!["reply_author_missing_ignored"] + ); + assert_eq!(projection.diagnostics()[0].tag_index(), None); + assert_eq!(projection.diagnostics()[0].raw_tag(), None); + } + + #[test] + fn tolerant_inbound_retains_raw_optional_metadata_and_orders_diagnostics() { + let root_id = "a".repeat(64); + let parent_id = "d".repeat(64); + let participant = "b".repeat(64); + let parent_author_hint = "c".repeat(64); + let tags = vec![ + vec![ + "e".to_string(), + root_id.clone(), + "https://relay.example".to_string(), + "root".to_string(), + "not-a-pubkey".to_string(), + ], + vec![ + "e".to_string(), + parent_id.clone(), + String::new(), + "reply".to_string(), + parent_author_hint, + ], + vec!["p".to_string()], + vec!["p".to_string(), "not-a-pubkey".to_string()], + vec![ + "p".to_string(), + participant.clone(), + "https://relay.example".to_string(), + ], + vec!["p".to_string(), participant.clone()], + ]; + + let projection = project_nip10_reply_parts(KIND_POST, &tags, "Reply", 10) + .expect("malformed optional metadata must not erase a Reply"); + + assert!(!projection.is_direct()); + assert_eq!(projection.root().tag_index(), 0); + assert_eq!(projection.root().raw_tag(), tags[0]); + assert_eq!(projection.root().event_id().as_str(), root_id); + assert!(projection.root().relay().is_none()); + assert!(projection.root().author_hint().is_none()); + let parent = projection.reply_reference().expect("reply reference"); + assert_eq!(parent.tag_index(), 1); + assert_eq!(parent.raw_tag(), tags[1]); + assert_eq!(parent.event_id().as_str(), parent_id); + assert_eq!(projection.participants().len(), 1); + assert_eq!(projection.participants()[0].tag_index(), 4); + assert_eq!(projection.participants()[0].raw_tag(), tags[4]); + assert_eq!(projection.participants()[0].pubkey().as_str(), participant); + assert!(projection.participants()[0].relay().is_none()); + + assert_eq!( + projection + .diagnostics() + .iter() + .map(RadrootsNip10ReplyDiagnostic::code) + .collect::<Vec<_>>(), + vec![ + "reply_reference_relay_ignored", + "reply_reference_author_ignored", + "reply_author_shape_ignored", + "reply_author_invalid_ignored", + "reply_author_relay_ignored", + "reply_author_duplicate_ignored", + "reply_author_mismatch_ignored", + ] + ); + assert_eq!( + projection + .diagnostics() + .iter() + .map(RadrootsNip10ReplyDiagnostic::tag_index) + .collect::<Vec<_>>(), + vec![ + Some(0), + Some(0), + Some(2), + Some(3), + Some(4), + Some(5), + Some(1), + ] + ); + for diagnostic in projection.diagnostics() { + let Some(tag_index) = diagnostic.tag_index() else { + continue; + }; + assert_eq!(diagnostic.raw_tag().expect("source tag"), tags[tag_index]); + } + } + + #[test] + fn tolerant_inbound_keeps_reference_ids_and_markers_as_hard_gates() { + let author = "b".repeat(64); + for (tag, expected) in [ + ( + vec![ + "e".to_string(), + "not-an-event-id".to_string(), + String::new(), + "root".to_string(), + ], + "reply_event_id_invalid", + ), + ( + vec![ + "e".to_string(), + "a".repeat(64), + String::new(), + "mention".to_string(), + ], + "reply_marker_missing", + ), + ] { + let error = project_nip10_reply_parts( + KIND_POST, + &[tag, vec!["p".to_string(), author.clone()]], + "Reply", + 10, + ) + .unwrap_err(); + assert_eq!(error.code(), expected); + } + } + + #[test] + fn marked_inbound_retains_citations_and_ignores_malformed_supplements() { + let root_id = "a".repeat(64); + let citation_id = "c".repeat(64); + let tags = vec![ + vec!["e".to_string()], + vec![ + "e".to_string(), + citation_id.clone(), + "wss://relay.example".to_string(), + String::new(), + "b".repeat(64), + ], + vec!["e".to_string(), "not-an-event-id".to_string()], + vec![ + "e".to_string(), + "d".repeat(64), + String::new(), + "mention".to_string(), + ], + vec![ + "e".to_string(), + root_id.clone(), + String::new(), + "root".to_string(), + ], + ]; + + let projection = project_nip10_reply_parts(KIND_POST, &tags, "Reply", 10) + .expect("supplemental references must not erase a marked Reply"); + + assert_eq!(projection.root().event_id().as_str(), root_id); + assert_eq!(projection.citations().len(), 1); + assert_eq!(projection.citations()[0].tag_index(), 1); + assert_eq!(projection.citations()[0].raw_tag(), tags[1]); + assert_eq!(projection.citations()[0].event_id().as_str(), citation_id); + assert_eq!( + projection.citations()[0] + .relay() + .expect("citation relay") + .as_str(), + "wss://relay.example" + ); + assert_eq!( + projection.citations()[0] + .author_hint() + .expect("citation author") + .as_str(), + "b".repeat(64) + ); + assert_eq!( + projection + .diagnostics() + .iter() + .map(RadrootsNip10ReplyDiagnostic::code) + .collect::<Vec<_>>(), + vec![ + "reply_citation_shape_ignored", + "reply_citation_event_id_ignored", + "reply_citation_marker_ignored", + "reply_author_missing_ignored", + "reply_author_mismatch_ignored", + ] + ); + assert_eq!( + projection + .diagnostics() + .iter() + .map(RadrootsNip10ReplyDiagnostic::tag_index) + .collect::<Vec<_>>(), + vec![Some(0), Some(2), Some(3), None, Some(1)] + ); + } + + #[test] + fn positional_inbound_accepts_empty_markers_and_tolerates_middle_citations() { + let root_id = "a".repeat(64); + let root_author = "b".repeat(64); + let direct_tags = vec![ + vec![ + "e".to_string(), + root_id.clone(), + "wss://root.relay.example".to_string(), + String::new(), + root_author.clone(), + ], + vec!["p".to_string(), root_author.clone()], + ]; + let direct = project_nip10_reply_parts(KIND_POST, &direct_tags, "Direct", 10) + .expect("empty marker and fifth author are valid positional input"); + assert_eq!(direct.style(), RadrootsNip10ReplyStyle::LegacyPositional); + assert!(direct.is_direct()); + assert_eq!( + direct + .root() + .author_hint() + .expect("root author hint") + .as_str(), + root_author + ); + assert!(direct.diagnostics().is_empty()); + + let parent_id = "c".repeat(64); + let parent_author = "d".repeat(64); + let citation_id = "e".repeat(64); + let citation_author = "f".repeat(64); + let nested_tags = vec![ + vec![ + "e".to_string(), + root_id, + String::new(), + String::new(), + root_author.clone(), + ], + vec![ + "e".to_string(), + "1".repeat(64), + String::new(), + "mention".to_string(), + ], + vec![ + "e".to_string(), + citation_id.clone(), + String::new(), + String::new(), + citation_author.clone(), + ], + vec![ + "e".to_string(), + parent_id.clone(), + "https://parent.relay.example".to_string(), + String::new(), + parent_author.clone(), + ], + vec!["p".to_string(), root_author], + vec!["p".to_string(), parent_author], + vec!["p".to_string(), citation_author], + ]; + let nested = project_nip10_reply_parts(KIND_POST, &nested_tags, "Nested", 10) + .expect("malformed middle citations must not erase positional anchors"); + assert_eq!(nested.style(), RadrootsNip10ReplyStyle::LegacyPositional); + assert_eq!(nested.parent().event_id().as_str(), parent_id); + assert_eq!(nested.citations().len(), 1); + assert_eq!(nested.citations()[0].tag_index(), 2); + assert_eq!(nested.citations()[0].event_id().as_str(), citation_id); + assert_eq!( + nested + .diagnostics() + .iter() + .map(RadrootsNip10ReplyDiagnostic::code) + .collect::<Vec<_>>(), + vec![ + "reply_citation_marker_ignored", + "reply_reference_relay_ignored", + ] + ); + assert_eq!(nested.diagnostics()[0].tag_index(), Some(1)); + assert_eq!( + nested.diagnostics()[0].raw_tag(), + Some(nested_tags[1].as_slice()) + ); + assert_eq!(nested.diagnostics()[1].tag_index(), Some(3)); + assert_eq!( + nested.diagnostics()[1].raw_tag(), + Some(nested_tags[3].as_slice()) + ); + } +} diff --git a/crates/event_codec/src/reply/mod.rs b/crates/event_codec/src/reply/mod.rs @@ -0,0 +1,3 @@ +pub mod admission; +pub mod authored; +pub mod inbound; diff --git a/crates/event_codec/tests/fixtures/post_verified_profiles.v1.json b/crates/event_codec/tests/fixtures/post_verified_profiles.v1.json @@ -137,6 +137,748 @@ } }, { + "id": "authored_nip10_direct_wire", + "kind": "social.reply.build_authored_draft.valid", + "input": { + "content": "Direct reply", + "root": { + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": "wss://root.relay.example" + } + }, + "expected": { + "kind": 1, + "content": "Direct reply", + "tags": [ + ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://root.relay.example", "root"], + ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"] + ] + } + }, + { + "id": "authored_nip10_nested_wire", + "kind": "social.reply.build_authored_draft.valid", + "input": { + "content": "Nested reply", + "root": { + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + }, + "parent": { + "event_id": "2222222222222222222222222222222222222222222222222222222222222222", + "author": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "relay": "wss://parent.relay.example" + } + }, + "expected": { + "kind": 1, + "content": "Nested reply", + "tags": [ + ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root"], + ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://parent.relay.example", "reply"], + ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"] + ] + } + }, + { + "id": "authored_nip10_ambiguous_parent", + "kind": "social.reply.build_authored_draft.invalid", + "input": { + "content": "Ambiguous", + "root": { + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + }, + "parent": { + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "author": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "relay": null + } + }, + "expected": { + "error": "reply_reference_ambiguous" + } + }, + { + "id": "authored_nip10_invalid_event_id", + "kind": "social.reply.build_authored_draft.invalid", + "input": { + "content": "Invalid", + "root": { + "event_id": "not-an-event-id", + "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + } + }, + "expected": { + "error": "reply_event_id_invalid" + } + }, + { + "id": "project_signed_nip10_marked_direct", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"2340fc3fec291f4d4429bf13bf23cc3b7ec8589aa5e6426a5fc5a25bfcf1a896\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000001,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Direct reply\",\"sig\":\"16afb66cf2e30450a1055d697044b0f27835352553f32f7ac8d18387cc27861dfde3bb820a8882c00f933c13d4c967df5d5db986cc228a80dd3d291841bf08cd\"}" + }, + "expected": { + "style": "marked", + "contract_id": "radroots.social.reply.v1", + "direct": true, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": null, + "author_hint": null + }, + "parent": null, + "citations": [], + "participants": [ + { + "tag_index": 1, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + } + ], + "diagnostics": [] + } + }, + { + "id": "project_signed_nip10_marked_with_citation", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"b6bd8c4e2c8e3098a0b6fdbefbf7a25285887519e2ea58738af4221ddb5fd39e\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000016,\"kind\":1,\"tags\":[[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"wss://relay.example\",\"\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Marked reply with citation\",\"sig\":\"9f6516def04521e4e9c9b27f1225e43c03919914c8ced58dfb0afd5ee4fb950bcbbe42ba505f00f75be399ac4a61f7a7eb612b58051bc78ae245b7b122f760e4\"}" + }, + "expected": { + "style": "marked", + "contract_id": "radroots.social.reply.v1", + "direct": true, + "root": { + "tag_index": 1, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": null, + "author_hint": null + }, + "parent": null, + "citations": [ + { + "tag_index": 0, + "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://relay.example", "", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "event_id": "3333333333333333333333333333333333333333333333333333333333333333", + "relay": "wss://relay.example", + "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + ], + "participants": [ + { + "tag_index": 2, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + } + ], + "diagnostics": [] + } + }, + { + "id": "project_signed_nip10_marked_with_malformed_citation", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"c346aaff3f00a5c8fac4d8dcc15f286f56acbc4608c2e67e799266643f2544fa\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000017,\"kind\":1,\"tags\":[[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"\",\"mention\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Marked reply with malformed citation\",\"sig\":\"25d8248ba0220435036611dfbf14c4d52f63a2f29e568f9fd79435faa126d7f862068a1db66dac9a2179911e084eb12a03948a51f0ad7a50ab4fc6a83491037b\"}" + }, + "expected": { + "style": "marked", + "contract_id": "radroots.social.reply.v1", + "direct": true, + "root": { + "tag_index": 1, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": null, + "author_hint": null + }, + "parent": null, + "citations": [], + "participants": [ + { + "tag_index": 2, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + } + ], + "diagnostics": [ + { + "code": "reply_citation_marker_ignored", + "tag_index": 0, + "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "", "mention"] + } + ] + } + }, + { + "id": "project_signed_nip10_marked_nested_reordered", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"8179bf853f4b24f4ba7af58f162c0dabf08e74c2fe27df4e5573e48d86a8c1c5\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000002,\"kind\":1,\"tags\":[[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"wss://parent.relay.example\",\"reply\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://root.relay.example\",\"root\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Nested reply\",\"sig\":\"53b88d556d28f98b43ee36eb8b6453d1fa36da7058891100608ab8e0842dc3d1c49de29323f08679ecbee9e49cf3a09c59139c9e7285826dc7db479dd6219fd3\"}" + }, + "expected": { + "style": "marked", + "contract_id": "radroots.social.reply.v1", + "direct": false, + "root": { + "tag_index": 1, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://root.relay.example", "root", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": "wss://root.relay.example", + "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "parent": { + "tag_index": 0, + "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://parent.relay.example", "reply", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], + "event_id": "2222222222222222222222222222222222222222222222222222222222222222", + "relay": "wss://parent.relay.example", + "author_hint": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "citations": [], + "participants": [ + { + "tag_index": 2, + "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "relay": null + }, + { + "tag_index": 3, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + } + ], + "diagnostics": [] + } + }, + { + "id": "project_signed_nip10_positional_direct", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"802da76963580d5932a400a2e56e73caccb80c78d43845c62f5fd95feee600e8\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000003,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Legacy direct reply\",\"sig\":\"9a1a958170b2faf3c467bf174e776f7f61bf886644657cc12d92d6a9782db89f273508e1d940412645bbb73a6ce7948b7d20daa77647aa5fd8f9e164159826e7\"}" + }, + "expected": { + "style": "legacy_positional", + "contract_id": "radroots.social.reply.v1", + "direct": true, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": null, + "author_hint": null + }, + "parent": null, + "citations": [], + "participants": [ + { + "tag_index": 1, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + } + ], + "diagnostics": [] + } + }, + { + "id": "project_signed_nip10_positional_many", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"71af1b702a370218eeb1d6d4fd4734c8cd5fbc64c02ef90196365bda0755490b\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000004,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://root.relay.example\"],[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"wss://parent.relay.example\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"]],\"content\":\"Legacy nested reply\",\"sig\":\"49d2bf6c65edab401f7d981bf7bd87b7b1ece61d347f75270f38cf67597791f4058c6bd58444a52f831b0cd15c1f91e18958b3ca877a196ababd7055247d65aa\"}" + }, + "expected": { + "style": "legacy_positional", + "contract_id": "radroots.social.reply.v1", + "direct": false, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://root.relay.example"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": "wss://root.relay.example", + "author_hint": null + }, + "parent": { + "tag_index": 2, + "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://parent.relay.example"], + "event_id": "2222222222222222222222222222222222222222222222222222222222222222", + "relay": "wss://parent.relay.example", + "author_hint": null + }, + "citations": [ + { + "tag_index": 1, + "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333"], + "event_id": "3333333333333333333333333333333333333333333333333333333333333333", + "relay": null, + "author_hint": null + } + ], + "participants": [ + { + "tag_index": 3, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + }, + { + "tag_index": 4, + "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "relay": null + } + ], + "diagnostics": [] + } + }, + { + "id": "project_signed_nip10_positional_direct_with_author_hint", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"ff050a31d73546d609080281ad2c1e98f878aba826108e7ee0462cf6b397a941\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000018,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://root.relay.example\",\"\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Legacy direct with author hint\",\"sig\":\"e71bcb61d49862cd7fa1c776e4fe51aa9e4198234ee93efeacc9afc3d7038c00dee246f1df19eb0fd22aee0a70436708faec39e92bee16e86601f1c91fee1117\"}" + }, + "expected": { + "style": "legacy_positional", + "contract_id": "radroots.social.reply.v1", + "direct": true, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://root.relay.example", "", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": "wss://root.relay.example", + "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "parent": null, + "citations": [], + "participants": [ + { + "tag_index": 1, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + } + ], + "diagnostics": [] + } + }, + { + "id": "project_signed_nip10_positional_many_with_author_hints", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"2473084e4272dc675f34e18b9b2aaa3a164e3132bf19056c734759c922acafa7\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000019,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://root.relay.example\",\"\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"wss://citation.relay.example\",\"\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"wss://parent.relay.example\",\"\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"p\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"]],\"content\":\"Legacy nested with author hints\",\"sig\":\"e94c18ae8d2c8785a32f42dfff57712508018e2820d24497fbafcfcecc50890d19a4c15d48b2729c4f9dbe73e4c985e055097174b4505ed3a68858d4d56f7809\"}" + }, + "expected": { + "style": "legacy_positional", + "contract_id": "radroots.social.reply.v1", + "direct": false, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://root.relay.example", "", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": "wss://root.relay.example", + "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "parent": { + "tag_index": 2, + "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://parent.relay.example", "", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], + "event_id": "2222222222222222222222222222222222222222222222222222222222222222", + "relay": "wss://parent.relay.example", + "author_hint": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "citations": [ + { + "tag_index": 1, + "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://citation.relay.example", "", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"], + "event_id": "3333333333333333333333333333333333333333333333333333333333333333", + "relay": "wss://citation.relay.example", + "author_hint": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + } + ], + "participants": [ + { + "tag_index": 3, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + }, + { + "tag_index": 4, + "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "relay": null + }, + { + "tag_index": 5, + "raw_tag": ["p", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"], + "pubkey": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "relay": null + } + ], + "diagnostics": [] + } + }, + { + "id": "project_signed_nip10_positional_malformed_middle_citation", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"ce7684655dafc04bcb8048f4ba95da4b4780316ce90143c332ca4ed40af60ac7\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000020,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\"],[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"\",\"mention\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"]],\"content\":\"Legacy nested with malformed citation\",\"sig\":\"a278e44d6c44fe196c3557d01a99ec3cf3eaa31e16b66370743183db671eac6da75f11151b9da194582b03f529c4e9f871cf743d58dea430d0d12c3ed90c4997\"}" + }, + "expected": { + "style": "legacy_positional", + "contract_id": "radroots.social.reply.v1", + "direct": false, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": null, + "author_hint": null + }, + "parent": { + "tag_index": 2, + "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222"], + "event_id": "2222222222222222222222222222222222222222222222222222222222222222", + "relay": null, + "author_hint": null + }, + "citations": [], + "participants": [ + { + "tag_index": 3, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + }, + { + "tag_index": 4, + "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "relay": null + } + ], + "diagnostics": [ + { + "code": "reply_citation_marker_ignored", + "tag_index": 1, + "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "", "mention"] + } + ] + } + }, + { + "id": "project_signed_nip10_precedes_ask_and_media", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"7911ed2e9e2e08173a357f5f83fcdc415ca0c87e95682bad28cefe3a616f5be3\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000005,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"t\",\"radroots-ask\"],[\"imeta\",\"url https://media.example/image.webp\",\"x malformed\"]],\"content\":\"Reply with compatibility metadata\",\"sig\":\"9bd6c5f40c8587893b3460a53931c1253b2764c44922b25ffb64fe3e96e7fe2b8d139d5fca5ffd7b6f5a1cf19598c2a34e7ab48f5d15b18e695a5e39c81fc9f4\"}" + }, + "expected": { + "style": "marked", + "contract_id": "radroots.social.reply.v1", + "direct": true, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": null, + "author_hint": null + }, + "parent": null, + "citations": [], + "participants": [ + { + "tag_index": 1, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + } + ], + "diagnostics": [] + } + }, + { + "id": "project_signed_nip10_invalid_relay", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"15e0f001e760eb1ac3fd15b8a85e43a5c07603d4ceccdc6ccb93e6cc1876a626\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000006,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"https://relay.example\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Invalid relay\",\"sig\":\"18065b4433c33b29a06ecec64f0f863482479aec2832fcde3c95d2814b5ed91310fdd44df507f346e0231206be239ed4ac85353fc60a838435ae10feae2cc7c6\"}" + }, + "expected": { + "style": "marked", + "contract_id": "radroots.social.reply.v1", + "direct": true, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "https://relay.example", "root"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": null, + "author_hint": null + }, + "parent": null, + "citations": [], + "participants": [ + { + "tag_index": 1, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + } + ], + "diagnostics": [ + { + "code": "reply_reference_relay_ignored", + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "https://relay.example", "root"] + } + ] + } + }, + { + "id": "project_signed_nip10_ambiguous_same_reference", + "kind": "social.reply.project_verified_event.invalid", + "input": { + "event_json": "{\"id\":\"86f19437493f84ecd999d1cd5890ff53fa157b99309f65c056de48e82a8c75bf\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000007,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"reply\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Ambiguous\",\"sig\":\"730b282fadf78838f6dad8830711fff6cff5b6da0d3c42db49faf1ef547cf3908ec0c901e1ac7069f8c346e9ec73cea879830a8687e18d87e476a1e0f82a0ca7\"}" + }, + "expected": { + "error": "reply_reference_ambiguous" + } + }, + { + "id": "project_signed_nip10_missing_author", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"2747dc566bfa7ecbc9744bf675d0bdaff435a16a4824a60a4ac5a6eb81b38bf7\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000008,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"]],\"content\":\"Missing author\",\"sig\":\"ceb6c490fe33ccf32c70fef87164212eb4b0d11f98e314b8a87fd9f5e6a9a08a175ab068fa307ac9b16a2e39c120e3c6185e42b8bb3a67e6843c1f676c62b97e\"}" + }, + "expected": { + "style": "marked", + "contract_id": "radroots.social.reply.v1", + "direct": true, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": null, + "author_hint": null + }, + "parent": null, + "citations": [], + "participants": [], + "diagnostics": [ + { + "code": "reply_author_missing_ignored", + "tag_index": null, + "raw_tag": null + } + ] + } + }, + { + "id": "project_signed_nip10_invalid_event_id", + "kind": "social.reply.project_verified_event.invalid", + "input": { + "event_json": "{\"id\":\"2c071cfb2696c8cb2e676564290007bcbc995d08a5404462f747d6ceb73194fe\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000009,\"kind\":1,\"tags\":[[\"e\",\"not-an-event-id\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Invalid event id\",\"sig\":\"712bed9b975b2742dbb4b241e70893c67fbd4debab110c7d44bf365500405cc9c5c39efa1f9a3260a3a57f10cd0b981d84c327ff4ae0f1afa21c6e9fc8f8b851\"}" + }, + "expected": { + "error": "reply_event_id_invalid" + } + }, + { + "id": "project_signed_nip10_author_hint_mismatch", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"a54d89f21c0a80df592eab60b07b0800ea5bd212fbe17d0f9deb852c109775a9\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000010,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"]],\"content\":\"Author mismatch\",\"sig\":\"3a9b2af22a1b63a1bf6a4cdf0f83497a199ff6f5edc5b1ea9fd0a70c4dd12034431e3992be9ee80827cd0f396e70d0a9f0c316408a269c80949cb5a0cf59c5f7\"}" + }, + "expected": { + "style": "marked", + "contract_id": "radroots.social.reply.v1", + "direct": true, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": null, + "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "parent": null, + "citations": [], + "participants": [ + { + "tag_index": 1, + "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"], + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "relay": null + } + ], + "diagnostics": [ + { + "code": "reply_author_mismatch_ignored", + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"] + } + ] + } + }, + { + "id": "project_signed_nip10_blank_content", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"bb57163b91910e28747d2ea50c89a84fc4d72ce0509d87e97492518f71081d39\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000013,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"\",\"sig\":\"b1833129fc7568362e5c308a3d4b44b09c5d7b7c474b1335cb9a332e9167689678c009547f312b5bc79442bd018ed8ed2af6c64eafd3112bb96e154d6b7b4b48\"}" + }, + "expected": { + "style": "marked", + "contract_id": "radroots.social.reply.v1", + "direct": true, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": null, + "author_hint": null + }, + "parent": null, + "citations": [], + "participants": [ + { + "tag_index": 1, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + } + ], + "diagnostics": [] + } + }, + { + "id": "project_signed_nip10_invalid_author_hint_tolerated", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"64a50435d0e23c12ae6e52b0eddcd0e9a8efb07f0c59e2b41f839a30b9593d56\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000014,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\",\"not-a-pubkey\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Malformed optional author\",\"sig\":\"3a554dd47779861e5c020bdcad2103b4831eaf7930b8a801bb99d7c0bcd898ef1a4a79aa8a3856533ab1b88bed0b2a8c44afafa6118daf8b172d502e22eeb95f\"}" + }, + "expected": { + "style": "marked", + "contract_id": "radroots.social.reply.v1", + "direct": true, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root", "not-a-pubkey"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": null, + "author_hint": null + }, + "parent": null, + "citations": [], + "participants": [ + { + "tag_index": 1, + "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relay": null + } + ], + "diagnostics": [ + { + "code": "reply_reference_author_ignored", + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root", "not-a-pubkey"] + } + ] + } + }, + { + "id": "project_signed_nip10_invalid_participant_tolerated", + "kind": "social.reply.project_verified_event.valid", + "input": { + "event_json": "{\"id\":\"2d76c510e6002361b33eb0a0aa01ebb612629a3c33190472f981460c85628683\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000015,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"not-a-pubkey\"]],\"content\":\"Malformed optional participant\",\"sig\":\"b84460d29b4cda084dc338c5e0596415dd7d48e2c494e54de3a6d59076fdbb418e595665f0016d0a3a93125ecaae848c962fe1bdf6efd4a635e38659424e9a75\"}" + }, + "expected": { + "style": "marked", + "contract_id": "radroots.social.reply.v1", + "direct": true, + "root": { + "tag_index": 0, + "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "", "root"], + "event_id": "1111111111111111111111111111111111111111111111111111111111111111", + "relay": null, + "author_hint": null + }, + "parent": null, + "citations": [], + "participants": [], + "diagnostics": [ + { + "code": "reply_author_invalid_ignored", + "tag_index": 1, + "raw_tag": ["p", "not-a-pubkey"] + } + ] + } + }, + { + "id": "project_signed_nip10_lone_reply_marker", + "kind": "social.reply.project_verified_event.invalid", + "input": { + "event_json": "{\"id\":\"eb5f2eea6aa74ffcbd758cb5fb72749babcad7343717cb30caac19200154f03a\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000011,\"kind\":1,\"tags\":[[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"\",\"reply\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"]],\"content\":\"Lone reply\",\"sig\":\"b4f2d2a564f248fc7012e427a1fea9267de728342485c3f8a4f19066cd15e4295b14a51f7aab898d38e61a5c86d134a80863e6ae1394081b4d9e368dee65e94b\"}" + }, + "expected": { + "error": "reply_marker_count" + } + }, + { + "id": "project_signed_nip10_unknown_marker", + "kind": "social.reply.project_verified_event.invalid", + "input": { + "event_json": "{\"id\":\"266e800a1a46fefad41fd745d3750eac53e5527339be4aa0b28f0fb8ebe34bea\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000012,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"mention\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Unknown marker\",\"sig\":\"fd06119aad71aef0851c900fed068540ca6dde40ccfb8fc70c74cf8d10f0fdd13aac49863753fa8f1fd679c1890ba410259a291780682cc3f8fcf5b89e381afe\"}" + }, + "expected": { + "error": "reply_marker_missing" + } + }, + { + "id": "admit_signed_nip10_marked_direct", + "kind": "social.reply.verify_and_admit_event.valid", + "input": { + "event_json": "{\"id\":\"2340fc3fec291f4d4429bf13bf23cc3b7ec8589aa5e6426a5fc5a25bfcf1a896\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000001,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Direct reply\",\"sig\":\"16afb66cf2e30450a1055d697044b0f27835352553f32f7ac8d18387cc27861dfde3bb820a8882c00f933c13d4c967df5d5db986cc228a80dd3d291841bf08cd\"}" + }, + "expected": { + "contract_id": "radroots.social.reply.v1", + "style": "marked", + "direct": true + } + }, + { + "id": "admit_signed_nip10_positional_direct", + "kind": "social.reply.verify_and_admit_event.valid", + "input": { + "event_json": "{\"id\":\"802da76963580d5932a400a2e56e73caccb80c78d43845c62f5fd95feee600e8\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000003,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Legacy direct reply\",\"sig\":\"9a1a958170b2faf3c467bf174e776f7f61bf886644657cc12d92d6a9782db89f273508e1d940412645bbb73a6ce7948b7d20daa77647aa5fd8f9e164159826e7\"}" + }, + "expected": { + "contract_id": "radroots.social.reply.v1", + "style": "legacy_positional", + "direct": true + } + }, + { + "id": "admit_signed_nip10_invalid_signature", + "kind": "social.reply.verify_and_admit_event.invalid", + "input": { + "event_json": "{\"id\":\"2340fc3fec291f4d4429bf13bf23cc3b7ec8589aa5e6426a5fc5a25bfcf1a896\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000001,\"kind\":1,\"tags\":[[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"\",\"root\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"content\":\"Direct reply\",\"sig\":\"06afb66cf2e30450a1055d697044b0f27835352553f32f7ac8d18387cc27861dfde3bb820a8882c00f933c13d4c967df5d5db986cc228a80dd3d291841bf08cd\"}" + }, + "expected": { + "error": "signature_invalid" + } + }, + { "expected": { "ask_marker": null, "classification": "update", diff --git a/crates/event_codec/tests/verified_post_conformance.rs b/crates/event_codec/tests/verified_post_conformance.rs @@ -13,6 +13,7 @@ use radroots_event::{ RadrootsAuthoredAsk, RadrootsAuthoredPhotoUpdate, RadrootsAuthoredPostError, RadrootsAuthoredPostImage, RadrootsAuthoredUpdate, RadrootsPostImageDimensions, }, + reply::{RadrootsAuthoredNip10Reply, RadrootsNip10ReplyError, RadrootsNip10ReplyReference}, wire::RadrootsNip01EventWireParts, }; use radroots_event_codec::post::{ @@ -23,6 +24,15 @@ use radroots_event_codec::post::{ }, inbound::{RadrootsInboundPostProjection, RadrootsPostClassification, RadrootsPostDiagnostic}, }; +use radroots_event_codec::reply::{ + admission::verify_and_admit_nip10_reply_event, + authored::authored_nip10_reply_to_wire_parts, + inbound::{ + RadrootsInboundNip10EventReference, RadrootsInboundNip10Participant, + RadrootsInboundNip10ReplyProjection, RadrootsNip10ReplyDiagnostic, RadrootsNip10ReplyStyle, + project_verified_nip10_reply_event, + }, +}; use radroots_event_codec::{ post::inbound::project_verified_post_event, verification::verify_nip01_event, }; @@ -33,7 +43,7 @@ const PACKAGED_VECTORS: &str = include_str!("fixtures/post_verified_profiles.v1. const WORKSPACE_VECTOR_PATH: &str = "../../contracts/conformance/vectors/post/verified_profiles.v1.json"; const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml"; -const EXPECTED_VECTOR_CASES: [(&str, &str); 27] = [ +const EXPECTED_VECTOR_CASES: [(&str, &str); 54] = [ ( "admit_signed_duplicate_normalized_ask_marker", "social.post.verify_and_admit_event.invalid", @@ -55,6 +65,18 @@ const EXPECTED_VECTOR_CASES: [(&str, &str); 27] = [ "social.post.verify_and_admit_event.invalid", ), ( + "admit_signed_nip10_invalid_signature", + "social.reply.verify_and_admit_event.invalid", + ), + ( + "admit_signed_nip10_marked_direct", + "social.reply.verify_and_admit_event.valid", + ), + ( + "admit_signed_nip10_positional_direct", + "social.reply.verify_and_admit_event.valid", + ), + ( "admit_signed_normalized_ask_precedes_malformed_media", "social.post.verify_and_admit_event.valid", ), @@ -76,6 +98,22 @@ const EXPECTED_VECTOR_CASES: [(&str, &str); 27] = [ ), ("authored_ask_wire", "social.ask.build_authored_draft.valid"), ( + "authored_nip10_ambiguous_parent", + "social.reply.build_authored_draft.invalid", + ), + ( + "authored_nip10_direct_wire", + "social.reply.build_authored_draft.valid", + ), + ( + "authored_nip10_invalid_event_id", + "social.reply.build_authored_draft.invalid", + ), + ( + "authored_nip10_nested_wire", + "social.reply.build_authored_draft.valid", + ), + ( "authored_photo_update_mime_underscore", "social.photo_update.build_authored_draft.invalid", ), @@ -120,6 +158,86 @@ const EXPECTED_VECTOR_CASES: [(&str, &str); 27] = [ "social.post.project_verified_event.valid", ), ( + "project_signed_nip10_ambiguous_same_reference", + "social.reply.project_verified_event.invalid", + ), + ( + "project_signed_nip10_author_hint_mismatch", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_blank_content", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_invalid_event_id", + "social.reply.project_verified_event.invalid", + ), + ( + "project_signed_nip10_invalid_relay", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_invalid_author_hint_tolerated", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_invalid_participant_tolerated", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_lone_reply_marker", + "social.reply.project_verified_event.invalid", + ), + ( + "project_signed_nip10_marked_direct", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_marked_with_citation", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_marked_with_malformed_citation", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_marked_nested_reordered", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_missing_author", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_positional_direct", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_positional_direct_with_author_hint", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_positional_many", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_positional_many_with_author_hints", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_positional_malformed_middle_citation", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_precedes_ask_and_media", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_unknown_marker", + "social.reply.project_verified_event.invalid", + ), + ( "project_signed_normalized_ask_precedes_malformed_media", "social.post.project_verified_event.valid", ), @@ -237,6 +355,61 @@ fn execute(vector: &Vector) { .expect_err("invalid authored Ask must fail"); assert_eq!(error.code(), expected_str(vector, "error"), "{}", vector.id); } + "social.reply.build_authored_draft.valid" => { + let reply = authored_reply(vector) + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); + let first = authored_nip10_reply_to_wire_parts(&reply); + let second = authored_nip10_reply_to_wire_parts(&reply); + assert_eq!(first, second, "{} repeat encoding drifted", vector.id); + assert_eq!(wire_parts_value(&first), vector.expected, "{}", vector.id); + } + "social.reply.build_authored_draft.invalid" => { + let error = authored_reply(vector).expect_err("invalid authored Reply must fail"); + assert_eq!(error.code(), expected_str(vector, "error"), "{}", vector.id); + } + "social.reply.project_verified_event.valid" => { + let envelope = canonical_envelope(input_str(vector, "event_json")); + let verified = verify_nip01_event(envelope) + .unwrap_or_else(|error| panic!("{} verification failed: {error}", vector.id)); + let projection = project_verified_nip10_reply_event(&verified) + .unwrap_or_else(|error| panic!("{} projection failed: {error}", vector.id)); + assert_eq!( + reply_projection_value(&projection), + vector.expected, + "{}", + vector.id + ); + } + "social.reply.project_verified_event.invalid" => { + let envelope = canonical_envelope(input_str(vector, "event_json")); + let verified = verify_nip01_event(envelope) + .unwrap_or_else(|error| panic!("{} verification failed: {error}", vector.id)); + let error = project_verified_nip10_reply_event(&verified) + .expect_err("invalid verified Reply projection must fail"); + assert_eq!(error.code(), expected_str(vector, "error"), "{}", vector.id); + } + "social.reply.verify_and_admit_event.valid" => { + let envelope = canonical_envelope(input_str(vector, "event_json")); + let expected_envelope = envelope.clone(); + let admitted = verify_and_admit_nip10_reply_event(envelope) + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); + assert_eq!(admitted.event(), &expected_envelope, "{}", vector.id); + let actual = json!({ + "contract_id": admitted.contract().id, + "style": reply_style_label(admitted.projection().style()), + "direct": admitted.projection().is_direct(), + }); + let (verified, projection) = admitted.into_parts(); + assert_eq!(verified.event(), &expected_envelope, "{}", vector.id); + assert_eq!(projection.contract_id(), "radroots.social.reply.v1"); + assert_eq!(actual, vector.expected, "{}", vector.id); + } + "social.reply.verify_and_admit_event.invalid" => { + let envelope = canonical_envelope(input_str(vector, "event_json")); + let error = verify_and_admit_nip10_reply_event(envelope) + .expect_err("invalid signed Reply vector must fail"); + assert_eq!(error.code(), expected_str(vector, "error"), "{}", vector.id); + } "social.post.project_verified_event.valid" => { let envelope = canonical_envelope(input_str(vector, "event_json")); let verified = verify_nip01_event(envelope) @@ -339,6 +512,58 @@ fn projection_value(projection: &RadrootsInboundPostProjection) -> Value { }) } +fn reply_projection_value(projection: &RadrootsInboundNip10ReplyProjection) -> Value { + json!({ + "style": reply_style_label(projection.style()), + "contract_id": projection.contract_id(), + "direct": projection.is_direct(), + "root": reply_event_reference_value(projection.root()), + "parent": projection.reply_reference().map(reply_event_reference_value), + "citations": projection + .citations() + .iter() + .map(reply_event_reference_value) + .collect::<Vec<_>>(), + "participants": projection + .participants() + .iter() + .map(reply_participant_value) + .collect::<Vec<_>>(), + "diagnostics": projection + .diagnostics() + .iter() + .map(reply_diagnostic_value) + .collect::<Vec<_>>(), + }) +} + +fn reply_event_reference_value(reference: &RadrootsInboundNip10EventReference) -> Value { + json!({ + "tag_index": reference.tag_index(), + "raw_tag": reference.raw_tag(), + "event_id": reference.event_id().as_str(), + "relay": reference.relay().map(|relay| relay.as_str()), + "author_hint": reference.author_hint().map(|author| author.as_str()), + }) +} + +fn reply_participant_value(participant: &RadrootsInboundNip10Participant) -> Value { + json!({ + "tag_index": participant.tag_index(), + "raw_tag": participant.raw_tag(), + "pubkey": participant.pubkey().as_str(), + "relay": participant.relay().map(|relay| relay.as_str()), + }) +} + +fn reply_diagnostic_value(diagnostic: &RadrootsNip10ReplyDiagnostic) -> Value { + json!({ + "code": diagnostic.code(), + "tag_index": diagnostic.tag_index(), + "raw_tag": diagnostic.raw_tag(), + }) +} + fn wire_parts_value(parts: &RadrootsNip01EventWireParts) -> Value { json!({ "kind": parts.kind, @@ -347,6 +572,39 @@ fn wire_parts_value(parts: &RadrootsNip01EventWireParts) -> Value { }) } +fn authored_reply(vector: &Vector) -> Result<RadrootsAuthoredNip10Reply, RadrootsNip10ReplyError> { + let root = authored_reply_reference(vector, &vector.input["root"])?; + match vector.input.get("parent") { + None | Some(Value::Null) => { + RadrootsAuthoredNip10Reply::direct(input_str(vector, "content"), root) + } + Some(parent) => RadrootsAuthoredNip10Reply::nested( + input_str(vector, "content"), + root, + authored_reply_reference(vector, parent)?, + ), + } +} + +fn authored_reply_reference( + vector: &Vector, + input: &Value, +) -> Result<RadrootsNip10ReplyReference, RadrootsNip10ReplyError> { + let relay = match input.get("relay") { + None | Some(Value::Null) => None, + Some(relay) => Some( + relay + .as_str() + .unwrap_or_else(|| panic!("{} Reply relay must be a string or null", vector.id)), + ), + }; + RadrootsNip10ReplyReference::parse( + value_str(vector, input, "event_id"), + value_str(vector, input, "author"), + relay, + ) +} + fn authored_images( vector: &Vector, ) -> Result<Vec<RadrootsAuthoredPostImage>, RadrootsAuthoredPostError> { @@ -423,6 +681,14 @@ fn classification_label(classification: RadrootsPostClassification) -> &'static } } +fn reply_style_label(style: RadrootsNip10ReplyStyle) -> &'static str { + match style { + RadrootsNip10ReplyStyle::Marked => "marked", + RadrootsNip10ReplyStyle::LegacyPositional => "legacy_positional", + _ => "future", + } +} + fn diagnostic_codes(diagnostics: &[RadrootsPostDiagnostic]) -> Vec<&'static str> { diagnostics .iter() diff --git a/crates/net/src/nostr_client/events/post.rs b/crates/net/src/nostr_client/events/post.rs @@ -1,9 +1,13 @@ use crate::error::{NetError, Result}; -use radroots_event::post::{RadrootsAuthoredUpdate, RadrootsPost}; +use radroots_event::{ + post::{RadrootsAuthoredUpdate, RadrootsPost}, + reply::RadrootsAuthoredNip10Reply, +}; use radroots_event_codec::parsed::RadrootsParsedData; use radroots_nostr::prelude::{ - radroots_nostr_build_post_reply_event, radroots_nostr_build_update_event, - radroots_nostr_fetch_post_events, radroots_nostr_send_event, radroots_nostr_send_post_event, + radroots_nostr_build_nip10_reply_event, radroots_nostr_build_update_event, + radroots_nostr_fetch_post_events, radroots_nostr_send_nip10_reply_event, + radroots_nostr_send_post_event, }; use crate::nostr_client::manager::NostrClientManager; @@ -24,46 +28,26 @@ impl NostrClientManager { rt.block_on(async move { this.publish_update_event(&update).await }) } - pub async fn publish_post_reply_event( + pub async fn publish_nip10_reply_event( &self, - parent_event_id_hex: String, - parent_author_hex: String, - content: String, - root_event_id_hex: Option<String>, + reply: &RadrootsAuthoredNip10Reply, ) -> Result<String> { - let builder = radroots_nostr_build_post_reply_event( - &parent_event_id_hex, - &parent_author_hex, - content, - root_event_id_hex.as_deref(), - ) - .map_err(|e| NetError::Msg(e.to_string()))?; - - let out = radroots_nostr_send_event(&self.inner.client, builder) + let builder = radroots_nostr_build_nip10_reply_event(reply) + .map_err(|e| NetError::Msg(e.to_string()))?; + let out = radroots_nostr_send_nip10_reply_event(&self.inner.client, builder) .await .map_err(|e| NetError::Msg(e.to_string()))?; Ok(out.val.to_string()) } - pub fn publish_post_reply_event_blocking( + pub fn publish_nip10_reply_event_blocking( &self, - parent_event_id_hex: String, - parent_author_hex: String, - content: String, - root_event_id_hex: Option<String>, + reply: RadrootsAuthoredNip10Reply, ) -> Result<String> { let rt = self.inner.rt.clone(); let this = self.clone(); - rt.block_on(async move { - this.publish_post_reply_event( - parent_event_id_hex, - parent_author_hex, - content, - root_event_id_hex, - ) - .await - }) + rt.block_on(async move { this.publish_nip10_reply_event(&reply).await }) } /// Fetches generic kind-1 compatibility projections without claiming diff --git a/crates/nostr/Cargo.toml b/crates/nostr/Cargo.toml @@ -60,3 +60,7 @@ required-features = ["events"] [[test]] name = "food_availability_profile" required-features = ["events"] + +[[test]] +name = "nip10_reply_profile" +required-features = ["codec", "events"] diff --git a/crates/nostr/README b/crates/nostr/README @@ -21,17 +21,27 @@ validation remain in `radroots_blossom`. The `events` feature is std-backed. With it, kind-1 root publication is available only through typed Update, PhotoUpdate, and Ask builders backed by -the strict `radroots_event_codec` wire operations. The former free-form -text-note post builder is removed. Reply construction remains a separate -compatibility surface pending the dedicated strict NIP-10 contract; it is not -used to author root product cards. Generic protocol builders reject kind-0 -Profile events and unmarked root kind-1 events at both direct signing and -client-publication boundaries. Kind-1 builders carrying an `e` tag remain -available for thread compatibility. Typed media builders can sign or publish -only after the owning runtime separately proves successful BUD-02 upload -completion; their byte-verified descriptors do not attest upload completion. -The generic net manager intentionally exposes no direct PhotoUpdate or media -Ask publisher. +the strict `radroots_event_codec` wire operations. NIP-10 Reply publication is +separately available through the typed direct or nested Reply model and its +sealed builder. Authored Replies always emit marked `root` and optional +`reply` event references plus the required referenced-author `p` tags. +Verified inbound projection also accepts deprecated positional NIP-10 +references for interoperability and preserves valid supplemental unmarked `e` +references as citations. Empty marker slots remain absent even when an optional +fifth-element author hint is present. Missing or malformed advisory +participant, middle citation, relay, and referenced-author metadata is retained +as ordered diagnostics instead of erasing an unambiguous Reply. A Reply remains +thread content and can never enter root-card admission. Reply admission proves +the Reply event's NIP-01 id and signature; it does not prove that a referenced +target exists, is kind `1`, or was authored by the declared referenced author. + +The former free-form text-note post builder is removed. Generic protocol +builders reject kind-0 Profile events and every kind-1 event at both direct +signing and client-publication boundaries. Typed media builders can sign or +publish only after the owning runtime separately proves successful BUD-02 +upload completion; their byte-verified descriptors do not attest upload +completion. The generic net manager intentionally exposes no direct +PhotoUpdate or media Ask publisher. Focused FoodAvailability kind-30402 authoring likewise uses a sealed builder. Its `created_at` is fixed during strict construction and cannot be changed diff --git a/crates/nostr/src/client.rs b/crates/nostr/src/client.rs @@ -13,6 +13,8 @@ use crate::error::RadrootsNostrError; use crate::events::food_availability::RadrootsNostrFoodAvailabilityEventBuilder; #[cfg(feature = "events")] use crate::events::post::RadrootsNostrPostEventBuilder; +#[cfg(feature = "events")] +use crate::events::reply::RadrootsNostrNip10ReplyEventBuilder; use crate::types::{ RadrootsNostrEvent, RadrootsNostrEventId, RadrootsNostrEventStream, RadrootsNostrFilter, RadrootsNostrGenericEventBuilder, RadrootsNostrKeys, RadrootsNostrMonitor, RadrootsNostrOutput, @@ -245,11 +247,10 @@ impl RadrootsNostrClient { /// Publishes a generic event builder. /// - /// Kind 0 profiles, unmarked root kind 1 events, and focused or mixed kind - /// 30402 FoodAvailability marker partitions are rejected because their - /// product shape must come from typed authoring. Thread kind 1, marker-free - /// NIP-99, and operational-only kind 30402 builders remain available for - /// compatibility. + /// Kind 0 profiles, all kind 1 events, and focused or mixed kind 30402 + /// FoodAvailability marker partitions are rejected because their product + /// shape must come from typed authoring. Marker-free NIP-99 and + /// operational-only kind 30402 builders remain available for compatibility. pub async fn send_event_builder( &self, event: RadrootsNostrGenericEventBuilder, @@ -270,6 +271,18 @@ impl RadrootsNostrClient { .await?) } + /// Publishes a validated strict marked NIP-10 Reply. + #[cfg(feature = "events")] + pub async fn send_nip10_reply_event_builder( + &self, + event: RadrootsNostrNip10ReplyEventBuilder, + ) -> Result<RadrootsNostrOutput<RadrootsNostrEventId>, RadrootsNostrError> { + Ok(self + .inner + .send_event_builder(event.into_event_builder()) + .await?) + } + /// Publishes a validated focused FoodAvailability event. /// /// Media-bearing callers must prove successful BUD-02 upload first. @@ -332,6 +345,15 @@ pub async fn radroots_nostr_send_post_event( client.send_post_event_builder(event).await } +/// Publishes a validated strict marked NIP-10 Reply. +#[cfg(feature = "events")] +pub async fn radroots_nostr_send_nip10_reply_event( + client: &RadrootsNostrClient, + event: RadrootsNostrNip10ReplyEventBuilder, +) -> Result<RadrootsNostrOutput<RadrootsNostrEventId>, RadrootsNostrError> { + client.send_nip10_reply_event_builder(event).await +} + /// Publishes a validated focused FoodAvailability event. /// /// Media-bearing callers must prove successful BUD-02 upload first. @@ -447,26 +469,24 @@ mod tests { } #[tokio::test] - async fn generic_builder_allows_e_tagged_thread_compatibility() { + async fn generic_builder_rejects_e_tagged_thread_before_signer_access() { let keys = RadrootsNostrKeys::new( RadrootsNostrSecretKey::from_slice(&[3_u8; 32]).expect("test secret key"), ); - let parent_author = keys.public_key().to_hex(); let client = RadrootsNostrClient::new(keys); - let builder = crate::events::post::radroots_nostr_build_post_reply_event( - "0000000000000000000000000000000000000000000000000000000000000000", - &parent_author, - "Reply", - None, - ) - .expect("reply builder"); + let builder = RadrootsNostrGenericEventBuilder::text_note("Reply").tag( + crate::types::RadrootsNostrTag::event(crate::types::RadrootsNostrEventId::all_zeros()), + ); let error = client .send_event_builder(builder) .await - .expect_err("no relay is configured"); + .expect_err("generic reply must fail before relay access"); - assert!(matches!(error, RadrootsNostrError::ClientError(_))); + assert!(matches!( + error, + RadrootsNostrError::TypedAuthoringRequired { .. } + )); } #[cfg(feature = "events")] @@ -488,4 +508,30 @@ mod tests { assert!(matches!(error, RadrootsNostrError::ClientError(_))); } + + #[cfg(feature = "events")] + #[tokio::test] + async fn sealed_nip10_reply_builder_reaches_typed_client_publication() { + let keys = RadrootsNostrKeys::new( + RadrootsNostrSecretKey::from_slice(&[5_u8; 32]).expect("test secret key"), + ); + let client = RadrootsNostrClient::new(keys); + let reference = radroots_event::reply::RadrootsNip10ReplyReference::parse( + "a".repeat(64), + "b".repeat(64), + None, + ) + .expect("reference"); + let reply = radroots_event::reply::RadrootsAuthoredNip10Reply::direct("Reply", reference) + .expect("reply"); + let builder = + crate::events::reply::radroots_nostr_build_nip10_reply_event(&reply).expect("builder"); + + let error = client + .send_nip10_reply_event_builder(builder) + .await + .expect_err("no relay is configured"); + + assert!(matches!(error, RadrootsNostrError::ClientError(_))); + } } diff --git a/crates/nostr/src/events/mod.rs b/crates/nostr/src/events/mod.rs @@ -5,6 +5,8 @@ pub mod food_availability; pub mod jobs; pub mod metadata; pub mod post; +#[cfg(feature = "events")] +pub mod reply; extern crate alloc; #[cfg(any(feature = "events", test))] diff --git a/crates/nostr/src/events/post.rs b/crates/nostr/src/events/post.rs @@ -1,14 +1,10 @@ -use alloc::{string::String, vec::Vec}; - +#[cfg(feature = "events")] use crate::error::RadrootsNostrError; #[cfg(feature = "events")] use crate::types::RadrootsNostrEventBuilderUnchecked; #[cfg(feature = "events")] use crate::types::{RadrootsNostrEvent, RadrootsNostrKeys}; -use crate::types::{ - RadrootsNostrEventId, RadrootsNostrFilter, RadrootsNostrGenericEventBuilder, RadrootsNostrKind, - RadrootsNostrPublicKey, RadrootsNostrTag, RadrootsNostrTimestamp, -}; +use crate::types::{RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrTimestamp}; #[cfg(feature = "events")] use radroots_event::post::{ @@ -93,29 +89,6 @@ pub fn radroots_nostr_post_events_filter( filter } -pub fn radroots_nostr_build_post_reply_event( - parent_event_id_hex: &str, - parent_author_hex: &str, - content: impl Into<String>, - root_event_id_hex: Option<&str>, -) -> Result<RadrootsNostrGenericEventBuilder, RadrootsNostrError> { - let parent_id = RadrootsNostrEventId::from_hex(parent_event_id_hex)?; - let parent_pubkey = RadrootsNostrPublicKey::from_hex(parent_author_hex)?; - let mut tags: Vec<RadrootsNostrTag> = Vec::new(); - - if let Some(root_hex) = root_event_id_hex - && !root_hex.is_empty() - && let Ok(root_id) = RadrootsNostrEventId::from_hex(root_hex) - { - tags.push(RadrootsNostrTag::event(root_id)); - } - - tags.push(RadrootsNostrTag::event(parent_id)); - tags.push(RadrootsNostrTag::public_key(parent_pubkey)); - - Ok(RadrootsNostrGenericEventBuilder::text_note(content).tags(tags)) -} - #[cfg(feature = "events")] fn builder_from_wire_parts( parts: RadrootsNip01EventWireParts, diff --git a/crates/nostr/src/events/reply.rs b/crates/nostr/src/events/reply.rs @@ -0,0 +1,59 @@ +use crate::{ + error::RadrootsNostrError, + types::{ + RadrootsNostrEvent, RadrootsNostrEventBuilderUnchecked, RadrootsNostrKeys, + RadrootsNostrTimestamp, + }, +}; +use radroots_event::{reply::RadrootsAuthoredNip10Reply, wire::RadrootsNip01EventWireParts}; +use radroots_event_codec::reply::authored::authored_nip10_reply_to_wire_parts; + +/// A sealed builder for a validated strict marked NIP-10 Reply. +/// +/// The wrapper exposes no raw builder conversion or tag/content mutation. +/// +/// ```compile_fail +/// use radroots_nostr::prelude::RadrootsNostrNip10ReplyEventBuilder; +/// +/// fn expose_raw_builder(builder: RadrootsNostrNip10ReplyEventBuilder) { +/// let _: nostr::EventBuilder = builder.into(); +/// } +/// ``` +#[must_use = "NIP-10 Reply builders must be signed or published"] +pub struct RadrootsNostrNip10ReplyEventBuilder { + inner: RadrootsNostrEventBuilderUnchecked, +} + +impl RadrootsNostrNip10ReplyEventBuilder { + pub fn custom_created_at(mut self, created_at: RadrootsNostrTimestamp) -> Self { + self.inner = self.inner.custom_created_at(created_at); + self + } + + pub fn sign_with_keys( + self, + keys: &RadrootsNostrKeys, + ) -> Result<RadrootsNostrEvent, RadrootsNostrError> { + Ok(self.inner.sign_with_keys(keys)?) + } + + #[cfg(feature = "client")] + pub(crate) fn into_event_builder(self) -> RadrootsNostrEventBuilderUnchecked { + self.inner + } +} + +pub fn radroots_nostr_build_nip10_reply_event( + reply: &RadrootsAuthoredNip10Reply, +) -> Result<RadrootsNostrNip10ReplyEventBuilder, RadrootsNostrError> { + let parts = authored_nip10_reply_to_wire_parts(reply); + builder_from_wire_parts(parts) +} + +fn builder_from_wire_parts( + parts: RadrootsNip01EventWireParts, +) -> Result<RadrootsNostrNip10ReplyEventBuilder, RadrootsNostrError> { + let inner = + crate::events::radroots_nostr_build_event_unchecked(parts.kind, parts.content, parts.tags)?; + Ok(RadrootsNostrNip10ReplyEventBuilder { inner }) +} diff --git a/crates/nostr/src/lib.rs b/crates/nostr/src/lib.rs @@ -62,7 +62,8 @@ pub mod prelude { #[cfg(all(feature = "client", feature = "events"))] pub use crate::client::{ - radroots_nostr_send_food_availability_event, radroots_nostr_send_post_event, + radroots_nostr_send_food_availability_event, radroots_nostr_send_nip10_reply_event, + radroots_nostr_send_post_event, }; pub use crate::error::{RadrootsNostrError, RadrootsNostrTagsResolveError}; @@ -73,7 +74,7 @@ pub mod prelude { pub use crate::events::{ jobs::{radroots_nostr_build_event_job_feedback, radroots_nostr_build_event_job_result}, - post::{radroots_nostr_build_post_reply_event, radroots_nostr_post_events_filter}, + post::radroots_nostr_post_events_filter, }; #[cfg(feature = "events")] @@ -88,6 +89,11 @@ pub mod prelude { }; #[cfg(feature = "events")] + pub use crate::events::reply::{ + RadrootsNostrNip10ReplyEventBuilder, radroots_nostr_build_nip10_reply_event, + }; + + #[cfg(feature = "events")] pub use crate::events::application_handler::{ RadrootsNostrApplicationHandlerSpec, radroots_nostr_build_application_handler_event, radroots_nostr_metadata_has_fields, diff --git a/crates/nostr/src/types.rs b/crates/nostr/src/types.rs @@ -25,12 +25,11 @@ pub type RadrootsNostrUrl = nostr::Url; /// An opaque builder for generic Nostr events. /// -/// Kind 0 profile events, unmarked root kind 1 events, and focused or mixed -/// kind 30402 FoodAvailability marker partitions are reserved for typed -/// Radroots authoring. Kind 1 events carrying an `e` tag, marker-free NIP-99, -/// and operational-only kind 30402 events remain available for compatibility. -/// The policy is enforced before direct signing and before a client is allowed -/// to consult its signer. +/// Kind 0 profile events, all kind 1 events, and focused or mixed kind 30402 +/// FoodAvailability marker partitions are reserved for typed Radroots +/// authoring. Marker-free NIP-99 and operational-only kind 30402 events remain +/// available for compatibility. The policy is enforced before direct signing +/// and before a client is allowed to consult its signer. /// /// The upstream unsigned builder is intentionally inaccessible: /// @@ -153,11 +152,7 @@ impl RadrootsNostrGenericEventBuilder { let event = inspection.build(inspection_pubkey); let kind = event.kind.as_u16(); let is_profile = kind == RadrootsNostrKind::Metadata.as_u16(); - let is_unmarked_root_post = kind == RadrootsNostrKind::TextNote.as_u16() - && !event - .tags - .iter() - .any(|tag| tag.kind() == RadrootsNostrTagKind::e()); + let is_reserved_post = kind == RadrootsNostrKind::TextNote.as_u16(); let classified_listing_partition = (kind == radroots_event::kinds::KIND_CLASSIFIED_LISTING as u16).then(|| { classify_classified_listing_marker_names( @@ -174,7 +169,7 @@ impl RadrootsNostrGenericEventBuilder { | RadrootsClassifiedListingPartition::Ambiguous ) ); - if is_profile || is_unmarked_root_post || is_reserved_focused_listing { + if is_profile || is_reserved_post || is_reserved_focused_listing { return Err(RadrootsNostrError::TypedAuthoringRequired { kind }); } Ok(()) @@ -241,13 +236,17 @@ mod tests { } #[test] - fn generic_direct_signing_allows_thread_kind_one() { - let event = RadrootsNostrGenericEventBuilder::text_note("reply") + fn generic_direct_signing_rejects_thread_kind_one() { + let error = RadrootsNostrGenericEventBuilder::text_note("reply") .tag(RadrootsNostrTag::event(RadrootsNostrEventId::all_zeros())) .sign_with_keys(&keys()) - .expect("e-tagged kind 1 remains generic-authorable"); + .expect_err("all kind-1 authoring is typed"); - assert_eq!(event.kind, RadrootsNostrKind::TextNote); + assert!(matches!( + error, + RadrootsNostrError::TypedAuthoringRequired { kind } + if kind == RadrootsNostrKind::TextNote.as_u16() + )); } #[test] diff --git a/crates/nostr/tests/coverage.rs b/crates/nostr/tests/coverage.rs @@ -4,13 +4,15 @@ mod test_fixtures; use std::borrow::Cow; use nostr::nips::nip04; +#[cfg(feature = "events")] +use radroots_event::reply::{RadrootsAuthoredNip10Reply, RadrootsNip10ReplyReference}; use radroots_nostr::error::RadrootsNostrTagsResolveError; use radroots_nostr::events::jobs::{ radroots_nostr_build_event_job_feedback, radroots_nostr_build_event_job_result, }; -use radroots_nostr::events::post::{ - radroots_nostr_build_post_reply_event, radroots_nostr_post_events_filter, -}; +use radroots_nostr::events::post::radroots_nostr_post_events_filter; +#[cfg(feature = "events")] +use radroots_nostr::events::reply::radroots_nostr_build_nip10_reply_event; use radroots_nostr::filter::{ radroots_nostr_filter_kind, radroots_nostr_filter_new_events, radroots_nostr_filter_tag, radroots_nostr_kind, @@ -112,55 +114,58 @@ fn job_event_builders_are_callable() { #[test] fn post_helpers_cover_success_and_error_paths() { - let keys = make_keys(); - let parent = text_event_with_tags(&keys, Vec::new()); - let parent_id_hex = parent.id.to_hex(); - let author_hex = parent.pubkey.to_hex(); - let root_id_hex = parent.id.to_hex(); - let _ = radroots_nostr_post_events_filter(None, None); let _ = radroots_nostr_post_events_filter(Some(10), Some(1_700_000_000)); - let reply_ok = radroots_nostr_build_post_reply_event( - &parent_id_hex, - &author_hex, - "reply", - Some(root_id_hex.as_str()), - ) - .expect("reply event builder"); - let _ = reply_ok - .sign_with_keys(&keys) - .expect("reply signs through the generic boundary"); - - let reply_invalid_root = radroots_nostr_build_post_reply_event( - &parent_id_hex, - &author_hex, - "reply", - Some("not-hex-root"), - ) - .expect("reply builder with invalid optional root"); - let _ = reply_invalid_root - .sign_with_keys(&keys) - .expect("reply with invalid optional root signs"); - let reply_empty_root = - radroots_nostr_build_post_reply_event(&parent_id_hex, &author_hex, "reply", Some("")) - .expect("reply builder with empty optional root"); - let _ = reply_empty_root - .sign_with_keys(&keys) - .expect("reply with empty optional root signs"); - let reply_none_root = - radroots_nostr_build_post_reply_event(&parent_id_hex, &author_hex, "reply", None) - .expect("reply builder without optional root"); - let _ = reply_none_root - .sign_with_keys(&keys) - .expect("reply without optional root signs"); - - let invalid_parent = radroots_nostr_build_post_reply_event("bad", &author_hex, "reply", None); - assert!(invalid_parent.is_err()); - - let invalid_author = - radroots_nostr_build_post_reply_event(&parent_id_hex, "bad", "reply", None); - assert!(invalid_author.is_err()); + #[cfg(feature = "events")] + { + let keys = make_keys(); + let root = nostr::EventBuilder::text_note("root") + .sign_with_keys(&keys) + .expect("root"); + let parent = nostr::EventBuilder::text_note("parent") + .sign_with_keys(&keys) + .expect("parent"); + let author_hex = root.pubkey.to_hex(); + + let root_reference = RadrootsNip10ReplyReference::parse( + root.id.to_hex(), + &author_hex, + Some(RELAY_PRIMARY_WSS), + ) + .expect("root reference"); + let direct = RadrootsAuthoredNip10Reply::direct("direct reply", root_reference.clone()) + .expect("direct reply"); + let direct_builder = + radroots_nostr_build_nip10_reply_event(&direct).expect("direct reply builder"); + let _ = direct_builder + .sign_with_keys(&keys) + .expect("direct reply signs through the typed boundary"); + + let parent_reference = + RadrootsNip10ReplyReference::parse(parent.id.to_hex(), &author_hex, None) + .expect("parent reference"); + let nested = + RadrootsAuthoredNip10Reply::nested("nested reply", root_reference, parent_reference) + .expect("nested reply"); + let nested_builder = + radroots_nostr_build_nip10_reply_event(&nested).expect("nested reply builder"); + let _ = nested_builder + .sign_with_keys(&keys) + .expect("nested reply signs through the typed boundary"); + + assert!(RadrootsNip10ReplyReference::parse("bad", &author_hex, None).is_err()); + assert!(RadrootsNip10ReplyReference::parse(root.id.to_hex(), "bad", None).is_err()); + assert!( + RadrootsNip10ReplyReference::parse( + root.id.to_hex(), + &author_hex, + Some("https://relay.example"), + ) + .is_err() + ); + assert!(RadrootsAuthoredNip10Reply::direct(" ", nested.root().clone()).is_err()); + } } #[test] diff --git a/crates/nostr/tests/nip10_reply_profile.rs b/crates/nostr/tests/nip10_reply_profile.rs @@ -0,0 +1,225 @@ +use radroots_event::reply::{RadrootsAuthoredNip10Reply, RadrootsNip10ReplyReference}; +use radroots_event_codec::{ + post::admission::{RadrootsPostAdmissionOutcome, verify_and_admit_post_event}, + reply::{ + admission::{admit_thread_excluded_post_candidate, verify_and_admit_nip10_reply_event}, + inbound::RadrootsNip10ReplyStyle, + }, +}; +use radroots_nostr::prelude::{ + RadrootsNostrError, RadrootsNostrGenericEventBuilder, RadrootsNostrKeys, RadrootsNostrKind, + RadrootsNostrSecretKey, RadrootsNostrTag, RadrootsNostrTimestamp, radroots_event_from_nostr, + radroots_nostr_build_nip10_reply_event, +}; +use radroots_test_fixtures::{ + FIXTURE_ALICE_SECRET_KEY_HEX, FIXTURE_BOB_PUBLIC_KEY_HEX, FIXTURE_CAROL_PUBLIC_KEY_HEX, + RELAY_PRIMARY_WSS, RELAY_SECONDARY_WSS, +}; + +#[cfg(feature = "client")] +use radroots_nostr::prelude::RadrootsNostrClient; + +const CREATED_AT: u64 = 1_784_347_200; +const ROOT_EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const PARENT_EVENT_ID: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + +#[test] +fn typed_nip10_reply_builders_sign_exact_marked_direct_and_nested_profiles() { + let keys = fixture_keys(); + let created_at = RadrootsNostrTimestamp::from_secs(CREATED_AT); + let root = reference( + ROOT_EVENT_ID, + FIXTURE_BOB_PUBLIC_KEY_HEX, + Some(RELAY_PRIMARY_WSS), + ); + + let direct = RadrootsAuthoredNip10Reply::direct("Direct reply", root.clone()) + .expect("authored direct reply"); + let direct_event = radroots_nostr_build_nip10_reply_event(&direct) + .expect("typed direct Reply builder") + .custom_created_at(created_at) + .sign_with_keys(&keys) + .expect("signed direct Reply"); + + assert_eq!(direct_event.kind, RadrootsNostrKind::TextNote); + assert_eq!(direct_event.created_at, created_at); + assert_eq!( + event_tags(&direct_event), + vec![ + tag(&["e", ROOT_EVENT_ID, RELAY_PRIMARY_WSS, "root"]), + tag(&["p", FIXTURE_BOB_PUBLIC_KEY_HEX]), + ] + ); + direct_event.verify().expect("valid direct Reply signature"); + + let parent = reference( + PARENT_EVENT_ID, + FIXTURE_CAROL_PUBLIC_KEY_HEX, + Some(RELAY_SECONDARY_WSS), + ); + let nested = RadrootsAuthoredNip10Reply::nested("Nested reply", root, parent) + .expect("authored nested reply"); + let nested_event = radroots_nostr_build_nip10_reply_event(&nested) + .expect("typed nested Reply builder") + .custom_created_at(created_at) + .sign_with_keys(&keys) + .expect("signed nested Reply"); + + assert_eq!( + event_tags(&nested_event), + vec![ + tag(&["e", ROOT_EVENT_ID, RELAY_PRIMARY_WSS, "root"]), + tag(&["e", PARENT_EVENT_ID, RELAY_SECONDARY_WSS, "reply"]), + tag(&["p", FIXTURE_BOB_PUBLIC_KEY_HEX]), + tag(&["p", FIXTURE_CAROL_PUBLIC_KEY_HEX]), + ] + ); + nested_event.verify().expect("valid nested Reply signature"); + let admitted = verify_and_admit_nip10_reply_event( + radroots_event_from_nostr(&nested_event).expect("nested Reply adapter"), + ) + .expect("nested Reply admission"); + assert!(!admitted.projection().is_direct()); + assert_eq!( + admitted + .projection() + .reply_reference() + .expect("nested parent") + .event_id() + .as_str(), + PARENT_EVENT_ID + ); +} + +#[test] +fn signed_nip10_reply_is_thread_excluded_before_semantic_reply_admission() { + let reply = RadrootsAuthoredNip10Reply::direct( + "Thread reply", + reference( + ROOT_EVENT_ID, + FIXTURE_BOB_PUBLIC_KEY_HEX, + Some(RELAY_PRIMARY_WSS), + ), + ) + .expect("authored direct reply"); + let event = radroots_nostr_build_nip10_reply_event(&reply) + .expect("typed Reply builder") + .custom_created_at(RadrootsNostrTimestamp::from_secs(CREATED_AT)) + .sign_with_keys(&fixture_keys()) + .expect("signed Reply"); + let envelope = radroots_event_from_nostr(&event).expect("Radroots event adapter"); + + let candidate = match verify_and_admit_post_event(envelope).expect("post admission") { + RadrootsPostAdmissionOutcome::ThreadExcluded(candidate) => candidate, + RadrootsPostAdmissionOutcome::Root(_) => panic!("Reply must never become a root card"), + _ => panic!("unexpected post admission outcome"), + }; + let admitted = + admit_thread_excluded_post_candidate(candidate).expect("semantic Reply admission"); + + assert_eq!(admitted.contract().id, "radroots.social.reply.v1"); + assert_eq!( + admitted.projection().style(), + RadrootsNip10ReplyStyle::Marked + ); + assert!(admitted.projection().is_direct()); + assert_eq!( + admitted.projection().root().event_id().as_str(), + ROOT_EVENT_ID + ); +} + +#[test] +fn verified_legacy_positional_nip10_reply_remains_tolerated_inbound() { + let keys = fixture_keys(); + let event = nostr::EventBuilder::text_note("Legacy positional reply") + .tags([ + RadrootsNostrTag::parse(["e", ROOT_EVENT_ID, RELAY_PRIMARY_WSS]) + .expect("positional root reference"), + RadrootsNostrTag::parse(["p", FIXTURE_BOB_PUBLIC_KEY_HEX]) + .expect("root author reference"), + ]) + .custom_created_at(RadrootsNostrTimestamp::from_secs(CREATED_AT)) + .sign_with_keys(&keys) + .expect("signed inbound fixture"); + let envelope = radroots_event_from_nostr(&event).expect("Radroots event adapter"); + let admitted = + verify_and_admit_nip10_reply_event(envelope).expect("legacy positional Reply admission"); + + assert_eq!( + admitted.projection().style(), + RadrootsNip10ReplyStyle::LegacyPositional + ); + assert!(admitted.projection().is_direct()); + assert_eq!( + admitted.projection().root().event_id().as_str(), + ROOT_EVENT_ID + ); +} + +#[test] +fn generic_kind_one_builder_cannot_bypass_typed_nip10_reply_authoring() { + let keys = fixture_keys(); + let marked_reply = RadrootsNostrGenericEventBuilder::text_note("Raw marked reply").tags([ + RadrootsNostrTag::parse(["e", ROOT_EVENT_ID, RELAY_PRIMARY_WSS, "root"]) + .expect("root reference"), + RadrootsNostrTag::parse(["p", FIXTURE_BOB_PUBLIC_KEY_HEX]).expect("root author reference"), + ]); + + for builder in [ + RadrootsNostrGenericEventBuilder::text_note("Raw root"), + marked_reply, + ] { + assert!(matches!( + builder.sign_with_keys(&keys), + Err(RadrootsNostrError::TypedAuthoringRequired { kind }) + if kind == RadrootsNostrKind::TextNote.as_u16() + )); + } +} + +#[cfg(feature = "client")] +#[tokio::test] +async fn typed_nip10_reply_builder_reaches_client_publication() { + let client = RadrootsNostrClient::new(fixture_keys()); + let reply = RadrootsAuthoredNip10Reply::direct( + "Publish Reply", + reference( + ROOT_EVENT_ID, + FIXTURE_BOB_PUBLIC_KEY_HEX, + Some(RELAY_PRIMARY_WSS), + ), + ) + .expect("authored direct reply"); + let builder = + radroots_nostr_build_nip10_reply_event(&reply).expect("typed NIP-10 Reply builder"); + + let error = client + .send_nip10_reply_event_builder(builder) + .await + .expect_err("no relay is configured"); + + assert!(matches!(error, RadrootsNostrError::ClientError(_))); +} + +fn fixture_keys() -> RadrootsNostrKeys { + RadrootsNostrKeys::new( + RadrootsNostrSecretKey::from_hex(FIXTURE_ALICE_SECRET_KEY_HEX).expect("fixture secret key"), + ) +} + +fn reference(event_id: &str, author: &str, relay: Option<&str>) -> RadrootsNip10ReplyReference { + RadrootsNip10ReplyReference::parse(event_id, author, relay).expect("valid Reply reference") +} + +fn event_tags(event: &nostr::Event) -> Vec<Vec<String>> { + event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect() +} + +fn tag(values: &[&str]) -> Vec<String> { + values.iter().map(|value| (*value).to_owned()).collect() +} diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs @@ -171,7 +171,7 @@ const REQUIRED_CALENDAR_PUBLIC_TYPES: [&str; 34] = [ "RadrootsParsedNip52CalendarEventRsvp", "RadrootsAdmittedCalendarEventRsvp", ]; -const REQUIRED_POST_PUBLIC_TYPES: [&str; 22] = [ +const REQUIRED_POST_PUBLIC_TYPES: [&str; 33] = [ "RadrootsBlossomApprovedBlobUrl", "RadrootsBlossomByteVerifiedDescriptor", "RadrootsNip01EventWireParts", @@ -185,6 +185,9 @@ const REQUIRED_POST_PUBLIC_TYPES: [&str; 22] = [ "RadrootsAuthoredUpdate", "RadrootsAuthoredPhotoUpdate", "RadrootsAuthoredAsk", + "RadrootsNip10ReplyError", + "RadrootsNip10ReplyReference", + "RadrootsAuthoredNip10Reply", "RadrootsPostDiagnostic", "RadrootsPostClassification", "RadrootsInboundPostImeta", @@ -194,6 +197,14 @@ const REQUIRED_POST_PUBLIC_TYPES: [&str; 22] = [ "RadrootsThreadExcludedPostCandidate", "RadrootsPostAdmissionOutcome", "RadrootsPostAdmissionError", + "RadrootsNip10ReplyStyle", + "RadrootsNip10ReplyDiagnostic", + "RadrootsInboundNip10EventReference", + "RadrootsInboundNip10Participant", + "RadrootsInboundNip10ReplyProjection", + "RadrootsNip10ReplyProjectionError", + "RadrootsAdmittedNip10ReplyEvent", + "RadrootsNip10ReplyAdmissionError", ]; const REQUIRED_FOOD_AVAILABILITY_PUBLIC_TYPES: [&str; 31] = [ "RadrootsBlossomByteVerifiedDescriptor", @@ -481,7 +492,7 @@ const CALENDAR_OPERATION_EXPECTATIONS: [CalendarOperationExpectation; 12] = [ vector: "contracts/conformance/vectors/calendar/radroots_profile.v1.json", }, ]; -const POST_OPERATION_EXPECTATIONS: [PostOperationExpectation; 5] = [ +const POST_OPERATION_EXPECTATIONS: [PostOperationExpectation; 8] = [ PostOperationExpectation { key: "social_update_build_authored_draft", id: "social.update.build_authored_draft", @@ -549,6 +560,73 @@ const POST_OPERATION_EXPECTATIONS: [PostOperationExpectation; 5] = [ ], }, PostOperationExpectation { + key: "social_reply_build_authored_draft", + id: "social.reply.build_authored_draft", + inputs: &["RadrootsAuthoredNip10Reply"], + outputs: &["RadrootsNip01EventWireParts"], + error_class: "encode_error", + signing: "none", + rust_modules: &[ + "crates/event/src/reply.rs", + "crates/event_codec/src/reply/authored.rs", + ], + rust_types: &[ + "radroots_event::reply::RadrootsAuthoredNip10Reply", + "radroots_event::reply::RadrootsNip10ReplyError", + "radroots_event::reply::RadrootsNip10ReplyReference", + ], + case_kinds: &[ + "social.reply.build_authored_draft.valid", + "social.reply.build_authored_draft.invalid", + ], + }, + PostOperationExpectation { + key: "social_reply_project_verified_event", + id: "social.reply.project_verified_event", + inputs: &["RadrootsSignatureVerifiedEvent"], + outputs: &["RadrootsInboundNip10ReplyProjection"], + error_class: "parse_error", + signing: "none", + rust_modules: &["crates/event_codec/src/reply/inbound.rs"], + rust_types: &[ + "radroots_event_codec::reply::inbound::RadrootsInboundNip10EventReference", + "radroots_event_codec::reply::inbound::RadrootsInboundNip10Participant", + "radroots_event_codec::reply::inbound::RadrootsInboundNip10ReplyProjection", + "radroots_event_codec::reply::inbound::RadrootsNip10ReplyDiagnostic", + "radroots_event_codec::reply::inbound::RadrootsNip10ReplyProjectionError", + "radroots_event_codec::reply::inbound::RadrootsNip10ReplyStyle", + "radroots_event_codec::verification::RadrootsSignatureVerifiedEvent", + ], + case_kinds: &[ + "social.reply.project_verified_event.valid", + "social.reply.project_verified_event.invalid", + ], + }, + PostOperationExpectation { + key: "social_reply_verify_and_admit_event", + id: "social.reply.verify_and_admit_event", + inputs: &["RadrootsEventEnvelope"], + outputs: &["RadrootsAdmittedNip10ReplyEvent"], + error_class: "admission_error", + signing: "nip01", + rust_modules: &[ + "crates/event_codec/src/reply/admission.rs", + "crates/event_codec/src/reply/inbound.rs", + "crates/event_codec/src/verification.rs", + ], + rust_types: &[ + "radroots_event::RadrootsEventEnvelope", + "radroots_event_codec::reply::admission::RadrootsAdmittedNip10ReplyEvent", + "radroots_event_codec::reply::admission::RadrootsNip10ReplyAdmissionError", + "radroots_event_codec::reply::inbound::RadrootsInboundNip10ReplyProjection", + "radroots_event_codec::verification::RadrootsSignatureVerifiedEvent", + ], + case_kinds: &[ + "social.reply.verify_and_admit_event.valid", + "social.reply.verify_and_admit_event.invalid", + ], + }, + PostOperationExpectation { key: "social_post_project_verified_event", id: "social.post.project_verified_event", inputs: &["RadrootsSignatureVerifiedEvent"], @@ -596,19 +674,21 @@ const POST_OPERATION_EXPECTATIONS: [PostOperationExpectation; 5] = [ ], }, ]; -const POST_OPERATION_KEY_PREFIXES: [&str; 4] = [ +const POST_OPERATION_KEY_PREFIXES: [&str; 5] = [ "social_update_", "social_photo_update_", "social_ask_", + "social_reply_", "social_post_", ]; -const POST_OPERATION_ID_PREFIXES: [&str; 4] = [ +const POST_OPERATION_ID_PREFIXES: [&str; 5] = [ "social.update.", "social.photo_update.", "social.ask.", + "social.reply.", "social.post.", ]; -const POST_VECTOR_EXPECTATIONS: [(&str, &str); 27] = [ +const POST_VECTOR_EXPECTATIONS: [(&str, &str); 54] = [ ( "authored_update_wire", "social.update.build_authored_draft.valid", @@ -631,6 +711,114 @@ const POST_VECTOR_EXPECTATIONS: [(&str, &str); 27] = [ "social.ask.build_authored_draft.invalid", ), ( + "authored_nip10_direct_wire", + "social.reply.build_authored_draft.valid", + ), + ( + "authored_nip10_nested_wire", + "social.reply.build_authored_draft.valid", + ), + ( + "authored_nip10_ambiguous_parent", + "social.reply.build_authored_draft.invalid", + ), + ( + "authored_nip10_invalid_event_id", + "social.reply.build_authored_draft.invalid", + ), + ( + "project_signed_nip10_marked_direct", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_marked_with_citation", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_marked_with_malformed_citation", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_marked_nested_reordered", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_positional_direct", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_positional_direct_with_author_hint", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_positional_many", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_positional_many_with_author_hints", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_positional_malformed_middle_citation", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_precedes_ask_and_media", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_invalid_relay", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_ambiguous_same_reference", + "social.reply.project_verified_event.invalid", + ), + ( + "project_signed_nip10_missing_author", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_invalid_event_id", + "social.reply.project_verified_event.invalid", + ), + ( + "project_signed_nip10_author_hint_mismatch", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_blank_content", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_invalid_author_hint_tolerated", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_invalid_participant_tolerated", + "social.reply.project_verified_event.valid", + ), + ( + "project_signed_nip10_lone_reply_marker", + "social.reply.project_verified_event.invalid", + ), + ( + "project_signed_nip10_unknown_marker", + "social.reply.project_verified_event.invalid", + ), + ( + "admit_signed_nip10_marked_direct", + "social.reply.verify_and_admit_event.valid", + ), + ( + "admit_signed_nip10_positional_direct", + "social.reply.verify_and_admit_event.valid", + ), + ( + "admit_signed_nip10_invalid_signature", + "social.reply.verify_and_admit_event.invalid", + ), + ( "project_signed_update", "social.post.project_verified_event.valid", ), @@ -1480,6 +1668,37 @@ const POST_WITNESSES: [EventBoundarySourceWitness; 5] = [ }, ]; +const REPLY_WITNESSES: [EventBoundarySourceWitness; 4] = [ + EventBoundarySourceWitness { + relative_path: "crates/event/src/reply.rs", + required_fragments: &[ + "pub struct RadrootsNip10ReplyReference", + "pub struct RadrootsAuthoredNip10Reply", + ], + }, + EventBoundarySourceWitness { + relative_path: "crates/event_codec/src/reply/inbound.rs", + required_fragments: &[ + "pub struct RadrootsInboundNip10ReplyProjection", + "pub fn project_verified_nip10_reply_event", + ], + }, + EventBoundarySourceWitness { + relative_path: "crates/event_codec/src/reply/admission.rs", + required_fragments: &[ + "pub struct RadrootsAdmittedNip10ReplyEvent", + "pub fn verify_and_admit_nip10_reply_event", + ], + }, + EventBoundarySourceWitness { + relative_path: "crates/nostr/src/events/reply.rs", + required_fragments: &[ + "pub struct RadrootsNostrNip10ReplyEventBuilder", + "pub fn radroots_nostr_build_nip10_reply_event", + ], + }, +]; + const COMMENT_WITNESSES: [EventBoundarySourceWitness; 2] = [ EventBoundarySourceWitness { relative_path: "crates/event/src/comment.rs", @@ -2107,7 +2326,7 @@ const RELAY_DOC_WITNESSES: [EventBoundarySourceWitness; 2] = [ }, ]; -const CANONICAL_EVENT_BOUNDARY_EXPECTATIONS: [EventBoundaryExpectation; 42] = [ +const CANONICAL_EVENT_BOUNDARY_EXPECTATIONS: [EventBoundaryExpectation; 43] = [ EventBoundaryExpectation { domain: "profile", kind: "0", @@ -2138,6 +2357,17 @@ const CANONICAL_EVENT_BOUNDARY_EXPECTATIONS: [EventBoundaryExpectation; 42] = [ witnesses: &POST_WITNESSES, }, EventBoundaryExpectation { + domain: "reply", + kind: "1", + radroots_type: "RadrootsAuthoredNip10Reply / RadrootsInboundNip10ReplyProjection / RadrootsAdmittedNip10ReplyEvent / RadrootsNostrNip10ReplyEventBuilder", + rpc_methods: &[ + "social.reply.build_authored_draft", + "social.reply.project_verified_event", + "social.reply.verify_and_admit_event", + ], + witnesses: &REPLY_WITNESSES, + }, + EventBoundaryExpectation { domain: "comment", kind: "1111", radroots_type: "RadrootsComment",