field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit a8643aba23e5d44707d4acfc71ca5f05ccd78d52
parent 36b13dc05ca8bcfc6be1da6846d1bd22558ae994
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:
Mcore/crates/tera_ffi/src/dto.rs | 312+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/tera_ffi/tests/uniffi_contract.rs | 30+++++++++++++++++++++++++++---
2 files changed, 339 insertions(+), 3 deletions(-)

diff --git a/core/crates/tera_ffi/src/dto.rs b/core/crates/tera_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/core/crates/tera_ffi/tests/uniffi_contract.rs b/core/crates/tera_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();