Skip to main content

boundless_market/request_builder/
offer_layer.rs

1// Copyright 2026 Boundless Foundation, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use super::{Adapt, Layer, MissingFieldError, RequestParams};
16use crate::{
17    contracts::{Offer, RequestId, Requirements},
18    price_provider::PriceProviderArc,
19    request_builder::ParameterizationMode,
20    selector::{ProofType, SupportedSelectors},
21    util::now_timestamp,
22    LARGE_REQUESTOR_LIST_THRESHOLD_KHZ, XL_REQUESTOR_LIST_THRESHOLD_KHZ,
23};
24use alloy::{
25    network::Ethereum,
26    primitives::{
27        utils::{format_units, Unit},
28        U256,
29    },
30    providers::Provider,
31};
32use anyhow::{Context, Result};
33use clap::Args;
34use derive_builder::Builder;
35
36pub(crate) const DEFAULT_TIMEOUT: u32 = 600;
37pub(crate) const DEFAULT_RAMP_UP_PERIOD: u32 = 60;
38/// Default min price when not set by params, config, or market (wei).
39pub(crate) const DEFAULT_MIN_PRICE: U256 = U256::ZERO;
40/// Default max price per cycle when not set by params, config, or market
41/// (100 Kwei/cycle in wei to match 100 Gwei/Mcycle ~99th percentile of market as of 2026-02-11).
42pub(crate) fn default_max_price_per_cycle() -> U256 {
43    U256::from(100) * Unit::KWEI.wei_const()
44}
45
46/// Resolves min price (total) with priority: params > config > market > default (default is per-cycle × cycle_count).
47pub(crate) fn resolve_min_price(
48    params_min: Option<U256>,
49    config_min_per_cycle: Option<U256>,
50    cycle_count: Option<u64>,
51    market_min: Option<U256>,
52) -> U256 {
53    params_min
54        .or_else(|| cycle_count.and_then(|c| config_min_per_cycle.map(|p| p * U256::from(c))))
55        .or(market_min)
56        .unwrap_or(DEFAULT_MIN_PRICE)
57}
58
59/// Resolves max price (total) with priority: params > config > market > default (default is per-cycle × cycle_count).
60pub(crate) fn resolve_max_price(
61    params_max: Option<U256>,
62    config_max: Option<U256>,
63    market_max: Option<U256>,
64    cycle_count: Option<u64>,
65) -> U256 {
66    params_max.or(config_max).or(market_max).unwrap_or_else(|| {
67        // Use at least 1 so zero-cycle offers get a positive default max (fixed costs).
68        let n = if let Some(count) = cycle_count {
69            count.max(1)
70        } else {
71            tracing::warn!("No cycle count provided using static fallback, defaulting to 1");
72            1
73        };
74        default_max_price_per_cycle() * U256::from(n)
75    })
76}
77
78struct CollateralRecommendation {
79    default: U256,
80    large: U256,
81    xl: U256,
82}
83
84impl CollateralRecommendation {
85    fn new(default: U256, large: U256, xl: U256) -> Self {
86        Self { default, large, xl }
87    }
88
89    /// Determine the recommended minimum collateral based on secondary performance and current collateral.
90    ///
91    /// Returns `Some(recommended_amount)` if the current collateral is too low, `None` otherwise.
92    fn recommend_collateral(
93        &self,
94        secondary_performance: f64,
95        lock_collateral: U256,
96    ) -> anyhow::Result<Option<U256>> {
97        let recommended = if secondary_performance < LARGE_REQUESTOR_LIST_THRESHOLD_KHZ
98            && lock_collateral < self.default
99        {
100            Some(self.default)
101        } else if (LARGE_REQUESTOR_LIST_THRESHOLD_KHZ..XL_REQUESTOR_LIST_THRESHOLD_KHZ)
102            .contains(&secondary_performance)
103            && lock_collateral < self.large
104        {
105            Some(self.large)
106        } else if secondary_performance >= XL_REQUESTOR_LIST_THRESHOLD_KHZ
107            && lock_collateral < self.xl
108        {
109            Some(self.xl)
110        } else {
111            None
112        };
113        Ok(recommended)
114    }
115}
116/// Check if primary performance exceeds threshold and log a warning with recommended lock timeout.
117///
118/// Returns `true` if a warning was logged.
119fn check_primary_performance_warning(cycle_count: u64, primary_performance: f64) -> bool {
120    if primary_performance > XL_REQUESTOR_LIST_THRESHOLD_KHZ {
121        let recommended_lock_timeout =
122            cycle_count.div_ceil(XL_REQUESTOR_LIST_THRESHOLD_KHZ as u64) as u32;
123        tracing::warn!(
124            "Warning: your request requires a proving Khz of {primary_performance} to be \
125             fulfilled before the lock timeout. This limits the number of provers in the \
126             network that will be able to fulfill your order. Consider setting a longer \
127             lock timeout of at least {recommended_lock_timeout} seconds."
128        );
129        true
130    } else {
131        false
132    }
133}
134
135/// Check if secondary performance exceeds threshold and log a warning with recommended timeout.
136///
137/// Returns `true` if a warning was logged.
138fn check_secondary_performance_warning(
139    cycle_count: u64,
140    secondary_performance: f64,
141    lock_timeout: u32,
142) -> bool {
143    if secondary_performance > XL_REQUESTOR_LIST_THRESHOLD_KHZ {
144        // Secondary prover has (timeout - lockTimeout) time available
145        // Minimum time needed for secondary window: cycle_count / XL_threshold
146        let min_secondary_window =
147            cycle_count.div_ceil(XL_REQUESTOR_LIST_THRESHOLD_KHZ as u64) as u32;
148        // Recommended total timeout = lockTimeout + minimum secondary window
149        let recommended_timeout = lock_timeout.saturating_add(min_secondary_window);
150        tracing::warn!(
151            "Warning: your request requires a proving Khz of {secondary_performance} to be \
152             fulfilled before the timeout. This limits the number of provers in the network \
153             that will be able to fulfill your order. Consider setting a longer timeout of \
154             at least {recommended_timeout} seconds."
155        );
156        true
157    } else {
158        false
159    }
160}
161
162/// Configuration for the [OfferLayer].
163///
164/// Defines the default pricing parameters, timeouts, gas estimates, and other
165/// settings used when constructing offers for proof requests.
166#[non_exhaustive]
167#[derive(Clone, Builder)]
168pub struct OfferLayerConfig {
169    /// Parameterization mode.
170    ///
171    /// Defines the offering parameters for the request. The default is
172    /// [ParameterizationMode::fulfillment()], which is a conservative mode that ensures
173    /// more provers can fulfill the request.
174    ///
175    /// # Example
176    /// ```rust
177    /// # use boundless_market::request_builder::{OfferLayerConfig, ParameterizationMode};
178    ///
179    /// OfferLayerConfig::builder().parameterization_mode(ParameterizationMode::fulfillment());
180    /// ```
181    #[builder(setter(into), default = "Some(ParameterizationMode::fulfillment())")]
182    pub parameterization_mode: Option<ParameterizationMode>,
183
184    /// Minimum price per RISC Zero execution cycle, in wei.
185    #[builder(setter(into, strip_option), default)]
186    pub min_price_per_cycle: Option<U256>,
187
188    /// Maximum price per RISC Zero execution cycle, in wei.
189    #[builder(setter(into, strip_option), default)]
190    pub max_price_per_cycle: Option<U256>,
191
192    /// Time in seconds to delay the start of bidding after request creation.
193    #[builder(setter(strip_option), default)]
194    pub bidding_start_delay: Option<u64>,
195
196    /// Duration in seconds for the price to ramp up from min to max.
197    #[builder(setter(strip_option), default)]
198    pub ramp_up_period: Option<u32>,
199
200    /// Time in seconds that a prover has to fulfill a locked request.
201    #[builder(setter(strip_option), default)]
202    pub lock_timeout: Option<u32>,
203
204    /// Maximum time in seconds that a request can remain active.
205    #[builder(setter(strip_option), default)]
206    pub timeout: Option<u32>,
207
208    /// Lock collateral
209    #[builder(setter(strip_option, into), default)]
210    pub lock_collateral: Option<U256>,
211
212    /// Estimated gas used when locking a request.
213    #[builder(default = "200_000")]
214    pub lock_gas_estimate: u64,
215
216    /// Estimated gas used when fulfilling a request.
217    #[builder(default = "750_000")]
218    pub fulfill_gas_estimate: u64,
219
220    /// Estimated gas used for Groth16 verification.
221    #[builder(default = "250_000")]
222    pub groth16_verify_gas_estimate: u64,
223
224    /// Estimated gas used for ERC-1271 signature verification.
225    #[builder(default = "100_000")]
226    pub smart_contract_sig_verify_gas_estimate: u64,
227
228    /// Supported proof types and their corresponding selectors.
229    #[builder(setter(into), default)]
230    pub supported_selectors: SupportedSelectors,
231}
232
233#[non_exhaustive]
234/// A layer responsible for configuring the offer part of a proof request.
235///
236/// This layer uses an Ethereum provider to estimate gas costs and sets appropriate
237/// pricing parameters for the proof request. It combines cycle count estimates with
238/// gas price information to determine minimum and maximum prices for the request.
239///
240/// If a price provider is configured, it will be used to fetch market prices when
241/// `OfferParams` doesn't explicitly set min_price or max_price.
242pub struct OfferLayer<P> {
243    /// The Ethereum provider used for gas price estimation.
244    pub provider: P,
245
246    /// Configuration for offer generation.
247    pub config: OfferLayerConfig,
248
249    /// Optional price provider for fetching market-based prices.
250    /// If set, will be used when `OfferParams` doesn't specify prices.
251    pub price_provider: Option<PriceProviderArc>,
252}
253
254impl<P: Clone> Clone for OfferLayer<P> {
255    fn clone(&self) -> Self {
256        Self {
257            provider: self.provider.clone(),
258            config: self.config.clone(),
259            price_provider: self.price_provider.clone(),
260        }
261    }
262}
263
264impl OfferLayerConfig {
265    /// Creates a new builder for constructing an [OfferLayerConfig].
266    ///
267    /// This provides a way to customize pricing parameters, timeouts, and other
268    /// offer-related settings used when generating proof requests.
269    pub fn builder() -> OfferLayerConfigBuilder {
270        Default::default()
271    }
272}
273
274impl Default for OfferLayerConfig {
275    fn default() -> Self {
276        Self::builder().build().expect("implementation error in Default for OfferLayerConfig")
277    }
278}
279
280impl<P: Clone> From<P> for OfferLayer<P> {
281    fn from(provider: P) -> Self {
282        OfferLayer { provider, config: Default::default(), price_provider: None }
283    }
284}
285
286#[non_exhaustive]
287#[derive(Clone, Debug, Default, Builder, Args)]
288/// A partial [Offer], with all the fields as optional. Used in the [OfferLayer] to override
289/// defaults set in the [OfferLayerConfig].
290pub struct OfferParams {
291    /// Minimum price willing to pay for the proof, in wei.
292    #[clap(long)]
293    #[builder(setter(strip_option, into), default)]
294    pub min_price: Option<U256>,
295
296    /// Maximum price willing to pay for the proof, in wei.
297    #[clap(long)]
298    #[builder(setter(strip_option, into), default)]
299    pub max_price: Option<U256>,
300
301    /// Timestamp when bidding will start for this request.
302    #[clap(long)]
303    #[builder(setter(strip_option), default)]
304    pub bidding_start: Option<u64>,
305
306    /// Duration in seconds for the price to ramp up from min to max.
307    #[clap(long)]
308    #[builder(setter(strip_option), default)]
309    pub ramp_up_period: Option<u32>,
310
311    /// Time in seconds that a prover has to fulfill a locked request.
312    #[clap(long)]
313    #[builder(setter(strip_option), default)]
314    pub lock_timeout: Option<u32>,
315
316    /// Maximum time in seconds that a request can remain active.
317    #[clap(long)]
318    #[builder(setter(strip_option), default)]
319    pub timeout: Option<u32>,
320
321    /// Amount of the stake token that the prover must stake when locking a request.
322    #[clap(long)]
323    #[builder(setter(strip_option, into), default)]
324    pub lock_collateral: Option<U256>,
325}
326
327impl From<Offer> for OfferParams {
328    fn from(value: Offer) -> Self {
329        Self {
330            timeout: Some(value.timeout),
331            min_price: Some(value.minPrice),
332            max_price: Some(value.maxPrice),
333            lock_collateral: Some(value.lockCollateral),
334            lock_timeout: Some(value.lockTimeout),
335            bidding_start: Some(value.rampUpStart),
336            ramp_up_period: Some(value.rampUpPeriod),
337        }
338    }
339}
340
341impl From<OfferParamsBuilder> for OfferParams {
342    fn from(value: OfferParamsBuilder) -> Self {
343        // Builder should be infallible.
344        value.build().expect("implementation error in OfferParams")
345    }
346}
347
348// Allows for a nicer builder pattern in RequestParams.
349impl From<&mut OfferParamsBuilder> for OfferParams {
350    fn from(value: &mut OfferParamsBuilder) -> Self {
351        value.clone().into()
352    }
353}
354
355impl TryFrom<OfferParams> for Offer {
356    type Error = MissingFieldError;
357
358    fn try_from(value: OfferParams) -> Result<Self, Self::Error> {
359        Ok(Self {
360            timeout: value.timeout.ok_or(MissingFieldError::new("timeout"))?,
361            minPrice: value.min_price.ok_or(MissingFieldError::new("min_price"))?,
362            maxPrice: value.max_price.ok_or(MissingFieldError::new("max_price"))?,
363            lockCollateral: value
364                .lock_collateral
365                .ok_or(MissingFieldError::new("lock_collateral"))?,
366            lockTimeout: value.lock_timeout.ok_or(MissingFieldError::new("lock_timeout"))?,
367            rampUpStart: value.bidding_start.ok_or(MissingFieldError::new("bidding_start"))?,
368            rampUpPeriod: value.ramp_up_period.ok_or(MissingFieldError::new("ramp_up_period"))?,
369        })
370    }
371}
372
373impl OfferParams {
374    /// Creates a new builder for constructing [OfferParams].
375    ///
376    /// Use this to set specific pricing parameters, timeouts, or other offer details
377    /// that will override the defaults from [OfferLayerConfig].
378    pub fn builder() -> OfferParamsBuilder {
379        Default::default()
380    }
381}
382
383impl<P> OfferLayer<P>
384where
385    P: Provider<Ethereum> + 'static + Clone,
386{
387    /// Creates a new [OfferLayer] with the given provider and configuration.
388    ///
389    /// The provider is used to fetch current gas prices for estimating transaction costs,
390    /// which are factored into the offer pricing.
391    pub fn new(provider: P, config: OfferLayerConfig) -> Self {
392        Self { provider, config, price_provider: None }
393    }
394
395    /// Set the price provider for the [OfferLayer].
396    ///
397    /// The price provider will be used to fetch market prices when `OfferParams` doesn't
398    /// explicitly set min_price or max_price.
399    ///
400    /// # Parameters
401    ///
402    /// * `price_provider`: The price provider to use.
403    ///
404    /// # Returns
405    ///
406    /// A new [OfferLayer] with the price provider set.
407    pub fn with_price_provider(self, price_provider: Option<PriceProviderArc>) -> Self {
408        Self { price_provider, ..self }
409    }
410
411    /// Estimates the maximum gas usage for a proof request.
412    ///
413    /// This calculates the upper bound of gas usage based on the request's requirements,
414    /// configuration settings, and request ID characteristics.
415    ///
416    /// The estimate includes gas for locking, fulfilling, signature verification,
417    /// callback execution, and proof verification.
418    pub fn estimate_gas_usage_upper_bound(
419        &self,
420        requirements: &Requirements,
421        request_id: &RequestId,
422    ) -> anyhow::Result<u64> {
423        let mut gas_usage_estimate =
424            self.config.lock_gas_estimate + self.config.fulfill_gas_estimate;
425        if request_id.smart_contract_signed {
426            gas_usage_estimate += self.config.smart_contract_sig_verify_gas_estimate;
427        }
428        if let Some(callback) = requirements.callback.as_option() {
429            gas_usage_estimate +=
430                u64::try_from(callback.gasLimit).context("callback gas limit too large for u64")?;
431        }
432
433        let proof_type = self
434            .config
435            .supported_selectors
436            .proof_type(requirements.selector)
437            .context("cannot estimate gas usage for request with unsupported selector")?;
438        if let ProofType::Groth16 = proof_type {
439            gas_usage_estimate += self.config.groth16_verify_gas_estimate;
440        };
441        Ok(gas_usage_estimate)
442    }
443
444    /// Estimates the maximum gas cost for a proof request.
445    ///
446    /// This calculates the cost in wei based on the estimated gas usage and
447    /// the provided gas price.
448    ///
449    /// The result is used to determine appropriate pricing parameters for
450    /// the proof request offer.
451    pub fn estimate_gas_cost_upper_bound(
452        &self,
453        requirements: &Requirements,
454        request_id: &RequestId,
455        gas_price: u128,
456    ) -> anyhow::Result<U256> {
457        let gas_usage_estimate = self.estimate_gas_usage_upper_bound(requirements, request_id)?;
458
459        let gas_cost_estimate = gas_price * (gas_usage_estimate as u128);
460        Ok(U256::from(gas_cost_estimate))
461    }
462
463    /// Computes max price as cycle-based price plus 2x current gas cost estimate.
464    ///
465    /// Fetches gas price from the provider, estimates gas cost for the request,
466    /// and returns the sum with `max_price_cycle`.
467    pub async fn max_price_with_gas(
468        &self,
469        requirements: &Requirements,
470        request_id: &RequestId,
471        max_price: U256,
472    ) -> anyhow::Result<U256> {
473        let gas_price: u128 = self.provider.get_gas_price().await?;
474        let gas_cost_estimate =
475            self.estimate_gas_cost_upper_bound(requirements, request_id, gas_price)?;
476        let adjusted_gas_cost_estimate = gas_cost_estimate + gas_cost_estimate;
477        let adjusted_max_price = max_price + adjusted_gas_cost_estimate;
478        tracing::debug!(
479            "Setting a max price of {} ether: {} max_price + {} (2x) gas_cost_estimate [gas price: {} gwei]",
480            format_units(adjusted_max_price, "ether")?,
481            format_units(max_price, "ether")?,
482            format_units(adjusted_gas_cost_estimate, "ether")?,
483            format_units(U256::from(gas_price), "gwei")?,
484        );
485        Ok(adjusted_max_price)
486    }
487}
488
489impl<P> Layer<(&Requirements, &RequestId, Option<u64>, &OfferParams)> for OfferLayer<P>
490where
491    P: Provider<Ethereum> + 'static + Clone,
492{
493    type Output = Offer;
494    type Error = anyhow::Error;
495
496    async fn process(
497        &self,
498        (requirements, request_id, cycle_count, params): (
499            &Requirements,
500            &RequestId,
501            Option<u64>,
502            &OfferParams,
503        ),
504    ) -> Result<Self::Output, Self::Error> {
505        // Try to use market prices from price provider if prices aren't set in params or config
506        let (market_min_price, market_max_price) = if (params.min_price.is_none()
507            && self.config.min_price_per_cycle.is_none())
508            || (params.max_price.is_none() && self.config.max_price_per_cycle.is_none())
509        {
510            if let Some(ref price_provider) = self.price_provider {
511                if let Some(cycle_count) = cycle_count {
512                    match price_provider.price_percentiles().await {
513                        Ok(percentiles) => {
514                            let min = U256::ZERO;
515                            let max = percentiles.p99.min(percentiles.p50 * U256::from(2))
516                                * U256::from(cycle_count);
517                            tracing::debug!(
518                                "Using market prices from price provider: min={}, max={} (for {} cycles)",
519                                format_units(min, "ether")?,
520                                format_units(max, "ether")?,
521                                cycle_count
522                            );
523                            (Some(min), Some(max))
524                        }
525                        Err(e) => {
526                            tracing::warn!(
527                                "Failed to fetch market prices from price provider: {}. Falling back to config-based pricing.",
528                                e
529                            );
530                            (None, None)
531                        }
532                    }
533                } else {
534                    tracing::warn!("No cycle count provided, falling back to default pricing");
535                    (None, None)
536                }
537            } else {
538                (None, None)
539            }
540        } else {
541            (None, None)
542        };
543
544        // Priority: params > config > market > static default
545        let min_price = resolve_min_price(
546            params.min_price,
547            self.config.min_price_per_cycle,
548            cycle_count,
549            market_min_price,
550        );
551
552        let config_max_value = if params.max_price.is_none() {
553            let c = cycle_count.context("cycle count required to set max price in OfferLayer")?;
554            if let Some(per_cycle) = self.config.max_price_per_cycle {
555                let max_price = per_cycle * U256::from(c);
556                Some(max_price)
557            } else {
558                None
559            }
560        } else {
561            None
562        };
563
564        let max_price =
565            resolve_max_price(params.max_price, config_max_value, market_max_price, cycle_count);
566        let max_price = if params.max_price.is_none() {
567            self.max_price_with_gas(requirements, request_id, max_price).await?
568        } else {
569            max_price
570        };
571
572        // Priority: config > recommended (from parameterization_mode) > default
573        let (recommended_ramp_up_start, recommended_ramp_up_period, recommended_lock_timeout) =
574            match self.config.parameterization_mode {
575                Some(m) => (
576                    Some(m.recommended_ramp_up_start(cycle_count)),
577                    Some(m.recommended_ramp_up_period(cycle_count)),
578                    Some(m.recommended_timeout(cycle_count)),
579                ),
580                None => (None, None, None),
581            };
582
583        let ramp_up_start = self
584            .config
585            .bidding_start_delay
586            .map(|d| now_timestamp() + d)
587            .or(recommended_ramp_up_start)
588            .unwrap_or_else(|| now_timestamp() + 15);
589
590        let ramp_up_period = self
591            .config
592            .ramp_up_period
593            .or(recommended_ramp_up_period)
594            .unwrap_or(DEFAULT_RAMP_UP_PERIOD);
595
596        let lock_timeout = self
597            .config
598            .lock_timeout
599            .or(recommended_lock_timeout)
600            .unwrap_or(DEFAULT_TIMEOUT + DEFAULT_RAMP_UP_PERIOD);
601
602        let timeout = self
603            .config
604            .timeout
605            .or(Some(lock_timeout * 2))
606            .unwrap_or((DEFAULT_TIMEOUT + DEFAULT_RAMP_UP_PERIOD) * 2);
607
608        if self.config.bidding_start_delay.is_none() && recommended_ramp_up_start.is_none() {
609            tracing::warn!("Using default ramp up start: {}", ramp_up_start);
610        }
611        if self.config.ramp_up_period.is_none() && recommended_ramp_up_period.is_none() {
612            tracing::warn!("Using default ramp up period: {}", ramp_up_period);
613        }
614        if self.config.lock_timeout.is_none() && recommended_lock_timeout.is_none() {
615            tracing::warn!("Using default lock timeout: {}", lock_timeout);
616        }
617        if self.config.timeout.is_none() && recommended_lock_timeout.is_none() {
618            tracing::warn!("Using default timeout: {}", timeout);
619        }
620
621        let chain_id = self.provider.get_chain_id().await?;
622        let default_collaterals = default_lock_collateral(chain_id);
623        let lock_collateral = self.config.lock_collateral.unwrap_or(default_collaterals.default);
624
625        let offer = Offer {
626            minPrice: min_price,
627            maxPrice: max_price,
628            rampUpStart: params.bidding_start.unwrap_or(ramp_up_start),
629            rampUpPeriod: params.ramp_up_period.unwrap_or(ramp_up_period),
630            lockTimeout: params.lock_timeout.unwrap_or(lock_timeout),
631            timeout: params.timeout.unwrap_or(timeout),
632            lockCollateral: params.lock_collateral.unwrap_or(lock_collateral),
633        };
634
635        if let Some(cycle_count) = cycle_count {
636            let primary_performance = offer.required_khz_performance(cycle_count);
637            let secondary_performance =
638                offer.required_khz_performance_secondary_prover(cycle_count);
639
640            check_primary_performance_warning(cycle_count, primary_performance);
641            check_secondary_performance_warning(
642                cycle_count,
643                secondary_performance,
644                offer.lockTimeout,
645            );
646
647            // Check if the collateral requirement is low and raise a warning if it is.
648            if let Some(collateral) = default_collaterals
649                .recommend_collateral(secondary_performance, offer.lockCollateral)?
650            {
651                tracing::warn!(
652                    "Warning: the collateral requirement of your request is low. This means the \
653                     incentives for secondary provers to fulfill the order if the primary prover \
654                     is slashed may be too low. It is recommended to set the lock collateral to at least {} ZKC.",
655                    format_units(collateral, "ether")?
656                );
657            }
658        }
659
660        Ok(offer)
661    }
662}
663
664/// Returns the default lock collateral for the given chain ID.
665fn default_lock_collateral(chain_id: u64) -> CollateralRecommendation {
666    match chain_id {
667        8453 => CollateralRecommendation::new(
668            U256::from(20) * Unit::ETHER.wei_const(),
669            U256::from(50) * Unit::ETHER.wei_const(),
670            U256::from(100) * Unit::ETHER.wei_const(),
671        ), // Base mainnet - 20 ZKC
672        84532 => CollateralRecommendation::new(
673            U256::from(5) * Unit::ETHER.wei_const(),
674            U256::from(10) * Unit::ETHER.wei_const(),
675            U256::from(20) * Unit::ETHER.wei_const(),
676        ), // Base Sepolia - 5 ZKC
677        11155111 => CollateralRecommendation::new(
678            U256::from(5) * Unit::ETHER.wei_const(),
679            U256::from(10) * Unit::ETHER.wei_const(),
680            U256::from(20) * Unit::ETHER.wei_const(),
681        ), // Sepolia - 5 ZKC
682        // Default for local/unknown chains (e.g., Anvil) - use similar to testnet defaults
683        _ => CollateralRecommendation::new(
684            U256::from(5) * Unit::ETHER.wei_const(),
685            U256::from(10) * Unit::ETHER.wei_const(),
686            U256::from(20) * Unit::ETHER.wei_const(),
687        ),
688    }
689}
690
691impl<P> Adapt<OfferLayer<P>> for RequestParams
692where
693    P: Provider<Ethereum> + 'static + Clone,
694{
695    type Output = RequestParams;
696    type Error = anyhow::Error;
697
698    async fn process_with(self, layer: &OfferLayer<P>) -> Result<Self::Output, Self::Error> {
699        tracing::trace!("Processing {self:?} with OfferLayer");
700
701        let requirements: Requirements = self
702            .requirements
703            .clone()
704            .try_into()
705            .context("failed to construct requirements in OfferLayer")?;
706        let request_id = self.require_request_id()?;
707
708        let offer = layer.process((&requirements, request_id, self.cycles, &self.offer)).await?;
709        Ok(self.with_offer(offer))
710    }
711}
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716    use alloy::primitives::utils::parse_ether;
717    use tracing_test::traced_test;
718
719    fn default_collaterals() -> CollateralRecommendation {
720        CollateralRecommendation::new(
721            parse_ether("20").unwrap(),
722            parse_ether("50").unwrap(),
723            parse_ether("100").unwrap(),
724        )
725    }
726
727    mod performance_warnings {
728        use super::*;
729
730        #[test]
731        fn primary_below_threshold() {
732            assert!(!check_primary_performance_warning(1000, 5000.0));
733        }
734
735        #[test]
736        #[traced_test]
737        fn primary_above_threshold() {
738            assert!(check_primary_performance_warning(20000, 15000.0));
739            assert!(logs_contain("Warning: your request requires a proving Khz"));
740            assert!(logs_contain("lock timeout"));
741        }
742
743        #[test]
744        fn primary_at_threshold() {
745            assert!(!check_primary_performance_warning(1000, XL_REQUESTOR_LIST_THRESHOLD_KHZ));
746        }
747
748        #[test]
749        fn secondary_below_threshold() {
750            assert!(!check_secondary_performance_warning(1000, 5000.0, 600));
751        }
752
753        #[test]
754        #[traced_test]
755        fn secondary_above_threshold() {
756            assert!(check_secondary_performance_warning(20000, 15000.0, 600));
757            assert!(logs_contain("Warning: your request requires a proving Khz"));
758            assert!(logs_contain("timeout"));
759        }
760
761        #[test]
762        fn secondary_at_threshold() {
763            assert!(!check_secondary_performance_warning(
764                1000,
765                XL_REQUESTOR_LIST_THRESHOLD_KHZ,
766                600
767            ));
768        }
769    }
770
771    mod collateral_recommendations {
772        use super::*;
773
774        #[test]
775        fn low_performance_low_collateral() {
776            let result = default_collaterals()
777                .recommend_collateral(2000.0, parse_ether("10").unwrap())
778                .unwrap();
779            assert_eq!(result, Some(parse_ether("20").unwrap()));
780        }
781
782        #[test]
783        fn low_performance_sufficient_collateral() {
784            let result = default_collaterals()
785                .recommend_collateral(2000.0, parse_ether("25").unwrap())
786                .unwrap();
787            assert_eq!(result, None);
788        }
789
790        #[test]
791        fn medium_performance_low_collateral() {
792            let result = default_collaterals()
793                .recommend_collateral(5000.0, parse_ether("30").unwrap())
794                .unwrap();
795            assert_eq!(result, Some(parse_ether("50").unwrap()));
796        }
797
798        #[test]
799        fn medium_performance_sufficient_collateral() {
800            let result = default_collaterals()
801                .recommend_collateral(5000.0, parse_ether("60").unwrap())
802                .unwrap();
803            assert_eq!(result, None);
804        }
805
806        #[test]
807        fn high_performance_low_collateral() {
808            let result = default_collaterals()
809                .recommend_collateral(12000.0, parse_ether("80").unwrap())
810                .unwrap();
811            assert_eq!(result, Some(parse_ether("100").unwrap()));
812        }
813
814        #[test]
815        fn high_performance_sufficient_collateral() {
816            let result = default_collaterals()
817                .recommend_collateral(12000.0, parse_ether("120").unwrap())
818                .unwrap();
819            assert_eq!(result, None);
820        }
821
822        #[test]
823        fn at_large_threshold() {
824            let result = default_collaterals()
825                .recommend_collateral(
826                    LARGE_REQUESTOR_LIST_THRESHOLD_KHZ,
827                    parse_ether("10").unwrap(),
828                )
829                .unwrap();
830            assert_eq!(result, Some(parse_ether("50").unwrap()));
831        }
832
833        #[test]
834        fn at_xl_threshold() {
835            let result = default_collaterals()
836                .recommend_collateral(XL_REQUESTOR_LIST_THRESHOLD_KHZ, parse_ether("80").unwrap())
837                .unwrap();
838            assert_eq!(result, Some(parse_ether("100").unwrap()));
839        }
840
841        #[test]
842        fn exact_threshold_low() {
843            let result = default_collaterals()
844                .recommend_collateral(2000.0, parse_ether("20").unwrap())
845                .unwrap();
846            assert_eq!(result, None);
847        }
848
849        #[test]
850        fn exact_threshold_medium() {
851            let result = default_collaterals()
852                .recommend_collateral(5000.0, parse_ether("50").unwrap())
853                .unwrap();
854            assert_eq!(result, None);
855        }
856
857        #[test]
858        fn exact_threshold_high() {
859            let result = default_collaterals()
860                .recommend_collateral(12000.0, parse_ether("100").unwrap())
861                .unwrap();
862            assert_eq!(result, None);
863        }
864    }
865
866    mod price_priority {
867        use super::*;
868
869        fn u(n: u64) -> U256 {
870            U256::from(n)
871        }
872
873        #[test]
874        fn min_price_params_takes_priority() {
875            assert_eq!(resolve_min_price(Some(u(1)), Some(u(2)), Some(10), Some(u(3))), u(1));
876        }
877
878        #[test]
879        fn min_price_config_over_market_and_default() {
880            assert_eq!(resolve_min_price(None, Some(u(5)), Some(10), Some(u(100))), u(50));
881        }
882
883        #[test]
884        fn min_price_market_over_default() {
885            assert_eq!(resolve_min_price(None, None, None, Some(u(42))), u(42));
886        }
887
888        #[test]
889        fn min_price_default_when_all_none() {
890            assert_eq!(
891                resolve_min_price(None, None, None, None),
892                DEFAULT_MIN_PRICE * U256::from(1)
893            );
894        }
895
896        #[test]
897        fn min_price_config_requires_cycle_count() {
898            assert_eq!(resolve_min_price(None, Some(u(5)), None, Some(u(10))), u(10));
899        }
900
901        #[test]
902        fn max_price_params_takes_priority() {
903            assert_eq!(resolve_max_price(Some(u(1)), Some(u(2)), Some(u(3)), Some(10)), u(1));
904        }
905
906        #[test]
907        fn max_price_config_over_market_and_default() {
908            assert_eq!(resolve_max_price(None, Some(u(50)), Some(u(100)), Some(10)), u(50));
909        }
910
911        #[test]
912        fn max_price_market_over_default() {
913            assert_eq!(resolve_max_price(None, None, Some(u(42)), Some(10)), u(42));
914        }
915
916        #[test]
917        fn max_price_default_when_all_none() {
918            assert_eq!(
919                resolve_max_price(None, None, None, Some(10)),
920                default_max_price_per_cycle() * u(10)
921            );
922        }
923    }
924}