From 0d0a9ea73aebfb4e17c96865e6108a4146bfffa4 Mon Sep 17 00:00:00 2001 From: jonastheis <4181434+jonastheis@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:18:26 +0800 Subject: [PATCH 1/3] feat(cli): default prover fulfill to the on-chain assessor The fulfill command's --assessor-selector flag now defaults to the on-chain assessor (0x00000022). In that mode the assessor guest is neither downloaded nor proven: only the order claims are aggregated, and the batch is sealed with an EIP-712 FulfillmentBatchAuth signature verified by the OnChainAssessor adapter, whose address is discovered through the market's router. Passing any other selector (e.g. 0x00000024) keeps the previous guest-based R0 assessor path. --- crates/boundless-cli/src/bin/boundless-ffi.rs | 6 +- .../src/commands/prover/fulfill.rs | 44 ++- crates/boundless-cli/src/lib.rs | 275 +++++++++++++----- crates/indexer/src/market/caching/file.rs | 2 +- crates/indexer/tests/market/common.rs | 2 +- crates/slasher/tests/basic.rs | 2 +- 6 files changed, 244 insertions(+), 87 deletions(-) diff --git a/crates/boundless-cli/src/bin/boundless-ffi.rs b/crates/boundless-cli/src/bin/boundless-ffi.rs index 41fe2f080d..37a58a175d 100644 --- a/crates/boundless-cli/src/bin/boundless-ffi.rs +++ b/crates/boundless-cli/src/bin/boundless-ffi.rs @@ -25,7 +25,7 @@ use alloy::{ sol_types::SolValue, }; use anyhow::{bail, ensure, Context, Result}; -use boundless_cli::{OrderFulfilled, OrderFulfiller}; +use boundless_cli::{AssessorMode, OrderFulfilled, OrderFulfiller}; use boundless_market::contracts::{eip712_domain, ProofRequest}; use boundless_market::storage::StandardDownloader; use broker::provers::{DefaultProver as BrokerDefaultProver, Prover}; @@ -136,10 +136,10 @@ async fn main() -> Result<()> { prover, Arc::new(StandardDownloader::new().await), set_builder_image_id, - assessor_image_id, + Some(assessor_image_id), args.prover_address, domain.clone(), - args.assessor_selector, + AssessorMode::R0 { selector: args.assessor_selector }, )?; let request = ::abi_decode(&hex::decode(args.request.trim_start_matches("0x"))?) .map_err(|_| anyhow::anyhow!("Failed to decode ProofRequest from input"))?; diff --git a/crates/boundless-cli/src/commands/prover/fulfill.rs b/crates/boundless-cli/src/commands/prover/fulfill.rs index fc89a91e1c..b2e30fcaea 100644 --- a/crates/boundless-cli/src/commands/prover/fulfill.rs +++ b/crates/boundless-cli/src/commands/prover/fulfill.rs @@ -12,10 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::{OrderFulfilled, OrderFulfiller}; -use alloy::primitives::{FixedBytes, B256, U256}; -use anyhow::{bail, Context, Result}; -use boundless_market::contracts::boundless_market::{FulfillmentTx, UnlockedRequest}; +use crate::{AssessorMode, OrderFulfilled, OrderFulfiller}; +use alloy::primitives::{Address, FixedBytes, B256, U256}; +use anyhow::{bail, ensure, Context, Result}; +use boundless_market::contracts::{ + boundless_market::{FulfillmentTx, UnlockedRequest}, + ONCHAIN_ASSESSOR_SELECTOR, +}; use clap::Args; use crate::config::{GlobalConfig, ProverConfig}; @@ -41,8 +44,10 @@ pub struct ProverFulfill { #[arg(long, default_value = "false")] pub withdraw: bool, - /// The 4-byte BoundlessRouter assessor selector to prepend to the assessor seal (hex, e.g. 0x00000022) - #[arg(long)] + /// The 4-byte BoundlessRouter assessor selector to prepend to the assessor seal. Defaults to + /// the on-chain assessor (an EIP-712 batch signature, no assessor guest proof); pass the R0 + /// assessor selector (e.g. 0x00000024) to prove the assessor guest instead. + #[arg(long, default_value_t = ONCHAIN_ASSESSOR_SELECTOR)] pub assessor_selector: FixedBytes<4>, /// Lower bound: search events backwards down to this block @@ -89,12 +94,35 @@ impl ProverFulfill { display.header("Fulfilling Proof Requests"); display.item_colored("Request IDs", &request_ids_string, "cyan"); + display.item_colored("Assessor", self.assessor_selector.to_string(), "cyan"); display.status("Status", "Initializing prover and fetching images", "yellow"); + let assessor_mode = if self.assessor_selector == ONCHAIN_ASSESSOR_SELECTOR { + // The adapter address is the EIP-712 verifying contract of the batch signature; + // discover it behind the selector via the market's router. + let adapter = + client + .boundless_market + .router_entry_impl(self.assessor_selector) + .await + .context("failed to resolve the OnChainAssessor adapter from the router")?; + ensure!( + adapter != Address::ZERO, + "the on-chain assessor is not registered in this market's router; pass \ + --assessor-selector with the R0 assessor selector (e.g. 0x00000024) instead" + ); + AssessorMode::Onchain { + selector: self.assessor_selector, + adapter, + signer: prover_config.require_private_key_with_help()?, + } + } else { + AssessorMode::R0 { selector: self.assessor_selector } + }; + // Initialize fulfiller with prover setup and image uploads let fulfiller = - OrderFulfiller::initialize_from_config(&prover_config, &client, self.assessor_selector) - .await?; + OrderFulfiller::initialize_from_config(&prover_config, &client, assessor_mode).await?; let fetch_order_jobs = self.request_ids.iter().enumerate().map(|(i, request_id)| { let client = client.clone(); diff --git a/crates/boundless-cli/src/lib.rs b/crates/boundless-cli/src/lib.rs index 5a3db7e551..f67e020c1d 100644 --- a/crates/boundless-cli/src/lib.rs +++ b/crates/boundless-cli/src/lib.rs @@ -30,7 +30,10 @@ pub mod contracts; pub mod display; pub mod price_oracle_helper; -use alloy::primitives::{Address, Bytes, FixedBytes}; +use alloy::{ + primitives::{Address, Bytes, FixedBytes}, + signers::local::PrivateKeySigner, +}; use anyhow::{bail, Context, Result}; use blake3_groth16::Blake3Groth16Receipt; use boundless_assessor::{AssessorInput, Fulfillment}; @@ -51,8 +54,8 @@ use std::sync::Arc; use boundless_market::{ contracts::{ - EIP712DomainSaltless, Fulfillment as BoundlessFulfillment, FulfillmentBatch, - FulfillmentData, PredicateType, RequestInputType, SlimRequest, + build_onchain_assessor_seal, EIP712DomainSaltless, Fulfillment as BoundlessFulfillment, + FulfillmentBatch, FulfillmentData, PredicateType, RequestInputType, SlimRequest, }, input::GuestEnv, selector::{is_blake3_groth16_selector, is_groth16_selector, SupportedSelectors}, @@ -60,6 +63,27 @@ use boundless_market::{ NotProvided, ProofRequest, }; +/// How the assessor seal for a fulfillment batch is produced. +#[derive(Clone)] +pub enum AssessorMode { + /// Prove the R0 assessor guest and aggregate it with the order claims; the seal is the guest + /// receipt's set-inclusion proof framed behind `selector`. + R0 { + /// The 4-byte router entry selector of the deployed `R0BoundlessAssessorAdapter`. + selector: FixedBytes<4>, + }, + /// Skip the assessor guest; the seal is an EIP-712 `FulfillmentBatchAuth` signature framed + /// behind `selector`, verified by the `OnChainAssessor` adapter deployed at `adapter`. + Onchain { + /// The 4-byte router entry selector of the deployed `OnChainAssessor` adapter. + selector: FixedBytes<4>, + /// The deployed `OnChainAssessor` adapter (the EIP-712 `verifyingContract`). + adapter: Address, + /// Signs the batch authorization; must control the prover address credited on-chain. + signer: PrivateKeySigner, + }, +} + /// Default URL for assessor image - matches broker config defaults. The assessor guest currently /// deployed on-chain (image id 0x6c5a03c0…56694100), matching the market `imageInfo()` and the /// router R0 assessor adapter's pinned `ASSESSOR_IMAGE_ID`. @@ -212,12 +236,13 @@ pub struct OrderFulfiller { prover: Arc, downloader: Arc, set_builder_image_id: Digest, - assessor_image_id: Digest, + /// The R0 assessor guest image id; only set in [AssessorMode::R0]. + assessor_image_id: Option, address: Address, domain: EIP712DomainSaltless, supported_selectors: SupportedSelectors, - /// The 4-byte router assessor selector prepended to the assessor seal. - assessor_selector: FixedBytes<4>, + /// How the batch assessor seal is produced. + assessor_mode: AssessorMode, } impl OrderFulfiller { @@ -226,10 +251,10 @@ impl OrderFulfiller { prover: Arc, downloader: Arc, set_builder_image_id: Digest, - assessor_image_id: Digest, + assessor_image_id: Option, address: Address, domain: EIP712DomainSaltless, - assessor_selector: FixedBytes<4>, + assessor_mode: AssessorMode, ) -> Result { let supported_selectors = SupportedSelectors::default().with_set_builder_image_id(set_builder_image_id); @@ -241,14 +266,14 @@ impl OrderFulfiller { address, domain, supported_selectors, - assessor_selector, + assessor_mode, }) } pub(crate) async fn initialize_from_config( prover_config: &config::ProverConfig, client: &boundless_market::Client, - assessor_selector: FixedBytes<4>, + assessor_mode: AssessorMode, ) -> Result where P: alloy::providers::Provider + Clone + 'static, @@ -284,19 +309,20 @@ impl OrderFulfiller { )?) }; - Self::initialize(prover, client, assessor_selector, ASSESSOR_DEFAULT_IMAGE_URL).await + Self::initialize(prover, client, assessor_mode, ASSESSOR_DEFAULT_IMAGE_URL).await } /// Initialize an OrderFulfiller from a provided Prover instance. /// - /// `assessor_image_url` is the source for the assessor guest ELF; its image id must match the - /// one the deployed `R0BoundlessAssessorAdapter` verifies against. Production passes - /// [ASSESSOR_DEFAULT_IMAGE_URL]; tests point it at the locally-built guest so the proven image - /// matches the image deployed by the test harness. + /// In [AssessorMode::R0], `assessor_image_url` is the source for the assessor guest ELF; its + /// image id must match the one the deployed `R0BoundlessAssessorAdapter` verifies against. + /// Production passes [ASSESSOR_DEFAULT_IMAGE_URL]; tests point it at the locally-built guest + /// so the proven image matches the image deployed by the test harness. In + /// [AssessorMode::Onchain] no assessor guest is proven and the URL is unused. pub async fn initialize( prover: Arc, client: &boundless_market::Client, - assessor_selector: FixedBytes<4>, + assessor_mode: AssessorMode, assessor_image_url: &str, ) -> Result where @@ -310,24 +336,31 @@ impl OrderFulfiller { client.set_verifier.image_info().await?; let set_builder_image_id = Digest::try_from(set_builder_image_id_bytes.as_slice())?; - // The market no longer exposes the assessor image info; derive it from the configured ELF. - let assessor_program = downloader - .download(assessor_image_url) - .await - .context("Failed to download assessor image")?; - let assessor_image_id = - compute_image_id(&assessor_program).context("Failed to compute assessor image ID")?; - - tracing::debug!("Fetching Assessor program (ID: {})", assessor_image_id); - ensure_prover_has_image( - &prover, - "assessor", - assessor_image_id, - assessor_image_url, - assessor_image_url, - &downloader, - ) - .await?; + let assessor_image_id = match &assessor_mode { + AssessorMode::Onchain { .. } => None, + AssessorMode::R0 { .. } => { + // The market no longer exposes the assessor image info; derive it from the + // configured ELF. + let assessor_program = downloader + .download(assessor_image_url) + .await + .context("Failed to download assessor image")?; + let assessor_image_id = compute_image_id(&assessor_program) + .context("Failed to compute assessor image ID")?; + + tracing::debug!("Fetching Assessor program (ID: {})", assessor_image_id); + ensure_prover_has_image( + &prover, + "assessor", + assessor_image_id, + assessor_image_url, + assessor_image_url, + &downloader, + ) + .await?; + Some(assessor_image_id) + } + }; tracing::debug!("Fetching SetBuilder program (ID: {})", set_builder_image_id); ensure_prover_has_image( @@ -347,7 +380,7 @@ impl OrderFulfiller { assessor_image_id, client.boundless_market.caller(), domain, - assessor_selector, + assessor_mode, ) } @@ -409,8 +442,9 @@ impl OrderFulfiller { let stdin = GuestEnv::builder().write_frame(&assessor_input.encode()).stdin; - let image_id_str = self.assessor_image_id.to_string(); - self.prove_stark(&image_id_str, stdin, assumption_ids).await + let image_id = + self.assessor_image_id.context("assessor image id is only set in R0 assessor mode")?; + self.prove_stark(&image_id.to_string(), stdin, assumption_ids).await } /// Fulfills a list of orders, returning the relevant data: @@ -518,28 +552,38 @@ impl OrderFulfiller { } } - tracing::debug!("Proving assessor"); - let assessor_receipt_id = self.assessor(fills.clone(), proof_ids.clone()).await?; - let assessor_receipt = self - .prover - .get_receipt(&assessor_receipt_id) - .await? - .ok_or_else(|| anyhow::anyhow!("Assessor receipt not found"))?; - let assessor_journal = assessor_receipt.journal.bytes.clone(); - let assessor_claim = prune_receipt_claim_journal(ReceiptClaim::ok( - self.assessor_image_id, - assessor_journal.clone(), - )); - claims.push(assessor_claim.clone()); - claim_digests.push(assessor_claim.digest()); + anyhow::ensure!(!fills.is_empty(), "no orders were successfully proven"); + + // The R0 assessor guest is only proven (and aggregated with the order claims) when it + // seals the batch; the on-chain assessor replaces it with an EIP-712 batch signature. + let assessor_r0 = match &self.assessor_mode { + AssessorMode::Onchain { .. } => None, + AssessorMode::R0 { .. } => { + tracing::debug!("Proving assessor"); + let assessor_receipt_id = self.assessor(fills.clone(), proof_ids.clone()).await?; + let assessor_receipt = self + .prover + .get_receipt(&assessor_receipt_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Assessor receipt not found"))?; + let assessor_journal = assessor_receipt.journal.bytes.clone(); + let assessor_claim = prune_receipt_claim_journal(ReceiptClaim::ok( + self.assessor_image_id + .context("assessor image id is only set in R0 assessor mode")?, + assessor_journal.clone(), + )); + claims.push(assessor_claim.clone()); + claim_digests.push(assessor_claim.digest()); + Some((assessor_receipt_id, assessor_claim)) + } + }; tracing::debug!("Finalizing"); - let root_receipt_id = self - .finalize( - claims.clone(), - [proof_ids.as_slice(), std::slice::from_ref(&assessor_receipt_id)].concat(), - ) - .await?; + let mut assumption_ids = proof_ids.clone(); + if let Some((assessor_receipt_id, _)) = &assessor_r0 { + assumption_ids.push(assessor_receipt_id.clone()); + } + let root_receipt_id = self.finalize(claims.clone(), assumption_ids).await?; let compressed_receipt_bytes = self .prover .get_compressed_receipt(&root_receipt_id) @@ -616,18 +660,41 @@ impl OrderFulfiller { boundless_fills.push(fulfillment); } - let assessor_inclusion_receipt = SetInclusionReceipt::from_path_with_verifier_params( - assessor_claim, - merkle_path(&claim_digests, claim_digests.len() - 1), - verifier_parameters.digest(), - ); - - // The on-chain assessor seal is `router assessor selector ++ inner seal`. Callbacks and - // selectors are no longer submitted; they are derived on-chain from the signed SlimRequest. - let assessor_seal = boundless_market::contracts::assessor_seal( - self.assessor_selector, - assessor_inclusion_receipt.abi_encode_seal()?, - ); + // The assessor seal is `router assessor selector ++ inner seal`. Callbacks and selectors + // are no longer submitted; they are derived on-chain from the signed SlimRequest. + let assessor_seal = match &self.assessor_mode { + AssessorMode::R0 { selector } => { + let (_, assessor_claim) = + assessor_r0.context("the R0 assessor mode proves the assessor guest")?; + let assessor_inclusion_receipt = + SetInclusionReceipt::from_path_with_verifier_params( + assessor_claim, + merkle_path(&claim_digests, claim_digests.len() - 1), + verifier_parameters.digest(), + ); + boundless_market::contracts::assessor_seal( + *selector, + assessor_inclusion_receipt.abi_encode_seal()?, + ) + } + AssessorMode::Onchain { selector, adapter, signer } => { + // The signed batch authorization must cover exactly the fills being submitted. + let requests: Vec = + successful_indices.iter().map(|&idx| orders[idx].0.clone()).collect(); + build_onchain_assessor_seal( + signer, + *selector, + *adapter, + self.domain.chain_id, + &self.domain.alloy_struct(), + self.address, + &requests, + &boundless_fills, + ) + .await + .context("Failed to build the on-chain assessor seal")? + } + }; Ok((boundless_fills, root_receipt, assessor_seal)) } @@ -657,7 +724,7 @@ mod tests { }; use boundless_test_utils::{ guests::{ASSESSOR_GUEST_PATH, ECHO_ID, ECHO_PATH}, - market::{create_test_ctx, ASSESSOR_R0_SELECTOR}, + market::{create_test_ctx, ASSESSOR_ONCHAIN_SELECTOR, ASSESSOR_R0_SELECTOR}, }; use std::sync::Arc; @@ -703,7 +770,7 @@ mod tests { let mut fulfiller = OrderFulfiller::initialize( prover, &client, - ASSESSOR_R0_SELECTOR, + AssessorMode::R0 { selector: ASSESSOR_R0_SELECTOR }, &format!("file://{ASSESSOR_GUEST_PATH}"), ) .await @@ -730,7 +797,7 @@ mod tests { let mut fulfiller = OrderFulfiller::initialize( prover, &client, - ASSESSOR_R0_SELECTOR, + AssessorMode::R0 { selector: ASSESSOR_R0_SELECTOR }, &format!("file://{ASSESSOR_GUEST_PATH}"), ) .await @@ -740,6 +807,68 @@ mod tests { fulfiller.fulfill(&[(request, signature.as_bytes().into())]).await.unwrap(); } + #[tokio::test] + #[cfg_attr(not(feature = "test-r0vm"), ignore = "runs a proof; slow without RISC0_DEV_MODE=1")] + async fn test_fulfill_onchain_assessor() { + use alloy::sol_types::SolStruct; + use boundless_market::contracts::{ + fulfillment_batch_auth_signing_hash, onchain_assessor_eip712_domain, + }; + + let anvil = Anvil::new().spawn(); + let ctx = create_test_ctx(&anvil).await.unwrap(); + let client = boundless_market::Client::new( + ctx.customer_market.clone(), + ctx.set_verifier.clone(), + StandardDownloader::new().await, + ); + + let signer = PrivateKeySigner::random(); + let (request, signature) = setup_proving_request_and_signature(&signer, None).await; + + let adapter = + ctx.customer_market.router_entry_impl(ASSESSOR_ONCHAIN_SELECTOR).await.unwrap(); + assert_ne!(adapter, Address::ZERO); + + let prover: Arc = Arc::new(BrokerDefaultProver::default()); + let mut fulfiller = OrderFulfiller::initialize( + prover, + &client, + AssessorMode::Onchain { + selector: ASSESSOR_ONCHAIN_SELECTOR, + adapter, + signer: ctx.customer_signer.clone(), + }, + &format!("file://{ASSESSOR_GUEST_PATH}"), + ) + .await + .unwrap(); + fulfiller.domain = eip712_domain(Address::ZERO, 1); + + let (fills, _root_receipt, assessor_seal) = + fulfiller.fulfill(&[(request.clone(), signature.as_bytes().into())]).await.unwrap(); + + // The seal is the 4-byte on-chain assessor selector followed by a 65-byte ECDSA + // signature that recovers to the prover over the hash `OnChainAssessor` reconstructs. + assert_eq!(fills.len(), 1); + assert_eq!(assessor_seal.len(), 69); + assert_eq!(&assessor_seal[..4], ASSESSOR_ONCHAIN_SELECTOR.as_slice()); + + let prover_address = ctx.customer_signer.address(); + let market_domain = eip712_domain(Address::ZERO, 1).alloy_struct(); + let hash = fulfillment_batch_auth_signing_hash( + &onchain_assessor_eip712_domain(adapter, 1), + prover_address, + &[request.eip712_signing_hash(&market_domain)], + &[fills[0].claimDigest], + ); + let recovered = Signature::try_from(&assessor_seal[4..]) + .unwrap() + .recover_address_from_prehash(&hash) + .unwrap(); + assert_eq!(recovered, prover_address); + } + #[tokio::test] #[cfg_attr(not(feature = "test-r0vm"), ignore = "runs a proof; slow without RISC0_DEV_MODE=1")] async fn test_fulfill_blake3_groth16_selector() { @@ -774,7 +903,7 @@ mod tests { let mut fulfiller = OrderFulfiller::initialize( prover, &client, - ASSESSOR_R0_SELECTOR, + AssessorMode::R0 { selector: ASSESSOR_R0_SELECTOR }, &format!("file://{ASSESSOR_GUEST_PATH}"), ) .await diff --git a/crates/indexer/src/market/caching/file.rs b/crates/indexer/src/market/caching/file.rs index 0c59184c56..76727f805c 100644 --- a/crates/indexer/src/market/caching/file.rs +++ b/crates/indexer/src/market/caching/file.rs @@ -251,7 +251,7 @@ mod tests { let prover = OrderFulfiller::initialize( Arc::new(DefaultProver::default()), &client, - ASSESSOR_R0_SELECTOR, + boundless_cli::AssessorMode::R0 { selector: ASSESSOR_R0_SELECTOR }, &format!("file://{ASSESSOR_GUEST_PATH}"), ) .await diff --git a/crates/indexer/tests/market/common.rs b/crates/indexer/tests/market/common.rs index 95547c78cb..4bf4857556 100644 --- a/crates/indexer/tests/market/common.rs +++ b/crates/indexer/tests/market/common.rs @@ -73,7 +73,7 @@ pub async fn new_market_test_fixture( let prover = OrderFulfiller::initialize( Arc::new(BrokerDefaultProver::default()), &client, - ASSESSOR_R0_SELECTOR, + boundless_cli::AssessorMode::R0 { selector: ASSESSOR_R0_SELECTOR }, &format!("file://{ASSESSOR_GUEST_PATH}"), ) .await diff --git a/crates/slasher/tests/basic.rs b/crates/slasher/tests/basic.rs index e761ec7fd4..03cf7d80b9 100644 --- a/crates/slasher/tests/basic.rs +++ b/crates/slasher/tests/basic.rs @@ -274,7 +274,7 @@ async fn test_slash_fulfilled(pool: sqlx::PgPool) { let fulfiller = OrderFulfiller::initialize( prover, &client, - ASSESSOR_R0_SELECTOR, + boundless_cli::AssessorMode::R0 { selector: ASSESSOR_R0_SELECTOR }, &format!("file://{ASSESSOR_GUEST_PATH}"), ) .await From 146423268a3aa0d4b7c8358506432f8539616203 Mon Sep 17 00:00:00 2001 From: jonastheis <4181434+jonastheis@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:02:03 +0800 Subject: [PATCH 2/3] fix(market): wait for the head to pass the commit block before revealing The open-path reveal is broadcast via a send() whose gas estimation simulates against the current head. On nodes where the pending tag aliases latest (op-geth), that is the commit's own block, where committedBlock + COMMIT_REVEAL_MIN_BLOCKS > block.number holds and the estimation reverts MissingFulfillmentCommitment before the reveal is ever broadcast. Awaiting the commit receipt only guarantees the reveal is mined in a later block, not that it is estimated against one. After recording the commitment, wait (bounded) for the chain head to pass the commit block so the estimation context satisfies the same condition the mined transaction will. Chains that only mine on demand (anvil) time out of the grace period and proceed; their estimation runs on a next-block env and passes anyway. Found live on Base Sepolia staging, where the race hit on every first attempt; verified fixed there via both the CLI fulfiller and the broker open-path submission. --- .../src/contracts/boundless_market.rs | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/boundless-market/src/contracts/boundless_market.rs b/crates/boundless-market/src/contracts/boundless_market.rs index 1163652485..056ed16f08 100644 --- a/crates/boundless-market/src/contracts/boundless_market.rs +++ b/crates/boundless-market/src/contracts/boundless_market.rs @@ -903,8 +903,11 @@ impl BoundlessMarketService

{ /// Records a fulfillment commitment for the open path (front-running guard, #2052). /// /// The market requires the commitment to have been recorded in a strictly earlier block - /// than the reveal, so callers must await this receipt before broadcasting the `fulfill`: - /// awaiting it guarantees the reveal is mined at least one block later. + /// than the reveal. Awaiting the commit receipt guarantees the reveal is *mined* at least + /// one block later, but not that it is *broadcast*: the reveal's gas estimation simulates + /// against the current head, and on nodes where the pending tag aliases latest (op-geth) + /// that is the commit's own block, where the age check reverts. This method therefore also + /// waits for the head to pass the commit block before returning. pub async fn commit_fulfillment(&self, commitment: B256) -> Result<(), MarketError> { tracing::trace!("Calling commitFulfillment({commitment:x})"); let call = self.instance.commitFulfillment(commitment).from(self.caller); @@ -912,6 +915,27 @@ impl BoundlessMarketService

{ tracing::debug!("Broadcasting commit tx {}", pending_tx.tx_hash()); let receipt = self.get_receipt_with_retry(pending_tx).await?; tracing::debug!("Fulfillment commitment recorded in tx {}", receipt.transaction_hash); + + // The reveal must execute in a strictly later block, and the caller's next step is a + // `send()` whose gas estimation simulates against the current head: on nodes that + // estimate against the latest block (e.g. op-geth, where the pending tag aliases + // latest), estimating in the commit's own block reverts `MissingFulfillmentCommitment` + // before the reveal is ever broadcast. Wait for the head to pass the commit block. + // Bounded: chains that only mine on demand (anvil) never advance here, but their + // estimation runs on a next-block env and passes anyway. + if let Some(commit_block) = receipt.block_number { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while self.get_latest_block_number().await? <= commit_block { + if tokio::time::Instant::now() >= deadline { + tracing::debug!( + "Chain head did not pass the commit block within the grace period; \ + proceeding to the reveal" + ); + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + } Ok(()) } From 7571264059e53e70e3ca39825cf11eb754c2c80a Mon Sep 17 00:00:00 2001 From: jonastheis <4181434+jonastheis@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:11:49 +0800 Subject: [PATCH 3/3] fix(market): retry the open-path reveal instead of pre-waiting for the head The unconditional post-commit wait burned its full grace period on chains that only mine on demand (anvil), delaying every open-path reveal by ~10s and handing the slasher's poll loop a guaranteed win over the reveal in test_slash_fulfilled - turning a pre-existing flaky race into a deterministic CI failure. Drop the wait and retry the reveal dispatch (bounded) when its gas estimation reverts MissingFulfillmentCommitment: nodes that estimate on a next-block env (anvil, L1 geth) pass on the first attempt with no added latency, while nodes where the pending tag aliases latest (op-geth) retry until the head passes the commit block - the case observed live on Base Sepolia staging. --- .../src/contracts/boundless_market.rs | 177 ++++++++++-------- 1 file changed, 97 insertions(+), 80 deletions(-) diff --git a/crates/boundless-market/src/contracts/boundless_market.rs b/crates/boundless-market/src/contracts/boundless_market.rs index 056ed16f08..18684e5e2e 100644 --- a/crates/boundless-market/src/contracts/boundless_market.rs +++ b/crates/boundless-market/src/contracts/boundless_market.rs @@ -904,10 +904,8 @@ impl BoundlessMarketService

{ /// /// The market requires the commitment to have been recorded in a strictly earlier block /// than the reveal. Awaiting the commit receipt guarantees the reveal is *mined* at least - /// one block later, but not that it is *broadcast*: the reveal's gas estimation simulates - /// against the current head, and on nodes where the pending tag aliases latest (op-geth) - /// that is the commit's own block, where the age check reverts. This method therefore also - /// waits for the head to pass the commit block before returning. + /// one block later; whether its gas estimation also sees a later block depends on the + /// node — see the reveal retry in [`BoundlessMarketService::fulfill`]. pub async fn commit_fulfillment(&self, commitment: B256) -> Result<(), MarketError> { tracing::trace!("Calling commitFulfillment({commitment:x})"); let call = self.instance.commitFulfillment(commitment).from(self.caller); @@ -915,27 +913,6 @@ impl BoundlessMarketService

{ tracing::debug!("Broadcasting commit tx {}", pending_tx.tx_hash()); let receipt = self.get_receipt_with_retry(pending_tx).await?; tracing::debug!("Fulfillment commitment recorded in tx {}", receipt.transaction_hash); - - // The reveal must execute in a strictly later block, and the caller's next step is a - // `send()` whose gas estimation simulates against the current head: on nodes that - // estimate against the latest block (e.g. op-geth, where the pending tag aliases - // latest), estimating in the commit's own block reverts `MissingFulfillmentCommitment` - // before the reveal is ever broadcast. Wait for the head to pass the commit block. - // Bounded: chains that only mine on demand (anvil) never advance here, but their - // estimation runs on a next-block env and passes anyway. - if let Some(commit_block) = receipt.block_number { - let deadline = tokio::time::Instant::now() + Duration::from_secs(10); - while self.get_latest_block_number().await? <= commit_block { - if tokio::time::Instant::now() >= deadline { - tracing::debug!( - "Chain head did not pass the commit block within the grace period; \ - proceeding to the reveal" - ); - break; - } - tokio::time::sleep(Duration::from_millis(500)).await; - } - } Ok(()) } @@ -987,64 +964,104 @@ impl BoundlessMarketService

{ self.commit_fulfillment(commitment).await?; } - match root { - None => match (price, withdraw) { - (false, false) => { - tracing::debug!("Fulfilling requests {:?} with fulfill", request_ids); - self._fulfill(fulfillment_batches).await - } - (false, true) => { - tracing::debug!( - "Fulfilling requests {:?} with fulfill and withdraw", - request_ids - ); - self.fulfill_and_withdraw(fulfillment_batches).await - } - (true, false) => { - tracing::debug!("Fulfilling requests {:?} with price and fulfill", request_ids); - self.price_and_fulfill(request_batches, fulfillment_batches).await - } - (true, true) => { - tracing::debug!( - "Fulfilling requests {:?} with price and fulfill and withdraw", - request_ids - ); - self.price_and_fulfill_and_withdraw(request_batches, fulfillment_batches).await - } - }, - Some(root) => match (price, withdraw) { - (false, false) => { - tracing::debug!( - "Fulfilling requests {:?} with submitting root and fulfill", - request_ids - ); - self.submit_root_and_fulfill(root, fulfillment_batches).await - } - (false, true) => { - tracing::debug!( - "Fulfilling requests {:?} with submitting root and fulfill and withdraw", - request_ids - ); - self.submit_root_and_fulfill_and_withdraw(root, fulfillment_batches).await - } - (true, false) => { + // The reveal must land in a strictly later block than the commit, and its gas + // estimation simulates against the current head: on nodes where the pending tag + // aliases latest (op-geth), that is the commit's own block, where the age check still + // fails and estimation reverts `MissingFulfillmentCommitment` before the reveal is + // ever broadcast. Retry until the head passes the commit block (bounded); nodes that + // estimate on a next-block env (anvil, L1 geth) pass on the first attempt. + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + loop { + let result = match &root { + None => match (price, withdraw) { + (false, false) => { + tracing::debug!("Fulfilling requests {:?} with fulfill", request_ids); + self._fulfill(fulfillment_batches.clone()).await + } + (false, true) => { + tracing::debug!( + "Fulfilling requests {:?} with fulfill and withdraw", + request_ids + ); + self.fulfill_and_withdraw(fulfillment_batches.clone()).await + } + (true, false) => { + tracing::debug!( + "Fulfilling requests {:?} with price and fulfill", + request_ids + ); + self.price_and_fulfill(request_batches.clone(), fulfillment_batches.clone()) + .await + } + (true, true) => { + tracing::debug!( + "Fulfilling requests {:?} with price and fulfill and withdraw", + request_ids + ); + self.price_and_fulfill_and_withdraw( + request_batches.clone(), + fulfillment_batches.clone(), + ) + .await + } + }, + Some(root) => match (price, withdraw) { + (false, false) => { + tracing::debug!( + "Fulfilling requests {:?} with submitting root and fulfill", + request_ids + ); + self.submit_root_and_fulfill(root.clone(), fulfillment_batches.clone()) + .await + } + (false, true) => { + tracing::debug!( + "Fulfilling requests {:?} with submitting root and fulfill and withdraw", + request_ids + ); + self.submit_root_and_fulfill_and_withdraw( + root.clone(), + fulfillment_batches.clone(), + ) + .await + } + (true, false) => { + tracing::debug!( + "Fulfilling requests {:?} with submitting root and price and fulfill", + request_ids + ); + self.submit_root_and_price_fulfill( + root.clone(), + request_batches.clone(), + fulfillment_batches.clone(), + ) + .await + } + (true, true) => { + tracing::debug!("Fulfilling requests {:?} with submitting root and price and fulfill and withdraw", request_ids); + self.submit_root_and_price_fulfill_and_withdraw( + root.clone(), + request_batches.clone(), + fulfillment_batches.clone(), + ) + .await + } + }, + }; + match result { + Err(err) + if price + && format!("{err:?}").contains("MissingFulfillmentCommitment") + && tokio::time::Instant::now() < deadline => + { tracing::debug!( - "Fulfilling requests {:?} with submitting root and price and fulfill", - request_ids + "Reveal was estimated in the commit's own block; retrying once the \ + head advances" ); - self.submit_root_and_price_fulfill(root, request_batches, fulfillment_batches) - .await - } - (true, true) => { - tracing::debug!("Fulfilling requests {:?} with submitting root and price and fulfill and withdraw", request_ids); - self.submit_root_and_price_fulfill_and_withdraw( - root, - request_batches, - fulfillment_batches, - ) - .await + tokio::time::sleep(Duration::from_millis(500)).await; } - }, + result => return result, + } } }