lib

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

commit da1baf441e0eaff1f18d049aefc7d0af74d7d90b
parent 0e436a924e8cbd708b35a708b179e01f6a1450ba
Author: triesap <tyson@radroots.org>
Date:   Sun, 16 Aug 2026 22:14:15 +0000

trade: freeze immutable evidence manifests

- Intent: Add a bounded canonical manifest for one trade generation and its exact policy, source, event, and provenance commitments.

- Why: Deterministic reconciliation requires immutable evidence input before projection, report, and attestation contracts can be built.

- Verification: Extbuild package tests, bounds and permutation vectors, feature checks, Clippy, Rustdoc, workspace checks, contract guards, and API regeneration passed.

- Risk: The manifest commits to separately retained canonical records; report construction, runtime persistence, signing, publication, and prototype removal remain later checkpoints.

Diffstat:
Mcontracts/api_baselines/radroots_trade.txt | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/trade/README.md | 25+++++++++++++++++++++++--
Mcrates/trade/src/evidence.rs | 14++++++++++++++
Acrates/trade/src/evidence_manifest.rs | 1156++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/trade/src/lib.rs | 4+++-
Mcrates/trade/tests/package_boundary.rs | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 1380 insertions(+), 3 deletions(-)

diff --git a/contracts/api_baselines/radroots_trade.txt b/contracts/api_baselines/radroots_trade.txt @@ -17,6 +17,23 @@ pub radroots_trade::evidence::RadrootsTradeEvidenceCoverageV1::ScopeSatisfied pub radroots_trade::evidence::RadrootsTradeEvidenceCoverageV1::Unsupported impl radroots_trade::evidence::RadrootsTradeEvidenceCoverageV1 pub const fn radroots_trade::evidence::RadrootsTradeEvidenceCoverageV1::permits(self, radroots_trade::evidence::RadrootsTradeEvidenceOutcomeV1) -> bool +pub enum radroots_trade::evidence::RadrootsTradeEvidenceManifestError +pub radroots_trade::evidence::RadrootsTradeEvidenceManifestError::ConflictingEvent +pub radroots_trade::evidence::RadrootsTradeEvidenceManifestError::DuplicateObservation +pub radroots_trade::evidence::RadrootsTradeEvidenceManifestError::DuplicateSource +pub radroots_trade::evidence::RadrootsTradeEvidenceManifestError::InvalidSourceId +pub radroots_trade::evidence::RadrootsTradeEvidenceManifestError::Malformed +pub radroots_trade::evidence::RadrootsTradeEvidenceManifestError::ManifestTooLarge +pub radroots_trade::evidence::RadrootsTradeEvidenceManifestError::NoRequiredSource +pub radroots_trade::evidence::RadrootsTradeEvidenceManifestError::NonCanonical +pub radroots_trade::evidence::RadrootsTradeEvidenceManifestError::ObservationCountOutOfRange +pub radroots_trade::evidence::RadrootsTradeEvidenceManifestError::SourceCountOutOfRange +pub radroots_trade::evidence::RadrootsTradeEvidenceManifestError::SourceEventCountMismatch +pub radroots_trade::evidence::RadrootsTradeEvidenceManifestError::UnknownObservationSource +pub radroots_trade::evidence::RadrootsTradeEvidenceManifestError::UnsupportedVersion +impl core::error::Error for radroots_trade::evidence::RadrootsTradeEvidenceManifestError +impl core::fmt::Display for radroots_trade::evidence::RadrootsTradeEvidenceManifestError +pub fn radroots_trade::evidence::RadrootsTradeEvidenceManifestError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub enum radroots_trade::evidence::RadrootsTradeEvidenceOutcomeV1 pub radroots_trade::evidence::RadrootsTradeEvidenceOutcomeV1::Indeterminate pub radroots_trade::evidence::RadrootsTradeEvidenceOutcomeV1::Invalid @@ -42,6 +59,79 @@ pub const fn radroots_trade::evidence::RadrootsTradeAttestationRecordV1::claim_m pub const fn radroots_trade::evidence::RadrootsTradeAttestationRecordV1::event_id(&self) -> &radroots_event::id::EventId pub const fn radroots_trade::evidence::RadrootsTradeAttestationRecordV1::new(radroots_event::id::EventId, radroots_event::id::MutationId, radroots_trade::evidence::RadrootsTradeAttestationResultV1) -> Self pub const fn radroots_trade::evidence::RadrootsTradeAttestationRecordV1::result(&self) -> radroots_trade::evidence::RadrootsTradeAttestationResultV1 +pub struct radroots_trade::evidence::RadrootsTradeEvidenceManifestDigestV1(_) +impl radroots_trade::evidence::RadrootsTradeEvidenceManifestDigestV1 +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceManifestDigestV1::as_bytes(&self) -> &[u8; 32] +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceManifestDigestV1::from_bytes([u8; 32]) -> Self +pub fn radroots_trade::evidence::RadrootsTradeEvidenceManifestDigestV1::to_hex(&self) -> alloc::string::String +impl core::fmt::Debug for radroots_trade::evidence::RadrootsTradeEvidenceManifestDigestV1 +pub fn radroots_trade::evidence::RadrootsTradeEvidenceManifestDigestV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_trade::evidence::RadrootsTradeEvidenceManifestObservationV1 +impl radroots_trade::evidence::RadrootsTradeEvidenceManifestObservationV1 +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceManifestObservationV1::event_id(&self) -> &radroots_event::id::EventId +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceManifestObservationV1::mutation_id(&self) -> &radroots_event::id::MutationId +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceManifestObservationV1::new(radroots_trade::evidence::RadrootsTradeEvidenceSourceIdV1, radroots_event::id::MutationId, radroots_event::id::EventId, radroots_trade::evidence::RadrootsTradeSignedEventDigestV1, radroots_trade::evidence::RadrootsTradeEvidenceProvenanceDigestV1) -> Self +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceManifestObservationV1::provenance_digest(&self) -> radroots_trade::evidence::RadrootsTradeEvidenceProvenanceDigestV1 +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceManifestObservationV1::signed_event_digest(&self) -> radroots_trade::evidence::RadrootsTradeSignedEventDigestV1 +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceManifestObservationV1::source_id(&self) -> &radroots_trade::evidence::RadrootsTradeEvidenceSourceIdV1 +impl core::fmt::Debug for radroots_trade::evidence::RadrootsTradeEvidenceManifestObservationV1 +pub fn radroots_trade::evidence::RadrootsTradeEvidenceManifestObservationV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_trade::evidence::RadrootsTradeEvidenceManifestSourceResultV1 +impl radroots_trade::evidence::RadrootsTradeEvidenceManifestSourceResultV1 +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceManifestSourceResultV1::new(radroots_trade::evidence::RadrootsTradeEvidenceSourceIdV1, radroots_trade::evidence::RadrootsTradeEvidenceSourceResultV1, radroots_trade::evidence::RadrootsTradeEvidenceSourceResultDigestV1) -> Self +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceManifestSourceResultV1::result(&self) -> radroots_trade::evidence::RadrootsTradeEvidenceSourceResultV1 +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceManifestSourceResultV1::result_digest(&self) -> radroots_trade::evidence::RadrootsTradeEvidenceSourceResultDigestV1 +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceManifestSourceResultV1::source_id(&self) -> &radroots_trade::evidence::RadrootsTradeEvidenceSourceIdV1 +impl core::fmt::Debug for radroots_trade::evidence::RadrootsTradeEvidenceManifestSourceResultV1 +pub fn radroots_trade::evidence::RadrootsTradeEvidenceManifestSourceResultV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_trade::evidence::RadrootsTradeEvidenceManifestV1 +impl radroots_trade::evidence::RadrootsTradeEvidenceManifestV1 +pub fn radroots_trade::evidence::RadrootsTradeEvidenceManifestV1::canonical_bytes(&self) -> &[u8] +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceManifestV1::contract_id(&self) -> &'static str +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceManifestV1::contract_version(&self) -> u16 +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceManifestV1::coverage(&self) -> radroots_trade::evidence::RadrootsTradeEvidenceCoverageV1 +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceManifestV1::digest(&self) -> radroots_trade::evidence::RadrootsTradeEvidenceManifestDigestV1 +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceManifestV1::evidence_policy_digest(&self) -> radroots_trade::evidence::RadrootsTradeEvidencePolicyDigestV1 +pub fn radroots_trade::evidence::RadrootsTradeEvidenceManifestV1::from_canonical_bytes(&[u8]) -> core::result::Result<Self, radroots_trade::evidence::RadrootsTradeEvidenceManifestError> +pub fn radroots_trade::evidence::RadrootsTradeEvidenceManifestV1::new<S, O>(radroots_event::id::TradeId, core::num::nonzero::NonZeroU64, radroots_trade::evidence::RadrootsTradeEvidencePolicyDigestV1, u64, radroots_trade::evidence::RadrootsTradeEvidenceScopePrerequisitesV1, S, O) -> core::result::Result<Self, radroots_trade::evidence::RadrootsTradeEvidenceManifestError> where S: core::iter::traits::collect::IntoIterator<Item = radroots_trade::evidence::RadrootsTradeEvidenceManifestSourceResultV1>, O: core::iter::traits::collect::IntoIterator<Item = radroots_trade::evidence::RadrootsTradeEvidenceManifestObservationV1> +pub fn radroots_trade::evidence::RadrootsTradeEvidenceManifestV1::observations(&self) -> &[radroots_trade::evidence::RadrootsTradeEvidenceManifestObservationV1] +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceManifestV1::observed_at_unix_s(&self) -> u64 +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceManifestV1::scope_prerequisites(&self) -> radroots_trade::evidence::RadrootsTradeEvidenceScopePrerequisitesV1 +pub fn radroots_trade::evidence::RadrootsTradeEvidenceManifestV1::sources(&self) -> &[radroots_trade::evidence::RadrootsTradeEvidenceManifestSourceResultV1] +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceManifestV1::trade_generation(&self) -> core::num::nonzero::NonZeroU64 +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceManifestV1::trade_id(&self) -> &radroots_event::id::TradeId +impl core::fmt::Debug for radroots_trade::evidence::RadrootsTradeEvidenceManifestV1 +pub fn radroots_trade::evidence::RadrootsTradeEvidenceManifestV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_trade::evidence::RadrootsTradeEvidencePolicyDigestV1(_) +impl radroots_trade::evidence::RadrootsTradeEvidencePolicyDigestV1 +pub const fn radroots_trade::evidence::RadrootsTradeEvidencePolicyDigestV1::as_bytes(&self) -> &[u8; 32] +pub const fn radroots_trade::evidence::RadrootsTradeEvidencePolicyDigestV1::from_bytes([u8; 32]) -> Self +pub fn radroots_trade::evidence::RadrootsTradeEvidencePolicyDigestV1::sha256(&[u8]) -> Self +pub fn radroots_trade::evidence::RadrootsTradeEvidencePolicyDigestV1::to_hex(&self) -> alloc::string::String +impl core::fmt::Debug for radroots_trade::evidence::RadrootsTradeEvidencePolicyDigestV1 +pub fn radroots_trade::evidence::RadrootsTradeEvidencePolicyDigestV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_trade::evidence::RadrootsTradeEvidenceProvenanceDigestV1(_) +impl radroots_trade::evidence::RadrootsTradeEvidenceProvenanceDigestV1 +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceProvenanceDigestV1::as_bytes(&self) -> &[u8; 32] +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceProvenanceDigestV1::from_bytes([u8; 32]) -> Self +pub fn radroots_trade::evidence::RadrootsTradeEvidenceProvenanceDigestV1::sha256(&[u8]) -> Self +pub fn radroots_trade::evidence::RadrootsTradeEvidenceProvenanceDigestV1::to_hex(&self) -> alloc::string::String +impl core::fmt::Debug for radroots_trade::evidence::RadrootsTradeEvidenceProvenanceDigestV1 +pub fn radroots_trade::evidence::RadrootsTradeEvidenceProvenanceDigestV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_trade::evidence::RadrootsTradeEvidenceSourceIdV1(_) +impl radroots_trade::evidence::RadrootsTradeEvidenceSourceIdV1 +pub fn radroots_trade::evidence::RadrootsTradeEvidenceSourceIdV1::as_str(&self) -> &str +pub fn radroots_trade::evidence::RadrootsTradeEvidenceSourceIdV1::parse(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_trade::evidence::RadrootsTradeEvidenceManifestError> +impl core::fmt::Debug for radroots_trade::evidence::RadrootsTradeEvidenceSourceIdV1 +pub fn radroots_trade::evidence::RadrootsTradeEvidenceSourceIdV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_trade::evidence::RadrootsTradeEvidenceSourceResultDigestV1(_) +impl radroots_trade::evidence::RadrootsTradeEvidenceSourceResultDigestV1 +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceSourceResultDigestV1::as_bytes(&self) -> &[u8; 32] +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceSourceResultDigestV1::from_bytes([u8; 32]) -> Self +pub fn radroots_trade::evidence::RadrootsTradeEvidenceSourceResultDigestV1::sha256(&[u8]) -> Self +pub fn radroots_trade::evidence::RadrootsTradeEvidenceSourceResultDigestV1::to_hex(&self) -> alloc::string::String +impl core::fmt::Debug for radroots_trade::evidence::RadrootsTradeEvidenceSourceResultDigestV1 +pub fn radroots_trade::evidence::RadrootsTradeEvidenceSourceResultDigestV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct radroots_trade::evidence::RadrootsTradeEvidenceSourceResultV1 impl radroots_trade::evidence::RadrootsTradeEvidenceSourceResultV1 pub const fn radroots_trade::evidence::RadrootsTradeEvidenceSourceResultV1::admitted_event_count(&self) -> u32 @@ -58,8 +148,21 @@ impl radroots_trade::evidence::RadrootsTradePrivateTermsEvidenceV1 pub const fn radroots_trade::evidence::RadrootsTradePrivateTermsEvidenceV1::candidate_id(&self) -> &radroots_event::id::CandidateId pub const fn radroots_trade::evidence::RadrootsTradePrivateTermsEvidenceV1::new(radroots_event::id::CandidateId, radroots_trade::model::RadrootsTradePrivateTermsStateV1) -> Self pub const fn radroots_trade::evidence::RadrootsTradePrivateTermsEvidenceV1::state(&self) -> radroots_trade::model::RadrootsTradePrivateTermsStateV1 +pub struct radroots_trade::evidence::RadrootsTradeSignedEventDigestV1(_) +impl radroots_trade::evidence::RadrootsTradeSignedEventDigestV1 +pub const fn radroots_trade::evidence::RadrootsTradeSignedEventDigestV1::as_bytes(&self) -> &[u8; 32] +pub const fn radroots_trade::evidence::RadrootsTradeSignedEventDigestV1::from_bytes([u8; 32]) -> Self +pub fn radroots_trade::evidence::RadrootsTradeSignedEventDigestV1::sha256(&[u8]) -> Self +pub fn radroots_trade::evidence::RadrootsTradeSignedEventDigestV1::to_hex(&self) -> alloc::string::String +impl core::fmt::Debug for radroots_trade::evidence::RadrootsTradeSignedEventDigestV1 +pub fn radroots_trade::evidence::RadrootsTradeSignedEventDigestV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub const radroots_trade::evidence::RADROOTS_TRADE_EVIDENCE_MANIFEST_CONTRACT_ID: &str +pub const radroots_trade::evidence::RADROOTS_TRADE_EVIDENCE_MANIFEST_CONTRACT_VERSION: u16 +pub const radroots_trade::evidence::RADROOTS_TRADE_EVIDENCE_MANIFEST_MAXIMUM_BYTES: usize +pub const radroots_trade::evidence::RADROOTS_TRADE_EVIDENCE_MANIFEST_MAXIMUM_OBSERVATIONS: usize pub const radroots_trade::evidence::RADROOTS_TRADE_EVIDENCE_MAXIMUM_EVENTS_PER_SOURCE: u32 pub const radroots_trade::evidence::RADROOTS_TRADE_EVIDENCE_MAXIMUM_SOURCE_COUNT: usize +pub const radroots_trade::evidence::RADROOTS_TRADE_EVIDENCE_SOURCE_ID_MAXIMUM_BYTES: usize pub fn radroots_trade::evidence::classify_trade_evidence_coverage_v1(impl core::iter::traits::collect::IntoIterator<Item = radroots_trade::evidence::RadrootsTradeEvidenceSourceResultV1>, radroots_trade::evidence::RadrootsTradeEvidenceScopePrerequisitesV1) -> core::result::Result<radroots_trade::evidence::RadrootsTradeEvidenceCoverageV1, radroots_trade::evidence::RadrootsTradeEvidenceCoverageError> pub mod radroots_trade::model pub enum radroots_trade::model::OrderIdError diff --git a/crates/trade/README.md b/crates/trade/README.md @@ -15,7 +15,7 @@ New code should enter through these modules: | Module | Responsibility | | --- | --- | -| `evidence` | Immutable mutation, private-term, and attestation observations consumed by reduction. | +| `evidence` | Immutable mutation, private-term, and attestation observations plus bounded evidence-manifest models. | | `model` | Trade projection state and validated business identifiers. | | `reducer` | Deterministic reduction, evidence precedence, and conflict reporting. | | `validation` | Validation-error ownership for canonical trade inputs. | @@ -74,6 +74,27 @@ permit only `Indeterminate`. These portable values classify caller-supplied facts and perform no source query, policy lookup, clock read, persistence, signing, or publication. +## Immutable evidence manifests + +`evidence::RadrootsTradeEvidenceManifestV1` freezes one nonzero trade +generation, policy digest, explicit observation time, scope prerequisites, +bounded source results, and the exact accepted mutation/event/provenance +inventory. Canonical ordering is independent of caller order. Mutation IDs +bind canonical mutation content, while semantically distinct SHA-256 types +bind the exact caller-supplied canonical policy, signed-event, provenance, and +complete source-result record bytes without allowing those authorities to be +interchanged. The manifest commits to those separately retained records; it +does not contain or independently validate them. + +The manifest uses a versioned, domain-separated, length-framed binary encoding +and exposes its canonical bytes plus a distinct manifest digest. Parsing caps +the input at 16 MiB before allocation and accepts only the byte-exact canonical +form. Source and observation iterators are bounded before normalization; +source IDs are lowercase stable identifiers, observations must name a retained +source, exact duplicates reject, and per-source admitted counts must match the +frozen inventory. Building or parsing a manifest performs no I/O, source +query, reduction, signing, persistence, or publication. + ## Workflow planning `WorkflowPlan::prepare` validates a canonical proposal, decision, revision, @@ -93,7 +114,7 @@ or proof that referenced private material exists. | --- | --- | --- | | `std` | yes | Standard-library integration for the portable model and errors. | | `serde` | yes | Serialization support for native and versioned trade values. | -| `json` | yes | Executable JSON conformance vectors and deterministic projection digests; enables `serde`. | +| `json` | yes | Executable JSON conformance vectors, deterministic projection digests, and immutable evidence manifests; enables `serde`. | `--no-default-features` keeps the allocation-backed trade model, reducer, and workflow planner available in `no_std` environments. Features are additive; diff --git a/crates/trade/src/evidence.rs b/crates/trade/src/evidence.rs @@ -200,6 +200,20 @@ pub use crate::trade_contract_v1::{ RadrootsTradePrivateTermsEvidenceV1, }; +#[cfg(feature = "json")] +pub use crate::evidence_manifest::{ + RADROOTS_TRADE_EVIDENCE_MANIFEST_CONTRACT_ID, + RADROOTS_TRADE_EVIDENCE_MANIFEST_CONTRACT_VERSION, + RADROOTS_TRADE_EVIDENCE_MANIFEST_MAXIMUM_BYTES, + RADROOTS_TRADE_EVIDENCE_MANIFEST_MAXIMUM_OBSERVATIONS, + RADROOTS_TRADE_EVIDENCE_SOURCE_ID_MAXIMUM_BYTES, RadrootsTradeEvidenceManifestDigestV1, + RadrootsTradeEvidenceManifestError, RadrootsTradeEvidenceManifestObservationV1, + RadrootsTradeEvidenceManifestSourceResultV1, RadrootsTradeEvidenceManifestV1, + RadrootsTradeEvidencePolicyDigestV1, RadrootsTradeEvidenceProvenanceDigestV1, + RadrootsTradeEvidenceSourceIdV1, RadrootsTradeEvidenceSourceResultDigestV1, + RadrootsTradeSignedEventDigestV1, +}; + #[cfg(test)] mod tests { #[cfg(not(feature = "std"))] diff --git a/crates/trade/src/evidence_manifest.rs b/crates/trade/src/evidence_manifest.rs @@ -0,0 +1,1156 @@ +#![forbid(unsafe_code)] + +//! Immutable evidence inventory for one governed trade generation. +//! +//! The manifest commits to caller-supplied canonical record bytes through +//! semantically distinct SHA-256 digest types. It does not retrieve, validate, +//! persist, sign, or publish those records. + +use alloc::{boxed::Box, string::String, vec, vec::Vec}; +use core::{fmt, num::NonZeroU64}; + +use radroots_event::id::{EventId, MutationId, TradeId}; +use sha2::{Digest as _, Sha256}; + +#[cfg(test)] +use crate::evidence::RADROOTS_TRADE_EVIDENCE_MAXIMUM_EVENTS_PER_SOURCE; +use crate::evidence::{ + RADROOTS_TRADE_EVIDENCE_MAXIMUM_SOURCE_COUNT, RadrootsTradeEvidenceCoverageError, + RadrootsTradeEvidenceCoverageV1, RadrootsTradeEvidenceScopePrerequisitesV1, + RadrootsTradeEvidenceSourceCompletionV1, RadrootsTradeEvidenceSourceRequirementV1, + RadrootsTradeEvidenceSourceResultV1, classify_trade_evidence_coverage_v1, +}; + +pub const RADROOTS_TRADE_EVIDENCE_MANIFEST_CONTRACT_ID: &str = + "radroots.trade.evidence-manifest.v1"; +pub const RADROOTS_TRADE_EVIDENCE_MANIFEST_CONTRACT_VERSION: u16 = 1; +pub const RADROOTS_TRADE_EVIDENCE_SOURCE_ID_MAXIMUM_BYTES: usize = 64; +pub const RADROOTS_TRADE_EVIDENCE_MANIFEST_MAXIMUM_OBSERVATIONS: usize = 65_536; +pub const RADROOTS_TRADE_EVIDENCE_MANIFEST_MAXIMUM_BYTES: usize = 16 * 1024 * 1024; + +const MANIFEST_PREFIX: &[u8] = b"radroots.trade.evidence-manifest.v1\0"; +const MANIFEST_DIGEST_DOMAIN: &[u8] = b"radroots:trade-evidence-manifest-digest:v1\0"; + +macro_rules! define_content_digest { + ($name:ident) => { + #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct $name([u8; 32]); + + impl $name { + pub const fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + pub fn sha256(bytes: &[u8]) -> Self { + Self(Sha256::digest(bytes).into()) + } + + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + pub fn to_hex(&self) -> String { + hex::encode(self.0) + } + } + + impl fmt::Debug for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(concat!(stringify!($name), "(<redacted>)")) + } + } + }; +} + +define_content_digest!(RadrootsTradeEvidencePolicyDigestV1); +define_content_digest!(RadrootsTradeEvidenceSourceResultDigestV1); +define_content_digest!(RadrootsTradeSignedEventDigestV1); +define_content_digest!(RadrootsTradeEvidenceProvenanceDigestV1); + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RadrootsTradeEvidenceManifestDigestV1([u8; 32]); + +impl RadrootsTradeEvidenceManifestDigestV1 { + pub const fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + pub fn to_hex(&self) -> String { + hex::encode(self.0) + } +} + +impl fmt::Debug for RadrootsTradeEvidenceManifestDigestV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RadrootsTradeEvidenceManifestDigestV1(<redacted>)") + } +} + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RadrootsTradeEvidenceSourceIdV1(String); + +impl RadrootsTradeEvidenceSourceIdV1 { + pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsTradeEvidenceManifestError> { + let value = value.as_ref(); + if value.is_empty() + || value.len() > RADROOTS_TRADE_EVIDENCE_SOURCE_ID_MAXIMUM_BYTES + || !value.as_bytes()[0].is_ascii_lowercase() + || value.bytes().any(|byte| { + !(byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || byte == b'_' + || byte == b'-') + }) + { + return Err(RadrootsTradeEvidenceManifestError::InvalidSourceId); + } + Ok(Self(String::from(value))) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl fmt::Debug for RadrootsTradeEvidenceSourceIdV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RadrootsTradeEvidenceSourceIdV1(<redacted>)") + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct RadrootsTradeEvidenceManifestSourceResultV1 { + source_id: RadrootsTradeEvidenceSourceIdV1, + result: RadrootsTradeEvidenceSourceResultV1, + result_digest: RadrootsTradeEvidenceSourceResultDigestV1, +} + +impl RadrootsTradeEvidenceManifestSourceResultV1 { + pub const fn new( + source_id: RadrootsTradeEvidenceSourceIdV1, + result: RadrootsTradeEvidenceSourceResultV1, + result_digest: RadrootsTradeEvidenceSourceResultDigestV1, + ) -> Self { + Self { + source_id, + result, + result_digest, + } + } + + pub const fn source_id(&self) -> &RadrootsTradeEvidenceSourceIdV1 { + &self.source_id + } + + pub const fn result(&self) -> RadrootsTradeEvidenceSourceResultV1 { + self.result + } + + pub const fn result_digest(&self) -> RadrootsTradeEvidenceSourceResultDigestV1 { + self.result_digest + } +} + +impl fmt::Debug for RadrootsTradeEvidenceManifestSourceResultV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RadrootsTradeEvidenceManifestSourceResultV1(<redacted>)") + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct RadrootsTradeEvidenceManifestObservationV1 { + source_id: RadrootsTradeEvidenceSourceIdV1, + mutation_id: MutationId, + event_id: EventId, + signed_event_digest: RadrootsTradeSignedEventDigestV1, + provenance_digest: RadrootsTradeEvidenceProvenanceDigestV1, +} + +impl RadrootsTradeEvidenceManifestObservationV1 { + pub const fn new( + source_id: RadrootsTradeEvidenceSourceIdV1, + mutation_id: MutationId, + event_id: EventId, + signed_event_digest: RadrootsTradeSignedEventDigestV1, + provenance_digest: RadrootsTradeEvidenceProvenanceDigestV1, + ) -> Self { + Self { + source_id, + mutation_id, + event_id, + signed_event_digest, + provenance_digest, + } + } + + pub const fn source_id(&self) -> &RadrootsTradeEvidenceSourceIdV1 { + &self.source_id + } + + pub const fn mutation_id(&self) -> &MutationId { + &self.mutation_id + } + + pub const fn event_id(&self) -> &EventId { + &self.event_id + } + + pub const fn signed_event_digest(&self) -> RadrootsTradeSignedEventDigestV1 { + self.signed_event_digest + } + + pub const fn provenance_digest(&self) -> RadrootsTradeEvidenceProvenanceDigestV1 { + self.provenance_digest + } +} + +impl fmt::Debug for RadrootsTradeEvidenceManifestObservationV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RadrootsTradeEvidenceManifestObservationV1(<redacted>)") + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct RadrootsTradeEvidenceManifestV1 { + trade_id: TradeId, + trade_generation: NonZeroU64, + evidence_policy_digest: RadrootsTradeEvidencePolicyDigestV1, + observed_at_unix_s: u64, + scope_prerequisites: RadrootsTradeEvidenceScopePrerequisitesV1, + coverage: RadrootsTradeEvidenceCoverageV1, + sources: Box<[RadrootsTradeEvidenceManifestSourceResultV1]>, + observations: Box<[RadrootsTradeEvidenceManifestObservationV1]>, + canonical_bytes: Box<[u8]>, + digest: RadrootsTradeEvidenceManifestDigestV1, +} + +impl RadrootsTradeEvidenceManifestV1 { + pub fn new<S, O>( + trade_id: TradeId, + trade_generation: NonZeroU64, + evidence_policy_digest: RadrootsTradeEvidencePolicyDigestV1, + observed_at_unix_s: u64, + scope_prerequisites: RadrootsTradeEvidenceScopePrerequisitesV1, + sources: S, + observations: O, + ) -> Result<Self, RadrootsTradeEvidenceManifestError> + where + S: IntoIterator<Item = RadrootsTradeEvidenceManifestSourceResultV1>, + O: IntoIterator<Item = RadrootsTradeEvidenceManifestObservationV1>, + { + let mut sources = collect_bounded( + sources, + RADROOTS_TRADE_EVIDENCE_MAXIMUM_SOURCE_COUNT, + RadrootsTradeEvidenceManifestError::SourceCountOutOfRange, + )?; + if sources.is_empty() { + return Err(RadrootsTradeEvidenceManifestError::SourceCountOutOfRange); + } + sources.sort_by(|left, right| left.source_id.cmp(&right.source_id)); + if sources + .windows(2) + .any(|pair| pair[0].source_id == pair[1].source_id) + { + return Err(RadrootsTradeEvidenceManifestError::DuplicateSource); + } + + let coverage = classify_trade_evidence_coverage_v1( + sources.iter().map(|source| source.result), + scope_prerequisites, + ) + .map_err(map_coverage_error)?; + + let mut observations = collect_bounded( + observations, + RADROOTS_TRADE_EVIDENCE_MANIFEST_MAXIMUM_OBSERVATIONS, + RadrootsTradeEvidenceManifestError::ObservationCountOutOfRange, + )?; + observations.sort_by(|left, right| { + left.event_id + .as_bytes() + .cmp(right.event_id.as_bytes()) + .then_with(|| left.source_id.cmp(&right.source_id)) + .then_with(|| { + left.mutation_id + .as_bytes() + .cmp(right.mutation_id.as_bytes()) + }) + .then_with(|| left.signed_event_digest.cmp(&right.signed_event_digest)) + .then_with(|| left.provenance_digest.cmp(&right.provenance_digest)) + }); + for pair in observations.windows(2) { + if pair[0].event_id == pair[1].event_id + && (pair[0].mutation_id != pair[1].mutation_id + || pair[0].signed_event_digest != pair[1].signed_event_digest) + { + return Err(RadrootsTradeEvidenceManifestError::ConflictingEvent); + } + if pair[0].event_id == pair[1].event_id && pair[0].source_id == pair[1].source_id { + return Err(RadrootsTradeEvidenceManifestError::DuplicateObservation); + } + } + + let mut actual_counts = vec![0_u32; sources.len()]; + for observation in &observations { + let source_index = sources + .binary_search_by(|source| source.source_id.cmp(&observation.source_id)) + .map_err(|_| RadrootsTradeEvidenceManifestError::UnknownObservationSource)?; + actual_counts[source_index] = actual_counts[source_index] + .checked_add(1) + .ok_or(RadrootsTradeEvidenceManifestError::ObservationCountOutOfRange)?; + } + if sources + .iter() + .zip(actual_counts) + .any(|(source, actual)| source.result.admitted_event_count() != actual) + { + return Err(RadrootsTradeEvidenceManifestError::SourceEventCountMismatch); + } + + let canonical_bytes = encode_manifest( + &trade_id, + trade_generation, + evidence_policy_digest, + observed_at_unix_s, + scope_prerequisites, + &sources, + &observations, + )?; + let digest = manifest_digest(canonical_bytes.as_slice())?; + + Ok(Self { + trade_id, + trade_generation, + evidence_policy_digest, + observed_at_unix_s, + scope_prerequisites, + coverage, + sources: sources.into_boxed_slice(), + observations: observations.into_boxed_slice(), + canonical_bytes: canonical_bytes.into_boxed_slice(), + digest, + }) + } + + pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, RadrootsTradeEvidenceManifestError> { + if bytes.is_empty() { + return Err(RadrootsTradeEvidenceManifestError::Malformed); + } + if bytes.len() > RADROOTS_TRADE_EVIDENCE_MANIFEST_MAXIMUM_BYTES { + return Err(RadrootsTradeEvidenceManifestError::ManifestTooLarge); + } + + let mut reader = ManifestReader::new(bytes); + if reader.take(MANIFEST_PREFIX.len())? != MANIFEST_PREFIX { + return Err(RadrootsTradeEvidenceManifestError::Malformed); + } + if reader.u16()? != RADROOTS_TRADE_EVIDENCE_MANIFEST_CONTRACT_VERSION { + return Err(RadrootsTradeEvidenceManifestError::UnsupportedVersion); + } + + let trade_id = TradeId::from_bytes(reader.array()?); + let trade_generation = + NonZeroU64::new(reader.u64()?).ok_or(RadrootsTradeEvidenceManifestError::Malformed)?; + let evidence_policy_digest = + RadrootsTradeEvidencePolicyDigestV1::from_bytes(reader.array()?); + let observed_at_unix_s = reader.u64()?; + let scope_prerequisites = decode_scope_prerequisites(reader.u8()?)?; + + let source_count = usize::from(reader.u8()?); + if source_count == 0 || source_count > RADROOTS_TRADE_EVIDENCE_MAXIMUM_SOURCE_COUNT { + return Err(RadrootsTradeEvidenceManifestError::SourceCountOutOfRange); + } + let mut sources = Vec::with_capacity(source_count); + for _ in 0..source_count { + let source_id = decode_source_id(&mut reader)?; + let requirement = decode_requirement(reader.u8()?)?; + let completion = decode_completion(reader.u8()?)?; + let admitted_event_count = reader.u32()?; + let result = RadrootsTradeEvidenceSourceResultV1::new( + requirement, + completion, + admitted_event_count, + ) + .map_err(map_coverage_error)?; + let result_digest = + RadrootsTradeEvidenceSourceResultDigestV1::from_bytes(reader.array()?); + sources.push(RadrootsTradeEvidenceManifestSourceResultV1::new( + source_id, + result, + result_digest, + )); + } + + let observation_count = usize::try_from(reader.u32()?) + .map_err(|_| RadrootsTradeEvidenceManifestError::ObservationCountOutOfRange)?; + if observation_count > RADROOTS_TRADE_EVIDENCE_MANIFEST_MAXIMUM_OBSERVATIONS { + return Err(RadrootsTradeEvidenceManifestError::ObservationCountOutOfRange); + } + let mut observations = Vec::with_capacity(observation_count); + for _ in 0..observation_count { + let source_index = usize::from(reader.u8()?); + let source = sources + .get(source_index) + .ok_or(RadrootsTradeEvidenceManifestError::UnknownObservationSource)?; + observations.push(RadrootsTradeEvidenceManifestObservationV1::new( + source.source_id.clone(), + MutationId::from_bytes(reader.array()?), + EventId::from_bytes(reader.array()?), + RadrootsTradeSignedEventDigestV1::from_bytes(reader.array()?), + RadrootsTradeEvidenceProvenanceDigestV1::from_bytes(reader.array()?), + )); + } + reader.finish()?; + + let manifest = Self::new( + trade_id, + trade_generation, + evidence_policy_digest, + observed_at_unix_s, + scope_prerequisites, + sources, + observations, + )?; + if manifest.canonical_bytes.as_ref() != bytes { + return Err(RadrootsTradeEvidenceManifestError::NonCanonical); + } + Ok(manifest) + } + + pub const fn contract_id(&self) -> &'static str { + RADROOTS_TRADE_EVIDENCE_MANIFEST_CONTRACT_ID + } + + pub const fn contract_version(&self) -> u16 { + RADROOTS_TRADE_EVIDENCE_MANIFEST_CONTRACT_VERSION + } + + pub const fn trade_id(&self) -> &TradeId { + &self.trade_id + } + + pub const fn trade_generation(&self) -> NonZeroU64 { + self.trade_generation + } + + pub const fn evidence_policy_digest(&self) -> RadrootsTradeEvidencePolicyDigestV1 { + self.evidence_policy_digest + } + + pub const fn observed_at_unix_s(&self) -> u64 { + self.observed_at_unix_s + } + + pub const fn scope_prerequisites(&self) -> RadrootsTradeEvidenceScopePrerequisitesV1 { + self.scope_prerequisites + } + + pub const fn coverage(&self) -> RadrootsTradeEvidenceCoverageV1 { + self.coverage + } + + pub fn sources(&self) -> &[RadrootsTradeEvidenceManifestSourceResultV1] { + &self.sources + } + + pub fn observations(&self) -> &[RadrootsTradeEvidenceManifestObservationV1] { + &self.observations + } + + pub fn canonical_bytes(&self) -> &[u8] { + &self.canonical_bytes + } + + pub const fn digest(&self) -> RadrootsTradeEvidenceManifestDigestV1 { + self.digest + } +} + +impl fmt::Debug for RadrootsTradeEvidenceManifestV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RadrootsTradeEvidenceManifestV1") + .field("source_count", &self.sources.len()) + .field("observation_count", &self.observations.len()) + .finish_non_exhaustive() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsTradeEvidenceManifestError { + InvalidSourceId, + SourceCountOutOfRange, + DuplicateSource, + NoRequiredSource, + ObservationCountOutOfRange, + UnknownObservationSource, + DuplicateObservation, + ConflictingEvent, + SourceEventCountMismatch, + ManifestTooLarge, + Malformed, + UnsupportedVersion, + NonCanonical, +} + +impl fmt::Display for RadrootsTradeEvidenceManifestError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidSourceId => "evidence manifest source identity is invalid", + Self::SourceCountOutOfRange => "evidence manifest source count is out of range", + Self::DuplicateSource => "evidence manifest contains a duplicate source", + Self::NoRequiredSource => "evidence manifest has no required source", + Self::ObservationCountOutOfRange => { + "evidence manifest observation count is out of range" + } + Self::UnknownObservationSource => { + "evidence manifest observation references an unknown source" + } + Self::DuplicateObservation => "evidence manifest contains a duplicate observation", + Self::ConflictingEvent => { + "evidence manifest contains conflicting records for one event" + } + Self::SourceEventCountMismatch => { + "evidence manifest source event count does not match its observations" + } + Self::ManifestTooLarge => "evidence manifest exceeds its canonical byte limit", + Self::Malformed => "evidence manifest encoding is malformed", + Self::UnsupportedVersion => "evidence manifest version is unsupported", + Self::NonCanonical => "evidence manifest encoding is not canonical", + }) + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsTradeEvidenceManifestError {} + +fn collect_bounded<T>( + values: impl IntoIterator<Item = T>, + maximum: usize, + error: RadrootsTradeEvidenceManifestError, +) -> Result<Vec<T>, RadrootsTradeEvidenceManifestError> { + let mut collected = Vec::new(); + for value in values { + if collected.len() == maximum { + return Err(error); + } + collected.push(value); + } + Ok(collected) +} + +fn map_coverage_error( + error: RadrootsTradeEvidenceCoverageError, +) -> RadrootsTradeEvidenceManifestError { + match error { + RadrootsTradeEvidenceCoverageError::SourceCountOutOfRange => { + RadrootsTradeEvidenceManifestError::SourceCountOutOfRange + } + RadrootsTradeEvidenceCoverageError::NoRequiredSource => { + RadrootsTradeEvidenceManifestError::NoRequiredSource + } + RadrootsTradeEvidenceCoverageError::AdmittedEventCountOutOfRange => { + RadrootsTradeEvidenceManifestError::SourceEventCountMismatch + } + } +} + +fn encode_manifest( + trade_id: &TradeId, + trade_generation: NonZeroU64, + evidence_policy_digest: RadrootsTradeEvidencePolicyDigestV1, + observed_at_unix_s: u64, + scope_prerequisites: RadrootsTradeEvidenceScopePrerequisitesV1, + sources: &[RadrootsTradeEvidenceManifestSourceResultV1], + observations: &[RadrootsTradeEvidenceManifestObservationV1], +) -> Result<Vec<u8>, RadrootsTradeEvidenceManifestError> { + let mut bytes = Vec::new(); + bytes.extend_from_slice(MANIFEST_PREFIX); + bytes.extend_from_slice(&RADROOTS_TRADE_EVIDENCE_MANIFEST_CONTRACT_VERSION.to_be_bytes()); + bytes.extend_from_slice(trade_id.as_bytes()); + bytes.extend_from_slice(&trade_generation.get().to_be_bytes()); + bytes.extend_from_slice(evidence_policy_digest.as_bytes()); + bytes.extend_from_slice(&observed_at_unix_s.to_be_bytes()); + bytes.push(encode_scope_prerequisites(scope_prerequisites)); + bytes.push( + u8::try_from(sources.len()) + .map_err(|_| RadrootsTradeEvidenceManifestError::SourceCountOutOfRange)?, + ); + for source in sources { + bytes.push( + u8::try_from(source.source_id.as_str().len()) + .map_err(|_| RadrootsTradeEvidenceManifestError::InvalidSourceId)?, + ); + bytes.extend_from_slice(source.source_id.as_str().as_bytes()); + bytes.push(encode_requirement(source.result.requirement())); + bytes.push(encode_completion(source.result.completion())); + bytes.extend_from_slice(&source.result.admitted_event_count().to_be_bytes()); + bytes.extend_from_slice(source.result_digest.as_bytes()); + } + bytes.extend_from_slice( + &u32::try_from(observations.len()) + .map_err(|_| RadrootsTradeEvidenceManifestError::ObservationCountOutOfRange)? + .to_be_bytes(), + ); + for observation in observations { + let source_index = sources + .binary_search_by(|source| source.source_id.cmp(&observation.source_id)) + .map_err(|_| RadrootsTradeEvidenceManifestError::UnknownObservationSource)?; + bytes.push( + u8::try_from(source_index) + .map_err(|_| RadrootsTradeEvidenceManifestError::SourceCountOutOfRange)?, + ); + bytes.extend_from_slice(observation.mutation_id.as_bytes()); + bytes.extend_from_slice(observation.event_id.as_bytes()); + bytes.extend_from_slice(observation.signed_event_digest.as_bytes()); + bytes.extend_from_slice(observation.provenance_digest.as_bytes()); + } + if bytes.len() > RADROOTS_TRADE_EVIDENCE_MANIFEST_MAXIMUM_BYTES { + return Err(RadrootsTradeEvidenceManifestError::ManifestTooLarge); + } + Ok(bytes) +} + +fn manifest_digest( + bytes: &[u8], +) -> Result<RadrootsTradeEvidenceManifestDigestV1, RadrootsTradeEvidenceManifestError> { + let length = u64::try_from(bytes.len()) + .map_err(|_| RadrootsTradeEvidenceManifestError::ManifestTooLarge)?; + let mut hasher = Sha256::new(); + hasher.update(MANIFEST_DIGEST_DOMAIN); + hasher.update(length.to_be_bytes()); + hasher.update(bytes); + Ok(RadrootsTradeEvidenceManifestDigestV1( + hasher.finalize().into(), + )) +} + +const fn encode_scope_prerequisites(value: RadrootsTradeEvidenceScopePrerequisitesV1) -> u8 { + match value { + RadrootsTradeEvidenceScopePrerequisitesV1::Satisfied => 0, + RadrootsTradeEvidenceScopePrerequisitesV1::Unsatisfied => 1, + } +} + +fn decode_scope_prerequisites( + value: u8, +) -> Result<RadrootsTradeEvidenceScopePrerequisitesV1, RadrootsTradeEvidenceManifestError> { + match value { + 0 => Ok(RadrootsTradeEvidenceScopePrerequisitesV1::Satisfied), + 1 => Ok(RadrootsTradeEvidenceScopePrerequisitesV1::Unsatisfied), + _ => Err(RadrootsTradeEvidenceManifestError::Malformed), + } +} + +const fn encode_requirement(value: RadrootsTradeEvidenceSourceRequirementV1) -> u8 { + match value { + RadrootsTradeEvidenceSourceRequirementV1::Required => 0, + RadrootsTradeEvidenceSourceRequirementV1::Optional => 1, + } +} + +fn decode_requirement( + value: u8, +) -> Result<RadrootsTradeEvidenceSourceRequirementV1, RadrootsTradeEvidenceManifestError> { + match value { + 0 => Ok(RadrootsTradeEvidenceSourceRequirementV1::Required), + 1 => Ok(RadrootsTradeEvidenceSourceRequirementV1::Optional), + _ => Err(RadrootsTradeEvidenceManifestError::Malformed), + } +} + +const fn encode_completion(value: RadrootsTradeEvidenceSourceCompletionV1) -> u8 { + match value { + RadrootsTradeEvidenceSourceCompletionV1::Complete => 0, + RadrootsTradeEvidenceSourceCompletionV1::Incomplete => 1, + RadrootsTradeEvidenceSourceCompletionV1::Unsupported => 2, + } +} + +fn decode_completion( + value: u8, +) -> Result<RadrootsTradeEvidenceSourceCompletionV1, RadrootsTradeEvidenceManifestError> { + match value { + 0 => Ok(RadrootsTradeEvidenceSourceCompletionV1::Complete), + 1 => Ok(RadrootsTradeEvidenceSourceCompletionV1::Incomplete), + 2 => Ok(RadrootsTradeEvidenceSourceCompletionV1::Unsupported), + _ => Err(RadrootsTradeEvidenceManifestError::Malformed), + } +} + +fn decode_source_id( + reader: &mut ManifestReader<'_>, +) -> Result<RadrootsTradeEvidenceSourceIdV1, RadrootsTradeEvidenceManifestError> { + let length = usize::from(reader.u8()?); + if length == 0 || length > RADROOTS_TRADE_EVIDENCE_SOURCE_ID_MAXIMUM_BYTES { + return Err(RadrootsTradeEvidenceManifestError::InvalidSourceId); + } + let value = core::str::from_utf8(reader.take(length)?) + .map_err(|_| RadrootsTradeEvidenceManifestError::InvalidSourceId)?; + RadrootsTradeEvidenceSourceIdV1::parse(value) +} + +struct ManifestReader<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> ManifestReader<'a> { + const fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + + fn take(&mut self, length: usize) -> Result<&'a [u8], RadrootsTradeEvidenceManifestError> { + let end = self + .offset + .checked_add(length) + .ok_or(RadrootsTradeEvidenceManifestError::Malformed)?; + let value = self + .bytes + .get(self.offset..end) + .ok_or(RadrootsTradeEvidenceManifestError::Malformed)?; + self.offset = end; + Ok(value) + } + + fn array<const N: usize>(&mut self) -> Result<[u8; N], RadrootsTradeEvidenceManifestError> { + self.take(N)? + .try_into() + .map_err(|_| RadrootsTradeEvidenceManifestError::Malformed) + } + + fn u8(&mut self) -> Result<u8, RadrootsTradeEvidenceManifestError> { + Ok(self.array::<1>()?[0]) + } + + fn u16(&mut self) -> Result<u16, RadrootsTradeEvidenceManifestError> { + Ok(u16::from_be_bytes(self.array()?)) + } + + fn u32(&mut self) -> Result<u32, RadrootsTradeEvidenceManifestError> { + Ok(u32::from_be_bytes(self.array()?)) + } + + fn u64(&mut self) -> Result<u64, RadrootsTradeEvidenceManifestError> { + Ok(u64::from_be_bytes(self.array()?)) + } + + fn finish(self) -> Result<(), RadrootsTradeEvidenceManifestError> { + if self.offset == self.bytes.len() { + Ok(()) + } else { + Err(RadrootsTradeEvidenceManifestError::NonCanonical) + } + } +} + +#[cfg(test)] +mod tests { + use alloc::{format, string::ToString, vec}; + + use super::*; + + fn source_id(value: &str) -> RadrootsTradeEvidenceSourceIdV1 { + RadrootsTradeEvidenceSourceIdV1::parse(value).unwrap() + } + + fn policy_digest(byte: u8) -> RadrootsTradeEvidencePolicyDigestV1 { + RadrootsTradeEvidencePolicyDigestV1::from_bytes([byte; 32]) + } + + fn source_result_digest(byte: u8) -> RadrootsTradeEvidenceSourceResultDigestV1 { + RadrootsTradeEvidenceSourceResultDigestV1::from_bytes([byte; 32]) + } + + fn signed_event_digest(byte: u8) -> RadrootsTradeSignedEventDigestV1 { + RadrootsTradeSignedEventDigestV1::from_bytes([byte; 32]) + } + + fn provenance_digest(byte: u8) -> RadrootsTradeEvidenceProvenanceDigestV1 { + RadrootsTradeEvidenceProvenanceDigestV1::from_bytes([byte; 32]) + } + + fn event_id(byte: u8) -> EventId { + EventId::from_bytes([byte; 32]) + } + + fn mutation_id(byte: u8) -> MutationId { + MutationId::from_bytes([byte; 32]) + } + + fn ordinal_ids(ordinal: u32) -> (MutationId, EventId) { + let mut mutation = [0_u8; 32]; + mutation[28..].copy_from_slice(&ordinal.to_be_bytes()); + let mut event = mutation; + event[0] = 1; + (MutationId::from_bytes(mutation), EventId::from_bytes(event)) + } + + fn source( + id: &str, + requirement: RadrootsTradeEvidenceSourceRequirementV1, + completion: RadrootsTradeEvidenceSourceCompletionV1, + count: u32, + digest_byte: u8, + ) -> RadrootsTradeEvidenceManifestSourceResultV1 { + RadrootsTradeEvidenceManifestSourceResultV1::new( + source_id(id), + RadrootsTradeEvidenceSourceResultV1::new(requirement, completion, count).unwrap(), + source_result_digest(digest_byte), + ) + } + + fn observation( + source: &str, + mutation: u8, + event: u8, + signed: u8, + provenance: u8, + ) -> RadrootsTradeEvidenceManifestObservationV1 { + RadrootsTradeEvidenceManifestObservationV1::new( + source_id(source), + mutation_id(mutation), + event_id(event), + signed_event_digest(signed), + provenance_digest(provenance), + ) + } + + fn manifest() -> RadrootsTradeEvidenceManifestV1 { + RadrootsTradeEvidenceManifestV1::new( + TradeId::from_bytes([0x11; 16]), + NonZeroU64::new(7).unwrap(), + policy_digest(0x22), + 1_700_000_000, + RadrootsTradeEvidenceScopePrerequisitesV1::Satisfied, + [ + source( + "source-b", + RadrootsTradeEvidenceSourceRequirementV1::Optional, + RadrootsTradeEvidenceSourceCompletionV1::Incomplete, + 1, + 0x42, + ), + source( + "source-a", + RadrootsTradeEvidenceSourceRequirementV1::Required, + RadrootsTradeEvidenceSourceCompletionV1::Complete, + 1, + 0x41, + ), + ], + [ + observation("source-b", 0x32, 0x52, 0x62, 0x72), + observation("source-a", 0x31, 0x51, 0x61, 0x71), + ], + ) + .unwrap() + } + + #[test] + fn exact_manifest_vector_and_digest_are_frozen() { + let manifest = manifest(); + assert_eq!( + hex::encode(manifest.canonical_bytes()), + "726164726f6f74732e74726164652e65766964656e63652d6d616e69666573742e76310000011111111111111111111111111111111100000000000000072222222222222222222222222222222222222222222222222222222222222222000000006553f100000208736f757263652d61000000000001414141414141414141414141414141414141414141414141414141414141414108736f757263652d62010100000001424242424242424242424242424242424242424242424242424242424242424200000002003131313131313131313131313131313131313131313131313131313131313131515151515151515151515151515151515151515151515151515151515151515161616161616161616161616161616161616161616161616161616161616161617171717171717171717171717171717171717171717171717171717171717171013232323232323232323232323232323232323232323232323232323232323232525252525252525252525252525252525252525252525252525252525252525262626262626262626262626262626262626262626262626262626262626262627272727272727272727272727272727272727272727272727272727272727272" + ); + assert_eq!( + manifest.digest().to_hex(), + "ec5e2cdc85107afe43c90925b695fe9cd8605970886f1d2a2d431b602adeb3a9" + ); + assert_eq!( + manifest.contract_id(), + RADROOTS_TRADE_EVIDENCE_MANIFEST_CONTRACT_ID + ); + assert_eq!(manifest.contract_version(), 1); + assert_eq!( + manifest.coverage(), + RadrootsTradeEvidenceCoverageV1::ScopeSatisfied + ); + } + + #[test] + fn construction_is_permutation_invariant_and_parser_is_strict() { + let first = manifest(); + let second = RadrootsTradeEvidenceManifestV1::new( + *first.trade_id(), + first.trade_generation(), + first.evidence_policy_digest(), + first.observed_at_unix_s(), + first.scope_prerequisites(), + first.sources().iter().cloned().rev(), + first.observations().iter().cloned().rev(), + ) + .unwrap(); + assert_eq!(first, second); + assert_eq!( + RadrootsTradeEvidenceManifestV1::from_canonical_bytes(first.canonical_bytes()), + Ok(first.clone()) + ); + + let mut trailing = first.canonical_bytes().to_vec(); + trailing.push(0); + assert_eq!( + RadrootsTradeEvidenceManifestV1::from_canonical_bytes(&trailing), + Err(RadrootsTradeEvidenceManifestError::NonCanonical) + ); + let mut version = first.canonical_bytes().to_vec(); + version[MANIFEST_PREFIX.len() + 1] = 2; + assert_eq!( + RadrootsTradeEvidenceManifestV1::from_canonical_bytes(&version), + Err(RadrootsTradeEvidenceManifestError::UnsupportedVersion) + ); + for length in 0..first.canonical_bytes().len() { + assert!( + RadrootsTradeEvidenceManifestV1::from_canonical_bytes( + &first.canonical_bytes()[..length] + ) + .is_err() + ); + } + } + + #[test] + fn manifest_rejects_unknown_duplicate_and_mismatched_inventory() { + let required = source( + "required", + RadrootsTradeEvidenceSourceRequirementV1::Required, + RadrootsTradeEvidenceSourceCompletionV1::Complete, + 1, + 1, + ); + let base = |sources: Vec<_>, observations: Vec<_>| { + RadrootsTradeEvidenceManifestV1::new( + TradeId::from_bytes([1; 16]), + NonZeroU64::new(1).unwrap(), + policy_digest(2), + 3, + RadrootsTradeEvidenceScopePrerequisitesV1::Satisfied, + sources, + observations, + ) + }; + assert_eq!( + base( + vec![required.clone()], + vec![observation("other", 1, 2, 3, 4)] + ), + Err(RadrootsTradeEvidenceManifestError::UnknownObservationSource) + ); + assert_eq!( + base(vec![required.clone(), required.clone()], vec![]), + Err(RadrootsTradeEvidenceManifestError::DuplicateSource) + ); + let observed = observation("required", 1, 2, 3, 4); + assert_eq!( + base(vec![required.clone()], vec![observed.clone(), observed]), + Err(RadrootsTradeEvidenceManifestError::DuplicateObservation) + ); + let conflicting = RadrootsTradeEvidenceManifestObservationV1::new( + source_id("required"), + mutation_id(1), + event_id(2), + signed_event_digest(9), + provenance_digest(10), + ); + assert_eq!( + base( + vec![required.clone()], + vec![observation("required", 1, 2, 3, 4), conflicting] + ), + Err(RadrootsTradeEvidenceManifestError::ConflictingEvent) + ); + let optional = source( + "optional", + RadrootsTradeEvidenceSourceRequirementV1::Optional, + RadrootsTradeEvidenceSourceCompletionV1::Complete, + 1, + 2, + ); + assert_eq!( + base( + vec![required.clone(), optional], + vec![ + observation("required", 1, 2, 3, 4), + observation("optional", 9, 2, 3, 5), + ] + ), + Err(RadrootsTradeEvidenceManifestError::ConflictingEvent) + ); + assert_eq!( + base(vec![required], vec![]), + Err(RadrootsTradeEvidenceManifestError::SourceEventCountMismatch) + ); + } + + #[test] + fn source_identity_and_inventory_bounds_are_exact() { + for invalid in ["", "Upper", "-prefix", "has space", "a/b"] { + assert_eq!( + RadrootsTradeEvidenceSourceIdV1::parse(invalid), + Err(RadrootsTradeEvidenceManifestError::InvalidSourceId) + ); + } + let maximum = format!("a{}", "1".repeat(63)); + assert_eq!(source_id(&maximum).as_str(), maximum); + assert_eq!( + RadrootsTradeEvidenceSourceIdV1::parse(format!("a{}", "1".repeat(64))), + Err(RadrootsTradeEvidenceManifestError::InvalidSourceId) + ); + + let required = source( + "required", + RadrootsTradeEvidenceSourceRequirementV1::Required, + RadrootsTradeEvidenceSourceCompletionV1::Incomplete, + 0, + 1, + ); + fn create( + sources: impl IntoIterator<Item = RadrootsTradeEvidenceManifestSourceResultV1>, + ) -> Result<RadrootsTradeEvidenceManifestV1, RadrootsTradeEvidenceManifestError> { + RadrootsTradeEvidenceManifestV1::new( + TradeId::from_bytes([1; 16]), + NonZeroU64::new(1).unwrap(), + policy_digest(2), + 3, + RadrootsTradeEvidenceScopePrerequisitesV1::Unsatisfied, + sources, + core::iter::empty(), + ) + } + assert_eq!( + create(core::iter::empty()), + Err(RadrootsTradeEvidenceManifestError::SourceCountOutOfRange) + ); + assert_eq!( + create(core::iter::repeat(required)), + Err(RadrootsTradeEvidenceManifestError::SourceCountOutOfRange) + ); + assert_eq!( + create([source( + "optional", + RadrootsTradeEvidenceSourceRequirementV1::Optional, + RadrootsTradeEvidenceSourceCompletionV1::Incomplete, + 0, + 1, + )]), + Err(RadrootsTradeEvidenceManifestError::NoRequiredSource) + ); + } + + #[test] + fn observation_and_canonical_input_bounds_are_exact() { + let sources = (0..RADROOTS_TRADE_EVIDENCE_MAXIMUM_SOURCE_COUNT).map(|index| { + source( + &format!("source-{index:02}"), + if index == 0 { + RadrootsTradeEvidenceSourceRequirementV1::Required + } else { + RadrootsTradeEvidenceSourceRequirementV1::Optional + }, + RadrootsTradeEvidenceSourceCompletionV1::Complete, + RADROOTS_TRADE_EVIDENCE_MAXIMUM_EVENTS_PER_SOURCE, + u8::try_from(index).unwrap(), + ) + }); + let observations = + (0..RADROOTS_TRADE_EVIDENCE_MANIFEST_MAXIMUM_OBSERVATIONS).map(|index| { + let source_index = index + / usize::try_from(RADROOTS_TRADE_EVIDENCE_MAXIMUM_EVENTS_PER_SOURCE).unwrap(); + let (mutation_id, event_id) = ordinal_ids(u32::try_from(index).unwrap()); + RadrootsTradeEvidenceManifestObservationV1::new( + source_id(&format!("source-{source_index:02}")), + mutation_id, + event_id, + signed_event_digest(1), + provenance_digest(2), + ) + }); + let maximum = RadrootsTradeEvidenceManifestV1::new( + TradeId::from_bytes([1; 16]), + NonZeroU64::new(1).unwrap(), + policy_digest(2), + 3, + RadrootsTradeEvidenceScopePrerequisitesV1::Satisfied, + sources, + observations, + ) + .unwrap(); + assert_eq!( + maximum.observations().len(), + RADROOTS_TRADE_EVIDENCE_MANIFEST_MAXIMUM_OBSERVATIONS + ); + assert_eq!( + RadrootsTradeEvidenceManifestV1::from_canonical_bytes(maximum.canonical_bytes()), + Ok(maximum) + ); + + let required = source( + "required", + RadrootsTradeEvidenceSourceRequirementV1::Required, + RadrootsTradeEvidenceSourceCompletionV1::Incomplete, + 0, + 1, + ); + assert_eq!( + RadrootsTradeEvidenceManifestV1::new( + TradeId::from_bytes([1; 16]), + NonZeroU64::new(1).unwrap(), + policy_digest(2), + 3, + RadrootsTradeEvidenceScopePrerequisitesV1::Unsatisfied, + [required], + core::iter::repeat(observation("required", 1, 2, 3, 4)), + ), + Err(RadrootsTradeEvidenceManifestError::ObservationCountOutOfRange) + ); + assert_eq!( + RadrootsTradeEvidenceManifestV1::from_canonical_bytes(&vec![ + 0; + RADROOTS_TRADE_EVIDENCE_MANIFEST_MAXIMUM_BYTES + + 1 + ]), + Err(RadrootsTradeEvidenceManifestError::ManifestTooLarge) + ); + } + + #[test] + fn debug_and_errors_do_not_expose_evidence_identity() { + let manifest = manifest(); + let debug = format!("{manifest:?}"); + for secret in ["source-a", "source-b", &manifest.trade_id().to_string()] { + assert!(!debug.contains(secret)); + } + assert_eq!( + format!("{:?}", manifest.sources()[0]), + "RadrootsTradeEvidenceManifestSourceResultV1(<redacted>)" + ); + assert_eq!( + format!("{:?}", manifest.observations()[0]), + "RadrootsTradeEvidenceManifestObservationV1(<redacted>)" + ); + for error in [ + RadrootsTradeEvidenceManifestError::InvalidSourceId, + RadrootsTradeEvidenceManifestError::SourceCountOutOfRange, + RadrootsTradeEvidenceManifestError::DuplicateSource, + RadrootsTradeEvidenceManifestError::NoRequiredSource, + RadrootsTradeEvidenceManifestError::ObservationCountOutOfRange, + RadrootsTradeEvidenceManifestError::UnknownObservationSource, + RadrootsTradeEvidenceManifestError::DuplicateObservation, + RadrootsTradeEvidenceManifestError::ConflictingEvent, + RadrootsTradeEvidenceManifestError::SourceEventCountMismatch, + RadrootsTradeEvidenceManifestError::ManifestTooLarge, + RadrootsTradeEvidenceManifestError::Malformed, + RadrootsTradeEvidenceManifestError::UnsupportedVersion, + RadrootsTradeEvidenceManifestError::NonCanonical, + ] { + assert!(!error.to_string().is_empty()); + #[cfg(feature = "std")] + assert!(std::error::Error::source(&error).is_none()); + } + } +} diff --git a/crates/trade/src/lib.rs b/crates/trade/src/lib.rs @@ -3,12 +3,14 @@ #![forbid(unsafe_code)] #![deny(rustdoc::broken_intra_doc_links)] #![doc = include_str!("../README.md")] -#[cfg(not(feature = "std"))] extern crate alloc; #[cfg(test)] mod test_fixtures; +#[cfg(feature = "json")] +mod evidence_manifest; + pub mod evidence; pub mod model; pub mod reducer; diff --git a/crates/trade/tests/package_boundary.rs b/crates/trade/tests/package_boundary.rs @@ -5,6 +5,7 @@ use radroots_trade::{evidence as _, model as _, reducer as _, validation as _, w const MANIFEST: &str = include_str!("../Cargo.toml"); const EVIDENCE: &str = include_str!("../src/evidence.rs"); +const EVIDENCE_MANIFEST: &str = include_str!("../src/evidence_manifest.rs"); const MODEL: &str = include_str!("../src/model.rs"); const OPERATIONS: &str = include_str!("../../../contracts/operations.toml"); const ROOT: &str = include_str!("../src/lib.rs"); @@ -291,6 +292,79 @@ fn approved_evidence_coverage_and_outcome_are_portable_and_bounded() { } #[test] +fn immutable_evidence_manifest_is_sealed_bounded_and_side_effect_free() { + use radroots_trade::evidence::{ + RADROOTS_TRADE_EVIDENCE_MANIFEST_CONTRACT_ID, + RADROOTS_TRADE_EVIDENCE_MANIFEST_CONTRACT_VERSION, + RADROOTS_TRADE_EVIDENCE_MANIFEST_MAXIMUM_BYTES, + RADROOTS_TRADE_EVIDENCE_MANIFEST_MAXIMUM_OBSERVATIONS, + RADROOTS_TRADE_EVIDENCE_SOURCE_ID_MAXIMUM_BYTES, RadrootsTradeEvidenceManifestDigestV1, + RadrootsTradeEvidenceManifestError, RadrootsTradeEvidenceManifestObservationV1, + RadrootsTradeEvidenceManifestSourceResultV1, RadrootsTradeEvidenceManifestV1, + RadrootsTradeEvidencePolicyDigestV1, RadrootsTradeEvidenceProvenanceDigestV1, + RadrootsTradeEvidenceSourceIdV1, RadrootsTradeEvidenceSourceResultDigestV1, + RadrootsTradeSignedEventDigestV1, + }; + + fn assert_portable<T: Clone + core::fmt::Debug + Eq + Send + Sync>() {} + assert_portable::<RadrootsTradeEvidenceManifestDigestV1>(); + assert_portable::<RadrootsTradeEvidenceManifestError>(); + assert_portable::<RadrootsTradeEvidenceManifestObservationV1>(); + assert_portable::<RadrootsTradeEvidenceManifestSourceResultV1>(); + assert_portable::<RadrootsTradeEvidenceManifestV1>(); + assert_portable::<RadrootsTradeEvidencePolicyDigestV1>(); + assert_portable::<RadrootsTradeEvidenceProvenanceDigestV1>(); + assert_portable::<RadrootsTradeEvidenceSourceIdV1>(); + assert_portable::<RadrootsTradeEvidenceSourceResultDigestV1>(); + assert_portable::<RadrootsTradeSignedEventDigestV1>(); + + assert_eq!( + RADROOTS_TRADE_EVIDENCE_MANIFEST_CONTRACT_ID, + "radroots.trade.evidence-manifest.v1" + ); + assert_eq!(RADROOTS_TRADE_EVIDENCE_MANIFEST_CONTRACT_VERSION, 1); + assert_eq!(RADROOTS_TRADE_EVIDENCE_SOURCE_ID_MAXIMUM_BYTES, 64); + assert_eq!( + RADROOTS_TRADE_EVIDENCE_MANIFEST_MAXIMUM_OBSERVATIONS, + 65_536 + ); + assert_eq!( + RADROOTS_TRADE_EVIDENCE_MANIFEST_MAXIMUM_BYTES, + 16 * 1024 * 1024 + ); + + for forbidden in [ + "std::fs", + "std::net", + "sqlx", + "tokio", + "reqwest", + "radroots_transport", + "spawn", + "SystemTime", + ] { + assert!( + !EVIDENCE_MANIFEST.contains(forbidden), + "manifest acquired forbidden side effect or upward dependency: {forbidden}" + ); + } + for field in [ + "pub trade_id:", + "pub sources:", + "pub observations:", + "pub canonical_bytes:", + "pub digest:", + ] { + assert!( + !EVIDENCE_MANIFEST.contains(field), + "manifest field escaped: {field}" + ); + } + assert!(ROOT.contains("mod evidence_manifest;")); + assert!(!ROOT.contains("pub mod evidence_manifest;")); +} + +#[test] fn workflow_plan_is_root_exported_and_side_effect_free() { let _: Option<radroots_trade::WorkflowPlan> = None; let _: Option<radroots_trade::Error> = None; @@ -348,6 +422,7 @@ fn package_documentation_and_reviewed_api_baseline_are_complete() { "## Canonical surface", "## Deterministic reduction", "## Evidence coverage and outcome", + "## Immutable evidence manifests", "## Workflow planning", "## Features", "## Serialization and versioning", @@ -368,6 +443,12 @@ fn package_documentation_and_reviewed_api_baseline_are_complete() { "pub mod radroots_trade::evidence", "pub enum radroots_trade::evidence::RadrootsTradeEvidenceCoverageV1", "pub enum radroots_trade::evidence::RadrootsTradeEvidenceOutcomeV1", + "pub struct radroots_trade::evidence::RadrootsTradeEvidenceManifestV1", + "pub struct radroots_trade::evidence::RadrootsTradeEvidenceManifestDigestV1", + "pub struct radroots_trade::evidence::RadrootsTradeEvidencePolicyDigestV1", + "pub struct radroots_trade::evidence::RadrootsTradeEvidenceProvenanceDigestV1", + "pub struct radroots_trade::evidence::RadrootsTradeEvidenceSourceResultDigestV1", + "pub struct radroots_trade::evidence::RadrootsTradeSignedEventDigestV1", "pub mod radroots_trade::model", "pub mod radroots_trade::reducer", "pub mod radroots_trade::validation",