commit e40c353aa233ea422420f490767382589a08e45c
parent 955ac53189a6abc005436f46165dbb83475f68ab
Author: triesap <tyson@radroots.org>
Date: Thu, 18 Jun 2026 20:26:01 -0700
sdk: add farm publish runtime
Diffstat:
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()))