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