commit b6f024d271fdf6a6eb1cf815e85a6d225fe7d8e1 parent f8aa69ceeb40c0721b8a0881d11c5a5fc35ad983 Author: triesap <tyson@radroots.org> Date: Thu, 20 Aug 2026 06:06:24 +0000 refactor(events): enforce typed rhi attestations - Add an exact immutable RHI attestation wire model and typed authoring path. - Validate canonical reports, ordered tags, signatures, and supersession rules. - Extend registry, SDK inventory, signed corpus, vectors, and API baselines. - Prove the checkpoint with full workspace, contract, lint, and docs gates. Diffstat:
20 files changed, 1636 insertions(+), 13 deletions(-)
diff --git a/contracts/api_baselines/radroots_event.txt b/contracts/api_baselines/radroots_event.txt @@ -45,6 +45,7 @@ impl radroots_event::VerifiedEvent pub const fn radroots_event::VerifiedEvent::event(&self) -> &radroots_event::envelope::EventEnvelope pub fn radroots_event::VerifiedEvent::into_event(self) -> radroots_event::envelope::EventEnvelope pub fn radroots_event::VerifiedEvent::validate_contract(self) -> core::result::Result<radroots_event::admission::ContractValidatedEvent, radroots_event::Error> +pub fn radroots_event::VerifiedEvent::validate_contract_for_admission(self, &str) -> core::result::Result<radroots_event::admission::ContractValidatedEvent, radroots_event::Error> pub struct radroots_event::admission::VisibleEvent impl radroots_event::admission::VisibleEvent pub const fn radroots_event::admission::VisibleEvent::admitted_event(&self) -> &radroots_event::admission::AdmittedEvent @@ -672,6 +673,7 @@ pub const radroots_event::envelope::kind::KIND_REPORT: u32 pub const radroots_event::envelope::kind::KIND_REPOST: u32 pub const radroots_event::envelope::kind::KIND_RESOURCE_AREA: u32 pub const radroots_event::envelope::kind::KIND_RESOURCE_HARVEST_CAP: u32 +pub const radroots_event::envelope::kind::KIND_RHI_EVIDENCE_ATTESTATION: u32 pub const radroots_event::envelope::kind::KIND_SEAL: u32 pub const radroots_event::envelope::kind::KIND_TRADE_CANCELLATION: u32 pub const radroots_event::envelope::kind::KIND_TRADE_DECISION: u32 @@ -3592,3 +3594,4 @@ impl radroots_event::VerifiedEvent pub const fn radroots_event::VerifiedEvent::event(&self) -> &radroots_event::envelope::EventEnvelope pub fn radroots_event::VerifiedEvent::into_event(self) -> radroots_event::envelope::EventEnvelope pub fn radroots_event::VerifiedEvent::validate_contract(self) -> core::result::Result<radroots_event::admission::ContractValidatedEvent, radroots_event::Error> +pub fn radroots_event::VerifiedEvent::validate_contract_for_admission(self, &str) -> core::result::Result<radroots_event::admission::ContractValidatedEvent, radroots_event::Error> diff --git a/contracts/api_baselines/radroots_event_codec.txt b/contracts/api_baselines/radroots_event_codec.txt @@ -182,6 +182,7 @@ pub radroots_event_codec::authoring::AuthoredPlanError::Envelope(radroots_event: pub radroots_event_codec::authoring::AuthoredPlanError::FoodAvailability(radroots_event_codec::encode::food_availability::RadrootsFoodAvailabilityEncodeError) pub radroots_event_codec::authoring::AuthoredPlanError::InvalidAuthor(radroots_identity::error::Error) pub radroots_event_codec::authoring::AuthoredPlanError::Profile(radroots_event_codec::encode::profile::RadrootsAuthoredProfileEncodeError) +pub radroots_event_codec::authoring::AuthoredPlanError::Rhi(radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError) pub radroots_event_codec::authoring::AuthoredPlanError::Trade(radroots_event_codec::decode::trade::RadrootsTradeMutationError) impl radroots_event_codec::authoring::AuthoredPlanError pub const fn radroots_event_codec::authoring::AuthoredPlanError::code(&self) -> &'static str @@ -235,6 +236,7 @@ pub fn radroots_event_codec::authoring::AuthoredEventBody::from_nip10_reply(&rad pub fn radroots_event_codec::authoring::AuthoredEventBody::from_nip22_comment(&radroots_event::post::comment::AuthoredNip22Comment) -> core::result::Result<Self, radroots_event_codec::authoring::AuthoredPlanError> pub fn radroots_event_codec::authoring::AuthoredEventBody::from_photo_update(&radroots_event::post::AuthoredPhotoUpdate) -> core::result::Result<Self, radroots_event_codec::authoring::AuthoredPlanError> pub fn radroots_event_codec::authoring::AuthoredEventBody::from_profile(&radroots_event::profile::AuthoredProfile) -> core::result::Result<Self, radroots_event_codec::authoring::AuthoredPlanError> +pub fn radroots_event_codec::authoring::AuthoredEventBody::from_rhi_evidence_attestation(&radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1) -> core::result::Result<Self, radroots_event_codec::authoring::AuthoredPlanError> pub fn radroots_event_codec::authoring::AuthoredEventBody::from_trade_mutation(radroots_event::trade::TradeMutationEnvelopeV1) -> core::result::Result<Self, radroots_event_codec::authoring::AuthoredPlanError> pub fn radroots_event_codec::authoring::AuthoredEventBody::from_update(&radroots_event::post::AuthoredUpdate) -> core::result::Result<Self, radroots_event_codec::authoring::AuthoredPlanError> pub struct radroots_event_codec::authoring::AuthoredEventPlan @@ -256,6 +258,7 @@ pub fn radroots_event_codec::authoring::AuthoredEventPlan::from_nip10_reply(&rad pub fn radroots_event_codec::authoring::AuthoredEventPlan::from_nip22_comment(&radroots_event::post::comment::AuthoredNip22Comment, u64, impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_event_codec::authoring::AuthoredPlanError> pub fn radroots_event_codec::authoring::AuthoredEventPlan::from_photo_update(&radroots_event::post::AuthoredPhotoUpdate, u64, impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_event_codec::authoring::AuthoredPlanError> pub fn radroots_event_codec::authoring::AuthoredEventPlan::from_profile(&radroots_event::profile::AuthoredProfile, u64, impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_event_codec::authoring::AuthoredPlanError> +pub fn radroots_event_codec::authoring::AuthoredEventPlan::from_rhi_evidence_attestation(&radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1, u64) -> core::result::Result<Self, radroots_event_codec::authoring::AuthoredPlanError> pub fn radroots_event_codec::authoring::AuthoredEventPlan::from_trade_mutation(radroots_event::trade::TradeMutationEnvelopeV1) -> core::result::Result<Self, radroots_event_codec::authoring::AuthoredPlanError> pub fn radroots_event_codec::authoring::AuthoredEventPlan::from_update(&radroots_event::post::AuthoredUpdate, u64, impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_event_codec::authoring::AuthoredPlanError> pub struct radroots_event_codec::authoring::BlossomAuthorizationPlan @@ -302,7 +305,7 @@ impl serde_core::ser::Serialize for radroots_event_codec::authoring::PlanWireV1 pub fn radroots_event_codec::authoring::PlanWireV1::serialize<S>(&self, S) -> core::result::Result<<S as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error> where S: serde_core::ser::Serializer pub const radroots_event_codec::authoring::PLAN_WIRE_MAX_BYTES: usize pub const radroots_event_codec::authoring::PLAN_WIRE_VERSION_V1: u32 -pub const radroots_event_codec::authoring::REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS: [&str; 15] +pub const radroots_event_codec::authoring::REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS: [&str; 16] pub mod radroots_event_codec::canonical pub fn radroots_event_codec::canonical::id(&radroots_event::envelope::EventEnvelope) -> core::result::Result<radroots_event::id::EventId, radroots_event_codec::canonical::CanonicalError> pub fn radroots_event_codec::canonical::id_preimage(&radroots_event::envelope::EventEnvelope) -> core::result::Result<alloc::string::String, radroots_event_codec::canonical::CanonicalError> @@ -615,6 +618,64 @@ pub mod radroots_event_codec::decode::resource_cap pub fn radroots_event_codec::decode::resource_cap::data_from_event(alloc::string::String, alloc::string::String, u64, u32, alloc::string::String, alloc::vec::Vec<alloc::vec::Vec<alloc::string::String>>) -> core::result::Result<radroots_event_codec::decode::parsed::RadrootsParsedData<radroots_event::farm::resource_cap::ResourceHarvestCap>, radroots_event_codec::decode::EventParseError> pub fn radroots_event_codec::decode::resource_cap::parsed_from_event(alloc::string::String, alloc::string::String, u64, u32, alloc::string::String, alloc::vec::Vec<alloc::vec::Vec<alloc::string::String>>, alloc::string::String) -> core::result::Result<radroots_event_codec::decode::parsed::RadrootsParsedEvent<radroots_event::farm::resource_cap::ResourceHarvestCap>, radroots_event_codec::decode::EventParseError> pub fn radroots_event_codec::decode::resource_cap::resource_harvest_cap_from_event(u32, &[alloc::vec::Vec<alloc::string::String>], &str) -> core::result::Result<radroots_event::farm::resource_cap::ResourceHarvestCap, radroots_event_codec::decode::EventParseError> +pub mod radroots_event_codec::decode::rhi +pub enum radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError +pub radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::CallerStructuralTagForbidden +pub radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::ClaimTagMismatch +pub radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::ContractTagMismatch +pub radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::DuplicateStatementTag +pub radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::DuplicateTradeTag +pub radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::IncompleteSupersessionReference +pub radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::InvalidAttestationKind +pub radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::InvalidIdentifier +pub radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::InvalidOutcome +pub radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::InvalidReport +pub radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::InvalidTagShape +pub radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::IssuerAuthorMismatch +pub radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::MissingClaimTag +pub radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::NoncanonicalReportContent +pub radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::OutcomeTagMismatch +pub radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::StaleTradeGeneration +pub radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::StatementDigestMismatch +pub radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::StatementTagMismatch +pub radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::SupersessionTagMismatch +pub radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::TradeTagMismatch +pub radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::UnexpectedTag +impl radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError +pub const fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::code(self) -> &'static str +impl core::error::Error for radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError +impl core::fmt::Display for radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError +pub fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub enum radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationOutcomeV1 +pub radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationOutcomeV1::Indeterminate +pub radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationOutcomeV1::Invalid +pub radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationOutcomeV1::Valid +impl radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationOutcomeV1 +pub const fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationOutcomeV1::as_str(self) -> &'static str +pub struct radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationSupersessionV1 +impl radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationSupersessionV1 +pub const fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationSupersessionV1::event_id(&self) -> &radroots_event::id::EventId +pub const fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationSupersessionV1::report_id(&self) -> &[u8; 32] +impl core::fmt::Debug for radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationSupersessionV1 +pub fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationSupersessionV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1 +impl radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1 +pub fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1::canonical_content(&self) -> &str +pub const fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1::claim_mutation_id(&self) -> &radroots_event::id::MutationId +pub fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1::from_canonical_content(impl core::convert::AsRef<[u8]>) -> core::result::Result<Self, radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError> +pub const fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1::issuer(&self) -> &radroots_identity::key::PublicKey +pub const fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1::observed_at_unix_s(&self) -> u64 +pub const fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1::outcome(&self) -> radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationOutcomeV1 +pub const fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1::statement_digest(&self) -> &[u8; 32] +pub const fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1::supersession(&self) -> core::option::Option<radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationSupersessionV1> +pub const fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1::trade_generation(&self) -> core::num::nonzero::NonZeroU64 +pub const fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1::trade_id(&self) -> &radroots_event::id::TradeId +impl core::fmt::Debug for radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1 +pub fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub fn radroots_event_codec::decode::rhi::rhi_evidence_attestation_from_event(&radroots_event::envelope::EventEnvelope) -> core::result::Result<radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1, radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError> +pub fn radroots_event_codec::decode::rhi::rhi_evidence_attestation_from_verified_event(&radroots_event::verification::SignatureVerifiedEvent) -> core::result::Result<radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1, radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError> +pub fn radroots_event_codec::decode::rhi::validate_rhi_evidence_attestation_supersession(&radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1, &radroots_event::id::EventId, &radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1) -> core::result::Result<(), radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError> +pub fn radroots_event_codec::decode::rhi::validate_rhi_evidence_attestation_tags(&radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1, &[alloc::vec::Vec<alloc::string::String>]) -> core::result::Result<(), radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError> pub mod radroots_event_codec::decode::seal pub fn radroots_event_codec::decode::seal::data_from_event(alloc::string::String, alloc::string::String, u64, u32, alloc::string::String, alloc::vec::Vec<alloc::vec::Vec<alloc::string::String>>) -> core::result::Result<radroots_event_codec::decode::parsed::RadrootsParsedData<radroots_event::social::seal::Seal>, radroots_event_codec::decode::EventParseError> pub fn radroots_event_codec::decode::seal::parsed_from_event(alloc::string::String, alloc::string::String, u64, u32, alloc::string::String, alloc::vec::Vec<alloc::vec::Vec<alloc::string::String>>, alloc::string::String) -> core::result::Result<radroots_event_codec::decode::parsed::RadrootsParsedEvent<radroots_event::social::seal::Seal>, radroots_event_codec::decode::EventParseError> @@ -998,6 +1059,52 @@ pub mod radroots_event_codec::encode::resource_cap pub fn radroots_event_codec::encode::resource_cap::resource_harvest_cap_build_tags(&radroots_event::farm::resource_cap::ResourceHarvestCap) -> core::result::Result<alloc::vec::Vec<alloc::vec::Vec<alloc::string::String>>, radroots_event_codec::encode::EventEncodeError> pub fn radroots_event_codec::encode::resource_cap::to_wire_parts(&radroots_event::farm::resource_cap::ResourceHarvestCap) -> core::result::Result<radroots_event::wire::v1::Nip01EventWireParts, radroots_event_codec::encode::EventEncodeError> pub fn radroots_event_codec::encode::resource_cap::to_wire_parts_with_kind(&radroots_event::farm::resource_cap::ResourceHarvestCap, u32) -> core::result::Result<radroots_event::wire::v1::Nip01EventWireParts, radroots_event_codec::encode::EventEncodeError> +pub mod radroots_event_codec::encode::rhi +pub enum radroots_event_codec::encode::rhi::RadrootsRhiEvidenceAttestationError +pub radroots_event_codec::encode::rhi::RadrootsRhiEvidenceAttestationError::CallerStructuralTagForbidden +pub radroots_event_codec::encode::rhi::RadrootsRhiEvidenceAttestationError::ClaimTagMismatch +pub radroots_event_codec::encode::rhi::RadrootsRhiEvidenceAttestationError::ContractTagMismatch +pub radroots_event_codec::encode::rhi::RadrootsRhiEvidenceAttestationError::DuplicateStatementTag +pub radroots_event_codec::encode::rhi::RadrootsRhiEvidenceAttestationError::DuplicateTradeTag +pub radroots_event_codec::encode::rhi::RadrootsRhiEvidenceAttestationError::IncompleteSupersessionReference +pub radroots_event_codec::encode::rhi::RadrootsRhiEvidenceAttestationError::InvalidAttestationKind +pub radroots_event_codec::encode::rhi::RadrootsRhiEvidenceAttestationError::InvalidIdentifier +pub radroots_event_codec::encode::rhi::RadrootsRhiEvidenceAttestationError::InvalidOutcome +pub radroots_event_codec::encode::rhi::RadrootsRhiEvidenceAttestationError::InvalidReport +pub radroots_event_codec::encode::rhi::RadrootsRhiEvidenceAttestationError::InvalidTagShape +pub radroots_event_codec::encode::rhi::RadrootsRhiEvidenceAttestationError::IssuerAuthorMismatch +pub radroots_event_codec::encode::rhi::RadrootsRhiEvidenceAttestationError::MissingClaimTag +pub radroots_event_codec::encode::rhi::RadrootsRhiEvidenceAttestationError::NoncanonicalReportContent +pub radroots_event_codec::encode::rhi::RadrootsRhiEvidenceAttestationError::OutcomeTagMismatch +pub radroots_event_codec::encode::rhi::RadrootsRhiEvidenceAttestationError::StaleTradeGeneration +pub radroots_event_codec::encode::rhi::RadrootsRhiEvidenceAttestationError::StatementDigestMismatch +pub radroots_event_codec::encode::rhi::RadrootsRhiEvidenceAttestationError::StatementTagMismatch +pub radroots_event_codec::encode::rhi::RadrootsRhiEvidenceAttestationError::SupersessionTagMismatch +pub radroots_event_codec::encode::rhi::RadrootsRhiEvidenceAttestationError::TradeTagMismatch +pub radroots_event_codec::encode::rhi::RadrootsRhiEvidenceAttestationError::UnexpectedTag +impl radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError +pub const fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::code(self) -> &'static str +impl core::error::Error for radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError +impl core::fmt::Display for radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError +pub fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_event_codec::encode::rhi::RadrootsRhiEvidenceAttestationV1 +impl radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1 +pub fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1::canonical_content(&self) -> &str +pub const fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1::claim_mutation_id(&self) -> &radroots_event::id::MutationId +pub fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1::from_canonical_content(impl core::convert::AsRef<[u8]>) -> core::result::Result<Self, radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError> +pub const fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1::issuer(&self) -> &radroots_identity::key::PublicKey +pub const fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1::observed_at_unix_s(&self) -> u64 +pub const fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1::outcome(&self) -> radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationOutcomeV1 +pub const fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1::statement_digest(&self) -> &[u8; 32] +pub const fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1::supersession(&self) -> core::option::Option<radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationSupersessionV1> +pub const fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1::trade_generation(&self) -> core::num::nonzero::NonZeroU64 +pub const fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1::trade_id(&self) -> &radroots_event::id::TradeId +impl core::fmt::Debug for radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1 +pub fn radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub const radroots_event_codec::encode::rhi::RADROOTS_RHI_EVIDENCE_ATTESTATION_CONTRACT_ID: &str +pub const radroots_event_codec::encode::rhi::RADROOTS_RHI_EVIDENCE_ATTESTATION_MAXIMUM_BYTES: usize +pub fn radroots_event_codec::encode::rhi::rhi_evidence_attestation_event_build(&radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1) -> radroots_event::wire::v1::Nip01EventWireParts +pub fn radroots_event_codec::encode::rhi::rhi_evidence_attestation_event_build_with_extra_tags(&radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1, &[alloc::vec::Vec<alloc::string::String>]) -> core::result::Result<radroots_event::wire::v1::Nip01EventWireParts, radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationError> pub mod radroots_event_codec::encode::seal pub fn radroots_event_codec::encode::seal::seal_build_tags(&radroots_event::social::seal::Seal) -> core::result::Result<alloc::vec::Vec<alloc::vec::Vec<alloc::string::String>>, radroots_event_codec::encode::EventEncodeError> pub fn radroots_event_codec::encode::seal::to_wire_parts(&radroots_event::social::seal::Seal) -> core::result::Result<radroots_event::wire::v1::Nip01EventWireParts, radroots_event_codec::encode::EventEncodeError> diff --git a/contracts/conformance/vectors/event/authored_operations.v1.json b/contracts/conformance/vectors/event/authored_operations.v1.json @@ -718,6 +718,48 @@ "signature": "141c3eace0da665038626a73d336e48bc2ebc2bdee1f460bedd35034d85f199c1059f5b37c3c9b8a51730c4db0b8240223c00a0fc3fc2807cb46a7b6a0504a36", "raw_json": "{\"id\":\"2cb6574e48eb71825016b5fb94aed7df459948cab74b8f45f34cc1b42ca8767a\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":3474,\"tags\":[[\"contract\",\"radroots.trade.cancellation.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"fe65c35d4a280a66a309e2834a58712ad8cf837386edb8fd25b73c946b1a273d\",\"mutation\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"root\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"parent\"],[\"p\",\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"{\\\"author_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"authored_at_unix_s\\\":1784347200,\\\"body\\\":{\\\"mutation_type\\\":\\\"cancellation\\\",\\\"reason\\\":\\\"cancelled\\\",\\\"target_candidate_id\\\":\\\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\\\",\\\"target_claim_mutation_id\\\":null},\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"contract_id\\\":\\\"radroots.trade.cancellation.v1\\\",\\\"counterparty_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"farm_id\\\":\\\"farm-1\\\",\\\"mutation_id\\\":\\\"fe65c35d4a280a66a309e2834a58712ad8cf837386edb8fd25b73c946b1a273d\\\",\\\"parent_mutation_ids\\\":[\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"],\\\"root_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\",\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\",\"sig\":\"141c3eace0da665038626a73d336e48bc2ebc2bdee1f460bedd35034d85f199c1059f5b37c3c9b8a51730c4db0b8240223c00a0fc3fc2807cb46a7b6a0504a36\"}" } + }, + { + "id": "typed_rhi_evidence_attestation_017", + "kind": "authored_operations.wire", + "input": { + "contract_id": "radroots.rhi.evidence_attestation.v1", + "authoring": "typed" + }, + "expected": { + "kind": 3441, + "created_at": 1784347200, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "tags": [ + [ + "contract", + "radroots.rhi.evidence_attestation.v1" + ], + [ + "d", + "11111111111111111111111111111111" + ], + [ + "x", + "2222222222222222222222222222222222222222222222222222222222222222", + "claim" + ], + [ + "x", + "254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30", + "statement" + ], + [ + "t", + "radroots:rhi-outcome:indeterminate" + ] + ], + "content": "{\"attestation_method\":\"signed_evidence_snapshot\",\"claim_mutation_id\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"contract_id\":\"radroots.rhi.evidence_attestation.v1\",\"contract_version\":1,\"evidence_manifest_digest\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"evidence_policy_digest\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"issuer_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"observed_at_unix_s\":1800000000,\"outcome\":\"indeterminate\",\"projection_digest\":\"6666666666666666666666666666666666666666666666666666666666666666\",\"reason_codes\":[\"required_source_incomplete\"],\"reducer_contract_id\":\"radroots.trade.reducer.v1\",\"reducer_contract_version\":1,\"report_id\":\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\",\"statement_digest\":\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\",\"supersedes_event_id\":null,\"supersedes_report_id\":null,\"trade_generation\":7,\"trade_id\":\"11111111111111111111111111111111\"}", + "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,3441,[[\"contract\",\"radroots.rhi.evidence_attestation.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"claim\"],[\"x\",\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\",\"statement\"],[\"t\",\"radroots:rhi-outcome:indeterminate\"]],\"{\\\"attestation_method\\\":\\\"signed_evidence_snapshot\\\",\\\"claim_mutation_id\\\":\\\"2222222222222222222222222222222222222222222222222222222222222222\\\",\\\"contract_id\\\":\\\"radroots.rhi.evidence_attestation.v1\\\",\\\"contract_version\\\":1,\\\"evidence_manifest_digest\\\":\\\"4444444444444444444444444444444444444444444444444444444444444444\\\",\\\"evidence_policy_digest\\\":\\\"5555555555555555555555555555555555555555555555555555555555555555\\\",\\\"issuer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"observed_at_unix_s\\\":1800000000,\\\"outcome\\\":\\\"indeterminate\\\",\\\"projection_digest\\\":\\\"6666666666666666666666666666666666666666666666666666666666666666\\\",\\\"reason_codes\\\":[\\\"required_source_incomplete\\\"],\\\"reducer_contract_id\\\":\\\"radroots.trade.reducer.v1\\\",\\\"reducer_contract_version\\\":1,\\\"report_id\\\":\\\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\\\",\\\"statement_digest\\\":\\\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\\\",\\\"supersedes_event_id\\\":null,\\\"supersedes_report_id\\\":null,\\\"trade_generation\\\":7,\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\"]", + "event_id": "a811ca5f84414e9d817ca47b923d8fd7f61c1a03d498b32f6b0816b99d57b8ce", + "signature": "8abd1ba0b3b597629939a559e2536897b602ef4e225f1552a57ddce72dd372f98ad21891cfe10bdb1a50532f50a0dd3d9b14fd46677dccd0a4fa652cdee17063", + "raw_json": "{\"id\":\"a811ca5f84414e9d817ca47b923d8fd7f61c1a03d498b32f6b0816b99d57b8ce\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":3441,\"tags\":[[\"contract\",\"radroots.rhi.evidence_attestation.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"claim\"],[\"x\",\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\",\"statement\"],[\"t\",\"radroots:rhi-outcome:indeterminate\"]],\"content\":\"{\\\"attestation_method\\\":\\\"signed_evidence_snapshot\\\",\\\"claim_mutation_id\\\":\\\"2222222222222222222222222222222222222222222222222222222222222222\\\",\\\"contract_id\\\":\\\"radroots.rhi.evidence_attestation.v1\\\",\\\"contract_version\\\":1,\\\"evidence_manifest_digest\\\":\\\"4444444444444444444444444444444444444444444444444444444444444444\\\",\\\"evidence_policy_digest\\\":\\\"5555555555555555555555555555555555555555555555555555555555555555\\\",\\\"issuer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"observed_at_unix_s\\\":1800000000,\\\"outcome\\\":\\\"indeterminate\\\",\\\"projection_digest\\\":\\\"6666666666666666666666666666666666666666666666666666666666666666\\\",\\\"reason_codes\\\":[\\\"required_source_incomplete\\\"],\\\"reducer_contract_id\\\":\\\"radroots.trade.reducer.v1\\\",\\\"reducer_contract_version\\\":1,\\\"report_id\\\":\\\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\\\",\\\"statement_digest\\\":\\\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\\\",\\\"supersedes_event_id\\\":null,\\\"supersedes_report_id\\\":null,\\\"trade_generation\\\":7,\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\",\"sig\":\"8abd1ba0b3b597629939a559e2536897b602ef4e225f1552a57ddce72dd372f98ad21891cfe10bdb1a50532f50a0dd3d9b14fd46677dccd0a4fa652cdee17063\"}" + } } ] } diff --git a/contracts/event_store/event_contract_registry_v7.inventory.json b/contracts/event_store/event_contract_registry_v7.inventory.json @@ -1034,6 +1034,17 @@ "accepted_event_contracts": [ "radroots.trade.validation_receipt.v1" ] + }, + { + "ordinal": 93, + "kind": 3441, + "canonical_constant": "KIND_RHI_EVIDENCE_ATTESTATION", + "name": "RHI Evidence Attestation", + "class": "regular", + "standard": "radroots", + "accepted_event_contracts": [ + "radroots.rhi.evidence_attestation.v1" + ] } ], "event_contracts": [ @@ -5912,6 +5923,62 @@ }, { "ordinal": 102, + "contract_id": "radroots.rhi.evidence_attestation.v1", + "kind": 3441, + "name": "RHI Evidence Attestation", + "payload_type": "RadrootsRhiEvidenceAttestationV1", + "class": "regular", + "stability": "stable", + "privacy": "public", + "author_role": "service", + "content_schema": "json_object", + "authoring_policy": "typed_only", + "discriminator": { + "type": "kind_only" + }, + "tags": [ + { + "name": "contract", + "cardinality": "required_one", + "semantic": "contract", + "value_type": "contract_id", + "relay_indexed": false + }, + { + "name": "d", + "cardinality": "required_one", + "semantic": "identifier", + "value_type": "d_tag", + "relay_indexed": true + }, + { + "name": "x", + "cardinality": "required_many", + "semantic": "reference", + "value_type": "sha256", + "relay_indexed": true + }, + { + "name": "t", + "cardinality": "required_one", + "semantic": "status", + "value_type": "text", + "relay_indexed": true + }, + { + "name": "e", + "cardinality": "optional_one", + "semantic": "previous_event", + "value_type": "event_id", + "relay_indexed": true + } + ], + "reducers": [ + "trade_validation" + ] + }, + { + "ordinal": 103, "contract_id": "radroots.trade.validation_receipt.v1", "kind": 3440, "name": "Trade Validation Receipt", diff --git a/contracts/event_store/event_contract_registry_v7.inventory.sha256 b/contracts/event_store/event_contract_registry_v7.inventory.sha256 @@ -1 +1 @@ -20458a303373c4b51530a33cb07bc4e8ad208587838d5ccf31ba2fe28f539c2f +a449519d0c5f88845fe48458f6d59a53c5b399be998edada4b163edb673ca535 diff --git a/crates/event/src/contract/registry_v7.rs b/crates/event/src/contract/registry_v7.rs @@ -568,6 +568,27 @@ const TAG_X_TRADE_MUTATIONS: TagContract = tag( TagValueType::MutationId, true, ); +const TAG_X_RHI_ATTESTATION_REFERENCES: TagContract = tag( + "x", + TagCardinality::RequiredMany, + TagSemantic::Reference, + TagValueType::Sha256, + true, +); +const TAG_T_RHI_ATTESTATION_OUTCOME: TagContract = tag( + "t", + TagCardinality::RequiredOne, + TagSemantic::Status, + TagValueType::Text, + true, +); +const TAG_E_RHI_SUPERSEDES_EVENT: TagContract = tag( + "e", + TagCardinality::OptionalOne, + TagSemantic::PreviousEvent, + TagValueType::EventId, + true, +); const TAG_CALENDAR_PARTICIPANT: TagContract = tag( "p", TagCardinality::OptionalMany, @@ -1335,6 +1356,13 @@ const TRADE_MUTATION_TAGS: &[TagContract] = &[ TAG_X_TRADE_MUTATIONS, TAG_P_TRADE_PARTIES, ]; +const RHI_EVIDENCE_ATTESTATION_TAGS: &[TagContract] = &[ + TAG_CONTRACT_REQUIRED, + TAG_D, + TAG_X_RHI_ATTESTATION_REFERENCES, + TAG_T_RHI_ATTESTATION_OUTCOME, + TAG_E_RHI_SUPERSEDES_EVENT, +]; const TRADE_VALIDATION_RECEIPT_TAGS: &[TagContract] = &[TAG_E_ROOT, TAG_A_OPTIONAL, TAG_SERVICE_OUTPUT]; const KNOWLEDGE_SOURCE_TAGS: &[TagContract] = &[ @@ -2317,6 +2345,14 @@ static KIND_CONTRACTS_REGISTRY_V7: &[KindContract] = &[ NostrStandard::Radroots, ["radroots.trade.validation_receipt.v1"] ), + kind_contract!( + KIND_RHI_EVIDENCE_ATTESTATION, + "KIND_RHI_EVIDENCE_ATTESTATION", + "RHI Evidence Attestation", + EventClass::Regular, + NostrStandard::Radroots, + ["radroots.rhi.evidence_attestation.v1"] + ), ]; static EVENT_CONTRACTS_REGISTRY_V7: &[EventContract] = &[ @@ -3703,6 +3739,20 @@ static EVENT_CONTRACTS_REGISTRY_V7: &[EventContract] = &[ TRADE_MUTATION_TAGS, TRADE_MUTATION_REDUCERS ), + event_contract_with_authoring_policy!( + "radroots.rhi.evidence_attestation.v1", + KIND_RHI_EVIDENCE_ATTESTATION, + "RHI Evidence Attestation", + "RadrootsRhiEvidenceAttestationV1", + EventClass::Regular, + EventPrivacy::Public, + AuthorRole::Service, + ContentSchema::JsonObject, + EventAuthoringPolicy::TypedOnly, + EventDiscriminator::KindOnly, + RHI_EVIDENCE_ATTESTATION_TAGS, + TRADE_VALIDATION_REDUCERS + ), event_contract!( "radroots.trade.validation_receipt.v1", KIND_TRADE_VALIDATION_RECEIPT, @@ -3815,6 +3865,7 @@ pub fn kind_contract_family(contract: &KindContract) -> Option<ContractFamily> { | KIND_FARM_CRDT_CHANGE => ContractFamily::Farm, KIND_CLASSIFIED_LISTING => ContractFamily::Market, KIND_TRADE_VALIDATION_RECEIPT + | KIND_RHI_EVIDENCE_ATTESTATION | KIND_TRADE_PROPOSAL | KIND_TRADE_DECISION | KIND_TRADE_REVISION_PROPOSAL diff --git a/crates/event/src/kinds.rs b/crates/event/src/kinds.rs @@ -89,6 +89,7 @@ pub const KIND_ORDER_DECISION: u32 = 3423; pub const KIND_ORDER_CANCELLATION: u32 = 3432; pub const KIND_TRADE_FORBIDDEN_3431: u32 = 3431; pub const KIND_TRADE_VALIDATION_RECEIPT: u32 = 3440; +pub const KIND_RHI_EVIDENCE_ATTESTATION: u32 = 3441; pub const KIND_KNOWLEDGE_CLAIM: u32 = 3460; pub const KIND_KNOWLEDGE_RELATION: u32 = 3461; pub const KIND_KNOWLEDGE_REVIEW: u32 = 3462; diff --git a/crates/event_codec/src/authoring/typed.rs b/crates/event_codec/src/authoring/typed.rs @@ -54,6 +54,10 @@ use crate::{ }, post::inbound::registry_v7::{RadrootsPostClassification, project_inbound_post_parts}, reply::inbound::registry_v7::{RadrootsNip10ReplyStyle, project_nip10_reply_parts}, + rhi::{ + RadrootsRhiEvidenceAttestationError, RadrootsRhiEvidenceAttestationV1, + rhi_evidence_attestation_event_build, validate_parts as validate_rhi_attestation_parts, + }, trade::{ RadrootsTradeMutationError, trade_mutation_event_build, validate_trade_mutation_parts, }, @@ -61,7 +65,7 @@ use crate::{ use super::{AuthoredEventBody, AuthoredEventPlan}; -pub const REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS: [&str; 15] = [ +pub const REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS: [&str; 16] = [ "radroots.profile.metadata.v1", "radroots.social.update.v1", "radroots.social.photo_update.v1", @@ -77,6 +81,7 @@ pub const REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS: [&str; 15] = [ "radroots.trade.revision_proposal.v1", "radroots.trade.revision_decision.v1", "radroots.trade.cancellation.v1", + "radroots.rhi.evidence_attestation.v1", ]; #[non_exhaustive] @@ -101,6 +106,8 @@ pub enum AuthoredPlanError { Profile(RadrootsAuthoredProfileEncodeError), #[cfg(feature = "json")] Trade(RadrootsTradeMutationError), + #[cfg(feature = "json")] + Rhi(RadrootsRhiEvidenceAttestationError), FoodAvailability(RadrootsFoodAvailabilityEncodeError), Calendar(crate::encode::EventEncodeError), } @@ -120,6 +127,8 @@ impl AuthoredPlanError { Self::Profile(error) => error.code(), #[cfg(feature = "json")] Self::Trade(error) => error.code(), + #[cfg(feature = "json")] + Self::Rhi(error) => error.code(), Self::FoodAvailability(error) => error.code(), Self::Calendar(error) => error.code(), } @@ -153,6 +162,8 @@ impl fmt::Display for AuthoredPlanError { Self::Profile(error) => write!(formatter, "{error}"), #[cfg(feature = "json")] Self::Trade(error) => write!(formatter, "{error}"), + #[cfg(feature = "json")] + Self::Rhi(error) => write!(formatter, "{error}"), Self::FoodAvailability(error) => write!(formatter, "{error}"), Self::Calendar(error) => write!(formatter, "{error}"), } @@ -241,6 +252,16 @@ impl AuthoredEventBody { let wire = trade_mutation_event_build(envelope).map_err(AuthoredPlanError::Trade)?; build_typed_body(&contract_id, wire) } + + #[cfg(feature = "json")] + pub fn from_rhi_evidence_attestation( + attestation: &RadrootsRhiEvidenceAttestationV1, + ) -> Result<Self, AuthoredPlanError> { + build_typed_body( + "radroots.rhi.evidence_attestation.v1", + rhi_evidence_attestation_event_build(attestation), + ) + } } impl AuthoredEventPlan { @@ -400,6 +421,18 @@ impl AuthoredEventPlan { expected_author, ) } + + #[cfg(feature = "json")] + pub fn from_rhi_evidence_attestation( + attestation: &RadrootsRhiEvidenceAttestationV1, + created_at: u64, + ) -> Result<Self, AuthoredPlanError> { + Self::bind( + AuthoredEventBody::from_rhi_evidence_attestation(attestation)?, + created_at, + attestation.issuer().to_hex(), + ) + } } fn build_typed_body( @@ -469,6 +502,11 @@ pub(super) fn validate_historical_typed_profile( .map(|_| ()) .map_err(|error| error.code().to_string()) } + "radroots.rhi.evidence_attestation.v1" => { + validate_rhi_attestation_parts(kind, expected_author, tags, content) + .map(|_| ()) + .map_err(|error| error.code().to_string()) + } _ => Err("historical_typed_profile_unavailable".to_string()), } } diff --git a/crates/event_codec/src/decode.rs b/crates/event_codec/src/decode.rs @@ -129,6 +129,16 @@ pub mod reply { } #[cfg(feature = "json")] +pub mod rhi { + pub use crate::rhi::{ + RadrootsRhiEvidenceAttestationError, RadrootsRhiEvidenceAttestationOutcomeV1, + RadrootsRhiEvidenceAttestationSupersessionV1, RadrootsRhiEvidenceAttestationV1, + rhi_evidence_attestation_from_event, rhi_evidence_attestation_from_verified_event, + validate_rhi_evidence_attestation_supersession, validate_rhi_evidence_attestation_tags, + }; +} + +#[cfg(feature = "json")] pub mod trade { pub use crate::trade::{ RadrootsTradeMutationError, trade_mutation_from_event, trade_mutation_from_verified_event, diff --git a/crates/event_codec/src/encode.rs b/crates/event_codec/src/encode.rs @@ -122,6 +122,16 @@ pub mod reply { } #[cfg(feature = "json")] +pub mod rhi { + pub use crate::rhi::{ + RADROOTS_RHI_EVIDENCE_ATTESTATION_CONTRACT_ID, + RADROOTS_RHI_EVIDENCE_ATTESTATION_MAXIMUM_BYTES, RadrootsRhiEvidenceAttestationError, + RadrootsRhiEvidenceAttestationV1, rhi_evidence_attestation_event_build, + rhi_evidence_attestation_event_build_with_extra_tags, + }; +} + +#[cfg(feature = "json")] pub mod trade { pub use crate::trade::{ RadrootsTradeMutationError, trade_mutation_event_build, diff --git a/crates/event_codec/src/lib.rs b/crates/event_codec/src/lib.rs @@ -57,6 +57,8 @@ mod report; mod repost; mod resource_area; mod resource_cap; +#[cfg(feature = "json")] +mod rhi; mod seal; mod tag_builders; mod trade; diff --git a/crates/event_codec/src/rhi.rs b/crates/event_codec/src/rhi.rs @@ -0,0 +1,709 @@ +#![forbid(unsafe_code)] + +//! Exact RHI evidence-attestation wire construction and validation. + +#[cfg(not(feature = "std"))] +use alloc::{ + boxed::Box, + format, + string::{String, ToString}, + vec, + vec::Vec, +}; +use core::{cmp::Ordering, fmt, num::NonZeroU64}; +#[cfg(feature = "std")] +use std::{ + boxed::Box, + format, + string::{String, ToString}, + vec, + vec::Vec, +}; + +use radroots_event::{ + admission::SignatureVerifiedEvent, + envelope::EventEnvelope, + envelope::kind::KIND_RHI_EVIDENCE_ATTESTATION, + id::{EventId, MutationId, TradeId}, + trade::canonical_jcs_value, + wire::Nip01EventWireParts, +}; +use radroots_identity::PublicKey; +use serde::Deserialize; +use serde_json::Value; +use sha2::{Digest as _, Sha256}; + +pub const RADROOTS_RHI_EVIDENCE_ATTESTATION_CONTRACT_ID: &str = + "radroots.rhi.evidence_attestation.v1"; +pub const RADROOTS_RHI_EVIDENCE_ATTESTATION_MAXIMUM_BYTES: usize = 16 * 1024; +const MAXIMUM_TAGS: usize = 7; +const MAXIMUM_REASON_CODES: usize = 16; +const MAXIMUM_REASON_CODE_BYTES: usize = 64; +const STATEMENT_DIGEST_DOMAIN: &[u8] = b"radroots:rhi-evidence-attestation-statement:v1\0"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsRhiEvidenceAttestationOutcomeV1 { + Valid, + Invalid, + Indeterminate, +} + +impl RadrootsRhiEvidenceAttestationOutcomeV1 { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Valid => "valid", + Self::Invalid => "invalid", + Self::Indeterminate => "indeterminate", + } + } + + fn topic(self) -> String { + format!("radroots:rhi-outcome:{}", self.as_str()) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RadrootsRhiEvidenceAttestationSupersessionV1 { + report_id: [u8; 32], + event_id: EventId, +} + +impl RadrootsRhiEvidenceAttestationSupersessionV1 { + #[must_use] + pub const fn report_id(&self) -> &[u8; 32] { + &self.report_id + } + + #[must_use] + pub const fn event_id(&self) -> &EventId { + &self.event_id + } +} + +impl fmt::Debug for RadrootsRhiEvidenceAttestationSupersessionV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RadrootsRhiEvidenceAttestationSupersessionV1(<redacted>)") + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct RadrootsRhiEvidenceAttestationV1 { + issuer: PublicKey, + trade_id: TradeId, + claim_mutation_id: MutationId, + outcome: RadrootsRhiEvidenceAttestationOutcomeV1, + observed_at_unix_s: u64, + trade_generation: NonZeroU64, + statement_digest: [u8; 32], + supersession: Option<RadrootsRhiEvidenceAttestationSupersessionV1>, + canonical_content: Box<str>, +} + +impl RadrootsRhiEvidenceAttestationV1 { + pub fn from_canonical_content( + content: impl AsRef<[u8]>, + ) -> Result<Self, RadrootsRhiEvidenceAttestationError> { + let content = content.as_ref(); + if content.is_empty() || content.len() > RADROOTS_RHI_EVIDENCE_ATTESTATION_MAXIMUM_BYTES { + return Err(RadrootsRhiEvidenceAttestationError::NoncanonicalReportContent); + } + let raw: RawReport = serde_json::from_slice(content) + .map_err(|_| RadrootsRhiEvidenceAttestationError::NoncanonicalReportContent)?; + validate_fixed_fields(&raw)?; + validate_reason_codes(&raw.reason_codes)?; + + let value: Value = serde_json::from_slice(content) + .map_err(|_| RadrootsRhiEvidenceAttestationError::NoncanonicalReportContent)?; + let object = value + .as_object() + .ok_or(RadrootsRhiEvidenceAttestationError::NoncanonicalReportContent)?; + if !object.contains_key("supersedes_report_id") + || !object.contains_key("supersedes_event_id") + { + return Err(RadrootsRhiEvidenceAttestationError::NoncanonicalReportContent); + } + + let issuer = canonical_public_key(&raw.issuer_pubkey)?; + let trade_id = canonical_trade_id(&raw.trade_id)?; + let claim_mutation_id = canonical_mutation_id(&raw.claim_mutation_id)?; + let outcome = parse_outcome(&raw.outcome)?; + let trade_generation = NonZeroU64::new(raw.trade_generation) + .ok_or(RadrootsRhiEvidenceAttestationError::InvalidReport)?; + let report_id = parse_hex_32(&raw.report_id)?; + let declared_statement_digest = parse_hex_32(&raw.statement_digest)?; + if report_id != declared_statement_digest { + return Err(RadrootsRhiEvidenceAttestationError::StatementDigestMismatch); + } + for digest in [ + &raw.projection_digest, + &raw.evidence_manifest_digest, + &raw.evidence_policy_digest, + ] { + parse_hex_32(digest)?; + } + let supersession = parse_supersession( + raw.supersedes_report_id.as_deref(), + raw.supersedes_event_id.as_deref(), + )?; + + let canonical_content = canonical_jcs_value(&value) + .map_err(|_| RadrootsRhiEvidenceAttestationError::NoncanonicalReportContent)?; + if canonical_content.as_bytes() != content { + return Err(RadrootsRhiEvidenceAttestationError::NoncanonicalReportContent); + } + let mut statement = value; + let object = statement + .as_object_mut() + .ok_or(RadrootsRhiEvidenceAttestationError::NoncanonicalReportContent)?; + object.remove("report_id"); + object.remove("statement_digest"); + let statement_payload = canonical_jcs_value(&statement) + .map_err(|_| RadrootsRhiEvidenceAttestationError::NoncanonicalReportContent)?; + let mut hasher = Sha256::new(); + hasher.update(STATEMENT_DIGEST_DOMAIN); + hasher.update(statement_payload.as_bytes()); + let computed_statement_digest: [u8; 32] = hasher.finalize().into(); + if computed_statement_digest != declared_statement_digest { + return Err(RadrootsRhiEvidenceAttestationError::StatementDigestMismatch); + } + + Ok(Self { + issuer, + trade_id, + claim_mutation_id, + outcome, + observed_at_unix_s: raw.observed_at_unix_s, + trade_generation, + statement_digest: declared_statement_digest, + supersession, + canonical_content: canonical_content.into_boxed_str(), + }) + } + + #[must_use] + pub const fn issuer(&self) -> &PublicKey { + &self.issuer + } + + #[must_use] + pub const fn trade_id(&self) -> &TradeId { + &self.trade_id + } + + #[must_use] + pub const fn claim_mutation_id(&self) -> &MutationId { + &self.claim_mutation_id + } + + #[must_use] + pub const fn outcome(&self) -> RadrootsRhiEvidenceAttestationOutcomeV1 { + self.outcome + } + + #[must_use] + pub const fn observed_at_unix_s(&self) -> u64 { + self.observed_at_unix_s + } + + #[must_use] + pub const fn trade_generation(&self) -> NonZeroU64 { + self.trade_generation + } + + #[must_use] + pub const fn statement_digest(&self) -> &[u8; 32] { + &self.statement_digest + } + + #[must_use] + pub const fn supersession(&self) -> Option<RadrootsRhiEvidenceAttestationSupersessionV1> { + self.supersession + } + + #[must_use] + pub fn canonical_content(&self) -> &str { + &self.canonical_content + } +} + +impl fmt::Debug for RadrootsRhiEvidenceAttestationV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RadrootsRhiEvidenceAttestationV1") + .field("outcome", &self.outcome) + .field("has_supersession", &self.supersession.is_some()) + .finish_non_exhaustive() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsRhiEvidenceAttestationError { + InvalidAttestationKind, + IssuerAuthorMismatch, + NoncanonicalReportContent, + StatementDigestMismatch, + InvalidOutcome, + MissingClaimTag, + DuplicateTradeTag, + DuplicateStatementTag, + IncompleteSupersessionReference, + StaleTradeGeneration, + CallerStructuralTagForbidden, + InvalidIdentifier, + InvalidReport, + InvalidTagShape, + UnexpectedTag, + ContractTagMismatch, + TradeTagMismatch, + ClaimTagMismatch, + StatementTagMismatch, + OutcomeTagMismatch, + SupersessionTagMismatch, +} + +impl RadrootsRhiEvidenceAttestationError { + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidAttestationKind => "invalid_attestation_kind", + Self::IssuerAuthorMismatch => "issuer_author_mismatch", + Self::NoncanonicalReportContent => "noncanonical_report_content", + Self::StatementDigestMismatch => "statement_digest_mismatch", + Self::InvalidOutcome => "invalid_outcome", + Self::MissingClaimTag => "missing_claim_tag", + Self::DuplicateTradeTag => "duplicate_trade_tag", + Self::DuplicateStatementTag => "duplicate_statement_tag", + Self::IncompleteSupersessionReference => "incomplete_supersession_reference", + Self::StaleTradeGeneration => "stale_trade_generation", + Self::CallerStructuralTagForbidden => "caller_structural_tag_forbidden", + Self::InvalidIdentifier => "invalid_identifier", + Self::InvalidReport => "invalid_report", + Self::InvalidTagShape => "invalid_tag_shape", + Self::UnexpectedTag => "unexpected_tag", + Self::ContractTagMismatch => "contract_tag_mismatch", + Self::TradeTagMismatch => "trade_tag_mismatch", + Self::ClaimTagMismatch => "claim_tag_mismatch", + Self::StatementTagMismatch => "statement_tag_mismatch", + Self::OutcomeTagMismatch => "outcome_tag_mismatch", + Self::SupersessionTagMismatch => "supersession_tag_mismatch", + } + } +} + +impl fmt::Display for RadrootsRhiEvidenceAttestationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidAttestationKind => "RHI attestation kind is invalid", + Self::IssuerAuthorMismatch => "RHI attestation issuer does not match author", + Self::NoncanonicalReportContent => "RHI attestation report is not canonical", + Self::StatementDigestMismatch => "RHI attestation statement digest does not match", + Self::InvalidOutcome => "RHI attestation outcome is invalid", + Self::MissingClaimTag => "RHI attestation claim tag is missing", + Self::DuplicateTradeTag => "RHI attestation trade tag is duplicated", + Self::DuplicateStatementTag => "RHI attestation statement tag is duplicated", + Self::IncompleteSupersessionReference => "RHI attestation supersession is incomplete", + Self::StaleTradeGeneration => "RHI attestation supersession is stale", + Self::CallerStructuralTagForbidden => "caller supplied a governed RHI attestation tag", + Self::InvalidIdentifier => "RHI attestation identifier is invalid", + Self::InvalidReport => "RHI attestation report is invalid", + Self::InvalidTagShape => "RHI attestation tag shape is invalid", + Self::UnexpectedTag => "RHI attestation tag is not permitted", + Self::ContractTagMismatch => "RHI attestation contract tag does not match", + Self::TradeTagMismatch => "RHI attestation trade tag does not match", + Self::ClaimTagMismatch => "RHI attestation claim tag does not match", + Self::StatementTagMismatch => "RHI attestation statement tag does not match", + Self::OutcomeTagMismatch => "RHI attestation outcome tag does not match", + Self::SupersessionTagMismatch => "RHI attestation supersession tag does not match", + }) + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsRhiEvidenceAttestationError {} + +pub fn rhi_evidence_attestation_event_build( + attestation: &RadrootsRhiEvidenceAttestationV1, +) -> Nip01EventWireParts { + Nip01EventWireParts { + kind: KIND_RHI_EVIDENCE_ATTESTATION, + tags: canonical_tags(attestation), + content: attestation.canonical_content().to_string(), + } +} + +pub fn rhi_evidence_attestation_event_build_with_extra_tags( + attestation: &RadrootsRhiEvidenceAttestationV1, + extra_tags: &[Vec<String>], +) -> Result<Nip01EventWireParts, RadrootsRhiEvidenceAttestationError> { + if extra_tags.iter().any(|tag| { + matches!( + tag.first().map(String::as_str), + Some("contract" | "d" | "x" | "t" | "e") + ) + }) { + return Err(RadrootsRhiEvidenceAttestationError::CallerStructuralTagForbidden); + } + if !extra_tags.is_empty() { + return Err(RadrootsRhiEvidenceAttestationError::UnexpectedTag); + } + Ok(rhi_evidence_attestation_event_build(attestation)) +} + +/// Structurally parses an RHI attestation without claiming signature proof. +pub fn rhi_evidence_attestation_from_event( + event: &EventEnvelope, +) -> Result<RadrootsRhiEvidenceAttestationV1, RadrootsRhiEvidenceAttestationError> { + validate_parts( + event.kind_u32(), + &event.author().to_hex(), + &event.tags_as_vec(), + event.content(), + ) +} + +/// Validates an RHI attestation whose NIP-01 signature is already verified. +pub fn rhi_evidence_attestation_from_verified_event( + event: &SignatureVerifiedEvent, +) -> Result<RadrootsRhiEvidenceAttestationV1, RadrootsRhiEvidenceAttestationError> { + rhi_evidence_attestation_from_event(event.event()) +} + +pub fn validate_rhi_evidence_attestation_tags( + attestation: &RadrootsRhiEvidenceAttestationV1, + tags: &[Vec<String>], +) -> Result<(), RadrootsRhiEvidenceAttestationError> { + if tags.len() > MAXIMUM_TAGS { + return Err(RadrootsRhiEvidenceAttestationError::InvalidTagShape); + } + let trade_count = count_unmarked(tags, "d"); + if trade_count > 1 { + return Err(RadrootsRhiEvidenceAttestationError::DuplicateTradeTag); + } + let statement_count = count_marked(tags, "x", "statement"); + if statement_count > 1 { + return Err(RadrootsRhiEvidenceAttestationError::DuplicateStatementTag); + } + let claim_count = count_marked(tags, "x", "claim"); + if claim_count == 0 { + return Err(RadrootsRhiEvidenceAttestationError::MissingClaimTag); + } + let report_count = count_marked(tags, "x", "supersedes_report"); + let event_count = count_unmarked(tags, "e"); + if report_count != event_count { + return Err(RadrootsRhiEvidenceAttestationError::IncompleteSupersessionReference); + } + if trade_count != 1 + || statement_count != 1 + || claim_count != 1 + || report_count > 1 + || count_unmarked(tags, "contract") != 1 + || count_unmarked(tags, "t") != 1 + || count_named(tags, "x") != claim_count + statement_count + report_count + { + return Err(RadrootsRhiEvidenceAttestationError::InvalidTagShape); + } + if tags.iter().any(|tag| { + !matches!( + tag.first().map(String::as_str), + Some("contract" | "d" | "x" | "t" | "e") + ) + }) { + return Err(RadrootsRhiEvidenceAttestationError::UnexpectedTag); + } + + if exact_unmarked(tags.first(), "contract")? != RADROOTS_RHI_EVIDENCE_ATTESTATION_CONTRACT_ID { + return Err(RadrootsRhiEvidenceAttestationError::ContractTagMismatch); + } + if canonical_trade_id(exact_unmarked(tags.get(1), "d")?)? != attestation.trade_id { + return Err(RadrootsRhiEvidenceAttestationError::TradeTagMismatch); + } + if canonical_mutation_id(exact_marked(tags.get(2), "x", "claim")?)? + != attestation.claim_mutation_id + { + return Err(RadrootsRhiEvidenceAttestationError::ClaimTagMismatch); + } + if parse_hex_32(exact_marked(tags.get(3), "x", "statement")?)? != attestation.statement_digest { + return Err(RadrootsRhiEvidenceAttestationError::StatementTagMismatch); + } + if exact_unmarked(tags.get(4), "t")? != attestation.outcome.topic() { + return Err(RadrootsRhiEvidenceAttestationError::OutcomeTagMismatch); + } + match attestation.supersession { + None if tags.len() == 5 => Ok(()), + Some(supersession) if tags.len() == 7 => { + if parse_hex_32(exact_marked(tags.get(5), "x", "supersedes_report")?)? + != supersession.report_id + || canonical_event_id(exact_unmarked(tags.get(6), "e")?)? != supersession.event_id + { + return Err(RadrootsRhiEvidenceAttestationError::SupersessionTagMismatch); + } + Ok(()) + } + _ => Err(RadrootsRhiEvidenceAttestationError::IncompleteSupersessionReference), + } +} + +pub fn validate_rhi_evidence_attestation_supersession( + current: &RadrootsRhiEvidenceAttestationV1, + current_event_id: &EventId, + candidate: &RadrootsRhiEvidenceAttestationV1, +) -> Result<(), RadrootsRhiEvidenceAttestationError> { + if current.trade_id != candidate.trade_id { + return Err(RadrootsRhiEvidenceAttestationError::SupersessionTagMismatch); + } + let Some(supersession) = candidate.supersession else { + return Err(RadrootsRhiEvidenceAttestationError::IncompleteSupersessionReference); + }; + if supersession.report_id != current.statement_digest + || supersession.event_id != *current_event_id + { + return Err(RadrootsRhiEvidenceAttestationError::SupersessionTagMismatch); + } + let ordering = candidate + .trade_generation + .cmp(¤t.trade_generation) + .then_with(|| { + candidate + .observed_at_unix_s + .cmp(¤t.observed_at_unix_s) + }) + .then_with(|| candidate.statement_digest.cmp(¤t.statement_digest)); + if ordering != Ordering::Greater { + return Err(RadrootsRhiEvidenceAttestationError::StaleTradeGeneration); + } + Ok(()) +} + +pub(crate) fn validate_parts( + kind: u32, + author: &str, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsRhiEvidenceAttestationV1, RadrootsRhiEvidenceAttestationError> { + if kind != KIND_RHI_EVIDENCE_ATTESTATION { + return Err(RadrootsRhiEvidenceAttestationError::InvalidAttestationKind); + } + let attestation = RadrootsRhiEvidenceAttestationV1::from_canonical_content(content)?; + if canonical_public_key(author)? != attestation.issuer { + return Err(RadrootsRhiEvidenceAttestationError::IssuerAuthorMismatch); + } + validate_rhi_evidence_attestation_tags(&attestation, tags)?; + Ok(attestation) +} + +fn canonical_tags(attestation: &RadrootsRhiEvidenceAttestationV1) -> Vec<Vec<String>> { + let mut tags = Vec::with_capacity(if attestation.supersession.is_some() { + 7 + } else { + 5 + }); + tags.push(vec![ + "contract".to_string(), + RADROOTS_RHI_EVIDENCE_ATTESTATION_CONTRACT_ID.to_string(), + ]); + tags.push(vec!["d".to_string(), attestation.trade_id.to_hex()]); + tags.push(vec![ + "x".to_string(), + attestation.claim_mutation_id.to_hex(), + "claim".to_string(), + ]); + tags.push(vec![ + "x".to_string(), + hex::encode(attestation.statement_digest), + "statement".to_string(), + ]); + tags.push(vec!["t".to_string(), attestation.outcome.topic()]); + if let Some(supersession) = attestation.supersession { + tags.push(vec![ + "x".to_string(), + hex::encode(supersession.report_id), + "supersedes_report".to_string(), + ]); + tags.push(vec!["e".to_string(), supersession.event_id.to_hex()]); + } + tags +} + +fn validate_fixed_fields(raw: &RawReport) -> Result<(), RadrootsRhiEvidenceAttestationError> { + if raw.contract_id != RADROOTS_RHI_EVIDENCE_ATTESTATION_CONTRACT_ID + || raw.contract_version != 1 + || raw.reducer_contract_id != "radroots.trade.reducer.v1" + || raw.reducer_contract_version != 1 + || raw.attestation_method != "signed_evidence_snapshot" + { + return Err(RadrootsRhiEvidenceAttestationError::InvalidReport); + } + Ok(()) +} + +fn validate_reason_codes(codes: &[String]) -> Result<(), RadrootsRhiEvidenceAttestationError> { + if codes.is_empty() || codes.len() > MAXIMUM_REASON_CODES { + return Err(RadrootsRhiEvidenceAttestationError::InvalidReport); + } + let mut previous: Option<&str> = None; + for code in codes { + let bytes = code.as_bytes(); + if bytes.is_empty() + || bytes.len() > MAXIMUM_REASON_CODE_BYTES + || !bytes[0].is_ascii_lowercase() + || !bytes[bytes.len() - 1].is_ascii_alphanumeric() + || bytes + .iter() + .any(|byte| !(byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_')) + || previous.is_some_and(|value| value >= code.as_str()) + { + return Err(RadrootsRhiEvidenceAttestationError::InvalidReport); + } + previous = Some(code); + } + Ok(()) +} + +fn parse_outcome( + outcome: &str, +) -> Result<RadrootsRhiEvidenceAttestationOutcomeV1, RadrootsRhiEvidenceAttestationError> { + match outcome { + "valid" => Ok(RadrootsRhiEvidenceAttestationOutcomeV1::Valid), + "invalid" => Ok(RadrootsRhiEvidenceAttestationOutcomeV1::Invalid), + "indeterminate" => Ok(RadrootsRhiEvidenceAttestationOutcomeV1::Indeterminate), + _ => Err(RadrootsRhiEvidenceAttestationError::InvalidOutcome), + } +} + +fn parse_supersession( + report: Option<&str>, + event: Option<&str>, +) -> Result<Option<RadrootsRhiEvidenceAttestationSupersessionV1>, RadrootsRhiEvidenceAttestationError> +{ + match (report, event) { + (None, None) => Ok(None), + (Some(report), Some(event)) => Ok(Some(RadrootsRhiEvidenceAttestationSupersessionV1 { + report_id: parse_hex_32(report)?, + event_id: canonical_event_id(event)?, + })), + _ => Err(RadrootsRhiEvidenceAttestationError::IncompleteSupersessionReference), + } +} + +fn canonical_public_key(value: &str) -> Result<PublicKey, RadrootsRhiEvidenceAttestationError> { + let key = PublicKey::from_hex(value) + .map_err(|_| RadrootsRhiEvidenceAttestationError::InvalidIdentifier)?; + if key.to_hex() != value { + return Err(RadrootsRhiEvidenceAttestationError::InvalidIdentifier); + } + Ok(key) +} + +fn canonical_trade_id(value: &str) -> Result<TradeId, RadrootsRhiEvidenceAttestationError> { + let id = TradeId::parse(value) + .map_err(|_| RadrootsRhiEvidenceAttestationError::InvalidIdentifier)?; + if id.to_hex() != value { + return Err(RadrootsRhiEvidenceAttestationError::InvalidIdentifier); + } + Ok(id) +} + +fn canonical_mutation_id(value: &str) -> Result<MutationId, RadrootsRhiEvidenceAttestationError> { + let id = MutationId::parse(value) + .map_err(|_| RadrootsRhiEvidenceAttestationError::InvalidIdentifier)?; + if id.to_hex() != value { + return Err(RadrootsRhiEvidenceAttestationError::InvalidIdentifier); + } + Ok(id) +} + +fn canonical_event_id(value: &str) -> Result<EventId, RadrootsRhiEvidenceAttestationError> { + let id = EventId::parse(value) + .map_err(|_| RadrootsRhiEvidenceAttestationError::InvalidIdentifier)?; + if id.to_hex() != value { + return Err(RadrootsRhiEvidenceAttestationError::InvalidIdentifier); + } + Ok(id) +} + +fn parse_hex_32(value: &str) -> Result<[u8; 32], RadrootsRhiEvidenceAttestationError> { + if value.len() != 64 + || value + .bytes() + .any(|byte| !(byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))) + { + return Err(RadrootsRhiEvidenceAttestationError::InvalidIdentifier); + } + let mut bytes = [0_u8; 32]; + hex::decode_to_slice(value, &mut bytes) + .map_err(|_| RadrootsRhiEvidenceAttestationError::InvalidIdentifier)?; + Ok(bytes) +} + +fn count_named(tags: &[Vec<String>], name: &str) -> usize { + tags.iter() + .filter(|tag| tag.first().map(String::as_str) == Some(name)) + .count() +} + +fn count_unmarked(tags: &[Vec<String>], name: &str) -> usize { + tags.iter() + .filter(|tag| tag.len() == 2 && tag.first().map(String::as_str) == Some(name)) + .count() +} + +fn count_marked(tags: &[Vec<String>], name: &str, marker: &str) -> usize { + tags.iter() + .filter(|tag| { + tag.len() == 3 + && tag.first().map(String::as_str) == Some(name) + && tag.get(2).map(String::as_str) == Some(marker) + }) + .count() +} + +fn exact_unmarked<'a>( + tag: Option<&'a Vec<String>>, + name: &str, +) -> Result<&'a str, RadrootsRhiEvidenceAttestationError> { + let tag = tag.ok_or(RadrootsRhiEvidenceAttestationError::InvalidTagShape)?; + if tag.len() != 2 || tag.first().map(String::as_str) != Some(name) { + return Err(RadrootsRhiEvidenceAttestationError::InvalidTagShape); + } + Ok(&tag[1]) +} + +fn exact_marked<'a>( + tag: Option<&'a Vec<String>>, + name: &str, + marker: &str, +) -> Result<&'a str, RadrootsRhiEvidenceAttestationError> { + let tag = tag.ok_or(RadrootsRhiEvidenceAttestationError::InvalidTagShape)?; + if tag.len() != 3 + || tag.first().map(String::as_str) != Some(name) + || tag.get(2).map(String::as_str) != Some(marker) + { + return Err(RadrootsRhiEvidenceAttestationError::InvalidTagShape); + } + Ok(&tag[1]) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawReport { + attestation_method: String, + claim_mutation_id: String, + contract_id: String, + contract_version: u16, + evidence_manifest_digest: String, + evidence_policy_digest: String, + issuer_pubkey: String, + observed_at_unix_s: u64, + outcome: String, + projection_digest: String, + reason_codes: Vec<String>, + reducer_contract_id: String, + reducer_contract_version: u16, + report_id: String, + statement_digest: String, + supersedes_event_id: Option<String>, + supersedes_report_id: Option<String>, + trade_generation: u64, + trade_id: String, +} diff --git a/crates/event_codec/tests/authored_typed_plans.rs b/crates/event_codec/tests/authored_typed_plans.rs @@ -38,6 +38,7 @@ use radroots_event::{ use radroots_event_codec::authoring::{ AuthoredEventPlan, PlanDecodeError, PlanWireV1, REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS, }; +use radroots_event_codec::decode::rhi::RadrootsRhiEvidenceAttestationV1; use radroots_identity::PublicKey; use serde::Deserialize; use serde_json::Value; @@ -48,6 +49,7 @@ const CREATED_AT: u64 = 1_784_347_200; const ROOT_EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const TARGET_EVENT_ID: &str = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; const RELAY: &str = "wss://relay.example.com"; +const RHI_REPORT: &str = "{\"attestation_method\":\"signed_evidence_snapshot\",\"claim_mutation_id\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"contract_id\":\"radroots.rhi.evidence_attestation.v1\",\"contract_version\":1,\"evidence_manifest_digest\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"evidence_policy_digest\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"issuer_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"observed_at_unix_s\":1800000000,\"outcome\":\"indeterminate\",\"projection_digest\":\"6666666666666666666666666666666666666666666666666666666666666666\",\"reason_codes\":[\"required_source_incomplete\"],\"reducer_contract_id\":\"radroots.trade.reducer.v1\",\"reducer_contract_version\":1,\"report_id\":\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\",\"statement_digest\":\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\",\"supersedes_event_id\":null,\"supersedes_report_id\":null,\"trade_generation\":7,\"trade_id\":\"11111111111111111111111111111111\"}"; const WORKSPACE_CORPUS_PATH: &str = "../../contracts/conformance/vectors/event/authored_operations.v1.json"; @@ -212,6 +214,33 @@ fn trade_plan_history_rejects_author_time_content_and_tag_drift() { } } +#[test] +fn rhi_plan_history_rejects_author_time_content_and_tag_drift() { + let plans = typed_plans(); + let plan = plans + .get("radroots.rhi.evidence_attestation.v1") + .expect("RHI plan"); + for drifted in [ + mutate_plan_wire(plan, |value| { + value["expected_author"] = Value::String(OTHER_AUTHOR.to_owned()); + }), + mutate_plan_wire(plan, |value| { + value["content"] = Value::String(format!("{} ", RHI_REPORT)); + }), + mutate_plan_wire(plan, |value| { + value["tags"][2][2] = Value::String("unknown".to_owned()); + }), + mutate_plan_wire(plan, |value| { + value["tags"].as_array_mut().expect("tags").swap(0, 1); + }), + ] { + assert!(matches!( + PlanWireV1::from_json(&drifted), + Err(PlanDecodeError::HistoricalShape(_)) + )); + } +} + fn mutate_plan_wire(plan: &AuthoredEventPlan, mutator: impl FnOnce(&mut Value)) -> Vec<u8> { let mut value = serde_json::from_slice::<Value>(&PlanWireV1::from_plan(plan).to_json().expect("plan wire")) @@ -344,6 +373,12 @@ fn typed_plans() -> BTreeMap<&'static str, AuthoredEventPlan> { AuthoredEventPlan::from_trade_mutation(mutation).expect("trade plan"), ); } + let report = + RadrootsRhiEvidenceAttestationV1::from_canonical_content(RHI_REPORT).expect("RHI report"); + plans.insert( + "radroots.rhi.evidence_attestation.v1", + AuthoredEventPlan::from_rhi_evidence_attestation(&report, CREATED_AT).expect("RHI plan"), + ); plans } diff --git a/crates/event_codec/tests/package_boundary.rs b/crates/event_codec/tests/package_boundary.rs @@ -6,6 +6,7 @@ use radroots_event_codec::{authoring as _, canonical as _, decode as _, encode a const MANIFEST: &str = include_str!("../Cargo.toml"); const README: &str = include_str!("../README.md"); const ROOT: &str = include_str!("../src/lib.rs"); +const RHI: &str = include_str!("../src/rhi.rs"); const VERIFICATION: &str = include_str!("../src/verification/v1.rs"); const EXAMPLE: &str = include_str!("../examples/verify_profile.rs"); const FUZZ_LOCK: &str = include_str!("../../../fuzz/event_codec/Cargo.lock"); @@ -125,6 +126,27 @@ fn compatibility_surface_is_removed() { } #[test] +fn rhi_contract_is_private_curated_and_host_free() { + assert!(ROOT.contains("#[cfg(feature = \"json\")]\nmod rhi;")); + assert!(!ROOT.contains("pub mod rhi;")); + + for forbidden in [ + "nostr_sdk::", + "reqwest::", + "sqlx::", + "tokio::", + "std::fs", + "std::net", + "std::process", + ] { + assert!( + !RHI.contains(forbidden), + "RHI codec must not acquire host authority through {forbidden}" + ); + } +} + +#[test] fn codec_runtime_is_protocol_neutral_and_host_free() { let features = table_keys(MANIFEST, "[features]"); let dependencies = table_keys(MANIFEST, "[dependencies]"); @@ -204,8 +226,12 @@ fn package_documentation_and_reviewed_api_baseline_are_complete() { "pub mod radroots_event_codec::verify", "pub use radroots_event_codec::VerificationError", "pub struct radroots_event_codec::authoring::BlossomAuthorizationPlan", - "pub const radroots_event_codec::authoring::REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS: [&str; 15]", + "pub const radroots_event_codec::authoring::REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS: [&str; 16]", "pub fn radroots_event_codec::authoring::AuthoredEventPlan::from_trade_mutation", + "pub fn radroots_event_codec::authoring::AuthoredEventPlan::from_rhi_evidence_attestation", + "pub fn radroots_event_codec::decode::rhi::rhi_evidence_attestation_from_verified_event", + "pub fn radroots_event_codec::decode::rhi::validate_rhi_evidence_attestation_supersession", + "pub fn radroots_event_codec::encode::rhi::rhi_evidence_attestation_event_build_with_extra_tags", "pub fn radroots_event_codec::decode::trade::trade_mutation_from_verified_event", "pub fn radroots_event_codec::encode::trade::trade_mutation_event_build_with_extra_tags", "pub enum radroots_event_codec::decode::trade::RadrootsTradeMutationError", diff --git a/crates/event_codec/tests/rhi_attestation.rs b/crates/event_codec/tests/rhi_attestation.rs @@ -0,0 +1,394 @@ +#![cfg(feature = "json")] + +use std::error::Error as _; + +use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; +use radroots_event::{ + admission::RawEvent, + envelope::{EventEnvelope, EventEnvelopeParts}, + id::EventId, + trade::canonical_jcs_value, +}; +use radroots_event_codec::{ + decode::rhi::{ + RadrootsRhiEvidenceAttestationError, RadrootsRhiEvidenceAttestationV1, + rhi_evidence_attestation_from_event, rhi_evidence_attestation_from_verified_event, + validate_rhi_evidence_attestation_supersession, validate_rhi_evidence_attestation_tags, + }, + encode::rhi::{ + RADROOTS_RHI_EVIDENCE_ATTESTATION_MAXIMUM_BYTES, rhi_evidence_attestation_event_build, + rhi_evidence_attestation_event_build_with_extra_tags, + }, + verify::Nip01SignatureVerifier, +}; +use serde_json::Value; +use sha2::{Digest as _, Sha256}; + +const VECTORS: &str = include_str!( + "../../../contracts/conformance/vectors/rhi/evidence_attestation_decision.v1.json" +); +const DIGEST_DOMAIN: &[u8] = b"radroots:rhi-evidence-attestation-statement:v1\0"; + +fn vectors() -> Value { + serde_json::from_str(VECTORS).expect("RHI conformance vectors") +} + +fn vector<'a>(vectors: &'a Value, id: &str) -> &'a Value { + vectors["vectors"] + .as_array() + .expect("vector list") + .iter() + .find(|vector| vector["id"] == id) + .unwrap_or_else(|| panic!("missing vector {id}")) +} + +fn positive_content(vector: &Value) -> &str { + vector["expected"]["canonical_event_content_utf8"] + .as_str() + .expect("canonical event content") +} + +fn tags(vector: &Value) -> Vec<Vec<String>> { + vector["expected"]["tags"] + .as_array() + .expect("tag list") + .iter() + .map(|tag| { + tag.as_array() + .expect("tag") + .iter() + .map(|value| value.as_str().expect("tag value").to_owned()) + .collect() + }) + .collect() +} + +fn structural_event( + kind: u32, + author: &str, + tags: Vec<Vec<String>>, + content: String, +) -> EventEnvelope { + EventEnvelope::new(EventEnvelopeParts { + id: "0".repeat(64), + author: author.to_owned(), + created_at: 1_800_000_000, + kind, + tags, + content, + sig: "1".repeat(128), + }) + .expect("structurally valid event") +} + +fn canonical_report_from_statement(statement: Value) -> String { + let payload = canonical_jcs_value(&statement).expect("canonical statement"); + let mut hasher = Sha256::new(); + hasher.update(DIGEST_DOMAIN); + hasher.update(payload.as_bytes()); + let digest = hex::encode(hasher.finalize()); + let mut report = statement; + let object = report.as_object_mut().expect("statement object"); + object.insert("report_id".to_owned(), Value::String(digest.clone())); + object.insert("statement_digest".to_owned(), Value::String(digest)); + canonical_jcs_value(&report).expect("canonical report") +} + +#[test] +fn all_frozen_rhi_vectors_execute_their_governed_boundaries() { + let vectors = vectors(); + let current_vector = vector(&vectors, "rhi_evidence_attestation_current_001"); + let superseding_vector = vector(&vectors, "rhi_evidence_attestation_superseding_002"); + let current = + RadrootsRhiEvidenceAttestationV1::from_canonical_content(positive_content(current_vector)) + .expect("current report"); + let superseding = RadrootsRhiEvidenceAttestationV1::from_canonical_content(positive_content( + superseding_vector, + )) + .expect("superseding report"); + + for (fixture, attestation) in [ + (current_vector, ¤t), + (superseding_vector, &superseding), + ] { + let built = rhi_evidence_attestation_event_build(attestation); + assert_eq!(built.kind, 3441); + assert_eq!(built.tags, tags(fixture)); + assert_eq!(built.content, positive_content(fixture)); + validate_rhi_evidence_attestation_tags(attestation, &built.tags) + .expect("independent tag validator"); + let parsed = rhi_evidence_attestation_from_event(&structural_event( + built.kind, + &attestation.issuer().to_hex(), + built.tags, + built.content, + )) + .expect("structural parser"); + assert_eq!(&parsed, attestation); + } + + let negative_ids = vectors["vectors"] + .as_array() + .expect("vectors") + .iter() + .filter(|vector| vector["kind"] == "rhi.evidence_attestation.invalid") + .map(|vector| vector["id"].as_str().expect("vector id")) + .collect::<Vec<_>>(); + assert_eq!(negative_ids.len(), 11); + + let current_parts = rhi_evidence_attestation_event_build(¤t); + let error = rhi_evidence_attestation_from_event(&structural_event( + 3440, + ¤t.issuer().to_hex(), + current_parts.tags.clone(), + current_parts.content.clone(), + )) + .expect_err("wrong kind"); + assert_vector_error(&vectors, "rhi_evidence_attestation_wrong_kind_003", error); + + let error = rhi_evidence_attestation_from_event(&structural_event( + current_parts.kind, + "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", + current_parts.tags.clone(), + current_parts.content.clone(), + )) + .expect_err("wrong author"); + assert_vector_error(&vectors, "rhi_evidence_attestation_wrong_author_004", error); + + let error = rhi_evidence_attestation_from_event(&structural_event( + current_parts.kind, + ¤t.issuer().to_hex(), + current_parts.tags.clone(), + format!("{} ", current_parts.content), + )) + .expect_err("noncanonical content"); + assert_vector_error( + &vectors, + "rhi_evidence_attestation_noncanonical_content_005", + error, + ); + + let mut digest_mismatch: Value = serde_json::from_str(¤t_parts.content).unwrap(); + digest_mismatch["statement_digest"] = Value::String("0".repeat(64)); + let error = RadrootsRhiEvidenceAttestationV1::from_canonical_content( + canonical_jcs_value(&digest_mismatch).unwrap(), + ) + .expect_err("digest mismatch"); + assert_vector_error( + &vectors, + "rhi_evidence_attestation_digest_mismatch_006", + error, + ); + + let mut unknown_outcome: Value = serde_json::from_str(¤t_parts.content).unwrap(); + unknown_outcome["outcome"] = Value::String("complete".to_owned()); + let error = RadrootsRhiEvidenceAttestationV1::from_canonical_content( + canonical_jcs_value(&unknown_outcome).unwrap(), + ) + .expect_err("unknown outcome"); + assert_vector_error( + &vectors, + "rhi_evidence_attestation_unknown_outcome_007", + error, + ); + + let mut missing_claim = current_parts.tags.clone(); + missing_claim.retain(|tag| tag.get(2).map(String::as_str) != Some("claim")); + assert_vector_error( + &vectors, + "rhi_evidence_attestation_missing_claim_tag_008", + validate_rhi_evidence_attestation_tags(¤t, &missing_claim).unwrap_err(), + ); + + let mut duplicate_trade = current_parts.tags.clone(); + duplicate_trade.push(vec!["d".to_owned(), "9".repeat(32)]); + assert_vector_error( + &vectors, + "rhi_evidence_attestation_duplicate_trade_tag_009", + validate_rhi_evidence_attestation_tags(¤t, &duplicate_trade).unwrap_err(), + ); + + let mut duplicate_statement = current_parts.tags.clone(); + duplicate_statement.push(vec!["x".to_owned(), "9".repeat(64), "statement".to_owned()]); + assert_vector_error( + &vectors, + "rhi_evidence_attestation_duplicate_statement_tag_010", + validate_rhi_evidence_attestation_tags(¤t, &duplicate_statement).unwrap_err(), + ); + + let mut incomplete: Value = serde_json::from_str(¤t_parts.content).unwrap(); + incomplete["supersedes_report_id"] = Value::String("7".repeat(64)); + let error = RadrootsRhiEvidenceAttestationV1::from_canonical_content( + canonical_jcs_value(&incomplete).unwrap(), + ) + .expect_err("incomplete supersession"); + assert_vector_error( + &vectors, + "rhi_evidence_attestation_incomplete_supersession_011", + error, + ); + + let mut current_statement = current_vector["input"]["statement_payload"].clone(); + current_statement["trade_generation"] = Value::from(8); + current_statement["observed_at_unix_s"] = Value::from(1_800_000_100_u64); + let ordered_current = RadrootsRhiEvidenceAttestationV1::from_canonical_content( + canonical_report_from_statement(current_statement.clone()), + ) + .unwrap(); + let current_event_id = EventId::parse("8".repeat(64)).unwrap(); + current_statement["trade_generation"] = Value::from(7); + current_statement["observed_at_unix_s"] = Value::from(1_800_000_200_u64); + current_statement["supersedes_report_id"] = + Value::String(hex::encode(ordered_current.statement_digest())); + current_statement["supersedes_event_id"] = Value::String(current_event_id.to_hex()); + let stale = RadrootsRhiEvidenceAttestationV1::from_canonical_content( + canonical_report_from_statement(current_statement), + ) + .unwrap(); + assert_vector_error( + &vectors, + "rhi_evidence_attestation_stale_supersession_012", + validate_rhi_evidence_attestation_supersession(&ordered_current, ¤t_event_id, &stale) + .unwrap_err(), + ); + + assert_vector_error( + &vectors, + "rhi_evidence_attestation_caller_structural_tag_013", + rhi_evidence_attestation_event_build_with_extra_tags( + ¤t, + &[vec!["d".to_owned(), current.trade_id().to_hex()]], + ) + .unwrap_err(), + ); +} + +fn assert_vector_error(vectors: &Value, id: &str, error: RadrootsRhiEvidenceAttestationError) { + assert_eq!( + error.code(), + vector(vectors, id)["expected"]["error_code"] + .as_str() + .expect("error code"), + "{id}" + ); +} + +#[test] +fn signed_attestation_requires_and_preserves_the_verified_typestate() { + let vectors = vectors(); + let base = vector(&vectors, "rhi_evidence_attestation_current_001"); + let keys = Keys::parse("0101010101010101010101010101010101010101010101010101010101010101") + .expect("fixture keys"); + let mut statement = base["input"]["statement_payload"].clone(); + statement["issuer_pubkey"] = Value::String(keys.public_key().to_hex()); + let report = RadrootsRhiEvidenceAttestationV1::from_canonical_content( + canonical_report_from_statement(statement), + ) + .expect("fixture report"); + let parts = rhi_evidence_attestation_event_build(&report); + let event = EventBuilder::new(Kind::Custom(parts.kind as u16), parts.content) + .tags( + parts + .tags + .into_iter() + .map(Tag::parse) + .collect::<Result<Vec<_>, _>>() + .expect("tags"), + ) + .custom_created_at(Timestamp::from_secs(1_800_000_000)) + .sign_with_keys(&keys) + .expect("signed event"); + let envelope = EventEnvelope::new(EventEnvelopeParts { + id: event.id.to_hex(), + author: event.pubkey.to_hex(), + created_at: event.created_at.as_secs(), + kind: u32::from(event.kind.as_u16()), + tags: event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect(), + content: event.content, + sig: event.sig.to_string(), + }) + .expect("event envelope"); + let verified = RawEvent::new(envelope) + .verify_id() + .expect("verified id") + .verify_signature(&Nip01SignatureVerifier) + .expect("verified signature"); + assert_eq!( + rhi_evidence_attestation_from_verified_event(&verified).unwrap(), + report + ); +} + +#[test] +fn malformed_shapes_bounds_and_diagnostics_fail_closed() { + let vectors = vectors(); + let fixture = vector(&vectors, "rhi_evidence_attestation_current_001"); + let report = + RadrootsRhiEvidenceAttestationV1::from_canonical_content(positive_content(fixture)) + .expect("report"); + let parts = rhi_evidence_attestation_event_build(&report); + + let mut reordered = parts.tags.clone(); + reordered.swap(0, 1); + assert_eq!( + validate_rhi_evidence_attestation_tags(&report, &reordered).unwrap_err(), + RadrootsRhiEvidenceAttestationError::InvalidTagShape + ); + let mut malformed_marker = parts.tags.clone(); + malformed_marker[2][2] = "unknown".to_owned(); + assert_eq!( + validate_rhi_evidence_attestation_tags(&report, &malformed_marker).unwrap_err(), + RadrootsRhiEvidenceAttestationError::MissingClaimTag + ); + let mut unknown = parts.tags.clone(); + unknown.push(vec!["a".to_owned(), "value".to_owned()]); + assert_eq!( + validate_rhi_evidence_attestation_tags(&report, &unknown).unwrap_err(), + RadrootsRhiEvidenceAttestationError::UnexpectedTag + ); + assert_eq!( + RadrootsRhiEvidenceAttestationV1::from_canonical_content(vec![ + b'x'; + RADROOTS_RHI_EVIDENCE_ATTESTATION_MAXIMUM_BYTES + + 1 + ]) + .unwrap_err(), + RadrootsRhiEvidenceAttestationError::NoncanonicalReportContent + ); + let mut missing_supersession_fields: Value = + serde_json::from_str(parts.content.as_str()).expect("report JSON"); + let object = missing_supersession_fields + .as_object_mut() + .expect("report object"); + object.remove("supersedes_event_id"); + object.remove("supersedes_report_id"); + assert_eq!( + RadrootsRhiEvidenceAttestationV1::from_canonical_content( + canonical_jcs_value(&missing_supersession_fields).expect("canonical malformed report") + ) + .unwrap_err(), + RadrootsRhiEvidenceAttestationError::NoncanonicalReportContent + ); + + let diagnostic = format!( + "{0:?} {0}", + RadrootsRhiEvidenceAttestationError::StatementDigestMismatch + ); + for secret in [ + report.issuer().to_hex(), + report.trade_id().to_hex(), + hex::encode(report.statement_digest()), + ] { + assert!(!diagnostic.contains(&secret)); + } + assert!( + RadrootsRhiEvidenceAttestationError::StatementDigestMismatch + .source() + .is_none() + ); + assert!(!format!("{report:?}").contains(&report.issuer().to_hex())); +} diff --git a/crates/event_codec/tests/services_hardening_event_decisions.rs b/crates/event_codec/tests/services_hardening_event_decisions.rs @@ -112,9 +112,13 @@ fn services_hardening_event_decision_reserves_unique_exact_kinds() { .as_u64() .expect("attestation kind"); assert_eq!(attestation_kind, 3441); - assert!( - !registered.contains(&attestation_kind), - "reserved attestation kind must not collide with a registered kind" + assert_eq!( + registered + .iter() + .filter(|kind| **kind == attestation_kind) + .count(), + 1, + "implemented attestation kind must be uniquely registered" ); } diff --git a/crates/event_codec/tests/wire.rs b/crates/event_codec/tests/wire.rs @@ -1,6 +1,6 @@ use radroots_event::GenericEventDraft; use radroots_event::draft::DraftError; -use radroots_event::envelope::kind::{KIND_GEOCHAT, KIND_PROFILE}; +use radroots_event::envelope::kind::{KIND_GEOCHAT, KIND_PROFILE, KIND_RHI_EVIDENCE_ATTESTATION}; use radroots_event::wire::Nip01EventWireParts; use radroots_event_codec::decode::wire::{canonicalize_tags, empty_content}; @@ -87,6 +87,23 @@ fn generic_draft_rejects_typed_only_contracts() { } #[test] +fn generic_draft_rejects_the_typed_only_rhi_attestation_contract() { + let error = GenericEventDraft::new( + "radroots.rhi.evidence_attestation.v1", + KIND_RHI_EVIDENCE_ATTESTATION, + 99, + Vec::new(), + "{}", + "a".repeat(64), + ) + .expect_err("RHI attestation requires the typed authoring path"); + assert!(matches!( + error, + DraftError::ContractNotDraftAuthorable { .. } + )); +} + +#[test] fn wire_empty_content_is_empty_string() { let content = empty_content(); assert!(content.is_empty()); diff --git a/crates/nostr/tests/authored_wire_corpus.rs b/crates/nostr/tests/authored_wire_corpus.rs @@ -39,7 +39,11 @@ use radroots_event::{ wire::canonical_nip01_event_id_preimage, }; use radroots_event_codec::{ - authoring::AuthoredEventPlan, decode::trade::trade_mutation_from_verified_event, + authoring::AuthoredEventPlan, + decode::{ + rhi::{RadrootsRhiEvidenceAttestationV1, rhi_evidence_attestation_from_verified_event}, + trade::trade_mutation_from_verified_event, + }, verify::verify_nip01_event, }; use radroots_identity::PublicKey; @@ -61,6 +65,7 @@ use support::{ const CREATED_AT: u64 = 1_784_347_200; const ROOT_EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const TARGET_EVENT_ID: &str = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; +const RHI_REPORT: &str = "{\"attestation_method\":\"signed_evidence_snapshot\",\"claim_mutation_id\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"contract_id\":\"radroots.rhi.evidence_attestation.v1\",\"contract_version\":1,\"evidence_manifest_digest\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"evidence_policy_digest\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"issuer_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"observed_at_unix_s\":1800000000,\"outcome\":\"indeterminate\",\"projection_digest\":\"6666666666666666666666666666666666666666666666666666666666666666\",\"reason_codes\":[\"required_source_incomplete\"],\"reducer_contract_id\":\"radroots.trade.reducer.v1\",\"reducer_contract_version\":1,\"report_id\":\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\",\"statement_digest\":\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\",\"supersedes_event_id\":null,\"supersedes_report_id\":null,\"trade_generation\":7,\"trade_id\":\"11111111111111111111111111111111\"}"; const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml"; const PACKAGED_CORPUS: &str = include_str!("fixtures/authored_operations.v1.json"); const WORKSPACE_CORPUS_PATH: &str = @@ -113,7 +118,6 @@ fn checked_in_authored_wire_corpus_is_exact_and_executable() { contract_version: "1.0.0".to_owned(), vectors: authored_wire_vectors(), }; - if expected.vectors.is_empty() { panic!( "authored corpus requires generated vectors:\n{}", @@ -121,7 +125,7 @@ fn checked_in_authored_wire_corpus_is_exact_and_executable() { ); } assert_eq!(actual, expected); - assert_eq!(actual.vectors.len(), 16); + assert_eq!(actual.vectors.len(), 17); assert_eq!(APPROVED_FIXTURE_NAMESPACE, "radroots-approved-fixture-v1"); assert_eq!( actual @@ -129,7 +133,7 @@ fn checked_in_authored_wire_corpus_is_exact_and_executable() { .iter() .filter(|vector| vector.input.authoring == "typed") .count(), - 15 + 16 ); assert!(actual.vectors.iter().all(|vector| { !vector @@ -400,9 +404,70 @@ fn authored_wire_vectors() -> Vec<WireVector> { vectors.push(typed_trade_vector(id, mutation, &keys)); } + vectors.push(typed_rhi_vector( + "typed_rhi_evidence_attestation_017", + &keys, + )); + vectors } +fn typed_rhi_vector(id: &str, keys: &Keys) -> WireVector { + let report = RadrootsRhiEvidenceAttestationV1::from_canonical_content(RHI_REPORT) + .expect("typed RHI report"); + let plan = AuthoredEventPlan::from_rhi_evidence_attestation(&report, CREATED_AT) + .expect("typed RHI plan"); + let tags = plan + .body() + .tags() + .iter() + .cloned() + .map(Tag::parse) + .collect::<Result<Vec<_>, _>>() + .expect("RHI tags"); + let event = UnsignedEvent { + id: Some( + nostr::EventId::from_hex(&plan.expected_event_id().to_hex()) + .expect("planned RHI event id"), + ), + pubkey: keys.public_key(), + created_at: Timestamp::from_secs(plan.created_at()), + kind: Kind::Custom(u16::try_from(plan.body().kind()).expect("RHI kind")), + tags: nostr::Tags::from_list(tags), + content: plan.body().content().to_owned(), + } + .sign_with_ctx(nostr::SECP256K1, &mut CorpusAuxRng, keys) + .expect("RHI event"); + let verified = verify_nip01_event( + EventEnvelope::new(EventEnvelopeParts { + id: event.id.to_hex(), + author: event.pubkey.to_hex(), + created_at: event.created_at.as_secs(), + kind: u32::from(event.kind.as_u16()), + tags: event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect(), + content: event.content.clone(), + sig: event.sig.to_string(), + }) + .expect("RHI event envelope"), + ) + .expect("verified RHI event"); + let signature_verified = radroots_event::admission::RawEvent::new(verified.into_event()) + .verify_id() + .expect("RHI identifier typestate") + .verify_signature(&radroots_event_codec::verify::Nip01SignatureVerifier) + .expect("RHI signature typestate"); + assert_eq!( + rhi_evidence_attestation_from_verified_event(&signature_verified) + .expect("validated RHI event"), + report + ); + vector(id, "radroots.rhi.evidence_attestation.v1", "typed", event) +} + fn typed_trade_vector(id: &str, mutation: TradeMutationEnvelopeV1, keys: &Keys) -> WireVector { let plan = AuthoredEventPlan::from_trade_mutation(mutation).expect("typed trade plan"); let tags = plan diff --git a/crates/nostr/tests/fixtures/authored_operations.v1.json b/crates/nostr/tests/fixtures/authored_operations.v1.json @@ -718,6 +718,48 @@ "signature": "141c3eace0da665038626a73d336e48bc2ebc2bdee1f460bedd35034d85f199c1059f5b37c3c9b8a51730c4db0b8240223c00a0fc3fc2807cb46a7b6a0504a36", "raw_json": "{\"id\":\"2cb6574e48eb71825016b5fb94aed7df459948cab74b8f45f34cc1b42ca8767a\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":3474,\"tags\":[[\"contract\",\"radroots.trade.cancellation.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"fe65c35d4a280a66a309e2834a58712ad8cf837386edb8fd25b73c946b1a273d\",\"mutation\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"root\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"parent\"],[\"p\",\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"{\\\"author_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"authored_at_unix_s\\\":1784347200,\\\"body\\\":{\\\"mutation_type\\\":\\\"cancellation\\\",\\\"reason\\\":\\\"cancelled\\\",\\\"target_candidate_id\\\":\\\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\\\",\\\"target_claim_mutation_id\\\":null},\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"contract_id\\\":\\\"radroots.trade.cancellation.v1\\\",\\\"counterparty_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"farm_id\\\":\\\"farm-1\\\",\\\"mutation_id\\\":\\\"fe65c35d4a280a66a309e2834a58712ad8cf837386edb8fd25b73c946b1a273d\\\",\\\"parent_mutation_ids\\\":[\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"],\\\"root_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\",\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\",\"sig\":\"141c3eace0da665038626a73d336e48bc2ebc2bdee1f460bedd35034d85f199c1059f5b37c3c9b8a51730c4db0b8240223c00a0fc3fc2807cb46a7b6a0504a36\"}" } + }, + { + "id": "typed_rhi_evidence_attestation_017", + "kind": "authored_operations.wire", + "input": { + "contract_id": "radroots.rhi.evidence_attestation.v1", + "authoring": "typed" + }, + "expected": { + "kind": 3441, + "created_at": 1784347200, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "tags": [ + [ + "contract", + "radroots.rhi.evidence_attestation.v1" + ], + [ + "d", + "11111111111111111111111111111111" + ], + [ + "x", + "2222222222222222222222222222222222222222222222222222222222222222", + "claim" + ], + [ + "x", + "254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30", + "statement" + ], + [ + "t", + "radroots:rhi-outcome:indeterminate" + ] + ], + "content": "{\"attestation_method\":\"signed_evidence_snapshot\",\"claim_mutation_id\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"contract_id\":\"radroots.rhi.evidence_attestation.v1\",\"contract_version\":1,\"evidence_manifest_digest\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"evidence_policy_digest\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"issuer_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"observed_at_unix_s\":1800000000,\"outcome\":\"indeterminate\",\"projection_digest\":\"6666666666666666666666666666666666666666666666666666666666666666\",\"reason_codes\":[\"required_source_incomplete\"],\"reducer_contract_id\":\"radroots.trade.reducer.v1\",\"reducer_contract_version\":1,\"report_id\":\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\",\"statement_digest\":\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\",\"supersedes_event_id\":null,\"supersedes_report_id\":null,\"trade_generation\":7,\"trade_id\":\"11111111111111111111111111111111\"}", + "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,3441,[[\"contract\",\"radroots.rhi.evidence_attestation.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"claim\"],[\"x\",\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\",\"statement\"],[\"t\",\"radroots:rhi-outcome:indeterminate\"]],\"{\\\"attestation_method\\\":\\\"signed_evidence_snapshot\\\",\\\"claim_mutation_id\\\":\\\"2222222222222222222222222222222222222222222222222222222222222222\\\",\\\"contract_id\\\":\\\"radroots.rhi.evidence_attestation.v1\\\",\\\"contract_version\\\":1,\\\"evidence_manifest_digest\\\":\\\"4444444444444444444444444444444444444444444444444444444444444444\\\",\\\"evidence_policy_digest\\\":\\\"5555555555555555555555555555555555555555555555555555555555555555\\\",\\\"issuer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"observed_at_unix_s\\\":1800000000,\\\"outcome\\\":\\\"indeterminate\\\",\\\"projection_digest\\\":\\\"6666666666666666666666666666666666666666666666666666666666666666\\\",\\\"reason_codes\\\":[\\\"required_source_incomplete\\\"],\\\"reducer_contract_id\\\":\\\"radroots.trade.reducer.v1\\\",\\\"reducer_contract_version\\\":1,\\\"report_id\\\":\\\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\\\",\\\"statement_digest\\\":\\\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\\\",\\\"supersedes_event_id\\\":null,\\\"supersedes_report_id\\\":null,\\\"trade_generation\\\":7,\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\"]", + "event_id": "a811ca5f84414e9d817ca47b923d8fd7f61c1a03d498b32f6b0816b99d57b8ce", + "signature": "8abd1ba0b3b597629939a559e2536897b602ef4e225f1552a57ddce72dd372f98ad21891cfe10bdb1a50532f50a0dd3d9b14fd46677dccd0a4fa652cdee17063", + "raw_json": "{\"id\":\"a811ca5f84414e9d817ca47b923d8fd7f61c1a03d498b32f6b0816b99d57b8ce\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":3441,\"tags\":[[\"contract\",\"radroots.rhi.evidence_attestation.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"claim\"],[\"x\",\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\",\"statement\"],[\"t\",\"radroots:rhi-outcome:indeterminate\"]],\"content\":\"{\\\"attestation_method\\\":\\\"signed_evidence_snapshot\\\",\\\"claim_mutation_id\\\":\\\"2222222222222222222222222222222222222222222222222222222222222222\\\",\\\"contract_id\\\":\\\"radroots.rhi.evidence_attestation.v1\\\",\\\"contract_version\\\":1,\\\"evidence_manifest_digest\\\":\\\"4444444444444444444444444444444444444444444444444444444444444444\\\",\\\"evidence_policy_digest\\\":\\\"5555555555555555555555555555555555555555555555555555555555555555\\\",\\\"issuer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"observed_at_unix_s\\\":1800000000,\\\"outcome\\\":\\\"indeterminate\\\",\\\"projection_digest\\\":\\\"6666666666666666666666666666666666666666666666666666666666666666\\\",\\\"reason_codes\\\":[\\\"required_source_incomplete\\\"],\\\"reducer_contract_id\\\":\\\"radroots.trade.reducer.v1\\\",\\\"reducer_contract_version\\\":1,\\\"report_id\\\":\\\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\\\",\\\"statement_digest\\\":\\\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\\\",\\\"supersedes_event_id\\\":null,\\\"supersedes_report_id\\\":null,\\\"trade_generation\\\":7,\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\",\"sig\":\"8abd1ba0b3b597629939a559e2536897b602ef4e225f1552a57ddce72dd372f98ad21891cfe10bdb1a50532f50a0dd3d9b14fd46677dccd0a4fa652cdee17063\"}" + } } ] } diff --git a/crates/sdk/tests/package_boundary.rs b/crates/sdk/tests/package_boundary.rs @@ -352,7 +352,7 @@ fn sync_operations_only_delegate_to_the_canonical_engine() { fn authored_submission_is_one_protocol_neutral_boundary_for_all_typed_contracts() { use radroots_event_codec::authoring::REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS; - assert_eq!(REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS.len(), 15); + assert_eq!(REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS.len(), 16); assert!(SYNC.contains("pub async fn submit_push")); assert!(SYNC.contains("request: PushRequest")); assert!(SYNC.contains("Result<PushStatus, Error>"));