diff --git a/bin/dipper-service/src/config.rs b/bin/dipper-service/src/config.rs index 1772a77a..bbef7f05 100644 --- a/bin/dipper-service/src/config.rs +++ b/bin/dipper-service/src/config.rs @@ -903,6 +903,11 @@ pub struct DipsAgreementConfig { #[serde(default = "default_max_grt_per_30_days")] pub max_grt_per_30_days: BTreeMap, + /// Maximum GRT per 30 days Dipper will pay on a chain with no entry in + /// `max_grt_per_30_days`, so every chain has a base-price ceiling. + #[serde(default = "default_fallback_max_grt_per_30_days")] + pub fallback_max_grt_per_30_days: f64, + /// Maximum GRT per billion entities per 30 days Dipper will pay, on every chain. /// /// Sent to IISA, which excludes indexers advertising a higher per-entity price. @@ -1017,6 +1022,12 @@ impl DipsAgreementConfig { )); } } + let fallback = self.fallback_max_grt_per_30_days; + if !(fallback.is_finite() && fallback > 0.0) { + return Err(format!( + "fallback_max_grt_per_30_days ({fallback}) must be a positive number" + )); + } if self.mass_unresponsive_reset_fraction >= self.mass_unresponsive_trip_fraction { return Err(format!( "mass_unresponsive_reset_fraction ({}) must be below mass_unresponsive_trip_fraction ({})", @@ -1088,10 +1099,9 @@ fn default_deadline_seconds() -> u64 { /// indexer-rs published minimum on a given chain. /// /// **Scope**: only the chains in the initial DIPs rollout set are -/// listed here. Other chains carry no default filter — an indexer -/// can offer any base price on them and still pass selection. -/// Operators who want filter coverage on additional chains must -/// add explicit entries to `max_grt_per_30_days` in their config. +/// listed here. Other chains get `fallback_max_grt_per_30_days`; +/// operators can give one its own ceiling with an explicit entry in +/// `max_grt_per_30_days`. /// /// Synced from /// (the rollout-trimmed `[dips.min_grt_per_30_days]` section). @@ -1122,6 +1132,15 @@ fn default_max_grt_per_30_days() -> BTreeMap { .collect() } +/// Default ceiling for chains outside the rollout set: the highest of the default chain +/// ceilings, whatever `max_grt_per_30_days` an operator configures. +fn default_fallback_max_grt_per_30_days() -> f64 { + INDEXER_RS_MIN_GRT_PER_30_DAYS + .iter() + .map(|(_, min)| min * PAYMENT_CEILING_MULTIPLIER) + .fold(0.0, f64::max) +} + fn default_max_grt_per_billion_entities_per_30_days() -> f64 { 2000.0 } @@ -1294,6 +1313,8 @@ pub struct IndexingAgreementConfig { pub deadline_seconds: u64, /// Payment ceiling per chain (GRT per 30 days). pub max_grt_per_30_days: BTreeMap, + /// Payment ceiling (GRT per 30 days) for a chain with no entry in `max_grt_per_30_days`. + pub fallback_max_grt_per_30_days: f64, /// Payment ceiling for entity pricing (GRT per billion entities per 30 days). pub max_grt_per_billion_entities_per_30_days: f64, /// Number of days to look back for declined indexers (standard exclusion). @@ -1325,8 +1346,8 @@ pub struct IndexingAgreementConfig { #[cfg(test)] impl IndexingAgreementConfig { /// Addresses and limits all set to 0, with permissive breaker and cache settings, for tests - /// to adjust the fields they care about. The agreement and per-entity price caps keep their - /// defaults, since dipper refuses to start with either at 0. + /// to adjust the fields they care about. The agreement, fallback and per-entity price caps + /// keep their defaults, since dipper refuses to start with any of them at 0. pub fn for_tests() -> Self { Self { data_service: Address::ZERO, @@ -1338,6 +1359,7 @@ impl IndexingAgreementConfig { duration_seconds: 0, deadline_seconds: 0, max_grt_per_30_days: BTreeMap::new(), + fallback_max_grt_per_30_days: default_fallback_max_grt_per_30_days(), max_grt_per_billion_entities_per_30_days: default_max_grt_per_billion_entities_per_30_days(), declined_indexer_lookback_days: 0, @@ -1397,8 +1419,12 @@ impl IndexingAgreementConfig { self.deadline_seconds } - pub fn max_grt_per_30_days(&self) -> &BTreeMap { - &self.max_grt_per_30_days + /// The base-price ceiling for a chain: its own entry, or the fallback when it has none. + pub fn max_grt_per_30_days_for(&self, chain: &str) -> f64 { + self.max_grt_per_30_days + .get(chain) + .copied() + .unwrap_or(self.fallback_max_grt_per_30_days) } pub fn max_grt_per_billion_entities_per_30_days(&self) -> f64 { @@ -1467,6 +1493,7 @@ impl From duration_seconds: value.duration_seconds.unwrap_or(u64::MAX), deadline_seconds: value.deadline_seconds, max_grt_per_30_days: value.max_grt_per_30_days, + fallback_max_grt_per_30_days: value.fallback_max_grt_per_30_days, max_grt_per_billion_entities_per_30_days: value .max_grt_per_billion_entities_per_30_days, declined_indexer_lookback_days: value.declined_indexer_lookback_days, @@ -1771,6 +1798,52 @@ mod tests { } } + #[test] + fn caps_a_chain_without_its_own_ceiling_at_the_fallback() { + let conf: DipsAgreementConfig = serde_json::from_str( + r#"{ + "data_service": "0x1111111111111111111111111111111111111111", + "recurring_collector": "0x2222222222222222222222222222222222222222", + "recurring_agreement_manager": "0x3333333333333333333333333333333333333333", + "min_seconds_per_collection": 60, + "max_seconds_per_collection": 3600, + "pricing_table": {}, + "max_grt_per_30_days": {"mainnet": 450} + }"#, + ) + .expect("deserialization"); + // Unset, the fallback is the highest default chain ceiling (arbitrum-one's). + assert_eq!(conf.fallback_max_grt_per_30_days, 4500.0); + let (agreement_conf, _) = + <(Arc, Arc>)>::from(conf); + assert_eq!(agreement_conf.max_grt_per_30_days_for("mainnet"), 450.0); + assert_eq!(agreement_conf.max_grt_per_30_days_for("gnosis"), 4500.0); + } + + #[test] + fn validate_rejects_a_fallback_ceiling_that_is_not_a_positive_number() { + let mut conf: DipsAgreementConfig = serde_json::from_str( + r#"{ + "data_service": "0x1111111111111111111111111111111111111111", + "recurring_collector": "0x2222222222222222222222222222222222222222", + "recurring_agreement_manager": "0x3333333333333333333333333333333333333333", + "min_seconds_per_collection": 60, + "max_seconds_per_collection": 3600, + "pricing_table": {} + }"#, + ) + .expect("deserialization"); + for ceiling in [0.0, -1.0, f64::NAN, f64::INFINITY] { + conf.fallback_max_grt_per_30_days = ceiling; + let err = conf.validate().expect_err(&format!("{ceiling}")); + assert!(err.contains("fallback_max_grt_per_30_days"), "{err}"); + } + for ceiling in [0.01, 4500.0] { + conf.fallback_max_grt_per_30_days = ceiling; + assert!(conf.validate().is_ok(), "{ceiling}"); + } + } + #[test] fn validate_rejects_a_collection_window_the_contract_would_refuse() { // The RecurringCollector requires max - min >= 600; an offer built from diff --git a/bin/dipper-service/src/network/service/escrow_reconciler.rs b/bin/dipper-service/src/network/service/escrow_reconciler.rs index b4bb7894..67fd5e8d 100644 --- a/bin/dipper-service/src/network/service/escrow_reconciler.rs +++ b/bin/dipper-service/src/network/service/escrow_reconciler.rs @@ -750,6 +750,7 @@ mod tests { duration_seconds: 0, deadline_seconds: 0, max_grt_per_30_days: std::collections::BTreeMap::new(), + fallback_max_grt_per_30_days: 4500.0, max_grt_per_billion_entities_per_30_days: 2000.0, declined_indexer_lookback_days: 0, price_rejection_lookback_days: 0, diff --git a/bin/dipper-service/src/worker/handlers/reassess_indexing_request.rs b/bin/dipper-service/src/worker/handlers/reassess_indexing_request.rs index c2160f93..23978535 100644 --- a/bin/dipper-service/src/worker/handlers/reassess_indexing_request.rs +++ b/bin/dipper-service/src/worker/handlers/reassess_indexing_request.rs @@ -219,11 +219,7 @@ where // Cap the breaker's pool to indexers dipper could actually pay on this chain, so // the denominator matches the indexers the unresponsive numerator is drawn from. let ceilings = PriceCeilings { - max_grt_per_30_days: ctx - .agreement_conf - .max_grt_per_30_days() - .get(&chain_name) - .copied(), + max_grt_per_30_days: Some(ctx.agreement_conf.max_grt_per_30_days_for(&chain_name)), max_grt_per_billion_entities_per_30_days: Some( ctx.agreement_conf .max_grt_per_billion_entities_per_30_days(), @@ -1537,6 +1533,34 @@ mod lifecycle_event_tests { ); } + #[tokio::test] + async fn sends_the_fallback_base_price_ceiling_for_a_chain_without_its_own() { + let iisa = MockIisa::default(); + let contexts = Arc::clone(&iisa.contexts); + let mut ctx = build_ctx( + MockRegistry::default(), + iisa, + MockQueue::default(), + MockChainClient::default(), + CapturingEventsProducer::new(), + indexer_urls::Snapshot::new(), + ); + ctx.agreement_conf = Arc::new(IndexingAgreementConfig { + max_grt_per_30_days: std::collections::BTreeMap::from([( + "other-chain".to_string(), + 450.0, + )]), + fallback_max_grt_per_30_days: 900.0, + ..test_agreement_conf() + }); + + handle(ctx, &test_message(1)).await.expect("handler ok"); + + let contexts = contexts.lock().unwrap(); + assert_eq!(contexts.len(), 1, "IISA asked once: {contexts:?}"); + assert_eq!(contexts[0].max_grt_per_30_days, Some(900.0)); + } + #[tokio::test] async fn sends_both_price_ceilings_to_the_breaker_pool_lookup() { // The breaker's pool must be priced like selection, so its lookup carries diff --git a/k8s/configmap-example.yaml b/k8s/configmap-example.yaml index 9d4ccb87..e0ad4471 100644 --- a/k8s/configmap-example.yaml +++ b/k8s/configmap-example.yaml @@ -40,6 +40,7 @@ data: "base-sepolia": 150, "sepolia": 50 }, + "fallback_max_grt_per_30_days": 4500, "max_grt_per_billion_entities_per_30_days": 2000 }, "admin_rpc": {