Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/full-node/sov-aggregated-proof/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions crates/full-node/sov-aggregated-proof/program/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ publish = false

[dependencies]
bincode = { workspace = true }
serde = { workspace = true }
demo-stf = { path = "../../../../examples/demo-rollup/stf", default-features = false }
sha2 = { workspace = true }
sp1-zkvm = { workspace = true }
Expand All @@ -14,4 +15,9 @@ sov-aggregated-proof-shared = { workspace = true }
sov-mock-da = { workspace = true }
sov-mock-zkvm = { workspace = true }
sov-modules-api = { workspace = true }
sov-rollup-interface = { workspace = true }
sov-sp1-adapter = { workspace = true }

[build-dependencies]
bincode = { workspace = true }
sp1-sdk = { workspace = true }
37 changes: 37 additions & 0 deletions crates/full-node/sov-aggregated-proof/program/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
use std::env;
use std::fs;
use std::path::PathBuf;

use sp1_sdk::prelude::HashableKey;
use sp1_sdk::SP1VerifyingKey;

fn main() {
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
let data_dir = manifest_dir
.parent()
.expect("program crate must live under the sov-aggregated-proof workspace root")
.join("data");
let inner_vk_path = data_dir.join("inner_vk.bin");
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));

println!("cargo:rerun-if-changed={}", inner_vk_path.display());

let inner_vk_bytes = fs::read(&inner_vk_path).unwrap_or_else(|error| {
panic!(
"Failed to read saved inner verifying key fixture at {}: {error}",
inner_vk_path.display()
)
});
let inner_vk: SP1VerifyingKey = bincode::deserialize(&inner_vk_bytes).unwrap_or_else(|error| {
panic!(
"Failed to deserialize saved inner verifying key fixture at {}: {error}",
inner_vk_path.display()
)
});
let inner_vk_hash = inner_vk.hash_u32();

let generated = format!("const INNER_VKEY_HASH: [u32; 8] = {inner_vk_hash:?};\n");

fs::write(out_dir.join("inner_vk_hash.rs"), generated)
.expect("Failed to write generated inner VK hash file");
}
202 changes: 202 additions & 0 deletions crates/full-node/sov-aggregated-proof/program/src/aggregation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
use sha2::{Digest, Sha256};
use sov_aggregated_proof_shared::{
AggPubData, AggregatedProofWitness, DeferredProofInput, StfPubData,
};
use sov_modules_api::da::BlockHeaderTrait;
use sov_modules_api::{DaSpec, OuterCodeCommitmentHash, Spec, Storage};
use sov_rollup_interface::common::SlotNumber;

struct BoundaryData<Hash, Root> {
slot_hash: Hash,
state_root: Root,
slot_number: SlotNumber,
}

struct VerifiedProofData<Address, Hash, Root> {
initial_boundary: BoundaryData<Hash, Root>,
final_boundary: BoundaryData<Hash, Root>,
rewarded_addresses: Vec<Address>,
}

type VerifyResult<S, Da> = VerifiedProofData<
<S as Spec>::Address,
<Da as DaSpec>::SlotHash,
<<S as Spec>::Storage as Storage>::Root,
>;

pub(crate) fn run_aggregation_program<S: Spec<Da = Da>, Da: DaSpec>(
witness: AggregatedProofWitness<Da>,
inner_vkey_hash: [u32; 8],
) {
let proof_inputs = witness.proof_inputs;
let outer_vkey_hash = witness.outer_vkey_hash;
let prev_outer_proof_witness = witness.prev_outer_proof_witness;

// Verify the previous aggregation proof if one exists. On the first aggregation
// after genesis, there is no predecessor, the chain starts here.
let previous_public_data = if let Some(prev_outer_proof_witness) = prev_outer_proof_witness {
Some(deserialize_and_verify_pub_data::<AggPubData<S, Da>>(
&prev_outer_proof_witness.public_values,
outer_vkey_hash,
))
} else {
None
};

let verified_proof_data: VerifyResult<S, Da> =
verify_proof_chain::<S, Da>(proof_inputs, inner_vkey_hash, previous_public_data.as_ref());

let VerifiedProofData {
initial_boundary,
final_boundary,
rewarded_addresses,
} = verified_proof_data;

// Propagate the genesis state root forward through recursive aggregations.
// For the very first aggregation, the genesis root is the initial state root
// of the first inner proof (i.e. the state root at chain genesis).
let genesis_state_root = previous_public_data
.as_ref()
.map(|public_data| public_data.genesis_state_root.clone())
.unwrap_or_else(|| initial_boundary.state_root.clone());

let outer_vk_hash = outer_vk_hash_from_vkey_hash(outer_vkey_hash);

let aggregated_public_data = AggPubData::<S, Da> {
initial_slot_number: initial_boundary.slot_number,
final_slot_number: final_boundary.slot_number,
genesis_state_root,
initial_state_root: initial_boundary.state_root,
final_state_root: final_boundary.state_root,
initial_slot_hash: initial_boundary.slot_hash,
final_slot_hash: final_boundary.slot_hash,
outer_vk_hash,
rewarded_addresses,
};

// Commit the aggregated public data as this program's public output.
// This is what external verifiers (and the next recursive aggregation) will see.
sp1_zkvm::io::commit(&aggregated_public_data);
}

fn verify_proof_chain<S: Spec, Da: DaSpec>(
proof_inputs: Vec<DeferredProofInput<Da>>,
vkey_hash: [u32; 8],
previous_agg_proof_public_data: Option<&AggPubData<S, Da>>,
) -> VerifyResult<S, Da> {
assert!(
!proof_inputs.is_empty(),
"Aggregated proof must contain at least one proof input"
);

let mut expected_prev_slot_hash =
previous_agg_proof_public_data.map(|public_data| public_data.final_slot_hash.clone());

let mut expected_prev_state_root =
previous_agg_proof_public_data.map(|public_data| public_data.final_state_root.clone());

// We intentionally scope the output to the current set of inner proofs only.
// The predecessor proof is verified for chain continuity, but its slot range
// and rewards are not carried forward — each aggregation covers only the
// proofs it directly verifies.
let mut initial_boundary = None;
let mut final_boundary = None;

let mut rewarded_addresses = Vec::with_capacity(proof_inputs.len());

for (index, proof_input) in proof_inputs.iter().enumerate() {
let stf_public_data = deserialize_and_verify_pub_data::<StfPubData<S, Da>>(
&proof_input.public_values,
vkey_hash,
);

let current_slot_number = SlotNumber::new(proof_input.da_block_header.height());

// Verify DA block hash-chain continuity: each block's prev_hash must equal
// the predecessor's hash. Also cross-check that the DA header hash matches
// the slot_hash committed in the STF proof's public data, binding the DA
// layer to the execution layer.
{
let da_block_header = &proof_input.da_block_header;
let current_block_hash = da_block_header.hash();

if let Some(expected_prev_slot_hash) = &expected_prev_slot_hash {
assert_eq!(
expected_prev_slot_hash,
&da_block_header.prev_hash(),
"DA block chain broken at index {index}: prev_hash mismatch"
);
}

assert_eq!(
current_block_hash, stf_public_data.slot_hash,
"Slot hash mismatch at index {index}: DA block header hash doesn't match public data"
);
expected_prev_slot_hash = Some(current_block_hash);
}

// Verify state root continuity: each proof's initial_state_root must equal
// the predecessor's final_state_root, ensuring no gaps in the state
// transition.
{
if let Some(expected_prev_state_root) = &expected_prev_state_root {
assert_eq!(
expected_prev_state_root, &stf_public_data.initial_state_root,
"State root discontinuity at index {index}: previous final_state_root != current initial_state_root"
);
}

expected_prev_state_root = Some(stf_public_data.final_state_root.clone());
}

// Record the boundary data for this aggregation's public output.
// initial_boundary is captured only from the first proof; final_boundary
// is overwritten on every iteration so it reflects the last proof.
if initial_boundary.is_none() {
initial_boundary = Some(BoundaryData {
slot_hash: proof_input.da_block_header.hash(),
state_root: stf_public_data.initial_state_root.clone(),
slot_number: current_slot_number.clone(),
});
}

rewarded_addresses.push(stf_public_data.prover_address.clone());
final_boundary = Some(BoundaryData {
slot_hash: proof_input.da_block_header.hash(),
state_root: stf_public_data.final_state_root,
slot_number: current_slot_number,
});
}

let initial_boundary = initial_boundary.expect("proof_inputs is non-empty");
let final_boundary = final_boundary.expect("proof_inputs is non-empty");

VerifyResult::<S, Da> {
initial_boundary,
final_boundary,
rewarded_addresses,
}
}

// Verify that a proof with the given vkey_hash actually
// produced these public values, then deserialize them. This is the core
// trust anchor: the vkey_hash pins which program generated the proof.
fn deserialize_and_verify_pub_data<T: serde::de::DeserializeOwned>(
pub_values: &[u8],
vkey_hash: [u32; 8],
) -> T {
let public_values_digest: [u8; 32] = Sha256::digest(pub_values).into();
sp1_zkvm::lib::verify::verify_sp1_proof(&vkey_hash, &public_values_digest);

bincode::deserialize(pub_values)
.unwrap_or_else(|error| panic!("Failed to deserialize aggregated public data: {error}"))
}

fn outer_vk_hash_from_vkey_hash(vkey_hash: [u32; 8]) -> OuterCodeCommitmentHash {
let mut bytes = Vec::with_capacity(32);
for word in vkey_hash {
// Match SP1's HashableKey::hash_bytes representation for hash_u32().
bytes.extend_from_slice(&word.to_be_bytes());
}
OuterCodeCommitmentHash(bytes)
}
94 changes: 7 additions & 87 deletions crates/full-node/sov-aggregated-proof/program/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,103 +1,23 @@
#![no_main]

mod aggregation;

sp1_zkvm::entrypoint!(main);

use crate::aggregation::run_aggregation_program;
use demo_stf::MultiAddressEvmSolana;
use sha2::{Digest, Sha256};
use sov_aggregated_proof_shared::{
AggregatedProofWitness, DeferredProofInput, PreviousOuterProofWitness,
};
use sov_aggregated_proof_shared::AggregatedProofWitness;
use sov_mock_da::MockDaSpec;
use sov_mock_zkvm::MockZkvm;
use sov_modules_api::configurable_spec::ConfigurableSpec;
use sov_modules_api::da::BlockHeaderTrait;
use sov_modules_api::execution_mode::Zk;
use sov_modules_api::DaSpec;
use sov_modules_api::Spec;
use sov_modules_api::StateTransitionPublicData;
use sov_modules_api::Storage;
use sov_sp1_adapter::SP1;

type S = ConfigurableSpec<MockDaSpec, SP1, MockZkvm, MultiAddressEvmSolana, Zk>;
include!(concat!(env!("OUT_DIR"), "/inner_vk_hash.rs"));

type StPubData<S: Spec, Da: DaSpec> =
StateTransitionPublicData<<S as Spec>::Address, Da, <<S as Spec>::Storage as Storage>::Root>;
type ProgramSpec = ConfigurableSpec<MockDaSpec, SP1, MockZkvm, MultiAddressEvmSolana, Zk>;

pub fn main() {
let witness = sp1_zkvm::io::read::<AggregatedProofWitness<MockDaSpec>>();
let proof_inputs = witness.proof_inputs;
let vkey_hash = witness.vkey_hash;
let prev_outer_proof_witness = witness.prev_outer_proof_witness;

verify::<S, MockDaSpec>(proof_inputs, vkey_hash);

if let Some(prev_outer_proof_witness) = prev_outer_proof_witness {
verify_sp1_proof(
&prev_outer_proof_witness.public_values,
prev_outer_proof_witness.vkey_hash,
);
}
}

fn verify<S: Spec, Da: DaSpec>(proof_inputs: Vec<DeferredProofInput<Da>>, vkey_hash: [u32; 8]) {
assert!(
!proof_inputs.is_empty(),
"Aggregated proof must contain at least one proof input"
);

// `None` means no predecessor to check against (first iteration).
let mut expected_prev_hash = None;
let mut expected_state_root = None;

for (index, proof_input) in proof_inputs.iter().enumerate() {
let stf_public_data =
deserialize_pub_data::<S, Da>(proof_input.public_values.as_slice(), index);

// Check that DA blocks form a chain.
{
let da_block_header = &proof_input.da_block_header;
let current_block_hash = da_block_header.hash();

if let Some(expected_prev_hash) = &expected_prev_hash {
assert_eq!(
expected_prev_hash,
&da_block_header.prev_hash(),
"DA block chain broken at index {index}: prev_hash mismatch"
);
}

// Check that the slot hash from the public input matches the current block hash.
assert_eq!(
current_block_hash, stf_public_data.slot_hash,
"Slot hash mismatch at index {index}: DA block header hash doesn't match public data"
);
expected_prev_hash = Some(current_block_hash);
}

// Check that state roots are sequentially related by the state transition.
{
if let Some(expected_state_root) = &expected_state_root {
assert_eq!(
expected_state_root, &stf_public_data.initial_state_root,
"State root discontinuity at index {index}: previous final_state_root != current initial_state_root"
);
}

println!("Verifying {index}");
verify_sp1_proof(&proof_input.public_values, vkey_hash);

expected_state_root = Some(stf_public_data.final_state_root.clone());
}
}
}

fn verify_sp1_proof(public_values: &[u8], vkey_hash: [u32; 8]) {
let public_values_digest: [u8; 32] = Sha256::digest(public_values).into();
sp1_zkvm::lib::verify::verify_sp1_proof(&vkey_hash, &public_values_digest);
}

fn deserialize_pub_data<S: Spec, Da: DaSpec>(data: &[u8], index: usize) -> StPubData<S, Da> {
bincode::deserialize(data).unwrap_or_else(|error| {
panic!("Failed to deserialize public values from proof input {index}: {error}")
})
run_aggregation_program::<ProgramSpec, MockDaSpec>(witness, INNER_VKEY_HASH);
}
Loading
Loading