commit 4fc669d02c8bf516c53cf5ad436cdbed5a715f2f
parent 68e9202c767c70508d294c75d6ae21b2659d4a48
Author: triesap <tyson@radroots.org>
Date: Tue, 23 Jun 2026 09:17:38 +0000
publish-proxy: add sdk proxy publish transport
- replace bridge JSON-RPC requests with publish.event transport
- add direct relay versus radrootsd proxy publish selection
- allow daemon-resolved outbox targets for proxy publishing
- cover proxy decoding, outcome mapping, and workflow enqueue paths
Diffstat:
19 files changed, 1027 insertions(+), 933 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1844,6 +1844,13 @@ dependencies = [
]
[[package]]
+name = "radroots_publish_proxy_protocol"
+version = "0.1.0-alpha.2"
+dependencies = [
+ "serde",
+]
+
+[[package]]
name = "radroots_relay_transport"
version = "0.1.0-alpha.2"
dependencies = [
@@ -1987,6 +1994,7 @@ dependencies = [
"radroots_nostr_connect",
"radroots_nostr_signer",
"radroots_outbox",
+ "radroots_publish_proxy_protocol",
"radroots_relay_transport",
"radroots_replica_db",
"radroots_replica_db_schema",
diff --git a/Cargo.toml b/Cargo.toml
@@ -40,6 +40,7 @@ radroots_nostr = { path = "../lib/crates/nostr", version = "0.1.0-alpha.2", defa
radroots_nostr_connect = { path = "../lib/crates/nostr_connect", version = "0.1.0-alpha.2", default-features = false }
radroots_nostr_signer = { path = "../lib/crates/nostr_signer", version = "0.1.0-alpha.2", default-features = false }
radroots_outbox = { path = "../lib/crates/outbox", version = "0.1.0-alpha.2", default-features = false }
+radroots_publish_proxy_protocol = { path = "../lib/crates/publish_proxy_protocol", version = "0.1.0-alpha.2", default-features = false }
radroots_relay_transport = { path = "../lib/crates/relay_transport", version = "0.1.0-alpha.2", default-features = false }
radroots_replica_db = { path = "../lib/crates/replica_db", version = "0.1.0-alpha.2", default-features = false }
radroots_replica_db_schema = { path = "../lib/crates/replica_db_schema", version = "0.1.0-alpha.2", default-features = false }
diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml
@@ -32,7 +32,17 @@ identity-models = [
identity-storage = ["identity-models", "std", "radroots_identity/std"]
signing = ["dep:radroots_nostr", "nostr"]
relay-client = ["signing", "std", "serde_json", "radroots_nostr/client"]
-radrootsd-client = ["std", "serde_json", "dep:reqwest"]
+radrootsd-proxy = [
+ "std",
+ "serde_json",
+ "dep:futures",
+ "dep:radroots_publish_proxy_protocol",
+ "dep:radroots_relay_transport",
+ "dep:reqwest",
+ "radroots_publish_proxy_protocol/serde",
+ "radroots_publish_proxy_protocol/std",
+ "radroots_relay_transport/std",
+]
signer-adapters = [
"identity-models",
"signing",
@@ -78,13 +88,24 @@ local-runtime = [
"signing",
"relay-client",
]
+local-runtime-radrootsd-proxy = [
+ "std",
+ "serde",
+ "serde_json",
+ "runtime",
+ "local-signer",
+ "signing",
+ "radrootsd-proxy",
+]
[dependencies]
+futures = { workspace = true, optional = true }
radroots_authority = { workspace = true, optional = true, default-features = false }
radroots_event_store = { workspace = true, optional = true, default-features = false }
radroots_events = { workspace = true, default-features = false }
radroots_events_codec = { workspace = true, default-features = false }
radroots_outbox = { workspace = true, optional = true, default-features = false }
+radroots_publish_proxy_protocol = { workspace = true, optional = true, default-features = false }
radroots_relay_transport = { workspace = true, optional = true, default-features = false }
radroots_trade = { workspace = true, default-features = false }
radroots_identity = { workspace = true, optional = true, default-features = false }
diff --git a/crates/sdk/README b/crates/sdk/README
@@ -30,6 +30,8 @@ signed outbox work when `relay-runtime` is enabled. Push time uses the relay
targets already stored on each queued outbox event, so already queued work does
not require configured builder relays. `push_outbox_with_adapter(...)` remains
available for tests and controlled adapter-level substrate checks.
+`radrootsd-proxy` adds the Radrootsd Publish Proxy transport for daemon-resolved
+publishing through `publish.event`.
The `local-runtime` feature is the curated feature bundle for local product
runtime consumers. It enables `std`, `serde`, `serde_json`, `runtime`,
@@ -37,8 +39,8 @@ runtime consumers. It enables `std`, `serde`, `serde_json`, `runtime`,
is retained in this bundle only for current direct relay publish callers that
have not migrated to the product runtime yet; it is classified for removal once
those callers are SDK-runtime backed. `local-runtime` does not enable
-`radrootsd-client`. CLI, app, and local tooling consumers should depend on this
-feature directly so they stay on the same runtime contract.
+`radrootsd-proxy`; use `local-runtime-radrootsd-proxy` when the runtime should
+publish through Radrootsd instead of a direct relay transport.
Relay URL policy is explicit. Public relay URLs must use `wss://`. Local
development `ws://` relay URLs are accepted only under `SdkRelayUrlPolicy::Localhost`
diff --git a/crates/sdk/src/adapters/mod.rs b/crates/sdk/src/adapters/mod.rs
@@ -1,4 +1,4 @@
-#[cfg(feature = "radrootsd-client")]
+#[cfg(feature = "radrootsd-proxy")]
pub mod radrootsd;
#[cfg(feature = "relay-client")]
pub mod relay;
diff --git a/crates/sdk/src/adapters/radrootsd.rs b/crates/sdk/src/adapters/radrootsd.rs
@@ -1,16 +1,23 @@
use core::fmt;
use core::time::Duration;
-use crate::farm::RadrootsFarm;
-use crate::listing;
-use crate::listing::RadrootsListing;
-use crate::profile::{RadrootsProfile, RadrootsProfileType};
-use radroots_events::RadrootsNostrEvent;
-use radroots_events::kinds::KIND_LISTING;
+use radroots_events::draft::RadrootsSignedNostrEvent;
+use radroots_publish_proxy_protocol::{
+ METHOD_EVENT, PublishDeliveryPolicy, PublishEventRequest, PublishEventResponse,
+ PublishProxyProtocolError, PublishRelayOutcomeKind, PublishRelayPolicy, SignedNostrEventWire,
+};
+use radroots_relay_transport::{
+ RadrootsRelayOutcome, RadrootsRelayOutcomeKind, RadrootsRelayPublishAdapter,
+ RadrootsRelayPublishReceipt, RadrootsRelayPublishRelayReceipt, RadrootsRelayPublishRequest,
+ RadrootsRelayTransportError,
+};
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use serde_json::{Value, json};
+pub const SDK_RADROOTSD_PROXY_REQUEST_ID: &str = "radroots-sdk-publish-event";
+pub const SDK_RADROOTSD_PROXY_MAX_RELAYS: usize = 20;
+
#[derive(Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum RadrootsdAuth {
#[default]
@@ -27,365 +34,160 @@ impl fmt::Debug for RadrootsdAuth {
}
}
-#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct SdkRadrootsdSignerAuthority {
- pub provider_runtime_id: String,
- pub account_identity_id: String,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub provider_signer_session_id: Option<String>,
-}
-
-impl fmt::Debug for SdkRadrootsdSignerAuthority {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- let mut debug = f.debug_struct("SdkRadrootsdSignerAuthority");
- debug.field("provider_runtime_id", &self.provider_runtime_id);
- debug.field("account_identity_id", &self.account_identity_id);
- debug.field(
- "provider_signer_session_id",
- &self
- .provider_signer_session_id
- .as_ref()
- .map(|_| "<redacted>"),
- );
- debug.finish()
- }
-}
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
-pub enum SdkRadrootsdSignerSessionMode {
- #[serde(alias = "bunker")]
- Bunker,
- #[serde(alias = "nostrconnect")]
- Nostrconnect,
-}
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum SdkRadrootsdSignerSessionRole {
- InboundLocalSigner,
- OutboundRemoteSigner,
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsdProxyConfig {
+ pub endpoint: String,
+ pub auth: RadrootsdAuth,
+ pub relay_policy: PublishRelayPolicy,
+ pub timeout: Duration,
+ pub request_timeout_ms: Option<u64>,
}
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum SdkRadrootsdBridgeDeliveryPolicy {
- Any,
- Quorum,
- All,
-}
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum SdkRadrootsdBridgeJobStatus {
- Accepted,
- Published,
- Failed,
-}
-
-#[derive(Clone, PartialEq, Eq, Serialize)]
-pub struct SdkRadrootsdSignerSessionConnectRequest {
- pub url: String,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub client_secret_key: Option<String>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub signer_authority: Option<SdkRadrootsdSignerAuthority>,
-}
-
-impl SdkRadrootsdSignerSessionConnectRequest {
- pub fn bunker(url: impl Into<String>) -> Self {
+impl RadrootsdProxyConfig {
+ pub fn new(endpoint: impl Into<String>) -> Self {
Self {
- url: url.into(),
- client_secret_key: None,
- signer_authority: None,
+ endpoint: endpoint.into(),
+ auth: RadrootsdAuth::None,
+ relay_policy: PublishRelayPolicy::RequestThenAuthorWriteThenDaemonDefault,
+ timeout: Duration::from_secs(10),
+ request_timeout_ms: None,
}
}
- pub fn nostrconnect(url: impl Into<String>, client_secret_key: impl Into<String>) -> Self {
- Self {
- url: url.into(),
- client_secret_key: Some(client_secret_key.into()),
- signer_authority: None,
- }
+ pub fn with_auth(mut self, auth: RadrootsdAuth) -> Self {
+ self.auth = auth;
+ self
}
- pub fn with_signer_authority(mut self, signer_authority: SdkRadrootsdSignerAuthority) -> Self {
- self.signer_authority = Some(signer_authority);
+ pub fn with_relay_policy(mut self, relay_policy: PublishRelayPolicy) -> Self {
+ self.relay_policy = relay_policy;
self
}
-}
-impl fmt::Debug for SdkRadrootsdSignerSessionConnectRequest {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- let mut debug = f.debug_struct("SdkRadrootsdSignerSessionConnectRequest");
- debug.field("url", &self.url);
- debug.field(
- "client_secret_key",
- &self.client_secret_key.as_ref().map(|_| "<redacted>"),
- );
- debug.field("signer_authority", &self.signer_authority);
- debug.finish()
+ pub fn with_timeout(mut self, timeout: Duration) -> Self {
+ self.timeout = timeout;
+ self
}
-}
-#[derive(Clone, Serialize)]
-pub struct SdkRadrootsdProfilePublishRequest {
- pub profile: RadrootsProfile,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub profile_type: Option<RadrootsProfileType>,
- pub signer_session_id: String,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub signer_authority: Option<SdkRadrootsdSignerAuthority>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub idempotency_key: Option<String>,
-}
-
-impl fmt::Debug for SdkRadrootsdProfilePublishRequest {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- let mut debug = f.debug_struct("SdkRadrootsdProfilePublishRequest");
- debug.field("profile", &self.profile);
- debug.field("profile_type", &self.profile_type);
- debug.field("signer_session_id", &"<redacted>");
- debug.field("signer_authority", &self.signer_authority);
- debug.field("idempotency_key", &self.idempotency_key);
- debug.finish()
+ pub fn with_request_timeout_ms(mut self, timeout_ms: u64) -> Self {
+ self.request_timeout_ms = Some(timeout_ms);
+ self
}
}
-#[derive(Clone, Serialize)]
-pub struct SdkRadrootsdFarmPublishRequest {
- pub farm: RadrootsFarm,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub kind: Option<u32>,
- pub signer_session_id: String,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub signer_authority: Option<SdkRadrootsdSignerAuthority>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub idempotency_key: Option<String>,
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsdProxyPublishAdapter {
+ config: RadrootsdProxyConfig,
}
-impl fmt::Debug for SdkRadrootsdFarmPublishRequest {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- let mut debug = f.debug_struct("SdkRadrootsdFarmPublishRequest");
- debug.field("farm", &self.farm);
- debug.field("kind", &self.kind);
- debug.field("signer_session_id", &"<redacted>");
- debug.field("signer_authority", &self.signer_authority);
- debug.field("idempotency_key", &self.idempotency_key);
- debug.finish()
+impl RadrootsdProxyPublishAdapter {
+ pub fn new(config: RadrootsdProxyConfig) -> Self {
+ Self { config }
}
-}
-#[derive(Clone, Serialize)]
-pub struct SdkRadrootsdListingPublishRequest {
- pub listing: RadrootsListing,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub kind: Option<u32>,
- pub signer_session_id: String,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub signer_authority: Option<SdkRadrootsdSignerAuthority>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub idempotency_key: Option<String>,
-}
-
-impl fmt::Debug for SdkRadrootsdListingPublishRequest {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- let mut debug = f.debug_struct("SdkRadrootsdListingPublishRequest");
- debug.field("listing", &self.listing);
- debug.field("kind", &self.kind);
- debug.field("signer_session_id", &"<redacted>");
- debug.field("signer_authority", &self.signer_authority);
- debug.field("idempotency_key", &self.idempotency_key);
- debug.finish()
+ pub fn config(&self) -> &RadrootsdProxyConfig {
+ &self.config
}
-}
-impl SdkRadrootsdListingPublishRequest {
- pub fn from_event(
- event: &RadrootsNostrEvent,
- signer_session_id: impl Into<String>,
- signer_authority: Option<SdkRadrootsdSignerAuthority>,
- idempotency_key: Option<String>,
- ) -> Result<Self, listing::RadrootsListingParseError> {
- if event.kind != KIND_LISTING {
- return Err(listing::RadrootsListingParseError::InvalidKind(event.kind));
- }
- Ok(Self {
- listing: listing::parse_event(event)?,
- kind: Some(event.kind),
- signer_session_id: signer_session_id.into(),
- signer_authority,
- idempotency_key,
- })
+ pub async fn publish_signed_event(
+ &self,
+ request: RadrootsdProxyPublishRequest,
+ ) -> Result<RadrootsRelayPublishReceipt, RadrootsdError> {
+ let request = request.into_protocol_request(self.config.relay_policy)?;
+ request
+ .validate(SDK_RADROOTSD_PROXY_MAX_RELAYS)
+ .map_err(RadrootsdError::from_protocol)?;
+ let response = publish_event(
+ self.config.endpoint.as_str(),
+ &self.config.auth,
+ &request,
+ self.config.timeout,
+ )
+ .await?;
+ proxy_receipt_from_response(response)
}
}
-#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
-pub struct SdkRadrootsdBridgePublishResponse {
- pub deduplicated: bool,
- pub job: SdkRadrootsdBridgeJob,
-}
-
-#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
-pub struct SdkRadrootsdBridgeStatusResponse {
- pub enabled: bool,
- pub ready: bool,
- pub auth_mode: String,
- pub signer_mode: String,
- pub default_signer_mode: String,
- pub supported_signer_modes: Vec<String>,
- pub available_nip46_signer_sessions: usize,
- pub relay_count: usize,
- pub delivery_policy: SdkRadrootsdBridgeDeliveryPolicy,
- #[serde(default)]
- pub delivery_quorum: Option<usize>,
- pub publish_max_attempts: usize,
- pub publish_initial_backoff_millis: u64,
- pub publish_max_backoff_millis: u64,
- pub job_status_retention: usize,
- pub retained_jobs: usize,
- pub retained_idempotency_keys: usize,
- pub accepted_jobs: usize,
- pub published_jobs: usize,
- pub failed_jobs: usize,
- pub recovered_failed_jobs: usize,
- pub methods: Vec<String>,
-}
-
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
-pub struct SdkRadrootsdBridgeRelayPublishResult {
- pub relay_url: String,
- pub acknowledged: bool,
- #[serde(default)]
- pub detail: Option<String>,
-}
-
-#[derive(Clone, PartialEq, Eq, Deserialize)]
-pub struct SdkRadrootsdBridgeJob {
- pub job_id: String,
- pub command: String,
- pub status: String,
- pub terminal: bool,
- pub recovered_after_restart: bool,
- pub signer_mode: String,
- #[serde(default)]
- pub signer_session_id: Option<String>,
- pub event_kind: u32,
- #[serde(default)]
- pub event_id: Option<String>,
- #[serde(default)]
- pub event_addr: Option<String>,
- pub relay_count: usize,
- pub acknowledged_relay_count: usize,
-}
-
-impl fmt::Debug for SdkRadrootsdBridgeJob {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- let mut debug = f.debug_struct("SdkRadrootsdBridgeJob");
- debug.field("job_id", &self.job_id);
- debug.field("command", &self.command);
- debug.field("status", &self.status);
- debug.field("terminal", &self.terminal);
- debug.field("recovered_after_restart", &self.recovered_after_restart);
- debug.field("signer_mode", &"<redacted>");
- debug.field(
- "signer_session_id",
- &self.signer_session_id.as_ref().map(|_| "<redacted>"),
- );
- debug.field("event_kind", &self.event_kind);
- debug.field("event_id", &self.event_id);
- debug.field("event_addr", &self.event_addr);
- debug.field("relay_count", &self.relay_count);
- debug.field("acknowledged_relay_count", &self.acknowledged_relay_count);
- debug.finish()
+impl RadrootsRelayPublishAdapter for RadrootsdProxyPublishAdapter {
+ fn publish<'a>(
+ &'a self,
+ request: RadrootsRelayPublishRequest,
+ ) -> futures::future::BoxFuture<
+ 'a,
+ Result<Vec<RadrootsRelayPublishRelayReceipt>, RadrootsRelayTransportError>,
+ > {
+ Box::pin(async move {
+ let request = RadrootsdProxyPublishRequest {
+ delivery_policy: delivery_policy_from_relay_request(
+ request.targets.len(),
+ request.accepted_quorum,
+ ),
+ signed_event: request.signed_event,
+ relays: request.targets.relay_strings(),
+ idempotency_key: None,
+ timeout_ms: self.config.request_timeout_ms,
+ };
+ let receipt = self
+ .publish_signed_event(request)
+ .await
+ .map_err(|error| RadrootsRelayTransportError::Transport(error.to_string()))?;
+ Ok(receipt.relays)
+ })
}
}
-#[derive(Clone, PartialEq, Eq, Deserialize)]
-pub struct SdkRadrootsdBridgeJobView {
- pub job_id: String,
- pub command: String,
- #[serde(default)]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsdProxyPublishRequest {
+ pub signed_event: RadrootsSignedNostrEvent,
+ pub relays: Vec<String>,
+ pub delivery_policy: PublishDeliveryPolicy,
pub idempotency_key: Option<String>,
- pub status: SdkRadrootsdBridgeJobStatus,
- pub terminal: bool,
- pub recovered_after_restart: bool,
- pub requested_at_unix: u64,
- #[serde(default)]
- pub completed_at_unix: Option<u64>,
- pub signer_mode: String,
- #[serde(default)]
- pub signer_session_id: Option<String>,
- pub event_kind: u32,
- #[serde(default)]
- pub event_id: Option<String>,
- #[serde(default)]
- pub event_addr: Option<String>,
- pub delivery_policy: SdkRadrootsdBridgeDeliveryPolicy,
- #[serde(default)]
- pub delivery_quorum: Option<usize>,
- pub relay_count: usize,
- pub acknowledged_relay_count: usize,
- pub required_acknowledged_relay_count: usize,
- pub attempt_count: usize,
- #[serde(default)]
- pub attempt_summaries: Vec<String>,
- #[serde(default)]
- pub relay_results: Vec<SdkRadrootsdBridgeRelayPublishResult>,
- pub relay_outcome_summary: String,
-}
-
-impl fmt::Debug for SdkRadrootsdBridgeJobView {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- let mut debug = f.debug_struct("SdkRadrootsdBridgeJobView");
- debug.field("job_id", &self.job_id);
- debug.field("command", &self.command);
- debug.field("idempotency_key", &self.idempotency_key);
- debug.field("status", &self.status);
- debug.field("terminal", &self.terminal);
- debug.field("recovered_after_restart", &self.recovered_after_restart);
- debug.field("requested_at_unix", &self.requested_at_unix);
- debug.field("completed_at_unix", &self.completed_at_unix);
- debug.field("signer_mode", &self.signer_mode.as_str());
- debug.field(
- "signer_session_id",
- &self.signer_session_id.as_ref().map(|_| "<redacted>"),
- );
- debug.field("event_kind", &self.event_kind);
- debug.field("event_id", &self.event_id);
- debug.field("event_addr", &self.event_addr);
- debug.field("delivery_policy", &self.delivery_policy);
- debug.field("delivery_quorum", &self.delivery_quorum);
- debug.field("relay_count", &self.relay_count);
- debug.field("acknowledged_relay_count", &self.acknowledged_relay_count);
- debug.field(
- "required_acknowledged_relay_count",
- &self.required_acknowledged_relay_count,
- );
- debug.field("attempt_count", &self.attempt_count);
- debug.field("attempt_summaries", &self.attempt_summaries);
- debug.field("relay_results", &self.relay_results);
- debug.field("relay_outcome_summary", &self.relay_outcome_summary);
- debug.finish()
+ pub timeout_ms: Option<u64>,
+}
+
+impl RadrootsdProxyPublishRequest {
+ fn into_protocol_request(
+ self,
+ relay_policy: PublishRelayPolicy,
+ ) -> Result<PublishEventRequest, RadrootsdError> {
+ Ok(PublishEventRequest {
+ event: signed_event_wire(&self.signed_event),
+ relays: self.relays,
+ relay_policy,
+ delivery_policy: self.delivery_policy,
+ idempotency_key: self.idempotency_key,
+ timeout_ms: self.timeout_ms,
+ })
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RadrootsdError {
InvalidAuthHeader(String),
+ InvalidRequest(String),
Http(String),
- JsonRpc(String),
+ JsonRpc { code: i64, message: String },
MalformedResponse(String),
}
-impl core::fmt::Display for RadrootsdError {
- fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+impl RadrootsdError {
+ fn from_protocol(error: PublishProxyProtocolError) -> Self {
+ Self::InvalidRequest(error.to_string())
+ }
+}
+
+impl fmt::Display for RadrootsdError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidAuthHeader(value) => {
write!(f, "invalid radrootsd bearer token header: {value}")
}
- Self::Http(value) => write!(f, "{value}"),
- Self::JsonRpc(value) => write!(f, "{value}"),
- Self::MalformedResponse(value) => write!(f, "{value}"),
+ Self::InvalidRequest(value) | Self::Http(value) | Self::MalformedResponse(value) => {
+ f.write_str(value)
+ }
+ Self::JsonRpc { code, message } => {
+ write!(f, "radrootsd jsonrpc failed {code}: {message}")
+ }
}
}
}
@@ -406,17 +208,17 @@ struct JsonRpcError {
message: String,
}
-pub async fn publish_listing(
+pub async fn publish_event(
endpoint: &str,
auth: &RadrootsdAuth,
- request: &SdkRadrootsdListingPublishRequest,
+ request: &PublishEventRequest,
timeout: Duration,
-) -> Result<SdkRadrootsdBridgePublishResponse, RadrootsdError> {
+) -> Result<PublishEventResponse, RadrootsdError> {
jsonrpc_call(
endpoint,
auth,
- "radroots-sdk-listing-publish",
- "bridge.listing.publish",
+ SDK_RADROOTSD_PROXY_REQUEST_ID,
+ METHOD_EVENT,
request,
timeout,
)
@@ -436,10 +238,8 @@ fn auth_headers(auth: &RadrootsdAuth) -> Result<HeaderMap, RadrootsdError> {
}
}
-pub fn bridge_listing_publish_request_json(
- request: &SdkRadrootsdListingPublishRequest,
-) -> Result<Value, RadrootsdError> {
- Ok(serde_json::to_value(request).expect("radrootsd listing publish request serializes"))
+pub fn publish_event_request_json(request: &PublishEventRequest) -> Result<Value, RadrootsdError> {
+ Ok(serde_json::to_value(request).expect("radrootsd publish.event request serializes"))
}
fn http_status_error(status: reqwest::StatusCode, body: &str) -> RadrootsdError {
@@ -479,10 +279,10 @@ where
}
match (envelope.result, envelope.error) {
(Some(result), None) => Ok(result),
- (None, Some(error)) => Err(RadrootsdError::JsonRpc(format!(
- "radrootsd {method} failed {}: {}",
- error.code, error.message
- ))),
+ (None, Some(error)) => Err(RadrootsdError::JsonRpc {
+ code: error.code,
+ message: error.message,
+ }),
(Some(_), Some(error)) => Err(RadrootsdError::MalformedResponse(format!(
"radrootsd {method} returned result and error: {} {}",
error.code, error.message
@@ -538,6 +338,99 @@ where
decode_jsonrpc_response(method, request_id, body.as_str())
}
+fn signed_event_wire(event: &RadrootsSignedNostrEvent) -> SignedNostrEventWire {
+ SignedNostrEventWire {
+ id: event.id.clone(),
+ pubkey: event.pubkey.clone(),
+ created_at: event.created_at as u64,
+ kind: event.kind,
+ tags: event.tags.clone(),
+ content: event.content.clone(),
+ sig: event.sig.clone(),
+ }
+}
+
+fn delivery_policy_from_relay_request(
+ target_count: usize,
+ accepted_quorum: usize,
+) -> PublishDeliveryPolicy {
+ if accepted_quorum >= target_count {
+ PublishDeliveryPolicy::All
+ } else if accepted_quorum <= 1 {
+ PublishDeliveryPolicy::Any
+ } else {
+ PublishDeliveryPolicy::Quorum {
+ quorum: accepted_quorum,
+ }
+ }
+}
+
+fn proxy_receipt_from_response(
+ response: PublishEventResponse,
+) -> Result<RadrootsRelayPublishReceipt, RadrootsdError> {
+ response
+ .job
+ .validate()
+ .map_err(RadrootsdError::from_protocol)?;
+ let quorum = response
+ .job
+ .delivery_policy
+ .required_ack_count(response.job.relay_count);
+ let attempted_count = response
+ .job
+ .relays
+ .iter()
+ .filter(|relay| relay.attempted)
+ .count();
+ let relays = response
+ .job
+ .relays
+ .into_iter()
+ .map(|relay| RadrootsRelayPublishRelayReceipt {
+ relay_url: relay.relay_url,
+ attempted: relay.attempted,
+ outcome: RadrootsRelayOutcome {
+ kind: relay_outcome_kind(relay.outcome_kind),
+ message: relay.message,
+ },
+ })
+ .collect();
+ Ok(RadrootsRelayPublishReceipt {
+ event_id: response.job.event_id,
+ attempted_count,
+ accepted_count: response.job.acknowledged_count,
+ retryable_count: response.job.retryable_count,
+ terminal_count: response.job.terminal_count,
+ quorum,
+ quorum_met: response.job.delivery_satisfied,
+ relays,
+ })
+}
+
+fn relay_outcome_kind(kind: PublishRelayOutcomeKind) -> RadrootsRelayOutcomeKind {
+ match kind {
+ PublishRelayOutcomeKind::Accepted => RadrootsRelayOutcomeKind::Accepted,
+ PublishRelayOutcomeKind::DuplicateAccepted => RadrootsRelayOutcomeKind::DuplicateAccepted,
+ PublishRelayOutcomeKind::Blocked => RadrootsRelayOutcomeKind::Blocked,
+ PublishRelayOutcomeKind::RateLimited => RadrootsRelayOutcomeKind::RateLimited,
+ PublishRelayOutcomeKind::Invalid => RadrootsRelayOutcomeKind::Invalid,
+ PublishRelayOutcomeKind::PowRequired => RadrootsRelayOutcomeKind::PowRequired,
+ PublishRelayOutcomeKind::Restricted => RadrootsRelayOutcomeKind::Restricted,
+ PublishRelayOutcomeKind::AuthRequired => RadrootsRelayOutcomeKind::AuthRequired,
+ PublishRelayOutcomeKind::Muted => RadrootsRelayOutcomeKind::Muted,
+ PublishRelayOutcomeKind::Unsupported => RadrootsRelayOutcomeKind::Unsupported,
+ PublishRelayOutcomeKind::PaymentRequired => RadrootsRelayOutcomeKind::PaymentRequired,
+ PublishRelayOutcomeKind::Error => RadrootsRelayOutcomeKind::Error,
+ PublishRelayOutcomeKind::Timeout => RadrootsRelayOutcomeKind::Timeout,
+ PublishRelayOutcomeKind::ConnectionFailed => RadrootsRelayOutcomeKind::ConnectionFailed,
+ PublishRelayOutcomeKind::RelayUrlRejected => RadrootsRelayOutcomeKind::RelayUrlRejected,
+ PublishRelayOutcomeKind::SkippedAlreadyAccepted => {
+ RadrootsRelayOutcomeKind::SkippedAlreadyAccepted
+ }
+ PublishRelayOutcomeKind::Unknown => RadrootsRelayOutcomeKind::Unknown,
+ }
+}
+
#[cfg(test)]
#[path = "../../tests/unit/adapters_radrootsd_tests.rs"]
mod tests;
diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs
@@ -590,6 +590,18 @@ impl From<radroots_relay_transport::RadrootsRelayTransportError> for RadrootsSdk
radroots_relay_transport::RadrootsRelayTransportError::RelayUrlQueryOrFragment {
url,
} => Self::invalid_relay_url(url, "relay URL must not include query or fragment"),
+ radroots_relay_transport::RadrootsRelayTransportError::RelayUrlForbiddenDestination {
+ url,
+ reason,
+ } => Self::invalid_relay_url(url, reason),
+ radroots_relay_transport::RadrootsRelayTransportError::RelayUrlResolvedForbiddenDestination {
+ url,
+ address,
+ reason,
+ } => Self::invalid_relay_url(
+ url,
+ format!("relay URL resolved to forbidden address `{address}`: {reason}"),
+ ),
radroots_relay_transport::RadrootsRelayTransportError::EmptyTargetSet => {
Self::empty_target_relays("relay publish")
}
diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs
@@ -7,7 +7,7 @@ extern crate alloc;
#[cfg(feature = "runtime")]
mod actor_json;
#[cfg(any(
- feature = "radrootsd-client",
+ feature = "radrootsd-proxy",
feature = "signing",
feature = "relay-client",
feature = "signer-adapters"
@@ -89,8 +89,8 @@ pub use crate::runtime::{
RadrootsSdkBuilder, RadrootsSdkClock, RadrootsSdkStorageConfig, RadrootsSdkStoragePaths,
RadrootsSdkTimestamp, RestoreArchive, RestoreReceipt, RestoreRequest, SdkBackupManifest,
SdkBackupManifestKind, SdkBackupState, SdkBackupVerification, SdkEventStoreStorageStatus,
- SdkOutboxStorageStatus, SdkRestoreState, SdkSqliteStoreStatus, SdkStorageKind,
- StorageStatusReceipt, StorageStatusRequest,
+ SdkOutboxStorageStatus, SdkPublishTransport, SdkRestoreState, SdkSqliteStoreStatus,
+ SdkStorageKind, StorageStatusReceipt, StorageStatusRequest,
};
#[cfg(feature = "runtime")]
pub use crate::sync_runtime::{
diff --git a/crates/sdk/src/relay_targets.rs b/crates/sdk/src/relay_targets.rs
@@ -27,6 +27,7 @@ impl SdkRelayUrlPolicy {
pub enum SdkRelayTargetPolicy {
Explicit(SdkRelayTargetSet),
UseConfiguredRelays,
+ UsePublishTransport,
}
impl SdkRelayTargetPolicy {
@@ -44,6 +45,10 @@ impl SdkRelayTargetPolicy {
{
Ok(Self::Explicit(SdkRelayTargetSet::new(relays, url_policy)?))
}
+
+ pub fn use_publish_transport() -> Self {
+ Self::UsePublishTransport
+ }
}
impl serde::Serialize for SdkRelayTargetPolicy {
@@ -64,6 +69,11 @@ impl serde::Serialize for SdkRelayTargetPolicy {
state.serialize_field("kind", "use_configured_relays")?;
state.end()
}
+ Self::UsePublishTransport => {
+ let mut state = serializer.serialize_struct("SdkRelayTargetPolicy", 1)?;
+ state.serialize_field("kind", "use_publish_transport")?;
+ state.end()
+ }
}
}
}
diff --git a/crates/sdk/src/runtime.rs b/crates/sdk/src/runtime.rs
@@ -17,6 +17,9 @@ use std::{
time::{SystemTime, UNIX_EPOCH},
};
+#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))]
+use crate::adapters::radrootsd::RadrootsdProxyConfig;
+
#[cfg(feature = "runtime")]
const SDK_STORAGE_MANIFEST_VERSION: u16 = 1;
#[cfg(feature = "runtime")]
@@ -328,6 +331,33 @@ pub enum SdkRestoreState {
}
#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq)]
+#[non_exhaustive]
+pub enum SdkPublishTransport {
+ DirectNostrRelay,
+ #[cfg(feature = "radrootsd-proxy")]
+ RadrootsdProxy(RadrootsdProxyConfig),
+}
+
+#[cfg(feature = "runtime")]
+impl Default for SdkPublishTransport {
+ fn default() -> Self {
+ Self::DirectNostrRelay
+ }
+}
+
+#[cfg(feature = "runtime")]
+impl SdkPublishTransport {
+ pub(crate) fn supports_delegated_relay_resolution(&self) -> bool {
+ match self {
+ Self::DirectNostrRelay => false,
+ #[cfg(feature = "radrootsd-proxy")]
+ Self::RadrootsdProxy(_) => true,
+ }
+ }
+}
+
+#[cfg(feature = "runtime")]
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct RestoreArchive {
pub source: PathBuf,
@@ -360,6 +390,7 @@ pub struct RadrootsSdkBuilder {
clock: RadrootsSdkClock,
relay_urls: Vec<String>,
relay_url_policy: SdkRelayUrlPolicy,
+ publish_transport: SdkPublishTransport,
}
#[cfg(feature = "runtime")]
@@ -370,6 +401,7 @@ impl Default for RadrootsSdkBuilder {
clock: RadrootsSdkClock::System,
relay_urls: Vec::new(),
relay_url_policy: SdkRelayUrlPolicy::Public,
+ publish_transport: SdkPublishTransport::DirectNostrRelay,
}
}
}
@@ -406,6 +438,11 @@ impl RadrootsSdkBuilder {
self
}
+ pub fn publish_transport(mut self, transport: SdkPublishTransport) -> Self {
+ self.publish_transport = transport;
+ self
+ }
+
pub async fn build(self) -> Result<RadrootsSdk, RadrootsSdkError> {
let storage = open_storage(&self.storage).await?;
let relay_urls =
@@ -416,6 +453,7 @@ impl RadrootsSdkBuilder {
storage_paths: storage.paths,
clock: self.clock,
relay_urls,
+ publish_transport: self.publish_transport,
})
}
}
@@ -428,6 +466,7 @@ pub struct RadrootsSdk {
storage_paths: Option<RadrootsSdkStoragePaths>,
clock: RadrootsSdkClock,
relay_urls: Vec<String>,
+ publish_transport: SdkPublishTransport,
}
#[cfg(feature = "runtime")]
@@ -460,6 +499,10 @@ impl RadrootsSdk {
&self.relay_urls
}
+ pub fn publish_transport(&self) -> &SdkPublishTransport {
+ &self.publish_transport
+ }
+
pub fn storage_paths(&self) -> Option<&RadrootsSdkStoragePaths> {
self.storage_paths.as_ref()
}
diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs
@@ -1,3 +1,7 @@
+#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))]
+use crate::adapters::radrootsd::{
+ RadrootsdError, RadrootsdProxyPublishAdapter, RadrootsdProxyPublishRequest,
+};
#[cfg(feature = "runtime")]
use crate::{RadrootsSdkError, SdkRelayUrlPolicy, SyncClient, runtime::sdk_now_ms};
#[cfg(feature = "runtime")]
@@ -7,7 +11,11 @@ use radroots_events::ids::RadrootsEventId;
#[cfg(all(feature = "runtime", feature = "relay-runtime"))]
use radroots_nostr::prelude::RadrootsNostrClient;
#[cfg(feature = "runtime")]
-use radroots_outbox::{RadrootsOutboxEventState, RadrootsOutboxStatusSummary};
+use radroots_outbox::{
+ RadrootsOutboxClaimedEvent, RadrootsOutboxEventState, RadrootsOutboxStatusSummary,
+};
+#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))]
+use radroots_publish_proxy_protocol::PublishDeliveryPolicy;
#[cfg(all(feature = "runtime", feature = "relay-runtime"))]
use radroots_relay_transport::RadrootsNostrClientPublishAdapter;
#[cfg(feature = "runtime")]
@@ -310,9 +318,14 @@ pub enum PushOutboxRelayOutcomeKind {
PowRequired,
Restricted,
AuthRequired,
+ Muted,
+ Unsupported,
+ PaymentRequired,
Error,
Timeout,
ConnectionFailed,
+ RelayUrlRejected,
+ SkippedAlreadyAccepted,
Unknown,
}
@@ -328,9 +341,14 @@ impl From<RadrootsRelayOutcomeKind> for PushOutboxRelayOutcomeKind {
RadrootsRelayOutcomeKind::PowRequired => Self::PowRequired,
RadrootsRelayOutcomeKind::Restricted => Self::Restricted,
RadrootsRelayOutcomeKind::AuthRequired => Self::AuthRequired,
+ RadrootsRelayOutcomeKind::Muted => Self::Muted,
+ RadrootsRelayOutcomeKind::Unsupported => Self::Unsupported,
+ RadrootsRelayOutcomeKind::PaymentRequired => Self::PaymentRequired,
RadrootsRelayOutcomeKind::Error => Self::Error,
RadrootsRelayOutcomeKind::Timeout => Self::Timeout,
RadrootsRelayOutcomeKind::ConnectionFailed => Self::ConnectionFailed,
+ RadrootsRelayOutcomeKind::RelayUrlRejected => Self::RelayUrlRejected,
+ RadrootsRelayOutcomeKind::SkippedAlreadyAccepted => Self::SkippedAlreadyAccepted,
RadrootsRelayOutcomeKind::Unknown => Self::Unknown,
}
}
@@ -361,20 +379,30 @@ impl<'sdk> SyncClient<'sdk> {
&self,
request: PushOutboxRequest,
) -> Result<PushOutboxReceipt, RadrootsSdkError> {
- #[cfg(feature = "relay-runtime")]
- {
- let adapter =
- RadrootsNostrClientPublishAdapter::new(RadrootsNostrClient::new_signerless());
- self.push_outbox_with_adapter(&adapter, request).await
- }
-
- #[cfg(not(feature = "relay-runtime"))]
- {
- let _ = request;
- Err(RadrootsSdkError::ProductSyncUnsupported {
- operation: "sync.push_outbox",
- required_feature: "relay-runtime",
- })
+ match self.sdk.publish_transport() {
+ crate::runtime::SdkPublishTransport::DirectNostrRelay => {
+ #[cfg(feature = "relay-runtime")]
+ {
+ let adapter = RadrootsNostrClientPublishAdapter::new(
+ RadrootsNostrClient::new_signerless(),
+ );
+ self.push_outbox_with_adapter(&adapter, request).await
+ }
+
+ #[cfg(not(feature = "relay-runtime"))]
+ {
+ let _ = request;
+ Err(RadrootsSdkError::ProductSyncUnsupported {
+ operation: "sync.push_outbox",
+ required_feature: "relay-runtime",
+ })
+ }
+ }
+ #[cfg(feature = "radrootsd-proxy")]
+ crate::runtime::SdkPublishTransport::RadrootsdProxy(config) => {
+ let adapter = RadrootsdProxyPublishAdapter::new(config.clone());
+ self.push_outbox_with_proxy_adapter(&adapter, request).await
+ }
}
}
@@ -427,6 +455,188 @@ impl<'sdk> SyncClient<'sdk> {
}
Ok(receipt)
}
+
+ #[cfg(feature = "radrootsd-proxy")]
+ pub async fn push_outbox_with_proxy_adapter(
+ &self,
+ adapter: &RadrootsdProxyPublishAdapter,
+ request: PushOutboxRequest,
+ ) -> Result<PushOutboxReceipt, RadrootsSdkError> {
+ request.validate()?;
+ let mut receipt = PushOutboxReceipt::default();
+ for _ in 0..request.limit {
+ let claim_now_ms = sdk_now_ms(self.sdk)?;
+ let claim_token = push_outbox_claim_token();
+ let Some(claimed) = self
+ .sdk
+ ._outbox
+ .claim_next_ready_signed_event(
+ CLAIM_OWNER,
+ claim_token.as_str(),
+ claim_now_ms.saturating_add(request.claim_ttl_ms),
+ claim_now_ms,
+ )
+ .await?
+ else {
+ break;
+ };
+ let publish_now_ms = claim_now_ms;
+ let publish = push_proxy_claimed_outbox_event(
+ self,
+ adapter,
+ &claimed,
+ request.next_attempt_delay_ms,
+ publish_now_ms,
+ )
+ .await?;
+ receipt.push_event(push_event_receipt(
+ claimed.outbox_event_id,
+ push_event_final_state(&publish),
+ publish,
+ ));
+ }
+ Ok(receipt)
+ }
+}
+
+#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))]
+async fn push_proxy_claimed_outbox_event(
+ sync: &SyncClient<'_>,
+ adapter: &RadrootsdProxyPublishAdapter,
+ claimed: &RadrootsOutboxClaimedEvent,
+ next_attempt_delay_ms: i64,
+ now_ms: i64,
+) -> Result<RadrootsRelayPublishReceipt, RadrootsSdkError> {
+ let signed_event = claimed.signed_event.clone().ok_or(
+ radroots_relay_transport::RadrootsRelayTransportError::MissingSignedOutboxEvent(
+ claimed.outbox_event_id,
+ ),
+ )?;
+ sync.sdk
+ ._outbox
+ .ingest_signed_event_local(
+ &sync.sdk._event_store,
+ claimed.outbox_event_id,
+ claimed.claim_token.as_str(),
+ now_ms,
+ )
+ .await?;
+ let request = RadrootsdProxyPublishRequest {
+ signed_event: signed_event.clone(),
+ relays: claimed.target_relays.clone(),
+ delivery_policy: proxy_delivery_policy(claimed.target_relays.len()),
+ idempotency_key: Some(proxy_outbox_idempotency_key(
+ claimed.outbox_event_id,
+ signed_event.id.as_str(),
+ )),
+ timeout_ms: adapter.config().request_timeout_ms,
+ };
+ let publish = match adapter.publish_signed_event(request).await {
+ Ok(publish) => publish,
+ Err(error) => {
+ let message = proxy_error_message(&error);
+ sync.sdk
+ ._outbox
+ .mark_publish_retryable(
+ claimed.outbox_event_id,
+ claimed.claim_token.as_str(),
+ message.as_str(),
+ now_ms.saturating_add(next_attempt_delay_ms),
+ now_ms,
+ )
+ .await?;
+ return Ok(proxy_transport_error_receipt(signed_event.id));
+ }
+ };
+ complete_proxy_publish_attempt(sync, claimed, &publish, next_attempt_delay_ms, now_ms).await?;
+ Ok(publish)
+}
+
+#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))]
+fn proxy_delivery_policy(target_count: usize) -> PublishDeliveryPolicy {
+ if target_count == 0 {
+ PublishDeliveryPolicy::Any
+ } else {
+ PublishDeliveryPolicy::All
+ }
+}
+
+#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))]
+fn proxy_outbox_idempotency_key(outbox_event_id: i64, event_id: &str) -> String {
+ format!("radroots-sdk-outbox-{outbox_event_id}-{event_id}")
+}
+
+#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))]
+async fn complete_proxy_publish_attempt(
+ sync: &SyncClient<'_>,
+ claimed: &RadrootsOutboxClaimedEvent,
+ publish: &RadrootsRelayPublishReceipt,
+ next_attempt_delay_ms: i64,
+ now_ms: i64,
+) -> Result<(), RadrootsSdkError> {
+ if publish.quorum_met {
+ sync.sdk
+ ._outbox
+ .set_publish_quorum(
+ claimed.outbox_event_id,
+ claimed.claim_token.as_str(),
+ 0,
+ now_ms,
+ )
+ .await?;
+ sync.sdk
+ ._outbox
+ .complete_publish_attempt(
+ claimed.outbox_event_id,
+ claimed.claim_token.as_str(),
+ "radrootsd proxy publish incomplete",
+ "radrootsd proxy publish terminal",
+ now_ms.saturating_add(next_attempt_delay_ms),
+ now_ms,
+ )
+ .await?;
+ } else if publish.retryable_count > 0 {
+ sync.sdk
+ ._outbox
+ .mark_publish_retryable(
+ claimed.outbox_event_id,
+ claimed.claim_token.as_str(),
+ "radrootsd proxy publish incomplete",
+ now_ms.saturating_add(next_attempt_delay_ms),
+ now_ms,
+ )
+ .await?;
+ } else {
+ sync.sdk
+ ._outbox
+ .mark_publish_failed_terminal(
+ claimed.outbox_event_id,
+ claimed.claim_token.as_str(),
+ "radrootsd proxy publish terminal",
+ now_ms,
+ )
+ .await?;
+ }
+ Ok(())
+}
+
+#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))]
+fn proxy_transport_error_receipt(event_id: String) -> RadrootsRelayPublishReceipt {
+ RadrootsRelayPublishReceipt {
+ event_id,
+ attempted_count: 1,
+ accepted_count: 0,
+ retryable_count: 1,
+ terminal_count: 0,
+ quorum: 1,
+ quorum_met: false,
+ relays: Vec::new(),
+ }
+}
+
+#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))]
+fn proxy_error_message(error: &RadrootsdError) -> String {
+ format!("radrootsd proxy publish failed: {error}")
}
#[cfg(feature = "runtime")]
diff --git a/crates/sdk/src/workflow_runtime.rs b/crates/sdk/src/workflow_runtime.rs
@@ -42,7 +42,7 @@ pub(crate) async fn enqueue_signed_workflow(
request.operation_kind,
request.frozen_draft.expected_event_id.as_str(),
request.frozen_draft.expected_pubkey.as_str(),
- target_relays.canonical_relays(),
+ target_relays.canonical_relays.as_slice(),
),
};
let observed_at_ms = sdk_now_ms(sdk)?;
@@ -52,8 +52,8 @@ pub(crate) async fn enqueue_signed_workflow(
let ingest = RadrootsEventIngest::new(event, observed_at_ms)
.with_raw_json(signed_event.raw_json.clone());
let ingest_receipt = sdk._event_store.ingest_event(ingest).await?;
- let canonical_target_relays = target_relays.canonical_relays().to_vec();
- let target_relay_values = target_relays.into_vec();
+ let canonical_target_relays = target_relays.canonical_relays.clone();
+ let target_relay_values = target_relays.relays;
let partial_failure_digest_prefix = outbox_idempotency_digest_prefix(
request.operation_kind,
request.frozen_draft,
@@ -65,6 +65,7 @@ pub(crate) async fn enqueue_signed_workflow(
signed_event,
target_relay_values,
idempotency_key,
+ target_relays.allow_empty_target_relays,
ingest_receipt.inserted,
observed_at_ms,
);
@@ -101,14 +102,46 @@ pub(crate) async fn enqueue_signed_workflow(
})
}
+struct SdkResolvedRelayTargets {
+ relays: Vec<String>,
+ canonical_relays: Vec<String>,
+ allow_empty_target_relays: bool,
+}
+
fn resolved_target_relays(
sdk: &RadrootsSdk,
target_relays: &SdkRelayTargetPolicy,
-) -> Result<SdkRelayTargetSet, RadrootsSdkError> {
+) -> Result<SdkResolvedRelayTargets, RadrootsSdkError> {
match target_relays {
- SdkRelayTargetPolicy::Explicit(target_relays) => Ok(target_relays.clone()),
+ SdkRelayTargetPolicy::Explicit(target_relays) => Ok(SdkResolvedRelayTargets {
+ relays: target_relays.relays().to_vec(),
+ canonical_relays: target_relays.canonical_relays().to_vec(),
+ allow_empty_target_relays: false,
+ }),
SdkRelayTargetPolicy::UseConfiguredRelays => {
- SdkRelayTargetSet::from_normalized_relays(sdk.relay_urls().to_vec())
+ let target_relays =
+ SdkRelayTargetSet::from_normalized_relays(sdk.relay_urls().to_vec())?;
+ Ok(SdkResolvedRelayTargets {
+ relays: target_relays.relays().to_vec(),
+ canonical_relays: target_relays.canonical_relays().to_vec(),
+ allow_empty_target_relays: false,
+ })
+ }
+ SdkRelayTargetPolicy::UsePublishTransport => {
+ if sdk
+ .publish_transport()
+ .supports_delegated_relay_resolution()
+ {
+ Ok(SdkResolvedRelayTargets {
+ relays: Vec::new(),
+ canonical_relays: Vec::new(),
+ allow_empty_target_relays: true,
+ })
+ } else {
+ Err(RadrootsSdkError::empty_target_relays(
+ "publish transport relay resolution",
+ ))
+ }
}
}
}
@@ -153,10 +186,11 @@ fn signed_outbox_input(
signed_event: RadrootsSignedNostrEvent,
target_relays: Vec<String>,
idempotency_key: SdkIdempotencyKey,
+ allow_empty_target_relays: bool,
event_store_inserted: bool,
observed_at_ms: i64,
) -> RadrootsOutboxSignedOperationInput {
- RadrootsOutboxSignedOperationInput::new(
+ let input = RadrootsOutboxSignedOperationInput::new(
operation_kind,
frozen_draft.clone(),
signed_event,
@@ -165,7 +199,12 @@ fn signed_outbox_input(
observed_at_ms,
observed_at_ms,
)
- .with_idempotency_key(idempotency_key.into_string())
+ .with_idempotency_key(idempotency_key.into_string());
+ if allow_empty_target_relays {
+ input.allow_empty_target_relays()
+ } else {
+ input
+ }
}
fn event_from_signed(signed_event: &RadrootsSignedNostrEvent) -> RadrootsNostrEvent {
diff --git a/crates/sdk/tests/runtime_foundation.rs b/crates/sdk/tests/runtime_foundation.rs
@@ -48,10 +48,11 @@ async fn sdk_builder_rejects_ws_relay_without_localhost_policy() {
.build()
.await;
- assert!(matches!(
- result,
- Err(RadrootsSdkError::InvalidRelayUrl { .. })
- ));
+ match result {
+ Err(RadrootsSdkError::InvalidRelayUrl { .. }) => {}
+ Err(error) => panic!("unexpected builder error: {error}"),
+ Ok(_) => panic!("builder accepted ws relay without localhost policy"),
+ }
}
#[test]
diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs
@@ -30,8 +30,16 @@ use radroots_sdk::{
SdkBackupManifestKind, SdkRelayAuthPolicy, SdkRelayTargetPolicy, SdkRelayUrlPolicy,
SdkRestoreState, StorageStatusRequest, SyncStatusRequest, SyncStatusSource,
};
+#[cfg(feature = "radrootsd-proxy")]
+use radroots_sdk::{SdkPublishTransport, adapters::radrootsd::RadrootsdProxyConfig};
+#[cfg(feature = "radrootsd-proxy")]
+use std::io::{Read, Write};
+#[cfg(feature = "radrootsd-proxy")]
+use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
+#[cfg(feature = "radrootsd-proxy")]
+use std::thread::JoinHandle;
use std::time::Duration;
const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
@@ -55,6 +63,11 @@ struct FixtureSigner {
struct TransportFailurePublishAdapter;
+#[cfg(feature = "radrootsd-proxy")]
+struct RecordedProxyRequest {
+ body: String,
+}
+
#[derive(Clone)]
struct RecordingPublishAdapter {
delay: Duration,
@@ -62,6 +75,96 @@ struct RecordingPublishAdapter {
request_times_ms: Arc<Mutex<Vec<i64>>>,
}
+#[cfg(feature = "radrootsd-proxy")]
+fn spawn_publish_proxy_server() -> (String, JoinHandle<RecordedProxyRequest>) {
+ let listener = TcpListener::bind("127.0.0.1:0").expect("bind proxy server");
+ let endpoint = format!("http://{}/rpc", listener.local_addr().expect("addr"));
+ let handle = std::thread::spawn(move || {
+ let (mut stream, _) = listener.accept().expect("accept");
+ let mut request = Vec::new();
+ let mut buffer = [0u8; 1024];
+ loop {
+ let read = stream.read(&mut buffer).expect("read request");
+ if read == 0 {
+ break;
+ }
+ request.extend_from_slice(&buffer[..read]);
+ if request.windows(4).any(|window| window == b"\r\n\r\n") {
+ let headers_end = request
+ .windows(4)
+ .position(|window| window == b"\r\n\r\n")
+ .expect("headers end")
+ + 4;
+ let header_text = String::from_utf8_lossy(&request[..headers_end]);
+ let content_length = header_text
+ .lines()
+ .find_map(|line| {
+ let (name, value) = line.split_once(':')?;
+ name.eq_ignore_ascii_case("content-length")
+ .then(|| value.trim().parse::<usize>().expect("content length"))
+ })
+ .unwrap_or(0);
+ while request.len() < headers_end + content_length {
+ let read = stream.read(&mut buffer).expect("read body");
+ if read == 0 {
+ break;
+ }
+ request.extend_from_slice(&buffer[..read]);
+ }
+ break;
+ }
+ }
+ let request_text = String::from_utf8_lossy(&request);
+ let (_, body) = request_text.split_once("\r\n\r\n").expect("request body");
+ let body_json: serde_json::Value = serde_json::from_str(body).expect("body json");
+ let event = &body_json["params"]["event"];
+ let response_body = serde_json::json!({
+ "jsonrpc": "2.0",
+ "id": body_json["id"],
+ "result": {
+ "deduplicated": false,
+ "job": {
+ "job_id": "job-1",
+ "status": "delivery_satisfied",
+ "terminal": true,
+ "delivery_satisfied": true,
+ "event_id": event["id"],
+ "pubkey": event["pubkey"],
+ "event_kind": event["kind"],
+ "relay_policy": body_json["params"]["relay_policy"],
+ "delivery_policy": body_json["params"]["delivery_policy"],
+ "relay_count": 1,
+ "acknowledged_count": 1,
+ "retryable_count": 0,
+ "terminal_count": 0,
+ "requested_at_ms": 1700000000000i64,
+ "completed_at_ms": 1700000000100i64,
+ "relays": [{
+ "relay_url": "wss://daemon-resolved.example.com",
+ "source": "daemon_default",
+ "attempted": true,
+ "outcome_kind": "accepted",
+ "message": "accepted"
+ }]
+ }
+ }
+ })
+ .to_string();
+ let response = format!(
+ "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
+ response_body.len(),
+ response_body
+ );
+ stream
+ .write_all(response.as_bytes())
+ .expect("write response");
+ RecordedProxyRequest {
+ body: body.to_owned(),
+ }
+ });
+ (endpoint, handle)
+}
+
impl RadrootsRelayPublishAdapter for TransportFailurePublishAdapter {
fn publish<'a>(
&'a self,
@@ -1185,6 +1288,79 @@ async fn push_outbox_empty_queue_returns_zero_counts() {
assert!(adapter.captured_raw_events().is_empty());
}
+#[cfg(feature = "radrootsd-proxy")]
+#[tokio::test]
+async fn product_push_outbox_uses_radrootsd_proxy_transport_with_daemon_resolved_relays() {
+ let (endpoint, handle) = spawn_publish_proxy_server();
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ let sdk = RadrootsSdk::builder()
+ .directory_storage(tempdir.path().join("sdk"))
+ .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000))
+ .publish_transport(SdkPublishTransport::RadrootsdProxy(
+ RadrootsdProxyConfig::new(endpoint),
+ ))
+ .build()
+ .await
+ .expect("sdk");
+
+ let enqueue = sdk
+ .listings()
+ .enqueue_publish(
+ ListingEnqueuePublishRequest::new(
+ actor(),
+ listing(LISTING_A_D_TAG, "Proxy Coffee"),
+ SdkRelayTargetPolicy::use_publish_transport(),
+ ),
+ &FixtureSigner::new(SELLER),
+ )
+ .await
+ .expect("enqueue");
+
+ let receipt = sdk
+ .sync()
+ .push_outbox(PushOutboxRequest::new().with_limit(1))
+ .await
+ .expect("proxy push");
+
+ assert_eq!(receipt.attempted_events, 1);
+ assert_eq!(receipt.published_events, 1);
+ assert_eq!(receipt.events[0].outbox_event_id, enqueue.outbox_event_id);
+ assert_eq!(
+ receipt.events[0].final_state,
+ PushOutboxEventState::Published
+ );
+ assert_eq!(receipt.events[0].relays.len(), 1);
+ assert_eq!(
+ receipt.events[0].relays[0].relay_url,
+ "wss://daemon-resolved.example.com"
+ );
+ assert_eq!(
+ receipt.events[0].relays[0].outcome_kind,
+ PushOutboxRelayOutcomeKind::Accepted
+ );
+
+ let recorded = handle.join().expect("proxy request");
+ let body: serde_json::Value = serde_json::from_str(recorded.body.as_str()).expect("body");
+ assert_eq!(body["method"], "publish.event");
+ assert_eq!(body["params"]["relays"], serde_json::json!([]));
+ assert_eq!(
+ body["params"]["relay_policy"],
+ "request_then_author_write_then_daemon_default"
+ );
+ assert_eq!(body["params"]["delivery_policy"]["mode"], "any");
+ assert!(body["params"]["event"]["sig"].as_str().is_some());
+ assert!(!recorded.body.contains("bridge."));
+ assert!(!recorded.body.contains("signer_session_id"));
+
+ let status = sdk
+ .sync()
+ .status(SyncStatusRequest::new())
+ .await
+ .expect("status");
+ assert_eq!(status.outbox.terminal_events, 1);
+ assert_eq!(status.outbox.ready_signed_events, 0);
+}
+
#[test]
fn push_outbox_contract_dtos_serialize_deterministically() {
let request = PushOutboxRequest::new()
diff --git a/crates/sdk/tests/unit/adapters_radrootsd_tests.rs b/crates/sdk/tests/unit/adapters_radrootsd_tests.rs
@@ -1,12 +1,7 @@
use super::*;
-use crate::farm::RadrootsFarmRef;
-use crate::listing::{
- RadrootsListingAvailability, RadrootsListingBin, RadrootsListingDeliveryMethod,
- RadrootsListingLocation, RadrootsListingProduct, RadrootsListingStatus,
-};
-use radroots_core::{
- RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
- RadrootsCoreQuantityPrice, RadrootsCoreUnit,
+use radroots_publish_proxy_protocol::{
+ PublishJobStatus, PublishJobView, PublishRelayOutcome, PublishRelayOutcomeKind,
+ PublishRelaySource,
};
use std::io::{Read, Write};
use std::net::TcpListener;
@@ -22,18 +17,11 @@ fn spawn_http_server(
status: &str,
response_body: &str,
) -> (String, JoinHandle<RecordedHttpRequest>) {
- spawn_http_server_with_content_length(status, response_body, response_body.len())
-}
-
-fn spawn_http_server_with_content_length(
- status: &str,
- response_body: &str,
- content_length: usize,
-) -> (String, JoinHandle<RecordedHttpRequest>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
let endpoint = format!("http://{}/rpc", listener.local_addr().expect("addr"));
let status = status.to_owned();
let response_body = response_body.to_owned();
+ let content_length = response_body.len();
let handle = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept");
let mut request = Vec::new();
@@ -51,7 +39,7 @@ fn spawn_http_server_with_content_length(
.expect("headers end")
+ 4;
let header_text = String::from_utf8_lossy(&request[..headers_end]);
- let content_length = header_text
+ let request_content_length = header_text
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
@@ -59,7 +47,7 @@ fn spawn_http_server_with_content_length(
.then(|| value.trim().parse::<usize>().expect("content length"))
})
.unwrap_or(0);
- while request.len() < headers_end + content_length {
+ while request.len() < headers_end + request_content_length {
let read = stream.read(&mut buffer).expect("read body");
if read == 0 {
break;
@@ -94,127 +82,78 @@ fn spawn_http_server_with_content_length(
(endpoint, handle)
}
-fn sample_listing() -> RadrootsListing {
- RadrootsListing {
- d_tag: "AAAAAAAAAAAAAAAAAAAAAg".parse().expect("listing d tag"),
- published_at: None,
- farm: RadrootsFarmRef {
- pubkey: "a".repeat(64),
- d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(),
- },
- product: RadrootsListingProduct {
- key: "coffee".into(),
- title: "Coffee".into(),
- category: "coffee".into(),
- summary: Some("Single origin coffee".into()),
- process: None,
- lot: None,
- location: None,
- profile: None,
- year: None,
- },
- primary_bin_id: "bin-1".parse().expect("primary bin id"),
- bins: vec![RadrootsListingBin {
- bin_id: "bin-1".parse().expect("bin id"),
- quantity: RadrootsCoreQuantity::new(
- RadrootsCoreDecimal::from(1000u32),
- RadrootsCoreUnit::MassG,
- ),
- price_per_canonical_unit: RadrootsCoreQuantityPrice {
- amount: RadrootsCoreMoney::new(
- RadrootsCoreDecimal::from(20u32),
- RadrootsCoreCurrency::USD,
- ),
- quantity: RadrootsCoreQuantity::new(
- RadrootsCoreDecimal::from(1u32),
- RadrootsCoreUnit::MassG,
- ),
- },
- display_amount: None,
- display_unit: None,
- display_label: None,
- display_price: None,
- display_price_unit: None,
- }],
- resource_area: None,
- plot: None,
- discounts: None,
- inventory_available: Some(RadrootsCoreDecimal::from(5u32)),
- availability: Some(RadrootsListingAvailability::Status {
- status: RadrootsListingStatus::Active,
- }),
- delivery_method: Some(RadrootsListingDeliveryMethod::Pickup),
- location: Some(RadrootsListingLocation {
- primary: "North Farm".into(),
- city: None,
- region: None,
- country: None,
- lat: None,
- lng: None,
- geohash: None,
- }),
- images: None,
+fn signed_event() -> RadrootsSignedNostrEvent {
+ RadrootsSignedNostrEvent {
+ id: "a".repeat(64),
+ pubkey: "b".repeat(64),
+ created_at: 1_700_000_000,
+ kind: 30_402,
+ tags: vec![vec!["d".to_owned(), "listing-1".to_owned()]],
+ content: "{\"name\":\"carrots\"}".to_owned(),
+ sig: "c".repeat(128),
+ raw_json: serde_json::json!({
+ "id": "a".repeat(64),
+ "pubkey": "b".repeat(64),
+ "created_at": 1_700_000_000u32,
+ "kind": 30402u32,
+ "tags": [["d", "listing-1"]],
+ "content": "{\"name\":\"carrots\"}",
+ "sig": "c".repeat(128)
+ })
+ .to_string(),
}
}
-fn sample_profile() -> RadrootsProfile {
- RadrootsProfile {
- name: "North Farm".into(),
- display_name: Some("North Farm".into()),
- nip05: None,
- about: Some("Organic coffee".into()),
- website: Some("https://example.com".into()),
- picture: None,
- banner: None,
- lud06: None,
- lud16: None,
- bot: None,
+fn publish_request() -> PublishEventRequest {
+ PublishEventRequest {
+ event: signed_event_wire(&signed_event()),
+ relays: vec!["wss://relay.example.com".to_owned()],
+ relay_policy: PublishRelayPolicy::RequestThenAuthorWriteThenDaemonDefault,
+ delivery_policy: PublishDeliveryPolicy::Any,
+ idempotency_key: Some("idem-1".to_owned()),
+ timeout_ms: Some(10_000),
}
}
-fn sample_farm() -> RadrootsFarm {
- RadrootsFarm {
- d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(),
- name: "North Farm".into(),
- about: Some("Organic coffee".into()),
- website: None,
- picture: None,
- banner: None,
- location: None,
- tags: Some(vec!["coffee".into()]),
- }
-}
-
-fn sample_listing_event(kind: u32) -> RadrootsNostrEvent {
- let listing = sample_listing();
- let parts = listing::build_draft(&listing).expect("listing draft");
- RadrootsNostrEvent {
- id: "event-1".into(),
- author: listing.farm.pubkey,
- created_at: 1,
- kind,
- tags: parts.as_wire_parts().tags.clone(),
- content: parts.as_wire_parts().content.clone(),
- sig: String::new(),
- }
-}
-
-fn sample_authority() -> SdkRadrootsdSignerAuthority {
- SdkRadrootsdSignerAuthority {
- provider_runtime_id: "local-runtime".into(),
- account_identity_id: "account-1".into(),
- provider_signer_session_id: Some("provider-session-secret".into()),
+fn job(outcome_kind: PublishRelayOutcomeKind) -> PublishJobView {
+ PublishJobView {
+ job_id: "job-1".to_owned(),
+ status: PublishJobStatus::DeliverySatisfied,
+ terminal: true,
+ delivery_satisfied: true,
+ event_id: "a".repeat(64),
+ pubkey: "b".repeat(64),
+ event_kind: 30_402,
+ relay_policy: PublishRelayPolicy::RequestThenAuthorWriteThenDaemonDefault,
+ delivery_policy: PublishDeliveryPolicy::Any,
+ relay_count: 1,
+ acknowledged_count: usize::from(outcome_kind.counts_toward_quorum()),
+ retryable_count: usize::from(outcome_kind.is_retryable()),
+ terminal_count: usize::from(outcome_kind.is_terminal_failure()),
+ requested_at_ms: 1_700_000_000_000,
+ completed_at_ms: Some(1_700_000_000_100),
+ last_error: None,
+ relays: vec![PublishRelayOutcome {
+ relay_url: "wss://relay.example.com".to_owned(),
+ source: PublishRelaySource::Request,
+ attempted: true,
+ outcome_kind,
+ message: Some("relay outcome".to_owned()),
+ latency_ms: Some(7),
+ }],
}
}
-fn sample_listing_publish_request() -> SdkRadrootsdListingPublishRequest {
- SdkRadrootsdListingPublishRequest {
- listing: sample_listing(),
- kind: Some(KIND_LISTING),
- signer_session_id: "signer-session-secret".into(),
- signer_authority: Some(sample_authority()),
- idempotency_key: Some("idem-1".into()),
- }
+fn publish_response_json() -> String {
+ serde_json::json!({
+ "jsonrpc": "2.0",
+ "id": SDK_RADROOTSD_PROXY_REQUEST_ID,
+ "result": {
+ "deduplicated": false,
+ "job": job(PublishRelayOutcomeKind::Accepted)
+ }
+ })
+ .to_string()
}
fn assert_message(error: RadrootsdError, fragment: &str) {
@@ -226,495 +165,179 @@ fn assert_message(error: RadrootsdError, fragment: &str) {
}
#[test]
-fn auth_headers_omit_authorization_when_auth_is_none() {
- let headers = auth_headers(&RadrootsdAuth::None).expect("headers");
-
- assert!(!headers.contains_key(AUTHORIZATION));
-}
-
-#[test]
-fn auth_headers_build_bearer_authorization() {
- let headers = auth_headers(&RadrootsdAuth::BearerToken("sdk-token".into())).expect("headers");
+fn auth_headers_omit_or_redact_bearer_authorization() {
+ let none = auth_headers(&RadrootsdAuth::None).expect("none auth");
+ assert!(!none.contains_key(AUTHORIZATION));
+ let bearer = auth_headers(&RadrootsdAuth::BearerToken("sdk-token".into())).expect("bearer");
assert_eq!(
- headers
+ bearer
.get(AUTHORIZATION)
.expect("authorization")
.to_str()
.expect("authorization str"),
"Bearer sdk-token"
);
-}
-#[test]
-fn auth_headers_reject_invalid_bearer_header_values() {
let error = auth_headers(&RadrootsdAuth::BearerToken("bad\ntoken".into())).expect_err("error");
-
assert!(matches!(error, RadrootsdError::InvalidAuthHeader(_)));
-}
-
-#[test]
-fn bridge_listing_publish_request_json_preserves_request_contract() {
- let value =
- bridge_listing_publish_request_json(&sample_listing_publish_request()).expect("json");
-
- assert_eq!(value["kind"], KIND_LISTING);
- assert_eq!(value["signer_session_id"], "signer-session-secret");
assert_eq!(
- value["signer_authority"]["provider_signer_session_id"],
- "provider-session-secret"
+ format!("{:?}", RadrootsdAuth::BearerToken("token-secret".into())),
+ "BearerToken(<redacted>)"
);
- assert_eq!(value["idempotency_key"], "idem-1");
- assert_eq!(value["listing"]["product"]["title"], "Coffee");
}
#[test]
-fn listing_publish_request_from_event_parses_listing_and_rejects_wrong_kind() {
- let event = sample_listing_event(KIND_LISTING);
- let request = SdkRadrootsdListingPublishRequest::from_event(
- &event,
- "signer-session-secret",
- Some(sample_authority()),
- Some("idem-1".to_owned()),
- )
- .expect("request");
+fn publish_event_request_json_uses_signed_event_contract() {
+ let value = publish_event_request_json(&publish_request()).expect("request json");
- assert_eq!(request.kind, Some(KIND_LISTING));
- assert_eq!(request.signer_session_id, "signer-session-secret");
- assert_eq!(request.idempotency_key.as_deref(), Some("idem-1"));
- assert_eq!(request.listing.product.title, "Coffee");
-
- let wrong_kind = sample_listing_event(1);
+ assert_eq!(value["event"]["id"], "a".repeat(64));
+ assert_eq!(value["event"]["pubkey"], "b".repeat(64));
+ assert_eq!(value["event"]["kind"], 30_402);
+ assert_eq!(value["relays"][0], "wss://relay.example.com");
assert_eq!(
- SdkRadrootsdListingPublishRequest::from_event(&wrong_kind, "session", None, None)
- .expect_err("wrong kind"),
- listing::RadrootsListingParseError::InvalidKind(1)
- );
-
- let mut malformed = sample_listing_event(KIND_LISTING);
- malformed.tags = Vec::new();
- let malformed_error =
- SdkRadrootsdListingPublishRequest::from_event(&malformed, "session", None, None)
- .expect_err("malformed listing");
- assert!(!malformed_error.to_string().is_empty());
-}
-
-#[tokio::test]
-async fn jsonrpc_call_rejects_invalid_auth_before_transport() {
- let error = jsonrpc_call::<_, Value>(
- "http://127.0.0.1:9/rpc",
- &RadrootsdAuth::BearerToken("bad\ntoken".to_owned()),
- "1",
- "listing.publish",
- &json!({}),
- core::time::Duration::from_millis(10),
- )
- .await
- .expect_err("invalid auth");
- assert!(matches!(error, RadrootsdError::InvalidAuthHeader(_)));
-}
-
-#[test]
-fn debug_output_redacts_auth_and_signer_secrets() {
- let auth = RadrootsdAuth::BearerToken("token-secret".into());
- let none_auth = RadrootsdAuth::None;
- let bunker = SdkRadrootsdSignerSessionConnectRequest::bunker("bunker://session");
- let connect =
- SdkRadrootsdSignerSessionConnectRequest::nostrconnect("nostrconnect://session", "nsec")
- .with_signer_authority(sample_authority());
- let profile_request = SdkRadrootsdProfilePublishRequest {
- profile: sample_profile(),
- profile_type: Some(RadrootsProfileType::Farm),
- signer_session_id: "profile-session-secret".into(),
- signer_authority: Some(sample_authority()),
- idempotency_key: Some("profile-idem".into()),
- };
- let farm_request = SdkRadrootsdFarmPublishRequest {
- farm: sample_farm(),
- kind: Some(30_000),
- signer_session_id: "farm-session-secret".into(),
- signer_authority: Some(sample_authority()),
- idempotency_key: Some("farm-idem".into()),
- };
- let listing_request = sample_listing_publish_request();
- let job = SdkRadrootsdBridgeJob {
- job_id: "job-1".into(),
- command: "bridge.listing.publish".into(),
- status: "accepted".into(),
- terminal: false,
- recovered_after_restart: false,
- signer_mode: "bunker".into(),
- signer_session_id: Some("signer-session-secret".into()),
- event_kind: KIND_LISTING,
- event_id: Some("event-1".into()),
- event_addr: Some("30402:pubkey:d-tag".into()),
- relay_count: 2,
- acknowledged_relay_count: 1,
- };
- let job_view = SdkRadrootsdBridgeJobView {
- job_id: "job-view-1".into(),
- command: "bridge.listing.publish".into(),
- idempotency_key: Some("view-idem".into()),
- status: SdkRadrootsdBridgeJobStatus::Accepted,
- terminal: false,
- recovered_after_restart: true,
- requested_at_unix: 1,
- completed_at_unix: Some(2),
- signer_mode: "nostrconnect".into(),
- signer_session_id: Some("view-session-secret".into()),
- event_kind: KIND_LISTING,
- event_id: Some("event-1".into()),
- event_addr: Some("30402:pubkey:d-tag".into()),
- delivery_policy: SdkRadrootsdBridgeDeliveryPolicy::Quorum,
- delivery_quorum: Some(2),
- relay_count: 3,
- acknowledged_relay_count: 2,
- required_acknowledged_relay_count: 2,
- attempt_count: 1,
- attempt_summaries: vec!["ok".into()],
- relay_results: vec![SdkRadrootsdBridgeRelayPublishResult {
- relay_url: "wss://relay.example.com".into(),
- acknowledged: true,
- detail: Some("accepted".into()),
- }],
- relay_outcome_summary: "2/3 acknowledged".into(),
- };
-
- let rendered = format!(
- "{none_auth:?} {auth:?} {bunker:?} {connect:?} {profile_request:?} {farm_request:?} {listing_request:?} {job:?} {job_view:?}"
- );
-
- assert!(rendered.contains("None"));
- assert!(rendered.contains("<redacted>"));
- assert!(!rendered.contains("token-secret"));
- assert!(!rendered.contains("nsec"));
- assert!(!rendered.contains("provider-session-secret"));
- assert!(!rendered.contains("signer-session-secret"));
- assert!(!rendered.contains("profile-session-secret"));
- assert!(!rendered.contains("farm-session-secret"));
- assert!(!rendered.contains("view-session-secret"));
- assert!(!rendered.contains("signer_mode: \"bunker\""));
-}
-
-#[test]
-fn http_status_error_omits_raw_body() {
- let error = http_status_error(reqwest::StatusCode::UNAUTHORIZED, "missing secret token");
-
- let message = error.to_string();
- assert!(message.contains("radrootsd returned http 401"));
- assert!(message.contains("response body omitted"));
- assert!(!message.contains("missing secret token"));
-
- assert_message(
- http_status_error(reqwest::StatusCode::BAD_GATEWAY, ""),
- "response body empty",
- );
-}
-
-#[test]
-fn radrootsd_error_display_covers_all_variants() {
- assert_message(
- RadrootsdError::InvalidAuthHeader("bad header".into()),
- "invalid radrootsd bearer token header",
- );
- assert_message(RadrootsdError::Http("http".into()), "http");
- assert_message(RadrootsdError::JsonRpc("jsonrpc".into()), "jsonrpc");
- assert_message(
- RadrootsdError::MalformedResponse("malformed".into()),
- "malformed",
+ value["relay_policy"],
+ "request_then_author_write_then_daemon_default"
);
+ assert_eq!(value["delivery_policy"]["mode"], "any");
+ assert_eq!(value["idempotency_key"], "idem-1");
+ let rendered = value.to_string();
+ assert!(!rendered.contains("signer_session_id"));
+ assert!(!rendered.contains("bridge."));
}
#[test]
-fn decode_jsonrpc_response_returns_result() {
- let response: SdkRadrootsdBridgePublishResponse = decode_jsonrpc_response(
- "bridge.listing.publish",
- "radroots-sdk-listing-publish",
- r#"{
- "jsonrpc": "2.0",
- "id": "radroots-sdk-listing-publish",
- "result": {
- "deduplicated": false,
- "job": {
- "job_id": "job-1",
- "command": "bridge.listing.publish",
- "status": "accepted",
- "terminal": false,
- "recovered_after_restart": false,
- "signer_mode": "bunker",
- "signer_session_id": "signer-session-secret",
- "event_kind": 30402,
- "event_id": "event-1",
- "event_addr": "30402:pubkey:d-tag",
- "relay_count": 2,
- "acknowledged_relay_count": 1
- }
- }
- }"#,
+fn decode_jsonrpc_response_validates_envelope_and_errors() {
+ let response: PublishEventResponse = decode_jsonrpc_response(
+ METHOD_EVENT,
+ SDK_RADROOTSD_PROXY_REQUEST_ID,
+ publish_response_json().as_str(),
)
.expect("response");
+ assert_eq!(response.job.event_id, "a".repeat(64));
- assert!(!response.deduplicated);
- assert_eq!(response.job.job_id, "job-1");
- assert_eq!(
- response.job.signer_session_id.as_deref(),
- Some("signer-session-secret")
- );
-}
-
-#[test]
-fn decode_jsonrpc_response_returns_jsonrpc_error() {
- let error = decode_jsonrpc_response::<SdkRadrootsdBridgePublishResponse>(
- "bridge.listing.publish",
- "radroots-sdk-listing-publish",
- r#"{
- "jsonrpc": "2.0",
- "id": "radroots-sdk-listing-publish",
- "error": { "code": -32001, "message": "signer unavailable" }
- }"#,
+ let error = decode_jsonrpc_response::<PublishEventResponse>(
+ METHOD_EVENT,
+ SDK_RADROOTSD_PROXY_REQUEST_ID,
+ r#"{"jsonrpc":"2.0","id":"radroots-sdk-publish-event","error":{"code":-32001,"message":"principal unauthorized"}}"#,
)
- .expect_err("error");
-
- assert!(matches!(error, RadrootsdError::JsonRpc(_)));
- assert_message(
+ .expect_err("jsonrpc error");
+ assert!(matches!(
error,
- "radrootsd bridge.listing.publish failed -32001: signer unavailable",
- );
+ RadrootsdError::JsonRpc { code: -32001, .. }
+ ));
+ assert_message(error, "principal unauthorized");
+
+ assert!(matches!(
+ decode_jsonrpc_response::<PublishEventResponse>(
+ METHOD_EVENT,
+ "expected",
+ r#"{"jsonrpc":"2.0","id":"other","result":{}}"#
+ ),
+ Err(RadrootsdError::MalformedResponse(_))
+ ));
+ assert!(matches!(
+ decode_jsonrpc_response::<PublishEventResponse>(
+ METHOD_EVENT,
+ "expected",
+ r#"{"jsonrpc":"2.0","id":"expected"}"#
+ ),
+ Err(RadrootsdError::MalformedResponse(_))
+ ));
}
#[test]
-fn decode_jsonrpc_response_rejects_result_plus_error() {
- let error = decode_jsonrpc_response::<serde_json::Value>(
- "bridge.listing.publish",
- "radroots-sdk-listing-publish",
- r#"{
- "jsonrpc": "2.0",
- "id": "radroots-sdk-listing-publish",
- "result": { "ok": true },
- "error": { "code": -32002, "message": "ambiguous response" }
- }"#,
- )
- .expect_err("error");
-
- assert!(matches!(error, RadrootsdError::MalformedResponse(_)));
- assert_message(
- error,
- "radrootsd bridge.listing.publish returned result and error: -32002 ambiguous response",
+fn daemon_outcomes_map_to_relay_transport_receipts() {
+ let payment = proxy_receipt_from_response(PublishEventResponse {
+ deduplicated: false,
+ job: job(PublishRelayOutcomeKind::PaymentRequired),
+ })
+ .expect("payment receipt");
+ assert_eq!(
+ payment.relays[0].outcome.kind,
+ RadrootsRelayOutcomeKind::PaymentRequired
);
-}
-
-#[test]
-fn decode_jsonrpc_response_rejects_missing_result_and_error() {
- let error = decode_jsonrpc_response::<serde_json::Value>(
- "bridge.listing.publish",
- "radroots-sdk-listing-publish",
- r#"{ "jsonrpc": "2.0", "id": "radroots-sdk-listing-publish" }"#,
- )
- .expect_err("error");
+ assert_eq!(payment.terminal_count, 1);
- assert!(matches!(error, RadrootsdError::MalformedResponse(_)));
- assert_message(
- error,
- "radrootsd bridge.listing.publish returned neither result nor error",
+ let skipped = proxy_receipt_from_response(PublishEventResponse {
+ deduplicated: true,
+ job: job(PublishRelayOutcomeKind::SkippedAlreadyAccepted),
+ })
+ .expect("skipped receipt");
+ assert_eq!(
+ skipped.relays[0].outcome.kind,
+ RadrootsRelayOutcomeKind::SkippedAlreadyAccepted
);
-}
-
-#[test]
-fn decode_jsonrpc_response_rejects_malformed_json() {
- let error = decode_jsonrpc_response::<serde_json::Value>(
- "bridge.listing.publish",
- "radroots-sdk-listing-publish",
- r#"{ "result": "#,
- )
- .expect_err("error");
-
- assert!(matches!(error, RadrootsdError::MalformedResponse(_)));
- assert_message(error, "decode radrootsd bridge.listing.publish response");
-}
-
-#[test]
-fn decode_jsonrpc_response_rejects_invalid_version() {
- let error = decode_jsonrpc_response::<serde_json::Value>(
- "bridge.listing.publish",
- "radroots-sdk-listing-publish",
- r#"{
- "jsonrpc": "1.0",
- "id": "radroots-sdk-listing-publish",
- "result": { "ok": true }
- }"#,
- )
- .expect_err("error");
-
- assert_message(error, "returned invalid jsonrpc version");
-}
-
-#[test]
-fn decode_jsonrpc_response_rejects_mismatched_id() {
- let error = decode_jsonrpc_response::<serde_json::Value>(
- "bridge.listing.publish",
- "radroots-sdk-listing-publish",
- r#"{
- "jsonrpc": "2.0",
- "id": "other-id",
- "result": { "ok": true }
- }"#,
- )
- .expect_err("error");
-
- assert_message(error, "returned mismatched jsonrpc id");
+ assert!(skipped.quorum_met);
}
#[tokio::test]
-async fn publish_listing_uses_http_jsonrpc_request_path() {
- let (endpoint, handle) = spawn_http_server(
- "200 OK",
- r#"{
- "jsonrpc": "2.0",
- "id": "radroots-sdk-listing-publish",
- "result": {
- "deduplicated": true,
- "job": {
- "job_id": "job-1",
- "command": "bridge.listing.publish",
- "status": "accepted",
- "terminal": false,
- "recovered_after_restart": false,
- "signer_mode": "bunker",
- "signer_session_id": "signer-session-secret",
- "event_kind": 30402,
- "event_id": "event-1",
- "event_addr": "30402:pubkey:d-tag",
- "relay_count": 2,
- "acknowledged_relay_count": 1
- }
- }
- }"#,
- );
+async fn publish_event_posts_publish_proxy_jsonrpc() {
+ let (endpoint, handle) = spawn_http_server("200 OK", publish_response_json().as_str());
- let response = publish_listing(
- &endpoint,
+ let receipt = publish_event(
+ endpoint.as_str(),
&RadrootsdAuth::BearerToken("sdk-token".into()),
- &sample_listing_publish_request(),
- Duration::from_secs(5),
+ &publish_request(),
+ Duration::from_secs(2),
)
.await
- .expect("publish response");
- let request = handle.join().expect("request");
- let body = serde_json::from_str::<Value>(&request.body).expect("body json");
+ .expect("publish");
- assert!(response.deduplicated);
- assert_eq!(request.request_line, "POST /rpc HTTP/1.1");
+ assert_eq!(receipt.job.event_id, "a".repeat(64));
+ let recorded = handle.join().expect("server thread");
+ assert_eq!(recorded.request_line, "POST /rpc HTTP/1.1");
assert!(
- request
+ recorded
.headers
.iter()
- .any(|(name, value)| { name == "authorization" && value == "Bearer sdk-token" })
- );
- assert_eq!(body["jsonrpc"], "2.0");
- assert_eq!(body["id"], "radroots-sdk-listing-publish");
- assert_eq!(body["method"], "bridge.listing.publish");
- assert_eq!(
- body["params"]["signer_authority"]["provider_signer_session_id"],
- "provider-session-secret"
- );
-}
-
-#[tokio::test]
-async fn publish_listing_returns_jsonrpc_errors_from_http_path() {
- let (endpoint, handle) = spawn_http_server(
- "200 OK",
- r#"{
- "jsonrpc": "2.0",
- "id": "radroots-sdk-listing-publish",
- "error": { "code": -32001, "message": "signer unavailable" }
- }"#,
+ .any(|(name, value)| name == "authorization" && value == "Bearer sdk-token")
);
-
- let error = publish_listing(
- &endpoint,
- &RadrootsdAuth::None,
- &sample_listing_publish_request(),
- Duration::from_secs(5),
- )
- .await
- .expect_err("error");
- handle.join().expect("request");
-
- assert!(matches!(error, RadrootsdError::JsonRpc(_)));
- assert_message(error, "signer unavailable");
+ let body: serde_json::Value = serde_json::from_str(recorded.body.as_str()).expect("body");
+ assert_eq!(body["method"], METHOD_EVENT);
+ assert_eq!(body["id"], SDK_RADROOTSD_PROXY_REQUEST_ID);
+ assert_eq!(body["params"]["event"]["content"], "{\"name\":\"carrots\"}");
+ assert_eq!(body["params"]["relays"][0], "wss://relay.example.com");
}
#[tokio::test]
-async fn publish_listing_sanitizes_http_status_body() {
- let (endpoint, handle) = spawn_http_server("500 Internal Server Error", "secret body");
-
- let error = publish_listing(
- &endpoint,
- &RadrootsdAuth::None,
- &sample_listing_publish_request(),
- Duration::from_secs(5),
+async fn publish_event_http_errors_omit_body_and_token_material() {
+ let body = "{\"error\":\"token-secret content carrots\"}";
+ let (endpoint, _handle) = spawn_http_server("503 Service Unavailable", body);
+
+ let error = publish_event(
+ endpoint.as_str(),
+ &RadrootsdAuth::BearerToken("token-secret".into()),
+ &publish_request(),
+ Duration::from_secs(2),
)
.await
- .expect_err("error");
- handle.join().expect("request");
-
+ .expect_err("http error");
let message = error.to_string();
- assert!(message.contains("radrootsd returned http 500"));
- assert!(!message.contains("secret body"));
-}
-
-#[tokio::test]
-async fn publish_listing_reports_malformed_http_response_body() {
- let (endpoint, handle) = spawn_http_server("200 OK", r#"{ "result": "#);
-
- let error = publish_listing(
- &endpoint,
- &RadrootsdAuth::None,
- &sample_listing_publish_request(),
- Duration::from_secs(5),
- )
- .await
- .expect_err("error");
- handle.join().expect("request");
-
- assert!(matches!(error, RadrootsdError::MalformedResponse(_)));
- assert_message(error, "decode radrootsd bridge.listing.publish response");
-}
-
-#[tokio::test]
-async fn publish_listing_reports_http_response_body_read_errors() {
- let body = r#"{ "jsonrpc": "2.0", "id": "radroots-sdk-listing-publish" }"#;
- let (endpoint, handle) = spawn_http_server_with_content_length("200 OK", body, body.len() + 64);
- let error = publish_listing(
- &endpoint,
- &RadrootsdAuth::None,
- &sample_listing_publish_request(),
- Duration::from_secs(5),
- )
- .await
- .expect_err("error");
- handle.join().expect("request");
-
- assert!(matches!(error, RadrootsdError::Http(_)));
- assert_message(error, "read radrootsd response body");
+ assert!(message.contains("503"));
+ assert!(message.contains("response body omitted"));
+ assert!(!message.contains("token-secret"));
+ assert!(!message.contains("carrots"));
}
#[tokio::test]
-async fn publish_listing_reports_transport_send_errors() {
- let listener = TcpListener::bind("127.0.0.1:0").expect("bind unused port");
- let endpoint = format!("http://{}/rpc", listener.local_addr().expect("addr"));
- drop(listener);
+async fn adapter_rejects_invalid_request_before_transport() {
+ let adapter =
+ RadrootsdProxyPublishAdapter::new(RadrootsdProxyConfig::new("http://127.0.0.1:9/rpc"));
+ let mut request = RadrootsdProxyPublishRequest {
+ signed_event: signed_event(),
+ relays: Vec::new(),
+ delivery_policy: PublishDeliveryPolicy::Quorum { quorum: 0 },
+ idempotency_key: None,
+ timeout_ms: None,
+ };
+ request.signed_event.id = "A".repeat(64);
- let error = publish_listing(
- &endpoint,
- &RadrootsdAuth::None,
- &sample_listing_publish_request(),
- Duration::from_millis(250),
- )
- .await
- .expect_err("error");
+ let error = adapter
+ .publish_signed_event(request)
+ .await
+ .expect_err("invalid request");
- assert!(matches!(error, RadrootsdError::Http(_)));
- assert_message(error, "send radrootsd bridge.listing.publish request");
+ assert!(matches!(error, RadrootsdError::InvalidRequest(_)));
}
diff --git a/crates/sdk/tests/unit/relay_targets_tests.rs b/crates/sdk/tests/unit/relay_targets_tests.rs
@@ -40,6 +40,13 @@ fn use_configured_policy_serializes_as_kind_only() {
serde_json::json!({ "kind": "use_configured_relays" })
);
assert_struct_serialize_error_paths(&policy, 1);
+
+ let publish_transport_policy = SdkRelayTargetPolicy::use_publish_transport();
+ assert_eq!(
+ serde_json::to_value(&publish_transport_policy).expect("json"),
+ serde_json::json!({ "kind": "use_publish_transport" })
+ );
+ assert_struct_serialize_error_paths(&publish_transport_policy, 1);
}
#[test]
diff --git a/crates/sdk/tests/unit/runtime_tests.rs b/crates/sdk/tests/unit/runtime_tests.rs
@@ -130,6 +130,7 @@ async fn open_storage_and_storage_kind_cover_memory_directory_and_file_failures(
storage_paths: None,
clock: RadrootsSdkClock::Fixed(RadrootsSdkTimestamp::from_unix_seconds(1)),
relay_urls: Vec::new(),
+ publish_transport: SdkPublishTransport::DirectNostrRelay,
};
assert_eq!(memory_sdk.storage_kind(), SdkStorageKind::Memory);
@@ -147,6 +148,7 @@ async fn open_storage_and_storage_kind_cover_memory_directory_and_file_failures(
storage_paths: Some(directory_paths),
clock: RadrootsSdkClock::Fixed(RadrootsSdkTimestamp::from_unix_seconds(1)),
relay_urls: Vec::new(),
+ publish_transport: SdkPublishTransport::DirectNostrRelay,
};
assert_eq!(directory_sdk.storage_kind(), SdkStorageKind::Directory);
diff --git a/crates/sdk/tests/unit/sync_runtime_tests.rs b/crates/sdk/tests/unit/sync_runtime_tests.rs
@@ -87,6 +87,30 @@ fn push_event_final_state_follows_publish_quorum_and_retryability() {
}
#[test]
+fn push_relay_outcome_mapping_covers_daemon_proxy_results() {
+ assert_eq!(
+ PushOutboxRelayOutcomeKind::from(RadrootsRelayOutcomeKind::Muted),
+ PushOutboxRelayOutcomeKind::Muted
+ );
+ assert_eq!(
+ PushOutboxRelayOutcomeKind::from(RadrootsRelayOutcomeKind::Unsupported),
+ PushOutboxRelayOutcomeKind::Unsupported
+ );
+ assert_eq!(
+ PushOutboxRelayOutcomeKind::from(RadrootsRelayOutcomeKind::PaymentRequired),
+ PushOutboxRelayOutcomeKind::PaymentRequired
+ );
+ assert_eq!(
+ PushOutboxRelayOutcomeKind::from(RadrootsRelayOutcomeKind::RelayUrlRejected),
+ PushOutboxRelayOutcomeKind::RelayUrlRejected
+ );
+ assert_eq!(
+ PushOutboxRelayOutcomeKind::from(RadrootsRelayOutcomeKind::SkippedAlreadyAccepted),
+ PushOutboxRelayOutcomeKind::SkippedAlreadyAccepted
+ );
+}
+
+#[test]
fn auth_policy_defaults_and_outbox_state_mappings_cover_all_public_states() {
assert_eq!(
SdkRelayAuthPolicy::default(),
diff --git a/crates/sdk/tests/unit/workflow_runtime_tests.rs b/crates/sdk/tests/unit/workflow_runtime_tests.rs
@@ -111,6 +111,7 @@ fn workflow_digest_and_event_helpers_cover_error_and_input_paths() {
signed_event(),
vec!["wss://relay.example.com".to_owned()],
idempotency_key,
+ false,
true,
1_700_000_000_000,
);
@@ -212,3 +213,24 @@ async fn enqueue_signed_workflow_reports_clock_failures() {
Err(RadrootsSdkError::ClockBeforeUnixEpoch)
));
}
+
+#[tokio::test]
+async fn enqueue_signed_workflow_rejects_publish_transport_targets_without_proxy_transport() {
+ let sdk = crate::RadrootsSdk::builder().build().await.expect("sdk");
+ let actor = RadrootsActorContext::test(FARMER_PUBLIC_KEY_HEX, [RadrootsActorRole::Farmer])
+ .expect("actor");
+ let draft = frozen_draft_for(FARMER_PUBLIC_KEY_HEX);
+ let request = SdkWorkflowEnqueueRequest {
+ operation_kind: "workflow.test.v1",
+ actor: &actor,
+ frozen_draft: &draft,
+ target_relays: SdkRelayTargetPolicy::UsePublishTransport,
+ idempotency_key: None,
+ };
+
+ assert!(matches!(
+ enqueue_signed_workflow(&sdk, request, &WorkflowSigner::new()).await,
+ Err(RadrootsSdkError::EmptyTargetRelays { operation })
+ if operation == "publish transport relay resolution"
+ ));
+}