Skip to main content

boundless_market/contracts/
mod.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
15#[cfg(not(target_os = "zkvm"))]
16use std::str::FromStr;
17use std::{borrow::Cow, ops::Not};
18
19#[cfg(not(target_os = "zkvm"))]
20use alloy::{
21    contract::Error as ContractErr,
22    primitives::{Signature, SignatureError},
23    signers::Signer,
24    sol_types::{Error as DecoderErr, SolInterface, SolStruct},
25    transports::TransportError,
26};
27use alloy_primitives::{
28    aliases::{U160, U32, U96},
29    Address, Bytes, FixedBytes, Keccak256, U256,
30};
31use alloy_sol_types::{eip712_domain, Eip712Domain, SolValue};
32use serde::{Deserialize, Serialize};
33#[cfg(not(target_os = "zkvm"))]
34use std::time::Duration;
35#[cfg(not(target_os = "zkvm"))]
36use thiserror::Error;
37#[cfg(not(target_os = "zkvm"))]
38use token::{
39    IHitPoints::{self, IHitPointsErrors},
40    IERC20::IERC20Errors,
41};
42use url::Url;
43
44use risc0_zkvm::{
45    sha::{Digest, Digestible},
46    MaybePruned, ReceiptClaim,
47};
48
49#[cfg(not(target_os = "zkvm"))]
50pub use risc0_ethereum_contracts::{encode_seal, selector::Selector, IRiscZeroSetVerifier};
51
52#[cfg(not(target_os = "zkvm"))]
53use crate::{input::GuestEnvBuilder, util::now_timestamp};
54
55#[cfg(not(target_os = "zkvm"))]
56const TXN_CONFIRM_TIMEOUT: Duration = Duration::from_secs(45);
57
58// boundless_market_generated.rs contains the Boundless contract types
59// with alloy derive statements added.
60// See the build.rs script in this crate for more details.
61include!(concat!(env!("OUT_DIR"), "/boundless_market_generated.rs"));
62pub use boundless_market_contract::{
63    AssessorCallback, AssessorCommitment, AssessorJournal, AssessorReceipt, Callback, Fulfillment,
64    FulfillmentContext, FulfillmentDataImageIdAndJournal, FulfillmentDataType, IBoundlessMarket,
65    Input as RequestInput, InputType as RequestInputType, LockRequest, Offer,
66    Predicate as RequestPredicate, PredicateType, ProofRequest, RequestLock, Requirements,
67    Selector as AssessorSelector,
68};
69
70#[allow(missing_docs)]
71#[cfg(not(target_os = "zkvm"))]
72pub mod token {
73    use alloy::{
74        primitives::{Signature, B256},
75        signers::Signer,
76        sol_types::SolStruct,
77    };
78    use anyhow::Result;
79    use serde::Serialize;
80
81    alloy::sol!(
82        #![sol(rpc, all_derives)]
83        "src/contracts/artifacts/IHitPoints.sol"
84    );
85
86    alloy::sol! {
87        #[derive(Debug, Serialize)]
88        struct Permit {
89            address owner;
90            address spender;
91            uint256 value;
92            uint256 nonce;
93            uint256 deadline;
94        }
95    }
96
97    alloy::sol! {
98        #[derive(Debug)]
99        #[sol(rpc)]
100        interface IERC20 {
101            error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed);
102            error ERC20InvalidSender(address sender);
103            error ERC20InvalidReceiver(address receiver);
104            error ERC20InsufficientAllowance(address spender, uint256 allowance, uint256 needed);
105            error ERC20InvalidApprover(address approver);
106            error ERC20InvalidSpender(address spender);
107            function approve(address spender, uint256 value) external returns (bool);
108            function balanceOf(address account) external view returns (uint256);
109            function symbol() external view returns (string memory);
110            function decimals() external view returns (uint8);
111        }
112    }
113
114    alloy::sol! {
115        #[sol(rpc)]
116        interface IERC20Permit {
117            function nonces(address owner) external view returns (uint256);
118            function DOMAIN_SEPARATOR() external view returns (bytes32);
119        }
120    }
121
122    impl Permit {
123        /// Signs the [Permit] with the given signer and EIP-712 domain separator.
124        ///
125        /// The content to be signed is the hash of the magic bytes 0x1901
126        /// concatenated with the domain separator and the `hashStruct` result:
127        /// `keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(permit))`
128        pub async fn sign(
129            &self,
130            signer: &impl Signer,
131            domain_separator: B256,
132        ) -> Result<Signature> {
133            let struct_hash = self.eip712_hash_struct();
134            let prefix: &[u8] = &[0x19, 0x01];
135            let signing_bytes = prefix
136                .iter()
137                .chain(domain_separator.as_slice())
138                .chain(struct_hash.as_slice())
139                .cloned()
140                .collect::<Vec<u8>>();
141            let signing_hash = alloy::primitives::keccak256(signing_bytes);
142
143            Ok(signer.sign_hash(&signing_hash).await?)
144        }
145    }
146}
147
148/// Status of a proof request
149#[derive(Default, Debug, PartialEq)]
150pub enum RequestStatus {
151    /// The request has expired.
152    Expired,
153    /// The request is locked in and waiting for fulfillment.
154    Locked,
155    /// The request has been fulfilled.
156    Fulfilled,
157    /// The request has an unknown status.
158    ///
159    /// This is used to represent the status of a request
160    /// with no evidence in the state. The request may be
161    /// open for bidding or it may not exist.
162    #[default]
163    Unknown,
164}
165
166#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
167/// EIP-712 domain separator without the salt field.
168pub struct EIP712DomainSaltless {
169    /// The name of the domain.
170    pub name: Cow<'static, str>,
171    /// The protocol version.
172    pub version: Cow<'static, str>,
173    /// The chain ID.
174    pub chain_id: u64,
175    /// The address of the verifying contract.
176    pub verifying_contract: Address,
177}
178
179impl EIP712DomainSaltless {
180    /// Returns the EIP-712 domain with the salt field set to zero.
181    pub fn alloy_struct(&self) -> Eip712Domain {
182        eip712_domain! {
183            name: self.name.clone(),
184            version: self.version.clone(),
185            chain_id: self.chain_id,
186            verifying_contract: self.verifying_contract,
187        }
188    }
189}
190
191/// Structured represent of a request ID.
192///
193/// This struct can be packed and unpacked from a U256 value.
194#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
195#[non_exhaustive]
196pub struct RequestId {
197    /// Address of the wallet or contract authorizing the request.
198    pub addr: Address,
199    /// Index of the request, assigned by the requester.
200    ///
201    /// Each index can correspond to a single fulfilled request. If multiple requests have the same
202    /// address and index, only one can ever be fulfilled. This is similar to how transaction
203    /// nonces work on Ethereum.
204    pub index: u32,
205    /// A flag set to true when the signature over the request is provided by a smart contract,
206    /// using ERC-1271. When set to false, the request is signed using ECDSA.
207    pub smart_contract_signed: bool,
208}
209
210impl RequestId {
211    /// Create a [RequestId] with the given [Address] and index. Sets flags to default values.
212    pub fn new(addr: Address, index: u32) -> Self {
213        Self { addr, index, smart_contract_signed: false }
214    }
215
216    /// Create a packed [RequestId] with the given [Address] and index. Sets flags to default values.
217    pub fn u256(addr: Address, index: u32) -> U256 {
218        Self::new(addr, index).into()
219    }
220
221    /// Set the smart contract signed flag to true. This indicates that the signature associated
222    /// with the request should be validated using ERC-1271's isValidSignature function.
223    pub fn set_smart_contract_signed_flag(self) -> Self {
224        Self { addr: self.addr, index: self.index, smart_contract_signed: true }
225    }
226
227    /// Unpack a [RequestId] from a [U256] ignoring bits that do not correspond to known fields.
228    ///
229    /// Note that this is a lossy conversion in that converting the resulting [RequestId] back into
230    /// a [U256] is not guaranteed to give the original value. If flags are added in future
231    /// versions of the Boundless Market, this function will ignore them.
232    pub fn from_lossy(value: U256) -> Self {
233        let mut addr_u256 = value >> U256::from(32);
234        addr_u256 &= (U256::from(1) << U256::from(160)) - U256::from(1); // mask out the flags
235        let addr = Address::from(addr_u256.to::<U160>());
236        Self {
237            addr,
238            index: (value & U32::MAX.to::<U256>()).to::<u32>(),
239            smart_contract_signed: (value & (U256::from(1) << 192)) != U256::ZERO,
240        }
241    }
242}
243
244impl TryFrom<U256> for RequestId {
245    type Error = RequestError;
246
247    fn try_from(value: U256) -> Result<Self, Self::Error> {
248        // Check if any bits above the smart contract signed flag are set.
249        // An error here could indicate that this logic has not been updated to support new flags
250        if value >> 193 != U256::ZERO {
251            return Err(RequestError::MalformedRequestId);
252        }
253        Ok(RequestId::from_lossy(value))
254    }
255}
256
257impl From<RequestId> for U256 {
258    fn from(value: RequestId) -> Self {
259        #[allow(clippy::unnecessary_fallible_conversions)] // U160::from does not compile
260        let addr = U160::try_from(value.addr).unwrap();
261        let smart_contract_signed_flag =
262            if value.smart_contract_signed { U256::from(1) } else { U256::ZERO };
263        (smart_contract_signed_flag << 192) | (U256::from(addr) << 32) | U256::from(value.index)
264    }
265}
266
267#[non_exhaustive]
268#[derive(thiserror::Error, Debug)]
269/// Errors that can occur when creating a proof request.
270pub enum RequestError {
271    /// The request ID is malformed.
272    #[error("malformed request ID")]
273    MalformedRequestId,
274
275    /// The client address is all zeroes.
276    #[error("request ID has client address of all zeroes")]
277    ClientAddrIsZero,
278
279    /// The signature is invalid.
280    #[cfg(not(target_os = "zkvm"))]
281    #[error("signature error: {0}")]
282    SignatureError(#[from] alloy::signers::Error),
283
284    /// Signature is non-canonical.
285    #[error("invalid signature: not normalized s-value")]
286    SignatureNonCanonicalError,
287
288    /// The image URL is empty.
289    #[error("image URL must not be empty")]
290    EmptyImageUrl,
291
292    /// The image URL is malformed.
293    #[error("malformed image URL: {0}")]
294    MalformedImageUrl(#[from] url::ParseError),
295
296    /// The image ID is zero.
297    #[error("image ID must not be ZERO")]
298    ImageIdIsZero,
299
300    /// The offer timeout is zero.
301    #[error("offer timeout must be greater than 0")]
302    OfferTimeoutIsZero,
303
304    /// The offer lock timeout is zero.
305    #[error("offer lock timeout must be greater than 0")]
306    OfferLockTimeoutIsZero,
307
308    /// The offer ramp up period is longer than the lock timeout
309    #[error("offer ramp up period must be less than or equal to the lock timeout")]
310    OfferRampUpGreaterThanLockTimeout,
311
312    /// The offer lock timeout is greater than the timeout
313    #[error("offer lock timeout must be less than or equal to the timeout")]
314    OfferLockTimeoutGreaterThanTimeout,
315
316    /// Difference between timeout and lockTimeout much be less than 2^24
317    ///
318    /// This is a requirement of the BoundlessMarket smart to optimize use of storage.
319    #[error("difference between timeout and lockTimeout much be less than 2^24")]
320    OfferTimeoutRangeTooLarge,
321
322    /// The offer max price is zero.
323    #[error("offer maxPrice must be greater than 0")]
324    OfferMaxPriceIsZero,
325
326    /// The offer max price is less than the min price.
327    #[error("offer maxPrice must be greater than or equal to minPrice")]
328    OfferMaxPriceIsLessThanMin,
329
330    /// The offer ramp-up start is zero.
331    #[error("offer rampUpStart must be greater than 0")]
332    OfferRampUpStartIsZero,
333
334    /// The requirements are missing from the request.
335    #[error("missing requirements")]
336    MissingRequirements,
337
338    /// The image URL is missing from the request.
339    #[error("missing image URL")]
340    MissingImageUrl,
341
342    /// The input is missing from the request.
343    #[error("missing input")]
344    MissingInput,
345
346    /// The offer is missing from the request.
347    #[error("missing offer")]
348    MissingOffer,
349
350    /// The request ID is missing from the request.
351    #[error("missing request ID")]
352    MissingRequestId,
353
354    /// Request digest mismatch.
355    #[error("request digest mismatch")]
356    DigestMismatch,
357
358    /// Predicate related errors
359    #[error("predicate data error: {0}")]
360    PredicateError(#[from] PredicateError),
361}
362
363#[cfg(not(target_os = "zkvm"))]
364impl From<SignatureError> for RequestError {
365    fn from(err: alloy::primitives::SignatureError) -> Self {
366        RequestError::SignatureError(err.into())
367    }
368}
369
370#[non_exhaustive]
371#[derive(thiserror::Error, Debug)]
372/// Errors that can occur when handling fulfillment data.
373pub enum FulfillmentDataError {
374    /// The fulfillment data is malformed.
375    #[error("malformed fulfillment data")]
376    Malformed,
377    /// Invalid fulfillment data type.
378    #[error("invalid fulfillment data type")]
379    InvalidType,
380}
381
382impl ProofRequest {
383    /// Creates a new proof request with the given parameters.
384    ///
385    /// The request ID is generated by combining the address and given idx.
386    pub fn new(
387        request_id: RequestId,
388        requirements: impl Into<Requirements>,
389        image_url: impl Into<String>,
390        input: impl Into<RequestInput>,
391        offer: impl Into<Offer>,
392    ) -> Self {
393        Self {
394            id: request_id.into(),
395            requirements: requirements.into(),
396            imageUrl: image_url.into(),
397            input: input.into(),
398            offer: offer.into(),
399        }
400    }
401
402    /// Returns the client address from the request ID.
403    pub fn client_address(&self) -> Address {
404        RequestId::from_lossy(self.id).addr
405    }
406
407    /// Returns the time, in seconds since the UNIX epoch, at which the request expires.
408    pub fn expires_at(&self) -> u64 {
409        self.offer.rampUpStart + self.offer.timeout as u64
410    }
411
412    /// Returns true if the expiration time has passed, according to the system clock.
413    ///
414    /// NOTE: If the system clock has significant has drifted relative to the chain's clock, this
415    /// may not give the correct result.
416    #[cfg(not(target_os = "zkvm"))]
417    pub fn is_expired(&self) -> bool {
418        self.expires_at() < now_timestamp()
419    }
420
421    /// Returns the time, in seconds since the UNIX epoch, at which the request lock expires.
422    pub fn lock_expires_at(&self) -> u64 {
423        self.offer.rampUpStart + self.offer.lockTimeout as u64
424    }
425
426    /// Returns true if the lock expiration time has passed, according to the system clock.
427    ///
428    /// NOTE: If the system clock has significant has drifted relative to the chain's clock, this
429    /// may not give the correct result.
430    #[cfg(not(target_os = "zkvm"))]
431    pub fn is_lock_expired(&self) -> bool {
432        self.lock_expires_at() < now_timestamp()
433    }
434
435    /// Return true if the request ID indicates that it is authorized by a smart contract, rather
436    /// than an EOA (i.e. an ECDSA key).
437    pub fn is_smart_contract_signed(&self) -> bool {
438        RequestId::from_lossy(self.id).smart_contract_signed
439    }
440
441    /// Check that the request is valid and internally consistent.
442    ///
443    /// If any field are empty, or if two fields conflict (e.g. the max price is less than the min
444    /// price) this function will return an error.
445    ///
446    /// NOTE: This does not check whether the request has expired. You can use
447    /// [ProofRequest::is_lock_expired] to do so.
448    pub fn validate(&self) -> Result<(), RequestError> {
449        if RequestId::from_lossy(self.id).addr == Address::ZERO {
450            return Err(RequestError::ClientAddrIsZero);
451        }
452        if self.imageUrl.is_empty() {
453            return Err(RequestError::EmptyImageUrl);
454        }
455        Url::parse(&self.imageUrl).map(|_| ())?;
456
457        // The conversion from RequestPredicate to Predicate will validate
458        Predicate::try_from(self.requirements.predicate.clone())?;
459
460        if self.offer.timeout == 0 {
461            return Err(RequestError::OfferTimeoutIsZero);
462        }
463        if self.offer.lockTimeout == 0 {
464            return Err(RequestError::OfferLockTimeoutIsZero);
465        }
466        if self.offer.rampUpPeriod > self.offer.lockTimeout {
467            return Err(RequestError::OfferRampUpGreaterThanLockTimeout);
468        }
469        if self.offer.lockTimeout > self.offer.timeout {
470            return Err(RequestError::OfferLockTimeoutGreaterThanTimeout);
471        }
472        if self.offer.timeout - self.offer.lockTimeout >= 1 << 24 {
473            return Err(RequestError::OfferTimeoutRangeTooLarge);
474        }
475        if self.offer.maxPrice == U256::ZERO {
476            return Err(RequestError::OfferMaxPriceIsZero);
477        }
478        if self.offer.maxPrice < self.offer.minPrice {
479            return Err(RequestError::OfferMaxPriceIsLessThanMin);
480        }
481        if self.offer.rampUpStart == 0 {
482            return Err(RequestError::OfferRampUpStartIsZero);
483        }
484
485        Ok(())
486    }
487}
488
489#[cfg(not(target_os = "zkvm"))]
490impl ProofRequest {
491    /// Signs the request with the given signer and EIP-712 domain derived from the given
492    /// contract address and chain ID.
493    pub async fn sign_request(
494        &self,
495        signer: &impl Signer,
496        contract_addr: Address,
497        chain_id: u64,
498    ) -> Result<Signature, RequestError> {
499        let hash = self.signing_hash(contract_addr, chain_id)?;
500        Ok(signer.sign_hash(&hash).await?)
501    }
502
503    /// Returns the EIP-712 signing hash for the request.
504    pub fn signing_hash(
505        &self,
506        contract_addr: Address,
507        chain_id: u64,
508    ) -> Result<FixedBytes<32>, RequestError> {
509        let domain = eip712_domain(contract_addr, chain_id);
510        let hash = self.eip712_signing_hash(&domain.alloy_struct());
511        Ok(hash)
512    }
513
514    /// Verifies the request signature with the given signer and EIP-712 domain derived from
515    /// the given contract address and chain ID.
516    pub fn verify_signature(
517        &self,
518        signature: &Bytes,
519        contract_addr: Address,
520        chain_id: u64,
521    ) -> Result<(), RequestError> {
522        let sig = Signature::try_from(signature.as_ref())?;
523        // Check if the signature is non-canonical.
524        if sig.normalize_s().is_some() {
525            return Err(RequestError::SignatureNonCanonicalError);
526        }
527        let hash = self.signing_hash(contract_addr, chain_id)?;
528        let addr = sig.recover_address_from_prehash(&hash)?;
529        if addr == self.client_address() {
530            Ok(())
531        } else {
532            Err(SignatureError::FromBytes("Address mismatch").into())
533        }
534    }
535}
536
537impl Requirements {
538    /// Creates a new requirements with the given image ID and predicate.
539    pub fn new(predicate: impl Into<RequestPredicate>) -> Self {
540        Self {
541            predicate: predicate.into(),
542            callback: Callback::default(),
543            selector: UNSPECIFIED_SELECTOR,
544        }
545    }
546
547    /// Sets the predicate.
548    pub fn with_predicate(self, predicate: impl Into<RequestPredicate>) -> Self {
549        Self { predicate: predicate.into(), ..self }
550    }
551
552    /// Sets the callback.
553    pub fn with_callback(self, callback: Callback) -> Self {
554        Self { callback, ..self }
555    }
556
557    /// Sets the selector.
558    pub fn with_selector(self, selector: FixedBytes<4>) -> Self {
559        Self { selector, ..self }
560    }
561
562    /// Set the selector for a groth16 proof.
563    ///
564    /// This will set the selector to the appropriate value based on the current environment.
565    /// In dev mode, the selector will be set to `FakeReceipt`, otherwise it will be set
566    /// to the latest Groth16 selector.
567    #[cfg(not(target_os = "zkvm"))]
568    pub fn with_groth16_proof(self) -> Self {
569        use crate::selector::SelectorExt;
570
571        match crate::util::is_dev_mode() {
572            true => Self { selector: FixedBytes::from(SelectorExt::FakeReceipt as u32), ..self },
573            false => {
574                Self { selector: FixedBytes::from(SelectorExt::groth16_latest() as u32), ..self }
575            }
576        }
577    }
578
579    /// Set the selector for a blake3 groth16 proof.
580    ///
581    /// This will set the selector to the appropriate value based on the current environment.
582    /// In dev mode, the selector will be set to `FakeBlake3Groth16`, otherwise it will be set
583    /// to the latest Blake3Groth16 selector.
584    #[cfg(not(target_os = "zkvm"))]
585    pub fn with_blake3_groth16_proof(self) -> Self {
586        use crate::selector::SelectorExt;
587
588        match crate::util::is_dev_mode() {
589            true => {
590                Self { selector: FixedBytes::from(SelectorExt::FakeBlake3Groth16 as u32), ..self }
591            }
592            false => Self {
593                selector: FixedBytes::from(SelectorExt::blake3_groth16_latest() as u32),
594                ..self
595            },
596        }
597    }
598}
599
600/// The data that is used to construct the claim for a fulfillment.
601#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
602#[non_exhaustive]
603pub enum FulfillmentData {
604    /// No data is provided in the fulfillment.
605    None,
606    /// Proofs fulfilled with both image id and journal and the claim is calculated from them.
607    ImageIdAndJournal(Digest, Bytes),
608}
609
610impl FulfillmentData {
611    /// Create the claim data from image_id and journal.
612    pub fn from_image_id_and_journal(
613        image_id: impl Into<Digest>,
614        journal: impl Into<Bytes>,
615    ) -> Self {
616        Self::ImageIdAndJournal(image_id.into(), journal.into())
617    }
618
619    /// Encodes to FulfillmentType and data
620    pub fn fulfillment_type_and_data(&self) -> (FulfillmentDataType, Vec<u8>) {
621        match self {
622            Self::None => (FulfillmentDataType::None, Vec::new()),
623            Self::ImageIdAndJournal(image_id, journal) => (
624                FulfillmentDataType::ImageIdAndJournal,
625                FulfillmentDataImageIdAndJournal {
626                    imageId: <[u8; 32]>::from(*image_id).into(),
627                    journal: journal.clone(),
628                }
629                .abi_encode(),
630            ),
631        }
632    }
633
634    /// Decodes the fulfillment data from the given type and data.
635    pub fn decode_with_type(
636        fill_type: FulfillmentDataType,
637        data: impl AsRef<[u8]>,
638    ) -> Result<Self, FulfillmentDataError> {
639        match fill_type {
640            FulfillmentDataType::None => {
641                if !data.as_ref().is_empty() {
642                    return Err(FulfillmentDataError::Malformed);
643                }
644                Ok(Self::None)
645            }
646            FulfillmentDataType::ImageIdAndJournal => {
647                let FulfillmentDataImageIdAndJournal { imageId, journal } =
648                    FulfillmentDataImageIdAndJournal::abi_decode(data.as_ref())
649                        .map_err(|_| FulfillmentDataError::Malformed)?;
650                Ok(Self::ImageIdAndJournal(imageId.0.into(), journal))
651            }
652            FulfillmentDataType::__Invalid => Err(FulfillmentDataError::InvalidType),
653        }
654    }
655
656    /// Returns the fulfillment data digest committed to by the assessor.
657    pub fn fulfillment_data_digest(&self) -> Result<Digest, FulfillmentDataError> {
658        let mut hasher = Keccak256::new();
659        match self {
660            FulfillmentData::None => hasher.update([FulfillmentDataType::None as u8]),
661            FulfillmentData::ImageIdAndJournal(image_id, journal) => {
662                hasher.update([FulfillmentDataType::ImageIdAndJournal as u8]);
663                hasher.update(
664                    FulfillmentDataImageIdAndJournal {
665                        imageId: <[u8; 32]>::from(*image_id).into(),
666                        journal: journal.clone(),
667                    }
668                    .abi_encode(),
669                );
670            }
671        }
672        Ok(hasher.finalize().0.into())
673    }
674
675    /// Returns the journal if available
676    pub fn journal(&self) -> Option<&Bytes> {
677        match self {
678            FulfillmentData::None => None,
679            FulfillmentData::ImageIdAndJournal(_, journal) => Some(journal),
680        }
681    }
682
683    /// Returns the image id if available
684    pub fn image_id(&self) -> Option<Digest> {
685        match self {
686            FulfillmentData::None => None,
687            FulfillmentData::ImageIdAndJournal(image_id, _) => Some(*image_id),
688        }
689    }
690}
691
692impl From<FulfillmentDataImageIdAndJournal> for FulfillmentData {
693    fn from(value: FulfillmentDataImageIdAndJournal) -> Self {
694        Self::ImageIdAndJournal(Digest::from_bytes(*value.imageId), value.journal)
695    }
696}
697
698impl Fulfillment {
699    /// Decode and return the [FulfillmentData] for the this fulfillment.
700    pub fn data(&self) -> Result<FulfillmentData, FulfillmentDataError> {
701        FulfillmentData::decode_with_type(self.fulfillmentDataType, &self.fulfillmentData)
702    }
703}
704
705#[derive(thiserror::Error, Debug)]
706/// Errors related to predicate encoding/decoding and evaluation
707pub enum PredicateError {
708    /// Data was not correctly constructed
709    #[error("malformed predicate data")]
710    DataMalformed,
711    /// Invalid predicate type
712    #[error("invalid predicate type")]
713    InvalidType,
714}
715
716#[derive(Clone, Debug)]
717#[non_exhaustive]
718/// A Predicate is a function over the claim that determines whether it meets the clients requirements.
719pub enum Predicate {
720    /// The image id and digest must match the provided image id and journal digest.
721    DigestMatch(Digest, Digest),
722    /// The image id and journal prefix must match the provided image id and journal.
723    PrefixMatch(Digest, Bytes),
724    /// The claim digest must match the provided claim digest.
725    ClaimDigestMatch(Digest),
726}
727
728impl From<Predicate> for RequestPredicate {
729    fn from(value: Predicate) -> Self {
730        match value {
731            Predicate::DigestMatch(image_id, journal_digest) => Self {
732                predicateType: PredicateType::DigestMatch,
733                data: [image_id.as_bytes(), journal_digest.as_bytes()].concat().into(),
734            },
735            Predicate::PrefixMatch(image_id, journal_prefix) => Self {
736                predicateType: PredicateType::PrefixMatch,
737                data: [image_id.as_bytes(), journal_prefix.as_ref()].concat().into(),
738            },
739            Predicate::ClaimDigestMatch(claim_digest) => Self {
740                predicateType: PredicateType::ClaimDigestMatch,
741                data: claim_digest.as_bytes().to_vec().into(),
742            },
743        }
744    }
745}
746
747impl TryFrom<RequestPredicate> for Predicate {
748    type Error = PredicateError;
749
750    fn try_from(value: RequestPredicate) -> Result<Self, Self::Error> {
751        match value.predicateType {
752            PredicateType::DigestMatch => {
753                let (image_id, journal_digest) = value
754                    .data
755                    .as_ref()
756                    .split_at_checked(32)
757                    .ok_or(PredicateError::DataMalformed)?;
758                Ok(Predicate::DigestMatch(
759                    Digest::try_from(image_id).map_err(|_| PredicateError::DataMalformed)?,
760                    Digest::try_from(journal_digest).map_err(|_| PredicateError::DataMalformed)?,
761                ))
762            }
763            PredicateType::PrefixMatch => {
764                let (image_id, journal_prefix) = value
765                    .data
766                    .as_ref()
767                    .split_at_checked(32)
768                    .ok_or(PredicateError::DataMalformed)?;
769                Ok(Predicate::PrefixMatch(
770                    Digest::try_from(image_id).map_err(|_| PredicateError::DataMalformed)?,
771                    journal_prefix.to_vec().into(),
772                ))
773            }
774            PredicateType::ClaimDigestMatch => {
775                // Don't need to check the length because that is checked when converting to Digest
776                Ok(Predicate::ClaimDigestMatch(
777                    Digest::try_from(value.data.as_ref())
778                        .map_err(|_| PredicateError::DataMalformed)?,
779                ))
780            }
781            PredicateType::__Invalid => Err(PredicateError::InvalidType),
782        }
783    }
784}
785
786impl Predicate {
787    /// Returns a predicate to match the journal digest. This ensures that the request's
788    /// fulfillment will contain a journal with the same digest.
789    pub fn digest_match(image_id: impl Into<Digest>, journal_digest: impl Into<Digest>) -> Self {
790        Self::DigestMatch(image_id.into(), journal_digest.into())
791    }
792    /// Returns a predicate to match the journal prefix. This ensures that the request's
793    /// fulfillment will contain a journal with the same prefix.
794    pub fn prefix_match(image_id: impl Into<Digest>, journal_prefix: impl Into<Bytes>) -> Self {
795        Self::PrefixMatch(image_id.into(), journal_prefix.into())
796    }
797    /// Returns a predicate to match the claim digest. This ensures that the request's
798    /// fulfillment will contain a claim with the same digest.
799    pub fn claim_digest_match(claim_digest: impl Into<Digest>) -> Self {
800        Self::ClaimDigestMatch(claim_digest.into())
801    }
802
803    /// Returns the image_id if it exists.
804    pub fn image_id(&self) -> Option<Digest> {
805        match self {
806            Predicate::DigestMatch(image_id, ..) => Some(*image_id),
807            Predicate::PrefixMatch(image_id, ..) => Some(*image_id),
808            Predicate::ClaimDigestMatch(..) => None,
809        }
810    }
811
812    /// Evaluates the predicate against the fulfillment data, returning the claim digest if the
813    /// evaluation succeeds or `None` if it fails.
814    pub fn eval(&self, fulfillment_data: &FulfillmentData) -> Option<Digest> {
815        let claim_digest_data = match fulfillment_data {
816            FulfillmentData::None => None,
817            FulfillmentData::ImageIdAndJournal(image_id, journal) => {
818                Some(ReceiptClaim::ok(*image_id, journal.to_vec()).digest())
819            }
820        };
821        let claim_digest_predicate = match self {
822            Predicate::DigestMatch(image_id, journal) => {
823                // With the DigestMatch predicate, we require the delivery of the journal.
824                // If is checked to match the predicate by the claim digest comparison below.
825                let FulfillmentData::ImageIdAndJournal(_, _) = &fulfillment_data else {
826                    return None;
827                };
828                Some(ReceiptClaim::ok(*image_id, MaybePruned::Pruned(*journal)).digest())
829            }
830            Predicate::PrefixMatch(image_id, prefix) => {
831                // With the PrefixMatch predicate, we need to check the condition on the journal.
832                let FulfillmentData::ImageIdAndJournal(fill_image_id, fill_journal) =
833                    &fulfillment_data
834                else {
835                    return None;
836                };
837                let matches =
838                    fill_image_id == image_id && fill_journal.starts_with(prefix.as_ref());
839                if !matches {
840                    return None;
841                }
842                None
843            }
844            Predicate::ClaimDigestMatch(claim_digest) => Some(*claim_digest),
845        };
846        if let (Some(claim_digest_predicate), Some(claim_digest_data)) =
847            (claim_digest_predicate, claim_digest_data)
848        {
849            if claim_digest_predicate != claim_digest_data {
850                return None;
851            }
852        }
853        claim_digest_data.or(claim_digest_predicate)
854    }
855}
856
857impl Callback {
858    /// Constant representing a none callback (i.e. no call will be made).
859    pub const NONE: Self = Self { addr: Address::ZERO, gasLimit: U96::ZERO };
860
861    /// Sets the address of the callback.
862    pub fn with_addr(self, addr: impl Into<Address>) -> Self {
863        Self { addr: addr.into(), ..self }
864    }
865
866    /// Sets the gas limit of the callback.
867    pub fn with_gas_limit(self, gas_limit: u64) -> Self {
868        Self { gasLimit: U96::from(gas_limit), ..self }
869    }
870
871    /// Returns true if this is a none callback (i.e. no call will be made).
872    ///
873    /// NOTE: A callback is considered none if the address is zero, regardless of the gas limit.
874    pub fn is_none(&self) -> bool {
875        self.addr == Address::ZERO
876    }
877
878    /// Convert to an option representation, mapping a none callback to `None`.
879    pub fn into_option(self) -> Option<Self> {
880        self.is_none().not().then_some(self)
881    }
882
883    /// Convert to an option representation, mapping a none callback to `None`.
884    pub fn as_option(&self) -> Option<&Self> {
885        self.is_none().not().then_some(self)
886    }
887
888    /// Convert from an option representation, mapping `None` to [Self::NONE].
889    pub fn from_option(opt: Option<Self>) -> Self {
890        opt.unwrap_or(Self::NONE)
891    }
892}
893
894impl RequestInput {
895    /// Create a new [GuestEnvBuilder] for use in constructing and encoding the guest zkVM environment.
896    #[cfg(not(target_os = "zkvm"))]
897    pub fn builder() -> GuestEnvBuilder {
898        GuestEnvBuilder::new()
899    }
900
901    /// Sets the input type to inline and the data to the given bytes.
902    ///
903    /// See [GuestEnvBuilder] for more details on how to write input data.
904    ///
905    /// # Example
906    ///
907    /// ```
908    /// use boundless_market::contracts::RequestInput;
909    ///
910    /// let input_vec = RequestInput::builder().write(&[0x41, 0x41, 0x41, 0x41])?.build_vec()?;
911    /// let input = RequestInput::inline(input_vec);
912    /// # anyhow::Ok(())
913    /// ```
914    pub fn inline(data: impl Into<Bytes>) -> Self {
915        Self { inputType: RequestInputType::Inline, data: data.into() }
916    }
917
918    /// Sets the input type to URL and the data to the given URL.
919    pub fn url(url: impl Into<String>) -> Self {
920        Self { inputType: RequestInputType::Url, data: url.into().as_bytes().to_vec().into() }
921    }
922}
923
924impl From<Url> for RequestInput {
925    /// Create a URL input from the given URL.
926    fn from(value: Url) -> Self {
927        Self::url(value)
928    }
929}
930
931impl Offer {
932    /// Sets the offer minimum price.
933    pub fn with_min_price(self, min_price: U256) -> Self {
934        Self { minPrice: min_price, ..self }
935    }
936
937    /// Sets the offer maximum price.
938    pub fn with_max_price(self, max_price: U256) -> Self {
939        Self { maxPrice: max_price, ..self }
940    }
941
942    /// Sets the offer lock-in collateral.
943    pub fn with_lock_collateral(self, lock_collateral: U256) -> Self {
944        Self { lockCollateral: lock_collateral, ..self }
945    }
946
947    /// Sets the offer ramp-up start time, in seconds since the UNIX epoch.
948    pub fn with_ramp_up_start(self, ramp_up_start: u64) -> Self {
949        Self { rampUpStart: ramp_up_start, ..self }
950    }
951
952    /// Sets the offer timeout as seconds from the bidding start before expiring.
953    pub fn with_timeout(self, timeout: u32) -> Self {
954        Self { timeout, ..self }
955    }
956
957    /// Sets the offer lock-in timeout as seconds from the bidding start before expiring.
958    pub fn with_lock_timeout(self, lock_timeout: u32) -> Self {
959        Self { lockTimeout: lock_timeout, ..self }
960    }
961
962    /// Sets the duration (in seconds) during which the auction price increases linearly
963    /// from the minimum to the maximum price. After this period, the price remains at maximum.
964    pub fn with_ramp_up_period(self, ramp_up_period: u32) -> Self {
965        Self { rampUpPeriod: ramp_up_period, ..self }
966    }
967
968    /// Sets the offer minimum price based on the desired price per million cycles.
969    pub fn with_min_price_per_mcycle(self, mcycle_price: U256, mcycle: u64) -> Self {
970        let min_price = mcycle_price * U256::from(mcycle);
971        Self { minPrice: min_price, ..self }
972    }
973
974    /// Sets the offer maximum price based on the desired price per million cycles.
975    pub fn with_max_price_per_mcycle(self, mcycle_price: U256, mcycle: u64) -> Self {
976        let max_price = mcycle_price * U256::from(mcycle);
977        Self { maxPrice: max_price, ..self }
978    }
979
980    /// Sets the offer lock-in collateral based on the desired price per million cycles.
981    pub fn with_lock_collateral_per_mcycle(self, mcycle_price: U256, mcycle: u64) -> Self {
982        let lock_collateral = mcycle_price * U256::from(mcycle);
983        Self { lockCollateral: lock_collateral, ..self }
984    }
985
986    /// Calculates the required performance (kHz) for the offer.
987    ///
988    /// The result is computed over the time period defined by the lock timeout.
989    /// Returns `f64::INFINITY` if `lockTimeout` is zero (invalid offer).
990    #[cfg(not(target_os = "zkvm"))]
991    pub fn required_khz_performance(&self, cycle_count: u64) -> f64 {
992        if self.lockTimeout == 0 {
993            tracing::warn!(
994                "lockTimeout is zero, cannot calculate required performance. Returning infinity."
995            );
996            return f64::INFINITY;
997        }
998        let frequency = cycle_count as f64 / (self.lockTimeout as f64);
999        // Convert to kHz
1000        frequency / 1_000.0
1001    }
1002
1003    /// Calculates the required performance (kHz) for the secondary prover.
1004    ///
1005    /// The result is computed over the time period defined by the timeout minus the lock timeout.
1006    /// Returns `f64::INFINITY` if there is no secondary window (i.e., `timeout <= lockTimeout`),
1007    /// indicating that a secondary prover cannot fulfill the request.
1008    #[cfg(not(target_os = "zkvm"))]
1009    pub fn required_khz_performance_secondary_prover(&self, cycle_count: u64) -> f64 {
1010        let secondary_window = self.timeout.saturating_sub(self.lockTimeout);
1011        if secondary_window == 0 {
1012            tracing::warn!(
1013                "No secondary window available (timeout <= lockTimeout). Secondary prover cannot fulfill request. Returning infinity."
1014            );
1015            return f64::INFINITY;
1016        }
1017        let frequency = cycle_count as f64 / (secondary_window as f64);
1018        // Convert to kHz
1019        frequency / 1_000.0
1020    }
1021}
1022
1023#[cfg(not(target_os = "zkvm"))]
1024use IBoundlessMarket::IBoundlessMarketErrors;
1025#[cfg(not(target_os = "zkvm"))]
1026use IRiscZeroSetVerifier::IRiscZeroSetVerifierErrors;
1027
1028#[cfg(not(target_os = "zkvm"))]
1029/// The Boundless market module.
1030pub mod boundless_market;
1031#[cfg(not(target_os = "zkvm"))]
1032/// The Hit Points module.
1033pub mod hit_points;
1034
1035/// Pricing utilities for calculating offer prices at specific timestamps.
1036/// This module is separated from the Offer struct to allow reuse in contexts where
1037/// individual pricing parameters are available (e.g., database queries in the indexer).
1038pub mod pricing;
1039
1040#[cfg(not(target_os = "zkvm"))]
1041#[derive(Error, Debug)]
1042/// Errors that can occur when interacting with the contracts.
1043pub enum TxnErr {
1044    /// Error from the SetVerifier contract.
1045    #[error("SetVerifier error: {0:?}")]
1046    SetVerifierErr(IRiscZeroSetVerifierErrors),
1047
1048    /// Error from the BoundlessMarket contract.
1049    #[error("BoundlessMarket Err: {0:?}")]
1050    BoundlessMarketErr(IBoundlessMarket::IBoundlessMarketErrors),
1051
1052    /// Error from the HitPoints contract.
1053    #[error("HitPoints Err: {0:?}")]
1054    HitPointsErr(IHitPoints::IHitPointsErrors),
1055
1056    /// Error from the ERC20 contract.
1057    #[error("IERC20 Err: {0:?}")]
1058    ERC20Err(token::IERC20::IERC20Errors),
1059
1060    /// Missing data while decoding the error response from the contract.
1061    #[error("decoding err, missing data, code: {0} msg: {1}")]
1062    MissingData(i64, String),
1063
1064    /// Error decoding the error response from the contract.
1065    #[error("decoding err: bytes decoding")]
1066    BytesDecode,
1067
1068    /// Error from the contract.
1069    #[error("contract error: {0}")]
1070    ContractErr(ContractErr),
1071
1072    /// ABI decoder error.
1073    #[error("abi decoder error: {0} - {1}")]
1074    DecodeErr(DecoderErr, Bytes),
1075}
1076
1077// TODO: Deduplicate the code from the following two conversion methods.
1078#[cfg(not(target_os = "zkvm"))]
1079impl From<ContractErr> for TxnErr {
1080    fn from(err: ContractErr) -> Self {
1081        match &err {
1082            ContractErr::TransportError(TransportError::ErrorResp(ts_err)) => {
1083                let Some(data) = &ts_err.data else {
1084                    return TxnErr::MissingData(ts_err.code, ts_err.message.to_string());
1085                };
1086
1087                let data = data.get().trim_matches('"');
1088
1089                let Ok(data) = Bytes::from_str(data) else {
1090                    return Self::BytesDecode;
1091                };
1092
1093                // Trial deocde the error with each possible contract ABI.
1094                if let Ok(decoded_error) = IBoundlessMarketErrors::abi_decode(&data) {
1095                    Self::BoundlessMarketErr(decoded_error)
1096                } else if let Ok(decoded_error) = IHitPointsErrors::abi_decode(&data) {
1097                    Self::HitPointsErr(decoded_error)
1098                } else if let Ok(decoded_error) = IRiscZeroSetVerifierErrors::abi_decode(&data) {
1099                    Self::SetVerifierErr(decoded_error)
1100                } else if let Ok(decoded_error) = IERC20Errors::abi_decode(&data) {
1101                    Self::ERC20Err(decoded_error)
1102                } else {
1103                    Self::ContractErr(err)
1104                }
1105            }
1106            _ => Self::ContractErr(err),
1107        }
1108    }
1109}
1110
1111#[cfg(not(target_os = "zkvm"))]
1112fn decode_contract_err<T: SolInterface>(err: ContractErr) -> Result<T, TxnErr> {
1113    match err {
1114        ContractErr::TransportError(TransportError::ErrorResp(ts_err)) => {
1115            let Some(data) = ts_err.data else {
1116                return Err(TxnErr::MissingData(ts_err.code, ts_err.message.to_string()));
1117            };
1118
1119            let data = data.get().trim_matches('"');
1120
1121            let Ok(data) = Bytes::from_str(data) else {
1122                return Err(TxnErr::BytesDecode);
1123            };
1124
1125            let decoded_error = match T::abi_decode(&data) {
1126                Ok(res) => res,
1127                Err(err) => {
1128                    return Err(TxnErr::DecodeErr(err, data));
1129                }
1130            };
1131
1132            Ok(decoded_error)
1133        }
1134        _ => Err(TxnErr::ContractErr(err)),
1135    }
1136}
1137
1138#[cfg(not(target_os = "zkvm"))]
1139impl IHitPointsErrors {
1140    pub(crate) fn decode_error(err: ContractErr) -> TxnErr {
1141        match decode_contract_err(err) {
1142            Ok(res) => TxnErr::HitPointsErr(res),
1143            Err(decode_err) => decode_err,
1144        }
1145    }
1146}
1147
1148#[cfg(not(target_os = "zkvm"))]
1149/// The EIP-712 domain separator for the Boundless Market contract.
1150pub fn eip712_domain(addr: Address, chain_id: u64) -> EIP712DomainSaltless {
1151    EIP712DomainSaltless {
1152        name: "IBoundlessMarket".into(),
1153        version: "1".into(),
1154        chain_id,
1155        verifying_contract: addr,
1156    }
1157}
1158
1159/// Constant to specify when no selector is specified.
1160pub const UNSPECIFIED_SELECTOR: FixedBytes<4> = FixedBytes::<4>([0; 4]);
1161
1162#[cfg(feature = "test-utils")]
1163#[allow(missing_docs)]
1164pub mod bytecode;
1165
1166#[cfg(test)]
1167mod tests {
1168    use super::*;
1169    use alloy::signers::local::PrivateKeySigner;
1170
1171    async fn create_order(
1172        signer: &impl Signer,
1173        signer_addr: Address,
1174        order_id: u32,
1175        contract_addr: Address,
1176        chain_id: u64,
1177    ) -> (ProofRequest, [u8; 65]) {
1178        let request_id = RequestId::u256(signer_addr, order_id);
1179
1180        let req = ProofRequest {
1181            id: request_id,
1182            requirements: Requirements::new(Predicate::prefix_match(
1183                Digest::ZERO,
1184                Bytes::default(),
1185            )),
1186            imageUrl: "https://dev.null".to_string(),
1187            input: RequestInput::builder().build_inline().unwrap(),
1188            offer: Offer {
1189                minPrice: U256::from(0),
1190                maxPrice: U256::from(1),
1191                rampUpStart: 0,
1192                timeout: 1000,
1193                rampUpPeriod: 1,
1194                lockTimeout: 1000,
1195                lockCollateral: U256::from(0),
1196            },
1197        };
1198
1199        let client_sig = req.sign_request(signer, contract_addr, chain_id).await.unwrap();
1200
1201        (req, client_sig.as_bytes())
1202    }
1203
1204    #[tokio::test]
1205    async fn validate_sig() {
1206        let signer: PrivateKeySigner =
1207            "6f142508b4eea641e33cb2a0161221105086a84584c74245ca463a49effea30b".parse().unwrap();
1208        let order_id: u32 = 1;
1209        let contract_addr = Address::ZERO;
1210        let chain_id = 1;
1211        let signer_addr = signer.address();
1212
1213        let (req, client_sig) =
1214            create_order(&signer, signer_addr, order_id, contract_addr, chain_id).await;
1215
1216        req.verify_signature(&Bytes::from(client_sig), contract_addr, chain_id).unwrap();
1217    }
1218
1219    #[tokio::test]
1220    #[should_panic(expected = "SignatureError")]
1221    async fn invalid_sig() {
1222        let signer: PrivateKeySigner =
1223            "6f142508b4eea641e33cb2a0161221105086a84584c74245ca463a49effea30b".parse().unwrap();
1224        let order_id: u32 = 1;
1225        let contract_addr = Address::ZERO;
1226        let chain_id = 1;
1227        let signer_addr = signer.address();
1228
1229        let (req, mut client_sig) =
1230            create_order(&signer, signer_addr, order_id, contract_addr, chain_id).await;
1231
1232        client_sig[0] = 1;
1233        req.verify_signature(&Bytes::from(client_sig), contract_addr, chain_id).unwrap();
1234    }
1235
1236    #[tokio::test]
1237    async fn test_request_id() {
1238        // Test case 1: Regular signature
1239        let raw_id1 =
1240            U256::from_str("3130239009558586413752262552917257075388277690201777635428").unwrap();
1241        let request_id1 = RequestId::from_lossy(raw_id1);
1242
1243        let client1 = request_id1.addr;
1244        let idx1 = request_id1.index;
1245        let is_smart_contract1 = request_id1.smart_contract_signed;
1246
1247        assert_eq!(
1248            client1,
1249            Address::from_str("0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496").unwrap()
1250        );
1251        assert_eq!(idx1, 100);
1252        assert!(!is_smart_contract1);
1253
1254        // Test case 2: Smart contract signature
1255        let raw_id2 =
1256            U256::from_str("9407340744945267177588051976124923491490633134665812148266").unwrap();
1257        let request_id2 = RequestId::from_lossy(raw_id2);
1258
1259        let client2 = request_id2.addr;
1260        let idx2 = request_id2.index;
1261        let is_smart_contract2 = request_id2.smart_contract_signed;
1262
1263        assert_eq!(
1264            client2,
1265            Address::from_str("0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496").unwrap()
1266        );
1267        assert_eq!(idx2, 42);
1268        assert!(is_smart_contract2);
1269
1270        // Test conversion back to U256
1271        let request_id1_u256: U256 = request_id1.into();
1272        let request_id2_u256: U256 = request_id2.into();
1273        assert_eq!(request_id1_u256, raw_id1);
1274        assert_eq!(request_id2_u256, raw_id2);
1275    }
1276}