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:
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",