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

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?;

3. Create and Submit a Proof Request

// 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?;

4. Retrieve the Proof

Once submitted, you can keep track of the request using:
// 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);
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():
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?;
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.
The SDK generates S3 presigned URLs that expire after 12 hours. If your request takes longer to fulfill, provers cannot download your program or inputs after expiry. For long-running requests, use IPFS storage or set S3_NO_PRESIGNED=1 to use direct S3 URLs with appropriate bucket policies.

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

let client = Client::builder()
  .with_storage_provider(Some(storage_provider_from_env()?))
  .build()
  .await?;
let program_url = client.upload_program(program).await?;
After which, you’d create a request with:
let request = client.new_request()
  .with_program_url(program_url)?
  .with_input_url(input_url);
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:
// 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!");

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:
let input_url = client.upload_input(&input_bytes).await?;
or if we look back at the counter example, we can see that the inputs are included directly into the request builder:
// 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?;
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.

Size Limits

Provers enforce limits on file and journal sizes. Requests exceeding these limits are ignored by most provers.

File Size Limits

Programs and input files must be under 50MB. This applies to guest program ELF binaries and input files uploaded to storage providers. Oversized files would force provers to waste bandwidth and storage, so they reject such requests.

Journal Size Limits

Journals delivered on-chain must be under 10KB. Larger journals would force provers to post expensive calldata, so they reject such requests. If your journal exceeds 10KB, use the ClaimDigestMatch predicate and store journals off-chain. See Journal Size Limits for details.

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:
// 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?;
For a complete working example of requesting a Groth16 proof, see the composition example.

Requesting a Blake3 Groth16 Proof

Blake3 Groth16 proofs allow verification in environments where SHA2 hashing is impossible or expensive (e.g. BitVM). To request a Blake3 Groth16 proof, use the with_blake3_groth16_proof() method:
let request = client
    .new_request()
    .with_program(program)
    .with_stdin(input)
    .with_blake3_groth16_proof(); // [!code hl]
Blake3 Groth16 proofs are only supported with the ClaimDigestMatch predicate, meaning you should only use this if you do not require the journal to be delivered on-chain. Additionally, the journal must be exactly 32 bytes.
For more details on proof types and when to use each, see Proof Types.

Onchain vs Offchain

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

Onchain

To submit a request onchain, we use:
// 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]

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:
// 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]

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
// 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?;

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:
// 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?;
With this configuration, the SDK will execute the request to estimate cycles and calculate appropriate prices.

Funding Modes

For most use-cases, we recommend using the default setting of Always, which ensures that your requests will always be fully funded and thus can be fulfilled by the network. Setting any other funding mode is considered an advanced feature, and may lead to a degredation of proof fufillment rate.
When submitting requests onchain, the Boundless Market SDK needs to fund the request with ETH to cover the max_price (see Auction Parameters) of the proof. For more advanced use-cases, the SDK provides several funding modes to control how this funding is handled, allowing you to optimize gas costs and manage your onchain balance efficiently. The funding mode can be configured when building the client using with_funding_mode:
let client = Client::builder()
  .with_rpc_url(args.rpc_url)
  .with_private_key(args.private_key)
  .with_storage_provider(Some(storage_provider_from_env()?))
  .with_funding_mode(FundingMode::Always) // [!code hl]
  .build()
  .await?;

Always (Default)

The Always mode always sends max_price as the transaction value with each request. This is the simplest mode and ensures your requests are always fully funded.
let funding_mode = FundingMode::Always;
If your balance is more than 3x the max_price, the SDK will log a warning suggesting you consider a different funding mode to avoid overfunding.

Never

The Never mode never sends value with the request. Use this mode only if you are managing the onchain balance through other means (e.g., manual top-ups, external funding management).
let funding_mode = FundingMode::Never;
When using Never mode, you must ensure your onchain balance is sufficient to cover the max_price of each request. Otherwise, requests may fail.

AvailableBalance

The AvailableBalance mode uses the available onchain balance for funding the request. If the balance is insufficient, only the difference will be sent as value.
let funding_mode = FundingMode::AvailableBalance;
This mode is useful when you want to minimize the amount of ETH sent with each transaction while ensuring requests are properly funded.

BelowThreshold

The BelowThreshold mode sends value only if the balance is below a configurable threshold. If the balance is below the threshold, the difference will be sent as value (up to max_price).
let threshold = parse_ether("0.1")?; // 0.1 ETH
let funding_mode = FundingMode::BelowThreshold(threshold);
Set the threshold appropriately to avoid underfunding. The threshold should be at least as large as your typical max_price to ensure requests can be funded when needed.

MinMaxBalance

The MinMaxBalance mode maintains a minimum and maximum balance by funding requests accordingly. If the balance is below min_balance, the request will be funded to bring the balance up to max_balance (or to cover max_price, whichever is greater).
let min_balance = parse_ether("0.05")?;  // 0.05 ETH minimum
let max_balance = parse_ether("0.2")?;   // 0.2 ETH maximum
let funding_mode = FundingMode::MinMaxBalance {
    min_balance,
    max_balance,
};
This mode should minimize the number of onchain fundings while ensuring sufficient balance is maintained. It’s ideal for applications that make frequent requests and want to optimize gas costs by reducing the number of funding transactions.
When the balance drops below min_balance, the SDK will fund up to max_balance in a single transaction, reducing the need for frequent top-ups.