lib

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

commit 5f87264fe5e1e8fef740a4b8ab6cda4476e044aa
parent f133f4d07f00b9d1209d79daca052a8c57e7ecd8
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Jul 2026 12:36:31 +0000

event: add verified NIP-01 boundary

- expose generic id and signature typestates without knowledge decoding
- admit signed tagless Profile events through tolerant metadata parsing
- reject out-of-range Nostr kinds across codec and integration paths
- govern replacement ordering with contracts and raw signed vectors

Diffstat:
MCHANGELOG.md | 11+++++++++++
Mbuild/nix/common.nix | 2+-
Acontracts/conformance/vectors/profile/verified_event.v1.json | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/coverage-profiles.toml | 2+-
Mcontracts/events/profile-metadata.md | 34+++++++++++++++++++++++++++-------
Mcontracts/operations.toml | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/releases/1.0.0-alpha.1.toml | 11+++++++++++
Mcrates/event/src/trade.rs | 33++++++++++++++++++---------------
Mcrates/event_codec/README | 9+++++++++
Mcrates/event_codec/src/job/error.rs | 4++++
Mcrates/event_codec/src/job/feedback/decode.rs | 3++-
Mcrates/event_codec/src/job/request/decode.rs | 3++-
Mcrates/event_codec/src/job/result/decode.rs | 3++-
Mcrates/event_codec/src/knowledge/mod.rs | 1+
Acrates/event_codec/src/knowledge/verification.rs | 211+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/src/lib.rs | 7++++---
Acrates/event_codec/src/profile/admission.rs | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/src/profile/mod.rs | 3+++
Mcrates/event_codec/src/verification.rs | 333+++++++++++++++++++++++++------------------------------------------------------
Mcrates/event_codec/tests/codec_error_job.rs | 4++++
Acrates/event_codec/tests/fixtures/profile_verified_event.v1.json | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/tests/job_feedback.rs | 13+++++++++++--
Mcrates/event_codec/tests/job_request.rs | 13+++++++++++--
Mcrates/event_codec/tests/job_result.rs | 13+++++++++++--
Mcrates/event_codec/tests/job_traits.rs | 6+++---
Mcrates/event_codec/tests/knowledge.rs | 3++-
Mcrates/event_codec/tests/knowledge_fixtures.rs | 3++-
Mcrates/event_codec/tests/tag_builders.rs | 10+++++-----
Acrates/event_codec/tests/verified_profile_conformance.rs | 287+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/nostr/src/error.rs | 3+++
Mcrates/nostr/src/events/application_handler.rs | 8+++++++-
Mcrates/nostr/src/events/mod.rs | 33+++++++++++++++++++++++++++++----
Mcrates/nostr/src/nip17.rs | 48+++++++++++++++++++++++++++++++++++++-----------
Mcrates/transport_nostr/tests/transport.rs | 12+++++++++---
Mtools/xtask/src/contract.rs | 22++++++++++++++++++++--
35 files changed, 1264 insertions(+), 294 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -9,6 +9,9 @@ publish policy both pass for the same source revision. ### Changed +- Generic NIP-01 identifier and signature verification is now independent of + knowledge decoding, and every dynamic Nostr kind conversion rejects values + above `65535` instead of truncating them. - Calendar authoring and admission now use explicit NIP-52 authored, parsed, and admitted states for date events, time events, calendars, and RSVPs. Kind `31922` no longer emits uppercase `D`; kind `31923` derives integer UTC-day @@ -23,6 +26,12 @@ publish policy both pass for the same source revision. fields, functions, modules, constants, Cargo features, and trait implementations, plus changed field types, constant values, and algorithms. +### Added + +- Verified Profile admission binds a signed exact kind-`0` envelope to the + tolerant metadata projection, accepts standard tagless events, and exposes + deterministic equal-time lowest-id replacement vectors. + ### Removed - Legacy calendar event models and permissive calendar tag-builder authoring @@ -39,6 +48,8 @@ publish policy both pass for the same source revision. ### Compatibility +- Callers that previously passed out-of-range `u32` kinds to Nostr builders or + job decoders now receive typed range errors instead of truncated kinds. - Identity JSON containing the removed embedded `profile` projection is now rejected, including the nested public-profile form, instead of being loaded and later rewritten without that field. diff --git a/build/nix/common.nix b/build/nix/common.nix @@ -100,7 +100,7 @@ let ]; coreContractCargoArgs = lib.concatStringsSep " " (map (crate: "-p ${crate}") coreContractCrates) - + " --features radroots_event_codec/serde_json,radroots_nostr/blossom"; + + " --features radroots_event_codec/serde_json,radroots_event_codec/nostr,radroots_nostr/blossom"; craneLib = (crane.mkLib pkgs).overrideToolchain toolchains.stable; commonCraneArgs = { inherit version; diff --git a/contracts/conformance/vectors/profile/verified_event.v1.json b/contracts/conformance/vectors/profile/verified_event.v1.json @@ -0,0 +1,100 @@ +{ + "contract_version": "1.0.0", + "suite": "verified_profile_event", + "vectors": [ + { + "expected": { + "event_id": "a07878757d705d3cd848b9264791d699069068a5f0a575112f351367b0987958", + "kind": 65535 + }, + "id": "event_verify_max_kind_001", + "input": { + "event_json": "{\"id\":\"a07878757d705d3cd848b9264791d699069068a5f0a575112f351367b0987958\",\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"created_at\":1800000104,\"kind\":65535,\"tags\":[],\"content\":\"maximum-kind\",\"sig\":\"d79b19843a0bfd769c02c73866d44a3a06f7b11e107a5257971b60e700aa25565802fd3a7eed4042fe8db7d709a465e5f61478eb8291178831bf48f6b0980671\"}" + }, + "kind": "event.verify_nip01.valid" + }, + { + "expected": { + "error": "id_mismatch" + }, + "id": "event_verify_id_mismatch_001", + "input": { + "event_json": "{\"content\":\"{\\\"display_name\\\":\\\"Moss Street Farm\\\",\\\"bot\\\":false,\\\"website\\\":\\\"https://mossstreet.example\\\",\\\"picture\\\":42}\",\"created_at\":1800000100,\"id\":\"fdcebf1cbdcddea8027b5e214cb7b223fa662d8b85f5d68acf2e8849be0d71c4\",\"kind\":0,\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"sig\":\"e5448d11671bcf73aa8d56941aff9df46d4e9fb250596671950e5f3c9d747440523efd33d8b9ff4f2a2ef1bd4b79e0a7cf5850132172b1db4b642ef2b348f721\",\"tags\":[]}" + }, + "kind": "event.verify_nip01.invalid_id" + }, + { + "expected": { + "error": "signature_invalid" + }, + "id": "event_verify_signature_invalid_001", + "input": { + "event_json": "{\"content\":\"{\\\"display_name\\\":\\\"Moss Street Farm\\\",\\\"bot\\\":false,\\\"website\\\":\\\"https://mossstreet.example\\\",\\\"picture\\\":42}\",\"created_at\":1800000100,\"id\":\"b0450c4fb0a82cc829159646f90dc548503e8b7f850a434fd9ea58bf1b8d677f\",\"kind\":0,\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"sig\":\"d015e365c530c44fbc33e970af13616e482fafe65d0947edcdaa1e7b3038e9285b4b6826bdf454e8381d66e9dc6978d9974358572d97850ebc726fcc539e2b88\",\"tags\":[]}" + }, + "kind": "event.verify_nip01.invalid_signature" + }, + { + "expected": { + "error": "kind_out_of_range", + "kind": 65536 + }, + "id": "event_verify_kind_overflow_001", + "input": { + "event_json": "{\"content\":\"overflow-kind\",\"created_at\":1800000106,\"id\":\"82dff74958bd7e1e52ad98add08bbb70d4ce9cf9d90e4741c64c212df0804ccb\",\"kind\":65536,\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"sig\":\"00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\",\"tags\":[]}" + }, + "kind": "event.verify_nip01.kind_overflow" + }, + { + "expected": { + "event_id": "b0450c4fb0a82cc829159646f90dc548503e8b7f850a434fd9ea58bf1b8d677f", + "projected": { + "bot": false, + "display_name": "Moss Street Farm" + }, + "residual_fields": { + "picture": 42, + "website": "https://mossstreet.example" + }, + "tags": [] + }, + "id": "profile_admit_tagless_tolerant_001", + "input": { + "event_json": "{\"id\":\"b0450c4fb0a82cc829159646f90dc548503e8b7f850a434fd9ea58bf1b8d677f\",\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"created_at\":1800000100,\"kind\":0,\"tags\":[],\"content\":\"{\\\"display_name\\\":\\\"Moss Street Farm\\\",\\\"bot\\\":false,\\\"website\\\":\\\"https://mossstreet.example\\\",\\\"picture\\\":42}\",\"sig\":\"e5448d11671bcf73aa8d56941aff9df46d4e9fb250596671950e5f3c9d747440523efd33d8b9ff4f2a2ef1bd4b79e0a7cf5850132172b1db4b642ef2b348f721\"}" + }, + "kind": "profile.verify_and_admit.valid" + }, + { + "expected": { + "actual": 1, + "error": "invalid_kind" + }, + "id": "profile_admit_wrong_kind_001", + "input": { + "event_json": "{\"id\":\"05b2ebdf444f09330198f8c07ea3e3c7d8ac97d94c41e957b74bce921f611080\",\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"created_at\":1800000102,\"kind\":1,\"tags\":[],\"content\":\"{\\\"name\\\":\\\"not-a-profile\\\"}\",\"sig\":\"3d12c2dfea5cff7e9993166d20b9092f4a6a9dd7e9f4af26355d2f4c4fa0e1ba7a0e5fe6e4b90c93ae57d52cc71b38b409051702200868dfd601dbdd74b89710\"}" + }, + "kind": "profile.verify_and_admit.invalid_kind" + }, + { + "expected": { + "error": "root_not_object" + }, + "id": "profile_admit_non_object_001", + "input": { + "event_json": "{\"id\":\"671f2d9f80a2fa2759c9514ae36fd18296cee71417fc86995525f2c659a5f781\",\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"created_at\":1800000103,\"kind\":0,\"tags\":[],\"content\":\"[]\",\"sig\":\"67d0420384e366407499ce4ab501cd87a02bea0d4221007b7a3ff01d9b68a0b9d372882184ab48775a8516f6a6554b506fc3e7652162a53e08a43e63daeacac7\"}" + }, + "kind": "profile.verify_and_admit.invalid_metadata" + }, + { + "expected": { + "created_at": 1800000105, + "event_id": "ad00fc208cc7036b95fadb7a4660bc600aa007502430250630d70d15b50695b3" + }, + "id": "profile_equal_time_lowest_id_001", + "input": { + "first_event_json": "{\"id\":\"fdcebf1cbdcddea8027b5e214cb7b223fa662d8b85f5d68acf2e8849be0d71c4\",\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"created_at\":1800000105,\"kind\":0,\"tags\":[],\"content\":\"{\\\"name\\\":\\\"arrival-a\\\"}\",\"sig\":\"445a4a08bd365acbe3d68b1433cd8952f8974cb1f454c7d6790881b123c1e7a6449662f78e3044436c7cda8745ae74aa8ee382e292160a3a3f420dd3bb4f275c\"}", + "second_event_json": "{\"id\":\"ad00fc208cc7036b95fadb7a4660bc600aa007502430250630d70d15b50695b3\",\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"created_at\":1800000105,\"kind\":0,\"tags\":[],\"content\":\"{\\\"name\\\":\\\"arrival-b\\\"}\",\"sig\":\"6b52cb1cbcf2639104b564144517d8479833272690514af9e9d45b170a1c88320ac3425b9339aa525e591584f97666b1c556b4709fb6ef27959775e70c8caabe\"}" + }, + "kind": "profile.select_equal_time_head" + } + ] +} diff --git a/contracts/coverage-profiles.toml b/contracts/coverage-profiles.toml @@ -10,7 +10,7 @@ test_threads = 1 [profiles.crates."radroots_event_codec"] no_default_features = false -features = ["serde_json"] +features = ["serde_json", "nostr"] test_threads = 1 [profiles.crates."radroots_nostr_runtime"] diff --git a/contracts/events/profile-metadata.md b/contracts/events/profile-metadata.md @@ -12,12 +12,24 @@ pinned NIP-01, NIP-05, and NIP-24 documents at NIPs commit | Operation | Boundary | Signing | Transport | | --- | --- | --- | --- | +| `event.verify_nip01` | exact identifier and Schnorr verification to a non-forgeable wrapper | NIP-01 | none | +| `event.select_head` | NIP-01 replaceable/addressable timestamp and lowest-id selection | none | none | | `profile.build_authored_draft` | strict authored metadata to kind-`0` wire parts | none | none | | `profile.parse_inbound_metadata` | JSON object to tolerant inbound metadata | none | none | +| `profile.verify_and_admit_event` | verified exact kind-`0` envelope to tolerant metadata bound to that envelope | NIP-01 | none | `profile.parse_inbound_metadata` is a content parser, not an event-acceptance boundary. A caller must supply content from a kind-`0` event only after the -event identifier and signature have been verified. +event identifier and signature have been verified. The authoritative combined +boundary is `profile.verify_and_admit_event`: it recomputes the identifier, +verifies the Schnorr signature, requires exact kind `0`, and only then invokes +the same tolerant parser. It accepts standard tagless Profile events and does +not require a Radroots marker tag. + +`event.verify_nip01` and its `RadrootsSignatureVerifiedEvent` result are +available through the codec's `nostr` feature without enabling `knowledge`. +Knowledge contract validation and decoding are a later optional stage and +cannot be substituted for general event verification. The direct legacy `RadrootsProfile` codec, Profile-specific Nostr/network publish helpers, and replica Profile draft emission were removed in the @@ -77,6 +89,10 @@ format. A publication runtime must require successful BUD-02 completion before signing media-bearing output. A consuming media runtime remains responsible for decode and format-safety policy. +For two verified kind-`0` events from the same author, the head is the event +with the greater `created_at`. Equal timestamps select the lexicographically +lowest canonical event id, independent of relay or ingestion arrival order. + ## Tolerant inbound boundary `profile.parse_inbound_metadata` accepts a JSON object of at most 131072 UTF-8 @@ -126,6 +142,8 @@ making downstream matches exhaustive. Current stable codes are: | shared authored image construction | `media_type_not_image` | | strict Profile encoding | `content_too_large` | | tolerant inbound parsing | `content_too_large`, `invalid_json`, `root_not_object`, `duplicate_field` | +| NIP-01 event verification | `malformed_envelope`, `kind_out_of_range`, `id_mismatch`, `signature_invalid`, `signature_verification_unavailable` | +| verified Profile admission | NIP-01 codes plus `invalid_kind`, `content_too_large`, `invalid_json`, `root_not_object`, `duplicate_field` | Inbound size validation occurs before JSON parsing. For content within the limit, malformed JSON returns `invalid_json`; a well-formed non-object returns @@ -136,9 +154,11 @@ parse errors. ## Conformance The canonical suite is -`contracts/conformance/vectors/profile/metadata.v1.json`. It is mirrored under -`crates/event_codec/tests/fixtures/` for published-package tests. The dispatcher -executes every vector against the public strict authored or tolerant inbound -API and requires the canonical and packaged copies to be byte-for-byte equal -when the workspace contract is present. Consumers must enable the codec's -optional `serde_json` feature to use these operations. +`contracts/conformance/vectors/profile/metadata.v1.json`. Verified event +admission and replacement use +`contracts/conformance/vectors/profile/verified_event.v1.json`. Both are +mirrored under `crates/event_codec/tests/fixtures/` for published-package +tests. The dispatchers execute every vector against the public APIs and require +canonical and packaged copies to be byte-for-byte equal when the workspace +contract is present. Consumers enable `serde_json` for metadata operations and +both `serde_json` and `nostr` for cryptographic Profile admission. diff --git a/contracts/operations.toml b/contracts/operations.toml @@ -6,6 +6,7 @@ source = "rust" [public] domains = [ "blossom", + "event", "profile", "farm", "listing", @@ -44,6 +45,13 @@ public = [ "RadrootsEventDraft", "RadrootsSignedEvent", "RadrootsEventEnvelope", + "RadrootsIdVerifiedEvent", + "RadrootsSignatureVerifiedEvent", + "RadrootsNip01VerificationError", + "RadrootsEventHeadCoordinate", + "RadrootsEventHeadCandidate", + "RadrootsCurrentEventHead", + "RadrootsEventHeadDecision", "RadrootsEventRef", "RadrootsEventPtr", "RadrootsListingAddress", @@ -56,6 +64,8 @@ public = [ "RadrootsAuthoredProfileEncodeError", "RadrootsInboundProfileMetadata", "RadrootsProfileMetadataParseError", + "RadrootsAdmittedProfileEvent", + "RadrootsProfileAdmissionError", "RadrootsNip05IdentityVerification", "RadrootsUnverifiedProfileMediaReference", "RadrootsFarm", @@ -152,6 +162,7 @@ classes = [ "address_error", "decode_error", "admission_error", + "verification_error", ] [implementation_provenance] @@ -341,6 +352,54 @@ rust_types = [ [operations.blossom_decode_verify_authorization_header.conformance] vector = "contracts/conformance/vectors/blossom/bud11_nostr_adapter.v1.json" +[operations.event_verify_nip01] +domain = "event" +id = "event.verify_nip01" +stability = "beta" +inputs = ["RadrootsEventEnvelope"] +outputs = ["RadrootsSignatureVerifiedEvent"] +error_class = "verification_error" +deterministic = true +signing = "nip01" +transport = "none" + +[operations.event_verify_nip01.implementation] +rust_modules = [ + "crates/event/src/wire.rs", + "crates/event_codec/src/verification.rs", +] +rust_types = [ + "radroots_event::RadrootsEventEnvelope", + "radroots_event_codec::verification::RadrootsIdVerifiedEvent", + "radroots_event_codec::verification::RadrootsNip01VerificationError", + "radroots_event_codec::verification::RadrootsSignatureVerifiedEvent", +] + +[operations.event_verify_nip01.conformance] +vector = "contracts/conformance/vectors/profile/verified_event.v1.json" + +[operations.event_select_head] +domain = "event" +id = "event.select_head" +stability = "beta" +inputs = ["RadrootsEventHeadCandidate", "RadrootsCurrentEventHead?"] +outputs = ["RadrootsEventHeadDecision"] +error_class = "validation_error" +deterministic = true +signing = "none" +transport = "none" + +[operations.event_select_head.implementation] +rust_modules = ["crates/event/src/event_head.rs"] +rust_types = [ + "radroots_event::event_head::RadrootsCurrentEventHead", + "radroots_event::event_head::RadrootsEventHeadCandidate", + "radroots_event::event_head::RadrootsEventHeadDecision", +] + +[operations.event_select_head.conformance] +vector = "contracts/conformance/vectors/profile/verified_event.v1.json" + [operations.profile_build_authored_draft] domain = "profile" id = "profile.build_authored_draft" @@ -398,6 +457,34 @@ rust_types = [ [operations.profile_parse_inbound_metadata.conformance] vector = "contracts/conformance/vectors/profile/metadata.v1.json" +[operations.profile_verify_and_admit_event] +domain = "profile" +id = "profile.verify_and_admit_event" +stability = "beta" +inputs = ["RadrootsEventEnvelope"] +outputs = ["RadrootsAdmittedProfileEvent"] +error_class = "admission_error" +deterministic = true +signing = "nip01" +transport = "none" + +[operations.profile_verify_and_admit_event.implementation] +rust_modules = [ + "crates/event_codec/src/profile/admission.rs", + "crates/event_codec/src/profile/inbound.rs", + "crates/event_codec/src/verification.rs", +] +rust_types = [ + "radroots_event::RadrootsEventEnvelope", + "radroots_event_codec::profile::admission::RadrootsAdmittedProfileEvent", + "radroots_event_codec::profile::admission::RadrootsProfileAdmissionError", + "radroots_event_codec::profile::inbound::RadrootsInboundProfileMetadata", + "radroots_event_codec::verification::RadrootsSignatureVerifiedEvent", +] + +[operations.profile_verify_and_admit_event.conformance] +vector = "contracts/conformance/vectors/profile/verified_event.v1.json" + [operations.farm_build_draft] domain = "farm" id = "farm.build_draft" @@ -1070,6 +1157,7 @@ transport = "native" [operations.knowledge_verify_and_decode_event.implementation] rust_modules = [ "crates/event_codec/src/verification.rs", + "crates/event_codec/src/knowledge/verification.rs", "crates/event_codec/src/knowledge/decode.rs", ] rust_types = [ diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml @@ -77,3 +77,14 @@ id = "workspace-version-lockstep" classification = "fix" semver_impacts = ["fix_packaging_metadata"] summary = "Move the workspace and event contract to the 1.0.0-alpha.1 version cohort with exact internal requirements." + +[[changes]] +id = "general-verified-event-boundary" +classification = "breaking" +semver_impacts = [ + "add_exported_type", + "add_enum_variant", + "add_conformance_vector", + "change_exported_algorithm_behavior", +] +summary = "Expose knowledge-independent NIP-01 verification and verified Profile admission while rejecting every out-of-range Nostr kind conversion." diff --git a/crates/event/src/trade.rs b/crates/event/src/trade.rs @@ -1,18 +1,17 @@ #![forbid(unsafe_code)] +#[cfg(all(not(feature = "std"), feature = "serde"))] +use alloc::collections::BTreeMap; +#[cfg(all(not(feature = "std"), any(feature = "serde", test)))] +use alloc::{format, string::ToString}; #[cfg(not(feature = "std"))] -use alloc::{ - collections::BTreeMap, - format, - string::{String, ToString}, - vec::Vec, -}; +use alloc::{string::String, vec::Vec}; +#[cfg(all(feature = "std", feature = "serde"))] +use std::collections::BTreeMap; +#[cfg(all(feature = "std", feature = "serde"))] +use std::string::ToString; #[cfg(feature = "std")] -use std::{ - collections::BTreeMap, - string::{String, ToString}, - vec::Vec, -}; +use std::{string::String, vec::Vec}; use core::fmt; @@ -30,7 +29,9 @@ use serde::{ Deserialize, Deserializer, Serialize, de::{Error as _, MapAccess, SeqAccess, Visitor}, }; +#[cfg(feature = "serde")] use serde_json::{Map, Number, Value}; +#[cfg(feature = "serde")] use sha2::{Digest, Sha256}; pub const RADROOTS_TRADE_SCHEMA_VERSION: u16 = 1; @@ -816,6 +817,7 @@ fn write_canonical_jcs( Ok(()) } +#[cfg(feature = "serde")] fn canonical_number(number: &Number) -> Result<String, RadrootsTradeProtocolError> { if number.is_i64() || number.is_u64() { Ok(number.to_string()) @@ -824,6 +826,7 @@ fn canonical_number(number: &Number) -> Result<String, RadrootsTradeProtocolErro } } +#[cfg(feature = "serde")] fn digest_prefixed(domain: &[u8], bytes: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(domain); @@ -925,10 +928,10 @@ where for item in items { let item_key = key(item); validate_non_empty(item_key, field)?; - if let Some(previous) = previous { - if previous >= item_key { - return Err(RadrootsTradeProtocolError::InvalidField(field)); - } + if let Some(previous) = previous + && previous >= item_key + { + return Err(RadrootsTradeProtocolError::InvalidField(field)); } previous = Some(item_key); } diff --git a/crates/event_codec/README b/crates/event_codec/README @@ -20,6 +20,15 @@ byte-verified Blossom media references. Tolerant inbound parsing retains raw and residual fields and never upgrades observed media or NIP-05 syntax into network verification. Its content parser accepts only bounded kind-`0` content from an event whose identifier and signature the caller has already verified. +With `serde_json,nostr`, `profile::admission::verify_and_admit_profile_event` +provides that combined boundary and returns metadata bound to a non-forgeable +signature-verified envelope. Standard tagless kind-`0` events are accepted; no +Radroots marker is required. + +General NIP-01 identifier and Schnorr verification lives in `verification` and +is independent of the optional `knowledge` decoder. The `nostr` feature exposes +`RadrootsSignatureVerifiedEvent` and rejects event kinds above `u16::MAX` +instead of truncating them. The NIP-52 calendar codecs expose a deliberate three-stage boundary for kinds `31922`, `31923`, `31924`, and `31925`: diff --git a/crates/event_codec/src/job/error.rs b/crates/event_codec/src/job/error.rs @@ -2,6 +2,7 @@ use core::fmt; #[derive(Debug)] pub enum JobParseError { + KindOutOfRange(u32), MissingTag(&'static str), InvalidTag(&'static str), InvalidNumber(&'static str, core::num::ParseIntError), @@ -13,6 +14,9 @@ pub enum JobParseError { impl fmt::Display for JobParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + JobParseError::KindOutOfRange(kind) => { + write!(f, "Nostr event kind {kind} exceeds {}", u16::MAX) + } JobParseError::MissingTag(t) => write!(f, "missing tag: {}", t), JobParseError::InvalidTag(t) => write!(f, "invalid tag structure for '{}'", t), JobParseError::InvalidNumber(t, e) => write!(f, "invalid number in '{}': {}", t, e), diff --git a/crates/event_codec/src/job/feedback/decode.rs b/crates/event_codec/src/job/feedback/decode.rs @@ -20,6 +20,7 @@ pub fn job_feedback_from_tags( tags: &[Vec<String>], content: &str, ) -> Result<RadrootsJobFeedback, JobParseError> { + let kind = u16::try_from(kind).map_err(|_| JobParseError::KindOutOfRange(kind))?; let etag = tags .iter() .find(|t| t.first().map(|s| s.as_str()) == Some("e")) @@ -56,7 +57,7 @@ pub fn job_feedback_from_tags( .and_then(|t| t.get(1).cloned()); Ok(RadrootsJobFeedback { - kind: kind as u16, + kind, status, extra_info, request_event: RadrootsEventPtr { diff --git a/crates/event_codec/src/job/request/decode.rs b/crates/event_codec/src/job/request/decode.rs @@ -16,6 +16,7 @@ pub fn job_request_from_tags( kind: u32, tags: &[Vec<String>], ) -> Result<RadrootsJobRequest, JobParseError> { + let kind = u16::try_from(kind).map_err(|_| JobParseError::KindOutOfRange(kind))?; let inputs: Vec<RadrootsJobInput> = parse_i_tags(tags); let output = tags @@ -52,7 +53,7 @@ pub fn job_request_from_tags( } Ok(RadrootsJobRequest { - kind: kind as u16, + kind, inputs, output, params, diff --git a/crates/event_codec/src/job/result/decode.rs b/crates/event_codec/src/job/result/decode.rs @@ -20,6 +20,7 @@ pub fn job_result_from_tags( tags: &[Vec<String>], content: &str, ) -> Result<RadrootsJobResult, JobParseError> { + let kind = u16::try_from(kind).map_err(|_| JobParseError::KindOutOfRange(kind))?; let etag = tags .iter() .find(|t| t.first().map(|s| s.as_str()) == Some("e")) @@ -52,7 +53,7 @@ pub fn job_result_from_tags( .and_then(|t| t.get(1).cloned()); Ok(RadrootsJobResult { - kind: kind as u16, + kind, request_event: RadrootsEventPtr { id: req_id, relays: relay_hint, diff --git a/crates/event_codec/src/knowledge/mod.rs b/crates/event_codec/src/knowledge/mod.rs @@ -1,5 +1,6 @@ pub mod decode; pub mod encode; +pub mod verification; pub use decode::{ contribution_attestation_from_event, evidence_bounty_from_event, diff --git a/crates/event_codec/src/knowledge/verification.rs b/crates/event_codec/src/knowledge/verification.rs @@ -0,0 +1,211 @@ +#[cfg(not(feature = "std"))] +use alloc::string::{String, ToString}; + +use core::fmt; + +use radroots_event::RadrootsEventEnvelope; +use radroots_event::contract::{ + RadrootsContractValidationError, RadrootsEventContract, + validate_event_contract as validate_radroots_event_contract, +}; +use radroots_event::knowledge::{ + RadrootsContributionAttestation, RadrootsEvidenceBounty, RadrootsKnowledgeChangeProposal, + RadrootsKnowledgeClaim, RadrootsKnowledgeFieldReport, RadrootsKnowledgeRelation, + RadrootsKnowledgeReview, RadrootsKnowledgeSource, RadrootsWikiArticle, + RadrootsWikiMergeRequest, RadrootsWikiRedirect, +}; + +use crate::error::EventParseError; +use crate::knowledge::decode::{ + contribution_attestation_from_event, evidence_bounty_from_event, + knowledge_change_proposal_from_event, knowledge_claim_from_event, + knowledge_field_report_from_event, knowledge_relation_from_event, knowledge_review_from_event, + knowledge_source_from_event, wiki_article_from_event, wiki_merge_request_from_event, + wiki_redirect_from_event, +}; +use crate::parsed::RadrootsParsedEvent; +use crate::verification::{ + RadrootsNip01VerificationError, RadrootsSignatureVerifiedEvent, verify_nip01_event, +}; + +/// A NIP-01 verified event whose Radroots contract shape has been validated. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsContractValidatedEvent { + event: RadrootsEventEnvelope, + contract: &'static RadrootsEventContract, +} + +impl RadrootsContractValidatedEvent { + pub fn event(&self) -> &RadrootsEventEnvelope { + &self.event + } + + pub fn contract(&self) -> &'static RadrootsEventContract { + self.contract + } + + pub fn contract_id(&self) -> &'static str { + self.contract.id + } + + pub fn into_event(self) -> RadrootsEventEnvelope { + self.event + } +} + +#[derive(Debug)] +pub enum RadrootsDecodeError { + Nip01Verification(RadrootsNip01VerificationError), + ContractValidation(RadrootsContractValidationError), + EventParse(EventParseError), + UnsupportedContract { contract_id: String }, +} + +impl RadrootsDecodeError { + pub const fn code(&self) -> &'static str { + match self { + Self::Nip01Verification(_) => "nip01_verification", + Self::ContractValidation(_) => "contract_validation", + Self::EventParse(_) => "event_parse", + Self::UnsupportedContract { .. } => "unsupported_contract", + } + } +} + +impl fmt::Display for RadrootsDecodeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Nip01Verification(error) => write!(formatter, "{error}"), + Self::ContractValidation(error) => { + write!( + formatter, + "contract validation failed with code {}", + error.code() + ) + } + Self::EventParse(error) => write!(formatter, "{error}"), + Self::UnsupportedContract { contract_id } => { + write!(formatter, "unsupported event contract `{contract_id}`") + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsDecodeError {} + +impl From<EventParseError> for RadrootsDecodeError { + fn from(value: EventParseError) -> Self { + Self::EventParse(value) + } +} + +impl From<RadrootsNip01VerificationError> for RadrootsDecodeError { + fn from(value: RadrootsNip01VerificationError) -> Self { + Self::Nip01Verification(value) + } +} + +impl From<RadrootsContractValidationError> for RadrootsDecodeError { + fn from(value: RadrootsContractValidationError) -> Self { + Self::ContractValidation(value) + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub enum RadrootsDecodedEvent { + WikiArticle(RadrootsParsedEvent<RadrootsWikiArticle>), + WikiRedirect(RadrootsParsedEvent<RadrootsWikiRedirect>), + WikiMergeRequest(RadrootsParsedEvent<RadrootsWikiMergeRequest>), + KnowledgeSource(RadrootsParsedEvent<RadrootsKnowledgeSource>), + KnowledgeClaim(RadrootsParsedEvent<RadrootsKnowledgeClaim>), + KnowledgeRelation(RadrootsParsedEvent<RadrootsKnowledgeRelation>), + KnowledgeReview(RadrootsParsedEvent<RadrootsKnowledgeReview>), + KnowledgeFieldReport(RadrootsParsedEvent<RadrootsKnowledgeFieldReport>), + EvidenceBounty(RadrootsParsedEvent<RadrootsEvidenceBounty>), + KnowledgeChangeProposal(RadrootsParsedEvent<RadrootsKnowledgeChangeProposal>), + ContributionAttestation(RadrootsParsedEvent<RadrootsContributionAttestation>), +} + +impl RadrootsDecodedEvent { + pub fn event(&self) -> &RadrootsEventEnvelope { + match self { + Self::WikiArticle(parsed) => &parsed.event, + Self::WikiRedirect(parsed) => &parsed.event, + Self::WikiMergeRequest(parsed) => &parsed.event, + Self::KnowledgeSource(parsed) => &parsed.event, + Self::KnowledgeClaim(parsed) => &parsed.event, + Self::KnowledgeRelation(parsed) => &parsed.event, + Self::KnowledgeReview(parsed) => &parsed.event, + Self::KnowledgeFieldReport(parsed) => &parsed.event, + Self::EvidenceBounty(parsed) => &parsed.event, + Self::KnowledgeChangeProposal(parsed) => &parsed.event, + Self::ContributionAttestation(parsed) => &parsed.event, + } + } +} + +pub fn validate_event_contract( + event: RadrootsSignatureVerifiedEvent, +) -> Result<RadrootsContractValidatedEvent, RadrootsContractValidationError> { + let event = event.into_event(); + let contract = validate_radroots_event_contract(&event)?; + Ok(RadrootsContractValidatedEvent { event, contract }) +} + +pub fn decode_validated_event( + event: RadrootsContractValidatedEvent, +) -> Result<RadrootsDecodedEvent, RadrootsDecodeError> { + match event.contract.id { + "radroots.wiki.article.v1" => Ok(RadrootsDecodedEvent::WikiArticle( + wiki_article_from_event(event.event)?, + )), + "radroots.wiki.redirect.v1" => Ok(RadrootsDecodedEvent::WikiRedirect( + wiki_redirect_from_event(event.event)?, + )), + "radroots.wiki.merge_request.v1" => Ok(RadrootsDecodedEvent::WikiMergeRequest( + wiki_merge_request_from_event(event.event)?, + )), + "radroots.knowledge.source.v1" => Ok(RadrootsDecodedEvent::KnowledgeSource( + knowledge_source_from_event(event.event)?, + )), + "radroots.knowledge.claim.v1" => Ok(RadrootsDecodedEvent::KnowledgeClaim( + knowledge_claim_from_event(event.event)?, + )), + "radroots.knowledge.relation.v1" => Ok(RadrootsDecodedEvent::KnowledgeRelation( + knowledge_relation_from_event(event.event)?, + )), + "radroots.knowledge.review.v1" => Ok(RadrootsDecodedEvent::KnowledgeReview( + knowledge_review_from_event(event.event)?, + )), + "radroots.knowledge.field_report.v1" => Ok(RadrootsDecodedEvent::KnowledgeFieldReport( + knowledge_field_report_from_event(event.event)?, + )), + "radroots.knowledge.evidence_bounty.v1" => Ok(RadrootsDecodedEvent::EvidenceBounty( + evidence_bounty_from_event(event.event)?, + )), + "radroots.knowledge.change_proposal.v1" => { + Ok(RadrootsDecodedEvent::KnowledgeChangeProposal( + knowledge_change_proposal_from_event(event.event)?, + )) + } + "radroots.knowledge.contribution_attestation.v1" => { + Ok(RadrootsDecodedEvent::ContributionAttestation( + contribution_attestation_from_event(event.event)?, + )) + } + contract_id => Err(RadrootsDecodeError::UnsupportedContract { + contract_id: contract_id.to_string(), + }), + } +} + +/// Verifies NIP-01 identity before applying the knowledge contract and decoder. +pub fn verify_and_decode_radroots_event( + event: RadrootsEventEnvelope, +) -> Result<RadrootsDecodedEvent, RadrootsDecodeError> { + let verified = verify_nip01_event(event)?; + let contract_validated = validate_event_contract(verified)?; + decode_validated_event(contract_validated) +} diff --git a/crates/event_codec/src/lib.rs b/crates/event_codec/src/lib.rs @@ -18,7 +18,6 @@ pub mod report; pub mod repost; mod social_helpers; pub mod tag_builders; -#[cfg(feature = "knowledge")] pub mod verification; pub mod wire; @@ -69,7 +68,9 @@ pub use tag_builders::RadrootsEventTagBuilder; #[cfg(feature = "knowledge")] pub use verification::{ RadrootsContractValidatedEvent, RadrootsDecodeError, RadrootsDecodedEvent, - RadrootsIdVerifiedEvent, RadrootsNip01VerificationError, RadrootsSignatureVerifiedEvent, decode_validated_event, validate_event_contract, verify_and_decode_radroots_event, - verify_event_id, verify_event_signature, +}; +pub use verification::{ + RadrootsIdVerifiedEvent, RadrootsNip01VerificationError, RadrootsSignatureVerifiedEvent, + verify_event_id, verify_event_signature, verify_nip01_event, }; diff --git a/crates/event_codec/src/profile/admission.rs b/crates/event_codec/src/profile/admission.rs @@ -0,0 +1,122 @@ +use core::fmt; + +use radroots_event::{RadrootsEventEnvelope, kinds::KIND_PROFILE}; + +use crate::profile::inbound::{ + RadrootsInboundProfileMetadata, RadrootsProfileMetadataParseError, + parse_inbound_profile_metadata, +}; +use crate::verification::{ + RadrootsNip01VerificationError, RadrootsSignatureVerifiedEvent, verify_nip01_event, +}; + +/// A verified kind-0 event bound to its tolerant metadata projection. +#[derive(Clone, Debug, PartialEq)] +pub struct RadrootsAdmittedProfileEvent { + verified_event: RadrootsSignatureVerifiedEvent, + metadata: RadrootsInboundProfileMetadata, +} + +impl RadrootsAdmittedProfileEvent { + pub fn verified_event(&self) -> &RadrootsSignatureVerifiedEvent { + &self.verified_event + } + + pub fn event(&self) -> &RadrootsEventEnvelope { + self.verified_event.event() + } + + pub fn metadata(&self) -> &RadrootsInboundProfileMetadata { + &self.metadata + } + + pub fn into_parts( + self, + ) -> ( + RadrootsSignatureVerifiedEvent, + RadrootsInboundProfileMetadata, + ) { + (self.verified_event, self.metadata) + } +} + +#[non_exhaustive] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsProfileAdmissionError { + Nip01Verification(RadrootsNip01VerificationError), + InvalidKind { expected: u32, actual: u32 }, + Metadata(RadrootsProfileMetadataParseError), +} + +impl RadrootsProfileAdmissionError { + pub const fn code(&self) -> &'static str { + match self { + Self::Nip01Verification(error) => error.code(), + Self::InvalidKind { .. } => "invalid_kind", + Self::Metadata(error) => error.code(), + } + } +} + +impl fmt::Display for RadrootsProfileAdmissionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Nip01Verification(error) => write!(formatter, "{error}"), + Self::InvalidKind { expected, actual } => { + write!( + formatter, + "Profile event kind must be {expected}, got {actual}" + ) + } + Self::Metadata(error) => write!(formatter, "{error}"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsProfileAdmissionError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Nip01Verification(error) => Some(error), + Self::Metadata(error) => Some(error), + Self::InvalidKind { .. } => None, + } + } +} + +impl From<RadrootsNip01VerificationError> for RadrootsProfileAdmissionError { + fn from(value: RadrootsNip01VerificationError) -> Self { + Self::Nip01Verification(value) + } +} + +impl From<RadrootsProfileMetadataParseError> for RadrootsProfileAdmissionError { + fn from(value: RadrootsProfileMetadataParseError) -> Self { + Self::Metadata(value) + } +} + +/// Admits an already verified Profile event and preserves its exact envelope. +pub fn admit_verified_profile_event( + verified_event: RadrootsSignatureVerifiedEvent, +) -> Result<RadrootsAdmittedProfileEvent, RadrootsProfileAdmissionError> { + let actual = verified_event.event().kind_u32(); + if actual != KIND_PROFILE { + return Err(RadrootsProfileAdmissionError::InvalidKind { + expected: KIND_PROFILE, + actual, + }); + } + let metadata = parse_inbound_profile_metadata(verified_event.event().content())?; + Ok(RadrootsAdmittedProfileEvent { + verified_event, + metadata, + }) +} + +/// Verifies id and signature before parsing and admitting exact kind-0 content. +pub fn verify_and_admit_profile_event( + event: RadrootsEventEnvelope, +) -> Result<RadrootsAdmittedProfileEvent, RadrootsProfileAdmissionError> { + admit_verified_profile_event(verify_nip01_event(event)?) +} diff --git a/crates/event_codec/src/profile/mod.rs b/crates/event_codec/src/profile/mod.rs @@ -4,6 +4,9 @@ extern crate alloc; use radroots_event::profile::{RadrootsProfile, RadrootsProfileType}; #[cfg(feature = "serde_json")] +pub mod admission; + +#[cfg(feature = "serde_json")] pub mod authored; #[cfg(feature = "serde_json")] diff --git a/crates/event_codec/src/verification.rs b/crates/event_codec/src/verification.rs @@ -6,28 +6,14 @@ use core::fmt; use core::str::FromStr; use radroots_event::RadrootsEventEnvelope; -use radroots_event::contract::{ - RadrootsContractValidationError, RadrootsEventContract, - validate_event_contract as validate_radroots_event_contract, -}; use radroots_event::ids::RadrootsEventId; -use radroots_event::knowledge::{ - RadrootsContributionAttestation, RadrootsEvidenceBounty, RadrootsKnowledgeChangeProposal, - RadrootsKnowledgeClaim, RadrootsKnowledgeFieldReport, RadrootsKnowledgeRelation, - RadrootsKnowledgeReview, RadrootsKnowledgeSource, RadrootsWikiArticle, - RadrootsWikiMergeRequest, RadrootsWikiRedirect, -}; use radroots_event::wire::compute_canonical_nip01_event_id; -use crate::error::EventParseError; -use crate::knowledge::decode::{ - contribution_attestation_from_event, evidence_bounty_from_event, - knowledge_change_proposal_from_event, knowledge_claim_from_event, - knowledge_field_report_from_event, knowledge_relation_from_event, knowledge_review_from_event, - knowledge_source_from_event, wiki_article_from_event, wiki_merge_request_from_event, - wiki_redirect_from_event, +#[cfg(feature = "knowledge")] +pub use crate::knowledge::verification::{ + RadrootsContractValidatedEvent, RadrootsDecodeError, RadrootsDecodedEvent, + decode_validated_event, validate_event_contract, verify_and_decode_radroots_event, }; -use crate::parsed::RadrootsParsedEvent; #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsIdVerifiedEvent { @@ -59,39 +45,11 @@ impl RadrootsSignatureVerifiedEvent { } } -/// A NIP-01 verified event whose Radroots contract shape has been validated. -/// -/// This stage has checked contract-level kind, discriminator, content schema, -/// schema/schema_version markers where required, and tag cardinality/value -/// shape. It has not yet returned the typed payload semantics; those are -/// checked by `decode_validated_event`. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsContractValidatedEvent { - event: RadrootsEventEnvelope, - contract: &'static RadrootsEventContract, -} - -impl RadrootsContractValidatedEvent { - pub fn event(&self) -> &RadrootsEventEnvelope { - &self.event - } - - pub fn contract(&self) -> &'static RadrootsEventContract { - self.contract - } - - pub fn contract_id(&self) -> &'static str { - self.contract.id - } - - pub fn into_event(self) -> RadrootsEventEnvelope { - self.event - } -} - +#[non_exhaustive] #[derive(Clone, Debug, PartialEq, Eq)] pub enum RadrootsNip01VerificationError { MalformedEnvelope, + KindOutOfRange { kind: u32 }, IdMismatch { expected: String, actual: String }, SignatureInvalid, SignatureVerificationUnavailable, @@ -101,6 +59,7 @@ impl RadrootsNip01VerificationError { pub const fn code(&self) -> &'static str { match self { Self::MalformedEnvelope => "malformed_envelope", + Self::KindOutOfRange { .. } => "kind_out_of_range", Self::IdMismatch { .. } => "id_mismatch", Self::SignatureInvalid => "signature_invalid", Self::SignatureVerificationUnavailable => "signature_verification_unavailable", @@ -112,6 +71,9 @@ impl fmt::Display for RadrootsNip01VerificationError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::MalformedEnvelope => formatter.write_str("malformed NIP-01 event envelope"), + Self::KindOutOfRange { kind } => { + write!(formatter, "NIP-01 event kind {kind} exceeds {}", u16::MAX) + } Self::IdMismatch { expected, actual } => { write!( formatter, @@ -129,99 +91,6 @@ impl fmt::Display for RadrootsNip01VerificationError { #[cfg(feature = "std")] impl std::error::Error for RadrootsNip01VerificationError {} -#[derive(Debug)] -pub enum RadrootsDecodeError { - Nip01Verification(RadrootsNip01VerificationError), - ContractValidation(RadrootsContractValidationError), - EventParse(EventParseError), - UnsupportedContract { contract_id: String }, -} - -impl RadrootsDecodeError { - pub const fn code(&self) -> &'static str { - match self { - Self::Nip01Verification(_) => "nip01_verification", - Self::ContractValidation(_) => "contract_validation", - Self::EventParse(_) => "event_parse", - Self::UnsupportedContract { .. } => "unsupported_contract", - } - } -} - -impl fmt::Display for RadrootsDecodeError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Nip01Verification(error) => write!(formatter, "{error}"), - Self::ContractValidation(error) => { - write!( - formatter, - "contract validation failed with code {}", - error.code() - ) - } - Self::EventParse(error) => write!(formatter, "{error}"), - Self::UnsupportedContract { contract_id } => { - write!(formatter, "unsupported event contract `{contract_id}`") - } - } - } -} - -#[cfg(feature = "std")] -impl std::error::Error for RadrootsDecodeError {} - -impl From<EventParseError> for RadrootsDecodeError { - fn from(value: EventParseError) -> Self { - Self::EventParse(value) - } -} - -impl From<RadrootsNip01VerificationError> for RadrootsDecodeError { - fn from(value: RadrootsNip01VerificationError) -> Self { - Self::Nip01Verification(value) - } -} - -impl From<RadrootsContractValidationError> for RadrootsDecodeError { - fn from(value: RadrootsContractValidationError) -> Self { - Self::ContractValidation(value) - } -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Debug)] -pub enum RadrootsDecodedEvent { - WikiArticle(RadrootsParsedEvent<RadrootsWikiArticle>), - WikiRedirect(RadrootsParsedEvent<RadrootsWikiRedirect>), - WikiMergeRequest(RadrootsParsedEvent<RadrootsWikiMergeRequest>), - KnowledgeSource(RadrootsParsedEvent<RadrootsKnowledgeSource>), - KnowledgeClaim(RadrootsParsedEvent<RadrootsKnowledgeClaim>), - KnowledgeRelation(RadrootsParsedEvent<RadrootsKnowledgeRelation>), - KnowledgeReview(RadrootsParsedEvent<RadrootsKnowledgeReview>), - KnowledgeFieldReport(RadrootsParsedEvent<RadrootsKnowledgeFieldReport>), - EvidenceBounty(RadrootsParsedEvent<RadrootsEvidenceBounty>), - KnowledgeChangeProposal(RadrootsParsedEvent<RadrootsKnowledgeChangeProposal>), - ContributionAttestation(RadrootsParsedEvent<RadrootsContributionAttestation>), -} - -impl RadrootsDecodedEvent { - pub fn event(&self) -> &RadrootsEventEnvelope { - match self { - Self::WikiArticle(parsed) => &parsed.event, - Self::WikiRedirect(parsed) => &parsed.event, - Self::WikiMergeRequest(parsed) => &parsed.event, - Self::KnowledgeSource(parsed) => &parsed.event, - Self::KnowledgeClaim(parsed) => &parsed.event, - Self::KnowledgeRelation(parsed) => &parsed.event, - Self::KnowledgeReview(parsed) => &parsed.event, - Self::KnowledgeFieldReport(parsed) => &parsed.event, - Self::EvidenceBounty(parsed) => &parsed.event, - Self::KnowledgeChangeProposal(parsed) => &parsed.event, - Self::ContributionAttestation(parsed) => &parsed.event, - } - } -} - pub fn verify_event_id( event: RadrootsEventEnvelope, ) -> Result<RadrootsIdVerifiedEvent, RadrootsNip01VerificationError> { @@ -249,7 +118,6 @@ pub fn verify_event_id( pub fn verify_event_signature( event: RadrootsIdVerifiedEvent, ) -> Result<RadrootsSignatureVerifiedEvent, RadrootsNip01VerificationError> { - verify_event_id(event.event.clone())?; let raw_event = raw_event_from_radroots(&event.event)?; if raw_event.verify_signature() { Ok(RadrootsSignatureVerifiedEvent { event: event.event }) @@ -265,89 +133,11 @@ pub fn verify_event_signature( Err(RadrootsNip01VerificationError::SignatureVerificationUnavailable) } -/// Validate the Radroots event contract after NIP-01 id and signature checks. -/// -/// The successful result is `RadrootsContractValidatedEvent`, which preserves -/// the raw event plus the matched contract metadata. It means the event matched -/// a known Radroots contract shape, not that a typed domain payload has already -/// been returned. -pub fn validate_event_contract( - event: RadrootsSignatureVerifiedEvent, -) -> Result<RadrootsContractValidatedEvent, RadrootsContractValidationError> { - let contract = validate_radroots_event_contract(&event.event)?; - Ok(RadrootsContractValidatedEvent { - event: event.event, - contract, - }) -} - -/// Decode a contract-validated event into its typed Radroots event variant. -/// -/// This is the stage that turns `RadrootsContractValidatedEvent` into -/// `RadrootsDecodedEvent` and runs the typed decoder/semantic validation for -/// the matched contract. Unsupported contract ids still fail here, even after -/// the generic contract shape was valid. -pub fn decode_validated_event( - event: RadrootsContractValidatedEvent, -) -> Result<RadrootsDecodedEvent, RadrootsDecodeError> { - match event.contract.id { - "radroots.wiki.article.v1" => Ok(RadrootsDecodedEvent::WikiArticle( - wiki_article_from_event(event.event)?, - )), - "radroots.wiki.redirect.v1" => Ok(RadrootsDecodedEvent::WikiRedirect( - wiki_redirect_from_event(event.event)?, - )), - "radroots.wiki.merge_request.v1" => Ok(RadrootsDecodedEvent::WikiMergeRequest( - wiki_merge_request_from_event(event.event)?, - )), - "radroots.knowledge.source.v1" => Ok(RadrootsDecodedEvent::KnowledgeSource( - knowledge_source_from_event(event.event)?, - )), - "radroots.knowledge.claim.v1" => Ok(RadrootsDecodedEvent::KnowledgeClaim( - knowledge_claim_from_event(event.event)?, - )), - "radroots.knowledge.relation.v1" => Ok(RadrootsDecodedEvent::KnowledgeRelation( - knowledge_relation_from_event(event.event)?, - )), - "radroots.knowledge.review.v1" => Ok(RadrootsDecodedEvent::KnowledgeReview( - knowledge_review_from_event(event.event)?, - )), - "radroots.knowledge.field_report.v1" => Ok(RadrootsDecodedEvent::KnowledgeFieldReport( - knowledge_field_report_from_event(event.event)?, - )), - "radroots.knowledge.evidence_bounty.v1" => Ok(RadrootsDecodedEvent::EvidenceBounty( - evidence_bounty_from_event(event.event)?, - )), - "radroots.knowledge.change_proposal.v1" => { - Ok(RadrootsDecodedEvent::KnowledgeChangeProposal( - knowledge_change_proposal_from_event(event.event)?, - )) - } - "radroots.knowledge.contribution_attestation.v1" => { - Ok(RadrootsDecodedEvent::ContributionAttestation( - contribution_attestation_from_event(event.event)?, - )) - } - contract_id => Err(RadrootsDecodeError::UnsupportedContract { - contract_id: contract_id.to_string(), - }), - } -} - -/// Verify NIP-01 identity, validate the Radroots contract, and decode the event. -/// -/// The pipeline is: -/// `RadrootsEventEnvelope -> verify_event_id -> RadrootsIdVerifiedEvent -> -/// verify_event_signature -> RadrootsSignatureVerifiedEvent -> -/// validate_event_contract -> RadrootsContractValidatedEvent -> -/// decode_validated_event -> RadrootsDecodedEvent`. -pub fn verify_and_decode_radroots_event( +/// Verifies the canonical NIP-01 identifier and Schnorr signature in order. +pub fn verify_nip01_event( event: RadrootsEventEnvelope, -) -> Result<RadrootsDecodedEvent, RadrootsDecodeError> { - let id_verified = verify_event_id(event)?; - let signature_verified = verify_event_signature(id_verified)?; - let contract_validated = validate_event_contract(signature_verified)?; - decode_validated_event(contract_validated) +) -> Result<RadrootsSignatureVerifiedEvent, RadrootsNip01VerificationError> { + verify_event_signature(verify_event_id(event)?) } #[cfg(feature = "nostr")] @@ -358,8 +148,11 @@ fn raw_event_from_radroots( .map_err(|_| RadrootsNip01VerificationError::MalformedEnvelope)?; let public_key = nostr::PublicKey::from_hex(event.author_str()) .map_err(|_| RadrootsNip01VerificationError::MalformedEnvelope)?; - let kind_u16 = u16::try_from(event.kind_u32()) - .map_err(|_| RadrootsNip01VerificationError::MalformedEnvelope)?; + let kind = u16::try_from(event.kind_u32()).map_err(|_| { + RadrootsNip01VerificationError::KindOutOfRange { + kind: event.kind_u32(), + } + })?; let tags_vec = event.tags_as_vec(); let mut tags = Vec::with_capacity(tags_vec.len()); for tag in tags_vec { @@ -374,9 +167,95 @@ fn raw_event_from_radroots( id, public_key, nostr::Timestamp::from_secs(event.created_at_u64()), - nostr::Kind::Custom(kind_u16), + nostr::Kind::Custom(kind), tags, event.content().to_string(), sig, )) } + +#[cfg(test)] +mod tests { + use super::*; + use radroots_event::RadrootsEventEnvelopeParts; + + #[test] + fn id_verification_returns_the_exact_envelope() { + let event = signed_max_kind_event(); + let verified = verify_event_id(event.clone()).expect("canonical event id"); + + assert_eq!(verified.event(), &event); + assert_eq!(verified.into_event(), event); + } + + #[cfg(feature = "nostr")] + #[test] + fn signature_verification_returns_the_exact_envelope() { + let event = signed_max_kind_event(); + let verified = verify_nip01_event(event.clone()).expect("valid Schnorr signature"); + + assert_eq!(verified.event(), &event); + assert_eq!(verified.into_event(), event); + } + + #[cfg(not(feature = "nostr"))] + #[test] + fn signature_verification_reports_unavailable_without_nostr() { + let event = verify_event_id(signed_max_kind_event()).expect("canonical event id"); + + assert_eq!( + verify_event_signature(event), + Err(RadrootsNip01VerificationError::SignatureVerificationUnavailable) + ); + } + + #[test] + fn verification_error_codes_are_stable() { + let errors = [ + ( + RadrootsNip01VerificationError::MalformedEnvelope, + "malformed_envelope", + ), + ( + RadrootsNip01VerificationError::KindOutOfRange { kind: 65_536 }, + "kind_out_of_range", + ), + ( + RadrootsNip01VerificationError::IdMismatch { + expected: "expected".to_string(), + actual: "actual".to_string(), + }, + "id_mismatch", + ), + ( + RadrootsNip01VerificationError::SignatureInvalid, + "signature_invalid", + ), + ( + RadrootsNip01VerificationError::SignatureVerificationUnavailable, + "signature_verification_unavailable", + ), + ]; + + for (error, expected) in errors { + assert_eq!(error.code(), expected); + assert!(!error.to_string().is_empty()); + } + } + + fn signed_max_kind_event() -> RadrootsEventEnvelope { + RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { + id: "a07878757d705d3cd848b9264791d699069068a5f0a575112f351367b0987958" + .to_string(), + author: "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f" + .to_string(), + created_at: 1_800_000_104, + kind: u32::from(u16::MAX), + tags: Vec::new(), + content: "maximum-kind".to_string(), + sig: "d79b19843a0bfd769c02c73866d44a3a06f7b11e107a5257971b60e700aa25565802fd3a7eed4042fe8db7d709a465e5f61478eb8291178831bf48f6b0980671" + .to_string(), + }) + .expect("valid event envelope") + } +} diff --git a/crates/event_codec/tests/codec_error_job.rs b/crates/event_codec/tests/codec_error_job.rs @@ -146,6 +146,10 @@ fn job_encode_error_display_covers_variants() { #[test] fn job_parse_error_display_and_source_covers_variants() { + let kind = JobParseError::KindOutOfRange(u32::from(u16::MAX) + 1); + assert!(kind.to_string().contains("Nostr event kind")); + assert!(kind.source().is_none()); + let missing = JobParseError::MissingTag("e"); assert_eq!(missing.to_string(), "missing tag: e"); assert!(missing.source().is_none()); diff --git a/crates/event_codec/tests/fixtures/profile_verified_event.v1.json b/crates/event_codec/tests/fixtures/profile_verified_event.v1.json @@ -0,0 +1,100 @@ +{ + "contract_version": "1.0.0", + "suite": "verified_profile_event", + "vectors": [ + { + "expected": { + "event_id": "a07878757d705d3cd848b9264791d699069068a5f0a575112f351367b0987958", + "kind": 65535 + }, + "id": "event_verify_max_kind_001", + "input": { + "event_json": "{\"id\":\"a07878757d705d3cd848b9264791d699069068a5f0a575112f351367b0987958\",\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"created_at\":1800000104,\"kind\":65535,\"tags\":[],\"content\":\"maximum-kind\",\"sig\":\"d79b19843a0bfd769c02c73866d44a3a06f7b11e107a5257971b60e700aa25565802fd3a7eed4042fe8db7d709a465e5f61478eb8291178831bf48f6b0980671\"}" + }, + "kind": "event.verify_nip01.valid" + }, + { + "expected": { + "error": "id_mismatch" + }, + "id": "event_verify_id_mismatch_001", + "input": { + "event_json": "{\"content\":\"{\\\"display_name\\\":\\\"Moss Street Farm\\\",\\\"bot\\\":false,\\\"website\\\":\\\"https://mossstreet.example\\\",\\\"picture\\\":42}\",\"created_at\":1800000100,\"id\":\"fdcebf1cbdcddea8027b5e214cb7b223fa662d8b85f5d68acf2e8849be0d71c4\",\"kind\":0,\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"sig\":\"e5448d11671bcf73aa8d56941aff9df46d4e9fb250596671950e5f3c9d747440523efd33d8b9ff4f2a2ef1bd4b79e0a7cf5850132172b1db4b642ef2b348f721\",\"tags\":[]}" + }, + "kind": "event.verify_nip01.invalid_id" + }, + { + "expected": { + "error": "signature_invalid" + }, + "id": "event_verify_signature_invalid_001", + "input": { + "event_json": "{\"content\":\"{\\\"display_name\\\":\\\"Moss Street Farm\\\",\\\"bot\\\":false,\\\"website\\\":\\\"https://mossstreet.example\\\",\\\"picture\\\":42}\",\"created_at\":1800000100,\"id\":\"b0450c4fb0a82cc829159646f90dc548503e8b7f850a434fd9ea58bf1b8d677f\",\"kind\":0,\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"sig\":\"d015e365c530c44fbc33e970af13616e482fafe65d0947edcdaa1e7b3038e9285b4b6826bdf454e8381d66e9dc6978d9974358572d97850ebc726fcc539e2b88\",\"tags\":[]}" + }, + "kind": "event.verify_nip01.invalid_signature" + }, + { + "expected": { + "error": "kind_out_of_range", + "kind": 65536 + }, + "id": "event_verify_kind_overflow_001", + "input": { + "event_json": "{\"content\":\"overflow-kind\",\"created_at\":1800000106,\"id\":\"82dff74958bd7e1e52ad98add08bbb70d4ce9cf9d90e4741c64c212df0804ccb\",\"kind\":65536,\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"sig\":\"00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\",\"tags\":[]}" + }, + "kind": "event.verify_nip01.kind_overflow" + }, + { + "expected": { + "event_id": "b0450c4fb0a82cc829159646f90dc548503e8b7f850a434fd9ea58bf1b8d677f", + "projected": { + "bot": false, + "display_name": "Moss Street Farm" + }, + "residual_fields": { + "picture": 42, + "website": "https://mossstreet.example" + }, + "tags": [] + }, + "id": "profile_admit_tagless_tolerant_001", + "input": { + "event_json": "{\"id\":\"b0450c4fb0a82cc829159646f90dc548503e8b7f850a434fd9ea58bf1b8d677f\",\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"created_at\":1800000100,\"kind\":0,\"tags\":[],\"content\":\"{\\\"display_name\\\":\\\"Moss Street Farm\\\",\\\"bot\\\":false,\\\"website\\\":\\\"https://mossstreet.example\\\",\\\"picture\\\":42}\",\"sig\":\"e5448d11671bcf73aa8d56941aff9df46d4e9fb250596671950e5f3c9d747440523efd33d8b9ff4f2a2ef1bd4b79e0a7cf5850132172b1db4b642ef2b348f721\"}" + }, + "kind": "profile.verify_and_admit.valid" + }, + { + "expected": { + "actual": 1, + "error": "invalid_kind" + }, + "id": "profile_admit_wrong_kind_001", + "input": { + "event_json": "{\"id\":\"05b2ebdf444f09330198f8c07ea3e3c7d8ac97d94c41e957b74bce921f611080\",\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"created_at\":1800000102,\"kind\":1,\"tags\":[],\"content\":\"{\\\"name\\\":\\\"not-a-profile\\\"}\",\"sig\":\"3d12c2dfea5cff7e9993166d20b9092f4a6a9dd7e9f4af26355d2f4c4fa0e1ba7a0e5fe6e4b90c93ae57d52cc71b38b409051702200868dfd601dbdd74b89710\"}" + }, + "kind": "profile.verify_and_admit.invalid_kind" + }, + { + "expected": { + "error": "root_not_object" + }, + "id": "profile_admit_non_object_001", + "input": { + "event_json": "{\"id\":\"671f2d9f80a2fa2759c9514ae36fd18296cee71417fc86995525f2c659a5f781\",\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"created_at\":1800000103,\"kind\":0,\"tags\":[],\"content\":\"[]\",\"sig\":\"67d0420384e366407499ce4ab501cd87a02bea0d4221007b7a3ff01d9b68a0b9d372882184ab48775a8516f6a6554b506fc3e7652162a53e08a43e63daeacac7\"}" + }, + "kind": "profile.verify_and_admit.invalid_metadata" + }, + { + "expected": { + "created_at": 1800000105, + "event_id": "ad00fc208cc7036b95fadb7a4660bc600aa007502430250630d70d15b50695b3" + }, + "id": "profile_equal_time_lowest_id_001", + "input": { + "first_event_json": "{\"id\":\"fdcebf1cbdcddea8027b5e214cb7b223fa662d8b85f5d68acf2e8849be0d71c4\",\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"created_at\":1800000105,\"kind\":0,\"tags\":[],\"content\":\"{\\\"name\\\":\\\"arrival-a\\\"}\",\"sig\":\"445a4a08bd365acbe3d68b1433cd8952f8974cb1f454c7d6790881b123c1e7a6449662f78e3044436c7cda8745ae74aa8ee382e292160a3a3f420dd3bb4f275c\"}", + "second_event_json": "{\"id\":\"ad00fc208cc7036b95fadb7a4660bc600aa007502430250630d70d15b50695b3\",\"pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"created_at\":1800000105,\"kind\":0,\"tags\":[],\"content\":\"{\\\"name\\\":\\\"arrival-b\\\"}\",\"sig\":\"6b52cb1cbcf2639104b564144517d8479833272690514af9e9d45b170a1c88320ac3425b9339aa525e591584f97666b1c556b4709fb6ef27959775e70c8caabe\"}" + }, + "kind": "profile.select_equal_time_head" + } + ] +} diff --git a/crates/event_codec/tests/job_feedback.rs b/crates/event_codec/tests/job_feedback.rs @@ -10,7 +10,7 @@ use radroots_event_codec::job::feedback::encode::to_wire_parts; fn sample_feedback() -> RadrootsJobFeedback { RadrootsJobFeedback { - kind: KIND_JOB_FEEDBACK as u16, + kind: u16::try_from(KIND_JOB_FEEDBACK).expect("feedback kind must fit NIP-01"), status: JobFeedbackStatus::Processing, extra_info: Some("queued".to_string()), request_event: common::event_ptr("req", Some("wss://relay")), @@ -35,6 +35,15 @@ fn job_feedback_roundtrip_from_tags() { } #[test] +fn job_feedback_from_tags_rejects_kind_overflow() { + let kind = u32::from(u16::MAX) + 1; + assert!(matches!( + job_feedback_from_tags(kind, &[], ""), + Err(JobParseError::KindOutOfRange(actual)) if actual == kind + )); +} + +#[test] fn job_feedback_from_tags_accepts_e_ref_and_empty_content() { let tags = vec![ vec![ @@ -53,7 +62,7 @@ fn job_feedback_from_tags_accepts_e_ref_and_empty_content() { #[test] fn job_feedback_requires_valid_kind() { let mut fb = sample_feedback(); - fb.kind = KIND_JOB_RESULT_MIN as u16; + fb.kind = u16::try_from(KIND_JOB_RESULT_MIN).expect("result kind must fit NIP-01"); let err = to_wire_parts(&fb, "payload").unwrap_err(); assert!(matches!( diff --git a/crates/event_codec/tests/job_request.rs b/crates/event_codec/tests/job_request.rs @@ -12,7 +12,7 @@ use test_fixtures::{APP_PRIMARY_HTTPS, RELAY_PRIMARY_WSS}; fn sample_request() -> RadrootsJobRequest { RadrootsJobRequest { - kind: (KIND_JOB_REQUEST_MIN + 1) as u16, + kind: u16::try_from(KIND_JOB_REQUEST_MIN + 1).expect("request kind must fit NIP-01"), inputs: vec![RadrootsJobInput { data: APP_PRIMARY_HTTPS.to_string(), input_type: JobInputType::Url, @@ -42,9 +42,18 @@ fn job_request_roundtrip_from_tags() { } #[test] +fn job_request_from_tags_rejects_kind_overflow() { + let kind = u32::from(u16::MAX) + 1; + assert!(matches!( + job_request_from_tags(kind, &[]), + Err(JobParseError::KindOutOfRange(actual)) if actual == kind + )); +} + +#[test] fn job_request_requires_valid_kind() { let mut req = sample_request(); - req.kind = KIND_JOB_FEEDBACK as u16; + req.kind = u16::try_from(KIND_JOB_FEEDBACK).expect("feedback kind must fit NIP-01"); let err = to_wire_parts(&req, "payload").unwrap_err(); assert!(matches!( diff --git a/crates/event_codec/tests/job_result.rs b/crates/event_codec/tests/job_result.rs @@ -14,7 +14,7 @@ use test_fixtures::{APP_PRIMARY_HTTPS, RELAY_PRIMARY_WSS, RELAY_SECONDARY_WSS}; fn sample_result() -> RadrootsJobResult { RadrootsJobResult { - kind: (KIND_JOB_RESULT_MIN + 1) as u16, + kind: u16::try_from(KIND_JOB_RESULT_MIN + 1).expect("result kind must fit NIP-01"), request_event: common::event_ptr("req", Some(RELAY_PRIMARY_WSS)), request_json: Some("{\"foo\":\"bar\"}".to_string()), inputs: vec![RadrootsJobInput { @@ -44,6 +44,15 @@ fn job_result_roundtrip_from_tags() { } #[test] +fn job_result_from_tags_rejects_kind_overflow() { + let kind = u32::from(u16::MAX) + 1; + assert!(matches!( + job_result_from_tags(kind, &[], ""), + Err(JobParseError::KindOutOfRange(actual)) if actual == kind + )); +} + +#[test] fn job_result_roundtrip_with_empty_content_sets_none() { let res = sample_result(); let parts = to_wire_parts(&res, "").unwrap(); @@ -69,7 +78,7 @@ fn job_result_roundtrip_preserves_input_relay_and_marker() { #[test] fn job_result_requires_valid_kind() { let mut res = sample_result(); - res.kind = KIND_JOB_REQUEST_MIN as u16; + res.kind = u16::try_from(KIND_JOB_REQUEST_MIN).expect("request kind must fit NIP-01"); let err = to_wire_parts(&res, "payload").unwrap_err(); assert!(matches!( diff --git a/crates/event_codec/tests/job_traits.rs b/crates/event_codec/tests/job_traits.rs @@ -35,7 +35,7 @@ fn event_envelope(kind: u32, tags: Vec<Vec<String>>, content: &str) -> RadrootsE fn sample_request() -> RadrootsJobRequest { RadrootsJobRequest { - kind: (KIND_JOB_REQUEST_MIN + 1) as u16, + kind: u16::try_from(KIND_JOB_REQUEST_MIN + 1).expect("request kind must fit NIP-01"), inputs: vec![RadrootsJobInput { data: "hello".to_string(), input_type: JobInputType::Text, @@ -75,7 +75,7 @@ fn borrowed_event_adapter_builds_request_metadata() { fn sample_result() -> RadrootsJobResult { RadrootsJobResult { - kind: (KIND_JOB_RESULT_MIN + 1) as u16, + kind: u16::try_from(KIND_JOB_RESULT_MIN + 1).expect("result kind must fit NIP-01"), request_event: radroots_event::RadrootsEventPtr { id: "req".to_string(), relays: Some(RELAY_PRIMARY_WSS.to_string()), @@ -99,7 +99,7 @@ fn sample_result() -> RadrootsJobResult { fn sample_feedback() -> RadrootsJobFeedback { RadrootsJobFeedback { - kind: KIND_JOB_FEEDBACK as u16, + kind: u16::try_from(KIND_JOB_FEEDBACK).expect("feedback kind must fit NIP-01"), status: JobFeedbackStatus::Processing, extra_info: Some("processing".to_string()), request_event: radroots_event::RadrootsEventPtr { diff --git a/crates/event_codec/tests/knowledge.rs b/crates/event_codec/tests/knowledge.rs @@ -184,7 +184,8 @@ fn sign_parts(parts: RadrootsNip01EventWireParts) -> RadrootsEventEnvelope { let keys = nostr::Keys::parse("0101010101010101010101010101010101010101010101010101010101010101") .expect("keys"); - let event = nostr::EventBuilder::new(nostr::Kind::Custom(parts.kind as u16), parts.content) + let kind = u16::try_from(parts.kind).expect("knowledge event kind must fit NIP-01"); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(kind), parts.content) .tags(tags) .custom_created_at(nostr::Timestamp::from_secs(1_800_000_000)) .sign_with_keys(&keys) diff --git a/crates/event_codec/tests/knowledge_fixtures.rs b/crates/event_codec/tests/knowledge_fixtures.rs @@ -59,7 +59,8 @@ fn sign_parts(parts: RadrootsNip01EventWireParts) -> RadrootsEventEnvelope { let keys = nostr::Keys::parse("0101010101010101010101010101010101010101010101010101010101010101") .expect("keys"); - let event = nostr::EventBuilder::new(nostr::Kind::Custom(parts.kind as u16), parts.content) + let kind = u16::try_from(parts.kind).expect("knowledge event kind must fit NIP-01"); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(kind), parts.content) .tags(tags) .custom_created_at(nostr::Timestamp::from_secs(1_800_000_000)) .sign_with_keys(&keys) diff --git a/crates/event_codec/tests/tag_builders.rs b/crates/event_codec/tests/tag_builders.rs @@ -357,7 +357,7 @@ fn event_tag_builder_impls_build_tags_for_all_supported_types() { assert!(!plot.build_tags().unwrap().is_empty()); let job_request = RadrootsJobRequest { - kind: (KIND_JOB_REQUEST_MIN + 1) as u16, + kind: u16::try_from(KIND_JOB_REQUEST_MIN + 1).expect("request kind must fit NIP-01"), inputs: vec![RadrootsJobInput { data: "hello".to_string(), input_type: JobInputType::Text, @@ -378,7 +378,7 @@ fn event_tag_builder_impls_build_tags_for_all_supported_types() { assert!(!job_request.build_tags().unwrap().is_empty()); let job_result = RadrootsJobResult { - kind: (KIND_JOB_RESULT_MIN + 1) as u16, + kind: u16::try_from(KIND_JOB_RESULT_MIN + 1).expect("result kind must fit NIP-01"), request_event: RadrootsEventPtr { id: "req".to_string(), relays: Some(RELAY_PRIMARY_WSS.to_string()), @@ -401,7 +401,7 @@ fn event_tag_builder_impls_build_tags_for_all_supported_types() { assert!(!job_result.build_tags().unwrap().is_empty()); let job_feedback = RadrootsJobFeedback { - kind: KIND_JOB_FEEDBACK as u16, + kind: u16::try_from(KIND_JOB_FEEDBACK).expect("feedback kind must fit NIP-01"), status: JobFeedbackStatus::Processing, extra_info: Some("queued".to_string()), request_event: RadrootsEventPtr { @@ -674,7 +674,7 @@ fn listing_builder_rejects_required_field_errors() { #[test] fn job_request_tag_builder_rejects_encrypted_without_provider() { let request = RadrootsJobRequest { - kind: (KIND_JOB_REQUEST_MIN + 1) as u16, + kind: u16::try_from(KIND_JOB_REQUEST_MIN + 1).expect("request kind must fit NIP-01"), inputs: vec![RadrootsJobInput { data: "hello".to_string(), input_type: JobInputType::Text, @@ -696,7 +696,7 @@ fn job_request_tag_builder_rejects_encrypted_without_provider() { #[test] fn job_request_tag_builder_accepts_encrypted_with_provider() { let request = RadrootsJobRequest { - kind: (KIND_JOB_REQUEST_MIN + 1) as u16, + kind: u16::try_from(KIND_JOB_REQUEST_MIN + 1).expect("request kind must fit NIP-01"), inputs: vec![RadrootsJobInput { data: "hello".to_string(), input_type: JobInputType::Text, diff --git a/crates/event_codec/tests/verified_profile_conformance.rs b/crates/event_codec/tests/verified_profile_conformance.rs @@ -0,0 +1,287 @@ +#![cfg(all(feature = "serde_json", feature = "nostr"))] + +use std::{borrow::Cow, fs, path::Path}; + +use radroots_event::event_head::{ + RadrootsCurrentEventHead, RadrootsEventHeadCandidate, RadrootsEventHeadCandidateResult, + RadrootsEventHeadDecision, event_head_candidate_for_event, select_event_head, +}; +use radroots_event::{RadrootsEventEnvelope, RadrootsEventEnvelopeParts, RadrootsNip01EventWire}; +use radroots_event_codec::profile::admission::{ + RadrootsAdmittedProfileEvent, RadrootsProfileAdmissionError, verify_and_admit_profile_event, +}; +use radroots_event_codec::profile::inbound::RadrootsProfileMetadataParseError; +use radroots_event_codec::verification::{RadrootsNip01VerificationError, verify_nip01_event}; +use serde::Deserialize; +use serde_json::{Map, Value}; + +const PACKAGED_VECTORS: &str = include_str!("fixtures/profile_verified_event.v1.json"); +const WORKSPACE_VECTOR_PATH: &str = + "../../contracts/conformance/vectors/profile/verified_event.v1.json"; +const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml"; + +#[derive(Debug, Deserialize)] +struct Suite { + suite: String, + contract_version: String, + vectors: Vec<Vector>, +} + +#[derive(Debug, Deserialize)] +struct Vector { + id: String, + kind: String, + input: Value, + expected: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawEvent { + id: String, + pubkey: String, + created_at: u64, + kind: u32, + tags: Vec<Vec<String>>, + content: String, + sig: String, +} + +#[test] +fn raw_signed_vectors_execute_against_verified_event_and_profile_boundaries() { + let vectors = conformance_vectors(); + let suite: Suite = serde_json::from_str(&vectors).expect("verified Profile vectors must parse"); + assert_eq!(suite.suite, "verified_profile_event"); + assert_eq!(suite.contract_version, "1.0.0"); + assert!(!suite.vectors.is_empty()); + + for vector in &suite.vectors { + execute(vector); + } +} + +fn conformance_vectors() -> Cow<'static, str> { + let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(WORKSPACE_VECTOR_PATH); + match fs::read_to_string(&workspace_path) { + Ok(canonical) => { + assert_eq!( + canonical, + PACKAGED_VECTORS, + "packaged verified Profile vectors must match {}", + workspace_path.display() + ); + Cow::Owned(canonical) + } + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + && !Path::new(env!("CARGO_MANIFEST_DIR")) + .join(WORKSPACE_CONTRACT_MARKER_PATH) + .is_file() => + { + Cow::Borrowed(PACKAGED_VECTORS) + } + Err(error) => panic!("failed to read {}: {error}", workspace_path.display()), + } +} + +fn execute(vector: &Vector) { + match vector.kind.as_str() { + "event.verify_nip01.valid" => verify_valid(vector), + "event.verify_nip01.invalid_id" => verify_invalid_id(vector), + "event.verify_nip01.invalid_signature" => verify_invalid_signature(vector), + "event.verify_nip01.kind_overflow" => verify_kind_overflow(vector), + "profile.verify_and_admit.valid" => profile_admit_valid(vector), + "profile.verify_and_admit.invalid_kind" => profile_admit_invalid_kind(vector), + "profile.verify_and_admit.invalid_metadata" => profile_admit_invalid_metadata(vector), + "profile.select_equal_time_head" => profile_select_equal_time_head(vector), + kind => panic!("{} uses unsupported vector kind {kind}", vector.id), + } +} + +fn verify_valid(vector: &Vector) { + let verified = verify_nip01_event(canonical_envelope(input_str(vector, "event_json"))) + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); + assert_eq!( + verified.event().id_str(), + expected_str(vector, "event_id"), + "{}", + vector.id + ); + assert_eq!( + u64::from(verified.event().kind_u32()), + vector.expected["kind"].as_u64().expect("expected.kind"), + "{}", + vector.id + ); +} + +fn verify_invalid_id(vector: &Vector) { + let error = verify_nip01_event(unchecked_id_envelope(input_str(vector, "event_json"))) + .expect_err("mismatched event id must fail"); + assert_eq!(error.code(), expected_str(vector, "error")); + assert!(matches!( + error, + RadrootsNip01VerificationError::IdMismatch { .. } + )); +} + +fn verify_invalid_signature(vector: &Vector) { + let error = verify_nip01_event(canonical_envelope(input_str(vector, "event_json"))) + .expect_err("invalid event signature must fail"); + assert_eq!(error.code(), expected_str(vector, "error")); + assert_eq!(error, RadrootsNip01VerificationError::SignatureInvalid); +} + +fn verify_kind_overflow(vector: &Vector) { + let error = verify_nip01_event(canonical_envelope(input_str(vector, "event_json"))) + .expect_err("out-of-range event kind must fail"); + assert_eq!(error.code(), expected_str(vector, "error")); + assert!(matches!( + error, + RadrootsNip01VerificationError::KindOutOfRange { kind } + if u64::from(kind) == vector.expected["kind"].as_u64().expect("expected.kind") + )); +} + +fn profile_admit_valid(vector: &Vector) { + let admitted = admitted_event(input_str(vector, "event_json"), &vector.id); + assert_eq!(admitted.event().id_str(), expected_str(vector, "event_id")); + assert_eq!( + admitted.verified_event().event().id_str(), + expected_str(vector, "event_id") + ); + assert_eq!( + serde_json::to_value(admitted.event().tags_as_vec()).expect("tags"), + vector.expected["tags"] + ); + assert_eq!(projected_metadata(&admitted), vector.expected["projected"]); + assert_eq!( + serde_json::to_value(admitted.metadata().residual_fields()).expect("residual fields"), + vector.expected["residual_fields"] + ); + let (verified, metadata) = admitted.into_parts(); + assert_eq!(verified.event().id_str(), expected_str(vector, "event_id")); + assert!(metadata.name().is_none()); +} + +fn profile_admit_invalid_kind(vector: &Vector) { + let error = verify_and_admit_profile_event(canonical_envelope(input_str(vector, "event_json"))) + .expect_err("verified non-Profile kind must fail admission"); + assert_eq!(error.code(), expected_str(vector, "error")); + assert!(matches!( + error, + RadrootsProfileAdmissionError::InvalidKind { expected: 0, actual } + if u64::from(actual) == vector.expected["actual"].as_u64().expect("expected.actual") + )); +} + +fn profile_admit_invalid_metadata(vector: &Vector) { + let error = verify_and_admit_profile_event(canonical_envelope(input_str(vector, "event_json"))) + .expect_err("verified Profile with non-object content must fail admission"); + assert_eq!(error.code(), expected_str(vector, "error")); + assert_eq!( + error, + RadrootsProfileAdmissionError::Metadata(RadrootsProfileMetadataParseError::RootNotObject) + ); +} + +fn profile_select_equal_time_head(vector: &Vector) { + let first = admitted_event(input_str(vector, "first_event_json"), &vector.id); + let second = admitted_event(input_str(vector, "second_event_json"), &vector.id); + assert_eq!( + first.event().created_at_u64(), + second.event().created_at_u64() + ); + assert_eq!( + selected_event_id(&first, &second), + expected_str(vector, "event_id") + ); + assert_eq!( + selected_event_id(&second, &first), + expected_str(vector, "event_id") + ); + assert_eq!( + first.event().created_at_u64(), + vector.expected["created_at"] + .as_u64() + .expect("expected.created_at") + ); +} + +fn selected_event_id( + current: &RadrootsAdmittedProfileEvent, + candidate: &RadrootsAdmittedProfileEvent, +) -> String { + let current: RadrootsCurrentEventHead = head_candidate(current).into(); + match select_event_head(head_candidate(candidate), Some(&current)) { + RadrootsEventHeadDecision::Applied(head) => head.event_id.into_string(), + RadrootsEventHeadDecision::SkippedDuplicate + | RadrootsEventHeadDecision::SkippedOlder + | RadrootsEventHeadDecision::SkippedSameTimestampHigherEventId => { + current.event_id.into_string() + } + RadrootsEventHeadDecision::CoordinateMismatch => { + panic!("admitted Profile events from one author must share a coordinate") + } + } +} + +fn head_candidate(admitted: &RadrootsAdmittedProfileEvent) -> RadrootsEventHeadCandidate { + match event_head_candidate_for_event(admitted.event()).expect("Profile contract") { + RadrootsEventHeadCandidateResult::Candidate(candidate) => candidate, + other => panic!("admitted Profile must be a replaceable head candidate: {other:?}"), + } +} + +fn admitted_event(raw_json: &str, vector_id: &str) -> RadrootsAdmittedProfileEvent { + verify_and_admit_profile_event(canonical_envelope(raw_json)) + .unwrap_or_else(|error| panic!("{vector_id} failed: {error}")) +} + +fn canonical_envelope(raw_json: &str) -> RadrootsEventEnvelope { + RadrootsNip01EventWire::parse_json(raw_json) + .expect("canonical raw event") + .into_envelope() + .expect("event envelope") +} + +fn unchecked_id_envelope(raw_json: &str) -> RadrootsEventEnvelope { + let raw: RawEvent = serde_json::from_str(raw_json).expect("raw event"); + RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { + id: raw.id, + author: raw.pubkey, + created_at: raw.created_at, + kind: raw.kind, + tags: raw.tags, + content: raw.content, + sig: raw.sig, + }) + .expect("unchecked-id envelope") +} + +fn projected_metadata(admitted: &RadrootsAdmittedProfileEvent) -> Value { + let metadata = admitted.metadata(); + let mut projected = Map::new(); + if let Some(value) = metadata.name() { + projected.insert("name".to_string(), Value::String(value.to_string())); + } + if let Some(value) = metadata.display_name() { + projected.insert("display_name".to_string(), Value::String(value.to_string())); + } + if let Some(value) = metadata.bot() { + projected.insert("bot".to_string(), Value::Bool(value)); + } + Value::Object(projected) +} + +fn input_str<'a>(vector: &'a Vector, field: &str) -> &'a str { + vector.input[field] + .as_str() + .unwrap_or_else(|| panic!("{} input.{field} must be a string", vector.id)) +} + +fn expected_str<'a>(vector: &'a Vector, field: &str) -> &'a str { + vector.expected[field] + .as_str() + .unwrap_or_else(|| panic!("{} expected.{field} must be a string", vector.id)) +} diff --git a/crates/nostr/src/error.rs b/crates/nostr/src/error.rs @@ -2,6 +2,9 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum RadrootsNostrError { + #[error("Nostr event kind {kind} exceeds {max}")] + KindOutOfRange { kind: u32, max: u16 }, + #[cfg(feature = "client")] #[error("Client error: {0}")] ClientError(#[from] nostr_sdk::client::Error), diff --git a/crates/nostr/src/events/application_handler.rs b/crates/nostr/src/events/application_handler.rs @@ -127,10 +127,16 @@ async fn fetch_existing_identifier( .kinds .first() .ok_or_else(|| RadrootsNostrError::FilterTagError("kinds are empty".to_string()))?; + let application_handler_kind = u16::try_from(KIND_APPLICATION_HANDLER).map_err(|_| { + RadrootsNostrError::KindOutOfRange { + kind: KIND_APPLICATION_HANDLER, + max: u16::MAX, + } + })?; let author = client.public_key().await?; let filter = RadrootsNostrFilter::new() .author(author) - .kind(RadrootsNostrKind::Custom(KIND_APPLICATION_HANDLER as u16)); + .kind(RadrootsNostrKind::Custom(application_handler_kind)); let filter = radroots_nostr_filter_tag(filter, "k", vec![first_kind.to_string()])?; let mut events = client.fetch_events(filter, Duration::from_secs(5)).await?; events.sort_by_key(|event| event.created_at.as_secs()); diff --git a/crates/nostr/src/events/mod.rs b/crates/nostr/src/events/mod.rs @@ -17,6 +17,10 @@ pub fn radroots_nostr_build_event( content: impl Into<String>, tag_slices: Vec<Vec<String>>, ) -> Result<RadrootsNostrEventBuilder, RadrootsNostrError> { + let kind = u16::try_from(kind_u32).map_err(|_| RadrootsNostrError::KindOutOfRange { + kind: kind_u32, + max: u16::MAX, + })?; let mut tags: Vec<RadrootsNostrTag> = Vec::new(); for mut s in tag_slices { if s.is_empty() { @@ -29,16 +33,16 @@ pub fn radroots_nostr_build_event( values, )); } - let builder = - RadrootsNostrEventBuilder::new(RadrootsNostrKind::Custom(kind_u32 as u16), content.into()) - .tags(tags) - .allow_self_tagging(); + let builder = RadrootsNostrEventBuilder::new(RadrootsNostrKind::Custom(kind), content.into()) + .tags(tags) + .allow_self_tagging(); Ok(builder) } #[cfg(test)] mod tests { use super::radroots_nostr_build_event; + use crate::error::RadrootsNostrError; use crate::test_fixtures::FIXTURE_ALICE_PUBLIC_KEY_HEX; use crate::types::{RadrootsNostrPublicKey, RadrootsNostrTagKind}; @@ -65,4 +69,25 @@ mod tests { }); assert!(!has_other_self_tag); } + + #[test] + fn build_event_accepts_maximum_nip01_kind() { + let builder = radroots_nostr_build_event(u32::from(u16::MAX), "test", Vec::new()) + .expect("maximum NIP-01 kind"); + let event = builder + .build(RadrootsNostrPublicKey::from_hex(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("pubkey")); + assert_eq!(event.kind.as_u16(), u16::MAX); + } + + #[test] + fn build_event_rejects_kind_overflow() { + let kind = u32::from(u16::MAX) + 1; + assert!(matches!( + radroots_nostr_build_event(kind, "test", Vec::new()), + Err(RadrootsNostrError::KindOutOfRange { + kind: actual, + max: u16::MAX + }) if actual == kind + )); + } } diff --git a/crates/nostr/src/nip17.rs b/crates/nostr/src/nip17.rs @@ -79,21 +79,17 @@ fn rumor_from_parts( parts: RadrootsNip01EventWireParts, author: PublicKey, created_at: Option<u64>, -) -> UnsignedEvent { +) -> Result<UnsignedEvent, RadrootsNip17Error> { + let kind = u16::try_from(parts.kind) + .map_err(|_| RadrootsNip17Error::UnsupportedRumorKind(parts.kind))?; let tags = tags_from_slices(&parts.tags); let timestamp = match created_at { Some(ts) => Timestamp::from_secs(ts), None => Timestamp::now(), }; - let mut rumor = UnsignedEvent::new( - author, - timestamp, - Kind::Custom(parts.kind as u16), - tags, - parts.content, - ); + let mut rumor = UnsignedEvent::new(author, timestamp, Kind::Custom(kind), tags, parts.content); rumor.ensure_id(); - rumor + Ok(rumor) } fn parse_recipients( @@ -147,7 +143,7 @@ where { let parts = message_encode::to_wire_parts(message)?; let author = signer.get_public_key().await?; - let rumor = rumor_from_parts(parts, author, options.rumor_created_at); + let rumor = rumor_from_parts(parts, author, options.rumor_created_at)?; let recipients = parse_recipients(&message.recipients)?; wrap_rumor(signer, rumor, recipients, &options).await } @@ -162,7 +158,7 @@ where { let parts = message_file_encode::to_wire_parts(message)?; let author = signer.get_public_key().await?; - let rumor = rumor_from_parts(parts, author, options.rumor_created_at); + let rumor = rumor_from_parts(parts, author, options.rumor_created_at)?; let recipients = parse_recipients(&message.recipients)?; wrap_rumor(signer, rumor, recipients, &options).await } @@ -225,6 +221,36 @@ mod tests { Keys::new(SecretKey::from_hex(FIXTURE_BOB.secret_key_hex).unwrap()) } + #[test] + fn rumor_kind_conversion_is_range_checked() { + let author = sender_keys().public_key(); + let max = rumor_from_parts( + RadrootsNip01EventWireParts { + kind: u32::from(u16::MAX), + content: String::new(), + tags: Vec::new(), + }, + author, + Some(1_700_000_000), + ) + .expect("maximum NIP-01 kind"); + assert_eq!(max.kind.as_u16(), u16::MAX); + + let overflow = u32::from(u16::MAX) + 1; + assert!(matches!( + rumor_from_parts( + RadrootsNip01EventWireParts { + kind: overflow, + content: String::new(), + tags: Vec::new(), + }, + author, + Some(1_700_000_000), + ), + Err(RadrootsNip17Error::UnsupportedRumorKind(kind)) if kind == overflow + )); + } + #[tokio::test] async fn wrap_and_unwrap_message() { let sender = sender_keys(); diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs @@ -289,7 +289,9 @@ fn unsupported_raw_event() -> String { fn post_relay_fetch_filter(limit: usize) -> RadrootsNostrFilter { radroots_nostr_filter_tag( RadrootsNostrFilter::new() - .kind(RadrootsNostrKind::Custom(KIND_POST as u16)) + .kind(RadrootsNostrKind::Custom( + u16::try_from(KIND_POST).expect("post kind must fit NIP-01"), + )) .limit(limit), "t", vec!["soil".to_owned()], @@ -1253,7 +1255,9 @@ async fn fetch_rejects_out_of_filter_events_before_store_mutation() { ]); let filter = radroots_nostr_filter_tag( RadrootsNostrFilter::new() - .kind(RadrootsNostrKind::Custom(KIND_POST as u16)) + .kind(RadrootsNostrKind::Custom( + u16::try_from(KIND_POST).expect("post kind must fit NIP-01"), + )) .limit(10), "t", vec!["soil".to_owned()], @@ -1409,7 +1413,9 @@ async fn fetch_relay_events_applies_shared_filter_limit_and_outcome_evidence() { signed_event_with_kind_and_hashtag("shared fetch wrong tag", KIND_POST, "compost"); let filter = radroots_nostr_filter_tag( RadrootsNostrFilter::new() - .kind(RadrootsNostrKind::Custom(KIND_POST as u16)) + .kind(RadrootsNostrKind::Custom( + u16::try_from(KIND_POST).expect("post kind must fit NIP-01"), + )) .limit(10), "t", vec!["soil".to_owned()], diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs @@ -28,7 +28,7 @@ const REPLICA_CONTRACT_NAME: &str = "radroots_replica_contract"; const REPLICA_TRANSFER_CONSTANT: &str = "RADROOTS_REPLICA_TRANSFER_VERSION"; const REPLICA_TRANSFER_VERSION: u32 = 2; const VENDORED_WORKSPACE_MEMBER_RELATIVE: &str = "crates/libsqlite3_sys_3_53_3"; -const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 6] = [ +const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 7] = [ ( "contracts/conformance/vectors/blossom/bud11_claims.v1.json", "crates/blossom/tests/fixtures/bud11_claims.v1.json", @@ -53,6 +53,10 @@ const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 6] = [ "contracts/conformance/vectors/profile/metadata.v1.json", "crates/event_codec/tests/fixtures/profile_metadata.v1.json", ), + ( + "contracts/conformance/vectors/profile/verified_event.v1.json", + "crates/event_codec/tests/fixtures/profile_verified_event.v1.json", + ), ]; const KNOWLEDGE_MVP_SUPPORT_CONTRACT_IDS: [&str; 8] = [ "radroots.wiki.article.v1", @@ -762,7 +766,7 @@ struct EventBoundaryExpectation { witnesses: &'static [EventBoundarySourceWitness], } -const PROFILE_WITNESSES: [EventBoundarySourceWitness; 3] = [ +const PROFILE_WITNESSES: [EventBoundarySourceWitness; 5] = [ EventBoundarySourceWitness { relative_path: "crates/event/src/profile.rs", required_fragments: &["pub struct RadrootsAuthoredProfile"], @@ -772,6 +776,20 @@ const PROFILE_WITNESSES: [EventBoundarySourceWitness; 3] = [ required_fragments: &["pub struct RadrootsInboundProfileMetadata"], }, EventBoundarySourceWitness { + relative_path: "crates/event_codec/src/profile/admission.rs", + required_fragments: &[ + "pub struct RadrootsAdmittedProfileEvent", + "pub fn verify_and_admit_profile_event", + ], + }, + EventBoundarySourceWitness { + relative_path: "crates/event_codec/src/verification.rs", + required_fragments: &[ + "pub struct RadrootsSignatureVerifiedEvent", + "pub fn verify_nip01_event", + ], + }, + EventBoundarySourceWitness { relative_path: "crates/event/src/kinds.rs", required_fragments: &["pub const KIND_PROFILE: u32 = 0;"], },