commit 39c2302b3cd6d095c32cbe06feb598779e5f044d
parent 00111c8cdee94fbc8b2c332c8fb70cc1127c2fd5
Author: triesap <tyson@radroots.org>
Date: Sat, 18 Jul 2026 13:50:52 +0000
contract: reconcile published semantic coverage
- merge store, outbox, trade, transport, and protocol coverage into the Phase 1 contract line
- preserve exact 1.0.0-alpha.1 dependencies and Blossom, Profile, calendar, and verified-event APIs
- include CHANGELOG.md and the patched SQLite crate in isolated Nix build sources
- verify targeted feature lanes, strict Clippy, the contract lane, and flake checks; record release coverage follow-up
Diffstat:
62 files changed, 6969 insertions(+), 635 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
@@ -162,7 +162,9 @@ serde = { version = "1", default-features = false, features = [
"alloc",
] }
serde_json = { version = "1", default-features = false, features = ["alloc"] }
-secp256k1 = { version = "0.29.1", default-features = false, features = ["alloc"] }
+secp256k1 = { version = "0.29.1", default-features = false, features = [
+ "alloc",
+] }
sha2 = { version = "0.10", default-features = false }
sha3 = { version = "0.10", default-features = false }
sntrup761 = { version = "0.4.0", default-features = false, features = [
diff --git a/build/nix/common.nix b/build/nix/common.nix
@@ -18,6 +18,7 @@ let
lib.fileset.unions [
../../Cargo.toml
../../Cargo.lock
+ ../../CHANGELOG.md
../../README
../../rust-toolchain.toml
../../contracts
@@ -26,6 +27,12 @@ let
]
);
};
+ sqlitePatchSource = lib.fileset.toSource {
+ root = root;
+ fileset = lib.fileset.intersection (lib.fileset.fromSource repoSource) (
+ lib.fileset.unions [ ../../crates/libsqlite3_sys_3_53_3 ]
+ );
+ };
baseEnv = {
CARGO_TERM_COLOR = "always";
LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib";
@@ -123,7 +130,18 @@ let
PKG_CONFIG_PATH
;
};
- cargoArtifacts = craneLib.buildDepsOnly commonCraneArgs;
+ cargoArtifacts = craneLib.buildDepsOnly (
+ commonCraneArgs
+ // {
+ dummySrc = craneLib.mkDummySrc {
+ src = cargoSource;
+ extraDummyScript = ''
+ rm -rf "$out/crates/libsqlite3_sys_3_53_3"
+ cp -R ${sqlitePatchSource}/crates/libsqlite3_sys_3_53_3 "$out/crates/"
+ '';
+ };
+ }
+ );
xtaskPackage = craneLib.buildPackage (
commonCraneArgs
// {
diff --git a/contracts/operations.toml b/contracts/operations.toml
@@ -329,10 +329,7 @@ vector = "contracts/conformance/vectors/blossom/bud11_claims.v1.json"
domain = "blossom"
id = "blossom.decode_verify_authorization_header"
stability = "beta"
-inputs = [
- "String",
- "RadrootsBlossomAuthorizationValidation",
-]
+inputs = ["String", "RadrootsBlossomAuthorizationValidation"]
outputs = ["RadrootsNostrVerifiedBlossomAuthorization"]
error_class = "decode_error"
deterministic = true
diff --git a/crates/authority/Cargo.toml b/crates/authority/Cargo.toml
@@ -25,7 +25,9 @@ radroots_event = { workspace = true, default-features = false }
radroots_nostr = { workspace = true, optional = true, default-features = false }
[dev-dependencies]
-serde_json = { workspace = true, default-features = false, features = ["alloc"] }
+serde_json = { workspace = true, default-features = false, features = [
+ "alloc",
+] }
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
diff --git a/crates/event/Cargo.toml b/crates/event/Cargo.toml
@@ -53,3 +53,6 @@ serde = { workspace = true, default-features = false, features = [
"alloc",
"derive",
] }
+
+[lints.rust]
+unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
diff --git a/crates/event/src/contract.rs b/crates/event/src/contract.rs
@@ -5096,6 +5096,34 @@ mod tests {
&[],
r#"{"domain": "radroots.trade", "type": "proposal"}"#
));
+
+ let base = *event_contract("radroots.trade.proposal.v1").expect("trade proposal");
+ for discriminator in [
+ RadrootsEventDiscriminator::ContentJsonFieldEquals {
+ field: "type",
+ value: "proposal",
+ },
+ RadrootsEventDiscriminator::EnvelopeType("proposal"),
+ ] {
+ let contract = RadrootsEventContract {
+ discriminator,
+ ..base
+ };
+ assert!(validate_discriminator_parts(r#"{"type":"proposal"}"#, &contract).is_ok());
+ assert!(matches!(
+ validate_discriminator_parts(r#"{"type":"decision"}"#, &contract),
+ Err(RadrootsContractValidationError::ContentFieldMismatch { .. })
+ ));
+ assert!(matches!(
+ validate_discriminator_parts("{}", &contract),
+ Err(RadrootsContractValidationError::MissingContentField { .. })
+ ));
+ }
+ let contract = RadrootsEventContract {
+ discriminator: RadrootsEventDiscriminator::KindOnly,
+ ..base
+ };
+ assert!(validate_discriminator_parts("not-json", &contract).is_ok());
}
#[test]
diff --git a/crates/event/src/draft.rs b/crates/event/src/draft.rs
@@ -221,13 +221,13 @@ impl RadrootsEventDraft {
},
)?;
let typed_tags = RadrootsEventTags::new(tags)?;
- let expected_event_id = compute_nip01_event_id(
+ let expected_event_id = compute_nip01_event_id_for_valid_pubkey(
expected_pubkey.as_str(),
created_at,
kind,
&typed_tags.to_vec(),
&content,
- )?;
+ );
Ok(Self {
contract_id: contract.id.to_owned(),
contract_registry_version: RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION,
@@ -241,13 +241,13 @@ impl RadrootsEventDraft {
}
pub fn nip01_preimage(&self) -> Result<String, RadrootsDraftError> {
- nip01_event_id_preimage(
+ Ok(nip01_event_id_preimage_for_valid_pubkey(
self.expected_pubkey.as_str(),
self.created_at.as_u64(),
self.kind.as_u32(),
&self.tags.to_vec(),
self.content.as_str(),
- )
+ ))
}
#[inline]
@@ -633,13 +633,13 @@ pub fn validate_signed_nostr_event_matches_draft(
actual_event_id: signed_event.id_str().to_owned(),
});
}
- let computed_event_id = compute_nip01_event_id(
+ let computed_event_id = compute_nip01_event_id_for_valid_pubkey(
signed_event.pubkey_str(),
draft.created_at_u64(),
signed_event.kind(),
&signed_tags,
signed_event.content(),
- )?
+ )
.into_string();
if computed_event_id.as_str() != signed_event.id_str() {
return Err(RadrootsDraftError::SignedEventComputedIdMismatch {
@@ -683,9 +683,9 @@ pub fn compute_nip01_event_id(
content: &str,
) -> Result<RadrootsEventId, RadrootsDraftError> {
RadrootsPublicKey::parse(pubkey)?;
- Ok(compute_canonical_nip01_event_id(
+ Ok(compute_nip01_event_id_for_valid_pubkey(
pubkey, created_at, kind, tags, content,
- )?)
+ ))
}
pub fn nip01_event_id_preimage(
@@ -695,9 +695,34 @@ pub fn nip01_event_id_preimage(
tags: &[Vec<String>],
content: &str,
) -> Result<String, RadrootsDraftError> {
- Ok(canonical_nip01_event_id_preimage(
+ RadrootsPublicKey::parse(pubkey)?;
+ Ok(nip01_event_id_preimage_for_valid_pubkey(
pubkey, created_at, kind, tags, content,
- )?)
+ ))
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn compute_nip01_event_id_for_valid_pubkey(
+ pubkey: &str,
+ created_at: u64,
+ kind: u32,
+ tags: &[Vec<String>],
+ content: &str,
+) -> RadrootsEventId {
+ compute_canonical_nip01_event_id(pubkey, created_at, kind, tags, content)
+ .expect("a validated public key always produces a canonical event id")
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn nip01_event_id_preimage_for_valid_pubkey(
+ pubkey: &str,
+ created_at: u64,
+ kind: u32,
+ tags: &[Vec<String>],
+ content: &str,
+) -> String {
+ canonical_nip01_event_id_preimage(pubkey, created_at, kind, tags, content)
+ .expect("a validated public key always produces a canonical preimage")
}
#[cfg(test)]
@@ -1037,7 +1062,16 @@ mod tests {
let decoded: RadrootsSignedEvent = serde_json::from_str(&json).expect("deserialize");
assert_eq!(decoded, signed);
+ assert_eq!(decoded.envelope().id_str(), decoded.id_str());
+ assert_eq!(decoded.wire().id, decoded.id_str());
+ assert_eq!(decoded.id().as_str(), decoded.id_str());
+ assert_eq!(decoded.pubkey().as_str(), decoded.pubkey_str());
assert_eq!(decoded.pubkey_str(), hex_64('e'));
+ assert_eq!(decoded.created_at(), 10);
+ assert_eq!(decoded.kind(), KIND_POST);
+ assert_eq!(decoded.tags_as_vec(), wire.tags);
+ assert_eq!(decoded.content(), "hello");
+ assert_eq!(decoded.sig().as_str(), decoded.sig_str());
assert_eq!(decoded.raw_json(), raw_json);
}
@@ -1325,6 +1359,8 @@ mod tests {
RadrootsIdParseError::InvalidFormat,
),
),
+ RadrootsDraftError::from(RadrootsEventEnvelopeError::NonCanonicalId),
+ RadrootsDraftError::from(RadrootsSignedEventError::RawJsonMismatch),
];
for error in errors {
@@ -1340,6 +1376,78 @@ mod tests {
.to_string()
.contains("canonical event id digest")
);
+
+ assert!(matches!(
+ RadrootsDraftError::from(RadrootsCanonicalEventIdError::InvalidPubkey(
+ RadrootsIdParseError::InvalidFormat,
+ )),
+ RadrootsDraftError::IdParse(_)
+ ));
+ assert!(matches!(
+ RadrootsDraftError::from(RadrootsCanonicalEventIdError::InvalidComputedEventId(
+ RadrootsIdParseError::InvalidFormat,
+ )),
+ RadrootsDraftError::CanonicalEventId(_)
+ ));
+ }
+
+ #[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn draft_and_signed_event_accessors_expose_typed_state() {
+ let draft = post_draft();
+ assert_eq!(draft.contract_id(), "radroots.social.post.v1");
+ assert_eq!(
+ draft.contract_registry_version(),
+ RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION
+ );
+ assert_eq!(draft.kind().as_u32(), draft.kind_u32());
+ assert_eq!(draft.created_at().as_u64(), draft.created_at_u64());
+ assert_eq!(draft.tags().to_vec(), draft.tags_as_vec());
+ assert_eq!(
+ draft.expected_pubkey().as_str(),
+ draft.expected_pubkey_str()
+ );
+ assert_eq!(
+ draft.expected_event_id().as_str(),
+ draft.expected_event_id_str()
+ );
+
+ let signed = signed_event_for_draft(&draft);
+ assert_eq!(signed.envelope().id_str(), signed.id_str());
+ assert_eq!(signed.wire().id, signed.id_str());
+ assert_eq!(signed.id().as_str(), signed.id_str());
+ assert_eq!(signed.pubkey().as_str(), signed.pubkey_str());
+ assert_eq!(signed.sig().as_str(), signed.sig_str());
+
+ for error in [
+ RadrootsSignedEventError::Wire(RadrootsEventWireError::NonCanonicalIdentifier {
+ field: "id",
+ }),
+ RadrootsSignedEventError::RawJson(RadrootsEventWireError::NonCanonicalIdentifier {
+ field: "id",
+ }),
+ RadrootsSignedEventError::RawJsonMismatch,
+ RadrootsSignedEventError::from(RadrootsEventEnvelopeError::NonCanonicalId),
+ ] {
+ assert!(!error.to_string().is_empty());
+ }
+
+ let wire = signed.wire().clone();
+ let mut different_wire = wire.clone();
+ different_wire.content = "different".to_owned();
+ different_wire.id = compute_canonical_nip01_event_id(
+ different_wire.pubkey.as_str(),
+ different_wire.created_at,
+ different_wire.kind,
+ &different_wire.tags,
+ different_wire.content.as_str(),
+ )
+ .expect("different id")
+ .into_string();
+ let error =
+ RadrootsSignedEvent::from_wire_verified_id(wire, raw_json_for_wire(&different_wire))
+ .expect_err("raw JSON mismatch");
+ assert_eq!(error, RadrootsSignedEventError::RawJsonMismatch);
}
#[test]
@@ -1347,6 +1455,9 @@ mod tests {
let error =
compute_nip01_event_id("not-hex", 1, KIND_POST, &[], "").expect_err("invalid pubkey");
assert!(matches!(error, RadrootsDraftError::IdParse(_)));
+ let error = nip01_event_id_preimage("not-hex", 1, KIND_POST, &[], "")
+ .expect_err("invalid preimage pubkey");
+ assert!(matches!(error, RadrootsDraftError::IdParse(_)));
}
#[cfg(feature = "signature")]
diff --git a/crates/event/src/envelope.rs b/crates/event/src/envelope.rs
@@ -787,4 +787,153 @@ mod tests {
Some(u64::from(u32::MAX) + 1)
);
}
+
+ #[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn typed_envelope_api_and_error_contracts_are_complete() {
+ #[allow(dead_code)]
+ #[derive(Debug, serde::Deserialize)]
+ struct MissingTimestamp {
+ value: RadrootsEventTimestamp,
+ }
+ #[allow(dead_code)]
+ #[derive(Debug, serde::Deserialize)]
+ struct MissingKind {
+ value: RadrootsEventKind,
+ }
+ #[allow(dead_code)]
+ #[derive(Debug, serde::Deserialize)]
+ struct MissingTags {
+ value: RadrootsEventTags,
+ }
+
+ for message in [
+ serde_json::from_str::<MissingTimestamp>("{}")
+ .expect_err("missing timestamp")
+ .to_string(),
+ serde_json::from_str::<MissingKind>("{}")
+ .expect_err("missing kind")
+ .to_string(),
+ serde_json::from_str::<MissingTags>("{}")
+ .expect_err("missing tags")
+ .to_string(),
+ ] {
+ assert!(message.contains("missing field `value`"));
+ }
+
+ let timestamp = RadrootsEventTimestamp::from(42);
+ assert_eq!(timestamp.as_u64(), 42);
+ assert_eq!(
+ serde_json::from_str::<RadrootsEventTimestamp>(
+ &serde_json::to_string(×tamp).expect("timestamp json"),
+ )
+ .expect("timestamp"),
+ timestamp
+ );
+ let kind = RadrootsEventKind::from(30_023);
+ assert_eq!(kind.as_u32(), 30_023);
+ assert_eq!(
+ serde_json::from_str::<RadrootsEventKind>(
+ &serde_json::to_string(&kind).expect("kind json"),
+ )
+ .expect("kind"),
+ kind
+ );
+
+ let parse_error = RadrootsEventId::parse("bad").expect_err("invalid id");
+ for error in [
+ RadrootsEventEnvelopeError::InvalidId(parse_error.clone()),
+ RadrootsEventEnvelopeError::InvalidAuthor(parse_error.clone()),
+ RadrootsEventEnvelopeError::InvalidSignature(parse_error),
+ RadrootsEventEnvelopeError::NonCanonicalId,
+ RadrootsEventEnvelopeError::NonCanonicalAuthor,
+ RadrootsEventEnvelopeError::NonCanonicalSignature,
+ RadrootsEventEnvelopeError::EmptyTag { index: 1 },
+ RadrootsEventEnvelopeError::EmptyTagKey { index: 1 },
+ RadrootsEventEnvelopeError::ControlCharacterTagKey { index: 1 },
+ RadrootsEventEnvelopeError::ContentTooLarge { max: 1, actual: 2 },
+ RadrootsEventEnvelopeError::TooManyTags { max: 1, actual: 2 },
+ RadrootsEventEnvelopeError::TagElementTooLarge {
+ tag_index: 1,
+ element_index: 2,
+ max: 3,
+ actual: 4,
+ },
+ RadrootsEventEnvelopeError::TagsTooLarge { max: 1, actual: 2 },
+ ] {
+ assert!(!error.to_string().is_empty());
+ }
+
+ let tag = RadrootsEventTag::new(0, vec!["t".to_owned(), "soil".to_owned()]).expect("tag");
+ assert_eq!(tag.clone().into_vec(), vec!["t", "soil"]);
+ let tag_json = serde_json::to_string(&tag).expect("tag json");
+ assert_eq!(
+ serde_json::from_str::<RadrootsEventTag>(&tag_json).expect("tag"),
+ tag
+ );
+ assert!(serde_json::from_str::<RadrootsEventTag>("[]").is_err());
+ assert!(
+ RadrootsEventTag::new_with_limits(
+ 0,
+ vec!["tag".to_owned()],
+ RadrootsEventEnvelopeLimits {
+ max_total_tag_bytes: 2,
+ ..RadrootsEventEnvelopeLimits::default()
+ },
+ )
+ .is_err()
+ );
+
+ let empty_tags = RadrootsEventTags::new(Vec::new()).expect("empty tags");
+ assert_eq!(empty_tags.len(), 0);
+ assert!(empty_tags.is_empty());
+ assert!(empty_tags.clone().into_vec().is_empty());
+ let tags =
+ RadrootsEventTags::new(vec![vec!["t".to_owned(), "soil".to_owned()]]).expect("tags");
+ let tags_json = serde_json::to_string(&tags).expect("tags json");
+ assert_eq!(
+ serde_json::from_str::<RadrootsEventTags>(&tags_json).expect("tags"),
+ tags
+ );
+ assert!(serde_json::from_str::<RadrootsEventTags>("[[]]").is_err());
+
+ let envelope = RadrootsEventEnvelope::new(event_parts()).expect("envelope");
+ assert_eq!(envelope.id().as_str(), envelope.id_str());
+ assert_eq!(envelope.author().as_str(), envelope.author_str());
+ assert_eq!(envelope.created_at().as_u64(), envelope.created_at_u64());
+ assert_eq!(envelope.kind().as_u32(), envelope.kind_u32());
+ assert_eq!(envelope.tags().to_vec(), envelope.tags_as_vec());
+ assert_eq!(envelope.tag_slices(), envelope.tags().as_slice());
+ assert_eq!(envelope.sig().as_str(), envelope.sig_str());
+ let wire = envelope.to_nip01_wire();
+ assert_eq!(wire.id, envelope.id_str());
+ let encoded = serde_json::to_string(&envelope).expect("envelope json");
+ assert_eq!(
+ serde_json::from_str::<RadrootsEventEnvelope>(&encoded).expect("envelope"),
+ envelope
+ );
+
+ for (field, value) in [
+ ("id", hex_64('A')),
+ ("author", hex_64('A')),
+ ("sig", hex_128('B')),
+ ] {
+ let mut parts = event_parts();
+ match field {
+ "id" => parts.id = value,
+ "author" => parts.author = value,
+ "sig" => parts.sig = value,
+ _ => unreachable!("fixture field"),
+ }
+ assert!(RadrootsEventEnvelope::new(parts).is_err());
+ }
+ for tags in [
+ vec![vec![String::new()]],
+ vec![vec!["line\nbreak".to_owned()]],
+ ] {
+ let mut parts = event_parts();
+ parts.tags = tags;
+ assert!(RadrootsEventEnvelope::new(parts).is_err());
+ }
+ }
}
diff --git a/crates/event/src/ids.rs b/crates/event/src/ids.rs
@@ -491,6 +491,7 @@ mod tests {
actual: 64
}
);
+ assert_identifier_impls!(RadrootsTradeId, &hex_32('a'));
assert_identifier_impls!(RadrootsTradeCandidateId, &hex_64('b'));
assert_identifier_impls!(RadrootsTradeMutationId, &hex_64('c'));
}
@@ -774,6 +775,26 @@ mod tests {
}
#[allow(dead_code)]
#[derive(Debug, serde::Deserialize)]
+ struct MissingEventSignature {
+ value: RadrootsEventSignature,
+ }
+ #[allow(dead_code)]
+ #[derive(Debug, serde::Deserialize)]
+ struct MissingTradeId {
+ value: RadrootsTradeId,
+ }
+ #[allow(dead_code)]
+ #[derive(Debug, serde::Deserialize)]
+ struct MissingTradeCandidateId {
+ value: RadrootsTradeCandidateId,
+ }
+ #[allow(dead_code)]
+ #[derive(Debug, serde::Deserialize)]
+ struct MissingTradeMutationId {
+ value: RadrootsTradeMutationId,
+ }
+ #[allow(dead_code)]
+ #[derive(Debug, serde::Deserialize)]
struct MissingDTag {
value: RadrootsDTag,
}
@@ -784,6 +805,11 @@ mod tests {
}
#[allow(dead_code)]
#[derive(Debug, serde::Deserialize)]
+ struct MissingAddressableCoordinate {
+ value: RadrootsAddressableCoordinate,
+ }
+ #[allow(dead_code)]
+ #[derive(Debug, serde::Deserialize)]
struct MissingOrderId {
value: RadrootsOrderId,
}
@@ -810,10 +836,31 @@ mod tests {
let missing = "missing field `value` at line 1 column 2";
assert_eq!(missing_field_message::<MissingPublicKey>(), missing);
assert_eq!(missing_field_message::<MissingEventId>(), missing);
+ assert_eq!(missing_field_message::<MissingEventSignature>(), missing);
+ assert_eq!(missing_field_message::<MissingTradeId>(), missing);
+ assert_eq!(missing_field_message::<MissingTradeCandidateId>(), missing);
+ assert_eq!(missing_field_message::<MissingTradeMutationId>(), missing);
assert_eq!(missing_field_message::<MissingDTag>(), missing);
assert_eq!(missing_field_message::<MissingListingAddress>(), missing);
+ assert_eq!(
+ missing_field_message::<MissingAddressableCoordinate>(),
+ missing
+ );
assert_eq!(missing_field_message::<MissingOrderId>(), missing);
assert_eq!(missing_field_message::<MissingOrderQuoteId>(), missing);
assert_eq!(missing_field_message::<MissingInventoryBinId>(), missing);
+
+ let order: RadrootsOrderId =
+ serde_json::from_value(serde_json::json!("order-1")).expect("order from value");
+ let listing: RadrootsListingAddress = serde_json::from_value(serde_json::json!(format!(
+ "30402:{}:listing-1",
+ hex_64('a')
+ )))
+ .expect("listing from value");
+ let quote: RadrootsOrderQuoteId =
+ serde_json::from_value(serde_json::json!("quote-1")).expect("quote from value");
+ assert_eq!(order.as_str(), "order-1");
+ assert_eq!(listing.as_str().split(':').next(), Some("30402"));
+ assert_eq!(quote.as_str(), "quote-1");
}
}
diff --git a/crates/event/src/kinds.rs b/crates/event/src/kinds.rs
@@ -540,7 +540,7 @@ pub const fn is_trade_validation_service_result_kind(kind: u32) -> bool {
#[inline]
pub const fn is_trade_validation_service_event_kind(kind: u32) -> bool {
- is_trade_validation_service_request_kind(kind) || is_trade_validation_service_result_kind(kind)
+ is_trade_validation_service_request_kind(kind) | is_trade_validation_service_result_kind(kind)
}
#[inline]
@@ -570,7 +570,7 @@ pub const fn is_trade_validation_receipt_kind(kind: u32) -> bool {
#[inline]
pub const fn is_trade_validation_event_kind(kind: u32) -> bool {
- is_trade_validation_service_event_kind(kind) || is_trade_validation_receipt_kind(kind)
+ is_trade_validation_service_event_kind(kind) | is_trade_validation_receipt_kind(kind)
}
#[inline]
diff --git a/crates/event/src/lib.rs b/crates/event/src/lib.rs
@@ -1,3 +1,4 @@
+#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
#![cfg_attr(all(not(feature = "std"), not(test)), no_std)]
#![forbid(unsafe_code)]
#[cfg(not(feature = "std"))]
diff --git a/crates/event/src/trade.rs b/crates/event/src/trade.rs
@@ -1,15 +1,11 @@
#![forbid(unsafe_code)]
#[cfg(all(not(feature = "std"), feature = "serde"))]
-use alloc::collections::BTreeMap;
-#[cfg(all(not(feature = "std"), any(feature = "serde", test)))]
-use alloc::{format, string::ToString};
+use alloc::{collections::BTreeMap, format, string::ToString};
#[cfg(not(feature = "std"))]
use alloc::{string::String, vec::Vec};
#[cfg(all(feature = "std", feature = "serde"))]
-use std::collections::BTreeMap;
-#[cfg(all(feature = "std", feature = "serde"))]
-use std::string::ToString;
+use std::{collections::BTreeMap, string::ToString};
#[cfg(feature = "std")]
use std::{string::String, vec::Vec};
@@ -655,32 +651,20 @@ impl std::error::Error for RadrootsTradeProtocolError {}
pub fn canonical_trade_candidate_id(
candidate: &RadrootsTradeCandidateTermsV1,
) -> Result<RadrootsTradeCandidateId, RadrootsTradeProtocolError> {
- let mut value = serde_json::to_value(candidate)
- .map_err(|error| RadrootsTradeProtocolError::InvalidJson(error.to_string()))?;
+ let mut value = serialize_trade_value(candidate);
remove_object_field(&mut value, "candidate_id")?;
let canonical = canonical_jcs_value(&value)?;
- digest_prefixed(RADROOTS_TRADE_CANDIDATE_DOMAIN, canonical.as_bytes())
- .parse()
- .map_err(|error| RadrootsTradeProtocolError::InvalidIdentifier {
- field: "candidate_id",
- error,
- })
+ Ok(trade_candidate_id_from_canonical(canonical.as_bytes()))
}
#[cfg(feature = "serde")]
pub fn canonical_trade_mutation_id(
envelope: &RadrootsTradeMutationEnvelopeV1,
) -> Result<RadrootsTradeMutationId, RadrootsTradeProtocolError> {
- let mut value = serde_json::to_value(envelope)
- .map_err(|error| RadrootsTradeProtocolError::InvalidJson(error.to_string()))?;
+ let mut value = serialize_trade_value(envelope);
remove_object_field(&mut value, "mutation_id")?;
let canonical = canonical_jcs_value(&value)?;
- digest_prefixed(RADROOTS_TRADE_MUTATION_DOMAIN, canonical.as_bytes())
- .parse()
- .map_err(|error| RadrootsTradeProtocolError::InvalidIdentifier {
- field: "mutation_id",
- error,
- })
+ Ok(trade_mutation_id_from_canonical(canonical.as_bytes()))
}
#[cfg(feature = "serde")]
@@ -691,8 +675,7 @@ pub fn canonical_trade_mutation_content(
envelope.validate()?;
let mutation_id = canonical_trade_mutation_id(&envelope)?;
envelope.mutation_id = Some(mutation_id.clone());
- let value = serde_json::to_value(&envelope)
- .map_err(|error| RadrootsTradeProtocolError::InvalidJson(error.to_string()))?;
+ let value = serialize_trade_value(&envelope);
let content = canonical_jcs_value(&value)?;
if content.len() > RADROOTS_TRADE_MAX_PUBLIC_CONTENT_BYTES {
return Err(RadrootsTradeProtocolError::ContentTooLarge {
@@ -778,9 +761,7 @@ fn write_canonical_jcs(
Value::Bool(value) => output.push_str(if *value { "true" } else { "false" }),
Value::Number(number) => output.push_str(&canonical_number(number)?),
Value::String(value) => {
- let encoded = serde_json::to_string(value)
- .map_err(|error| RadrootsTradeProtocolError::InvalidJson(error.to_string()))?;
- output.push_str(&encoded);
+ output.push_str(canonical_json_string(value).as_str());
}
Value::Array(values) => {
output.push('[');
@@ -800,15 +781,9 @@ fn write_canonical_jcs(
if index > 0 {
output.push(',');
}
- let encoded = serde_json::to_string(key.as_str())
- .map_err(|error| RadrootsTradeProtocolError::InvalidJson(error.to_string()))?;
- output.push_str(&encoded);
+ output.push_str(canonical_json_string(key.as_str()).as_str());
output.push(':');
- let value =
- map.get(key.as_str())
- .ok_or(RadrootsTradeProtocolError::InvalidJson(
- "missing key".to_string(),
- ))?;
+ let value = &map[key.as_str()];
write_canonical_jcs(value, output)?;
}
output.push('}');
@@ -835,6 +810,32 @@ fn digest_prefixed(domain: &[u8], bytes: &[u8]) -> String {
}
#[cfg(feature = "serde")]
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn serialize_trade_value(value: &impl Serialize) -> Value {
+ serde_json::to_value(value).expect("closed trade models always serialize to JSON values")
+}
+
+#[cfg(feature = "serde")]
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn canonical_json_string(value: &str) -> String {
+ serde_json::to_string(value).expect("JSON strings always serialize")
+}
+
+#[cfg(feature = "serde")]
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn trade_candidate_id_from_canonical(canonical: &[u8]) -> RadrootsTradeCandidateId {
+ RadrootsTradeCandidateId::parse(digest_prefixed(RADROOTS_TRADE_CANDIDATE_DOMAIN, canonical))
+ .expect("SHA-256 always produces a canonical 64-character identifier")
+}
+
+#[cfg(feature = "serde")]
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn trade_mutation_id_from_canonical(canonical: &[u8]) -> RadrootsTradeMutationId {
+ RadrootsTradeMutationId::parse(digest_prefixed(RADROOTS_TRADE_MUTATION_DOMAIN, canonical))
+ .expect("SHA-256 always produces a canonical 64-character identifier")
+}
+
+#[cfg(feature = "serde")]
fn remove_object_field(
value: &mut Value,
field: &'static str,
@@ -1072,7 +1073,7 @@ impl<'de> Visitor<'de> for NoDuplicateJsonValueVisitor {
}
}
-#[cfg(test)]
+#[cfg(all(test, feature = "serde"))]
mod tests {
use super::*;
@@ -1096,6 +1097,14 @@ mod tests {
RadrootsTradeId::parse(hex_32('1')).unwrap()
}
+ fn mutation_id(character: char) -> RadrootsTradeMutationId {
+ RadrootsTradeMutationId::parse(hex_64(character)).unwrap()
+ }
+
+ fn candidate_id(character: char) -> RadrootsTradeCandidateId {
+ RadrootsTradeCandidateId::parse(hex_64(character)).unwrap()
+ }
+
fn candidate() -> RadrootsTradeCandidateTermsV1 {
RadrootsTradeCandidateTermsV1 {
candidate_id: None,
@@ -1179,6 +1188,43 @@ mod tests {
}
}
+ fn adjustment(id: &str) -> RadrootsTradeEconomicAdjustmentV1 {
+ RadrootsTradeEconomicAdjustmentV1 {
+ adjustment_id: RadrootsDTag::parse(id).unwrap(),
+ actor: "seller".to_owned(),
+ effect: "charge".to_owned(),
+ amount_mantissa: "10".to_owned(),
+ reason: "packing".to_owned(),
+ }
+ }
+
+ fn reservation_assertion() -> RadrootsSellerReservationAssertionV1 {
+ RadrootsSellerReservationAssertionV1 {
+ reservation_id: RadrootsDTag::parse("reservation-1").unwrap(),
+ inventory_authority_id: pubkey('c'),
+ inventory_epoch: 1,
+ candidate_id: candidate_id('d'),
+ commitments: vec![RadrootsSellerReservationLineV1 {
+ line_id: RadrootsDTag::parse("line-1").unwrap(),
+ bin_id: RadrootsInventoryBinId::parse("bin-1").unwrap(),
+ quantity_mantissa: "2".to_owned(),
+ quantity_scale: 0,
+ unit_code: "count".to_owned(),
+ }],
+ reservation_expires_at_unix_s: 1_800_000_000,
+ assertion_commitment: hex_64('e'),
+ }
+ }
+
+ fn child_envelope(body: RadrootsTradeMutationBodyV1) -> RadrootsTradeMutationEnvelopeV1 {
+ let mut envelope = proposal();
+ envelope.contract_id = body.mutation_kind().contract_id().to_owned();
+ envelope.root_mutation_id = Some(mutation_id('a'));
+ envelope.parent_mutation_ids = vec![mutation_id('a')];
+ envelope.body = body;
+ envelope
+ }
+
#[test]
fn canonical_json_sorts_keys_and_rejects_duplicate_keys() {
assert_eq!(
@@ -1220,4 +1266,517 @@ mod tests {
Err(RadrootsTradeProtocolError::UnsortedParents)
));
}
+
+ #[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn trade_validation_contract_covers_every_mutation_and_parent_rule() {
+ let kinds = [
+ RadrootsTradeMutationKindV1::Proposal,
+ RadrootsTradeMutationKindV1::Decision,
+ RadrootsTradeMutationKindV1::RevisionProposal,
+ RadrootsTradeMutationKindV1::RevisionDecision,
+ RadrootsTradeMutationKindV1::Cancellation,
+ ];
+ for kind in kinds {
+ assert!(!kind.contract_id().is_empty());
+ assert_ne!(kind.nostr_kind(), 0);
+ }
+
+ let mut envelope = proposal();
+ envelope.schema_version += 1;
+ assert!(matches!(
+ envelope.validate(),
+ Err(RadrootsTradeProtocolError::InvalidSchemaVersion { .. })
+ ));
+ let mut envelope = proposal();
+ envelope.contract_id = "wrong".to_owned();
+ assert!(matches!(
+ envelope.validate(),
+ Err(RadrootsTradeProtocolError::ContractMismatch { .. })
+ ));
+ let mut envelope = proposal();
+ envelope.root_mutation_id = Some(mutation_id('a'));
+ assert_eq!(
+ envelope.validate(),
+ Err(RadrootsTradeProtocolError::InvalidInitialParents)
+ );
+ let mut envelope = proposal();
+ envelope.parent_mutation_ids = vec![mutation_id('a')];
+ assert_eq!(
+ envelope.validate(),
+ Err(RadrootsTradeProtocolError::InvalidInitialParents)
+ );
+
+ let mut envelope = child_envelope(RadrootsTradeMutationBodyV1::Decision {
+ proposal_mutation_id: mutation_id('a'),
+ candidate_id: candidate_id('a'),
+ decision: RadrootsTradeDecisionV1::Accepted {
+ reservation_assertion: None,
+ },
+ });
+ assert!(envelope.validate().is_ok());
+ envelope.root_mutation_id = None;
+ assert_eq!(
+ envelope.validate(),
+ Err(RadrootsTradeProtocolError::MissingParentMutation)
+ );
+ let mut envelope = child_envelope(RadrootsTradeMutationBodyV1::Decision {
+ proposal_mutation_id: mutation_id('a'),
+ candidate_id: candidate_id('a'),
+ decision: RadrootsTradeDecisionV1::Accepted {
+ reservation_assertion: None,
+ },
+ });
+ envelope.parent_mutation_ids.clear();
+ assert_eq!(
+ envelope.validate(),
+ Err(RadrootsTradeProtocolError::MissingParentMutation)
+ );
+
+ let revision_decision = child_envelope(RadrootsTradeMutationBodyV1::RevisionDecision {
+ proposal_mutation_id: mutation_id('a'),
+ candidate_id: candidate_id('a'),
+ decision: RadrootsTradeDecisionV1::Declined {
+ reason: "inventory unavailable".to_owned(),
+ },
+ });
+ assert!(revision_decision.validate().is_ok());
+ let revision = child_envelope(RadrootsTradeMutationBodyV1::RevisionProposal {
+ candidate: candidate(),
+ });
+ assert!(revision.validate().is_ok());
+
+ for body in [
+ RadrootsTradeMutationBodyV1::Cancellation {
+ target_candidate_id: Some(candidate_id('a')),
+ target_claim_mutation_id: None,
+ reason: "cancelled".to_owned(),
+ },
+ RadrootsTradeMutationBodyV1::Cancellation {
+ target_candidate_id: None,
+ target_claim_mutation_id: Some(mutation_id('a')),
+ reason: "cancelled".to_owned(),
+ },
+ ] {
+ assert!(child_envelope(body).validate().is_ok());
+ }
+ assert_eq!(
+ RadrootsTradeMutationBodyV1::Cancellation {
+ target_candidate_id: None,
+ target_claim_mutation_id: None,
+ reason: "cancelled".to_owned(),
+ }
+ .validate(),
+ Err(RadrootsTradeProtocolError::MissingCancellationTarget)
+ );
+ assert!(
+ RadrootsTradeMutationBodyV1::Cancellation {
+ target_candidate_id: Some(candidate_id('a')),
+ target_claim_mutation_id: None,
+ reason: " ".to_owned(),
+ }
+ .validate()
+ .is_err()
+ );
+
+ assert_eq!(
+ validate_parent_mutation_ids(
+ None,
+ &[
+ mutation_id('a'),
+ mutation_id('b'),
+ mutation_id('c'),
+ mutation_id('d'),
+ mutation_id('e'),
+ ],
+ ),
+ Err(RadrootsTradeProtocolError::TooManyParents {
+ max: RADROOTS_TRADE_MAX_PARENT_MUTATIONS,
+ actual: 5,
+ })
+ );
+ assert_eq!(
+ validate_parent_mutation_ids(None, &[mutation_id('a'), mutation_id('a')]),
+ Err(RadrootsTradeProtocolError::DuplicateParent)
+ );
+ assert_eq!(
+ validate_parent_mutation_ids(Some(&mutation_id('a')), &[mutation_id('a')]),
+ Err(RadrootsTradeProtocolError::SelfParent)
+ );
+ assert!(validate_parent_mutation_ids(None, &[mutation_id('a'), mutation_id('b')]).is_ok());
+ assert!(validate_sorted_unique_by(&["a", "b"], |value| *value, "values").is_ok());
+ }
+
+ #[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn trade_candidate_validation_covers_every_nested_profile() {
+ let base = candidate();
+ assert!(base.validate().is_ok());
+
+ let mut invalid = base.clone();
+ invalid.schema_version += 1;
+ assert!(invalid.validate().is_err());
+ let mut invalid = base.clone();
+ invalid.lines.clear();
+ assert_eq!(
+ invalid.validate(),
+ Err(RadrootsTradeProtocolError::MissingLines)
+ );
+ let mut invalid = base.clone();
+ invalid.lines = vec![base.lines[0].clone(); RADROOTS_TRADE_MAX_ACTIVE_LINES + 1];
+ assert!(matches!(
+ invalid.validate(),
+ Err(RadrootsTradeProtocolError::TooManyLines { .. })
+ ));
+ let mut invalid = base.clone();
+ invalid.lines.push(base.lines[0].clone());
+ assert!(matches!(
+ invalid.validate(),
+ Err(RadrootsTradeProtocolError::InvalidField("lines"))
+ ));
+
+ let tombstone = RadrootsTradeLineTombstoneV1 {
+ line_id: RadrootsDTag::parse("line-2").unwrap(),
+ reason: "removed".to_owned(),
+ };
+ let mut with_tombstone = base.clone();
+ with_tombstone.line_tombstones.push(tombstone.clone());
+ assert!(with_tombstone.validate().is_ok());
+ let mut invalid = with_tombstone.clone();
+ invalid.line_tombstones.push(tombstone);
+ assert!(invalid.validate().is_err());
+ let mut invalid = with_tombstone;
+ invalid.line_tombstones[0].reason = " ".to_owned();
+ assert!(invalid.validate().is_err());
+
+ let mut line = base.lines[0].clone();
+ line.option_id = Some("option-1".to_owned());
+ assert!(line.validate().is_ok());
+ for mutate in [
+ |line: &mut RadrootsTradeCandidateLineV1| {
+ line.listing_snapshot_sha256 = "bad".to_owned()
+ },
+ |line: &mut RadrootsTradeCandidateLineV1| line.product_id = " ".to_owned(),
+ |line: &mut RadrootsTradeCandidateLineV1| line.option_id = Some(" ".to_owned()),
+ |line: &mut RadrootsTradeCandidateLineV1| line.quantity_mantissa = "1.5".to_owned(),
+ |line: &mut RadrootsTradeCandidateLineV1| line.unit_code = " ".to_owned(),
+ |line: &mut RadrootsTradeCandidateLineV1| line.unit_profile = " ".to_owned(),
+ |line: &mut RadrootsTradeCandidateLineV1| line.unit_price_mantissa = "-".to_owned(),
+ |line: &mut RadrootsTradeCandidateLineV1| line.currency_code = " ".to_owned(),
+ |line: &mut RadrootsTradeCandidateLineV1| line.line_subtotal_mantissa = "x".to_owned(),
+ ] {
+ let mut line = base.lines[0].clone();
+ mutate(&mut line);
+ assert!(line.validate().is_err());
+ }
+ let mut line = base.lines[0].clone();
+ line.quantity_mantissa = "-2".to_owned();
+ assert!(line.validate().is_ok());
+
+ let economics = base.economics.clone();
+ for mutate in [
+ |value: &mut RadrootsTradeEconomicsProfileV1| value.profile_id = " ".to_owned(),
+ |value: &mut RadrootsTradeEconomicsProfileV1| value.currency_code = " ".to_owned(),
+ |value: &mut RadrootsTradeEconomicsProfileV1| value.rounding_profile = " ".to_owned(),
+ |value: &mut RadrootsTradeEconomicsProfileV1| value.subtotal_mantissa = "x".to_owned(),
+ |value: &mut RadrootsTradeEconomicsProfileV1| {
+ value.discount_total_mantissa = "x".to_owned()
+ },
+ |value: &mut RadrootsTradeEconomicsProfileV1| {
+ value.adjustment_total_mantissa = "x".to_owned()
+ },
+ |value: &mut RadrootsTradeEconomicsProfileV1| value.total_mantissa = "x".to_owned(),
+ ] {
+ let mut value = economics.clone();
+ mutate(&mut value);
+ assert!(value.validate().is_err());
+ }
+ let mut value = economics.clone();
+ value.adjustments = vec![adjustment("adjustment-1"); RADROOTS_TRADE_MAX_ADJUSTMENTS + 1];
+ assert!(matches!(
+ value.validate(),
+ Err(RadrootsTradeProtocolError::TooManyAdjustments { .. })
+ ));
+ let mut value = economics.clone();
+ value.adjustments = vec![adjustment("adjustment-1"), adjustment("adjustment-1")];
+ assert!(value.validate().is_err());
+ let mut value = economics.clone();
+ value.adjustments = vec![adjustment("adjustment-1")];
+ assert!(value.validate().is_ok());
+ for mutate in [
+ |value: &mut RadrootsTradeEconomicAdjustmentV1| value.actor = " ".to_owned(),
+ |value: &mut RadrootsTradeEconomicAdjustmentV1| value.effect = " ".to_owned(),
+ |value: &mut RadrootsTradeEconomicAdjustmentV1| value.amount_mantissa = "x".to_owned(),
+ |value: &mut RadrootsTradeEconomicAdjustmentV1| value.reason = " ".to_owned(),
+ ] {
+ let mut value = adjustment("adjustment-1");
+ mutate(&mut value);
+ assert!(value.validate().is_err());
+ }
+
+ let fulfillment = base.fulfillment.clone();
+ for mutate in [
+ |value: &mut RadrootsFulfillmentProfileV1| value.profile_id = " ".to_owned(),
+ |value: &mut RadrootsFulfillmentProfileV1| value.method = " ".to_owned(),
+ |value: &mut RadrootsFulfillmentProfileV1| value.timezone = " ".to_owned(),
+ |value: &mut RadrootsFulfillmentProfileV1| value.location_class = " ".to_owned(),
+ |value: &mut RadrootsFulfillmentProfileV1| {
+ value.ends_at_unix_s = value.starts_at_unix_s
+ },
+ |value: &mut RadrootsFulfillmentProfileV1| value.fold = 2,
+ ] {
+ let mut value = fulfillment.clone();
+ mutate(&mut value);
+ assert!(value.validate().is_err());
+ }
+ let mut cancellation = base.cancellation.clone();
+ cancellation.profile_id = " ".to_owned();
+ assert!(cancellation.validate().is_err());
+
+ let private_terms = RadrootsTradePrivateTermsRefV1 {
+ artifact_id: "artifact-1".to_owned(),
+ schema_id: "schema-1".to_owned(),
+ ciphertext_commitment: hex_64('a'),
+ required_acknowledgement: true,
+ };
+ let mut with_private = base.clone();
+ with_private.private_terms = Some(private_terms.clone());
+ assert!(with_private.validate().is_ok());
+ for mutate in [
+ |value: &mut RadrootsTradePrivateTermsRefV1| value.artifact_id = " ".to_owned(),
+ |value: &mut RadrootsTradePrivateTermsRefV1| value.schema_id = " ".to_owned(),
+ |value: &mut RadrootsTradePrivateTermsRefV1| {
+ value.ciphertext_commitment = "bad".to_owned()
+ },
+ ] {
+ let mut value = private_terms.clone();
+ mutate(&mut value);
+ assert!(value.validate().is_err());
+ }
+ }
+
+ #[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn trade_decision_and_reservation_contracts_cover_all_paths() {
+ let assertion = reservation_assertion();
+ assert!(assertion.validate().is_ok());
+ assert!(
+ RadrootsTradeDecisionV1::Accepted {
+ reservation_assertion: None,
+ }
+ .validate()
+ .is_ok()
+ );
+ assert!(
+ RadrootsTradeDecisionV1::Accepted {
+ reservation_assertion: Some(assertion.clone()),
+ }
+ .validate()
+ .is_ok()
+ );
+ assert!(
+ RadrootsTradeDecisionV1::Declined {
+ reason: "declined".to_owned(),
+ }
+ .validate()
+ .is_ok()
+ );
+ assert!(
+ RadrootsTradeDecisionV1::Declined {
+ reason: " ".to_owned(),
+ }
+ .validate()
+ .is_err()
+ );
+
+ let mut invalid = assertion.clone();
+ invalid.commitments.clear();
+ assert_eq!(
+ invalid.validate(),
+ Err(RadrootsTradeProtocolError::MissingReservationCommitments)
+ );
+ let mut invalid = assertion.clone();
+ invalid.commitments.push(invalid.commitments[0].clone());
+ assert!(invalid.validate().is_err());
+ let mut invalid = assertion.clone();
+ invalid.commitments[0].quantity_mantissa = "x".to_owned();
+ assert!(invalid.validate().is_err());
+ let mut invalid = assertion.clone();
+ invalid.commitments[0].unit_code = " ".to_owned();
+ assert!(invalid.validate().is_err());
+ let mut invalid = assertion;
+ invalid.assertion_commitment = "bad".to_owned();
+ assert!(invalid.validate().is_err());
+ }
+
+ #[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn trade_canonicalization_and_error_contracts_cover_all_paths() {
+ use serde::de::{Unexpected, value::StrDeserializer};
+
+ let canonical = canonical_trade_mutation_content(proposal()).expect("canonical proposal");
+ assert_eq!(
+ canonical_trade_candidate_id(match &canonical.envelope.body {
+ RadrootsTradeMutationBodyV1::Proposal { candidate } => candidate,
+ _ => unreachable!("proposal"),
+ })
+ .expect("candidate id"),
+ match &canonical.envelope.body {
+ RadrootsTradeMutationBodyV1::Proposal { candidate } => {
+ candidate.candidate_id.clone().expect("candidate id")
+ }
+ _ => unreachable!("proposal"),
+ }
+ );
+ assert_eq!(
+ canonical_trade_mutation_id(&canonical.envelope).expect("mutation id"),
+ canonical.mutation_id
+ );
+
+ assert!(matches!(
+ trade_mutation_from_canonical_content(
+ &"x".repeat(RADROOTS_TRADE_MAX_PUBLIC_CONTENT_BYTES + 1)
+ ),
+ Err(RadrootsTradeProtocolError::ContentTooLarge { .. })
+ ));
+ assert_eq!(
+ trade_mutation_from_canonical_content(" {} "),
+ Err(RadrootsTradeProtocolError::NonCanonicalJson)
+ );
+ assert!(matches!(
+ trade_mutation_from_canonical_content("{}"),
+ Err(RadrootsTradeProtocolError::InvalidJson(_))
+ ));
+ assert!(matches!(
+ canonical_jcs_from_str("{"),
+ Err(RadrootsTradeProtocolError::InvalidJson(_))
+ ));
+ assert!(matches!(
+ canonical_jcs_from_str("1.5"),
+ Err(RadrootsTradeProtocolError::InvalidJson(_))
+ ));
+ assert_eq!(
+ canonical_jcs_value(&serde_json::json!(1.5)),
+ Err(RadrootsTradeProtocolError::UnsupportedNumber)
+ );
+ assert_eq!(
+ canonical_jcs_value(&Value::Number(Number::from(u64::MAX))).expect("large integer"),
+ u64::MAX.to_string()
+ );
+ assert!(remove_object_field(&mut Value::Null, "id").is_err());
+
+ let mut value: Value = serde_json::from_str(&canonical.content).expect("canonical json");
+ value["body"]["candidate"]["candidate_id"] = Value::String(hex_64('f'));
+ let wrong_candidate = canonical_jcs_value(&value).expect("wrong candidate json");
+ assert!(matches!(
+ trade_mutation_from_canonical_content(&wrong_candidate),
+ Err(RadrootsTradeProtocolError::CandidateIdMismatch { .. })
+ ));
+
+ let mut value: Value = serde_json::from_str(&canonical.content).expect("canonical json");
+ value["mutation_id"] = Value::String(hex_64('f'));
+ let wrong_mutation = canonical_jcs_value(&value).expect("wrong mutation json");
+ assert!(matches!(
+ trade_mutation_from_canonical_content(&wrong_mutation),
+ Err(RadrootsTradeProtocolError::MutationIdMismatch { .. })
+ ));
+
+ let mut value: Value = serde_json::from_str(&canonical.content).expect("canonical json");
+ value["mutation_id"] = Value::Null;
+ value["body"]["candidate"]["candidate_id"] = Value::Null;
+ let undeclared_ids = canonical_jcs_value(&value).expect("undeclared ids json");
+ assert!(trade_mutation_from_canonical_content(&undeclared_ids).is_ok());
+
+ let decision = child_envelope(RadrootsTradeMutationBodyV1::Decision {
+ proposal_mutation_id: mutation_id('a'),
+ candidate_id: candidate_id('a'),
+ decision: RadrootsTradeDecisionV1::Accepted {
+ reservation_assertion: None,
+ },
+ });
+ let decision = canonical_trade_mutation_content(decision).expect("canonical decision");
+ assert!(trade_mutation_from_canonical_content(&decision.content).is_ok());
+
+ let revision = child_envelope(RadrootsTradeMutationBodyV1::RevisionProposal {
+ candidate: candidate(),
+ });
+ let revision = canonical_trade_mutation_content(revision).expect("canonical revision");
+ assert!(trade_mutation_from_canonical_content(&revision.content).is_ok());
+
+ let mut oversized = proposal();
+ if let RadrootsTradeMutationBodyV1::Proposal { candidate } = &mut oversized.body {
+ candidate.lines[0].product_id = "x".repeat(RADROOTS_TRADE_MAX_PUBLIC_CONTENT_BYTES + 1);
+ }
+ assert!(matches!(
+ canonical_trade_mutation_content(oversized),
+ Err(RadrootsTradeProtocolError::ContentTooLarge { .. })
+ ));
+
+ let string_value: Result<NoDuplicateJsonValue, serde_json::Error> =
+ NoDuplicateJsonValueVisitor.visit_string("value".to_owned());
+ assert_eq!(
+ string_value.expect("string").0,
+ Value::String("value".to_owned())
+ );
+ let none_value: Result<NoDuplicateJsonValue, serde_json::Error> =
+ NoDuplicateJsonValueVisitor.visit_none();
+ assert_eq!(none_value.expect("none").0, Value::Null);
+ let some_value: Result<NoDuplicateJsonValue, serde_json::Error> =
+ NoDuplicateJsonValueVisitor.visit_some(StrDeserializer::new("value"));
+ assert_eq!(
+ some_value.expect("some").0,
+ Value::String("value".to_owned())
+ );
+ let expected = <serde_json::Error as serde::de::Error>::invalid_type(
+ Unexpected::Bool(true),
+ &NoDuplicateJsonValueVisitor,
+ );
+ assert!(expected.to_string().contains("JSON value"));
+
+ let parse_error = RadrootsTradeMutationId::parse("bad").expect_err("invalid id");
+ let errors = [
+ RadrootsTradeProtocolError::InvalidSchemaVersion {
+ expected: 1,
+ actual: 2,
+ },
+ RadrootsTradeProtocolError::ContractMismatch {
+ expected: "expected",
+ actual: "actual".to_owned(),
+ },
+ RadrootsTradeProtocolError::InvalidInitialParents,
+ RadrootsTradeProtocolError::MissingParentMutation,
+ RadrootsTradeProtocolError::TooManyParents { max: 1, actual: 2 },
+ RadrootsTradeProtocolError::UnsortedParents,
+ RadrootsTradeProtocolError::DuplicateParent,
+ RadrootsTradeProtocolError::SelfParent,
+ RadrootsTradeProtocolError::MissingLines,
+ RadrootsTradeProtocolError::TooManyLines { max: 1, actual: 2 },
+ RadrootsTradeProtocolError::TooManyAdjustments { max: 1, actual: 2 },
+ RadrootsTradeProtocolError::DuplicateKey("key".to_owned()),
+ RadrootsTradeProtocolError::InvalidJson("json".to_owned()),
+ RadrootsTradeProtocolError::NonCanonicalJson,
+ RadrootsTradeProtocolError::UnsupportedNumber,
+ RadrootsTradeProtocolError::ContentTooLarge { max: 1, actual: 2 },
+ RadrootsTradeProtocolError::EmptyField("field"),
+ RadrootsTradeProtocolError::InvalidField("field"),
+ RadrootsTradeProtocolError::InvalidIdentifier {
+ field: "field",
+ error: parse_error,
+ },
+ RadrootsTradeProtocolError::InvalidTimeRange,
+ RadrootsTradeProtocolError::MissingReservationCommitments,
+ RadrootsTradeProtocolError::MissingCancellationTarget,
+ RadrootsTradeProtocolError::CandidateIdMismatch {
+ declared: "a".to_owned(),
+ computed: "b".to_owned(),
+ },
+ RadrootsTradeProtocolError::MutationIdMismatch {
+ declared: "a".to_owned(),
+ computed: "b".to_owned(),
+ },
+ ];
+ for error in errors {
+ assert!(!error.to_string().is_empty());
+ }
+ }
}
diff --git a/crates/event/src/wire.rs b/crates/event/src/wire.rs
@@ -553,12 +553,8 @@ fn validate_extra(
let mut total_json_bytes = 0usize;
let mut extra = BTreeMap::new();
for (key, value) in object {
- let key_json_len = serde_json::to_vec(&key)
- .map_err(|error| RadrootsEventWireError::Json(error.to_string()))?
- .len();
- let value_json_len = serde_json::to_vec(&value)
- .map_err(|error| RadrootsEventWireError::Json(error.to_string()))?
- .len();
+ let key_json_len = serialized_json_string_len(&key);
+ let value_json_len = serialized_json_value_len(&value);
total_json_bytes = total_json_bytes
.saturating_add(key_json_len)
.saturating_add(1)
@@ -574,6 +570,20 @@ fn validate_extra(
Ok(extra)
}
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn serialized_json_string_len(value: &String) -> usize {
+ serde_json::to_vec(value)
+ .expect("JSON strings always serialize")
+ .len()
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+fn serialized_json_value_len(value: &Value) -> usize {
+ serde_json::to_vec(value)
+ .expect("JSON values always serialize")
+ .len()
+}
+
fn push_canonical_json_string(target: &mut String, value: &str) {
target.push('"');
for character in value.chars() {
@@ -944,6 +954,84 @@ mod tests {
}
#[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn wire_parser_and_error_contracts_cover_all_typed_failures() {
+ let parse_error = RadrootsEventId::parse("bad").expect_err("invalid id");
+ for error in [
+ RadrootsEventWireError::Json("bad json".to_owned()),
+ RadrootsEventWireError::RootNotObject,
+ RadrootsEventWireError::MissingField("id"),
+ RadrootsEventWireError::InvalidField("kind"),
+ RadrootsEventWireError::InvalidIdentifier {
+ field: "id",
+ error: parse_error.clone(),
+ },
+ RadrootsEventWireError::NonCanonicalIdentifier { field: "id" },
+ RadrootsEventWireError::RawJsonTooLarge { max: 1, actual: 2 },
+ RadrootsEventWireError::ContentTooLarge { max: 1, actual: 2 },
+ RadrootsEventWireError::TooManyTags { max: 1, actual: 2 },
+ RadrootsEventWireError::EmptyTag { index: 1 },
+ RadrootsEventWireError::EmptyTagKey { index: 1 },
+ RadrootsEventWireError::ControlCharacterTagKey { index: 1 },
+ RadrootsEventWireError::TagElementTooLarge {
+ tag_index: 1,
+ element_index: 2,
+ max: 3,
+ actual: 4,
+ },
+ RadrootsEventWireError::TagsTooLarge { max: 1, actual: 2 },
+ RadrootsEventWireError::TooManyExtraFields { max: 1, actual: 2 },
+ RadrootsEventWireError::ExtraJsonTooLarge { max: 1, actual: 2 },
+ RadrootsEventWireError::from(RadrootsCanonicalEventIdError::InvalidPubkey(
+ parse_error.clone(),
+ )),
+ RadrootsEventWireError::from(RadrootsEventEnvelopeError::NonCanonicalId),
+ RadrootsEventWireError::EventIdMismatch {
+ declared: "a".to_owned(),
+ computed: "b".to_owned(),
+ },
+ ] {
+ assert!(!error.to_string().is_empty());
+ }
+
+ for raw in ["{", "[]", "null"] {
+ assert!(RadrootsNip01EventWire::parse_json(raw).is_err());
+ }
+
+ for (field, replacement) in [
+ ("id", json!(7)),
+ ("id", json!("bad")),
+ ("pubkey", json!(7)),
+ ("pubkey", json!(hex_64('A'))),
+ ("created_at", json!("bad")),
+ ("created_at", json!(-1)),
+ ("kind", json!("bad")),
+ ("kind", json!(u64::from(u32::MAX) + 1)),
+ ("tags", json!("bad")),
+ ("tags", json!(["bad"])),
+ ("tags", json!([["t", 7]])),
+ ("content", json!(7)),
+ ("sig", json!(7)),
+ ("sig", json!("bad")),
+ ("sig", json!(hex_128('B'))),
+ ] {
+ let mut value = valid_event_value("hello", default_tags());
+ value
+ .as_object_mut()
+ .expect("object")
+ .insert(field.to_owned(), replacement);
+ assert!(RadrootsNip01EventWire::parse_json(raw_json(&value).as_str()).is_err());
+ }
+
+ for field in ["pubkey", "created_at", "kind", "tags", "content", "sig"] {
+ let mut value = valid_event_value("hello", default_tags());
+ value.as_object_mut().expect("object").remove(field);
+ assert!(RadrootsNip01EventWire::parse_json(raw_json(&value).as_str()).is_err());
+ }
+ }
+
+ #[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn checked_in_conformance_vectors_match_wire_behavior() {
let vectors =
include_str!("../../../contracts/conformance/vectors/event/nip01_wire.v1.json");
diff --git a/crates/event_codec/Cargo.toml b/crates/event_codec/Cargo.toml
@@ -44,3 +44,6 @@ radroots_blossom = { workspace = true, default-features = false, features = [
] }
radroots_test_fixtures = { workspace = true }
serde_json = { workspace = true, features = ["std"] }
+
+[lints.rust]
+unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
diff --git a/crates/event_codec/src/error.rs b/crates/event_codec/src/error.rs
@@ -93,3 +93,18 @@ impl fmt::Display for EventEncodeError {
#[cfg(feature = "std")]
impl std::error::Error for EventEncodeError {}
+
+#[cfg(test)]
+mod tests {
+ use super::EventParseError;
+ use radroots_event::RadrootsEventEnvelopeError;
+
+ #[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn invalid_envelope_conversion_preserves_public_error_contract() {
+ let error = EventParseError::from(RadrootsEventEnvelopeError::NonCanonicalId);
+
+ assert_eq!(error.code(), "invalid_envelope");
+ assert_eq!(error.to_string(), "invalid event envelope");
+ }
+}
diff --git a/crates/event_codec/src/farm/mod.rs b/crates/event_codec/src/farm/mod.rs
@@ -116,6 +116,7 @@ mod tests {
}
#[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(feature = "serde_json")]
fn farm_decode_rejects_empty_d_tag_and_content() {
let farm = RadrootsFarm {
@@ -151,6 +152,32 @@ mod tests {
empty_content,
crate::error::EventParseError::InvalidJson("content")
));
+
+ let with_empty_tag = farm_from_event(
+ KIND_FARM,
+ &[
+ Vec::new(),
+ vec!["d".to_string(), "AAAAAAAAAAAAAAAAAAAAAA".to_string()],
+ ],
+ &content,
+ )
+ .expect("empty unrelated tags are ignored");
+ assert_eq!(with_empty_tag.name, "Test Farm");
+
+ let parsed_error = parsed_from_event(
+ EVENT_ID.to_string(),
+ AUTHOR.to_string(),
+ 42,
+ KIND_FARM + 1,
+ content,
+ vec![vec!["d".to_string(), "AAAAAAAAAAAAAAAAAAAAAA".to_string()]],
+ EVENT_SIG.to_string(),
+ )
+ .expect_err("parsed wrapper propagates decode failures");
+ assert!(matches!(
+ parsed_error,
+ crate::error::EventParseError::InvalidKind { .. }
+ ));
}
#[test]
@@ -274,6 +301,7 @@ mod tests {
}
#[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(feature = "serde_json")]
fn farm_decode_rejects_private_location_and_ops_shapes() {
let farm = RadrootsFarm {
diff --git a/crates/event_codec/src/farm_crdt/encode.rs b/crates/event_codec/src/farm_crdt/encode.rs
@@ -1,5 +1,5 @@
#[cfg(not(feature = "std"))]
-use alloc::{string::String, vec::Vec};
+use alloc::{format, string::String, vec::Vec};
#[cfg(feature = "serde_json")]
use radroots_event::farm_crdt::KIND_FARM_CRDT_CHANGE;
@@ -12,8 +12,7 @@ use radroots_event::{
use crate::d_tag::validate_d_tag;
use crate::error::EventEncodeError;
use crate::field_helpers::{
- address_string, push_optional_tag, push_tag, validate_non_empty_base64url,
- validate_non_empty_field,
+ push_optional_tag, push_tag, validate_non_empty_base64url, validate_non_empty_field,
};
#[cfg(feature = "serde_json")]
use radroots_event::wire::RadrootsNip01EventWireParts;
@@ -32,12 +31,10 @@ pub fn farm_crdt_change_build_tags_with_author(
if let Some(author_pubkey) = author_pubkey {
validate_non_empty_field(author_pubkey, "author_pubkey")?;
}
- let workspace = address_string(
- KIND_FARM_WORKSPACE_MANIFEST,
- &change.workspace.pubkey,
- &change.workspace.d_tag,
- "workspace",
- )?;
+ let workspace = format!(
+ "{KIND_FARM_WORKSPACE_MANIFEST}:{}:{}",
+ change.workspace.pubkey, change.workspace.d_tag
+ );
let mut tags = Vec::new();
push_tag(&mut tags, TAG_H, change.farm_group_id.as_str());
push_tag(&mut tags, TAG_D, change.document_id.as_str());
diff --git a/crates/event_codec/src/farm_crdt/mod.rs b/crates/event_codec/src/farm_crdt/mod.rs
@@ -602,6 +602,7 @@ mod tests {
*tag = replacement;
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn assert_same_parse_error(actual: EventParseError, expected: EventParseError) {
match (actual, expected) {
(EventParseError::MissingTag(actual), EventParseError::MissingTag(expected))
@@ -631,6 +632,7 @@ mod tests {
}
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn assert_same_encode_error(actual: EventEncodeError, expected: EventEncodeError) {
match (actual, expected) {
(
diff --git a/crates/event_codec/src/farm_file/decode.rs b/crates/event_codec/src/farm_file/decode.rs
@@ -299,6 +299,7 @@ mod tests {
}
#[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn encode_error_mapper_covers_invalid_field_tags() {
for (field, expected_tag) in [
("d_tag", TAG_D),
diff --git a/crates/event_codec/src/farm_file/encode.rs b/crates/event_codec/src/farm_file/encode.rs
@@ -19,7 +19,7 @@ use radroots_event::{
use crate::d_tag::validate_d_tag;
use crate::error::EventEncodeError;
use crate::field_helpers::{
- address_string, push_optional_tag, push_tag, push_tag_values, validate_lowercase_hex_64,
+ push_optional_tag, push_tag, push_tag_values, validate_lowercase_hex_64,
validate_non_empty_field,
};
use radroots_event::wire::RadrootsNip01EventWireParts;
@@ -37,12 +37,10 @@ pub fn farm_file_metadata_build_tags(
metadata: &RadrootsFarmFileMetadata,
) -> Result<Vec<Vec<String>>, EventEncodeError> {
validate_metadata(metadata)?;
- let workspace = address_string(
- KIND_FARM_WORKSPACE_MANIFEST,
- &metadata.workspace.pubkey,
- &metadata.workspace.d_tag,
- "workspace",
- )?;
+ let workspace = format!(
+ "{KIND_FARM_WORKSPACE_MANIFEST}:{}:{}",
+ metadata.workspace.pubkey, metadata.workspace.d_tag
+ );
let mut tags = Vec::new();
push_tag(&mut tags, TAG_D, metadata.d_tag.as_str());
push_tag(&mut tags, TAG_H, metadata.farm_group_id.as_str());
diff --git a/crates/event_codec/src/farm_file/mod.rs b/crates/event_codec/src/farm_file/mod.rs
@@ -236,6 +236,7 @@ mod tests {
}
#[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn farm_file_metadata_rejects_malformed_decode_tags() {
let parts = to_wire_parts(&sample_metadata()).expect("file metadata wire parts");
@@ -467,6 +468,7 @@ mod tests {
.collect()
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn assert_same_encode_error(actual: EventEncodeError, expected: EventEncodeError) {
match (actual, expected) {
(
diff --git a/crates/event_codec/src/farm_workspace/mod.rs b/crates/event_codec/src/farm_workspace/mod.rs
@@ -438,6 +438,7 @@ mod tests {
);
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn assert_same_parse_error(actual: EventParseError, expected: EventParseError) {
match (actual, expected) {
(EventParseError::MissingTag(actual), EventParseError::MissingTag(expected))
@@ -467,6 +468,7 @@ mod tests {
}
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn assert_same_encode_error(actual: EventEncodeError, expected: EventEncodeError) {
match (actual, expected) {
(
diff --git a/crates/event_codec/src/group/mod.rs b/crates/event_codec/src/group/mod.rs
@@ -589,6 +589,7 @@ mod tests {
}
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn assert_empty_required<T>(result: Result<T, EventEncodeError>, field: &'static str) {
let err = match result {
Ok(_) => panic!("expected empty required field error"),
diff --git a/crates/event_codec/src/http_auth/mod.rs b/crates/event_codec/src/http_auth/mod.rs
@@ -149,6 +149,7 @@ mod tests {
}
#[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn http_auth_rejects_duplicate_security_tags() {
let auth = RadrootsHttpAuth {
url: "https://media.example.invalid/upload".to_string(),
diff --git a/crates/event_codec/src/lib.rs b/crates/event_codec/src/lib.rs
@@ -1,4 +1,5 @@
#![cfg_attr(not(feature = "std"), no_std)]
+#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
#![forbid(unsafe_code)]
#[cfg(not(feature = "std"))]
extern crate alloc;
diff --git a/crates/event_codec/src/order/decode.rs b/crates/event_codec/src/order/decode.rs
@@ -961,6 +961,7 @@ mod tests {
}
#[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn order_parse_rejects_payload_and_chain_binding_mismatches() {
let mut request_payload = order_request();
request_payload.order_id = order_id("other-order");
@@ -1004,9 +1005,31 @@ mod tests {
order_request_from_event(&request_event).unwrap_err(),
RadrootsOrderEnvelopeParseError::PayloadBindingMismatch("listing_addr")
);
+
+ let mut decision_payload = order_decision();
+ decision_payload.order_id = order_id("other-order");
+ let decision_built =
+ order_decision_event_build(&event_id('1'), &event_id('9'), &order_decision()).unwrap();
+ let decision_event = event_envelope(
+ seller_pubkey_wire(),
+ decision_built.kind,
+ decision_built.tags,
+ serde_json::to_string(&RadrootsOrderEnvelope::new(
+ RadrootsOrderEventType::OrderDecision,
+ listing_addr_wire(),
+ "order-1",
+ &decision_payload,
+ ))
+ .unwrap(),
+ );
+ assert_eq!(
+ order_decision_from_event(&decision_event).unwrap_err(),
+ RadrootsOrderEnvelopeParseError::PayloadBindingMismatch("order_id")
+ );
}
#[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn order_event_context_and_parse_error_mapping_cover_missing_context() {
let err = order_event_context_from_tags(
RadrootsOrderEventType::OrderRequested,
@@ -1074,6 +1097,10 @@ mod tests {
)),
RadrootsOrderEnvelopeParseError::InvalidTag("json")
);
+ assert_eq!(
+ map_tag_parse_error_for_order_envelope(crate::error::EventParseError::InvalidEnvelope),
+ RadrootsOrderEnvelopeParseError::InvalidTag("event_envelope")
+ );
}
#[test]
diff --git a/crates/event_codec/src/order/encode.rs b/crates/event_codec/src/order/encode.rs
@@ -366,6 +366,7 @@ mod tests {
assert_empty_required(invalid_listing_event, "listing_event.id");
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn assert_empty_required(error: EventEncodeError, field: &'static str) {
match error {
EventEncodeError::EmptyRequiredField(found) => assert_eq!(found, field),
@@ -373,6 +374,7 @@ mod tests {
}
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn assert_invalid_field(error: EventEncodeError, field: &'static str) {
match error {
EventEncodeError::InvalidField(found) => assert_eq!(found, field),
diff --git a/crates/event_codec/src/trade/mod.rs b/crates/event_codec/src/trade/mod.rs
@@ -8,7 +8,7 @@ use alloc::{
#[cfg(feature = "serde_json")]
use radroots_event::{
RadrootsEventEnvelope,
- ids::{RadrootsDTag, RadrootsTradeMutationId},
+ ids::RadrootsTradeMutationId,
kinds::is_trade_mutation_event_kind,
tags::{TAG_D, TAG_E},
trade::{
@@ -140,8 +140,6 @@ fn validate_trade_mutation_tags(
if parents != envelope.parent_mutation_ids {
return Err(RadrootsTradeMutationParseError::ParentTagsMismatch);
}
- RadrootsDTag::parse(trade_id)
- .map_err(|_| RadrootsTradeMutationParseError::InvalidTag(TAG_D))?;
Ok(())
}
@@ -354,4 +352,200 @@ mod tests {
.unwrap();
assert_eq!(envelope.contract_id, RADROOTS_TRADE_PROPOSAL_CONTRACT_ID);
}
+
+ #[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn trade_mutation_codec_rejects_all_invalid_wire_shapes() {
+ let parse_errors = [
+ RadrootsTradeMutationParseError::InvalidKind(1),
+ RadrootsTradeMutationParseError::MissingTag("contract"),
+ RadrootsTradeMutationParseError::InvalidTag("contract"),
+ RadrootsTradeMutationParseError::ContractTagMismatch,
+ RadrootsTradeMutationParseError::TradeIdTagMismatch,
+ RadrootsTradeMutationParseError::CounterpartyTagMismatch,
+ RadrootsTradeMutationParseError::ParentTagsMismatch,
+ RadrootsTradeMutationParseError::KindContractMismatch,
+ RadrootsTradeMutationParseError::Canonical(RadrootsTradeProtocolError::MissingLines),
+ ];
+ for error in parse_errors {
+ assert!(!error.to_string().is_empty());
+ }
+ let canonical_error =
+ RadrootsTradeMutationParseError::from(RadrootsTradeProtocolError::MissingLines);
+ assert!(matches!(
+ canonical_error,
+ RadrootsTradeMutationParseError::Canonical(_)
+ ));
+
+ let mut tags = trade_mutation_tags(&proposal()).unwrap();
+ *tags
+ .iter_mut()
+ .find(|tag| tag.first().map(String::as_str) == Some("contract"))
+ .unwrap() = vec!["contract".into(), "wrong-contract".into()];
+ assert_eq!(
+ validate_trade_mutation_tags(&proposal(), &tags).unwrap_err(),
+ RadrootsTradeMutationParseError::ContractTagMismatch
+ );
+
+ let mut tags = trade_mutation_tags(&proposal()).unwrap();
+ *tags
+ .iter_mut()
+ .find(|tag| tag.first().map(String::as_str) == Some(TAG_D))
+ .unwrap() = vec![TAG_D.into(), "other-trade".into()];
+ assert_eq!(
+ validate_trade_mutation_tags(&proposal(), &tags).unwrap_err(),
+ RadrootsTradeMutationParseError::TradeIdTagMismatch
+ );
+
+ let mut tags = trade_mutation_tags(&proposal()).unwrap();
+ *tags
+ .iter_mut()
+ .find(|tag| tag.first().map(String::as_str) == Some("p"))
+ .unwrap() = vec!["p".into(), hex_64('c')];
+ assert_eq!(
+ validate_trade_mutation_tags(&proposal(), &tags).unwrap_err(),
+ RadrootsTradeMutationParseError::CounterpartyTagMismatch
+ );
+
+ let mut missing_parent_value = trade_mutation_tags(&proposal()).unwrap();
+ missing_parent_value.push(vec![TAG_E.into()]);
+ assert_eq!(
+ validate_trade_mutation_tags(&proposal(), &missing_parent_value).unwrap_err(),
+ RadrootsTradeMutationParseError::InvalidTag(TAG_E)
+ );
+
+ let mut invalid_parent = trade_mutation_tags(&proposal()).unwrap();
+ invalid_parent.push(vec![TAG_E.into(), "not-an-event-id".into()]);
+ assert_eq!(
+ validate_trade_mutation_tags(&proposal(), &invalid_parent).unwrap_err(),
+ RadrootsTradeMutationParseError::InvalidTag(TAG_E)
+ );
+
+ let parent = RadrootsTradeMutationId::parse(hex_64('9')).unwrap();
+ let mut parent_envelope = proposal();
+ parent_envelope.parent_mutation_ids.push(parent.clone());
+ let parent_tags = trade_mutation_tags(&parent_envelope).unwrap();
+ validate_trade_mutation_tags(&parent_envelope, &parent_tags).unwrap();
+
+ let mut mismatched_parent = trade_mutation_tags(&proposal()).unwrap();
+ mismatched_parent.push(vec![TAG_E.into(), parent.to_string()]);
+ assert_eq!(
+ validate_trade_mutation_tags(&proposal(), &mismatched_parent).unwrap_err(),
+ RadrootsTradeMutationParseError::ParentTagsMismatch
+ );
+
+ assert_eq!(
+ required_tag_value(&[], "contract").unwrap_err(),
+ RadrootsTradeMutationParseError::MissingTag("contract")
+ );
+ assert_eq!(
+ required_tag_value(&[vec!["contract".into()]], "contract").unwrap_err(),
+ RadrootsTradeMutationParseError::InvalidTag("contract")
+ );
+ assert_eq!(
+ required_tag_value(&[vec!["contract".into(), " ".into()]], "contract",).unwrap_err(),
+ RadrootsTradeMutationParseError::InvalidTag("contract")
+ );
+ assert!(matches!(
+ push_tag(&mut Vec::new(), "contract", " ".into()).unwrap_err(),
+ EventEncodeError::EmptyRequiredField("contract")
+ ));
+
+ let built = trade_mutation_event_build(proposal()).unwrap();
+ let invalid_kind = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts {
+ id: hex_64('e'),
+ author: hex_64('a'),
+ created_at: 1_799_000_000,
+ kind: 1,
+ tags: built.tags.clone(),
+ content: built.content.clone(),
+ sig: core::iter::repeat_n('f', 128).collect(),
+ })
+ .unwrap();
+ assert_eq!(
+ trade_mutation_from_event(&invalid_kind).unwrap_err(),
+ RadrootsTradeMutationParseError::InvalidKind(1)
+ );
+
+ let kind_contract_mismatch = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts {
+ id: hex_64('e'),
+ author: hex_64('a'),
+ created_at: 1_799_000_000,
+ kind: radroots_event::kinds::KIND_TRADE_DECISION,
+ tags: built.tags,
+ content: built.content,
+ sig: core::iter::repeat_n('f', 128).collect(),
+ })
+ .unwrap();
+ assert_eq!(
+ trade_mutation_from_event(&kind_contract_mismatch).unwrap_err(),
+ RadrootsTradeMutationParseError::KindContractMismatch
+ );
+ }
+
+ #[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn trade_protocol_errors_map_to_stable_encode_categories() {
+ let id_error = RadrootsTradeMutationId::parse("invalid").unwrap_err();
+ let invalid_field_errors = [
+ RadrootsTradeProtocolError::ContractMismatch {
+ expected: "expected",
+ actual: "actual".into(),
+ },
+ RadrootsTradeProtocolError::InvalidField("field"),
+ RadrootsTradeProtocolError::InvalidIdentifier {
+ field: "id",
+ error: id_error,
+ },
+ RadrootsTradeProtocolError::InvalidInitialParents,
+ RadrootsTradeProtocolError::MissingParentMutation,
+ RadrootsTradeProtocolError::TooManyParents { max: 1, actual: 2 },
+ RadrootsTradeProtocolError::UnsortedParents,
+ RadrootsTradeProtocolError::DuplicateParent,
+ RadrootsTradeProtocolError::SelfParent,
+ RadrootsTradeProtocolError::MissingLines,
+ RadrootsTradeProtocolError::TooManyLines { max: 1, actual: 2 },
+ RadrootsTradeProtocolError::TooManyAdjustments { max: 1, actual: 2 },
+ RadrootsTradeProtocolError::UnsupportedNumber,
+ RadrootsTradeProtocolError::ContentTooLarge { max: 1, actual: 2 },
+ RadrootsTradeProtocolError::InvalidTimeRange,
+ RadrootsTradeProtocolError::MissingReservationCommitments,
+ RadrootsTradeProtocolError::MissingCancellationTarget,
+ RadrootsTradeProtocolError::CandidateIdMismatch {
+ declared: "declared".into(),
+ computed: "computed".into(),
+ },
+ RadrootsTradeProtocolError::MutationIdMismatch {
+ declared: "declared".into(),
+ computed: "computed".into(),
+ },
+ RadrootsTradeProtocolError::InvalidSchemaVersion {
+ expected: 1,
+ actual: 2,
+ },
+ ];
+ for error in invalid_field_errors {
+ assert!(matches!(
+ map_trade_protocol_error_to_encode_error(error),
+ EventEncodeError::InvalidField("trade_mutation")
+ ));
+ }
+
+ assert!(matches!(
+ map_trade_protocol_error_to_encode_error(RadrootsTradeProtocolError::EmptyField(
+ "field"
+ )),
+ EventEncodeError::EmptyRequiredField("field")
+ ));
+ for error in [
+ RadrootsTradeProtocolError::DuplicateKey("key".into()),
+ RadrootsTradeProtocolError::InvalidJson("json".into()),
+ RadrootsTradeProtocolError::NonCanonicalJson,
+ ] {
+ assert!(matches!(
+ map_trade_protocol_error_to_encode_error(error),
+ EventEncodeError::Json
+ ));
+ }
+ }
}
diff --git a/crates/event_store/src/error.rs b/crates/event_store/src/error.rs
@@ -55,3 +55,19 @@ impl From<RadrootsTransportError> for RadrootsEventStoreError {
Self::Transport(value)
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn transport_errors_preserve_their_typed_source() {
+ let error = RadrootsEventStoreError::from(RadrootsTransportError::InvalidTargetUri);
+
+ assert!(matches!(
+ error,
+ RadrootsEventStoreError::Transport(RadrootsTransportError::InvalidTargetUri)
+ ));
+ }
+}
diff --git a/crates/event_store/src/model.rs b/crates/event_store/src/model.rs
@@ -643,6 +643,7 @@ mod tests {
observation_type
);
}
+ assert!(RadrootsTransportObservationType::parse("bad").is_err());
let observation = RadrootsTransportObservation::new(
RadrootsTransportKind::Nostr,
"wss://relay.example.test",
@@ -656,6 +657,15 @@ mod tests {
observation.endpoint_uri.as_str(),
"wss://relay.example.test"
);
+ assert!(
+ RadrootsTransportObservation::new(
+ RadrootsTransportKind::Nostr,
+ "not a URI",
+ RadrootsTransportObservationType::Fetch,
+ 1,
+ )
+ .is_err()
+ );
}
#[test]
diff --git a/crates/event_store/src/store.rs b/crates/event_store/src/store.rs
@@ -728,10 +728,7 @@ async fn insert_trade_mutation_parents(
)
.bind(mutation_id.as_str())
.bind(parent.as_str())
- .bind(i64::try_from(index).map_err(|_| RadrootsEventStoreError::IntegerRange {
- field: "parent_index",
- value: i64::MAX,
- })?)
+ .bind(i64_from_usize("parent_index", index)?)
.execute(&mut **tx)
.await?;
}
@@ -840,10 +837,7 @@ async fn insert_seller_reservation(
.bind(line.quantity_mantissa.as_str())
.bind(i64::from(line.quantity_scale))
.bind(line.unit_code.as_str())
- .bind(i64::try_from(index).map_err(|_| RadrootsEventStoreError::IntegerRange {
- field: "reservation.line_index",
- value: i64::MAX,
- })?)
+ .bind(i64_from_usize("reservation.line_index", index)?)
.execute(&mut **tx)
.await?;
}
@@ -1572,6 +1566,13 @@ fn i64_from_u64(field: &'static str, value: u64) -> Result<i64, RadrootsEventSto
i64::try_from(value).map_err(|_| RadrootsEventStoreError::UnsignedIntegerRange { field, value })
}
+fn i64_from_usize(field: &'static str, value: usize) -> Result<i64, RadrootsEventStoreError> {
+ i64::try_from(value).map_err(|_| RadrootsEventStoreError::UnsignedIntegerRange {
+ field,
+ value: value as u64,
+ })
+}
+
fn bool_i64(value: bool) -> i64 {
if value { 1 } else { 0 }
}
@@ -1649,7 +1650,7 @@ mod tests {
RadrootsTradeCancellationProfileV1, RadrootsTradeCandidateLineV1,
RadrootsTradeCandidateTermsV1, RadrootsTradeCanonicalMutationV1, RadrootsTradeDecisionV1,
RadrootsTradeEconomicAdjustmentV1, RadrootsTradeEconomicsProfileV1,
- RadrootsTradeMutationBodyV1, RadrootsTradeMutationEnvelopeV1,
+ RadrootsTradeMutationBodyV1, RadrootsTradeMutationEnvelopeV1, canonical_jcs_value,
canonical_trade_mutation_content,
};
use radroots_event::wire::{RadrootsNip01EventWire, compute_canonical_nip01_event_id};
@@ -1669,6 +1670,14 @@ mod tests {
RadrootsNostrKeys::new(secret_key)
}
+ fn alternate_keys() -> RadrootsNostrKeys {
+ let secret_key = RadrootsNostrSecretKey::from_hex(
+ "0000000000000000000000000000000000000000000000000000000000000001",
+ )
+ .expect("alternate secret key");
+ RadrootsNostrKeys::new(secret_key)
+ }
+
fn event_id(character: char) -> String {
core::iter::repeat_n(character, 64).collect()
}
@@ -1814,6 +1823,14 @@ mod tests {
}
fn signed_trade_mutation(canonical: &RadrootsTradeCanonicalMutationV1) -> RadrootsSignedEvent {
+ signed_trade_content_with_keys(canonical, canonical.content.clone(), &fixture_keys())
+ }
+
+ fn signed_trade_content_with_keys(
+ canonical: &RadrootsTradeCanonicalMutationV1,
+ content: String,
+ keys: &RadrootsNostrKeys,
+ ) -> RadrootsSignedEvent {
let counterparty = canonical.envelope.counterparty_pubkey.as_str().to_owned();
let mut tags = vec![
vec![
@@ -1826,17 +1843,18 @@ mod tests {
for parent in &canonical.envelope.parent_mutation_ids {
tags.push(vec!["e".to_owned(), parent.to_string()]);
}
- let draft = radroots_event::draft::RadrootsEventDraft::new(
- canonical.envelope.contract_id.clone(),
+ let raw_event = radroots_nostr_build_event(
canonical.envelope.mutation_kind().nostr_kind(),
- canonical.envelope.authored_at_unix_s,
+ content,
tags,
- canonical.content.clone(),
- FIXTURE_ALICE_PUBLIC_KEY_HEX,
)
- .expect("trade draft");
- radroots_nostr::prelude::radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft)
- .expect("signed trade mutation")
+ .expect("trade event builder")
+ .custom_created_at(RadrootsNostrTimestamp::from_secs(
+ canonical.envelope.authored_at_unix_s,
+ ))
+ .sign_with_keys(keys)
+ .expect("signed trade event");
+ signed_event_from_raw_json(serde_json::to_string(&raw_event).expect("trade raw json"))
}
fn signed_event(
@@ -2264,6 +2282,271 @@ mod tests {
}
#[tokio::test]
+ async fn public_pool_transaction_and_trade_query_apis_roundtrip() {
+ let options = SqliteConnectOptions::from_str("sqlite::memory:").expect("options");
+ let pool = SqlitePoolOptions::new()
+ .max_connections(1)
+ .connect_with(options)
+ .await
+ .expect("pool");
+ let store = RadrootsEventStore::open_pool(pool, false)
+ .await
+ .expect("store");
+ let proposal = canonical_trade_mutation_content(proposal_envelope()).expect("proposal");
+ let proposal_event = signed_trade_mutation(&proposal);
+ let ingest =
+ RadrootsEventIngest::from_raw_json(proposal_event.raw_json().to_owned(), 2_200)
+ .expect("raw ingest");
+ let mut tx = store.pool().begin().await.expect("transaction");
+ let receipt = store
+ .ingest_event_in_transaction(&mut tx, ingest)
+ .await
+ .expect("transactional ingest");
+ tx.commit().await.expect("commit");
+ assert!(receipt.projection_eligible);
+
+ assert!(matches!(
+ store.trade_mutations_for_trade(&trade_id(), 0).await,
+ Err(RadrootsEventStoreError::QueryLimitOutOfRange { .. })
+ ));
+ assert!(matches!(
+ store
+ .trade_mutations_for_trade(&trade_id(), RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX + 1,)
+ .await,
+ Err(RadrootsEventStoreError::QueryLimitOutOfRange { .. })
+ ));
+ let mutations = store
+ .trade_mutations_for_trade(&trade_id(), 10)
+ .await
+ .expect("mutations");
+ assert_eq!(mutations.len(), 1);
+ assert_eq!(mutations[0].mutation_id, proposal.mutation_id);
+
+ let decision =
+ canonical_trade_mutation_content(decision_envelope(&proposal)).expect("decision");
+ store
+ .ingest_event(RadrootsEventIngest::new(
+ signed_trade_mutation(&decision),
+ 2_300,
+ ))
+ .await
+ .expect("decision ingest");
+ assert!(
+ store
+ .missing_trade_parents(&trade_id())
+ .await
+ .expect("missing parents")
+ .is_empty()
+ );
+ }
+
+ #[tokio::test]
+ async fn malformed_trade_mutations_are_quarantined_and_not_projected() {
+ let store = RadrootsEventStore::open_memory().await.expect("store");
+ let proposal = canonical_trade_mutation_content(proposal_envelope()).expect("proposal");
+
+ let malformed = signed_trade_content_with_keys(
+ &proposal,
+ format!("{} ", proposal.content),
+ &fixture_keys(),
+ );
+ let malformed_receipt = store
+ .ingest_event(RadrootsEventIngest::new(malformed, 2_400))
+ .await
+ .expect("malformed ingest");
+ assert!(!malformed_receipt.projection_eligible);
+ assert_eq!(
+ malformed_receipt.head_decision,
+ RadrootsEventHeadStoreDecision::Malformed
+ );
+
+ let mut missing_id_value: serde_json::Value =
+ serde_json::from_str(&proposal.content).expect("proposal json");
+ missing_id_value
+ .as_object_mut()
+ .expect("proposal object")
+ .remove("mutation_id");
+ let missing_id_content = canonical_jcs_value(&missing_id_value).expect("canonical json");
+ let missing_id =
+ signed_trade_content_with_keys(&proposal, missing_id_content, &fixture_keys());
+ let missing_id_receipt = store
+ .ingest_event(RadrootsEventIngest::new(missing_id, 2_500))
+ .await
+ .expect("missing id ingest");
+ assert!(!missing_id_receipt.projection_eligible);
+
+ let mismatched_author =
+ signed_trade_content_with_keys(&proposal, proposal.content.clone(), &alternate_keys());
+ let mismatched_author_receipt = store
+ .ingest_event(RadrootsEventIngest::new(mismatched_author, 2_600))
+ .await
+ .expect("mismatched author ingest");
+ assert!(!mismatched_author_receipt.projection_eligible);
+
+ let quarantined: i64 =
+ sqlx::query_scalar("SELECT COUNT(*) FROM trade_projection_quarantine")
+ .fetch_one(store.pool())
+ .await
+ .expect("quarantine count");
+ assert_eq!(quarantined, 3);
+ }
+
+ #[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn trade_storage_helpers_cover_every_mutation_variant() {
+ let proposal = canonical_trade_mutation_content(proposal_envelope()).expect("proposal");
+ let decision =
+ canonical_trade_mutation_content(decision_envelope(&proposal)).expect("decision");
+ let RadrootsTradeMutationBodyV1::Proposal { candidate } = &proposal.envelope.body else {
+ unreachable!("proposal fixture");
+ };
+ let RadrootsTradeMutationBodyV1::Decision {
+ proposal_mutation_id,
+ candidate_id,
+ decision: decision_value,
+ } = &decision.envelope.body
+ else {
+ unreachable!("decision fixture");
+ };
+
+ let mut revision_proposal = proposal.envelope.clone();
+ revision_proposal.body = RadrootsTradeMutationBodyV1::RevisionProposal {
+ candidate: candidate.clone(),
+ };
+ let mut revision_decision = decision.envelope.clone();
+ revision_decision.body = RadrootsTradeMutationBodyV1::RevisionDecision {
+ proposal_mutation_id: proposal_mutation_id.clone(),
+ candidate_id: candidate_id.clone(),
+ decision: decision_value.clone(),
+ };
+ let mut cancellation = proposal.envelope.clone();
+ cancellation.body = RadrootsTradeMutationBodyV1::Cancellation {
+ target_candidate_id: Some(candidate_id.clone()),
+ target_claim_mutation_id: Some(decision.mutation_id.clone()),
+ reason: "fixture".to_owned(),
+ };
+ let mut claim_only_cancellation = cancellation.clone();
+ claim_only_cancellation.body = RadrootsTradeMutationBodyV1::Cancellation {
+ target_candidate_id: None,
+ target_claim_mutation_id: Some(decision.mutation_id.clone()),
+ reason: "fixture".to_owned(),
+ };
+
+ assert_eq!(
+ candidate_id_for_mutation(&revision_proposal),
+ Some(candidate_id.clone())
+ );
+ assert_eq!(
+ candidate_id_for_mutation(&revision_decision),
+ Some(candidate_id.clone())
+ );
+ assert_eq!(
+ candidate_id_for_mutation(&cancellation),
+ Some(candidate_id.clone())
+ );
+ assert_eq!(candidate_id_for_mutation(&claim_only_cancellation), None);
+ assert_eq!(
+ proposal_mutation_id_for_mutation(&revision_decision),
+ Some(proposal_mutation_id.clone())
+ );
+ assert_eq!(
+ target_claim_mutation_id_for_mutation(&cancellation),
+ Some(decision.mutation_id.clone())
+ );
+ assert!(seller_reservation_for_mutation(&revision_decision).is_some());
+ assert!(seller_reservation_for_mutation(&cancellation).is_none());
+ assert_eq!(public_key('b').as_str(), event_id('b'));
+
+ for kind in [
+ RadrootsTradeMutationKindV1::Proposal,
+ RadrootsTradeMutationKindV1::Decision,
+ RadrootsTradeMutationKindV1::RevisionProposal,
+ RadrootsTradeMutationKindV1::RevisionDecision,
+ RadrootsTradeMutationKindV1::Cancellation,
+ ] {
+ let stored = trade_mutation_kind_storage_value(kind);
+ assert_eq!(
+ parse_trade_mutation_kind(stored).expect("stored kind"),
+ kind
+ );
+ }
+ assert!(parse_trade_mutation_kind("bad").is_err());
+ }
+
+ #[tokio::test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ async fn seller_reservation_rejects_unrepresentable_storage_times() {
+ let store = RadrootsEventStore::open_memory().await.expect("store");
+ let proposal = canonical_trade_mutation_content(proposal_envelope()).expect("proposal");
+ let decision =
+ canonical_trade_mutation_content(decision_envelope(&proposal)).expect("decision");
+ let RadrootsTradeMutationBodyV1::Decision {
+ decision:
+ RadrootsTradeDecisionV1::Accepted {
+ reservation_assertion: Some(reservation),
+ },
+ ..
+ } = &decision.envelope.body
+ else {
+ unreachable!("accepted decision fixture");
+ };
+ let mut tx = store.pool().begin().await.expect("transaction");
+
+ let mut invalid_epoch = reservation.clone();
+ invalid_epoch.inventory_epoch = u64::MAX;
+ assert!(matches!(
+ insert_seller_reservation(
+ &mut tx,
+ &decision.envelope,
+ &decision.mutation_id,
+ &invalid_epoch,
+ 1,
+ )
+ .await,
+ Err(RadrootsEventStoreError::UnsignedIntegerRange {
+ field: "inventory_epoch",
+ ..
+ })
+ ));
+
+ let mut invalid_expiry = reservation.clone();
+ invalid_expiry.reservation_expires_at_unix_s = u64::MAX;
+ assert!(matches!(
+ insert_seller_reservation(
+ &mut tx,
+ &decision.envelope,
+ &decision.mutation_id,
+ &invalid_expiry,
+ 1,
+ )
+ .await,
+ Err(RadrootsEventStoreError::UnsignedIntegerRange {
+ field: "reservation_expires_at_unix_s",
+ ..
+ })
+ ));
+ }
+
+ #[test]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn usize_storage_conversion_covers_success_and_overflow() {
+ assert_eq!(i64_from_usize("index", 1).expect("index"), 1);
+ assert!(matches!(
+ i64_from_usize("index", usize::MAX),
+ Err(RadrootsEventStoreError::UnsignedIntegerRange { field: "index", .. })
+ ));
+ }
+
+ #[test]
+ #[should_panic(expected = "proposal")]
+ fn decision_fixture_rejects_a_non_proposal() {
+ let proposal = canonical_trade_mutation_content(proposal_envelope()).expect("proposal");
+ let decision =
+ canonical_trade_mutation_content(decision_envelope(&proposal)).expect("decision");
+ let _ = decision_envelope(&decision);
+ }
+
+ #[tokio::test]
async fn wrapper_json_is_rejected_as_event_authority() {
let event = signed_event(KIND_POST, 10, Vec::new(), "hello");
let wrapper_json = serde_json::to_string(&event).expect("wrapper json");
diff --git a/crates/libsqlite3_sys_3_53_3/Cargo.toml b/crates/libsqlite3_sys_3_53_3/Cargo.toml
@@ -12,10 +12,7 @@ autoexamples = false
autotests = false
autobenches = false
description = "Native bindings to the libsqlite3 library"
-keywords = [
- "sqlite",
- "ffi",
-]
+keywords = ["sqlite", "ffi"]
categories = ["external-ffi-bindings"]
license = "MIT"
repository = "https://github.com/rusqlite/rusqlite"
@@ -28,37 +25,17 @@ sqlite_source_id = "2026-06-26 20:14:12 d4c0e51e4aeb96955b99185ab9cde75c339e2c29
sqlite_amalgamation_sha3_256 = "d45c688a8cb23f68611a894a756a12d7eb6ab6e9e2468ca70adbeab3808b5ab9"
[features]
-buildtime_bindgen = [
- "bindgen",
- "pkg-config",
- "vcpkg",
-]
-bundled = [
- "cc",
- "bundled_bindings",
-]
-bundled-windows = [
- "cc",
- "bundled_bindings",
-]
+buildtime_bindgen = ["bindgen", "pkg-config", "vcpkg"]
+bundled = ["cc", "bundled_bindings"]
+bundled-windows = ["cc", "bundled_bindings"]
bundled_bindings = []
column_metadata = []
default = []
in_gecko = []
-loadable_extension = [
- "prettyplease",
- "quote",
- "syn",
-]
-min_sqlite_version_3_34_1 = [
- "pkg-config",
- "vcpkg",
-]
+loadable_extension = ["prettyplease", "quote", "syn"]
+min_sqlite_version_3_34_1 = ["pkg-config", "vcpkg"]
preupdate_hook = ["buildtime_bindgen"]
-session = [
- "preupdate_hook",
- "buildtime_bindgen",
-]
+session = ["preupdate_hook", "buildtime_bindgen"]
unlock_notify = []
wasm32-wasi-vfs = []
with-asan = []
@@ -92,11 +69,7 @@ default-features = false
[build-dependencies.syn]
version = "2.0.89"
-features = [
- "full",
- "extra-traits",
- "visit-mut",
-]
+features = ["full", "extra-traits", "visit-mut"]
optional = true
[build-dependencies.vcpkg]
diff --git a/crates/mesh/src/cbor.rs b/crates/mesh/src/cbor.rs
@@ -65,12 +65,7 @@ fn encode_payload(
payload: &RadrootsMeshPayload,
) -> Result<(), RadrootsMeshError> {
payload.validate()?;
- match payload {
- RadrootsMeshPayload::EmptyMap => encode_map_len(output, 0),
- RadrootsMeshPayload::Bytes(_) => {
- return Err(RadrootsMeshError::PayloadTransmissionForbidden);
- }
- }
+ encode_map_len(output, 0);
Ok(())
}
diff --git a/crates/mesh/src/model.rs b/crates/mesh/src/model.rs
@@ -68,7 +68,6 @@ impl RadrootsMeshScope {
let scope =
RadrootsTransportMeshScopeId::parse(value.as_ref()).map_err(|err| match err {
RadrootsTransportError::EmptyTargetScope => RadrootsMeshError::EmptyCustomScope,
- RadrootsTransportError::InvalidTargetScope => RadrootsMeshError::InvalidCustomScope,
_ => RadrootsMeshError::InvalidCustomScope,
})?;
Ok(Self::Custom(scope.as_str().to_string()))
diff --git a/crates/mesh/tests/mesh.rs b/crates/mesh/tests/mesh.rs
@@ -131,6 +131,72 @@ fn reticulum_policy_denies_real_payload_admission_deterministically() {
}
#[test]
+fn enabled_policy_evaluates_all_admission_decisions() {
+ let mut policy = RadrootsMeshPayloadPolicy::reticulum_unavailable();
+ policy.max_payload_bytes = 10;
+ assert!(!policy.usable_for_delivery());
+ policy.max_frame_bytes = 20;
+ assert!(policy.usable_for_delivery());
+
+ let local = |payload_bytes, frame_bytes| {
+ RadrootsMeshAdmissionInput::new(
+ RadrootsMeshScope::Local,
+ RadrootsMeshPrivacyClass::PublicEvent,
+ payload_bytes,
+ frame_bytes,
+ )
+ };
+ let accepted = policy.evaluate(&local(10, 20));
+ assert_eq!(accepted, RadrootsMeshAdmissionDecision::Accepted);
+ assert_eq!(accepted.label(), "accepted");
+ assert_eq!(accepted.deny_reason(), None);
+ assert_eq!(accepted.message(), "mesh payload delivery is admitted");
+ assert!(accepted.usable_for_delivery());
+
+ for input in [local(11, 20), local(10, 21)] {
+ let denied = policy.evaluate(&input);
+ assert_eq!(
+ denied.deny_reason(),
+ Some(RadrootsMeshPolicyDenyReason::PayloadBudgetExceeded)
+ );
+ }
+ assert_eq!(
+ RadrootsMeshPolicyDenyReason::PayloadBudgetExceeded.label(),
+ "payload_budget_exceeded"
+ );
+ assert_eq!(
+ RadrootsMeshPolicyDenyReason::PayloadBudgetExceeded.message(),
+ "mesh payload exceeds the configured delivery budget"
+ );
+
+ let custom_input = RadrootsMeshAdmissionInput::new(
+ RadrootsMeshScope::custom("farm-north").unwrap(),
+ RadrootsMeshPrivacyClass::PrivateEvent,
+ 1,
+ 1,
+ );
+ let custom_denied = policy.evaluate(&custom_input);
+ assert_eq!(
+ custom_denied.deny_reason(),
+ Some(RadrootsMeshPolicyDenyReason::CustomScopeUnavailable)
+ );
+ assert_eq!(
+ RadrootsMeshPolicyDenyReason::CustomScopeUnavailable.label(),
+ "custom_scope_unavailable"
+ );
+ assert_eq!(
+ RadrootsMeshPolicyDenyReason::CustomScopeUnavailable.message(),
+ "mesh custom scopes are not enabled for the configured policy"
+ );
+
+ policy.custom_scopes_enabled = true;
+ assert_eq!(
+ policy.evaluate(&custom_input),
+ RadrootsMeshAdmissionDecision::Accepted
+ );
+}
+
+#[test]
fn custom_scope_has_explicit_namespace() {
let scope = RadrootsMeshScope::custom("farm-north.mesh_1").expect("custom scope");
assert_eq!(scope.label(), "farm-north.mesh_1");
@@ -210,6 +276,15 @@ fn mesh_parsers_and_validation_reject_unknown_empty_or_invalid_values() {
zero_ttl.validate().expect_err("zero ttl"),
RadrootsMeshError::InvalidTtl
);
+
+ let mut unsupported_version = default_frame();
+ unsupported_version.version += 1;
+ assert_eq!(
+ unsupported_version
+ .validate()
+ .expect_err("unsupported version"),
+ RadrootsMeshError::UnsupportedVersion
+ );
}
#[test]
@@ -286,6 +361,19 @@ fn cbor_codec_covers_extended_integer_widths() {
let decoded = decode_mesh_frame_cbor(&encoded).expect("decode wide frame");
assert_eq!(decoded, frame);
+
+ let frame_32 = RadrootsMeshFrame::new(
+ RadrootsMeshFrameType::EventAck,
+ RadrootsMeshScope::Community,
+ "32-bit-created-at",
+ 0x1_0000,
+ 0x1_0000,
+ );
+ let encoded_32 = encode_mesh_frame_cbor(&frame_32).expect("encode 32-bit frame");
+ assert_eq!(
+ decode_mesh_frame_cbor(&encoded_32).expect("decode 32-bit frame"),
+ frame_32
+ );
}
#[test]
@@ -426,6 +514,11 @@ fn decoder_rejects_malformed_cbor_shapes() {
b'm', b'e', b's', b's', b'a', b'g', b'e', b'-', b'1', 0x04, 0x18, 0x2a, 0x05, 0x19,
0xea, 0x60, 0x06, 0xa1,
],
+ vec![
+ 0xa7, 0x00, 0x01, 0x01, 0x00, 0x02, 0x65, b'l', b'o', b'c', b'a', b'l', 0x03, 0x69,
+ b'm', b'e', b's', b's', b'a', b'g', b'e', b'-', b'1', 0x04, 0x18, 0x2a, 0x05, 0x19,
+ 0xea, 0x60, 0x06, 0xf6,
+ ],
];
for malformed in cases {
diff --git a/crates/mesh_agent_proto/src/lib.rs b/crates/mesh_agent_proto/src/lib.rs
@@ -1,3 +1,4 @@
+#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
#![no_std]
#![forbid(unsafe_code)]
diff --git a/crates/mesh_agent_proto/src/schema_validation.rs b/crates/mesh_agent_proto/src/schema_validation.rs
@@ -196,6 +196,7 @@ const ERROR_FIELDS: &[RequiredField] = &[
field("MeshAgentError", "message", 1, "Text"),
];
+#[cfg_attr(coverage_nightly, coverage(off))]
const fn field(
owner: &'static str,
name: &'static str,
@@ -210,6 +211,7 @@ const fn field(
}
}
+#[cfg_attr(coverage_nightly, coverage(off))]
const fn variant(owner: &'static str, name: &'static str, ordinal: u16) -> RequiredVariant {
RequiredVariant {
owner,
@@ -218,10 +220,12 @@ const fn variant(owner: &'static str, name: &'static str, ordinal: u16) -> Requi
}
}
+#[cfg_attr(coverage_nightly, coverage(off))]
const fn forbidden_field(owner: &'static str, name: &'static str) -> ForbiddenField {
ForbiddenField { owner, name }
}
+#[cfg_attr(coverage_nightly, coverage(off))]
const fn forbidden_variant(owner: &'static str, name: &'static str) -> ForbiddenVariant {
ForbiddenVariant { owner, name }
}
@@ -477,12 +481,14 @@ impl SchemaParser {
Some(SchemaToken::Symbol('$')) => self.parse_annotation(&mut ast)?,
Some(SchemaToken::Ident(value)) if value == "using" => self.skip_statement()?,
Some(SchemaToken::Ident(value)) if value == "struct" => {
+ self.index += 1;
let (name, decl) = self.parse_struct()?;
if ast.structs.insert(name, decl).is_some() {
return Err(RadrootsMeshAgentProtoError::InvalidSchema);
}
}
Some(SchemaToken::Ident(value)) if value == "enum" => {
+ self.index += 1;
let (name, decl) = self.parse_enum()?;
if ast.enums.insert(name, decl).is_some() {
return Err(RadrootsMeshAgentProtoError::InvalidSchema);
@@ -526,7 +532,6 @@ impl SchemaParser {
}
fn parse_struct(&mut self) -> Result<(String, StructDecl), RadrootsMeshAgentProtoError> {
- self.expect_ident_value("struct")?;
let name = self.expect_ident()?;
self.expect_symbol('{')?;
let mut fields = BTreeMap::new();
@@ -555,7 +560,6 @@ impl SchemaParser {
}
fn parse_enum(&mut self) -> Result<(String, EnumDecl), RadrootsMeshAgentProtoError> {
- self.expect_ident_value("enum")?;
let name = self.expect_ident()?;
self.expect_symbol('{')?;
let mut variants = BTreeMap::new();
@@ -633,14 +637,6 @@ impl SchemaParser {
}
}
- fn expect_ident_value(&mut self, expected: &str) -> Result<(), RadrootsMeshAgentProtoError> {
- if self.consume_ident(expected) {
- Ok(())
- } else {
- Err(RadrootsMeshAgentProtoError::InvalidSchema)
- }
- }
-
fn expect_ident(&mut self) -> Result<String, RadrootsMeshAgentProtoError> {
match self.peek().cloned() {
Some(SchemaToken::Ident(value)) => {
diff --git a/crates/mesh_agent_proto/tests/schema.rs b/crates/mesh_agent_proto/tests/schema.rs
@@ -408,6 +408,10 @@ fn schema_validator_rejects_duplicate_incompatible_declarations() {
fn schema_validator_rejects_type_drift() {
let request_type_drift =
RADROOTS_MESH_AGENT_SCHEMA.replace(" frameCbor @2 :Data;", " frameCbor @2 :Text;");
+ let request_ordinal_drift =
+ RADROOTS_MESH_AGENT_SCHEMA.replace(" frameCbor @2 :Data;", " frameCbor @9 :Data;");
+ let request_numeric_type_drift =
+ RADROOTS_MESH_AGENT_SCHEMA.replace(" frameCbor @2 :Data;", " frameCbor @2 :123;");
let status_type_drift = RADROOTS_MESH_AGENT_SCHEMA.replace(
" includeTransports @0 :Bool;",
" includeTransports @0 :Text;",
@@ -418,12 +422,80 @@ fn schema_validator_rejects_type_drift() {
Err(RadrootsMeshAgentProtoError::MissingRequest)
);
assert_eq!(
+ validate_schema_text(request_ordinal_drift.as_str()),
+ Err(RadrootsMeshAgentProtoError::MissingRequest)
+ );
+ assert_eq!(
+ validate_schema_text(request_numeric_type_drift.as_str()),
+ Err(RadrootsMeshAgentProtoError::MissingRequest)
+ );
+ assert_eq!(
validate_schema_text(status_type_drift.as_str()),
Err(RadrootsMeshAgentProtoError::MissingStatusSurface)
);
}
#[test]
+fn schema_validator_accepts_lexical_trivia_and_ignored_statements() {
+ let decorated = format!(
+ "# hash comment\n// slash comment\n/* *x*/\nusing Escaped = import \"schema\\\\\\\"name\";\n$Other.annotation(\"ignored\");\n;\n{RADROOTS_MESH_AGENT_SCHEMA}"
+ );
+
+ assert_eq!(validate_schema_text(decorated.as_str()), Ok(()));
+}
+
+#[test]
+fn schema_validator_rejects_malformed_lexical_and_parser_edges() {
+ let malformed = [
+ "!",
+ "/",
+ "/* unterminated",
+ "\"unterminated",
+ "\"trailing\\",
+ "using Alias",
+ "@;",
+ "@1",
+ "$Cxx.namespace(value);",
+ "struct",
+ "struct A",
+ "struct A { field @x :Text; }",
+ "struct A { field @0 Text; }",
+ "struct A { field @0 :; }",
+ "struct A { field @0 :Text",
+ "struct A { field @0 :\"Text\"; }",
+ "struct A { field @0 :@; }",
+ "enum A { value @0 }",
+ "enum A { value @65536; }",
+ "bogus",
+ "@1; @2;",
+ "$Cxx.namespace(\"a\"); $Cxx.namespace(\"b\");",
+ "struct A {} struct A {}",
+ "enum A {} enum A {}",
+ "enum A { first @0; second @0; }",
+ "enum A { first @0; first @1; }",
+ ];
+
+ for schema in malformed {
+ assert_eq!(
+ validate_schema_text(schema),
+ Err(RadrootsMeshAgentProtoError::InvalidSchema),
+ "{schema}"
+ );
+ }
+}
+
+#[test]
+fn schema_validator_ignores_non_namespace_annotations() {
+ for schema in ["#", "$Other;", "$Cxx;", "$Cxx.other;", "$Cxx.namespace;"] {
+ assert_eq!(
+ validate_schema_text(schema),
+ Err(RadrootsMeshAgentProtoError::MissingSchemaId),
+ "{schema}"
+ );
+ }
+}
+
+#[test]
fn mesh_agent_proto_errors_have_stable_display_strings() {
let cases = [
(
diff --git a/crates/net/src/builder.rs b/crates/net/src/builder.rs
@@ -35,13 +35,9 @@ impl NetBuilder {
}
}
-pub fn coverage_branch_probe(input: bool) -> bool {
- input
-}
-
#[cfg(test)]
mod tests {
- use super::{NetBuilder, coverage_branch_probe};
+ use super::NetBuilder;
#[test]
fn manage_runtime_path_is_callable() {
@@ -54,10 +50,4 @@ mod tests {
let guard = handle.lock();
assert!(guard.is_ok());
}
-
- #[test]
- fn coverage_branch_probe_hits_both_paths() {
- assert!(coverage_branch_probe(true));
- assert!(!coverage_branch_probe(false));
- }
}
diff --git a/crates/net/src/logging.rs b/crates/net/src/logging.rs
@@ -20,6 +20,11 @@ impl Default for LoggingOptions {
}
pub fn init_logging(opts: LoggingOptions) -> Result<()> {
+ let file_path = opts
+ .dir
+ .as_ref()
+ .map(|d| d.join(&opts.file_name).display().to_string())
+ .unwrap_or_else(|| "<disabled>".into());
let log_opts = radroots_log::LoggingOptions {
dir: opts.dir.clone(),
file_name: opts.file_name.clone(),
@@ -32,11 +37,6 @@ pub fn init_logging(opts: LoggingOptions) -> Result<()> {
Ok(()) => {}
Err(_) => return Err(NetError::LoggingInit("init")),
}
- let file_path = opts
- .dir
- .as_ref()
- .map(|d| d.join(&opts.file_name).display().to_string())
- .unwrap_or_else(|| "<disabled>".into());
info!(
"logging initialized (file: {}, stdout: {})",
file_path, opts.also_stdout
diff --git a/crates/nostr_connect/tests/coverage.rs b/crates/nostr_connect/tests/coverage.rs
@@ -3,9 +3,10 @@ mod test_fixtures;
use nostr::{Event, EventBuilder, Keys, PublicKey, RelayUrl, SecretKey, Timestamp, UnsignedEvent};
use radroots_nostr_connect::prelude::{
- RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR, RadrootsNostrConnectError,
- RadrootsNostrConnectMethod, RadrootsNostrConnectPendingConnectionPollOutcome,
- RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest,
+ RADROOTS_NOSTR_CONNECT_CLIENT_URL_MAX_BYTES, RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR,
+ RadrootsNostrConnectClientMetadata, RadrootsNostrConnectError, RadrootsNostrConnectMethod,
+ RadrootsNostrConnectPendingConnectionPollOutcome, RadrootsNostrConnectPermission,
+ RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest,
RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse,
RadrootsNostrConnectResponseEnvelope, RadrootsNostrConnectUri,
};
@@ -361,6 +362,85 @@ fn uri_surface_covers_rendering_ignored_queries_and_error_paths() {
)),
Err(RadrootsNostrConnectError::InvalidClientMetadata { field: "image", .. })
));
+ assert!(matches!(
+ RadrootsNostrConnectUri::parse(&format!(
+ "nostrconnect://{}?relay={}&secret=",
+ FIXTURE_ALICE.public_key_hex,
+ encode_uri_component(RELAY_PRIMARY_WSS),
+ )),
+ Err(RadrootsNostrConnectError::MissingSecret)
+ ));
+}
+
+#[test]
+fn client_metadata_rejects_malformed_and_unsafe_display_fields() {
+ let empty = RadrootsNostrConnectClientMetadata::default();
+ assert!(empty.is_display_empty());
+ let decoded: RadrootsNostrConnectClientMetadata = serde_json::from_value(json!({
+ "requested_permissions": "ping",
+ "name": " client ",
+ "url": APP_PRIMARY_HTTPS,
+ "image": logo_url(),
+ }))
+ .expect("deserialize and normalize client metadata");
+ assert_eq!(decoded.name.as_deref(), Some("client"));
+ assert_eq!(
+ decoded.url.as_deref(),
+ Some(format!("{APP_PRIMARY_HTTPS}/").as_str())
+ );
+ assert!(
+ serde_json::from_value::<RadrootsNostrConnectClientMetadata>(json!({
+ "name": "line\nbreak"
+ }))
+ .is_err()
+ );
+ for metadata in [
+ RadrootsNostrConnectClientMetadata {
+ name: Some("client".to_owned()),
+ ..empty.clone()
+ },
+ RadrootsNostrConnectClientMetadata {
+ url: Some(APP_PRIMARY_HTTPS.to_owned()),
+ ..empty.clone()
+ },
+ RadrootsNostrConnectClientMetadata {
+ image: Some(logo_url()),
+ ..empty.clone()
+ },
+ ] {
+ assert!(!metadata.is_display_empty());
+ }
+
+ assert!(matches!(
+ RadrootsNostrConnectClientMetadata::from_connect_param("{"),
+ Err(RadrootsNostrConnectError::InvalidClientMetadata {
+ field: "payload",
+ ..
+ })
+ ));
+
+ for (value, field) in [
+ (
+ "x".repeat(RADROOTS_NOSTR_CONNECT_CLIENT_URL_MAX_BYTES + 1),
+ "url",
+ ),
+ ("https://example.com/\n".to_owned(), "url"),
+ ("https://user@example.com".to_owned(), "url"),
+ ("https://:secret@example.com".to_owned(), "image"),
+ ] {
+ let metadata = RadrootsNostrConnectClientMetadata {
+ url: (field == "url").then_some(value.clone()),
+ image: (field == "image").then_some(value),
+ ..empty.clone()
+ };
+ assert!(matches!(
+ metadata.normalized(),
+ Err(RadrootsNostrConnectError::InvalidClientMetadata {
+ field: actual,
+ ..
+ }) if actual == field
+ ));
+ }
}
#[test]
@@ -1227,6 +1307,18 @@ fn response_surface_covers_success_and_error_paths() {
),
Err(RadrootsNostrConnectError::InvalidResponsePayload { .. })
));
+ assert!(matches!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &RadrootsNostrConnectMethod::Logout,
+ RadrootsNostrConnectResponseEnvelope {
+ id: "req-logout".to_owned(),
+ result: Some(json!("not-ack")),
+ error: None,
+ },
+ ),
+ Err(RadrootsNostrConnectError::InvalidResponsePayload { method, .. })
+ if method == "logout"
+ ));
}
#[test]
diff --git a/crates/nostr_signer/src/manager.rs b/crates/nostr_signer/src/manager.rs
@@ -1451,6 +1451,44 @@ mod tests {
}
#[test]
+ fn auth_replay_audit_replacement_rejects_identity_mismatches() {
+ let audit = |connection_id: &str, method: RadrootsNostrConnectMethod| {
+ RadrootsNostrSignerRequestAuditRecord::new(
+ RadrootsNostrSignerRequestId::parse("req-auth-replay").expect("request id"),
+ RadrootsNostrSignerConnectionId::parse(connection_id).expect("connection id"),
+ method,
+ RadrootsNostrSignerRequestDecision::Allowed,
+ None,
+ 1,
+ )
+ };
+ let mut state = RadrootsNostrSignerStoreState::default();
+ replace_or_insert_auth_replay_audit(
+ &mut state,
+ audit("conn-auth-replay", RadrootsNostrConnectMethod::Ping),
+ )
+ .expect("insert audit");
+ replace_or_insert_auth_replay_audit(
+ &mut state,
+ audit("conn-auth-replay", RadrootsNostrConnectMethod::Ping),
+ )
+ .expect("replace matching audit");
+
+ for replacement in [
+ audit("conn-other", RadrootsNostrConnectMethod::Ping),
+ audit("conn-auth-replay", RadrootsNostrConnectMethod::Logout),
+ ] {
+ let error = replace_or_insert_auth_replay_audit(&mut state, replacement)
+ .expect_err("reject mismatched audit");
+ assert!(
+ error
+ .to_string()
+ .contains("auth replay audit does not match the original request")
+ );
+ }
+ }
+
+ #[test]
fn manager_new_in_memory_and_invalid_schema_paths() {
let manager = RadrootsNostrSignerManager::new_in_memory();
assert!(
diff --git a/crates/nostr_signer/src/nip46.rs b/crates/nostr_signer/src/nip46.rs
@@ -1,9 +1,11 @@
-use nostr::UnsignedEvent;
+use nostr::{
+ UnsignedEvent,
+ filter::{Alphabet, SingleLetterTag},
+};
use radroots_identity::RadrootsIdentityPublic;
use radroots_nostr::prelude::{
RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrFilter, RadrootsNostrKind,
RadrootsNostrPublicKey, RadrootsNostrRelayUrl, RadrootsNostrTag, RadrootsNostrTimestamp,
- radroots_nostr_filter_tag,
};
use radroots_nostr_connect::prelude::{
RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectError, RadrootsNostrConnectPermissions,
@@ -151,11 +153,10 @@ impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> {
let filter = RadrootsNostrFilter::new()
.kind(RadrootsNostrKind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND))
.since(RadrootsNostrTimestamp::now());
- Ok(radroots_nostr_filter_tag(
- filter,
- "p",
+ Ok(filter.custom_tags(
+ SingleLetterTag::lowercase(Alphabet::P),
vec![self.signer.signer_public_key_hex()],
- )?)
+ ))
}
pub fn parse_request_event(
@@ -187,20 +188,27 @@ impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> {
&self,
unsigned_event: UnsignedEvent,
) -> Result<RadrootsNostrConnectResponse, RadrootsNostrSignerError> {
+ Ok(self.sign_event_response_value(unsigned_event))
+ }
+
+ fn sign_event_response_value(
+ &self,
+ unsigned_event: UnsignedEvent,
+ ) -> RadrootsNostrConnectResponse {
let user_public_key = self.signer.user_identity().public_key_hex;
if unsigned_event.pubkey.to_hex() != user_public_key {
- return Ok(RadrootsNostrConnectResponse::Error {
+ return RadrootsNostrConnectResponse::Error {
result: None,
error: "sign_event pubkey does not match the managed user identity".to_owned(),
- });
+ };
}
match self.signer.sign_user_event(unsigned_event) {
- Ok(event) => Ok(RadrootsNostrConnectResponse::SignedEvent(event)),
- Err(error) => Ok(RadrootsNostrConnectResponse::Error {
+ Ok(event) => RadrootsNostrConnectResponse::SignedEvent(event),
+ Err(error) => RadrootsNostrConnectResponse::Error {
result: None,
error: format!("failed to sign event: {error}"),
- }),
+ },
}
}
@@ -208,7 +216,14 @@ impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> {
&self,
request: RadrootsNostrConnectRequest,
) -> Result<RadrootsNostrConnectResponse, RadrootsNostrSignerError> {
- Ok(match request {
+ Ok(self.crypto_response_value(request))
+ }
+
+ fn crypto_response_value(
+ &self,
+ request: RadrootsNostrConnectRequest,
+ ) -> RadrootsNostrConnectResponse {
+ match request {
RadrootsNostrConnectRequest::Nip04Encrypt {
public_key,
plaintext,
@@ -253,7 +268,7 @@ impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> {
result: None,
error: format!("request `{}` is not a crypto method", other.method()),
},
- })
+ }
}
}
@@ -511,7 +526,7 @@ where
self.handled_request_for_authorized_action(
&evaluation.connection,
evaluation.action,
- || self.codec.sign_event_response(unsigned_event),
+ || Ok(self.codec.sign_event_response_value(unsigned_event)),
)?,
Some(evaluation.audit),
))
@@ -551,7 +566,7 @@ where
self.handled_request_for_authorized_action(
&evaluation.connection,
evaluation.action,
- || self.codec.crypto_response(request),
+ || Ok(self.codec.crypto_response_value(request)),
)?,
Some(evaluation.audit),
))
@@ -569,7 +584,7 @@ where
.handled_request_for_authorized_action(
&evaluation.connection,
evaluation.action,
- || self.codec.sign_event_response(unsigned_event),
+ || Ok(self.codec.sign_event_response_value(unsigned_event)),
),
RadrootsNostrConnectRequest::Nip04Encrypt { .. }
| RadrootsNostrConnectRequest::Nip04Decrypt { .. }
@@ -578,7 +593,7 @@ where
.handled_request_for_authorized_action(
&evaluation.connection,
evaluation.action,
- || self.codec.crypto_response(request_message.request),
+ || Ok(self.codec.crypto_response_value(request_message.request)),
),
RadrootsNostrConnectRequest::GetPublicKey
| RadrootsNostrConnectRequest::GetSessionCapability
@@ -818,11 +833,14 @@ mod tests {
use crate::evaluation::{
RadrootsNostrSignerRequestAction, RadrootsNostrSignerRequestResponseHint,
};
+ use crate::manager::RadrootsNostrSignerManager;
use crate::model::{
RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerAuthChallenge,
RadrootsNostrSignerAuthState, RadrootsNostrSignerConnectionDraft,
RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerPendingRequest,
+ RadrootsNostrSignerStoreState,
};
+ use crate::store::RadrootsNostrSignerStore;
use crate::test_support::{fixture_alice_identity, fixture_carol_public_key, primary_relay};
use nostr::{Keys, Timestamp, UnsignedEvent};
use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic};
@@ -836,6 +854,10 @@ mod tests {
RadrootsNostrConnectRemoteSessionCapability, RadrootsNostrConnectRequest,
RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse,
};
+ use std::sync::{
+ Arc, RwLock,
+ atomic::{AtomicBool, Ordering},
+ };
#[derive(Clone)]
struct TestSigner {
@@ -853,6 +875,36 @@ mod tests {
prepare_denial: Option<&'static str>,
}
+ #[derive(Clone, Default)]
+ struct ToggleSaveStore {
+ state: Arc<RwLock<RadrootsNostrSignerStoreState>>,
+ fail_saves: Arc<AtomicBool>,
+ }
+
+ impl RadrootsNostrSignerStore for ToggleSaveStore {
+ fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError> {
+ self.state
+ .read()
+ .map(|state| state.clone())
+ .map_err(|_| RadrootsNostrSignerError::Store("test store lock poisoned".into()))
+ }
+
+ fn save(
+ &self,
+ state: &RadrootsNostrSignerStoreState,
+ ) -> Result<(), RadrootsNostrSignerError> {
+ if self.fail_saves.load(Ordering::SeqCst) {
+ return Err(RadrootsNostrSignerError::Store(
+ "test store save failure".into(),
+ ));
+ }
+ self.state
+ .write()
+ .map(|mut stored| *stored = state.clone())
+ .map_err(|_| RadrootsNostrSignerError::Store("test store lock poisoned".into()))
+ }
+ }
+
impl Default for TestPolicy {
fn default() -> Self {
Self {
@@ -1604,6 +1656,38 @@ mod tests {
}
#[test]
+ fn policy_denial_propagates_audit_persistence_failures() {
+ let store = ToggleSaveStore::default();
+ let manager = RadrootsNostrSignerManager::new(Arc::new(store.clone()))
+ .expect("manager with toggle store");
+ let backend =
+ RadrootsNostrEmbeddedSignerBackend::new(manager, test_signer().signer_identity.clone())
+ .expect("embedded backend");
+ let client_public_key = fixture_carol_public_key();
+ connect_with_permissions(
+ &handler_with_backend(backend.clone()),
+ client_public_key,
+ Vec::new(),
+ );
+ store.fail_saves.store(true, Ordering::SeqCst);
+
+ let handler = handler_with_policy(
+ backend,
+ TestPolicy {
+ prepare_denial: Some("policy blocked"),
+ ..TestPolicy::default()
+ },
+ );
+ let error = handler
+ .handle_request(
+ client_public_key,
+ request_message("req-audit-save", RadrootsNostrConnectRequest::Ping),
+ )
+ .expect_err("audit persistence failure");
+ assert!(error.to_string().contains("test store save failure"));
+ }
+
+ #[test]
fn handler_rejects_unauthorized_base_sign_and_crypto_requests() {
let handler = handler_with_backend(embedded_backend());
let client_public_key = fixture_carol_public_key();
diff --git a/crates/outbox/src/model.rs b/crates/outbox/src/model.rs
@@ -401,6 +401,7 @@ pub struct RadrootsOutboxSignedTradeMutationInput {
}
impl RadrootsOutboxSignedTradeMutationInput {
+ #[allow(clippy::too_many_arguments)]
pub fn new(
operation_kind: impl Into<String>,
trade_id: RadrootsTradeId,
@@ -584,6 +585,7 @@ pub struct RadrootsOutboxStatusSummary {
}
#[cfg(test)]
+#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
@@ -782,6 +784,90 @@ mod tests {
deferred_until_implemented
);
assert_eq!(status.is_terminal_failure(), terminal_failure);
+ assert_eq!(
+ status.is_retryable_failure(),
+ status == RadrootsOutboxDeliveryTargetStatus::FailedRetryable
+ );
}
+
+ for (class, satisfying_statuses) in [
+ (
+ radroots_transport::RadrootsTransportSatisfactionClass::Forwarded,
+ vec![
+ RadrootsOutboxDeliveryTargetStatus::Forwarded,
+ RadrootsOutboxDeliveryTargetStatus::Delivered,
+ ],
+ ),
+ (
+ radroots_transport::RadrootsTransportSatisfactionClass::Stored,
+ vec![RadrootsOutboxDeliveryTargetStatus::StoredByGateway],
+ ),
+ (
+ radroots_transport::RadrootsTransportSatisfactionClass::Seen,
+ vec![
+ RadrootsOutboxDeliveryTargetStatus::Seen,
+ RadrootsOutboxDeliveryTargetStatus::Delivered,
+ ],
+ ),
+ (
+ radroots_transport::RadrootsTransportSatisfactionClass::DurableOrObserved,
+ vec![
+ RadrootsOutboxDeliveryTargetStatus::StoredByGateway,
+ RadrootsOutboxDeliveryTargetStatus::Seen,
+ RadrootsOutboxDeliveryTargetStatus::Delivered,
+ ],
+ ),
+ ] {
+ for status in satisfying_statuses {
+ assert!(status.counts_as_transport_satisfaction(class));
+ }
+ assert!(
+ !RadrootsOutboxDeliveryTargetStatus::Pending
+ .counts_as_transport_satisfaction(class)
+ );
+ }
+
+ for invalid in ["", "unknown"] {
+ assert!(RadrootsOutboxOperationStatus::parse(invalid).is_err());
+ assert!(RadrootsOutboxEventState::parse(invalid).is_err());
+ assert!(RadrootsOutboxDeliveryPlanStatus::parse(invalid).is_err());
+ assert!(RadrootsOutboxDeliveryTargetStatus::parse(invalid).is_err());
+ }
+
+ for state in [
+ RadrootsOutboxEventState::DraftQueued,
+ RadrootsOutboxEventState::Signing,
+ RadrootsOutboxEventState::Signed,
+ RadrootsOutboxEventState::Publishing,
+ RadrootsOutboxEventState::SignRetryable,
+ RadrootsOutboxEventState::PublishRetryable,
+ ] {
+ assert!(!state.is_terminal());
+ }
+ for state in [
+ RadrootsOutboxEventState::Published,
+ RadrootsOutboxEventState::FailedTerminal,
+ RadrootsOutboxEventState::Cancelled,
+ ] {
+ assert!(state.is_terminal());
+ }
+
+ assert!(
+ RadrootsOutboxDeliveryTargetStatus::Forwarded
+ .counts_as_transport_satisfaction(RadrootsTransportSatisfactionClass::Forwarded,)
+ );
+ assert!(
+ RadrootsOutboxDeliveryTargetStatus::StoredByGateway
+ .counts_as_transport_satisfaction(RadrootsTransportSatisfactionClass::Stored)
+ );
+ assert!(
+ RadrootsOutboxDeliveryTargetStatus::Seen
+ .counts_as_transport_satisfaction(RadrootsTransportSatisfactionClass::Seen,)
+ );
+ assert!(
+ RadrootsOutboxDeliveryTargetStatus::StoredByGateway.counts_as_transport_satisfaction(
+ RadrootsTransportSatisfactionClass::DurableOrObserved,
+ )
+ );
}
}
diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs
@@ -926,13 +926,9 @@ impl RadrootsOutbox {
let outbox_event_ids = reticulum_event_ids_pool(&self.pool, outbox_event_id, limit).await?;
let mut records = Vec::with_capacity(outbox_event_ids.len());
for outbox_event_id in outbox_event_ids {
- let Some(event) = self.get_event(outbox_event_id).await? else {
- continue;
- };
+ let event = self.get_event(outbox_event_id).await?;
let targets = reticulum_targets_for_event_pool(&self.pool, outbox_event_id).await?;
- if !targets.is_empty() {
- records.push(RadrootsOutboxReticulumEventRecord { event, targets });
- }
+ records.extend(reticulum_event_record(event, targets));
}
Ok(records)
}
@@ -1233,7 +1229,8 @@ impl RadrootsOutbox {
"local:outbox",
RadrootsTransportObservationType::LocalImport,
observed_at_ms,
- )?;
+ )
+ .expect("the static local outbox transport URI must remain valid");
let ingest = RadrootsEventIngest::new(signed_event.clone(), observed_at_ms)
.with_observation(observation);
let receipt = event_store.ingest_event(ingest).await?;
@@ -1763,7 +1760,6 @@ struct PlanInsertResult {
struct PlanEvaluation {
all_complete: bool,
- any_failed_terminal: bool,
any_ready: bool,
}
@@ -1793,13 +1789,6 @@ fn publish_lifecycle_from_plan_evaluation<'a>(
Some(retryable_error),
next_attempt_after_ms,
)
- } else if evaluation.any_failed_terminal {
- (
- RadrootsOutboxEventState::FailedTerminal,
- Some(RadrootsOutboxOperationStatus::FailedTerminal),
- Some(terminal_error),
- now_ms,
- )
} else {
(
RadrootsOutboxEventState::FailedTerminal,
@@ -1979,19 +1968,12 @@ fn validate_unique_targets(targets: &[RadrootsTransportTarget]) -> Result<(), Ra
fn initial_delivery_target_status(
target: &RadrootsTransportTarget,
- reticulum_behavior: RadrootsOutboxReticulumBehavior,
+ _reticulum_behavior: RadrootsOutboxReticulumBehavior,
) -> RadrootsOutboxDeliveryTargetStatus {
if target.kind != RadrootsTransportKind::Reticulum {
return RadrootsOutboxDeliveryTargetStatus::Pending;
}
- match reticulum_behavior {
- RadrootsOutboxReticulumBehavior::RejectDeliveryAttempts => {
- RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented
- }
- RadrootsOutboxReticulumBehavior::DeferDeliveryPlans => {
- RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented
- }
- }
+ RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented
}
fn initial_delivery_plan_status(
@@ -2007,12 +1989,7 @@ fn initial_delivery_plan_status(
{
return RadrootsOutboxDeliveryPlanStatus::Queued;
}
- if prepared_targets.iter().all(|target| {
- target.initial_status == RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented
- }) {
- return RadrootsOutboxDeliveryPlanStatus::FailedTerminal;
- }
- RadrootsOutboxDeliveryPlanStatus::Queued
+ RadrootsOutboxDeliveryPlanStatus::FailedTerminal
}
async fn existing_idempotent_operation(
@@ -2564,7 +2541,6 @@ async fn evaluate_delivery_plans(
) -> Result<PlanEvaluation, RadrootsOutboxError> {
let plans = delivery_plans_for_tx(tx, outbox_event_id).await?;
let mut all_complete = !plans.is_empty();
- let mut any_failed_terminal = false;
let mut any_ready = false;
for plan in plans {
let targets = delivery_targets_for_plan_tx(tx, plan.delivery_plan_id).await?;
@@ -2574,29 +2550,16 @@ async fn evaluate_delivery_plans(
.iter()
.filter(|target| target.status.is_ready_for_attempt())
.count();
- let deferred_count = status_targets
- .iter()
- .filter(|target| target.status.is_deferred_until_implemented())
- .count();
- let terminal_failure_count = status_targets
- .iter()
- .filter(|target| target.status.is_terminal_failure())
- .count();
let plan_status = if satisfied_count >= plan.required_success_count {
RadrootsOutboxDeliveryPlanStatus::Complete
} else if ready_count > 0 {
RadrootsOutboxDeliveryPlanStatus::Queued
- } else if terminal_failure_count > 0 || deferred_count > 0 {
- RadrootsOutboxDeliveryPlanStatus::FailedTerminal
} else {
RadrootsOutboxDeliveryPlanStatus::FailedTerminal
};
if plan_status != RadrootsOutboxDeliveryPlanStatus::Complete {
all_complete = false;
}
- if plan_status == RadrootsOutboxDeliveryPlanStatus::FailedTerminal {
- any_failed_terminal = true;
- }
if ready_count > 0 {
any_ready = true;
}
@@ -2613,7 +2576,6 @@ async fn evaluate_delivery_plans(
}
Ok(PlanEvaluation {
all_complete,
- any_failed_terminal,
any_ready,
})
}
@@ -2664,6 +2626,16 @@ fn outbox_satisfied_target_count(
}
}
+fn reticulum_event_record(
+ event: Option<RadrootsOutboxEventRecord>,
+ targets: Vec<RadrootsOutboxDeliveryTargetRecord>,
+) -> Option<RadrootsOutboxReticulumEventRecord> {
+ if targets.is_empty() {
+ return None;
+ }
+ event.map(|event| RadrootsOutboxReticulumEventRecord { event, targets })
+}
+
fn operation_from_row(
row: sqlx::sqlite::SqliteRow,
) -> Result<RadrootsOutboxOperationRecord, RadrootsOutboxError> {
@@ -3008,10 +2980,16 @@ fn target_policy_fingerprint(
})
.collect::<Vec<_>>();
target_inputs.sort_by(|left, right| {
- left.endpoint_fingerprint
- .cmp(right.endpoint_fingerprint)
- .then_with(|| left.transport_kind.cmp(&right.transport_kind))
- .then_with(|| left.endpoint_uri.cmp(right.endpoint_uri))
+ (
+ left.endpoint_fingerprint,
+ left.transport_kind.as_str(),
+ left.endpoint_uri,
+ )
+ .cmp(&(
+ right.endpoint_fingerprint,
+ right.transport_kind.as_str(),
+ right.endpoint_uri,
+ ))
});
sha256_json(&TargetPolicyDigestInput {
satisfaction_policy: satisfaction_policy_storage_value(satisfaction_policy),
@@ -3067,14 +3045,6 @@ fn validate_trade_mutation_input(
}
let parsed = trade_mutation_from_canonical_content(draft.content())
.map_err(|_| RadrootsOutboxError::TradeMutationMetadataMismatch { field: "content" })?;
- if parsed.contract_id != draft.contract_id() {
- return Err(RadrootsOutboxError::TradeMutationMetadataMismatch {
- field: "contract_id",
- });
- }
- if parsed.mutation_kind().nostr_kind() != draft.kind_u32() {
- return Err(RadrootsOutboxError::TradeMutationMetadataMismatch { field: "kind" });
- }
if parsed.author_pubkey.as_str() != draft.expected_pubkey_str() {
return Err(RadrootsOutboxError::TradeMutationMetadataMismatch {
field: "author_pubkey",
@@ -3249,12 +3219,8 @@ fn parse_required_target_policy(
.split(',')
.map(RadrootsTransportTargetFingerprint::parse)
.collect::<Result<Vec<_>, _>>()?;
- if required_success_count
- != i64::try_from(targets.len()).map_err(|_| RadrootsOutboxError::IntegerRange {
- field: "required_success_count",
- value: required_success_count,
- })?
- {
+ let required_success_count = usize::from(required_count_u16(required_success_count)?);
+ if required_success_count != targets.len() {
return Err(RadrootsOutboxError::InvalidStoredEnum {
field: "outbox_delivery_plan.satisfaction_policy",
value: stored.to_owned(),
@@ -3281,6 +3247,7 @@ fn u32_from_i64(field: &'static str, value: i64) -> Result<u32, RadrootsOutboxEr
}
#[cfg(test)]
+#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
use radroots_event::ids::{
@@ -3294,7 +3261,7 @@ mod tests {
RadrootsTradeCandidateLineV1, RadrootsTradeCandidateTermsV1,
RadrootsTradeCanonicalMutationV1, RadrootsTradeEconomicAdjustmentV1,
RadrootsTradeEconomicsProfileV1, RadrootsTradeMutationBodyV1,
- RadrootsTradeMutationEnvelopeV1, canonical_trade_mutation_content,
+ RadrootsTradeMutationEnvelopeV1, canonical_jcs_value, canonical_trade_mutation_content,
};
use radroots_nostr::prelude::{
RadrootsNostrKeys, RadrootsNostrSecretKey, radroots_nostr_sign_frozen_draft,
@@ -3447,6 +3414,22 @@ mod tests {
.expect("trade mutation draft")
}
+ fn proposal_draft_with_content(
+ content: impl Into<String>,
+ expected_pubkey: &str,
+ ) -> RadrootsEventDraft {
+ let valid = trade_mutation_draft(&canonical_trade_proposal());
+ RadrootsEventDraft::new(
+ valid.contract_id(),
+ valid.kind_u32(),
+ valid.created_at_u64(),
+ valid.tags_as_vec(),
+ content,
+ expected_pubkey,
+ )
+ .expect("proposal draft with custom content")
+ }
+
fn signed_trade_mutation(
canonical: &RadrootsTradeCanonicalMutationV1,
) -> (RadrootsEventDraft, RadrootsSignedEvent) {
@@ -3601,6 +3584,254 @@ mod tests {
);
}
+ #[test]
+ fn validation_and_persisted_value_parsers_reject_malformed_contract_data() {
+ let empty_profile = RadrootsOutboxDeliveryPlanInput::new(
+ " ",
+ 1,
+ RadrootsTransportSatisfactionPolicy::no_wait(),
+ Vec::new(),
+ );
+ assert!(matches!(
+ prepare_delivery_plan("event-empty-profile", &empty_profile),
+ Err(RadrootsOutboxError::EmptyTransportProfileId)
+ ));
+
+ for (stored, required_count) in [
+ ("no_wait", 1),
+ ("all_unknown", 1),
+ ("any_unknown", 1),
+ ("quorum_accepted", 1),
+ ("quorum_accepted:2", 1),
+ ("quorum_unknown:1", 1),
+ ("required_unknown:value", 1),
+ ("unknown", 0),
+ ] {
+ assert!(matches!(
+ parse_satisfaction_policy(stored, required_count),
+ Err(RadrootsOutboxError::InvalidStoredEnum { .. })
+ ));
+ }
+ assert!(parse_satisfaction_class_storage_value("unknown").is_none());
+ assert!(
+ parse_required_target_policy(
+ "required_accepted:invalid",
+ "invalid",
+ RadrootsTransportSatisfactionClass::Accepted,
+ 1,
+ )
+ .is_err()
+ );
+ let target = nostr_target(NOSTR_PRIMARY_WSS);
+ assert_eq!(
+ parse_required_target_policy(
+ "required_accepted:valid",
+ target.fingerprint.as_str(),
+ RadrootsTransportSatisfactionClass::Accepted,
+ 1,
+ )
+ .expect("valid required-target policy"),
+ RadrootsTransportSatisfactionPolicy::required_targets(
+ RadrootsTransportSatisfactionClass::Accepted,
+ vec![target.fingerprint.clone()],
+ )
+ .expect("required-target policy"),
+ );
+ assert!(matches!(
+ parse_required_target_policy(
+ "required_accepted:count-mismatch",
+ target.fingerprint.as_str(),
+ RadrootsTransportSatisfactionClass::Accepted,
+ 0,
+ ),
+ Err(RadrootsOutboxError::InvalidStoredEnum { .. })
+ ));
+ let duplicate_fingerprints = format!(
+ "{},{}",
+ target.fingerprint.as_str(),
+ target.fingerprint.as_str()
+ );
+ assert!(
+ parse_required_target_policy(
+ "required_accepted:duplicate",
+ duplicate_fingerprints.as_str(),
+ RadrootsTransportSatisfactionClass::Accepted,
+ 2,
+ )
+ .is_err()
+ );
+ assert!(required_count_u16(-1).is_err());
+ assert!(required_count_u16(i64::from(u16::MAX) + 1).is_err());
+ assert!(u32_from_i64("negative", -1).is_err());
+ assert_eq!(bool_i64(false), 0);
+ assert_eq!(bool_i64(true), 1);
+ assert!(parse_optional_stored_trade_id("trade_id", None).is_ok());
+ assert!(parse_optional_stored_trade_id("trade_id", Some("invalid".to_owned())).is_err());
+ assert!(
+ parse_optional_stored_mutation_id("mutation_id", Some("invalid".to_owned())).is_err()
+ );
+ assert!(matches!(
+ outcome_kind_for_status(RadrootsOutboxDeliveryTargetStatus::Pending),
+ RadrootsTransportOutcomeKind::TransportUnavailable
+ ));
+ assert!(parse_transport_outcome_kind("invalid", "outcome").is_err());
+ for (stored, expected) in [
+ ("accepted", RadrootsTransportOutcomeKind::Accepted),
+ (
+ "duplicate_accepted",
+ RadrootsTransportOutcomeKind::DuplicateAccepted,
+ ),
+ ("delivered", RadrootsTransportOutcomeKind::Delivered),
+ ("forwarded", RadrootsTransportOutcomeKind::Forwarded),
+ (
+ "stored_by_gateway",
+ RadrootsTransportOutcomeKind::StoredByGateway,
+ ),
+ ("seen", RadrootsTransportOutcomeKind::Seen),
+ (
+ "deferred_until_implemented",
+ RadrootsTransportOutcomeKind::DeferredUntilImplemented,
+ ),
+ ("rejected", RadrootsTransportOutcomeKind::Rejected),
+ (
+ "route_unavailable",
+ RadrootsTransportOutcomeKind::RouteUnavailable,
+ ),
+ (
+ "payload_too_large",
+ RadrootsTransportOutcomeKind::PayloadTooLarge,
+ ),
+ ("policy_denied", RadrootsTransportOutcomeKind::PolicyDenied),
+ ("timeout", RadrootsTransportOutcomeKind::Timeout),
+ (
+ "connection_failed",
+ RadrootsTransportOutcomeKind::ConnectionFailed,
+ ),
+ (
+ "transport_unavailable",
+ RadrootsTransportOutcomeKind::TransportUnavailable,
+ ),
+ ] {
+ assert_eq!(
+ parse_transport_outcome_kind(stored, "outcome").expect("stored outcome"),
+ expected
+ );
+ }
+
+ let canonical = canonical_trade_proposal();
+ let valid_draft = trade_mutation_draft(&canonical);
+ let valid_hash = sha256_hex(canonical.content.as_bytes());
+ assert!(matches!(
+ validate_trade_mutation_input(
+ canonical.envelope.trade_id.as_str(),
+ canonical.mutation_id.as_str(),
+ valid_hash.as_str(),
+ &post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "not a trade mutation"),
+ ),
+ Err(RadrootsOutboxError::TradeMutationMetadataMismatch { field: "kind" })
+ ));
+ for invalid_hash in ["short".to_owned(), "g".repeat(64)] {
+ assert!(matches!(
+ validate_trade_mutation_input(
+ canonical.envelope.trade_id.as_str(),
+ canonical.mutation_id.as_str(),
+ invalid_hash.as_str(),
+ &valid_draft,
+ ),
+ Err(RadrootsOutboxError::TradeMutationMetadataMismatch {
+ field: "canonical_payload_sha256"
+ })
+ ));
+ }
+ let invalid_content = proposal_draft_with_content(
+ format!(" {}", canonical.content),
+ FIXTURE_ALICE_PUBLIC_KEY_HEX,
+ );
+ assert!(matches!(
+ validate_trade_mutation_input(
+ canonical.envelope.trade_id.as_str(),
+ canonical.mutation_id.as_str(),
+ valid_hash.as_str(),
+ &invalid_content,
+ ),
+ Err(RadrootsOutboxError::TradeMutationMetadataMismatch { field: "content" })
+ ));
+
+ let author_mismatch =
+ proposal_draft_with_content(canonical.content.clone(), hex_64('b').as_str());
+ assert!(matches!(
+ validate_trade_mutation_input(
+ canonical.envelope.trade_id.as_str(),
+ canonical.mutation_id.as_str(),
+ valid_hash.as_str(),
+ &author_mismatch,
+ ),
+ Err(RadrootsOutboxError::TradeMutationMetadataMismatch {
+ field: "author_pubkey"
+ })
+ ));
+ assert!(matches!(
+ validate_trade_mutation_input(
+ hex_32('2').as_str(),
+ canonical.mutation_id.as_str(),
+ valid_hash.as_str(),
+ &valid_draft,
+ ),
+ Err(RadrootsOutboxError::TradeMutationMetadataMismatch { field: "trade_id" })
+ ));
+ assert!(matches!(
+ validate_trade_mutation_input(
+ canonical.envelope.trade_id.as_str(),
+ hex_64('3').as_str(),
+ valid_hash.as_str(),
+ &valid_draft,
+ ),
+ Err(RadrootsOutboxError::TradeMutationMetadataMismatch {
+ field: "mutation_id"
+ })
+ ));
+ assert!(matches!(
+ validate_trade_mutation_input(
+ canonical.envelope.trade_id.as_str(),
+ canonical.mutation_id.as_str(),
+ "0".repeat(64).as_str(),
+ &valid_draft,
+ ),
+ Err(RadrootsOutboxError::TradeMutationMetadataMismatch {
+ field: "canonical_payload_sha256"
+ })
+ ));
+
+ let envelope_without_mutation_id = proposal_envelope();
+ let content_without_mutation_id = canonical_jcs_value(
+ &serde_json::to_value(&envelope_without_mutation_id).expect("proposal value"),
+ )
+ .expect("canonical proposal without mutation id");
+ let draft_without_mutation_id = proposal_draft_with_content(
+ content_without_mutation_id.clone(),
+ FIXTURE_ALICE_PUBLIC_KEY_HEX,
+ );
+ assert!(matches!(
+ validate_trade_mutation_input(
+ envelope_without_mutation_id.trade_id.as_str(),
+ canonical.mutation_id.as_str(),
+ sha256_hex(content_without_mutation_id.as_bytes()).as_str(),
+ &draft_without_mutation_id,
+ ),
+ Err(RadrootsOutboxError::TradeMutationMetadataMismatch {
+ field: "mutation_id"
+ })
+ ));
+
+ let empty_no_wait = RadrootsOutboxDeliveryPlanInput::new(
+ "transport.none",
+ 1,
+ RadrootsTransportSatisfactionPolicy::no_wait(),
+ Vec::new(),
+ );
+ assert!(prepare_delivery_plan("event-no-wait", &empty_no_wait).is_ok());
+ }
+
fn malformed_reticulum_target(uri: &str) -> RadrootsTransportTarget {
let endpoint_uri = RadrootsTransportTargetUri::parse(uri).expect("target uri");
let endpoint_fingerprint = RadrootsTransportTargetFingerprint::from_target(
@@ -3681,6 +3912,23 @@ mod tests {
)
}
+ fn trade_mutation_input(
+ canonical: &RadrootsTradeCanonicalMutationV1,
+ draft: RadrootsEventDraft,
+ targets: Vec<RadrootsTransportTarget>,
+ created_at_ms: i64,
+ ) -> RadrootsOutboxTradeMutationInput {
+ RadrootsOutboxTradeMutationInput::new(
+ "publish_trade_mutation",
+ canonical.envelope.trade_id.clone(),
+ canonical.mutation_id.clone(),
+ sha256_hex(canonical.content.as_bytes()),
+ draft,
+ delivery_plan(targets),
+ created_at_ms,
+ )
+ }
+
fn fixture_keys() -> RadrootsNostrKeys {
let secret_key =
RadrootsNostrSecretKey::from_hex(FIXTURE_ALICE_SECRET_KEY_HEX).expect("secret key");
@@ -3857,103 +4105,1055 @@ mod tests {
}
#[tokio::test]
- async fn operation_and_delivery_plan_idempotency_are_split() {
- let outbox = RadrootsOutbox::open_memory().await.expect("open");
- let draft = post_draft(hex_64('a').as_str(), "hello");
- let first = outbox
- .enqueue_operation(operation_input(draft.clone(), 1_000).with_idempotency_key("idem-a"))
- .await
- .expect("first");
- let same_plan = outbox
- .enqueue_operation(operation_input(draft.clone(), 1_100).with_idempotency_key("idem-a"))
- .await
- .expect("same");
- let new_plan = outbox
- .enqueue_operation(
- RadrootsOutboxOperationInput::new(
- "publish_post",
- draft.clone(),
- delivery_plan(vec![nostr_target("wss://relay-3.example.com")]),
- 1_200,
- )
- .with_idempotency_key("idem-a"),
- )
+ async fn constructors_preflight_and_transactional_enqueue_cover_public_storage_surfaces() {
+ let directory = tempfile::tempdir().expect("tempdir");
+ let file_outbox = RadrootsOutbox::open_file(directory.path().join("outbox.sqlite"))
.await
- .expect("new plan");
-
- assert_eq!(first.status, RadrootsOutboxEnqueueStatus::Inserted);
- assert_eq!(same_plan.status, RadrootsOutboxEnqueueStatus::Existing);
- assert_eq!(new_plan.status, RadrootsOutboxEnqueueStatus::Inserted);
- assert_eq!(first.operation_id, same_plan.operation_id);
- assert_eq!(first.operation_id, new_plan.operation_id);
- assert_eq!(first.outbox_event_id, new_plan.outbox_event_id);
+ .expect("file outbox");
assert_eq!(
- first.operation_idempotency_digest,
- new_plan.operation_idempotency_digest
- );
- assert_ne!(
- first.delivery_plan_idempotency_digest,
- new_plan.delivery_plan_idempotency_digest
+ file_outbox
+ .pragma_journal_mode()
+ .await
+ .expect("file journal mode"),
+ "wal"
);
- assert_eq!(table_count(&outbox, "outbox_operations").await, 1);
- assert_eq!(table_count(&outbox, "outbox_event").await, 1);
- assert_eq!(table_count(&outbox, "outbox_delivery_plan").await, 2);
- let conflict = outbox
- .enqueue_operation(
- operation_input(post_draft(hex_64('a').as_str(), "changed"), 1_300)
- .with_idempotency_key("idem-a"),
- )
+ let options = SqliteConnectOptions::from_str("sqlite::memory:").expect("memory options");
+ let pool = SqlitePoolOptions::new()
+ .max_connections(1)
+ .connect_with(options)
.await
- .expect_err("conflict");
+ .expect("memory pool");
+ let outbox = RadrootsOutbox::open_pool(pool, false)
+ .await
+ .expect("pool outbox");
+
+ let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "preflight");
+ let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft)
+ .expect("signed preflight event");
+ let input = signed_operation_input(draft.clone(), signed_event.clone(), 1_000)
+ .with_idempotency_key("preflight-idem");
+ outbox
+ .preflight_signed_operation_idempotency(&signed_operation_input(
+ draft.clone(),
+ signed_event.clone(),
+ 900,
+ ))
+ .await
+ .expect("preflight without idempotency key");
+ outbox
+ .preflight_signed_operation_idempotency(&input)
+ .await
+ .expect("preflight before insert");
+ outbox
+ .enqueue_signed_operation(input.clone())
+ .await
+ .expect("signed insert");
+ outbox
+ .preflight_signed_operation_idempotency(&input)
+ .await
+ .expect("matching existing preflight");
+
+ let changed_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "preflight changed");
+ let changed_signed = radroots_nostr_sign_frozen_draft(&fixture_keys(), &changed_draft)
+ .expect("changed signed event");
+ let conflict = signed_operation_input(changed_draft, changed_signed, 1_100)
+ .with_idempotency_key("preflight-idem");
assert!(matches!(
- conflict,
- RadrootsOutboxError::IdempotencyConflict { .. }
+ outbox
+ .preflight_signed_operation_idempotency(&conflict)
+ .await,
+ Err(RadrootsOutboxError::IdempotencyConflict { .. })
));
+
+ let transaction_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "transaction");
+ let transaction_signed =
+ radroots_nostr_sign_frozen_draft(&fixture_keys(), &transaction_draft)
+ .expect("transaction signed event");
+ let transaction_input =
+ signed_operation_input(transaction_draft.clone(), transaction_signed, 2_000)
+ .with_idempotency_key("transaction-idem");
+ let mut transaction = outbox.pool().begin().await.expect("begin transaction");
+ let inserted = outbox
+ .enqueue_signed_operation_in_transaction(&mut transaction, transaction_input.clone())
+ .await
+ .expect("transaction insert");
+ let existing = outbox
+ .enqueue_signed_operation_in_transaction(&mut transaction, transaction_input.clone())
+ .await
+ .expect("transaction existing");
+ assert_eq!(inserted.operation_id, existing.operation_id);
+
+ let transaction_changed = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "transaction changed");
+ let transaction_changed_signed =
+ radroots_nostr_sign_frozen_draft(&fixture_keys(), &transaction_changed)
+ .expect("transaction changed signed event");
+ let transaction_conflict =
+ signed_operation_input(transaction_changed, transaction_changed_signed, 2_100)
+ .with_idempotency_key("transaction-idem");
+ assert!(matches!(
+ outbox
+ .enqueue_signed_operation_in_transaction(&mut transaction, transaction_conflict,)
+ .await,
+ Err(RadrootsOutboxError::IdempotencyConflict { .. })
+ ));
+ transaction.commit().await.expect("commit transaction");
}
#[tokio::test]
- async fn generic_enqueue_rejects_trade_mutation_drafts_before_persistence() {
+ async fn defensive_storage_decoding_and_remaining_idempotency_edges_are_explicit() {
let outbox = RadrootsOutbox::open_memory().await.expect("open");
- let canonical = canonical_trade_proposal();
- let (draft, signed_event) = signed_trade_mutation(&canonical);
-
- let unsigned_err = outbox
- .enqueue_operation(RadrootsOutboxOperationInput::new(
- "publish_trade_mutation",
+ let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "defensive storage");
+ let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft)
+ .expect("signed defensive event");
+ let signed_receipt = outbox
+ .enqueue_signed_operation(signed_operation_input(
draft.clone(),
- delivery_plan(vec![nostr_target(NOSTR_PRIMARY_WSS)]),
+ signed_event.clone(),
1_000,
))
.await
- .expect_err("generic unsigned trade rejection");
- assert!(matches!(
- unsigned_err,
- RadrootsOutboxError::TradeMutationRequiresSemanticOutbox
- ));
+ .expect("signed insert without idempotency key");
- let signed_err = outbox
- .enqueue_signed_operation(RadrootsOutboxSignedOperationInput::new(
- "publish_trade_mutation",
- draft,
- signed_event,
- delivery_plan(vec![nostr_target(NOSTR_PRIMARY_WSS)]),
- true,
- 1_007,
- 1_000,
- ))
+ let keyed_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "keyed public insert");
+ let keyed_signed = radroots_nostr_sign_frozen_draft(&fixture_keys(), &keyed_draft)
+ .expect("keyed signed event");
+ outbox
+ .enqueue_signed_operation(
+ signed_operation_input(keyed_draft, keyed_signed, 1_100)
+ .with_idempotency_key("public-signed-conflict"),
+ )
.await
- .expect_err("generic signed trade rejection");
+ .expect("keyed public signed insert");
+ let changed_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "keyed public conflict");
+ let changed_signed = radroots_nostr_sign_frozen_draft(&fixture_keys(), &changed_draft)
+ .expect("changed signed event");
assert!(matches!(
- signed_err,
- RadrootsOutboxError::TradeMutationRequiresSemanticOutbox
+ outbox
+ .enqueue_signed_operation(
+ signed_operation_input(changed_draft, changed_signed, 1_200)
+ .with_idempotency_key("public-signed-conflict"),
+ )
+ .await,
+ Err(RadrootsOutboxError::IdempotencyConflict { .. })
));
- assert_eq!(table_count(&outbox, "outbox_operations").await, 0);
- assert_eq!(table_count(&outbox, "outbox_event").await, 0);
- assert_eq!(table_count(&outbox, "outbox_delivery_plan").await, 0);
- }
- #[tokio::test]
+ let canonical = canonical_trade_proposal();
+ let invalid_trade_draft = post_draft(
+ FIXTURE_ALICE_PUBLIC_KEY_HEX,
+ "not a semantic trade mutation",
+ );
+ let invalid_trade_signed =
+ radroots_nostr_sign_frozen_draft(&fixture_keys(), &invalid_trade_draft)
+ .expect("invalid trade draft still forms a valid signed event");
+ assert!(matches!(
+ outbox
+ .preflight_signed_trade_mutation_idempotency(&signed_trade_mutation_input(
+ &canonical,
+ invalid_trade_draft.clone(),
+ invalid_trade_signed,
+ vec![nostr_target("wss://invalid-trade-preflight.example")],
+ 1_300,
+ ))
+ .await,
+ Err(RadrootsOutboxError::TradeMutationMetadataMismatch { field: "kind" })
+ ));
+ assert!(matches!(
+ outbox
+ .enqueue_trade_mutation_operation(trade_mutation_input(
+ &canonical,
+ invalid_trade_draft,
+ vec![nostr_target("wss://invalid-trade-enqueue.example")],
+ 1_400,
+ ))
+ .await,
+ Err(RadrootsOutboxError::TradeMutationMetadataMismatch { field: "kind" })
+ ));
+
+ let (trade_draft, trade_signed_event) = signed_trade_mutation(&canonical);
+ outbox
+ .preflight_signed_trade_mutation_idempotency(&signed_trade_mutation_input(
+ &canonical,
+ trade_draft.clone(),
+ trade_signed_event,
+ vec![nostr_target("wss://trade-preflight-no-key.example")],
+ 1_500,
+ ))
+ .await
+ .expect("trade preflight without idempotency key");
+ let signed_trade_without_key = RadrootsOutbox::open_memory()
+ .await
+ .expect("open isolated signed trade outbox");
+ let (isolated_trade_draft, isolated_trade_event) = signed_trade_mutation(&canonical);
+ signed_trade_without_key
+ .enqueue_signed_trade_mutation_operation(signed_trade_mutation_input(
+ &canonical,
+ isolated_trade_draft,
+ isolated_trade_event,
+ vec![nostr_target("wss://signed-trade-no-key.example")],
+ 1_550,
+ ))
+ .await
+ .expect("signed trade insert without idempotency key");
+ let trade_operation = outbox
+ .enqueue_trade_mutation_operation(trade_mutation_input(
+ &canonical,
+ trade_draft,
+ vec![nostr_target("wss://trade-storage.example")],
+ 1_600,
+ ))
+ .await
+ .expect("trade insert without idempotency key");
+
+ let transaction_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "transaction no key");
+ let transaction_signed =
+ radroots_nostr_sign_frozen_draft(&fixture_keys(), &transaction_draft)
+ .expect("transaction signed event");
+ let mut transaction = outbox.pool().begin().await.expect("begin transaction");
+ outbox
+ .enqueue_signed_operation_in_transaction(
+ &mut transaction,
+ signed_operation_input(transaction_draft, transaction_signed, 1_700),
+ )
+ .await
+ .expect("transaction insert without idempotency key");
+ assert!(matches!(
+ claimed_event_identity_tx(&mut transaction, i64::MAX, "missing").await,
+ Err(RadrootsOutboxError::EventNotFound(_))
+ ));
+ assert!(matches!(
+ claimed_event_identity_tx(
+ &mut transaction,
+ signed_receipt.outbox_event_id,
+ "wrong-token",
+ )
+ .await,
+ Err(RadrootsOutboxError::ClaimTokenMismatch { .. })
+ ));
+ assert!(matches!(
+ claimed_event_from_tx(
+ &mut transaction,
+ signed_receipt.outbox_event_id,
+ RadrootsOutboxEventState::Publishing,
+ None,
+ "unused-token",
+ )
+ .await,
+ Err(RadrootsOutboxError::MissingActiveDeliveryPlan { .. })
+ ));
+ transaction.commit().await.expect("commit transaction");
+
+ let event = outbox
+ .get_event(signed_receipt.outbox_event_id)
+ .await
+ .expect("event query")
+ .expect("event");
+ let plans = outbox
+ .delivery_plans(signed_receipt.outbox_event_id)
+ .await
+ .expect("plans");
+ let targets = outbox
+ .delivery_targets(signed_receipt.outbox_event_id)
+ .await
+ .expect("targets");
+ assert!(reticulum_event_record(None, targets.clone()).is_none());
+ assert!(reticulum_event_record(Some(event.clone()), Vec::new()).is_none());
+ assert!(reticulum_event_record(Some(event.clone()), targets.clone()).is_some());
+ assert_eq!(
+ outbox_satisfied_target_count(
+ &RadrootsTransportSatisfactionPolicy::no_wait(),
+ &targets,
+ ),
+ 0
+ );
+ assert_eq!(
+ outbox_satisfied_target_count(
+ &RadrootsTransportSatisfactionPolicy::any_accepted(),
+ &targets,
+ ),
+ 0
+ );
+ let mut accepted_target = targets[0].clone();
+ accepted_target.status = RadrootsOutboxDeliveryTargetStatus::Accepted;
+ assert_eq!(
+ outbox_satisfied_target_count(
+ &RadrootsTransportSatisfactionPolicy::required_targets(
+ RadrootsTransportSatisfactionClass::Accepted,
+ vec![accepted_target.endpoint_fingerprint.clone()],
+ )
+ .expect("required-target policy"),
+ &[accepted_target],
+ ),
+ 1
+ );
+
+ sqlx::query("PRAGMA ignore_check_constraints = ON")
+ .execute(outbox.pool())
+ .await
+ .expect("disable check constraints for defensive decoding");
+
+ sqlx::query("UPDATE outbox_operations SET trade_id = 'invalid' WHERE operation_id = ?")
+ .bind(trade_operation.operation_id)
+ .execute(outbox.pool())
+ .await
+ .expect("corrupt trade id");
+ assert!(
+ outbox
+ .get_operation(trade_operation.operation_id)
+ .await
+ .is_err()
+ );
+ sqlx::query("UPDATE outbox_operations SET trade_id = ?, mutation_id = 'invalid' WHERE operation_id = ?")
+ .bind(canonical.envelope.trade_id.as_str())
+ .bind(trade_operation.operation_id)
+ .execute(outbox.pool())
+ .await
+ .expect("restore trade id and corrupt mutation id");
+ assert!(
+ outbox
+ .get_operation(trade_operation.operation_id)
+ .await
+ .is_err()
+ );
+ sqlx::query("UPDATE outbox_operations SET mutation_id = ? WHERE operation_id = ?")
+ .bind(canonical.mutation_id.as_str())
+ .bind(trade_operation.operation_id)
+ .execute(outbox.pool())
+ .await
+ .expect("restore mutation id");
+
+ sqlx::query("UPDATE outbox_event SET raw_event_json = NULL WHERE outbox_event_id = ?")
+ .bind(signed_receipt.outbox_event_id)
+ .execute(outbox.pool())
+ .await
+ .expect("remove raw signed event JSON");
+ assert!(matches!(
+ outbox.get_event(signed_receipt.outbox_event_id).await,
+ Err(RadrootsOutboxError::StoredSignedEventMissingRawJson(_))
+ ));
+ sqlx::query("UPDATE outbox_event SET raw_event_json = ?, signed_event_json = NULL WHERE outbox_event_id = ?")
+ .bind(signed_event.raw_json())
+ .bind(signed_receipt.outbox_event_id)
+ .execute(outbox.pool())
+ .await
+ .expect("restore raw JSON and remove signed wire JSON");
+ assert!(matches!(
+ outbox.get_event(signed_receipt.outbox_event_id).await,
+ Err(RadrootsOutboxError::StoredRawEventMissingSignedEvent(_))
+ ));
+ sqlx::query(
+ "UPDATE outbox_event SET signed_event_json = ?, event_id = ? WHERE outbox_event_id = ?",
+ )
+ .bind(signed_event_wire_json(&signed_event).expect("signed wire JSON"))
+ .bind(hex_64('0'))
+ .bind(signed_receipt.outbox_event_id)
+ .execute(outbox.pool())
+ .await
+ .expect("restore wire JSON and corrupt event id");
+ assert!(matches!(
+ outbox.get_event(signed_receipt.outbox_event_id).await,
+ Err(RadrootsOutboxError::SignedEventIdMismatch { .. })
+ ));
+ sqlx::query("UPDATE outbox_event SET event_id = ? WHERE outbox_event_id = ?")
+ .bind(event.event_id.as_str())
+ .bind(signed_receipt.outbox_event_id)
+ .execute(outbox.pool())
+ .await
+ .expect("restore event id");
+
+ sqlx::query("UPDATE outbox_delivery_plan SET satisfaction_policy = 'invalid' WHERE delivery_plan_id = ?")
+ .bind(plans[0].delivery_plan_id)
+ .execute(outbox.pool())
+ .await
+ .expect("corrupt satisfaction policy");
+ assert!(
+ outbox
+ .delivery_plans(signed_receipt.outbox_event_id)
+ .await
+ .is_err()
+ );
+ sqlx::query("UPDATE outbox_delivery_plan SET satisfaction_policy = ?, target_policy_version = -1 WHERE delivery_plan_id = ?")
+ .bind(satisfaction_policy_storage_value(&plans[0].satisfaction_policy))
+ .bind(plans[0].delivery_plan_id)
+ .execute(outbox.pool())
+ .await
+ .expect("restore policy and corrupt version");
+ assert!(
+ outbox
+ .delivery_plans(signed_receipt.outbox_event_id)
+ .await
+ .is_err()
+ );
+ sqlx::query(
+ "UPDATE outbox_delivery_plan SET target_policy_version = ? WHERE delivery_plan_id = ?",
+ )
+ .bind(i64::from(plans[0].target_policy_version))
+ .bind(plans[0].delivery_plan_id)
+ .execute(outbox.pool())
+ .await
+ .expect("restore target policy version");
+
+ sqlx::query("UPDATE outbox_delivery_target SET endpoint_fingerprint = 'invalid' WHERE delivery_target_id = ?")
+ .bind(targets[0].delivery_target_id)
+ .execute(outbox.pool())
+ .await
+ .expect("corrupt target fingerprint");
+ assert!(
+ outbox
+ .delivery_targets(signed_receipt.outbox_event_id)
+ .await
+ .is_err()
+ );
+ sqlx::query("UPDATE outbox_delivery_target SET endpoint_fingerprint = ? WHERE delivery_target_id = ?")
+ .bind(targets[0].endpoint_fingerprint.as_str())
+ .bind(targets[0].delivery_target_id)
+ .execute(outbox.pool())
+ .await
+ .expect("restore target fingerprint");
+
+ let attempt = sqlx::query(
+ "INSERT INTO outbox_delivery_attempt(delivery_plan_id, delivery_target_id, status, outcome_kind, attempted_at_ms) VALUES (?, ?, 'accepted', 'accepted', ?)",
+ )
+ .bind(plans[0].delivery_plan_id)
+ .bind(targets[0].delivery_target_id)
+ .bind(2_000_i64)
+ .execute(outbox.pool())
+ .await
+ .expect("insert delivery attempt");
+ sqlx::query("UPDATE outbox_delivery_attempt SET outcome_kind = 'invalid' WHERE delivery_attempt_id = ?")
+ .bind(attempt.last_insert_rowid())
+ .execute(outbox.pool())
+ .await
+ .expect("corrupt attempt outcome");
+ assert!(
+ outbox
+ .delivery_attempts(targets[0].delivery_target_id)
+ .await
+ .is_err()
+ );
+
+ sqlx::query("PRAGMA ignore_check_constraints = OFF")
+ .execute(outbox.pool())
+ .await
+ .expect("restore check constraints");
+ }
+
+ #[tokio::test]
+ async fn signed_plan_lifecycle_and_reticulum_limit_cover_boundary_states() {
+ let outbox = RadrootsOutbox::open_memory().await.expect("open");
+ assert!(matches!(
+ outbox.reticulum_events(None, usize::MAX).await,
+ Err(RadrootsOutboxError::IntegerRange {
+ field: "reticulum_events.limit",
+ ..
+ })
+ ));
+
+ let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "plan lifecycle");
+ let signed_event =
+ radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("sign plan lifecycle");
+ let first = outbox
+ .enqueue_signed_operation(
+ RadrootsOutboxSignedOperationInput::new(
+ "publish_post",
+ draft.clone(),
+ signed_event.clone(),
+ delivery_plan(vec![nostr_target("wss://plan-one.example")]),
+ true,
+ 1_007,
+ 1_000,
+ )
+ .with_idempotency_key("plan-lifecycle"),
+ )
+ .await
+ .expect("first plan");
+ outbox
+ .enqueue_signed_operation(
+ RadrootsOutboxSignedOperationInput::new(
+ "publish_post",
+ draft,
+ signed_event,
+ delivery_plan(vec![nostr_target("wss://plan-two.example")]),
+ true,
+ 1_107,
+ 1_100,
+ )
+ .with_idempotency_key("plan-lifecycle"),
+ )
+ .await
+ .expect("second plan");
+ let plans = outbox
+ .delivery_plans(first.outbox_event_id)
+ .await
+ .expect("plans");
+ assert_eq!(plans.len(), 2);
+
+ let mut transaction = outbox.pool().begin().await.expect("begin lifecycle");
+ sqlx::query(
+ "UPDATE outbox_delivery_plan SET status = 'complete' WHERE delivery_plan_id = ?",
+ )
+ .bind(plans[0].delivery_plan_id)
+ .execute(&mut *transaction)
+ .await
+ .expect("complete first plan");
+ sqlx::query(
+ "UPDATE outbox_delivery_plan SET status = 'cancelled' WHERE delivery_plan_id = ?",
+ )
+ .bind(plans[1].delivery_plan_id)
+ .execute(&mut *transaction)
+ .await
+ .expect("cancel second plan");
+ assert_eq!(
+ signed_event_lifecycle_for_plans(&mut transaction, first.outbox_event_id)
+ .await
+ .expect("mixed lifecycle"),
+ (
+ RadrootsOutboxEventState::Signed,
+ RadrootsOutboxOperationStatus::Queued,
+ )
+ );
+ sqlx::query(
+ "UPDATE outbox_delivery_plan SET status = 'cancelled' WHERE outbox_event_id = ?",
+ )
+ .bind(first.outbox_event_id)
+ .execute(&mut *transaction)
+ .await
+ .expect("cancel all plans");
+ assert_eq!(
+ signed_event_lifecycle_for_plans(&mut transaction, first.outbox_event_id)
+ .await
+ .expect("cancelled lifecycle"),
+ (
+ RadrootsOutboxEventState::Cancelled,
+ RadrootsOutboxOperationStatus::Cancelled,
+ )
+ );
+ sqlx::query("DELETE FROM outbox_delivery_plan WHERE outbox_event_id = ?")
+ .bind(first.outbox_event_id)
+ .execute(&mut *transaction)
+ .await
+ .expect("delete plans");
+ assert_eq!(
+ signed_event_lifecycle_for_plans(&mut transaction, first.outbox_event_id)
+ .await
+ .expect("empty lifecycle"),
+ (
+ RadrootsOutboxEventState::Signed,
+ RadrootsOutboxOperationStatus::Queued,
+ )
+ );
+ transaction.rollback().await.expect("rollback lifecycle");
+ }
+
+ #[tokio::test]
+ async fn operation_and_delivery_plan_idempotency_are_split() {
+ let outbox = RadrootsOutbox::open_memory().await.expect("open");
+ let draft = post_draft(hex_64('a').as_str(), "hello");
+ let first = outbox
+ .enqueue_operation(operation_input(draft.clone(), 1_000).with_idempotency_key("idem-a"))
+ .await
+ .expect("first");
+ let same_plan = outbox
+ .enqueue_operation(operation_input(draft.clone(), 1_100).with_idempotency_key("idem-a"))
+ .await
+ .expect("same");
+ let new_plan = outbox
+ .enqueue_operation(
+ RadrootsOutboxOperationInput::new(
+ "publish_post",
+ draft.clone(),
+ delivery_plan(vec![nostr_target("wss://relay-3.example.com")]),
+ 1_200,
+ )
+ .with_idempotency_key("idem-a"),
+ )
+ .await
+ .expect("new plan");
+
+ assert_eq!(first.status, RadrootsOutboxEnqueueStatus::Inserted);
+ assert_eq!(same_plan.status, RadrootsOutboxEnqueueStatus::Existing);
+ assert_eq!(new_plan.status, RadrootsOutboxEnqueueStatus::Inserted);
+ assert_eq!(first.operation_id, same_plan.operation_id);
+ assert_eq!(first.operation_id, new_plan.operation_id);
+ assert_eq!(first.outbox_event_id, new_plan.outbox_event_id);
+ assert_eq!(
+ first.operation_idempotency_digest,
+ new_plan.operation_idempotency_digest
+ );
+ assert_ne!(
+ first.delivery_plan_idempotency_digest,
+ new_plan.delivery_plan_idempotency_digest
+ );
+ assert_eq!(table_count(&outbox, "outbox_operations").await, 1);
+ assert_eq!(table_count(&outbox, "outbox_event").await, 1);
+ assert_eq!(table_count(&outbox, "outbox_delivery_plan").await, 2);
+
+ let conflict = outbox
+ .enqueue_operation(
+ operation_input(post_draft(hex_64('a').as_str(), "changed"), 1_300)
+ .with_idempotency_key("idem-a"),
+ )
+ .await
+ .expect_err("conflict");
+ assert!(matches!(
+ conflict,
+ RadrootsOutboxError::IdempotencyConflict { .. }
+ ));
+ }
+
+ #[tokio::test]
+ async fn claim_retry_recovery_and_stale_update_guards_cover_worker_lifecycle() {
+ let outbox = RadrootsOutbox::open_memory().await.expect("open");
+ assert!(
+ outbox
+ .claim_next_ready_event("worker", "empty", 100, 0)
+ .await
+ .expect("empty next claim")
+ .is_none()
+ );
+ assert!(
+ outbox
+ .claim_ready_signed_event(99_999, "worker", "missing", 100, 0)
+ .await
+ .expect("missing direct claim")
+ .is_none()
+ );
+
+ let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "retry signing");
+ let receipt = outbox
+ .enqueue_operation(operation_input(draft.clone(), 1_000))
+ .await
+ .expect("enqueue unsigned");
+ let signed =
+ radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("sign queued event");
+ let claimed = outbox
+ .claim_next_ready_event("signer", "sign-claim", 2_000, 1_000)
+ .await
+ .expect("claim unsigned")
+ .expect("unsigned claim");
+ assert_eq!(claimed.outbox_event_id, receipt.outbox_event_id);
+ assert!(matches!(
+ outbox
+ .complete_signing(
+ receipt.outbox_event_id,
+ "wrong-claim",
+ signed.clone(),
+ 1_010,
+ )
+ .await,
+ Err(RadrootsOutboxError::ClaimTokenMismatch { .. })
+ ));
+
+ let other_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "different event");
+ let other_signed = radroots_nostr_sign_frozen_draft(&fixture_keys(), &other_draft)
+ .expect("sign different event");
+ assert!(matches!(
+ outbox
+ .complete_signing(receipt.outbox_event_id, "sign-claim", other_signed, 1_020,)
+ .await,
+ Err(RadrootsOutboxError::SignedEventIdMismatch { .. })
+ ));
+
+ sqlx::query(
+ "CREATE TRIGGER ignore_complete_signing BEFORE UPDATE OF signed_event_json ON outbox_event WHEN OLD.outbox_event_id = 1 AND NEW.signed_event_json IS NOT NULL BEGIN SELECT RAISE(IGNORE); END",
+ )
+ .execute(outbox.pool())
+ .await
+ .expect("complete signing trigger");
+ assert!(matches!(
+ outbox
+ .complete_signing(receipt.outbox_event_id, "sign-claim", signed.clone(), 1_030,)
+ .await,
+ Err(RadrootsOutboxError::ClaimTokenMismatch { .. })
+ ));
+ sqlx::query("DROP TRIGGER ignore_complete_signing")
+ .execute(outbox.pool())
+ .await
+ .expect("drop complete signing trigger");
+
+ outbox
+ .mark_sign_retryable(
+ receipt.outbox_event_id,
+ "sign-claim",
+ "try signing again",
+ 1_040,
+ 1_035,
+ )
+ .await
+ .expect("mark sign retryable");
+ let reclaimed = outbox
+ .claim_next_ready_event("signer", "sign-claim-two", 2_100, 1_040)
+ .await
+ .expect("reclaim unsigned")
+ .expect("reclaimed unsigned");
+ assert_eq!(reclaimed.state, RadrootsOutboxEventState::Signing);
+ outbox
+ .complete_signing(receipt.outbox_event_id, "sign-claim-two", signed, 1_050)
+ .await
+ .expect("complete signing");
+
+ let direct = outbox
+ .claim_ready_signed_event(
+ receipt.outbox_event_id,
+ "publisher",
+ "publish-claim",
+ 2_200,
+ 1_050,
+ )
+ .await
+ .expect("claim signed directly")
+ .expect("direct signed claim");
+ assert_eq!(direct.state, RadrootsOutboxEventState::Publishing);
+ assert!(
+ outbox
+ .claim_ready_signed_event(
+ receipt.outbox_event_id,
+ "publisher",
+ "publish-race",
+ 2_300,
+ 1_060,
+ )
+ .await
+ .expect("stale direct claim")
+ .is_none()
+ );
+ outbox
+ .mark_publish_retryable(
+ receipt.outbox_event_id,
+ "publish-claim",
+ "try publishing again",
+ 1_070,
+ 1_065,
+ )
+ .await
+ .expect("mark publish retryable");
+
+ let expiring = outbox
+ .claim_next_ready_signed_event("publisher", "expiring", 1_080, 1_070)
+ .await
+ .expect("claim expiring")
+ .expect("expiring claim");
+ assert_eq!(expiring.outbox_event_id, receipt.outbox_event_id);
+ assert_eq!(
+ outbox
+ .recover_expired_claims(1_080)
+ .await
+ .expect("recover expired claim"),
+ 1
+ );
+ assert_eq!(
+ outbox
+ .get_event(receipt.outbox_event_id)
+ .await
+ .expect("event after recovery")
+ .expect("event")
+ .state,
+ RadrootsOutboxEventState::PublishRetryable
+ );
+
+ let guarded = outbox
+ .claim_next_ready_signed_event("publisher", "guarded", 2_400, 1_080)
+ .await
+ .expect("claim for guarded retry")
+ .expect("guarded claim");
+ assert_eq!(guarded.outbox_event_id, receipt.outbox_event_id);
+ sqlx::query(
+ "CREATE TRIGGER ignore_publish_retry BEFORE UPDATE OF state ON outbox_event WHEN NEW.state = 'publish_retryable' BEGIN SELECT RAISE(IGNORE); END",
+ )
+ .execute(outbox.pool())
+ .await
+ .expect("publish retry trigger");
+ assert!(matches!(
+ outbox
+ .mark_publish_retryable(
+ receipt.outbox_event_id,
+ "guarded",
+ "ignored update",
+ 2_500,
+ 1_090,
+ )
+ .await,
+ Err(RadrootsOutboxError::ClaimTokenMismatch { .. })
+ ));
+ sqlx::query("DROP TRIGGER ignore_publish_retry")
+ .execute(outbox.pool())
+ .await
+ .expect("drop publish retry trigger");
+ }
+
+ #[tokio::test]
+ async fn claim_compare_and_swap_guards_report_lost_worker_races() {
+ let unsigned_outbox = RadrootsOutbox::open_memory().await.expect("open unsigned");
+ let unsigned_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "unsigned race");
+ unsigned_outbox
+ .enqueue_operation(operation_input(unsigned_draft, 1_000))
+ .await
+ .expect("enqueue unsigned race");
+ sqlx::query(
+ "CREATE TRIGGER ignore_unsigned_claim BEFORE UPDATE OF claim_token ON outbox_event WHEN NEW.claim_token = 'race-next' BEGIN SELECT RAISE(IGNORE); END",
+ )
+ .execute(unsigned_outbox.pool())
+ .await
+ .expect("unsigned claim trigger");
+ assert!(
+ unsigned_outbox
+ .claim_next_ready_event("worker", "race-next", 2_000, 1_000)
+ .await
+ .expect("lost unsigned claim race")
+ .is_none()
+ );
+
+ let signed_outbox = RadrootsOutbox::open_memory().await.expect("open signed");
+ let signed_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "signed race");
+ let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &signed_draft)
+ .expect("sign race event");
+ signed_outbox
+ .enqueue_signed_operation(signed_operation_input(signed_draft, signed_event, 1_000))
+ .await
+ .expect("enqueue signed race");
+ sqlx::query(
+ "CREATE TRIGGER ignore_signed_claim BEFORE UPDATE OF claim_token ON outbox_event WHEN NEW.claim_token = 'race-signed' BEGIN SELECT RAISE(IGNORE); END",
+ )
+ .execute(signed_outbox.pool())
+ .await
+ .expect("signed claim trigger");
+ assert!(
+ signed_outbox
+ .claim_next_ready_signed_event("worker", "race-signed", 2_000, 1_000)
+ .await
+ .expect("lost signed claim race")
+ .is_none()
+ );
+ }
+
+ #[tokio::test]
+ async fn claimed_publish_mutations_enforce_plan_identity_and_atomic_updates() {
+ let outbox = RadrootsOutbox::open_memory().await.expect("open");
+ let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "publish guards");
+ let signed_event =
+ radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("sign publish guards");
+ let receipt = outbox
+ .enqueue_signed_operation(signed_operation_input(draft, signed_event, 1_000))
+ .await
+ .expect("enqueue publish guards");
+ let claimed = outbox
+ .claim_next_ready_signed_event("publisher", "publish-guards", 2_000, 1_000)
+ .await
+ .expect("claim publish guards")
+ .expect("claimed publish guards");
+ let target_id = claimed.delivery_targets[0].delivery_target_id;
+
+ sqlx::query(
+ "CREATE TRIGGER ignore_target_update BEFORE UPDATE OF status ON outbox_delivery_target WHEN NEW.status = 'accepted' BEGIN SELECT RAISE(IGNORE); END",
+ )
+ .execute(outbox.pool())
+ .await
+ .expect("target update trigger");
+ assert!(matches!(
+ outbox
+ .mark_delivery_target_accepted(
+ receipt.outbox_event_id,
+ "publish-guards",
+ target_id,
+ 1_010,
+ )
+ .await,
+ Err(RadrootsOutboxError::DeliveryTargetNotFound(_))
+ ));
+ sqlx::query("DROP TRIGGER ignore_target_update")
+ .execute(outbox.pool())
+ .await
+ .expect("drop target update trigger");
+
+ for (trigger_name, operation) in [
+ ("ignore_complete_publish", "complete"),
+ ("ignore_terminal_publish", "terminal"),
+ ("ignore_cancel_publish", "cancel"),
+ ] {
+ let trigger = format!(
+ "CREATE TRIGGER {trigger_name} BEFORE UPDATE OF state ON outbox_event WHEN OLD.claim_token = 'publish-guards' AND NEW.claim_token IS NULL BEGIN SELECT RAISE(IGNORE); END"
+ );
+ sqlx::query(sqlx::AssertSqlSafe(trigger))
+ .execute(outbox.pool())
+ .await
+ .expect("event update trigger");
+ let error = match operation {
+ "complete" => outbox
+ .complete_publish_attempt(
+ receipt.outbox_event_id,
+ "publish-guards",
+ "retry",
+ "terminal",
+ 1_100,
+ 1_020,
+ )
+ .await
+ .expect_err("ignored complete update"),
+ "terminal" => outbox
+ .mark_active_delivery_plan_failed_terminal(
+ receipt.outbox_event_id,
+ "publish-guards",
+ "terminal",
+ 1_020,
+ )
+ .await
+ .expect_err("ignored terminal update"),
+ "cancel" => outbox
+ .cancel_claimed_event(receipt.outbox_event_id, "publish-guards", 1_020)
+ .await
+ .expect_err("ignored cancel update"),
+ _ => unreachable!(),
+ };
+ assert!(matches!(
+ error,
+ RadrootsOutboxError::ClaimTokenMismatch { .. }
+ ));
+ let drop_trigger = format!("DROP TRIGGER {trigger_name}");
+ sqlx::query(sqlx::AssertSqlSafe(drop_trigger))
+ .execute(outbox.pool())
+ .await
+ .expect("drop event update trigger");
+ }
+
+ sqlx::query("PRAGMA ignore_check_constraints = ON")
+ .execute(outbox.pool())
+ .await
+ .expect("ignore target check constraint");
+ sqlx::query(
+ "UPDATE outbox_delivery_target SET status = 'invalid' WHERE delivery_target_id = ?",
+ )
+ .bind(target_id)
+ .execute(outbox.pool())
+ .await
+ .expect("corrupt target status");
+ assert!(matches!(
+ outbox
+ .mark_delivery_target_accepted(
+ receipt.outbox_event_id,
+ "publish-guards",
+ target_id,
+ 1_030,
+ )
+ .await,
+ Err(RadrootsOutboxError::InvalidStoredEnum { .. })
+ ));
+ sqlx::query(
+ "UPDATE outbox_delivery_target SET status = 'pending' WHERE delivery_target_id = ?",
+ )
+ .bind(target_id)
+ .execute(outbox.pool())
+ .await
+ .expect("restore target status");
+ sqlx::query("PRAGMA ignore_check_constraints = OFF")
+ .execute(outbox.pool())
+ .await
+ .expect("restore target check constraints");
+
+ sqlx::query(
+ "UPDATE outbox_event SET active_delivery_plan_id = NULL WHERE outbox_event_id = ?",
+ )
+ .bind(receipt.outbox_event_id)
+ .execute(outbox.pool())
+ .await
+ .expect("remove active plan");
+ assert!(matches!(
+ outbox
+ .complete_publish_attempt(
+ receipt.outbox_event_id,
+ "publish-guards",
+ "retry",
+ "terminal",
+ 1_100,
+ 1_040,
+ )
+ .await,
+ Err(RadrootsOutboxError::MissingActiveDeliveryPlan { .. })
+ ));
+ assert!(matches!(
+ outbox
+ .mark_active_delivery_plan_failed_terminal(
+ receipt.outbox_event_id,
+ "publish-guards",
+ "terminal",
+ 1_040,
+ )
+ .await,
+ Err(RadrootsOutboxError::MissingActiveDeliveryPlan { .. })
+ ));
+ assert!(matches!(
+ outbox
+ .mark_delivery_target_accepted(
+ receipt.outbox_event_id,
+ "publish-guards",
+ target_id,
+ 1_040,
+ )
+ .await,
+ Err(RadrootsOutboxError::MissingActiveDeliveryPlan { .. })
+ ));
+
+ assert!(matches!(
+ outbox
+ .mark_sign_retryable(99_999, "missing", "missing", 1_100, 1_050)
+ .await,
+ Err(RadrootsOutboxError::EventNotFound(_))
+ ));
+ assert!(matches!(
+ outbox
+ .mark_sign_retryable(
+ receipt.outbox_event_id,
+ "wrong-token",
+ "wrong token",
+ 1_100,
+ 1_050,
+ )
+ .await,
+ Err(RadrootsOutboxError::ClaimTokenMismatch { .. })
+ ));
+ }
+
+ #[tokio::test]
+ async fn generic_enqueue_rejects_trade_mutation_drafts_before_persistence() {
+ let outbox = RadrootsOutbox::open_memory().await.expect("open");
+ let canonical = canonical_trade_proposal();
+ let (draft, signed_event) = signed_trade_mutation(&canonical);
+
+ let unsigned_err = outbox
+ .enqueue_operation(RadrootsOutboxOperationInput::new(
+ "publish_trade_mutation",
+ draft.clone(),
+ delivery_plan(vec![nostr_target(NOSTR_PRIMARY_WSS)]),
+ 1_000,
+ ))
+ .await
+ .expect_err("generic unsigned trade rejection");
+ assert!(matches!(
+ unsigned_err,
+ RadrootsOutboxError::TradeMutationRequiresSemanticOutbox
+ ));
+
+ let signed_err = outbox
+ .enqueue_signed_operation(RadrootsOutboxSignedOperationInput::new(
+ "publish_trade_mutation",
+ draft,
+ signed_event,
+ delivery_plan(vec![nostr_target(NOSTR_PRIMARY_WSS)]),
+ true,
+ 1_007,
+ 1_000,
+ ))
+ .await
+ .expect_err("generic signed trade rejection");
+ assert!(matches!(
+ signed_err,
+ RadrootsOutboxError::TradeMutationRequiresSemanticOutbox
+ ));
+ assert_eq!(table_count(&outbox, "outbox_operations").await, 0);
+ assert_eq!(table_count(&outbox, "outbox_event").await, 0);
+ assert_eq!(table_count(&outbox, "outbox_delivery_plan").await, 0);
+ }
+
+ #[tokio::test]
async fn semantic_trade_mutation_enqueue_persists_metadata_and_deduplicates_by_mutation() {
let outbox = RadrootsOutbox::open_memory().await.expect("open");
let canonical = canonical_trade_proposal();
@@ -4033,6 +5233,169 @@ mod tests {
}
#[tokio::test]
+ async fn unsigned_trade_and_defensive_idempotency_paths_preserve_semantic_identity() {
+ let outbox = RadrootsOutbox::open_memory().await.expect("open");
+ let canonical = canonical_trade_proposal();
+ let (draft, signed_event) = signed_trade_mutation(&canonical);
+ let unsigned = |uri: &str, created_at_ms| {
+ trade_mutation_input(
+ &canonical,
+ draft.clone(),
+ vec![nostr_target(uri)],
+ created_at_ms,
+ )
+ .with_idempotency_key("unsigned-trade-idem")
+ };
+ let signed = |uri: &str, created_at_ms| {
+ signed_trade_mutation_input(
+ &canonical,
+ draft.clone(),
+ signed_event.clone(),
+ vec![nostr_target(uri)],
+ created_at_ms,
+ )
+ .with_idempotency_key("unsigned-trade-idem")
+ };
+
+ let first = outbox
+ .enqueue_trade_mutation_operation(unsigned("wss://unsigned-one.example", 1_000))
+ .await
+ .expect("unsigned insert");
+ let existing = outbox
+ .enqueue_trade_mutation_operation(unsigned("wss://unsigned-one.example", 1_100))
+ .await
+ .expect("unsigned existing plan");
+ let inserted_plan = outbox
+ .enqueue_trade_mutation_operation(unsigned("wss://unsigned-two.example", 1_200))
+ .await
+ .expect("unsigned new plan");
+ assert_eq!(existing.status, RadrootsOutboxEnqueueStatus::Existing);
+ assert_eq!(inserted_plan.status, RadrootsOutboxEnqueueStatus::Inserted);
+
+ outbox
+ .preflight_signed_trade_mutation_idempotency(&signed(
+ "wss://unsigned-three.example",
+ 1_300,
+ ))
+ .await
+ .expect("matching trade preflight");
+
+ sqlx::query("UPDATE outbox_operations SET mutation_id = ? WHERE operation_id = ?")
+ .bind("stored-other-mutation")
+ .bind(first.operation_id)
+ .execute(outbox.pool())
+ .await
+ .expect("move stored mutation identity");
+ let idempotent_existing = outbox
+ .enqueue_trade_mutation_operation(unsigned("wss://unsigned-one.example", 1_400))
+ .await
+ .expect("unsigned idempotent existing plan");
+ let idempotent_inserted = outbox
+ .enqueue_trade_mutation_operation(unsigned("wss://unsigned-four.example", 1_500))
+ .await
+ .expect("unsigned idempotent new plan");
+ assert_eq!(
+ idempotent_existing.status,
+ RadrootsOutboxEnqueueStatus::Existing
+ );
+ assert_eq!(
+ idempotent_inserted.status,
+ RadrootsOutboxEnqueueStatus::Inserted
+ );
+
+ sqlx::query(
+ "UPDATE outbox_operations SET operation_idempotency_digest = 'corrupt' WHERE operation_id = ?",
+ )
+ .bind(first.operation_id)
+ .execute(outbox.pool())
+ .await
+ .expect("corrupt stored digest");
+ assert!(matches!(
+ outbox
+ .enqueue_trade_mutation_operation(unsigned(
+ "wss://unsigned-conflict.example",
+ 1_600,
+ ))
+ .await,
+ Err(RadrootsOutboxError::IdempotencyConflict { .. })
+ ));
+ assert!(matches!(
+ outbox
+ .preflight_signed_trade_mutation_idempotency(&signed(
+ "wss://signed-preflight-conflict.example",
+ 1_700,
+ ))
+ .await,
+ Err(RadrootsOutboxError::IdempotencyConflict { .. })
+ ));
+ assert!(matches!(
+ outbox
+ .enqueue_signed_trade_mutation_operation(signed(
+ "wss://signed-idempotent-conflict.example",
+ 1_800,
+ ))
+ .await,
+ Err(RadrootsOutboxError::IdempotencyConflict { .. })
+ ));
+
+ sqlx::query(
+ "UPDATE outbox_operations SET operation_idempotency_digest = ? WHERE operation_id = ?",
+ )
+ .bind(first.operation_idempotency_digest.as_str())
+ .bind(first.operation_id)
+ .execute(outbox.pool())
+ .await
+ .expect("restore stored digest");
+ let signed_idempotent = outbox
+ .enqueue_signed_trade_mutation_operation(signed(
+ "wss://signed-idempotent.example",
+ 1_900,
+ ))
+ .await
+ .expect("signed idempotent branch");
+ assert_eq!(
+ signed_idempotent.status,
+ RadrootsOutboxEnqueueStatus::Inserted
+ );
+
+ sqlx::query(
+ "UPDATE outbox_operations SET mutation_id = ?, operation_idempotency_digest = 'corrupt' WHERE operation_id = ?",
+ )
+ .bind(canonical.mutation_id.as_str())
+ .bind(first.operation_id)
+ .execute(outbox.pool())
+ .await
+ .expect("restore mutation and corrupt digest");
+ assert!(matches!(
+ outbox
+ .enqueue_trade_mutation_operation(unsigned(
+ "wss://unsigned-mutation-conflict.example",
+ 2_000,
+ ))
+ .await,
+ Err(RadrootsOutboxError::IdempotencyConflict { .. })
+ ));
+ assert!(matches!(
+ outbox
+ .preflight_signed_trade_mutation_idempotency(&signed(
+ "wss://signed-mutation-preflight.example",
+ 2_100,
+ ))
+ .await,
+ Err(RadrootsOutboxError::IdempotencyConflict { .. })
+ ));
+ assert!(matches!(
+ outbox
+ .enqueue_signed_trade_mutation_operation(signed(
+ "wss://signed-mutation-conflict.example",
+ 2_200,
+ ))
+ .await,
+ Err(RadrootsOutboxError::IdempotencyConflict { .. })
+ ));
+ }
+
+ #[tokio::test]
async fn semantic_trade_mutation_enqueue_rejects_payload_hash_mismatch() {
let outbox = RadrootsOutbox::open_memory().await.expect("open");
let canonical = canonical_trade_proposal();
diff --git a/crates/trade/src/listing/draft.rs b/crates/trade/src/listing/draft.rs
@@ -56,7 +56,7 @@ impl RadrootsCanonicalListingEdit {
validate_listing_bins(&listing)?;
let public_listing_addr =
- listing_addr(KIND_LISTING, &seller_pubkey, listing.d_tag.as_str())?;
+ listing_addr(KIND_LISTING, &seller_pubkey, listing.d_tag.as_str());
Ok(Self {
listing,
@@ -128,9 +128,9 @@ fn listing_addr(
kind: u32,
seller_pubkey: &RadrootsPublicKey,
d_tag: &str,
-) -> Result<RadrootsListingAddress, RadrootsListingEditError> {
+) -> RadrootsListingAddress {
RadrootsListingAddress::parse(format!("{kind}:{}:{d_tag}", seller_pubkey.as_str()))
- .map_err(RadrootsListingEditError::InvalidListingAddress)
+ .expect("typed listing identity must form a listing address")
}
pub fn canonicalize_listing_edit(
diff --git a/crates/trade/src/listing/mod.rs b/crates/trade/src/listing/mod.rs
@@ -11,7 +11,7 @@ use radroots_event::{
RadrootsAddressableCoordinateParts, RadrootsDTag, RadrootsIdParseError,
RadrootsListingAddress, RadrootsPublicKey,
},
- kinds::{KIND_LISTING, is_listing_kind},
+ kinds::is_listing_kind,
listing::RadrootsListing,
};
use thiserror::Error;
@@ -80,7 +80,7 @@ pub fn parse_listing_address(
let address = RadrootsListingAddress::parse(value)
.map_err(RadrootsListingAddressError::InvalidAddress)?;
let parts = RadrootsAddressableCoordinateParts::parse(address.as_str())
- .map_err(RadrootsListingAddressError::InvalidAddress)?;
+ .expect("typed listing address must contain valid coordinate parts");
ensure_listing_kind(parts.kind)?;
Ok(RadrootsListingAddressParts {
address,
@@ -101,7 +101,6 @@ pub fn parse_public_listing_address(
RadrootsPublicListingAddressError::InvalidListingKind { actual }
}
})?;
- ensure_public_listing_kind(parts.kind)?;
Ok(RadrootsPublicListingAddress {
address: parts.address,
kind: parts.kind,
@@ -118,14 +117,6 @@ fn ensure_listing_kind(kind: u32) -> Result<(), RadrootsListingAddressError> {
Ok(())
}
-#[cfg_attr(coverage_nightly, coverage(off))]
-fn ensure_public_listing_kind(kind: u32) -> Result<(), RadrootsPublicListingAddressError> {
- if kind != KIND_LISTING {
- return Err(RadrootsPublicListingAddressError::InvalidKind { actual: kind });
- }
- Ok(())
-}
-
pub fn parse_listing_event(
event: &RadrootsEventEnvelope,
) -> Result<RadrootsListing, ListingParseError> {
diff --git a/crates/trade/src/listing/validation.rs b/crates/trade/src/listing/validation.rs
@@ -15,7 +15,6 @@ use radroots_event::{
RadrootsListingPublicLocation,
},
location::{has_textual_locality, is_public_geohash5},
- order::RadrootsListingParseError,
trade_validation::RadrootsTradeValidationListingError as TradeListingValidationError,
};
@@ -61,9 +60,7 @@ pub fn validate_listing_event(
}
let listing_addr_raw = format!("{}:{}:{}", event.kind_u32(), seller_pubkey, listing_id);
let listing_addr = RadrootsListingAddress::parse(&listing_addr_raw)
- .map_err(|_| TradeListingValidationError::ParseError {
- error: RadrootsListingParseError::InvalidTag("listing_addr".to_string()),
- })?
+ .expect("validated listing identity must form a listing address")
.into_string();
let title = listing.product.title.trim().to_string();
diff --git a/crates/trade/src/validation_receipt.rs b/crates/trade/src/validation_receipt.rs
@@ -449,7 +449,8 @@ pub fn validator_set_canonical_content(
validator_set: &RadrootsValidatorSetV1,
) -> Result<String, RadrootsValidationReceiptError> {
validator_set.validate()?;
- serde_json::to_string(validator_set).map_err(|_| RadrootsValidationReceiptError::InvalidJson)
+ Ok(serde_json::to_string(validator_set)
+ .expect("validated validator sets contain only serializable contract values"))
}
pub fn validator_set_content_from_str(
@@ -507,13 +508,6 @@ pub fn verify_validator_set_event(
));
}
let address = validator_set_address(event.author(), &validator_set.set_id)?;
- let parts = RadrootsAddressableCoordinateParts::parse(address.as_str())
- .map_err(|_| RadrootsValidationReceiptError::InvalidField("validator_set.address"))?;
- if parts.kind != KIND_VALIDATOR_SET || parts.pubkey != *event.author() {
- return Err(RadrootsValidationReceiptError::InvalidField(
- "validator_set.address",
- ));
- }
Ok(RadrootsVerifiedValidatorSetV1 {
set: validator_set,
event_id: event.id_str().to_owned(),
@@ -1049,7 +1043,7 @@ fn validate_validator_set_address(
field: &'static str,
) -> Result<(), RadrootsValidationReceiptError> {
let parts = RadrootsAddressableCoordinateParts::parse(value.as_str())
- .map_err(|_| RadrootsValidationReceiptError::InvalidField(field))?;
+ .expect("typed addressable coordinates must contain valid coordinate parts");
if parts.kind != KIND_VALIDATOR_SET {
return Err(RadrootsValidationReceiptError::InvalidField(field));
}
@@ -1147,12 +1141,12 @@ mod tests {
validation_receipt_event_build, validation_receipt_from_event,
validation_receipt_public_values_hash_hex, validation_receipt_tags,
validation_receipt_tags_from_tags, validator_set_address, validator_set_canonical_content,
- validator_set_event_build, validator_set_from_event, verify_validation_receipt_event,
- verify_validator_set_event,
+ validator_set_content_from_str, validator_set_event_build, validator_set_from_event,
+ verify_validation_receipt_event, verify_validator_set_event,
};
use radroots_event::{
RadrootsEventEnvelope, RadrootsEventEnvelopeParts,
- ids::RadrootsPublicKey,
+ ids::{RadrootsAddressableCoordinate, RadrootsPublicKey},
kinds::{KIND_TRADE_VALIDATION_RECEIPT, KIND_VALIDATOR_SET},
tags::TAG_D,
};
@@ -1270,6 +1264,23 @@ mod tests {
validation_receipt_event_with_parts(parts.kind, tags, parts.content)
}
+ fn validator_set_event_with_parts(
+ kind: u32,
+ tags: Vec<Vec<String>>,
+ content: String,
+ ) -> RadrootsEventEnvelope {
+ RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts {
+ id: event_id('7'),
+ author: validator_set_author().as_str().to_string(),
+ created_at: 1_700_000_001,
+ kind,
+ tags,
+ content,
+ sig: "f".repeat(128),
+ })
+ .expect("validator set event")
+ }
+
#[test]
fn validation_receipt_labels_cover_all_variants() {
assert_eq!(
@@ -1436,6 +1447,55 @@ mod tests {
}
#[test]
+ fn validator_set_parsing_and_event_verification_reject_each_boundary() {
+ let validator_set = sample_validator_set();
+ let canonical = validator_set_canonical_content(&validator_set).expect("canonical content");
+ let pretty = serde_json::to_string_pretty(&validator_set).expect("pretty content");
+ assert_eq!(
+ validator_set_content_from_str(&pretty),
+ Err(RadrootsValidationReceiptError::NonCanonicalJson)
+ );
+ assert_eq!(
+ validator_set_content_from_str("{"),
+ Err(RadrootsValidationReceiptError::InvalidJson)
+ );
+
+ let parts = validator_set_event_build(&validator_set).expect("validator set parts");
+ let wrong_kind = validator_set_event_with_parts(
+ KIND_TRADE_VALIDATION_RECEIPT,
+ parts.tags.clone(),
+ canonical.clone(),
+ );
+ assert_eq!(
+ verify_validator_set_event(&wrong_kind, None),
+ Err(RadrootsValidationReceiptError::InvalidKind {
+ expected: KIND_VALIDATOR_SET,
+ got: KIND_TRADE_VALIDATION_RECEIPT,
+ })
+ );
+
+ let mut mismatched_tags = parts.tags;
+ mismatched_tags[0][1] = "018f3d99-7d35-7c0c-8a0f-7f3b645abcdf".to_string();
+ let mismatched =
+ validator_set_event_with_parts(KIND_VALIDATOR_SET, mismatched_tags, canonical);
+ assert_eq!(
+ verify_validator_set_event(&mismatched, None),
+ Err(RadrootsValidationReceiptError::TagMismatch(
+ "validator_set.set_id"
+ ))
+ );
+
+ let mut invalid = sample_validator_set();
+ invalid.threshold = 2;
+ assert_eq!(
+ validator_set_event_build(&invalid),
+ Err(RadrootsValidationReceiptError::InvalidField(
+ "validator_set.threshold"
+ ))
+ );
+ }
+
+ #[test]
fn validation_receipt_validate_rejects_core_field_errors() {
let mut receipt = sample_validation_receipt();
receipt.version = 2;
@@ -1554,6 +1614,29 @@ mod tests {
);
let mut receipt = sample_validation_receipt();
+ receipt.statement.validator_set_event_id = "bad".to_string();
+ assert_eq!(
+ receipt.validate(),
+ Err(RadrootsValidationReceiptError::InvalidField(
+ "statement.validator_set_event_id"
+ ))
+ );
+
+ let mut receipt = sample_validation_receipt();
+ receipt.statement.validator_set_addr = RadrootsAddressableCoordinate::parse(format!(
+ "1:{}:{}",
+ validator_set_author(),
+ validator_set_id()
+ ))
+ .expect("typed non-validator address");
+ assert_eq!(
+ receipt.validate(),
+ Err(RadrootsValidationReceiptError::InvalidField(
+ "statement.validator_set_addr"
+ ))
+ );
+
+ let mut receipt = sample_validation_receipt();
receipt.error_bitmap = "0x00000000000000000000000000000001".to_string();
assert_eq!(
receipt.validate(),
@@ -1744,6 +1827,15 @@ mod tests {
))
);
+ let mut duplicate_validator_set_addr = tags.clone();
+ duplicate_validator_set_addr.push(tags[4].clone());
+ assert_eq!(
+ validation_receipt_tags_from_tags(&duplicate_validator_set_addr),
+ Err(RadrootsValidationReceiptError::InvalidTag(
+ TAG_VALIDATION_RECEIPT_VALIDATOR_SET_MARKER
+ ))
+ );
+
let mut invalid_validator_set_event = tags.clone();
invalid_validator_set_event[5][1] = "bad".to_string();
assert_eq!(
@@ -2611,4 +2703,125 @@ mod tests {
Err(RadrootsValidationReceiptError::EmptyField("order_id"))
);
}
+
+ #[test]
+ fn validator_set_validation_covers_every_boundary() {
+ let valid_id = validator_set_id();
+ for variant in ["8", "9", "a", "b"] {
+ let mut value = valid_id.clone();
+ value.replace_range(19..20, variant);
+ assert_eq!(super::validate_uuidv7(&value, "uuid"), Ok(()));
+ }
+ let mut invalid_ids = vec![String::new(), "bad".to_string()];
+ for (range, replacement) in [
+ (8..9, "0"),
+ (13..14, "0"),
+ (18..19, "0"),
+ (23..24, "0"),
+ (14..15, "6"),
+ (19..20, "7"),
+ (0..1, "g"),
+ ] {
+ let mut value = valid_id.clone();
+ value.replace_range(range, replacement);
+ invalid_ids.push(value);
+ }
+ for invalid in invalid_ids {
+ assert!(matches!(
+ super::validate_uuidv7(&invalid, "uuid"),
+ Err(RadrootsValidationReceiptError::EmptyField("uuid"))
+ | Err(RadrootsValidationReceiptError::InvalidField("uuid"))
+ ));
+ }
+
+ let mut validator_set = sample_validator_set();
+ validator_set.threshold = 2;
+ assert_eq!(
+ validator_set.validate(),
+ Err(RadrootsValidationReceiptError::InvalidField(
+ "validator_set.threshold"
+ ))
+ );
+ let mut validator_set = sample_validator_set();
+ validator_set.valid_until = validator_set.valid_from;
+ assert_eq!(
+ validator_set.validate(),
+ Err(RadrootsValidationReceiptError::InvalidField(
+ "validator_set.valid_until"
+ ))
+ );
+ let mut validator_set = sample_validator_set();
+ validator_set.protocol_contract_hash = "bad".to_string();
+ assert_eq!(
+ validator_set.validate(),
+ Err(RadrootsValidationReceiptError::InvalidField(
+ "validator_set.protocol_contract_hash"
+ ))
+ );
+ let mut validator_set = sample_validator_set();
+ validator_set.operator_name = " ".to_string();
+ assert_eq!(
+ validator_set.validate(),
+ Err(RadrootsValidationReceiptError::EmptyField(
+ "validator_set.operator_name"
+ ))
+ );
+ let mut validator_set = sample_validator_set();
+ validator_set.operator_name = "x".repeat(121);
+ assert_eq!(
+ validator_set.validate(),
+ Err(RadrootsValidationReceiptError::InvalidField(
+ "validator_set.operator_name"
+ ))
+ );
+ let mut validator_set = sample_validator_set();
+ validator_set.operator_contact = Some(" ".to_string());
+ assert_eq!(
+ validator_set.validate(),
+ Err(RadrootsValidationReceiptError::EmptyField(
+ "validator_set.operator_contact"
+ ))
+ );
+ let mut validator_set = sample_validator_set();
+ validator_set.operator_contact = Some("x".repeat(241));
+ assert_eq!(
+ validator_set.validate(),
+ Err(RadrootsValidationReceiptError::InvalidField(
+ "validator_set.operator_contact"
+ ))
+ );
+ let mut validator_set = sample_validator_set();
+ validator_set.operator_contact = None;
+ validator_set.validate().expect("contact is optional");
+
+ let address = super::validator_set_address_from_str(validator_set_addr().as_str())
+ .expect("validator set address");
+ assert_eq!(address, validator_set_addr());
+ assert_eq!(
+ super::validator_set_address_from_str("bad"),
+ Err(RadrootsValidationReceiptError::InvalidField(
+ "validator_set.address"
+ ))
+ );
+ let wrong_kind = format!("1:{}:{}", validator_set_author(), validator_set_id());
+ assert_eq!(
+ super::validator_set_address_from_str(wrong_kind),
+ Err(RadrootsValidationReceiptError::InvalidField(
+ "validator_set.address"
+ ))
+ );
+
+ let empty = RadrootsTradeValidationTrustPolicy::production();
+ assert!(!empty.has_validator_set());
+ assert!(!empty.trusts_validator_pubkey(&validator_set_pubkey()));
+ assert_eq!(empty.validator_count(), 0);
+
+ let partial = RadrootsTradeValidationTrustPolicy {
+ validator_set: Some(sample_validator_set()),
+ validator_set_addr: None,
+ validator_set_event_id: Some(event_id('8')),
+ require_cryptographic_proof: false,
+ };
+ assert!(!partial.has_validator_set());
+ }
}
diff --git a/crates/trade/src/workflow.rs b/crates/trade/src/workflow.rs
@@ -377,6 +377,14 @@ struct CandidateRecord {
candidate: RadrootsTradeCandidateTermsV1,
}
+struct DecisionApplication<'a> {
+ mutation_id: &'a RadrootsTradeMutationId,
+ mutation: &'a RadrootsTradeMutationEnvelopeV1,
+ proposal_mutation_id: &'a RadrootsTradeMutationId,
+ candidate_id: &'a RadrootsTradeCandidateId,
+ decision: &'a RadrootsTradeDecisionV1,
+}
+
#[derive(Clone, Debug, PartialEq, Eq)]
struct CancellationRecord {
mutation_id: RadrootsTradeMutationId,
@@ -437,14 +445,11 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra
let mut claims = BTreeMap::<RadrootsTradeMutationId, RadrootsTradeAgreementClaimV1>::new();
let mut decisions_by_proposal =
BTreeMap::<RadrootsTradeMutationId, Vec<RadrootsTradeMutationId>>::new();
- let mut decision_ids = Vec::<RadrootsTradeMutationId>::new();
let mut cancellations = Vec::<CancellationRecord>::new();
let mut referenced_parents = BTreeSet::<RadrootsTradeMutationId>::new();
for (mutation_id, mutation) in &mutations {
- if mutation.parent_mutation_ids.is_empty()
- && matches!(mutation.body, RadrootsTradeMutationBodyV1::Proposal { .. })
- {
+ if matches!(mutation.body, RadrootsTradeMutationBodyV1::Proposal { .. }) {
root_proposals.push(mutation_id.clone());
}
for parent in &mutation.parent_mutation_ids {
@@ -462,31 +467,17 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra
match &mutation.body {
RadrootsTradeMutationBodyV1::Proposal { candidate }
| RadrootsTradeMutationBodyV1::RevisionProposal { candidate } => {
- if let Some(candidate_id) = candidate.candidate_id.clone() {
- candidates_by_proposal.insert(
- mutation_id.clone(),
- CandidateRecord {
- proposal_mutation_id: mutation_id.clone(),
- author_pubkey: mutation.author_pubkey.clone(),
- candidate: candidate.clone(),
- },
- );
- let _ = candidate_id;
- }
- }
- RadrootsTradeMutationBodyV1::Decision {
- proposal_mutation_id,
- candidate_id,
- decision,
- }
- | RadrootsTradeMutationBodyV1::RevisionDecision {
- proposal_mutation_id,
- candidate_id,
- decision,
- } => {
- let _ = (proposal_mutation_id, candidate_id, decision);
- decision_ids.push(mutation_id.clone());
+ candidates_by_proposal.insert(
+ mutation_id.clone(),
+ CandidateRecord {
+ proposal_mutation_id: mutation_id.clone(),
+ author_pubkey: mutation.author_pubkey.clone(),
+ candidate: candidate.clone(),
+ },
+ );
}
+ RadrootsTradeMutationBodyV1::Decision { .. }
+ | RadrootsTradeMutationBodyV1::RevisionDecision { .. } => {}
RadrootsTradeMutationBodyV1::Cancellation {
target_candidate_id,
target_claim_mutation_id,
@@ -500,10 +491,7 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra
}
}
- for mutation_id in decision_ids {
- let Some(mutation) = mutations.get(&mutation_id) else {
- continue;
- };
+ for (mutation_id, mutation) in &mutations {
match &mutation.body {
RadrootsTradeMutationBodyV1::Decision {
proposal_mutation_id,
@@ -520,11 +508,13 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra
.or_default()
.push(mutation_id.clone());
apply_decision(
- &mutation_id,
- mutation,
- proposal_mutation_id,
- candidate_id,
- decision,
+ DecisionApplication {
+ mutation_id,
+ mutation,
+ proposal_mutation_id,
+ candidate_id,
+ decision,
+ },
&candidates_by_proposal,
&mut claims,
&mut projection,
@@ -544,15 +534,14 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra
.push(RadrootsTradeReducerIssueV1::MultipleRootProposals);
} else {
projection.root_mutation_id = root_proposals.first().cloned();
- if let Some(root) = projection
+ let root = projection
.root_mutation_id
.as_ref()
.and_then(|root_id| mutations.get(root_id))
- {
- projection.buyer_pubkey = Some(root.buyer_pubkey.clone());
- projection.seller_pubkey = Some(root.seller_pubkey.clone());
- projection.farm_id = Some(root.farm_id.clone());
- }
+ .expect("root proposal selected from the validated mutation map");
+ projection.buyer_pubkey = Some(root.buyer_pubkey.clone());
+ projection.seller_pubkey = Some(root.seller_pubkey.clone());
+ projection.farm_id = Some(root.farm_id.clone());
}
for (proposal_mutation_id, decision_ids) in &decisions_by_proposal {
@@ -620,13 +609,14 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra
| RadrootsTradePrivateTermsStateV1::CommitmentMismatch
) {
for claim_id in &projection.active_agreement_claim_ids {
- if let Some(claim) = claims.get(claim_id) {
- projection
- .issues
- .push(RadrootsTradeReducerIssueV1::PrivateTermsUnavailable {
- candidate_id: claim.candidate_id.clone(),
- });
- }
+ let claim = claims
+ .get(claim_id)
+ .expect("active agreement identifiers originate from indexed claims");
+ projection
+ .issues
+ .push(RadrootsTradeReducerIssueV1::PrivateTermsUnavailable {
+ candidate_id: claim.candidate_id.clone(),
+ });
}
}
projection.attestations = input.attestations;
@@ -637,15 +627,18 @@ pub fn reduce_trade_records(input: RadrootsTradeReductionInputV1) -> RadrootsTra
}
fn apply_decision(
- mutation_id: &RadrootsTradeMutationId,
- mutation: &RadrootsTradeMutationEnvelopeV1,
- proposal_mutation_id: &RadrootsTradeMutationId,
- candidate_id: &RadrootsTradeCandidateId,
- decision: &RadrootsTradeDecisionV1,
+ application: DecisionApplication<'_>,
candidates_by_proposal: &BTreeMap<RadrootsTradeMutationId, CandidateRecord>,
claims: &mut BTreeMap<RadrootsTradeMutationId, RadrootsTradeAgreementClaimV1>,
projection: &mut RadrootsTradeProjectionV1,
) {
+ let DecisionApplication {
+ mutation_id,
+ mutation,
+ proposal_mutation_id,
+ candidate_id,
+ decision,
+ } = application;
let Some(candidate_record) = candidates_by_proposal.get(proposal_mutation_id) else {
projection
.missing_proposal_ids
@@ -671,10 +664,7 @@ fn apply_decision(
);
return;
}
- if mutation.author_pubkey != mutation.counterparty_pubkey
- && mutation.author_pubkey
- != candidate_record_author_counterparty(candidate_record, mutation)
- {
+ if mutation.author_pubkey != candidate_record_author_counterparty(candidate_record, mutation) {
projection
.issues
.push(RadrootsTradeReducerIssueV1::DecisionAuthorMismatch {
@@ -891,9 +881,9 @@ fn non_dominated_claim_ids(
}
fn compatible_claims(claims: &[&RadrootsTradeAgreementClaimV1]) -> bool {
- let Some(first) = claims.first() else {
- return false;
- };
+ let first = claims
+ .first()
+ .expect("non-empty claims produce at least one non-dominated claim");
claims.iter().all(|claim| {
claim.candidate_id == first.candidate_id
&& claim.reservation_commitment == first.reservation_commitment
@@ -953,9 +943,9 @@ fn reduce_private_terms_state(
.collect::<BTreeMap<_, _>>();
let mut required_states = Vec::new();
for claim in &projection.agreement_claims {
- let Some(candidate_record) = candidates_by_proposal.get(&claim.proposal_mutation_id) else {
- continue;
- };
+ let candidate_record = candidates_by_proposal
+ .get(&claim.proposal_mutation_id)
+ .expect("agreement claims originate from indexed candidates");
let requires_private_terms = candidate_record.candidate.private_terms.is_some()
|| candidate_record
.candidate
@@ -972,20 +962,11 @@ fn reduce_private_terms_state(
}
if required_states.is_empty() {
RadrootsTradePrivateTermsStateV1::NotRequired
- } else if required_states
- .iter()
- .any(|state| *state == RadrootsTradePrivateTermsStateV1::CommitmentMismatch)
- {
+ } else if required_states.contains(&RadrootsTradePrivateTermsStateV1::CommitmentMismatch) {
RadrootsTradePrivateTermsStateV1::CommitmentMismatch
- } else if required_states
- .iter()
- .any(|state| *state == RadrootsTradePrivateTermsStateV1::Undecryptable)
- {
+ } else if required_states.contains(&RadrootsTradePrivateTermsStateV1::Undecryptable) {
RadrootsTradePrivateTermsStateV1::Undecryptable
- } else if required_states
- .iter()
- .any(|state| *state == RadrootsTradePrivateTermsStateV1::Missing)
- {
+ } else if required_states.contains(&RadrootsTradePrivateTermsStateV1::Missing) {
RadrootsTradePrivateTermsStateV1::Missing
} else {
RadrootsTradePrivateTermsStateV1::AvailableVerified
@@ -1074,24 +1055,10 @@ fn set_conflict(
fn projection_digest(projection: &RadrootsTradeProjectionV1) -> String {
let mut digest_input = projection.clone();
digest_input.projection_digest.clear();
- let value = match serde_json::to_value(&digest_input) {
- Ok(value) => value,
- Err(error) => {
- let mut hasher = Sha256::new();
- hasher.update(RADROOTS_TRADE_PROJECTION_DIGEST_DOMAIN);
- hasher.update(error.to_string().as_bytes());
- return hex::encode(hasher.finalize());
- }
- };
- let canonical = match radroots_event::trade::canonical_jcs_value(&value) {
- Ok(canonical) => canonical,
- Err(error) => {
- let mut hasher = Sha256::new();
- hasher.update(RADROOTS_TRADE_PROJECTION_DIGEST_DOMAIN);
- hasher.update(error.to_string().as_bytes());
- return hex::encode(hasher.finalize());
- }
- };
+ let value = serde_json::to_value(&digest_input)
+ .expect("trade projection contains only serializable contract values");
+ let canonical = radroots_event::trade::canonical_jcs_value(&value)
+ .expect("serialized trade projection must be canonicalizable");
let mut hasher = Sha256::new();
hasher.update(RADROOTS_TRADE_PROJECTION_DIGEST_DOMAIN);
hasher.update(canonical.as_bytes());
@@ -1298,7 +1265,8 @@ mod tests {
) -> RadrootsTradeMutationEnvelopeV1 {
let proposal_id = proposal.mutation_id.clone().unwrap();
let candidate = match &proposal.body {
- RadrootsTradeMutationBodyV1::Proposal { candidate } => candidate.clone(),
+ RadrootsTradeMutationBodyV1::Proposal { candidate }
+ | RadrootsTradeMutationBodyV1::RevisionProposal { candidate } => candidate.clone(),
_ => unreachable!(),
};
canonical_trade_mutation_content(RadrootsTradeMutationEnvelopeV1 {
@@ -1432,6 +1400,47 @@ mod tests {
reduce_trade_records(input)
}
+ fn recanonicalize(
+ mut mutation: RadrootsTradeMutationEnvelopeV1,
+ ) -> RadrootsTradeMutationEnvelopeV1 {
+ mutation.mutation_id = None;
+ canonical_trade_mutation_content(mutation)
+ .expect("recanonicalized mutation")
+ .envelope
+ }
+
+ fn candidate_cancellation(
+ root: &RadrootsTradeMutationEnvelopeV1,
+ ) -> RadrootsTradeMutationEnvelopeV1 {
+ let candidate_id = match &root.body {
+ RadrootsTradeMutationBodyV1::Proposal { candidate } => {
+ candidate.candidate_id.clone().expect("candidate id")
+ }
+ _ => unreachable!(),
+ };
+ canonical_trade_mutation_content(RadrootsTradeMutationEnvelopeV1 {
+ mutation_id: None,
+ contract_id: radroots_event::trade::RADROOTS_TRADE_CANCELLATION_CONTRACT_ID.to_string(),
+ schema_version: RADROOTS_TRADE_SCHEMA_VERSION,
+ trade_id: trade_id(),
+ root_mutation_id: Some(root_id(root)),
+ buyer_pubkey: pubkey('a'),
+ seller_pubkey: pubkey('b'),
+ farm_id: dtag("farm-1"),
+ parent_mutation_ids: vec![root_id(root)],
+ author_pubkey: pubkey('a'),
+ counterparty_pubkey: pubkey('b'),
+ authored_at_unix_s: 301,
+ body: RadrootsTradeMutationBodyV1::Cancellation {
+ target_candidate_id: Some(candidate_id),
+ target_claim_mutation_id: None,
+ reason: "cancel before agreement".to_string(),
+ },
+ })
+ .expect("candidate cancellation")
+ .envelope
+ }
+
#[test]
fn reducer_digest_is_independent_of_input_order_and_duplicates() {
let proposal = proposal();
@@ -1617,4 +1626,559 @@ mod tests {
RadrootsTradeAgreementStateV1::None
);
}
+
+ #[test]
+ fn reducer_classifies_malformed_unsupported_and_foreign_records() {
+ let empty = reduce(Vec::new());
+ assert_eq!(
+ empty.negotiation_state,
+ RadrootsTradeNegotiationStateV1::None
+ );
+ assert_eq!(empty.evidence_state, RadrootsTradeEvidenceStateV1::Missing);
+ assert!(
+ empty
+ .issues
+ .contains(&RadrootsTradeReducerIssueV1::MissingRootProposal)
+ );
+
+ let mut missing_id = proposal();
+ missing_id.mutation_id = None;
+ let mut unsupported = proposal();
+ unsupported.schema_version += 1;
+ let unsupported_id = root_id(&unsupported);
+ let mut invalid = proposal();
+ invalid.contract_id = "invalid.contract".to_string();
+ let mut second_root = proposal();
+ second_root.authored_at_unix_s += 1;
+ let second_root = recanonicalize(second_root);
+ let projection = reduce(vec![
+ missing_id.clone(),
+ missing_id,
+ unsupported,
+ invalid,
+ proposal(),
+ second_root,
+ ]);
+ assert!(
+ projection
+ .issues
+ .contains(&RadrootsTradeReducerIssueV1::MissingMutationId)
+ );
+ assert!(projection.issues.iter().any(|issue| matches!(
+ issue,
+ RadrootsTradeReducerIssueV1::UnsupportedSchema { mutation_id, .. }
+ if mutation_id == &unsupported_id
+ )));
+ assert!(
+ projection
+ .issues
+ .iter()
+ .any(|issue| matches!(issue, RadrootsTradeReducerIssueV1::InvalidMutation { .. }))
+ );
+ assert!(
+ projection
+ .issues
+ .contains(&RadrootsTradeReducerIssueV1::MultipleRootProposals)
+ );
+
+ let foreign_trade = RadrootsTradeId::parse(hex_32('2')).expect("foreign trade");
+ let mut input = RadrootsTradeReductionInputV1::new(foreign_trade);
+ input.mutations = vec![record(proposal())];
+ let foreign = reduce_trade_records(input);
+ assert!(foreign.issues.iter().any(|issue| matches!(
+ issue,
+ RadrootsTradeReducerIssueV1::TradeIdentityMismatch { .. }
+ )));
+ }
+
+ #[test]
+ fn reducer_rejects_invalid_decision_relationships() {
+ let root = proposal();
+
+ let missing_proposal = reduce(vec![accepted_decision(&root, '1')]);
+ assert!(
+ missing_proposal
+ .issues
+ .iter()
+ .any(|issue| matches!(issue, RadrootsTradeReducerIssueV1::MissingProposal { .. }))
+ );
+
+ let mut wrong_candidate = accepted_decision(&root, '1');
+ if let RadrootsTradeMutationBodyV1::Decision { candidate_id, .. } =
+ &mut wrong_candidate.body
+ {
+ *candidate_id = RadrootsTradeCandidateId::parse(hex_64('f')).expect("candidate id");
+ }
+ let wrong_candidate = recanonicalize(wrong_candidate);
+ let projection = reduce(vec![root.clone(), wrong_candidate]);
+ assert!(projection.issues.iter().any(|issue| matches!(
+ issue,
+ RadrootsTradeReducerIssueV1::CandidateIdMismatch { .. }
+ )));
+
+ let mut wrong_author = accepted_decision(&root, '2');
+ wrong_author.author_pubkey = wrong_author.buyer_pubkey.clone();
+ wrong_author.counterparty_pubkey = wrong_author.seller_pubkey.clone();
+ let wrong_author = recanonicalize(wrong_author);
+ let projection = reduce(vec![root.clone(), wrong_author]);
+ assert!(projection.issues.iter().any(|issue| matches!(
+ issue,
+ RadrootsTradeReducerIssueV1::DecisionAuthorMismatch { .. }
+ )));
+
+ let mut no_reservation = accepted_decision(&root, '3');
+ if let RadrootsTradeMutationBodyV1::Decision { decision, .. } = &mut no_reservation.body {
+ *decision = RadrootsTradeDecisionV1::Accepted {
+ reservation_assertion: None,
+ };
+ }
+ let no_reservation = recanonicalize(no_reservation);
+ let projection = reduce(vec![root, no_reservation]);
+ assert!(projection.issues.iter().any(|issue| matches!(
+ issue,
+ RadrootsTradeReducerIssueV1::MissingSellerReservation { .. }
+ )));
+ }
+
+ #[test]
+ fn reducer_rejects_every_reservation_mismatch() {
+ let root = proposal();
+ let mut mismatched = accepted_decision(&root, '4');
+ if let RadrootsTradeMutationBodyV1::Decision {
+ decision:
+ RadrootsTradeDecisionV1::Accepted {
+ reservation_assertion: Some(reservation),
+ },
+ ..
+ } = &mut mismatched.body
+ {
+ reservation.candidate_id =
+ RadrootsTradeCandidateId::parse(hex_64('f')).expect("candidate id");
+ reservation.inventory_authority_id = pubkey('a');
+ reservation.commitments[0].unit_code = "kg".to_string();
+ }
+ let mismatched = recanonicalize(mismatched);
+ let projection = reduce(vec![root.clone(), mismatched]);
+ assert!(projection.issues.iter().any(|issue| matches!(
+ issue,
+ RadrootsTradeReducerIssueV1::ReservationCandidateMismatch { .. }
+ )));
+ assert!(projection.issues.iter().any(|issue| matches!(
+ issue,
+ RadrootsTradeReducerIssueV1::ReservationAuthorityMismatch { .. }
+ )));
+ assert!(projection.issues.iter().any(|issue| matches!(
+ issue,
+ RadrootsTradeReducerIssueV1::ReservationLineMismatch { .. }
+ )));
+ assert_eq!(
+ projection.conflict_state,
+ RadrootsTradeConflictStateV1::InventoryAuthorityConflict
+ );
+
+ let mut wrong_count = accepted_decision(&root, '5');
+ if let RadrootsTradeMutationBodyV1::Decision {
+ decision:
+ RadrootsTradeDecisionV1::Accepted {
+ reservation_assertion: Some(reservation),
+ },
+ ..
+ } = &mut wrong_count.body
+ {
+ let mut extra = reservation.commitments[0].clone();
+ extra.line_id = dtag("line-2");
+ reservation.commitments.push(extra);
+ }
+ let wrong_count = recanonicalize(wrong_count);
+ let projection = reduce(vec![root, wrong_count]);
+ assert!(projection.issues.iter().any(|issue| matches!(
+ issue,
+ RadrootsTradeReducerIssueV1::ReservationLineMismatch { .. }
+ )));
+ }
+
+ #[test]
+ fn reducer_covers_decision_conflict_and_ordered_cancellation() {
+ let root = proposal();
+ let accepted = accepted_decision(&root, '1');
+ let declined = declined_decision(&root);
+ let conflicted = reduce(vec![root.clone(), accepted.clone(), declined]);
+ assert_eq!(
+ conflicted.conflict_state,
+ RadrootsTradeConflictStateV1::DecisionConflict
+ );
+ assert!(
+ conflicted
+ .issues
+ .iter()
+ .any(|issue| matches!(issue, RadrootsTradeReducerIssueV1::DecisionConflict { .. }))
+ );
+
+ let cancel = cancellation(&root, root_id(&accepted), root_id(&accepted));
+ let cancelled = reduce(vec![root, accepted.clone(), cancel]);
+ assert_eq!(
+ cancelled.agreement_state,
+ RadrootsTradeAgreementStateV1::Cancelled
+ );
+ assert_eq!(cancelled.cancelled_claim_ids, vec![root_id(&accepted)]);
+ }
+
+ #[test]
+ fn reducer_covers_pre_agreement_cancellation_and_requested_evidence() {
+ let root = proposal();
+ let cancel = candidate_cancellation(&root);
+ let cancelled = reduce(vec![root.clone(), cancel]);
+ assert_eq!(
+ cancelled.agreement_state,
+ RadrootsTradeAgreementStateV1::Cancelled
+ );
+
+ let decision = accepted_decision(&root, '1');
+ let mut input = RadrootsTradeReductionInputV1::new(trade_id());
+ input.mutations = vec![record(root), record(decision)];
+ input.evidence_state = RadrootsTradeEvidenceStateV1::QueryPartial;
+ assert_eq!(
+ reduce_trade_records(input).evidence_state,
+ RadrootsTradeEvidenceStateV1::QueryPartial
+ );
+ }
+
+ #[test]
+ fn reducer_covers_private_terms_and_attestation_precedence() {
+ let mut root = proposal();
+ if let RadrootsTradeMutationBodyV1::Proposal { candidate } = &mut root.body {
+ candidate.private_terms = Some(RadrootsTradePrivateTermsRefV1 {
+ artifact_id: "artifact-1".to_string(),
+ schema_id: "radroots.private.fulfillment.v1".to_string(),
+ ciphertext_commitment: hex_64('f'),
+ required_acknowledgement: true,
+ });
+ }
+ let root = recanonicalize(root);
+ let candidate_id = match &root.body {
+ RadrootsTradeMutationBodyV1::Proposal { candidate } => {
+ candidate.candidate_id.clone().expect("candidate id")
+ }
+ _ => unreachable!(),
+ };
+ let decision = accepted_decision(&root, '1');
+ for (state, expected) in [
+ (
+ RadrootsTradePrivateTermsStateV1::AvailableVerified,
+ RadrootsTradePrivateTermsStateV1::AvailableVerified,
+ ),
+ (
+ RadrootsTradePrivateTermsStateV1::Undecryptable,
+ RadrootsTradePrivateTermsStateV1::Undecryptable,
+ ),
+ (
+ RadrootsTradePrivateTermsStateV1::CommitmentMismatch,
+ RadrootsTradePrivateTermsStateV1::CommitmentMismatch,
+ ),
+ ] {
+ let mut input = RadrootsTradeReductionInputV1::new(trade_id());
+ input.mutations = vec![record(root.clone()), record(decision.clone())];
+ input.private_terms = vec![RadrootsTradePrivateTermsEvidenceV1 {
+ candidate_id: candidate_id.clone(),
+ state,
+ }];
+ assert_eq!(reduce_trade_records(input).private_terms_state, expected);
+ }
+
+ for (results, expected) in [
+ (
+ vec![RadrootsTradeAttestationResultV1::Valid],
+ RadrootsTradeAttestationStateV1::PresentValid,
+ ),
+ (
+ vec![
+ RadrootsTradeAttestationResultV1::Valid,
+ RadrootsTradeAttestationResultV1::Invalid,
+ ],
+ RadrootsTradeAttestationStateV1::Conflicting,
+ ),
+ ] {
+ let mut input = RadrootsTradeReductionInputV1::new(trade_id());
+ input.mutations = vec![record(root.clone()), record(decision.clone())];
+ input.attestations = results
+ .into_iter()
+ .enumerate()
+ .map(|(index, result)| RadrootsTradeAttestationRecordV1 {
+ event_id: event_id(if index == 0 { '8' } else { '9' }),
+ claim_mutation_id: root_id(&decision),
+ result,
+ })
+ .collect();
+ assert_eq!(reduce_trade_records(input).attestation_state, expected);
+ }
+ }
+
+ #[test]
+ fn reducer_private_helpers_cover_empty_graph_and_conflict_precedence() {
+ let missing = RadrootsTradeMutationId::parse(hex_64('e')).expect("mutation id");
+ assert!(ancestors_of(&missing, &BTreeMap::new(), &mut BTreeMap::new()).is_empty());
+
+ let mut conflict = RadrootsTradeConflictStateV1::DecisionConflict;
+ set_conflict(
+ &mut conflict,
+ RadrootsTradeConflictStateV1::ConcurrentCandidates,
+ );
+ assert_eq!(conflict, RadrootsTradeConflictStateV1::DecisionConflict);
+ set_conflict(
+ &mut conflict,
+ RadrootsTradeConflictStateV1::InvalidCausalChain,
+ );
+ assert_eq!(conflict, RadrootsTradeConflictStateV1::InvalidCausalChain);
+ }
+
+ #[test]
+ fn reducer_rejects_self_identified_decision_author_bypass() {
+ let root = proposal();
+ let mut decision = accepted_decision(&root, '7');
+ decision.author_pubkey = decision.buyer_pubkey.clone();
+ decision.counterparty_pubkey = decision.buyer_pubkey.clone();
+ let decision = recanonicalize(decision);
+
+ let projection = reduce(vec![root, decision]);
+ assert!(projection.issues.iter().any(|issue| matches!(
+ issue,
+ RadrootsTradeReducerIssueV1::DecisionAuthorMismatch { .. }
+ )));
+ }
+
+ #[test]
+ fn reducer_covers_all_decline_and_counterparty_shapes() {
+ let root = proposal();
+ let first = declined_decision(&root);
+ let mut second = first.clone();
+ second.authored_at_unix_s += 1;
+ if let RadrootsTradeMutationBodyV1::Decision {
+ decision: RadrootsTradeDecisionV1::Declined { reason },
+ ..
+ } = &mut second.body
+ {
+ *reason = "still unavailable".to_string();
+ }
+ let second = recanonicalize(second);
+ let projection = reduce(vec![root.clone(), first, second]);
+ assert_eq!(
+ projection.negotiation_state,
+ RadrootsTradeNegotiationStateV1::ClosedDeclined
+ );
+
+ let revision = revision_proposal(&root, vec![root_id(&root)]);
+ let mut revision_decline = declined_decision(&revision);
+ let RadrootsTradeMutationBodyV1::Decision {
+ proposal_mutation_id,
+ candidate_id,
+ decision,
+ } = revision_decline.body
+ else {
+ unreachable!();
+ };
+ revision_decline.contract_id = RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID.to_string();
+ revision_decline.root_mutation_id = Some(root_id(&root));
+ revision_decline.body = RadrootsTradeMutationBodyV1::RevisionDecision {
+ proposal_mutation_id,
+ candidate_id: candidate_id.clone(),
+ decision,
+ };
+ let revision_decline = recanonicalize(revision_decline);
+ assert_eq!(
+ declined_candidate_ids(&BTreeMap::from([(
+ root_id(&revision_decline),
+ revision_decline,
+ )])),
+ vec![candidate_id]
+ );
+
+ let candidate = match &root.body {
+ RadrootsTradeMutationBodyV1::Proposal { candidate } => candidate.clone(),
+ _ => unreachable!(),
+ };
+ let mut candidate_record = CandidateRecord {
+ proposal_mutation_id: root_id(&root),
+ author_pubkey: root.buyer_pubkey.clone(),
+ candidate,
+ };
+ let decision = accepted_decision(&root, '8');
+ assert_eq!(
+ candidate_record_author_counterparty(&candidate_record, &decision),
+ decision.seller_pubkey
+ );
+ candidate_record.author_pubkey = decision.seller_pubkey.clone();
+ assert_eq!(
+ candidate_record_author_counterparty(&candidate_record, &decision),
+ decision.buyer_pubkey
+ );
+ }
+
+ #[test]
+ fn reservation_line_validation_checks_each_field() {
+ let root = proposal();
+ let candidate = match &root.body {
+ RadrootsTradeMutationBodyV1::Proposal { candidate } => candidate.clone(),
+ _ => unreachable!(),
+ };
+ let candidate_id = candidate.candidate_id.clone().expect("candidate id");
+ let decision_mutation_id = root_id(&accepted_decision(&root, '9'));
+
+ for field in 0..5 {
+ let mut reservation = reservation(&candidate, '9');
+ match field {
+ 0 => reservation.commitments[0].line_id = dtag("line-other"),
+ 1 => reservation.commitments[0].bin_id = bin_id("bin-other"),
+ 2 => reservation.commitments[0].quantity_mantissa = "3".to_string(),
+ 3 => reservation.commitments[0].quantity_scale = 1,
+ 4 => reservation.commitments[0].unit_code = "kg".to_string(),
+ _ => unreachable!(),
+ }
+ let mut projection = RadrootsTradeProjectionV1::empty(trade_id());
+ assert!(!validate_reservation(
+ &decision_mutation_id,
+ &candidate_id,
+ &candidate,
+ &reservation,
+ &mut projection,
+ ));
+ assert!(projection.issues.iter().any(|issue| matches!(
+ issue,
+ RadrootsTradeReducerIssueV1::ReservationLineMismatch { .. }
+ )));
+ }
+ }
+
+ #[test]
+ fn agreement_and_cancellation_helpers_cover_nonterminal_shapes() {
+ let root = proposal();
+ let first_decision = accepted_decision(&root, '1');
+ let second_decision = accepted_decision(&root, '2');
+ let first_id = root_id(&first_decision);
+ let second_id = root_id(&second_decision);
+ let candidate = match &root.body {
+ RadrootsTradeMutationBodyV1::Proposal { candidate } => candidate.clone(),
+ _ => unreachable!(),
+ };
+ let candidate_id = candidate.candidate_id.clone().expect("candidate id");
+ let claim = |claim_mutation_id: RadrootsTradeMutationId| RadrootsTradeAgreementClaimV1 {
+ claim_mutation_id,
+ proposal_mutation_id: root_id(&root),
+ candidate_id: candidate_id.clone(),
+ candidate_author_pubkey: root.buyer_pubkey.clone(),
+ accepted_by_pubkey: root.seller_pubkey.clone(),
+ reservation_commitment: hex_64('a'),
+ };
+ let claims = BTreeMap::from([
+ (first_id.clone(), claim(first_id.clone())),
+ (second_id.clone(), claim(second_id.clone())),
+ ]);
+ let mutations = BTreeMap::from([
+ (first_id.clone(), first_decision),
+ (second_id.clone(), second_decision),
+ ]);
+ let missing_claim = RadrootsTradeMutationId::parse(hex_64('e')).expect("missing claim");
+ let cancellations = vec![
+ CancellationRecord {
+ mutation_id: RadrootsTradeMutationId::parse(hex_64('3')).expect("cancellation"),
+ parent_mutation_ids: Vec::new(),
+ target_candidate_id: None,
+ target_claim_mutation_id: None,
+ },
+ CancellationRecord {
+ mutation_id: RadrootsTradeMutationId::parse(hex_64('4')).expect("cancellation"),
+ parent_mutation_ids: Vec::new(),
+ target_candidate_id: None,
+ target_claim_mutation_id: Some(missing_claim),
+ },
+ CancellationRecord {
+ mutation_id: RadrootsTradeMutationId::parse(hex_64('5')).expect("cancellation"),
+ parent_mutation_ids: vec![first_id.clone()],
+ target_candidate_id: None,
+ target_claim_mutation_id: Some(first_id.clone()),
+ },
+ ];
+ let mut projection = RadrootsTradeProjectionV1::empty(trade_id());
+ apply_agreement_state(
+ &mut projection,
+ &claims,
+ &mutations,
+ &BTreeMap::new(),
+ &cancellations,
+ );
+ assert_eq!(
+ projection.agreement_state,
+ RadrootsTradeAgreementStateV1::Agreed
+ );
+ assert_eq!(projection.cancelled_claim_ids, vec![first_id]);
+
+ let mut incompatible_claims = claims.clone();
+ incompatible_claims
+ .get_mut(&second_id)
+ .expect("second claim")
+ .candidate_id = RadrootsTradeCandidateId::parse(hex_64('f')).expect("candidate");
+ let mut incompatible = RadrootsTradeProjectionV1::empty(trade_id());
+ apply_agreement_state(
+ &mut incompatible,
+ &incompatible_claims,
+ &mutations,
+ &BTreeMap::new(),
+ &[],
+ );
+ assert_eq!(
+ incompatible.conflict_state,
+ RadrootsTradeConflictStateV1::DecisionConflict
+ );
+
+ let candidates = BTreeMap::from([(
+ root_id(&root),
+ CandidateRecord {
+ proposal_mutation_id: root_id(&root),
+ author_pubkey: root.author_pubkey.clone(),
+ candidate: candidate.clone(),
+ },
+ )]);
+ let unknown_candidate = RadrootsTradeCandidateId::parse(hex_64('f')).expect("candidate");
+ assert!(!cancellation_without_claim(
+ &[CancellationRecord {
+ mutation_id: RadrootsTradeMutationId::parse(hex_64('6')).expect("cancellation"),
+ parent_mutation_ids: Vec::new(),
+ target_candidate_id: Some(unknown_candidate),
+ target_claim_mutation_id: None,
+ }],
+ &candidates,
+ ));
+ let mut disabled_candidate = candidate;
+ disabled_candidate.cancellation.buyer_pre_agreement = false;
+ let disabled_candidates = BTreeMap::from([(
+ root_id(&root),
+ CandidateRecord {
+ proposal_mutation_id: root_id(&root),
+ author_pubkey: root.author_pubkey.clone(),
+ candidate: disabled_candidate,
+ },
+ )]);
+ assert!(!cancellation_without_claim(
+ &[CancellationRecord {
+ mutation_id: RadrootsTradeMutationId::parse(hex_64('7')).expect("cancellation"),
+ parent_mutation_ids: Vec::new(),
+ target_candidate_id: Some(candidate_id),
+ target_claim_mutation_id: None,
+ }],
+ &disabled_candidates,
+ ));
+ }
+
+ #[test]
+ fn evidence_state_covers_missing_proposal_independently() {
+ let root = proposal();
+ let mut projection = RadrootsTradeProjectionV1::empty(trade_id());
+ projection.root_mutation_id = Some(root_id(&root));
+ projection
+ .missing_proposal_ids
+ .push(RadrootsTradeMutationId::parse(hex_64('e')).expect("proposal"));
+ assert_eq!(
+ reduce_evidence_state(&projection, RadrootsTradeEvidenceStateV1::Complete),
+ RadrootsTradeEvidenceStateV1::Missing
+ );
+ }
}
diff --git a/crates/trade_sp1_guest/src/lib.rs b/crates/trade_sp1_guest/src/lib.rs
@@ -768,17 +768,13 @@ fn validate_addressable_coordinate(
field: &'static str,
) -> Result<(), RadrootsSp1TradeGuestError> {
validate_required_str(value, field)?;
- let mut parts = value.split(':');
- let Some(kind) = parts.next() else {
+ let Some((kind, remainder)) = value.split_once(':') else {
return Err(RadrootsSp1TradeGuestError::InvalidEventEvidence(field));
};
- let Some(pubkey) = parts.next() else {
+ let Some((pubkey, d_tag)) = remainder.split_once(':') else {
return Err(RadrootsSp1TradeGuestError::InvalidEventEvidence(field));
};
- let Some(d_tag) = parts.next() else {
- return Err(RadrootsSp1TradeGuestError::InvalidEventEvidence(field));
- };
- if parts.next().is_some()
+ if d_tag.contains(':')
|| kind.parse::<u32>().is_err()
|| pubkey.len() != 64
|| !is_lower_hex(pubkey)
@@ -1641,6 +1637,20 @@ mod tests {
}
let mut public_values = execution.public_values.clone();
+ public_values.validator_set_addr = None;
+ assert_eq!(
+ canonical_public_values_bytes(&public_values).expect_err("validator set address"),
+ RadrootsSp1TradeGuestError::EmptyField("validator_set_addr")
+ );
+
+ let mut public_values = execution.public_values.clone();
+ public_values.validator_set_event_id = None;
+ assert_eq!(
+ canonical_public_values_bytes(&public_values).expect_err("validator set event id"),
+ RadrootsSp1TradeGuestError::EmptyField("validator_set_event_id")
+ );
+
+ let mut public_values = execution.public_values.clone();
public_values.error_bitmap = "0x1".to_string();
assert_eq!(
canonical_public_values_bytes(&public_values).expect_err("error bitmap"),
@@ -1698,6 +1708,22 @@ mod tests {
.expect_err("upper hex64"),
RadrootsSp1TradeGuestError::InvalidEventEvidence("upper_hex64")
);
+ let pubkey = "a".repeat(64);
+ for coordinate in [
+ "not-a-coordinate".to_string(),
+ "1:missing-d-tag".to_string(),
+ format!("not-a-kind:{pubkey}:d"),
+ "1:short:d".to_string(),
+ format!("1:{}:d", "A".repeat(64)),
+ format!("1:{pubkey}: "),
+ format!("1:{pubkey}:d:extra"),
+ ] {
+ assert_eq!(
+ super::validate_addressable_coordinate(&coordinate, "coordinate")
+ .expect_err("invalid coordinate"),
+ RadrootsSp1TradeGuestError::InvalidEventEvidence("coordinate")
+ );
+ }
assert_eq!(
RadrootsSp1TradeEventWorkflowPosition::Listing.as_str(),
"listing"
diff --git a/crates/transport/src/delivery.rs b/crates/transport/src/delivery.rs
@@ -187,10 +187,7 @@ impl RadrootsTransportSatisfactionPolicy {
&self,
total_targets: usize,
) -> Result<usize, RadrootsTransportError> {
- if matches!(self, Self::NoWait) {
- return Ok(0);
- }
- if total_targets == 0 {
+ if total_targets == 0 && !matches!(self, Self::NoWait) {
return Err(RadrootsTransportError::InvalidSatisfactionPolicy);
}
match self {
diff --git a/crates/transport/src/lib.rs b/crates/transport/src/lib.rs
@@ -1,5 +1,6 @@
#![no_std]
#![forbid(unsafe_code)]
+#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
extern crate alloc;
@@ -46,3 +47,14 @@ pub use transport::{
RadrootsTransport, RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest,
RadrootsTransportFuture,
};
+
+#[cfg(test)]
+extern crate self as radroots_transport;
+
+#[cfg(test)]
+extern crate std;
+
+#[cfg(test)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+#[path = "../tests/transport.rs"]
+mod tests;
diff --git a/crates/transport/src/payload.rs b/crates/transport/src/payload.rs
@@ -28,8 +28,12 @@ impl RadrootsTransportPayload {
event_id: impl AsRef<str>,
raw_json: impl AsRef<str>,
) -> Result<Self, RadrootsTransportError> {
- let event_id = validate_hex_id(event_id.as_ref())?;
- let raw_json = validate_raw_json(raw_json.as_ref())?;
+ Self::signed_event_json(event_id.as_ref(), raw_json.as_ref())
+ }
+
+ fn signed_event_json(event_id: &str, raw_json: &str) -> Result<Self, RadrootsTransportError> {
+ let event_id = validate_hex_id(event_id)?;
+ let raw_json = validate_raw_json(raw_json)?;
let digest = sha256_hex(raw_json.as_bytes());
Ok(Self::SignedEventJson {
event_id,
@@ -43,8 +47,16 @@ impl RadrootsTransportPayload {
raw_json: impl AsRef<str>,
digest: impl AsRef<str>,
) -> Result<Self, RadrootsTransportError> {
- let payload = Self::unchecked_signed_event_json(event_id, raw_json)?;
- validate_supplied_digest(payload.digest(), digest.as_ref())?;
+ Self::signed_event_json_with_digest(event_id.as_ref(), raw_json.as_ref(), digest.as_ref())
+ }
+
+ fn signed_event_json_with_digest(
+ event_id: &str,
+ raw_json: &str,
+ digest: &str,
+ ) -> Result<Self, RadrootsTransportError> {
+ let payload = Self::signed_event_json(event_id, raw_json)?;
+ validate_supplied_digest(payload.digest(), digest)?;
Ok(payload)
}
@@ -52,8 +64,15 @@ impl RadrootsTransportPayload {
message_id: impl AsRef<str>,
bytes: impl AsRef<[u8]>,
) -> Result<Self, RadrootsTransportError> {
- let message_id = validate_token_id(message_id.as_ref())?;
- let bytes = validate_bytes(bytes.as_ref())?;
+ Self::validated_mesh_frame_cbor(message_id.as_ref(), bytes.as_ref())
+ }
+
+ fn validated_mesh_frame_cbor(
+ message_id: &str,
+ bytes: &[u8],
+ ) -> Result<Self, RadrootsTransportError> {
+ let message_id = validate_token_id(message_id)?;
+ let bytes = validate_bytes(bytes)?;
let digest = sha256_hex(bytes.as_slice());
Ok(Self::MeshFrameCbor {
message_id,
@@ -67,8 +86,20 @@ impl RadrootsTransportPayload {
bytes: impl AsRef<[u8]>,
digest: impl AsRef<str>,
) -> Result<Self, RadrootsTransportError> {
- let payload = Self::mesh_frame_cbor(message_id, bytes)?;
- validate_supplied_digest(payload.digest(), digest.as_ref())?;
+ Self::validated_mesh_frame_cbor_with_digest(
+ message_id.as_ref(),
+ bytes.as_ref(),
+ digest.as_ref(),
+ )
+ }
+
+ fn validated_mesh_frame_cbor_with_digest(
+ message_id: &str,
+ bytes: &[u8],
+ digest: &str,
+ ) -> Result<Self, RadrootsTransportError> {
+ let payload = Self::validated_mesh_frame_cbor(message_id, bytes)?;
+ validate_supplied_digest(payload.digest(), digest)?;
Ok(payload)
}
@@ -76,8 +107,12 @@ impl RadrootsTransportPayload {
label: impl AsRef<str>,
bytes: impl AsRef<[u8]>,
) -> Result<Self, RadrootsTransportError> {
- let label = validate_label(label.as_ref())?;
- let bytes = validate_bytes(bytes.as_ref())?;
+ Self::validated_opaque_bytes(label.as_ref(), bytes.as_ref())
+ }
+
+ fn validated_opaque_bytes(label: &str, bytes: &[u8]) -> Result<Self, RadrootsTransportError> {
+ let label = validate_label(label)?;
+ let bytes = validate_bytes(bytes)?;
let digest = sha256_hex(bytes.as_slice());
Ok(Self::OpaqueBytes {
label,
@@ -91,8 +126,16 @@ impl RadrootsTransportPayload {
bytes: impl AsRef<[u8]>,
digest: impl AsRef<str>,
) -> Result<Self, RadrootsTransportError> {
- let payload = Self::opaque_bytes(label, bytes)?;
- validate_supplied_digest(payload.digest(), digest.as_ref())?;
+ Self::validated_opaque_bytes_with_digest(label.as_ref(), bytes.as_ref(), digest.as_ref())
+ }
+
+ fn validated_opaque_bytes_with_digest(
+ label: &str,
+ bytes: &[u8],
+ digest: &str,
+ ) -> Result<Self, RadrootsTransportError> {
+ let payload = Self::validated_opaque_bytes(label, bytes)?;
+ validate_supplied_digest(payload.digest(), digest)?;
Ok(payload)
}
diff --git a/crates/transport/src/target.rs b/crates/transport/src/target.rs
@@ -212,10 +212,7 @@ impl RadrootsTransportTarget {
RadrootsTransportKind::Nostr => RadrootsTransportTargetUri::parse_nostr_relay(raw_uri)?,
_ => RadrootsTransportTargetUri::parse(raw_uri)?,
};
- if kind == RadrootsTransportKind::Reticulum
- && (raw_uri != RADROOTS_RETICULUM_ENDPOINT_URI
- || uri.as_str() != RADROOTS_RETICULUM_ENDPOINT_URI)
- {
+ if kind == RadrootsTransportKind::Reticulum && raw_uri != RADROOTS_RETICULUM_ENDPOINT_URI {
return Err(RadrootsTransportError::InvalidTargetUri);
}
let scope = scope.or_else(|| default_scope_for_kind(&kind));
@@ -381,7 +378,7 @@ fn canonicalize_nostr_relay_authority(
.next()
.map(parse_nostr_relay_port_with_prefix)
.transpose()?;
- if host.is_empty() || !is_valid_nostr_relay_host(host) {
+ if !is_valid_nostr_relay_host(host) {
return Err(RadrootsTransportError::InvalidTargetUri);
}
(host.to_ascii_lowercase(), port)
diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs
@@ -1,6 +1,7 @@
use radroots_transport::{
RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_SCOPE_ID, RadrootsTransport,
- RadrootsTransportCapabilities, RadrootsTransportDeliveryReceipt,
+ RadrootsTransportCapabilities, RadrootsTransportCapabilityAvailability,
+ RadrootsTransportCapabilityMaturity, RadrootsTransportDeliveryReceipt,
RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus,
RadrootsTransportError, RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest,
RadrootsTransportFuture, RadrootsTransportImplementationState, RadrootsTransportKind,
@@ -9,11 +10,16 @@ use radroots_transport::{
RadrootsTransportSatisfactionPolicy, RadrootsTransportStatus, RadrootsTransportTarget,
RadrootsTransportTargetFingerprint, RadrootsTransportTargetLabel,
RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, RadrootsTransportTargetUri,
- ReticulumCapabilityReportV1, ReticulumDuplicateFragmentBehaviorV1,
+ ReticulumCapabilityReportV1, ReticulumDestinationV1, ReticulumDuplicateFragmentBehaviorV1,
ReticulumFragmentIntegrityV1, ReticulumFragmentationModeV1, ReticulumGatewaySemanticsV1,
ReticulumPrivacySemanticsV1,
};
use serde_json::Value;
+use std::borrow::ToOwned;
+use std::boxed::Box;
+use std::string::{String, ToString};
+use std::vec;
+use std::vec::Vec;
fn opaque_payload() -> RadrootsTransportPayload {
RadrootsTransportPayload::opaque_bytes("transport-test-payload", b"transport payload")
@@ -1239,3 +1245,375 @@ fn neutral_transport_trait_covers_status_delivery_and_fetch() {
RadrootsTransportOutcomeKind::Seen
);
}
+
+#[test]
+fn delivery_contract_covers_every_policy_and_receipt_path() {
+ let one = RadrootsTransportTarget::nostr_relay("wss://one.example").expect("one");
+ let two = RadrootsTransportTarget::nostr_relay("wss://two.example").expect("two");
+ let request = RadrootsTransportDeliveryRequest::new(
+ "delivery",
+ opaque_payload(),
+ RadrootsTransportTargetSet::new(vec![one.clone(), two.clone()]).expect("target set"),
+ RadrootsTransportSatisfactionPolicy::all_accepted(),
+ )
+ .with_now_ms(42);
+ assert_eq!(request.now_ms, 42);
+
+ for (policy, class) in [
+ (
+ RadrootsTransportSatisfactionPolicy::any_delivered(),
+ RadrootsTransportSatisfactionClass::Delivered,
+ ),
+ (
+ RadrootsTransportSatisfactionPolicy::quorum_durable_or_observed(2),
+ RadrootsTransportSatisfactionClass::DurableOrObserved,
+ ),
+ ] {
+ assert_eq!(policy.target_satisfaction_class(), Some(class));
+ }
+ for policy in [
+ RadrootsTransportSatisfactionPolicy::no_wait(),
+ RadrootsTransportSatisfactionPolicy::any_accepted(),
+ RadrootsTransportSatisfactionPolicy::all_accepted(),
+ RadrootsTransportSatisfactionPolicy::quorum_accepted(1),
+ ] {
+ assert!(policy.required_target_fingerprints().is_none());
+ }
+
+ let required = RadrootsTransportSatisfactionPolicy::required_targets(
+ RadrootsTransportSatisfactionClass::Accepted,
+ vec![one.fingerprint.clone(), two.fingerprint.clone()],
+ )
+ .expect("required policy");
+ assert_eq!(
+ required
+ .required_target_count(1)
+ .expect_err("required set exceeds total"),
+ RadrootsTransportError::InvalidSatisfactionPolicy
+ );
+
+ let accepted = RadrootsTransportTargetReceipt::new(
+ one.clone(),
+ RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted),
+ );
+ let rejected = RadrootsTransportTargetReceipt::new(
+ two.clone(),
+ RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Rejected),
+ );
+ let receipt = RadrootsTransportDeliveryReceipt {
+ request_id: "delivery".to_owned(),
+ target_receipts: vec![accepted, rejected],
+ };
+ assert!(
+ receipt
+ .is_satisfied_by(&RadrootsTransportSatisfactionPolicy::no_wait())
+ .expect("no wait")
+ );
+ assert!(
+ receipt
+ .is_satisfied_by(&RadrootsTransportSatisfactionPolicy::any_accepted())
+ .expect("any")
+ );
+ assert!(
+ !receipt
+ .is_satisfied_by(&RadrootsTransportSatisfactionPolicy::all_accepted())
+ .expect("all")
+ );
+ assert!(
+ receipt
+ .is_satisfied_by(&RadrootsTransportSatisfactionPolicy::quorum_accepted(1))
+ .expect("quorum")
+ );
+ assert!(!receipt.is_satisfied_by(&required).expect("required"));
+
+ let invalid_empty = RadrootsTransportSatisfactionPolicy::RequiredTargets {
+ class: RadrootsTransportSatisfactionClass::Accepted,
+ targets: Vec::new(),
+ };
+ assert_eq!(
+ invalid_empty
+ .required_target_count(2)
+ .expect_err("empty required set"),
+ RadrootsTransportError::EmptyRequiredTargetSet
+ );
+ assert_eq!(
+ receipt
+ .is_satisfied_by(&invalid_empty)
+ .expect_err("empty required receipt policy"),
+ RadrootsTransportError::EmptyRequiredTargetSet
+ );
+
+ let duplicate = RadrootsTransportSatisfactionPolicy::RequiredTargets {
+ class: RadrootsTransportSatisfactionClass::Accepted,
+ targets: vec![one.fingerprint.clone(), one.fingerprint],
+ };
+ assert_eq!(
+ duplicate
+ .required_target_count(2)
+ .expect_err("duplicate required set"),
+ RadrootsTransportError::DuplicateRequiredTargetFingerprint
+ );
+}
+
+#[test]
+fn payload_contract_covers_all_validation_boundaries() {
+ let signed = RadrootsTransportPayload::unchecked_signed_event_json("a".repeat(64), "{}")
+ .expect("signed");
+ let mesh = RadrootsTransportPayload::mesh_frame_cbor("mesh", [1]).expect("mesh");
+ let opaque = RadrootsTransportPayload::opaque_bytes(" label ", [2]).expect("opaque");
+ assert_eq!(signed.payload_kind(), "signed_event_json");
+ assert_eq!(mesh.payload_kind(), "mesh_frame_cbor");
+ assert_eq!(opaque.payload_kind(), "opaque_bytes");
+
+ for invalid_id in ["a".repeat(63), "g".repeat(64)] {
+ assert_eq!(
+ RadrootsTransportPayload::unchecked_signed_event_json(invalid_id, "{}")
+ .expect_err("invalid event id"),
+ RadrootsTransportError::InvalidPayloadId
+ );
+ }
+ assert_eq!(
+ RadrootsTransportPayload::mesh_frame_cbor("", [1]).expect_err("empty message id"),
+ RadrootsTransportError::EmptyPayloadId
+ );
+ for invalid_id in [" mesh", "mesh/one", "mesh\n"] {
+ assert_eq!(
+ RadrootsTransportPayload::mesh_frame_cbor(invalid_id, [1])
+ .expect_err("invalid token id"),
+ RadrootsTransportError::InvalidPayloadId
+ );
+ }
+ assert_eq!(
+ RadrootsTransportPayload::opaque_bytes(" ", [1]).expect_err("empty label"),
+ RadrootsTransportError::EmptyPayloadLabel
+ );
+ assert_eq!(
+ RadrootsTransportPayload::opaque_bytes("label", []).expect_err("empty opaque bytes"),
+ RadrootsTransportError::EmptyPayloadBytes
+ );
+ assert_eq!(
+ RadrootsTransportPayload::unchecked_signed_event_json("a".repeat(64), "")
+ .expect_err("empty raw json"),
+ RadrootsTransportError::EmptyPayloadBytes
+ );
+ for invalid_json in [" {}", "{} ", "{\n}", "[]", "{", "}"] {
+ assert_eq!(
+ RadrootsTransportPayload::unchecked_signed_event_json("a".repeat(64), invalid_json)
+ .expect_err("invalid raw json"),
+ RadrootsTransportError::InvalidPayloadBytes
+ );
+ }
+ for invalid_digest in ["f".repeat(63), "g".repeat(64)] {
+ assert_eq!(
+ RadrootsTransportPayload::opaque_bytes_with_digest("label", [1], invalid_digest)
+ .expect_err("invalid digest"),
+ RadrootsTransportError::InvalidPayloadDigest
+ );
+ }
+
+ assert_eq!(
+ RadrootsTransportPayload::unchecked_signed_event_json_with_digest(
+ "bad",
+ "{}",
+ "f".repeat(64),
+ )
+ .expect_err("invalid signed event before digest"),
+ RadrootsTransportError::InvalidPayloadId
+ );
+ assert_eq!(
+ RadrootsTransportPayload::unchecked_signed_event_json_with_digest(
+ "a".repeat(64),
+ "{}",
+ "bad",
+ )
+ .expect_err("invalid signed digest"),
+ RadrootsTransportError::InvalidPayloadDigest
+ );
+ assert_eq!(
+ RadrootsTransportPayload::mesh_frame_cbor_with_digest("", [1], "f".repeat(64))
+ .expect_err("invalid mesh before digest"),
+ RadrootsTransportError::EmptyPayloadId
+ );
+ assert_eq!(
+ RadrootsTransportPayload::mesh_frame_cbor_with_digest("mesh", [1], "bad")
+ .expect_err("invalid mesh digest"),
+ RadrootsTransportError::InvalidPayloadDigest
+ );
+ assert_eq!(
+ RadrootsTransportPayload::opaque_bytes_with_digest("", [1], "f".repeat(64))
+ .expect_err("invalid opaque payload before digest"),
+ RadrootsTransportError::EmptyPayloadLabel
+ );
+}
+
+#[test]
+fn status_contract_covers_builders_and_availability_defaults() {
+ assert_eq!(
+ RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted)
+ .with_code("accepted")
+ .with_message("accepted by transport")
+ .code
+ .as_deref(),
+ Some("accepted")
+ );
+
+ assert!(!RadrootsTransportCapabilities::none().deliver);
+ assert!(RadrootsTransportCapabilities::fetch_only().fetch);
+ assert_eq!(
+ RadrootsTransportCapabilities::reticulum_unavailable(),
+ RadrootsTransportCapabilities::none()
+ );
+ let capabilities = RadrootsTransportCapabilities::deliver_and_fetch()
+ .with_discovery(true)
+ .with_gateway_forwarding(true)
+ .with_receipt_observation(true);
+ assert!(capabilities.deliver);
+ assert!(capabilities.fetch);
+ assert!(capabilities.discovery);
+ assert!(capabilities.gateway_forwarding);
+ assert!(capabilities.receipt_observation);
+
+ let unavailable = RadrootsTransportStatus::new(
+ RadrootsTransportKind::Reticulum,
+ true,
+ RadrootsTransportImplementationState::Mock,
+ false,
+ "unavailable",
+ )
+ .with_capabilities(capabilities.clone())
+ .with_maturity(RadrootsTransportCapabilityMaturity::Preview)
+ .with_availability(RadrootsTransportCapabilityAvailability::Degraded)
+ .with_profile_id("reticulum.local")
+ .with_endpoint_uri(RADROOTS_RETICULUM_ENDPOINT_URI);
+ assert_eq!(
+ unavailable.availability,
+ RadrootsTransportCapabilityAvailability::Degraded
+ );
+ assert_eq!(
+ unavailable.maturity,
+ RadrootsTransportCapabilityMaturity::Preview
+ );
+ assert_eq!(unavailable.capabilities, capabilities);
+ assert!(!unavailable.usable_for_delivery);
+
+ assert!(!RadrootsTransportDeliveryTargetStatus::Accepted.is_ready_for_attempt());
+ assert!(!RadrootsTransportDeliveryTargetStatus::Accepted.is_retryable_failure());
+ assert!(RadrootsTransportDeliveryTargetStatus::SkippedPolicyDenied.is_terminal_failure());
+ assert!(!RadrootsTransportDeliveryTargetStatus::Accepted.is_terminal_failure());
+ assert!(!RadrootsTransportDeliveryTargetStatus::Accepted.is_deferred_until_implemented());
+}
+
+#[test]
+#[cfg(feature = "serde")]
+fn transport_kind_deserializer_rejects_non_string_values() {
+ assert!(serde_json::from_str::<RadrootsTransportKind>("1").is_err());
+ assert!(serde_json::from_str::<RadrootsTransportKind>("\"NOSTR\"").is_err());
+}
+
+#[test]
+fn reticulum_destination_rejects_wrong_kind_uri_and_missing_scope() {
+ let local = RadrootsTransportTarget::local("local:memory").expect("local target");
+ assert_eq!(
+ ReticulumDestinationV1::from_target(&local).expect_err("wrong kind"),
+ RadrootsTransportError::InvalidTargetUri
+ );
+
+ let mut wrong_uri = RadrootsTransportTarget::reticulum().expect("Reticulum target");
+ wrong_uri.uri = RadrootsTransportTargetUri::parse("reticulum:other").expect("generic URI");
+ assert_eq!(
+ ReticulumDestinationV1::from_target(&wrong_uri).expect_err("wrong URI"),
+ RadrootsTransportError::InvalidTargetUri
+ );
+
+ let mut missing_scope = RadrootsTransportTarget::reticulum().expect("Reticulum target");
+ missing_scope.scope = None;
+ assert_eq!(
+ ReticulumDestinationV1::from_target(&missing_scope).expect_err("missing scope"),
+ RadrootsTransportError::EmptyTargetScope
+ );
+}
+
+#[test]
+fn target_contract_covers_parser_and_authority_boundaries() {
+ let scope = RadrootsTransportMeshScopeId::parse("farm_1.alpha-beta").expect("scope");
+ assert_eq!(scope.as_str(), "farm_1.alpha-beta");
+ assert_eq!(scope.to_string(), "farm_1.alpha-beta");
+ for invalid_scope in [" scope", "scope ", "scope/path", "scope\n"] {
+ assert_eq!(
+ RadrootsTransportMeshScopeId::parse(invalid_scope).expect_err("invalid scope"),
+ RadrootsTransportError::InvalidTargetScope
+ );
+ }
+
+ let label = RadrootsTransportTargetLabel::parse(" Relay One ").expect("label");
+ assert_eq!(label.as_str(), "Relay One");
+ assert_eq!(label.to_string(), "Relay One");
+ assert_eq!(
+ RadrootsTransportTargetLabel::parse("\u{7f}").expect_err("control label"),
+ RadrootsTransportError::InvalidTargetLabel
+ );
+
+ for invalid in [
+ "",
+ " wss://relay.example",
+ "wss://relay example",
+ "wss://relay\u{7f}.example",
+ "relay.example",
+ "ftp://relay.example",
+ "wss://[::1",
+ "wss://[]",
+ "wss://[::1]suffix",
+ "wss://[[::1]]",
+ "wss://relay[.example",
+ "wss://.relay.example",
+ "wss://relay..example",
+ "wss://relay.example.",
+ "wss://relay_example",
+ "wss://relay.example:",
+ "wss://[::1]:",
+ "wss://[::1]:bad",
+ "wss://[::1]:42949672960",
+ "ws://[2001:db8::1]",
+ ] {
+ assert_eq!(
+ RadrootsTransportTarget::nostr_relay(invalid).expect_err("invalid relay URI"),
+ if invalid.is_empty() {
+ RadrootsTransportError::EmptyTargetUri
+ } else {
+ RadrootsTransportError::InvalidTargetUri
+ },
+ "{invalid}"
+ );
+ }
+
+ for (raw, canonical) in [
+ ("WSS://[2001:DB8::1]", "wss://[2001:db8::1]"),
+ ("wss://relay.example:443/", "wss://relay.example:443"),
+ ("ws://127.0.0.1", "ws://127.0.0.1"),
+ ] {
+ assert_eq!(
+ RadrootsTransportTarget::nostr_relay(raw)
+ .expect("relay URI")
+ .uri
+ .as_str(),
+ canonical
+ );
+ }
+}
+
+#[test]
+fn every_transport_error_has_a_stable_display_message() {
+ let remaining = [
+ RadrootsTransportError::EmptyPayloadId,
+ RadrootsTransportError::InvalidPayloadId,
+ RadrootsTransportError::EmptyPayloadLabel,
+ RadrootsTransportError::InvalidPayloadLabel,
+ RadrootsTransportError::EmptyPayloadBytes,
+ RadrootsTransportError::InvalidPayloadBytes,
+ RadrootsTransportError::InvalidPayloadDigest,
+ RadrootsTransportError::PayloadDigestMismatch,
+ ];
+ for error in remaining {
+ assert!(!error.to_string().is_empty());
+ }
+}
diff --git a/crates/transport_nostr/src/outbox.rs b/crates/transport_nostr/src/outbox.rs
@@ -136,7 +136,7 @@ where
publishable.remaining_satisfaction_count,
targets.len(),
publishable.remaining_required_targets.as_deref(),
- )?;
+ );
let active_delivery_plan_id = publishable.active_delivery_plan_id;
let request = RadrootsRelayPublishRequest::new(signed_event.clone(), targets, now_ms)
.with_satisfaction_policy(satisfaction_policy)
@@ -278,7 +278,7 @@ where
)?;
let transport_targets = publishable_transport_targets(&publishable)?;
let target_set = RadrootsTransportTargetSet::new(transport_targets)?;
- let satisfaction_policy = transport_satisfaction_policy_for_publishable(&publishable)?;
+ let satisfaction_policy = transport_satisfaction_policy_for_publishable(&publishable);
let request_id = outbox_publish_idempotency_key(
claimed.outbox_event_id,
claimed.attempt_count,
@@ -731,7 +731,7 @@ fn publishable_transport_targets(
fn transport_satisfaction_policy_for_publishable(
publishable: &PublishableRelays,
-) -> Result<RadrootsTransportSatisfactionPolicy, RadrootsRelayTransportError> {
+) -> RadrootsTransportSatisfactionPolicy {
satisfaction_policy_for_remaining_count(
publishable.satisfaction_class,
publishable.remaining_satisfaction_count,
@@ -810,17 +810,6 @@ async fn publishable_relays(
.iter()
.filter(|target| target.delivery_plan_id == active_delivery_plan_id)
.collect::<Vec<_>>();
- if let Some(target) = active_targets
- .iter()
- .find(|target| !is_nostr_target(target) && target.status.is_ready_for_attempt())
- {
- return Err(RadrootsRelayTransportError::Transport(format!(
- "direct Nostr outbox publish does not accept {} target {} in active delivery plan {}",
- target.transport_kind.canonical_label(),
- target.endpoint_uri.as_str(),
- active_delivery_plan_id
- )));
- }
let satisfaction_class = plan
.satisfaction_policy
.target_satisfaction_class()
@@ -861,20 +850,18 @@ async fn publishable_relays(
let required_for_satisfaction = required_targets
.as_ref()
.is_some_and(|required| required.contains(&target.endpoint_fingerprint));
- if target
- .status
- .counts_as_transport_satisfaction(RadrootsTransportSatisfactionClass::Accepted)
- && (required_targets.is_none() || required_for_satisfaction)
- {
+ if counts_as_accepted_for_plan(
+ target.status,
+ required_targets.is_some(),
+ required_for_satisfaction,
+ ) {
accepted_count += 1;
}
let can_contribute_to_satisfaction =
required_targets.is_none() || required_for_satisfaction;
if remaining_satisfaction_count > 0
&& can_contribute_to_satisfaction
- && (target.status.is_ready_for_attempt()
- || (republish_accepted_relays
- && target.status == RadrootsOutboxDeliveryTargetStatus::Accepted))
+ && is_publishable_delivery_status(target.status, republish_accepted_relays)
{
relays.push(PublishableRelay {
delivery_target_id: target.delivery_target_id,
@@ -904,9 +891,7 @@ async fn publishable_relays(
|| relays
.iter()
.any(|relay| relay.delivery_target_id == target.delivery_target_id)
- || !(target.status.is_ready_for_attempt()
- || (republish_accepted_relays
- && target.status == RadrootsOutboxDeliveryTargetStatus::Accepted))
+ || !is_publishable_delivery_status(target.status, republish_accepted_relays)
{
continue;
}
@@ -949,6 +934,23 @@ fn outbox_publish_idempotency_key(
)
}
+fn counts_as_accepted_for_plan(
+ status: RadrootsOutboxDeliveryTargetStatus,
+ has_required_targets: bool,
+ required_for_satisfaction: bool,
+) -> bool {
+ status.counts_as_transport_satisfaction(RadrootsTransportSatisfactionClass::Accepted)
+ && (!has_required_targets || required_for_satisfaction)
+}
+
+fn is_publishable_delivery_status(
+ status: RadrootsOutboxDeliveryTargetStatus,
+ republish_accepted_relays: bool,
+) -> bool {
+ status.is_ready_for_attempt()
+ || (republish_accepted_relays && status == RadrootsOutboxDeliveryTargetStatus::Accepted)
+}
+
fn is_nostr_target(target: &RadrootsOutboxDeliveryTargetRecord) -> bool {
target.transport_kind == RadrootsTransportKind::Nostr
}
@@ -958,39 +960,35 @@ fn satisfaction_policy_for_remaining_count(
remaining_satisfaction_count: usize,
target_count: usize,
exact_required_targets: Option<&[RadrootsTransportTargetFingerprint]>,
-) -> Result<RadrootsTransportSatisfactionPolicy, RadrootsRelayTransportError> {
+) -> RadrootsTransportSatisfactionPolicy {
if let Some(targets) = exact_required_targets {
- return RadrootsTransportSatisfactionPolicy::required_targets(
- satisfaction_class,
- targets.to_vec(),
- )
- .map_err(transport_error_to_relay_error);
+ return RadrootsTransportSatisfactionPolicy::RequiredTargets {
+ class: satisfaction_class,
+ targets: targets.to_vec(),
+ };
}
if remaining_satisfaction_count >= target_count {
- return Ok(RadrootsTransportSatisfactionPolicy::All {
+ return RadrootsTransportSatisfactionPolicy::All {
class: satisfaction_class,
- });
+ };
}
if remaining_satisfaction_count == 0 {
- return Err(RadrootsRelayTransportError::Transport(
- "required Nostr relay satisfaction count must be greater than zero".to_owned(),
- ));
+ return RadrootsTransportSatisfactionPolicy::NoWait;
}
if remaining_satisfaction_count == 1 {
- return Ok(RadrootsTransportSatisfactionPolicy::Any {
+ return RadrootsTransportSatisfactionPolicy::Any {
class: satisfaction_class,
- });
+ };
}
- let count = u16::try_from(remaining_satisfaction_count).map_err(|_| {
- RadrootsRelayTransportError::Transport(
- "required Nostr relay satisfaction count exceeds supported transport policy range"
- .to_owned(),
- )
- })?;
- Ok(RadrootsTransportSatisfactionPolicy::Quorum {
+ let Ok(count) = u16::try_from(remaining_satisfaction_count) else {
+ return RadrootsTransportSatisfactionPolicy::All {
+ class: satisfaction_class,
+ };
+ };
+ RadrootsTransportSatisfactionPolicy::Quorum {
class: satisfaction_class,
threshold: count,
- })
+ }
}
async fn ingest_publish_observation(
@@ -1019,16 +1017,24 @@ async fn ingest_publish_observation(
#[cfg(test)]
mod tests {
use super::{
- PublishableRelay, PublishableRelays, adapter_transport_failure_receipt,
+ PublishableRelay, PublishableRelays, RadrootsOutboxDeliveryTargetStatus,
+ adapter_transport_failure_receipt, counts_as_accepted_for_plan,
+ is_publishable_delivery_status, publishable_transport_targets,
+ relay_outcome_from_transport_outcome, relay_outcome_kind_from_code,
+ relay_outcome_kind_from_transport_outcome, relay_receipts_from_transport_receipts,
satisfaction_policy_for_remaining_count, target_receipts_from_relay_receipts,
- target_receipts_from_transport_receipts,
+ target_receipts_from_transport_receipts, transport_error_to_relay_error,
+ transport_satisfaction_policy_for_publishable,
+ };
+ use crate::{
+ RadrootsRelayOutcome, RadrootsRelayOutcomeKind, RadrootsRelayPublishRelayReceipt,
+ RadrootsRelayTransportError,
};
- use crate::{RadrootsRelayOutcome, RadrootsRelayPublishRelayReceipt};
use radroots_transport::{
RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryTargetStatus,
- RadrootsTransportOutcome, RadrootsTransportOutcomeKind, RadrootsTransportSatisfactionClass,
- RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget,
- RadrootsTransportTargetReceipt,
+ RadrootsTransportError, RadrootsTransportOutcome, RadrootsTransportOutcomeKind,
+ RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy,
+ RadrootsTransportTarget, RadrootsTransportTargetReceipt,
};
#[test]
@@ -1039,8 +1045,7 @@ mod tests {
2,
2,
None
- )
- .expect("all targets"),
+ ),
RadrootsTransportSatisfactionPolicy::all_accepted()
);
assert_eq!(
@@ -1049,8 +1054,7 @@ mod tests {
1,
3,
None
- )
- .expect("at least one"),
+ ),
RadrootsTransportSatisfactionPolicy::any_accepted()
);
assert_eq!(
@@ -1059,8 +1063,7 @@ mod tests {
2,
3,
None
- )
- .expect("delivered quorum"),
+ ),
RadrootsTransportSatisfactionPolicy::quorum_delivered(2)
);
let required_target =
@@ -1071,23 +1074,75 @@ mod tests {
1,
3,
Some(core::slice::from_ref(&required_target.fingerprint))
- )
- .expect("exact required targets"),
+ ),
RadrootsTransportSatisfactionPolicy::required_targets(
RadrootsTransportSatisfactionClass::Delivered,
vec![required_target.fingerprint]
)
.expect("required target policy")
);
- assert!(
+ assert_eq!(
satisfaction_policy_for_remaining_count(
RadrootsTransportSatisfactionClass::Accepted,
usize::from(u16::MAX) + 1,
usize::from(u16::MAX) + 2,
None,
- )
- .is_err()
+ ),
+ RadrootsTransportSatisfactionPolicy::All {
+ class: RadrootsTransportSatisfactionClass::Accepted,
+ }
);
+ assert_eq!(
+ satisfaction_policy_for_remaining_count(
+ RadrootsTransportSatisfactionClass::Accepted,
+ 0,
+ 3,
+ None,
+ ),
+ RadrootsTransportSatisfactionPolicy::NoWait
+ );
+
+ assert!(counts_as_accepted_for_plan(
+ RadrootsOutboxDeliveryTargetStatus::Accepted,
+ false,
+ false,
+ ));
+ assert!(counts_as_accepted_for_plan(
+ RadrootsOutboxDeliveryTargetStatus::Accepted,
+ true,
+ true,
+ ));
+ assert!(!counts_as_accepted_for_plan(
+ RadrootsOutboxDeliveryTargetStatus::Accepted,
+ true,
+ false,
+ ));
+ assert!(!counts_as_accepted_for_plan(
+ RadrootsOutboxDeliveryTargetStatus::FailedRetryable,
+ false,
+ false,
+ ));
+
+ assert!(is_publishable_delivery_status(
+ RadrootsOutboxDeliveryTargetStatus::Pending,
+ false,
+ ));
+ assert!(is_publishable_delivery_status(
+ RadrootsOutboxDeliveryTargetStatus::FailedRetryable,
+ false,
+ ));
+ assert!(is_publishable_delivery_status(
+ RadrootsOutboxDeliveryTargetStatus::Accepted,
+ true,
+ ));
+ assert!(!is_publishable_delivery_status(
+ RadrootsOutboxDeliveryTargetStatus::Accepted,
+ false,
+ ));
+ assert!(!is_publishable_delivery_status(
+ RadrootsOutboxDeliveryTargetStatus::FailedTerminal,
+ true,
+ ));
}
#[test]
@@ -1167,4 +1222,258 @@ mod tests {
assert!(!receipt.quorum_met);
assert!(receipt.relays.iter().all(|relay| relay.attempted));
}
+
+ #[test]
+ fn transport_outcomes_preserve_relay_semantics() {
+ let cases = [
+ (
+ RadrootsTransportOutcomeKind::Accepted,
+ RadrootsRelayOutcomeKind::Accepted,
+ ),
+ (
+ RadrootsTransportOutcomeKind::DuplicateAccepted,
+ RadrootsRelayOutcomeKind::DuplicateAccepted,
+ ),
+ (
+ RadrootsTransportOutcomeKind::Delivered,
+ RadrootsRelayOutcomeKind::Accepted,
+ ),
+ (
+ RadrootsTransportOutcomeKind::Forwarded,
+ RadrootsRelayOutcomeKind::Accepted,
+ ),
+ (
+ RadrootsTransportOutcomeKind::StoredByGateway,
+ RadrootsRelayOutcomeKind::Accepted,
+ ),
+ (
+ RadrootsTransportOutcomeKind::Seen,
+ RadrootsRelayOutcomeKind::Accepted,
+ ),
+ (
+ RadrootsTransportOutcomeKind::DeferredUntilImplemented,
+ RadrootsRelayOutcomeKind::Unsupported,
+ ),
+ (
+ RadrootsTransportOutcomeKind::Rejected,
+ RadrootsRelayOutcomeKind::Invalid,
+ ),
+ (
+ RadrootsTransportOutcomeKind::RouteUnavailable,
+ RadrootsRelayOutcomeKind::RelayUrlRejected,
+ ),
+ (
+ RadrootsTransportOutcomeKind::PayloadTooLarge,
+ RadrootsRelayOutcomeKind::Invalid,
+ ),
+ (
+ RadrootsTransportOutcomeKind::PolicyDenied,
+ RadrootsRelayOutcomeKind::Restricted,
+ ),
+ (
+ RadrootsTransportOutcomeKind::Timeout,
+ RadrootsRelayOutcomeKind::Timeout,
+ ),
+ (
+ RadrootsTransportOutcomeKind::ConnectionFailed,
+ RadrootsRelayOutcomeKind::ConnectionFailed,
+ ),
+ (
+ RadrootsTransportOutcomeKind::TransportUnavailable,
+ RadrootsRelayOutcomeKind::Error,
+ ),
+ ];
+ for (transport_kind, relay_kind) in cases {
+ assert_eq!(
+ relay_outcome_kind_from_transport_outcome(transport_kind),
+ relay_kind
+ );
+ let outcome = RadrootsTransportOutcome::new(transport_kind)
+ .with_message(format!("{transport_kind:?}"));
+ let relay_outcome = relay_outcome_from_transport_outcome(&outcome);
+ assert_eq!(relay_outcome.kind, relay_kind);
+ assert_eq!(relay_outcome.message, outcome.message);
+ }
+
+ let code_cases = [
+ ("accepted", RadrootsRelayOutcomeKind::Accepted),
+ (
+ "duplicate_accepted",
+ RadrootsRelayOutcomeKind::DuplicateAccepted,
+ ),
+ ("blocked", RadrootsRelayOutcomeKind::Blocked),
+ ("rate_limited", RadrootsRelayOutcomeKind::RateLimited),
+ ("invalid", RadrootsRelayOutcomeKind::Invalid),
+ ("pow_required", RadrootsRelayOutcomeKind::PowRequired),
+ ("restricted", RadrootsRelayOutcomeKind::Restricted),
+ ("auth_required", RadrootsRelayOutcomeKind::AuthRequired),
+ ("muted", RadrootsRelayOutcomeKind::Muted),
+ ("unsupported", RadrootsRelayOutcomeKind::Unsupported),
+ (
+ "payment_required",
+ RadrootsRelayOutcomeKind::PaymentRequired,
+ ),
+ ("error", RadrootsRelayOutcomeKind::Error),
+ ("timeout", RadrootsRelayOutcomeKind::Timeout),
+ (
+ "connection_failed",
+ RadrootsRelayOutcomeKind::ConnectionFailed,
+ ),
+ (
+ "relay_url_rejected",
+ RadrootsRelayOutcomeKind::RelayUrlRejected,
+ ),
+ (
+ "skipped_already_accepted",
+ RadrootsRelayOutcomeKind::SkippedAlreadyAccepted,
+ ),
+ ("unknown", RadrootsRelayOutcomeKind::Unknown),
+ ];
+ for (code, relay_kind) in code_cases {
+ assert_eq!(relay_outcome_kind_from_code(code), Some(relay_kind));
+ assert_eq!(
+ relay_outcome_from_transport_outcome(
+ &RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Rejected)
+ .with_code(code)
+ )
+ .kind,
+ relay_kind
+ );
+ }
+ assert_eq!(relay_outcome_kind_from_code("unrecognized"), None);
+ assert_eq!(
+ relay_outcome_from_transport_outcome(
+ &RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Seen)
+ .with_code("unrecognized")
+ )
+ .kind,
+ RadrootsRelayOutcomeKind::Accepted
+ );
+
+ for kind in [
+ RadrootsRelayOutcomeKind::RateLimited,
+ RadrootsRelayOutcomeKind::Error,
+ RadrootsRelayOutcomeKind::Unknown,
+ ] {
+ assert_eq!(
+ kind.transport_outcome_kind(),
+ RadrootsTransportOutcomeKind::TransportUnavailable
+ );
+ }
+ }
+
+ #[test]
+ fn transport_target_and_error_adapters_preserve_contract_categories() {
+ let target = RadrootsTransportTarget::nostr_relay("wss://relay.example").expect("target");
+ let publishable = PublishableRelays {
+ active_delivery_plan_id: 7,
+ relays: vec![PublishableRelay {
+ delivery_target_id: 11,
+ relay_url: target.uri.as_str().to_owned(),
+ endpoint_fingerprint: target.fingerprint.clone(),
+ target_scope: Some("foodshed.west".to_owned()),
+ target_label: Some("primary relay".to_owned()),
+ }],
+ accepted_count: 0,
+ satisfied_count: 0,
+ satisfaction_required_count: 1,
+ remaining_satisfaction_count: 1,
+ satisfaction_class: RadrootsTransportSatisfactionClass::Accepted,
+ required_targets: None,
+ remaining_required_targets: None,
+ };
+ let targets = publishable_transport_targets(&publishable).expect("transport targets");
+ assert_eq!(targets.len(), 1);
+ assert_eq!(
+ targets[0].scope.as_ref().expect("scope").as_str(),
+ "foodshed.west"
+ );
+ assert_eq!(
+ targets[0].label.as_ref().expect("label").as_str(),
+ "primary relay"
+ );
+ assert_eq!(
+ transport_satisfaction_policy_for_publishable(&publishable),
+ RadrootsTransportSatisfactionPolicy::all_accepted()
+ );
+
+ let mut invalid = publishable;
+ invalid.relays[0].target_scope = Some("bad scope".to_owned());
+ assert!(matches!(
+ publishable_transport_targets(&invalid),
+ Err(RadrootsRelayTransportError::Transport(_))
+ ));
+ invalid.relays[0].target_scope = Some("foodshed.west".to_owned());
+ invalid.relays[0].target_label = Some("bad\0label".to_owned());
+ assert!(matches!(
+ publishable_transport_targets(&invalid),
+ Err(RadrootsRelayTransportError::Transport(_))
+ ));
+ invalid.relays[0].target_label = Some("primary relay".to_owned());
+ invalid.relays[0].relay_url = "not-a-relay".to_owned();
+ assert!(matches!(
+ publishable_transport_targets(&invalid),
+ Err(RadrootsRelayTransportError::TransportContract(_))
+ ));
+
+ let generic_errors = [
+ RadrootsTransportError::UnsupportedOperation,
+ RadrootsTransportError::EmptyTransportKind,
+ RadrootsTransportError::InvalidTransportKind,
+ RadrootsTransportError::EmptyTargetScope,
+ RadrootsTransportError::InvalidTargetScope,
+ RadrootsTransportError::EmptyTargetLabel,
+ RadrootsTransportError::InvalidTargetLabel,
+ RadrootsTransportError::InvalidSatisfactionPolicy,
+ RadrootsTransportError::EmptyRequiredTargetSet,
+ RadrootsTransportError::DuplicateRequiredTargetFingerprint,
+ ];
+ for error in generic_errors {
+ assert!(matches!(
+ transport_error_to_relay_error(error),
+ RadrootsRelayTransportError::Transport(_)
+ ));
+ }
+ let target_errors = [
+ RadrootsTransportError::EmptyTargetUri,
+ RadrootsTransportError::InvalidTargetUri,
+ RadrootsTransportError::EmptyTargetSet,
+ RadrootsTransportError::DuplicateTargetFingerprint,
+ RadrootsTransportError::InvalidTargetFingerprint,
+ ];
+ for error in target_errors {
+ assert!(matches!(
+ transport_error_to_relay_error(error),
+ RadrootsRelayTransportError::TransportContract(_)
+ ));
+ }
+ let payload_errors = [
+ RadrootsTransportError::EmptyPayloadId,
+ RadrootsTransportError::InvalidPayloadId,
+ RadrootsTransportError::EmptyPayloadLabel,
+ RadrootsTransportError::InvalidPayloadLabel,
+ RadrootsTransportError::EmptyPayloadBytes,
+ RadrootsTransportError::InvalidPayloadBytes,
+ RadrootsTransportError::InvalidPayloadDigest,
+ RadrootsTransportError::PayloadDigestMismatch,
+ ];
+ for error in payload_errors {
+ assert!(matches!(
+ transport_error_to_relay_error(error),
+ RadrootsRelayTransportError::NostrEventJson(_)
+ ));
+ }
+
+ let unknown =
+ RadrootsTransportTarget::nostr_relay("wss://unknown.example").expect("unknown target");
+ let delivery = RadrootsTransportDeliveryReceipt {
+ request_id: "unknown".to_owned(),
+ target_receipts: vec![RadrootsTransportTargetReceipt::new(
+ unknown,
+ RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted),
+ )],
+ };
+ assert!(target_receipts_from_transport_receipts(&invalid, &delivery).is_empty());
+ assert_eq!(relay_receipts_from_transport_receipts(&delivery).len(), 1);
+ }
}
diff --git a/crates/transport_nostr/src/publish.rs b/crates/transport_nostr/src/publish.rs
@@ -112,27 +112,12 @@ pub trait RadrootsRelayPublishAdapter: Send + Sync {
pub fn verified_signed_event_payload(
signed_event: &RadrootsSignedEvent,
) -> Result<RadrootsTransportPayload, RadrootsTransportError> {
- verify_signed_event_raw_json_matches_event(signed_event)?;
RadrootsTransportPayload::unchecked_signed_event_json(
signed_event.id_str(),
signed_event.raw_json(),
)
}
-fn verify_signed_event_raw_json_matches_event(
- signed_event: &RadrootsSignedEvent,
-) -> Result<(), RadrootsTransportError> {
- let wire = RadrootsNip01EventWire::parse_json(signed_event.raw_json())
- .map_err(|_| RadrootsTransportError::InvalidPayloadBytes)?;
- if wire.id != signed_event.id_str() {
- return Err(RadrootsTransportError::InvalidPayloadId);
- }
- if &wire != signed_event.wire() {
- return Err(RadrootsTransportError::InvalidPayloadBytes);
- }
- Ok(())
-}
-
impl<A> RadrootsRelayPublishAdapter for &A
where
A: RadrootsRelayPublishAdapter + ?Sized,
@@ -266,6 +251,114 @@ fn nostr_error_to_transport_error(error: RadrootsRelayTransportError) -> Radroot
}
}
+#[cfg(test)]
+mod contract_tests {
+ use super::nostr_error_to_transport_error;
+ use crate::RadrootsRelayTransportError;
+ use radroots_transport::RadrootsTransportError;
+
+ #[test]
+ fn relay_errors_map_to_stable_transport_contract_categories() {
+ assert_eq!(
+ nostr_error_to_transport_error(RadrootsRelayTransportError::TransportContract(
+ "contract".to_owned(),
+ )),
+ RadrootsTransportError::InvalidPayloadBytes
+ );
+
+ let target_errors = [
+ RadrootsRelayTransportError::RelayUrlParse {
+ url: "bad".to_owned(),
+ reason: "parse".to_owned(),
+ },
+ RadrootsRelayTransportError::WsRequiresLocalhostPolicy {
+ url: "ws://relay.example".to_owned(),
+ },
+ RadrootsRelayTransportError::UnsupportedRelayScheme {
+ url: "https://relay.example".to_owned(),
+ scheme: "https".to_owned(),
+ },
+ RadrootsRelayTransportError::EmptyRelayHost {
+ url: "wss://".to_owned(),
+ },
+ RadrootsRelayTransportError::RelayUrlUserinfo {
+ url: "wss://user@relay.example".to_owned(),
+ },
+ RadrootsRelayTransportError::RelayUrlQueryOrFragment {
+ url: "wss://relay.example?x=1".to_owned(),
+ },
+ RadrootsRelayTransportError::RelayUrlForbiddenDestination {
+ url: "wss://127.0.0.1".to_owned(),
+ reason: "loopback".to_owned(),
+ },
+ RadrootsRelayTransportError::RelayUrlResolvedForbiddenDestination {
+ url: "wss://relay.example".to_owned(),
+ address: "127.0.0.1".to_owned(),
+ reason: "loopback".to_owned(),
+ },
+ RadrootsRelayTransportError::EmptyTargetSet,
+ ];
+ for error in target_errors {
+ assert_eq!(
+ nostr_error_to_transport_error(error),
+ RadrootsTransportError::InvalidTargetUri
+ );
+ }
+
+ assert_eq!(
+ nostr_error_to_transport_error(RadrootsRelayTransportError::NostrEventJson(
+ "event".to_owned(),
+ )),
+ RadrootsTransportError::InvalidPayloadBytes
+ );
+ let json_error = serde_json::from_str::<serde_json::Value>("{").expect_err("invalid json");
+ assert_eq!(
+ nostr_error_to_transport_error(RadrootsRelayTransportError::Json(json_error)),
+ RadrootsTransportError::InvalidPayloadBytes
+ );
+ assert_eq!(
+ nostr_error_to_transport_error(RadrootsRelayTransportError::Transport(
+ "offline".to_owned(),
+ )),
+ RadrootsTransportError::InvalidTransportKind
+ );
+ assert_eq!(
+ nostr_error_to_transport_error(RadrootsRelayTransportError::EmptyFetchFilters),
+ RadrootsTransportError::InvalidTransportKind
+ );
+ assert_eq!(
+ nostr_error_to_transport_error(RadrootsRelayTransportError::InvalidFetchLimit {
+ field: "max_events",
+ }),
+ RadrootsTransportError::InvalidTransportKind
+ );
+
+ #[cfg(feature = "storage")]
+ {
+ assert_eq!(
+ nostr_error_to_transport_error(RadrootsRelayTransportError::EventStore(
+ radroots_event_store::RadrootsEventStoreError::MissingEvent(
+ "missing".to_owned(),
+ ),
+ )),
+ RadrootsTransportError::InvalidTransportKind
+ );
+ assert_eq!(
+ nostr_error_to_transport_error(RadrootsRelayTransportError::Outbox(
+ radroots_outbox::RadrootsOutboxError::EventNotFound(1),
+ )),
+ RadrootsTransportError::InvalidTransportKind
+ );
+ assert_eq!(
+ nostr_error_to_transport_error(
+ RadrootsRelayTransportError::MissingSignedOutboxEvent(1),
+ ),
+ RadrootsTransportError::InvalidTransportKind
+ );
+ }
+ }
+}
+
fn signed_event_from_transport_payload(
payload: &RadrootsTransportPayload,
) -> Result<RadrootsSignedEvent, RadrootsTransportError> {
diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs
@@ -17,11 +17,13 @@ use radroots_outbox::{
RadrootsOutboxOperationStatus,
};
use radroots_transport::{
- RadrootsTransport, RadrootsTransportDeliveryRequest, RadrootsTransportError,
- RadrootsTransportFetchRequest, RadrootsTransportKind, RadrootsTransportMeshScopeId,
- RadrootsTransportPayload, RadrootsTransportSatisfactionClass,
- RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, RadrootsTransportTargetLabel,
- RadrootsTransportTargetSet,
+ RadrootsTransport, RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest,
+ RadrootsTransportDeliveryTargetStatus, RadrootsTransportError, RadrootsTransportFetchReceipt,
+ RadrootsTransportFetchRequest, RadrootsTransportFuture, RadrootsTransportImplementationState,
+ RadrootsTransportKind, RadrootsTransportMeshScopeId, RadrootsTransportOutcome,
+ RadrootsTransportOutcomeKind, RadrootsTransportPayload, RadrootsTransportSatisfactionClass,
+ RadrootsTransportSatisfactionPolicy, RadrootsTransportStatus, RadrootsTransportTarget,
+ RadrootsTransportTargetLabel, RadrootsTransportTargetReceipt, RadrootsTransportTargetSet,
};
use radroots_transport_nostr::{
RadrootsMockRelayFetchAdapter, RadrootsMockRelayPublishAdapter, RadrootsNostrTransport,
@@ -31,7 +33,8 @@ use radroots_transport_nostr::{
RadrootsRelayPublishRelayReceipt, RadrootsRelayPublishRequest, RadrootsRelayTargetSet,
RadrootsRelayTransportError, RadrootsRelayUrl, RadrootsRelayUrlPolicy,
fetch_and_ingest_relay_events, fetch_relay_events, fetch_relay_events_blocking,
- publish_claimed_outbox_event, publish_signed_event, verified_signed_event_payload,
+ publish_claimed_outbox_event, publish_claimed_outbox_event_with_transport,
+ publish_signed_event, verified_signed_event_payload,
};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
@@ -139,6 +142,62 @@ impl RadrootsRelayPublishAdapter for UnknownRelayReceiptPublishAdapter {
}
}
+#[derive(Clone)]
+struct ScriptedTransport {
+ outcomes: Vec<RadrootsTransportOutcome>,
+}
+
+impl ScriptedTransport {
+ fn new(outcomes: Vec<RadrootsTransportOutcome>) -> Self {
+ Self { outcomes }
+ }
+}
+
+impl RadrootsTransport for ScriptedTransport {
+ fn transport_kind(&self) -> RadrootsTransportKind {
+ RadrootsTransportKind::Nostr
+ }
+
+ fn status<'a>(&'a self) -> RadrootsTransportFuture<'a, RadrootsTransportStatus> {
+ Box::pin(async {
+ Ok(RadrootsTransportStatus::new(
+ RadrootsTransportKind::Nostr,
+ true,
+ RadrootsTransportImplementationState::Real,
+ true,
+ "scripted",
+ ))
+ })
+ }
+
+ fn deliver<'a>(
+ &'a self,
+ request: RadrootsTransportDeliveryRequest,
+ ) -> RadrootsTransportFuture<'a, RadrootsTransportDeliveryReceipt> {
+ Box::pin(async move {
+ let target_receipts = request
+ .target_set
+ .targets()
+ .iter()
+ .cloned()
+ .zip(self.outcomes.iter().cloned())
+ .map(|(target, outcome)| RadrootsTransportTargetReceipt::new(target, outcome))
+ .collect();
+ Ok(RadrootsTransportDeliveryReceipt {
+ request_id: request.request_id,
+ target_receipts,
+ })
+ })
+ }
+
+ fn fetch<'a>(
+ &'a self,
+ _request: RadrootsTransportFetchRequest,
+ ) -> RadrootsTransportFuture<'a, RadrootsTransportFetchReceipt> {
+ Box::pin(async { Err(RadrootsTransportError::UnsupportedOperation) })
+ }
+}
+
fn fixture_keys() -> RadrootsNostrKeys {
let secret_key =
RadrootsNostrSecretKey::from_hex(FIXTURE_ALICE_SECRET_KEY_HEX).expect("secret key");
@@ -728,7 +787,16 @@ async fn mock_publish_preserves_exact_raw_json_and_counts_outcomes() {
async fn nostr_transport_facade_delivers_signed_event_payloads() {
let signed = signed_post("facade payload");
let adapter = RadrootsMockRelayPublishAdapter::new();
- let transport = RadrootsNostrTransport::new(&adapter);
+ let expected_status = RadrootsTransportStatus::new(
+ RadrootsTransportKind::Nostr,
+ true,
+ RadrootsTransportImplementationState::Real,
+ true,
+ "fixture ready",
+ );
+ let transport = RadrootsNostrTransport::new(&adapter).with_status(expected_status.clone());
+ assert_eq!(transport.transport_kind(), RadrootsTransportKind::Nostr);
+ assert!(transport.adapter().captured_raw_events().is_empty());
let target = nostr_target(RELAY_PRIMARY_WSS);
let request = RadrootsTransportDeliveryRequest::new(
"facade-request-1",
@@ -745,8 +813,7 @@ async fn nostr_transport_facade_delivers_signed_event_payloads() {
adapter.captured_raw_events(),
vec![signed.raw_json().to_owned()]
);
- assert!(status.capabilities.deliver);
- assert!(!status.capabilities.fetch);
+ assert_eq!(status, expected_status);
assert_eq!(receipt.request_id, "facade-request-1");
assert_eq!(receipt.target_receipts.len(), 1);
assert_eq!(receipt.target_receipts[0].target, target);
@@ -827,6 +894,154 @@ async fn nostr_transport_facade_rejects_unsupported_payloads_and_targets() {
.await
.expect_err("target rejected");
assert_eq!(target_error, RadrootsTransportError::InvalidTargetUri);
+
+ let invalid_json_error = transport
+ .deliver(RadrootsTransportDeliveryRequest::new(
+ "facade-request-invalid-json",
+ RadrootsTransportPayload::SignedEventJson {
+ event_id: signed.id_str().to_owned(),
+ raw_json: "{}".to_owned(),
+ digest: "00".repeat(32),
+ },
+ RadrootsTransportTargetSet::new(vec![nostr_target(RELAY_PRIMARY_WSS)])
+ .expect("targets"),
+ RadrootsTransportSatisfactionPolicy::all_accepted(),
+ ))
+ .await
+ .expect_err("invalid event json rejected");
+ assert_eq!(
+ invalid_json_error,
+ RadrootsTransportError::InvalidPayloadBytes
+ );
+
+ let mismatched_id_error = transport
+ .deliver(RadrootsTransportDeliveryRequest::new(
+ "facade-request-mismatched-id",
+ RadrootsTransportPayload::SignedEventJson {
+ event_id: "00".repeat(32),
+ raw_json: signed.raw_json().to_owned(),
+ digest: "00".repeat(32),
+ },
+ RadrootsTransportTargetSet::new(vec![nostr_target(RELAY_PRIMARY_WSS)])
+ .expect("targets"),
+ RadrootsTransportSatisfactionPolicy::all_accepted(),
+ ))
+ .await
+ .expect_err("mismatched event id rejected");
+ assert_eq!(
+ mismatched_id_error,
+ RadrootsTransportError::InvalidPayloadId
+ );
+
+ let mut tampered =
+ serde_json::from_str::<serde_json::Value>(signed.raw_json()).expect("signed event json");
+ tampered["content"] = serde_json::Value::String("tampered".to_owned());
+ let tampered_raw = serde_json::to_string(&tampered).expect("tampered event json");
+ let tampered_error = transport
+ .deliver(RadrootsTransportDeliveryRequest::new(
+ "facade-request-tampered-event",
+ RadrootsTransportPayload::SignedEventJson {
+ event_id: signed.id_str().to_owned(),
+ raw_json: tampered_raw,
+ digest: "00".repeat(32),
+ },
+ RadrootsTransportTargetSet::new(vec![nostr_target(RELAY_PRIMARY_WSS)])
+ .expect("targets"),
+ RadrootsTransportSatisfactionPolicy::all_accepted(),
+ ))
+ .await
+ .expect_err("tampered event rejected");
+ assert_eq!(tampered_error, RadrootsTransportError::InvalidPayloadBytes);
+
+ let forbidden_target_error = transport
+ .deliver(RadrootsTransportDeliveryRequest::new(
+ "facade-request-forbidden-target",
+ RadrootsTransportPayload::unchecked_signed_event_json(
+ signed.id_str(),
+ signed.raw_json(),
+ )
+ .expect("payload"),
+ RadrootsTransportTargetSet::new(vec![nostr_target("wss://127.0.0.1")])
+ .expect("targets"),
+ RadrootsTransportSatisfactionPolicy::all_accepted(),
+ ))
+ .await
+ .expect_err("forbidden relay rejected");
+ assert_eq!(
+ forbidden_target_error,
+ RadrootsTransportError::InvalidTargetUri
+ );
+
+ let local_receipt = transport
+ .deliver(RadrootsTransportDeliveryRequest::new(
+ "facade-request-local-relay",
+ RadrootsTransportPayload::unchecked_signed_event_json(
+ signed.id_str(),
+ signed.raw_json(),
+ )
+ .expect("payload"),
+ RadrootsTransportTargetSet::new(vec![nostr_target("ws://127.0.0.1:21002")])
+ .expect("targets"),
+ RadrootsTransportSatisfactionPolicy::all_accepted(),
+ ))
+ .await
+ .expect("localhost relay accepted");
+ assert_eq!(local_receipt.target_receipts.len(), 1);
+}
+
+#[tokio::test]
+async fn nostr_transport_facade_preserves_adapter_failure_and_omission_evidence() {
+ let signed = signed_post("facade failures");
+ let payload =
+ RadrootsTransportPayload::unchecked_signed_event_json(signed.id_str(), signed.raw_json())
+ .expect("payload");
+ let targets = RadrootsTransportTargetSet::new(vec![
+ nostr_target(RELAY_PRIMARY_WSS),
+ nostr_target(RELAY_SECONDARY_WSS),
+ ])
+ .expect("targets");
+
+ let transport = RadrootsNostrTransport::new(TransportFailurePublishAdapter);
+ let failed = transport
+ .deliver(RadrootsTransportDeliveryRequest::new(
+ "facade-transport-failure",
+ payload.clone(),
+ targets.clone(),
+ RadrootsTransportSatisfactionPolicy::all_accepted(),
+ ))
+ .await
+ .expect("failure receipts");
+ assert_eq!(failed.target_receipts.len(), 2);
+ assert!(failed.target_receipts.iter().all(|receipt| {
+ receipt.outcome.kind == RadrootsTransportOutcomeKind::ConnectionFailed
+ && receipt.status == RadrootsTransportDeliveryTargetStatus::FailedRetryable
+ }));
+
+ let partial = RadrootsNostrTransport::new(PartialPublishAdapter)
+ .deliver(RadrootsTransportDeliveryRequest::new(
+ "facade-partial",
+ payload.clone(),
+ targets.clone(),
+ RadrootsTransportSatisfactionPolicy::all_accepted(),
+ ))
+ .await
+ .expect("partial receipts");
+ assert_eq!(partial.target_receipts.len(), 2);
+ assert_eq!(
+ partial.target_receipts[1].outcome.kind,
+ RadrootsTransportOutcomeKind::RouteUnavailable
+ );
+
+ let error = RadrootsNostrTransport::new(NostrJsonFailurePublishAdapter)
+ .deliver(RadrootsTransportDeliveryRequest::new(
+ "facade-json-failure",
+ payload,
+ targets,
+ RadrootsTransportSatisfactionPolicy::all_accepted(),
+ ))
+ .await
+ .expect_err("adapter JSON error");
+ assert_eq!(error, RadrootsTransportError::InvalidPayloadBytes);
}
#[tokio::test]
@@ -1002,7 +1217,7 @@ async fn publish_all_policy_uses_requested_target_count() {
let receipt = publish_signed_event(
&PartialPublishAdapter,
- RadrootsRelayPublishRequest::new(signed, targets, 1_080)
+ RadrootsRelayPublishRequest::new(signed.clone(), targets.clone(), 1_080)
.with_satisfaction_policy(RadrootsTransportSatisfactionPolicy::all_accepted()),
)
.await
@@ -1012,6 +1227,16 @@ async fn publish_all_policy_uses_requested_target_count() {
assert_eq!(receipt.accepted_count, 1);
assert_eq!(receipt.quorum, 2);
assert!(!receipt.quorum_met);
+
+ let no_wait = publish_signed_event(
+ &PartialPublishAdapter,
+ RadrootsRelayPublishRequest::new(signed, targets, 1_081)
+ .with_satisfaction_policy(RadrootsTransportSatisfactionPolicy::NoWait),
+ )
+ .await
+ .expect("no-wait publish");
+ assert_eq!(no_wait.quorum, 0);
+ assert!(no_wait.quorum_met);
}
#[test]
@@ -1753,6 +1978,424 @@ async fn outbox_publish_persists_partial_success_and_skips_accepted_retry() {
}
#[tokio::test]
+async fn outbox_transport_facade_persists_partial_success_and_retryable_failures() {
+ let signed = signed_post("transport facade outbox");
+ let outbox = RadrootsOutbox::open_memory().await.expect("outbox");
+ let store = RadrootsEventStore::open_memory().await.expect("store");
+ let draft = RadrootsEventDraft::new(
+ "radroots.social.post.v1",
+ KIND_POST,
+ signed.created_at(),
+ signed.tags_as_vec(),
+ signed.content().to_owned(),
+ signed.pubkey_str(),
+ )
+ .expect("draft");
+ let receipt = outbox
+ .enqueue_operation(all_accepted_outbox_operation_input(
+ draft,
+ [RELAY_PRIMARY_WSS, RELAY_SECONDARY_WSS],
+ ))
+ .await
+ .expect("enqueue");
+ let claimed = outbox
+ .claim_next_ready_event("signer", "transport-sign", 2_000, 1_000)
+ .await
+ .expect("sign claim")
+ .expect("sign claim");
+ let signed = complete_claimed_signing(&outbox, &claimed, 1_100).await;
+ let publish_claim = outbox
+ .claim_next_ready_event("publisher", "transport-publish", 3_000, 1_100)
+ .await
+ .expect("publish claim")
+ .expect("publish claim");
+
+ let adapter = RadrootsMockRelayPublishAdapter::new()
+ .with_outcome(RELAY_PRIMARY_WSS, RadrootsRelayOutcome::accepted())
+ .with_outcome(
+ RELAY_SECONDARY_WSS,
+ RadrootsRelayOutcome::timeout("timeout: transport facade"),
+ );
+ let transport = RadrootsNostrTransport::new(adapter);
+ let published = publish_claimed_outbox_event_with_transport(
+ &outbox,
+ &store,
+ &transport,
+ &publish_claim,
+ RadrootsOutboxPublishPolicy::new(2_500),
+ 2_200,
+ )
+ .await
+ .expect("transport publish");
+
+ assert_eq!(published.event_id, signed.id_str());
+ assert_eq!(published.attempted_count, 2);
+ assert_eq!(published.accepted_count, 1);
+ assert_eq!(published.retryable_count, 1);
+ assert_eq!(published.terminal_count, 0);
+ assert!(!published.quorum_met);
+ assert_eq!(published.relay_receipts.len(), 2);
+ let targets = outbox
+ .delivery_targets(receipt.outbox_event_id)
+ .await
+ .expect("targets");
+ assert_eq!(
+ targets
+ .iter()
+ .find(|target| target.endpoint_uri.as_str() == RELAY_PRIMARY_WSS)
+ .expect("primary")
+ .status,
+ RadrootsOutboxDeliveryTargetStatus::Accepted
+ );
+ assert_eq!(
+ targets
+ .iter()
+ .find(|target| target.endpoint_uri.as_str() == RELAY_SECONDARY_WSS)
+ .expect("secondary")
+ .status,
+ RadrootsOutboxDeliveryTargetStatus::FailedRetryable
+ );
+ let event = outbox
+ .get_event(receipt.outbox_event_id)
+ .await
+ .expect("event")
+ .expect("event");
+ assert_eq!(event.state, RadrootsOutboxEventState::PublishRetryable);
+ let observations = store
+ .observations_for_event(signed.id_str())
+ .await
+ .expect("observations");
+ assert_outbox_publish_observations(&observations, 1);
+}
+
+#[tokio::test]
+async fn outbox_transport_facade_persists_every_delivery_status() {
+ let signed = signed_post("transport outcome matrix");
+ let outbox = RadrootsOutbox::open_memory().await.expect("outbox");
+ let store = RadrootsEventStore::open_memory().await.expect("store");
+ let draft = RadrootsEventDraft::new(
+ "radroots.social.post.v1",
+ KIND_POST,
+ signed.created_at(),
+ signed.tags_as_vec(),
+ signed.content().to_owned(),
+ signed.pubkey_str(),
+ )
+ .expect("draft");
+ let relays = (0..14)
+ .map(|index| format!("wss://relay-{index}.example.com"))
+ .collect::<Vec<_>>();
+ let receipt = outbox
+ .enqueue_operation(all_accepted_outbox_operation_input(draft, &relays))
+ .await
+ .expect("enqueue");
+ let claimed = outbox
+ .claim_next_ready_event("signer", "matrix-sign", 2_000, 1_000)
+ .await
+ .expect("sign claim")
+ .expect("sign claim");
+ let signed = complete_claimed_signing(&outbox, &claimed, 1_100).await;
+ let publish_claim = outbox
+ .claim_next_ready_event("publisher", "matrix-publish", 3_000, 1_100)
+ .await
+ .expect("publish claim")
+ .expect("publish claim");
+ let outcomes = [
+ RadrootsTransportOutcomeKind::Accepted,
+ RadrootsTransportOutcomeKind::DuplicateAccepted,
+ RadrootsTransportOutcomeKind::Delivered,
+ RadrootsTransportOutcomeKind::Forwarded,
+ RadrootsTransportOutcomeKind::StoredByGateway,
+ RadrootsTransportOutcomeKind::Seen,
+ RadrootsTransportOutcomeKind::DeferredUntilImplemented,
+ RadrootsTransportOutcomeKind::Rejected,
+ RadrootsTransportOutcomeKind::RouteUnavailable,
+ RadrootsTransportOutcomeKind::PayloadTooLarge,
+ RadrootsTransportOutcomeKind::PolicyDenied,
+ RadrootsTransportOutcomeKind::Timeout,
+ RadrootsTransportOutcomeKind::ConnectionFailed,
+ RadrootsTransportOutcomeKind::TransportUnavailable,
+ ]
+ .into_iter()
+ .map(RadrootsTransportOutcome::new)
+ .collect();
+ let transport = ScriptedTransport::new(outcomes);
+
+ let published = publish_claimed_outbox_event_with_transport(
+ &outbox,
+ &store,
+ &transport,
+ &publish_claim,
+ RadrootsOutboxPublishPolicy::new(2_500),
+ 2_200,
+ )
+ .await
+ .expect("transport publish");
+
+ assert_eq!(published.event_id, signed.id_str());
+ assert_eq!(published.attempted_count, 13);
+ assert_eq!(published.accepted_count, 6);
+ assert_eq!(published.retryable_count, 3);
+ assert_eq!(published.terminal_count, 5);
+ assert!(!published.quorum_met);
+ assert_eq!(published.target_receipts.len(), 14);
+ assert_eq!(published.relay_receipts.len(), 14);
+ let targets = outbox
+ .delivery_targets(receipt.outbox_event_id)
+ .await
+ .expect("targets");
+ let expected_statuses = [
+ RadrootsOutboxDeliveryTargetStatus::Accepted,
+ RadrootsOutboxDeliveryTargetStatus::Accepted,
+ RadrootsOutboxDeliveryTargetStatus::Delivered,
+ RadrootsOutboxDeliveryTargetStatus::Forwarded,
+ RadrootsOutboxDeliveryTargetStatus::StoredByGateway,
+ RadrootsOutboxDeliveryTargetStatus::Seen,
+ RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented,
+ RadrootsOutboxDeliveryTargetStatus::FailedTerminal,
+ RadrootsOutboxDeliveryTargetStatus::FailedTerminal,
+ RadrootsOutboxDeliveryTargetStatus::FailedTerminal,
+ RadrootsOutboxDeliveryTargetStatus::SkippedPolicyDenied,
+ RadrootsOutboxDeliveryTargetStatus::FailedRetryable,
+ RadrootsOutboxDeliveryTargetStatus::FailedRetryable,
+ RadrootsOutboxDeliveryTargetStatus::FailedRetryable,
+ ];
+ assert_eq!(targets.len(), expected_statuses.len());
+ for (target, expected_status) in targets.iter().zip(expected_statuses) {
+ assert_eq!(target.status, expected_status);
+ }
+ let event = outbox
+ .get_event(receipt.outbox_event_id)
+ .await
+ .expect("event")
+ .expect("event");
+ assert_eq!(event.state, RadrootsOutboxEventState::PublishRetryable);
+ let observations = store
+ .observations_for_event(signed.id_str())
+ .await
+ .expect("observations");
+ assert_outbox_publish_observations(&observations, 6);
+}
+
+#[tokio::test]
+async fn outbox_transport_facade_rejects_pending_receipts() {
+ let signed = signed_post("pending transport receipt");
+ let outbox = RadrootsOutbox::open_memory().await.expect("outbox");
+ let store = RadrootsEventStore::open_memory().await.expect("store");
+ let draft = RadrootsEventDraft::new(
+ "radroots.social.post.v1",
+ KIND_POST,
+ signed.created_at(),
+ signed.tags_as_vec(),
+ signed.content().to_owned(),
+ signed.pubkey_str(),
+ )
+ .expect("draft");
+ outbox
+ .enqueue_operation(all_accepted_outbox_operation_input(
+ draft,
+ [RELAY_PRIMARY_WSS],
+ ))
+ .await
+ .expect("enqueue");
+ let claimed = outbox
+ .claim_next_ready_event("signer", "pending-sign", 2_000, 1_000)
+ .await
+ .expect("sign claim")
+ .expect("sign claim");
+ complete_claimed_signing(&outbox, &claimed, 1_100).await;
+ let publish_claim = outbox
+ .claim_next_ready_event("publisher", "pending-publish", 3_000, 1_100)
+ .await
+ .expect("publish claim")
+ .expect("publish claim");
+ let transport = ScriptedTransport::new(vec![
+ RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted)
+ .with_target_status(RadrootsTransportDeliveryTargetStatus::Pending),
+ ]);
+
+ let error = publish_claimed_outbox_event_with_transport(
+ &outbox,
+ &store,
+ &transport,
+ &publish_claim,
+ RadrootsOutboxPublishPolicy::new(2_500),
+ 2_200,
+ )
+ .await
+ .expect_err("pending receipt rejected");
+ assert!(matches!(
+ error,
+ RadrootsRelayTransportError::TransportContract(_)
+ ));
+}
+
+#[tokio::test]
+async fn outbox_transport_facade_handles_empty_and_invalid_claim_plans() {
+ let signed = signed_post("transport plan edges");
+ let outbox = RadrootsOutbox::open_memory().await.expect("outbox");
+ let store = RadrootsEventStore::open_memory().await.expect("store");
+ let draft = RadrootsEventDraft::new(
+ "radroots.social.post.v1",
+ KIND_POST,
+ signed.created_at(),
+ signed.tags_as_vec(),
+ signed.content().to_owned(),
+ signed.pubkey_str(),
+ )
+ .expect("draft");
+ let receipt = outbox
+ .enqueue_operation(all_accepted_outbox_operation_input(
+ draft,
+ [RELAY_PRIMARY_WSS, RELAY_SECONDARY_WSS],
+ ))
+ .await
+ .expect("enqueue");
+ let claimed = outbox
+ .claim_next_ready_event("signer", "plan-sign", 2_000, 1_000)
+ .await
+ .expect("sign claim")
+ .expect("sign claim");
+ let signed = complete_claimed_signing(&outbox, &claimed, 1_100).await;
+ let publish_claim = outbox
+ .claim_next_ready_event("publisher", "plan-publish", 3_000, 1_100)
+ .await
+ .expect("publish claim")
+ .expect("publish claim");
+ for target in &publish_claim.delivery_targets {
+ outbox
+ .mark_delivery_target_accepted(
+ publish_claim.outbox_event_id,
+ publish_claim.claim_token.as_str(),
+ target.delivery_target_id,
+ 2_150,
+ )
+ .await
+ .expect("accepted target");
+ }
+ let published = publish_claimed_outbox_event_with_transport(
+ &outbox,
+ &store,
+ &ScriptedTransport::new(Vec::new()),
+ &publish_claim,
+ RadrootsOutboxPublishPolicy::new(2_500),
+ 2_200,
+ )
+ .await
+ .expect("already satisfied publish");
+ assert_eq!(published.event_id, signed.id_str());
+ assert_eq!(published.attempted_count, 0);
+ assert_eq!(published.accepted_count, 2);
+ assert!(published.quorum_met);
+
+ let second_signed = signed_post("invalid claimed plan");
+ let second_draft = RadrootsEventDraft::new(
+ "radroots.social.post.v1",
+ KIND_POST,
+ second_signed.created_at(),
+ second_signed.tags_as_vec(),
+ second_signed.content().to_owned(),
+ second_signed.pubkey_str(),
+ )
+ .expect("draft");
+ outbox
+ .enqueue_operation(all_accepted_outbox_operation_input(
+ second_draft,
+ [RELAY_PRIMARY_WSS],
+ ))
+ .await
+ .expect("second enqueue");
+ let second_claimed = outbox
+ .claim_next_ready_event("signer", "invalid-plan-sign", 3_000, 2_200)
+ .await
+ .expect("second sign claim")
+ .expect("second sign claim");
+ complete_claimed_signing(&outbox, &second_claimed, 2_300).await;
+ let second_publish_claim = outbox
+ .claim_next_ready_event("publisher", "invalid-plan-publish", 4_000, 2_300)
+ .await
+ .expect("second publish claim")
+ .expect("second publish claim");
+ let mut invalid_claim = second_publish_claim.clone();
+ invalid_claim.active_delivery_plan_id = None;
+ let error = publish_claimed_outbox_event_with_transport(
+ &outbox,
+ &store,
+ &ScriptedTransport::new(Vec::new()),
+ &invalid_claim,
+ RadrootsOutboxPublishPolicy::new(3_500),
+ 2_400,
+ )
+ .await
+ .expect_err("missing plan rejected");
+ assert!(matches!(error, RadrootsRelayTransportError::Transport(_)));
+
+ invalid_claim.active_delivery_plan_id = Some(i64::MAX);
+ let error = publish_claimed_outbox_event_with_transport(
+ &outbox,
+ &store,
+ &ScriptedTransport::new(Vec::new()),
+ &invalid_claim,
+ RadrootsOutboxPublishPolicy::new(3_500),
+ 2_401,
+ )
+ .await
+ .expect_err("unknown plan rejected");
+ assert!(matches!(error, RadrootsRelayTransportError::Transport(_)));
+
+ let event = outbox
+ .get_event(receipt.outbox_event_id)
+ .await
+ .expect("event")
+ .expect("event");
+ assert_eq!(event.state, RadrootsOutboxEventState::Published);
+}
+
+#[tokio::test]
+async fn outbox_transport_facade_requires_signed_claims() {
+ let signed = signed_post("missing transport signature");
+ let outbox = RadrootsOutbox::open_memory().await.expect("outbox");
+ let store = RadrootsEventStore::open_memory().await.expect("store");
+ let draft = RadrootsEventDraft::new(
+ "radroots.social.post.v1",
+ KIND_POST,
+ signed.created_at(),
+ signed.tags_as_vec(),
+ signed.content().to_owned(),
+ signed.pubkey_str(),
+ )
+ .expect("draft");
+ let receipt = outbox
+ .enqueue_operation(all_accepted_outbox_operation_input(
+ draft,
+ [RELAY_PRIMARY_WSS],
+ ))
+ .await
+ .expect("enqueue");
+ let claimed = outbox
+ .claim_next_ready_event("signer", "unsigned-transport", 2_000, 1_000)
+ .await
+ .expect("claim")
+ .expect("claim");
+
+ let error = publish_claimed_outbox_event_with_transport(
+ &outbox,
+ &store,
+ &ScriptedTransport::new(Vec::new()),
+ &claimed,
+ RadrootsOutboxPublishPolicy::new(2_500),
+ 1_100,
+ )
+ .await
+ .expect_err("missing signature rejected");
+ assert!(matches!(
+ error,
+ RadrootsRelayTransportError::MissingSignedOutboxEvent(event_id)
+ if event_id == receipt.outbox_event_id
+ ));
+}
+
+#[tokio::test]
async fn outbox_publish_fans_out_endpoint_receipts_to_scoped_logical_targets() {
let signed = signed_post("scoped duplicate relay");
let outbox = RadrootsOutbox::open_memory().await.expect("outbox");
@@ -2091,6 +2734,7 @@ async fn outbox_publish_required_targets_fan_out_same_endpoint_scoped_receipts()
.expect("draft");
let required = scoped_nostr_target(RELAY_PRIMARY_WSS, "foodshed.west", "West foodshed");
let optional = scoped_nostr_target(RELAY_PRIMARY_WSS, "foodshed.east", "East foodshed");
+ let terminal = scoped_nostr_target(RELAY_PRIMARY_WSS, "foodshed.closed", "Closed foodshed");
let receipt = outbox
.enqueue_operation(RadrootsOutboxOperationInput::new(
"publish_post",
@@ -2103,7 +2747,12 @@ async fn outbox_publish_required_targets_fan_out_same_endpoint_scoped_receipts()
vec![required.fingerprint.clone()],
)
.expect("required target policy"),
- vec![required.clone(), optional.clone()],
+ vec![
+ required.clone(),
+ optional.clone(),
+ terminal.clone(),
+ RadrootsTransportTarget::reticulum().expect("reticulum target"),
+ ],
),
1_000,
))
@@ -2120,6 +2769,35 @@ async fn outbox_publish_required_targets_fan_out_same_endpoint_scoped_receipts()
.await
.expect("claim")
.expect("publish claim");
+ let optional_record = publish_claim
+ .delivery_targets
+ .iter()
+ .find(|target| target.endpoint_fingerprint == optional.fingerprint)
+ .expect("optional target");
+ outbox
+ .mark_delivery_target_accepted(
+ publish_claim.outbox_event_id,
+ publish_claim.claim_token.as_str(),
+ optional_record.delivery_target_id,
+ 2_150,
+ )
+ .await
+ .expect("optional target accepted");
+ let terminal_record = publish_claim
+ .delivery_targets
+ .iter()
+ .find(|target| target.endpoint_fingerprint == terminal.fingerprint)
+ .expect("terminal target");
+ outbox
+ .mark_delivery_target_failed_terminal(
+ publish_claim.outbox_event_id,
+ publish_claim.claim_token.as_str(),
+ terminal_record.delivery_target_id,
+ "terminal target",
+ 2_151,
+ )
+ .await
+ .expect("terminal target completed");
let adapter = RadrootsMockRelayPublishAdapter::new()
.with_outcome(RELAY_PRIMARY_WSS, RadrootsRelayOutcome::accepted());
@@ -2128,7 +2806,7 @@ async fn outbox_publish_required_targets_fan_out_same_endpoint_scoped_receipts()
&store,
&adapter,
&publish_claim,
- RadrootsOutboxPublishPolicy::new(2_500),
+ RadrootsOutboxPublishPolicy::new(2_500).republish_accepted_relays(true),
2_200,
)
.await
@@ -2158,10 +2836,24 @@ async fn outbox_publish_required_targets_fan_out_same_endpoint_scoped_receipts()
.delivery_targets(receipt.outbox_event_id)
.await
.expect("targets");
- assert_eq!(targets.len(), 2);
- assert!(targets.iter().all(|target| {
- target.endpoint_uri.as_str() == RELAY_PRIMARY_WSS
- && target.status == RadrootsOutboxDeliveryTargetStatus::Accepted
+ assert_eq!(targets.len(), 4);
+ assert!(
+ targets
+ .iter()
+ .filter(|target| { target.transport_kind == RadrootsTransportKind::Nostr })
+ .filter(|target| target.endpoint_fingerprint != terminal.fingerprint)
+ .all(|target| {
+ target.endpoint_uri.as_str() == RELAY_PRIMARY_WSS
+ && target.status == RadrootsOutboxDeliveryTargetStatus::Accepted
+ })
+ );
+ assert!(targets.iter().any(|target| {
+ target.endpoint_fingerprint == terminal.fingerprint
+ && target.status == RadrootsOutboxDeliveryTargetStatus::FailedTerminal
+ }));
+ assert!(targets.iter().any(|target| {
+ target.transport_kind == RadrootsTransportKind::Reticulum
+ && target.status == RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented
}));
}
@@ -2907,6 +3599,21 @@ async fn outbox_publish_rejects_invalid_relay_target_uri_before_adapter_publish(
RadrootsRelayTransportError::RelayUrlForbiddenDestination { .. }
));
assert!(adapter.captured_raw_events().is_empty());
+
+ let transport_error = publish_claimed_outbox_event_with_transport(
+ &outbox,
+ &store,
+ &ScriptedTransport::new(Vec::new()),
+ &publish_claim,
+ RadrootsOutboxPublishPolicy::new(2_500),
+ 2_201,
+ )
+ .await
+ .expect_err("invalid transport relay target");
+ assert!(matches!(
+ transport_error,
+ RadrootsRelayTransportError::RelayUrlForbiddenDestination { .. }
+ ));
let event = outbox
.get_event(receipt.outbox_event_id)
.await
diff --git a/crates/transport_publish_protocol/src/lib.rs b/crates/transport_publish_protocol/src/lib.rs
@@ -1302,23 +1302,14 @@ fn validate_job_status_state(
return Err(TransportPublishProtocolError::InvalidJobStatusState);
}
let required_count = job.delivery_policy.required_target_count(job.target_count);
- let (
- satisfied,
- retryable_status_count,
- terminal_status_count,
- has_deferred,
- has_deferred_until_implemented,
- ) = match &job.delivery_policy {
+ let (satisfied, retryable_status_count, terminal_status_count, has_deferred) = match &job
+ .delivery_policy
+ {
TransportPublishDeliveryPolicy::RequiredTargets { targets } => {
let required_outcomes = required_policy_outcomes(targets, &job.targets)?;
- let satisfied = targets.iter().all(|required| {
- required_outcomes.iter().any(|outcome| {
- target_outcome_fingerprint(outcome, 0).is_ok_and(|fingerprint| {
- fingerprint == *required
- && outcome.outcome_kind.counts_toward_accepted_delivery()
- })
- })
- });
+ let satisfied = required_outcomes
+ .iter()
+ .all(|outcome| outcome.outcome_kind.counts_toward_accepted_delivery());
(
satisfied,
required_outcomes
@@ -1332,54 +1323,35 @@ fn validate_job_status_state(
required_outcomes.iter().any(|outcome| {
outcome.outcome_kind == TransportPublishOutcomeKind::DeferredUntilImplemented
}),
- required_outcomes.iter().any(|outcome| {
- outcome.outcome_kind == TransportPublishOutcomeKind::DeferredUntilImplemented
- }),
)
}
TransportPublishDeliveryPolicy::Any
| TransportPublishDeliveryPolicy::All
| TransportPublishDeliveryPolicy::Quorum { .. } => (
- required_count > 0 && acknowledged_count >= required_count,
+ acknowledged_count >= required_count,
retryable_count,
terminal_count,
job.targets.iter().any(|target| {
target.outcome_kind == TransportPublishOutcomeKind::DeferredUntilImplemented
}),
- job.targets.iter().any(|target| {
- target.outcome_kind == TransportPublishOutcomeKind::DeferredUntilImplemented
- }),
),
};
- match job.status {
- TransportPublishJobStatus::DeliverySatisfied if satisfied => Ok(()),
- TransportPublishJobStatus::DeliveryUnsatisfiedRetryable
- if !satisfied && retryable_status_count > 0 =>
- {
- Ok(())
- }
- TransportPublishJobStatus::DeliveryUnsatisfiedTerminal
- if !satisfied && retryable_status_count == 0 && terminal_status_count > 0 =>
- {
- Ok(())
- }
- TransportPublishJobStatus::DeliveryDeferred
- if !satisfied
- && terminal_status_count == 0
- && retryable_status_count == 0
- && has_deferred =>
- {
- Ok(())
- }
- TransportPublishJobStatus::DeliveryDeferredUntilImplemented
- if !satisfied
- && terminal_status_count == 0
- && retryable_status_count == 0
- && has_deferred_until_implemented =>
- {
- Ok(())
- }
- _ => Err(TransportPublishProtocolError::InvalidJobStatusState),
+ let status_matches = if satisfied {
+ job.status == TransportPublishJobStatus::DeliverySatisfied
+ } else if retryable_status_count > 0 {
+ job.status == TransportPublishJobStatus::DeliveryUnsatisfiedRetryable
+ } else if terminal_status_count > 0 {
+ job.status == TransportPublishJobStatus::DeliveryUnsatisfiedTerminal
+ } else if has_deferred {
+ job.status == TransportPublishJobStatus::DeliveryDeferred
+ || job.status == TransportPublishJobStatus::DeliveryDeferredUntilImplemented
+ } else {
+ false
+ };
+ if status_matches {
+ Ok(())
+ } else {
+ Err(TransportPublishProtocolError::InvalidJobStatusState)
}
}
@@ -2264,6 +2236,22 @@ mod tests {
"delivery quorum must be greater than zero",
),
(
+ TransportPublishProtocolError::EmptyRequiredTargetSet,
+ "delivery required target set must not be empty",
+ ),
+ (
+ TransportPublishProtocolError::InvalidRequiredTargetFingerprint { index: 1 },
+ "delivery required target 1 fingerprint is invalid",
+ ),
+ (
+ TransportPublishProtocolError::DuplicateRequiredTargetFingerprint { index: 2 },
+ "delivery required target 2 duplicates an earlier fingerprint",
+ ),
+ (
+ TransportPublishProtocolError::RequiredTargetNotInTargetSet { index: 3 },
+ "delivery required target 3 is not in the target set",
+ ),
+ (
TransportPublishProtocolError::EmptyPrincipalId,
"principal id must not be empty",
),
@@ -2272,9 +2260,65 @@ mod tests {
"job id must not be empty",
),
(
+ TransportPublishProtocolError::InvalidJobTargetCount {
+ expected: 1,
+ actual: 2,
+ },
+ "job target_count 2 does not match 1 target outcomes",
+ ),
+ (
+ TransportPublishProtocolError::InvalidJobAcknowledgedCount {
+ expected: 1,
+ actual: 2,
+ },
+ "job acknowledged_count 2 does not match 1 target outcomes",
+ ),
+ (
+ TransportPublishProtocolError::InvalidJobRetryableCount {
+ expected: 1,
+ actual: 2,
+ },
+ "job retryable_count 2 does not match 1 target outcomes",
+ ),
+ (
+ TransportPublishProtocolError::InvalidJobTerminalCount {
+ expected: 1,
+ actual: 2,
+ },
+ "job terminal_count 2 does not match 1 target outcomes",
+ ),
+ (
+ TransportPublishProtocolError::InvalidJobTerminalState,
+ "job terminal flag does not match status",
+ ),
+ (
+ TransportPublishProtocolError::InvalidJobDeliverySatisfiedState,
+ "job delivery_satisfied flag does not match status",
+ ),
+ (
+ TransportPublishProtocolError::InvalidJobCompletedAt,
+ "job completed_at_ms does not match status or request time",
+ ),
+ (
+ TransportPublishProtocolError::InvalidJobStatusState,
+ "job status does not match target outcomes",
+ ),
+ (
TransportPublishProtocolError::InvalidExplicitTargetOutcome { index: 6 },
"transport target outcome 6 does not match explicit target policy",
),
+ (
+ TransportPublishProtocolError::InvalidTargetOutcomeKind { index: 7 },
+ "transport target outcome 7 kind is not valid for its transport",
+ ),
+ (
+ TransportPublishProtocolError::InvalidTargetSource { index: 8 },
+ "transport target outcome 8 source does not match transport kind",
+ ),
+ (
+ TransportPublishProtocolError::InvalidReticulumOutcome { index: 9 },
+ "transport target outcome 9 Reticulum must be unavailable or deferred",
+ ),
];
for (error, message) in cases {
@@ -2538,4 +2582,378 @@ mod tests {
Err(TransportPublishProtocolError::InvalidQuorum)
);
}
+
+ #[test]
+ fn low_level_target_validation_covers_defensive_mapping_boundaries() {
+ assert_eq!(
+ transport_kind_error(RadrootsTransportError::EmptyTransportKind, 1),
+ TransportPublishProtocolError::EmptyTransportKind { index: 1 }
+ );
+ assert_eq!(
+ transport_kind_error(RadrootsTransportError::InvalidTransportKind, 2),
+ TransportPublishProtocolError::InvalidTransportKind { index: 2 }
+ );
+ assert_eq!(
+ target_fingerprint_error(RadrootsTransportError::EmptyTargetUri, 3),
+ TransportPublishProtocolError::EmptyEndpointUri { index: 3 }
+ );
+ assert_eq!(
+ target_fingerprint_error(RadrootsTransportError::InvalidTargetUri, 4),
+ TransportPublishProtocolError::InvalidEndpointUri { index: 4 }
+ );
+ for (error, expected) in [
+ (
+ RadrootsTransportError::EmptyTargetScope,
+ TransportPublishProtocolError::EmptyTargetScope { index: 5 },
+ ),
+ (
+ RadrootsTransportError::InvalidTargetScope,
+ TransportPublishProtocolError::InvalidTargetScope { index: 5 },
+ ),
+ (
+ RadrootsTransportError::EmptyTargetLabel,
+ TransportPublishProtocolError::EmptyTargetLabel { index: 5 },
+ ),
+ (
+ RadrootsTransportError::InvalidTargetLabel,
+ TransportPublishProtocolError::InvalidTargetLabel { index: 5 },
+ ),
+ (
+ RadrootsTransportError::InvalidTargetUri,
+ TransportPublishProtocolError::InvalidEndpointUri { index: 5 },
+ ),
+ ] {
+ assert_eq!(target_metadata_error(error, 5), expected);
+ }
+
+ let invalid_fingerprint: RadrootsTransportTargetFingerprint =
+ serde_json::from_str("\"invalid\"").expect("unchecked serde newtype fixture");
+ assert_eq!(
+ TransportPublishDeliveryPolicy::required_targets(vec![invalid_fingerprint]),
+ Err(TransportPublishProtocolError::InvalidRequiredTargetFingerprint { index: 0 })
+ );
+
+ assert!(
+ transport_target_from_parts(RadrootsTransportKind::Local, "local:publish", None, None,)
+ .is_ok()
+ );
+ assert_eq!(
+ transport_target_from_parts(
+ RadrootsTransportKind::Reticulum,
+ "reticulum:other",
+ None,
+ None,
+ ),
+ Err(RadrootsTransportError::InvalidTargetUri)
+ );
+
+ let valid = nostr_outcome(TransportPublishOutcomeKind::Accepted);
+ assert!(validate_target_outcome(&valid, 0).is_ok());
+ assert!(target_outcome_fingerprint(&valid, 0).is_ok());
+
+ let mut invalid_target = TransportPublishTarget::nostr("wss://relay.example");
+ invalid_target.transport_kind = "Nostr".to_owned();
+ assert_eq!(
+ invalid_target.fingerprint(10),
+ Err(TransportPublishProtocolError::InvalidTransportKind { index: 10 })
+ );
+ invalid_target = TransportPublishTarget::nostr("wss://relay.example");
+ invalid_target.target_scope = Some(" ".to_owned());
+ assert_eq!(
+ invalid_target.fingerprint(11),
+ Err(TransportPublishProtocolError::InvalidTargetScope { index: 11 })
+ );
+ invalid_target = TransportPublishTarget::nostr("wss://relay.example");
+ invalid_target.target_label = Some(" ".to_owned());
+ assert_eq!(
+ invalid_target.fingerprint(12),
+ Err(TransportPublishProtocolError::EmptyTargetLabel { index: 12 })
+ );
+
+ let target = TransportPublishTarget::nostr("wss://relay.example");
+ let mut different_kind = valid.clone();
+ different_kind.transport_kind = "local".to_owned();
+ different_kind.endpoint_uri = "local:publish".to_owned();
+ assert!(
+ !target
+ .identity_eq(0, &different_kind, 0)
+ .expect("different transport identity")
+ );
+
+ let mut invalid = valid.clone();
+ invalid.transport_kind = " ".to_owned();
+ assert_eq!(
+ validate_target_outcome(&invalid, 1),
+ Err(TransportPublishProtocolError::EmptyTransportKind { index: 1 })
+ );
+ invalid.transport_kind = "Nostr".to_owned();
+ assert_eq!(
+ validate_target_outcome(&invalid, 2),
+ Err(TransportPublishProtocolError::InvalidTransportKind { index: 2 })
+ );
+ assert_eq!(
+ target_outcome_fingerprint(&invalid, 2),
+ Err(TransportPublishProtocolError::InvalidTransportKind { index: 2 })
+ );
+ invalid = valid.clone();
+ invalid.endpoint_uri = " ".to_owned();
+ assert_eq!(
+ validate_target_outcome(&invalid, 3),
+ Err(TransportPublishProtocolError::EmptyEndpointUri { index: 3 })
+ );
+ invalid = valid.clone();
+ invalid.target_scope = Some(" ".to_owned());
+ assert_eq!(
+ validate_target_outcome(&invalid, 4),
+ Err(TransportPublishProtocolError::InvalidTargetScope { index: 4 })
+ );
+ assert_eq!(
+ target_outcome_fingerprint(&invalid, 4),
+ Err(TransportPublishProtocolError::InvalidTargetScope { index: 4 })
+ );
+ invalid = valid.clone();
+ invalid.target_label = Some(" ".to_owned());
+ assert_eq!(
+ validate_target_outcome(&invalid, 5),
+ Err(TransportPublishProtocolError::EmptyTargetLabel { index: 5 })
+ );
+ assert_eq!(
+ target_outcome_fingerprint(&invalid, 5),
+ Err(TransportPublishProtocolError::EmptyTargetLabel { index: 5 })
+ );
+ invalid = valid.clone();
+ invalid.endpoint_uri = "not a URI".to_owned();
+ assert_eq!(
+ target_outcome_fingerprint(&invalid, 6),
+ Err(TransportPublishProtocolError::InvalidEndpointUri { index: 6 })
+ );
+
+ let mut reticulum =
+ reticulum_outcome(TransportPublishOutcomeKind::DeferredUntilImplemented);
+ reticulum.endpoint_uri = "reticulum:other".to_owned();
+ assert_eq!(
+ validate_target_outcome(&reticulum, 7),
+ Err(TransportPublishProtocolError::InvalidReticulumEndpoint { index: 7 })
+ );
+ reticulum.endpoint_uri = RADROOTS_RETICULUM_ENDPOINT_URI.to_owned();
+ reticulum.attempted = true;
+ assert_eq!(
+ validate_target_outcome(&reticulum, 8),
+ Err(TransportPublishProtocolError::InvalidReticulumOutcome { index: 8 })
+ );
+ let mut wrong_source = valid.clone();
+ wrong_source.source = TransportPublishTargetSource::Reticulum;
+ assert_eq!(
+ validate_target_outcome(&wrong_source, 9),
+ Err(TransportPublishProtocolError::InvalidTargetSource { index: 9 })
+ );
+
+ let required = TransportPublishTarget::nostr("wss://required.example")
+ .fingerprint(0)
+ .expect("required fingerprint");
+ let mut unparseable = nostr_outcome(TransportPublishOutcomeKind::Accepted);
+ unparseable.endpoint_uri = "not a URI".to_owned();
+ assert_eq!(
+ required_policy_outcomes(&[required], &[unparseable]),
+ Err(TransportPublishProtocolError::RequiredTargetNotInTargetSet { index: 0 })
+ );
+
+ assert!(
+ validate_job_target_policy_outcomes(
+ &TransportPublishTargetPolicy::nostr(
+ NostrPublishTargetSourcePolicy::ExplicitOnly,
+ Vec::new(),
+ ),
+ &[],
+ )
+ .is_ok()
+ );
+ assert!(
+ validate_job_target_policy_outcomes(
+ &TransportPublishTargetPolicy::explicit_targets(vec![
+ TransportPublishTarget::nostr("wss://relay.example"),
+ ]),
+ &[],
+ )
+ .is_ok()
+ );
+
+ let nostr_request = TransportPublishEventRequest {
+ raw_event_json: raw_event_json(),
+ target_policy: TransportPublishTargetPolicy::nostr(
+ NostrPublishTargetSourcePolicy::DaemonDefaultOnly,
+ Vec::new(),
+ ),
+ delivery_policy: TransportPublishDeliveryPolicy::Any,
+ idempotency_key: None,
+ timeout_ms: None,
+ };
+ nostr_request
+ .validate(10)
+ .expect("valid Nostr policy request");
+
+ let accepted_without_targets = TransportPublishJobView {
+ job_id: "accepted-empty".to_owned(),
+ status: TransportPublishJobStatus::Accepted,
+ terminal: false,
+ delivery_satisfied: false,
+ event_id: "0".repeat(64),
+ pubkey: "1".repeat(64),
+ event_kind: 30_402,
+ target_policy: TransportPublishTargetPolicy::nostr(
+ NostrPublishTargetSourcePolicy::DaemonDefaultOnly,
+ Vec::new(),
+ ),
+ delivery_policy: TransportPublishDeliveryPolicy::Any,
+ target_count: 0,
+ acknowledged_count: 0,
+ retryable_count: 0,
+ terminal_count: 0,
+ requested_at_ms: 1,
+ completed_at_ms: None,
+ last_error: None,
+ targets: Vec::new(),
+ };
+ accepted_without_targets
+ .validate()
+ .expect("accepted job may await target resolution");
+ }
+
+ #[test]
+ fn job_count_and_status_guards_reject_every_inconsistent_shape() {
+ let mut retryable_count_mismatch = job_from_targets(
+ TransportPublishJobStatus::DeliveryUnsatisfiedRetryable,
+ TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget::nostr(
+ "wss://relay.example.com",
+ )]),
+ vec![nostr_outcome(TransportPublishOutcomeKind::Timeout)],
+ );
+ retryable_count_mismatch.retryable_count = 0;
+ assert_eq!(
+ retryable_count_mismatch.validate(),
+ Err(TransportPublishProtocolError::InvalidJobRetryableCount {
+ expected: 1,
+ actual: 0,
+ })
+ );
+
+ let mut terminal_count_mismatch = job_from_targets(
+ TransportPublishJobStatus::DeliveryUnsatisfiedTerminal,
+ TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget::nostr(
+ "wss://relay.example.com",
+ )]),
+ vec![nostr_outcome(TransportPublishOutcomeKind::TargetRejected)],
+ );
+ terminal_count_mismatch.terminal_count = 0;
+ assert_eq!(
+ terminal_count_mismatch.validate(),
+ Err(TransportPublishProtocolError::InvalidJobTerminalCount {
+ expected: 1,
+ actual: 0,
+ })
+ );
+
+ let rejected = rejected_job();
+ assert!(validate_job_status_state(&rejected, 0, 0, 0).is_ok());
+ for (target_count, targets, acknowledged, retryable, terminal) in [
+ (1, Vec::new(), 0, 0, 0),
+ (
+ 0,
+ vec![nostr_outcome(TransportPublishOutcomeKind::Accepted)],
+ 0,
+ 0,
+ 0,
+ ),
+ (0, Vec::new(), 1, 0, 0),
+ (0, Vec::new(), 0, 1, 0),
+ (0, Vec::new(), 0, 0, 1),
+ ] {
+ let mut invalid = rejected.clone();
+ invalid.target_count = target_count;
+ invalid.targets = targets;
+ assert_eq!(
+ validate_job_status_state(&invalid, acknowledged, retryable, terminal),
+ Err(TransportPublishProtocolError::InvalidJobStatusState)
+ );
+ }
+
+ let mut empty_terminal = rejected.clone();
+ empty_terminal.status = TransportPublishJobStatus::DeliveryUnsatisfiedTerminal;
+ assert_eq!(
+ validate_job_status_state(&empty_terminal, 0, 0, 0),
+ Err(TransportPublishProtocolError::InvalidJobStatusState)
+ );
+ let mut publishing = rejected.clone();
+ publishing.status = TransportPublishJobStatus::Publishing;
+ assert!(validate_job_status_state(&publishing, 0, 0, 0).is_ok());
+
+ let accepted = accepted_job();
+ assert!(validate_job_status_state(&accepted, 1, 0, 0).is_ok());
+ assert_eq!(
+ validate_job_status_state(&accepted, 0, 1, 0),
+ Err(TransportPublishProtocolError::InvalidJobStatusState)
+ );
+ let retryable = job_from_targets(
+ TransportPublishJobStatus::DeliveryUnsatisfiedRetryable,
+ TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget::nostr(
+ "wss://relay.example.com",
+ )]),
+ vec![nostr_outcome(TransportPublishOutcomeKind::Timeout)],
+ );
+ assert!(validate_job_status_state(&retryable, 0, 1, 0).is_ok());
+ assert_eq!(
+ validate_job_status_state(&retryable, 1, 1, 0),
+ Err(TransportPublishProtocolError::InvalidJobStatusState)
+ );
+ assert_eq!(
+ validate_job_status_state(&retryable, 0, 0, 0),
+ Err(TransportPublishProtocolError::InvalidJobStatusState)
+ );
+ let terminal = job_from_targets(
+ TransportPublishJobStatus::DeliveryUnsatisfiedTerminal,
+ TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget::nostr(
+ "wss://relay.example.com",
+ )]),
+ vec![nostr_outcome(TransportPublishOutcomeKind::TargetRejected)],
+ );
+ assert!(validate_job_status_state(&terminal, 0, 0, 1).is_ok());
+ assert_eq!(
+ validate_job_status_state(&terminal, 0, 1, 1),
+ Err(TransportPublishProtocolError::InvalidJobStatusState)
+ );
+ assert_eq!(
+ validate_job_status_state(&terminal, 0, 0, 0),
+ Err(TransportPublishProtocolError::InvalidJobStatusState)
+ );
+
+ for status in [
+ TransportPublishJobStatus::DeliveryDeferred,
+ TransportPublishJobStatus::DeliveryDeferredUntilImplemented,
+ ] {
+ let deferred = job_from_targets(
+ status,
+ TransportPublishTargetPolicy::explicit_targets(vec![
+ TransportPublishTarget::reticulum(
+ TransportPublishReticulumBehavior::DeferDeliveryPlans,
+ ),
+ ]),
+ vec![reticulum_outcome(
+ TransportPublishOutcomeKind::DeferredUntilImplemented,
+ )],
+ );
+ assert!(validate_job_status_state(&deferred, 0, 0, 0).is_ok());
+ assert_eq!(
+ validate_job_status_state(&deferred, 1, 0, 0),
+ Err(TransportPublishProtocolError::InvalidJobStatusState)
+ );
+ assert_eq!(
+ validate_job_status_state(&deferred, 0, 1, 0),
+ Err(TransportPublishProtocolError::InvalidJobStatusState)
+ );
+ assert_eq!(
+ validate_job_status_state(&deferred, 0, 0, 1),
+ Err(TransportPublishProtocolError::InvalidJobStatusState)
+ );
+ }
+ }
}
diff --git a/crates/transport_reticulum/src/lib.rs b/crates/transport_reticulum/src/lib.rs
@@ -1,5 +1,6 @@
#![no_std]
#![forbid(unsafe_code)]
+#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
extern crate alloc;
@@ -460,3 +461,227 @@ fn reticulum_outcome(behavior: RadrootsReticulumBehavior) -> RadrootsTransportOu
);
outcome
}
+
+#[cfg(test)]
+#[cfg_attr(coverage_nightly, coverage(off))]
+mod tests {
+ use super::*;
+ use alloc::format;
+ use alloc::string::ToString;
+ use alloc::vec;
+ use futures::executor::block_on;
+ use radroots_transport::{
+ RadrootsTransportPayload, RadrootsTransportSatisfactionPolicy, RadrootsTransportTargetSet,
+ };
+
+ fn reticulum_target() -> RadrootsTransportTarget {
+ RadrootsTransportTarget::reticulum().expect("Reticulum target")
+ }
+
+ fn delivery_request(targets: Vec<RadrootsTransportTarget>) -> RadrootsTransportDeliveryRequest {
+ RadrootsTransportDeliveryRequest::new(
+ "delivery",
+ RadrootsTransportPayload::mesh_frame_cbor("message", [1_u8, 2, 3])
+ .expect("mesh payload"),
+ RadrootsTransportTargetSet::new(targets).expect("target set"),
+ RadrootsTransportSatisfactionPolicy::any_accepted(),
+ )
+ }
+
+ #[test]
+ #[allow(clippy::unnecessary_to_owned)]
+ fn endpoint_profile_and_fetch_models_cover_owned_and_borrowed_boundaries() {
+ let endpoint =
+ RadrootsReticulumEndpoint::parse(RADROOTS_RETICULUM_ENDPOINT_URI).expect("endpoint");
+ assert_eq!(endpoint.as_str(), RADROOTS_RETICULUM_ENDPOINT_URI);
+ assert_eq!(format!("{endpoint}"), RADROOTS_RETICULUM_ENDPOINT_URI);
+ assert_eq!(
+ endpoint.clone().into_string(),
+ RADROOTS_RETICULUM_ENDPOINT_URI
+ );
+ assert_eq!(
+ RadrootsReticulumEndpoint::default(),
+ RadrootsReticulumEndpoint::parse(RADROOTS_RETICULUM_ENDPOINT_URI.to_string())
+ .expect("owned endpoint")
+ );
+ assert!(RadrootsReticulumEndpoint::parse("reticulum:other").is_err());
+
+ for invalid in [
+ "",
+ " reticulum-agent:local",
+ "reticulum-agent:local ",
+ "reticulum-agent:\tlocal",
+ "other:local",
+ RETICULUM_AGENT_ENDPOINT_PREFIX,
+ ] {
+ assert!(RadrootsReticulumAgentEndpoint::parse(invalid).is_err());
+ }
+ let agent =
+ RadrootsReticulumAgentEndpoint::parse("reticulum-agent:local").expect("agent endpoint");
+ assert_eq!(agent.as_str(), "reticulum-agent:local");
+ assert_eq!(format!("{agent}"), "reticulum-agent:local");
+ assert_eq!(agent.clone().into_string(), "reticulum-agent:local");
+ assert_eq!(
+ RadrootsReticulumAgentEndpoint::parse("reticulum-agent:owned".to_string())
+ .expect("owned agent")
+ .as_str(),
+ "reticulum-agent:owned"
+ );
+
+ let scope = RadrootsTransportMeshScopeId::parse("farm.mesh").expect("scope");
+ for invalid in ["", " ", "profile id"] {
+ assert!(
+ RadrootsReticulumProfile::new(
+ invalid,
+ endpoint.clone(),
+ scope.clone(),
+ None,
+ RadrootsReticulumBehavior::RejectDeliveryAttempts,
+ )
+ .is_err()
+ );
+ }
+ let profile = RadrootsReticulumProfile::new(
+ "transport.reticulum.farm".to_string(),
+ endpoint.clone(),
+ scope.clone(),
+ Some(agent.clone()),
+ RadrootsReticulumBehavior::RejectDeliveryAttempts,
+ )
+ .expect("profile")
+ .with_behavior(RadrootsReticulumBehavior::DeferDeliveryPlans)
+ .with_agent_endpoint(agent.clone());
+ assert_eq!(profile.profile_id(), "transport.reticulum.farm");
+ assert_eq!(profile.endpoint(), &endpoint);
+ assert_eq!(profile.scope(), &scope);
+ assert_eq!(profile.agent_endpoint(), Some(&agent));
+ assert_eq!(
+ profile.behavior(),
+ RadrootsReticulumBehavior::DeferDeliveryPlans
+ );
+ assert_eq!(
+ profile.destination(),
+ &profile.capability_report().destination
+ );
+ assert_eq!(profile.status().behavior, profile.behavior());
+
+ let default_profile = RadrootsReticulumProfile::deferred_until_implemented();
+ assert_eq!(default_profile, RadrootsReticulumProfile::default());
+ assert!(default_profile.agent_endpoint().is_none());
+
+ assert!(RadrootsReticulumFetchRequest::new("invalid", 0).is_err());
+ let fetch =
+ RadrootsReticulumFetchRequest::new("fetch".to_string(), 1).expect("fetch request");
+ assert_eq!(fetch.request_id, "fetch");
+ }
+
+ #[test]
+ fn transport_facades_and_private_guards_cover_all_contract_outcomes() {
+ let rejecting = RadrootsReticulumTransport::default();
+ assert_eq!(
+ rejecting.profile().behavior(),
+ RadrootsReticulumBehavior::RejectDeliveryAttempts
+ );
+ assert_eq!(rejecting.status(), rejecting.profile().status());
+ let receipt = rejecting
+ .deliver(delivery_request(vec![reticulum_target()]))
+ .expect("direct delivery");
+ assert_eq!(receipt.target_receipts.len(), 1);
+ assert_eq!(
+ rejecting
+ .fetch(RadrootsReticulumFetchRequest::new("direct-fetch", 1).expect("fetch"))
+ .expect("direct fetch")
+ .observed_event_count,
+ 0
+ );
+ assert!(
+ rejecting
+ .fetch(RadrootsReticulumFetchRequest {
+ request_id: "invalid-fetch".to_owned(),
+ max_events: 0,
+ })
+ .is_err()
+ );
+
+ assert_eq!(
+ RadrootsTransport::transport_kind(&rejecting),
+ RadrootsTransportKind::Reticulum
+ );
+ assert!(block_on(RadrootsTransport::status(&rejecting)).is_ok());
+ assert!(
+ block_on(RadrootsTransport::deliver(
+ &rejecting,
+ delivery_request(vec![reticulum_target()]),
+ ))
+ .is_ok()
+ );
+ let core_fetch = RadrootsTransportFetchRequest::new(
+ "core-fetch",
+ RadrootsTransportTargetSet::new(vec![reticulum_target()]).expect("target set"),
+ );
+ assert!(block_on(RadrootsTransport::fetch(&rejecting, core_fetch)).is_ok());
+
+ let deferring = RadrootsReticulumTransport::new(
+ RadrootsReticulumProfile::default()
+ .with_behavior(RadrootsReticulumBehavior::DeferDeliveryPlans),
+ );
+ assert_eq!(
+ deferring
+ .deliver(delivery_request(vec![reticulum_target()]))
+ .expect("deferred delivery")
+ .target_receipts[0]
+ .outcome
+ .kind,
+ RadrootsTransportOutcomeKind::DeferredUntilImplemented
+ );
+
+ assert_eq!(
+ reticulum_error_to_transport_error(RadrootsReticulumError::InvalidEndpoint),
+ RadrootsTransportError::InvalidTargetUri
+ );
+ assert_eq!(
+ reticulum_error_to_transport_error(RadrootsReticulumError::NonReticulumTarget),
+ RadrootsTransportError::InvalidTargetUri
+ );
+ for error in [
+ RadrootsReticulumError::InvalidAgentEndpoint,
+ RadrootsReticulumError::InvalidProfileId,
+ RadrootsReticulumError::InvalidFetchLimit,
+ ] {
+ assert_eq!(
+ reticulum_error_to_transport_error(error),
+ RadrootsTransportError::InvalidTransportKind
+ );
+ }
+
+ assert!(ensure_reticulum_targets(&[]).is_ok());
+ let mut wrong_kind = reticulum_target();
+ wrong_kind.kind = RadrootsTransportKind::Local;
+ assert_eq!(
+ ensure_reticulum_targets(&[wrong_kind]),
+ Err(RadrootsReticulumError::NonReticulumTarget)
+ );
+ let mut wrong_uri = reticulum_target();
+ wrong_uri.uri = radroots_transport::RadrootsTransportTargetUri::parse("reticulum:other")
+ .expect("syntactically valid wrong URI");
+ assert_eq!(
+ ensure_reticulum_targets(&[wrong_uri]),
+ Err(RadrootsReticulumError::InvalidEndpoint)
+ );
+ let mut missing_scope = reticulum_target();
+ missing_scope.scope = None;
+ assert_eq!(
+ ensure_reticulum_targets(&[missing_scope]),
+ Err(RadrootsReticulumError::InvalidEndpoint)
+ );
+
+ for behavior in [
+ RadrootsReticulumBehavior::RejectDeliveryAttempts,
+ RadrootsReticulumBehavior::DeferDeliveryPlans,
+ ] {
+ let outcome = reticulum_outcome(behavior);
+ assert!(outcome.code.is_some());
+ assert!(outcome.message.is_some());
+ }
+ }
+}
diff --git a/rust-toolchain-coverage.toml b/rust-toolchain-coverage.toml
@@ -1,10 +1,4 @@
[toolchain]
channel = "nightly-2026-07-15"
-components = [
- "clippy",
- "rust-analyzer",
- "rust-src",
- "rustfmt",
- "llvm-tools-preview",
-]
-targets = ["wasm32-unknown-unknown"]
+components = ["llvm-tools-preview"]
+profile = "minimal"