commit 938502894e3c147984021fcf93d702216554232d
parent cade95a66cf37159071c9e59b836081bc3881b4b
Author: triesap <tyson@radroots.org>
Date: Mon, 13 Apr 2026 01:24:56 +0000
sdk: add radrootsd transport adapter
Diffstat:
8 files changed, 609 insertions(+), 11 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -2658,6 +2658,9 @@ dependencies = [
"radroots_replica_sync",
"radroots_sql_core",
"radroots_trade",
+ "reqwest",
+ "serde",
+ "serde_json",
"tempfile",
"tokio",
"tokio-tungstenite",
diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml
@@ -15,8 +15,9 @@ readme = "README"
[features]
default = ["std", "serde", "serde_json", "identity-models"]
std = ["radroots_events/std", "radroots_events_codec/std", "radroots_trade/std"]
-serde = ["radroots_events/serde", "radroots_trade/serde"]
+serde = ["dep:serde", "radroots_events/serde", "radroots_trade/serde"]
serde_json = [
+ "dep:serde_json",
"serde",
"nostr",
"radroots_events_codec/serde_json",
@@ -31,6 +32,7 @@ identity-models = [
identity-storage = ["identity-models", "std", "radroots_identity/std"]
signing = ["dep:radroots_nostr", "nostr"]
relay-client = ["signing", "std", "radroots_nostr/client"]
+radrootsd-client = ["std", "serde_json", "dep:reqwest"]
signer-adapters = [
"identity-models",
"signing",
@@ -49,6 +51,9 @@ radroots_identity = { workspace = true, optional = true, default-features = fals
radroots_nostr = { workspace = true, optional = true, default-features = false }
radroots_nostr_connect = { workspace = true, optional = true }
radroots_nostr_signer = { workspace = true, optional = true, default-features = false }
+reqwest = { workspace = true, optional = true, default-features = false, features = ["json", "rustls-tls"] }
+serde = { workspace = true, optional = true, default-features = false, features = ["derive", "alloc"] }
+serde_json = { workspace = true, optional = true, default-features = false, features = ["alloc"] }
[dev-dependencies]
futures = { workspace = true }
diff --git a/crates/sdk/src/adapters/mod.rs b/crates/sdk/src/adapters/mod.rs
@@ -1,3 +1,5 @@
+#[cfg(feature = "radrootsd-client")]
+pub mod radrootsd;
#[cfg(feature = "relay-client")]
pub mod relay;
#[cfg(feature = "signing")]
diff --git a/crates/sdk/src/adapters/radrootsd.rs b/crates/sdk/src/adapters/radrootsd.rs
@@ -0,0 +1,172 @@
+use core::time::Duration;
+
+use crate::config::RadrootsdAuth;
+use crate::listing::RadrootsListing;
+use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
+use serde::{Deserialize, Serialize};
+use serde_json::{Value, json};
+
+#[derive(Debug, 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>,
+}
+
+#[derive(Debug, 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>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
+pub struct SdkRadrootsdBridgePublishResponse {
+ pub deduplicated: bool,
+ pub job: SdkRadrootsdBridgeJob,
+}
+
+#[derive(Debug, 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,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RadrootsdError {
+ InvalidAuthHeader(String),
+ Http(String),
+ JsonRpc(String),
+ MalformedResponse(String),
+}
+
+impl core::fmt::Display for RadrootsdError {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::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}"),
+ }
+ }
+}
+
+impl std::error::Error for RadrootsdError {}
+
+#[derive(Debug, Deserialize)]
+struct JsonRpcEnvelope<T> {
+ result: Option<T>,
+ error: Option<JsonRpcError>,
+}
+
+#[derive(Debug, Deserialize)]
+struct JsonRpcError {
+ code: i64,
+ message: String,
+}
+
+pub async fn publish_listing(
+ endpoint: &str,
+ auth: &RadrootsdAuth,
+ request: &SdkRadrootsdListingPublishRequest,
+ timeout: Duration,
+) -> Result<SdkRadrootsdBridgePublishResponse, RadrootsdError> {
+ let client = reqwest::Client::builder()
+ .timeout(timeout)
+ .build()
+ .map_err(|err| RadrootsdError::Http(format!("build radrootsd client: {err}")))?;
+ let mut request_builder = client
+ .post(endpoint)
+ .headers(auth_headers(auth)?)
+ .json(&json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-listing-publish",
+ "method": "bridge.listing.publish",
+ "params": request,
+ }));
+
+ request_builder = request_builder.header(CONTENT_TYPE, "application/json");
+
+ let response = request_builder
+ .send()
+ .await
+ .map_err(|err| RadrootsdError::Http(format!("send radrootsd listing publish request: {err}")))?;
+ let status = response.status();
+ let body = response
+ .text()
+ .await
+ .map_err(|err| RadrootsdError::Http(format!("read radrootsd response body: {err}")))?;
+
+ if !status.is_success() {
+ return Err(RadrootsdError::Http(format!(
+ "radrootsd returned http {}: {}",
+ status.as_u16(),
+ body
+ )));
+ }
+
+ let envelope: JsonRpcEnvelope<SdkRadrootsdBridgePublishResponse> =
+ serde_json::from_str(body.as_str()).map_err(|err| {
+ RadrootsdError::MalformedResponse(format!(
+ "decode radrootsd bridge.listing.publish response: {err}"
+ ))
+ })?;
+ match (envelope.result, envelope.error) {
+ (Some(result), None) => Ok(result),
+ (None, Some(error)) => Err(RadrootsdError::JsonRpc(format!(
+ "radrootsd bridge.listing.publish failed {}: {}",
+ error.code, error.message
+ ))),
+ (Some(_), Some(error)) => Err(RadrootsdError::MalformedResponse(format!(
+ "radrootsd bridge.listing.publish returned result and error: {} {}",
+ error.code, error.message
+ ))),
+ (None, None) => Err(RadrootsdError::MalformedResponse(
+ "radrootsd bridge.listing.publish returned neither result nor error".to_owned(),
+ )),
+ }
+}
+
+fn auth_headers(auth: &RadrootsdAuth) -> Result<HeaderMap, RadrootsdError> {
+ let mut headers = HeaderMap::new();
+ match auth {
+ RadrootsdAuth::None => Ok(headers),
+ RadrootsdAuth::BearerToken(token) => {
+ let value = HeaderValue::from_str(format!("Bearer {token}").as_str())
+ .map_err(|err| RadrootsdError::InvalidAuthHeader(err.to_string()))?;
+ headers.insert(AUTHORIZATION, value);
+ Ok(headers)
+ }
+ }
+}
+
+pub fn bridge_listing_publish_request_json(
+ request: &SdkRadrootsdListingPublishRequest,
+) -> Result<Value, RadrootsdError> {
+ serde_json::to_value(request).map_err(|err| {
+ RadrootsdError::MalformedResponse(format!(
+ "serialize radrootsd listing publish request: {err}"
+ ))
+ })
+}
diff --git a/crates/sdk/src/client.rs b/crates/sdk/src/client.rs
@@ -3,6 +3,8 @@ use alloc::{string::String, vec::Vec};
#[cfg(feature = "std")]
use std::{string::String, vec::Vec};
+#[cfg(feature = "radrootsd-client")]
+use crate::adapters::radrootsd;
#[cfg(all(feature = "identity-models", feature = "relay-client", feature = "signing"))]
use crate::adapters::relay;
#[cfg(all(feature = "identity-models", feature = "relay-client", feature = "signing"))]
@@ -13,14 +15,17 @@ use crate::{
RadrootsTradeEnvelope, TradeListingValidateResult, WireEventParts, farm, listing, profile,
trade,
};
-#[cfg(all(feature = "identity-models", feature = "relay-client", feature = "signing"))]
+#[cfg(any(
+ feature = "radrootsd-client",
+ all(feature = "identity-models", feature = "relay-client", feature = "signing")
+))]
use core::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SdkPublishReceipt {
pub transport: SdkTransportMode,
- pub event_kind: u32,
- pub event_id: String,
+ pub event_kind: Option<u32>,
+ pub event_id: Option<String>,
pub transport_receipt: SdkTransportReceipt,
}
@@ -45,8 +50,14 @@ pub struct SdkRelayFailure {
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SdkRadrootsdPublishReceipt {
pub accepted: bool,
+ pub deduplicated: bool,
pub job_id: Option<String>,
- pub details: Option<String>,
+ pub status: Option<String>,
+ pub signer_mode: Option<String>,
+ pub signer_session_id: Option<String>,
+ pub event_addr: Option<String>,
+ pub relay_count: Option<usize>,
+ pub acknowledged_relay_count: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -62,6 +73,7 @@ pub enum SdkPublishError {
transport: SdkTransportMode,
failed_relays: Vec<SdkRelayFailure>,
},
+ Radrootsd(String),
}
impl From<SdkConfigError> for SdkPublishError {
@@ -103,6 +115,7 @@ impl core::fmt::Display for SdkPublishError {
)
}
}
+ Self::Radrootsd(message) => write!(f, "{message}"),
}
}
}
@@ -182,6 +195,32 @@ impl RadrootsSdkClient {
.map_err(|err| SdkPublishError::Relay(err.to_string()))?;
sdk_publish_receipt_from_relay_output(event_kind, output)
}
+
+ #[cfg(feature = "radrootsd-client")]
+ async fn publish_listing_via_radrootsd(
+ &self,
+ request: &radrootsd::SdkRadrootsdListingPublishRequest,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ if self.transport() != SdkTransportMode::Radrootsd {
+ return Err(SdkPublishError::UnsupportedTransport {
+ transport: self.transport(),
+ operation: "listing.publish_via_radrootsd",
+ });
+ }
+
+ let endpoint = self.resolved_radrootsd_endpoint()?;
+ let response = radrootsd::publish_listing(
+ endpoint.as_str(),
+ &self.config.radrootsd.auth,
+ request,
+ Duration::from_millis(self.config.network.timeout_ms),
+ )
+ .await
+ .map_err(|err| SdkPublishError::Radrootsd(err.to_string()))?;
+ Ok(sdk_publish_receipt_from_radrootsd_listing_response(
+ response,
+ ))
+ }
}
#[derive(Debug, Clone, Copy)]
@@ -283,6 +322,14 @@ impl<'a> ListingClient<'a> {
)
.await
}
+
+ #[cfg(feature = "radrootsd-client")]
+ pub async fn publish_via_radrootsd(
+ &self,
+ request: &radrootsd::SdkRadrootsdListingPublishRequest,
+ ) -> Result<SdkPublishReceipt, SdkPublishError> {
+ self.client.publish_listing_via_radrootsd(request).await
+ }
}
#[derive(Debug, Clone, Copy)]
@@ -380,8 +427,8 @@ fn sdk_publish_receipt_from_relay_output(
Ok(SdkPublishReceipt {
transport: SdkTransportMode::RelayDirect,
- event_kind,
- event_id: output.val.to_string(),
+ event_kind: Some(event_kind),
+ event_id: Some(output.val.to_string()),
transport_receipt: SdkTransportReceipt::RelayDirect(SdkRelayPublishReceipt {
acknowledged_relays,
failed_relays,
@@ -389,6 +436,29 @@ fn sdk_publish_receipt_from_relay_output(
})
}
+#[cfg(feature = "radrootsd-client")]
+fn sdk_publish_receipt_from_radrootsd_listing_response(
+ response: radrootsd::SdkRadrootsdBridgePublishResponse,
+) -> SdkPublishReceipt {
+ let job = response.job;
+ SdkPublishReceipt {
+ transport: SdkTransportMode::Radrootsd,
+ event_kind: Some(job.event_kind),
+ event_id: job.event_id.clone(),
+ transport_receipt: SdkTransportReceipt::Radrootsd(SdkRadrootsdPublishReceipt {
+ accepted: true,
+ deduplicated: response.deduplicated,
+ job_id: Some(job.job_id),
+ status: Some(job.status),
+ signer_mode: Some(job.signer_mode),
+ signer_session_id: job.signer_session_id,
+ event_addr: job.event_addr,
+ relay_count: Some(job.relay_count),
+ acknowledged_relay_count: Some(job.acknowledged_relay_count),
+ }),
+ }
+}
+
#[cfg(all(test, feature = "identity-models", feature = "relay-client", feature = "signing"))]
mod tests {
use super::{
@@ -418,10 +488,10 @@ mod tests {
let receipt = sdk_publish_receipt_from_relay_output(30402, output).expect("receipt");
assert_eq!(receipt.transport, SdkTransportMode::RelayDirect);
- assert_eq!(receipt.event_kind, 30402);
+ assert_eq!(receipt.event_kind, Some(30402));
assert_eq!(
receipt.event_id,
- "5f3cf27d85c9571a2dca28269f6547f625364a7e06e5e853ee1bc74d2c4aa3d4"
+ Some("5f3cf27d85c9571a2dca28269f6547f625364a7e06e5e853ee1bc74d2c4aa3d4".to_owned())
);
}
diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs
@@ -10,6 +10,7 @@ use alloc::{string::String, vec::Vec};
use std::{string::String, vec::Vec};
#[cfg(any(
+ feature = "radrootsd-client",
feature = "signing",
feature = "relay-client",
feature = "signer-adapters"
@@ -36,6 +37,11 @@ pub use crate::client::{
SdkPublishReceipt, SdkRadrootsdPublishReceipt, SdkRelayFailure, SdkRelayPublishReceipt,
SdkTransportReceipt, TradeClient,
};
+#[cfg(feature = "radrootsd-client")]
+pub use crate::adapters::radrootsd::{
+ SdkRadrootsdBridgeJob, SdkRadrootsdBridgePublishResponse,
+ SdkRadrootsdListingPublishRequest, SdkRadrootsdSignerAuthority,
+};
pub use radroots_events::{
RadrootsNostrEvent, RadrootsNostrEventPtr, RadrootsNostrEventRef,
farm::RadrootsFarm,
diff --git a/crates/sdk/tests/radrootsd.rs b/crates/sdk/tests/radrootsd.rs
@@ -0,0 +1,340 @@
+#![cfg(feature = "radrootsd-client")]
+
+use radroots_core::{
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
+ RadrootsCoreQuantityPrice, RadrootsCoreUnit,
+};
+use radroots_sdk::listing::{
+ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
+ RadrootsListingDeliveryMethod, RadrootsListingFarmRef, RadrootsListingLocation,
+ RadrootsListingProduct, RadrootsListingStatus,
+};
+use radroots_sdk::{
+ RadrootsSdkClient, RadrootsSdkConfig, RadrootsdAuth, RadrootsdConfig, SdkEnvironment,
+ SdkPublishError, SdkRadrootsdListingPublishRequest, SdkTransportMode, SdkTransportReceipt,
+};
+use serde_json::{Value, json};
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::TcpListener;
+use tokio::sync::oneshot;
+
+type TestResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
+
+struct JsonRpcServer {
+ endpoint: String,
+ shutdown_tx: Option<oneshot::Sender<()>>,
+}
+
+impl JsonRpcServer {
+ async fn spawn(
+ expected_auth: Option<&str>,
+ response_body: Value,
+ ) -> TestResult<(Self, oneshot::Receiver<Value>)> {
+ let listener = TcpListener::bind("127.0.0.1:0").await?;
+ let addr = listener.local_addr()?;
+ let endpoint = format!("http://{addr}/jsonrpc");
+ let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
+ let (request_tx, request_rx) = oneshot::channel();
+ let expected_auth = expected_auth.map(str::to_owned);
+ let response_text = response_body.to_string();
+
+ tokio::spawn(async move {
+ loop {
+ tokio::select! {
+ _ = &mut shutdown_rx => break,
+ accept = listener.accept() => {
+ let Ok((mut stream, _)) = accept else {
+ break;
+ };
+ let mut buffer = Vec::new();
+ let mut chunk = [0_u8; 4096];
+ let header_end = loop {
+ let Ok(read) = stream.read(&mut chunk).await else {
+ return;
+ };
+ if read == 0 {
+ return;
+ }
+ buffer.extend_from_slice(&chunk[..read]);
+ if let Some(index) = find_headers_end(&buffer) {
+ break index;
+ }
+ };
+
+ let headers = String::from_utf8_lossy(&buffer[..header_end]).into_owned();
+ let content_length = parse_content_length(headers.as_str()).unwrap_or(0);
+ let body_start = header_end + 4;
+ while buffer.len().saturating_sub(body_start) < content_length {
+ let Ok(read) = stream.read(&mut chunk).await else {
+ return;
+ };
+ if read == 0 {
+ break;
+ }
+ buffer.extend_from_slice(&chunk[..read]);
+ }
+
+ if let Some(expected_auth) = expected_auth.as_deref() {
+ let actual_auth = parse_authorization(headers.as_str());
+ if actual_auth.as_deref() != Some(expected_auth) {
+ let _ = write_http_response(
+ &mut stream,
+ 401,
+ json!({
+ "jsonrpc": "2.0",
+ "id": "sdk-test",
+ "error": {
+ "code": -32001,
+ "message": format!(
+ "unexpected authorization header: {:?}",
+ actual_auth
+ ),
+ }
+ })
+ .to_string()
+ .as_str(),
+ )
+ .await;
+ return;
+ }
+ }
+
+ let body = &buffer[body_start..body_start + content_length];
+ let Ok(request_json) = serde_json::from_slice::<Value>(body) else {
+ return;
+ };
+ let _ = request_tx.send(request_json);
+ let _ = write_http_response(&mut stream, 200, response_text.as_str()).await;
+ break;
+ }
+ }
+ }
+ });
+
+ Ok((
+ Self {
+ endpoint,
+ shutdown_tx: Some(shutdown_tx),
+ },
+ request_rx,
+ ))
+ }
+
+ fn endpoint(&self) -> &str {
+ self.endpoint.as_str()
+ }
+}
+
+impl Drop for JsonRpcServer {
+ fn drop(&mut self) {
+ if let Some(shutdown_tx) = self.shutdown_tx.take() {
+ let _ = shutdown_tx.send(());
+ }
+ }
+}
+
+fn find_headers_end(buffer: &[u8]) -> Option<usize> {
+ buffer.windows(4).position(|window| window == b"\r\n\r\n")
+}
+
+fn parse_content_length(headers: &str) -> Option<usize> {
+ headers.lines().find_map(|line| {
+ let (name, value) = line.split_once(':')?;
+ if !name.eq_ignore_ascii_case("content-length") {
+ return None;
+ }
+ value.trim().parse().ok()
+ })
+}
+
+fn parse_authorization(headers: &str) -> Option<String> {
+ headers.lines().find_map(|line| {
+ let (name, value) = line.split_once(':')?;
+ if !name.eq_ignore_ascii_case("authorization") {
+ return None;
+ }
+ Some(value.trim().to_owned())
+ })
+}
+
+async fn write_http_response(
+ stream: &mut tokio::net::TcpStream,
+ status: u16,
+ body: &str,
+) -> Result<(), std::io::Error> {
+ let status_text = match status {
+ 200 => "OK",
+ 401 => "Unauthorized",
+ _ => "Internal Server Error",
+ };
+ let response = format!(
+ "HTTP/1.1 {status} {status_text}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
+ body.len(),
+ body
+ );
+ stream.write_all(response.as_bytes()).await
+}
+
+fn sample_listing() -> RadrootsListing {
+ RadrootsListing {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAg".into(),
+ farm: RadrootsListingFarmRef {
+ pubkey: "seller".into(),
+ 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".into(),
+ bins: vec![RadrootsListingBin {
+ bin_id: "bin-1".into(),
+ 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,
+ }
+}
+
+#[tokio::test]
+async fn radrootsd_listing_publish_returns_normalized_receipt() -> TestResult<()> {
+ let (server, request_rx) = JsonRpcServer::spawn(
+ Some("Bearer sdk-secret"),
+ json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-listing-publish",
+ "result": {
+ "deduplicated": false,
+ "job": {
+ "job_id": "job-1",
+ "command": "bridge.listing.publish",
+ "status": "published",
+ "terminal": true,
+ "recovered_after_restart": false,
+ "signer_mode": "nip46_session:session-123",
+ "signer_session_id": "session-123",
+ "event_kind": 30402,
+ "event_id": "event-1",
+ "event_addr": "30402:seller:listing-1",
+ "relay_count": 1,
+ "acknowledged_relay_count": 1
+ }
+ }
+ }),
+ )
+ .await?;
+
+ let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Production);
+ config.transport = SdkTransportMode::Radrootsd;
+ config.radrootsd = RadrootsdConfig {
+ endpoint: Some(server.endpoint().to_owned()),
+ auth: RadrootsdAuth::BearerToken("sdk-secret".to_owned()),
+ };
+ let client = RadrootsSdkClient::from_config(config)?;
+ let request = SdkRadrootsdListingPublishRequest {
+ listing: sample_listing(),
+ kind: None,
+ signer_session_id: "session-123".to_owned(),
+ signer_authority: None,
+ idempotency_key: Some("idem-1".to_owned()),
+ };
+
+ let receipt = client
+ .listing()
+ .publish_via_radrootsd(&request)
+ .await?;
+ let request_json = request_rx.await?;
+
+ assert_eq!(request_json["method"], "bridge.listing.publish");
+ assert_eq!(request_json["params"]["signer_session_id"], "session-123");
+ assert_eq!(request_json["params"]["idempotency_key"], "idem-1");
+ assert_eq!(request_json["params"]["listing"]["d_tag"], "AAAAAAAAAAAAAAAAAAAAAg");
+
+ assert_eq!(receipt.transport, SdkTransportMode::Radrootsd);
+ assert_eq!(receipt.event_kind, Some(30402));
+ assert_eq!(receipt.event_id, Some("event-1".to_owned()));
+ match receipt.transport_receipt {
+ SdkTransportReceipt::Radrootsd(rpc_receipt) => {
+ assert!(rpc_receipt.accepted);
+ assert!(!rpc_receipt.deduplicated);
+ assert_eq!(rpc_receipt.job_id.as_deref(), Some("job-1"));
+ assert_eq!(rpc_receipt.status.as_deref(), Some("published"));
+ assert_eq!(rpc_receipt.signer_session_id.as_deref(), Some("session-123"));
+ assert_eq!(rpc_receipt.event_addr.as_deref(), Some("30402:seller:listing-1"));
+ assert_eq!(rpc_receipt.relay_count, Some(1));
+ assert_eq!(rpc_receipt.acknowledged_relay_count, Some(1));
+ }
+ SdkTransportReceipt::RelayDirect(_) => panic!("unexpected relay receipt"),
+ }
+
+ Ok(())
+}
+
+#[tokio::test]
+async fn radrootsd_listing_publish_rejects_relay_transport_mode() -> TestResult<()> {
+ let client = RadrootsSdkClient::from_config(RadrootsSdkConfig::production())?;
+ let request = SdkRadrootsdListingPublishRequest {
+ listing: sample_listing(),
+ kind: None,
+ signer_session_id: "session-123".to_owned(),
+ signer_authority: None,
+ idempotency_key: None,
+ };
+
+ let error = client
+ .listing()
+ .publish_via_radrootsd(&request)
+ .await
+ .expect_err("unsupported transport");
+
+ assert!(matches!(
+ error,
+ SdkPublishError::UnsupportedTransport {
+ transport: SdkTransportMode::RelayDirect,
+ operation: "listing.publish_via_radrootsd",
+ }
+ ));
+
+ Ok(())
+}
diff --git a/crates/sdk/tests/relay_direct.rs b/crates/sdk/tests/relay_direct.rs
@@ -177,8 +177,8 @@ async fn relay_direct_listing_publish_returns_normalized_receipt() -> TestResult
.await?;
assert_eq!(receipt.transport, SdkTransportMode::RelayDirect);
- assert_eq!(receipt.event_kind, 30402);
- assert!(!receipt.event_id.is_empty());
+ assert_eq!(receipt.event_kind, Some(30402));
+ assert!(receipt.event_id.is_some());
match receipt.transport_receipt {
SdkTransportReceipt::RelayDirect(relay_receipt) => {
assert_eq!(relay_receipt.acknowledged_relays, vec![relay.url().to_owned()]);