listing.rs (121224B)
1 use std::collections::HashMap; 2 use std::fs; 3 use std::path::{Path, PathBuf}; 4 use std::sync::atomic::{AtomicU64, Ordering}; 5 use std::time::{SystemTime, UNIX_EPOCH}; 6 7 use radroots_authority::RadrootsActorContext; 8 use radroots_core::{ 9 RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountScope, 10 RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreMoney, 11 RadrootsCorePercent, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, 12 }; 13 use radroots_events::RadrootsNostrEvent; 14 use radroots_events::contract::RadrootsActorRole; 15 use radroots_events::farm::RadrootsFarmRef; 16 use radroots_events::ids::{RadrootsDTag, RadrootsInventoryBinId}; 17 use radroots_events::kinds::{KIND_LISTING, KIND_LISTING_DRAFT}; 18 use radroots_events::listing::{ 19 RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, 20 RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingProduct, 21 RadrootsListingStatus, 22 }; 23 use radroots_events::trade_validation::RadrootsTradeValidationListingError; 24 use radroots_events_codec::d_tag::is_d_tag_base64url; 25 use radroots_events_codec::listing::encode::to_wire_parts_with_kind; 26 use radroots_local_events::{LocalEventRecord, LocalRecordFamily, SourceRuntime}; 27 use radroots_replica_db::ReplicaSql; 28 use radroots_sdk::{ 29 ListingEnqueuePublishRequest, ListingEnqueueReceipt, ListingPreparePublishRequest, 30 ListingPublishPlan, PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, 31 PushOutboxRelayOutcomeKind, PushOutboxRequest, SdkMutationState, 32 }; 33 use radroots_sql_core::SqliteExecutor; 34 use radroots_trade::listing::{RadrootsListingDraftDocumentV1, validation::validate_listing_event}; 35 use serde::{Deserialize, Serialize}; 36 use serde_json::{Value, json}; 37 38 use crate::cli::global::{ 39 ListingAppRecordExportArgs, ListingCreateArgs, ListingFileArgs, ListingMutationArgs, 40 ListingRebindArgs, RecordLookupArgs, 41 }; 42 use crate::runtime::RuntimeError; 43 use crate::runtime::account; 44 use crate::runtime::config::RuntimeConfig; 45 use crate::runtime::farm_config; 46 use crate::runtime::local_events::{ 47 append_local_work, get_shared_record, list_shared_records_before, list_shared_records_latest, 48 shared_local_events_db_path, 49 }; 50 use crate::runtime::sdk::{ 51 CliSdkAdapterError, CliSdkSession, sdk_relay_target_policy, sdk_relay_url_policy, 52 validate_configured_signer_for_actor, 53 }; 54 use crate::runtime::sync::{ 55 RelayIngestScope, freshness_for_scope_from_executor, market_refresh, missing_freshness, 56 }; 57 use crate::view::runtime::{ 58 FindPriceView, FindQuantityView, FindResultProvenanceView, ListingAppRecordExportView, 59 ListingAppRecordListView, ListingAppRecordSummaryView, ListingGetView, ListingListView, 60 ListingMutationEventView, ListingMutationView, ListingNewView, ListingRebindView, 61 ListingSummaryView, ListingValidateView, ListingValidationIssueView, MarketReadinessView, 62 RelayFailureView, 63 }; 64 65 const DRAFT_KIND: &str = "listing_draft_v1"; 66 const LISTING_SOURCE: &str = "local draft · local first"; 67 const LISTING_READ_SOURCE: &str = "local replica · local first"; 68 const LISTING_APP_RECORD_SOURCE: &str = "shared local events · app"; 69 const SDK_LISTING_WRITE_SOURCE: &str = "SDK listing publish · configured signer"; 70 const LISTING_DRAFTS_DIR: &str = "listings/drafts"; 71 const LISTING_SELLER_ACTOR_SOURCE_FARM_CONFIG: &str = "farm_config"; 72 const LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT: &str = "resolved_account"; 73 const LISTING_SELLER_ACTOR_SOURCE_REBIND: &str = "listing_rebind"; 74 const CANONICAL_OWNER_PUBKEY_REQUIRED_REASON: &str = "canonical hex pubkey required before export"; 75 const APP_RECORD_LIST_LIMIT: u32 = 500; 76 77 static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0); 78 79 fn protocol_d_tag(value: &str, field: &str) -> Result<RadrootsDTag, RuntimeError> { 80 value 81 .parse() 82 .map_err(|error| RuntimeError::Config(format!("{field} is not a valid d tag: {error}"))) 83 } 84 85 fn protocol_inventory_bin_id( 86 value: &str, 87 field: &str, 88 ) -> Result<RadrootsInventoryBinId, RuntimeError> { 89 value.parse().map_err(|error| { 90 RuntimeError::Config(format!("{field} is not a valid inventory bin id: {error}")) 91 }) 92 } 93 94 #[derive(Debug, Clone, Serialize, Deserialize)] 95 #[serde(deny_unknown_fields)] 96 struct ListingDraftDocument { 97 version: u32, 98 kind: String, 99 listing: ListingDraftMeta, 100 seller_actor: ListingDraftSellerActor, 101 product: ListingDraftProduct, 102 primary_bin: ListingDraftPrimaryBin, 103 inventory: ListingDraftInventory, 104 availability: ListingDraftAvailability, 105 delivery: ListingDraftDelivery, 106 location: ListingDraftLocation, 107 #[serde(default, skip_serializing_if = "Vec::is_empty")] 108 discounts: Vec<ListingDraftDiscount>, 109 } 110 111 #[derive(Debug, Clone, Serialize, Deserialize)] 112 #[serde(deny_unknown_fields)] 113 struct ListingDraftMeta { 114 d_tag: String, 115 farm_d_tag: String, 116 } 117 118 #[derive(Debug, Clone, Serialize, Deserialize)] 119 #[serde(deny_unknown_fields)] 120 struct ListingDraftSellerActor { 121 account_id: String, 122 pubkey: String, 123 source: String, 124 } 125 126 #[derive(Debug, Clone, Serialize, Deserialize)] 127 #[serde(deny_unknown_fields)] 128 struct ListingDraftProduct { 129 key: String, 130 title: String, 131 category: String, 132 summary: String, 133 } 134 135 #[derive(Debug, Clone, Serialize, Deserialize)] 136 #[serde(deny_unknown_fields)] 137 struct ListingDraftPrimaryBin { 138 bin_id: String, 139 quantity_amount: String, 140 quantity_unit: String, 141 price_amount: String, 142 price_currency: String, 143 price_per_amount: String, 144 price_per_unit: String, 145 #[serde(default, skip_serializing_if = "String::is_empty")] 146 label: String, 147 } 148 149 #[derive(Debug, Clone, Serialize, Deserialize)] 150 #[serde(deny_unknown_fields)] 151 struct ListingDraftInventory { 152 available: String, 153 } 154 155 #[derive(Debug, Clone, Serialize, Deserialize)] 156 #[serde(deny_unknown_fields)] 157 struct ListingDraftAvailability { 158 #[serde(default, skip_serializing_if = "String::is_empty")] 159 kind: String, 160 #[serde(default, skip_serializing_if = "String::is_empty")] 161 status: String, 162 #[serde(default, skip_serializing_if = "Option::is_none")] 163 start: Option<u64>, 164 #[serde(default, skip_serializing_if = "Option::is_none")] 165 end: Option<u64>, 166 } 167 168 #[derive(Debug, Clone, Serialize, Deserialize)] 169 #[serde(deny_unknown_fields)] 170 struct ListingDraftDelivery { 171 method: String, 172 } 173 174 #[derive(Debug, Clone, Serialize, Deserialize)] 175 #[serde(deny_unknown_fields)] 176 struct ListingDraftLocation { 177 primary: String, 178 #[serde(default, skip_serializing_if = "Option::is_none")] 179 city: Option<String>, 180 #[serde(default, skip_serializing_if = "Option::is_none")] 181 region: Option<String>, 182 #[serde(default, skip_serializing_if = "Option::is_none")] 183 country: Option<String>, 184 } 185 186 #[derive(Debug, Clone, Serialize, Deserialize)] 187 #[serde(deny_unknown_fields)] 188 struct ListingDraftDiscount { 189 id: String, 190 #[serde(default, skip_serializing_if = "String::is_empty")] 191 label: String, 192 kind: String, 193 #[serde(default, skip_serializing_if = "String::is_empty")] 194 value: String, 195 #[serde(default, skip_serializing_if = "String::is_empty")] 196 amount: String, 197 #[serde(default, skip_serializing_if = "String::is_empty")] 198 currency: String, 199 #[serde(default, skip_serializing_if = "Option::is_none")] 200 bin_id: Option<String>, 201 #[serde(default, skip_serializing_if = "Option::is_none")] 202 min_bin_count: Option<u32>, 203 } 204 205 #[derive(Debug, Clone)] 206 struct ListingValidationContext { 207 farm_setup_action: String, 208 } 209 210 #[derive(Debug, Clone)] 211 struct ListingAuthoringDefaults { 212 farm_config_present: bool, 213 farm_defaults_ready: bool, 214 farm_next_action: Option<String>, 215 farm_reason: Option<String>, 216 farm_name: Option<String>, 217 seller_account_id: String, 218 seller_pubkey: String, 219 seller_actor_source: String, 220 selected_farm_d_tag: Option<String>, 221 delivery_method: Option<String>, 222 location: Option<ListingDraftLocation>, 223 } 224 225 #[derive(Debug, Clone)] 226 struct CanonicalListingDraft { 227 listing_id: String, 228 seller_account_id: String, 229 seller_pubkey: String, 230 seller_actor_source: String, 231 farm_d_tag: String, 232 listing: RadrootsListing, 233 } 234 235 #[derive(Debug, Clone)] 236 struct SdkListingPublishInput { 237 canonical: CanonicalListingDraft, 238 actor: RadrootsActorContext, 239 document: RadrootsListingDraftDocumentV1, 240 } 241 242 #[derive(Debug, Clone)] 243 struct LoadedListingDraft { 244 file: PathBuf, 245 updated_at_unix: u64, 246 contents: String, 247 document: ListingDraftDocument, 248 } 249 250 #[derive(Debug, Clone)] 251 enum ListingDraftValidationError { 252 Issue(ListingValidationIssueView), 253 MissingSellerAccount(ListingValidationIssueView), 254 } 255 256 impl ListingDraftValidationError { 257 fn into_issue(self) -> ListingValidationIssueView { 258 match self { 259 Self::Issue(issue) | Self::MissingSellerAccount(issue) => issue, 260 } 261 } 262 } 263 264 impl From<ListingValidationIssueView> for ListingDraftValidationError { 265 fn from(issue: ListingValidationIssueView) -> Self { 266 Self::Issue(issue) 267 } 268 } 269 270 #[derive(Debug, Clone, Copy)] 271 pub enum ListingMutationOperation { 272 Publish, 273 Update, 274 Archive, 275 } 276 277 impl ListingMutationOperation { 278 fn as_str(self) -> &'static str { 279 match self { 280 Self::Publish => "publish", 281 Self::Update => "update", 282 Self::Archive => "archive", 283 } 284 } 285 } 286 287 pub fn scaffold( 288 config: &RuntimeConfig, 289 args: &ListingCreateArgs, 290 ) -> Result<ListingNewView, RuntimeError> { 291 let (draft, defaults) = build_listing_draft(config, args)?; 292 let output_path = listing_output_path(config, args.output.as_ref(), &draft.listing.d_tag)?; 293 write_listing_draft(&output_path, &draft, false)?; 294 append_listing_local_work(config, output_path.as_path(), &draft)?; 295 296 let mut actions = vec![format!( 297 "radroots listing validate {}", 298 output_path.display() 299 )]; 300 if let Some(action) = &defaults.farm_next_action { 301 actions.push(action.clone()); 302 } 303 304 Ok(ListingNewView { 305 state: "draft created".to_owned(), 306 source: LISTING_SOURCE.to_owned(), 307 file: output_path.display().to_string(), 308 listing_id: draft.listing.d_tag, 309 key: non_empty(draft.product.key.clone()), 310 seller_account_id: Some(defaults.seller_account_id), 311 seller_pubkey: Some(defaults.seller_pubkey), 312 seller_actor_source: Some(defaults.seller_actor_source), 313 farm_d_tag: defaults.selected_farm_d_tag, 314 delivery_method: non_empty(draft.delivery.method.clone()), 315 location_primary: non_empty(draft.location.primary.clone()), 316 reason: defaults.farm_reason, 317 actions, 318 }) 319 } 320 321 pub fn scaffold_preflight( 322 config: &RuntimeConfig, 323 args: &ListingCreateArgs, 324 ) -> Result<ListingNewView, RuntimeError> { 325 let (draft, defaults) = build_listing_draft(config, args)?; 326 let output_path = listing_output_path(config, args.output.as_ref(), &draft.listing.d_tag)?; 327 validate_listing_output_target(&output_path)?; 328 329 let mut actions = vec![format!( 330 "radroots listing validate {}", 331 output_path.display() 332 )]; 333 if let Some(action) = &defaults.farm_next_action { 334 actions.push(action.clone()); 335 } 336 337 Ok(ListingNewView { 338 state: "dry_run".to_owned(), 339 source: LISTING_SOURCE.to_owned(), 340 file: output_path.display().to_string(), 341 listing_id: draft.listing.d_tag, 342 key: non_empty(draft.product.key.clone()), 343 seller_account_id: Some(defaults.seller_account_id), 344 seller_pubkey: Some(defaults.seller_pubkey), 345 seller_actor_source: Some(defaults.seller_actor_source), 346 farm_d_tag: defaults.selected_farm_d_tag, 347 delivery_method: non_empty(draft.delivery.method.clone()), 348 location_primary: non_empty(draft.location.primary.clone()), 349 reason: Some("dry run requested; listing draft was not written".to_owned()), 350 actions, 351 }) 352 } 353 354 fn build_listing_draft( 355 config: &RuntimeConfig, 356 args: &ListingCreateArgs, 357 ) -> Result<(ListingDraftDocument, ListingAuthoringDefaults), RuntimeError> { 358 let defaults = authoring_defaults(config)?; 359 let quantity_unit = args.quantity_unit.clone().unwrap_or_else(|| "g".to_owned()); 360 let draft = ListingDraftDocument { 361 version: 1, 362 kind: DRAFT_KIND.to_owned(), 363 listing: ListingDraftMeta { 364 d_tag: generate_d_tag(), 365 farm_d_tag: defaults.selected_farm_d_tag.clone().unwrap_or_default(), 366 }, 367 seller_actor: ListingDraftSellerActor { 368 account_id: defaults.seller_account_id.clone(), 369 pubkey: defaults.seller_pubkey.clone(), 370 source: defaults.seller_actor_source.clone(), 371 }, 372 product: ListingDraftProduct { 373 key: args.key.clone().unwrap_or_default(), 374 title: args.title.clone().unwrap_or_default(), 375 category: args.category.clone().unwrap_or_default(), 376 summary: args.summary.clone().unwrap_or_default(), 377 }, 378 primary_bin: ListingDraftPrimaryBin { 379 bin_id: args.bin_id.clone().unwrap_or_else(|| "bin-1".to_owned()), 380 quantity_amount: args 381 .quantity_amount 382 .clone() 383 .unwrap_or_else(|| "1000".to_owned()), 384 quantity_unit: quantity_unit.clone(), 385 price_amount: args 386 .price_amount 387 .clone() 388 .unwrap_or_else(|| "0.01".to_owned()), 389 price_currency: args 390 .price_currency 391 .clone() 392 .unwrap_or_else(|| "USD".to_owned()), 393 price_per_amount: args 394 .price_per_amount 395 .clone() 396 .unwrap_or_else(|| "1".to_owned()), 397 price_per_unit: args 398 .price_per_unit 399 .clone() 400 .unwrap_or_else(|| quantity_unit.clone()), 401 label: args.label.clone().unwrap_or_default(), 402 }, 403 inventory: ListingDraftInventory { 404 available: args.available.clone().unwrap_or_else(|| "1".to_owned()), 405 }, 406 availability: ListingDraftAvailability { 407 kind: "status".to_owned(), 408 status: "active".to_owned(), 409 start: None, 410 end: None, 411 }, 412 delivery: ListingDraftDelivery { 413 method: defaults.delivery_method.clone().unwrap_or_default(), 414 }, 415 location: defaults.location.clone().unwrap_or(ListingDraftLocation { 416 primary: String::new(), 417 city: None, 418 region: None, 419 country: None, 420 }), 421 discounts: listing_discount_drafts_from_args(args), 422 }; 423 Ok((draft, defaults)) 424 } 425 426 fn listing_discount_drafts_from_args(args: &ListingCreateArgs) -> Vec<ListingDraftDiscount> { 427 let has_discount = args.discount_id.is_some() 428 || args.discount_label.is_some() 429 || args.discount_kind.is_some() 430 || args.discount_value.is_some() 431 || args.discount_amount.is_some() 432 || args.discount_currency.is_some(); 433 if !has_discount { 434 return Vec::new(); 435 } 436 let kind = args.discount_kind.clone().unwrap_or_else(|| { 437 if args.discount_amount.is_some() { 438 "amount".to_owned() 439 } else { 440 "percent".to_owned() 441 } 442 }); 443 vec![ListingDraftDiscount { 444 id: args 445 .discount_id 446 .clone() 447 .unwrap_or_else(|| "discount_1".to_owned()), 448 label: args.discount_label.clone().unwrap_or_default(), 449 kind, 450 value: args.discount_value.clone().unwrap_or_default(), 451 amount: args.discount_amount.clone().unwrap_or_default(), 452 currency: args.discount_currency.clone().unwrap_or_default(), 453 bin_id: None, 454 min_bin_count: None, 455 }] 456 } 457 458 fn listing_output_path( 459 config: &RuntimeConfig, 460 explicit: Option<&std::path::PathBuf>, 461 listing_id: &str, 462 ) -> Result<std::path::PathBuf, RuntimeError> { 463 match explicit { 464 Some(path) => Ok(path.clone()), 465 None => Ok(drafts_dir(config).join(format!("{listing_id}.toml"))), 466 } 467 } 468 469 fn write_listing_draft( 470 output_path: &Path, 471 draft: &ListingDraftDocument, 472 overwrite: bool, 473 ) -> Result<(), RuntimeError> { 474 if !overwrite { 475 validate_listing_output_target(output_path)?; 476 } 477 if let Some(parent) = output_path.parent() { 478 fs::create_dir_all(parent)?; 479 } 480 fs::write(output_path, scaffold_contents(draft)?)?; 481 Ok(()) 482 } 483 484 fn append_listing_local_work( 485 config: &RuntimeConfig, 486 path: &Path, 487 draft: &ListingDraftDocument, 488 ) -> Result<(), RuntimeError> { 489 let listing_id = draft.listing.d_tag.trim(); 490 let seller_pubkey = draft.seller_actor.pubkey.trim(); 491 let listing_addr = if seller_pubkey.is_empty() || listing_id.is_empty() { 492 None 493 } else { 494 Some(listing_addr(seller_pubkey, listing_id)) 495 }; 496 let payload = json!({ 497 "record_kind": DRAFT_KIND, 498 "path": path.display().to_string(), 499 "document": draft, 500 }); 501 let subject = format!("listing:{}", draft.listing.d_tag); 502 append_local_work( 503 config, 504 subject.as_str(), 505 non_empty(draft.seller_actor.account_id.clone()), 506 non_empty(draft.seller_actor.pubkey.clone()), 507 non_empty(draft.listing.farm_d_tag.clone()), 508 listing_addr, 509 payload, 510 )?; 511 Ok(()) 512 } 513 514 fn validate_listing_output_target(output_path: &Path) -> Result<(), RuntimeError> { 515 if output_path.exists() { 516 return Err(RuntimeError::Config(format!( 517 "listing draft output {} must not already exist", 518 output_path.display() 519 ))); 520 } 521 if let Some(parent) = output_path.parent() { 522 if parent.exists() && !parent.is_dir() { 523 return Err(RuntimeError::Config(format!( 524 "listing draft parent {} is not a directory", 525 parent.display() 526 ))); 527 } 528 } 529 Ok(()) 530 } 531 532 pub fn validate( 533 config: &RuntimeConfig, 534 args: &ListingFileArgs, 535 ) -> Result<ListingValidateView, RuntimeError> { 536 let contents = fs::read_to_string(&args.file)?; 537 let context = validation_context(config)?; 538 539 let parsed = match toml::from_str::<ListingDraftDocument>(&contents) { 540 Ok(parsed) => parsed, 541 Err(error) => { 542 return Ok(ListingValidateView { 543 state: "invalid".to_owned(), 544 source: LISTING_SOURCE.to_owned(), 545 file: args.file.display().to_string(), 546 valid: false, 547 listing_id: None, 548 seller_account_id: None, 549 seller_pubkey: None, 550 seller_actor_source: None, 551 farm_d_tag: None, 552 issues: vec![ListingValidationIssueView { 553 field: "toml".to_owned(), 554 message: error.to_string(), 555 line: error 556 .span() 557 .map(|span| line_for_offset(&contents, span.start + 1)), 558 }], 559 actions: vec![format!("edit {}", args.file.display())], 560 }); 561 } 562 }; 563 564 match canonicalize_draft(&parsed, &contents, &context) { 565 Ok(canonical) => { 566 let parts = match to_wire_parts_with_kind(&canonical.listing, KIND_LISTING_DRAFT) { 567 Ok(parts) => parts, 568 Err(error) => { 569 return Ok(invalid_validation_view( 570 args.file.as_path(), 571 &parsed, 572 &context, 573 ListingValidationIssueView { 574 field: "listing".to_owned(), 575 message: format!("invalid listing contract: {error}"), 576 line: None, 577 }, 578 )); 579 } 580 }; 581 if let Some(issue) = listing_bound_account_issue(config, &canonical, &contents)? { 582 return Ok(invalid_validation_view( 583 args.file.as_path(), 584 &parsed, 585 &context, 586 issue, 587 )); 588 } 589 let event = RadrootsNostrEvent { 590 id: String::new(), 591 author: canonical.seller_pubkey.clone(), 592 created_at: 0, 593 kind: KIND_LISTING_DRAFT, 594 tags: parts.tags, 595 content: parts.content, 596 sig: String::new(), 597 }; 598 match validate_listing_event(&event) { 599 Ok(_) => Ok(ListingValidateView { 600 state: "valid".to_owned(), 601 source: LISTING_SOURCE.to_owned(), 602 file: args.file.display().to_string(), 603 valid: true, 604 listing_id: Some(canonical.listing_id), 605 seller_account_id: Some(canonical.seller_account_id), 606 seller_pubkey: Some(canonical.seller_pubkey), 607 seller_actor_source: Some(canonical.seller_actor_source), 608 farm_d_tag: Some(canonical.farm_d_tag), 609 issues: Vec::new(), 610 actions: vec![format!("radroots listing publish {}", args.file.display())], 611 }), 612 Err(error) => Ok(invalid_validation_view( 613 args.file.as_path(), 614 &parsed, 615 &context, 616 issue_from_trade_validation(error, &contents), 617 )), 618 } 619 } 620 Err(error) => Ok(invalid_validation_view( 621 args.file.as_path(), 622 &parsed, 623 &context, 624 error.into_issue(), 625 )), 626 } 627 } 628 629 pub fn list(config: &RuntimeConfig) -> Result<ListingListView, RuntimeError> { 630 let dir = drafts_dir(config); 631 if !dir.exists() { 632 return Ok(ListingListView { 633 state: "empty".to_owned(), 634 source: LISTING_SOURCE.to_owned(), 635 count: 0, 636 draft_dir: dir.display().to_string(), 637 listings: Vec::new(), 638 actions: vec!["radroots listing create".to_owned()], 639 }); 640 } 641 642 let context = validation_context(config).map_err(|error| error.to_string()); 643 let mut listings = Vec::new(); 644 for entry in fs::read_dir(&dir)? { 645 let entry = entry?; 646 let path = entry.path(); 647 if path.extension().and_then(|value| value.to_str()) != Some("toml") { 648 continue; 649 } 650 match load_listing_draft(path.as_path()) { 651 Ok(loaded) => listings.push(summary_from_loaded(config, &loaded, context.as_ref())), 652 Err(issue) => listings.push(summary_for_invalid_file(path.as_path(), issue)), 653 } 654 } 655 656 listings.sort_by(|left, right| { 657 right 658 .updated_at_unix 659 .cmp(&left.updated_at_unix) 660 .then_with(|| left.id.cmp(&right.id)) 661 }); 662 663 let state = if listings.is_empty() { 664 "empty" 665 } else if listings.iter().any(|listing| listing.state == "error") { 666 "degraded" 667 } else { 668 "ready" 669 }; 670 let actions = if listings.is_empty() { 671 vec!["radroots listing create".to_owned()] 672 } else { 673 Vec::new() 674 }; 675 676 Ok(ListingListView { 677 state: state.to_owned(), 678 source: LISTING_SOURCE.to_owned(), 679 count: listings.len(), 680 draft_dir: dir.display().to_string(), 681 listings, 682 actions, 683 }) 684 } 685 686 pub fn app_record_list(config: &RuntimeConfig) -> Result<ListingAppRecordListView, RuntimeError> { 687 let database_path = shared_local_events_db_path(config)?; 688 let mut entries = current_app_record_entries(app_local_records(config)?); 689 let has_more = entries.len() > APP_RECORD_LIST_LIMIT as usize; 690 if has_more { 691 entries.truncate(APP_RECORD_LIST_LIMIT as usize); 692 } 693 let next_cursor = if has_more { 694 entries 695 .last() 696 .map(|entry| (entry.record.change_seq, entry.record.seq)) 697 } else { 698 None 699 }; 700 let records = entries 701 .iter() 702 .map(|entry| app_record_summary(&entry.record, entry.superseded_count)) 703 .collect::<Vec<_>>(); 704 let state = if records.is_empty() { "empty" } else { "ready" }; 705 let actions = if records.is_empty() { 706 vec!["create or save a farm listing in radroots_app".to_owned()] 707 } else { 708 Vec::new() 709 }; 710 711 Ok(ListingAppRecordListView { 712 state: state.to_owned(), 713 source: LISTING_APP_RECORD_SOURCE.to_owned(), 714 count: records.len(), 715 limit: APP_RECORD_LIST_LIMIT, 716 has_more, 717 next_before_change_seq: next_cursor.map(|(change_seq, _)| change_seq), 718 next_before_seq: next_cursor.map(|(_, seq)| seq), 719 local_events_db: database_path.display().to_string(), 720 records, 721 actions, 722 }) 723 } 724 725 pub fn app_record_export( 726 config: &RuntimeConfig, 727 args: &ListingAppRecordExportArgs, 728 ) -> Result<ListingAppRecordExportView, RuntimeError> { 729 let Some(record) = get_shared_record(config, args.record_id.as_str())? else { 730 return Ok(ListingAppRecordExportView { 731 state: "missing".to_owned(), 732 source: LISTING_APP_RECORD_SOURCE.to_owned(), 733 record_id: args.record_id.clone(), 734 dry_run: config.output.dry_run, 735 file: args 736 .output 737 .as_ref() 738 .map(|path| path.display().to_string()) 739 .unwrap_or_default(), 740 valid: false, 741 listing_id: None, 742 listing_addr: None, 743 seller_account_id: None, 744 seller_pubkey: None, 745 seller_actor_source: None, 746 farm_d_tag: None, 747 issues: Vec::new(), 748 reason: Some(format!( 749 "app-authored local record `{}` was not found", 750 args.record_id 751 )), 752 actions: vec!["radroots listing app list".to_owned()], 753 }); 754 }; 755 756 if let Some(current_record) = current_app_record_for(config, &record)? 757 && current_record.record_id != record.record_id 758 { 759 let (listing_id, title, farm_d_tag) = app_listing_display_parts(&record); 760 let current_action = format!("radroots listing app export {}", current_record.record_id); 761 return Ok(ListingAppRecordExportView { 762 state: "stale".to_owned(), 763 source: LISTING_APP_RECORD_SOURCE.to_owned(), 764 record_id: args.record_id.clone(), 765 dry_run: config.output.dry_run, 766 file: args 767 .output 768 .as_ref() 769 .map(|path| path.display().to_string()) 770 .unwrap_or_default(), 771 valid: false, 772 listing_id, 773 listing_addr: record.listing_addr.clone(), 774 seller_account_id: record.owner_account_id.clone(), 775 seller_pubkey: record.owner_pubkey.clone(), 776 seller_actor_source: None, 777 farm_d_tag: farm_d_tag.or(record.farm_id.clone()), 778 issues: vec![ListingValidationIssueView { 779 field: "record_id".to_owned(), 780 message: format!( 781 "app-authored local record `{}` was superseded by `{}`", 782 record.record_id, current_record.record_id 783 ), 784 line: None, 785 }], 786 reason: Some(format!( 787 "app-authored local record `{}` was superseded by current record `{}`{}", 788 record.record_id, 789 current_record.record_id, 790 title 791 .as_deref() 792 .map(|value| format!(" for `{value}`")) 793 .unwrap_or_default() 794 )), 795 actions: vec![current_action, "radroots listing app list".to_owned()], 796 }); 797 } 798 799 let draft = match app_listing_draft_from_record(&record) { 800 Ok(draft) => draft, 801 Err(reason) => { 802 return Ok(ListingAppRecordExportView { 803 state: "unsupported".to_owned(), 804 source: LISTING_APP_RECORD_SOURCE.to_owned(), 805 record_id: args.record_id.clone(), 806 dry_run: config.output.dry_run, 807 file: args 808 .output 809 .as_ref() 810 .map(|path| path.display().to_string()) 811 .unwrap_or_default(), 812 valid: false, 813 listing_id: None, 814 listing_addr: record.listing_addr.clone(), 815 seller_account_id: record.owner_account_id.clone(), 816 seller_pubkey: record.owner_pubkey.clone(), 817 seller_actor_source: None, 818 farm_d_tag: record.farm_id.clone(), 819 issues: vec![ListingValidationIssueView { 820 field: "local_work_json".to_owned(), 821 message: reason.clone(), 822 line: None, 823 }], 824 reason: Some(reason), 825 actions: vec!["radroots listing app list".to_owned()], 826 }); 827 } 828 }; 829 let output_path = listing_output_path(config, args.output.as_ref(), &draft.listing.d_tag)?; 830 validate_listing_output_target(output_path.as_path())?; 831 let contents = scaffold_contents(&draft)?; 832 let context = validation_context(config)?; 833 let issues = app_listing_export_issues(config, &draft, contents.as_str(), &context)?; 834 let listing_addr_value = app_record_listing_addr(&draft); 835 836 if !issues.is_empty() { 837 return Ok(ListingAppRecordExportView { 838 state: "invalid".to_owned(), 839 source: LISTING_APP_RECORD_SOURCE.to_owned(), 840 record_id: args.record_id.clone(), 841 dry_run: config.output.dry_run, 842 file: output_path.display().to_string(), 843 valid: false, 844 listing_id: non_empty(draft.listing.d_tag.clone()), 845 listing_addr: listing_addr_value, 846 seller_account_id: non_empty(draft.seller_actor.account_id.clone()), 847 seller_pubkey: non_empty(draft.seller_actor.pubkey.clone()), 848 seller_actor_source: non_empty(draft.seller_actor.source.clone()), 849 farm_d_tag: non_empty(draft.listing.farm_d_tag.clone()), 850 issues, 851 reason: Some(format!( 852 "app-authored local record `{}` does not validate as a CLI listing draft", 853 args.record_id 854 )), 855 actions: vec!["radroots listing app list".to_owned()], 856 }); 857 } 858 859 if !config.output.dry_run { 860 write_listing_draft(output_path.as_path(), &draft, false)?; 861 } 862 863 Ok(ListingAppRecordExportView { 864 state: if config.output.dry_run { 865 "dry_run" 866 } else { 867 "exported" 868 } 869 .to_owned(), 870 source: LISTING_APP_RECORD_SOURCE.to_owned(), 871 record_id: args.record_id.clone(), 872 dry_run: config.output.dry_run, 873 file: output_path.display().to_string(), 874 valid: true, 875 listing_id: Some(draft.listing.d_tag.clone()), 876 listing_addr: app_record_listing_addr(&draft), 877 seller_account_id: Some(draft.seller_actor.account_id.clone()), 878 seller_pubkey: Some(draft.seller_actor.pubkey.clone()), 879 seller_actor_source: Some(draft.seller_actor.source.clone()), 880 farm_d_tag: Some(draft.listing.farm_d_tag.clone()), 881 issues: Vec::new(), 882 reason: Some(if config.output.dry_run { 883 "dry run requested; listing draft was not written".to_owned() 884 } else { 885 "app-authored listing record exported as a CLI listing draft".to_owned() 886 }), 887 actions: vec![ 888 format!("radroots listing validate {}", output_path.display()), 889 format!("radroots listing publish {}", output_path.display()), 890 ], 891 }) 892 } 893 894 pub fn rebind( 895 config: &RuntimeConfig, 896 args: &ListingRebindArgs, 897 ) -> Result<ListingRebindView, RuntimeError> { 898 rebind_inner(config, args, false) 899 } 900 901 pub fn rebind_preflight( 902 config: &RuntimeConfig, 903 args: &ListingRebindArgs, 904 ) -> Result<ListingRebindView, RuntimeError> { 905 rebind_inner(config, args, true) 906 } 907 908 fn rebind_inner( 909 config: &RuntimeConfig, 910 args: &ListingRebindArgs, 911 dry_run: bool, 912 ) -> Result<ListingRebindView, RuntimeError> { 913 let contents = fs::read_to_string(&args.file)?; 914 let mut draft = toml::from_str::<ListingDraftDocument>(&contents).map_err(|error| { 915 RuntimeError::Config(format!( 916 "invalid listing draft {}: {error}", 917 args.file.display() 918 )) 919 })?; 920 let listing_id = draft.listing.d_tag.trim().to_owned(); 921 if !is_d_tag_base64url(&listing_id) { 922 return Err(RuntimeError::Config(format!( 923 "invalid listing draft {}: listing d_tag must be a 22-character base64url identifier", 924 args.file.display() 925 ))); 926 } 927 928 let target_account = account::resolve_account_selector(config, args.selector.as_str()) 929 .map_err(|error| listing_rebind_selector_error(args.selector.as_str(), error))?; 930 let from_seller_account_id = non_empty(draft.seller_actor.account_id.clone()); 931 let from_seller_pubkey = non_empty(draft.seller_actor.pubkey.clone()); 932 let from_seller_actor_source = non_empty(draft.seller_actor.source.clone()); 933 let from_farm_d_tag = non_empty(draft.listing.farm_d_tag.clone()); 934 let target_account_id = target_account.record.account_id.to_string(); 935 let target_pubkey = target_account.record.public_identity.public_key_hex.clone(); 936 let target_farm_d_tag = resolve_rebind_farm_d_tag( 937 config, 938 args, 939 from_seller_account_id.as_deref(), 940 from_farm_d_tag.as_deref(), 941 target_account_id.as_str(), 942 )?; 943 let from_listing_addr = from_seller_pubkey 944 .as_ref() 945 .map(|pubkey| listing_addr(pubkey, listing_id.as_str())); 946 let to_listing_addr = listing_addr(target_pubkey.as_str(), listing_id.as_str()); 947 let seller_pubkey_changed = from_seller_pubkey 948 .as_deref() 949 .map(|pubkey| !pubkey.eq_ignore_ascii_case(target_pubkey.as_str())); 950 let listing_addr_changed = from_listing_addr 951 .as_deref() 952 .map(|addr| addr != to_listing_addr.as_str()); 953 let farm_d_tag_changed = from_farm_d_tag 954 .as_deref() 955 .map(|d_tag| d_tag != target_farm_d_tag.as_str()); 956 957 draft.seller_actor.account_id = target_account_id.clone(); 958 draft.seller_actor.pubkey = target_pubkey.clone(); 959 draft.seller_actor.source = LISTING_SELLER_ACTOR_SOURCE_REBIND.to_owned(); 960 draft.listing.farm_d_tag = target_farm_d_tag.clone(); 961 962 if !dry_run { 963 write_listing_draft(args.file.as_path(), &draft, true)?; 964 append_listing_local_work(config, args.file.as_path(), &draft)?; 965 } 966 967 Ok(ListingRebindView { 968 state: if dry_run { "dry_run" } else { "rebound" }.to_owned(), 969 source: LISTING_SOURCE.to_owned(), 970 file: args.file.display().to_string(), 971 listing_id, 972 dry_run, 973 from_seller_account_id, 974 from_seller_pubkey, 975 from_seller_actor_source, 976 to_seller_account_id: target_account_id, 977 to_seller_pubkey: target_pubkey, 978 to_seller_actor_source: LISTING_SELLER_ACTOR_SOURCE_REBIND.to_owned(), 979 seller_pubkey_changed, 980 from_listing_addr, 981 to_listing_addr, 982 listing_addr_changed, 983 from_farm_d_tag, 984 to_farm_d_tag: target_farm_d_tag, 985 farm_d_tag_changed, 986 reason: Some(if dry_run { 987 "dry run requested; listing seller actor binding was not written".to_owned() 988 } else { 989 "listing seller actor binding updated".to_owned() 990 }), 991 actions: if dry_run { 992 vec![format!( 993 "radroots --approval-token approve listing rebind {} {}", 994 args.file.display(), 995 args.selector 996 )] 997 } else { 998 vec![format!("radroots listing validate {}", args.file.display())] 999 }, 1000 }) 1001 } 1002 1003 fn resolve_rebind_farm_d_tag( 1004 config: &RuntimeConfig, 1005 args: &ListingRebindArgs, 1006 from_seller_account_id: Option<&str>, 1007 from_farm_d_tag: Option<&str>, 1008 target_account_id: &str, 1009 ) -> Result<String, RuntimeError> { 1010 if let Some(explicit) = args 1011 .farm_d_tag 1012 .as_deref() 1013 .map(str::trim) 1014 .filter(|value| !value.is_empty()) 1015 { 1016 if !is_d_tag_base64url(explicit) { 1017 return Err(RuntimeError::Config( 1018 "listing rebind --farm-d-tag must be a 22-character base64url identifier" 1019 .to_owned(), 1020 )); 1021 } 1022 return Ok(explicit.to_owned()); 1023 } 1024 if from_seller_account_id == Some(target_account_id) 1025 && let Some(existing) = from_farm_d_tag 1026 { 1027 return Ok(existing.to_owned()); 1028 } 1029 if let Some(resolved) = farm_config::load(config, None)? 1030 && resolved.document.selection.account == target_account_id 1031 { 1032 return Ok(resolved.document.selection.farm_d_tag); 1033 } 1034 Err(RuntimeError::Config(format!( 1035 "listing rebind requires --farm-d-tag when target account `{target_account_id}` is not bound by the selected farm config" 1036 ))) 1037 } 1038 1039 fn listing_rebind_selector_error(selector: &str, error: RuntimeError) -> RuntimeError { 1040 match error { 1041 RuntimeError::Account(account::AccountRuntimeFailure::Unresolved(issue)) => { 1042 account::AccountRuntimeFailure::unresolved_with_detail( 1043 issue.message().to_owned(), 1044 json!({ 1045 "seller_actor_source": LISTING_SELLER_ACTOR_SOURCE_REBIND, 1046 "selector": selector, 1047 "actions": [ 1048 "radroots account import <path>", 1049 "radroots account create", 1050 ], 1051 }), 1052 ) 1053 .into() 1054 } 1055 other => other, 1056 } 1057 } 1058 1059 fn listing_addr(seller_pubkey: &str, listing_id: &str) -> String { 1060 format!("{KIND_LISTING}:{seller_pubkey}:{listing_id}") 1061 } 1062 1063 fn load_listing_draft(path: &Path) -> Result<LoadedListingDraft, ListingValidationIssueView> { 1064 let contents = fs::read_to_string(path).map_err(|error| ListingValidationIssueView { 1065 field: "file".to_owned(), 1066 message: format!("read listing draft {}: {error}", path.display()), 1067 line: None, 1068 })?; 1069 let document = toml::from_str::<ListingDraftDocument>(contents.as_str()).map_err(|error| { 1070 ListingValidationIssueView { 1071 field: "toml".to_owned(), 1072 message: error.to_string(), 1073 line: error 1074 .span() 1075 .map(|span| line_for_offset(contents.as_str(), span.start + 1)), 1076 } 1077 })?; 1078 Ok(LoadedListingDraft { 1079 file: path.to_path_buf(), 1080 updated_at_unix: modified_unix(path).unwrap_or_default(), 1081 contents, 1082 document, 1083 }) 1084 } 1085 1086 fn summary_from_loaded( 1087 config: &RuntimeConfig, 1088 loaded: &LoadedListingDraft, 1089 context: Result<&ListingValidationContext, &String>, 1090 ) -> ListingSummaryView { 1091 let mut seller_account_id = non_empty(loaded.document.seller_actor.account_id.clone()); 1092 let mut seller_pubkey = non_empty(loaded.document.seller_actor.pubkey.clone()); 1093 let mut seller_actor_source = non_empty(loaded.document.seller_actor.source.clone()); 1094 let mut farm_d_tag = non_empty(loaded.document.listing.farm_d_tag.clone()); 1095 let mut issues = Vec::new(); 1096 let mut state = "draft"; 1097 1098 match context { 1099 Ok(context) => { 1100 match canonicalize_draft(&loaded.document, loaded.contents.as_str(), context) { 1101 Ok(canonical) => { 1102 seller_account_id = Some(canonical.seller_account_id.clone()); 1103 seller_pubkey = Some(canonical.seller_pubkey.clone()); 1104 seller_actor_source = Some(canonical.seller_actor_source.clone()); 1105 farm_d_tag = Some(canonical.farm_d_tag.clone()); 1106 issues = listing_ready_issues(&canonical, loaded.contents.as_str()); 1107 if let Ok(Some(issue)) = 1108 listing_bound_account_issue(config, &canonical, loaded.contents.as_str()) 1109 { 1110 issues.push(issue); 1111 } 1112 if issues.is_empty() { 1113 state = "ready"; 1114 } 1115 } 1116 Err(error) => issues.push(error.into_issue()), 1117 } 1118 } 1119 Err(reason) => issues.push(ListingValidationIssueView { 1120 field: "context".to_owned(), 1121 message: reason.to_string(), 1122 line: None, 1123 }), 1124 } 1125 1126 ListingSummaryView { 1127 id: non_empty(loaded.document.listing.d_tag.clone()) 1128 .unwrap_or_else(|| file_stem(loaded.file.as_path())), 1129 state: state.to_owned(), 1130 file: loaded.file.display().to_string(), 1131 product_key: non_empty(loaded.document.product.key.clone()), 1132 title: non_empty(loaded.document.product.title.clone()), 1133 category: non_empty(loaded.document.product.category.clone()), 1134 seller_account_id, 1135 seller_pubkey, 1136 seller_actor_source, 1137 farm_d_tag, 1138 location_primary: non_empty(loaded.document.location.primary.clone()), 1139 updated_at_unix: loaded.updated_at_unix, 1140 issues, 1141 } 1142 } 1143 1144 fn listing_ready_issues( 1145 canonical: &CanonicalListingDraft, 1146 contents: &str, 1147 ) -> Vec<ListingValidationIssueView> { 1148 let parts = match to_wire_parts_with_kind(&canonical.listing, KIND_LISTING_DRAFT) { 1149 Ok(parts) => parts, 1150 Err(error) => { 1151 return vec![ListingValidationIssueView { 1152 field: "listing".to_owned(), 1153 message: format!("invalid listing contract: {error}"), 1154 line: None, 1155 }]; 1156 } 1157 }; 1158 let event = RadrootsNostrEvent { 1159 id: String::new(), 1160 author: canonical.seller_pubkey.clone(), 1161 created_at: 0, 1162 kind: KIND_LISTING_DRAFT, 1163 tags: parts.tags, 1164 content: parts.content, 1165 sig: String::new(), 1166 }; 1167 match validate_listing_event(&event) { 1168 Ok(_) => Vec::new(), 1169 Err(error) => vec![issue_from_trade_validation(error, contents)], 1170 } 1171 } 1172 1173 fn summary_for_invalid_file(path: &Path, issue: ListingValidationIssueView) -> ListingSummaryView { 1174 ListingSummaryView { 1175 id: file_stem(path), 1176 state: "error".to_owned(), 1177 file: path.display().to_string(), 1178 product_key: None, 1179 title: None, 1180 category: None, 1181 seller_account_id: None, 1182 seller_pubkey: None, 1183 seller_actor_source: None, 1184 farm_d_tag: None, 1185 location_primary: None, 1186 updated_at_unix: modified_unix(path).unwrap_or_default(), 1187 issues: vec![issue], 1188 } 1189 } 1190 1191 #[derive(Debug, Clone)] 1192 struct AppRecordListEntry { 1193 record: LocalEventRecord, 1194 superseded_count: usize, 1195 } 1196 1197 fn app_local_records(config: &RuntimeConfig) -> Result<Vec<LocalEventRecord>, RuntimeError> { 1198 let mut app_records = Vec::new(); 1199 let mut before_cursor = None::<(i64, i64)>; 1200 loop { 1201 let shared_records = if let Some((before_change_seq, before_seq)) = before_cursor { 1202 list_shared_records_before( 1203 config, 1204 before_change_seq, 1205 before_seq, 1206 APP_RECORD_LIST_LIMIT, 1207 )? 1208 } else { 1209 list_shared_records_latest(config, APP_RECORD_LIST_LIMIT)? 1210 }; 1211 let Some(next_cursor) = shared_records 1212 .last() 1213 .map(|record| (record.change_seq, record.seq)) 1214 else { 1215 break; 1216 }; 1217 let has_more = shared_records.len() == APP_RECORD_LIST_LIMIT as usize; 1218 app_records.extend( 1219 shared_records 1220 .into_iter() 1221 .filter(is_supported_app_local_record), 1222 ); 1223 if !has_more { 1224 break; 1225 } 1226 before_cursor = Some(next_cursor); 1227 } 1228 Ok(app_records) 1229 } 1230 1231 fn is_supported_app_local_record(record: &LocalEventRecord) -> bool { 1232 record.source_runtime == SourceRuntime::App 1233 && record.family == LocalRecordFamily::LocalWork 1234 && matches!( 1235 local_record_kind(record).as_deref(), 1236 Some("farm_config_v1" | DRAFT_KIND) 1237 ) 1238 } 1239 1240 fn current_app_record_entries(mut records: Vec<LocalEventRecord>) -> Vec<AppRecordListEntry> { 1241 records.sort_by(|left, right| { 1242 right 1243 .change_seq 1244 .cmp(&left.change_seq) 1245 .then_with(|| right.seq.cmp(&left.seq)) 1246 .then_with(|| left.record_id.cmp(&right.record_id)) 1247 }); 1248 1249 let mut entries: Vec<AppRecordListEntry> = Vec::new(); 1250 let mut seen = HashMap::<String, usize>::new(); 1251 for record in records { 1252 let key = app_record_current_key(&record); 1253 if let Some(index) = seen.get(&key).copied() { 1254 entries[index].superseded_count += 1; 1255 } else { 1256 seen.insert(key, entries.len()); 1257 entries.push(AppRecordListEntry { 1258 record, 1259 superseded_count: 0, 1260 }); 1261 } 1262 } 1263 entries 1264 } 1265 1266 fn current_app_record_for( 1267 config: &RuntimeConfig, 1268 record: &LocalEventRecord, 1269 ) -> Result<Option<LocalEventRecord>, RuntimeError> { 1270 let key = app_record_current_key(record); 1271 Ok(app_local_records(config)? 1272 .into_iter() 1273 .filter(|candidate| app_record_current_key(candidate) == key) 1274 .max_by(|left, right| { 1275 left.change_seq 1276 .cmp(&right.change_seq) 1277 .then_with(|| left.seq.cmp(&right.seq)) 1278 })) 1279 } 1280 1281 fn app_record_summary( 1282 record: &LocalEventRecord, 1283 superseded_count: usize, 1284 ) -> ListingAppRecordSummaryView { 1285 let record_kind = local_record_kind(record).unwrap_or_else(|| "unknown".to_owned()); 1286 let (listing_id, title, exportable, reason) = match record_kind.as_str() { 1287 DRAFT_KIND => { 1288 if let Some(reason) = app_record_exportability_reason(record) { 1289 let (listing_id, title, _) = app_listing_display_parts(record); 1290 (listing_id, title, false, Some(reason)) 1291 } else { 1292 match app_listing_draft_from_record(record) { 1293 Ok(draft) => ( 1294 non_empty(draft.listing.d_tag), 1295 non_empty(draft.product.title), 1296 true, 1297 None, 1298 ), 1299 Err(reason) => { 1300 let (listing_id, title, _) = app_listing_display_parts(record); 1301 (listing_id, title, false, Some(reason)) 1302 } 1303 } 1304 } 1305 } 1306 "farm_config_v1" => ( 1307 None, 1308 record 1309 .local_work_json 1310 .as_ref() 1311 .and_then(|payload| payload["document"]["farm"]["name"].as_str()) 1312 .map(str::to_owned), 1313 false, 1314 Some("farm records provide defaults; export selects listing records".to_owned()), 1315 ), 1316 _ => ( 1317 None, 1318 None, 1319 false, 1320 Some(format!("unsupported app record kind `{record_kind}`")), 1321 ), 1322 }; 1323 let actions = if exportable { 1324 vec![format!("radroots listing app export {}", record.record_id)] 1325 } else { 1326 Vec::new() 1327 }; 1328 1329 ListingAppRecordSummaryView { 1330 record_id: record.record_id.clone(), 1331 seq: record.seq, 1332 change_seq: record.change_seq, 1333 superseded_count, 1334 record_kind, 1335 status: record.status.as_str().to_owned(), 1336 source_runtime: record.source_runtime.as_str().to_owned(), 1337 owner_account_id: record.owner_account_id.clone(), 1338 owner_pubkey: record.owner_pubkey.clone(), 1339 farm_id: record.farm_id.clone(), 1340 listing_addr: record.listing_addr.clone(), 1341 listing_id, 1342 title, 1343 exportable, 1344 reason, 1345 actions, 1346 } 1347 } 1348 1349 fn app_record_current_key(record: &LocalEventRecord) -> String { 1350 match local_record_kind(record).as_deref() { 1351 Some(DRAFT_KIND) => { 1352 if let Some(listing_addr) = record 1353 .listing_addr 1354 .as_deref() 1355 .map(str::trim) 1356 .filter(|value| !value.is_empty()) 1357 { 1358 return format!("listing_addr:{listing_addr}"); 1359 } 1360 let (listing_id, _, _) = app_listing_display_parts(record); 1361 if let (Some(owner_pubkey), Some(listing_id)) = ( 1362 app_record_canonical_owner_pubkey(record), 1363 listing_id.filter(|value| is_d_tag_base64url(value)), 1364 ) { 1365 return format!("listing_owner:{owner_pubkey}:{listing_id}"); 1366 } 1367 } 1368 Some("farm_config_v1") => { 1369 if let Some(farm_id) = record 1370 .farm_id 1371 .as_deref() 1372 .map(str::trim) 1373 .filter(|value| !value.is_empty()) 1374 { 1375 return format!("farm:{farm_id}"); 1376 } 1377 if let Some(farm_id) = record 1378 .local_work_json 1379 .as_ref() 1380 .and_then(|payload| payload["document"]["farm"]["d_tag"].as_str()) 1381 .map(str::trim) 1382 .filter(|value| !value.is_empty()) 1383 { 1384 return format!("farm:{farm_id}"); 1385 } 1386 } 1387 _ => {} 1388 } 1389 format!("record:{}", record.record_id) 1390 } 1391 1392 fn canonical_hex_pubkey(value: &str) -> Option<String> { 1393 let trimmed = value.trim(); 1394 if trimmed.len() == 64 && trimmed.chars().all(|char| char.is_ascii_hexdigit()) { 1395 Some(trimmed.to_ascii_lowercase()) 1396 } else { 1397 None 1398 } 1399 } 1400 1401 fn app_record_canonical_owner_pubkey(record: &LocalEventRecord) -> Option<String> { 1402 record 1403 .owner_pubkey 1404 .as_deref() 1405 .and_then(canonical_hex_pubkey) 1406 } 1407 1408 fn app_listing_display_parts( 1409 record: &LocalEventRecord, 1410 ) -> (Option<String>, Option<String>, Option<String>) { 1411 let document = record 1412 .local_work_json 1413 .as_ref() 1414 .and_then(|payload| payload.get("document")); 1415 let listing_id = document 1416 .and_then(|document| document["listing"]["d_tag"].as_str()) 1417 .map(str::trim) 1418 .filter(|value| !value.is_empty()) 1419 .map(str::to_owned); 1420 let title = document 1421 .and_then(|document| document["product"]["title"].as_str()) 1422 .map(str::trim) 1423 .filter(|value| !value.is_empty()) 1424 .map(str::to_owned); 1425 let farm_d_tag = document 1426 .and_then(|document| document["listing"]["farm_d_tag"].as_str()) 1427 .map(str::trim) 1428 .filter(|value| !value.is_empty()) 1429 .map(str::to_owned); 1430 (listing_id, title, farm_d_tag) 1431 } 1432 1433 fn app_record_exportability_reason(record: &LocalEventRecord) -> Option<String> { 1434 if local_record_kind(record).as_deref() == Some(DRAFT_KIND) 1435 && app_record_canonical_owner_pubkey(record).is_none() 1436 { 1437 return Some(CANONICAL_OWNER_PUBKEY_REQUIRED_REASON.to_owned()); 1438 } 1439 let exportability = record 1440 .local_work_json 1441 .as_ref() 1442 .and_then(|payload| payload.get("exportability"))?; 1443 let state = exportability 1444 .get("state") 1445 .and_then(Value::as_str) 1446 .unwrap_or_default(); 1447 if state.is_empty() || state == "exportable" { 1448 return None; 1449 } 1450 let reason = exportability 1451 .get("reason") 1452 .and_then(Value::as_str) 1453 .unwrap_or_default(); 1454 Some(match (state, reason) { 1455 ("identity_unresolved", "canonical_hex_pubkey_required") => { 1456 CANONICAL_OWNER_PUBKEY_REQUIRED_REASON.to_owned() 1457 } 1458 ("identity_unresolved", _) => "app record identity is unresolved".to_owned(), 1459 (_, "") => format!("app record exportability state `{state}` is not exportable"), 1460 (_, reason) => format!("app record exportability state `{state}`: {reason}"), 1461 }) 1462 } 1463 1464 fn app_listing_draft_from_record( 1465 record: &LocalEventRecord, 1466 ) -> Result<ListingDraftDocument, String> { 1467 if record.source_runtime != SourceRuntime::App { 1468 return Err(format!( 1469 "record source_runtime `{}` is not app", 1470 record.source_runtime.as_str() 1471 )); 1472 } 1473 if record.family != LocalRecordFamily::LocalWork { 1474 return Err(format!( 1475 "record family `{}` is not local_work", 1476 record.family.as_str() 1477 )); 1478 } 1479 let payload = record 1480 .local_work_json 1481 .as_ref() 1482 .ok_or_else(|| "record has no local_work_json payload".to_owned())?; 1483 let record_kind = local_record_kind(record).unwrap_or_else(|| "unknown".to_owned()); 1484 if record_kind != DRAFT_KIND { 1485 return Err(format!("record kind `{record_kind}` is not {DRAFT_KIND}")); 1486 } 1487 if let Some(reason) = app_record_exportability_reason(record) { 1488 return Err(reason); 1489 } 1490 let owner_pubkey = app_record_canonical_owner_pubkey(record) 1491 .ok_or_else(|| CANONICAL_OWNER_PUBKEY_REQUIRED_REASON.to_owned())?; 1492 let document = payload 1493 .get("document") 1494 .cloned() 1495 .ok_or_else(|| "record local_work_json.document is missing".to_owned())?; 1496 let mut draft = serde_json::from_value::<ListingDraftDocument>(document) 1497 .map_err(|error| format!("record listing document is invalid: {error}"))?; 1498 if let Some(account_id) = record 1499 .owner_account_id 1500 .as_deref() 1501 .map(str::trim) 1502 .filter(|value| !value.is_empty()) 1503 { 1504 draft.seller_actor.account_id = account_id.to_owned(); 1505 } 1506 draft.seller_actor.pubkey = owner_pubkey; 1507 if draft.listing.farm_d_tag.trim().is_empty() 1508 && let Some(farm_id) = record.farm_id.as_ref() 1509 { 1510 draft.listing.farm_d_tag = farm_id.clone(); 1511 } 1512 normalize_app_listing_availability(&mut draft)?; 1513 normalize_app_listing_units(&mut draft); 1514 Ok(draft) 1515 } 1516 1517 fn normalize_app_listing_availability(draft: &mut ListingDraftDocument) -> Result<(), String> { 1518 let kind = draft.availability.kind.trim(); 1519 if kind.is_empty() || kind == "local" { 1520 draft.availability.kind = "status".to_owned(); 1521 } else if !matches!(kind, "status" | "window") { 1522 return Err(format!( 1523 "unsupported app listing availability kind `{kind}`" 1524 )); 1525 } 1526 if draft.availability.kind == "window" { 1527 return Ok(()); 1528 } 1529 1530 let status = draft.availability.status.trim(); 1531 draft.availability.status = match status { 1532 "" | "active" | "draft" | "published" => "active".to_owned(), 1533 "archived" | "paused" | "sold" => { 1534 return Err(format!( 1535 "app listing status `{status}` is not exportable as a publishable CLI draft" 1536 )); 1537 } 1538 other => return Err(format!("unsupported app listing status `{other}`")), 1539 }; 1540 Ok(()) 1541 } 1542 1543 fn normalize_app_listing_units(draft: &mut ListingDraftDocument) { 1544 let quantity_unit = draft.primary_bin.quantity_unit.trim().to_owned(); 1545 let price_per_unit = draft.primary_bin.price_per_unit.trim().to_owned(); 1546 let quantity_unit_supported = quantity_unit.parse::<RadrootsCoreUnit>().is_ok(); 1547 let price_per_unit_supported = price_per_unit.parse::<RadrootsCoreUnit>().is_ok(); 1548 if quantity_unit_supported && price_per_unit_supported { 1549 return; 1550 } 1551 1552 if draft.primary_bin.label.trim().is_empty() { 1553 draft.primary_bin.label = if !quantity_unit_supported && !quantity_unit.is_empty() { 1554 quantity_unit.clone() 1555 } else { 1556 price_per_unit.clone() 1557 }; 1558 } 1559 if !quantity_unit_supported { 1560 draft.primary_bin.quantity_unit = "each".to_owned(); 1561 } 1562 if !price_per_unit_supported { 1563 draft.primary_bin.price_per_unit = "each".to_owned(); 1564 } 1565 } 1566 1567 fn app_listing_export_issues( 1568 config: &RuntimeConfig, 1569 draft: &ListingDraftDocument, 1570 contents: &str, 1571 context: &ListingValidationContext, 1572 ) -> Result<Vec<ListingValidationIssueView>, RuntimeError> { 1573 let canonical = match canonicalize_draft(draft, contents, context) { 1574 Ok(canonical) => canonical, 1575 Err(error) => return Ok(vec![error.into_issue()]), 1576 }; 1577 let mut issues = listing_ready_issues(&canonical, contents); 1578 if let Some(issue) = listing_bound_account_issue(config, &canonical, contents)? { 1579 issues.push(issue); 1580 } 1581 Ok(issues) 1582 } 1583 1584 fn app_record_listing_addr(draft: &ListingDraftDocument) -> Option<String> { 1585 let seller_pubkey = draft.seller_actor.pubkey.trim(); 1586 let listing_id = draft.listing.d_tag.trim(); 1587 if seller_pubkey.is_empty() || listing_id.is_empty() { 1588 None 1589 } else { 1590 Some(listing_addr(seller_pubkey, listing_id)) 1591 } 1592 } 1593 1594 fn local_record_kind(record: &LocalEventRecord) -> Option<String> { 1595 record 1596 .local_work_json 1597 .as_ref() 1598 .and_then(|payload| payload.get("record_kind")) 1599 .and_then(Value::as_str) 1600 .map(str::to_owned) 1601 } 1602 1603 pub fn get( 1604 config: &RuntimeConfig, 1605 args: &RecordLookupArgs, 1606 ) -> Result<ListingGetView, RuntimeError> { 1607 refresh_market_listing_if_needed(config)?; 1608 let freshness = if config.local.replica_db_path.exists() { 1609 let executor = SqliteExecutor::open(&config.local.replica_db_path)?; 1610 freshness_for_scope_from_executor(config, &executor, RelayIngestScope::MarketRefresh)? 1611 } else { 1612 missing_freshness() 1613 }; 1614 let provenance = FindResultProvenanceView { 1615 origin: "local_replica.trade_product".to_owned(), 1616 freshness: freshness.display.clone(), 1617 relay_count: config.relay.urls.len(), 1618 }; 1619 1620 if !config.local.replica_db_path.exists() { 1621 return Ok(ListingGetView { 1622 state: "unconfigured".to_owned(), 1623 source: LISTING_READ_SOURCE.to_owned(), 1624 lookup: args.key.clone(), 1625 readiness: MarketReadinessView::unavailable("local_replica_not_initialized"), 1626 listing_id: None, 1627 product_key: None, 1628 listing_addr: None, 1629 primary_bin_id: None, 1630 title: None, 1631 category: None, 1632 description: None, 1633 location_primary: None, 1634 available: None, 1635 price: None, 1636 provenance, 1637 reason: Some("local replica database is not initialized".to_owned()), 1638 actions: vec!["radroots store init".to_owned()], 1639 }); 1640 } 1641 1642 let db = ReplicaSql::new(SqliteExecutor::open(&config.local.replica_db_path)?); 1643 let rows = db.trade_product_lookup(args.key.as_str())?; 1644 let Some(row) = rows.into_iter().next() else { 1645 return Ok(ListingGetView { 1646 state: "missing".to_owned(), 1647 source: LISTING_READ_SOURCE.to_owned(), 1648 lookup: args.key.clone(), 1649 readiness: MarketReadinessView::unavailable("market_listing_missing"), 1650 listing_id: None, 1651 product_key: None, 1652 listing_addr: None, 1653 primary_bin_id: None, 1654 title: None, 1655 category: None, 1656 description: None, 1657 location_primary: None, 1658 available: None, 1659 price: None, 1660 provenance, 1661 reason: Some(format!( 1662 "listing `{}` is not available in the local replica", 1663 args.key 1664 )), 1665 actions: vec![ 1666 "radroots sync pull".to_owned(), 1667 format!("radroots market product search {}", args.key), 1668 ], 1669 }); 1670 }; 1671 1672 let listing_addr = row.listing_addr.and_then(non_empty); 1673 let primary_bin_id = row.primary_bin_id.and_then(non_empty); 1674 let verified_primary_bin_id = row.verified_primary_bin_id.and_then(non_empty); 1675 let available_amount = row.qty_avail; 1676 let price_amount = row.price_amt; 1677 let price_currency = row.price_currency; 1678 let price_per_amount = row.price_qty_amt; 1679 let readiness = MarketReadinessView::from_market_projection( 1680 listing_addr.as_deref(), 1681 primary_bin_id.as_deref(), 1682 verified_primary_bin_id.as_deref(), 1683 Some(row.title.as_str()), 1684 Some(row.category.as_str()), 1685 available_amount, 1686 price_amount, 1687 price_currency.as_str(), 1688 price_per_amount, 1689 ); 1690 1691 Ok(ListingGetView { 1692 state: "ready".to_owned(), 1693 source: LISTING_READ_SOURCE.to_owned(), 1694 lookup: args.key.clone(), 1695 readiness, 1696 listing_id: Some(row.id), 1697 product_key: Some(row.key), 1698 listing_addr, 1699 primary_bin_id, 1700 title: Some(row.title), 1701 category: Some(row.category), 1702 description: non_empty(row.summary), 1703 location_primary: row.location_primary.and_then(non_empty), 1704 available: Some(FindQuantityView { 1705 total_amount: row.qty_amt, 1706 total_unit: row.qty_unit, 1707 label: row.qty_label.and_then(non_empty), 1708 available_amount, 1709 }), 1710 price: Some(FindPriceView { 1711 amount: price_amount, 1712 currency: price_currency, 1713 per_amount: price_per_amount, 1714 per_unit: row.price_qty_unit, 1715 }), 1716 provenance, 1717 reason: None, 1718 actions: Vec::new(), 1719 }) 1720 } 1721 1722 fn refresh_market_listing_if_needed(config: &RuntimeConfig) -> Result<(), RuntimeError> { 1723 if !config.local.replica_db_path.exists() 1724 || config.output.dry_run 1725 || config.relay.urls.is_empty() 1726 { 1727 return Ok(()); 1728 } 1729 let executor = SqliteExecutor::open(&config.local.replica_db_path)?; 1730 let freshness = 1731 freshness_for_scope_from_executor(config, &executor, RelayIngestScope::MarketRefresh)?; 1732 if crate::runtime::sync::freshness_requires_refresh(&freshness) { 1733 let _ = market_refresh(config)?; 1734 } 1735 Ok(()) 1736 } 1737 1738 pub fn publish_via_sdk( 1739 config: &RuntimeConfig, 1740 args: &ListingMutationArgs, 1741 ) -> Result<ListingMutationView, CliSdkAdapterError> { 1742 let input = sdk_listing_publish_input(config, args)?; 1743 if config.output.dry_run { 1744 validate_configured_listing_signer(config, &input.canonical)?; 1745 let session = CliSdkSession::connect_memory(config)?; 1746 let plan = session.sdk().listings().prepare_publish( 1747 ListingPreparePublishRequest::from_document( 1748 input.actor.clone(), 1749 input.document.clone(), 1750 ), 1751 )?; 1752 return Ok(sdk_prepared_publish_view( 1753 config, 1754 args, 1755 ListingMutationOperation::Publish, 1756 &input.canonical, 1757 plan, 1758 )); 1759 } 1760 1761 let session = CliSdkSession::connect_for_actor( 1762 config, 1763 Some(input.canonical.seller_account_id.as_str()), 1764 input.canonical.seller_pubkey.as_str(), 1765 "listing seller", 1766 )?; 1767 let mut request = ListingEnqueuePublishRequest::from_document( 1768 input.actor, 1769 input.document, 1770 sdk_relay_target_policy(config), 1771 ); 1772 if let Some(idempotency_key) = args.idempotency_key.as_deref() { 1773 request = request.try_with_idempotency_key(idempotency_key)?; 1774 } 1775 let enqueue_receipt = session.block_on(session.sdk().listings().enqueue_publish(request))?; 1776 let push_receipt = if args.offline { 1777 None 1778 } else { 1779 Some( 1780 session.block_on( 1781 session.sdk().sync().push_outbox( 1782 PushOutboxRequest::new() 1783 .with_limit(1) 1784 .with_relay_url_policy(sdk_relay_url_policy(config)), 1785 ), 1786 )?, 1787 ) 1788 }; 1789 Ok(sdk_enqueued_publish_view( 1790 config, 1791 args, 1792 ListingMutationOperation::Publish, 1793 &input.canonical, 1794 enqueue_receipt, 1795 push_receipt, 1796 )) 1797 } 1798 1799 fn sdk_listing_publish_input( 1800 config: &RuntimeConfig, 1801 args: &ListingMutationArgs, 1802 ) -> Result<SdkListingPublishInput, RuntimeError> { 1803 let contents = fs::read_to_string(&args.file)?; 1804 let parsed = toml::from_str::<ListingDraftDocument>(&contents).map_err(|error| { 1805 RuntimeError::Config(format!( 1806 "invalid listing draft {}: {error}", 1807 args.file.display() 1808 )) 1809 })?; 1810 let context = mutation_validation_context(config)?; 1811 let canonical = canonicalize_draft(&parsed, &contents, &context).map_err(|error| { 1812 let issue = match error { 1813 ListingDraftValidationError::MissingSellerAccount(issue) => { 1814 return account::AccountRuntimeFailure::unresolved_with_detail( 1815 format!("{} ({})", issue.message, issue.field), 1816 json!({ 1817 "seller_actor_source": "listing_draft", 1818 "listing_file": args.file.display().to_string(), 1819 "actions": listing_bound_account_recovery_actions(args.file.as_path()), 1820 }), 1821 ) 1822 .into(); 1823 } 1824 ListingDraftValidationError::Issue(issue) => issue, 1825 }; 1826 RuntimeError::Config(format!( 1827 "invalid listing draft {}: {} ({})", 1828 args.file.display(), 1829 issue.message, 1830 issue.field 1831 )) 1832 })?; 1833 ensure_listing_bound_account(config, &canonical, args.file.as_path())?; 1834 let actor = RadrootsActorContext::local_account( 1835 canonical.seller_pubkey.as_str(), 1836 canonical.seller_account_id.clone(), 1837 [RadrootsActorRole::Seller], 1838 ) 1839 .map_err(|error| RuntimeError::Config(format!("invalid listing SDK actor: {error}")))?; 1840 let document = RadrootsListingDraftDocumentV1::new(canonical.listing.clone()); 1841 Ok(SdkListingPublishInput { 1842 canonical, 1843 actor, 1844 document, 1845 }) 1846 } 1847 1848 fn sdk_prepared_publish_view( 1849 config: &RuntimeConfig, 1850 args: &ListingMutationArgs, 1851 operation: ListingMutationOperation, 1852 canonical: &CanonicalListingDraft, 1853 plan: ListingPublishPlan, 1854 ) -> ListingMutationView { 1855 let listing_addr = plan.public_listing_addr.as_str().to_owned(); 1856 let event = sdk_plan_event_view(&plan); 1857 ListingMutationView { 1858 state: "dry_run".to_owned(), 1859 operation: operation.as_str().to_owned(), 1860 source: SDK_LISTING_WRITE_SOURCE.to_owned(), 1861 file: args.file.display().to_string(), 1862 listing_id: canonical.listing_id.clone(), 1863 listing_addr: listing_addr.clone(), 1864 seller_account_id: canonical.seller_account_id.clone(), 1865 seller_pubkey: canonical.seller_pubkey.clone(), 1866 seller_actor_source: canonical.seller_actor_source.clone(), 1867 event_kind: KIND_LISTING, 1868 dry_run: true, 1869 deduplicated: false, 1870 target_relays: Vec::new(), 1871 connected_relays: Vec::new(), 1872 acknowledged_relays: Vec::new(), 1873 failed_relays: Vec::new(), 1874 job_id: None, 1875 job_status: None, 1876 signer_mode: Some(config.signer.backend.as_str().to_owned()), 1877 event_id: Some(plan.expected_event_id.as_str().to_owned()), 1878 event_addr: Some(listing_addr), 1879 idempotency_key: args.idempotency_key.clone(), 1880 local_replica: None, 1881 reason: Some("dry run requested; SDK enqueue and relay push skipped".to_owned()), 1882 job: None, 1883 event: args.print_event.then_some(event), 1884 actions: vec![format!("radroots listing publish {}", args.file.display())], 1885 } 1886 } 1887 1888 fn sdk_enqueued_publish_view( 1889 config: &RuntimeConfig, 1890 args: &ListingMutationArgs, 1891 operation: ListingMutationOperation, 1892 canonical: &CanonicalListingDraft, 1893 enqueue: ListingEnqueueReceipt, 1894 push: Option<PushOutboxReceipt>, 1895 ) -> ListingMutationView { 1896 let push_event = push 1897 .as_ref() 1898 .and_then(|receipt| sdk_push_event_for_listing(&enqueue, receipt)); 1899 let state = sdk_publish_state(args, push_event); 1900 let reason = sdk_publish_reason(args, push_event); 1901 let target_relays = push_event 1902 .map(sdk_push_target_relays) 1903 .unwrap_or_else(|| config.relay.urls.clone()); 1904 let connected_relays = push_event 1905 .map(sdk_push_connected_relays) 1906 .unwrap_or_default(); 1907 let acknowledged_relays = push_event 1908 .map(sdk_push_acknowledged_relays) 1909 .unwrap_or_default(); 1910 let failed_relays = push_event.map(sdk_push_failed_relays).unwrap_or_default(); 1911 let event_id = enqueue.signed_event_id.as_str().to_owned(); 1912 let listing_addr = enqueue.public_listing_addr.as_str().to_owned(); 1913 ListingMutationView { 1914 state, 1915 operation: operation.as_str().to_owned(), 1916 source: SDK_LISTING_WRITE_SOURCE.to_owned(), 1917 file: args.file.display().to_string(), 1918 listing_id: canonical.listing_id.clone(), 1919 listing_addr: listing_addr.clone(), 1920 seller_account_id: canonical.seller_account_id.clone(), 1921 seller_pubkey: canonical.seller_pubkey.clone(), 1922 seller_actor_source: canonical.seller_actor_source.clone(), 1923 event_kind: KIND_LISTING, 1924 dry_run: false, 1925 deduplicated: matches!(enqueue.state, SdkMutationState::AlreadyQueued), 1926 target_relays, 1927 connected_relays, 1928 acknowledged_relays, 1929 failed_relays, 1930 job_id: None, 1931 job_status: None, 1932 signer_mode: Some(config.signer.backend.as_str().to_owned()), 1933 event_id: Some(event_id), 1934 event_addr: Some(listing_addr), 1935 idempotency_key: args.idempotency_key.clone(), 1936 local_replica: None, 1937 reason, 1938 job: None, 1939 event: None, 1940 actions: sdk_publish_actions(args, push_event), 1941 } 1942 } 1943 1944 fn sdk_plan_event_view(plan: &ListingPublishPlan) -> ListingMutationEventView { 1945 ListingMutationEventView { 1946 kind: plan.frozen_draft.kind, 1947 author: plan.frozen_draft.expected_pubkey.clone(), 1948 created_at: Some(plan.frozen_draft.created_at), 1949 content: plan.frozen_draft.content.clone(), 1950 tags: plan.frozen_draft.tags.clone(), 1951 event_id: Some(plan.expected_event_id.as_str().to_owned()), 1952 signature: None, 1953 event_addr: plan.public_listing_addr.as_str().to_owned(), 1954 } 1955 } 1956 1957 fn sdk_push_event_for_listing<'a>( 1958 enqueue: &ListingEnqueueReceipt, 1959 push: &'a PushOutboxReceipt, 1960 ) -> Option<&'a PushOutboxEventReceipt> { 1961 push.events 1962 .iter() 1963 .find(|event| event.event_id == enqueue.signed_event_id) 1964 } 1965 1966 fn sdk_publish_state( 1967 args: &ListingMutationArgs, 1968 push_event: Option<&PushOutboxEventReceipt>, 1969 ) -> String { 1970 match push_event.map(|event| event.final_state) { 1971 Some(PushOutboxEventState::Published) => "published", 1972 Some(PushOutboxEventState::PublishRetryable | PushOutboxEventState::FailedTerminal) => { 1973 "unavailable" 1974 } 1975 Some(_) | None if args.offline => "queued", 1976 Some(_) | None => "queued", 1977 } 1978 .to_owned() 1979 } 1980 1981 fn sdk_publish_reason( 1982 args: &ListingMutationArgs, 1983 push_event: Option<&PushOutboxEventReceipt>, 1984 ) -> Option<String> { 1985 match push_event.map(|event| event.final_state) { 1986 Some(PushOutboxEventState::Published) => None, 1987 Some(PushOutboxEventState::PublishRetryable) => Some( 1988 "SDK relay publish did not reach accepted quorum; outbox event remains retryable" 1989 .to_owned(), 1990 ), 1991 Some(PushOutboxEventState::FailedTerminal) => { 1992 Some("SDK relay publish failed terminally".to_owned()) 1993 } 1994 Some(state) => Some(format!("SDK relay push left event in state `{state:?}`")), 1995 None if args.offline => Some( 1996 "listing publish queued in SDK outbox; relay push skipped for offline mode".to_owned(), 1997 ), 1998 None => Some( 1999 "listing publish queued in SDK outbox; no ready SDK outbox event was pushed".to_owned(), 2000 ), 2001 } 2002 } 2003 2004 fn sdk_publish_actions( 2005 args: &ListingMutationArgs, 2006 push_event: Option<&PushOutboxEventReceipt>, 2007 ) -> Vec<String> { 2008 if args.offline 2009 || !matches!( 2010 push_event.map(|event| event.final_state), 2011 Some(PushOutboxEventState::Published) 2012 ) 2013 { 2014 return vec!["radroots sync push".to_owned()]; 2015 } 2016 Vec::new() 2017 } 2018 2019 fn sdk_push_target_relays(event: &PushOutboxEventReceipt) -> Vec<String> { 2020 event 2021 .relays 2022 .iter() 2023 .map(|relay| relay.relay_url.clone()) 2024 .collect() 2025 } 2026 2027 fn sdk_push_connected_relays(event: &PushOutboxEventReceipt) -> Vec<String> { 2028 event 2029 .relays 2030 .iter() 2031 .filter(|relay| relay.attempted) 2032 .map(|relay| relay.relay_url.clone()) 2033 .collect() 2034 } 2035 2036 fn sdk_push_acknowledged_relays(event: &PushOutboxEventReceipt) -> Vec<String> { 2037 event 2038 .relays 2039 .iter() 2040 .filter(|relay| { 2041 matches!( 2042 relay.outcome_kind, 2043 PushOutboxRelayOutcomeKind::Accepted 2044 | PushOutboxRelayOutcomeKind::DuplicateAccepted 2045 ) 2046 }) 2047 .map(|relay| relay.relay_url.clone()) 2048 .collect() 2049 } 2050 2051 fn sdk_push_failed_relays(event: &PushOutboxEventReceipt) -> Vec<RelayFailureView> { 2052 event 2053 .relays 2054 .iter() 2055 .filter(|relay| { 2056 !matches!( 2057 relay.outcome_kind, 2058 PushOutboxRelayOutcomeKind::Accepted 2059 | PushOutboxRelayOutcomeKind::DuplicateAccepted 2060 ) 2061 }) 2062 .map(|relay| RelayFailureView { 2063 relay: relay.relay_url.clone(), 2064 reason: relay 2065 .message 2066 .clone() 2067 .unwrap_or_else(|| sdk_relay_outcome_kind(relay.outcome_kind).to_owned()), 2068 }) 2069 .collect() 2070 } 2071 2072 fn sdk_relay_outcome_kind(kind: PushOutboxRelayOutcomeKind) -> &'static str { 2073 match kind { 2074 PushOutboxRelayOutcomeKind::Accepted => "accepted", 2075 PushOutboxRelayOutcomeKind::DuplicateAccepted => "duplicate_accepted", 2076 PushOutboxRelayOutcomeKind::Blocked => "blocked", 2077 PushOutboxRelayOutcomeKind::RateLimited => "rate_limited", 2078 PushOutboxRelayOutcomeKind::Invalid => "invalid", 2079 PushOutboxRelayOutcomeKind::PowRequired => "pow_required", 2080 PushOutboxRelayOutcomeKind::Restricted => "restricted", 2081 PushOutboxRelayOutcomeKind::AuthRequired => "auth_required", 2082 PushOutboxRelayOutcomeKind::Error => "error", 2083 PushOutboxRelayOutcomeKind::Timeout => "timeout", 2084 PushOutboxRelayOutcomeKind::ConnectionFailed => "connection_failed", 2085 PushOutboxRelayOutcomeKind::Unknown => "unknown", 2086 _ => "unknown", 2087 } 2088 } 2089 2090 pub fn update( 2091 config: &RuntimeConfig, 2092 args: &ListingMutationArgs, 2093 ) -> Result<ListingMutationView, CliSdkAdapterError> { 2094 mutate(config, args, ListingMutationOperation::Update) 2095 } 2096 2097 pub fn archive( 2098 config: &RuntimeConfig, 2099 args: &ListingMutationArgs, 2100 ) -> Result<ListingMutationView, CliSdkAdapterError> { 2101 mutate(config, args, ListingMutationOperation::Archive) 2102 } 2103 2104 fn mutate( 2105 config: &RuntimeConfig, 2106 args: &ListingMutationArgs, 2107 operation: ListingMutationOperation, 2108 ) -> Result<ListingMutationView, CliSdkAdapterError> { 2109 let contents = fs::read_to_string(&args.file).map_err(RuntimeError::from)?; 2110 let parsed = toml::from_str::<ListingDraftDocument>(&contents).map_err(|error| { 2111 RuntimeError::Config(format!( 2112 "invalid listing draft {}: {error}", 2113 args.file.display() 2114 )) 2115 })?; 2116 let context = mutation_validation_context(config)?; 2117 let mut canonical = canonicalize_draft(&parsed, &contents, &context).map_err(|error| { 2118 let issue = match error { 2119 ListingDraftValidationError::MissingSellerAccount(issue) => { 2120 return account::AccountRuntimeFailure::unresolved_with_detail( 2121 format!("{} ({})", issue.message, issue.field), 2122 json!({ 2123 "seller_actor_source": "listing_draft", 2124 "listing_file": args.file.display().to_string(), 2125 "actions": listing_bound_account_recovery_actions(args.file.as_path()), 2126 }), 2127 ) 2128 .into(); 2129 } 2130 ListingDraftValidationError::Issue(issue) => issue, 2131 }; 2132 RuntimeError::Config(format!( 2133 "invalid listing draft {}: {} ({})", 2134 args.file.display(), 2135 issue.message, 2136 issue.field 2137 )) 2138 })?; 2139 ensure_listing_bound_account(config, &canonical, args.file.as_path())?; 2140 2141 if matches!(operation, ListingMutationOperation::Archive) { 2142 canonical.listing.availability = Some(RadrootsListingAvailability::Status { 2143 status: RadrootsListingStatus::Other { 2144 value: "archived".to_owned(), 2145 }, 2146 }); 2147 } 2148 2149 if config.output.dry_run { 2150 validate_configured_listing_signer(config, &canonical)?; 2151 } 2152 2153 mutate_via_sdk_from_canonical(config, args, operation, canonical) 2154 } 2155 2156 fn mutate_via_sdk_from_canonical( 2157 config: &RuntimeConfig, 2158 args: &ListingMutationArgs, 2159 operation: ListingMutationOperation, 2160 canonical: CanonicalListingDraft, 2161 ) -> Result<ListingMutationView, CliSdkAdapterError> { 2162 let actor = RadrootsActorContext::local_account( 2163 canonical.seller_pubkey.as_str(), 2164 canonical.seller_account_id.clone(), 2165 [RadrootsActorRole::Seller], 2166 ) 2167 .map_err(|error| RuntimeError::Config(format!("invalid listing SDK actor: {error}")))?; 2168 let document = RadrootsListingDraftDocumentV1::new(canonical.listing.clone()); 2169 if config.output.dry_run { 2170 let session = CliSdkSession::connect_memory(config)?; 2171 let plan = session 2172 .sdk() 2173 .listings() 2174 .prepare_publish(ListingPreparePublishRequest::from_document(actor, document))?; 2175 return Ok(sdk_prepared_publish_view( 2176 config, args, operation, &canonical, plan, 2177 )); 2178 } 2179 2180 let session = CliSdkSession::connect_for_actor( 2181 config, 2182 Some(canonical.seller_account_id.as_str()), 2183 canonical.seller_pubkey.as_str(), 2184 "listing seller", 2185 )?; 2186 let mut request = ListingEnqueuePublishRequest::from_document( 2187 actor, 2188 document, 2189 sdk_relay_target_policy(config), 2190 ); 2191 if let Some(idempotency_key) = args.idempotency_key.as_deref() { 2192 request = request.try_with_idempotency_key(idempotency_key)?; 2193 } 2194 let enqueue_receipt = session.block_on(session.sdk().listings().enqueue_publish(request))?; 2195 let push_receipt = if args.offline { 2196 None 2197 } else { 2198 Some( 2199 session.block_on( 2200 session.sdk().sync().push_outbox( 2201 PushOutboxRequest::new() 2202 .with_limit(1) 2203 .with_relay_url_policy(sdk_relay_url_policy(config)), 2204 ), 2205 )?, 2206 ) 2207 }; 2208 Ok(sdk_enqueued_publish_view( 2209 config, 2210 args, 2211 operation, 2212 &canonical, 2213 enqueue_receipt, 2214 push_receipt, 2215 )) 2216 } 2217 2218 fn scaffold_contents(draft: &ListingDraftDocument) -> Result<String, RuntimeError> { 2219 let toml = toml::to_string_pretty(draft).map_err(|error| { 2220 RuntimeError::Config(format!("failed to render listing draft: {error}")) 2221 })?; 2222 Ok(format!( 2223 "# radroots listing draft v1\n# this scaffold applies selected farm defaults and provided product inputs when available\n# review any remaining empty fields, then run `radroots listing validate <file>`\n\n{toml}" 2224 )) 2225 } 2226 2227 fn validation_context(config: &RuntimeConfig) -> Result<ListingValidationContext, RuntimeError> { 2228 Ok(ListingValidationContext { 2229 farm_setup_action: farm_setup_action(config)?, 2230 }) 2231 } 2232 2233 fn mutation_validation_context( 2234 config: &RuntimeConfig, 2235 ) -> Result<ListingValidationContext, RuntimeError> { 2236 validation_context(config) 2237 } 2238 2239 fn canonicalize_draft( 2240 draft: &ListingDraftDocument, 2241 contents: &str, 2242 _context: &ListingValidationContext, 2243 ) -> Result<CanonicalListingDraft, ListingDraftValidationError> { 2244 if draft.version != 1 { 2245 return Err(issue_for_field( 2246 contents, 2247 "version", 2248 format!("unsupported listing draft version `{}`", draft.version), 2249 ) 2250 .into()); 2251 } 2252 if draft.kind.trim() != DRAFT_KIND { 2253 return Err(issue_for_field( 2254 contents, 2255 "kind", 2256 format!("unsupported listing draft kind `{}`", draft.kind), 2257 ) 2258 .into()); 2259 } 2260 2261 let listing_id = draft.listing.d_tag.trim().to_owned(); 2262 if !is_d_tag_base64url(&listing_id) { 2263 return Err(issue_for_field( 2264 contents, 2265 "listing.d_tag", 2266 "listing d_tag must be a 22-character base64url identifier", 2267 ) 2268 .into()); 2269 } 2270 2271 let seller_account_id = 2272 if let Some(account_id) = non_empty(draft.seller_actor.account_id.clone()) { 2273 account_id 2274 } else { 2275 return Err(ListingDraftValidationError::MissingSellerAccount( 2276 issue_for_field( 2277 contents, 2278 "seller_actor.account_id", 2279 "missing listing seller_actor account_id", 2280 ), 2281 )); 2282 }; 2283 2284 let seller_pubkey = if let Some(pubkey) = non_empty(draft.seller_actor.pubkey.clone()) { 2285 pubkey 2286 } else { 2287 return Err(ListingDraftValidationError::MissingSellerAccount( 2288 issue_for_field( 2289 contents, 2290 "seller_actor.pubkey", 2291 "missing listing seller_actor pubkey", 2292 ), 2293 )); 2294 }; 2295 2296 let seller_actor_source = if let Some(source) = non_empty(draft.seller_actor.source.clone()) { 2297 source 2298 } else { 2299 return Err(ListingDraftValidationError::MissingSellerAccount( 2300 issue_for_field( 2301 contents, 2302 "seller_actor.source", 2303 "missing listing seller_actor source", 2304 ), 2305 )); 2306 }; 2307 if !matches!( 2308 seller_actor_source.as_str(), 2309 LISTING_SELLER_ACTOR_SOURCE_FARM_CONFIG 2310 | LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT 2311 | LISTING_SELLER_ACTOR_SOURCE_REBIND 2312 ) { 2313 return Err(issue_for_field( 2314 contents, 2315 "seller_actor.source", 2316 format!("unsupported listing seller_actor source `{seller_actor_source}`"), 2317 ) 2318 .into()); 2319 } 2320 2321 let farm_d_tag = if let Some(d_tag) = non_empty(draft.listing.farm_d_tag.clone()) { 2322 d_tag 2323 } else { 2324 return Err( 2325 issue_for_field(contents, "listing.farm_d_tag", "missing listing farm_d_tag").into(), 2326 ); 2327 }; 2328 if !is_d_tag_base64url(&farm_d_tag) { 2329 return Err(issue_for_field( 2330 contents, 2331 "listing.farm_d_tag", 2332 "farm_d_tag must be a 22-character base64url identifier", 2333 ) 2334 .into()); 2335 } 2336 2337 let quantity_amount = parse_decimal_field( 2338 draft.primary_bin.quantity_amount.as_str(), 2339 contents, 2340 "primary_bin.quantity_amount", 2341 )?; 2342 let quantity_unit = parse_unit_field( 2343 draft.primary_bin.quantity_unit.as_str(), 2344 contents, 2345 "primary_bin.quantity_unit", 2346 )?; 2347 let quantity = RadrootsCoreQuantity::new(quantity_amount, quantity_unit) 2348 .with_optional_label(non_empty(draft.primary_bin.label.clone())) 2349 .to_canonical() 2350 .map_err(|error| { 2351 issue_for_field( 2352 contents, 2353 "primary_bin.quantity_unit", 2354 format!("invalid primary_bin quantity unit conversion: {error}"), 2355 ) 2356 })?; 2357 2358 let price_amount = parse_decimal_field( 2359 draft.primary_bin.price_amount.as_str(), 2360 contents, 2361 "primary_bin.price_amount", 2362 )?; 2363 let price_currency = parse_currency_field( 2364 draft.primary_bin.price_currency.as_str(), 2365 contents, 2366 "primary_bin.price_currency", 2367 )?; 2368 let price_per_amount = parse_decimal_field( 2369 draft.primary_bin.price_per_amount.as_str(), 2370 contents, 2371 "primary_bin.price_per_amount", 2372 )?; 2373 let price_per_unit = parse_unit_field( 2374 draft.primary_bin.price_per_unit.as_str(), 2375 contents, 2376 "primary_bin.price_per_unit", 2377 )?; 2378 let price = RadrootsCoreQuantityPrice::new( 2379 RadrootsCoreMoney::new(price_amount, price_currency), 2380 RadrootsCoreQuantity::new(price_per_amount, price_per_unit), 2381 ) 2382 .try_to_canonical_unit_price() 2383 .map_err(|error| { 2384 issue_for_field( 2385 contents, 2386 "primary_bin.price_per_unit", 2387 format!("invalid primary_bin price definition: {error:?}"), 2388 ) 2389 })?; 2390 2391 let inventory_available = parse_decimal_field( 2392 draft.inventory.available.as_str(), 2393 contents, 2394 "inventory.available", 2395 )?; 2396 let availability = build_availability(draft, contents)?; 2397 let delivery_method = build_delivery_method(draft, contents)?; 2398 let location = build_location(draft); 2399 let discounts = build_listing_discounts( 2400 draft, 2401 contents, 2402 draft.primary_bin.bin_id.trim(), 2403 price_currency, 2404 )?; 2405 let primary_bin_id = 2406 protocol_inventory_bin_id(draft.primary_bin.bin_id.trim(), "primary_bin.bin_id").map_err( 2407 |error| { 2408 issue_for_field( 2409 contents, 2410 "primary_bin.bin_id", 2411 format!("invalid primary_bin bin id: {error}"), 2412 ) 2413 }, 2414 )?; 2415 2416 let listing = RadrootsListing { 2417 d_tag: protocol_d_tag(listing_id.as_str(), "listing d_tag").map_err(|error| { 2418 issue_for_field( 2419 contents, 2420 "listing.d_tag", 2421 format!("invalid listing d_tag: {error}"), 2422 ) 2423 })?, 2424 published_at: None, 2425 farm: RadrootsFarmRef { 2426 pubkey: seller_pubkey.clone(), 2427 d_tag: farm_d_tag.clone(), 2428 }, 2429 product: RadrootsListingProduct { 2430 key: draft.product.key.trim().to_owned(), 2431 title: draft.product.title.trim().to_owned(), 2432 category: draft.product.category.trim().to_owned(), 2433 summary: non_empty(draft.product.summary.clone()), 2434 process: None, 2435 lot: None, 2436 location: None, 2437 profile: None, 2438 year: None, 2439 }, 2440 primary_bin_id: primary_bin_id.clone(), 2441 bins: vec![RadrootsListingBin { 2442 bin_id: primary_bin_id, 2443 quantity, 2444 price_per_canonical_unit: price, 2445 display_amount: None, 2446 display_unit: None, 2447 display_label: non_empty(draft.primary_bin.label.clone()), 2448 display_price: None, 2449 display_price_unit: None, 2450 }], 2451 resource_area: None, 2452 plot: None, 2453 discounts, 2454 inventory_available: Some(inventory_available), 2455 availability: Some(availability), 2456 delivery_method: Some(delivery_method), 2457 location: Some(location), 2458 images: None, 2459 }; 2460 2461 Ok(CanonicalListingDraft { 2462 listing_id, 2463 seller_account_id, 2464 seller_pubkey, 2465 seller_actor_source, 2466 farm_d_tag, 2467 listing, 2468 }) 2469 } 2470 2471 fn build_availability( 2472 draft: &ListingDraftDocument, 2473 contents: &str, 2474 ) -> Result<RadrootsListingAvailability, ListingValidationIssueView> { 2475 let kind = if draft.availability.kind.trim().is_empty() { 2476 if draft.availability.start.is_some() || draft.availability.end.is_some() { 2477 "window" 2478 } else { 2479 "status" 2480 } 2481 } else { 2482 draft.availability.kind.trim() 2483 }; 2484 2485 match kind { 2486 "status" => { 2487 let status = draft.availability.status.trim(); 2488 if status.is_empty() { 2489 return Err(issue_for_field( 2490 contents, 2491 "availability.status", 2492 "missing availability status", 2493 )); 2494 } 2495 Ok(RadrootsListingAvailability::Status { 2496 status: match status { 2497 "active" => RadrootsListingStatus::Active, 2498 "sold" => RadrootsListingStatus::Sold, 2499 other => RadrootsListingStatus::Other { 2500 value: other.to_owned(), 2501 }, 2502 }, 2503 }) 2504 } 2505 "window" => Ok(RadrootsListingAvailability::Window { 2506 start: draft.availability.start, 2507 end: draft.availability.end, 2508 }), 2509 _ => Err(issue_for_field( 2510 contents, 2511 "availability.kind", 2512 format!("unsupported availability kind `{kind}`"), 2513 )), 2514 } 2515 } 2516 2517 fn build_delivery_method( 2518 draft: &ListingDraftDocument, 2519 contents: &str, 2520 ) -> Result<RadrootsListingDeliveryMethod, ListingValidationIssueView> { 2521 let method = draft.delivery.method.trim(); 2522 if method.is_empty() { 2523 return Err(issue_for_field( 2524 contents, 2525 "delivery.method", 2526 "missing delivery method", 2527 )); 2528 } 2529 2530 Ok(match method { 2531 "pickup" => RadrootsListingDeliveryMethod::Pickup, 2532 "local_delivery" => RadrootsListingDeliveryMethod::LocalDelivery, 2533 "shipping" => RadrootsListingDeliveryMethod::Shipping, 2534 other => RadrootsListingDeliveryMethod::Other { 2535 method: other.to_owned(), 2536 }, 2537 }) 2538 } 2539 2540 fn build_location(draft: &ListingDraftDocument) -> RadrootsListingLocation { 2541 RadrootsListingLocation { 2542 primary: draft.location.primary.trim().to_owned(), 2543 city: draft.location.city.clone().and_then(non_empty), 2544 region: draft.location.region.clone().and_then(non_empty), 2545 country: draft.location.country.clone().and_then(non_empty), 2546 lat: None, 2547 lng: None, 2548 geohash: None, 2549 } 2550 } 2551 2552 fn build_listing_discounts( 2553 draft: &ListingDraftDocument, 2554 contents: &str, 2555 primary_bin_id: &str, 2556 price_currency: RadrootsCoreCurrency, 2557 ) -> Result<Option<Vec<RadrootsCoreDiscount>>, ListingValidationIssueView> { 2558 let mut discounts = Vec::new(); 2559 for (index, discount) in draft.discounts.iter().enumerate() { 2560 let field_prefix = format!("discounts.{index}"); 2561 if discount.id.trim().is_empty() { 2562 return Err(issue_for_field( 2563 contents, 2564 field_prefix.as_str(), 2565 "discount id must not be empty", 2566 )); 2567 } 2568 let bin_id = discount 2569 .bin_id 2570 .as_deref() 2571 .map(str::trim) 2572 .filter(|value| !value.is_empty()) 2573 .unwrap_or(primary_bin_id) 2574 .to_owned(); 2575 let min = discount.min_bin_count.unwrap_or(1); 2576 if min == 0 { 2577 return Err(issue_for_field( 2578 contents, 2579 field_prefix.as_str(), 2580 "discount min_bin_count must be greater than zero", 2581 )); 2582 } 2583 let value = match discount.kind.trim() { 2584 "percent" => { 2585 let raw = discount.value.trim(); 2586 if raw.is_empty() { 2587 return Err(issue_for_field( 2588 contents, 2589 field_prefix.as_str(), 2590 "percent discount requires value", 2591 )); 2592 } 2593 let percent = raw.parse::<RadrootsCorePercent>().map_err(|error| { 2594 issue_for_field( 2595 contents, 2596 field_prefix.as_str(), 2597 format!("percent discount value is invalid: {error}"), 2598 ) 2599 })?; 2600 RadrootsCoreDiscountValue::Percent(percent) 2601 } 2602 "amount" => { 2603 let raw_amount = discount.amount.trim(); 2604 if raw_amount.is_empty() { 2605 return Err(issue_for_field( 2606 contents, 2607 field_prefix.as_str(), 2608 "amount discount requires amount", 2609 )); 2610 } 2611 let amount = parse_decimal_field(raw_amount, contents, field_prefix.as_str())?; 2612 let currency = if discount.currency.trim().is_empty() { 2613 price_currency 2614 } else { 2615 parse_currency_field( 2616 discount.currency.as_str(), 2617 contents, 2618 field_prefix.as_str(), 2619 )? 2620 }; 2621 RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new(amount, currency)) 2622 } 2623 other => { 2624 return Err(issue_for_field( 2625 contents, 2626 field_prefix.as_str(), 2627 format!("unsupported discount kind `{other}`"), 2628 )); 2629 } 2630 }; 2631 let discount = RadrootsCoreDiscount { 2632 scope: RadrootsCoreDiscountScope::Bin, 2633 threshold: RadrootsCoreDiscountThreshold::BinCount { bin_id, min }, 2634 value, 2635 }; 2636 if !discount.is_non_negative() { 2637 return Err(issue_for_field( 2638 contents, 2639 field_prefix.as_str(), 2640 "discount value must not be negative", 2641 )); 2642 } 2643 discounts.push(discount); 2644 } 2645 Ok((!discounts.is_empty()).then_some(discounts)) 2646 } 2647 2648 fn listing_bound_account_issue( 2649 config: &RuntimeConfig, 2650 canonical: &CanonicalListingDraft, 2651 contents: &str, 2652 ) -> Result<Option<ListingValidationIssueView>, RuntimeError> { 2653 let Some(account) = configured_account(config, &canonical.seller_account_id)? else { 2654 return Ok(Some(issue_for_field( 2655 contents, 2656 "seller_actor.account_id", 2657 format!( 2658 "listing seller_actor account_id `{}` is not present in the local account store", 2659 canonical.seller_account_id 2660 ), 2661 ))); 2662 }; 2663 let account_pubkey = account.record.public_identity.public_key_hex; 2664 if !account_pubkey.eq_ignore_ascii_case(canonical.seller_pubkey.as_str()) { 2665 return Ok(Some(issue_for_field( 2666 contents, 2667 "seller_actor.pubkey", 2668 format!( 2669 "listing seller_actor pubkey `{}` does not match account `{}` pubkey `{account_pubkey}`", 2670 canonical.seller_pubkey, canonical.seller_account_id 2671 ), 2672 ))); 2673 } 2674 Ok(None) 2675 } 2676 2677 fn ensure_listing_bound_account( 2678 config: &RuntimeConfig, 2679 canonical: &CanonicalListingDraft, 2680 file: &Path, 2681 ) -> Result<(), RuntimeError> { 2682 validate_invocation_account_matches_bound(config, canonical, file)?; 2683 let Some(account) = configured_account(config, &canonical.seller_account_id)? else { 2684 return Err(account::AccountRuntimeFailure::unresolved_with_detail( 2685 format!( 2686 "listing-bound seller account `{}` is not present in the local account store", 2687 canonical.seller_account_id 2688 ), 2689 json!({ 2690 "seller_actor_source": canonical.seller_actor_source, 2691 "listing_seller_account_id": canonical.seller_account_id, 2692 "listing_file": file.display().to_string(), 2693 "actions": listing_bound_account_recovery_actions(file), 2694 }), 2695 ) 2696 .into()); 2697 }; 2698 let account_pubkey = account.record.public_identity.public_key_hex; 2699 if !account_pubkey.eq_ignore_ascii_case(canonical.seller_pubkey.as_str()) { 2700 return Err(account::AccountRuntimeFailure::mismatch_with_detail( 2701 format!( 2702 "account mismatch: listing-bound seller account `{}` pubkey `{account_pubkey}` cannot sign listing seller_pubkey `{}`", 2703 canonical.seller_account_id, canonical.seller_pubkey 2704 ), 2705 json!({ 2706 "seller_actor_source": canonical.seller_actor_source, 2707 "listing_seller_account_id": canonical.seller_account_id, 2708 "listing_seller_pubkey": canonical.seller_pubkey, 2709 "account_pubkey": account_pubkey, 2710 "listing_file": file.display().to_string(), 2711 "actions": listing_bound_account_recovery_actions(file), 2712 }), 2713 ) 2714 .into()); 2715 } 2716 Ok(()) 2717 } 2718 2719 fn validate_invocation_account_matches_bound( 2720 config: &RuntimeConfig, 2721 canonical: &CanonicalListingDraft, 2722 file: &Path, 2723 ) -> Result<(), RuntimeError> { 2724 let Some(selector) = config 2725 .account 2726 .selector 2727 .as_deref() 2728 .map(str::trim) 2729 .filter(|selector| !selector.is_empty()) 2730 else { 2731 return Ok(()); 2732 }; 2733 let attempted = account::resolve_account_selector(config, selector)?; 2734 if attempted.record.account_id.to_string() == canonical.seller_account_id { 2735 return Ok(()); 2736 } 2737 Err(account::AccountRuntimeFailure::mismatch_with_detail( 2738 format!( 2739 "account mismatch: listing draft is bound to seller account `{}`; invocation selected `{}`", 2740 canonical.seller_account_id, attempted.record.account_id 2741 ), 2742 json!({ 2743 "seller_actor_source": canonical.seller_actor_source, 2744 "listing_seller_account_id": canonical.seller_account_id, 2745 "attempted_seller_account_id": attempted.record.account_id.to_string(), 2746 "listing_file": file.display().to_string(), 2747 "actions": listing_bound_account_recovery_actions(file), 2748 }), 2749 ) 2750 .into()) 2751 } 2752 2753 fn listing_bound_account_recovery_actions(file: &Path) -> Vec<String> { 2754 vec![ 2755 "radroots account import <path>".to_owned(), 2756 format!("radroots listing rebind {} <selector>", file.display()), 2757 ] 2758 } 2759 2760 fn invalid_validation_view( 2761 file: &Path, 2762 draft: &ListingDraftDocument, 2763 context: &ListingValidationContext, 2764 issue: ListingValidationIssueView, 2765 ) -> ListingValidateView { 2766 let mut actions = vec![format!("edit {}", file.display())]; 2767 if draft.seller_actor.account_id.trim().is_empty() { 2768 actions.push("radroots account create".to_owned()); 2769 } else { 2770 actions.push(format!( 2771 "radroots listing rebind {} <selector>", 2772 file.display() 2773 )); 2774 } 2775 if draft.listing.farm_d_tag.trim().is_empty() { 2776 actions.push(context.farm_setup_action.clone()); 2777 } 2778 2779 ListingValidateView { 2780 state: "invalid".to_owned(), 2781 source: LISTING_SOURCE.to_owned(), 2782 file: file.display().to_string(), 2783 valid: false, 2784 listing_id: non_empty(draft.listing.d_tag.clone()), 2785 seller_account_id: non_empty(draft.seller_actor.account_id.clone()), 2786 seller_pubkey: non_empty(draft.seller_actor.pubkey.clone()), 2787 seller_actor_source: non_empty(draft.seller_actor.source.clone()), 2788 farm_d_tag: non_empty(draft.listing.farm_d_tag.clone()), 2789 issues: vec![issue], 2790 actions, 2791 } 2792 } 2793 2794 fn validate_configured_listing_signer( 2795 config: &RuntimeConfig, 2796 canonical: &CanonicalListingDraft, 2797 ) -> Result<(), RuntimeError> { 2798 validate_configured_signer_for_actor( 2799 config, 2800 Some(canonical.seller_account_id.as_str()), 2801 canonical.seller_pubkey.as_str(), 2802 "listing seller", 2803 ) 2804 } 2805 2806 fn issue_from_trade_validation( 2807 error: RadrootsTradeValidationListingError, 2808 contents: &str, 2809 ) -> ListingValidationIssueView { 2810 match error { 2811 RadrootsTradeValidationListingError::InvalidSeller => issue_for_field( 2812 contents, 2813 "seller_actor.pubkey", 2814 "listing author does not match the farm pubkey", 2815 ), 2816 RadrootsTradeValidationListingError::MissingTitle => { 2817 issue_for_field(contents, "product.title", "missing listing title") 2818 } 2819 RadrootsTradeValidationListingError::MissingDescription => { 2820 issue_for_field(contents, "product.summary", "missing listing description") 2821 } 2822 RadrootsTradeValidationListingError::MissingProductType => { 2823 issue_for_field(contents, "product.category", "missing listing product type") 2824 } 2825 RadrootsTradeValidationListingError::MissingBins 2826 | RadrootsTradeValidationListingError::MissingPrimaryBin 2827 | RadrootsTradeValidationListingError::InvalidBin => { 2828 issue_for_field(contents, "primary_bin.bin_id", error.to_string()) 2829 } 2830 RadrootsTradeValidationListingError::InvalidPrice => issue_for_field( 2831 contents, 2832 "primary_bin.price_amount", 2833 "invalid listing price", 2834 ), 2835 RadrootsTradeValidationListingError::MissingInventory 2836 | RadrootsTradeValidationListingError::InvalidInventory => { 2837 issue_for_field(contents, "inventory.available", error.to_string()) 2838 } 2839 RadrootsTradeValidationListingError::MissingAvailability => issue_for_field( 2840 contents, 2841 "availability.status", 2842 "missing listing availability", 2843 ), 2844 RadrootsTradeValidationListingError::MissingLocation => { 2845 issue_for_field(contents, "location.primary", "missing listing location") 2846 } 2847 RadrootsTradeValidationListingError::MissingDeliveryMethod => issue_for_field( 2848 contents, 2849 "delivery.method", 2850 "missing listing delivery method", 2851 ), 2852 other => issue_for_field(contents, "listing", other.to_string()), 2853 } 2854 } 2855 2856 fn authoring_defaults(config: &RuntimeConfig) -> Result<ListingAuthoringDefaults, RuntimeError> { 2857 let account_resolution = account::resolve_account_resolution(config)?; 2858 let Some(selected_account) = account_resolution.resolved_account.clone() else { 2859 return Err(account::AccountRuntimeFailure::unresolved_with_detail( 2860 "no resolved account is available for listing seller actor", 2861 json!({ 2862 "seller_actor_source": LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT, 2863 "actions": [ 2864 "radroots account create", 2865 "radroots account import <path>", 2866 ], 2867 }), 2868 ) 2869 .into()); 2870 }; 2871 let mut defaults = ListingAuthoringDefaults { 2872 farm_config_present: false, 2873 farm_defaults_ready: false, 2874 farm_next_action: Some(farm_setup_action(config)?), 2875 farm_reason: Some( 2876 "selected farm draft not found; delivery, location, and farm defaults were left blank" 2877 .to_owned(), 2878 ), 2879 farm_name: None, 2880 seller_account_id: selected_account.record.account_id.to_string(), 2881 seller_pubkey: selected_account 2882 .record 2883 .public_identity 2884 .public_key_hex 2885 .clone(), 2886 seller_actor_source: LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT.to_owned(), 2887 selected_farm_d_tag: None, 2888 delivery_method: None, 2889 location: None, 2890 }; 2891 2892 let Some(resolved) = farm_config::load(config, None)? else { 2893 return Ok(defaults); 2894 }; 2895 let Some(account) = configured_account(config, &resolved.document.selection.account)? else { 2896 let account_id = resolved.document.selection.account.clone(); 2897 return Err(account::AccountRuntimeFailure::unresolved_with_detail( 2898 format!( 2899 "farm-bound seller account `{account_id}` is not present in the local account store" 2900 ), 2901 json!({ 2902 "seller_actor_source": "farm_config", 2903 "farm_bound_seller_account_id": account_id, 2904 "actions": [ 2905 "radroots account import <path>", 2906 "radroots farm rebind <selector>", 2907 ], 2908 }), 2909 ) 2910 .into()); 2911 }; 2912 2913 defaults.farm_config_present = true; 2914 defaults.farm_name = resolved 2915 .document 2916 .profile 2917 .display_name 2918 .clone() 2919 .and_then(non_empty) 2920 .or_else(|| non_empty(resolved.document.profile.name.clone())) 2921 .or_else(|| non_empty(resolved.document.farm.name.clone())); 2922 defaults.seller_account_id = resolved.document.selection.account.clone(); 2923 defaults.seller_pubkey = account.record.public_identity.public_key_hex.clone(); 2924 defaults.seller_actor_source = LISTING_SELLER_ACTOR_SOURCE_FARM_CONFIG.to_owned(); 2925 defaults.selected_farm_d_tag = Some(resolved.document.selection.farm_d_tag.clone()); 2926 let draft_missing = farm_config::missing_fields(&resolved.document); 2927 defaults.farm_defaults_ready = !draft_missing.iter().any(|field| { 2928 matches!( 2929 field, 2930 farm_config::FarmMissingField::Location | farm_config::FarmMissingField::Delivery 2931 ) 2932 }); 2933 if defaults.farm_defaults_ready { 2934 defaults.delivery_method = Some(resolved.document.listing_defaults.delivery_method.clone()); 2935 defaults.location = Some(draft_location_from_model( 2936 &resolved.document.listing_defaults.location, 2937 )); 2938 defaults.farm_next_action = None; 2939 defaults.farm_reason = None; 2940 } else { 2941 defaults.farm_next_action = Some("radroots farm readiness check".to_owned()); 2942 defaults.farm_reason = Some( 2943 "selected farm draft is missing delivery or location defaults; those fields were left blank" 2944 .to_owned(), 2945 ); 2946 } 2947 Ok(defaults) 2948 } 2949 2950 fn draft_location_from_model(location: &RadrootsListingLocation) -> ListingDraftLocation { 2951 ListingDraftLocation { 2952 primary: location.primary.clone(), 2953 city: location.city.clone(), 2954 region: location.region.clone(), 2955 country: location.country.clone(), 2956 } 2957 } 2958 2959 fn farm_setup_action(_config: &RuntimeConfig) -> Result<String, RuntimeError> { 2960 Ok("radroots farm create".to_owned()) 2961 } 2962 2963 fn drafts_dir(config: &RuntimeConfig) -> PathBuf { 2964 config.paths.app_data_root.join(LISTING_DRAFTS_DIR) 2965 } 2966 2967 fn file_stem(path: &Path) -> String { 2968 path.file_stem() 2969 .and_then(|value| value.to_str()) 2970 .unwrap_or("unknown") 2971 .to_owned() 2972 } 2973 2974 fn modified_unix(path: &Path) -> Option<u64> { 2975 let modified = fs::metadata(path).ok()?.modified().ok()?; 2976 modified 2977 .duration_since(UNIX_EPOCH) 2978 .ok() 2979 .map(|value| value.as_secs()) 2980 } 2981 2982 fn configured_account( 2983 config: &RuntimeConfig, 2984 account_id: &str, 2985 ) -> Result<Option<account::AccountRecordView>, RuntimeError> { 2986 let snapshot = account::snapshot(config)?; 2987 Ok(snapshot 2988 .accounts 2989 .into_iter() 2990 .find(|account| account.record.account_id.as_str() == account_id)) 2991 } 2992 2993 fn parse_decimal_field( 2994 value: &str, 2995 contents: &str, 2996 field: &str, 2997 ) -> Result<RadrootsCoreDecimal, ListingValidationIssueView> { 2998 value.trim().parse::<RadrootsCoreDecimal>().map_err(|_| { 2999 issue_for_field( 3000 contents, 3001 field, 3002 format!("`{field}` must be a valid decimal value"), 3003 ) 3004 }) 3005 } 3006 3007 fn parse_unit_field( 3008 value: &str, 3009 contents: &str, 3010 field: &str, 3011 ) -> Result<RadrootsCoreUnit, ListingValidationIssueView> { 3012 value.parse::<RadrootsCoreUnit>().map_err(|_| { 3013 issue_for_field( 3014 contents, 3015 field, 3016 format!("`{field}` must be a valid unit code"), 3017 ) 3018 }) 3019 } 3020 3021 fn parse_currency_field( 3022 value: &str, 3023 contents: &str, 3024 field: &str, 3025 ) -> Result<RadrootsCoreCurrency, ListingValidationIssueView> { 3026 let upper = value.trim().to_ascii_uppercase(); 3027 RadrootsCoreCurrency::from_str_upper(&upper).map_err(|_| { 3028 issue_for_field( 3029 contents, 3030 field, 3031 format!("`{field}` must be a valid ISO currency code"), 3032 ) 3033 }) 3034 } 3035 3036 fn issue_for_field( 3037 contents: &str, 3038 field: &str, 3039 message: impl Into<String>, 3040 ) -> ListingValidationIssueView { 3041 ListingValidationIssueView { 3042 field: field.to_owned(), 3043 message: message.into(), 3044 line: line_for_field(contents, field), 3045 } 3046 } 3047 3048 fn line_for_field(contents: &str, field: &str) -> Option<usize> { 3049 let needles: &[&str] = match field { 3050 "version" => &["version ="], 3051 "kind" => &["kind ="], 3052 "listing.d_tag" => &["d_tag ="], 3053 "listing.farm_d_tag" => &["farm_d_tag ="], 3054 "seller_actor.account_id" => &["[seller_actor]", "account_id ="], 3055 "seller_actor.pubkey" => &["[seller_actor]", "pubkey ="], 3056 "seller_actor.source" => &["[seller_actor]", "source ="], 3057 "product.key" => &["key ="], 3058 "product.title" => &["title ="], 3059 "product.category" => &["category ="], 3060 "product.summary" => &["summary ="], 3061 "primary_bin.bin_id" => &["bin_id ="], 3062 "primary_bin.quantity_amount" => &["quantity_amount ="], 3063 "primary_bin.quantity_unit" => &["quantity_unit ="], 3064 "primary_bin.price_amount" => &["price_amount ="], 3065 "primary_bin.price_currency" => &["price_currency ="], 3066 "primary_bin.price_per_amount" => &["price_per_amount ="], 3067 "primary_bin.price_per_unit" => &["price_per_unit ="], 3068 "inventory.available" => &["available ="], 3069 "availability.kind" => &["[availability]", "kind ="], 3070 "availability.status" => &["status ="], 3071 "delivery.method" => &["method ="], 3072 "location.primary" => &["primary ="], 3073 field if field.starts_with("discounts.") => &["[[discounts]]"], 3074 _ => &[], 3075 }; 3076 for needle in needles { 3077 if let Some(line) = contents.lines().position(|line| line.contains(needle)) { 3078 return Some(line + 1); 3079 } 3080 } 3081 None 3082 } 3083 3084 fn line_for_offset(contents: &str, offset: usize) -> usize { 3085 let mut seen = 0usize; 3086 for (index, line) in contents.lines().enumerate() { 3087 seen += line.len() + 1; 3088 if seen >= offset { 3089 return index + 1; 3090 } 3091 } 3092 contents.lines().count().max(1) 3093 } 3094 3095 fn non_empty(value: String) -> Option<String> { 3096 let trimmed = value.trim(); 3097 if trimmed.is_empty() { 3098 None 3099 } else { 3100 Some(trimmed.to_owned()) 3101 } 3102 } 3103 3104 fn generate_d_tag() -> String { 3105 let nanos = SystemTime::now() 3106 .duration_since(UNIX_EPOCH) 3107 .map(|duration| duration.as_nanos()) 3108 .unwrap_or_default(); 3109 let counter = D_TAG_COUNTER.fetch_add(1, Ordering::Relaxed) as u128; 3110 let mixed = nanos ^ counter; 3111 encode_base64url_no_pad(mixed.to_be_bytes()) 3112 } 3113 3114 fn encode_base64url_no_pad(bytes: [u8; 16]) -> String { 3115 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; 3116 let mut output = String::with_capacity(22); 3117 let mut index = 0usize; 3118 while index + 3 <= bytes.len() { 3119 let block = ((bytes[index] as u32) << 16) 3120 | ((bytes[index + 1] as u32) << 8) 3121 | (bytes[index + 2] as u32); 3122 output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); 3123 output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); 3124 output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char); 3125 output.push(ALPHABET[(block & 0x3f) as usize] as char); 3126 index += 3; 3127 } 3128 let remaining = bytes.len() - index; 3129 if remaining == 1 { 3130 let block = (bytes[index] as u32) << 16; 3131 output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); 3132 output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); 3133 } else if remaining == 2 { 3134 let block = ((bytes[index] as u32) << 16) | ((bytes[index + 1] as u32) << 8); 3135 output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); 3136 output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); 3137 output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char); 3138 } 3139 output 3140 } 3141 3142 #[cfg(test)] 3143 mod tests { 3144 use super::{ 3145 DRAFT_KIND, ListingDraftDocument, encode_base64url_no_pad, generate_d_tag, 3146 sdk_publish_actions, sdk_publish_reason, sdk_publish_state, sdk_push_acknowledged_relays, 3147 sdk_push_failed_relays, 3148 }; 3149 use crate::cli::global::ListingMutationArgs; 3150 use radroots_events::ids::RadrootsEventId; 3151 use radroots_events_codec::d_tag::is_d_tag_base64url; 3152 use radroots_sdk::{ 3153 PushOutboxEventReceipt, PushOutboxEventState, PushOutboxRelayOutcomeKind, 3154 PushOutboxRelayReceipt, 3155 }; 3156 3157 #[test] 3158 fn generated_listing_d_tag_is_valid_base64url() { 3159 let d_tag = generate_d_tag(); 3160 assert!(is_d_tag_base64url(&d_tag)); 3161 } 3162 3163 #[test] 3164 fn base64url_encoder_produces_twenty_two_characters_for_sixteen_bytes() { 3165 let encoded = encode_base64url_no_pad([0u8; 16]); 3166 assert_eq!(encoded.len(), 22); 3167 assert!(is_d_tag_base64url(&encoded)); 3168 } 3169 3170 #[test] 3171 fn sdk_push_receipt_helpers_map_published_and_auth_required_states() { 3172 let accepted = sdk_push_event( 3173 PushOutboxEventState::Published, 3174 PushOutboxRelayOutcomeKind::Accepted, 3175 Some("accepted".to_owned()), 3176 ); 3177 let args = listing_mutation_args(false); 3178 3179 assert_eq!(sdk_publish_state(&args, Some(&accepted)), "published"); 3180 assert!(sdk_publish_reason(&args, Some(&accepted)).is_none()); 3181 assert!(sdk_publish_actions(&args, Some(&accepted)).is_empty()); 3182 assert_eq!( 3183 sdk_push_acknowledged_relays(&accepted), 3184 vec!["ws://127.0.0.1:19000".to_owned()] 3185 ); 3186 assert!(sdk_push_failed_relays(&accepted).is_empty()); 3187 3188 let auth_required = sdk_push_event( 3189 PushOutboxEventState::PublishRetryable, 3190 PushOutboxRelayOutcomeKind::AuthRequired, 3191 Some("auth required".to_owned()), 3192 ); 3193 let failed = sdk_push_failed_relays(&auth_required); 3194 3195 assert_eq!( 3196 sdk_publish_state(&args, Some(&auth_required)), 3197 "unavailable" 3198 ); 3199 assert!( 3200 sdk_publish_reason(&args, Some(&auth_required)) 3201 .expect("retry reason") 3202 .contains("accepted quorum") 3203 ); 3204 assert_eq!(failed.len(), 1); 3205 assert_eq!(failed[0].relay, "ws://127.0.0.1:19000"); 3206 assert_eq!(failed[0].reason, "auth required"); 3207 assert_eq!( 3208 sdk_publish_actions(&args, Some(&auth_required)), 3209 vec!["radroots sync push".to_owned()] 3210 ); 3211 } 3212 3213 #[test] 3214 fn listing_draft_kind_constant_is_stable() { 3215 let document = ListingDraftDocument { 3216 version: 1, 3217 kind: DRAFT_KIND.to_owned(), 3218 listing: super::ListingDraftMeta { 3219 d_tag: "AAAAAAAAAAAAAAAAAAAAAg".to_owned(), 3220 farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_owned(), 3221 }, 3222 seller_actor: super::ListingDraftSellerActor { 3223 account_id: "acct_seller".to_owned(), 3224 pubkey: "a".repeat(64), 3225 source: super::LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT.to_owned(), 3226 }, 3227 product: super::ListingDraftProduct { 3228 key: "sku".to_owned(), 3229 title: "Widget".to_owned(), 3230 category: "produce".to_owned(), 3231 summary: "Fresh".to_owned(), 3232 }, 3233 primary_bin: super::ListingDraftPrimaryBin { 3234 bin_id: "bin-1".to_owned(), 3235 quantity_amount: "1".to_owned(), 3236 quantity_unit: "kg".to_owned(), 3237 price_amount: "12.50".to_owned(), 3238 price_currency: "USD".to_owned(), 3239 price_per_amount: "1".to_owned(), 3240 price_per_unit: "kg".to_owned(), 3241 label: "kg".to_owned(), 3242 }, 3243 inventory: super::ListingDraftInventory { 3244 available: "2".to_owned(), 3245 }, 3246 availability: super::ListingDraftAvailability { 3247 kind: "status".to_owned(), 3248 status: "active".to_owned(), 3249 start: None, 3250 end: None, 3251 }, 3252 delivery: super::ListingDraftDelivery { 3253 method: "pickup".to_owned(), 3254 }, 3255 location: super::ListingDraftLocation { 3256 primary: "Asheville".to_owned(), 3257 city: None, 3258 region: None, 3259 country: None, 3260 }, 3261 discounts: Vec::new(), 3262 }; 3263 let rendered = toml::to_string_pretty(&document).expect("render draft"); 3264 assert!(rendered.contains("kind = \"listing_draft_v1\"")); 3265 } 3266 3267 #[test] 3268 fn listing_draft_canonicalization_preserves_discounts() { 3269 let seller_pubkey = "a".repeat(64); 3270 let document = ListingDraftDocument { 3271 version: 1, 3272 kind: DRAFT_KIND.to_owned(), 3273 listing: super::ListingDraftMeta { 3274 d_tag: "AAAAAAAAAAAAAAAAAAAAAg".to_owned(), 3275 farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_owned(), 3276 }, 3277 seller_actor: super::ListingDraftSellerActor { 3278 account_id: "acct_seller".to_owned(), 3279 pubkey: seller_pubkey.clone(), 3280 source: super::LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT.to_owned(), 3281 }, 3282 product: super::ListingDraftProduct { 3283 key: "sku".to_owned(), 3284 title: "Widget".to_owned(), 3285 category: "produce".to_owned(), 3286 summary: "Fresh".to_owned(), 3287 }, 3288 primary_bin: super::ListingDraftPrimaryBin { 3289 bin_id: "bin-1".to_owned(), 3290 quantity_amount: "1".to_owned(), 3291 quantity_unit: "each".to_owned(), 3292 price_amount: "10".to_owned(), 3293 price_currency: "USD".to_owned(), 3294 price_per_amount: "1".to_owned(), 3295 price_per_unit: "each".to_owned(), 3296 label: "each".to_owned(), 3297 }, 3298 inventory: super::ListingDraftInventory { 3299 available: "2".to_owned(), 3300 }, 3301 availability: super::ListingDraftAvailability { 3302 kind: "status".to_owned(), 3303 status: "active".to_owned(), 3304 start: None, 3305 end: None, 3306 }, 3307 delivery: super::ListingDraftDelivery { 3308 method: "pickup".to_owned(), 3309 }, 3310 location: super::ListingDraftLocation { 3311 primary: "Asheville".to_owned(), 3312 city: None, 3313 region: None, 3314 country: None, 3315 }, 3316 discounts: vec![super::ListingDraftDiscount { 3317 id: "discount_farmstand".to_owned(), 3318 label: "farmstand pickup".to_owned(), 3319 kind: "percent".to_owned(), 3320 value: "10".to_owned(), 3321 amount: String::new(), 3322 currency: String::new(), 3323 bin_id: None, 3324 min_bin_count: None, 3325 }], 3326 }; 3327 let contents = toml::to_string_pretty(&document).expect("render draft"); 3328 let context = super::ListingValidationContext { 3329 farm_setup_action: "radroots farm create".to_owned(), 3330 }; 3331 3332 let canonical = 3333 super::canonicalize_draft(&document, contents.as_str(), &context).expect("canonical"); 3334 3335 assert!(contents.contains("[[discounts]]")); 3336 assert_eq!( 3337 canonical 3338 .listing 3339 .discounts 3340 .as_ref() 3341 .expect("discounts") 3342 .len(), 3343 1 3344 ); 3345 } 3346 3347 fn sdk_push_event( 3348 final_state: PushOutboxEventState, 3349 outcome_kind: PushOutboxRelayOutcomeKind, 3350 message: Option<String>, 3351 ) -> PushOutboxEventReceipt { 3352 PushOutboxEventReceipt { 3353 event_id: RadrootsEventId::parse("e".repeat(64)).expect("event id"), 3354 outbox_event_id: 7, 3355 final_state, 3356 attempted_count: 1, 3357 accepted_count: usize::from(matches!( 3358 outcome_kind, 3359 PushOutboxRelayOutcomeKind::Accepted 3360 | PushOutboxRelayOutcomeKind::DuplicateAccepted 3361 )), 3362 retryable_count: usize::from(matches!( 3363 outcome_kind, 3364 PushOutboxRelayOutcomeKind::AuthRequired 3365 | PushOutboxRelayOutcomeKind::Timeout 3366 | PushOutboxRelayOutcomeKind::ConnectionFailed 3367 )), 3368 terminal_count: 0, 3369 quorum: 1, 3370 quorum_met: matches!( 3371 outcome_kind, 3372 PushOutboxRelayOutcomeKind::Accepted 3373 | PushOutboxRelayOutcomeKind::DuplicateAccepted 3374 ), 3375 relays: vec![PushOutboxRelayReceipt { 3376 relay_url: "ws://127.0.0.1:19000".to_owned(), 3377 outcome_kind, 3378 attempted: true, 3379 message, 3380 }], 3381 } 3382 } 3383 3384 fn listing_mutation_args(offline: bool) -> ListingMutationArgs { 3385 ListingMutationArgs { 3386 file: "listing.toml".into(), 3387 idempotency_key: None, 3388 print_event: false, 3389 offline, 3390 } 3391 } 3392 }