diff --git a/broker-template.toml b/broker-template.toml
index 0bb6c6c91..8debb1225 100644
--- a/broker-template.toml
+++ b/broker-template.toml
@@ -25,6 +25,23 @@
# `min_mcycle_price` to determine if the order should be locked.
min_mcycle_price = "0.00002 USD"
+
+# Per-requestor and per-proof-type min_mcycle_price overrides.
+#
+# Resolution priority: by_requestor_proof_type > by_proof_type > by_requestor > global default.
+# Changes take effect immediately when the broker reloads this file.
+#
+# Uncomment and populate any of the sections below to enable overrides:
+#
+# [market.pricing_overrides.by_requestor]
+# "0xAbC...123" = "0.0001 USD"
+#
+# [market.pricing_overrides.by_proof_type]
+# "0x12345678" = "0.0005 USD"
+#
+# [market.pricing_overrides.by_requestor_proof_type]
+# "0xAbC...123:0x12345678" = "0.001 USD"
+
# The minimum price per mega-cycle (i.e. million RISC-V cycles) for the broker
# to attempt to fulfill an order in the case that the order was locked by another prover
# but not fulfilled within the lock timeout.
diff --git a/crates/boundless-market/src/prover_utils/config.rs b/crates/boundless-market/src/prover_utils/config.rs
index f5a37a2f9..94af46fca 100644
--- a/crates/boundless-market/src/prover_utils/config.rs
+++ b/crates/boundless-market/src/prover_utils/config.rs
@@ -21,9 +21,12 @@
#[cfg(any(feature = "prover_utils", feature = "test-utils"))]
use std::path::Path;
-use std::{collections::HashSet, path::PathBuf};
+use std::{
+ collections::{HashMap, HashSet},
+ path::PathBuf,
+};
-use alloy::primitives::Address;
+use alloy::primitives::{Address, FixedBytes};
#[cfg(any(feature = "prover_utils", feature = "test-utils"))]
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
@@ -513,6 +516,16 @@ pub struct MarketConfig {
/// market. This should remain false to avoid losing partial PoVW jobs.
#[serde(default)]
pub cancel_proving_expired_orders: bool,
+ /// Per-requestor and per-proof-type `min_mcycle_price` overrides.
+ ///
+ /// The proof type is the 4-byte function selector from the order's requirements
+ /// (`request.requirements.selector`), identifying the verifier entry point.
+ ///
+ /// When the broker evaluates an order, it resolves the effective `min_mcycle_price`
+ /// with priority: `by_requestor_proof_type` > `by_proof_type` > `by_requestor` > global default.
+ /// Changes to this section are picked up automatically when the broker reloads `broker.toml`.
+ #[serde(default)]
+ pub pricing_overrides: PricingOverrides,
/// Maximum order expiry duration in seconds (order_expiry - now).
/// Orders with a longer time until expiry will be skipped.
/// If not set (None), no maximum is enforced.
@@ -565,6 +578,7 @@ impl Default for MarketConfig {
order_pricing_priority: OrderPricingPriority::default(),
order_commitment_priority: OrderCommitmentPriority::default(),
cancel_proving_expired_orders: false,
+ pricing_overrides: PricingOverrides::default(),
max_order_expiry_secs: defaults::max_order_expiry_secs(),
}
}
@@ -743,3 +757,238 @@ impl Config {
fs::write(path, data).await.context("Failed to write Config to disk")
}
}
+
+/// Per-requestor and per-proof-type `min_mcycle_price` overrides.
+///
+/// The proof type is the 4-byte selector from the order's requirements
+/// (i.e. `request.requirements.selector`), which identifies the verifier entry point.
+///
+/// Resolution priority (first match wins):
+/// 1. `by_requestor_proof_type` -- keyed by `"
:"`
+/// 2. `by_proof_type` -- keyed by proof type hex (e.g. `"0x12345678"`)
+/// 3. `by_requestor` -- keyed by requestor address
+/// 4. Fall back to global `MarketConfig::min_mcycle_price`
+#[derive(Debug, Clone, Default, Deserialize, Serialize)]
+pub struct PricingOverrides {
+ /// Overrides keyed by requestor address (checksummed or lowercase hex).
+ #[serde(default)]
+ pub by_requestor: HashMap,
+ /// Overrides keyed by proof type hex (e.g. `"0x12345678"`).
+ #[serde(default)]
+ pub by_proof_type: HashMap, Amount>,
+ /// Overrides keyed by `":"` for the most specific match.
+ #[serde(
+ default,
+ deserialize_with = "deserialize_requestor_proof_type_map",
+ serialize_with = "serialize_requestor_proof_type_map"
+ )]
+ pub by_requestor_proof_type: HashMap<(Address, FixedBytes<4>), Amount>,
+}
+
+fn deserialize_requestor_proof_type_map<'de, D>(
+ deserializer: D,
+) -> Result), Amount>, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ let raw: HashMap = HashMap::deserialize(deserializer)?;
+ let mut map = HashMap::with_capacity(raw.len());
+ for (key, amount) in raw {
+ let (addr_str, proof_type_str) = key.split_once(':').ok_or_else(|| {
+ serde::de::Error::custom(format!(
+ "by_requestor_proof_type key must be ':', got '{key}'"
+ ))
+ })?;
+ let addr: Address = addr_str.parse().map_err(serde::de::Error::custom)?;
+ let proof_type: FixedBytes<4> = proof_type_str.parse().map_err(serde::de::Error::custom)?;
+ map.insert((addr, proof_type), amount);
+ }
+ Ok(map)
+}
+
+fn serialize_requestor_proof_type_map(
+ map: &HashMap<(Address, FixedBytes<4>), Amount>,
+ serializer: S,
+) -> Result
+where
+ S: serde::Serializer,
+{
+ use serde::ser::SerializeMap;
+ let mut ser_map = serializer.serialize_map(Some(map.len()))?;
+ for ((addr, proof_type), amount) in map {
+ ser_map.serialize_entry(&format!("{addr}:{proof_type}"), amount)?;
+ }
+ ser_map.end()
+}
+
+impl PricingOverrides {
+ /// Resolve the effective `min_mcycle_price` for a given requestor and proof type.
+ ///
+ /// Returns `Some(amount)` if an override matches, `None` to use the global default.
+ pub fn resolve(&self, requestor: &Address, proof_type: &FixedBytes<4>) -> Option<&Amount> {
+ self.by_requestor_proof_type
+ .get(&(*requestor, *proof_type))
+ .or_else(|| self.by_proof_type.get(proof_type))
+ .or_else(|| self.by_requestor.get(requestor))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn addr(s: &str) -> Address {
+ s.parse().unwrap()
+ }
+
+ fn sel(s: &str) -> FixedBytes<4> {
+ s.parse().unwrap()
+ }
+
+ fn amount(s: &str) -> Amount {
+ Amount::parse(s, None).unwrap()
+ }
+
+ #[test]
+ fn test_resolve_priority_requestor_proof_type_over_individual() {
+ let requestor = addr("0x0000000000000000000000000000000000000001");
+ let proof_type = sel("0x12345678");
+
+ let mut overrides = PricingOverrides::default();
+ overrides.by_requestor.insert(requestor, amount("0.001 USD"));
+ overrides.by_proof_type.insert(proof_type, amount("0.002 USD"));
+ overrides.by_requestor_proof_type.insert((requestor, proof_type), amount("0.003 USD"));
+
+ let result = overrides.resolve(&requestor, &proof_type);
+ assert_eq!(result.unwrap().to_string(), "0.003 USD");
+ }
+
+ #[test]
+ fn test_resolve_proof_type_over_requestor() {
+ let requestor = addr("0x0000000000000000000000000000000000000001");
+ let proof_type = sel("0x12345678");
+
+ let mut overrides = PricingOverrides::default();
+ overrides.by_requestor.insert(requestor, amount("0.001 USD"));
+ overrides.by_proof_type.insert(proof_type, amount("0.002 USD"));
+
+ let result = overrides.resolve(&requestor, &proof_type);
+ assert_eq!(result.unwrap().to_string(), "0.002 USD");
+ }
+
+ #[test]
+ fn test_toml_round_trip() {
+ let toml = r#"
+[by_requestor]
+"0x0000000000000000000000000000000000000001" = "0.001 USD"
+
+[by_proof_type]
+"0x12345678" = "0.005 ETH"
+
+[by_requestor_proof_type]
+"0x0000000000000000000000000000000000000001:0x12345678" = "0.01 USD"
+"#;
+ let overrides: PricingOverrides = toml::from_str(toml).unwrap();
+
+ assert_eq!(overrides.by_requestor.len(), 1);
+ assert_eq!(overrides.by_proof_type.len(), 1);
+ assert_eq!(overrides.by_requestor_proof_type.len(), 1);
+
+ let requestor = addr("0x0000000000000000000000000000000000000001");
+ let proof_type = sel("0x12345678");
+
+ assert_eq!(overrides.resolve(&requestor, &proof_type).unwrap().to_string(), "0.01 USD");
+
+ let other_proof_type = sel("0xdeadbeef");
+ assert_eq!(
+ overrides.resolve(&requestor, &other_proof_type).unwrap().to_string(),
+ "0.001 USD"
+ );
+
+ let other_addr = addr("0x0000000000000000000000000000000000000099");
+ assert_eq!(overrides.resolve(&other_addr, &proof_type).unwrap().to_string(), "0.005 ETH");
+
+ assert!(overrides.resolve(&other_addr, &other_proof_type).is_none());
+ }
+
+ #[test]
+ fn test_toml_multiple_entries() {
+ let toml = r#"
+[by_requestor]
+"0x0000000000000000000000000000000000000001" = "0.001 USD"
+"0x0000000000000000000000000000000000000002" = "0.002 USD"
+
+[by_proof_type]
+"0x12345678" = "0.005 ETH"
+"0xdeadbeef" = "0.01 ETH"
+
+[by_requestor_proof_type]
+"0x0000000000000000000000000000000000000001:0x12345678" = "0.001 USD"
+"0x0000000000000000000000000000000000000002:0xdeadbeef" = "0.002 USD"
+"#;
+ let overrides: PricingOverrides = toml::from_str(toml).unwrap();
+
+ assert_eq!(overrides.by_requestor.len(), 2);
+ assert_eq!(overrides.by_proof_type.len(), 2);
+ assert_eq!(overrides.by_requestor_proof_type.len(), 2);
+
+ // Verify serialize → deserialize round-trip preserves all entries
+ let serialized = toml::to_string(&overrides).unwrap();
+ let deserialized: PricingOverrides = toml::from_str(&serialized).unwrap();
+ assert_eq!(deserialized.by_requestor.len(), 2);
+ assert_eq!(deserialized.by_proof_type.len(), 2);
+ assert_eq!(deserialized.by_requestor_proof_type.len(), 2);
+ }
+
+ #[test]
+ fn test_toml_invalid_requestor_proof_type_key() {
+ let toml = r#"
+[by_requestor_proof_type]
+"bad-key-no-colon" = "0.001 USD"
+"#;
+ let result: Result = toml::from_str(toml);
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn test_market_config_toml_with_pricing_overrides() {
+ let toml = r#"
+mcycle_price = "0.00002 USD"
+mcycle_price_collateral_token = "0.001 ZKC"
+max_stake = "10 USD"
+
+[pricing_overrides.by_requestor]
+"0x0000000000000000000000000000000000000001" = "0.001 USD"
+
+[pricing_overrides.by_proof_type]
+"0x12345678" = "0.005 ETH"
+
+[pricing_overrides.by_requestor_proof_type]
+"0x0000000000000000000000000000000000000001:0x12345678" = "0.01 USD"
+"#;
+ let config: MarketConfig = toml::from_str(toml).unwrap();
+
+ let requestor = addr("0x0000000000000000000000000000000000000001");
+ let proof_type = sel("0x12345678");
+
+ // requestor+proof_type combo takes highest priority
+ assert_eq!(
+ config.pricing_overrides.resolve(&requestor, &proof_type).unwrap().to_string(),
+ "0.01 USD"
+ );
+ // requestor-only match
+ assert_eq!(
+ config.pricing_overrides.resolve(&requestor, &sel("0xdeadbeef")).unwrap().to_string(),
+ "0.001 USD"
+ );
+ // proof_type-only match
+ assert_eq!(
+ config
+ .pricing_overrides
+ .resolve(&addr("0x0000000000000000000000000000000000000099"), &proof_type)
+ .unwrap()
+ .to_string(),
+ "0.005 ETH"
+ );
+ }
+}
diff --git a/crates/boundless-market/src/prover_utils/mod.rs b/crates/boundless-market/src/prover_utils/mod.rs
index fd0cea579..2ae41b7fc 100644
--- a/crates/boundless-market/src/prover_utils/mod.rs
+++ b/crates/boundless-market/src/prover_utils/mod.rs
@@ -23,7 +23,7 @@ pub use config::MarketConfig;
#[cfg(feature = "prover_utils")]
pub use config::{
defaults as config_defaults, BatcherConfig, Config, MarketConfig, OrderCommitmentPriority,
- OrderPricingPriority, ProverConfig,
+ OrderPricingPriority, PricingOverrides, ProverConfig,
};
use crate::{
@@ -965,8 +965,11 @@ pub trait OrderPricingContext {
journal_len,
})
} else {
- // For lockable orders, evaluate based on ETH price
- let config_min_mcycle_price_amount = &config.min_mcycle_price;
+ // For lockable orders, evaluate based on ETH price.
+ let config_min_mcycle_price_amount = config
+ .pricing_overrides
+ .resolve(&order.request.client_address(), &order.request.requirements.selector)
+ .unwrap_or(&config.min_mcycle_price);
// Convert configured price to ETH (i.e., handles USD via price oracle)
let config_min_mcycle_price_eth =
@@ -1080,7 +1083,10 @@ pub trait OrderPricingContext {
let lock_expiry = order.request.lock_expires_at();
let order_expiry = order.request.expires_at();
let config = self.market_config()?;
- let min_mcycle_price_amount = &config.min_mcycle_price;
+ let min_mcycle_price_amount = config
+ .pricing_overrides
+ .resolve(&order.request.client_address(), &order.request.requirements.selector)
+ .unwrap_or(&config.min_mcycle_price);
// Convert configured price to ETH (handles USD via price oracle)
let min_mcycle_price_eth = self.convert_to_eth(min_mcycle_price_amount).await?;
diff --git a/crates/broker/src/order_picker.rs b/crates/broker/src/order_picker.rs
index c921d64bd..e7708c793 100644
--- a/crates/broker/src/order_picker.rs
+++ b/crates/broker/src/order_picker.rs
@@ -3075,4 +3075,66 @@ pub(crate) mod tests {
let priced_order = ctx.priced_orders_rx.try_recv().unwrap();
assert!(priced_order.target_timestamp.is_some());
}
+
+ #[tokio::test]
+ async fn test_calculate_exec_limits_proof_type_override_takes_priority() {
+ let mut market_config = MarketConfig::default();
+ market_config.min_mcycle_price = Amount::parse("0.001 ETH", None).unwrap();
+ market_config.min_mcycle_price_collateral_token = Amount::parse("10 ZKC", None).unwrap();
+ market_config.max_mcycle_limit = 100_000;
+
+ let config = ConfigLock::default();
+ config.load_write().unwrap().market = market_config;
+
+ // Set up overrides: requestor gets 0.005 ETH, but proof_type gets 0.01 ETH.
+ // proof_type should win per the cascade priority.
+ let ctx_baseline =
+ PickerTestCtxBuilder::default().with_config(config.clone()).build().await;
+
+ let order_template = ctx_baseline
+ .generate_next_order(OrderParams {
+ fulfillment_type: FulfillmentType::LockAndFulfill,
+ max_price: parse_ether("0.05").unwrap(),
+ lock_collateral: parse_collateral_tokens("100"),
+ lock_timeout: 900,
+ timeout: 1200,
+ ..Default::default()
+ })
+ .await;
+
+ let requestor_addr = order_template.request.client_address();
+ let proof_type = order_template.request.requirements.selector;
+
+ let mut config2_market = config.lock_all().unwrap().market.clone();
+ config2_market
+ .pricing_overrides
+ .by_requestor
+ .insert(requestor_addr, Amount::parse("0.005 ETH", None).unwrap());
+ config2_market
+ .pricing_overrides
+ .by_proof_type
+ .insert(proof_type, Amount::parse("0.01 ETH", None).unwrap());
+ let config2 = ConfigLock::default();
+ config2.load_write().unwrap().market = config2_market;
+
+ let ctx = PickerTestCtxBuilder::default().with_config(config2).build().await;
+
+ let gas_cost = parse_ether("0.001").unwrap();
+ let order = ctx
+ .generate_next_order(OrderParams {
+ fulfillment_type: FulfillmentType::LockAndFulfill,
+ max_price: parse_ether("0.05").unwrap(),
+ lock_collateral: parse_collateral_tokens("100"),
+ lock_timeout: 900,
+ timeout: 1200,
+ ..Default::default()
+ })
+ .await;
+
+ let (_, prove_limit, _) = ctx.picker.calculate_exec_limits(&order, gas_cost).await.unwrap();
+
+ // proof_type override (0.01 ETH) should take priority over requestor (0.005 ETH).
+ // ETH based: (0.05 - 0.001) * 1M / 0.01 = 4.9M cycles
+ assert_eq!(prove_limit, 4_900_000u64);
+ }
}