From 6d853a93ffbfa88a9b860a696ebdc83618621ad4 Mon Sep 17 00:00:00 2001 From: hellojason3 Date: Thu, 10 Sep 2026 12:44:56 +0800 Subject: [PATCH 01/13] feat(prove-proxy): add --role user|system|all argument --- client_prover/psy_core/psy_common/src/args.rs | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/client_prover/psy_core/psy_common/src/args.rs b/client_prover/psy_core/psy_common/src/args.rs index c4cb3181..9dd52e5c 100644 --- a/client_prover/psy_core/psy_common/src/args.rs +++ b/client_prover/psy_core/psy_common/src/args.rs @@ -192,12 +192,45 @@ pub struct ProverArgs { pub api_key: String, } +/// Which proof families a prove-proxy instance serves. +/// +/// `user` — wallet proofs: UPS session chain, contract calls, signatures, minifiers. +/// `system` — relayer proofs: the three bridge Groth16 methods. +/// `all` — both; intended for single-machine local testnets. +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub enum ProveProxyRole { + User, + System, + All, +} + +impl ProveProxyRole { + pub fn serves_user(&self) -> bool { + matches!(self, ProveProxyRole::User | ProveProxyRole::All) + } + + pub fn serves_system(&self) -> bool { + matches!(self, ProveProxyRole::System | ProveProxyRole::All) + } + + pub fn as_str(&self) -> &'static str { + match self { + ProveProxyRole::User => "user", + ProveProxyRole::System => "system", + ProveProxyRole::All => "all", + } + } +} + #[derive(Clone, Debug, Parser)] pub struct ProveProxyArgs { #[clap(env = "PROVE_PROXY_LISTEN_ADDR", long, default_value = "0.0.0.0:9999")] pub listen_addr: String, #[clap(env, long, default_value = "config.json", env)] pub rpc_config: String, + /// Proof families this instance serves. Defaults to user proofs only. + #[clap(env = "PROVE_PROXY_ROLE", long, value_enum, default_value_t = ProveProxyRole::User, ignore_case = true)] + pub role: ProveProxyRole, } #[derive(Clone, Debug, Parser)] @@ -258,3 +291,39 @@ pub struct ExportKeyStoreArgs { #[clap(long, env = "WALLET_PASSWORD")] pub wallet_password: String, } + +#[cfg(test)] +mod prove_proxy_role_tests { + use super::*; + use clap::Parser; + + #[test] + fn role_defaults_to_user() { + let args = ProveProxyArgs::try_parse_from(["prove-proxy"]).unwrap(); + assert_eq!(args.role, ProveProxyRole::User); + assert!(args.role.serves_user()); + assert!(!args.role.serves_system()); + } + + #[test] + fn role_parses_case_insensitively() { + let args = ProveProxyArgs::try_parse_from(["prove-proxy", "--role", "SYSTEM"]).unwrap(); + assert_eq!(args.role, ProveProxyRole::System); + assert!(!args.role.serves_user()); + assert!(args.role.serves_system()); + } + + #[test] + fn role_all_serves_both() { + let args = ProveProxyArgs::try_parse_from(["prove-proxy", "--role", "all"]).unwrap(); + assert_eq!(args.role, ProveProxyRole::All); + assert!(args.role.serves_user()); + assert!(args.role.serves_system()); + assert_eq!(args.role.as_str(), "all"); + } + + #[test] + fn role_rejects_unknown_value() { + assert!(ProveProxyArgs::try_parse_from(["prove-proxy", "--role", "bridge"]).is_err()); + } +} From 93444496ee2b96e8bc28d6ba095a4287c6b52d42 Mon Sep 17 00:00:00 2001 From: hellojason3 Date: Thu, 10 Sep 2026 13:01:20 +0800 Subject: [PATCH 02/13] refactor(prove-proxy): split into user and system rpc modules Pure move. ProveProxyServerProvider becomes UserProveProvider (UPS circuits, contract cache, coordinator RPC) and SystemProveProvider (bridge wrap circuits, Groth16 wrappers, keystore preload). Method names and bodies unchanged. --- client_prover/psy_prover/src/lib.rs | 24 +- .../psy_prover/src/local/native/mod.rs | 1 - .../src/local/native/prove_proxy/mod.rs | 17 + .../src/local/native/prove_proxy/system.rs | 650 +++++++++++++ .../src/local/native/prove_proxy/types.rs | 235 +++++ .../{prove_proxy.rs => prove_proxy/user.rs} | 884 +----------------- 6 files changed, 925 insertions(+), 886 deletions(-) create mode 100644 client_prover/psy_prover/src/local/native/prove_proxy/mod.rs create mode 100644 client_prover/psy_prover/src/local/native/prove_proxy/system.rs create mode 100644 client_prover/psy_prover/src/local/native/prove_proxy/types.rs rename client_prover/psy_prover/src/local/native/{prove_proxy.rs => prove_proxy/user.rs} (52%) diff --git a/client_prover/psy_prover/src/lib.rs b/client_prover/psy_prover/src/lib.rs index 4920c36e..32965158 100644 --- a/client_prover/psy_prover/src/lib.rs +++ b/client_prover/psy_prover/src/lib.rs @@ -4,13 +4,11 @@ pub mod signature; pub mod trace; pub mod wallet; -#[cfg(all(not(target_arch = "wasm32"), feature = "gnark-wrap"))] +#[cfg(not(target_arch = "wasm32"))] use psy_config::PSY_NETWORK_MAGIC; #[cfg(not(target_arch = "wasm32"))] use crate::local::native::faucet::PsyFaucetServerProvider; -#[cfg(all(not(target_arch = "wasm32"), feature = "gnark-wrap"))] -use crate::local::native::prove_proxy::ProveProxyServerProvider; #[cfg(not(target_arch = "wasm32"))] pub async fn run_server(args: psy_client_common::args::ProverArgs) -> anyhow::Result<()> { @@ -64,20 +62,30 @@ pub async fn run_server(args: psy_client_common::args::ProverArgs) -> anyhow::Re Ok(()) } -#[cfg(all(not(target_arch = "wasm32"), feature = "gnark-wrap"))] +#[cfg(not(target_arch = "wasm32"))] pub async fn run_prove_proxy_server(args: psy_client_common::args::ProveProxyArgs) -> anyhow::Result<()> { use std::net::SocketAddr; use hyper::Method; - use jsonrpsee::server::{ServerBuilder, ServerConfig}; + use jsonrpsee::server::{RpcModule, ServerBuilder, ServerConfig}; use psy_client_common::health::HealthLayer; use tower_http::cors::{Any, CorsLayer}; - use crate::local::native::prove_proxy::ProveProxyRpcServer; + use crate::local::native::prove_proxy::user::{ProveProxyUserRpcServer, UserProveProvider}; let psy_config = psy_config::PsyConfigGoldilocks::from_file(&args.rpc_config)?; let rpc_config = psy_config.get_current_network()?; - let prove_proxy = ProveProxyServerProvider::new_with_config(rpc_config.clone(), PSY_NETWORK_MAGIC).await?; + + let mut module = RpcModule::new(()); + let user = UserProveProvider::new_with_config(rpc_config.clone(), PSY_NETWORK_MAGIC).await?; + module.merge(user.into_rpc())?; + #[cfg(feature = "gnark-wrap")] + { + use crate::local::native::prove_proxy::system::{ProveProxySystemRpcServer, SystemProveProvider}; + let system = SystemProveProvider::new()?; + module.merge(system.into_rpc())?; + } + let cors_opts = CorsLayer::new() .allow_methods([Method::POST, Method::OPTIONS]) .allow_origin(Any) @@ -96,7 +104,7 @@ pub async fn run_prove_proxy_server(args: psy_client_common::args::ProveProxyArg .build(server_addr) .await?; - let handle = server.start(prove_proxy.into_rpc()); + let handle = server.start(module); println!("\n[CFLI:PSY_PROVE_PROXY_STARTED][{}]\n", server_addr); handle.stopped().await; Ok(()) diff --git a/client_prover/psy_prover/src/local/native/mod.rs b/client_prover/psy_prover/src/local/native/mod.rs index cb5571b1..da0f08eb 100644 --- a/client_prover/psy_prover/src/local/native/mod.rs +++ b/client_prover/psy_prover/src/local/native/mod.rs @@ -1,5 +1,4 @@ pub mod faucet; -#[cfg(feature = "gnark-wrap")] pub mod prove_proxy; use std::{sync::Arc, time::Duration}; diff --git a/client_prover/psy_prover/src/local/native/prove_proxy/mod.rs b/client_prover/psy_prover/src/local/native/prove_proxy/mod.rs new file mode 100644 index 00000000..f9f3c1dd --- /dev/null +++ b/client_prover/psy_prover/src/local/native/prove_proxy/mod.rs @@ -0,0 +1,17 @@ +//! prove-proxy: wallet-facing (user) and relayer-facing (system) proving RPCs. +//! +//! `user` and `system` are separate `#[rpc]` traits so a process can register +//! exactly one family; see `assemble_rpc_module` (Task 3). + +pub mod user; + +#[cfg(feature = "gnark-wrap")] +pub mod system; +#[cfg(feature = "gnark-wrap")] +pub mod types; + +use plonky2::plonk::config::{GenericConfig, PoseidonGoldilocksConfig}; + +pub(crate) type C = PoseidonGoldilocksConfig; +pub(crate) type F = >::F; +pub(crate) const D: usize = 2; diff --git a/client_prover/psy_prover/src/local/native/prove_proxy/system.rs b/client_prover/psy_prover/src/local/native/prove_proxy/system.rs new file mode 100644 index 00000000..f45d33a5 --- /dev/null +++ b/client_prover/psy_prover/src/local/native/prove_proxy/system.rs @@ -0,0 +1,650 @@ +use std::sync::{Arc, OnceLock}; + +use jsonrpsee::{core::async_trait, proc_macros::rpc, types::ErrorObjectOwned}; +use parth_core::{ + crypto::hash::merkle_proof::DeltaMerkleProofCore as ParthDeltaMerkleProofCore, pgoldilocks::QHashOut as ParthQHashOut, + protocol::core_types::QNetworkTreeConstants, +}; +use plonky2::{ + field::types::{Field, PrimeField64}, + plonk::{circuit_data::CommonCircuitData, proof::ProofWithPublicInputs}, +}; +use psy_config::network_constants::{DEPOSIT_TREE_CONTRACT_STATE_TREE_HEIGHT, WITHDRAWAL_TREE_CONTRACT_STATE_TREE_HEIGHT}; +use psy_core::{constants::chain_id::PsyChainNetworkType, job::job_id::ProvingJobCircuitType, network_config::PsyNetworkLocalDevnetConstants}; +use psy_data::v1::qdata::checkpoint::PQEDCheckpointGlobalStateRoots; +use psy_plonky2_basic_helpers::verifier::circuit_library::CircuitInfoLibraryCore; +use psy_plonky2_circuits::{ + bridge::{ + circuits::{ + bridge_agg_final::BridgeAggFinalCircuit, + bridge_wrap::{BridgeWrapCircuit, DepositBatchWrapCircuit, SharedGroth16Wrapper, WithdrawalClaimWrapCircuit}, + }, + gadgets::tree_root_in_contract_state::TreeRootInContractStateWitnessInput, + }, + circuit_library::get_plonky2_circuit_library_and_prover_for_network, + coordinator::coordinator_helper::QEDCoordinatorCircuitManager, +}; +use psy_plonky2_common_circuits::bridge::{ + deposit_batch_append_circuit::{ + compute_batch_append_preimage, BatchAppendInputs as DepositBatchAppendInputs, DepositBatchAppendCircuit, + DepositLeafData as DepositBatchLeafData, MAX_DEPOSIT_BATCH_SIZE, + }, + withdrawal_batch_claim_circuit::{ + WithdrawalBatchClaimCircuit, WithdrawalBatchClaimInputs, WithdrawalBatchClaimSlotInputs, MAX_WITHDRAWAL_CLAIM_BATCH_SIZE, + WITHDRAWAL_BATCH_CLAIM_PUBLIC_INPUTS_WORDS, WITHDRAWAL_BATCH_CLAIM_SLOT_WORDS, + }, +}; + +use super::{ + types::*, + C, D, F, +}; + +#[rpc(server, client, namespace = "psy")] +pub trait ProveProxySystemRpc { + #[method(name = "prove_withdrawal_batch_claim_groth16")] + async fn prove_withdrawal_batch_claim_groth16( + &self, + input: BridgeWithdrawalBatchWitnessInput, + ) -> Result; + + #[method(name = "prove_deposit_batch_append_groth16")] + async fn prove_deposit_batch_append_groth16( + &self, + input: BridgeDepositBatchWitnessInput, + ) -> Result; + + /// Bridge aggregation: checkpoints → BridgeAggCircuit → BridgeWrapCircuit → + /// Groth16 + #[method(name = "prove_bridge_agg_groth16")] + async fn prove_bridge_agg_groth16(&self, deps_network: String, input: BridgeAggWitnessInput) -> Result; +} + +pub struct SystemProveProvider { + pub deposit_batch_wrap_circuit: Arc, + pub withdrawal_claim_wrap_circuit: Arc, + pub bridge_wrap_circuit: Arc, + pub deposit_batch_groth16_wrapper: Arc, + pub withdrawal_claim_groth16_wrapper: Arc, + pub bridge_groth16_wrapper: Arc, +} + +impl SystemProveProvider { + /// Builds the bridge wrap circuits and preloads the three Groth16 keystores. + /// Does not touch the coordinator RPC or the UPS circuit manager. + pub fn new() -> anyhow::Result { + use psy_plonky2_circuits::qstandard::QStandardCircuit; + + // ── Pre-build Groth16 wrapping circuits (shared across all threads) ── + // These depend only on the inner circuit structure, not on runtime data. + // Building once at startup saves ~200ms per request (CircuitBuilder::new + + // builder.build). + + tracing::info!("Pre-building DepositBatchWrapCircuit..."); + let deposit_template = DepositBatchAppendCircuit::::build(MAX_DEPOSIT_BATCH_SIZE, 32); + let deposit_minifier = psy_plonky2_circuits::proof_minifier::pm_chain::QEDProofMinifierChain::::new( + &deposit_template.circuit_data.verifier_only, + &deposit_template.circuit_data.common, + 2, + ); + let deposit_fp = ParthQHashOut(deposit_minifier.get_fingerprint()); + let deposit_batch_wrap_circuit = Arc::new(DepositBatchWrapCircuit::new( + deposit_minifier.get_common_data(), + deposit_fp, + deposit_minifier.get_verifier_data().constants_sigmas_cap.height(), + )); + let deposit_batch_groth16_wrapper = Arc::new( + DepositBatchWrapCircuit::new( + deposit_minifier.get_common_data(), + deposit_fp, + deposit_minifier.get_verifier_data().constants_sigmas_cap.height(), + ) + .into_shared_groth16_wrapper(format!("{}/.psy/keystore/deposit_append/", dirs::home_dir().unwrap().display())), + ); + + tracing::info!("Pre-building WithdrawalClaimWrapCircuit..."); + let withdrawal_template = WithdrawalBatchClaimCircuit::::build(32); + let withdrawal_fp = ParthQHashOut(psy_plonky2_circuits::proof_minifier::pm_core::get_circuit_fingerprint_generic( + &withdrawal_template.circuit_data.verifier_only, + )); + let withdrawal_claim_wrap_circuit = Arc::new(WithdrawalClaimWrapCircuit::new( + &withdrawal_template.circuit_data.common, + withdrawal_fp, + withdrawal_template.circuit_data.verifier_only.constants_sigmas_cap.height(), + )); + let withdrawal_claim_groth16_wrapper = Arc::new( + WithdrawalClaimWrapCircuit::new( + &withdrawal_template.circuit_data.common, + withdrawal_fp, + withdrawal_template.circuit_data.verifier_only.constants_sigmas_cap.height(), + ) + .into_shared_groth16_wrapper(format!("{}/.psy/keystore/withdrawal_claim/", dirs::home_dir().unwrap().display())), + ); + + tracing::info!("Pre-building BridgeWrapCircuit..."); + let coordinator_circuits = cached_bridge_coordinator_circuits()?; + let checkpoint_common_data: &CommonCircuitData = coordinator_circuits.checkpoint_root_transition.get_common_circuit_data_ref(); + let checkpoint_verifier_data = coordinator_circuits.checkpoint_root_transition.get_verifier_config_ref(); + let checkpoint_cap_height = checkpoint_verifier_data.constants_sigmas_cap.height(); + let coordinator_checkpoint_fp = coordinator_circuits.checkpoint_root_transition.get_fingerprint(); + // step_commit must use the cached library fingerprint (same as RCP circuit + // genesis proving), NOT base_fingerprint or minifier get_fingerprint(). + let cached_lib = psy_plonky2_circuits::generated::cached_circuit_library::get_cached_circuit_library::(); + let coordinator_checkpoint_step_commit_fp = cached_lib + .get_fingerprint(ProvingJobCircuitType::GenerateRollupStateTransitionProof) + .expect("GenerateRollupStateTransitionProof not found in cached circuit library"); + + tracing::info!( + "[PROXY] checkpoint minifier_fp={:?} step_commit_fp(cached)={:?}", + coordinator_checkpoint_fp.0.elements, + coordinator_checkpoint_step_commit_fp.0.elements, + ); + + let bridge_agg_template = BridgeAggFinalCircuit::::prebuild_final_circuit( + checkpoint_common_data, + checkpoint_cap_height, + coordinator_checkpoint_fp, + coordinator_checkpoint_step_commit_fp, + 32, + PsyNetworkLocalDevnetConstants::GLOBAL_USER_TREE_HEIGHT_USIZE, + PsyNetworkLocalDevnetConstants::GLOBAL_CONTRACT_TREE_HEIGHT_USIZE, + DEPOSIT_TREE_CONTRACT_STATE_TREE_HEIGHT as usize, + WITHDRAWAL_TREE_CONTRACT_STATE_TREE_HEIGHT as usize, + ); + let bridge_agg_fingerprint = bridge_agg_template.get_fingerprint(); + let bridge_agg_common = bridge_agg_template.get_common_circuit_data_ref(); + let bridge_agg_verifier = bridge_agg_template.get_verifier_config_ref(); + let bridge_wrap_circuit = Arc::new(BridgeWrapCircuit::new( + bridge_agg_common, + bridge_agg_fingerprint, + bridge_agg_verifier.constants_sigmas_cap.height(), + )); + let bridge_groth16_wrapper = Arc::new( + BridgeWrapCircuit::new( + bridge_agg_common, + bridge_agg_fingerprint, + bridge_agg_verifier.constants_sigmas_cap.height(), + ) + .into_shared_groth16_wrapper(format!("{}/.psy/keystore/", dirs::home_dir().unwrap().display())), + ); + + tracing::info!("Groth16 wrapping circuits pre-built successfully."); + + // Preload Groth16 keystores into the gnark Go runtime so the first proof + // request doesn't pay the ~15s cold-start penalty (ReadCircuit + + // ReadProvingKey). Each keystore is ~500MB–800MB on disk; loading + // lazily on first request causes relayer claim-proof-fetch timeouts. + tracing::info!("Preloading Groth16 keystores..."); + for (label, keystore_path) in [ + ("bridge", &bridge_groth16_wrapper.keystore_path), + ("deposit_append", &deposit_batch_groth16_wrapper.keystore_path), + ("withdrawal_claim", &withdrawal_claim_groth16_wrapper.keystore_path), + ] { + let keystore_dir = std::path::Path::new(keystore_path); + if keystore_dir.join("circuit_groth16.bin").exists() + && keystore_dir.join("pk_groth16.bin").exists() + && keystore_dir.join("vk_groth16.bin").exists() + { + tracing::info!(keystore = label, path = keystore_path, "preloading Groth16 setup"); + gnark_plonky2_verifier_ffi::initialize(keystore_path); + tracing::info!(keystore = label, "Groth16 setup preloaded"); + } else { + tracing::warn!(keystore = label, path = keystore_path, "skipping preload — keystore files missing"); + } + } + tracing::info!("All Groth16 keystores preloaded."); + + Ok(Self { + deposit_batch_wrap_circuit, + withdrawal_claim_wrap_circuit, + bridge_wrap_circuit, + deposit_batch_groth16_wrapper, + withdrawal_claim_groth16_wrapper, + bridge_groth16_wrapper, + }) + } +} + +fn cached_bridge_coordinator_circuits() -> anyhow::Result<&'static QEDCoordinatorCircuitManager> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE + .get_or_init(|| { + tracing::info!("Building QEDCoordinatorCircuitManager for bridge agg..."); + get_plonky2_circuit_library_and_prover_for_network::(PsyChainNetworkType::LocalDevnet).map(|(_, circuits)| circuits) + }) + .as_ref() + .map_err(|e| anyhow::anyhow!("failed to build/retrieve cached bridge circuits: {}", e)) +} + +#[async_trait] +impl ProveProxySystemRpcServer for SystemProveProvider { + async fn prove_withdrawal_batch_claim_groth16( + &self, + input: BridgeWithdrawalBatchWitnessInput, + ) -> Result { + tracing::debug!("prove_withdrawal_batch_claim_groth16 count={}", input.withdrawals.len()); + + let wrap_circuit = self.withdrawal_claim_wrap_circuit.clone(); + let groth16_wrapper = self.withdrawal_claim_groth16_wrapper.clone(); + tokio::task::spawn_blocking(move || { + anyhow::ensure!( + input.withdrawals.len() <= MAX_WITHDRAWAL_CLAIM_BATCH_SIZE, + "withdrawal batch too large: got {}, max {}", + input.withdrawals.len(), + MAX_WITHDRAWAL_CLAIM_BATCH_SIZE + ); + anyhow::ensure!(!input.withdrawals.is_empty(), "withdrawal batch must include at least one withdrawal"); + + let mut slot_data = vec![0u64; MAX_WITHDRAWAL_CLAIM_BATCH_SIZE * WITHDRAWAL_BATCH_CLAIM_SLOT_WORDS]; + let mut root: Option> = None; + let mut withdrawals = Vec::with_capacity(input.withdrawals.len()); + for (i, withdrawal) in input.withdrawals.iter().enumerate() { + anyhow::ensure!( + withdrawal.siblings.len() == 32, + "withdrawal[{}] expected 32 siblings, got {}", + i, + withdrawal.siblings.len() + ); + let parsed_root = parse_hex_qhashout(&withdrawal.withdrawal_root)?; + if let Some(existing) = root { + anyhow::ensure!(existing == parsed_root, "withdrawal[{}] root mismatch within batch", i); + } else { + root = Some(parsed_root); + } + let siblings = withdrawal + .siblings + .iter() + .map(|hex| parse_hex_qhashout(hex)) + .collect::>>()?; + let slot_offset = i * WITHDRAWAL_BATCH_CLAIM_SLOT_WORDS; + slot_data[slot_offset] = withdrawal.sender_user_id as u64; + for (j, word) in withdrawal.recipient.iter().enumerate() { + slot_data[slot_offset + 1 + j] = *word as u64; + } + for (j, word) in withdrawal.token.iter().enumerate() { + slot_data[slot_offset + 9 + j] = *word as u64; + } + for (j, word) in withdrawal.amount.iter().enumerate() { + slot_data[slot_offset + 17 + j] = *word as u64; + } + for (j, word) in withdrawal.nonce.iter().enumerate() { + slot_data[slot_offset + 25 + j] = *word as u64; + } + slot_data[slot_offset + 33] = withdrawal.destination_chain_index as u64; + withdrawals.push(WithdrawalBatchClaimSlotInputs:: { + sender_user_id: withdrawal.sender_user_id, + recipient: withdrawal.recipient, + token: withdrawal.token, + amount: withdrawal.amount, + nonce: withdrawal.nonce, + destination_chain_index: withdrawal.destination_chain_index, + leaf_index: withdrawal.leaf_index, + siblings, + }); + } + + let circuit = WithdrawalBatchClaimCircuit::::build(32); + let proof = circuit.generate_proof(&WithdrawalBatchClaimInputs:: { + withdrawal_root: root.expect("non-empty batch ensured above"), + bridge_user_id: input.bridge_user_id, + withdrawals, + })?; + let groth16 = wrap_circuit.prove_groth16_with_shared_wrapper(&groth16_wrapper, &circuit.circuit_data.verifier_only, &proof)?; + tracing::warn!( + withdrawal_claim_gnark_public_inputs = ?groth16.public_inputs, + "withdrawal claim gnark returned public inputs" + ); + + Ok::<_, anyhow::Error>(BridgeWithdrawalBatchGroth16Proof { + solidity_proof: g16_proof_to_solidity_words(&groth16), + public_inputs: { + let pis = proof.public_inputs.iter().map(|x| x.to_noncanonical_u64()).collect::>(); + anyhow::ensure!( + pis.len() == WITHDRAWAL_BATCH_CLAIM_PUBLIC_INPUTS_WORDS, + "expected {} withdrawal batch public inputs, got {}", + WITHDRAWAL_BATCH_CLAIM_PUBLIC_INPUTS_WORDS, + pis.len() + ); + pis + }, + slot_data, + }) + }) + .await + .map_err(|join_err| { + ErrorObjectOwned::owned( + 1, + "prove_withdrawal_batch_claim_groth16: task schedule failed", + Some(format!("Thread pool task execution failed: {}", join_err)), + ) + })? + .map_err(|err| ErrorObjectOwned::owned(1, "prove_withdrawal_batch_claim_groth16 proving error", Some(err.to_string()))) + } + + async fn prove_deposit_batch_append_groth16( + &self, + input: BridgeDepositBatchWitnessInput, + ) -> Result { + tracing::debug!( + "prove_deposit_batch_append_groth16 from_index={} count={}", + input.from_index, + input.deposits.len() + ); + + let wrap_circuit = self.deposit_batch_wrap_circuit.clone(); + let groth16_wrapper = self.deposit_batch_groth16_wrapper.clone(); + tokio::task::spawn_blocking(move || { + anyhow::ensure!( + input.old_frontier.len() == 32, + "expected 32 frontier nodes, got {}", + input.old_frontier.len() + ); + anyhow::ensure!(!input.deposits.is_empty(), "deposit batch must include at least one deposit"); + + let old_frontier_vec = input + .old_frontier + .iter() + .map(|hex| parse_hex_qhashout(hex)) + .collect::>>()?; + let old_frontier: [ParthQHashOut; 32] = old_frontier_vec + .try_into() + .map_err(|v: Vec>| anyhow::anyhow!("invalid frontier length: {}", v.len()))?; + let deposits = input + .deposits + .into_iter() + .map(|leaf| DepositBatchLeafData { + shield_address: leaf.shield_address, + token: leaf.token, + l2_token_contract_id: leaf.l2_token_contract_id, + amount: leaf.amount, + chain_index: leaf.chain_index, + note_commitment: leaf.note_commitment, + }) + .collect::>(); + let batch_inputs = DepositBatchAppendInputs { + frontier: old_frontier, + from_index: input.from_index, + deposits, + bridge_user_id: input.bridge_user_id, + }; + + let circuit = DepositBatchAppendCircuit::::build( + psy_plonky2_common_circuits::bridge::deposit_batch_append_circuit::MAX_DEPOSIT_BATCH_SIZE, + 32, + ); + let proof = circuit.generate_proof(&batch_inputs)?; + let preimage = compute_batch_append_preimage(&batch_inputs); + let minifier = psy_plonky2_circuits::proof_minifier::pm_chain::QEDProofMinifierChain::::new( + &circuit.circuit_data.verifier_only, + &circuit.circuit_data.common, + 2, + ); + let minified_proof = minifier.prove(&proof)?; + let groth16 = wrap_circuit.prove_groth16_with_shared_wrapper(&groth16_wrapper, minifier.get_verifier_data(), &minified_proof)?; + + Ok::<_, anyhow::Error>(BridgeDepositBatchGroth16Proof { + solidity_proof: g16_proof_to_solidity_words(&groth16), + public_inputs: preimage.to_u32_words().into_iter().map(|x| x as u64).collect(), + }) + }) + .await + .map_err(|join_err| { + ErrorObjectOwned::owned( + 1, + "prove_deposit_batch_append_groth16: task schedule failed", + Some(format!("Thread pool task execution failed: {}", join_err)), + ) + })? + .map_err(|err| ErrorObjectOwned::owned(1, "prove_deposit_batch_append_groth16 proving error", Some(err.to_string()))) + } + + async fn prove_bridge_agg_groth16( + &self, + _deps_network: String, + input: BridgeAggWitnessInput, + ) -> Result { + tracing::debug!("prove_bridge_agg_groth16 from={} to={}", input.from_checkpoint, input.to_checkpoint); + + let from_checkpoint = input.from_checkpoint.max(1); + let to_checkpoint = input.to_checkpoint; + if from_checkpoint > to_checkpoint { + return Err(ErrorObjectOwned::owned( + 1, + "prove_bridge_agg_groth16: from_checkpoint must be <= to_checkpoint", + None::<()>, + )); + } + let num_checkpoints_aggregated = to_checkpoint - from_checkpoint + 1; + + let wrap_circuit = self.bridge_wrap_circuit.clone(); + let groth16_wrapper = self.bridge_groth16_wrapper.clone(); + tokio::task::spawn_blocking(move || -> Result { + use psy_plonky2_circuits::qstandard::QStandardCircuit; + let coordinator_circuits = cached_bridge_coordinator_circuits() + .map_err(|e| ErrorObjectOwned::owned(1, "failed to load bridge circuits", Some(e.to_string())))?; + + let checkpoint_common_data: &CommonCircuitData = + coordinator_circuits.checkpoint_root_transition.get_common_circuit_data_ref(); + let checkpoint_verifier_data = + coordinator_circuits.checkpoint_root_transition.get_verifier_config_ref(); + let cap_height = checkpoint_verifier_data.constants_sigmas_cap.height(); + let coordinator_checkpoint_fp = + coordinator_circuits.checkpoint_root_transition.get_fingerprint(); + let checkpoint_state_transition_fingerprint = parse_hex_qhashout_to_qhash(&input.checkpoint_fp) + .map_err(|e| ErrorObjectOwned::owned(1, "parse checkpoint_fp", Some(e.to_string())))?; + if checkpoint_state_transition_fingerprint != coordinator_checkpoint_fp { + return Err(ErrorObjectOwned::owned( + 1, + "checkpoint_fp mismatch", + Some(format!( + "bridge agg witness checkpoint_fp differs from proxy coordinator fingerprint: input={:?} coordinator={:?}", + checkpoint_state_transition_fingerprint, + coordinator_checkpoint_fp + )), + )); + } + // step_commit must use the cached library fingerprint (same as RCP circuit genesis proving). + let cached_lib = psy_plonky2_circuits::generated::cached_circuit_library::get_cached_circuit_library::(); + let checkpoint_step_commit_fingerprint = cached_lib + .get_fingerprint(ProvingJobCircuitType::GenerateRollupStateTransitionProof) + .expect("GenerateRollupStateTransitionProof not found in cached circuit library"); + + // Deserialize the final (to_checkpoint) checkpoint proof from bincode hex + let final_checkpoint_proof_bytes = hex::decode( + input.final_checkpoint_proof_hex.trim_start_matches("0x"), + ) + .map_err(|e| ErrorObjectOwned::owned(1, "hex decode final checkpoint proof", Some(e.to_string())))?; + let final_checkpoint_proof: ProofWithPublicInputs = + bincode::deserialize(&final_checkpoint_proof_bytes) + .map_err(|e| ErrorObjectOwned::owned(1, "bincode deserialize final checkpoint proof", Some(e.to_string())))?; + + // Parse delta merkle proofs + use plonky2::hash::poseidon::PoseidonHash; + let parse_delta = |dp: &BridgeAggDeltaProof| -> anyhow::Result>> { + let new_value = parse_hex_qhashout_to_qhash(&dp.new_value)?; + let siblings = dp.siblings.iter().map(|s| parse_hex_qhashout_to_qhash(s)).collect::, _>>()?; + Ok(ParthDeltaMerkleProofCore::from_params::( + dp.index, + parth_core::pgoldilocks::QHashOut::default(), + new_value, + siblings, + )) + }; + + let delta_merkle_proofs: Vec>> = input.delta_merkle_proofs + .iter() + .map(parse_delta) + .collect::>>() + .map_err(|e| ErrorObjectOwned::owned(1, "parse delta proofs", Some(e.to_string())))?; + + let pre_delta_merkle_proofs: Vec>> = input.pre_delta_merkle_proofs + .iter() + .map(parse_delta) + .collect::>>() + .map_err(|e| ErrorObjectOwned::owned(1, "parse pre-delta proofs", Some(e.to_string())))?; + + // `chain_start` is the chain hash immediately before the aggregated range + // (chain hash of checkpoint `from_checkpoint - 1`; for `from_checkpoint <= 1` + // this is the genesis checkpoint state transition hash). + let start_chain_hash = parse_hex_qhashout_to_qhash(&input.chain_start) + .map_err(|e| ErrorObjectOwned::owned(1, "parse chain_start", Some(e.to_string())))?; + + let final_leaf = psy_data::v1::qdata::checkpoint::PQEDCheckpointLeafCompact { + global_chain_root: parse_hex_qhashout_to_qhash(&input.final_checkpoint_leaf.global_chain_root) + .map_err(|e| ErrorObjectOwned::owned(1, "parse final leaf chain root", Some(e.to_string())))?, + stats_hash: parse_hex_qhashout_to_qhash(&input.final_checkpoint_leaf.stats_hash) + .map_err(|e| ErrorObjectOwned::owned(1, "parse final leaf stats hash", Some(e.to_string())))?, + }; + + // Parse global state roots (anchors the user_tree_root to the verified checkpoint) + let parse_qhash = |hex: &str| -> anyhow::Result> { + parse_hex_qhashout_to_qhash(hex) + }; + let global_state_roots = PQEDCheckpointGlobalStateRoots { + contract_tree_root: parse_qhash(&input.final_checkpoint_global_state_roots.contract_tree_root) + .map_err(|e| ErrorObjectOwned::owned(1, "parse contract_tree_root", Some(e.to_string())))?, + deposit_tree_root: parse_qhash(&input.final_checkpoint_global_state_roots.deposit_tree_root) + .map_err(|e| ErrorObjectOwned::owned(1, "parse deposit_tree_root", Some(e.to_string())))?, + user_tree_root: parse_qhash(&input.final_checkpoint_global_state_roots.user_tree_root) + .map_err(|e| ErrorObjectOwned::owned(1, "parse user_tree_root", Some(e.to_string())))?, + withdrawal_tree_root: parse_qhash(&input.final_checkpoint_global_state_roots.withdrawal_tree_root) + .map_err(|e| ErrorObjectOwned::owned(1, "parse withdrawal_tree_root", Some(e.to_string())))?, + user_registration_tree_root: parse_qhash(&input.final_checkpoint_global_state_roots.user_registration_tree_root) + .map_err(|e| ErrorObjectOwned::owned(1, "parse user_registration_tree_root", Some(e.to_string())))?, + }; + + // Parse witnesses (slot witnesses are the full TreeRootInContractStateWitnessInput) + let parse_slot_witness = |w: &BridgeAggSlotWitness| -> anyhow::Result> { + let user_leaf = psy_data::v1::qdata::user::PQEDUserLeaf::> { + public_key: parse_hex_qhashout_to_qhash(&w.user_leaf_public_key)?, + user_state_tree_root: parse_hex_qhashout_to_qhash(&w.user_leaf_user_state_tree_root)?, + balance: F::from_canonical_u64(w.user_leaf_balance), + nonce: F::from_canonical_u64(w.user_leaf_nonce), + last_checkpoint_id: F::from_canonical_u64(w.user_leaf_last_checkpoint_id), + event_index: F::from_canonical_u64(w.user_leaf_event_index), + user_id: F::from_canonical_u64(w.user_leaf_user_id), + }; + + let mk_proof = |root: &str, value: &str, index: u64, sibs: &[String]| -> anyhow::Result>> { + Ok(parth_core::crypto::hash::merkle_proof::MerkleProofCore { + root: parse_hex_qhashout_to_qhash(root)?, + value: parse_hex_qhashout_to_qhash(value)?, + index, + siblings: sibs.iter().map(|s| parse_hex_qhashout_to_qhash(s)).collect::, _>>()?, + }) + }; + + Ok(TreeRootInContractStateWitnessInput { + owner_user_id: w.owner_user_id, + contract_id: w.contract_id, + user_leaf, + slot0_proof: mk_proof(&w.slot0_root, &w.slot0_value, w.slot0_index, &w.slot0_siblings)?, + slot1_proof: mk_proof(&w.slot1_root, &w.slot1_value, w.slot1_index, &w.slot1_siblings)?, + contract_proof: mk_proof(&w.contract_root, &w.contract_value, w.contract_index, &w.contract_siblings)?, + user_tree_proof: mk_proof(&w.user_tree_root, &w.user_tree_value, w.user_tree_index, &w.user_tree_siblings)?, + }) + }; + + let deposit_witness = parse_slot_witness(&input.deposit_witness) + .map_err(|e| ErrorObjectOwned::owned(1, "parse deposit witness", Some(e.to_string())))?; + let withdrawal_witness = parse_slot_witness(&input.withdrawal_witness) + .map_err(|e| ErrorObjectOwned::owned(1, "parse withdrawal witness", Some(e.to_string())))?; + + tracing::info!( + "Proving bridge aggregation for checkpoints {} to {}...", + from_checkpoint, + to_checkpoint + ); + + let result = BridgeAggFinalCircuit::::prove_range( + from_checkpoint, + to_checkpoint, + start_chain_hash, + checkpoint_common_data, + cap_height, + checkpoint_state_transition_fingerprint, + checkpoint_step_commit_fingerprint, + &final_checkpoint_proof, + &checkpoint_verifier_data, + &delta_merkle_proofs, + &pre_delta_merkle_proofs, + &final_leaf, + &global_state_roots, + &deposit_witness, + &withdrawal_witness, + 32, // CHECKPOINT_TREE_HEIGHT + PsyNetworkLocalDevnetConstants::GLOBAL_USER_TREE_HEIGHT_USIZE, + PsyNetworkLocalDevnetConstants::GLOBAL_CONTRACT_TREE_HEIGHT_USIZE, + DEPOSIT_TREE_CONTRACT_STATE_TREE_HEIGHT as usize, + WITHDRAWAL_TREE_CONTRACT_STATE_TREE_HEIGHT as usize, + ) + .map_err(|e| ErrorObjectOwned::owned(1, "bridge_agg prove_range failed", Some(e.to_string())))?; + + let bridge_agg_proof = result.proof; + let bridge_agg_verifier_data = result.verifier_data; + + tracing::info!("Proving BridgeWrapCircuit (Groth16 wrap)..."); + let groth16_proof = wrap_circuit + .prove_groth16_with_shared_wrapper(&groth16_wrapper, &bridge_agg_verifier_data, &bridge_agg_proof) + .map_err(|e| ErrorObjectOwned::owned(1, "bridge_wrap Groth16 failed", Some(e.to_string())))?; + + // Format outputs + let groth16_pi = &bridge_agg_proof.public_inputs; + let checkpoint_roots = vec![ + felt4_to_bytes32_hex(&groth16_pi[0..4]), + felt4_to_bytes32_hex(&groth16_pi[20..24]), + ]; + let deposit_tree_root = u32x8_to_bytes32_hex(&groth16_pi[4..12]); + let withdrawal_tree_root = u32x8_to_bytes32_hex(&groth16_pi[12..20]); + let end_checkpoint_index = groth16_pi[24].to_canonical_u64(); + if end_checkpoint_index != to_checkpoint { + return Err(ErrorObjectOwned::owned( + 1, + "prove_bridge_agg_groth16: end_checkpoint_index mismatch", + Some(format!("pi={} expected={}", end_checkpoint_index, to_checkpoint)), + )); + } + + let solidity_words = g16_proof_to_solidity_words(&groth16_proof); + let pub_inputs_0 = groth16_proof.public_inputs[0].clone(); + let pub_inputs_1 = groth16_proof.public_inputs[1].clone(); + let public_inputs_str: Vec = groth16_pi.iter().map(|x| x.to_canonical_u64().to_string()).collect(); + let num_pis = groth16_pi.len(); + + Ok(BridgeAggGroth16Output { + from_checkpoint, + to_checkpoint, + num_checkpoints_aggregated, + bridge_agg_public_inputs_count: num_pis, + bridge_agg_public_inputs: public_inputs_str, + groth16_proof, + solidity_proof: [ + solidity_words[0].clone(), + solidity_words[1].clone(), + solidity_words[2].clone(), + solidity_words[3].clone(), + solidity_words[4].clone(), + solidity_words[5].clone(), + solidity_words[6].clone(), + solidity_words[7].clone(), + ], + solidity_public_inputs: [ + pub_inputs_0, + pub_inputs_1, + ], + checkpoint_roots, + deposit_tree_root, + withdrawal_tree_root, + end_checkpoint_index, + }) + }) + .await + .map_err(|join_err| { + ErrorObjectOwned::owned( + 1, + "prove_bridge_agg_groth16: task schedule failed", + Some(format!("Thread pool task execution failed: {}", join_err)), + ) + })? + } +} diff --git a/client_prover/psy_prover/src/local/native/prove_proxy/types.rs b/client_prover/psy_prover/src/local/native/prove_proxy/types.rs new file mode 100644 index 00000000..2242f8b0 --- /dev/null +++ b/client_prover/psy_prover/src/local/native/prove_proxy/types.rs @@ -0,0 +1,235 @@ +use parth_core::pgoldilocks::QHashOut as ParthQHashOut; +use plonky2::{ + field::types::{Field, PrimeField64}, + hash::hash_types::HashOut, +}; +use psy_plonky2_circuits::bridge::circuits::bridge_wrap::UncompressedGroth16ProofData; + +use super::F; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BridgeWithdrawalWitnessInput { + pub withdrawal_root: String, + pub sender_user_id: u32, + pub recipient: [u32; 8], + pub token: [u32; 8], + pub amount: [u32; 8], + pub nonce: [u32; 8], + pub destination_chain_index: u32, + pub leaf_index: u32, + pub bridge_user_id: u32, + pub siblings: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BridgeWithdrawalBatchWitnessInput { + pub bridge_user_id: u32, + pub withdrawals: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BridgeWithdrawalBatchGroth16Proof { + pub solidity_proof: [String; 8], + pub public_inputs: Vec, + pub slot_data: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BridgeDepositLeafInput { + pub shield_address: [u32; 8], + pub token: [u32; 8], + pub l2_token_contract_id: [u32; 8], + pub amount: [u32; 8], + pub chain_index: u32, + pub note_commitment: [u32; 8], +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BridgeDepositBatchWitnessInput { + pub from_index: u32, + pub bridge_user_id: u32, + pub old_frontier: Vec, + pub deposits: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BridgeDepositBatchGroth16Proof { + pub solidity_proof: [String; 8], + pub public_inputs: Vec, +} + +pub fn parse_hex_qhashout(hex: &str) -> anyhow::Result> { + let hex = hex.trim_start_matches("0x"); + anyhow::ensure!(hex.len() == 64, "expected 64 hex chars, got {}", hex.len()); + let bytes = hex::decode(hex)?; + let mut elems = [0u64; 4]; + for i in 0..4 { + let reverse_i = 3 - i; + let hi = u32::from_be_bytes(bytes[reverse_i * 8..reverse_i * 8 + 4].try_into()?); + let lo = u32::from_be_bytes(bytes[reverse_i * 8 + 4..reverse_i * 8 + 8].try_into()?); + elems[i] = ((hi as u64) << 32) | (lo as u64); + } + Ok(ParthQHashOut(HashOut { + elements: elems.map(F::from_canonical_u64), + })) +} + +pub fn parse_internal_u32x8_qhashout(hex: &str) -> anyhow::Result> { + let hex = hex.trim_start_matches("0x"); + anyhow::ensure!(hex.len() == 64, "expected 64 hex chars, got {}", hex.len()); + let bytes = hex::decode(hex)?; + let mut words = [0u32; 8]; + for i in 0..8 { + words[i] = u32::from_be_bytes(bytes[i * 4..i * 4 + 4].try_into()?); + } + let elems = [ + ((words[1] as u64) << 32) | words[0] as u64, + ((words[3] as u64) << 32) | words[2] as u64, + ((words[5] as u64) << 32) | words[4] as u64, + ((words[7] as u64) << 32) | words[6] as u64, + ]; + Ok(ParthQHashOut(HashOut { + elements: elems.map(F::from_canonical_u64), + })) +} + +// ── Bridge Aggregation Types ───────────────────────────────────────────── + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BridgeAggCheckpointLeaf { + pub global_chain_root: String, + pub stats_hash: String, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BridgeAggGlobalStateRoots { + pub contract_tree_root: String, + pub deposit_tree_root: String, + pub user_tree_root: String, + pub withdrawal_tree_root: String, + pub user_registration_tree_root: String, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BridgeAggSlotWitness { + pub owner_user_id: u64, + pub contract_id: u64, + pub user_leaf_public_key: String, + pub user_leaf_user_state_tree_root: String, + pub user_leaf_balance: u64, + pub user_leaf_nonce: u64, + pub user_leaf_last_checkpoint_id: u64, + pub user_leaf_event_index: u64, + pub user_leaf_user_id: u64, + pub slot0_root: String, + pub slot0_value: String, + pub slot0_index: u64, + pub slot0_siblings: Vec, + pub slot1_root: String, + pub slot1_value: String, + pub slot1_index: u64, + pub slot1_siblings: Vec, + pub contract_root: String, + pub contract_value: String, + pub contract_index: u64, + pub contract_siblings: Vec, + pub user_tree_root: String, + pub user_tree_value: String, + pub user_tree_index: u64, + pub user_tree_siblings: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BridgeAggDeltaProof { + pub index: u64, + pub new_value: String, + pub siblings: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BridgeAggWitnessInput { + pub from_checkpoint: u64, + pub to_checkpoint: u64, + /// Bincode-serialized ProofWithPublicInputs for the final (to_checkpoint) + /// checkpoint state transition proof, hex-encoded. + pub final_checkpoint_proof_hex: String, + pub delta_merkle_proofs: Vec, + pub pre_delta_merkle_proofs: Vec, + /// Chain hash immediately before the aggregated range (chain hash of + /// checkpoint `from_checkpoint - 1`; for `from_checkpoint <= 1` this is the + /// genesis checkpoint state transition hash). + pub chain_start: String, + /// Checkpoint state transition circuit fingerprint (hex). + /// Must match the fingerprint the coordinator used when generating + /// checkpoint proofs. + pub checkpoint_fp: String, + pub final_checkpoint_leaf: BridgeAggCheckpointLeaf, + pub final_checkpoint_global_state_roots: BridgeAggGlobalStateRoots, + pub deposit_witness: BridgeAggSlotWitness, + pub withdrawal_witness: BridgeAggSlotWitness, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BridgeAggGroth16Output { + pub from_checkpoint: u64, + pub to_checkpoint: u64, + pub num_checkpoints_aggregated: u64, + pub bridge_agg_public_inputs_count: usize, + pub bridge_agg_public_inputs: Vec, + pub groth16_proof: UncompressedGroth16ProofData, + pub solidity_proof: [String; 8], + pub solidity_public_inputs: [String; 2], + pub checkpoint_roots: Vec, + pub deposit_tree_root: String, + pub withdrawal_tree_root: String, + pub end_checkpoint_index: u64, +} + +pub fn g16_proof_to_solidity_words(groth16: &UncompressedGroth16ProofData) -> [String; 8] { + let with_0x = |s: &str| -> String { + if s.starts_with("0x") { + s.to_string() + } else { + format!("0x{}", s) + } + }; + [ + with_0x(&groth16.pi_a[0]), + with_0x(&groth16.pi_a[1]), + with_0x(&groth16.pi_b[0][1]), + with_0x(&groth16.pi_b[0][0]), + with_0x(&groth16.pi_b[1][1]), + with_0x(&groth16.pi_b[1][0]), + with_0x(&groth16.pi_c[0]), + with_0x(&groth16.pi_c[1]), + ] +} + +pub fn parse_hex_qhashout_to_qhash(h: &str) -> anyhow::Result> { + let pq = parse_hex_qhashout(h)?; + Ok(parth_core::pgoldilocks::QHashOut(pq.0)) +} + +pub fn qhashout_from_felts(elems: &[F]) -> parth_core::pgoldilocks::QHashOut { + parth_core::pgoldilocks::QHashOut(HashOut { + elements: [elems[0], elems[1], elems[2], elems[3]], + }) +} + +pub fn felt4_to_bytes32_hex(felts: &[F]) -> String { + let mut out = [0u8; 32]; + for i in 0..4 { + let v = felts[3 - i].to_canonical_u64(); + out[i * 8..(i + 1) * 8].copy_from_slice(&v.to_be_bytes()); + } + format!("0x{}", hex::encode(out)) +} + +pub fn u32x8_to_bytes32_hex(felts: &[F]) -> String { + let mut out = [0u8; 32]; + for i in 0..8 { + let v = felts[i].to_canonical_u64() as u32; + out[i * 4..(i + 1) * 4].copy_from_slice(&v.to_be_bytes()); + } + format!("0x{}", hex::encode(out)) +} diff --git a/client_prover/psy_prover/src/local/native/prove_proxy.rs b/client_prover/psy_prover/src/local/native/prove_proxy/user.rs similarity index 52% rename from client_prover/psy_prover/src/local/native/prove_proxy.rs rename to client_prover/psy_prover/src/local/native/prove_proxy/user.rs index 143d8412..15326b0b 100644 --- a/client_prover/psy_prover/src/local/native/prove_proxy.rs +++ b/client_prover/psy_prover/src/local/native/prove_proxy/user.rs @@ -1,26 +1,10 @@ -use std::{ - path::PathBuf, - sync::{Arc, OnceLock}, -}; +use std::sync::Arc; use jsonrpsee::{ core::async_trait, proc_macros::rpc, types::{ErrorObject, ErrorObjectOwned}, }; -use parth_core::{ - crypto::hash::merkle_proof::DeltaMerkleProofCore as ParthDeltaMerkleProofCore, pgoldilocks::QHashOut as ParthQHashOut, - protocol::core_types::QNetworkTreeConstants, -}; -use plonky2::{ - field::types::{Field, PrimeField64}, - hash::hash_types::HashOut, - plonk::{ - circuit_data::CommonCircuitData, - config::{GenericConfig, PoseidonGoldilocksConfig}, - proof::ProofWithPublicInputs, - }, -}; use psy_client_common::data::{alt::AltVerifierOnlyCircuitData, qhashout::QHashOut}; use psy_client_data::{ qdata::contract::ContractCodeDefinition, @@ -36,41 +20,11 @@ use psy_client_data::{ }, }; use psy_common_circuit::circuits::traits::qstandard::QStandardCircuit; -use psy_config::network_constants::{ - DEPOSIT_TREE_CONTRACT_STATE_TREE_HEIGHT, - WITHDRAWAL_TREE_CONTRACT_STATE_TREE_HEIGHT, -}; -use psy_core::{constants::chain_id::PsyChainNetworkType, job::job_id::ProvingJobCircuitType, network_config::PsyNetworkLocalDevnetConstants}; use psy_crypto::{ common::witnesses::qrecursion::{header::QRecursionAggStandardHeader, proof_data::QStandardBinaryTreeCircuitType}, hash::merkle::core::{DeltaMerkleProofCore, MerkleProofCore}, signature::secp256k1::core::PsyCompressedSecp256K1Signature, }; -use psy_data::v1::qdata::checkpoint::PQEDCheckpointGlobalStateRoots; -use psy_plonky2_basic_helpers::verifier::circuit_library::CircuitInfoLibraryCore; -use psy_plonky2_circuits::{ - bridge::{ - circuits::{ - bridge_agg_final::BridgeAggFinalCircuit, - bridge_wrap::{ - BridgeWrapCircuit, DepositBatchWrapCircuit, SharedGroth16Wrapper, UncompressedGroth16ProofData, WithdrawalClaimWrapCircuit, - }, - }, - gadgets::tree_root_in_contract_state::TreeRootInContractStateWitnessInput, - }, - circuit_library::get_plonky2_circuit_library_and_prover_for_network, - coordinator::coordinator_helper::QEDCoordinatorCircuitManager, -}; -use psy_plonky2_common_circuits::bridge::{ - deposit_batch_append_circuit::{ - compute_batch_append_preimage, BatchAppendInputs as DepositBatchAppendInputs, DepositBatchAppendCircuit, - DepositLeafData as DepositBatchLeafData, MAX_DEPOSIT_BATCH_SIZE, - }, - withdrawal_batch_claim_circuit::{ - WithdrawalBatchClaimCircuit, WithdrawalBatchClaimInputs, WithdrawalBatchClaimSlotInputs, MAX_WITHDRAWAL_CLAIM_BATCH_SIZE, - WITHDRAWAL_BATCH_CLAIM_PUBLIC_INPUTS_WORDS, WITHDRAWAL_BATCH_CLAIM_SLOT_WORDS, - }, -}; use psy_provider::{ provider::{LocalCommonCircuitsData, QCommonCircuitData, RpcProvider}, request::{DPNSoftwareDefinedSignatureInput, QRegisterDPNSoftwareDefinedCircuitRPCRequest, QRegisterPlonky2SoftwareDefinedCircuitRPCRequest}, @@ -80,212 +34,13 @@ use psy_vm::{ ups::{circuit_manager::UPSCircuitManager, signature::Plonky2SoftwareDefinedSignatureInput}, vm::cfc_input::DapenContractFunctionCircuitInput, }; +use plonky2::plonk::proof::ProofWithPublicInputs; +use super::{C, D, F}; use crate::local::native::DPNFunctionCircuitDefinition; -type C = PoseidonGoldilocksConfig; -type F = >::F; -const D: usize = 2; -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct BridgeWithdrawalWitnessInput { - pub withdrawal_root: String, - pub sender_user_id: u32, - pub recipient: [u32; 8], - pub token: [u32; 8], - pub amount: [u32; 8], - pub nonce: [u32; 8], - pub destination_chain_index: u32, - pub leaf_index: u32, - pub bridge_user_id: u32, - pub siblings: Vec, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct BridgeWithdrawalBatchWitnessInput { - pub bridge_user_id: u32, - pub withdrawals: Vec, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct BridgeWithdrawalBatchGroth16Proof { - pub solidity_proof: [String; 8], - pub public_inputs: Vec, - pub slot_data: Vec, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct BridgeDepositLeafInput { - pub shield_address: [u32; 8], - pub token: [u32; 8], - pub l2_token_contract_id: [u32; 8], - pub amount: [u32; 8], - pub chain_index: u32, - pub note_commitment: [u32; 8], -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct BridgeDepositBatchWitnessInput { - pub from_index: u32, - pub bridge_user_id: u32, - pub old_frontier: Vec, - pub deposits: Vec, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct BridgeDepositBatchGroth16Proof { - pub solidity_proof: [String; 8], - pub public_inputs: Vec, -} - -fn parse_hex_qhashout(hex: &str) -> anyhow::Result> { - let hex = hex.trim_start_matches("0x"); - anyhow::ensure!(hex.len() == 64, "expected 64 hex chars, got {}", hex.len()); - let bytes = hex::decode(hex)?; - let mut elems = [0u64; 4]; - for i in 0..4 { - let reverse_i = 3 - i; - let hi = u32::from_be_bytes(bytes[reverse_i * 8..reverse_i * 8 + 4].try_into()?); - let lo = u32::from_be_bytes(bytes[reverse_i * 8 + 4..reverse_i * 8 + 8].try_into()?); - elems[i] = ((hi as u64) << 32) | (lo as u64); - } - Ok(ParthQHashOut(HashOut { - elements: elems.map(F::from_canonical_u64), - })) -} - -fn parse_internal_u32x8_qhashout(hex: &str) -> anyhow::Result> { - let hex = hex.trim_start_matches("0x"); - anyhow::ensure!(hex.len() == 64, "expected 64 hex chars, got {}", hex.len()); - let bytes = hex::decode(hex)?; - let mut words = [0u32; 8]; - for i in 0..8 { - words[i] = u32::from_be_bytes(bytes[i * 4..i * 4 + 4].try_into()?); - } - let elems = [ - ((words[1] as u64) << 32) | words[0] as u64, - ((words[3] as u64) << 32) | words[2] as u64, - ((words[5] as u64) << 32) | words[4] as u64, - ((words[7] as u64) << 32) | words[6] as u64, - ]; - Ok(ParthQHashOut(HashOut { - elements: elems.map(F::from_canonical_u64), - })) -} - -// ── Bridge Aggregation Types ───────────────────────────────────────────── - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct BridgeAggCheckpointLeaf { - pub global_chain_root: String, - pub stats_hash: String, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct BridgeAggGlobalStateRoots { - pub contract_tree_root: String, - pub deposit_tree_root: String, - pub user_tree_root: String, - pub withdrawal_tree_root: String, - pub user_registration_tree_root: String, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct BridgeAggSlotWitness { - pub owner_user_id: u64, - pub contract_id: u64, - pub user_leaf_public_key: String, - pub user_leaf_user_state_tree_root: String, - pub user_leaf_balance: u64, - pub user_leaf_nonce: u64, - pub user_leaf_last_checkpoint_id: u64, - pub user_leaf_event_index: u64, - pub user_leaf_user_id: u64, - pub slot0_root: String, - pub slot0_value: String, - pub slot0_index: u64, - pub slot0_siblings: Vec, - pub slot1_root: String, - pub slot1_value: String, - pub slot1_index: u64, - pub slot1_siblings: Vec, - pub contract_root: String, - pub contract_value: String, - pub contract_index: u64, - pub contract_siblings: Vec, - pub user_tree_root: String, - pub user_tree_value: String, - pub user_tree_index: u64, - pub user_tree_siblings: Vec, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct BridgeAggDeltaProof { - pub index: u64, - pub new_value: String, - pub siblings: Vec, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct BridgeAggWitnessInput { - pub from_checkpoint: u64, - pub to_checkpoint: u64, - /// Bincode-serialized ProofWithPublicInputs for the final (to_checkpoint) - /// checkpoint state transition proof, hex-encoded. - pub final_checkpoint_proof_hex: String, - pub delta_merkle_proofs: Vec, - pub pre_delta_merkle_proofs: Vec, - /// Chain hash immediately before the aggregated range (chain hash of - /// checkpoint `from_checkpoint - 1`; for `from_checkpoint <= 1` this is the - /// genesis checkpoint state transition hash). - pub chain_start: String, - /// Checkpoint state transition circuit fingerprint (hex). - /// Must match the fingerprint the coordinator used when generating - /// checkpoint proofs. - pub checkpoint_fp: String, - pub final_checkpoint_leaf: BridgeAggCheckpointLeaf, - pub final_checkpoint_global_state_roots: BridgeAggGlobalStateRoots, - pub deposit_witness: BridgeAggSlotWitness, - pub withdrawal_witness: BridgeAggSlotWitness, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct BridgeAggGroth16Output { - pub from_checkpoint: u64, - pub to_checkpoint: u64, - pub num_checkpoints_aggregated: u64, - pub bridge_agg_public_inputs_count: usize, - pub bridge_agg_public_inputs: Vec, - pub groth16_proof: UncompressedGroth16ProofData, - pub solidity_proof: [String; 8], - pub solidity_public_inputs: [String; 2], - pub checkpoint_roots: Vec, - pub deposit_tree_root: String, - pub withdrawal_tree_root: String, - pub end_checkpoint_index: u64, -} - -fn g16_proof_to_solidity_words(groth16: &UncompressedGroth16ProofData) -> [String; 8] { - let with_0x = |s: &str| -> String { - if s.starts_with("0x") { - s.to_string() - } else { - format!("0x{}", s) - } - }; - [ - with_0x(&groth16.pi_a[0]), - with_0x(&groth16.pi_a[1]), - with_0x(&groth16.pi_b[0][1]), - with_0x(&groth16.pi_b[0][0]), - with_0x(&groth16.pi_b[1][1]), - with_0x(&groth16.pi_b[1][0]), - with_0x(&groth16.pi_c[0]), - with_0x(&groth16.pi_c[1]), - ] -} - #[rpc(server, client, namespace = "psy")] -pub trait ProveProxyRpc { +pub trait ProveProxyUserRpc { /// local proving proof generate #[method(name = "prove_ups_start")] async fn prove_ups_start(&self, input: UPSStartStepInput) -> Result, ErrorObjectOwned>; @@ -474,46 +229,17 @@ pub trait ProveProxyRpc { right_verifier_data: AltVerifierOnlyCircuitData, ) -> Result, ErrorObjectOwned>; - #[method(name = "prove_withdrawal_batch_claim_groth16")] - async fn prove_withdrawal_batch_claim_groth16( - &self, - input: BridgeWithdrawalBatchWitnessInput, - ) -> Result; - - #[method(name = "prove_deposit_batch_append_groth16")] - async fn prove_deposit_batch_append_groth16( - &self, - input: BridgeDepositBatchWitnessInput, - ) -> Result; - - /// Bridge aggregation: checkpoints → BridgeAggCircuit → BridgeWrapCircuit → - /// Groth16 - #[method(name = "prove_bridge_agg_groth16")] - async fn prove_bridge_agg_groth16(&self, deps_network: String, input: BridgeAggWitnessInput) -> Result; } -pub struct ProveProxyServerProvider { +pub struct UserProveProvider { pub rpc_provider: RpcProvider, pub circuit_manager: Arc>, pub circuit_info: Arc>, pub circuits_data: LocalCommonCircuitsData, - pub keystore_dir: Option, - pub deployments_network: String, - /// Pre-built wrapping circuits shared across all prove requests. - pub deposit_batch_wrap_circuit: Arc, - pub withdrawal_claim_wrap_circuit: Arc, - pub bridge_wrap_circuit: Arc, - pub deposit_batch_groth16_wrapper: Arc, - pub withdrawal_claim_groth16_wrapper: Arc, - pub bridge_groth16_wrapper: Arc, } -impl ProveProxyServerProvider { +impl UserProveProvider { pub async fn new_with_config(rpc_config: psy_config::NetworkConfigGoldilocks, network_magic: u64) -> anyhow::Result { - use psy_client_data::qstore::controllers::session_info::SessionCircuitInfoStore; - use psy_common_circuit::circuits::traits::qstandard::QStandardCircuit; - use psy_plonky2_circuits::qstandard::QStandardCircuit as PlonkyQStandardCircuit; - let rpc_provider = RpcProvider::new_with_config(&rpc_config)?; let circuit_manager = PsyUPSStepCircuitManager::::new_with_config(network_magic); @@ -637,138 +363,12 @@ impl ProveProxyServerProvider { }), }; - // ── Pre-build Groth16 wrapping circuits (shared across all threads) ── - // These depend only on the inner circuit structure, not on runtime data. - // Building once at startup saves ~200ms per request (CircuitBuilder::new + - // builder.build). - - tracing::info!("Pre-building DepositBatchWrapCircuit..."); - let deposit_template = DepositBatchAppendCircuit::::build(MAX_DEPOSIT_BATCH_SIZE, 32); - let deposit_minifier = psy_plonky2_circuits::proof_minifier::pm_chain::QEDProofMinifierChain::::new( - &deposit_template.circuit_data.verifier_only, - &deposit_template.circuit_data.common, - 2, - ); - let deposit_fp = ParthQHashOut(deposit_minifier.get_fingerprint()); - let deposit_batch_wrap_circuit = Arc::new(DepositBatchWrapCircuit::new( - deposit_minifier.get_common_data(), - deposit_fp, - deposit_minifier.get_verifier_data().constants_sigmas_cap.height(), - )); - let deposit_batch_groth16_wrapper = Arc::new( - DepositBatchWrapCircuit::new( - deposit_minifier.get_common_data(), - deposit_fp, - deposit_minifier.get_verifier_data().constants_sigmas_cap.height(), - ) - .into_shared_groth16_wrapper(format!("{}/.psy/keystore/deposit_append/", dirs::home_dir().unwrap().display())), - ); - - tracing::info!("Pre-building WithdrawalClaimWrapCircuit..."); - let withdrawal_template = WithdrawalBatchClaimCircuit::::build(32); - let withdrawal_fp = ParthQHashOut(psy_plonky2_circuits::proof_minifier::pm_core::get_circuit_fingerprint_generic( - &withdrawal_template.circuit_data.verifier_only, - )); - let withdrawal_claim_wrap_circuit = Arc::new(WithdrawalClaimWrapCircuit::new( - &withdrawal_template.circuit_data.common, - withdrawal_fp, - withdrawal_template.circuit_data.verifier_only.constants_sigmas_cap.height(), - )); - let withdrawal_claim_groth16_wrapper = Arc::new( - WithdrawalClaimWrapCircuit::new( - &withdrawal_template.circuit_data.common, - withdrawal_fp, - withdrawal_template.circuit_data.verifier_only.constants_sigmas_cap.height(), - ) - .into_shared_groth16_wrapper(format!("{}/.psy/keystore/withdrawal_claim/", dirs::home_dir().unwrap().display())), - ); - - tracing::info!("Pre-building BridgeWrapCircuit..."); - let coordinator_circuits = cached_bridge_coordinator_circuits()?; - let checkpoint_common_data: &CommonCircuitData = coordinator_circuits.checkpoint_root_transition.get_common_circuit_data_ref(); - let checkpoint_verifier_data = coordinator_circuits.checkpoint_root_transition.get_verifier_config_ref(); - let checkpoint_cap_height = checkpoint_verifier_data.constants_sigmas_cap.height(); - let coordinator_checkpoint_fp = coordinator_circuits.checkpoint_root_transition.get_fingerprint(); - // step_commit must use the cached library fingerprint (same as RCP circuit - // genesis proving), NOT base_fingerprint or minifier get_fingerprint(). - let cached_lib = psy_plonky2_circuits::generated::cached_circuit_library::get_cached_circuit_library::(); - let coordinator_checkpoint_step_commit_fp = cached_lib - .get_fingerprint(ProvingJobCircuitType::GenerateRollupStateTransitionProof) - .expect("GenerateRollupStateTransitionProof not found in cached circuit library"); - - tracing::info!( - "[PROXY] checkpoint minifier_fp={:?} step_commit_fp(cached)={:?}", - coordinator_checkpoint_fp.0.elements, - coordinator_checkpoint_step_commit_fp.0.elements, - ); - - let bridge_agg_template = BridgeAggFinalCircuit::::prebuild_final_circuit( - checkpoint_common_data, - checkpoint_cap_height, - coordinator_checkpoint_fp, - coordinator_checkpoint_step_commit_fp, - 32, - PsyNetworkLocalDevnetConstants::GLOBAL_USER_TREE_HEIGHT_USIZE, - PsyNetworkLocalDevnetConstants::GLOBAL_CONTRACT_TREE_HEIGHT_USIZE, - DEPOSIT_TREE_CONTRACT_STATE_TREE_HEIGHT as usize, - WITHDRAWAL_TREE_CONTRACT_STATE_TREE_HEIGHT as usize, - ); - let bridge_agg_fingerprint = bridge_agg_template.get_fingerprint(); - let bridge_agg_common = bridge_agg_template.get_common_circuit_data_ref(); - let bridge_agg_verifier = bridge_agg_template.get_verifier_config_ref(); - let bridge_wrap_circuit = Arc::new(BridgeWrapCircuit::new( - bridge_agg_common, - bridge_agg_fingerprint, - bridge_agg_verifier.constants_sigmas_cap.height(), - )); - let bridge_groth16_wrapper = Arc::new( - BridgeWrapCircuit::new( - bridge_agg_common, - bridge_agg_fingerprint, - bridge_agg_verifier.constants_sigmas_cap.height(), - ) - .into_shared_groth16_wrapper(format!("{}/.psy/keystore/", dirs::home_dir().unwrap().display())), - ); - - tracing::info!("Groth16 wrapping circuits pre-built successfully."); - - // Preload Groth16 keystores into the gnark Go runtime so the first proof - // request doesn't pay the ~15s cold-start penalty (ReadCircuit + - // ReadProvingKey). Each keystore is ~500MB–800MB on disk; loading - // lazily on first request causes relayer claim-proof-fetch timeouts. - tracing::info!("Preloading Groth16 keystores..."); - for (label, keystore_path) in [ - ("bridge", &bridge_groth16_wrapper.keystore_path), - ("deposit_append", &deposit_batch_groth16_wrapper.keystore_path), - ("withdrawal_claim", &withdrawal_claim_groth16_wrapper.keystore_path), - ] { - let keystore_dir = std::path::Path::new(keystore_path); - if keystore_dir.join("circuit_groth16.bin").exists() - && keystore_dir.join("pk_groth16.bin").exists() - && keystore_dir.join("vk_groth16.bin").exists() - { - tracing::info!(keystore = label, path = keystore_path, "preloading Groth16 setup"); - gnark_plonky2_verifier_ffi::initialize(keystore_path); - tracing::info!(keystore = label, "Groth16 setup preloaded"); - } else { - tracing::warn!(keystore = label, path = keystore_path, "skipping preload — keystore files missing"); - } - } - tracing::info!("All Groth16 keystores preloaded."); Ok(Self { rpc_provider, circuit_manager: Arc::new(circuit_manager), circuit_info: Arc::new(circuit_info), circuits_data, - keystore_dir: None, - deployments_network: "localhost".to_string(), - deposit_batch_wrap_circuit, - withdrawal_claim_wrap_circuit, - bridge_wrap_circuit, - deposit_batch_groth16_wrapper, - withdrawal_claim_groth16_wrapper, - bridge_groth16_wrapper, }) } @@ -786,478 +386,8 @@ impl ProveProxyServerProvider { } } -fn cached_bridge_coordinator_circuits() -> anyhow::Result<&'static QEDCoordinatorCircuitManager> { - static CACHE: OnceLock>> = OnceLock::new(); - CACHE - .get_or_init(|| { - tracing::info!("Building QEDCoordinatorCircuitManager for bridge agg..."); - get_plonky2_circuit_library_and_prover_for_network::(PsyChainNetworkType::LocalDevnet).map(|(_, circuits)| circuits) - }) - .as_ref() - .map_err(|e| anyhow::anyhow!("failed to build/retrieve cached bridge circuits: {}", e)) -} - -fn parse_hex_qhashout_to_qhash(h: &str) -> anyhow::Result> { - let pq = parse_hex_qhashout(h)?; - Ok(parth_core::pgoldilocks::QHashOut(pq.0)) -} - -fn qhashout_from_felts(elems: &[F]) -> parth_core::pgoldilocks::QHashOut { - parth_core::pgoldilocks::QHashOut(HashOut { - elements: [elems[0], elems[1], elems[2], elems[3]], - }) -} - -fn felt4_to_bytes32_hex(felts: &[F]) -> String { - let mut out = [0u8; 32]; - for i in 0..4 { - let v = felts[3 - i].to_canonical_u64(); - out[i * 8..(i + 1) * 8].copy_from_slice(&v.to_be_bytes()); - } - format!("0x{}", hex::encode(out)) -} - -fn u32x8_to_bytes32_hex(felts: &[F]) -> String { - let mut out = [0u8; 32]; - for i in 0..8 { - let v = felts[i].to_canonical_u64() as u32; - out[i * 4..(i + 1) * 4].copy_from_slice(&v.to_be_bytes()); - } - format!("0x{}", hex::encode(out)) -} - #[async_trait] -impl ProveProxyRpcServer for ProveProxyServerProvider { - async fn prove_withdrawal_batch_claim_groth16( - &self, - input: BridgeWithdrawalBatchWitnessInput, - ) -> Result { - tracing::debug!("prove_withdrawal_batch_claim_groth16 count={}", input.withdrawals.len()); - - let wrap_circuit = self.withdrawal_claim_wrap_circuit.clone(); - let groth16_wrapper = self.withdrawal_claim_groth16_wrapper.clone(); - tokio::task::spawn_blocking(move || { - anyhow::ensure!( - input.withdrawals.len() <= MAX_WITHDRAWAL_CLAIM_BATCH_SIZE, - "withdrawal batch too large: got {}, max {}", - input.withdrawals.len(), - MAX_WITHDRAWAL_CLAIM_BATCH_SIZE - ); - anyhow::ensure!(!input.withdrawals.is_empty(), "withdrawal batch must include at least one withdrawal"); - - let mut slot_data = vec![0u64; MAX_WITHDRAWAL_CLAIM_BATCH_SIZE * WITHDRAWAL_BATCH_CLAIM_SLOT_WORDS]; - let mut root: Option> = None; - let mut withdrawals = Vec::with_capacity(input.withdrawals.len()); - for (i, withdrawal) in input.withdrawals.iter().enumerate() { - anyhow::ensure!( - withdrawal.siblings.len() == 32, - "withdrawal[{}] expected 32 siblings, got {}", - i, - withdrawal.siblings.len() - ); - let parsed_root = parse_hex_qhashout(&withdrawal.withdrawal_root)?; - if let Some(existing) = root { - anyhow::ensure!(existing == parsed_root, "withdrawal[{}] root mismatch within batch", i); - } else { - root = Some(parsed_root); - } - let siblings = withdrawal - .siblings - .iter() - .map(|hex| parse_hex_qhashout(hex)) - .collect::>>()?; - let slot_offset = i * WITHDRAWAL_BATCH_CLAIM_SLOT_WORDS; - slot_data[slot_offset] = withdrawal.sender_user_id as u64; - for (j, word) in withdrawal.recipient.iter().enumerate() { - slot_data[slot_offset + 1 + j] = *word as u64; - } - for (j, word) in withdrawal.token.iter().enumerate() { - slot_data[slot_offset + 9 + j] = *word as u64; - } - for (j, word) in withdrawal.amount.iter().enumerate() { - slot_data[slot_offset + 17 + j] = *word as u64; - } - for (j, word) in withdrawal.nonce.iter().enumerate() { - slot_data[slot_offset + 25 + j] = *word as u64; - } - slot_data[slot_offset + 33] = withdrawal.destination_chain_index as u64; - withdrawals.push(WithdrawalBatchClaimSlotInputs:: { - sender_user_id: withdrawal.sender_user_id, - recipient: withdrawal.recipient, - token: withdrawal.token, - amount: withdrawal.amount, - nonce: withdrawal.nonce, - destination_chain_index: withdrawal.destination_chain_index, - leaf_index: withdrawal.leaf_index, - siblings, - }); - } - - let circuit = WithdrawalBatchClaimCircuit::::build(32); - let proof = circuit.generate_proof(&WithdrawalBatchClaimInputs:: { - withdrawal_root: root.expect("non-empty batch ensured above"), - bridge_user_id: input.bridge_user_id, - withdrawals, - })?; - let groth16 = wrap_circuit.prove_groth16_with_shared_wrapper(&groth16_wrapper, &circuit.circuit_data.verifier_only, &proof)?; - tracing::warn!( - withdrawal_claim_gnark_public_inputs = ?groth16.public_inputs, - "withdrawal claim gnark returned public inputs" - ); - - Ok::<_, anyhow::Error>(BridgeWithdrawalBatchGroth16Proof { - solidity_proof: g16_proof_to_solidity_words(&groth16), - public_inputs: { - let pis = proof.public_inputs.iter().map(|x| x.to_noncanonical_u64()).collect::>(); - anyhow::ensure!( - pis.len() == WITHDRAWAL_BATCH_CLAIM_PUBLIC_INPUTS_WORDS, - "expected {} withdrawal batch public inputs, got {}", - WITHDRAWAL_BATCH_CLAIM_PUBLIC_INPUTS_WORDS, - pis.len() - ); - pis - }, - slot_data, - }) - }) - .await - .map_err(|join_err| { - ErrorObjectOwned::owned( - 1, - "prove_withdrawal_batch_claim_groth16: task schedule failed", - Some(format!("Thread pool task execution failed: {}", join_err)), - ) - })? - .map_err(|err| ErrorObjectOwned::owned(1, "prove_withdrawal_batch_claim_groth16 proving error", Some(err.to_string()))) - } - - async fn prove_deposit_batch_append_groth16( - &self, - input: BridgeDepositBatchWitnessInput, - ) -> Result { - tracing::debug!( - "prove_deposit_batch_append_groth16 from_index={} count={}", - input.from_index, - input.deposits.len() - ); - - let wrap_circuit = self.deposit_batch_wrap_circuit.clone(); - let groth16_wrapper = self.deposit_batch_groth16_wrapper.clone(); - tokio::task::spawn_blocking(move || { - anyhow::ensure!( - input.old_frontier.len() == 32, - "expected 32 frontier nodes, got {}", - input.old_frontier.len() - ); - anyhow::ensure!(!input.deposits.is_empty(), "deposit batch must include at least one deposit"); - - let old_frontier_vec = input - .old_frontier - .iter() - .map(|hex| parse_hex_qhashout(hex)) - .collect::>>()?; - let old_frontier: [ParthQHashOut; 32] = old_frontier_vec - .try_into() - .map_err(|v: Vec>| anyhow::anyhow!("invalid frontier length: {}", v.len()))?; - let deposits = input - .deposits - .into_iter() - .map(|leaf| DepositBatchLeafData { - shield_address: leaf.shield_address, - token: leaf.token, - l2_token_contract_id: leaf.l2_token_contract_id, - amount: leaf.amount, - chain_index: leaf.chain_index, - note_commitment: leaf.note_commitment, - }) - .collect::>(); - let batch_inputs = DepositBatchAppendInputs { - frontier: old_frontier, - from_index: input.from_index, - deposits, - bridge_user_id: input.bridge_user_id, - }; - - let circuit = DepositBatchAppendCircuit::::build( - psy_plonky2_common_circuits::bridge::deposit_batch_append_circuit::MAX_DEPOSIT_BATCH_SIZE, - 32, - ); - let proof = circuit.generate_proof(&batch_inputs)?; - let preimage = compute_batch_append_preimage(&batch_inputs); - let minifier = psy_plonky2_circuits::proof_minifier::pm_chain::QEDProofMinifierChain::::new( - &circuit.circuit_data.verifier_only, - &circuit.circuit_data.common, - 2, - ); - let minified_proof = minifier.prove(&proof)?; - let groth16 = wrap_circuit.prove_groth16_with_shared_wrapper(&groth16_wrapper, minifier.get_verifier_data(), &minified_proof)?; - - Ok::<_, anyhow::Error>(BridgeDepositBatchGroth16Proof { - solidity_proof: g16_proof_to_solidity_words(&groth16), - public_inputs: preimage.to_u32_words().into_iter().map(|x| x as u64).collect(), - }) - }) - .await - .map_err(|join_err| { - ErrorObjectOwned::owned( - 1, - "prove_deposit_batch_append_groth16: task schedule failed", - Some(format!("Thread pool task execution failed: {}", join_err)), - ) - })? - .map_err(|err| ErrorObjectOwned::owned(1, "prove_deposit_batch_append_groth16 proving error", Some(err.to_string()))) - } - - async fn prove_bridge_agg_groth16( - &self, - _deps_network: String, - input: BridgeAggWitnessInput, - ) -> Result { - tracing::debug!("prove_bridge_agg_groth16 from={} to={}", input.from_checkpoint, input.to_checkpoint); - - let from_checkpoint = input.from_checkpoint.max(1); - let to_checkpoint = input.to_checkpoint; - if from_checkpoint > to_checkpoint { - return Err(ErrorObjectOwned::owned( - 1, - "prove_bridge_agg_groth16: from_checkpoint must be <= to_checkpoint", - None::<()>, - )); - } - let num_checkpoints_aggregated = to_checkpoint - from_checkpoint + 1; - - let wrap_circuit = self.bridge_wrap_circuit.clone(); - let groth16_wrapper = self.bridge_groth16_wrapper.clone(); - tokio::task::spawn_blocking(move || -> Result { - use psy_plonky2_circuits::qstandard::QStandardCircuit; - let coordinator_circuits = cached_bridge_coordinator_circuits() - .map_err(|e| ErrorObjectOwned::owned(1, "failed to load bridge circuits", Some(e.to_string())))?; - - let checkpoint_common_data: &CommonCircuitData = - coordinator_circuits.checkpoint_root_transition.get_common_circuit_data_ref(); - let checkpoint_verifier_data = - coordinator_circuits.checkpoint_root_transition.get_verifier_config_ref(); - let cap_height = checkpoint_verifier_data.constants_sigmas_cap.height(); - let coordinator_checkpoint_fp = - coordinator_circuits.checkpoint_root_transition.get_fingerprint(); - let checkpoint_state_transition_fingerprint = parse_hex_qhashout_to_qhash(&input.checkpoint_fp) - .map_err(|e| ErrorObjectOwned::owned(1, "parse checkpoint_fp", Some(e.to_string())))?; - if checkpoint_state_transition_fingerprint != coordinator_checkpoint_fp { - return Err(ErrorObjectOwned::owned( - 1, - "checkpoint_fp mismatch", - Some(format!( - "bridge agg witness checkpoint_fp differs from proxy coordinator fingerprint: input={:?} coordinator={:?}", - checkpoint_state_transition_fingerprint, - coordinator_checkpoint_fp - )), - )); - } - // step_commit must use the cached library fingerprint (same as RCP circuit genesis proving). - let cached_lib = psy_plonky2_circuits::generated::cached_circuit_library::get_cached_circuit_library::(); - let checkpoint_step_commit_fingerprint = cached_lib - .get_fingerprint(ProvingJobCircuitType::GenerateRollupStateTransitionProof) - .expect("GenerateRollupStateTransitionProof not found in cached circuit library"); - - // Deserialize the final (to_checkpoint) checkpoint proof from bincode hex - let final_checkpoint_proof_bytes = hex::decode( - input.final_checkpoint_proof_hex.trim_start_matches("0x"), - ) - .map_err(|e| ErrorObjectOwned::owned(1, "hex decode final checkpoint proof", Some(e.to_string())))?; - let final_checkpoint_proof: ProofWithPublicInputs = - bincode::deserialize(&final_checkpoint_proof_bytes) - .map_err(|e| ErrorObjectOwned::owned(1, "bincode deserialize final checkpoint proof", Some(e.to_string())))?; - - // Parse delta merkle proofs - use plonky2::hash::poseidon::PoseidonHash; - let parse_delta = |dp: &BridgeAggDeltaProof| -> anyhow::Result>> { - let new_value = parse_hex_qhashout_to_qhash(&dp.new_value)?; - let siblings = dp.siblings.iter().map(|s| parse_hex_qhashout_to_qhash(s)).collect::, _>>()?; - Ok(ParthDeltaMerkleProofCore::from_params::( - dp.index, - parth_core::pgoldilocks::QHashOut::default(), - new_value, - siblings, - )) - }; - - let delta_merkle_proofs: Vec>> = input.delta_merkle_proofs - .iter() - .map(parse_delta) - .collect::>>() - .map_err(|e| ErrorObjectOwned::owned(1, "parse delta proofs", Some(e.to_string())))?; - - let pre_delta_merkle_proofs: Vec>> = input.pre_delta_merkle_proofs - .iter() - .map(parse_delta) - .collect::>>() - .map_err(|e| ErrorObjectOwned::owned(1, "parse pre-delta proofs", Some(e.to_string())))?; - - // `chain_start` is the chain hash immediately before the aggregated range - // (chain hash of checkpoint `from_checkpoint - 1`; for `from_checkpoint <= 1` - // this is the genesis checkpoint state transition hash). - let start_chain_hash = parse_hex_qhashout_to_qhash(&input.chain_start) - .map_err(|e| ErrorObjectOwned::owned(1, "parse chain_start", Some(e.to_string())))?; - - let final_leaf = psy_data::v1::qdata::checkpoint::PQEDCheckpointLeafCompact { - global_chain_root: parse_hex_qhashout_to_qhash(&input.final_checkpoint_leaf.global_chain_root) - .map_err(|e| ErrorObjectOwned::owned(1, "parse final leaf chain root", Some(e.to_string())))?, - stats_hash: parse_hex_qhashout_to_qhash(&input.final_checkpoint_leaf.stats_hash) - .map_err(|e| ErrorObjectOwned::owned(1, "parse final leaf stats hash", Some(e.to_string())))?, - }; - - // Parse global state roots (anchors the user_tree_root to the verified checkpoint) - let parse_qhash = |hex: &str| -> anyhow::Result> { - parse_hex_qhashout_to_qhash(hex) - }; - let global_state_roots = PQEDCheckpointGlobalStateRoots { - contract_tree_root: parse_qhash(&input.final_checkpoint_global_state_roots.contract_tree_root) - .map_err(|e| ErrorObjectOwned::owned(1, "parse contract_tree_root", Some(e.to_string())))?, - deposit_tree_root: parse_qhash(&input.final_checkpoint_global_state_roots.deposit_tree_root) - .map_err(|e| ErrorObjectOwned::owned(1, "parse deposit_tree_root", Some(e.to_string())))?, - user_tree_root: parse_qhash(&input.final_checkpoint_global_state_roots.user_tree_root) - .map_err(|e| ErrorObjectOwned::owned(1, "parse user_tree_root", Some(e.to_string())))?, - withdrawal_tree_root: parse_qhash(&input.final_checkpoint_global_state_roots.withdrawal_tree_root) - .map_err(|e| ErrorObjectOwned::owned(1, "parse withdrawal_tree_root", Some(e.to_string())))?, - user_registration_tree_root: parse_qhash(&input.final_checkpoint_global_state_roots.user_registration_tree_root) - .map_err(|e| ErrorObjectOwned::owned(1, "parse user_registration_tree_root", Some(e.to_string())))?, - }; - - // Parse witnesses (slot witnesses are the full TreeRootInContractStateWitnessInput) - let parse_slot_witness = |w: &BridgeAggSlotWitness| -> anyhow::Result> { - let user_leaf = psy_data::v1::qdata::user::PQEDUserLeaf::> { - public_key: parse_hex_qhashout_to_qhash(&w.user_leaf_public_key)?, - user_state_tree_root: parse_hex_qhashout_to_qhash(&w.user_leaf_user_state_tree_root)?, - balance: F::from_canonical_u64(w.user_leaf_balance), - nonce: F::from_canonical_u64(w.user_leaf_nonce), - last_checkpoint_id: F::from_canonical_u64(w.user_leaf_last_checkpoint_id), - event_index: F::from_canonical_u64(w.user_leaf_event_index), - user_id: F::from_canonical_u64(w.user_leaf_user_id), - }; - - let mk_proof = |root: &str, value: &str, index: u64, sibs: &[String]| -> anyhow::Result>> { - Ok(parth_core::crypto::hash::merkle_proof::MerkleProofCore { - root: parse_hex_qhashout_to_qhash(root)?, - value: parse_hex_qhashout_to_qhash(value)?, - index, - siblings: sibs.iter().map(|s| parse_hex_qhashout_to_qhash(s)).collect::, _>>()?, - }) - }; - - Ok(TreeRootInContractStateWitnessInput { - owner_user_id: w.owner_user_id, - contract_id: w.contract_id, - user_leaf, - slot0_proof: mk_proof(&w.slot0_root, &w.slot0_value, w.slot0_index, &w.slot0_siblings)?, - slot1_proof: mk_proof(&w.slot1_root, &w.slot1_value, w.slot1_index, &w.slot1_siblings)?, - contract_proof: mk_proof(&w.contract_root, &w.contract_value, w.contract_index, &w.contract_siblings)?, - user_tree_proof: mk_proof(&w.user_tree_root, &w.user_tree_value, w.user_tree_index, &w.user_tree_siblings)?, - }) - }; - - let deposit_witness = parse_slot_witness(&input.deposit_witness) - .map_err(|e| ErrorObjectOwned::owned(1, "parse deposit witness", Some(e.to_string())))?; - let withdrawal_witness = parse_slot_witness(&input.withdrawal_witness) - .map_err(|e| ErrorObjectOwned::owned(1, "parse withdrawal witness", Some(e.to_string())))?; - - tracing::info!( - "Proving bridge aggregation for checkpoints {} to {}...", - from_checkpoint, - to_checkpoint - ); - - let result = BridgeAggFinalCircuit::::prove_range( - from_checkpoint, - to_checkpoint, - start_chain_hash, - checkpoint_common_data, - cap_height, - checkpoint_state_transition_fingerprint, - checkpoint_step_commit_fingerprint, - &final_checkpoint_proof, - &checkpoint_verifier_data, - &delta_merkle_proofs, - &pre_delta_merkle_proofs, - &final_leaf, - &global_state_roots, - &deposit_witness, - &withdrawal_witness, - 32, // CHECKPOINT_TREE_HEIGHT - PsyNetworkLocalDevnetConstants::GLOBAL_USER_TREE_HEIGHT_USIZE, - PsyNetworkLocalDevnetConstants::GLOBAL_CONTRACT_TREE_HEIGHT_USIZE, - DEPOSIT_TREE_CONTRACT_STATE_TREE_HEIGHT as usize, - WITHDRAWAL_TREE_CONTRACT_STATE_TREE_HEIGHT as usize, - ) - .map_err(|e| ErrorObjectOwned::owned(1, "bridge_agg prove_range failed", Some(e.to_string())))?; - - let bridge_agg_proof = result.proof; - let bridge_agg_verifier_data = result.verifier_data; - - tracing::info!("Proving BridgeWrapCircuit (Groth16 wrap)..."); - let groth16_proof = wrap_circuit - .prove_groth16_with_shared_wrapper(&groth16_wrapper, &bridge_agg_verifier_data, &bridge_agg_proof) - .map_err(|e| ErrorObjectOwned::owned(1, "bridge_wrap Groth16 failed", Some(e.to_string())))?; - - // Format outputs - let groth16_pi = &bridge_agg_proof.public_inputs; - let checkpoint_roots = vec![ - felt4_to_bytes32_hex(&groth16_pi[0..4]), - felt4_to_bytes32_hex(&groth16_pi[20..24]), - ]; - let deposit_tree_root = u32x8_to_bytes32_hex(&groth16_pi[4..12]); - let withdrawal_tree_root = u32x8_to_bytes32_hex(&groth16_pi[12..20]); - let end_checkpoint_index = groth16_pi[24].to_canonical_u64(); - if end_checkpoint_index != to_checkpoint { - return Err(ErrorObjectOwned::owned( - 1, - "prove_bridge_agg_groth16: end_checkpoint_index mismatch", - Some(format!("pi={} expected={}", end_checkpoint_index, to_checkpoint)), - )); - } - - let solidity_words = g16_proof_to_solidity_words(&groth16_proof); - let pub_inputs_0 = groth16_proof.public_inputs[0].clone(); - let pub_inputs_1 = groth16_proof.public_inputs[1].clone(); - let public_inputs_str: Vec = groth16_pi.iter().map(|x| x.to_canonical_u64().to_string()).collect(); - let num_pis = groth16_pi.len(); - - Ok(BridgeAggGroth16Output { - from_checkpoint, - to_checkpoint, - num_checkpoints_aggregated, - bridge_agg_public_inputs_count: num_pis, - bridge_agg_public_inputs: public_inputs_str, - groth16_proof, - solidity_proof: [ - solidity_words[0].clone(), - solidity_words[1].clone(), - solidity_words[2].clone(), - solidity_words[3].clone(), - solidity_words[4].clone(), - solidity_words[5].clone(), - solidity_words[6].clone(), - solidity_words[7].clone(), - ], - solidity_public_inputs: [ - pub_inputs_0, - pub_inputs_1, - ], - checkpoint_roots, - deposit_tree_root, - withdrawal_tree_root, - end_checkpoint_index, - }) - }) - .await - .map_err(|join_err| { - ErrorObjectOwned::owned( - 1, - "prove_bridge_agg_groth16: task schedule failed", - Some(format!("Thread pool task execution failed: {}", join_err)), - ) - })? - } - +impl ProveProxyUserRpcServer for UserProveProvider { async fn prove_ups_start(&self, input: UPSStartStepInput) -> Result, ErrorObjectOwned> { tracing::debug!("prove_ups_start input"); From ba0a1b8bbeee7f3bcc53843b8b67a2f7aaba9afc Mon Sep 17 00:00:00 2001 From: hellojason3 Date: Thu, 10 Sep 2026 13:11:06 +0800 Subject: [PATCH 03/13] feat(prove-proxy): assemble rpc module by role, add psy_get_prove_proxy_role --- client_prover/psy_prover/Cargo.toml | 2 + .../src/local/native/prove_proxy/mod.rs | 53 +++++++++++++ .../psy_prover/tests/prove_proxy_role.rs | 77 +++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 client_prover/psy_prover/tests/prove_proxy_role.rs diff --git a/client_prover/psy_prover/Cargo.toml b/client_prover/psy_prover/Cargo.toml index 0474345b..01d3f03b 100644 --- a/client_prover/psy_prover/Cargo.toml +++ b/client_prover/psy_prover/Cargo.toml @@ -83,6 +83,8 @@ js-sys = { workspace = true } [dev-dependencies] flate2 = { workspace = true } +tokio = { workspace = true } +anyhow = { workspace = true } [features] gnark-wrap = ["dep:gnark-plonky2-verifier-ffi", "psy_plonky2_circuits/gnark-wrap"] diff --git a/client_prover/psy_prover/src/local/native/prove_proxy/mod.rs b/client_prover/psy_prover/src/local/native/prove_proxy/mod.rs index f9f3c1dd..b89a09e7 100644 --- a/client_prover/psy_prover/src/local/native/prove_proxy/mod.rs +++ b/client_prover/psy_prover/src/local/native/prove_proxy/mod.rs @@ -10,8 +10,61 @@ pub mod system; #[cfg(feature = "gnark-wrap")] pub mod types; +use jsonrpsee::{ + proc_macros::rpc, + server::{Methods, RpcModule}, + types::ErrorObjectOwned, +}; use plonky2::plonk::config::{GenericConfig, PoseidonGoldilocksConfig}; +use psy_client_common::args::ProveProxyRole; pub(crate) type C = PoseidonGoldilocksConfig; pub(crate) type F = >::F; pub(crate) const D: usize = 2; + +/// Answer of `psy_get_prove_proxy_role`. Lets deploy scripts and operators +/// confirm which pool an instance belongs to without probing proof methods. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct ProveProxyRoleInfo { + pub role: String, + pub user_methods: bool, + pub system_methods: bool, +} + +#[rpc(server, client, namespace = "psy")] +pub trait ProveProxyInfoRpc { + #[method(name = "get_prove_proxy_role")] + async fn get_prove_proxy_role(&self) -> Result; +} + +struct RoleInfoProvider(ProveProxyRole); + +#[jsonrpsee::core::async_trait] +impl ProveProxyInfoRpcServer for RoleInfoProvider { + async fn get_prove_proxy_role(&self) -> Result { + Ok(ProveProxyRoleInfo { + role: self.0.as_str().to_string(), + user_methods: self.0.serves_user(), + system_methods: self.0.serves_system(), + }) + } +} + +/// Builds the server module for `role`. Constructors for families the role +/// does not serve are never invoked, so a `user` process never builds bridge +/// circuits and a `system` process never builds the UPS circuit manager. +pub fn assemble_rpc_module(role: ProveProxyRole, make_user: U, make_system: S) -> anyhow::Result> +where + U: FnOnce() -> anyhow::Result, + S: FnOnce() -> anyhow::Result, +{ + let mut module = RpcModule::new(()); + module.merge(RoleInfoProvider(role).into_rpc())?; + if role.serves_user() { + module.merge(make_user()?)?; + } + if role.serves_system() { + module.merge(make_system()?)?; + } + Ok(module) +} diff --git a/client_prover/psy_prover/tests/prove_proxy_role.rs b/client_prover/psy_prover/tests/prove_proxy_role.rs new file mode 100644 index 00000000..dd1723ca --- /dev/null +++ b/client_prover/psy_prover/tests/prove_proxy_role.rs @@ -0,0 +1,77 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + +use jsonrpsee::server::RpcModule; +use psy_client_common::args::ProveProxyRole; +use psy_prover::local::native::prove_proxy::{assemble_rpc_module, ProveProxyRoleInfo}; + +fn marker(name: &'static str) -> jsonrpsee::server::Methods { + let mut m = RpcModule::new(()); + m.register_method(name, |_, _, _| "ok").unwrap(); + m.into() +} + +fn names(module: &RpcModule<()>) -> Vec<&'static str> { + let mut v: Vec<_> = module.method_names().collect(); + v.sort(); + v +} + +#[test] +fn user_role_registers_only_user_family() { + let user_called = AtomicBool::new(false); + let system_called = AtomicBool::new(false); + let module = assemble_rpc_module( + ProveProxyRole::User, + || { user_called.store(true, Ordering::SeqCst); Ok(marker("psy_user_marker")) }, + || { system_called.store(true, Ordering::SeqCst); Ok(marker("psy_system_marker")) }, + ) + .unwrap(); + assert!(user_called.load(Ordering::SeqCst)); + assert!(!system_called.load(Ordering::SeqCst), "system constructor must not run in user role"); + assert_eq!(names(&module), vec!["psy_get_prove_proxy_role", "psy_user_marker"]); +} + +#[test] +fn system_role_registers_only_system_family() { + let user_called = AtomicBool::new(false); + let module = assemble_rpc_module( + ProveProxyRole::System, + || { user_called.store(true, Ordering::SeqCst); Ok(marker("psy_user_marker")) }, + || Ok(marker("psy_system_marker")), + ) + .unwrap(); + assert!(!user_called.load(Ordering::SeqCst), "user constructor must not run in system role"); + assert_eq!(names(&module), vec!["psy_get_prove_proxy_role", "psy_system_marker"]); +} + +#[test] +fn all_role_registers_both() { + let module = assemble_rpc_module( + ProveProxyRole::All, + || Ok(marker("psy_user_marker")), + || Ok(marker("psy_system_marker")), + ) + .unwrap(); + assert_eq!(names(&module), vec!["psy_get_prove_proxy_role", "psy_system_marker", "psy_user_marker"]); +} + +#[test] +fn constructor_error_propagates() { + let err = assemble_rpc_module( + ProveProxyRole::System, + || Ok(marker("psy_user_marker")), + || Err(anyhow::anyhow!("gnark keystore missing")), + ) + .err() + .expect("must fail"); + assert!(err.to_string().contains("gnark keystore missing")); +} + +#[tokio::test] +async fn role_info_reports_role() { + let module = assemble_rpc_module(ProveProxyRole::System, || Ok(marker("psy_user_marker")), || Ok(marker("psy_system_marker"))).unwrap(); + let info: ProveProxyRoleInfo = module.call("psy_get_prove_proxy_role", jsonrpsee::rpc_params![]).await.unwrap(); + assert_eq!(info.role, "system"); + assert!(!info.user_methods); + assert!(info.system_methods); +} From f945f0510212d622834c8361a6f640b958c7e160 Mon Sep 17 00:00:00 2001 From: hellojason3 Date: Thu, 10 Sep 2026 13:15:27 +0800 Subject: [PATCH 04/13] feat(prove-proxy): build only the providers the role serves --- client_prover/psy_prover/src/lib.rs | 41 ++++++++++++++----- .../src/local/native/prove_proxy/mod.rs | 2 +- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/client_prover/psy_prover/src/lib.rs b/client_prover/psy_prover/src/lib.rs index 32965158..26c4978c 100644 --- a/client_prover/psy_prover/src/lib.rs +++ b/client_prover/psy_prover/src/lib.rs @@ -67,7 +67,7 @@ pub async fn run_prove_proxy_server(args: psy_client_common::args::ProveProxyArg use std::net::SocketAddr; use hyper::Method; - use jsonrpsee::server::{RpcModule, ServerBuilder, ServerConfig}; + use jsonrpsee::server::{ServerBuilder, ServerConfig}; use psy_client_common::health::HealthLayer; use tower_http::cors::{Any, CorsLayer}; @@ -76,15 +76,34 @@ pub async fn run_prove_proxy_server(args: psy_client_common::args::ProveProxyArg let psy_config = psy_config::PsyConfigGoldilocks::from_file(&args.rpc_config)?; let rpc_config = psy_config.get_current_network()?; - let mut module = RpcModule::new(()); - let user = UserProveProvider::new_with_config(rpc_config.clone(), PSY_NETWORK_MAGIC).await?; - module.merge(user.into_rpc())?; - #[cfg(feature = "gnark-wrap")] - { - use crate::local::native::prove_proxy::system::{ProveProxySystemRpcServer, SystemProveProvider}; - let system = SystemProveProvider::new()?; - module.merge(system.into_rpc())?; - } + use crate::local::native::prove_proxy::assemble_rpc_module; + + let role = args.role; + tracing::info!(role = role.as_str(), "prove proxy role"); + + // User circuits are built before assembly because the constructor is + // async; assemble_rpc_module only decides whether to *use* it. + let user = if role.serves_user() { + Some(UserProveProvider::new_with_config(rpc_config.clone(), PSY_NETWORK_MAGIC).await?) + } else { + None + }; + + let module = assemble_rpc_module( + role, + move || Ok(user.expect("user provider built when role serves user").into_rpc().into()), + || { + #[cfg(feature = "gnark-wrap")] + { + use crate::local::native::prove_proxy::system::{ProveProxySystemRpcServer, SystemProveProvider}; + Ok(SystemProveProvider::new()?.into_rpc().into()) + } + #[cfg(not(feature = "gnark-wrap"))] + { + anyhow::bail!("role `{}` needs system proofs, but this binary was built without the `gnark-wrap` feature", role.as_str()) + } + }, + )?; let cors_opts = CorsLayer::new() .allow_methods([Method::POST, Method::OPTIONS]) @@ -105,7 +124,7 @@ pub async fn run_prove_proxy_server(args: psy_client_common::args::ProveProxyArg .await?; let handle = server.start(module); - println!("\n[CFLI:PSY_PROVE_PROXY_STARTED][{}]\n", server_addr); + println!("\n[CFLI:PSY_PROVE_PROXY_STARTED][{}][{}]\n", server_addr, role.as_str()); handle.stopped().await; Ok(()) } diff --git a/client_prover/psy_prover/src/local/native/prove_proxy/mod.rs b/client_prover/psy_prover/src/local/native/prove_proxy/mod.rs index b89a09e7..73d33a98 100644 --- a/client_prover/psy_prover/src/local/native/prove_proxy/mod.rs +++ b/client_prover/psy_prover/src/local/native/prove_proxy/mod.rs @@ -1,7 +1,7 @@ //! prove-proxy: wallet-facing (user) and relayer-facing (system) proving RPCs. //! //! `user` and `system` are separate `#[rpc]` traits so a process can register -//! exactly one family; see `assemble_rpc_module` (Task 3). +//! exactly one family; see `assemble_rpc_module`. pub mod user; From 48a1fc1037f5595548f04003ff75c66fe7aca7c3 Mon Sep 17 00:00:00 2001 From: hellojason3 Date: Thu, 10 Sep 2026 13:23:50 +0800 Subject: [PATCH 05/13] feat(config): add system_prove_proxy_url to NetworkConfig --- client_prover/psy_core/psy_config/src/lib.rs | 58 ++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/client_prover/psy_core/psy_config/src/lib.rs b/client_prover/psy_core/psy_config/src/lib.rs index 8352705d..08bcc984 100644 --- a/client_prover/psy_core/psy_config/src/lib.rs +++ b/client_prover/psy_core/psy_config/src/lib.rs @@ -55,6 +55,10 @@ pub struct NetworkConfig { pub realm_configs: Vec, pub coordinator_configs: Vec, pub prove_proxy_url: Vec, + /// Prove-proxy pool that serves the bridge Groth16 (relayer) proofs. + /// The relayer refuses to start when this is empty for the current network. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub system_prove_proxy_url: Vec, pub faucet_rpc_url: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub api_services_url: Option>, @@ -803,6 +807,7 @@ mod tests { "realm_configs": [{"id": 0, "rpc_url": ["http://127.0.0.1:8546"]}], "coordinator_configs": [{"id": 0, "rpc_url": ["http://127.0.0.1:8545"]}], "prove_proxy_url": ["http://127.0.0.1:9999"], + "system_prove_proxy_url": ["http://127.0.0.1:9999"], "native_currency": "PSY", "native_currency_decimal": 9, "native_currency_name": "PSY", @@ -822,6 +827,7 @@ mod tests { "realm_configs": [{"id": 0, "rpc_url": ["https://testnet.example.com"]}], "coordinator_configs": [{"id": 0, "rpc_url": ["https://testnet-coord.example.com"]}], "prove_proxy_url": ["https://testnet-prover.example.com"], + "system_prove_proxy_url": ["https://testnet-prover.example.com"], "native_currency": "tPSY", "native_currency_decimal": 9, "native_currency_name": "Test PSY", @@ -846,6 +852,53 @@ mod tests { assert_eq!(testnet.fees.register_user_fee, 1000); } + #[test] + fn system_prove_proxy_url_defaults_to_empty_and_reads_when_present() { + // Template: the "localhost" network object's fields from test_network_switching + // above, plus `magic`, `faucet_rpc_url` and `nostr_relay_url` (required fields + // on NetworkConfig with no serde default) and without the extra "network" + // wrapper key that test_network_switching's literal uses. Config::networks is + // HashMap>, i.e. flat — real config.json (see + // psy-genesis/config.json) has no "network" wrapper either. The + // test_network_switching-family tests are pre-existing failures for this + // structural reason; see task report. + // This base still does not contain system_prove_proxy_url. + let base = r#"{ + "magic": "0x1", + "users_per_realm": 1048576, + "global_user_tree_height": 24, + "realm_user_tree_height": 20, + "group_realm_height": 1, + "realm_configs": [{"id": 0, "rpc_url": ["http://127.0.0.1:8546"]}], + "coordinator_configs": [{"id": 0, "rpc_url": ["http://127.0.0.1:8545"]}], + "prove_proxy_url": ["http://127.0.0.1:9999"], + "faucet_rpc_url": ["http://127.0.0.1:8547"], + "nostr_relay_url": "wss://relay.127.0.0.1.example", + "native_currency": "PSY", + "native_currency_decimal": 9, + "native_currency_name": "PSY", + "fees": { + "register_user_fee": 0, + "deploy_contract_fee": 0, + "guta_fee": 5000000000, + "da_fee": 0 + } + }"#; + let without = format!(r#"{{"networks":{{"localhost":{base}}},"defaultNetwork":"localhost"}}"#); + let with = without.replacen( + r#""prove_proxy_url": ["http://127.0.0.1:9999"],"#, + r#""prove_proxy_url": ["http://127.0.0.1:9999"], "system_prove_proxy_url": ["http://127.0.0.1:9997"],"#, + 1, + ); + assert_ne!(with, without, "replacen must hit the prove_proxy_url line"); + + let cfg = PsyConfigGoldilocks::from_json(&without).unwrap(); + assert!(cfg.get_current_network().unwrap().system_prove_proxy_url.is_empty()); + + let cfg = PsyConfigGoldilocks::from_json(&with).unwrap(); + assert_eq!(cfg.get_current_network().unwrap().system_prove_proxy_url, vec!["http://127.0.0.1:9997"]); + } + #[test] fn test_flexible_config_creation() { let json = r#"{ @@ -859,6 +912,7 @@ mod tests { "realm_configs": [{"id": 0, "rpc_url": ["http://dev.local"]}], "coordinator_configs": [{"id": 0, "rpc_url": ["http://coord.local"]}], "prove_proxy_url": ["http://prover.local"], + "system_prove_proxy_url": ["http://prover.local"], "native_currency": "DEV", "native_currency_decimal": 6, "native_currency_name": "Development", @@ -878,6 +932,7 @@ mod tests { "realm_configs": [{"id": 0, "rpc_url": ["http://localhost:8546"]}], "coordinator_configs": [{"id": 0, "rpc_url": ["http://localhost:8545"]}], "prove_proxy_url": ["http://localhost:9999"], + "system_prove_proxy_url": ["http://localhost:9999"], "native_currency": "LOCAL", "native_currency_decimal": 8, "native_currency_name": "Local Token", @@ -917,6 +972,7 @@ mod tests { "realm_configs": [{"id": 0, "rpc_url": ["http://dev.local"]}], "coordinator_configs": [{"id": 0, "rpc_url": ["http://coord.local"]}], "prove_proxy_url": ["http://prover.local"], + "system_prove_proxy_url": ["http://prover.local"], "native_currency": "DEV", "native_currency_decimal": 6, "native_currency_name": "Development", @@ -936,6 +992,7 @@ mod tests { "realm_configs": [{"id": 0, "rpc_url": ["http://localhost:8546"]}], "coordinator_configs": [{"id": 0, "rpc_url": ["http://localhost:8545"]}], "prove_proxy_url": ["http://localhost:9999"], + "system_prove_proxy_url": ["http://localhost:9999"], "native_currency": "LOCAL", "native_currency_decimal": 8, "native_currency_name": "Local Token", @@ -983,6 +1040,7 @@ mod tests { "realm_configs": [{"id": 0, "rpc_url": ["http://test.local"]}], "coordinator_configs": [{"id": 0, "rpc_url": ["http://coord.local"]}], "prove_proxy_url": ["http://prover.local"], + "system_prove_proxy_url": ["http://prover.local"], "native_currency": "TEST", "native_currency_decimal": 6, "native_currency_name": "Test", From 57dfd9bd56fb6a5541297495d67d1a688d255e80 Mon Sep 17 00:00:00 2001 From: hellojason3 Date: Thu, 10 Sep 2026 13:34:21 +0800 Subject: [PATCH 06/13] feat(relayer): require system_prove_proxy_url, no fallback to the user pool --- psy_cli/psy_relayer_cli/src/bridge/daemon.rs | 117 +++++++++++++++--- .../psy_relayer_cli/src/bridge/l1_client.rs | 10 +- 2 files changed, 105 insertions(+), 22 deletions(-) diff --git a/psy_cli/psy_relayer_cli/src/bridge/daemon.rs b/psy_cli/psy_relayer_cli/src/bridge/daemon.rs index 6403b441..def2f16d 100644 --- a/psy_cli/psy_relayer_cli/src/bridge/daemon.rs +++ b/psy_cli/psy_relayer_cli/src/bridge/daemon.rs @@ -857,8 +857,8 @@ async fn run_multichain( let max_batch = config.max_checkpoint_batch.unwrap_or(DEFAULT_MAX_CHECKPOINT_BATCH); validate_max_checkpoint_batch(max_batch)?; let state_path = proof_dir.join("daemon_state_multichain.toml"); - let proxy = resolve_prove_proxy_url(&config); - if proxy.is_none() { warmup_bridge_resources()?; } + let proxy = resolve_system_prove_proxy_url(&config)?; + tracing::info!(prove_proxy = %proxy, "system prove proxy configured"); tracing::info!(config=%config_path.display(), chain_count=chains.len(), %identity_namespace, "multichain bridge relayer started"); loop { @@ -948,7 +948,7 @@ async fn run_multichain( let needs_finalize = chains.iter().any(|chain| cursors[&chain.chain_index] < to_checkpoint); if needs_finalize && !shared_path.exists() { if let Err(error) = chains[0].l1.load_or_build_proof( - &chains[0].config, &shared_path, from_checkpoint, to_checkpoint, proxy.as_deref(), + &chains[0].config, &shared_path, from_checkpoint, to_checkpoint, Some(proxy.as_str()), ).await { tracing::error!(%error, "shared bridge proof generation failed"); tokio::time::sleep(poll_interval).await; continue; @@ -1028,12 +1028,8 @@ async fn run_single_chain(config: BridgeProposeDaemonConfig, config_path: &Path) tracing::info!(config = %config_path.display(), proof_dir = %proof_dir.display(), "bridge relayer started"); // Phase 4.4: skip local circuit/Groth16 warmup when remote prove proxy is configured - let proxy_url_at_startup = resolve_prove_proxy_url(&config); - if proxy_url_at_startup.is_some() { - tracing::info!("prove proxy configured; skipping local circuit/Groth16 warmup"); - } else { - warmup_bridge_resources()?; - } + let proxy_url_at_startup = resolve_system_prove_proxy_url(&config)?; + tracing::info!(prove_proxy = %proxy_url_at_startup, "system prove proxy configured; local Groth16 warmup skipped"); let provider = RpcProvider::new_with_config_path(&config.rpc_config)?; let l1 = L1Client::from_finalize_config(&config.finalize); @@ -1321,7 +1317,7 @@ async fn run_single_chain(config: BridgeProposeDaemonConfig, config_path: &Path) // PHASE 3 ─ Proof Generation (bridge aggregation + Groth16 wrap) // ═══════════════════════════════════════════════════════════════════ - let prove_proxy_url = resolve_prove_proxy_url(&config); + let prove_proxy_url = resolve_system_prove_proxy_url(&config)?; let prove_result = match dispatch_post_l2_phase( &post_l2_phase_permit, l1.load_or_build_proof( @@ -1329,7 +1325,7 @@ async fn run_single_chain(config: BridgeProposeDaemonConfig, config_path: &Path) &proof_path, from_checkpoint, to_checkpoint, - prove_proxy_url.as_deref(), + Some(prove_proxy_url.as_str()), ), ) .await @@ -1651,6 +1647,7 @@ pub(crate) async fn submit_deposit_batch_appends_with_l1_rpc( Ok(()) } +#[allow(dead_code)] // kept for the local-proving CLI path fn warmup_bridge_resources() -> anyhow::Result<()> { tracing::info!("warming bridge relayer resources"); @@ -1682,14 +1679,29 @@ fn warmup_bridge_resources() -> anyhow::Result<()> { Ok(()) } -pub(crate) fn resolve_prove_proxy_url(config: &BridgeProposeDaemonConfig) -> Option { - let rpc_config = psy_config::PsyConfigGoldilocks::from_file(&config.rpc_config).ok()?; - let network = rpc_config.get_current_network().ok()?; +/// The prove-proxy pool that serves bridge Groth16 proofs. +/// +/// Reads `system_prove_proxy_url` for the current network. There is no +/// fallback to `prove_proxy_url`: that pool runs the `user` role and does not +/// register the bridge methods, so routing there would only fail later. +pub(crate) fn resolve_system_prove_proxy_url(config: &BridgeProposeDaemonConfig) -> anyhow::Result { + let rpc_config = psy_config::PsyConfigGoldilocks::from_file(&config.rpc_config) + .with_context(|| format!("failed to load rpc config {}", config.rpc_config))?; + let network_name = rpc_config.current_network_name().to_string(); + let network = rpc_config.get_current_network()?; network - .prove_proxy_url + .system_prove_proxy_url .iter() - .find(|url| !url.trim().is_empty()) - .cloned() + .map(|url| url.trim()) + .find(|url| !url.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + anyhow::anyhow!( + "network `{}` in {} has no `system_prove_proxy_url`; the relayer needs a prove-proxy running role=system or role=all", + network_name, + config.rpc_config + ) + }) } fn load_config(path: &Path) -> anyhow::Result { @@ -6003,4 +6015,75 @@ deployments_network = "localhostBase" assert_eq!(config.chains.iter().map(|chain| chain.chain_index).collect::>(), vec![0, 1, 2]); assert!(config.chains.iter().all(|chain| chain.family == "evm")); } + + fn write_temp_rpc_config(system_urls: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "relayer-proxy-test-{}-{}", + std::process::id(), + system_urls.len() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("config.json"); + // Template: the "localhost" NetworkConfig object, copied from + // psy_config::tests::system_prove_proxy_url_defaults_to_empty_and_reads_when_present + // (client_prover/psy_core/psy_config/src/lib.rs). That test's `base` is the + // correct, flat NetworkConfig shape (no "network" wrapper key) — unlike + // test_network_switching's literal, which is broken on this branch (extra + // "network" wrapper, missing required fields). + let json = r#"{"networks":{"localhost":{ + "magic": "0x1", + "users_per_realm": 1048576, + "global_user_tree_height": 24, + "realm_user_tree_height": 20, + "group_realm_height": 1, + "realm_configs": [{"id": 0, "rpc_url": ["http://127.0.0.1:8546"]}], + "coordinator_configs": [{"id": 0, "rpc_url": ["http://127.0.0.1:8545"]}], + "prove_proxy_url": ["http://127.0.0.1:9999"], + "system_prove_proxy_url": __SYSTEM_URLS__, + "faucet_rpc_url": ["http://127.0.0.1:8547"], + "nostr_relay_url": "wss://relay.127.0.0.1.example", + "native_currency": "PSY", + "native_currency_decimal": 9, + "native_currency_name": "PSY", + "fees": { + "register_user_fee": 0, + "deploy_contract_fee": 0, + "guta_fee": 5000000000, + "da_fee": 0 + } + }},"defaultNetwork":"localhost"}"# + .replace("__SYSTEM_URLS__", system_urls); + std::fs::write(&path, json).unwrap(); + path + } + + fn daemon_config_with_rpc(path: &std::path::Path) -> BridgeProposeDaemonConfig { + toml::from_str(&format!( + "rpc_config = {:?}\nservices_url = \"http://127.0.0.1:1\"\nwithdraw_method_id = 0\n", + path.display() + )) + .unwrap() + } + + #[test] + fn system_prove_proxy_url_is_required() { + let path = write_temp_rpc_config("[]"); + let err = resolve_system_prove_proxy_url(&daemon_config_with_rpc(&path)).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("system_prove_proxy_url"), "{msg}"); + assert!(msg.contains("localhost"), "{msg}"); + } + + #[test] + fn system_prove_proxy_url_skips_blank_entries() { + let path = write_temp_rpc_config(r#"[" ", "http://127.0.0.1:9997"]"#); + let url = resolve_system_prove_proxy_url(&daemon_config_with_rpc(&path)).unwrap(); + assert_eq!(url, "http://127.0.0.1:9997"); + } + + #[test] + fn user_pool_url_is_not_a_fallback() { + let path = write_temp_rpc_config(r#"[""]"#); + assert!(resolve_system_prove_proxy_url(&daemon_config_with_rpc(&path)).is_err()); + } } diff --git a/psy_cli/psy_relayer_cli/src/bridge/l1_client.rs b/psy_cli/psy_relayer_cli/src/bridge/l1_client.rs index 3382d3ee..22d5f505 100644 --- a/psy_cli/psy_relayer_cli/src/bridge/l1_client.rs +++ b/psy_cli/psy_relayer_cli/src/bridge/l1_client.rs @@ -9,7 +9,7 @@ use crate::bridge::{ claim_withdrawals, constants::{DEFAULT_DEPLOYMENTS_NETWORK, DEFAULT_L1_RPC_URL}, daemon::{ - fetch_l1_last_finalized_checkpoint, resolve_bridge_address, resolve_prove_proxy_url, + fetch_l1_last_finalized_checkpoint, resolve_bridge_address, resolve_system_prove_proxy_url, run_l2_bridge_round_with_l1_provider, submit_deposit_batch_appends_with_l1_rpc, BridgeProposeDaemonConfig, DaemonState, DaemonFinalizeConfig, L2RoundResult, }, @@ -218,7 +218,7 @@ impl L1Client { config: &BridgeProposeDaemonConfig, target_deposit_count: u32, ) -> anyhow::Result<()> { - let prove_proxy_url = resolve_prove_proxy_url(config); + let prove_proxy_url = resolve_system_prove_proxy_url(config)?; self.with_retry("deposit_batch_appends", L1_RETRY_MAX_ATTEMPTS, |url| { let owned = url.to_string(); let proxy = prove_proxy_url.clone(); @@ -227,7 +227,7 @@ impl L1Client { config, &owned, target_deposit_count, - proxy.as_deref(), + Some(proxy.as_str()), ) .await } @@ -247,7 +247,7 @@ impl L1Client { to_checkpoint, "claiming current batch withdrawals on L1" ); - let prove_proxy_url = resolve_prove_proxy_url(config); + let prove_proxy_url = resolve_system_prove_proxy_url(config)?; self.with_retry("claim_withdrawals", L1_RETRY_MAX_ATTEMPTS, |url| { let owned = url.to_string(); let bridge_addr = bridge_addr.clone(); @@ -258,7 +258,7 @@ impl L1Client { config, &owned, &bridge_addr, - proxy.as_deref(), + Some(proxy.as_str()), ) .await?; tracing::info!( From a7cf9a535d51787d1c099d8554880e950a5ac5f5 Mon Sep 17 00:00:00 2001 From: hellojason3 Date: Thu, 10 Sep 2026 13:39:14 +0800 Subject: [PATCH 07/13] chore: bump psy-genesis for system_prove_proxy_url --- psy-genesis | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/psy-genesis b/psy-genesis index 628e2fc7..285d9a2a 160000 --- a/psy-genesis +++ b/psy-genesis @@ -1 +1 @@ -Subproject commit 628e2fc785d61ccd780dc6a500a7fd7cf9ae161c +Subproject commit 285d9a2a82e20ae20053f81345537abba23136a1 From 7ae82c91e4e29dc5c0389756ad5b4fb48daf6a02 Mon Sep 17 00:00:00 2001 From: hellojason3 Date: Thu, 10 Sep 2026 13:40:36 +0800 Subject: [PATCH 08/13] docs(prove-proxy): document roles; local devnet runs role=all --- dev/locSetupV4.ts | 2 ++ docs/src/rpc/ProverProxy.md | 25 ++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/dev/locSetupV4.ts b/dev/locSetupV4.ts index f3b53d85..37b2cdd6 100644 --- a/dev/locSetupV4.ts +++ b/dev/locSetupV4.ts @@ -3806,6 +3806,8 @@ class DevNetProcessManager { `0.0.0.0:${port}`, '--rpc-config', 'psy-genesis/config.json', + '--role', + 'all', ], proveProxyStartedDetector, { cwd, ...getLogPaths(`prove_proxy_${i}`, false), maxRetries: 3, retryDelayMs: 2000, env: this.getEnv() } diff --git a/docs/src/rpc/ProverProxy.md b/docs/src/rpc/ProverProxy.md index a38a8557..d536d738 100644 --- a/docs/src/rpc/ProverProxy.md +++ b/docs/src/rpc/ProverProxy.md @@ -11,6 +11,7 @@ This document provides comprehensive documentation for the Psy Prover Proxy RPC ## Table of Contents 1. [Overview](#overview) + - [Roles](#roles) 2. [UPS (Unified Proving System) Methods](#ups-unified-proving-system-methods) 3. [Contract Management](#contract-management) 4. [Signature Proving](#signature-proving) @@ -35,6 +36,26 @@ The Prover Proxy is a local proving service that generates zero-knowledge proofs - **Contract Circuit Management**: Dynamic registration and execution of contract circuits - **Proof Tree Aggregation**: Hierarchical proof composition and verification +### Roles + +Every instance runs exactly one role, chosen with `--role` (env `PROVE_PROXY_ROLE`, default `user`): + +| Role | Registers | Built at startup | Intended for | +|---|---|---|---| +| `user` | UPS session chain, proof-tree aggregation, contract circuits, signature and minifier proofs, circuit queries | UPS circuit manager | wallet-facing pool (CLI, web wallet, faucet-server, MCP) | +| `system` | `psy_prove_withdrawal_batch_claim_groth16`, `psy_prove_deposit_batch_append_groth16`, `psy_prove_bridge_agg_groth16` | bridge wrap circuits, three Groth16 keystores | relayer pool | +| `all` | both | both | single-machine local testnets | + +A method outside the instance's role is not registered; calling it returns JSON-RPC `-32601 Method not found`. + +Every role also registers `psy_get_prove_proxy_role`: + +```json +{ "jsonrpc": "2.0", "id": 1, "result": { "role": "user", "user_methods": true, "system_methods": false } } +``` + +Clients pick the pool through `config.json`: wallets use `prove_proxy_url`; the relayer reads `system_prove_proxy_url` and refuses to start when it is missing for the current network. `psy_user_cli claim-withdrawal --prove-proxy-url` must point at a `system` or `all` instance. + --- ## UPS (Unified Proving System) Methods @@ -738,6 +759,7 @@ The prover proxy is configured via `ProveProxyArgs`: pub struct ProveProxyArgs { pub listen_addr: String, // Default: "0.0.0.0:9999" pub rpc_config: String, // Path to network config file + pub role: ProveProxyRole, // user | system | all, default user } ``` @@ -747,7 +769,8 @@ pub struct ProveProxyArgs { # Start prover proxy psy_user_cli prove-proxy \ --listen-addr "127.0.0.1:9999" \ - --rpc-config "config.json" + --rpc-config "config.json" \ + --role user ``` ### Circuit Initialization From 9ee0cc45109b52afea4741e8a60c29b75883dc70 Mon Sep 17 00:00:00 2001 From: hellojason3 Date: Thu, 10 Sep 2026 14:03:06 +0800 Subject: [PATCH 09/13] feat(relayer): verify the system prove proxy serves bridge methods at startup --- psy_cli/psy_relayer_cli/src/bridge/daemon.rs | 115 +++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/psy_cli/psy_relayer_cli/src/bridge/daemon.rs b/psy_cli/psy_relayer_cli/src/bridge/daemon.rs index def2f16d..a6edf529 100644 --- a/psy_cli/psy_relayer_cli/src/bridge/daemon.rs +++ b/psy_cli/psy_relayer_cli/src/bridge/daemon.rs @@ -858,6 +858,7 @@ async fn run_multichain( validate_max_checkpoint_batch(max_batch)?; let state_path = proof_dir.join("daemon_state_multichain.toml"); let proxy = resolve_system_prove_proxy_url(&config)?; + verify_system_prove_proxy(&proxy).await?; tracing::info!(prove_proxy = %proxy, "system prove proxy configured"); tracing::info!(config=%config_path.display(), chain_count=chains.len(), %identity_namespace, "multichain bridge relayer started"); @@ -1029,6 +1030,7 @@ async fn run_single_chain(config: BridgeProposeDaemonConfig, config_path: &Path) // Phase 4.4: skip local circuit/Groth16 warmup when remote prove proxy is configured let proxy_url_at_startup = resolve_system_prove_proxy_url(&config)?; + verify_system_prove_proxy(&proxy_url_at_startup).await?; tracing::info!(prove_proxy = %proxy_url_at_startup, "system prove proxy configured; local Groth16 warmup skipped"); let provider = RpcProvider::new_with_config_path(&config.rpc_config)?; @@ -1704,6 +1706,64 @@ pub(crate) fn resolve_system_prove_proxy_url(config: &BridgeProposeDaemonConfig) }) } +/// Confirms the resolved prove-proxy registers the bridge (system) methods. +/// A pool running role=user answers `psy_get_prove_proxy_role` with +/// `system_methods: false`; treat that like a missing URL and refuse to start. +pub(crate) async fn verify_system_prove_proxy(url: &str) -> anyhow::Result<()> { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .context("failed to build reqwest client for prove-proxy role verification")?; + + let response = client + .post(url) + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "psy_get_prove_proxy_role", + "params": [] + })) + .send() + .await + .with_context(|| format!("failed to reach prove proxy {url} for psy_get_prove_proxy_role"))?; + + let status = response.status(); + if !status.is_success() { + anyhow::bail!("prove proxy {url} returned HTTP {status} for psy_get_prove_proxy_role"); + } + + let body: serde_json::Value = response + .json() + .await + .with_context(|| format!("failed to parse psy_get_prove_proxy_role response from {url}"))?; + + let role = body + .get("result") + .and_then(|result| result.get("role")) + .and_then(|role| role.as_str()) + .ok_or_else(|| { + anyhow::anyhow!("prove proxy {url} psy_get_prove_proxy_role response missing result.role") + })? + .to_string(); + + let system_methods = body + .get("result") + .and_then(|result| result.get("system_methods")) + .and_then(|value| value.as_bool()) + .ok_or_else(|| { + anyhow::anyhow!("prove proxy {url} psy_get_prove_proxy_role response missing result.system_methods") + })?; + + if !system_methods { + anyhow::bail!( + "prove proxy {url} runs role `{role}` and does not serve system proofs; point system_prove_proxy_url at a role=system or role=all instance" + ); + } + + tracing::info!(prove_proxy = %url, role = %role, "system prove proxy verified"); + Ok(()) +} + fn load_config(path: &Path) -> anyhow::Result { let raw = fs::read_to_string(path).with_context(|| format!("failed to read daemon config {}", path.display()))?; toml::from_str(&raw).with_context(|| format!("failed to parse daemon config {}", path.display())) @@ -6086,4 +6146,59 @@ deployments_network = "localhostBase" let path = write_temp_rpc_config(r#"[""]"#); assert!(resolve_system_prove_proxy_url(&daemon_config_with_rpc(&path)).is_err()); } + + /// Starts a minimal single-shot HTTP responder on `127.0.0.1:0` that reads + /// one request and replies with `body` as a JSON response, then returns + /// the URL to reach it. + fn spawn_json_responder(body: &'static str) -> String { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + use std::io::{Read, Write}; + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + } + }); + format!("http://{addr}") + } + + #[tokio::test] + async fn verify_system_prove_proxy_accepts_role_system() { + let url = spawn_json_responder( + r#"{"jsonrpc":"2.0","id":1,"result":{"role":"system","user_methods":false,"system_methods":true}}"#, + ); + + verify_system_prove_proxy(&url).await.unwrap(); + } + + #[tokio::test] + async fn verify_system_prove_proxy_rejects_role_user() { + let url = spawn_json_responder( + r#"{"jsonrpc":"2.0","id":1,"result":{"role":"user","user_methods":true,"system_methods":false}}"#, + ); + + let err = verify_system_prove_proxy(&url).await.unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("role `user`"), "{msg}"); + assert!(msg.contains("system_prove_proxy_url"), "{msg}"); + } + + #[tokio::test] + async fn verify_system_prove_proxy_errs_on_closed_port() { + // Bind to get a free port, then drop the listener so nothing answers. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + drop(listener); + let url = format!("http://{addr}"); + + assert!(verify_system_prove_proxy(&url).await.is_err()); + } } From 95002515ea61c1d2a0d00a7d7706f0598df5a99d Mon Sep 17 00:00:00 2001 From: hellojason3 Date: Thu, 10 Sep 2026 14:05:13 +0800 Subject: [PATCH 10/13] chore(user-cli): prove-proxy subcommand no longer needs gnark-wrap --- client_prover/psy_cli/psy_user_cli/src/main.rs | 2 -- client_prover/psy_cli/psy_user_cli/src/subcommand/mod.rs | 2 -- 2 files changed, 4 deletions(-) diff --git a/client_prover/psy_cli/psy_user_cli/src/main.rs b/client_prover/psy_cli/psy_user_cli/src/main.rs index 44e82c15..14900ddb 100644 --- a/client_prover/psy_cli/psy_user_cli/src/main.rs +++ b/client_prover/psy_cli/psy_user_cli/src/main.rs @@ -208,7 +208,6 @@ fn command_paths(cli: &Cli) -> Vec<&str> { Commands::GetLatestBlockState(args) => paths.push(&args.rpc_config), Commands::GetBlockState(args) => paths.push(&args.rpc_config), Commands::LocalProver(args) => paths.push(&args.rpc_config), - #[cfg(feature = "gnark-wrap")] Commands::ProveProxy(args) => paths.push(&args.rpc_config), Commands::FaucetServer(args) => paths.push(&args.rpc_config), Commands::GetClaimAmount(args) => paths.push(&args.rpc_config), @@ -666,7 +665,6 @@ async fn main() -> anyhow::Result<()> { psy_prover::run_server(prover_args).await?; CommandResult::generic("local-prover") } - #[cfg(feature = "gnark-wrap")] Commands::ProveProxy(prove_proxy_args) => { crate::subcommand::prove_proxy::run(prove_proxy_args).await?; CommandResult::generic("prove-proxy") diff --git a/client_prover/psy_cli/psy_user_cli/src/subcommand/mod.rs b/client_prover/psy_cli/psy_user_cli/src/subcommand/mod.rs index 98836f5c..42215aaa 100644 --- a/client_prover/psy_cli/psy_user_cli/src/subcommand/mod.rs +++ b/client_prover/psy_cli/psy_user_cli/src/subcommand/mod.rs @@ -10,7 +10,6 @@ pub mod contract_abi_upload; pub mod deploy_contract; pub mod faucet_server; pub mod local_prover; -#[cfg(feature = "gnark-wrap")] pub mod prove_proxy; pub mod simulate; pub mod update_contract; @@ -110,7 +109,6 @@ pub enum Commands { // local proving LocalProver(ProverArgs), - #[cfg(feature = "gnark-wrap")] ProveProxy(psy_client_common::args::ProveProxyArgs), FaucetServer(PsyFaucetServerArgs), From d7f458ca9165248e02c51836881e1b73471895fe Mon Sep 17 00:00:00 2001 From: hellojason3 Date: Thu, 10 Sep 2026 14:07:06 +0800 Subject: [PATCH 11/13] chore(prove-proxy): drop dead hex helpers and redundant dev-deps --- client_prover/psy_prover/Cargo.toml | 2 -- client_prover/psy_prover/src/lib.rs | 3 +-- .../src/local/native/prove_proxy/types.rs | 25 ------------------- 3 files changed, 1 insertion(+), 29 deletions(-) diff --git a/client_prover/psy_prover/Cargo.toml b/client_prover/psy_prover/Cargo.toml index 01d3f03b..0474345b 100644 --- a/client_prover/psy_prover/Cargo.toml +++ b/client_prover/psy_prover/Cargo.toml @@ -83,8 +83,6 @@ js-sys = { workspace = true } [dev-dependencies] flate2 = { workspace = true } -tokio = { workspace = true } -anyhow = { workspace = true } [features] gnark-wrap = ["dep:gnark-plonky2-verifier-ffi", "psy_plonky2_circuits/gnark-wrap"] diff --git a/client_prover/psy_prover/src/lib.rs b/client_prover/psy_prover/src/lib.rs index 26c4978c..b6aae6ca 100644 --- a/client_prover/psy_prover/src/lib.rs +++ b/client_prover/psy_prover/src/lib.rs @@ -71,13 +71,12 @@ pub async fn run_prove_proxy_server(args: psy_client_common::args::ProveProxyArg use psy_client_common::health::HealthLayer; use tower_http::cors::{Any, CorsLayer}; + use crate::local::native::prove_proxy::assemble_rpc_module; use crate::local::native::prove_proxy::user::{ProveProxyUserRpcServer, UserProveProvider}; let psy_config = psy_config::PsyConfigGoldilocks::from_file(&args.rpc_config)?; let rpc_config = psy_config.get_current_network()?; - use crate::local::native::prove_proxy::assemble_rpc_module; - let role = args.role; tracing::info!(role = role.as_str(), "prove proxy role"); diff --git a/client_prover/psy_prover/src/local/native/prove_proxy/types.rs b/client_prover/psy_prover/src/local/native/prove_proxy/types.rs index 2242f8b0..bd1e263a 100644 --- a/client_prover/psy_prover/src/local/native/prove_proxy/types.rs +++ b/client_prover/psy_prover/src/local/native/prove_proxy/types.rs @@ -74,25 +74,6 @@ pub fn parse_hex_qhashout(hex: &str) -> anyhow::Result> { })) } -pub fn parse_internal_u32x8_qhashout(hex: &str) -> anyhow::Result> { - let hex = hex.trim_start_matches("0x"); - anyhow::ensure!(hex.len() == 64, "expected 64 hex chars, got {}", hex.len()); - let bytes = hex::decode(hex)?; - let mut words = [0u32; 8]; - for i in 0..8 { - words[i] = u32::from_be_bytes(bytes[i * 4..i * 4 + 4].try_into()?); - } - let elems = [ - ((words[1] as u64) << 32) | words[0] as u64, - ((words[3] as u64) << 32) | words[2] as u64, - ((words[5] as u64) << 32) | words[4] as u64, - ((words[7] as u64) << 32) | words[6] as u64, - ]; - Ok(ParthQHashOut(HashOut { - elements: elems.map(F::from_canonical_u64), - })) -} - // ── Bridge Aggregation Types ───────────────────────────────────────────── #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -210,12 +191,6 @@ pub fn parse_hex_qhashout_to_qhash(h: &str) -> anyhow::Result parth_core::pgoldilocks::QHashOut { - parth_core::pgoldilocks::QHashOut(HashOut { - elements: [elems[0], elems[1], elems[2], elems[3]], - }) -} - pub fn felt4_to_bytes32_hex(felts: &[F]) -> String { let mut out = [0u8; 32]; for i in 0..4 { From f0caa83868c4bd9a50a945b5b392c53d171ac6aa Mon Sep 17 00:00:00 2001 From: hellojason3 Date: Thu, 10 Sep 2026 14:07:08 +0800 Subject: [PATCH 12/13] docs(prove-proxy): circuit init note is role-dependent --- docs/src/rpc/ProverProxy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/rpc/ProverProxy.md b/docs/src/rpc/ProverProxy.md index d536d738..6da1a375 100644 --- a/docs/src/rpc/ProverProxy.md +++ b/docs/src/rpc/ProverProxy.md @@ -778,7 +778,7 @@ psy_user_cli prove-proxy \ The prover proxy initializes with: - Network magic number for proof validation - RPC provider for fetching contract code -- Circuit manager for all proof types +- Circuit manager for the proof families the configured role serves (see Roles) - Session circuit info store for fingerprint tracking --- From 3a81f59e0cf9333fc2ad4aefda7c83787660c9ab Mon Sep 17 00:00:00 2001 From: hellojason3 Date: Thu, 10 Sep 2026 14:27:34 +0800 Subject: [PATCH 13/13] test(config): repair the psy_config suite for the current schema The embedded fixtures wrapped each network in a "network" key that Config::networks never had, lacked magic / faucet_rpc_url / nostr_relay_url / fees.da_fee, and test_config_loading read a client_prover/config.json that no longer exists; it now reads psy-genesis/config.json. Two fixtures also contradicted from_json's defaultNetwork semantics and are corrected so they test what they claim. --- client_prover/psy_core/psy_config/src/lib.rs | 62 ++++++++++++-------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/client_prover/psy_core/psy_config/src/lib.rs b/client_prover/psy_core/psy_config/src/lib.rs index 08bcc984..be2f485b 100644 --- a/client_prover/psy_core/psy_config/src/lib.rs +++ b/client_prover/psy_core/psy_config/src/lib.rs @@ -784,7 +784,7 @@ mod tests { #[test] fn test_config_loading() { - let config_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../config.json"); + let config_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../psy-genesis/config.json"); let config = PsyConfigGoldilocks::from_file(config_path.to_str().unwrap()).unwrap(); assert_eq!(config.current_network_name(), "localhost"); @@ -799,7 +799,9 @@ mod tests { let json = r#"{ "networks": { "localhost": { - "network": { + "magic": "0x1", + "faucet_rpc_url": [], + "nostr_relay_url": "ws://127.0.0.1:8081", "users_per_realm": 1048576, "global_user_tree_height": 24, "realm_user_tree_height": 20, @@ -814,12 +816,14 @@ mod tests { "fees": { "register_user_fee": 0, "deploy_contract_fee": 0, - "guta_fee": 5000000000 + "guta_fee": 5000000000, + "da_fee": 0 } - } }, "testnet": { - "network": { + "magic": "0x1", + "faucet_rpc_url": [], + "nostr_relay_url": "ws://127.0.0.1:8081", "users_per_realm": 1048576, "global_user_tree_height": 24, "realm_user_tree_height": 20, @@ -834,9 +838,9 @@ mod tests { "fees": { "register_user_fee": 1000, "deploy_contract_fee": 5000, - "guta_fee": 5000000000 + "guta_fee": 5000000000, + "da_fee": 0 } - } } }, "defaultNetwork": "localhost" @@ -904,7 +908,9 @@ mod tests { let json = r#"{ "networks": { "dev": { - "network": { + "magic": "0x1", + "faucet_rpc_url": [], + "nostr_relay_url": "ws://127.0.0.1:8081", "users_per_realm": 1024, "global_user_tree_height": 20, "realm_user_tree_height": 10, @@ -919,12 +925,14 @@ mod tests { "fees": { "register_user_fee": 100, "deploy_contract_fee": 500, - "guta_fee": 1000000000 + "guta_fee": 1000000000, + "da_fee": 0 } - } }, "localhost": { - "network": { + "magic": "0x1", + "faucet_rpc_url": [], + "nostr_relay_url": "ws://127.0.0.1:8081", "users_per_realm": 1024, "global_user_tree_height": 20, "realm_user_tree_height": 10, @@ -939,12 +947,12 @@ mod tests { "fees": { "register_user_fee": 50, "deploy_contract_fee": 250, - "guta_fee": 500000000 + "guta_fee": 500000000, + "da_fee": 0 } - } } }, - "defaultNetwork": "dev" + "defaultNetwork": "localhost" }"#; let config1 = PsyConfigGoldilocks::from_json(json).unwrap(); @@ -964,7 +972,9 @@ mod tests { let json = r#"{ "networks": { "dev": { - "network": { + "magic": "0x1", + "faucet_rpc_url": [], + "nostr_relay_url": "ws://127.0.0.1:8081", "users_per_realm": 1024, "global_user_tree_height": 20, "realm_user_tree_height": 10, @@ -979,12 +989,14 @@ mod tests { "fees": { "register_user_fee": 100, "deploy_contract_fee": 500, - "guta_fee": 1000000000 + "guta_fee": 1000000000, + "da_fee": 0 } - } }, "localhost": { - "network": { + "magic": "0x1", + "faucet_rpc_url": [], + "nostr_relay_url": "ws://127.0.0.1:8081", "users_per_realm": 512, "global_user_tree_height": 18, "realm_user_tree_height": 9, @@ -999,9 +1011,9 @@ mod tests { "fees": { "register_user_fee": 50, "deploy_contract_fee": 250, - "guta_fee": 500000000 + "guta_fee": 500000000, + "da_fee": 0 } - } } }, "defaultNetwork": "localhost" @@ -1032,7 +1044,9 @@ mod tests { let json = r#"{ "networks": { "only_network": { - "network": { + "magic": "0x1", + "faucet_rpc_url": [], + "nostr_relay_url": "ws://127.0.0.1:8081", "users_per_realm": 1024, "global_user_tree_height": 20, "realm_user_tree_height": 10, @@ -1047,12 +1061,12 @@ mod tests { "fees": { "register_user_fee": 0, "deploy_contract_fee": 0, - "guta_fee": 1000000000 + "guta_fee": 1000000000, + "da_fee": 0 } - } } }, - "defaultNetwork": "only_network" + "defaultNetwork": "missing_network" }"#; let result = PsyConfigGoldilocks::from_json(json);