commit 50d084db20365d446b6c989ad5c0417129b102eb
parent d7c023544deb3310ef4d7a177bf527c9a45d0b27
Author: triesap <tyson@radroots.org>
Date: Sat, 8 Aug 2026 07:31:28 +0000
mobile: prove local MVP protocol flows
Diffstat:
14 files changed, 1146 insertions(+), 11 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -3594,6 +3594,9 @@ version = "0.1.0-alpha"
dependencies = [
"async-trait",
"hex",
+ "nostr 0.44.1",
+ "nostr-relay-builder",
+ "nostr-sdk 0.44.0",
"radroots_blossom",
"radroots_event",
"radroots_mobile_core",
diff --git a/crates/mobile_core/src/runtime/product_surface/model.rs b/crates/mobile_core/src/runtime/product_surface/model.rs
@@ -197,6 +197,12 @@ pub struct ClassifiedCard {
pub price_unit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quantity: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub food_summary: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub food_published_at: Option<u64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub food_status: Option<String>,
pub context_rank: ContextRank,
pub inclusion_reason: String,
pub media: Vec<MediaReference>,
diff --git a/crates/mobile_core/src/runtime/product_surface/outbox.rs b/crates/mobile_core/src/runtime/product_surface/outbox.rs
@@ -100,6 +100,8 @@ pub struct Phase1DraftFormSnapshot {
pub currency: Option<String>,
pub unit: Option<String>,
pub quantity: Option<String>,
+ #[serde(default)]
+ pub food_published_at_unix_s: Option<u64>,
pub food_status: Option<String>,
pub media: Vec<Phase1DraftMediaSnapshot>,
}
@@ -1730,7 +1732,9 @@ fn aggregate_state(draft: &AuthoredDraft, push: Option<&PushStatus>) -> Phase1Ou
return Phase1OutboxState::Complete;
}
if push.settlement().has_failures() {
- return if push.settlement().retryable() != 0 || push.settlement().delivery_retryable() != 0
+ return if has_delivery_success(push) {
+ Phase1OutboxState::PartiallyDelivered
+ } else if push.settlement().retryable() != 0 || push.settlement().delivery_retryable() != 0
{
Phase1OutboxState::Retryable
} else if push.settlement().cancelled() != 0 || push.settlement().delivery_cancelled() != 0
@@ -1919,6 +1923,7 @@ mod tests {
currency: None,
unit: None,
quantity: None,
+ food_published_at_unix_s: None,
food_status: None,
media: Vec::new(),
}
diff --git a/crates/mobile_core/src/runtime/product_surface/projection.rs b/crates/mobile_core/src/runtime/product_surface/projection.rs
@@ -165,6 +165,15 @@ fn card(
.then(|| tag_values(&tags, "radroots:quantity"))
.flatten()
.and_then(|values| values.first().cloned());
+ let food_summary = matches!(card_type, TodayCardType::FoodAvailability)
+ .then(|| tag_value(&tags, &["summary"]))
+ .flatten();
+ let food_published_at = matches!(card_type, TodayCardType::FoodAvailability)
+ .then(|| tag_time(&tags, "published_at"))
+ .flatten();
+ let food_status = matches!(card_type, TodayCardType::FoodAvailability)
+ .then(|| tag_value(&tags, &["status"]))
+ .flatten();
let (effective_at, event_start, event_end) = match card_type {
TodayCardType::Event => {
let start = tag_time(&tags, "start").unwrap_or_else(|| event.created_at_u64());
@@ -198,6 +207,9 @@ fn card(
price_currency,
price_unit,
quantity,
+ food_summary,
+ food_published_at,
+ food_status,
context_rank: context.rank,
inclusion_reason: context.reason.to_owned(),
media,
@@ -474,6 +486,9 @@ mod tests {
assert_eq!(food.price_currency.as_deref(), Some("CAD"));
assert_eq!(food.price_unit.as_deref(), Some("lb"));
assert_eq!(food.quantity.as_deref(), Some("12"));
+ assert_eq!(food.food_summary.as_deref(), Some("Fresh bunches"));
+ assert_eq!(food.food_published_at, Some(1_999_999_999));
+ assert_eq!(food.food_status.as_deref(), Some("active"));
}
#[test]
diff --git a/crates/mobile_core/src/runtime/product_surface/today.rs b/crates/mobile_core/src/runtime/product_surface/today.rs
@@ -659,6 +659,16 @@ impl AdmissionPolicy for TodayAdmissionPolicy {
"radroots.mobile.today.v1"
}
+ fn select_contract(
+ &self,
+ event: &radroots_event::admission::SignatureVerifiedEvent,
+ ) -> Option<&'static str> {
+ verify_nip01_event(event.event().clone())
+ .ok()
+ .and_then(|event| admit_verified_event(event).ok())
+ .map(|event| event.contract().id)
+ }
+
fn decide(&self, event: &ContractValidatedEvent) -> AdmissionDecision {
let admitted = verify_nip01_event(event.event().clone())
.ok()
diff --git a/crates/mobile_ffi/Cargo.toml b/crates/mobile_ffi/Cargo.toml
@@ -38,6 +38,9 @@ uniffi = { workspace = true }
rustix = { workspace = true }
[dev-dependencies]
+nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr" }
+nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-relay-builder" }
+nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-sdk" }
secp256k1 = { workspace = true }
tempfile = { workspace = true }
-tokio = { workspace = true, features = ["macros", "rt", "time"] }
+tokio = { workspace = true, features = ["io-util", "macros", "net", "rt-multi-thread", "sync", "time"] }
diff --git a/crates/mobile_ffi/src/dto.rs b/crates/mobile_ffi/src/dto.rs
@@ -419,6 +419,9 @@ pub struct FfiTodayCardRecord {
pub price_currency: Option<String>,
pub price_unit: Option<String>,
pub quantity: Option<String>,
+ pub food_summary: Option<String>,
+ pub food_published_at_unix_s: Option<u64>,
+ pub food_status: Option<String>,
pub context_rank: u8,
pub inclusion_reason: String,
pub media: Vec<FfiMediaReferenceRecord>,
@@ -453,6 +456,9 @@ impl From<TodayCard> for FfiTodayCardRecord {
price_currency: card.price_currency,
price_unit: card.price_unit,
quantity: card.quantity,
+ food_summary: card.food_summary,
+ food_published_at_unix_s: card.food_published_at,
+ food_status: card.food_status,
context_rank: card.context_rank.value(),
inclusion_reason: card.inclusion_reason,
media: card.media.into_iter().map(Into::into).collect(),
@@ -861,6 +867,7 @@ pub struct FfiAddDraftInput {
pub currency: Option<String>,
pub unit: Option<String>,
pub quantity: Option<String>,
+ pub food_published_at_unix_s: Option<u64>,
pub food_status: Option<String>,
pub media: Vec<FfiPreparedMediaInput>,
}
@@ -994,6 +1001,7 @@ impl FfiAddDraftInput {
currency: self.currency.clone(),
unit: self.unit.clone(),
quantity: self.quantity.clone(),
+ food_published_at_unix_s: self.food_published_at_unix_s,
food_status: self.food_status.clone(),
media: self
.media
@@ -1252,8 +1260,10 @@ fn food_command(
.map_err(|_| RadrootsAppError::invalid_argument("invalid_food_title"))?,
summary: FoodText::new(required(input.summary, "food_summary_required")?)
.map_err(|_| RadrootsAppError::invalid_argument("invalid_food_summary"))?,
- published_at: FoodPublishedAt::new(authored_at_unix_s)
- .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_published_at"))?,
+ published_at: FoodPublishedAt::new(
+ input.food_published_at_unix_s.unwrap_or(authored_at_unix_s),
+ )
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_published_at"))?,
location: FoodText::new(required(input.location, "food_location_required")?)
.map_err(|_| RadrootsAppError::invalid_argument("invalid_food_location"))?,
price: FoodPrice::new(
@@ -1461,6 +1471,7 @@ pub struct FfiDraftFormRecord {
pub currency: Option<String>,
pub unit: Option<String>,
pub quantity: Option<String>,
+ pub food_published_at_unix_s: Option<u64>,
pub food_status: Option<String>,
pub media: Vec<FfiDraftFormMediaRecord>,
}
@@ -1489,6 +1500,7 @@ impl From<&Phase1DraftFormSnapshot> for FfiDraftFormRecord {
currency: value.currency.clone(),
unit: value.unit.clone(),
quantity: value.quantity.clone(),
+ food_published_at_unix_s: value.food_published_at_unix_s,
food_status: value.food_status.clone(),
media: value.media.iter().map(Into::into).collect(),
}
@@ -1825,6 +1837,7 @@ mod tests {
currency: None,
unit: None,
quantity: None,
+ food_published_at_unix_s: None,
food_status: None,
media: vec![FfiPreparedMediaInput {
schema_version: MOBILE_FFI_SCHEMA_VERSION,
@@ -1861,6 +1874,7 @@ mod tests {
currency: None,
unit: None,
quantity: None,
+ food_published_at_unix_s: None,
food_status: None,
media: Vec::new(),
}
diff --git a/crates/mobile_ffi/tests/local_mvp_real_io.rs b/crates/mobile_ffi/tests/local_mvp_real_io.rs
@@ -0,0 +1,875 @@
+use std::io::Write;
+use std::os::fd::AsRawFd;
+use std::time::Duration;
+
+use nostr::{EventBuilder, Keys, Kind, Metadata, Tag, Timestamp};
+use nostr_relay_builder::MockRelay;
+use nostr_sdk::Client;
+use radroots_blossom::Sha256;
+use radroots_mobile_ffi::{
+ FfiAddCommandType, FfiAddDraftInput, FfiBlossomUploadInput, FfiCancellationPolicy,
+ FfiEventTimingKind, FfiLocalNetworkRecord, FfiMediaStage, FfiOutboxState,
+ FfiPreparedMediaInput, FfiQueuePolicyRecord, FfiRelaySatisfaction, FfiRetractionDraftInput,
+ FfiTodayCardType, FfiTodayProjectionUpdate, FfiTodayRelaySyncState, HostSigningOutcome,
+ HostSigningRequest, HostSigningResult, MOBILE_FFI_SCHEMA_VERSION, ProtectedDataAvailability,
+ RadrootsHostSigner, RadrootsRuntime, SignerAvailabilityRecord, SignerStatusRecord,
+};
+use secp256k1::{Keypair, Message, Secp256k1, SecretKey};
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::TcpListener;
+
+#[allow(dead_code)]
+mod support;
+
+const AUTHORED_AT: u64 = 1_786_000_000;
+const AS_OF: u64 = 1_786_200_000;
+const FIXTURE_SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000001";
+const REPLY_SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000002";
+
+struct FixtureHostSigner;
+
+#[async_trait::async_trait]
+impl RadrootsHostSigner for FixtureHostSigner {
+ async fn signer_status(&self) -> SignerStatusRecord {
+ SignerStatusRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ availability: SignerAvailabilityRecord::Ready,
+ }
+ }
+
+ async fn sign(&self, request: HostSigningRequest) -> HostSigningResult {
+ let secret = SecretKey::from_slice(&hex::decode(FIXTURE_SECRET).expect("fixture secret"))
+ .expect("valid fixture secret");
+ let keypair = Keypair::from_secret_key(&Secp256k1::new(), &secret);
+ let digest: [u8; 32] = request
+ .event_id_digest
+ .clone()
+ .try_into()
+ .expect("32-byte event digest");
+ assert_eq!(hex::encode(digest), request.expected_event_id);
+ assert_eq!(
+ keypair.x_only_public_key().0.to_string(),
+ request.public_key
+ );
+ let signature = Secp256k1::new()
+ .sign_schnorr_no_aux_rand(&Message::from_digest(digest), &keypair)
+ .to_string();
+ HostSigningResult {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ outcome: HostSigningOutcome::Signed,
+ operation_id: request.operation_id,
+ signer_request_id: request.signer_request_id,
+ public_key: request.public_key,
+ purpose: request.purpose,
+ signature_hex: Some(signature),
+ completed_at_unix_ms: unix_time_ms(),
+ }
+ }
+}
+
+struct BlossomServer {
+ origin: String,
+ task: tokio::task::JoinHandle<()>,
+}
+
+impl BlossomServer {
+ async fn spawn(bytes: Vec<u8>, corrupt_retrieval: bool) -> Self {
+ let listener = TcpListener::bind("127.0.0.1:0")
+ .await
+ .expect("bind Blossom server");
+ let origin = format!("http://{}", listener.local_addr().expect("Blossom address"));
+ let hash = Sha256::digest(bytes.as_slice()).to_string();
+ let blob_url = format!("{origin}/{hash}.png");
+ let descriptor = serde_json::to_vec(&serde_json::json!({
+ "url": blob_url,
+ "sha256": hash,
+ "size": bytes.len(),
+ "type": "image/png",
+ "uploaded": AUTHORED_AT,
+ }))
+ .expect("descriptor JSON");
+ let task = tokio::spawn(async move {
+ let (mut upload, _) = listener.accept().await.expect("accept Blossom upload");
+ let request = read_http_request(&mut upload).await;
+ let request_text = String::from_utf8_lossy(&request);
+ assert!(request_text.starts_with("PUT /upload HTTP/1.1\r\n"));
+ assert!(
+ request_text
+ .to_ascii_lowercase()
+ .contains("authorization: nostr ")
+ );
+ assert!(
+ request_text
+ .to_ascii_lowercase()
+ .contains(format!("x-sha-256: {hash}").as_str())
+ );
+ write_http_response(&mut upload, "application/json", &descriptor).await;
+
+ let (mut retrieval, _) = listener.accept().await.expect("accept Blossom retrieval");
+ let request = read_http_request(&mut retrieval).await;
+ assert!(
+ String::from_utf8_lossy(&request)
+ .starts_with(format!("GET /{hash}.png HTTP/1.1\r\n").as_str())
+ );
+ let mut response_bytes = bytes;
+ if corrupt_retrieval {
+ response_bytes[0] ^= 1;
+ }
+ write_http_response(&mut retrieval, "image/png", &response_bytes).await;
+ });
+ Self { origin, task }
+ }
+
+ async fn finish(self) {
+ self.task.await.expect("Blossom server task");
+ }
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn public_runtime_completes_the_local_mvp_against_real_protocol_services() {
+ let relay = MockRelay::run().await.expect("local NIP-01 relay");
+ let relay_url = relay.url().await.to_string();
+ let image_bytes = png(2, 3);
+ let blossom = BlossomServer::spawn(image_bytes.clone(), false).await;
+ let mut image_file = tempfile::tempfile().expect("media file");
+ image_file.write_all(&image_bytes).expect("write media");
+ let media = prepared_media(&blossom.origin, &image_bytes, &image_file, "Harvest photo");
+
+ let publisher_root = tempfile::tempdir().expect("publisher root");
+ support::prepare(publisher_root.path());
+ let publisher = runtime_with_signer(publisher_root.path()).await;
+ configure_simulator(&publisher, &relay_url, &blossom.origin);
+ let context = local_network(&relay_url);
+
+ let update_id = draft_id(1);
+ let update = publisher
+ .phase1_save_draft(
+ update_id.clone(),
+ add_input(
+ FfiAddCommandType::CreateUpdate,
+ "Equal-time harvest update",
+ None,
+ ),
+ AUTHORED_AT,
+ None,
+ 1_800_000_000_000,
+ )
+ .await
+ .expect("save offline update");
+ let queued_update = queue(
+ &publisher,
+ &update_id,
+ update.revision,
+ &relay_url,
+ false,
+ 1_800_000_000_500,
+ )
+ .await;
+ assert_eq!(queued_update.state, FfiOutboxState::Queued);
+ publisher
+ .shutdown()
+ .await
+ .expect("shutdown before delivery");
+
+ let publisher = runtime_with_signer(publisher_root.path()).await;
+ configure_simulator(&publisher, &relay_url, &blossom.origin);
+ let recovered = publisher
+ .phase1_recover_draft_queue(update_id.clone(), 1_800_000_001_000)
+ .await
+ .expect("recover queued update after restart");
+ assert_eq!(recovered.state, FfiOutboxState::Queued);
+ advance_complete(&publisher, &update_id, recovered.revision).await;
+
+ let flows = [
+ (
+ 2,
+ add_input(
+ FfiAddCommandType::CreatePhotoUpdate,
+ "Equal-time photo harvest",
+ Some(media.clone()),
+ ),
+ ),
+ (
+ 3,
+ add_input(
+ FfiAddCommandType::CreateAsk,
+ "Equal-time ask: who has basil?",
+ None,
+ ),
+ ),
+ (4, event_input("Equal-time Saturday market")),
+ (5, food_input("Equal-time carrots", "today-carrots")),
+ ];
+ for (index, input) in flows {
+ let id = draft_id(index);
+ let saved = publisher
+ .phase1_save_draft(
+ id.clone(),
+ input,
+ AUTHORED_AT,
+ None,
+ 1_800_000_000_000 + u64::from(index),
+ )
+ .await
+ .expect("save authored flow");
+ let ready = if index == 2 {
+ let uploaded = publisher
+ .phase1_upload_draft_media(FfiBlossomUploadInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ draft_id: id.clone(),
+ expected_revision: saved.revision,
+ media: media.clone(),
+ authorization_content: "Upload the exact local harvest image".to_owned(),
+ authorization_created_at_unix_s: AUTHORED_AT,
+ authorization_lifetime_seconds: 300,
+ operation_id: "21".repeat(16),
+ artifact_id: "22".repeat(16),
+ signing_deadline_unix_ms: u64::MAX,
+ signing_cancellation: FfiCancellationPolicy::LocalCooperative,
+ verified_at_unix_ms: 1_800_000_000_100,
+ updated_at_unix_ms: 1_800_000_000_200,
+ })
+ .await
+ .expect("upload and re-fetch exact media");
+ assert_eq!(uploaded.media[0].stage, FfiMediaStage::Verified);
+ uploaded
+ } else {
+ saved
+ };
+ let queued = queue(
+ &publisher,
+ &id,
+ ready.revision,
+ &relay_url,
+ false,
+ 1_800_000_000_500 + u64::from(index),
+ )
+ .await;
+ advance_complete(&publisher, &id, queued.revision).await;
+ }
+ blossom.finish().await;
+
+ let reader_root = tempfile::tempdir().expect("fresh reader root");
+ support::prepare(reader_root.path());
+ let reader = RadrootsRuntime::new(
+ reader_root.path().to_string_lossy().into_owned(),
+ support::PUBLIC_KEY.to_owned(),
+ support::GENERATION.to_owned(),
+ 1_800_000_000_000,
+ ProtectedDataAvailability::Available,
+ )
+ .await
+ .expect("fresh reader runtime");
+ reader
+ .configure_simulator_relays(vec![relay_url.clone()])
+ .expect("reader relay profile");
+ let first_sync = reader
+ .phase1_sync_today(context.clone(), AS_OF, FfiTodayProjectionUpdate::Rebuild)
+ .await
+ .expect("fresh relay re-read");
+ assert_eq!(first_sync.relay_state, FfiTodayRelaySyncState::Complete);
+ assert_eq!(first_sync.events_admitted, 5);
+
+ let cards = collect_pages(&reader, &context, 2, AS_OF).await;
+ assert_eq!(cards.len(), 5, "all equal-time cards survive frozen paging");
+ let mut card_types = cards.iter().map(|card| card.card_type).collect::<Vec<_>>();
+ card_types.sort_by_key(|card_type| match card_type {
+ FfiTodayCardType::Update => 0,
+ FfiTodayCardType::PhotoUpdate => 1,
+ FfiTodayCardType::Ask => 2,
+ FfiTodayCardType::Event => 3,
+ FfiTodayCardType::FoodAvailability => 4,
+ });
+ assert_eq!(
+ card_types,
+ vec![
+ FfiTodayCardType::Update,
+ FfiTodayCardType::PhotoUpdate,
+ FfiTodayCardType::Ask,
+ FfiTodayCardType::Event,
+ FfiTodayCardType::FoodAvailability,
+ ]
+ );
+ let photo = cards
+ .iter()
+ .find(|card| card.card_type == FfiTodayCardType::PhotoUpdate)
+ .expect("photo card");
+ assert_eq!(photo.media.len(), 1);
+ assert_eq!(
+ photo.media[0].sha256.as_deref(),
+ Some(media.sha256.as_str())
+ );
+ let update = cards
+ .iter()
+ .find(|card| card.card_type == FfiTodayCardType::Update)
+ .expect("update card");
+ let food = cards
+ .iter()
+ .find(|card| card.card_type == FfiTodayCardType::FoodAvailability)
+ .expect("food card");
+ assert_eq!(food.food_summary.as_deref(), Some("Freshly harvested"));
+ assert_eq!(food.food_published_at_unix_s, Some(AUTHORED_AT));
+ assert_eq!(food.food_status.as_deref(), Some("active"));
+ let update_event_id = update.source_event_id.clone();
+ let update_card_id = update.card_id.clone();
+ let food_event_id = food.source_event_id.clone();
+ let food_card_id = food.card_id.clone();
+
+ publish_supporting_events(&relay_url, &update_event_id, &food_event_id).await;
+ reader
+ .phase1_sync_today(
+ context.clone(),
+ AS_OF,
+ FfiTodayProjectionUpdate::Incremental,
+ )
+ .await
+ .expect("sync profile and thread events");
+ let enriched = reader
+ .phase1_today_page(context.clone(), 20, Some(AS_OF), None)
+ .await
+ .expect("enriched Today");
+ assert_eq!(
+ enriched
+ .items
+ .iter()
+ .find(|card| card.card_id == update_card_id)
+ .expect("reply root")
+ .thread
+ .len(),
+ 1
+ );
+ assert_eq!(
+ enriched
+ .items
+ .iter()
+ .find(|card| card.card_id == food_card_id)
+ .expect("comment root")
+ .thread
+ .len(),
+ 1
+ );
+
+ let offline_port = unused_loopback_port().await;
+ let replacement_id = draft_id(6);
+ let mut replacement_input = food_input("Corrected carrots from Moss Farm", "today-carrots");
+ replacement_input.food_published_at_unix_s = Some(AUTHORED_AT);
+ let replacement = publisher
+ .phase1_save_draft(
+ replacement_id.clone(),
+ replacement_input,
+ AUTHORED_AT + 10,
+ None,
+ 1_800_000_010_000,
+ )
+ .await
+ .expect("save replacement");
+ let replacement_queued = publisher
+ .phase1_queue_draft(
+ replacement_id.clone(),
+ replacement.revision,
+ FfiQueuePolicyRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ relay_urls: vec![relay_url.clone(), format!("ws://127.0.0.1:{offline_port}")],
+ satisfaction: FfiRelaySatisfaction::AllAccepted,
+ delivery_deadline_unix_ms: u64::MAX,
+ cancellation: FfiCancellationPolicy::LocalCooperative,
+ },
+ 1_800_000_010_100,
+ )
+ .await
+ .expect("queue replacement");
+ let partial = publisher
+ .phase1_advance_draft(replacement_id, replacement_queued.revision)
+ .await
+ .expect("attempt partial replacement delivery");
+ assert_eq!(
+ partial.state,
+ FfiOutboxState::PartiallyDelivered,
+ "partial delivery status: {partial:?}"
+ );
+ let settlement = partial.settlement.expect("partial settlement");
+ assert_eq!(settlement.delivery_satisfied, 0);
+ assert_eq!(settlement.delivery_exhausted, 1);
+
+ reader
+ .phase1_sync_today(
+ context.clone(),
+ AS_OF,
+ FfiTodayProjectionUpdate::Incremental,
+ )
+ .await
+ .expect("sync replacement");
+ let replaced = reader
+ .phase1_today_page(context.clone(), 20, Some(AS_OF), None)
+ .await
+ .expect("replacement projection");
+ let current_food = replaced
+ .items
+ .iter()
+ .find(|card| card.card_id == food_card_id)
+ .expect("current food head");
+ assert_eq!(current_food.content, "Corrected carrots from Moss Farm");
+ assert_ne!(current_food.source_event_id, food_event_id);
+
+ let retraction_id = draft_id(7);
+ let retraction = publisher
+ .phase1_save_retraction_draft(
+ retraction_id.clone(),
+ FfiRetractionDraftInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type: FfiAddCommandType::CreateUpdate,
+ target_card_id: update_card_id.clone(),
+ target_event_id: update_event_id,
+ target_kind: 1,
+ target_address: None,
+ reason: "Superseded local update".to_owned(),
+ },
+ AUTHORED_AT + 20,
+ 1_800_000_020_000,
+ )
+ .await
+ .expect("save deletion request");
+ let retraction_queued = queue(
+ &publisher,
+ &retraction_id,
+ retraction.revision,
+ &relay_url,
+ false,
+ 1_800_000_020_100,
+ )
+ .await;
+ advance_complete(&publisher, &retraction_id, retraction_queued.revision).await;
+ reader
+ .phase1_sync_today(
+ context.clone(),
+ AS_OF,
+ FfiTodayProjectionUpdate::Incremental,
+ )
+ .await
+ .expect("sync deletion");
+ let after_deletion = reader
+ .phase1_today_page(context.clone(), 20, Some(AS_OF), None)
+ .await
+ .expect("projection after deletion");
+ assert_eq!(after_deletion.items.len(), 4);
+ assert!(
+ after_deletion
+ .items
+ .iter()
+ .all(|card| card.card_id != update_card_id)
+ );
+
+ let search = reader
+ .phase1_search(context.clone(), "moss farm".to_owned(), 20, AS_OF)
+ .await
+ .expect("search current projection");
+ assert!(search.iter().any(|result| result.profile.is_some()));
+ assert!(search.iter().any(|result| {
+ result
+ .card
+ .as_ref()
+ .is_some_and(|card| card.content == "Corrected carrots from Moss Farm")
+ }));
+ let me = reader
+ .phase1_me(context.clone(), AS_OF)
+ .await
+ .expect("Me projection");
+ assert_eq!(
+ me.profile
+ .as_ref()
+ .and_then(|profile| profile.display_name.as_deref()),
+ Some("Moss Farm")
+ );
+ assert_eq!(me.cards.len(), 4);
+
+ prove_corrupted_media_fails(&publisher, &image_bytes, &image_file).await;
+
+ reader.shutdown().await.expect("reader shutdown");
+ publisher.shutdown().await.expect("publisher shutdown");
+ relay.shutdown();
+}
+
+async fn runtime_with_signer(root: &std::path::Path) -> RadrootsRuntime {
+ RadrootsRuntime::with_host_signer(
+ root.to_string_lossy().into_owned(),
+ support::PUBLIC_KEY.to_owned(),
+ support::GENERATION.to_owned(),
+ 1_800_000_000_000,
+ ProtectedDataAvailability::Available,
+ Box::new(FixtureHostSigner),
+ )
+ .await
+ .expect("runtime with fixture host signer")
+}
+
+fn configure_simulator(runtime: &RadrootsRuntime, relay_url: &str, blossom_origin: &str) {
+ runtime
+ .configure_simulator_relays(vec![relay_url.to_owned()])
+ .expect("simulator relay profile");
+ runtime
+ .configure_simulator_blossom(vec![blossom_origin.to_owned()])
+ .expect("simulator Blossom profile");
+}
+
+fn local_network(relay_url: &str) -> FfiLocalNetworkRecord {
+ FfiLocalNetworkRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ id: "local-mvp".to_owned(),
+ label: "Local MVP".to_owned(),
+ relay_urls: vec![relay_url.to_owned()],
+ locality: None,
+ followed_authors: Vec::new(),
+ generation: 1,
+ }
+}
+
+fn add_input(
+ command_type: FfiAddCommandType,
+ content: &str,
+ media: Option<FfiPreparedMediaInput>,
+) -> FfiAddDraftInput {
+ FfiAddDraftInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type,
+ content: content.to_owned(),
+ identifier: None,
+ title: None,
+ summary: None,
+ location: None,
+ event_timing: None,
+ event_start_date: None,
+ event_end_date: None,
+ event_start_unix_s: None,
+ event_end_unix_s: None,
+ event_timezone: None,
+ price_amount: None,
+ currency: None,
+ unit: None,
+ quantity: None,
+ food_published_at_unix_s: None,
+ food_status: None,
+ media: media.into_iter().collect(),
+ }
+}
+
+fn event_input(content: &str) -> FfiAddDraftInput {
+ FfiAddDraftInput {
+ identifier: Some("saturday-market".to_owned()),
+ title: Some("Saturday Market".to_owned()),
+ location: Some("Victoria".to_owned()),
+ event_timing: Some(FfiEventTimingKind::AllDay),
+ event_start_date: Some("2026-08-09".to_owned()),
+ ..add_input(FfiAddCommandType::CreateEvent, content, None)
+ }
+}
+
+fn food_input(content: &str, identifier: &str) -> FfiAddDraftInput {
+ FfiAddDraftInput {
+ identifier: Some(identifier.to_owned()),
+ title: Some("Carrots".to_owned()),
+ summary: Some("Freshly harvested".to_owned()),
+ location: Some("Victoria".to_owned()),
+ price_amount: Some("4.5".to_owned()),
+ currency: Some("CAD".to_owned()),
+ unit: Some("kg".to_owned()),
+ quantity: Some("12".to_owned()),
+ food_status: Some("active".to_owned()),
+ ..add_input(FfiAddCommandType::CreateFoodAvailability, content, None)
+ }
+}
+
+fn prepared_media(
+ origin: &str,
+ bytes: &[u8],
+ file: &std::fs::File,
+ alt: &str,
+) -> FfiPreparedMediaInput {
+ let hash = Sha256::digest(bytes).to_string();
+ FfiPreparedMediaInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ opaque_reference: format!("media:{hash}"),
+ file_descriptor: u64::try_from(file.as_raw_fd()).expect("nonnegative media descriptor"),
+ url: format!("{origin}/{hash}.png"),
+ sha256: hash,
+ media_type: "image/png".to_owned(),
+ byte_size: u64::try_from(bytes.len()).expect("media size"),
+ width: 2,
+ height: 3,
+ alt: alt.to_owned(),
+ prepared_at_unix_s: AUTHORED_AT,
+ }
+}
+
+async fn queue(
+ runtime: &RadrootsRuntime,
+ draft_id: &str,
+ revision: u64,
+ relay_url: &str,
+ all: bool,
+ queued_at_unix_ms: u64,
+) -> radroots_mobile_ffi::FfiDraftStatusRecord {
+ runtime
+ .phase1_queue_draft(
+ draft_id.to_owned(),
+ revision,
+ FfiQueuePolicyRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ relay_urls: vec![relay_url.to_owned()],
+ satisfaction: if all {
+ FfiRelaySatisfaction::AllAccepted
+ } else {
+ FfiRelaySatisfaction::AnyAccepted
+ },
+ delivery_deadline_unix_ms: u64::MAX,
+ cancellation: FfiCancellationPolicy::LocalCooperative,
+ },
+ queued_at_unix_ms,
+ )
+ .await
+ .expect("queue draft")
+}
+
+async fn advance_complete(runtime: &RadrootsRuntime, draft_id: &str, revision: u64) {
+ let status = match runtime
+ .phase1_advance_draft(draft_id.to_owned(), revision)
+ .await
+ {
+ Ok(status) => status,
+ Err(error) => {
+ let durable = runtime
+ .phase1_draft_status(draft_id.to_owned())
+ .await
+ .expect("durable failure status");
+ panic!("advance failed: {error:?}; durable status: {durable:?}");
+ }
+ };
+ assert_eq!(status.state, FfiOutboxState::Complete);
+ let settlement = status.settlement.expect("complete settlement");
+ assert_eq!(settlement.signed, 1);
+ assert_eq!(settlement.admitted, 1);
+ assert_eq!(settlement.delivery_satisfied, 1);
+}
+
+async fn collect_pages(
+ runtime: &RadrootsRuntime,
+ context: &FfiLocalNetworkRecord,
+ limit: u16,
+ as_of: u64,
+) -> Vec<radroots_mobile_ffi::FfiTodayCardRecord> {
+ let first = runtime
+ .phase1_today_page(context.clone(), limit, Some(as_of), None)
+ .await
+ .expect("first Today page");
+ let mut items = first.items;
+ let mut cursor = first.next_cursor;
+ while let Some(next) = cursor {
+ let page = runtime
+ .phase1_today_page(context.clone(), limit, None, Some(next))
+ .await
+ .expect("continued Today page");
+ items.extend(page.items);
+ cursor = page.next_cursor;
+ }
+ let mut unique = items.iter().map(|card| &card.card_id).collect::<Vec<_>>();
+ unique.sort_unstable();
+ unique.dedup();
+ assert_eq!(unique.len(), items.len(), "frozen pages have no duplicates");
+ items
+}
+
+async fn publish_supporting_events(relay_url: &str, update_id: &str, food_id: &str) {
+ let profile_keys = Keys::parse(FIXTURE_SECRET).expect("profile keys");
+ let profile_client = Client::new(profile_keys);
+ profile_client
+ .add_relay(relay_url)
+ .await
+ .expect("profile relay");
+ profile_client.connect().await;
+ profile_client
+ .wait_for_connection(Duration::from_secs(2))
+ .await;
+ profile_client
+ .send_event_builder(
+ EventBuilder::metadata(
+ &Metadata::new()
+ .name("moss")
+ .display_name("Moss Farm")
+ .about("Local harvests"),
+ )
+ .custom_created_at(Timestamp::from_secs(AUTHORED_AT + 1)),
+ )
+ .await
+ .expect("publish profile");
+ profile_client.shutdown().await;
+
+ let reply_keys = Keys::parse(REPLY_SECRET).expect("reply keys");
+ let reply_author = reply_keys.public_key().to_string();
+ let reply_client = Client::new(reply_keys);
+ reply_client
+ .add_relay(relay_url)
+ .await
+ .expect("reply relay");
+ reply_client.connect().await;
+ reply_client
+ .wait_for_connection(Duration::from_secs(2))
+ .await;
+ reply_client
+ .send_event_builder(
+ EventBuilder::text_note("The farm stand is open")
+ .tags([
+ Tag::parse(["e", update_id, relay_url, "root"]).expect("reply root tag"),
+ Tag::parse(["p", support::PUBLIC_KEY]).expect("reply author tag"),
+ ])
+ .custom_created_at(Timestamp::from_secs(AUTHORED_AT + 2)),
+ )
+ .await
+ .expect("publish reply");
+ reply_client
+ .send_event_builder(
+ EventBuilder::new(Kind::Custom(1_111), "Are these available Saturday?")
+ .tags([
+ Tag::parse(["E", food_id, relay_url, support::PUBLIC_KEY])
+ .expect("comment root event tag"),
+ Tag::parse(["K", "30402"]).expect("comment root kind tag"),
+ Tag::parse(["P", support::PUBLIC_KEY, relay_url])
+ .expect("comment root author tag"),
+ Tag::parse(["e", food_id, relay_url, support::PUBLIC_KEY])
+ .expect("comment parent event tag"),
+ Tag::parse(["k", "30402"]).expect("comment parent kind tag"),
+ Tag::parse(["p", support::PUBLIC_KEY, relay_url])
+ .expect("comment parent author tag"),
+ Tag::parse(["p", reply_author.as_str()]).expect("self author context tag"),
+ ])
+ .custom_created_at(Timestamp::from_secs(AUTHORED_AT + 3)),
+ )
+ .await
+ .expect("publish comment");
+ reply_client.shutdown().await;
+}
+
+async fn prove_corrupted_media_fails(
+ runtime: &RadrootsRuntime,
+ bytes: &[u8],
+ image_file: &std::fs::File,
+) {
+ let corrupt = BlossomServer::spawn(bytes.to_vec(), true).await;
+ runtime
+ .configure_simulator_blossom(vec![corrupt.origin.clone()])
+ .expect("corrupt test Blossom profile");
+ let media = prepared_media(
+ &corrupt.origin,
+ bytes,
+ image_file,
+ "Corrupt retrieval proof",
+ );
+ let id = draft_id(8);
+ let saved = runtime
+ .phase1_save_draft(
+ id.clone(),
+ add_input(
+ FfiAddCommandType::CreatePhotoUpdate,
+ "This image must fail closed",
+ Some(media.clone()),
+ ),
+ AUTHORED_AT + 30,
+ None,
+ 1_800_000_030_000,
+ )
+ .await
+ .expect("save corrupt-media draft");
+ let error = runtime
+ .phase1_upload_draft_media(FfiBlossomUploadInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ draft_id: id.clone(),
+ expected_revision: saved.revision,
+ media,
+ authorization_content: "Verify corrupt retrieval rejection".to_owned(),
+ authorization_created_at_unix_s: AUTHORED_AT,
+ authorization_lifetime_seconds: 300,
+ operation_id: "31".repeat(16),
+ artifact_id: "32".repeat(16),
+ signing_deadline_unix_ms: u64::MAX,
+ signing_cancellation: FfiCancellationPolicy::LocalCooperative,
+ verified_at_unix_ms: 1_800_000_030_100,
+ updated_at_unix_ms: 1_800_000_030_200,
+ })
+ .await
+ .expect_err("corrupt media retrieval must fail");
+ assert_eq!(error.report().code, "authoring_failed");
+ let failed = runtime
+ .phase1_draft_status(id)
+ .await
+ .expect("durable corrupt-media status");
+ assert_eq!(failed.media[0].stage, FfiMediaStage::Failed);
+ assert!(failed.media[0].possible_orphan);
+ corrupt.finish().await;
+}
+
+async fn unused_loopback_port() -> u16 {
+ let listener = TcpListener::bind("127.0.0.1:0")
+ .await
+ .expect("reserve unused port");
+ listener.local_addr().expect("unused address").port()
+}
+
+async fn read_http_request(stream: &mut tokio::net::TcpStream) -> Vec<u8> {
+ let mut request = Vec::new();
+ let header_end = loop {
+ let mut chunk = [0_u8; 1_024];
+ let read = stream.read(&mut chunk).await.expect("read HTTP request");
+ assert_ne!(read, 0, "HTTP request ended before headers");
+ request.extend_from_slice(&chunk[..read]);
+ if let Some(index) = request.windows(4).position(|value| value == b"\r\n\r\n") {
+ break index + 4;
+ }
+ assert!(request.len() < 64 * 1_024, "HTTP headers are bounded");
+ };
+ let content_length = String::from_utf8_lossy(&request[..header_end])
+ .lines()
+ .find_map(|line| {
+ line.to_ascii_lowercase()
+ .strip_prefix("content-length: ")
+ .and_then(|value| value.trim().parse::<usize>().ok())
+ })
+ .unwrap_or_default();
+ while request.len() - header_end < content_length {
+ let mut chunk = [0_u8; 1_024];
+ let read = stream.read(&mut chunk).await.expect("read HTTP body");
+ assert_ne!(read, 0, "HTTP request ended before body");
+ request.extend_from_slice(&chunk[..read]);
+ }
+ request
+}
+
+async fn write_http_response(stream: &mut tokio::net::TcpStream, content_type: &str, body: &[u8]) {
+ let head = format!(
+ "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
+ body.len()
+ );
+ stream
+ .write_all(head.as_bytes())
+ .await
+ .expect("write response head");
+ stream.write_all(body).await.expect("write response body");
+ stream.shutdown().await.expect("close HTTP response");
+}
+
+fn draft_id(index: u8) -> String {
+ format!("{index:02x}").repeat(16)
+}
+
+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());
+ bytes.extend_from_slice(&height.to_be_bytes());
+ bytes
+}
+
+fn unix_time_ms() -> u64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("system clock after epoch")
+ .as_millis()
+ .try_into()
+ .expect("current time fits u64")
+}
diff --git a/crates/mobile_ffi/tests/runtime_delegation.rs b/crates/mobile_ffi/tests/runtime_delegation.rs
@@ -251,6 +251,7 @@ async fn native_boundary_delegates_the_complete_core_surface() {
currency: None,
unit: None,
quantity: None,
+ food_published_at_unix_s: None,
food_status: None,
media: Vec::new(),
};
diff --git a/crates/mobile_ffi/tests/uniffi_contract.rs b/crates/mobile_ffi/tests/uniffi_contract.rs
@@ -180,6 +180,7 @@ async fn host_signer_constructor_exposes_only_an_opaque_configured_boundary() {
currency: None,
unit: None,
quantity: None,
+ food_published_at_unix_s: None,
food_status: None,
media: Vec::new(),
},
diff --git a/crates/sync/src/ingest.rs b/crates/sync/src/ingest.rs
@@ -2,7 +2,7 @@
use radroots_event::admission::{
AdmissionPolicy as EventAdmissionPolicy, AdmittedEvent, ContractValidatedEvent, RawEvent,
- VisibilityPolicy,
+ SignatureVerifiedEvent, VisibilityPolicy,
};
use radroots_event_codec::verify::{self, Nip01SignatureVerifier};
use radroots_storage::{
@@ -42,6 +42,13 @@ pub trait AdmissionPolicy: Send + Sync {
/// Stable policy identity retained in event typestate evidence.
fn policy_id(&self) -> &'static str;
+ /// Selects a contract whose public wire shape requires an explicit
+ /// admission boundary. Returning `None` preserves ordinary registry
+ /// selection; any returned identity is still fully contract-validated.
+ fn select_contract(&self, _event: &SignatureVerifiedEvent) -> Option<&'static str> {
+ None
+ }
+
/// Decides whether a contract-valid event is rejected, verified-only, or visible.
fn decide(&self, event: &ContractValidatedEvent) -> AdmissionDecision;
}
@@ -143,8 +150,13 @@ impl Engine {
&Nip01SignatureVerifier,
)
.map_err(|_| Error::VerificationFailed)?;
- let validated =
- verify::contract(verified.clone()).map_err(|_| Error::VerificationFailed)?;
+ let validated = match policy.select_contract(&verified) {
+ Some(contract_id) => verified
+ .clone()
+ .validate_contract_for_admission(contract_id),
+ None => verify::contract(verified.clone()),
+ }
+ .map_err(|_| Error::VerificationFailed)?;
let decision = policy.decide(&validated);
if decision == AdmissionDecision::Reject {
return Err(Error::PolicyRejected);
diff --git a/crates/sync/src/push.rs b/crates/sync/src/push.rs
@@ -685,7 +685,14 @@ impl Engine {
.ok_or(Error::InvalidSignerOutput)?
.event()
.clone();
- let admission = match outbound_admission(&event, now) {
+ let persisted_plan = claimed
+ .plan()
+ .ok_or(Error::StorageFailed)?
+ .decode()
+ .map_err(map_storage_error)?
+ .into_plan();
+ let contract_id = persisted_plan.body().contract().contract_id().as_str();
+ let admission = match outbound_admission(&event, contract_id, now) {
Ok(admission) => admission,
Err(error) => {
let failure = WorkFailure::new(
@@ -1107,6 +1114,7 @@ fn delivery_retry_schedule(
fn outbound_admission(
event: &radroots_event::SignedEvent,
+ contract_id: &str,
observed_at_unix_ms: u64,
) -> Result<EventAdmission, Error> {
let verified = verify::signature(
@@ -1115,7 +1123,9 @@ fn outbound_admission(
&Nip01SignatureVerifier,
)
.map_err(|_| Error::InvalidSignerOutput)?;
- let validated = verify::contract(verified).map_err(|_| Error::InvalidSignerOutput)?;
+ let validated = verified
+ .validate_contract_for_admission(contract_id)
+ .map_err(|_| Error::InvalidSignerOutput)?;
let policy = RegistryPolicy::visible();
if policy.decide(&validated) != AdmissionDecision::Visible {
return Err(Error::InvalidSignerOutput);
diff --git a/crates/sync/tests/ingest.rs b/crates/sync/tests/ingest.rs
@@ -4,7 +4,15 @@ use std::sync::{
};
use futures_executor::block_on;
-use radroots_event::{SignedEvent, draft::SignedEventParts};
+use radroots_event::{
+ SignedEvent,
+ draft::SignedEventParts,
+ food::availability::{
+ FoodAvailabilityDetails, FoodAvailabilityDetailsParts, FoodAvailabilityStatus, FoodContent,
+ FoodCurrency, FoodIdentifier, FoodPrice, FoodPublishedAt, FoodText, FoodUnit,
+ },
+};
+use radroots_event_codec::authoring::AuthoredEventPlan;
use radroots_storage::{
EventStore,
event::{AdmissionDisposition, AdmissionStage, EventQuery, EventQueryBounds, SourceGeneration},
@@ -20,6 +28,7 @@ use radroots_transport::{
TransportId,
source::{EventProvenance, ObservedEvent},
};
+use secp256k1::{Keypair, Message, Secp256k1, SecretKey};
const EVENT_ID: &str = "762bee187e9e645b81ec26ade05a69b5e8398caf527be8de0d9a45311ed0c7a0";
const PUBKEY: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df";
@@ -83,6 +92,28 @@ impl AdmissionPolicy for Reject {
}
}
+struct FoodAvailabilityPolicy;
+
+impl AdmissionPolicy for FoodAvailabilityPolicy {
+ fn policy_id(&self) -> &'static str {
+ "test.food-availability.v1"
+ }
+
+ fn select_contract(
+ &self,
+ _event: &radroots_event::admission::SignatureVerifiedEvent,
+ ) -> Option<&'static str> {
+ Some("radroots.food.availability.v1")
+ }
+
+ fn decide(
+ &self,
+ _event: &radroots_event::admission::ContractValidatedEvent,
+ ) -> AdmissionDecision {
+ AdmissionDecision::Visible
+ }
+}
+
fn setup_engine(first_id: u8) -> (Engine, Arc<MemoryStorage>) {
setup_engine_with_ids(Arc::new(SequenceIds(AtomicU8::new(first_id))))
}
@@ -133,6 +164,67 @@ fn observed(signature: &str, observed_at: u64) -> ObservedEvent {
ObservedEvent::new(signed_event(signature), provenance)
}
+fn observed_food(observed_at: u64) -> ObservedEvent {
+ let created_at = 1_800_000_100;
+ let keypair = Keypair::from_secret_key(
+ &Secp256k1::new(),
+ &SecretKey::from_slice(&[1; 32]).expect("food fixture secret"),
+ );
+ let public_key = keypair.x_only_public_key().0.to_string();
+ let details = FoodAvailabilityDetails::new(FoodAvailabilityDetailsParts {
+ content: FoodContent::new("Carrots available this week.").expect("content"),
+ identifier: FoodIdentifier::parse("nantes-carrots").expect("identifier"),
+ title: FoodText::new("Nantes Carrots").expect("title"),
+ summary: FoodText::new("Fresh bunches").expect("summary"),
+ published_at: FoodPublishedAt::new(created_at).expect("published at"),
+ location: FoodText::new("Central Saanich, BC").expect("location"),
+ price: FoodPrice::new(
+ "3",
+ FoodCurrency::parse("CAD").expect("currency"),
+ FoodUnit::Pound,
+ )
+ .expect("price"),
+ quantity: None,
+ status: FoodAvailabilityStatus::Active,
+ images: Vec::new(),
+ })
+ .expect("food availability");
+ let plan = AuthoredEventPlan::from_food_availability(&details, created_at, &public_key)
+ .expect("food plan");
+ let id = plan.expected_event_id().to_hex();
+ let signature = Secp256k1::new()
+ .sign_schnorr_no_aux_rand(
+ &Message::from_digest(*plan.expected_event_id().as_bytes()),
+ &keypair,
+ )
+ .to_string();
+ let raw_json = format!(
+ "{{\"id\":\"{id}\",\"pubkey\":\"{public_key}\",\"created_at\":{created_at},\"kind\":{},\"tags\":{:?},\"content\":{content:?},\"sig\":\"{signature}\"}}",
+ plan.body().kind(),
+ plan.body().tags(),
+ content = plan.body().content(),
+ );
+ let event = SignedEvent::new(SignedEventParts {
+ id,
+ pubkey: public_key,
+ created_at,
+ kind: plan.body().kind(),
+ tags: plan.body().tags().to_vec(),
+ content: plan.body().content().to_owned(),
+ sig: signature,
+ raw_json,
+ })
+ .expect("signed food event");
+ let target = Target::new(TransportId::NOSTR, "wss://relay.example").expect("target");
+ let provenance = EventProvenance::new(
+ TransportId::NOSTR,
+ target.fingerprint().clone(),
+ observed_at,
+ )
+ .expect("provenance");
+ ObservedEvent::new(event, provenance)
+}
+
#[test]
fn valid_visible_ingest_is_atomic_and_preserves_provenance() {
let (engine, storage) = setup_engine(1);
@@ -166,6 +258,24 @@ fn valid_visible_ingest_is_atomic_and_preserves_provenance() {
}
#[test]
+fn admission_policy_selects_and_fully_validates_admission_only_wire_profiles() {
+ let (engine, storage) = setup_engine(1);
+ assert_eq!(
+ block_on(engine.ingest(observed_food(1), &RegistryPolicy::visible())),
+ Err(Error::VerificationFailed)
+ );
+ let receipt = block_on(engine.ingest(observed_food(2), &FoodAvailabilityPolicy))
+ .expect("policy-selected food admission");
+ assert_eq!(receipt.admission().stage(), AdmissionStage::Visible);
+ let visible = block_on(storage.query_visible(EventQuery::all(
+ EventQueryBounds::first(10).expect("bounds"),
+ )))
+ .expect("visible query");
+ assert_eq!(visible.items().len(), 1);
+ assert_eq!(visible.items()[0].event().kind(), 30_402);
+}
+
+#[test]
fn invalid_policy_rejected_and_verified_only_inputs_fail_closed() {
let (engine, storage) = setup_engine(1);
let invalid_signature = format!("0{}", &SIGNATURE[1..]);
diff --git a/crates/sync/tests/push_enqueue.rs b/crates/sync/tests/push_enqueue.rs
@@ -9,7 +9,13 @@ use std::{
use futures::{FutureExt, task::noop_waker_ref};
use futures_executor::block_on;
use radroots_event::{
- GenericEventDraft, SignedEvent, contract::AuthorRole, draft::SignedEventParts,
+ GenericEventDraft, SignedEvent,
+ contract::AuthorRole,
+ draft::SignedEventParts,
+ food::availability::{
+ FoodAvailabilityDetails, FoodAvailabilityDetailsParts, FoodAvailabilityStatus, FoodContent,
+ FoodCurrency, FoodIdentifier, FoodPrice, FoodPublishedAt, FoodText, FoodUnit,
+ },
};
use radroots_event_codec::authoring::AuthoredEventPlan;
use radroots_protocol::runtime::v1::SyncRetryDecision;
@@ -894,6 +900,50 @@ fn request(operation_byte: u8, relay: &str) -> PushRequest {
)
}
+fn food_request(operation_byte: u8, relay: &str) -> PushRequest {
+ let created_at = 1_800_000_100;
+ let details = FoodAvailabilityDetails::new(FoodAvailabilityDetailsParts {
+ content: FoodContent::new("Carrots available this week.").expect("food content"),
+ identifier: FoodIdentifier::parse("nantes-carrots").expect("food identifier"),
+ title: FoodText::new("Nantes Carrots").expect("food title"),
+ summary: FoodText::new("Fresh bunches").expect("food summary"),
+ published_at: FoodPublishedAt::new(created_at).expect("published at"),
+ location: FoodText::new("Central Saanich, BC").expect("food location"),
+ price: FoodPrice::new(
+ "3",
+ FoodCurrency::parse("CAD").expect("currency"),
+ FoodUnit::Pound,
+ )
+ .expect("food price"),
+ quantity: None,
+ status: FoodAvailabilityStatus::Active,
+ images: Vec::new(),
+ })
+ .expect("food availability");
+ let plan = AuthoredEventPlan::from_food_availability(&details, created_at, public_key_hex())
+ .expect("typed food plan");
+ let actor = Actor::new(
+ *plan.author(),
+ ActorSource::ExplicitPublicKey,
+ [AuthorRole::Seller],
+ )
+ .expect("food actor");
+ PushRequest::new(
+ SyncId::new([operation_byte; 16]).expect("operation id"),
+ IdempotencyKey::parse(format!("food-push-{operation_byte}")).expect("idempotency key"),
+ actor,
+ plan,
+ TargetSet::new(vec![
+ Target::new(TransportId::NOSTR, relay).expect("target"),
+ ])
+ .expect("targets"),
+ SatisfactionPolicy::new(SatisfactionClass::Accepted, TargetPolicy::any()),
+ 1_800_000_300_000,
+ CancellationPolicy::PreservePublishedRequest,
+ )
+ .expect("food push request")
+}
+
fn request_with_policy(
operation_byte: u8,
relays: &[&str],
@@ -1003,6 +1053,26 @@ fn execute_to_admitted(engine: &Engine, push: &PushRequest) {
}
#[test]
+fn typed_only_authored_contract_identity_survives_signing_and_local_admission() {
+ let signer = Arc::new(MockSigner::new(SignBehavior::Success {
+ completed_at_unix_ms: 1_800_000_200_500,
+ }));
+ let (engine, storage) = setup_engine(signer);
+ let push = food_request(9, "wss://relay.example");
+
+ block_on(engine.sign_prepared(push.clone())).expect("sign typed food plan");
+ let admitted = block_on(engine.admit_signed(push.operation_id()))
+ .expect("admit with the frozen typed contract identity");
+ assert!(admitted.artifact().admission_state().is_admitted());
+ let events = block_on(storage.query_visible(EventQuery::all(
+ EventQueryBounds::first(10).expect("bounds"),
+ )))
+ .expect("visible authored events");
+ assert_eq!(events.items().len(), 1);
+ assert_eq!(events.items()[0].event().kind(), 30_402);
+}
+
+#[test]
fn caller_revalidates_late_and_cancelled_signer_success_before_persistence() {
for (byte, violation, expected) in [
(