diff --git a/Cargo.lock b/Cargo.lock index 1b267a8a..81c83065 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,6 +140,7 @@ dependencies = [ "eyre", "futures", "proposer-client", + "proposer-elfs", "proposer-service", "prover-alloy", "serde", diff --git a/crates/aggchain-proof-builder/src/lib.rs b/crates/aggchain-proof-builder/src/lib.rs index eca82a1f..200c5671 100644 --- a/crates/aggchain-proof-builder/src/lib.rs +++ b/crates/aggchain-proof-builder/src/lib.rs @@ -342,21 +342,25 @@ impl AggchainProofBuilder { let prover = Buffer::new(executor, MAX_CONCURRENT_REQUESTS); - // Retrieve the entire aggregation vkey and the range vkey commitment from the - // ELF - let aggregation_vkey = proposer_elfs::aggregation::VKEY.vkey().clone(); - let range_vkey_commitment = Digest(proposer_elfs::range::VKEY_COMMITMENT); - - // Check mismatch on aggregation vkey + // Resolve the aggregation vkey and range vkey commitment. These use the + // configured op-succinct override when one was installed at startup (see + // `proposer_elfs::install_overrides`), otherwise the values embedded from + // op-succinct-elfs at build time. + let aggregation_vkey = Arc::new(proposer_elfs::aggregation::vkey().clone()); + let range_vkey_commitment = Digest(proposer_elfs::range::commitment()); + + // Sanity-check that the embedded op-succinct-elfs vkey constants are + // internally consistent. The resolved (possibly overridden) key is + // validated against the on-chain op-succinct config below instead. { - let retrieved = sp1_fast(|| VKeyHash::from_vkey(&aggregation_vkey)) - .context("Computing VKey hash")?; - let expected = AGGREGATION_VKEY_HASH; + let retrieved = + sp1_fast(|| VKeyHash::from_vkey(proposer_elfs::aggregation::VKEY.vkey())) + .context("Computing VKey hash")?; - if retrieved != expected { + if retrieved != AGGREGATION_VKEY_HASH { return Err(eyre::Report::from(Error::MismatchAggregationElfVkeyHash { got: retrieved, - expected, + expected: AGGREGATION_VKEY_HASH, })); } } @@ -371,7 +375,7 @@ impl AggchainProofBuilder { // Validate that the OpSuccinct config keys match expected values validate_op_succinct_config_keys( &op_succinct_config, - &aggregation_vkey, + aggregation_vkey.as_ref(), &range_vkey_commitment, )?; @@ -380,7 +384,7 @@ impl AggchainProofBuilder { contracts_client, prover, network_id: config.network_id, - aggregation_vkey: Arc::new(aggregation_vkey), + aggregation_vkey, range_vkey_commitment, static_call_caller_address: config.contracts.static_call_caller_address, }) @@ -529,7 +533,7 @@ impl AggchainProofBuilder { l1_info_tree_leaf, l1_head_inclusion_proof: request.aggchain_proof_inputs.l1_info_tree_merkle_proof, aggregation_vkey_hash: KoalaBearDigest(aggregation_vkey.hash_u32()), - range_vkey_commitment: RANGE_VKEY_COMMITMENT, + range_vkey_commitment: range_vkey_commitment.0, }; { diff --git a/crates/aggchain-proof-service/Cargo.toml b/crates/aggchain-proof-service/Cargo.toml index 3e04175a..c92777c4 100644 --- a/crates/aggchain-proof-service/Cargo.toml +++ b/crates/aggchain-proof-service/Cargo.toml @@ -15,6 +15,7 @@ aggchain-proof-core.workspace = true aggchain-proof-types.workspace = true eyre.workspace = true proposer-client.workspace = true +proposer-elfs.workspace = true proposer-service.workspace = true prover-alloy.workspace = true unified-bridge.workspace = true diff --git a/crates/aggchain-proof-service/src/config.rs b/crates/aggchain-proof-service/src/config.rs index 71a6dd59..f8103524 100644 --- a/crates/aggchain-proof-service/src/config.rs +++ b/crates/aggchain-proof-service/src/config.rs @@ -1,5 +1,3 @@ -use std::fmt::Debug; - use aggchain_proof_builder::config::AggchainProofBuilderConfig; use proposer_service::config::ProposerServiceConfig; use serde::{Deserialize, Serialize}; @@ -10,4 +8,73 @@ use serde::{Deserialize, Serialize}; pub struct AggchainProofServiceConfig { pub aggchain_proof_builder: AggchainProofBuilderConfig, pub proposer_service: ProposerServiceConfig, + + /// Optional overrides for the op-succinct verification key material. + /// + /// When unset, the values embedded from `op-succinct-elfs` at build time + /// are used. Supplying them here lets an op-succinct upgrade be rolled out + /// without rebuilding the aggkit-prover image. + #[serde(default, skip_serializing_if = "OpSuccinctVkeyConfig::is_empty")] + pub op_succinct: OpSuccinctVkeyConfig, +} + +/// Optional overrides of the op-succinct verification key material derived from +/// a given op-succinct release. Each field independently falls back to the +/// value embedded from `op-succinct-elfs` when absent. +/// +/// Both values are hex strings handled by their types' existing serde; the +/// aggregation vkey bytes are turned into a real `SP1VerifyingKey` with the +/// same bincode codec the prover uses elsewhere. Produce them with the +/// `op-succinct-vkey` CLI subcommand. +#[derive(Serialize, Deserialize, Clone, Debug, Default)] +#[serde(rename_all = "kebab-case")] +pub struct OpSuccinctVkeyConfig { + /// Bincode-serialized aggregation `SP1VerifyingKey`, hex-encoded (`0x` + /// prefix optional). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub aggregation_vkey: Option, + + /// Range vkey commitment, hex-encoded 32 bytes (`0x` prefix optional). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub range_vkey_commitment: Option, +} + +impl OpSuccinctVkeyConfig { + /// Returns `true` when no override is set, so the section can be omitted + /// from serialized configuration. + fn is_empty(&self) -> bool { + self.aggregation_vkey.is_none() && self.range_vkey_commitment.is_none() + } +} + +#[cfg(test)] +mod tests { + use proposer_elfs::{Sp1VKeyHash as _, VKeyHash}; + + use super::*; + + #[test] + fn op_succinct_aggregation_vkey_round_trips_through_config() { + // `Bytes` carries the hex value through serde, and the existing bincode + // codec turns it back into the real verifying key. Use a real serialized + // vkey: the one embedded from op-succinct-elfs. + let encoded = alloy_primitives::hex::encode(proposer_elfs::aggregation::VKEY.as_bytes()); + let config: OpSuccinctVkeyConfig = + serde_json::from_str(&format!(r#"{{ "aggregation-vkey": "0x{encoded}" }}"#)) + .expect("parsing aggregation vkey"); + + let bytes = config.aggregation_vkey.expect("aggregation vkey present"); + let vkey = proposer_elfs::decode_verifying_key(bytes.as_ref()).expect("decodes"); + assert_eq!( + VKeyHash::from_vkey(&vkey), + VKeyHash::from_vkey(proposer_elfs::aggregation::VKEY.vkey()), + ); + } + + #[test] + fn op_succinct_overrides_default_to_none() { + let config: OpSuccinctVkeyConfig = + serde_json::from_str("{}").expect("parsing empty overrides"); + assert!(config.is_empty()); + } } diff --git a/crates/aggchain-proof-service/src/error.rs b/crates/aggchain-proof-service/src/error.rs index ac68c0ff..d6abc3a4 100644 --- a/crates/aggchain-proof-service/src/error.rs +++ b/crates/aggchain-proof-service/src/error.rs @@ -26,4 +26,7 @@ pub enum Error { #[error("Unable to resolve aggchain proof vkey")] AggchainProofVkeyResolveFailed(#[source] aggchain_proof_contracts::Error), + + #[error("Unable to decode the configured op-succinct aggregation verification key")] + OpSuccinctVkeyDecode(#[source] proposer_elfs::VKeyDecodeError), } diff --git a/crates/aggchain-proof-service/src/service.rs b/crates/aggchain-proof-service/src/service.rs index a476df89..9dcd437f 100644 --- a/crates/aggchain-proof-service/src/service.rs +++ b/crates/aggchain-proof-service/src/service.rs @@ -13,8 +13,9 @@ use alloy_primitives::B256; use futures::FutureExt as _; use proposer_client::FepProposerRequest; use proposer_service::ProposerService; +use sp1_sdk::HashableKey as _; use tower::{util::BoxCloneService, Service as _, ServiceExt as _}; -use tracing::debug; +use tracing::{debug, info}; use unified_bridge::AggchainProofPublicValues; use crate::{ @@ -88,6 +89,51 @@ pub struct AggchainProofService { impl AggchainProofService { pub async fn new(config: &AggchainProofServiceConfig) -> Result { debug!("Initializing AggchainProofService"); + + // Install the optional op-succinct vkey overrides from configuration before + // constructing the services, so the proposer service (host-side + // verification) and the proof builder (recursive verification) both read + // the same in-effect values via `proposer_elfs`. When absent, the values + // embedded from op-succinct-elfs are used. + proposer_elfs::install_overrides( + config + .op_succinct + .aggregation_vkey + .as_ref() + .map(|vkey| vkey.as_ref()), + config + .op_succinct + .range_vkey_commitment + .map(|digest| digest.0), + ) + .map_err(Error::OpSuccinctVkeyDecode)?; + + // Report the op-succinct verification keys in effect, and whether each came + // from a config override or the embedded op-succinct-elfs default, so the + // active keys can be confirmed at runtime. + let source = |overridden: bool| { + if overridden { + "config override" + } else { + "embedded (op-succinct-elfs)" + } + }; + let aggregation_vkey_hash = format!( + "0x{}", + alloy_primitives::hex::encode(proposer_elfs::aggregation::vkey().bytes32_raw()) + ); + let range_vkey_commitment = format!( + "0x{}", + alloy_primitives::hex::encode(proposer_elfs::range::commitment()) + ); + info!( + aggregation_vkey_source = source(config.op_succinct.aggregation_vkey.is_some()), + %aggregation_vkey_hash, + range_vkey_commitment_source = source(config.op_succinct.range_vkey_commitment.is_some()), + %range_vkey_commitment, + "Resolved op-succinct verification keys", + ); + let client = prover_alloy::AlloyProvider::new( &config.proposer_service.l1_rpc_endpoint.url, prover_alloy::DEFAULT_HTTP_RPC_NODE_INITIAL_BACKOFF_MS, diff --git a/crates/aggkit-prover-types/src/vkey.rs b/crates/aggkit-prover-types/src/vkey.rs index 42c1fcbd..db09e326 100644 --- a/crates/aggkit-prover-types/src/vkey.rs +++ b/crates/aggkit-prover-types/src/vkey.rs @@ -61,6 +61,37 @@ impl UpperHex for LazyVerifyingKey { } } +/// Error returned when a configured verifying key cannot be decoded. +#[derive(Debug, thiserror::Error)] +#[error("failed to decode SP1 verifying key from the configured bytes: {0}")] +pub struct VKeyDecodeError(String); + +/// Error returned when a verifying key cannot be encoded. +#[derive(Debug, thiserror::Error)] +#[error("failed to encode SP1 verifying key: {0}")] +pub struct VKeyEncodeError(String); + +/// Decode a bincode-encoded [`SP1VerifyingKey`], as produced by the build-time +/// `prover_elf_utils::ElfInfo::emit_vkey_bytes` / +/// [`LazyVerifyingKey::as_bytes`]. +/// +/// This must use the exact same codec as [`LazyVerifyingKey::vkey`] so that a +/// configured override and the embedded fallback decode identically. +pub fn decode_verifying_key(bytes: &[u8]) -> Result { + prover_elf_utils::elf_info::bincode_codec() + .deserialize(bytes) + .map_err(|error| VKeyDecodeError(error.to_string())) +} + +/// Encode an [`SP1VerifyingKey`] into the bincode representation accepted by +/// [`decode_verifying_key`]. Uses the same codec as [`decode_verifying_key`], +/// so the two are guaranteed to round-trip. +pub fn encode_verifying_key(vkey: &SP1VerifyingKey) -> Result, VKeyEncodeError> { + prover_elf_utils::elf_info::bincode_codec() + .serialize(vkey) + .map_err(|error| VKeyEncodeError(error.to_string())) +} + #[cfg(test)] mod test { use super::*; diff --git a/crates/aggkit-prover/Cargo.toml b/crates/aggkit-prover/Cargo.toml index 588ef18d..1e3a5f12 100644 --- a/crates/aggkit-prover/Cargo.toml +++ b/crates/aggkit-prover/Cargo.toml @@ -30,7 +30,7 @@ tracing.workspace = true aggchain-proof-service.workspace = true aggchain-proof-types.workspace = true aggkit-prover-config.workspace = true -aggkit-prover-types.workspace = true +aggkit-prover-types = { workspace = true, features = ["sp1"] } agglayer-interop = { workspace = true, features = ["grpc-compat"] } proposer-client.workspace = true proposer-service.workspace = true diff --git a/crates/aggkit-prover/src/cli/mod.rs b/crates/aggkit-prover/src/cli/mod.rs index c454ec17..ebf22ded 100644 --- a/crates/aggkit-prover/src/cli/mod.rs +++ b/crates/aggkit-prover/src/cli/mod.rs @@ -34,4 +34,14 @@ pub enum Commands { /// Proof verification key selector. VkeySelector, + + /// Derive the op-succinct vkey override config values from a directory + /// containing the op-succinct ELFs (`aggregation-elf` and + /// `range-elf-embedded`). Prints a ready-to-paste + /// `[aggchain-proof-service.op-succinct]` section. + OpSuccinctVkey { + /// Path to the directory holding the op-succinct ELF binaries. + #[arg(long, value_hint = ValueHint::DirPath)] + elf_dir: PathBuf, + }, } diff --git a/crates/aggkit-prover/src/main.rs b/crates/aggkit-prover/src/main.rs index 5904874f..28f4e5a2 100644 --- a/crates/aggkit-prover/src/main.rs +++ b/crates/aggkit-prover/src/main.rs @@ -50,6 +50,50 @@ fn main() -> eyre::Result<()> { let vkey_selector_hex = hex::encode(AGGCHAIN_VKEY_SELECTOR.to_be_bytes()); println!("0x{vkey_selector_hex}"); } + + aggkit_prover::cli::Commands::OpSuccinctVkey { elf_dir } => { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(async move { + // The CLI is short-lived, so leaking the ELF bytes to satisfy the + // `'static` bound of `compute_program_vkey` is acceptable. + let read_elf = |name: &str| -> eyre::Result<&'static [u8]> { + let path = elf_dir.join(name); + let bytes = std::fs::read(&path) + .with_context(|| format!("Reading ELF {}", path.display()))?; + let leaked: &'static [u8] = Box::leak(bytes.into_boxed_slice()); + Ok(leaked) + }; + + let aggregation_vkey = prover_executor::Executor::compute_program_vkey( + read_elf("aggregation-elf")?, + ) + .await?; + let range_vkey = prover_executor::Executor::compute_program_vkey(read_elf( + "range-elf-embedded", + )?) + .await?; + + let aggregation_vkey_bytes = + aggkit_prover_types::vkey::encode_verifying_key(&aggregation_vkey)?; + + println!("[aggchain-proof-service.op-succinct]"); + println!( + "aggregation-vkey = \"0x{}\"", + hex::encode(&aggregation_vkey_bytes) + ); + println!( + "range-vkey-commitment = \"0x{}\"", + hex::encode(range_vkey.hash_bytes()) + ); + println!( + "# aggregation on-chain vkey hash (bytes32): 0x{}", + hex::encode(aggregation_vkey.bytes32_raw()) + ); + Ok::<(), eyre::Report>(()) + })?; + } } Ok(()) diff --git a/crates/proposer-elfs/src/lib.rs b/crates/proposer-elfs/src/lib.rs index df468cd3..2e27fda4 100644 --- a/crates/proposer-elfs/src/lib.rs +++ b/crates/proposer-elfs/src/lib.rs @@ -1,5 +1,5 @@ pub use aggkit_prover_types::{ - vkey::LazyVerifyingKey, + vkey::{decode_verifying_key, LazyVerifyingKey, SP1VerifyingKey, VKeyDecodeError}, vkey_hash::{HashU32, Sp1VKeyHash, VKeyHash}, }; @@ -8,9 +8,11 @@ mod vkeys_raw { } pub mod aggregation { + use std::sync::OnceLock; + pub use op_succinct_elfs::AGGREGATION_ELF as ELF; - use crate::{vkeys_raw, HashU32, LazyVerifyingKey, VKeyHash}; + use crate::{vkeys_raw, HashU32, LazyVerifyingKey, SP1VerifyingKey, VKeyHash}; pub static VKEY: LazyVerifyingKey = LazyVerifyingKey::from_unparsed_bytes(vkeys_raw::aggregation::VKEY_BYTES); @@ -18,9 +20,21 @@ pub mod aggregation { pub const VKEY_HASH_U32: HashU32 = vkeys_raw::aggregation::VKEY_HASH; pub const VKEY_HASH: VKeyHash = VKeyHash::from_hash_u32(VKEY_HASH_U32); + + /// Configured override, installed once at startup via + /// [`crate::install_overrides`]. + pub(crate) static VKEY_OVERRIDE: OnceLock = OnceLock::new(); + + /// The aggregation verifying key in effect: the configured override when + /// set, otherwise the key embedded from op-succinct-elfs at build time. + pub fn vkey() -> &'static SP1VerifyingKey { + VKEY_OVERRIDE.get().unwrap_or_else(|| VKEY.vkey()) + } } pub mod range { + use std::sync::OnceLock; + pub use op_succinct_elfs::RANGE_ELF_EMBEDDED as ELF; pub use vkeys_raw::range::VKEY_COMMITMENT; @@ -32,6 +46,42 @@ pub mod range { pub const VKEY_HASH_U32: HashU32 = vkeys_raw::range::VKEY_HASH; pub const VKEY_HASH: VKeyHash = VKeyHash::from_hash_u32(VKEY_HASH_U32); + + /// Configured override, installed once at startup via + /// [`crate::install_overrides`]. + pub(crate) static VKEY_COMMITMENT_OVERRIDE: OnceLock<[u8; 32]> = OnceLock::new(); + + /// The range vkey commitment in effect: the configured override when set, + /// otherwise the value embedded from op-succinct-elfs at build time. + pub fn commitment() -> [u8; 32] { + VKEY_COMMITMENT_OVERRIDE + .get() + .copied() + .unwrap_or(VKEY_COMMITMENT) + } +} + +/// Install optional op-succinct vkey overrides parsed from configuration. +/// +/// The values are process-global and intended to be installed once at startup, +/// before the proof services are constructed; a repeated install keeps the +/// first value. When an override is absent, the value embedded from +/// `op-succinct-elfs` at build time is used instead. See [`aggregation::vkey`] +/// and [`range::commitment`] for the resolved accessors the services read. +pub fn install_overrides( + aggregation_vkey: Option<&[u8]>, + range_vkey_commitment: Option<[u8; 32]>, +) -> Result<(), VKeyDecodeError> { + // An `Err` from `set` means the value was already installed, which is + // expected on repeated startup-time calls (e.g. multiple service + // constructions in tests); the first installed value is kept. + if let Some(bytes) = aggregation_vkey { + let _ = aggregation::VKEY_OVERRIDE.set(decode_verifying_key(bytes)?); + } + if let Some(commitment) = range_vkey_commitment { + let _ = range::VKEY_COMMITMENT_OVERRIDE.set(commitment); + } + Ok(()) } #[cfg(test)] diff --git a/crates/proposer-elfs/src/test/mod.rs b/crates/proposer-elfs/src/test/mod.rs index 3f418c65..7d4e9ab6 100644 --- a/crates/proposer-elfs/src/test/mod.rs +++ b/crates/proposer-elfs/src/test/mod.rs @@ -32,3 +32,33 @@ fn snap_vkey_hash(#[case] name: &'static str, #[case] vkey: &LazyVerifyingKey) { insta::assert_snapshot!(format!("{name}_vkey"), snap); } + +/// The config-override codec must round-trip with the embedded representation: +/// the bytes emitted by `encode_verifying_key` (used by the `op-succinct-vkey` +/// CLI) must decode back into the same key the runtime falls back to. +#[rstest::rstest] +#[case::agg(&aggregation::VKEY)] +#[case::range(&range::VKEY)] +fn vkey_codec_round_trip(#[case] vkey: &LazyVerifyingKey) { + use aggkit_prover_types::vkey::{decode_verifying_key, encode_verifying_key}; + + let decoded = decode_verifying_key(&vkey.as_bytes()).expect("decoding embedded vkey"); + assert_eq!( + VKeyHash::from_vkey(&decoded), + VKeyHash::from_vkey(vkey.vkey()) + ); + + let encoded = encode_verifying_key(&decoded).expect("encoding vkey"); + assert_eq!(encoded.as_slice(), vkey.as_bytes().as_ref()); +} + +/// Decoding incomplete vkey bytes must fail rather than silently producing a +/// bogus key, so a malformed config override is rejected at startup. +#[test] +fn decode_rejects_truncated_vkey() { + let bytes = aggregation::VKEY.as_bytes(); + let full: &[u8] = bytes.as_ref(); + let truncated = &full[..full.len() / 2]; + + assert!(aggkit_prover_types::vkey::decode_verifying_key(truncated).is_err()); +} diff --git a/crates/proposer-service/src/lib.rs b/crates/proposer-service/src/lib.rs index d0926ea1..137d78fd 100644 --- a/crates/proposer-service/src/lib.rs +++ b/crates/proposer-service/src/lib.rs @@ -8,7 +8,6 @@ use agglayer_evm_client::GetBlockNumber; use alloy_sol_types::SolType; use educe::Educe; pub use error::Error; -use eyre::Context as _; use futures::{future::BoxFuture, FutureExt}; use proposer_client::{ aggregation_prover::AggregationProver, @@ -36,8 +35,6 @@ pub mod error; #[cfg(test)] mod tests; -pub const AGGREGATION_ELF: &[u8] = proposer_elfs::aggregation::ELF; - #[derive(Educe)] #[educe(Clone(bound()))] pub struct ProposerService { @@ -67,10 +64,10 @@ where .await?, ); - let aggregation_vkey = Self::extract_aggregation_vkey(&prover, AGGREGATION_ELF) - .await - .context("Retrieving aggregation vkey") - .map_err(Error::Other)?; + // Use the op-succinct aggregation vkey in effect: the configured override + // when installed at startup (see `proposer_elfs::install_overrides`), + // otherwise the value embedded from op-succinct-elfs. + let aggregation_vkey = proposer_elfs::aggregation::vkey().clone(); Ok(Self { l1_rpc, @@ -82,14 +79,6 @@ where aggregation_vkey, }) } - - async fn extract_aggregation_vkey( - prover: &Prover, - elf: &[u8], - ) -> eyre::Result { - let (_pkey, vkey) = prover.compute_pkey_vkey(elf).await?; - Ok(vkey) - } } impl