From a74ba8152c1cae03c36176a5dabb4a657b63b5c3 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 Date: Fri, 23 Oct 2026 18:50:15 +0200 Subject: [PATCH 1/4] fix(config): cap the base price on chains without their own ceiling Only the 9 rollout chains had a base-price ceiling, so on any other chain an indexer could ask any base price and still be picked. A fallback ceiling now covers every chain without its own entry, by default the highest chain ceiling (4,500 GRT per 30 days). --- bin/dipper-service/src/config.rs | 84 +++++++++++++++++-- .../src/network/service/escrow_reconciler.rs | 1 + .../handlers/reassess_indexing_request.rs | 34 ++++++-- k8s/configmap-example.yaml | 1 + 4 files changed, 109 insertions(+), 11 deletions(-) diff --git a/bin/dipper-service/src/config.rs b/bin/dipper-service/src/config.rs index b9feae9d..91f0077b 100644 --- a/bin/dipper-service/src/config.rs +++ b/bin/dipper-service/src/config.rs @@ -902,6 +902,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. @@ -1007,6 +1012,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 ({})", @@ -1078,10 +1089,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). @@ -1112,6 +1122,14 @@ fn default_max_grt_per_30_days() -> BTreeMap { .collect() } +/// Default ceiling for chains outside the rollout set: the highest default chain ceiling, so +/// an unlisted chain is capped no looser than the priciest listed one. +fn default_fallback_max_grt_per_30_days() -> f64 { + default_max_grt_per_30_days() + .into_values() + .fold(0.0, f64::max) +} + fn default_max_grt_per_billion_entities_per_30_days() -> f64 { 2000.0 } @@ -1284,6 +1302,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). @@ -1327,6 +1347,7 @@ impl IndexingAgreementConfig { duration_seconds: 0, deadline_seconds: 0, max_grt_per_30_days: BTreeMap::new(), + fallback_max_grt_per_30_days: 0.0, max_grt_per_billion_entities_per_30_days: 0.0, declined_indexer_lookback_days: 0, price_rejection_lookback_days: 0, @@ -1385,8 +1406,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 { @@ -1455,6 +1480,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, @@ -1735,6 +1761,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_excludes_every_priced_indexer() { + 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 7151042b..5686fd2c 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: 0.0, max_grt_per_billion_entities_per_30_days: 0.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 dc0fb0ef..5e6d6068 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(), @@ -1534,6 +1530,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": { From fb39b216d2c403c3a7e61d58674ba19ac671187c Mon Sep 17 00:00:00 2001 From: MoonBoi9001 Date: Fri, 23 Oct 2026 18:53:45 +0200 Subject: [PATCH 2/4] refactor(config): read the fallback ceiling from the rollout table The default fallback ceiling was worked out by building the whole default chain map, and its comment implied it follows an operator's own chain ceilings, which it does not. --- bin/dipper-service/src/config.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bin/dipper-service/src/config.rs b/bin/dipper-service/src/config.rs index 91f0077b..771db06b 100644 --- a/bin/dipper-service/src/config.rs +++ b/bin/dipper-service/src/config.rs @@ -1122,11 +1122,12 @@ fn default_max_grt_per_30_days() -> BTreeMap { .collect() } -/// Default ceiling for chains outside the rollout set: the highest default chain ceiling, so -/// an unlisted chain is capped no looser than the priciest listed one. +/// 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 { - default_max_grt_per_30_days() - .into_values() + INDEXER_RS_MIN_GRT_PER_30_DAYS + .iter() + .map(|(_, min)| min * PAYMENT_CEILING_MULTIPLIER) .fold(0.0, f64::max) } From 4ed1abfcf1455c17f98273146220edc94e7e8703 Mon Sep 17 00:00:00 2001 From: MoonBoi9001 Date: Fri, 23 Oct 2026 18:54:10 +0200 Subject: [PATCH 3/4] test(config): name the fallback check after what it rejects An infinite ceiling would let every indexer through rather than shut them out, so the test's name now says it rejects anything that is not a positive number. --- bin/dipper-service/src/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/dipper-service/src/config.rs b/bin/dipper-service/src/config.rs index 771db06b..1ad0e366 100644 --- a/bin/dipper-service/src/config.rs +++ b/bin/dipper-service/src/config.rs @@ -1785,7 +1785,7 @@ mod tests { } #[test] - fn validate_rejects_a_fallback_ceiling_that_excludes_every_priced_indexer() { + fn validate_rejects_a_fallback_ceiling_that_is_not_a_positive_number() { let mut conf: DipsAgreementConfig = serde_json::from_str( r#"{ "data_service": "0x1111111111111111111111111111111111111111", From 8484e7ff1d9b801b3eb7e959f4f0be1981a6520b Mon Sep 17 00:00:00 2001 From: MoonBoi9001 Date: Tue, 27 Oct 2026 10:49:06 +0100 Subject: [PATCH 4/4] test(config): give test configs a fallback price cap dipper accepts The shared test configs set the fallback cap to 0, which dipper refuses to start with, so tests ran on a setup production can't have. They now use the default of 4,500. --- bin/dipper-service/src/config.rs | 6 +++--- bin/dipper-service/src/network/service/escrow_reconciler.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bin/dipper-service/src/config.rs b/bin/dipper-service/src/config.rs index 30ba1e88..428fd0ef 100644 --- a/bin/dipper-service/src/config.rs +++ b/bin/dipper-service/src/config.rs @@ -1345,8 +1345,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, @@ -1358,7 +1358,7 @@ impl IndexingAgreementConfig { duration_seconds: 0, deadline_seconds: 0, max_grt_per_30_days: BTreeMap::new(), - fallback_max_grt_per_30_days: 0.0, + 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, diff --git a/bin/dipper-service/src/network/service/escrow_reconciler.rs b/bin/dipper-service/src/network/service/escrow_reconciler.rs index 2a72eff0..67fd5e8d 100644 --- a/bin/dipper-service/src/network/service/escrow_reconciler.rs +++ b/bin/dipper-service/src/network/service/escrow_reconciler.rs @@ -750,7 +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: 0.0, + 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,