rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

commit 65d7461bbdae768c30e2f76a119a74772eec5ee8
parent 7c3ae187bee0d3e319bb7dd2530d6f5b25479923
Author: triesap <tyson@radroots.org>
Date:   Mon, 24 Aug 2026 01:05:33 +0000

rhi: admit canonical signed trade events

- bound wire, identifiers, content, tags, and extensions before canonical allocation\n- verify event identity, signature, kind, canonical mutation, author, tags, and explicit time policy\n- freeze machine contract, conformance vector, public API, and redaction tests

Diffstat:
MAGENTS.md | 4++++
MCargo.toml | 2+-
MREADME | 20++++++++++++++++++++
Mcontracts/api_baselines/rhi.txt | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontracts/conformance/vectors/trade_ingest_proposal.v1.json | 1+
Acontracts/services_hardening/trade_ingest.v1.json | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 10++++++++++
Asrc/trade_ingest.rs | 944+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/package_boundary.rs | 59++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Atests/services_hardening_trade_ingest.rs | 444+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 1606 insertions(+), 2 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -83,6 +83,10 @@ and signature, registered kind, author, canonical mutation content and ID, canonical serialization, mandatory structural tag cardinality/content binding, duplicate or conflicting structural tags, and explicit time policy. +- Route accepted trade mutations only through the sealed + `admit_rhi_trade_mutation_event` boundary. Its wire limits come from the + validated configuration, its future-time tolerance is explicit with no + default, and it must remain pure until Step 184 owns persistence. - Persist canonical mutation, every distinct signed event carrying it, and every accepted source observation as separate typed facts. Two events for one mutation never overwrite one another, and arrival order never selects truth. diff --git a/Cargo.toml b/Cargo.toml @@ -70,7 +70,7 @@ jsonschema = { version = "0.48.1", default-features = false } nostr = { version = "0.44.7" } rustix = { version = "1", features = ["fs", "process", "std"] } serde = { version = "1", default-features = false } -serde_json = { version = "1", default-features = false } +serde_json = { version = "1", default-features = false, features = ["raw_value"] } sha2 = { version = "0.10" } sqlx = { version = "0.9.0", default-features = false, features = ["sqlite-bundled"] } thiserror = { version = "2" } diff --git a/README b/README @@ -57,6 +57,26 @@ no signal handler, Tokio runtime, logger, or process-exit policy; the final binary checkpoint owns those authorities. The exact machine contract is [`runtime_adapters.v1.json`](contracts/services_hardening/runtime_adapters.v1.json). +## Canonical trade-event admission + +`admit_rhi_trade_mutation_event` is the sole accepted-ingest boundary for +signed trade mutations. It caps the original UTF-8 event before parsing, +measures decoded content, tags, tag elements, aggregate tag bytes, identifiers, +and non-authoritative outer extensions before the canonical decoder allocates +them, then independently verifies the NIP-01 identifier and Schnorr signature. +The promoted Lib contract performs the exact registered-kind, author, +canonical content and mutation-ID, and ordered structural-tag validation. + +Observation time is injected. Future skew is an explicit caller-supplied +inclusive policy with no implicit default, while old events remain admissible +for lineage reconstruction. The sealed accepted value retains the exact +bounded input bytes plus the verified event and canonical typed mutation; +ordinary Debug output reveals only sizes, kind, authored time, and a redacted +identity marker. This pure boundary performs no clock read, network operation, +SQLite access, checkpoint update, or dirty-generation change. Its exact +machine contract is +[`trade_ingest.v1.json`](contracts/services_hardening/trade_ingest.v1.json). + ## Existing-state runtime foundation `open_rhi_runtime_foundation` opens only an already initialized database from diff --git a/contracts/api_baselines/rhi.txt b/contracts/api_baselines/rhi.txt @@ -271,6 +271,30 @@ pub enum rhi::RhiTradeCommandV1 pub rhi::RhiTradeCommandV1::Projection pub rhi::RhiTradeCommandV1::ReportCurrent pub rhi::RhiTradeCommandV1::Reports +pub enum rhi::RhiTradeMutationAdmissionErrorKind +pub rhi::RhiTradeMutationAdmissionErrorKind::AuthoredTimeRejected +pub rhi::RhiTradeMutationAdmissionErrorKind::DuplicateEventField +pub rhi::RhiTradeMutationAdmissionErrorKind::EmptyEvent +pub rhi::RhiTradeMutationAdmissionErrorKind::EventContentTooLarge +pub rhi::RhiTradeMutationAdmissionErrorKind::EventIdentifierTooLarge +pub rhi::RhiTradeMutationAdmissionErrorKind::EventTooLarge +pub rhi::RhiTradeMutationAdmissionErrorKind::ExtraFieldsTooLarge +pub rhi::RhiTradeMutationAdmissionErrorKind::InvalidAuthor +pub rhi::RhiTradeMutationAdmissionErrorKind::InvalidAuthoredTime +pub rhi::RhiTradeMutationAdmissionErrorKind::InvalidEventId +pub rhi::RhiTradeMutationAdmissionErrorKind::InvalidEventUtf8 +pub rhi::RhiTradeMutationAdmissionErrorKind::InvalidLimits +pub rhi::RhiTradeMutationAdmissionErrorKind::InvalidMutation +pub rhi::RhiTradeMutationAdmissionErrorKind::InvalidObservationTime +pub rhi::RhiTradeMutationAdmissionErrorKind::InvalidSignature +pub rhi::RhiTradeMutationAdmissionErrorKind::InvalidTimePolicy +pub rhi::RhiTradeMutationAdmissionErrorKind::MalformedEvent +pub rhi::RhiTradeMutationAdmissionErrorKind::TagElementTooLarge +pub rhi::RhiTradeMutationAdmissionErrorKind::TagsTooLarge +pub rhi::RhiTradeMutationAdmissionErrorKind::TooManyExtraFields +pub rhi::RhiTradeMutationAdmissionErrorKind::TooManyTagElements +pub rhi::RhiTradeMutationAdmissionErrorKind::TooManyTags +pub rhi::RhiTradeMutationAdmissionErrorKind::UnsupportedKind pub enum rhi::TradeAgreementAttestationBackend pub rhi::TradeAgreementAttestationBackend::LocalStatementHash impl rhi::TradeAgreementAttestationBackend @@ -310,6 +334,16 @@ pub fn rhi::NostrEventAdapter<'a>::raw_author(&'a self) -> alloc::string::String pub fn rhi::NostrEventAdapter<'a>::raw_content(&'a self) -> &'a str pub fn rhi::NostrEventAdapter<'a>::raw_id(&'a self) -> alloc::string::String pub fn rhi::NostrEventAdapter<'a>::raw_kind(&'a self) -> u32 +pub struct rhi::RhiAdmittedTradeMutationEvent +impl rhi::RhiAdmittedTradeMutationEvent +pub fn rhi::RhiAdmittedTradeMutationEvent::authored_at_unix_seconds(&self) -> u64 +pub fn rhi::RhiAdmittedTradeMutationEvent::event_id(&self) -> &radroots_event::id::EventId +pub fn rhi::RhiAdmittedTradeMutationEvent::event_kind(&self) -> u32 +pub const fn rhi::RhiAdmittedTradeMutationEvent::mutation(&self) -> &radroots_event::trade::TradeMutationEnvelopeV1 +pub const fn rhi::RhiAdmittedTradeMutationEvent::mutation_id(&self) -> &radroots_event::id::MutationId +pub fn rhi::RhiAdmittedTradeMutationEvent::original_bytes(&self) -> &[u8] +impl core::fmt::Debug for rhi::RhiAdmittedTradeMutationEvent +pub fn rhi::RhiAdmittedTradeMutationEvent::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiCliInvocationV1 impl rhi::RhiCliInvocationV1 pub const fn rhi::RhiCliInvocationV1::command(&self) -> rhi::RhiCommandV1 @@ -713,6 +747,33 @@ pub fn rhi::RhiTimeEntropyAdapters::sample_full_jitter(&self, rhi::RhiJitterBoun pub fn rhi::RhiTimeEntropyAdapters::system() -> Self impl core::fmt::Debug for rhi::RhiTimeEntropyAdapters pub fn rhi::RhiTimeEntropyAdapters::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiTradeMutationAdmissionError +impl rhi::RhiTradeMutationAdmissionError +pub const fn rhi::RhiTradeMutationAdmissionError::kind(self) -> rhi::RhiTradeMutationAdmissionErrorKind +impl core::error::Error for rhi::RhiTradeMutationAdmissionError +impl core::fmt::Debug for rhi::RhiTradeMutationAdmissionError +pub fn rhi::RhiTradeMutationAdmissionError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for rhi::RhiTradeMutationAdmissionError +pub fn rhi::RhiTradeMutationAdmissionError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiTradeMutationAdmissionLimits +impl rhi::RhiTradeMutationAdmissionLimits +pub const fn rhi::RhiTradeMutationAdmissionLimits::content_bytes(self) -> usize +pub fn rhi::RhiTradeMutationAdmissionLimits::from_config(&rhi::RhiConfigDocumentV1) -> core::result::Result<Self, rhi::RhiTradeMutationAdmissionError> +pub const fn rhi::RhiTradeMutationAdmissionLimits::tag_count(self) -> usize +pub const fn rhi::RhiTradeMutationAdmissionLimits::tag_element_bytes(self) -> usize +pub const fn rhi::RhiTradeMutationAdmissionLimits::tag_total_bytes(self) -> usize +pub const fn rhi::RhiTradeMutationAdmissionLimits::tag_total_elements(self) -> usize +pub const fn rhi::RhiTradeMutationAdmissionLimits::wire_bytes(self) -> usize +impl core::fmt::Debug for rhi::RhiTradeMutationAdmissionLimits +pub fn rhi::RhiTradeMutationAdmissionLimits::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiTradeMutationAuthoredTimePolicy +impl rhi::RhiTradeMutationAuthoredTimePolicy +pub const fn rhi::RhiTradeMutationAuthoredTimePolicy::maximum_future_seconds(self) -> u64 +pub fn rhi::RhiTradeMutationAuthoredTimePolicy::new(u64) -> core::result::Result<Self, rhi::RhiTradeMutationAdmissionError> +pub struct rhi::RhiTradeMutationObservedAtUnixSeconds(_) +impl rhi::RhiTradeMutationObservedAtUnixSeconds +pub const fn rhi::RhiTradeMutationObservedAtUnixSeconds::get(self) -> u64 +pub fn rhi::RhiTradeMutationObservedAtUnixSeconds::new(u64) -> core::result::Result<Self, rhi::RhiTradeMutationAdmissionError> pub struct rhi::RhiTransportAdapters impl rhi::RhiTransportAdapters pub fn rhi::RhiTransportAdapters::new(alloc::sync::Arc<dyn radroots_transport::source::EventSource>, alloc::sync::Arc<dyn radroots_transport::source::EventSubscriber>, alloc::sync::Arc<dyn radroots_transport::sink::EventSink>) -> Self @@ -804,6 +865,12 @@ pub const rhi::RHI_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256: [u8; 32] pub const rhi::RHI_STATE_SCHEMA_VERSION_2_OBJECT_COUNT: u32 pub const rhi::RHI_STATE_SCHEMA_VERSION_2_SHA256: [u8; 32] pub const rhi::RHI_STATUS_CONTRACT_VERSION: u32 +pub const rhi::RHI_TRADE_EVENT_EXTRA_FIELD_MAX_COUNT: usize +pub const rhi::RHI_TRADE_EVENT_EXTRA_JSON_MAX_BYTES: usize +pub const rhi::RHI_TRADE_EVENT_ID_MAX_BYTES: usize +pub const rhi::RHI_TRADE_EVENT_PUBLIC_KEY_MAX_BYTES: usize +pub const rhi::RHI_TRADE_EVENT_SIGNATURE_MAX_BYTES: usize +pub const rhi::RHI_TRADE_INGEST_CONTRACT_VERSION: u32 pub const rhi::RHI_WRAPPING_CREDENTIAL_ARTIFACT_BYTES: usize pub const rhi::RHI_WRAPPING_CREDENTIAL_CONTRACT_VERSION: u32 pub trait rhi::RhiCredentialAccess: core::marker::Send + core::marker::Sync @@ -814,6 +881,7 @@ pub trait rhi::RhiIdentityAccess: core::marker::Send + core::marker::Sync pub fn rhi::RhiIdentityAccess::open_existing(&self, &rhi::RhiIdentityEnvelopeBinding, &rhi::RhiWrappingCredential) -> core::result::Result<rhi::RhiDecryptedIdentity, rhi::RhiEncryptedIdentityEnvelopeError> impl rhi::RhiIdentityAccess for rhi::CanonicalRhiIdentityAccess pub fn rhi::CanonicalRhiIdentityAccess::open_existing(&self, &rhi::RhiIdentityEnvelopeBinding, &rhi::RhiWrappingCredential) -> core::result::Result<rhi::RhiDecryptedIdentity, rhi::RhiEncryptedIdentityEnvelopeError> +pub fn rhi::admit_rhi_trade_mutation_event(rhi::RhiTradeMutationAdmissionLimits, &[u8], rhi::RhiTradeMutationObservedAtUnixSeconds, rhi::RhiTradeMutationAuthoredTimePolicy) -> core::result::Result<rhi::RhiAdmittedTradeMutationEvent, rhi::RhiTradeMutationAdmissionError> pub async fn rhi::apply_rhi_configuration(&rhi::RhiRuntimeContext, &rhi::RhiConfigDocumentV1, &rhi::RhiConfigDocumentV1, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<rhi::RhiConfigApplyOutcome, rhi::RhiConfigApplyError> pub fn rhi::attest_projection_claim(&radroots_trade::trade_contract_v1::RadrootsTradeProjectionV1, &radroots_event::id::MutationId, &rhi::TradeAgreementAttestationPolicy) -> core::result::Result<rhi::TradeAgreementAttestationReportV1, rhi::TradeAgreementAttestationError> pub async fn rhi::finalize_rhi_state_restore(rhi::RhiStagedStateRestore) -> core::result::Result<(), rhi::RhiStateMaintenanceError> diff --git a/contracts/conformance/vectors/trade_ingest_proposal.v1.json b/contracts/conformance/vectors/trade_ingest_proposal.v1.json @@ -0,0 +1 @@ +{"source_vector":"typed_trade_proposal_010","event_id":"58eacb99d9b4e4da7f614fb37ed84b482d2cb85e30785d49ea6a29b555bfcd3a","created_at":1784347200,"kind":3470,"pubkey":"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df","raw_json":"{\"id\":\"58eacb99d9b4e4da7f614fb37ed84b482d2cb85e30785d49ea6a29b555bfcd3a\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":3470,\"tags\":[[\"contract\",\"radroots.trade.proposal.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"mutation\"],[\"p\",\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"{\\\"author_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"authored_at_unix_s\\\":1784347200,\\\"body\\\":{\\\"candidate\\\":{\\\"base_candidate_id\\\":null,\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"cancellation\\\":{\\\"buyer_pre_agreement\\\":true,\\\"post_agreement_cutoff_unix_s\\\":null,\\\"profile_id\\\":\\\"buyer-pre-agreement\\\"},\\\"candidate_id\\\":\\\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\\\",\\\"economics\\\":{\\\"adjustment_total_mantissa\\\":\\\"0\\\",\\\"adjustments\\\":[],\\\"currency_code\\\":\\\"USD\\\",\\\"currency_exponent\\\":2,\\\"discount_total_mantissa\\\":\\\"0\\\",\\\"profile_id\\\":\\\"mvp-fixed\\\",\\\"rounding_profile\\\":\\\"half-even\\\",\\\"subtotal_mantissa\\\":\\\"1000\\\",\\\"total_mantissa\\\":\\\"1000\\\"},\\\"farm_id\\\":\\\"farm-1\\\",\\\"fulfillment\\\":{\\\"ends_at_unix_s\\\":1800003600,\\\"fold\\\":0,\\\"location_class\\\":\\\"farmstand\\\",\\\"method\\\":\\\"pickup\\\",\\\"profile_id\\\":\\\"market-pickup\\\",\\\"requires_private_terms\\\":false,\\\"starts_at_unix_s\\\":1800000000,\\\"timezone\\\":\\\"America/New_York\\\",\\\"utc_offset_seconds\\\":-18000},\\\"line_tombstones\\\":[],\\\"lines\\\":[{\\\"bin_id\\\":\\\"bin-1\\\",\\\"currency_code\\\":\\\"USD\\\",\\\"line_id\\\":\\\"line-1\\\",\\\"line_subtotal_mantissa\\\":\\\"1000\\\",\\\"listing_addr\\\":\\\"30402:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:listing-1\\\",\\\"listing_event_id\\\":\\\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\\\",\\\"listing_snapshot_sha256\\\":\\\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\\\",\\\"option_id\\\":null,\\\"product_id\\\":\\\"carrots\\\",\\\"quantity_mantissa\\\":\\\"2\\\",\\\"quantity_scale\\\":0,\\\"replaces_line_id\\\":null,\\\"unit_code\\\":\\\"count\\\",\\\"unit_price_mantissa\\\":\\\"500\\\",\\\"unit_profile\\\":\\\"mvp-count\\\"}],\\\"private_terms\\\":null,\\\"proposal_expires_at_unix_s\\\":1799999000,\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"supersession_intent\\\":null},\\\"mutation_type\\\":\\\"proposal\\\"},\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"contract_id\\\":\\\"radroots.trade.proposal.v1\\\",\\\"counterparty_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"farm_id\\\":\\\"farm-1\\\",\\\"mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\",\\\"parent_mutation_ids\\\":[],\\\"root_mutation_id\\\":null,\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\",\"sig\":\"b39fc5d285826bda6462dc9fb8c20c6519b54d4c8ac9673f3c53c6424e2e1703762b4126282e579dcb11c6738477738a61396595fb6c13331e038c3b06b18517\"}"} diff --git a/contracts/services_hardening/trade_ingest.v1.json b/contracts/services_hardening/trade_ingest.v1.json @@ -0,0 +1,56 @@ +{ + "schema": "radroots.rhi.trade-ingest.v1", + "contract_version": 1, + "source": "validated_config_resource_limits_events", + "wire": { + "original_wire_cap_before_parse": true, + "utf8": "required", + "required_fields": ["id", "pubkey", "created_at", "kind", "tags", "content", "sig"], + "duplicate_fields": "reject", + "null_required_fields": "reject", + "identifier_max_bytes": { "id": 64, "pubkey": 64, "sig": 128 }, + "configured_bounds": ["wire_bytes", "content_bytes", "tag_count", "tag_total_elements", "tag_element_bytes", "tag_total_bytes"], + "extra_fields": { + "maximum_count": 64, + "maximum_aggregate_json_bytes": 65536, + "measurement": "canonical_key_json_bytes_plus_colon_plus_original_value_json_bytes_per_field", + "authority": "radroots_event_wire_v1", + "semantic_authority": "none" + } + }, + "verification": { + "event_id": "recomputed_and_exact", + "signature": "bip340_schnorr_verified", + "registered_kinds": [3470, 3471, 3472, 3473, 3474], + "mutation_contract_family": "radroots.trade.mutation-index.v1", + "author": "exact_mutation_author_and_party_binding", + "content": "exact_canonical_trade_mutation_content_and_computed_mutation_id", + "tags": "exact_ordered_derived_structural_tags", + "duplicate_or_conflicting_structural_tags": "reject", + "legacy_shape": "reject" + }, + "authored_time": { + "representation": "zero_through_i64_max_unix_seconds", + "observation": "positive_injected_unix_seconds_through_i64_max", + "policy": "explicit_caller_supplied_inclusive_maximum_future_seconds", + "default": "none", + "past_age_limit": "none_at_admission", + "arithmetic": "saturating_upper_bound" + }, + "accepted_value": { + "original_bytes": "retained_exactly", + "event_id": "verified", + "mutation_id": "content_derived_and_verified", + "mutation": "canonical_typed_envelope", + "debug": "sizes_kind_time_and_redacted_identity_only", + "construction": "sealed" + }, + "conformance_vector": { + "path": "contracts/conformance/vectors/trade_ingest_proposal.v1.json", + "source": "radrootslabs/lib:contracts/conformance/vectors/event/authored_operations.v1.json#typed_trade_proposal_010", + "sha256": "89fed4e1e73d917c2f083b5e1569f17952c3381a8691c0e0089e08ede2e8009a" + }, + "errors": "crate_owned_source_free_redacted", + "effects": { "filesystem": false, "sqlite": false, "clock_read": false, "network": false, "checkpoint": false, "dirty_generation": false }, + "deferred": ["mutation_event_and_provenance_persistence", "idempotency_and_conflict_resolution", "source_adapter_and_completion", "checkpoint_and_dirty_generation", "reconciliation", "publication"] +} diff --git a/src/lib.rs b/src/lib.rs @@ -17,6 +17,7 @@ mod state_host; mod state_maintenance; mod state_metadata; mod state_repository; +mod trade_ingest; pub use adapters::nostr::event::NostrEventAdapter; pub use cli_v1::{ @@ -118,3 +119,12 @@ pub use state_repository::{ RhiStateRepositoryKind, RhiStateRepositoryWriteClass, RhiSupersessionRepository, rhi_state_repository_descriptors, }; +pub use trade_ingest::{ + RHI_TRADE_EVENT_EXTRA_FIELD_MAX_COUNT, RHI_TRADE_EVENT_EXTRA_JSON_MAX_BYTES, + RHI_TRADE_EVENT_ID_MAX_BYTES, RHI_TRADE_EVENT_PUBLIC_KEY_MAX_BYTES, + RHI_TRADE_EVENT_SIGNATURE_MAX_BYTES, RHI_TRADE_INGEST_CONTRACT_VERSION, + RhiAdmittedTradeMutationEvent, RhiTradeMutationAdmissionError, + RhiTradeMutationAdmissionErrorKind, RhiTradeMutationAdmissionLimits, + RhiTradeMutationAuthoredTimePolicy, RhiTradeMutationObservedAtUnixSeconds, + admit_rhi_trade_mutation_event, +}; diff --git a/src/trade_ingest.rs b/src/trade_ingest.rs @@ -0,0 +1,944 @@ +//! Allocation-bounded, cryptographically verified trade-mutation admission. + +use core::fmt; +use std::{borrow::Cow, collections::BTreeSet, error::Error}; + +use radroots_event::{ + envelope::{EventEnvelope, kind::is_trade_mutation_event_kind}, + id::{EventId, MutationId}, + trade::TradeMutationEnvelopeV1, + wire::{ + DEFAULT_EXTRA_MAX_FIELDS, DEFAULT_EXTRA_TOTAL_JSON_MAX_BYTES, EventWireLimits, + Nip01EventWire, + }, +}; +use radroots_event_codec::decode::trade::{RadrootsTradeMutationError, trade_mutation_from_event}; +use radroots_nostr::event::{Verification, verify, verify_id}; +use serde::Deserialize; +use serde::de::{self, DeserializeSeed, IgnoredAny, MapAccess, SeqAccess, Visitor}; +use serde_json::value::RawValue; + +use crate::RhiConfigDocumentV1; + +/// Maximum encoded Nostr event-identifier length admitted before allocation. +pub const RHI_TRADE_EVENT_ID_MAX_BYTES: usize = 64; + +/// Exact version of the RHI trade-ingest contract. +pub const RHI_TRADE_INGEST_CONTRACT_VERSION: u32 = 1; + +/// Maximum encoded Nostr public-key length admitted before allocation. +pub const RHI_TRADE_EVENT_PUBLIC_KEY_MAX_BYTES: usize = 64; + +/// Maximum encoded Nostr signature length admitted before allocation. +pub const RHI_TRADE_EVENT_SIGNATURE_MAX_BYTES: usize = 128; + +/// Maximum number of bounded, non-authoritative outer event extensions. +pub const RHI_TRADE_EVENT_EXTRA_FIELD_MAX_COUNT: usize = DEFAULT_EXTRA_MAX_FIELDS; + +/// Maximum aggregate JSON bytes for non-authoritative outer event extensions. +pub const RHI_TRADE_EVENT_EXTRA_JSON_MAX_BYTES: usize = DEFAULT_EXTRA_TOTAL_JSON_MAX_BYTES; + +const DUPLICATE_FIELD_SENTINEL: &str = "rhi-duplicate-event-field"; +const EXTRA_COUNT_SENTINEL: &str = "rhi-extra-field-count-limit"; +const EXTRA_BYTES_SENTINEL: &str = "rhi-extra-field-bytes-limit"; +const TAG_COUNT_SENTINEL: &str = "rhi-tag-count-limit"; +const TAG_ELEMENT_COUNT_SENTINEL: &str = "rhi-tag-element-count-limit"; +const TAG_ELEMENT_BYTES_SENTINEL: &str = "rhi-tag-element-bytes-limit"; +const TAG_TOTAL_BYTES_SENTINEL: &str = "rhi-tag-total-bytes-limit"; + +/// Immutable trade-event limits projected from one admitted RHI configuration. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RhiTradeMutationAdmissionLimits { + wire_bytes: usize, + content_bytes: usize, + tag_count: usize, + tag_total_elements: usize, + tag_element_bytes: usize, + tag_total_bytes: usize, +} + +impl RhiTradeMutationAdmissionLimits { + /// Projects the exact event limits from a validated immutable configuration. + pub fn from_config( + configuration: &RhiConfigDocumentV1, + ) -> Result<Self, RhiTradeMutationAdmissionError> { + Ok(Self { + wire_bytes: config_limit(configuration, "/resource_limits/events/wire_bytes")?, + content_bytes: config_limit(configuration, "/resource_limits/events/content_bytes")?, + tag_count: config_limit(configuration, "/resource_limits/events/tag_count")?, + tag_total_elements: config_limit( + configuration, + "/resource_limits/events/tag_total_elements", + )?, + tag_element_bytes: config_limit( + configuration, + "/resource_limits/events/tag_element_bytes", + )?, + tag_total_bytes: config_limit( + configuration, + "/resource_limits/events/tag_total_bytes", + )?, + }) + } + + /// Returns the original event-wire byte cap. + #[must_use] + pub const fn wire_bytes(self) -> usize { + self.wire_bytes + } + + /// Returns the decoded canonical-content byte cap. + #[must_use] + pub const fn content_bytes(self) -> usize { + self.content_bytes + } + + /// Returns the event-tag count cap. + #[must_use] + pub const fn tag_count(self) -> usize { + self.tag_count + } + + /// Returns the aggregate event-tag-element count cap. + #[must_use] + pub const fn tag_total_elements(self) -> usize { + self.tag_total_elements + } + + /// Returns the decoded byte cap for one tag element. + #[must_use] + pub const fn tag_element_bytes(self) -> usize { + self.tag_element_bytes + } + + /// Returns the aggregate decoded byte cap for all tag elements. + #[must_use] + pub const fn tag_total_bytes(self) -> usize { + self.tag_total_bytes + } + + const fn wire_limits(self) -> EventWireLimits { + EventWireLimits { + max_raw_json_bytes: self.wire_bytes, + max_content_bytes: self.content_bytes, + max_tag_count: self.tag_count, + max_total_tag_elements: self.tag_total_elements, + max_tag_element_bytes: self.tag_element_bytes, + max_total_tag_bytes: self.tag_total_bytes, + max_extra_fields: RHI_TRADE_EVENT_EXTRA_FIELD_MAX_COUNT, + max_total_extra_json_bytes: RHI_TRADE_EVENT_EXTRA_JSON_MAX_BYTES, + } + } +} + +impl fmt::Debug for RhiTradeMutationAdmissionLimits { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiTradeMutationAdmissionLimits") + .field("wire_bytes", &self.wire_bytes) + .field("content_bytes", &self.content_bytes) + .field("tag_count", &self.tag_count) + .field("tag_total_elements", &self.tag_total_elements) + .field("tag_element_bytes", &self.tag_element_bytes) + .field("tag_total_bytes", &self.tag_total_bytes) + .finish() + } +} + +/// Injected UTC second at which one trade event is observed. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RhiTradeMutationObservedAtUnixSeconds(u64); + +impl RhiTradeMutationObservedAtUnixSeconds { + /// Validates a positive instant representable by SQLite's signed integer. + pub fn new(value: u64) -> Result<Self, RhiTradeMutationAdmissionError> { + if value == 0 || i64::try_from(value).is_err() { + return Err(failure( + RhiTradeMutationAdmissionErrorKind::InvalidObservationTime, + )); + } + Ok(Self(value)) + } + + /// Returns the injected observation time. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} + +/// Explicit caller-selected future authored-time tolerance with no default. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct RhiTradeMutationAuthoredTimePolicy { + maximum_future_seconds: u64, +} + +impl RhiTradeMutationAuthoredTimePolicy { + /// Validates the inclusive maximum future skew. + pub fn new(maximum_future_seconds: u64) -> Result<Self, RhiTradeMutationAdmissionError> { + if i64::try_from(maximum_future_seconds).is_err() { + return Err(failure( + RhiTradeMutationAdmissionErrorKind::InvalidTimePolicy, + )); + } + Ok(Self { + maximum_future_seconds, + }) + } + + /// Returns the inclusive maximum future skew. + #[must_use] + pub const fn maximum_future_seconds(self) -> u64 { + self.maximum_future_seconds + } +} + +/// Stable source-free classification for trade-mutation admission failures. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiTradeMutationAdmissionErrorKind { + InvalidLimits, + EmptyEvent, + EventTooLarge, + InvalidEventUtf8, + MalformedEvent, + DuplicateEventField, + EventIdentifierTooLarge, + EventContentTooLarge, + TooManyTags, + TooManyTagElements, + TagElementTooLarge, + TagsTooLarge, + TooManyExtraFields, + ExtraFieldsTooLarge, + InvalidObservationTime, + InvalidTimePolicy, + InvalidAuthoredTime, + InvalidEventId, + InvalidSignature, + UnsupportedKind, + InvalidAuthor, + InvalidMutation, + AuthoredTimeRejected, +} + +impl RhiTradeMutationAdmissionErrorKind { + const fn message(self) -> &'static str { + match self { + Self::InvalidLimits => "trade-event admission limits are invalid", + Self::EmptyEvent => "trade event bytes are empty", + Self::EventTooLarge => "trade event exceeds its wire limit", + Self::InvalidEventUtf8 => "trade event is not valid UTF-8", + Self::MalformedEvent => "trade event structure is invalid", + Self::DuplicateEventField => "trade event contains a duplicate field", + Self::EventIdentifierTooLarge => "trade event identifier exceeds its limit", + Self::EventContentTooLarge => "trade event content exceeds its limit", + Self::TooManyTags => "trade event tag count exceeds its limit", + Self::TooManyTagElements => "trade event tag elements exceed their count limit", + Self::TagElementTooLarge => "trade event tag element exceeds its byte limit", + Self::TagsTooLarge => "trade event tags exceed their aggregate byte limit", + Self::TooManyExtraFields => "trade event extras exceed their field limit", + Self::ExtraFieldsTooLarge => "trade event extras exceed their byte limit", + Self::InvalidObservationTime => "trade-event observation time is invalid", + Self::InvalidTimePolicy => "trade-event authored-time policy is invalid", + Self::InvalidAuthoredTime => "trade-event authored time is invalid", + Self::InvalidEventId => "trade event identifier verification failed", + Self::InvalidSignature => "trade event signature verification failed", + Self::UnsupportedKind => "trade event kind is unsupported", + Self::InvalidAuthor => "trade event author binding is invalid", + Self::InvalidMutation => "trade mutation contract is invalid", + Self::AuthoredTimeRejected => "trade-event authored time is outside policy", + } + } +} + +/// One redacted trade-mutation admission failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RhiTradeMutationAdmissionError { + kind: RhiTradeMutationAdmissionErrorKind, +} + +impl RhiTradeMutationAdmissionError { + /// Returns the stable failure classification. + #[must_use] + pub const fn kind(self) -> RhiTradeMutationAdmissionErrorKind { + self.kind + } +} + +impl fmt::Debug for RhiTradeMutationAdmissionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiTradeMutationAdmissionError") + .field("kind", &self.kind) + .finish() + } +} + +impl fmt::Display for RhiTradeMutationAdmissionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.kind.message()) + } +} + +impl Error for RhiTradeMutationAdmissionError {} + +/// One bounded, signature-verified, canonical trade-mutation event. +/// +/// Construction is sealed to the admission boundary: +/// +/// ```compile_fail +/// use rhi::RhiAdmittedTradeMutationEvent; +/// +/// let _forged = RhiAdmittedTradeMutationEvent {}; +/// ``` +pub struct RhiAdmittedTradeMutationEvent { + original: Box<[u8]>, + event: EventEnvelope, + mutation: TradeMutationEnvelopeV1, + mutation_id: MutationId, +} + +impl RhiAdmittedTradeMutationEvent { + /// Returns the exact bounded wire bytes supplied to the admission boundary. + #[must_use] + pub fn original_bytes(&self) -> &[u8] { + &self.original + } + + /// Returns the independently verified Nostr event identifier. + #[must_use] + pub fn event_id(&self) -> &EventId { + self.event.id() + } + + /// Returns the canonical content-derived mutation identifier. + #[must_use] + pub const fn mutation_id(&self) -> &MutationId { + &self.mutation_id + } + + /// Returns the exact registered Nostr event kind. + #[must_use] + pub fn event_kind(&self) -> u32 { + self.event.kind_u32() + } + + /// Returns the untrusted-but-policy-admitted event-authored UTC second. + #[must_use] + pub fn authored_at_unix_seconds(&self) -> u64 { + self.event.created_at_u64() + } + + /// Returns the canonical typed mutation bound to the signed event. + #[must_use] + pub const fn mutation(&self) -> &TradeMutationEnvelopeV1 { + &self.mutation + } +} + +impl fmt::Debug for RhiAdmittedTradeMutationEvent { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiAdmittedTradeMutationEvent") + .field("wire_bytes", &self.original.len()) + .field("content_bytes", &self.event.content().len()) + .field("tag_count", &self.event.tags().len()) + .field("event_kind", &self.event.kind_u32()) + .field("authored_at_unix_seconds", &self.event.created_at_u64()) + .field("identity", &"[redacted]") + .finish() + } +} + +/// Bounds, verifies, and admits one canonical signed trade-mutation event. +pub fn admit_rhi_trade_mutation_event( + limits: RhiTradeMutationAdmissionLimits, + original: &[u8], + observed_at: RhiTradeMutationObservedAtUnixSeconds, + authored_time_policy: RhiTradeMutationAuthoredTimePolicy, +) -> Result<RhiAdmittedTradeMutationEvent, RhiTradeMutationAdmissionError> { + if original.is_empty() { + return Err(failure(RhiTradeMutationAdmissionErrorKind::EmptyEvent)); + } + if original.len() > limits.wire_bytes { + return Err(failure(RhiTradeMutationAdmissionErrorKind::EventTooLarge)); + } + let source = std::str::from_utf8(original) + .map_err(|_| failure(RhiTradeMutationAdmissionErrorKind::InvalidEventUtf8))?; + preflight_wire(source, limits)?; + + let wire = Nip01EventWire::parse_json_unverified_with_limits(source, limits.wire_limits()) + .map_err(|_| failure(RhiTradeMutationAdmissionErrorKind::MalformedEvent))?; + let event = wire + .into_unverified_envelope() + .map_err(|_| failure(RhiTradeMutationAdmissionErrorKind::MalformedEvent))?; + + match verify_id(&event) { + Verification::IdVerified => {} + Verification::IdMismatch => { + return Err(failure(RhiTradeMutationAdmissionErrorKind::InvalidEventId)); + } + _ => return Err(failure(RhiTradeMutationAdmissionErrorKind::MalformedEvent)), + } + match verify(&event) { + Verification::Verified => {} + Verification::IdMismatch => { + return Err(failure(RhiTradeMutationAdmissionErrorKind::InvalidEventId)); + } + Verification::SignatureInvalid => { + return Err(failure( + RhiTradeMutationAdmissionErrorKind::InvalidSignature, + )); + } + Verification::IdVerified | Verification::MalformedEnvelope => { + return Err(failure(RhiTradeMutationAdmissionErrorKind::MalformedEvent)); + } + } + + validate_authored_time_representation(event.created_at_u64())?; + if !is_trade_mutation_event_kind(event.kind_u32()) { + return Err(failure(RhiTradeMutationAdmissionErrorKind::UnsupportedKind)); + } + let mutation = trade_mutation_from_event(&event).map_err(classify_mutation_error)?; + let mutation_id = mutation + .mutation_id + .ok_or_else(|| failure(RhiTradeMutationAdmissionErrorKind::InvalidMutation))?; + enforce_authored_time_policy(event.created_at_u64(), observed_at, authored_time_policy)?; + + Ok(RhiAdmittedTradeMutationEvent { + original: original.into(), + event, + mutation, + mutation_id, + }) +} + +fn validate_authored_time_representation( + authored_at: u64, +) -> Result<(), RhiTradeMutationAdmissionError> { + if i64::try_from(authored_at).is_err() { + return Err(failure( + RhiTradeMutationAdmissionErrorKind::InvalidAuthoredTime, + )); + } + Ok(()) +} + +fn enforce_authored_time_policy( + authored_at: u64, + observed_at: RhiTradeMutationObservedAtUnixSeconds, + policy: RhiTradeMutationAuthoredTimePolicy, +) -> Result<(), RhiTradeMutationAdmissionError> { + let latest = observed_at + .get() + .saturating_add(policy.maximum_future_seconds()); + if authored_at > latest { + return Err(failure( + RhiTradeMutationAdmissionErrorKind::AuthoredTimeRejected, + )); + } + Ok(()) +} + +fn classify_mutation_error(error: RadrootsTradeMutationError) -> RhiTradeMutationAdmissionError { + let kind = match error { + RadrootsTradeMutationError::InvalidKind => { + RhiTradeMutationAdmissionErrorKind::UnsupportedKind + } + RadrootsTradeMutationError::AuthorMismatch => { + RhiTradeMutationAdmissionErrorKind::InvalidAuthor + } + _ => RhiTradeMutationAdmissionErrorKind::InvalidMutation, + }; + failure(kind) +} + +fn config_limit( + configuration: &RhiConfigDocumentV1, + pointer: &str, +) -> Result<usize, RhiTradeMutationAdmissionError> { + configuration + .normalized() + .pointer(pointer) + .and_then(serde_json::Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .filter(|value| *value > 0) + .ok_or_else(|| failure(RhiTradeMutationAdmissionErrorKind::InvalidLimits)) +} + +struct RawEvent<'a> { + id: &'a RawValue, + pubkey: &'a RawValue, + created_at: &'a RawValue, + kind: &'a RawValue, + tags: &'a RawValue, + content: &'a RawValue, + sig: &'a RawValue, +} + +fn preflight_wire( + source: &str, + limits: RhiTradeMutationAdmissionLimits, +) -> Result<(), RhiTradeMutationAdmissionError> { + let mut deserializer = serde_json::Deserializer::from_str(source); + let raw = RawEventSeed + .deserialize(&mut deserializer) + .map_err(classify_wire_preflight_error)?; + deserializer + .end() + .map_err(|_| failure(RhiTradeMutationAdmissionErrorKind::MalformedEvent))?; + + validate_bounded_string( + raw.id, + RHI_TRADE_EVENT_ID_MAX_BYTES, + RhiTradeMutationAdmissionErrorKind::EventIdentifierTooLarge, + )?; + validate_bounded_string( + raw.pubkey, + RHI_TRADE_EVENT_PUBLIC_KEY_MAX_BYTES, + RhiTradeMutationAdmissionErrorKind::EventIdentifierTooLarge, + )?; + validate_bounded_string( + raw.sig, + RHI_TRADE_EVENT_SIGNATURE_MAX_BYTES, + RhiTradeMutationAdmissionErrorKind::EventIdentifierTooLarge, + )?; + validate_bounded_string( + raw.content, + limits.content_bytes, + RhiTradeMutationAdmissionErrorKind::EventContentTooLarge, + )?; + parse_scalar::<u64>(raw.created_at)?; + parse_scalar::<u32>(raw.kind)?; + measure_tags(raw.tags, limits)?; + Ok(()) +} + +struct RawEventSeed; + +impl<'de> DeserializeSeed<'de> for RawEventSeed { + type Value = RawEvent<'de>; + + fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error> + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_map(RawEventVisitor) + } +} + +struct RawEventVisitor; + +impl<'de> Visitor<'de> for RawEventVisitor { + type Value = RawEvent<'de>; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a bounded NIP-01 event object") + } + + fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> + where + A: MapAccess<'de>, + { + let mut id = None; + let mut pubkey = None; + let mut created_at = None; + let mut kind = None; + let mut tags = None; + let mut content = None; + let mut sig = None; + let mut extras = BTreeSet::new(); + let mut extra_bytes = 0usize; + + while let Some(key) = map.next_key::<Cow<'de, str>>()? { + let slot = match key.as_ref() { + "id" => Some(&mut id), + "pubkey" => Some(&mut pubkey), + "created_at" => Some(&mut created_at), + "kind" => Some(&mut kind), + "tags" => Some(&mut tags), + "content" => Some(&mut content), + "sig" => Some(&mut sig), + _ => None, + }; + if let Some(slot) = slot { + if slot.is_some() { + return Err(de::Error::custom(DUPLICATE_FIELD_SENTINEL)); + } + *slot = Some(map.next_value::<&'de RawValue>()?); + continue; + } + + if !extras.insert(key.clone()) { + return Err(de::Error::custom(DUPLICATE_FIELD_SENTINEL)); + } + if extras.len() > RHI_TRADE_EVENT_EXTRA_FIELD_MAX_COUNT { + return Err(de::Error::custom(EXTRA_COUNT_SENTINEL)); + } + let value = map.next_value::<&'de RawValue>()?; + extra_bytes = extra_bytes + .checked_add(canonical_json_string_len(key.as_ref())) + .and_then(|total| total.checked_add(1)) + .and_then(|total| total.checked_add(value.get().len())) + .ok_or_else(|| de::Error::custom(EXTRA_BYTES_SENTINEL))?; + if extra_bytes > RHI_TRADE_EVENT_EXTRA_JSON_MAX_BYTES { + return Err(de::Error::custom(EXTRA_BYTES_SENTINEL)); + } + } + + Ok(RawEvent { + id: required(id)?, + pubkey: required(pubkey)?, + created_at: required(created_at)?, + kind: required(kind)?, + tags: required(tags)?, + content: required(content)?, + sig: required(sig)?, + }) + } +} + +fn required<E>(value: Option<&RawValue>) -> Result<&RawValue, E> +where + E: de::Error, +{ + value.ok_or_else(|| de::Error::custom("missing required event field")) +} + +fn canonical_json_string_len(value: &str) -> usize { + value.chars().fold(2usize, |length, character| { + length.saturating_add(match character { + '"' | '\\' | '\n' | '\r' | '\t' | '\u{08}' | '\u{0c}' => 2, + '\u{00}'..='\u{1f}' => 6, + _ => character.len_utf8(), + }) + }) +} + +fn parse_scalar<T>(raw: &RawValue) -> Result<T, RhiTradeMutationAdmissionError> +where + T: serde::de::DeserializeOwned, +{ + serde_json::from_str(raw.get()) + .map_err(|_| failure(RhiTradeMutationAdmissionErrorKind::MalformedEvent)) +} + +fn validate_bounded_string( + raw: &RawValue, + maximum: usize, + too_large: RhiTradeMutationAdmissionErrorKind, +) -> Result<usize, RhiTradeMutationAdmissionError> { + let length = decoded_json_string_utf8_bytes(raw.get()) + .ok_or_else(|| failure(RhiTradeMutationAdmissionErrorKind::MalformedEvent))?; + if length > maximum { + return Err(failure(too_large)); + } + Ok(length) +} + +fn decoded_json_string_utf8_bytes(raw: &str) -> Option<usize> { + let bytes = raw.as_bytes(); + if bytes.len() < 2 || bytes.first() != Some(&b'"') || bytes.last() != Some(&b'"') { + return None; + } + let end = bytes.len() - 1; + let mut index = 1; + let mut length = 0usize; + while index < end { + let byte = bytes[index]; + if byte == b'\\' { + index = index.checked_add(1)?; + let escaped = *bytes.get(index)?; + match escaped { + b'"' | b'\\' | b'/' | b'b' | b'f' | b'n' | b'r' | b't' => { + length = length.checked_add(1)?; + index = index.checked_add(1)?; + } + b'u' => { + let first = parse_hex_u16(bytes.get(index + 1..index + 5)?)?; + index = index.checked_add(5)?; + let scalar = if (0xd800..=0xdbff).contains(&first) { + if bytes.get(index..index + 2)? != b"\\u" { + return None; + } + let second = parse_hex_u16(bytes.get(index + 2..index + 6)?)?; + if !(0xdc00..=0xdfff).contains(&second) { + return None; + } + index = index.checked_add(6)?; + 0x1_0000 + + ((u32::from(first) - 0xd800) << 10) + + (u32::from(second) - 0xdc00) + } else if (0xdc00..=0xdfff).contains(&first) { + return None; + } else { + u32::from(first) + }; + length = length.checked_add(char::from_u32(scalar)?.len_utf8())?; + } + _ => return None, + } + } else if byte < 0x80 { + if byte < 0x20 || byte == b'"' { + return None; + } + length = length.checked_add(1)?; + index = index.checked_add(1)?; + } else { + let character = raw.get(index..end)?.chars().next()?; + let width = character.len_utf8(); + length = length.checked_add(width)?; + index = index.checked_add(width)?; + } + } + (index == end).then_some(length) +} + +fn parse_hex_u16(bytes: &[u8]) -> Option<u16> { + if bytes.len() != 4 { + return None; + } + bytes.iter().try_fold(0u16, |value, byte| { + let digit = match byte { + b'0'..=b'9' => u16::from(byte - b'0'), + b'a'..=b'f' => u16::from(byte - b'a') + 10, + b'A'..=b'F' => u16::from(byte - b'A') + 10, + _ => return None, + }; + value.checked_mul(16)?.checked_add(digit) + }) +} + +#[derive(Clone, Copy)] +struct Measurement { + count: usize, + elements: usize, + bytes: usize, +} + +fn measure_tags( + raw: &RawValue, + limits: RhiTradeMutationAdmissionLimits, +) -> Result<Measurement, RhiTradeMutationAdmissionError> { + let mut deserializer = serde_json::Deserializer::from_str(raw.get()); + let measurement = TagsSeed { limits } + .deserialize(&mut deserializer) + .map_err(classify_tag_error)?; + deserializer + .end() + .map_err(|_| failure(RhiTradeMutationAdmissionErrorKind::MalformedEvent))?; + Ok(measurement) +} + +struct TagsSeed { + limits: RhiTradeMutationAdmissionLimits, +} + +impl<'de> DeserializeSeed<'de> for TagsSeed { + type Value = Measurement; + + fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error> + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_seq(TagsVisitor { + limits: self.limits, + }) + } +} + +struct TagsVisitor { + limits: RhiTradeMutationAdmissionLimits, +} + +impl<'de> Visitor<'de> for TagsVisitor { + type Value = Measurement; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a bounded array of Nostr tags") + } + + fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error> + where + A: SeqAccess<'de>, + { + let mut result = Measurement { + count: 0, + elements: 0, + bytes: 0, + }; + while result.count < self.limits.tag_count { + let remaining_elements = self + .limits + .tag_total_elements + .checked_sub(result.elements) + .ok_or_else(|| de::Error::custom(TAG_ELEMENT_COUNT_SENTINEL))?; + let Some(tag) = sequence.next_element_seed(TagSeed { + maximum_elements: remaining_elements, + maximum_element_bytes: self.limits.tag_element_bytes, + })? + else { + return Ok(result); + }; + result.count += 1; + result.elements = result + .elements + .checked_add(tag.elements) + .ok_or_else(|| de::Error::custom(TAG_ELEMENT_COUNT_SENTINEL))?; + result.bytes = result + .bytes + .checked_add(tag.bytes) + .ok_or_else(|| de::Error::custom(TAG_TOTAL_BYTES_SENTINEL))?; + if result.bytes > self.limits.tag_total_bytes { + return Err(de::Error::custom(TAG_TOTAL_BYTES_SENTINEL)); + } + } + if sequence.next_element::<IgnoredAny>()?.is_some() { + return Err(de::Error::custom(TAG_COUNT_SENTINEL)); + } + Ok(result) + } +} + +struct TagSeed { + maximum_elements: usize, + maximum_element_bytes: usize, +} + +impl<'de> DeserializeSeed<'de> for TagSeed { + type Value = Measurement; + + fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error> + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_seq(TagVisitor { + maximum_elements: self.maximum_elements, + maximum_element_bytes: self.maximum_element_bytes, + }) + } +} + +struct TagVisitor { + maximum_elements: usize, + maximum_element_bytes: usize, +} + +impl<'de> Visitor<'de> for TagVisitor { + type Value = Measurement; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a bounded Nostr tag") + } + + fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error> + where + A: SeqAccess<'de>, + { + let mut result = Measurement { + count: 1, + elements: 0, + bytes: 0, + }; + while result.elements < self.maximum_elements { + let Some(length) = sequence.next_element_seed(StringLengthSeed { + maximum: self.maximum_element_bytes, + })? + else { + return Ok(result); + }; + result.elements += 1; + result.bytes = result + .bytes + .checked_add(length) + .ok_or_else(|| de::Error::custom(TAG_TOTAL_BYTES_SENTINEL))?; + } + if sequence.next_element::<IgnoredAny>()?.is_some() { + return Err(de::Error::custom(TAG_ELEMENT_COUNT_SENTINEL)); + } + Ok(result) + } +} + +struct StringLengthSeed { + maximum: usize, +} + +impl<'de> DeserializeSeed<'de> for StringLengthSeed { + type Value = usize; + + fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error> + where + D: serde::Deserializer<'de>, + { + let raw = <&RawValue>::deserialize(deserializer)?; + let length = decoded_json_string_utf8_bytes(raw.get()) + .ok_or_else(|| de::Error::invalid_type(de::Unexpected::Other("non-string"), &self))?; + if length > self.maximum { + return Err(de::Error::custom(TAG_ELEMENT_BYTES_SENTINEL)); + } + Ok(length) + } +} + +impl de::Expected for StringLengthSeed { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON string") + } +} + +fn classify_wire_preflight_error(error: serde_json::Error) -> RhiTradeMutationAdmissionError { + let rendered = error.to_string(); + let kind = if rendered.contains(DUPLICATE_FIELD_SENTINEL) { + RhiTradeMutationAdmissionErrorKind::DuplicateEventField + } else if rendered.contains(EXTRA_COUNT_SENTINEL) { + RhiTradeMutationAdmissionErrorKind::TooManyExtraFields + } else if rendered.contains(EXTRA_BYTES_SENTINEL) { + RhiTradeMutationAdmissionErrorKind::ExtraFieldsTooLarge + } else { + RhiTradeMutationAdmissionErrorKind::MalformedEvent + }; + failure(kind) +} + +fn classify_tag_error(error: serde_json::Error) -> RhiTradeMutationAdmissionError { + let rendered = error.to_string(); + let kind = if rendered.contains(TAG_COUNT_SENTINEL) { + RhiTradeMutationAdmissionErrorKind::TooManyTags + } else if rendered.contains(TAG_ELEMENT_COUNT_SENTINEL) { + RhiTradeMutationAdmissionErrorKind::TooManyTagElements + } else if rendered.contains(TAG_ELEMENT_BYTES_SENTINEL) { + RhiTradeMutationAdmissionErrorKind::TagElementTooLarge + } else if rendered.contains(TAG_TOTAL_BYTES_SENTINEL) { + RhiTradeMutationAdmissionErrorKind::TagsTooLarge + } else { + RhiTradeMutationAdmissionErrorKind::MalformedEvent + }; + failure(kind) +} + +const fn failure(kind: RhiTradeMutationAdmissionErrorKind) -> RhiTradeMutationAdmissionError { + RhiTradeMutationAdmissionError { kind } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonical_json_string_length_is_allocation_free_and_exact() { + assert_eq!(canonical_json_string_len("plain"), 7); + assert_eq!(canonical_json_string_len("a\nb"), 6); + assert_eq!(canonical_json_string_len("é"), 4); + assert_eq!(canonical_json_string_len("\u{0001}"), 8); + } + + #[test] + fn decoded_json_string_length_handles_escapes_and_surrogates() { + assert_eq!(decoded_json_string_utf8_bytes(r#""plain""#), Some(5)); + assert_eq!(decoded_json_string_utf8_bytes(r#""a\nb""#), Some(3)); + assert_eq!(decoded_json_string_utf8_bytes(r#""\u00e9""#), Some(2)); + assert_eq!(decoded_json_string_utf8_bytes(r#""\ud83c\udf31""#), Some(4)); + assert_eq!(decoded_json_string_utf8_bytes(r#""\ud83c""#), None); + } +} diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -13,6 +13,8 @@ const RUNTIME_ADAPTER_CONTRACT: &str = const RUNTIME_FOUNDATION: &str = include_str!("../src/runtime_foundation.rs"); const RUNTIME_FOUNDATION_CONTRACT: &str = include_str!("../contracts/services_hardening/runtime_foundation.v1.json"); +const TRADE_INGEST_CONTRACT: &str = + include_str!("../contracts/services_hardening/trade_ingest.v1.json"); const PUBLIC_API: &str = include_str!("../contracts/api_baselines/rhi.txt"); const SOURCES: &[&str] = &[ include_str!("../src/adapters/nostr/event.rs"), @@ -30,6 +32,7 @@ const SOURCES: &[&str] = &[ include_str!("../src/state_maintenance.rs"), include_str!("../src/state_metadata.rs"), include_str!("../src/state_repository.rs"), + include_str!("../src/trade_ingest.rs"), ]; #[test] @@ -93,6 +96,7 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { "state_maintenance", "state_metadata", "state_repository", + "trade_ingest", ] { assert!( ROOT.contains(&format!("mod {module};")), @@ -131,6 +135,9 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { "EntropyError", "WallClockError", "MonotonicClockError", + "admit_rhi_trade_mutation_event", + "RhiTradeMutationAdmissionLimits", + "RhiAdmittedTradeMutationEvent", ] { assert!( ROOT.contains(required), @@ -182,7 +189,55 @@ fn public_errors_are_crate_owned_redacted_and_source_free() { .lines() .filter(|line| line.starts_with("pub struct rhi::") && line.ends_with("Error")) .count(); - assert_eq!(public_error_count, 13); + assert_eq!(public_error_count, 14); +} + +#[test] +fn trade_ingest_is_sealed_bounded_verified_and_effect_free() { + let contract: serde_json::Value = + serde_json::from_str(TRADE_INGEST_CONTRACT).expect("trade-ingest contract"); + assert_eq!(contract["schema"], "radroots.rhi.trade-ingest.v1"); + assert_eq!(contract["contract_version"], 1); + assert_eq!(contract["wire"]["original_wire_cap_before_parse"], true); + assert_eq!(contract["wire"]["duplicate_fields"], "reject"); + assert_eq!(contract["authored_time"]["default"], "none"); + assert_eq!(contract["verification"]["event_id"], "recomputed_and_exact"); + assert_eq!( + contract["verification"]["signature"], + "bip340_schnorr_verified" + ); + assert_eq!(contract["effects"]["filesystem"], false); + assert_eq!(contract["effects"]["sqlite"], false); + assert_eq!(contract["effects"]["network"], false); + + let source = include_str!("../src/trade_ingest.rs"); + for required in [ + "preflight_wire(source, limits)?", + "verify_id(&event)", + "verify(&event)", + "trade_mutation_from_event(&event)", + "original: original.into()", + ] { + assert!( + source.contains(required), + "trade ingest is missing {required}" + ); + } + for forbidden in [ + "sqlx::", + "std::fs", + "std::net", + "tokio::", + "SystemTime", + "process::", + ] { + assert!( + !source.contains(forbidden), + "trade ingest gained effect authority {forbidden}" + ); + } + assert!(!ROOT.contains("pub mod trade_ingest")); + assert!(!PUBLIC_API.contains("rhi::trade_ingest::")); } #[test] @@ -297,6 +352,7 @@ fn readme_freezes_the_root_only_boundary_and_exact_baseline() { "failures into stable RHI classifications", "```compile_fail", "[RHI API baseline](contracts/api_baselines/rhi.txt)", + "[`trade_ingest.v1.json`](contracts/services_hardening/trade_ingest.v1.json)", "## Injected runtime adapters", "whole-second wall UTC", "process-local monotonic time", @@ -322,6 +378,7 @@ fn readme_freezes_the_root_only_boundary_and_exact_baseline() { "no raw dependency-owned source chain", "Compose those dependencies only through the sealed runtime-adapter boundary", "exposes no task handle or concrete transport handle", + "admit_rhi_trade_mutation_event", ] { assert!(AGENTS.contains(required), "AGENTS is missing {required}"); } diff --git a/tests/services_hardening_trade_ingest.rs b/tests/services_hardening_trade_ingest.rs @@ -0,0 +1,444 @@ +#![forbid(unsafe_code)] + +use std::error::Error; + +use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; +use rhi::{ + RHI_TRADE_EVENT_EXTRA_FIELD_MAX_COUNT, RHI_TRADE_EVENT_EXTRA_JSON_MAX_BYTES, + RHI_TRADE_EVENT_ID_MAX_BYTES, RHI_TRADE_EVENT_PUBLIC_KEY_MAX_BYTES, + RHI_TRADE_EVENT_SIGNATURE_MAX_BYTES, RHI_TRADE_INGEST_CONTRACT_VERSION, RhiConfigProfile, + RhiTradeMutationAdmissionErrorKind, RhiTradeMutationAdmissionLimits, + RhiTradeMutationAuthoredTimePolicy, RhiTradeMutationObservedAtUnixSeconds, + admit_rhi_trade_mutation_event, parse_rhi_config_v1, +}; +use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; + +const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); +const CONTRACT: &str = include_str!("../contracts/services_hardening/trade_ingest.v1.json"); +const VECTOR: &str = include_str!("../contracts/conformance/vectors/trade_ingest_proposal.v1.json"); + +fn configuration(overrides: &[(&str, usize)]) -> rhi::RhiConfigDocumentV1 { + let mut source = CONFIG.to_owned(); + for (field, value) in overrides { + let prefix = format!("{field} = "); + let original = source + .lines() + .find(|line| line.starts_with(&prefix)) + .expect("configured event limit") + .to_owned(); + source = source.replacen(&original, &format!("{field} = {value}"), 1); + } + parse_rhi_config_v1(source.as_bytes(), RhiConfigProfile::RepoLocal).expect("test configuration") +} + +fn limits(overrides: &[(&str, usize)]) -> RhiTradeMutationAdmissionLimits { + RhiTradeMutationAdmissionLimits::from_config(&configuration(overrides)).expect("event limits") +} + +fn vector() -> Value { + serde_json::from_str(VECTOR).expect("trade-ingest vector") +} + +fn valid_wire() -> Vec<u8> { + vector()["raw_json"] + .as_str() + .expect("raw event") + .as_bytes() + .to_vec() +} + +fn observed(value: u64) -> RhiTradeMutationObservedAtUnixSeconds { + RhiTradeMutationObservedAtUnixSeconds::new(value).expect("observation") +} + +fn policy(value: u64) -> RhiTradeMutationAuthoredTimePolicy { + RhiTradeMutationAuthoredTimePolicy::new(value).expect("time policy") +} + +fn error(bytes: &[u8]) -> RhiTradeMutationAdmissionErrorKind { + admit_rhi_trade_mutation_event(limits(&[]), bytes, observed(1_784_347_200), policy(0)) + .expect_err("event must fail") + .kind() +} + +fn keys(seed: u8) -> Keys { + Keys::parse(&format!("{seed:02x}{}", "00".repeat(31))).expect("test keys") +} + +fn fixture_keys() -> Keys { + Keys::parse("10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5") + .expect("approved fixture keys") +} + +fn signed_variant( + kind: u16, + created_at: u64, + content: String, + tags: Vec<Tag>, + keys: &Keys, +) -> Vec<u8> { + let event = EventBuilder::new(Kind::Custom(kind), content) + .tags(tags) + .custom_created_at(Timestamp::from_secs(created_at)) + .sign_with_keys(keys) + .expect("signed event"); + serde_json::to_vec(&event).expect("event JSON") +} + +fn vector_parts() -> (String, Vec<Tag>, u64) { + let event: Value = serde_json::from_slice(&valid_wire()).expect("event JSON"); + let content = event["content"].as_str().expect("content").to_owned(); + let tags = event["tags"] + .as_array() + .expect("tags") + .iter() + .map(|tag| { + let values = tag + .as_array() + .expect("tag") + .iter() + .map(|value| value.as_str().expect("tag element").to_owned()) + .collect::<Vec<_>>(); + Tag::parse(values).expect("typed tag") + }) + .collect::<Vec<_>>(); + let created_at = event["created_at"].as_u64().expect("created_at"); + (content, tags, created_at) +} + +#[test] +fn machine_contract_vector_and_configuration_projection_are_exact() { + let contract: Value = serde_json::from_str(CONTRACT).expect("trade-ingest contract"); + assert_eq!(contract["schema"], "radroots.rhi.trade-ingest.v1"); + assert_eq!(contract["contract_version"], 1); + assert_eq!(RHI_TRADE_INGEST_CONTRACT_VERSION, 1); + assert_eq!(contract["wire"]["original_wire_cap_before_parse"], true); + assert_eq!( + contract["verification"]["registered_kinds"], + json!([3470, 3471, 3472, 3473, 3474]) + ); + assert_eq!(contract["authored_time"]["default"], "none"); + assert_eq!(contract["effects"]["sqlite"], false); + + let digest = format!("{:x}", Sha256::digest(VECTOR.as_bytes())); + assert_eq!(contract["conformance_vector"]["sha256"], digest); + + let limits = limits(&[]); + assert_eq!(limits.wire_bytes(), 262_144); + assert_eq!(limits.content_bytes(), 131_072); + assert_eq!(limits.tag_count(), 1_024); + assert_eq!(limits.tag_total_elements(), 4_096); + assert_eq!(limits.tag_element_bytes(), 4_096); + assert_eq!(limits.tag_total_bytes(), 131_072); + assert_eq!(RHI_TRADE_EVENT_ID_MAX_BYTES, 64); + assert_eq!(RHI_TRADE_EVENT_PUBLIC_KEY_MAX_BYTES, 64); + assert_eq!(RHI_TRADE_EVENT_SIGNATURE_MAX_BYTES, 128); + assert_eq!(RHI_TRADE_EVENT_EXTRA_FIELD_MAX_COUNT, 64); + assert_eq!(RHI_TRADE_EVENT_EXTRA_JSON_MAX_BYTES, 65_536); +} + +#[test] +fn promoted_lib_proposal_vector_is_verified_and_retained_exactly() { + let bytes = valid_wire(); + let admitted = + admit_rhi_trade_mutation_event(limits(&[]), &bytes, observed(1_784_347_200), policy(0)) + .expect("canonical signed mutation"); + assert_eq!(admitted.original_bytes(), bytes); + assert_eq!(admitted.event_id().to_hex(), vector()["event_id"]); + assert_eq!(admitted.event_kind(), 3470); + assert_eq!(admitted.authored_at_unix_seconds(), 1_784_347_200); + assert_eq!( + admitted.mutation().mutation_id.as_ref(), + Some(admitted.mutation_id()) + ); + + let rendered = format!("{admitted:?}"); + assert!(!rendered.contains(vector()["event_id"].as_str().expect("event id"))); + assert!(!rendered.contains(&admitted.mutation_id().to_hex())); + assert!(!rendered.contains("farm-1")); +} + +#[test] +fn every_configured_wire_limit_is_exact_and_precedes_verification() { + let bytes = valid_wire(); + let value: Value = serde_json::from_slice(&bytes).expect("event"); + let tags = value["tags"].as_array().expect("tags"); + let content_bytes = value["content"].as_str().expect("content").len(); + let tag_count = tags.len(); + let tag_elements = tags + .iter() + .map(|tag| tag.as_array().expect("tag").len()) + .sum::<usize>(); + let tag_bytes = tags + .iter() + .flat_map(|tag| tag.as_array().expect("tag")) + .map(|value| value.as_str().expect("element").len()) + .sum::<usize>(); + let tag_element_bytes = tags + .iter() + .flat_map(|tag| tag.as_array().expect("tag")) + .map(|value| value.as_str().expect("element").len()) + .max() + .expect("element"); + + for (field, exact, rejected) in [ + ( + "wire_bytes", + bytes.len(), + RhiTradeMutationAdmissionErrorKind::EventTooLarge, + ), + ( + "content_bytes", + content_bytes, + RhiTradeMutationAdmissionErrorKind::EventContentTooLarge, + ), + ( + "tag_count", + tag_count, + RhiTradeMutationAdmissionErrorKind::TooManyTags, + ), + ( + "tag_total_elements", + tag_elements, + RhiTradeMutationAdmissionErrorKind::TooManyTagElements, + ), + ( + "tag_element_bytes", + tag_element_bytes, + RhiTradeMutationAdmissionErrorKind::TagElementTooLarge, + ), + ( + "tag_total_bytes", + tag_bytes, + RhiTradeMutationAdmissionErrorKind::TagsTooLarge, + ), + ] { + admit_rhi_trade_mutation_event( + limits(&[(field, exact)]), + &bytes, + observed(1_784_347_200), + policy(0), + ) + .unwrap_or_else(|failure| panic!("{field} exact boundary failed: {failure}")); + let failure = admit_rhi_trade_mutation_event( + limits(&[(field, exact - 1)]), + &bytes, + observed(1_784_347_200), + policy(0), + ) + .expect_err("just below required capacity"); + assert_eq!(failure.kind(), rejected, "{field}"); + } +} + +#[test] +fn original_bytes_identifiers_duplicates_utf8_and_required_shape_fail_closed() { + assert_eq!(error(&[]), RhiTradeMutationAdmissionErrorKind::EmptyEvent); + assert_eq!( + error(&[0xff]), + RhiTradeMutationAdmissionErrorKind::InvalidEventUtf8 + ); + + let oversized = vec![b' '; limits(&[]).wire_bytes() + 1]; + assert_eq!( + error(&oversized), + RhiTradeMutationAdmissionErrorKind::EventTooLarge + ); + + let valid = String::from_utf8(valid_wire()).expect("UTF-8 event"); + let duplicate = valid.replacen("\"id\":", "\"id\":\"11\",\"id\":", 1); + assert_eq!( + error(duplicate.as_bytes()), + RhiTradeMutationAdmissionErrorKind::DuplicateEventField + ); + + let mut value: Value = serde_json::from_str(&valid).expect("event"); + value["id"] = Value::String("1".repeat(65)); + assert_eq!( + error(&serde_json::to_vec(&value).expect("event")), + RhiTradeMutationAdmissionErrorKind::EventIdentifierTooLarge + ); + value["id"] = Value::Null; + assert_eq!( + error(&serde_json::to_vec(&value).expect("event")), + RhiTradeMutationAdmissionErrorKind::MalformedEvent + ); +} + +#[test] +fn outer_extensions_are_bounded_but_never_gain_semantic_authority() { + let mut exact: Value = serde_json::from_slice(&valid_wire()).expect("event"); + let object = exact.as_object_mut().expect("object"); + for index in 0..RHI_TRADE_EVENT_EXTRA_FIELD_MAX_COUNT { + object.insert(format!("extension_{index:02}"), json!(index)); + } + let exact_bytes = serde_json::to_vec(&exact).expect("event"); + admit_rhi_trade_mutation_event( + limits(&[]), + &exact_bytes, + observed(1_784_347_200), + policy(0), + ) + .expect("exact extra count"); + exact + .as_object_mut() + .expect("object") + .insert("extension_over".to_owned(), json!(true)); + assert_eq!( + error(&serde_json::to_vec(&exact).expect("event")), + RhiTradeMutationAdmissionErrorKind::TooManyExtraFields + ); + + let mut exact_bytes_value: Value = serde_json::from_slice(&valid_wire()).expect("event"); + exact_bytes_value + .as_object_mut() + .expect("object") + .insert("extra".to_owned(), Value::String("x".repeat(65_526))); + admit_rhi_trade_mutation_event( + limits(&[]), + &serde_json::to_vec(&exact_bytes_value).expect("event"), + observed(1_784_347_200), + policy(0), + ) + .expect("exact extra byte budget"); + exact_bytes_value["extra"] = Value::String("x".repeat(65_527)); + assert_eq!( + error(&serde_json::to_vec(&exact_bytes_value).expect("event")), + RhiTradeMutationAdmissionErrorKind::ExtraFieldsTooLarge + ); +} + +#[test] +fn event_id_signature_kind_author_content_and_tags_are_independent_checks() { + let mut value: Value = serde_json::from_slice(&valid_wire()).expect("event"); + value["id"] = Value::String("0".repeat(64)); + assert_eq!( + error(&serde_json::to_vec(&value).expect("event")), + RhiTradeMutationAdmissionErrorKind::InvalidEventId + ); + + value = serde_json::from_slice(&valid_wire()).expect("event"); + value["sig"] = Value::String("0".repeat(128)); + assert_eq!( + error(&serde_json::to_vec(&value).expect("event")), + RhiTradeMutationAdmissionErrorKind::InvalidSignature + ); + + let (content, tags, created_at) = vector_parts(); + let unsupported = signed_variant(9_999, created_at, content.clone(), tags.clone(), &keys(9)); + assert_eq!( + error(&unsupported), + RhiTradeMutationAdmissionErrorKind::UnsupportedKind + ); + let future_unsupported = signed_variant( + 9_999, + created_at + 10, + content.clone(), + tags.clone(), + &keys(9), + ); + assert_eq!( + error(&future_unsupported), + RhiTradeMutationAdmissionErrorKind::UnsupportedKind + ); + + let wrong_author = signed_variant(3_470, created_at, content.clone(), tags.clone(), &keys(9)); + assert_eq!( + error(&wrong_author), + RhiTradeMutationAdmissionErrorKind::InvalidAuthor + ); + + assert_eq!(fixture_keys().public_key().to_hex(), vector()["pubkey"]); + let mut noncanonical_content = content.clone(); + noncanonical_content.insert(1, ' '); + let noncanonical = signed_variant( + 3_470, + created_at, + noncanonical_content.clone(), + tags.clone(), + &fixture_keys(), + ); + assert_eq!( + error(&noncanonical), + RhiTradeMutationAdmissionErrorKind::InvalidMutation + ); + let future_noncanonical = signed_variant( + 3_470, + created_at + 10, + noncanonical_content, + tags.clone(), + &fixture_keys(), + ); + assert_eq!( + error(&future_noncanonical), + RhiTradeMutationAdmissionErrorKind::InvalidMutation + ); + + let mut duplicate_tags = tags; + duplicate_tags.push(Tag::parse(["d", "99999999999999999999999999999999"]).expect("tag")); + let duplicate = signed_variant(3_470, created_at, content, duplicate_tags, &fixture_keys()); + assert_eq!( + error(&duplicate), + RhiTradeMutationAdmissionErrorKind::InvalidMutation + ); +} + +#[test] +fn authored_time_policy_is_explicit_inclusive_old_safe_and_overflow_bounded() { + assert!(RhiTradeMutationObservedAtUnixSeconds::new(0).is_err()); + assert!(RhiTradeMutationObservedAtUnixSeconds::new(i64::MAX as u64).is_ok()); + assert!(RhiTradeMutationObservedAtUnixSeconds::new(i64::MAX as u64 + 1).is_err()); + assert!(RhiTradeMutationAuthoredTimePolicy::new(i64::MAX as u64).is_ok()); + assert!(RhiTradeMutationAuthoredTimePolicy::new(i64::MAX as u64 + 1).is_err()); + + let bytes = valid_wire(); + admit_rhi_trade_mutation_event(limits(&[]), &bytes, observed(1_784_347_202), policy(0)) + .expect("old lineage event"); + admit_rhi_trade_mutation_event(limits(&[]), &bytes, observed(1_784_347_198), policy(2)) + .expect("inclusive future boundary"); + assert_eq!( + admit_rhi_trade_mutation_event(limits(&[]), &bytes, observed(1_784_347_197), policy(2),) + .expect_err("excessive future") + .kind(), + RhiTradeMutationAdmissionErrorKind::AuthoredTimeRejected + ); + + let (content, tags, _) = vector_parts(); + let unrepresentable = + signed_variant(3_470, i64::MAX as u64 + 1, content, tags, &fixture_keys()); + assert_eq!( + admit_rhi_trade_mutation_event( + limits(&[]), + &unrepresentable, + observed(i64::MAX as u64), + policy(0), + ) + .expect_err("unrepresentable authored time") + .kind(), + RhiTradeMutationAdmissionErrorKind::InvalidAuthoredTime + ); +} + +#[test] +fn errors_and_accepted_debug_are_source_free_and_redacted() { + let secret = "trade-secret-evidence-marker"; + let malformed = format!("{{\"content\":\"{secret}\"}}"); + let failure = + admit_rhi_trade_mutation_event(limits(&[]), malformed.as_bytes(), observed(1), policy(0)) + .expect_err("malformed event"); + assert!(failure.source().is_none()); + for rendered in [failure.to_string(), format!("{failure:?}")] { + assert!(!rendered.contains(secret)); + assert!(!rendered.contains("content")); + assert!(!rendered.contains("serde")); + } +} + +#[test] +fn extra_byte_measurement_matches_the_frozen_member_formula() { + let mut object = Map::new(); + object.insert("extra".to_owned(), Value::String("x".repeat(65_526))); + let encoded = serde_json::to_vec(&Value::Object(object)).expect("JSON"); + assert_eq!(encoded.len() - 2, RHI_TRADE_EVENT_EXTRA_JSON_MAX_BYTES); +}