Skip to main content
The Boundless Market SDK allows developers to build and submit requests to the Boundless protocol; the SDK has sensible defaults, designed to make sending ~95% of requests straightforward. Therefore, this page is split into two sections:
  • The first section, Sending A Request, shows the quickest and easiest way to request a proof using these sensible defaults, without any additional configuration.
  • The second section, Request Configuration, covers all available configuration options for the 5% of requests that require fine-tuning.
The Sending a Request section uses the counter example as a template, its source code can be found at: boundless/examples/counter

Sending a Request

If you want to submit a one-off request via the Boundless CLI, please see Requesting a Proof via the Boundless CLI.

1. Setting environment variables

We recommend using clap to parse these environment variables, as seen in apps/L37-52.

Blockchain

We recommend using Alchemy for your RPC URL during testing; their free tier is more than enough to test requesting a proof. Receiving proofs requires event queries, which public RPCs may not support.
Since we are submitting requests onchain, we will need private key for a wallet with sufficient funds on Sepolia, and a working RPC URL:
export RPC_URL="https://..."
export PRIVATE_KEY="abcdef..."

Storage Provider

For this tutorial, we suggest using a Pinata API key which will upload your program at runtime.If you do not want to use an API key, or if you want to use a provider other than Pinata, you can pre-upload you program to a public URL (this could be hosted via Pinata or any other service).To see more information about this option, please read No Storage Provider.
To make a program, and its inputs, accessible to provers, they need to be hosted at a public URL. We recommend using IPFS for storage, particularly via Pinata, as their free tier comfortably covers most Boundless use cases. Before submitting a request, you’ll need to:
  • Sign up for an account with Pinata.
  • Generate an API key following their documentation.
  • Copy the JWT token and set it as the PINATA_JWT environment variable:
export PINATA_JWT="abcdef..."

2. Build the Boundless Client

# use alloy::signers::local::PrivateKeySigner;
# use boundless_market::{Client, storage::storage_provider_from_env};
# use url::Url;
# #[derive(Clone)]
# struct Args {
#     rpc_url: Url,
#     private_key: PrivateKeySigner,
#     storage_config: String,
# }
# async fn create_boundless_client() -> Result<(), Box<dyn std::error::Error>> {
# let args = Args {
#     rpc_url: "https://example.com".parse()?,
#     private_key: "0x0000000000000000000000000000000000000000000000000000000000000001".parse()?,
#     storage_config: "mock".to_string(),
# };
let client = Client::builder()
  .with_rpc_url(args.rpc_url)
  .with_private_key(args.private_key)
  .with_storage_provider(Some(storage_provider_from_env()?))
  .build()
  .await?;
# Ok(())
# }

3. Create and Submit a Proof Request

# use anyhow::Result;
# use boundless_market::{Client};
# async fn create_proof_request(
# client: Client,
# program: &'static [u8],
# input: &[u8]) -> Result<()> {
# // In a real application, ECHO_ELF would come from guest_util::ECHO_ELF
# let ECHO_ELF = program;
# let echo_message = "Hello, world!";
// Create a request using new_request
let request = client.new_request().with_program(ECHO_ELF).with_stdin(echo_message.as_bytes());

// Submit the request onchain, via a transaction
let (request_id, expires_at) = client.submit_onchain(request).await?;
# Ok(())
# }

4. Retrieve the Proof

Once submitted, you can keep track of the request using:
# use std::time::Duration;
# use anyhow::Result;
# use boundless_market::Client;
# use alloy_primitives::U256;
# async fn wait_for_fulfillment(
# client: Client,
# request_id: U256,
# expires_at: u64) -> Result<()> {
// Wait for the request to be fulfilled. The market will return the fulfillment.
tracing::info!("Waiting for request {:x} to be fulfilled", request_id);
let fulfillment = client
    .wait_for_request_fulfillment(
        request_id,
        Duration::from_secs(5), // check every 5 seconds
        expires_at,
    )
    .await?;
tracing::info!("Request {:x} fulfilled", request_id);
# Ok(())
# }
This will store the journal and seal from the Boundless market, together they represent the public outputs of your guest and the proof itself, respectively. You can use a proof in your application to access the power of verifiable compute using Boundless.

Request Configuration

Storage Providers

The Boundless Market SDK automatically configures the storage provider based on environment variables; it supports both IPFS and S3 for uploading programs and inputs.

IPFS

For example, if you set the following:
export PINATA_JWT="abcdef"...
then when you use .with_storage_provider():
# use alloy::signers::local::PrivateKeySigner;
# use boundless_market::{Client, storage::storage_provider_from_env};
# use url::Url;
# #[derive(Clone)]
# struct Args {
#     rpc_url: Url,
#     private_key: PrivateKeySigner,
#     storage_config: String,
# }
# async fn create_boundless_client() -> Result<(), Box<dyn std::error::Error>> {
# let args = Args {
#     rpc_url: "https://example.com".parse()?,
#     private_key: "0x0000000000000000000000000000000000000000000000000000000000000001".parse()?,
#     storage_config: "mock".to_string(),
# };
let client = Client::builder()
  .with_rpc_url(args.rpc_url)
  .with_private_key(args.private_key)
  .with_storage_provider(Some(storage_provider_from_env()?)) // [!code hl]
  .build()
  .await?;
# Ok(())
# }
IPFS is set automatically to the storage provider, and your JWT will be used to upload programs/inputs via Pinata’s gateway.

S3

To use S3 as your storage provider, you need to set the following environment variables:
export S3_ACCESS_KEY="abcdef..."
export S3_SECRET_KEY="abcdef..."
export S3_BUCKET="bucket-name..."
export S3_URL="https://bucket-url..."
export AWS_REGION="us-east-1"
Once these are set, this will automatically use the specified AWS S3 bucket for storage of programs and inputs.

No Storage Provider

A perfectly valid option for StorageProvider is None; if you don’t set any relevant environment variables for IPFS/S3, it won’t use a storage provider to upload programs or inputs at runtime. This means you will need to upload your program ahead of time, and provide the public URL. For the inputs, you can also pass them inline (i.e. in the transaction) if they are small enough. Otherwise, you can upload inputs ahead of time as well.

Uploading Programs

Provers must be able to access your guest program via a publicly accessible URL; the Boundless Market SDK allows you to directly upload your program in a few different ways.

Manually

# use boundless_market::{Client, storage::storage_provider_from_env};
# async fn upload_program(program: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::builder()
  .with_storage_provider(Some(storage_provider_from_env()?))
  .build()
  .await?;
let program_url = client.upload_program(program).await?;
# Ok(())
# }
After which, you’d create a request with:
# use boundless_market::Client;
# async fn create_request_with_urls(
# client: &Client,
# program_url: &str,
# input_url: &str) -> Result<(), Box<dyn std::error::Error>> {
let request = client.new_request()
  .with_program_url(program_url)?
  .with_input_url(input_url);
# Ok(())
# }
If you already have the program_url, you do not need to upload the program again; you can simply use with_program_url with a hard-coded URL.

Automagically

If you are working in a monorepo (i.e. your zkVM host/guest is in the same repo), you can take advantage of risc0-build which automatically builds and exposes the ELF for the guest. The counter example uses this method:
# use anyhow::Result;
# use boundless_market::{Client};
# async fn create_proof_request(
# client: Client,
# input: &[u8]) -> Result<()> {
# mod guest_util {
#    pub const ECHO_ELF: &[u8] = b"";
# }
// Import ECHO_ELF from your guest code
use guest_util::{ECHO_ELF};
// Create a request using new_request
let request = client.new_request()
  .with_program(ECHO_ELF)
  .with_stdin(b"Hello, world!");
# Ok(())
# }

Inputs

When working with trusted provers, you can store inputs in Amazon S3 and restrict access via AWS S3’s permission management - Sensitive Inputs tutorial.
To execute and run proving, the prover requires the inputs of the program. Inputs can be provides as a public URL, or “inline” by including them directly in the request. Program inputs are uploaded to the same storage provider. This can be done manually like so:
# use boundless_market::{Client, storage::storage_provider_from_env};
# async fn upload_input(input_bytes: Vec<u8>) -> Result<(), Box<dyn std::error::Error>> {
# let client = Client::builder()
#   .with_storage_provider(Some(storage_provider_from_env()?))
#   .build()
#   .await?;
let input_url = client.upload_input(&input_bytes).await?;
# Ok(())
# }
or if we look back at the counter example, we can see that the inputs are included directly into the request builder:
# use anyhow::Result;
# use boundless_market::{Client};
# async fn create_proof_request(
# client: Client,
# program: &'static [u8],
# input: &[u8]) -> Result<()> {
# let ECHO_ELF = program;
# let echo_message = "Hello, world!";
// Create a request using new_request
let request = client.new_request().with_program(ECHO_ELF).with_stdin(echo_message.as_bytes()); // [!code hl]

// Submit the request directly
let (request_id, expires_at) = client.submit_onchain(request).await?;
# Ok(())
# }
In this example, inputs are included inline if they are small (e.g. less than 1 kB) or uploaded to a public URL first if they are large. When submitting requests onchain with inline inputs, this will cost more gas if the inputs are large. The offchain order-stream service also places limits on the size of inline input.

Proof Types

By default, the Boundless SDK requests aggregated proofs. However, you can also request Groth16 proofs, which are SNARK proofs that are highly efficient for onchain verification.

Requesting a Groth16 Proof

To request a Groth16 proof instead of the default aggregated proof, use the with_groth16_proof() method when building your request:
# use anyhow::Result;
# use boundless_market::{Client};
# async fn create_proof_request(
# client: Client,
# program: &'static [u8],
# input: &[u8]) -> Result<()> {
# let ECHO_ELF = program;
# let echo_message = "Hello, world!";
// Request an un-aggregated proof from the Boundless market using the ECHO guest.
let echo_request = client
    .new_request()
    .with_program(ECHO_ELF)
    .with_stdin(echo_message.as_bytes())
    .with_groth16_proof(); // [!code hl]

// Submit the request onchain
let (request_id, expires_at) = client.submit_onchain(echo_request).await?;
# Ok(())
# }
For a complete working example of requesting a Groth16 proof, see the composition example.

Onchain vs Offchain

The Boundless protocol allows you to submit requests both onchain and offchain.

Onchain

To submit a request onchain, we use:
# use anyhow::Result;
# use boundless_market::{Client};
# async fn create_proof_request(
# client: Client,
# program: &'static [u8],
# input: &[u8]) -> Result<()> {
# let ECHO_ELF = program;
# let echo_message = "Hello, world!";
// Create a request using new_request
let request = client.new_request().with_program(ECHO_ELF).with_stdin(echo_message.as_bytes());

// Submit the request directly
let (request_id, expires_at) = client.submit_onchain(request).await?; // [!code hl]
# Ok(())
# }

Offchain

When using offchain requests, you are required to deposit funds into the Boundless market contract before you can make any proof requests. This can be done with the Boundless CLI.
To submit a request offchain, we use:
# use anyhow::Result;
# use boundless_market::{Client};
# async fn create_proof_request(
# client: Client,
# program: &'static [u8],
# input: &[u8]) -> Result<()> {
# let ECHO_ELF = program;
# let echo_message = "Hello, world!";
// Create a request using new_request
let request = client.new_request().with_program(ECHO_ELF).with_stdin(echo_message.as_bytes());

// Submit the request directly
let (request_id, expires_at) = client.submit_offchain(request).await?; // [!code hl]
# Ok(())
# }

Offer

The Offer specifies how much the requestor will pay for a proof, by setting the auction parameters; price, timing, stake requirements, and expiration. The Client helps you build requests and set these parameters. Within the client, the OfferLayer creates the offer. It contains a set of defaults, and logic to assign a price to your request. There are two ways to configure auction parameters:
  1. Using client_builder.config_offer_layer to configure the offer building logic.
  2. Using request.with_offer to override parameters for a specific request. This gives you direct control over the offer.

When to Use Each Approach

  • Use config_offer_layer when:
    • You want to configure cycle-based pricing that applies to all requests
    • You need to adjust gas estimates or other calculation parameters
    • You want consistent pricing logic across multiple requests
  • Use with_offer when:
    • You need to override the automatic calculations for a specific request
    • You want to set exact prices rather than using cycle-based and gas-price calculations
    • You have special requirements for a particular proof request

Per-Request Configuration with with_offer

Use with_offer when you want to override specific pricing parameters for an individual request:
showLineNumbers
# use anyhow::Result;
# use alloy_primitives::utils::parse_ether;
# use boundless_market::{Client, request_builder::OfferParams};
# async fn create_proof_request(
# client: Client,
# program: &'static [u8],
# input: &[u8]) -> Result<()> {
// Create a request using new_request
let request = client.new_request()
  .with_program(program)
  .with_stdin(input)
  .with_offer(
    OfferParams::builder()
      // The market uses a reverse Dutch auction mechanism to match requests with provers.
      // Each request has a price range that a prover can bid on.
      .min_price(parse_ether("0.001")?)
      .max_price(parse_ether("0.002")?)
      // The timeout is the maximum number of blocks the request can stay
      // unfulfilled in the market before it expires. If a prover locks in
      // the request and does not fulfill it before the lock timeout, the
      // prover can be slashed.
      .timeout(1000)
      .lock_timeout(500)
      .ramp_up_period(100)
  );

// Submit the request directly
let (request_id, expires_at) = client.submit_onchain(request).await?;
# Ok(())
# }

Client-Level Configuration with config_offer_layer

Use config_offer_layer when you want to adjust how the SDK calculates auction parameters based on cycle count and gas prices. This is particularly useful when you want to use cycle-based pricing:
# use anyhow::Result;
# use alloy::signers::local::PrivateKeySigner;
# use alloy::primitives::utils::parse_units;
# use boundless_market::{Client, storage::storage_provider_from_env};
# use url::Url;
# #[derive(Clone)]
# struct Args {
#     rpc_url: Url,
#     private_key: PrivateKeySigner,
#     storage_config: String,
# }
# async fn create_boundless_client() -> Result<(), Box<dyn std::error::Error>> {
# let args = Args {
#     rpc_url: "https://example.com".parse()?,
#     private_key: "0x0000000000000000000000000000000000000000000000000000000000000001".parse()?,
#     storage_config: "mock".to_string(),
# };
// Configure the offer layer logic when building the client
let client = Client::builder()
  .with_rpc_url(args.rpc_url)
  .with_private_key(args.private_key)
  .with_storage_provider(Some(storage_provider_from_env()?))
  .config_offer_layer(|config| config
    // Set the price per cycle for automatic pricing calculations
    .max_price_per_cycle(parse_units("0.1", "gwei").unwrap())
    .min_price_per_cycle(parse_units("0.01", "gwei").unwrap())
    // Configure default timeouts and auction parameters
    .ramp_up_period(36)
    .lock_timeout(120)
    .timeout(300)
  )
  .build()
  .await?;
# Ok(())
# }
With this configuration, the SDK will execute the request to estimate cycles and calculate appropriate prices.