Skip to main content

hyli_bonsai_runner/
lib.rs

1use std::{
2    str::FromStr,
3    time::{Duration, SystemTime, UNIX_EPOCH},
4};
5
6use anyhow::{bail, Context, Result};
7use bonsai_sdk::non_blocking::Client;
8use borsh::BorshSerialize;
9use boundless_market::{
10    alloy::{
11        primitives::{utils::parse_ether, Uint},
12        signers::local::PrivateKeySigner,
13        transports::http::reqwest::Url,
14    },
15    client::ClientBuilder,
16    contracts::Offer,
17    deployments::NamedChain,
18    storage::{StandardUploader, StorageUploader, StorageUploaderConfig, StorageUploaderType},
19    Deployment, GuestEnvBuilder,
20};
21use risc0_zkvm::{compute_image_id, default_executor, sha::Digestible, Receipt};
22use tracing::info;
23
24#[allow(dead_code)]
25pub fn as_input_data<T: BorshSerialize>(data: &T) -> Result<Vec<u8>> {
26    let data = borsh::to_vec(&data)?;
27    let size = risc0_zkvm::serde::to_vec(&data.len())?;
28    let mut input_data = bytemuck::cast_slice(&size).to_vec();
29    input_data.extend(data);
30    Ok(input_data)
31}
32
33pub struct ProofResult {
34    pub receipt: Receipt,
35    pub cycles: Option<u64>,
36}
37
38fn is_dev_mode() -> bool {
39    std::env::var("RISC0_DEV_MODE")
40        .ok()
41        .map(|value| value.to_lowercase())
42        .filter(|value| value == "1" || value == "true" || value == "yes")
43        .is_some()
44}
45
46fn parse_url_env(key: &str) -> Result<Option<Url>> {
47    let value = match std::env::var(key) {
48        Ok(value) => value,
49        Err(std::env::VarError::NotPresent) => return Ok(None),
50        Err(err) => return Err(err.into()),
51    };
52
53    Ok(Some(
54        Url::parse(&value).with_context(|| format!("invalid URL in {key}"))?,
55    ))
56}
57
58async fn storage_uploader_from_env() -> Result<StandardUploader> {
59    if is_dev_mode() {
60        return Ok(StandardUploader::from_config(&StorageUploaderConfig::dev_mode()).await?);
61    }
62
63    if std::env::var("PINATA_JWT").is_ok() {
64        let mut config = StorageUploaderConfig::default();
65        config.storage_uploader = StorageUploaderType::Pinata;
66        config.pinata_jwt = std::env::var("PINATA_JWT").ok();
67        config.pinata_api_url = parse_url_env("PINATA_API_URL")?;
68        config.ipfs_gateway_url = parse_url_env("IPFS_GATEWAY_URL")?;
69        return Ok(StandardUploader::from_config(&config).await?);
70    }
71
72    anyhow::bail!(
73        "no storage uploader configured: set RISC0_DEV_MODE, PINATA_JWT, or storage env vars"
74    );
75}
76
77pub async fn run_boundless(elf: &[u8], input_data: Vec<u8>) -> Result<ProofResult> {
78    let chain_id = std::env::var("BOUNDLESS_CHAIN_ID").unwrap_or("11155111".to_string());
79    let offchain = std::env::var("BOUNDLESS_OFFCHAIN").unwrap_or_default() == "true";
80    let wallet_private_key = std::env::var("BOUNDLESS_WALLET_PRIVATE_KEY").unwrap_or_default();
81    let rpc_url = std::env::var("BOUNDLESS_RPC_URL").unwrap_or_default();
82
83    let min_price_per_mcycle =
84        std::env::var("BOUNDLESS_MIN_PRICE_PER_MCYCLE").unwrap_or_else(|_| "0.000001".to_string());
85    let max_price_per_mcycle =
86        std::env::var("BOUNDLESS_MAX_PRICE_PER_MCYCLE").unwrap_or_else(|_| "0.000005".to_string());
87    let timeout = std::env::var("BOUNDLESS_TIMEOUT").unwrap_or_else(|_| "120".to_string());
88    let lock_timeout = std::env::var("BOUNDLESS_LOCK_TIMEOUT").unwrap_or_else(|_| "60".to_string());
89    let ramp_up_period =
90        std::env::var("BOUNDLESS_RAMP_UP_PERIOD").unwrap_or_else(|_| "15".to_string());
91
92    let chain_id: u64 = chain_id.parse()?;
93    let min_price_per_mcycle = parse_ether(&min_price_per_mcycle)?;
94    let max_price_per_mcycle = parse_ether(&max_price_per_mcycle)?;
95    let timeout: u32 = timeout.parse()?;
96    let lock_timeout: u32 = lock_timeout.parse()?;
97    let ramp_up_period: u32 = ramp_up_period.parse()?;
98
99    // Creates a storage uploader based on environment variables.
100    //
101    // If the environment variable `RISC0_DEV_MODE` is set, a temporary file storage uploader is used.
102    // Otherwise, the following environment variables are checked in order:
103    // - `PINATA_JWT`, `PINATA_API_URL`, `IPFS_GATEWAY_URL`: Pinata uploader.
104    let storage_uploader = storage_uploader_from_env().await?;
105
106    let image_url = storage_uploader.upload_program(elf).await?;
107    info!("Uploaded image to {}", image_url);
108
109    let wallet_private_key = PrivateKeySigner::from_str(&wallet_private_key)?;
110    let rpc_url = Url::parse(&rpc_url)?;
111
112    let mut deployment = Deployment::from_chain_id(chain_id);
113
114    if let Some(dep) = deployment.as_mut() {
115        if dep.market_chain_id.unwrap() == NamedChain::Base as u64 && chain_id == 84532 {
116            dep.market_chain_id = Some(NamedChain::BaseSepolia as u64);
117        }
118    }
119
120    // Create a Boundless client from the provided parameters.
121    let boundless_client = ClientBuilder::new()
122        .with_rpc_url(rpc_url)
123        .with_deployment(deployment)
124        .with_uploader(Some(storage_uploader))
125        .with_private_key(wallet_private_key)
126        .build()
127        .await
128        .context("failed to build boundless client")?;
129
130    // Encode the input and upload it to the storage provider.
131    let guest_env = risc0_zkvm::ExecutorEnv::builder()
132        .write_slice(&input_data)
133        .build()
134        .unwrap();
135
136    // Dry run the ELF with the input to get the journal and cycle count.
137    // This can be useful to estimate the cost of the proving request.
138    // It can also be useful to ensure the guest can be executed correctly and we do not send into
139    // the market unprovable proving requests. If you have a different mechanism to get the expected
140    // journal and set a price, you can skip this step.
141    let session_info = default_executor().execute(guest_env, elf)?;
142    let cycles_count = session_info
143        .segments
144        .iter()
145        .map(|segment| 1 << segment.po2)
146        .sum::<u64>();
147    let mcycles_count = cycles_count.div_ceil(1_000_000);
148    let journal = session_info.journal;
149
150    info!(
151        "Dry run completed: {} mcycles, journal digest: {}",
152        mcycles_count,
153        journal.digest()
154    );
155
156    let address = boundless_client.signer.as_ref().unwrap().address();
157    let balance = boundless_client
158        .boundless_market
159        .balance_of(address)
160        .await?;
161    let max_price = max_price_per_mcycle * Uint::from(mcycles_count);
162    info!(address = %address, max_price = %max_price, "Wallet balance: {}", balance);
163    if balance < max_price {
164        let deposit = std::cmp::max(max_price, parse_ether("0.1")?);
165        info!(
166            "Wallet balance ({}) is low, depositing {} ETH",
167            balance, deposit
168        );
169        boundless_client.boundless_market.deposit(deposit).await?;
170    }
171
172    // Create a proof request with the image, input, requirements and offer.
173    // The ELF (i.e. image) is specified by the image URL.
174    // The input can be specified by an URL, as in this example, or can be posted on chain by using
175    // the `with_inline` method with the input bytes.
176    // The requirements are the image ID and the digest of the journal. In this way, the market can
177    // verify that the proof is correct by checking both the committed image id and digest of the
178    // journal. The offer specifies the price range and the timeout for the request.
179    // Additionally, the offer can also specify:
180    // - the bidding start time: the block number when the bidding starts;
181    // - the ramp up period: the number of blocks before the price start increasing until reaches
182    //   the maxPrice, starting from the the bidding start;
183    // - the lockin price: the price at which the request can be locked in by a prover, if the
184    //   request is not fulfilled before the timeout, the prover can be slashed.
185    // If the input exceeds 2 kB, upload the input and provide its URL instead, as a rule of thumb.
186    // let request_input = if guest_env_bytes.len() > 2 << 10 {
187    //     let input_url = boundless_client.upload_input(&guest_env_bytes).await?;
188    //     tracing::info!("Uploaded input to {}", input_url);
189    //     Input::url(input_url)
190    // } else {
191    //     tracing::info!("Sending input inline with request");
192    //     Input::inline(guest_env_bytes.clone())
193    // };
194    //
195    let env = GuestEnvBuilder::new().write_slice(&input_data).build_env();
196
197    let request = boundless_client
198        .new_request()
199        .with_program_url(image_url)?
200        .with_env(env)
201        // .with_requirements(Requirements::new(
202        //     compute_image_id(elf)?,
203        //     Predicate::digest_match(journal.digest()),
204        // ))
205        .with_offer(
206            Offer::default()
207                // The market uses a reverse Dutch auction mechanism to match requests with provers.
208                // Each request has a price range that a prover can bid on. One way to set the price
209                // is to choose a desired (min and max) price per million cycles and multiply it
210                // by the number of cycles. Alternatively, you can use the `with_min_price` and
211                // `with_max_price` methods to set the price directly.
212                .with_min_price_per_mcycle(min_price_per_mcycle, mcycles_count)
213                // NOTE: If your offer is not being accepted, try increasing the max price.
214                .with_max_price_per_mcycle(max_price_per_mcycle, mcycles_count)
215                // The timeout is the maximum number of blocks the request can stay
216                // unfulfilled in the market before it expires. If a prover locks in
217                // the request and does not fulfill it before the timeout, the prover can be
218                // slashed.
219                .with_timeout(timeout)
220                .with_lock_timeout(lock_timeout)
221                .with_ramp_up_period(ramp_up_period),
222        );
223
224    // Send the request and wait for it to be completed.
225    let (request_id, expires_at) = if offchain {
226        boundless_client.submit_offchain(request).await?
227    } else {
228        boundless_client.submit_onchain(request).await?
229    };
230    tracing::info!("Request 0x{request_id:x} submitted");
231    tracing::info!("https://explorer.beboundless.xyz/orders/0x{request_id:x}");
232
233    // Wait for the request to be fulfilled by the market, returning the journal and seal.
234    let fullfillment = boundless_client
235        .wait_for_request_fulfillment(request_id, Duration::from_secs(3), expires_at)
236        .await?;
237    tracing::info!("Request 0x{request_id:x} fulfilled");
238
239    let journal = fullfillment
240        .data()?
241        .journal()
242        .ok_or_else(|| {
243            anyhow::anyhow!(
244                "Failed to get journal from fulfillment, this is likely a bug in the SDK"
245            )
246        })?
247        .clone();
248    let seal = fullfillment.seal;
249
250    // write journal & seal to disk for debugging purposes
251    std::fs::write("journal.bin", bincode::serialize(&journal)?)?;
252    std::fs::write("seal.bin", seal.clone())?;
253
254    let image_id = compute_image_id(elf)?;
255    let receipt = boundless_client
256        .set_verifier
257        .fetch_receipt(seal, image_id, journal.to_vec())
258        .await?;
259
260    let receipt = receipt.root.ok_or(anyhow::anyhow!(
261        "Failed to get root from receipt, this is likely a bug in the SDK"
262    ))?;
263
264    receipt.verify(image_id).context("Verify proof")?;
265
266    info!("Receipt verified successfully");
267
268    Ok(ProofResult {
269        receipt,
270        cycles: Some(cycles_count),
271    })
272}
273
274#[allow(dead_code)]
275pub async fn run_bonsai(elf: &[u8], input_data: Vec<u8>) -> Result<ProofResult> {
276    let client = Client::from_env(risc0_zkvm::VERSION)?;
277
278    // Compute the image_id, then upload the ELF with the image_id as its key.
279    let image_id = hex::encode(compute_image_id(elf)?);
280    client.upload_img(&image_id, elf.to_vec()).await?;
281
282    // Prepare input data and upload it.
283    let input_id = client.upload_input(input_data).await?;
284
285    // Add a list of assumptions
286    let assumptions: Vec<String> = vec![];
287
288    // Wether to run in execute only mode
289    let execute_only = false;
290
291    // Start a session running the prover
292    let session = client
293        .create_session(image_id, input_id, assumptions, execute_only)
294        .await?;
295    loop {
296        let res = session.status(&client).await?;
297        if res.status == "RUNNING" {
298            info!(
299                "Current status: {} - state: {} - continue polling...",
300                res.status,
301                res.state.unwrap_or_default()
302            );
303            tokio::time::sleep(Duration::from_secs(1)).await;
304            continue;
305        }
306        if res.status == "SUCCEEDED" {
307            // Download the receipt, containing the output
308            let receipt_url = res
309                .receipt_url
310                .expect("API error, missing receipt on completed session");
311
312            let receipt_buf = client.download(&receipt_url).await?;
313            let receipt: Receipt = bincode::deserialize(&receipt_buf)?;
314            return Ok(ProofResult {
315                receipt,
316                cycles: res.stats.map(|s| s.total_cycles),
317            });
318        } else {
319            bail!(
320                "Workflow exited: {} - | err: {}",
321                res.status,
322                res.error_msg.unwrap_or_default()
323            );
324        }
325    }
326}
327
328pub fn get_current_timestamp_secs() -> u64 {
329    SystemTime::now()
330        .duration_since(UNIX_EPOCH)
331        .expect("Time went backwards")
332        .as_secs()
333}