diff --git a/.gitignore b/.gitignore index 19d3521c..65bf24eb 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,6 @@ pgo-data.profdata .env # Generated inputs -/script/examples/* \ No newline at end of file +/script/examples/* + +CLAUDE.md \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 2686e62f..e1045e83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6087,6 +6087,7 @@ version = "0.1.0" dependencies = [ "alloy-primitives", "alloy-sol-types", + "alloy-trie", "helios-consensus-core", "serde", ] @@ -6096,8 +6097,11 @@ name = "sp1-helios-program" version = "0.1.0" dependencies = [ "alloy-primitives", + "alloy-rlp", "alloy-sol-types", + "alloy-trie", "helios-consensus-core", + "hex", "serde_cbor", "sp1-helios-primitives", "sp1-zkvm", @@ -6110,6 +6114,7 @@ version = "0.1.0" dependencies = [ "alloy", "alloy-primitives", + "alloy-trie", "anyhow", "cargo_metadata", "clap", @@ -6129,6 +6134,32 @@ dependencies = [ "tree_hash", ] +[[package]] +name = "sp1-helios-test" +version = "0.1.0" +dependencies = [ + "alloy", + "alloy-primitives", + "alloy-rlp", + "alloy-trie", + "anyhow", + "cargo_metadata", + "clap", + "dotenv", + "env_logger", + "helios-consensus-core", + "helios-ethereum", + "log", + "reqwest", + "serde", + "serde_cbor", + "serde_json", + "sp1-helios-primitives", + "sp1-sdk", + "tokio", + "tree_hash", +] + [[package]] name = "sp1-lib" version = "4.1.1" diff --git a/Cargo.toml b/Cargo.toml index ffe4f259..c7c093bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["primitives", "script", "program"] +members = ["primitives", "script", "program", "test"] resolver = "2" [workspace.package] @@ -38,6 +38,11 @@ clap = "4.5.9" log = "0.4.22" env_logger = "0.11.3" alloy-primitives = "0.8.15" +alloy-trie = "0.7.9" +alloy-rlp = { version = "0.3.9", default-features = false, features = [ + "derive", + "arrayvec", +] } alloy = { version = "0.9.1", features = ["full"] } anyhow = "1.0.86" reqwest = "0.12.5" @@ -52,4 +57,4 @@ sha3-v0-10-8 = { git = "https://github.com/sp1-patches/RustCrypto-hashes", packa tiny-keccak = { git = "https://github.com/sp1-patches/tiny-keccak", tag = "patch-2.0.2-sp1-4.0.0" } bls12_381 = { git = "https://github.com/sp1-patches/bls12_381", tag = "patch-0.8.0-sp1-4.0.0" } # From upstream: https://github.com/a16z/helios/blob/master/Cargo.toml#L115 -ethereum_hashing = { git = "https://github.com/ncitron/ethereum_hashing", rev = "7ee70944ed4fabe301551da8c447e4f4ae5e6c35" } \ No newline at end of file +ethereum_hashing = { git = "https://github.com/ncitron/ethereum_hashing", rev = "7ee70944ed4fabe301551da8c447e4f4ae5e6c35" } diff --git a/contracts/genesis.json b/contracts/genesis.json index 093ad78d..64054941 100644 --- a/contracts/genesis.json +++ b/contracts/genesis.json @@ -1,15 +1,15 @@ { - "executionStateRoot": "0xdf692e41511ac4a0382f73ea44b3820bd87effc050d43c104abca9bc482c250b", - "genesisTime": 1655733600, - "genesisValidatorsRoot": "0xd8ea171f3c94aea21ebc42a1ed61052acf3f9209c00e4efbaaddac09ed9b8078", - "guardian": "0x788c45cafab3ea427b9079889be43d7d3cd7500c", - "head": 6733152, - "header": "0xce795ebea5c29436d4a10ca097712d2785e4f8541caec377c7388306d6c21376", - "heliosProgramVkey": "0x00796ec46c244906ad25c835f76c682e538e7ec0837a77bdaa88f0eb1aad8e9f", + "executionStateRoot": "0xfe4cb76cb3d629d8c73f0421b399416549d61382d49c749f6bef8e17f13a415b", + "genesisTime": 1606824023, + "genesisValidatorsRoot": "0x4b363db94e286120d76eb905340fdd4e54bfe9f06bf33ff6cf5ad27f511bfe95", + "guardian": "0x77349640ef922e919d14e24692be1ee38dbfde54", + "head": 11349408, + "header": "0x3262d85ccc58ea8d41b69c779bfa5672ab34058a251bfd4b4ea8cd90122c7914", + "heliosProgramVkey": "0x005b36529a91cf26aca076f004673c457efbbd34ccb19bf4d8f90d395e17027c", "secondsPerSlot": 12, "slotsPerEpoch": 32, "slotsPerPeriod": 8192, - "sourceChainId": 11155111, - "syncCommitteeHash": "0x5aaad4aeaf4c830b38bfe4f9f5fe3cfcdad919639b871ea20cf1e7eff30a8a36", - "verifier": "0x3b6041173b80e77f038f3f2c0f9744f04837185e" -} \ No newline at end of file + "sourceChainId": 1, + "syncCommitteeHash": "0xff83cc067aad82f208b045a9313f09036b5b55b9f67ea0dd2bf5d7eaa8bdee7f", + "verifier": "0x397a5f7f3dbd538f23de225b51f532c34448da9b" +} diff --git a/contracts/src/SP1Helios.sol b/contracts/src/SP1Helios.sol index 6d7beaaa..6449ce0e 100644 --- a/contracts/src/SP1Helios.sol +++ b/contracts/src/SP1Helios.sol @@ -13,13 +13,19 @@ contract SP1Helios { uint256 public immutable SLOTS_PER_EPOCH; uint256 public immutable SOURCE_CHAIN_ID; + /// @notice Maximum number of time behind current timestamp for a block to be used for proving + /// @dev This is set to 1 week to prevent timing attacks where malicious validators + /// could retroactively create forks that diverge from the canonical chain. To minimize this + /// risk, we limit the maximum age of a block to 1 week. + uint256 public constant MAX_SLOT_AGE = 1 weeks; + modifier onlyGuardian() { require(msg.sender == guardian, "Caller is not the guardian"); _; } /// @notice The latest slot the light client has a finalized header for. - uint256 public head = 0; + uint256 public head; /// @notice Maps from a slot to a beacon block header root. mapping(uint256 => bytes32) public headers; @@ -30,6 +36,9 @@ contract SP1Helios { /// @notice Maps from a period to the hash for the sync committee. mapping(uint256 => bytes32) public syncCommittees; + /// @notice Maps from (block number, contract, slot) tuple to storage value + mapping(bytes32 => bytes32) public storageValues; + /// @notice The verification key for the SP1 Helios program. bytes32 public heliosProgramVkey; @@ -39,6 +48,12 @@ contract SP1Helios { /// @notice The address of the guardian address public guardian; + struct StorageSlot { + bytes32 key; + bytes32 value; + address contractAddress; + } + struct ProofOutputs { bytes32 executionStateRoot; bytes32 newHeader; @@ -50,6 +65,7 @@ contract SP1Helios { bytes32 syncCommitteeHash; // Hash of the current sync committee that signed the previous update. bytes32 startSyncCommitteeHash; + StorageSlot[] slots; } struct InitParams { @@ -70,12 +86,20 @@ contract SP1Helios { event HeadUpdate(uint256 indexed slot, bytes32 indexed root); event SyncCommitteeUpdate(uint256 indexed period, bytes32 indexed root); + event StorageSlotVerified( + uint256 indexed slot, + bytes32 indexed key, + bytes32 value, + address contractAddress + ); error SlotBehindHead(uint256 slot); error SyncCommitteeAlreadySet(uint256 period); - error HeaderRootAlreadySet(uint256 slot); - error StateRootAlreadySet(uint256 slot); + error InvalidHeaderRoot(uint256 slot); + error InvalidStateRoot(uint256 slot); error SyncCommitteeStartMismatch(bytes32 given, bytes32 expected); + error PreviousHeadNotSet(uint256 slot); + error PreviousHeadTooOld(uint256 slot); constructor(InitParams memory params) { GENESIS_VALIDATORS_ROOT = params.genesisValidatorsRoot; @@ -84,7 +108,8 @@ contract SP1Helios { SLOTS_PER_PERIOD = params.slotsPerPeriod; SLOTS_PER_EPOCH = params.slotsPerEpoch; SOURCE_CHAIN_ID = params.sourceChainId; - syncCommittees[getSyncCommitteePeriod(params.head)] = params.syncCommitteeHash; + syncCommittees[getSyncCommitteePeriod(params.head)] = params + .syncCommitteeHash; heliosProgramVkey = params.heliosProgramVkey; headers[params.head] = params.header; executionStateRoots[params.head] = params.executionStateRoot; @@ -96,42 +121,89 @@ contract SP1Helios { /// @notice Updates the light client with a new header, execution state root, and sync committee (if changed) /// @param proof The proof bytes for the SP1 proof. /// @param publicValues The public commitments from the SP1 proof. - function update(bytes calldata proof, bytes calldata publicValues) external { + /// @param fromHead The head slot to prove against. + function update( + bytes calldata proof, + bytes calldata publicValues, + uint256 fromHead + ) external { + if (headers[fromHead] == bytes32(0)) { + revert PreviousHeadNotSet(fromHead); + } + + // Check if the head being proved against is older than allowed. + if (block.timestamp - slotTimestamp(fromHead) > MAX_SLOT_AGE) { + revert PreviousHeadTooOld(fromHead); + } + // Parse the outputs from the committed public values associated with the proof. ProofOutputs memory po = abi.decode(publicValues, (ProofOutputs)); - if (po.newHead <= head) { + if (po.newHead < fromHead) { revert SlotBehindHead(po.newHead); } - uint256 currentPeriod = getSyncCommitteePeriod(head); + uint256 currentPeriod = getSyncCommitteePeriod(fromHead); // Note: We should always have a sync committee for the current head. // The "start" sync committee hash is the hash of the sync committee that should sign the next update. bytes32 currentSyncCommitteeHash = syncCommittees[currentPeriod]; if (currentSyncCommitteeHash != po.startSyncCommitteeHash) { - revert SyncCommitteeStartMismatch(po.startSyncCommitteeHash, currentSyncCommitteeHash); + revert SyncCommitteeStartMismatch( + po.startSyncCommitteeHash, + currentSyncCommitteeHash + ); } // Verify the proof with the associated public values. This will revert if proof invalid. - ISP1Verifier(verifier).verifyProof(heliosProgramVkey, publicValues, proof); + ISP1Verifier(verifier).verifyProof( + heliosProgramVkey, + publicValues, + proof + ); // Check that the new header hasnt been set already. - head = po.newHead; - if (headers[po.newHead] != bytes32(0)) { - revert HeaderRootAlreadySet(po.newHead); + if ( + headers[po.newHead] != bytes32(0) && + headers[po.newHead] != po.newHeader + ) { + revert InvalidHeaderRoot(po.newHead); + } + // Set new header. + headers[po.newHead] = po.newHeader; + if (head < po.newHead) { + head = po.newHead; } // Check that the new state root hasnt been set already. - headers[po.newHead] = po.newHeader; - if (executionStateRoots[po.newHead] != bytes32(0)) { - revert StateRootAlreadySet(po.newHead); + if ( + executionStateRoots[po.newHead] != bytes32(0) && + executionStateRoots[po.newHead] != po.executionStateRoot + ) { + revert InvalidStateRoot(po.newHead); } // Finally set the new state root. executionStateRoots[po.newHead] = po.executionStateRoot; emit HeadUpdate(po.newHead, po.newHeader); - uint256 period = getSyncCommitteePeriod(head); + // Store all provided storage slot values + for (uint256 i = 0; i < po.slots.length; i++) { + StorageSlot memory slot = po.slots[i]; + bytes32 storageKey = computeStorageKey( + po.newHead, + slot.contractAddress, + slot.key + ); + storageValues[storageKey] = slot.value; + emit StorageSlotVerified( + po.newHead, + slot.key, + slot.value, + slot.contractAddress + ); + } + + uint256 period = getSyncCommitteePeriod(po.newHead); // If the sync committee for the new peroid is not set, set it. // This can happen if the light client was very behind and had a lot of updates @@ -158,7 +230,9 @@ contract SP1Helios { } /// @notice Gets the sync committee period from a slot. - function getSyncCommitteePeriod(uint256 slot) public view returns (uint256) { + function getSyncCommitteePeriod( + uint256 slot + ) public view returns (uint256) { return slot / SLOTS_PER_PERIOD; } @@ -171,4 +245,35 @@ contract SP1Helios { function updateHeliosProgramVkey(bytes32 newVkey) external onlyGuardian { heliosProgramVkey = newVkey; } + + /// @notice Gets the timestamp of a slot + function slotTimestamp(uint256 slot) public view returns (uint256) { + return GENESIS_TIME + slot * SECONDS_PER_SLOT; + } + + /// @notice Gets the timestamp of the latest head + function headTimestamp() public view returns (uint256) { + return slotTimestamp(head); + } + + /// @notice Computes the key for a contract's storage slot + function computeStorageKey( + uint256 blockNumber, + address contractAddress, + bytes32 slot + ) public pure returns (bytes32) { + return keccak256(abi.encodePacked(blockNumber, contractAddress, slot)); + } + + /// @notice Gets the value of a storage slot at a specific block + function getStorageSlot( + uint256 blockNumber, + address contractAddress, + bytes32 slot + ) external view returns (bytes32) { + return + storageValues[ + computeStorageKey(blockNumber, contractAddress, slot) + ]; + } } diff --git a/elf/sp1-helios-elf b/elf/sp1-helios-elf index 2731c620..bd049744 100755 Binary files a/elf/sp1-helios-elf and b/elf/sp1-helios-elf differ diff --git a/primitives/Cargo.toml b/primitives/Cargo.toml index 12650535..bb687627 100644 --- a/primitives/Cargo.toml +++ b/primitives/Cargo.toml @@ -10,3 +10,4 @@ serde = { workspace = true } helios-consensus-core = { workspace = true } alloy-sol-types = { workspace = true } alloy-primitives = { workspace = true } +alloy-trie = { workspace = true } diff --git a/primitives/src/types.rs b/primitives/src/types.rs index c3463d4a..0c631cb2 100644 --- a/primitives/src/types.rs +++ b/primitives/src/types.rs @@ -1,10 +1,26 @@ -use alloy_primitives::B256; +use alloy_primitives::{Address, Bytes, B256}; use alloy_sol_types::sol; +use alloy_trie::TrieAccount; use helios_consensus_core::consensus_spec::MainnetConsensusSpec; use helios_consensus_core::types::Forks; use helios_consensus_core::types::{FinalityUpdate, LightClientStore, Update}; use serde::{Deserialize, Serialize}; +#[derive(Serialize, Deserialize, Debug)] +pub struct StorageSlot { + pub key: B256, // raw 32 byte storage slot key e.g. for slot 0: 0x000...00 + pub expected_value: B256, // raw `keccak256(abi.encode(target, data));` that we store in `HubPoolStore.sol` + pub mpt_proof: Vec, // contract-specific MPT proof +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ContractStorage { + pub address: Address, + pub expected_value: TrieAccount, + pub mpt_proof: Vec, // global MPT proof + pub storage_slots: Vec, +} + #[derive(Serialize, Deserialize, Debug)] pub struct ProofInputs { pub sync_committee_updates: Vec>, @@ -13,6 +29,7 @@ pub struct ProofInputs { pub store: LightClientStore, pub genesis_root: B256, pub forks: Forks, + pub contract_storage_slots: ContractStorage, } #[derive(serde::Serialize, serde::Deserialize, Debug)] @@ -25,6 +42,12 @@ pub struct ExecutionStateProof { } sol! { + struct VerifiedStorageSlot { + bytes32 key; + bytes32 value; + address contractAddress; + } + struct ProofOutputs { bytes32 executionStateRoot; bytes32 newHeader; @@ -34,5 +57,6 @@ sol! { uint256 prevHead; bytes32 syncCommitteeHash; bytes32 startSyncCommitteeHash; + VerifiedStorageSlot[] slots; } } diff --git a/program/Cargo.toml b/program/Cargo.toml index 6eefcc7a..a8dca1e3 100644 --- a/program/Cargo.toml +++ b/program/Cargo.toml @@ -13,3 +13,6 @@ sp1-helios-primitives = { workspace = true } alloy-sol-types = { workspace = true } alloy-primitives = { workspace = true, features = ["sha3-keccak"] } tree_hash = { workspace = true } +alloy-trie = { workspace = true } +alloy-rlp = { workspace = true } +hex = "0.4.3" diff --git a/program/src/main.rs b/program/src/main.rs index a3a20263..17b44b82 100644 --- a/program/src/main.rs +++ b/program/src/main.rs @@ -1,20 +1,25 @@ #![no_main] sp1_zkvm::entrypoint!(main); -use alloy_primitives::{B256, U256}; +use alloy_primitives::{keccak256, Bytes, FixedBytes, B256, U256}; +use alloy_rlp::Encodable; use alloy_sol_types::SolValue; +use alloy_trie::{proof, Nibbles}; use helios_consensus_core::{ apply_finality_update, apply_update, verify_finality_update, verify_update, }; -use sp1_helios_primitives::types::{ProofInputs, ProofOutputs}; +use sp1_helios_primitives::types::{ + ContractStorage, ProofInputs, ProofOutputs, VerifiedStorageSlot, +}; use tree_hash::TreeHash; /// Program flow: /// 1. Apply sync committee updates, if any /// 2. Apply finality update /// 3. Verify execution state root proof -/// 4. Asset all updates are valid -/// 5. Commit new state root, header, and sync committee for usage in the on-chain contract +/// 4. Verify storage slot proofs +/// 5. Asset all updates are valid +/// 6. Commit new state root, header, and sync committee for usage in the on-chain contract pub fn main() { let encoded_inputs = sp1_zkvm::io::read_vec(); @@ -25,6 +30,7 @@ pub fn main() { mut store, genesis_root, forks, + contract_storage_slots, } = serde_cbor::from_slice(&encoded_inputs).unwrap(); let start_sync_committee_hash = store.current_sync_committee.tree_hash_root(); @@ -64,7 +70,16 @@ pub fn main() { apply_finality_update(&mut store, &finality_update); - // 3. Commit new state root, header, and sync committee for usage in the on-chain contract + // 3. Verify storage slot proofs + let execution_state_root = *store + .finalized_header + .execution() + .expect("Execution payload doesn't exist.") + .state_root(); + + let verified_slots = verify_storage_slot_proofs(execution_state_root, contract_storage_slots); + + // 4. Commit new state root, header, and sync committee for usage in the on-chain contract let header: B256 = store.finalized_header.beacon().tree_hash_root(); let sync_committee_hash: B256 = store.current_sync_committee.tree_hash_root(); let next_sync_committee_hash: B256 = match &mut store.next_sync_committee { @@ -74,11 +89,7 @@ pub fn main() { let head = store.finalized_header.beacon().slot; let proof_outputs = ProofOutputs { - executionStateRoot: *store - .finalized_header - .execution() - .expect("Execution payload doesn't exist.") - .state_root(), + executionStateRoot: execution_state_root, newHeader: header, nextSyncCommitteeHash: next_sync_committee_hash, newHead: U256::from(head), @@ -86,6 +97,73 @@ pub fn main() { prevHead: U256::from(prev_head), syncCommitteeHash: sync_committee_hash, startSyncCommitteeHash: start_sync_committee_hash, + slots: verified_slots, }; sp1_zkvm::io::commit_slice(&proof_outputs.abi_encode()); } + +fn verify_storage_slot_proofs( + execution_state_root: FixedBytes<32>, + contract_storage: ContractStorage, +) -> Vec { + // Convert the contract address into nibbles for the global MPT proof + // We need to keccak256 the address before converting to nibbles for the MPT proof + let address_hash = keccak256(contract_storage.address.as_slice()); + let address_nibbles = Nibbles::unpack(Bytes::copy_from_slice(address_hash.as_ref())); + // RLP-encode the `TrieAccount`. This is what's actually stored in the global MPT + let mut rlp_encoded_trie_account = Vec::new(); + contract_storage + .expected_value + .encode(&mut rlp_encoded_trie_account); + + // 1) Verify the contract's account node in the *global* MPT: + // We expect to find 'contract_trie_value_bytes' as the 'value' for this address. + if let Err(e) = proof::verify_proof( + execution_state_root, + address_nibbles, + Some(rlp_encoded_trie_account.clone()), + &contract_storage.mpt_proof, + ) { + panic!( + "Could not verify the contract's `TrieAccount` in the global MPT for address {}: {}", + hex::encode(contract_storage.address), + e + ); + } + + // 2) Now that we've verified the contract's `TrieAccount`, use it to verify each storage slot proof + let mut verified_slots = Vec::with_capacity(contract_storage.storage_slots.len()); + for slot in contract_storage.storage_slots { + let key = slot.key; + let value = slot.expected_value; + // We need to keccak256 the slot key before converting to nibbles for the MPT proof + let key_hash = keccak256(key.as_slice()); + let key_nibbles = Nibbles::unpack(Bytes::copy_from_slice(key_hash.as_ref())); + // RLP-encode expected value. This is what's actually stored in the contract MPT + let mut rlp_encoded_value = Vec::new(); + value.encode(&mut rlp_encoded_value); + + // Verify the storage proof under the *contract's* storage root + if let Err(e) = proof::verify_proof( + contract_storage.expected_value.storage_root, + key_nibbles, + Some(rlp_encoded_value), + &slot + .mpt_proof + .iter() + .map(|b| Bytes::copy_from_slice(b.as_ref())) + .collect::>(), + ) { + panic!("Storage proof invalid for slot {}: {}", hex::encode(key), e); + } + + verified_slots.push(VerifiedStorageSlot { + key, + value, + contractAddress: contract_storage.address, + }); + println!("Verified storage slot: {}", hex::encode(key)); + } + + verified_slots +} diff --git a/script/Cargo.toml b/script/Cargo.toml index 5010a6e7..f1eb9aae 100644 --- a/script/Cargo.toml +++ b/script/Cargo.toml @@ -40,6 +40,7 @@ cargo_metadata = { workspace = true } reqwest = { workspace = true } tree_hash = { workspace = true } serde_json = { workspace = true } +alloy-trie = { workspace = true } [build-dependencies] sp1-build = { workspace = true } diff --git a/script/bin/operator.rs b/script/bin/operator.rs index e9c5c0b3..89530030 100644 --- a/script/bin/operator.rs +++ b/script/bin/operator.rs @@ -11,7 +11,7 @@ use helios_ethereum::rpc::http_rpc::HttpRpc; use helios_ethereum::rpc::ConsensusRpc; use log::{error, info}; use reqwest::Url; -use sp1_helios_primitives::types::ProofInputs; +use sp1_helios_primitives::types::{ContractStorage, ProofInputs}; use sp1_helios_script::*; use sp1_sdk::{EnvProver, ProverClient, SP1ProofWithPublicValues, SP1ProvingKey, SP1Stdin}; use std::env; @@ -42,9 +42,16 @@ sol! { mapping(uint256 => bytes32) public syncCommittees; mapping(uint256 => bytes32) public executionStateRoots; mapping(uint256 => bytes32) public headers; + mapping(bytes32 => bytes32) public storageValues; bytes32 public heliosProgramVkey; address public verifier; + struct StorageSlot { + bytes32 key; + bytes32 value; + address contractAddress; + } + struct ProofOutputs { bytes32 executionStateRoot; bytes32 newHeader; @@ -53,15 +60,20 @@ sol! { bytes32 prevHeader; uint256 prevHead; bytes32 syncCommitteeHash; + bytes32 startSyncCommitteeHash; + StorageSlot[] slots; } event HeadUpdate(uint256 indexed slot, bytes32 indexed root); event SyncCommitteeUpdate(uint256 indexed period, bytes32 indexed root); + event StorageSlotVerified(uint256 indexed slot, bytes32 indexed key, bytes32 value, address contractAddress); - function update(bytes calldata proof, bytes calldata publicValues) external; + function update(bytes calldata proof, bytes calldata publicValues, uint256 head) external; function getSyncCommitteePeriod(uint256 slot) internal view returns (uint256); function getCurrentSlot() internal view returns (uint256); function getCurrentEpoch() internal view returns (uint256); + function computeStorageKey(uint256 blockNumber, address contractAddress, bytes32 slot) public pure returns (bytes32); + function getStorageSlot(uint256 blockNumber, address contractAddress, bytes32 slot) external view returns (bytes32); } } @@ -169,6 +181,12 @@ impl SP1HeliosOperator { store: client.store.clone(), genesis_root: client.config.chain.genesis_root, forks: client.config.forks.clone(), + contract_storage_slots: ContractStorage { + address: todo!(), + expected_value: todo!(), + mpt_proof: todo!(), + storage_slots: todo!(), + }, }; let encoded_proof_inputs = serde_cbor::to_vec(&inputs)?; stdin.write_slice(&encoded_proof_inputs); @@ -181,7 +199,7 @@ impl SP1HeliosOperator { } /// Relay an update proof to the SP1 Helios contract. - async fn relay_update(&self, proof: SP1ProofWithPublicValues) -> Result<()> { + async fn relay_update(&self, proof: SP1ProofWithPublicValues, head: u64) -> Result<()> { let public_values_bytes = proof.public_values.to_vec(); let wallet_filler = ProviderBuilder::new() @@ -198,7 +216,11 @@ impl SP1HeliosOperator { const NUM_CONFIRMATIONS: u64 = 3; const TIMEOUT_SECONDS: u64 = 60; let receipt = contract - .update(proof.bytes().into(), public_values_bytes.into()) + .update( + proof.bytes().into(), + public_values_bytes.into(), + head.try_into().unwrap(), + ) .nonce(nonce) .send() .await? @@ -250,7 +272,7 @@ impl SP1HeliosOperator { // Request an update match self.request_update(client).await { Ok(Some(proof)) => { - self.relay_update(proof).await?; + self.relay_update(proof, slot).await?; } Ok(None) => { // Contract is up to date. Nothing to update. diff --git a/script/bin/test.rs b/script/bin/test.rs index 6e71c95f..67865439 100644 --- a/script/bin/test.rs +++ b/script/bin/test.rs @@ -1,9 +1,16 @@ +use alloy::{ + eips::BlockId, + providers::{Provider, ProviderBuilder}, +}; +use alloy_primitives::{address, b256, hex, Bytes, B256}; +use alloy_trie::TrieAccount; use anyhow::Result; use clap::{command, Parser}; use helios_ethereum::rpc::ConsensusRpc; -use sp1_helios_primitives::types::ProofInputs; +use sp1_helios_primitives::types::{ContractStorage, ProofInputs, StorageSlot}; use sp1_helios_script::{get_checkpoint, get_client, get_latest_checkpoint, get_updates}; use sp1_sdk::{utils::setup_logger, ProverClient, SP1Stdin}; +use std::str::FromStr; const ELF: &[u8] = include_bytes!("../../elf/sp1-helios-elf"); #[derive(Parser, Debug, Clone)] @@ -32,19 +39,65 @@ async fn main() -> Result<()> { let finality_update = helios_client.rpc.get_finality_update().await.unwrap(); let expected_current_slot = helios_client.expected_current_slot(); + + // Get the block number for the current slot + let block_number = helios_client + .store + .finalized_header + .execution() + .unwrap() + .block_number(); + + println!( + "slot {}", + helios_client.store.finalized_header.beacon().slot + ); + println!("block number {}", block_number); + + // Expected values for the proof (mainnet Across SpokePool->crossDomainAdmin()). + let contract_address = address!("0xcf340c078e4909f1796b28a78f35db9d60842cfb"); + let storage_slot = b256!("0000000000000000000000000000000000000000000000000000000000000869"); + let expected_value = b256!("c186fA914353c44b2E33eBE05f21846F1048bEda000000000000000000000000"); + + // Setup execution RPC client + let execution_rpc = std::env::var("SOURCE_EXECUTION_RPC_URL").unwrap(); + let provider = ProviderBuilder::new().on_http(execution_rpc.parse()?); + + // Get the proof using eth_getProof + let proof = provider + .get_proof(contract_address, vec![storage_slot]) + .block_id(BlockId::number(*block_number)) + .await?; + let inputs = ProofInputs { sync_committee_updates, finality_update, expected_current_slot, - store: helios_client.store.clone(), + store: helios_client.store, genesis_root: helios_client.config.chain.genesis_root, forks: helios_client.config.forks.clone(), + contract_storage_slots: ContractStorage { + address: contract_address, + expected_value: TrieAccount { + nonce: proof.nonce, + balance: proof.balance, + storage_root: proof.storage_hash, + code_hash: proof.code_hash, + }, + mpt_proof: proof.account_proof, + storage_slots: vec![StorageSlot { + key: storage_slot, + expected_value, + mpt_proof: proof.storage_proof[0].proof.clone(), + }], + }, }; // Write the inputs to the VM let mut stdin = SP1Stdin::new(); stdin.write_slice(&serde_cbor::to_vec(&inputs)?); + // Configure a ProverClient for testing let prover_client = ProverClient::builder().cpu().build(); let (_, report) = prover_client.execute(ELF, &stdin).run()?; println!("Execution Report: {:?}", report); diff --git a/script/build.rs b/script/build.rs index c349e688..58251275 100644 --- a/script/build.rs +++ b/script/build.rs @@ -2,13 +2,13 @@ use sp1_build::{build_program_with_args, BuildArgs}; fn main() { - // build_program_with_args( - // "../program", - // BuildArgs { - // docker: true, - // elf_name: Some("sp1-helios-elf".to_string()), - // output_directory: Some("../elf".to_string()), - // ..Default::default() - // }, - //); + build_program_with_args( + "../program", + BuildArgs { + docker: true, + elf_name: Some("sp1-helios-elf".to_string()), + output_directory: Some("../elf".to_string()), + ..Default::default() + }, + ); } diff --git a/test/Cargo.toml b/test/Cargo.toml new file mode 100644 index 00000000..e9827dd4 --- /dev/null +++ b/test/Cargo.toml @@ -0,0 +1,31 @@ +[package] +version = "0.1.0" +name = "sp1-helios-test" +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +dotenv = { workspace = true } +sp1-sdk = { workspace = true } +tokio = { workspace = true } +helios-consensus-core = { workspace = true } +helios-ethereum = { workspace = true } +sp1-helios-primitives = { workspace = true } +serde = { workspace = true } +serde_cbor = { workspace = true } +clap = { workspace = true } +log = { workspace = true } +env_logger = { workspace = true } +alloy-primitives = { workspace = true } +alloy = { workspace = true } +anyhow = { workspace = true } +cargo_metadata = { workspace = true } +reqwest = { workspace = true } +tree_hash = { workspace = true } +serde_json = { workspace = true } +alloy-trie = { workspace = true } +alloy-rlp = { workspace = true } + +# [build-dependencies] +# sp1-build = { workspace = true } diff --git a/test/src/main.rs b/test/src/main.rs new file mode 100644 index 00000000..2f7d9543 --- /dev/null +++ b/test/src/main.rs @@ -0,0 +1,143 @@ +use alloy::{ + eips::BlockId, + providers::{Provider, ProviderBuilder}, + rpc::types::BlockTransactionsKind, +}; +use alloy_primitives::{address, keccak256, Address, Bytes, B256}; +use alloy_rlp::Encodable; +use alloy_trie::{self, Nibbles, TrieAccount}; +use anyhow::{Context, Result}; +use std::env; + +#[tokio::main] +async fn main() -> Result<()> { + dotenv::dotenv().ok(); + + // Convert u64 to storage slot representations needed for EVM interactions + fn storage_slot_from_u64(slot: u64) -> (alloy_primitives::U256, B256) { + let u256_slot = alloy_primitives::U256::from(slot); + + // Create right-aligned 32-byte representation for B256 + let mut b256_bytes = [0u8; 32]; + let slot_bytes = slot.to_be_bytes(); + b256_bytes[32 - slot_bytes.len()..].copy_from_slice(&slot_bytes); + + (u256_slot, B256::from(b256_bytes)) + } + + // Testing HubPool->owner() which is at storage slot 1 + // Expected owner: 0xB524735356985D2f267FA010D681f061DfF03715 + let contract_address = address!("0xc186fA914353c44b2E33eBE05f21846F1048bEda"); + let expected_owner = address!("0xB524735356985D2f267FA010D681f061DfF03715"); + let slot_index = 1u64; + let (u256_slot, storage_slot) = storage_slot_from_u64(slot_index); + + println!("Storage slot index: {}", slot_index); + println!("As U256: {:?}", u256_slot); + println!("As B256: {:?}", storage_slot); + + // Setup execution RPC client + let execution_rpc = env::var("SOURCE_EXECUTION_RPC_URL") + .context("SOURCE_EXECUTION_RPC_URL environment variable not set")?; + let provider = ProviderBuilder::new().on_http(execution_rpc.parse()?); + + // Get latest block and its state root + let block = provider + .get_block(BlockId::latest(), BlockTransactionsKind::Hashes) + .await? + .ok_or_else(|| anyhow::anyhow!("Block not found"))?; + let state_root = block.header.state_root; + let block_number = block.header.number; + println!("Using block number: {}", block_number); + println!("State root: {:?}", state_root); + + // Get the proof using eth_getProof + let proof = provider + .get_proof(contract_address, vec![storage_slot]) + .block_id(BlockId::number(block_number)) + .await + .context("Failed to retrieve storage proof")?; + + println!("Retrieved proof for contract: {:?}", contract_address); + println!("Account proof nodes: {}", proof.account_proof.len()); + println!( + "Storage proof nodes: {}", + proof.storage_proof[0].proof.len() + ); + println!("Contract balance: {:?}", proof.balance); + println!("Contract state root: {:?}", proof.storage_hash); + println!("Contract code hash: {:?}", proof.code_hash); + + // STEP 1: Verify the account proof in the global state trie + // ------------------------------------------------------------- + // Prepare the address key (keccak256 hash of address) + let address_hash = keccak256(contract_address.as_slice()); + let address_nibbles = Nibbles::unpack(Bytes::copy_from_slice(address_hash.as_ref())); + + // RLP-encode the account data (this is what's stored in the state trie) + let trie_acc = TrieAccount { + nonce: proof.nonce, + balance: proof.balance, + storage_root: proof.storage_hash, + code_hash: proof.code_hash, + }; + let mut rlp_encoded_trie_account = Vec::new(); + trie_acc.encode(&mut rlp_encoded_trie_account); + + // Verify account exists in global state trie + if let Err(e) = alloy_trie::proof::verify_proof( + state_root, + address_nibbles, + Some(rlp_encoded_trie_account), + &proof.account_proof, + ) { + panic!( + "Could not verify the contract's `TrieAccount` in the global MPT for address {}: {}", + contract_address, e + ); + } + println!("VERIFIED ACCOUNT PROOF!"); + + // STEP 2: Verify the storage proof in the contract's storage trie + // ------------------------------------------------------------- + // Get the actual storage value using RPC + let slot_val = provider + .get_storage_at(contract_address, u256_slot) + .block_id(BlockId::number(block_number)) + .await?; + + // Extract address from the 32-byte storage value (addresses are right-padded) + let bts = slot_val.to_be_bytes::<32>(); + let hubpool_owner = Address::from_slice(&bts[12..]); + + // Assert that the retrieved owner equals the expected owner + assert_eq!(expected_owner, hubpool_owner, "HubPool owner mismatch"); + println!("hubpool owner: {:?}", hubpool_owner); + + // Prepare the storage key (keccak256 hash of storage slot) + let storage_key_hash = keccak256(storage_slot.as_slice()); + let storage_key_nibbles = Nibbles::unpack(Bytes::copy_from_slice(storage_key_hash.as_ref())); + + // RLP-encode the owner address for verification + let mut rlp_owner = Vec::new(); + hubpool_owner.encode(&mut rlp_owner); + + println!("Verifying storage proof for slot: {:?}", storage_slot); + println!("Expected owner: {:?}", hubpool_owner); + + // Verify storage value exists in contract's storage trie + if let Err(e) = alloy_trie::proof::verify_proof( + proof.storage_hash, + storage_key_nibbles, + Some(rlp_owner), + &proof.storage_proof[0].proof, + ) { + panic!( + "Could not verify the storage proof for slot {}: {}", + u256_slot, e + ); + } + println!("VERIFIED STORAGE PROOF!"); + + Ok(()) +}