sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit e40c353aa233ea422420f490767382589a08e45c
parent 955ac53189a6abc005436f46165dbb83475f68ab
Author: triesap <tyson@radroots.org>
Date:   Thu, 18 Jun 2026 20:26:01 -0700

sdk: add farm publish runtime

Diffstat:
Acrates/sdk/src/actor_json.rs | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/src/farms_runtime.rs | 306+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/lib.rs | 11++++++++++-
Mcrates/sdk/src/listings_runtime.rs | 55++-----------------------------------------------------
Mcrates/sdk/src/product_clients.rs | 13+++++++++++++
Mcrates/sdk/src/runtime.rs | 8++++++--
Acrates/sdk/tests/farms_runtime.rs | 491+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/source_boundary.rs | 24++++++++++++++++++++++++
8 files changed, 902 insertions(+), 56 deletions(-)

diff --git a/crates/sdk/src/actor_json.rs b/crates/sdk/src/actor_json.rs @@ -0,0 +1,50 @@ +use radroots_authority::{RadrootsActorContext, RadrootsActorSource}; +use radroots_events::contract::RadrootsActorRole; +use serde::ser::SerializeStruct; + +pub(crate) struct SdkActorContextJson<'a>(pub(crate) &'a RadrootsActorContext); + +impl serde::Serialize for SdkActorContextJson<'_> { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let roles = self + .0 + .roles() + .iter() + .map(actor_role_code) + .collect::<Vec<_>>(); + let account_id = self.0.account_id().map(|account_id| account_id.as_str()); + let mut state = serializer.serialize_struct("SdkActorContext", 4)?; + state.serialize_field("pubkey", self.0.pubkey().as_str())?; + state.serialize_field("roles", &roles)?; + state.serialize_field("account_id", &account_id)?; + state.serialize_field("source", actor_source_code(self.0.source()))?; + state.end() + } +} + +fn actor_role_code(role: &RadrootsActorRole) -> &'static str { + match role { + RadrootsActorRole::Any => "any", + RadrootsActorRole::Application => "application", + RadrootsActorRole::Buyer => "buyer", + RadrootsActorRole::Farmer => "farmer", + RadrootsActorRole::Member => "member", + RadrootsActorRole::Moderator => "moderator", + RadrootsActorRole::Relay => "relay", + RadrootsActorRole::Seller => "seller", + RadrootsActorRole::Service => "service", + } +} + +fn actor_source_code(source: RadrootsActorSource) -> &'static str { + match source { + RadrootsActorSource::LocalAccount => "local_account", + RadrootsActorSource::ExplicitPubkey => "explicit_pubkey", + RadrootsActorSource::RemoteSigner => "remote_signer", + RadrootsActorSource::Service => "service", + RadrootsActorSource::Test => "test", + } +} diff --git a/crates/sdk/src/farms_runtime.rs b/crates/sdk/src/farms_runtime.rs @@ -0,0 +1,306 @@ +#[cfg(feature = "runtime")] +use crate::{ + FarmsClient, RadrootsSdkError, RadrootsSdkTimestamp, SdkIdempotencyKey, SdkMutationState, + SdkRelayTargetPolicy, SdkRelayUrlPolicy, + actor_json::SdkActorContextJson, + farm, + workflow_runtime::{SdkWorkflowEnqueueRequest, enqueue_signed_workflow}, +}; +#[cfg(feature = "runtime")] +use radroots_authority::{RadrootsActorContext, RadrootsEventSigner}; +#[cfg(feature = "runtime")] +use radroots_events::{ + contract::RadrootsActorRole, + draft::RadrootsFrozenEventDraft, + farm::RadrootsFarm, + ids::{RadrootsAddressableCoordinate, RadrootsEventId}, + kinds::KIND_FARM, +}; +#[cfg(feature = "runtime")] +use radroots_events_codec::wire::to_frozen_draft; +#[cfg(feature = "runtime")] +use serde::ser::SerializeStruct; + +#[cfg(feature = "runtime")] +pub const FARM_PUBLISH_OPERATION_KIND: &str = "farm.publish.v1"; + +#[cfg(feature = "runtime")] +const FARM_PROFILE_CONTRACT_ID: &str = "radroots.farm.profile.v1"; + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct FarmPreparePublishRequest { + pub actor: RadrootsActorContext, + pub farm: RadrootsFarm, + pub created_at: Option<RadrootsSdkTimestamp>, +} + +#[cfg(feature = "runtime")] +impl serde::Serialize for FarmPreparePublishRequest { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let mut state = serializer.serialize_struct("FarmPreparePublishRequest", 3)?; + state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; + state.serialize_field("farm", &self.farm)?; + state.serialize_field("created_at", &self.created_at)?; + state.end() + } +} + +#[cfg(feature = "runtime")] +impl FarmPreparePublishRequest { + pub fn new(actor: RadrootsActorContext, farm: RadrootsFarm) -> Self { + Self { + actor, + farm, + created_at: None, + } + } + + pub fn with_created_at(mut self, created_at: RadrootsSdkTimestamp) -> Self { + self.created_at = Some(created_at); + self + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct FarmEnqueuePublishRequest { + pub actor: RadrootsActorContext, + pub farm: RadrootsFarm, + pub target_relays: SdkRelayTargetPolicy, + pub idempotency_key: Option<SdkIdempotencyKey>, + pub created_at: Option<RadrootsSdkTimestamp>, +} + +#[cfg(feature = "runtime")] +impl serde::Serialize for FarmEnqueuePublishRequest { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let mut state = serializer.serialize_struct("FarmEnqueuePublishRequest", 5)?; + state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; + state.serialize_field("farm", &self.farm)?; + state.serialize_field("target_relays", &self.target_relays)?; + state.serialize_field("idempotency_key", &self.idempotency_key)?; + state.serialize_field("created_at", &self.created_at)?; + state.end() + } +} + +#[cfg(feature = "runtime")] +impl FarmEnqueuePublishRequest { + pub fn new( + actor: RadrootsActorContext, + farm: RadrootsFarm, + target_relays: SdkRelayTargetPolicy, + ) -> Self { + Self { + actor, + farm, + target_relays, + idempotency_key: None, + created_at: None, + } + } + + pub fn try_with_target_relays<I, S>( + mut self, + target_relays: I, + policy: SdkRelayUrlPolicy, + ) -> Result<Self, RadrootsSdkError> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + self.target_relays = SdkRelayTargetPolicy::try_explicit(target_relays, policy)?; + Ok(self) + } + + pub fn with_idempotency_key(mut self, idempotency_key: SdkIdempotencyKey) -> Self { + self.idempotency_key = Some(idempotency_key.into()); + self + } + + pub fn try_with_idempotency_key( + mut self, + idempotency_key: impl AsRef<str>, + ) -> Result<Self, RadrootsSdkError> { + self.idempotency_key = Some(SdkIdempotencyKey::new(idempotency_key)?); + Ok(self) + } + + pub fn with_created_at(mut self, created_at: RadrootsSdkTimestamp) -> Self { + self.created_at = Some(created_at); + self + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct FarmPublishPlan { + pub farm_addr: RadrootsAddressableCoordinate, + pub expected_event_id: RadrootsEventId, + pub frozen_draft: RadrootsFrozenEventDraft, + pub created_at: RadrootsSdkTimestamp, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct FarmEnqueueReceipt { + pub farm_addr: RadrootsAddressableCoordinate, + pub expected_event_id: RadrootsEventId, + pub signed_event_id: RadrootsEventId, + pub local_event_seq: i64, + pub outbox_operation_id: i64, + pub outbox_event_id: i64, + pub state: SdkMutationState, + pub idempotency_digest_prefix: Option<String>, +} + +#[cfg(feature = "runtime")] +impl<'sdk> FarmsClient<'sdk> { + pub fn prepare_publish( + &self, + request: FarmPreparePublishRequest, + ) -> Result<FarmPublishPlan, RadrootsSdkError> { + let created_at = self.resolved_created_at(request.created_at)?; + farm_publish_plan(&request.actor, request.farm, created_at) + } + + pub async fn enqueue_publish<S>( + &self, + request: FarmEnqueuePublishRequest, + signer: &S, + ) -> Result<FarmEnqueueReceipt, RadrootsSdkError> + where + S: RadrootsEventSigner + ?Sized, + { + let FarmEnqueuePublishRequest { + actor, + farm, + target_relays, + idempotency_key, + created_at, + } = request; + let prepare_request = FarmPreparePublishRequest { + actor: actor.clone(), + farm, + created_at, + }; + let plan = self.prepare_publish(prepare_request)?; + self.enqueue_prepared_publish(&actor, plan, target_relays, idempotency_key, signer) + .await + } + + pub async fn enqueue_prepared_publish<S>( + &self, + actor: &RadrootsActorContext, + plan: FarmPublishPlan, + target_relays: SdkRelayTargetPolicy, + idempotency_key: Option<SdkIdempotencyKey>, + signer: &S, + ) -> Result<FarmEnqueueReceipt, RadrootsSdkError> + where + S: RadrootsEventSigner + ?Sized, + { + let enqueue = enqueue_signed_workflow( + self.sdk, + SdkWorkflowEnqueueRequest { + operation_kind: FARM_PUBLISH_OPERATION_KIND, + actor, + frozen_draft: &plan.frozen_draft, + target_relays, + idempotency_key, + }, + signer, + ) + .await?; + Ok(FarmEnqueueReceipt { + farm_addr: plan.farm_addr, + expected_event_id: plan.expected_event_id, + signed_event_id: enqueue.signed_event_id, + local_event_seq: enqueue.local_event_seq, + outbox_operation_id: enqueue.outbox_operation_id, + outbox_event_id: enqueue.outbox_event_id, + state: enqueue.state.into(), + idempotency_digest_prefix: Some(enqueue.idempotency_digest_prefix), + }) + } + + fn resolved_created_at( + &self, + created_at: Option<RadrootsSdkTimestamp>, + ) -> Result<RadrootsSdkTimestamp, RadrootsSdkError> { + match created_at { + Some(created_at) => Ok(created_at), + None => self.sdk.now(), + } + } +} + +#[cfg(feature = "runtime")] +fn farm_publish_plan( + actor: &RadrootsActorContext, + farm_value: RadrootsFarm, + created_at: RadrootsSdkTimestamp, +) -> Result<FarmPublishPlan, RadrootsSdkError> { + require_farmer_actor(actor, "farm.prepare_publish")?; + let created_at_nostr = created_at.try_into_nostr_created_at()?; + let farm_addr = farm_addr(actor, farm_value.d_tag.as_str())?; + let parts = + farm::build_draft(&farm_value).map_err(|error| RadrootsSdkError::InvalidRequest { + message: format!("farm publish draft encode failed: {error}"), + })?; + let frozen_draft = to_frozen_draft( + parts, + FARM_PROFILE_CONTRACT_ID, + actor.pubkey().as_str(), + created_at_nostr, + ) + .map_err(|error| RadrootsSdkError::InvalidRequest { + message: format!("farm publish draft freeze failed: {error}"), + })?; + let expected_event_id = RadrootsEventId::parse(frozen_draft.expected_event_id.as_str()) + .map_err(|error| RadrootsSdkError::InvalidRequest { + message: format!("farm publish draft produced invalid event id: {error}"), + })?; + Ok(FarmPublishPlan { + farm_addr, + expected_event_id, + frozen_draft, + created_at, + }) +} + +#[cfg(feature = "runtime")] +fn require_farmer_actor( + actor: &RadrootsActorContext, + operation: &'static str, +) -> Result<(), RadrootsSdkError> { + if actor.satisfies(RadrootsActorRole::Farmer) { + Ok(()) + } else { + Err(RadrootsSdkError::UnauthorizedActor { + operation: operation.to_owned(), + reason: "missing role Farmer".to_owned(), + }) + } +} + +#[cfg(feature = "runtime")] +fn farm_addr( + actor: &RadrootsActorContext, + d_tag: &str, +) -> Result<RadrootsAddressableCoordinate, RadrootsSdkError> { + RadrootsAddressableCoordinate::parse(format!("{KIND_FARM}:{}:{d_tag}", actor.pubkey())).map_err( + |error| RadrootsSdkError::InvalidRequest { + message: format!("farm address is invalid: {error}"), + }, + ) +} diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -4,6 +4,8 @@ #[cfg(not(feature = "std"))] extern crate alloc; +#[cfg(feature = "runtime")] +mod actor_json; #[cfg(any( feature = "radrootsd-client", feature = "signing", @@ -17,6 +19,8 @@ pub mod config; mod error; mod farm; #[cfg(feature = "runtime")] +mod farms_runtime; +#[cfg(feature = "runtime")] mod idempotency; #[cfg(feature = "identity-models")] mod identity; @@ -68,6 +72,11 @@ pub use crate::error::{ RadrootsSdkPartialLocalMutationFailure, RadrootsSdkRecoveryAction, }; #[cfg(feature = "runtime")] +pub use crate::farms_runtime::{ + FARM_PUBLISH_OPERATION_KIND, FarmEnqueuePublishRequest, FarmEnqueueReceipt, + FarmPreparePublishRequest, FarmPublishPlan, +}; +#[cfg(feature = "runtime")] pub use crate::idempotency::{SDK_IDEMPOTENCY_KEY_MAX_LEN, SdkIdempotencyKey}; #[cfg(feature = "runtime")] pub use crate::listings_runtime::{ @@ -81,7 +90,7 @@ pub use crate::orders_runtime::{ OrderStatusRequest, SdkOrderStatusIssue, SdkOrderStatusIssueKind, SdkOrderStatusSource, }; #[cfg(feature = "runtime")] -pub use crate::product_clients::{ListingsClient, OrdersClient, SyncClient}; +pub use crate::product_clients::{FarmsClient, ListingsClient, OrdersClient, SyncClient}; #[cfg(feature = "runtime")] pub use crate::relay_targets::{ SDK_RELAY_TARGET_MAX_COUNT, SdkRelayTargetPolicy, SdkRelayTargetSet, SdkRelayUrlPolicy, diff --git a/crates/sdk/src/listings_runtime.rs b/crates/sdk/src/listings_runtime.rs @@ -2,13 +2,13 @@ use crate::{ ListingsClient, RadrootsSdkError, RadrootsSdkTimestamp, SdkIdempotencyKey, SdkRelayTargetPolicy, SdkRelayUrlPolicy, + actor_json::SdkActorContextJson, workflow_runtime::{SdkWorkflowEnqueueRequest, enqueue_signed_workflow}, }; #[cfg(feature = "runtime")] -use radroots_authority::{RadrootsActorContext, RadrootsActorSource, RadrootsEventSigner}; +use radroots_authority::{RadrootsActorContext, RadrootsEventSigner}; #[cfg(feature = "runtime")] use radroots_events::{ - contract::RadrootsActorRole, draft::RadrootsFrozenEventDraft, ids::{RadrootsEventId, RadrootsListingAddress}, listing::RadrootsListing, @@ -320,54 +320,3 @@ fn listing_publish_plan( created_at, }) } - -#[cfg(feature = "runtime")] -struct SdkActorContextJson<'a>(&'a RadrootsActorContext); - -#[cfg(feature = "runtime")] -impl serde::Serialize for SdkActorContextJson<'_> { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: serde::Serializer, - { - let roles = self - .0 - .roles() - .iter() - .map(actor_role_code) - .collect::<Vec<_>>(); - let account_id = self.0.account_id().map(|account_id| account_id.as_str()); - let mut state = serializer.serialize_struct("SdkActorContext", 4)?; - state.serialize_field("pubkey", self.0.pubkey().as_str())?; - state.serialize_field("roles", &roles)?; - state.serialize_field("account_id", &account_id)?; - state.serialize_field("source", actor_source_code(self.0.source()))?; - state.end() - } -} - -#[cfg(feature = "runtime")] -fn actor_role_code(role: &RadrootsActorRole) -> &'static str { - match role { - RadrootsActorRole::Any => "any", - RadrootsActorRole::Application => "application", - RadrootsActorRole::Buyer => "buyer", - RadrootsActorRole::Farmer => "farmer", - RadrootsActorRole::Member => "member", - RadrootsActorRole::Moderator => "moderator", - RadrootsActorRole::Relay => "relay", - RadrootsActorRole::Seller => "seller", - RadrootsActorRole::Service => "service", - } -} - -#[cfg(feature = "runtime")] -fn actor_source_code(source: RadrootsActorSource) -> &'static str { - match source { - RadrootsActorSource::LocalAccount => "local_account", - RadrootsActorSource::ExplicitPubkey => "explicit_pubkey", - RadrootsActorSource::RemoteSigner => "remote_signer", - RadrootsActorSource::Service => "service", - RadrootsActorSource::Test => "test", - } -} diff --git a/crates/sdk/src/product_clients.rs b/crates/sdk/src/product_clients.rs @@ -3,6 +3,19 @@ use crate::RadrootsSdk; #[cfg(feature = "runtime")] #[derive(Clone, Copy)] +pub struct FarmsClient<'sdk> { + pub(crate) sdk: &'sdk RadrootsSdk, +} + +#[cfg(feature = "runtime")] +impl<'sdk> FarmsClient<'sdk> { + pub(crate) fn new(sdk: &'sdk RadrootsSdk) -> Self { + Self { sdk } + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Copy)] pub struct ListingsClient<'sdk> { pub(crate) sdk: &'sdk RadrootsSdk, } diff --git a/crates/sdk/src/runtime.rs b/crates/sdk/src/runtime.rs @@ -1,7 +1,7 @@ #[cfg(feature = "runtime")] use crate::{ - ListingsClient, OrdersClient, RadrootsSdkError, SdkRelayTargetSet, SdkRelayUrlPolicy, - SyncClient, + FarmsClient, ListingsClient, OrdersClient, RadrootsSdkError, SdkRelayTargetSet, + SdkRelayUrlPolicy, SyncClient, }; #[cfg(feature = "runtime")] use radroots_event_store::RadrootsEventStore; @@ -427,6 +427,10 @@ impl RadrootsSdk { RadrootsSdkBuilder::default() } + pub fn farms(&self) -> FarmsClient<'_> { + FarmsClient::new(self) + } + pub fn listings(&self) -> ListingsClient<'_> { ListingsClient::new(self) } diff --git a/crates/sdk/tests/farms_runtime.rs b/crates/sdk/tests/farms_runtime.rs @@ -0,0 +1,491 @@ +#![cfg(feature = "runtime")] + +use radroots_authority::{ + RadrootsActorContext, RadrootsEventSigner, RadrootsSignerError, RadrootsSignerIdentity, +}; +use radroots_event_store::RadrootsEventStore; +use radroots_events::{ + contract::RadrootsActorRole, + draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent, RadrootsSignedNostrEventParts}, + farm::RadrootsFarm, + kinds::{KIND_FARM, KIND_PROFILE}, +}; +use radroots_outbox::{RadrootsOutbox, RadrootsOutboxEventState}; +use radroots_sdk::{ + FARM_PUBLISH_OPERATION_KIND, FarmEnqueuePublishRequest, FarmPreparePublishRequest, RadrootsSdk, + RadrootsSdkError, RadrootsSdkPartialLocalMutationFailure, RadrootsSdkRecoveryAction, + RadrootsSdkTimestamp, SdkMutationState, SdkRelayTargetPolicy, SdkRelayTargetSet, + SdkRelayUrlPolicy, +}; + +const FARMER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const OTHER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const FARM_A_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; +const FARM_B_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAQ"; +const FARM_C_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAg"; +const FARM_D_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAw"; +const FARM_E_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAABA"; +const FARM_F_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAABQ"; +const RELAY: &str = "wss://relay.example.com"; +const RELAY_B: &str = "wss://relay-b.example.com"; + +#[derive(Clone)] +struct FixtureSigner { + identity: RadrootsSignerIdentity, +} + +impl FixtureSigner { + fn new(pubkey: &str) -> Self { + Self { + identity: RadrootsSignerIdentity::new(pubkey).expect("identity"), + } + } +} + +impl RadrootsEventSigner for FixtureSigner { + fn pubkey(&self) -> &radroots_events::ids::RadrootsPublicKey { + self.identity.pubkey() + } + + fn sign_frozen_draft( + &self, + draft: &RadrootsFrozenEventDraft, + ) -> Result<RadrootsSignedNostrEvent, RadrootsSignerError> { + if self.pubkey().as_str() != draft.expected_pubkey.as_str() { + return Err(RadrootsSignerError::SigningFailed { + message: "wrong fixture signer".to_owned(), + }); + } + let sig = "f".repeat(128); + let raw_json = serde_json::json!({ + "id": draft.expected_event_id, + "pubkey": self.pubkey().as_str(), + "created_at": draft.created_at, + "kind": draft.kind, + "tags": draft.tags, + "content": draft.content, + "sig": sig, + }) + .to_string(); + RadrootsSignedNostrEvent::new(RadrootsSignedNostrEventParts { + id: draft.expected_event_id.clone(), + pubkey: self.pubkey().as_str().to_owned(), + created_at: draft.created_at, + kind: draft.kind, + tags: draft.tags.clone(), + content: draft.content.clone(), + sig, + raw_json, + }) + .map_err(|error| RadrootsSignerError::SigningFailed { + message: error.to_string(), + }) + } +} + +fn farmer_actor() -> RadrootsActorContext { + RadrootsActorContext::test(FARMER, [RadrootsActorRole::Farmer]).expect("actor") +} + +fn non_farmer_actor() -> RadrootsActorContext { + RadrootsActorContext::test(FARMER, [RadrootsActorRole::Buyer]).expect("actor") +} + +fn farm(d_tag: &str, name: &str) -> RadrootsFarm { + RadrootsFarm { + d_tag: d_tag.to_owned(), + name: name.to_owned(), + about: Some("Vegetable farm".to_owned()), + website: Some("https://example.invalid/north-farm".to_owned()), + picture: None, + banner: None, + location: None, + tags: Some(vec!["vegetables".to_owned(), "local".to_owned()]), + } +} + +async fn directory_sdk() -> (tempfile::TempDir, RadrootsSdk) { + directory_sdk_with_relays(&[RELAY]).await +} + +async fn directory_sdk_with_relays(relays: &[&str]) -> (tempfile::TempDir, RadrootsSdk) { + let tempdir = tempfile::tempdir().expect("tempdir"); + let mut builder = RadrootsSdk::builder() + .directory_storage(tempdir.path().join("sdk")) + .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)); + for relay in relays { + builder = builder.relay_url(*relay); + } + let sdk = builder.build().await.expect("sdk"); + (tempdir, sdk) +} + +#[tokio::test] +async fn farm_prepare_publish_is_side_effect_free() { + let (_tempdir, sdk) = directory_sdk().await; + let request = FarmPreparePublishRequest::new(farmer_actor(), farm(FARM_A_D_TAG, "North Farm")); + let prepared = sdk.farms().prepare_publish(request).expect("prepared"); + + assert_eq!(prepared.frozen_draft.kind, KIND_FARM); + assert_eq!(prepared.created_at.unix_seconds(), 1_700_000_000); + assert_eq!( + prepared.expected_event_id, + prepared.frozen_draft.expected_event_id + ); + assert_eq!( + prepared.farm_addr.as_str(), + format!("{KIND_FARM}:{FARMER}:{FARM_A_D_TAG}") + ); + + let paths = sdk.storage_paths().expect("paths"); + let event_store = RadrootsEventStore::open_file(&paths.event_store_path) + .await + .expect("event store"); + assert_eq!( + event_store + .status_summary() + .await + .expect("event store status") + .total_events, + 0 + ); + assert!( + event_store + .get_event(prepared.expected_event_id.as_str()) + .await + .expect("event lookup") + .is_none() + ); + let outbox = RadrootsOutbox::open_file(&paths.outbox_path) + .await + .expect("outbox"); + assert!( + outbox + .claim_next_ready_event("worker", "claim", 2_000, 1_700_000_000_000) + .await + .expect("claim") + .is_none() + ); +} + +#[tokio::test] +async fn farm_prepare_publish_rejects_non_farmer_actor() { + let (_tempdir, sdk) = directory_sdk().await; + let request = + FarmPreparePublishRequest::new(non_farmer_actor(), farm(FARM_B_D_TAG, "North Farm")); + + let error = sdk + .farms() + .prepare_publish(request) + .expect_err("non farmer"); + + assert!(matches!(error, RadrootsSdkError::UnauthorizedActor { .. })); +} + +#[tokio::test] +async fn farm_enqueue_publish_stores_event_and_queues_signed_outbox_without_profile_event() { + let (_tempdir, sdk) = directory_sdk().await; + let request = FarmEnqueuePublishRequest::new( + farmer_actor(), + farm(FARM_B_D_TAG, "North Farm"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_idempotency_key("farm-idem-b") + .expect("idempotency key"); + let prepared = sdk + .farms() + .prepare_publish(FarmPreparePublishRequest::new( + farmer_actor(), + farm(FARM_B_D_TAG, "North Farm"), + )) + .expect("prepared"); + let receipt = sdk + .farms() + .enqueue_publish(request, &FixtureSigner::new(FARMER)) + .await + .expect("enqueue"); + + assert_eq!(receipt.expected_event_id, prepared.expected_event_id); + assert_eq!(receipt.signed_event_id, receipt.expected_event_id); + assert_eq!(receipt.farm_addr, prepared.farm_addr); + assert_eq!(receipt.local_event_seq, 1); + assert_eq!(receipt.outbox_operation_id, 1); + assert_eq!(receipt.outbox_event_id, 1); + assert_eq!(receipt.state, SdkMutationState::StoredAndQueued); + assert!(receipt.idempotency_digest_prefix.is_some()); + + let paths = sdk.storage_paths().expect("paths"); + let event_store = RadrootsEventStore::open_file(&paths.event_store_path) + .await + .expect("event store"); + let status = event_store + .status_summary() + .await + .expect("event store status"); + assert_eq!(status.total_events, 1); + let stored_event = event_store + .get_event(receipt.signed_event_id.as_str()) + .await + .expect("event lookup") + .expect("stored event"); + assert_eq!(stored_event.kind, KIND_FARM); + assert_ne!(stored_event.kind, KIND_PROFILE); + assert_eq!( + stored_event.contract_id.as_deref(), + Some("radroots.farm.profile.v1") + ); + + let outbox = RadrootsOutbox::open_file(&paths.outbox_path) + .await + .expect("outbox"); + let outbox_event = outbox + .get_event(receipt.outbox_event_id) + .await + .expect("outbox event") + .expect("outbox event"); + assert_eq!(outbox_event.state, RadrootsOutboxEventState::Signed); + assert_eq!(outbox_event.draft.kind, KIND_FARM); + assert!(outbox_event.signed_event.is_some()); +} + +#[tokio::test] +async fn farm_enqueue_publish_returns_sanitized_signer_errors_before_mutation() { + let (_tempdir, sdk) = directory_sdk().await; + let request = FarmEnqueuePublishRequest::new( + farmer_actor(), + farm(FARM_C_D_TAG, "North Farm"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ); + let error = sdk + .farms() + .enqueue_publish(request, &FixtureSigner::new(OTHER)) + .await + .expect_err("signer error"); + let message = error.to_string(); + + assert!(matches!( + error, + RadrootsSdkError::SignerPubkeyMismatch { .. } + )); + assert!(!message.contains("raw")); + assert!(!message.contains("ffff")); + + let paths = sdk.storage_paths().expect("paths"); + let event_store = RadrootsEventStore::open_file(&paths.event_store_path) + .await + .expect("event store"); + assert_eq!( + event_store + .status_summary() + .await + .expect("event store status") + .total_events, + 0 + ); + let outbox = RadrootsOutbox::open_file(&paths.outbox_path) + .await + .expect("outbox"); + assert!( + outbox + .claim_next_ready_event("worker", "claim", 2_000, 1_700_000_000_000) + .await + .expect("claim") + .is_none() + ); +} + +#[tokio::test] +async fn farm_enqueue_publish_derives_order_independent_idempotency_key() { + let (_tempdir, sdk) = directory_sdk().await; + let first = FarmEnqueuePublishRequest::new( + farmer_actor(), + farm(FARM_D_D_TAG, "North Farm"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY_B, RELAY, RELAY], SdkRelayUrlPolicy::Public) + .expect("first target relays"); + let second = FarmEnqueuePublishRequest::new( + farmer_actor(), + farm(FARM_D_D_TAG, "North Farm"), + SdkRelayTargetPolicy::explicit( + SdkRelayTargetSet::new([RELAY, RELAY_B], SdkRelayUrlPolicy::Public) + .expect("second target relays"), + ), + ); + + let first_receipt = sdk + .farms() + .enqueue_publish(first, &FixtureSigner::new(FARMER)) + .await + .expect("first enqueue"); + let second_receipt = sdk + .farms() + .enqueue_publish(second, &FixtureSigner::new(FARMER)) + .await + .expect("second enqueue"); + + assert_eq!( + first_receipt.outbox_event_id, + second_receipt.outbox_event_id + ); + assert_eq!( + first_receipt.idempotency_digest_prefix, + second_receipt.idempotency_digest_prefix + ); + assert_eq!(second_receipt.state, SdkMutationState::AlreadyQueued); + + let paths = sdk.storage_paths().expect("paths"); + let outbox = RadrootsOutbox::open_file(&paths.outbox_path) + .await + .expect("outbox"); + let relay_urls = outbox + .relay_statuses(first_receipt.outbox_event_id) + .await + .expect("relay statuses") + .into_iter() + .map(|status| status.relay_url) + .collect::<Vec<_>>(); + assert_eq!(relay_urls, vec![RELAY_B.to_owned(), RELAY.to_owned()]); +} + +#[tokio::test] +async fn farm_enqueue_publish_reports_partial_local_mutation_after_outbox_conflict() { + let (_tempdir, sdk) = directory_sdk().await; + let first = FarmEnqueuePublishRequest::new( + farmer_actor(), + farm(FARM_E_D_TAG, "North Farm"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_idempotency_key("farm-idem-e") + .expect("idempotency key"); + sdk.farms() + .enqueue_publish(first, &FixtureSigner::new(FARMER)) + .await + .expect("first enqueue"); + + let second = FarmEnqueuePublishRequest::new( + farmer_actor(), + farm(FARM_F_D_TAG, "Changed Farm"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_idempotency_key("farm-idem-e") + .expect("idempotency key"); + let error = sdk + .farms() + .enqueue_publish(second, &FixtureSigner::new(FARMER)) + .await + .expect_err("partial"); + + assert!(matches!( + error, + RadrootsSdkError::PartialLocalMutation(ref partial) + if partial.stored + && !partial.queued + && partial.event_id.is_some() + && partial.operation_kind == FARM_PUBLISH_OPERATION_KIND + && partial.idempotency_digest_prefix.is_some() + && partial.failure == RadrootsSdkPartialLocalMutationFailure::OutboxIdempotencyConflict + && partial.recovery == RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey + )); + assert!(!error.to_string().contains("farm-idem-e")); +} + +#[tokio::test] +async fn farm_runtime_dtos_serialize_deterministically() { + let (_tempdir, sdk) = directory_sdk().await; + let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_123); + let prepare_request = + FarmPreparePublishRequest::new(farmer_actor(), farm(FARM_A_D_TAG, "Serialized Farm")) + .with_created_at(created_at); + let prepare_json = serde_json::to_value(&prepare_request).expect("prepare request json"); + + assert_eq!( + prepare_json, + serde_json::json!({ + "actor": { + "pubkey": FARMER, + "roles": ["farmer"], + "account_id": null, + "source": "test" + }, + "farm": { + "d_tag": FARM_A_D_TAG, + "name": "Serialized Farm", + "about": "Vegetable farm", + "website": "https://example.invalid/north-farm", + "picture": null, + "banner": null, + "location": null, + "tags": ["vegetables", "local"] + }, + "created_at": 1_700_000_123 + }) + ); + + let enqueue_request = FarmEnqueuePublishRequest::new( + farmer_actor(), + farm(FARM_B_D_TAG, "Queued Farm"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY, RELAY_B], SdkRelayUrlPolicy::Public) + .expect("relay targets") + .try_with_idempotency_key("farm-serialized-idempotency") + .expect("idempotency") + .with_created_at(created_at); + let enqueue_json = serde_json::to_value(&enqueue_request).expect("enqueue request json"); + + assert_eq!( + enqueue_json, + serde_json::json!({ + "actor": { + "pubkey": FARMER, + "roles": ["farmer"], + "account_id": null, + "source": "test" + }, + "farm": { + "d_tag": FARM_B_D_TAG, + "name": "Queued Farm", + "about": "Vegetable farm", + "website": "https://example.invalid/north-farm", + "picture": null, + "banner": null, + "location": null, + "tags": ["vegetables", "local"] + }, + "target_relays": { + "kind": "explicit", + "relays": [RELAY, RELAY_B], + "canonical_relays": [RELAY_B, RELAY] + }, + "idempotency_key": { "value": "<redacted>", "len": 27 }, + "created_at": 1_700_000_123 + }) + ); + assert!( + !enqueue_json + .to_string() + .contains("farm-serialized-idempotency") + ); + + let receipt = sdk + .farms() + .enqueue_publish(enqueue_request, &FixtureSigner::new(FARMER)) + .await + .expect("enqueue"); + let receipt_json = serde_json::to_value(&receipt).expect("receipt json"); + + assert_eq!( + receipt_json, + serde_json::json!({ + "farm_addr": receipt.farm_addr.as_str(), + "expected_event_id": receipt.expected_event_id.as_str(), + "signed_event_id": receipt.signed_event_id.as_str(), + "local_event_seq": 1, + "outbox_operation_id": 1, + "outbox_event_id": 1, + "state": "stored_and_queued", + "idempotency_digest_prefix": receipt.idempotency_digest_prefix.as_deref() + }) + ); +} diff --git a/crates/sdk/tests/source_boundary.rs b/crates/sdk/tests/source_boundary.rs @@ -68,6 +68,30 @@ fn sdk_manifest_does_not_depend_on_app_or_cli_crates() { } } +#[test] +fn farm_runtime_stays_on_product_runtime_boundary() { + let source = read_source( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src") + .join("farms_runtime.rs") + .as_path(), + ); + + for forbidden in [ + "RadrootsSdkClient", + "SdkTransportMode", + "SdkPublishReceipt", + "radrootsd", + "publish_with_identity", + "publish_parts_via_relay", + ] { + assert!( + !source.contains(forbidden), + "farms_runtime.rs must not use legacy SDK client or transport concept `{forbidden}`" + ); + } +} + fn read_source(path: &Path) -> String { fs::read_to_string(path) .unwrap_or_else(|error| panic!("failed to read source {}: {error}", path.display()))