commit c0a0936d98f5bc4983831bd6fb020b78fdd49852
parent cb9e46dd62eb275ad53c4c43acf974dce317a0c9
Author: triesap <tyson@radroots.org>
Date: Sun, 19 Jul 2026 12:06:27 +0000
event: canonicalize NIP-10 relay hints
- add an opaque portable relay-hint type for authored and inbound Reply metadata
- reject normalization-dependent authorities while preserving raw diagnostics
- extend signed Reply conformance and contract inventory to 64 cases
- document breaking API and wire-budget boundaries across release surfaces
Diffstat:
14 files changed, 1272 insertions(+), 30 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
@@ -53,6 +53,11 @@ publish policy both pass for the same source revision.
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.
+- Authored and projected NIP-10 relay hints now use one portable, canonical
+ visible-ASCII WebSocket URL profile instead of generic URL normalization.
+ Strict authoring rejects noncanonical hints; tolerant verified projection
+ preserves rejected hints verbatim in ordered raw-tag diagnostics. Relay
+ syntax remains separate from Reply wire-size budgets.
- 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 every kind `1` before signer access;
diff --git a/contracts/conformance/vectors/post/verified_profiles.v1.json b/contracts/conformance/vectors/post/verified_profiles.v1.json
@@ -219,6 +219,131 @@
}
},
{
+ "id": "authored_nip10_canonical_ipv6_relay",
+ "kind": "social.reply.build_authored_draft.valid",
+ "input": {
+ "content": "Canonical relay",
+ "root": {
+ "event_id": "1111111111111111111111111111111111111111111111111111111111111111",
+ "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relay": "wss://[2001:db8::1]:8443/nostr?region=ca-bc&next=%2Ffeed"
+ }
+ },
+ "expected": {
+ "kind": 1,
+ "content": "Canonical relay",
+ "tags": [
+ ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://[2001:db8::1]:8443/nostr?region=ca-bc&next=%2Ffeed", "root"],
+ ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"]
+ ]
+ }
+ },
+ {
+ "id": "authored_nip10_invalid_relay_percent_host",
+ "kind": "social.reply.build_authored_draft.invalid",
+ "input": {
+ "content": "Invalid relay",
+ "root": {
+ "event_id": "1111111111111111111111111111111111111111111111111111111111111111",
+ "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relay": "wss://%"
+ }
+ },
+ "expected": {
+ "error": "reply_relay_invalid"
+ }
+ },
+ {
+ "id": "authored_nip10_invalid_relay_bad_percent_host",
+ "kind": "social.reply.build_authored_draft.invalid",
+ "input": {
+ "content": "Invalid relay",
+ "root": {
+ "event_id": "1111111111111111111111111111111111111111111111111111111111111111",
+ "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relay": "wss://%zz"
+ }
+ },
+ "expected": {
+ "error": "reply_relay_invalid"
+ }
+ },
+ {
+ "id": "authored_nip10_invalid_relay_ipv4_overflow",
+ "kind": "social.reply.build_authored_draft.invalid",
+ "input": {
+ "content": "Invalid relay",
+ "root": {
+ "event_id": "1111111111111111111111111111111111111111111111111111111111111111",
+ "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relay": "wss://256.1.1.1"
+ }
+ },
+ "expected": {
+ "error": "reply_relay_invalid"
+ }
+ },
+ {
+ "id": "authored_nip10_invalid_relay_ipvfuture",
+ "kind": "social.reply.build_authored_draft.invalid",
+ "input": {
+ "content": "Invalid relay",
+ "root": {
+ "event_id": "1111111111111111111111111111111111111111111111111111111111111111",
+ "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relay": "wss://[v1.foo]"
+ }
+ },
+ "expected": {
+ "error": "reply_relay_invalid"
+ }
+ },
+ {
+ "id": "authored_nip10_invalid_relay_empty_port",
+ "kind": "social.reply.build_authored_draft.invalid",
+ "input": {
+ "content": "Invalid relay",
+ "root": {
+ "event_id": "1111111111111111111111111111111111111111111111111111111111111111",
+ "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relay": "wss://relay.example:"
+ }
+ },
+ "expected": {
+ "error": "reply_relay_invalid"
+ }
+ },
+ {
+ "id": "authored_nip10_invalid_relay_zero_port",
+ "kind": "social.reply.build_authored_draft.invalid",
+ "input": {
+ "content": "Invalid relay",
+ "root": {
+ "event_id": "1111111111111111111111111111111111111111111111111111111111111111",
+ "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relay": "wss://relay.example:0"
+ }
+ },
+ "expected": {
+ "error": "reply_relay_invalid"
+ }
+ },
+ {
+ "id": "authored_nip10_invalid_relay_port_overflow",
+ "kind": "social.reply.build_authored_draft.invalid",
+ "input": {
+ "content": "Invalid relay",
+ "root": {
+ "event_id": "1111111111111111111111111111111111111111111111111111111111111111",
+ "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relay": "wss://relay.example:65536"
+ }
+ },
+ "expected": {
+ "error": "reply_relay_invalid"
+ }
+ },
+ {
"id": "project_signed_nip10_marked_direct",
"kind": "social.reply.project_verified_event.valid",
"input": {
@@ -645,6 +770,139 @@
}
},
{
+ "id": "project_signed_nip10_canonical_relay_authorities",
+ "kind": "social.reply.project_verified_event.valid",
+ "input": {
+ "event_json": "{\"id\":\"a9f7683ed2eaf63e64af15dddf726ea32ec86e6079cd1038edf379c2de96a199\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000020,\"kind\":1,\"tags\":[[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"wss://relay.example/nostr/v1?region=ca-bc&next=%2Ffeed\",\"\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"ws://127.0.0.1:21003\",\"reply\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://[2001:db8::1]:8443/nostr\",\"root\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"ws://localhost:21003\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"wss://participant.relay.example?region=ca-bc\"],[\"p\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"]],\"content\":\"Canonical relay authorities\",\"sig\":\"4b1691d3d3abcb62a70041da50a0c9790871454a2fb234bac6061a97b4407f91299e905210792c1005883a800d31a7580a218172b4c8ac2834e2c6bcf5b0d925\"}"
+ },
+ "expected": {
+ "style": "marked",
+ "contract_id": "radroots.social.reply.v1",
+ "direct": false,
+ "root": {
+ "tag_index": 2,
+ "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://[2001:db8::1]:8443/nostr", "root", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"],
+ "event_id": "1111111111111111111111111111111111111111111111111111111111111111",
+ "relay": "wss://[2001:db8::1]:8443/nostr",
+ "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+ },
+ "parent": {
+ "tag_index": 1,
+ "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "ws://127.0.0.1:21003", "reply", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"],
+ "event_id": "2222222222222222222222222222222222222222222222222222222222222222",
+ "relay": "ws://127.0.0.1:21003",
+ "author_hint": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
+ },
+ "citations": [
+ {
+ "tag_index": 0,
+ "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://relay.example/nostr/v1?region=ca-bc&next=%2Ffeed", "", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"],
+ "event_id": "3333333333333333333333333333333333333333333333333333333333333333",
+ "relay": "wss://relay.example/nostr/v1?region=ca-bc&next=%2Ffeed",
+ "author_hint": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
+ }
+ ],
+ "participants": [
+ {
+ "tag_index": 3,
+ "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "ws://localhost:21003"],
+ "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relay": "ws://localhost:21003"
+ },
+ {
+ "tag_index": 4,
+ "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "wss://participant.relay.example?region=ca-bc"],
+ "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "relay": "wss://participant.relay.example?region=ca-bc"
+ },
+ {
+ "tag_index": 5,
+ "raw_tag": ["p", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"],
+ "pubkey": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
+ "relay": null
+ }
+ ],
+ "diagnostics": []
+ }
+ },
+ {
+ "id": "project_signed_nip10_malformed_relay_authorities",
+ "kind": "social.reply.project_verified_event.valid",
+ "input": {
+ "event_json": "{\"id\":\"715bf99c903fcc3ff2f78411de07df916ab6b4476142c4f6974cf5eeddcabfd5\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000021,\"kind\":1,\"tags\":[[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"wss://%\",\"\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://%zz\",\"root\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"wss://256.1.1.1\",\"reply\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"wss://[v1.foo]\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"p\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"]],\"content\":\"Malformed relay authorities\",\"sig\":\"37644f8a4b353d758aa1ce78f78fd17c702c4fd862b0408c3c61000ef9533150dc4888ef78b1023f6ee26b0abe786e31e9cc442f62c12260af04750b58f3f20e\"}"
+ },
+ "expected": {
+ "style": "marked",
+ "contract_id": "radroots.social.reply.v1",
+ "direct": false,
+ "root": {
+ "tag_index": 1,
+ "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://%zz", "root", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"],
+ "event_id": "1111111111111111111111111111111111111111111111111111111111111111",
+ "relay": null,
+ "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+ },
+ "parent": {
+ "tag_index": 2,
+ "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://256.1.1.1", "reply", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"],
+ "event_id": "2222222222222222222222222222222222222222222222222222222222222222",
+ "relay": null,
+ "author_hint": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
+ },
+ "citations": [
+ {
+ "tag_index": 0,
+ "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://%", "", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"],
+ "event_id": "3333333333333333333333333333333333333333333333333333333333333333",
+ "relay": null,
+ "author_hint": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
+ }
+ ],
+ "participants": [
+ {
+ "tag_index": 3,
+ "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://[v1.foo]"],
+ "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": [
+ {
+ "code": "reply_reference_relay_ignored",
+ "tag_index": 0,
+ "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://%", "", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"]
+ },
+ {
+ "code": "reply_reference_relay_ignored",
+ "tag_index": 1,
+ "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://%zz", "root", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"]
+ },
+ {
+ "code": "reply_reference_relay_ignored",
+ "tag_index": 2,
+ "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://256.1.1.1", "reply", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"]
+ },
+ {
+ "code": "reply_author_relay_ignored",
+ "tag_index": 3,
+ "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://[v1.foo]"]
+ }
+ ]
+ }
+ },
+ {
"id": "project_signed_nip10_ambiguous_same_reference",
"kind": "social.reply.project_verified_event.invalid",
"input": {
diff --git a/contracts/event_boundary_matrix.md b/contracts/event_boundary_matrix.md
@@ -123,6 +123,16 @@ 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.
+Authored and projected NIP-10 relay hints share one portable canonical profile:
+exact lowercase `ws://` or `wss://`, visible ASCII, canonical lowercase DNS or
+four-octet IPv4 or bracketed pure-hex RFC 5952 IPv6, optional canonical port
+`1..65535`, and RFC 3986 path-abempty/query syntax with uppercase `%HH`
+escapes. The profile rejects userinfo, fragments, backslashes, IDNA and
+percent-encoded hosts, legacy IPv4, and normalization-dependent spellings.
+Malformed inbound hints stay verbatim in ordered raw-tag diagnostics. Relay
+syntax validation is independent from the Reply boundary's 4,096-byte
+tag-element budget.
+
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
diff --git a/contracts/events/social-events.md b/contracts/events/social-events.md
@@ -168,9 +168,24 @@ consulted.
`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.
+lowercase event id, a referenced-author pubkey, and an optional
+`RadrootsNip10RelayHint`. The relay hint profile accepts only byte-stable ASCII
+WebSocket URLs: an exact lowercase `ws://` or `wss://` scheme; a canonical
+lowercase DNS, four-octet IPv4, or bracketed pure-hex RFC 5952 IPv6 authority;
+an optional canonical decimal port from `1` through `65535`; and an RFC 3986
+ASCII path-abempty and optional query whose percent escapes use uppercase
+`%HH`. DNS labels are 1 through 63 bytes, hosts are at most 253 bytes, and
+punycode (`xn--`) labels plus final all-decimal or `0x`-hex labels are rejected.
+The profile also rejects IDNA or percent-encoded hosts, legacy IPv4 forms,
+userinfo, fragments, controls, spaces, backslashes, and any
+normalization-dependent spelling.
+
+Relay syntax and Reply wire budgets are separate. The relay-hint type can hold
+a canonical URL whose path is longer than one tag element, while authored
+construction and verified inbound projection independently enforce the
+4,096-byte tag-element ceiling and return `reply_tag_element_too_large`.
+Content is non-whitespace and shares the root-post content, 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
@@ -195,8 +210,10 @@ 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.
+valid relay hints are projected through the same `RadrootsNip10RelayHint`
+profile used for authoring, and rejected hints remain verbatim in the ordered
+diagnostic's raw tag. 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
diff --git a/contracts/operations.toml b/contracts/operations.toml
@@ -105,6 +105,7 @@ public = [
"RadrootsAuthoredPhotoUpdate",
"RadrootsAuthoredAsk",
"RadrootsNip10ReplyError",
+ "RadrootsNip10RelayHint",
"RadrootsNip10ReplyReference",
"RadrootsAuthoredNip10Reply",
"RadrootsPostDiagnostic",
@@ -858,6 +859,7 @@ rust_modules = [
rust_types = [
"radroots_event::reply::RadrootsAuthoredNip10Reply",
"radroots_event::reply::RadrootsNip10ReplyError",
+ "radroots_event::reply::RadrootsNip10RelayHint",
"radroots_event::reply::RadrootsNip10ReplyReference",
]
@@ -882,6 +884,7 @@ transport = "none"
[operations.social_reply_project_verified_event.implementation]
rust_modules = ["crates/event_codec/src/reply/inbound.rs"]
rust_types = [
+ "radroots_event::reply::RadrootsNip10RelayHint",
"radroots_event_codec::reply::inbound::RadrootsInboundNip10EventReference",
"radroots_event_codec::reply::inbound::RadrootsInboundNip10Participant",
"radroots_event_codec::reply::inbound::RadrootsInboundNip10ReplyProjection",
diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml
@@ -197,3 +197,15 @@ semver_impacts = [
"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."
+
+[[changes]]
+id = "canonical-nip10-relay-hints"
+classification = "breaking"
+semver_impacts = [
+ "add_exported_type",
+ "add_exported_function",
+ "add_conformance_vector",
+ "change_exported_function_signature",
+ "change_exported_algorithm_behavior",
+]
+summary = "Replace generic URL normalization for NIP-10 relay hints with one portable canonical profile, reject noncanonical hints during strict authoring, and preserve rejected inbound hints verbatim in ordered diagnostics."
diff --git a/crates/event/README b/crates/event/README
@@ -30,13 +30,22 @@ 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.
+The Reply module exposes opaque `RadrootsNip10RelayHint`,
+`RadrootsNip10ReplyReference`, and `RadrootsAuthoredNip10Reply` types for strict
+direct and nested kind-1 authoring. The relay hint is a byte-stable subset of
+WebSocket URLs: exact lowercase `ws://` or `wss://`, visible ASCII, canonical
+lowercase DNS or four-octet IPv4 or bracketed pure-hex RFC 5952 IPv6,
+canonical optional port `1..65535`, and RFC 3986 path-abempty/query syntax
+with uppercase `%HH` escapes. It rejects IDNA and percent-encoded hosts,
+legacy IPv4, userinfo, fragments, controls, backslashes, and
+normalization-dependent spellings.
+
+References carry a validated event id, referenced author, and optional
+canonical relay hint; construction emits either one marked root or distinct
+marked root and parent references. Relay-hint syntax is not a wire-size claim:
+Reply construction separately enforces the 4,096-byte tag-element ceiling.
+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
diff --git a/crates/event/src/reply.rs b/crates/event/src/reply.rs
@@ -1,9 +1,9 @@
#[cfg(not(feature = "std"))]
-use alloc::string::String;
-use core::fmt;
+use alloc::string::{String, ToString};
+use core::{borrow::Borrow, fmt, net::Ipv6Addr, ops::Deref, str::FromStr};
use crate::{
- ids::{RadrootsEventId, RadrootsIdParseError, RadrootsPublicKey, RadrootsRelayUrl},
+ ids::{RadrootsEventId, RadrootsIdParseError, RadrootsPublicKey},
post::{
RADROOTS_POST_CONTENT_MAX_BYTES, RADROOTS_POST_EVENT_WIRE_MAX_BYTES,
RADROOTS_POST_TAG_ELEMENT_MAX_BYTES, RADROOTS_POST_TAG_TOTAL_MAX_BYTES,
@@ -108,6 +108,90 @@ impl std::error::Error for RadrootsNip10ReplyError {
}
}
+/// One canonical relay hint for a NIP-10 event or participant reference.
+///
+/// This profile intentionally accepts a conservative, byte-stable subset of
+/// WebSocket URLs. It does not inherit browser URL normalization, legacy IPv4
+/// syntax, Unicode host processing, user information, or fragments.
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct RadrootsNip10RelayHint(String);
+
+impl RadrootsNip10RelayHint {
+ pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsIdParseError> {
+ validate_nip10_relay_hint(value.as_ref()).map(Self)
+ }
+
+ #[inline]
+ pub fn as_str(&self) -> &str {
+ self.0.as_str()
+ }
+
+ #[inline]
+ pub fn into_string(self) -> String {
+ self.0
+ }
+}
+
+impl AsRef<str> for RadrootsNip10RelayHint {
+ #[inline]
+ fn as_ref(&self) -> &str {
+ self.as_str()
+ }
+}
+
+impl Deref for RadrootsNip10RelayHint {
+ type Target = str;
+
+ #[inline]
+ fn deref(&self) -> &Self::Target {
+ self.as_str()
+ }
+}
+
+impl Borrow<str> for RadrootsNip10RelayHint {
+ #[inline]
+ fn borrow(&self) -> &str {
+ self.as_str()
+ }
+}
+
+impl From<RadrootsNip10RelayHint> for String {
+ #[inline]
+ fn from(value: RadrootsNip10RelayHint) -> Self {
+ value.into_string()
+ }
+}
+
+impl fmt::Display for RadrootsNip10RelayHint {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(self.as_str())
+ }
+}
+
+impl FromStr for RadrootsNip10RelayHint {
+ type Err = RadrootsIdParseError;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ Self::parse(value)
+ }
+}
+
+impl TryFrom<&str> for RadrootsNip10RelayHint {
+ type Error = RadrootsIdParseError;
+
+ fn try_from(value: &str) -> Result<Self, Self::Error> {
+ Self::parse(value)
+ }
+}
+
+impl TryFrom<String> for RadrootsNip10RelayHint {
+ type Error = RadrootsIdParseError;
+
+ fn try_from(value: String) -> Result<Self, Self::Error> {
+ Self::parse(value)
+ }
+}
+
/// 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
@@ -116,14 +200,14 @@ impl std::error::Error for RadrootsNip10ReplyError {
pub struct RadrootsNip10ReplyReference {
event_id: RadrootsEventId,
author: RadrootsPublicKey,
- relay: Option<RadrootsRelayUrl>,
+ relay: Option<RadrootsNip10RelayHint>,
}
impl RadrootsNip10ReplyReference {
pub fn new(
event_id: RadrootsEventId,
author: RadrootsPublicKey,
- relay: Option<RadrootsRelayUrl>,
+ relay: Option<RadrootsNip10RelayHint>,
) -> Result<Self, RadrootsNip10ReplyError> {
if let Some(relay) = &relay {
validate_tag_element(relay.as_str())?;
@@ -147,7 +231,8 @@ impl RadrootsNip10ReplyReference {
let relay = match relay {
None | Some("") => None,
Some(relay) => Some(
- RadrootsRelayUrl::parse(relay).map_err(RadrootsNip10ReplyError::RelayInvalid)?,
+ RadrootsNip10RelayHint::parse(relay)
+ .map_err(RadrootsNip10ReplyError::RelayInvalid)?,
),
};
Self::new(event_id, author, relay)
@@ -161,13 +246,275 @@ impl RadrootsNip10ReplyReference {
&self.author
}
- pub const fn relay(&self) -> Option<&RadrootsRelayUrl> {
+ pub const fn relay(&self) -> Option<&RadrootsNip10RelayHint> {
self.relay.as_ref()
}
pub fn relay_or_empty(&self) -> &str {
- self.relay.as_ref().map_or("", RadrootsRelayUrl::as_str)
+ self.relay
+ .as_ref()
+ .map_or("", RadrootsNip10RelayHint::as_str)
+ }
+}
+
+fn validate_nip10_relay_hint(value: &str) -> Result<String, RadrootsIdParseError> {
+ if value.is_empty() {
+ return Err(RadrootsIdParseError::Empty);
+ }
+ if !value.bytes().all(|byte| matches!(byte, 0x21..=0x7e)) {
+ return Err(RadrootsIdParseError::InvalidCharacter);
+ }
+ if value.bytes().any(|byte| matches!(byte, b'#' | b'\\')) {
+ return Err(RadrootsIdParseError::InvalidFormat);
+ }
+
+ let remainder = value
+ .strip_prefix("wss://")
+ .or_else(|| value.strip_prefix("ws://"))
+ .ok_or(RadrootsIdParseError::InvalidFormat)?;
+ let authority_end = remainder
+ .bytes()
+ .position(|byte| matches!(byte, b'/' | b'?'))
+ .unwrap_or(remainder.len());
+ let authority = &remainder[..authority_end];
+ if authority.is_empty()
+ || authority.bytes().any(|byte| byte == b'@')
+ || matches!(remainder.as_bytes().first(), Some(b'/' | b'?'))
+ {
+ return Err(RadrootsIdParseError::InvalidFormat);
+ }
+ if !nip10_relay_authority_is_valid(authority)
+ || !nip10_relay_path_and_query_are_valid(&remainder[authority_end..])
+ {
+ return Err(RadrootsIdParseError::InvalidFormat);
}
+ Ok(value.to_string())
+}
+
+fn nip10_relay_authority_is_valid(authority: &str) -> bool {
+ if let Some(ipv6) = authority.strip_prefix('[') {
+ let Some(closing_index) = ipv6.find(']') else {
+ return false;
+ };
+ let address = &ipv6[..closing_index];
+ let suffix = &ipv6[closing_index + 1..];
+ return canonical_nip10_ipv6(address)
+ && (suffix.is_empty() || suffix.strip_prefix(':').is_some_and(canonical_nip10_port));
+ }
+
+ let mut parts = authority.split(':');
+ let host = parts.next().unwrap_or_default();
+ let port = parts.next();
+ if parts.next().is_some()
+ || port.is_some_and(|port| !canonical_nip10_port(port))
+ || host.is_empty()
+ {
+ return false;
+ }
+ canonical_nip10_ipv4(host) || canonical_nip10_dns_host(host)
+}
+
+fn canonical_nip10_port(value: &str) -> bool {
+ if value.is_empty()
+ || !value.bytes().all(|byte| byte.is_ascii_digit())
+ || value.len() > 5
+ || value.len() > 1 && value.starts_with('0')
+ {
+ return false;
+ }
+ value
+ .parse::<u32>()
+ .is_ok_and(|port| (1..=u16::MAX.into()).contains(&port))
+}
+
+fn canonical_nip10_ipv4(value: &str) -> bool {
+ let mut count = 0usize;
+ for part in value.split('.') {
+ if part.is_empty()
+ || !part.bytes().all(|byte| byte.is_ascii_digit())
+ || part.len() > 1 && part.starts_with('0')
+ || part.parse::<u8>().is_err()
+ {
+ return false;
+ }
+ count += 1;
+ }
+ count == 4
+}
+
+fn canonical_nip10_dns_host(value: &str) -> bool {
+ if value.len() > 253 {
+ return false;
+ }
+ let mut final_label = "";
+ for label in value.split('.') {
+ if label.is_empty()
+ || label.len() > 63
+ || label.starts_with("xn--")
+ || !label
+ .bytes()
+ .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
+ || !label
+ .as_bytes()
+ .first()
+ .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
+ || !label
+ .as_bytes()
+ .last()
+ .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
+ {
+ return false;
+ }
+ final_label = label;
+ }
+ !nip10_dns_label_is_whatwg_number(final_label)
+}
+
+fn nip10_dns_label_is_whatwg_number(value: &str) -> bool {
+ value.bytes().all(|byte| byte.is_ascii_digit())
+ || value
+ .strip_prefix("0x")
+ .is_some_and(|digits| digits.bytes().all(|byte| byte.is_ascii_hexdigit()))
+}
+
+fn canonical_nip10_ipv6(value: &str) -> bool {
+ if value.is_empty()
+ || !value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f' | b':'))
+ {
+ return false;
+ }
+ let Ok(address) = value.parse::<Ipv6Addr>() else {
+ return false;
+ };
+ canonical_nip10_ipv6_string(address) == value
+}
+
+fn canonical_nip10_ipv6_string(address: Ipv6Addr) -> String {
+ let segments = address.segments();
+ let mut best_start = 0usize;
+ let mut best_len = 0usize;
+ let mut index = 0usize;
+ while index < segments.len() {
+ if segments[index] != 0 {
+ index += 1;
+ continue;
+ }
+ let start = index;
+ while index < segments.len() && segments[index] == 0 {
+ index += 1;
+ }
+ let len = index - start;
+ if len >= 2 && len > best_len {
+ best_start = start;
+ best_len = len;
+ }
+ }
+
+ let mut canonical = String::new();
+ if best_len == 0 {
+ push_nip10_ipv6_segments(&mut canonical, &segments);
+ return canonical;
+ }
+ push_nip10_ipv6_segments(&mut canonical, &segments[..best_start]);
+ canonical.push_str("::");
+ push_nip10_ipv6_segments(&mut canonical, &segments[best_start + best_len..]);
+ canonical
+}
+
+fn push_nip10_ipv6_segments(output: &mut String, segments: &[u16]) {
+ for (index, segment) in segments.iter().enumerate() {
+ if index > 0 {
+ output.push(':');
+ }
+ push_nip10_ipv6_segment(output, *segment);
+ }
+}
+
+fn push_nip10_ipv6_segment(output: &mut String, segment: u16) {
+ let mut shift = 12u32;
+ while shift > 0 && segment >> shift == 0 {
+ shift -= 4;
+ }
+ loop {
+ let nibble = ((segment >> shift) & 0x0f) as u8;
+ output.push(match nibble {
+ 0..=9 => (b'0' + nibble) as char,
+ _ => (b'a' + nibble - 10) as char,
+ });
+ if shift == 0 {
+ break;
+ }
+ shift -= 4;
+ }
+}
+
+fn nip10_relay_path_and_query_are_valid(value: &str) -> bool {
+ if value.is_empty() {
+ return true;
+ }
+ if let Some(query) = value.strip_prefix('?') {
+ return nip10_relay_component_is_valid(query, true);
+ }
+ let Some(path) = value.strip_prefix('/') else {
+ return false;
+ };
+ let (path, query) = path
+ .split_once('?')
+ .map_or((path, None), |(path, query)| (path, Some(query)));
+ nip10_relay_component_is_valid(path, false)
+ && query.is_none_or(|query| nip10_relay_component_is_valid(query, true))
+}
+
+fn nip10_relay_component_is_valid(value: &str, query: bool) -> bool {
+ let bytes = value.as_bytes();
+ let mut index = 0usize;
+ while index < bytes.len() {
+ if bytes[index] == b'%' {
+ if index + 2 >= bytes.len()
+ || !nip10_upper_hex_digit(bytes[index + 1])
+ || !nip10_upper_hex_digit(bytes[index + 2])
+ {
+ return false;
+ }
+ index += 3;
+ continue;
+ }
+ if !nip10_relay_pchar(bytes[index]) && !(query && bytes[index] == b'?') {
+ return false;
+ }
+ index += 1;
+ }
+ true
+}
+
+fn nip10_relay_pchar(byte: u8) -> bool {
+ byte.is_ascii_alphanumeric()
+ || matches!(
+ byte,
+ b'-' | b'.'
+ | b'_'
+ | b'~'
+ | b'!'
+ | b'$'
+ | b'&'
+ | b'\''
+ | b'('
+ | b')'
+ | b'*'
+ | b'+'
+ | b','
+ | b';'
+ | b'='
+ | b':'
+ | b'@'
+ | b'/'
+ )
+}
+
+fn nip10_upper_hex_digit(byte: u8) -> bool {
+ byte.is_ascii_digit() || matches!(byte, b'A'..=b'F')
}
/// Strict authored marked NIP-10 reply.
@@ -417,6 +764,129 @@ mod tests {
}
#[test]
+ fn canonical_relay_hints_accept_portable_hosts_paths_and_queries() {
+ for value in [
+ "wss://relay.example",
+ "ws://127.0.0.1:21003",
+ "wss://localhost",
+ "wss://[::1]",
+ "wss://[2001:db8::1]:65535/nostr/v1?region=ca-bc&next=%2Ffeed",
+ "wss://[::ffff:c000:201]",
+ "wss://relay.example:443?",
+ "wss://relay.example/a/b:@!$&'()*+,;=~_-?next=/feed??page=1",
+ ] {
+ let relay = RadrootsNip10RelayHint::parse(value)
+ .unwrap_or_else(|error| panic!("{value} must be canonical: {error}"));
+ assert_eq!(relay.as_str(), value);
+ assert_eq!(relay.to_string(), value);
+ assert_eq!(
+ value.parse::<RadrootsNip10RelayHint>().expect("FromStr"),
+ relay
+ );
+ }
+
+ let maximum_host = format!(
+ "{}.{}.{}.{}",
+ "a".repeat(63),
+ "b".repeat(63),
+ "c".repeat(63),
+ "d".repeat(61)
+ );
+ assert_eq!(maximum_host.len(), 253);
+ RadrootsNip10RelayHint::parse(format!("wss://{maximum_host}")).expect("253-byte DNS host");
+ }
+
+ #[test]
+ fn canonical_relay_hints_reject_normalizing_or_ambiguous_forms() {
+ assert_eq!(
+ RadrootsNip10RelayHint::parse("").unwrap_err(),
+ RadrootsIdParseError::Empty
+ );
+ for value in [
+ "WSS://relay.example",
+ "https://relay.example",
+ "wss://",
+ "wss:///relay.example",
+ "wss:////relay.example",
+ "wss://?region=ca-bc",
+ "wss://user@relay.example",
+ "wss://@relay.example",
+ "wss://relay.example#read",
+ "wss://relay.example\\path",
+ "wss://Relay.example",
+ "wss://relay_example",
+ "wss://-relay.example",
+ "wss://relay-.example",
+ "wss://relay..example",
+ "wss://relay.example.",
+ "wss://xn--fa-hia.example",
+ "wss://example.999",
+ "wss://example.0x",
+ "wss://example.0x1",
+ "wss://%65xample.com",
+ "wss://127.1",
+ "wss://2130706433",
+ "wss://0x7f.1",
+ "wss://01.2.3.4",
+ "wss://256.1.1.1",
+ "wss://[2001:DB8::1]",
+ "wss://[2001:0db8::1]",
+ "wss://[2001:db8:0:0:0:0:0:1]",
+ "wss://[2001:0:0:1::1:1]",
+ "wss://[::ffff:192.0.2.1]",
+ "wss://[fe80::1%25en0]",
+ "wss://[v1.foo]",
+ "wss://2001:db8::1",
+ "wss://relay.example:",
+ "wss://relay.example:0",
+ "wss://relay.example:01",
+ "wss://relay.example:+443",
+ "wss://relay.example:65536",
+ "wss://relay.example/[raw]",
+ "wss://relay.example/%",
+ "wss://relay.example/%2",
+ "wss://relay.example/%2f",
+ "wss://relay.example/%GG",
+ ] {
+ assert_eq!(
+ RadrootsNip10RelayHint::parse(value).unwrap_err(),
+ RadrootsIdParseError::InvalidFormat,
+ "{value}"
+ );
+ }
+
+ for value in [
+ "wss://relay.example path",
+ "wss://relay.example/\u{007f}",
+ "wss://relay.example/é",
+ ] {
+ assert_eq!(
+ RadrootsNip10RelayHint::parse(value).unwrap_err(),
+ RadrootsIdParseError::InvalidCharacter,
+ "{value}"
+ );
+ }
+
+ let label_too_long = "a".repeat(64);
+ assert_eq!(
+ RadrootsNip10RelayHint::parse(format!("wss://{label_too_long}.example")).unwrap_err(),
+ RadrootsIdParseError::InvalidFormat
+ );
+ let host_too_long = format!(
+ "{}.{}.{}.{}",
+ "a".repeat(63),
+ "b".repeat(63),
+ "c".repeat(63),
+ "d".repeat(62)
+ );
+ assert_eq!(host_too_long.len(), 254);
+ assert_eq!(
+ RadrootsNip10RelayHint::parse(format!("wss://{host_too_long}")).unwrap_err(),
+ RadrootsIdParseError::InvalidFormat
+ );
+ }
+
+ #[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'))
diff --git a/crates/event_codec/README b/crates/event_codec/README
@@ -49,6 +49,17 @@ malformed advisory relay, author, participant, and citation metadata through
ordered diagnostics. Root and parent ambiguity or malformed hard anchors
remain projection failures.
+Authored and inbound relay metadata uses the shared
+`RadrootsNip10RelayHint` profile: exact lowercase `ws://` or `wss://`, visible
+ASCII, canonical lowercase DNS or four-octet IPv4 or bracketed pure-hex RFC
+5952 IPv6, canonical optional port `1..65535`, and RFC 3986
+path-abempty/query syntax with uppercase `%HH` escapes. IDNA or
+percent-encoded hosts, legacy IPv4, userinfo, fragments, controls,
+backslashes, and normalization-dependent forms are rejected. Inbound
+projection ignores an invalid advisory hint while preserving its exact raw tag
+in ordered diagnostics. Relay syntax remains independent from the 4,096-byte
+Reply tag-element budget.
+
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/reply/inbound.rs b/crates/event_codec/src/reply/inbound.rs
@@ -5,12 +5,13 @@ use core::fmt;
use std::collections::BTreeSet;
use radroots_event::{
- ids::{RadrootsEventId, RadrootsIdParseError, RadrootsPublicKey, RadrootsRelayUrl},
+ ids::{RadrootsEventId, RadrootsIdParseError, RadrootsPublicKey},
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,
},
+ reply::RadrootsNip10RelayHint,
};
use crate::verification::RadrootsSignatureVerifiedEvent;
@@ -145,7 +146,7 @@ pub struct RadrootsInboundNip10EventReference {
tag_index: usize,
raw_tag: Vec<String>,
event_id: RadrootsEventId,
- relay: Option<RadrootsRelayUrl>,
+ relay: Option<RadrootsNip10RelayHint>,
author_hint: Option<RadrootsPublicKey>,
}
@@ -162,7 +163,7 @@ impl RadrootsInboundNip10EventReference {
&self.event_id
}
- pub const fn relay(&self) -> Option<&RadrootsRelayUrl> {
+ pub const fn relay(&self) -> Option<&RadrootsNip10RelayHint> {
self.relay.as_ref()
}
@@ -176,7 +177,7 @@ pub struct RadrootsInboundNip10Participant {
tag_index: usize,
raw_tag: Vec<String>,
pubkey: RadrootsPublicKey,
- relay: Option<RadrootsRelayUrl>,
+ relay: Option<RadrootsNip10RelayHint>,
}
impl RadrootsInboundNip10Participant {
@@ -192,7 +193,7 @@ impl RadrootsInboundNip10Participant {
&self.pubkey
}
- pub const fn relay(&self) -> Option<&RadrootsRelayUrl> {
+ pub const fn relay(&self) -> Option<&RadrootsNip10RelayHint> {
self.relay.as_ref()
}
}
@@ -734,10 +735,12 @@ fn project_participants(
participants
}
-fn parse_relay_hint(value: Option<&str>) -> Result<Option<RadrootsRelayUrl>, RadrootsIdParseError> {
+fn parse_relay_hint(
+ value: Option<&str>,
+) -> Result<Option<RadrootsNip10RelayHint>, RadrootsIdParseError> {
match value {
None | Some("") => Ok(None),
- Some(value) => RadrootsRelayUrl::parse(value).map(Some),
+ Some(value) => RadrootsNip10RelayHint::parse(value).map(Some),
}
}
@@ -931,6 +934,99 @@ mod tests {
}
#[test]
+ fn inbound_projection_uses_the_canonical_relay_hint_profile() {
+ let root_id = "a".repeat(64);
+ let root_author = "b".repeat(64);
+ for relay in [
+ "wss://%65xample.com",
+ "wss://127.1",
+ "wss://relay.example:01",
+ "wss://[2001:0db8::1]",
+ "wss://relay.example/%2f",
+ ] {
+ let root_tag = vec![
+ "e".to_string(),
+ root_id.clone(),
+ relay.to_string(),
+ "root".to_string(),
+ ];
+ let tags = vec![root_tag.clone(), vec!["p".to_string(), root_author.clone()]];
+ let projection = project_nip10_reply_parts(KIND_POST, &tags, "Reply", 10)
+ .unwrap_or_else(|error| panic!("{relay} must remain advisory: {error}"));
+
+ assert!(projection.root().relay().is_none(), "{relay}");
+ assert_eq!(projection.root().raw_tag(), root_tag);
+ assert_eq!(
+ projection
+ .diagnostics()
+ .iter()
+ .map(RadrootsNip10ReplyDiagnostic::code)
+ .collect::<Vec<_>>(),
+ vec!["reply_reference_relay_ignored"],
+ "{relay}"
+ );
+ assert_eq!(
+ projection.diagnostics()[0].raw_tag(),
+ Some(root_tag.as_slice()),
+ "{relay}"
+ );
+ }
+
+ let tags = vec![
+ vec![
+ "e".to_string(),
+ root_id,
+ "wss://[2001:db8::1]:65535/nostr?region=ca-bc".to_string(),
+ "root".to_string(),
+ ],
+ vec![
+ "p".to_string(),
+ root_author,
+ "ws://127.0.0.1:21003".to_string(),
+ ],
+ ];
+ let projection = project_nip10_reply_parts(KIND_POST, &tags, "Reply", 10)
+ .expect("canonical reference and participant relays");
+ assert_eq!(
+ projection.root().relay().expect("root relay").as_str(),
+ "wss://[2001:db8::1]:65535/nostr?region=ca-bc"
+ );
+ assert_eq!(
+ projection.participants()[0]
+ .relay()
+ .expect("participant relay")
+ .as_str(),
+ "ws://127.0.0.1:21003"
+ );
+ assert!(projection.diagnostics().is_empty());
+ }
+
+ #[test]
+ fn inbound_relay_syntax_and_tag_element_budgets_remain_separate() {
+ let prefix = "wss://relay.example/";
+ let relay = format!(
+ "{prefix}{}",
+ "a".repeat(RADROOTS_POST_TAG_ELEMENT_MAX_BYTES + 1 - prefix.len())
+ );
+ assert_eq!(relay.len(), RADROOTS_POST_TAG_ELEMENT_MAX_BYTES + 1);
+ RadrootsNip10RelayHint::parse(&relay).expect("relay syntax has no Reply wire budget");
+
+ let tags = vec![
+ vec!["e".to_string(), "a".repeat(64), relay, "root".to_string()],
+ vec!["p".to_string(), "b".repeat(64)],
+ ];
+ assert!(matches!(
+ project_nip10_reply_parts(KIND_POST, &tags, "Reply", 10),
+ Err(RadrootsNip10ReplyProjectionError::TagElementTooLarge {
+ max: RADROOTS_POST_TAG_ELEMENT_MAX_BYTES,
+ actual,
+ tag_index: 0,
+ element_index: 2,
+ }) if actual == RADROOTS_POST_TAG_ELEMENT_MAX_BYTES + 1
+ ));
+ }
+
+ #[test]
fn tolerant_inbound_keeps_reference_ids_and_markers_as_hard_gates() {
let author = "b".repeat(64);
for (tag, expected) in [
diff --git a/crates/event_codec/tests/fixtures/post_verified_profiles.v1.json b/crates/event_codec/tests/fixtures/post_verified_profiles.v1.json
@@ -219,6 +219,131 @@
}
},
{
+ "id": "authored_nip10_canonical_ipv6_relay",
+ "kind": "social.reply.build_authored_draft.valid",
+ "input": {
+ "content": "Canonical relay",
+ "root": {
+ "event_id": "1111111111111111111111111111111111111111111111111111111111111111",
+ "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relay": "wss://[2001:db8::1]:8443/nostr?region=ca-bc&next=%2Ffeed"
+ }
+ },
+ "expected": {
+ "kind": 1,
+ "content": "Canonical relay",
+ "tags": [
+ ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://[2001:db8::1]:8443/nostr?region=ca-bc&next=%2Ffeed", "root"],
+ ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"]
+ ]
+ }
+ },
+ {
+ "id": "authored_nip10_invalid_relay_percent_host",
+ "kind": "social.reply.build_authored_draft.invalid",
+ "input": {
+ "content": "Invalid relay",
+ "root": {
+ "event_id": "1111111111111111111111111111111111111111111111111111111111111111",
+ "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relay": "wss://%"
+ }
+ },
+ "expected": {
+ "error": "reply_relay_invalid"
+ }
+ },
+ {
+ "id": "authored_nip10_invalid_relay_bad_percent_host",
+ "kind": "social.reply.build_authored_draft.invalid",
+ "input": {
+ "content": "Invalid relay",
+ "root": {
+ "event_id": "1111111111111111111111111111111111111111111111111111111111111111",
+ "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relay": "wss://%zz"
+ }
+ },
+ "expected": {
+ "error": "reply_relay_invalid"
+ }
+ },
+ {
+ "id": "authored_nip10_invalid_relay_ipv4_overflow",
+ "kind": "social.reply.build_authored_draft.invalid",
+ "input": {
+ "content": "Invalid relay",
+ "root": {
+ "event_id": "1111111111111111111111111111111111111111111111111111111111111111",
+ "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relay": "wss://256.1.1.1"
+ }
+ },
+ "expected": {
+ "error": "reply_relay_invalid"
+ }
+ },
+ {
+ "id": "authored_nip10_invalid_relay_ipvfuture",
+ "kind": "social.reply.build_authored_draft.invalid",
+ "input": {
+ "content": "Invalid relay",
+ "root": {
+ "event_id": "1111111111111111111111111111111111111111111111111111111111111111",
+ "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relay": "wss://[v1.foo]"
+ }
+ },
+ "expected": {
+ "error": "reply_relay_invalid"
+ }
+ },
+ {
+ "id": "authored_nip10_invalid_relay_empty_port",
+ "kind": "social.reply.build_authored_draft.invalid",
+ "input": {
+ "content": "Invalid relay",
+ "root": {
+ "event_id": "1111111111111111111111111111111111111111111111111111111111111111",
+ "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relay": "wss://relay.example:"
+ }
+ },
+ "expected": {
+ "error": "reply_relay_invalid"
+ }
+ },
+ {
+ "id": "authored_nip10_invalid_relay_zero_port",
+ "kind": "social.reply.build_authored_draft.invalid",
+ "input": {
+ "content": "Invalid relay",
+ "root": {
+ "event_id": "1111111111111111111111111111111111111111111111111111111111111111",
+ "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relay": "wss://relay.example:0"
+ }
+ },
+ "expected": {
+ "error": "reply_relay_invalid"
+ }
+ },
+ {
+ "id": "authored_nip10_invalid_relay_port_overflow",
+ "kind": "social.reply.build_authored_draft.invalid",
+ "input": {
+ "content": "Invalid relay",
+ "root": {
+ "event_id": "1111111111111111111111111111111111111111111111111111111111111111",
+ "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relay": "wss://relay.example:65536"
+ }
+ },
+ "expected": {
+ "error": "reply_relay_invalid"
+ }
+ },
+ {
"id": "project_signed_nip10_marked_direct",
"kind": "social.reply.project_verified_event.valid",
"input": {
@@ -645,6 +770,139 @@
}
},
{
+ "id": "project_signed_nip10_canonical_relay_authorities",
+ "kind": "social.reply.project_verified_event.valid",
+ "input": {
+ "event_json": "{\"id\":\"a9f7683ed2eaf63e64af15dddf726ea32ec86e6079cd1038edf379c2de96a199\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000020,\"kind\":1,\"tags\":[[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"wss://relay.example/nostr/v1?region=ca-bc&next=%2Ffeed\",\"\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"ws://127.0.0.1:21003\",\"reply\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://[2001:db8::1]:8443/nostr\",\"root\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"ws://localhost:21003\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"wss://participant.relay.example?region=ca-bc\"],[\"p\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"]],\"content\":\"Canonical relay authorities\",\"sig\":\"4b1691d3d3abcb62a70041da50a0c9790871454a2fb234bac6061a97b4407f91299e905210792c1005883a800d31a7580a218172b4c8ac2834e2c6bcf5b0d925\"}"
+ },
+ "expected": {
+ "style": "marked",
+ "contract_id": "radroots.social.reply.v1",
+ "direct": false,
+ "root": {
+ "tag_index": 2,
+ "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://[2001:db8::1]:8443/nostr", "root", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"],
+ "event_id": "1111111111111111111111111111111111111111111111111111111111111111",
+ "relay": "wss://[2001:db8::1]:8443/nostr",
+ "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+ },
+ "parent": {
+ "tag_index": 1,
+ "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "ws://127.0.0.1:21003", "reply", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"],
+ "event_id": "2222222222222222222222222222222222222222222222222222222222222222",
+ "relay": "ws://127.0.0.1:21003",
+ "author_hint": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
+ },
+ "citations": [
+ {
+ "tag_index": 0,
+ "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://relay.example/nostr/v1?region=ca-bc&next=%2Ffeed", "", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"],
+ "event_id": "3333333333333333333333333333333333333333333333333333333333333333",
+ "relay": "wss://relay.example/nostr/v1?region=ca-bc&next=%2Ffeed",
+ "author_hint": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
+ }
+ ],
+ "participants": [
+ {
+ "tag_index": 3,
+ "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "ws://localhost:21003"],
+ "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relay": "ws://localhost:21003"
+ },
+ {
+ "tag_index": 4,
+ "raw_tag": ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "wss://participant.relay.example?region=ca-bc"],
+ "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "relay": "wss://participant.relay.example?region=ca-bc"
+ },
+ {
+ "tag_index": 5,
+ "raw_tag": ["p", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"],
+ "pubkey": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
+ "relay": null
+ }
+ ],
+ "diagnostics": []
+ }
+ },
+ {
+ "id": "project_signed_nip10_malformed_relay_authorities",
+ "kind": "social.reply.project_verified_event.valid",
+ "input": {
+ "event_json": "{\"id\":\"715bf99c903fcc3ff2f78411de07df916ab6b4476142c4f6974cf5eeddcabfd5\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781000021,\"kind\":1,\"tags\":[[\"e\",\"3333333333333333333333333333333333333333333333333333333333333333\",\"wss://%\",\"\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"],[\"e\",\"1111111111111111111111111111111111111111111111111111111111111111\",\"wss://%zz\",\"root\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],[\"e\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"wss://256.1.1.1\",\"reply\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"wss://[v1.foo]\"],[\"p\",\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"],[\"p\",\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"]],\"content\":\"Malformed relay authorities\",\"sig\":\"37644f8a4b353d758aa1ce78f78fd17c702c4fd862b0408c3c61000ef9533150dc4888ef78b1023f6ee26b0abe786e31e9cc442f62c12260af04750b58f3f20e\"}"
+ },
+ "expected": {
+ "style": "marked",
+ "contract_id": "radroots.social.reply.v1",
+ "direct": false,
+ "root": {
+ "tag_index": 1,
+ "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://%zz", "root", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"],
+ "event_id": "1111111111111111111111111111111111111111111111111111111111111111",
+ "relay": null,
+ "author_hint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+ },
+ "parent": {
+ "tag_index": 2,
+ "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://256.1.1.1", "reply", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"],
+ "event_id": "2222222222222222222222222222222222222222222222222222222222222222",
+ "relay": null,
+ "author_hint": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
+ },
+ "citations": [
+ {
+ "tag_index": 0,
+ "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://%", "", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"],
+ "event_id": "3333333333333333333333333333333333333333333333333333333333333333",
+ "relay": null,
+ "author_hint": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
+ }
+ ],
+ "participants": [
+ {
+ "tag_index": 3,
+ "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://[v1.foo]"],
+ "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": [
+ {
+ "code": "reply_reference_relay_ignored",
+ "tag_index": 0,
+ "raw_tag": ["e", "3333333333333333333333333333333333333333333333333333333333333333", "wss://%", "", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"]
+ },
+ {
+ "code": "reply_reference_relay_ignored",
+ "tag_index": 1,
+ "raw_tag": ["e", "1111111111111111111111111111111111111111111111111111111111111111", "wss://%zz", "root", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"]
+ },
+ {
+ "code": "reply_reference_relay_ignored",
+ "tag_index": 2,
+ "raw_tag": ["e", "2222222222222222222222222222222222222222222222222222222222222222", "wss://256.1.1.1", "reply", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"]
+ },
+ {
+ "code": "reply_author_relay_ignored",
+ "tag_index": 3,
+ "raw_tag": ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://[v1.foo]"]
+ }
+ ]
+ }
+ },
+ {
"id": "project_signed_nip10_ambiguous_same_reference",
"kind": "social.reply.project_verified_event.invalid",
"input": {
diff --git a/crates/event_codec/tests/verified_post_conformance.rs b/crates/event_codec/tests/verified_post_conformance.rs
@@ -43,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); 54] = [
+const EXPECTED_VECTOR_CASES: [(&str, &str); 64] = [
(
"admit_signed_duplicate_normalized_ask_marker",
"social.post.verify_and_admit_event.invalid",
@@ -102,10 +102,42 @@ const EXPECTED_VECTOR_CASES: [(&str, &str); 54] = [
"social.reply.build_authored_draft.invalid",
),
(
+ "authored_nip10_canonical_ipv6_relay",
+ "social.reply.build_authored_draft.valid",
+ ),
+ (
"authored_nip10_direct_wire",
"social.reply.build_authored_draft.valid",
),
(
+ "authored_nip10_invalid_relay_bad_percent_host",
+ "social.reply.build_authored_draft.invalid",
+ ),
+ (
+ "authored_nip10_invalid_relay_empty_port",
+ "social.reply.build_authored_draft.invalid",
+ ),
+ (
+ "authored_nip10_invalid_relay_ipv4_overflow",
+ "social.reply.build_authored_draft.invalid",
+ ),
+ (
+ "authored_nip10_invalid_relay_ipvfuture",
+ "social.reply.build_authored_draft.invalid",
+ ),
+ (
+ "authored_nip10_invalid_relay_percent_host",
+ "social.reply.build_authored_draft.invalid",
+ ),
+ (
+ "authored_nip10_invalid_relay_port_overflow",
+ "social.reply.build_authored_draft.invalid",
+ ),
+ (
+ "authored_nip10_invalid_relay_zero_port",
+ "social.reply.build_authored_draft.invalid",
+ ),
+ (
"authored_nip10_invalid_event_id",
"social.reply.build_authored_draft.invalid",
),
@@ -178,6 +210,14 @@ const EXPECTED_VECTOR_CASES: [(&str, &str); 54] = [
"social.reply.project_verified_event.valid",
),
(
+ "project_signed_nip10_canonical_relay_authorities",
+ "social.reply.project_verified_event.valid",
+ ),
+ (
+ "project_signed_nip10_malformed_relay_authorities",
+ "social.reply.project_verified_event.valid",
+ ),
+ (
"project_signed_nip10_invalid_author_hint_tolerated",
"social.reply.project_verified_event.valid",
),
diff --git a/crates/nostr/README b/crates/nostr/README
@@ -35,6 +35,15 @@ 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.
+Reply authoring and verified projection share the portable
+`RadrootsNip10RelayHint` profile rather than the generic relay URL type. It
+accepts only exact lowercase `ws://` or `wss://` visible-ASCII URLs with a
+canonical lowercase DNS, four-octet IPv4, or bracketed pure-hex RFC 5952 IPv6
+authority, an optional canonical port `1..65535`, and RFC 3986
+path-abempty/query syntax using uppercase `%HH` escapes. Rejected inbound hints
+remain verbatim in ordered raw-tag diagnostics. The hint profile does not own
+the Reply boundary's separate 4,096-byte tag-element budget.
+
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
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; 33] = [
+const REQUIRED_POST_PUBLIC_TYPES: [&str; 34] = [
"RadrootsBlossomApprovedBlobUrl",
"RadrootsBlossomByteVerifiedDescriptor",
"RadrootsNip01EventWireParts",
@@ -186,6 +186,7 @@ const REQUIRED_POST_PUBLIC_TYPES: [&str; 33] = [
"RadrootsAuthoredPhotoUpdate",
"RadrootsAuthoredAsk",
"RadrootsNip10ReplyError",
+ "RadrootsNip10RelayHint",
"RadrootsNip10ReplyReference",
"RadrootsAuthoredNip10Reply",
"RadrootsPostDiagnostic",
@@ -573,6 +574,7 @@ const POST_OPERATION_EXPECTATIONS: [PostOperationExpectation; 8] = [
rust_types: &[
"radroots_event::reply::RadrootsAuthoredNip10Reply",
"radroots_event::reply::RadrootsNip10ReplyError",
+ "radroots_event::reply::RadrootsNip10RelayHint",
"radroots_event::reply::RadrootsNip10ReplyReference",
],
case_kinds: &[
@@ -589,6 +591,7 @@ const POST_OPERATION_EXPECTATIONS: [PostOperationExpectation; 8] = [
signing: "none",
rust_modules: &["crates/event_codec/src/reply/inbound.rs"],
rust_types: &[
+ "radroots_event::reply::RadrootsNip10RelayHint",
"radroots_event_codec::reply::inbound::RadrootsInboundNip10EventReference",
"radroots_event_codec::reply::inbound::RadrootsInboundNip10Participant",
"radroots_event_codec::reply::inbound::RadrootsInboundNip10ReplyProjection",
@@ -688,7 +691,7 @@ const POST_OPERATION_ID_PREFIXES: [&str; 5] = [
"social.reply.",
"social.post.",
];
-const POST_VECTOR_EXPECTATIONS: [(&str, &str); 54] = [
+const POST_VECTOR_EXPECTATIONS: [(&str, &str); 64] = [
(
"authored_update_wire",
"social.update.build_authored_draft.valid",
@@ -719,6 +722,10 @@ const POST_VECTOR_EXPECTATIONS: [(&str, &str); 54] = [
"social.reply.build_authored_draft.valid",
),
(
+ "authored_nip10_canonical_ipv6_relay",
+ "social.reply.build_authored_draft.valid",
+ ),
+ (
"authored_nip10_ambiguous_parent",
"social.reply.build_authored_draft.invalid",
),
@@ -727,6 +734,34 @@ const POST_VECTOR_EXPECTATIONS: [(&str, &str); 54] = [
"social.reply.build_authored_draft.invalid",
),
(
+ "authored_nip10_invalid_relay_percent_host",
+ "social.reply.build_authored_draft.invalid",
+ ),
+ (
+ "authored_nip10_invalid_relay_bad_percent_host",
+ "social.reply.build_authored_draft.invalid",
+ ),
+ (
+ "authored_nip10_invalid_relay_ipv4_overflow",
+ "social.reply.build_authored_draft.invalid",
+ ),
+ (
+ "authored_nip10_invalid_relay_ipvfuture",
+ "social.reply.build_authored_draft.invalid",
+ ),
+ (
+ "authored_nip10_invalid_relay_empty_port",
+ "social.reply.build_authored_draft.invalid",
+ ),
+ (
+ "authored_nip10_invalid_relay_zero_port",
+ "social.reply.build_authored_draft.invalid",
+ ),
+ (
+ "authored_nip10_invalid_relay_port_overflow",
+ "social.reply.build_authored_draft.invalid",
+ ),
+ (
"project_signed_nip10_marked_direct",
"social.reply.project_verified_event.valid",
),
@@ -771,6 +806,14 @@ const POST_VECTOR_EXPECTATIONS: [(&str, &str); 54] = [
"social.reply.project_verified_event.valid",
),
(
+ "project_signed_nip10_canonical_relay_authorities",
+ "social.reply.project_verified_event.valid",
+ ),
+ (
+ "project_signed_nip10_malformed_relay_authorities",
+ "social.reply.project_verified_event.valid",
+ ),
+ (
"project_signed_nip10_ambiguous_same_reference",
"social.reply.project_verified_event.invalid",
),
@@ -1672,6 +1715,7 @@ const REPLY_WITNESSES: [EventBoundarySourceWitness; 4] = [
EventBoundarySourceWitness {
relative_path: "crates/event/src/reply.rs",
required_fragments: &[
+ "pub struct RadrootsNip10RelayHint",
"pub struct RadrootsNip10ReplyReference",
"pub struct RadrootsAuthoredNip10Reply",
],