Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions broker-template.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
253 changes: 251 additions & 2 deletions crates/boundless-market/src/prover_utils/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(),
}
}
Expand Down Expand Up @@ -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 `"<address>:<proof_type_hex>"`
/// 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<Address, Amount>,
/// Overrides keyed by proof type hex (e.g. `"0x12345678"`).
#[serde(default)]
pub by_proof_type: HashMap<FixedBytes<4>, Amount>,
/// Overrides keyed by `"<requestor_address>:<proof_type_hex>"` 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<HashMap<(Address, FixedBytes<4>), Amount>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw: HashMap<String, Amount> = 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 '<address>:<proof_type_hex>', 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<S>(
map: &HashMap<(Address, FixedBytes<4>), Amount>,
serializer: S,
) -> Result<S::Ok, S::Error>
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<PricingOverrides, _> = 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"
);
}
}
14 changes: 10 additions & 4 deletions crates/boundless-market/src/prover_utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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?;
Expand Down
Loading
Loading