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); + } }