lib

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

commit 3563f3b5a4331eb2cb3f925cafc9de524d844228
parent b6f024d271fdf6a6eb1cf815e85a6d225fe7d8e1
Author: triesap <tyson@radroots.org>
Date:   Thu, 20 Aug 2026 07:04:10 +0000

bindings: expose evidence attestation contracts

- Expose final evidence manifest, report, and verified kind-3441 SDK adapters.
- Generate exact TypeScript, Wasm, UniFFI, and retained-mobile value surfaces.
- Keep manifest and signed-event admission bounded, strict, and secret-safe.
- Refresh dependency locks, inventories, fixtures, and the reviewed SDK API baseline.

Diffstat:
MCargo.lock | 6++++++
Mcontracts/api_baselines/radroots_sdk.txt | 38++++++++++++++++++++++++++++++++++++++
Mcrates/event_bindings/src/lib.rs | 2++
Mcrates/event_bindings/src/model.rs | 4++++
Mcrates/event_bindings/tests/fixtures/generated_kinds.ts | 2++
Mcrates/event_codec_wasm/Cargo.toml | 5+++++
Mcrates/event_codec_wasm/src/lib.rs | 312+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/mobile_core/src/runtime/sdk.rs | 5+++++
Mcrates/mobile_core/tests/package_boundary.rs | 21+++++++++++++++++++++
Mcrates/mobile_ffi/src/dto.rs | 312+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/mobile_ffi/tests/uniffi_contract.rs | 30+++++++++++++++++++++++++++---
Mcrates/sdk/src/trade.rs | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/package_boundary.rs | 28++++++++++++++++++++++++++++
Mcrates/sdk_ffi/Cargo.toml | 4++++
Mcrates/sdk_ffi/src/lib.rs | 371++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/trade_bindings/src/lib.rs | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/trade_bindings/tests/fixtures/generated_types.ts | 14++++++++++++++
Mtools/xtask/src/sdk_generation/dto_roots.rs | 7+++++++
Mtools/xtask/src/sdk_generation/output.rs | 2+-
Mtools/xtask/src/sdk_generation/wasm_declarations.rs | 103++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
20 files changed, 1484 insertions(+), 16 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3107,12 +3107,14 @@ dependencies = [ name = "radroots_event_codec_wasm" version = "0.1.0-alpha" dependencies = [ + "hex", "nostr 0.44.7", "radroots_blossom", "radroots_core", "radroots_event", "radroots_event_codec", "radroots_identity", + "radroots_trade", "serde", "serde_json", "wasm-bindgen", @@ -3453,7 +3455,11 @@ dependencies = [ name = "radroots_sdk_ffi" version = "0.1.0-alpha" dependencies = [ + "hex", + "radroots_event", + "radroots_event_codec", "radroots_sdk", + "serde_json", "thiserror 1.0.69", "tokio", "uniffi", diff --git a/contracts/api_baselines/radroots_sdk.txt b/contracts/api_baselines/radroots_sdk.txt @@ -375,6 +375,40 @@ pub async fn radroots_sdk::sync::Operations<'a>::submit_push(&self, radroots_syn impl core::fmt::Debug for radroots_sdk::sync::Operations<'_> pub fn radroots_sdk::sync::Operations<'_>::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub mod radroots_sdk::trade +pub use radroots_sdk::trade::RadrootsRhiEvidenceAttestationError +pub use radroots_sdk::trade::RadrootsRhiEvidenceAttestationOutcomeV1 +pub use radroots_sdk::trade::RadrootsRhiEvidenceAttestationSupersessionV1 +pub use radroots_sdk::trade::RadrootsRhiEvidenceAttestationV1 +pub use radroots_sdk::trade::RadrootsRhiEvidenceReasonCodeV1 +pub use radroots_sdk::trade::RadrootsRhiEvidenceReportError +pub use radroots_sdk::trade::RadrootsRhiEvidenceReportV1 +pub use radroots_sdk::trade::RadrootsRhiEvidenceStatementDigestV1 +pub use radroots_sdk::trade::RadrootsRhiEvidenceSupersessionV1 +pub use radroots_sdk::trade::RadrootsTradeEvidenceCoverageError +pub use radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1 +pub use radroots_sdk::trade::RadrootsTradeEvidenceManifestDigestV1 +pub use radroots_sdk::trade::RadrootsTradeEvidenceManifestError +pub use radroots_sdk::trade::RadrootsTradeEvidenceManifestObservationV1 +pub use radroots_sdk::trade::RadrootsTradeEvidenceManifestSourceResultV1 +pub use radroots_sdk::trade::RadrootsTradeEvidenceManifestV1 +pub use radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1 +pub use radroots_sdk::trade::RadrootsTradeEvidencePolicyDigestV1 +pub use radroots_sdk::trade::RadrootsTradeEvidenceProjectionDigestV1 +pub use radroots_sdk::trade::RadrootsTradeEvidenceProvenanceDigestV1 +pub use radroots_sdk::trade::RadrootsTradeEvidenceScopePrerequisitesV1 +pub use radroots_sdk::trade::RadrootsTradeEvidenceSourceCompletionV1 +pub use radroots_sdk::trade::RadrootsTradeEvidenceSourceIdV1 +pub use radroots_sdk::trade::RadrootsTradeEvidenceSourceRequirementV1 +pub use radroots_sdk::trade::RadrootsTradeEvidenceSourceResultDigestV1 +pub use radroots_sdk::trade::RadrootsTradeEvidenceSourceResultV1 +pub use radroots_sdk::trade::RadrootsTradeSignedEventDigestV1 +pub use radroots_sdk::trade::classify_trade_evidence_coverage_v1 +pub enum radroots_sdk::trade::EvidenceAttestationValidationError +pub radroots_sdk::trade::EvidenceAttestationValidationError::Contract +pub radroots_sdk::trade::EvidenceAttestationValidationError::Signature +impl core::error::Error for radroots_sdk::trade::EvidenceAttestationValidationError +impl core::fmt::Display for radroots_sdk::trade::EvidenceAttestationValidationError +pub fn radroots_sdk::trade::EvidenceAttestationValidationError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result #[non_exhaustive] pub enum radroots_sdk::trade::PrepareErrorKind pub radroots_sdk::trade::PrepareErrorKind::CanonicalMutation pub radroots_sdk::trade::PrepareErrorKind::Draft @@ -413,8 +447,12 @@ pub fn radroots_sdk::trade::PrepareError::fmt(&self, &mut core::fmt::Formatter<' pub struct radroots_sdk::trade::PrepareRequest impl radroots_sdk::trade::PrepareRequest pub const fn radroots_sdk::trade::PrepareRequest::new(radroots_signing::actor::Actor, radroots_event::trade::TradeMutationEnvelopeV1) -> Self +pub fn radroots_sdk::trade::parse_evidence_manifest(&[u8]) -> core::result::Result<radroots_trade::evidence_manifest::RadrootsTradeEvidenceManifestV1, radroots_trade::evidence_manifest::RadrootsTradeEvidenceManifestError> +pub fn radroots_sdk::trade::parse_rhi_evidence_report(&[u8]) -> core::result::Result<radroots_trade::evidence_report::RadrootsRhiEvidenceReportV1, radroots_trade::evidence_report::RadrootsRhiEvidenceReportError> pub fn radroots_sdk::trade::prepare(radroots_sdk::trade::PrepareRequest) -> core::result::Result<radroots_sdk::trade::Plan, radroots_sdk::trade::PrepareError> +pub fn radroots_sdk::trade::prepare_rhi_evidence_attestation(&radroots_trade::evidence_report::RadrootsRhiEvidenceReportV1, u64) -> core::result::Result<radroots_event_codec::authoring::AuthoredEventPlan, radroots_event_codec::authoring::typed::AuthoredPlanError> pub fn radroots_sdk::trade::project(radroots_trade::trade_contract_v1::RadrootsTradeReductionInputV1) -> radroots_trade::trade_contract_v1::RadrootsTradeProjectionV1 +pub fn radroots_sdk::trade::validate_rhi_evidence_attestation(radroots_event::envelope::EventEnvelope) -> core::result::Result<radroots_event_codec::rhi::RadrootsRhiEvidenceAttestationV1, radroots_sdk::trade::EvidenceAttestationValidationError> pub mod radroots_sdk::transport pub use radroots_sdk::transport::ReconnectBackoff pub use radroots_sdk::transport::RelayAccess diff --git a/crates/event_bindings/src/lib.rs b/crates/event_bindings/src/lib.rs @@ -23,6 +23,8 @@ mod tests { assert!(kinds_ts.contains(&kinds::KIND_CLASSIFIED_LISTING.to_string())); assert!(kinds_ts.contains("KIND_TRADE_PROPOSAL")); assert!(kinds_ts.contains(&kinds::KIND_TRADE_PROPOSAL.to_string())); + assert!(kinds_ts.contains("KIND_RHI_EVIDENCE_ATTESTATION")); + assert!(kinds_ts.contains(&kinds::KIND_RHI_EVIDENCE_ATTESTATION.to_string())); assert!(!kinds_ts.contains("KIND_ORDER_REQUEST")); } } diff --git a/crates/event_bindings/src/model.rs b/crates/event_bindings/src/model.rs @@ -136,6 +136,10 @@ const EVENT_KIND_EXPORTS: &[(&str, u32)] = &[ "KIND_TRADE_VALIDATION_RECEIPT", kinds::KIND_TRADE_VALIDATION_RECEIPT, ), + ( + "KIND_RHI_EVIDENCE_ATTESTATION", + kinds::KIND_RHI_EVIDENCE_ATTESTATION, + ), ("KIND_KNOWLEDGE_CLAIM", kinds::KIND_KNOWLEDGE_CLAIM), ("KIND_KNOWLEDGE_RELATION", kinds::KIND_KNOWLEDGE_RELATION), ("KIND_KNOWLEDGE_REVIEW", kinds::KIND_KNOWLEDGE_REVIEW), diff --git a/crates/event_bindings/tests/fixtures/generated_kinds.ts b/crates/event_bindings/tests/fixtures/generated_kinds.ts @@ -122,6 +122,8 @@ export const KIND_TRADE_SELLER_RESERVATION_ASSERTION = 3475; export const KIND_TRADE_VALIDATION_RECEIPT = 3440; +export const KIND_RHI_EVIDENCE_ATTESTATION = 3441; + export const KIND_KNOWLEDGE_CLAIM = 3460; export const KIND_KNOWLEDGE_RELATION = 3461; diff --git a/crates/event_codec_wasm/Cargo.toml b/crates/event_codec_wasm/Cargo.toml @@ -15,6 +15,7 @@ readme = "README" crate-type = ["cdylib", "rlib"] [dependencies] +hex = { workspace = true } radroots_blossom = { workspace = true, default-features = false, features = [ "std", ] } @@ -27,6 +28,10 @@ radroots_event_codec = { workspace = true, default-features = false, features = "std", "manifests", ] } +radroots_trade = { workspace = true, default-features = false, features = [ + "json", + "std", +] } serde = { workspace = true } serde_json = { workspace = true } wasm-bindgen = { workspace = true } diff --git a/crates/event_codec_wasm/src/lib.rs b/crates/event_codec_wasm/src/lib.rs @@ -91,6 +91,18 @@ use radroots_event_codec::encode::report::report_build_tags; use radroots_event_codec::encode::repost::{generic_repost_build_tags, repost_build_tags}; use radroots_event_codec::encode::seal::seal_build_tags; use radroots_event_codec::verify::{RadrootsDecodeError, RadrootsDecodedEvent}; +use radroots_event_codec::{ + authoring::AuthoredEventPlan, + decode::rhi::{ + RadrootsRhiEvidenceAttestationOutcomeV1, RadrootsRhiEvidenceAttestationV1, + rhi_evidence_attestation_from_verified_event, + }, + verify::{Nip01SignatureVerifier, id as verify_event_id, signature as verify_event_signature}, +}; +use radroots_trade::evidence::{ + RadrootsRhiEvidenceReportV1, RadrootsTradeEvidenceCoverageV1, RadrootsTradeEvidenceManifestV1, + RadrootsTradeEvidenceOutcomeV1, +}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; #[cfg(target_arch = "wasm32")] @@ -164,6 +176,134 @@ fn tags_to_json(tags: Vec<Vec<String>>) -> Result<String, RadrootsJsValue> { serde_json::to_string(&tags).map_err(err_js) } +#[derive(Serialize)] +struct EvidenceManifestJson { + contract_id: &'static str, + contract_version: u16, + trade_id: String, + trade_generation: String, + observed_at_unix_s: String, + coverage: &'static str, + evidence_policy_digest: String, + manifest_digest: String, + canonical_bytes_hex: String, +} + +#[derive(Serialize)] +struct EvidenceSupersessionJson { + report_id: String, + event_id: String, +} + +#[derive(Serialize)] +struct EvidenceReportJson { + contract_id: &'static str, + contract_version: u16, + issuer_pubkey: String, + trade_id: String, + claim_mutation_id: String, + outcome: &'static str, + reason_codes: Vec<String>, + projection_digest: String, + evidence_manifest_digest: String, + evidence_policy_digest: String, + observed_at_unix_s: String, + trade_generation: String, + statement_digest: String, + supersession: Option<EvidenceSupersessionJson>, + canonical_content: String, +} + +#[derive(Serialize)] +struct TypedEvidenceEventPlanJson { + contract_id: String, + kind: u32, + author_pubkey: String, + created_at_unix_s: String, + expected_event_id: String, + tags: Vec<Vec<String>>, + content: String, +} + +#[derive(Serialize)] +struct EvidenceAttestationJson { + issuer_pubkey: String, + trade_id: String, + claim_mutation_id: String, + outcome: &'static str, + observed_at_unix_s: String, + trade_generation: String, + statement_digest: String, + supersession: Option<EvidenceSupersessionJson>, + canonical_content: String, +} + +fn coverage_name(value: RadrootsTradeEvidenceCoverageV1) -> &'static str { + match value { + RadrootsTradeEvidenceCoverageV1::Missing => "missing", + RadrootsTradeEvidenceCoverageV1::Partial => "partial", + RadrootsTradeEvidenceCoverageV1::ScopeSatisfied => "scope_satisfied", + RadrootsTradeEvidenceCoverageV1::Unsupported => "unsupported", + } +} + +fn outcome_name(value: RadrootsTradeEvidenceOutcomeV1) -> &'static str { + match value { + RadrootsTradeEvidenceOutcomeV1::Valid => "valid", + RadrootsTradeEvidenceOutcomeV1::Invalid => "invalid", + RadrootsTradeEvidenceOutcomeV1::Indeterminate => "indeterminate", + } +} + +fn attestation_outcome_name(value: RadrootsRhiEvidenceAttestationOutcomeV1) -> &'static str { + match value { + RadrootsRhiEvidenceAttestationOutcomeV1::Valid => "valid", + RadrootsRhiEvidenceAttestationOutcomeV1::Invalid => "invalid", + RadrootsRhiEvidenceAttestationOutcomeV1::Indeterminate => "indeterminate", + } +} + +fn evidence_report_json(value: &RadrootsRhiEvidenceReportV1) -> EvidenceReportJson { + EvidenceReportJson { + contract_id: value.contract_id(), + contract_version: value.contract_version(), + issuer_pubkey: value.issuer_public_key().to_hex(), + trade_id: value.trade_id().to_string(), + claim_mutation_id: value.claim_mutation_id().to_string(), + outcome: outcome_name(value.outcome()), + reason_codes: value + .reason_codes() + .iter() + .map(|code| code.as_str().to_owned()) + .collect(), + projection_digest: value.projection_digest().to_hex(), + evidence_manifest_digest: value.evidence_manifest_digest().to_hex(), + evidence_policy_digest: value.evidence_policy_digest().to_hex(), + observed_at_unix_s: value.observed_at_unix_s().to_string(), + trade_generation: value.trade_generation().get().to_string(), + statement_digest: value.statement_digest().to_hex(), + supersession: value + .supersession() + .map(|supersession| EvidenceSupersessionJson { + report_id: supersession.report_id().to_hex(), + event_id: supersession.event_id().to_hex(), + }), + canonical_content: value.canonical_content().to_owned(), + } +} + +fn typed_plan_json(value: &AuthoredEventPlan) -> TypedEvidenceEventPlanJson { + TypedEvidenceEventPlanJson { + contract_id: value.body().contract().contract_id().as_str().to_owned(), + kind: value.body().kind(), + author_pubkey: value.author().to_hex(), + created_at_unix_s: value.created_at().to_string(), + expected_event_id: value.expected_event_id().to_hex(), + tags: value.body().tags().to_vec(), + content: value.body().content().to_owned(), + } +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct EventEnvelopeInput { @@ -210,6 +350,13 @@ fn parse_event_json(input: &str) -> Result<EventEnvelope, RadrootsJsValue> { .map_err(|error| error_json("invalid_event", Some(envelope_error_code(&error)))) } +fn parse_bounded_event_json(input: &str) -> Result<EventEnvelope, RadrootsJsValue> { + if input.len() > radroots_event::wire::v1::DEFAULT_RAW_JSON_MAX_BYTES { + return Err(error_json("invalid_event", Some("event_too_large"))); + } + parse_event_json(input) +} + fn build_tags_json<T, E, F>(input: &str, build: F) -> Result<String, RadrootsJsValue> where T: DeserializeOwned, @@ -610,6 +757,107 @@ pub fn verify_and_decode_event_json(event_json: &str) -> Result<String, Radroots decoded_event_to_json(decoded) } +/// Parses one bounded canonical evidence manifest supplied as lowercase hex. +#[cfg_attr( + target_arch = "wasm32", + wasm_bindgen(js_name = trade_evidence_manifest_parse_json) +)] +pub fn trade_evidence_manifest_parse_json( + canonical_bytes_hex: &str, +) -> Result<String, RadrootsJsValue> { + if canonical_bytes_hex.len() + > radroots_trade::evidence::RADROOTS_TRADE_EVIDENCE_MANIFEST_MAXIMUM_BYTES * 2 + || !canonical_bytes_hex.len().is_multiple_of(2) + || !canonical_bytes_hex + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(error_json("invalid_evidence_manifest", None)); + } + let canonical_bytes = hex::decode(canonical_bytes_hex) + .map_err(|_| error_json("invalid_evidence_manifest", None))?; + let manifest = RadrootsTradeEvidenceManifestV1::from_canonical_bytes(&canonical_bytes) + .map_err(|_| error_json("invalid_evidence_manifest", None))?; + serde_json::to_string(&EvidenceManifestJson { + contract_id: manifest.contract_id(), + contract_version: manifest.contract_version(), + trade_id: manifest.trade_id().to_string(), + trade_generation: manifest.trade_generation().get().to_string(), + observed_at_unix_s: manifest.observed_at_unix_s().to_string(), + coverage: coverage_name(manifest.coverage()), + evidence_policy_digest: manifest.evidence_policy_digest().to_hex(), + manifest_digest: manifest.digest().to_hex(), + canonical_bytes_hex: hex::encode(manifest.canonical_bytes()), + }) + .map_err(|_| error_json("internal_error", Some("evidence_manifest_serialization"))) +} + +/// Parses one bounded canonical RHI evidence report. +#[cfg_attr( + target_arch = "wasm32", + wasm_bindgen(js_name = rhi_evidence_report_parse_json) +)] +pub fn rhi_evidence_report_parse_json(canonical_content: &str) -> Result<String, RadrootsJsValue> { + let report = RadrootsRhiEvidenceReportV1::from_canonical_content(canonical_content.as_bytes()) + .map_err(|_| error_json("invalid_evidence_report", None))?; + serde_json::to_string(&evidence_report_json(&report)) + .map_err(|_| error_json("internal_error", Some("evidence_report_serialization"))) +} + +/// Builds one unsigned typed RHI evidence-attestation plan. +#[cfg_attr( + target_arch = "wasm32", + wasm_bindgen(js_name = rhi_evidence_attestation_build_draft) +)] +pub fn rhi_evidence_attestation_build_draft( + canonical_content: &str, + created_at_unix_s: u64, +) -> Result<String, RadrootsJsValue> { + let report = RadrootsRhiEvidenceReportV1::from_canonical_content(canonical_content.as_bytes()) + .map_err(|_| error_json("invalid_evidence_report", None))?; + let attestation = + RadrootsRhiEvidenceAttestationV1::from_canonical_content(report.canonical_content()) + .map_err(|_| error_json("invalid_evidence_attestation", None))?; + let plan = AuthoredEventPlan::from_rhi_evidence_attestation(&attestation, created_at_unix_s) + .map_err(|_| error_json("invalid_evidence_attestation_plan", None))?; + serde_json::to_string(&typed_plan_json(&plan)) + .map_err(|_| error_json("internal_error", Some("evidence_plan_serialization"))) +} + +/// Verifies NIP-01 identity/signature before validating the RHI contract. +#[cfg_attr( + target_arch = "wasm32", + wasm_bindgen(js_name = rhi_evidence_attestation_validate_signed_json) +)] +pub fn rhi_evidence_attestation_validate_signed_json( + event_json: &str, +) -> Result<String, RadrootsJsValue> { + let event = parse_bounded_event_json(event_json)?; + let verified = verify_event_id(radroots_event::admission::RawEvent::new(event)) + .and_then(|event| verify_event_signature(event, &Nip01SignatureVerifier)) + .map_err(|_| error_json("invalid_event_signature", None))?; + let attestation = rhi_evidence_attestation_from_verified_event(&verified) + .map_err(|error| error_json("invalid_evidence_attestation", Some(error.code())))?; + let supersession = attestation + .supersession() + .map(|supersession| EvidenceSupersessionJson { + report_id: hex::encode(supersession.report_id()), + event_id: supersession.event_id().to_hex(), + }); + serde_json::to_string(&EvidenceAttestationJson { + issuer_pubkey: attestation.issuer().to_hex(), + trade_id: attestation.trade_id().to_string(), + claim_mutation_id: attestation.claim_mutation_id().to_string(), + outcome: attestation_outcome_name(attestation.outcome()), + observed_at_unix_s: attestation.observed_at_unix_s().to_string(), + trade_generation: attestation.trade_generation().get().to_string(), + statement_digest: hex::encode(attestation.statement_digest()), + supersession, + canonical_content: attestation.canonical_content().to_owned(), + }) + .map_err(|_| error_json("internal_error", Some("evidence_attestation_serialization"))) +} + #[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = contract_manifest_json))] pub fn contract_manifest_json() -> Result<String, RadrootsJsValue> { radroots_event_codec::manifest::contract_manifest_json().map_err(err_js) @@ -2179,4 +2427,68 @@ mod tests { assert!(operational_listing_tags(&listing_json).is_err()); assert!(operational_listing_tags_full(&listing_json).is_err()); } + + #[test] + fn evidence_report_plan_and_signed_validation_use_final_contracts() { + assert!(trade_evidence_manifest_parse_json("AA").is_err()); + assert!( + rhi_evidence_attestation_validate_signed_json( + &"x".repeat(radroots_event::wire::v1::DEFAULT_RAW_JSON_MAX_BYTES + 1) + ) + .is_err() + ); + + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../contracts/conformance/vectors/event/authored_operations.v1.json" + )) + .expect("authored corpus"); + let expected = fixture["vectors"] + .as_array() + .expect("operations") + .iter() + .find(|entry| entry["id"] == "typed_rhi_evidence_attestation_017") + .expect("RHI operation") + .get("expected") + .expect("expected"); + let content = expected["content"].as_str().expect("content"); + + let report: serde_json::Value = serde_json::from_str( + &rhi_evidence_report_parse_json(content).expect("report projection"), + ) + .expect("report JSON"); + assert_eq!(report["outcome"], "indeterminate"); + assert_eq!(report["trade_generation"], "7"); + + let plan: serde_json::Value = serde_json::from_str( + &rhi_evidence_attestation_build_draft(content, 1_784_347_200).expect("typed plan"), + ) + .expect("plan JSON"); + assert_eq!(plan["kind"], 3_441); + assert_eq!(plan["created_at_unix_s"], "1784347200"); + assert_eq!(plan["expected_event_id"], expected["event_id"]); + + let raw: serde_json::Value = + serde_json::from_str(expected["raw_json"].as_str().expect("signed event")) + .expect("raw event JSON"); + let boundary_event = serde_json::json!({ + "id": raw["id"], + "author": raw["pubkey"], + "created_at": raw["created_at"], + "kind": raw["kind"], + "tags": raw["tags"], + "content": raw["content"], + "sig": raw["sig"], + }); + let attestation: serde_json::Value = serde_json::from_str( + &rhi_evidence_attestation_validate_signed_json(&boundary_event.to_string()) + .expect("verified attestation"), + ) + .expect("attestation JSON"); + assert_eq!(attestation["outcome"], "indeterminate"); + assert_eq!(attestation["trade_generation"], "7"); + + let secret = "private-report-content"; + let error = rhi_evidence_report_parse_json(secret).expect_err("malformed report"); + assert!(!format!("{error:?}").contains(secret)); + } } diff --git a/crates/mobile_core/src/runtime/sdk.rs b/crates/mobile_core/src/runtime/sdk.rs @@ -1,5 +1,10 @@ use radroots_sdk::capability::{Availability, Maturity}; +pub use radroots_sdk::trade::{ + RadrootsRhiEvidenceReportV1, RadrootsTradeEvidenceCoverageV1, RadrootsTradeEvidenceManifestV1, + RadrootsTradeEvidenceOutcomeV1, +}; + use super::RadrootsRuntime; #[cfg(feature = "mobile-social")] use super::product_surface::{BlossomPreferences, RelayPreferences}; diff --git a/crates/mobile_core/tests/package_boundary.rs b/crates/mobile_core/tests/package_boundary.rs @@ -78,3 +78,24 @@ fn production_runtime_requires_validated_sqlite_and_memory_is_test_only() { assert!(RUNTIME.contains("#[cfg(test)]\n pub(crate) fn test_memory()")); assert!(!RUNTIME.contains("pub fn new()")); } + +#[test] +fn mobile_core_reuses_the_final_sdk_evidence_vocabulary() { + for required in [ + "RadrootsRhiEvidenceReportV1", + "RadrootsTradeEvidenceCoverageV1", + "RadrootsTradeEvidenceManifestV1", + "RadrootsTradeEvidenceOutcomeV1", + ] { + assert!( + SDK.contains(required), + "missing SDK evidence type `{required}`" + ); + } + for forbidden in ["SecretKey", "sign_event", "publish_event", "tokio::spawn"] { + assert!( + !SDK.contains(forbidden), + "mobile evidence projection gained forbidden authority `{forbidden}`" + ); + } +} diff --git a/crates/mobile_ffi/src/dto.rs b/crates/mobile_ffi/src/dto.rs @@ -43,6 +43,260 @@ pub const MOBILE_FFI_SCHEMA_VERSION: u16 = 1; const MEDIA_FILE_MAX_BYTES: u64 = 10 * 1024 * 1024; const MEDIA_REFERENCE_MAX_BYTES: usize = 256; +/// Final four-state trade-evidence coverage vocabulary. +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiTradeEvidenceCoverage { + Missing, + Partial, + ScopeSatisfied, + Unsupported, +} + +/// Final three-state trade-evidence outcome vocabulary. +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiTradeEvidenceOutcome { + Valid, + Invalid, + Indeterminate, +} + +/// Secret-free projection of one canonical evidence manifest. +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiTradeEvidenceManifestRecord { + pub schema_version: u16, + pub contract_id: String, + pub contract_version: u16, + pub trade_id: String, + pub trade_generation: String, + pub observed_at_unix_s: String, + pub coverage: FfiTradeEvidenceCoverage, + pub evidence_policy_digest: String, + pub manifest_digest: String, + pub canonical_bytes_hex: String, +} + +/// Secret-free projection of one canonical RHI evidence report. +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiRhiEvidenceReportRecord { + pub schema_version: u16, + pub contract_id: String, + pub contract_version: u16, + pub issuer_pubkey: String, + pub trade_id: String, + pub claim_mutation_id: String, + pub outcome: FfiTradeEvidenceOutcome, + pub reason_codes: Vec<String>, + pub projection_digest: String, + pub evidence_manifest_digest: String, + pub evidence_policy_digest: String, + pub observed_at_unix_s: String, + pub trade_generation: String, + pub statement_digest: String, + pub supersedes_report_id: Option<String>, + pub supersedes_event_id: Option<String>, + pub canonical_content: String, +} + +/// Unsigned typed event plan ready for host-owned signing. +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiTypedEvidenceEventPlanRecord { + pub schema_version: u16, + pub contract_id: String, + pub kind: u32, + pub author_pubkey: String, + pub created_at_unix_s: String, + pub expected_event_id: String, + pub tags: Vec<Vec<String>>, + pub content: String, +} + +/// Signed NIP-01 event input for verified attestation admission. +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiSignedEvidenceEventRecord { + pub id: String, + pub author_pubkey: String, + pub created_at_unix_s: u64, + pub kind: u32, + pub tags: Vec<Vec<String>>, + pub content: String, + pub signature: String, +} + +/// Secret-free supersession projection from one verified attestation. +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiRhiEvidenceAttestationSupersessionRecord { + pub report_id: String, + pub event_id: String, +} + +/// Verified final RHI evidence attestation. +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiRhiEvidenceAttestationRecord { + pub schema_version: u16, + pub issuer_pubkey: String, + pub trade_id: String, + pub claim_mutation_id: String, + pub outcome: FfiTradeEvidenceOutcome, + pub observed_at_unix_s: String, + pub trade_generation: String, + pub statement_digest: String, + pub supersession: Option<FfiRhiEvidenceAttestationSupersessionRecord>, + pub canonical_content: String, +} + +#[uniffi::export] +pub fn parse_trade_evidence_manifest( + canonical_bytes: Vec<u8>, +) -> Result<FfiTradeEvidenceManifestRecord, RadrootsAppError> { + let manifest = radroots_sdk::trade::parse_evidence_manifest(&canonical_bytes) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_evidence_manifest"))?; + Ok(FfiTradeEvidenceManifestRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + contract_id: manifest.contract_id().to_owned(), + contract_version: manifest.contract_version(), + trade_id: manifest.trade_id().to_string(), + trade_generation: manifest.trade_generation().get().to_string(), + observed_at_unix_s: manifest.observed_at_unix_s().to_string(), + coverage: manifest.coverage().into(), + evidence_policy_digest: manifest.evidence_policy_digest().to_hex(), + manifest_digest: manifest.digest().to_hex(), + canonical_bytes_hex: hex::encode(manifest.canonical_bytes()), + }) +} + +#[uniffi::export] +pub fn parse_rhi_evidence_report( + canonical_content: String, +) -> Result<FfiRhiEvidenceReportRecord, RadrootsAppError> { + let report = radroots_sdk::trade::parse_rhi_evidence_report(canonical_content.as_bytes()) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_evidence_report"))?; + let supersession = report.supersession(); + Ok(FfiRhiEvidenceReportRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + contract_id: report.contract_id().to_owned(), + contract_version: report.contract_version(), + issuer_pubkey: report.issuer_public_key().to_hex(), + trade_id: report.trade_id().to_string(), + claim_mutation_id: report.claim_mutation_id().to_string(), + outcome: report.outcome().into(), + reason_codes: report + .reason_codes() + .iter() + .map(|code| code.as_str().to_owned()) + .collect(), + projection_digest: report.projection_digest().to_hex(), + evidence_manifest_digest: report.evidence_manifest_digest().to_hex(), + evidence_policy_digest: report.evidence_policy_digest().to_hex(), + observed_at_unix_s: report.observed_at_unix_s().to_string(), + trade_generation: report.trade_generation().get().to_string(), + statement_digest: report.statement_digest().to_hex(), + supersedes_report_id: supersession.map(|value| value.report_id().to_hex()), + supersedes_event_id: supersession.map(|value| value.event_id().to_hex()), + canonical_content: report.canonical_content().to_owned(), + }) +} + +#[uniffi::export] +pub fn prepare_rhi_evidence_attestation( + canonical_content: String, + created_at_unix_s: u64, +) -> Result<FfiTypedEvidenceEventPlanRecord, RadrootsAppError> { + let report = radroots_sdk::trade::parse_rhi_evidence_report(canonical_content.as_bytes()) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_evidence_report"))?; + let plan = radroots_sdk::trade::prepare_rhi_evidence_attestation(&report, created_at_unix_s) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_evidence_attestation_plan"))?; + Ok(FfiTypedEvidenceEventPlanRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + contract_id: plan.body().contract().contract_id().as_str().to_owned(), + kind: plan.body().kind(), + author_pubkey: plan.author().to_hex(), + created_at_unix_s: plan.created_at().to_string(), + expected_event_id: plan.expected_event_id().to_hex(), + tags: plan.body().tags().to_vec(), + content: plan.body().content().to_owned(), + }) +} + +#[uniffi::export] +pub fn validate_rhi_evidence_attestation( + event: FfiSignedEvidenceEventRecord, +) -> Result<FfiRhiEvidenceAttestationRecord, RadrootsAppError> { + let event = radroots_event::envelope::EventEnvelope::new( + radroots_event::envelope::EventEnvelopeParts { + id: event.id, + author: event.author_pubkey, + created_at: event.created_at_unix_s, + kind: event.kind, + tags: event.tags, + content: event.content, + sig: event.signature, + }, + ) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_signed_event"))?; + let attestation = radroots_sdk::trade::validate_rhi_evidence_attestation(event).map_err( + |error| match error { + radroots_sdk::trade::EvidenceAttestationValidationError::Signature => { + RadrootsAppError::invalid_argument("invalid_event_signature") + } + radroots_sdk::trade::EvidenceAttestationValidationError::Contract => { + RadrootsAppError::invalid_argument("invalid_evidence_attestation") + } + }, + )?; + Ok(FfiRhiEvidenceAttestationRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + issuer_pubkey: attestation.issuer().to_hex(), + trade_id: attestation.trade_id().to_string(), + claim_mutation_id: attestation.claim_mutation_id().to_string(), + outcome: match attestation.outcome() { + radroots_sdk::trade::RadrootsRhiEvidenceAttestationOutcomeV1::Valid => { + FfiTradeEvidenceOutcome::Valid + } + radroots_sdk::trade::RadrootsRhiEvidenceAttestationOutcomeV1::Invalid => { + FfiTradeEvidenceOutcome::Invalid + } + radroots_sdk::trade::RadrootsRhiEvidenceAttestationOutcomeV1::Indeterminate => { + FfiTradeEvidenceOutcome::Indeterminate + } + }, + observed_at_unix_s: attestation.observed_at_unix_s().to_string(), + trade_generation: attestation.trade_generation().get().to_string(), + statement_digest: hex::encode(attestation.statement_digest()), + supersession: attestation.supersession().map(|value| { + FfiRhiEvidenceAttestationSupersessionRecord { + report_id: hex::encode(value.report_id()), + event_id: value.event_id().to_hex(), + } + }), + canonical_content: attestation.canonical_content().to_owned(), + }) +} + +impl From<radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1> for FfiTradeEvidenceCoverage { + fn from(value: radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1) -> Self { + match value { + radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Missing => Self::Missing, + radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Partial => Self::Partial, + radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::ScopeSatisfied => { + Self::ScopeSatisfied + } + radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Unsupported => Self::Unsupported, + } + } +} + +impl From<radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1> for FfiTradeEvidenceOutcome { + fn from(value: radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1) -> Self { + match value { + radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Valid => Self::Valid, + radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Invalid => Self::Invalid, + radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Indeterminate => { + Self::Indeterminate + } + } + } +} + #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] pub struct FfiBuildInfoRecord { pub schema_version: u16, @@ -2049,6 +2303,64 @@ mod tests { use super::*; + fn rhi_attestation_fixture() -> serde_json::Value { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../contracts/conformance/vectors/event/authored_operations.v1.json" + )) + .expect("authored corpus"); + fixture["vectors"] + .as_array() + .expect("operations") + .iter() + .find(|entry| entry["id"] == "typed_rhi_evidence_attestation_017") + .expect("RHI operation") + .get("expected") + .expect("expected") + .clone() + } + + #[test] + fn evidence_report_plan_and_verified_event_use_final_mobile_vocabulary() { + let expected = rhi_attestation_fixture(); + let content = expected["content"].as_str().expect("content").to_owned(); + let report = parse_rhi_evidence_report(content.clone()).expect("report"); + assert_eq!(report.outcome, FfiTradeEvidenceOutcome::Indeterminate); + assert_eq!(report.trade_generation, "7"); + assert_eq!(report.observed_at_unix_s, "1800000000"); + + let plan = prepare_rhi_evidence_attestation(content, 1_784_347_200).expect("plan"); + assert_eq!(plan.kind, 3_441); + assert_eq!(plan.created_at_unix_s, "1784347200"); + assert_eq!( + plan.expected_event_id, + expected["event_id"].as_str().expect("event id") + ); + + let raw: serde_json::Value = + serde_json::from_str(expected["raw_json"].as_str().expect("raw event")) + .expect("raw event JSON"); + let signed = FfiSignedEvidenceEventRecord { + id: raw["id"].as_str().expect("id").to_owned(), + author_pubkey: raw["pubkey"].as_str().expect("pubkey").to_owned(), + created_at_unix_s: raw["created_at"].as_u64().expect("created_at"), + kind: u32::try_from(raw["kind"].as_u64().expect("kind")).expect("u32 kind"), + tags: serde_json::from_value(raw["tags"].clone()).expect("tags"), + content: raw["content"].as_str().expect("content").to_owned(), + signature: raw["sig"].as_str().expect("signature").to_owned(), + }; + let attestation = validate_rhi_evidence_attestation(signed).expect("attestation"); + assert_eq!(attestation.outcome, FfiTradeEvidenceOutcome::Indeterminate); + assert_eq!(attestation.trade_generation, "7"); + } + + #[test] + fn evidence_errors_do_not_render_untrusted_content() { + let secret = "mobile-private-evidence"; + let error = parse_rhi_evidence_report(secret.to_owned()).expect_err("malformed report"); + assert!(!error.to_string().contains(secret)); + assert!(!format!("{error:?}").contains(secret)); + } + fn png(width: u32, height: u32) -> Vec<u8> { let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); bytes.extend_from_slice(&width.to_be_bytes()); diff --git a/crates/mobile_ffi/tests/uniffi_contract.rs b/crates/mobile_ffi/tests/uniffi_contract.rs @@ -1,8 +1,9 @@ use radroots_mobile_ffi::{ FfiAddCommandType, FfiAddDraftInput, FfiCancellationPolicy, FfiMediaOperation, - FfiQueuePolicyRecord, FfiRelaySatisfaction, HostSigningOutcome, HostSigningRequest, - HostSigningResult, MOBILE_FFI_SCHEMA_VERSION, ProtectedDataAvailability, RadrootsAppError, - RadrootsHostSigner, RadrootsRuntime, SignerAvailabilityRecord, SignerStatusRecord, + FfiQueuePolicyRecord, FfiRelaySatisfaction, FfiTradeEvidenceCoverage, FfiTradeEvidenceOutcome, + HostSigningOutcome, HostSigningRequest, HostSigningResult, MOBILE_FFI_SCHEMA_VERSION, + ProtectedDataAvailability, RadrootsAppError, RadrootsHostSigner, RadrootsRuntime, + SignerAvailabilityRecord, SignerStatusRecord, }; use secp256k1::{Keypair, Message, Secp256k1, SecretKey}; use std::sync::{Arc, Mutex}; @@ -77,6 +78,29 @@ fn swift_module_names_preserve_the_host_contract() { } #[test] +fn final_evidence_vocabularies_are_exact_at_the_mobile_boundary() { + assert_eq!( + [ + FfiTradeEvidenceCoverage::Missing, + FfiTradeEvidenceCoverage::Partial, + FfiTradeEvidenceCoverage::ScopeSatisfied, + FfiTradeEvidenceCoverage::Unsupported, + ] + .len(), + 4 + ); + assert_eq!( + [ + FfiTradeEvidenceOutcome::Valid, + FfiTradeEvidenceOutcome::Invalid, + FfiTradeEvidenceOutcome::Indeterminate, + ] + .len(), + 3 + ); +} + +#[test] fn media_cancellation_handle_owns_one_stable_opaque_operation_identity() { let operation = FfiMediaOperation::new().expect("media operation"); let operation_id = operation.operation_id(); diff --git a/crates/sdk/src/trade.rs b/crates/sdk/src/trade.rs @@ -10,6 +10,88 @@ use radroots_event_codec::authoring::{AuthoredEventPlan, AuthoredPlanError}; use radroots_signing::Actor; use radroots_trade::{Projection, ReductionInput, WorkflowPlan, reducer::reduce_trade_records}; +pub use radroots_event_codec::decode::rhi::{ + RadrootsRhiEvidenceAttestationError, RadrootsRhiEvidenceAttestationOutcomeV1, + RadrootsRhiEvidenceAttestationSupersessionV1, RadrootsRhiEvidenceAttestationV1, +}; +pub use radroots_trade::evidence::{ + RadrootsRhiEvidenceReasonCodeV1, RadrootsRhiEvidenceReportError, RadrootsRhiEvidenceReportV1, + RadrootsRhiEvidenceStatementDigestV1, RadrootsRhiEvidenceSupersessionV1, + RadrootsTradeEvidenceCoverageError, RadrootsTradeEvidenceCoverageV1, + RadrootsTradeEvidenceManifestDigestV1, RadrootsTradeEvidenceManifestError, + RadrootsTradeEvidenceManifestObservationV1, RadrootsTradeEvidenceManifestSourceResultV1, + RadrootsTradeEvidenceManifestV1, RadrootsTradeEvidenceOutcomeV1, + RadrootsTradeEvidencePolicyDigestV1, RadrootsTradeEvidenceProjectionDigestV1, + RadrootsTradeEvidenceProvenanceDigestV1, RadrootsTradeEvidenceScopePrerequisitesV1, + RadrootsTradeEvidenceSourceCompletionV1, RadrootsTradeEvidenceSourceIdV1, + RadrootsTradeEvidenceSourceRequirementV1, RadrootsTradeEvidenceSourceResultDigestV1, + RadrootsTradeEvidenceSourceResultV1, RadrootsTradeSignedEventDigestV1, + classify_trade_evidence_coverage_v1, +}; + +/// Parses one bounded canonical trade-evidence manifest. +pub fn parse_evidence_manifest( + canonical_bytes: &[u8], +) -> Result<RadrootsTradeEvidenceManifestV1, RadrootsTradeEvidenceManifestError> { + RadrootsTradeEvidenceManifestV1::from_canonical_bytes(canonical_bytes) +} + +/// Parses one bounded canonical RHI evidence report. +pub fn parse_rhi_evidence_report( + canonical_content: &[u8], +) -> Result<RadrootsRhiEvidenceReportV1, RadrootsRhiEvidenceReportError> { + RadrootsRhiEvidenceReportV1::from_canonical_content(canonical_content) +} + +/// Builds one immutable typed RHI attestation plan without signing or I/O. +pub fn prepare_rhi_evidence_attestation( + report: &RadrootsRhiEvidenceReportV1, + created_at: u64, +) -> Result<AuthoredEventPlan, AuthoredPlanError> { + let attestation = RadrootsRhiEvidenceAttestationV1::from_canonical_content( + report.canonical_content().as_bytes(), + ) + .map_err(AuthoredPlanError::Rhi)?; + AuthoredEventPlan::from_rhi_evidence_attestation(&attestation, created_at) +} + +/// Verifies NIP-01 identity/signature and then validates the exact RHI event. +pub fn validate_rhi_evidence_attestation( + event: radroots_event::envelope::EventEnvelope, +) -> Result<RadrootsRhiEvidenceAttestationV1, EvidenceAttestationValidationError> { + let verified = + radroots_event_codec::verify::id(radroots_event::admission::RawEvent::new(event)) + .and_then(|event| { + radroots_event_codec::verify::signature( + event, + &radroots_event_codec::verify::Nip01SignatureVerifier, + ) + }) + .map_err(|_| EvidenceAttestationValidationError::Signature)?; + radroots_event_codec::decode::rhi::rhi_evidence_attestation_from_verified_event(&verified) + .map_err(|_| EvidenceAttestationValidationError::Contract) +} + +/// Stable, value-free failure for SDK signed-attestation validation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EvidenceAttestationValidationError { + /// NIP-01 event identity or signature verification failed. + Signature, + /// The verified event does not satisfy the RHI attestation contract. + Contract, +} + +impl fmt::Display for EvidenceAttestationValidationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Signature => "evidence attestation signature validation failed", + Self::Contract => "evidence attestation contract validation failed", + }) + } +} + +impl error::Error for EvidenceAttestationValidationError {} + /// Pure inputs for one frozen trade command. #[derive(Clone, Debug)] pub struct PrepareRequest { @@ -391,6 +473,22 @@ mod tests { .expect("actor") } + fn rhi_attestation_fixture() -> serde_json::Value { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../contracts/conformance/vectors/event/authored_operations.v1.json" + )) + .expect("authored corpus"); + fixture["vectors"] + .as_array() + .expect("operations") + .iter() + .find(|entry| entry["id"] == "typed_rhi_evidence_attestation_017") + .expect("RHI operation") + .get("expected") + .expect("expected") + .clone() + } + fn mutation_id(marker: char) -> MutationId { MutationId::parse(std::iter::repeat_n(marker, 64).collect::<String>()).expect("mutation id") } @@ -666,6 +764,67 @@ mod tests { assert!(!projection.projection_digest().is_empty()); } + #[test] + fn evidence_adapters_parse_plan_and_verify_the_authored_corpus_event() { + let expected = rhi_attestation_fixture(); + let content = expected["content"].as_str().expect("content"); + let report = parse_rhi_evidence_report(content.as_bytes()).expect("report"); + assert_eq!( + report.outcome(), + RadrootsTradeEvidenceOutcomeV1::Indeterminate + ); + + let plan = prepare_rhi_evidence_attestation(&report, 1_784_347_200).expect("plan"); + assert_eq!(plan.body().kind(), 3_441); + assert_eq!( + plan.expected_event_id().to_hex(), + expected["event_id"].as_str().expect("event id") + ); + + let raw: serde_json::Value = + serde_json::from_str(expected["raw_json"].as_str().expect("raw event")) + .expect("raw event JSON"); + let event = radroots_event::envelope::EventEnvelope::new( + radroots_event::envelope::EventEnvelopeParts { + id: raw["id"].as_str().expect("id").to_owned(), + author: raw["pubkey"].as_str().expect("pubkey").to_owned(), + created_at: raw["created_at"].as_u64().expect("created_at"), + kind: u32::try_from(raw["kind"].as_u64().expect("kind")).expect("u32 kind"), + tags: serde_json::from_value(raw["tags"].clone()).expect("tags"), + content: raw["content"].as_str().expect("content").to_owned(), + sig: raw["sig"].as_str().expect("signature").to_owned(), + }, + ) + .expect("event"); + let attestation = validate_rhi_evidence_attestation(event).expect("attestation"); + assert_eq!(attestation.trade_generation().get(), 7); + } + + #[test] + fn evidence_validation_error_is_stable_and_value_free() { + let expected = rhi_attestation_fixture(); + let raw: serde_json::Value = + serde_json::from_str(expected["raw_json"].as_str().expect("raw event")) + .expect("raw event JSON"); + let event = radroots_event::envelope::EventEnvelope::new( + radroots_event::envelope::EventEnvelopeParts { + id: raw["id"].as_str().expect("id").to_owned(), + author: raw["pubkey"].as_str().expect("pubkey").to_owned(), + created_at: raw["created_at"].as_u64().expect("created_at"), + kind: u32::try_from(raw["kind"].as_u64().expect("kind")).expect("u32 kind"), + tags: serde_json::from_value(raw["tags"].clone()).expect("tags"), + content: "private-tamper".to_owned(), + sig: raw["sig"].as_str().expect("signature").to_owned(), + }, + ) + .expect("event"); + let error = validate_rhi_evidence_attestation(event).expect_err("signature mismatch"); + assert_eq!(error, EvidenceAttestationValidationError::Signature); + assert!(std::error::Error::source(&error).is_none()); + assert!(!error.to_string().contains("private-tamper")); + assert!(!format!("{error:?}").contains("private-tamper")); + } + #[cfg(all(feature = "sync", feature = "memory", feature = "local-signing"))] mod operations { use std::sync::{ diff --git a/crates/sdk/tests/package_boundary.rs b/crates/sdk/tests/package_boundary.rs @@ -376,6 +376,34 @@ fn trade_planning_uses_only_the_typed_mutation_authoring_boundary() { } #[test] +fn trade_evidence_surface_is_curated_and_signature_verified() { + let production = TRADE + .split_once("#[cfg(test)]") + .expect("trade tests remain separated") + .0; + for required in [ + "pub use radroots_trade::evidence::{", + "pub fn parse_evidence_manifest(", + "pub fn parse_rhi_evidence_report(", + "pub fn prepare_rhi_evidence_attestation(", + "pub fn validate_rhi_evidence_attestation(", + "Nip01SignatureVerifier", + "rhi_evidence_attestation_from_verified_event", + ] { + assert!( + production.contains(required), + "missing trade evidence boundary `{required}`" + ); + } + for forbidden in ["SecretKey", "sign_event", "tokio::spawn", "publish_event("] { + assert!( + !production.contains(forbidden), + "trade evidence surface gained forbidden authority `{forbidden}`" + ); + } +} + +#[test] fn farm_operations_preserve_pure_planning_commit_and_privacy_boundaries() { for required in [ "encode::farm::to_wire_parts", diff --git a/crates/sdk_ffi/Cargo.toml b/crates/sdk_ffi/Cargo.toml @@ -19,11 +19,15 @@ name = "radroots-sdk-bindgen" path = "src/bin/bindgen.rs" [dependencies] +hex = { workspace = true } +radroots_event = { workspace = true, features = ["std"] } +radroots_event_codec = { workspace = true, features = ["json", "std"] } radroots_sdk = { workspace = true, features = ["memory"] } thiserror = { workspace = true } uniffi = { workspace = true, features = ["cli"] } [dev-dependencies] +serde_json = { workspace = true } tokio = { workspace = true, features = ["macros", "rt"] } [lints] diff --git a/crates/sdk_ffi/src/lib.rs b/crates/sdk_ffi/src/lib.rs @@ -62,6 +62,313 @@ pub mod v1 { } } + impl Error { + fn contract(code: &'static str, message: &'static str) -> Self { + Self::Sdk { + code: code.to_owned(), + message: message.to_owned(), + retryable: false, + } + } + } + + /// Final evidence coverage vocabulary. + #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] + pub enum TradeEvidenceCoverage { + Missing, + Partial, + ScopeSatisfied, + Unsupported, + } + + /// Final evidence outcome vocabulary. + #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] + pub enum TradeEvidenceOutcome { + Valid, + Invalid, + Indeterminate, + } + + /// Bounded projection of one canonical evidence manifest. + #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] + pub struct TradeEvidenceManifest { + pub contract_id: String, + pub contract_version: u16, + pub trade_id: String, + pub trade_generation: String, + pub observed_at_unix_s: String, + pub coverage: TradeEvidenceCoverage, + pub evidence_policy_digest: String, + pub manifest_digest: String, + pub canonical_bytes_hex: String, + } + + /// Exact immutable RHI supersession reference. + #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] + pub struct RhiEvidenceSupersession { + pub report_id: String, + pub event_id: String, + } + + /// Bounded projection of one canonical RHI evidence report. + #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] + pub struct RhiEvidenceReport { + pub contract_id: String, + pub contract_version: u16, + pub issuer_pubkey: String, + pub trade_id: String, + pub claim_mutation_id: String, + pub outcome: TradeEvidenceOutcome, + pub reason_codes: Vec<String>, + pub projection_digest: String, + pub evidence_manifest_digest: String, + pub evidence_policy_digest: String, + pub observed_at_unix_s: String, + pub trade_generation: String, + pub statement_digest: String, + pub supersession: Option<RhiEvidenceSupersession>, + pub canonical_content: String, + } + + /// One unsigned typed event plan ready for host-owned signing. + #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] + pub struct TypedEvidenceEventPlan { + pub contract_id: String, + pub kind: u32, + pub author_pubkey: String, + pub created_at_unix_s: String, + pub expected_event_id: String, + pub tags: Vec<Vec<String>>, + pub content: String, + } + + /// Signed NIP-01 event input for verified attestation admission. + #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] + pub struct SignedEvent { + pub id: String, + pub author_pubkey: String, + pub created_at_unix_s: u64, + pub kind: u32, + pub tags: Vec<Vec<String>>, + pub content: String, + pub signature: String, + } + + /// Verified final RHI attestation projection. + #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] + pub struct RhiEvidenceAttestation { + pub issuer_pubkey: String, + pub trade_id: String, + pub claim_mutation_id: String, + pub outcome: TradeEvidenceOutcome, + pub observed_at_unix_s: String, + pub trade_generation: String, + pub statement_digest: String, + pub supersession: Option<RhiEvidenceSupersession>, + pub canonical_content: String, + } + + #[uniffi::export] + pub fn parse_trade_evidence_manifest( + canonical_bytes: Vec<u8>, + ) -> Result<TradeEvidenceManifest, Error> { + let manifest = + radroots_sdk::trade::parse_evidence_manifest(&canonical_bytes).map_err(|_| { + Error::contract("invalid_evidence_manifest", "evidence manifest is invalid") + })?; + Ok(TradeEvidenceManifest::from(&manifest)) + } + + #[uniffi::export] + pub fn parse_rhi_evidence_report( + canonical_content: String, + ) -> Result<RhiEvidenceReport, Error> { + let report = radroots_sdk::trade::parse_rhi_evidence_report(canonical_content.as_bytes()) + .map_err(|_| { + Error::contract("invalid_evidence_report", "evidence report is invalid") + })?; + Ok(RhiEvidenceReport::from(&report)) + } + + #[uniffi::export] + pub fn prepare_rhi_evidence_attestation( + canonical_content: String, + created_at_unix_s: u64, + ) -> Result<TypedEvidenceEventPlan, Error> { + let report = radroots_sdk::trade::parse_rhi_evidence_report(canonical_content.as_bytes()) + .map_err(|_| { + Error::contract("invalid_evidence_report", "evidence report is invalid") + })?; + let plan = + radroots_sdk::trade::prepare_rhi_evidence_attestation(&report, created_at_unix_s) + .map_err(|_| { + Error::contract( + "invalid_evidence_attestation_plan", + "evidence attestation plan is invalid", + ) + })?; + Ok(TypedEvidenceEventPlan::from(&plan)) + } + + #[uniffi::export] + pub fn validate_rhi_evidence_attestation( + event: SignedEvent, + ) -> Result<RhiEvidenceAttestation, Error> { + let event = radroots_event::envelope::EventEnvelope::new( + radroots_event::envelope::EventEnvelopeParts { + id: event.id, + author: event.author_pubkey, + created_at: event.created_at_unix_s, + kind: event.kind, + tags: event.tags, + content: event.content, + sig: event.signature, + }, + ) + .map_err(|_| Error::contract("invalid_signed_event", "signed event is invalid"))?; + let attestation = + radroots_sdk::trade::validate_rhi_evidence_attestation(event).map_err(|error| { + match error { + radroots_sdk::trade::EvidenceAttestationValidationError::Signature => { + Error::contract( + "invalid_event_signature", + "event signature validation failed", + ) + } + radroots_sdk::trade::EvidenceAttestationValidationError::Contract => { + Error::contract( + "invalid_evidence_attestation", + "evidence attestation is invalid", + ) + } + } + })?; + Ok(RhiEvidenceAttestation::from(&attestation)) + } + + impl From<&radroots_sdk::trade::RadrootsTradeEvidenceManifestV1> for TradeEvidenceManifest { + fn from(value: &radroots_sdk::trade::RadrootsTradeEvidenceManifestV1) -> Self { + Self { + contract_id: value.contract_id().to_owned(), + contract_version: value.contract_version(), + trade_id: value.trade_id().to_string(), + trade_generation: value.trade_generation().get().to_string(), + observed_at_unix_s: value.observed_at_unix_s().to_string(), + coverage: value.coverage().into(), + evidence_policy_digest: value.evidence_policy_digest().to_hex(), + manifest_digest: value.digest().to_hex(), + canonical_bytes_hex: hex::encode(value.canonical_bytes()), + } + } + } + + impl From<radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1> for TradeEvidenceCoverage { + fn from(value: radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1) -> Self { + match value { + radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Missing => Self::Missing, + radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Partial => Self::Partial, + radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::ScopeSatisfied => { + Self::ScopeSatisfied + } + radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Unsupported => { + Self::Unsupported + } + } + } + } + + impl From<radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1> for TradeEvidenceOutcome { + fn from(value: radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1) -> Self { + match value { + radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Valid => Self::Valid, + radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Invalid => Self::Invalid, + radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Indeterminate => { + Self::Indeterminate + } + } + } + } + + impl From<&radroots_sdk::trade::RadrootsRhiEvidenceReportV1> for RhiEvidenceReport { + fn from(value: &radroots_sdk::trade::RadrootsRhiEvidenceReportV1) -> Self { + Self { + contract_id: value.contract_id().to_owned(), + contract_version: value.contract_version(), + issuer_pubkey: value.issuer_public_key().to_hex(), + trade_id: value.trade_id().to_string(), + claim_mutation_id: value.claim_mutation_id().to_string(), + outcome: value.outcome().into(), + reason_codes: value + .reason_codes() + .iter() + .map(|code| code.as_str().to_owned()) + .collect(), + projection_digest: value.projection_digest().to_hex(), + evidence_manifest_digest: value.evidence_manifest_digest().to_hex(), + evidence_policy_digest: value.evidence_policy_digest().to_hex(), + observed_at_unix_s: value.observed_at_unix_s().to_string(), + trade_generation: value.trade_generation().get().to_string(), + statement_digest: value.statement_digest().to_hex(), + supersession: value.supersession().map(RhiEvidenceSupersession::from), + canonical_content: value.canonical_content().to_owned(), + } + } + } + + impl From<radroots_sdk::trade::RadrootsRhiEvidenceSupersessionV1> for RhiEvidenceSupersession { + fn from(value: radroots_sdk::trade::RadrootsRhiEvidenceSupersessionV1) -> Self { + Self { + report_id: value.report_id().to_hex(), + event_id: value.event_id().to_hex(), + } + } + } + + impl From<&radroots_event_codec::authoring::AuthoredEventPlan> for TypedEvidenceEventPlan { + fn from(value: &radroots_event_codec::authoring::AuthoredEventPlan) -> Self { + Self { + contract_id: value.body().contract().contract_id().as_str().to_owned(), + kind: value.body().kind(), + author_pubkey: value.author().to_hex(), + created_at_unix_s: value.created_at().to_string(), + expected_event_id: value.expected_event_id().to_hex(), + tags: value.body().tags().to_vec(), + content: value.body().content().to_owned(), + } + } + } + + impl From<&radroots_sdk::trade::RadrootsRhiEvidenceAttestationV1> for RhiEvidenceAttestation { + fn from(value: &radroots_sdk::trade::RadrootsRhiEvidenceAttestationV1) -> Self { + Self { + issuer_pubkey: value.issuer().to_hex(), + trade_id: value.trade_id().to_string(), + claim_mutation_id: value.claim_mutation_id().to_string(), + outcome: match value.outcome() { + radroots_sdk::trade::RadrootsRhiEvidenceAttestationOutcomeV1::Valid => { + TradeEvidenceOutcome::Valid + } + radroots_sdk::trade::RadrootsRhiEvidenceAttestationOutcomeV1::Invalid => { + TradeEvidenceOutcome::Invalid + } + radroots_sdk::trade::RadrootsRhiEvidenceAttestationOutcomeV1::Indeterminate => { + TradeEvidenceOutcome::Indeterminate + } + }, + observed_at_unix_s: value.observed_at_unix_s().to_string(), + trade_generation: value.trade_generation().get().to_string(), + statement_digest: hex::encode(value.statement_digest()), + supersession: value + .supersession() + .map(|supersession| RhiEvidenceSupersession { + report_id: hex::encode(supersession.report_id()), + event_id: supersession.event_id().to_hex(), + }), + canonical_content: value.canonical_content().to_owned(), + } + } + } + /// Thread-safe mobile handle delegating lifecycle to [`radroots_sdk::Client`]. #[derive(uniffi::Object)] pub struct MobileClient { @@ -132,7 +439,11 @@ pub mod v1 { #[cfg(test)] mod tests { - use super::v1::{CapabilityAvailability, Error, MobileClient}; + use super::v1::{ + CapabilityAvailability, Error, MobileClient, SignedEvent, TradeEvidenceOutcome, + parse_rhi_evidence_report, prepare_rhi_evidence_attestation, + validate_rhi_evidence_attestation, + }; fn assert_send_sync<T: Send + Sync>() {} @@ -170,4 +481,62 @@ mod tests { assert_eq!(message, "SDK storage capability is not configured"); assert!(!retryable); } + + #[test] + fn final_report_plan_and_signed_event_cross_the_ffi_without_numeric_narrowing() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../contracts/conformance/vectors/event/authored_operations.v1.json" + )) + .expect("authored corpus"); + let expected = fixture["vectors"] + .as_array() + .expect("operations") + .iter() + .find(|entry| entry["id"] == "typed_rhi_evidence_attestation_017") + .expect("RHI operation") + .get("expected") + .expect("expected"); + let content = expected["content"].as_str().expect("content").to_owned(); + + let report = parse_rhi_evidence_report(content.clone()).expect("report"); + assert_eq!(report.outcome, TradeEvidenceOutcome::Indeterminate); + assert_eq!(report.trade_generation, "7"); + assert_eq!(report.observed_at_unix_s, "1800000000"); + assert_eq!(report.canonical_content, content); + + let plan = prepare_rhi_evidence_attestation(content, 1_784_347_200).expect("plan"); + assert_eq!(plan.kind, 3_441); + assert_eq!(plan.created_at_unix_s, "1784347200"); + assert_eq!( + plan.expected_event_id, + expected["event_id"].as_str().expect("expected event id") + ); + + let raw: serde_json::Value = + serde_json::from_str(expected["raw_json"].as_str().expect("raw event")) + .expect("raw event JSON"); + let signed = SignedEvent { + id: raw["id"].as_str().expect("id").to_owned(), + author_pubkey: raw["pubkey"].as_str().expect("pubkey").to_owned(), + created_at_unix_s: raw["created_at"].as_u64().expect("created_at"), + kind: u32::try_from(raw["kind"].as_u64().expect("kind")).expect("u32 kind"), + tags: serde_json::from_value(raw["tags"].clone()).expect("tags"), + content: raw["content"].as_str().expect("content").to_owned(), + signature: raw["sig"].as_str().expect("signature").to_owned(), + }; + let attestation = validate_rhi_evidence_attestation(signed).expect("signed attestation"); + assert_eq!(attestation.outcome, TradeEvidenceOutcome::Indeterminate); + assert_eq!(attestation.trade_generation, "7"); + } + + #[test] + fn ffi_contract_errors_are_bounded_and_secret_safe() { + let secret = "private-report-content"; + let error = parse_rhi_evidence_report(secret.to_owned()).expect_err("malformed report"); + let display = error.to_string(); + let debug = format!("{error:?}"); + assert!(!display.contains(secret)); + assert!(!debug.contains(secret)); + assert!(display.contains("invalid_evidence_report")); + } } diff --git a/crates/trade_bindings/src/lib.rs b/crates/trade_bindings/src/lib.rs @@ -23,6 +23,7 @@ pub struct TradeTypeInventoryEntry { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum TradeLargeIntegerPolicy { JsonNumberSafeCount, + DecimalStringU64, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -46,6 +47,13 @@ pub const TRADE_TYPE_INVENTORY: &[TradeTypeInventoryEntry] = &[ source_root("RadrootsTradeAttestationStateV1"), source_root("RadrootsTradeConflictStateV1"), source_root("RadrootsTradeEvidenceStateV1"), + source_root("RadrootsTradeEvidenceCoverageV1"), + source_root("RadrootsTradeEvidenceOutcomeV1"), + source_root("RadrootsTradeEvidenceManifestV1"), + source_root("RadrootsRhiEvidenceSupersessionV1"), + source_root("RadrootsRhiEvidenceReportV1"), + source_root("RadrootsRhiEvidenceAttestationV1"), + local_shape("RadrootsTypedEvidenceEventPlanV1"), source_root("RadrootsTradeFulfillmentStateV1"), source_root("RadrootsOperationalListingTradeProjection"), local_shape("RadrootsTradeListingBackofficeOverlay"), @@ -75,6 +83,13 @@ pub const TRADE_TYPE_INVENTORY: &[TradeTypeInventoryEntry] = &[ ]; pub const TRADE_LARGE_INTEGER_POLICIES: &[TradeLargeIntegerPolicyEntry] = &[ + decimal_string_u64("RadrootsTradeEvidenceManifestV1", "trade_generation"), + decimal_string_u64("RadrootsTradeEvidenceManifestV1", "observed_at_unix_s"), + decimal_string_u64("RadrootsRhiEvidenceReportV1", "trade_generation"), + decimal_string_u64("RadrootsRhiEvidenceReportV1", "observed_at_unix_s"), + decimal_string_u64("RadrootsRhiEvidenceAttestationV1", "trade_generation"), + decimal_string_u64("RadrootsRhiEvidenceAttestationV1", "observed_at_unix_s"), + decimal_string_u64("RadrootsTypedEvidenceEventPlanV1", "created_at_unix_s"), json_number_safe_count("RadrootsTradeFacetCount", "count"), json_number_safe_count( "RadrootsTradeListingBackofficeView", @@ -127,6 +142,18 @@ const fn json_number_safe_count( } } +#[cfg_attr(coverage_nightly, coverage(off))] +const fn decimal_string_u64( + type_name: &'static str, + field_name: &'static str, +) -> TradeLargeIntegerPolicyEntry { + TradeLargeIntegerPolicyEntry { + type_name, + field_name, + policy: TradeLargeIntegerPolicy::DecimalStringU64, + } +} + /// Validates that the checked-in trade binding inventory is internally coherent. #[must_use] pub fn inventory_is_valid() -> bool { @@ -213,6 +240,13 @@ mod tests { "RadrootsTradeAttestationStateV1", "RadrootsTradeConflictStateV1", "RadrootsTradeEvidenceStateV1", + "RadrootsTradeEvidenceCoverageV1", + "RadrootsTradeEvidenceOutcomeV1", + "RadrootsTradeEvidenceManifestV1", + "RadrootsRhiEvidenceSupersessionV1", + "RadrootsRhiEvidenceReportV1", + "RadrootsRhiEvidenceAttestationV1", + "RadrootsTypedEvidenceEventPlanV1", "RadrootsTradeFulfillmentStateV1", "RadrootsOperationalListingTradeProjection", "RadrootsTradeListingBackofficeOverlay", @@ -277,6 +311,12 @@ mod tests { "RadrootsTradeAttestationStateV1", "RadrootsTradeConflictStateV1", "RadrootsTradeEvidenceStateV1", + "RadrootsTradeEvidenceCoverageV1", + "RadrootsTradeEvidenceOutcomeV1", + "RadrootsTradeEvidenceManifestV1", + "RadrootsRhiEvidenceSupersessionV1", + "RadrootsRhiEvidenceReportV1", + "RadrootsRhiEvidenceAttestationV1", "RadrootsTradeFulfillmentStateV1", "RadrootsOperationalListingTradeProjection", "RadrootsOperationalListingSubtotal", @@ -309,6 +349,41 @@ mod tests { actual, [ ( + "RadrootsTradeEvidenceManifestV1", + "trade_generation", + super::TradeLargeIntegerPolicy::DecimalStringU64 + ), + ( + "RadrootsTradeEvidenceManifestV1", + "observed_at_unix_s", + super::TradeLargeIntegerPolicy::DecimalStringU64 + ), + ( + "RadrootsRhiEvidenceReportV1", + "trade_generation", + super::TradeLargeIntegerPolicy::DecimalStringU64 + ), + ( + "RadrootsRhiEvidenceReportV1", + "observed_at_unix_s", + super::TradeLargeIntegerPolicy::DecimalStringU64 + ), + ( + "RadrootsRhiEvidenceAttestationV1", + "trade_generation", + super::TradeLargeIntegerPolicy::DecimalStringU64 + ), + ( + "RadrootsRhiEvidenceAttestationV1", + "observed_at_unix_s", + super::TradeLargeIntegerPolicy::DecimalStringU64 + ), + ( + "RadrootsTypedEvidenceEventPlanV1", + "created_at_unix_s", + super::TradeLargeIntegerPolicy::DecimalStringU64 + ), + ( "RadrootsTradeFacetCount", "count", super::TradeLargeIntegerPolicy::JsonNumberSafeCount diff --git a/crates/trade_bindings/tests/fixtures/generated_types.ts b/crates/trade_bindings/tests/fixtures/generated_types.ts @@ -23,6 +23,20 @@ export type RadrootsTradeConflictStateV1 = "none" | "concurrent_candidates" | "d export type RadrootsTradeEvidenceStateV1 = "complete" | "missing" | "query_partial" | "unsupported_version"; +export type RadrootsTradeEvidenceCoverageV1 = "missing" | "partial" | "scope_satisfied" | "unsupported"; + +export type RadrootsTradeEvidenceOutcomeV1 = "valid" | "invalid" | "indeterminate"; + +export type RadrootsTradeEvidenceManifestV1 = { contract_id: "radroots.trade.evidence-manifest.v1", contract_version: 1, trade_id: string, trade_generation: string, observed_at_unix_s: string, coverage: RadrootsTradeEvidenceCoverageV1, evidence_policy_digest: string, manifest_digest: string, canonical_bytes_hex: string, }; + +export type RadrootsRhiEvidenceSupersessionV1 = { report_id: string, event_id: string, }; + +export type RadrootsRhiEvidenceReportV1 = { contract_id: "radroots.rhi.evidence_attestation.v1", contract_version: 1, issuer_pubkey: string, trade_id: string, claim_mutation_id: string, outcome: RadrootsTradeEvidenceOutcomeV1, reason_codes: Array<string>, projection_digest: string, evidence_manifest_digest: string, evidence_policy_digest: string, observed_at_unix_s: string, trade_generation: string, statement_digest: string, supersession?: RadrootsRhiEvidenceSupersessionV1 | null, canonical_content: string, }; + +export type RadrootsRhiEvidenceAttestationV1 = { issuer_pubkey: string, trade_id: string, claim_mutation_id: string, outcome: RadrootsTradeEvidenceOutcomeV1, observed_at_unix_s: string, trade_generation: string, statement_digest: string, supersession?: RadrootsRhiEvidenceSupersessionV1 | null, canonical_content: string, }; + +export type RadrootsTypedEvidenceEventPlanV1 = { contract_id: string, kind: number, author_pubkey: string, created_at_unix_s: string, expected_event_id: string, tags: Array<Array<string>>, content: string, }; + export type RadrootsTradeFacetCount = { key: string, count: number, }; export type RadrootsTradeFulfillmentStateV1 = "not_started"; diff --git a/tools/xtask/src/sdk_generation/dto_roots.rs b/tools/xtask/src/sdk_generation/dto_roots.rs @@ -493,6 +493,13 @@ mod tests { "RadrootsTradeAttestationStateV1", "RadrootsTradeConflictStateV1", "RadrootsTradeEvidenceStateV1", + "RadrootsTradeEvidenceCoverageV1", + "RadrootsTradeEvidenceOutcomeV1", + "RadrootsTradeEvidenceManifestV1", + "RadrootsRhiEvidenceSupersessionV1", + "RadrootsRhiEvidenceReportV1", + "RadrootsRhiEvidenceAttestationV1", + "RadrootsTypedEvidenceEventPlanV1", "RadrootsTradeFacetCount", "RadrootsTradeFulfillmentStateV1", "RadrootsTradeListingBackofficeOverlay", diff --git a/tools/xtask/src/sdk_generation/output.rs b/tools/xtask/src/sdk_generation/output.rs @@ -67,7 +67,7 @@ const EVENT_BINDINGS_TYPES_SHA256: &str = const TRADE_BINDINGS_TYPES_TS: &str = include_str!("../../../../crates/trade_bindings/tests/fixtures/generated_types.ts"); const TRADE_BINDINGS_TYPES_SHA256: &str = - "49560c572206095bd7de5cfd377c234a7f136e4affc13d53516ae2c4b8998e87"; + "66e6a378231e5341a59cf331b23b3164b27a43fa00411e889d634e4d9d19b53d"; const EVENT_NATIVE_SOURCES: &[&str] = &["radroots_event", "radroots_core"]; const TRADE_NATIVE_SOURCES: &[&str] = &["radroots_trade", "radroots_event", "radroots_core"]; diff --git a/tools/xtask/src/sdk_generation/wasm_declarations.rs b/tools/xtask/src/sdk_generation/wasm_declarations.rs @@ -26,6 +26,7 @@ struct WasmDeclarationInventory { struct WasmPublicFunction { name: &'static str, parameter: Option<&'static str>, + bigint_parameter: Option<&'static str>, return_kind: WasmReturnKind, low_level: LowLevelSignature, } @@ -39,7 +40,8 @@ enum WasmReturnKind { #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct LowLevelSignature { - parameters: usize, + number_parameters: usize, + bigint_parameters: usize, returns: usize, } @@ -62,9 +64,27 @@ macro_rules! public_function { WasmPublicFunction { name: $name, parameter: $parameter, + bigint_parameter: None, return_kind: WasmReturnKind::$return_kind, low_level: LowLevelSignature { - parameters: $low_parameters, + number_parameters: $low_parameters, + bigint_parameters: 0, + returns: $low_returns, + }, + } + }; +} + +macro_rules! public_function_with_bigint { + ($name:literal, $parameter:literal, $bigint_parameter:literal, $return_kind:ident, $low_number_parameters:literal, $low_bigint_parameters:literal, $low_returns:literal) => { + WasmPublicFunction { + name: $name, + parameter: Some($parameter), + bigint_parameter: Some($bigint_parameter), + return_kind: WasmReturnKind::$return_kind, + low_level: LowLevelSignature { + number_parameters: $low_number_parameters, + bigint_parameters: $low_bigint_parameters, returns: $low_returns, }, } @@ -94,7 +114,8 @@ macro_rules! void_function_export { WasmSupportExport { name: $name, export_type: WasmSupportExportType::Function(LowLevelSignature { - parameters: $parameters, + number_parameters: $parameters, + bigint_parameters: 0, returns: 0, }), } @@ -161,7 +182,7 @@ fn primary_module(spec: WasmPackageSpec, inventory: WasmDeclarationInventory) -> for function in inventory.public_functions { module.push_declaration(TypeScriptDeclaration::function( function.name, - public_parameters(function.parameter), + public_parameters(function.parameter, function.bigint_parameter), public_return_type(function.return_kind), )); } @@ -243,10 +264,20 @@ fn render_declaration_module(module: TypeScriptModule) -> String { rendered } -fn public_parameters(parameter: Option<&'static str>) -> Vec<TypeScriptParameter> { - parameter +fn public_parameters( + parameter: Option<&'static str>, + bigint_parameter: Option<&'static str>, +) -> Vec<TypeScriptParameter> { + let mut parameters = parameter .map(|name| vec![TypeScriptParameter::new(name, TypeScriptType::String)]) - .unwrap_or_default() + .unwrap_or_default(); + if let Some(name) = bigint_parameter { + parameters.push(TypeScriptParameter::new( + name, + TypeScriptType::named("bigint"), + )); + } + parameters } fn public_return_type(kind: WasmReturnKind) -> TypeScriptType { @@ -320,10 +351,11 @@ fn low_level_number_function_type(parameters: usize) -> TypeScriptType { } fn low_level_function_type(signature: LowLevelSignature) -> TypeScriptType { - TypeScriptType::function( - number_parameters(signature.parameters), - low_level_return_type(signature.returns), - ) + let mut parameters = number_parameters(signature.number_parameters); + parameters.extend((0..signature.bigint_parameters).map(|index| { + TypeScriptParameter::new(format!("i64_arg{index}"), TypeScriptType::named("bigint")) + })); + TypeScriptType::function(parameters, low_level_return_type(signature.returns)) } fn number_parameters(count: usize) -> Vec<TypeScriptParameter> { @@ -433,6 +465,36 @@ const EVENT_CODEC_FUNCTIONS: &[WasmPublicFunction] = &[ public_function!("repost_tags", Some("repost_json"), String, 2, 4), public_function!("seal_tags", Some("seal_json"), String, 2, 4), public_function!( + "trade_evidence_manifest_parse_json", + Some("canonical_bytes_hex"), + String, + 2, + 4 + ), + public_function!( + "rhi_evidence_report_parse_json", + Some("canonical_content"), + String, + 2, + 4 + ), + public_function_with_bigint!( + "rhi_evidence_attestation_build_draft", + "canonical_content", + "created_at_unix_s", + String, + 2, + 1, + 4 + ), + public_function!( + "rhi_evidence_attestation_validate_signed_json", + Some("event_json"), + String, + 2, + 4 + ), + public_function!( "social_ask_build_authored_draft", Some("input_json"), String, @@ -1081,6 +1143,25 @@ mod tests { .contains(&format!("export declare const {authored}")) ); } + for evidence in [ + "trade_evidence_manifest_parse_json", + "rhi_evidence_report_parse_json", + "rhi_evidence_attestation_build_draft", + "rhi_evidence_attestation_validate_signed_json", + ] { + assert!( + events[0] + .contents + .contains(&format!("export function {evidence}")) + ); + assert!( + events[1] + .contents + .contains(&format!("export declare const {evidence}")) + ); + } + assert!(events[0].contents.contains("created_at_unix_s: bigint")); + assert!(events[1].contents.contains("i64_arg0: bigint")); for retired in [ "calendar_date_event_tags", "calendar_event_rsvp_tags",