lib

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

commit 5ed07d69ae5739867e40aa706b5bce99d6f6cbb0
parent ab2bda16beed08db8819299d4cda167d89428da2
Author: triesap <tyson@radroots.org>
Date:   Mon, 20 Jul 2026 00:55:23 +0000

sdk: align foundational event workflows

- adopt current Operational Listing and verified event-store owner APIs
- expose strict authored social and exact-byte Blossom bindings
- enforce verified signer, ingestion, and structured validation boundaries
- seal prepared publish plans and rederive receipt identities

Diffstat:
Mcrates/event_bindings/src/lib.rs | 12+++++++-----
Mcrates/event_bindings/src/model.rs | 14++++++++------
Mcrates/event_codec_wasm/Cargo.toml | 3+++
Mcrates/event_codec_wasm/src/lib.rs | 614+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mcrates/replica_sync_wasm/src/lib.rs | 44++++++++++++++++++++++++++++++++++++--------
Mcrates/sdk/examples/runtime_local.rs | 33+++++++++++++++++++++++----------
Mcrates/sdk/examples/sdk_v1_knowledge_prepare.rs | 17+++++------------
Mcrates/sdk/examples/sdk_v1_listing_prepare.rs | 35++++++++++++++++++++++++-----------
Mcrates/sdk/examples/sdk_v1_local_enqueue_and_mock_sync.rs | 31++++++++++++++++++++++---------
Mcrates/sdk/src/error.rs | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/sdk/src/farms_runtime.rs | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/sdk/src/knowledge.rs | 71+++++++++++++++++++++++------------------------------------------------
Mcrates/sdk/src/lib.rs | 4++--
Mcrates/sdk/src/listings_runtime.rs | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mcrates/sdk/src/private_store.rs | 10++++------
Mcrates/sdk/src/runtime.rs | 142+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mcrates/sdk/src/signer_provider.rs | 6+++---
Mcrates/sdk/src/sync_runtime.rs | 48++++++++++++++++--------------------------------
Mcrates/sdk/src/trade_runtime.rs | 10++--------
Mcrates/sdk/src/workflow_runtime.rs | 102++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mcrates/sdk/tests/farms_runtime.rs | 125+++++++++++++++++++++++++------------------------------------------------------
Mcrates/sdk/tests/knowledge_public_api.rs | 40+++++++++++++++++++++++++++++++++-------
Mcrates/sdk/tests/listings_runtime.rs | 195+++++++++++++++++++++++++++++++++++---------------------------------------------
Mcrates/sdk/tests/runtime_foundation.rs | 47++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/sdk/tests/source_boundary.rs | 43+++++++++++++++++++++++++++++++++++++------
Mcrates/sdk/tests/support/fixture_signer.rs | 62++++++++++++++++++++++++++++++++++++++------------------------
Mcrates/sdk/tests/sync_runtime.rs | 128+++++++++++++++++++++++++++++--------------------------------------------------
Mcrates/sdk/tests/unit/error_tests.rs | 35++++++++++++++++++++++++++++++-----
Mcrates/sdk/tests/unit/farms_runtime_tests.rs | 187++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/sdk/tests/unit/listings_runtime_tests.rs | 270+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/sdk/tests/unit/runtime_tests.rs | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mcrates/sdk/tests/unit/signer_provider_tests.rs | 104++++++++++++++++++++++++++++++++++---------------------------------------------
Mcrates/sdk/tests/unit/sync_runtime_tests.rs | 38++++++++++++++++++++------------------
Mcrates/sdk/tests/unit/trade_runtime_tests.rs | 20+++++++-------------
Mcrates/sdk/tests/unit/workflow_runtime_tests.rs | 306++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/trade_bindings/src/dto.rs | 46+++++++++++++++++++++++++++-------------------
Mcrates/trade_bindings/src/lib.rs | 60++++++++++++++++++++++++++++++------------------------------
Mtools/sdk_xtask_import/src/dto_roots.rs | 55+++++++++++++++++++++++++++----------------------------
Mtools/sdk_xtask_import/src/smoke.rs | 21++++++++-------------
Mtools/sdk_xtask_import/src/wasm_declarations.rs | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
40 files changed, 2574 insertions(+), 1015 deletions(-)

diff --git a/crates/event_bindings/src/lib.rs b/crates/event_bindings/src/lib.rs @@ -6,7 +6,9 @@ pub use model::{constants_module, kinds_module}; #[cfg(test)] mod tests { - use radroots_event::{kinds, listing::RADROOTS_LISTING_PRODUCT_TAG_KEYS}; + use radroots_event::{ + kinds, operational_listing::RADROOTS_OPERATIONAL_LISTING_PRODUCT_TAG_KEYS, + }; use super::{constants_module, kinds_module}; @@ -14,10 +16,10 @@ mod tests { fn preserves_event_constant_exports() { let constants = constants_module().render_source(); let kinds_ts = kinds_module().render_source(); - assert!(constants.contains("RADROOTS_LISTING_PRODUCT_TAG_KEYS")); - assert!(constants.contains(RADROOTS_LISTING_PRODUCT_TAG_KEYS[0])); - assert!(kinds_ts.contains("KIND_LISTING")); - assert!(kinds_ts.contains(&kinds::KIND_LISTING.to_string())); + assert!(constants.contains("RADROOTS_OPERATIONAL_LISTING_PRODUCT_TAG_KEYS")); + assert!(constants.contains(RADROOTS_OPERATIONAL_LISTING_PRODUCT_TAG_KEYS[0])); + assert!(kinds_ts.contains("KIND_CLASSIFIED_LISTING")); + assert!(kinds_ts.contains(&kinds::KIND_CLASSIFIED_LISTING.to_string())); assert!(kinds_ts.contains("KIND_TRADE_PROPOSAL")); assert!(kinds_ts.contains(&kinds::KIND_TRADE_PROPOSAL.to_string())); assert!(!kinds_ts.contains("KIND_ORDER_REQUEST")); diff --git a/crates/event_bindings/src/model.rs b/crates/event_bindings/src/model.rs @@ -1,19 +1,21 @@ use dto_bindgen_backend_ts::{ TypeScriptDeclaration, TypeScriptImport, TypeScriptModule, TypeScriptType, TypeScriptValue, }; -use radroots_event::{kinds, listing::RADROOTS_LISTING_PRODUCT_TAG_KEYS}; +use radroots_event::{kinds, operational_listing::RADROOTS_OPERATIONAL_LISTING_PRODUCT_TAG_KEYS}; pub fn constants_module() -> TypeScriptModule { TypeScriptModule::new("src/generated/constants.ts") .with_import(TypeScriptImport::type_only( - ["RadrootsListingProductTagKeys"], + ["RadrootsOperationalListingProductTagKeys"], "./types.js", )) .with_declaration(TypeScriptDeclaration::constant( - "RADROOTS_LISTING_PRODUCT_TAG_KEYS", - Some(TypeScriptType::named("RadrootsListingProductTagKeys")), + "RADROOTS_OPERATIONAL_LISTING_PRODUCT_TAG_KEYS", + Some(TypeScriptType::named( + "RadrootsOperationalListingProductTagKeys", + )), TypeScriptValue::array( - RADROOTS_LISTING_PRODUCT_TAG_KEYS + RADROOTS_OPERATIONAL_LISTING_PRODUCT_TAG_KEYS .iter() .map(|value| TypeScriptValue::string(*value)) .collect::<Vec<_>>(), @@ -111,7 +113,7 @@ const EVENT_KIND_EXPORTS: &[(&str, u32)] = &[ ), ("KIND_ACCOUNT_CLAIM", kinds::KIND_ACCOUNT_CLAIM), ("KIND_APP_DATA", kinds::KIND_APP_DATA), - ("KIND_LISTING", kinds::KIND_LISTING), + ("KIND_CLASSIFIED_LISTING", kinds::KIND_CLASSIFIED_LISTING), ("KIND_APPLICATION_HANDLER", kinds::KIND_APPLICATION_HANDLER), ("KIND_TRADE_PROPOSAL", kinds::KIND_TRADE_PROPOSAL), ("KIND_TRADE_DECISION", kinds::KIND_TRADE_DECISION), diff --git a/crates/event_codec_wasm/Cargo.toml b/crates/event_codec_wasm/Cargo.toml @@ -15,6 +15,9 @@ readme = "README" crate-type = ["cdylib", "rlib"] [dependencies] +radroots_blossom = { workspace = true, default-features = false, features = [ + "std", +] } radroots_event = { workspace = true, default-features = false, features = [ "std", "serde", diff --git a/crates/event_codec_wasm/src/lib.rs b/crates/event_codec_wasm/src/lib.rs @@ -1,7 +1,14 @@ #![forbid(unsafe_code)] +use radroots_blossom::{ + RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomError, + RadrootsBlossomMediaType, RadrootsBlossomSha256, +}; use radroots_event::article::RadrootsArticle; -use radroots_event::comment::RadrootsComment; +use radroots_event::comment::{ + RadrootsAuthoredNip22Comment, RadrootsNip22AddressRootReference, RadrootsNip22CommentError, + RadrootsNip22CommentParentReference, RadrootsNip22CommentRoot, RadrootsNip22EventRootReference, +}; use radroots_event::coop::RadrootsCoop; use radroots_event::document::RadrootsDocument; use radroots_event::farm::RadrootsFarm; @@ -28,21 +35,26 @@ use radroots_event::knowledge::{ }; use radroots_event::list::RadrootsList; use radroots_event::list_set::RadrootsListSet; -use radroots_event::listing::RadrootsListing; +use radroots_event::media::{RadrootsAuthoredImage, RadrootsAuthoredImageError}; use radroots_event::message::RadrootsMessage; use radroots_event::message_file::RadrootsMessageFile; +use radroots_event::operational_listing::RadrootsOperationalListing; use radroots_event::plot::RadrootsPlot; -use radroots_event::post::RadrootsPost; +use radroots_event::post::{ + RadrootsAuthoredAsk, RadrootsAuthoredPhotoUpdate, RadrootsAuthoredPostError, + RadrootsAuthoredPostImage, RadrootsAuthoredUpdate, RadrootsPostImageDimensions, +}; use radroots_event::reaction::RadrootsReaction; use radroots_event::relay_auth::RadrootsRelayAuth; use radroots_event::report::RadrootsReport; use radroots_event::repost::{RadrootsGenericRepost, RadrootsRepost}; use radroots_event::seal::RadrootsSeal; +use radroots_event::wire::RadrootsNip01EventWireParts; use radroots_event::{ RadrootsEventEnvelope, RadrootsEventEnvelopeError, RadrootsEventEnvelopeParts, }; use radroots_event_codec::article::encode::article_build_tags; -use radroots_event_codec::comment::encode::comment_build_tags; +use radroots_event_codec::comment::authored::authored_nip22_comment_to_wire_parts; use radroots_event_codec::coop::encode::coop_build_tags; use radroots_event_codec::document::encode::document_build_tags; use radroots_event_codec::farm::encode::farm_build_tags; @@ -70,13 +82,16 @@ use radroots_event_codec::knowledge::{ }; use radroots_event_codec::list::encode::list_build_tags; use radroots_event_codec::list_set::encode::list_set_build_tags; -use radroots_event_codec::listing::tags::{ - listing_tags as listing_tags_impl, listing_tags_full as listing_tags_full_impl, -}; use radroots_event_codec::message::encode::message_build_tags; use radroots_event_codec::message_file::encode::message_file_build_tags; +use radroots_event_codec::operational_listing::tags::{ + operational_listing_tags as operational_listing_tags_impl, + operational_listing_tags_full as operational_listing_tags_full_impl, +}; use radroots_event_codec::plot::encode::plot_build_tags; -use radroots_event_codec::post::encode::post_build_tags; +use radroots_event_codec::post::authored::{ + authored_ask_to_wire_parts, authored_photo_update_to_wire_parts, authored_update_to_wire_parts, +}; use radroots_event_codec::reaction::encode::reaction_build_tags; use radroots_event_codec::relay_auth::encode::relay_auth_build_tags; use radroots_event_codec::report::encode::report_build_tags; @@ -123,6 +138,35 @@ fn parse_json<T: DeserializeOwned>(input: &str) -> Result<T, RadrootsJsValue> { serde_json::from_str(normalized_payload(input)).map_err(err_js) } +fn parse_authored_json<T: DeserializeOwned>(input: &str) -> Result<T, RadrootsJsValue> { + serde_json::from_str(input).map_err(|_| error_json("invalid_json", Some("authored_input"))) +} + +fn authored_error(code: &str) -> RadrootsJsValue { + error_json("encode_error", Some(code)) +} + +fn blossom_authored_error(error: RadrootsBlossomError) -> RadrootsJsValue { + authored_error(error.code()) +} + +fn post_authored_error(error: RadrootsAuthoredPostError) -> RadrootsJsValue { + authored_error(error.code()) +} + +fn image_authored_error(error: RadrootsAuthoredImageError) -> RadrootsJsValue { + authored_error(error.code()) +} + +fn comment_authored_error(error: RadrootsNip22CommentError) -> RadrootsJsValue { + authored_error(error.code()) +} + +fn wire_parts_to_json(parts: &RadrootsNip01EventWireParts) -> Result<String, RadrootsJsValue> { + serde_json::to_string(parts) + .map_err(|_| error_json("internal_error", Some("wire_parts_serialization"))) +} + fn tags_to_json(tags: Vec<Vec<String>>) -> Result<String, RadrootsJsValue> { serde_json::to_string(&tags).map_err(err_js) } @@ -152,6 +196,7 @@ fn envelope_error_code(error: &RadrootsEventEnvelopeError) -> &'static str { RadrootsEventEnvelopeError::ControlCharacterTagKey { .. } => "tag_key_control_character", RadrootsEventEnvelopeError::ContentTooLarge { .. } => "content_too_large", RadrootsEventEnvelopeError::TooManyTags { .. } => "too_many_tags", + RadrootsEventEnvelopeError::TooManyTagElements { .. } => "too_many_tag_elements", RadrootsEventEnvelopeError::TagElementTooLarge { .. } => "tag_element_too_large", RadrootsEventEnvelopeError::TagsTooLarge { .. } => "tags_too_large", } @@ -287,24 +332,247 @@ struct FarmCrdtTagsInput { author_pubkey: String, } -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = listing_tags))] -pub fn listing_tags(listing_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<RadrootsListing, _, _>(listing_json, listing_tags_impl) +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AuthoredUpdateInput { + content: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AuthoredMediaPostInput { + content: String, + #[serde(default)] + images: Vec<AuthoredImageInput>, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AuthoredImageInput { + bytes: Vec<u8>, + url: String, + media_type: String, + uploaded: u64, + width: u32, + height: u32, + alt: String, + #[serde(default)] + fallbacks: Vec<String>, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AuthoredCommentInput { + content: String, + root: AuthoredCommentRootInput, + position: AuthoredCommentPositionInput, +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +enum AuthoredCommentRootInput { + Event { + event_id: String, + author: String, + kind: u32, + #[serde(default)] + relay: Option<String>, + }, + Address { + coordinate: String, + author: String, + kind: u32, + #[serde(default)] + relay: Option<String>, + }, +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +enum AuthoredCommentPositionInput { + TopEvent, + TopAddress { current_revision: String }, + Nested { parent: AuthoredCommentParentInput }, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AuthoredCommentParentInput { + event_id: String, + author: String, + #[serde(default)] + relay: Option<String>, +} + +fn authored_post_images( + inputs: Vec<AuthoredImageInput>, +) -> Result<Vec<RadrootsAuthoredPostImage>, RadrootsJsValue> { + inputs.into_iter().map(authored_post_image).collect() +} + +fn authored_post_image( + input: AuthoredImageInput, +) -> Result<RadrootsAuthoredPostImage, RadrootsJsValue> { + let media_type = + RadrootsBlossomMediaType::parse(&input.media_type).map_err(blossom_authored_error)?; + let sha256 = RadrootsBlossomSha256::digest(&input.bytes); + let size = u64::try_from(input.bytes.len()) + .map_err(|_| authored_error("blob_size_platform_overflow"))?; + let descriptor = RadrootsBlossomBlobDescriptor::new( + RadrootsBlossomBlobUrl::parse(&input.url).map_err(blossom_authored_error)?, + sha256, + size, + media_type.clone(), + input.uploaded, + ) + .map_err(blossom_authored_error)? + .approve_reference() + .map_err(blossom_authored_error)? + .verify_bytes(&input.bytes, &media_type) + .map_err(blossom_authored_error)?; + let image = RadrootsAuthoredImage::try_from_verified_descriptor(descriptor) + .map_err(image_authored_error)?; + let dimensions = + RadrootsPostImageDimensions::new(input.width, input.height).map_err(post_authored_error)?; + let mut authored = RadrootsAuthoredPostImage::new(image, dimensions, input.alt) + .map_err(post_authored_error)?; + for fallback in input.fallbacks { + let fallback = RadrootsBlossomBlobUrl::parse(&fallback) + .map_err(blossom_authored_error)? + .approve() + .map_err(blossom_authored_error)?; + authored = authored + .try_with_fallback(fallback) + .map_err(post_authored_error)?; + } + Ok(authored) +} + +fn authored_comment_from_input( + input: AuthoredCommentInput, +) -> Result<RadrootsAuthoredNip22Comment, RadrootsJsValue> { + let root = match input.root { + AuthoredCommentRootInput::Event { + event_id, + author, + kind, + relay, + } => RadrootsNip22CommentRoot::Event( + RadrootsNip22EventRootReference::parse(event_id, author, kind, relay.as_deref()) + .map_err(comment_authored_error)?, + ), + AuthoredCommentRootInput::Address { + coordinate, + author, + kind, + relay, + } => { + let root = RadrootsNip22AddressRootReference::parse(coordinate, relay.as_deref()) + .map_err(comment_authored_error)?; + if root.author().as_str() != author { + return Err(authored_error("comment_root_author_mismatch")); + } + if root.kind().as_u32() != kind { + return Err(authored_error("comment_root_kind_mismatch")); + } + RadrootsNip22CommentRoot::Address(root) + } + }; + + match (root, input.position) { + (RadrootsNip22CommentRoot::Event(root), AuthoredCommentPositionInput::TopEvent) => { + RadrootsAuthoredNip22Comment::top_level_event(input.content, root) + .map_err(comment_authored_error) + } + ( + RadrootsNip22CommentRoot::Address(root), + AuthoredCommentPositionInput::TopAddress { current_revision }, + ) => RadrootsAuthoredNip22Comment::parse_top_level_address( + input.content, + root, + current_revision, + ) + .map_err(comment_authored_error), + (root, AuthoredCommentPositionInput::Nested { parent }) => { + let parent = RadrootsNip22CommentParentReference::parse( + parent.event_id, + parent.author, + parent.relay.as_deref(), + ) + .map_err(comment_authored_error)?; + RadrootsAuthoredNip22Comment::nested(input.content, root, parent) + .map_err(comment_authored_error) + } + _ => Err(authored_error("comment_root_position_incompatible")), + } +} + +/// Builds deterministic unsigned kind-1 wire parts for a strict Update. +#[cfg_attr( + target_arch = "wasm32", + wasm_bindgen(js_name = social_update_build_authored_draft) +)] +pub fn social_update_build_authored_draft(input_json: &str) -> Result<String, RadrootsJsValue> { + let input = parse_authored_json::<AuthoredUpdateInput>(input_json)?; + let update = RadrootsAuthoredUpdate::new(input.content).map_err(post_authored_error)?; + wire_parts_to_json(&authored_update_to_wire_parts(&update)) +} + +/// Builds deterministic unsigned kind-1 wire parts for a strict PhotoUpdate. +#[cfg_attr( + target_arch = "wasm32", + wasm_bindgen(js_name = social_photo_update_build_authored_draft) +)] +pub fn social_photo_update_build_authored_draft( + input_json: &str, +) -> Result<String, RadrootsJsValue> { + let input = parse_authored_json::<AuthoredMediaPostInput>(input_json)?; + let images = authored_post_images(input.images)?; + let photo = + RadrootsAuthoredPhotoUpdate::new(input.content, images).map_err(post_authored_error)?; + wire_parts_to_json(&authored_photo_update_to_wire_parts(&photo)) +} + +/// Builds deterministic unsigned kind-1 wire parts for a strict Ask. +#[cfg_attr( + target_arch = "wasm32", + wasm_bindgen(js_name = social_ask_build_authored_draft) +)] +pub fn social_ask_build_authored_draft(input_json: &str) -> Result<String, RadrootsJsValue> { + let input = parse_authored_json::<AuthoredMediaPostInput>(input_json)?; + let images = authored_post_images(input.images)?; + let ask = RadrootsAuthoredAsk::new(input.content, images).map_err(post_authored_error)?; + wire_parts_to_json(&authored_ask_to_wire_parts(&ask)) } -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = listing_tags_full))] -pub fn listing_tags_full(listing_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<RadrootsListing, _, _>(listing_json, listing_tags_full_impl) +/// Builds deterministic unsigned kind-1111 wire parts for a strict NIP-22 Comment. +#[cfg_attr( + target_arch = "wasm32", + wasm_bindgen(js_name = social_comment_build_authored_draft) +)] +pub fn social_comment_build_authored_draft(input_json: &str) -> Result<String, RadrootsJsValue> { + let input = parse_authored_json::<AuthoredCommentInput>(input_json)?; + let comment = authored_comment_from_input(input)?; + wire_parts_to_json(&authored_nip22_comment_to_wire_parts(&comment)) } -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = post_tags))] -pub fn post_tags(post_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<RadrootsPost, _, _>(post_json, post_build_tags) +#[cfg_attr( + target_arch = "wasm32", + wasm_bindgen(js_name = operational_listing_tags) +)] +pub fn operational_listing_tags(listing_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsOperationalListing, _, _>(listing_json, operational_listing_tags_impl) } -#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = comment_tags))] -pub fn comment_tags(comment_json: &str) -> Result<String, RadrootsJsValue> { - build_tags_json::<RadrootsComment, _, _>(comment_json, comment_build_tags) +#[cfg_attr( + target_arch = "wasm32", + wasm_bindgen(js_name = operational_listing_tags_full) +)] +pub fn operational_listing_tags_full(listing_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsOperationalListing, _, _>( + listing_json, + operational_listing_tags_full_impl, + ) } #[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = article_tags))] @@ -606,18 +874,19 @@ mod tests { RadrootsKnowledgeReviewScope, RadrootsKnowledgeReviewScore, RadrootsKnowledgeReviewTarget, RadrootsWikiArticleVersionRef, }; - use radroots_event::listing::{RadrootsListingBin, RadrootsListingProduct}; + use radroots_event::operational_listing::{ + RadrootsOperationalListingBin, RadrootsOperationalListingProduct, + }; use radroots_event::relay_auth::RadrootsRelayAuth; use radroots_event::social::{ RadrootsReportFileTarget, RadrootsReportType, RadrootsSocialFarmAnchor, - RadrootsSocialLocation, RadrootsSocialMediaDimensions, RadrootsSocialMediaMetadata, - RadrootsSocialTarget, + RadrootsSocialLocation, RadrootsSocialMediaDimensions, RadrootsSocialTarget, }; use radroots_event::{ RadrootsEventEnvelope, RadrootsEventEnvelopeLimits, RadrootsEventEnvelopeParts, }; - fn sample_listing() -> RadrootsListing { + fn sample_listing() -> RadrootsOperationalListing { let quantity = RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each); let price = RadrootsCoreQuantityPrice::new( @@ -625,14 +894,14 @@ mod tests { quantity.clone(), ); - RadrootsListing { + RadrootsOperationalListing { d_tag: "AAAAAAAAAAAAAAAAAAAAAg".parse().expect("listing d tag"), published_at: None, farm: RadrootsFarmRef { pubkey: "farm_pubkey".to_string(), d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), }, - product: RadrootsListingProduct { + product: RadrootsOperationalListingProduct { key: "sku".to_string(), title: "widget".to_string(), category: "tools".to_string(), @@ -644,7 +913,7 @@ mod tests { year: None, }, primary_bin_id: "bin-1".parse().expect("primary bin id"), - bins: vec![RadrootsListingBin { + bins: vec![RadrootsOperationalListingBin { bin_id: "bin-1".parse().expect("bin id"), quantity, price_per_canonical_unit: price, @@ -888,9 +1157,7 @@ mod tests { .map(nostr::Tag::parse) .collect::<Result<Vec<_>, _>>() .expect("parsed tags"); - let keys = - nostr::Keys::parse("0101010101010101010101010101010101010101010101010101010101010101") - .expect("keys"); + let keys = nostr::Keys::generate(); let event = nostr::EventBuilder::new(nostr::Kind::Custom(KIND_KNOWLEDGE_CLAIM as u16), claim_json) .tags(tags) @@ -922,40 +1189,6 @@ mod tests { } } - fn sample_post() -> RadrootsPost { - RadrootsPost { - content: "field update".to_string(), - farm: Some(social_farm_anchor()), - address_refs: Some(vec![address_target(30023, "AAAAAAAAAAAAAAAAAAAAAQ")]), - location: Some(social_location()), - topics: Some(vec!["soil".to_string(), "market".to_string()]), - quote_refs: Some(vec![event_target(30023, 'd')]), - media: Some(vec![RadrootsSocialMediaMetadata { - url: Some("https://media.example.test/field.jpg".to_string()), - mime_type: Some("image/jpeg".to_string()), - sha256: Some( - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(), - ), - original_sha256: None, - size: Some(4096), - dimensions: Some(RadrootsSocialMediaDimensions { - width: 1200, - height: 800, - }), - blurhash: None, - thumbnails: None, - image: None, - summary: Some("field photo".to_string()), - alt: Some("rows after harvest".to_string()), - fallback: None, - magnet: Some("magnet:?xt=urn:btih:abc".to_string()), - content_hashes: Some(vec!["sha256:field".to_string()]), - services: Some(vec!["https://media.example.test".to_string()]), - imeta: None, - }]), - } - } - fn sample_article() -> RadrootsArticle { RadrootsArticle { d_tag: "AAAAAAAAAAAAAAAAAAAAAg".to_string(), @@ -995,14 +1228,6 @@ mod tests { } } - fn sample_comment() -> RadrootsComment { - RadrootsComment { - root: event_target(30023, 'a'), - parent: address_target(30023, "AAAAAAAAAAAAAAAAAAAAAg"), - content: "great notes".to_string(), - } - } - fn sample_reaction() -> RadrootsReaction { RadrootsReaction { target: address_target(30023, "AAAAAAAAAAAAAAAAAAAAAg"), @@ -1196,13 +1421,136 @@ mod tests { type BindingEncoder = fn(&str) -> Result<String, RadrootsJsValue>; + const POST_AUTHORED_VECTORS: &str = include_str!( + "../../../../lib/contracts/conformance/vectors/post/verified_profiles.v1.json" + ); + const COMMENT_AUTHORED_VECTORS: &str = include_str!( + "../../../../lib/contracts/conformance/vectors/comment/verified_profile.v1.json" + ); + + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct AuthoredVectorSuite { + suite: String, + contract_version: String, + vectors: Vec<AuthoredVector>, + } + + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct AuthoredVector { + id: String, + kind: String, + input: serde_json::Value, + expected: serde_json::Value, + } + + fn authored_binding(kind: &str) -> Option<BindingEncoder> { + if kind.starts_with("social.update.build_authored_draft.") { + Some(social_update_build_authored_draft) + } else if kind.starts_with("social.photo_update.build_authored_draft.") { + Some(social_photo_update_build_authored_draft) + } else if kind.starts_with("social.ask.build_authored_draft.") { + Some(social_ask_build_authored_draft) + } else if kind.starts_with("social.comment.build_authored_draft.") { + Some(social_comment_build_authored_draft) + } else { + None + } + } + + fn authored_vector_request(vector: &AuthoredVector) -> String { + let mut input = vector.input.clone(); + if let Some(images) = input + .get_mut("images") + .and_then(serde_json::Value::as_array_mut) + { + for image in images { + let image = image + .as_object_mut() + .unwrap_or_else(|| panic!("{} image must be an object", vector.id)); + let bytes_utf8 = image + .remove("bytes_utf8") + .unwrap_or_else(|| panic!("{} image must carry vector bytes", vector.id)); + let bytes_utf8 = bytes_utf8 + .as_str() + .unwrap_or_else(|| panic!("{} vector bytes must be UTF-8 text", vector.id)); + image.insert( + "bytes".to_owned(), + serde_json::Value::Array( + bytes_utf8 + .as_bytes() + .iter() + .copied() + .map(serde_json::Value::from) + .collect(), + ), + ); + } + } + serde_json::to_string(&input) + .unwrap_or_else(|error| panic!("{} request JSON failed: {error}", vector.id)) + } + + fn authored_error_value(error: RadrootsJsValue) -> serde_json::Value { + serde_json::from_str(&error).expect("authored binding error must be coded JSON") + } + + fn authored_error_inner(error: RadrootsJsValue) -> String { + let error = authored_error_value(error); + assert_eq!(error["code"], "encode_error"); + error["inner_code"] + .as_str() + .expect("authored error inner_code") + .to_owned() + } + + fn execute_authored_vectors(raw: &str, expected_suite: &str, expected_vector_count: usize) { + let suite: AuthoredVectorSuite = + serde_json::from_str(raw).expect("owner conformance vectors"); + assert_eq!(suite.suite, expected_suite); + assert_eq!(suite.contract_version, "1.0.0"); + let mut executed = 0usize; + for vector in suite.vectors { + let Some(binding) = authored_binding(&vector.kind) else { + continue; + }; + executed += 1; + let request = authored_vector_request(&vector); + if vector.kind.ends_with(".valid") { + let first = binding(&request) + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); + let second = binding(&request) + .unwrap_or_else(|error| panic!("{} repeat failed: {error}", vector.id)); + assert_eq!(first, second, "{} repeat encoding drifted", vector.id); + let actual: serde_json::Value = serde_json::from_str(&first) + .unwrap_or_else(|error| panic!("{} output JSON failed: {error}", vector.id)); + assert_eq!(actual, vector.expected, "{}", vector.id); + } else if vector.kind.ends_with(".invalid") { + let first = binding(&request).expect_err("invalid authored vector must fail"); + let second = + binding(&request).expect_err("repeated invalid authored vector must fail"); + let expected = vector.expected["error"] + .as_str() + .unwrap_or_else(|| panic!("{} expected error must be a string", vector.id)); + assert_eq!(authored_error_inner(first), expected, "{}", vector.id); + assert_eq!(authored_error_inner(second), expected, "{}", vector.id); + } else { + panic!("{} has unsupported authored vector kind", vector.id); + } + } + assert_eq!(executed, expected_vector_count); + } + #[test] fn bindings_reject_invalid_json() { - let bindings: [BindingEncoder; 42] = [ - listing_tags, - listing_tags_full, - post_tags, - comment_tags, + let bindings: [BindingEncoder; 44] = [ + social_update_build_authored_draft, + social_photo_update_build_authored_draft, + social_ask_build_authored_draft, + social_comment_build_authored_draft, + operational_listing_tags, + operational_listing_tags_full, article_tags, file_metadata_tags, repost_tags, @@ -1246,13 +1594,107 @@ mod tests { for binding in bindings { assert!(binding("{").is_err()); } - assert!(listing_tags("").is_err()); + assert!(operational_listing_tags("").is_err()); + } + + #[test] + fn strict_authored_post_bindings_execute_owner_conformance_vectors() { + execute_authored_vectors(POST_AUTHORED_VECTORS, "post_profiles", 6); + } + + #[test] + fn strict_authored_comment_binding_executes_owner_conformance_vectors() { + execute_authored_vectors(COMMENT_AUTHORED_VECTORS, "nip22_comment_profile", 31); + } + + #[test] + fn authored_media_binding_verifies_exact_arbitrary_bytes() { + let bytes = vec![0, 159, 146, 150]; + let hash = RadrootsBlossomSha256::digest(&bytes); + let url = format!("https://media.example/{hash}.webp"); + let input = serde_json::json!({ + "content": format!("Harvest {url}"), + "images": [{ + "bytes": bytes, + "url": url, + "media_type": "image/webp", + "uploaded": 1_784_347_200_u64, + "width": 1200, + "height": 900, + "alt": "Harvest", + "fallbacks": [], + }], + }); + + let encoded = social_photo_update_build_authored_draft( + &serde_json::to_string(&input).expect("media request"), + ) + .expect("byte-verified PhotoUpdate"); + let encoded: RadrootsNip01EventWireParts = + serde_json::from_str(&encoded).expect("wire parts"); + assert_eq!(encoded.tags.len(), 1); + assert!(encoded.tags[0].contains(&format!("x {hash}"))); + assert!(encoded.tags[0].contains(&"size 4".to_owned())); + + let mut wrong_bytes = input.clone(); + wrong_bytes["images"][0]["bytes"] = serde_json::json!([1, 2, 3, 4]); + let error = social_photo_update_build_authored_draft( + &serde_json::to_string(&wrong_bytes).expect("wrong-byte request"), + ) + .expect_err("URL digest must bind exact bytes"); + assert_eq!(authored_error_inner(error), "descriptor_hash_mismatch"); + } + + #[test] + fn authored_media_binding_rejects_fabricated_descriptor_fields() { + let bytes = b"image".to_vec(); + let hash = RadrootsBlossomSha256::digest(&bytes); + let url = format!("https://media.example/{hash}.webp"); + let mut input = serde_json::json!({ + "content": format!("Harvest {url}"), + "images": [{ + "bytes": bytes, + "url": url, + "media_type": "image/webp", + "uploaded": 1_784_347_200_u64, + "width": 1200, + "height": 900, + "alt": "Harvest", + }], + }); + input["images"][0]["sha256"] = serde_json::json!(hash.to_string()); + input["images"][0]["size"] = serde_json::json!(5); + + let error = social_photo_update_build_authored_draft( + &serde_json::to_string(&input).expect("fabricated descriptor request"), + ) + .expect_err("caller-supplied descriptor commitments must be rejected"); + let error = authored_error_value(error); + assert_eq!(error["code"], "invalid_json"); + assert_eq!(error["inner_code"], "authored_input"); + } + + #[test] + fn ask_allows_zero_media_while_photo_update_requires_media() { + let ask = social_ask_build_authored_draft(r#"{"content":"When is harvest?","images":[]}"#) + .expect("Ask without media"); + let ask: RadrootsNip01EventWireParts = serde_json::from_str(&ask).expect("Ask wire parts"); + assert_eq!( + ask.tags, + vec![vec!["t".to_owned(), "radroots-ask".to_owned()]] + ); + + let error = + social_photo_update_build_authored_draft(r#"{"content":"Harvest","images":[]}"#) + .expect_err("PhotoUpdate without media"); + assert_eq!(authored_error_inner(error), "photo_imeta_missing"); } #[test] fn bindings_encode_to_json_when_input_is_valid() { let listing_json = serde_json::to_string(&sample_listing()).expect("listing json"); - let listing_tags_json = listing_tags(&listing_json).expect("listing tags"); + let listing_tags_json = + operational_listing_tags(&listing_json).expect("operational listing tags"); let listing_tags: Vec<Vec<String>> = serde_json::from_str(&listing_tags_json).expect("listing tags json"); assert!(!listing_tags.is_empty()); @@ -1266,12 +1708,6 @@ mod tests { #[test] fn social_bindings_encode_to_json_when_input_is_valid() { - assert_tags_json(post_tags( - &serde_json::to_string(&sample_post()).expect("post json"), - )); - assert_tags_json(comment_tags( - &serde_json::to_string(&sample_comment()).expect("comment json"), - )); assert_tags_json(article_tags( &serde_json::to_string(&sample_article()).expect("article json"), )); @@ -1298,10 +1734,6 @@ mod tests { article.d_tag.clear(); assert!(article_tags(&serde_json::to_string(&article).expect("article json")).is_err()); - let mut comment = sample_comment(); - comment.root = event_target(1, 'a'); - assert!(comment_tags(&serde_json::to_string(&comment).expect("comment json")).is_err()); - let mut reaction = sample_reaction(); reaction.target = RadrootsSocialTarget::External { id: "https://example.test/object".to_string(), @@ -1687,7 +2119,7 @@ mod tests { listing_json["bins"] = serde_json::Value::Array(Vec::new()); let listing_json = serde_json::to_string(&listing_json).expect("listing json"); - assert!(listing_tags(&listing_json).is_err()); - assert!(listing_tags_full(&listing_json).is_err()); + assert!(operational_listing_tags(&listing_json).is_err()); + assert!(operational_listing_tags_full(&listing_json).is_err()); } } diff --git a/crates/replica_sync_wasm/src/lib.rs b/crates/replica_sync_wasm/src/lib.rs @@ -5,11 +5,12 @@ use base64::Engine; #[cfg(target_arch = "wasm32")] use base64::engine::general_purpose::URL_SAFE_NO_PAD; use radroots_event::{RadrootsEventEnvelope, RadrootsEventEnvelopeParts}; +#[cfg(any(target_arch = "wasm32", test))] +use radroots_replica_sync::RadrootsReplicaIngestOutcome; use radroots_replica_sync::RadrootsReplicaSyncRequest; #[cfg(target_arch = "wasm32")] use radroots_replica_sync::{ - RadrootsReplicaIdFactory, RadrootsReplicaIngestOutcome, - radroots_replica_ingest_event_with_factory, radroots_replica_sync_all, + RadrootsReplicaIdFactory, radroots_replica_ingest_event_with_factory, radroots_replica_sync_all, }; #[cfg(target_arch = "wasm32")] use radroots_sdk_sql_wasm_runtime::WasmSqlExecutor; @@ -66,6 +67,16 @@ pub fn parse_event_model(event_json: &str) -> Result<RadrootsEventEnvelope, Stri .map_err(|error| error.to_string()) } +#[cfg(any(target_arch = "wasm32", test))] +fn ingest_outcome_label(outcome: RadrootsReplicaIngestOutcome) -> &'static str { + match outcome { + RadrootsReplicaIngestOutcome::Applied => "applied", + RadrootsReplicaIngestOutcome::Excluded => "excluded", + RadrootsReplicaIngestOutcome::Rejected => "rejected", + RadrootsReplicaIngestOutcome::Skipped => "skipped", + } +} + #[cfg(target_arch = "wasm32")] #[wasm_bindgen(js_name = replica_sync_sync_all)] pub fn replica_sync_sync_all(request_json: &str) -> Result<JsValue, JsValue> { @@ -83,16 +94,13 @@ pub fn replica_sync_ingest_event(event_json: &str) -> Result<JsValue, JsValue> { let factory = WasmIdFactory; let outcome = radroots_replica_ingest_event_with_factory(&exec, &event, &factory).map_err(err_js)?; - let value = match outcome { - RadrootsReplicaIngestOutcome::Applied => "applied", - RadrootsReplicaIngestOutcome::Skipped => "skipped", - }; - Ok(JsValue::from_str(value)) + Ok(JsValue::from_str(ingest_outcome_label(outcome))) } #[cfg(test)] mod tests { - use super::{parse_event_model, parse_request_model}; + use super::{ingest_outcome_label, parse_event_model, parse_request_model}; + use radroots_replica_sync::RadrootsReplicaIngestOutcome; fn event_json(author: Option<&str>, pubkey: Option<&str>) -> String { let mut fields = vec![ @@ -148,4 +156,24 @@ mod tests { let error = parse_request_model("{").expect_err("error"); assert!(error.contains("EOF")); } + + #[test] + fn ingest_outcome_labels_cover_the_replica_contract() { + assert_eq!( + ingest_outcome_label(RadrootsReplicaIngestOutcome::Applied), + "applied" + ); + assert_eq!( + ingest_outcome_label(RadrootsReplicaIngestOutcome::Excluded), + "excluded" + ); + assert_eq!( + ingest_outcome_label(RadrootsReplicaIngestOutcome::Rejected), + "rejected" + ); + assert_eq!( + ingest_outcome_label(RadrootsReplicaIngestOutcome::Skipped), + "skipped" + ); + } } diff --git a/crates/sdk/examples/runtime_local.rs b/crates/sdk/examples/runtime_local.rs @@ -6,7 +6,12 @@ use radroots_core::{ use radroots_event::contract::RadrootsActorRole; use radroots_event::farm::RadrootsFarmRef; use radroots_event::ids::{RadrootsDTag, RadrootsInventoryBinId}; -use radroots_event::listing::{RadrootsListing, RadrootsListingBin, RadrootsListingProduct}; +use radroots_event::operational_listing::{ + RadrootsOperationalListing, RadrootsOperationalListingAvailability, + RadrootsOperationalListingBin, RadrootsOperationalListingDeliveryMethod, + RadrootsOperationalListingProduct, RadrootsOperationalListingPublicLocation, + RadrootsOperationalListingStatus, +}; use radroots_nostr::prelude::RadrootsNostrKeys; use radroots_sdk::{ ListingPreparePublishRequest, NostrRelayUrlPolicy, PushOutboxRequest, RadrootsClient, @@ -48,7 +53,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { .push_outbox(PushOutboxRequest::new().with_limit(1)) .await; assert_eq!( - prepared.public_listing_addr.as_str(), + prepared.public_listing_addr().as_str(), enqueue.public_listing_addr.as_str() ); #[cfg(feature = "transport-nostr-runtime")] @@ -64,15 +69,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { Ok(()) } -fn sample_listing(seller: &str) -> RadrootsListing { - RadrootsListing { +fn sample_listing(seller: &str) -> RadrootsOperationalListing { + RadrootsOperationalListing { d_tag: RadrootsDTag::parse("AAAAAAAAAAAAAAAAAAAAAQ").expect("d tag"), published_at: None, farm: RadrootsFarmRef { pubkey: seller.to_owned(), d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(), }, - product: RadrootsListingProduct { + product: RadrootsOperationalListingProduct { key: "coffee".to_owned(), title: "Coffee".to_owned(), category: "coffee".to_owned(), @@ -84,7 +89,7 @@ fn sample_listing(seller: &str) -> RadrootsListing { year: None, }, primary_bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"), - bins: vec![RadrootsListingBin { + bins: vec![RadrootsOperationalListingBin { bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"), quantity: RadrootsCoreQuantity::new( RadrootsCoreDecimal::from(1000u32), @@ -109,10 +114,18 @@ fn sample_listing(seller: &str) -> RadrootsListing { resource_area: None, plot: None, discounts: None, - inventory_available: None, - availability: None, - delivery_method: None, - location: None, + inventory_available: Some(RadrootsCoreDecimal::from(5u32)), + availability: Some(RadrootsOperationalListingAvailability::Status { + status: RadrootsOperationalListingStatus::Active, + }), + delivery_method: Some(RadrootsOperationalListingDeliveryMethod::Pickup), + location: Some(RadrootsOperationalListingPublicLocation { + primary: "Victoria".to_owned(), + city: Some("Victoria".to_owned()), + region: Some("British Columbia".to_owned()), + country: Some("CA".to_owned()), + geohash: "c287g".to_owned(), + }), images: None, } } diff --git a/crates/sdk/examples/sdk_v1_knowledge_prepare.rs b/crates/sdk/examples/sdk_v1_knowledge_prepare.rs @@ -2,15 +2,15 @@ use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use radroots_event::RadrootsEventEnvelopeParts; use radroots_sdk::knowledge::prelude::*; -const SECRET_KEY_HEX: &str = "0101010101010101010101010101010101010101010101010101010101010101"; const CREATED_AT: u32 = 1_800_000_000; const RELAY: &str = "wss://relay.radroots.example"; fn main() -> Result<(), Box<dyn std::error::Error>> { + let keys = Keys::generate(); let claim = claim_builder().build()?; let parts = claim_builder().build_event()?; - let draft = claim_builder().build_draft(public_key_hex(), CREATED_AT)?; - let signed = sign_parts(parts)?; + let draft = claim_builder().build_draft(keys.public_key().to_hex(), CREATED_AT)?; + let signed = sign_parts(parts, &keys)?; let decoded = KnowledgeCodec::new().verify_and_decode_radroots_event(signed)?; let manifest = contract_manifest(); let manifest_hash = contract_manifest_sha256()?; @@ -36,17 +36,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { fn sign_parts( parts: RadrootsNip01EventWireParts, + keys: &Keys, ) -> Result<RadrootsEventEnvelope, Box<dyn std::error::Error>> { let tags = parts .tags .into_iter() .map(Tag::parse) .collect::<Result<Vec<_>, _>>()?; - let keys = Keys::parse(SECRET_KEY_HEX)?; let event = EventBuilder::new(Kind::Custom(parts.kind as u16), parts.content) .tags(tags) .custom_created_at(Timestamp::from_secs(u64::from(CREATED_AT))) - .sign_with_keys(&keys)?; + .sign_with_keys(keys)?; Ok(RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { id: event.id.to_hex(), author: event.pubkey.to_hex(), @@ -63,13 +63,6 @@ fn sign_parts( })?) } -fn public_key_hex() -> String { - Keys::parse(SECRET_KEY_HEX) - .expect("keys") - .public_key() - .to_hex() -} - fn hex_64(character: char) -> String { character.to_string().repeat(64) } diff --git a/crates/sdk/examples/sdk_v1_listing_prepare.rs b/crates/sdk/examples/sdk_v1_listing_prepare.rs @@ -6,7 +6,12 @@ use radroots_core::{ use radroots_event::contract::RadrootsActorRole; use radroots_event::farm::RadrootsFarmRef; use radroots_event::ids::{RadrootsDTag, RadrootsInventoryBinId}; -use radroots_event::listing::{RadrootsListing, RadrootsListingBin, RadrootsListingProduct}; +use radroots_event::operational_listing::{ + RadrootsOperationalListing, RadrootsOperationalListingAvailability, + RadrootsOperationalListingBin, RadrootsOperationalListingDeliveryMethod, + RadrootsOperationalListingProduct, RadrootsOperationalListingPublicLocation, + RadrootsOperationalListingStatus, +}; use radroots_sdk::{ListingPreparePublishRequest, RadrootsClient, RadrootsSdkTimestamp}; const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; @@ -22,20 +27,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { let plan = sdk.listings().prepare_publish(request)?; - println!("public listing: {}", plan.public_listing_addr.as_str()); - println!("expected event: {}", plan.expected_event_id.as_str()); + println!("public listing: {}", plan.public_listing_addr().as_str()); + println!("expected event: {}", plan.expected_event_id().as_str()); Ok(()) } -fn sample_listing(seller: &str) -> RadrootsListing { - RadrootsListing { +fn sample_listing(seller: &str) -> RadrootsOperationalListing { + RadrootsOperationalListing { d_tag: RadrootsDTag::parse("AAAAAAAAAAAAAAAAAAAAAQ").expect("d tag"), published_at: None, farm: RadrootsFarmRef { pubkey: seller.to_owned(), d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(), }, - product: RadrootsListingProduct { + product: RadrootsOperationalListingProduct { key: "coffee".to_owned(), title: "Coffee".to_owned(), category: "coffee".to_owned(), @@ -47,7 +52,7 @@ fn sample_listing(seller: &str) -> RadrootsListing { year: None, }, primary_bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"), - bins: vec![RadrootsListingBin { + bins: vec![RadrootsOperationalListingBin { bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"), quantity: RadrootsCoreQuantity::new( RadrootsCoreDecimal::from(1000u32), @@ -72,10 +77,18 @@ fn sample_listing(seller: &str) -> RadrootsListing { resource_area: None, plot: None, discounts: None, - inventory_available: None, - availability: None, - delivery_method: None, - location: None, + inventory_available: Some(RadrootsCoreDecimal::from(5u32)), + availability: Some(RadrootsOperationalListingAvailability::Status { + status: RadrootsOperationalListingStatus::Active, + }), + delivery_method: Some(RadrootsOperationalListingDeliveryMethod::Pickup), + location: Some(RadrootsOperationalListingPublicLocation { + primary: "Victoria".to_owned(), + city: Some("Victoria".to_owned()), + region: Some("British Columbia".to_owned()), + country: Some("CA".to_owned()), + geohash: "c287g".to_owned(), + }), images: None, } } diff --git a/crates/sdk/examples/sdk_v1_local_enqueue_and_mock_sync.rs b/crates/sdk/examples/sdk_v1_local_enqueue_and_mock_sync.rs @@ -6,7 +6,12 @@ use radroots_core::{ use radroots_event::contract::RadrootsActorRole; use radroots_event::farm::RadrootsFarmRef; use radroots_event::ids::{RadrootsDTag, RadrootsInventoryBinId}; -use radroots_event::listing::{RadrootsListing, RadrootsListingBin, RadrootsListingProduct}; +use radroots_event::operational_listing::{ + RadrootsOperationalListing, RadrootsOperationalListingAvailability, + RadrootsOperationalListingBin, RadrootsOperationalListingDeliveryMethod, + RadrootsOperationalListingProduct, RadrootsOperationalListingPublicLocation, + RadrootsOperationalListingStatus, +}; use radroots_nostr::prelude::RadrootsNostrKeys; use radroots_sdk::{ ListingPreparePublishRequest, NostrRelayUrlPolicy, PushOutboxRequest, RadrootsClient, @@ -61,15 +66,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { Ok(()) } -fn sample_listing(seller: &str) -> RadrootsListing { - RadrootsListing { +fn sample_listing(seller: &str) -> RadrootsOperationalListing { + RadrootsOperationalListing { d_tag: RadrootsDTag::parse("AAAAAAAAAAAAAAAAAAAAAQ").expect("d tag"), published_at: None, farm: RadrootsFarmRef { pubkey: seller.to_owned(), d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(), }, - product: RadrootsListingProduct { + product: RadrootsOperationalListingProduct { key: "coffee".to_owned(), title: "Coffee".to_owned(), category: "coffee".to_owned(), @@ -81,7 +86,7 @@ fn sample_listing(seller: &str) -> RadrootsListing { year: None, }, primary_bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"), - bins: vec![RadrootsListingBin { + bins: vec![RadrootsOperationalListingBin { bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"), quantity: RadrootsCoreQuantity::new( RadrootsCoreDecimal::from(1000u32), @@ -106,10 +111,18 @@ fn sample_listing(seller: &str) -> RadrootsListing { resource_area: None, plot: None, discounts: None, - inventory_available: None, - availability: None, - delivery_method: None, - location: None, + inventory_available: Some(RadrootsCoreDecimal::from(5u32)), + availability: Some(RadrootsOperationalListingAvailability::Status { + status: RadrootsOperationalListingStatus::Active, + }), + delivery_method: Some(RadrootsOperationalListingDeliveryMethod::Pickup), + location: Some(RadrootsOperationalListingPublicLocation { + primary: "Victoria".to_owned(), + city: Some("Victoria".to_owned()), + region: Some("British Columbia".to_owned()), + country: Some("CA".to_owned()), + geohash: "c287g".to_owned(), + }), images: None, } } diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs @@ -6,6 +6,8 @@ use crate::privacy::{PrivacyPreflightStatus, ProductSensitivityField}; #[cfg(feature = "runtime")] use crate::transport::ReticulumBehavior; #[cfg(feature = "runtime")] +use radroots_event::trade_validation::RadrootsOperationalListingValidationError; +#[cfg(feature = "runtime")] use serde_json::{Value, json}; #[cfg(feature = "runtime")] @@ -58,6 +60,91 @@ pub enum RadrootsSdkGeoNamesErrorKind { #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "snake_case")] #[non_exhaustive] +pub enum RadrootsSdkListingValidationErrorKind { + InvalidKind, + InvalidProfile, + MissingListingId, + ListingEventNotFound, + ListingEventFetchFailed, + ParseError, + InvalidSeller, + MissingFarmProfile, + MissingFarmRecord, + MissingTitle, + MissingDescription, + MissingProductType, + MissingBins, + MissingPrimaryBin, + InvalidBin, + MissingPrice, + InvalidPrice, + MissingInventory, + InvalidInventory, + MissingAvailability, + MissingLocation, + MissingLocationLocality, + MissingLocationGeohash, + InvalidLocationGeohash, + MissingDeliveryMethod, +} + +#[cfg(feature = "runtime")] +impl From<&RadrootsOperationalListingValidationError> for RadrootsSdkListingValidationErrorKind { + fn from(error: &RadrootsOperationalListingValidationError) -> Self { + match error { + RadrootsOperationalListingValidationError::InvalidKind { .. } => Self::InvalidKind, + RadrootsOperationalListingValidationError::InvalidProfile => Self::InvalidProfile, + RadrootsOperationalListingValidationError::MissingListingId => Self::MissingListingId, + RadrootsOperationalListingValidationError::ListingEventNotFound { .. } => { + Self::ListingEventNotFound + } + RadrootsOperationalListingValidationError::ListingEventFetchFailed { .. } => { + Self::ListingEventFetchFailed + } + RadrootsOperationalListingValidationError::ParseError { .. } => Self::ParseError, + RadrootsOperationalListingValidationError::InvalidSeller => Self::InvalidSeller, + RadrootsOperationalListingValidationError::MissingFarmProfile => { + Self::MissingFarmProfile + } + RadrootsOperationalListingValidationError::MissingFarmRecord => Self::MissingFarmRecord, + RadrootsOperationalListingValidationError::MissingTitle => Self::MissingTitle, + RadrootsOperationalListingValidationError::MissingDescription => { + Self::MissingDescription + } + RadrootsOperationalListingValidationError::MissingProductType => { + Self::MissingProductType + } + RadrootsOperationalListingValidationError::MissingBins => Self::MissingBins, + RadrootsOperationalListingValidationError::MissingPrimaryBin => Self::MissingPrimaryBin, + RadrootsOperationalListingValidationError::InvalidBin => Self::InvalidBin, + RadrootsOperationalListingValidationError::MissingPrice => Self::MissingPrice, + RadrootsOperationalListingValidationError::InvalidPrice => Self::InvalidPrice, + RadrootsOperationalListingValidationError::MissingInventory => Self::MissingInventory, + RadrootsOperationalListingValidationError::InvalidInventory => Self::InvalidInventory, + RadrootsOperationalListingValidationError::MissingAvailability => { + Self::MissingAvailability + } + RadrootsOperationalListingValidationError::MissingLocation => Self::MissingLocation, + RadrootsOperationalListingValidationError::MissingLocationLocality => { + Self::MissingLocationLocality + } + RadrootsOperationalListingValidationError::MissingLocationGeohash => { + Self::MissingLocationGeohash + } + RadrootsOperationalListingValidationError::InvalidLocationGeohash => { + Self::InvalidLocationGeohash + } + RadrootsOperationalListingValidationError::MissingDeliveryMethod => { + Self::MissingDeliveryMethod + } + } + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] pub enum RadrootsSdkTradeErrorKind { InvalidEnvelope, InvalidCommandBody, @@ -221,7 +308,7 @@ pub const RADROOTS_SDK_ERROR_CATALOG: &[RadrootsSdkErrorCatalogEntry] = &[ "signer_returned_event_drift", RadrootsSdkErrorClass::Authorization, false, - RECOVERY_SELECT_AUTHORIZED_ACTOR, + RECOVERY_CONFIGURE_SIGNER, ), RadrootsSdkErrorCatalogEntry::new( "empty_transport_targets", @@ -356,6 +443,12 @@ pub const RADROOTS_SDK_ERROR_CATALOG: &[RadrootsSdkErrorCatalogEntry] = &[ RECOVERY_FIX_REQUEST, ), RadrootsSdkErrorCatalogEntry::new( + "listing_validation", + RadrootsSdkErrorClass::Request, + false, + RECOVERY_FIX_REQUEST, + ), + RadrootsSdkErrorCatalogEntry::new( "listing_mutation", RadrootsSdkErrorClass::Request, false, @@ -537,6 +630,10 @@ pub enum RadrootsSdkError { ListingEdit { message: String, }, + ListingValidation { + kind: RadrootsSdkListingValidationErrorKind, + message: String, + }, ListingMutation { message: String, }, @@ -594,6 +691,7 @@ impl RadrootsSdkError { Self::InvalidRequest { .. } => "invalid_request", Self::UnsupportedProfileSchema { .. } => "unsupported_profile_schema", Self::ListingEdit { .. } => "listing_edit", + Self::ListingValidation { .. } => "listing_validation", Self::ListingMutation { .. } => "listing_mutation", Self::Outbox { .. } => "outbox", Self::PrivateStore { .. } => "private_store", @@ -647,6 +745,7 @@ impl RadrootsSdkError { | Self::InvalidRequest { .. } | Self::UnsupportedProfileSchema { .. } | Self::ListingEdit { .. } + | Self::ListingValidation { .. } | Self::ListingMutation { .. } => RadrootsSdkErrorClass::Request, Self::ProductSyncUnsupported { .. } | Self::ReticulumTransportUnavailable { .. } => { RadrootsSdkErrorClass::Unsupported @@ -704,9 +803,10 @@ impl RadrootsSdkError { Self::UnauthorizedActor { .. } | Self::SignerPubkeyMismatch { .. } | Self::SignerRequestRejected { .. } - | Self::SignerReturnedEventDrift { .. } | Self::Authority { .. } => vec![RadrootsSdkRecoveryAction::SelectAuthorizedActor], - Self::SignerUnavailable { .. } => vec![RadrootsSdkRecoveryAction::ConfigureSigner], + Self::SignerUnavailable { .. } | Self::SignerReturnedEventDrift { .. } => { + vec![RadrootsSdkRecoveryAction::ConfigureSigner] + } Self::EmptyTransportTargets { .. } | Self::TransportTargetLimitExceeded { .. } | Self::InvalidRelayUrl { .. } => { @@ -740,6 +840,7 @@ impl RadrootsSdkError { | Self::SignerProtocol { .. } | Self::InvalidRequest { .. } | Self::ListingEdit { .. } + | Self::ListingValidation { .. } | Self::ListingMutation { .. } => vec![RadrootsSdkRecoveryAction::FixRequest], } } @@ -823,6 +924,9 @@ impl RadrootsSdkError { "endpoint_uri": endpoint_uri, "behavior": behavior.as_str() }), + Self::ListingValidation { kind, message } => { + json!({ "kind": kind, "message": message }) + } Self::ProductSyncTransportSetupFailure { message } | Self::Authority { message } | Self::EventStore { message } @@ -989,6 +1093,9 @@ impl fmt::Display for RadrootsSdkError { path.display() ), Self::ListingEdit { message } => write!(f, "sdk listing edit error: {message}"), + Self::ListingValidation { kind, message } => { + write!(f, "sdk listing validation error ({kind:?}): {message}") + } Self::ListingMutation { message } => { write!(f, "sdk listing mutation error: {message}") } @@ -1093,15 +1200,21 @@ impl From<radroots_geocoder::GeocoderError> for RadrootsSdkError { } #[cfg(feature = "runtime")] -impl From<radroots_trade::listing::RadrootsListingEditError> for RadrootsSdkError { - fn from(error: radroots_trade::listing::RadrootsListingEditError) -> Self { +impl From<radroots_trade::operational_listing::RadrootsOperationalListingEditError> + for RadrootsSdkError +{ + fn from( + error: radroots_trade::operational_listing::RadrootsOperationalListingEditError, + ) -> Self { match error { - radroots_trade::listing::RadrootsListingEditError::ActorRoleUnsatisfied { - required_role, - } => Self::UnauthorizedActor { + radroots_trade::operational_listing::RadrootsOperationalListingEditError::ActorRoleUnsatisfied { required_role } => Self::UnauthorizedActor { operation: "listing.prepare_publish".to_owned(), reason: format!("missing role {required_role:?}"), }, + radroots_trade::operational_listing::RadrootsOperationalListingEditError::InvalidModel(error) => Self::ListingValidation { + kind: RadrootsSdkListingValidationErrorKind::from(&error), + message: error.to_string(), + }, error => Self::ListingEdit { message: error.to_string(), }, @@ -1110,8 +1223,12 @@ impl From<radroots_trade::listing::RadrootsListingEditError> for RadrootsSdkErro } #[cfg(feature = "runtime")] -impl From<radroots_trade::listing::RadrootsListingMutationError> for RadrootsSdkError { - fn from(error: radroots_trade::listing::RadrootsListingMutationError) -> Self { +impl From<radroots_trade::operational_listing::RadrootsOperationalListingMutationError> + for RadrootsSdkError +{ + fn from( + error: radroots_trade::operational_listing::RadrootsOperationalListingMutationError, + ) -> Self { Self::ListingMutation { message: error.to_string(), } diff --git a/crates/sdk/src/farms_runtime.rs b/crates/sdk/src/farms_runtime.rs @@ -20,7 +20,7 @@ use radroots_event::{ farm::{RadrootsFarm, RadrootsFarmPublicLocation}, ids::{RadrootsAddressableCoordinate, RadrootsEventId}, kinds::KIND_FARM, - listing::RadrootsListingPublicLocation, + operational_listing::RadrootsOperationalListingPublicLocation, }; #[cfg(feature = "runtime")] #[cfg(feature = "runtime")] @@ -124,10 +124,29 @@ impl FarmEnqueuePublishRequest { #[cfg(feature = "runtime")] #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct FarmPublishPlan { - pub farm_addr: RadrootsAddressableCoordinate, - pub expected_event_id: RadrootsEventId, - pub frozen_draft: RadrootsEventDraft, - pub created_at: RadrootsSdkTimestamp, + farm_addr: RadrootsAddressableCoordinate, + expected_event_id: RadrootsEventId, + frozen_draft: RadrootsEventDraft, + created_at: RadrootsSdkTimestamp, +} + +#[cfg(feature = "runtime")] +impl FarmPublishPlan { + pub fn farm_addr(&self) -> &RadrootsAddressableCoordinate { + &self.farm_addr + } + + pub fn expected_event_id(&self) -> &RadrootsEventId { + &self.expected_event_id + } + + pub fn frozen_draft(&self) -> &RadrootsEventDraft { + &self.frozen_draft + } + + pub fn created_at(&self) -> RadrootsSdkTimestamp { + self.created_at + } } #[cfg(feature = "runtime")] @@ -182,8 +201,8 @@ impl SdkPublicLocality { } } - pub fn to_listing_public_location(&self) -> RadrootsListingPublicLocation { - RadrootsListingPublicLocation { + pub fn to_listing_public_location(&self) -> RadrootsOperationalListingPublicLocation { + RadrootsOperationalListingPublicLocation { primary: self.primary.clone(), city: self.city.clone(), region: self.region.clone(), @@ -473,6 +492,7 @@ impl<'sdk> FarmsClient<'sdk> { target_policy: TargetPolicy, idempotency_key: Option<SdkIdempotencyKey>, ) -> Result<FarmEnqueueReceipt, RadrootsSdkError> { + let metadata = validate_farm_publish_plan(&plan)?; let enqueue = enqueue_configured_signed_workflow( self.sdk, SdkWorkflowEnqueueRequest { @@ -485,7 +505,7 @@ impl<'sdk> FarmsClient<'sdk> { }, ) .await?; - Ok(farm_enqueue_receipt(plan, enqueue)) + Ok(farm_enqueue_receipt(metadata, enqueue)) } pub async fn enqueue_prepared_publish_with_explicit_signer( @@ -496,6 +516,7 @@ impl<'sdk> FarmsClient<'sdk> { idempotency_key: Option<SdkIdempotencyKey>, signer: &dyn RadrootsEventSigner, ) -> Result<FarmEnqueueReceipt, RadrootsSdkError> { + let metadata = validate_farm_publish_plan(&plan)?; let enqueue = enqueue_signed_workflow( self.sdk, SdkWorkflowEnqueueRequest { @@ -509,7 +530,7 @@ impl<'sdk> FarmsClient<'sdk> { signer, ) .await?; - Ok(farm_enqueue_receipt(plan, enqueue)) + Ok(farm_enqueue_receipt(metadata, enqueue)) } pub async fn upsert_private_location( @@ -710,12 +731,12 @@ impl<'sdk> FarmsClient<'sdk> { #[cfg(feature = "runtime")] fn farm_enqueue_receipt( - plan: FarmPublishPlan, + metadata: ValidatedFarmPublishPlanMetadata, enqueue: crate::workflow_runtime::SdkWorkflowEnqueueReceipt, ) -> FarmEnqueueReceipt { FarmEnqueueReceipt { - farm_addr: plan.farm_addr, - expected_event_id: plan.expected_event_id, + farm_addr: metadata.farm_addr, + expected_event_id: metadata.expected_event_id, signed_event_id: enqueue.signed_event_id, local_event_seq: enqueue.local_event_seq, outbox_operation_id: enqueue.outbox_operation_id, @@ -726,6 +747,62 @@ fn farm_enqueue_receipt( } #[cfg(feature = "runtime")] +struct ValidatedFarmPublishPlanMetadata { + farm_addr: RadrootsAddressableCoordinate, + expected_event_id: RadrootsEventId, +} + +#[cfg(feature = "runtime")] +fn validate_farm_publish_plan( + plan: &FarmPublishPlan, +) -> Result<ValidatedFarmPublishPlanMetadata, RadrootsSdkError> { + let invalid = |reason: &str| RadrootsSdkError::InvalidRequest { + message: format!("invalid prepared farm publish plan: {reason}"), + }; + plan.frozen_draft + .validate_for_signing() + .map_err(|_| invalid("frozen draft is invalid"))?; + if plan.frozen_draft.contract_id() != FARM_PROFILE_CONTRACT_ID + || plan.frozen_draft.kind_u32() != KIND_FARM + { + return Err(invalid("contract or kind does not match Farm publish")); + } + + let expected_event_id = RadrootsEventId::parse(plan.frozen_draft.expected_event_id_str()) + .expect("validated frozen draft has a typed event ID"); + if plan.expected_event_id != expected_event_id { + return Err(invalid("expected event ID does not match frozen draft")); + } + if plan.created_at.unix_seconds() != plan.frozen_draft.created_at_u64() { + return Err(invalid("created-at timestamp does not match frozen draft")); + } + + let tags = plan.frozen_draft.tags_as_vec(); + let mut d_tags = tags + .iter() + .filter(|tag| tag.first().is_some_and(|value| value == "d")); + let d_tag = d_tags + .next() + .and_then(|tag| tag.get(1)) + .ok_or_else(|| invalid("frozen draft is missing its farm identifier"))?; + if d_tags.next().is_some() { + return Err(invalid("frozen draft contains duplicate farm identifiers")); + } + let farm_addr = RadrootsAddressableCoordinate::parse(format!( + "{KIND_FARM}:{}:{d_tag}", + plan.frozen_draft.expected_pubkey_str() + )) + .map_err(|_| invalid("frozen draft farm address is invalid"))?; + if plan.farm_addr != farm_addr { + return Err(invalid("farm address does not match frozen draft")); + } + Ok(ValidatedFarmPublishPlanMetadata { + farm_addr, + expected_event_id, + }) +} + +#[cfg(feature = "runtime")] fn farm_publish_plan( actor: &RadrootsActorContext, farm_value: RadrootsFarm, diff --git a/crates/sdk/src/knowledge.rs b/crates/sdk/src/knowledge.rs @@ -273,7 +273,7 @@ impl RadrootsWikiArticleBuilder { } pub fn build_event(self) -> Result<RadrootsNip01EventWireParts, RadrootsSdkKnowledgeError> { - Ok(build_wiki_article_event(&self.build()?)?) + build_wiki_article_event(&self.build()?) } pub fn build_draft( @@ -281,11 +281,7 @@ impl RadrootsWikiArticleBuilder { expected_pubkey: impl AsRef<str>, created_at: u32, ) -> Result<RadrootsEventDraft, RadrootsSdkKnowledgeError> { - Ok(prepare_wiki_article_draft( - &self.build()?, - expected_pubkey, - created_at, - )?) + prepare_wiki_article_draft(&self.build()?, expected_pubkey, created_at) } } @@ -317,7 +313,7 @@ impl RadrootsWikiRedirectBuilder { } pub fn build_event(self) -> Result<RadrootsNip01EventWireParts, RadrootsSdkKnowledgeError> { - Ok(build_wiki_redirect_event(&self.build()?)?) + build_wiki_redirect_event(&self.build()?) } pub fn build_draft( @@ -325,11 +321,7 @@ impl RadrootsWikiRedirectBuilder { expected_pubkey: impl AsRef<str>, created_at: u32, ) -> Result<RadrootsEventDraft, RadrootsSdkKnowledgeError> { - Ok(prepare_wiki_redirect_draft( - &self.build()?, - expected_pubkey, - created_at, - )?) + prepare_wiki_redirect_draft(&self.build()?, expected_pubkey, created_at) } } @@ -396,7 +388,7 @@ impl RadrootsWikiMergeRequestBuilder { } pub fn build_event(self) -> Result<RadrootsNip01EventWireParts, RadrootsSdkKnowledgeError> { - Ok(build_wiki_merge_request_event(&self.build()?)?) + build_wiki_merge_request_event(&self.build()?) } pub fn build_draft( @@ -404,11 +396,7 @@ impl RadrootsWikiMergeRequestBuilder { expected_pubkey: impl AsRef<str>, created_at: u32, ) -> Result<RadrootsEventDraft, RadrootsSdkKnowledgeError> { - Ok(prepare_wiki_merge_request_draft( - &self.build()?, - expected_pubkey, - created_at, - )?) + prepare_wiki_merge_request_draft(&self.build()?, expected_pubkey, created_at) } } @@ -512,7 +500,7 @@ impl RadrootsKnowledgeSourceBuilder { } pub fn build_event(self) -> Result<RadrootsNip01EventWireParts, RadrootsSdkKnowledgeError> { - Ok(build_knowledge_source_event(&self.build()?)?) + build_knowledge_source_event(&self.build()?) } pub fn build_draft( @@ -520,11 +508,7 @@ impl RadrootsKnowledgeSourceBuilder { expected_pubkey: impl AsRef<str>, created_at: u32, ) -> Result<RadrootsEventDraft, RadrootsSdkKnowledgeError> { - Ok(prepare_knowledge_source_draft( - &self.build()?, - expected_pubkey, - created_at, - )?) + prepare_knowledge_source_draft(&self.build()?, expected_pubkey, created_at) } } @@ -606,7 +590,7 @@ impl RadrootsKnowledgeClaimBuilder { } pub fn build_event(self) -> Result<RadrootsNip01EventWireParts, RadrootsSdkKnowledgeError> { - Ok(build_knowledge_claim_event(&self.build()?)?) + build_knowledge_claim_event(&self.build()?) } pub fn build_draft( @@ -614,11 +598,7 @@ impl RadrootsKnowledgeClaimBuilder { expected_pubkey: impl AsRef<str>, created_at: u32, ) -> Result<RadrootsEventDraft, RadrootsSdkKnowledgeError> { - Ok(prepare_knowledge_claim_draft( - &self.build()?, - expected_pubkey, - created_at, - )?) + prepare_knowledge_claim_draft(&self.build()?, expected_pubkey, created_at) } } @@ -692,7 +672,7 @@ impl RadrootsKnowledgeRelationBuilder { } pub fn build_event(self) -> Result<RadrootsNip01EventWireParts, RadrootsSdkKnowledgeError> { - Ok(build_knowledge_relation_event(&self.build()?)?) + build_knowledge_relation_event(&self.build()?) } pub fn build_draft( @@ -700,11 +680,7 @@ impl RadrootsKnowledgeRelationBuilder { expected_pubkey: impl AsRef<str>, created_at: u32, ) -> Result<RadrootsEventDraft, RadrootsSdkKnowledgeError> { - Ok(prepare_knowledge_relation_draft( - &self.build()?, - expected_pubkey, - created_at, - )?) + prepare_knowledge_relation_draft(&self.build()?, expected_pubkey, created_at) } } @@ -775,7 +751,7 @@ impl RadrootsKnowledgeReviewBuilder { } pub fn build_event(self) -> Result<RadrootsNip01EventWireParts, RadrootsSdkKnowledgeError> { - Ok(build_knowledge_review_event(&self.build()?)?) + build_knowledge_review_event(&self.build()?) } pub fn build_draft( @@ -783,11 +759,7 @@ impl RadrootsKnowledgeReviewBuilder { expected_pubkey: impl AsRef<str>, created_at: u32, ) -> Result<RadrootsEventDraft, RadrootsSdkKnowledgeError> { - Ok(prepare_knowledge_review_draft( - &self.build()?, - expected_pubkey, - created_at, - )?) + prepare_knowledge_review_draft(&self.build()?, expected_pubkey, created_at) } } @@ -874,7 +846,7 @@ impl RadrootsKnowledgeFieldReportBuilder { } pub fn build_event(self) -> Result<RadrootsNip01EventWireParts, RadrootsSdkKnowledgeError> { - Ok(build_knowledge_field_report_event(&self.build()?)?) + build_knowledge_field_report_event(&self.build()?) } pub fn build_draft( @@ -882,11 +854,7 @@ impl RadrootsKnowledgeFieldReportBuilder { expected_pubkey: impl AsRef<str>, created_at: u32, ) -> Result<RadrootsEventDraft, RadrootsSdkKnowledgeError> { - Ok(prepare_knowledge_field_report_draft( - &self.build()?, - expected_pubkey, - created_at, - )?) + prepare_knowledge_field_report_draft(&self.build()?, expected_pubkey, created_at) } } @@ -1251,6 +1219,13 @@ fn prepare_draft( fn draft_error_code(error: &RadrootsDraftError) -> &'static str { match error { RadrootsDraftError::UnknownContract(_) => "unknown_contract", + RadrootsDraftError::ContractNotDraftAuthorable { .. } => "contract_not_draft_authorable", + RadrootsDraftError::ContractRegistryVersionMismatch { .. } => { + "contract_registry_version_mismatch" + } + RadrootsDraftError::DraftExpectedEventIdMismatch { .. } => { + "draft_expected_event_id_mismatch" + } RadrootsDraftError::ContractKindMismatch { .. } => "contract_kind_mismatch", RadrootsDraftError::ContractShape { error, .. } => error.code(), RadrootsDraftError::SignedEventPubkeyMismatch { .. } => "signed_event_pubkey_mismatch", diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -60,8 +60,8 @@ pub use radroots_runtime_contract_v1 as runtime_contract_v1; #[cfg(feature = "runtime")] pub use crate::error::{ RADROOTS_SDK_ERROR_CATALOG, RadrootsSdkError, RadrootsSdkErrorCatalogEntry, - RadrootsSdkErrorClass, RadrootsSdkGeoNamesErrorKind, RadrootsSdkRecoveryAction, - RadrootsSdkTradeErrorKind, radroots_sdk_error_catalog, + RadrootsSdkErrorClass, RadrootsSdkGeoNamesErrorKind, RadrootsSdkListingValidationErrorKind, + RadrootsSdkRecoveryAction, RadrootsSdkTradeErrorKind, radroots_sdk_error_catalog, }; #[cfg(feature = "runtime")] pub use crate::farms_runtime::{ diff --git a/crates/sdk/src/listings_runtime.rs b/crates/sdk/src/listings_runtime.rs @@ -11,42 +11,47 @@ use radroots_authority::{RadrootsActorContext, RadrootsEventSigner}; #[cfg(feature = "runtime")] use radroots_event::{ draft::RadrootsEventDraft, - ids::{RadrootsEventId, RadrootsListingAddress}, - listing::RadrootsListing, + ids::{RadrootsClassifiedListingAddress, RadrootsEventId}, + kinds::KIND_CLASSIFIED_LISTING, + operational_listing::RadrootsOperationalListing, }; #[cfg(feature = "runtime")] use radroots_outbox::RadrootsOutboxEnqueueStatus; #[cfg(feature = "runtime")] -use radroots_trade::listing::{ - RadrootsCanonicalListingEdit, RadrootsListingEditDocumentV1, RadrootsListingMutation, - build_listing_mutation_draft, canonicalize_listing_edit, +use radroots_trade::operational_listing::{ + RadrootsOperationalListingCanonicalEdit, RadrootsOperationalListingEditDocumentV1, + RadrootsOperationalListingMutation, build_operational_listing_mutation_draft, + canonicalize_operational_listing_edit, }; #[cfg(feature = "runtime")] pub const LISTING_PUBLISH_OPERATION_KIND: &str = "listing.publish.v1"; #[cfg(feature = "runtime")] +const OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID: &str = "radroots.operational_listing.published.v1"; + +#[cfg(feature = "runtime")] #[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] pub struct ListingPreparePublishRequest { #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] pub actor: RadrootsActorContext, - pub document: RadrootsListingEditDocumentV1, + pub document: RadrootsOperationalListingEditDocumentV1, pub created_at: Option<RadrootsSdkTimestamp>, } #[cfg(feature = "runtime")] impl ListingPreparePublishRequest { - pub fn new(actor: RadrootsActorContext, listing: RadrootsListing) -> Self { + pub fn new(actor: RadrootsActorContext, listing: RadrootsOperationalListing) -> Self { Self { actor, - document: RadrootsListingEditDocumentV1::new(listing), + document: RadrootsOperationalListingEditDocumentV1::new(listing), created_at: None, } } pub fn from_document( actor: RadrootsActorContext, - document: RadrootsListingEditDocumentV1, + document: RadrootsOperationalListingEditDocumentV1, ) -> Self { Self { actor, @@ -67,7 +72,7 @@ impl ListingPreparePublishRequest { pub struct ListingEnqueuePublishRequest { #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] pub actor: RadrootsActorContext, - pub document: RadrootsListingEditDocumentV1, + pub document: RadrootsOperationalListingEditDocumentV1, pub target_policy: TargetPolicy, pub idempotency_key: Option<SdkIdempotencyKey>, pub created_at: Option<RadrootsSdkTimestamp>, @@ -77,19 +82,19 @@ pub struct ListingEnqueuePublishRequest { impl ListingEnqueuePublishRequest { pub fn new( actor: RadrootsActorContext, - listing: RadrootsListing, + listing: RadrootsOperationalListing, target_policy: TargetPolicy, ) -> Self { Self::from_document( actor, - RadrootsListingEditDocumentV1::new(listing), + RadrootsOperationalListingEditDocumentV1::new(listing), target_policy, ) } pub fn from_document( actor: RadrootsActorContext, - document: RadrootsListingEditDocumentV1, + document: RadrootsOperationalListingEditDocumentV1, target_policy: TargetPolicy, ) -> Self { Self { @@ -136,10 +141,29 @@ impl ListingEnqueuePublishRequest { #[cfg(feature = "runtime")] #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct ListingPublishPlan { - pub public_listing_addr: RadrootsListingAddress, - pub expected_event_id: RadrootsEventId, - pub frozen_draft: RadrootsEventDraft, - pub created_at: RadrootsSdkTimestamp, + public_listing_addr: RadrootsClassifiedListingAddress, + expected_event_id: RadrootsEventId, + frozen_draft: RadrootsEventDraft, + created_at: RadrootsSdkTimestamp, +} + +#[cfg(feature = "runtime")] +impl ListingPublishPlan { + pub fn public_listing_addr(&self) -> &RadrootsClassifiedListingAddress { + &self.public_listing_addr + } + + pub fn expected_event_id(&self) -> &RadrootsEventId { + &self.expected_event_id + } + + pub fn frozen_draft(&self) -> &RadrootsEventDraft { + &self.frozen_draft + } + + pub fn created_at(&self) -> RadrootsSdkTimestamp { + self.created_at + } } #[cfg(feature = "runtime")] @@ -164,7 +188,7 @@ impl From<RadrootsOutboxEnqueueStatus> for SdkMutationState { #[cfg(feature = "runtime")] #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct ListingEnqueueReceipt { - pub public_listing_addr: RadrootsListingAddress, + pub public_listing_addr: RadrootsClassifiedListingAddress, pub expected_event_id: RadrootsEventId, pub signed_event_id: RadrootsEventId, pub local_event_seq: i64, @@ -242,6 +266,7 @@ impl<'sdk> ListingsClient<'sdk> { target_policy: TargetPolicy, idempotency_key: Option<SdkIdempotencyKey>, ) -> Result<ListingEnqueueReceipt, RadrootsSdkError> { + let metadata = validate_listing_publish_plan(&plan)?; let enqueue = enqueue_configured_signed_workflow( self.sdk, SdkWorkflowEnqueueRequest { @@ -254,7 +279,7 @@ impl<'sdk> ListingsClient<'sdk> { }, ) .await?; - Ok(listing_enqueue_receipt(plan, enqueue)) + Ok(listing_enqueue_receipt(metadata, enqueue)) } pub async fn enqueue_prepared_publish_with_explicit_signer( @@ -265,6 +290,7 @@ impl<'sdk> ListingsClient<'sdk> { idempotency_key: Option<SdkIdempotencyKey>, signer: &dyn RadrootsEventSigner, ) -> Result<ListingEnqueueReceipt, RadrootsSdkError> { + let metadata = validate_listing_publish_plan(&plan)?; let enqueue = enqueue_signed_workflow( self.sdk, SdkWorkflowEnqueueRequest { @@ -278,7 +304,7 @@ impl<'sdk> ListingsClient<'sdk> { signer, ) .await?; - Ok(listing_enqueue_receipt(plan, enqueue)) + Ok(listing_enqueue_receipt(metadata, enqueue)) } fn resolved_created_at( @@ -294,12 +320,12 @@ impl<'sdk> ListingsClient<'sdk> { #[cfg(feature = "runtime")] fn listing_enqueue_receipt( - plan: ListingPublishPlan, + metadata: ValidatedListingPublishPlanMetadata, enqueue: crate::workflow_runtime::SdkWorkflowEnqueueReceipt, ) -> ListingEnqueueReceipt { ListingEnqueueReceipt { - public_listing_addr: plan.public_listing_addr, - expected_event_id: plan.expected_event_id, + public_listing_addr: metadata.public_listing_addr, + expected_event_id: metadata.expected_event_id, signed_event_id: enqueue.signed_event_id, local_event_seq: enqueue.local_event_seq, outbox_operation_id: enqueue.outbox_operation_id, @@ -312,22 +338,83 @@ fn listing_enqueue_receipt( #[cfg(feature = "runtime")] fn canonical_listing_edit( actor: &RadrootsActorContext, - document: RadrootsListingEditDocumentV1, -) -> Result<RadrootsCanonicalListingEdit, RadrootsSdkError> { - canonicalize_listing_edit(actor, document).map_err(Into::into) + document: RadrootsOperationalListingEditDocumentV1, +) -> Result<RadrootsOperationalListingCanonicalEdit, RadrootsSdkError> { + canonicalize_operational_listing_edit(actor, document).map_err(Into::into) +} + +#[cfg(feature = "runtime")] +struct ValidatedListingPublishPlanMetadata { + public_listing_addr: RadrootsClassifiedListingAddress, + expected_event_id: RadrootsEventId, +} + +#[cfg(feature = "runtime")] +fn validate_listing_publish_plan( + plan: &ListingPublishPlan, +) -> Result<ValidatedListingPublishPlanMetadata, RadrootsSdkError> { + let invalid = |reason: &str| RadrootsSdkError::InvalidRequest { + message: format!("invalid prepared listing publish plan: {reason}"), + }; + plan.frozen_draft + .validate_for_signing() + .map_err(|_| invalid("frozen draft is invalid"))?; + if plan.frozen_draft.contract_id() != OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID + || plan.frozen_draft.kind_u32() != KIND_CLASSIFIED_LISTING + { + return Err(invalid( + "contract or kind does not match Operational Listing publish", + )); + } + + let expected_event_id = RadrootsEventId::parse(plan.frozen_draft.expected_event_id_str()) + .expect("validated frozen draft has a typed event ID"); + if plan.expected_event_id != expected_event_id { + return Err(invalid("expected event ID does not match frozen draft")); + } + if plan.created_at.unix_seconds() != plan.frozen_draft.created_at_u64() { + return Err(invalid("created-at timestamp does not match frozen draft")); + } + + let tags = plan.frozen_draft.tags_as_vec(); + let mut d_tags = tags + .iter() + .filter(|tag| tag.first().is_some_and(|value| value == "d")); + let d_tag = d_tags + .next() + .and_then(|tag| tag.get(1)) + .ok_or_else(|| invalid("frozen draft is missing its listing identifier"))?; + if d_tags.next().is_some() { + return Err(invalid( + "frozen draft contains duplicate listing identifiers", + )); + } + let public_listing_addr = RadrootsClassifiedListingAddress::parse(format!( + "{KIND_CLASSIFIED_LISTING}:{}:{d_tag}", + plan.frozen_draft.expected_pubkey_str() + )) + .map_err(|_| invalid("frozen draft listing address is invalid"))?; + if plan.public_listing_addr != public_listing_addr { + return Err(invalid("listing address does not match frozen draft")); + } + Ok(ValidatedListingPublishPlanMetadata { + public_listing_addr, + expected_event_id, + }) } #[cfg(feature = "runtime")] fn listing_publish_plan( actor: &RadrootsActorContext, - document: RadrootsListingEditDocumentV1, + document: RadrootsOperationalListingEditDocumentV1, created_at: RadrootsSdkTimestamp, ) -> Result<ListingPublishPlan, RadrootsSdkError> { let created_at_nostr = created_at.try_into_nostr_created_at()?; let canonical = canonical_listing_edit(actor, document)?; let public_listing_addr = canonical.public_listing_addr().clone(); - let mutation = RadrootsListingMutation::publish(canonical); - let frozen_draft = build_listing_mutation_draft(&mutation, u64::from(created_at_nostr))?; + let mutation = RadrootsOperationalListingMutation::publish(canonical); + let frozen_draft = + build_operational_listing_mutation_draft(&mutation, u64::from(created_at_nostr))?; let expected_event_id = RadrootsEventId::parse(frozen_draft.expected_event_id_str()) .expect("frozen listing edit produces a valid event id"); Ok(ListingPublishPlan { diff --git a/crates/sdk/src/private_store.rs b/crates/sdk/src/private_store.rs @@ -579,12 +579,10 @@ impl SdkPrivateStore { if record.deleted_at_ms.is_some() || record.trade_id != trade_id || record.artifact_kind != SdkPrivateTradeArtifactKind::BindingTerms - { - RadrootsTradePrivateTermsStateV1::Missing - } else if record - .candidate_id - .as_deref() - .is_some_and(|stored_candidate_id| stored_candidate_id != candidate_id) + || record + .candidate_id + .as_deref() + .is_some_and(|stored_candidate_id| stored_candidate_id != candidate_id) { RadrootsTradePrivateTermsStateV1::Missing } else if record.schema_id != schema_id diff --git a/crates/sdk/src/runtime.rs b/crates/sdk/src/runtime.rs @@ -309,7 +309,8 @@ pub struct SdkSqliteWalCheckpointReceipt { pub struct SdkEventStoreStorageStatus { pub store: SdkSqliteStoreStatus, pub total_events: i64, - pub projection_eligible_events: i64, + #[serde(alias = "projection_eligible_events")] + pub valid_stream_events: i64, pub transport_observations: i64, pub last_event_seq: Option<i64>, pub last_event_updated_at_ms: Option<i64>, @@ -785,7 +786,7 @@ impl RadrootsClient { event_store: SdkEventStoreStorageStatus { store: event_store_status, total_events: event_summary.total_events, - projection_eligible_events: event_summary.projection_eligible_events, + valid_stream_events: event_summary.valid_stream_events, transport_observations: event_summary.transport_observations, last_event_seq: event_summary.last_event_seq, last_event_updated_at_ms: event_summary.last_event_updated_at_ms, @@ -1110,7 +1111,7 @@ async fn directory_storage_status_read_only( event_store: SdkEventStoreStorageStatus { store: event_store_status, total_events: event_summary.total_events, - projection_eligible_events: event_summary.projection_eligible_events, + valid_stream_events: event_summary.valid_stream_events, transport_observations: event_summary.transport_observations, last_event_seq: event_summary.last_event_seq, last_event_updated_at_ms: event_summary.last_event_updated_at_ms, @@ -1180,33 +1181,9 @@ async fn sqlite_store_status_from_pool( async fn event_store_status_summary_from_pool( pool: &SqlitePool, ) -> Result<radroots_event_store::RadrootsEventStoreStatusSummary, RadrootsSdkError> { - let row = sqlx::query( - "SELECT COUNT(*) AS total_events, COALESCE(SUM(CASE WHEN projection_eligible = 1 THEN 1 ELSE 0 END), 0) AS projection_eligible_events, MAX(seq) AS last_event_seq, MAX(updated_at_ms) AS last_event_updated_at_ms FROM event_envelopes", - ) - .fetch_one(pool) - .await - .map_err(|error| SqliteStoreRole::EventStore.error(error.to_string()))?; - let transport_observations = sqlite_query_i64( - pool, - "SELECT COUNT(*) FROM event_transport_observation", - SqliteStoreRole::EventStore, - ) - .await?; - Ok(radroots_event_store::RadrootsEventStoreStatusSummary { - total_events: row - .try_get("total_events") - .map_err(|error| SqliteStoreRole::EventStore.error(error.to_string()))?, - projection_eligible_events: row - .try_get("projection_eligible_events") - .map_err(|error| SqliteStoreRole::EventStore.error(error.to_string()))?, - transport_observations, - last_event_seq: row - .try_get("last_event_seq") - .map_err(|error| SqliteStoreRole::EventStore.error(error.to_string()))?, - last_event_updated_at_ms: row - .try_get("last_event_updated_at_ms") - .map_err(|error| SqliteStoreRole::EventStore.error(error.to_string()))?, - }) + radroots_event_store::inspect_event_store_status(pool) + .await + .map_err(|error| SqliteStoreRole::EventStore.error(error.to_string())) } #[cfg(feature = "runtime")] @@ -2350,13 +2327,17 @@ async fn recover_sdk_runtime_state(pool: &SqlitePool, now_ms: i64) -> Result<(), if operation_recovery.rows_affected() > 0 { record_runtime_recovery_receipt( pool, - "signer_timeout", - None, - None, - None, - "retry_operation_with_same_idempotency_key", - serde_json::json!({ "recovered_operations": operation_recovery.rows_affected() }), - now_ms, + RuntimeRecoveryReceiptWrite { + recovery_code: "signer_timeout", + operation_kind: None, + actor_pubkey: None, + idempotency_key: None, + recovery_action: "retry_operation_with_same_idempotency_key", + detail_json: serde_json::json!({ + "recovered_operations": operation_recovery.rows_affected() + }), + created_at_ms: now_ms, + }, ) .await?; } @@ -2371,13 +2352,17 @@ async fn recover_sdk_runtime_state(pool: &SqlitePool, now_ms: i64) -> Result<(), if reservation_expiry.rows_affected() > 0 { record_runtime_recovery_receipt( pool, - "reservation_expiry", - None, - None, - None, - "retry_operation_with_same_idempotency_key", - serde_json::json!({ "expired_reservations": reservation_expiry.rows_affected() }), - now_ms, + RuntimeRecoveryReceiptWrite { + recovery_code: "reservation_expiry", + operation_kind: None, + actor_pubkey: None, + idempotency_key: None, + recovery_action: "retry_operation_with_same_idempotency_key", + detail_json: serde_json::json!({ + "expired_reservations": reservation_expiry.rows_affected() + }), + created_at_ms: now_ms, + }, ) .await?; } @@ -2391,13 +2376,17 @@ async fn recover_sdk_runtime_state(pool: &SqlitePool, now_ms: i64) -> Result<(), if projection_stale.rows_affected() > 0 { record_runtime_recovery_receipt( pool, - "projection_stale", - None, - None, - None, - "inspect_local_stores", - serde_json::json!({ "stale_projection_checkpoints": projection_stale.rows_affected() }), - now_ms, + RuntimeRecoveryReceiptWrite { + recovery_code: "projection_stale", + operation_kind: None, + actor_pubkey: None, + idempotency_key: None, + recovery_action: "inspect_local_stores", + detail_json: serde_json::json!({ + "stale_projection_checkpoints": projection_stale.rows_affected() + }), + created_at_ms: now_ms, + }, ) .await?; } @@ -2412,13 +2401,17 @@ async fn recover_sdk_runtime_state(pool: &SqlitePool, now_ms: i64) -> Result<(), if outbox_claim_recovery.rows_affected() > 0 { record_runtime_recovery_receipt( pool, - "relay_failure", - None, - None, - None, - "retry_after_transport_failure", - serde_json::json!({ "recovered_outbox_claims": outbox_claim_recovery.rows_affected() }), - now_ms, + RuntimeRecoveryReceiptWrite { + recovery_code: "relay_failure", + operation_kind: None, + actor_pubkey: None, + idempotency_key: None, + recovery_action: "retry_after_transport_failure", + detail_json: serde_json::json!({ + "recovered_outbox_claims": outbox_claim_recovery.rows_affected() + }), + created_at_ms: now_ms, + }, ) .await?; } @@ -2426,26 +2419,31 @@ async fn recover_sdk_runtime_state(pool: &SqlitePool, now_ms: i64) -> Result<(), } #[cfg(feature = "runtime")] +pub(crate) struct RuntimeRecoveryReceiptWrite<'a> { + pub(crate) recovery_code: &'a str, + pub(crate) operation_kind: Option<&'a str>, + pub(crate) actor_pubkey: Option<&'a str>, + pub(crate) idempotency_key: Option<&'a str>, + pub(crate) recovery_action: &'a str, + pub(crate) detail_json: serde_json::Value, + pub(crate) created_at_ms: i64, +} + +#[cfg(feature = "runtime")] pub(crate) async fn record_runtime_recovery_receipt( pool: &SqlitePool, - recovery_code: &'static str, - operation_kind: Option<&str>, - actor_pubkey: Option<&str>, - idempotency_key: Option<&str>, - recovery_action: &'static str, - detail_json: serde_json::Value, - created_at_ms: i64, + receipt: RuntimeRecoveryReceiptWrite<'_>, ) -> Result<(), RadrootsSdkError> { sqlx::query( "INSERT INTO sdk_runtime_recovery_receipt(recovery_code, operation_kind, actor_pubkey, idempotency_key, recovery_action, detail_json, created_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?)", ) - .bind(recovery_code) - .bind(operation_kind) - .bind(actor_pubkey) - .bind(idempotency_key) - .bind(recovery_action) - .bind(detail_json.to_string()) - .bind(created_at_ms) + .bind(receipt.recovery_code) + .bind(receipt.operation_kind) + .bind(receipt.actor_pubkey) + .bind(receipt.idempotency_key) + .bind(receipt.recovery_action) + .bind(receipt.detail_json.to_string()) + .bind(receipt.created_at_ms) .execute(pool) .await .map(|_| ()) diff --git a/crates/sdk/src/signer_provider.rs b/crates/sdk/src/signer_provider.rs @@ -7,8 +7,8 @@ use radroots_authority::{ use radroots_event::draft::{RadrootsEventDraft, RadrootsSignedEvent}; use radroots_event::ids::RadrootsPublicKey; use radroots_event::kinds::{ - KIND_FARM, KIND_LISTING, KIND_TRADE_CANCELLATION, KIND_TRADE_DECISION, KIND_TRADE_PROPOSAL, - KIND_TRADE_REVISION_DECISION, KIND_TRADE_REVISION_PROPOSAL, + KIND_CLASSIFIED_LISTING, KIND_FARM, KIND_TRADE_CANCELLATION, KIND_TRADE_DECISION, + KIND_TRADE_PROPOSAL, KIND_TRADE_REVISION_DECISION, KIND_TRADE_REVISION_PROPOSAL, }; use radroots_event::wire::RadrootsNip01EventWire; use radroots_nostr::prelude::{RadrootsNostrEvent, RadrootsNostrKeys}; @@ -29,7 +29,7 @@ pub type RadrootsSdkLocalSignerCapability = dyn RadrootsEventSigner + Send + Syn pub const RADROOTS_SDK_MYC_NIP46_PRODUCT_SIGN_EVENT_KINDS: [u32; 7] = [ KIND_FARM, - KIND_LISTING, + KIND_CLASSIFIED_LISTING, KIND_TRADE_PROPOSAL, KIND_TRADE_DECISION, KIND_TRADE_REVISION_PROPOSAL, diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs @@ -121,7 +121,7 @@ pub enum SyncStatusSource { #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct SyncEventStoreStatus { pub total_events: i64, - pub projection_eligible_events: i64, + pub valid_stream_events: i64, pub transport_observations: i64, pub last_event_seq: Option<i64>, pub last_event_updated_at_ms: Option<i64>, @@ -132,7 +132,7 @@ impl From<RadrootsEventStoreStatusSummary> for SyncEventStoreStatus { fn from(summary: RadrootsEventStoreStatusSummary) -> Self { Self { total_events: summary.total_events, - projection_eligible_events: summary.projection_eligible_events, + valid_stream_events: summary.valid_stream_events, transport_observations: summary.transport_observations, last_event_seq: summary.last_event_seq, last_event_updated_at_ms: summary.last_event_updated_at_ms, @@ -1006,20 +1006,9 @@ fn reticulum_event_receipt( #[cfg(feature = "runtime")] fn reticulum_event_final_state( _event_state: RadrootsOutboxEventState, - targets: &[RadrootsOutboxDeliveryTargetRecord], + _targets: &[RadrootsOutboxDeliveryTargetRecord], ) -> PushOutboxEventState { - if targets.iter().any(|target| { - matches!( - target.status, - RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented - | RadrootsOutboxDeliveryTargetStatus::Pending - | RadrootsOutboxDeliveryTargetStatus::FailedRetryable - ) - }) { - PushOutboxEventState::DeferredUntilImplemented - } else { - PushOutboxEventState::DeferredUntilImplemented - } + PushOutboxEventState::DeferredUntilImplemented } #[cfg(feature = "runtime")] @@ -1093,7 +1082,7 @@ pub(crate) async fn refresh_product_projections_for_sdk( } let refreshed_at_ms = sdk_now_ms(sdk)?; let summary = sdk._event_store.status_summary().await?; - let scanned_events = usize::try_from(summary.projection_eligible_events.max(0)) + let scanned_events = usize::try_from(summary.valid_stream_events.max(0)) .unwrap_or(usize::MAX) .min(request.limit as usize); let trade_upserts = trade_mutation_count(sdk).await?; @@ -1149,7 +1138,7 @@ fn projection_source_digest( hasher.update(b"\0"); hasher.update(summary.total_events.to_string().as_bytes()); hasher.update(b"\0"); - hasher.update(summary.projection_eligible_events.to_string().as_bytes()); + hasher.update(summary.valid_stream_events.to_string().as_bytes()); hasher.update(b"\0"); hasher.update( summary @@ -1648,18 +1637,11 @@ async fn complete_missing_radrootsd_delivery_target( publish: &TransportPublishJobView, now_ms: i64, ) -> Result<(), RadrootsSdkError> { - if publish.status == TransportPublishJobStatus::DeliveryDeferred { - sync.sdk - ._outbox - .mark_delivery_target_deferred_until_implemented( - claimed.outbox_event_id, - claimed.claim_token.as_str(), - target.delivery_target_id, - "radrootsd publish deferred until implemented", - now_ms, - ) - .await?; - } else if publish.status == TransportPublishJobStatus::DeliveryDeferredUntilImplemented { + if matches!( + publish.status, + TransportPublishJobStatus::DeliveryDeferred + | TransportPublishJobStatus::DeliveryDeferredUntilImplemented + ) { sync.sdk ._outbox .mark_delivery_target_deferred_until_implemented( @@ -1754,9 +1736,11 @@ fn radrootsd_transport_error_receipt( fn radrootsd_push_event_final_state(publish: &TransportPublishJobView) -> PushOutboxEventState { if publish.delivery_satisfied { PushOutboxEventState::Published - } else if publish.status == TransportPublishJobStatus::DeliveryDeferred { - PushOutboxEventState::DeferredUntilImplemented - } else if publish.status == TransportPublishJobStatus::DeliveryDeferredUntilImplemented { + } else if matches!( + publish.status, + TransportPublishJobStatus::DeliveryDeferred + | TransportPublishJobStatus::DeliveryDeferredUntilImplemented + ) { PushOutboxEventState::DeferredUntilImplemented } else if publish.retryable_count > 0 || !publish.terminal { PushOutboxEventState::PublishRetryable diff --git a/crates/sdk/src/trade_runtime.rs b/crates/sdk/src/trade_runtime.rs @@ -905,20 +905,14 @@ pub enum TradeListAnyOf { } #[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TradeListSort { + #[default] UpdatedDesc, } #[cfg(feature = "runtime")] -impl Default for TradeListSort { - fn default() -> Self { - Self::UpdatedDesc - } -} - -#[cfg(feature = "runtime")] #[derive(Clone, Debug, Serialize)] #[non_exhaustive] pub struct ListTradesRequest { diff --git a/crates/sdk/src/workflow_runtime.rs b/crates/sdk/src/workflow_runtime.rs @@ -3,15 +3,17 @@ use crate::RadrootsSdkSignRequest; use crate::{ RadrootsClient, RadrootsSdkError, ReticulumBehavior, SatisfactionPolicy, SdkIdempotencyKey, TargetPolicy, TargetSet, TransportProfile, - runtime::{record_runtime_recovery_receipt, sdk_now_ms}, + runtime::{RuntimeRecoveryReceiptWrite, record_runtime_recovery_receipt, sdk_now_ms}, }; use radroots_authority::{RadrootsActorContext, RadrootsEventSigner, sign_authorized_draft}; use radroots_event::{ + RadrootsEventKind, RadrootsEventKindClass, draft::{RadrootsEventDraft, RadrootsSignedEvent}, ids::RadrootsEventId, }; use radroots_event_store::{ - RadrootsEventIngest, RadrootsTransportObservation, RadrootsTransportObservationType, + RadrootsEventIngest, RadrootsEventPersistence, RadrootsEventStoreError, + RadrootsTransportObservation, RadrootsTransportObservationType, }; use radroots_outbox::{ RadrootsOutboxDeliveryPlanInput, RadrootsOutboxEnqueueStatus, RadrootsOutboxReticulumBehavior, @@ -50,6 +52,7 @@ pub(crate) async fn enqueue_signed_workflow( request: SdkWorkflowEnqueueRequest<'_>, signer: &dyn RadrootsEventSigner, ) -> Result<SdkWorkflowEnqueueReceipt, RadrootsSdkError> { + ensure_durable_workflow_kind(&request)?; let delivery_plan = resolved_delivery_plan(sdk, &request.target_policy, &request.satisfaction_policy)?; let prepared = prepare_runtime_operation_journal(sdk, &request, &delivery_plan).await?; @@ -88,6 +91,7 @@ pub(crate) async fn enqueue_configured_signed_workflow( sdk: &RadrootsClient, request: SdkWorkflowEnqueueRequest<'_>, ) -> Result<SdkWorkflowEnqueueReceipt, RadrootsSdkError> { + ensure_durable_workflow_kind(&request)?; let delivery_plan = resolved_delivery_plan(sdk, &request.target_policy, &request.satisfaction_policy)?; let prepared = prepare_runtime_operation_journal(sdk, &request, &delivery_plan).await?; @@ -167,19 +171,24 @@ async fn enqueue_signed_workflow_event( RadrootsTransportObservationType::LocalImport, observed_at_ms, )?; - let ingest = RadrootsEventIngest::new(signed_event.clone(), observed_at_ms) - .with_observation(local_import_observation); + let ingest = + workflow_event_ingest(request.operation_kind, signed_event.clone(), observed_at_ms)? + .with_observation(local_import_observation); let ingest_receipt = sdk ._event_store .ingest_event_in_transaction(&mut tx, ingest) .await?; + let (event_store_inserted, local_event_seq) = durable_event_persistence( + ingest_receipt.event_id.as_str(), + &ingest_receipt.persistence, + )?; let outbox_input = signed_outbox_input( request.operation_kind, request.frozen_draft, signed_event, delivery_plan_value, idempotency_key.clone(), - ingest_receipt.inserted, + event_store_inserted, observed_at_ms, ); let outbox_receipt = sdk @@ -190,7 +199,7 @@ async fn enqueue_signed_workflow_event( digest_prefix(outbox_receipt.operation_idempotency_digest.as_str()); let receipt = SdkWorkflowEnqueueReceipt { signed_event_id, - local_event_seq: ingest_receipt.seq, + local_event_seq, outbox_operation_id: outbox_receipt.operation_id, outbox_event_id: outbox_receipt.outbox_event_id, state: outbox_receipt.status, @@ -241,12 +250,17 @@ async fn enqueue_signed_trade_workflow_event( RadrootsTransportObservationType::LocalImport, observed_at_ms, )?; - let ingest = RadrootsEventIngest::new(signed_event.clone(), observed_at_ms) - .with_observation(local_import_observation); + let ingest = + workflow_event_ingest(request.operation_kind, signed_event.clone(), observed_at_ms)? + .with_observation(local_import_observation); let ingest_receipt = sdk ._event_store .ingest_event_in_transaction(&mut tx, ingest) .await?; + let (event_store_inserted, local_event_seq) = durable_event_persistence( + ingest_receipt.event_id.as_str(), + &ingest_receipt.persistence, + )?; tx.commit() .await .map_err(|error| RadrootsSdkError::EventStore { @@ -258,7 +272,7 @@ async fn enqueue_signed_trade_workflow_event( signed_event, delivery_plan_value, idempotency_key.clone(), - ingest_receipt.inserted, + event_store_inserted, observed_at_ms, )?; let outbox_receipt = sdk @@ -269,7 +283,7 @@ async fn enqueue_signed_trade_workflow_event( digest_prefix(outbox_receipt.operation_idempotency_digest.as_str()); let receipt = SdkWorkflowEnqueueReceipt { signed_event_id, - local_event_seq: ingest_receipt.seq, + local_event_seq, outbox_operation_id: outbox_receipt.operation_id, outbox_event_id: outbox_receipt.outbox_event_id, state: outbox_receipt.status, @@ -418,6 +432,58 @@ fn digest_prefix(digest: &str) -> String { digest.chars().take(12).collect() } +fn ensure_durable_workflow_kind( + request: &SdkWorkflowEnqueueRequest<'_>, +) -> Result<(), RadrootsSdkError> { + if RadrootsEventKind::new(request.frozen_draft.kind_u32()).class() + == RadrootsEventKindClass::Ephemeral + { + return Err(RadrootsSdkError::InvalidRequest { + message: format!( + "{} cannot enqueue ephemeral event kind {} into a durable workflow", + request.operation_kind, + request.frozen_draft.kind_u32() + ), + }); + } + Ok(()) +} + +fn workflow_event_ingest( + operation_kind: &str, + signed_event: RadrootsSignedEvent, + observed_at_ms: i64, +) -> Result<RadrootsEventIngest, RadrootsSdkError> { + RadrootsEventIngest::from_signed_event(signed_event, observed_at_ms).map_err( + |error| match error { + RadrootsEventStoreError::Nip01Verification(error) => { + RadrootsSdkError::SignerReturnedEventDrift { + operation: operation_kind.to_owned(), + reason: format!( + "signer returned an event that failed NIP-01 verification: {error}" + ), + } + } + error => error.into(), + }, + ) +} + +fn durable_event_persistence( + event_id: &str, + persistence: &RadrootsEventPersistence, +) -> Result<(bool, i64), RadrootsSdkError> { + match persistence { + RadrootsEventPersistence::Inserted { seq } => Ok((true, *seq)), + RadrootsEventPersistence::Duplicate { seq } => Ok((false, *seq)), + RadrootsEventPersistence::NotPersisted => Err(RadrootsSdkError::InvalidRequest { + message: format!( + "workflow event `{event_id}` requires durable local event-store persistence" + ), + }), + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SdkRuntimeOperationState { Prepared, @@ -727,13 +793,15 @@ async fn record_runtime_operation_failure( if let Some((recovery_code, recovery_action)) = recovery { record_runtime_recovery_receipt( sdk._event_store.pool(), - recovery_code, - Some(request.operation_kind), - Some(request.actor.pubkey().as_str()), - Some(idempotency_key.as_str()), - recovery_action, - error.detail_json(), - sdk_now_ms(sdk)?, + RuntimeRecoveryReceiptWrite { + recovery_code, + operation_kind: Some(request.operation_kind), + actor_pubkey: Some(request.actor.pubkey().as_str()), + idempotency_key: Some(idempotency_key.as_str()), + recovery_action, + detail_json: error.detail_json(), + created_at_ms: sdk_now_ms(sdk)?, + }, ) .await?; } diff --git a/crates/sdk/tests/farms_runtime.rs b/crates/sdk/tests/farms_runtime.rs @@ -1,11 +1,8 @@ #![cfg(feature = "runtime")] -use radroots_authority::{ - RadrootsActorContext, RadrootsEventSigner, RadrootsSignerError, RadrootsSignerIdentity, -}; +use radroots_authority::RadrootsActorContext; use radroots_event::{ contract::RadrootsActorRole, - draft::{RadrootsEventDraft, RadrootsSignedEvent, RadrootsSignedEventParts}, farm::RadrootsFarm, ids::RadrootsAddressableCoordinate, kinds::{KIND_FARM, KIND_PROFILE}, @@ -30,13 +27,14 @@ use radroots_sdk::{ use radroots_transport_nostr::{RadrootsMockRelayPublishAdapter, RadrootsNostrTransport}; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +#[path = "support/fixture_signer.rs"] +mod fixture_signer; #[path = "support/serializer_failure.rs"] mod serializer_failure; +use fixture_signer::{FixtureSigner, fixture_alice_pubkey, fixture_bob_pubkey}; use serializer_failure::assert_struct_serialize_error_paths; -const FARMER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; -const OTHER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const FARM_A_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; const FARM_B_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAQ"; const FARM_C_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAg"; @@ -46,66 +44,20 @@ const FARM_F_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAABQ"; const RELAY: &str = "wss://relay.example.com"; const RELAY_B: &str = "wss://relay-b.example.com"; -#[derive(Clone)] -struct FixtureSigner { - identity: RadrootsSignerIdentity, -} - -impl FixtureSigner { - fn new(pubkey: &str) -> Self { - Self { - identity: RadrootsSignerIdentity::new(pubkey).expect("identity"), - } - } +fn farmer_pubkey() -> &'static str { + fixture_alice_pubkey() } -impl RadrootsEventSigner for FixtureSigner { - fn pubkey(&self) -> &radroots_event::ids::RadrootsPublicKey { - self.identity.pubkey() - } - - fn sign_frozen_draft( - &self, - draft: &RadrootsEventDraft, - ) -> Result<RadrootsSignedEvent, RadrootsSignerError> { - if self.pubkey().as_str() != draft.expected_pubkey_str() { - return Err(RadrootsSignerError::SigningFailed { - message: "wrong fixture signer".to_owned(), - }); - } - let sig = "f".repeat(128); - let raw_json = serde_json::json!({ - "id": draft.expected_event_id_str(), - "pubkey": self.pubkey().as_str(), - "created_at": draft.created_at_u64(), - "kind": draft.kind_u32(), - "tags": draft.tags_as_vec(), - "content": draft.content(), - "sig": sig, - }) - .to_string(); - RadrootsSignedEvent::new(RadrootsSignedEventParts { - id: draft.expected_event_id_str().to_owned(), - pubkey: self.pubkey().as_str().to_owned(), - created_at: draft.created_at_u64(), - kind: draft.kind_u32(), - tags: draft.tags_as_vec(), - content: draft.content().to_owned(), - sig, - raw_json, - }) - .map_err(|error| RadrootsSignerError::SigningFailed { - message: error.to_string(), - }) - } +fn other_pubkey() -> &'static str { + fixture_bob_pubkey() } fn farmer_actor() -> RadrootsActorContext { - RadrootsActorContext::test(FARMER, [RadrootsActorRole::Farmer]).expect("actor") + RadrootsActorContext::test(farmer_pubkey(), [RadrootsActorRole::Farmer]).expect("actor") } fn non_farmer_actor() -> RadrootsActorContext { - RadrootsActorContext::test(FARMER, [RadrootsActorRole::Buyer]).expect("actor") + RadrootsActorContext::test(farmer_pubkey(), [RadrootsActorRole::Buyer]).expect("actor") } fn farm(d_tag: &str, name: &str) -> RadrootsFarm { @@ -252,15 +204,15 @@ async fn farm_prepare_publish_is_side_effect_free() { let request = FarmPreparePublishRequest::new(farmer_actor(), farm(FARM_A_D_TAG, "North Farm")); let prepared = sdk.farms().prepare_publish(request).expect("prepared"); - assert_eq!(prepared.frozen_draft.kind_u32(), KIND_FARM); - assert_eq!(prepared.created_at.unix_seconds(), 1_700_000_000); + assert_eq!(prepared.frozen_draft().kind_u32(), KIND_FARM); + assert_eq!(prepared.created_at().unix_seconds(), 1_700_000_000); assert_eq!( - prepared.expected_event_id, - prepared.frozen_draft.expected_event_id_str() + prepared.expected_event_id().as_str(), + prepared.frozen_draft().expected_event_id_str() ); assert_eq!( - prepared.farm_addr.as_str(), - format!("{KIND_FARM}:{FARMER}:{FARM_A_D_TAG}") + prepared.farm_addr().as_str(), + format!("{KIND_FARM}:{}:{FARM_A_D_TAG}", farmer_pubkey()) ); let paths = sdk.storage_paths().expect("paths"); @@ -277,7 +229,7 @@ async fn farm_prepare_publish_is_side_effect_free() { ); assert!( event_store - .get_event(prepared.expected_event_id.as_str()) + .raw_event(prepared.expected_event_id().as_str()) .await .expect("event lookup") .is_none() @@ -327,9 +279,9 @@ async fn farm_private_location_upsert_stores_exact_location_and_public_locality_ assert_eq!( receipt.farm_addr.as_str(), - format!("{KIND_FARM}:{FARMER}:{FARM_A_D_TAG}") + format!("{KIND_FARM}:{}:{FARM_A_D_TAG}", farmer_pubkey()) ); - assert_eq!(receipt.farm_pubkey, FARMER); + assert_eq!(receipt.farm_pubkey, farmer_pubkey()); assert_eq!(receipt.farm_d_tag, FARM_A_D_TAG); assert_eq!(receipt.label, None); assert_eq!(receipt.exact_location, SdkExactLocation::new(12.26, -34.51)); @@ -641,13 +593,13 @@ async fn farm_enqueue_publish_stores_event_and_queues_signed_outbox_without_prof .expect("prepared"); let receipt = sdk .farms() - .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(FARMER)) + .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(farmer_pubkey())) .await .expect("enqueue"); - assert_eq!(receipt.expected_event_id, prepared.expected_event_id); + assert_eq!(&receipt.expected_event_id, prepared.expected_event_id()); assert_eq!(receipt.signed_event_id, receipt.expected_event_id); - assert_eq!(receipt.farm_addr, prepared.farm_addr); + assert_eq!(&receipt.farm_addr, prepared.farm_addr()); assert_eq!(receipt.local_event_seq, 1); assert_eq!(receipt.outbox_operation_id, 1); assert_eq!(receipt.outbox_event_id, 1); @@ -664,10 +616,11 @@ async fn farm_enqueue_publish_stores_event_and_queues_signed_outbox_without_prof .expect("event store status"); assert_eq!(status.total_events, 1); let stored_event = event_store - .get_event(receipt.signed_event_id.as_str()) + .valid_event(receipt.signed_event_id.as_str()) .await .expect("event lookup") .expect("stored event"); + let stored_event = stored_event.raw_event(); assert_eq!(stored_event.kind, KIND_FARM); assert_ne!(stored_event.kind, KIND_PROFILE); assert_eq!( @@ -700,7 +653,7 @@ async fn farm_enqueue_publish_returns_sanitized_signer_errors_before_mutation() .expect("idempotency key"); let error = sdk .farms() - .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(OTHER)) + .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(other_pubkey())) .await .expect_err("signer error"); let message = error.to_string(); @@ -761,12 +714,12 @@ async fn farm_enqueue_publish_uses_explicit_idempotency_key_across_equivalent_ta let first_receipt = sdk .farms() - .enqueue_publish_with_explicit_signer(first, &FixtureSigner::new(FARMER)) + .enqueue_publish_with_explicit_signer(first, &FixtureSigner::new(farmer_pubkey())) .await .expect("first enqueue"); let second_receipt = sdk .farms() - .enqueue_publish_with_explicit_signer(second, &FixtureSigner::new(FARMER)) + .enqueue_publish_with_explicit_signer(second, &FixtureSigner::new(farmer_pubkey())) .await .expect("second enqueue"); @@ -808,7 +761,7 @@ async fn farm_enqueue_publish_pushes_queued_event_with_mock_relay_sync() { .expect("idempotency key"); let enqueue_receipt = sdk .farms() - .enqueue_publish_with_explicit_signer(enqueue_request, &FixtureSigner::new(FARMER)) + .enqueue_publish_with_explicit_signer(enqueue_request, &FixtureSigner::new(farmer_pubkey())) .await .expect("enqueue"); let adapter = RadrootsMockRelayPublishAdapter::new(); @@ -869,7 +822,7 @@ async fn farm_multi_target_profile_publishes_after_nostr_success_and_retains_ret ) .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000022f") .expect("idempotency key"), - &FixtureSigner::new(FARMER), + &FixtureSigner::new(farmer_pubkey()), ) .await .expect("enqueue"); @@ -944,7 +897,7 @@ async fn farm_enqueue_publish_reports_preflight_idempotency_conflict_without_mut .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000022a") .expect("idempotency key"); sdk.farms() - .enqueue_publish_with_explicit_signer(first, &FixtureSigner::new(FARMER)) + .enqueue_publish_with_explicit_signer(first, &FixtureSigner::new(farmer_pubkey())) .await .expect("first enqueue"); let paths = sdk.storage_paths().expect("paths"); @@ -980,7 +933,7 @@ async fn farm_enqueue_publish_reports_preflight_idempotency_conflict_without_mut .expect("idempotency key"); let error = sdk .farms() - .enqueue_publish_with_explicit_signer(second, &FixtureSigner::new(FARMER)) + .enqueue_publish_with_explicit_signer(second, &FixtureSigner::new(farmer_pubkey())) .await .expect_err("conflict"); @@ -1030,7 +983,7 @@ async fn farm_runtime_dtos_serialize_deterministically() { prepare_json, serde_json::json!({ "actor": { - "pubkey": FARMER, + "pubkey": farmer_pubkey(), "roles": ["farmer"], "account_id": null, "source": "test" @@ -1067,7 +1020,7 @@ async fn farm_runtime_dtos_serialize_deterministically() { enqueue_json, serde_json::json!({ "actor": { - "pubkey": FARMER, + "pubkey": farmer_pubkey(), "roles": ["farmer"], "account_id": null, "source": "test" @@ -1138,7 +1091,7 @@ async fn farm_runtime_dtos_serialize_deterministically() { serde_json::to_value(&private_upsert).expect("private upsert json"), serde_json::json!({ "actor": { - "pubkey": FARMER, + "pubkey": farmer_pubkey(), "roles": ["farmer"], "account_id": null, "source": "test" @@ -1165,7 +1118,7 @@ async fn farm_runtime_dtos_serialize_deterministically() { serde_json::to_value(&private_set).expect("private set json"), serde_json::json!({ "actor": { - "pubkey": FARMER, + "pubkey": farmer_pubkey(), "roles": ["farmer"], "account_id": null, "source": "test" @@ -1224,7 +1177,7 @@ async fn farm_runtime_dtos_serialize_deterministically() { serde_json::to_value(&private_clear).expect("private clear json"), serde_json::json!({ "actor": { - "pubkey": FARMER, + "pubkey": farmer_pubkey(), "roles": ["farmer"], "account_id": null, "source": "test" @@ -1236,7 +1189,7 @@ async fn farm_runtime_dtos_serialize_deterministically() { let private_receipt = FarmPrivateLocationReceipt { farm_addr: farm_addr(&farmer_actor(), FARM_D_D_TAG), - farm_pubkey: FARMER.to_owned(), + farm_pubkey: farmer_pubkey().to_owned(), farm_d_tag: FARM_D_D_TAG.to_owned(), label: Some("identifier gate".to_owned()), exact_location: SdkExactLocation::new(48.9, -123.4), @@ -1293,7 +1246,7 @@ async fn farm_runtime_dtos_serialize_deterministically() { assert_struct_serialize_error_paths(&candidate, 7); let lookup = FarmPrivateLocationLookupReceipt { farm_addr: farm_addr(&farmer_actor(), FARM_F_D_TAG), - farm_pubkey: FARMER.to_owned(), + farm_pubkey: farmer_pubkey().to_owned(), farm_d_tag: FARM_F_D_TAG.to_owned(), input: FarmPrivateLocationInput::query("Shared Market"), candidates: vec![candidate], @@ -1322,7 +1275,7 @@ async fn farm_runtime_dtos_serialize_deterministically() { let receipt = sdk .farms() - .enqueue_publish_with_explicit_signer(enqueue_request, &FixtureSigner::new(FARMER)) + .enqueue_publish_with_explicit_signer(enqueue_request, &FixtureSigner::new(farmer_pubkey())) .await .expect("enqueue"); let receipt_json = serde_json::to_value(&receipt).expect("receipt json"); diff --git a/crates/sdk/tests/knowledge_public_api.rs b/crates/sdk/tests/knowledge_public_api.rs @@ -3,8 +3,9 @@ use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use radroots_event::RadrootsEventEnvelopeParts; use radroots_sdk::knowledge::prelude::*; +use std::sync::LazyLock; -const SECRET_KEY_HEX: &str = "0101010101010101010101010101010101010101010101010101010101010101"; +static KNOWLEDGE_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate); const CREATED_AT: u32 = 1_800_000_000; const RELAY: &str = "wss://relay.radroots.example"; @@ -410,6 +411,35 @@ fn knowledge_errors_expose_stable_codes() { .into(); assert_eq!(draft_error.code(), "knowledge_draft"); assert_eq!(draft_error.inner_code(), "missing_tag"); + + let draft_errors = [ + ( + RadrootsDraftError::ContractNotDraftAuthorable { + contract_id: KNOWLEDGE_CLAIM_CONTRACT_ID.to_owned(), + }, + "contract_not_draft_authorable", + ), + ( + RadrootsDraftError::ContractRegistryVersionMismatch { + expected: 2, + actual: 1, + }, + "contract_registry_version_mismatch", + ), + ( + RadrootsDraftError::DraftExpectedEventIdMismatch { + expected_event_id: hex_64('a'), + actual_event_id: hex_64('b'), + }, + "draft_expected_event_id_mismatch", + ), + ]; + + for (draft_error, expected_inner_code) in draft_errors { + let error = RadrootsSdkKnowledgeError::from(draft_error); + assert_eq!(error.code(), "knowledge_draft"); + assert_eq!(error.inner_code(), expected_inner_code); + } } fn sign_parts(parts: RadrootsNip01EventWireParts) -> RadrootsEventEnvelope { @@ -419,11 +449,10 @@ fn sign_parts(parts: RadrootsNip01EventWireParts) -> RadrootsEventEnvelope { .map(Tag::parse) .collect::<Result<Vec<_>, _>>() .expect("tags"); - let keys = Keys::parse(SECRET_KEY_HEX).expect("keys"); let event = EventBuilder::new(Kind::Custom(parts.kind as u16), parts.content) .tags(tags) .custom_created_at(Timestamp::from_secs(u64::from(CREATED_AT))) - .sign_with_keys(&keys) + .sign_with_keys(&KNOWLEDGE_KEYS) .expect("signed event"); RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { id: event.id.to_hex(), @@ -443,10 +472,7 @@ fn sign_parts(parts: RadrootsNip01EventWireParts) -> RadrootsEventEnvelope { } fn public_key_hex() -> String { - Keys::parse(SECRET_KEY_HEX) - .expect("keys") - .public_key() - .to_hex() + KNOWLEDGE_KEYS.public_key().to_hex() } fn hex_64(character: char) -> String { diff --git a/crates/sdk/tests/listings_runtime.rs b/crates/sdk/tests/listings_runtime.rs @@ -1,19 +1,21 @@ #![cfg(feature = "runtime")] -use radroots_authority::{ - RadrootsActorContext, RadrootsEventSigner, RadrootsSignerError, RadrootsSignerIdentity, -}; +use radroots_authority::RadrootsActorContext; use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, }; use radroots_event::{ contract::RadrootsActorRole, - draft::{RadrootsEventDraft, RadrootsSignedEvent, RadrootsSignedEventParts}, farm::RadrootsFarmRef, ids::{RadrootsDTag, RadrootsInventoryBinId}, - kinds::KIND_LISTING, - listing::{RadrootsListing, RadrootsListingBin, RadrootsListingProduct}, + kinds::KIND_CLASSIFIED_LISTING, + operational_listing::{ + RadrootsOperationalListing, RadrootsOperationalListingAvailability, + RadrootsOperationalListingBin, RadrootsOperationalListingDeliveryMethod, + RadrootsOperationalListingProduct, RadrootsOperationalListingPublicLocation, + RadrootsOperationalListingStatus, + }, }; use radroots_event_store::RadrootsEventStore; use radroots_outbox::{ @@ -27,17 +29,18 @@ use radroots_sdk::{ RadrootsSdkTimestamp, ReticulumProfile, SdkIdempotencyKey, SdkMutationState, TargetPolicy, TargetSet, TransportProfile, }; -use radroots_trade::listing::RadrootsListingEditDocumentV1; +use radroots_trade::operational_listing::RadrootsOperationalListingEditDocumentV1; use radroots_transport_nostr::{RadrootsMockRelayPublishAdapter, RadrootsNostrTransport}; use sqlx::Row; +#[path = "support/fixture_signer.rs"] +mod fixture_signer; #[path = "support/serializer_failure.rs"] mod serializer_failure; +use fixture_signer::{FixtureSigner, fixture_alice_pubkey, fixture_bob_pubkey}; use serializer_failure::assert_struct_serialize_error_paths; -const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; -const OTHER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; const LISTING_A_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAQ"; const LISTING_B_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAg"; @@ -53,77 +56,31 @@ const LISTING_K_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAACw"; const RELAY: &str = "wss://relay.example.com"; const RELAY_B: &str = "wss://relay-b.example.com"; -#[derive(Clone)] -struct FixtureSigner { - identity: RadrootsSignerIdentity, +fn seller_pubkey() -> &'static str { + fixture_alice_pubkey() } -impl FixtureSigner { - fn new(pubkey: &str) -> Self { - Self { - identity: RadrootsSignerIdentity::new(pubkey).expect("identity"), - } - } -} - -impl RadrootsEventSigner for FixtureSigner { - fn pubkey(&self) -> &radroots_event::ids::RadrootsPublicKey { - self.identity.pubkey() - } - - fn sign_frozen_draft( - &self, - draft: &RadrootsEventDraft, - ) -> Result<RadrootsSignedEvent, RadrootsSignerError> { - if self.pubkey().as_str() != draft.expected_pubkey_str() { - return Err(RadrootsSignerError::SigningFailed { - message: "wrong fixture signer".to_owned(), - }); - } - let sig = "f".repeat(128); - let raw_json = serde_json::json!({ - "id": draft.expected_event_id_str(), - "pubkey": self.pubkey().as_str(), - "created_at": draft.created_at_u64(), - "kind": draft.kind_u32(), - "tags": draft.tags_as_vec(), - "content": draft.content(), - "sig": sig, - }) - .to_string(); - RadrootsSignedEvent::new(RadrootsSignedEventParts { - id: draft.expected_event_id_str().to_owned(), - pubkey: self.pubkey().as_str().to_owned(), - created_at: draft.created_at_u64(), - kind: draft.kind_u32(), - tags: draft.tags_as_vec(), - content: draft.content().to_owned(), - sig, - raw_json, - }) - .map_err(|error| RadrootsSignerError::SigningFailed { - message: error.to_string(), - }) - } +fn other_pubkey() -> &'static str { + fixture_bob_pubkey() } fn actor() -> RadrootsActorContext { - RadrootsActorContext::test(SELLER, [RadrootsActorRole::Seller]).expect("actor") + RadrootsActorContext::test(seller_pubkey(), [RadrootsActorRole::Seller]).expect("actor") } fn non_seller_actor() -> RadrootsActorContext { - RadrootsActorContext::test(SELLER, [RadrootsActorRole::Buyer]).expect("actor") + RadrootsActorContext::test(seller_pubkey(), [RadrootsActorRole::Buyer]).expect("actor") } -fn listing(d_tag: &str, title: &str) -> RadrootsListing { - RadrootsListing { +fn listing(d_tag: &str, title: &str) -> RadrootsOperationalListing { + RadrootsOperationalListing { d_tag: RadrootsDTag::parse(d_tag).expect("d tag"), published_at: None, farm: RadrootsFarmRef { - pubkey: SELLER.to_owned(), + pubkey: seller_pubkey().to_owned(), d_tag: FARM_D_TAG.to_owned(), }, - product: RadrootsListingProduct { + product: RadrootsOperationalListingProduct { key: "coffee".to_owned(), title: title.to_owned(), category: "coffee".to_owned(), @@ -135,7 +92,7 @@ fn listing(d_tag: &str, title: &str) -> RadrootsListing { year: None, }, primary_bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"), - bins: vec![RadrootsListingBin { + bins: vec![RadrootsOperationalListingBin { bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"), quantity: RadrootsCoreQuantity::new( RadrootsCoreDecimal::from(1000u32), @@ -160,10 +117,18 @@ fn listing(d_tag: &str, title: &str) -> RadrootsListing { resource_area: None, plot: None, discounts: None, - inventory_available: None, - availability: None, - delivery_method: None, - location: None, + inventory_available: Some(RadrootsCoreDecimal::from(5u32)), + availability: Some(RadrootsOperationalListingAvailability::Status { + status: RadrootsOperationalListingStatus::Active, + }), + delivery_method: Some(RadrootsOperationalListingDeliveryMethod::Pickup), + location: Some(RadrootsOperationalListingPublicLocation { + primary: "Victoria".to_owned(), + city: Some("Victoria".to_owned()), + region: Some("British Columbia".to_owned()), + country: Some("CA".to_owned()), + geohash: "c287g".to_owned(), + }), images: None, } } @@ -208,15 +173,18 @@ async fn prepare_publish_is_side_effect_free() { let request = ListingPreparePublishRequest::new(actor(), listing(LISTING_A_D_TAG, "Coffee")); let prepared = sdk.listings().prepare_publish(request).expect("prepared"); - assert_eq!(prepared.frozen_draft.kind_u32(), KIND_LISTING); - assert_eq!(prepared.created_at.unix_seconds(), 1_700_000_000); + assert_eq!(prepared.frozen_draft().kind_u32(), KIND_CLASSIFIED_LISTING); + assert_eq!(prepared.created_at().unix_seconds(), 1_700_000_000); assert_eq!( - prepared.expected_event_id, - prepared.frozen_draft.expected_event_id_str() + prepared.expected_event_id().as_str(), + prepared.frozen_draft().expected_event_id_str() ); assert_eq!( - prepared.public_listing_addr.as_str(), - format!("{KIND_LISTING}:{SELLER}:{LISTING_A_D_TAG}") + prepared.public_listing_addr().as_str(), + format!( + "{KIND_CLASSIFIED_LISTING}:{}:{LISTING_A_D_TAG}", + seller_pubkey() + ) ); let paths = sdk.storage_paths().expect("paths"); @@ -225,7 +193,7 @@ async fn prepare_publish_is_side_effect_free() { .expect("event store"); assert!( event_store - .get_event(prepared.expected_event_id.as_str()) + .raw_event(prepared.expected_event_id().as_str()) .await .expect("event lookup") .is_none() @@ -275,13 +243,13 @@ async fn enqueue_publish_stores_event_and_queues_signed_outbox_without_publish() .expect("prepared"); let receipt = sdk .listings() - .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(SELLER)) + .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(seller_pubkey())) .await .expect("enqueue"); - assert_eq!(receipt.expected_event_id, prepared.expected_event_id); + assert_eq!(&receipt.expected_event_id, prepared.expected_event_id()); assert_eq!(receipt.signed_event_id, receipt.expected_event_id); - assert_eq!(receipt.public_listing_addr, prepared.public_listing_addr); + assert_eq!(&receipt.public_listing_addr, prepared.public_listing_addr()); assert_eq!(receipt.local_event_seq, 1); assert_eq!(receipt.outbox_operation_id, 1); assert_eq!(receipt.outbox_event_id, 1); @@ -294,7 +262,7 @@ async fn enqueue_publish_stores_event_and_queues_signed_outbox_without_publish() .expect("event store"); assert!( event_store - .get_event(receipt.signed_event_id.as_str()) + .valid_event(receipt.signed_event_id.as_str()) .await .expect("event lookup") .is_some() @@ -323,7 +291,7 @@ async fn enqueue_publish_default_profile_rejects_empty_transport_targets() { let error = sdk .listings() - .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(SELLER)) + .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(seller_pubkey())) .await .expect_err("empty transport profile"); @@ -355,13 +323,13 @@ async fn prepare_then_enqueue_prepared_uses_same_event_id() { SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000238") .expect("idempotency"), ), - &FixtureSigner::new(SELLER), + &FixtureSigner::new(seller_pubkey()), ) .await .expect("prepared enqueue"); - assert_eq!(receipt.expected_event_id, prepared.expected_event_id); - assert_eq!(receipt.signed_event_id, prepared.expected_event_id); + assert_eq!(&receipt.expected_event_id, prepared.expected_event_id()); + assert_eq!(&receipt.signed_event_id, prepared.expected_event_id()); let paths = sdk.storage_paths().expect("paths"); let event_store = RadrootsEventStore::open_file(&paths.runtime_path) @@ -369,7 +337,7 @@ async fn prepare_then_enqueue_prepared_uses_same_event_id() { .expect("event store"); assert!( event_store - .get_event(prepared.expected_event_id.as_str()) + .valid_event(prepared.expected_event_id().as_str()) .await .expect("event lookup") .is_some() @@ -383,7 +351,7 @@ async fn prepare_then_enqueue_prepared_uses_same_event_id() { .await .expect("outbox event") .expect("outbox event"); - assert_eq!(outbox_event.event_id, prepared.expected_event_id.as_str()); + assert_eq!(outbox_event.event_id, prepared.expected_event_id().as_str()); } #[tokio::test] @@ -398,7 +366,7 @@ async fn enqueue_receipt_debug_omits_signed_event_payload_material() { .expect("idempotency key"); let receipt = sdk .listings() - .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(SELLER)) + .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(seller_pubkey())) .await .expect("enqueue"); let debug = format!("{receipt:?}"); @@ -430,13 +398,16 @@ async fn listing_runtime_dtos_serialize_deterministically() { let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_123); let prepare_request = ListingPreparePublishRequest::from_document( actor(), - RadrootsListingEditDocumentV1::new(listing(LISTING_A_D_TAG, "Serialized Coffee")), + RadrootsOperationalListingEditDocumentV1::new(listing( + LISTING_A_D_TAG, + "Serialized Coffee", + )), ) .with_created_at(created_at); let prepare_json = serde_json::to_value(&prepare_request).expect("prepare request json"); assert_struct_serialize_error_paths(&prepare_request, 3); - assert_eq!(prepare_json["actor"]["pubkey"], SELLER); + assert_eq!(prepare_json["actor"]["pubkey"], seller_pubkey()); assert_eq!( prepare_json["actor"]["roles"], serde_json::json!(["seller"]) @@ -501,7 +472,7 @@ async fn listing_runtime_dtos_serialize_deterministically() { let try_key_request = ListingEnqueuePublishRequest::from_document( actor(), - RadrootsListingEditDocumentV1::new(listing(LISTING_C_D_TAG, "Queued Coffee")), + RadrootsOperationalListingEditDocumentV1::new(listing(LISTING_C_D_TAG, "Queued Coffee")), TargetPolicy::default_profile(), ) .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000227") @@ -513,7 +484,7 @@ async fn listing_runtime_dtos_serialize_deterministically() { let receipt = sdk .listings() - .enqueue_publish_with_explicit_signer(enqueue_request, &FixtureSigner::new(SELLER)) + .enqueue_publish_with_explicit_signer(enqueue_request, &FixtureSigner::new(seller_pubkey())) .await .expect("enqueue"); let receipt_json = serde_json::to_value(&receipt).expect("receipt json"); @@ -544,7 +515,7 @@ async fn enqueue_publish_convenience_matches_prepare_plus_enqueue_prepared() { SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000239") .expect("idempotency"), ), - &FixtureSigner::new(SELLER), + &FixtureSigner::new(seller_pubkey()), ) .await .expect("prepared enqueue"); @@ -559,7 +530,10 @@ async fn enqueue_publish_convenience_matches_prepare_plus_enqueue_prepared() { .expect("idempotency"); let convenience_receipt = convenience_sdk .listings() - .enqueue_publish_with_explicit_signer(convenience_request, &FixtureSigner::new(SELLER)) + .enqueue_publish_with_explicit_signer( + convenience_request, + &FixtureSigner::new(seller_pubkey()), + ) .await .expect("convenience enqueue"); @@ -586,7 +560,7 @@ async fn enqueue_prepared_publish_returns_structured_actor_errors() { SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-00000000023e") .expect("idempotency key"), ), - &FixtureSigner::new(SELLER), + &FixtureSigner::new(seller_pubkey()), ) .await .expect_err("actor error"); @@ -615,7 +589,7 @@ async fn enqueue_prepared_publish_returns_sanitized_signer_errors() { SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-00000000023f") .expect("idempotency key"), ), - &FixtureSigner::new(OTHER), + &FixtureSigner::new(other_pubkey()), ) .await .expect_err("signer error"); @@ -645,7 +619,7 @@ async fn explicit_historical_created_at_does_not_backdate_observed_at_ms() { let receipt = sdk .listings() - .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(SELLER)) + .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(seller_pubkey())) .await .expect("enqueue"); @@ -654,10 +628,11 @@ async fn explicit_historical_created_at_does_not_backdate_observed_at_ms() { .await .expect("event store"); let stored_event = event_store - .get_event(receipt.signed_event_id.as_str()) + .valid_event(receipt.signed_event_id.as_str()) .await .expect("event lookup") .expect("stored event"); + let stored_event = stored_event.raw_event(); assert_eq!(stored_event.created_at, 1_600_000_000); assert_eq!(stored_event.inserted_at_ms, observed_at_ms); assert_eq!(stored_event.updated_at_ms, observed_at_ms); @@ -691,7 +666,7 @@ async fn enqueue_publish_returns_sanitized_signer_errors() { .expect("idempotency key"); let error = sdk .listings() - .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(OTHER)) + .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(other_pubkey())) .await .expect_err("signer error"); let message = error.to_string(); @@ -715,7 +690,7 @@ async fn enqueue_publish_reports_preflight_idempotency_conflict_without_mutation .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000228") .expect("idempotency key"); sdk.listings() - .enqueue_publish_with_explicit_signer(first, &FixtureSigner::new(SELLER)) + .enqueue_publish_with_explicit_signer(first, &FixtureSigner::new(seller_pubkey())) .await .expect("first enqueue"); let paths = sdk.storage_paths().expect("paths"); @@ -751,7 +726,7 @@ async fn enqueue_publish_reports_preflight_idempotency_conflict_without_mutation .expect("idempotency key"); let error = sdk .listings() - .enqueue_publish_with_explicit_signer(second, &FixtureSigner::new(SELLER)) + .enqueue_publish_with_explicit_signer(second, &FixtureSigner::new(seller_pubkey())) .await .expect_err("conflict"); @@ -799,7 +774,7 @@ async fn enqueue_publish_rolls_back_event_and_journal_when_outbox_conflicts_afte "INSERT INTO outbox_operations(operation_kind, expected_pubkey, semantic_scope, trade_id, mutation_id, canonical_payload_sha256, idempotency_key, operation_idempotency_digest, status, created_at_ms, updated_at_ms) VALUES (?, ?, 'generic_event', NULL, NULL, NULL, ?, ?, 'queued', ?, ?)", ) .bind(LISTING_PUBLISH_OPERATION_KIND) - .bind(SELLER) + .bind(seller_pubkey()) .bind(idempotency_key) .bind("conflicting-digest") .bind(1_700_000_000_000i64) @@ -813,7 +788,7 @@ async fn enqueue_publish_rolls_back_event_and_journal_when_outbox_conflicts_afte ) .bind(conflicting_operation) .bind("0".repeat(64)) - .bind(SELLER) + .bind(seller_pubkey()) .bind(1_700_000_000_000i64) .bind(1_700_000_000_000i64) .bind(1_700_000_000_000i64) @@ -837,7 +812,7 @@ async fn enqueue_publish_rolls_back_event_and_journal_when_outbox_conflicts_afte .expect("prepared"); let error = sdk .listings() - .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(SELLER)) + .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(seller_pubkey())) .await .expect_err("outbox conflict"); assert!(matches!( @@ -850,7 +825,7 @@ async fn enqueue_publish_rolls_back_event_and_journal_when_outbox_conflicts_afte .expect("event store"); assert!( event_store - .get_event(prepared.expected_event_id.as_str()) + .raw_event(prepared.expected_event_id().as_str()) .await .expect("event lookup") .is_none() @@ -859,7 +834,7 @@ async fn enqueue_publish_rolls_back_event_and_journal_when_outbox_conflicts_afte "SELECT state, last_error_code FROM sdk_runtime_operation_journal WHERE operation_kind = ? AND actor_pubkey = ? AND idempotency_key = ?", ) .bind(LISTING_PUBLISH_OPERATION_KIND) - .bind(SELLER) + .bind(seller_pubkey()) .bind(idempotency_key) .fetch_one(event_store.pool()) .await @@ -874,7 +849,7 @@ async fn enqueue_publish_rolls_back_event_and_journal_when_outbox_conflicts_afte "SELECT COUNT(*) FROM sdk_runtime_recovery_receipt WHERE recovery_code = 'idempotency_conflict' AND operation_kind = ? AND actor_pubkey = ? AND idempotency_key = ?", ) .bind(LISTING_PUBLISH_OPERATION_KIND) - .bind(SELLER) + .bind(seller_pubkey()) .bind(idempotency_key) .fetch_one(event_store.pool()) .await @@ -920,12 +895,12 @@ async fn enqueue_publish_uses_explicit_idempotency_key_across_equivalent_target_ let first_receipt = sdk .listings() - .enqueue_publish_with_explicit_signer(first, &FixtureSigner::new(SELLER)) + .enqueue_publish_with_explicit_signer(first, &FixtureSigner::new(seller_pubkey())) .await .expect("first enqueue"); let second_receipt = sdk .listings() - .enqueue_publish_with_explicit_signer(second, &FixtureSigner::new(SELLER)) + .enqueue_publish_with_explicit_signer(second, &FixtureSigner::new(seller_pubkey())) .await .expect("second enqueue"); @@ -966,7 +941,7 @@ async fn listing_multi_target_profile_publishes_after_nostr_success_and_retains_ ) .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000023c") .expect("idempotency"), - &FixtureSigner::new(SELLER), + &FixtureSigner::new(seller_pubkey()), ) .await .expect("enqueue"); diff --git a/crates/sdk/tests/runtime_foundation.rs b/crates/sdk/tests/runtime_foundation.rs @@ -3,9 +3,9 @@ use radroots_sdk::{ BackupRequest, IntegrityRequest, LISTING_PUBLISH_OPERATION_KIND, NostrProfile, NostrRelayUrlPolicy, RadrootsClient, RadrootsSdkClock, RadrootsSdkError, RadrootsSdkErrorClass, - RadrootsSdkGeoNamesErrorKind, RadrootsSdkRecoveryAction, RadrootsSdkStorageConfig, - RadrootsSdkTimestamp, RadrootsSdkTradeErrorKind, RestoreRequest, ReticulumBehavior, - SDK_IDEMPOTENCY_KEY_MAX_LEN, SDK_TRANSPORT_TARGET_MAX_COUNT, SdkBackupState, + RadrootsSdkGeoNamesErrorKind, RadrootsSdkListingValidationErrorKind, RadrootsSdkRecoveryAction, + RadrootsSdkStorageConfig, RadrootsSdkTimestamp, RadrootsSdkTradeErrorKind, RestoreRequest, + ReticulumBehavior, SDK_IDEMPOTENCY_KEY_MAX_LEN, SDK_TRANSPORT_TARGET_MAX_COUNT, SdkBackupState, SdkBackupVerification, SdkEventStoreStorageStatus, SdkIdempotencyKey, SdkOutboxStorageStatus, SdkPrivateStoreStorageStatus, SdkRestoreState, SdkSqliteStoreStatus, SdkSqliteWalCheckpointReceipt, SdkSqliteWalStatus, SdkStorageKind, SdkStudioStoreStorageStatus, @@ -539,6 +539,16 @@ fn sdk_error_contract_methods_cover_all_variants() { vec![RadrootsSdkRecoveryAction::FixRequest], ), ( + RadrootsSdkError::ListingValidation { + kind: RadrootsSdkListingValidationErrorKind::MissingInventory, + message: "missing listing inventory".to_owned(), + }, + "listing_validation", + RadrootsSdkErrorClass::Request, + false, + vec![RadrootsSdkRecoveryAction::FixRequest], + ), + ( RadrootsSdkError::ListingMutation { message: "mutation".to_owned(), }, @@ -753,7 +763,7 @@ fn storage_backup_and_integrity_contract_dtos_serialize() { event_store: SdkEventStoreStorageStatus { store: store.clone(), total_events: 2, - projection_eligible_events: 1, + valid_stream_events: 1, transport_observations: 1, last_event_seq: Some(2), last_event_updated_at_ms: Some(1_700_000_000_000), @@ -797,7 +807,7 @@ fn storage_backup_and_integrity_contract_dtos_serialize() { "integrity_result": "ok" }, "total_events": 2, - "projection_eligible_events": 1, + "valid_stream_events": 1, "transport_observations": 1, "last_event_seq": 2, "last_event_updated_at_ms": 1700000000000i64 @@ -856,6 +866,33 @@ fn storage_backup_and_integrity_contract_dtos_serialize() { } }) ); + let mut legacy_event_store_json = serde_json::to_value(SdkEventStoreStorageStatus { + store: store.clone(), + total_events: 2, + valid_stream_events: 1, + transport_observations: 1, + last_event_seq: Some(2), + last_event_updated_at_ms: Some(1_700_000_000_000), + }) + .expect("event store status"); + let legacy_event_store_object = legacy_event_store_json + .as_object_mut() + .expect("event store status object"); + let valid_stream_events = legacy_event_store_object + .remove("valid_stream_events") + .expect("valid stream events"); + legacy_event_store_object.insert("projection_eligible_events".to_owned(), valid_stream_events); + let legacy_event_store: SdkEventStoreStorageStatus = + serde_json::from_value(legacy_event_store_json).expect("legacy event store status"); + assert_eq!(legacy_event_store.valid_stream_events, 1); + let current_event_store_json = + serde_json::to_value(legacy_event_store).expect("current event store status"); + assert_eq!(current_event_store_json["valid_stream_events"], 1); + assert!( + current_event_store_json + .get("projection_eligible_events") + .is_none() + ); assert_eq!( serde_json::to_value(StorageCheckpointRequest::new()).expect("checkpoint request"), serde_json::json!({}) diff --git a/crates/sdk/tests/source_boundary.rs b/crates/sdk/tests/source_boundary.rs @@ -12,12 +12,24 @@ fn read_source(path: &Path) -> String { } fn active_source_files() -> Vec<PathBuf> { - let src = manifest_dir().join("src"); - fs::read_dir(src) - .expect("src entries") - .map(|entry| entry.expect("src entry").path()) - .filter(|path| path.extension().is_some_and(|extension| extension == "rs")) - .collect() + fn collect_rust_sources(directory: &Path, files: &mut Vec<PathBuf>) { + let mut entries = fs::read_dir(directory) + .unwrap_or_else(|error| panic!("read {}: {error}", directory.display())) + .map(|entry| entry.expect("source entry").path()) + .collect::<Vec<_>>(); + entries.sort(); + for path in entries { + if path.is_dir() { + collect_rust_sources(&path, files); + } else if path.extension().is_some_and(|extension| extension == "rs") { + files.push(path); + } + } + } + + let mut files = Vec::new(); + collect_rust_sources(&manifest_dir().join("src"), &mut files); + files } #[test] @@ -72,6 +84,25 @@ fn active_sources_do_not_import_retired_trade_modules() { } #[test] +fn active_sources_do_not_import_retired_listing_contracts() { + for path in active_source_files() { + let source = read_source(&path); + for forbidden in [ + "radroots_event::listing", + "radroots_trade::listing", + "RadrootsListingAddress", + "KIND_LISTING", + ] { + assert!( + !source.contains(forbidden), + "{} must not import retired listing contract `{forbidden}`", + path.display() + ); + } + } +} + +#[test] fn active_sources_do_not_describe_compatibility_paths() { for path in active_source_files() { let source = read_source(&path).to_lowercase(); diff --git a/crates/sdk/tests/support/fixture_signer.rs b/crates/sdk/tests/support/fixture_signer.rs @@ -1,15 +1,48 @@ use radroots_authority::{RadrootsEventSigner, RadrootsSignerError, RadrootsSignerIdentity}; -use radroots_event::draft::{RadrootsEventDraft, RadrootsSignedEvent, RadrootsSignedEventParts}; +use radroots_event::draft::{RadrootsEventDraft, RadrootsSignedEvent}; +use radroots_nostr::prelude::{RadrootsNostrKeys, radroots_nostr_sign_frozen_draft}; +use std::sync::LazyLock; + +struct FixtureKeyMaterial { + keys: RadrootsNostrKeys, + pubkey: String, +} + +impl FixtureKeyMaterial { + fn generate() -> Self { + let keys = RadrootsNostrKeys::generate(); + let pubkey = keys.public_key().to_hex(); + Self { keys, pubkey } + } +} + +static FIXTURE_ALICE: LazyLock<FixtureKeyMaterial> = LazyLock::new(FixtureKeyMaterial::generate); +static FIXTURE_BOB: LazyLock<FixtureKeyMaterial> = LazyLock::new(FixtureKeyMaterial::generate); + +pub(crate) fn fixture_alice_pubkey() -> &'static str { + FIXTURE_ALICE.pubkey.as_str() +} + +pub(crate) fn fixture_bob_pubkey() -> &'static str { + FIXTURE_BOB.pubkey.as_str() +} #[derive(Clone)] pub struct FixtureSigner { identity: RadrootsSignerIdentity, + keys: RadrootsNostrKeys, } impl FixtureSigner { pub fn new(pubkey: &str) -> Self { + let material = match pubkey { + pubkey if pubkey == fixture_alice_pubkey() => &*FIXTURE_ALICE, + pubkey if pubkey == fixture_bob_pubkey() => &*FIXTURE_BOB, + _ => panic!("unsupported fixture signer public key"), + }; Self { identity: RadrootsSignerIdentity::new(pubkey).expect("identity"), + keys: material.keys.clone(), } } } @@ -28,29 +61,10 @@ impl RadrootsEventSigner for FixtureSigner { message: "wrong fixture signer".to_owned(), }); } - let sig = "f".repeat(128); - let raw_json = serde_json::json!({ - "id": draft.expected_event_id_str(), - "pubkey": self.pubkey().as_str(), - "created_at": draft.created_at_u64(), - "kind": draft.kind_u32(), - "tags": draft.tags_as_vec(), - "content": draft.content(), - "sig": sig, - }) - .to_string(); - RadrootsSignedEvent::new(RadrootsSignedEventParts { - id: draft.expected_event_id_str().to_owned(), - pubkey: self.pubkey().as_str().to_owned(), - created_at: draft.created_at_u64(), - kind: draft.kind_u32(), - tags: draft.tags_as_vec(), - content: draft.content().to_owned(), - sig, - raw_json, - }) - .map_err(|error| RadrootsSignerError::SigningFailed { - message: error.to_string(), + radroots_nostr_sign_frozen_draft(&self.keys, draft).map_err(|error| { + RadrootsSignerError::SigningFailed { + message: error.to_string(), + } }) } } diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs @@ -1,19 +1,21 @@ #![cfg(feature = "runtime")] use futures::future::BoxFuture; -use radroots_authority::{ - RadrootsActorContext, RadrootsEventSigner, RadrootsSignerError, RadrootsSignerIdentity, -}; +use radroots_authority::{RadrootsActorContext, RadrootsEventSigner}; use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, }; use radroots_event::{ contract::RadrootsActorRole, - draft::{RadrootsEventDraft, RadrootsSignedEvent, RadrootsSignedEventParts}, farm::RadrootsFarmRef, ids::{RadrootsDTag, RadrootsEventId, RadrootsInventoryBinId}, - listing::{RadrootsListing, RadrootsListingBin, RadrootsListingProduct}, + operational_listing::{ + RadrootsOperationalListing, RadrootsOperationalListingAvailability, + RadrootsOperationalListingBin, RadrootsOperationalListingDeliveryMethod, + RadrootsOperationalListingProduct, RadrootsOperationalListingPublicLocation, + RadrootsOperationalListingStatus, + }, }; use radroots_event_store::{RadrootsEventStore, RadrootsTransportObservationType}; use radroots_outbox::{ @@ -52,7 +54,11 @@ use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; use std::time::Duration; -const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +#[path = "support/fixture_signer.rs"] +mod fixture_signer; + +use fixture_signer::{FixtureSigner, fixture_alice_pubkey}; + const FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; const LISTING_A_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAQ"; const LISTING_B_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAg"; @@ -68,9 +74,8 @@ const PRIVATE_LAN_WS_RELAY: &str = "ws://192.168.1.10:8080"; #[cfg(feature = "radrootsd-execution")] const RADROOTSD_EXECUTION_TEST_RELAY: &str = "wss://daemon-resolved.example.com"; -#[derive(Clone)] -struct FixtureSigner { - identity: RadrootsSignerIdentity, +fn seller_pubkey() -> &'static str { + fixture_alice_pubkey() } struct TransportFailurePublishAdapter; @@ -382,68 +387,19 @@ impl RadrootsRelayPublishAdapter for RecordingPublishAdapter { } } -impl FixtureSigner { - fn new(pubkey: &str) -> Self { - Self { - identity: RadrootsSignerIdentity::new(pubkey).expect("identity"), - } - } -} - -impl RadrootsEventSigner for FixtureSigner { - fn pubkey(&self) -> &radroots_event::ids::RadrootsPublicKey { - self.identity.pubkey() - } - - fn sign_frozen_draft( - &self, - draft: &RadrootsEventDraft, - ) -> Result<RadrootsSignedEvent, RadrootsSignerError> { - if self.pubkey().as_str() != draft.expected_pubkey_str() { - return Err(RadrootsSignerError::SigningFailed { - message: "wrong fixture signer".to_owned(), - }); - } - let sig = "f".repeat(128); - let raw_json = serde_json::json!({ - "id": draft.expected_event_id_str(), - "pubkey": self.pubkey().as_str(), - "created_at": draft.created_at_u64(), - "kind": draft.kind_u32(), - "tags": draft.tags_as_vec(), - "content": draft.content(), - "sig": sig, - }) - .to_string(); - RadrootsSignedEvent::new(RadrootsSignedEventParts { - id: draft.expected_event_id_str().to_owned(), - pubkey: self.pubkey().as_str().to_owned(), - created_at: draft.created_at_u64(), - kind: draft.kind_u32(), - tags: draft.tags_as_vec(), - content: draft.content().to_owned(), - sig, - raw_json, - }) - .map_err(|error| RadrootsSignerError::SigningFailed { - message: error.to_string(), - }) - } -} - fn actor() -> RadrootsActorContext { - RadrootsActorContext::test(SELLER, [RadrootsActorRole::Seller]).expect("actor") + RadrootsActorContext::test(seller_pubkey(), [RadrootsActorRole::Seller]).expect("actor") } -fn listing(d_tag: &str, title: &str) -> RadrootsListing { - RadrootsListing { +fn listing(d_tag: &str, title: &str) -> RadrootsOperationalListing { + RadrootsOperationalListing { d_tag: RadrootsDTag::parse(d_tag).expect("d tag"), published_at: None, farm: RadrootsFarmRef { - pubkey: SELLER.to_owned(), + pubkey: seller_pubkey().to_owned(), d_tag: FARM_D_TAG.to_owned(), }, - product: RadrootsListingProduct { + product: RadrootsOperationalListingProduct { key: "coffee".to_owned(), title: title.to_owned(), category: "coffee".to_owned(), @@ -455,7 +411,7 @@ fn listing(d_tag: &str, title: &str) -> RadrootsListing { year: None, }, primary_bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"), - bins: vec![RadrootsListingBin { + bins: vec![RadrootsOperationalListingBin { bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"), quantity: RadrootsCoreQuantity::new( RadrootsCoreDecimal::from(1000u32), @@ -480,10 +436,18 @@ fn listing(d_tag: &str, title: &str) -> RadrootsListing { resource_area: None, plot: None, discounts: None, - inventory_available: None, - availability: None, - delivery_method: None, - location: None, + inventory_available: Some(RadrootsCoreDecimal::from(5u32)), + availability: Some(RadrootsOperationalListingAvailability::Status { + status: RadrootsOperationalListingStatus::Active, + }), + delivery_method: Some(RadrootsOperationalListingDeliveryMethod::Pickup), + location: Some(RadrootsOperationalListingPublicLocation { + primary: "Victoria".to_owned(), + city: Some("Victoria".to_owned()), + region: Some("British Columbia".to_owned()), + country: Some("CA".to_owned()), + geohash: "c287g".to_owned(), + }), images: None, } } @@ -559,9 +523,9 @@ async fn enqueue_scoped_duplicate_listing(sdk: &RadrootsClient, d_tag: &str, tit listing(d_tag, title), )) .expect("prepared listing"); - let signer = FixtureSigner::new(SELLER); + let signer = FixtureSigner::new(seller_pubkey()); let signed_event = signer - .sign_frozen_draft(&plan.frozen_draft) + .sign_frozen_draft(plan.frozen_draft()) .expect("signed listing"); let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path) .await @@ -569,7 +533,7 @@ async fn enqueue_scoped_duplicate_listing(sdk: &RadrootsClient, d_tag: &str, tit outbox .enqueue_signed_operation(RadrootsOutboxSignedOperationInput::new( LISTING_PUBLISH_OPERATION_KIND, - plan.frozen_draft, + plan.frozen_draft().clone(), signed_event, scoped_duplicate_relay_delivery_plan(RELAY_A), true, @@ -706,7 +670,7 @@ async fn enqueue_listing_with_policy( .expect("relay targets") .try_with_idempotency_key(sync_fixture_idempotency_key(d_tag, title, relays)) .expect("idempotency key"), - &FixtureSigner::new(SELLER), + &FixtureSigner::new(seller_pubkey()), ) .await .expect("enqueue") @@ -726,7 +690,7 @@ async fn sync_status_empty_store_reports_canonical_sources_and_transport_targets assert_eq!(receipt.source, SyncStatusSource::SdkCanonicalStores); assert_eq!(receipt.observed_at_ms, 1_700_000_000_000); assert_eq!(receipt.event_store.total_events, 0); - assert_eq!(receipt.event_store.projection_eligible_events, 0); + assert_eq!(receipt.event_store.valid_stream_events, 0); assert_eq!(receipt.event_store.transport_observations, 0); assert_eq!(receipt.event_store.last_event_seq, None); assert_eq!(receipt.outbox.total_events, 0); @@ -765,7 +729,7 @@ async fn sync_status_empty_store_reports_canonical_sources_and_transport_targets "observed_at_ms": 1700000000000i64, "event_store": { "total_events": 0, - "projection_eligible_events": 0, + "valid_stream_events": 0, "transport_observations": 0, "last_event_seq": null, "last_event_updated_at_ms": null @@ -1765,7 +1729,7 @@ async fn product_push_outbox_uses_radrootsd_execution_transport_with_daemon_reso ) .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000250") .expect("idempotency key"), - &FixtureSigner::new(SELLER), + &FixtureSigner::new(seller_pubkey()), ) .await .expect("enqueue"); @@ -1873,7 +1837,7 @@ async fn product_push_outbox_radrootsd_execution_recovers_expired_publishing_cla ) .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000251") .expect("idempotency key"), - &FixtureSigner::new(SELLER), + &FixtureSigner::new(seller_pubkey()), ) .await .expect("enqueue"); @@ -1978,7 +1942,7 @@ async fn product_push_outbox_radrootsd_execution_idempotency_is_attempt_scoped() ) .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000252") .expect("idempotency key"), - &FixtureSigner::new(SELLER), + &FixtureSigner::new(seller_pubkey()), ) .await .expect("enqueue"); @@ -2087,7 +2051,7 @@ async fn product_push_outbox_radrootsd_execution_error_and_terminal_paths_update ) .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000253") .expect("idempotency key"), - &FixtureSigner::new(SELLER), + &FixtureSigner::new(seller_pubkey()), ) .await .expect("enqueue retryable"); @@ -2152,7 +2116,7 @@ async fn product_push_outbox_radrootsd_execution_error_and_terminal_paths_update ) .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000254") .expect("idempotency key"), - &FixtureSigner::new(SELLER), + &FixtureSigner::new(seller_pubkey()), ) .await .expect("enqueue terminal"); @@ -2335,7 +2299,7 @@ async fn sync_runtime_product_push_outbox_reticulum_reports_zero_attempts_with_r ) .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000255") .expect("idempotency key"), - &FixtureSigner::new(SELLER), + &FixtureSigner::new(seller_pubkey()), ) .await .expect("enqueue"); @@ -2769,7 +2733,7 @@ async fn push_outbox_with_transport_scopes_duplicate_endpoint_sibling_plans() { .expect("first targets") .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000022e") .expect("first idempotency"), - &FixtureSigner::new(SELLER), + &FixtureSigner::new(seller_pubkey()), ) .await .expect("first enqueue"); @@ -3309,7 +3273,7 @@ async fn push_outbox_does_not_claim_unsigned_outbox_work() { let unsigned = outbox .enqueue_operation(RadrootsOutboxOperationInput::new( LISTING_PUBLISH_OPERATION_KIND, - prepared.frozen_draft, + prepared.frozen_draft().clone(), delivery_plan_for_relays([RELAY_A], NostrRelayUrlPolicy::Public), 1_700_000_000_000, )) diff --git a/crates/sdk/tests/unit/error_tests.rs b/crates/sdk/tests/unit/error_tests.rs @@ -1,6 +1,6 @@ use super::{ - RadrootsSdkError, RadrootsSdkGeoNamesErrorKind, RadrootsSdkTradeErrorKind, - radroots_sdk_error_catalog, redacted_relay_url, + RadrootsSdkError, RadrootsSdkGeoNamesErrorKind, RadrootsSdkListingValidationErrorKind, + RadrootsSdkTradeErrorKind, radroots_sdk_error_catalog, redacted_relay_url, }; use crate::privacy::{PrivacyPreflightStatus, ProductSensitivityField}; use crate::transport::ReticulumBehavior; @@ -33,7 +33,7 @@ fn authority_error_conversion_redacts_pubkey_mismatches_and_falls_back() { #[test] fn listing_and_store_errors_convert_to_sdk_error_classes() { let draft = RadrootsSdkError::from( - radroots_trade::listing::RadrootsListingEditError::ActorRoleUnsatisfied { + radroots_trade::operational_listing::RadrootsOperationalListingEditError::ActorRoleUnsatisfied { required_role: RadrootsActorRole::Seller, }, ); @@ -44,7 +44,7 @@ fn listing_and_store_errors_convert_to_sdk_error_classes() { )); let draft_fallback = RadrootsSdkError::from( - radroots_trade::listing::RadrootsListingEditError::InvalidFarmPubkey( + radroots_trade::operational_listing::RadrootsOperationalListingEditError::InvalidFarmPubkey( radroots_event::ids::RadrootsIdParseError::InvalidCharacter, ), ); @@ -54,8 +54,28 @@ fn listing_and_store_errors_convert_to_sdk_error_classes() { if message.contains("invalid listing edit farm pubkey") )); + let invalid_model = RadrootsSdkError::from( + radroots_trade::operational_listing::RadrootsOperationalListingEditError::InvalidModel( + radroots_event::trade_validation::RadrootsOperationalListingValidationError::MissingInventory, + ), + ); + assert!(matches!( + invalid_model, + RadrootsSdkError::ListingValidation { + kind: RadrootsSdkListingValidationErrorKind::MissingInventory, + ref message, + } if message == "missing listing inventory" + )); + let detail = invalid_model.detail_json(); + assert_eq!(detail["code"], "listing_validation"); + assert_eq!( + detail["detail"]["kind"], + serde_json::json!("missing_inventory") + ); + assert_eq!(detail["detail"]["message"], "missing listing inventory"); + let mutation = RadrootsSdkError::from( - radroots_trade::listing::RadrootsListingMutationError::UnsupportedMutation, + radroots_trade::operational_listing::RadrootsOperationalListingMutationError::UnsupportedMutation, ); assert!(matches!( mutation, @@ -343,6 +363,10 @@ fn sdk_error_contract_methods_cover_representative_classes_and_details() { RadrootsSdkError::ListingEdit { message: "edit".to_owned(), }, + RadrootsSdkError::ListingValidation { + kind: RadrootsSdkListingValidationErrorKind::MissingInventory, + message: "missing listing inventory".to_owned(), + }, RadrootsSdkError::ListingMutation { message: "mutation".to_owned(), }, @@ -414,6 +438,7 @@ fn sdk_error_catalog_exposes_stable_codes_and_metadata() { assert!(codes.contains("product_sync_unsupported")); assert!(codes.contains("reticulum_transport_deferred")); assert!(codes.contains("unsupported_profile_schema")); + assert!(codes.contains("listing_validation")); assert!(codes.contains("geonames_lookup")); for trade_kind in [ diff --git a/crates/sdk/tests/unit/farms_runtime_tests.rs b/crates/sdk/tests/unit/farms_runtime_tests.rs @@ -2,18 +2,21 @@ use super::*; use crate::{RadrootsSdkLocalKeySigner, RadrootsSdkSignerProvider}; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; -use crate::fixture_signer::FixtureSigner; +use crate::fixture_signer::{FixtureSigner, fixture_alice_pubkey, fixture_bob_pubkey}; use crate::serializer_failure::assert_struct_serialize_error_paths; -const FARMER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const FARM_A_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; const FARM_B_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAQ"; const FARM_C_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAg"; const RELAY_A: &str = "wss://relay-a.radroots.test"; const RELAY_B: &str = "wss://relay-b.radroots.test"; +fn farmer_pubkey() -> &'static str { + fixture_alice_pubkey() +} + fn farmer_actor() -> RadrootsActorContext { - RadrootsActorContext::test(FARMER, [RadrootsActorRole::Farmer]).expect("actor") + RadrootsActorContext::test(farmer_pubkey(), [RadrootsActorRole::Farmer]).expect("actor") } fn farm(d_tag: &str, name: &str) -> RadrootsFarm { @@ -154,7 +157,7 @@ fn farm_runtime_request_builders_and_serializers_cover_success_paths() { .with_created_at(created_at); assert_struct_serialize_error_paths(&prepare, 3); let prepare_json = serde_json::to_value(&prepare).expect("prepare json"); - assert_eq!(prepare_json["actor"]["pubkey"], FARMER); + assert_eq!(prepare_json["actor"]["pubkey"], farmer_pubkey()); assert_eq!(prepare_json["created_at"], 1_700_000_321); let enqueue = FarmEnqueuePublishRequest::new( @@ -275,7 +278,7 @@ fn farm_runtime_request_builders_and_serializers_cover_success_paths() { let private_receipt = FarmPrivateLocationReceipt { farm_addr: farm_addr(&farmer_actor(), FARM_B_D_TAG).expect("farm addr"), - farm_pubkey: FARMER.to_owned(), + farm_pubkey: farmer_pubkey().to_owned(), farm_d_tag: FARM_B_D_TAG.to_owned(), label: Some("query gate".to_owned()), exact_location: SdkExactLocation::new(12.25, -34.5), @@ -320,7 +323,7 @@ fn farm_runtime_request_builders_and_serializers_cover_success_paths() { assert_struct_serialize_error_paths(&candidate, 7); let lookup = FarmPrivateLocationLookupReceipt { farm_addr: farm_addr(&farmer_actor(), FARM_B_D_TAG).expect("farm addr"), - farm_pubkey: FARMER.to_owned(), + farm_pubkey: farmer_pubkey().to_owned(), farm_d_tag: FARM_B_D_TAG.to_owned(), input: FarmPrivateLocationInput::query("Fixture City, FX"), candidates: vec![candidate], @@ -479,7 +482,7 @@ async fn farm_client_prepare_resolves_default_and_explicit_created_at() { )) .expect("default plan"); assert_eq!( - default_plan.created_at, + default_plan.created_at(), RadrootsSdkTimestamp::from_unix_seconds(1_700_000_400) ); @@ -494,12 +497,162 @@ async fn farm_client_prepare_resolves_default_and_explicit_created_at() { ) .expect("explicit plan"); assert_eq!( - explicit_plan.created_at, + explicit_plan.created_at(), RadrootsSdkTimestamp::from_unix_seconds(1_700_000_401) ); } #[tokio::test] +async fn farm_prepared_plan_rejects_forged_state_before_signing_or_mutation() { + let sdk = crate::RadrootsClient::builder() + .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_450)) + .build() + .await + .expect("sdk"); + let actor = farmer_actor(); + let plan = sdk + .farms() + .prepare_publish(FarmPreparePublishRequest::new( + actor.clone(), + farm(FARM_A_D_TAG, "Sealed Plan Farm"), + )) + .expect("plan"); + assert_eq!( + serde_json::to_value(&plan).expect("plan json"), + serde_json::json!({ + "farm_addr": plan.farm_addr(), + "expected_event_id": plan.expected_event_id(), + "frozen_draft": plan.frozen_draft(), + "created_at": plan.created_at(), + }) + ); + // Frozen drafts enforce registry contract-kind consistency, so a valid foreign pair is the + // safe representable substitution for both identity fields. + let foreign_draft = RadrootsEventDraft::new( + "radroots.social.geochat.v1", + radroots_event::kinds::KIND_GEOCHAT, + plan.created_at().unix_seconds(), + Vec::new(), + "Foreign draft", + farmer_pubkey(), + ) + .expect("valid foreign draft"); + let mut forged_contract_kind = plan.clone(); + forged_contract_kind.frozen_draft = foreign_draft; + assert!(matches!( + validate_farm_publish_plan(&forged_contract_kind), + Err(RadrootsSdkError::InvalidRequest { ref message }) + if message.contains("contract or kind") + )); + + let mut forged_event_id = plan.clone(); + forged_event_id.expected_event_id = + RadrootsEventId::parse(fixture_bob_pubkey()).expect("alternate event ID"); + assert!(matches!( + validate_farm_publish_plan(&forged_event_id), + Err(RadrootsSdkError::InvalidRequest { ref message }) + if message.contains("expected event ID") + )); + + let mut forged_address = plan.clone(); + forged_address.farm_addr = RadrootsAddressableCoordinate::parse(format!( + "{KIND_FARM}:{}:{FARM_B_D_TAG}", + farmer_pubkey() + )) + .expect("alternate farm address"); + assert!(matches!( + validate_farm_publish_plan(&forged_address), + Err(RadrootsSdkError::InvalidRequest { ref message }) + if message.contains("farm address") + )); + + let mut forged_created_at = plan.clone(); + forged_created_at.created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_451); + assert!(matches!( + validate_farm_publish_plan(&forged_created_at), + Err(RadrootsSdkError::InvalidRequest { ref message }) + if message.contains("created-at timestamp") + )); + + let alternate_plan = sdk + .farms() + .prepare_publish(FarmPreparePublishRequest::new( + actor.clone(), + farm(FARM_B_D_TAG, "Alternate Plan Farm"), + )) + .expect("alternate plan"); + let mut forged_draft = plan; + forged_draft.frozen_draft = alternate_plan.frozen_draft; + assert!(matches!( + validate_farm_publish_plan(&forged_draft), + Err(RadrootsSdkError::InvalidRequest { ref message }) + if message.contains("expected event ID") + )); + + let error = sdk + .farms() + .enqueue_prepared_publish_with_explicit_signer( + &actor, + forged_contract_kind, + TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) + .expect("transport targets"), + Some( + SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000445") + .expect("idempotency"), + ), + &FixtureSigner::new(fixture_bob_pubkey()), + ) + .await + .expect_err("forged plan"); + assert!(matches!( + error, + RadrootsSdkError::InvalidRequest { ref message } + if message.contains("contract or kind") + )); + let error = sdk + .farms() + .enqueue_prepared_publish( + &actor, + forged_address, + TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) + .expect("transport targets"), + Some( + SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000446") + .expect("idempotency"), + ), + ) + .await + .expect_err("forged configured-signer plan"); + assert!(matches!( + error, + RadrootsSdkError::InvalidRequest { ref message } + if message.contains("farm address") + )); + assert_eq!( + sdk._event_store + .status_summary() + .await + .expect("event store status") + .total_events, + 0 + ); + assert_eq!( + sdk._outbox + .status_summary(0) + .await + .expect("outbox status") + .total_events, + 0 + ); + let journal_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM sdk_runtime_operation_journal") + .fetch_one(sdk._event_store.pool()) + .await + .expect("journal count"); + assert_eq!(journal_count, 0); +} + +#[tokio::test] async fn farm_client_prepare_reports_clock_errors() { let sdk = crate::RadrootsClient::builder() .clock(crate::RadrootsSdkClock::BeforeUnixEpoch) @@ -532,7 +685,7 @@ async fn farm_enqueue_publish_reports_prepare_errors_before_signing() { TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) .expect("transport targets"), ), - &FixtureSigner::new(FARMER), + &FixtureSigner::new(farmer_pubkey()), ) .await .expect_err("prepare error"); @@ -546,7 +699,7 @@ async fn farm_client_enqueue_methods_cover_source_attached_workflow_paths() { .build() .await .expect("sdk"); - let signer = FixtureSigner::new(FARMER); + let signer = FixtureSigner::new(farmer_pubkey()); let actor = farmer_actor(); let receipt = sdk .farms() @@ -597,13 +750,14 @@ async fn farm_configured_local_signer_enqueues_publish_without_explicit_signer() let sdk = crate::RadrootsClient::builder() .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500)) .signer_provider(RadrootsSdkSignerProvider::LocalKey( - RadrootsSdkLocalKeySigner::from_event_signer(FixtureSigner::new(FARMER)) + RadrootsSdkLocalKeySigner::from_event_signer(FixtureSigner::new(farmer_pubkey())) .expect("signer"), )) .build() .await .expect("sdk"); - let actor = RadrootsActorContext::test(FARMER, [RadrootsActorRole::Farmer]).expect("actor"); + let actor = + RadrootsActorContext::test(farmer_pubkey(), [RadrootsActorRole::Farmer]).expect("actor"); let receipt = sdk .farms() @@ -629,13 +783,14 @@ async fn farm_configured_enqueue_reports_prepare_and_signer_errors() { let configured_sdk = crate::RadrootsClient::builder() .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500)) .signer_provider(RadrootsSdkSignerProvider::LocalKey( - RadrootsSdkLocalKeySigner::from_event_signer(FixtureSigner::new(FARMER)) + RadrootsSdkLocalKeySigner::from_event_signer(FixtureSigner::new(farmer_pubkey())) .expect("signer"), )) .build() .await .expect("configured sdk"); - let actor = RadrootsActorContext::test(FARMER, [RadrootsActorRole::Farmer]).expect("actor"); + let actor = + RadrootsActorContext::test(farmer_pubkey(), [RadrootsActorRole::Farmer]).expect("actor"); assert!(matches!( configured_sdk @@ -767,8 +922,8 @@ async fn farm_private_location_default_client_and_lookup_report_store_edges() { farm_addr(&actor, FARM_B_D_TAG).expect("farm b addr") ); - let non_farmer_actor = - RadrootsActorContext::test(FARMER, [RadrootsActorRole::Buyer]).expect("buyer actor"); + let non_farmer_actor = RadrootsActorContext::test(farmer_pubkey(), [RadrootsActorRole::Buyer]) + .expect("buyer actor"); assert!(matches!( sdk.farms() .clear_private_location(FarmPrivateLocationClearRequest::new( diff --git a/crates/sdk/tests/unit/listings_runtime_tests.rs b/crates/sdk/tests/unit/listings_runtime_tests.rs @@ -8,14 +8,17 @@ use radroots_event::{ contract::RadrootsActorRole, farm::RadrootsFarmRef, ids::{RadrootsDTag, RadrootsInventoryBinId}, - listing::{RadrootsListingBin, RadrootsListingProduct}, + operational_listing::{ + RadrootsOperationalListingAvailability, RadrootsOperationalListingBin, + RadrootsOperationalListingDeliveryMethod, RadrootsOperationalListingProduct, + RadrootsOperationalListingPublicLocation, RadrootsOperationalListingStatus, + }, resource_area::RadrootsResourceAreaRef, }; -use crate::fixture_signer::FixtureSigner; +use crate::fixture_signer::{FixtureSigner, fixture_alice_pubkey, fixture_bob_pubkey}; use crate::serializer_failure::assert_struct_serialize_error_paths; -const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; const LISTING_A_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAQ"; const LISTING_B_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAg"; @@ -23,23 +26,27 @@ const LISTING_C_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAw"; const RELAY_A: &str = "wss://relay-a.radroots.test"; const RELAY_B: &str = "wss://relay-b.radroots.test"; +fn seller_pubkey() -> &'static str { + fixture_alice_pubkey() +} + fn actor() -> RadrootsActorContext { - RadrootsActorContext::test(SELLER, [RadrootsActorRole::Seller]).expect("actor") + RadrootsActorContext::test(seller_pubkey(), [RadrootsActorRole::Seller]).expect("actor") } -fn listing(d_tag: &str, title: &str) -> RadrootsListing { - listing_for_seller(SELLER, d_tag, title) +fn listing(d_tag: &str, title: &str) -> RadrootsOperationalListing { + listing_for_seller(seller_pubkey(), d_tag, title) } -fn listing_for_seller(seller: &str, d_tag: &str, title: &str) -> RadrootsListing { - RadrootsListing { +fn listing_for_seller(seller: &str, d_tag: &str, title: &str) -> RadrootsOperationalListing { + RadrootsOperationalListing { d_tag: RadrootsDTag::parse(d_tag).expect("d tag"), published_at: None, farm: RadrootsFarmRef { pubkey: seller.to_owned(), d_tag: FARM_D_TAG.to_owned(), }, - product: RadrootsListingProduct { + product: RadrootsOperationalListingProduct { key: "lettuce".to_owned(), title: title.to_owned(), category: "greens".to_owned(), @@ -51,7 +58,7 @@ fn listing_for_seller(seller: &str, d_tag: &str, title: &str) -> RadrootsListing year: None, }, primary_bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"), - bins: vec![RadrootsListingBin { + bins: vec![RadrootsOperationalListingBin { bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"), quantity: RadrootsCoreQuantity::new( RadrootsCoreDecimal::from(12u32), @@ -76,10 +83,18 @@ fn listing_for_seller(seller: &str, d_tag: &str, title: &str) -> RadrootsListing resource_area: None, plot: None, discounts: None, - inventory_available: None, - availability: None, - delivery_method: None, - location: None, + inventory_available: Some(RadrootsCoreDecimal::from(12u32)), + availability: Some(RadrootsOperationalListingAvailability::Status { + status: RadrootsOperationalListingStatus::Active, + }), + delivery_method: Some(RadrootsOperationalListingDeliveryMethod::Pickup), + location: Some(RadrootsOperationalListingPublicLocation { + primary: "Victoria".to_owned(), + city: Some("Victoria".to_owned()), + region: Some("British Columbia".to_owned()), + country: Some("CA".to_owned()), + geohash: "c287g".to_owned(), + }), images: None, } } @@ -92,12 +107,12 @@ fn listing_runtime_request_builders_and_serializers_cover_success_paths() { .with_created_at(created_at); assert_struct_serialize_error_paths(&prepare, 3); let prepare_json = serde_json::to_value(&prepare).expect("prepare json"); - assert_eq!(prepare_json["actor"]["pubkey"], SELLER); + assert_eq!(prepare_json["actor"]["pubkey"], seller_pubkey()); assert_eq!(prepare_json["created_at"], 1_700_000_321); let enqueue = ListingEnqueuePublishRequest::from_document( actor(), - RadrootsListingEditDocumentV1::new(listing(LISTING_B_D_TAG, "Queued Greens")), + RadrootsOperationalListingEditDocumentV1::new(listing(LISTING_B_D_TAG, "Queued Greens")), TargetPolicy::default_profile(), ) .try_with_nostr_targets([RELAY_A, RELAY_B], NostrRelayUrlPolicy::Public) @@ -141,7 +156,7 @@ fn listing_request_builders_reject_invalid_options_and_timestamp_bounds() { let timestamp_error = listing_publish_plan( &actor(), - RadrootsListingEditDocumentV1::new(listing(LISTING_B_D_TAG, "Future Greens")), + RadrootsOperationalListingEditDocumentV1::new(listing(LISTING_B_D_TAG, "Future Greens")), RadrootsSdkTimestamp::from_unix_seconds(u64::MAX), ) .expect_err("timestamp error"); @@ -153,12 +168,12 @@ fn listing_request_builders_reject_invalid_options_and_timestamp_bounds() { let mut invalid_resource_area_listing = listing(LISTING_C_D_TAG, "Invalid Resource Area Greens"); invalid_resource_area_listing.resource_area = Some(RadrootsResourceAreaRef { - pubkey: SELLER.to_owned(), + pubkey: seller_pubkey().to_owned(), d_tag: "bad d tag".to_owned(), }); let mutation_error = listing_publish_plan( &actor(), - RadrootsListingEditDocumentV1::new(invalid_resource_area_listing), + RadrootsOperationalListingEditDocumentV1::new(invalid_resource_area_listing), RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000), ) .expect_err("mutation error"); @@ -183,7 +198,7 @@ async fn listing_client_prepare_resolves_default_and_explicit_created_at() { )) .expect("default plan"); assert_eq!( - default_plan.created_at, + default_plan.created_at(), RadrootsSdkTimestamp::from_unix_seconds(1_700_000_400) ); @@ -198,12 +213,162 @@ async fn listing_client_prepare_resolves_default_and_explicit_created_at() { ) .expect("explicit plan"); assert_eq!( - explicit_plan.created_at, + explicit_plan.created_at(), RadrootsSdkTimestamp::from_unix_seconds(1_700_000_401) ); } #[tokio::test] +async fn listing_prepared_plan_rejects_forged_state_before_signing_or_mutation() { + let sdk = crate::RadrootsClient::builder() + .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_450)) + .build() + .await + .expect("sdk"); + let actor = actor(); + let plan = sdk + .listings() + .prepare_publish(ListingPreparePublishRequest::new( + actor.clone(), + listing(LISTING_A_D_TAG, "Sealed Plan Greens"), + )) + .expect("plan"); + assert_eq!( + serde_json::to_value(&plan).expect("plan json"), + serde_json::json!({ + "public_listing_addr": plan.public_listing_addr(), + "expected_event_id": plan.expected_event_id(), + "frozen_draft": plan.frozen_draft(), + "created_at": plan.created_at(), + }) + ); + // Frozen drafts enforce registry contract-kind consistency, so a valid foreign pair is the + // safe representable substitution for both identity fields. + let foreign_draft = RadrootsEventDraft::new( + "radroots.social.geochat.v1", + radroots_event::kinds::KIND_GEOCHAT, + plan.created_at().unix_seconds(), + Vec::new(), + "Foreign draft", + seller_pubkey(), + ) + .expect("valid foreign draft"); + let mut forged_contract_kind = plan.clone(); + forged_contract_kind.frozen_draft = foreign_draft; + assert!(matches!( + validate_listing_publish_plan(&forged_contract_kind), + Err(RadrootsSdkError::InvalidRequest { ref message }) + if message.contains("contract or kind") + )); + + let mut forged_event_id = plan.clone(); + forged_event_id.expected_event_id = + RadrootsEventId::parse(fixture_bob_pubkey()).expect("alternate event ID"); + assert!(matches!( + validate_listing_publish_plan(&forged_event_id), + Err(RadrootsSdkError::InvalidRequest { ref message }) + if message.contains("expected event ID") + )); + + let mut forged_address = plan.clone(); + forged_address.public_listing_addr = RadrootsClassifiedListingAddress::parse(format!( + "{KIND_CLASSIFIED_LISTING}:{}:{LISTING_B_D_TAG}", + seller_pubkey() + )) + .expect("alternate listing address"); + assert!(matches!( + validate_listing_publish_plan(&forged_address), + Err(RadrootsSdkError::InvalidRequest { ref message }) + if message.contains("listing address") + )); + + let mut forged_created_at = plan.clone(); + forged_created_at.created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_451); + assert!(matches!( + validate_listing_publish_plan(&forged_created_at), + Err(RadrootsSdkError::InvalidRequest { ref message }) + if message.contains("created-at timestamp") + )); + + let alternate_plan = sdk + .listings() + .prepare_publish(ListingPreparePublishRequest::new( + actor.clone(), + listing(LISTING_B_D_TAG, "Alternate Plan Greens"), + )) + .expect("alternate plan"); + let mut forged_draft = plan; + forged_draft.frozen_draft = alternate_plan.frozen_draft; + assert!(matches!( + validate_listing_publish_plan(&forged_draft), + Err(RadrootsSdkError::InvalidRequest { ref message }) + if message.contains("expected event ID") + )); + + let error = sdk + .listings() + .enqueue_prepared_publish_with_explicit_signer( + &actor, + forged_contract_kind, + TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) + .expect("transport targets"), + Some( + SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000245") + .expect("idempotency"), + ), + &FixtureSigner::new(fixture_bob_pubkey()), + ) + .await + .expect_err("forged plan"); + assert!(matches!( + error, + RadrootsSdkError::InvalidRequest { ref message } + if message.contains("contract or kind") + )); + let error = sdk + .listings() + .enqueue_prepared_publish( + &actor, + forged_address, + TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) + .expect("transport targets"), + Some( + SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000246") + .expect("idempotency"), + ), + ) + .await + .expect_err("forged configured-signer plan"); + assert!(matches!( + error, + RadrootsSdkError::InvalidRequest { ref message } + if message.contains("listing address") + )); + assert_eq!( + sdk._event_store + .status_summary() + .await + .expect("event store status") + .total_events, + 0 + ); + assert_eq!( + sdk._outbox + .status_summary(0) + .await + .expect("outbox status") + .total_events, + 0 + ); + let journal_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM sdk_runtime_operation_journal") + .fetch_one(sdk._event_store.pool()) + .await + .expect("journal count"); + assert_eq!(journal_count, 0); +} + +#[tokio::test] async fn listing_client_prepare_reports_clock_errors() { let sdk = crate::RadrootsClient::builder() .clock(crate::RadrootsSdkClock::BeforeUnixEpoch) @@ -237,7 +402,7 @@ async fn listing_enqueue_publish_reports_prepare_errors_before_signing() { .expect("transport targets"), ) .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(u64::MAX)), - &FixtureSigner::new(SELLER), + &FixtureSigner::new(seller_pubkey()), ) .await .expect_err("prepare error"); @@ -245,6 +410,53 @@ async fn listing_enqueue_publish_reports_prepare_errors_before_signing() { error, RadrootsSdkError::TimestampOutOfRange { .. } )); + + let mut invalid_listing = listing(LISTING_B_D_TAG, "Invalid Inventory Greens"); + invalid_listing.inventory_available = None; + let error = sdk + .listings() + .enqueue_publish_with_explicit_signer( + ListingEnqueuePublishRequest::new( + actor(), + invalid_listing, + TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) + .expect("transport targets"), + ) + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000239") + .expect("idempotency"), + &FixtureSigner::new(fixture_bob_pubkey()), + ) + .await + .expect_err("invalid listing model"); + assert!(matches!( + error, + RadrootsSdkError::ListingValidation { + kind: crate::RadrootsSdkListingValidationErrorKind::MissingInventory, + ref message, + } if message == "missing listing inventory" + )); + assert_eq!( + sdk._event_store + .status_summary() + .await + .expect("event store status") + .total_events, + 0 + ); + assert_eq!( + sdk._outbox + .status_summary(0) + .await + .expect("outbox status") + .total_events, + 0 + ); + let journal_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM sdk_runtime_operation_journal") + .fetch_one(sdk._event_store.pool()) + .await + .expect("journal count"); + assert_eq!(journal_count, 0); } #[tokio::test] @@ -254,7 +466,7 @@ async fn listing_client_enqueue_methods_cover_source_attached_workflow_paths() { .build() .await .expect("sdk"); - let signer = FixtureSigner::new(SELLER); + let signer = FixtureSigner::new(seller_pubkey()); let actor = actor(); let receipt = sdk .listings() @@ -278,7 +490,10 @@ async fn listing_client_enqueue_methods_cover_source_attached_workflow_paths() { .listings() .prepare_publish(ListingPreparePublishRequest::from_document( actor.clone(), - RadrootsListingEditDocumentV1::new(listing(LISTING_B_D_TAG, "Prepared Greens")), + RadrootsOperationalListingEditDocumentV1::new(listing( + LISTING_B_D_TAG, + "Prepared Greens", + )), )) .expect("prepared listing"); let prepared = sdk @@ -305,20 +520,21 @@ async fn listing_configured_local_signer_enqueues_publish_without_explicit_signe let sdk = crate::RadrootsClient::builder() .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500)) .signer_provider(RadrootsSdkSignerProvider::LocalKey( - RadrootsSdkLocalKeySigner::from_event_signer(FixtureSigner::new(SELLER)) + RadrootsSdkLocalKeySigner::from_event_signer(FixtureSigner::new(seller_pubkey())) .expect("signer"), )) .build() .await .expect("sdk"); - let actor = RadrootsActorContext::test(SELLER, [RadrootsActorRole::Seller]).expect("actor"); + let actor = + RadrootsActorContext::test(seller_pubkey(), [RadrootsActorRole::Seller]).expect("actor"); let receipt = sdk .listings() .enqueue_publish( ListingEnqueuePublishRequest::new( actor, - listing_for_seller(SELLER, LISTING_C_D_TAG, "Configured Greens"), + listing_for_seller(seller_pubkey(), LISTING_C_D_TAG, "Configured Greens"), TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) .expect("transport targets"), ) diff --git a/crates/sdk/tests/unit/runtime_tests.rs b/crates/sdk/tests/unit/runtime_tests.rs @@ -100,7 +100,7 @@ fn storage_status() -> StorageStatusReceipt { event_store: SdkEventStoreStorageStatus { store: sqlite_status(), total_events: 0, - projection_eligible_events: 0, + valid_stream_events: 0, transport_observations: 0, last_event_seq: None, last_event_updated_at_ms: None, @@ -774,6 +774,82 @@ fn sqlite_wal_checkpoint_receipt_mapping_covers_edge_states() { } #[tokio::test] +async fn read_only_event_store_status_enforces_valid_stream_invariants() { + let sdk = RadrootsClient::builder().build().await.expect("sdk"); + let pool = sdk._event_store.pool(); + let valid_event_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let unsupported_event_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + sqlx::query( + "INSERT INTO event_envelopes(event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms) VALUES (?, ?, 1, 1, '[]', 'valid', ?, '{}', 'verified', 'admitted', 'radroots.social.post.v1', 'regular', 1, 1, 1)", + ) + .bind(valid_event_id) + .bind("c".repeat(64)) + .bind("d".repeat(128)) + .execute(pool) + .await + .expect("valid event row"); + sqlx::query( + "INSERT INTO event_envelopes(event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms) VALUES (?, ?, 2, 65000, '[]', 'unsupported', ?, '{}', 'verified', 'unsupported', NULL, 'regular', 0, 2, 2)", + ) + .bind(unsupported_event_id) + .bind("e".repeat(64)) + .bind("f".repeat(128)) + .execute(pool) + .await + .expect("unsupported event row"); + + let summary = event_store_status_summary_from_pool(pool) + .await + .expect("valid stream summary"); + assert_eq!(summary.total_events, 2); + assert_eq!(summary.valid_stream_events, 1); + + sqlx::query( + "UPDATE event_envelopes SET contract_status = 'supported', contract_id = 'radroots.legacy.supported.v0', projection_eligible = 1 WHERE event_id = ?", + ) + .bind(unsupported_event_id) + .execute(pool) + .await + .expect("legacy status"); + let legacy_summary = event_store_status_summary_from_pool(pool) + .await + .expect("legacy status summary"); + assert_eq!(legacy_summary.total_events, 2); + assert_eq!(legacy_summary.valid_stream_events, 1); + sqlx::query( + "UPDATE event_envelopes SET contract_status = 'unsupported', contract_id = NULL, projection_eligible = 0 WHERE event_id = ?", + ) + .bind(unsupported_event_id) + .execute(pool) + .await + .expect("restore admission status"); + + sqlx::query( + "UPDATE event_envelopes SET verification_status = 'signature_invalid' WHERE event_id = ?", + ) + .bind(valid_event_id) + .execute(pool) + .await + .expect("invalid verification"); + assert_event_store_error(event_store_status_summary_from_pool(pool).await); + sqlx::query("UPDATE event_envelopes SET verification_status = 'verified' WHERE event_id = ?") + .bind(valid_event_id) + .execute(pool) + .await + .expect("restore verification"); + + sqlx::query( + "UPDATE event_envelopes SET kind = 20000, event_class = 'ephemeral', projection_eligible = 0 WHERE event_id = ?", + ) + .bind(valid_event_id) + .execute(pool) + .await + .expect("ephemeral stored row"); + assert_event_store_error(event_store_status_summary_from_pool(pool).await); +} + +#[tokio::test] async fn storage_status_inspection_is_read_only_and_never_creates_missing_profiles() { let tempdir = tempfile::tempdir().expect("tempdir"); let missing = tempdir.path().join("missing-profile"); @@ -782,6 +858,28 @@ async fn storage_status_inspection_is_read_only_and_never_creates_missing_profil ); assert!(!missing.exists()); + let legacy_profile = tempdir.path().join("legacy-profile"); + let legacy_sdk = RadrootsClient::builder() + .directory_storage(&legacy_profile) + .build() + .await + .expect("legacy sdk"); + sqlx::query( + "INSERT INTO event_envelopes(event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms) VALUES (?, ?, 1, 1, '[]', 'legacy', ?, '{}', 'verified', 'supported', 'radroots.social.post.v1', NULL, 1, 1, 1)", + ) + .bind("cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc") + .bind("d".repeat(64)) + .bind("e".repeat(128)) + .execute(legacy_sdk._event_store.pool()) + .await + .expect("legacy event row"); + let legacy_status = + RadrootsClient::inspect_storage_status(&legacy_profile, StorageStatusRequest::new()) + .await + .expect("legacy storage status"); + assert_eq!(legacy_status.event_store.total_events, 1); + assert_eq!(legacy_status.event_store.valid_stream_events, 0); + let pre_v1_profile = tempdir.path().join("pre-v1"); fs::create_dir(&pre_v1_profile).expect("pre-v1 profile"); fs::write(pre_v1_profile.join("event_store.sqlite"), b"old runtime").expect("old event store"); @@ -1876,40 +1974,29 @@ async fn restore_archive_private_failures_cover_staging_and_verification_edges() .build() .await .expect("populated sdk"); - let populated_event_pubkey = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - let populated_event_tags = Vec::<Vec<String>>::new(); - let populated_event_content = "{}".to_owned(); - let populated_event_id = radroots_event::wire::compute_canonical_nip01_event_id( - populated_event_pubkey, + let populated_event_keys = radroots_nostr::prelude::RadrootsNostrKeys::generate(); + let populated_event_draft = radroots_event::draft::RadrootsEventDraft::new( + "radroots.farm.profile.v1", + radroots_event::kinds::KIND_FARM, 1_700_000_002, - 1, - &populated_event_tags, - &populated_event_content, + vec![vec!["d".to_owned(), "backup-fixture".to_owned()]], + "{}", + populated_event_keys.public_key().to_hex(), ) - .expect("canonical event id"); - let populated_event_wire = radroots_event::wire::RadrootsNip01EventWire { - id: populated_event_id.into_string(), - pubkey: populated_event_pubkey.to_owned(), - created_at: 1_700_000_002, - kind: 1, - tags: populated_event_tags, - content: populated_event_content, - sig: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_owned(), - extra: Default::default(), - }; - let populated_event_raw_json = - serde_json::to_string(&populated_event_wire).expect("raw event json"); - let populated_event = radroots_event::draft::RadrootsSignedEvent::from_wire_verified_id( - populated_event_wire, - populated_event_raw_json, + .expect("event draft"); + let populated_event = radroots_nostr::prelude::radroots_nostr_sign_frozen_draft( + &populated_event_keys, + &populated_event_draft, ) .expect("signed event"); + let populated_ingest = radroots_event_store::RadrootsEventIngest::from_signed_event( + populated_event, + 1_700_000_002_000, + ) + .expect("verified event ingest"); populated_sdk ._event_store - .ingest_event(radroots_event_store::RadrootsEventIngest::new( - populated_event, - 1_700_000_002_000, - )) + .ingest_event(populated_ingest) .await .expect("populated event"); let populated_backup_destination = tempdir.path().join("populated-backup"); diff --git a/crates/sdk/tests/unit/signer_provider_tests.rs b/crates/sdk/tests/unit/signer_provider_tests.rs @@ -4,34 +4,31 @@ use nostr::{EventBuilder, JsonUtil, Kind, Tag}; use radroots_authority::RadrootsLocalEventSigner; use radroots_event::contract::RadrootsActorRole; use radroots_event::draft::RadrootsEventDraft; -use radroots_event::kinds::{KIND_COOP, KIND_FARM, KIND_LISTING, TRADE_MUTATION_EVENT_KINDS}; -use radroots_nostr::prelude::{RadrootsNostrEvent, RadrootsNostrSecretKey}; +use radroots_event::kinds::{ + KIND_CLASSIFIED_LISTING, KIND_COOP, KIND_FARM, TRADE_MUTATION_EVENT_KINDS, +}; +use radroots_nostr::prelude::RadrootsNostrEvent; use radroots_nostr_connect::prelude::{ RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectClientTarget, RadrootsNostrConnectError, RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, }; use std::collections::VecDeque; use std::future; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, LazyLock, Mutex}; use std::time::Duration; use uuid::Uuid; -const USER_SECRET_KEY_HEX: &str = - "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; -const USER_PUBLIC_KEY_HEX: &str = - "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; -const REMOTE_SECRET_KEY_HEX: &str = - "59392e9068f66431b12f70218fb61281cb6b433d7f27c55d61f1a63fe1a96ff8"; -const CLIENT_SECRET_KEY_HEX: &str = - "4d6c20fdd86857de77ff5cfa5c545751ba2efd126e0b6642dae9764d782d6509"; - -fn keys(secret_key_hex: &str) -> RadrootsNostrKeys { - let secret_key = RadrootsNostrSecretKey::from_hex(secret_key_hex).expect("secret key"); - RadrootsNostrKeys::new(secret_key) -} +static USER_KEYS: LazyLock<RadrootsNostrKeys> = LazyLock::new(RadrootsNostrKeys::generate); +static USER_PUBLIC_KEY: LazyLock<String> = LazyLock::new(|| USER_KEYS.public_key().to_hex()); +static REMOTE_KEYS: LazyLock<RadrootsNostrKeys> = LazyLock::new(RadrootsNostrKeys::generate); +static CLIENT_KEYS: LazyLock<RadrootsNostrKeys> = LazyLock::new(RadrootsNostrKeys::generate); fn user_keys() -> RadrootsNostrKeys { - keys(USER_SECRET_KEY_HEX) + USER_KEYS.clone() +} + +fn user_pubkey() -> &'static str { + USER_PUBLIC_KEY.as_str() } fn local_sdk_signer(keys: RadrootsNostrKeys) -> RadrootsSdkLocalKeySigner { @@ -42,21 +39,21 @@ fn local_sdk_signer(keys: RadrootsNostrKeys) -> RadrootsSdkLocalKeySigner { } fn remote_keys() -> RadrootsNostrKeys { - keys(REMOTE_SECRET_KEY_HEX) + REMOTE_KEYS.clone() } fn client_keys() -> RadrootsNostrKeys { - keys(CLIENT_SECRET_KEY_HEX) + CLIENT_KEYS.clone() } fn actor() -> RadrootsActorContext { - RadrootsActorContext::test(USER_PUBLIC_KEY_HEX, [RadrootsActorRole::Farmer]).expect("actor") + RadrootsActorContext::test(user_pubkey(), [RadrootsActorRole::Farmer]).expect("actor") } fn frozen_draft() -> RadrootsEventDraft { frozen_draft_with( "radroots.farm.profile.v1", - USER_PUBLIC_KEY_HEX, + user_pubkey(), KIND_FARM, 1_700_000_000, vec![vec!["d".to_owned(), "sdk-signer".to_owned()]], @@ -121,13 +118,9 @@ fn myc_signer_with_responses( remote_keys.public_key(), vec![nostr::RelayUrl::parse("wss://relay.example.com").expect("relay")], ); - let signer = RadrootsSdkMycNip46Signer::new( - client_keys(), - target, - USER_PUBLIC_KEY_HEX, - transport.clone(), - ) - .expect("signer"); + let signer = + RadrootsSdkMycNip46Signer::new(client_keys(), target, user_pubkey(), transport.clone()) + .expect("signer"); (signer, transport) } @@ -260,7 +253,7 @@ async fn local_key_provider_signs_authorized_frozen_draft() { assert_eq!(provider.status(), signer.status()); assert!(provider.capability().nip46_permissions.is_empty()); assert_eq!(receipt.mode, RadrootsSdkSignerMode::LocalKey); - assert_eq!(receipt.signer_pubkey, USER_PUBLIC_KEY_HEX); + assert_eq!(receipt.signer_pubkey, user_pubkey()); assert_eq!(receipt.signed_event_id, draft.expected_event_id_str()); assert_eq!( progress, @@ -370,13 +363,9 @@ fn signer_provider_reports_myc_status_capability_and_constructor_errors() { ]; let target = RadrootsNostrConnectClientTarget::new(remote_keys.public_key(), relays); let transport = Arc::new(MockNip46Transport::new(remote_keys.clone(), Vec::new())); - let signer = RadrootsSdkMycNip46Signer::new( - client_keys(), - target, - USER_PUBLIC_KEY_HEX, - transport.clone(), - ) - .expect("signer"); + let signer = + RadrootsSdkMycNip46Signer::new(client_keys(), target, user_pubkey(), transport.clone()) + .expect("signer"); let provider = RadrootsSdkSignerProvider::MycNip46(Box::new(signer)); assert_eq!(provider.mode(), RadrootsSdkSignerMode::MycNip46); @@ -385,7 +374,7 @@ fn signer_provider_reports_myc_status_capability_and_constructor_errors() { RadrootsSdkSignerStatus { mode: RadrootsSdkSignerMode::MycNip46, state: RadrootsSdkSignerState::Ready, - signer_pubkey: USER_PUBLIC_KEY_HEX.to_owned(), + signer_pubkey: user_pubkey().to_owned(), remote_signer_pubkey: Some(remote_keys.public_key().to_hex()), relay_count: 2, } @@ -394,7 +383,7 @@ fn signer_provider_reports_myc_status_capability_and_constructor_errors() { provider.capability(), RadrootsSdkSignerCapability { mode: RadrootsSdkSignerMode::MycNip46, - signer_pubkey: USER_PUBLIC_KEY_HEX.to_owned(), + signer_pubkey: user_pubkey().to_owned(), remote_signer_pubkey: Some(remote_keys.public_key().to_hex()), relays: vec![ "wss://relay-a.example.com".to_owned(), @@ -420,9 +409,9 @@ fn signer_provider_reports_myc_status_capability_and_constructor_errors() { #[test] fn nip46_private_helpers_map_identity_adapter_and_response_edges() { - let pubkey = USER_PUBLIC_KEY_HEX.parse().expect("pubkey"); + let pubkey = user_pubkey().parse().expect("pubkey"); let identity = RadrootsSdkSignerIdentityOnly { pubkey }; - assert_eq!(identity.pubkey().as_str(), USER_PUBLIC_KEY_HEX); + assert_eq!(identity.pubkey().as_str(), user_pubkey()); assert!(matches!( identity.sign_frozen_draft(&frozen_draft()), Err(RadrootsSignerError::Unavailable) @@ -516,7 +505,7 @@ async fn nip46_transport_adapter_delegates_publish_and_response_poll() { fn myc_nip46_product_permissions_cover_sdk_write_event_kinds() { let permissions = radroots_sdk_myc_nip46_product_permissions(); let rendered = radroots_sdk_myc_nip46_product_permission_strings(); - let mut expected_kinds = vec![KIND_FARM, KIND_LISTING]; + let mut expected_kinds = vec![KIND_FARM, KIND_CLASSIFIED_LISTING]; expected_kinds.extend_from_slice(&TRADE_MUTATION_EVENT_KINDS); assert_eq!(permissions.as_slice().len(), expected_kinds.len()); @@ -554,7 +543,7 @@ async fn myc_nip46_provider_signs_and_validates_remote_event() { vec![nostr::RelayUrl::parse("wss://relay.example.com").expect("relay")], ); let signer = - RadrootsSdkMycNip46Signer::new(client_keys, target, USER_PUBLIC_KEY_HEX, transport.clone()) + RadrootsSdkMycNip46Signer::new(client_keys, target, user_pubkey(), transport.clone()) .expect("signer"); let provider = RadrootsSdkSignerProvider::MycNip46(Box::new(signer)); assert_eq!( @@ -577,7 +566,7 @@ async fn myc_nip46_provider_signs_and_validates_remote_event() { .expect("receipt"); assert_eq!(receipt.mode, RadrootsSdkSignerMode::MycNip46); - assert_eq!(receipt.signer_pubkey, USER_PUBLIC_KEY_HEX); + assert_eq!(receipt.signer_pubkey, user_pubkey()); assert_eq!( receipt.remote_signer_pubkey, Some(remote_keys.public_key().to_hex()) @@ -759,9 +748,8 @@ async fn myc_nip46_provider_reports_auth_challenge_progress_and_timeout() { )], )); let target = RadrootsNostrConnectClientTarget::new(remote_keys.public_key(), Vec::new()); - let signer = - RadrootsSdkMycNip46Signer::new(client_keys, target, USER_PUBLIC_KEY_HEX, transport) - .expect("signer"); + let signer = RadrootsSdkMycNip46Signer::new(client_keys, target, user_pubkey(), transport) + .expect("signer"); let mut progress = Vec::new(); let draft = frozen_draft(); let actor = actor(); @@ -807,9 +795,8 @@ async fn myc_nip46_provider_returns_progress_sink_errors_from_auth_challenge() { )], )); let target = RadrootsNostrConnectClientTarget::new(remote_keys.public_key(), Vec::new()); - let signer = - RadrootsSdkMycNip46Signer::new(client_keys, target, USER_PUBLIC_KEY_HEX, transport) - .expect("signer"); + let signer = RadrootsSdkMycNip46Signer::new(client_keys, target, user_pubkey(), transport) + .expect("signer"); let draft = frozen_draft(); let actor = actor(); let mut observed = Vec::new(); @@ -860,7 +847,7 @@ async fn myc_nip46_provider_rejects_zero_timeout_policy() { let constructor_error = match RadrootsSdkMycNip46Signer::new_with_request_policy( client_keys(), target, - USER_PUBLIC_KEY_HEX, + user_pubkey(), transport, RadrootsSdkMycNip46RequestPolicy { request_timeout: Duration::ZERO, @@ -886,7 +873,7 @@ async fn myc_nip46_provider_times_out_hanging_transport() { let signer = RadrootsSdkMycNip46Signer::new_with_request_policy( client_keys, target, - USER_PUBLIC_KEY_HEX, + user_pubkey(), transport, policy, ) @@ -928,7 +915,7 @@ async fn myc_nip46_provider_rejects_returned_event_drift() { user_keys(), frozen_draft_with( "radroots.farm.profile.v1", - USER_PUBLIC_KEY_HEX, + user_pubkey(), KIND_FARM, 1_700_000_000, vec![vec!["d".to_owned(), "sdk-signer-id-drift".to_owned()]], @@ -940,7 +927,7 @@ async fn myc_nip46_provider_rejects_returned_event_drift() { user_keys(), frozen_draft_with( "radroots.farm.profile.v1", - USER_PUBLIC_KEY_HEX, + user_pubkey(), KIND_FARM, 1_700_000_001, vec![vec!["d".to_owned(), "sdk-signer".to_owned()]], @@ -952,7 +939,7 @@ async fn myc_nip46_provider_rejects_returned_event_drift() { user_keys(), frozen_draft_with( "radroots.farm.coop.v1", - USER_PUBLIC_KEY_HEX, + user_pubkey(), KIND_COOP, 1_700_000_000, vec![vec!["d".to_owned(), "sdk-signer".to_owned()]], @@ -964,7 +951,7 @@ async fn myc_nip46_provider_rejects_returned_event_drift() { user_keys(), frozen_draft_with( "radroots.farm.profile.v1", - USER_PUBLIC_KEY_HEX, + user_pubkey(), KIND_FARM, 1_700_000_000, vec![vec!["d".to_owned(), "sdk-signer-tags-drift".to_owned()]], @@ -976,7 +963,7 @@ async fn myc_nip46_provider_rejects_returned_event_drift() { user_keys(), frozen_draft_with( "radroots.farm.profile.v1", - USER_PUBLIC_KEY_HEX, + user_pubkey(), KIND_FARM, 1_700_000_000, vec![vec!["d".to_owned(), "sdk-signer".to_owned()]], @@ -996,9 +983,8 @@ async fn myc_nip46_provider_rejects_returned_event_drift() { )], )); let target = RadrootsNostrConnectClientTarget::new(remote_keys.public_key(), Vec::new()); - let signer = - RadrootsSdkMycNip46Signer::new(client_keys, target, USER_PUBLIC_KEY_HEX, transport) - .expect("signer"); + let signer = RadrootsSdkMycNip46Signer::new(client_keys, target, user_pubkey(), transport) + .expect("signer"); let actor = actor(); let error = signer diff --git a/crates/sdk/tests/unit/sync_runtime_tests.rs b/crates/sdk/tests/unit/sync_runtime_tests.rs @@ -30,9 +30,7 @@ use radroots_event::ids::RadrootsEventId; use radroots_event::kinds::KIND_FARM; use radroots_event_store::RadrootsEventStoreStatusSummary; #[cfg(feature = "radrootsd-execution")] -use radroots_nostr::prelude::{ - RadrootsNostrKeys, RadrootsNostrSecretKey, radroots_nostr_sign_frozen_draft, -}; +use radroots_nostr::prelude::{RadrootsNostrKeys, radroots_nostr_sign_frozen_draft}; #[cfg(feature = "radrootsd-execution")] use radroots_outbox::{ RadrootsOutboxClaimedEvent, RadrootsOutboxDeliveryPlanInput, RadrootsOutboxDeliveryPlanStatus, @@ -65,14 +63,21 @@ use std::io::ErrorKind; #[cfg(feature = "radrootsd-execution")] use std::net::TcpListener; #[cfg(feature = "radrootsd-execution")] +use std::sync::LazyLock; +#[cfg(feature = "radrootsd-execution")] use std::time::Duration; #[cfg(feature = "radrootsd-execution")] -const RADROOTSD_FIXTURE_SIGNER_SECRET_KEY_HEX: &str = - "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; +static RADROOTSD_FIXTURE_SIGNER_KEYS: LazyLock<RadrootsNostrKeys> = + LazyLock::new(RadrootsNostrKeys::generate); #[cfg(feature = "radrootsd-execution")] -const RADROOTSD_FIXTURE_SIGNER_PUBLIC_KEY_HEX: &str = - "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; +static RADROOTSD_FIXTURE_SIGNER_PUBLIC_KEY: LazyLock<String> = + LazyLock::new(|| RADROOTSD_FIXTURE_SIGNER_KEYS.public_key().to_hex()); + +#[cfg(feature = "radrootsd-execution")] +fn radrootsd_fixture_signer_pubkey() -> &'static str { + RADROOTSD_FIXTURE_SIGNER_PUBLIC_KEY.as_str() +} struct UnusedPublishAdapter; @@ -85,13 +90,10 @@ struct RadrootsdFixtureSigner { #[cfg(feature = "radrootsd-execution")] impl RadrootsdFixtureSigner { fn new() -> Self { - let secret_key = RadrootsNostrSecretKey::from_hex(RADROOTSD_FIXTURE_SIGNER_SECRET_KEY_HEX) - .expect("secret key"); - let keys = RadrootsNostrKeys::new(secret_key); Self { - identity: RadrootsSignerIdentity::new(RADROOTSD_FIXTURE_SIGNER_PUBLIC_KEY_HEX) + identity: RadrootsSignerIdentity::new(radrootsd_fixture_signer_pubkey()) .expect("identity"), - keys, + keys: RADROOTSD_FIXTURE_SIGNER_KEYS.clone(), } } } @@ -127,7 +129,7 @@ impl RadrootsRelayPublishAdapter for UnusedPublishAdapter { #[cfg(feature = "radrootsd-execution")] fn radrootsd_actor() -> RadrootsActorContext { RadrootsActorContext::test( - RADROOTSD_FIXTURE_SIGNER_PUBLIC_KEY_HEX, + radrootsd_fixture_signer_pubkey(), [RadrootsActorRole::Farmer], ) .expect("actor") @@ -141,7 +143,7 @@ fn radrootsd_frozen_draft(d_tag: &str) -> RadrootsEventDraft { 1_700_000_000, vec![vec!["d".to_owned(), d_tag.to_owned()]], "{}", - RADROOTSD_FIXTURE_SIGNER_PUBLIC_KEY_HEX, + radrootsd_fixture_signer_pubkey(), ) .expect("frozen draft") } @@ -344,7 +346,7 @@ fn radrootsd_job( terminal: !retryable, delivery_satisfied, event_id: event_id.to_owned(), - pubkey: RADROOTSD_FIXTURE_SIGNER_PUBLIC_KEY_HEX.to_owned(), + pubkey: radrootsd_fixture_signer_pubkey().to_owned(), event_kind: KIND_FARM, target_policy: TransportPublishTargetPolicy::nostr( NostrPublishTargetSourcePolicy::RequestThenAuthorWriteThenDaemonDefault, @@ -531,14 +533,14 @@ fn auth_policy_defaults_and_outbox_state_mappings_cover_all_public_states() { fn sync_status_summary_conversions_preserve_all_fields() { let event_summary = RadrootsEventStoreStatusSummary { total_events: 11, - projection_eligible_events: 7, + valid_stream_events: 7, transport_observations: 3, last_event_seq: Some(9), last_event_updated_at_ms: Some(1_700_000_000_000), }; let event_status = SyncEventStoreStatus::from(event_summary); assert_eq!(event_status.total_events, 11); - assert_eq!(event_status.projection_eligible_events, 7); + assert_eq!(event_status.valid_stream_events, 7); assert_eq!(event_status.transport_observations, 3); assert_eq!(event_status.last_event_seq, Some(9)); assert_eq!( @@ -1104,7 +1106,7 @@ async fn radrootsd_push_reports_missing_signed_claim_before_daemon_publish() { 1_700_000_000, vec![vec!["d".to_owned(), "missing-signed-event".to_owned()]], "{}", - RADROOTSD_FIXTURE_SIGNER_PUBLIC_KEY_HEX, + radrootsd_fixture_signer_pubkey(), ) .expect("draft"), signed_event: None, diff --git a/crates/sdk/tests/unit/trade_runtime_tests.rs b/crates/sdk/tests/unit/trade_runtime_tests.rs @@ -7,7 +7,7 @@ use radroots_authority::{RadrootsActorContext, RadrootsLocalEventSigner}; use radroots_event::{ contract::RadrootsActorRole, ids::{ - RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsInventoryBinId, + RadrootsClassifiedListingAddress, RadrootsDTag, RadrootsEventId, RadrootsInventoryBinId, RadrootsPublicKey, RadrootsTradeId, }, kinds::TRADE_MUTATION_EVENT_KINDS, @@ -22,17 +22,12 @@ use radroots_event::{ RadrootsTradeMutationEnvelopeV1, canonical_trade_mutation_content, }, }; -use radroots_nostr::prelude::{RadrootsNostrKeys, RadrootsNostrSecretKey}; +use radroots_nostr::prelude::RadrootsNostrKeys; use radroots_trade::workflow::{ RADROOTS_TRADE_REDUCER_CONTRACT_ID, RADROOTS_TRADE_REDUCER_VERSION, RadrootsTradePrivateTermsStateV1, }; -const BUYER_SECRET_KEY_HEX: &str = - "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; -const SELLER_SECRET_KEY_HEX: &str = - "59392e9068f66431b12f70218fb61281cb6b433d7f27c55d61f1a63fe1a96ff8"; - fn pubkey(value: &str) -> RadrootsPublicKey { RadrootsPublicKey::parse(value).expect("pubkey") } @@ -45,9 +40,8 @@ fn trade_id() -> RadrootsTradeId { RadrootsTradeId::parse("11111111111111111111111111111111").expect("trade id") } -fn local_signer(secret_key_hex: &str) -> (String, RadrootsLocalEventSigner) { - let secret_key = RadrootsNostrSecretKey::from_hex(secret_key_hex).expect("secret key"); - let keys = RadrootsNostrKeys::new(secret_key); +fn local_signer() -> (String, RadrootsLocalEventSigner) { + let keys = RadrootsNostrKeys::generate(); let pubkey = keys.public_key().to_hex(); ( pubkey, @@ -135,7 +129,7 @@ fn candidate(buyer_pubkey: &str, seller_pubkey: &str) -> RadrootsTradeCandidateT farm_id: RadrootsDTag::parse("farm-1").expect("farm id"), lines: vec![RadrootsTradeCandidateLineV1 { line_id: RadrootsDTag::parse("line-1").expect("line id"), - listing_addr: RadrootsAddressableCoordinate::parse(format!( + listing_addr: RadrootsClassifiedListingAddress::parse(format!( "30402:{seller_pubkey}:listing-1" )) .expect("listing address"), @@ -268,8 +262,8 @@ fn accepted_decision( #[tokio::test] async fn trade_commands_query_and_private_terms_are_release_product_v1() { - let (buyer_pubkey, buyer_signer) = local_signer(BUYER_SECRET_KEY_HEX); - let (seller_pubkey, seller_signer) = local_signer(SELLER_SECRET_KEY_HEX); + let (buyer_pubkey, buyer_signer) = local_signer(); + let (seller_pubkey, seller_signer) = local_signer(); let sdk = RadrootsClient::builder() .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_799_000_100)) .build() diff --git a/crates/sdk/tests/unit/workflow_runtime_tests.rs b/crates/sdk/tests/unit/workflow_runtime_tests.rs @@ -4,15 +4,24 @@ use crate::{RadrootsSdkLocalKeySigner, RadrootsSdkSignerProvider}; use radroots_authority::{RadrootsSignerError, RadrootsSignerIdentity}; use radroots_event::contract::RadrootsActorRole; use radroots_event::draft::{RadrootsEventDraft, RadrootsSignedEvent, RadrootsSignedEventParts}; -use radroots_event::kinds::KIND_FARM; -use radroots_nostr::prelude::{ - RadrootsNostrKeys, RadrootsNostrSecretKey, radroots_nostr_sign_frozen_draft, -}; +use radroots_event::kinds::{KIND_FARM, KIND_GEOCHAT}; +use radroots_nostr::prelude::{RadrootsNostrKeys, radroots_nostr_sign_frozen_draft}; +use std::sync::LazyLock; -const FARMER_SECRET_KEY_HEX: &str = - "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; -const FARMER_PUBLIC_KEY_HEX: &str = - "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; +struct WorkflowKeyMaterial { + keys: RadrootsNostrKeys, + pubkey: String, +} + +static WORKFLOW_KEY_MATERIAL: LazyLock<WorkflowKeyMaterial> = LazyLock::new(|| { + let keys = RadrootsNostrKeys::generate(); + let pubkey = keys.public_key().to_hex(); + WorkflowKeyMaterial { keys, pubkey } +}); + +fn farmer_pubkey() -> &'static str { + WORKFLOW_KEY_MATERIAL.pubkey.as_str() +} fn workflow_idempotency_key(index: u16) -> SdkIdempotencyKey { SdkIdempotencyKey::new(format!("01890f0e-6c00-7000-8000-00000000{index:04x}")) @@ -26,16 +35,68 @@ struct WorkflowSigner { impl WorkflowSigner { fn new() -> Self { - let secret_key = - RadrootsNostrSecretKey::from_hex(FARMER_SECRET_KEY_HEX).expect("secret key"); - let keys = RadrootsNostrKeys::new(secret_key); Self { - identity: RadrootsSignerIdentity::new(FARMER_PUBLIC_KEY_HEX).expect("identity"), - keys, + identity: RadrootsSignerIdentity::new(farmer_pubkey()).expect("identity"), + keys: WORKFLOW_KEY_MATERIAL.keys.clone(), + } + } +} + +struct FailIfCalledSigner { + identity: RadrootsSignerIdentity, +} + +impl FailIfCalledSigner { + fn new() -> Self { + Self { + identity: RadrootsSignerIdentity::new(farmer_pubkey()).expect("identity"), } } } +impl RadrootsEventSigner for FailIfCalledSigner { + fn pubkey(&self) -> &radroots_event::ids::RadrootsPublicKey { + self.identity.pubkey() + } + + fn sign_frozen_draft( + &self, + _draft: &RadrootsEventDraft, + ) -> Result<RadrootsSignedEvent, RadrootsSignerError> { + panic!("ephemeral workflow preflight must not invoke the signer") + } +} + +struct InvalidSignatureSigner(WorkflowSigner); + +impl InvalidSignatureSigner { + fn new() -> Self { + Self(WorkflowSigner::new()) + } +} + +impl RadrootsEventSigner for InvalidSignatureSigner { + fn pubkey(&self) -> &radroots_event::ids::RadrootsPublicKey { + self.0.pubkey() + } + + fn sign_frozen_draft( + &self, + draft: &RadrootsEventDraft, + ) -> Result<RadrootsSignedEvent, RadrootsSignerError> { + let signed = self.0.sign_frozen_draft(draft)?; + let mut wire = signed.wire().clone(); + wire.sig = "0".repeat(128); + let raw_json = + serde_json::to_string(&wire).expect("invalid-signature fixture must serialize"); + RadrootsSignedEvent::from_wire_verified_id(wire, raw_json).map_err(|error| { + RadrootsSignerError::SigningFailed { + message: error.to_string(), + } + }) + } +} + impl RadrootsEventSigner for WorkflowSigner { fn pubkey(&self) -> &radroots_event::ids::RadrootsPublicKey { self.identity.pubkey() @@ -73,6 +134,18 @@ fn frozen_draft() -> RadrootsEventDraft { frozen_draft_for("a".repeat(64).as_str()) } +fn ephemeral_draft_for(pubkey: &str) -> RadrootsEventDraft { + RadrootsEventDraft::new( + "radroots.social.geochat.v1", + KIND_GEOCHAT, + 1_700_000_000, + Vec::new(), + "Transient local message", + pubkey, + ) + .expect("ephemeral draft") +} + fn signed_event() -> RadrootsSignedEvent { let draft = frozen_draft(); let sig = "c".repeat(128); @@ -157,6 +230,30 @@ fn workflow_digest_and_event_helpers_cover_error_and_input_paths() { "wss://relay.example.com" ); assert!(input.event_store_inserted); + assert_eq!( + durable_event_persistence( + draft.expected_event_id_str(), + &RadrootsEventPersistence::Inserted { seq: 7 }, + ) + .expect("inserted persistence"), + (true, 7) + ); + assert_eq!( + durable_event_persistence( + draft.expected_event_id_str(), + &RadrootsEventPersistence::Duplicate { seq: 7 }, + ) + .expect("duplicate persistence"), + (false, 7) + ); + assert!(matches!( + durable_event_persistence( + draft.expected_event_id_str(), + &RadrootsEventPersistence::NotPersisted, + ), + Err(RadrootsSdkError::InvalidRequest { message }) + if message.contains("requires durable local event-store persistence") + )); let frozen = frozen_draft_json(&draft).expect("frozen draft json"); assert!(frozen.contains("\"expected_event_id\"")); let receipt = SdkWorkflowEnqueueReceipt { @@ -175,6 +272,133 @@ fn workflow_digest_and_event_helpers_cover_error_and_input_paths() { } #[tokio::test] +async fn enqueue_signed_workflow_rejects_ephemeral_event_before_durable_commit() { + let sdk = crate::RadrootsClient::builder() + .fixed_clock(crate::RadrootsSdkTimestamp::from_unix_seconds( + 1_700_000_013, + )) + .build() + .await + .expect("sdk"); + let actor = + RadrootsActorContext::test(farmer_pubkey(), [RadrootsActorRole::Farmer]).expect("actor"); + let draft = ephemeral_draft_for(farmer_pubkey()); + let error = enqueue_signed_workflow( + &sdk, + SdkWorkflowEnqueueRequest { + operation_kind: "workflow.ephemeral.test.v1", + actor: &actor, + frozen_draft: &draft, + target_policy: TargetPolicy::LocalOnly, + satisfaction_policy: SatisfactionPolicy::NoWait, + idempotency_key: Some(workflow_idempotency_key(0x247)), + }, + &FailIfCalledSigner::new(), + ) + .await + .expect_err("ephemeral workflow"); + + assert!(matches!( + error, + RadrootsSdkError::InvalidRequest { ref message } + if message.contains("cannot enqueue ephemeral event kind") + )); + assert!(!error.retryable()); + assert_eq!( + sdk._event_store + .status_summary() + .await + .expect("event store status") + .total_events, + 0 + ); + assert_eq!( + sdk._outbox + .status_summary(0) + .await + .expect("outbox status") + .total_events, + 0 + ); + let journal_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM sdk_runtime_operation_journal WHERE operation_kind = ?", + ) + .bind("workflow.ephemeral.test.v1") + .fetch_one(sdk._event_store.pool()) + .await + .expect("journal count"); + assert_eq!(journal_count, 0); +} + +#[tokio::test] +async fn enqueue_signed_workflow_rejects_invalid_signer_signature_without_storage_mutation() { + let sdk = crate::RadrootsClient::builder() + .fixed_clock(crate::RadrootsSdkTimestamp::from_unix_seconds( + 1_700_000_013, + )) + .build() + .await + .expect("sdk"); + let actor = + RadrootsActorContext::test(farmer_pubkey(), [RadrootsActorRole::Farmer]).expect("actor"); + let draft = frozen_draft_for_d_tag(farmer_pubkey(), "workflow-invalid-signature"); + let operation_kind = "workflow.invalid-signature.test.v1"; + + let error = enqueue_signed_workflow( + &sdk, + SdkWorkflowEnqueueRequest { + operation_kind, + actor: &actor, + frozen_draft: &draft, + target_policy: TargetPolicy::LocalOnly, + satisfaction_policy: SatisfactionPolicy::NoWait, + idempotency_key: Some(workflow_idempotency_key(0x248)), + }, + &InvalidSignatureSigner::new(), + ) + .await + .expect_err("invalid signer signature"); + + assert!(matches!( + error, + RadrootsSdkError::SignerReturnedEventDrift { + ref operation, + ref reason, + } if operation == operation_kind + && reason.contains("failed NIP-01 verification") + )); + assert!(!error.retryable()); + assert_eq!( + error.recovery_actions(), + vec![crate::RadrootsSdkRecoveryAction::ConfigureSigner] + ); + assert_eq!( + sdk._event_store + .status_summary() + .await + .expect("event store status") + .total_events, + 0 + ); + assert_eq!( + sdk._outbox + .status_summary(0) + .await + .expect("outbox status") + .total_events, + 0 + ); + let journal_state: String = sqlx::query_scalar( + "SELECT state FROM sdk_runtime_operation_journal WHERE operation_kind = ?", + ) + .bind(operation_kind) + .fetch_one(sdk._event_store.pool()) + .await + .expect("journal state"); + assert_eq!(journal_state, "rejected"); +} + +#[tokio::test] async fn workflow_idempotency_replays_original_receipt_and_conflicts_on_new_command_hash() { let sdk = crate::RadrootsClient::builder() .fixed_clock(crate::RadrootsSdkTimestamp::from_unix_seconds( @@ -183,10 +407,10 @@ async fn workflow_idempotency_replays_original_receipt_and_conflicts_on_new_comm .build() .await .expect("sdk"); - let actor = RadrootsActorContext::test(FARMER_PUBLIC_KEY_HEX, [RadrootsActorRole::Farmer]) - .expect("actor"); + let actor = + RadrootsActorContext::test(farmer_pubkey(), [RadrootsActorRole::Farmer]).expect("actor"); let signer = WorkflowSigner::new(); - let draft = frozen_draft_for_d_tag(FARMER_PUBLIC_KEY_HEX, "workflow-target-policy"); + let draft = frozen_draft_for_d_tag(farmer_pubkey(), "workflow-target-policy"); let first_target_policy = TargetPolicy::try_nostr_relays( ["wss://relay-a.example.com"], crate::NostrRelayUrlPolicy::Public, @@ -298,10 +522,10 @@ async fn enqueue_signed_workflow_maps_no_wait_directly_and_allows_local_only_pro .build() .await .expect("sdk"); - let actor = RadrootsActorContext::test(FARMER_PUBLIC_KEY_HEX, [RadrootsActorRole::Farmer]) - .expect("actor"); + let actor = + RadrootsActorContext::test(farmer_pubkey(), [RadrootsActorRole::Farmer]).expect("actor"); let signer = WorkflowSigner::new(); - let draft = frozen_draft_for_d_tag(FARMER_PUBLIC_KEY_HEX, "workflow-no-wait"); + let draft = frozen_draft_for_d_tag(farmer_pubkey(), "workflow-no-wait"); let receipt = enqueue_signed_workflow( &sdk, @@ -372,9 +596,9 @@ async fn enqueue_signed_workflow_rejects_missing_explicit_idempotency_key_withou .build() .await .expect("sdk"); - let actor = RadrootsActorContext::test(FARMER_PUBLIC_KEY_HEX, [RadrootsActorRole::Farmer]) - .expect("actor"); - let draft = frozen_draft_for_d_tag(FARMER_PUBLIC_KEY_HEX, "workflow-missing-idempotency"); + let actor = + RadrootsActorContext::test(farmer_pubkey(), [RadrootsActorRole::Farmer]).expect("actor"); + let draft = frozen_draft_for_d_tag(farmer_pubkey(), "workflow-missing-idempotency"); let error = match enqueue_signed_workflow( &sdk, @@ -423,10 +647,10 @@ async fn enqueue_signed_workflow_stores_signed_event_and_reports_idempotency_con .build() .await .expect("sdk"); - let actor = RadrootsActorContext::test(FARMER_PUBLIC_KEY_HEX, [RadrootsActorRole::Farmer]) - .expect("actor"); + let actor = + RadrootsActorContext::test(farmer_pubkey(), [RadrootsActorRole::Farmer]).expect("actor"); let signer = WorkflowSigner::new(); - let first_draft = frozen_draft_for_d_tag(FARMER_PUBLIC_KEY_HEX, "workflow-success"); + let first_draft = frozen_draft_for_d_tag(farmer_pubkey(), "workflow-success"); let idempotency_key = SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000237").expect("idempotency"); let receipt = enqueue_signed_workflow( @@ -469,7 +693,7 @@ async fn enqueue_signed_workflow_stores_signed_event_and_reports_idempotency_con 1 ); - let second_draft = frozen_draft_for_d_tag(FARMER_PUBLIC_KEY_HEX, "workflow-conflict"); + let second_draft = frozen_draft_for_d_tag(farmer_pubkey(), "workflow-conflict"); let error = match enqueue_signed_workflow( &sdk, SdkWorkflowEnqueueRequest { @@ -528,9 +752,9 @@ async fn enqueue_configured_signed_workflow_uses_sdk_signer_provider() { .build() .await .expect("sdk"); - let actor = RadrootsActorContext::test(FARMER_PUBLIC_KEY_HEX, [RadrootsActorRole::Farmer]) - .expect("actor"); - let draft = frozen_draft_for_d_tag(FARMER_PUBLIC_KEY_HEX, "workflow-configured"); + let actor = + RadrootsActorContext::test(farmer_pubkey(), [RadrootsActorRole::Farmer]).expect("actor"); + let draft = frozen_draft_for_d_tag(farmer_pubkey(), "workflow-configured"); let receipt = enqueue_configured_signed_workflow( &sdk, @@ -569,9 +793,9 @@ async fn enqueue_signed_workflow_reports_runtime_pool_failure_before_mutation() 0 ); sdk._outbox.pool().close().await; - let actor = RadrootsActorContext::test(FARMER_PUBLIC_KEY_HEX, [RadrootsActorRole::Farmer]) - .expect("actor"); - let draft = frozen_draft_for(FARMER_PUBLIC_KEY_HEX); + let actor = + RadrootsActorContext::test(farmer_pubkey(), [RadrootsActorRole::Farmer]).expect("actor"); + let draft = frozen_draft_for(farmer_pubkey()); let request = SdkWorkflowEnqueueRequest { operation_kind: "workflow.test.v1", actor: &actor, @@ -591,9 +815,9 @@ async fn enqueue_signed_workflow_reports_runtime_pool_failure_before_mutation() #[tokio::test] async fn enqueue_signed_workflow_reports_store_failures() { - let actor = RadrootsActorContext::test(FARMER_PUBLIC_KEY_HEX, [RadrootsActorRole::Farmer]) - .expect("actor"); - let draft = frozen_draft_for(FARMER_PUBLIC_KEY_HEX); + let actor = + RadrootsActorContext::test(farmer_pubkey(), [RadrootsActorRole::Farmer]).expect("actor"); + let draft = frozen_draft_for(farmer_pubkey()); let closed_store_sdk = crate::RadrootsClient::builder() .transport_profile(nostr_profile("wss://relay.example.com")) .build() @@ -627,9 +851,9 @@ async fn enqueue_signed_workflow_reports_clock_failures() { .build() .await .expect("sdk"); - let actor = RadrootsActorContext::test(FARMER_PUBLIC_KEY_HEX, [RadrootsActorRole::Farmer]) - .expect("actor"); - let draft = frozen_draft_for(FARMER_PUBLIC_KEY_HEX); + let actor = + RadrootsActorContext::test(farmer_pubkey(), [RadrootsActorRole::Farmer]).expect("actor"); + let draft = frozen_draft_for(farmer_pubkey()); let request = SdkWorkflowEnqueueRequest { operation_kind: "workflow.test.v1", actor: &actor, @@ -647,9 +871,9 @@ async fn enqueue_signed_workflow_reports_clock_failures() { #[tokio::test] async fn enqueue_signed_workflow_rejects_transport_profile_targets_without_radrootsd_execution() { let sdk = crate::RadrootsClient::builder().build().await.expect("sdk"); - let actor = RadrootsActorContext::test(FARMER_PUBLIC_KEY_HEX, [RadrootsActorRole::Farmer]) - .expect("actor"); - let draft = frozen_draft_for(FARMER_PUBLIC_KEY_HEX); + let actor = + RadrootsActorContext::test(farmer_pubkey(), [RadrootsActorRole::Farmer]).expect("actor"); + let draft = frozen_draft_for(farmer_pubkey()); let request = SdkWorkflowEnqueueRequest { operation_kind: "workflow.test.v1", actor: &actor, diff --git a/crates/trade_bindings/src/dto.rs b/crates/trade_bindings/src/dto.rs @@ -3,7 +3,7 @@ use dto_bindgen_core::{ RustTypeId, SourceSpan, TargetFieldNames, TargetOverride, TypeDef, TypeRef, VariantDef, VariantShape, WireFieldNames, }; -use radroots_trade::listing::model::RadrootsTradeListingTotal; +use radroots_trade::operational_listing::model::RadrootsOperationalListingTotal; pub fn dto_roots() -> Vec<RootDescriptor> { let mut roots = radroots_trade::dto::dto_roots() @@ -52,22 +52,30 @@ imported_ts_type!(RadrootsCoreQuantityImport, "RadrootsCoreQuantity"); imported_ts_type!(RadrootsCoreQuantityPriceImport, "RadrootsCoreQuantityPrice"); imported_ts_type!(RadrootsCoreUnitImport, "RadrootsCoreUnit"); imported_ts_type!(RadrootsFarmRefImport, "RadrootsFarmRef"); -imported_ts_type!(RadrootsListingImport, "RadrootsListing"); imported_ts_type!( - RadrootsListingAvailabilityImport, - "RadrootsListingAvailability" + RadrootsOperationalListingAvailabilityImport, + "RadrootsOperationalListingAvailability" ); -imported_ts_type!(RadrootsListingBinImport, "RadrootsListingBin"); imported_ts_type!( - RadrootsListingDeliveryMethodImport, - "RadrootsListingDeliveryMethod" + RadrootsOperationalListingBinImport, + "RadrootsOperationalListingBin" ); -imported_ts_type!(RadrootsListingImageImport, "RadrootsListingImage"); imported_ts_type!( - RadrootsListingPublicLocationImport, - "RadrootsListingPublicLocation" + RadrootsOperationalListingDeliveryMethodImport, + "RadrootsOperationalListingDeliveryMethod" +); +imported_ts_type!( + RadrootsOperationalListingImageImport, + "RadrootsOperationalListingImage" +); +imported_ts_type!( + RadrootsOperationalListingPublicLocationImport, + "RadrootsOperationalListingPublicLocation" +); +imported_ts_type!( + RadrootsOperationalListingProductImport, + "RadrootsOperationalListingProduct" ); -imported_ts_type!(RadrootsListingProductImport, "RadrootsListingProduct"); imported_ts_type!(RadrootsPlotRefImport, "RadrootsPlotRef"); imported_ts_type!(RadrootsResourceAreaRefImport, "RadrootsResourceAreaRef"); @@ -102,8 +110,8 @@ pub struct RadrootsTradeListingBackofficeView { #[derive(dto_bindgen::Dto)] pub struct RadrootsTradeListingBinProjection { - pub bin: RadrootsListingBinImport, - pub one_bin_total: RadrootsTradeListingTotal, + pub bin: RadrootsOperationalListingBinImport, + pub one_bin_total: RadrootsOperationalListingTotal, } #[derive(dto_bindgen::Dto)] @@ -178,17 +186,17 @@ pub struct RadrootsTradeListingProjection { pub seller_pubkey: String, pub listing_id: String, pub farm: RadrootsFarmRefImport, - pub product: RadrootsListingProductImport, + pub product: RadrootsOperationalListingProductImport, pub primary_bin_id: String, pub bins: Vec<RadrootsTradeListingBinProjection>, pub resource_area: Option<RadrootsResourceAreaRefImport>, pub plot: Option<RadrootsPlotRefImport>, pub discounts: Option<Vec<RadrootsCoreDiscountImport>>, pub inventory_available: Option<RadrootsCoreDecimalImport>, - pub availability: Option<RadrootsListingAvailabilityImport>, - pub delivery_method: Option<RadrootsListingDeliveryMethodImport>, - pub location: Option<RadrootsListingPublicLocationImport>, - pub images: Option<Vec<RadrootsListingImageImport>>, + pub availability: Option<RadrootsOperationalListingAvailabilityImport>, + pub delivery_method: Option<RadrootsOperationalListingDeliveryMethodImport>, + pub location: Option<RadrootsOperationalListingPublicLocationImport>, + pub images: Option<Vec<RadrootsOperationalListingImageImport>>, pub trade_count: u32, pub open_trade_count: u32, pub terminal_trade_count: u32, @@ -243,7 +251,7 @@ pub struct RadrootsTradeMarketplaceListingSummary { pub inventory_available: Option<RadrootsCoreDecimalImport>, pub primary_bin_id: String, pub primary_bin_label: Option<String>, - pub primary_bin_total: RadrootsTradeListingTotal, + pub primary_bin_total: RadrootsOperationalListingTotal, pub trade_count: u32, pub open_trade_count: u32, pub terminal_trade_count: u32, diff --git a/crates/trade_bindings/src/lib.rs b/crates/trade_bindings/src/lib.rs @@ -32,20 +32,20 @@ pub struct TradeLargeIntegerPolicyEntry { pub const TRADE_TYPE_INVENTORY: &[TradeTypeInventoryEntry] = &[ event_import("RadrootsFarmRef"), - event_import("RadrootsListing"), - event_import("RadrootsListingAvailability"), - event_import("RadrootsListingBin"), - event_import("RadrootsListingDeliveryMethod"), - event_import("RadrootsListingProduct"), - event_import("RadrootsListingPublicLocation"), - event_import("RadrootsListingStatus"), + event_import("RadrootsOperationalListing"), + event_import("RadrootsOperationalListingAvailability"), + event_import("RadrootsOperationalListingBin"), + event_import("RadrootsOperationalListingDeliveryMethod"), + event_import("RadrootsOperationalListingProduct"), + event_import("RadrootsOperationalListingPublicLocation"), + event_import("RadrootsOperationalListingStatus"), local_shape("RadrootsTradeFacetCount"), source_root("RadrootsTradeAgreementStateV1"), source_root("RadrootsTradeAttestationStateV1"), source_root("RadrootsTradeConflictStateV1"), source_root("RadrootsTradeEvidenceStateV1"), source_root("RadrootsTradeFulfillmentStateV1"), - source_root("RadrootsTradeListing"), + source_root("RadrootsOperationalListingTradeProjection"), local_shape("RadrootsTradeListingBackofficeOverlay"), local_shape("RadrootsTradeListingBackofficeQuery"), local_shape("RadrootsTradeListingBackofficeView"), @@ -56,8 +56,8 @@ pub const TRADE_TYPE_INVENTORY: &[TradeTypeInventoryEntry] = &[ local_shape("RadrootsTradeListingQuery"), local_shape("RadrootsTradeListingSort"), local_shape("RadrootsTradeListingSortField"), - source_root("RadrootsTradeListingSubtotal"), - source_root("RadrootsTradeListingTotal"), + source_root("RadrootsOperationalListingSubtotal"), + source_root("RadrootsOperationalListingTotal"), local_shape("RadrootsTradeMarketplaceListingSummary"), local_shape("RadrootsTradeModerationFlag"), local_shape("RadrootsTradeModerationSeverity"), @@ -149,20 +149,20 @@ mod tests { expected, [ "RadrootsFarmRef", - "RadrootsListing", - "RadrootsListingAvailability", - "RadrootsListingBin", - "RadrootsListingDeliveryMethod", - "RadrootsListingProduct", - "RadrootsListingPublicLocation", - "RadrootsListingStatus", + "RadrootsOperationalListing", + "RadrootsOperationalListingAvailability", + "RadrootsOperationalListingBin", + "RadrootsOperationalListingDeliveryMethod", + "RadrootsOperationalListingProduct", + "RadrootsOperationalListingPublicLocation", + "RadrootsOperationalListingStatus", "RadrootsTradeFacetCount", "RadrootsTradeAgreementStateV1", "RadrootsTradeAttestationStateV1", "RadrootsTradeConflictStateV1", "RadrootsTradeEvidenceStateV1", "RadrootsTradeFulfillmentStateV1", - "RadrootsTradeListing", + "RadrootsOperationalListingTradeProjection", "RadrootsTradeListingBackofficeOverlay", "RadrootsTradeListingBackofficeQuery", "RadrootsTradeListingBackofficeView", @@ -173,8 +173,8 @@ mod tests { "RadrootsTradeListingQuery", "RadrootsTradeListingSort", "RadrootsTradeListingSortField", - "RadrootsTradeListingSubtotal", - "RadrootsTradeListingTotal", + "RadrootsOperationalListingSubtotal", + "RadrootsOperationalListingTotal", "RadrootsTradeMarketplaceListingSummary", "RadrootsTradeModerationFlag", "RadrootsTradeModerationSeverity", @@ -195,13 +195,13 @@ mod tests { fn source_owned_trade_support_types_are_marked_for_event_import() { for export_name in [ "RadrootsFarmRef", - "RadrootsListing", - "RadrootsListingAvailability", - "RadrootsListingBin", - "RadrootsListingDeliveryMethod", - "RadrootsListingProduct", - "RadrootsListingPublicLocation", - "RadrootsListingStatus", + "RadrootsOperationalListing", + "RadrootsOperationalListingAvailability", + "RadrootsOperationalListingBin", + "RadrootsOperationalListingDeliveryMethod", + "RadrootsOperationalListingProduct", + "RadrootsOperationalListingPublicLocation", + "RadrootsOperationalListingStatus", ] { assert_eq!( disposition(export_name), @@ -226,9 +226,9 @@ mod tests { "RadrootsTradeConflictStateV1", "RadrootsTradeEvidenceStateV1", "RadrootsTradeFulfillmentStateV1", - "RadrootsTradeListing", - "RadrootsTradeListingSubtotal", - "RadrootsTradeListingTotal", + "RadrootsOperationalListingTradeProjection", + "RadrootsOperationalListingSubtotal", + "RadrootsOperationalListingTotal", "RadrootsTradeNegotiationStateV1", "RadrootsTradePaymentStateV1", "RadrootsTradePrivateTermsStateV1", diff --git a/tools/sdk_xtask_import/src/dto_roots.rs b/tools/sdk_xtask_import/src/dto_roots.rs @@ -134,14 +134,14 @@ const TRADE_EXTERNAL_OVERRIDES: &[DtoExternalOverride] = &[ core_override("RadrootsCoreQuantityPrice"), core_override("RadrootsCoreUnit"), event_override("RadrootsFarmRef"), - event_override("RadrootsListing"), - event_override("RadrootsListingAvailability"), - event_override("RadrootsListingBin"), - event_override("RadrootsListingDeliveryMethod"), - event_override("RadrootsListingImage"), - event_override("RadrootsListingProduct"), - event_override("RadrootsListingPublicLocation"), - event_override("RadrootsListingStatus"), + event_override("RadrootsOperationalListing"), + event_override("RadrootsOperationalListingAvailability"), + event_override("RadrootsOperationalListingBin"), + event_override("RadrootsOperationalListingDeliveryMethod"), + event_override("RadrootsOperationalListingImage"), + event_override("RadrootsOperationalListingProduct"), + event_override("RadrootsOperationalListingPublicLocation"), + event_override("RadrootsOperationalListingStatus"), event_override("RadrootsEventPtr"), event_override("RadrootsPlotRef"), event_override("RadrootsResourceAreaRef"), @@ -451,7 +451,7 @@ fn with_event_sdk_wrappers(module: DtoTypesModule) -> DtoTypesModule { .map(str::to_owned) .collect::<Vec<_>>(); declarations.push(type_alias( - "RadrootsListingProductTagKeys", + "RadrootsOperationalListingProductTagKeys", TypeScriptType::readonly_tuple( [ "key", "title", "category", "summary", "process", "lot", "location", "profile", @@ -583,7 +583,6 @@ mod tests { "RadrootsAccountClaim", "RadrootsAddressableRef", "RadrootsAppData", - "RadrootsComment", "RadrootsCommercialDomain", "RadrootsContributionAttestation", "RadrootsCoop", @@ -630,22 +629,23 @@ mod tests { "RadrootsList", "RadrootsListEntry", "RadrootsListSet", - "RadrootsListing", - "RadrootsListingAvailability", - "RadrootsListingBin", - "RadrootsListingDeliveryMethod", - "RadrootsListingImage", - "RadrootsListingImageSize", - "RadrootsListingParseError", - "RadrootsListingProduct", - "RadrootsListingProductTagKeys", - "RadrootsListingPublicLocation", - "RadrootsListingStatus", "RadrootsMessage", "RadrootsMessageFile", "RadrootsMessageFileDimensions", "RadrootsMessageRecipient", "RadrootsNip01EventWireDto", + "RadrootsOperationalListing", + "RadrootsOperationalListingAvailability", + "RadrootsOperationalListingBin", + "RadrootsOperationalListingDeliveryMethod", + "RadrootsOperationalListingImage", + "RadrootsOperationalListingImageSize", + "RadrootsOperationalListingParseError", + "RadrootsOperationalListingProduct", + "RadrootsOperationalListingProductTagKeys", + "RadrootsOperationalListingPublicLocation", + "RadrootsOperationalListingStatus", + "RadrootsOperationalListingValidationError", "RadrootsPlot", "RadrootsPlotLocation", "RadrootsPlotRef", @@ -668,7 +668,6 @@ mod tests { "RadrootsSocialMediaMetadata", "RadrootsSocialMediaThumbnail", "RadrootsSocialTarget", - "RadrootsTradeValidationListingError", "RadrootsVerifiedSignedEventDto", "RadrootsVerifiedSignedEventVerificationStateDto", "RadrootsWikiArticle", @@ -685,6 +684,9 @@ mod tests { "RadrootsEventIndexCheckpoint", ]; const TRADE_TYPE_INVENTORY: &[&str] = &[ + "RadrootsOperationalListingSubtotal", + "RadrootsOperationalListingTotal", + "RadrootsOperationalListingTradeProjection", "RadrootsTradeAgreementClaimV1", "RadrootsTradeAgreementStateV1", "RadrootsTradeAttestationRecordV1", @@ -694,7 +696,6 @@ mod tests { "RadrootsTradeEvidenceStateV1", "RadrootsTradeFacetCount", "RadrootsTradeFulfillmentStateV1", - "RadrootsTradeListing", "RadrootsTradeListingBackofficeOverlay", "RadrootsTradeListingBackofficeQuery", "RadrootsTradeListingBackofficeView", @@ -705,8 +706,6 @@ mod tests { "RadrootsTradeListingQuery", "RadrootsTradeListingSort", "RadrootsTradeListingSortField", - "RadrootsTradeListingSubtotal", - "RadrootsTradeListingTotal", "RadrootsTradeMarketplaceListingSummary", "RadrootsTradeModerationFlag", "RadrootsTradeModerationSeverity", @@ -1004,16 +1003,16 @@ mod tests { assert!( imports .get("@radroots/event-bindings") - .is_some_and(|names| names.contains("RadrootsListing")) + .is_some_and(|names| names.contains("RadrootsOperationalListing")) ); assert!( imports .get("@radroots/event-bindings") - .is_some_and(|names| names.contains("RadrootsListingBin")) + .is_some_and(|names| names.contains("RadrootsOperationalListingBin")) ); for duplicate in [ - "export type RadrootsListing = ", + "export type RadrootsOperationalListing = ", "export type RadrootsFarmRef = ", "export type RadrootsOrderEconomics = ", "export type RadrootsOrderInventoryCommitment = ", diff --git a/tools/sdk_xtask_import/src/smoke.rs b/tools/sdk_xtask_import/src/smoke.rs @@ -123,14 +123,14 @@ const CONSUMER_MAIN: &str = r#"use nostr::{EventBuilder, Keys, Kind, Tag, Timest use radroots_event::RadrootsEventEnvelopeParts; use radroots_sdk::knowledge::prelude::*; -const SECRET_KEY_HEX: &str = "0101010101010101010101010101010101010101010101010101010101010101"; const CREATED_AT: u32 = 1_800_000_000; fn main() -> Result<(), Box<dyn std::error::Error>> { + let keys = Keys::generate(); let claim = claim_builder().build()?; let parts = claim_builder().build_event()?; - let draft = claim_builder().build_draft(public_key_hex(), CREATED_AT)?; - let decoded = verify_and_decode_radroots_event(sign_parts(parts)?)?; + let draft = claim_builder().build_draft(keys.public_key().to_hex(), CREATED_AT)?; + let decoded = verify_and_decode_radroots_event(sign_parts(parts, &keys)?)?; let manifest = contract_manifest(); assert_eq!(draft.contract_id, KNOWLEDGE_CLAIM_CONTRACT_ID); @@ -142,17 +142,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { Ok(()) } -fn sign_parts(parts: RadrootsNip01EventWireParts) -> Result<RadrootsEventEnvelope, Box<dyn std::error::Error>> { +fn sign_parts( + parts: RadrootsNip01EventWireParts, + keys: &Keys, +) -> Result<RadrootsEventEnvelope, Box<dyn std::error::Error>> { let tags = parts .tags .into_iter() .map(Tag::parse) .collect::<Result<Vec<_>, _>>()?; - let keys = Keys::parse(SECRET_KEY_HEX)?; let event = EventBuilder::new(Kind::Custom(parts.kind as u16), parts.content) .tags(tags) .custom_created_at(Timestamp::from_secs(u64::from(CREATED_AT))) - .sign_with_keys(&keys)?; + .sign_with_keys(keys)?; Ok(RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { id: event.id.to_hex(), author: event.pubkey.to_hex(), @@ -169,13 +171,6 @@ fn sign_parts(parts: RadrootsNip01EventWireParts) -> Result<RadrootsEventEnvelop })?) } -fn public_key_hex() -> String { - Keys::parse(SECRET_KEY_HEX) - .expect("keys") - .public_key() - .to_hex() -} - fn hex_64(character: char) -> String { character.to_string().repeat(64) } diff --git a/tools/sdk_xtask_import/src/wasm_declarations.rs b/tools/sdk_xtask_import/src/wasm_declarations.rs @@ -355,7 +355,6 @@ fn promise_of(type_expr: TypeScriptType) -> TypeScriptType { const EVENT_CODEC_FUNCTIONS: &[WasmPublicFunction] = &[ public_function!("article_tags", Some("article_json"), String, 2, 4), - public_function!("comment_tags", Some("comment_json"), String, 2, 4), public_function!("coop_tags", Some("coop_json"), String, 2, 4), public_function!("document_tags", Some("document_json"), String, 2, 4), public_function!("farm_crdt_change_tags", Some("input_json"), String, 2, 4), @@ -408,17 +407,56 @@ const EVENT_CODEC_FUNCTIONS: &[WasmPublicFunction] = &[ public_function!("knowledge_source_tags", Some("source_json"), String, 2, 4), public_function!("list_set_tags", Some("list_json"), String, 2, 4), public_function!("list_tags", Some("list_json"), String, 2, 4), - public_function!("listing_tags", Some("listing_json"), String, 2, 4), - public_function!("listing_tags_full", Some("listing_json"), String, 2, 4), + public_function!( + "operational_listing_tags", + Some("listing_json"), + String, + 2, + 4 + ), + public_function!( + "operational_listing_tags_full", + Some("listing_json"), + String, + 2, + 4 + ), public_function!("message_file_tags", Some("message_json"), String, 2, 4), public_function!("message_tags", Some("message_json"), String, 2, 4), public_function!("plot_tags", Some("plot_json"), String, 2, 4), - public_function!("post_tags", Some("post_json"), String, 2, 4), public_function!("reaction_tags", Some("reaction_json"), String, 2, 4), public_function!("relay_auth_tags", Some("auth_json"), String, 2, 4), public_function!("report_tags", Some("report_json"), String, 2, 4), public_function!("repost_tags", Some("repost_json"), String, 2, 4), public_function!("seal_tags", Some("seal_json"), String, 2, 4), + public_function!( + "social_ask_build_authored_draft", + Some("input_json"), + String, + 2, + 4 + ), + public_function!( + "social_comment_build_authored_draft", + Some("input_json"), + String, + 2, + 4 + ), + public_function!( + "social_photo_update_build_authored_draft", + Some("input_json"), + String, + 2, + 4 + ), + public_function!( + "social_update_build_authored_draft", + Some("input_json"), + String, + 2, + 4 + ), public_function!("wiki_article_tags", Some("article_json"), String, 2, 4), public_function!( "wiki_merge_request_tags", @@ -1013,12 +1051,33 @@ mod tests { fn generated_declarations_include_public_wasm_entrypoints() { let specs = wasm_package_specs(); let events = declaration_files(specs[0]).expect("event declarations"); - assert!(events[0].contents.contains("export function listing_tags")); + assert!( + events[0] + .contents + .contains("export function operational_listing_tags") + ); assert!( events[1] .contents - .contains("export declare const listing_tags") + .contains("export declare const operational_listing_tags") ); + for authored in [ + "social_ask_build_authored_draft", + "social_comment_build_authored_draft", + "social_photo_update_build_authored_draft", + "social_update_build_authored_draft", + ] { + assert!( + events[0] + .contents + .contains(&format!("export function {authored}")) + ); + assert!( + events[1] + .contents + .contains(&format!("export declare const {authored}")) + ); + } for retired in [ "calendar_date_event_tags", "calendar_event_rsvp_tags", @@ -1028,6 +1087,18 @@ mod tests { assert!(!events[0].contents.contains(retired)); assert!(!events[1].contents.contains(retired)); } + for retired in ["comment_tags", "post_tags"] { + assert!( + !events[0] + .contents + .contains(&format!("export function {retired}(")) + ); + assert!( + !events[1] + .contents + .contains(&format!("export declare const {retired}:")) + ); + } let replica_store = declaration_files(specs[1]).expect("replica store declarations"); assert!(