commit 4a3cbe30a88fee436f41094acfcd02caaf8eaa1c
parent 2de5fda8382b4548f960f12e5b85de980677958d
Author: triesap <tyson@radroots.org>
Date: Wed, 8 Jul 2026 05:01:24 +0000
transport: reject duplicate SDK targets
- stop deduping normalized Nostr relay inputs in target sets
- reject duplicate explicit transport targets through the shared target-set error
- update product runtime idempotency tests to use unique relay sets
- cover duplicate target failures and unique relay normalization
Diffstat:
6 files changed, 59 insertions(+), 27 deletions(-)
diff --git a/crates/sdk/src/transport.rs b/crates/sdk/src/transport.rs
@@ -132,15 +132,12 @@ impl TargetSet {
S: AsRef<str>,
{
let mut targets = Vec::new();
- let mut seen = BTreeSet::new();
for relay in relays {
let normalized = normalized_nostr_relay_url(relay.as_ref(), policy)?;
- if seen.insert(normalized.clone()) {
- targets.push(RadrootsTransportTarget::new(
- RadrootsTransportKind::Nostr,
- normalized,
- )?);
- }
+ targets.push(RadrootsTransportTarget::new(
+ RadrootsTransportKind::Nostr,
+ normalized,
+ )?);
}
Self::from_transport_targets(targets)
}
@@ -229,6 +226,7 @@ impl TargetSet {
});
}
}
+ RadrootsTransportTargetSet::new(targets.clone())?;
let canonical_targets = targets
.iter()
.map(|target| {
diff --git a/crates/sdk/tests/farms_runtime.rs b/crates/sdk/tests/farms_runtime.rs
@@ -723,7 +723,7 @@ async fn farm_enqueue_publish_derives_order_independent_idempotency_key() {
farm(FARM_D_D_TAG, "North Farm"),
TargetPolicy::use_transport_profile(),
)
- .try_with_nostr_targets([RELAY_B, RELAY, RELAY], NostrRelayUrlPolicy::Public)
+ .try_with_nostr_targets([RELAY_B, RELAY], NostrRelayUrlPolicy::Public)
.expect("first transport targets");
let second = FarmEnqueuePublishRequest::new(
farmer_actor(),
diff --git a/crates/sdk/tests/listings_runtime.rs b/crates/sdk/tests/listings_runtime.rs
@@ -743,7 +743,7 @@ async fn enqueue_publish_derives_order_independent_idempotency_key() {
listing(LISTING_F_D_TAG, "Coffee"),
TargetPolicy::use_transport_profile(),
)
- .try_with_nostr_targets([RELAY_B, RELAY, RELAY], NostrRelayUrlPolicy::Public)
+ .try_with_nostr_targets([RELAY_B, RELAY], NostrRelayUrlPolicy::Public)
.expect("first transport targets");
let second = ListingEnqueuePublishRequest::new(
actor(),
diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs
@@ -3386,7 +3386,7 @@ async fn order_submit_enqueue_derives_order_independent_idempotency_key() {
PublishMode::EnqueueOnly,
SatisfactionPolicy::NoWait,
)
- .try_with_nostr_targets([RELAY_B, RELAY, RELAY], NostrRelayUrlPolicy::Public)
+ .try_with_nostr_targets([RELAY_B, RELAY], NostrRelayUrlPolicy::Public)
.expect("first transport targets");
let second = TradeSubmitEnqueueRequest::new(
buyer_actor(),
diff --git a/crates/sdk/tests/runtime_foundation.rs b/crates/sdk/tests/runtime_foundation.rs
@@ -48,11 +48,7 @@ async fn sdk_builder_validates_configured_relay_targets() {
let sdk = RadrootsClient::builder()
.transport_profile(
nostr_profile(
- [
- " wss://relay-b.example.com/ ",
- "wss://relay-a.example.com",
- "wss://relay-a.example.com",
- ],
+ [" wss://relay-b.example.com/ ", "wss://relay-a.example.com"],
NostrRelayUrlPolicy::Public,
)
.expect("profile"),
@@ -625,13 +621,9 @@ fn sdk_error_contract_methods_cover_all_variants() {
}
#[test]
-fn relay_target_set_validates_normalizes_dedupes_preserves_order_and_caps() {
+fn relay_target_set_validates_normalizes_preserves_order_and_caps() {
let targets = TargetSet::new(
- [
- " wss://relay-b.example.com/ ",
- "wss://relay-a.example.com",
- "wss://relay-a.example.com",
- ],
+ [" wss://relay-b.example.com/ ", "wss://relay-a.example.com"],
NostrRelayUrlPolicy::Public,
)
.expect("targets");
@@ -675,6 +667,15 @@ fn relay_target_set_validates_normalizes_dedupes_preserves_order_and_caps() {
);
assert!(matches!(
+ TargetSet::new(
+ ["wss://relay-a.example.com", "WSS://RELAY-A.EXAMPLE.COM/"],
+ NostrRelayUrlPolicy::Public,
+ ),
+ Err(RadrootsSdkError::Transport { ref message })
+ if message == "transport target set contains duplicate fingerprints"
+ ));
+
+ assert!(matches!(
TargetSet::new(Vec::<String>::new(), NostrRelayUrlPolicy::Public),
Err(RadrootsSdkError::EmptyTransportTargets { .. })
));
diff --git a/crates/sdk/tests/unit/transport_tests.rs b/crates/sdk/tests/unit/transport_tests.rs
@@ -88,7 +88,7 @@ fn transport_profile_policy_serializes_as_kind_only() {
}
#[test]
-fn target_set_accessors_and_configured_relays_cover_empty_and_dedupe_paths() {
+fn target_set_accessors_and_configured_relays_cover_empty_paths() {
assert!(
TransportProfile::local_only()
.configured_nostr_relay_urls()
@@ -96,11 +96,7 @@ fn target_set_accessors_and_configured_relays_cover_empty_and_dedupe_paths() {
);
let targets = TargetSet::new(
- [
- "wss://relay-a.example.com",
- "wss://relay-a.example.com",
- "wss://relay-b.example.com",
- ],
+ ["wss://relay-a.example.com", "wss://relay-b.example.com"],
NostrRelayUrlPolicy::Public,
)
.expect("targets");
@@ -171,6 +167,43 @@ fn target_set_accessors_and_configured_relays_cover_empty_and_dedupe_paths() {
}
#[test]
+fn target_sets_reject_duplicate_transport_fingerprints() {
+ let duplicate_relays = TargetSet::new(
+ [
+ "wss://relay-a.example.com/path",
+ "WSS://RELAY-A.EXAMPLE.COM/path",
+ ],
+ NostrRelayUrlPolicy::Public,
+ )
+ .expect_err("duplicate relays");
+
+ assert!(matches!(
+ duplicate_relays,
+ RadrootsSdkError::Transport { ref message }
+ if message == "transport target set contains duplicate fingerprints"
+ ));
+
+ let first = RadrootsTransportTarget::new(
+ RadrootsTransportKind::Nostr,
+ "wss://relay-a.example.com/path",
+ )
+ .expect("first target");
+ let second = RadrootsTransportTarget::new(
+ RadrootsTransportKind::Nostr,
+ "WSS://RELAY-A.EXAMPLE.COM/path",
+ )
+ .expect("second target");
+ let duplicate_targets =
+ TargetSet::transport_targets(vec![first, second]).expect_err("duplicate targets");
+
+ assert!(matches!(
+ duplicate_targets,
+ RadrootsSdkError::Transport { ref message }
+ if message == "transport target set contains duplicate fingerprints"
+ ));
+}
+
+#[test]
fn reticulum_preview_profile_uses_canonical_endpoint_and_behavior_names() {
let profile = ReticulumPreviewProfile::preview_unavailable();