lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 938502894e3c147984021fcf93d702216554232d
parent cade95a66cf37159071c9e59b836081bc3881b4b
Author: triesap <tyson@radroots.org>
Date:   Mon, 13 Apr 2026 01:24:56 +0000

sdk: add radrootsd transport adapter

Diffstat:
MCargo.lock | 3+++
Mcrates/sdk/Cargo.toml | 7++++++-
Mcrates/sdk/src/adapters/mod.rs | 2++
Acrates/sdk/src/adapters/radrootsd.rs | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/client.rs | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/sdk/src/lib.rs | 6++++++
Acrates/sdk/tests/radrootsd.rs | 340+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/relay_direct.rs | 4++--
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()]);