1use std::collections::BTreeMap;
16
17use serde::{Deserialize, Serialize};
18
19use crate::crypto::PublicKey;
20use crate::receipt::lineage::SignedExportEnvelope;
21use crate::util::bounded_listing_limit;
22use crate::{
23 aggregate_generic_listing_reports, normalize_namespace, GenericListingActorKind,
24 GenericListingFreshnessState, GenericListingQuery, GenericListingReplicaFreshness,
25 GenericListingReport, GenericListingSearchError, GenericListingStatus,
26 GenericRegistryPublisher, MonetaryAmount, SignedGenericListing, MAX_GENERIC_LISTING_LIMIT,
27};
28
29pub const LISTING_PRICING_HINT_SCHEMA: &str = "chio.marketplace.listing-pricing-hint.v1";
31
32pub const LISTING_SEARCH_SCHEMA: &str = "chio.marketplace.search.v1";
34
35pub const LISTING_COMPARISON_SCHEMA: &str = "chio.marketplace.compare.v1";
37
38pub const MAX_MARKETPLACE_SEARCH_LIMIT: usize = MAX_GENERIC_LISTING_LIMIT;
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47#[serde(rename_all = "camelCase")]
48pub struct ListingPricingHint {
49 pub schema: String,
50 pub listing_id: String,
52 pub namespace: String,
54 pub provider_operator_id: String,
57 pub capability_scope: String,
60 pub price_per_call: MonetaryAmount,
62 pub sla: ListingSla,
64 pub revocation_rate_bps: u32,
67 pub recent_receipts_volume: u64,
69 pub issued_at: u64,
71 pub expires_at: u64,
74}
75
76impl ListingPricingHint {
77 pub fn validate(&self) -> Result<(), String> {
80 if self.schema != LISTING_PRICING_HINT_SCHEMA {
81 return Err(format!(
82 "unsupported listing pricing hint schema: {}",
83 self.schema
84 ));
85 }
86 non_empty(&self.listing_id, "listing_id")?;
87 non_empty(&self.namespace, "namespace")?;
88 non_empty(&self.provider_operator_id, "provider_operator_id")?;
89 non_empty(&self.capability_scope, "capability_scope")?;
90 validate_currency_code(&self.price_per_call.currency, "price_per_call.currency")?;
91 if self.price_per_call.units == 0 {
92 return Err("price_per_call.units must be greater than zero".to_string());
93 }
94 if self.revocation_rate_bps > 10_000 {
95 return Err("revocation_rate_bps must be within [0, 10000]".to_string());
96 }
97 self.sla.validate()?;
98 if self.expires_at <= self.issued_at {
99 return Err("expires_at must be greater than issued_at".to_string());
100 }
101 Ok(())
102 }
103
104 #[must_use]
106 pub fn is_live_at(&self, now: u64) -> bool {
107 now >= self.issued_at && now < self.expires_at
108 }
109}
110
111pub type SignedListingPricingHint = SignedExportEnvelope<ListingPricingHint>;
112
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
115#[serde(rename_all = "camelCase")]
116pub struct ListingSla {
117 pub max_latency_ms: u64,
118 pub availability_bps: u32,
120 pub throughput_rps: u64,
121}
122
123impl ListingSla {
124 pub fn validate(&self) -> Result<(), String> {
125 if self.max_latency_ms == 0 {
126 return Err("sla.max_latency_ms must be greater than zero".to_string());
127 }
128 if self.availability_bps == 0 || self.availability_bps > 10_000 {
129 return Err("sla.availability_bps must be within (0, 10000]".to_string());
130 }
131 if self.throughput_rps == 0 {
132 return Err("sla.throughput_rps must be greater than zero".to_string());
133 }
134 Ok(())
135 }
136}
137
138#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
143#[serde(rename_all = "camelCase")]
144pub struct ListingQuery {
145 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub capability_scope_prefix: Option<String>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub namespace: Option<String>,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub actor_kind: Option<GenericListingActorKind>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub max_price_per_call: Option<MonetaryAmount>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub provider_operator_id: Option<String>,
163 #[serde(default = "default_require_fresh")]
166 pub require_fresh: bool,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub limit: Option<usize>,
170}
171
172fn default_require_fresh() -> bool {
173 true
174}
175
176impl ListingQuery {
177 #[must_use]
178 pub fn limit_or_default(&self) -> usize {
179 bounded_listing_limit(self.limit, MAX_MARKETPLACE_SEARCH_LIMIT)
180 }
181
182 #[must_use]
185 pub fn to_listing_query(&self) -> GenericListingQuery {
186 GenericListingQuery {
187 namespace: self.namespace.clone(),
188 actor_kind: Some(
189 self.actor_kind
190 .unwrap_or(GenericListingActorKind::ToolServer),
191 ),
192 actor_id: None,
193 status: Some(GenericListingStatus::Active),
194 limit: Some(self.limit_or_default()),
195 }
196 }
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
202#[serde(rename_all = "camelCase")]
203pub struct Listing {
204 pub rank: u64,
205 pub listing: SignedGenericListing,
206 pub pricing: SignedListingPricingHint,
207 pub publisher: GenericRegistryPublisher,
208 pub freshness: GenericListingReplicaFreshness,
209}
210
211impl Listing {
212 #[must_use]
214 pub fn listing_id(&self) -> &str {
215 &self.listing.body.listing_id
216 }
217
218 #[must_use]
220 pub fn price_per_call(&self) -> &MonetaryAmount {
221 &self.pricing.body.price_per_call
222 }
223
224 #[must_use]
227 pub fn is_admissible_at(&self, now: u64) -> bool {
228 matches!(self.listing.body.status, GenericListingStatus::Active)
229 && self.pricing.body.is_live_at(now)
230 && self.freshness.state == GenericListingFreshnessState::Fresh
231 }
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
236#[serde(rename_all = "camelCase")]
237pub struct ListingSearchResponse {
238 pub schema: String,
239 pub generated_at: u64,
240 pub query: ListingQuery,
241 pub result_count: u64,
242 pub results: Vec<Listing>,
243 #[serde(default, skip_serializing_if = "Vec::is_empty")]
244 pub errors: Vec<GenericListingSearchError>,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
249#[serde(rename_all = "camelCase")]
250pub struct ListingComparison {
251 pub schema: String,
252 pub generated_at: u64,
253 pub entry_count: u64,
254 pub rows: Vec<ListingComparisonRow>,
255 pub currency_consistent: bool,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
261#[serde(rename_all = "camelCase")]
262pub struct ListingComparisonRow {
263 pub listing_id: String,
264 pub provider_operator_id: String,
265 pub capability_scope: String,
266 pub price_per_call: MonetaryAmount,
267 pub price_index_bps: u32,
271 pub sla: ListingSla,
272 pub revocation_rate_bps: u32,
273 pub recent_receipts_volume: u64,
274 pub freshness_state: GenericListingFreshnessState,
275 pub status: GenericListingStatus,
276}
277
278#[must_use]
291pub fn search(
292 reports: &[GenericListingReport],
293 pricing_hints: &[SignedListingPricingHint],
294 query: &ListingQuery,
295 now: u64,
296) -> ListingSearchResponse {
297 let listing_query = query.to_listing_query();
298 let aggregated = aggregate_generic_listing_reports(reports, &listing_query, now);
299 let mut errors = aggregated.errors;
300
301 let mut indexed_hints: BTreeMap<String, Vec<SignedListingPricingHint>> = BTreeMap::new();
305 for hint in pricing_hints {
306 if let Err(error) = hint.body.validate() {
307 errors.push(GenericListingSearchError {
308 operator_id: hint.body.provider_operator_id.clone(),
309 operator_name: None,
310 registry_url: String::new(),
311 error: format!("pricing hint `{}` invalid: {error}", hint.body.listing_id),
312 });
313 continue;
314 }
315 match hint.verify_signature() {
316 Ok(true) => {}
317 Ok(false) => {
318 errors.push(GenericListingSearchError {
319 operator_id: hint.body.provider_operator_id.clone(),
320 operator_name: None,
321 registry_url: String::new(),
322 error: format!(
323 "pricing hint `{}` signature is invalid",
324 hint.body.listing_id
325 ),
326 });
327 continue;
328 }
329 Err(error) => {
330 errors.push(GenericListingSearchError {
331 operator_id: hint.body.provider_operator_id.clone(),
332 operator_name: None,
333 registry_url: String::new(),
334 error: format!(
335 "pricing hint `{}` verification failed: {error}",
336 hint.body.listing_id
337 ),
338 });
339 continue;
340 }
341 }
342 if !hint.body.is_live_at(now) {
343 continue;
344 }
345 indexed_hints
346 .entry(hint.body.listing_id.clone())
347 .or_default()
348 .push(hint.clone());
349 }
350
351 let max_price = query.max_price_per_call.as_ref();
352 let scope_prefix = query
353 .capability_scope_prefix
354 .as_deref()
355 .map(str::trim)
356 .filter(|prefix| !prefix.is_empty());
357 let provider_filter = query
358 .provider_operator_id
359 .as_deref()
360 .map(str::trim)
361 .filter(|id| !id.is_empty());
362
363 let mut results: Vec<Listing> = Vec::new();
364 for aggregated_result in aggregated.results {
365 if matches!(
366 aggregated_result.listing.body.status,
367 GenericListingStatus::Revoked
368 | GenericListingStatus::Retired
369 | GenericListingStatus::Suspended
370 | GenericListingStatus::Superseded
371 ) {
372 continue;
373 }
374 if query.require_fresh
375 && aggregated_result.freshness.state != GenericListingFreshnessState::Fresh
376 {
377 continue;
378 }
379
380 let Some(hints) = indexed_hints.get(&aggregated_result.listing.body.listing_id) else {
381 continue;
382 };
383
384 let mut selected_hint = None;
385 for hint in hints {
386 if let Err(error) = validate_pricing_hint_listing_authority(
387 hint,
388 &aggregated_result.listing,
389 &aggregated_result.publisher,
390 ) {
391 errors.push(GenericListingSearchError {
392 operator_id: hint.body.provider_operator_id.clone(),
393 operator_name: None,
394 registry_url: aggregated_result.publisher.registry_url.clone(),
395 error,
396 });
397 continue;
398 }
399 if selected_hint
400 .as_ref()
401 .is_none_or(|existing: &&SignedListingPricingHint| {
402 existing.body.issued_at < hint.body.issued_at
403 })
404 {
405 selected_hint = Some(hint);
406 }
407 }
408 let Some(hint) = selected_hint else {
409 continue;
410 };
411 if let Some(prefix) = scope_prefix {
412 if !hint.body.capability_scope.starts_with(prefix) {
413 continue;
414 }
415 }
416 if let Some(max) = max_price {
417 if max.currency != hint.body.price_per_call.currency {
418 continue;
419 }
420 if hint.body.price_per_call.units > max.units {
421 continue;
422 }
423 }
424 if let Some(provider) = provider_filter {
425 if hint.body.provider_operator_id != provider {
426 continue;
427 }
428 }
429
430 results.push(Listing {
431 rank: 0,
432 listing: aggregated_result.listing,
433 pricing: hint.clone(),
434 publisher: aggregated_result.publisher,
435 freshness: aggregated_result.freshness,
436 });
437 }
438
439 results.sort_by(|left, right| {
443 let left_currency = &left.pricing.body.price_per_call.currency;
444 let right_currency = &right.pricing.body.price_per_call.currency;
445 left_currency
446 .cmp(right_currency)
447 .then(
448 left.pricing
449 .body
450 .price_per_call
451 .units
452 .cmp(&right.pricing.body.price_per_call.units),
453 )
454 .then(
455 left.pricing
456 .body
457 .revocation_rate_bps
458 .cmp(&right.pricing.body.revocation_rate_bps),
459 )
460 .then(
461 right
462 .pricing
463 .body
464 .recent_receipts_volume
465 .cmp(&left.pricing.body.recent_receipts_volume),
466 )
467 .then(
468 left.listing
469 .body
470 .listing_id
471 .cmp(&right.listing.body.listing_id),
472 )
473 });
474
475 for (index, result) in results.iter_mut().enumerate() {
476 result.rank = (index + 1) as u64;
477 }
478 results.truncate(query.limit_or_default());
479
480 ListingSearchResponse {
481 schema: LISTING_SEARCH_SCHEMA.to_string(),
482 generated_at: now,
483 query: query.clone(),
484 result_count: results.len() as u64,
485 results,
486 errors,
487 }
488}
489
490#[must_use]
498pub fn compare(listings: &[Listing]) -> ListingComparison {
499 let generated_at = listings
500 .iter()
501 .map(|entry| entry.pricing.body.issued_at)
502 .max()
503 .unwrap_or_default();
504 let mut currencies: BTreeMap<String, u64> = BTreeMap::new();
505 for entry in listings {
506 let currency = entry.pricing.body.price_per_call.currency.clone();
507 let min = currencies.entry(currency).or_insert(u64::MAX);
508 *min = (*min).min(entry.pricing.body.price_per_call.units);
509 }
510
511 let currency_consistent = currencies.len() <= 1;
512
513 let rows = listings
514 .iter()
515 .map(|entry| {
516 let currency = entry.pricing.body.price_per_call.currency.clone();
517 let min = currencies.get(¤cy).copied().unwrap_or(u64::MAX);
518 let units = entry.pricing.body.price_per_call.units;
519 let price_index_bps = if min == 0 || units == 0 {
520 10_000
521 } else {
522 let numerator = (units as u128).saturating_mul(10_000_u128);
524 let value = numerator / (min as u128);
525 value.min(u32::MAX as u128) as u32
526 };
527 ListingComparisonRow {
528 listing_id: entry.listing.body.listing_id.clone(),
529 provider_operator_id: entry.pricing.body.provider_operator_id.clone(),
530 capability_scope: entry.pricing.body.capability_scope.clone(),
531 price_per_call: entry.pricing.body.price_per_call.clone(),
532 price_index_bps,
533 sla: entry.pricing.body.sla.clone(),
534 revocation_rate_bps: entry.pricing.body.revocation_rate_bps,
535 recent_receipts_volume: entry.pricing.body.recent_receipts_volume,
536 freshness_state: entry.freshness.state,
537 status: entry.listing.body.status,
538 }
539 })
540 .collect::<Vec<_>>();
541
542 ListingComparison {
543 schema: LISTING_COMPARISON_SCHEMA.to_string(),
544 generated_at,
545 entry_count: rows.len() as u64,
546 rows,
547 currency_consistent,
548 }
549}
550
551#[must_use]
555pub fn resolve_admissible_listing<'a>(
556 search_results: &'a [Listing],
557 listing_id: &str,
558 now: u64,
559) -> Option<&'a Listing> {
560 search_results
561 .iter()
562 .find(|listing| listing.listing_id() == listing_id && listing.is_admissible_at(now))
563}
564
565#[must_use]
569pub fn provider_signing_key(listing: &Listing) -> &PublicKey {
570 &listing.pricing.signer_key
571}
572
573fn validate_pricing_hint_listing_authority(
574 hint: &SignedListingPricingHint,
575 listing: &SignedGenericListing,
576 publisher: &GenericRegistryPublisher,
577) -> Result<(), String> {
578 if normalize_namespace(&hint.body.namespace) != normalize_namespace(&listing.body.namespace) {
579 return Err(format!(
580 "pricing hint `{}` namespace mismatch",
581 hint.body.listing_id
582 ));
583 }
584 if hint.body.provider_operator_id != publisher.operator_id {
585 return Err(format!(
586 "pricing hint `{}` provider does not match publisher",
587 hint.body.listing_id
588 ));
589 }
590 if hint.body.provider_operator_id != listing.body.namespace_ownership.owner_id {
591 return Err(format!(
592 "pricing hint `{}` provider does not match listing authority",
593 hint.body.listing_id
594 ));
595 }
596 if hint.signer_key != listing.body.namespace_ownership.signer_public_key {
597 return Err(format!(
598 "pricing hint `{}` signer does not match listing pricing authority",
599 hint.body.listing_id
600 ));
601 }
602 Ok(())
603}
604
605fn non_empty(value: &str, field: &str) -> Result<(), String> {
606 if value.trim().is_empty() {
607 Err(format!("{field} must not be empty"))
608 } else {
609 Ok(())
610 }
611}
612
613fn validate_currency_code(value: &str, field: &str) -> Result<(), String> {
614 if value.len() == 3
615 && value
616 .chars()
617 .all(|character| character.is_ascii_uppercase())
618 {
619 Ok(())
620 } else {
621 Err(format!(
622 "{field} must be a 3-letter uppercase currency code"
623 ))
624 }
625}
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630 use crate::crypto::Keypair;
631 use crate::{
632 GenericListingArtifact, GenericListingBoundary, GenericListingCompatibilityReference,
633 GenericListingFreshnessWindow, GenericListingSearchPolicy, GenericListingSubject,
634 GenericListingSummary, GenericNamespaceOwnership, GenericRegistryPublisherRole,
635 GENERIC_LISTING_ARTIFACT_SCHEMA, GENERIC_LISTING_REPORT_SCHEMA,
636 };
637
638 fn require_ok<T, E>(result: Result<T, E>, context: &'static str) -> T
639 where
640 E: std::fmt::Debug,
641 {
642 result.unwrap_or_else(|error| panic!("{context}: {error:?}"))
643 }
644
645 fn require_err<T, E>(result: Result<T, E>, context: &'static str) -> E {
646 match result {
647 Ok(_) => panic!("{context}"),
648 Err(error) => error,
649 }
650 }
651
652 fn require_some<T>(value: Option<T>, context: &'static str) -> T {
653 value.unwrap_or_else(|| panic!("{context}"))
654 }
655
656 fn sample_namespace(keypair: &Keypair) -> GenericNamespaceOwnership {
657 GenericNamespaceOwnership {
658 namespace: "https://registry.chio.example".to_string(),
659 owner_id: "operator-a".to_string(),
660 owner_name: Some("Operator A".to_string()),
661 registry_url: "https://registry.chio.example".to_string(),
662 signer_public_key: keypair.public_key(),
663 registered_at: 1,
664 transferred_from_owner_id: None,
665 }
666 }
667
668 fn sample_listing(
669 keypair: &Keypair,
670 listing_id: &str,
671 status: GenericListingStatus,
672 ) -> SignedGenericListing {
673 let body = GenericListingArtifact {
674 schema: GENERIC_LISTING_ARTIFACT_SCHEMA.to_string(),
675 listing_id: listing_id.to_string(),
676 namespace: "https://registry.chio.example".to_string(),
677 published_at: 10,
678 expires_at: Some(1000),
679 status,
680 namespace_ownership: sample_namespace(keypair),
681 subject: GenericListingSubject {
682 actor_kind: GenericListingActorKind::ToolServer,
683 actor_id: format!("server-{listing_id}"),
684 display_name: None,
685 metadata_url: None,
686 resolution_url: None,
687 homepage_url: None,
688 },
689 compatibility: GenericListingCompatibilityReference {
690 source_schema: "chio.certify.check.v1".to_string(),
691 source_artifact_id: format!("artifact-{listing_id}"),
692 source_artifact_sha256: format!("sha-{listing_id}"),
693 },
694 boundary: GenericListingBoundary::default(),
695 };
696 require_ok(SignedGenericListing::sign(body, keypair), "sign listing")
697 }
698
699 fn sample_publisher(operator_id: &str) -> GenericRegistryPublisher {
700 GenericRegistryPublisher {
701 role: GenericRegistryPublisherRole::Origin,
702 operator_id: operator_id.to_string(),
703 operator_name: Some(format!("Operator {operator_id}")),
704 registry_url: format!("https://{operator_id}.chio.example"),
705 upstream_registry_urls: Vec::new(),
706 }
707 }
708
709 fn sample_report(
710 keypair: &Keypair,
711 operator_id: &str,
712 generated_at: u64,
713 listings: Vec<SignedGenericListing>,
714 ) -> GenericListingReport {
715 GenericListingReport {
716 schema: GENERIC_LISTING_REPORT_SCHEMA.to_string(),
717 generated_at,
718 query: GenericListingQuery::default(),
719 namespace: sample_namespace(keypair),
720 publisher: sample_publisher(operator_id),
721 freshness: GenericListingFreshnessWindow {
722 max_age_secs: 300,
723 valid_until: generated_at + 300,
724 },
725 search_policy: GenericListingSearchPolicy::default(),
726 summary: GenericListingSummary {
727 matching_listings: listings.len() as u64,
728 returned_listings: listings.len() as u64,
729 active_listings: listings.len() as u64,
730 suspended_listings: 0,
731 superseded_listings: 0,
732 revoked_listings: 0,
733 retired_listings: 0,
734 },
735 listings,
736 }
737 }
738
739 fn sample_pricing_hint(
740 operator_keypair: &Keypair,
741 operator_id: &str,
742 listing_id: &str,
743 scope: &str,
744 price_units: u64,
745 issued_at: u64,
746 ) -> SignedListingPricingHint {
747 let body = ListingPricingHint {
748 schema: LISTING_PRICING_HINT_SCHEMA.to_string(),
749 listing_id: listing_id.to_string(),
750 namespace: "https://registry.chio.example".to_string(),
751 provider_operator_id: operator_id.to_string(),
752 capability_scope: scope.to_string(),
753 price_per_call: MonetaryAmount {
754 units: price_units,
755 currency: "USD".to_string(),
756 },
757 sla: ListingSla {
758 max_latency_ms: 250,
759 availability_bps: 9_990,
760 throughput_rps: 50,
761 },
762 revocation_rate_bps: 25,
763 recent_receipts_volume: 1_000,
764 issued_at,
765 expires_at: issued_at + 600,
766 };
767 require_ok(
768 SignedListingPricingHint::sign(body, operator_keypair),
769 "sign hint",
770 )
771 }
772
773 #[test]
774 fn listing_pricing_hint_rejects_lowercase_currency() {
775 let operator_keypair = Keypair::generate();
776 let mut hint = sample_pricing_hint(
777 &operator_keypair,
778 "operator-a",
779 "listing-1",
780 "tools:search",
781 50,
782 100,
783 );
784 hint.body.price_per_call.currency = "usd".to_string();
785
786 let error = require_err(
787 hint.body.validate(),
788 "lowercase pricing currency must fail closed",
789 );
790 assert!(error.contains("currency"));
791 }
792
793 #[test]
794 fn search_filters_by_scope_prefix_and_price_ceiling() {
795 let registry_keypair = Keypair::generate();
796 let listing_cheap = sample_listing(
797 ®istry_keypair,
798 "listing-cheap",
799 GenericListingStatus::Active,
800 );
801 let listing_pricey = sample_listing(
802 ®istry_keypair,
803 "listing-pricey",
804 GenericListingStatus::Active,
805 );
806 let listing_other_scope = sample_listing(
807 ®istry_keypair,
808 "listing-offscope",
809 GenericListingStatus::Active,
810 );
811 let report = sample_report(
812 ®istry_keypair,
813 "operator-a",
814 100,
815 vec![
816 listing_cheap.clone(),
817 listing_pricey.clone(),
818 listing_other_scope.clone(),
819 ],
820 );
821
822 let operator_keypair = registry_keypair.clone();
823 let hints = vec![
824 sample_pricing_hint(
825 &operator_keypair,
826 "operator-a",
827 "listing-cheap",
828 "tools:search",
829 50,
830 110,
831 ),
832 sample_pricing_hint(
833 &operator_keypair,
834 "operator-a",
835 "listing-pricey",
836 "tools:search:premium",
837 500,
838 110,
839 ),
840 sample_pricing_hint(
841 &operator_keypair,
842 "operator-a",
843 "listing-offscope",
844 "tools:write",
845 10,
846 110,
847 ),
848 ];
849
850 let query = ListingQuery {
851 capability_scope_prefix: Some("tools:search".to_string()),
852 max_price_per_call: Some(MonetaryAmount {
853 units: 100,
854 currency: "USD".to_string(),
855 }),
856 ..ListingQuery::default()
857 };
858 let response = search(&[report], &hints, &query, 120);
859
860 assert_eq!(response.result_count, 1);
861 assert_eq!(response.results[0].listing_id(), "listing-cheap");
862 assert_eq!(response.results[0].price_per_call().units, 50);
863 }
864
865 #[test]
866 fn search_rejects_non_active_listings_and_missing_hints() {
867 let registry_keypair = Keypair::generate();
868 let revoked = sample_listing(
869 ®istry_keypair,
870 "listing-revoked",
871 GenericListingStatus::Revoked,
872 );
873 let active_no_hint = sample_listing(
874 ®istry_keypair,
875 "listing-no-hint",
876 GenericListingStatus::Active,
877 );
878 let report = sample_report(
879 ®istry_keypair,
880 "operator-a",
881 100,
882 vec![revoked, active_no_hint],
883 );
884 let response = search(&[report], &[], &ListingQuery::default(), 120);
885 assert_eq!(response.result_count, 0);
886 }
887
888 #[test]
889 fn search_fails_closed_on_tampered_pricing_hint_signature() {
890 let registry_keypair = Keypair::generate();
891 let listing = sample_listing(®istry_keypair, "listing-1", GenericListingStatus::Active);
892 let report = sample_report(®istry_keypair, "operator-a", 100, vec![listing]);
893
894 let operator_keypair = registry_keypair.clone();
895 let mut hint = sample_pricing_hint(
896 &operator_keypair,
897 "operator-a",
898 "listing-1",
899 "tools:search",
900 10,
901 110,
902 );
903 hint.body.price_per_call.units = 1;
905
906 let response = search(&[report], &[hint], &ListingQuery::default(), 120);
907 assert_eq!(response.result_count, 0);
908 assert!(response
909 .errors
910 .iter()
911 .any(|error| error.error.contains("signature is invalid")));
912 }
913
914 #[test]
915 fn search_rejects_stale_pricing_hint() {
916 let registry_keypair = Keypair::generate();
917 let listing = sample_listing(®istry_keypair, "listing-1", GenericListingStatus::Active);
918 let report = sample_report(®istry_keypair, "operator-a", 100, vec![listing]);
919
920 let operator_keypair = registry_keypair.clone();
921 let stale = sample_pricing_hint(
923 &operator_keypair,
924 "operator-a",
925 "listing-1",
926 "tools:search",
927 10,
928 110,
929 );
930
931 let response = search(&[report], &[stale], &ListingQuery::default(), 2_000);
932 assert_eq!(response.result_count, 0);
933 }
934
935 #[test]
936 fn compare_normalizes_prices_within_currency() {
937 let registry_keypair = Keypair::generate();
938 let listing_a =
939 sample_listing(®istry_keypair, "listing-a", GenericListingStatus::Active);
940 let listing_b =
941 sample_listing(®istry_keypair, "listing-b", GenericListingStatus::Active);
942 let report = sample_report(
943 ®istry_keypair,
944 "operator-a",
945 100,
946 vec![listing_a, listing_b],
947 );
948
949 let operator_keypair = registry_keypair.clone();
950 let hints = vec![
951 sample_pricing_hint(
952 &operator_keypair,
953 "operator-a",
954 "listing-a",
955 "tools:search",
956 100,
957 110,
958 ),
959 sample_pricing_hint(
960 &operator_keypair,
961 "operator-a",
962 "listing-b",
963 "tools:search",
964 200,
965 110,
966 ),
967 ];
968 let response = search(&[report], &hints, &ListingQuery::default(), 120);
969 let comparison = compare(&response.results);
970 assert_eq!(comparison.entry_count, 2);
971 assert!(comparison.currency_consistent);
972 let row_a = require_some(
974 comparison
975 .rows
976 .iter()
977 .find(|row| row.listing_id == "listing-a"),
978 "row a present",
979 );
980 let row_b = require_some(
981 comparison
982 .rows
983 .iter()
984 .find(|row| row.listing_id == "listing-b"),
985 "row b present",
986 );
987 assert_eq!(row_a.price_index_bps, 10_000);
988 assert_eq!(row_b.price_index_bps, 20_000);
989 }
990
991 #[test]
992 fn compare_flags_currency_inconsistency() {
993 let registry_keypair = Keypair::generate();
994 let listing = sample_listing(®istry_keypair, "listing-a", GenericListingStatus::Active);
995 let operator_keypair = registry_keypair.clone();
996
997 let hint_usd = require_ok(
998 SignedListingPricingHint::sign(
999 ListingPricingHint {
1000 schema: LISTING_PRICING_HINT_SCHEMA.to_string(),
1001 listing_id: "listing-a".to_string(),
1002 namespace: "https://registry.chio.example".to_string(),
1003 provider_operator_id: "operator-a".to_string(),
1004 capability_scope: "tools:search".to_string(),
1005 price_per_call: MonetaryAmount {
1006 units: 100,
1007 currency: "USD".to_string(),
1008 },
1009 sla: ListingSla {
1010 max_latency_ms: 500,
1011 availability_bps: 9_990,
1012 throughput_rps: 10,
1013 },
1014 revocation_rate_bps: 0,
1015 recent_receipts_volume: 10,
1016 issued_at: 100,
1017 expires_at: 500,
1018 },
1019 &operator_keypair,
1020 ),
1021 "sign usd",
1022 );
1023 let hint_eur = require_ok(
1024 SignedListingPricingHint::sign(
1025 ListingPricingHint {
1026 schema: LISTING_PRICING_HINT_SCHEMA.to_string(),
1027 listing_id: "listing-b".to_string(),
1028 namespace: "https://registry.chio.example".to_string(),
1029 provider_operator_id: "operator-a".to_string(),
1030 capability_scope: "tools:search".to_string(),
1031 price_per_call: MonetaryAmount {
1032 units: 80,
1033 currency: "EUR".to_string(),
1034 },
1035 sla: ListingSla {
1036 max_latency_ms: 500,
1037 availability_bps: 9_990,
1038 throughput_rps: 10,
1039 },
1040 revocation_rate_bps: 0,
1041 recent_receipts_volume: 10,
1042 issued_at: 100,
1043 expires_at: 500,
1044 },
1045 &operator_keypair,
1046 ),
1047 "sign eur",
1048 );
1049
1050 let listings = vec![
1051 Listing {
1052 rank: 1,
1053 listing: listing.clone(),
1054 pricing: hint_usd,
1055 publisher: sample_publisher("operator-a"),
1056 freshness: GenericListingReplicaFreshness {
1057 state: GenericListingFreshnessState::Fresh,
1058 age_secs: 10,
1059 max_age_secs: 300,
1060 valid_until: 400,
1061 generated_at: 100,
1062 },
1063 },
1064 Listing {
1065 rank: 2,
1066 listing,
1067 pricing: hint_eur,
1068 publisher: sample_publisher("operator-a"),
1069 freshness: GenericListingReplicaFreshness {
1070 state: GenericListingFreshnessState::Fresh,
1071 age_secs: 10,
1072 max_age_secs: 300,
1073 valid_until: 400,
1074 generated_at: 100,
1075 },
1076 },
1077 ];
1078 let comparison = compare(&listings);
1079 assert!(!comparison.currency_consistent);
1080 }
1081}