From b9c2b542abe8171579c9a2026f334c18f4bd5244 Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 11 Nov 2026 10:48:51 +0000 Subject: [PATCH] fix: accumulate state-chain withdrawals in an MMR and bind operator proofs to the Gateway --- Cargo.lock | 2 + .../bitcoin-light-client-circuit/src/lib.rs | 55 +++++-- .../crates/state-chain/src/state_chain.rs | 136 +++++++++++++++++- circuits/operator-proof/guest/src/main.rs | 6 +- circuits/operator-proof/host/src/lib.rs | 95 +++++++++++- circuits/operator-proof/host/src/main.rs | 17 ++- circuits/proof-builder/src/lib.rs | 11 +- ...ff6de8c47e145580c980b40691f90d6a7aaa2.json | 12 -- .../20261112000000_withdrawal_accumulator.sql | 14 ++ crates/store/src/localdb/proof.rs | 94 +++++++++--- crates/store/src/localdb/tests.rs | 34 +++++ crates/store/src/schema.rs | 12 ++ node/src/bin/mock_proof_builder.rs | 10 +- node/src/utils/graph_validate.rs | 6 +- node/src/utils/proof_client.rs | 5 +- proof-builder-rpc/Cargo.toml | 2 + proof-builder-rpc/src/api/auth.rs | 18 ++- proof-builder-rpc/src/api/proof_handler.rs | 5 + proof-builder-rpc/src/task/mod.rs | 65 +++++++++ proof-builder-rpc/src/task/operator_proof.rs | 6 + .../src/task/state_chain_proof.rs | 28 ++++ 21 files changed, 569 insertions(+), 64 deletions(-) delete mode 100644 crates/store/.sqlx/query-128842963b6835643e4a62e8341ff6de8c47e145580c980b40691f90d6a7aaa2.json create mode 100644 crates/store/migrations/20261112000000_withdrawal_accumulator.sql diff --git a/Cargo.lock b/Cargo.lock index b62934fb8..a14960838 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9177,6 +9177,7 @@ dependencies = [ "commit-chain-proof", "dotenv", "futures", + "header-chain", "header-chain-proof", "hex", "http-body-util", @@ -9189,6 +9190,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "state-chain", "state-chain-proof", "store", "strum 0.26.3", diff --git a/circuits/crates/bitcoin-light-client-circuit/src/lib.rs b/circuits/crates/bitcoin-light-client-circuit/src/lib.rs index d516f19d9..2b8bc58ce 100644 --- a/circuits/crates/bitcoin-light-client-circuit/src/lib.rs +++ b/circuits/crates/bitcoin-light-client-circuit/src/lib.rs @@ -14,9 +14,11 @@ use commit_chain::{ }; use header_chain::{ BitcoinMerkleTree, BlockHeaderCircuitOutput, CircuitBlockHeader, CircuitTransaction, - HeaderChainCircuitInput, MMRHost, SPV, verify_merkle_proof, + HeaderChainCircuitInput, MMRHost, MMRInclusionProof, SPV, verify_merkle_proof, +}; +use state_chain::{ + StateChainCircuitInput, StateChainCircuitOutput, verify_sequencer_commit, withdrawal_leaf, }; -use state_chain::{StateChainCircuitInput, StateChainCircuitOutput, verify_sequencer_commit}; use zkm_primitives::io::ZKMPublicValues; use bitcoin::{Transaction, secp256k1::XOnlyPublicKey}; @@ -380,6 +382,8 @@ pub fn propose_longest_chain( included_watchtowers: U256, //pis graph_id: [u8; GRAPH_ID_SIZE], // pis operator_genesis_sequencer_commit_txid: [u8; 32], // pis + gateway_address: [u8; 20], // bound by the constant + withdrawal_proof: MMRInclusionProof, watchtower_challenge_init_txid: [u8; 32], watchtower_challenge_init_txn: Option, @@ -561,14 +565,14 @@ pub fn propose_longest_chain( .expect("watchtower challenge defeats operator chain"); } - let mut is_found = false; - for withdrawal in &state_chain_output.chain_state.withdrawals { - if withdrawal.2.contains(&graph_id) { - is_found = true; - break; - } - } - assert!(is_found, "Graph id {graph_id:?} is not included in current state chain"); + // The Gateway named by the constant processed a withdrawal for this graph. + assert!( + state_chain_output + .chain_state + .withdrawals + .verify_proof(withdrawal_leaf(&gateway_address, &graph_id), &withdrawal_proof), + "Graph id {graph_id:?} has no withdrawal from the Gateway in the state chain" + ); // (operator_total_work, included_watchtowers, graph_id, operator_genesis_sequencer_commit_txid, btc_best_block_hash) //(hash_operator_inputs(graph_id, operator_genesis_sequencer_commit_txid), btc_best_block_hash) @@ -580,6 +584,7 @@ pub fn propose_longest_chain( let constant = hash_operator_constant( graph_id, operator_genesis_sequencer_commit_txid, + gateway_address, watchtower_challenge_init_txid, graph_watchtower_xonly_public_keys, ); @@ -601,16 +606,21 @@ pub fn propose_longest_chain( (operator_committed_blockhash, constant, included_watchtowers.to_le_bytes::<32>()) } +/// The graph constant committed by the Operator proof and Connector D. It names the +/// Gateway whose withdrawal the proof must show, so a withdrawal for the same graph id +/// recorded by another contract cannot satisfy it. pub fn hash_operator_constant( graph_id: [u8; GRAPH_ID_SIZE], operator_genesis_sequencer_commit_txid: [u8; 32], + gateway_address: [u8; 20], watchtower_challenge_init_txid: [u8; 32], watchtower_xonly_public_keys: &[[u8; 32]], ) -> [u8; 32] { let mut engine = sha256::HashEngine::default(); - engine.input(b"bitvm/operator-constant/v3"); + engine.input(b"bitvm/operator-constant/v4"); engine.input(&graph_id); engine.input(&operator_genesis_sequencer_commit_txid); + engine.input(&gateway_address); engine.input(&watchtower_challenge_init_txid); engine.input(&(watchtower_xonly_public_keys.len() as u16).to_be_bytes()); for key in watchtower_xonly_public_keys { @@ -1036,9 +1046,11 @@ mod tests { let genesis_txid = [2u8; 32]; let watchtower_keys = [[3u8; 32], [4u8; 32]]; let challenge_init_txid = [5u8; 32]; - let mut input = b"bitvm/operator-constant/v3".to_vec(); + let gateway = [6u8; 20]; + let mut input = b"bitvm/operator-constant/v4".to_vec(); input.extend_from_slice(&graph_id); input.extend_from_slice(&genesis_txid); + input.extend_from_slice(&gateway); input.extend_from_slice(&challenge_init_txid); input.extend_from_slice(&(watchtower_keys.len() as u16).to_be_bytes()); for key in &watchtower_keys { @@ -1047,18 +1059,35 @@ mod tests { let expected = bitcoin::hashes::sha256::Hash::hash(&input); assert_eq!( - hash_operator_constant(graph_id, genesis_txid, challenge_init_txid, &watchtower_keys), + hash_operator_constant( + graph_id, + genesis_txid, + gateway, + challenge_init_txid, + &watchtower_keys + ), *expected.as_byte_array() ); assert_ne!( hash_operator_constant( graph_id, genesis_txid, + gateway, challenge_init_txid, &[watchtower_keys[1], watchtower_keys[0]], ), *expected.as_byte_array() ); + assert_ne!( + hash_operator_constant( + graph_id, + genesis_txid, + [7u8; 20], + challenge_init_txid, + &watchtower_keys + ), + *expected.as_byte_array() + ); } #[test] diff --git a/circuits/crates/state-chain/src/state_chain.rs b/circuits/crates/state-chain/src/state_chain.rs index 718b5891e..c96fb4343 100644 --- a/circuits/crates/state-chain/src/state_chain.rs +++ b/circuits/crates/state-chain/src/state_chain.rs @@ -5,12 +5,62 @@ use alloy_primitives::{Address, B256, U256}; use bincode::Options as BincodeOptions; use guest_executor::executor::EthClientExecutor; use guest_executor::io::EthClientExecutorInput; +use header_chain::MMRGuest; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::sync::Arc; use tendermint_light_client_verifier::types::LightBlock; // Contract address, base slot and key, the expected value of the slot is hardcoded to 1. -type WithdrawalSlot = (Address, [u8; 32], Vec<[u8; 16]>); +pub type WithdrawalSlot = (Address, [u8; 32], Vec<[u8; 16]>); + +/// Base storage slot of `Gateway.withdrawDataMap`, the only slot a withdrawal may +/// be read from. `forge inspect src/GatewayDebug.sol:GatewayDebug storage-layout`. +pub const WITHDRAW_DATA_BASE_SLOT: [u8; 32] = { + let mut slot = [0u8; 32]; + slot[31] = 16; + slot +}; + +/// Leaf of the withdrawal accumulator: the Gateway that processed the withdrawal +/// and the graph it was processed for. The slot is fixed by [`WITHDRAW_DATA_BASE_SLOT`]. +pub fn withdrawal_leaf(gateway: &[u8; 20], graph_id: &[u8; 16]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(b"bitvm/withdrawal-leaf/v1"); + hasher.update(gateway); + hasher.update(graph_id); + hasher.finalize().into() +} + +/// One withdrawal in accumulator order, as the host replays it to build inclusion proofs. +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)] +pub struct WithdrawalRecord { + pub block_number: u64, + pub gateway: [u8; 20], + pub graph_id: [u8; 16], +} + +impl WithdrawalRecord { + pub fn leaf(&self) -> [u8; 32] { + withdrawal_leaf(&self.gateway, &self.graph_id) + } +} + +/// The withdrawals of `block` in the order the circuit appends them. +pub fn block_withdrawal_records(block: &CircuitStateBlock) -> Vec { + let block_number = block.evm_block.current_block.number; + block + .withdrawals + .iter() + .flat_map(|(gateway, _, graph_ids)| { + graph_ids.iter().map(move |graph_id| WithdrawalRecord { + block_number, + gateway: gateway.into_array(), + graph_id: *graph_id, + }) + }) + .collect() +} /// The input proof of the commit chain circuit. /// The proof can be either None (implying the beginning) or a Succinct proof. @@ -37,7 +87,8 @@ pub struct StateChainState { pub genesis_evm_block_hash: [u8; 32], pub latest_evm_block_hash: [u8; 32], pub latest_cosmos_block: Vec, - pub withdrawals: Vec, + /// Every withdrawal processed since genesis, one [`withdrawal_leaf`] each. + pub withdrawals: MMRGuest, } #[derive(Serialize, Deserialize, PartialEq, Clone, Debug)] @@ -94,7 +145,7 @@ impl StateChainState { latest_evm_block_hash: genesis_evm_block_hash, genesis_evm_block_hash, latest_cosmos_block, - withdrawals: vec![], + withdrawals: MMRGuest::new(), } } @@ -135,7 +186,9 @@ impl StateChainState { &block.cosmos_txns, &data_hash, ); - self.withdrawals.extend_from_slice(&block.withdrawals); + for record in block_withdrawal_records(&block) { + self.withdrawals.append(record.leaf()); + } } self.evm_block_height += 1; self.latest_evm_block_hash = current_block_hash; @@ -158,6 +211,7 @@ pub fn execute_el_block_and_check_withdraw_tx( let mut storage_info = vec![]; withdrawals.iter().for_each(|withdrawal| { + assert_eq!(withdrawal.1, WITHDRAW_DATA_BASE_SLOT, "withdrawal is not in withdrawDataMap"); for graph_id in &withdrawal.2 { let mut data = [0u8; 32 * 2]; data[0..16].copy_from_slice(graph_id); @@ -192,4 +246,78 @@ mod tests { .unwrap(); chain_state.apply_blocks(blocks2); } + + #[test] + fn withdrawals_accumulate_in_block_order_and_prove_their_gateway() { + let blocks: Vec = + serde_json::from_slice(include_bytes!("../../../data/state-chain/10346758-10.blocks")) + .unwrap(); + let block_hash: [u8; 32] = blocks[0].evm_block.current_block.hash_slow().into(); + let block_height = blocks[0].evm_block.current_block.header.number; + let mut chain_state = + StateChainState::new(block_height, block_hash, blocks[0].cosmos_block.clone()); + assert_eq!(chain_state.withdrawals.size, 0); + + // The fixture has no withdrawals; a block-level unit check covers the leaf order. + let gateway = Address::repeat_byte(0x11); + let other_gateway = Address::repeat_byte(0x22); + let mut block = blocks[1].clone(); + block.withdrawals = vec![ + (gateway, WITHDRAW_DATA_BASE_SLOT, vec![[1; 16], [2; 16]]), + (other_gateway, WITHDRAW_DATA_BASE_SLOT, vec![[1; 16]]), + ]; + let records = block_withdrawal_records(&block); + assert_eq!( + records, + vec![ + WithdrawalRecord { + block_number: block.evm_block.current_block.number, + gateway: gateway.into_array(), + graph_id: [1; 16] + }, + WithdrawalRecord { + block_number: block.evm_block.current_block.number, + gateway: gateway.into_array(), + graph_id: [2; 16] + }, + WithdrawalRecord { + block_number: block.evm_block.current_block.number, + gateway: other_gateway.into_array(), + graph_id: [1; 16] + }, + ] + ); + let mut host = header_chain::MMRHost::new(); + for record in &records { + chain_state.withdrawals.append(record.leaf()); + host.append(record.leaf()); + } + assert_eq!(chain_state.withdrawals.size, 3); + let (leaf, proof) = host.generate_proof(1); + assert_eq!(leaf, withdrawal_leaf(&gateway.into_array(), &[2; 16])); + assert!(chain_state.withdrawals.verify_proof(leaf, &proof)); + // The same graph id under another Gateway is a different leaf. + assert!( + !chain_state + .withdrawals + .verify_proof(withdrawal_leaf(&other_gateway.into_array(), &[2; 16]), &proof) + ); + assert_ne!( + withdrawal_leaf(&gateway.into_array(), &[1; 16]), + withdrawal_leaf(&other_gateway.into_array(), &[1; 16]) + ); + } + + #[test] + fn withdrawals_outside_withdraw_data_map_are_rejected() { + let blocks: Vec = + serde_json::from_slice(include_bytes!("../../../data/state-chain/10346758-10.blocks")) + .unwrap(); + let mut block = blocks[1].clone(); + block.withdrawals = vec![(Address::repeat_byte(0x11), [0; 32], vec![[1; 16]])]; + let result = std::panic::catch_unwind(|| { + execute_el_block_and_check_withdraw_tx(&block.withdrawals, block.evm_block.clone()) + }); + assert!(result.is_err()); + } } diff --git a/circuits/operator-proof/guest/src/main.rs b/circuits/operator-proof/guest/src/main.rs index 51e7cfd52..075f7a4da 100644 --- a/circuits/operator-proof/guest/src/main.rs +++ b/circuits/operator-proof/guest/src/main.rs @@ -4,7 +4,7 @@ use alloy_primitives::U256; use bitcoin::Transaction; use bitcoin_light_client_circuit::IndexedWatchtowerChallenge; use commit_chain::CommitChainCircuitInput; -use header_chain::{HeaderChainCircuitInput, SPV}; +use header_chain::{HeaderChainCircuitInput, MMRInclusionProof, SPV}; use state_chain::StateChainCircuitInput; pub fn main() { @@ -12,6 +12,8 @@ pub fn main() { let included_watchtowers: U256 = zkm_zkvm::io::read::(); let graph_id: [u8; 16] = zkm_zkvm::io::read::<[u8; 16]>(); let operator_genesis_sequencer_commit_txid: [u8; 32] = zkm_zkvm::io::read(); + let gateway_address: [u8; 20] = zkm_zkvm::io::read(); + let withdrawal_proof: MMRInclusionProof = zkm_zkvm::io::read(); // https://github.com/KSlashh/BitVM/blob/v2/goat/src/transactions/watchtower_challenge.rs#L128 let watchtower_challenge_init_txid: [u8; 32] = zkm_zkvm::io::read(); @@ -30,6 +32,8 @@ pub fn main() { included_watchtowers, graph_id, operator_genesis_sequencer_commit_txid, + gateway_address, + withdrawal_proof, watchtower_challenge_init_txid, watchtower_challenge_init_txn, watchtower_challenges, diff --git a/circuits/operator-proof/host/src/lib.rs b/circuits/operator-proof/host/src/lib.rs index f7df0ddc0..5439e6058 100644 --- a/circuits/operator-proof/host/src/lib.rs +++ b/circuits/operator-proof/host/src/lib.rs @@ -7,9 +7,15 @@ use borsh::BorshDeserialize; use clap::Parser; use client::btc_chain::BTCClient; use commit_chain::{CommitChainCircuitInput, CommitChainPrevProofType}; -use header_chain::{CircuitBlockHeader, HeaderChainCircuitInput, HeaderChainPrevProofType}; +use header_chain::{ + CircuitBlockHeader, HeaderChainCircuitInput, HeaderChainPrevProofType, MMRHost, + MMRInclusionProof, +}; use proof_builder::{LongRunning, ProofBuilder, ProofRequest}; -use state_chain::{StateChainCircuitInput, StateChainPrevProofType}; +use state_chain::{ + StateChainCircuitInput, StateChainPrevProofType, WithdrawalRecord, + decode_state_chain_circuit_output, withdrawal_leaf, +}; use std::str::FromStr; use util::get_btc_block_confirms; use zkm_sdk::{ @@ -77,6 +83,29 @@ pub struct Args { )] pub genesis_sequencer_commit_txid: String, + /// The Gateway that processed the graph's withdrawal. + #[clap( + long, + env, + required = false, + required_unless_present = "print_program_id", + default_value_if("print_program_id", "true", Some("")) + )] + #[serde(default)] + pub gateway_address: String, + + /// JSON file with the state chain's withdrawal records, in accumulator order, up to + /// the end of the state-chain proof. + #[clap( + long, + env, + required = false, + required_unless_present = "print_program_id", + default_value_if("print_program_id", "true", Some("")) + )] + #[serde(default)] + pub withdrawal_records: String, + #[clap( long, env, @@ -341,6 +370,33 @@ pub async fn fetch_target_block_and_watchtower_tx( watchtower_challenge_witnesses, )) } +/// Proves that `records` holds a withdrawal of `graph_id` from `gateway`. The records +/// must be every withdrawal the state-chain proof accumulated, in order. +pub fn build_withdrawal_proof( + records: &[WithdrawalRecord], + gateway: [u8; 20], + graph_id: [u8; 16], +) -> anyhow::Result { + let leaf = withdrawal_leaf(&gateway, &graph_id); + let mut mmr = MMRHost::new(); + let mut index = None; + for (position, record) in records.iter().enumerate() { + let record_leaf = record.leaf(); + if index.is_none() && record_leaf == leaf { + index = Some(position as u32); + } + mmr.append(record_leaf); + } + let index = index.with_context(|| { + format!( + "graph {} has no withdrawal from Gateway 0x{} in the state chain", + hex::encode(graph_id), + hex::encode(gateway) + ) + })?; + Ok(mmr.generate_proof(index).1) +} + pub struct OperatorProofBuilder { client: Arc, proving_key: zkm_sdk::ZKMProvingKey, @@ -390,6 +446,8 @@ impl ProofBuilder for OperatorProofBuilder { commit_chain_input_proof, state_chain_input_proof, genesis_sequencer_commit_txid, + gateway_address, + withdrawal_proof, target_block_ss_commit, block_pos_ss_commit, @@ -485,6 +543,14 @@ impl ProofBuilder for OperatorProofBuilder { })?; let self_program_id = verifier::program_id(&zkm_vk_hash, &zkm_version).map_err(anyhow::Error::msg)?; + // Fail here rather than after a proving run. + anyhow::ensure!( + decode_state_chain_circuit_output(&zkm_public_values) + .chain_state + .withdrawals + .verify_proof(withdrawal_leaf(gateway_address, graph_id), withdrawal_proof), + "withdrawal proof does not match the state-chain proof" + ); StateChainCircuitInput { prev_proof: StateChainPrevProofType::GenesisBlock, // unused @@ -566,6 +632,8 @@ impl ProofBuilder for OperatorProofBuilder { stdin.write(&graph_id); stdin.write(&operator_genesis_sequencer_commit_txid.to_byte_array()); + stdin.write(gateway_address); + stdin.write(withdrawal_proof); stdin.write(&watchtower_challenge_init_txid.to_byte_array()); stdin.write(&watchtower_challenge_init_txn); @@ -638,6 +706,29 @@ mod tests { use super::*; use ark_bn254::Bn254; + #[test] + fn withdrawal_proof_names_the_gateway_and_verifies_against_the_accumulator() { + let records = (0..5u8) + .map(|i| WithdrawalRecord { + block_number: 100 + u64::from(i), + gateway: [i % 2; 20], + graph_id: [i; 16], + }) + .collect::>(); + let mut accumulator = header_chain::MMRGuest::new(); + for record in &records { + accumulator.append(record.leaf()); + } + let proof = build_withdrawal_proof(&records, [1; 20], [3; 16]).unwrap(); + assert_eq!(proof.leaf_index, 3); + assert!(accumulator.verify_proof(withdrawal_leaf(&[1; 20], &[3; 16]), &proof)); + // Same graph id, other Gateway: not in the accumulator. + assert!(build_withdrawal_proof(&records, [0; 20], [3; 16]).is_err()); + // A proof from a record list whose peak the accumulator has since merged fails. + let stale = build_withdrawal_proof(&records[..3], [0; 20], [2; 16]).unwrap(); + assert!(!accumulator.verify_proof(withdrawal_leaf(&[0; 20], &[2; 16]), &stale)); + } + use ark_groth16::{Groth16, r1cs_to_qap::LibsnarkReduction}; use zkm_verifier::{IMM_GROTH16_VK_BYTES, convert_ark_imm_wrap_vk}; diff --git a/circuits/operator-proof/host/src/main.rs b/circuits/operator-proof/host/src/main.rs index dab623f7b..325dc3231 100644 --- a/circuits/operator-proof/host/src/main.rs +++ b/circuits/operator-proof/host/src/main.rs @@ -1,7 +1,11 @@ //! Generate operator proof use clap::Parser; -use operator_proof::{Args, OperatorProofBuilder, fetch_target_block_and_watchtower_tx}; +use operator_proof::{ + Args, OperatorProofBuilder, build_withdrawal_proof, fetch_target_block_and_watchtower_tx, +}; use proof_builder::{ProofBuilder, ProofRequest}; +use state_chain::WithdrawalRecord; +use std::str::FromStr; use util::hex_parse; use zkm_sdk::HashableKey; @@ -40,10 +44,19 @@ async fn main() { .await .unwrap(); + let graph_id = hex_parse::<16>(&args.graph_id).unwrap(); + let gateway_address = + alloy_primitives::Address::from_str(&args.gateway_address).unwrap().into_array(); + let records: Vec = + serde_json::from_slice(&std::fs::read(&args.withdrawal_records).unwrap()).unwrap(); + let withdrawal_proof = build_withdrawal_proof(&records, gateway_address, graph_id).unwrap(); + let ctx = ProofRequest::OperatorProofRequest { included_watchtowers: args.included_watchtowers.clone(), - graph_id: hex_parse::<16>(&args.graph_id).unwrap(), + graph_id, genesis_sequencer_commit_txid: args.genesis_sequencer_commit_txid.clone(), + gateway_address, + withdrawal_proof, header_chain_input_proof: args.header_chain_input_proof.clone(), commit_chain_input_proof: args.commit_chain_input_proof.clone(), diff --git a/circuits/proof-builder/src/lib.rs b/circuits/proof-builder/src/lib.rs index 94656ee5e..78dcf7c00 100644 --- a/circuits/proof-builder/src/lib.rs +++ b/circuits/proof-builder/src/lib.rs @@ -1,7 +1,7 @@ use anyhow::Result; use bitcoin::{Block, BlockHash, Transaction, Txid}; use commit_chain::CircuitCommit; -use header_chain::CircuitBlockHeader; +use header_chain::{CircuitBlockHeader, MMRInclusionProof}; use serde::{Deserialize, Serialize}; use state_chain::CircuitStateBlock; use std::fs; @@ -14,6 +14,8 @@ use zkm_sdk::{Prover, ZKMProofKind, ZKMProvingKey, ZKMStdin, ZKMVerifyingKey}; pub mod api_auth; pub mod artifacts; +// Every variant carries a batch of inputs; the request is built once per proof. +#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ProofRequest { HeaderChainProofRequest { @@ -55,6 +57,10 @@ pub enum ProofRequest { included_watchtowers: String, graph_id: [u8; 16], genesis_sequencer_commit_txid: String, + /// The Gateway that processed the graph's withdrawal; part of the constant. + gateway_address: [u8; 20], + /// Inclusion of that withdrawal in the state-chain proof's accumulator. + withdrawal_proof: MMRInclusionProof, header_chain_input_proof: String, commit_chain_input_proof: String, state_chain_input_proof: String, @@ -208,6 +214,9 @@ pub struct OnDemandTask { pub watchtower_public_keys: Vec, pub graph_id: Option, pub operator_committed_blockhash: Option, + /// Operator tasks only: the Gateway of the withdrawal and its inclusion proof. + pub gateway_address: Option<[u8; 20]>, + pub withdrawal_proof: Option, } #[derive(Clone, Debug, Serialize, Deserialize, Display, EnumString)] diff --git a/crates/store/.sqlx/query-128842963b6835643e4a62e8341ff6de8c47e145580c980b40691f90d6a7aaa2.json b/crates/store/.sqlx/query-128842963b6835643e4a62e8341ff6de8c47e145580c980b40691f90d6a7aaa2.json deleted file mode 100644 index 4b335a7ba..000000000 --- a/crates/store/.sqlx/query-128842963b6835643e4a62e8341ff6de8c47e145580c980b40691f90d6a7aaa2.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT\n INTO operator_proof (instance_id, graph_id, execution_layer_block_number, path_to_proof, public_value_hex, proof_size, cycles, proof_state, total_time_to_proof, proving_time,\n zkm_version, extra, updated_at, created_at, operator_committed_blockhash)\n VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 15 - }, - "nullable": [] - }, - "hash": "128842963b6835643e4a62e8341ff6de8c47e145580c980b40691f90d6a7aaa2" -} diff --git a/crates/store/migrations/20261112000000_withdrawal_accumulator.sql b/crates/store/migrations/20261112000000_withdrawal_accumulator.sql new file mode 100644 index 000000000..983243a40 --- /dev/null +++ b/crates/store/migrations/20261112000000_withdrawal_accumulator.sql @@ -0,0 +1,14 @@ +-- The Gateway an operator task proves its withdrawal from; part of the graph constant. +ALTER TABLE operator_proof ADD COLUMN `gateway_address` TEXT NOT NULL DEFAULT ''; + +-- Every withdrawal the proven state chain accumulated, in leaf order. Rebuilt from +-- `block_number` on when a batch is proven again. +CREATE TABLE state_chain_withdrawal +( + `leaf_index` BIGINT PRIMARY KEY, + `block_number` BIGINT NOT NULL, + `gateway_address` TEXT NOT NULL, + `graph_id` TEXT NOT NULL +); +CREATE INDEX idx_state_chain_withdrawal_block ON state_chain_withdrawal (block_number); +CREATE INDEX idx_state_chain_withdrawal_graph ON state_chain_withdrawal (gateway_address, graph_id); diff --git a/crates/store/src/localdb/proof.rs b/crates/store/src/localdb/proof.rs index 18e9f81c7..31d8a3056 100644 --- a/crates/store/src/localdb/proof.rs +++ b/crates/store/src/localdb/proof.rs @@ -4,7 +4,7 @@ use super::{StorageProcessor, get_current_timestamp_secs}; use crate::{ LongRunningTaskProof, MetricsStateCount, OnDemandProofCompletion, OnDemandProofTable, - OperatorProof, WatchtowerProof, + OperatorProof, StateChainWithdrawal, WatchtowerProof, }; use sqlx::types::Uuid; @@ -269,30 +269,84 @@ impl StorageProcessor<'_> { &mut self, operator_proof: &OperatorProof, ) -> anyhow::Result { - let res = sqlx::query!( + let res = sqlx::query( "INSERT INTO operator_proof (instance_id, graph_id, execution_layer_block_number, path_to_proof, public_value_hex, proof_size, cycles, proof_state, total_time_to_proof, proving_time, - zkm_version, extra, updated_at, created_at, operator_committed_blockhash) - VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - operator_proof.instance_id, - operator_proof.graph_id, - operator_proof.execution_layer_block_number, - operator_proof.path_to_proof, - operator_proof.public_value_hex, - operator_proof.proof_size, - operator_proof.cycles, - operator_proof.proof_state, - operator_proof.total_time_to_proof, - operator_proof.proving_time, - operator_proof.zkm_version, - operator_proof.extra, - operator_proof.updated_at, - operator_proof.created_at, - operator_proof.operator_committed_blockhash, + zkm_version, extra, updated_at, created_at, operator_committed_blockhash, gateway_address) + VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) + .bind(operator_proof.instance_id) + .bind(operator_proof.graph_id) + .bind(operator_proof.execution_layer_block_number) + .bind(&operator_proof.path_to_proof) + .bind(&operator_proof.public_value_hex) + .bind(operator_proof.proof_size) + .bind(operator_proof.cycles) + .bind(operator_proof.proof_state) + .bind(operator_proof.total_time_to_proof) + .bind(operator_proof.proving_time) + .bind(&operator_proof.zkm_version) + .bind(&operator_proof.extra) + .bind(operator_proof.updated_at) + .bind(operator_proof.created_at) + .bind(&operator_proof.operator_committed_blockhash) + .bind(&operator_proof.gateway_address) + .execute(self.conn()) + .await?; + Ok(res.rows_affected()) + } + + /// Replaces the accumulated withdrawals from `from_block` on with `withdrawals`, in + /// order, continuing the leaf numbering of the rows that stay. + pub async fn replace_state_chain_withdrawals( + &mut self, + from_block: i64, + withdrawals: &[StateChainWithdrawal], + ) -> anyhow::Result<()> { + sqlx::query("DELETE FROM state_chain_withdrawal WHERE block_number >= ?") + .bind(from_block) .execute(self.conn()) .await?; - Ok(res.rows_affected()) + let next: i64 = sqlx::query_scalar( + "SELECT COALESCE(MAX(leaf_index) + 1, 0) FROM state_chain_withdrawal", + ) + .fetch_one(self.conn()) + .await?; + for (offset, withdrawal) in withdrawals.iter().enumerate() { + anyhow::ensure!( + withdrawal.block_number >= from_block, + "withdrawal in block {} precedes batch start {from_block}", + withdrawal.block_number + ); + sqlx::query( + "INSERT INTO state_chain_withdrawal (leaf_index, block_number, gateway_address, graph_id) + VALUES (?, ?, ?, ?)", + ) + .bind(next + offset as i64) + .bind(withdrawal.block_number) + .bind(&withdrawal.gateway_address) + .bind(withdrawal.graph_id) + .execute(self.conn()) + .await?; + } + Ok(()) + } + + /// The accumulated withdrawals of every block before `block_end`, in leaf order. + pub async fn list_state_chain_withdrawals_before( + &mut self, + block_end: i64, + ) -> anyhow::Result> { + let rows = sqlx::query_as::<_, StateChainWithdrawal>( + "SELECT leaf_index, block_number, gateway_address, graph_id + FROM state_chain_withdrawal + WHERE block_number < ? + ORDER BY leaf_index ASC", + ) + .bind(block_end) + .fetch_all(self.conn()) + .await?; + Ok(rows) } #[allow(clippy::too_many_arguments)] diff --git a/crates/store/src/localdb/tests.rs b/crates/store/src/localdb/tests.rs index 07950a40c..dcf9e0062 100644 --- a/crates/store/src/localdb/tests.rs +++ b/crates/store/src/localdb/tests.rs @@ -638,6 +638,40 @@ async fn watchtower_key_migration_keeps_one_task_per_key() { assert_eq!(id, 6); } +#[tokio::test] +async fn state_chain_withdrawals_are_numbered_in_order_and_rebuilt_from_a_block() { + use crate::StateChainWithdrawal; + let local_db = create_local_db("sqlite::memory:").await; + let mut storage = local_db.acquire().await.unwrap(); + let row = |block_number, graph| StateChainWithdrawal { + leaf_index: 0, + block_number, + gateway_address: "0x1111111111111111111111111111111111111111".to_string(), + graph_id: Uuid::from_bytes([graph; 16]), + }; + storage.replace_state_chain_withdrawals(10, &[row(10, 1), row(12, 2)]).await.unwrap(); + storage.replace_state_chain_withdrawals(20, &[row(21, 3)]).await.unwrap(); + let leaves = storage.list_state_chain_withdrawals_before(30).await.unwrap(); + assert_eq!( + leaves.iter().map(|leaf| (leaf.leaf_index, leaf.block_number)).collect::>(), + [(0, 10), (1, 12), (2, 21)] + ); + assert_eq!(storage.list_state_chain_withdrawals_before(21).await.unwrap().len(), 2); + + // Proving batch 20 again replaces its leaves and keeps the numbering. + storage.replace_state_chain_withdrawals(20, &[row(20, 4), row(22, 5)]).await.unwrap(); + let leaves = storage.list_state_chain_withdrawals_before(30).await.unwrap(); + assert_eq!( + leaves + .iter() + .map(|leaf| (leaf.leaf_index, leaf.graph_id.as_bytes()[0])) + .collect::>(), + [(0, 1), (1, 2), (2, 4), (3, 5)] + ); + // A leaf before the batch start is a caller error. + assert!(storage.replace_state_chain_withdrawals(20, &[row(19, 6)]).await.is_err()); +} + #[tokio::test] async fn test_list_recent_committee_addresses_dedupes_recent_instances() { let local_db = create_local_db("sqlite::memory:").await; diff --git a/crates/store/src/schema.rs b/crates/store/src/schema.rs index b5a9f87d5..458e14cec 100644 --- a/crates/store/src/schema.rs +++ b/crates/store/src/schema.rs @@ -833,6 +833,8 @@ pub struct OperatorProof { pub id: i64, pub instance_id: Uuid, pub graph_id: Uuid, + /// Checksummed hex of the Gateway the proof must show a withdrawal from. + pub gateway_address: String, pub operator_committed_blockhash: String, pub execution_layer_block_number: i64, pub path_to_proof: Option, @@ -848,6 +850,16 @@ pub struct OperatorProof { pub updated_at: i64, } +/// One leaf of the state chain's withdrawal accumulator. +#[derive(Clone, FromRow, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct StateChainWithdrawal { + pub leaf_index: i64, + pub block_number: i64, + /// Checksummed hex Gateway address. + pub gateway_address: String, + pub graph_id: Uuid, +} + #[derive(Clone, FromRow, Debug, Serialize, Deserialize, Default)] pub struct LongRunningTaskProof { pub block_start: i64, diff --git a/node/src/bin/mock_proof_builder.rs b/node/src/bin/mock_proof_builder.rs index d85709081..d32713f41 100644 --- a/node/src/bin/mock_proof_builder.rs +++ b/node/src/bin/mock_proof_builder.rs @@ -175,9 +175,17 @@ fn build_operator_public_values( xonly_keys.push(xonly); } + let gateway = req + .gateway_address + .as_deref() + .ok_or_else(|| "gateway_address is required".to_string())? + .parse::() + .map_err(|e| format!("invalid gateway_address: {e}"))? + .into_array(); let constant = bitcoin_light_client_circuit::hash_operator_constant( graph_id_bytes, genesis, + gateway, wci_internal, &xonly_keys, ); @@ -415,7 +423,7 @@ mod tests { OperatorProofRequest { instance_id: "11111111-1111-1111-1111-111111111111".to_string(), graph_id: "22222222-2222-2222-2222-222222222222".to_string(), - gateway_address: None, + gateway_address: Some(format!("0x{}", "33".repeat(20))), operator_committed_blockhash: "aa".repeat(32), execution_layer_block_number: 5, watchtower_challenge_txids: vec![Some("bb".repeat(32))], diff --git a/node/src/utils/graph_validate.rs b/node/src/utils/graph_validate.rs index 696134fa3..78a840875 100644 --- a/node/src/utils/graph_validate.rs +++ b/node/src/utils/graph_validate.rs @@ -89,12 +89,14 @@ fn validate_watchtower_selection( registered: &[XOnlyPublicKey], graph_id: [u8; 16], genesis_txid: [u8; 32], + gateway: [u8; 20], challenge_init_txid: [u8; 32], constant: [u8; 32], ) -> Result<()> { validate_watchtower_registry_selection(selected, registered)?; let key_bytes = selected.iter().map(XOnlyPublicKey::serialize).collect::>(); - let expected = hash_operator_constant(graph_id, genesis_txid, challenge_init_txid, &key_bytes); + let expected = + hash_operator_constant(graph_id, genesis_txid, gateway, challenge_init_txid, &key_bytes); if constant != expected { bail!(SpecialError::InvalidGraph( "operator constant mismatch for graph watchtower list".to_string() @@ -314,6 +316,7 @@ pub async fn validate_init_graph_base( &watchtowers_on_chain, *graph.parameters.graph_id.as_bytes(), get_genesis_sequencer_commit_id(), + get_goat_gateway_contract_from_env().into_array(), full_graph.watchtower_challenge_init.tx().compute_txid().to_byte_array(), graph.parameters.pubin_disprove_constant, )?; @@ -495,6 +498,7 @@ pub async fn validate_finalized_graph( &watchtowers_on_chain, *graph.parameters.graph_id.as_bytes(), get_genesis_sequencer_commit_id(), + get_goat_gateway_contract_from_env().into_array(), full_graph.watchtower_challenge_init.tx().compute_txid().to_byte_array(), graph.parameters.pubin_disprove_constant, )?; diff --git a/node/src/utils/proof_client.rs b/node/src/utils/proof_client.rs index 618ffaf39..851f0b356 100644 --- a/node/src/utils/proof_client.rs +++ b/node/src/utils/proof_client.rs @@ -398,6 +398,7 @@ pub fn derive_operator_statement( let constant = hash_operator_constant( *graph_id.as_bytes(), get_genesis_sequencer_commit_id(), + get_goat_gateway_contract_from_env().into_array(), challenge_init_txid, &key_bytes, ); @@ -591,7 +592,8 @@ pub async fn notify_watchtower_proof_timeout(instance_id: Uuid, graph_id: Uuid) Ok(response.data.is_some()) } -/// Computes the graph constant committed by the Operator guest and Connector D. +/// Computes the graph constant committed by the Operator guest and Connector D. It names +/// this node's Gateway, so the proof must show a withdrawal processed by that contract. pub fn get_guest_constant_value( graph_id: Uuid, challenge_init_txid: [u8; 32], @@ -601,6 +603,7 @@ pub fn get_guest_constant_value( Ok(hash_operator_constant( graph_id.into_bytes(), get_genesis_sequencer_commit_id(), + get_goat_gateway_contract_from_env().into_array(), challenge_init_txid, &key_bytes, )) diff --git a/proof-builder-rpc/Cargo.toml b/proof-builder-rpc/Cargo.toml index bf3639f83..68530ddee 100644 --- a/proof-builder-rpc/Cargo.toml +++ b/proof-builder-rpc/Cargo.toml @@ -37,9 +37,11 @@ hex = { workspace = true } # proofs proof-builder = { workspace = true } +header-chain = { workspace = true } header-chain-proof = { workspace = true } commit-chain-proof = { workspace = true } commit-chain = { workspace = true } +state-chain = { workspace = true } state-chain-proof = { workspace = true } watchtower-proof = { workspace = true } operator-proof = { workspace = true } diff --git a/proof-builder-rpc/src/api/auth.rs b/proof-builder-rpc/src/api/auth.rs index e0a9cde56..a16605ef8 100644 --- a/proof-builder-rpc/src/api/auth.rs +++ b/proof-builder-rpc/src/api/auth.rs @@ -208,8 +208,8 @@ impl RequestAuthorizer { Ok(graph_data) } - /// Selects the configured contract set identified by the signed request body. - fn chain_for_gateway(&self, gateway_address: Option<&str>) -> AuthResult<&ChainEntry> { + /// The configured Gateway a request names, or the only one when it names none. + pub(crate) fn resolve_gateway(&self, gateway_address: Option<&str>) -> AuthResult
{ let gateway_address = match gateway_address { Some(value) => { let address = value @@ -221,7 +221,7 @@ impl RequestAuthorizer { address } None if self.chains.len() == 1 => { - return Ok(self.chains.values().next().expect("one chain is configured")); + return Ok(*self.chains.keys().next().expect("one chain is configured")); } None => { return Err(bad_request( @@ -229,10 +229,16 @@ impl RequestAuthorizer { )); } }; + if !self.chains.contains_key(&gateway_address) { + return Err(forbidden("gateway_address is not configured")); + } + Ok(gateway_address) + } - self.chains - .get(&gateway_address) - .ok_or_else(|| forbidden("gateway_address is not configured")) + /// Selects the configured contract set identified by the signed request body. + fn chain_for_gateway(&self, gateway_address: Option<&str>) -> AuthResult<&ChainEntry> { + let gateway_address = self.resolve_gateway(gateway_address)?; + Ok(self.chains.get(&gateway_address).expect("resolved gateway is configured")) } /// Atomically records an authorized request's nonce, rejecting a diff --git a/proof-builder-rpc/src/api/proof_handler.rs b/proof-builder-rpc/src/api/proof_handler.rs index 08061b528..7fba75f30 100644 --- a/proof-builder-rpc/src/api/proof_handler.rs +++ b/proof-builder-rpc/src/api/proof_handler.rs @@ -257,10 +257,15 @@ pub(super) async fn post_operator_proof_task( &mut api_result, InputValidator::validate_operator_task(&payload, &graph_data), )?; + let gateway_address = record_auth_result( + &mut api_result, + api_state.auth.resolve_gateway(payload.gateway_address.as_deref()), + )?; add_operator_task( &api_state.local_db, instance_id, graph_id, + gateway_address.to_string(), payload.operator_committed_blockhash, payload.execution_layer_block_number, payload.watchtower_challenge_txids.clone(), diff --git a/proof-builder-rpc/src/task/mod.rs b/proof-builder-rpc/src/task/mod.rs index c048c74f8..e51627777 100644 --- a/proof-builder-rpc/src/task/mod.rs +++ b/proof-builder-rpc/src/task/mod.rs @@ -15,14 +15,18 @@ use crate::task::{ }; use ::commit_chain_proof::CommitChainProofBuilder; use ::header_chain_proof::HeaderChainProofBuilder; +use ::operator_proof::build_withdrawal_proof; use ::state_chain_proof::StateChainProofBuilder; +use alloy_primitives::Address; use bitcoin::{BlockHash, Network, PublicKey, Txid}; use client::btc_chain::BTCClient; use secp256k1::XOnlyPublicKey; +use state_chain::WithdrawalRecord; use std::collections::{HashMap, HashSet}; use std::str::FromStr; use std::sync::Arc; use std::time::{Instant, UNIX_EPOCH}; +use util::hex_parse; use uuid::Uuid; use futures::future::Either; @@ -557,6 +561,7 @@ async fn read_watchtower_challenge_details<'a>( Vec, Option, Option, + Option, )> { let ( task_index, @@ -567,6 +572,7 @@ async fn read_watchtower_challenge_details<'a>( watchtower_public_keys, graph_id, operator_committed_blockhash, + gateway_address, ) = match row { OnDemandRow::Watchtower(task) => { tracing::info!("watchtower task: {task:?}"); @@ -583,6 +589,7 @@ async fn read_watchtower_challenge_details<'a>( pubkeys, Some(task.graph_id.as_simple().to_string()), None, + None, ) } OnDemandRow::Operator(task) => { @@ -627,6 +634,7 @@ async fn read_watchtower_challenge_details<'a>( challenge_public_keys, Some(task.graph_id.as_simple().to_string()), Some(task.operator_committed_blockhash), + Some(task.gateway_address), ) } }; @@ -643,9 +651,45 @@ async fn read_watchtower_challenge_details<'a>( watchtower_public_keys, graph_id, operator_committed_blockhash, + gateway_address, )) } +/// The inclusion proof of the graph's withdrawal from `gateway_address` in the +/// accumulator of the state-chain proof ending at `block_end`. +async fn build_operator_withdrawal_proof( + storage_processor: &mut store::localdb::StorageProcessor<'_>, + gateway_address: &str, + graph_id: &str, + block_end: i64, +) -> anyhow::Result { + let gateway = Address::from_str(gateway_address) + .map_err(|error| invalid_task(format!("invalid gateway {gateway_address}: {error}")))? + .into_array(); + let graph_id = hex_parse::<16>(graph_id) + .map_err(|error| invalid_task(format!("invalid graph id {graph_id}: {error}")))?; + let rows = storage_processor.list_state_chain_withdrawals_before(block_end).await?; + let records = rows + .iter() + .enumerate() + .map(|(position, row)| { + anyhow::ensure!( + row.leaf_index == position as i64, + "state chain withdrawal leaves are not contiguous at {}", + row.leaf_index + ); + Ok(WithdrawalRecord { + block_number: row.block_number as u64, + gateway: Address::from_str(&row.gateway_address)?.into_array(), + graph_id: *row.graph_id.as_bytes(), + }) + }) + .collect::>>()?; + // The proven chain holds every withdrawal before `block_end`; a missing one never appears. + build_withdrawal_proof(&records, gateway, graph_id) + .map_err(|error| invalid_task(format!("{error:#}"))) +} + /// Fetches and prepares the next watchtower or operator task after `after_id`. /// Returns `None` when no task is `New`. #[tracing::instrument(level = "info", skip(local_db))] @@ -699,6 +743,7 @@ async fn prepare_on_demand_task( watchtower_public_keys, graph_id, operator_committed_blockhash, + gateway_address, ) = read_watchtower_challenge_details(storage_processor, row).await?; if !is_watchtower && operator_committed_blockhash.is_none() { @@ -824,6 +869,18 @@ async fn prepare_on_demand_task( return Ok(None); } }; + let withdrawal_proof = match &gateway_address { + Some(gateway_address) => Some( + build_operator_withdrawal_proof( + storage_processor, + gateway_address, + graph_id.as_deref().unwrap_or_default(), + state_chain_input_proof.block_end, + ) + .await?, + ), + None => None, + }; let state_chain_input_proof = state_chain_input_proof.path_to_proof.unwrap(); // commit chain @@ -870,6 +927,10 @@ async fn prepare_on_demand_task( watchtower_public_keys, graph_id, operator_committed_blockhash, + gateway_address: gateway_address + .map(|address| Ok::<_, anyhow::Error>(Address::from_str(&address)?.into_array())) + .transpose()?, + withdrawal_proof, })) } @@ -1105,6 +1166,7 @@ pub(crate) async fn add_operator_task( local_db: &LocalDB, instance_id: Uuid, graph_id: Uuid, + gateway_address: String, operator_committed_blockhash: String, execution_layer_block_number: i64, watchtower_challenge_txids: Vec>, @@ -1203,6 +1265,7 @@ pub(crate) async fn add_operator_task( id: 1, instance_id, graph_id, + gateway_address, execution_layer_block_number, proof_state: ProofState::New.to_i64(), created_at: current_time_secs(), @@ -1428,6 +1491,7 @@ mod tests { &local_db, instance_id, graph_id, + Address::repeat_byte(0x11).to_string(), operator_committed_blockhash, number, watchtower_challenge_txids, @@ -1569,6 +1633,7 @@ mod watchtower_slot_tests { &local_db, Uuid::nil(), graph_id, + Address::repeat_byte(0x11).to_string(), "11".repeat(32), 1, vec![None], diff --git a/proof-builder-rpc/src/task/operator_proof.rs b/proof-builder-rpc/src/task/operator_proof.rs index 97a26dc28..bcd01f67a 100644 --- a/proof-builder-rpc/src/task/operator_proof.rs +++ b/proof-builder-rpc/src/task/operator_proof.rs @@ -51,6 +51,8 @@ pub(crate) fn spawn_operator_proof_task( _ = tokio::time::sleep(Duration::from_secs(interval)) => { // fetch args from the database. let task_index; + let gateway_address; + let withdrawal_proof; match fetch_on_demand_task( &local_db, false, cursor.after_id(), args.bitcoin_network, &args.esplora_url, ).await { @@ -77,6 +79,8 @@ pub(crate) fn spawn_operator_proof_task( // LE array to string, e.g. [1, 1, 1, 0] => 7 args.included_watchtowers = le_bits_to_u256(&next_task.included_watchtowers).to_string(); task_index = next_task.task_index; + gateway_address = next_task.gateway_address.expect("operator task names its gateway"); + withdrawal_proof = next_task.withdrawal_proof.expect("operator task carries its withdrawal proof"); } Ok(Some(OnDemandFetch::NotReady(task_index))) => { cursor.advance(task_index); @@ -135,6 +139,8 @@ pub(crate) fn spawn_operator_proof_task( included_watchtowers: args.included_watchtowers.clone(), graph_id: hex_parse::<16>(&args.graph_id).unwrap(), genesis_sequencer_commit_txid: args.genesis_sequencer_commit_txid.clone(), + gateway_address, + withdrawal_proof, header_chain_input_proof: args.header_chain_input_proof.clone(), commit_chain_input_proof: args.commit_chain_input_proof.clone(), diff --git a/proof-builder-rpc/src/task/state_chain_proof.rs b/proof-builder-rpc/src/task/state_chain_proof.rs index b9a13092f..9dbf45e2b 100644 --- a/proof-builder-rpc/src/task/state_chain_proof.rs +++ b/proof-builder-rpc/src/task/state_chain_proof.rs @@ -7,8 +7,10 @@ use crate::task::{ }; use crate::{ProofBuilderConfig, task::fetch_latest_long_running_task}; use proof_builder::{ProofBuilder, ProofRequest}; +use state_chain::block_withdrawal_records; use state_chain_proof::{StateChainProofBuilder, fetch_state_chain}; use std::time::Duration; +use store::StateChainWithdrawal; use store::localdb::LocalDB; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; @@ -154,6 +156,25 @@ async fn spawn_state_chain_ctx_builder( } } +/// The withdrawals the circuit appends for this batch, in leaf order. The genesis batch +/// starts at its first block, whose withdrawals the circuit does not accumulate. +fn batch_withdrawals(ctx: &ProofRequest) -> Vec { + let ProofRequest::StateChainProofRequest { init_input, blocks, .. } = ctx else { + return Vec::new(); + }; + blocks + .iter() + .skip(usize::from(*init_input)) + .flat_map(block_withdrawal_records) + .map(|record| StateChainWithdrawal { + leaf_index: 0, + block_number: record.block_number as i64, + gateway_address: alloy_primitives::Address::from(record.gateway).to_string(), + graph_id: uuid::Uuid::from_bytes(record.graph_id), + }) + .collect() +} + #[tracing::instrument(level = "info", skip(runtime, local_db, metrics_state, cancellation_token))] async fn spawn_state_chain_prover( runtime: ProverRuntime, @@ -224,6 +245,7 @@ async fn spawn_state_chain_prover( }; tracing::info!("Updated long running task to proving state, affected rows: {affteced}"); + let withdrawals = batch_withdrawals(&ctx); let attempt_start = tokio::time::Instant::now(); let unit = { let (runtime, local_db, builder) = (runtime.clone(), local_db.clone(), builder.clone()); @@ -233,6 +255,12 @@ async fn spawn_state_chain_prover( Ok(proved) => proved, Err(error) => return Ok(Err(error)), }; + // Operator tasks prove withdrawals against this batch's accumulator. + let mut storage_processor = local_db.start_transaction().await?; + storage_processor + .replace_state_chain_withdrawals(start_index as i64, &withdrawals) + .await?; + storage_processor.commit().await?; let affected = update_long_running_task( &local_db, start_index as i64,