lib

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

commit 515ef6828d9b8fd7eee655db29a822e42b0f028a
parent 6d0441a8b62df67a0744f125e5f95c8cf3c6397c
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 02:15:30 -0700

trade: canonicalize listing drafts

- add authority-backed listing draft canonicalization
- derive deterministic draft addresses from seller actors
- reject mismatched sellers and invalid bin state
- validate with cargo fmt, check, and tests for trade and authority

Diffstat:
MCargo.lock | 1+
Mcrates/trade/Cargo.toml | 8+++++++-
Mcrates/trade/src/listing/draft.rs | 177++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/trade/src/listing/mod.rs | 1+
4 files changed, 185 insertions(+), 2 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4497,6 +4497,7 @@ version = "0.1.0-alpha.2" dependencies = [ "base64 0.22.1", "hex", + "radroots_authority", "radroots_core", "radroots_event_store", "radroots_events", diff --git a/crates/trade/Cargo.toml b/crates/trade/Cargo.toml @@ -14,7 +14,12 @@ readme = "README" [features] default = ["std", "serde", "serde_json"] -std = ["radroots_core/std", "radroots_events/std", "radroots_events_codec/std"] +std = [ + "radroots_authority/std", + "radroots_core/std", + "radroots_events/std", + "radroots_events_codec/std", +] event_store = [ "std", "serde_json", @@ -38,6 +43,7 @@ serde_json = [ ] [dependencies] +radroots_authority = { workspace = true, default-features = false } radroots_core = { workspace = true, default-features = false } radroots_events = { workspace = true, default-features = false } radroots_events_codec = { workspace = true, default-features = false } diff --git a/crates/trade/src/listing/draft.rs b/crates/trade/src/listing/draft.rs @@ -1,7 +1,18 @@ #![forbid(unsafe_code)] +#[cfg(not(feature = "std"))] +use alloc::{format, string::ToString, vec::Vec}; + +#[cfg(feature = "std")] +use std::{string::ToString, vec::Vec}; + +use radroots_authority::RadrootsActorContext; use radroots_events::{ - ids::{RadrootsIdParseError, RadrootsListingAddress, RadrootsPublicKey}, + contract::RadrootsActorRole, + ids::{ + RadrootsIdParseError, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsPublicKey, + }, + kinds::KIND_LISTING_DRAFT, listing::RadrootsListing, }; use thiserror::Error; @@ -46,15 +57,92 @@ pub enum RadrootsListingDraftError { InvalidSellerPubkey(RadrootsIdParseError), #[error("invalid listing draft address: {0}")] InvalidListingAddress(RadrootsIdParseError), + #[error("listing draft actor does not satisfy required role {required_role:?}")] + ActorRoleUnsatisfied { required_role: RadrootsActorRole }, + #[error("listing draft farm pubkey does not match seller")] + FarmPubkeyMismatch { + expected_pubkey: RadrootsPublicKey, + actual_pubkey: RadrootsPublicKey, + }, + #[error("listing draft primary bin is missing")] + MissingPrimaryBin { + primary_bin_id: RadrootsInventoryBinId, + }, + #[error("listing draft contains duplicate bin ID")] + DuplicateBinId { bin_id: RadrootsInventoryBinId }, +} + +pub fn canonicalize_listing_draft( + actor: &RadrootsActorContext, + mut document: RadrootsListingDraftDocumentV1, +) -> Result<RadrootsCanonicalListingDraft, RadrootsListingDraftError> { + if !actor.satisfies(RadrootsActorRole::Seller) { + return Err(RadrootsListingDraftError::ActorRoleUnsatisfied { + required_role: RadrootsActorRole::Seller, + }); + } + + let seller_pubkey = actor.pubkey().clone(); + let farm_pubkey = document.listing.farm.pubkey.as_str(); + if farm_pubkey.is_empty() { + document.listing.farm.pubkey = seller_pubkey.as_str().to_string(); + } else { + let farm_pubkey = RadrootsPublicKey::parse(farm_pubkey) + .map_err(RadrootsListingDraftError::InvalidSellerPubkey)?; + if farm_pubkey != seller_pubkey { + return Err(RadrootsListingDraftError::FarmPubkeyMismatch { + expected_pubkey: seller_pubkey, + actual_pubkey: farm_pubkey, + }); + } + document.listing.farm.pubkey = farm_pubkey.as_str().to_string(); + } + + let primary_bin_id = document.listing.primary_bin_id.clone(); + let mut seen_bin_ids = Vec::new(); + let mut primary_bin_found = false; + for bin in &document.listing.bins { + if seen_bin_ids + .iter() + .any(|seen_bin_id| seen_bin_id == &bin.bin_id) + { + return Err(RadrootsListingDraftError::DuplicateBinId { + bin_id: bin.bin_id.clone(), + }); + } + if bin.bin_id == primary_bin_id { + primary_bin_found = true; + } + seen_bin_ids.push(bin.bin_id.clone()); + } + + if !primary_bin_found { + return Err(RadrootsListingDraftError::MissingPrimaryBin { primary_bin_id }); + } + + let listing_addr = RadrootsListingAddress::parse(format!( + "{KIND_LISTING_DRAFT}:{}:{}", + seller_pubkey.as_str(), + document.listing.d_tag.as_str() + )) + .map_err(RadrootsListingDraftError::InvalidListingAddress)?; + + Ok(RadrootsCanonicalListingDraft::new( + seller_pubkey, + listing_addr, + document, + )) } #[cfg(test)] mod tests { + use radroots_authority::RadrootsActorContext; use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, }; use radroots_events::{ + contract::RadrootsActorRole, farm::RadrootsFarmRef, ids::{RadrootsDTag, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsPublicKey}, kinds::KIND_LISTING_DRAFT, @@ -63,9 +151,11 @@ mod tests { use super::{ RadrootsCanonicalListingDraft, RadrootsListingDraftDocumentV1, RadrootsListingDraftError, + canonicalize_listing_draft, }; const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const OTHER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; fn d_tag(raw: &str) -> RadrootsDTag { RadrootsDTag::parse(raw).expect("d tag") @@ -128,6 +218,14 @@ mod tests { } } + fn seller_actor() -> RadrootsActorContext { + RadrootsActorContext::with_roles(SELLER, [RadrootsActorRole::Seller]).expect("actor") + } + + fn buyer_actor() -> RadrootsActorContext { + RadrootsActorContext::with_roles(SELLER, [RadrootsActorRole::Buyer]).expect("actor") + } + #[test] fn draft_document_wraps_listing() { let document = RadrootsListingDraftDocumentV1::new(listing()); @@ -174,4 +272,81 @@ mod tests { RadrootsListingDraftError::InvalidListingAddress(_) )); } + + #[test] + fn canonicalize_listing_draft_fills_missing_farm_pubkey_and_derives_address() { + let mut listing = listing(); + listing.farm.pubkey.clear(); + let document = RadrootsListingDraftDocumentV1::new(listing); + + let canonical = + canonicalize_listing_draft(&seller_actor(), document).expect("canonical draft"); + + assert_eq!(canonical.seller_pubkey.as_str(), SELLER); + assert_eq!( + canonical.listing_addr.as_str(), + format!("{KIND_LISTING_DRAFT}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg") + ); + assert_eq!(canonical.document.listing.farm.pubkey, SELLER); + } + + #[test] + fn canonicalize_listing_draft_rejects_non_seller_actor() { + let document = RadrootsListingDraftDocumentV1::new(listing()); + + let error = canonicalize_listing_draft(&buyer_actor(), document).unwrap_err(); + + assert_eq!( + error, + RadrootsListingDraftError::ActorRoleUnsatisfied { + required_role: RadrootsActorRole::Seller + } + ); + } + + #[test] + fn canonicalize_listing_draft_rejects_mismatched_farm_pubkey() { + let mut listing = listing(); + listing.farm.pubkey = OTHER.to_string(); + let document = RadrootsListingDraftDocumentV1::new(listing); + + let error = canonicalize_listing_draft(&seller_actor(), document).unwrap_err(); + + assert!(matches!( + error, + RadrootsListingDraftError::FarmPubkeyMismatch { .. } + )); + } + + #[test] + fn canonicalize_listing_draft_rejects_missing_primary_bin() { + let mut listing = listing(); + listing.primary_bin_id = bin_id("bin-2"); + let document = RadrootsListingDraftDocumentV1::new(listing); + + let error = canonicalize_listing_draft(&seller_actor(), document).unwrap_err(); + + assert_eq!( + error, + RadrootsListingDraftError::MissingPrimaryBin { + primary_bin_id: bin_id("bin-2") + } + ); + } + + #[test] + fn canonicalize_listing_draft_rejects_duplicate_bin_ids() { + let mut listing = listing(); + listing.bins.push(listing.bins[0].clone()); + let document = RadrootsListingDraftDocumentV1::new(listing); + + let error = canonicalize_listing_draft(&seller_actor(), document).unwrap_err(); + + assert_eq!( + error, + RadrootsListingDraftError::DuplicateBinId { + bin_id: bin_id("bin-1") + } + ); + } } diff --git a/crates/trade/src/listing/mod.rs b/crates/trade/src/listing/mod.rs @@ -9,6 +9,7 @@ use radroots_events::{RadrootsNostrEvent, kinds::is_listing_kind, listing::Radro pub use self::draft::{ RadrootsCanonicalListingDraft, RadrootsListingDraftDocumentV1, RadrootsListingDraftError, + canonicalize_listing_draft, }; pub use radroots_events::order::RadrootsListingParseError as ListingParseError;